diff --git a/.github/workflows/build-ipa.yml b/.github/workflows/build-ipa.yml index 6e27bbb1..014cf817 100644 --- a/.github/workflows/build-ipa.yml +++ b/.github/workflows/build-ipa.yml @@ -63,11 +63,12 @@ jobs: run: | brew tap FelixHerrmann/tap brew trust FelixHerrmann/tap - brew install xcodegen swiftgen swift-package-list + brew install make xcodegen swiftgen swift-package-list + echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH" - name: Generate Xcode project if: steps.version_check.outputs.should_build == 'true' || github.event_name == 'workflow_dispatch' - run: xcodegen generate + run: make generate - name: Update acknowledgements if: steps.version_check.outputs.should_build == 'true' || github.event_name == 'workflow_dispatch' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..cc11f949 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,59 @@ +name: Lint + +on: + pull_request: + branches: + - main + - dev + + push: + branches: + - main + - dev + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + linting: + name: Formatting and Linting + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install dependencies + run: command -v swiftlint >/dev/null 2>&1 || brew install swiftlint + + - name: Lint + run: swiftlint lint --strict + + - name: Format + run: swiftformat --lint --strict . + + codegen: + name: Code Generation Checks + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install dependencies + run: command -v swiftgen >/dev/null 2>&1 || brew install swiftgen + + - name: Regenerate + run: swiftgen config run --config swiftgen.yml + + - name: Verify no changes + run: | + if ! git diff --exit-code; then + echo "::error::Generated files are out of date. Run 'swiftgen config run --config swiftgen.yml' and commit the result." + exit 1 + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5396e67..fb4a7314 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,7 @@ on: branches: - main - dev + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -13,9 +14,8 @@ concurrency: jobs: spm-tests: name: SPM Package Tests - # Temporarily disabled - if: false runs-on: macos-26 + timeout-minutes: 25 steps: - name: Checkout @@ -24,14 +24,85 @@ jobs: submodules: recursive - name: Test MeshCore - run: swift test --package-path MeshCore + run: | + run_tests() { + rm -f "$RUNNER_TEMP/watchdog-fired" + swift test --package-path MeshCore --no-parallel & + TEST_PID=$! + ( + sleep 480 + touch "$RUNNER_TEMP/watchdog-fired" + echo "::warning::swift test still running after 8 minutes - sampling process tree" + for pid in $(pgrep -f "MeshCorePackageTests|swiftpm-testing-helper|swift-test" || true); do + echo "=== sample of PID $pid ===" + sample "$pid" 5 2>/dev/null || true + done + kill -9 $TEST_PID 2>/dev/null || true + ) & + WATCHDOG=$! + if wait $TEST_PID; then + kill $WATCHDOG 2>/dev/null || true + return 0 + else + STATUS=$? + kill $WATCHDOG 2>/dev/null || true + return $STATUS + fi + } + STATUS=0 + run_tests || STATUS=$? + if [ "$STATUS" -eq 0 ]; then + exit 0 + fi + if [ -f "$RUNNER_TEMP/watchdog-fired" ]; then + echo "::warning::first attempt hung - retrying once" + run_tests + else + exit "$STATUS" + fi - name: Test MC1Services - run: swift test --package-path MC1Services + run: | + run_tests() { + rm -f "$RUNNER_TEMP/watchdog-fired" + swift test --package-path MC1Services --no-parallel & + TEST_PID=$! + ( + sleep 480 + touch "$RUNNER_TEMP/watchdog-fired" + echo "::warning::swift test still running after 8 minutes - sampling process tree" + for pid in $(pgrep -f "MC1ServicesPackageTests|swiftpm-testing-helper|swift-test" || true); do + echo "=== sample of PID $pid ===" + sample "$pid" 5 2>/dev/null || true + done + kill -9 $TEST_PID 2>/dev/null || true + ) & + WATCHDOG=$! + if wait $TEST_PID; then + kill $WATCHDOG 2>/dev/null || true + return 0 + else + STATUS=$? + kill $WATCHDOG 2>/dev/null || true + return $STATUS + fi + } + STATUS=0 + run_tests || STATUS=$? + if [ "$STATUS" -eq 0 ]; then + exit 0 + fi + if [ -f "$RUNNER_TEMP/watchdog-fired" ]; then + echo "::warning::first attempt hung - retrying once" + run_tests + else + exit "$STATUS" + fi xcode-build: name: Xcode Build runs-on: macos-26 + timeout-minutes: 15 steps: - name: Checkout @@ -41,11 +112,13 @@ jobs: - name: Install dependencies run: | + brew install make + echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH" command -v xcodegen >/dev/null 2>&1 || brew install xcodegen command -v swiftgen >/dev/null 2>&1 || brew install swiftgen - name: Generate Xcode project - run: xcodegen generate + run: make generate - name: Cache Xcode build uses: irgaly/xcode-cache@v1 diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index f3742efc..87272677 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -31,8 +31,9 @@ jobs: run: | brew tap FelixHerrmann/tap brew trust FelixHerrmann/tap - brew install xcodegen swiftgen swift-package-list - gem install fastlane -v 2.236.1 + brew install make xcodegen swiftgen swift-package-list + echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH" + gem install fastlane -v 2.237.0 - name: Setup SSH for Match uses: webfactory/ssh-agent@v0.10.0 diff --git a/.gitignore b/.gitignore index 28657ace..a30c0946 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ xcuserdata/ ## Xcodegen *.xcodeproj +dev.yml ## Obj-C/Swift specific *.hmap diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 00000000..c6f8a6cf --- /dev/null +++ b/.swiftformat @@ -0,0 +1,52 @@ +# SwiftFormat configuration — mirrors the conventions already in the codebase. +# Run: swiftformat . (lint-only: swiftformat --lint .) + +--swiftversion 6.2 + +# Match .swiftlint.yml exclusions +--exclude .build,.worktrees,build,tmp,MC1.xcodeproj,MC1/Resources/Generated,MeshCore/.build,MeshCore-references,MC1Services/.build,fastlane,scripts + +# Indentation — 2 spaces, no tabs +--indent 2 +--tabwidth 2 +--smarttabs enabled +--indentcase false # switch cases align with switch (switch_case_alignment disabled in SwiftLint) + +# Line width — aligned with SwiftLint line_length warning +--maxwidth 250 + +# Imports — alphabetized within a single group +--importgrouping alpha + +# Self — codebase omits redundant self; keep only where required +--self remove + +# Braces / control flow — K&R, else & catch on the same line as the brace +--allman false +--elseposition same-line +--guardelse auto + +# Wrapping — preserve author's line breaks; only balance the closing paren +--wraparguments preserve +--wrapparameters preserve +--wrapcollections preserve +--wrapconditions preserve +--closingparen balanced + +# Spacing / misc conventions observed in the code +--voidtype void +--stripunusedargs closure-only +--commas inline # no trailing commas (trailing_comma disabled in SwiftLint) +--emptybraces no-space +--ranges no-space +--operatorfunc spaced +--nospaceoperators ..<,... + +# Rules that would introduce churn against the existing style +--disable markTypes # code does not MARK every type declaration +--disable trailingCommas # matches SwiftLint disabling trailing_comma +--disable wrapMultilineStatementBraces +--disable redundantSelf # bugged: https://github.com/nicklockwood/SwiftFormat/issues/1732 + +# Leave file headers untouched +--header ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6364b72..fa6042b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ It's usually easier to simply create a feature request GitHub issue with your id 1. **Clone the repository**. 2. **Generate the Xcode project**: ```bash - xcodegen generate + make generate # creates a gitignored dev.yml; set your Apple team ID there for local signing ``` The project is generated by XcodeGen from `project.yml`; never edit `MC1.xcodeproj` directly, as it is overwritten on the next generate. Make project configuration changes in `project.yml`. 3. **Open `MC1.xcodeproj`**. diff --git a/MC1/ContentView.swift b/MC1/ContentView.swift index e13786cb..d8339176 100644 --- a/MC1/ContentView.swift +++ b/MC1/ContentView.swift @@ -1,197 +1,199 @@ -import SwiftUI import MC1Services +import SwiftUI struct ContentView: View { - @Environment(\.appState) private var appState - @Environment(\.scenePhase) private var scenePhase - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - var body: some View { - @Bindable var connectionUI = appState.connectionUI - - Group { - if appState.onboarding.hasCompletedOnboarding { - if horizontalSizeClass == .regular { - MainSidebarView() - } else { - MainTabView() - } - } else { - OnboardingView() - } - } - .animation(.default, value: appState.onboarding.hasCompletedOnboarding) - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - appState.handleBecameActive() - } - } - .alert( - connectionUI.connectionFailedTitle ?? L10n.Localizable.Alert.ConnectionFailed.title, - isPresented: $connectionUI.showingConnectionFailedAlert - ) { - if appState.connectionUI.failedPairingDeviceID != nil { - switch appState.connectionUI.pairingFailureKind { - case .authentication: - // Auth-failure variant — bond is bad, destructive remove is the recovery - Button(L10n.Localizable.Alert.ConnectionFailed.removeAndRetry, role: .destructive) { - appState.removeFailedPairingAndRetry() - } - .accessibilityLabel(L10n.Localizable.Accessibility.Alert.ConnectionFailed.removeAndRetry) - Button(L10n.Localizable.Common.cancel, role: .cancel) { - appState.connectionUI.failedPairingDeviceID = nil - } - case .transient, .none: - // Transient variant — bond is still good, prefer non-destructive retry. - // `.none` is unreachable in practice (every pairing-failure path routes - // through `presentPairingFailure`, which always sets the kind). Folding - // it into the safer branch ensures a missing kind can't promote a working - // bond into the destructive recovery. - Button(L10n.Localizable.Common.tryAgain) { - Task { await appState.retryFailedPairingConnect() } - } - Button(L10n.Localizable.Alert.ConnectionFailed.removeAndRetry, role: .destructive) { - appState.removeFailedPairingAndRetry() - } - .accessibilityLabel(L10n.Localizable.Accessibility.Alert.ConnectionFailed.removeAndRetry) - Button(L10n.Localizable.Common.cancel, role: .cancel) { - appState.connectionUI.failedPairingDeviceID = nil - } - } - } else { - Button(L10n.Localizable.Common.ok, role: .cancel) { } - } - } message: { - Text(appState.connectionUI.connectionFailedMessage ?? L10n.Localizable.Alert.ConnectionFailed.defaultMessage) + @Environment(\.appState) private var appState + @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + @Bindable var connectionUI = appState.connectionUI + + Group { + if appState.onboarding.hasCompletedOnboarding { + if horizontalSizeClass == .regular { + MainSidebarView() + } else { + MainTabView() } - .alert( - L10n.Localizable.Alert.CouldNotConnect.title, - isPresented: Binding( - get: { appState.connectionUI.otherAppWarningDeviceID != nil }, - set: { if !$0 { appState.connectionUI.otherAppWarningDeviceID = nil } } - ) - ) { - Button(L10n.Localizable.Common.ok) { - appState.connectionUI.otherAppWarningDeviceID = nil - } - } message: { - Text(L10n.Localizable.Alert.CouldNotConnect.otherAppMessage) - } - // macOS "Designed for iPad" device picker. `bluetoothScanPicker` is nil on iOS, where - // AccessorySetupKit presents its own system picker, so this sheet never appears there. - .sheet(isPresented: Binding( - get: { appState.connectionManager.bluetoothScanPicker?.isPresenting ?? false }, - set: { if !$0 { appState.connectionManager.bluetoothScanPicker?.cancel() } } - )) { - if let scanPicker = appState.connectionManager.bluetoothScanPicker { - DeviceScannerSheet(picker: scanPicker) - } - } - // SwiftUI does not reliably co-present a sheet and an alert from the same host, - // so the binding yields a release only while the connection UI above is quiescent; - // `pendingRelease` stays set and re-presents on the next render once any alert clears. - .sheet(item: Binding( - get: { connectionUIQuiescent ? appState.whatsNew.pendingRelease : nil }, - set: { if $0 == nil { appState.whatsNew.markShown() } } - )) { release in - WhatsNewSheet(release: release) + } else { + OnboardingView() + } + } + .animation(.default, value: appState.onboarding.hasCompletedOnboarding) + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + appState.handleBecameActive() + } + } + .alert( + connectionUI.connectionFailedTitle ?? L10n.Localizable.Alert.ConnectionFailed.title, + isPresented: $connectionUI.showingConnectionFailedAlert + ) { + if appState.connectionUI.failedPairingDeviceID != nil { + switch appState.connectionUI.pairingFailureKind { + case .authentication, .pinRejected: + // Bond can't proceed (a dead saved bond or a rejected fresh PIN), so + // destructive remove is the recovery. The two kinds share these + // buttons and differ only in the message copy set by the presenter. + Button(L10n.Localizable.Alert.ConnectionFailed.removeAndRetry, role: .destructive) { + appState.removeFailedPairingAndRetry() + } + .accessibilityLabel(L10n.Localizable.Accessibility.Alert.ConnectionFailed.removeAndRetry) + Button(L10n.Localizable.Common.cancel, role: .cancel) { + appState.connectionUI.failedPairingDeviceID = nil + } + case .transient, .none: + // Transient variant — bond is still good, prefer non-destructive retry. + // `.none` is unreachable in practice (every pairing-failure path routes + // through `presentPairingFailure`, which always sets the kind). Folding + // it into the safer branch ensures a missing kind can't promote a working + // bond into the destructive recovery. + Button(L10n.Localizable.Common.tryAgain) { + Task { await appState.retryFailedPairingConnect() } + } + Button(L10n.Localizable.Alert.ConnectionFailed.removeAndRetry, role: .destructive) { + appState.removeFailedPairingAndRetry() + } + .accessibilityLabel(L10n.Localizable.Accessibility.Alert.ConnectionFailed.removeAndRetry) + Button(L10n.Localizable.Common.cancel, role: .cancel) { + appState.connectionUI.failedPairingDeviceID = nil + } } + } else { + Button(L10n.Localizable.Common.ok, role: .cancel) {} + } + } message: { + Text(appState.connectionUI.connectionFailedMessage ?? L10n.Localizable.Alert.ConnectionFailed.defaultMessage) } - - /// True when no connection alert or scan picker from this host is presenting. - private var connectionUIQuiescent: Bool { - !appState.connectionUI.showingConnectionFailedAlert - && appState.connectionUI.otherAppWarningDeviceID == nil - && !(appState.connectionManager.bluetoothScanPicker?.isPresenting ?? false) + .alert( + L10n.Localizable.Alert.CouldNotConnect.title, + isPresented: Binding( + get: { appState.connectionUI.otherAppWarningDeviceID != nil }, + set: { if !$0 { appState.connectionUI.otherAppWarningDeviceID = nil } } + ) + ) { + Button(L10n.Localizable.Common.ok) { + appState.connectionUI.otherAppWarningDeviceID = nil + } + } message: { + Text(L10n.Localizable.Alert.CouldNotConnect.otherAppMessage) + } + // macOS "Designed for iPad" device picker. `bluetoothScanPicker` is nil on iOS, where + // AccessorySetupKit presents its own system picker, so this sheet never appears there. + .sheet(isPresented: Binding( + get: { appState.connectionManager.bluetoothScanPicker?.isPresenting ?? false }, + set: { if !$0 { appState.connectionManager.bluetoothScanPicker?.cancel() } } + )) { + if let scanPicker = appState.connectionManager.bluetoothScanPicker { + DeviceScannerSheet(picker: scanPicker) + } } + // SwiftUI does not reliably co-present a sheet and an alert from the same host, + // so the binding yields a release only while the connection UI above is quiescent; + // `pendingRelease` stays set and re-presents on the next render once any alert clears. + .sheet(item: Binding( + get: { connectionUIQuiescent ? appState.whatsNew.pendingRelease : nil }, + set: { if $0 == nil { appState.whatsNew.markShown() } } + )) { release in + WhatsNewSheet(release: release) + } + } + + /// True when no connection alert or scan picker from this host is presenting. + private var connectionUIQuiescent: Bool { + !appState.connectionUI.showingConnectionFailedAlert + && appState.connectionUI.otherAppWarningDeviceID == nil + && !(appState.connectionManager.bluetoothScanPicker?.isPresenting ?? false) + } } // MARK: - Onboarding View struct OnboardingView: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - var body: some View { - @Bindable var onboarding = appState.onboarding + var body: some View { + @Bindable var onboarding = appState.onboarding - NavigationStack(path: $onboarding.onboardingPath) { + NavigationStack(path: $onboarding.onboardingPath) { + WelcomeView() + .navigationDestination(for: OnboardingStep.self) { step in + switch step { + case .welcome: WelcomeView() - .navigationDestination(for: OnboardingStep.self) { step in - switch step { - case .welcome: - WelcomeView() - case .permissions: - PermissionsView() - case .pair: - DeviceScanView() - case .region: - RegionStepView() - case .preset: - PresetStepView() - } - } + case .permissions: + PermissionsView() + case .pair: + DeviceScanView() + case .region: + RegionStepView() + case .preset: + PresetStepView() + } } } + } } // MARK: - Main Tab View struct MainTabView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingDeviceSelection = false - - var body: some View { - @Bindable var navigation = appState.navigation - - TabView(selection: $navigation.selectedTab) { - Tab(L10n.Localizable.Tabs.chats, systemImage: "message.fill", value: AppTab.chats.rawValue) { - ChatsView() - } - .badge(appState.services?.notificationService.badgeCount ?? 0) - - Tab(L10n.Localizable.Tabs.nodes, systemImage: "flipphone", value: AppTab.nodes.rawValue) { - ContactsListView() - } - - Tab(L10n.Localizable.Tabs.map, systemImage: "map.fill", value: AppTab.map.rawValue) { - MapView() - } - - Tab(L10n.Localizable.Tabs.tools, systemImage: "wrench.and.screwdriver", value: AppTab.tools.rawValue) { - ToolsView() - } - - Tab(L10n.Localizable.Tabs.settings, systemImage: "gear", value: AppTab.settings.rawValue) { - SettingsView() - } - } - .themedChrome(theme) - .syncingPillOverlay(onDisconnectedTap: { showingDeviceSelection = true }) - .onChange(of: appState.navigation.selectedTab) { _, _ in - // Donate pending device menu tip when returning to a valid tab - if appState.navigation.pendingDeviceMenuTipDonation && appState.navigation.isOnValidTabForDeviceMenuTip { - Task { - await appState.donateDeviceMenuTipIfOnValidTab() - } - } - } - .sheet(isPresented: $showingDeviceSelection) { - DeviceSelectionSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingDeviceSelection = false + + var body: some View { + @Bindable var navigation = appState.navigation + + TabView(selection: $navigation.selectedTab) { + Tab(L10n.Localizable.Tabs.chats, systemImage: "message.fill", value: AppTab.chats.rawValue) { + ChatsView() + } + .badge(appState.services?.notificationService.badgeCount ?? 0) + + Tab(L10n.Localizable.Tabs.nodes, systemImage: "flipphone", value: AppTab.nodes.rawValue) { + ContactsListView() + } + + Tab(L10n.Localizable.Tabs.map, systemImage: "map.fill", value: AppTab.map.rawValue) { + MapView() + } + + Tab(L10n.Localizable.Tabs.tools, systemImage: "wrench.and.screwdriver", value: AppTab.tools.rawValue) { + ToolsView() + } + + Tab(L10n.Localizable.Tabs.settings, systemImage: "gear", value: AppTab.settings.rawValue) { + SettingsView() + } + } + .themedChrome(theme) + .syncingPillOverlay(onDisconnectedTap: { showingDeviceSelection = true }) + .onChange(of: appState.navigation.selectedTab) { _, _ in + // Donate pending device menu tip when returning to a valid tab + if appState.navigation.pendingDeviceMenuTipDonation, appState.navigation.isOnValidTabForDeviceMenuTip { + Task { + await appState.donateDeviceMenuTipIfOnValidTab() } + } + } + .sheet(isPresented: $showingDeviceSelection) { + DeviceSelectionSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) } + } } #Preview("Content View - Onboarding") { - ContentView() - .environment(\.appState, AppState()) + ContentView() + .environment(\.appState, AppState()) } #Preview("Content View - Main App") { - let appState = AppState() - appState.onboarding.hasCompletedOnboarding = true - return ContentView() - .environment(\.appState, appState) + let appState = AppState() + appState.onboarding.hasCompletedOnboarding = true + return ContentView() + .environment(\.appState, appState) } diff --git a/MC1/Extensions/BatteryInfo+Display.swift b/MC1/Extensions/BatteryInfo+Display.swift index e366bd16..0526c366 100644 --- a/MC1/Extensions/BatteryInfo+Display.swift +++ b/MC1/Extensions/BatteryInfo+Display.swift @@ -5,89 +5,91 @@ import SwiftUI /// Consolidates LiPo voltage-to-percentage calculation previously duplicated in /// BLEStatusIndicatorView and DeviceInfoView. extension BatteryInfo { - /// Whether this reading represents a real battery. - /// 0mV indicates no battery hardware (e.g., mains-powered device with no ADC pin). - var isBatteryPresent: Bool { level > 0 } + /// Whether this reading represents a real battery. + /// 0mV indicates no battery hardware (e.g., mains-powered device with no ADC pin). + var isBatteryPresent: Bool { + level > 0 + } - /// Battery voltage in volts (converted from millivolts) - var voltage: Double { - Double(level) / 1000.0 - } - - /// Estimated percentage based on LiPo curve (4.2V = 100%, 3.0V = 0%) - var percentage: Int { - let percent = ((voltage - 3.0) / 1.2) * 100 - return Int(min(100, max(0, percent))) - } + /// Battery voltage in volts (converted from millivolts) + var voltage: Double { + Double(level) / 1000.0 + } - /// Calculate percentage using OCV array lookup with linear interpolation. - /// The OCV array should have 11 values mapping to 100%, 90%, 80%... 0%. - func percentage(using ocvArray: [Int]) -> Int { - guard ocvArray.count == 11 else { return percentage } // Fallback to linear + /// Estimated percentage based on LiPo curve (4.2V = 100%, 3.0V = 0%) + var percentage: Int { + let percent = ((voltage - 3.0) / 1.2) * 100 + return Int(min(100, max(0, percent))) + } - let millivolts = level + /// Calculate percentage using OCV array lookup with linear interpolation. + /// The OCV array should have 11 values mapping to 100%, 90%, 80%... 0%. + func percentage(using ocvArray: [Int]) -> Int { + guard ocvArray.count == 11 else { return percentage } // Fallback to linear - // Above max voltage = 100% - if millivolts >= ocvArray[0] { - return 100 - } + let millivolts = level - // Below min voltage = 0% - if millivolts <= ocvArray[10] { - return 0 - } + // Above max voltage = 100% + if millivolts >= ocvArray[0] { + return 100 + } - // Find segment and interpolate - for index in 0..<10 { - let upperV = ocvArray[index] - let lowerV = ocvArray[index + 1] - if millivolts >= lowerV { - let segmentPercent = Double(millivolts - lowerV) / Double(upperV - lowerV) - let basePercent = (10 - index - 1) * 10 // 90, 80, 70, ... - return basePercent + Int((segmentPercent * 10).rounded()) - } - } + // Below min voltage = 0% + if millivolts <= ocvArray[10] { + return 0 + } - return 0 + // Find segment and interpolate + for index in 0..<10 { + let upperV = ocvArray[index] + let lowerV = ocvArray[index + 1] + if millivolts >= lowerV { + let segmentPercent = Double(millivolts - lowerV) / Double(upperV - lowerV) + let basePercent = (10 - index - 1) * 10 // 90, 80, 70, ... + return basePercent + Int((segmentPercent * 10).rounded()) + } } - /// SF Symbol name for battery level - var iconName: String { - switch percentage { - case 88...100: "battery.100" - case 63..<88: "battery.75" - case 38..<63: "battery.50" - case 13..<38: "battery.25" - default: "battery.0" - } + return 0 + } + + /// SF Symbol name for battery level + var iconName: String { + switch percentage { + case 88...100: "battery.100" + case 63..<88: "battery.75" + case 38..<63: "battery.50" + case 13..<38: "battery.25" + default: "battery.0" } + } - /// SF Symbol name for battery level using OCV array - func iconName(using ocvArray: [Int]) -> String { - switch percentage(using: ocvArray) { - case 88...100: "battery.100" - case 63..<88: "battery.75" - case 38..<63: "battery.50" - case 13..<38: "battery.25" - default: "battery.0" - } + /// SF Symbol name for battery level using OCV array + func iconName(using ocvArray: [Int]) -> String { + switch percentage(using: ocvArray) { + case 88...100: "battery.100" + case 63..<88: "battery.75" + case 38..<63: "battery.50" + case 13..<38: "battery.25" + default: "battery.0" } + } - /// Color for battery display based on level - var levelColor: Color { - switch percentage { - case 20...100: .primary - case 10..<20: .orange - default: .red - } + /// Color for battery display based on level + var levelColor: Color { + switch percentage { + case 20...100: .primary + case 10..<20: .orange + default: .red } + } - /// Color for battery display based on OCV level - func levelColor(using ocvArray: [Int]) -> Color { - switch percentage(using: ocvArray) { - case 20...100: .primary - case 10..<20: .orange - default: .red - } + /// Color for battery display based on OCV level + func levelColor(using ocvArray: [Int]) -> Color { + switch percentage(using: ocvArray) { + case 20...100: .primary + case 10..<20: .orange + default: .red } + } } diff --git a/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift index a5fc25aa..ee3aa43e 100644 --- a/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift +++ b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift @@ -2,51 +2,51 @@ import CoreLocation import MapKit import MapLibre -extension Array where Element == CLLocationCoordinate2D { - /// Computes a bounding `MKCoordinateRegion` that fits all coordinates with padding. - func boundingRegion(paddingMultiplier: Double = 1.5) -> MKCoordinateRegion? { - guard let first else { return nil } +extension [CLLocationCoordinate2D] { + /// Computes a bounding `MKCoordinateRegion` that fits all coordinates with padding. + func boundingRegion(paddingMultiplier: Double = 1.5) -> MKCoordinateRegion? { + guard let first else { return nil } - var minLat = first.latitude, maxLat = first.latitude - var minLon = first.longitude, maxLon = first.longitude + var minLat = first.latitude, maxLat = first.latitude + var minLon = first.longitude, maxLon = first.longitude - for coord in dropFirst() { - minLat = Swift.min(minLat, coord.latitude) - maxLat = Swift.max(maxLat, coord.latitude) - minLon = Swift.min(minLon, coord.longitude) - maxLon = Swift.max(maxLon, coord.longitude) - } + for coord in dropFirst() { + minLat = Swift.min(minLat, coord.latitude) + maxLat = Swift.max(maxLat, coord.latitude) + minLon = Swift.min(minLon, coord.longitude) + maxLon = Swift.max(maxLon, coord.longitude) + } - let center = CLLocationCoordinate2D( - latitude: (minLat + maxLat) / 2, - longitude: (minLon + maxLon) / 2 - ) - let rawLatDelta = Swift.max(0.01, (maxLat - minLat) * paddingMultiplier) - let rawLonDelta = Swift.max(0.01, (maxLon - minLon) * paddingMultiplier) + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + let rawLatDelta = Swift.max(0.01, (maxLat - minLat) * paddingMultiplier) + let rawLonDelta = Swift.max(0.01, (maxLon - minLon) * paddingMultiplier) - // Clamp spans so center ± span/2 stays within valid coordinate ranges - let maxLatDelta = (90 - abs(center.latitude)) * 2 - let latDelta = Swift.min(rawLatDelta, maxLatDelta) - let lonDelta = Swift.min(rawLonDelta, 360) + // Clamp spans so center ± span/2 stays within valid coordinate ranges + let maxLatDelta = (90 - abs(center.latitude)) * 2 + let latDelta = Swift.min(rawLatDelta, maxLatDelta) + let lonDelta = Swift.min(rawLonDelta, 360) - return MKCoordinateRegion( - center: center, - span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta) - ) - } + return MKCoordinateRegion( + center: center, + span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta) + ) + } } extension MKCoordinateRegion { - func toMLNCoordinateBounds() -> MLNCoordinateBounds { - MLNCoordinateBounds( - sw: CLLocationCoordinate2D( - latitude: center.latitude - span.latitudeDelta / 2, - longitude: center.longitude - span.longitudeDelta / 2 - ), - ne: CLLocationCoordinate2D( - latitude: center.latitude + span.latitudeDelta / 2, - longitude: center.longitude + span.longitudeDelta / 2 - ) - ) - } + func toMLNCoordinateBounds() -> MLNCoordinateBounds { + MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: center.latitude - span.latitudeDelta / 2, + longitude: center.longitude - span.longitudeDelta / 2 + ), + ne: CLLocationCoordinate2D( + latitude: center.latitude + span.latitudeDelta / 2, + longitude: center.longitude + span.longitudeDelta / 2 + ) + ) + } } diff --git a/MC1/Extensions/CLLocationCoordinate2D+Distance.swift b/MC1/Extensions/CLLocationCoordinate2D+Distance.swift new file mode 100644 index 00000000..2da1876a --- /dev/null +++ b/MC1/Extensions/CLLocationCoordinate2D+Distance.swift @@ -0,0 +1,21 @@ +import CoreLocation + +extension [CLLocationCoordinate2D] { + /// Total length in meters along the ordered coordinate chain, summing + /// great-circle (WGS-84) distance between consecutive points. Nil for + /// fewer than 2 points. + /// + /// Separate from `TracePathViewModel.calculateDistance(for:)` on purpose: + /// that helper is all-or-nothing (nil if any hop lacks a location), whereas + /// callers here pass already-located coordinates, so this always succeeds. + func totalDistance() -> CLLocationDistance? { + guard count >= 2 else { return nil } + var meters: CLLocationDistance = 0 + for index in 0..<(count - 1) { + let origin = CLLocation(latitude: self[index].latitude, longitude: self[index].longitude) + let destination = CLLocation(latitude: self[index + 1].latitude, longitude: self[index + 1].longitude) + meters += origin.distance(from: destination) + } + return meters + } +} diff --git a/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift b/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift index 08bd9730..897ed6ea 100644 --- a/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift +++ b/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift @@ -1,7 +1,7 @@ import CoreLocation extension CLLocationCoordinate2D { - var formattedString: String { - "\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))" - } + var formattedString: String { + "\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))" + } } diff --git a/MC1/Extensions/ChannelDTO+DisplayName.swift b/MC1/Extensions/ChannelDTO+DisplayName.swift index 4ac9fc0c..34bebc3a 100644 --- a/MC1/Extensions/ChannelDTO+DisplayName.swift +++ b/MC1/Extensions/ChannelDTO+DisplayName.swift @@ -1,8 +1,8 @@ import MC1Services extension ChannelDTO { - /// Localized display name, falling back to a default name based on channel index. - var displayName: String { - name.isEmpty ? L10n.Chats.Chats.Channel.defaultName(Int(index)) : name - } + /// Localized display name, falling back to a default name based on channel index. + var displayName: String { + name.isEmpty ? L10n.Chats.Chats.Channel.defaultName(Int(index)) : name + } } diff --git a/MC1/Extensions/Collection+Chunked.swift b/MC1/Extensions/Collection+Chunked.swift index 2ad35a7a..25090b8b 100644 --- a/MC1/Extensions/Collection+Chunked.swift +++ b/MC1/Extensions/Collection+Chunked.swift @@ -1,12 +1,12 @@ import Foundation extension Collection { - /// Split collection into chunks of specified size. - /// Returns empty array if size <= 0 (prevents infinite loop). - func chunked(into size: Int) -> [[Element]] { - guard size > 0 else { return [] } - return stride(from: 0, to: count, by: size).map { - Array(self[index(startIndex, offsetBy: $0).. [[Element]] { + guard size > 0 else { return [] } + return stride(from: 0, to: count, by: size).map { + Array(self[index(startIndex, offsetBy: $0)..> 16) & 0xFF) / 255, - green: Double((hex >> 8) & 0xFF) / 255, - blue: Double(hex & 0xFF) / 255 - ) - } + init(hex: Int) { + self.init( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255 + ) + } } diff --git a/MC1/Extensions/ContactDTO+Coordinate.swift b/MC1/Extensions/ContactDTO+Coordinate.swift index 8f268014..4568317f 100644 --- a/MC1/Extensions/ContactDTO+Coordinate.swift +++ b/MC1/Extensions/ContactDTO+Coordinate.swift @@ -2,7 +2,7 @@ import CoreLocation import MC1Services extension ContactDTO { - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - } + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } diff --git a/MC1/Extensions/ContactType+Display.swift b/MC1/Extensions/ContactType+Display.swift index d69a7a88..e976c2ec 100644 --- a/MC1/Extensions/ContactType+Display.swift +++ b/MC1/Extensions/ContactType+Display.swift @@ -2,36 +2,36 @@ import MeshCore import SwiftUI extension ContactType { - /// Localized display label for contact-type rows and sheets. - var localizedName: String { - switch self { - case .chat: L10n.Contacts.Contacts.NodeKind.contact - case .repeater: L10n.Contacts.Contacts.NodeKind.repeater - case .room: L10n.Contacts.Contacts.NodeKind.room - } + /// Localized display label for contact-type rows and sheets. + var localizedName: String { + switch self { + case .chat: L10n.Contacts.Contacts.NodeKind.contact + case .repeater: L10n.Contacts.Contacts.NodeKind.repeater + case .room: L10n.Contacts.Contacts.NodeKind.room } + } - var iconSystemName: String { - switch self { - case .chat: "person.fill" - case .repeater: "antenna.radiowaves.left.and.right" - case .room: "person.3.fill" - } + var iconSystemName: String { + switch self { + case .chat: "person.fill" + case .repeater: "antenna.radiowaves.left.and.right" + case .room: "person.3.fill" } + } - var displayColor: Color { - switch self { - case .chat: .blue - case .repeater: .green - case .room: .purple - } + var displayColor: Color { + switch self { + case .chat: .blue + case .repeater: .green + case .room: .purple } + } - var pinStyle: MapPoint.PinStyle { - switch self { - case .chat: .contactChat - case .repeater: .contactRepeater - case .room: .contactRoom - } + var pinStyle: MapPoint.PinStyle { + switch self { + case .chat: .contactChat + case .repeater: .contactRepeater + case .room: .contactRoom } + } } diff --git a/MC1/Extensions/Date+RelativeFormatting.swift b/MC1/Extensions/Date+RelativeFormatting.swift index c769777b..8464da5b 100644 --- a/MC1/Extensions/Date+RelativeFormatting.swift +++ b/MC1/Extensions/Date+RelativeFormatting.swift @@ -1,9 +1,9 @@ import Foundation extension Date { - var relativeFormatted: String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: self, relativeTo: Date()) - } + var relativeFormatted: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self, relativeTo: Date()) + } } diff --git a/MC1/Extensions/DecryptStatus+Display.swift b/MC1/Extensions/DecryptStatus+Display.swift index 6435f265..d004738c 100644 --- a/MC1/Extensions/DecryptStatus+Display.swift +++ b/MC1/Extensions/DecryptStatus+Display.swift @@ -1,16 +1,16 @@ import MC1Services extension DecryptStatus { - /// Localized display label for decrypt-status indicators. - var localizedName: String { - switch self { - case .notApplicable: L10n.Tools.Tools.RxLog.DecryptStatus.notApplicable - case .noMatchingKey: L10n.Tools.Tools.RxLog.DecryptStatus.noKey - case .hmacFailed: L10n.Tools.Tools.RxLog.DecryptStatus.hmacFailed - case .decryptFailed: L10n.Tools.Tools.RxLog.DecryptStatus.decryptFailed - case .success: L10n.Tools.Tools.RxLog.DecryptStatus.decrypted - case .pending: L10n.Tools.Tools.RxLog.DecryptStatus.hasKey - case .dmNoMatchingKey: L10n.Tools.Tools.RxLog.DecryptStatus.noDmKey - } + /// Localized display label for decrypt-status indicators. + var localizedName: String { + switch self { + case .notApplicable: L10n.Tools.Tools.RxLog.DecryptStatus.notApplicable + case .noMatchingKey: L10n.Tools.Tools.RxLog.DecryptStatus.noKey + case .hmacFailed: L10n.Tools.Tools.RxLog.DecryptStatus.hmacFailed + case .decryptFailed: L10n.Tools.Tools.RxLog.DecryptStatus.decryptFailed + case .success: L10n.Tools.Tools.RxLog.DecryptStatus.decrypted + case .pending: L10n.Tools.Tools.RxLog.DecryptStatus.hasKey + case .dmNoMatchingKey: L10n.Tools.Tools.RxLog.DecryptStatus.noDmKey } + } } diff --git a/MC1/Extensions/Errors/AccessorySetupKitError+UserFacingMessage.swift b/MC1/Extensions/Errors/AccessorySetupKitError+UserFacingMessage.swift index 96e652ef..d47909d3 100644 --- a/MC1/Extensions/Errors/AccessorySetupKitError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/AccessorySetupKitError+UserFacingMessage.swift @@ -1,28 +1,28 @@ import MC1Services extension AccessorySetupKitError { - /// L10n-routed user-facing message for accessory pairing errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .sessionNotActive: - L10n.Localizable.Error.AccessorySetup.sessionNotActive - case .sessionInvalidated: - L10n.Localizable.Error.AccessorySetup.sessionInvalidated - case .pickerDismissed: - L10n.Localizable.Error.AccessorySetup.pickerDismissed - case .pickerRestricted: - L10n.Localizable.Error.AccessorySetup.pickerRestricted - case .pickerAlreadyActive: - L10n.Localizable.Error.AccessorySetup.pickerAlreadyActive - case .pairingFailed(let reason): - L10n.Localizable.Error.AccessorySetup.pairingFailed(reason) - case .noBluetoothIdentifier: - L10n.Localizable.Error.AccessorySetup.noBluetoothIdentifier - case .discoveryTimeout: - L10n.Localizable.Error.AccessorySetup.discoveryTimeout - case .connectionFailed: - L10n.Localizable.Error.AccessorySetup.connectionFailed - } + /// L10n-routed user-facing message for accessory pairing errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .sessionNotActive: + L10n.Localizable.Error.AccessorySetup.sessionNotActive + case .sessionInvalidated: + L10n.Localizable.Error.AccessorySetup.sessionInvalidated + case .pickerDismissed: + L10n.Localizable.Error.AccessorySetup.pickerDismissed + case .pickerRestricted: + L10n.Localizable.Error.AccessorySetup.pickerRestricted + case .pickerAlreadyActive: + L10n.Localizable.Error.AccessorySetup.pickerAlreadyActive + case let .pairingFailed(reason): + L10n.Localizable.Error.AccessorySetup.pairingFailed(reason) + case .noBluetoothIdentifier: + L10n.Localizable.Error.AccessorySetup.noBluetoothIdentifier + case .discoveryTimeout: + L10n.Localizable.Error.AccessorySetup.discoveryTimeout + case .connectionFailed: + L10n.Localizable.Error.AccessorySetup.connectionFailed } + } } diff --git a/MC1/Extensions/Errors/AdvertisementError+UserFacingMessage.swift b/MC1/Extensions/Errors/AdvertisementError+UserFacingMessage.swift index 60174c3e..3618a183 100644 --- a/MC1/Extensions/Errors/AdvertisementError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/AdvertisementError+UserFacingMessage.swift @@ -1,18 +1,18 @@ import MC1Services extension AdvertisementError { - /// L10n-routed user-facing message for advertisement errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.Advertisement.notConnected - case .sendFailed: - L10n.Localizable.Error.Advertisement.sendFailed - case .invalidResponse: - L10n.Localizable.Error.Advertisement.invalidResponse - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for advertisement errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.Advertisement.notConnected + case .sendFailed: + L10n.Localizable.Error.Advertisement.sendFailed + case .invalidResponse: + L10n.Localizable.Error.Advertisement.invalidResponse + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/BLEError+UserFacingMessage.swift b/MC1/Extensions/Errors/BLEError+UserFacingMessage.swift index 80921eba..1e7930a6 100644 --- a/MC1/Extensions/Errors/BLEError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/BLEError+UserFacingMessage.swift @@ -1,38 +1,38 @@ import MC1Services extension BLEError { - /// L10n-routed user-facing message for BLE transport errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .bluetoothUnavailable: - L10n.Localizable.Error.Ble.bluetoothUnavailable - case .bluetoothUnauthorized: - L10n.Localizable.Error.Ble.bluetoothUnauthorized - case .bluetoothPoweredOff: - L10n.Localizable.Error.Ble.bluetoothPoweredOff - case .deviceNotFound: - L10n.Localizable.Error.Ble.deviceNotFound - case .connectionFailed(let message): - L10n.Localizable.Error.Ble.connectionFailed(message) - case .connectionTimeout: - L10n.Localizable.Error.Ble.connectionTimeout - case .notConnected: - L10n.Localizable.Error.Ble.notConnected - case .characteristicNotFound: - L10n.Localizable.Error.Ble.characteristicNotFound - case .writeError(let message): - L10n.Localizable.Error.Ble.writeError(message) - case .invalidResponse: - L10n.Localizable.Error.Ble.invalidResponse - case .operationTimeout: - L10n.Localizable.Error.Ble.operationTimeout - case .authenticationFailed: - L10n.Localizable.Error.Ble.authenticationFailed - case .pairingFailed(let reason): - L10n.Localizable.Error.Ble.pairingFailed(reason) - case .deviceConnectedToOtherApp: - L10n.Localizable.Error.Ble.deviceConnectedToOtherApp - } + /// L10n-routed user-facing message for BLE transport errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .bluetoothUnavailable: + L10n.Localizable.Error.Ble.bluetoothUnavailable + case .bluetoothUnauthorized: + L10n.Localizable.Error.Ble.bluetoothUnauthorized + case .bluetoothPoweredOff: + L10n.Localizable.Error.Ble.bluetoothPoweredOff + case .deviceNotFound: + L10n.Localizable.Error.Ble.deviceNotFound + case let .connectionFailed(message): + L10n.Localizable.Error.Ble.connectionFailed(message) + case .connectionTimeout: + L10n.Localizable.Error.Ble.connectionTimeout + case .notConnected: + L10n.Localizable.Error.Ble.notConnected + case .characteristicNotFound: + L10n.Localizable.Error.Ble.characteristicNotFound + case let .writeError(message): + L10n.Localizable.Error.Ble.writeError(message) + case .invalidResponse: + L10n.Localizable.Error.Ble.invalidResponse + case .operationTimeout: + L10n.Localizable.Error.Ble.operationTimeout + case .authenticationFailed: + L10n.Localizable.Error.Ble.authenticationFailed + case let .pairingFailed(reason): + L10n.Localizable.Error.Ble.pairingFailed(reason) + case .deviceConnectedToOtherApp: + L10n.Localizable.Error.Ble.deviceConnectedToOtherApp } + } } diff --git a/MC1/Extensions/Errors/BinaryProtocolError+UserFacingMessage.swift b/MC1/Extensions/Errors/BinaryProtocolError+UserFacingMessage.swift index b76f05a1..1aaea2a8 100644 --- a/MC1/Extensions/Errors/BinaryProtocolError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/BinaryProtocolError+UserFacingMessage.swift @@ -1,20 +1,20 @@ import MC1Services extension BinaryProtocolError { - /// L10n-routed user-facing message for binary protocol errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.BinaryProtocol.notConnected - case .sendFailed: - L10n.Localizable.Error.BinaryProtocol.sendFailed - case .timeout: - L10n.Localizable.Error.BinaryProtocol.timeout - case .invalidResponse: - L10n.Localizable.Error.BinaryProtocol.invalidResponse - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for binary protocol errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.BinaryProtocol.notConnected + case .sendFailed: + L10n.Localizable.Error.BinaryProtocol.sendFailed + case .timeout: + L10n.Localizable.Error.BinaryProtocol.timeout + case .invalidResponse: + L10n.Localizable.Error.BinaryProtocol.invalidResponse + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/ChannelServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/ChannelServiceError+UserFacingMessage.swift index 2be3533a..894644bc 100644 --- a/MC1/Extensions/Errors/ChannelServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ChannelServiceError+UserFacingMessage.swift @@ -1,28 +1,28 @@ import MC1Services extension ChannelServiceError { - /// L10n-routed user-facing message for channel service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.ChannelService.notConnected - case .channelNotFound: - L10n.Localizable.Error.ChannelService.channelNotFound - case .invalidChannelIndex: - L10n.Localizable.Error.ChannelService.invalidChannelIndex - case .secretHashingFailed: - L10n.Localizable.Error.ChannelService.secretHashingFailed - case .saveFailed(let reason): - L10n.Localizable.Error.ChannelService.saveFailed(reason) - case .sendFailed(let reason): - L10n.Localizable.Error.ChannelService.sendFailed(reason) - case .sessionError(let error): - error.userFacingMessage - case .syncAlreadyInProgress: - L10n.Localizable.Error.ChannelService.syncAlreadyInProgress - case .circuitBreakerOpen(let consecutiveFailures): - L10n.Localizable.Error.ChannelService.circuitBreakerOpen(consecutiveFailures) - } + /// L10n-routed user-facing message for channel service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.ChannelService.notConnected + case .channelNotFound: + L10n.Localizable.Error.ChannelService.channelNotFound + case .invalidChannelIndex: + L10n.Localizable.Error.ChannelService.invalidChannelIndex + case .secretHashingFailed: + L10n.Localizable.Error.ChannelService.secretHashingFailed + case let .saveFailed(reason): + L10n.Localizable.Error.ChannelService.saveFailed(reason) + case let .sendFailed(reason): + L10n.Localizable.Error.ChannelService.sendFailed(reason) + case let .sessionError(error): + error.userFacingMessage + case .syncAlreadyInProgress: + L10n.Localizable.Error.ChannelService.syncAlreadyInProgress + case let .circuitBreakerOpen(consecutiveFailures): + L10n.Localizable.Error.ChannelService.circuitBreakerOpen(consecutiveFailures) } + } } diff --git a/MC1/Extensions/Errors/ChatSendQueueServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/ChatSendQueueServiceError+UserFacingMessage.swift index 4fa40f54..d73ba9cd 100644 --- a/MC1/Extensions/Errors/ChatSendQueueServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ChatSendQueueServiceError+UserFacingMessage.swift @@ -1,15 +1,15 @@ import MC1Services extension ChatSendQueueServiceError { - /// L10n-routed user-facing message for send queue errors. The wrapped - /// error in `persistFailed` recurses through `userFacingMessage` so - /// mapped service errors localize instead of falling back to English. - var userFacingMessage: String { - switch self { - case .persistFailed(let underlying): - L10n.Localizable.Error.ChatSendQueue.persistFailed(underlying.userFacingMessage) - case .notConnected: - L10n.Localizable.Error.ChatSendQueue.notConnected - } + /// L10n-routed user-facing message for send queue errors. The wrapped + /// error in `persistFailed` recurses through `userFacingMessage` so + /// mapped service errors localize instead of falling back to English. + var userFacingMessage: String { + switch self { + case let .persistFailed(underlying): + L10n.Localizable.Error.ChatSendQueue.persistFailed(underlying.userFacingMessage) + case .notConnected: + L10n.Localizable.Error.ChatSendQueue.notConnected } + } } diff --git a/MC1/Extensions/Errors/ConnectionError+UserFacingMessage.swift b/MC1/Extensions/Errors/ConnectionError+UserFacingMessage.swift index ab1835bb..10749c0d 100644 --- a/MC1/Extensions/Errors/ConnectionError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ConnectionError+UserFacingMessage.swift @@ -1,18 +1,18 @@ import MC1Services extension ConnectionError { - /// L10n-routed user-facing message for connection lifecycle errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .connectionFailed(let reason): - L10n.Localizable.Error.Connection.connectionFailed(reason) - case .deviceNotFound: - L10n.Localizable.Error.Connection.deviceNotFound - case .notConnected: - L10n.Localizable.Error.Connection.notConnected - case .initializationFailed(let reason): - L10n.Localizable.Error.Connection.initializationFailed(reason) - } + /// L10n-routed user-facing message for connection lifecycle errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case let .connectionFailed(reason): + L10n.Localizable.Error.Connection.connectionFailed(reason) + case .deviceNotFound: + L10n.Localizable.Error.Connection.deviceNotFound + case .notConnected: + L10n.Localizable.Error.Connection.notConnected + case let .initializationFailed(reason): + L10n.Localizable.Error.Connection.initializationFailed(reason) } + } } diff --git a/MC1/Extensions/Errors/ContactServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/ContactServiceError+UserFacingMessage.swift index e05d1956..9e82c0f8 100644 --- a/MC1/Extensions/Errors/ContactServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ContactServiceError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension ContactServiceError { - /// L10n-routed user-facing message for contact service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.ContactService.notConnected - case .sendFailed: - L10n.Localizable.Error.ContactService.sendFailed - case .invalidResponse: - L10n.Localizable.Error.ContactService.invalidResponse - case .syncInterrupted: - L10n.Localizable.Error.ContactService.syncInterrupted - case .contactNotFound: - L10n.Localizable.Error.ContactService.contactNotFound - case .contactTableFull: - L10n.Localizable.Error.ContactService.contactTableFull - case .shareContactUnavailable: - L10n.Localizable.Error.ContactService.shareContactUnavailable - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for contact service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.ContactService.notConnected + case .sendFailed: + L10n.Localizable.Error.ContactService.sendFailed + case .invalidResponse: + L10n.Localizable.Error.ContactService.invalidResponse + case .syncInterrupted: + L10n.Localizable.Error.ContactService.syncInterrupted + case .contactNotFound: + L10n.Localizable.Error.ContactService.contactNotFound + case .contactTableFull: + L10n.Localizable.Error.ContactService.contactTableFull + case .shareContactUnavailable: + L10n.Localizable.Error.ContactService.shareContactUnavailable + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/DeviceServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/DeviceServiceError+UserFacingMessage.swift index 9a5cf771..97ee3cc8 100644 --- a/MC1/Extensions/Errors/DeviceServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/DeviceServiceError+UserFacingMessage.swift @@ -1,14 +1,14 @@ import MC1Services extension DeviceServiceError { - /// L10n-routed user-facing message for device service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .deviceNotFound: - L10n.Localizable.Error.DeviceService.deviceNotFound - case .persistenceFailed(let reason): - L10n.Localizable.Error.DeviceService.persistenceFailed(reason) - } + /// L10n-routed user-facing message for device service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .deviceNotFound: + L10n.Localizable.Error.DeviceService.deviceNotFound + case let .persistenceFailed(reason): + L10n.Localizable.Error.DeviceService.persistenceFailed(reason) } + } } diff --git a/MC1/Extensions/Errors/Error+UserFacingMessage.swift b/MC1/Extensions/Errors/Error+UserFacingMessage.swift index bd56bd53..7ea6f687 100644 --- a/MC1/Extensions/Errors/Error+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/Error+UserFacingMessage.swift @@ -2,39 +2,39 @@ import Foundation import MC1Services extension Error { - /// Localized user-facing message for `errorMessage` at the view boundary. - /// Inside each `case let` the value is concretely typed, so the concrete - /// type's `userFacingMessage` extension member wins over this protocol - /// extension and there is no recursion. Errors without a mapping fall back - /// to `localizedDescription`; each newly mapped error type adds a case here. - var userFacingMessage: String { - switch self { - case let error as MeshCoreError: error.userFacingMessage - case let error as ProtocolError: error.userFacingMessage - case let error as TimeoutError: error.userFacingMessage - case let error as AppBackupError: error.userFacingMessage - case let error as BLEError: error.userFacingMessage - case let error as ConnectionError: error.userFacingMessage - case let error as WiFiTransportError: error.userFacingMessage - case let error as AccessorySetupKitError: error.userFacingMessage - case let error as ContactServiceError: error.userFacingMessage - case let error as MessageServiceError: error.userFacingMessage - case let error as ChannelServiceError: error.userFacingMessage - case let error as ChatSendQueueServiceError: error.userFacingMessage - case let error as MessagePollingError: error.userFacingMessage - case let error as AdvertisementError: error.userFacingMessage - case let error as RemoteNodeError: error.userFacingMessage - case let error as RoomServerError: error.userFacingMessage - case let error as BinaryProtocolError: error.userFacingMessage - case let error as PersistenceStoreError: error.userFacingMessage - case let error as SyncCoordinatorError: error.userFacingMessage - case let error as DeviceServiceError: error.userFacingMessage - case let error as SettingsServiceError: error.userFacingMessage - case let error as KeychainError: error.userFacingMessage - case let error as KeyGenerationError: error.userFacingMessage - case let error as NodeConfigServiceError: error.userFacingMessage - case let error as StoreServiceError: error.userFacingMessage - default: localizedDescription - } + /// Localized user-facing message for `errorMessage` at the view boundary. + /// Inside each `case let` the value is concretely typed, so the concrete + /// type's `userFacingMessage` extension member wins over this protocol + /// extension and there is no recursion. Errors without a mapping fall back + /// to `localizedDescription`; each newly mapped error type adds a case here. + var userFacingMessage: String { + switch self { + case let error as MeshCoreError: error.userFacingMessage + case let error as ProtocolError: error.userFacingMessage + case let error as TimeoutError: error.userFacingMessage + case let error as AppBackupError: error.userFacingMessage + case let error as BLEError: error.userFacingMessage + case let error as ConnectionError: error.userFacingMessage + case let error as WiFiTransportError: error.userFacingMessage + case let error as AccessorySetupKitError: error.userFacingMessage + case let error as ContactServiceError: error.userFacingMessage + case let error as MessageServiceError: error.userFacingMessage + case let error as ChannelServiceError: error.userFacingMessage + case let error as ChatSendQueueServiceError: error.userFacingMessage + case let error as MessagePollingError: error.userFacingMessage + case let error as AdvertisementError: error.userFacingMessage + case let error as RemoteNodeError: error.userFacingMessage + case let error as RoomServerError: error.userFacingMessage + case let error as BinaryProtocolError: error.userFacingMessage + case let error as PersistenceStoreError: error.userFacingMessage + case let error as SyncCoordinatorError: error.userFacingMessage + case let error as DeviceServiceError: error.userFacingMessage + case let error as SettingsServiceError: error.userFacingMessage + case let error as KeychainError: error.userFacingMessage + case let error as KeyGenerationError: error.userFacingMessage + case let error as NodeConfigServiceError: error.userFacingMessage + case let error as StoreServiceError: error.userFacingMessage + default: localizedDescription } + } } diff --git a/MC1/Extensions/Errors/KeyGenerationError+UserFacingMessage.swift b/MC1/Extensions/Errors/KeyGenerationError+UserFacingMessage.swift index be78f944..c002bf40 100644 --- a/MC1/Extensions/Errors/KeyGenerationError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/KeyGenerationError+UserFacingMessage.swift @@ -1,18 +1,18 @@ import MC1Services extension KeyGenerationError { - /// L10n-routed user-facing message for key generation errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .maxAttemptsExceeded: - L10n.Localizable.Error.KeyGeneration.maxAttemptsExceeded - case .reservedPrefix: - L10n.Localizable.Error.KeyGeneration.reservedPrefix - case .randomGenerationFailed: - L10n.Localizable.Error.KeyGeneration.randomGenerationFailed - case .invalidKey: - L10n.Localizable.Error.KeyGeneration.invalidKey - } + /// L10n-routed user-facing message for key generation errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .maxAttemptsExceeded: + L10n.Localizable.Error.KeyGeneration.maxAttemptsExceeded + case .reservedPrefix: + L10n.Localizable.Error.KeyGeneration.reservedPrefix + case .randomGenerationFailed: + L10n.Localizable.Error.KeyGeneration.randomGenerationFailed + case .invalidKey: + L10n.Localizable.Error.KeyGeneration.invalidKey } + } } diff --git a/MC1/Extensions/Errors/KeychainError+UserFacingMessage.swift b/MC1/Extensions/Errors/KeychainError+UserFacingMessage.swift index 997ec952..f71c2da6 100644 --- a/MC1/Extensions/Errors/KeychainError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/KeychainError+UserFacingMessage.swift @@ -1,18 +1,18 @@ import MC1Services extension KeychainError { - /// L10n-routed user-facing message for keychain errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .encodingFailed: - L10n.Localizable.Error.Keychain.encodingFailed - case .storageFailed(let status): - L10n.Localizable.Error.Keychain.storageFailed(Int(status)) - case .retrievalFailed(let status): - L10n.Localizable.Error.Keychain.retrievalFailed(Int(status)) - case .deletionFailed(let status): - L10n.Localizable.Error.Keychain.deletionFailed(Int(status)) - } + /// L10n-routed user-facing message for keychain errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .encodingFailed: + L10n.Localizable.Error.Keychain.encodingFailed + case let .storageFailed(status): + L10n.Localizable.Error.Keychain.storageFailed(Int(status)) + case let .retrievalFailed(status): + L10n.Localizable.Error.Keychain.retrievalFailed(Int(status)) + case let .deletionFailed(status): + L10n.Localizable.Error.Keychain.deletionFailed(Int(status)) } + } } diff --git a/MC1/Extensions/Errors/MeshCoreError+UserFacingMessage.swift b/MC1/Extensions/Errors/MeshCoreError+UserFacingMessage.swift index 187d5eea..03f96ff5 100644 --- a/MC1/Extensions/Errors/MeshCoreError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/MeshCoreError+UserFacingMessage.swift @@ -2,59 +2,59 @@ import Foundation import MC1Services extension MeshCoreError { - /// L10n-routed user-facing message for session errors. The package-level - /// `errorDescription` provides an English fallback so MC1Services stays - /// independent of the app target's L10n; views should prefer this property. - var userFacingMessage: String { - switch self { - case .timeout: - L10n.Localizable.Error.MeshCore.timeout - case .deviceError(let code): - Self.deviceErrorMessage(code: code) - case .parseError(let detail): - L10n.Localizable.Error.MeshCore.parseError(detail) - case .notConnected: - L10n.Localizable.Error.MeshCore.notConnected - case .commandFailed(_, let reason): - L10n.Localizable.Error.MeshCore.commandFailed(reason) - case .invalidResponse(let expected, let got): - L10n.Localizable.Error.MeshCore.invalidResponse(expected, got) - case .contactNotFound: - L10n.Localizable.Error.MeshCore.contactNotFound - case .dataTooLarge(let maxSize, let actualSize): - L10n.Localizable.Error.MeshCore.dataTooLarge(actualSize, maxSize) - case .signingFailed(let reason): - L10n.Localizable.Error.MeshCore.signingFailed(reason) - case .invalidInput(let detail): - L10n.Localizable.Error.MeshCore.invalidInput(detail) - case .unknown(let detail): - L10n.Localizable.Error.MeshCore.unknown(detail) - case .bluetoothUnavailable: - L10n.Localizable.Error.MeshCore.bluetoothUnavailable - case .bluetoothUnauthorized: - L10n.Localizable.Error.MeshCore.bluetoothUnauthorized - case .bluetoothPoweredOff: - L10n.Localizable.Error.MeshCore.bluetoothPoweredOff - case .connectionLost(let underlying): - if let underlying { - L10n.Localizable.Error.MeshCore.connectionLost(underlying.userFacingMessage) - } else { - L10n.Localizable.Error.MeshCore.connectionLostNoDetail - } - case .sessionNotStarted: - L10n.Localizable.Error.MeshCore.sessionNotStarted - case .featureDisabled: - L10n.Localizable.Error.MeshCore.featureDisabled - } + /// L10n-routed user-facing message for session errors. The package-level + /// `errorDescription` provides an English fallback so MC1Services stays + /// independent of the app target's L10n; views should prefer this property. + var userFacingMessage: String { + switch self { + case .timeout: + L10n.Localizable.Error.MeshCore.timeout + case let .deviceError(code): + Self.deviceErrorMessage(code: code) + case let .parseError(detail): + L10n.Localizable.Error.MeshCore.parseError(detail) + case .notConnected: + L10n.Localizable.Error.MeshCore.notConnected + case let .commandFailed(_, reason): + L10n.Localizable.Error.MeshCore.commandFailed(reason) + case let .invalidResponse(expected, got): + L10n.Localizable.Error.MeshCore.invalidResponse(expected, got) + case .contactNotFound: + L10n.Localizable.Error.MeshCore.contactNotFound + case let .dataTooLarge(maxSize, actualSize): + L10n.Localizable.Error.MeshCore.dataTooLarge(actualSize, maxSize) + case let .signingFailed(reason): + L10n.Localizable.Error.MeshCore.signingFailed(reason) + case let .invalidInput(detail): + L10n.Localizable.Error.MeshCore.invalidInput(detail) + case let .unknown(detail): + L10n.Localizable.Error.MeshCore.unknown(detail) + case .bluetoothUnavailable: + L10n.Localizable.Error.MeshCore.bluetoothUnavailable + case .bluetoothUnauthorized: + L10n.Localizable.Error.MeshCore.bluetoothUnauthorized + case .bluetoothPoweredOff: + L10n.Localizable.Error.MeshCore.bluetoothPoweredOff + case let .connectionLost(underlying): + if let underlying { + L10n.Localizable.Error.MeshCore.connectionLost(underlying.userFacingMessage) + } else { + L10n.Localizable.Error.MeshCore.connectionLostNoDetail + } + case .sessionNotStarted: + L10n.Localizable.Error.MeshCore.sessionNotStarted + case .featureDisabled: + L10n.Localizable.Error.MeshCore.featureDisabled } + } - /// Maps the firmware error sub-codes carried by `deviceError(code:)` to the - /// shared `error.device.*` keys that `ProtocolError` also uses, so the two - /// renderings of the same firmware codes cannot drift apart. - private static func deviceErrorMessage(code: UInt8) -> String { - guard let protocolError = ProtocolError(rawValue: code) else { - return L10n.Localizable.Error.Device.unknown(Int(code)) - } - return protocolError.userFacingMessage + /// Maps the firmware error sub-codes carried by `deviceError(code:)` to the + /// shared `error.device.*` keys that `ProtocolError` also uses, so the two + /// renderings of the same firmware codes cannot drift apart. + private static func deviceErrorMessage(code: UInt8) -> String { + guard let protocolError = ProtocolError(rawValue: code) else { + return L10n.Localizable.Error.Device.unknown(Int(code)) } + return protocolError.userFacingMessage + } } diff --git a/MC1/Extensions/Errors/MessagePollingError+UserFacingMessage.swift b/MC1/Extensions/Errors/MessagePollingError+UserFacingMessage.swift index ae834ad9..b84867f2 100644 --- a/MC1/Extensions/Errors/MessagePollingError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/MessagePollingError+UserFacingMessage.swift @@ -1,16 +1,16 @@ import MC1Services extension MessagePollingError { - /// L10n-routed user-facing message for message polling errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.MessagePolling.notConnected - case .pollingFailed: - L10n.Localizable.Error.MessagePolling.pollingFailed - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for message polling errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.MessagePolling.notConnected + case .pollingFailed: + L10n.Localizable.Error.MessagePolling.pollingFailed + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/MessageServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/MessageServiceError+UserFacingMessage.swift index 09e46e82..2cafb930 100644 --- a/MC1/Extensions/Errors/MessageServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/MessageServiceError+UserFacingMessage.swift @@ -1,24 +1,24 @@ import MC1Services extension MessageServiceError { - /// L10n-routed user-facing message for message service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.MessageService.notConnected - case .contactNotFound: - L10n.Localizable.Error.MessageService.contactNotFound - case .channelNotFound: - L10n.Localizable.Error.MessageService.channelNotFound - case .sendFailed: - L10n.Localizable.Error.MessageService.sendFailed - case .invalidRecipient: - L10n.Localizable.Error.MessageService.invalidRecipient - case .messageTooLong: - L10n.Localizable.Error.MessageService.messageTooLong - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for message service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.MessageService.notConnected + case .contactNotFound: + L10n.Localizable.Error.MessageService.contactNotFound + case .channelNotFound: + L10n.Localizable.Error.MessageService.channelNotFound + case .sendFailed: + L10n.Localizable.Error.MessageService.sendFailed + case .invalidRecipient: + L10n.Localizable.Error.MessageService.invalidRecipient + case .messageTooLong: + L10n.Localizable.Error.MessageService.messageTooLong + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/NodeConfigServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/NodeConfigServiceError+UserFacingMessage.swift index b53b4f6c..66dc5124 100644 --- a/MC1/Extensions/Errors/NodeConfigServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/NodeConfigServiceError+UserFacingMessage.swift @@ -1,50 +1,50 @@ import MC1Services extension NodeConfigServiceError { - /// L10n-routed user-facing message for config import validation errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .invalidChannelSecret(let index, let hexLength): - L10n.Settings.ConfigImport.Error.invalidChannelSecret(index, hexLength) - case .invalidContactPublicKey(let name): - L10n.Settings.ConfigImport.Error.invalidContactPublicKey(name) - case .invalidPathHashMode(let name, let mode): - L10n.Settings.ConfigImport.Error.invalidPathHashMode(name, Int(mode)) - case .invalidPrivateKey(let hexLength): - L10n.Settings.ConfigImport.Error.invalidPrivateKey(hexLength, ProtocolLimits.privateKeySize * 2) - case .invalidRadioSettings(let field): - L10n.Settings.ConfigImport.Error.radioOutOfRange(Self.radioFieldLabel(field)) - case .noAvailableChannelSlot(let name): - L10n.Settings.ConfigImport.Error.noAvailableChannelSlot(name) - case .invalidCoordinate(let field): - switch field { - case .positionLatitude, .positionLongitude: - L10n.Settings.ConfigImport.Error.positionInvalid(Self.coordinateLabel(field)) - case .contactLatitude(let name), .contactLongitude(let name): - L10n.Settings.ConfigImport.Error.contactCoordinateInvalid(name, Self.coordinateLabel(field)) - } - case .invalidOutPath(let name): - L10n.Settings.ConfigImport.Error.invalidOutPath(name) - case .contactCapacityExceeded(let needed, let available): - L10n.Settings.ConfigImport.Error.contactCapacityExceeded(needed, available) - } + /// L10n-routed user-facing message for config import validation errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case let .invalidChannelSecret(index, hexLength): + L10n.Settings.ConfigImport.Error.invalidChannelSecret(index, hexLength) + case let .invalidContactPublicKey(name): + L10n.Settings.ConfigImport.Error.invalidContactPublicKey(name) + case let .invalidPathHashMode(name, mode): + L10n.Settings.ConfigImport.Error.invalidPathHashMode(name, Int(mode)) + case let .invalidPrivateKey(hexLength): + L10n.Settings.ConfigImport.Error.invalidPrivateKey(hexLength, ProtocolLimits.privateKeySize * 2) + case let .invalidRadioSettings(field): + L10n.Settings.ConfigImport.Error.radioOutOfRange(Self.radioFieldLabel(field)) + case let .noAvailableChannelSlot(name): + L10n.Settings.ConfigImport.Error.noAvailableChannelSlot(name) + case let .invalidCoordinate(field): + switch field { + case .positionLatitude, .positionLongitude: + L10n.Settings.ConfigImport.Error.positionInvalid(Self.coordinateLabel(field)) + case let .contactLatitude(name), let .contactLongitude(name): + L10n.Settings.ConfigImport.Error.contactCoordinateInvalid(name, Self.coordinateLabel(field)) + } + case let .invalidOutPath(name): + L10n.Settings.ConfigImport.Error.invalidOutPath(name) + case let .contactCapacityExceeded(needed, available): + L10n.Settings.ConfigImport.Error.contactCapacityExceeded(needed, available) } + } - private static func radioFieldLabel(_ field: RadioField) -> String { - switch field { - case .frequency: L10n.Settings.ConfigImport.Field.frequency - case .bandwidth: L10n.Settings.ConfigImport.Field.bandwidth - case .spreadingFactor: L10n.Settings.ConfigImport.Field.spreadingFactor - case .codingRate: L10n.Settings.ConfigImport.Field.codingRate - case .txPower: L10n.Settings.ConfigImport.Field.txPower - } + private static func radioFieldLabel(_ field: RadioField) -> String { + switch field { + case .frequency: L10n.Settings.ConfigImport.Field.frequency + case .bandwidth: L10n.Settings.ConfigImport.Field.bandwidth + case .spreadingFactor: L10n.Settings.ConfigImport.Field.spreadingFactor + case .codingRate: L10n.Settings.ConfigImport.Field.codingRate + case .txPower: L10n.Settings.ConfigImport.Field.txPower } + } - private static func coordinateLabel(_ field: CoordinateField) -> String { - switch field { - case .positionLatitude, .contactLatitude: L10n.Settings.ConfigImport.Field.latitude - case .positionLongitude, .contactLongitude: L10n.Settings.ConfigImport.Field.longitude - } + private static func coordinateLabel(_ field: CoordinateField) -> String { + switch field { + case .positionLatitude, .contactLatitude: L10n.Settings.ConfigImport.Field.latitude + case .positionLongitude, .contactLongitude: L10n.Settings.ConfigImport.Field.longitude } + } } diff --git a/MC1/Extensions/Errors/PersistenceStoreError+UserFacingMessage.swift b/MC1/Extensions/Errors/PersistenceStoreError+UserFacingMessage.swift index d7d0e474..41e4a880 100644 --- a/MC1/Extensions/Errors/PersistenceStoreError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/PersistenceStoreError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension PersistenceStoreError { - /// L10n-routed user-facing message for persistence errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .deviceNotFound: - L10n.Localizable.Error.Persistence.deviceNotFound - case .contactNotFound: - L10n.Localizable.Error.Persistence.contactNotFound - case .messageNotFound: - L10n.Localizable.Error.Persistence.messageNotFound - case .channelNotFound: - L10n.Localizable.Error.Persistence.channelNotFound - case .remoteNodeSessionNotFound: - L10n.Localizable.Error.Persistence.remoteNodeSessionNotFound - case .saveFailed(let reason): - L10n.Localizable.Error.Persistence.saveFailed(reason) - case .fetchFailed(let reason): - L10n.Localizable.Error.Persistence.fetchFailed(reason) - case .invalidData: - L10n.Localizable.Error.Persistence.invalidData - } + /// L10n-routed user-facing message for persistence errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .deviceNotFound: + L10n.Localizable.Error.Persistence.deviceNotFound + case .contactNotFound: + L10n.Localizable.Error.Persistence.contactNotFound + case .messageNotFound: + L10n.Localizable.Error.Persistence.messageNotFound + case .channelNotFound: + L10n.Localizable.Error.Persistence.channelNotFound + case .remoteNodeSessionNotFound: + L10n.Localizable.Error.Persistence.remoteNodeSessionNotFound + case let .saveFailed(reason): + L10n.Localizable.Error.Persistence.saveFailed(reason) + case let .fetchFailed(reason): + L10n.Localizable.Error.Persistence.fetchFailed(reason) + case .invalidData: + L10n.Localizable.Error.Persistence.invalidData } + } } diff --git a/MC1/Extensions/Errors/ProtocolError+UserFacingMessage.swift b/MC1/Extensions/Errors/ProtocolError+UserFacingMessage.swift index a0380a7e..4574424c 100644 --- a/MC1/Extensions/Errors/ProtocolError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ProtocolError+UserFacingMessage.swift @@ -1,17 +1,17 @@ import MC1Services extension ProtocolError { - /// L10n-routed user-facing message for raw firmware error codes. Shares the - /// `error.device.*` keys with `MeshCoreError.deviceError(code:)` because both - /// describe the identical firmware codes. - var userFacingMessage: String { - switch self { - case .unsupportedCommand: L10n.Localizable.Error.Device.unsupportedCommand - case .notFound: L10n.Localizable.Error.Device.notFound - case .tableFull: L10n.Localizable.Error.Device.storageFull - case .badState: L10n.Localizable.Error.Device.invalidState - case .fileIOError: L10n.Localizable.Error.Device.fileSystem - case .illegalArgument: L10n.Localizable.Error.Device.invalidParameter - } + /// L10n-routed user-facing message for raw firmware error codes. Shares the + /// `error.device.*` keys with `MeshCoreError.deviceError(code:)` because both + /// describe the identical firmware codes. + var userFacingMessage: String { + switch self { + case .unsupportedCommand: L10n.Localizable.Error.Device.unsupportedCommand + case .notFound: L10n.Localizable.Error.Device.notFound + case .tableFull: L10n.Localizable.Error.Device.storageFull + case .badState: L10n.Localizable.Error.Device.invalidState + case .fileIOError: L10n.Localizable.Error.Device.fileSystem + case .illegalArgument: L10n.Localizable.Error.Device.invalidParameter } + } } diff --git a/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift b/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift index 47322716..83aa272c 100644 --- a/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift @@ -1,36 +1,38 @@ import MC1Services extension RemoteNodeError { - /// L10n-routed user-facing message for remote node errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.RemoteNode.notConnected - case .loginFailed: - L10n.Localizable.Error.RemoteNode.loginFailed - case .sendFailed: - L10n.Localizable.Error.RemoteNode.sendFailed - case .invalidResponse: - L10n.Localizable.Error.RemoteNode.invalidResponse - case .permissionDenied: - L10n.Localizable.Error.RemoteNode.permissionDenied - case .timeout: - L10n.Localizable.Error.RemoteNode.timeout - case .sessionNotFound: - L10n.Localizable.Error.RemoteNode.sessionNotFound - case .passwordNotFound: - L10n.Localizable.Error.RemoteNode.passwordNotFound - case .floodRouted: - L10n.Localizable.Error.RemoteNode.floodRouted - case .pathDiscoveryFailed: - L10n.Localizable.Error.RemoteNode.pathDiscoveryFailed - case .contactNotFound: - L10n.Localizable.Error.RemoteNode.contactNotFound - case .cancelled: - L10n.Localizable.Error.RemoteNode.cancelled - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for remote node errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.RemoteNode.notConnected + case .loginFailed: + L10n.Localizable.Error.RemoteNode.loginFailed + case .sendFailed: + L10n.Localizable.Error.RemoteNode.sendFailed + case .invalidResponse: + L10n.Localizable.Error.RemoteNode.invalidResponse + case .permissionDenied: + L10n.Localizable.Error.RemoteNode.permissionDenied + case .timeout: + L10n.Localizable.Error.RemoteNode.timeout + case .sessionNotFound: + L10n.Localizable.Error.RemoteNode.sessionNotFound + case .passwordNotFound: + L10n.Localizable.Error.RemoteNode.passwordNotFound + case .floodRouted: + L10n.Localizable.Error.RemoteNode.floodRouted + case .pathDiscoveryFailed: + L10n.Localizable.Error.RemoteNode.pathDiscoveryFailed + case .contactNotFound: + L10n.Localizable.Error.RemoteNode.contactNotFound + case .radioContactsFull: + L10n.Localizable.Error.RemoteNode.radioContactsFull + case .cancelled: + L10n.Localizable.Error.RemoteNode.cancelled + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/RoomServerError+UserFacingMessage.swift b/MC1/Extensions/Errors/RoomServerError+UserFacingMessage.swift index 8f9488cb..f6905467 100644 --- a/MC1/Extensions/Errors/RoomServerError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/RoomServerError+UserFacingMessage.swift @@ -1,22 +1,22 @@ import MC1Services extension RoomServerError { - /// L10n-routed user-facing message for room server errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.RoomServer.notConnected - case .sessionNotFound: - L10n.Localizable.Error.RoomServer.sessionNotFound - case .sendFailed: - L10n.Localizable.Error.RoomServer.sendFailed - case .permissionDenied: - L10n.Localizable.Error.RoomServer.permissionDenied - case .invalidResponse: - L10n.Localizable.Error.RoomServer.invalidResponse - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for room server errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.RoomServer.notConnected + case .sessionNotFound: + L10n.Localizable.Error.RoomServer.sessionNotFound + case .sendFailed: + L10n.Localizable.Error.RoomServer.sendFailed + case .permissionDenied: + L10n.Localizable.Error.RoomServer.permissionDenied + case .invalidResponse: + L10n.Localizable.Error.RoomServer.invalidResponse + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/SettingsServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/SettingsServiceError+UserFacingMessage.swift index 82e215b2..aa112e2e 100644 --- a/MC1/Extensions/Errors/SettingsServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/SettingsServiceError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension SettingsServiceError { - /// L10n-routed user-facing message for settings service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.Settings.notConnected - case .sendFailed: - L10n.Localizable.Error.Settings.sendFailed - case .invalidResponse: - L10n.Localizable.Error.Settings.invalidResponse - case .sessionError(let error): - error.userFacingMessage - case .verificationFailed(let expected, let actual): - L10n.Localizable.Error.Settings.verificationFailed(expected, actual) - case .deviceGPSVerificationFailed(let expectedEnabled, _): - // Two whole-sentence variants instead of interpolating an On/Off word, - // so every locale can phrase the toggle state naturally. - expectedEnabled - ? L10n.Localizable.Error.Settings.gpsNotSavedExpectedOn - : L10n.Localizable.Error.Settings.gpsNotSavedExpectedOff - } + /// L10n-routed user-facing message for settings service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.Settings.notConnected + case .sendFailed: + L10n.Localizable.Error.Settings.sendFailed + case .invalidResponse: + L10n.Localizable.Error.Settings.invalidResponse + case let .sessionError(error): + error.userFacingMessage + case let .verificationFailed(expected, actual): + L10n.Localizable.Error.Settings.verificationFailed(expected, actual) + case let .deviceGPSVerificationFailed(expectedEnabled, _): + // Two whole-sentence variants instead of interpolating an On/Off word, + // so every locale can phrase the toggle state naturally. + expectedEnabled + ? L10n.Localizable.Error.Settings.gpsNotSavedExpectedOn + : L10n.Localizable.Error.Settings.gpsNotSavedExpectedOff } + } } diff --git a/MC1/Extensions/Errors/StoreServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/StoreServiceError+UserFacingMessage.swift index 2280dbec..b9b805b6 100644 --- a/MC1/Extensions/Errors/StoreServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/StoreServiceError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension StoreServiceError { - /// L10n-routed user-facing message for store errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .productsNotLoaded: - L10n.Settings.Support.Error.productsNotLoaded - case .productNotFound: - L10n.Settings.Support.Error.productNotFound - case .purchaseFailed(let reason): - L10n.Settings.Support.Error.purchaseFailed(reason) - case .verificationFailed: - L10n.Settings.Support.Error.verificationFailed - case .notEntitled: - L10n.Settings.Support.Error.notEntitled - case .networkUnavailable: - L10n.Settings.Support.Error.networkUnavailable - case .storefrontUnavailable: - L10n.Settings.Support.Error.storefrontUnavailable - case .unsupported: - L10n.Settings.Support.Error.unsupported - } + /// L10n-routed user-facing message for store errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .productsNotLoaded: + L10n.Settings.Support.Error.productsNotLoaded + case .productNotFound: + L10n.Settings.Support.Error.productNotFound + case let .purchaseFailed(reason): + L10n.Settings.Support.Error.purchaseFailed(reason) + case .verificationFailed: + L10n.Settings.Support.Error.verificationFailed + case .notEntitled: + L10n.Settings.Support.Error.notEntitled + case .networkUnavailable: + L10n.Settings.Support.Error.networkUnavailable + case .storefrontUnavailable: + L10n.Settings.Support.Error.storefrontUnavailable + case .unsupported: + L10n.Settings.Support.Error.unsupported } + } } diff --git a/MC1/Extensions/Errors/SyncCoordinatorError+UserFacingMessage.swift b/MC1/Extensions/Errors/SyncCoordinatorError+UserFacingMessage.swift index 3236dccd..156aee80 100644 --- a/MC1/Extensions/Errors/SyncCoordinatorError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/SyncCoordinatorError+UserFacingMessage.swift @@ -1,16 +1,16 @@ import MC1Services extension SyncCoordinatorError { - /// L10n-routed user-facing message for sync coordinator errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.SyncCoordinator.notConnected - case .syncFailed(let reason): - L10n.Localizable.Error.SyncCoordinator.syncFailed(reason) - case .alreadySyncing: - L10n.Localizable.Error.SyncCoordinator.alreadySyncing - } + /// L10n-routed user-facing message for sync coordinator errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.SyncCoordinator.notConnected + case let .syncFailed(reason): + L10n.Localizable.Error.SyncCoordinator.syncFailed(reason) + case .alreadySyncing: + L10n.Localizable.Error.SyncCoordinator.alreadySyncing } + } } diff --git a/MC1/Extensions/Errors/TimeoutError+UserFacingMessage.swift b/MC1/Extensions/Errors/TimeoutError+UserFacingMessage.swift index e60cbee4..5edb3868 100644 --- a/MC1/Extensions/Errors/TimeoutError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/TimeoutError+UserFacingMessage.swift @@ -1,10 +1,10 @@ import MC1Services extension TimeoutError { - /// Generic localized timeout message. `operationName` is a `#function` - /// string, developer-facing by construction, so it stays out of the - /// user-facing rendering. - var userFacingMessage: String { - L10n.Localizable.Error.Timeout.operationTimedOut - } + /// Generic localized timeout message. `operationName` is a `#function` + /// string, developer-facing by construction, so it stays out of the + /// user-facing rendering. + var userFacingMessage: String { + L10n.Localizable.Error.Timeout.operationTimedOut + } } diff --git a/MC1/Extensions/Errors/WiFiTransportError+UserFacingMessage.swift b/MC1/Extensions/Errors/WiFiTransportError+UserFacingMessage.swift index e12977df..74a72c19 100644 --- a/MC1/Extensions/Errors/WiFiTransportError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/WiFiTransportError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension WiFiTransportError { - /// L10n-routed user-facing message for WiFi transport errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .connectionFailed(let reason): - L10n.Localizable.Error.Wifi.connectionFailed(reason) - case .connectionTimeout: - L10n.Localizable.Error.Wifi.connectionTimeout - case .notConnected: - L10n.Localizable.Error.Wifi.notConnected - case .sendFailed(let reason): - L10n.Localizable.Error.Wifi.sendFailed(reason) - case .sendTimeout: - L10n.Localizable.Error.Wifi.sendTimeout - case .invalidHost: - L10n.Localizable.Error.Wifi.invalidHost - case .invalidPort: - L10n.Localizable.Error.Wifi.invalidPort - case .notConfigured: - L10n.Localizable.Error.Wifi.notConfigured - } + /// L10n-routed user-facing message for WiFi transport errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case let .connectionFailed(reason): + L10n.Localizable.Error.Wifi.connectionFailed(reason) + case .connectionTimeout: + L10n.Localizable.Error.Wifi.connectionTimeout + case .notConnected: + L10n.Localizable.Error.Wifi.notConnected + case let .sendFailed(reason): + L10n.Localizable.Error.Wifi.sendFailed(reason) + case .sendTimeout: + L10n.Localizable.Error.Wifi.sendTimeout + case .invalidHost: + L10n.Localizable.Error.Wifi.invalidHost + case .invalidPort: + L10n.Localizable.Error.Wifi.invalidPort + case .notConfigured: + L10n.Localizable.Error.Wifi.notConfigured } + } } diff --git a/MC1/Extensions/LPPSensorType+Chart.swift b/MC1/Extensions/LPPSensorType+Chart.swift index a4139a46..5c47a155 100644 --- a/MC1/Extensions/LPPSensorType+Chart.swift +++ b/MC1/Extensions/LPPSensorType+Chart.swift @@ -2,30 +2,30 @@ import MeshCore import SwiftUI extension LPPSensorType { - /// Chart accent color for telemetry history views. - var chartColor: Color { - switch self { - case .voltage: .orange - case .temperature: .red - case .humidity: .teal - case .barometer: .purple - case .illuminance: .yellow - case .current: .mint - case .power: .pink - case .frequency: .blue - case .altitude, .distance: .green - case .energy: .orange - case .direction: .indigo - case .percentage: .cyan - default: .cyan - } + /// Chart accent color for telemetry history views. + var chartColor: Color { + switch self { + case .voltage: .orange + case .temperature: .red + case .humidity: .teal + case .barometer: .purple + case .illuminance: .yellow + case .current: .mint + case .power: .pink + case .frequency: .blue + case .altitude, .distance: .green + case .energy: .orange + case .direction: .indigo + case .percentage: .cyan + default: .cyan } + } - /// Sort priority for telemetry charts (lower = earlier). - var chartSortPriority: Int { - switch self { - case .voltage: 0 - default: 1 - } + /// Sort priority for telemetry charts (lower = earlier). + var chartSortPriority: Int { + switch self { + case .voltage: 0 + default: 1 } + } } diff --git a/MC1/Extensions/LPPSensorType+LocalizedName.swift b/MC1/Extensions/LPPSensorType+LocalizedName.swift index 09bb3b32..bfca54d7 100644 --- a/MC1/Extensions/LPPSensorType+LocalizedName.swift +++ b/MC1/Extensions/LPPSensorType+LocalizedName.swift @@ -1,39 +1,39 @@ import MeshCore extension LPPSensorType { - /// Localized display name for telemetry labels. - /// Distinct from `name`, which is the wire/serialization identifier - /// (stored in snapshot entries and reverse-looked-up via `init?(name:)`) - /// and must stay English. - var localizedName: String { - switch self { - case .digitalInput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.digitalInput - case .digitalOutput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.digitalOutput - case .analogInput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.analogInput - case .analogOutput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.analogOutput - case .genericSensor: L10n.RemoteNodes.RemoteNodes.Status.Sensor.genericSensor - case .illuminance: L10n.RemoteNodes.RemoteNodes.Status.Sensor.illuminance - case .presence: L10n.RemoteNodes.RemoteNodes.Status.Sensor.presence - case .temperature: L10n.RemoteNodes.RemoteNodes.Status.Sensor.temperature - case .humidity: L10n.RemoteNodes.RemoteNodes.Status.Sensor.humidity - case .accelerometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.accelerometer - case .barometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.barometer - case .voltage: L10n.RemoteNodes.RemoteNodes.Status.Sensor.voltage - case .current: L10n.RemoteNodes.RemoteNodes.Status.Sensor.current - case .frequency: L10n.RemoteNodes.RemoteNodes.Status.Sensor.frequency - case .percentage: L10n.RemoteNodes.RemoteNodes.Status.Sensor.percentage - case .altitude: L10n.RemoteNodes.RemoteNodes.Status.Sensor.altitude - case .load: L10n.RemoteNodes.RemoteNodes.Status.Sensor.load - case .concentration: L10n.RemoteNodes.RemoteNodes.Status.Sensor.concentration - case .power: L10n.RemoteNodes.RemoteNodes.Status.Sensor.power - case .distance: L10n.RemoteNodes.RemoteNodes.Status.Sensor.distance - case .energy: L10n.RemoteNodes.RemoteNodes.Status.Sensor.energy - case .direction: L10n.RemoteNodes.RemoteNodes.Status.Sensor.direction - case .unixTime: L10n.RemoteNodes.RemoteNodes.Status.Sensor.unixTime - case .gyrometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.gyrometer - case .colour: L10n.RemoteNodes.RemoteNodes.Status.Sensor.colour - case .gps: L10n.RemoteNodes.RemoteNodes.Status.Sensor.gps - case .switchValue: L10n.RemoteNodes.RemoteNodes.Status.Sensor.switchValue - } + /// Localized display name for telemetry labels. + /// Distinct from `name`, which is the wire/serialization identifier + /// (stored in snapshot entries and reverse-looked-up via `init?(name:)`) + /// and must stay English. + var localizedName: String { + switch self { + case .digitalInput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.digitalInput + case .digitalOutput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.digitalOutput + case .analogInput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.analogInput + case .analogOutput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.analogOutput + case .genericSensor: L10n.RemoteNodes.RemoteNodes.Status.Sensor.genericSensor + case .illuminance: L10n.RemoteNodes.RemoteNodes.Status.Sensor.illuminance + case .presence: L10n.RemoteNodes.RemoteNodes.Status.Sensor.presence + case .temperature: L10n.RemoteNodes.RemoteNodes.Status.Sensor.temperature + case .humidity: L10n.RemoteNodes.RemoteNodes.Status.Sensor.humidity + case .accelerometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.accelerometer + case .barometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.barometer + case .voltage: L10n.RemoteNodes.RemoteNodes.Status.Sensor.voltage + case .current: L10n.RemoteNodes.RemoteNodes.Status.Sensor.current + case .frequency: L10n.RemoteNodes.RemoteNodes.Status.Sensor.frequency + case .percentage: L10n.RemoteNodes.RemoteNodes.Status.Sensor.percentage + case .altitude: L10n.RemoteNodes.RemoteNodes.Status.Sensor.altitude + case .load: L10n.RemoteNodes.RemoteNodes.Status.Sensor.load + case .concentration: L10n.RemoteNodes.RemoteNodes.Status.Sensor.concentration + case .power: L10n.RemoteNodes.RemoteNodes.Status.Sensor.power + case .distance: L10n.RemoteNodes.RemoteNodes.Status.Sensor.distance + case .energy: L10n.RemoteNodes.RemoteNodes.Status.Sensor.energy + case .direction: L10n.RemoteNodes.RemoteNodes.Status.Sensor.direction + case .unixTime: L10n.RemoteNodes.RemoteNodes.Status.Sensor.unixTime + case .gyrometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.gyrometer + case .colour: L10n.RemoteNodes.RemoteNodes.Status.Sensor.colour + case .gps: L10n.RemoteNodes.RemoteNodes.Status.Sensor.gps + case .switchValue: L10n.RemoteNodes.RemoteNodes.Status.Sensor.switchValue } + } } diff --git a/MC1/Extensions/NotificationLevel+Display.swift b/MC1/Extensions/NotificationLevel+Display.swift index 735e4334..3cb1b6db 100644 --- a/MC1/Extensions/NotificationLevel+Display.swift +++ b/MC1/Extensions/NotificationLevel+Display.swift @@ -1,21 +1,21 @@ import MC1Services extension NotificationLevel { - /// Localized display label for pickers and rows. - var localizedName: String { - switch self { - case .muted: L10n.Chats.Chats.NotificationLevel.muted - case .mentionsOnly: L10n.Chats.Chats.NotificationLevel.mentions - case .all: L10n.Chats.Chats.NotificationLevel.all - } + /// Localized display label for pickers and rows. + var localizedName: String { + switch self { + case .muted: L10n.Chats.Chats.NotificationLevel.muted + case .mentionsOnly: L10n.Chats.Chats.NotificationLevel.mentions + case .all: L10n.Chats.Chats.NotificationLevel.all } + } - /// Localized VoiceOver description of the level's effect. - var localizedAccessibilityDescription: String { - switch self { - case .muted: L10n.Chats.Chats.NotificationLevel.Accessibility.muted - case .mentionsOnly: L10n.Chats.Chats.NotificationLevel.Accessibility.mentionsOnly - case .all: L10n.Chats.Chats.NotificationLevel.Accessibility.all - } + /// Localized VoiceOver description of the level's effect. + var localizedAccessibilityDescription: String { + switch self { + case .muted: L10n.Chats.Chats.NotificationLevel.Accessibility.muted + case .mentionsOnly: L10n.Chats.Chats.NotificationLevel.Accessibility.mentionsOnly + case .all: L10n.Chats.Chats.NotificationLevel.Accessibility.all } + } } diff --git a/MC1/Extensions/ProcessInfo+ScreenshotMode.swift b/MC1/Extensions/ProcessInfo+ScreenshotMode.swift index d4d689c2..733e5599 100644 --- a/MC1/Extensions/ProcessInfo+ScreenshotMode.swift +++ b/MC1/Extensions/ProcessInfo+ScreenshotMode.swift @@ -1,10 +1,10 @@ import Foundation #if DEBUG -extension ProcessInfo { + extension ProcessInfo { /// True when launched with `-screenshotMode` for App Store screenshot capture. var isScreenshotMode: Bool { - arguments.contains("-screenshotMode") + arguments.contains("-screenshotMode") } -} + } #endif diff --git a/MC1/Extensions/RoomPermissionLevel+Display.swift b/MC1/Extensions/RoomPermissionLevel+Display.swift index 247055a8..bb2d3e1e 100644 --- a/MC1/Extensions/RoomPermissionLevel+Display.swift +++ b/MC1/Extensions/RoomPermissionLevel+Display.swift @@ -1,12 +1,12 @@ import MC1Services extension RoomPermissionLevel { - /// Localized display label for room info and status rows. - var localizedName: String { - switch self { - case .guest: L10n.RemoteNodes.RemoteNodes.Permission.guest - case .readWrite: L10n.RemoteNodes.RemoteNodes.Permission.member - case .admin: L10n.RemoteNodes.RemoteNodes.Permission.admin - } + /// Localized display label for room info and status rows. + var localizedName: String { + switch self { + case .guest: L10n.RemoteNodes.RemoteNodes.Permission.guest + case .readWrite: L10n.RemoteNodes.RemoteNodes.Permission.member + case .admin: L10n.RemoteNodes.RemoteNodes.Permission.admin } + } } diff --git a/MC1/Extensions/SNRQuality+Color.swift b/MC1/Extensions/SNRQuality+Color.swift index 4a26f8bd..84f94ce9 100644 --- a/MC1/Extensions/SNRQuality+Color.swift +++ b/MC1/Extensions/SNRQuality+Color.swift @@ -3,34 +3,34 @@ import SwiftUI import UIKit extension SNRQuality { - /// SwiftUI color for signal quality indicators. - var color: Color { - switch self { - case .excellent, .good: .green - case .fair: .yellow - case .poor: .red - case .unknown: .secondary - } + /// SwiftUI color for signal quality indicators. + var color: Color { + switch self { + case .excellent, .good: .green + case .fair: .yellow + case .poor: .red + case .unknown: .secondary } + } - /// UIKit color for MapKit renderers. - var uiColor: UIColor { - switch self { - case .excellent, .good: .systemGreen - case .fair: .systemYellow - case .poor: .systemRed - case .unknown: .systemGray - } + /// UIKit color for MapKit renderers. + var uiColor: UIColor { + switch self { + case .excellent, .good: .systemGreen + case .fair: .systemYellow + case .poor: .systemRed + case .unknown: .systemGray } + } - /// Localized display label for signal quality. - var localizedLabel: String { - switch self { - case .excellent: L10n.Chats.Chats.Signal.excellent - case .good: L10n.Chats.Chats.Signal.good - case .fair: L10n.Chats.Chats.Signal.fair - case .poor: L10n.Chats.Chats.Signal.poor - case .unknown: L10n.Chats.Chats.Path.Hop.signalUnknown - } + /// Localized display label for signal quality. + var localizedLabel: String { + switch self { + case .excellent: L10n.Chats.Chats.Signal.excellent + case .good: L10n.Chats.Chats.Signal.good + case .fair: L10n.Chats.Chats.Signal.fair + case .poor: L10n.Chats.Chats.Signal.poor + case .unknown: L10n.Chats.Chats.Path.Hop.signalUnknown } + } } diff --git a/MC1/Extensions/UInt8+Hex.swift b/MC1/Extensions/UInt8+Hex.swift index fcde1584..843e00ec 100644 --- a/MC1/Extensions/UInt8+Hex.swift +++ b/MC1/Extensions/UInt8+Hex.swift @@ -1,9 +1,9 @@ import Foundation extension UInt8 { - /// Two-character uppercase hex string (e.g., "0A", "FF") - var hexString: String { - let hex = String(self, radix: 16, uppercase: true) - return hex.count < 2 ? "0" + hex : hex - } + /// Two-character uppercase hex string (e.g., "0A", "FF") + var hexString: String { + let hex = String(self, radix: 16, uppercase: true) + return hex.count < 2 ? "0" + hex : hex + } } diff --git a/MC1/Extensions/View+LiquidGlass.swift b/MC1/Extensions/View+LiquidGlass.swift index a7ad6ba5..17871481 100644 --- a/MC1/Extensions/View+LiquidGlass.swift +++ b/MC1/Extensions/View+LiquidGlass.swift @@ -1,84 +1,82 @@ import SwiftUI extension View { - /// Applies liquid glass effect on iOS 26+, falls back to regularMaterial on earlier versions - @ViewBuilder - func liquidGlass(in shape: some Shape = .rect(cornerRadius: 12)) -> some View { - if #available(iOS 26.0, *) { - self.glassEffect(in: shape) - } else { - self.background(.regularMaterial, in: shape) - } + /// Applies liquid glass effect on iOS 26+, falls back to regularMaterial on earlier versions + @ViewBuilder + func liquidGlass(in shape: some Shape = .rect(cornerRadius: 12)) -> some View { + if #available(iOS 26.0, *) { + glassEffect(in: shape) + } else { + background(.regularMaterial, in: shape) } + } - /// Applies glass button style on iOS 26+, falls back to borderedProminent on earlier versions - @ViewBuilder - func liquidGlassButtonStyle() -> some View { - if #available(iOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.borderedProminent) - } + /// Applies glass button style on iOS 26+, falls back to borderedProminent on earlier versions + @ViewBuilder + func liquidGlassButtonStyle() -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.glass) + } else { + buttonStyle(.borderedProminent) } + } - /// Applies glass button style on iOS 26+, falls back to bordered (secondary weight) on earlier versions - @ViewBuilder - func liquidGlassSecondaryButtonStyle() -> some View { - if #available(iOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.bordered) - } + /// Applies glass button style on iOS 26+, falls back to bordered (secondary weight) on earlier versions + @ViewBuilder + func liquidGlassSecondaryButtonStyle() -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.glass) + } else { + buttonStyle(.bordered) } + } - /// Applies prominent glass button style with tint on iOS 26+, falls back to borderedProminent on earlier versions - @ViewBuilder - func liquidGlassProminentButtonStyle() -> some View { - if #available(iOS 26.0, *) { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.borderedProminent) - } + /// Applies prominent glass button style with tint on iOS 26+, falls back to borderedProminent on earlier versions + @ViewBuilder + func liquidGlassProminentButtonStyle() -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.glassProminent) + } else { + buttonStyle(.borderedProminent) } + } - /// Applies interactive liquid glass effect on iOS 26+, falls back to thinMaterial on earlier versions - @ViewBuilder - func liquidGlassInteractive(in shape: some Shape = .circle) -> some View { - if #available(iOS 26.0, *) { - self.glassEffect(.regular.interactive(), in: shape) - } else { - self.background(.thinMaterial, in: shape) - } + /// Applies interactive liquid glass effect on iOS 26+, falls back to thinMaterial on earlier versions + @ViewBuilder + func liquidGlassInteractive(in shape: some Shape = .circle) -> some View { + if #available(iOS 26.0, *) { + glassEffect(.regular.interactive(), in: shape) + } else { + background(.thinMaterial, in: shape) } + } - #if os(iOS) + #if os(iOS) /// Applies visible toolbar backgrounds for full-screen content views. /// On iOS 26+, explicitly sets visibility so system applies liquid glass. /// On iOS 18, uses regularMaterial background. @ViewBuilder func liquidGlassToolbarBackground() -> some View { - if #available(iOS 26.0, *) { - self - .toolbarBackgroundVisibility(.visible, for: .navigationBar) - } else { - self - .toolbarBackground(.regularMaterial, for: .navigationBar, .tabBar) - .toolbarBackgroundVisibility(.visible, for: .navigationBar, .tabBar) - } + if #available(iOS 26.0, *) { + toolbarBackgroundVisibility(.visible, for: .navigationBar) + } else { + toolbarBackground(.regularMaterial, for: .navigationBar, .tabBar) + .toolbarBackgroundVisibility(.visible, for: .navigationBar, .tabBar) + } } - #endif + #endif } extension View { - /// Applies glassEffectID on iOS 26+ for smooth morphing transitions, no-op on earlier versions - @ViewBuilder - func liquidGlassID(_ id: ID, in namespace: Namespace.ID) -> some View { - if #available(iOS 26.0, *) { - self.glassEffectID(id, in: namespace) - } else { - self - } + /// Applies glassEffectID on iOS 26+ for smooth morphing transitions, no-op on earlier versions + @ViewBuilder + func liquidGlassID(_ id: ID, in namespace: Namespace.ID) -> some View { + if #available(iOS 26.0, *) { + glassEffectID(id, in: namespace) + } else { + self } + } } /// Drop-in replacement for `Menu` that works around an iPadOS 26 Liquid Glass bug @@ -88,45 +86,40 @@ extension View { /// overlay, so the glass morph animation has nothing visible to ghost. /// On earlier iOS versions, uses a standard `Menu`. struct ToolbarMenu: View { - let content: Content - let label: LabelView + @ViewBuilder let content: Content + @ViewBuilder let label: LabelView - init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> LabelView) { - self.content = content() - self.label = label() - } - - var body: some View { - if #available(iOS 26, *) { - label - .accessibilityHidden(true) - .overlay { - Menu { content } label: { label } - .colorMultiply(.clear) - } - } else { - Menu { content } label: { label } + var body: some View { + if #available(iOS 26, *) { + label + .accessibilityHidden(true) + .overlay { + Menu { content } label: { label } + .colorMultiply(.clear) } + } else { + Menu { content } label: { label } } + } } /// A container that uses GlassEffectContainer on iOS 26+, passes through content on earlier versions struct LiquidGlassContainer: View { - let spacing: CGFloat - @ViewBuilder let content: Content + let spacing: CGFloat + @ViewBuilder let content: Content - init(spacing: CGFloat = 20, @ViewBuilder content: () -> Content) { - self.spacing = spacing - self.content = content() - } + init(spacing: CGFloat = 20, @ViewBuilder content: () -> Content) { + self.spacing = spacing + self.content = content() + } - var body: some View { - if #available(iOS 26.0, *) { - GlassEffectContainer(spacing: spacing) { - content - } - } else { - content - } + var body: some View { + if #available(iOS 26.0, *) { + GlassEffectContainer(spacing: spacing) { + content + } + } else { + content } + } } diff --git a/MC1/Extensions/View+MapControlButton.swift b/MC1/Extensions/View+MapControlButton.swift index 5ad7efc6..c87485a6 100644 --- a/MC1/Extensions/View+MapControlButton.swift +++ b/MC1/Extensions/View+MapControlButton.swift @@ -1,18 +1,18 @@ import SwiftUI extension View { - /// Applies the shared styling for a map control toolbar button: a fixed - /// 44pt icon-only tappable square with a medium-weight glyph and the given tint. - func mapControlButton(tint: Color) -> some View { - font(.body.weight(.medium)) - .foregroundStyle(tint) - .frame(width: MapControlButtonMetrics.size, height: MapControlButtonMetrics.size) - .contentShape(.rect) - .buttonStyle(.plain) - .labelStyle(.iconOnly) - } + /// Applies the shared styling for a map control toolbar button: a fixed + /// 44pt icon-only tappable square with a medium-weight glyph and the given tint. + func mapControlButton(tint: Color) -> some View { + font(.body.weight(.medium)) + .foregroundStyle(tint) + .frame(width: MapControlButtonMetrics.size, height: MapControlButtonMetrics.size) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + } } private enum MapControlButtonMetrics { - static let size: CGFloat = 44 + static let size: CGFloat = 44 } diff --git a/MC1/Extensions/View+RadioDisabled.swift b/MC1/Extensions/View+RadioDisabled.swift index 190dc6f6..af1f741e 100644 --- a/MC1/Extensions/View+RadioDisabled.swift +++ b/MC1/Extensions/View+RadioDisabled.swift @@ -1,29 +1,28 @@ -import SwiftUI import MC1Services +import SwiftUI extension View { - /// Disables the view when radio connection is not ready, applying visual - /// feedback to indicate unavailability. - /// - /// - Parameters: - /// - connectionState: The current connection state from appState - /// - otherCondition: Additional condition that should disable the view - /// - /// Example: - /// ```swift - /// Button("Save") { } - /// .radioDisabled(for: appState.connectionState, or: isSaving) - /// ``` - @ViewBuilder - func radioDisabled(for connectionState: DeviceConnectionState, or otherCondition: Bool = false) -> some View { - let isNotReady = connectionState != .ready - if isNotReady { - self - .disabled(true) - .foregroundStyle(.secondary) - .accessibilityHint(L10n.Localizable.Accessibility.requiresRadioConnection) - } else { - self.disabled(otherCondition) - } + /// Disables the view when radio connection is not ready, applying visual + /// feedback to indicate unavailability. + /// + /// - Parameters: + /// - connectionState: The current connection state from appState + /// - otherCondition: Additional condition that should disable the view + /// + /// Example: + /// ```swift + /// Button("Save") { } + /// .radioDisabled(for: appState.connectionState, or: isSaving) + /// ``` + @ViewBuilder + func radioDisabled(for connectionState: DeviceConnectionState, or otherCondition: Bool = false) -> some View { + let isNotReady = connectionState != .ready + if isNotReady { + disabled(true) + .foregroundStyle(.secondary) + .accessibilityHint(L10n.Localizable.Accessibility.requiresRadioConnection) + } else { + disabled(otherCondition) } + } } diff --git a/MC1/Extensions/View+ScrollRevealTitle.swift b/MC1/Extensions/View+ScrollRevealTitle.swift new file mode 100644 index 00000000..62d49a9f --- /dev/null +++ b/MC1/Extensions/View+ScrollRevealTitle.swift @@ -0,0 +1,44 @@ +import SwiftUI + +extension View { + /// Leaves the navigation bar title empty while the header is on screen, then fades + /// in `title` (the entity name) once the user scrolls past `revealAfter` points. + /// Pass the header's measured height (see `scrollRevealHeaderHeight`) so the reveal + /// tracks the real header size and adapts to Dynamic Type. + /// Pairs with `.navigationBarTitleDisplayMode(.inline)`. + func scrollRevealNavigationTitle(_ title: String, revealAfter: CGFloat = 150) -> some View { + modifier(ScrollRevealNavigationTitle(title: title, revealAfter: revealAfter)) + } + + /// Reports the measured height of the scroll-reveal header into `height`, so the + /// title reveals based on the header's real (Dynamic Type-aware) size rather than a + /// fixed threshold. Attach to the header content, then feed `height` to + /// `scrollRevealNavigationTitle(_:revealAfter:)`. + func scrollRevealHeaderHeight(into height: Binding) -> some View { + onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height.wrappedValue = $0 } + } +} + +private struct ScrollRevealNavigationTitle: ViewModifier { + let title: String + let revealAfter: CGFloat + + @State private var isRevealed = false + + func body(content: Content) -> some View { + content + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y > revealAfter + } action: { _, revealed in + guard revealed != isRevealed else { return } + withAnimation(.easeInOut(duration: 0.2)) { isRevealed = revealed } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text(title) + .font(.headline) + .opacity(isRevealed ? 1 : 0) + } + } + } +} diff --git a/MC1/Info-Debug.plist b/MC1/Info-Debug.plist index 66d870a6..de8ea1bd 100644 --- a/MC1/Info-Debug.plist +++ b/MC1/Info-Debug.plist @@ -95,6 +95,7 @@ CFBundleURLSchemes + meshcore meshcoreone diff --git a/MC1/Info.plist b/MC1/Info.plist index 9d6d0cf1..2403f193 100644 --- a/MC1/Info.plist +++ b/MC1/Info.plist @@ -97,6 +97,7 @@ CFBundleURLSchemes + meshcore meshcoreone diff --git a/MC1/Intents/AdvertReach.swift b/MC1/Intents/AdvertReach.swift index db2e22da..7364abf5 100644 --- a/MC1/Intents/AdvertReach.swift +++ b/MC1/Intents/AdvertReach.swift @@ -6,19 +6,19 @@ import AppIntents /// saved shortcut persists, so a case must never be renamed without preserving /// its raw value. enum AdvertReach: String, AppEnum { - // swiftlint:disable redundant_string_enum_value - case zeroHop = "zeroHop" - case flood = "flood" - // swiftlint:enable redundant_string_enum_value + case zeroHop + case flood - static let typeDisplayRepresentation = TypeDisplayRepresentation( - name: LocalizedStringResource("intent.advert.reach.type", table: "Tools") - ) + static let typeDisplayRepresentation = TypeDisplayRepresentation( + name: LocalizedStringResource("intent.advert.reach.type", table: "Tools") + ) - static let caseDisplayRepresentations: [AdvertReach: DisplayRepresentation] = [ - .zeroHop: DisplayRepresentation(title: LocalizedStringResource("intent.advert.reach.zeroHop", table: "Tools")), - .flood: DisplayRepresentation(title: LocalizedStringResource("intent.advert.reach.flood", table: "Tools")) - ] + static let caseDisplayRepresentations: [AdvertReach: DisplayRepresentation] = [ + .zeroHop: DisplayRepresentation(title: LocalizedStringResource("intent.advert.reach.zeroHop", table: "Tools")), + .flood: DisplayRepresentation(title: LocalizedStringResource("intent.advert.reach.flood", table: "Tools")) + ] - var sendsFlood: Bool { self == .flood } + var sendsFlood: Bool { + self == .flood + } } diff --git a/MC1/Intents/AppState+IntentRefresh.swift b/MC1/Intents/AppState+IntentRefresh.swift index 64f039b9..9266fe58 100644 --- a/MC1/Intents/AppState+IntentRefresh.swift +++ b/MC1/Intents/AppState+IntentRefresh.swift @@ -1,10 +1,10 @@ import AppIntents extension AppState { - /// Re-resolves the App Intents parameter queries when the addressable - /// contact/channel set changes, so saved shortcuts and Siri offer the - /// currently connected radio's contacts and channels rather than a stale set. - func refreshAppShortcutParameters() { - MC1AppShortcutsProvider.updateAppShortcutParameters() - } + /// Re-resolves the App Intents parameter queries when the addressable + /// contact/channel set changes, so saved shortcuts and Siri offer the + /// currently connected radio's contacts and channels rather than a stale set. + func refreshAppShortcutParameters() { + MC1AppShortcutsProvider.updateAppShortcutParameters() + } } diff --git a/MC1/Intents/CompositeIDHelpers.swift b/MC1/Intents/CompositeIDHelpers.swift index e18248a2..e45cd9fb 100644 --- a/MC1/Intents/CompositeIDHelpers.swift +++ b/MC1/Intents/CompositeIDHelpers.swift @@ -21,7 +21,7 @@ private let compositeIDSeparator: Character = "/" /// and the kind segment lets resolution route a contact id (public key) apart /// from a channel id (secret digest) without trusting an in-memory field. func formatCompositeID(radioID: UUID, kind: MessageTargetKind, keyHex: String) -> String { - "\(radioID.uuidString)\(compositeIDSeparator)\(kind.rawValue)\(compositeIDSeparator)\(keyHex)" + "\(radioID.uuidString)\(compositeIDSeparator)\(kind.rawValue)\(compositeIDSeparator)\(keyHex)" } /// Parses a composite id back into its radio scope, kind, and key hex, returning @@ -29,24 +29,24 @@ func formatCompositeID(radioID: UUID, kind: MessageTargetKind, keyHex: String) - /// entity" rather than resolving against the wrong radio or kind. Splits on the /// first two separators only; the key hex never contains a separator. func parseCompositeID(_ id: String) -> (radioID: UUID, kind: MessageTargetKind, keyHex: String)? { - guard let firstSeparator = id.firstIndex(of: compositeIDSeparator) else { return nil } - let radioPart = String(id[.. String { - var input = Data(channelDigestDomain.utf8) - withUnsafeBytes(of: radioID.uuid) { input.append(contentsOf: $0) } - input.append(secret) - let digest = SHA256.hash(data: input) - return Data(digest.prefix(channelDigestByteCount)).hexString + var input = Data(channelDigestDomain.utf8) + withUnsafeBytes(of: radioID.uuid) { input.append(contentsOf: $0) } + input.append(secret) + let digest = SHA256.hash(data: input) + return Data(digest.prefix(channelDigestByteCount)).hexString } diff --git a/MC1/Intents/IntentBridge.swift b/MC1/Intents/IntentBridge.swift index e4448089..4b1aab31 100644 --- a/MC1/Intents/IntentBridge.swift +++ b/MC1/Intents/IntentBridge.swift @@ -9,9 +9,9 @@ import MC1Services @Observable @MainActor final class IntentBridge { - private(set) var appState: AppState? + private(set) var appState: AppState? - func adopt(_ appState: AppState) { - self.appState = appState - } + func adopt(_ appState: AppState) { + self.appState = appState + } } diff --git a/MC1/Intents/IntentError.swift b/MC1/Intents/IntentError.swift index c268bb2b..9a7cb12f 100644 --- a/MC1/Intents/IntentError.swift +++ b/MC1/Intents/IntentError.swift @@ -4,28 +4,28 @@ import MC1Services /// Errors thrown by MeshCore One's App Intents, mirroring the /// `.sessionError(MeshCoreError)` wrapping convention of `MessageServiceError`. enum IntentError: LocalizedError { - case notConnected - case invalidRecipient - case messageTooLong - case sendFailed - case advertFailed - case sessionError(MeshCoreError) + case notConnected + case invalidRecipient + case messageTooLong + case sendFailed + case advertFailed + case sessionError(MeshCoreError) - /// Siri and Shortcuts read `errorDescription`, so this is the L10n seam. - var errorDescription: String? { - switch self { - case .notConnected: - L10n.Localizable.Error.Intent.notConnected - case .invalidRecipient: - L10n.Localizable.Error.Intent.invalidRecipient - case .messageTooLong: - L10n.Localizable.Error.Intent.messageTooLong - case .sendFailed: - L10n.Localizable.Error.Intent.sendFailed - case .advertFailed: - L10n.Localizable.Error.Advertisement.sendFailed - case .sessionError(let error): - error.userFacingMessage - } + /// Siri and Shortcuts read `errorDescription`, so this is the L10n seam. + var errorDescription: String? { + switch self { + case .notConnected: + L10n.Localizable.Error.Intent.notConnected + case .invalidRecipient: + L10n.Localizable.Error.Intent.invalidRecipient + case .messageTooLong: + L10n.Localizable.Error.Intent.messageTooLong + case .sendFailed: + L10n.Localizable.Error.Intent.sendFailed + case .advertFailed: + L10n.Localizable.Error.Advertisement.sendFailed + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Intents/MC1AppShortcutsProvider.swift b/MC1/Intents/MC1AppShortcutsProvider.swift index db16cf20..133b60d3 100644 --- a/MC1/Intents/MC1AppShortcutsProvider.swift +++ b/MC1/Intents/MC1AppShortcutsProvider.swift @@ -8,34 +8,34 @@ import AppIntents /// MeshCore One" by voice) and the advert phrase binds the reach, but neither /// binds free text. struct MC1AppShortcutsProvider: AppShortcutsProvider { - static var appShortcuts: [AppShortcut] { - AppShortcut( - intent: StatusQueryIntent(), - phrases: [ - "Check radio status in \(.applicationName)", - "What's my radio battery in \(.applicationName)", - "Is my radio connected in \(.applicationName)" - ], - shortTitle: LocalizedStringResource("intent.status.shortTitle", table: "Tools"), - systemImageName: "antenna.radiowaves.left.and.right" - ) - AppShortcut( - intent: SendMessageIntent(), - phrases: [ - "Send a message in \(.applicationName)", - "Message \(\.$target) in \(.applicationName)" - ], - shortTitle: LocalizedStringResource("intent.send.shortTitle", table: "Tools"), - systemImageName: "paperplane" - ) - AppShortcut( - intent: SendAdvertIntent(), - phrases: [ - "Send an advert in \(.applicationName)", - "Send a \(\.$reach) advert in \(.applicationName)" - ], - shortTitle: LocalizedStringResource("intent.advert.shortTitle", table: "Tools"), - systemImageName: "dot.radiowaves.left.and.right" - ) - } + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StatusQueryIntent(), + phrases: [ + "Check radio status in \(.applicationName)", + "What's my radio battery in \(.applicationName)", + "Is my radio connected in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("intent.status.shortTitle", table: "Tools"), + systemImageName: "antenna.radiowaves.left.and.right" + ) + AppShortcut( + intent: SendMessageIntent(), + phrases: [ + "Send a message in \(.applicationName)", + "Message \(\.$target) in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("intent.send.shortTitle", table: "Tools"), + systemImageName: "paperplane" + ) + AppShortcut( + intent: SendAdvertIntent(), + phrases: [ + "Send an advert in \(.applicationName)", + "Send a \(\.$reach) advert in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("intent.advert.shortTitle", table: "Tools"), + systemImageName: "dot.radiowaves.left.and.right" + ) + } } diff --git a/MC1/Intents/MessageRecipient.swift b/MC1/Intents/MessageRecipient.swift index 6cfa7605..88fae656 100644 --- a/MC1/Intents/MessageRecipient.swift +++ b/MC1/Intents/MessageRecipient.swift @@ -4,16 +4,16 @@ import MC1Services /// The resolved, validated send target carried across the actor boundary as a /// Sendable DTO. The raw `@Model` never leaves `PersistenceStore`, so the intent /// works with these DTOs. -enum MessageRecipient: Sendable { - case contact(ContactDTO) - case channel(ChannelDTO) +enum MessageRecipient { + case contact(ContactDTO) + case channel(ChannelDTO) - /// The radio the recipient was resolved against, used to confirm the live - /// radio still owns it before enqueuing. - var radioID: UUID { - switch self { - case .contact(let dto): dto.radioID - case .channel(let dto): dto.radioID - } + /// The radio the recipient was resolved against, used to confirm the live + /// radio still owns it before enqueuing. + var radioID: UUID { + switch self { + case let .contact(dto): dto.radioID + case let .channel(dto): dto.radioID } + } } diff --git a/MC1/Intents/MessageTargetEntity.swift b/MC1/Intents/MessageTargetEntity.swift index 143b9906..73df7873 100644 --- a/MC1/Intents/MessageTargetEntity.swift +++ b/MC1/Intents/MessageTargetEntity.swift @@ -9,44 +9,43 @@ import MC1Services /// when a channel is relocated). The kind is re-derived from the id at resolution /// time, so the in-memory `kind` read here drives display only. struct MessageTargetEntity: AppEntity { - static let typeDisplayRepresentation = TypeDisplayRepresentation( - name: LocalizedStringResource("intent.entity.target", table: "Tools") - ) - static let defaultQuery = MessageTargetQuery() + static let typeDisplayRepresentation = TypeDisplayRepresentation( + name: LocalizedStringResource("intent.entity.target", table: "Tools") + ) + static let defaultQuery = MessageTargetQuery() - let id: String - let kind: MessageTargetKind - let displayName: String - let subtitle: String + let id: String + let kind: MessageTargetKind + let displayName: String + let subtitle: String - var displayRepresentation: DisplayRepresentation { - let symbol: String - switch kind { - case .contact: symbol = "person.fill" - case .channel: symbol = "person.3.fill" - } - return DisplayRepresentation( - title: "\(displayName)", - subtitle: "\(subtitle)", - image: .init(systemName: symbol) - ) + var displayRepresentation: DisplayRepresentation { + let symbol = switch kind { + case .contact: "person.fill" + case .channel: "person.3.fill" } + return DisplayRepresentation( + title: "\(displayName)", + subtitle: "\(subtitle)", + image: .init(systemName: symbol) + ) + } - init(dto: ContactDTO) { - self.id = formatCompositeID(radioID: dto.radioID, kind: .contact, keyHex: dto.publicKey.hexString) - self.kind = .contact - self.displayName = dto.displayName - self.subtitle = dto.publicKeyPrefix.hexString - } + init(dto: ContactDTO) { + id = formatCompositeID(radioID: dto.radioID, kind: .contact, keyHex: dto.publicKey.hexString) + kind = .contact + displayName = dto.displayName + subtitle = dto.publicKeyPrefix.hexString + } - init(dto: ChannelDTO) { - self.id = formatCompositeID( - radioID: dto.radioID, - kind: .channel, - keyHex: channelSecretDigestHex(radioID: dto.radioID, secret: dto.secret) - ) - self.kind = .channel - self.displayName = dto.displayName - self.subtitle = L10n.Tools.Intent.Entity.channel - } + init(dto: ChannelDTO) { + id = formatCompositeID( + radioID: dto.radioID, + kind: .channel, + keyHex: channelSecretDigestHex(radioID: dto.radioID, secret: dto.secret) + ) + kind = .channel + displayName = dto.displayName + subtitle = L10n.Tools.Intent.Entity.channel + } } diff --git a/MC1/Intents/MessageTargetKind.swift b/MC1/Intents/MessageTargetKind.swift index 6b7fc4d8..8db5dc14 100644 --- a/MC1/Intents/MessageTargetKind.swift +++ b/MC1/Intents/MessageTargetKind.swift @@ -1,9 +1,7 @@ /// Discriminates the two send targets a `MessageTargetEntity` can represent. Raw /// values are pinned strings because they are embedded in the framework-persisted /// composite id; a case reorder must never change a saved id. -enum MessageTargetKind: String, Sendable { - // swiftlint:disable redundant_string_enum_value - case contact = "contact" - case channel = "channel" - // swiftlint:enable redundant_string_enum_value +enum MessageTargetKind: String { + case contact + case channel } diff --git a/MC1/Intents/MessageTargetQuery.swift b/MC1/Intents/MessageTargetQuery.swift index 760d7dd8..d8490727 100644 --- a/MC1/Intents/MessageTargetQuery.swift +++ b/MC1/Intents/MessageTargetQuery.swift @@ -7,37 +7,37 @@ import MC1Services /// stay nonisolated and hop to a single `@MainActor` fetch that reads the live /// connection through `IntentBridge`. struct MessageTargetQuery: EntityQuery { - @Dependency private var bridge: IntentBridge + @Dependency private var bridge: IntentBridge - /// Re-fetches by id against the current radio and resolves through - /// `resolveMessageTargets`. Deliberately does not chat-filter: - /// `SendMessageIntent.validate` is the type gate, so a contact that changed - /// type after being saved still resolves here and is rejected at send time. - func entities(for identifiers: [MessageTargetEntity.ID]) async throws -> [MessageTargetEntity] { - guard !identifiers.isEmpty else { return [] } - let fetched = try await fetchTargets() - return resolveMessageTargets(matching: identifiers, contacts: fetched.contacts, channels: fetched.channels) - } + /// Re-fetches by id against the current radio and resolves through + /// `resolveMessageTargets`. Deliberately does not chat-filter: + /// `SendMessageIntent.validate` is the type gate, so a contact that changed + /// type after being saved still resolves here and is rejected at send time. + func entities(for identifiers: [MessageTargetEntity.ID]) async throws -> [MessageTargetEntity] { + guard !identifiers.isEmpty else { return [] } + let fetched = try await fetchTargets() + return resolveMessageTargets(matching: identifiers, contacts: fetched.contacts, channels: fetched.channels) + } - func suggestedEntities() async throws -> [MessageTargetEntity] { - let fetched = try await fetchTargets() - return buildMessageTargets(contacts: chatContacts(fetched.contacts), channels: fetched.channels) - } + func suggestedEntities() async throws -> [MessageTargetEntity] { + let fetched = try await fetchTargets() + return buildMessageTargets(contacts: chatContacts(fetched.contacts), channels: fetched.channels) + } - @MainActor - private func fetchTargets() async throws -> (contacts: [ContactDTO], channels: [ChannelDTO]) { - guard let scope = currentRadioScope(bridge) else { return ([], []) } - let contacts = try await scope.store.fetchContacts(radioID: scope.radioID) - let channels = try await scope.store.fetchChannels(radioID: scope.radioID) - return (contacts, channels) - } + @MainActor + private func fetchTargets() async throws -> (contacts: [ContactDTO], channels: [ChannelDTO]) { + guard let scope = currentRadioScope(bridge) else { return ([], []) } + let contacts = try await scope.store.fetchContacts(radioID: scope.radioID) + let channels = try await scope.store.fetchChannels(radioID: scope.radioID) + return (contacts, channels) + } } extension MessageTargetQuery: EntityStringQuery { - func entities(matching string: String) async throws -> [MessageTargetEntity] { - let needle = string.lowercased() - let fetched = try await fetchTargets() - return buildMessageTargets(contacts: chatContacts(fetched.contacts), channels: fetched.channels) - .filter { $0.displayName.lowercased().contains(needle) } - } + func entities(matching string: String) async throws -> [MessageTargetEntity] { + let needle = string.lowercased() + let fetched = try await fetchTargets() + return buildMessageTargets(contacts: chatContacts(fetched.contacts), channels: fetched.channels) + .filter { $0.displayName.lowercased().contains(needle) } + } } diff --git a/MC1/Intents/MessageTargetResolution.swift b/MC1/Intents/MessageTargetResolution.swift index e5c779ae..9c3cd230 100644 --- a/MC1/Intents/MessageTargetResolution.swift +++ b/MC1/Intents/MessageTargetResolution.swift @@ -12,11 +12,11 @@ import MC1Services /// an empty result rather than an error. @MainActor func currentRadioScope(_ bridge: IntentBridge) -> (radioID: UUID, store: PersistenceStore)? { - guard let appState = bridge.appState, - let radioID = appState.currentRadioID else { return nil } - let store = appState.services?.dataStore - ?? appState.connectionManager.createStandalonePersistenceStore() - return (radioID, store) + guard let appState = bridge.appState, + let radioID = appState.currentRadioID else { return nil } + let store = appState.services?.dataStore + ?? appState.connectionManager.createStandalonePersistenceStore() + return (radioID, store) } // MARK: - Channel resolution @@ -26,28 +26,28 @@ func currentRadioScope(_ bridge: IntentBridge) -> (radioID: UUID, store: Persist /// or more than one channel fails safe to no match rather than guessing, so a /// relocated or duplicated channel can never mis-route a send. func resolveUniqueChannels(matching ids: [String], in channels: [ChannelDTO]) -> [ChannelDTO] { - let wanted = Set(ids) - var matchesByID: [String: [ChannelDTO]] = [:] - for channel in channels { - let id = formatCompositeID( - radioID: channel.radioID, - kind: .channel, - keyHex: channelSecretDigestHex(radioID: channel.radioID, secret: channel.secret) - ) - guard wanted.contains(id) else { continue } - matchesByID[id, default: []].append(channel) - } - return wanted.compactMap { id in - guard let matches = matchesByID[id], matches.count == 1 else { return nil } - return matches.first - } + let wanted = Set(ids) + var matchesByID: [String: [ChannelDTO]] = [:] + for channel in channels { + let id = formatCompositeID( + radioID: channel.radioID, + kind: .channel, + keyHex: channelSecretDigestHex(radioID: channel.radioID, secret: channel.secret) + ) + guard wanted.contains(id) else { continue } + matchesByID[id, default: []].append(channel) + } + return wanted.compactMap { id in + guard let matches = matchesByID[id], matches.count == 1 else { return nil } + return matches.first + } } // MARK: - Picker list /// Chat contacts only: a DM target must be a person, never a repeater or room. func chatContacts(_ contacts: [ContactDTO]) -> [ContactDTO] { - contacts.filter { $0.type == .chat } + contacts.filter { $0.type == .chat } } /// Resolves saved-shortcut ids against fetched DTOs the way `entities(for:)` @@ -56,27 +56,27 @@ func chatContacts(_ contacts: [ContactDTO]) -> [ContactDTO] { /// zero/duplicate-digest fail-safe. The two strategies are distinct and must not /// collapse: a channel digest can collide across rows, a public-key id cannot. func resolveMessageTargets(matching ids: [String], contacts: [ContactDTO], channels: [ChannelDTO]) -> [MessageTargetEntity] { - let wanted = Set(ids) - let contactIDs = wanted.filter { parseCompositeID($0)?.kind == .contact } - let channelIDs = wanted.filter { parseCompositeID($0)?.kind == .channel } - let contactMatches = contacts - .map(MessageTargetEntity.init(dto:)) - .filter { contactIDs.contains($0.id) } - let channelMatches = resolveUniqueChannels(matching: Array(channelIDs), in: channels) - .map(MessageTargetEntity.init(dto:)) - return contactMatches + channelMatches + let wanted = Set(ids) + let contactIDs = wanted.filter { parseCompositeID($0)?.kind == .contact } + let channelIDs = wanted.filter { parseCompositeID($0)?.kind == .channel } + let contactMatches = contacts + .map(MessageTargetEntity.init(dto:)) + .filter { contactIDs.contains($0.id) } + let channelMatches = resolveUniqueChannels(matching: Array(channelIDs), in: channels) + .map(MessageTargetEntity.init(dto:)) + return contactMatches + channelMatches } /// The combined recipient-picker list: contacts then channels, each sorted by /// display name so the order is stable across cold launches. Channels whose /// secret digest collides are omitted because the send path cannot resolve them. func buildMessageTargets(contacts: [ContactDTO], channels: [ChannelDTO]) -> [MessageTargetEntity] { - func byDisplayName(_ lhs: MessageTargetEntity, _ rhs: MessageTargetEntity) -> Bool { - lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending - } - let contactEntities = contacts.map(MessageTargetEntity.init(dto:)).sorted(by: byDisplayName) - let channelEntities = resolveUniqueChannels(matching: channels.map { MessageTargetEntity(dto: $0).id }, in: channels) - .map(MessageTargetEntity.init(dto:)) - .sorted(by: byDisplayName) - return contactEntities + channelEntities + func byDisplayName(_ lhs: MessageTargetEntity, _ rhs: MessageTargetEntity) -> Bool { + lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + let contactEntities = contacts.map(MessageTargetEntity.init(dto:)).sorted(by: byDisplayName) + let channelEntities = resolveUniqueChannels(matching: channels.map { MessageTargetEntity(dto: $0).id }, in: channels) + .map(MessageTargetEntity.init(dto:)) + .sorted(by: byDisplayName) + return contactEntities + channelEntities } diff --git a/MC1/Intents/SendAdvertIntent.swift b/MC1/Intents/SendAdvertIntent.swift index 92a30736..47431f14 100644 --- a/MC1/Intents/SendAdvertIntent.swift +++ b/MC1/Intents/SendAdvertIntent.swift @@ -10,66 +10,66 @@ import MC1Services /// an advert has no delivery ACK, so the radio queuing the command is the /// terminal success state, the same as a channel broadcast. struct SendAdvertIntent: AppIntent { - static let title = LocalizedStringResource("intent.advert.title", table: "Tools") - static let description = IntentDescription( - LocalizedStringResource("intent.advert.description", table: "Tools") - ) - static let openAppWhenRun = false + static let title = LocalizedStringResource("intent.advert.title", table: "Tools") + static let description = IntentDescription( + LocalizedStringResource("intent.advert.description", table: "Tools") + ) + static let openAppWhenRun = false - /// The advert transmits the radio's live GPS, so it must run only when this - /// iPhone is unlocked, not merely an authenticated companion device. - static let authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication + /// The advert transmits the radio's live GPS, so it must run only when this + /// iPhone is unlocked, not merely an authenticated companion device. + static let authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication - @Parameter(title: LocalizedStringResource("intent.advert.param.reach", table: "Tools"), default: .zeroHop) - var reach: AdvertReach + @Parameter(title: LocalizedStringResource("intent.advert.param.reach", table: "Tools"), default: .zeroHop) + var reach: AdvertReach - @Dependency var bridge: IntentBridge + @Dependency var bridge: IntentBridge - static var parameterSummary: some ParameterSummary { - Summary("Send a \(\.$reach) advert", table: "Tools") - } + static var parameterSummary: some ParameterSummary { + Summary("Send a \(\.$reach) advert", table: "Tools") + } - @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { - guard let appState = bridge.appState else { throw IntentError.notConnected } - do { - try await appState.sendSelfAdvert(flood: reach.sendsFlood, allowLocationPrompt: false) - } catch { - throw Self.mapToIntentError(error) - } - return .result(dialog: IntentDialog(stringLiteral: Self.successDialog(for: reach))) + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let appState = bridge.appState else { throw IntentError.notConnected } + do { + try await appState.sendSelfAdvert(flood: reach.sendsFlood, allowLocationPrompt: false) + } catch { + throw Self.mapToIntentError(error) } + return .result(dialog: IntentDialog(stringLiteral: Self.successDialog(for: reach))) + } - /// The spoken confirmation, branched on reach. Pure so it is unit-assertable - /// without the framework's perform machinery. - static func successDialog(for reach: AdvertReach) -> String { - switch reach { - case .zeroHop: L10n.Tools.Intent.Advert.Dialog.sentZeroHop - case .flood: L10n.Tools.Intent.Advert.Dialog.sentFlood - } + /// The spoken confirmation, branched on reach. Pure so it is unit-assertable + /// without the framework's perform machinery. + static func successDialog(for reach: AdvertReach) -> String { + switch reach { + case .zeroHop: L10n.Tools.Intent.Advert.Dialog.sentZeroHop + case .flood: L10n.Tools.Intent.Advert.Dialog.sentFlood } + } - /// Maps any error from the send onto the localized `IntentError` surface so - /// Siri never speaks a raw error. The service layer rewraps only - /// `MeshCoreError`, so a mid-advert radio drop can surface a raw transport - /// error here; anything unrecognized maps to the generic advert failure. - static func mapToIntentError(_ error: Error) -> IntentError { - switch error { - case let intentError as IntentError: intentError - case let advertError as AdvertisementError: mapToIntentError(advertError) - case let meshError as MeshCoreError: .sessionError(meshError) - default: .advertFailed - } + /// Maps any error from the send onto the localized `IntentError` surface so + /// Siri never speaks a raw error. The service layer rewraps only + /// `MeshCoreError`, so a mid-advert radio drop can surface a raw transport + /// error here; anything unrecognized maps to the generic advert failure. + static func mapToIntentError(_ error: Error) -> IntentError { + switch error { + case let intentError as IntentError: intentError + case let advertError as AdvertisementError: mapToIntentError(advertError) + case let meshError as MeshCoreError: .sessionError(meshError) + default: .advertFailed } + } - /// Maps the advert service's errors onto the localized `IntentError` surface. - /// Typed and exhaustive so an unexpected case can't silently collapse to the - /// wrong message; `.sendFailed` and `.invalidResponse` share one user line. - static func mapToIntentError(_ error: AdvertisementError) -> IntentError { - switch error { - case .notConnected: .notConnected - case .sessionError(let meshError): .sessionError(meshError) - case .sendFailed, .invalidResponse: .advertFailed - } + /// Maps the advert service's errors onto the localized `IntentError` surface. + /// Typed and exhaustive so an unexpected case can't silently collapse to the + /// wrong message; `.sendFailed` and `.invalidResponse` share one user line. + static func mapToIntentError(_ error: AdvertisementError) -> IntentError { + switch error { + case .notConnected: .notConnected + case let .sessionError(meshError): .sessionError(meshError) + case .sendFailed, .invalidResponse: .advertFailed } + } } diff --git a/MC1/Intents/SendMessageIntent+Routing.swift b/MC1/Intents/SendMessageIntent+Routing.swift index b6afd416..90fdb4e2 100644 --- a/MC1/Intents/SendMessageIntent+Routing.swift +++ b/MC1/Intents/SendMessageIntent+Routing.swift @@ -2,92 +2,91 @@ import Foundation import MC1Services extension SendMessageIntent { - - /// Classifies a send purely from the connection rung. `.connected` is - /// deliberately treated as not-yet-ready (services are built after the rung - /// flips to `.connected`), so it never takes the queue path. - static func route(for state: DeviceConnectionState) -> SendRoute { - switch state { - case .ready: .headlessQueue - case .syncing: .queueAfterSync - case .connected, .connecting: .foregroundEscalate - case .disconnected: .notConnected - } + /// Classifies a send purely from the connection rung. `.connected` is + /// deliberately treated as not-yet-ready (services are built after the rung + /// flips to `.connected`), so it never takes the queue path. + static func route(for state: DeviceConnectionState) -> SendRoute { + switch state { + case .ready: .headlessQueue + case .syncing: .queueAfterSync + case .connected, .connecting: .foregroundEscalate + case .disconnected: .notConnected } + } - /// Splits the disconnected rung on whether a prior radio is restorable: with - /// a last-connected radio the send foreground-escalates so the app reconnects; - /// with nothing to restore, retrying cannot help, so the send throws. - static func disconnectedRoute(hasRestorableRadio: Bool) -> SendRoute { - hasRestorableRadio ? .foregroundEscalate : .notConnected - } + /// Splits the disconnected rung on whether a prior radio is restorable: with + /// a last-connected radio the send foreground-escalates so the app reconnects; + /// with nothing to restore, retrying cannot help, so the send throws. + static func disconnectedRoute(hasRestorableRadio: Bool) -> SendRoute { + hasRestorableRadio ? .foregroundEscalate : .notConnected + } - /// Rejects a message that cannot be sent to the chosen recipient: only a chat - /// contact can receive a DM (a repeater or room cannot), and channel text is - /// capped at the node-name-adjusted budget. The firmware prepends - /// `": "` to every channel broadcast, so the usable text is the - /// total length minus the node name and its separator; validating against the - /// full total would let a near-limit message pass and then be silently - /// truncated on the air. DM length is enforced downstream by - /// `createPendingMessage`. - static func validate(message: String, for recipient: MessageRecipient, nodeNameByteCount: Int) throws { - switch recipient { - case .contact(let dto): - guard dto.type == .chat else { throw IntentError.invalidRecipient } - case .channel: - let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: nodeNameByteCount) - guard message.utf8.count <= maxBytes else { - throw IntentError.messageTooLong - } - } + /// Rejects a message that cannot be sent to the chosen recipient: only a chat + /// contact can receive a DM (a repeater or room cannot), and channel text is + /// capped at the node-name-adjusted budget. The firmware prepends + /// `": "` to every channel broadcast, so the usable text is the + /// total length minus the node name and its separator; validating against the + /// full total would let a near-limit message pass and then be silently + /// truncated on the air. DM length is enforced downstream by + /// `createPendingMessage`. + static func validate(message: String, for recipient: MessageRecipient, nodeNameByteCount: Int) throws { + switch recipient { + case let .contact(dto): + guard dto.type == .chat else { throw IntentError.invalidRecipient } + case .channel: + let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: nodeNameByteCount) + guard message.utf8.count <= maxBytes else { + throw IntentError.messageTooLong + } } + } - /// The confirmation prompt naming the recipient, spoken before the send so a - /// broadcast under the user's identity is always deliberate. - static func confirmText(for recipient: MessageRecipient) -> String { - L10n.Tools.Intent.Send.confirm(recipientName(for: recipient)) - } + /// The confirmation prompt naming the recipient, spoken before the send so a + /// broadcast under the user's identity is always deliberate. + static func confirmText(for recipient: MessageRecipient) -> String { + L10n.Tools.Intent.Send.confirm(recipientName(for: recipient)) + } - /// Rewraps a reused-service error into a localized `IntentError` so Siri - /// never speaks a raw `MessageServiceError`/`ChannelServiceError`/ - /// `ChatSendQueueServiceError`. A failure to persist or enqueue a connected - /// send maps to `.sendFailed`, never `.notConnected`, so the spoken line - /// doesn't blame the connection for a local write that failed. - static func mapToIntentError(_ error: Error) -> IntentError { - switch error { - case let intentError as IntentError: - return intentError - case let serviceError as MessageServiceError: - switch serviceError { - case .notConnected: return .notConnected - case .invalidRecipient, .contactNotFound, .channelNotFound: return .invalidRecipient - case .messageTooLong: return .messageTooLong - case .sessionError(let underlying): return .sessionError(underlying) - case .sendFailed: return .sendFailed - } - case let serviceError as ChannelServiceError: - switch serviceError { - case .notConnected: return .notConnected - case .channelNotFound, .invalidChannelIndex: return .invalidRecipient - case .sessionError(let underlying): return .sessionError(underlying) - case .secretHashingFailed, .saveFailed, .sendFailed, - .syncAlreadyInProgress, .circuitBreakerOpen: - return .sendFailed - } - case let queueError as ChatSendQueueServiceError: - switch queueError { - case .notConnected: return .notConnected - case .persistFailed: return .sendFailed - } - default: - return .sendFailed - } + /// Rewraps a reused-service error into a localized `IntentError` so Siri + /// never speaks a raw `MessageServiceError`/`ChannelServiceError`/ + /// `ChatSendQueueServiceError`. A failure to persist or enqueue a connected + /// send maps to `.sendFailed`, never `.notConnected`, so the spoken line + /// doesn't blame the connection for a local write that failed. + static func mapToIntentError(_ error: Error) -> IntentError { + switch error { + case let intentError as IntentError: + intentError + case let serviceError as MessageServiceError: + switch serviceError { + case .notConnected: .notConnected + case .invalidRecipient, .contactNotFound, .channelNotFound: .invalidRecipient + case .messageTooLong: .messageTooLong + case let .sessionError(underlying): .sessionError(underlying) + case .sendFailed: .sendFailed + } + case let serviceError as ChannelServiceError: + switch serviceError { + case .notConnected: .notConnected + case .channelNotFound, .invalidChannelIndex: .invalidRecipient + case let .sessionError(underlying): .sessionError(underlying) + case .secretHashingFailed, .saveFailed, .sendFailed, + .syncAlreadyInProgress, .circuitBreakerOpen: + .sendFailed + } + case let queueError as ChatSendQueueServiceError: + switch queueError { + case .notConnected: .notConnected + case .persistFailed: .sendFailed + } + default: + .sendFailed } + } - private static func recipientName(for recipient: MessageRecipient) -> String { - switch recipient { - case .contact(let dto): dto.displayName - case .channel(let dto): dto.displayName - } + private static func recipientName(for recipient: MessageRecipient) -> String { + switch recipient { + case let .contact(dto): dto.displayName + case let .channel(dto): dto.displayName } + } } diff --git a/MC1/Intents/SendMessageIntent.swift b/MC1/Intents/SendMessageIntent.swift index 93138635..682511ef 100644 --- a/MC1/Intents/SendMessageIntent.swift +++ b/MC1/Intents/SendMessageIntent.swift @@ -10,204 +10,204 @@ import MC1Services /// foreground or throws a localized error rather than silently dropping a /// message the user believes was sent. struct SendMessageIntent: AppIntent { - static let title = LocalizedStringResource("intent.send.title", table: "Tools") - static let description = IntentDescription( - LocalizedStringResource("intent.send.description", table: "Tools") - ) - static let openAppWhenRun = false - - /// A send broadcasts under the user's identity, so it must not run from a - /// locked device; this gates running the send, not entity-picker resolution. - static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication - - @Parameter(title: LocalizedStringResource("intent.send.param.target", table: "Tools")) - var target: MessageTargetEntity - - @Parameter(title: LocalizedStringResource("intent.send.param.message", table: "Tools")) - var message: String - - @Dependency var bridge: IntentBridge - - static var parameterSummary: some ParameterSummary { - Summary("Send \(\.$message) to \(\.$target)", table: "Tools") - } - - @MainActor - func perform() async throws -> some IntentResult { - try await executeSend() - return .result() + static let title = LocalizedStringResource("intent.send.title", table: "Tools") + static let description = IntentDescription( + LocalizedStringResource("intent.send.description", table: "Tools") + ) + static let openAppWhenRun = false + + /// A send broadcasts under the user's identity, so it must not run from a + /// locked device; this gates running the send, not entity-picker resolution. + static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + + @Parameter(title: LocalizedStringResource("intent.send.param.target", table: "Tools")) + var target: MessageTargetEntity + + @Parameter(title: LocalizedStringResource("intent.send.param.message", table: "Tools")) + var message: String + + @Dependency var bridge: IntentBridge + + static var parameterSummary: some ParameterSummary { + Summary("Send \(\.$message) to \(\.$target)", table: "Tools") + } + + @MainActor + func perform() async throws -> some IntentResult { + try await executeSend() + return .result() + } + + /// Drives the readiness matrix to a confirmation-gated send, a foreground + /// handoff, or a thrown localized `IntentError`. A successful queue returns + /// no spoken result: the confirmation prompt is the only interaction, and the + /// message's true status surfaces in the chat rather than as a second popup. + @MainActor + private func executeSend() async throws { + guard let appState = bridge.appState else { + try await continueInForeground() + return } - /// Drives the readiness matrix to a confirmation-gated send, a foreground - /// handoff, or a thrown localized `IntentError`. A successful queue returns - /// no spoken result: the confirmation prompt is the only interaction, and the - /// message's true status surfaces in the chat rather than as a second popup. - @MainActor - private func executeSend() async throws { - guard let appState = bridge.appState else { - try await continueInForeground() - return - } - - // Classify the route before resolving a recipient: only the queue paths - // need one, and resolution depends on a live radio scope, so resolving it - // up front would surface `invalidRecipient` on a disconnected radio where - // the route is the true diagnosis. A drop during the later confirmation - // await is re-checked inside `performSend`. - let route = Self.route(for: appState.connectionManager.connectionState) - switch route { - case .headlessQueue, .queueAfterSync: - let recipient = try await resolveRecipient() - let nodeNameByteCount = appState.connectedDevice?.nodeName.utf8.count ?? 0 - try Self.validate(message: message, for: recipient, nodeNameByteCount: nodeNameByteCount) - try await requestConfirmation(dialog: IntentDialog(stringLiteral: Self.confirmText(for: recipient))) - switch try await Self.performSend(message: message, recipient: recipient, in: appState) { - case .queued: - return - case .mustForeground: - try await continueInForeground() - } - case .foregroundEscalate: - try await continueInForeground() - case .notConnected: - let hasRestorableRadio = appState.connectionManager.lastConnectedRadioID != nil - switch Self.disconnectedRoute(hasRestorableRadio: hasRestorableRadio) { - case .foregroundEscalate: - try await continueInForeground() - default: - throw IntentError.notConnected - } - } + // Classify the route before resolving a recipient: only the queue paths + // need one, and resolution depends on a live radio scope, so resolving it + // up front would surface `invalidRecipient` on a disconnected radio where + // the route is the true diagnosis. A drop during the later confirmation + // await is re-checked inside `performSend`. + let route = Self.route(for: appState.connectionManager.connectionState) + switch route { + case .headlessQueue, .queueAfterSync: + let recipient = try await resolveRecipient() + let nodeNameByteCount = appState.connectedDevice?.nodeName.utf8.count ?? 0 + try Self.validate(message: message, for: recipient, nodeNameByteCount: nodeNameByteCount) + try await requestConfirmation(dialog: IntentDialog(stringLiteral: Self.confirmText(for: recipient))) + switch try await Self.performSend(message: message, recipient: recipient, in: appState) { + case .queued: + return + case .mustForeground: + try await continueInForeground() + } + case .foregroundEscalate: + try await continueInForeground() + case .notConnected: + let hasRestorableRadio = appState.connectionManager.lastConnectedRadioID != nil + switch Self.disconnectedRoute(hasRestorableRadio: hasRestorableRadio) { + case .foregroundEscalate: + try await continueInForeground() + default: + throw IntentError.notConnected + } } - - // MARK: - Recipient resolution - - /// Re-resolves the chosen target id against the currently connected radio, - /// carrying only a Sendable DTO across the actor boundary. Routing keys on the - /// kind parsed from the id, never the entity's in-memory `kind`, and re-fetches - /// (rather than trusting the saved-shortcut entity) so a stale or other-radio - /// id can't orphan the message on a radio it doesn't belong to. An - /// unparseable id fails safe to `invalidRecipient`. - @MainActor - private func resolveRecipient() async throws -> MessageRecipient { - switch parseCompositeID(target.id)?.kind { - case .contact: - guard let dto = try await liveContact(for: target) else { - throw IntentError.invalidRecipient - } - return .contact(dto) - case .channel: - guard let dto = try await liveChannel(for: target) else { - throw IntentError.invalidRecipient - } - return .channel(dto) - case nil: - throw IntentError.invalidRecipient - } + } + + // MARK: - Recipient resolution + + /// Re-resolves the chosen target id against the currently connected radio, + /// carrying only a Sendable DTO across the actor boundary. Routing keys on the + /// kind parsed from the id, never the entity's in-memory `kind`, and re-fetches + /// (rather than trusting the saved-shortcut entity) so a stale or other-radio + /// id can't orphan the message on a radio it doesn't belong to. An + /// unparseable id fails safe to `invalidRecipient`. + @MainActor + private func resolveRecipient() async throws -> MessageRecipient { + switch parseCompositeID(target.id)?.kind { + case .contact: + guard let dto = try await liveContact(for: target) else { + throw IntentError.invalidRecipient + } + return .contact(dto) + case .channel: + guard let dto = try await liveChannel(for: target) else { + throw IntentError.invalidRecipient + } + return .channel(dto) + case nil: + throw IntentError.invalidRecipient } - - @MainActor - private func liveContact(for entity: MessageTargetEntity) async throws -> ContactDTO? { - guard let scope = currentRadioScope(bridge) else { return nil } - let contacts = try await scope.store.fetchContacts(radioID: scope.radioID) - return contacts.first { MessageTargetEntity(dto: $0).id == entity.id } - } - - @MainActor - private func liveChannel(for entity: MessageTargetEntity) async throws -> ChannelDTO? { - guard let scope = currentRadioScope(bridge) else { return nil } - let channels = try await scope.store.fetchChannels(radioID: scope.radioID) - return resolveUniqueChannels(matching: [entity.id], in: channels).first - } - - // MARK: - Enqueue - - /// Performs the durable-queue send after confirmation, re-reading services - /// first. `requestConfirmation` is an unbounded user-driven await, and a drop - /// during it nils `services` synchronously, so a nil read here means the - /// radio is no longer ready: the send returns `.mustForeground` so the caller - /// escalates rather than reporting a queued message that never enqueued. - /// Reached only with a confirmed-live decision; carries Sendable DTOs. - @MainActor - static func performSend( - message: String, - recipient: MessageRecipient, - in appState: AppState - ) async throws -> SendOutcome { - guard let services = appState.services else { return .mustForeground } - // The recipient was resolved against one radio before the confirmation - // await; a switch to another radio during it leaves services non-nil for - // the new radio. Enqueuing here would scope the message row to the old - // radio and the PendingSend to the new one, mis-routing the send, so a - // radio change foreground-escalates exactly like a dropped connection. - guard appState.currentRadioID == recipient.radioID else { return .mustForeground } - // Re-validate against the node name the envelope is about to capture. The - // budget checked before the unbounded confirmation await can be stale: a - // rename or reconnect during it changes the firmware-prepended - // ": " length, and a now-too-long channel message would - // otherwise be silently truncated on the air. - let nodeNameByteCount = appState.connectedDevice?.nodeName.utf8.count ?? 0 - try validate(message: message, for: recipient, nodeNameByteCount: nodeNameByteCount) - - let pending: MessageDTO - do { - switch recipient { - case .contact(let dto): - pending = try await services.messageService.createPendingMessage(text: message, to: dto) - case .channel(let dto): - pending = try await services.messageService.createPendingChannelMessage( - text: message, - channelIndex: dto.index, - radioID: dto.radioID - ) - } - } catch { - throw mapToIntentError(error) - } - - do { - switch recipient { - case .contact(let dto): - try await services.chatSendQueueService.enqueueDM( - DirectMessageEnvelope(messageID: pending.id, contactID: dto.id) - ) - case .channel(let dto): - try await services.chatSendQueueService.enqueueChannel( - ChannelMessageEnvelope( - messageID: pending.id, - channelIndex: dto.index, - isResend: false, - messageText: pending.text, - messageTimestamp: pending.timestamp, - localNodeName: appState.connectedDevice?.nodeName - ) - ) - } - } catch { - // The row is already persisted as `.pending`, but the enqueue write - // failed so no `PendingSend` backs it and the drain never sends it. - // Mark it `.failed` so it surfaces a retry instead of hanging pending, - // the same recovery the chat send path performs. - _ = try? await services.dataStore.updateMessageStatusUnlessDelivered(id: pending.id, status: .failed) - throw mapToIntentError(error) - } - // A headless send never crosses the chat view model that an in-app send - // refreshes through, so announce the change here or the Chats list keeps - // its cached preview until an unrelated reload fires. - appState.refreshConversations() - return .queued + } + + @MainActor + private func liveContact(for entity: MessageTargetEntity) async throws -> ContactDTO? { + guard let scope = currentRadioScope(bridge) else { return nil } + let contacts = try await scope.store.fetchContacts(radioID: scope.radioID) + return contacts.first { MessageTargetEntity(dto: $0).id == entity.id } + } + + @MainActor + private func liveChannel(for entity: MessageTargetEntity) async throws -> ChannelDTO? { + guard let scope = currentRadioScope(bridge) else { return nil } + let channels = try await scope.store.fetchChannels(radioID: scope.radioID) + return resolveUniqueChannels(matching: [entity.id], in: channels).first + } + + // MARK: - Enqueue + + /// Performs the durable-queue send after confirmation, re-reading services + /// first. `requestConfirmation` is an unbounded user-driven await, and a drop + /// during it nils `services` synchronously, so a nil read here means the + /// radio is no longer ready: the send returns `.mustForeground` so the caller + /// escalates rather than reporting a queued message that never enqueued. + /// Reached only with a confirmed-live decision; carries Sendable DTOs. + @MainActor + static func performSend( + message: String, + recipient: MessageRecipient, + in appState: AppState + ) async throws -> SendOutcome { + guard let services = appState.services else { return .mustForeground } + // The recipient was resolved against one radio before the confirmation + // await; a switch to another radio during it leaves services non-nil for + // the new radio. Enqueuing here would scope the message row to the old + // radio and the PendingSend to the new one, mis-routing the send, so a + // radio change foreground-escalates exactly like a dropped connection. + guard appState.currentRadioID == recipient.radioID else { return .mustForeground } + // Re-validate against the node name the envelope is about to capture. The + // budget checked before the unbounded confirmation await can be stale: a + // rename or reconnect during it changes the firmware-prepended + // ": " length, and a now-too-long channel message would + // otherwise be silently truncated on the air. + let nodeNameByteCount = appState.connectedDevice?.nodeName.utf8.count ?? 0 + try validate(message: message, for: recipient, nodeNameByteCount: nodeNameByteCount) + + let pending: MessageDTO + do { + switch recipient { + case let .contact(dto): + pending = try await services.messageService.createPendingMessage(text: message, to: dto) + case let .channel(dto): + pending = try await services.messageService.createPendingChannelMessage( + text: message, + channelIndex: dto.index, + radioID: dto.radioID + ) + } + } catch { + throw mapToIntentError(error) } - /// Foregrounds the app (still launching, connecting, or a restorable - /// disconnect) so the user can finish the send there, speaking the handoff - /// prompt as it hands off. The dictated text is not carried across: this - /// hands control to the app rather than enqueuing the message itself. - @MainActor - private func continueInForeground() async throws { - try await requestToContinueInForeground( - IntentDialog(stringLiteral: L10n.Tools.Intent.Send.foreground) + do { + switch recipient { + case let .contact(dto): + try await services.chatSendQueueService.enqueueDM( + DirectMessageEnvelope(messageID: pending.id, contactID: dto.id) ) + case let .channel(dto): + try await services.chatSendQueueService.enqueueChannel( + ChannelMessageEnvelope( + messageID: pending.id, + channelIndex: dto.index, + isResend: false, + messageText: pending.text, + messageTimestamp: pending.timestamp, + localNodeName: appState.connectedDevice?.nodeName + ) + ) + } + } catch { + // The row is already persisted as `.pending`, but the enqueue write + // failed so no `PendingSend` backs it and the drain never sends it. + // Mark it `.failed` so it surfaces a retry instead of hanging pending, + // the same recovery the chat send path performs. + _ = try? await services.dataStore.updateMessageStatusUnlessDelivered(id: pending.id, status: .failed) + throw mapToIntentError(error) } + // A headless send never crosses the chat view model that an in-app send + // refreshes through, so announce the change here or the Chats list keeps + // its cached preview until an unrelated reload fires. + appState.refreshConversations() + return .queued + } + + /// Foregrounds the app (still launching, connecting, or a restorable + /// disconnect) so the user can finish the send there, speaking the handoff + /// prompt as it hands off. The dictated text is not carried across: this + /// hands control to the app rather than enqueuing the message itself. + @MainActor + private func continueInForeground() async throws { + try await requestToContinueInForeground( + IntentDialog(stringLiteral: L10n.Tools.Intent.Send.foreground) + ) + } } @available(iOSApplicationExtension, unavailable) diff --git a/MC1/Intents/SendOutcome.swift b/MC1/Intents/SendOutcome.swift index 3aeaa4b3..5c37e579 100644 --- a/MC1/Intents/SendOutcome.swift +++ b/MC1/Intents/SendOutcome.swift @@ -2,6 +2,6 @@ /// services re-read maps to `.mustForeground` so the caller escalates instead of /// reporting a queued message that was never enqueued. enum SendOutcome: Equatable { - case queued - case mustForeground + case queued + case mustForeground } diff --git a/MC1/Intents/SendRoute.swift b/MC1/Intents/SendRoute.swift index a5be9fe2..78c870a6 100644 --- a/MC1/Intents/SendRoute.swift +++ b/MC1/Intents/SendRoute.swift @@ -5,16 +5,16 @@ import MC1Services /// This shadows nothing: it maps the existing connection rungs onto the one /// branch the send cares about (durable queue vs foreground vs throw). enum SendRoute: Equatable { - /// `.ready`: the queue drains now, so enqueue headlessly. - case headlessQueue - /// `.syncing`: services exist and the row persists, but the queue drains - /// only once sync clears, so the dialog says "after sync". - case queueAfterSync - /// `.connected` (services may be nil) or `.connecting`: do not enqueue - /// through a possibly-nil service; foreground-escalate instead. - case foregroundEscalate - /// `.disconnected` with nothing to restore: retrying cannot help, so throw. - /// A restorable disconnect instead resolves to `foregroundEscalate` via - /// `disconnectedRoute(hasRestorableRadio:)`. - case notConnected + /// `.ready`: the queue drains now, so enqueue headlessly. + case headlessQueue + /// `.syncing`: services exist and the row persists, but the queue drains + /// only once sync clears, so the dialog says "after sync". + case queueAfterSync + /// `.connected` (services may be nil) or `.connecting`: do not enqueue + /// through a possibly-nil service; foreground-escalate instead. + case foregroundEscalate + /// `.disconnected` with nothing to restore: retrying cannot help, so throw. + /// A restorable disconnect instead resolves to `foregroundEscalate` via + /// `disconnectedRoute(hasRestorableRadio:)`. + case notConnected } diff --git a/MC1/Intents/StatusQueryIntent.swift b/MC1/Intents/StatusQueryIntent.swift index 13d55bbf..fd39bf2f 100644 --- a/MC1/Intents/StatusQueryIntent.swift +++ b/MC1/Intents/StatusQueryIntent.swift @@ -8,62 +8,62 @@ import MC1Services /// pocketed responder wants it. The battery value is honestly staleness-labeled /// because it is whatever the last background poll read, not a fresh reading. struct StatusQueryIntent: AppIntent { - static let title = LocalizedStringResource("intent.status.title", table: "Tools") - static let description = IntentDescription( - LocalizedStringResource("intent.status.description", table: "Tools") - ) - static let openAppWhenRun = false + static let title = LocalizedStringResource("intent.status.title", table: "Tools") + static let description = IntentDescription( + LocalizedStringResource("intent.status.description", table: "Tools") + ) + static let openAppWhenRun = false - /// Reads only cached `@Observable` state and never touches the radio, so it is - /// safe to answer from the lock screen. Without this the default - /// `.requiresAuthentication` would force an unlock first, defeating the - /// pocketed-responder glance the intent exists for. - static let authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed + /// Reads only cached `@Observable` state and never touches the radio, so it is + /// safe to answer from the lock screen. Without this the default + /// `.requiresAuthentication` would force an unlock first, defeating the + /// pocketed-responder glance the intent exists for. + static let authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed - @Dependency var bridge: IntentBridge + @Dependency var bridge: IntentBridge - @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { - guard let appState = bridge.appState else { - return .result(dialog: IntentDialog(stringLiteral: L10n.Tools.Intent.Status.Dialog.notReady)) - } - return .result(dialog: IntentDialog(stringLiteral: Self.dialogText(for: appState))) + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let appState = bridge.appState else { + return .result(dialog: IntentDialog(stringLiteral: L10n.Tools.Intent.Status.Dialog.notReady)) } + return .result(dialog: IntentDialog(stringLiteral: Self.dialogText(for: appState))) + } - /// Builds the spoken line from synchronous `@Observable` state only. Connected - /// rungs report cached battery (or "no reading" when the radio is mains-powered - /// or the value is absent); other rungs report the connection state with the - /// best offline-readable radio name. - @MainActor - static func dialogText(for appState: AppState) -> String { - let connectionState = appState.connectionManager.connectionState + /// Builds the spoken line from synchronous `@Observable` state only. Connected + /// rungs report cached battery (or "no reading" when the radio is mains-powered + /// or the value is absent); other rungs report the connection state with the + /// best offline-readable radio name. + @MainActor + static func dialogText(for appState: AppState) -> String { + let connectionState = appState.connectionManager.connectionState - if connectionState.isConnected { - let name = appState.connectedDevice?.nodeName ?? offlineName(for: appState) ?? L10n.Tools.Intent.Status.radioFallbackName - guard let battery = appState.batteryMonitor.deviceBattery, - battery.isBatteryPresent else { - return L10n.Tools.Intent.Status.Dialog.connectedNoBattery(name) - } - let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) - let percent = battery.percentage(using: ocvArray) - return L10n.Tools.Intent.Status.Dialog.connectedWithBattery(name, percent) - } - - if connectionState == .connecting { - let name = offlineName(for: appState) ?? L10n.Tools.Intent.Status.radioFallbackName - return L10n.Tools.Intent.Status.Dialog.connecting(name) - } + if connectionState.isConnected { + let name = appState.connectedDevice?.nodeName ?? offlineName(for: appState) ?? L10n.Tools.Intent.Status.radioFallbackName + guard let battery = appState.batteryMonitor.deviceBattery, + battery.isBatteryPresent else { + return L10n.Tools.Intent.Status.Dialog.connectedNoBattery(name) + } + let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) + let percent = battery.percentage(using: ocvArray) + return L10n.Tools.Intent.Status.Dialog.connectedWithBattery(name, percent) + } - if let name = offlineName(for: appState) { - return L10n.Tools.Intent.Status.Dialog.disconnectedNamed(name) - } - return L10n.Tools.Intent.Status.Dialog.disconnectedUnknown + if connectionState == .connecting { + let name = offlineName(for: appState) ?? L10n.Tools.Intent.Status.radioFallbackName + return L10n.Tools.Intent.Status.Dialog.connecting(name) } - /// Last-connected radio name for offline display, or nil when this install has - /// never connected so a caller can speak a generic fallback instead. - @MainActor - private static func offlineName(for appState: AppState) -> String? { - appState.connectionManager.lastConnectedDeviceName + if let name = offlineName(for: appState) { + return L10n.Tools.Intent.Status.Dialog.disconnectedNamed(name) } + return L10n.Tools.Intent.Status.Dialog.disconnectedUnknown + } + + /// Last-connected radio name for offline display, or nil when this install has + /// never connected so a caller can speak a generic fallback instead. + @MainActor + private static func offlineName(for appState: AppState) -> String? { + appState.connectionManager.lastConnectedDeviceName + } } diff --git a/MC1/MC1App.swift b/MC1/MC1App.swift index 204ceae1..b5adbc68 100644 --- a/MC1/MC1App.swift +++ b/MC1/MC1App.swift @@ -1,216 +1,223 @@ -import os import AppIntents -import SwiftUI +import MC1Services +import os import SwiftData +import SwiftUI import TipKit -import MC1Services private let logger = Logger(subsystem: "com.mc1", category: "MC1App") @main struct MC1App: App { - @State private var appState: AppState - @State private var awaitingDataProtection = false - @Environment(\.scenePhase) private var scenePhase - - /// Stable holder that App Intents read through `AppDependencyManager`. It - /// must outlive the before-first-unlock `AppState` swap, so it is a plain - /// stored property registered once in `init()`, not `@State`. - private let intentBridge = IntentBridge() - - init() { - // Register the bridge synchronously here: a background-launched intent - // runs only `App.init` (no scene, no `.task`), so a deferred - // registration could let its `@Dependency` read throw before it lands. - // Capture into a local so the escaping autoclosure does not capture the - // still-initializing `self`. - let intentBridge = self.intentBridge - AppDependencyManager.shared.add(dependency: intentBridge) - - // True only on the before-first-unlock path, where the normal init site - // holds an in-memory throwaway the bridge must not adopt. - var usingThrowawayStore = false - - let container: ModelContainer + @State private var appState: AppState + @State private var awaitingDataProtection = false + @Environment(\.scenePhase) private var scenePhase + + /// Stable holder that App Intents read through `AppDependencyManager`. It + /// must outlive the before-first-unlock `AppState` swap, so it is a plain + /// stored property registered once in `init()`, not `@State`. + private let intentBridge = IntentBridge() + + /// Stages externally opened URLs across launch; see `PendingExternalURL`. + private let pendingExternalURL = PendingExternalURL() + + init() { + // Register the bridge synchronously here: a background-launched intent + // runs only `App.init` (no scene, no `.task`), so a deferred + // registration could let its `@Dependency` read throw before it lands. + // Capture into a local so the escaping autoclosure does not capture the + // still-initializing `self`. + let intentBridge = intentBridge + AppDependencyManager.shared.add(dependency: intentBridge) + + // True only on the before-first-unlock path, where the normal init site + // holds an in-memory throwaway the bridge must not adopt. + var usingThrowawayStore = false + + let container: ModelContainer + do { + container = try PersistenceStore.createContainer() + } catch { + logger.error("Container creation failed: \(error)") + + if UIApplication.shared.isProtectedDataAvailable { + // Data is accessible — this is a genuine failure, not BFU. + // Retry once for transient file system issues. + logger.info("Retrying container creation") do { - container = try PersistenceStore.createContainer() + container = try PersistenceStore.createContainer() } catch { - logger.error("Container creation failed: \(error)") - - if UIApplication.shared.isProtectedDataAvailable { - // Data is accessible — this is a genuine failure, not BFU. - // Retry once for transient file system issues. - logger.info("Retrying container creation") - do { - container = try PersistenceStore.createContainer() - } catch { - let nsError = error as NSError - logger.fault(""" - Container creation failed after retry: \ - domain=\(nsError.domain, privacy: .public) \ - code=\(nsError.code, privacy: .public) \ - desc=\(nsError.localizedDescription, privacy: .public) \ - userInfo=\(String(describing: nsError.userInfo), privacy: .public) - """) - fatalError("ModelContainer creation failed after retry while data is available: \(nsError.domain) \(nsError.code)") - } - let appState = AppState(modelContainer: container) - _appState = State(initialValue: appState) - intentBridge.adopt(appState) - return - } - - // Before first unlock: the encrypted store is inaccessible. Create a throwaway - // in-memory container so the struct can initialize. The .task body will wait for - // data protection and replace this with the real store before doing any work. - logger.warning("Protected data unavailable (before first unlock), deferring initialization") - do { - container = try PersistenceStore.createContainer(inMemory: true) - } catch { - fatalError("In-memory ModelContainer creation failed: \(error)") - } - _awaitingDataProtection = State(initialValue: true) - usingThrowawayStore = true + let nsError = error as NSError + logger.fault(""" + Container creation failed after retry: \ + domain=\(nsError.domain, privacy: .public) \ + code=\(nsError.code, privacy: .public) \ + desc=\(nsError.localizedDescription, privacy: .public) \ + userInfo=\(String(describing: nsError.userInfo), privacy: .public) + """) + fatalError("ModelContainer creation failed after retry while data is available: \(nsError.domain) \(nsError.code)") } let appState = AppState(modelContainer: container) _appState = State(initialValue: appState) - // BFU throwaway: defer adoption to the post-unlock swap so a pre-unlock - // intent reads nil, not an empty store. - if !usingThrowawayStore { - intentBridge.adopt(appState) - } + intentBridge.adopt(appState) + return + } + + // Before first unlock: the encrypted store is inaccessible. Create a throwaway + // in-memory container so the struct can initialize. The .task body will wait for + // data protection and replace this with the real store before doing any work. + logger.warning("Protected data unavailable (before first unlock), deferring initialization") + do { + container = try PersistenceStore.createContainer(inMemory: true) + } catch { + fatalError("In-memory ModelContainer creation failed: \(error)") + } + _awaitingDataProtection = State(initialValue: true) + usingThrowawayStore = true } + let appState = AppState(modelContainer: container) + _appState = State(initialValue: appState) + // BFU throwaway: defer adoption to the post-unlock swap so a pre-unlock + // intent reads nil, not an empty store. + if !usingThrowawayStore { + intentBridge.adopt(appState) + } + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(\.appState, appState) + .environment(\.appTheme, appState.themeService.current) + .tint(appState.themeService.current.chromeTint) + .preferredColorScheme(appState.themeService.effectiveColorScheme) + #if !SIDELOAD + .task(id: ObjectIdentifier(appState)) { await appState.storeState.service.load() } + #endif + .task { + if awaitingDataProtection { + await waitForProtectedData() + do { + let container = try PersistenceStore.createContainer() + // Tear down the BFU-bootstrap AppState's StoreService listener Task + // before swapping in the real AppState — otherwise the bootstrap + // instance's Transaction.updates listener leaks for the process + // lifetime and every later transaction event fires `walkCurrentEntitlements` + // twice (once per orphaned StoreService). + appState.shutdown() + let realAppState = AppState(modelContainer: container) + appState = realAppState + // First real store on the BFU path; the bridge was + // left nil in `init()` until now. + intentBridge.adopt(realAppState) + awaitingDataProtection = false + } catch { + let nsError = error as NSError + logger.fault(""" + Container creation failed after unlock: \ + domain=\(nsError.domain, privacy: .public) \ + code=\(nsError.code, privacy: .public) \ + desc=\(nsError.localizedDescription, privacy: .public) \ + userInfo=\(String(describing: nsError.userInfo), privacy: .public) + """) + fatalError("ModelContainer creation failed after protected data became available: \(nsError.domain) \(nsError.code)") + } + } + + try? Tips.configure([ + .displayFrequency(.immediate) + ]) + + #if DEBUG + if ProcessInfo.processInfo.isScreenshotMode { + await setupScreenshotMode() + } else { + await appState.initialize() + } + #else + await appState.initialize() + #endif - var body: some Scene { - WindowGroup { - ContentView() - .environment(\.appState, appState) - .environment(\.appTheme, appState.themeService.current) - .tint(appState.themeService.current.chromeTint) - .preferredColorScheme(appState.themeService.effectiveColorScheme) - #if !SIDELOAD - .task(id: ObjectIdentifier(appState)) { await appState.storeState.service.load() } - #endif - .task { - if awaitingDataProtection { - await waitForProtectedData() - do { - let container = try PersistenceStore.createContainer() - // Tear down the BFU-bootstrap AppState's StoreService listener Task - // before swapping in the real AppState — otherwise the bootstrap - // instance's Transaction.updates listener leaks for the process - // lifetime and every later transaction event fires `walkCurrentEntitlements` - // twice (once per orphaned StoreService). - appState.shutdown() - let realAppState = AppState(modelContainer: container) - appState = realAppState - // First real store on the BFU path; the bridge was - // left nil in `init()` until now. - intentBridge.adopt(realAppState) - awaitingDataProtection = false - } catch { - let nsError = error as NSError - logger.fault(""" - Container creation failed after unlock: \ - domain=\(nsError.domain, privacy: .public) \ - code=\(nsError.code, privacy: .public) \ - desc=\(nsError.localizedDescription, privacy: .public) \ - userInfo=\(String(describing: nsError.userInfo), privacy: .public) - """) - fatalError("ModelContainer creation failed after protected data became available: \(nsError.domain) \(nsError.code)") - } - } - - try? Tips.configure([ - .displayFrequency(.immediate) - ]) - - #if DEBUG - if ProcessInfo.processInfo.isScreenshotMode { - await setupScreenshotMode() - } else { - await appState.initialize() - } - #else - await appState.initialize() - #endif - - await runInitialForegroundReconciliationIfNeeded() - } - .onOpenURL { _ in - // meshcoreone://status — tapped from Live Activity - // Opening the app is sufficient; future: navigate based on url.host - } - .onChange(of: scenePhase) { oldPhase, newPhase in - handleScenePhaseChange(from: oldPhase, to: newPhase) - } + await runInitialForegroundReconciliationIfNeeded() + pendingExternalURL.markReady(appState) + } + .onOpenURL { url in + pendingExternalURL.submit(url, appState: appState) + } + .onChange(of: scenePhase) { oldPhase, newPhase in + handleScenePhaseChange(from: oldPhase, to: newPhase) } } + } - #if DEBUG && targetEnvironment(simulator) + #if DEBUG && targetEnvironment(simulator) /// Sets up the app for App Store screenshot capture. /// Bypasses onboarding and auto-connects to simulator with mock data. @MainActor private func setupScreenshotMode() async { - // Bypass onboarding - appState.onboarding.hasCompletedOnboarding = true + // Bypass onboarding + appState.onboarding.hasCompletedOnboarding = true - // Persist simulator device ID for auto-reconnect - UserDefaults.standard.set( - MockDataProvider.simulatorDeviceID.uuidString, - forKey: PersistenceKeys.lastConnectedDeviceID - ) + // Persist simulator device ID for auto-reconnect + UserDefaults.standard.set( + MockDataProvider.simulatorDeviceID.uuidString, + forKey: PersistenceKeys.lastConnectedDeviceID + ) - // Initialize app (will auto-connect to simulator device) - await appState.initialize() + // Initialize app (will auto-connect to simulator device) + await appState.initialize() } - #elseif DEBUG + + #elseif DEBUG @MainActor private func setupScreenshotMode() async { - // Screenshot mode only works in simulator - await appState.initialize() + // Screenshot mode only works in simulator + await appState.initialize() } - #endif - - private func waitForProtectedData() async { - guard !UIApplication.shared.isProtectedDataAvailable else { return } - let notification = UIApplication.protectedDataDidBecomeAvailableNotification - await withTaskGroup(of: Void.self) { group in - group.addTask { - for await _ in NotificationCenter.default.notifications(named: notification) { - return - } - } - group.addTask { - while await !UIApplication.shared.isProtectedDataAvailable { - try? await Task.sleep(for: .seconds(1)) - } - } - await group.next() - group.cancelAll() + #endif + + private func waitForProtectedData() async { + guard !UIApplication.shared.isProtectedDataAvailable else { return } + let notification = UIApplication.protectedDataDidBecomeAvailableNotification + await withTaskGroup(of: Void.self) { group in + group.addTask { + for await _ in NotificationCenter.default.notifications(named: notification) { + return } - } - - private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) { - switch newPhase { - case .active: - Task { - await appState.handleReturnToForeground() - } - case .background: - appState.handleEnterBackground() - Task { - await appState.services?.debugLogBuffer.flush() - } - case .inactive: - break - @unknown default: - break + } + group.addTask { + while await !UIApplication.shared.isProtectedDataAvailable { + try? await Task.sleep(for: .seconds(1)) } + } + await group.next() + group.cancelAll() } + } - private func runInitialForegroundReconciliationIfNeeded() async { - guard scenePhase == .active else { return } + private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) { + switch newPhase { + case .active: + Task { await appState.handleReturnToForeground() + } + case .background: + appState.handleEnterBackground() + // Flush the shared buffer, not services?.debugLogBuffer: while disconnected + // (a failing reconnect is exactly that window) services is nil, and unflushed + // entries die with the process if iOS terminates the suspended app. + Task { + await DebugLogBuffer.shared?.flush() + } + case .inactive: + break + @unknown default: + break } + } + + private func runInitialForegroundReconciliationIfNeeded() async { + guard scenePhase == .active else { return } + await appState.handleReturnToForeground() + } } diff --git a/MC1/Models/ChatFilter.swift b/MC1/Models/ChatFilter.swift index f96c9582..980bdc25 100644 --- a/MC1/Models/ChatFilter.swift +++ b/MC1/Models/ChatFilter.swift @@ -2,21 +2,23 @@ import Foundation /// Filter options for the Chats list view enum ChatFilter: String, CaseIterable, Identifiable { - case all - case unread - case directMessages - case channels - case rooms + case all + case unread + case directMessages + case channels + case rooms - var id: String { rawValue } + var id: String { + rawValue + } - var localizedName: String { - switch self { - case .all: L10n.Chats.Chats.Filter.all - case .unread: L10n.Chats.Chats.Filter.unread - case .directMessages: L10n.Chats.Chats.Filter.directMessages - case .channels: L10n.Chats.Chats.Filter.channels - case .rooms: L10n.Chats.Chats.Filter.rooms - } + var localizedName: String { + switch self { + case .all: L10n.Chats.Chats.Filter.all + case .unread: L10n.Chats.Chats.Filter.unread + case .directMessages: L10n.Chats.Chats.Filter.directMessages + case .channels: L10n.Chats.Chats.Filter.channels + case .rooms: L10n.Chats.Chats.Filter.rooms } + } } diff --git a/MC1/Models/Conversation+Filtering.swift b/MC1/Models/Conversation+Filtering.swift index 085a8b61..a95a8d9c 100644 --- a/MC1/Models/Conversation+Filtering.swift +++ b/MC1/Models/Conversation+Filtering.swift @@ -1,39 +1,39 @@ import Foundation -extension Array where Element == Conversation { - /// Filters conversations by category and search text - /// - Parameters: - /// - filter: Filter category - /// - searchText: Search string to match against display names - /// - Returns: Filtered array of conversations - func filtered(by filter: ChatFilter, searchText: String) -> [Conversation] { - // When searching, ignore the selected filter and search all conversations - if !searchText.isEmpty { - return self.filter { conversation in - conversation.displayName.localizedStandardContains(searchText) - } - } +extension [Conversation] { + /// Filters conversations by category and search text + /// - Parameters: + /// - filter: Filter category + /// - searchText: Search string to match against display names + /// - Returns: Filtered array of conversations + func filtered(by filter: ChatFilter, searchText: String) -> [Conversation] { + // When searching, ignore the selected filter and search all conversations + if !searchText.isEmpty { + return self.filter { conversation in + conversation.displayName.localizedStandardContains(searchText) + } + } - switch filter { - case .all: - return self - case .unread: - return self.filter { $0.unreadCount > 0 && !$0.isMuted } - case .directMessages: - return self.filter { - if case .direct = $0 { return true } - return false - } - case .channels: - return self.filter { - if case .channel = $0 { return true } - return false - } - case .rooms: - return self.filter { - if case .room = $0 { return true } - return false - } - } + switch filter { + case .all: + return self + case .unread: + return self.filter { $0.unreadCount > 0 && !$0.isMuted } + case .directMessages: + return self.filter { + if case .direct = $0 { return true } + return false + } + case .channels: + return self.filter { + if case .channel = $0 { return true } + return false + } + case .rooms: + return self.filter { + if case .room = $0 { return true } + return false + } } + } } diff --git a/MC1/Models/Conversation.swift b/MC1/Models/Conversation.swift index 2b5fa755..920f10b7 100644 --- a/MC1/Models/Conversation.swift +++ b/MC1/Models/Conversation.swift @@ -3,78 +3,77 @@ import MC1Services /// Represents a conversation in the chat list - direct chat, channel, or room enum Conversation: Identifiable, Hashable { - case direct(ContactDTO) - case channel(ChannelDTO) - case room(RemoteNodeSessionDTO) + case direct(ContactDTO) + case channel(ChannelDTO) + case room(RemoteNodeSessionDTO) - var id: UUID { - switch self { - case .direct(let contact): - return contact.id - case .channel(let channel): - return channel.id - case .room(let session): - return session.id - } + var id: UUID { + switch self { + case let .direct(contact): + contact.id + case let .channel(channel): + channel.id + case let .room(session): + session.id } + } - var displayName: String { - switch self { - case .direct(let contact): - return contact.displayName - case .channel(let channel): - return channel.displayName - case .room(let session): - return session.name - } + var displayName: String { + switch self { + case let .direct(contact): + contact.displayName + case let .channel(channel): + channel.displayName + case let .room(session): + session.name } + } - var lastMessageDate: Date? { - switch self { - case .direct(let contact): - return contact.lastMessageDate - case .channel(let channel): - return channel.lastMessageDate - case .room(let session): - return session.lastMessageDate - } + var lastMessageDate: Date? { + switch self { + case let .direct(contact): + contact.lastMessageDate + case let .channel(channel): + channel.lastMessageDate + case let .room(session): + session.lastMessageDate } + } - var unreadCount: Int { - switch self { - case .direct(let contact): - return contact.unreadCount - case .channel(let channel): - return channel.unreadCount - case .room(let session): - return session.unreadCount - } + var unreadCount: Int { + switch self { + case let .direct(contact): + contact.unreadCount + case let .channel(channel): + channel.unreadCount + case let .room(session): + session.unreadCount } + } - var notificationLevel: NotificationLevel { - switch self { - case .direct(let contact): - return contact.isMuted ? .muted : .all - case .channel(let channel): - return channel.notificationLevel - case .room(let session): - return session.notificationLevel - } + var notificationLevel: NotificationLevel { + switch self { + case let .direct(contact): + contact.isMuted ? .muted : .all + case let .channel(channel): + channel.notificationLevel + case let .room(session): + session.notificationLevel } + } - var isMuted: Bool { - notificationLevel == .muted - } + var isMuted: Bool { + notificationLevel == .muted + } - var isFavorite: Bool { - switch self { - case .direct(let contact): - return contact.isFavorite - case .channel(let channel): - return channel.isFavorite - case .room(let session): - return session.isFavorite - } + var isFavorite: Bool { + switch self { + case let .direct(contact): + contact.isFavorite + case let .channel(channel): + channel.isFavorite + case let .room(session): + session.isFavorite } - + } } diff --git a/MC1/Models/DevicePreferenceStore.swift b/MC1/Models/DevicePreferenceStore.swift index bed60235..34444a13 100644 --- a/MC1/Models/DevicePreferenceStore.swift +++ b/MC1/Models/DevicePreferenceStore.swift @@ -1,52 +1,52 @@ import Foundation -enum GPSSource: String, Sendable, CaseIterable { - case phone - case device +enum GPSSource: String, CaseIterable { + case phone + case device } struct DevicePreferenceStore { - private let userDefaults: UserDefaults + private let userDefaults: UserDefaults - init(userDefaults: UserDefaults = .standard) { - self.userDefaults = userDefaults - } + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } - // MARK: - Auto-Update Location + // MARK: - Auto-Update Location - func isAutoUpdateLocationEnabled(deviceID: UUID) -> Bool { - userDefaults.bool(forKey: Self.autoUpdateLocationKey(deviceID: deviceID)) - } + func isAutoUpdateLocationEnabled(deviceID: UUID) -> Bool { + userDefaults.bool(forKey: Self.autoUpdateLocationKey(deviceID: deviceID)) + } - func setAutoUpdateLocationEnabled(_ enabled: Bool, deviceID: UUID) { - userDefaults.set(enabled, forKey: Self.autoUpdateLocationKey(deviceID: deviceID)) - } + func setAutoUpdateLocationEnabled(_ enabled: Bool, deviceID: UUID) { + userDefaults.set(enabled, forKey: Self.autoUpdateLocationKey(deviceID: deviceID)) + } - // MARK: - GPS Source + // MARK: - GPS Source - func gpsSource(deviceID: UUID) -> GPSSource { - guard let raw = userDefaults.string(forKey: Self.gpsSourceKey(deviceID: deviceID)), - let source = GPSSource(rawValue: raw) else { - return .phone - } - return source + func gpsSource(deviceID: UUID) -> GPSSource { + guard let raw = userDefaults.string(forKey: Self.gpsSourceKey(deviceID: deviceID)), + let source = GPSSource(rawValue: raw) else { + return .phone } + return source + } - func hasSetGPSSource(deviceID: UUID) -> Bool { - userDefaults.string(forKey: Self.gpsSourceKey(deviceID: deviceID)) != nil - } + func hasSetGPSSource(deviceID: UUID) -> Bool { + userDefaults.string(forKey: Self.gpsSourceKey(deviceID: deviceID)) != nil + } - func setGPSSource(_ source: GPSSource, deviceID: UUID) { - userDefaults.set(source.rawValue, forKey: Self.gpsSourceKey(deviceID: deviceID)) - } + func setGPSSource(_ source: GPSSource, deviceID: UUID) { + userDefaults.set(source.rawValue, forKey: Self.gpsSourceKey(deviceID: deviceID)) + } - // MARK: - Keys + // MARK: - Keys - private static func autoUpdateLocationKey(deviceID: UUID) -> String { - "device.\(deviceID.uuidString).autoUpdateLocation" - } + private static func autoUpdateLocationKey(deviceID: UUID) -> String { + "device.\(deviceID.uuidString).autoUpdateLocation" + } - private static func gpsSourceKey(deviceID: UUID) -> String { - "device.\(deviceID.uuidString).gpsSource" - } + private static func gpsSourceKey(deviceID: UUID) -> String { + "device.\(deviceID.uuidString).gpsSource" + } } diff --git a/MC1/Models/LinkPreviewPreferences.swift b/MC1/Models/LinkPreviewPreferences.swift index 658b6c25..b34cc4c0 100644 --- a/MC1/Models/LinkPreviewPreferences.swift +++ b/MC1/Models/LinkPreviewPreferences.swift @@ -3,37 +3,39 @@ import MC1Services /// User preferences for link preview behavior struct LinkPreviewPreferences: @unchecked Sendable { - private static let enabledKey = AppStorageKey.linkPreviewsEnabled.rawValue - private static let autoResolveDMKey = AppStorageKey.linkPreviewsAutoResolveDM.rawValue - private static let autoResolveChannelsKey = AppStorageKey.linkPreviewsAutoResolveChannels.rawValue - - private let defaults: UserDefaults - - var previewsEnabled: Bool { - get { defaults.bool(forKey: Self.enabledKey) } - nonmutating set { defaults.set(newValue, forKey: Self.enabledKey) } - } - var autoResolveDM: Bool { - get { defaults.object(forKey: Self.autoResolveDMKey) as? Bool ?? AppStorageKey.defaultLinkPreviewsAutoResolveDM } - nonmutating set { defaults.set(newValue, forKey: Self.autoResolveDMKey) } - } - var autoResolveChannels: Bool { - get { defaults.object(forKey: Self.autoResolveChannelsKey) as? Bool ?? AppStorageKey.defaultLinkPreviewsAutoResolveChannels } - nonmutating set { defaults.set(newValue, forKey: Self.autoResolveChannelsKey) } - } - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - /// Whether previews should be shown at all - var shouldShowPreview: Bool { - previewsEnabled - } - - /// Whether to auto-resolve based on message type - func shouldAutoResolve(isChannelMessage: Bool) -> Bool { - guard previewsEnabled else { return false } - return isChannelMessage ? autoResolveChannels : autoResolveDM - } + private static let enabledKey = AppStorageKey.linkPreviewsEnabled.rawValue + private static let autoResolveDMKey = AppStorageKey.linkPreviewsAutoResolveDM.rawValue + private static let autoResolveChannelsKey = AppStorageKey.linkPreviewsAutoResolveChannels.rawValue + + private let defaults: UserDefaults + + var previewsEnabled: Bool { + get { defaults.bool(forKey: Self.enabledKey) } + nonmutating set { defaults.set(newValue, forKey: Self.enabledKey) } + } + + var autoResolveDM: Bool { + get { defaults.object(forKey: Self.autoResolveDMKey) as? Bool ?? AppStorageKey.defaultLinkPreviewsAutoResolveDM } + nonmutating set { defaults.set(newValue, forKey: Self.autoResolveDMKey) } + } + + var autoResolveChannels: Bool { + get { defaults.object(forKey: Self.autoResolveChannelsKey) as? Bool ?? AppStorageKey.defaultLinkPreviewsAutoResolveChannels } + nonmutating set { defaults.set(newValue, forKey: Self.autoResolveChannelsKey) } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// Whether previews should be shown at all + var shouldShowPreview: Bool { + previewsEnabled + } + + /// Whether to auto-resolve based on message type + func shouldAutoResolve(isChannelMessage: Bool) -> Bool { + guard previewsEnabled else { return false } + return isChannelMessage ? autoResolveChannels : autoResolveDM + } } diff --git a/MC1/Models/WhatsNew/WhatsNewCatalog.swift b/MC1/Models/WhatsNew/WhatsNewCatalog.swift index cb6f56ad..e03781af 100644 --- a/MC1/Models/WhatsNew/WhatsNewCatalog.swift +++ b/MC1/Models/WhatsNew/WhatsNewCatalog.swift @@ -3,26 +3,26 @@ import Foundation /// Per-release What's New notes, keyed by the `major.minor` they belong to. Add a /// release as one `WhatsNewRelease` plus its `L10n` strings; it presents once on upgrade. enum WhatsNewCatalog { - static let releases: [WhatsNewRelease] = [ - WhatsNewRelease( - version: WhatsNewVersion(major: 1, minor: 1), - items: [ - WhatsNewItem( - symbol: "wand.and.rays", - title: L10n.WhatsNew.WhatsNew.SiriShortcuts.title, - description: L10n.WhatsNew.WhatsNew.SiriShortcuts.description - ), - WhatsNewItem( - symbol: "trash", - title: L10n.WhatsNew.WhatsNew.ClearMessages.title, - description: L10n.WhatsNew.WhatsNew.ClearMessages.description - ), - WhatsNewItem( - symbol: "antenna.radiowaves.left.and.right", - title: L10n.WhatsNew.WhatsNew.InboundHops.title, - description: L10n.WhatsNew.WhatsNew.InboundHops.description - ) - ] + static let releases: [WhatsNewRelease] = [ + WhatsNewRelease( + version: WhatsNewVersion(major: 1, minor: 1), + items: [ + WhatsNewItem( + symbol: "wand.and.rays", + title: L10n.WhatsNew.WhatsNew.SiriShortcuts.title, + description: L10n.WhatsNew.WhatsNew.SiriShortcuts.description + ), + WhatsNewItem( + symbol: "trash", + title: L10n.WhatsNew.WhatsNew.ClearMessages.title, + description: L10n.WhatsNew.WhatsNew.ClearMessages.description + ), + WhatsNewItem( + symbol: "antenna.radiowaves.left.and.right", + title: L10n.WhatsNew.WhatsNew.InboundHops.title, + description: L10n.WhatsNew.WhatsNew.InboundHops.description ) - ] + ] + ) + ] } diff --git a/MC1/Models/WhatsNew/WhatsNewItem.swift b/MC1/Models/WhatsNew/WhatsNewItem.swift index 60178605..4b0e4e0f 100644 --- a/MC1/Models/WhatsNew/WhatsNewItem.swift +++ b/MC1/Models/WhatsNew/WhatsNewItem.swift @@ -3,8 +3,8 @@ import Foundation /// One feature row in a What's New release: an SF Symbol with a localized title /// and description. struct WhatsNewItem: Identifiable { - let id = UUID() - let symbol: String - let title: String - let description: String + let id = UUID() + let symbol: String + let title: String + let description: String } diff --git a/MC1/Models/WhatsNew/WhatsNewRelease.swift b/MC1/Models/WhatsNew/WhatsNewRelease.swift index c79ccdda..1b04ef3b 100644 --- a/MC1/Models/WhatsNew/WhatsNewRelease.swift +++ b/MC1/Models/WhatsNew/WhatsNewRelease.swift @@ -2,8 +2,10 @@ import Foundation /// `Identifiable` by its `major.minor` version so a release drives `.sheet(item:)`. struct WhatsNewRelease: Identifiable { - let version: WhatsNewVersion - let items: [WhatsNewItem] + let version: WhatsNewVersion + let items: [WhatsNewItem] - var id: WhatsNewVersion { version } + var id: WhatsNewVersion { + version + } } diff --git a/MC1/Models/WhatsNew/WhatsNewVersion.swift b/MC1/Models/WhatsNew/WhatsNewVersion.swift index bc11476f..a6af161a 100644 --- a/MC1/Models/WhatsNew/WhatsNewVersion.swift +++ b/MC1/Models/WhatsNew/WhatsNewVersion.swift @@ -3,24 +3,24 @@ import Foundation /// The leading `major.minor` of a marketing version, ignoring patch. `Comparable` /// is lexicographic on `(major, minor)`, so "a 0.1 bump or more" is just `>`. struct WhatsNewVersion: Comparable, Hashable { - let major: Int - let minor: Int + let major: Int + let minor: Int - static func < (lhs: WhatsNewVersion, rhs: WhatsNewVersion) -> Bool { - (lhs.major, lhs.minor) < (rhs.major, rhs.minor) - } + static func < (lhs: WhatsNewVersion, rhs: WhatsNewVersion) -> Bool { + (lhs.major, lhs.minor) < (rhs.major, rhs.minor) + } } extension WhatsNewVersion { - /// Parses the leading `major.minor`; returns `nil` for anything unparseable - /// (`"unknown"`, `"2"`, a TestFlight `"1.0 (123)"`) so the sheet fails closed. - init?(marketingVersion: String) { - let components = marketingVersion.split(separator: ".") - guard components.count >= 2, - let major = Int(components[0]), - let minor = Int(components[1]) else { - return nil - } - self.init(major: major, minor: minor) + /// Parses the leading `major.minor`; returns `nil` for anything unparseable + /// (`"unknown"`, `"2"`, a TestFlight `"1.0 (123)"`) so the sheet fails closed. + init?(marketingVersion: String) { + let components = marketingVersion.split(separator: ".") + guard components.count >= 2, + let major = Int(components[0]), + let minor = Int(components[1]) else { + return nil } + self.init(major: major, minor: minor) + } } diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 8173acdb..53257e80 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -752,6 +752,16 @@ public enum L10n { public static let possibleMatch = L10n.tr("Chats", "chats.message.sender.possibleMatch", fallback: "Possible match, matched by short prefix") /// Location: UnifiedMessageBubble.swift - Fallback sender name public static let unknown = L10n.tr("Chats", "chats.message.sender.unknown", fallback: "Unknown") + /// Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover + public static let unverifiedNickname = L10n.tr("Chats", "chats.message.sender.unverifiedNickname", fallback: "Unverified name") + /// Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator + public static let unverifiedNicknameAccessibilityLabel = L10n.tr("Chats", "chats.message.sender.unverifiedNicknameAccessibilityLabel", fallback: "Unverified name match") + /// Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified + public static let unverifiedNicknameExplanation = L10n.tr("Chats", "chats.message.sender.unverifiedNicknameExplanation", fallback: "Channel message senders cannot be verified. This could be a different person.") + /// Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname + public static func unverifiedNicknameFormat(_ p1: Any) -> String { + return L10n.tr("Chats", "chats.message.sender.unverifiedNicknameFormat", String(describing: p1), fallback: "(%@)") + } } public enum Status { /// Location: UnifiedMessageBubble.swift - Message status delivered @@ -1804,8 +1814,8 @@ public enum L10n { return L10n.tr("Contacts", "contacts.results.avgRTTLabel", p1, p2, p3, fallback: "Average round trip: %d milliseconds, range %d to %d") } /// Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility - public static func batchCompleteLabel(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Contacts", "contacts.results.batchCompleteLabel", p1, p2, fallback: "Batch complete: %d of %d traces successful") + public static func batchCompleteLabel(_ p1: Int, _ p2: Int, _ p3: Int) -> String { + return L10n.tr("Contacts", "contacts.results.batchCompleteLabel", p1, p2, p3, fallback: "Batch complete: %d of %d traces successful (%d%%)") } /// Location: TraceResultsSheet.swift - Purpose: Batch progress public static func batchProgress(_ p1: Int, _ p2: Int) -> String { @@ -1816,8 +1826,8 @@ public enum L10n { return L10n.tr("Contacts", "contacts.results.batchProgressLabel", p1, p2, fallback: "Batch progress: trace %d of %d") } /// Location: TraceResultsSheet.swift - Purpose: Batch success count - public static func batchSuccess(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Contacts", "contacts.results.batchSuccess", p1, p2, fallback: "%d of %d successful") + public static func batchSuccess(_ p1: Int, _ p2: Int, _ p3: Int) -> String { + return L10n.tr("Contacts", "contacts.results.batchSuccess", p1, p2, p3, fallback: "%d of %d successful (%d%%)") } /// Location: TraceResultsSheet.swift - Purpose: Comparison text public static func comparison(_ p1: Int, _ p2: Any) -> String { @@ -2627,6 +2637,8 @@ public enum L10n { public static let pathDiscoveryFailed = L10n.tr("Localizable", "error.remoteNode.pathDiscoveryFailed", fallback: "Failed to establish direct path") /// Location: RemoteNodeError+UserFacingMessage.swift - Remote node rejected the operation public static let permissionDenied = L10n.tr("Localizable", "error.remoteNode.permissionDenied", fallback: "Permission denied") + /// Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login + public static let radioContactsFull = L10n.tr("Localizable", "error.remoteNode.radioContactsFull", fallback: "Your radio is out of contact slots. Remove a contact you no longer need, then retry sign-in.") /// Location: RemoteNodeError+UserFacingMessage.swift - Sending to the remote node failed public static let sendFailed = L10n.tr("Localizable", "error.remoteNode.sendFailed", fallback: "Failed to send.") /// Location: RemoteNodeError+UserFacingMessage.swift - No stored session for the remote node @@ -2805,8 +2817,6 @@ public enum L10n { } } public enum Common { - /// Dismiss - public static let dismissOverlay = L10n.tr("Map", "map.common.dismissOverlay", fallback: "Dismiss") /// Location: MapView.swift - Purpose: Done button for sheets public static let done = L10n.tr("Map", "map.common.done", fallback: "Done") } @@ -2815,18 +2825,14 @@ public enum L10n { public static let centerAll = L10n.tr("Map", "map.controls.centerAll", fallback: "Center on all contacts") /// Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button public static let centerOnMyLocation = L10n.tr("Map", "map.controls.centerOnMyLocation", fallback: "Center on my location") - /// Location: MapView.swift - Purpose: Accessibility label when labels are visible - public static let hideLabels = L10n.tr("Map", "map.controls.hideLabels", fallback: "Hide labels") - /// Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button - public static let layers = L10n.tr("Map", "map.controls.layers", fallback: "Map layers") - /// Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) - public static let lockNorth = L10n.tr("Map", "map.controls.lockNorth", fallback: "Lock to north") + /// Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north + public static let lockNorth = L10n.tr("Map", "map.controls.lockNorth", fallback: "North up") + /// Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button + public static let mapOptions = L10n.tr("Map", "map.controls.mapOptions", fallback: "Map options") /// Location: MapView.swift - Purpose: Accessibility label for refresh button public static let refresh = L10n.tr("Map", "map.controls.refresh", fallback: "Refresh contacts") /// Location: MapView.swift - Purpose: Accessibility label when labels are hidden public static let showLabels = L10n.tr("Map", "map.controls.showLabels", fallback: "Show labels") - /// Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) - public static let unlockNorth = L10n.tr("Map", "map.controls.unlockNorth", fallback: "Unlock rotation") } public enum Detail { /// Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited @@ -2941,7 +2947,7 @@ public enum L10n { /// Location: DeviceScanView.swift - Button to retry connection after other-app conflict public static let retryConnection = L10n.tr("Onboarding", "deviceScan.retryConnection", fallback: "Retry Connection") /// Location: DeviceScanView.swift - Subtitle with pairing instructions - public static let subtitle = L10n.tr("Onboarding", "deviceScan.subtitle", fallback: "Power it on, then tap Add Device.") + public static let subtitle = L10n.tr("Onboarding", "deviceScan.subtitle", fallback: "Power your radio on, disconnect it from all other apps and devices, then tap Add Device.") /// Location: DeviceScanView.swift - Screen title for device pairing public static let title = L10n.tr("Onboarding", "deviceScan.title", fallback: "Pair your device") public enum DemoModeAlert { @@ -2955,6 +2961,8 @@ public enum L10n { public static let authenticationFailed = L10n.tr("Onboarding", "deviceScan.error.authenticationFailed", fallback: "Couldn't pair the device. Check the PIN, then remove the device and try again.") /// Couldn't connect to the device. Try again, or remove it if the problem continues. public static let connectionFailed = L10n.tr("Onboarding", "deviceScan.error.connectionFailed", fallback: "Couldn't connect to the device. Try again, or remove it if the problem continues.") + /// The PIN wasn't accepted. Check the PIN shown on your device, then try again. iOS will ask you to confirm removing the failed pairing first. + public static let pinRejected = L10n.tr("Onboarding", "deviceScan.error.pinRejected", fallback: "The PIN wasn't accepted. Check the PIN shown on your device, then try again. iOS will ask you to confirm removing the failed pairing first.") } } public enum DeviceScanner { @@ -3259,6 +3267,12 @@ public enum L10n { public static let battery = L10n.tr("RemoteNodes", "remoteNodes.history.battery", fallback: "Battery") /// Location: NodeStatusHistoryView.swift - Empty state message public static let checkBack = L10n.tr("RemoteNodes", "remoteNodes.history.checkBack", fallback: "A snapshot is recorded at most every 15 minutes. Check back after your next visit to see trends.") + /// Location: RadioMetricCharts.swift - Direct packet series legend + public static let direct = L10n.tr("RemoteNodes", "remoteNodes.history.direct", fallback: "Direct") + /// Location: RadioMetricCharts.swift - Duplicates chart title, under the Packets group header + public static let duplicates = L10n.tr("RemoteNodes", "remoteNodes.history.duplicates", fallback: "Duplicates") + /// Location: RadioMetricCharts.swift - Flood packet series legend + public static let flood = L10n.tr("RemoteNodes", "remoteNodes.history.flood", fallback: "Flood") /// Location: NeighborHistoryView.swift - Last seen status public static func lastSeen(_ p1: Any) -> String { return L10n.tr("RemoteNodes", "remoteNodes.history.lastSeen", String(describing: p1), fallback: "Last seen %@") @@ -3281,14 +3295,16 @@ public enum L10n { public static let notSeen = L10n.tr("RemoteNodes", "remoteNodes.history.notSeen", fallback: "Not seen") /// Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title public static let overviewTitle = L10n.tr("RemoteNodes", "remoteNodes.history.overviewTitle", fallback: "Telemetry History") - /// Location: NodeStatusHistoryView.swift - Packets received chart title - public static let packetsReceived = L10n.tr("RemoteNodes", "remoteNodes.history.packetsReceived", fallback: "Packets Received") - /// Location: NodeStatusHistoryView.swift - Packets sent chart title - public static let packetsSent = L10n.tr("RemoteNodes", "remoteNodes.history.packetsSent", fallback: "Packets Sent") + /// Location: RadioMetricCharts.swift - Section header grouping the packet-count charts + public static let packets = L10n.tr("RemoteNodes", "remoteNodes.history.packets", fallback: "Packets") + /// Location: RadioMetricCharts.swift - Received packets chart title, under the Packets group header + public static let packetsReceived = L10n.tr("RemoteNodes", "remoteNodes.history.packetsReceived", fallback: "Received") + /// Location: RadioMetricCharts.swift - Sent packets chart title, under the Packets group header + public static let packetsSent = L10n.tr("RemoteNodes", "remoteNodes.history.packetsSent", fallback: "Sent") /// Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header public static let radioSection = L10n.tr("RemoteNodes", "remoteNodes.history.radioSection", fallback: "Radio") - /// Location: NodeStatusHistoryView.swift - Receive errors chart title - public static let receiveErrors = L10n.tr("RemoteNodes", "remoteNodes.history.receiveErrors", fallback: "Packet Errors Received") + /// Location: RadioMetricCharts.swift - Receive errors chart title, under the Packets group header + public static let receiveErrors = L10n.tr("RemoteNodes", "remoteNodes.history.receiveErrors", fallback: "Errors") /// Location: NodeStatusHistoryView.swift - Footer about data retention public static let retentionNotice = L10n.tr("RemoteNodes", "remoteNodes.history.retentionNotice", fallback: "History data older than one year is automatically removed.") /// Location: NodeStatusHistoryView.swift - RSSI chart title @@ -3541,18 +3557,26 @@ public enum L10n { public static let lat = L10n.tr("RemoteNodes", "remoteNodes.settings.lat", fallback: "Lat") /// Location: RepeaterSettingsView.swift - Latitude label public static let latitude = L10n.tr("RemoteNodes", "remoteNodes.settings.latitude", fallback: "Latitude") + /// Location: NodeSettingsViewModel.swift - Latitude range validation error + public static let latitudeValidation = L10n.tr("RemoteNodes", "remoteNodes.settings.latitudeValidation", fallback: "Accepts -90 to 90") /// Location: RepeaterSettingsView.swift - Loading placeholder public static let loading = L10n.tr("RemoteNodes", "remoteNodes.settings.loading", fallback: "Loading...") /// Location: RepeaterSettingsView.swift - Lon placeholder public static let lon = L10n.tr("RemoteNodes", "remoteNodes.settings.lon", fallback: "Lon") /// Location: RepeaterSettingsView.swift - Longitude label public static let longitude = L10n.tr("RemoteNodes", "remoteNodes.settings.longitude", fallback: "Longitude") + /// Location: NodeSettingsViewModel.swift - Longitude range validation error + public static let longitudeValidation = L10n.tr("RemoteNodes", "remoteNodes.settings.longitudeValidation", fallback: "Accepts -180 to 180") /// Location: RepeaterSettingsView.swift - Max flood hops label public static let maxFloodHops = L10n.tr("RemoteNodes", "remoteNodes.settings.maxFloodHops", fallback: "Max Flood Hops") /// Location: RepeaterSettingsView.swift - MHz placeholder public static let mhz = L10n.tr("RemoteNodes", "remoteNodes.settings.mhz", fallback: "MHz") /// Location: RepeaterSettingsView.swift - Minutes unit public static let min = L10n.tr("RemoteNodes", "remoteNodes.settings.min", fallback: "min") + /// Location: NodeSettingsViewModel.swift - Node name length validation error + public static func nameValidation(_ p1: Int) -> String { + return L10n.tr("RemoteNodes", "remoteNodes.settings.nameValidation", p1, fallback: "Accepts up to %d bytes") + } /// Location: RepeaterSettingsView.swift - New password placeholder public static let newPassword = L10n.tr("RemoteNodes", "remoteNodes.settings.newPassword", fallback: "New Password") /// Location: NodeSettingsViewModel.swift - No service error @@ -3713,6 +3737,8 @@ public enum L10n { } /// Location: RepeaterStatusView.swift - Discover neighbours button label public static let discoverNeighbors = L10n.tr("RemoteNodes", "remoteNodes.status.discoverNeighbors", fallback: "Discover Neighbours") + /// Location: SharedNodeStatusViews.swift - Duplicate packets label + public static let duplicates = L10n.tr("RemoteNodes", "remoteNodes.status.duplicates", fallback: "Duplicates") /// Location: RepeaterStatusView.swift - Guest mode badge in header public static let guestMode = L10n.tr("RemoteNodes", "remoteNodes.status.guestMode", fallback: "Guest Mode") /// Location: RepeaterStatusView.swift - Hours ago format @@ -3731,6 +3757,12 @@ public enum L10n { public static let neighbors = L10n.tr("RemoteNodes", "remoteNodes.status.neighbors", fallback: "Neighbors") /// Location: RepeaterStatusView.swift - Neighbors section footer public static let neighborsFooter = L10n.tr("RemoteNodes", "remoteNodes.status.neighborsFooter", fallback: "Other nodes discovered by this repeater and their signal quality.") + /// Location: NeighborSNRMapView.swift - Navigation title for the neighbors map + public static let neighborsMapTitle = L10n.tr("RemoteNodes", "remoteNodes.status.neighborsMapTitle", fallback: "Neighbors Map") + /// Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count + public static func neighborsNotShown(_ p1: Int) -> String { + return L10n.tr("RemoteNodes", "remoteNodes.status.neighborsNotShown", p1, fallback: "%d neighbors not shown") + } /// Location: RepeaterStatusView.swift - Noise floor label public static let noiseFloor = L10n.tr("RemoteNodes", "remoteNodes.status.noiseFloor", fallback: "Noise Floor") /// Location: RepeaterStatusView.swift - No neighbors empty state @@ -3751,18 +3783,24 @@ public enum L10n { public static let ocvSaveNoContact = L10n.tr("RemoteNodes", "remoteNodes.status.ocvSaveNoContact", fallback: "Cannot save: contact not found") /// Location: RepeaterStatusView.swift - Owner info section label public static let ownerInfo = L10n.tr("RemoteNodes", "remoteNodes.status.ownerInfo", fallback: "Contact Info") - /// Location: RepeaterStatusView.swift - Packets received label - public static let packetsReceived = L10n.tr("RemoteNodes", "remoteNodes.status.packetsReceived", fallback: "Packets Received") - /// Location: RepeaterStatusView.swift - Packets sent label - public static let packetsSent = L10n.tr("RemoteNodes", "remoteNodes.status.packetsSent", fallback: "Packets Sent") + /// Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows + public static let packets = L10n.tr("RemoteNodes", "remoteNodes.status.packets", fallback: "Packets") + /// Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header + public static let packetsReceived = L10n.tr("RemoteNodes", "remoteNodes.status.packetsReceived", fallback: "Received") + /// Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header + public static let packetsSent = L10n.tr("RemoteNodes", "remoteNodes.status.packetsSent", fallback: "Sent") /// Location: RepeaterStatusView.swift - Accessibility label for possible match indicator public static let possibleMatch = L10n.tr("RemoteNodes", "remoteNodes.status.possibleMatch", fallback: "Possible match, matched by short prefix") /// Location: RepeaterStatusView.swift - Explanation of what a possible match means public static let possibleMatchExplanation = L10n.tr("RemoteNodes", "remoteNodes.status.possibleMatchExplanation", fallback: "Multiple nodes share this prefix. The displayed name may not be correct.") /// Location: RepeaterStatusView.swift - Title for possible match explanation popover public static let possibleMatchTitle = L10n.tr("RemoteNodes", "remoteNodes.status.possibleMatchTitle", fallback: "Possible Match") - /// Location: RepeaterStatusView.swift - Receive errors label - public static let receiveErrors = L10n.tr("RemoteNodes", "remoteNodes.status.receiveErrors", fallback: "Packet Errors Received") + /// Location: SharedNodeStatusViews.swift - Received (direct) packets label + public static let receivedDirect = L10n.tr("RemoteNodes", "remoteNodes.status.receivedDirect", fallback: "Received (Direct)") + /// Location: SharedNodeStatusViews.swift - Received (flood) packets label + public static let receivedFlood = L10n.tr("RemoteNodes", "remoteNodes.status.receivedFlood", fallback: "Received (Flood)") + /// Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header + public static let receiveErrors = L10n.tr("RemoteNodes", "remoteNodes.status.receiveErrors", fallback: "Errors") /// Location: NodeTelemetryView.swift - Refresh button accessibility label public static let refresh = L10n.tr("RemoteNodes", "remoteNodes.status.refresh", fallback: "Refresh") /// Location: RepeaterStatusViewModel.swift - Request timed out @@ -3771,6 +3809,12 @@ public enum L10n { public static func secondsAgo(_ p1: Int) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.secondsAgo", p1, fallback: "%ds ago") } + /// Location: SharedNodeStatusViews.swift - Sent (direct) packets label + public static let sentDirect = L10n.tr("RemoteNodes", "remoteNodes.status.sentDirect", fallback: "Sent (Direct)") + /// Location: SharedNodeStatusViews.swift - Sent (flood) packets label + public static let sentFlood = L10n.tr("RemoteNodes", "remoteNodes.status.sentFlood", fallback: "Sent (Flood)") + /// Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form + public static let snrBadgeUnit = L10n.tr("RemoteNodes", "remoteNodes.status.snrBadgeUnit", fallback: "dB") /// Location: RepeaterStatusView.swift - SNR display format public static func snrFormat(_ p1: Any) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.snrFormat", String(describing: p1), fallback: "SNR %@dB") @@ -3805,6 +3849,8 @@ public enum L10n { public static func uptimeMinutes(_ p1: Int) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.uptimeMinutes", p1, fallback: "%dm") } + /// Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section + public static let viewOnMap = L10n.tr("RemoteNodes", "remoteNodes.status.viewOnMap", fallback: "View on Map") public enum Accessibility { /// Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors public static let reloadNeighbors = L10n.tr("RemoteNodes", "remoteNodes.status.accessibility.reloadNeighbors", fallback: "Reload neighbors") @@ -3814,6 +3860,8 @@ public enum L10n { public static let reloadStatus = L10n.tr("RemoteNodes", "remoteNodes.status.accessibility.reloadStatus", fallback: "Reload status") /// Location: SharedNodeStatusViews.swift - Per-section reload button accessibility label for telemetry public static let reloadTelemetry = L10n.tr("RemoteNodes", "remoteNodes.status.accessibility.reloadTelemetry", fallback: "Reload telemetry") + /// Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button + public static let viewNeighborsOnMap = L10n.tr("RemoteNodes", "remoteNodes.status.accessibility.viewNeighborsOnMap", fallback: "View neighbors on map") } public enum Sensor { /// Accelerometer @@ -4122,7 +4170,7 @@ public enum L10n { } public enum RepeatMode { /// Footer explaining repeat mode in advanced radio - public static let footer = L10n.tr("Settings", "advancedRadio.repeatMode.footer", fallback: "Creates a local repeater on a dedicated frequency. Useful for hiking and remote areas. Valid frequencies: 433, 869, 918 MHz.") + public static let footer = L10n.tr("Settings", "advancedRadio.repeatMode.footer", fallback: "Creates a local repeater on a dedicated frequency. Useful for hiking and remote areas. Valid frequencies: 433, 869.495, 918 MHz.") } } public enum AdvancedSettings { @@ -4792,26 +4840,24 @@ public enum L10n { public enum InlineImages { /// Toggle label for auto-play GIFs public static let autoPlayGifs = L10n.tr("Settings", "inlineImages.autoPlayGifs", fallback: "Auto-play GIFs") - /// Footer explaining inline image privacy implications - public static let footer = L10n.tr("Settings", "inlineImages.footer", fallback: "Fetching images from URLs may reveal your IP address to the server hosting the image.") - /// Toggle label for inline images - public static let toggle = L10n.tr("Settings", "inlineImages.toggle", fallback: "Inline Images") } public enum Language { /// Location: SettingsView.swift - Purpose: Language row title public static let title = L10n.tr("Settings", "language.title", fallback: "Language") } public enum LinkPreviews { - /// Footer explaining link preview privacy implications - public static let footer = L10n.tr("Settings", "linkPreviews.footer", fallback: "Link previews fetch data from the web, which may reveal your IP address to the server hosting the link.") - /// Section header for link preview settings - public static let header = L10n.tr("Settings", "linkPreviews.header", fallback: "Link Previews") - /// Toggle label for showing previews in channels + /// Footer explaining link content privacy implications + public static let footer = L10n.tr("Settings", "linkPreviews.footer", fallback: "Link previews and images are fetched over your phone's internet connection, not the mesh. This may reveal your IP address to the server hosting the content and can use cellular data.") + /// Section header for link content settings + public static let header = L10n.tr("Settings", "linkPreviews.header", fallback: "Link Content") + /// Footer note shown when Reduce Motion is enabled + public static let reduceMotionNote = L10n.tr("Settings", "linkPreviews.reduceMotionNote", fallback: "Reduce Motion is on, so GIFs will not auto-play.") + /// Toggle label for showing link content in channels public static let showInChannels = L10n.tr("Settings", "linkPreviews.showInChannels", fallback: "Show in Channels") - /// Toggle label for showing previews in DMs + /// Toggle label for showing link content in DMs public static let showInDMs = L10n.tr("Settings", "linkPreviews.showInDMs", fallback: "Show in Direct Messages") - /// Toggle label for link previews - public static let toggle = L10n.tr("Settings", "linkPreviews.toggle", fallback: "Link Previews") + /// Toggle label for the link content master switch + public static let toggle = L10n.tr("Settings", "linkPreviews.toggle", fallback: "Show Link Content") } public enum LiveActivity { /// Label for the Live Activity toggle in App Settings @@ -5773,8 +5819,6 @@ public enum L10n { public static let back = L10n.tr("Tools", "tools.lineOfSight.back", fallback: "Back") /// Location: LineOfSightView.swift - Cancel button public static let cancel = L10n.tr("Tools", "tools.lineOfSight.cancel", fallback: "Cancel") - /// Location: LineOfSightView.swift - Drop pin mode enabled - public static let cancelDropPin = L10n.tr("Tools", "tools.lineOfSight.cancelDropPin", fallback: "Cancel drop pin") /// Location: LineOfSightView.swift - Clear button public static let clear = L10n.tr("Tools", "tools.lineOfSight.clear", fallback: "Clear") /// Location: ResultsCardView.swift - Clearance section title @@ -5795,8 +5839,6 @@ public enum L10n { public static let dragToAdjust = L10n.tr("Tools", "tools.lineOfSight.dragToAdjust", fallback: "Drag to adjust") /// Location: LineOfSightViewModel.swift - Dropped pin display name public static let droppedPin = L10n.tr("Tools", "tools.lineOfSight.droppedPin", fallback: "Dropped pin") - /// Location: LineOfSightView.swift - Drop pin mode disabled - public static let dropPin = L10n.tr("Tools", "tools.lineOfSight.dropPin", fallback: "Drop pin") /// Location: LineOfSightView.swift - Earth curvature note, %@ is k-factor public static func earthCurvature(_ p1: Any) -> String { return L10n.tr("Tools", "tools.lineOfSight.earthCurvature", String(describing: p1), fallback: "Adjusted for earth curvature (%@)") @@ -5817,6 +5859,8 @@ public enum L10n { public static let indirectRoute = L10n.tr("Tools", "tools.lineOfSight.indirectRoute", fallback: "Indirect route via R · Relocate on map to adjust") /// Location: LineOfSightView.swift - Loading elevation status public static let loadingElevation = L10n.tr("Tools", "tools.lineOfSight.loadingElevation", fallback: "Loading elevation...") + /// Location: LineOfSightView.swift - Long press points hint + public static let longPressPointsHint = L10n.tr("Tools", "tools.lineOfSight.longPressPointsHint", fallback: "Long press on the map to add a location") /// Location: ResultsCardView.swift - Loss suffix public static let loss = L10n.tr("Tools", "tools.lineOfSight.loss", fallback: "loss") /// Location: LineOfSightView.swift - MHz unit @@ -5855,8 +5899,6 @@ public enum L10n { public static let retry = L10n.tr("Tools", "tools.lineOfSight.retry", fallback: "Retry") /// Location: LineOfSightView.swift - RF Settings section public static let rfSettings = L10n.tr("Tools", "tools.lineOfSight.rfSettings", fallback: "RF Settings") - /// Location: LineOfSightView.swift - Select points hint - public static let selectPointsHint = L10n.tr("Tools", "tools.lineOfSight.selectPointsHint", fallback: "Tap the pin button on the map to select points") /// Location: TerrainProfileCanvas.swift - Empty state description public static let selectTwoPoints = L10n.tr("Tools", "tools.lineOfSight.selectTwoPoints", fallback: "Select two points to analyze") /// Location: LineOfSightView.swift - Share button diff --git a/MC1/Resources/Localization/de.lproj/Chats.strings b/MC1/Resources/Localization/de.lproj/Chats.strings index 0f89e74e..70dfa6b1 100644 --- a/MC1/Resources/Localization/de.lproj/Chats.strings +++ b/MC1/Resources/Localization/de.lproj/Chats.strings @@ -1259,3 +1259,17 @@ /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "Passende Kontakte"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Ungeprüfter Name"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Absender von Kanalnachrichten können nicht verifiziert werden. Es könnte sich um eine andere Person handeln."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Ungeprüfter Namensvergleich"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 7c7800cd..ea3bbdd7 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -1059,13 +1059,13 @@ "contacts.results.dismiss" = "Schließen"; /* Location: TraceResultsSheet.swift - Purpose: Batch success count */ -"contacts.results.batchSuccess" = "%d von %d erfolgreich"; +"contacts.results.batchSuccess" = "%d von %d erfolgreich (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ "contacts.results.batchProgress" = "Trace %d von %d wird ausgeführt..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "Batch abgeschlossen: %d von %d Traces erfolgreich"; +"contacts.results.batchCompleteLabel" = "Batch abgeschlossen: %d von %d Traces erfolgreich (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ "contacts.results.batchProgressLabel" = "Batch-Fortschritt: Trace %d von %d"; diff --git a/MC1/Resources/Localization/de.lproj/Localizable.strings b/MC1/Resources/Localization/de.lproj/Localizable.strings index c26f421e..79cc0f42 100644 --- a/MC1/Resources/Localization/de.lproj/Localizable.strings +++ b/MC1/Resources/Localization/de.lproj/Localizable.strings @@ -538,6 +538,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Kontakt nicht in der Datenbank gefunden"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Auf deinem Funkgerät sind keine Kontaktplätze mehr frei. Entferne einen Kontakt, den du nicht mehr brauchst, und versuche dann erneut, dich anzumelden."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Anmeldung abgebrochen"; diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index 29e85cf0..e6da18a2 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -10,13 +10,9 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Fertig"; -"map.common.dismissOverlay" = "Schließen"; // MARK: - Map Controls -/* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ -"map.controls.hideLabels" = "Beschriftungen ausblenden"; - /* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ "map.controls.showLabels" = "Beschriftungen einblenden"; @@ -26,14 +22,13 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ "map.controls.centerOnMyLocation" = "Auf meinen Standort zentrieren"; -/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ -"map.controls.layers" = "Kartenebenen"; - -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ -"map.controls.lockNorth" = "Nach Norden ausrichten"; +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +/* AI-translated - please verify with native speakers. */ +"map.controls.mapOptions" = "Kartenoptionen"; -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ -"map.controls.unlockNorth" = "Drehung freigeben"; +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +/* AI-translated - please verify with native speakers. */ +"map.controls.lockNorth" = "Norden oben"; /* Location: MapView.swift - Purpose: Accessibility label for refresh button */ "map.controls.refresh" = "Kontakte aktualisieren"; diff --git a/MC1/Resources/Localization/de.lproj/Onboarding.strings b/MC1/Resources/Localization/de.lproj/Onboarding.strings index 70006257..6305e78f 100644 --- a/MC1/Resources/Localization/de.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/de.lproj/Onboarding.strings @@ -107,6 +107,7 @@ /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ "deviceScan.error.authenticationFailed" = "Gerät konnte nicht gekoppelt werden. Überprüfe die PIN, entferne dann das Gerät und versuche es erneut."; +"deviceScan.error.pinRejected" = "Die PIN wurde nicht akzeptiert. Überprüfe die auf deinem Gerät angezeigte PIN und versuche es erneut. iOS fragt dich zuerst, ob die fehlgeschlagene Kopplung entfernt werden soll."; "deviceScan.error.connectionFailed" = "Verbindung zum Gerät fehlgeschlagen. Versuche es erneut, oder entferne das Gerät, wenn das Problem weiterhin besteht."; // MARK: - Troubleshooting Sheet diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 50dd1daf..2aa4880d 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -343,6 +343,15 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Akzeptiert 0-64 Sprünge"; +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Akzeptiert bis zu %d Bytes"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Akzeptiert -90 bis 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Akzeptiert -180 bis 180"; + // MARK: - Repeater Settings: Regions /* Location: RepeaterSettingsView.swift - Regions section title */ @@ -440,14 +449,21 @@ /* Location: RepeaterStatusView.swift - Noise floor label */ "remoteNodes.status.noiseFloor" = "Grundrauschen"; -/* Location: RepeaterStatusView.swift - Packets sent label */ -"remoteNodes.status.packetsSent" = "Gesendete Pakete"; +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Pakete"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Gesendet"; -/* Location: RepeaterStatusView.swift - Packets received label */ -"remoteNodes.status.packetsReceived" = "Empfangene Pakete"; +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Empfangen"; -/* Location: RepeaterStatusView.swift - Receive errors label */ -"remoteNodes.status.receiveErrors" = "Empfangene Paketfehler"; +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Fehler"; +"remoteNodes.status.sentDirect" = "Gesendet (Direkt)"; +"remoteNodes.status.sentFlood" = "Gesendet (Flood)"; +"remoteNodes.status.receivedDirect" = "Empfangen (Direkt)"; +"remoteNodes.status.receivedFlood" = "Empfangen (Flood)"; +"remoteNodes.status.duplicates" = "Duplikate"; /* Location: RepeaterStatusView.swift - Neighbors section label */ "remoteNodes.status.neighbors" = "Nachbarn"; @@ -551,6 +567,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Nachbarn neu laden"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Auf Karte anzeigen"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Nachbarn auf Karte anzeigen"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Nachbarkarte"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d Nachbarn nicht angezeigt"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -601,14 +634,19 @@ /* Location: NodeStatusHistoryView.swift - Neighbor count chart title */ "remoteNodes.history.neighborCount" = "Nachbaranzahl"; -/* Location: NodeStatusHistoryView.swift - Packets sent chart title */ -"remoteNodes.history.packetsSent" = "Gesendete Pakete"; +/* Location: RadioMetricCharts.swift - Sent packets chart title, under the Packets group header */ +"remoteNodes.history.packetsSent" = "Gesendet"; -/* Location: NodeStatusHistoryView.swift - Packets received chart title */ -"remoteNodes.history.packetsReceived" = "Empfangene Pakete"; +/* Location: RadioMetricCharts.swift - Received packets chart title, under the Packets group header */ +"remoteNodes.history.packetsReceived" = "Empfangen"; -/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ -"remoteNodes.history.receiveErrors" = "Empfangene Paketfehler"; +/* Location: RadioMetricCharts.swift - Receive errors chart title, under the Packets group header */ +"remoteNodes.history.receiveErrors" = "Fehler"; +"remoteNodes.history.direct" = "Direkt"; +"remoteNodes.history.flood" = "Flood"; +"remoteNodes.history.duplicates" = "Duplikate"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Pakete"; /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Aktiv"; diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/de.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..155bd8ff --- /dev/null +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d Nachbar nicht angezeigt + other + %d Nachbarn nicht angezeigt + + + + diff --git a/MC1/Resources/Localization/de.lproj/Settings.strings b/MC1/Resources/Localization/de.lproj/Settings.strings index 1e00e4fb..e99bca24 100644 --- a/MC1/Resources/Localization/de.lproj/Settings.strings +++ b/MC1/Resources/Localization/de.lproj/Settings.strings @@ -335,7 +335,7 @@ "advancedRadio.repeatMode" = "Repeater-Modus"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "Erstellt einen lokalen Repeater auf einer eigenen Frequenz. Nützlich beim Wandern und in abgelegenen Gebieten. Gültige Frequenzen: 433, 869, 918 MHz."; +"advancedRadio.repeatMode.footer" = "Erstellt einen lokalen Repeater auf einer eigenen Frequenz. Nützlich beim Wandern und in abgelegenen Gebieten. Gültige Frequenzen: 433, 869.495, 918 MHz."; // MARK: - Path Hash Mode Section @@ -697,11 +697,11 @@ // MARK: - Link Preview Settings Section -/* Section header for link preview settings */ -"linkPreviews.header" = "Linkvorschau"; +/* Section header for link content settings */ +"linkPreviews.header" = "Linkinhalte"; -/* Toggle label for link previews */ -"linkPreviews.toggle" = "Link-Vorschauen"; +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Linkinhalte anzeigen"; /* Toggle label for showing previews in DMs */ "linkPreviews.showInDMs" = "In Direktnachrichten anzeigen"; @@ -709,8 +709,11 @@ /* Toggle label for showing previews in channels */ "linkPreviews.showInChannels" = "In Kanälen anzeigen"; -/* Footer explaining link preview privacy implications */ -"linkPreviews.footer" = "Link-Vorschauen rufen Daten aus dem Web ab, was deine IP-Adresse dem Server offenbaren kann, der den Link hostet."; +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "Linkvorschauen und Bilder werden über die Internetverbindung deines Telefons geladen, nicht über das Mesh. Dabei kann deine IP-Adresse dem Server offenbart werden, der den Inhalt hostet, und es kann Mobilfunkdatenvolumen anfallen."; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "Bewegung reduzieren ist aktiviert, daher werden GIFs nicht automatisch abgespielt."; // MARK: - Node Settings Section @@ -1259,15 +1262,9 @@ /* Validation error: no empty channel slot left (param: channel name) */ "configImport.error.noAvailableChannelSlot" = "Kein freier Kanalplatz für \"%@\" verfügbar"; -/* Toggle label for inline images */ -"inlineImages.toggle" = "Inline-Bilder"; - /* Toggle label for auto-play GIFs */ "inlineImages.autoPlayGifs" = "GIFs automatisch abspielen"; -/* Footer explaining inline image privacy implications */ -"inlineImages.footer" = "Das Laden von Bildern über URLs kann Ihre IP-Adresse dem Server preisgeben, der das Bild hostet."; - // MARK: - Map Preview Settings Section /* Section header for chat map preview settings */ diff --git a/MC1/Resources/Localization/de.lproj/Tools.strings b/MC1/Resources/Localization/de.lproj/Tools.strings index 72913e0f..d0f0ae82 100644 --- a/MC1/Resources/Localization/de.lproj/Tools.strings +++ b/MC1/Resources/Localization/de.lproj/Tools.strings @@ -341,12 +341,6 @@ /* Location: LineOfSightView.swift - Repeater annotation */ "tools.lineOfSight.repeater" = "Repeater"; -/* Location: LineOfSightView.swift - Drop pin mode enabled */ -"tools.lineOfSight.cancelDropPin" = "Stecknadel abbrechen"; - -/* Location: LineOfSightView.swift - Drop pin mode disabled */ -"tools.lineOfSight.dropPin" = "Stecknadel setzen"; - /* Location: LineOfSightView.swift - Points section title */ "tools.lineOfSight.points" = "Punkte"; @@ -365,8 +359,8 @@ /* Location: LineOfSightView.swift - Loading elevation status */ "tools.lineOfSight.loadingElevation" = "Höhe wird geladen..."; -/* Location: LineOfSightView.swift - Select points hint */ -"tools.lineOfSight.selectPointsHint" = "Tippe auf die Stecknadel-Schaltfläche auf der Karte, um Punkte auszuwählen"; +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Lange auf die Karte drücken, um einen Standort hinzuzufügen"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "Höhendaten nicht verfügbar. Meeresspiegel (0m) wird als Näherung verwendet."; diff --git a/MC1/Resources/Localization/en.lproj/Chats.strings b/MC1/Resources/Localization/en.lproj/Chats.strings index be80cf8c..33175785 100644 --- a/MC1/Resources/Localization/en.lproj/Chats.strings +++ b/MC1/Resources/Localization/en.lproj/Chats.strings @@ -1264,3 +1264,17 @@ /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "Matching contacts"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Unverified name"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Channel message senders cannot be verified. This could be a different person."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Unverified name match"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 5f2eb936..a4c2a36e 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -1065,13 +1065,13 @@ "contacts.results.dismiss" = "Dismiss"; /* Location: TraceResultsSheet.swift - Purpose: Batch success count */ -"contacts.results.batchSuccess" = "%d of %d successful"; +"contacts.results.batchSuccess" = "%d of %d successful (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ "contacts.results.batchProgress" = "Running Trace %d of %d..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "Batch complete: %d of %d traces successful"; +"contacts.results.batchCompleteLabel" = "Batch complete: %d of %d traces successful (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ "contacts.results.batchProgressLabel" = "Batch progress: trace %d of %d"; diff --git a/MC1/Resources/Localization/en.lproj/Localizable.strings b/MC1/Resources/Localization/en.lproj/Localizable.strings index 9ac4ed35..cb1a2b00 100644 --- a/MC1/Resources/Localization/en.lproj/Localizable.strings +++ b/MC1/Resources/Localization/en.lproj/Localizable.strings @@ -546,6 +546,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Contact not found in database"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Your radio is out of contact slots. Remove a contact you no longer need, then retry sign-in."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Login cancelled"; diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index 51509d74..236b6c63 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -10,13 +10,9 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Done"; -"map.common.dismissOverlay" = "Dismiss"; // MARK: - Map Controls -/* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ -"map.controls.hideLabels" = "Hide labels"; - /* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ "map.controls.showLabels" = "Show labels"; @@ -26,14 +22,11 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ "map.controls.centerOnMyLocation" = "Center on my location"; -/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ -"map.controls.layers" = "Map layers"; - -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ -"map.controls.lockNorth" = "Lock to north"; +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +"map.controls.mapOptions" = "Map options"; -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ -"map.controls.unlockNorth" = "Unlock rotation"; +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +"map.controls.lockNorth" = "North up"; /* Location: MapView.swift - Purpose: Accessibility label for refresh button */ "map.controls.refresh" = "Refresh contacts"; diff --git a/MC1/Resources/Localization/en.lproj/Onboarding.strings b/MC1/Resources/Localization/en.lproj/Onboarding.strings index f57857eb..a40ff8a9 100644 --- a/MC1/Resources/Localization/en.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/en.lproj/Onboarding.strings @@ -107,6 +107,7 @@ /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ "deviceScan.error.authenticationFailed" = "Couldn't pair the device. Check the PIN, then remove the device and try again."; +"deviceScan.error.pinRejected" = "The PIN wasn't accepted. Check the PIN shown on your device, then try again. iOS will ask you to confirm removing the failed pairing first."; "deviceScan.error.connectionFailed" = "Couldn't connect to the device. Try again, or remove it if the problem continues."; // MARK: - Troubleshooting Sheet diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index 18f127e6..d408eee8 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -343,6 +343,15 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepts 0-64 hops"; +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Accepts up to %d bytes"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Accepts -90 to 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Accepts -180 to 180"; + // MARK: - Repeater Settings: Regions /* Location: RepeaterSettingsView.swift - Regions section title */ @@ -440,14 +449,26 @@ /* Location: RepeaterStatusView.swift - Noise floor label */ "remoteNodes.status.noiseFloor" = "Noise Floor"; -/* Location: RepeaterStatusView.swift - Packets sent label */ -"remoteNodes.status.packetsSent" = "Packets Sent"; - -/* Location: RepeaterStatusView.swift - Packets received label */ -"remoteNodes.status.packetsReceived" = "Packets Received"; - -/* Location: RepeaterStatusView.swift - Receive errors label */ -"remoteNodes.status.receiveErrors" = "Packet Errors Received"; +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Packets"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Sent"; + +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Received"; + +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Errors"; +/* Location: SharedNodeStatusViews.swift - Sent (direct) packets label */ +"remoteNodes.status.sentDirect" = "Sent (Direct)"; +/* Location: SharedNodeStatusViews.swift - Sent (flood) packets label */ +"remoteNodes.status.sentFlood" = "Sent (Flood)"; +/* Location: SharedNodeStatusViews.swift - Received (direct) packets label */ +"remoteNodes.status.receivedDirect" = "Received (Direct)"; +/* Location: SharedNodeStatusViews.swift - Received (flood) packets label */ +"remoteNodes.status.receivedFlood" = "Received (Flood)"; +/* Location: SharedNodeStatusViews.swift - Duplicate packets label */ +"remoteNodes.status.duplicates" = "Duplicates"; /* Location: RepeaterStatusView.swift - Neighbors section label */ "remoteNodes.status.neighbors" = "Neighbors"; @@ -580,6 +601,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Reload neighbors"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "View on Map"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "View neighbors on map"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Neighbors Map"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d neighbors not shown"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -630,14 +668,22 @@ /* Location: NodeStatusHistoryView.swift - Neighbor count chart title */ "remoteNodes.history.neighborCount" = "Neighbor Count"; -/* Location: NodeStatusHistoryView.swift - Packets sent chart title */ -"remoteNodes.history.packetsSent" = "Packets Sent"; - -/* Location: NodeStatusHistoryView.swift - Packets received chart title */ -"remoteNodes.history.packetsReceived" = "Packets Received"; - -/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ -"remoteNodes.history.receiveErrors" = "Packet Errors Received"; +/* Location: RadioMetricCharts.swift - Sent packets chart title, under the Packets group header */ +"remoteNodes.history.packetsSent" = "Sent"; + +/* Location: RadioMetricCharts.swift - Received packets chart title, under the Packets group header */ +"remoteNodes.history.packetsReceived" = "Received"; + +/* Location: RadioMetricCharts.swift - Receive errors chart title, under the Packets group header */ +"remoteNodes.history.receiveErrors" = "Errors"; +/* Location: RadioMetricCharts.swift - Direct packet series legend */ +"remoteNodes.history.direct" = "Direct"; +/* Location: RadioMetricCharts.swift - Flood packet series legend */ +"remoteNodes.history.flood" = "Flood"; +/* Location: RadioMetricCharts.swift - Duplicates chart title, under the Packets group header */ +"remoteNodes.history.duplicates" = "Duplicates"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Packets"; /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Active"; diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/en.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..09223185 --- /dev/null +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d neighbor not shown + other + %d neighbors not shown + + + + diff --git a/MC1/Resources/Localization/en.lproj/Settings.strings b/MC1/Resources/Localization/en.lproj/Settings.strings index 710782ed..9b29baec 100644 --- a/MC1/Resources/Localization/en.lproj/Settings.strings +++ b/MC1/Resources/Localization/en.lproj/Settings.strings @@ -334,7 +334,7 @@ "advancedRadio.repeatMode" = "Repeat Mode"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "Creates a local repeater on a dedicated frequency. Useful for hiking and remote areas. Valid frequencies: 433, 869, 918 MHz."; +"advancedRadio.repeatMode.footer" = "Creates a local repeater on a dedicated frequency. Useful for hiking and remote areas. Valid frequencies: 433, 869.495, 918 MHz."; // MARK: - Path Hash Mode Section @@ -696,33 +696,30 @@ // MARK: - Link Preview Settings Section -/* Section header for link preview settings */ -"linkPreviews.header" = "Link Previews"; +/* Section header for link content settings */ +"linkPreviews.header" = "Link Content"; -/* Toggle label for link previews */ -"linkPreviews.toggle" = "Link Previews"; +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Show Link Content"; -/* Toggle label for showing previews in DMs */ +/* Toggle label for showing link content in DMs */ "linkPreviews.showInDMs" = "Show in Direct Messages"; -/* Toggle label for showing previews in channels */ +/* Toggle label for showing link content in channels */ "linkPreviews.showInChannels" = "Show in Channels"; -/* Footer explaining link preview privacy implications */ -"linkPreviews.footer" = "Link previews fetch data from the web, which may reveal your IP address to the server hosting the link."; +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "Link previews and images are fetched over your phone's internet connection, not the mesh. This may reveal your IP address to the server hosting the content and can use cellular data."; +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "Reduce Motion is on, so GIFs will not auto-play."; -// MARK: - Inline Image Settings Section -/* Toggle label for inline images */ -"inlineImages.toggle" = "Inline Images"; +// MARK: - Inline Image Settings Section /* Toggle label for auto-play GIFs */ "inlineImages.autoPlayGifs" = "Auto-play GIFs"; -/* Footer explaining inline image privacy implications */ -"inlineImages.footer" = "Fetching images from URLs may reveal your IP address to the server hosting the image."; - // MARK: - Map Preview Settings Section diff --git a/MC1/Resources/Localization/en.lproj/Tools.strings b/MC1/Resources/Localization/en.lproj/Tools.strings index 21e5a7a1..41f82049 100644 --- a/MC1/Resources/Localization/en.lproj/Tools.strings +++ b/MC1/Resources/Localization/en.lproj/Tools.strings @@ -341,12 +341,6 @@ /* Location: LineOfSightView.swift - Repeater annotation */ "tools.lineOfSight.repeater" = "Repeater"; -/* Location: LineOfSightView.swift - Drop pin mode enabled */ -"tools.lineOfSight.cancelDropPin" = "Cancel drop pin"; - -/* Location: LineOfSightView.swift - Drop pin mode disabled */ -"tools.lineOfSight.dropPin" = "Drop pin"; - /* Location: LineOfSightView.swift - Points section title */ "tools.lineOfSight.points" = "Points"; @@ -365,8 +359,8 @@ /* Location: LineOfSightView.swift - Loading elevation status */ "tools.lineOfSight.loadingElevation" = "Loading elevation..."; -/* Location: LineOfSightView.swift - Select points hint */ -"tools.lineOfSight.selectPointsHint" = "Tap the pin button on the map to select points"; +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Long press on the map to add a location"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "Elevation data unavailable. Using sea level (0m) as approximation."; diff --git a/MC1/Resources/Localization/es.lproj/Chats.strings b/MC1/Resources/Localization/es.lproj/Chats.strings index 4b0a018d..8781e911 100644 --- a/MC1/Resources/Localization/es.lproj/Chats.strings +++ b/MC1/Resources/Localization/es.lproj/Chats.strings @@ -1255,3 +1255,17 @@ /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "Contactos coincidentes"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Nombre no verificado"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Los remitentes de mensajes de canal no se pueden verificar. Podría ser otra persona."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Coincidencia de nombre no verificado"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 7b50b96a..ecdcac72 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -1059,13 +1059,13 @@ "contacts.results.dismiss" = "Cerrar"; /* Location: TraceResultsSheet.swift - Purpose: Batch success count */ -"contacts.results.batchSuccess" = "%d de %d exitosas"; +"contacts.results.batchSuccess" = "%d de %d exitosas (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ "contacts.results.batchProgress" = "Ejecutando traza %d de %d..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "Lote completo: %d de %d trazas exitosas"; +"contacts.results.batchCompleteLabel" = "Lote completo: %d de %d trazas exitosas (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ "contacts.results.batchProgressLabel" = "Progreso del lote: traza %d de %d"; diff --git a/MC1/Resources/Localization/es.lproj/Localizable.strings b/MC1/Resources/Localization/es.lproj/Localizable.strings index 27b3b679..e79720e5 100644 --- a/MC1/Resources/Localization/es.lproj/Localizable.strings +++ b/MC1/Resources/Localization/es.lproj/Localizable.strings @@ -542,6 +542,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Contacto no encontrado en la base de datos"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Tu radio se ha quedado sin espacios para contactos. Elimina un contacto que ya no necesites y vuelve a intentar iniciar sesión."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Inicio de sesión cancelado"; diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 0be72268..755dd5e4 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -10,13 +10,9 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Listo"; -"map.common.dismissOverlay" = "Cerrar"; // MARK: - Map Controls -/* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ -"map.controls.hideLabels" = "Ocultar etiquetas"; - /* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ "map.controls.showLabels" = "Mostrar etiquetas"; @@ -26,14 +22,13 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ "map.controls.centerOnMyLocation" = "Centrar en mi ubicación"; -/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ -"map.controls.layers" = "Capas del mapa"; - -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ -"map.controls.lockNorth" = "Fijar al norte"; +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +/* AI-translated - please verify with native speakers. */ +"map.controls.mapOptions" = "Opciones del mapa"; -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ -"map.controls.unlockNorth" = "Desbloquear rotación"; +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +/* AI-translated - please verify with native speakers. */ +"map.controls.lockNorth" = "Norte arriba"; /* Location: MapView.swift - Purpose: Accessibility label for refresh button */ "map.controls.refresh" = "Actualizar contactos"; diff --git a/MC1/Resources/Localization/es.lproj/Onboarding.strings b/MC1/Resources/Localization/es.lproj/Onboarding.strings index d72b7cc4..9e082098 100644 --- a/MC1/Resources/Localization/es.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/es.lproj/Onboarding.strings @@ -107,6 +107,7 @@ /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ "deviceScan.error.authenticationFailed" = "No se pudo enlazar el dispositivo. Comprueba el PIN, luego elimina el dispositivo e inténtalo de nuevo."; +"deviceScan.error.pinRejected" = "No se aceptó el PIN. Comprueba el PIN que se muestra en tu dispositivo e inténtalo de nuevo. Primero, iOS te pedirá que confirmes la eliminación del enlace fallido."; "deviceScan.error.connectionFailed" = "No se pudo conectar al dispositivo. Inténtalo de nuevo, o elimínalo si el problema persiste."; // MARK: - Troubleshooting Sheet diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 13d3e520..26b7855e 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -343,6 +343,15 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Acepta 0-64 saltos"; +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Acepta hasta %d bytes"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Acepta -90 a 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Acepta -180 a 180"; + // MARK: - Repeater Settings: Regions /* Location: RepeaterSettingsView.swift - Regions section title */ @@ -440,14 +449,21 @@ /* Location: RepeaterStatusView.swift - Noise floor label */ "remoteNodes.status.noiseFloor" = "Ruido de fondo"; -/* Location: RepeaterStatusView.swift - Packets sent label */ -"remoteNodes.status.packetsSent" = "Paquetes enviados"; +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Paquetes"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Enviados"; -/* Location: RepeaterStatusView.swift - Packets received label */ -"remoteNodes.status.packetsReceived" = "Paquetes recibidos"; +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Recibidos"; -/* Location: RepeaterStatusView.swift - Receive errors label */ -"remoteNodes.status.receiveErrors" = "Errores de paquetes recibidos"; +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Errores"; +"remoteNodes.status.sentDirect" = "Enviados (Directo)"; +"remoteNodes.status.sentFlood" = "Enviados (Flood)"; +"remoteNodes.status.receivedDirect" = "Recibidos (Directo)"; +"remoteNodes.status.receivedFlood" = "Recibidos (Flood)"; +"remoteNodes.status.duplicates" = "Duplicados"; /* Location: RepeaterStatusView.swift - Neighbors section label */ "remoteNodes.status.neighbors" = "Vecinos"; @@ -551,6 +567,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Recargar vecinos"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Ver en el mapa"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Ver vecinos en el mapa"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Mapa de vecinos"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d vecinos no mostrados"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -602,13 +635,18 @@ "remoteNodes.history.neighborCount" = "Cantidad de vecinos"; /* Location: NodeStatusHistoryView.swift - Packets sent chart title */ -"remoteNodes.history.packetsSent" = "Paquetes enviados"; +"remoteNodes.history.packetsSent" = "Enviados"; /* Location: NodeStatusHistoryView.swift - Packets received chart title */ -"remoteNodes.history.packetsReceived" = "Paquetes recibidos"; +"remoteNodes.history.packetsReceived" = "Recibidos"; /* Location: NodeStatusHistoryView.swift - Receive errors chart title */ -"remoteNodes.history.receiveErrors" = "Errores de paquetes recibidos"; +"remoteNodes.history.receiveErrors" = "Errores"; +"remoteNodes.history.direct" = "Directo"; +"remoteNodes.history.flood" = "Flood"; +"remoteNodes.history.duplicates" = "Duplicados"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Paquetes"; /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Activo"; diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/es.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..08b04465 --- /dev/null +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d vecino no mostrado + other + %d vecinos no mostrados + + + + diff --git a/MC1/Resources/Localization/es.lproj/Settings.strings b/MC1/Resources/Localization/es.lproj/Settings.strings index 2709f101..6d626ee3 100644 --- a/MC1/Resources/Localization/es.lproj/Settings.strings +++ b/MC1/Resources/Localization/es.lproj/Settings.strings @@ -335,7 +335,7 @@ "advancedRadio.repeatMode" = "Modo Repetidor"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "Crea un repetidor local en una frecuencia dedicada. Útil para senderismo y áreas remotas. Frecuencias válidas: 433, 869, 918 MHz."; +"advancedRadio.repeatMode.footer" = "Crea un repetidor local en una frecuencia dedicada. Útil para senderismo y áreas remotas. Frecuencias válidas: 433, 869.495, 918 MHz."; // MARK: - Path Hash Mode Section @@ -697,11 +697,11 @@ // MARK: - Link Preview Settings Section -/* Section header for link preview settings */ -"linkPreviews.header" = "Vista previa de enlaces"; +/* Section header for link content settings */ +"linkPreviews.header" = "Contenido de enlaces"; -/* Toggle label for link previews */ -"linkPreviews.toggle" = "Vista previa de enlaces"; +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Mostrar contenido de enlaces"; /* Toggle label for showing previews in DMs */ "linkPreviews.showInDMs" = "Mostrar en mensajes directos"; @@ -709,8 +709,11 @@ /* Toggle label for showing previews in channels */ "linkPreviews.showInChannels" = "Mostrar en canales"; -/* Footer explaining link preview privacy implications */ -"linkPreviews.footer" = "Las vistas previas de enlaces obtienen datos de la web, lo que puede revelar tu dirección IP al servidor que aloja el enlace."; +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "Las vistas previas de enlaces y las imágenes se obtienen a través de la conexión a internet de tu teléfono, no de la malla. Esto puede revelar tu dirección IP al servidor que aloja el contenido y puede consumir datos móviles."; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "Reducir movimiento está activado, por lo que los GIF no se reproducirán automáticamente."; // MARK: - Node Settings Section @@ -1259,15 +1262,9 @@ /* Validation error: no empty channel slot left (param: channel name) */ "configImport.error.noAvailableChannelSlot" = "No hay ningún espacio de canal libre disponible para \"%@\""; -/* Toggle label for inline images */ -"inlineImages.toggle" = "Imágenes en línea"; - /* Toggle label for auto-play GIFs */ "inlineImages.autoPlayGifs" = "Reproducir GIFs automáticamente"; -/* Footer explaining inline image privacy implications */ -"inlineImages.footer" = "Obtener imágenes de URLs puede revelar tu dirección IP al servidor que aloja la imagen."; - // MARK: - Map Preview Settings Section /* Section header for chat map preview settings */ diff --git a/MC1/Resources/Localization/es.lproj/Tools.strings b/MC1/Resources/Localization/es.lproj/Tools.strings index 006e3d3d..c21cbe8a 100644 --- a/MC1/Resources/Localization/es.lproj/Tools.strings +++ b/MC1/Resources/Localization/es.lproj/Tools.strings @@ -341,12 +341,6 @@ /* Location: LineOfSightView.swift - Repeater annotation */ "tools.lineOfSight.repeater" = "Repetidor"; -/* Location: LineOfSightView.swift - Drop pin mode enabled */ -"tools.lineOfSight.cancelDropPin" = "Cancelar colocar pin"; - -/* Location: LineOfSightView.swift - Drop pin mode disabled */ -"tools.lineOfSight.dropPin" = "Colocar pin"; - /* Location: LineOfSightView.swift - Points section title */ "tools.lineOfSight.points" = "Puntos"; @@ -365,8 +359,8 @@ /* Location: LineOfSightView.swift - Loading elevation status */ "tools.lineOfSight.loadingElevation" = "Cargando elevación..."; -/* Location: LineOfSightView.swift - Select points hint */ -"tools.lineOfSight.selectPointsHint" = "Toca el botón de pin en el mapa para seleccionar puntos"; +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Mantén pulsado el mapa para añadir una ubicación"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "Datos de elevación no disponibles. Usando nivel del mar (0m) como aproximación."; diff --git a/MC1/Resources/Localization/fr.lproj/Chats.strings b/MC1/Resources/Localization/fr.lproj/Chats.strings index 1c2b78bc..d3b0e7d5 100644 --- a/MC1/Resources/Localization/fr.lproj/Chats.strings +++ b/MC1/Resources/Localization/fr.lproj/Chats.strings @@ -1255,3 +1255,17 @@ /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "Contacts correspondants"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Nom non vérifié"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Les expéditeurs des messages de canal ne peuvent pas être vérifiés. Cela pourrait être une autre personne."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Correspondance de nom non vérifiée"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index 4deb82f0..78727242 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -1059,13 +1059,13 @@ "contacts.results.dismiss" = "Fermer"; /* Location: TraceResultsSheet.swift - Purpose: Batch success count */ -"contacts.results.batchSuccess" = "%d sur %d réussis"; +"contacts.results.batchSuccess" = "%d sur %d réussis (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ "contacts.results.batchProgress" = "Tracé %d sur %d en cours..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "Lot terminé : %d tracés sur %d réussis"; +"contacts.results.batchCompleteLabel" = "Lot terminé : %d tracés sur %d réussis (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ "contacts.results.batchProgressLabel" = "Progression du lot : tracé %d sur %d"; diff --git a/MC1/Resources/Localization/fr.lproj/Localizable.strings b/MC1/Resources/Localization/fr.lproj/Localizable.strings index e34dffe0..95bde14e 100644 --- a/MC1/Resources/Localization/fr.lproj/Localizable.strings +++ b/MC1/Resources/Localization/fr.lproj/Localizable.strings @@ -551,6 +551,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Contact introuvable dans la base de données"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Votre radio n'a plus d'emplacements de contact disponibles. Supprimez un contact dont vous n'avez plus besoin, puis réessayez de vous connecter."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Connexion annulée"; diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index 6b593404..81428a6d 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -10,13 +10,9 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Terminé"; -"map.common.dismissOverlay" = "Fermer"; // MARK: - Map Controls -/* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ -"map.controls.hideLabels" = "Masquer les étiquettes"; - /* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ "map.controls.showLabels" = "Afficher les étiquettes"; @@ -26,14 +22,13 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ "map.controls.centerOnMyLocation" = "Centrer sur ma position"; -/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ -"map.controls.layers" = "Couches de la carte"; - -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ -"map.controls.lockNorth" = "Verrouiller au nord"; +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +/* AI-translated - please verify with native speakers. */ +"map.controls.mapOptions" = "Options de la carte"; -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ -"map.controls.unlockNorth" = "Déverrouiller la rotation"; +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +/* AI-translated - please verify with native speakers. */ +"map.controls.lockNorth" = "Nord en haut"; /* Location: MapView.swift - Purpose: Accessibility label for refresh button */ "map.controls.refresh" = "Actualiser les contacts"; diff --git a/MC1/Resources/Localization/fr.lproj/Onboarding.strings b/MC1/Resources/Localization/fr.lproj/Onboarding.strings index 9ff9509c..1e18b1a2 100644 --- a/MC1/Resources/Localization/fr.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/fr.lproj/Onboarding.strings @@ -107,6 +107,7 @@ /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ "deviceScan.error.authenticationFailed" = "Impossible de jumeler l'appareil. Vérifiez le code PIN, puis supprimez l'appareil et réessayez."; +"deviceScan.error.pinRejected" = "Le code PIN n'a pas été accepté. Vérifiez le code PIN affiché sur votre appareil, puis réessayez. iOS vous demandera d'abord de confirmer la suppression du jumelage échoué."; "deviceScan.error.connectionFailed" = "Impossible de se connecter à l'appareil. Réessayez, ou supprimez-le si le problème persiste."; // MARK: - Troubleshooting Sheet diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index c72c3318..236e3f4a 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -343,6 +343,15 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepte 0-64 sauts"; +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Accepte jusqu'à %d octets"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Accepte -90 à 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Accepte -180 à 180"; + // MARK: - Repeater Settings: Regions /* Location: RepeaterSettingsView.swift - Regions section title */ @@ -440,14 +449,21 @@ /* Location: RepeaterStatusView.swift - Noise floor label */ "remoteNodes.status.noiseFloor" = "Plancher de bruit"; -/* Location: RepeaterStatusView.swift - Packets sent label */ -"remoteNodes.status.packetsSent" = "Paquets envoyés"; +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Paquets"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Envoyés"; -/* Location: RepeaterStatusView.swift - Packets received label */ -"remoteNodes.status.packetsReceived" = "Paquets reçus"; +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Reçus"; -/* Location: RepeaterStatusView.swift - Receive errors label */ -"remoteNodes.status.receiveErrors" = "Erreurs de paquets reçus"; +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Erreurs"; +"remoteNodes.status.sentDirect" = "Envoyés (Direct)"; +"remoteNodes.status.sentFlood" = "Envoyés (Flood)"; +"remoteNodes.status.receivedDirect" = "Reçus (Direct)"; +"remoteNodes.status.receivedFlood" = "Reçus (Flood)"; +"remoteNodes.status.duplicates" = "Doublons"; /* Location: RepeaterStatusView.swift - Neighbors section label */ "remoteNodes.status.neighbors" = "Voisins"; @@ -551,6 +567,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Recharger les voisins"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Voir sur la carte"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Voir les voisins sur la carte"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Carte des voisins"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d voisins non affichés"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -602,13 +635,18 @@ "remoteNodes.history.neighborCount" = "Nombre de voisins"; /* Location: NodeStatusHistoryView.swift - Packets sent chart title */ -"remoteNodes.history.packetsSent" = "Paquets envoyés"; +"remoteNodes.history.packetsSent" = "Envoyés"; /* Location: NodeStatusHistoryView.swift - Packets received chart title */ -"remoteNodes.history.packetsReceived" = "Paquets reçus"; +"remoteNodes.history.packetsReceived" = "Reçus"; /* Location: NodeStatusHistoryView.swift - Receive errors chart title */ -"remoteNodes.history.receiveErrors" = "Erreurs de paquets reçus"; +"remoteNodes.history.receiveErrors" = "Erreurs"; +"remoteNodes.history.direct" = "Direct"; +"remoteNodes.history.flood" = "Flood"; +"remoteNodes.history.duplicates" = "Doublons"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Paquets"; /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Actif"; diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/fr.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..530fc6bd --- /dev/null +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d voisin non affiché + other + %d voisins non affichés + + + + diff --git a/MC1/Resources/Localization/fr.lproj/Settings.strings b/MC1/Resources/Localization/fr.lproj/Settings.strings index 2bf09e61..7e3bac90 100644 --- a/MC1/Resources/Localization/fr.lproj/Settings.strings +++ b/MC1/Resources/Localization/fr.lproj/Settings.strings @@ -335,7 +335,7 @@ "advancedRadio.repeatMode" = "Mode Répéteur"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "Crée un répéteur local sur une fréquence dédiée. Utile pour la randonnée et les zones isolées. Fréquences valides : 433, 869, 918 MHz."; +"advancedRadio.repeatMode.footer" = "Crée un répéteur local sur une fréquence dédiée. Utile pour la randonnée et les zones isolées. Fréquences valides : 433, 869.495, 918 MHz."; /* Accessibility label for voltage text field - %d is the percentage level */ @@ -697,11 +697,11 @@ // MARK: - Link Preview Settings Section -/* Section header for link preview settings */ -"linkPreviews.header" = "Aperçus de liens"; +/* Section header for link content settings */ +"linkPreviews.header" = "Contenu des liens"; -/* Toggle label for link previews */ -"linkPreviews.toggle" = "Aperçus des liens"; +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Afficher le contenu des liens"; /* Toggle label for showing previews in DMs */ "linkPreviews.showInDMs" = "Afficher dans les messages directs"; @@ -709,8 +709,11 @@ /* Toggle label for showing previews in channels */ "linkPreviews.showInChannels" = "Afficher dans les canaux"; -/* Footer explaining link preview privacy implications */ -"linkPreviews.footer" = "Les aperçus de liens récupèrent des données du web, ce qui peut révéler votre adresse IP au serveur hébergeant le lien."; +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "Les aperçus de liens et les images sont récupérés via la connexion internet de votre téléphone, pas via le maillage. Cela peut révéler votre adresse IP au serveur hébergeant le contenu et peut consommer des données cellulaires."; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "Réduire les animations est activé, les GIF ne seront donc pas lus automatiquement."; // MARK: - Node Settings Section @@ -1259,15 +1262,9 @@ /* Validation error: no empty channel slot left (param: channel name) */ "configImport.error.noAvailableChannelSlot" = "Aucun emplacement de canal libre disponible pour \"%@\""; -/* Toggle label for inline images */ -"inlineImages.toggle" = "Images intégrées"; - /* Toggle label for auto-play GIFs */ "inlineImages.autoPlayGifs" = "Lecture automatique des GIF"; -/* Footer explaining inline image privacy implications */ -"inlineImages.footer" = "Le chargement d'images à partir d'URL peut révéler votre adresse IP au serveur hébergeant l'image."; - // MARK: - Map Preview Settings Section /* Section header for chat map preview settings */ diff --git a/MC1/Resources/Localization/fr.lproj/Tools.strings b/MC1/Resources/Localization/fr.lproj/Tools.strings index c1d33937..c294f122 100644 --- a/MC1/Resources/Localization/fr.lproj/Tools.strings +++ b/MC1/Resources/Localization/fr.lproj/Tools.strings @@ -341,12 +341,6 @@ /* Location: LineOfSightView.swift - Repeater annotation */ "tools.lineOfSight.repeater" = "Répéteur"; -/* Location: LineOfSightView.swift - Drop pin mode enabled */ -"tools.lineOfSight.cancelDropPin" = "Annuler le placement d'épingle"; - -/* Location: LineOfSightView.swift - Drop pin mode disabled */ -"tools.lineOfSight.dropPin" = "Placer une épingle"; - /* Location: LineOfSightView.swift - Points section title */ "tools.lineOfSight.points" = "Points"; @@ -365,8 +359,8 @@ /* Location: LineOfSightView.swift - Loading elevation status */ "tools.lineOfSight.loadingElevation" = "Chargement de l'altitude..."; -/* Location: LineOfSightView.swift - Select points hint */ -"tools.lineOfSight.selectPointsHint" = "Appuyez sur le bouton épingle sur la carte pour sélectionner des points"; +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Appuyez longuement sur la carte pour ajouter un emplacement"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "Données d'altitude non disponibles. Utilisation du niveau de la mer (0 m) comme approximation."; diff --git a/MC1/Resources/Localization/nl.lproj/Chats.strings b/MC1/Resources/Localization/nl.lproj/Chats.strings index 1347d3b3..bd66fff7 100644 --- a/MC1/Resources/Localization/nl.lproj/Chats.strings +++ b/MC1/Resources/Localization/nl.lproj/Chats.strings @@ -1255,3 +1255,17 @@ /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "Overeenkomende contacten"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Niet geverifieerde naam"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Afzenders van kanaalberichten kunnen niet worden geverifieerd. Dit kan een andere persoon zijn."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Niet geverifieerde naamovereenkomst"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 7b9538e2..d395ca02 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -1059,13 +1059,13 @@ "contacts.results.dismiss" = "Sluiten"; /* Location: TraceResultsSheet.swift - Purpose: Batch success count */ -"contacts.results.batchSuccess" = "%d van %d succesvol"; +"contacts.results.batchSuccess" = "%d van %d succesvol (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ "contacts.results.batchProgress" = "Trace %d van %d uitvoeren..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "Batch voltooid: %d van %d traces succesvol"; +"contacts.results.batchCompleteLabel" = "Batch voltooid: %d van %d traces succesvol (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ "contacts.results.batchProgressLabel" = "Batch-voortgang: trace %d van %d"; diff --git a/MC1/Resources/Localization/nl.lproj/Localizable.strings b/MC1/Resources/Localization/nl.lproj/Localizable.strings index ed699375..62b11b2d 100644 --- a/MC1/Resources/Localization/nl.lproj/Localizable.strings +++ b/MC1/Resources/Localization/nl.lproj/Localizable.strings @@ -553,6 +553,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Contact niet gevonden in de database"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Je radio heeft geen contactplekken meer vrij. Verwijder een contact dat je niet meer nodig hebt en probeer daarna opnieuw in te loggen."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Inloggen geannuleerd"; diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index 3e303cf9..b4dcde3a 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -10,13 +10,9 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Klaar"; -"map.common.dismissOverlay" = "Sluiten"; // MARK: - Map Controls -/* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ -"map.controls.hideLabels" = "Labels verbergen"; - /* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ "map.controls.showLabels" = "Labels tonen"; @@ -26,14 +22,13 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ "map.controls.centerOnMyLocation" = "Centreren op mijn locatie"; -/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ -"map.controls.layers" = "Kaartlagen"; - -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ -"map.controls.lockNorth" = "Vergrendel op noorden"; +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +/* AI-translated - please verify with native speakers. */ +"map.controls.mapOptions" = "Kaartopties"; -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ -"map.controls.unlockNorth" = "Rotatie ontgrendelen"; +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +/* AI-translated - please verify with native speakers. */ +"map.controls.lockNorth" = "Noorden boven"; /* Location: MapView.swift - Purpose: Accessibility label for refresh button */ "map.controls.refresh" = "Contacten vernieuwen"; diff --git a/MC1/Resources/Localization/nl.lproj/Onboarding.strings b/MC1/Resources/Localization/nl.lproj/Onboarding.strings index 60f27371..4d8f1371 100644 --- a/MC1/Resources/Localization/nl.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/nl.lproj/Onboarding.strings @@ -107,6 +107,7 @@ /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ "deviceScan.error.authenticationFailed" = "Kan apparaat niet koppelen. Controleer de pincode, verwijder vervolgens het apparaat en probeer het opnieuw."; +"deviceScan.error.pinRejected" = "De pincode is niet geaccepteerd. Controleer de pincode die op je apparaat wordt weergegeven en probeer het opnieuw. iOS vraagt je eerst om te bevestigen dat de mislukte koppeling wordt verwijderd."; "deviceScan.error.connectionFailed" = "Kan geen verbinding maken met het apparaat. Probeer het opnieuw, of verwijder het als het probleem aanhoudt."; // MARK: - Troubleshooting Sheet diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 62f60a03..203027f3 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -343,6 +343,15 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepteert 0-64 sprongen"; +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Accepteert tot %d bytes"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Accepteert -90 tot 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Accepteert -180 tot 180"; + // MARK: - Repeater Settings: Regions /* Location: RepeaterSettingsView.swift - Regions section title */ @@ -440,14 +449,21 @@ /* Location: RepeaterStatusView.swift - Noise floor label */ "remoteNodes.status.noiseFloor" = "Ruisvloer"; -/* Location: RepeaterStatusView.swift - Packets sent label */ -"remoteNodes.status.packetsSent" = "Pakketten verzonden"; +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Pakketten"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Verzonden"; -/* Location: RepeaterStatusView.swift - Packets received label */ -"remoteNodes.status.packetsReceived" = "Pakketten ontvangen"; +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Ontvangen"; -/* Location: RepeaterStatusView.swift - Receive errors label */ -"remoteNodes.status.receiveErrors" = "Pakketfouten ontvangen"; +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Fouten"; +"remoteNodes.status.sentDirect" = "Verzonden (Direct)"; +"remoteNodes.status.sentFlood" = "Verzonden (Flood)"; +"remoteNodes.status.receivedDirect" = "Ontvangen (Direct)"; +"remoteNodes.status.receivedFlood" = "Ontvangen (Flood)"; +"remoteNodes.status.duplicates" = "Duplicaten"; /* Location: RepeaterStatusView.swift - Neighbors section label */ "remoteNodes.status.neighbors" = "Buren"; @@ -551,6 +567,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Buren opnieuw laden"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Toon op kaart"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Buren op kaart tonen"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Burenkaart"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d buren niet weergegeven"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -601,14 +634,19 @@ /* Location: NodeStatusHistoryView.swift - Neighbor count chart title */ "remoteNodes.history.neighborCount" = "Aantal buren"; -/* Location: NodeStatusHistoryView.swift - Packets sent chart title */ -"remoteNodes.history.packetsSent" = "Pakketten verzonden"; +/* Location: RadioMetricCharts.swift - Sent packets chart title, under the Packets group header */ +"remoteNodes.history.packetsSent" = "Verzonden"; -/* Location: NodeStatusHistoryView.swift - Packets received chart title */ -"remoteNodes.history.packetsReceived" = "Pakketten ontvangen"; +/* Location: RadioMetricCharts.swift - Received packets chart title, under the Packets group header */ +"remoteNodes.history.packetsReceived" = "Ontvangen"; -/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ -"remoteNodes.history.receiveErrors" = "Pakketfouten ontvangen"; +/* Location: RadioMetricCharts.swift - Receive errors chart title, under the Packets group header */ +"remoteNodes.history.receiveErrors" = "Fouten"; +"remoteNodes.history.direct" = "Direct"; +"remoteNodes.history.flood" = "Flood"; +"remoteNodes.history.duplicates" = "Duplicaten"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Pakketten"; /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Actief"; diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/nl.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..29e18254 --- /dev/null +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d buur niet weergegeven + other + %d buren niet weergegeven + + + + diff --git a/MC1/Resources/Localization/nl.lproj/Settings.strings b/MC1/Resources/Localization/nl.lproj/Settings.strings index 0caad03a..2984d2ba 100644 --- a/MC1/Resources/Localization/nl.lproj/Settings.strings +++ b/MC1/Resources/Localization/nl.lproj/Settings.strings @@ -335,7 +335,7 @@ "advancedRadio.repeatMode" = "Repeatermodus"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "Creëert een lokale repeater op een eigen frequentie. Handig voor wandelen en afgelegen gebieden. Geldige frequenties: 433, 869, 918 MHz."; +"advancedRadio.repeatMode.footer" = "Creëert een lokale repeater op een eigen frequentie. Handig voor wandelen en afgelegen gebieden. Geldige frequenties: 433, 869.495, 918 MHz."; /* Accessibility label for voltage text field at specific battery percent - %d is percent */ @@ -697,11 +697,11 @@ // MARK: - Link Preview Settings Section -/* Section header for link preview settings */ -"linkPreviews.header" = "Linkvoorbeelden"; +/* Section header for link content settings */ +"linkPreviews.header" = "Linkinhoud"; -/* Toggle label for link previews */ -"linkPreviews.toggle" = "Linkvoorbeelden"; +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Linkinhoud tonen"; /* Toggle label for showing previews in DMs */ "linkPreviews.showInDMs" = "Tonen in directe berichten"; @@ -709,8 +709,11 @@ /* Toggle label for showing previews in channels */ "linkPreviews.showInChannels" = "Tonen in kanalen"; -/* Footer explaining link preview privacy implications */ -"linkPreviews.footer" = "Linkvoorbeelden halen gegevens op van het web, wat je IP-adres kan onthullen aan de server die de link host."; +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "Linkvoorbeelden en afbeeldingen worden opgehaald via de internetverbinding van je telefoon, niet via het mesh. Dit kan je IP-adres onthullen aan de server die de inhoud host en kan mobiele data gebruiken."; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "Verminder beweging staat aan, dus GIF's worden niet automatisch afgespeeld."; // MARK: - Node Settings Section @@ -1259,15 +1262,9 @@ /* Validation error: no empty channel slot left (param: channel name) */ "configImport.error.noAvailableChannelSlot" = "Geen lege kanaalplek beschikbaar voor \"%@\""; -/* Toggle label for inline images */ -"inlineImages.toggle" = "Inline afbeeldingen"; - /* Toggle label for auto-play GIFs */ "inlineImages.autoPlayGifs" = "GIF's automatisch afspelen"; -/* Footer explaining inline image privacy implications */ -"inlineImages.footer" = "Het ophalen van afbeeldingen via URL's kan uw IP-adres onthullen aan de server die de afbeelding host."; - // MARK: - Map Preview Settings Section /* Section header for chat map preview settings */ diff --git a/MC1/Resources/Localization/nl.lproj/Tools.strings b/MC1/Resources/Localization/nl.lproj/Tools.strings index 87cbe516..202b81ad 100644 --- a/MC1/Resources/Localization/nl.lproj/Tools.strings +++ b/MC1/Resources/Localization/nl.lproj/Tools.strings @@ -341,12 +341,6 @@ /* Location: LineOfSightView.swift - Repeater annotation */ "tools.lineOfSight.repeater" = "Repeater"; -/* Location: LineOfSightView.swift - Drop pin mode enabled */ -"tools.lineOfSight.cancelDropPin" = "Speld annuleren"; - -/* Location: LineOfSightView.swift - Drop pin mode disabled */ -"tools.lineOfSight.dropPin" = "Speld plaatsen"; - /* Location: LineOfSightView.swift - Points section title */ "tools.lineOfSight.points" = "Punten"; @@ -365,8 +359,8 @@ /* Location: LineOfSightView.swift - Loading elevation status */ "tools.lineOfSight.loadingElevation" = "Hoogte laden..."; -/* Location: LineOfSightView.swift - Select points hint */ -"tools.lineOfSight.selectPointsHint" = "Tik op de speldknop op de kaart om punten te selecteren"; +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Houd ingedrukt op de kaart om een locatie toe te voegen"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "Hoogtegegevens niet beschikbaar. Zeeniveau (0m) wordt gebruikt als benadering."; diff --git a/MC1/Resources/Localization/pl.lproj/Chats.strings b/MC1/Resources/Localization/pl.lproj/Chats.strings index 05e3c40d..1e7fa9ee 100644 --- a/MC1/Resources/Localization/pl.lproj/Chats.strings +++ b/MC1/Resources/Localization/pl.lproj/Chats.strings @@ -1249,3 +1249,17 @@ /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "Pasujące kontakty"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Niezweryfikowana nazwa"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Nadawców wiadomości w kanałach nie można zweryfikować. Może to być inna osoba."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Niezweryfikowane dopasowanie nazwy"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 47e5384a..42c02f3a 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -1046,13 +1046,13 @@ "contacts.results.dismiss" = "Zamknij"; /* Location: TraceResultsSheet.swift - Purpose: Batch success count */ -"contacts.results.batchSuccess" = "%d z %d udanych"; +"contacts.results.batchSuccess" = "%d z %d udanych (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ "contacts.results.batchProgress" = "Uruchamianie śledzenia %d z %d..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "Wsad zakończony: %d z %d śledzeń udanych"; +"contacts.results.batchCompleteLabel" = "Wsad zakończony: %d z %d śledzeń udanych (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ "contacts.results.batchProgressLabel" = "Postęp wsadu: śledzenie %d z %d"; diff --git a/MC1/Resources/Localization/pl.lproj/Localizable.strings b/MC1/Resources/Localization/pl.lproj/Localizable.strings index 1d26b832..034ae5dc 100644 --- a/MC1/Resources/Localization/pl.lproj/Localizable.strings +++ b/MC1/Resources/Localization/pl.lproj/Localizable.strings @@ -550,6 +550,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Nie znaleziono kontaktu w bazie danych"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "W Twoim radiu zabrakło miejsc na kontakty. Usuń kontakt, którego już nie potrzebujesz, a następnie spróbuj zalogować się ponownie."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Logowanie anulowane"; diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 48e97436..c2e7e1f8 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -10,13 +10,9 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Gotowe"; -"map.common.dismissOverlay" = "Zamknij"; // MARK: - Map Controls -/* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ -"map.controls.hideLabels" = "Ukryj etykiety"; - /* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ "map.controls.showLabels" = "Pokaż etykiety"; @@ -26,14 +22,13 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ "map.controls.centerOnMyLocation" = "Wyśrodkuj na mojej lokalizacji"; -/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ -"map.controls.layers" = "Warstwy mapy"; - -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ -"map.controls.lockNorth" = "Zablokuj na północ"; +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +/* AI-translated - please verify with native speakers. */ +"map.controls.mapOptions" = "Opcje mapy"; -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ -"map.controls.unlockNorth" = "Odblokuj obrót"; +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +/* AI-translated - please verify with native speakers. */ +"map.controls.lockNorth" = "Północ u górze"; /* Location: MapView.swift - Purpose: Accessibility label for refresh button */ "map.controls.refresh" = "Odśwież kontakty"; diff --git a/MC1/Resources/Localization/pl.lproj/Onboarding.strings b/MC1/Resources/Localization/pl.lproj/Onboarding.strings index 4104fa16..56458199 100644 --- a/MC1/Resources/Localization/pl.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/pl.lproj/Onboarding.strings @@ -107,6 +107,7 @@ /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ "deviceScan.error.authenticationFailed" = "Nie udało się sparować urządzenia. Sprawdź PIN, następnie usuń urządzenie i spróbuj ponownie."; +"deviceScan.error.pinRejected" = "PIN nie został zaakceptowany. Sprawdź PIN wyświetlany na urządzeniu i spróbuj ponownie. iOS najpierw poprosi o potwierdzenie usunięcia nieudanego parowania."; "deviceScan.error.connectionFailed" = "Nie można połączyć się z urządzeniem. Spróbuj ponownie lub usuń je, jeśli problem nie ustępuje."; // MARK: - Troubleshooting Sheet diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 51343456..f35dc6c6 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -340,6 +340,15 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Akceptuje 0-64 skoków"; +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Akceptuje do %d bajtów"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Akceptuje -90 do 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Akceptuje -180 do 180"; + // MARK: - Repeater Settings: Regions /* Location: RepeaterSettingsView.swift - Regions section title */ @@ -437,14 +446,21 @@ /* Location: RepeaterStatusView.swift - Noise floor label */ "remoteNodes.status.noiseFloor" = "Próg szumów"; -/* Location: RepeaterStatusView.swift - Packets sent label */ -"remoteNodes.status.packetsSent" = "Pakiety wysłane"; +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Pakiety"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Wysłane"; -/* Location: RepeaterStatusView.swift - Packets received label */ -"remoteNodes.status.packetsReceived" = "Pakiety odebrane"; +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Odebrane"; -/* Location: RepeaterStatusView.swift - Receive errors label */ -"remoteNodes.status.receiveErrors" = "Błędy pakietów odebrane"; +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Błędy"; +"remoteNodes.status.sentDirect" = "Wysłane (Bezpośrednie)"; +"remoteNodes.status.sentFlood" = "Wysłane (Flood)"; +"remoteNodes.status.receivedDirect" = "Odebrane (Bezpośrednie)"; +"remoteNodes.status.receivedFlood" = "Odebrane (Flood)"; +"remoteNodes.status.duplicates" = "Duplikaty"; /* Location: RepeaterStatusView.swift - Neighbors section label */ "remoteNodes.status.neighbors" = "Sąsiedzi"; @@ -548,6 +564,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Odśwież sąsiadów"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Pokaż na mapie"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Pokaż sąsiadów na mapie"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Mapa sąsiadów"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "Nie pokazano %d sąsiadów"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -599,13 +632,18 @@ "remoteNodes.history.neighborCount" = "Liczba sąsiadów"; /* Location: NodeStatusHistoryView.swift - Packets sent chart title */ -"remoteNodes.history.packetsSent" = "Pakiety wysłane"; +"remoteNodes.history.packetsSent" = "Wysłane"; /* Location: NodeStatusHistoryView.swift - Packets received chart title */ -"remoteNodes.history.packetsReceived" = "Pakiety odebrane"; +"remoteNodes.history.packetsReceived" = "Odebrane"; /* Location: NodeStatusHistoryView.swift - Receive errors chart title */ -"remoteNodes.history.receiveErrors" = "Błędy pakietów odebrane"; +"remoteNodes.history.receiveErrors" = "Błędy"; +"remoteNodes.history.direct" = "Bezpośrednie"; +"remoteNodes.history.flood" = "Flood"; +"remoteNodes.history.duplicates" = "Duplikaty"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Pakiety"; /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Aktywny"; diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/pl.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..a9fda4b4 --- /dev/null +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.stringsdict @@ -0,0 +1,32 @@ + + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Nie pokazano %d sąsiada + few + Nie pokazano %d sąsiadów + many + Nie pokazano %d sąsiadów + other + Nie pokazano %d sąsiadów + + + + diff --git a/MC1/Resources/Localization/pl.lproj/Settings.strings b/MC1/Resources/Localization/pl.lproj/Settings.strings index 1cf55b99..3424c32a 100644 --- a/MC1/Resources/Localization/pl.lproj/Settings.strings +++ b/MC1/Resources/Localization/pl.lproj/Settings.strings @@ -335,7 +335,7 @@ "advancedRadio.repeatMode" = "Tryb Repeatera"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "Tworzy lokalny repeater na wydzielonej częstotliwości. Przydatny podczas pieszych wędrówek i w odległych obszarach. Prawidłowe częstotliwości: 433, 869, 918 MHz."; +"advancedRadio.repeatMode.footer" = "Tworzy lokalny repeater na wydzielonej częstotliwości. Przydatny podczas pieszych wędrówek i w odległych obszarach. Prawidłowe częstotliwości: 433, 869.495, 918 MHz."; // MARK: - Path Hash Mode Section @@ -697,11 +697,11 @@ // MARK: - Link Preview Settings Section -/* Section header for link preview settings */ -"linkPreviews.header" = "Podgląd linków"; +/* Section header for link content settings */ +"linkPreviews.header" = "Zawartość linków"; -/* Toggle label for link previews */ -"linkPreviews.toggle" = "Podgląd linków"; +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Pokaż zawartość linków"; /* Toggle label for showing previews in DMs */ "linkPreviews.showInDMs" = "Pokazuj w wiadomościach bezpośrednich"; @@ -709,8 +709,11 @@ /* Toggle label for showing previews in channels */ "linkPreviews.showInChannels" = "Pokazuj w kanałach"; -/* Footer explaining link preview privacy implications */ -"linkPreviews.footer" = "Podgląd linków pobiera dane z sieci, co może ujawnić Twój adres IP serwerowi hostującemu link."; +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "Podglądy linków i obrazy są pobierane przez połączenie internetowe telefonu, a nie przez sieć mesh. Może to ujawnić Twój adres IP serwerowi hostującemu treść i zużywać dane komórkowe."; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "Ograniczanie ruchu jest włączone, więc GIF-y nie będą odtwarzane automatycznie."; // MARK: - Node Settings Section @@ -1259,15 +1262,9 @@ /* Validation error: no empty channel slot left (param: channel name) */ "configImport.error.noAvailableChannelSlot" = "Brak wolnego miejsca na kanał dla \"%@\""; -/* Toggle label for inline images */ -"inlineImages.toggle" = "Obrazy w wiadomościach"; - /* Toggle label for auto-play GIFs */ "inlineImages.autoPlayGifs" = "Automatyczne odtwarzanie GIF-ów"; -/* Footer explaining inline image privacy implications */ -"inlineImages.footer" = "Pobieranie obrazów z adresów URL może ujawnić Twój adres IP serwerowi hostującemu obraz."; - // MARK: - Map Preview Settings Section /* Section header for chat map preview settings */ diff --git a/MC1/Resources/Localization/pl.lproj/Tools.strings b/MC1/Resources/Localization/pl.lproj/Tools.strings index fa16ffa5..af88c4cc 100644 --- a/MC1/Resources/Localization/pl.lproj/Tools.strings +++ b/MC1/Resources/Localization/pl.lproj/Tools.strings @@ -341,12 +341,6 @@ /* Location: LineOfSightView.swift - Repeater annotation */ "tools.lineOfSight.repeater" = "Przekaźnik"; -/* Location: LineOfSightView.swift - Drop pin mode enabled */ -"tools.lineOfSight.cancelDropPin" = "Anuluj upuszczenie pinezki"; - -/* Location: LineOfSightView.swift - Drop pin mode disabled */ -"tools.lineOfSight.dropPin" = "Upuść pinezkę"; - /* Location: LineOfSightView.swift - Points section title */ "tools.lineOfSight.points" = "Punkty"; @@ -365,8 +359,8 @@ /* Location: LineOfSightView.swift - Loading elevation status */ "tools.lineOfSight.loadingElevation" = "Ładowanie wysokości..."; -/* Location: LineOfSightView.swift - Select points hint */ -"tools.lineOfSight.selectPointsHint" = "Dotknij przycisk pinezki na mapie, aby wybrać punkty"; +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Przytrzymaj na mapie, aby dodać lokalizację"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "Dane wysokości niedostępne. Używanie poziomu morza (0m) jako przybliżenia."; diff --git a/MC1/Resources/Localization/ru.lproj/Chats.strings b/MC1/Resources/Localization/ru.lproj/Chats.strings index dd6be5fa..8bd669bc 100644 --- a/MC1/Resources/Localization/ru.lproj/Chats.strings +++ b/MC1/Resources/Localization/ru.lproj/Chats.strings @@ -1246,3 +1246,17 @@ /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "Подходящие контакты"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Непроверенное имя"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Отправителей сообщений в каналах невозможно проверить. Это может быть другой человек."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Непроверенное совпадение имени"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index 30d7a1c5..d2897ca9 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -1046,13 +1046,13 @@ "contacts.results.dismiss" = "Закрыть"; /* Location: TraceResultsSheet.swift - Purpose: Batch success count */ -"contacts.results.batchSuccess" = "%d из %d успешных"; +"contacts.results.batchSuccess" = "%d из %d успешных (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ "contacts.results.batchProgress" = "Выполняется трассировка %d из %d..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "Пакет завершён: %d из %d трассировок успешны"; +"contacts.results.batchCompleteLabel" = "Пакет завершён: %d из %d трассировок успешны (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ "contacts.results.batchProgressLabel" = "Прогресс пакета: трассировка %d из %d"; diff --git a/MC1/Resources/Localization/ru.lproj/Localizable.strings b/MC1/Resources/Localization/ru.lproj/Localizable.strings index 7fc3a6b2..27b25ae2 100644 --- a/MC1/Resources/Localization/ru.lproj/Localizable.strings +++ b/MC1/Resources/Localization/ru.lproj/Localizable.strings @@ -549,6 +549,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Контакт не найден в базе данных"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "На вашей рации не осталось слотов для контактов. Удалите ненужный контакт и повторите вход."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Вход отменён"; diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index 384924f9..448443bf 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -10,13 +10,9 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Готово"; -"map.common.dismissOverlay" = "Закрыть"; // MARK: - Map Controls -/* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ -"map.controls.hideLabels" = "Скрыть метки"; - /* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ "map.controls.showLabels" = "Показать метки"; @@ -26,14 +22,13 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ "map.controls.centerOnMyLocation" = "Моё местоположение"; -/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ -"map.controls.layers" = "Слои карты"; - -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ -"map.controls.lockNorth" = "Зафиксировать на север"; +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +/* AI-translated - please verify with native speakers. */ +"map.controls.mapOptions" = "Параметры карты"; -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ -"map.controls.unlockNorth" = "Разблокировать вращение"; +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +/* AI-translated - please verify with native speakers. */ +"map.controls.lockNorth" = "Север сверху"; /* Location: MapView.swift - Purpose: Accessibility label for refresh button */ "map.controls.refresh" = "Обновить контакты"; diff --git a/MC1/Resources/Localization/ru.lproj/Onboarding.strings b/MC1/Resources/Localization/ru.lproj/Onboarding.strings index cb903ad2..a2f66c19 100644 --- a/MC1/Resources/Localization/ru.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/ru.lproj/Onboarding.strings @@ -107,6 +107,7 @@ /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ "deviceScan.error.authenticationFailed" = "Не удалось выполнить сопряжение с устройством. Проверьте PIN-код, затем удалите устройство и повторите попытку."; +"deviceScan.error.pinRejected" = "PIN-код не принят. Проверьте PIN-код, показанный на устройстве, и повторите попытку. Сначала iOS попросит подтвердить удаление неудачного сопряжения."; "deviceScan.error.connectionFailed" = "Не удалось подключиться к устройству. Попробуйте снова или удалите его, если проблема сохраняется."; // MARK: - Troubleshooting Sheet diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 97424195..28985082 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -340,6 +340,15 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Допустимые значения: 0-64 переходов"; +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Допустимые значения: до %d байт"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Допустимые значения: от -90 до 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Допустимые значения: от -180 до 180"; + // MARK: - Repeater Settings: Regions /* Location: RepeaterSettingsView.swift - Regions section title */ @@ -437,14 +446,21 @@ /* Location: RepeaterStatusView.swift - Noise floor label */ "remoteNodes.status.noiseFloor" = "Уровень шума"; -/* Location: RepeaterStatusView.swift - Packets sent label */ -"remoteNodes.status.packetsSent" = "Пакетов отправлено"; +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Пакеты"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Отправлено"; -/* Location: RepeaterStatusView.swift - Packets received label */ -"remoteNodes.status.packetsReceived" = "Пакетов получено"; +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Получено"; -/* Location: RepeaterStatusView.swift - Receive errors label */ -"remoteNodes.status.receiveErrors" = "Ошибок пакетов получено"; +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Ошибки"; +"remoteNodes.status.sentDirect" = "Отправлено (Прямой)"; +"remoteNodes.status.sentFlood" = "Отправлено (Flood)"; +"remoteNodes.status.receivedDirect" = "Получено (Прямой)"; +"remoteNodes.status.receivedFlood" = "Получено (Flood)"; +"remoteNodes.status.duplicates" = "Дубликаты"; /* Location: RepeaterStatusView.swift - Neighbors section label */ "remoteNodes.status.neighbors" = "Соседи"; @@ -548,6 +564,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Обновить соседей"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Показать на карте"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Показать соседей на карте"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Карта соседей"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "Не показано %d соседей"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -599,13 +632,18 @@ "remoteNodes.history.neighborCount" = "Количество соседей"; /* Location: NodeStatusHistoryView.swift - Packets sent chart title */ -"remoteNodes.history.packetsSent" = "Пакетов отправлено"; +"remoteNodes.history.packetsSent" = "Отправлено"; /* Location: NodeStatusHistoryView.swift - Packets received chart title */ -"remoteNodes.history.packetsReceived" = "Пакетов получено"; +"remoteNodes.history.packetsReceived" = "Получено"; /* Location: NodeStatusHistoryView.swift - Receive errors chart title */ -"remoteNodes.history.receiveErrors" = "Ошибок пакетов получено"; +"remoteNodes.history.receiveErrors" = "Ошибки"; +"remoteNodes.history.direct" = "Прямой"; +"remoteNodes.history.flood" = "Flood"; +"remoteNodes.history.duplicates" = "Дубликаты"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Пакеты"; /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Активен"; diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/ru.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..04c29ca9 --- /dev/null +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.stringsdict @@ -0,0 +1,32 @@ + + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Не показано %d соседа + few + Не показано %d соседей + many + Не показано %d соседей + other + Не показано %d соседей + + + + diff --git a/MC1/Resources/Localization/ru.lproj/Settings.strings b/MC1/Resources/Localization/ru.lproj/Settings.strings index 665d95e3..762d2560 100644 --- a/MC1/Resources/Localization/ru.lproj/Settings.strings +++ b/MC1/Resources/Localization/ru.lproj/Settings.strings @@ -335,7 +335,7 @@ "advancedRadio.repeatMode" = "Режим ретранслятора"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "Создаёт локальный ретранслятор на выделенной частоте. Полезно для походов и удалённых районов. Допустимые частоты: 433, 869, 918 МГц."; +"advancedRadio.repeatMode.footer" = "Создаёт локальный ретранслятор на выделенной частоте. Полезно для походов и удалённых районов. Допустимые частоты: 433, 869.495, 918 МГц."; /* Accessibility label for voltage text field - %d is the percentage level */ @@ -697,11 +697,11 @@ // MARK: - Link Preview Settings Section -/* Section header for link preview settings */ -"linkPreviews.header" = "Предпросмотр ссылок"; +/* Section header for link content settings */ +"linkPreviews.header" = "Содержимое ссылок"; -/* Toggle label for link previews */ -"linkPreviews.toggle" = "Предпросмотр ссылок"; +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Показывать содержимое ссылок"; /* Toggle label for showing previews in DMs */ "linkPreviews.showInDMs" = "Показывать в личных сообщениях"; @@ -709,8 +709,11 @@ /* Toggle label for showing previews in channels */ "linkPreviews.showInChannels" = "Показывать в каналах"; -/* Footer explaining link preview privacy implications */ -"linkPreviews.footer" = "Предпросмотр ссылок загружает данные из интернета, что может раскрыть ваш IP-адрес серверу, на котором размещена ссылка."; +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "Предпросмотры ссылок и изображения загружаются через интернет-соединение вашего телефона, а не через сеть mesh. Это может раскрыть ваш IP-адрес серверу, на котором размещено содержимое, и использовать мобильный трафик."; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "Уменьшение движения включено, поэтому GIF не будут воспроизводиться автоматически."; // MARK: - Node Settings Section @@ -1259,15 +1262,9 @@ /* Validation error: no empty channel slot left (param: channel name) */ "configImport.error.noAvailableChannelSlot" = "Нет свободного слота канала для \"%@\""; -/* Toggle label for inline images */ -"inlineImages.toggle" = "Встроенные изображения"; - /* Toggle label for auto-play GIFs */ "inlineImages.autoPlayGifs" = "Автовоспроизведение GIF"; -/* Footer explaining inline image privacy implications */ -"inlineImages.footer" = "Загрузка изображений по URL-адресам может раскрыть ваш IP-адрес серверу, на котором размещено изображение."; - // MARK: - Map Preview Settings Section /* Section header for chat map preview settings */ diff --git a/MC1/Resources/Localization/ru.lproj/Tools.strings b/MC1/Resources/Localization/ru.lproj/Tools.strings index 2bc52883..2e016f18 100644 --- a/MC1/Resources/Localization/ru.lproj/Tools.strings +++ b/MC1/Resources/Localization/ru.lproj/Tools.strings @@ -341,12 +341,6 @@ /* Location: LineOfSightView.swift - Repeater annotation */ "tools.lineOfSight.repeater" = "Ретранслятор"; -/* Location: LineOfSightView.swift - Drop pin mode enabled */ -"tools.lineOfSight.cancelDropPin" = "Отменить установку метки"; - -/* Location: LineOfSightView.swift - Drop pin mode disabled */ -"tools.lineOfSight.dropPin" = "Установить метку"; - /* Location: LineOfSightView.swift - Points section title */ "tools.lineOfSight.points" = "Точки"; @@ -365,8 +359,8 @@ /* Location: LineOfSightView.swift - Loading elevation status */ "tools.lineOfSight.loadingElevation" = "Загрузка высоты..."; -/* Location: LineOfSightView.swift - Select points hint */ -"tools.lineOfSight.selectPointsHint" = "Нажмите кнопку метки на карте, чтобы выбрать точки"; +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Нажмите и удерживайте на карте, чтобы добавить местоположение"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "Данные о высоте недоступны. Используется уровень моря (0 м) как приближение."; diff --git a/MC1/Resources/Localization/uk.lproj/Chats.strings b/MC1/Resources/Localization/uk.lproj/Chats.strings index 2339fcad..e7e0f255 100644 --- a/MC1/Resources/Localization/uk.lproj/Chats.strings +++ b/MC1/Resources/Localization/uk.lproj/Chats.strings @@ -1248,3 +1248,17 @@ /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "Відповідні контакти"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Неперевірене ім'я"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Відправників повідомлень у каналах неможливо перевірити. Це може бути інша особа."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Неперевірений збіг імені"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 9f1a3c3b..6cd5459c 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -1046,13 +1046,13 @@ "contacts.results.dismiss" = "Закрити"; /* Location: TraceResultsSheet.swift - Purpose: Batch success count */ -"contacts.results.batchSuccess" = "%d з %d успішних"; +"contacts.results.batchSuccess" = "%d з %d успішних (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ "contacts.results.batchProgress" = "Трасування %d з %d..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "Пакет завершено: %d з %d трасувань успішні"; +"contacts.results.batchCompleteLabel" = "Пакет завершено: %d з %d трасувань успішні (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ "contacts.results.batchProgressLabel" = "Прогрес пакету: трасування %d з %d"; diff --git a/MC1/Resources/Localization/uk.lproj/Localizable.strings b/MC1/Resources/Localization/uk.lproj/Localizable.strings index e4acd959..8bda863a 100644 --- a/MC1/Resources/Localization/uk.lproj/Localizable.strings +++ b/MC1/Resources/Localization/uk.lproj/Localizable.strings @@ -546,6 +546,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Контакт не знайдено в базі даних"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "На вашій рації не залишилося слотів для контактів. Видаліть непотрібний контакт і повторіть вхід."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Вхід скасовано"; diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 558bd23f..f4e1a82e 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -10,13 +10,9 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Готово"; -"map.common.dismissOverlay" = "Закрити"; // MARK: - Map Controls -/* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ -"map.controls.hideLabels" = "Сховати мітки"; - /* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ "map.controls.showLabels" = "Показати мітки"; @@ -26,14 +22,13 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ "map.controls.centerOnMyLocation" = "Центрувати на моєму місцезнаходженні"; -/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ -"map.controls.layers" = "Шари карти"; - -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ -"map.controls.lockNorth" = "Зафіксувати на північ"; +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +/* AI-translated - please verify with native speakers. */ +"map.controls.mapOptions" = "Параметри карти"; -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ -"map.controls.unlockNorth" = "Розблокувати обертання"; +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +/* AI-translated - please verify with native speakers. */ +"map.controls.lockNorth" = "Північ вгорі"; /* Location: MapView.swift - Purpose: Accessibility label for refresh button */ "map.controls.refresh" = "Оновити контакти"; diff --git a/MC1/Resources/Localization/uk.lproj/Onboarding.strings b/MC1/Resources/Localization/uk.lproj/Onboarding.strings index 460a30cb..da7d77b9 100644 --- a/MC1/Resources/Localization/uk.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/uk.lproj/Onboarding.strings @@ -107,6 +107,7 @@ /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ "deviceScan.error.authenticationFailed" = "Не вдалося спарувати пристрій. Перевірте PIN-код, потім видаліть пристрій і спробуйте ще раз."; +"deviceScan.error.pinRejected" = "PIN-код не прийнято. Перевірте PIN-код, показаний на пристрої, і спробуйте ще раз. Спочатку iOS попросить підтвердити видалення невдалого спарювання."; "deviceScan.error.connectionFailed" = "Не вдалося підключитися до пристрою. Спробуйте знову або видаліть пристрій, якщо проблема не зникає."; // MARK: - Troubleshooting Sheet diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index 107ab85a..81c9d007 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -340,6 +340,15 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Допустимі значення: 0–64 переходів"; +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Допустимі значення: до %d байт"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Допустимі значення: від -90 до 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Допустимі значення: від -180 до 180"; + // MARK: - Repeater Settings: Regions /* Location: RepeaterSettingsView.swift - Regions section title */ @@ -437,14 +446,21 @@ /* Location: RepeaterStatusView.swift - Noise floor label */ "remoteNodes.status.noiseFloor" = "Рівень шуму"; -/* Location: RepeaterStatusView.swift - Packets sent label */ -"remoteNodes.status.packetsSent" = "Пакетів надіслано"; +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Пакети"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Надіслано"; -/* Location: RepeaterStatusView.swift - Packets received label */ -"remoteNodes.status.packetsReceived" = "Пакетів отримано"; +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Отримано"; -/* Location: RepeaterStatusView.swift - Receive errors label */ -"remoteNodes.status.receiveErrors" = "Помилок пакетів отримано"; +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Помилки"; +"remoteNodes.status.sentDirect" = "Надіслано (Прямий)"; +"remoteNodes.status.sentFlood" = "Надіслано (Flood)"; +"remoteNodes.status.receivedDirect" = "Отримано (Прямий)"; +"remoteNodes.status.receivedFlood" = "Отримано (Flood)"; +"remoteNodes.status.duplicates" = "Дублікати"; /* Location: RepeaterStatusView.swift - Neighbors section label */ "remoteNodes.status.neighbors" = "Сусіди"; @@ -548,6 +564,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Оновити сусідів"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Показати на карті"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Показати сусідів на карті"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Карта сусідів"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "Не показано %d сусідів"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -599,13 +632,18 @@ "remoteNodes.history.neighborCount" = "Кількість сусідів"; /* Location: NodeStatusHistoryView.swift - Packets sent chart title */ -"remoteNodes.history.packetsSent" = "Пакетів надіслано"; +"remoteNodes.history.packetsSent" = "Надіслано"; /* Location: NodeStatusHistoryView.swift - Packets received chart title */ -"remoteNodes.history.packetsReceived" = "Пакетів отримано"; +"remoteNodes.history.packetsReceived" = "Отримано"; /* Location: NodeStatusHistoryView.swift - Receive errors chart title */ -"remoteNodes.history.receiveErrors" = "Помилок пакетів отримано"; +"remoteNodes.history.receiveErrors" = "Помилки"; +"remoteNodes.history.direct" = "Прямий"; +"remoteNodes.history.flood" = "Flood"; +"remoteNodes.history.duplicates" = "Дублікати"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Пакети"; /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Активний"; diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/uk.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..118eb60e --- /dev/null +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.stringsdict @@ -0,0 +1,32 @@ + + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Не показано %d сусіда + few + Не показано %d сусідів + many + Не показано %d сусідів + other + Не показано %d сусідів + + + + diff --git a/MC1/Resources/Localization/uk.lproj/Settings.strings b/MC1/Resources/Localization/uk.lproj/Settings.strings index 5494ce98..0b92752a 100644 --- a/MC1/Resources/Localization/uk.lproj/Settings.strings +++ b/MC1/Resources/Localization/uk.lproj/Settings.strings @@ -335,7 +335,7 @@ "advancedRadio.repeatMode" = "Режим ретранслятора"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "Створює локальний ретранслятор на виділеній частоті. Корисно для походів та віддалених місцевостей. Допустимі частоти: 433, 869, 918 МГц."; +"advancedRadio.repeatMode.footer" = "Створює локальний ретранслятор на виділеній частоті. Корисно для походів та віддалених місцевостей. Допустимі частоти: 433, 869.495, 918 МГц."; // MARK: - Path Hash Mode Section @@ -697,11 +697,11 @@ // MARK: - Link Preview Settings Section -/* Section header for link preview settings */ -"linkPreviews.header" = "Попередній перегляд посилань"; +/* Section header for link content settings */ +"linkPreviews.header" = "Вміст посилань"; -/* Toggle label for link previews */ -"linkPreviews.toggle" = "Попередній перегляд посилань"; +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Показувати вміст посилань"; /* Toggle label for showing previews in DMs */ "linkPreviews.showInDMs" = "Показувати в особистих повідомленнях"; @@ -709,8 +709,11 @@ /* Toggle label for showing previews in channels */ "linkPreviews.showInChannels" = "Показувати в каналах"; -/* Footer explaining link preview privacy implications */ -"linkPreviews.footer" = "Попередній перегляд посилань завантажує дані з інтернету, що може розкрити вашу IP-адресу серверу, на якому розміщено посилання."; +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "Попередні перегляди посилань та зображення завантажуються через інтернет-з'єднання вашого телефону, а не через мережу mesh. Це може розкрити вашу IP-адресу серверу, на якому розміщено вміст, і використовувати мобільний трафік."; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "Зменшення руху ввімкнено, тому GIF не відтворюватимуться автоматично."; // MARK: - Node Settings Section @@ -1259,15 +1262,9 @@ /* Validation error: no empty channel slot left (param: channel name) */ "configImport.error.noAvailableChannelSlot" = "Немає вільного слота каналу для \"%@\""; -/* Toggle label for inline images */ -"inlineImages.toggle" = "Вбудовані зображення"; - /* Toggle label for auto-play GIFs */ "inlineImages.autoPlayGifs" = "Автовідтворення GIF"; -/* Footer explaining inline image privacy implications */ -"inlineImages.footer" = "Завантаження зображень за URL-адресами може розкрити вашу IP-адресу серверу, який розміщує зображення."; - // MARK: - Map Preview Settings Section /* Section header for chat map preview settings */ diff --git a/MC1/Resources/Localization/uk.lproj/Tools.strings b/MC1/Resources/Localization/uk.lproj/Tools.strings index 2fb23bce..1319e46e 100644 --- a/MC1/Resources/Localization/uk.lproj/Tools.strings +++ b/MC1/Resources/Localization/uk.lproj/Tools.strings @@ -341,12 +341,6 @@ /* Location: LineOfSightView.swift - Repeater annotation */ "tools.lineOfSight.repeater" = "Ретранслятор"; -/* Location: LineOfSightView.swift - Drop pin mode enabled */ -"tools.lineOfSight.cancelDropPin" = "Скасувати встановлення мітки"; - -/* Location: LineOfSightView.swift - Drop pin mode disabled */ -"tools.lineOfSight.dropPin" = "Встановити мітку"; - /* Location: LineOfSightView.swift - Points section title */ "tools.lineOfSight.points" = "Точки"; @@ -365,8 +359,8 @@ /* Location: LineOfSightView.swift - Loading elevation status */ "tools.lineOfSight.loadingElevation" = "Завантаження висоти..."; -/* Location: LineOfSightView.swift - Select points hint */ -"tools.lineOfSight.selectPointsHint" = "Натисніть кнопку мітки на карті, щоб вибрати точки"; +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Натисніть і утримуйте на карті, щоб додати місце"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "Дані про висоту недоступні. Використовується рівень моря (0 м) як наближення."; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings index 8ec0eeaf..1c4abc8b 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings @@ -1256,3 +1256,17 @@ /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "匹配的联系人"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "未验证的名称"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "频道消息的发送者无法验证。这可能是另一个人。"; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "未验证的名称匹配"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index ff4664df..fa89b1c7 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -1059,13 +1059,13 @@ "contacts.results.dismiss" = "关闭"; /* Location: TraceResultsSheet.swift - Purpose: Batch success count */ -"contacts.results.batchSuccess" = "%d/%d 成功"; +"contacts.results.batchSuccess" = "%d/%d 成功 (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ "contacts.results.batchProgress" = "正在运行追踪 %d/%d..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "批量完成:%d/%d 次追踪成功"; +"contacts.results.batchCompleteLabel" = "批量完成:%d/%d 次追踪成功 (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ "contacts.results.batchProgressLabel" = "批量进度:追踪 %d/%d"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings b/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings index baeb0d5e..342f73d4 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings @@ -546,6 +546,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "数据库中未找到联系人"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "你的设备联系人槽位已满。请删除一个不再需要的联系人,然后重新登录。"; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "登录已取消"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index ba484c8b..9177c3cf 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -10,13 +10,9 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "完成"; -"map.common.dismissOverlay" = "关闭"; // MARK: - Map Controls -/* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ -"map.controls.hideLabels" = "隐藏标签"; - /* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ "map.controls.showLabels" = "显示标签"; @@ -26,14 +22,13 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ "map.controls.centerOnMyLocation" = "以我的位置为中心"; -/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ -"map.controls.layers" = "地图图层"; - -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ -"map.controls.lockNorth" = "锁定朝北"; +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +/* AI-translated - please verify with native speakers. */ +"map.controls.mapOptions" = "地图选项"; -/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ -"map.controls.unlockNorth" = "解锁旋转"; +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +/* AI-translated - please verify with native speakers. */ +"map.controls.lockNorth" = "正北朝上"; /* Location: MapView.swift - Purpose: Accessibility label for refresh button */ "map.controls.refresh" = "刷新联系人"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings b/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings index ceb3a4cd..7cc94720 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings @@ -105,6 +105,7 @@ /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ "deviceScan.error.authenticationFailed" = "无法配对设备。请检查 PIN 码,然后移除设备并重试。"; +"deviceScan.error.pinRejected" = "PIN 码未被接受。请检查设备上显示的 PIN 码,然后重试。iOS 会先要求你确认移除失败的配对。"; "deviceScan.error.connectionFailed" = "无法连接到设备。请重试,或在问题持续时移除设备。"; // MARK: - Troubleshooting Sheet diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 86d22bc1..b4df1859 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -343,6 +343,15 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "接受 0-64 跳"; +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "接受最多 %d 字节"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "接受 -90 到 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "接受 -180 到 180"; + // MARK: - Repeater Settings: Regions /* Location: RepeaterSettingsView.swift - Regions section title */ @@ -440,14 +449,21 @@ /* Location: RepeaterStatusView.swift - Noise floor label */ "remoteNodes.status.noiseFloor" = "无线电底噪"; -/* Location: RepeaterStatusView.swift - Packets sent label */ -"remoteNodes.status.packetsSent" = "已发送数据包"; +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "数据包"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "已发送"; -/* Location: RepeaterStatusView.swift - Packets received label */ -"remoteNodes.status.packetsReceived" = "已接收数据包"; +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "已接收"; -/* Location: RepeaterStatusView.swift - Receive errors label */ -"remoteNodes.status.receiveErrors" = "已接收数据包错误"; +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "错误"; +"remoteNodes.status.sentDirect" = "已发送(直连)"; +"remoteNodes.status.sentFlood" = "已发送(泛洪)"; +"remoteNodes.status.receivedDirect" = "已接收(直连)"; +"remoteNodes.status.receivedFlood" = "已接收(泛洪)"; +"remoteNodes.status.duplicates" = "重复"; /* Location: RepeaterStatusView.swift - Neighbors section header */ "remoteNodes.status.neighbors" = "邻居"; @@ -551,6 +567,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "重新加载邻居"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "在地图上查看"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "在地图上查看邻居"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "邻居地图"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d 个邻居未显示"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -602,13 +635,18 @@ "remoteNodes.history.neighborCount" = "邻居数量"; /* Location: NodeStatusHistoryView.swift - Packets sent chart title */ -"remoteNodes.history.packetsSent" = "已发送数据包"; +"remoteNodes.history.packetsSent" = "已发送"; /* Location: NodeStatusHistoryView.swift - Packets received chart title */ -"remoteNodes.history.packetsReceived" = "已接收数据包"; +"remoteNodes.history.packetsReceived" = "已接收"; /* Location: NodeStatusHistoryView.swift - Receive errors chart title */ -"remoteNodes.history.receiveErrors" = "已接收数据包错误"; +"remoteNodes.history.receiveErrors" = "错误"; +"remoteNodes.history.direct" = "直连"; +"remoteNodes.history.flood" = "泛洪"; +"remoteNodes.history.duplicates" = "重复"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "数据包"; /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "活跃"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..d513718f --- /dev/null +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.stringsdict @@ -0,0 +1,20 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + other + %d 个邻居未显示 + + + + diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings index c8e12378..9b97d3ed 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -326,7 +326,7 @@ "advancedRadio.repeatMode" = "转发模式"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "在专用频率上打开转发。适用于徒步旅行和偏远地区。有效频率:433、869、918 MHz。"; +"advancedRadio.repeatMode.footer" = "在专用频率上打开转发。适用于徒步旅行和偏远地区。有效频率:433、869.495、918 MHz。"; // MARK: - Path Hash Mode Section @@ -682,11 +682,11 @@ // MARK: - Link Preview Settings Section -/* Section header for link preview settings */ -"linkPreviews.header" = "链接预览"; +/* Section header for link content settings */ +"linkPreviews.header" = "链接内容"; -/* Toggle label for link previews */ -"linkPreviews.toggle" = "链接预览"; +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "显示链接内容"; /* Toggle label for showing previews in DMs */ "linkPreviews.showInDMs" = "在私信中显示"; @@ -694,8 +694,11 @@ /* Toggle label for showing previews in channels */ "linkPreviews.showInChannels" = "在频道中显示"; -/* Footer explaining link preview privacy implications */ -"linkPreviews.footer" = "链接预览从网络获取数据,这可能会向托管链接的服务器泄露您的 IP 地址。"; +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "链接预览和图片通过您手机的互联网连接获取,而非通过 mesh 网络。这可能会向托管内容的服务器泄露您的 IP 地址,并可能消耗蜂窝数据。"; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "已开启减弱动态效果,因此 GIF 不会自动播放。"; // MARK: - Node Settings Section @@ -1233,15 +1236,9 @@ /* Validation error: no empty channel slot left (param: channel name) */ "configImport.error.noAvailableChannelSlot" = "没有可用于 \"%@\" 的空闲频道槽位"; -/* Toggle label for inline images */ -"inlineImages.toggle" = "内嵌图片消息"; - /* Toggle label for auto-play GIFs */ "inlineImages.autoPlayGifs" = "自动播放 GIF"; -/* Footer explaining inline image privacy implications */ -"inlineImages.footer" = "从 URL 获取图片可能会将您的 IP 地址暴露给托管图片的服务器。"; - // MARK: - Map Preview Settings Section /* Section header for chat map preview settings */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Tools.strings b/MC1/Resources/Localization/zh-Hans.lproj/Tools.strings index 28c5d9eb..4bf6f277 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Tools.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Tools.strings @@ -341,12 +341,6 @@ /* Location: LineOfSightView.swift - Repeater annotation */ "tools.lineOfSight.repeater" = "转发节点"; -/* Location: LineOfSightView.swift - Drop pin mode enabled */ -"tools.lineOfSight.cancelDropPin" = "取消放置图钉"; - -/* Location: LineOfSightView.swift - Drop pin mode disabled */ -"tools.lineOfSight.dropPin" = "放置图钉"; - /* Location: LineOfSightView.swift - Points section title */ "tools.lineOfSight.points" = "点"; @@ -365,8 +359,8 @@ /* Location: LineOfSightView.swift - Loading elevation status */ "tools.lineOfSight.loadingElevation" = "正在加载高程..."; -/* Location: LineOfSightView.swift - Select points hint */ -"tools.lineOfSight.selectPointsHint" = "点击图钉按钮在地图上选择点"; +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "长按地图以添加位置"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "高程数据不可用。使用海平面(0m)作为近似值。"; diff --git a/MC1/Services/AppStateProviderImpl.swift b/MC1/Services/AppStateProviderImpl.swift index a85a7522..d3c6bd18 100644 --- a/MC1/Services/AppStateProviderImpl.swift +++ b/MC1/Services/AppStateProviderImpl.swift @@ -1,5 +1,5 @@ -import UIKit import MC1Services +import UIKit /// UIKit-based implementation of AppStateProvider. /// @@ -7,14 +7,13 @@ import MC1Services /// MainActor-isolated with async getter to allow cross-actor access. @MainActor final class AppStateProviderImpl: AppStateProvider { + init() {} - init() {} - - nonisolated var isInForeground: Bool { - get async { - await MainActor.run { - UIApplication.shared.applicationState != .background - } - } + nonisolated var isInForeground: Bool { + get async { + await MainActor.run { + UIApplication.shared.applicationState != .background + } } + } } diff --git a/MC1/Services/DecodedPreviewCache.swift b/MC1/Services/DecodedPreviewCache.swift index cbf6e177..547e8b4f 100644 --- a/MC1/Services/DecodedPreviewCache.swift +++ b/MC1/Services/DecodedPreviewCache.swift @@ -9,27 +9,27 @@ import UIKit /// re-decode. `@unchecked Sendable` is sound because every stored property is /// `let` and `UIImage` / `LinkPreviewDataDTO` are immutable post-construction. final class CachedDecodedPreview: @unchecked Sendable { - let dto: LinkPreviewDataDTO - let hero: UIImage? - let icon: UIImage? - let cost: Int + let dto: LinkPreviewDataDTO + let hero: UIImage? + let icon: UIImage? + let cost: Int - init(dto: LinkPreviewDataDTO, hero: UIImage?, icon: UIImage?) { - // Strip the raw image/icon bytes: the decoded hero and icon carry the - // pixels, and the rehydration render path reads only the DTO's url, - // title, and dimensions. Retaining the compressed source bytes would - // be dead weight that the pixel-based cost budget never accounts for. - self.dto = LinkPreviewDataDTO( - url: dto.url, - title: dto.title, - imageWidth: dto.imageWidth, - imageHeight: dto.imageHeight, - fetchedAt: dto.fetchedAt - ) - self.hero = hero - self.icon = icon - self.cost = ImageByteCost.bytes(for: hero) + ImageByteCost.bytes(for: icon) - } + init(dto: LinkPreviewDataDTO, hero: UIImage?, icon: UIImage?) { + // Strip the raw image/icon bytes: the decoded hero and icon carry the + // pixels, and the rehydration render path reads only the DTO's url, + // title, and dimensions. Retaining the compressed source bytes would + // be dead weight that the pixel-based cost budget never accounts for. + self.dto = LinkPreviewDataDTO( + url: dto.url, + title: dto.title, + imageWidth: dto.imageWidth, + imageHeight: dto.imageHeight, + fetchedAt: dto.fetchedAt + ) + self.hero = hero + self.icon = icon + cost = ImageByteCost.bytes(for: hero) + ImageByteCost.bytes(for: icon) + } } /// Process-lifetime cache of decoded link-preview assets keyed by URL. @@ -43,75 +43,75 @@ final class CachedDecodedPreview: @unchecked Sendable { /// in the same call frame. FIFO eviction bounded by `maxEntryCount` and /// `maxTotalCostBytes`; auto-clears on memory pressure. final class DecodedPreviewCache: Sendable { - static let shared = DecodedPreviewCache() + static let shared = DecodedPreviewCache() - private static let maxEntryCount = 50 - private static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB + private static let maxEntryCount = 50 + private static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB - private let mirror = OSAllocatedUnfairLock(initialState: DecodedPreviewCacheState()) + private let mirror = OSAllocatedUnfairLock(initialState: DecodedPreviewCacheState()) - init() { - NotificationCenter.default.addObserver( - forName: UIApplication.didReceiveMemoryWarningNotification, - object: nil, - queue: nil - ) { [weak self] _ in - self?.clear() - } + init() { + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.clear() } + } - /// Persists a decoded preview keyed on the message's detected URL. Called - /// from `ChatViewModel.decodeAndStorePreviewImages` once the hero and icon - /// decode completes, before the per-VM apply, so a chat-exit mid-decode - /// still hands the assets to the next visit. FIFO eviction inside the lock - /// keeps the mirror within budget. - func store(_ entry: CachedDecodedPreview, for url: URL) { - let key = url.absoluteString - mirror.withLock { state in - if let existing = state.entries[key] { - state.totalCostBytes -= existing.cost - if let idx = state.insertionOrder.firstIndex(of: key) { - state.insertionOrder.remove(at: idx) - } - } - state.entries[key] = entry - state.insertionOrder.append(key) - state.totalCostBytes += entry.cost + /// Persists a decoded preview keyed on the message's detected URL. Called + /// from `ChatViewModel.decodeAndStorePreviewImages` once the hero and icon + /// decode completes, before the per-VM apply, so a chat-exit mid-decode + /// still hands the assets to the next visit. FIFO eviction inside the lock + /// keeps the mirror within budget. + func store(_ entry: CachedDecodedPreview, for url: URL) { + let key = url.absoluteString + mirror.withLock { state in + if let existing = state.entries[key] { + state.totalCostBytes -= existing.cost + if let idx = state.insertionOrder.firstIndex(of: key) { + state.insertionOrder.remove(at: idx) + } + } + state.entries[key] = entry + state.insertionOrder.append(key) + state.totalCostBytes += entry.cost - // Keep at least the just-inserted entry even when it singly - // exceeds the cost budget, so a large hero is still served once. - while state.insertionOrder.count > 1, - state.insertionOrder.count > Self.maxEntryCount - || state.totalCostBytes > Self.maxTotalCostBytes { - let oldest = state.insertionOrder.removeFirst() - if let evicted = state.entries.removeValue(forKey: oldest) { - state.totalCostBytes -= evicted.cost - } - } + // Keep at least the just-inserted entry even when it singly + // exceeds the cost budget, so a large hero is still served once. + while state.insertionOrder.count > 1, + state.insertionOrder.count > Self.maxEntryCount + || state.totalCostBytes > Self.maxTotalCostBytes { + let oldest = state.insertionOrder.removeFirst() + if let evicted = state.entries.removeValue(forKey: oldest) { + state.totalCostBytes -= evicted.cost } + } } + } - /// Wait-free decoded-preview lookup. Safe to call from a main-actor view - /// body or the URL-detection write path without an await. - func decoded(for url: URL) -> CachedDecodedPreview? { - mirror.withLock { $0.entries[url.absoluteString] } - } + /// Wait-free decoded-preview lookup. Safe to call from a main-actor view + /// body or the URL-detection write path without an await. + func decoded(for url: URL) -> CachedDecodedPreview? { + mirror.withLock { $0.entries[url.absoluteString] } + } - /// Empties the cache in response to system memory pressure. - func clear() { - mirror.withLock { state in - state.entries.removeAll() - state.insertionOrder.removeAll() - state.totalCostBytes = 0 - } + /// Empties the cache in response to system memory pressure. + func clear() { + mirror.withLock { state in + state.entries.removeAll() + state.insertionOrder.removeAll() + state.totalCostBytes = 0 } + } } /// State held under the mirror lock. Combining the dict with the /// insertion-order list and running cost lets a single `withLock` perform /// both the write and the eviction sweep atomically. private struct DecodedPreviewCacheState { - var entries: [String: CachedDecodedPreview] = [:] - var insertionOrder: [String] = [] - var totalCostBytes: Int = 0 + var entries: [String: CachedDecodedPreview] = [:] + var insertionOrder: [String] = [] + var totalCostBytes: Int = 0 } diff --git a/MC1/Services/DemoInlineImageSeeder.swift b/MC1/Services/DemoInlineImageSeeder.swift index e6e08bb1..8e6978e2 100644 --- a/MC1/Services/DemoInlineImageSeeder.swift +++ b/MC1/Services/DemoInlineImageSeeder.swift @@ -7,11 +7,11 @@ import UIKit /// so seeding it here (the cache and `UIImage` are app-layer) lets the package-level /// seed reference the URL while the pixels stay embedded and render offline. enum DemoInlineImageSeeder { - /// Idempotent: re-seeding overwrites the same cache key. - static func seed() { - guard let url = URL(string: MockDataProvider.inlineImageURL), - let image = UIImage(data: MockDataProvider.demoImageData) else { return } - let entry = CachedDecodedImage(image: image, isGIF: false, data: MockDataProvider.demoImageData) - InlineImageCache.shared.storeDecoded(entry, for: ImageURLClassifier.directImageURL(for: url)) - } + /// Idempotent: re-seeding overwrites the same cache key. + static func seed() { + guard let url = URL(string: MockDataProvider.inlineImageURL), + let image = UIImage(data: MockDataProvider.demoImageData) else { return } + let entry = CachedDecodedImage(image: image, isGIF: false, data: MockDataProvider.demoImageData) + InlineImageCache.shared.storeDecoded(entry, for: ImageURLClassifier.directImageURL(for: url)) + } } diff --git a/MC1/Services/ElevationService.swift b/MC1/Services/ElevationService.swift index 95191a22..359d85ca 100644 --- a/MC1/Services/ElevationService.swift +++ b/MC1/Services/ElevationService.swift @@ -9,270 +9,269 @@ private let logger = Logger(subsystem: "com.mc1", category: "ElevationService") /// Errors from elevation service enum ElevationServiceError: LocalizedError { - case networkError(String) - case invalidResponse - case apiError(String) - case noData - case rateLimited - - var errorDescription: String? { - switch self { - case .networkError(let description): - return L10n.Localizable.Common.Error.networkError(description) - case .invalidResponse: - return L10n.Localizable.Common.Error.invalidResponse - case .apiError(let message): - return L10n.Localizable.Common.Error.apiError(message) - case .noData: - return L10n.Localizable.Common.Error.noElevationData - case .rateLimited: - return L10n.Localizable.Common.Error.rateLimited - } + case networkError(String) + case invalidResponse + case apiError(String) + case noData + case rateLimited + + var errorDescription: String? { + switch self { + case let .networkError(description): + L10n.Localizable.Common.Error.networkError(description) + case .invalidResponse: + L10n.Localizable.Common.Error.invalidResponse + case let .apiError(message): + L10n.Localizable.Common.Error.apiError(message) + case .noData: + L10n.Localizable.Common.Error.noElevationData + case .rateLimited: + L10n.Localizable.Common.Error.rateLimited } + } } // MARK: - Protocol /// Protocol for elevation data fetching (for testability) protocol ElevationServiceProtocol: Sendable { - func fetchElevation(at coordinate: CLLocationCoordinate2D) async throws -> Double - func fetchElevations(along path: [CLLocationCoordinate2D]) async throws -> [ElevationSample] + func fetchElevation(at coordinate: CLLocationCoordinate2D) async throws -> Double + func fetchElevations(along path: [CLLocationCoordinate2D]) async throws -> [ElevationSample] } // MARK: - Service /// Service for fetching elevation data from Open-Meteo API actor ElevationService: ElevationServiceProtocol { + // MARK: - Constants - // MARK: - Constants + private static let apiEndpoint = "https://api.open-meteo.com/v1/elevation" + private static let maxPointsPerRequest = 100 + private static let maxRetries = 3 + private static let baseRetryDelay: Duration = .milliseconds(500) - private static let apiEndpoint = "https://api.open-meteo.com/v1/elevation" - private static let maxPointsPerRequest = 100 - private static let maxRetries = 3 - private static let baseRetryDelay: Duration = .milliseconds(500) + // MARK: - Sample Count Thresholds - // MARK: - Sample Count Thresholds + private static let thresholdUnder1km = 1000.0 + private static let threshold1to5km = 5000.0 + private static let threshold5to20km = 20000.0 - private static let thresholdUnder1km = 1_000.0 - private static let threshold1to5km = 5_000.0 - private static let threshold5to20km = 20_000.0 + private static let sampleCountUnder1km = 20 + private static let sampleCount1to5km = 50 + private static let sampleCount5to20km = 80 + private static let sampleCountOver20km = 100 - private static let sampleCountUnder1km = 20 - private static let sampleCount1to5km = 50 - private static let sampleCount5to20km = 80 - private static let sampleCountOver20km = 100 + // MARK: - Dependencies - // MARK: - Dependencies + private let session: URLSession - private let session: URLSession + // MARK: - Initialization - // MARK: - Initialization + init(session: URLSession = .shared) { + self.session = session + } - init(session: URLSession = .shared) { - self.session = session + // MARK: - Public Methods + + /// Fetch elevation for a single coordinate + /// - Parameter coordinate: The coordinate to get elevation for + /// - Returns: Elevation in meters above sea level + func fetchElevation(at coordinate: CLLocationCoordinate2D) async throws -> Double { + let samples = try await fetchElevations(along: [coordinate]) + guard let first = samples.first else { + throw ElevationServiceError.noData + } + return first.elevation + } + + /// Fetch elevations for multiple coordinates along a path + /// - Parameter path: Array of coordinates to get elevations for + /// - Returns: Array of elevation samples with distances calculated from first point + func fetchElevations(along path: [CLLocationCoordinate2D]) async throws -> [ElevationSample] { + guard !path.isEmpty else { + throw ElevationServiceError.noData } - // MARK: - Public Methods - - /// Fetch elevation for a single coordinate - /// - Parameter coordinate: The coordinate to get elevation for - /// - Returns: Elevation in meters above sea level - func fetchElevation(at coordinate: CLLocationCoordinate2D) async throws -> Double { - let samples = try await fetchElevations(along: [coordinate]) - guard let first = samples.first else { - throw ElevationServiceError.noData - } - return first.elevation + var coordinatesToFetch = path + + // Subsample if exceeding max points, ensuring endpoints are preserved + if coordinatesToFetch.count > Self.maxPointsPerRequest { + logger.warning( + "Requested \(path.count) elevation points, subsampling to \(Self.maxPointsPerRequest)" + ) + coordinatesToFetch = subsample(path, targetCount: Self.maxPointsPerRequest) } - /// Fetch elevations for multiple coordinates along a path - /// - Parameter path: Array of coordinates to get elevations for - /// - Returns: Array of elevation samples with distances calculated from first point - func fetchElevations(along path: [CLLocationCoordinate2D]) async throws -> [ElevationSample] { - guard !path.isEmpty else { - throw ElevationServiceError.noData - } - - var coordinatesToFetch = path - - // Subsample if exceeding max points, ensuring endpoints are preserved - if coordinatesToFetch.count > Self.maxPointsPerRequest { - logger.warning( - "Requested \(path.count) elevation points, subsampling to \(Self.maxPointsPerRequest)" - ) - coordinatesToFetch = subsample(path, targetCount: Self.maxPointsPerRequest) - } - - // Build URL with query parameters (using POSIX locale to ensure dot decimal separator) - let coordinateFormat = FloatingPointFormatStyle - .number - .locale(Locale(identifier: "en_US_POSIX")) - .precision(.fractionLength(6)) - let latitudes = coordinatesToFetch.map { $0.latitude.formatted(coordinateFormat) }.joined(separator: ",") - let longitudes = coordinatesToFetch.map { $0.longitude.formatted(coordinateFormat) }.joined(separator: ",") - - guard var components = URLComponents(string: Self.apiEndpoint) else { - throw ElevationServiceError.invalidResponse - } - - components.queryItems = [ - URLQueryItem(name: "latitude", value: latitudes), - URLQueryItem(name: "longitude", value: longitudes) - ] - - guard let url = components.url else { - throw ElevationServiceError.invalidResponse - } - - // Make request with retry on rate limit - let data = try await performRequest(url: url) - - // Parse response - let elevations = try parseElevationResponse(data) - - guard elevations.count == coordinatesToFetch.count else { - throw ElevationServiceError.invalidResponse - } - - // Build elevation samples with distance from first point - var samples: [ElevationSample] = [] - let startCoordinate = coordinatesToFetch[0] - - for (index, coordinate) in coordinatesToFetch.enumerated() { - let distanceFromA = RFCalculator.distance(from: startCoordinate, to: coordinate) - let sample = ElevationSample( - coordinate: coordinate, - elevation: elevations[index], - distanceFromAMeters: distanceFromA - ) - samples.append(sample) - } - - return samples + // Build URL with query parameters (using POSIX locale to ensure dot decimal separator) + let coordinateFormat = FloatingPointFormatStyle + .number + .locale(Locale(identifier: "en_US_POSIX")) + .precision(.fractionLength(6)) + let latitudes = coordinatesToFetch.map { $0.latitude.formatted(coordinateFormat) }.joined(separator: ",") + let longitudes = coordinatesToFetch.map { $0.longitude.formatted(coordinateFormat) }.joined(separator: ",") + + guard var components = URLComponents(string: Self.apiEndpoint) else { + throw ElevationServiceError.invalidResponse } - // MARK: - Static Methods - - /// Generate evenly spaced coordinates between two points - /// - Parameters: - /// - from: Starting coordinate (will be first in result) - /// - to: Ending coordinate (will be last in result) - /// - sampleCount: Number of samples to generate (minimum 2) - /// - Returns: Array of evenly spaced coordinates including endpoints - static func sampleCoordinates( - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D, - sampleCount: Int - ) -> [CLLocationCoordinate2D] { - let count = max(2, min(sampleCount, maxPointsPerRequest)) - - guard count >= 2 else { - return [from, to] - } - - var coordinates: [CLLocationCoordinate2D] = [] - coordinates.reserveCapacity(count) - - for i in 0.. Int { - switch distanceMeters { - case .. Data { - for attempt in 0.. [Double] { - struct ElevationResponse: Decodable { - let elevation: [Double] - } - - do { - let response = try JSONDecoder().decode(ElevationResponse.self, from: data) - return response.elevation - } catch { - throw ElevationServiceError.invalidResponse - } + return samples + } + + // MARK: - Static Methods + + /// Generate evenly spaced coordinates between two points + /// - Parameters: + /// - from: Starting coordinate (will be first in result) + /// - to: Ending coordinate (will be last in result) + /// - sampleCount: Number of samples to generate (minimum 2) + /// - Returns: Array of evenly spaced coordinates including endpoints + static func sampleCoordinates( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D, + sampleCount: Int + ) -> [CLLocationCoordinate2D] { + let count = max(2, min(sampleCount, maxPointsPerRequest)) + + guard count >= 2 else { + return [from, to] } - /// Subsample an array to a target count, preserving first and last elements - private func subsample(_ array: [T], targetCount: Int) -> [T] { - guard array.count > targetCount, targetCount >= 2 else { - return array - } + var coordinates: [CLLocationCoordinate2D] = [] + coordinates.reserveCapacity(count) - var result: [T] = [] - result.reserveCapacity(targetCount) + for i in 0.. Int { + switch distanceMeters { + case .. Data { + for attempt in 0.. 0 { - let step = Double(array.count - 2) / Double(middleCount + 1) - for i in 1...middleCount { - let index = Int(Double(i) * step) - result.append(array[index]) - } - } + logger.error("Rate limited after \(Self.maxRetries) retries") + throw ElevationServiceError.rateLimited + } - // Always include last element - result.append(array[array.count - 1]) + private func parseElevationResponse(_ data: Data) throws -> [Double] { + struct ElevationResponse: Decodable { + let elevation: [Double] + } + + do { + let response = try JSONDecoder().decode(ElevationResponse.self, from: data) + return response.elevation + } catch { + throw ElevationServiceError.invalidResponse + } + } - return result + /// Subsample an array to a target count, preserving first and last elements + private func subsample(_ array: [T], targetCount: Int) -> [T] { + guard array.count > targetCount, targetCount >= 2 else { + return array } + + var result: [T] = [] + result.reserveCapacity(targetCount) + + // Always include first element + result.append(array[0]) + + // Calculate step for middle elements (targetCount - 2 middle samples) + let middleCount = targetCount - 2 + if middleCount > 0 { + let step = Double(array.count - 2) / Double(middleCount + 1) + for i in 1...middleCount { + let index = Int(Double(i) * step) + result.append(array[index]) + } + } + + // Always include last element + result.append(array[array.count - 1]) + + return result + } } diff --git a/MC1/Services/Geocoder.swift b/MC1/Services/Geocoder.swift index d272d409..e927847b 100644 --- a/MC1/Services/Geocoder.swift +++ b/MC1/Services/Geocoder.swift @@ -5,35 +5,35 @@ import Foundation /// `GeocodeResult` so test doubles don't need to construct `CLPlacemark` (which /// has no usable initializer outside CoreLocation). protocol Geocoder: Sendable { - func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? - func cancelGeocode() + func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? + func cancelGeocode() } -struct GeocodeResult: Sendable, Equatable { - let countryCode: String? - let administrativeArea: String? - let subAdministrativeArea: String? +struct GeocodeResult: Equatable { + let countryCode: String? + let administrativeArea: String? + let subAdministrativeArea: String? } /// `CLGeocoder`-backed implementation. Retains a single `CLGeocoder` instance /// so cancellation actually cancels the in-flight request — using a fresh /// instance per call (the previous shape) made `cancelGeocode()` a no-op. final class AppleGeocoder: Geocoder, @unchecked Sendable { - private let geocoder = CLGeocoder() + private let geocoder = CLGeocoder() - init() {} + init() {} - func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? { - let placemarks = try await geocoder.reverseGeocodeLocation(location, preferredLocale: preferredLocale) - guard let placemark = placemarks.first else { return nil } - return GeocodeResult( - countryCode: placemark.isoCountryCode, - administrativeArea: placemark.administrativeArea, - subAdministrativeArea: placemark.subAdministrativeArea - ) - } + func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? { + let placemarks = try await geocoder.reverseGeocodeLocation(location, preferredLocale: preferredLocale) + guard let placemark = placemarks.first else { return nil } + return GeocodeResult( + countryCode: placemark.isoCountryCode, + administrativeArea: placemark.administrativeArea, + subAdministrativeArea: placemark.subAdministrativeArea + ) + } - func cancelGeocode() { - geocoder.cancelGeocode() - } + func cancelGeocode() { + geocoder.cancelGeocode() + } } diff --git a/MC1/Services/ImageHeaderDecoder.swift b/MC1/Services/ImageHeaderDecoder.swift index 85af2591..8191e049 100644 --- a/MC1/Services/ImageHeaderDecoder.swift +++ b/MC1/Services/ImageHeaderDecoder.swift @@ -5,22 +5,21 @@ import ImageIO /// payload without decoding the full bitmap. Used by inline-image probes and /// link-preview hero extraction. enum ImageHeaderDecoder { - - /// Returns pixel width and height parsed from the image header in `data`. - /// Returns `nil` if the bytes are not a recognized image or either - /// dimension is missing or non-positive. - static func decodeDimensions(from data: Data) -> (width: Int, height: Int)? { - guard let source = CGImageSourceCreateWithData(data as CFData, nil), - let props = CGImageSourceCopyPropertiesAtIndex( - source, - 0, - [kCGImageSourceShouldCache: false] as CFDictionary - ) as? [CFString: Any], - let width = props[kCGImagePropertyPixelWidth] as? Int, - let height = props[kCGImagePropertyPixelHeight] as? Int, - width > 0, height > 0 else { - return nil - } - return (width, height) + /// Returns pixel width and height parsed from the image header in `data`. + /// Returns `nil` if the bytes are not a recognized image or either + /// dimension is missing or non-positive. + static func decodeDimensions(from data: Data) -> (width: Int, height: Int)? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let props = CGImageSourceCopyPropertiesAtIndex( + source, + 0, + [kCGImageSourceShouldCache: false] as CFDictionary + ) as? [CFString: Any], + let width = props[kCGImagePropertyPixelWidth] as? Int, + let height = props[kCGImagePropertyPixelHeight] as? Int, + width > 0, height > 0 else { + return nil } + return (width, height) + } } diff --git a/MC1/Services/ImageURLDetector.swift b/MC1/Services/ImageURLDetector.swift index 16859df4..5d8f6917 100644 --- a/MC1/Services/ImageURLDetector.swift +++ b/MC1/Services/ImageURLDetector.swift @@ -5,76 +5,75 @@ import UIKit /// Inline image decoding (UIKit/ImageIO). For URL classification, use /// `MC1Services.ImageURLClassifier`. enum ImageURLDetector { + /// Max pixel dimension for inline image display (280pt × 3x scale) + private static let inlineMaxPixelSize: CGFloat = 900 - /// Max pixel dimension for inline image display (280pt × 3x scale) - private static let inlineMaxPixelSize: CGFloat = 900 - - /// Decodes an image at a reduced size using ImageIO, avoiding full-resolution decode. - /// Falls back to `UIImage(data:)` if thumbnail generation fails. - static func downsampledImage(from data: Data) -> UIImage? { - let options: [CFString: Any] = [ - kCGImageSourceShouldCache: false - ] - guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else { - return UIImage(data: data) - } - let downsampleOptions: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true - ] - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { - return UIImage(data: data) - } - return UIImage(cgImage: cgImage) + /// Decodes an image at a reduced size using ImageIO, avoiding full-resolution decode. + /// Falls back to `UIImage(data:)` if thumbnail generation fails. + static func downsampledImage(from data: Data) -> UIImage? { + let options: [CFString: Any] = [ + kCGImageSourceShouldCache: false + ] + guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else { + return UIImage(data: data) } - - /// Returns `true` if the data begins with the GIF magic bytes (`GIF8`) - static func isGIFData(_ data: Data) -> Bool { - guard data.count >= 4 else { return false } - return data[data.startIndex] == 0x47 // G - && data[data.startIndex + 1] == 0x49 // I - && data[data.startIndex + 2] == 0x46 // F - && data[data.startIndex + 3] == 0x38 // 8 + let downsampleOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { + return UIImage(data: data) } + return UIImage(cgImage: cgImage) + } - /// Decodes GIF data into an animated UIImage using CGImageSource. - /// Returns `nil` if the data is not valid GIF data. - static func decodeGIFImage(from data: Data) -> UIImage? { - guard isGIFData(data), - let source = CGImageSourceCreateWithData(data as CFData, nil) else { - return nil - } + /// Returns `true` if the data begins with the GIF magic bytes (`GIF8`) + static func isGIFData(_ data: Data) -> Bool { + guard data.count >= 4 else { return false } + return data[data.startIndex] == 0x47 // G + && data[data.startIndex + 1] == 0x49 // I + && data[data.startIndex + 2] == 0x46 // F + && data[data.startIndex + 3] == 0x38 // 8 + } - let count = CGImageSourceGetCount(source) - guard count > 1 else { - return downsampledImage(from: data) - } + /// Decodes GIF data into an animated UIImage using CGImageSource. + /// Returns `nil` if the data is not valid GIF data. + static func decodeGIFImage(from data: Data) -> UIImage? { + guard isGIFData(data), + let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } - let thumbnailOptions: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true - ] + let count = CGImageSourceGetCount(source) + guard count > 1 else { + return downsampledImage(from: data) + } - var frames: [UIImage] = [] - var totalDuration: TimeInterval = 0 + let thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] - for i in 0..() - private let fetchSemaphore = AsyncSemaphore(value: 3) - private var failedURLs: Set = [] - private var inFlightURLs: Set = [] - private var dimensionsStore: InlineImageDimensionsStore? - - /// Per-URL decoded-image cache. Survives `ChatViewModel` teardown so - /// exit-then-reenter of the same chat does not reshimmer. Read via a - /// wait-free `OSAllocatedUnfairLock` mirror so main-actor view bodies - /// can resolve cache hits without awaiting an actor hop. FIFO eviction - /// bounded by `maxDecodedEntryCount` and `maxDecodedTotalCostBytes`. - private let decodedMirror = OSAllocatedUnfairLock(initialState: DecodedCacheState()) - - private static let maxEntryCount = 50 - private static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB - private static let maxDownloadBytes = 10 * 1024 * 1024 // 10MB per image - private static let probeMaxBufferBytes = 1 * 1024 * 1024 // 1MB cap for probe bodies - private static let probeByteRange = "bytes=0-65535" - private static let rangeHeaderField = "Range" - private static let httpStatusOK = 200 - private static let httpStatusPartialContent = 206 - - /// Decoded-cache caps cover the total working-set per entry: decoded - /// pixel bytes (cgImage bytesPerRow times height across frames) plus - /// optional retained encoded bytes for the viewer/share path. A 4MB - /// JPEG decoded to a 4096x3072 RGBA bitmap is ~48MB of pixels alone; - /// the encoded `data` term keeps the cost honest when both are held. - private static let maxDecodedEntryCount = 50 - private static let maxDecodedTotalCostBytes = 100 * 1024 * 1024 - - init() { - memoryCache.countLimit = Self.maxEntryCount - memoryCache.totalCostLimit = Self.maxTotalCostBytes - - // Mirror NSCache's auto-eviction-on-memory-warning behavior for the - // hand-rolled decoded mirror. The closure is retained by - // NotificationCenter for the singleton's lifetime; weak self keeps - // the contract clean even though the cache outlives the process. - NotificationCenter.default.addObserver( - forName: UIApplication.didReceiveMemoryWarningNotification, - object: nil, - queue: nil - ) { [weak self] _ in - self?.clearDecodedMirror() - } + static let shared = InlineImageCache() + + private let logger = Logger(subsystem: "com.mc1", category: "InlineImageCache") + + private let session: URLSession + private let memoryCache = NSCache() + private let fetchSemaphore = AsyncSemaphore(value: 3) + private var failedURLs: Set = [] + private var inFlightURLs: Set = [] + private var dimensionsStore: InlineImageDimensionsStore? + + /// Per-URL decoded-image cache. Survives `ChatViewModel` teardown so + /// exit-then-reenter of the same chat does not reshimmer. Read via a + /// wait-free `OSAllocatedUnfairLock` mirror so main-actor view bodies + /// can resolve cache hits without awaiting an actor hop. FIFO eviction + /// bounded by `maxDecodedEntryCount` and `maxDecodedTotalCostBytes`. + private let decodedMirror = OSAllocatedUnfairLock(initialState: DecodedCacheState()) + + /// URLs whose path carried an image extension but whose fetch returned an + /// HTML page (imgur, pasteboard, prnt.sc landing pages). Process-lifetime + /// and lock-guarded so the reroute-to-card verdict survives `ChatViewModel` + /// teardown: without it, exit-then-reenter re-classifies the URL as an + /// inline image and strands the reloaded card as an endless loading shimmer. + /// Bounded FIFO; the working set of such URLs is small. + private let servesPageMirror = OSAllocatedUnfairLock(initialState: ServesPageState()) + + private static let maxEntryCount = 50 + private static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB + private static let maxDownloadBytes = 10 * 1024 * 1024 // 10MB per image + private static let probeMaxBufferBytes = 1 * 1024 * 1024 // 1MB cap for probe bodies + private static let probeByteRange = "bytes=0-65535" + private static let rangeHeaderField = "Range" + private static let httpStatusOK = 200 + private static let httpStatusPartialContent = 206 + /// MIME type that marks a fetched body as an HTML page rather than an + /// image, even when the URL path carries an image extension. + private static let htmlMimeType = "text/html" + + /// Decoded-cache caps cover the total working-set per entry: decoded + /// pixel bytes (cgImage bytesPerRow times height across frames) plus + /// optional retained encoded bytes for the viewer/share path. A 4MB + /// JPEG decoded to a 4096x3072 RGBA bitmap is ~48MB of pixels alone; + /// the encoded `data` term keeps the cost honest when both are held. + private static let maxDecodedEntryCount = 50 + private static let maxDecodedTotalCostBytes = 100 * 1024 * 1024 + private static let maxServesPageEntries = 200 + + init(session: URLSession = .shared) { + self.session = session + memoryCache.countLimit = Self.maxEntryCount + memoryCache.totalCostLimit = Self.maxTotalCostBytes + + // Mirror NSCache's auto-eviction-on-memory-warning behavior for the + // hand-rolled decoded mirror. The closure is retained by + // NotificationCenter for the singleton's lifetime; weak self keeps + // the contract clean even though the cache outlives the process. + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.clearDecodedMirror() } - - /// Empties the decoded-image mirror in response to system memory - /// pressure. `nonisolated` so the notification block can call it - /// directly without an actor hop on whichever queue the system - /// delivers the warning. - nonisolated func clearDecodedMirror() { - decodedMirror.withLock { state in - state.entries.removeAll() - state.insertionOrder.removeAll() - state.totalCostBytes = 0 - } - } - - /// Registers the persistence sink for successful probes. Held strongly; - /// later calls overwrite the reference so each connection's store wins. - func attachDimensionsStore(_ store: InlineImageDimensionsStore) { - self.dimensionsStore = store + } + + /// Empties the decoded-image mirror in response to system memory + /// pressure. `nonisolated` so the notification block can call it + /// directly without an actor hop on whichever queue the system + /// delivers the warning. + nonisolated func clearDecodedMirror() { + decodedMirror.withLock { state in + state.entries.removeAll() + state.insertionOrder.removeAll() + state.totalCostBytes = 0 } + } - /// Fetches image data for the given URL, returning cached data when available. - func fetchImageData(for url: URL) async -> InlineImageResult { - let key = url.absoluteString - - // Check negative cache - if failedURLs.contains(key) { - return .failed - } - - // Check memory cache - if let cached = memoryCache.object(forKey: key as NSString) { - return .loaded(cached.data) - } - - // Prevent duplicate in-flight fetches - guard !inFlightURLs.contains(key) else { - return .loading - } + /// Registers the persistence sink for successful probes. Held strongly; + /// later calls overwrite the reference so each connection's store wins. + func attachDimensionsStore(_ store: InlineImageDimensionsStore) { + dimensionsStore = store + } - inFlightURLs.insert(key) - await fetchSemaphore.wait() - - // Single exit path after semaphore acquisition - let result: InlineImageResult - if let cached = memoryCache.object(forKey: key as NSString) { - result = .loaded(cached.data) - } else if Task.isCancelled { - result = .failed - } else { - result = await performFetch(for: url, key: key) - } + /// Fetches image data for the given URL, returning cached data when available. + func fetchImageData(for url: URL) async -> InlineImageResult { + let key = url.absoluteString - await fetchSemaphore.signal() - inFlightURLs.remove(key) - return result + // Check negative cache + if failedURLs.contains(key) { + return .failed } - /// Removes a URL from the negative cache, allowing it to be retried. - func clearFailure(for url: URL) { - failedURLs.remove(url.absoluteString) + // Check memory cache + if let cached = memoryCache.object(forKey: key as NSString) { + return .loaded(cached.data) } - /// Persists a decoded `UIImage` keyed on the direct image URL so a - /// later chat re-entry can skip the decode step. Called from - /// `ChatViewModel.fetchInlineImage` *before* its per-VM cancellation - /// guards so a scroll-away or chat-exit mid-decode still hands the - /// pixels to the next visit. FIFO eviction inside the lock keeps the - /// mirror within budget. `nonisolated` because the body only touches - /// the lock-guarded mirror, never actor-isolated state. - nonisolated func storeDecoded(_ entry: CachedDecodedImage, for url: URL) { - let key = url.absoluteString - decodedMirror.withLock { state in - if let existing = state.entries[key] { - state.totalCostBytes -= existing.cost - if let idx = state.insertionOrder.firstIndex(of: key) { - state.insertionOrder.remove(at: idx) - } - } - state.entries[key] = entry - state.insertionOrder.append(key) - state.totalCostBytes += entry.cost - - // Keep at least the just-inserted entry, even when it singly - // exceeds the cost budget — a 4K image decodes to ~50MB on its - // own and we'd otherwise evict immediately and never serve it. - while state.insertionOrder.count > 1, - state.insertionOrder.count > Self.maxDecodedEntryCount - || state.totalCostBytes > Self.maxDecodedTotalCostBytes { - let oldest = state.insertionOrder.removeFirst() - if let evicted = state.entries.removeValue(forKey: oldest) { - state.totalCostBytes -= evicted.cost - } - } - } + // Prevent duplicate in-flight fetches + guard !inFlightURLs.contains(key) else { + return .loading } - /// Nonisolated wait-free decoded-image lookup. Safe to call from a - /// SwiftUI view body or the main-actor URL-detection write path - /// without an actor hop, mirroring the shape of - /// `InlineImageDimensionsStore.aspect(for:)`. - nonisolated func decoded(for url: URL) -> CachedDecodedImage? { - decodedMirror.withLock { $0.entries[url.absoluteString] } + inFlightURLs.insert(key) + await fetchSemaphore.wait() + + // Single exit path after semaphore acquisition + let result: InlineImageResult = if let cached = memoryCache.object(forKey: key as NSString) { + .loaded(cached.data) + } else if Task.isCancelled { + .failed + } else { + await performFetch(for: url, key: key) } - /// Probes the image header for pixel dimensions without persisting the bytes. - /// Uses a small Range request and `ImageHeaderDecoder`. Persists the resolved - /// size to the attached dimensions store on success. Failures do not touch - /// the negative cache or the in-flight set. - func probeImageDimensions(url: URL) async -> CGSize? { - guard await URLSafetyChecker.isSafe(url) else { - logger.debug("Blocked probe to unsafe URL: \(url.host() ?? "unknown")") - return nil + await fetchSemaphore.signal() + inFlightURLs.remove(key) + return result + } + + /// Removes a URL from the negative cache, allowing it to be retried. + func clearFailure(for url: URL) { + failedURLs.remove(url.absoluteString) + } + + /// Persists a decoded `UIImage` keyed on the direct image URL so a + /// later chat re-entry can skip the decode step. Called from + /// `ChatViewModel.fetchInlineImage` *before* its per-VM cancellation + /// guards so a scroll-away or chat-exit mid-decode still hands the + /// pixels to the next visit. FIFO eviction inside the lock keeps the + /// mirror within budget. `nonisolated` because the body only touches + /// the lock-guarded mirror, never actor-isolated state. + nonisolated func storeDecoded(_ entry: CachedDecodedImage, for url: URL) { + let key = url.absoluteString + decodedMirror.withLock { state in + if let existing = state.entries[key] { + state.totalCostBytes -= existing.cost + if let idx = state.insertionOrder.firstIndex(of: key) { + state.insertionOrder.remove(at: idx) } + } + state.entries[key] = entry + state.insertionOrder.append(key) + state.totalCostBytes += entry.cost + + // Keep at least the just-inserted entry, even when it singly + // exceeds the cost budget — a 4K image decodes to ~50MB on its + // own and we'd otherwise evict immediately and never serve it. + while state.insertionOrder.count > 1, + state.insertionOrder.count > Self.maxDecodedEntryCount + || state.totalCostBytes > Self.maxDecodedTotalCostBytes { + let oldest = state.insertionOrder.removeFirst() + if let evicted = state.entries.removeValue(forKey: oldest) { + state.totalCostBytes -= evicted.cost + } + } + } + } + + /// Nonisolated wait-free decoded-image lookup. Safe to call from a + /// SwiftUI view body or the main-actor URL-detection write path + /// without an actor hop, mirroring the shape of + /// `InlineImageDimensionsStore.aspect(for:)`. + nonisolated func decoded(for url: URL) -> CachedDecodedImage? { + decodedMirror.withLock { $0.entries[url.absoluteString] } + } + + /// Records that an image-extension URL served an HTML page, so later + /// classification reroutes it to the link-preview path even across chat + /// re-entries. Called from `performFetch` on the `.notImage` verdict. + /// `nonisolated` because the body only touches the lock-guarded mirror. + nonisolated func markServesHTMLPage(_ url: URL) { + let key = url.absoluteString + servesPageMirror.withLock { state in + guard state.urls.insert(key).inserted else { return } + state.insertionOrder.append(key) + while state.insertionOrder.count > Self.maxServesPageEntries { + state.urls.remove(state.insertionOrder.removeFirst()) + } + } + } + + /// Nonisolated wait-free lookup for the build path: `true` when the URL is a + /// known image-extension URL that serves a page, so classification routes it + /// to the card fragment instead of stranding it as an inline-image shimmer. + nonisolated func servesHTMLPage(_ url: URL) -> Bool { + servesPageMirror.withLock { $0.urls.contains(url.absoluteString) } + } + + /// Probes the image header for pixel dimensions without persisting the bytes. + /// Uses a small Range request and `ImageHeaderDecoder`. Persists the resolved + /// size to the attached dimensions store on success. Failures do not touch + /// the negative cache or the in-flight set. + func probeImageDimensions(url: URL) async -> CGSize? { + guard await URLSafetyChecker.isSafe(url) else { + logger.debug("Blocked probe to unsafe URL: \(url.host() ?? "unknown")") + return nil + } - logger.debug("Probing image dimensions: \(url.absoluteString)") - - await fetchSemaphore.wait() + logger.debug("Probing image dimensions: \(url.absoluteString)") - var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) - request.setValue(Self.probeByteRange, forHTTPHeaderField: Self.rangeHeaderField) + await fetchSemaphore.wait() - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - logger.info("Image probe network failure: \(error.localizedDescription)") - await fetchSemaphore.signal() - return nil - } + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) + request.setValue(Self.probeByteRange, forHTTPHeaderField: Self.rangeHeaderField) - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == Self.httpStatusOK - || httpResponse.statusCode == Self.httpStatusPartialContent else { - let code = (response as? HTTPURLResponse)?.statusCode ?? -1 - logger.info("Image probe non-success status \(code): \(url.absoluteString)") - await fetchSemaphore.signal() - return nil - } + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + logger.info("Image probe network failure: \(error.localizedDescription)") + await fetchSemaphore.signal() + return nil + } - guard data.count <= Self.probeMaxBufferBytes else { - logger.info("Image probe body exceeds cap: \(url.absoluteString)") - await fetchSemaphore.signal() - return nil - } + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == Self.httpStatusOK + || httpResponse.statusCode == Self.httpStatusPartialContent else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger.info("Image probe non-success status \(code): \(url.absoluteString)") + await fetchSemaphore.signal() + return nil + } - guard let dims = ImageHeaderDecoder.decodeDimensions(from: data) else { - logger.info("Image probe could not decode dimensions: \(url.absoluteString)") - await fetchSemaphore.signal() - return nil - } + guard data.count <= Self.probeMaxBufferBytes else { + logger.info("Image probe body exceeds cap: \(url.absoluteString)") + await fetchSemaphore.signal() + return nil + } - let size = CGSize(width: dims.width, height: dims.height) - logger.debug("Probed dimensions \(dims.width)x\(dims.height): \(url.absoluteString)") + guard let dims = ImageHeaderDecoder.decodeDimensions(from: data) else { + logger.info("Image probe could not decode dimensions: \(url.absoluteString)") + await fetchSemaphore.signal() + return nil + } - if let dimensionsStore { - await dimensionsStore.save(url: url, size: size) - } + let size = CGSize(width: dims.width, height: dims.height) + logger.debug("Probed dimensions \(dims.width)x\(dims.height): \(url.absoluteString)") - await fetchSemaphore.signal() - return size + if let dimensionsStore { + await dimensionsStore.save(url: url, size: size) } - /// Performs the HTTP fetch, validates the response, and caches the result. - private func performFetch(for url: URL, key: String) async -> InlineImageResult { - guard await URLSafetyChecker.isSafe(url) else { - logger.debug("Blocked fetch to unsafe URL: \(url.host() ?? "unknown")") - failedURLs.insert(key) - return .failed - } + await fetchSemaphore.signal() + return size + } - do { - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - logger.debug("Non-success HTTP status for image: \(url.absoluteString)") - failedURLs.insert(key) - return .failed - } - - guard data.count <= Self.maxDownloadBytes else { - logger.debug("Image too large (\(data.count) bytes): \(url.absoluteString)") - failedURLs.insert(key) - return .failed - } - - // Lightweight validation: check that ImageIO recognizes the data as an image - guard CGImageSourceCreateWithData(data as CFData, nil) != nil else { - logger.debug("Data is not a valid image: \(url.absoluteString)") - failedURLs.insert(key) - return .failed - } - - memoryCache.setObject(CachedImageData(data), forKey: key as NSString, cost: data.count) - return .loaded(data) - - } catch { - if !Task.isCancelled { - logger.debug("Failed to fetch image: \(error.localizedDescription)") - failedURLs.insert(key) - } - return .failed - } + /// Performs the HTTP fetch, validates the response, and caches the result. + private func performFetch(for url: URL, key: String) async -> InlineImageResult { + guard await URLSafetyChecker.isSafe(url) else { + logger.debug("Blocked fetch to unsafe URL: \(url.host() ?? "unknown")") + failedURLs.insert(key) + return .failed } + do { + let (data, response) = try await session.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + logger.debug("Non-success HTTP status for image: \(url.absoluteString)") + failedURLs.insert(key) + return .failed + } + + // An image-extension URL that serves an HTML landing page (imgur, + // pasteboard, prnt.sc) is a page, not a decode failure. Return before + // the size guard so an oversized page still reroutes instead of + // dead-ending in the negative cache, and do not insert into failedURLs: + // this is a reclassification, so the URL must stay retryable. + if httpResponse.mimeType == Self.htmlMimeType { + logger.debug("Image URL served HTML, rerouting to preview: \(url.absoluteString)") + markServesHTMLPage(url) + return .notImage + } + + guard data.count <= Self.maxDownloadBytes else { + logger.debug("Image too large (\(data.count) bytes): \(url.absoluteString)") + failedURLs.insert(key) + return .failed + } + + // Lightweight validation: check that ImageIO recognizes the data as an image + guard CGImageSourceCreateWithData(data as CFData, nil) != nil else { + logger.debug("Data is not a valid image: \(url.absoluteString)") + failedURLs.insert(key) + return .failed + } + + memoryCache.setObject(CachedImageData(data), forKey: key as NSString, cost: data.count) + return .loaded(data) + + } catch { + if !Task.isCancelled { + logger.debug("Failed to fetch image: \(error.localizedDescription)") + failedURLs.insert(key) + } + return .failed + } + } } /// Wrapper class for NSCache (requires reference type) private final class CachedImageData: @unchecked Sendable { - let data: Data - init(_ data: Data) { self.data = data } + let data: Data + init(_ data: Data) { + self.data = data + } } /// Reference-typed payload for the decoded-image cache. Carries optional @@ -286,36 +337,41 @@ private final class CachedImageData: @unchecked Sendable { /// because the stored properties are `let` and `UIImage` / `Data` are /// immutable post-construction. final class CachedDecodedImage: @unchecked Sendable { - let image: UIImage - let isGIF: Bool - /// Original encoded bytes for static images, so the full-screen viewer - /// can present at full resolution and the share sheet can hand off - /// raw `Data`. Nil for GIFs, matching the existing per-VM - /// `loadedImageData` policy (GIFs inline-animate via the UIImage but - /// do not open the full-screen viewer). - let data: Data? - let cost: Int - - init(image: UIImage, isGIF: Bool, data: Data?) { - self.image = image - self.isGIF = isGIF - self.data = data - self.cost = Self.computeCost(for: image, isGIF: isGIF) + (data?.count ?? 0) + let image: UIImage + let isGIF: Bool + /// Original encoded bytes for static images, so the full-screen viewer + /// can present at full resolution and the share sheet can hand off + /// raw `Data`. Nil for GIFs, matching the existing per-VM + /// `loadedImageData` policy (GIFs inline-animate via the UIImage but + /// do not open the full-screen viewer). + let data: Data? + let cost: Int + + init(image: UIImage, isGIF: Bool, data: Data?) { + self.image = image + self.isGIF = isGIF + self.data = data + cost = Self.computeCost(for: image, isGIF: isGIF) + (data?.count ?? 0) + } + + private static func computeCost(for image: UIImage, isGIF: Bool) -> Int { + if isGIF, let frames = image.images, !frames.isEmpty { + return frames.reduce(0) { $0 + ImageByteCost.bytes(for: $1) } } + return ImageByteCost.bytes(for: image) + } +} - private static func computeCost(for image: UIImage, isGIF: Bool) -> Int { - if isGIF, let frames = image.images, !frames.isEmpty { - return frames.reduce(0) { $0 + ImageByteCost.bytes(for: $1) } - } - return ImageByteCost.bytes(for: image) - } +private struct ServesPageState { + var urls: Set = [] + var insertionOrder: [String] = [] } /// State held under the decoded-mirror lock. Combining the dict with the /// insertion-order list and running cost lets a single `withLock` perform /// both the write and the eviction sweep atomically. private struct DecodedCacheState { - var entries: [String: CachedDecodedImage] = [:] - var insertionOrder: [String] = [] - var totalCostBytes: Int = 0 + var entries: [String: CachedDecodedImage] = [:] + var insertionOrder: [String] = [] + var totalCostBytes: Int = 0 } diff --git a/MC1/Services/InlineImagePrefetcher.swift b/MC1/Services/InlineImagePrefetcher.swift index 504efad5..4e5fc274 100644 --- a/MC1/Services/InlineImagePrefetcher.swift +++ b/MC1/Services/InlineImagePrefetcher.swift @@ -1,11 +1,11 @@ -import Foundation import CoreGraphics +import Foundation import MC1Services /// Probing seam for inline image dimension lookup. Lets tests inject a /// stand-in for `InlineImageCache.probeImageDimensions(url:)`. protocol InlineImageDimensionProbing: AnyObject, Sendable { - func probeImageDimensions(url: URL) async -> CGSize? + func probeImageDimensions(url: URL) async -> CGSize? } extension InlineImageCache: InlineImageDimensionProbing {} @@ -16,58 +16,65 @@ extension InlineImageCache: InlineImageDimensionProbing {} /// a slow probe never blocks message admission. @MainActor final class InlineImagePrefetcher { + private let imageCache: any InlineImageDimensionProbing + private let linkPreviewCache: any LinkPreviewCaching + private let dimensionsStore: InlineImageDimensionsStore + private let dataStore: any PersistenceStoreProtocol - private let imageCache: any InlineImageDimensionProbing - private let linkPreviewCache: any LinkPreviewCaching - private let dimensionsStore: InlineImageDimensionsStore - private let dataStore: any PersistenceStoreProtocol - - init( - imageCache: any InlineImageDimensionProbing, - linkPreviewCache: any LinkPreviewCaching, - dimensionsStore: InlineImageDimensionsStore, - dataStore: any PersistenceStoreProtocol - ) { - self.imageCache = imageCache - self.linkPreviewCache = linkPreviewCache - self.dimensionsStore = dimensionsStore - self.dataStore = dataStore - } + init( + imageCache: any InlineImageDimensionProbing, + linkPreviewCache: any LinkPreviewCaching, + dimensionsStore: InlineImageDimensionsStore, + dataStore: any PersistenceStoreProtocol + ) { + self.imageCache = imageCache + self.linkPreviewCache = linkPreviewCache + self.dimensionsStore = dimensionsStore + self.dataStore = dataStore + } - /// Prefetch dimensions and link-preview metadata for every URL in `text`. - /// Returns once all probes have resolved (success or failure). Never throws. - /// - /// Delegates URL extraction to `LinkPreviewService.extractAllURLs(in:)` so - /// the receive-time prefetcher and the per-message URL-detection writer - /// see the same set of URLs (Giphy short-codes expanded, `@[mention]` - /// ranges skipped, HTTP/HTTPS only). - func prefetch(urlsIn text: String, isChannelMessage: Bool) async { - let urls = LinkPreviewService.extractAllURLs(in: text) - guard !urls.isEmpty else { return } + /// Prefetch dimensions and link-preview metadata for every URL in `text`. + /// Returns once all probes have resolved (success or failure). Never throws. + /// + /// Delegates URL extraction to `LinkPreviewService.extractAllURLs(in:)` so + /// the receive-time prefetcher and the per-message URL-detection writer + /// see the same set of URLs (Giphy short-codes expanded, `@[mention]` + /// ranges skipped, HTTP/HTTPS only). + /// + /// `allowImageProbes` is the privacy gate for direct-image URLs: when false + /// (master on but auto-resolve off for this conversation type, or handled by + /// the caller's own master check), the dimension probe is skipped so no + /// third-party image request fires on receive. The card branch stays + /// unconditional: `LinkPreviewCache.preview` self-gates via + /// `shouldAutoResolve` and its cache checks perform no network fetch. + func prefetch(urlsIn text: String, isChannelMessage: Bool, allowImageProbes: Bool) async { + let urls = LinkPreviewService.extractAllURLs(in: text) + guard !urls.isEmpty else { return } - let imageCache = self.imageCache - let linkPreviewCache = self.linkPreviewCache - let dimensionsStore = self.dimensionsStore - let dataStore = self.dataStore + let imageCache = imageCache + let linkPreviewCache = linkPreviewCache + let dimensionsStore = dimensionsStore + let dataStore = dataStore - await withTaskGroup(of: Void.self) { group in - for url in urls { - if ImageURLClassifier.isImageURL(url) { - let probeURL = ImageURLClassifier.directImageURL(for: url) - guard dimensionsStore.aspect(for: probeURL) == nil else { continue } - group.addTask { - _ = await imageCache.probeImageDimensions(url: probeURL) - } - } else { - group.addTask { - _ = await linkPreviewCache.preview( - for: url, - using: dataStore, - isChannelMessage: isChannelMessage - ) - } - } - } + await withTaskGroup(of: Void.self) { group in + for url in urls { + if ImageURLClassifier.isImageURL(url) { + guard allowImageProbes else { continue } + let probeURL = ImageURLClassifier.directImageURL(for: url) + guard dimensionsStore.aspect(for: probeURL) == nil else { continue } + group.addTask { + _ = await imageCache.probeImageDimensions(url: probeURL) + } + } else { + group.addTask { + _ = await linkPreviewCache.preview( + for: url, + using: dataStore, + isChannelMessage: isChannelMessage + ) + } } + } } + } } diff --git a/MC1/Services/LinkPreviewCache.swift b/MC1/Services/LinkPreviewCache.swift index d322a85a..9aad564d 100644 --- a/MC1/Services/LinkPreviewCache.swift +++ b/MC1/Services/LinkPreviewCache.swift @@ -1,184 +1,185 @@ import Foundation -import OSLog import MC1Services +import OSLog // MARK: - Constants private enum CacheConfig { - static let maxEntryCount = 100 - static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB - static let maxConcurrentFetches = 3 + static let maxEntryCount = 100 + static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB + static let maxConcurrentFetches = 3 } /// Two-tier cache for link previews with URL-based deduplication. /// Uses actor isolation for thread safety without blocking the main thread. /// Limits concurrent LPMetadataProvider instances to prevent WKWebView spawn bursts. actor LinkPreviewCache: LinkPreviewCaching { - private let logger = Logger(subsystem: "com.mc1", category: "LinkPreviewCache") - private let memoryCache = NSCache() - private let service: any LinkMetadataFetching - private nonisolated let preferences = LinkPreviewPreferences() - - /// Shared fetch task per in-flight URL. Concurrent requests for the same - /// URL await the same task and receive the resolved result, instead of a - /// `.loading` placeholder that would strand a follower's preview state with - /// no path back to `.loaded`. - private var inFlightTasks: [String: Task] = [:] - - /// URLs that have been fetched but have no preview available - private var noPreviewAvailable: Set = [] - - /// Semaphore to limit concurrent LPMetadataProvider instances. - /// Each LPMetadataProvider spawns WKWebView on main thread. - private let fetchSemaphore = AsyncSemaphore(value: CacheConfig.maxConcurrentFetches) - - init(service: any LinkMetadataFetching = LinkPreviewService()) { - self.service = service - memoryCache.countLimit = CacheConfig.maxEntryCount - memoryCache.totalCostLimit = CacheConfig.maxTotalCostBytes + private let logger = Logger(subsystem: "com.mc1", category: "LinkPreviewCache") + private let memoryCache = NSCache() + private let service: any LinkMetadataFetching + private nonisolated let preferences = LinkPreviewPreferences() + + /// Shared fetch task per in-flight URL. Concurrent requests for the same + /// URL await the same task and receive the resolved result, instead of a + /// `.loading` placeholder that would strand a follower's preview state with + /// no path back to `.loaded`. + private var inFlightTasks: [String: Task] = [:] + + /// URLs that have been fetched but have no preview available + private var noPreviewAvailable: Set = [] + + /// Semaphore to limit concurrent LPMetadataProvider instances (each spawns + /// WKWebView on main thread) and, per fetch, the `og:image` scrape and + /// image GET that run as a fallback when LinkPresentation finds no image. + private let fetchSemaphore = AsyncSemaphore(value: CacheConfig.maxConcurrentFetches) + + init(service: any LinkMetadataFetching = LinkPreviewService()) { + self.service = service + memoryCache.countLimit = CacheConfig.maxEntryCount + memoryCache.totalCostLimit = CacheConfig.maxTotalCostBytes + } + + func preview( + for url: URL, + using dataStore: any PersistenceStoreProtocol, + isChannelMessage: Bool + ) async -> LinkPreviewResult { + let urlString = url.absoluteString + + // Check negative cache first + if noPreviewAvailable.contains(urlString) { + return .noPreviewAvailable } - func preview( - for url: URL, - using dataStore: any PersistenceStoreProtocol, - isChannelMessage: Bool - ) async -> LinkPreviewResult { - let urlString = url.absoluteString - - // Check negative cache first - if noPreviewAvailable.contains(urlString) { - return .noPreviewAvailable - } - - // Check memory and database caches - if let cached = await checkCaches(urlString: urlString, dataStore: dataStore) { - return .loaded(cached) - } - - // Check preferences before network fetch - guard preferences.shouldAutoResolve(isChannelMessage: isChannelMessage) else { - return .disabled - } - - // Network fetch, coalescing concurrent requests for the same URL - return await fetchFromNetwork(url: url, urlString: urlString, dataStore: dataStore) + // Check memory and database caches + if let cached = await checkCaches(urlString: urlString, dataStore: dataStore) { + return .loaded(cached) } - func manualFetch( - for url: URL, - using dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - let urlString = url.absoluteString + // Check preferences before network fetch + guard preferences.shouldAutoResolve(isChannelMessage: isChannelMessage) else { + return .disabled + } - // Check memory and database caches (skip negative cache for manual retry) - if let cached = await checkCaches(urlString: urlString, dataStore: dataStore) { - return .loaded(cached) - } + // Network fetch, coalescing concurrent requests for the same URL + return await fetchFromNetwork(url: url, urlString: urlString, dataStore: dataStore) + } - // Clear from negative cache on manual retry - noPreviewAvailable.remove(urlString) + func manualFetch( + for url: URL, + using dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult { + let urlString = url.absoluteString - return await fetchFromNetwork(url: url, urlString: urlString, dataStore: dataStore) + // Check memory and database caches (skip negative cache for manual retry) + if let cached = await checkCaches(urlString: urlString, dataStore: dataStore) { + return .loaded(cached) } - // MARK: - Private Helpers - - /// Checks memory cache and database for existing preview data. - /// Returns the DTO if found and caches in memory if loaded from database. - private func checkCaches( - urlString: String, - dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewDataDTO? { - // Tier 1: Memory cache (instant) - if let cached = memoryCache.object(forKey: urlString as NSString) { - return cached.dto - } - - // Tier 2: Database lookup - do { - if let persisted = try await dataStore.fetchLinkPreview(url: urlString) { - let cost = (persisted.imageData?.count ?? 0) + (persisted.iconData?.count ?? 0) - memoryCache.setObject(CachedPreview(persisted), forKey: urlString as NSString, cost: cost) - return persisted - } - } catch { - logger.error("Failed to fetch link preview from database: \(error.localizedDescription)") - } - - return nil + // Clear from negative cache on manual retry + noPreviewAvailable.remove(urlString) + + return await fetchFromNetwork(url: url, urlString: urlString, dataStore: dataStore) + } + + // MARK: - Private Helpers + + /// Checks memory cache and database for existing preview data. + /// Returns the DTO if found and caches in memory if loaded from database. + private func checkCaches( + urlString: String, + dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewDataDTO? { + // Tier 1: Memory cache (instant) + if let cached = memoryCache.object(forKey: urlString as NSString) { + return cached.dto } - /// Coalesces concurrent fetches for the same URL onto a single shared task. - /// Followers await that task and receive its resolved result rather than a - /// `.loading` placeholder. The shared task is independent of any caller's - /// cancellation, so it completes and warms the cache even if the requesting - /// cell scrolls away or the conversation switches. - private func fetchFromNetwork( - url: URL, - urlString: String, - dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - if let existing = inFlightTasks[urlString] { - return await existing.value - } - - let task = Task { [self] in - await performNetworkFetch(url: url, urlString: urlString, dataStore: dataStore) - } - inFlightTasks[urlString] = task - let result = await task.value - inFlightTasks[urlString] = nil - return result + // Tier 2: Database lookup + do { + if let persisted = try await dataStore.fetchLinkPreview(url: urlString) { + let cost = (persisted.imageData?.count ?? 0) + (persisted.iconData?.count ?? 0) + memoryCache.setObject(CachedPreview(persisted), forKey: urlString as NSString, cost: cost) + return persisted + } + } catch { + logger.error("Failed to fetch link preview from database: \(error.localizedDescription)") } - private func performNetworkFetch( - url: URL, - urlString: String, - dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - // Wait for semaphore to limit concurrent fetches - await fetchSemaphore.wait() - defer { Task { await self.fetchSemaphore.signal() } } - - let metadata = await service.fetchMetadata(for: url) - - guard let metadata else { - // Cache negative result to avoid repeated fetch attempts - noPreviewAvailable.insert(urlString) - return .noPreviewAvailable - } - - let heroDimensions = metadata.imageData.flatMap(ImageHeaderDecoder.decodeDimensions(from:)) - let dto = LinkPreviewDataDTO( - url: urlString, - title: metadata.title, - imageData: metadata.imageData, - iconData: metadata.iconData, - imageWidth: heroDimensions?.width, - imageHeight: heroDimensions?.height - ) - - // Cache in memory with cost based on image sizes - let cost = (dto.imageData?.count ?? 0) + (dto.iconData?.count ?? 0) - memoryCache.setObject(CachedPreview(dto), forKey: urlString as NSString, cost: cost) - - // Persist to database - do { - try await dataStore.saveLinkPreview(dto) - } catch { - logger.error("Failed to save link preview to database: \(error.localizedDescription)") - } - - return .loaded(dto) + return nil + } + + /// Coalesces concurrent fetches for the same URL onto a single shared task. + /// Followers await that task and receive its resolved result rather than a + /// `.loading` placeholder. The shared task is independent of any caller's + /// cancellation, so it completes and warms the cache even if the requesting + /// cell scrolls away or the conversation switches. + private func fetchFromNetwork( + url: URL, + urlString: String, + dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult { + if let existing = inFlightTasks[urlString] { + return await existing.value } - func isFetching(_ url: URL) async -> Bool { - inFlightTasks[url.absoluteString] != nil + let task = Task { [self] in + await performNetworkFetch(url: url, urlString: urlString, dataStore: dataStore) + } + inFlightTasks[urlString] = task + let result = await task.value + inFlightTasks[urlString] = nil + return result + } + + private func performNetworkFetch( + url: URL, + urlString: String, + dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult { + // Wait for semaphore to limit concurrent fetches + await fetchSemaphore.wait() + defer { Task { await self.fetchSemaphore.signal() } } + + let metadata = await service.fetchMetadata(for: url) + + guard let metadata else { + // Cache negative result to avoid repeated fetch attempts + noPreviewAvailable.insert(urlString) + return .noPreviewAvailable } - func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { - memoryCache.object(forKey: url.absoluteString as NSString)?.dto + let heroDimensions = metadata.imageData.flatMap(ImageHeaderDecoder.decodeDimensions(from:)) + let dto = LinkPreviewDataDTO( + url: urlString, + title: metadata.title, + imageData: metadata.imageData, + iconData: metadata.iconData, + imageWidth: heroDimensions?.width, + imageHeight: heroDimensions?.height + ) + + // Cache in memory with cost based on image sizes + let cost = (dto.imageData?.count ?? 0) + (dto.iconData?.count ?? 0) + memoryCache.setObject(CachedPreview(dto), forKey: urlString as NSString, cost: cost) + + // Persist to database + do { + try await dataStore.saveLinkPreview(dto) + } catch { + logger.error("Failed to save link preview to database: \(error.localizedDescription)") } + + return .loaded(dto) + } + + func isFetching(_ url: URL) async -> Bool { + inFlightTasks[url.absoluteString] != nil + } + + func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { + memoryCache.object(forKey: url.absoluteString as NSString)?.dto + } } // MARK: - Supporting Types @@ -186,6 +187,8 @@ actor LinkPreviewCache: LinkPreviewCaching { /// Wrapper class for NSCache (requires reference type). /// Immutable after initialization, making @unchecked Sendable safe. private final class CachedPreview: @unchecked Sendable { - let dto: LinkPreviewDataDTO - init(_ dto: LinkPreviewDataDTO) { self.dto = dto } + let dto: LinkPreviewDataDTO + init(_ dto: LinkPreviewDataDTO) { + self.dto = dto + } } diff --git a/MC1/Services/LinkPreviewCaching.swift b/MC1/Services/LinkPreviewCaching.swift index 9e6879f9..6a5c170c 100644 --- a/MC1/Services/LinkPreviewCaching.swift +++ b/MC1/Services/LinkPreviewCaching.swift @@ -1,47 +1,38 @@ import Foundation -import SwiftUI import MC1Services +import SwiftUI /// Protocol for link preview caching, enabling dependency injection and testing protocol LinkPreviewCaching: Sendable { - /// Gets preview for a URL, fetching if needed - func preview( - for url: URL, - using dataStore: any PersistenceStoreProtocol, - isChannelMessage: Bool - ) async -> LinkPreviewResult - - /// Manual fetch bypassing preference check (for tap-to-load) - func manualFetch( - for url: URL, - using dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult - - /// Checks if a fetch is currently in progress for a URL - func isFetching(_ url: URL) async -> Bool - - /// Gets cached preview without triggering fetch - func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? + /// Gets preview for a URL, fetching if needed + func preview( + for url: URL, + using dataStore: any PersistenceStoreProtocol, + isChannelMessage: Bool + ) async -> LinkPreviewResult + + /// Manual fetch bypassing preference check (for tap-to-load) + func manualFetch( + for url: URL, + using dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult + + /// Checks if a fetch is currently in progress for a URL + func isFetching(_ url: URL) async -> Bool + + /// Gets cached preview without triggering fetch + func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? } /// Result of a link preview fetch operation -enum LinkPreviewResult: Sendable { - case loaded(LinkPreviewDataDTO) - case loading - case noPreviewAvailable - case disabled - case failed -} - -// MARK: - Environment Key - -private struct LinkPreviewCacheKey: EnvironmentKey { - static let defaultValue: any LinkPreviewCaching = LinkPreviewCache() +enum LinkPreviewResult { + case loaded(LinkPreviewDataDTO) + case loading + case noPreviewAvailable + case disabled + case failed } extension EnvironmentValues { - var linkPreviewCache: any LinkPreviewCaching { - get { self[LinkPreviewCacheKey.self] } - set { self[LinkPreviewCacheKey.self] = newValue } - } + @Entry var linkPreviewCache: any LinkPreviewCaching = LinkPreviewCache() } diff --git a/MC1/Services/LinkPreviewService+Scrape.swift b/MC1/Services/LinkPreviewService+Scrape.swift new file mode 100644 index 00000000..ed2fa6ce --- /dev/null +++ b/MC1/Services/LinkPreviewService+Scrape.swift @@ -0,0 +1,261 @@ +import Foundation +import ImageIO +import UIKit + +/// `og:image` / `og:title` HTML scrape fallback for `LinkPreviewService`, +/// used when `LPMetadataProvider` finds no hero image. No WebKit: a plain +/// GET of the page, a pure `` tag scan, and a capped image download. +extension LinkPreviewService { + // MARK: - Constants + + /// Streaming bound for the HTML scrape GET. `og:image`/`og:title` live in + /// ``, well under this ceiling. Enforced during the stream (reject + /// on an over-cap `expectedContentLength`, then cancel once the running + /// total exceeds the cap) rather than via a `Range` header, which is only + /// a hint a server may ignore, or `data(for:)`, which buffers the whole + /// body before any `prefix` could bound it. + private static let htmlScrapeByteCap = 512 * 1024 + + /// Pre-decode streaming ceiling for the scraped-image GET, enforced the + /// same streaming way as `htmlScrapeByteCap`. `maxImageSize` still governs + /// post-fetch compression, but that is a post-download threshold and + /// cannot bound an unbounded download or a decode bomb on its own. + private static let imageByteCap = 2 * 1024 * 1024 + + private static let htmlScrapeTimeout: TimeInterval = 5 + private static let imageFetchTimeout: TimeInterval = 5 + + /// Defensive only: both known target hosts (imgur, pasteboard) serve + /// their static `og:image` to a plain GET with no browser User-Agent. + /// Sent regardless, for hosts that do gate on it. + private static let scrapeUserAgent = "MC1LinkPreview/1.0" + private static let userAgentHTTPHeaderField = "User-Agent" + + private static let imageMimePrefix = "image/" + private static let htmlMimeSubstring = "html" + + /// Max pixel dimension for the bounded scraped-image decode. Applied via + /// `CGImageSourceCreateThumbnailAtIndex`, which never allocates a + /// full-size decode buffer, so a small file crafted to decompress into an + /// oversized bitmap cannot exhaust memory. + private static let scrapedImageMaxPixelSize: CGFloat = 2000 + private static let scrapedImageJPEGQuality: CGFloat = 0.9 + + private static let metaPropertyAttribute = "property" + private static let metaNameAttribute = "name" + private static let metaContentAttribute = "content" + private static let ogImageProperty = "og:image" + private static let twitterImageProperty = "twitter:image" + private static let ogTitleProperty = "og:title" + + private static let metaTagPattern: NSRegularExpression? = try? NSRegularExpression( + pattern: "]*>", + options: [.caseInsensitive] + ) + private static let metaAttributePattern: NSRegularExpression? = try? NSRegularExpression( + pattern: "([a-zA-Z][a-zA-Z0-9-]*)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')" + ) + + /// Ordered so `&` decodes last: unescaping it first would let an + /// already-escaped entity like `&lt;` incorrectly decode a second + /// time into `<` instead of the literal text `<`. + private static let htmlEntities: [(String, String)] = [ + (""", "\""), + ("'", "'"), + ("'", "'"), + ("<", "<"), + (">", ">"), + ("&", "&") + ] + + // MARK: - Session + + /// App-lifetime session for the scrape/image GETs, shared by every + /// `LinkPreviewService` instance: a delegate-bound `URLSession` retains + /// itself and its delegate until invalidated, so per-instance sessions + /// would accumulate. A default session follows 3xx redirects + /// automatically, which would bypass the initial `isSafe` check on a + /// page/host that redirects to a private target; `RedirectSafetyDelegate` + /// re-validates every redirect hop so that hop is refused instead of + /// followed. + static let sharedScrapeSession = URLSession( + configuration: .ephemeral, + delegate: RedirectSafetyDelegate(), + delegateQueue: nil + ) + + // MARK: - HTML scrape + + /// Fetches the page and scans its `` tags for an `og:image` / + /// `twitter:image` hero image and an `og:title`. Returns `nil` on any + /// network, status, size, or mime failure, or when the page carries + /// neither hint. + func scrapeHTMLMetadata(for url: URL) async -> (title: String?, imageURL: URL?)? { + guard let data = await boundedData( + for: url, + timeout: Self.htmlScrapeTimeout, + byteCap: Self.htmlScrapeByteCap, + acceptsMime: { $0.contains(Self.htmlMimeSubstring) } + ) else { + return nil + } + + guard let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) else { + return nil + } + + return Self.parseHTMLMetadata(html, baseURL: url) + } + + /// Bounded GET shared by the HTML scrape and the scraped-image fetch: + /// rejects on an over-cap `expectedContentLength` or unexpected mime, + /// then enforces `byteCap` while streaming so the cap holds even when the + /// server lies about, or omits, the content length. Returns `nil` on any + /// network, status, size, or mime failure. + private func boundedData( + for url: URL, + timeout: TimeInterval, + byteCap: Int, + acceptsMime: (String) -> Bool + ) async -> Data? { + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.setValue(Self.scrapeUserAgent, forHTTPHeaderField: Self.userAgentHTTPHeaderField) + + let bytes: URLSession.AsyncBytes + let response: URLResponse + do { + (bytes, response) = try await scrapeSession.bytes(for: request) + } catch { + logger.debug("Bounded fetch failed for \(url): \(error.localizedDescription)") + return nil + } + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode), + httpResponse.expectedContentLength <= Int64(byteCap), + let mimeType = httpResponse.mimeType, + acceptsMime(mimeType) else { + return nil + } + + var data = Data() + do { + for try await byte in bytes { + data.append(byte) + if data.count > byteCap { + return nil + } + } + } catch { + logger.debug("Bounded fetch stream failed for \(url): \(error.localizedDescription)") + return nil + } + + return data + } + + /// Scans every `` tag in `html` for Open Graph / Twitter Card hero + /// image and title hints. Pure and side-effect-free so it can run against + /// captured HTML fixtures in tests without a network fetch. `og:image` + /// wins over `twitter:image` when both are present. + static func parseHTMLMetadata(_ html: String, baseURL: URL) -> (title: String?, imageURL: URL?)? { + var ogImage: String? + var twitterImage: String? + var ogTitle: String? + + for tag in metaTagContents(in: html) { + let attributes = parseAttributes(from: tag) + guard let content = attributes[metaContentAttribute] else { continue } + let property = attributes[metaPropertyAttribute] ?? attributes[metaNameAttribute] + + if ogImage == nil, property == ogImageProperty { + ogImage = content + } else if twitterImage == nil, property == twitterImageProperty { + twitterImage = content + } else if ogTitle == nil, property == ogTitleProperty { + ogTitle = content + } + } + + let rawImageURLString: String? = (ogImage ?? twitterImage).map(unescapeHTMLEntities) + let resolvedImageURL: URL? = rawImageURLString.flatMap { URL(string: $0, relativeTo: baseURL)?.absoluteURL } + let imageURL: URL? = if let resolvedImageURL, resolvedImageURL.scheme == "http" || resolvedImageURL.scheme == "https" { + resolvedImageURL + } else { + nil + } + + let title = ogTitle.map(unescapeHTMLEntities) + + guard imageURL != nil || title != nil else { return nil } + return (title: title, imageURL: imageURL) + } + + /// Returns the raw text of every `` tag in `html`, in document order. + private static func metaTagContents(in html: String) -> [String] { + guard let regex = metaTagPattern else { return [] } + let range = NSRange(html.startIndex..., in: html) + return regex.matches(in: html, range: range).compactMap { match in + Range(match.range, in: html).map { String(html[$0]) } + } + } + + /// Extracts `name="value"` / `name='value'` pairs from a single tag's + /// text, independent of attribute order. + private static func parseAttributes(from tag: String) -> [String: String] { + guard let regex = metaAttributePattern else { return [:] } + let range = NSRange(tag.startIndex..., in: tag) + var attributes: [String: String] = [:] + for match in regex.matches(in: tag, range: range) { + guard let nameRange = Range(match.range(at: 1), in: tag) else { continue } + let valueRange = Range(match.range(at: 2), in: tag) ?? Range(match.range(at: 3), in: tag) + guard let valueRange else { continue } + attributes[tag[nameRange].lowercased()] = String(tag[valueRange]) + } + return attributes + } + + private static func unescapeHTMLEntities(_ string: String) -> String { + htmlEntities.reduce(string) { result, entity in + result.replacingOccurrences(of: entity.0, with: entity.1) + } + } + + // MARK: - Image fetch + + /// Fetches the scraped `og:image` bytes, bounding both the download and + /// the decode. Not private: exercised directly by tests through the + /// injectable `scrapeSession` seam, since `fetchMetadata`'s + /// `LPMetadataProvider` leg cannot be stubbed. + func loadImageData(from url: URL) async -> Data? { + guard let data = await boundedData( + for: url, + timeout: Self.imageFetchTimeout, + byteCap: Self.imageByteCap, + acceptsMime: { $0.hasPrefix(Self.imageMimePrefix) } + ) else { + return nil + } + + return fitToMaxSize(Self.boundedDecode(data)) + } + + /// Decodes at a bounded pixel size via ImageIO rather than `UIImage(data:)` + /// on raw network bytes, so a small file crafted to decompress into a huge + /// bitmap cannot exhaust memory. Always re-encodes to JPEG, since the + /// bound only applies going through the thumbnail path. + private static func boundedDecode(_ data: Data) -> Data? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: scrapedImageMaxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + return UIImage(cgImage: cgImage).jpegData(compressionQuality: scrapedImageJPEGQuality) + } +} diff --git a/MC1/Services/LinkPreviewService.swift b/MC1/Services/LinkPreviewService.swift index a973b2d5..bc0cb9a6 100644 --- a/MC1/Services/LinkPreviewService.swift +++ b/MC1/Services/LinkPreviewService.swift @@ -1,196 +1,223 @@ import Foundation import LinkPresentation -import os import MC1Services +import os import UIKit import UniformTypeIdentifiers /// Metadata extracted from a URL for link previews -struct LinkPreviewMetadata: Sendable { - let url: URL - let title: String? - let imageData: Data? - let iconData: Data? +struct LinkPreviewMetadata { + let url: URL + let title: String? + let imageData: Data? + let iconData: Data? } /// Abstracts the network metadata fetch so the cache layer's fetch /// coalescing can be tested without the LinkPresentation network path. protocol LinkMetadataFetching: Sendable { - func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? + func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? } /// Service for extracting URLs from text and fetching link metadata final class LinkPreviewService: LinkMetadataFetching, Sendable { - private let logger = Logger(subsystem: "com.mc1", category: "LinkPreviewService") - - /// Shared URL detector instance to avoid creating NSDataDetector on every call - private static let urlDetector: NSDataDetector? = { - try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - }() - - /// Extracts a Giphy GIF URL from meshcore-open `g:{id}` message format. - /// - Parameter text: Message text to check - /// - Returns: Giphy direct GIF URL if text matches `g:{id}` format, nil otherwise - static func extractGiphyGIFURL(from text: String) -> URL? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let match = trimmed.wholeMatch(of: /g:([A-Za-z0-9_-]+)/) else { return nil } - return URL(string: "https://media.giphy.com/media/\(match.1)/giphy.gif") + /// Not private: shared with the scrape/image-fetch helpers declared in + /// this type's extension. + let logger = Logger(subsystem: "com.mc1", category: "LinkPreviewService") + + /// Shared session for the `og:image` scrape/image GETs used when + /// `LPMetadataProvider` finds no hero image. Injectable so tests can + /// substitute a `URLProtocol` stub; production shares one app-lifetime + /// redirect-checked session, since a delegate-bound `URLSession` retains + /// itself and its delegate until invalidated and service instances are + /// created per environment read. + let scrapeSession: URLSession + + init(scrapeSession: URLSession? = nil) { + self.scrapeSession = scrapeSession ?? Self.sharedScrapeSession + } + + /// Shared URL detector instance to avoid creating NSDataDetector on every call + private static let urlDetector: NSDataDetector? = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + + /// Extracts a Giphy GIF URL from meshcore-open `g:{id}` message format. + /// - Parameter text: Message text to check + /// - Returns: Giphy direct GIF URL if text matches `g:{id}` format, nil otherwise + static func extractGiphyGIFURL(from text: String) -> URL? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let match = trimmed.wholeMatch(of: /g:([A-Za-z0-9_-]+)/) else { return nil } + return URL(string: "https://media.giphy.com/media/\(match.1)/giphy.gif") + } + + /// Extracts the first HTTP/HTTPS URL from text, excluding URLs within mentions. + /// - Parameter text: Message text to scan + /// - Returns: First HTTP(S) URL found outside mentions, or nil + static func extractFirstURL(from text: String) -> URL? { + extractAllURLs(in: text).first + } + + /// Extracts every HTTP/HTTPS URL from text, in document order. Used by the + /// receive-time prefetcher and the per-message URL-detection writer so + /// both paths see the same set of URLs: + /// - meshcore-open `g:{id}` short-codes are expanded to direct Giphy URLs + /// - URLs inside `@[mention]` ranges are skipped + /// - schemes are restricted to HTTP / HTTPS + static func extractAllURLs(in text: String) -> [URL] { + guard !text.isEmpty else { return [] } + + // Check for meshcore-open g:{giphy_id} format first; when present it + // is the entire message (trimmed wholeMatch), so no detector pass. + if let gifURL = extractGiphyGIFURL(from: text) { + return [gifURL] } - /// Extracts the first HTTP/HTTPS URL from text, excluding URLs within mentions. - /// - Parameter text: Message text to scan - /// - Returns: First HTTP(S) URL found outside mentions, or nil - static func extractFirstURL(from text: String) -> URL? { - extractAllURLs(in: text).first - } - - /// Extracts every HTTP/HTTPS URL from text, in document order. Used by the - /// receive-time prefetcher and the per-message URL-detection writer so - /// both paths see the same set of URLs: - /// - meshcore-open `g:{id}` short-codes are expanded to direct Giphy URLs - /// - URLs inside `@[mention]` ranges are skipped - /// - schemes are restricted to HTTP / HTTPS - static func extractAllURLs(in text: String) -> [URL] { - guard !text.isEmpty else { return [] } - - // Check for meshcore-open g:{giphy_id} format first; when present it - // is the entire message (trimmed wholeMatch), so no detector pass. - if let gifURL = extractGiphyGIFURL(from: text) { - return [gifURL] - } - - guard let detector = urlDetector else { return [] } + guard let detector = urlDetector else { return [] } - let mentionRanges = extractMentionRanges(from: text) - let range = NSRange(text.startIndex..., in: text) - let matches = detector.matches(in: text, options: [], range: range) + let mentionRanges = extractMentionRanges(from: text) + let range = NSRange(text.startIndex..., in: text) + let matches = detector.matches(in: text, options: [], range: range) - var urls: [URL] = [] - urls.reserveCapacity(matches.count) - for match in matches { - guard let url = match.url, - let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { - continue - } + var urls: [URL] = [] + urls.reserveCapacity(matches.count) + for match in matches { + guard let url = match.url, + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + continue + } - let urlRange = match.range - let overlapsWithMention = mentionRanges.contains { mentionRange in - NSIntersectionRange(urlRange, mentionRange).length > 0 - } - if overlapsWithMention { continue } + let urlRange = match.range + let overlapsWithMention = mentionRanges.contains { mentionRange in + NSIntersectionRange(urlRange, mentionRange).length > 0 + } + if overlapsWithMention { continue } - urls.append(url) - } + urls.append(url) + } - return urls + return urls + } + + /// Cached mention regex to avoid re-creating on every call + private static let mentionRegex: NSRegularExpression? = try? NSRegularExpression(pattern: MentionUtilities.mentionPattern) + + /// Extracts ranges of all mentions in the text (format: @[name]) + private static func extractMentionRanges(from text: String) -> [NSRange] { + guard let regex = mentionRegex else { return [] } + let range = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: range).map(\.range) + } + + /// Timeout for the LinkPresentation (WebKit-backed) metadata fetch. + private static let linkPresentationTimeout: TimeInterval = 10 + + /// Fetches metadata for a URL using LinkPresentation framework, falling + /// back to a plain-GET `og:image` scrape when LinkPresentation finds no + /// hero image (its WebKit-backed extractor chokes on some ad/JS-heavy + /// pages that still ship a static `og:image` in server HTML). + /// - Parameter url: The URL to fetch metadata for + /// - Returns: Metadata if either leg found a title or image, nil otherwise + func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? { + guard await URLSafetyChecker.isSafe(url) else { + logger.warning("Blocked metadata fetch to unsafe URL: \(url.host() ?? "unknown")") + return nil } - /// Cached mention regex to avoid re-creating on every call - private static let mentionRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: MentionUtilities.mentionPattern) - }() + let provider = LPMetadataProvider() + provider.timeout = Self.linkPresentationTimeout - /// Extracts ranges of all mentions in the text (format: @[name]) - private static func extractMentionRanges(from text: String) -> [NSRange] { - guard let regex = mentionRegex else { return [] } - let range = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: range).map(\.range) + let lpMetadata: LPLinkMetadata? + do { + lpMetadata = try await provider.startFetchingMetadata(for: url) + } catch { + logger.warning("Failed to fetch metadata for \(url): \(error.localizedDescription)") + lpMetadata = nil } - /// Fetches metadata for a URL using LinkPresentation framework - /// - Parameter url: The URL to fetch metadata for - /// - Returns: Metadata if successful, nil on failure - func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? { - guard await URLSafetyChecker.isSafe(url) else { - logger.warning("Blocked metadata fetch to unsafe URL: \(url.host() ?? "unknown")") - return nil - } + var title = lpMetadata?.title + var iconData: Data? + if let iconProvider = lpMetadata?.iconProvider { + iconData = await loadData(from: iconProvider) + } + var imageData: Data? + if let imageProvider = lpMetadata?.imageProvider { + imageData = await loadData(from: imageProvider) + } - let provider = LPMetadataProvider() - provider.timeout = 10 - - do { - let metadata = try await provider.startFetchingMetadata(for: url) - - // Extract image data - var imageData: Data? - if let imageProvider = metadata.imageProvider { - imageData = await loadData(from: imageProvider) - } - - // Extract icon data - var iconData: Data? - if let iconProvider = metadata.iconProvider { - iconData = await loadData(from: iconProvider) - } - - return LinkPreviewMetadata( - url: url, - title: metadata.title, - imageData: imageData, - iconData: iconData - ) - } catch { - logger.warning("Failed to fetch metadata for \(url): \(error.localizedDescription)") - return nil - } + if imageData == nil, let scraped = await scrapeHTMLMetadata(for: url) { + if title == nil { + title = scraped.title + } + if let imageURL = scraped.imageURL, await URLSafetyChecker.isSafe(imageURL) { + imageData = await loadImageData(from: imageURL) + } } - /// Maximum image size in bytes (500KB) - private static let maxImageSize = 500 * 1024 - - /// Loads image data from an NSItemProvider, compressing if necessary - private func loadData(from provider: NSItemProvider) async -> Data? { - let rawData = await withCheckedContinuation { continuation in - _ = provider.loadDataRepresentation(for: .image) { data, error in - if let error { - self.logger.debug("Failed to load image data: \(error.localizedDescription)") - } - continuation.resume(returning: data) - } - } + guard lpMetadata != nil || title != nil || imageData != nil else { return nil } + return LinkPreviewMetadata(url: url, title: title, imageData: imageData, iconData: iconData) + } - guard let data = rawData else { return nil } + /// Maximum image size in bytes (500KB) + private static let maxImageSize = 500 * 1024 - // If within size limit, return as-is - if data.count <= Self.maxImageSize { - return data + /// Loads image data from an NSItemProvider, compressing if necessary + private func loadData(from provider: NSItemProvider) async -> Data? { + let rawData = await withCheckedContinuation { continuation in + _ = provider.loadDataRepresentation(for: .image) { data, error in + if let error { + self.logger.debug("Failed to load image data: \(error.localizedDescription)") } - - // Compress the image - return compressImage(data: data, maxSize: Self.maxImageSize) + continuation.resume(returning: data) + } + } + return fitToMaxSize(rawData) + } + + /// Caps image data to `maxImageSize`, compressing if it's over. Shared by + /// both the LinkPresentation icon/image legs and the scraped-image + /// fallback so an oversized hero image from either path gets the same + /// downgrade treatment. + func fitToMaxSize(_ data: Data?) -> Data? { + guard let data else { return nil } + + // If within size limit, return as-is + if data.count <= Self.maxImageSize { + return data } - /// Compresses image data to fit within a maximum size - private func compressImage(data: Data, maxSize: Int) -> Data? { - guard let image = UIImage(data: data) else { return data } + // Compress the image + return compressImage(data: data, maxSize: Self.maxImageSize) + } - // Start with high quality and reduce until within size - var quality: CGFloat = 0.8 - var compressed = image.jpegData(compressionQuality: quality) + /// Compresses image data to fit within a maximum size + private func compressImage(data: Data, maxSize: Int) -> Data? { + guard let image = UIImage(data: data) else { return data } - while let compressedData = compressed, compressedData.count > maxSize, quality > 0.1 { - quality -= 0.1 - compressed = image.jpegData(compressionQuality: quality) - } + // Start with high quality and reduce until within size + var quality: CGFloat = 0.8 + var compressed = image.jpegData(compressionQuality: quality) - // If still too large, scale down the image - if let compressedData = compressed, compressedData.count > maxSize { - let scale = sqrt(Double(maxSize) / Double(compressedData.count)) - let newSize = CGSize( - width: image.size.width * scale, - height: image.size.height * scale - ) - - let renderer = UIGraphicsImageRenderer(size: newSize) - let resized = renderer.image { _ in - image.draw(in: CGRect(origin: .zero, size: newSize)) - } - compressed = resized.jpegData(compressionQuality: 0.7) - } + while let compressedData = compressed, compressedData.count > maxSize, quality > 0.1 { + quality -= 0.1 + compressed = image.jpegData(compressionQuality: quality) + } - logger.debug("Compressed image from \(data.count) to \(compressed?.count ?? 0) bytes") - return compressed + // If still too large, scale down the image + if let compressedData = compressed, compressedData.count > maxSize { + let scale = sqrt(Double(maxSize) / Double(compressedData.count)) + let newSize = CGSize( + width: image.size.width * scale, + height: image.size.height * scale + ) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let resized = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + compressed = resized.jpegData(compressionQuality: 0.7) } + + logger.debug("Compressed image from \(data.count) to \(compressed?.count ?? 0) bytes") + return compressed + } } diff --git a/MC1/Services/LocationService.swift b/MC1/Services/LocationService.swift index a359ba21..74f588cf 100644 --- a/MC1/Services/LocationService.swift +++ b/MC1/Services/LocationService.swift @@ -1,34 +1,34 @@ import CoreLocation import OSLog -enum LocationServiceError: Error, LocalizedError, Sendable { - case notAuthorized(CLAuthorizationStatus) - case requestInProgress - case permissionTimeout - case locationTimeout - case requestFailed(String) - - var errorDescription: String? { - switch self { - case .notAuthorized(let status): - switch status { - case .notDetermined: - "Location permission is required to update your node's location." - case .denied, .restricted: - "Location permission is denied. Enable it in Settings to update your node's location." - default: - "Location permission is not available." - } - case .requestInProgress: - "A location request is already in progress." - case .permissionTimeout: - "Timed out while waiting for location permission." - case .locationTimeout: - "Timed out while requesting location." - case .requestFailed(let message): - "Location request failed: \(message)" - } +enum LocationServiceError: Error, LocalizedError { + case notAuthorized(CLAuthorizationStatus) + case requestInProgress + case permissionTimeout + case locationTimeout + case requestFailed(String) + + var errorDescription: String? { + switch self { + case let .notAuthorized(status): + switch status { + case .notDetermined: + "Location permission is required to update your node's location." + case .denied, .restricted: + "Location permission is denied. Enable it in Settings to update your node's location." + default: + "Location permission is not available." + } + case .requestInProgress: + "A location request is already in progress." + case .permissionTimeout: + "Timed out while waiting for location permission." + case .locationTimeout: + "Timed out while requesting location." + case let .requestFailed(message): + "Location request failed: \(message)" } + } } /// App-wide location service for managing location permissions and access. @@ -36,208 +36,207 @@ enum LocationServiceError: Error, LocalizedError, Sendable { @Observable @MainActor final class LocationService: NSObject, CLLocationManagerDelegate { + // MARK: - Properties + + private let logger = Logger(subsystem: "com.mc1", category: "LocationService") + private let locationManager: CLLocationManager + + private var requestContinuation: CheckedContinuation? + private var locationTimeoutTask: Task? + + private var authorizationContinuation: CheckedContinuation? + private var permissionTimeoutTask: Task? + + /// Current authorization status + private(set) var authorizationStatus: CLAuthorizationStatus + + /// Current device location (nil if unavailable or not yet determined) + private(set) var currentLocation: CLLocation? - // MARK: - Properties + /// Whether a location request is in progress + private(set) var isRequestingLocation = false - private let logger = Logger(subsystem: "com.mc1", category: "LocationService") - private let locationManager: CLLocationManager + /// Whether location services are authorized for use + var isAuthorized: Bool { + authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways + } - private var requestContinuation: CheckedContinuation? - private var locationTimeoutTask: Task? + /// Whether permission has been determined (not .notDetermined) + var hasRequestedPermission: Bool { + authorizationStatus != .notDetermined + } - private var authorizationContinuation: CheckedContinuation? - private var permissionTimeoutTask: Task? + /// Whether location is denied or restricted + var isLocationDenied: Bool { + authorizationStatus == .denied || authorizationStatus == .restricted + } - /// Current authorization status - private(set) var authorizationStatus: CLAuthorizationStatus + // MARK: - Initialization - /// Current device location (nil if unavailable or not yet determined) - private(set) var currentLocation: CLLocation? + override init() { + locationManager = CLLocationManager() + authorizationStatus = locationManager.authorizationStatus + super.init() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } - /// Whether a location request is in progress - private(set) var isRequestingLocation = false + // MARK: - Public Methods - /// Whether location services are authorized for use - var isAuthorized: Bool { - authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways + /// Request location permission if not already determined. + /// Call this when a location-dependent feature is accessed. + func requestPermissionIfNeeded() { + guard authorizationStatus == .notDetermined else { + let raw = authorizationStatus.rawValue + logger.debug("Location permission already determined: \(String(describing: raw))") + return } - /// Whether permission has been determined (not .notDetermined) - var hasRequestedPermission: Bool { - authorizationStatus != .notDetermined + logger.info("Requesting location permission") + locationManager.requestWhenInUseAuthorization() + } + + /// Request a one-shot location update. + /// Call this when you need the current location (e.g., for distance sorting). + func requestLocation() { + guard isAuthorized else { + logger.debug("Cannot request location: not authorized") + requestPermissionIfNeeded() + return } - /// Whether location is denied or restricted - var isLocationDenied: Bool { - authorizationStatus == .denied || authorizationStatus == .restricted + guard !isRequestingLocation else { + logger.debug("Location request already in progress") + return } - // MARK: - Initialization + logger.info("Requesting one-shot location update") + isRequestingLocation = true + locationManager.requestLocation() + } - override init() { - locationManager = CLLocationManager() - authorizationStatus = locationManager.authorizationStatus - super.init() - locationManager.delegate = self - locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters + /// Request current location asynchronously with timeout. + /// Handles permission prompting if needed. + func requestCurrentLocation(timeout: Duration = .seconds(10)) async throws -> CLLocation { + guard requestContinuation == nil, authorizationContinuation == nil else { + throw LocationServiceError.requestInProgress } - // MARK: - Public Methods + if !isAuthorized { + if authorizationStatus == .notDetermined { + requestPermissionIfNeeded() + _ = try await waitForAuthorizationDecision(timeout: .seconds(30)) + } - /// Request location permission if not already determined. - /// Call this when a location-dependent feature is accessed. - func requestPermissionIfNeeded() { - guard authorizationStatus == .notDetermined else { - let raw = self.authorizationStatus.rawValue - logger.debug("Location permission already determined: \(String(describing: raw))") - return - } - - logger.info("Requesting location permission") - locationManager.requestWhenInUseAuthorization() + guard isAuthorized else { + throw LocationServiceError.notAuthorized(authorizationStatus) + } } - /// Request a one-shot location update. - /// Call this when you need the current location (e.g., for distance sorting). - func requestLocation() { - guard isAuthorized else { - logger.debug("Cannot request location: not authorized") - requestPermissionIfNeeded() - return - } - - guard !isRequestingLocation else { - logger.debug("Location request already in progress") - return - } + isRequestingLocation = true - logger.info("Requesting one-shot location update") - isRequestingLocation = true - locationManager.requestLocation() - } + return try await withCheckedThrowingContinuation { continuation in + requestContinuation = continuation + locationManager.requestLocation() - /// Request current location asynchronously with timeout. - /// Handles permission prompting if needed. - func requestCurrentLocation(timeout: Duration = .seconds(10)) async throws -> CLLocation { - guard requestContinuation == nil, authorizationContinuation == nil else { - throw LocationServiceError.requestInProgress + locationTimeoutTask?.cancel() + locationTimeoutTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: timeout) + } catch { + return } + guard let self, let continuation = requestContinuation else { return } - if !isAuthorized { - if authorizationStatus == .notDetermined { - requestPermissionIfNeeded() - _ = try await waitForAuthorizationDecision(timeout: .seconds(30)) - } + requestContinuation = nil + isRequestingLocation = false + continuation.resume(throwing: LocationServiceError.locationTimeout) + } + } + } - guard isAuthorized else { - throw LocationServiceError.notAuthorized(authorizationStatus) - } - } + // MARK: - Private Methods - isRequestingLocation = true - - return try await withCheckedThrowingContinuation { continuation in - requestContinuation = continuation - locationManager.requestLocation() - - locationTimeoutTask?.cancel() - locationTimeoutTask = Task { @MainActor [weak self] in - do { - try await Task.sleep(for: timeout) - } catch { - return - } - guard let self, let continuation = self.requestContinuation else { return } - - self.requestContinuation = nil - self.isRequestingLocation = false - continuation.resume(throwing: LocationServiceError.locationTimeout) - } - } + private func waitForAuthorizationDecision(timeout: Duration) async throws -> CLAuthorizationStatus { + guard authorizationStatus == .notDetermined else { return authorizationStatus } + guard authorizationContinuation == nil else { + throw LocationServiceError.requestInProgress } - // MARK: - Private Methods + return try await withCheckedThrowingContinuation { continuation in + authorizationContinuation = continuation - private func waitForAuthorizationDecision(timeout: Duration) async throws -> CLAuthorizationStatus { - guard authorizationStatus == .notDetermined else { return authorizationStatus } - guard authorizationContinuation == nil else { - throw LocationServiceError.requestInProgress + permissionTimeoutTask?.cancel() + permissionTimeoutTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: timeout) + } catch { + return } + guard let self, let continuation = authorizationContinuation else { return } - return try await withCheckedThrowingContinuation { continuation in - authorizationContinuation = continuation - - permissionTimeoutTask?.cancel() - permissionTimeoutTask = Task { @MainActor [weak self] in - do { - try await Task.sleep(for: timeout) - } catch { - return - } - guard let self, let continuation = self.authorizationContinuation else { return } - - self.authorizationContinuation = nil - continuation.resume(throwing: LocationServiceError.permissionTimeout) - } - } + authorizationContinuation = nil + continuation.resume(throwing: LocationServiceError.permissionTimeout) + } } - - // MARK: - CLLocationManagerDelegate - - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - Task { @MainActor in - self.authorizationStatus = status - self.logger.info("Location authorization changed: \(String(describing: status.rawValue))") - - if status != .notDetermined, let authorizationContinuation = self.authorizationContinuation { - self.authorizationContinuation = nil - self.permissionTimeoutTask?.cancel() - self.permissionTimeoutTask = nil - authorizationContinuation.resume(returning: status) - } - - if status == .denied || status == .restricted { - if let continuation = self.requestContinuation { - self.requestContinuation = nil - self.locationTimeoutTask?.cancel() - self.locationTimeoutTask = nil - self.isRequestingLocation = false - continuation.resume(throwing: LocationServiceError.notAuthorized(status)) - } - } + } + + // MARK: - CLLocationManagerDelegate + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + self.authorizationStatus = status + self.logger.info("Location authorization changed: \(String(describing: status.rawValue))") + + if status != .notDetermined, let authorizationContinuation = self.authorizationContinuation { + self.authorizationContinuation = nil + self.permissionTimeoutTask?.cancel() + self.permissionTimeoutTask = nil + authorizationContinuation.resume(returning: status) + } + + if status == .denied || status == .restricted { + if let continuation = self.requestContinuation { + self.requestContinuation = nil + self.locationTimeoutTask?.cancel() + self.locationTimeoutTask = nil + self.isRequestingLocation = false + continuation.resume(throwing: LocationServiceError.notAuthorized(status)) } + } } - - nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.last else { return } - Task { @MainActor in - self.currentLocation = location - self.isRequestingLocation = false - self.logger.info("Location updated: \(location.coordinate.latitude), \(location.coordinate.longitude)") - - self.locationTimeoutTask?.cancel() - self.locationTimeoutTask = nil - - if let continuation = self.requestContinuation { - self.requestContinuation = nil - continuation.resume(returning: location) - } - } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + Task { @MainActor in + self.currentLocation = location + self.isRequestingLocation = false + self.logger.info("Location updated: \(location.coordinate.latitude), \(location.coordinate.longitude)") + + self.locationTimeoutTask?.cancel() + self.locationTimeoutTask = nil + + if let continuation = self.requestContinuation { + self.requestContinuation = nil + continuation.resume(returning: location) + } } + } - nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - Task { @MainActor in - self.isRequestingLocation = false - self.logger.error("Location request failed: \(error.localizedDescription)") + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + self.isRequestingLocation = false + self.logger.error("Location request failed: \(error.localizedDescription)") - self.locationTimeoutTask?.cancel() - self.locationTimeoutTask = nil + self.locationTimeoutTask?.cancel() + self.locationTimeoutTask = nil - if let continuation = self.requestContinuation { - self.requestContinuation = nil - continuation.resume(throwing: LocationServiceError.requestFailed(error.localizedDescription)) - } - } + if let continuation = self.requestContinuation { + self.requestContinuation = nil + continuation.resume(throwing: LocationServiceError.requestFailed(error.localizedDescription)) + } } + } } diff --git a/MC1/Services/LogExportService.swift b/MC1/Services/LogExportService.swift index 2e37ac41..80662e15 100644 --- a/MC1/Services/LogExportService.swift +++ b/MC1/Services/LogExportService.swift @@ -1,194 +1,193 @@ import Foundation +import MC1Services import MeshCore import OSLog -import MC1Services import UIKit /// Service for exporting debug logs and app state for troubleshooting enum LogExportService { - private static let logger = Logger(subsystem: "com.mc1", category: "LogExportService") - - /// Generates a debug export containing app logs and current state - @MainActor - static func generateExport(appState: AppState, persistenceStore: PersistenceStore) async -> String { - // Flush buffered logs first so the export includes the latest lifecycle events. - if let debugLogBuffer = DebugLogBuffer.shared { - await debugLogBuffer.flush() - } - - let isConnected = appState.connectedDevice != nil - let device: DeviceDTO? - if let connectedDevice = appState.connectedDevice { - device = connectedDevice - } else { - do { - device = try await persistenceStore.fetchActiveDevice() - } catch { - logger.error("Failed to fetch active device for export: \(error.localizedDescription)") - device = nil - } - } - - var sections: [String] = [] + private static let logger = Logger(subsystem: "com.mc1", category: "LogExportService") + + /// Generates a debug export containing app logs and current state + @MainActor + static func generateExport(appState: AppState, persistenceStore: PersistenceStore) async -> String { + // Flush buffered logs first so the export includes the latest lifecycle events. + if let debugLogBuffer = DebugLogBuffer.shared { + await debugLogBuffer.flush() + } - // Header - sections.append(generateHeader()) + let isConnected = appState.connectedDevice != nil + let device: DeviceDTO? + if let connectedDevice = appState.connectedDevice { + device = connectedDevice + } else { + do { + device = try await persistenceStore.fetchActiveDevice() + } catch { + logger.error("Failed to fetch active device for export: \(error.localizedDescription)") + device = nil + } + } - // Connection info - sections.append(await generateConnectionSection(appState: appState, device: device)) + var sections: [String] = [] - // Device info (connected or last-connected from persistence) - if let device { - sections.append(generateDeviceSection(device: device, isConnected: isConnected)) - } + // Header + sections.append(generateHeader()) - // Battery info - if let battery = appState.batteryMonitor.deviceBattery { - sections.append(generateBatterySection(battery: battery)) - } + // Connection info + await sections.append(generateConnectionSection(appState: appState, device: device)) - // Logs - sections.append(await generateLogsSection(persistenceStore: persistenceStore)) + // Device info (connected or last-connected from persistence) + if let device { + sections.append(generateDeviceSection(device: device, isConnected: isConnected)) + } - return sections.joined(separator: "\n\n") + // Battery info + if let battery = appState.batteryMonitor.deviceBattery { + sections.append(generateBatterySection(battery: battery)) } - /// Creates a temporary file with the export content and returns its URL - @MainActor - static func createExportFile(appState: AppState, persistenceStore: PersistenceStore) async -> URL? { - let content = await generateExport(appState: appState, persistenceStore: persistenceStore) + // Logs + await sections.append(generateLogsSection(persistenceStore: persistenceStore)) - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd-HHmmss" - let timestamp = formatter.string(from: Date()) - let filename = "MeshCore-One-Debug-\(timestamp).txt" - - let tempURL = FileManager.default.temporaryDirectory.appending(path: filename) - - do { - try content.write(to: tempURL, atomically: true, encoding: .utf8) - return tempURL - } catch { - logger.error("Failed to write export file: \(error.localizedDescription)") - return nil - } - } + return sections.joined(separator: "\n\n") + } - // MARK: - Section Generators - - @MainActor - private static func generateHeader() -> String { - let appVersion = Bundle.main.appVersion - let buildNumber = Bundle.main.appBuild - let deviceModel = UIDevice.current.model - let systemVersion = UIDevice.current.systemVersion - - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - let exportedAt = formatter.string(from: Date()) - - return """ - === MeshCore One Debug Export === - Exported: \(exportedAt) - App Version: \(appVersion) (\(buildNumber)) - Device: \(deviceModel), iOS \(systemVersion) - """ - } + /// Creates a temporary file with the export content and returns its URL + @MainActor + static func createExportFile(appState: AppState, persistenceStore: PersistenceStore) async -> URL? { + let content = await generateExport(appState: appState, persistenceStore: persistenceStore) - @MainActor - private static func generateConnectionSection(appState: AppState, device: DeviceDTO?) async -> String { - let state = appState.connectionState - let stateString: String - switch state { - case .disconnected: stateString = "disconnected" - case .connecting: stateString = "connecting" - case .connected: stateString = "connected" - case .syncing: stateString = "syncing" - case .ready: stateString = "ready" - } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + let timestamp = formatter.string(from: Date()) + let filename = "MeshCore-One-Debug-\(timestamp).txt" - var lines = [ - "=== Connection ===", - "State: \(stateString)", - "Intent: \(appState.connectionManager.connectionIntentSummary)" - ] + let tempURL = FileManager.default.temporaryDirectory.appending(path: filename) - let disconnectDiagnostic = - appState.connectionManager.lastDisconnectDiagnostic ?? - "Unavailable (no disconnect callback captured; app may have been suspended)" - lines.append("Last Disconnect Diagnostic: \(disconnectDiagnostic)") - lines.append(await appState.connectionManager.currentBLEDiagnosticsSummary()) + do { + try content.write(to: tempURL, atomically: true, encoding: .utf8) + return tempURL + } catch { + logger.error("Failed to write export file: \(error.localizedDescription)") + return nil + } + } + + // MARK: - Section Generators + + @MainActor + private static func generateHeader() -> String { + let appVersion = Bundle.main.appVersion + let buildNumber = Bundle.main.appBuild + let deviceModel = UIDevice.current.model + let systemVersion = UIDevice.current.systemVersion + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let exportedAt = formatter.string(from: Date()) + + return """ + === MeshCore One Debug Export === + Exported: \(exportedAt) + App Version: \(appVersion) (\(buildNumber)) + Device: \(deviceModel), iOS \(systemVersion) + """ + } + + @MainActor + private static func generateConnectionSection(appState: AppState, device: DeviceDTO?) async -> String { + let state = appState.connectionState + let stateString = switch state { + case .disconnected: "disconnected" + case .connecting: "connecting" + case .connected: "connected" + case .syncing: "syncing" + case .ready: "ready" + } - if let device { - lines.append("Device: \(device.nodeName) (\(device.id.uuidString.prefix(8))...)") + var lines = [ + "=== Connection ===", + "State: \(stateString)", + "Intent: \(appState.connectionManager.connectionIntentSummary)" + ] - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - lines.append("Last Connected: \(formatter.string(from: device.lastConnected))") - } + let disconnectDiagnostic = + appState.connectionManager.lastDisconnectDiagnostic ?? + "Unavailable (no disconnect callback captured; app may have been suspended)" + lines.append("Last Disconnect Diagnostic: \(disconnectDiagnostic)") + await lines.append(appState.connectionManager.currentBLEDiagnosticsSummary()) - return lines.joined(separator: "\n") - } + if let device { + lines.append("Device: \(device.nodeName) (\(device.id.uuidString.prefix(8))...)") - private static func generateDeviceSection(device: DeviceDTO, isConnected: Bool) -> String { - let frequencyMHz = Double(device.frequency) / 1000.0 - let bandwidthKHz = device.bandwidth - let header = isConnected ? "=== Device Info ===" : "=== Device Info (Last Connected) ===" - - return """ - \(header) - Name: \(device.nodeName) - Firmware: \(device.firmwareVersionString) (v\(device.firmwareVersion)) - Manufacturer: \(device.manufacturerName) - Build Date: \(device.buildDate) - Radio: \(frequencyMHz.formatted(.number.precision(.fractionLength(3)))) MHz, BW \(bandwidthKHz) kHz, SF\(device.spreadingFactor), CR\(device.codingRate) - TX Power: \(device.txPower) dBm (max \(device.maxTxPower)) - Max Nodes: \(device.maxContacts) - Max Channels: \(device.maxChannels) - Manual Add Nodes: \(device.manualAddContacts) - Multi-ACKs: \(device.multiAcks) - """ + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + lines.append("Last Connected: \(formatter.string(from: device.lastConnected))") } - private static func generateBatterySection(battery: BatteryInfo) -> String { - return """ - === Battery === - Level: \(battery.percentage)% - Voltage: \(battery.voltage.formatted(.number.precision(.fractionLength(2)))) V - Raw: \(battery.level) mV - """ - } + return lines.joined(separator: "\n") + } + + private static func generateDeviceSection(device: DeviceDTO, isConnected: Bool) -> String { + let frequencyMHz = Double(device.frequency) / 1000.0 + let bandwidthKHz = device.bandwidth + let header = isConnected ? "=== Device Info ===" : "=== Device Info (Last Connected) ===" + + return """ + \(header) + Name: \(device.nodeName) + Firmware: \(device.firmwareVersionString) (v\(device.firmwareVersion)) + Manufacturer: \(device.manufacturerName) + Build Date: \(device.buildDate) + Radio: \(frequencyMHz.formatted(.number.precision(.fractionLength(3)))) MHz, BW \(bandwidthKHz) kHz, SF\(device.spreadingFactor), CR\(device.codingRate) + TX Power: \(device.txPower) dBm (max \(device.maxTxPower)) + Max Nodes: \(device.maxContacts) + Max Channels: \(device.maxChannels) + Manual Add Nodes: \(device.manualAddContacts) + Multi-ACKs: \(device.multiAcks) + """ + } + + private static func generateBatterySection(battery: BatteryInfo) -> String { + """ + === Battery === + Level: \(battery.percentage)% + Voltage: \(battery.voltage.formatted(.number.precision(.fractionLength(2)))) V + Raw: \(battery.level) mV + """ + } + + private static func generateLogsSection(persistenceStore: PersistenceStore) async -> String { + var lines = ["=== Logs (Last 24 Hours) ==="] + + do { + let secondsPerDay: TimeInterval = 86400 + let twentyFourHoursAgo = Date().addingTimeInterval(-secondsPerDay) + let entries = try await persistenceStore.fetchDebugLogEntries( + since: twentyFourHoursAgo, + limit: 1000 + ) + + if entries.isEmpty { + lines.append("(No logs found)") + } else { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - private static func generateLogsSection(persistenceStore: PersistenceStore) async -> String { - var lines = ["=== Logs (Last 24 Hours) ==="] - - do { - let secondsPerDay: TimeInterval = 86_400 - let twentyFourHoursAgo = Date().addingTimeInterval(-secondsPerDay) - let entries = try await persistenceStore.fetchDebugLogEntries( - since: twentyFourHoursAgo, - limit: 1000 - ) - - if entries.isEmpty { - lines.append("(No logs found)") - } else { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - - for entry in entries { - let timestamp = formatter.string(from: entry.timestamp) - lines.append("\(timestamp) [\(entry.level.label)] \(entry.category): \(entry.message)") - } - - lines.append("") - lines.append("Total entries: \(entries.count)") - } - } catch { - lines.append("(Failed to fetch logs: \(error.localizedDescription))") - logger.error("Debug log fetch failed: \(error.localizedDescription)") + for entry in entries { + let timestamp = formatter.string(from: entry.timestamp) + lines.append("\(timestamp) [\(entry.level.label)] \(entry.category): \(entry.message)") } - return lines.joined(separator: "\n") + lines.append("") + lines.append("Total entries: \(entries.count)") + } + } catch { + lines.append("(Failed to fetch logs: \(error.localizedDescription))") + logger.error("Debug log fetch failed: \(error.localizedDescription)") } + + return lines.joined(separator: "\n") + } } diff --git a/MC1/Services/MapSnapshotStore.swift b/MC1/Services/MapSnapshotStore.swift index 104c4951..8dbe7b08 100644 --- a/MC1/Services/MapSnapshotStore.swift +++ b/MC1/Services/MapSnapshotStore.swift @@ -6,270 +6,270 @@ import UIKit /// owns only the `resolutionStream` subscription. @MainActor final class MapSnapshotStore { - static let shared = MapSnapshotStore() + static let shared = MapSnapshotStore() - private static let cacheCountLimit = 50 - private static let cacheCostLimitBytes = 50 * 1024 * 1024 - private static let resolutionStreamBufferDepth = 64 - /// Serial by default: 2-3 concurrent `MLNMapSnapshotter`s are unprecedented - /// GL load in this app. Raise only if Instruments shows serial is too slow. - private static let maxConcurrent = 1 - /// Hard cap on the failed-render set. The set is sticky (failures stay - /// known until cleared) and grows with chat history during flaky networks; - /// this cap keeps memory bounded in pathological cases by FIFO-evicting the - /// oldest entry, which causes that one request to retry on next on-appear. - internal static let failedSetSizeLimit = 200 + private static let cacheCountLimit = 50 + private static let cacheCostLimitBytes = 50 * 1024 * 1024 + private static let resolutionStreamBufferDepth = 64 + /// Serial by default: 2-3 concurrent `MLNMapSnapshotter`s are unprecedented + /// GL load in this app. Raise only if Instruments shows serial is too slow. + private static let maxConcurrent = 1 + /// Hard cap on the failed-render set. The set is sticky (failures stay + /// known until cleared) and grows with chat history during flaky networks; + /// this cap keeps memory bounded in pathological cases by FIFO-evicting the + /// oldest entry, which causes that one request to retry on next on-appear. + static let failedSetSizeLimit = 200 - private let renderer: MapSnapshotRendering - /// Hand-rolled FIFO image cache. Mirrors the `DecodedPreviewCache` and - /// `InlineImageCache.decodedMirror` patterns — and unlike `NSCache`, FIFO - /// eviction here knows which key is being dropped, so it can notify - /// `resolvedKeys` and yield a resolution event so on-screen rows reload to - /// the skeleton state and re-request the snapshot. Also gives the - /// `resolvedKeys` mirror a natural bound (it shadows live cache entries). - private var imageEntries: [MapSnapshotRequest: ImageEntry] = [:] - private var imageInsertionOrder: [MapSnapshotRequest] = [] - private var totalImageCostBytes: Int = 0 - private var inFlight: Set = [] - private var failed: Set = [] - /// Insertion-order shadow of `failed` for FIFO eviction once the set hits - /// `failedSetSizeLimit`. Kept in sync with `failed`. - private var failedOrder: [MapSnapshotRequest] = [] - /// Every request that has ever reached a resolved state (cached image or - /// known failure). Used to broadcast invalidations on `clear()` and - /// `clearFailures()` so on-screen rows reload to the skeleton state and - /// re-request the snapshot. Bounded because cache eviction and failed-set - /// FIFO eviction both prune the matching key here. - private var resolvedKeys: Set = [] - private let semaphore = AsyncSemaphore(value: maxConcurrent) - /// Memory-warning observer token, retained for removal in `deinit`. - private var memoryWarningObserver: NSObjectProtocol? - /// Bumped on every `clear()` so a `performRender` that started before the - /// clear can detect it (the render's `await` releases the main actor; the - /// memory-warning observer can run during that suspension and empty - /// everything). The post-await branch reads this and bails to avoid - /// re-populating the state the clear was trying to evict. - private var clearGeneration: UInt64 = 0 + private let renderer: MapSnapshotRendering + /// Hand-rolled FIFO image cache. Mirrors the `DecodedPreviewCache` and + /// `InlineImageCache.decodedMirror` patterns — and unlike `NSCache`, FIFO + /// eviction here knows which key is being dropped, so it can notify + /// `resolvedKeys` and yield a resolution event so on-screen rows reload to + /// the skeleton state and re-request the snapshot. Also gives the + /// `resolvedKeys` mirror a natural bound (it shadows live cache entries). + private var imageEntries: [MapSnapshotRequest: ImageEntry] = [:] + private var imageInsertionOrder: [MapSnapshotRequest] = [] + private var totalImageCostBytes: Int = 0 + private var inFlight: Set = [] + private var failed: Set = [] + /// Insertion-order shadow of `failed` for FIFO eviction once the set hits + /// `failedSetSizeLimit`. Kept in sync with `failed`. + private var failedOrder: [MapSnapshotRequest] = [] + /// Every request that has ever reached a resolved state (cached image or + /// known failure). Used to broadcast invalidations on `clear()` and + /// `clearFailures()` so on-screen rows reload to the skeleton state and + /// re-request the snapshot. Bounded because cache eviction and failed-set + /// FIFO eviction both prune the matching key here. + private var resolvedKeys: Set = [] + private let semaphore = AsyncSemaphore(value: maxConcurrent) + /// Memory-warning observer token, retained for removal in `deinit`. + private var memoryWarningObserver: NSObjectProtocol? + /// Bumped on every `clear()` so a `performRender` that started before the + /// clear can detect it (the render's `await` releases the main actor; the + /// memory-warning observer can run during that suspension and empty + /// everything). The post-await branch reads this and bails to avoid + /// re-populating the state the clear was trying to evict. + private var clearGeneration: UInt64 = 0 - /// One continuation per live subscriber. `AsyncStream` is single-consumer: a - /// single shared stream hands each yield to only one iterator, so with two - /// `ChatViewModel`s alive (iPad split view, or the overlap during a - /// conversation switch) some thumbnails would never get their resolution and - /// stay stuck on the skeleton. Each subscriber gets its own stream via - /// `resolutionStream()`; every resolution is fanned out to all of them. - private var resolutionContinuations: [UUID: AsyncStream.Continuation] = [:] + /// One continuation per live subscriber. `AsyncStream` is single-consumer: a + /// single shared stream hands each yield to only one iterator, so with two + /// `ChatViewModel`s alive (iPad split view, or the overlap during a + /// conversation switch) some thumbnails would never get their resolution and + /// stay stuck on the skeleton. Each subscriber gets its own stream via + /// `resolutionStream()`; every resolution is fanned out to all of them. + private var resolutionContinuations: [UUID: AsyncStream.Continuation] = [:] - init(renderer: MapSnapshotRendering = MapSnapshotRenderer()) { - self.renderer = renderer + init(renderer: MapSnapshotRendering = MapSnapshotRenderer()) { + self.renderer = renderer - memoryWarningObserver = NotificationCenter.default.addObserver( - forName: UIApplication.didReceiveMemoryWarningNotification, - object: nil, - queue: nil - ) { [weak self] _ in - Task { @MainActor in self?.clear() } - } + memoryWarningObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { [weak self] _ in + Task { @MainActor in self?.clear() } } + } - isolated deinit { - if let memoryWarningObserver { - NotificationCenter.default.removeObserver(memoryWarningObserver) - } + isolated deinit { + if let memoryWarningObserver { + NotificationCenter.default.removeObserver(memoryWarningObserver) } + } - /// A resolution stream scoped to a single subscriber. The subscriber's task - /// ending (cancel or deinit) terminates the stream and drops its continuation. - /// Every render resolution is delivered to all live subscribers. - func resolutionStream() -> AsyncStream { - let id = UUID() - // `bufferingNewest` is the right policy for a latest-state notification - // stream: when the buffer is full, `bufferingOldest` would drop new - // events instead of replacing stale ones. `clear()` and `clearFailures()` - // can synchronously yield hundreds of events at once (one per resolved - // request), so the buffer can saturate before the main-actor consumer - // runs; the newest events are the ones the UI needs. - let (stream, continuation) = AsyncStream.makeStream( - of: MapSnapshotRequest.self, - bufferingPolicy: .bufferingNewest(Self.resolutionStreamBufferDepth) - ) - continuation.onTermination = { [weak self] _ in - Task { @MainActor in self?.resolutionContinuations.removeValue(forKey: id) } - } - resolutionContinuations[id] = continuation - return stream + /// A resolution stream scoped to a single subscriber. The subscriber's task + /// ending (cancel or deinit) terminates the stream and drops its continuation. + /// Every render resolution is delivered to all live subscribers. + func resolutionStream() -> AsyncStream { + let id = UUID() + // `bufferingNewest` is the right policy for a latest-state notification + // stream: when the buffer is full, `bufferingOldest` would drop new + // events instead of replacing stale ones. `clear()` and `clearFailures()` + // can synchronously yield hundreds of events at once (one per resolved + // request), so the buffer can saturate before the main-actor consumer + // runs; the newest events are the ones the UI needs. + let (stream, continuation) = AsyncStream.makeStream( + of: MapSnapshotRequest.self, + bufferingPolicy: .bufferingNewest(Self.resolutionStreamBufferDepth) + ) + continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.resolutionContinuations.removeValue(forKey: id) } } + resolutionContinuations[id] = continuation + return stream + } - /// Synchronous cache lookup for the build path and the view resolver. - func image(for request: MapSnapshotRequest) -> UIImage? { - imageEntries[request]?.image - } - - /// True once the render attempt has resolved (cached image or known failure). - /// Build-time `isReady` reads this. - func isResolved(_ request: MapSnapshotRequest) -> Bool { - imageEntries[request] != nil || failed.contains(request) - } + /// Synchronous cache lookup for the build path and the view resolver. + func image(for request: MapSnapshotRequest) -> UIImage? { + imageEntries[request]?.image + } - /// Lazily enqueue a render. Dedupes against the cache, the failed set, and the - /// in-flight set, so repeated on-appear calls during scroll are cheap. - func request(_ request: MapSnapshotRequest) { - guard imageEntries[request] == nil else { return } - guard !failed.contains(request) else { return } - guard !inFlight.contains(request) else { return } - inFlight.insert(request) - Task { await performRender(request) } - } + /// True once the render attempt has resolved (cached image or known failure). + /// Build-time `isReady` reads this. + func isResolved(_ request: MapSnapshotRequest) -> Bool { + imageEntries[request] != nil || failed.contains(request) + } - private func performRender(_ request: MapSnapshotRequest) async { - // `await`s here release the main actor; the memory-warning observer - // can run `clear()` during the suspension. Capture the generation up - // front so we can detect that case post-await and skip the writes — - // otherwise we'd re-populate the exact state the clear just evicted. - let generationAtStart = clearGeneration - // Drain `inFlight` on every exit path. The renderer's snapshotter is - // wrapped in `withTaskCancellationHandler`, but if a future caller - // ever cancels this task before the cache write runs, an explicit - // `inFlight` remove keeps subsequent on-appear retries from being - // dedupe-swallowed forever. - defer { inFlight.remove(request) } - await semaphore.wait() - defer { Task { await semaphore.signal() } } - let image = await renderer.render(request) + /// Lazily enqueue a render. Dedupes against the cache, the failed set, and the + /// in-flight set, so repeated on-appear calls during scroll are cheap. + func request(_ request: MapSnapshotRequest) { + guard imageEntries[request] == nil else { return } + guard !failed.contains(request) else { return } + guard !inFlight.contains(request) else { return } + inFlight.insert(request) + Task { await performRender(request) } + } - guard clearGeneration == generationAtStart else { - // `clear()` already evicted everything resolved at the time and - // yielded an invalidation. Skip the cache write so the eviction - // sticks; the deferred `inFlight.remove` keeps the dedupe set - // consistent regardless. - return - } + private func performRender(_ request: MapSnapshotRequest) async { + // `await`s here release the main actor; the memory-warning observer + // can run `clear()` during the suspension. Capture the generation up + // front so we can detect that case post-await and skip the writes — + // otherwise we'd re-populate the exact state the clear just evicted. + let generationAtStart = clearGeneration + // Drain `inFlight` on every exit path. The renderer's snapshotter is + // wrapped in `withTaskCancellationHandler`, but if a future caller + // ever cancels this task before the cache write runs, an explicit + // `inFlight` remove keeps subsequent on-appear retries from being + // dedupe-swallowed forever. + defer { inFlight.remove(request) } + await semaphore.wait() + defer { Task { await semaphore.signal() } } + let image = await renderer.render(request) - if let image { - cacheImage(image, for: request) - } else { - insertFailed(request) - } - resolvedKeys.insert(request) - yieldResolution(request) + guard clearGeneration == generationAtStart else { + // `clear()` already evicted everything resolved at the time and + // yielded an invalidation. Skip the cache write so the eviction + // sticks; the deferred `inFlight.remove` keeps the dedupe set + // consistent regardless. + return } - /// Inserts an image into the FIFO mirror and evicts oldest entries while - /// either the count or cost cap is exceeded. Each eviction prunes the - /// matching `resolvedKeys` entry and yields a resolution so on-screen rows - /// for that key reload to the skeleton state and re-fire `request(_:)`. - private func cacheImage(_ image: UIImage, for request: MapSnapshotRequest) { - let cost = ImageByteCost.bytes(for: image) - if let existing = imageEntries[request] { - totalImageCostBytes -= existing.cost - if let idx = imageInsertionOrder.firstIndex(of: request) { - imageInsertionOrder.remove(at: idx) - } - } - imageEntries[request] = ImageEntry(image: image, cost: cost) - imageInsertionOrder.append(request) - totalImageCostBytes += cost + if let image { + cacheImage(image, for: request) + } else { + insertFailed(request) + } + resolvedKeys.insert(request) + yieldResolution(request) + } - // Keep at least the just-inserted entry even when it singly exceeds - // the cost budget — matches `InlineImageCache.storeDecoded` so a large - // thumbnail is still served once before being evicted. - while imageInsertionOrder.count > 1, - imageInsertionOrder.count > Self.cacheCountLimit - || totalImageCostBytes > Self.cacheCostLimitBytes { - let evicted = imageInsertionOrder.removeFirst() - if let removed = imageEntries.removeValue(forKey: evicted) { - totalImageCostBytes -= removed.cost - } - resolvedKeys.remove(evicted) - yieldResolution(evicted) - } + /// Inserts an image into the FIFO mirror and evicts oldest entries while + /// either the count or cost cap is exceeded. Each eviction prunes the + /// matching `resolvedKeys` entry and yields a resolution so on-screen rows + /// for that key reload to the skeleton state and re-fire `request(_:)`. + private func cacheImage(_ image: UIImage, for request: MapSnapshotRequest) { + let cost = ImageByteCost.bytes(for: image) + if let existing = imageEntries[request] { + totalImageCostBytes -= existing.cost + if let idx = imageInsertionOrder.firstIndex(of: request) { + imageInsertionOrder.remove(at: idx) + } } + imageEntries[request] = ImageEntry(image: image, cost: cost) + imageInsertionOrder.append(request) + totalImageCostBytes += cost - private func insertFailed(_ request: MapSnapshotRequest) { - guard failed.insert(request).inserted else { return } - failedOrder.append(request) - while failedOrder.count > Self.failedSetSizeLimit { - let evicted = failedOrder.removeFirst() - failed.remove(evicted) - // Mirror the FIFO cache eviction path: pruning a failed entry must - // also drop the matching `resolvedKeys` and yield a resolution, so - // any on-screen row showing the stale failure fallback flips back - // to the skeleton state and re-fires `request(_:)`. - resolvedKeys.remove(evicted) - yieldResolution(evicted) - } + // Keep at least the just-inserted entry even when it singly exceeds + // the cost budget — matches `InlineImageCache.storeDecoded` so a large + // thumbnail is still served once before being evicted. + while imageInsertionOrder.count > 1, + imageInsertionOrder.count > Self.cacheCountLimit + || totalImageCostBytes > Self.cacheCostLimitBytes { + let evicted = imageInsertionOrder.removeFirst() + if let removed = imageEntries.removeValue(forKey: evicted) { + totalImageCostBytes -= removed.cost + } + resolvedKeys.remove(evicted) + yieldResolution(evicted) } + } - /// Yields a resolution event to every live subscriber. Each `ChatViewModel` - /// translates the request into the affected message rows via its - /// `mapPreviewRequestIndex` and rebuilds them. - private func yieldResolution(_ request: MapSnapshotRequest) { - for continuation in resolutionContinuations.values { - continuation.yield(request) - } + private func insertFailed(_ request: MapSnapshotRequest) { + guard failed.insert(request).inserted else { return } + failedOrder.append(request) + while failedOrder.count > Self.failedSetSizeLimit { + let evicted = failedOrder.removeFirst() + failed.remove(evicted) + // Mirror the FIFO cache eviction path: pruning a failed entry must + // also drop the matching `resolvedKeys` and yield a resolution, so + // any on-screen row showing the stale failure fallback flips back + // to the skeleton state and re-fires `request(_:)`. + resolvedKeys.remove(evicted) + yieldResolution(evicted) } + } - /// Drops a single failed key so the next `request(_:)` re-attempts the - /// render. Wired to the retry control on the chat thumbnail fallback so a - /// user who hit a transient online failure can recover without waiting on - /// the offline-to-online edge that calls `clearFailures()`. - func retry(_ request: MapSnapshotRequest) { - guard failed.remove(request) != nil else { return } - if let idx = failedOrder.firstIndex(of: request) { - failedOrder.remove(at: idx) - } - resolvedKeys.remove(request) - // Yielding flips the row back to the skeleton state via the rebuild, - // which re-fires `request(_:)` through the existing `.onAppear`. - yieldResolution(request) + /// Yields a resolution event to every live subscriber. Each `ChatViewModel` + /// translates the request into the affected message rows via its + /// `mapPreviewRequestIndex` and rebuilds them. + private func yieldResolution(_ request: MapSnapshotRequest) { + for continuation in resolutionContinuations.values { + continuation.yield(request) } + } - /// Drops every known failure so the next `request(_:)` for those keys - /// re-attempts the render. Called when the network transitions from - /// unavailable to available (in `ChatViewModel.applyEnvInputs`) so that - /// renders failed during the outage retry on the next chat rebuild. The - /// yield reloads on-screen rows showing the failure fallback to the - /// skeleton state so they re-fire `request(_:)` via `onAppear`. - func clearFailures() { - let cleared = failed - failed.removeAll() - failedOrder.removeAll() - for request in cleared { - resolvedKeys.remove(request) - yieldResolution(request) - } + /// Drops a single failed key so the next `request(_:)` re-attempts the + /// render. Wired to the retry control on the chat thumbnail fallback so a + /// user who hit a transient online failure can recover without waiting on + /// the offline-to-online edge that calls `clearFailures()`. + func retry(_ request: MapSnapshotRequest) { + guard failed.remove(request) != nil else { return } + if let idx = failedOrder.firstIndex(of: request) { + failedOrder.remove(at: idx) } + resolvedKeys.remove(request) + // Yielding flips the row back to the skeleton state via the rebuild, + // which re-fires `request(_:)` through the existing `.onAppear`. + yieldResolution(request) + } - /// Drops cached and failed state on memory pressure and yields a - /// resolution event for each previously-resolved request so on-screen - /// rows reload to the skeleton state and re-request the snapshot. Without - /// the broadcast, rows whose cached image was evicted would strand on the - /// fallback because the build-time `isReady` doesn't flip back without a - /// rebuild trigger. Internal for tests; in production it is invoked from - /// the memory-warning observer in `init`. - internal func clear() { - clearGeneration &+= 1 - let toInvalidate = resolvedKeys - imageEntries.removeAll() - imageInsertionOrder.removeAll() - totalImageCostBytes = 0 - failed.removeAll() - failedOrder.removeAll() - resolvedKeys.removeAll() - // In-flight renders restart with `inFlight` empty so post-clear - // on-appear `request(_:)` calls re-enqueue without dedupe; the - // generation guard in `performRender` keeps the pre-clear render's - // result from leaking back into the cleared state. - inFlight.removeAll() - for request in toInvalidate { - yieldResolution(request) - } + /// Drops every known failure so the next `request(_:)` for those keys + /// re-attempts the render. Called when the network transitions from + /// unavailable to available (in `ChatViewModel.applyEnvInputs`) so that + /// renders failed during the outage retry on the next chat rebuild. The + /// yield reloads on-screen rows showing the failure fallback to the + /// skeleton state so they re-fire `request(_:)` via `onAppear`. + func clearFailures() { + let cleared = failed + failed.removeAll() + failedOrder.removeAll() + for request in cleared { + resolvedKeys.remove(request) + yieldResolution(request) } + } - /// Pairs the cached image with its precomputed byte cost so the eviction - /// path can decrement the running total without re-running `ImageByteCost` - /// on the way out. - private struct ImageEntry { - let image: UIImage - let cost: Int + /// Drops cached and failed state on memory pressure and yields a + /// resolution event for each previously-resolved request so on-screen + /// rows reload to the skeleton state and re-request the snapshot. Without + /// the broadcast, rows whose cached image was evicted would strand on the + /// fallback because the build-time `isReady` doesn't flip back without a + /// rebuild trigger. Internal for tests; in production it is invoked from + /// the memory-warning observer in `init`. + func clear() { + clearGeneration &+= 1 + let toInvalidate = resolvedKeys + imageEntries.removeAll() + imageInsertionOrder.removeAll() + totalImageCostBytes = 0 + failed.removeAll() + failedOrder.removeAll() + resolvedKeys.removeAll() + // In-flight renders restart with `inFlight` empty so post-clear + // on-appear `request(_:)` calls re-enqueue without dedupe; the + // generation guard in `performRender` keeps the pre-clear render's + // result from leaking back into the cleared state. + inFlight.removeAll() + for request in toInvalidate { + yieldResolution(request) } + } + + /// Pairs the cached image with its precomputed byte cost so the eviction + /// path can decrement the running total without re-running `ImageByteCost` + /// on the way out. + private struct ImageEntry { + let image: UIImage + let cost: Int + } } diff --git a/MC1/Services/NotificationStringProviderImpl.swift b/MC1/Services/NotificationStringProviderImpl.swift index d0c496f5..6cbd8f44 100644 --- a/MC1/Services/NotificationStringProviderImpl.swift +++ b/MC1/Services/NotificationStringProviderImpl.swift @@ -3,41 +3,58 @@ import MC1Services /// App-layer implementation of NotificationStringProvider using L10n. struct NotificationStringProviderImpl: NotificationStringProvider { - func discoveryNotificationTitle(for type: ContactType) -> String { - switch type { - case .chat: - L10n.Localizable.Notifications.Discovery.contact - case .repeater: - L10n.Localizable.Notifications.Discovery.repeater - case .room: - L10n.Localizable.Notifications.Discovery.room - } + func discoveryNotificationTitle(for type: ContactType) -> String { + switch type { + case .chat: + L10n.Localizable.Notifications.Discovery.contact + case .repeater: + L10n.Localizable.Notifications.Discovery.repeater + case .room: + L10n.Localizable.Notifications.Discovery.room } + } - var replyActionTitle: String { L10n.Localizable.Notifications.Action.reply } - var sendButtonTitle: String { L10n.Localizable.Notifications.Action.send } - var messagePlaceholder: String { L10n.Localizable.Notifications.Action.messagePlaceholder } - var markAsReadActionTitle: String { L10n.Localizable.Notifications.Action.markAsRead } + var replyActionTitle: String { + L10n.Localizable.Notifications.Action.reply + } - var lowBatteryTitle: String { L10n.Localizable.Notifications.LowBattery.title } + var sendButtonTitle: String { + L10n.Localizable.Notifications.Action.send + } - func lowBatteryBody(deviceName: String, percentage: Int) -> String { - L10n.Localizable.Notifications.LowBattery.body(deviceName, percentage) - } + var messagePlaceholder: String { + L10n.Localizable.Notifications.Action.messagePlaceholder + } - var quickReplyFailedTitle: String { L10n.Localizable.Notifications.QuickReplyFailed.title } + var markAsReadActionTitle: String { + L10n.Localizable.Notifications.Action.markAsRead + } - func quickReplyFailedBody(conversationName: String) -> String { - L10n.Localizable.Notifications.QuickReplyFailed.body(conversationName) - } + var lowBatteryTitle: String { + L10n.Localizable.Notifications.LowBattery.title + } - var unknownContactName: String { L10n.Localizable.Notifications.Discovery.unknownContact } + func lowBatteryBody(deviceName: String, percentage: Int) -> String { + L10n.Localizable.Notifications.LowBattery.body(deviceName, percentage) + } - func defaultChannelName(index: Int) -> String { - L10n.Chats.Chats.Channel.defaultName(index) - } + var quickReplyFailedTitle: String { + L10n.Localizable.Notifications.QuickReplyFailed.title + } - func reactionNotificationBody(emoji: String, messagePreview: String) -> String { - L10n.Localizable.Notifications.Reaction.body(emoji, messagePreview) - } + func quickReplyFailedBody(conversationName: String) -> String { + L10n.Localizable.Notifications.QuickReplyFailed.body(conversationName) + } + + var unknownContactName: String { + L10n.Localizable.Notifications.Discovery.unknownContact + } + + func defaultChannelName(index: Int) -> String { + L10n.Chats.Chats.Channel.defaultName(index) + } + + func reactionNotificationBody(emoji: String, messagePreview: String) -> String { + L10n.Localizable.Notifications.Reaction.body(emoji, messagePreview) + } } diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index 7b4e0a2c..acf3df6e 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -4,64 +4,64 @@ import Network import os enum OfflineMapLayer: String, Codable { - case base - case topo + case base + case topo - var label: String { - switch self { - case .base: L10n.Settings.OfflineMaps.Layer.base - case .topo: L10n.Settings.OfflineMaps.Layer.topo - } + var label: String { + switch self { + case .base: L10n.Settings.OfflineMaps.Layer.base + case .topo: L10n.Settings.OfflineMaps.Layer.topo } + } - var maxDownloadZoom: Double { - switch self { - case .base: 14 - case .topo: 17 - } + var maxDownloadZoom: Double { + switch self { + case .base: 14 + case .topo: 17 } - - var styleURL: URL? { - switch self { - case .base: - URL(string: MapTileURLs.openFreeMapLiberty) - case .topo: - Bundle.main.url(forResource: "topo-offline", withExtension: "json") - } + } + + var styleURL: URL? { + switch self { + case .base: + URL(string: MapTileURLs.openFreeMapLiberty) + case .topo: + Bundle.main.url(forResource: "topo-offline", withExtension: "json") } + } } struct OfflinePackMetadata: Codable { - let name: String - let createdAt: Date - var layer: OfflineMapLayer - - init(name: String, createdAt: Date, layer: OfflineMapLayer = .base) { - self.name = name - self.createdAt = createdAt - self.layer = layer - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - createdAt = try container.decode(Date.self, forKey: .createdAt) - layer = try container.decodeIfPresent(OfflineMapLayer.self, forKey: .layer) ?? .base - } + let name: String + let createdAt: Date + var layer: OfflineMapLayer + + init(name: String, createdAt: Date, layer: OfflineMapLayer = .base) { + self.name = name + self.createdAt = createdAt + self.layer = layer + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + createdAt = try container.decode(Date.self, forKey: .createdAt) + layer = try container.decodeIfPresent(OfflineMapLayer.self, forKey: .layer) ?? .base + } } enum OfflineMapError: LocalizedError { - case insufficientDiskSpace - case missingStyleResource(OfflineMapLayer) - - var errorDescription: String? { - switch self { - case .insufficientDiskSpace: - L10n.Settings.OfflineMaps.Error.insufficientDiskSpace - case .missingStyleResource(let layer): - "Missing style resource for layer: \(layer.rawValue)" - } + case insufficientDiskSpace + case missingStyleResource(OfflineMapLayer) + + var errorDescription: String? { + switch self { + case .insufficientDiskSpace: + L10n.Settings.OfflineMaps.Error.insufficientDiskSpace + case let .missingStyleResource(layer): + "Missing style resource for layer: \(layer.rawValue)" } + } } private let logger = Logger(subsystem: "com.mc1", category: "OfflineMapService") @@ -69,359 +69,364 @@ private let logger = Logger(subsystem: "com.mc1", category: "OfflineMapService") @Observable @MainActor final class OfflineMapService { - - private static let minimumDiskSpaceBytes: Int64 = 100_000_000 - - private(set) var packs: [OfflinePack] = [] - private(set) var databaseSize: Int64 = 0 - private(set) var isNetworkAvailable = true - private(set) var lastPackError: String? - - private let monitor = NWPathMonitor() - private var observationTasks: [Task] = [] - private var pendingLoadTask: Task? - private var highWaterMarks: [ObjectIdentifier: Double] = [:] - private var byteSnapshots: [ObjectIdentifier: (bytes: UInt64, time: ContinuousClock.Instant)] = [:] - private var downloadSpeeds: [ObjectIdentifier: Int64] = [:] - private var metadataCache: [ObjectIdentifier: OfflinePackMetadata?] = [:] - private var deletingPackIDs: Set = [] - private var userPausedPackIDs: Set = [] - - init() { - let monitor = self.monitor - let networkStream = AsyncStream { continuation in - continuation.onTermination = { _ in monitor.cancel() } - monitor.pathUpdateHandler = { continuation.yield($0) } - // NWPathMonitor requires a DispatchQueue; no Swift concurrency alternative exists. - monitor.start(queue: .global(qos: .utility)) + private static let minimumDiskSpaceBytes: Int64 = 100_000_000 + + private(set) var packs: [OfflinePack] = [] + private(set) var databaseSize: Int64 = 0 + private(set) var isNetworkAvailable = true + private(set) var lastPackError: String? + + private let monitor = NWPathMonitor() + private var observationTasks: [Task] = [] + private var pendingLoadTask: Task? + private var highWaterMarks: [ObjectIdentifier: Double] = [:] + private var byteSnapshots: [ObjectIdentifier: (bytes: UInt64, time: ContinuousClock.Instant)] = [:] + private var downloadSpeeds: [ObjectIdentifier: Int64] = [:] + private var metadataCache: [ObjectIdentifier: OfflinePackMetadata?] = [:] + private var deletingPackIDs: Set = [] + private var userPausedPackIDs: Set = [] + + init() { + let monitor = monitor + let networkStream = AsyncStream { continuation in + continuation.onTermination = { _ in monitor.cancel() } + monitor.pathUpdateHandler = { continuation.yield($0) } + // NWPathMonitor requires a DispatchQueue; no Swift concurrency alternative exists. + monitor.start(queue: .global(qos: .utility)) + } + observationTasks.append(Task { [weak self] in + for await path in networkStream { + self?.isNetworkAvailable = path.status == .satisfied + } + }) + + observationTasks.append(Task { [weak self] in + for await _ in NotificationCenter.default.notifications(named: .MLNOfflinePackProgressChanged) { + self?.scheduleLoadPacks() + } + }) + observationTasks.append(Task { [weak self] in + for await notification in NotificationCenter.default.notifications(named: .MLNOfflinePackError) { + if let error = notification.userInfo?[MLNOfflinePackUserInfoKey.error] as? NSError { + logger.warning("Offline pack error: \(error.localizedDescription)") + self?.lastPackError = error.localizedDescription } - observationTasks.append(Task { [weak self] in - for await path in networkStream { - self?.isNetworkAvailable = path.status == .satisfied - } - }) - - observationTasks.append(Task { [weak self] in - for await _ in NotificationCenter.default.notifications(named: .MLNOfflinePackProgressChanged) { - self?.scheduleLoadPacks() - } - }) - observationTasks.append(Task { [weak self] in - for await notification in NotificationCenter.default.notifications(named: .MLNOfflinePackError) { - if let error = notification.userInfo?[MLNOfflinePackUserInfoKey.error] as? NSError { - logger.warning("Offline pack error: \(error.localizedDescription)") - self?.lastPackError = error.localizedDescription - } - } - }) - observationTasks.append(Task { [weak self] in - for await _ in NotificationCenter.default.notifications( - named: .MLNOfflinePackMaximumMapboxTilesReached - ) { - logger.warning("Offline pack tile limit reached") - self?.lastPackError = L10n.Settings.OfflineMaps.Error.tileLimitReached - } - }) - - excludeDatabaseFromBackup() - loadPacks() - updateDatabaseSize() - - // MLNOfflineStorage.shared.packs may be nil until async DB load completes. - // Retry once after a delay to catch late initialization. - if MLNOfflineStorage.shared.packs == nil { - observationTasks.append(Task { [weak self] in - do { - try await Task.sleep(for: .seconds(2)) - } catch { - return - } - self?.loadPacks() - }) + } + }) + observationTasks.append(Task { [weak self] in + for await _ in NotificationCenter.default.notifications( + named: .MLNOfflinePackMaximumMapboxTilesReached + ) { + logger.warning("Offline pack tile limit reached") + self?.lastPackError = L10n.Settings.OfflineMaps.Error.tileLimitReached + } + }) + + excludeDatabaseFromBackup() + loadPacks() + updateDatabaseSize() + + // MLNOfflineStorage.shared.packs may be nil until async DB load completes. + // Retry once after a delay to catch late initialization. + if MLNOfflineStorage.shared.packs == nil { + observationTasks.append(Task { [weak self] in + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return } + self?.loadPacks() + }) } + } - isolated deinit { - monitor.cancel() - pendingLoadTask?.cancel() - for task in observationTasks { - task.cancel() - } + isolated deinit { + monitor.cancel() + pendingLoadTask?.cancel() + for task in observationTasks { + task.cancel() } + } - func hasCompletedPack(for layer: OfflineMapLayer) -> Bool { - packs.contains { $0.layer == layer && $0.isComplete } - } + func hasCompletedPack(for layer: OfflineMapLayer) -> Bool { + packs.contains { $0.layer == layer && $0.isComplete } + } - func hasCompletedPack(for layer: OfflineMapLayer, overlapping viewport: MLNCoordinateBounds) -> Bool { - packs.contains { pack in - pack.layer == layer && pack.isComplete && pack.bounds.map { $0.overlaps(viewport) } ?? false - } + func hasCompletedPack(for layer: OfflineMapLayer, overlapping viewport: MLNCoordinateBounds) -> Bool { + packs.contains { pack in + pack.layer == layer && pack.isComplete && pack.bounds.map { $0.overlaps(viewport) } ?? false } - - func loadPacks() { - let mlnPacks = MLNOfflineStorage.shared.packs ?? [] - let currentIDs = Set(mlnPacks.map { ObjectIdentifier($0) }) - let now = ContinuousClock.now - - // Remove tracking data for deleted packs - highWaterMarks = highWaterMarks.filter { currentIDs.contains($0.key) } - byteSnapshots = byteSnapshots.filter { currentIDs.contains($0.key) } - downloadSpeeds = downloadSpeeds.filter { currentIDs.contains($0.key) } - metadataCache = metadataCache.filter { currentIDs.contains($0.key) } - - packs = mlnPacks.map { mlnPack in - let packID = ObjectIdentifier(mlnPack) - let previousFraction = highWaterMarks[packID] ?? 0 - let currentBytes = mlnPack.progress.countOfBytesCompleted - - if let previous = byteSnapshots[packID] { - let elapsed = now - previous.time - let seconds = elapsed / .seconds(1) - if seconds > 0.5, currentBytes > previous.bytes { - downloadSpeeds[packID] = Int64(Double(currentBytes - previous.bytes) / seconds) - } else if currentBytes == previous.bytes { - downloadSpeeds[packID] = 0 - } - } - byteSnapshots[packID] = (bytes: currentBytes, time: now) - - let speed = downloadSpeeds[packID] - if metadataCache[packID] == nil { - metadataCache[packID] = try? JSONDecoder().decode(OfflinePackMetadata.self, from: mlnPack.context) - } - let pack = OfflinePack(pack: mlnPack, metadata: metadataCache[packID] ?? nil, previousFraction: previousFraction, downloadSpeed: speed) - highWaterMarks[packID] = pack.completedFraction - return pack - } - - for mlnPack in mlnPacks where mlnPack.state == .unknown { - mlnPack.requestProgress() + } + + func loadPacks() { + let mlnPacks = MLNOfflineStorage.shared.packs ?? [] + let currentIDs = Set(mlnPacks.map { ObjectIdentifier($0) }) + let now = ContinuousClock.now + + // Remove tracking data for deleted packs + highWaterMarks = highWaterMarks.filter { currentIDs.contains($0.key) } + byteSnapshots = byteSnapshots.filter { currentIDs.contains($0.key) } + downloadSpeeds = downloadSpeeds.filter { currentIDs.contains($0.key) } + metadataCache = metadataCache.filter { currentIDs.contains($0.key) } + + packs = mlnPacks.map { mlnPack in + let packID = ObjectIdentifier(mlnPack) + let previousFraction = highWaterMarks[packID] ?? 0 + let currentBytes = mlnPack.progress.countOfBytesCompleted + + if let previous = byteSnapshots[packID] { + let elapsed = now - previous.time + let seconds = elapsed / .seconds(1) + if seconds > 0.5, currentBytes > previous.bytes { + downloadSpeeds[packID] = Int64(Double(currentBytes - previous.bytes) / seconds) + } else if currentBytes == previous.bytes { + downloadSpeeds[packID] = 0 } + } + byteSnapshots[packID] = (bytes: currentBytes, time: now) + + let speed = downloadSpeeds[packID] + if metadataCache[packID] == nil { + metadataCache[packID] = try? JSONDecoder().decode(OfflinePackMetadata.self, from: mlnPack.context) + } + let pack = OfflinePack(pack: mlnPack, metadata: metadataCache[packID] ?? nil, previousFraction: previousFraction, downloadSpeed: speed) + highWaterMarks[packID] = pack.completedFraction + return pack } - private func updateDatabaseSize() { - let url = MLNOfflineStorage.shared.databaseURL - let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 - databaseSize = Int64(size) + for mlnPack in mlnPacks where mlnPack.state == .unknown { + mlnPack.requestProgress() } - - /// Coalesces rapid progress notifications into a single `loadPacks()` call. - private func scheduleLoadPacks() { - pendingLoadTask?.cancel() - pendingLoadTask = Task { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { return } - loadPacks() - } - } - - // Note: active offline downloads share MapLibre's internal FIFO request queue - // with the interactive map renderer. Large downloads may degrade live map tile - // loading. Consider suspending packs during active map interaction if needed. - func downloadRegion( - name: String, - bounds: MLNCoordinateBounds, - layers: Set, - minZoom: Double = 10 - ) async throws { - let values = try URL.documentsDirectory.resourceValues( - forKeys: [.volumeAvailableCapacityForImportantUsageKey] - ) - if let available = values.volumeAvailableCapacityForImportantUsage, - available < Self.minimumDiskSpaceBytes { - throw OfflineMapError.insufficientDiskSpace - } - - let encoder = JSONEncoder() - let now = Date.now - - var pendingPacks: [(region: MLNTilePyramidOfflineRegion, context: Data)] = [] - for layer in layers { - guard let styleURL = layer.styleURL else { - throw OfflineMapError.missingStyleResource(layer) - } - let region = MLNTilePyramidOfflineRegion( - styleURL: styleURL, - bounds: bounds, - fromZoomLevel: minZoom, - toZoomLevel: layer.maxDownloadZoom - ) - let metadata = OfflinePackMetadata(name: name, createdAt: now, layer: layer) - let context = try encoder.encode(metadata) - pendingPacks.append((region, context)) - } - - for (region, context) in pendingPacks { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - MLNOfflineStorage.shared.addPack(for: region, withContext: context) { pack, error in - if let error { - continuation.resume(throwing: error) - } else { - pack?.resume() - continuation.resume() - } - } - } - } - loadPacks() - updateDatabaseSize() + } + + private func updateDatabaseSize() { + let url = MLNOfflineStorage.shared.databaseURL + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + databaseSize = Int64(size) + } + + /// Coalesces rapid progress notifications into a single `loadPacks()` call. + private func scheduleLoadPacks() { + pendingLoadTask?.cancel() + pendingLoadTask = Task { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + loadPacks() } - - func deletePack(_ pack: OfflinePack) async { - guard deletingPackIDs.insert(pack.id).inserted else { return } - defer { deletingPackIDs.remove(pack.id) } - - await withCheckedContinuation { continuation in - MLNOfflineStorage.shared.removePack(pack.mlnPack) { error in - if let error { - logger.error("Failed to delete offline pack: \(error.localizedDescription)") - } - continuation.resume() - } - } - highWaterMarks.removeValue(forKey: pack.id) - byteSnapshots.removeValue(forKey: pack.id) - downloadSpeeds.removeValue(forKey: pack.id) - loadPacks() - updateDatabaseSize() + } + + /// Note: active offline downloads share MapLibre's internal FIFO request queue + /// with the interactive map renderer. Large downloads may degrade live map tile + /// loading. Consider suspending packs during active map interaction if needed. + func downloadRegion( + name: String, + bounds: MLNCoordinateBounds, + layers: Set, + minZoom: Double = 10 + ) async throws { + let values = try URL.documentsDirectory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + if let available = values.volumeAvailableCapacityForImportantUsage, + available < Self.minimumDiskSpaceBytes { + throw OfflineMapError.insufficientDiskSpace } - func pausePack(_ pack: OfflinePack) { - userPausedPackIDs.insert(pack.id) - pack.mlnPack.suspend() - loadPacks() + let encoder = JSONEncoder() + let now = Date.now + + var pendingPacks: [(region: MLNTilePyramidOfflineRegion, context: Data)] = [] + for layer in layers { + guard let styleURL = layer.styleURL else { + throw OfflineMapError.missingStyleResource(layer) + } + let region = MLNTilePyramidOfflineRegion( + styleURL: styleURL, + bounds: bounds, + fromZoomLevel: minZoom, + toZoomLevel: layer.maxDownloadZoom + ) + let metadata = OfflinePackMetadata(name: name, createdAt: now, layer: layer) + let context = try encoder.encode(metadata) + pendingPacks.append((region, context)) } - func resumePack(_ pack: OfflinePack) { - userPausedPackIDs.remove(pack.id) - pack.mlnPack.resume() - loadPacks() + for (region, context) in pendingPacks { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + MLNOfflineStorage.shared.addPack(for: region, withContext: context) { pack, error in + if let error { + continuation.resume(throwing: error) + } else { + pack?.resume() + continuation.resume() + } + } + } } - - func resumeAllPacks() { - for pack in MLNOfflineStorage.shared.packs ?? [] { - let packID = ObjectIdentifier(pack) - if pack.state == .inactive, !userPausedPackIDs.contains(packID) { - pack.resume() - } + loadPacks() + updateDatabaseSize() + } + + func deletePack(_ pack: OfflinePack) async { + guard deletingPackIDs.insert(pack.id).inserted else { return } + defer { deletingPackIDs.remove(pack.id) } + + await withCheckedContinuation { continuation in + MLNOfflineStorage.shared.removePack(pack.mlnPack) { error in + if let error { + logger.error("Failed to delete offline pack: \(error.localizedDescription)") } - loadPacks() + continuation.resume() + } } - - func clearLastPackError() { - lastPackError = nil + highWaterMarks.removeValue(forKey: pack.id) + byteSnapshots.removeValue(forKey: pack.id) + downloadSpeeds.removeValue(forKey: pack.id) + loadPacks() + updateDatabaseSize() + } + + func pausePack(_ pack: OfflinePack) { + userPausedPackIDs.insert(pack.id) + pack.mlnPack.suspend() + loadPacks() + } + + func resumePack(_ pack: OfflinePack) { + userPausedPackIDs.remove(pack.id) + pack.mlnPack.resume() + loadPacks() + } + + func resumeAllPacks() { + for pack in MLNOfflineStorage.shared.packs ?? [] { + let packID = ObjectIdentifier(pack) + if pack.state == .inactive, !userPausedPackIDs.contains(packID) { + pack.resume() + } + } + loadPacks() + } + + func clearLastPackError() { + lastPackError = nil + } + + /// Estimated download size using per-zoom average byte sizes. + nonisolated static func estimatedDownloadSize( + bounds: MLNCoordinateBounds, + minZoom: Int, + maxZoom: Int, + layer: OfflineMapLayer = .base + ) -> Int64 { + let bytesPerTile: [Int: Int64] = switch layer { + case .base: + // OpenFreeMap vector tiles (OpenMapTiles schema, max z14). + // Populated land regions average 30-150 KB per tile at these zooms. + [ + 10: 15000, 11: 25000, 12: 45000, + 13: 70000, 14: 100_000, + ] + case .topo: + // OpenTopoMap PNG raster tiles (256px, max z17). + [ + 10: 15000, 11: 18000, 12: 22000, + 13: 25000, 14: 30000, 15: 35000, + 16: 40000, 17: 45000, + ] } - /// Estimated download size using per-zoom average byte sizes. - nonisolated static func estimatedDownloadSize( - bounds: MLNCoordinateBounds, - minZoom: Int, - maxZoom: Int, - layer: OfflineMapLayer = .base - ) -> Int64 { - let bytesPerTile: [Int: Int64] - switch layer { - case .base: - // OpenFreeMap vector tiles (OpenMapTiles schema, max z14). - // Populated land regions average 30-150 KB per tile at these zooms. - bytesPerTile = [ - 10: 15_000, 11: 25_000, 12: 45_000, - 13: 70_000, 14: 100_000, - ] - case .topo: - // OpenTopoMap PNG raster tiles (256px, max z17). - bytesPerTile = [ - 10: 15_000, 11: 18_000, 12: 22_000, - 13: 25_000, 14: 30_000, 15: 35_000, - 16: 40_000, 17: 45_000, - ] - } - - // Non-tile resources: style JSON, TileJSON manifests, sprites, glyph PBFs - let overhead: Int64 = 500_000 + // Non-tile resources: style JSON, TileJSON manifests, sprites, glyph PBFs + let overhead: Int64 = 500_000 - var total: Int64 = 0 - for z in minZoom...maxZoom { - let n = Double(1 << z) - let xMin = Int(floor((bounds.sw.longitude + 180) / 360 * n)) - let xMax = Int(floor((bounds.ne.longitude + 180) / 360 * n)) + var total: Int64 = 0 + for z in minZoom...maxZoom { + let n = Double(1 << z) + let xMin = Int(floor((bounds.sw.longitude + 180) / 360 * n)) + let xMax = Int(floor((bounds.ne.longitude + 180) / 360 * n)) - let latRadNE = bounds.ne.latitude * .pi / 180 - let latRadSW = bounds.sw.latitude * .pi / 180 - let yMin = Int(floor((1 - log(tan(latRadNE) + 1 / cos(latRadNE)) / .pi) / 2 * n)) - let yMax = Int(floor((1 - log(tan(latRadSW) + 1 / cos(latRadSW)) / .pi) / 2 * n)) + let latRadNE = bounds.ne.latitude * .pi / 180 + let latRadSW = bounds.sw.latitude * .pi / 180 + let yMin = Int(floor((1 - log(tan(latRadNE) + 1 / cos(latRadNE)) / .pi) / 2 * n)) + let yMax = Int(floor((1 - log(tan(latRadSW) + 1 / cos(latRadSW)) / .pi) / 2 * n)) - let tileCount = (abs(xMax - xMin) + 1) * (abs(yMax - yMin) + 1) - total += Int64(tileCount) * (bytesPerTile[z] ?? 10_000) - } - return total + overhead + let tileCount = (abs(xMax - xMin) + 1) * (abs(yMax - yMin) + 1) + total += Int64(tileCount) * (bytesPerTile[z] ?? 10000) } - - private func excludeDatabaseFromBackup() { - var url = MLNOfflineStorage.shared.databaseURL - var values = URLResourceValues() - values.isExcludedFromBackup = true - do { - try url.setResourceValues(values) - } catch { - logger.error("Failed to exclude offline database from backup: \(error.localizedDescription)") - } + return total + overhead + } + + private func excludeDatabaseFromBackup() { + var url = MLNOfflineStorage.shared.databaseURL + var values = URLResourceValues() + values.isExcludedFromBackup = true + do { + try url.setResourceValues(values) + } catch { + logger.error("Failed to exclude offline database from backup: \(error.localizedDescription)") } + } } struct OfflinePack: Identifiable { - let id: ObjectIdentifier - fileprivate let mlnPack: MLNOfflinePack - let name: String - let createdAt: Date? - let layer: OfflineMapLayer - let completedFraction: Double - let downloadSpeed: Int64? - let bounds: MLNCoordinateBounds? - - private let progress: MLNOfflinePackProgress - private let state: MLNOfflinePackState - - var completedBytes: UInt64 { progress.countOfBytesCompleted } - var isComplete: Bool { state == .complete } - var isPaused: Bool { state == .inactive } - - init(pack: MLNOfflinePack, metadata: OfflinePackMetadata?, previousFraction: Double = 0, downloadSpeed: Int64? = nil) { - self.id = ObjectIdentifier(pack) - self.mlnPack = pack - self.progress = pack.progress - self.state = pack.state - self.bounds = (pack.region as? MLNTilePyramidOfflineRegion)?.bounds - - let rawFraction: Double - if state == .complete { - rawFraction = 1 - } else if progress.countOfResourcesExpected > 0 { - rawFraction = Double(progress.countOfResourcesCompleted) / Double(progress.countOfResourcesExpected) - } else { - rawFraction = 0 - } - self.completedFraction = max(rawFraction, previousFraction) - self.downloadSpeed = state == .active ? downloadSpeed : nil - - if let metadata { - self.name = metadata.name - self.createdAt = metadata.createdAt - self.layer = metadata.layer - } else { - self.name = L10n.Settings.OfflineMaps.unknownRegion - self.createdAt = nil - self.layer = .base - } + let id: ObjectIdentifier + fileprivate let mlnPack: MLNOfflinePack + let name: String + let createdAt: Date? + let layer: OfflineMapLayer + let completedFraction: Double + let downloadSpeed: Int64? + let bounds: MLNCoordinateBounds? + + private let progress: MLNOfflinePackProgress + private let state: MLNOfflinePackState + + var completedBytes: UInt64 { + progress.countOfBytesCompleted + } + + var isComplete: Bool { + state == .complete + } + + var isPaused: Bool { + state == .inactive + } + + init(pack: MLNOfflinePack, metadata: OfflinePackMetadata?, previousFraction: Double = 0, downloadSpeed: Int64? = nil) { + id = ObjectIdentifier(pack) + mlnPack = pack + progress = pack.progress + state = pack.state + bounds = (pack.region as? MLNTilePyramidOfflineRegion)?.bounds + + let rawFraction: Double = if state == .complete { + 1 + } else if progress.countOfResourcesExpected > 0 { + Double(progress.countOfResourcesCompleted) / Double(progress.countOfResourcesExpected) + } else { + 0 } + completedFraction = max(rawFraction, previousFraction) + self.downloadSpeed = state == .active ? downloadSpeed : nil + + if let metadata { + name = metadata.name + createdAt = metadata.createdAt + layer = metadata.layer + } else { + name = L10n.Settings.OfflineMaps.unknownRegion + createdAt = nil + layer = .base + } + } } extension MLNCoordinateBounds { - func overlaps(_ other: MLNCoordinateBounds) -> Bool { - sw.latitude <= other.ne.latitude - && ne.latitude >= other.sw.latitude - && sw.longitude <= other.ne.longitude - && ne.longitude >= other.sw.longitude - } + func overlaps(_ other: MLNCoordinateBounds) -> Bool { + sw.latitude <= other.ne.latitude + && ne.latitude >= other.sw.latitude + && sw.longitude <= other.ne.longitude + && ne.longitude >= other.sw.longitude + } } diff --git a/MC1/Services/RedirectSafetyDelegate.swift b/MC1/Services/RedirectSafetyDelegate.swift new file mode 100644 index 00000000..9463a591 --- /dev/null +++ b/MC1/Services/RedirectSafetyDelegate.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Re-validates every HTTP redirect hop of `LinkPreviewService`'s scrape +/// session against the SSRF allow-list. A default `URLSession` follows 3xx +/// redirects automatically, so without this a URL that passes the initial +/// `isSafe` check could redirect to a private host and be fetched anyway. +final class RedirectSafetyDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + guard let url = request.url else { + completionHandler(nil) + return + } + Task { + let isSafe = await URLSafetyChecker.isSafe(url) + completionHandler(isSafe ? request : nil) + } + } +} diff --git a/MC1/Services/RegionResolver.swift b/MC1/Services/RegionResolver.swift index 31d52cf1..abff570c 100644 --- a/MC1/Services/RegionResolver.swift +++ b/MC1/Services/RegionResolver.swift @@ -10,97 +10,98 @@ import OSLog /// `AppState.regionSelection`, not this object — so it is not `@Observable`. @MainActor final class RegionResolver { + static let locationTimeout: Duration = .seconds(5) + static let geocodeTimeout: Duration = .seconds(5) + static let cacheTTL: TimeInterval = 24 * 60 * 60 - static let locationTimeout: Duration = .seconds(5) - static let geocodeTimeout: Duration = .seconds(5) - static let cacheTTL: TimeInterval = 24 * 60 * 60 + private static let geocodingLocale = Locale(identifier: "en_US") + private static let countySuffix = " county" - private static let geocodingLocale = Locale(identifier: "en_US") - private static let countySuffix = " county" + private let logger = Logger(subsystem: "com.mc1", category: "RegionResolver") + private let location: LocationService + private let geocoder: any Geocoder + private var cache: [CacheKey: CachedResult] = [:] - private let logger = Logger(subsystem: "com.mc1", category: "RegionResolver") - private let location: LocationService - private let geocoder: any Geocoder - private var cache: [CacheKey: CachedResult] = [:] + init(location: LocationService, geocoder: any Geocoder = AppleGeocoder()) { + self.location = location + self.geocoder = geocoder + } - init(location: LocationService, geocoder: any Geocoder = AppleGeocoder()) { - self.location = location - self.geocoder = geocoder - } - - /// Returns a `RegionSelection` derived from the device's current location, or - /// nil for any failure (denied, timeout, no network, nil isoCountryCode). - /// Failure modes are silent — callers fall through to manual picker. - func resolve() async -> RegionSelection? { - guard location.isAuthorized else { return nil } - do { - let loc = try await location.requestCurrentLocation(timeout: Self.locationTimeout) - let key = CacheKey(loc) - if let cached = cache[key], cached.isFresh { return cached.value } + /// Returns a `RegionSelection` derived from the device's current location, or + /// nil for any failure (denied, timeout, no network, nil isoCountryCode). + /// Failure modes are silent — callers fall through to manual picker. + func resolve() async -> RegionSelection? { + guard location.isAuthorized else { return nil } + do { + let loc = try await location.requestCurrentLocation(timeout: Self.locationTimeout) + let key = CacheKey(loc) + if let cached = cache[key], cached.isFresh { return cached.value } - let result = try await reverseGeocode(loc) + let result = try await reverseGeocode(loc) - guard let countryCode = result?.countryCode else { return nil } + guard let countryCode = result?.countryCode else { return nil } - let normalize: (String) -> String = { - $0.trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .folding(options: .diacriticInsensitive, locale: Self.geocodingLocale) - } - let normalizedAdmin = result?.administrativeArea.map(normalize) - let normalizedCounty = result?.subAdministrativeArea - .map(normalize) - .map { $0.replacingOccurrences(of: Self.countySuffix, with: "") } + let normalize: (String) -> String = { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .folding(options: .diacriticInsensitive, locale: Self.geocodingLocale) + } + let normalizedAdmin = result?.administrativeArea.map(normalize) + let normalizedCounty = result?.subAdministrativeArea + .map(normalize) + .map { $0.replacingOccurrences(of: Self.countySuffix, with: "") } - let adminCode = RegionalAreas.matchSubdivision( - country: countryCode, normalized: normalizedAdmin - ) - let countyKey = RegionalAreas.matchCounty( - country: countryCode, state: adminCode, normalized: normalizedCounty - ) + let adminCode = RegionalAreas.matchSubdivision( + country: countryCode, normalized: normalizedAdmin + ) + let countyKey = RegionalAreas.matchCounty( + country: countryCode, state: adminCode, normalized: normalizedCounty + ) - let selection = RegionSelection( - countryCode: countryCode, - administrativeAreaCode: adminCode, - countyKey: countyKey, - source: .location - ) - cache[key] = CachedResult(value: selection, expiresAt: Date().addingTimeInterval(Self.cacheTTL)) - return selection - } catch { - logger.debug("Region resolution failed: \(error)") - return nil - } + let selection = RegionSelection( + countryCode: countryCode, + administrativeAreaCode: adminCode, + countyKey: countyKey, + source: .location + ) + cache[key] = CachedResult(value: selection, expiresAt: Date().addingTimeInterval(Self.cacheTTL)) + return selection + } catch { + logger.debug("Region resolution failed: \(error)") + return nil } + } - /// Bounds the geocoder call. The location request already has its own timeout via - /// `LocationService`; without a separate bound here, a slow geocode (offline, throttled - /// CLGeocoder) would leave the user staring at the resolving screen indefinitely. On - /// timeout the geocoder is cancelled and `TimeoutError` surfaces to `resolve()`'s catch, - /// which returns nil and falls through to the manual picker — matching every other - /// failure mode in this resolver. - private func reverseGeocode(_ loc: CLLocation) async throws -> GeocodeResult? { - try await withTimeout(Self.geocodeTimeout, operationName: "reverseGeocode") { [geocoder] in - try await withTaskCancellationHandler { - try await geocoder.reverseGeocode(loc, preferredLocale: Self.geocodingLocale) - } onCancel: { - geocoder.cancelGeocode() - } - } + /// Bounds the geocoder call. The location request already has its own timeout via + /// `LocationService`; without a separate bound here, a slow geocode (offline, throttled + /// CLGeocoder) would leave the user staring at the resolving screen indefinitely. On + /// timeout the geocoder is cancelled and `TimeoutError` surfaces to `resolve()`'s catch, + /// which returns nil and falls through to the manual picker — matching every other + /// failure mode in this resolver. + private func reverseGeocode(_ loc: CLLocation) async throws -> GeocodeResult? { + try await withTimeout(Self.geocodeTimeout, operationName: "reverseGeocode") { [geocoder] in + try await withTaskCancellationHandler { + try await geocoder.reverseGeocode(loc, preferredLocale: Self.geocodingLocale) + } onCancel: { + geocoder.cancelGeocode() + } } + } - private struct CacheKey: Hashable { - let lat: Int - let lng: Int - init(_ location: CLLocation) { - self.lat = Int(location.coordinate.latitude.rounded()) - self.lng = Int(location.coordinate.longitude.rounded()) - } + private struct CacheKey: Hashable { + let lat: Int + let lng: Int + init(_ location: CLLocation) { + lat = Int(location.coordinate.latitude.rounded()) + lng = Int(location.coordinate.longitude.rounded()) } + } - private struct CachedResult { - let value: RegionSelection - let expiresAt: Date - var isFresh: Bool { Date() < expiresAt } + private struct CachedResult { + let value: RegionSelection + let expiresAt: Date + var isFresh: Bool { + Date() < expiresAt } + } } diff --git a/MC1/State/AppState+DeviceActions.swift b/MC1/State/AppState+DeviceActions.swift index b1628287..ebfdc92b 100644 --- a/MC1/State/AppState+DeviceActions.swift +++ b/MC1/State/AppState+DeviceActions.swift @@ -5,169 +5,172 @@ import MC1Services // MARK: - Device Actions extension AppState { + /// Start device scan/pairing + func startDeviceScan() { + // Hide disconnected pill when starting new connection + connectionUI.hideDisconnectedPill() + // Clear any previous pairing failure state + connectionUI.failedPairingDeviceID = nil + connectionUI.isBusy = true + + Task { + defer { connectionUI.isBusy = false } + + do { + // pairNewDevice() triggers onConnectionReady callback on success + try await connectionManager.pairNewDevice() + await wireServicesIfConnected() - /// Start device scan/pairing - func startDeviceScan() { - // Hide disconnected pill when starting new connection - connectionUI.hideDisconnectedPill() - // Clear any previous pairing failure state - connectionUI.failedPairingDeviceID = nil - connectionUI.isBusy = true - - Task { - defer { connectionUI.isBusy = false } - - do { - // pairNewDevice() triggers onConnectionReady callback on success - try await connectionManager.pairNewDevice() - await wireServicesIfConnected() - - // If still in onboarding, navigate to region step; otherwise mark complete - if !onboarding.hasCompletedOnboarding { - onboarding.onboardingPath.append(.region) - } - } catch DevicePairingError.cancelled { - // User cancelled - no error - } catch DevicePairingError.alreadyInProgress { - // Picker is already showing - ignore - } catch let pairingError as PairingError { - connectionUI.presentPairingFailure(pairingError) - } catch { - connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } + // If still in onboarding, navigate to region step; otherwise mark complete + if !onboarding.hasCompletedOnboarding { + onboarding.onboardingPath.append(.region) } + } catch DevicePairingError.cancelled { + // User cancelled - no error + } catch DevicePairingError.alreadyInProgress { + // Picker is already showing - ignore + } catch let pairingError as PairingError { + connectionUI.presentFreshPairingFailure(pairingError) + } catch { + connectionUI.presentConnectionFailure(message: error.userFacingMessage) + } } + } - /// Remove a device that failed pairing (wrong PIN) and automatically retry - func removeFailedPairingAndRetry() { - guard let deviceID = connectionUI.failedPairingDeviceID else { return } + /// Remove a device that failed pairing (wrong PIN) and automatically retry + func removeFailedPairingAndRetry() { + guard let deviceID = connectionUI.failedPairingDeviceID else { return } - Task { - await connectionManager.removeFailedPairing(deviceID: deviceID) - connectionUI.failedPairingDeviceID = nil - // Set flag - View observing scenePhase will trigger startDeviceScan when active - connectionUI.shouldShowPickerOnForeground = true - } + Task { + await connectionManager.removeFailedPairing(deviceID: deviceID) + connectionUI.failedPairingDeviceID = nil + // Set flag - View observing scenePhase will trigger startDeviceScan when active + connectionUI.shouldShowPickerOnForeground = true } - - /// Retry connecting to the device that just failed without removing the bond. - /// Used for transient pairing failures where the bond is still good — radio out of range, - /// brief BLE flap, etc. Auth-failure paths route through `removeFailedPairingAndRetry` - /// because the bond itself needs to be torn down before retrying. - func retryFailedPairingConnect() async { - guard let deviceID = connectionUI.failedPairingDeviceID else { return } - connectionUI.isBusy = true - defer { connectionUI.isBusy = false } - - do { - try await connectionManager.connect(to: deviceID, forceReconnect: true) - connectionUI.failedPairingDeviceID = nil - await wireServicesIfConnected() - } catch BLEError.deviceConnectedToOtherApp { - connectionUI.failedPairingDeviceID = nil - connectionUI.presentPairingFailure(.deviceConnectedToOtherApp(deviceID: deviceID)) - } catch { - connectionUI.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: error)) - } + } + + /// Retry connecting to the device that just failed without removing the bond. + /// Used for transient pairing failures where the bond is still good — radio out of range, + /// brief BLE flap, etc. Auth-failure paths route through `removeFailedPairingAndRetry` + /// because the bond itself needs to be torn down before retrying. + func retryFailedPairingConnect() async { + guard let deviceID = connectionUI.failedPairingDeviceID else { return } + connectionUI.isBusy = true + defer { connectionUI.isBusy = false } + + do { + try await connectionManager.connect(to: deviceID, forceReconnect: true) + connectionUI.failedPairingDeviceID = nil + await wireServicesIfConnected() + } catch BLEError.deviceConnectedToOtherApp { + connectionUI.failedPairingDeviceID = nil + connectionUI.presentPairingFailure(.deviceConnectedToOtherApp(deviceID: deviceID)) + } catch { + connectionUI.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: error)) } + } - /// Called by View when scenePhase becomes active and shouldShowPickerOnForeground is true - func handleBecameActive() { - if connectionUI.shouldShowPickerOnForeground { - connectionUI.shouldShowPickerOnForeground = false - startDeviceScan() - } + /// Called by View when scenePhase becomes active. + func handleBecameActive() { + // Clear the auth-failure latch so a still-invalid bond re-surfaces fresh + // from the foreground reconnect instead of staying silenced from background. + connectionManager.clearSurfacedAuthenticationFailure() - activeRecoveryFallbackTask?.cancel() - activeRecoveryFallbackTask = Task { @MainActor [weak self] in - guard let self else { return } - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { return } - guard self.connectionState == .disconnected, - self.connectionManager.lastConnectedDeviceID != nil else { return } - - self.logger.info("[BLE] Active fallback: disconnected after activation, running foreground reconciliation") - await self.handleReturnToForeground() - } + if connectionUI.shouldShowPickerOnForeground { + connectionUI.shouldShowPickerOnForeground = false + startDeviceScan() } - /// Disconnect from device - /// - Parameter reason: The reason for disconnecting (for debugging) - func disconnect(reason: DisconnectReason = .userInitiated) async { - await connectionManager.disconnect(reason: reason) - await liveActivityManager.endActivity() - // Explicit disconnect does not fire onConnectionLost, so run the same - // per-session teardown the loss path performs in wireServicesIfConnected. - tearDownAppStateSessionState() - } + activeRecoveryFallbackTask?.cancel() + activeRecoveryFallbackTask = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + guard connectionState == .disconnected, + connectionManager.lastConnectedDeviceID != nil else { return } - /// Connect to a device via WiFi/TCP - func connectViaWiFi(host: String, port: UInt16, forceFullSync: Bool = false) async throws { - // Hide disconnected pill when starting new connection - connectionUI.hideDisconnectedPill() - try await connectionManager.connectViaWiFi(host: host, port: port, forceFullSync: forceFullSync) - await wireServicesIfConnected() + logger.info("[BLE] Active fallback: disconnected after activation, running foreground reconciliation") + await handleReturnToForeground() } - - // MARK: - Advertising - - /// Broadcast a self-advertisement from the connected radio, refreshing GPS - /// location first when the device's policy and the user's per-device - /// preference allow it. `allowLocationPrompt` is false from the App Intent: a - /// background Siri context cannot present a location-permission dialog, so the - /// refresh there uses only already-authorized location instead of stalling on - /// a prompt that can never resolve. - func sendSelfAdvert(flood: Bool, allowLocationPrompt: Bool = true) async throws { - if let source = advertGPSSource(device: connectedDevice, store: DevicePreferenceStore()) { - await updateLocationFromGPS(source: source, allowLocationPrompt: allowLocationPrompt) - } - guard let advertisementService = services?.advertisementService else { - throw AdvertisementError.notConnected - } - try await advertisementService.sendSelfAdvertisement(flood: flood) + } + + /// Disconnect from device + /// - Parameter reason: The reason for disconnecting (for debugging) + func disconnect(reason: DisconnectReason = .userInitiated) async { + await connectionManager.disconnect(reason: reason) + await liveActivityManager.endActivity() + // Explicit disconnect does not fire onConnectionLost, so run the same + // per-session teardown the loss path performs in wireServicesIfConnected. + tearDownAppStateSessionState() + } + + /// Connect to a device via WiFi/TCP + func connectViaWiFi(host: String, port: UInt16, forceFullSync: Bool = false) async throws { + // Hide disconnected pill when starting new connection + connectionUI.hideDisconnectedPill() + try await connectionManager.connectViaWiFi(host: host, port: port, forceFullSync: forceFullSync) + await wireServicesIfConnected() + } + + // MARK: - Advertising + + /// Broadcast a self-advertisement from the connected radio, refreshing GPS + /// location first when the device's policy and the user's per-device + /// preference allow it. `allowLocationPrompt` is false from the App Intent: a + /// background Siri context cannot present a location-permission dialog, so the + /// refresh there uses only already-authorized location instead of stalling on + /// a prompt that can never resolve. + func sendSelfAdvert(flood: Bool, allowLocationPrompt: Bool = true) async throws { + if let source = advertGPSSource(device: connectedDevice, store: DevicePreferenceStore()) { + await updateLocationFromGPS(source: source, allowLocationPrompt: allowLocationPrompt) } - - /// The GPS source to refresh before an advert, or nil when the device's advert - /// policy or the user's per-device preference disables auto-update. Pure over - /// its inputs so the privacy gate is testable with an injected store. - func advertGPSSource(device: DeviceDTO?, store: DevicePreferenceStore) -> GPSSource? { - guard let device, - device.sharesLocationPublicly, - store.isAutoUpdateLocationEnabled(deviceID: device.id) else { - return nil - } - return store.gpsSource(deviceID: device.id) + guard let advertisementService = services?.advertisementService else { + throw AdvertisementError.notConnected } - - private func updateLocationFromGPS(source: GPSSource, allowLocationPrompt: Bool) async { - let settingsService = services?.settingsService - do { - switch source { - case .phone: - let location: CLLocation - if allowLocationPrompt || locationService.isAuthorized { - do { - location = try await locationService.requestCurrentLocation() - } catch { - guard let cached = locationService.currentLocation else { throw error } - location = cached - } - } else { - guard let cached = locationService.currentLocation else { return } - location = cached - } - _ = try await settingsService?.setLocationVerified( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ) - case .device: - let gpsState = try await settingsService?.getDeviceGPSState() - if gpsState?.isEnabled != true { - _ = try await settingsService?.setDeviceGPSEnabledVerified(true) - } - } - } catch { - logger.warning("Failed to update location from GPS: \(error.localizedDescription)") + try await advertisementService.sendSelfAdvertisement(flood: flood) + } + + /// The GPS source to refresh before an advert, or nil when the device's advert + /// policy or the user's per-device preference disables auto-update. Pure over + /// its inputs so the privacy gate is testable with an injected store. + func advertGPSSource(device: DeviceDTO?, store: DevicePreferenceStore) -> GPSSource? { + guard let device, + device.sharesLocationPublicly, + store.isAutoUpdateLocationEnabled(deviceID: device.id) else { + return nil + } + return store.gpsSource(deviceID: device.id) + } + + private func updateLocationFromGPS(source: GPSSource, allowLocationPrompt: Bool) async { + let settingsService = services?.settingsService + do { + switch source { + case .phone: + let location: CLLocation + if allowLocationPrompt || locationService.isAuthorized { + do { + location = try await locationService.requestCurrentLocation() + } catch { + guard let cached = locationService.currentLocation else { throw error } + location = cached + } + } else { + guard let cached = locationService.currentLocation else { return } + location = cached + } + _ = try await settingsService?.setLocationVerified( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + case .device: + let gpsState = try await settingsService?.getDeviceGPSState() + if gpsState?.isEnabled != true { + _ = try await settingsService?.setDeviceGPSEnabledVerified(true) } + } + } catch { + logger.warning("Failed to update location from GPS: \(error.localizedDescription)") } + } } diff --git a/MC1/State/AppState+Lifecycle.swift b/MC1/State/AppState+Lifecycle.swift index 27da780a..3d699286 100644 --- a/MC1/State/AppState+Lifecycle.swift +++ b/MC1/State/AppState+Lifecycle.swift @@ -4,102 +4,101 @@ import MC1Services // MARK: - App Lifecycle extension AppState { - - private enum BLELifecycleTransition { - case enterBackground - case becomeActive - } - - @discardableResult - private func enqueueBLELifecycleTransition(_ transition: BLELifecycleTransition) -> Task { - let priorTask = bleLifecycleTransitionTask - let manager = connectionManager - - let transitionTask = Task { @MainActor in - await priorTask?.value - -#if DEBUG - switch transition { - case .enterBackground: - if let override = bleEnterBackgroundOverride { - await override() - return - } - case .becomeActive: - if let override = bleBecomeActiveOverride { - await override() - return - } - } -#endif - - switch transition { - case .enterBackground: - await manager.appDidEnterBackground() - case .becomeActive: - await manager.appDidBecomeActive() - } + private enum BLELifecycleTransition { + case enterBackground + case becomeActive + } + + @discardableResult + private func enqueueBLELifecycleTransition(_ transition: BLELifecycleTransition) -> Task { + let priorTask = bleLifecycleTransitionTask + let manager = connectionManager + + let transitionTask = Task { @MainActor in + await priorTask?.value + + #if DEBUG + switch transition { + case .enterBackground: + if let override = bleEnterBackgroundOverride { + await override() + return + } + case .becomeActive: + if let override = bleBecomeActiveOverride { + await override() + return + } } - - bleLifecycleTransitionTask = transitionTask - return transitionTask + #endif + + switch transition { + case .enterBackground: + await manager.appDidEnterBackground() + case .becomeActive: + await manager.appDidBecomeActive() + } } - /// Called when app enters background - func handleEnterBackground() { - activeRecoveryFallbackTask?.cancel() - activeRecoveryFallbackTask = nil - - liveActivityManager.handleEnterBackground() + bleLifecycleTransitionTask = transitionTask + return transitionTask + } - // Keep battery polling alive when the live activity is visible on the lock screen - if !liveActivityManager.hasActiveActivity { - batteryMonitor.stop() - } + /// Called when app enters background + func handleEnterBackground() { + activeRecoveryFallbackTask?.cancel() + activeRecoveryFallbackTask = nil - // Stop room keepalives to save battery/bandwidth - Task { - await services?.remoteNodeService.stopAllKeepAlives() - } + liveActivityManager.handleEnterBackground() - // Queue BLE lifecycle transition so background/foreground hooks stay ordered. - enqueueBLELifecycleTransition(.enterBackground) + // Keep battery polling alive when the live activity is visible on the lock screen + if !liveActivityManager.hasActiveActivity { + batteryMonitor.stop() } - /// Called when app returns to foreground - func handleReturnToForeground() async { - // Update badge count from database - await services?.notificationService.updateBadgeCount() - - // Room keepalives are managed by RoomConversationView lifecycle - // (started on view appear, stopped on disappear, restarted via scenePhase) - - // Reconcile transport state first (WiFi check + BLE lifecycle transition, - // which internally fires checkBLEConnectionHealth) so any stale "connected" - // state gets cleaned up via - // handleConnectionLoss → onConnectionLost → liveActivityManager.handleConnectionLost - // before the Live Activity tries to validate or restart. - await connectionManager.checkWiFiConnectionHealth() - await enqueueBLELifecycleTransition(.becomeActive).value - - liveActivityManager.handleReturnToForeground() - await liveActivityManager.validateActivityState() - await restartLiveActivityIfMissing() - - // Check for expired ACKs - if connectionState == .ready { - try? await services?.messageService.checkExpiredAcks() - } + // Stop room keepalives to save battery/bandwidth + Task { + await services?.remoteNodeService.stopAllKeepAlives() + } - // Trigger resync if sync failed while connected - await connectionManager.checkSyncHealth() + // Queue BLE lifecycle transition so background/foreground hooks stay ordered. + enqueueBLELifecycleTransition(.enterBackground) + } + + /// Called when app returns to foreground + func handleReturnToForeground() async { + // Update badge count from database + await services?.notificationService.updateBadgeCount() + + // Room keepalives are managed by RoomConversationView lifecycle + // (started on view appear, stopped on disappear, restarted via scenePhase) + + // Reconcile transport state first (WiFi check + BLE lifecycle transition, + // which internally fires checkBLEConnectionHealth) so any stale "connected" + // state gets cleaned up via + // handleConnectionLoss → onConnectionLost → liveActivityManager.handleConnectionLost + // before the Live Activity tries to validate or restart. + await connectionManager.checkWiFiConnectionHealth() + await enqueueBLELifecycleTransition(.becomeActive).value + + liveActivityManager.handleReturnToForeground() + await liveActivityManager.validateActivityState() + await restartLiveActivityIfMissing() + + // Check for expired ACKs + if connectionState == .ready { + try? await services?.messageService.checkExpiredAcks() + } - // Check for missed battery thresholds and restart polling if connected - if let services { - await batteryMonitor.checkMissedBatteryThreshold(device: connectedDevice, services: services) - batteryMonitor.startRefreshLoop(services: services, device: connectedDevice) - } + // Trigger resync if sync failed while connected + await connectionManager.checkSyncHealth() - offlineMapService.resumeAllPacks() + // Check for missed battery thresholds and restart polling if connected + if let services { + await batteryMonitor.checkMissedBatteryThreshold(device: connectedDevice, services: services) + batteryMonitor.startRefreshLoop(services: services, device: connectedDevice) } + + offlineMapService.resumeAllPacks() + } } diff --git a/MC1/State/AppState+NotificationHandlers.swift b/MC1/State/AppState+NotificationHandlers.swift index fbd96509..94583320 100644 --- a/MC1/State/AppState+NotificationHandlers.swift +++ b/MC1/State/AppState+NotificationHandlers.swift @@ -4,50 +4,49 @@ import MC1Services // MARK: - Notification Handlers extension AppState { + /// Configure notification handlers once services are available. + /// The transaction scripts live in `NotificationActionHandler`; this + /// installs thin forwarders and injects the app-layer inputs. + func configureNotificationHandlers() { + guard let services else { return } + + // Navigation-related notification tap handlers (delegated to NavigationCoordinator) + navigation.configureNotificationHandlers( + notificationService: services.notificationService, + dataStore: services.dataStore, + connectedDevice: { [weak self] in self?.connectedDevice } + ) + + let handler = services.notificationActionHandler + handler.configure( + isConnectionReady: { [weak self] in self?.connectionState == .ready }, + localNodeName: { [weak self] in self?.connectedDevice?.nodeName } + ) + + services.notificationService.onQuickReply = { contactID, text in + await handler.handleQuickReply(contactID: contactID, text: text) + } + + services.notificationService.onChannelQuickReply = { radioID, channelIndex, text in + await handler.handleChannelQuickReply(radioID: radioID, channelIndex: channelIndex, text: text) + } - /// Configure notification handlers once services are available. - /// The transaction scripts live in `NotificationActionHandler`; this - /// installs thin forwarders and injects the app-layer inputs. - func configureNotificationHandlers() { - guard let services else { return } - - // Navigation-related notification tap handlers (delegated to NavigationCoordinator) - navigation.configureNotificationHandlers( - notificationService: services.notificationService, - dataStore: services.dataStore, - connectedDevice: { [weak self] in self?.connectedDevice } - ) - - let handler = services.notificationActionHandler - handler.configure( - isConnectionReady: { [weak self] in self?.connectionState == .ready }, - localNodeName: { [weak self] in self?.connectedDevice?.nodeName } - ) - - services.notificationService.onQuickReply = { contactID, text in - await handler.handleQuickReply(contactID: contactID, text: text) - } - - services.notificationService.onChannelQuickReply = { radioID, channelIndex, text in - await handler.handleChannelQuickReply(radioID: radioID, channelIndex: channelIndex, text: text) - } - - services.notificationService.onMarkAsRead = { contactID, messageID in - await handler.handleMarkAsRead(contactID: contactID, messageID: messageID) - } - - services.notificationService.onChannelMarkAsRead = { radioID, channelIndex, messageID in - await handler.handleChannelMarkAsRead(radioID: radioID, channelIndex: channelIndex, messageID: messageID) - } - - services.notificationService.onRoomMarkAsRead = { sessionID, messageID in - await handler.handleRoomMarkAsRead(sessionID: sessionID, messageID: messageID) - } + services.notificationService.onMarkAsRead = { contactID, messageID in + await handler.handleMarkAsRead(contactID: contactID, messageID: messageID) } - /// Handle posting a notification when someone reacts to the user's message - func handleReactionNotification(messageID: UUID) async { - guard let services else { return } - await services.notificationActionHandler.handleReactionNotification(messageID: messageID) + services.notificationService.onChannelMarkAsRead = { radioID, channelIndex, messageID in + await handler.handleChannelMarkAsRead(radioID: radioID, channelIndex: channelIndex, messageID: messageID) } + + services.notificationService.onRoomMarkAsRead = { sessionID, messageID in + await handler.handleRoomMarkAsRead(sessionID: sessionID, messageID: messageID) + } + } + + /// Handle posting a notification when someone reacts to the user's message + func handleReactionNotification(messageID: UUID) async { + guard let services else { return } + await services.notificationActionHandler.handleReactionNotification(messageID: messageID) + } } diff --git a/MC1/State/AppState+Wiring.swift b/MC1/State/AppState+Wiring.swift index f2d1e245..362181e5 100644 --- a/MC1/State/AppState+Wiring.swift +++ b/MC1/State/AppState+Wiring.swift @@ -4,179 +4,178 @@ import MC1Services // MARK: - Service Wiring Helpers extension AppState { - - /// Consume the sync coordinator's data event stream for SwiftUI observation - /// (actors don't participate in SwiftUI's observation system). - /// Message events are ignored here; `MessageEventDispatcher` owns them. - /// Re-subscribes per connection because `ServiceContainer` is rebuilt. - func wireSyncDataEvents(services: ServiceContainer) { - syncDataEventsTask?.cancel() - let events = services.syncCoordinator.dataEvents() - syncDataEventsTask = Task { [weak self] in - for await event in events { - guard let self else { return } - switch event { - case .contactsChanged: - self.contactsVersion += 1 - case .conversationsChanged: - self.refreshConversations() - case .directMessageReceived, .channelMessageReceived, .roomMessageReceived, .reactionReceived: - break - } - } + /// Consume the sync coordinator's data event stream for SwiftUI observation + /// (actors don't participate in SwiftUI's observation system). + /// Message events are ignored here; `MessageEventDispatcher` owns them. + /// Re-subscribes per connection because `ServiceContainer` is rebuilt. + func wireSyncDataEvents(services: ServiceContainer) { + syncDataEventsTask?.cancel() + let events = services.syncCoordinator.dataEvents() + syncDataEventsTask = Task { [weak self] in + for await event in events { + guard let self else { return } + switch event { + case .contactsChanged: + contactsVersion += 1 + case .conversationsChanged: + refreshConversations() + case .directMessageReceived, .channelMessageReceived, .roomMessageReceived, .reactionReceived: + break } + } } + } - /// Consume settings service event stream. - /// Updates connectedDevice when settings are changed via SettingsService. - func wireSettingsEventStream(services: ServiceContainer) async { - settingsEventsTask?.cancel() - let events = await services.settingsService.events() - settingsEventsTask = Task { [weak self] in - for await event in events { - guard let self else { return } - switch event { - case .deviceUpdated(let selfInfo): - await MainActor.run { - self.connectionManager.updateDevice(from: selfInfo) - } - case .autoAddConfigUpdated(let config): - await MainActor.run { - self.connectionManager.updateAutoAddConfig(config) - // Clear storage full flag when overwrite oldest is enabled - if config.bitmask & AutoAddConfig.overwriteOldestBit != 0 { - self.connectionUI.isNodeStorageFull = false - } - } - case .clientRepeatUpdated(let enabled): - await MainActor.run { - self.connectionManager.updateClientRepeat(enabled) - } - case .pathHashModeUpdated(let mode): - await MainActor.run { - self.connectionManager.updatePathHashMode(mode) - } - case .allowedRepeatFreqUpdated(let ranges): - await MainActor.run { - self.connectionManager.allowedRepeatFreqRanges = ranges - } - case .defaultFloodScopeUpdated(let name): - await MainActor.run { - self.connectionManager.updateDefaultFloodScopeName(name) - } - } + /// Consume settings service event stream. + /// Updates connectedDevice when settings are changed via SettingsService. + func wireSettingsEventStream(services: ServiceContainer) async { + settingsEventsTask?.cancel() + let events = await services.settingsService.events() + settingsEventsTask = Task { [weak self] in + for await event in events { + guard let self else { return } + switch event { + case let .deviceUpdated(selfInfo): + await MainActor.run { + self.connectionManager.updateDevice(from: selfInfo) + } + case let .autoAddConfigUpdated(config): + await MainActor.run { + self.connectionManager.updateAutoAddConfig(config) + // Clear storage full flag when overwrite oldest is enabled + if config.bitmask & AutoAddConfig.overwriteOldestBit != 0 { + self.connectionUI.isNodeStorageFull = false } + } + case let .clientRepeatUpdated(enabled): + await MainActor.run { + self.connectionManager.updateClientRepeat(enabled) + } + case let .pathHashModeUpdated(mode): + await MainActor.run { + self.connectionManager.updatePathHashMode(mode) + } + case let .allowedRepeatFreqUpdated(ranges): + await MainActor.run { + self.connectionManager.allowedRepeatFreqRanges = ranges + } + case let .defaultFloodScopeUpdated(name): + await MainActor.run { + self.connectionManager.updateDefaultFloodScopeName(name) + } } + } } + } - /// Wire device update and contact change callbacks. - /// Updates connectedDevice when local device settings (like OCV) are changed via DeviceService, - /// and handles contact updates/deletions for real-time Discover page updates. - func wireDeviceUpdateCallbacks(services: ServiceContainer) async { - await services.deviceService.setDeviceUpdateCallback { [weak self] deviceDTO in - await MainActor.run { - self?.connectionManager.updateDevice(with: deviceDTO) - } - } + /// Wire device update and contact change callbacks. + /// Updates connectedDevice when local device settings (like OCV) are changed via DeviceService, + /// and handles contact updates/deletions for real-time Discover page updates. + func wireDeviceUpdateCallbacks(services: ServiceContainer) async { + await services.deviceService.setDeviceUpdateCallback { [weak self] deviceDTO in + await MainActor.run { + self?.connectionManager.updateDevice(with: deviceDTO) + } + } - // Contact updates bump contactsVersion for real-time Discover page - // updates; contact-deleted cleanup removes notifications and refreshes - // the badge when the device auto-deletes a contact via 0x8F. - // Re-subscribes per connection because ServiceContainer is rebuilt. - advertisementEventsTask?.cancel() - let advertisementEvents = services.advertisementService.events() - advertisementEventsTask = Task { [weak self] in - for await event in advertisementEvents { - guard let self else { return } - switch event { - case .contactUpdated: - self.contactsVersion += 1 - PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - .info("B4 contactUpdated bump contactsVersion=\(self.contactsVersion)") - case .contactDeletedCleanup(let contactID, _): - self.logger.info("Overwrite oldest: running cleanup for deleted contact \(contactID) - removing notifications and updating badge") - await self.services?.notificationService.removeDeliveredNotifications(forContactID: contactID) - await self.services?.notificationService.updateBadgeCount() - case .newContactDiscovered, .contactSyncRequested, .nodeStorageFullChanged, - .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: - break - } - } + // Contact updates bump contactsVersion for real-time Discover page + // updates; contact-deleted cleanup removes notifications and refreshes + // the badge when the device auto-deletes a contact via 0x8F. + // Re-subscribes per connection because ServiceContainer is rebuilt. + advertisementEventsTask?.cancel() + let advertisementEvents = services.advertisementService.events() + advertisementEventsTask = Task { [weak self] in + for await event in advertisementEvents { + guard let self else { return } + switch event { + case .contactUpdated: + contactsVersion += 1 + PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + .info("B4 contactUpdated bump contactsVersion=\(contactsVersion)") + case let .contactDeletedCleanup(contactID, _): + logger.info("Overwrite oldest: running cleanup for deleted contact \(contactID) - removing notifications and updating badge") + await self.services?.notificationService.removeDeliveredNotifications(forContactID: contactID) + await self.services?.notificationService.updateBadgeCount() + case .newContactDiscovered, .contactSyncRequested, .nodeStorageFullChanged, + .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: + break } + } } + } - /// Wire the message event streams. Delegates to `MessageEventDispatcher`, - /// which subscribes to the service event streams and fans them out to - /// `messageEventStream` and `sessionStateChangeCount`. - func wireMessageEvents(services: ServiceContainer) { - messageEventDispatcher.wire(services: services) - } + /// Wire the message event streams. Delegates to `MessageEventDispatcher`, + /// which subscribes to the service event streams and fans them out to + /// `messageEventStream` and `sessionStateChangeCount`. + func wireMessageEvents(services: ServiceContainer) { + messageEventDispatcher.wire(services: services) + } - /// Wire Live Activity callbacks for RX freshness, battery, and connection lifecycle. - func wireLiveActivityCallbacks(services: ServiceContainer) async { - // Every received RF packet refreshes Live Activity freshness and may - // trigger an overdue battery read. Re-subscribes per connection - // because ServiceContainer is rebuilt. - rxLogEventsTask?.cancel() - let rxLogEntries = services.rxLogService.entryStream() - rxLogEventsTask = Task { [weak self] in - for await _ in rxLogEntries { - guard let self else { return } - await self.liveActivityManager.handlePacketReceived() - if self.liveActivityManager.hasActiveActivity { - await self.batteryMonitor.fetchBatteryIfOverdue( - services: self.services, device: self.connectedDevice - ) - } - } + /// Wire Live Activity callbacks for RX freshness, battery, and connection lifecycle. + func wireLiveActivityCallbacks(services: ServiceContainer) async { + // Every received RF packet refreshes Live Activity freshness and may + // trigger an overdue battery read. Re-subscribes per connection + // because ServiceContainer is rebuilt. + rxLogEventsTask?.cancel() + let rxLogEntries = services.rxLogService.entryStream() + rxLogEventsTask = Task { [weak self] in + for await _ in rxLogEntries { + guard let self else { return } + await liveActivityManager.handlePacketReceived() + if liveActivityManager.hasActiveActivity { + await batteryMonitor.fetchBatteryIfOverdue( + services: self.services, device: connectedDevice + ) } + } + } - batteryMonitor.onBatteryChanged = { [weak self] battery in - Task { @MainActor [weak self] in - await self?.liveActivityManager.handleBatteryChanged(battery: battery) - } - } + batteryMonitor.onBatteryChanged = { [weak self] battery in + Task { @MainActor [weak self] in + await self?.liveActivityManager.handleBatteryChanged(battery: battery) + } + } - let device = connectedDevice - let ocvArray = batteryMonitor.activeBatteryOCVArray(for: device) - let unreadCount = await totalUnreadCount(from: services) + let device = connectedDevice + let ocvArray = batteryMonitor.activeBatteryOCVArray(for: device) + let unreadCount = await totalUnreadCount(from: services) - if let device { - await liveActivityManager.handleConnectionReady( - device: device, - ocvArray: ocvArray, - unreadCount: unreadCount - ) - } + if let device { + await liveActivityManager.handleConnectionReady( + device: device, + ocvArray: ocvArray, + unreadCount: unreadCount + ) } + } - /// `Activity.request` only succeeds in the foreground, so a `startActivity` - /// triggered from background (e.g. BLE auto-reconnect after the disconnect - /// grace timer fired) throws and leaves `currentActivity` nil. iOS may also - /// end an activity from background — 8-hour active cap, 12-hour total, or - /// memory pressure — in which case the `.dismissed` branch in - /// `validateActivityState` clears the reference without restarting. Both - /// leave the radio connected with no LA on screen. - func restartLiveActivityIfMissing() async { - guard connectionState.isConnected, - liveActivityManager.isEnabled, - !liveActivityManager.hasActiveActivity, - let device = connectedDevice, - let services else { return } + /// `Activity.request` only succeeds in the foreground, so a `startActivity` + /// triggered from background (e.g. BLE auto-reconnect after the disconnect + /// grace timer fired) throws and leaves `currentActivity` nil. iOS may also + /// end an activity from background — 8-hour active cap, 12-hour total, or + /// memory pressure — in which case the `.dismissed` branch in + /// `validateActivityState` clears the reference without restarting. Both + /// leave the radio connected with no LA on screen. + func restartLiveActivityIfMissing() async { + guard connectionState.isConnected, + liveActivityManager.isEnabled, + !liveActivityManager.hasActiveActivity, + let device = connectedDevice, + let services else { return } - let ocvArray = batteryMonitor.activeBatteryOCVArray(for: device) - let unreadCount = await totalUnreadCount(from: services) - await liveActivityManager.handleConnectionReady( - device: device, - ocvArray: ocvArray, - unreadCount: unreadCount - ) - } + let ocvArray = batteryMonitor.activeBatteryOCVArray(for: device) + let unreadCount = await totalUnreadCount(from: services) + await liveActivityManager.handleConnectionReady( + device: device, + ocvArray: ocvArray, + unreadCount: unreadCount + ) + } - func totalUnreadCount(from services: ServiceContainer) async -> Int { - guard let radioID = currentRadioID else { return 0 } - let counts = (try? await services.dataStore.getTotalUnreadCounts(radioID: radioID)) - ?? (contacts: 0, channels: 0, rooms: 0) - return counts.contacts + counts.channels + counts.rooms - } + func totalUnreadCount(from services: ServiceContainer) async -> Int { + guard let radioID = currentRadioID else { return 0 } + let counts = await (try? services.dataStore.getTotalUnreadCounts(radioID: radioID)) + ?? (contacts: 0, channels: 0, rooms: 0) + return counts.contacts + counts.channels + counts.rooms + } } diff --git a/MC1/State/AppState.swift b/MC1/State/AppState.swift index e628b57b..5d2b25c1 100644 --- a/MC1/State/AppState.swift +++ b/MC1/State/AppState.swift @@ -1,11 +1,12 @@ import CoreLocation -import SwiftUI -import SwiftData -import UserNotifications +import MapKit import MC1Services import MeshCore import OSLog +import SwiftData +import SwiftUI import TipKit +import UserNotifications /// Simplified app-wide state management. /// Composes ConnectionManager for connection lifecycle. @@ -13,641 +14,717 @@ import TipKit @Observable @MainActor final class AppState { + // MARK: - Logging - // MARK: - Logging + let logger = Logger(subsystem: "com.mc1", category: "AppState") - let logger = Logger(subsystem: "com.mc1", category: "AppState") + // MARK: - Location - // MARK: - Location + /// App-wide location service for permission management + let locationService = LocationService() - /// App-wide location service for permission management - let locationService = LocationService() + // MARK: - Offline Maps - // MARK: - Offline Maps + /// Offline map pack management and network monitoring + let offlineMapService = OfflineMapService() - /// Offline map pack management and network monitoring - let offlineMapService = OfflineMapService() + // MARK: - Chat Drafts - // MARK: - Chat Drafts + /// Disk-backed store for unsent chat-composer text. Recreated on + /// before-first-unlock, but reads the same `UserDefaults` key, so the fresh + /// instance recovers every persisted draft — no in-memory-lifetime dependency. + let draftStore = DraftStore() - /// Disk-backed store for unsent chat-composer text. Recreated on - /// before-first-unlock, but reads the same `UserDefaults` key, so the fresh - /// instance recovers every persisted draft — no in-memory-lifetime dependency. - let draftStore = DraftStore() - - /// Best available location for proximity-based disambiguation. - var bestAvailableLocation: CLLocation? { - if let phoneLocation = locationService.currentLocation { - return phoneLocation - } - guard let device = connectedDevice, device.hasLocation else { - return nil - } - return CLLocation(latitude: device.latitude, longitude: device.longitude) + /// Best available location for proximity-based disambiguation. + var bestAvailableLocation: CLLocation? { + if let phoneLocation = locationService.currentLocation { + return phoneLocation } - - // MARK: - Region preference - - @ObservationIgnored lazy var regionResolver = RegionResolver(location: locationService) - - /// Suppresses the `regionSelection` `didSet` write-back during cold-start load. Without - /// this, `loadPersistedRegionSelection()` would re-encode the just-read JSON and rewrite - /// the same bytes to UserDefaults on every launch. - @ObservationIgnored private var suppressRegionPersist = false - - var regionSelection: RegionSelection? { - didSet { - guard !suppressRegionPersist else { return } - persistRegionSelection() - } + guard let device = connectedDevice, device.hasLocation else { + return nil } - - private func persistRegionSelection() { - BackupUserDefaults.persistRegionSelection(regionSelection) + return CLLocation(latitude: device.latitude, longitude: device.longitude) + } + + /// Centers the map on the best available location if one is known, otherwise requests one. + /// Returns whether the camera was moved, so callers can drive their `isCenteredOnUser` flag. + @discardableResult + func centerOnUserLocation( + span: CLLocationDegrees = 0.02, + setRegion: (MKCoordinateRegion) -> Void + ) -> Bool { + guard let location = bestAvailableLocation else { + locationService.requestLocation() + return false } - - private func loadPersistedRegionSelection() { - guard UserDefaults.standard.data(forKey: BackupUserDefaults.regionSelectionKey) != nil else { return } - guard let decoded = BackupUserDefaults.loadRegionSelection() else { - logger.warning("Failed to decode persisted region selection — clearing key") - UserDefaults.standard.removeObject(forKey: BackupUserDefaults.regionSelectionKey) - return - } - suppressRegionPersist = true - defer { suppressRegionPersist = false } - self.regionSelection = decoded + setRegion(MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: span, longitudeDelta: span) + )) + return true + } + + // MARK: - Region preference + + @ObservationIgnored lazy var regionResolver = RegionResolver(location: locationService) + + /// Suppresses the `regionSelection` `didSet` write-back during cold-start load. Without + /// this, `loadPersistedRegionSelection()` would re-encode the just-read JSON and rewrite + /// the same bytes to UserDefaults on every launch. + @ObservationIgnored private var suppressRegionPersist = false + + var regionSelection: RegionSelection? { + didSet { + guard !suppressRegionPersist else { return } + persistRegionSelection() } - - // MARK: - Connection (via ConnectionManager) - - /// The connection manager for device lifecycle - let connectionManager: ConnectionManager - let storeState: StoreState - let themeService: ThemeService - private let bootstrapDebugLogBuffer: DebugLogBuffer - - // Convenience accessors - var connectionState: MC1Services.DeviceConnectionState { connectionManager.connectionState } - var connectedDevice: DeviceDTO? { connectionManager.connectedDevice } - var services: ServiceContainer? { connectionManager.services } - - /// Local node name with fallback for display purposes. - var localNodeName: String { connectedDevice?.nodeName ?? "Me" } - - /// The sync coordinator for data synchronization - private(set) var syncCoordinator: SyncCoordinator? - - /// Incremented when services change (device switch, reconnect). Views observe this to reload. - private(set) var servicesVersion: Int = 0 - - /// Identity of the `ServiceContainer` the last `servicesVersion` bump was for, - /// so redundant re-wires of the same container don't bump it again. - private var lastBumpedServicesID: ObjectIdentifier? - - // MARK: - Offline Data Access - - /// Per-conversation coordinator registry. Lives at AppState scope so - /// chat detail screens render stored messages while disconnected. The - /// registry's dataStore rebinds to services.dataStore on connect and is - /// torn down on services-left. - private(set) var chatCoordinatorRegistry: ChatCoordinatorRegistry? - - /// Cached standalone persistence store for offline browsing - private var cachedOfflineStore: PersistenceStore? - - /// Radio ID for data access - returns connected device's radio ID or last-connected radio ID for offline browsing - var currentRadioID: UUID? { - connectedDevice?.radioID ?? connectionManager.lastConnectedRadioID + } + + private func persistRegionSelection() { + BackupUserDefaults.persistRegionSelection(regionSelection) + } + + private func loadPersistedRegionSelection() { + guard UserDefaults.standard.data(forKey: BackupUserDefaults.regionSelectionKey) != nil else { return } + guard let decoded = BackupUserDefaults.loadRegionSelection() else { + logger.warning("Failed to decode persisted region selection — clearing key") + UserDefaults.standard.removeObject(forKey: BackupUserDefaults.regionSelectionKey) + return } - - /// Data store that works regardless of connection state - uses services when connected, - /// cached standalone store when disconnected - var offlineDataStore: PersistenceStore? { - if let services { - cachedOfflineStore = nil // Clear cache when services available - return services.dataStore - } - guard connectionManager.lastConnectedDeviceID != nil else { - cachedOfflineStore = nil - return nil - } - if cachedOfflineStore == nil { - cachedOfflineStore = connectionManager.createStandalonePersistenceStore() - } - return cachedOfflineStore - } - - /// Ensures the chat coordinator registry exists, lazy-building one bound - /// to the offline data store if none has been built yet. Used by - /// `ChatViewModel` for the cold-launch-while-offline path where - /// `wireServicesIfConnected` has not yet run but - /// `connectionManager.lastConnectedDeviceID` is set so `offlineDataStore` - /// is non-nil. - func ensureChatCoordinatorRegistry() -> ChatCoordinatorRegistry? { - if let chatCoordinatorRegistry { return chatCoordinatorRegistry } - guard let store = offlineDataStore else { return nil } - chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: store) - return chatCoordinatorRegistry - } - - /// Incremented when contacts data changes. Views observe this to reload contact lists. - var contactsVersion: Int = 0 - - /// Incremented when conversations data changes. Views observe this to reload chat lists. - private(set) var conversationsVersion: Int = 0 - - /// Incremented when a remote-node session changes connection state. - /// `RoomConversationView` observes this counter via `.onChange` to refresh - /// its session DTO when re-authentication completes. - private(set) var sessionStateChangeCount: Int = 0 - - /// Called by `MessageEventDispatcher` when a remote-node session changes - /// connection state. Bundles refreshing conversations and bumping the - /// state-change counter so the dispatcher only needs one entry point. - func handleSessionStateChange() { - refreshConversations() - sessionStateChangeCount += 1 - } - - /// Bumps `conversationsVersion` and drives - /// `liveActivityManager.handleUnreadCountChanged`. Used by both the sync - /// coordinator's data event stream and the session-state events - /// (`RemoteNodeEvent.sessionStateChanged` / `RoomServerEvent.connectionRecovered`) - /// to keep the conversations list and unread badge in lockstep. - func refreshConversations() { - conversationsVersion += 1 - Task { @MainActor [weak self] in - guard let self, let services = self.services else { return } - let total = await self.totalUnreadCount(from: services) - await self.liveActivityManager.handleUnreadCountChanged(unreadCount: total) - } + suppressRegionPersist = true + defer { suppressRegionPersist = false } + regionSelection = decoded + } + + // MARK: - Connection (via ConnectionManager) + + /// The connection manager for device lifecycle + let connectionManager: ConnectionManager + let storeState: StoreState + let themeService: ThemeService + private let bootstrapDebugLogBuffer: DebugLogBuffer + + /// Convenience accessors + var connectionState: MC1Services.DeviceConnectionState { + connectionManager.connectionState + } + + var connectedDevice: DeviceDTO? { + connectionManager.connectedDevice + } + + var services: ServiceContainer? { + connectionManager.services + } + + /// Local node name with fallback for display purposes. + var localNodeName: String { + connectedDevice?.nodeName ?? "Me" + } + + /// The sync coordinator for data synchronization + private(set) var syncCoordinator: SyncCoordinator? + + /// Incremented when services change (device switch, reconnect). Views observe this to reload. + private(set) var servicesVersion: Int = 0 + + /// Identity of the `ServiceContainer` the last `servicesVersion` bump was for, + /// so redundant re-wires of the same container don't bump it again. + private var lastBumpedServicesID: ObjectIdentifier? + + // MARK: - Offline Data Access + + /// Per-conversation coordinator registry. Lives at AppState scope so + /// chat detail screens render stored messages while disconnected. The + /// registry's dataStore rebinds to services.dataStore on connect and is + /// torn down on services-left. + private(set) var chatCoordinatorRegistry: ChatCoordinatorRegistry? + + /// Cached standalone persistence store for offline browsing + private var cachedOfflineStore: PersistenceStore? + + /// Radio ID for data access - returns connected device's radio ID or last-connected radio ID for offline browsing + var currentRadioID: UUID? { + connectedDevice?.radioID ?? connectionManager.lastConnectedRadioID + } + + /// Data store that works regardless of connection state - uses services when connected, + /// cached standalone store when disconnected + var offlineDataStore: PersistenceStore? { + if let services { + cachedOfflineStore = nil // Clear cache when services available + return services.dataStore } - - /// Signals views observing `contactsVersion` / `conversationsVersion` to reload after - /// a backup restore writes directly to the persistence store. The normal sync-path - /// events don't fire for batch imports, so without this bump any currently-mounted - /// tabs keep showing their pre-restore snapshot until reconnect or relaunch. - /// Also re-reads the persisted region selection — the import wrote it to UserDefaults, - /// but `regionSelection` is only loaded once during `init`, so Settings → Region and - /// the radio-preset views would otherwise show pre-import data until next launch. - func notifyDataRestored() { - contactsVersion += 1 - conversationsVersion += 1 - loadPersistedRegionSelection() - themeService.refreshFromUserDefaults() + guard connectionManager.lastConnectedDeviceID != nil else { + cachedOfflineStore = nil + return nil + } + if cachedOfflineStore == nil { + cachedOfflineStore = connectionManager.createStandalonePersistenceStore() + } + return cachedOfflineStore + } + + /// Ensures the chat coordinator registry exists, lazy-building one bound + /// to the offline data store if none has been built yet. Used by + /// `ChatViewModel` for the cold-launch-while-offline path where + /// `wireServicesIfConnected` has not yet run but + /// `connectionManager.lastConnectedDeviceID` is set so `offlineDataStore` + /// is non-nil. + func ensureChatCoordinatorRegistry() -> ChatCoordinatorRegistry? { + if let chatCoordinatorRegistry { return chatCoordinatorRegistry } + guard let store = offlineDataStore else { return nil } + chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: store) + return chatCoordinatorRegistry + } + + /// Incremented when contacts data changes. Views observe this to reload contact lists. + var contactsVersion: Int = 0 + + /// Incremented when conversations data changes. Views observe this to reload chat lists. + private(set) var conversationsVersion: Int = 0 + + /// Incremented when a remote-node session changes connection state. + /// `RoomConversationView` observes this counter via `.onChange` to refresh + /// its session DTO when re-authentication completes. + private(set) var sessionStateChangeCount: Int = 0 + + /// Called by `MessageEventDispatcher` when a remote-node session changes + /// connection state. Bundles refreshing conversations and bumping the + /// state-change counter so the dispatcher only needs one entry point. + func handleSessionStateChange() { + refreshConversations() + sessionStateChangeCount += 1 + } + + /// Bumps `conversationsVersion` and drives + /// `liveActivityManager.handleUnreadCountChanged`. Used by both the sync + /// coordinator's data event stream and the session-state events + /// (`RemoteNodeEvent.sessionStateChanged` / `RoomServerEvent.connectionRecovered`) + /// to keep the conversations list and unread badge in lockstep. + func refreshConversations() { + conversationsVersion += 1 + Task { @MainActor [weak self] in + guard let self, let services else { return } + let total = await totalUnreadCount(from: services) + await liveActivityManager.handleUnreadCountChanged(unreadCount: total) } + } - // MARK: - Connection UI State + /// Signals views observing `contactsVersion` / `conversationsVersion` to reload after + /// a backup restore writes directly to the persistence store. The normal sync-path + /// events don't fire for batch imports, so without this bump any currently-mounted + /// tabs keep showing their pre-restore snapshot until reconnect or relaunch. + /// Also re-reads the persisted region selection — the import wrote it to UserDefaults, + /// but `regionSelection` is only loaded once during `init`, so Settings → Region and + /// the radio-preset views would otherwise show pre-import data until next launch. + func notifyDataRestored() { + contactsVersion += 1 + conversationsVersion += 1 + loadPersistedRegionSelection() + themeService.refreshFromUserDefaults() + } - /// Connection UI state (status pills, sync activity, alerts, pairing) - let connectionUI = ConnectionUIState() + // MARK: - Connection UI State - /// Battery monitoring (polling, thresholds, low-battery notifications) - let batteryMonitor = BatteryMonitor() + /// Connection UI state (status pills, sync activity, alerts, pairing) + let connectionUI = ConnectionUIState() - /// Live Activity lifecycle (start/update/stop on Lock Screen and Dynamic Island) - let liveActivityManager = LiveActivityManager() + /// Battery monitoring (polling, thresholds, low-battery notifications) + let batteryMonitor = BatteryMonitor() - /// Task chain that serializes BLE lifecycle transitions across scene-phase changes. - /// Do not cancel this task externally -- cancelling breaks the serialization - /// guarantee because Task.value returns immediately on cancellation. - var bleLifecycleTransitionTask: Task? + /// Live Activity lifecycle (start/update/stop on Lock Screen and Dynamic Island) + let liveActivityManager = LiveActivityManager() - /// Fallback task that re-runs foreground recovery shortly after activation when the - /// app is still disconnected. Covers edge cases where scene-phase callbacks are missed. - var activeRecoveryFallbackTask: Task? + /// Task chain that serializes BLE lifecycle transitions across scene-phase changes. + /// Do not cancel this task externally -- cancelling breaks the serialization + /// guarantee because Task.value returns immediately on cancellation. + var bleLifecycleTransitionTask: Task? - /// Task consuming SettingsService event stream, canceled on disconnect - var settingsEventsTask: Task? + /// Fallback task that re-runs foreground recovery shortly after activation when the + /// app is still disconnected. Covers edge cases where scene-phase callbacks are missed. + var activeRecoveryFallbackTask: Task? - /// Task consuming SyncCoordinator's data event stream, canceled on disconnect - var syncDataEventsTask: Task? + /// Task consuming SettingsService event stream, canceled on disconnect + var settingsEventsTask: Task? - /// Task consuming AdvertisementService's event stream, canceled on disconnect - var advertisementEventsTask: Task? + /// Task consuming SyncCoordinator's data event stream, canceled on disconnect + var syncDataEventsTask: Task? - /// Task consuming RxLogService's entry stream for Live Activity freshness, canceled on disconnect - var rxLogEventsTask: Task? + /// Task consuming AdvertisementService's event stream, canceled on disconnect + var advertisementEventsTask: Task? -#if DEBUG + /// Task consuming RxLogService's entry stream for Live Activity freshness, canceled on disconnect + var rxLogEventsTask: Task? + + #if DEBUG /// Optional test-only hooks for deterministic lifecycle ordering tests. var bleEnterBackgroundOverride: (@MainActor () async -> Void)? var bleBecomeActiveOverride: (@MainActor () async -> Void)? -#endif + #endif - // MARK: - Onboarding State + // MARK: - Onboarding State - /// Onboarding state (completion flag, navigation path) - let onboarding = OnboardingState() + /// Onboarding state (completion flag, navigation path) + let onboarding = OnboardingState() - // MARK: - What's New + // MARK: - What's New - /// What's New presentation gate (device-local baseline, pending release) - let whatsNew = WhatsNewState() + /// What's New presentation gate (device-local baseline, pending release) + let whatsNew = WhatsNewState() - // MARK: - Navigation State + // MARK: - Navigation State - /// Navigation coordinator (tab selection, pending targets, cross-tab navigation) - let navigation = NavigationCoordinator() + /// Navigation coordinator (tab selection, pending targets, cross-tab navigation) + let navigation = NavigationCoordinator() - // MARK: - UI Coordination + // MARK: - UI Coordination - /// AsyncStream-based distribution of `MessageEvent` to chat and room - /// consumers. Fed by the service event streams wired in `wireMessageEvents`. - let messageEventStream = MessageEventStream() + /// AsyncStream-based distribution of `MessageEvent` to chat and room + /// consumers. Fed by the service event streams wired in `wireMessageEvents`. + let messageEventStream = MessageEventStream() - /// Routes service event streams to `messageEventStream` and the - /// session-state counter. Lazy because it captures `self` and - /// `messageEventStream`. - @ObservationIgnored - lazy var messageEventDispatcher = MessageEventDispatcher( - appState: self, - stream: messageEventStream - ) + /// Routes service event streams to `messageEventStream` and the + /// session-state counter. Lazy because it captures `self` and + /// `messageEventStream`. + @ObservationIgnored + lazy var messageEventDispatcher = MessageEventDispatcher( + appState: self, + stream: messageEventStream + ) - // MARK: - CLI Tool + // MARK: - CLI Tool - /// Persistent CLI tool view model (survives tab switches, reset on device disconnect) - var cliToolViewModel: CLIToolViewModel? + /// Persistent CLI tool view model (survives tab switches, reset on device disconnect) + var cliToolViewModel: CLIToolViewModel? - /// Tracks the device ID for CLI state - reset CLI when device changes - private var lastConnectedDeviceIDForCLI: UUID? + /// Tracks the device ID for CLI state - reset CLI when device changes + private var lastConnectedDeviceIDForCLI: UUID? - // MARK: - Status Pill + // MARK: - Status Pill - /// The current status pill state, computed from all relevant conditions - /// Priority: failed > syncing > ready > connecting > disconnected > hidden - var statusPillState: StatusPillState { - if connectionUI.syncFailedPillVisible { - return .failed(message: L10n.Localizable.StatusPill.syncFailed) - } - if connectionUI.syncActivityCount > 0 || connectionState == .syncing { - return .syncing - } - if connectionUI.showReadyToast { - return .ready - } - if connectionState == .connecting { - return .connecting - } - if connectionUI.disconnectedPillVisible { - return .disconnected - } - return .hidden + /// The current status pill state, computed from all relevant conditions + /// Priority: failed > syncing > ready > connecting > disconnected > hidden + var statusPillState: StatusPillState { + if connectionUI.syncFailedPillVisible { + return .failed(message: L10n.Localizable.StatusPill.syncFailed) } - - /// Whether Settings startup reads should run right now. - var canRunSettingsStartupReads: Bool { - if connectionState == .ready { return true } - return connectionState == .connected && connectionUI.currentSyncPhase == .messages + if connectionUI.syncActivityCount > 0 || connectionState == .syncing { + return .syncing + } + if connectionUI.showReadyToast { + return .ready + } + if connectionState == .connecting { + return .connecting + } + if connectionUI.disconnectedPillVisible { + return .disconnected + } + return .hidden + } + + /// Whether Settings startup reads should run right now. + var canRunSettingsStartupReads: Bool { + if connectionState == .ready { return true } + return connectionState == .connected && connectionUI.currentSyncPhase == .messages + } + + // MARK: - Initialization + + init(modelContainer: ModelContainer, isPlaceholder: Bool = false) { + let bootstrapStore = PersistenceStore(modelContainer: modelContainer) + let bootstrapBuffer = DebugLogBuffer(dataStore: bootstrapStore) + bootstrapDebugLogBuffer = bootstrapBuffer + // The inert environment-default placeholder must not publish the process-global buffer, + // or it would displace the live one and route logs into a discarded in-memory store. + if !isPlaceholder { + DebugLogBuffer.shared = bootstrapBuffer } - // MARK: - Initialization - - init(modelContainer: ModelContainer) { - let bootstrapStore = PersistenceStore(modelContainer: modelContainer) - let bootstrapBuffer = DebugLogBuffer(dataStore: bootstrapStore) - self.bootstrapDebugLogBuffer = bootstrapBuffer - DebugLogBuffer.shared = bootstrapBuffer - - let store = StoreService() - let theme = ThemeService(store: store) - self.storeState = StoreState(service: store) - self.themeService = theme - - self.connectionManager = ConnectionManager(modelContainer: modelContainer) - - // Provide LiveActivityManager with current radio connection state so - // its restart/recovery/stale paths consult ground truth instead of - // the LA's last cached `isConnected`. Read from connectionState (a - // transport-link predicate) rather than connectedDevice: during iOS - // auto-reconnect connectedDevice is intentionally retained while the - // transport link is down, and using identity as a liveness signal - // would resurrect a "Connected" LA mid-reconnect. - liveActivityManager.connectionStateProvider = { [weak self] in - self?.connectionState.isConnected ?? false - } + let store = StoreService() + let theme = ThemeService(store: store) + storeState = StoreState(service: store) + themeService = theme + + connectionManager = ConnectionManager(modelContainer: modelContainer) + + // Provide LiveActivityManager with current radio connection state so + // its restart/recovery/stale paths consult ground truth instead of + // the LA's last cached `isConnected`. Read from connectionState (a + // transport-link predicate) rather than connectedDevice: during iOS + // auto-reconnect connectedDevice is intentionally retained while the + // transport link is down, and using identity as a liveness signal + // would resurrect a "Connected" LA mid-reconnect. + liveActivityManager.connectionStateProvider = { [weak self] in + self?.connectionState.isConnected ?? false + } - // Wire app state provider for incremental sync support - connectionManager.appStateProvider = AppStateProviderImpl() + // Live radioID for the stale-activity self-heal. Gated on the same + // transport-link predicate as `connectionStateProvider`, so it returns a + // radioID only while genuinely connected, never mid-reconnect. + liveActivityManager.connectedRadioIDProvider = { [weak self] in + guard let self, self.connectionState.isConnected else { return nil } + return self.connectedDevice?.radioID + } - // Wire connection ready callback - automatically updates UI when connection completes - connectionManager.onConnectionReady = { [weak self] in - await self?.wireServicesIfConnected() - } + // Wire app state provider for incremental sync support + connectionManager.appStateProvider = AppStateProviderImpl() - // Wire connection lost callback - updates UI when connection is lost - connectionManager.onConnectionLost = { [weak self] in - await self?.wireServicesIfConnected() - } + // Wire connection ready callback - automatically updates UI when connection completes + connectionManager.onConnectionReady = { [weak self] in + await self?.wireServicesIfConnected() + } - // Wire device synced callback - runs after sync completes and state is .ready - connectionManager.onDeviceSynced = { [weak self] in - self?.performStaleNodeCleanup() - } + // Wire connection lost callback - updates UI when connection is lost + connectionManager.onConnectionLost = { [weak self] in + await self?.wireServicesIfConnected() + } - loadPersistedRegionSelection() - - Task { [regionAlreadySet = regionSelection != nil] in - let suggested = await onboarding.suggestedStartingPath( - connectionManager: connectionManager, - locationAuthorizationStatus: locationService.authorizationStatus, - regionAlreadySet: regionAlreadySet - ) - if !suggested.isEmpty { - onboarding.onboardingPath = suggested - } - } + // Wire auto-reconnect entry callback - reflects an out-of-range drop on the + // Live Activity immediately, while connectionState is still .connecting. + connectionManager.onAutoReconnectStarted = { [weak self] in + await self?.liveActivityManager.handleConnectionLost() } - /// Releases process-scoped resources held by this `AppState` instance so the caller can - /// drop it. Currently only cancels `StoreService`'s `Transaction.updates` listener Task, - /// which otherwise self-retains via the `for await` loop and leaks across an `MC1App` BFU - /// reassignment of `appState`. Connection-layer teardown stays on `ConnectionManager`'s own - /// disconnect path and is intentionally not chained here. - func shutdown() { - storeState.service.shutdown() - } - - // MARK: - Lifecycle - - /// Initialize on app launch - func initialize() async { - // Decide What's New before activate() can drive any connection alert. The - // value of `hasCompletedOnboarding` read here is the "was onboarded at - // launch" signal that distinguishes a brand-new install from an upgrader. - #if DEBUG - let isScreenshotMode = ProcessInfo.processInfo.isScreenshotMode - #else - let isScreenshotMode = false - #endif - whatsNew.evaluate( - isOnboarded: onboarding.hasCompletedOnboarding, - isScreenshotMode: isScreenshotMode - ) + // Wire background auth-failure callback - surfaces guided pairing-failure + // recovery when an opportunistic reconnect finds the bond invalidated, but + // only while active so a backgrounded failure can't latch a stale alert. + connectionManager.onAuthenticationFailure = { [weak self] deviceID in + self?.handleAuthenticationFailure( + deviceID: deviceID, + isAppActive: UIApplication.shared.applicationState == .active + ) + } - // Recover any existing Live Activity before activate() so that onConnectionReady - // (which fires during activate) finds currentActivity populated and can update it. - await liveActivityManager.recoverExistingActivity() - liveActivityManager.startObservingEnablement() - await connectionManager.activate() - // Check if disconnected pill should show (for fresh launch after termination) - connectionUI.updateDisconnectedPillState( - connectionState: connectionState, - lastConnectedDeviceID: connectionManager.lastConnectedDeviceID, - shouldSuppressDisconnectedPill: connectionManager.shouldSuppressDisconnectedPill - ) + // Wire device synced callback - runs after sync completes and state is .ready + connectionManager.onDeviceSynced = { [weak self] in + self?.performStaleNodeCleanup() } - /// Per-session teardown shared by the connection-loss path and explicit - /// disconnect, which does not fire onConnectionLost. Cancels the event tasks - /// and releases the per-connection coordinators so a suspended task or a - /// torn-down store reference cannot survive into the next session. - func tearDownAppStateSessionState() { - settingsEventsTask?.cancel() - settingsEventsTask = nil - syncDataEventsTask?.cancel() - syncDataEventsTask = nil - advertisementEventsTask?.cancel() - advertisementEventsTask = nil - rxLogEventsTask?.cancel() - rxLogEventsTask = nil - messageEventDispatcher.cancelAll() - chatCoordinatorRegistry?.tearDown() - chatCoordinatorRegistry = nil - navigation.clearPendingLinks() - } - - /// Wire services-dependent callbacks after a successful connection. - func wireServicesIfConnected() async { - guard let services else { - tearDownAppStateSessionState() - syncCoordinator = nil - connectionUI.handleDisconnect( - connectionState: connectionState, - lastConnectedDeviceID: connectionManager.lastConnectedDeviceID, - shouldSuppressDisconnectedPill: connectionManager.shouldSuppressDisconnectedPill - ) - cliToolViewModel?.reset() - batteryMonitor.stop() - batteryMonitor.clearThresholds() - await liveActivityManager.handleConnectionLost() - lastBumpedServicesID = nil - return - } + loadPersistedRegionSelection() - // Wire ConnectionUI callbacks (sync activity, node storage, pills, VoiceOver) - // Must be set before onConnectionEstablished to avoid a race condition - await connectionUI.wireCallbacks( - syncCoordinator: services.syncCoordinator, - advertisementService: services.advertisementService, - contactService: services.contactService, - connectionManager: connectionManager + // The path suggestion drives real navigation; the placeholder has no scene to navigate, + // so skip the work and the strong self-capture it would keep alive. + if !isPlaceholder { + Task { [regionAlreadySet = regionSelection != nil] in + let suggested = await onboarding.suggestedStartingPath( + connectionManager: connectionManager, + locationAuthorizationStatus: locationService.authorizationStatus, + regionAlreadySet: regionAlreadySet ) - - // On a device switch onConnectionLost doesn't fire, so the disconnect teardown that - // resets per-radio UI state is skipped. Reset the CLI and every per-radio detail selection - // here so neither carries the previous radio's state into the new session. - if let newDeviceID = connectedDevice?.id, - let oldDeviceID = lastConnectedDeviceIDForCLI, - newDeviceID != oldDeviceID { - cliToolViewModel?.reset() - navigation.clearPerRadioSelection() + if !suggested.isEmpty { + onboarding.onboardingPath = suggested } - lastConnectedDeviceIDForCLI = connectedDevice?.id - - // Store syncCoordinator reference - syncCoordinator = services.syncCoordinator + } + } + } + + /// Releases process-scoped resources held by this `AppState` instance so the caller can + /// drop it. Currently only cancels `StoreService`'s `Transaction.updates` listener Task, + /// which otherwise self-retains via the `for await` loop and leaks across an `MC1App` BFU + /// reassignment of `appState`. Connection-layer teardown stays on `ConnectionManager`'s own + /// disconnect path and is intentionally not chained here. + func shutdown() { + storeState.service.shutdown() + } + + // MARK: - Lifecycle + + /// Initialize on app launch + func initialize() async { + // Decide What's New before activate() can drive any connection alert. The + // value of `hasCompletedOnboarding` read here is the "was onboarded at + // launch" signal that distinguishes a brand-new install from an upgrader. + #if DEBUG + let isScreenshotMode = ProcessInfo.processInfo.isScreenshotMode + #else + let isScreenshotMode = false + #endif + whatsNew.evaluate( + isOnboarded: onboarding.hasCompletedOnboarding, + isScreenshotMode: isScreenshotMode + ) - // Process-wide inline image cache learns where to persist probed dims. - await InlineImageCache.shared.attachDimensionsStore(services.inlineImageDimensionsStore) + // Recover any existing Live Activity before activate() so that onConnectionReady + // (which fires during activate) finds currentActivity populated and can update it. + await liveActivityManager.recoverExistingActivity() + liveActivityManager.startObservingEnablement() + await connectionManager.activate() + // Check if disconnected pill should show (for fresh launch after termination) + connectionUI.updateDisconnectedPillState( + connectionState: connectionState, + lastConnectedDeviceID: connectionManager.lastConnectedDeviceID, + shouldSuppressDisconnectedPill: connectionManager.shouldSuppressDisconnectedPill + ) + } + + /// Per-session teardown shared by the connection-loss path and explicit + /// disconnect, which does not fire onConnectionLost. Cancels the event tasks + /// and releases the per-connection coordinators so a suspended task or a + /// torn-down store reference cannot survive into the next session. + func tearDownAppStateSessionState() { + settingsEventsTask?.cancel() + settingsEventsTask = nil + syncDataEventsTask?.cancel() + syncDataEventsTask = nil + advertisementEventsTask?.cancel() + advertisementEventsTask = nil + rxLogEventsTask?.cancel() + rxLogEventsTask = nil + messageEventDispatcher.cancelAll() + chatCoordinatorRegistry?.tearDown() + chatCoordinatorRegistry = nil + navigation.clearPendingLinks() + } + + /// Presents the guided pairing-failure recovery for an invalidated bond, only + /// while the app is active: a backgrounded failure must not latch a stale alert + /// for the next foreground, where the reconnect re-surfaces it fresh if still bad. + func handleAuthenticationFailure(deviceID: UUID, isAppActive: Bool) { + guard isAppActive else { return } + connectionUI.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.authenticationFailed)) + } + + /// Wire services-dependent callbacks after a successful connection. + func wireServicesIfConnected() async { + guard let services else { + tearDownAppStateSessionState() + syncCoordinator = nil + connectionUI.handleDisconnect( + connectionState: connectionState, + lastConnectedDeviceID: connectionManager.lastConnectedDeviceID, + shouldSuppressDisconnectedPill: connectionManager.shouldSuppressDisconnectedPill + ) + cliToolViewModel?.reset() + batteryMonitor.stop() + batteryMonitor.clearThresholds() + await liveActivityManager.handleConnectionLost() + lastBumpedServicesID = nil + return + } - // Demo mode ships an inline image whose bytes are embedded offline; pre-seed the - // process-wide cache so the seeded DM renders it without a network fetch. - if connectedDevice?.id == MockDataProvider.simulatorDeviceID { - DemoInlineImageSeeder.seed() - } + // The link is up, so drop any pairing-failure alert latched while + // backgrounded before it can present over a working connection. + connectionUI.clearPairingFailure() + + // Wire ConnectionUI callbacks (sync activity, node storage, pills, VoiceOver) + // Must be set before onConnectionEstablished to avoid a race condition + await connectionUI.wireCallbacks( + syncCoordinator: services.syncCoordinator, + advertisementService: services.advertisementService, + contactService: services.contactService, + connectionManager: connectionManager + ) - if let existing = chatCoordinatorRegistry { - existing.rebind(dataStore: services.dataStore) - } else { - chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: services.dataStore) - } + // On a device switch onConnectionLost doesn't fire, so the disconnect teardown that + // resets per-radio UI state is skipped. Reset the CLI and every per-radio detail selection + // here so neither carries the previous radio's state into the new session. + if let newDeviceID = connectedDevice?.id, + let oldDeviceID = lastConnectedDeviceIDForCLI, + newDeviceID != oldDeviceID { + cliToolViewModel?.reset() + navigation.clearPerRadioSelection() + } + lastConnectedDeviceIDForCLI = connectedDevice?.id - wireSyncDataEvents(services: services) - await wireSettingsEventStream(services: services) - await wireDeviceUpdateCallbacks(services: services) - wireMessageEvents(services: services) - await wireLiveActivityCallbacks(services: services) - - // Drop drafts for channel slots vacated by a delete or sync prune so a - // reused slot can't surface the prior channel's draft. - await services.channelService.setDraftClearHandler { [weak self] radioID, indices in - await MainActor.run { - self?.draftStore.clearChannelDrafts(radioID: radioID, indices: indices) - } - } + // Store syncCoordinator reference + syncCoordinator = services.syncCoordinator - // Bump the version (which drives `.task(id:)` reloads in chat, tools, and - // room views) only when the services container actually changed. A single - // connect both fires `onConnectionReady` and is followed by an explicit - // `wireServicesIfConnected()` call, and the Live Activity toggle re-wires the - // same container — none of those are a services change, so they must not - // trigger a reload. - let servicesID = ObjectIdentifier(services) - if lastBumpedServicesID != servicesID { - lastBumpedServicesID = servicesID - servicesVersion += 1 - - // A real services change is the one moment the addressable contact/channel - // set can differ from what saved shortcuts were resolved against, so re-resolve - // the App Intents parameter queries here (debounced by this same guard, and past - // the connection-lost early return so it never fires on disconnect). - refreshAppShortcutParameters() - } + // Process-wide inline image cache learns where to persist probed dims. + await InlineImageCache.shared.attachDimensionsStore(services.inlineImageDimensionsStore) - // Set up notification center delegate, wire localized strings, then register categories - UNUserNotificationCenter.current().delegate = services.notificationService - services.notificationService.setStringProvider(NotificationStringProviderImpl()) - await services.notificationService.setup() - - // Configure badge count callback - services.notificationService.getBadgeCount = { [weak self, dataStore = services.dataStore] in - let radioID = await MainActor.run { self?.currentRadioID } - guard let radioID else { - return (contacts: 0, channels: 0, rooms: 0) - } - do { - return try await dataStore.getTotalUnreadCounts(radioID: radioID) - } catch { - return (contacts: 0, channels: 0, rooms: 0) - } - } + // Demo mode ships an inline image whose bytes are embedded offline; pre-seed the + // process-wide cache so the seeded DM renders it without a network fetch. + if connectedDevice?.id == MockDataProvider.simulatorDeviceID { + DemoInlineImageSeeder.seed() + } - // Seed the badge from persisted unread messages now that the callback is wired; otherwise - // badgeCount stays 0 until a message arrives or a chat opens and recomputes it. - await services.notificationService.updateBadgeCount() + if let existing = chatCoordinatorRegistry { + existing.rebind(dataStore: services.dataStore) + } else { + chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: services.dataStore) + } - // Configure notification interaction handlers - configureNotificationHandlers() + wireSyncDataEvents(services: services) + await wireSettingsEventStream(services: services) + await wireDeviceUpdateCallbacks(services: services) + wireMessageEvents(services: services) + await wireLiveActivityCallbacks(services: services) + + // Drop drafts for channel slots vacated by a delete or sync prune so a + // reused slot can't surface the prior channel's draft. + await services.channelService.setDraftClearHandler { [weak self] radioID, indices in + await MainActor.run { + self?.draftStore.clearChannelDrafts(radioID: radioID, indices: indices) + } + } - // Defer battery bootstrap so connection setup is not blocked by device request timeouts. - batteryMonitor.start(services: services, device: connectedDevice) + // Bump the version (which drives `.task(id:)` reloads in chat, tools, and + // room views) only when the services container actually changed. A single + // connect both fires `onConnectionReady` and is followed by an explicit + // `wireServicesIfConnected()` call, and the Live Activity toggle re-wires the + // same container — none of those are a services change, so they must not + // trigger a reload. + let servicesID = ObjectIdentifier(services) + if lastBumpedServicesID != servicesID { + lastBumpedServicesID = servicesID + servicesVersion += 1 + + // A real services change is the one moment the addressable contact/channel + // set can differ from what saved shortcuts were resolved against, so re-resolve + // the App Intents parameter queries here (debounced by this same guard, and past + // the connection-lost early return so it never fires on disconnect). + refreshAppShortcutParameters() + } + // Set up notification center delegate, wire localized strings, then register categories + UNUserNotificationCenter.current().delegate = services.notificationService + services.notificationService.setStringProvider(NotificationStringProviderImpl()) + await services.notificationService.setup() + + // Configure badge count callback + services.notificationService.getBadgeCount = { [weak self, dataStore = services.dataStore] in + let radioID = await MainActor.run { self?.currentRadioID } + guard let radioID else { + return (contacts: 0, channels: 0, rooms: 0) + } + do { + return try await dataStore.getTotalUnreadCounts(radioID: radioID) + } catch { + return (contacts: 0, channels: 0, rooms: 0) + } } - // MARK: - Stale Node Cleanup + // Seed the badge from persisted unread messages now that the callback is wired; otherwise + // badgeCount stays 0 until a message arrives or a chat opens and recomputes it. + await services.notificationService.updateBadgeCount() - /// Runs automatic cleanup of stale non-favorite nodes if the threshold is configured. - /// - Parameter force: When `true`, skips the 6-hour cooldown (used when the user changes the setting). - func performStaleNodeCleanup(force: Bool = false) { - let threshold = UserDefaults.standard.integer(forKey: AppStorageKey.autoDeleteStaleNodesDays.rawValue) - guard threshold > 0 else { return } + // Configure notification interaction handlers + configureNotificationHandlers() - if !force { - let lastRunTimestamp = UserDefaults.standard.double(forKey: AppStorageKey.lastStaleCleanupDate.rawValue) - let lastRun = lastRunTimestamp > 0 ? Date(timeIntervalSinceReferenceDate: lastRunTimestamp) : Date.distantPast - guard Date().timeIntervalSince(lastRun) >= 3 * 3600 else { - logger.debug("Stale node cleanup skipped — cooldown not expired") - return - } - } + // Defer battery bootstrap so connection setup is not blocked by device request timeouts. + batteryMonitor.start(services: services, device: connectedDevice) + } - Task { - do { - let result = try await connectionManager.removeStaleNodes(olderThanDays: threshold) - UserDefaults.standard.set(Date().timeIntervalSinceReferenceDate, forKey: AppStorageKey.lastStaleCleanupDate.rawValue) - if result.total > 0 { - logger.info("Stale node cleanup: removed \(result.removed) of \(result.total) nodes older than \(threshold) days") - } else { - logger.debug("Stale node cleanup: no stale nodes found") - } - } catch { - logger.warning("Stale node cleanup failed: \(error.localizedDescription)") - } - } - } + // MARK: - Stale Node Cleanup - // MARK: - Onboarding + /// Runs automatic cleanup of stale non-favorite nodes if the threshold is configured. + /// - Parameter force: When `true`, skips the 6-hour cooldown (used when the user changes the setting). + func performStaleNodeCleanup(force: Bool = false) { + let threshold = UserDefaults.standard.integer(forKey: AppStorageKey.autoDeleteStaleNodesDays.rawValue) + guard threshold > 0 else { return } - func completeOnboarding() { - onboarding.completeOnboarding() - Task { - try? await Task.sleep(for: .seconds(1.5)) - await donateDeviceMenuTipIfOnValidTab() - } + if !force { + let lastRunTimestamp = UserDefaults.standard.double(forKey: AppStorageKey.lastStaleCleanupDate.rawValue) + let lastRun = lastRunTimestamp > 0 ? Date(timeIntervalSinceReferenceDate: lastRunTimestamp) : Date.distantPast + guard Date().timeIntervalSince(lastRun) >= 3 * 3600 else { + logger.debug("Stale node cleanup skipped — cooldown not expired") + return + } } - /// Donates the tip if on a valid tab, otherwise marks it pending. - /// Thin coordinator that reads from both navigation and onboarding concerns. - func donateDeviceMenuTipIfOnValidTab() async { - if navigation.isOnValidTabForDeviceMenuTip { - navigation.pendingDeviceMenuTipDonation = false - await DeviceMenuTip.hasCompletedOnboarding.donate() + Task { + do { + let result = try await connectionManager.removeStaleNodes(olderThanDays: threshold) + UserDefaults.standard.set(Date().timeIntervalSinceReferenceDate, forKey: AppStorageKey.lastStaleCleanupDate.rawValue) + if result.total > 0 { + logger.info("Stale node cleanup: removed \(result.removed) of \(result.total) nodes older than \(threshold) days") } else { - navigation.pendingDeviceMenuTipDonation = true + logger.debug("Stale node cleanup: no stale nodes found") } + } catch { + logger.warning("Stale node cleanup failed: \(error.localizedDescription)") + } } + } - /// Donates the tip unconditionally. Used on iPad where the radio is always - /// visible in the sidebar regardless of which section is selected. - func donateDeviceMenuTip() async { - navigation.pendingDeviceMenuTipDonation = false - await DeviceMenuTip.hasCompletedOnboarding.donate() + // MARK: - Onboarding + + func completeOnboarding() { + onboarding.completeOnboarding() + Task { + try? await Task.sleep(for: .seconds(1.5)) + await donateDeviceMenuTipIfOnValidTab() + } + } + + /// Donates the tip if on a valid tab, otherwise marks it pending. + /// Thin coordinator that reads from both navigation and onboarding concerns. + func donateDeviceMenuTipIfOnValidTab() async { + if navigation.isOnValidTabForDeviceMenuTip { + navigation.pendingDeviceMenuTipDonation = false + await DeviceMenuTip.hasCompletedOnboarding.donate() + } else { + navigation.pendingDeviceMenuTipDonation = true } + } + + /// Donates the tip unconditionally. Used on iPad where the radio is always + /// visible in the sidebar regardless of which section is selected. + func donateDeviceMenuTip() async { + navigation.pendingDeviceMenuTipDonation = false + await DeviceMenuTip.hasCompletedOnboarding.donate() + } -#if DEBUG + #if DEBUG /// Test helper: Overrides BLE lifecycle operations for deterministic ordering tests. func setBLELifecycleOverridesForTesting( - enterBackground: (@MainActor () async -> Void)? = nil, - becomeActive: (@MainActor () async -> Void)? = nil + enterBackground: (@MainActor () async -> Void)? = nil, + becomeActive: (@MainActor () async -> Void)? = nil ) { - bleEnterBackgroundOverride = enterBackground - bleBecomeActiveOverride = becomeActive + bleEnterBackgroundOverride = enterBackground + bleBecomeActiveOverride = becomeActive } -#endif + #endif } // MARK: - Preview Support extension AppState { - /// Creates an AppState for previews using an in-memory container - @MainActor - convenience init() { - let schema = Schema([ - Device.self, - Contact.self, - Message.self, - Channel.self, - RemoteNodeSession.self, - RoomMessage.self - ]) - let config = ModelConfiguration(isStoredInMemoryOnly: true) - // swiftlint:disable:next force_try - let container = try! ModelContainer(for: schema, configurations: [config]) - self.init(modelContainer: container) - } + /// Creates an AppState for previews using an in-memory container + @MainActor + convenience init() { + self.init(modelContainer: Self.makeInMemoryContainer()) + } + + /// In-memory container over the canonical `PersistenceStore.schema`, shared by preview and + /// placeholder instances. Built directly rather than via `createContainer(inMemory:)`, which + /// interns its container and would share one store across otherwise independent instances. + private static func makeInMemoryContainer() -> ModelContainer { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + // swiftlint:disable:next force_try + return try! ModelContainer(for: PersistenceStore.schema, configurations: [config]) + } + + /// Shared inert stand-in backing the `appState` environment default. Built once and never + /// wired to a connection: its services are nil, and it publishes no process-global state. + static let placeholder = makePlaceholder() + + private static func makePlaceholder() -> AppState { + let state = AppState(modelContainer: makeInMemoryContainer(), isPlaceholder: true) + // Cancel the transaction listener the store service starts in init; the placeholder never + // observes purchases, and the listener would otherwise self-retain for the process lifetime. + state.shutdown() + return state + } } -// MARK: - Environment Key - -/// Environment key for AppState with safe default for background snapshot scenarios. -/// MainActor.assumeIsolated asserts we're on the main actor, which is always true -/// for SwiftUI environment access in views. +// A hand-written key rather than `@Entry`: the macro expands `defaultValue` into a computed +// property, which for a class type SwiftUI flags as reallocating on every read. A stored +// `static let` returns the one shared placeholder and silences that diagnostic. +// swiftformat:disable environmentEntry private struct AppStateKey: EnvironmentKey { - static var defaultValue: AppState { - MainActor.assumeIsolated { - AppState() - } - } + static let defaultValue = MainActor.assumeIsolated { AppState.placeholder } } extension EnvironmentValues { - /// AppState environment value with safe default for background snapshot scenarios. - /// Having a default value ensures a value is always available, preventing crashes when - /// iOS takes app switcher snapshots or launches the app in background. - var appState: AppState { - get { self[AppStateKey.self] } - set { self[AppStateKey.self] = newValue } - } + /// Inert stand-in returned when a view reads `appState` outside a configured scene, such as + /// an app-switcher snapshot. `AppState.placeholder` is shared and never wired to a connection. + var appState: AppState { + get { self[AppStateKey.self] } + set { self[AppStateKey.self] = newValue } + } } + +// swiftformat:enable environmentEntry diff --git a/MC1/State/AppTab.swift b/MC1/State/AppTab.swift index cfe8b43b..58dca95e 100644 --- a/MC1/State/AppTab.swift +++ b/MC1/State/AppTab.swift @@ -4,9 +4,9 @@ /// `NavigationCoordinator.selectedTab` remains `Int` (see the deferred /// Int-to-AppTab migration). enum AppTab: Int { - case chats - case nodes - case map - case tools - case settings + case chats + case nodes + case map + case tools + case settings } diff --git a/MC1/State/BatteryMonitor.swift b/MC1/State/BatteryMonitor.swift index dea7ad74..65ee464b 100644 --- a/MC1/State/BatteryMonitor.swift +++ b/MC1/State/BatteryMonitor.swift @@ -7,200 +7,199 @@ import OSLog @Observable @MainActor final class BatteryMonitor { + private let logger = Logger(subsystem: "com.mc1", category: "BatteryMonitor") - private let logger = Logger(subsystem: "com.mc1", category: "BatteryMonitor") + /// Current device battery info (nil if not fetched or disconnected) + var deviceBattery: BatteryInfo? - /// Current device battery info (nil if not fetched or disconnected) - var deviceBattery: BatteryInfo? + /// Task for initial battery fetch on connect (cancelled on disconnect) + private var bootstrapTask: Task? - /// Task for initial battery fetch on connect (cancelled on disconnect) - private var bootstrapTask: Task? + /// Task for periodic battery refresh (cancelled on disconnect/background) + private var batteryRefreshTask: Task? - /// Task for periodic battery refresh (cancelled on disconnect/background) - private var batteryRefreshTask: Task? + /// Thresholds that have already triggered a notification this session + private var notifiedBatteryThresholds: Set = [] - /// Thresholds that have already triggered a notification this session - private var notifiedBatteryThresholds: Set = [] + /// Tracks last successful battery fetch for background piggybacking + private var lastSuccessfulFetchDate: Date? - /// Tracks last successful battery fetch for background piggybacking - private var lastSuccessfulFetchDate: Date? + /// Battery warning threshold levels (percentage) + private let batteryWarningThresholds = [20, 10, 5] - /// Battery warning threshold levels (percentage) - private let batteryWarningThresholds = [20, 10, 5] + /// Called when battery info is updated, for Live Activity + var onBatteryChanged: ((_ battery: BatteryInfo) -> Void)? - /// Called when battery info is updated, for Live Activity - var onBatteryChanged: ((_ battery: BatteryInfo) -> Void)? + /// The active OCV array for the connected device + func activeBatteryOCVArray(for device: DeviceDTO?) -> [Int] { + device?.activeOCVArray ?? OCVPreset.liIon.ocvArray + } - /// The active OCV array for the connected device - func activeBatteryOCVArray(for device: DeviceDTO?) -> [Int] { - device?.activeOCVArray ?? OCVPreset.liIon.ocvArray - } - - // MARK: - Public API + // MARK: - Public API - /// Fetch device battery level on demand - func fetchDeviceBattery(services: ServiceContainer?, device: DeviceDTO?) async { - guard let settingsService = services?.settingsService else { return } + /// Fetch device battery level on demand + func fetchDeviceBattery(services: ServiceContainer?, device: DeviceDTO?) async { + guard let settingsService = services?.settingsService else { return } - do { - deviceBattery = try await settingsService.getBattery() - lastSuccessfulFetchDate = .now - if let battery = deviceBattery { - onBatteryChanged?(battery) - } - await checkBatteryThresholds(device: device, services: services) - } catch { - deviceBattery = nil - } + do { + deviceBattery = try await settingsService.getBattery() + lastSuccessfulFetchDate = .now + if let battery = deviceBattery { + onBatteryChanged?(battery) + } + await checkBatteryThresholds(device: device, services: services) + } catch { + deviceBattery = nil } + } + + private static let backgroundBatteryInterval: TimeInterval = 900 + + /// Fetch battery only if enough time has passed since last successful fetch. + /// Called from packet reception handler to piggyback on active BLE wake-ups. + func fetchBatteryIfOverdue(services: ServiceContainer?, device: DeviceDTO?) async { + if let last = lastSuccessfulFetchDate, + Date.now.timeIntervalSince(last) < Self.backgroundBatteryInterval { return } + await fetchDeviceBattery(services: services, device: device) + } + + /// Start battery monitoring for a newly connected device. + /// Defers bootstrap so connection setup is not blocked by device request timeouts. + func start(services: ServiceContainer, device: DeviceDTO?) { + bootstrapTask = Task { @MainActor [weak self] in + guard let self else { return } + + do { + deviceBattery = try await services.settingsService.getBattery() + lastSuccessfulFetchDate = .now + if let battery = deviceBattery { + onBatteryChanged?(battery) + } + } catch { + logger.debug("Deferred battery bootstrap failed: \(error.localizedDescription, privacy: .public)") + deviceBattery = nil + } - private static let backgroundBatteryInterval: TimeInterval = 900 + guard !Task.isCancelled else { return } - /// Fetch battery only if enough time has passed since last successful fetch. - /// Called from packet reception handler to piggyback on active BLE wake-ups. - func fetchBatteryIfOverdue(services: ServiceContainer?, device: DeviceDTO?) async { - if let last = lastSuccessfulFetchDate, - Date.now.timeIntervalSince(last) < Self.backgroundBatteryInterval { return } - await fetchDeviceBattery(services: services, device: device) + await initializeBatteryThresholds(device: device, services: services) + startRefreshLoop(services: services, device: device) } - - /// Start battery monitoring for a newly connected device. - /// Defers bootstrap so connection setup is not blocked by device request timeouts. - func start(services: ServiceContainer, device: DeviceDTO?) { - bootstrapTask = Task { @MainActor [weak self] in - guard let self else { return } - - do { - self.deviceBattery = try await services.settingsService.getBattery() - self.lastSuccessfulFetchDate = .now - if let battery = self.deviceBattery { - self.onBatteryChanged?(battery) - } - } catch { - self.logger.debug("Deferred battery bootstrap failed: \(error.localizedDescription, privacy: .public)") - self.deviceBattery = nil - } - - guard !Task.isCancelled else { return } - - await self.initializeBatteryThresholds(device: device, services: services) - self.startRefreshLoop(services: services, device: device) - } + } + + /// Stop battery monitoring (disconnect or background) + func stop() { + bootstrapTask?.cancel() + bootstrapTask = nil + batteryRefreshTask?.cancel() + batteryRefreshTask = nil + } + + /// Clear notification thresholds for a fresh connection + func clearThresholds() { + notifiedBatteryThresholds = [] + } + + /// Check for battery thresholds crossed while app was backgrounded. + /// Posts a single notification if any thresholds were missed. + func checkMissedBatteryThreshold(device: DeviceDTO?, services: ServiceContainer?) async { + guard let device, + let settingsService = services?.settingsService, + let notificationService = services?.notificationService else { return } + + do { + deviceBattery = try await settingsService.getBattery() + if let battery = deviceBattery { + onBatteryChanged?(battery) + } + } catch { + return } - /// Stop battery monitoring (disconnect or background) - func stop() { - bootstrapTask?.cancel() - bootstrapTask = nil - batteryRefreshTask?.cancel() - batteryRefreshTask = nil - } + guard let battery = deviceBattery else { return } + guard battery.isBatteryPresent else { return } + let percentage = battery.percentage(using: device.activeOCVArray) - /// Clear notification thresholds for a fresh connection - func clearThresholds() { - notifiedBatteryThresholds = [] + let missedThresholds = batteryWarningThresholds.filter { threshold in + percentage <= threshold && !notifiedBatteryThresholds.contains(threshold) } - /// Check for battery thresholds crossed while app was backgrounded. - /// Posts a single notification if any thresholds were missed. - func checkMissedBatteryThreshold(device: DeviceDTO?, services: ServiceContainer?) async { - guard let device, - let settingsService = services?.settingsService, - let notificationService = services?.notificationService else { return } + guard !missedThresholds.isEmpty else { return } + + for threshold in missedThresholds { + notifiedBatteryThresholds.insert(threshold) + } + await notificationService.postLowBatteryNotification( + deviceName: device.nodeName, + batteryPercentage: percentage + ) + } + + /// Restart the battery refresh loop (e.g., returning to foreground) + func startRefreshLoop(services: ServiceContainer, device: DeviceDTO?) { + batteryRefreshTask?.cancel() + batteryRefreshTask = Task { [weak self] in + while true { do { - deviceBattery = try await settingsService.getBattery() - if let battery = deviceBattery { - onBatteryChanged?(battery) - } + try await Task.sleep(for: .seconds(120)) } catch { - return - } - - guard let battery = deviceBattery else { return } - guard battery.isBatteryPresent else { return } - let percentage = battery.percentage(using: device.activeOCVArray) - - let missedThresholds = batteryWarningThresholds.filter { threshold in - percentage <= threshold && !notifiedBatteryThresholds.contains(threshold) + break } - - guard !missedThresholds.isEmpty else { return } - - for threshold in missedThresholds { - notifiedBatteryThresholds.insert(threshold) - } - - await notificationService.postLowBatteryNotification( - deviceName: device.nodeName, - batteryPercentage: percentage - ) + guard let self else { break } + await fetchDeviceBattery(services: services, device: device) + } + } + } + + /// Initialize battery thresholds based on current level and notify if already below a threshold + private func initializeBatteryThresholds(device: DeviceDTO?, services: ServiceContainer) async { + guard let battery = deviceBattery, + let device else { + notifiedBatteryThresholds = [] + return } - /// Restart the battery refresh loop (e.g., returning to foreground) - func startRefreshLoop(services: ServiceContainer, device: DeviceDTO?) { - batteryRefreshTask?.cancel() - batteryRefreshTask = Task { [weak self] in - while true { - do { - try await Task.sleep(for: .seconds(120)) - } catch { - break - } - guard let self else { break } - await self.fetchDeviceBattery(services: services, device: device) - } - } + guard battery.isBatteryPresent else { + notifiedBatteryThresholds = [] + return } - /// Initialize battery thresholds based on current level and notify if already below a threshold - private func initializeBatteryThresholds(device: DeviceDTO?, services: ServiceContainer) async { - guard let battery = deviceBattery, - let device else { - notifiedBatteryThresholds = [] - return - } + let percentage = battery.percentage(using: device.activeOCVArray) - guard battery.isBatteryPresent else { - notifiedBatteryThresholds = [] - return - } + let crossedThresholds = batteryWarningThresholds.filter { percentage <= $0 } - let percentage = battery.percentage(using: device.activeOCVArray) + notifiedBatteryThresholds = Set(crossedThresholds) - let crossedThresholds = batteryWarningThresholds.filter { percentage <= $0 } + if !crossedThresholds.isEmpty { + await services.notificationService.postLowBatteryNotification( + deviceName: device.nodeName, + batteryPercentage: percentage + ) + } + } - notifiedBatteryThresholds = Set(crossedThresholds) + /// Check battery level against thresholds and send notifications + private func checkBatteryThresholds(device: DeviceDTO?, services: ServiceContainer?) async { + guard let battery = deviceBattery, + let device, + let notificationService = services?.notificationService else { return } - if !crossedThresholds.isEmpty { - await services.notificationService.postLowBatteryNotification( - deviceName: device.nodeName, - batteryPercentage: percentage - ) - } - } + guard battery.isBatteryPresent else { return } - /// Check battery level against thresholds and send notifications - private func checkBatteryThresholds(device: DeviceDTO?, services: ServiceContainer?) async { - guard let battery = deviceBattery, - let device, - let notificationService = services?.notificationService else { return } - - guard battery.isBatteryPresent else { return } - - let percentage = battery.percentage(using: device.activeOCVArray) - - for threshold in batteryWarningThresholds { - if percentage <= threshold && !notifiedBatteryThresholds.contains(threshold) { - notifiedBatteryThresholds.insert(threshold) - await notificationService.postLowBatteryNotification( - deviceName: device.nodeName, - batteryPercentage: percentage - ) - break - } else if percentage > threshold && notifiedBatteryThresholds.contains(threshold) { - notifiedBatteryThresholds.remove(threshold) - } - } + let percentage = battery.percentage(using: device.activeOCVArray) + + for threshold in batteryWarningThresholds { + if percentage <= threshold, !notifiedBatteryThresholds.contains(threshold) { + notifiedBatteryThresholds.insert(threshold) + await notificationService.postLowBatteryNotification( + deviceName: device.nodeName, + batteryPercentage: percentage + ) + break + } else if percentage > threshold, notifiedBatteryThresholds.contains(threshold) { + notifiedBatteryThresholds.remove(threshold) + } } + } } diff --git a/MC1/State/ConnectionUIState.swift b/MC1/State/ConnectionUIState.swift index 9f47ae21..6f642960 100644 --- a/MC1/State/ConnectionUIState.swift +++ b/MC1/State/ConnectionUIState.swift @@ -6,325 +6,372 @@ import MC1Services @Observable @MainActor final class ConnectionUIState { + // MARK: - Ready Toast - // MARK: - Ready Toast + /// Whether the "Ready" toast pill is visible (shown briefly after connection completes) + private(set) var showReadyToast = false - /// Whether the "Ready" toast pill is visible (shown briefly after connection completes) - private(set) var showReadyToast = false + /// Task managing the ready toast visibility timer + private var readyToastTask: Task? - /// Task managing the ready toast visibility timer - private var readyToastTask: Task? + // MARK: - Sync Failed Pill - // MARK: - Sync Failed Pill + /// Whether the "Sync Failed" pill is visible + private(set) var syncFailedPillVisible = false - /// Whether the "Sync Failed" pill is visible - private(set) var syncFailedPillVisible = false + /// Task managing the pill visibility timer + private var syncFailedPillTask: Task? - /// Task managing the pill visibility timer - private var syncFailedPillTask: Task? + // MARK: - Disconnected Pill - // MARK: - Disconnected Pill + /// Whether the "Disconnected" pill is visible (shown after 1s delay) + private(set) var disconnectedPillVisible = false - /// Whether the "Disconnected" pill is visible (shown after 1s delay) - private(set) var disconnectedPillVisible = false + /// Task managing the disconnected pill delay + private var disconnectedPillTask: Task? - /// Task managing the disconnected pill delay - private var disconnectedPillTask: Task? + // MARK: - Sync Activity - // MARK: - Sync Activity + /// Counter for sync/settings operations (on-demand) - shows pill + var syncActivityCount: Int = 0 - /// Counter for sync/settings operations (on-demand) - shows pill - var syncActivityCount: Int = 0 + /// Current sync phase reported by SyncCoordinator callbacks. + /// Used to defer non-essential settings reads during connect/sync. + var currentSyncPhase: SyncPhase? - /// Current sync phase reported by SyncCoordinator callbacks. - /// Used to defer non-essential settings reads during connect/sync. - var currentSyncPhase: SyncPhase? + // MARK: - Connection Alerts & Pairing - // MARK: - Connection Alerts & Pairing + /// Whether to show connection failure alert + var showingConnectionFailedAlert = false - /// Whether to show connection failure alert - var showingConnectionFailedAlert = false + /// Message for connection failure alert + var connectionFailedMessage: String? - /// Message for connection failure alert - var connectionFailedMessage: String? + /// Optional override for the connection-failed alert title. nil falls back + /// to L10n.Localizable.Alert.ConnectionFailed.title ("Connection Failed"). + var connectionFailedTitle: String? - /// Optional override for the connection-failed alert title. nil falls back - /// to L10n.Localizable.Alert.ConnectionFailed.title ("Connection Failed"). - var connectionFailedTitle: String? + /// Variant of the pairing-failure alert when `failedPairingDeviceID` is set. + /// Drives action-button selection in `ContentView` so the discriminant is an + /// explicit semantic signal rather than a "title text happens to be non-nil" + /// heuristic. A future caller that sets a title for non-auth reasons can't + /// silently flip the user from a non-destructive Try Again into a destructive + /// Remove and Try Again — that mistake destroys a working bond. + var pairingFailureKind: PairingFailureKind? - /// Variant of the pairing-failure alert when `failedPairingDeviceID` is set. - /// Drives action-button selection in `ContentView` so the discriminant is an - /// explicit semantic signal rather than a "title text happens to be non-nil" - /// heuristic. A future caller that sets a title for non-auth reasons can't - /// silently flip the user from a non-destructive Try Again into a destructive - /// Remove and Try Again — that mistake destroys a working bond. - var pairingFailureKind: PairingFailureKind? + /// Device ID that failed pairing (wrong PIN) - for recovery UI + var failedPairingDeviceID: UUID? - /// Device ID that failed pairing (wrong PIN) - for recovery UI - var failedPairingDeviceID: UUID? + /// Device ID that triggered "connected to other app" warning - alert shown when non-nil + var otherAppWarningDeviceID: UUID? - /// Device ID that triggered "connected to other app" warning - alert shown when non-nil - var otherAppWarningDeviceID: UUID? + /// Whether any user-initiated connection attempt is in flight — pairing + /// (`AppState.startDeviceScan`), the transient-failure retry path + /// (`AppState.retryFailedPairingConnect`), or simulator connect. Drives + /// spinners and disabled buttons across pairing and retry flows. Distinct from + /// `ConnectionManager.isPairingInProgress`, which is narrowly scoped to the + /// `pairNewDevice` flow and is consulted by the BLE-layer reconnect gate. + var isBusy = false - /// Whether any user-initiated connection attempt is in flight — pairing - /// (`AppState.startDeviceScan`), the transient-failure retry path - /// (`AppState.retryFailedPairingConnect`), or simulator connect. Drives - /// spinners and disabled buttons across pairing and retry flows. Distinct from - /// `ConnectionManager.isPairingInProgress`, which is narrowly scoped to the - /// `pairNewDevice` flow and is consulted by the BLE-layer reconnect gate. - var isBusy = false + /// Whether the device's node storage is full (set by 0x90 push, cleared on delete/overwrite) + var isNodeStorageFull = false - /// Whether the device's node storage is full (set by 0x90 push, cleared on delete/overwrite) - var isNodeStorageFull = false + /// Task consuming `AdvertisementService.events()` for storage-full state. + /// Re-subscribed per connection in `wireCallbacks`; cancelled on disconnect. + private var nodeStorageEventsTask: Task? - /// Task consuming `AdvertisementService.events()` for storage-full state. - /// Re-subscribed per connection in `wireCallbacks`; cancelled on disconnect. - private var nodeStorageEventsTask: Task? + /// Task consuming `ContactService.events()` for node-deletion events. + /// Re-subscribed per connection in `wireCallbacks`; cancelled on disconnect. + private var nodeDeletedEventsTask: Task? - /// Task consuming `ContactService.events()` for node-deletion events. - /// Re-subscribed per connection in `wireCallbacks`; cancelled on disconnect. - private var nodeDeletedEventsTask: Task? + /// Flag indicating ASK picker should be shown when app returns to foreground + var shouldShowPickerOnForeground = false - /// Flag indicating ASK picker should be shown when app returns to foreground - var shouldShowPickerOnForeground = false + // MARK: - Ready Toast Methods - // MARK: - Ready Toast Methods + /// Shows "Ready" toast pill for 2 seconds + func showReadyToastBriefly() { + readyToastTask?.cancel() + showReadyToast = true - /// Shows "Ready" toast pill for 2 seconds - func showReadyToastBriefly() { - readyToastTask?.cancel() - showReadyToast = true - - readyToastTask = Task { - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - showReadyToast = false - } + readyToastTask = Task { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + showReadyToast = false } + } - /// Hides the ready toast immediately (called on disconnect) - func hideReadyToast() { - readyToastTask?.cancel() - readyToastTask = nil - showReadyToast = false - } + /// Hides the ready toast immediately (called on disconnect) + func hideReadyToast() { + readyToastTask?.cancel() + readyToastTask = nil + showReadyToast = false + } - // MARK: - Sync Failed Pill Methods + // MARK: - Sync Failed Pill Methods - /// Shows "Sync Failed" pill for 7 seconds with VoiceOver announcement - func showSyncFailedPill() { - syncFailedPillTask?.cancel() - syncFailedPillVisible = true + /// Shows "Sync Failed" pill for 7 seconds with VoiceOver announcement + func showSyncFailedPill() { + syncFailedPillTask?.cancel() + syncFailedPillVisible = true - announceConnectionState(L10n.Localizable.Accessibility.Connection.syncFailedDisconnecting) + announceConnectionState(L10n.Localizable.Accessibility.Connection.syncFailedDisconnecting) - syncFailedPillTask = Task { - try? await Task.sleep(for: .seconds(7)) - guard !Task.isCancelled else { return } - syncFailedPillVisible = false - } + syncFailedPillTask = Task { + try? await Task.sleep(for: .seconds(7)) + guard !Task.isCancelled else { return } + syncFailedPillVisible = false } - - /// Hides the sync failed pill immediately (called when resync succeeds) - func hideSyncFailedPill() { - syncFailedPillTask?.cancel() - syncFailedPillTask = nil - syncFailedPillVisible = false + } + + /// Hides the sync failed pill immediately (called when resync succeeds) + func hideSyncFailedPill() { + syncFailedPillTask?.cancel() + syncFailedPillTask = nil + syncFailedPillVisible = false + } + + // MARK: - Disconnected Pill Methods + + /// Updates disconnected pill visibility based on connection state. + /// Called when connectionState changes or on app launch. + func updateDisconnectedPillState( + connectionState: MC1Services.DeviceConnectionState, + lastConnectedDeviceID: UUID?, + shouldSuppressDisconnectedPill: Bool + ) { + disconnectedPillTask?.cancel() + + guard connectionState == .disconnected, + lastConnectedDeviceID != nil, + !shouldSuppressDisconnectedPill else { + disconnectedPillVisible = false + return } - // MARK: - Disconnected Pill Methods - - /// Updates disconnected pill visibility based on connection state. - /// Called when connectionState changes or on app launch. - func updateDisconnectedPillState( - connectionState: MC1Services.DeviceConnectionState, - lastConnectedDeviceID: UUID?, - shouldSuppressDisconnectedPill: Bool - ) { - disconnectedPillTask?.cancel() - - guard connectionState == .disconnected, - lastConnectedDeviceID != nil, - !shouldSuppressDisconnectedPill else { - disconnectedPillVisible = false - return - } - - disconnectedPillTask = Task { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { return } - disconnectedPillVisible = true - } + disconnectedPillTask = Task { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + disconnectedPillVisible = true } + } - /// Hides disconnected pill immediately (called when connection starts) - func hideDisconnectedPill() { - disconnectedPillTask?.cancel() - disconnectedPillTask = nil - disconnectedPillVisible = false - } + /// Hides disconnected pill immediately (called when connection starts) + func hideDisconnectedPill() { + disconnectedPillTask?.cancel() + disconnectedPillTask = nil + disconnectedPillVisible = false + } - // MARK: - Activity Tracking + // MARK: - Activity Tracking -#if DEBUG + #if DEBUG /// Test helper: Simulates sync activity started callback func simulateSyncStarted() { - syncActivityCount += 1 + syncActivityCount += 1 } /// Test helper: Simulates sync activity ended callback (mirrors actual callback guard logic) func simulateSyncEnded(succeeded: Bool = false) { + guard syncActivityCount > 0 else { return } + syncActivityCount -= 1 + if syncActivityCount == 0, succeeded { + showReadyToastBriefly() + } + } + #endif + + // MARK: - Service Wiring + + /// Resets connection UI state when services become unavailable (disconnect). + func handleDisconnect( + connectionState: MC1Services.DeviceConnectionState, + lastConnectedDeviceID: UUID?, + shouldSuppressDisconnectedPill: Bool + ) { + announceConnectionState(L10n.Localizable.Accessibility.Connection.deviceConnectionLost) + nodeStorageEventsTask?.cancel() + nodeStorageEventsTask = nil + nodeDeletedEventsTask?.cancel() + nodeDeletedEventsTask = nil + syncActivityCount = 0 + currentSyncPhase = nil + hideReadyToast() + isNodeStorageFull = false + updateDisconnectedPillState( + connectionState: connectionState, + lastConnectedDeviceID: lastConnectedDeviceID, + shouldSuppressDisconnectedPill: shouldSuppressDisconnectedPill + ) + } + + /// Wires ConnectionUI-related callbacks on the sync coordinator and services. + func wireCallbacks( + syncCoordinator: SyncCoordinator, + advertisementService: AdvertisementService, + contactService: ContactService, + connectionManager: ConnectionManager + ) async { + hideDisconnectedPill() + + announceConnectionState(L10n.Localizable.Accessibility.Connection.deviceReconnected) + + // Sync activity callbacks for syncing pill display + // These are called for contacts and channels phases, NOT for messages + await syncCoordinator.setSyncActivityCallbacks( + onStarted: { @MainActor [weak self] in + self?.syncActivityCount += 1 + }, + onEnded: { @MainActor [weak self] succeeded in + guard let self else { return } + // Guard against double-decrement: onDisconnected and sync error path + // can both call this if WiFi drops or device switch during sync guard syncActivityCount > 0 else { return } syncActivityCount -= 1 - if syncActivityCount == 0 && succeeded { - showReadyToastBriefly() + // Show "Ready" toast only when all sync activity completes successfully + if syncActivityCount == 0, succeeded { + showReadyToastBriefly() } - } -#endif - - // MARK: - Service Wiring - - /// Resets connection UI state when services become unavailable (disconnect). - func handleDisconnect( - connectionState: MC1Services.DeviceConnectionState, - lastConnectedDeviceID: UUID?, - shouldSuppressDisconnectedPill: Bool - ) { - announceConnectionState(L10n.Localizable.Accessibility.Connection.deviceConnectionLost) - nodeStorageEventsTask?.cancel() - nodeStorageEventsTask = nil - nodeDeletedEventsTask?.cancel() - nodeDeletedEventsTask = nil - syncActivityCount = 0 - currentSyncPhase = nil - hideReadyToast() - isNodeStorageFull = false - updateDisconnectedPillState( - connectionState: connectionState, - lastConnectedDeviceID: lastConnectedDeviceID, - shouldSuppressDisconnectedPill: shouldSuppressDisconnectedPill - ) + }, + onPhaseChanged: { @MainActor [weak self] phase in + self?.currentSyncPhase = phase + } + ) + + // Resync failed callback for "Sync Failed" pill + connectionManager.onResyncFailed = { [weak self] in + self?.showSyncFailedPill() } - /// Wires ConnectionUI-related callbacks on the sync coordinator and services. - func wireCallbacks( - syncCoordinator: SyncCoordinator, - advertisementService: AdvertisementService, - contactService: ContactService, - connectionManager: ConnectionManager - ) async { - hideDisconnectedPill() - - announceConnectionState(L10n.Localizable.Accessibility.Connection.deviceReconnected) - - // Sync activity callbacks for syncing pill display - // These are called for contacts and channels phases, NOT for messages - await syncCoordinator.setSyncActivityCallbacks( - onStarted: { @MainActor [weak self] in - self?.syncActivityCount += 1 - }, - onEnded: { @MainActor [weak self] succeeded in - guard let self else { return } - // Guard against double-decrement: onDisconnected and sync error path - // can both call this if WiFi drops or device switch during sync - guard self.syncActivityCount > 0 else { return } - self.syncActivityCount -= 1 - // Show "Ready" toast only when all sync activity completes successfully - if self.syncActivityCount == 0 && succeeded { - self.showReadyToastBriefly() - } - }, - onPhaseChanged: { @MainActor [weak self] phase in - self?.currentSyncPhase = phase - } - ) - - // Resync failed callback for "Sync Failed" pill - connectionManager.onResyncFailed = { [weak self] in - self?.showSyncFailedPill() - } - - // Node storage full events (0x90 contactsFull or 0x8F contactDeleted push). - // Subscribed synchronously so the registration is live before - // onConnectionEstablished can emit storage events. - nodeStorageEventsTask?.cancel() - let advertisementEvents = advertisementService.events() - nodeStorageEventsTask = Task { [weak self] in - for await event in advertisementEvents { - guard let self else { return } - if case .nodeStorageFullChanged(let isFull) = event { - self.isNodeStorageFull = isFull - } - } + // Node storage full events (0x90 contactsFull or 0x8F contactDeleted push). + // Subscribed synchronously so the registration is live before + // onConnectionEstablished can emit storage events. + nodeStorageEventsTask?.cancel() + let advertisementEvents = advertisementService.events() + nodeStorageEventsTask = Task { [weak self] in + for await event in advertisementEvents { + guard let self else { return } + if case let .nodeStorageFullChanged(isFull) = event { + isNodeStorageFull = isFull } + } + } - // Node deleted events clear the storage-full flag when the user manually - // deletes a node. Subscribed synchronously so the registration is live - // before a delete can emit. - nodeDeletedEventsTask?.cancel() - let contactEvents = contactService.events() - nodeDeletedEventsTask = Task { [weak self] in - for await event in contactEvents { - guard let self else { return } - if case .nodeDeleted = event { - self.isNodeStorageFull = false - } - } + // Node deleted events clear the storage-full flag when the user manually + // deletes a node. Subscribed synchronously so the registration is live + // before a delete can emit. + nodeDeletedEventsTask?.cancel() + let contactEvents = contactService.events() + nodeDeletedEventsTask = Task { [weak self] in + for await event in contactEvents { + guard let self else { return } + if case .nodeDeleted = event { + isNodeStorageFull = false } + } } - - // MARK: - Accessibility - - /// Posts a VoiceOver announcement for connection state changes - func announceConnectionState(_ message: String) { - AccessibilityNotification.Announcement(message).post() + } + + // MARK: - Accessibility + + /// Posts a VoiceOver announcement for connection state changes + func announceConnectionState(_ message: String) { + AccessibilityNotification.Announcement(message).post() + } + + // MARK: - Connection Failure Routing + + /// Routes a generic (non-pairing) connection failure. Clears + /// `connectionFailedTitle`, `pairingFailureKind`, and `failedPairingDeviceID` + /// so a prior `presentPairingFailure` can't leak stale state onto an unrelated + /// failure and flip the OK-only alert into the destructive re-pair variant. + func presentConnectionFailure(message: String?) { + connectionFailedTitle = nil + pairingFailureKind = nil + failedPairingDeviceID = nil + connectionFailedMessage = message + showingConnectionFailedAlert = true + } + + /// Routes a failure from a user-initiated connect to an already-paired radio. + /// An authentication failure means the saved bond is dead, so surface the + /// guided re-pair recovery immediately rather than an OK-only alert that + /// leaves the responder with no way forward. + func presentSavedDeviceConnectFailure(deviceID: UUID, error: Error) { + switch error { + case BLEError.deviceConnectedToOtherApp: + otherAppWarningDeviceID = deviceID + case BLEError.authenticationFailed: + presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: error)) + default: + presentConnectionFailure(message: error.userFacingMessage) } - - // MARK: - Connection Failure Routing - - /// Routes a generic (non-pairing) connection failure. Clears - /// `connectionFailedTitle` and `pairingFailureKind` so a prior - /// `presentPairingFailure` can't leak stale state onto an unrelated failure. - func presentConnectionFailure(message: String?) { - connectionFailedTitle = nil - pairingFailureKind = nil - connectionFailedMessage = message - showingConnectionFailedAlert = true + } + + /// Routes a failure from a fresh BLE pairing attempt (a device just chosen in + /// the picker). A rejected PIN carries copy distinct from an established + /// radio's dead bond: it names the PIN and warns that iOS will confirm + /// removing the half-formed pairing on retry. Every other failure shares the + /// standard pairing-failure routing. + func presentFreshPairingFailure(_ error: PairingError) { + guard case let .connectionFailed(deviceID, _) = error, error.isAuthenticationFailure else { + presentPairingFailure(error) + return } - - /// Routes a PairingError to the correct alert so every catch site produces - /// identical UX across the three pairing-failure paths. - func presentPairingFailure(_ error: PairingError) { - switch error { - case .deviceConnectedToOtherApp(let deviceID): - // Routes through the separate "Could Not Connect" alert binding. - otherAppWarningDeviceID = deviceID - - case .connectionFailed(let deviceID, _): - failedPairingDeviceID = deviceID - if error.isAuthenticationFailure { - connectionFailedTitle = L10n.Localizable.Alert.PairingFailed.title - connectionFailedMessage = L10n.Onboarding.DeviceScan.Error.authenticationFailed - pairingFailureKind = .authentication - } else { - connectionFailedTitle = nil - connectionFailedMessage = L10n.Onboarding.DeviceScan.Error.connectionFailed - pairingFailureKind = .transient - } - showingConnectionFailedAlert = true - } + failedPairingDeviceID = deviceID + connectionFailedTitle = L10n.Localizable.Alert.PairingFailed.title + connectionFailedMessage = L10n.Onboarding.DeviceScan.Error.pinRejected + pairingFailureKind = .pinRejected + showingConnectionFailedAlert = true + } + + /// Clears every field of the pairing-failure alert so a stale "Couldn't Pair" + /// cannot present once a connection has succeeded. + func clearPairingFailure() { + showingConnectionFailedAlert = false + connectionFailedTitle = nil + connectionFailedMessage = nil + pairingFailureKind = nil + failedPairingDeviceID = nil + } + + /// Routes a PairingError to the correct alert so every catch site produces + /// identical UX across the three pairing-failure paths. + func presentPairingFailure(_ error: PairingError) { + switch error { + case let .deviceConnectedToOtherApp(deviceID): + // Routes through the separate "Could Not Connect" alert binding. + otherAppWarningDeviceID = deviceID + + case let .connectionFailed(deviceID, _): + failedPairingDeviceID = deviceID + if error.isAuthenticationFailure { + connectionFailedTitle = L10n.Localizable.Alert.PairingFailed.title + connectionFailedMessage = L10n.Onboarding.DeviceScan.Error.authenticationFailed + pairingFailureKind = .authentication + } else { + connectionFailedTitle = nil + connectionFailedMessage = L10n.Onboarding.DeviceScan.Error.connectionFailed + pairingFailureKind = .transient + } + showingConnectionFailedAlert = true } + } } /// Variant of the pairing-failure alert. Determines whether the recovery action /// is destructive (auth: must remove the bond) or non-destructive (transient: /// keep the bond, just retry). -enum PairingFailureKind: Sendable { - /// Authentication failed — bond is bad. Recovery requires removing the bond - /// and re-pairing. - case authentication - - /// Transient connection failure — bond is good. Recovery prefers a plain - /// retry, with destructive remove available as a fallback. - case transient +enum PairingFailureKind { + /// Authentication failed — bond is bad. Recovery requires removing the bond + /// and re-pairing. + case authentication + + /// A fresh pairing attempt was rejected, typically a wrong PIN. Recovery + /// requires removing the half-formed pairing before another attempt. + case pinRejected + + /// Transient connection failure — bond is good. Recovery prefers a plain + /// retry, with destructive remove available as a fallback. + case transient } diff --git a/MC1/State/LiveActivityManager.swift b/MC1/State/LiveActivityManager.swift index ef92a12b..046fd682 100644 --- a/MC1/State/LiveActivityManager.swift +++ b/MC1/State/LiveActivityManager.swift @@ -1,549 +1,571 @@ @preconcurrency import ActivityKit import Foundation +import MC1Services import MeshCore import OSLog -import MC1Services @Observable @MainActor final class LiveActivityManager { - - static let enabledKey = AppStorageKey.liveActivityEnabled.rawValue - - private let logger = Logger(subsystem: "com.mc1", category: "LiveActivityManager") - - /// Returns whether BLE is currently connected at the radio layer. Defaults - /// to `false` so a missing wiring fails closed. - var connectionStateProvider: (@MainActor () -> Bool)? - - private var currentActivity: Activity? - private var decayTimer: Task? - private var disconnectTimer: Task? - private var enablementTask: Task? - private var stateObservationTask: Task? - private var throttleTask: Task? - private var ocvArray: [Int] = [] - private var recentPacketTimestamps: [Date] = [] - private var pendingUpdate: PendingUpdate? - private var lastFlushDate: Date = .distantPast - - static let decayInterval: TimeInterval = 15 - static let packetWindowSeconds: TimeInterval = 60 - static let secondsPerMinute: TimeInterval = 60 - static let connectedStaleInterval: TimeInterval = 30 - static let disconnectGracePeriod: TimeInterval = 300 - static let updateInterval: TimeInterval = 15 - - /// Packets in the trailing `packetWindowSeconds` as a per-minute rate. A - /// full-minute window makes this the true count for that minute, so the - /// displayed value matches the RX Log instead of extrapolating a burst. - private var recentPacketsPerMinute: Int { - Self.packetsPerMinute(timestamps: recentPacketTimestamps, now: .now) + static let enabledKey = AppStorageKey.liveActivityEnabled.rawValue + + private let logger = Logger(subsystem: "com.mc1", category: "LiveActivityManager") + + /// Returns whether BLE is currently connected at the radio layer. Defaults + /// to `false` so a missing wiring fails closed. + var connectionStateProvider: (@MainActor () -> Bool)? + + /// Returns the radioID of the currently connected radio, or `nil` when + /// disconnected. Lets the stale observer restore a connected activity only + /// when the live radio still matches the one the activity represents, so a + /// connection to a different radio can't relabel a stale activity. + var connectedRadioIDProvider: (@MainActor () -> UUID?)? + + private var currentActivity: Activity? + private var decayTimer: Task? + private var disconnectTimer: Task? + private var enablementTask: Task? + private var stateObservationTask: Task? + private var throttleTask: Task? + private var ocvArray: [Int] = [] + private var recentPacketTimestamps: [Date] = [] + private var pendingUpdate: PendingUpdate? + private var lastFlushDate: Date = .distantPast + + static let decayInterval: TimeInterval = 15 + static let packetWindowSeconds: TimeInterval = 60 + static let secondsPerMinute: TimeInterval = 60 + static let connectedStaleInterval: TimeInterval = 30 + static let disconnectGracePeriod: TimeInterval = 300 + static let updateInterval: TimeInterval = 15 + + /// Packets in the trailing `packetWindowSeconds` as a per-minute rate. A + /// full-minute window makes this the true count for that minute, so the + /// displayed value matches the RX Log instead of extrapolating a burst. + private var recentPacketsPerMinute: Int { + Self.packetsPerMinute(timestamps: recentPacketTimestamps, now: .now) + } + + /// Packets within `window` ending at `now` as a per-minute rate. Pure so the + /// rate math is unit-testable without an `Activity`. + static func packetsPerMinute( + timestamps: [Date], + now: Date, + window: TimeInterval = packetWindowSeconds + ) -> Int { + let cutoff = now.addingTimeInterval(-window) + let count = timestamps.count(where: { $0 >= cutoff }) + return Int((Double(count) * secondsPerMinute / window).rounded()) + } + + var hasActiveActivity: Bool { + currentActivity != nil + } + + var isEnabled: Bool { + didSet { UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey) } + } + + init() { + isEnabled = UserDefaults.standard.object(forKey: Self.enabledKey) as? Bool ?? AppStorageKey.defaultLiveActivityEnabled + } + + // MARK: - Pending Update + + private struct PendingUpdate { + var isConnected: Bool? + var battery: Int?? + var packetsPerMinute: Int? + var unreadCount: Int? + var disconnectedDate: Date?? + } + + // MARK: - Lifecycle + + func startObservingEnablement() { + enablementTask?.cancel() + enablementTask = Task { [weak self] in + for await enabled in ActivityAuthorizationInfo().activityEnablementUpdates { + guard let self else { break } + if !enabled { + await endActivity() + } + } } - - /// Packets within `window` ending at `now` as a per-minute rate. Pure so the - /// rate math is unit-testable without an `Activity`. - static func packetsPerMinute( - timestamps: [Date], - now: Date, - window: TimeInterval = packetWindowSeconds - ) -> Int { - let cutoff = now.addingTimeInterval(-window) - let count = timestamps.filter { $0 >= cutoff }.count - return Int((Double(count) * secondsPerMinute / window).rounded()) + } + + func handleConnectionReady( + device: DeviceDTO, + ocvArray: [Int], + unreadCount: Int + ) async { + self.ocvArray = ocvArray + + // If reconnecting to same device within grace period, restore connected state + if let activity = currentActivity, + let activityRadioID = activity.attributes.radioID, + activityRadioID == device.radioID { + disconnectTimer?.cancel() + disconnectTimer = nil + recentPacketTimestamps = [] + clearPendingUpdate() + await updateActivity( + isConnected: true, + battery: .some(nil), + packetsPerMinute: 0, + unreadCount: unreadCount, + disconnectedDate: .some(nil) + ) + startDecayTimer() + startObservingActivityState() + return } - var hasActiveActivity: Bool { currentActivity != nil } - - var isEnabled: Bool { - didSet { UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey) } + // If reconnecting to a different device, end the old activity first + if currentActivity != nil { + await endActivity() } - init() { - isEnabled = UserDefaults.standard.object(forKey: Self.enabledKey) as? Bool ?? AppStorageKey.defaultLiveActivityEnabled + await startActivity( + deviceName: device.nodeName, + radioID: device.radioID, + unreadCount: unreadCount + ) + startDecayTimer() + } + + func handleConnectionLost() async { + guard currentActivity != nil else { return } + + stopDecayTimer() + recentPacketTimestamps = [] + clearPendingUpdate() + await updateActivity( + isConnected: false, + battery: .some(nil), + packetsPerMinute: 0, + unreadCount: 0, + disconnectedDate: .some(.now) + ) + + disconnectTimer?.cancel() + disconnectTimer = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.disconnectGracePeriod)) + guard !Task.isCancelled else { return } + await self?.endActivity() } + } - // MARK: - Pending Update + func handleEnterBackground() { + stopDecayTimer() + } - private struct PendingUpdate { - var isConnected: Bool? - var battery: Int?? - var packetsPerMinute: Int? - var unreadCount: Int? - var disconnectedDate: Date?? + func handleReturnToForeground() { + guard hasActiveActivity else { return } + startDecayTimer() + if pendingUpdate != nil { + Task { await flushPendingUpdate() } } - - // MARK: - Lifecycle - - func startObservingEnablement() { - enablementTask?.cancel() - enablementTask = Task { [weak self] in - for await enabled in ActivityAuthorizationInfo().activityEnablementUpdates { - guard let self else { break } - if !enabled { - await self.endActivity() - } - } - } + } + + func handlePacketReceived() async { + let now = Date.now + recentPacketTimestamps.append(now) + let cutoff = now.addingTimeInterval(-Self.packetWindowSeconds) + recentPacketTimestamps.removeAll { $0 < cutoff } + await scheduleUpdate(packetsPerMinute: recentPacketsPerMinute) + } + + func handleBatteryChanged(battery: BatteryInfo) async { + let percent = battery.percentage(using: ocvArray) + await scheduleUpdate(battery: .some(percent)) + await flushPendingUpdate() + } + + func handleUnreadCountChanged(unreadCount: Int) async { + await scheduleUpdate(unreadCount: unreadCount) + await flushPendingUpdate() + } + + func setEnabled(_ enabled: Bool) async { + isEnabled = enabled + if !enabled { + await endActivity() } + } - func handleConnectionReady( - device: DeviceDTO, - ocvArray: [Int], - unreadCount: Int - ) async { - self.ocvArray = ocvArray - - // If reconnecting to same device within grace period, restore connected state - if let activity = currentActivity, - let activityRadioID = activity.attributes.radioID, - activityRadioID == device.radioID { - disconnectTimer?.cancel() - disconnectTimer = nil - recentPacketTimestamps = [] - clearPendingUpdate() - await updateActivity( - isConnected: true, - battery: .some(nil), - packetsPerMinute: 0, - unreadCount: unreadCount, - disconnectedDate: .some(nil) - ) - startDecayTimer() - startObservingActivityState() - return - } + // MARK: - App relaunch recovery - // If reconnecting to a different device, end the old activity first - if currentActivity != nil { - await endActivity() - } + func recoverExistingActivity() async { + let allActivities = Activity.activities - await startActivity( - deviceName: device.nodeName, - radioID: device.radioID, - unreadCount: unreadCount - ) - startDecayTimer() + // Clean up ended/dismissed activities still visible per their dismissal policy. + for activity in allActivities where + activity.activityState == .ended || activity.activityState == .dismissed { + await activity.end(nil, dismissalPolicy: .immediate) } - func handleConnectionLost() async { - guard currentActivity != nil else { return } - - stopDecayTimer() - recentPacketTimestamps = [] - clearPendingUpdate() - await updateActivity( - isConnected: false, - battery: .some(nil), - packetsPerMinute: 0, - unreadCount: 0, - disconnectedDate: .some(.now) - ) - - disconnectTimer?.cancel() - disconnectTimer = Task { [weak self] in - try? await Task.sleep(for: .seconds(Self.disconnectGracePeriod)) - guard !Task.isCancelled else { return } - await self?.endActivity() - } + // Adopt the first active/stale activity; immediately end any extras as + // orphans so the user never sees two LAs at once. + let activeOrStale = allActivities.filter { + $0.activityState == .active || $0.activityState == .stale } - - func handleEnterBackground() { - stopDecayTimer() + for orphan in activeOrStale.dropFirst() { + logger.warning("Ending duplicate orphan Live Activity on recovery") + await orphan.end(nil, dismissalPolicy: .immediate) } - func handleReturnToForeground() { - guard hasActiveActivity else { return } - startDecayTimer() - if pendingUpdate != nil { - Task { await flushPendingUpdate() } - } - } + currentActivity = activeOrStale.first + guard let activity = currentActivity else { return } - func handlePacketReceived() async { - let now = Date.now - recentPacketTimestamps.append(now) - let cutoff = now.addingTimeInterval(-Self.packetWindowSeconds) - recentPacketTimestamps.removeAll { $0 < cutoff } - await scheduleUpdate(packetsPerMinute: recentPacketsPerMinute) - } + startObservingActivityState() - func handleBatteryChanged(battery: BatteryInfo) async { - let percent = battery.percentage(using: ocvArray) - await scheduleUpdate(battery: .some(percent)) - await flushPendingUpdate() + // If the LA is cached as connected, force re-validation. The radio may + // actually be disconnected (prior session crashed mid-connection, app + // was killed during a drop, etc.). handleConnectionReady's same-device + // branch will restore connected state once auto-reconnect lands. + if activity.content.state.isConnected { + logger.info("Recovered Live Activity in cached connected state — forcing re-validation as disconnected") + await handleConnectionLost() + return } - func handleUnreadCountChanged(unreadCount: Int) async { - await scheduleUpdate(unreadCount: unreadCount) - await flushPendingUpdate() + // Already disconnected — re-arm the grace timer using the persisted + // disconnectedDate. If it's missing or already expired, end now. + guard let disconnectedDate = activity.content.state.disconnectedDate else { + await endActivity() + return } - func setEnabled(_ enabled: Bool) async { - isEnabled = enabled - if !enabled { - await endActivity() - } + let elapsed = Date.now.timeIntervalSince(disconnectedDate) + let remaining = Self.disconnectGracePeriod - elapsed + + if remaining > 0 { + disconnectTimer = Task { [weak self] in + try? await Task.sleep(for: .seconds(remaining)) + guard !Task.isCancelled else { return } + await self?.endActivity() + } + } else { + await endActivity() } + } - // MARK: - App relaunch recovery + // MARK: - Private - func recoverExistingActivity() async { - let allActivities = Activity.activities - - // Clean up ended/dismissed activities still visible per their dismissal policy. - for activity in allActivities where - activity.activityState == .ended || activity.activityState == .dismissed { - await activity.end(nil, dismissalPolicy: .immediate) - } - - // Adopt the first active/stale activity; immediately end any extras as - // orphans so the user never sees two LAs at once. - let activeOrStale = allActivities.filter { - $0.activityState == .active || $0.activityState == .stale - } - for orphan in activeOrStale.dropFirst() { - logger.warning("Ending duplicate orphan Live Activity on recovery") - await orphan.end(nil, dismissalPolicy: .immediate) - } - - currentActivity = activeOrStale.first - guard let activity = currentActivity else { return } - - startObservingActivityState() - - // If the LA is cached as connected, force re-validation. The radio may - // actually be disconnected (prior session crashed mid-connection, app - // was killed during a drop, etc.). handleConnectionReady's same-device - // branch will restore connected state once auto-reconnect lands. - if activity.content.state.isConnected { - logger.info("Recovered Live Activity in cached connected state — forcing re-validation as disconnected") - await handleConnectionLost() - return - } - - // Already disconnected — re-arm the grace timer using the persisted - // disconnectedDate. If it's missing or already expired, end now. - guard let disconnectedDate = activity.content.state.disconnectedDate else { - await endActivity() - return - } - - let elapsed = Date.now.timeIntervalSince(disconnectedDate) - let remaining = Self.disconnectGracePeriod - elapsed - - if remaining > 0 { - disconnectTimer = Task { [weak self] in - try? await Task.sleep(for: .seconds(remaining)) - guard !Task.isCancelled else { return } - await self?.endActivity() - } - } else { - await endActivity() - } + private func startDecayTimer() { + stopDecayTimer() + decayTimer = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(Self.decayInterval)) + guard !Task.isCancelled, let self else { return } + let cutoff = Date.now.addingTimeInterval(-Self.packetWindowSeconds) + recentPacketTimestamps.removeAll { $0 < cutoff } + await scheduleUpdate(packetsPerMinute: recentPacketsPerMinute) + } } - - // MARK: - Private - - private func startDecayTimer() { - stopDecayTimer() - decayTimer = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(Self.decayInterval)) - guard !Task.isCancelled, let self else { return } - let cutoff = Date.now.addingTimeInterval(-Self.packetWindowSeconds) - self.recentPacketTimestamps.removeAll { $0 < cutoff } - await self.scheduleUpdate(packetsPerMinute: self.recentPacketsPerMinute) - } - } + } + + private func stopDecayTimer() { + decayTimer?.cancel() + decayTimer = nil + } + + private func clearActivityReference() { + currentActivity = nil + stateObservationTask?.cancel() + stateObservationTask = nil + // Everything else here is bound to the activity's lifetime: timers + // that would mutate it, throttled updates queued for it, packet + // timestamps used to compute its rate. If any of these survive the + // reference being cleared, they can leak onto the next activity (a + // stale disconnect timer ending a fresh LA, a queued flush applying + // old battery/rate, packet-rate carry-over). + disconnectTimer?.cancel() + disconnectTimer = nil + stopDecayTimer() + clearPendingUpdate() + recentPacketTimestamps = [] + } + + private func startActivity( + deviceName: String, + radioID: UUID?, + unreadCount: Int + ) async { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + logger.warning("Cannot start Live Activity: not authorized") + return + } + guard isEnabled else { + logger.debug("Cannot start Live Activity: disabled by user") + return + } + guard !DemoModeManager.shared.isEnabled else { return } + + // Reclaim slots held by ended/dismissed entries before requesting. + // iOS keeps them in `.activities` for up to 4 hours (Lock Screen + // dismissal window) and counts them against the per-app activity + // cap, so `Activity.request` would otherwise throw + // `ActivityAuthorizationError.targetMaximumExceeded`. Mirrors + // `recoverExistingActivity`. + for activity in Activity.activities where + activity.activityState == .ended || activity.activityState == .dismissed { + await activity.end(nil, dismissalPolicy: .immediate) } - private func stopDecayTimer() { - decayTimer?.cancel() - decayTimer = nil + guard !Activity.activities.contains(where: { + $0.activityState == .active || $0.activityState == .stale + }) else { + logger.warning("Cannot start Live Activity: one already running") + return } - private func clearActivityReference() { - currentActivity = nil - stateObservationTask?.cancel() - stateObservationTask = nil - // Everything else here is bound to the activity's lifetime: timers - // that would mutate it, throttled updates queued for it, packet - // timestamps used to compute its rate. If any of these survive the - // reference being cleared, they can leak onto the next activity (a - // stale disconnect timer ending a fresh LA, a queued flush applying - // old battery/rate, packet-rate carry-over). - disconnectTimer?.cancel() - disconnectTimer = nil - stopDecayTimer() - clearPendingUpdate() - recentPacketTimestamps = [] + let attributes = MeshStatusAttributes(deviceName: deviceName, radioID: radioID) + let state = MeshStatusAttributes.ContentState( + isConnected: true, + batteryPercent: nil, + packetsPerMinute: 0, + unreadCount: unreadCount, + disconnectedDate: nil + ) + let staleDate = Calendar.current.date(byAdding: .minute, value: 5, to: .now) + let content = ActivityContent(state: state, staleDate: staleDate) + + do { + currentActivity = try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + LiveActivityTip.radioConnected.sendDonation() + startObservingActivityState() + logger.info("Started Live Activity for \(deviceName, privacy: .public)") + } catch { + logger.error("Failed to start Live Activity: \(error.localizedDescription, privacy: .public)") } + } - private func startActivity( - deviceName: String, - radioID: UUID?, - unreadCount: Int - ) async { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { - logger.warning("Cannot start Live Activity: not authorized") - return - } - guard isEnabled else { - logger.debug("Cannot start Live Activity: disabled by user") - return - } - guard !DemoModeManager.shared.isEnabled else { return } - - // Reclaim slots held by ended/dismissed entries before requesting. - // iOS keeps them in `.activities` for up to 4 hours (Lock Screen - // dismissal window) and counts them against the per-app activity - // cap, so `Activity.request` would otherwise throw - // `ActivityAuthorizationError.targetMaximumExceeded`. Mirrors - // `recoverExistingActivity`. - for activity in Activity.activities where - activity.activityState == .ended || activity.activityState == .dismissed { - await activity.end(nil, dismissalPolicy: .immediate) - } + // MARK: - Activity State Observation - guard !Activity.activities.contains(where: { - $0.activityState == .active || $0.activityState == .stale - }) else { - logger.warning("Cannot start Live Activity: one already running") - return - } + private func startObservingActivityState() { + stateObservationTask?.cancel() + guard let activity = currentActivity else { return } + stateObservationTask = Task { [weak self] in + for await state in activity.activityStateUpdates { + guard let self else { break } + switch state { + case .ended: + let lastState = activity.content.state + let deviceName = activity.attributes.deviceName + let radioID = activity.attributes.radioID + + // Clear the field synchronously before any await so a + // re-entrant @MainActor caller (e.g. handleConnectionReady) + // can't reassign currentActivity to a replacement and have + // it nulled out when we return. + clearActivityReference() + + // Dismiss the captured (now-detached) activity immediately + // so it doesn't linger on screen alongside any replacement + // (system-ended activities default to ~4h visible dismissal). + await activity.end(nil, dismissalPolicy: .immediate) + + // Don't resurrect a "connected" LA from cached state — + // only restart when the radio is actually connected. + let isCurrentlyConnected = connectionStateProvider?() ?? false + if isCurrentlyConnected { + logger.info("System ended Live Activity, restarting (radio still connected)") + await startActivity(deviceName: deviceName, radioID: radioID, unreadCount: lastState.unreadCount) + startDecayTimer() + } else { + logger.info("System ended Live Activity, not restarting (radio disconnected)") + } + return - let attributes = MeshStatusAttributes(deviceName: deviceName, radioID: radioID) - let state = MeshStatusAttributes.ContentState( - isConnected: true, - batteryPercent: nil, - packetsPerMinute: 0, - unreadCount: unreadCount, - disconnectedDate: nil - ) - let staleDate = Calendar.current.date(byAdding: .minute, value: 5, to: .now) - let content = ActivityContent(state: state, staleDate: staleDate) - - do { - currentActivity = try Activity.request( - attributes: attributes, - content: content, - pushType: nil - ) - LiveActivityTip.radioConnected.sendDonation() - startObservingActivityState() - logger.info("Started Live Activity for \(deviceName, privacy: .public)") - } catch { - logger.error("Failed to start Live Activity: \(error.localizedDescription, privacy: .public)") - } - } + case .dismissed: + clearActivityReference() + logger.info("Live Activity dismissed by user") + return + + case .stale: + let currentState = activity.content.state + let isCurrentlyConnected = connectionStateProvider?() ?? false + + if currentState.isConnected, !isCurrentlyConnected { + // LA cached as connected, but the radio is actually + // disconnected (we missed a notification somewhere). + // Force the disconnect path instead of refreshing. + logger.warning("Stale Live Activity cached connected but radio disconnected — forcing handleConnectionLost") + await handleConnectionLost() + } else if !currentState.isConnected, + let activityRadioID = activity.attributes.radioID, + let connectedRadioID = connectedRadioIDProvider?(), + activityRadioID == connectedRadioID { + // LA cached as disconnected, but the same radio is actually + // connected. A background reconnect restored the link with no + // runtime to update. Restore connected, since handleConnectionReady + // may not fire until the app foregrounds. + logger.warning("Stale Live Activity cached disconnected but radio connected — restoring connected state") + disconnectTimer?.cancel() + disconnectTimer = nil + recentPacketTimestamps = [] + clearPendingUpdate() + await updateActivity(isConnected: true, disconnectedDate: .some(nil)) + startDecayTimer() + } else if currentState.isConnected, currentState.packetsPerMinute > 0 { + logger.debug("Live Activity stale with active rate, resetting to 0") + recentPacketTimestamps = [] + clearPendingUpdate() + await updateActivity(packetsPerMinute: 0) + } else if pendingUpdate != nil { + await flushPendingUpdate() + } else { + await updateActivity() + } - // MARK: - Activity State Observation - - private func startObservingActivityState() { - stateObservationTask?.cancel() - guard let activity = currentActivity else { return } - stateObservationTask = Task { [weak self] in - for await state in activity.activityStateUpdates { - guard let self else { break } - switch state { - case .ended: - let lastState = activity.content.state - let deviceName = activity.attributes.deviceName - let radioID = activity.attributes.radioID - - // Clear the field synchronously before any await so a - // re-entrant @MainActor caller (e.g. handleConnectionReady) - // can't reassign currentActivity to a replacement and have - // it nulled out when we return. - self.clearActivityReference() - - // Dismiss the captured (now-detached) activity immediately - // so it doesn't linger on screen alongside any replacement - // (system-ended activities default to ~4h visible dismissal). - await activity.end(nil, dismissalPolicy: .immediate) - - // Don't resurrect a "connected" LA from cached state — - // only restart when the radio is actually connected. - let isCurrentlyConnected = self.connectionStateProvider?() ?? false - if isCurrentlyConnected { - logger.info("System ended Live Activity, restarting (radio still connected)") - await self.startActivity(deviceName: deviceName, radioID: radioID, unreadCount: lastState.unreadCount) - self.startDecayTimer() - } else { - logger.info("System ended Live Activity, not restarting (radio disconnected)") - } - return - - case .dismissed: - self.clearActivityReference() - logger.info("Live Activity dismissed by user") - return - - case .stale: - let currentState = activity.content.state - let isCurrentlyConnected = self.connectionStateProvider?() ?? false - - if currentState.isConnected && !isCurrentlyConnected { - // LA cached as connected, but the radio is actually - // disconnected (we missed a notification somewhere). - // Force the disconnect path instead of refreshing. - logger.warning("Stale Live Activity cached connected but radio disconnected — forcing handleConnectionLost") - await self.handleConnectionLost() - } else if currentState.isConnected && currentState.packetsPerMinute > 0 { - logger.debug("Live Activity stale with active rate, resetting to 0") - self.recentPacketTimestamps = [] - self.clearPendingUpdate() - await self.updateActivity(packetsPerMinute: 0) - } else if self.pendingUpdate != nil { - await self.flushPendingUpdate() - } else { - await self.updateActivity() - } - - case .active: - break - - case .pending: - break - - @unknown default: - break - } - } - } - } + case .active: + break - // MARK: - Throttled Updates - - private func scheduleUpdate( - isConnected: Bool? = nil, - battery: Int?? = nil, - packetsPerMinute: Int? = nil, - unreadCount: Int? = nil, - disconnectedDate: Date?? = nil - ) async { - guard currentActivity != nil else { return } - - // Merge into pending update (last-writer-wins per field) - var pending = pendingUpdate ?? PendingUpdate() - if let isConnected { pending.isConnected = isConnected } - if let battery { pending.battery = battery } - if let packetsPerMinute { pending.packetsPerMinute = packetsPerMinute } - if let unreadCount { pending.unreadCount = unreadCount } - if let disconnectedDate { pending.disconnectedDate = disconnectedDate } - pendingUpdate = pending - - // Leading edge: flush immediately if enough time has passed - if Date.now.timeIntervalSince(lastFlushDate) >= Self.updateInterval { - await flushPendingUpdate() - return - } + case .pending: + break - // Trailing edge: schedule flush if not already scheduled - guard throttleTask == nil else { return } - let delay = Self.updateInterval - Date.now.timeIntervalSince(lastFlushDate) - throttleTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(delay)) - guard !Task.isCancelled, let self else { return } - await self.flushPendingUpdate() + @unknown default: + break } + } } - - private func flushPendingUpdate() async { - guard let pending = pendingUpdate else { return } - pendingUpdate = nil - throttleTask?.cancel() - throttleTask = nil - lastFlushDate = .now - await updateActivity( - isConnected: pending.isConnected, - battery: pending.battery, - packetsPerMinute: pending.packetsPerMinute, - unreadCount: pending.unreadCount, - disconnectedDate: pending.disconnectedDate - ) + } + + // MARK: - Throttled Updates + + private func scheduleUpdate( + isConnected: Bool? = nil, + battery: Int?? = nil, + packetsPerMinute: Int? = nil, + unreadCount: Int? = nil, + disconnectedDate: Date?? = nil + ) async { + guard currentActivity != nil else { return } + + // Merge into pending update (last-writer-wins per field) + var pending = pendingUpdate ?? PendingUpdate() + if let isConnected { pending.isConnected = isConnected } + if let battery { pending.battery = battery } + if let packetsPerMinute { pending.packetsPerMinute = packetsPerMinute } + if let unreadCount { pending.unreadCount = unreadCount } + if let disconnectedDate { pending.disconnectedDate = disconnectedDate } + pendingUpdate = pending + + // Leading edge: flush immediately if enough time has passed + if Date.now.timeIntervalSince(lastFlushDate) >= Self.updateInterval { + await flushPendingUpdate() + return } - private func clearPendingUpdate() { - pendingUpdate = nil - throttleTask?.cancel() - throttleTask = nil + // Trailing edge: schedule flush if not already scheduled + guard throttleTask == nil else { return } + let delay = Self.updateInterval - Date.now.timeIntervalSince(lastFlushDate) + throttleTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + await flushPendingUpdate() } - - /// Updates the Live Activity state. Pass `nil` to keep the current value, `.some(value)` to override. - private func updateActivity( - isConnected: Bool? = nil, - battery: Int?? = nil, - packetsPerMinute: Int? = nil, - unreadCount: Int? = nil, - disconnectedDate: Date?? = nil - ) async { - guard let current = currentActivity?.content.state else { return } - let state = MeshStatusAttributes.ContentState( - isConnected: isConnected ?? current.isConnected, - batteryPercent: battery ?? current.batteryPercent, - packetsPerMinute: packetsPerMinute ?? current.packetsPerMinute, - unreadCount: unreadCount ?? current.unreadCount, - disconnectedDate: disconnectedDate ?? current.disconnectedDate - ) - let staleDate: Date? = if state.isConnected && state.packetsPerMinute > 0 { - Date.now.addingTimeInterval(Self.connectedStaleInterval) - } else { - Calendar.current.date(byAdding: .minute, value: 5, to: .now) - } - let content = ActivityContent(state: state, staleDate: staleDate) - await currentActivity?.update(content) + } + + private func flushPendingUpdate() async { + guard let pending = pendingUpdate else { return } + pendingUpdate = nil + throttleTask?.cancel() + throttleTask = nil + lastFlushDate = .now + await updateActivity( + isConnected: pending.isConnected, + battery: pending.battery, + packetsPerMinute: pending.packetsPerMinute, + unreadCount: pending.unreadCount, + disconnectedDate: pending.disconnectedDate + ) + } + + private func clearPendingUpdate() { + pendingUpdate = nil + throttleTask?.cancel() + throttleTask = nil + } + + /// Updates the Live Activity state. Pass `nil` to keep the current value, `.some(value)` to override. + private func updateActivity( + isConnected: Bool? = nil, + battery: Int?? = nil, + packetsPerMinute: Int? = nil, + unreadCount: Int? = nil, + disconnectedDate: Date?? = nil + ) async { + guard let current = currentActivity?.content.state else { return } + let state = MeshStatusAttributes.ContentState( + isConnected: isConnected ?? current.isConnected, + batteryPercent: battery ?? current.batteryPercent, + packetsPerMinute: packetsPerMinute ?? current.packetsPerMinute, + unreadCount: unreadCount ?? current.unreadCount, + disconnectedDate: disconnectedDate ?? current.disconnectedDate + ) + let staleDate: Date? = if state.isConnected, state.packetsPerMinute > 0 { + Date.now.addingTimeInterval(Self.connectedStaleInterval) + } else { + Calendar.current.date(byAdding: .minute, value: 5, to: .now) } - - /// Checks whether the current activity reference is still valid. - /// If the activity was ended while suspended (and `activityStateUpdates` didn't fire), this catches it. - func validateActivityState() async { - guard let activity = currentActivity else { return } - switch activity.activityState { - case .ended: - let lastState = activity.content.state - let deviceName = activity.attributes.deviceName - let radioID = activity.attributes.radioID - - // Clear the field synchronously before the await so a re-entrant - // main-actor caller can't have its replacement currentActivity - // nulled out when we resume. - clearActivityReference() - await activity.end(nil, dismissalPolicy: .immediate) - - let isCurrentlyConnected = connectionStateProvider?() ?? false - if isCurrentlyConnected { - logger.info("Detected ended Live Activity on foreground, restarting (radio still connected)") - await startActivity(deviceName: deviceName, radioID: radioID, unreadCount: lastState.unreadCount) - startDecayTimer() - } else { - logger.info("Detected ended Live Activity on foreground, not restarting (radio disconnected)") - } - case .dismissed: - clearActivityReference() - case .active, .stale: - break - case .pending: - break - @unknown default: - break - } + let content = ActivityContent(state: state, staleDate: staleDate) + await currentActivity?.update(content) + } + + /// Checks whether the current activity reference is still valid. + /// If the activity was ended while suspended (and `activityStateUpdates` didn't fire), this catches it. + func validateActivityState() async { + guard let activity = currentActivity else { return } + switch activity.activityState { + case .ended: + let lastState = activity.content.state + let deviceName = activity.attributes.deviceName + let radioID = activity.attributes.radioID + + // Clear the field synchronously before the await so a re-entrant + // main-actor caller can't have its replacement currentActivity + // nulled out when we resume. + clearActivityReference() + await activity.end(nil, dismissalPolicy: .immediate) + + let isCurrentlyConnected = connectionStateProvider?() ?? false + if isCurrentlyConnected { + logger.info("Detected ended Live Activity on foreground, restarting (radio still connected)") + await startActivity(deviceName: deviceName, radioID: radioID, unreadCount: lastState.unreadCount) + startDecayTimer() + } else { + logger.info("Detected ended Live Activity on foreground, not restarting (radio disconnected)") + } + case .dismissed: + clearActivityReference() + case .active, .stale: + break + case .pending: + break + @unknown default: + break } - - func endActivity() async { - stopDecayTimer() - stateObservationTask?.cancel() - stateObservationTask = nil - disconnectTimer?.cancel() - disconnectTimer = nil - clearPendingUpdate() - recentPacketTimestamps = [] - for activity in Activity.activities { - await activity.end(nil, dismissalPolicy: .immediate) - } - currentActivity = nil - logger.info("Ended Live Activity") + } + + func endActivity() async { + stopDecayTimer() + stateObservationTask?.cancel() + stateObservationTask = nil + disconnectTimer?.cancel() + disconnectTimer = nil + clearPendingUpdate() + recentPacketTimestamps = [] + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) } + currentActivity = nil + logger.info("Ended Live Activity") + } } diff --git a/MC1/State/MapFocusRequest.swift b/MC1/State/MapFocusRequest.swift index 871afbdb..172b72a0 100644 --- a/MC1/State/MapFocusRequest.swift +++ b/MC1/State/MapFocusRequest.swift @@ -5,10 +5,10 @@ import CoreLocation /// Wraps the coordinate in an `Equatable` value because `CLLocationCoordinate2D` /// is not `Equatable`, which `MapView`'s `.onChange(of:)` requires. struct MapFocusRequest: Equatable { - let latitude: Double - let longitude: Double + let latitude: Double + let longitude: Double - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - } + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } diff --git a/MC1/State/MessageEvent.swift b/MC1/State/MessageEvent.swift index 1da4f7fc..b3593a5b 100644 --- a/MC1/State/MessageEvent.swift +++ b/MC1/State/MessageEvent.swift @@ -7,30 +7,30 @@ import MC1Services /// speculative or unreachable cases. Consumers should switch /// exhaustively (no `default`) so a new case becomes a compile error /// rather than a silent skip. -enum MessageEvent: Sendable, Equatable { - case directMessageReceived(message: MessageDTO, contact: ContactDTO) - case channelMessageReceived(message: MessageDTO, channelIndex: UInt8) - case roomMessageReceived(message: RoomMessageDTO, sessionID: UUID) - /// Fired when a message's status resolves to .sent or .delivered for an - /// original (non-resend) send. `roundTripTime` is supplied only when firmware - /// reports it (.delivered via end-to-end ACK); .sent transitions and - /// finalizeSend-path .delivered pass nil. Consumers may animate the bubble - /// status footer in place without a DB fetch — the dispatcher writes the DB - /// row before firing for all five sites that route through this case. - case messageStatusResolved(messageID: UUID, status: MessageStatus, roundTripTime: UInt32? = nil) - /// Fired after a channel-message resend (`MessageService.resendChannelMessage`) - /// completes. Carries no status payload because the resend path mutates - /// `heardRepeats` and `sendCount` alongside the status flip, and the bubble - /// status row reads both fields off the DTO. Consumers must route this case - /// through `enqueueReload` so the next refresh re-fetches every affected - /// field — the in-place `applyStatusUpdate` helper cannot refresh - /// `heardRepeats`/`sendCount`. - case messageResent(messageID: UUID) - case messageFailed(messageID: UUID) - case messageRetrying(messageID: UUID, attempt: Int, maxAttempts: Int) - case heardRepeatRecorded(messageID: UUID, count: Int) - case reactionReceived(messageID: UUID, summary: String) - case routingChanged(contactID: UUID, isFlood: Bool) - case roomMessageStatusUpdated(messageID: UUID) - case roomMessageFailed(messageID: UUID) +enum MessageEvent: Equatable { + case directMessageReceived(message: MessageDTO, contact: ContactDTO) + case channelMessageReceived(message: MessageDTO, channelIndex: UInt8) + case roomMessageReceived(message: RoomMessageDTO, sessionID: UUID) + /// Fired when a message's status resolves to .sent or .delivered for an + /// original (non-resend) send. `roundTripTime` is supplied only when firmware + /// reports it (.delivered via end-to-end ACK); .sent transitions and + /// finalizeSend-path .delivered pass nil. Consumers may animate the bubble + /// status footer in place without a DB fetch — the dispatcher writes the DB + /// row before firing for all five sites that route through this case. + case messageStatusResolved(messageID: UUID, status: MessageStatus, roundTripTime: UInt32? = nil) + /// Fired after a channel-message resend (`MessageService.resendChannelMessage`) + /// completes. Carries no status payload because the resend path mutates + /// `heardRepeats` and `sendCount` alongside the status flip, and the bubble + /// status row reads both fields off the DTO. Consumers must route this case + /// through `enqueueReload` so the next refresh re-fetches every affected + /// field — the in-place `applyStatusUpdate` helper cannot refresh + /// `heardRepeats`/`sendCount`. + case messageResent(messageID: UUID) + case messageFailed(messageID: UUID) + case messageRetrying(messageID: UUID, attempt: Int, maxAttempts: Int) + case heardRepeatRecorded(messageID: UUID, count: Int) + case reactionReceived(messageID: UUID, summary: String) + case routingChanged(contactID: UUID, isFlood: Bool) + case roomMessageStatusUpdated(messageID: UUID) + case roomMessageFailed(messageID: UUID) } diff --git a/MC1/State/MessageEventDispatcher.swift b/MC1/State/MessageEventDispatcher.swift index c64345e9..db87c5b7 100644 --- a/MC1/State/MessageEventDispatcher.swift +++ b/MC1/State/MessageEventDispatcher.swift @@ -16,118 +16,118 @@ import MC1Services /// stale subscription can outlive a reconnect. @MainActor final class MessageEventDispatcher { - private weak var appState: AppState? - private let stream: MessageEventStream + private weak var appState: AppState? + private let stream: MessageEventStream - /// Stream-consuming tasks, cancelled on re-wire and on disconnect. - /// Each consumed stream also ends when its `ServiceContainer` is torn down. - private var tasks: [Task] = [] + /// Stream-consuming tasks, cancelled on re-wire and on disconnect. + /// Each consumed stream also ends when its `ServiceContainer` is torn down. + private var tasks: [Task] = [] - init(appState: AppState, stream: MessageEventStream) { - self.appState = appState - self.stream = stream - } + init(appState: AppState, stream: MessageEventStream) { + self.appState = appState + self.stream = stream + } - func wire(services: ServiceContainer) { - cancelAll() - wireSyncCoordinator(services.syncCoordinator) - wireHeardRepeats(services.heardRepeatsService) - wireRemoteNode(services.remoteNodeService) - wireRoomServer(services.roomServerService) - wireMessageService(services.messageService) - } + func wire(services: ServiceContainer) { + cancelAll() + wireSyncCoordinator(services.syncCoordinator) + wireHeardRepeats(services.heardRepeatsService) + wireRemoteNode(services.remoteNodeService) + wireRoomServer(services.roomServerService) + wireMessageService(services.messageService) + } - /// Cancels every stream-consuming task. Called before re-wiring against a - /// fresh `ServiceContainer` and from `AppState`'s disconnect teardown. - func cancelAll() { - for task in tasks { - task.cancel() - } - tasks.removeAll() + /// Cancels every stream-consuming task. Called before re-wiring against a + /// fresh `ServiceContainer` and from `AppState`'s disconnect teardown. + func cancelAll() { + for task in tasks { + task.cancel() } + tasks.removeAll() + } - private func wireSyncCoordinator(_ syncCoordinator: SyncCoordinator) { - let events = syncCoordinator.dataEvents() - let task = Task { [weak appState, stream] in - for await event in events { - switch event { - case .directMessageReceived(let message, let contact): - stream.send(.directMessageReceived(message: message, contact: contact)) - case .channelMessageReceived(let message, let channelIndex): - stream.send(.channelMessageReceived(message: message, channelIndex: channelIndex)) - case .roomMessageReceived(let message): - stream.send(.roomMessageReceived(message: message, sessionID: message.sessionID)) - case .reactionReceived(let messageID, let summary): - stream.send(.reactionReceived(messageID: messageID, summary: summary)) - await appState?.handleReactionNotification(messageID: messageID) - case .contactsChanged, .conversationsChanged: - break - } - } + private func wireSyncCoordinator(_ syncCoordinator: SyncCoordinator) { + let events = syncCoordinator.dataEvents() + let task = Task { [weak appState, stream] in + for await event in events { + switch event { + case let .directMessageReceived(message, contact): + stream.send(.directMessageReceived(message: message, contact: contact)) + case let .channelMessageReceived(message, channelIndex): + stream.send(.channelMessageReceived(message: message, channelIndex: channelIndex)) + case let .roomMessageReceived(message): + stream.send(.roomMessageReceived(message: message, sessionID: message.sessionID)) + case let .reactionReceived(messageID, summary): + stream.send(.reactionReceived(messageID: messageID, summary: summary)) + await appState?.handleReactionNotification(messageID: messageID) + case .contactsChanged, .conversationsChanged: + break } - tasks.append(task) + } } + tasks.append(task) + } - private func wireHeardRepeats(_ heardRepeatsService: HeardRepeatsService) { - let events = heardRepeatsService.events() - let task = Task { [stream] in - for await event in events { - stream.send(.heardRepeatRecorded(messageID: event.messageID, count: event.count)) - } - } - tasks.append(task) + private func wireHeardRepeats(_ heardRepeatsService: HeardRepeatsService) { + let events = heardRepeatsService.events() + let task = Task { [stream] in + for await event in events { + stream.send(.heardRepeatRecorded(messageID: event.messageID, count: event.count)) + } } + tasks.append(task) + } - private func wireRemoteNode(_ remoteNodeService: RemoteNodeService) { - let events = remoteNodeService.events() - let task = Task { [weak appState] in - for await event in events { - switch event { - case .sessionStateChanged: - appState?.handleSessionStateChange() - } - } + private func wireRemoteNode(_ remoteNodeService: RemoteNodeService) { + let events = remoteNodeService.events() + let task = Task { [weak appState] in + for await event in events { + switch event { + case .sessionStateChanged: + appState?.handleSessionStateChange() } - tasks.append(task) + } } + tasks.append(task) + } - private func wireRoomServer(_ roomServerService: RoomServerService) { - let events = roomServerService.events() - let task = Task { [weak appState, stream] in - for await event in events { - switch event { - case .statusUpdated(let messageID, let status): - if status == .failed { - stream.send(.roomMessageFailed(messageID: messageID)) - } else { - stream.send(.roomMessageStatusUpdated(messageID: messageID)) - } - case .connectionRecovered: - appState?.handleSessionStateChange() - } - } + private func wireRoomServer(_ roomServerService: RoomServerService) { + let events = roomServerService.events() + let task = Task { [weak appState, stream] in + for await event in events { + switch event { + case let .statusUpdated(messageID, status): + if status == .failed { + stream.send(.roomMessageFailed(messageID: messageID)) + } else { + stream.send(.roomMessageStatusUpdated(messageID: messageID)) + } + case .connectionRecovered: + appState?.handleSessionStateChange() } - tasks.append(task) + } } + tasks.append(task) + } - private func wireMessageService(_ messageService: MessageService) { - let events = messageService.statusEvents() - let task = Task { [stream] in - for await event in events { - switch event { - case .statusResolved(let messageID, let status, let roundTripTime): - stream.send(.messageStatusResolved(messageID: messageID, status: status, roundTripTime: roundTripTime)) - case .resent(let messageID): - stream.send(.messageResent(messageID: messageID)) - case .retrying(let messageID, let attempt, let maxAttempts): - stream.send(.messageRetrying(messageID: messageID, attempt: attempt, maxAttempts: maxAttempts)) - case .routingChanged(let contactID, let isFlood): - stream.send(.routingChanged(contactID: contactID, isFlood: isFlood)) - case .failed(let messageID): - stream.send(.messageFailed(messageID: messageID)) - } - } + private func wireMessageService(_ messageService: MessageService) { + let events = messageService.statusEvents() + let task = Task { [stream] in + for await event in events { + switch event { + case let .statusResolved(messageID, status, roundTripTime): + stream.send(.messageStatusResolved(messageID: messageID, status: status, roundTripTime: roundTripTime)) + case let .resent(messageID): + stream.send(.messageResent(messageID: messageID)) + case let .retrying(messageID, attempt, maxAttempts): + stream.send(.messageRetrying(messageID: messageID, attempt: attempt, maxAttempts: maxAttempts)) + case let .routingChanged(contactID, isFlood): + stream.send(.routingChanged(contactID: contactID, isFlood: isFlood)) + case let .failed(messageID): + stream.send(.messageFailed(messageID: messageID)) } - tasks.append(task) + } } + tasks.append(task) + } } diff --git a/MC1/State/MessageEventStream.swift b/MC1/State/MessageEventStream.swift index e95b7868..f01c8c2c 100644 --- a/MC1/State/MessageEventStream.swift +++ b/MC1/State/MessageEventStream.swift @@ -15,34 +15,35 @@ import MC1Services /// consumers would force duplicating `SyncCoordinator`'s resolution. @MainActor final class MessageEventStream { + private var continuations: [UUID: AsyncStream.Continuation] = [:] - private var continuations: [UUID: AsyncStream.Continuation] = [:] - - func events() -> AsyncStream { - let id = UUID() - return AsyncStream { continuation in - self.continuations[id] = continuation - continuation.onTermination = { [weak self, id] _ in - Task { @MainActor in - self?.continuations.removeValue(forKey: id) - } - } + func events() -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + self.continuations[id] = continuation + continuation.onTermination = { [weak self, id] _ in + Task { @MainActor in + self?.continuations.removeValue(forKey: id) } + } } + } - func send(_ event: MessageEvent) { - var staleIDs: [UUID] = [] - for (id, continuation) in continuations { - if case .terminated = continuation.yield(event) { - staleIDs.append(id) - } - } - for id in staleIDs { continuations.removeValue(forKey: id) } + func send(_ event: MessageEvent) { + var staleIDs: [UUID] = [] + for (id, continuation) in continuations { + if case .terminated = continuation.yield(event) { + staleIDs.append(id) + } + } + for id in staleIDs { + continuations.removeValue(forKey: id) } + } - #if DEBUG + #if DEBUG func subscriberCount() -> Int { - continuations.count + continuations.count } - #endif + #endif } diff --git a/MC1/State/NavigationCoordinator.swift b/MC1/State/NavigationCoordinator.swift index 51efb07a..d0da6454 100644 --- a/MC1/State/NavigationCoordinator.swift +++ b/MC1/State/NavigationCoordinator.swift @@ -1,274 +1,273 @@ -import SwiftUI -import MC1Services import CoreLocation +import MC1Services +import SwiftUI /// Manages tab selection, pending navigation targets, and cross-tab navigation coordination. @Observable @MainActor final class NavigationCoordinator { + /// Selected tab index + var selectedTab: Int = 0 - /// Selected tab index - var selectedTab: Int = 0 - - var tabBarVisibility: Visibility = .visible - - /// Whether the split container can tile all three columns side by side, measured at the shell. - /// When true, row selection leaves the sidebar tiled open instead of collapsing it. Defaults to - /// false so an unmeasured layout takes the safe collapse-on-selection branch rather than claiming - /// wide while actually narrow and overlaying the sidebar on a too-narrow container. - var isSidebarWide = false - - /// Contact to navigate to - var pendingChatContact: ContactDTO? - - /// The currently selected route in the Chats split view detail pane - var chatsSelectedRoute: ChatRoute? - - /// Channel to navigate to - var pendingChannel: ChannelDTO? - - /// Room session to navigate to - var pendingRoomSession: RemoteNodeSessionDTO? - - /// Room session a notification tap wants the user to authenticate into, set - /// when the tapped room is not currently connected. ChatsView presents - /// RoomAuthenticationSheet, mirroring a disconnected-room list tap. - var pendingRoomAuthentication: RemoteNodeSessionDTO? - - /// Whether to navigate to Discovery - var pendingDiscoveryNavigation = false - - /// Contact to navigate to (for detail view on Contacts tab) - var pendingContactDetail: ContactDTO? - - /// The currently selected contact in the Nodes split view detail pane. Kept in memory - /// only and never persisted: it carries a public key and `radioID` (identity-bearing - /// data) that must not be written outside the encrypted backup envelope. - var selectedContact: ContactDTO? - - /// Whether the Nodes split view detail pane is showing Discovery rather than a contact. - /// Shared so the iPad content and detail columns agree on which detail to render. - var nodesShowingDiscovery = false - - /// The selected tool on the Tools tab, shared so the iPad content and detail columns agree on - /// which tool is open. Held here rather than in the split view so it survives the section-switch - /// teardown of the inactive Tools tree. Radio-only tools clear on disconnect (`requiresRadio`). - var selectedTool: ToolSelection? - - /// The selected settings page on the Settings tab, shared so the iPad content and detail columns - /// agree on which page is open. Held here rather than in the split view so it survives the - /// section-switch teardown of the inactive Settings tree. My Device pages clear on disconnect - /// (`requiresDevice`). - var selectedSetting: SettingsDetail? - - /// Message to scroll to after navigation (for reaction notifications) - var pendingScrollToMessageID: UUID? - - /// Whether device menu tip donation is pending (waiting for valid tab) - var pendingDeviceMenuTipDonation = false - - /// Coordinate the Map tab should drop a pin on and center, set by a chat coordinate tap. - var pendingMapFocus: MapFocusRequest? - - /// Pending contact-add confirmation triggered by a `meshcore://contact/add` link tap inside any chat surface. - var pendingContactLink: MeshCoreURLParser.ContactResult? - - /// Pending channel-join confirmation triggered by a `meshcore://channel/...` link tap inside any chat surface. - var pendingChannelLink: MeshCoreURLParser.ChannelResult? - - /// Pending hashtag-channel join sheet triggered by a `meshcoreone://hashtag/...` link tap inside any chat surface. - var pendingHashtag: HashtagJoinRequest? - - // MARK: - Navigation - - func navigateToChat(with contact: ContactDTO, scrollToMessageID: UUID? = nil) { - tabBarVisibility = .hidden // Hide tab bar BEFORE switching tabs - pendingChatContact = contact - pendingScrollToMessageID = scrollToMessageID - chatsSelectedRoute = .direct(contact) - selectedTab = 0 - } - - func navigateToRoom(with session: RemoteNodeSessionDTO) { - tabBarVisibility = .hidden // Hide tab bar BEFORE switching tabs - pendingRoomSession = session - chatsSelectedRoute = .room(session) - selectedTab = 0 - } - - func navigateToChannel(with channel: ChannelDTO, scrollToMessageID: UUID? = nil) { - tabBarVisibility = .hidden - pendingChannel = channel - pendingScrollToMessageID = scrollToMessageID - chatsSelectedRoute = .channel(channel) - selectedTab = 0 - } - - func navigateToDiscovery() { - pendingDiscoveryNavigation = true - selectedTab = 1 - } - - func navigateToContacts() { - selectedTab = 1 - } - - func navigateToContactDetail(_ contact: ContactDTO) { - pendingContactDetail = contact - selectedTab = 1 - } - - func navigateToMap(coordinate: CLLocationCoordinate2D) { - pendingMapFocus = MapFocusRequest(latitude: coordinate.latitude, - longitude: coordinate.longitude) - selectedTab = AppTab.map.rawValue - } - - func clearPendingNavigation() { - pendingChatContact = nil - } + var tabBarVisibility: Visibility = .visible + + /// Whether the split container can tile all three columns side by side, measured at the shell. + /// When true, row selection leaves the sidebar tiled open instead of collapsing it. Defaults to + /// false so an unmeasured layout takes the safe collapse-on-selection branch rather than claiming + /// wide while actually narrow and overlaying the sidebar on a too-narrow container. + var isSidebarWide = false - func clearPendingRoomNavigation() { - pendingRoomSession = nil + /// Contact to navigate to + var pendingChatContact: ContactDTO? + + /// The currently selected route in the Chats split view detail pane + var chatsSelectedRoute: ChatRoute? + + /// Channel to navigate to + var pendingChannel: ChannelDTO? + + /// Room session to navigate to + var pendingRoomSession: RemoteNodeSessionDTO? + + /// Room session a notification tap wants the user to authenticate into, set + /// when the tapped room is not currently connected. ChatsView presents + /// RoomAuthenticationSheet, mirroring a disconnected-room list tap. + var pendingRoomAuthentication: RemoteNodeSessionDTO? + + /// Whether to navigate to Discovery + var pendingDiscoveryNavigation = false + + /// Contact to navigate to (for detail view on Contacts tab) + var pendingContactDetail: ContactDTO? + + /// The currently selected contact in the Nodes split view detail pane. Kept in memory + /// only and never persisted: it carries a public key and `radioID` (identity-bearing + /// data) that must not be written outside the encrypted backup envelope. + var selectedContact: ContactDTO? + + /// Whether the Nodes split view detail pane is showing Discovery rather than a contact. + /// Shared so the iPad content and detail columns agree on which detail to render. + var nodesShowingDiscovery = false + + /// The selected tool on the Tools tab, shared so the iPad content and detail columns agree on + /// which tool is open. Held here rather than in the split view so it survives the section-switch + /// teardown of the inactive Tools tree. Radio-only tools clear on disconnect (`requiresRadio`). + var selectedTool: ToolSelection? + + /// The selected settings page on the Settings tab, shared so the iPad content and detail columns + /// agree on which page is open. Held here rather than in the split view so it survives the + /// section-switch teardown of the inactive Settings tree. My Device pages clear on disconnect + /// (`requiresDevice`). + var selectedSetting: SettingsDetail? + + /// Message to scroll to after navigation (for reaction notifications) + var pendingScrollToMessageID: UUID? + + /// Whether device menu tip donation is pending (waiting for valid tab) + var pendingDeviceMenuTipDonation = false + + /// Coordinate the Map tab should drop a pin on and center, set by a chat coordinate tap. + var pendingMapFocus: MapFocusRequest? + + /// Pending contact-add confirmation triggered by a `meshcore://contact/add` link tap inside any chat surface. + var pendingContactLink: MeshCoreURLParser.ContactResult? + + /// Pending channel-join confirmation triggered by a `meshcore://channel/...` link tap inside any chat surface. + var pendingChannelLink: MeshCoreURLParser.ChannelResult? + + /// Pending hashtag-channel join sheet triggered by a `meshcoreone://hashtag/...` link tap inside any chat surface. + var pendingHashtag: HashtagJoinRequest? + + // MARK: - Navigation + + func navigateToChat(with contact: ContactDTO, scrollToMessageID: UUID? = nil) { + tabBarVisibility = .hidden // Hide tab bar BEFORE switching tabs + pendingChatContact = contact + pendingScrollToMessageID = scrollToMessageID + chatsSelectedRoute = .direct(contact) + selectedTab = 0 + } + + func navigateToRoom(with session: RemoteNodeSessionDTO) { + tabBarVisibility = .hidden // Hide tab bar BEFORE switching tabs + pendingRoomSession = session + chatsSelectedRoute = .room(session) + selectedTab = 0 + } + + func navigateToChannel(with channel: ChannelDTO, scrollToMessageID: UUID? = nil) { + tabBarVisibility = .hidden + pendingChannel = channel + pendingScrollToMessageID = scrollToMessageID + chatsSelectedRoute = .channel(channel) + selectedTab = 0 + } + + func navigateToDiscovery() { + pendingDiscoveryNavigation = true + selectedTab = 1 + } + + func navigateToContacts() { + selectedTab = 1 + } + + func navigateToContactDetail(_ contact: ContactDTO) { + pendingContactDetail = contact + selectedTab = 1 + } + + func navigateToMap(coordinate: CLLocationCoordinate2D) { + pendingMapFocus = MapFocusRequest(latitude: coordinate.latitude, + longitude: coordinate.longitude) + selectedTab = AppTab.map.rawValue + } + + func clearPendingNavigation() { + pendingChatContact = nil + } + + func clearPendingRoomNavigation() { + pendingRoomSession = nil + } + + func clearPendingRoomAuthentication() { + pendingRoomAuthentication = nil + } + + func clearPendingChannelNavigation() { + pendingChannel = nil + } + + func clearPendingDiscoveryNavigation() { + pendingDiscoveryNavigation = false + } + + func clearPendingScrollToMessage() { + pendingScrollToMessageID = nil + } + + func clearPendingContactDetailNavigation() { + pendingContactDetail = nil + } + + func clearPendingMapFocus() { + pendingMapFocus = nil + } + + func clearPendingContactLink() { + pendingContactLink = nil + } + + func clearPendingChannelLink() { + pendingChannelLink = nil + } + + func clearPendingHashtag() { + pendingHashtag = nil + } + + /// Clears deep-link confirmation state and every per-radio detail selection so a pending sheet + /// cannot re-present, and a selection made on the previous radio cannot drive a detail pane, + /// after the connection is torn down and replaced. + func clearPendingLinks() { + pendingContactLink = nil + pendingChannelLink = nil + pendingHashtag = nil + clearPerRadioSelection() + } + + /// Resets every detail selection scoped to the current radio so a stale selection cannot aim a + /// section's detail pane at the wrong radio. Runs on disconnect and on a direct radio-to-radio + /// switch. Line of Sight is preserved because it runs offline and is not radio-scoped. + func clearPerRadioSelection() { + selectedContact = nil + nodesShowingDiscovery = false + chatsSelectedRoute = nil + clearPerDeviceSelection() + } + + /// Clears only device-scoped selections (the radio-requiring tool and the My Device settings + /// page), but keeps an open Chats/Nodes detail so a manual disconnect does not eject the user from + /// an open conversation. Line of Sight and app-wide settings are radio-independent and preserved. + func clearPerDeviceSelection() { + if selectedTool?.requiresRadio == true { + selectedTool = nil } - - func clearPendingRoomAuthentication() { - pendingRoomAuthentication = nil + if selectedSetting?.requiresDevice == true { + selectedSetting = nil } - - func clearPendingChannelNavigation() { - pendingChannel = nil - } - - func clearPendingDiscoveryNavigation() { - pendingDiscoveryNavigation = false + } + + /// Tabs where BLEStatusIndicatorView exists and the device menu tip can anchor (Chats, Contacts, Map). + var isOnValidTabForDeviceMenuTip: Bool { + selectedTab == AppTab.chats.rawValue + || selectedTab == AppTab.nodes.rawValue + || selectedTab == AppTab.map.rawValue + } + + // MARK: - Notification Handlers + + /// Configure notification tap handlers that navigate to conversations. + /// Called from AppState.configureNotificationHandlers() when services become available. + func configureNotificationHandlers( + notificationService: NotificationService, + dataStore: PersistenceStore, + connectedDevice: @escaping @Sendable @MainActor () -> DeviceDTO? + ) { + // Direct message notification tap + notificationService.onNotificationTapped = { [weak self] contactID in + guard let self else { return } + guard let contact = try? await dataStore.fetchContact(id: contactID) else { return } + navigateToChat(with: contact) } - func clearPendingScrollToMessage() { - pendingScrollToMessageID = nil - } - - func clearPendingContactDetailNavigation() { - pendingContactDetail = nil - } - - func clearPendingMapFocus() { - pendingMapFocus = nil - } - - func clearPendingContactLink() { - pendingContactLink = nil - } - - func clearPendingChannelLink() { - pendingChannelLink = nil - } - - func clearPendingHashtag() { - pendingHashtag = nil - } - - /// Clears deep-link confirmation state and every per-radio detail selection so a pending sheet - /// cannot re-present, and a selection made on the previous radio cannot drive a detail pane, - /// after the connection is torn down and replaced. - func clearPendingLinks() { - pendingContactLink = nil - pendingChannelLink = nil - pendingHashtag = nil - clearPerRadioSelection() - } - - /// Resets every detail selection scoped to the current radio so a stale selection cannot aim a - /// section's detail pane at the wrong radio. Runs on disconnect and on a direct radio-to-radio - /// switch. Line of Sight is preserved because it runs offline and is not radio-scoped. - func clearPerRadioSelection() { - selectedContact = nil - nodesShowingDiscovery = false - chatsSelectedRoute = nil - clearPerDeviceSelection() - } - - /// Clears only device-scoped selections (the radio-requiring tool and the My Device settings - /// page), but keeps an open Chats/Nodes detail so a manual disconnect does not eject the user from - /// an open conversation. Line of Sight and app-wide settings are radio-independent and preserved. - func clearPerDeviceSelection() { - if selectedTool?.requiresRadio == true { - selectedTool = nil - } - if selectedSetting?.requiresDevice == true { - selectedSetting = nil + // New contact notification tap + notificationService.onNewContactNotificationTapped = { [weak self] contactID in + guard let self else { return } + if connectedDevice()?.manualAddContacts == true { + navigateToDiscovery() + } else { + guard let contact = try? await dataStore.fetchContact(id: contactID) else { + navigateToContacts() + return } + navigateToContactDetail(contact) + } } - /// Tabs where BLEStatusIndicatorView exists and the device menu tip can anchor (Chats, Contacts, Map). - var isOnValidTabForDeviceMenuTip: Bool { - selectedTab == AppTab.chats.rawValue - || selectedTab == AppTab.nodes.rawValue - || selectedTab == AppTab.map.rawValue + // Channel notification tap + notificationService.onChannelNotificationTapped = { [weak self] radioID, channelIndex in + guard let self else { return } + guard let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) else { return } + navigateToChannel(with: channel) } - // MARK: - Notification Handlers - - /// Configure notification tap handlers that navigate to conversations. - /// Called from AppState.configureNotificationHandlers() when services become available. - func configureNotificationHandlers( - notificationService: NotificationService, - dataStore: PersistenceStore, - connectedDevice: @escaping @Sendable @MainActor () -> DeviceDTO? - ) { - // Direct message notification tap - notificationService.onNotificationTapped = { [weak self] contactID in - guard let self else { return } - guard let contact = try? await dataStore.fetchContact(id: contactID) else { return } - self.navigateToChat(with: contact) - } - - // New contact notification tap - notificationService.onNewContactNotificationTapped = { [weak self] contactID in - guard let self else { return } - if connectedDevice()?.manualAddContacts == true { - self.navigateToDiscovery() - } else { - guard let contact = try? await dataStore.fetchContact(id: contactID) else { - self.navigateToContacts() - return - } - self.navigateToContactDetail(contact) - } - } - - // Channel notification tap - notificationService.onChannelNotificationTapped = { [weak self] radioID, channelIndex in - guard let self else { return } - guard let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) else { return } - self.navigateToChannel(with: channel) - } - - // Reaction notification tap - notificationService.onReactionNotificationTapped = { [weak self] contactID, channelIndex, radioID, messageID in - guard let self else { return } - if let contactID, - let contact = try? await dataStore.fetchContact(id: contactID) { - self.navigateToChat(with: contact, scrollToMessageID: messageID) - } else if let channelIndex, let radioID, - let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { - self.navigateToChannel(with: channel, scrollToMessageID: messageID) - } - } + // Reaction notification tap + notificationService.onReactionNotificationTapped = { [weak self] contactID, channelIndex, radioID, messageID in + guard let self else { return } + if let contactID, + let contact = try? await dataStore.fetchContact(id: contactID) { + navigateToChat(with: contact, scrollToMessageID: messageID) + } else if let channelIndex, let radioID, + let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { + navigateToChannel(with: channel, scrollToMessageID: messageID) + } + } - // Room notification tap. Resolves the full session from the stable - // sessionID carried in the notification. A connected room opens directly; - // a disconnected room is routed to the auth sheet, mirroring the list-row - // gate, instead of the iPad detail pane rendering an ungated read-only room. - notificationService.onRoomNotificationTapped = { [weak self] sessionID in - guard let self else { return } - guard let session = try? await dataStore.fetchRemoteNodeSession(id: sessionID) else { return } - if session.isConnected { - self.navigateToRoom(with: session) - } else { - self.selectedTab = 0 - self.pendingRoomAuthentication = session - } - } + // Room notification tap. Resolves the full session from the stable + // sessionID carried in the notification. A connected room opens directly; + // a disconnected room is routed to the auth sheet, mirroring the list-row + // gate, instead of the iPad detail pane rendering an ungated read-only room. + notificationService.onRoomNotificationTapped = { [weak self] sessionID in + guard let self else { return } + guard let session = try? await dataStore.fetchRemoteNodeSession(id: sessionID) else { return } + if session.isConnected { + navigateToRoom(with: session) + } else { + selectedTab = 0 + pendingRoomAuthentication = session + } } + } } diff --git a/MC1/State/OnboardingState.swift b/MC1/State/OnboardingState.swift index 2c490a10..7262982e 100644 --- a/MC1/State/OnboardingState.swift +++ b/MC1/State/OnboardingState.swift @@ -4,83 +4,82 @@ import MC1Services import UserNotifications enum OnboardingStep: String, CaseIterable, Hashable, Codable { - case welcome - case permissions - case pair - case region - case preset + case welcome + case permissions + case pair + case region + case preset } /// Manages onboarding completion flag and navigation path. @Observable @MainActor final class OnboardingState { + private let defaults: UserDefaults - private let defaults: UserDefaults - - /// Whether onboarding is complete (persisted to UserDefaults) - var hasCompletedOnboarding: Bool { - didSet { - defaults.set(hasCompletedOnboarding, forKey: AppStorageKey.hasCompletedOnboarding.rawValue) - } + /// Whether onboarding is complete (persisted to UserDefaults) + var hasCompletedOnboarding: Bool { + didSet { + defaults.set(hasCompletedOnboarding, forKey: AppStorageKey.hasCompletedOnboarding.rawValue) } + } - /// Navigation path for onboarding flow - var onboardingPath: [OnboardingStep] = [] + /// Navigation path for onboarding flow + var onboardingPath: [OnboardingStep] = [] - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - self.hasCompletedOnboarding = defaults.bool(forKey: AppStorageKey.hasCompletedOnboarding.rawValue) - } + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + hasCompletedOnboarding = defaults.bool(forKey: AppStorageKey.hasCompletedOnboarding.rawValue) + } - /// Mark onboarding as complete - func completeOnboarding() { - hasCompletedOnboarding = true - } + /// Mark onboarding as complete + func completeOnboarding() { + hasCompletedOnboarding = true + } - /// Reset onboarding state - func resetOnboarding() { - hasCompletedOnboarding = false - onboardingPath = [] - } + /// Reset onboarding state + func resetOnboarding() { + hasCompletedOnboarding = false + onboardingPath = [] + } } extension OnboardingState { - /// Computes the `NavigationStack` starting path. The view at the top of the - /// path is what the user lands on; `WelcomeView()` is the root, so any - /// returned path is pushed *above* it. - /// - /// Reads notification authorization directly from `UNUserNotificationCenter` - /// rather than `PermissionsCoordinator` (view-scoped, async-init). - /// - /// `regionAlreadySet` lets a returning user skip past the region step when - /// `AppState.regionSelection` is already populated — without it, partially - /// onboarded users land on `.region` even though they answered it last time. - func suggestedStartingPath( - connectionManager: ConnectionManager, - locationAuthorizationStatus: CLAuthorizationStatus, - regionAlreadySet: Bool - ) async -> [OnboardingStep] { - guard !hasCompletedOnboarding else { return [] } - // `pairedAccessoriesCount` is the AccessorySetupKit count, always 0 on macOS (no - // registry), so fall back to `lastConnectedDeviceID` — set only after a real successful - // connect and cleared when a device is forgotten, never by a backup import — as the - // platform-independent "this install has actually connected a radio" signal. A raw - // saved-device count would wrongly resume for backup-restored shadows and demoted ghosts. - guard connectionManager.pairedAccessoriesCount > 0 - || connectionManager.lastConnectedDeviceID != nil else { return [] } + /// Computes the `NavigationStack` starting path. The view at the top of the + /// path is what the user lands on; `WelcomeView()` is the root, so any + /// returned path is pushed *above* it. + /// + /// Reads notification authorization directly from `UNUserNotificationCenter` + /// rather than `PermissionsCoordinator` (view-scoped, async-init). + /// + /// `regionAlreadySet` lets a returning user skip past the region step when + /// `AppState.regionSelection` is already populated — without it, partially + /// onboarded users land on `.region` even though they answered it last time. + func suggestedStartingPath( + connectionManager: ConnectionManager, + locationAuthorizationStatus: CLAuthorizationStatus, + regionAlreadySet: Bool + ) async -> [OnboardingStep] { + guard !hasCompletedOnboarding else { return [] } + // `pairedAccessoriesCount` is the AccessorySetupKit count, always 0 on macOS (no + // registry), so fall back to `lastConnectedDeviceID` — set only after a real successful + // connect and cleared when a device is forgotten, never by a backup import — as the + // platform-independent "this install has actually connected a radio" signal. A raw + // saved-device count would wrongly resume for backup-restored shadows and demoted ghosts. + guard connectionManager.pairedAccessoriesCount > 0 + || connectionManager.lastConnectedDeviceID != nil else { return [] } - let notificationStatus = await UNUserNotificationCenter.current() - .notificationSettings().authorizationStatus + let notificationStatus = await UNUserNotificationCenter.current() + .notificationSettings().authorizationStatus - let permissionsHandled = locationAuthorizationStatus != .notDetermined - && notificationStatus != .notDetermined + let permissionsHandled = locationAuthorizationStatus != .notDetermined + && notificationStatus != .notDetermined - guard permissionsHandled else { return [.permissions] } + guard permissionsHandled else { return [.permissions] } - if regionAlreadySet { - return [.permissions, .pair, .preset] - } - return [.permissions, .pair, .region] + if regionAlreadySet { + return [.permissions, .pair, .preset] } + return [.permissions, .pair, .region] + } } diff --git a/MC1/State/PendingExternalURL.swift b/MC1/State/PendingExternalURL.swift new file mode 100644 index 00000000..f3c16cbb --- /dev/null +++ b/MC1/State/PendingExternalURL.swift @@ -0,0 +1,42 @@ +import Foundation + +/// App-level staging for a URL iOS delivers at or near cold launch, when routing +/// it synchronously would lose it. The before-first-unlock `AppState` swap drops +/// anything routed into the throwaway instance, and a failed auto-reconnect +/// during `AppState.initialize()` clears pending deep-link state before the +/// confirmation sheet can present. The URL is held here and routed only once +/// initialization has settled, so it survives both. +/// +/// Mirrors `IntentBridge`: a reference type stored as a `let` on `MC1App` so it +/// outlives the `AppState` swap, mutated through `submit` / `markReady` rather +/// than a stored property a value-typed `App` cannot reassign from its `body`. +@MainActor +final class PendingExternalURL { + private(set) var url: URL? + private(set) var isReady = false + + /// Records a URL from iOS and routes it now if initialization has settled, + /// otherwise holds it for `markReady`. + func submit(_ url: URL, appState: AppState) { + self.url = url + drainIfReady(appState) + } + + /// Records that initialization has settled and routes any URL held from + /// launch. Called once at the tail of `MC1App`'s launch task, after + /// `AppState.initialize()` and foreground reconciliation, so the failed + /// reconnect teardown has already run and cannot wipe a freshly staged link. + func markReady(_ appState: AppState) { + isReady = true + drainIfReady(appState) + } + + /// Routes the held URL once ready, then drops it so a warm-app `submit` and + /// the end-of-launch `markReady` cannot both route the same URL. A no-op + /// until `markReady` or when nothing is held. + private func drainIfReady(_ appState: AppState) { + guard isReady, let url else { return } + ChatLinkRouter.routeExternalOpen(url, appState: appState) + self.url = nil + } +} diff --git a/MC1/State/PendingPurchase.swift b/MC1/State/PendingPurchase.swift index df8a91d5..e446084d 100644 --- a/MC1/State/PendingPurchase.swift +++ b/MC1/State/PendingPurchase.swift @@ -2,7 +2,7 @@ import Foundation /// An in-flight Ask-to-Buy / SCA purchase awaiting approval. Drives the pending banner. /// Resolution arrives later via `StoreService`'s `Transaction.updates` listener. -struct PendingPurchase: Sendable, Equatable { - let productID: String - let displayName: String +struct PendingPurchase: Equatable { + let productID: String + let displayName: String } diff --git a/MC1/State/RestoreState.swift b/MC1/State/RestoreState.swift index 756e31ec..f639cb1e 100644 --- a/MC1/State/RestoreState.swift +++ b/MC1/State/RestoreState.swift @@ -3,10 +3,10 @@ import Foundation /// Drives the Restore Purchases button: disabled + `ProgressView` while `.syncing`, /// `.sensoryFeedback(.success)` on `.completed` only. `.cancelled` is distinct from `.completed` /// so a user dismissing the iCloud password sheet does not trigger the success haptic. -enum RestoreState: Sendable, Equatable { - case idle - case syncing - case completed - case cancelled - case failed +enum RestoreState: Equatable { + case idle + case syncing + case completed + case cancelled + case failed } diff --git a/MC1/State/StatusPillState.swift b/MC1/State/StatusPillState.swift index 127eb9f0..d5086489 100644 --- a/MC1/State/StatusPillState.swift +++ b/MC1/State/StatusPillState.swift @@ -2,42 +2,42 @@ import SwiftUI /// Represents the current state of the status pill UI component enum StatusPillState: Hashable { - case hidden - case connecting - case syncing - case ready - case disconnected - case failed(message: String) + case hidden + case connecting + case syncing + case ready + case disconnected + case failed(message: String) - var displayText: String { - switch self { - case .failed(let message): message - case .syncing: L10n.Localizable.Common.Status.syncing - case .connecting: L10n.Localizable.Common.Status.connecting - case .ready: L10n.Localizable.Common.Status.ready - case .disconnected: L10n.Localizable.Common.Status.disconnected - case .hidden: "" - } + var displayText: String { + switch self { + case let .failed(message): message + case .syncing: L10n.Localizable.Common.Status.syncing + case .connecting: L10n.Localizable.Common.Status.connecting + case .ready: L10n.Localizable.Common.Status.ready + case .disconnected: L10n.Localizable.Common.Status.disconnected + case .hidden: "" } + } - var systemImageName: String { - switch self { - case .failed: "exclamationmark.triangle.fill" - case .disconnected: "exclamationmark.triangle" - case .ready: "checkmark.circle" - case .connecting, .syncing: "arrow.trianglehead.2.clockwise" - case .hidden: "" - } + var systemImageName: String { + switch self { + case .failed: "exclamationmark.triangle.fill" + case .disconnected: "exclamationmark.triangle" + case .ready: "checkmark.circle" + case .connecting, .syncing: "arrow.trianglehead.2.clockwise" + case .hidden: "" } + } - var isFailure: Bool { - if case .failed = self { return true } - return false - } + var isFailure: Bool { + if case .failed = self { return true } + return false + } - var textColor: Color { - if isFailure { return .red } - if case .disconnected = self { return .orange } - return .primary - } + var textColor: Color { + if isFailure { return .red } + if case .disconnected = self { return .orange } + return .primary + } } diff --git a/MC1/State/StoreState.swift b/MC1/State/StoreState.swift index 66606144..08a87025 100644 --- a/MC1/State/StoreState.swift +++ b/MC1/State/StoreState.swift @@ -1,6 +1,6 @@ -import SwiftUI -import StoreKit import MC1Services +import StoreKit +import SwiftUI /// View-model wrapper over `StoreService` for the Support Development screen. Owned by `AppState`. /// Presents the project-standard `errorMessage: String?` (surfaced via `.errorAlert`) and maps @@ -8,128 +8,128 @@ import MC1Services @Observable @MainActor final class StoreState { - let service: StoreService + let service: StoreService - var errorMessage: String? - var restoreState: RestoreState = .idle - var pendingPurchase: PendingPurchase? + var errorMessage: String? + var restoreState: RestoreState = .idle + var pendingPurchase: PendingPurchase? - /// Persisted so purchase diagnostics reach the Settings "Export Debug Logs" output for - /// triaging failures reported from TestFlight. `StoreService` logs the StoreKit-side flow; - /// this logs the view-driven entry, guards, and outcome. - private let logger = PersistentLogger(subsystem: "com.mc1", category: "Store") + /// Persisted so purchase diagnostics reach the Settings "Export Debug Logs" output for + /// triaging failures reported from TestFlight. `StoreService` logs the StoreKit-side flow; + /// this logs the view-driven entry, guards, and outcome. + private let logger = PersistentLogger(subsystem: "com.mc1", category: "Store") - init(service: StoreService) { - self.service = service - // Out-of-band consumable resolution: an Ask-to-Buy-approved tip is finished by - // `applyTransactionUpdate` but does not enter `ownedThemeIDs`, so `reconcilePendingPurchase` - // (which only checks ownership) cannot clear the pending banner. This callback fires - // after the tip's `transaction.finish()` so the banner clears immediately. - service.onConsumablePurchased = { [weak self] productID in - guard let self else { return } - if self.pendingPurchase?.productID == productID { self.pendingPurchase = nil } - } - // Surface load failures as an alert so the user can see why the catalog didn't load - // (network down, etc.) and use the `ErrorAlertModifier` retry button. Without this, - // a failed initial load is silent — the Support screen shows disabled '—' buttons - // with no signal that retry is the right action. - service.onLoadStateFailed = { [weak self] in - guard let self else { return } - self.errorMessage = self.localizedMessage(for: .productsNotLoaded) - } + init(service: StoreService) { + self.service = service + // Out-of-band consumable resolution: an Ask-to-Buy-approved tip is finished by + // `applyTransactionUpdate` but does not enter `ownedThemeIDs`, so `reconcilePendingPurchase` + // (which only checks ownership) cannot clear the pending banner. This callback fires + // after the tip's `transaction.finish()` so the banner clears immediately. + service.onConsumablePurchased = { [weak self] productID in + guard let self else { return } + if pendingPurchase?.productID == productID { pendingPurchase = nil } } - - /// Returns `true` only on `.purchased`; callers that gate post-success UI (e.g. ContributionRow's - /// "thank you" animation) check this to avoid celebrating cancels, failures, or Ask-to-Buy pending. - /// `purchase` performs the StoreKit purchase. The view passes SwiftUI's `@Environment(\.purchase)` - /// action so StoreKit resolves the confirmation scene from the view environment, which is robust - /// against the foreground-scene churn (lifecycle reconciliation during sheet presentation) that - /// makes a bare `Product.purchase()` return a spurious `.userCancelled` on iOS 18.2+. Taking it as - /// a closure keeps this method testable, since `PurchaseAction` cannot be constructed off a view. - @discardableResult - func purchase( - productID: String, - purchase: (Product) async throws -> Product.PurchaseResult - ) async -> Bool { - guard service.loadState == .loaded else { - // Without this guard, an empty `products` array (because load failed/never finished) - // would surface `.productNotFound` instead of the truer `.productsNotLoaded`, which - // the ErrorAlertModifier retry button knows how to resolve via `service.load()`. - logger.error("[IAP] purchase blocked: products not loaded (loadState=\(String(describing: service.loadState)))") - errorMessage = localizedMessage(for: .productsNotLoaded) - return false - } - guard let product = service.product(for: productID) else { - logger.error("[IAP] purchase blocked: product not found product=\(productID)") - errorMessage = localizedMessage(for: .productNotFound(productID: productID)) - return false - } - logger.notice("[IAP] purchase requested product=\(productID)") - do { - let result = try await purchase(product) - let outcome = try await service.completePurchase(result) - switch outcome { - case .purchased: - // Clear pendingPurchase only when the just-completed product matches; an unrelated - // .purchased (e.g. tipping Coffee while Ember Ask-to-Buy is still in flight) must - // not wipe a banner for a different in-flight purchase. - if pendingPurchase?.productID == productID { pendingPurchase = nil } - return true - case .userCancelled: - return false - case .pending: - pendingPurchase = PendingPurchase(productID: productID, displayName: product.displayName) - return false - } - } catch let error as StoreServiceError { - logger.error("[IAP] purchase failed product=\(productID): \(error.errorDescription ?? "\(error)")") - errorMessage = localizedMessage(for: error) - return false - } catch let error as StoreKitError { - // .userCancelled maps to nil and stays a silent cancel; anything else is a real failure. - if let mapped = StoreServiceError.from(error) { - logger.error("[IAP] purchase StoreKit error product=\(productID): \(String(describing: error))") - errorMessage = localizedMessage(for: mapped) - } - return false - } catch { - logger.error("[IAP] purchase error product=\(productID): \(error.localizedDescription)") - errorMessage = localizedMessage(for: .purchaseFailed(reason: error.localizedDescription)) - return false - } + // Surface load failures as an alert so the user can see why the catalog didn't load + // (network down, etc.) and use the `ErrorAlertModifier` retry button. Without this, + // a failed initial load is silent — the Support screen shows disabled '—' buttons + // with no signal that retry is the right action. + service.onLoadStateFailed = { [weak self] in + guard let self else { return } + errorMessage = localizedMessage(for: .productsNotLoaded) } + } - func restorePurchases() async { - restoreState = .syncing - do { - switch try await service.restorePurchases() { - case .completed: restoreState = .completed - case .cancelled: restoreState = .cancelled - } - } catch let error as StoreServiceError { - errorMessage = localizedMessage(for: error) - restoreState = .failed - } catch { - errorMessage = localizedMessage(for: .purchaseFailed(reason: error.localizedDescription)) - restoreState = .failed - } + /// Returns `true` only on `.purchased`; callers that gate post-success UI (e.g. ContributionRow's + /// "thank you" animation) check this to avoid celebrating cancels, failures, or Ask-to-Buy pending. + /// `purchase` performs the StoreKit purchase. The view passes SwiftUI's `@Environment(\.purchase)` + /// action so StoreKit resolves the confirmation scene from the view environment, which is robust + /// against the foreground-scene churn (lifecycle reconciliation during sheet presentation) that + /// makes a bare `Product.purchase()` return a spurious `.userCancelled` on iOS 18.2+. Taking it as + /// a closure keeps this method testable, since `PurchaseAction` cannot be constructed off a view. + @discardableResult + func purchase( + productID: String, + purchase: (Product) async throws -> Product.PurchaseResult + ) async -> Bool { + guard service.loadState == .loaded else { + // Without this guard, an empty `products` array (because load failed/never finished) + // would surface `.productNotFound` instead of the truer `.productsNotLoaded`, which + // the ErrorAlertModifier retry button knows how to resolve via `service.load()`. + logger.error("[IAP] purchase blocked: products not loaded (loadState=\(String(describing: service.loadState)))") + errorMessage = localizedMessage(for: .productsNotLoaded) + return false } - - /// Clears the pending banner once the pending product's entitlement has arrived. Driven by the - /// view's `.onChange(of: service.ownedThemeIDs)`. The bundle expands to every purchasable theme - /// ID (its own product ID never appears in `ownedThemeIDs`), so it resolves via a superset check. - func reconcilePendingPurchase() { - guard let pending = pendingPurchase else { return } - let owned = service.ownedThemeIDs - let isResolved = pending.productID == StoreCatalog.Theme.bundleAll - ? owned.isSuperset(of: StoreCatalog.Theme.bundledThemeIDs) - : owned.contains(pending.productID) - if isResolved { pendingPurchase = nil } + guard let product = service.product(for: productID) else { + logger.error("[IAP] purchase blocked: product not found product=\(productID)") + errorMessage = localizedMessage(for: .productNotFound(productID: productID)) + return false + } + logger.notice("[IAP] purchase requested product=\(productID)") + do { + let result = try await purchase(product) + let outcome = try await service.completePurchase(result) + switch outcome { + case .purchased: + // Clear pendingPurchase only when the just-completed product matches; an unrelated + // .purchased (e.g. tipping Coffee while Ember Ask-to-Buy is still in flight) must + // not wipe a banner for a different in-flight purchase. + if pendingPurchase?.productID == productID { pendingPurchase = nil } + return true + case .userCancelled: + return false + case .pending: + pendingPurchase = PendingPurchase(productID: productID, displayName: product.displayName) + return false + } + } catch let error as StoreServiceError { + logger.error("[IAP] purchase failed product=\(productID): \(error.errorDescription ?? "\(error)")") + errorMessage = localizedMessage(for: error) + return false + } catch let error as StoreKitError { + // .userCancelled maps to nil and stays a silent cancel; anything else is a real failure. + if let mapped = StoreServiceError.from(error) { + logger.error("[IAP] purchase StoreKit error product=\(productID): \(String(describing: error))") + errorMessage = localizedMessage(for: mapped) + } + return false + } catch { + logger.error("[IAP] purchase error product=\(productID): \(error.localizedDescription)") + errorMessage = localizedMessage(for: .purchaseFailed(reason: error.localizedDescription)) + return false } + } - /// Internal (not private) as a testability seam (see `StoreStateErrorMappingTests`). - /// Delegates to the shared `StoreServiceError.userFacingMessage` mapping. - func localizedMessage(for error: StoreServiceError) -> String { - error.userFacingMessage + func restorePurchases() async { + restoreState = .syncing + do { + switch try await service.restorePurchases() { + case .completed: restoreState = .completed + case .cancelled: restoreState = .cancelled + } + } catch let error as StoreServiceError { + errorMessage = localizedMessage(for: error) + restoreState = .failed + } catch { + errorMessage = localizedMessage(for: .purchaseFailed(reason: error.localizedDescription)) + restoreState = .failed } + } + + /// Clears the pending banner once the pending product's entitlement has arrived. Driven by the + /// view's `.onChange(of: service.ownedThemeIDs)`. The bundle expands to every purchasable theme + /// ID (its own product ID never appears in `ownedThemeIDs`), so it resolves via a superset check. + func reconcilePendingPurchase() { + guard let pending = pendingPurchase else { return } + let owned = service.ownedThemeIDs + let isResolved = pending.productID == StoreCatalog.Theme.bundleAll + ? owned.isSuperset(of: StoreCatalog.Theme.bundledThemeIDs) + : owned.contains(pending.productID) + if isResolved { pendingPurchase = nil } + } + + /// Internal (not private) as a testability seam (see `StoreStateErrorMappingTests`). + /// Delegates to the shared `StoreServiceError.userFacingMessage` mapping. + func localizedMessage(for error: StoreServiceError) -> String { + error.userFacingMessage + } } diff --git a/MC1/State/WhatsNewState.swift b/MC1/State/WhatsNewState.swift index 25a52079..27a87aa6 100644 --- a/MC1/State/WhatsNewState.swift +++ b/MC1/State/WhatsNewState.swift @@ -6,68 +6,67 @@ import SwiftUI @Observable @MainActor final class WhatsNewState { + private let defaults: UserDefaults - private let defaults: UserDefaults + /// The release to present, set by `evaluate` and cleared by `markShown`. + var pendingRelease: WhatsNewRelease? - /// The release to present, set by `evaluate` and cleared by `markShown`. - var pendingRelease: WhatsNewRelease? + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } - init(defaults: UserDefaults = .standard) { - self.defaults = defaults + /// The launch-time show/suppress decision as a pure function, testable without + /// `@MainActor` or `UserDefaults`. Returns the release to show, or `nil`. + static func resolve( + current: WhatsNewVersion, + baselineString: String?, + isOnboarded: Bool, + catalog: [WhatsNewRelease] + ) -> WhatsNewRelease? { + guard let release = catalog.first(where: { $0.version == current }), + !release.items.isEmpty else { + return nil } - - /// The launch-time show/suppress decision as a pure function, testable without - /// `@MainActor` or `UserDefaults`. Returns the release to show, or `nil`. - static func resolve( - current: WhatsNewVersion, - baselineString: String?, - isOnboarded: Bool, - catalog: [WhatsNewRelease] - ) -> WhatsNewRelease? { - guard let release = catalog.first(where: { $0.version == current }), - !release.items.isEmpty else { - return nil - } - guard let baselineString else { - // No baseline: an upgrader sees the notes, a brand-new install (still - // mid-onboarding) does not. The caller records the baseline either way. - return isOnboarded ? release : nil - } - guard let baseline = WhatsNewVersion(marketingVersion: baselineString) else { - return nil - } - return current > baseline ? release : nil + guard let baselineString else { + // No baseline: an upgrader sees the notes, a brand-new install (still + // mid-onboarding) does not. The caller records the baseline either way. + return isOnboarded ? release : nil + } + guard let baseline = WhatsNewVersion(marketingVersion: baselineString) else { + return nil } + return current > baseline ? release : nil + } - /// Runs the resolver once at launch. A show defers the baseline to `markShown`; a - /// suppress finalizes it now so the sheet never re-appears. An unparseable version - /// fails closed (neither presented nor recorded); screenshot mode is skipped. - func evaluate( - isOnboarded: Bool, - isScreenshotMode: Bool, - currentVersionString: String = Bundle.main.appVersion, - catalog: [WhatsNewRelease] = WhatsNewCatalog.releases - ) { - guard !isScreenshotMode else { return } - guard let current = WhatsNewVersion(marketingVersion: currentVersionString) else { return } + /// Runs the resolver once at launch. A show defers the baseline to `markShown`; a + /// suppress finalizes it now so the sheet never re-appears. An unparseable version + /// fails closed (neither presented nor recorded); screenshot mode is skipped. + func evaluate( + isOnboarded: Bool, + isScreenshotMode: Bool, + currentVersionString: String = Bundle.main.appVersion, + catalog: [WhatsNewRelease] = WhatsNewCatalog.releases + ) { + guard !isScreenshotMode else { return } + guard let current = WhatsNewVersion(marketingVersion: currentVersionString) else { return } - let baselineString = defaults.string(forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) - if let release = Self.resolve( - current: current, - baselineString: baselineString, - isOnboarded: isOnboarded, - catalog: catalog - ) { - pendingRelease = release - } else { - defaults.set(currentVersionString, forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) - } + let baselineString = defaults.string(forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) + if let release = Self.resolve( + current: current, + baselineString: baselineString, + isOnboarded: isOnboarded, + catalog: catalog + ) { + pendingRelease = release + } else { + defaults.set(currentVersionString, forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) } + } - /// Persists the baseline for the running version and clears `pendingRelease`. - /// Both swipe-to-dismiss and Continue route through here. - func markShown() { - defaults.set(Bundle.main.appVersion, forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) - pendingRelease = nil - } + /// Persists the baseline for the running version and clears `pendingRelease`. + /// Both swipe-to-dismiss and Continue route through here. + func markShown() { + defaults.set(Bundle.main.appVersion, forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) + pendingRelease = nil + } } diff --git a/MC1/Theme/AppColorSchemePreference.swift b/MC1/Theme/AppColorSchemePreference.swift index 75b9102a..f6d8e493 100644 --- a/MC1/Theme/AppColorSchemePreference.swift +++ b/MC1/Theme/AppColorSchemePreference.swift @@ -2,21 +2,21 @@ import SwiftUI /// Global app appearance preference, independent of which theme is selected. /// Raw values are pinned (persisted + backed up); a rename must not change the on-disk format. -enum AppColorSchemePreference: String, Sendable, CaseIterable, Identifiable { - // swiftlint:disable redundant_string_enum_value - case system = "system" - case light = "light" - case dark = "dark" - // swiftlint:enable redundant_string_enum_value +enum AppColorSchemePreference: String, CaseIterable, Identifiable { + case system + case light + case dark - var id: String { rawValue } + var id: String { + rawValue + } - /// `nil` means "defer to the system" — the value passed to `.preferredColorScheme(_:)`. - var colorScheme: ColorScheme? { - switch self { - case .system: nil - case .light: .light - case .dark: .dark - } + /// `nil` means "defer to the system" — the value passed to `.preferredColorScheme(_:)`. + var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark } + } } diff --git a/MC1/Theme/AppColors.swift b/MC1/Theme/AppColors.swift index 98e118e1..ccf6c413 100644 --- a/MC1/Theme/AppColors.swift +++ b/MC1/Theme/AppColors.swift @@ -6,27 +6,26 @@ import SwiftUI /// - Identity palettes: Colors for identifying senders, contacts, and nodes /// - UI elements: Colors for interface components like message bubbles enum AppColors { + // MARK: - Radio Status - // MARK: - Radio Status + /// Colors for the BLE status indicator toolbar icon. + enum Radio { + static let repeatMode = Color(hex: 0xFF9500) + static let connecting = Color(hex: 0x007AFF) + static let ready = Color(hex: 0x34C759) + } - /// Colors for the BLE status indicator toolbar icon. - enum Radio { - static let repeatMode = Color(hex: 0xFF9500) - static let connecting = Color(hex: 0x007AFF) - static let ready = Color(hex: 0x34C759) - } - - // MARK: - UI Elements + // MARK: - UI Elements - /// Colors for message bubbles and related UI. - enum Message { - static let outgoingBubble = Color(hex: 0x2463EB) - static let incomingBubble = Color(.systemGray5) + /// Colors for message bubbles and related UI. + enum Message { + static let outgoingBubble = Color(hex: 0x2463EB) + static let incomingBubble = Color(.systemGray5) - /// Failed-bubble background. `highContrast: true` returns the - /// system-adaptive `.systemRed`; `false` keeps the legacy translucent red. - static func outgoingBubbleFailed(highContrast: Bool) -> Color { - highContrast ? Color(.systemRed) : Color.red.opacity(0.8) - } + /// Failed-bubble background. `highContrast: true` returns the + /// system-adaptive `.systemRed`; `false` keeps the legacy translucent red. + static func outgoingBubbleFailed(highContrast: Bool) -> Color { + highContrast ? Color(.systemRed) : Color.red.opacity(0.8) } + } } diff --git a/MC1/Theme/AppearanceToken.swift b/MC1/Theme/AppearanceToken.swift index c17ca1db..ec5eb002 100644 --- a/MC1/Theme/AppearanceToken.swift +++ b/MC1/Theme/AppearanceToken.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// A `Hashable` string capturing the appearance inputs that change a chat row's resolved /// rendering: light/dark, increased-contrast, and the Dynamic Type size. The chat table @@ -7,35 +7,35 @@ import MC1Services /// change, so sender names and avatars repaint on an appearance switch and message bubbles /// re-measure and reflow on a Dynamic Type change even though the bubble cells diff as equal. enum AppearanceToken { - static func make( - colorScheme: ColorScheme, - contrast: ColorSchemeContrast, - dynamicTypeSize: DynamicTypeSize - ) -> String { - let appearance = colorScheme == .dark ? "dark" : "light" - let contrastTag = contrast == .increased ? "hc" : "std" - return "\(appearance)-\(contrastTag)-\(contentSizeCategoryToken(dynamicTypeSize))" - } + static func make( + colorScheme: ColorScheme, + contrast: ColorSchemeContrast, + dynamicTypeSize: DynamicTypeSize + ) -> String { + let appearance = colorScheme == .dark ? "dark" : "light" + let contrastTag = contrast == .increased ? "hc" : "std" + return "\(appearance)-\(contrastTag)-\(contentSizeCategoryToken(dynamicTypeSize))" + } - /// Stable, `Hashable` string for a Dynamic Type size, mirroring `EnvInputs.contentSizeCategory` - /// and the bubble text view's size-cache key. `DynamicTypeSize` is not `RawRepresentable`, so - /// the mapping is explicit; `.large` resolves to the shared unscaled baseline so this token and - /// `EnvInputs.default.contentSizeCategory` agree. - static func contentSizeCategoryToken(_ size: DynamicTypeSize) -> String { - switch size { - case .xSmall: "xSmall" - case .small: "small" - case .medium: "medium" - case .large: EnvInputs.defaultContentSizeCategory - case .xLarge: "xLarge" - case .xxLarge: "xxLarge" - case .xxxLarge: "xxxLarge" - case .accessibility1: "accessibility1" - case .accessibility2: "accessibility2" - case .accessibility3: "accessibility3" - case .accessibility4: "accessibility4" - case .accessibility5: "accessibility5" - @unknown default: "unknown" - } + /// Stable, `Hashable` string for a Dynamic Type size, mirroring `EnvInputs.contentSizeCategory` + /// and the bubble text view's size-cache key. `DynamicTypeSize` is not `RawRepresentable`, so + /// the mapping is explicit; `.large` resolves to the shared unscaled baseline so this token and + /// `EnvInputs.default.contentSizeCategory` agree. + static func contentSizeCategoryToken(_ size: DynamicTypeSize) -> String { + switch size { + case .xSmall: "xSmall" + case .small: "small" + case .medium: "medium" + case .large: EnvInputs.defaultContentSizeCategory + case .xLarge: "xLarge" + case .xxLarge: "xxLarge" + case .xxxLarge: "xxxLarge" + case .accessibility1: "accessibility1" + case .accessibility2: "accessibility2" + case .accessibility3: "accessibility3" + case .accessibility4: "accessibility4" + case .accessibility5: "accessibility5" + @unknown default: "unknown" } + } } diff --git a/MC1/Theme/AvatarCategory.swift b/MC1/Theme/AvatarCategory.swift index 8e568367..70425d99 100644 --- a/MC1/Theme/AvatarCategory.swift +++ b/MC1/Theme/AvatarCategory.swift @@ -2,23 +2,23 @@ import Foundation /// The three avatar categories that get one fixed color per theme (no per-entity variation): /// channels in the Chats list, and repeater / room-server nodes in the Nodes list. -enum AvatarCategory: Sendable { - case channel - case repeater - case room +enum AvatarCategory { + case channel + case repeater + case room - /// Order the category avatars claim gamut anchors in. On a collision the later category steps to - /// the next free anchor, so earlier categories keep their preferred color. A channel and a room - /// can share a Chats list, so their colors must stay distinct. - static let anchorPriority: [AvatarCategory] = [.channel, .repeater, .room] + /// Order the category avatars claim gamut anchors in. On a collision the later category steps to + /// the next free anchor, so earlier categories keep their preferred color. A channel and a room + /// can share a Chats list, so their colors must stay distinct. + static let anchorPriority: [AvatarCategory] = [.channel, .repeater, .room] - /// Stable seed fed to a theme's `IdentityGamut` so each category resolves to a distinct, - /// on-theme color. Prefixed to keep it from colliding with a real contact or channel name. - var gamutSeed: String { - switch self { - case .channel: return "__avatar_category_channel__" - case .repeater: return "__avatar_category_repeater__" - case .room: return "__avatar_category_room__" - } + /// Stable seed fed to a theme's `IdentityGamut` so each category resolves to a distinct, + /// on-theme color. Prefixed to keep it from colliding with a real contact or channel name. + var gamutSeed: String { + switch self { + case .channel: "__avatar_category_channel__" + case .repeater: "__avatar_category_repeater__" + case .room: "__avatar_category_room__" } + } } diff --git a/MC1/Theme/CategoryAvatarColors.swift b/MC1/Theme/CategoryAvatarColors.swift index 3a07ba13..07374fb8 100644 --- a/MC1/Theme/CategoryAvatarColors.swift +++ b/MC1/Theme/CategoryAvatarColors.swift @@ -4,16 +4,16 @@ import SwiftUI /// channel / repeater / room avatars to their historical fixed values. Every other theme leaves /// this `nil` and derives its three category colors from its `IdentityGamut` instead, which keeps /// them on-theme and guarantees WCAG AA against the theme's surfaces. -struct CategoryAvatarColors: Sendable, Equatable { - let channel: Color - let repeaterNode: Color - let room: Color +struct CategoryAvatarColors: Equatable { + let channel: Color + let repeaterNode: Color + let room: Color - func color(for category: AvatarCategory) -> Color { - switch category { - case .channel: return channel - case .repeater: return repeaterNode - case .room: return room - } + func color(for category: AvatarCategory) -> Color { + switch category { + case .channel: channel + case .repeater: repeaterNode + case .room: room } + } } diff --git a/MC1/Theme/CategoryHues.swift b/MC1/Theme/CategoryHues.swift index 8aba8ab8..3a0591c9 100644 --- a/MC1/Theme/CategoryHues.swift +++ b/MC1/Theme/CategoryHues.swift @@ -4,16 +4,16 @@ import Foundation /// theme's own anchors to look good and stay distinct. The System theme pins full colors via /// `CategoryAvatarColors` and leaves this `nil`; a gamut theme that also leaves it `nil` falls back /// to a distinct on-anchor pick. -struct CategoryHues: Sendable, Equatable { - let channel: Double - let repeater: Double - let room: Double +struct CategoryHues: Equatable { + let channel: Double + let repeater: Double + let room: Double - func hue(for category: AvatarCategory) -> Double { - switch category { - case .channel: channel - case .repeater: repeater - case .room: room - } + func hue(for category: AvatarCategory) -> Double { + switch category { + case .channel: channel + case .repeater: repeater + case .room: room } + } } diff --git a/MC1/Theme/EnvironmentValues+AppTheme.swift b/MC1/Theme/EnvironmentValues+AppTheme.swift index e6cccbf9..6ffa49e3 100644 --- a/MC1/Theme/EnvironmentValues+AppTheme.swift +++ b/MC1/Theme/EnvironmentValues+AppTheme.swift @@ -1,9 +1,9 @@ import SwiftUI extension EnvironmentValues { - /// The active cosmetic theme. Set once at the `MC1App` scene root from - /// `AppState.themeService.current`; read by themed surfaces (chat bubble fill, - /// list/thread backgrounds). Defaults to `Theme.default` so previews and any - /// subtree that is not under the scene-root injection still resolve a valid theme. - @Entry var appTheme: Theme = .default + /// The active cosmetic theme. Set once at the `MC1App` scene root from + /// `AppState.themeService.current`; read by themed surfaces (chat bubble fill, + /// list/thread backgrounds). Defaults to `Theme.default` so previews and any + /// subtree that is not under the scene-root injection still resolve a valid theme. + @Entry var appTheme: Theme = .default } diff --git a/MC1/Theme/IdentityGamut.swift b/MC1/Theme/IdentityGamut.swift index 343e40ff..5ff0890e 100644 --- a/MC1/Theme/IdentityGamut.swift +++ b/MC1/Theme/IdentityGamut.swift @@ -9,218 +9,222 @@ import SwiftUI /// All math is pure `Double` work — no UIKit, no SwiftUI environment — so it can run off the main /// actor inside the message-text bake. Callers resolve their surface colors to relative-luminance /// values and pass them in. -struct IdentityGamut: Sendable, Equatable { - /// Hue centers (degrees, 0..<360) that define the theme. A name lands near one of these and is - /// jittered within the gap to its neighbors, so the union covers a continuous slice of the wheel - /// while every color still reads as on-theme. - let hueAnchors: [Double] - /// Saturation band the solver draws from. The solver may dip below `lowerBound` only when a hue - /// physically cannot reach the luminance a dark background demands at full saturation. - let saturation: ClosedRange - - // MARK: - Tunables - - private static let degreesPerCircle = 360.0 - /// Floor the solver desaturates toward when a vivid hue cannot otherwise reach the luminance a - /// dark surface requires. Near zero so legibility is always achievable (a gray reaches any - /// luminance); colors only wash out this far in the rare high-contrast-on-dark case that needs it. - private static let saturationReachFloor = 0.0 - /// Step used when relaxing saturation to reach an unreachable luminance target. - private static let saturationRelaxStep = 0.04 - /// Fraction of the AA-feasible luminance span used as the dark-text variety window. Names spread - /// across `[darkVarietyFloor, boundary]` so brightness varies per identity without breaching AA. - private static let darkVarietyFloor = 0.45 - /// Upper luminance cap for light-text (dark-appearance) colors so they never go pure white. - private static let lightLuminanceCap = 0.86 - /// Added to the target contrast ratio so rendering drift (HSB rounding, wide-gamut resolution) - /// cannot drop the rendered color below the real AA floor. - private static let contrastSafetyMargin = 0.35 - /// Binary-search iteration count for mapping a luminance target to a brightness value. - private static let brightnessSearchIterations = 24 - /// Luminance above which a background is treated as "light" (dark text) rather than "dark". - private static let appearanceMidpointLuminance = 0.3 - - private static let contrastFlare = 0.05 - - // MARK: - Public - - /// Resolves the on-theme identity color for `name`, guaranteed to clear the contrast floor - /// against every surface in `backgroundLuminances` (all expected to share the current - /// appearance's polarity — e.g. the canvas and incoming-bubble luminances for one appearance). - func color(forName name: String, backgroundLuminances: [Double], highContrast: Bool, atHue: Double? = nil, atVariety: Double? = nil) -> Color { - let resolved = resolve(forName: name, backgroundLuminances: backgroundLuminances, highContrast: highContrast, atHue: atHue, atVariety: atVariety) - // Build the color from the exact sRGB components the solver evaluated, rather than letting - // `Color(hue:saturation:brightness:)` re-derive them in a wider gamut: that would drift the - // rendered luminance off the solved value and could undercut the AA floor the solver targeted. - let rgb = Self.hsbToRGB(hue: resolved.hue, saturation: resolved.saturation, brightness: resolved.brightness) - return Color(.sRGB, red: rgb.red, green: rgb.green, blue: rgb.blue) +struct IdentityGamut: Equatable { + /// Hue centers (degrees, 0..<360) that define the theme. A name lands near one of these and is + /// jittered within the gap to its neighbors, so the union covers a continuous slice of the wheel + /// while every color still reads as on-theme. + let hueAnchors: [Double] + /// Saturation band the solver draws from. The solver may dip below `lowerBound` only when a hue + /// physically cannot reach the luminance a dark background demands at full saturation. + let saturation: ClosedRange + + // MARK: - Tunables + + private static let degreesPerCircle = 360.0 + /// Floor the solver desaturates toward when a vivid hue cannot otherwise reach the luminance a + /// dark surface requires. Near zero so legibility is always achievable (a gray reaches any + /// luminance); colors only wash out this far in the rare high-contrast-on-dark case that needs it. + private static let saturationReachFloor = 0.0 + /// Step used when relaxing saturation to reach an unreachable luminance target. + private static let saturationRelaxStep = 0.04 + /// Fraction of the AA-feasible luminance span used as the dark-text variety window. Names spread + /// across `[darkVarietyFloor, boundary]` so brightness varies per identity without breaching AA. + private static let darkVarietyFloor = 0.45 + /// Upper luminance cap for light-text (dark-appearance) colors so they never go pure white. + private static let lightLuminanceCap = 0.86 + /// Added to the target contrast ratio so rendering drift (HSB rounding, wide-gamut resolution) + /// cannot drop the rendered color below the real AA floor. + private static let contrastSafetyMargin = 0.35 + /// Binary-search iteration count for mapping a luminance target to a brightness value. + private static let brightnessSearchIterations = 24 + /// Luminance above which a background is treated as "light" (dark text) rather than "dark". + private static let appearanceMidpointLuminance = 0.3 + + private static let contrastFlare = 0.05 + + // MARK: - Public + + /// Resolves the on-theme identity color for `name`, guaranteed to clear the contrast floor + /// against every surface in `backgroundLuminances` (all expected to share the current + /// appearance's polarity — e.g. the canvas and incoming-bubble luminances for one appearance). + func color(forName name: String, backgroundLuminances: [Double], highContrast: Bool, atHue: Double? = nil, atVariety: Double? = nil) -> Color { + let resolved = resolve(forName: name, backgroundLuminances: backgroundLuminances, highContrast: highContrast, atHue: atHue, atVariety: atVariety) + // Build the color from the exact sRGB components the solver evaluated, rather than letting + // `Color(hue:saturation:brightness:)` re-derive them in a wider gamut: that would drift the + // rendered luminance off the solved value and could undercut the AA floor the solver targeted. + let rgb = Self.hsbToRGB(hue: resolved.hue, saturation: resolved.saturation, brightness: resolved.brightness) + return Color(.sRGB, red: rgb.red, green: rgb.green, blue: rgb.blue) + } + + /// Sorted hue anchors, the on-theme hues a name can land on before jitter. + var sortedAnchors: [Double] { + hueAnchors.sorted() + } + + /// Assigns each name in `names` a distinct anchor hue (no jitter), bumping to the next anchor on + /// collision so a small fixed set — the channel / repeater / room category avatars — never share a + /// color. Earlier names in the array win their preferred anchor; later ones step to the next free + /// one. Stays on-palette because every result is one of the theme's declared anchors. + func distinctAnchorHues(forNames names: [String]) -> [Double] { + let anchors = sortedAnchors + var taken = Set() + return names.map { name in + var index = Int(Self.fnv1a(name) % UInt64(anchors.count)) + while taken.contains(index) { + index = (index + 1) % anchors.count + } + taken.insert(index) + return anchors[index] } - - /// Sorted hue anchors, the on-theme hues a name can land on before jitter. - var sortedAnchors: [Double] { hueAnchors.sorted() } - - /// Assigns each name in `names` a distinct anchor hue (no jitter), bumping to the next anchor on - /// collision so a small fixed set — the channel / repeater / room category avatars — never share a - /// color. Earlier names in the array win their preferred anchor; later ones step to the next free - /// one. Stays on-palette because every result is one of the theme's declared anchors. - func distinctAnchorHues(forNames names: [String]) -> [Double] { - let anchors = sortedAnchors - var taken = Set() - return names.map { name in - var index = Int(Self.fnv1a(name) % UInt64(anchors.count)) - while taken.contains(index) { index = (index + 1) % anchors.count } - taken.insert(index) - return anchors[index] - } - } - - /// The glyph (initials / icon) color that reads on a fill of the given luminance: white when the - /// fill is dark enough, otherwise near-black. One of the two always clears AA for any fill. - static func glyphColor(forFillLuminance luminance: Double) -> Color { - let whiteContrast = (1.0 + contrastFlare) / (luminance + contrastFlare) - let blackContrast = (luminance + contrastFlare) / contrastFlare - return whiteContrast >= blackContrast ? .white : Color(white: 0.1) - } - - // MARK: - Solver (exposed at package level for tests) - - /// Hue (degrees), saturation, and brightness a name resolves to, before constructing the `Color`. - /// Exposed so tests can resolve the same values the renderer will and assert AA on the real color. - /// `atHue` overrides the jittered hue with a fixed one — used to pin category avatars to an anchor — - /// while saturation and brightness still derive from the name's seed. `atVariety` overrides the - /// per-name brightness draw (0 = darkest legible, 1 = lightest legible); category avatars pass 0 so - /// they render as deep, consistent swatches rather than washed-out brights. - func resolve( - forName name: String, - backgroundLuminances: [Double], - highContrast: Bool, - atHue: Double? = nil, - atVariety: Double? = nil - ) -> (hue: Double, saturation: Double, brightness: Double) { - let seed = Self.fnv1a(name) - let hue = atHue ?? self.hue(for: seed) - let baseSaturation = pickSaturation(for: seed) - let varietyFraction = atVariety ?? Self.fraction(seed, shift: 48) - let floor = (highContrast ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor) + Self.contrastSafetyMargin - - let backgrounds = backgroundLuminances.isEmpty ? [1.0] : backgroundLuminances - let darkText = (backgrounds.min() ?? 1.0) >= Self.appearanceMidpointLuminance - - if darkText { - // Light surfaces -> dark text. Binding surface is the darkest (least headroom). - let bindingBackground = backgrounds.min() ?? 1.0 - let maxLuminance = (bindingBackground + Self.contrastFlare) / floor - Self.contrastFlare - let safeMax = max(maxLuminance, 0) - let targetLuminance = safeMax * (Self.darkVarietyFloor + (1 - Self.darkVarietyFloor) * varietyFraction) - let brightness = brightnessFor(luminance: targetLuminance, hue: hue, saturation: baseSaturation) - return (hue, baseSaturation, brightness) - } else { - // Dark surfaces -> light text. Binding surface is the lightest (least headroom). - let bindingBackground = backgrounds.max() ?? 0.0 - // Required luminance to clear the floor. Not capped: a light dark-mode bubble can demand a - // near-white text, and undershooting it would breach AA. - let requiredLuminance = max(floor * (bindingBackground + Self.contrastFlare) - Self.contrastFlare, 0) - // A vivid hue caps the luminance it can reach at full brightness; desaturate as far as - // needed to reach the requirement. Legibility outranks saturation in the rare - // high-contrast-on-dark case that forces this; a gray reaches any luminance. - var saturation = baseSaturation - while saturation > Self.saturationReachFloor - && Self.luminance(hue: hue, saturation: saturation, brightness: 1.0) < requiredLuminance { - saturation = max(Self.saturationReachFloor, saturation - Self.saturationRelaxStep) - } - // Vary lighter than the minimum (lighter = more contrast = still AA), capped at what the - // hue can reach so the target is always achievable. - let reachableMax = Self.luminance(hue: hue, saturation: saturation, brightness: 1.0) - let ceiling = max(requiredLuminance, min(reachableMax, Self.lightLuminanceCap)) - let targetLuminance = min(requiredLuminance + (ceiling - requiredLuminance) * varietyFraction, reachableMax) - let brightness = brightnessFor(luminance: targetLuminance, hue: hue, saturation: saturation) - return (hue, saturation, brightness) - } - } - - // MARK: - Hue / saturation selection - - private func hue(for seed: UInt64) -> Double { - let sorted = hueAnchors.sorted() - let index = Int(seed % UInt64(sorted.count)) - let anchor = sorted[index] - // Gap to each neighbor on the circle; jitter within half the gap so anchors tile the wheel. - let next = sorted[(index + 1) % sorted.count] - let prev = sorted[(index - 1 + sorted.count) % sorted.count] - let gapNext = Self.circularGap(from: anchor, to: next) - let gapPrev = Self.circularGap(from: prev, to: anchor) - let fraction = Self.fraction(seed, shift: 16) * 2 - 1 - let jitter = fraction >= 0 ? fraction * gapNext / 2 : fraction * gapPrev / 2 - return (anchor + jitter).truncatingRemainder(dividingBy: Self.degreesPerCircle) + (anchor + jitter < 0 ? Self.degreesPerCircle : 0) - } - - private func pickSaturation(for seed: UInt64) -> Double { - saturation.lowerBound + (saturation.upperBound - saturation.lowerBound) * Self.fraction(seed, shift: 32) + } + + /// The glyph (initials / icon) color that reads on a fill of the given luminance: white when the + /// fill is dark enough, otherwise near-black. One of the two always clears AA for any fill. + static func glyphColor(forFillLuminance luminance: Double) -> Color { + let whiteContrast = (1.0 + contrastFlare) / (luminance + contrastFlare) + let blackContrast = (luminance + contrastFlare) / contrastFlare + return whiteContrast >= blackContrast ? .white : Color(white: 0.1) + } + + // MARK: - Solver (exposed at package level for tests) + + /// Hue (degrees), saturation, and brightness a name resolves to, before constructing the `Color`. + /// Exposed so tests can resolve the same values the renderer will and assert AA on the real color. + /// `atHue` overrides the jittered hue with a fixed one — used to pin category avatars to an anchor — + /// while saturation and brightness still derive from the name's seed. `atVariety` overrides the + /// per-name brightness draw (0 = darkest legible, 1 = lightest legible); category avatars pass 0 so + /// they render as deep, consistent swatches rather than washed-out brights. + func resolve( + forName name: String, + backgroundLuminances: [Double], + highContrast: Bool, + atHue: Double? = nil, + atVariety: Double? = nil + ) -> (hue: Double, saturation: Double, brightness: Double) { + let seed = Self.fnv1a(name) + let hue = atHue ?? hue(for: seed) + let baseSaturation = pickSaturation(for: seed) + let varietyFraction = atVariety ?? Self.fraction(seed, shift: 48) + let floor = (highContrast ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor) + Self.contrastSafetyMargin + + let backgrounds = backgroundLuminances.isEmpty ? [1.0] : backgroundLuminances + let darkText = (backgrounds.min() ?? 1.0) >= Self.appearanceMidpointLuminance + + if darkText { + // Light surfaces -> dark text. Binding surface is the darkest (least headroom). + let bindingBackground = backgrounds.min() ?? 1.0 + let maxLuminance = (bindingBackground + Self.contrastFlare) / floor - Self.contrastFlare + let safeMax = max(maxLuminance, 0) + let targetLuminance = safeMax * (Self.darkVarietyFloor + (1 - Self.darkVarietyFloor) * varietyFraction) + let brightness = brightnessFor(luminance: targetLuminance, hue: hue, saturation: baseSaturation) + return (hue, baseSaturation, brightness) + } else { + // Dark surfaces -> light text. Binding surface is the lightest (least headroom). + let bindingBackground = backgrounds.max() ?? 0.0 + // Required luminance to clear the floor. Not capped: a light dark-mode bubble can demand a + // near-white text, and undershooting it would breach AA. + let requiredLuminance = max(floor * (bindingBackground + Self.contrastFlare) - Self.contrastFlare, 0) + // A vivid hue caps the luminance it can reach at full brightness; desaturate as far as + // needed to reach the requirement. Legibility outranks saturation in the rare + // high-contrast-on-dark case that forces this; a gray reaches any luminance. + var saturation = baseSaturation + while saturation > Self.saturationReachFloor, + Self.luminance(hue: hue, saturation: saturation, brightness: 1.0) < requiredLuminance { + saturation = max(Self.saturationReachFloor, saturation - Self.saturationRelaxStep) + } + // Vary lighter than the minimum (lighter = more contrast = still AA), capped at what the + // hue can reach so the target is always achievable. + let reachableMax = Self.luminance(hue: hue, saturation: saturation, brightness: 1.0) + let ceiling = max(requiredLuminance, min(reachableMax, Self.lightLuminanceCap)) + let targetLuminance = min(requiredLuminance + (ceiling - requiredLuminance) * varietyFraction, reachableMax) + let brightness = brightnessFor(luminance: targetLuminance, hue: hue, saturation: saturation) + return (hue, saturation, brightness) } - - private static func circularGap(from: Double, to: Double) -> Double { - let raw = (to - from).truncatingRemainder(dividingBy: degreesPerCircle) - return raw <= 0 ? raw + degreesPerCircle : raw + } + + // MARK: - Hue / saturation selection + + private func hue(for seed: UInt64) -> Double { + let sorted = hueAnchors.sorted() + let index = Int(seed % UInt64(sorted.count)) + let anchor = sorted[index] + // Gap to each neighbor on the circle; jitter within half the gap so anchors tile the wheel. + let next = sorted[(index + 1) % sorted.count] + let prev = sorted[(index - 1 + sorted.count) % sorted.count] + let gapNext = Self.circularGap(from: anchor, to: next) + let gapPrev = Self.circularGap(from: prev, to: anchor) + let fraction = Self.fraction(seed, shift: 16) * 2 - 1 + let jitter = fraction >= 0 ? fraction * gapNext / 2 : fraction * gapPrev / 2 + return (anchor + jitter).truncatingRemainder(dividingBy: Self.degreesPerCircle) + (anchor + jitter < 0 ? Self.degreesPerCircle : 0) + } + + private func pickSaturation(for seed: UInt64) -> Double { + saturation.lowerBound + (saturation.upperBound - saturation.lowerBound) * Self.fraction(seed, shift: 32) + } + + private static func circularGap(from: Double, to: Double) -> Double { + let raw = (to - from).truncatingRemainder(dividingBy: degreesPerCircle) + return raw <= 0 ? raw + degreesPerCircle : raw + } + + // MARK: - Luminance / brightness math + + private func brightnessFor(luminance target: Double, hue: Double, saturation: Double) -> Double { + var low = 0.0 + var high = 1.0 + for _ in 0.. Double { - var low = 0.0 - var high = 1.0 - for _ in 0.. Double { + let rgb = hsbToRGB(hue: hue, saturation: saturation, brightness: brightness) + return WCAGContrast.relativeLuminance(red: rgb.red, green: rgb.green, blue: rgb.blue) + } + + /// Standard sRGB HSB-to-RGB conversion (`hue` in degrees). Matches `Color(hue:saturation:brightness:)` + /// closely enough that the solver's luminance prediction tracks the rendered color; the + /// `contrastSafetyMargin` absorbs any residual drift. + private static func hsbToRGB(hue: Double, saturation: Double, brightness: Double) -> (red: Double, green: Double, blue: Double) { + let h = (hue.truncatingRemainder(dividingBy: degreesPerCircle) + degreesPerCircle).truncatingRemainder(dividingBy: degreesPerCircle) / 60 + let sector = Int(h) % 6 + let fraction = h - Double(Int(h)) + let p = brightness * (1 - saturation) + let q = brightness * (1 - fraction * saturation) + let t = brightness * (1 - (1 - fraction) * saturation) + switch sector { + case 0: return (brightness, t, p) + case 1: return (q, brightness, p) + case 2: return (p, brightness, t) + case 3: return (p, q, brightness) + case 4: return (t, p, brightness) + default: return (brightness, p, q) } - - private static func luminance(hue: Double, saturation: Double, brightness: Double) -> Double { - let rgb = hsbToRGB(hue: hue, saturation: saturation, brightness: brightness) - return WCAGContrast.relativeLuminance(red: rgb.red, green: rgb.green, blue: rgb.blue) - } - - /// Standard sRGB HSB-to-RGB conversion (`hue` in degrees). Matches `Color(hue:saturation:brightness:)` - /// closely enough that the solver's luminance prediction tracks the rendered color; the - /// `contrastSafetyMargin` absorbs any residual drift. - private static func hsbToRGB(hue: Double, saturation: Double, brightness: Double) -> (red: Double, green: Double, blue: Double) { - let h = (hue.truncatingRemainder(dividingBy: degreesPerCircle) + degreesPerCircle).truncatingRemainder(dividingBy: degreesPerCircle) / 60 - let sector = Int(h) % 6 - let fraction = h - Double(Int(h)) - let p = brightness * (1 - saturation) - let q = brightness * (1 - fraction * saturation) - let t = brightness * (1 - (1 - fraction) * saturation) - switch sector { - case 0: return (brightness, t, p) - case 1: return (q, brightness, p) - case 2: return (p, brightness, t) - case 3: return (p, q, brightness) - case 4: return (t, p, brightness) - default: return (brightness, p, q) - } - } - - // MARK: - Hashing - - /// FNV-1a 64-bit: a stable, well-distributed hash. Stable across launches (unlike `Hasher`, which - /// is per-process seeded) and free of the order-insensitivity that makes XOR-folding collide - /// anagrams and cluster on a continuous hue space. - private static func fnv1a(_ string: String) -> UInt64 { - var hash: UInt64 = 0xcbf2_9ce4_8422_2325 - for byte in string.utf8 { - hash ^= UInt64(byte) - hash = hash &* 0x0000_0100_0000_01b3 - } - return hash - } - - /// A 0...1 fraction from 16 bits of the hash starting at `shift`, giving independent draws for - /// hue jitter, saturation, and brightness variety from one hash. - private static func fraction(_ seed: UInt64, shift: UInt64) -> Double { - Double((seed >> shift) & 0xFFFF) / Double(0xFFFF) + } + + // MARK: - Hashing + + /// FNV-1a 64-bit: a stable, well-distributed hash. Stable across launches (unlike `Hasher`, which + /// is per-process seeded) and free of the order-insensitivity that makes XOR-folding collide + /// anagrams and cluster on a continuous hue space. + private static func fnv1a(_ string: String) -> UInt64 { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in string.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 } + return hash + } + + /// A 0...1 fraction from 16 bits of the hash starting at `shift`, giving independent draws for + /// hue jitter, saturation, and brightness variety from one hash. + private static func fraction(_ seed: UInt64, shift: UInt64) -> Double { + Double((seed >> shift) & 0xFFFF) / Double(0xFFFF) + } } diff --git a/MC1/Theme/Theme+AvatarColors.swift b/MC1/Theme/Theme+AvatarColors.swift index cffe0f2c..46fea7ea 100644 --- a/MC1/Theme/Theme+AvatarColors.swift +++ b/MC1/Theme/Theme+AvatarColors.swift @@ -10,91 +10,92 @@ import UIKit /// already runs there. @MainActor extension Theme { + /// Relative luminances of the surfaces avatars and identity names sit on, resolved for the given + /// appearance. The canvas (or the system background, for the surfaceless default theme) and the + /// incoming bubble are the two surfaces an identity color must stay legible against. + func avatarSurfaceLuminances(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> [Double] { + let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) + let surfaces = [surfaces?.canvas ?? Color(.systemBackground), incomingBubbleColor] + return surfaces.map { WCAGContrast.relativeLuminance(of: UIColor($0).resolvedColor(with: traits)) } + } - /// Relative luminances of the surfaces avatars and identity names sit on, resolved for the given - /// appearance. The canvas (or the system background, for the surfaceless default theme) and the - /// incoming bubble are the two surfaces an identity color must stay legible against. - func avatarSurfaceLuminances(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> [Double] { - let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) - let surfaces = [self.surfaces?.canvas ?? Color(.systemBackground), incomingBubbleColor] - return surfaces.map { WCAGContrast.relativeLuminance(of: UIColor($0).resolvedColor(with: traits)) } - } - - /// Relative luminance of the only surface a category avatar (channel / repeater / room) sits on: - /// the list canvas. Unlike identity colors, these never render as a sender name on the incoming - /// bubble, so constraining them against it only forces them needlessly bright. - func categorySurfaceLuminance(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Double { - let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) - let canvas = self.surfaces?.canvas ?? Color(.systemBackground) - return WCAGContrast.relativeLuminance(of: UIColor(canvas).resolvedColor(with: traits)) - } + /// Relative luminance of the only surface a category avatar (channel / repeater / room) sits on: + /// the list canvas. Unlike identity colors, these never render as a sender name on the incoming + /// bubble, so constraining them against it only forces them needlessly bright. + func categorySurfaceLuminance(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Double { + let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) + let canvas = surfaces?.canvas ?? Color(.systemBackground) + return WCAGContrast.relativeLuminance(of: UIColor(canvas).resolvedColor(with: traits)) + } - /// Color for a contact avatar / channel sender name / mention with the given identity name. - func identityColor(forName name: String, colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Color { - identityGamut.color( - forName: name, - backgroundLuminances: avatarSurfaceLuminances(colorScheme: colorScheme, contrast: contrast), - highContrast: contrast == .increased - ) - } + /// Color for a contact avatar / channel sender name / mention with the given identity name. + func identityColor(forName name: String, colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Color { + identityGamut.color( + forName: name, + backgroundLuminances: avatarSurfaceLuminances(colorScheme: colorScheme, contrast: contrast), + highContrast: contrast == .increased + ) + } - /// The single fixed color for a channel / repeater / room avatar. The System theme pins these to - /// legacy values; every other theme resolves each category at its curated `categoryHue`, against - /// the list canvas only and at the darkest legible brightness, so the swatch is a deep, on-theme - /// color (and the three stay distinct even when a room and a channel share the Chats list). - func categoryAvatarColor(_ category: AvatarCategory, colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Color { - if let override = categoryAvatarOverride { return override.color(for: category) } - return identityGamut.color( - forName: category.gamutSeed, - backgroundLuminances: [categorySurfaceLuminance(colorScheme: colorScheme, contrast: contrast)], - highContrast: contrast == .increased, - atHue: categoryHue(for: category), - atVariety: Self.categoryDarkestVariety - ) - } + /// The single fixed color for a channel / repeater / room avatar. The System theme pins these to + /// legacy values; every other theme resolves each category at its curated `categoryHue`, against + /// the list canvas only and at the darkest legible brightness, so the swatch is a deep, on-theme + /// color (and the three stay distinct even when a room and a channel share the Chats list). + func categoryAvatarColor(_ category: AvatarCategory, colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Color { + if let override = categoryAvatarOverride { return override.color(for: category) } + return identityGamut.color( + forName: category.gamutSeed, + backgroundLuminances: [categorySurfaceLuminance(colorScheme: colorScheme, contrast: contrast)], + highContrast: contrast == .increased, + atHue: categoryHue(for: category), + atVariety: Self.categoryDarkestVariety + ) + } - /// Glyph (initials / icon) color for an avatar of the given fill. The System category override - /// keeps the historical white glyph; gamut-derived avatars adapt so the glyph reads whether the - /// fill resolved light (dark appearance) or dark (light appearance). - func avatarGlyphColor( - forFill fill: Color, - usesCategoryOverride: Bool, - colorScheme: ColorScheme, - contrast: ColorSchemeContrast - ) -> Color { - guard !usesCategoryOverride else { return .white } - let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) - let luminance = WCAGContrast.relativeLuminance(of: UIColor(fill).resolvedColor(with: traits)) - return IdentityGamut.glyphColor(forFillLuminance: luminance) - } + /// Glyph (initials / icon) color for an avatar of the given fill. The System category override + /// keeps the historical white glyph; gamut-derived avatars adapt so the glyph reads whether the + /// fill resolved light (dark appearance) or dark (light appearance). + func avatarGlyphColor( + forFill fill: Color, + usesCategoryOverride: Bool, + colorScheme: ColorScheme, + contrast: ColorSchemeContrast + ) -> Color { + guard !usesCategoryOverride else { return .white } + let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) + let luminance = WCAGContrast.relativeLuminance(of: UIColor(fill).resolvedColor(with: traits)) + return IdentityGamut.glyphColor(forFillLuminance: luminance) + } - /// True when this theme pins its category avatars to fixed colors (System) rather than deriving - /// them from the gamut. Drives the glyph choice in the avatar views. - var usesCategoryAvatarOverride: Bool { categoryAvatarOverride != nil } + /// True when this theme pins its category avatars to fixed colors (System) rather than deriving + /// them from the gamut. Drives the glyph choice in the avatar views. + var usesCategoryAvatarOverride: Bool { + categoryAvatarOverride != nil + } - static func traitCollection(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> UITraitCollection { - UITraitCollection { traits in - traits.userInterfaceStyle = colorScheme == .dark ? .dark : .light - traits.accessibilityContrast = contrast == .increased ? .high : .unspecified - } + static func traitCollection(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> UITraitCollection { + UITraitCollection { traits in + traits.userInterfaceStyle = colorScheme == .dark ? .dark : .light + traits.accessibilityContrast = contrast == .increased ? .high : .unspecified } + } } extension Theme { - /// Brightness draw category avatars resolve at: the darkest legible value (no variety), so they - /// read as deep, on-theme swatches instead of the washed-out brights the bubble surface forced. - static let categoryDarkestVariety = 0.0 + /// Brightness draw category avatars resolve at: the darkest legible value (no variety), so they + /// read as deep, on-theme swatches instead of the washed-out brights the bubble surface forced. + static let categoryDarkestVariety = 0.0 - /// Hue (degrees) a category avatar resolves at: the theme's curated `categoryHues` value, or a - /// distinct on-anchor pick for a gamut theme that hasn't curated them. Pure hue selection, so it - /// stays off the main actor for the color bake and tests. - func categoryHue(for category: AvatarCategory) -> Double { - if let curated = categoryHues?.hue(for: category) { return curated } - let hues = identityGamut.distinctAnchorHues(forNames: AvatarCategory.anchorPriority.map(\.gamutSeed)) - switch category { - case .channel: return hues[0] - case .repeater: return hues[1] - case .room: return hues[2] - } + /// Hue (degrees) a category avatar resolves at: the theme's curated `categoryHues` value, or a + /// distinct on-anchor pick for a gamut theme that hasn't curated them. Pure hue selection, so it + /// stays off the main actor for the color bake and tests. + func categoryHue(for category: AvatarCategory) -> Double { + if let curated = categoryHues?.hue(for: category) { return curated } + let hues = identityGamut.distinctAnchorHues(forNames: AvatarCategory.anchorPriority.map(\.gamutSeed)) + switch category { + case .channel: return hues[0] + case .repeater: return hues[1] + case .room: return hues[2] } + } } diff --git a/MC1/Theme/Theme+LocalizedName.swift b/MC1/Theme/Theme+LocalizedName.swift index 2403cc38..c15e3be7 100644 --- a/MC1/Theme/Theme+LocalizedName.swift +++ b/MC1/Theme/Theme+LocalizedName.swift @@ -1,41 +1,41 @@ import Foundation extension Theme { - /// The user-facing theme name, resolved through static `L10n` members for the known themes - /// (compile-time-safe key references). A theme added to the registry without a case here is a - /// bug: it traps in debug (caught by `ThemeLocalizedNameTests`) and, in release, falls back to - /// a dynamic lookup of `displayNameKey` so the user never sees a raw dotted key string. - var localizedName: String { - switch id { - case Theme.default.id: return L10n.Settings.Support.Theme.default - case Theme.ember.id: return L10n.Settings.Support.Theme.ember - case Theme.fern.id: return L10n.Settings.Support.Theme.fern - case Theme.marine.id: return L10n.Settings.Support.Theme.marine - case Theme.olive.id: return L10n.Settings.Support.Theme.olive - case Theme.lavender.id: return L10n.Settings.Support.Theme.lavender - // Proper nouns, never translated: Sakura is romanized Japanese; Solarized, Nord, and - // Catppuccin are the names of the upstream MIT-licensed palettes. - case Theme.sakura.id: return "Sakura" - case Theme.solarized.id: return "Solarized" - case Theme.nord.id: return "Nord" - case Theme.catppuccin.id: return "Catppuccin" - default: - assertionFailure("Theme '\(id)' is missing a localizedName case") - return dynamicallyResolvedName - } + /// The user-facing theme name, resolved through static `L10n` members for the known themes + /// (compile-time-safe key references). A theme added to the registry without a case here is a + /// bug: it traps in debug (caught by `ThemeLocalizedNameTests`) and, in release, falls back to + /// a dynamic lookup of `displayNameKey` so the user never sees a raw dotted key string. + var localizedName: String { + switch id { + case Theme.default.id: return L10n.Settings.Support.Theme.default + case Theme.ember.id: return L10n.Settings.Support.Theme.ember + case Theme.fern.id: return L10n.Settings.Support.Theme.fern + case Theme.marine.id: return L10n.Settings.Support.Theme.marine + case Theme.olive.id: return L10n.Settings.Support.Theme.olive + case Theme.lavender.id: return L10n.Settings.Support.Theme.lavender + // Proper nouns, never translated: Sakura is romanized Japanese; Solarized, Nord, and + // Catppuccin are the names of the upstream MIT-licensed palettes. + case Theme.sakura.id: return "Sakura" + case Theme.solarized.id: return "Solarized" + case Theme.nord.id: return "Nord" + case Theme.catppuccin.id: return "Catppuccin" + default: + assertionFailure("Theme '\(id)' is missing a localizedName case") + return dynamicallyResolvedName } + } - /// Fallback for a registry theme that has a localization key but no explicit `localizedName` - /// case above. Resolves `displayNameKey` against the Settings strings table; `displayNameKey` is - /// the SwiftGen accessor path (`Settings.Support.Theme.X`), and the on-disk key is that path - /// minus the leading table component. Proper-noun themes carry no key — they return their fixed - /// name from the switch and never reach here; a keyless theme that somehow does falls back to its - /// raw id rather than crashing. - private var dynamicallyResolvedName: String { - guard let displayNameKey else { return id } - let table = "Settings" - let prefix = table + "." - let key = displayNameKey.hasPrefix(prefix) ? String(displayNameKey.dropFirst(prefix.count)) : displayNameKey - return Bundle.main.localizedString(forKey: key, value: displayNameKey, table: table) - } + /// Fallback for a registry theme that has a localization key but no explicit `localizedName` + /// case above. Resolves `displayNameKey` against the Settings strings table; `displayNameKey` is + /// the SwiftGen accessor path (`Settings.Support.Theme.X`), and the on-disk key is that path + /// minus the leading table component. Proper-noun themes carry no key — they return their fixed + /// name from the switch and never reach here; a keyless theme that somehow does falls back to its + /// raw id rather than crashing. + private var dynamicallyResolvedName: String { + guard let displayNameKey else { return id } + let table = "Settings" + let prefix = table + "." + let key = displayNameKey.hasPrefix(prefix) ? String(displayNameKey.dropFirst(prefix.count)) : displayNameKey + return Bundle.main.localizedString(forKey: key, value: displayNameKey, table: table) + } } diff --git a/MC1/Theme/Theme.swift b/MC1/Theme/Theme.swift index 495c252f..337e304c 100644 --- a/MC1/Theme/Theme.swift +++ b/MC1/Theme/Theme.swift @@ -1,255 +1,255 @@ -import SwiftUI import MC1Services +import SwiftUI /// A cosmetic theme: accent + chat colors + optional surfaces + optional forced color scheme. /// Built-in themes are static factory values (below), not subtypes. -struct Theme: Sendable, Identifiable, Equatable { - let id: String - /// SwiftGen key path (`Settings.Support.Theme.X`) for themes whose name is localized. `nil` for - /// proper-noun themes (Sakura, Solarized, Nord, Catppuccin) that are never translated and resolve - /// their fixed name from the explicit `localizedName` switch instead. - let displayNameKey: String? - let productID: String? - let accentColor: Color - let outgoingTextColor: Color - let hashtagColor: Color - let preferredColorScheme: ColorScheme? - let surfaces: Surfaces? - /// The theme's identity-color space for contact avatars, channel sender names, and mentions. - /// Varied per identity, drawn from the theme's character hues. - let identityGamut: IdentityGamut - /// Set only by the System theme to pin channel / repeater / room avatars to fixed legacy colors. - /// `nil` for every other theme, which derives its category colors from `identityGamut`. - let categoryAvatarOverride: CategoryAvatarColors? - /// Curated channel / repeater / room avatar hues for a gamut theme, each picked from the theme's - /// anchors to be on-palette and distinct. `nil` falls back to a distinct auto-anchor pick. - let categoryHues: CategoryHues? - - init( - id: String, - displayNameKey: String?, - productID: String?, - accentColor: Color, - outgoingTextColor: Color, - hashtagColor: Color, - preferredColorScheme: ColorScheme?, - surfaces: Surfaces? = nil, - identityGamut: IdentityGamut, - categoryAvatarOverride: CategoryAvatarColors? = nil, - categoryHues: CategoryHues? = nil - ) { - self.id = id - self.displayNameKey = displayNameKey - self.productID = productID - self.accentColor = accentColor - self.outgoingTextColor = outgoingTextColor - self.hashtagColor = hashtagColor - self.preferredColorScheme = preferredColorScheme - self.surfaces = surfaces - self.identityGamut = identityGamut - self.categoryAvatarOverride = categoryAvatarOverride - self.categoryHues = categoryHues - } +struct Theme: Identifiable, Equatable { + let id: String + /// SwiftGen key path (`Settings.Support.Theme.X`) for themes whose name is localized. `nil` for + /// proper-noun themes (Sakura, Solarized, Nord, Catppuccin) that are never translated and resolve + /// their fixed name from the explicit `localizedName` switch instead. + let displayNameKey: String? + let productID: String? + let accentColor: Color + let outgoingTextColor: Color + let hashtagColor: Color + let preferredColorScheme: ColorScheme? + let surfaces: Surfaces? + /// The theme's identity-color space for contact avatars, channel sender names, and mentions. + /// Varied per identity, drawn from the theme's character hues. + let identityGamut: IdentityGamut + /// Set only by the System theme to pin channel / repeater / room avatars to fixed legacy colors. + /// `nil` for every other theme, which derives its category colors from `identityGamut`. + let categoryAvatarOverride: CategoryAvatarColors? + /// Curated channel / repeater / room avatar hues for a gamut theme, each picked from the theme's + /// anchors to be on-palette and distinct. `nil` falls back to a distinct auto-anchor pick. + let categoryHues: CategoryHues? + + init( + id: String, + displayNameKey: String?, + productID: String?, + accentColor: Color, + outgoingTextColor: Color, + hashtagColor: Color, + preferredColorScheme: ColorScheme?, + surfaces: Surfaces? = nil, + identityGamut: IdentityGamut, + categoryAvatarOverride: CategoryAvatarColors? = nil, + categoryHues: CategoryHues? = nil + ) { + self.id = id + self.displayNameKey = displayNameKey + self.productID = productID + self.accentColor = accentColor + self.outgoingTextColor = outgoingTextColor + self.hashtagColor = hashtagColor + self.preferredColorScheme = preferredColorScheme + self.surfaces = surfaces + self.identityGamut = identityGamut + self.categoryAvatarOverride = categoryAvatarOverride + self.categoryHues = categoryHues + } } extension Theme { - /// Canvas (system grouped replacement) + card (secondary system grouped replacement) for paid themes. - /// `card == nil` means "paint the canvas but leave card rows on the system tier" (Ember). - struct Surfaces: Sendable, Equatable { - let canvas: Color - let card: Color? - - init(canvas: Color, card: Color? = nil) { - self.canvas = canvas - self.card = card - } - - /// Fill for a card-tier list row. Returns `nil` when `flatten` is set (the iPad Settings - /// sidebar column), so rows stay transparent: the column canvas shows through and the native - /// `.sidebar` selection highlight survives, which any `listRowBackground` would suppress. - /// Otherwise returns the card tier — the inset-grouped "card on grouped gray" look used - /// everywhere else. `nil` for surfaces without a card tier (Ember). - func rowFill(flatten: Bool) -> Color? { - flatten ? nil : card - } + /// Canvas (system grouped replacement) + card (secondary system grouped replacement) for paid themes. + /// `card == nil` means "paint the canvas but leave card rows on the system tier" (Ember). + struct Surfaces: Equatable { + let canvas: Color + let card: Color? + + init(canvas: Color, card: Color? = nil) { + self.canvas = canvas + self.card = card } - /// Tint to impose on global chrome (buttons, links, controls) at the scene root. - /// The default theme returns `nil` so chrome defers to the system tint; `accentColor` - /// is reserved for deliberate brand surfaces (chat bubbles, palette swatch). - var chromeTint: Color? { - id == Theme.default.id ? nil : accentColor - } - - /// Incoming message bubble fill. Themed surfaces use the card tier, which is tuned to - /// contrast with the canvas; the default theme (and canvas-only themes like Ember) keep - /// the system gray that reads correctly on `systemBackground`. - var incomingBubbleColor: Color { - surfaces?.card ?? AppColors.Message.incomingBubble + /// Fill for a card-tier list row. Returns `nil` when `flatten` is set (the iPad Settings + /// sidebar column), so rows stay transparent: the column canvas shows through and the native + /// `.sidebar` selection highlight survives, which any `listRowBackground` would suppress. + /// Otherwise returns the card tier — the inset-grouped "card on grouped gray" look used + /// everywhere else. `nil` for surfaces without a card tier (Ember). + func rowFill(flatten: Bool) -> Color? { + flatten ? nil : card } + } + + /// Tint to impose on global chrome (buttons, links, controls) at the scene root. + /// The default theme returns `nil` so chrome defers to the system tint; `accentColor` + /// is reserved for deliberate brand surfaces (chat bubbles, palette swatch). + var chromeTint: Color? { + id == Theme.default.id ? nil : accentColor + } + + /// Incoming message bubble fill. Themed surfaces use the card tier, which is tuned to + /// contrast with the canvas; the default theme (and canvas-only themes like Ember) keep + /// the system gray that reads correctly on `systemBackground`. + var incomingBubbleColor: Color { + surfaces?.card ?? AppColors.Message.incomingBubble + } } extension Theme { - static let `default` = Theme( - id: EnvInputs.defaultThemeID, - displayNameKey: "Settings.Support.Theme.Default", - productID: nil, - accentColor: Color("AppAccentColor"), - outgoingTextColor: .white, - hashtagColor: Color("HashtagDefault"), - preferredColorScheme: nil, - identityGamut: IdentityGamut( - hueAnchors: [18, 25, 44, 77, 120, 180, 215, 255, 307, 343], - saturation: 0.45...0.70 - ), - categoryAvatarOverride: CategoryAvatarColors( - channel: Color(hex: 0x336688), - repeaterNode: Color(hex: 0x00AAFF), - room: Color(hex: 0xFF8800) - ) - ) - - static let ember = Theme( - id: "ember", - displayNameKey: "Settings.Support.Theme.Ember", - productID: StoreCatalog.Theme.ember, - accentColor: Color("Theme/Ember/Accent"), - outgoingTextColor: Color("Theme/Ember/Text"), - hashtagColor: Color("Theme/Ember/Hashtag"), - preferredColorScheme: .dark, - surfaces: .init(canvas: .black), - identityGamut: IdentityGamut( - hueAnchors: [0, 8, 12, 24, 18, 345], - saturation: 0.50...0.82 - ), - categoryHues: CategoryHues(channel: 0, repeater: 24, room: 345) - ) - - static let fern = Theme( - id: "fern", - displayNameKey: "Settings.Support.Theme.Fern", - productID: StoreCatalog.Theme.fern, - accentColor: Color("Theme/Fern/Accent"), - outgoingTextColor: Color("Theme/Fern/OutgoingText"), - hashtagColor: Color("Theme/Fern/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Fern/Canvas"), card: Color("Theme/Fern/Card")), - identityGamut: IdentityGamut( - hueAnchors: [72, 90, 108, 128, 150, 165], - saturation: 0.35...0.65 - ), - categoryHues: CategoryHues(channel: 90, repeater: 128, room: 165) - ) - - static let marine = Theme( - id: "marine", - displayNameKey: "Settings.Support.Theme.Marine", - productID: StoreCatalog.Theme.marine, - accentColor: Color("Theme/Marine/Accent"), - outgoingTextColor: .white, - hashtagColor: Color("Theme/Marine/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Marine/Canvas"), card: Color("Theme/Marine/Card")), - identityGamut: IdentityGamut( - hueAnchors: [178, 188, 200, 215, 232, 245], - saturation: 0.40...0.72 - ), - categoryHues: CategoryHues(channel: 215, repeater: 188, room: 245) - ) - - static let olive = Theme( - id: "olive", - displayNameKey: "Settings.Support.Theme.Olive", - productID: StoreCatalog.Theme.olive, - accentColor: Color("Theme/Olive/Accent"), - outgoingTextColor: Color("Theme/Olive/OutgoingText"), - hashtagColor: Color("Theme/Olive/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Olive/Canvas"), card: Color("Theme/Olive/Card")), - identityGamut: IdentityGamut( - hueAnchors: [45, 55, 70, 85, 100, 112], - saturation: 0.35...0.62 - ), - categoryHues: CategoryHues(channel: 85, repeater: 112, room: 70) - ) - - static let lavender = Theme( - id: "lavender", - displayNameKey: "Settings.Support.Theme.Lavender", - productID: StoreCatalog.Theme.lavender, - accentColor: Color("Theme/Lavender/Accent"), - outgoingTextColor: Color("Theme/Lavender/OutgoingText"), - hashtagColor: Color("Theme/Lavender/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Lavender/Canvas"), card: Color("Theme/Lavender/Card")), - identityGamut: IdentityGamut( - hueAnchors: [222, 238, 248, 265, 285, 302], - saturation: 0.35...0.62 - ), - categoryHues: CategoryHues(channel: 265, repeater: 222, room: 302) - ) - - static let sakura = Theme( - id: "sakura", - displayNameKey: nil, - productID: StoreCatalog.Theme.sakura, - accentColor: Color("Theme/Sakura/Accent"), - outgoingTextColor: Color("Theme/Sakura/OutgoingText"), - hashtagColor: Color("Theme/Sakura/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Sakura/Canvas"), card: Color("Theme/Sakura/Card")), - identityGamut: IdentityGamut( - hueAnchors: [290, 305, 320, 335, 350, 8], - saturation: 0.40...0.70 - ), - categoryHues: CategoryHues(channel: 320, repeater: 290, room: 350) - ) - - static let solarized = Theme( - id: "solarized", - displayNameKey: nil, - productID: StoreCatalog.Theme.solarized, - accentColor: Color("Theme/Solarized/Accent"), - outgoingTextColor: Color("Theme/Solarized/OutgoingText"), - hashtagColor: Color("Theme/Solarized/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Solarized/Canvas"), card: Color("Theme/Solarized/Card")), - identityGamut: IdentityGamut( - hueAnchors: [1, 18, 45, 68, 175, 205, 237, 331], - saturation: 0.50...0.80 - ), - categoryHues: CategoryHues(channel: 205, repeater: 175, room: 331) - ) - - static let nord = Theme( - id: "nord", - displayNameKey: nil, - productID: StoreCatalog.Theme.nord, - accentColor: Color("Theme/Nord/Accent"), - outgoingTextColor: Color("Theme/Nord/OutgoingText"), - hashtagColor: Color("Theme/Nord/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Nord/Canvas"), card: Color("Theme/Nord/Card")), - identityGamut: IdentityGamut( - hueAnchors: [14, 40, 92, 178, 193, 210, 213, 240, 280, 311, 354], - saturation: 0.25...0.52 - ), - categoryHues: CategoryHues(channel: 210, repeater: 193, room: 280) - ) - - static let catppuccin = Theme( - id: "catppuccin", - displayNameKey: nil, - productID: StoreCatalog.Theme.catppuccin, - accentColor: Color("Theme/Catppuccin/Accent"), - outgoingTextColor: Color("Theme/Catppuccin/OutgoingText"), - hashtagColor: Color("Theme/Catppuccin/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Catppuccin/Canvas"), card: Color("Theme/Catppuccin/Card")), - identityGamut: IdentityGamut( - hueAnchors: [0, 10, 23, 41, 115, 170, 189, 199, 217, 232, 267, 316, 343, 351], - saturation: 0.40...0.70 - ), - categoryHues: CategoryHues(channel: 267, repeater: 217, room: 316) + static let `default` = Theme( + id: EnvInputs.defaultThemeID, + displayNameKey: "Settings.Support.Theme.Default", + productID: nil, + accentColor: Color("AppAccentColor"), + outgoingTextColor: .white, + hashtagColor: Color("HashtagDefault"), + preferredColorScheme: nil, + identityGamut: IdentityGamut( + hueAnchors: [18, 25, 44, 77, 120, 180, 215, 255, 307, 343], + saturation: 0.45...0.70 + ), + categoryAvatarOverride: CategoryAvatarColors( + channel: Color(hex: 0x336688), + repeaterNode: Color(hex: 0x00AAFF), + room: Color(hex: 0xFF8800) ) + ) + + static let ember = Theme( + id: "ember", + displayNameKey: "Settings.Support.Theme.Ember", + productID: StoreCatalog.Theme.ember, + accentColor: Color("Theme/Ember/Accent"), + outgoingTextColor: Color("Theme/Ember/Text"), + hashtagColor: Color("Theme/Ember/Hashtag"), + preferredColorScheme: .dark, + surfaces: .init(canvas: .black), + identityGamut: IdentityGamut( + hueAnchors: [0, 8, 12, 24, 18, 345], + saturation: 0.50...0.82 + ), + categoryHues: CategoryHues(channel: 0, repeater: 24, room: 345) + ) + + static let fern = Theme( + id: "fern", + displayNameKey: "Settings.Support.Theme.Fern", + productID: StoreCatalog.Theme.fern, + accentColor: Color("Theme/Fern/Accent"), + outgoingTextColor: Color("Theme/Fern/OutgoingText"), + hashtagColor: Color("Theme/Fern/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Fern/Canvas"), card: Color("Theme/Fern/Card")), + identityGamut: IdentityGamut( + hueAnchors: [72, 90, 108, 128, 150, 165], + saturation: 0.35...0.65 + ), + categoryHues: CategoryHues(channel: 90, repeater: 128, room: 165) + ) + + static let marine = Theme( + id: "marine", + displayNameKey: "Settings.Support.Theme.Marine", + productID: StoreCatalog.Theme.marine, + accentColor: Color("Theme/Marine/Accent"), + outgoingTextColor: .white, + hashtagColor: Color("Theme/Marine/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Marine/Canvas"), card: Color("Theme/Marine/Card")), + identityGamut: IdentityGamut( + hueAnchors: [178, 188, 200, 215, 232, 245], + saturation: 0.40...0.72 + ), + categoryHues: CategoryHues(channel: 215, repeater: 188, room: 245) + ) + + static let olive = Theme( + id: "olive", + displayNameKey: "Settings.Support.Theme.Olive", + productID: StoreCatalog.Theme.olive, + accentColor: Color("Theme/Olive/Accent"), + outgoingTextColor: Color("Theme/Olive/OutgoingText"), + hashtagColor: Color("Theme/Olive/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Olive/Canvas"), card: Color("Theme/Olive/Card")), + identityGamut: IdentityGamut( + hueAnchors: [45, 55, 70, 85, 100, 112], + saturation: 0.35...0.62 + ), + categoryHues: CategoryHues(channel: 85, repeater: 112, room: 70) + ) + + static let lavender = Theme( + id: "lavender", + displayNameKey: "Settings.Support.Theme.Lavender", + productID: StoreCatalog.Theme.lavender, + accentColor: Color("Theme/Lavender/Accent"), + outgoingTextColor: Color("Theme/Lavender/OutgoingText"), + hashtagColor: Color("Theme/Lavender/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Lavender/Canvas"), card: Color("Theme/Lavender/Card")), + identityGamut: IdentityGamut( + hueAnchors: [222, 238, 248, 265, 285, 302], + saturation: 0.35...0.62 + ), + categoryHues: CategoryHues(channel: 265, repeater: 222, room: 302) + ) + + static let sakura = Theme( + id: "sakura", + displayNameKey: nil, + productID: StoreCatalog.Theme.sakura, + accentColor: Color("Theme/Sakura/Accent"), + outgoingTextColor: Color("Theme/Sakura/OutgoingText"), + hashtagColor: Color("Theme/Sakura/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Sakura/Canvas"), card: Color("Theme/Sakura/Card")), + identityGamut: IdentityGamut( + hueAnchors: [290, 305, 320, 335, 350, 8], + saturation: 0.40...0.70 + ), + categoryHues: CategoryHues(channel: 320, repeater: 290, room: 350) + ) + + static let solarized = Theme( + id: "solarized", + displayNameKey: nil, + productID: StoreCatalog.Theme.solarized, + accentColor: Color("Theme/Solarized/Accent"), + outgoingTextColor: Color("Theme/Solarized/OutgoingText"), + hashtagColor: Color("Theme/Solarized/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Solarized/Canvas"), card: Color("Theme/Solarized/Card")), + identityGamut: IdentityGamut( + hueAnchors: [1, 18, 45, 68, 175, 205, 237, 331], + saturation: 0.50...0.80 + ), + categoryHues: CategoryHues(channel: 205, repeater: 175, room: 331) + ) + + static let nord = Theme( + id: "nord", + displayNameKey: nil, + productID: StoreCatalog.Theme.nord, + accentColor: Color("Theme/Nord/Accent"), + outgoingTextColor: Color("Theme/Nord/OutgoingText"), + hashtagColor: Color("Theme/Nord/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Nord/Canvas"), card: Color("Theme/Nord/Card")), + identityGamut: IdentityGamut( + hueAnchors: [14, 40, 92, 178, 193, 210, 213, 240, 280, 311, 354], + saturation: 0.25...0.52 + ), + categoryHues: CategoryHues(channel: 210, repeater: 193, room: 280) + ) + + static let catppuccin = Theme( + id: "catppuccin", + displayNameKey: nil, + productID: StoreCatalog.Theme.catppuccin, + accentColor: Color("Theme/Catppuccin/Accent"), + outgoingTextColor: Color("Theme/Catppuccin/OutgoingText"), + hashtagColor: Color("Theme/Catppuccin/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Catppuccin/Canvas"), card: Color("Theme/Catppuccin/Card")), + identityGamut: IdentityGamut( + hueAnchors: [0, 10, 23, 41, 115, 170, 189, 199, 217, 232, 267, 316, 343, 351], + saturation: 0.40...0.70 + ), + categoryHues: CategoryHues(channel: 267, repeater: 217, room: 316) + ) } diff --git a/MC1/Theme/ThemeCardMetrics.swift b/MC1/Theme/ThemeCardMetrics.swift index 070f789e..d9f6fd01 100644 --- a/MC1/Theme/ThemeCardMetrics.swift +++ b/MC1/Theme/ThemeCardMetrics.swift @@ -4,19 +4,19 @@ import SwiftUI /// radius in one place stops a card's background fill and its selection-stroke overlay from /// drifting out of sync. enum ThemeCardMetrics { - static let cornerRadius: CGFloat = 14 - static let contentPadding: CGFloat = 8 - static let selectionStrokeWidth: CGFloat = 2 - static let badgeIconSpacing: CGFloat = 3 + static let cornerRadius: CGFloat = 14 + static let contentPadding: CGFloat = 8 + static let selectionStrokeWidth: CGFloat = 2 + static let badgeIconSpacing: CGFloat = 3 - /// Shared theme-grid layout, kept here so the Appearance and Support card grids cannot drift. - static let gridItemMinimum: CGFloat = 160 - static let gridSpacing: CGFloat = 12 - static let gridRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + /// Shared theme-grid layout, kept here so the Appearance and Support card grids cannot drift. + static let gridItemMinimum: CGFloat = 160 + static let gridSpacing: CGFloat = 12 + static let gridRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) - /// Layout for the "all themes unlocked" celebration that replaces the card grid once every - /// purchasable theme is owned. - static let allUnlockedEmojiSize: CGFloat = 56 - static let allUnlockedSpacing: CGFloat = 12 - static let allUnlockedVerticalPadding: CGFloat = 24 + /// Layout for the "all themes unlocked" celebration that replaces the card grid once every + /// purchasable theme is owned. + static let allUnlockedEmojiSize: CGFloat = 56 + static let allUnlockedSpacing: CGFloat = 12 + static let allUnlockedVerticalPadding: CGFloat = 24 } diff --git a/MC1/Theme/ThemePaletteSwatch.swift b/MC1/Theme/ThemePaletteSwatch.swift index 6e3375ec..fa4e3c8c 100644 --- a/MC1/Theme/ThemePaletteSwatch.swift +++ b/MC1/Theme/ThemePaletteSwatch.swift @@ -3,83 +3,85 @@ import SwiftUI /// Shared theme preview for `ThemePreviewCard` and `ThemeSelectionCard`. The diagonal split /// signals light+dark support; an un-split swatch signals a dark-only theme (Ember). struct ThemePaletteSwatch: View { - let theme: Theme - /// `.tappable` enforces the 44pt HIG tap-target minimum (default; used by the preview and - /// selection cards). `.thumbnail` drops the minimum so a row of many swatches can fit a - /// shared parent width. - var size: SizeStyle = .tappable + let theme: Theme + /// `.tappable` enforces the 44pt HIG tap-target minimum (default; used by the preview and + /// selection cards). `.thumbnail` drops the minimum so a row of many swatches can fit a + /// shared parent width. + var size: SizeStyle = .tappable - enum SizeStyle: Sendable { - case tappable - case thumbnail - } + enum SizeStyle { + case tappable + case thumbnail + } - private enum Layout { - static let tappableMinSide: CGFloat = 44 - static let corner: CGFloat = 12 - static let bubbleCorner: CGFloat = 6 - static let bubbleInset: CGFloat = 8 - } + private enum Layout { + static let tappableMinSide: CGFloat = 44 + static let corner: CGFloat = 12 + static let bubbleCorner: CGFloat = 6 + static let bubbleInset: CGFloat = 8 + } - private var isDualMode: Bool { theme.preferredColorScheme == nil } + private var isDualMode: Bool { + theme.preferredColorScheme == nil + } - private var enforcedMinSide: CGFloat? { - size == .tappable ? Layout.tappableMinSide : nil - } + private var enforcedMinSide: CGFloat? { + size == .tappable ? Layout.tappableMinSide : nil + } - var body: some View { - ZStack { - if isDualMode { - palette - .environment(\.colorScheme, .light) - .clipShape(DiagonalHalf(top: true)) - palette - .environment(\.colorScheme, .dark) - .clipShape(DiagonalHalf(top: false)) - } else { - palette - .environment(\.colorScheme, .dark) - } - } - .frame(minWidth: enforcedMinSide, minHeight: enforcedMinSide) - .clipShape(RoundedRectangle(cornerRadius: Layout.corner)) - .accessibilityElement() - .accessibilityLabel(accessibilityLabel) + var body: some View { + ZStack { + if isDualMode { + palette + .environment(\.colorScheme, .light) + .clipShape(DiagonalHalf(top: true)) + palette + .environment(\.colorScheme, .dark) + .clipShape(DiagonalHalf(top: false)) + } else { + palette + .environment(\.colorScheme, .dark) + } } + .frame(minWidth: enforcedMinSide, minHeight: enforcedMinSide) + .clipShape(RoundedRectangle(cornerRadius: Layout.corner)) + .accessibilityElement() + .accessibilityLabel(accessibilityLabel) + } - /// Accent + background + a sample outgoing bubble, all theme-driven. - private var palette: some View { - ZStack { - (theme.surfaces?.canvas ?? Color(.secondarySystemBackground)) - RoundedRectangle(cornerRadius: Layout.bubbleCorner) - .fill(theme.accentColor) - .padding(Layout.bubbleInset) - } + /// Accent + background + a sample outgoing bubble, all theme-driven. + private var palette: some View { + ZStack { + (theme.surfaces?.canvas ?? Color(.secondarySystemBackground)) + RoundedRectangle(cornerRadius: Layout.bubbleCorner) + .fill(theme.accentColor) + .padding(Layout.bubbleInset) } + } - private var accessibilityLabel: String { - isDualMode - ? L10n.Settings.Appearance.Accessibility.Swatch.dualMode(theme.localizedName) - : L10n.Settings.Appearance.Accessibility.Swatch.darkOnly(theme.localizedName) - } + private var accessibilityLabel: String { + isDualMode + ? L10n.Settings.Appearance.Accessibility.Swatch.dualMode(theme.localizedName) + : L10n.Settings.Appearance.Accessibility.Swatch.darkOnly(theme.localizedName) + } } /// One triangular half of the swatch, split corner-to-corner (top-leading triangle when `top`). private struct DiagonalHalf: Shape { - let top: Bool + let top: Bool - func path(in rect: CGRect) -> Path { - var path = Path() - if top { - path.move(to: CGPoint(x: rect.minX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - } else { - path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - } - path.closeSubpath() - return path + func path(in rect: CGRect) -> Path { + var path = Path() + if top { + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + } else { + path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) } + path.closeSubpath() + return path + } } diff --git a/MC1/Theme/ThemeRegistry.swift b/MC1/Theme/ThemeRegistry.swift index a66d63c6..d9da7c1a 100644 --- a/MC1/Theme/ThemeRegistry.swift +++ b/MC1/Theme/ThemeRegistry.swift @@ -3,12 +3,12 @@ import Foundation /// The catalog of built-in themes. Plain enum with `Sendable` static values — no `@MainActor`, /// since the constants are immutable and require no actor hop to read. enum ThemeRegistry { - static let allThemes: [Theme] = [ - .default, .ember, .fern, .marine, .olive, .lavender, .sakura, - .solarized, .nord, .catppuccin - ] + static let allThemes: [Theme] = [ + .default, .ember, .fern, .marine, .olive, .lavender, .sakura, + .solarized, .nord, .catppuccin + ] - static func theme(forID id: String) -> Theme? { - allThemes.first { $0.id == id } - } + static func theme(forID id: String) -> Theme? { + allThemes.first { $0.id == id } + } } diff --git a/MC1/Theme/ThemeService.swift b/MC1/Theme/ThemeService.swift index 6679d4ec..c7f0744f 100644 --- a/MC1/Theme/ThemeService.swift +++ b/MC1/Theme/ThemeService.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Owns the selected theme and the global color-scheme preference, enforces the ownership /// access rule against `StoreService`, and reverts an inaccessible theme on entitlement loss. @@ -8,146 +8,146 @@ import MC1Services @Observable @MainActor final class ThemeService { - private(set) var current: Theme - private(set) var colorSchemePreference: AppColorSchemePreference - private let store: StoreService - private let defaults: UserDefaults - - init(store: StoreService, defaults: UserDefaults = .standard) { - self.store = store - self.defaults = defaults + private(set) var current: Theme + private(set) var colorSchemePreference: AppColorSchemePreference + private let store: StoreService + private let defaults: UserDefaults - // Init contract: registry-based fallback only. Do not enforce ownership here — - // StoreService.ownedThemeIDs is not yet populated (the entitlement walk is async, - // triggered by load() later). Enforcing access here would overwrite a paid user's - // persisted theme on every cold launch. Reversion happens only via the post-load callback. - let storedID = defaults.string(forKey: PersistenceKeys.selectedThemeID) - if let id = storedID, let theme = ThemeRegistry.theme(forID: id) { - self.current = theme // adopt as-is; no write-back - } else if storedID == nil { - self.current = .default // missing key: default in memory, no write-back - } else { - self.current = .default // unknown ID: fall back and overwrite the stale value - defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) - } + init(store: StoreService, defaults: UserDefaults = .standard) { + self.store = store + self.defaults = defaults - let storedScheme = defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - if let raw = storedScheme, let preference = AppColorSchemePreference(rawValue: raw) { - self.colorSchemePreference = preference - } else if storedScheme == nil { - self.colorSchemePreference = .system // missing key: no write-back - } else { - self.colorSchemePreference = .system // unknown raw value: fall back and overwrite - defaults.set(AppColorSchemePreference.system.rawValue, - forKey: PersistenceKeys.appColorSchemePreference) - } - - // StoreService is constructed before ThemeService, so this callback is wired before - // any Transaction.updates emission. - store.onEntitlementsChanged = { [weak self] in - self?.revertToDefaultIfInaccessible() - } + // Init contract: registry-based fallback only. Do not enforce ownership here — + // StoreService.ownedThemeIDs is not yet populated (the entitlement walk is async, + // triggered by load() later). Enforcing access here would overwrite a paid user's + // persisted theme on every cold launch. Reversion happens only via the post-load callback. + let storedID = defaults.string(forKey: PersistenceKeys.selectedThemeID) + if let id = storedID, let theme = ThemeRegistry.theme(forID: id) { + current = theme // adopt as-is; no write-back + } else if storedID == nil { + current = .default // missing key: default in memory, no write-back + } else { + current = .default // unknown ID: fall back and overwrite the stale value + defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) } - func setCurrent(_ theme: Theme) throws { - guard Self.isAccessible(theme, store: store) else { - throw ThemeServiceError.notOwned(productID: theme.productID ?? "") - } - current = theme - defaults.set(theme.id, forKey: PersistenceKeys.selectedThemeID) + let storedScheme = defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + if let raw = storedScheme, let preference = AppColorSchemePreference(rawValue: raw) { + colorSchemePreference = preference + } else if storedScheme == nil { + colorSchemePreference = .system // missing key: no write-back + } else { + colorSchemePreference = .system // unknown raw value: fall back and overwrite + defaults.set(AppColorSchemePreference.system.rawValue, + forKey: PersistenceKeys.appColorSchemePreference) } - func setColorSchemePreference(_ preference: AppColorSchemePreference) { - colorSchemePreference = preference - defaults.set(preference.rawValue, forKey: PersistenceKeys.appColorSchemePreference) + // StoreService is constructed before ThemeService, so this callback is wired before + // any Transaction.updates emission. + store.onEntitlementsChanged = { [weak self] in + self?.revertToDefaultIfInaccessible() } + } - /// Theme-forced override wins; otherwise the user's global preference applies. - var effectiveColorScheme: ColorScheme? { - current.preferredColorScheme ?? colorSchemePreference.colorScheme + func setCurrent(_ theme: Theme) throws { + guard Self.isAccessible(theme, store: store) else { + throw ThemeServiceError.notOwned(productID: theme.productID ?? "") } + current = theme + defaults.set(theme.id, forKey: PersistenceKeys.selectedThemeID) + } - func availableToCurrentUser() -> [Theme] { - ThemeRegistry.allThemes.filter { Self.isAccessible($0, store: store) } - } + func setColorSchemePreference(_ preference: AppColorSchemePreference) { + colorSchemePreference = preference + defaults.set(preference.rawValue, forKey: PersistenceKeys.appColorSchemePreference) + } - /// Re-reads both keys so a mid-session backup import (`AppState.notifyDataRestored`) - /// takes effect without a cold launch. Unlike `init`, the restore caller is the only - /// production path here, so this branch *does* enforce ownership: a backup imported from a - /// different Apple ID can carry a paid theme this user doesn't own, and without the check - /// the paid theme would render for free until the next entitlement walk (which never fires - /// on the restore path). Unknown stored values are corrected with a write-back, mirroring - /// `init`'s ladder. - func refreshFromUserDefaults() { - let resolvedTheme = resolveThemeFromDefaults() - if resolvedTheme.id != current.id { - current = resolvedTheme - if resolvedTheme.id == Theme.default.id { - AccessibilityNotification.Announcement(L10n.Settings.Appearance.Accessibility.themeReverted).post() - } - } + /// Theme-forced override wins; otherwise the user's global preference applies. + var effectiveColorScheme: ColorScheme? { + current.preferredColorScheme ?? colorSchemePreference.colorScheme + } - let resolvedScheme = resolveSchemePreferenceFromDefaults() - if resolvedScheme != colorSchemePreference { colorSchemePreference = resolvedScheme } - } + func availableToCurrentUser() -> [Theme] { + ThemeRegistry.allThemes.filter { Self.isAccessible($0, store: store) } + } - private func resolveThemeFromDefaults() -> Theme { - let storedID = defaults.string(forKey: PersistenceKeys.selectedThemeID) - if let id = storedID, let theme = ThemeRegistry.theme(forID: id) { - if Self.isAccessible(theme, store: store) { return theme } - // Known but inaccessible. Only revert + overwrite once entitlements are authoritative - // (`loadState == .loaded`). Before then `ownedThemeIDs` may be empty merely because the - // walk hasn't populated it (idle/loading) or failed (offline) — wiping the persisted - // selection there downgrades a paid user who restored a backup before the walk finished. - // Adopt the stored theme in memory without write-back, mirroring `init`; the post-load - // listener reverts it later if it is genuinely unowned. - guard store.loadState == .loaded else { return theme } - defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) - return .default - } - if storedID == nil { return .default } // missing key: no write-back - // Unknown ID (e.g., future-build theme via backup): fall back + corrective overwrite. - defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) - return .default + /// Re-reads both keys so a mid-session backup import (`AppState.notifyDataRestored`) + /// takes effect without a cold launch. Unlike `init`, the restore caller is the only + /// production path here, so this branch *does* enforce ownership: a backup imported from a + /// different Apple ID can carry a paid theme this user doesn't own, and without the check + /// the paid theme would render for free until the next entitlement walk (which never fires + /// on the restore path). Unknown stored values are corrected with a write-back, mirroring + /// `init`'s ladder. + func refreshFromUserDefaults() { + let resolvedTheme = resolveThemeFromDefaults() + if resolvedTheme.id != current.id { + current = resolvedTheme + if resolvedTheme.id == Theme.default.id { + AccessibilityNotification.Announcement(L10n.Settings.Appearance.Accessibility.themeReverted).post() + } } - private func resolveSchemePreferenceFromDefaults() -> AppColorSchemePreference { - let storedScheme = defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - if let raw = storedScheme, let preference = AppColorSchemePreference(rawValue: raw) { - return preference - } - if storedScheme == nil { return .system } // missing key: no write-back - // Unknown raw value (e.g., a future-build case via backup): fall back + corrective overwrite. - defaults.set(AppColorSchemePreference.system.rawValue, - forKey: PersistenceKeys.appColorSchemePreference) - return .system - } + let resolvedScheme = resolveSchemePreferenceFromDefaults() + if resolvedScheme != colorSchemePreference { colorSchemePreference = resolvedScheme } + } - /// Idempotent: invoked from `StoreService.onEntitlementsChanged`. If the current theme is no - /// longer accessible (e.g. a refund), revert to default, persist, and announce for VoiceOver. - /// - /// Acts only once entitlements are authoritative (`loadState == .loaded`). The first walk fires - /// this from inside `load()` while still `.loading`, and on a cold storekitd that walk can read - /// an empty `ownedThemeIDs` for a user who does own the theme — persisting a revert there would - /// wipe their selection (and the listener never re-applies, so it is unrecoverable until they - /// re-select). Post-load walks (refund, restore) fire this while `.loaded`, where empty ownership - /// is trustworthy. - private func revertToDefaultIfInaccessible() { - guard store.loadState == .loaded else { return } - guard !Self.isAccessible(current, store: store) else { return } - current = .default - defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) - AccessibilityNotification.Announcement(L10n.Settings.Appearance.Accessibility.themeReverted).post() + private func resolveThemeFromDefaults() -> Theme { + let storedID = defaults.string(forKey: PersistenceKeys.selectedThemeID) + if let id = storedID, let theme = ThemeRegistry.theme(forID: id) { + if Self.isAccessible(theme, store: store) { return theme } + // Known but inaccessible. Only revert + overwrite once entitlements are authoritative + // (`loadState == .loaded`). Before then `ownedThemeIDs` may be empty merely because the + // walk hasn't populated it (idle/loading) or failed (offline) — wiping the persisted + // selection there downgrades a paid user who restored a backup before the walk finished. + // Adopt the stored theme in memory without write-back, mirroring `init`; the post-load + // listener reverts it later if it is genuinely unowned. + guard store.loadState == .loaded else { return theme } + defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) + return .default } + if storedID == nil { return .default } // missing key: no write-back + // Unknown ID (e.g., future-build theme via backup): fall back + corrective overwrite. + defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) + return .default + } - private static func isAccessible(_ theme: Theme, store: StoreService) -> Bool { - #if SIDELOAD - // Sideload builds have no App Store entitlement, so StoreKit can never populate - // ownedThemeIDs. Unlock every theme rather than leaving paid themes permanently locked. - return true - #else - guard let productID = theme.productID else { return true } // default is always accessible - return store.ownedThemeIDs.contains(productID) - #endif + private func resolveSchemePreferenceFromDefaults() -> AppColorSchemePreference { + let storedScheme = defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + if let raw = storedScheme, let preference = AppColorSchemePreference(rawValue: raw) { + return preference } + if storedScheme == nil { return .system } // missing key: no write-back + // Unknown raw value (e.g., a future-build case via backup): fall back + corrective overwrite. + defaults.set(AppColorSchemePreference.system.rawValue, + forKey: PersistenceKeys.appColorSchemePreference) + return .system + } + + /// Idempotent: invoked from `StoreService.onEntitlementsChanged`. If the current theme is no + /// longer accessible (e.g. a refund), revert to default, persist, and announce for VoiceOver. + /// + /// Acts only once entitlements are authoritative (`loadState == .loaded`). The first walk fires + /// this from inside `load()` while still `.loading`, and on a cold storekitd that walk can read + /// an empty `ownedThemeIDs` for a user who does own the theme — persisting a revert there would + /// wipe their selection (and the listener never re-applies, so it is unrecoverable until they + /// re-select). Post-load walks (refund, restore) fire this while `.loaded`, where empty ownership + /// is trustworthy. + private func revertToDefaultIfInaccessible() { + guard store.loadState == .loaded else { return } + guard !Self.isAccessible(current, store: store) else { return } + current = .default + defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) + AccessibilityNotification.Announcement(L10n.Settings.Appearance.Accessibility.themeReverted).post() + } + + private static func isAccessible(_ theme: Theme, store: StoreService) -> Bool { + #if SIDELOAD + // Sideload builds have no App Store entitlement, so StoreKit can never populate + // ownedThemeIDs. Unlock every theme rather than leaving paid themes permanently locked. + return true + #else + guard let productID = theme.productID else { return true } // default is always accessible + return store.ownedThemeIDs.contains(productID) + #endif + } } diff --git a/MC1/Theme/ThemeServiceError.swift b/MC1/Theme/ThemeServiceError.swift index 8da1993b..3b61531a 100644 --- a/MC1/Theme/ThemeServiceError.swift +++ b/MC1/Theme/ThemeServiceError.swift @@ -2,10 +2,10 @@ import Foundation /// Errors surfaced by `ThemeService`. Localized via `L10n` (this type lives in the MC1 target, /// where the generated `L10n` enum is visible — unlike the MC1Services error types). -enum ThemeServiceError: LocalizedError, Sendable { - case notOwned(productID: String) +enum ThemeServiceError: LocalizedError { + case notOwned(productID: String) - var errorDescription: String? { - L10n.Settings.Support.Error.themeNotOwned - } + var errorDescription: String? { + L10n.Settings.Support.Error.themeNotOwned + } } diff --git a/MC1/Theme/ThemedSurfaceModifiers.swift b/MC1/Theme/ThemedSurfaceModifiers.swift index 5ecb6553..01d8271f 100644 --- a/MC1/Theme/ThemedSurfaceModifiers.swift +++ b/MC1/Theme/ThemedSurfaceModifiers.swift @@ -1,100 +1,99 @@ import SwiftUI extension View { - /// Paint the receiver's scroll/content background with the theme's canvas color, hiding the - /// SwiftUI default. No-op for themes without surfaces — preserves system rendering, including - /// iOS 26 Form Liquid Glass treatments that a manual `Color(.systemGroupedBackground)` would - /// override. - func themedCanvas(_ theme: Theme) -> some View { - modifier(ThemedCanvasModifier(theme: theme)) - } + /// Paint the receiver's scroll/content background with the theme's canvas color, hiding the + /// SwiftUI default. No-op for themes without surfaces — preserves system rendering, including + /// iOS 26 Form Liquid Glass treatments that a manual `Color(.systemGroupedBackground)` would + /// override. + func themedCanvas(_ theme: Theme) -> some View { + modifier(ThemedCanvasModifier(theme: theme)) + } - /// Paint a `Section`'s rows with the theme's card color. No-op for themes without a card tier - /// (Default, Ember) — preserves system `.secondarySystemGroupedBackground` rendering. - /// Apply per Section in `.insetGrouped` lists; use `themedPlainRowBackground` on `.plain` lists - /// instead. For a `.sidebar`-styled list (the iPad Settings split-view column) pass - /// `flatten: true`, so rows stay transparent and read flush with the canvas, not as cards. - func themedRowBackground(_ theme: Theme, flatten: Bool = false) -> some View { - modifier(ThemedRowBackgroundModifier(theme: theme, flatten: flatten)) - } + /// Paint a `Section`'s rows with the theme's card color. No-op for themes without a card tier + /// (Default, Ember) — preserves system `.secondarySystemGroupedBackground` rendering. + /// Apply per Section in `.insetGrouped` lists; use `themedPlainRowBackground` on `.plain` lists + /// instead. For a `.sidebar`-styled list (the iPad Settings split-view column) pass + /// `flatten: true`, so rows stay transparent and read flush with the canvas, not as cards. + func themedRowBackground(_ theme: Theme, flatten: Bool = false) -> some View { + modifier(ThemedRowBackgroundModifier(theme: theme, flatten: flatten)) + } - /// Paint a `.plain` list's rows with the theme canvas so the themed background shows through. - /// Plain rows draw an opaque `systemBackground` by default, which otherwise hides the canvas - /// painted by `themedCanvas`. No-op for themes without surfaces — preserves system rendering - /// on the default theme. - func themedPlainRowBackground(_ theme: Theme) -> some View { - modifier(ThemedPlainRowBackgroundModifier(theme: theme)) - } + /// Paint a `.plain` list's rows with the theme canvas so the themed background shows through. + /// Plain rows draw an opaque `systemBackground` by default, which otherwise hides the canvas + /// painted by `themedCanvas`. No-op for themes without surfaces — preserves system rendering + /// on the default theme. + func themedPlainRowBackground(_ theme: Theme) -> some View { + modifier(ThemedPlainRowBackgroundModifier(theme: theme)) + } - /// Match the navigation bar and tab bar backgrounds to the theme canvas so chrome blends - /// with themed content instead of reading as a mismatched neutral material. No-op for themes - /// without surfaces — preserves the system Liquid Glass bars on the default theme. - func themedChrome(_ theme: Theme) -> some View { - modifier(ThemedChromeModifier(theme: theme)) - } + /// Match the navigation bar and tab bar backgrounds to the theme canvas so chrome blends + /// with themed content instead of reading as a mismatched neutral material. No-op for themes + /// without surfaces — preserves the system Liquid Glass bars on the default theme. + func themedChrome(_ theme: Theme) -> some View { + modifier(ThemedChromeModifier(theme: theme)) + } } private struct ThemedCanvasModifier: ViewModifier { - let theme: Theme - func body(content: Content) -> some View { - if let canvas = theme.surfaces?.canvas { - content - .scrollContentBackground(.hidden) - .background(canvas) - } else { - content - } + let theme: Theme + func body(content: Content) -> some View { + if let canvas = theme.surfaces?.canvas { + content + .scrollContentBackground(.hidden) + .background(canvas) + } else { + content } + } } private struct ThemedRowBackgroundModifier: ViewModifier { - let theme: Theme - let flatten: Bool - func body(content: Content) -> some View { - if let rowFill = theme.surfaces?.rowFill(flatten: flatten) { - content.listRowBackground(rowFill) - } else { - content - } + let theme: Theme + let flatten: Bool + func body(content: Content) -> some View { + if let rowFill = theme.surfaces?.rowFill(flatten: flatten) { + content.listRowBackground(rowFill) + } else { + content } + } } private struct ThemedPlainRowBackgroundModifier: ViewModifier { - let theme: Theme + let theme: Theme - @ViewBuilder - func body(content: Content) -> some View { - if let canvas = theme.surfaces?.canvas { - content.listRowBackground(canvas) - } else { - content - } + func body(content: Content) -> some View { + if let canvas = theme.surfaces?.canvas { + content.listRowBackground(canvas) + } else { + content } + } } private struct ThemedChromeModifier: ViewModifier { - let theme: Theme + let theme: Theme - // Always applies the same modifier chain, varying only the values. Resolving the surface - // branch into computed values instead of a `ViewBuilder` `if`/`else` keeps this view's - // structural identity stable across theme changes. Because `themedChrome` wraps the TabView, - // a branch flip here would re-identify every tab and reset their NavigationStacks, popping any - // pushed screen — which is exactly what switching to or from the surface-less Default theme did. - func body(content: Content) -> some View { - content - .toolbarBackground(barStyle, for: .navigationBar, .tabBar) - .toolbarBackgroundVisibility(barVisibility, for: .navigationBar, .tabBar) - } + /// Always applies the same modifier chain, varying only the values. Resolving the surface + /// branch into computed values instead of a `ViewBuilder` `if`/`else` keeps this view's + /// structural identity stable across theme changes. Because `themedChrome` wraps the TabView, + /// a branch flip here would re-identify every tab and reset their NavigationStacks, popping any + /// pushed screen — which is exactly what switching to or from the surface-less Default theme did. + func body(content: Content) -> some View { + content + .toolbarBackground(barStyle, for: .navigationBar, .tabBar) + .toolbarBackgroundVisibility(barVisibility, for: .navigationBar, .tabBar) + } - private var barStyle: AnyShapeStyle { - if let canvas = theme.surfaces?.canvas { - AnyShapeStyle(canvas) - } else { - AnyShapeStyle(Material.bar) - } + private var barStyle: AnyShapeStyle { + if let canvas = theme.surfaces?.canvas { + AnyShapeStyle(canvas) + } else { + AnyShapeStyle(Material.bar) } + } - private var barVisibility: Visibility { - theme.surfaces?.canvas == nil ? .automatic : .visible - } + private var barVisibility: Visibility { + theme.surfaces?.canvas == nil ? .automatic : .visible + } } diff --git a/MC1/Theme/WCAGContrast.swift b/MC1/Theme/WCAGContrast.swift index d37e1f4b..d7290b29 100644 --- a/MC1/Theme/WCAGContrast.swift +++ b/MC1/Theme/WCAGContrast.swift @@ -8,53 +8,52 @@ import UIKit /// The pure-`Double` entry points carry no UIKit dependency, so the identity-color solver can /// run off the main actor (it is invoked from the off-main message-text bake). enum WCAGContrast { - - /// Standard WCAG AA floor for normal-size text. - static let aaFloor = 4.5 - - /// Floor applied when the user has Increased Contrast enabled. Targets the AAA ratio so the - /// identity palette tightens the same way the legacy high-contrast palette did. - static let increasedContrastFloor = 7.0 - - /// sRGB linearization threshold and curve constants from the WCAG 2.x definition. - private static let linearThreshold = 0.03928 - private static let linearDivisor = 12.92 - private static let curveOffset = 0.055 - private static let curveDivisor = 1.055 - private static let curveExponent = 2.4 - - /// Rec. 709 luminance coefficients (a standard, fixed table — not tunable). - private static let redCoefficient = 0.2126 - private static let greenCoefficient = 0.7152 - private static let blueCoefficient = 0.0722 - - /// Additive term in the WCAG contrast ratio, modelling ambient flare. - private static let contrastFlare = 0.05 - - /// Relative luminance of an sRGB color expressed as 0...1 components. Components are clamped - /// because a wide-gamut or HSB-derived color can resolve slightly outside the unit range. - static func relativeLuminance(red: Double, green: Double, blue: Double) -> Double { - func linearize(_ component: Double) -> Double { - let c = min(max(component, 0), 1) - return c <= linearThreshold ? c / linearDivisor : pow((c + curveOffset) / curveDivisor, curveExponent) - } - return redCoefficient * linearize(red) - + greenCoefficient * linearize(green) - + blueCoefficient * linearize(blue) - } - - /// Contrast ratio between two relative-luminance values (order-independent). - static func contrastRatio(_ lhs: Double, _ rhs: Double) -> Double { - let lighter = max(lhs, rhs) - let darker = min(lhs, rhs) - return (lighter + contrastFlare) / (darker + contrastFlare) - } - - /// Relative luminance of a resolved `UIColor`. Main-actor / UIKit path used by live views to - /// turn an adaptive theme surface into the `Double` the solver consumes. - static func relativeLuminance(of color: UIColor) -> Double { - var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 - color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - return relativeLuminance(red: Double(red), green: Double(green), blue: Double(blue)) + /// Standard WCAG AA floor for normal-size text. + static let aaFloor = 4.5 + + /// Floor applied when the user has Increased Contrast enabled. Targets the AAA ratio so the + /// identity palette tightens the same way the legacy high-contrast palette did. + static let increasedContrastFloor = 7.0 + + /// sRGB linearization threshold and curve constants from the WCAG 2.x definition. + private static let linearThreshold = 0.03928 + private static let linearDivisor = 12.92 + private static let curveOffset = 0.055 + private static let curveDivisor = 1.055 + private static let curveExponent = 2.4 + + /// Rec. 709 luminance coefficients (a standard, fixed table — not tunable). + private static let redCoefficient = 0.2126 + private static let greenCoefficient = 0.7152 + private static let blueCoefficient = 0.0722 + + /// Additive term in the WCAG contrast ratio, modelling ambient flare. + private static let contrastFlare = 0.05 + + /// Relative luminance of an sRGB color expressed as 0...1 components. Components are clamped + /// because a wide-gamut or HSB-derived color can resolve slightly outside the unit range. + static func relativeLuminance(red: Double, green: Double, blue: Double) -> Double { + func linearize(_ component: Double) -> Double { + let c = min(max(component, 0), 1) + return c <= linearThreshold ? c / linearDivisor : pow((c + curveOffset) / curveDivisor, curveExponent) } + return redCoefficient * linearize(red) + + greenCoefficient * linearize(green) + + blueCoefficient * linearize(blue) + } + + /// Contrast ratio between two relative-luminance values (order-independent). + static func contrastRatio(_ lhs: Double, _ rhs: Double) -> Double { + let lighter = max(lhs, rhs) + let darker = min(lhs, rhs) + return (lighter + contrastFlare) / (darker + contrastFlare) + } + + /// Relative luminance of a resolved `UIColor`. Main-actor / UIKit path used by live views to + /// turn an adaptive theme surface into the `Double` the solver consumes. + static func relativeLuminance(of color: UIColor) -> Double { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return relativeLuminance(red: Double(red), green: Double(green), blue: Double(blue)) + } } diff --git a/MC1/Tips/DeviceMenuTip.swift b/MC1/Tips/DeviceMenuTip.swift index 1e3fff30..100f4ee8 100644 --- a/MC1/Tips/DeviceMenuTip.swift +++ b/MC1/Tips/DeviceMenuTip.swift @@ -3,31 +3,31 @@ import TipKit /// Tip shown after onboarding to introduce the device menu and its controls struct DeviceMenuTip: Tip { - static let hasCompletedOnboarding = Tips.Event(id: "hasCompletedOnboarding") + static let hasCompletedOnboarding = Tips.Event(id: "hasCompletedOnboarding") - /// Live connection state. The tip introduces the connected device menu, so it - /// must stay hidden while disconnected, when the menu offers only Connect Device. - @Parameter - static var isConnected: Bool = false + /// Live connection state. The tip introduces the connected device menu, so it + /// must stay hidden while disconnected, when the menu offers only Connect Device. + @Parameter + static var isConnected: Bool = false - var title: Text { - Text(L10n.Chats.Chats.Tip.DeviceMenu.title) - } + var title: Text { + Text(L10n.Chats.Chats.Tip.DeviceMenu.title) + } - var message: Text? { - Text(L10n.Chats.Chats.Tip.DeviceMenu.message) - } + var message: Text? { + Text(L10n.Chats.Chats.Tip.DeviceMenu.message) + } - var image: Image? { - Image(systemName: "antenna.radiowaves.left.and.right") - } + var image: Image? { + Image(systemName: "antenna.radiowaves.left.and.right") + } - var options: [TipOption] { - [Tips.MaxDisplayCount(1)] - } + var options: [TipOption] { + [Tips.MaxDisplayCount(1)] + } - var rules: [Rule] { - #Rule(Self.hasCompletedOnboarding) { $0.donations.count >= 1 } - #Rule(Self.$isConnected) { $0 == true } - } + var rules: [Rule] { + #Rule(Self.hasCompletedOnboarding) { $0.donations.count >= 1 } + #Rule(Self.$isConnected) { $0 == true } + } } diff --git a/MC1/Tips/LiveActivityTip.swift b/MC1/Tips/LiveActivityTip.swift index a4cccfb2..4537cf66 100644 --- a/MC1/Tips/LiveActivityTip.swift +++ b/MC1/Tips/LiveActivityTip.swift @@ -3,21 +3,21 @@ import TipKit /// Tip shown after a Live Activity starts for the first time struct LiveActivityTip: Tip { - static let radioConnected = Tips.Event(id: "radioConnected") + static let radioConnected = Tips.Event(id: "radioConnected") - var title: Text { - Text(L10n.Settings.LiveActivity.Tip.title) - } + var title: Text { + Text(L10n.Settings.LiveActivity.Tip.title) + } - var message: Text? { - Text(L10n.Settings.LiveActivity.Tip.message) - } + var message: Text? { + Text(L10n.Settings.LiveActivity.Tip.message) + } - var options: [TipOption] { - [Tips.MaxDisplayCount(1)] - } + var options: [TipOption] { + [Tips.MaxDisplayCount(1)] + } - var rules: [Rule] { - #Rule(Self.radioConnected) { $0.donations.count >= 1 } - } + var rules: [Rule] { + #Rule(Self.radioConnected) { $0.donations.count >= 1 } + } } diff --git a/MC1/Utilities/AsyncSemaphore.swift b/MC1/Utilities/AsyncSemaphore.swift index 70d0c1b5..ec5e6681 100644 --- a/MC1/Utilities/AsyncSemaphore.swift +++ b/MC1/Utilities/AsyncSemaphore.swift @@ -2,29 +2,29 @@ import Foundation /// Simple async semaphore for limiting concurrent operations actor AsyncSemaphore { - private var count: Int - private var waiters: [CheckedContinuation] = [] + private var count: Int + private var waiters: [CheckedContinuation] = [] - init(value: Int) { - self.count = value - } + init(value: Int) { + count = value + } - func wait() async { - if count > 0 { - count -= 1 - return - } - await withCheckedContinuation { continuation in - waiters.append(continuation) - } + func wait() async { + if count > 0 { + count -= 1 + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) } + } - func signal() { - if let waiter = waiters.first { - waiters.removeFirst() - waiter.resume() - } else { - count += 1 - } + func signal() { + if let waiter = waiters.first { + waiters.removeFirst() + waiter.resume() + } else { + count += 1 } + } } diff --git a/MC1/Utilities/DemoModeManager.swift b/MC1/Utilities/DemoModeManager.swift index d09e9b46..25564281 100644 --- a/MC1/Utilities/DemoModeManager.swift +++ b/MC1/Utilities/DemoModeManager.swift @@ -1,33 +1,33 @@ import MC1Services -import SwiftUI import os +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "DemoMode") @Observable @MainActor final class DemoModeManager { - static let shared = DemoModeManager() + static let shared = DemoModeManager() - private let defaults: UserDefaults + private let defaults: UserDefaults - var isUnlocked: Bool { - didSet { defaults.set(isUnlocked, forKey: AppStorageKey.isDemoModeUnlocked.rawValue) } - } + var isUnlocked: Bool { + didSet { defaults.set(isUnlocked, forKey: AppStorageKey.isDemoModeUnlocked.rawValue) } + } - var isEnabled: Bool { - didSet { defaults.set(isEnabled, forKey: AppStorageKey.isDemoModeEnabled.rawValue) } - } + var isEnabled: Bool { + didSet { defaults.set(isEnabled, forKey: AppStorageKey.isDemoModeEnabled.rawValue) } + } - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - self.isUnlocked = defaults.bool(forKey: AppStorageKey.isDemoModeUnlocked.rawValue) - self.isEnabled = defaults.bool(forKey: AppStorageKey.isDemoModeEnabled.rawValue) - } + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + isUnlocked = defaults.bool(forKey: AppStorageKey.isDemoModeUnlocked.rawValue) + isEnabled = defaults.bool(forKey: AppStorageKey.isDemoModeEnabled.rawValue) + } - func unlock() { - logger.info("Demo mode unlocked and enabled") - isUnlocked = true - isEnabled = true - } + func unlock() { + logger.info("Demo mode unlocked and enabled") + isUnlocked = true + isEnabled = true + } } diff --git a/MC1/Utilities/FirmwareSuggestedTimeout.swift b/MC1/Utilities/FirmwareSuggestedTimeout.swift index 1399e46c..b2d2cba7 100644 --- a/MC1/Utilities/FirmwareSuggestedTimeout.swift +++ b/MC1/Utilities/FirmwareSuggestedTimeout.swift @@ -1,28 +1,45 @@ import Foundation /// Sanitizes the firmware's `suggested_timeout_ms` hint shared by trace, ping, -/// and path-discovery sends. The hint is the radio's airtime estimate; scale it -/// for slack and reject implausible values (a hint of 0 would otherwise make -/// every wait expire immediately). +/// and path-discovery sends. The hint is the radio's airtime estimate, already +/// hop-aware, so it is honored: scale it for slack and clamp into a per-use-case +/// band. Only a missing hint (0) falls back to a default, because a hint of 0 +/// would otherwise make every wait expire immediately. enum FirmwareSuggestedTimeout { - static let multiplier = 1.2 - static let minimumSeconds = 5.0 - static let maximumSeconds = 60.0 - static let defaultSeconds = 30.0 - private static let millisecondsPerSecond = 1000.0 + static let multiplier = 1.2 + private static let millisecondsPerSecond = 1000.0 - /// The scaled hint before bounds checking. Exposed so callers can log the - /// rejected value when `sanitizedSeconds` falls back to the default. - static func candidateSeconds(suggestedTimeoutMs: UInt32) -> Double { - Double(suggestedTimeoutMs) / millisecondsPerSecond * multiplier - } + /// Per-use-case bounds. A single-neighbor round trip and a mesh-wide flood + /// need very different bands, so callers pick the one matching their send. + struct Profile { + let minimumSeconds: Double + let defaultSeconds: Double + let maximumSeconds: Double - /// The scaled hint when it lands within sane bounds, otherwise `defaultSeconds`. - static func sanitizedSeconds(suggestedTimeoutMs: UInt32) -> Double { - let candidateSeconds = Self.candidateSeconds(suggestedTimeoutMs: suggestedTimeoutMs) - guard candidateSeconds >= minimumSeconds, candidateSeconds <= maximumSeconds else { - return defaultSeconds - } - return candidateSeconds - } + /// Single-neighbor ping to a direct contact. The round trip is one hop, so a + /// valid hint is small (a few seconds on common presets); honor it down to a + /// second rather than inflating a fast link to a slow link's budget. The + /// ceiling still covers a legitimate max-range preset, where one zero-hop + /// round trip at SF12/BW125 genuinely approaches half a minute. + static let zeroHop = Profile(minimumSeconds: 1.0, defaultSeconds: 5.0, maximumSeconds: 30.0) + + /// Flood path discovery and user-built multi-hop traces: the request can cross + /// the whole mesh and legitimately take tens of seconds, so keep a wide band. + static let flood = Profile(minimumSeconds: 5.0, defaultSeconds: 30.0, maximumSeconds: 60.0) + } + + /// The scaled hint before clamping. Exposed so callers can log the raw estimate + /// alongside the value actually used. + static func candidateSeconds(suggestedTimeoutMs: UInt32) -> Double { + Double(suggestedTimeoutMs) / millisecondsPerSecond * multiplier + } + + /// The scaled hint clamped into `profile`'s band. A missing hint (0) yields the + /// profile default; a valid-but-small hint is honored down to the floor rather + /// than discarded, so a fast link isn't made to wait out a slow link's default. + static func sanitizedSeconds(suggestedTimeoutMs: UInt32, profile: Profile) -> Double { + guard suggestedTimeoutMs > 0 else { return profile.defaultSeconds } + let candidate = candidateSeconds(suggestedTimeoutMs: suggestedTimeoutMs) + return min(max(candidate, profile.minimumSeconds), profile.maximumSeconds) + } } diff --git a/MC1/Utilities/ImageByteCost.swift b/MC1/Utilities/ImageByteCost.swift index c31b7b79..45bcd3f8 100644 --- a/MC1/Utilities/ImageByteCost.swift +++ b/MC1/Utilities/ImageByteCost.swift @@ -13,17 +13,17 @@ import UIKit /// Animation-specific aggregation (e.g. summing GIF frames) stays at the /// caller, which invokes `bytes(for:)` once per frame. enum ImageByteCost { - private static let bytesPerPixelRGBA = 4 + private static let bytesPerPixelRGBA = 4 - static func bytes(for image: UIImage) -> Int { - if let cgImage = image.cgImage { - return cgImage.bytesPerRow * cgImage.height - } - return Int(image.size.width * image.size.height) * bytesPerPixelRGBA + static func bytes(for image: UIImage) -> Int { + if let cgImage = image.cgImage { + return cgImage.bytesPerRow * cgImage.height } + return Int(image.size.width * image.size.height) * bytesPerPixelRGBA + } - static func bytes(for image: UIImage?) -> Int { - guard let image else { return 0 } - return bytes(for: image) - } + static func bytes(for image: UIImage?) -> Int { + guard let image else { return 0 } + return bytes(for: image) + } } diff --git a/MC1/Utilities/MalwareDomainFilter.swift b/MC1/Utilities/MalwareDomainFilter.swift index 2e436b32..8a8f9501 100644 --- a/MC1/Utilities/MalwareDomainFilter.swift +++ b/MC1/Utilities/MalwareDomainFilter.swift @@ -4,135 +4,135 @@ import OSLog /// Manages a set of blocked malware domains sourced from the URLhaus blocklist. /// Uses a bundled snapshot as baseline, with background refresh from the network. actor MalwareDomainFilter { - static let shared = MalwareDomainFilter() + static let shared = MalwareDomainFilter() - private let logger = Logger(subsystem: "com.mc1", category: "MalwareDomainFilter") + private let logger = Logger(subsystem: "com.mc1", category: "MalwareDomainFilter") - private var blockedDomains: Set = [] - private var loadTask: Task? + private var blockedDomains: Set = [] + private var loadTask: Task? - private static let primaryURL = URL(string: "https://malware-filter.gitlab.io/malware-filter/urlhaus-filter-hosts-online.txt")! - private static let fallbackURL = URL(string: "https://curbengh.github.io/urlhaus-filter/urlhaus-filter-hosts-online.txt")! - private static let cacheFilename = "malware-domains.txt" - private static let refreshIntervalDays = 7 - private static let lastRefreshKey = "MalwareDomainFilter.lastRefresh" + private static let primaryURL = URL(string: "https://malware-filter.gitlab.io/malware-filter/urlhaus-filter-hosts-online.txt")! + private static let fallbackURL = URL(string: "https://curbengh.github.io/urlhaus-filter/urlhaus-filter-hosts-online.txt")! + private static let cacheFilename = "malware-domains.txt" + private static let refreshIntervalDays = 7 + private static let lastRefreshKey = "MalwareDomainFilter.lastRefresh" - /// Returns `true` if the given host is on the malware blocklist. - func isBlocked(_ host: String) async -> Bool { - await ensureLoaded() - return blockedDomains.contains(host) - } + /// Returns `true` if the given host is on the malware blocklist. + func isBlocked(_ host: String) async -> Bool { + await ensureLoaded() + return blockedDomains.contains(host) + } - // MARK: - Loading + // MARK: - Loading - /// Ensures domains are loaded exactly once, coalescing concurrent callers. - private func ensureLoaded() async { - if loadTask == nil { - loadTask = Task { await loadDomains() } - } - await loadTask?.value + /// Ensures domains are loaded exactly once, coalescing concurrent callers. + private func ensureLoaded() async { + if loadTask == nil { + loadTask = Task { await loadDomains() } } - - /// Loads domains from disk cache or bundle fallback, then triggers background refresh if stale. - private func loadDomains() async { - // Try disk cache first - if let cached = readDiskCache() { - blockedDomains = cached - logger.debug("Loaded \(cached.count) blocked domains from disk cache") - } else if let bundled = readBundledList() { - blockedDomains = bundled - logger.debug("Loaded \(bundled.count) blocked domains from bundle") - } - - // Trigger background refresh if stale - if shouldRefresh { - Task { await refresh() } - } + await loadTask?.value + } + + /// Loads domains from disk cache or bundle fallback, then triggers background refresh if stale. + private func loadDomains() async { + // Try disk cache first + if let cached = readDiskCache() { + blockedDomains = cached + logger.debug("Loaded \(cached.count) blocked domains from disk cache") + } else if let bundled = readBundledList() { + blockedDomains = bundled + logger.debug("Loaded \(bundled.count) blocked domains from bundle") } - // MARK: - Parsing - - /// Parses URLhaus hosts file format: lines of "0.0.0.0 domain" with # comments. - private func parseDomains(from text: String) -> Set { - var domains: Set = [] - for line in text.split(separator: "\n") { - if line.hasPrefix("#") { continue } - let parts = line.split(separator: " ", maxSplits: 1) - if parts.count == 2 { - domains.insert(String(parts[1])) - } - } - return domains + // Trigger background refresh if stale + if shouldRefresh { + Task { await refresh() } } - - // MARK: - Disk Cache - - private var cacheFileURL: URL { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return appSupport.appending(path: Self.cacheFilename) + } + + // MARK: - Parsing + + /// Parses URLhaus hosts file format: lines of "0.0.0.0 domain" with # comments. + private func parseDomains(from text: String) -> Set { + var domains: Set = [] + for line in text.split(separator: "\n") { + if line.hasPrefix("#") { continue } + let parts = line.split(separator: " ", maxSplits: 1) + if parts.count == 2 { + domains.insert(String(parts[1])) + } } - - private func readDiskCache() -> Set? { - guard let text = try? String(contentsOf: cacheFileURL, encoding: .utf8) else { return nil } - let domains = parseDomains(from: text) - return domains.isEmpty ? nil : domains + return domains + } + + // MARK: - Disk Cache + + private var cacheFileURL: URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return appSupport.appending(path: Self.cacheFilename) + } + + private func readDiskCache() -> Set? { + guard let text = try? String(contentsOf: cacheFileURL, encoding: .utf8) else { return nil } + let domains = parseDomains(from: text) + return domains.isEmpty ? nil : domains + } + + private func writeDiskCache(_ text: String) { + do { + let directory = cacheFileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try text.write(to: cacheFileURL, atomically: true, encoding: .utf8) + } catch { + logger.warning("Failed to write malware domain cache: \(error.localizedDescription)") } + } - private func writeDiskCache(_ text: String) { - do { - let directory = cacheFileURL.deletingLastPathComponent() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try text.write(to: cacheFileURL, atomically: true, encoding: .utf8) - } catch { - logger.warning("Failed to write malware domain cache: \(error.localizedDescription)") - } - } + // MARK: - Bundle Fallback - // MARK: - Bundle Fallback - - private func readBundledList() -> Set? { - guard let url = Bundle.main.url(forResource: "urlhaus-filter-hosts-online", withExtension: "txt"), - let text = try? String(contentsOf: url, encoding: .utf8) else { - return nil - } - return parseDomains(from: text) + private func readBundledList() -> Set? { + guard let url = Bundle.main.url(forResource: "urlhaus-filter-hosts-online", withExtension: "txt"), + let text = try? String(contentsOf: url, encoding: .utf8) else { + return nil } + return parseDomains(from: text) + } - // MARK: - Refresh + // MARK: - Refresh - private var shouldRefresh: Bool { - guard let last = UserDefaults.standard.object(forKey: Self.lastRefreshKey) as? Date else { - return true - } - let daysSince = Calendar.current.dateComponents([.day], from: last, to: .now).day ?? Int.max - return daysSince >= Self.refreshIntervalDays + private var shouldRefresh: Bool { + guard let last = UserDefaults.standard.object(forKey: Self.lastRefreshKey) as? Date else { + return true } - - /// Downloads the latest blocklist, updating cache and in-memory set on success. - /// Silently fails — stale data remains until the next attempt. - private func refresh() async { - for url in [Self.primaryURL, Self.fallbackURL] { - do { - let (data, response) = try await URLSession.shared.data(from: url) - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode), - let text = String(data: data, encoding: .utf8) else { - continue - } - - let domains = parseDomains(from: text) - guard !domains.isEmpty else { continue } - - blockedDomains = domains - writeDiskCache(text) - UserDefaults.standard.set(Date.now, forKey: Self.lastRefreshKey) - logger.info("Refreshed malware domain list: \(domains.count) domains") - return - } catch { - logger.debug("Failed to refresh from \(url): \(error.localizedDescription)") - continue - } + let daysSince = Calendar.current.dateComponents([.day], from: last, to: .now).day ?? Int.max + return daysSince >= Self.refreshIntervalDays + } + + /// Downloads the latest blocklist, updating cache and in-memory set on success. + /// Silently fails — stale data remains until the next attempt. + private func refresh() async { + for url in [Self.primaryURL, Self.fallbackURL] { + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode), + let text = String(data: data, encoding: .utf8) else { + continue } - logger.warning("All malware domain list refresh attempts failed") + + let domains = parseDomains(from: text) + guard !domains.isEmpty else { continue } + + blockedDomains = domains + writeDiskCache(text) + UserDefaults.standard.set(Date.now, forKey: Self.lastRefreshKey) + logger.info("Refreshed malware domain list: \(domains.count) domains") + return + } catch { + logger.debug("Failed to refresh from \(url): \(error.localizedDescription)") + continue + } } + logger.warning("All malware domain list refresh attempts failed") + } } diff --git a/MC1/Utilities/MeshCoreURLParser.swift b/MC1/Utilities/MeshCoreURLParser.swift index 811b6a8b..b20e000e 100644 --- a/MC1/Utilities/MeshCoreURLParser.swift +++ b/MC1/Utilities/MeshCoreURLParser.swift @@ -1,106 +1,107 @@ -import Foundation import CoreLocation +import Foundation import MC1Services /// Parses meshcore:// deep link URLs for channel and contact imports. enum MeshCoreURLParser { + static let scheme = "meshcore" + + /// Parsed channel data from a meshcore://channel/add URL + struct ChannelResult: Identifiable { + let name: String + let secret: Data - static let scheme = "meshcore" + var id: String { + secret.uppercaseHexString() + } + } + + /// Parsed contact data from a meshcore://contact/add URL. + /// The concrete type lives in MC1Services so the shared share-token utility can return it. + typealias ContactResult = MC1Services.ContactResult + + /// Parses a meshcore://channel/add URL string. + /// Returns nil if the string is not a valid channel URL. + static func parseChannelURL(_ string: String) -> ChannelResult? { + guard let url = URL(string: string), + url.scheme == "meshcore", + url.host() == "channel", + url.path() == "/add", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return nil + } - /// Parsed channel data from a meshcore://channel/add URL - struct ChannelResult: Identifiable { - let name: String - let secret: Data + let name = queryItems.first(where: { $0.name == "name" })?.value ?? "" + let secretHex = queryItems.first(where: { $0.name == "secret" })?.value ?? "" - var id: String { secret.uppercaseHexString() } + guard !name.isEmpty, + let secretData = Data(hexString: secretHex), + secretData.count == 16 else { + return nil } - /// Parsed contact data from a meshcore://contact/add URL. - /// The concrete type lives in MC1Services so the shared share-token utility can return it. - typealias ContactResult = MC1Services.ContactResult - - /// Parses a meshcore://channel/add URL string. - /// Returns nil if the string is not a valid channel URL. - static func parseChannelURL(_ string: String) -> ChannelResult? { - guard let url = URL(string: string), - url.scheme == "meshcore", - url.host() == "channel", - url.path() == "/add", - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - return nil - } - - let name = queryItems.first(where: { $0.name == "name" })?.value ?? "" - let secretHex = queryItems.first(where: { $0.name == "secret" })?.value ?? "" - - guard !name.isEmpty, - let secretData = Data(hexString: secretHex), - secretData.count == 16 else { - return nil - } - - return ChannelResult(name: name, secret: secretData) + return ChannelResult(name: name, secret: secretData) + } + + /// Parses a meshcore://contact/add URL string. + /// Returns nil if the string is not a valid contact URL. + static func parseContactURL(_ string: String) -> ContactResult? { + guard let url = URL(string: string), + url.scheme == "meshcore", + url.host() == "contact", + url.path() == "/add", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return nil } - /// Parses a meshcore://contact/add URL string. - /// Returns nil if the string is not a valid contact URL. - static func parseContactURL(_ string: String) -> ContactResult? { - guard let url = URL(string: string), - url.scheme == "meshcore", - url.host() == "contact", - url.path() == "/add", - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - return nil - } - - // Custom scheme, not x-www-form-urlencoded: a literal "+" is a name character, not a - // space. URLComponents has already percent-decoded the value, so use it verbatim. - let name = queryItems.first(where: { $0.name == "name" })?.value ?? "" - let publicKeyHex = queryItems.first(where: { $0.name == "public_key" })?.value ?? "" - - guard !name.isEmpty, - let keyData = Data(hexString: publicKeyHex), - keyData.count == ProtocolLimits.publicKeySize else { - return nil - } - - let typeValue = queryItems.first(where: { $0.name == "type" })?.value.flatMap { Int($0) } ?? 1 - let contactType = UInt8(exactly: typeValue).flatMap { ContactType(rawValue: $0) } ?? .chat - - return ContactResult(name: name, publicKey: keyData, contactType: contactType) + // Custom scheme, not x-www-form-urlencoded: a literal "+" is a name character, not a + // space. URLComponents has already percent-decoded the value, so use it verbatim. + let name = queryItems.first(where: { $0.name == "name" })?.value ?? "" + let publicKeyHex = queryItems.first(where: { $0.name == "public_key" })?.value ?? "" + + guard !name.isEmpty, + let keyData = Data(hexString: publicKeyHex), + keyData.count == ProtocolLimits.publicKeySize else { + return nil + } + + let typeValue = queryItems.first(where: { $0.name == "type" })?.value.flatMap { Int($0) } ?? 1 + let contactType = UInt8(exactly: typeValue).flatMap { ContactType(rawValue: $0) } ?? .chat + + return ContactResult(name: name, publicKey: keyData, contactType: contactType) + } + + /// Parses a meshcore://map?lat=&lon= URL into a coordinate. Returns nil if not a + /// valid map URL or if the values are out of range or not plain decimal degrees. + static func parseMapURL(_ string: String) -> CLLocationCoordinate2D? { + guard let url = URL(string: string), + url.scheme == scheme, + url.host() == "map", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return nil } - /// Parses a meshcore://map?lat=&lon= URL into a coordinate. Returns nil if not a - /// valid map URL or if the values are out of range or not plain decimal degrees. - static func parseMapURL(_ string: String) -> CLLocationCoordinate2D? { - guard let url = URL(string: string), - url.scheme == scheme, - url.host() == "map", - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - return nil - } - - guard let latitude = decimalDegree(queryItems.first(where: { $0.name == "lat" })?.value), - let longitude = decimalDegree(queryItems.first(where: { $0.name == "lon" })?.value), - (-90.0...90.0).contains(latitude), - (-180.0...180.0).contains(longitude) else { - return nil - } - - return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + guard let latitude = decimalDegree(queryItems.first(where: { $0.name == "lat" })?.value), + let longitude = decimalDegree(queryItems.first(where: { $0.name == "lon" })?.value), + (-90.0...90.0).contains(latitude), + (-180.0...180.0).contains(longitude) else { + return nil } - /// Parses a plain decimal-degree string. The format gate rejects hex floats - /// (`0x1p4`), `inf`, and `nan` — all of which `Double(_:)` accepts and would - /// otherwise feed into MKCoordinateRegion / openInMaps. - private static func decimalDegree(_ value: String?) -> Double? { - guard let value, - value.range(of: #"^-?\d{1,3}(\.\d+)?$"#, options: .regularExpression) != nil else { - return nil - } - return Double(value) + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + + /// Parses a plain decimal-degree string. The format gate rejects hex floats + /// (`0x1p4`), `inf`, and `nan` — all of which `Double(_:)` accepts and would + /// otherwise feed into MKCoordinateRegion / openInMaps. + private static func decimalDegree(_ value: String?) -> Double? { + guard let value, + value.range(of: #"^-?\d{1,3}(\.\d+)?$"#, options: .regularExpression) != nil else { + return nil } + return Double(value) + } } diff --git a/MC1/Utilities/PingHelper.swift b/MC1/Utilities/PingHelper.swift index bdbdda42..cf5c335c 100644 --- a/MC1/Utilities/PingHelper.swift +++ b/MC1/Utilities/PingHelper.swift @@ -4,77 +4,78 @@ import MC1Services import os enum PingError: Error { - case notConnected - case timeout + case notConnected + case timeout } @MainActor enum PingHelper { - private static let logger = Logger(subsystem: "com.mc1", category: "Ping") + private static let logger = Logger(subsystem: "com.mc1", category: "Ping") - /// Send a zero-hop trace ping to a contact and return the timed result. - /// Posts a VoiceOver announcement on completion. - static func zeroHopPing(contact: ContactDTO, appState: AppState) async -> PingResult { - let startTime = ContinuousClock.now - let tag = UInt32.random(in: 0.. PingResult { + let startTime = ContinuousClock.now + let tag = UInt32.random(in: 0.. UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = correctionLevel + + guard let qrImage = filter.outputImage else { return nil } + + let colorFilter = CIFilter.falseColor() + colorFilter.inputImage = qrImage + colorFilter.color0 = CIColor.black + colorFilter.color1 = CIColor(red: 0, green: 0, blue: 0, alpha: 0) + + guard let coloredImage = colorFilter.outputImage else { return nil } + + let scaledImage = coloredImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { + return nil + } + + return UIImage(cgImage: cgImage).withRenderingMode(.alwaysTemplate) + } +} diff --git a/MC1/Utilities/RepeaterResolver.swift b/MC1/Utilities/RepeaterResolver.swift index 0bb207cc..a32cc7d1 100644 --- a/MC1/Utilities/RepeaterResolver.swift +++ b/MC1/Utilities/RepeaterResolver.swift @@ -2,193 +2,249 @@ import CoreLocation import Foundation import MC1Services -struct ResolvedNode: Sendable { - let node: T - let matchKind: NodeNameMatchKind +struct ResolvedNode { + let node: T + let matchKind: NodeNameMatchKind +} + +/// A neighbor resolution that also carries the resolved node's location so callers +/// (the SNR map) can place it. The coordinate is stored as the `Double` pair (not +/// `CLLocationCoordinate2D`, which is neither `Sendable` nor `Hashable`) so the struct +/// stays `Sendable`, matching its file siblings. +struct ResolvedNeighbor { + let displayName: String + let matchKind: NodeNameMatchKind + let latitude: Double? + let longitude: Double? + + var coordinate: CLLocationCoordinate2D? { + guard let latitude, let longitude else { return nil } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } /// A single routing hop (hash bytes shown as hex) resolved to a repeater name, ready to display. -struct ResolvedPathHop: Identifiable, Sendable { - let id: Int - let hex: String - let resolution: NodeNameResolution +struct ResolvedPathHop: Identifiable { + let id: Int + let hex: String + let resolution: NodeNameResolution } /// Resolves repeater collisions by proximity and recency. enum RepeaterResolver { - private static let exactPrefixLength = 6 - - /// Match using a PathHop: exact public key match first, then hash bytes fallback. - static func bestMatch( - for hop: PathHop, - in nodes: [T], - userLocation: CLLocation? - ) -> T? { - resolve(for: hop, in: nodes, userLocation: userLocation)?.node + private static let exactPrefixLength = 6 + + /// Match using a PathHop: exact public key match first, then hash bytes fallback. + static func bestMatch( + for hop: PathHop, + in nodes: [T], + userLocation: CLLocation? + ) -> T? { + resolve(for: hop, in: nodes, userLocation: userLocation)?.node + } + + static func resolve( + for hop: PathHop, + in nodes: [T], + userLocation: CLLocation? + ) -> ResolvedNode? { + if let key = hop.publicKey, + let exact = nodes.first(where: { $0.publicKey == key }) { + return ResolvedNode(node: exact, matchKind: .exact) } - - static func resolve( - for hop: PathHop, - in nodes: [T], - userLocation: CLLocation? - ) -> ResolvedNode? { - if let key = hop.publicKey, - let exact = nodes.first(where: { $0.publicKey == key }) { - return ResolvedNode(node: exact, matchKind: .exact) - } - return resolve(for: hop.hashBytes, in: nodes, userLocation: userLocation) + return resolve(for: hop.hashBytes, in: nodes, userLocation: userLocation) + } + + /// Match using hash bytes (1-3 byte prefix) + static func bestMatch( + for hashBytes: Data, + in nodes: [T], + userLocation: CLLocation? + ) -> T? { + resolve(for: hashBytes, in: nodes, userLocation: userLocation)?.node + } + + static func resolve( + for hashBytes: Data, + in nodes: [T], + userLocation: CLLocation? + ) -> ResolvedNode? { + guard !hashBytes.isEmpty else { return nil } + + let prefixLen = hashBytes.count + let candidates = nodes.compactMap { node -> (T, Double?)? in + guard node.publicKey.prefix(prefixLen) == hashBytes else { return nil } + + let distance: Double? + if let userLocation, node.hasLocation { + let nodeLocation = CLLocation(latitude: node.latitude, longitude: node.longitude) + distance = userLocation.distance(from: nodeLocation) + } else { + distance = nil + } + + return (node, distance) } - /// Match using hash bytes (1-3 byte prefix) - static func bestMatch( - for hashBytes: Data, - in nodes: [T], - userLocation: CLLocation? - ) -> T? { - resolve(for: hashBytes, in: nodes, userLocation: userLocation)?.node + guard !candidates.isEmpty else { return nil } + + let sorted = candidates.sorted { lhs, rhs in + switch (lhs.1, rhs.1) { + case let (left?, right?): + if left != right { return left < right } + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + break + } + + if lhs.0.lastAdvertTimestamp != rhs.0.lastAdvertTimestamp { + return lhs.0.lastAdvertTimestamp > rhs.0.lastAdvertTimestamp + } + + if lhs.0.recencyDate != rhs.0.recencyDate { + return lhs.0.recencyDate > rhs.0.recencyDate + } + + return lhs.0.resolvableName.localizedStandardCompare(rhs.0.resolvableName) == .orderedAscending } - static func resolve( - for hashBytes: Data, - in nodes: [T], - userLocation: CLLocation? - ) -> ResolvedNode? { - guard !hashBytes.isEmpty else { return nil } - - let prefixLen = hashBytes.count - let candidates = nodes.compactMap { node -> (T, Double?)? in - guard node.publicKey.prefix(prefixLen) == hashBytes else { return nil } - - let distance: Double? - if let userLocation, node.hasLocation { - let nodeLocation = CLLocation(latitude: node.latitude, longitude: node.longitude) - distance = userLocation.distance(from: nodeLocation) - } else { - distance = nil - } - - return (node, distance) - } - - guard !candidates.isEmpty else { return nil } - - let sorted = candidates.sorted { lhs, rhs in - switch (lhs.1, rhs.1) { - case let (left?, right?): - if left != right { return left < right } - case (.some, .none): - return true - case (.none, .some): - return false - case (.none, .none): - break - } - - if lhs.0.lastAdvertTimestamp != rhs.0.lastAdvertTimestamp { - return lhs.0.lastAdvertTimestamp > rhs.0.lastAdvertTimestamp - } - - if lhs.0.recencyDate != rhs.0.recencyDate { - return lhs.0.recencyDate > rhs.0.recencyDate - } - - return lhs.0.resolvableName.localizedStandardCompare(rhs.0.resolvableName) == .orderedAscending - } - - guard let node = sorted.first?.0 else { return nil } - let matchingPublicKeys = Set(candidates.map { $0.0.publicKey }) - let matchKind: NodeNameMatchKind = prefixLen >= exactPrefixLength || matchingPublicKeys.count == 1 - ? .exact - : .fallback - return ResolvedNode(node: node, matchKind: matchKind) - } + guard let node = sorted.first?.0 else { return nil } + let matchingPublicKeys = Set(candidates.map(\.0.publicKey)) + let matchKind: NodeNameMatchKind = prefixLen >= exactPrefixLength || matchingPublicKeys.count == 1 + ? .exact + : .fallback + return ResolvedNode(node: node, matchKind: matchKind) + } } enum NeighborNameResolver { - static func resolve( - for prefix: Data, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - userLocation: CLLocation? - ) -> NodeNameResolution? { - if let contact = RepeaterResolver.resolve(for: prefix, in: contacts, userLocation: userLocation) { - return NodeNameResolution( - displayName: contact.node.resolvableName, - matchKind: matchKind(for: prefix, resolvedMatchKind: contact.matchKind, contacts: contacts, discoveredNodes: discoveredNodes) - ) - } - - if let node = RepeaterResolver.resolve(for: prefix, in: discoveredNodes, userLocation: userLocation) { - return NodeNameResolution( - displayName: node.node.resolvableName, - matchKind: matchKind(for: prefix, resolvedMatchKind: node.matchKind, contacts: contacts, discoveredNodes: discoveredNodes) - ) - } - - return nil - } - - private static func matchKind( - for prefix: Data, - resolvedMatchKind: NodeNameMatchKind, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO] - ) -> NodeNameMatchKind { - guard resolvedMatchKind != .unresolved, prefix.count < 6 else { - return resolvedMatchKind - } - - let matchingContactKeys = contacts - .filter { $0.publicKey.prefix(prefix.count) == prefix } - .map(\.publicKey) - let matchingDiscoveredKeys = discoveredNodes - .filter { $0.publicKey.prefix(prefix.count) == prefix } - .map(\.publicKey) - - return Set(matchingContactKeys + matchingDiscoveredKeys).count > 1 ? .fallback : .exact + /// Single resolution core: resolves a neighbor prefix to a name, match confidence, and the + /// resolved node's location. Contacts are preferred over discovered nodes, and the cross-source + /// `matchKind` refinement is applied here so every caller reads identical name/matchKind/coordinate. + static func resolveLocated( + for prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> ResolvedNeighbor? { + if let contact = RepeaterResolver.resolve(for: prefix, in: contacts, userLocation: userLocation) { + return located( + node: contact.node, + resolvedMatchKind: contact.matchKind, + prefix: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes + ) } - static func resolveName( - for prefix: Data, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - userLocation: CLLocation? - ) -> String? { - resolve( - for: prefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - )?.displayName + if let node = RepeaterResolver.resolve(for: prefix, in: discoveredNodes, userLocation: userLocation) { + return located( + node: node.node, + resolvedMatchKind: node.matchKind, + prefix: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes + ) } - static func fallbackName(for prefix: Data) -> String { - prefix.prefix(4).uppercaseHexString() + return nil + } + + private static func located( + node: some RepeaterResolvable, + resolvedMatchKind: NodeNameMatchKind, + prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO] + ) -> ResolvedNeighbor { + ResolvedNeighbor( + displayName: node.resolvableName, + matchKind: matchKind(for: prefix, resolvedMatchKind: resolvedMatchKind, contacts: contacts, discoveredNodes: discoveredNodes), + latitude: node.hasLocation ? node.latitude : nil, + longitude: node.hasLocation ? node.longitude : nil + ) + } + + static func resolve( + for prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> NodeNameResolution? { + guard let resolved = resolveLocated( + for: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) else { return nil } + + return NodeNameResolution(displayName: resolved.displayName, matchKind: resolved.matchKind) + } + + private static func matchKind( + for prefix: Data, + resolvedMatchKind: NodeNameMatchKind, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO] + ) -> NodeNameMatchKind { + guard resolvedMatchKind != .unresolved, prefix.count < 6 else { + return resolvedMatchKind } - /// Resolves every hop of a node's stored path to a repeater name, falling back to a placeholder - /// when no repeater matches a hop. Only repeaters can relay, so contacts and discovered nodes are - /// narrowed to repeaters before matching, matching the login sheet's path display. - static func resolvePath( - _ hops: [(data: Data, hex: String)], - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - userLocation: CLLocation? - ) -> [ResolvedPathHop] { - let repeaters = contacts.filter { $0.type == .repeater } - let discoveredRepeaters = discoveredNodes.filter { $0.nodeType == .repeater } - - return hops.enumerated().map { index, hop in - let resolution = resolve( - for: hop.data, - contacts: repeaters, - discoveredNodes: discoveredRepeaters, - userLocation: userLocation - ) ?? NodeNameResolution( - displayName: L10n.RemoteNodes.RemoteNodes.Auth.pathHopUnknown, - matchKind: .unresolved - ) - return ResolvedPathHop(id: index, hex: hop.hex, resolution: resolution) - } + let matchingContactKeys = contacts + .filter { $0.publicKey.prefix(prefix.count) == prefix } + .map(\.publicKey) + let matchingDiscoveredKeys = discoveredNodes + .filter { $0.publicKey.prefix(prefix.count) == prefix } + .map(\.publicKey) + + return Set(matchingContactKeys + matchingDiscoveredKeys).count > 1 ? .fallback : .exact + } + + static func resolveName( + for prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> String? { + resolve( + for: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + )?.displayName + } + + static func fallbackName(for prefix: Data) -> String { + prefix.prefix(4).uppercaseHexString() + } + + /// Resolves every hop of a node's stored path to a repeater name, falling back to a placeholder + /// when no repeater matches a hop. Only repeaters can relay, so contacts and discovered nodes are + /// narrowed to repeaters before matching, matching the login sheet's path display. + static func resolvePath( + _ hops: [(data: Data, hex: String)], + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> [ResolvedPathHop] { + let repeaters = contacts.filter { $0.type == .repeater } + let discoveredRepeaters = discoveredNodes.filter { $0.nodeType == .repeater } + + return hops.enumerated().map { index, hop in + let resolution = resolve( + for: hop.data, + contacts: repeaters, + discoveredNodes: discoveredRepeaters, + userLocation: userLocation + ) ?? NodeNameResolution( + displayName: L10n.RemoteNodes.RemoteNodes.Auth.pathHopUnknown, + matchKind: .unresolved + ) + return ResolvedPathHop(id: index, hex: hop.hex, resolution: resolution) } + } } diff --git a/MC1/Utilities/URLSafetyChecker.swift b/MC1/Utilities/URLSafetyChecker.swift index 7ecb27c3..4e3955b1 100644 --- a/MC1/Utilities/URLSafetyChecker.swift +++ b/MC1/Utilities/URLSafetyChecker.swift @@ -4,165 +4,165 @@ import OSLog /// Validates URLs before fetching to prevent SSRF attacks via private network access. /// Rejects non-HTTP(S) schemes and private/reserved IPs. enum URLSafetyChecker { - private static let logger = Logger(subsystem: "com.mc1", category: "URLSafetyChecker") - - /// Hosts that bypass safety checks (known-safe CDN domains) - private static let allowedHosts: Set = [ - "media.giphy.com", - "i.giphy.com" - ] - - private static let dnsTimeoutSeconds: Duration = .seconds(5) - - // MARK: - Public API - - /// Returns `true` if the URL is safe to fetch, `false` otherwise. - /// Fails closed: DNS failure or timeout returns `false`. - static func isSafe(_ url: URL) async -> Bool { - // Only allow HTTP(S) - guard let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { - logger.warning("Rejected non-HTTP(S) scheme: \(url.scheme ?? "nil")") - return false - } - - // Must have a host - guard let host = url.host() else { - logger.warning("Rejected URL with no host") - return false - } - - // Allow-listed hosts bypass SSRF checks (known-safe CDN domains) - if allowedHosts.contains(host) { - return true - } - - // If host is an IP literal, check directly - if isPrivateOrReserved(host) { - logger.warning("Rejected private/reserved IP: \(host)") - return false - } - - // Resolve DNS and check all addresses - return await resolveAndCheck(host: host) + private static let logger = Logger(subsystem: "com.mc1", category: "URLSafetyChecker") + + /// Hosts that bypass safety checks (known-safe CDN domains) + private static let allowedHosts: Set = [ + "media.giphy.com", + "i.giphy.com" + ] + + private static let dnsTimeoutSeconds: Duration = .seconds(5) + + // MARK: - Public API + + /// Returns `true` if the URL is safe to fetch, `false` otherwise. + /// Fails closed: DNS failure or timeout returns `false`. + static func isSafe(_ url: URL) async -> Bool { + // Only allow HTTP(S) + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + logger.warning("Rejected non-HTTP(S) scheme: \(url.scheme ?? "nil")") + return false } - /// Checks whether an IP address string falls within private or reserved ranges. - static func isPrivateOrReserved(_ address: String) -> Bool { - var addr4 = in_addr() - if inet_pton(AF_INET, address, &addr4) == 1 { - return isPrivateIPv4(UInt32(bigEndian: addr4.s_addr)) - } - - var addr6 = in6_addr() - if inet_pton(AF_INET6, address, &addr6) == 1 { - let bytes = withUnsafeBytes(of: &addr6.__u6_addr.__u6_addr8) { Array($0) } - return isPrivateIPv6(bytes) - } - - // Not a valid IP literal — not private (will be resolved via DNS) - return false + // Must have a host + guard let host = url.host() else { + logger.warning("Rejected URL with no host") + return false } - // MARK: - Private IP Range Checks - - /// Checks whether a parsed IPv4 address falls within private or reserved ranges. - private static func isPrivateIPv4(_ ip: UInt32) -> Bool { - if ip == 0 { return true } // 0.0.0.0 - if ip >> 24 == 127 { return true } // 127.0.0.0/8 - if ip >> 24 == 10 { return true } // 10.0.0.0/8 - if ip >> 20 == 0xAC1 { return true } // 172.16.0.0/12 - if ip >> 16 == 0xC0A8 { return true } // 192.168.0.0/16 - if ip >> 16 == 0xA9FE { return true } // 169.254.0.0/16 (link-local) - if ip >> 28 == 0xE { return true } // 224.0.0.0/4 (multicast) - if ip >> 28 == 0xF { return true } // 240.0.0.0/4 (reserved) - return false + // Allow-listed hosts bypass SSRF checks (known-safe CDN domains) + if allowedHosts.contains(host) { + return true } - /// Checks whether a parsed IPv6 address falls within private or reserved ranges. - private static func isPrivateIPv6(_ bytes: [UInt8]) -> Bool { - if bytes.allSatisfy({ $0 == 0 }) { return true } // :: - if bytes.dropLast().allSatisfy({ $0 == 0 }) && bytes.last == 1 { return true } // ::1 - if bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80 { return true } // fe80::/10 - if (bytes[0] & 0xFE) == 0xFC { return true } // fc00::/7 - // IPv4-mapped: check the embedded IPv4 address - if bytes[0...9].allSatisfy({ $0 == 0 }) && bytes[10] == 0xFF && bytes[11] == 0xFF { - let mapped = String(format: "%d.%d.%d.%d", bytes[12], bytes[13], bytes[14], bytes[15]) - return isPrivateOrReserved(mapped) - } - if bytes[0] == 0xFF { return true } // ff00::/8 - return false + // If host is an IP literal, check directly + if isPrivateOrReserved(host) { + logger.warning("Rejected private/reserved IP: \(host)") + return false } - // MARK: - DNS Resolution - - /// Resolves the host via DNS and checks all returned addresses. - /// Uses a 5-second timeout to prevent thread pool starvation. - /// Returns `true` if all resolved addresses are public, `false` otherwise. - private static func resolveAndCheck(host: String) async -> Bool { - do { - return try await withThrowingTaskGroup(of: Bool.self) { group in - group.addTask { - await Task.detached { - resolveDNS(host: host) - }.value - } - group.addTask { - try await Task.sleep(for: dnsTimeoutSeconds) - throw CancellationError() - } - let result = try await group.next() ?? false - group.cancelAll() - return result - } - } catch { - logger.warning("DNS resolution timed out or failed for \(host)") - return false - } + // Resolve DNS and check all addresses + return await resolveAndCheck(host: host) + } + + /// Checks whether an IP address string falls within private or reserved ranges. + static func isPrivateOrReserved(_ address: String) -> Bool { + var addr4 = in_addr() + if inet_pton(AF_INET, address, &addr4) == 1 { + return isPrivateIPv4(UInt32(bigEndian: addr4.s_addr)) } - /// Synchronous DNS resolution + private IP check (runs on detached task). - private static func resolveDNS(host: String) -> Bool { - var hints = addrinfo() - hints.ai_family = AF_UNSPEC - hints.ai_socktype = SOCK_STREAM + var addr6 = in6_addr() + if inet_pton(AF_INET6, address, &addr6) == 1 { + let bytes = withUnsafeBytes(of: &addr6.__u6_addr.__u6_addr8) { Array($0) } + return isPrivateIPv6(bytes) + } - var result: UnsafeMutablePointer? - let status = getaddrinfo(host, nil, &hints, &result) - guard status == 0, let addrList = result else { - return false + // Not a valid IP literal — not private (will be resolved via DNS) + return false + } + + // MARK: - Private IP Range Checks + + /// Checks whether a parsed IPv4 address falls within private or reserved ranges. + private static func isPrivateIPv4(_ ip: UInt32) -> Bool { + if ip == 0 { return true } // 0.0.0.0 + if ip >> 24 == 127 { return true } // 127.0.0.0/8 + if ip >> 24 == 10 { return true } // 10.0.0.0/8 + if ip >> 20 == 0xAC1 { return true } // 172.16.0.0/12 + if ip >> 16 == 0xC0A8 { return true } // 192.168.0.0/16 + if ip >> 16 == 0xA9FE { return true } // 169.254.0.0/16 (link-local) + if ip >> 28 == 0xE { return true } // 224.0.0.0/4 (multicast) + if ip >> 28 == 0xF { return true } // 240.0.0.0/4 (reserved) + return false + } + + /// Checks whether a parsed IPv6 address falls within private or reserved ranges. + private static func isPrivateIPv6(_ bytes: [UInt8]) -> Bool { + if bytes.allSatisfy({ $0 == 0 }) { return true } // :: + if bytes.dropLast().allSatisfy({ $0 == 0 }), bytes.last == 1 { return true } // ::1 + if bytes[0] == 0xFE, (bytes[1] & 0xC0) == 0x80 { return true } // fe80::/10 + if (bytes[0] & 0xFE) == 0xFC { return true } // fc00::/7 + // IPv4-mapped: check the embedded IPv4 address + if bytes[0...9].allSatisfy({ $0 == 0 }), bytes[10] == 0xFF, bytes[11] == 0xFF { + let mapped = String(format: "%d.%d.%d.%d", bytes[12], bytes[13], bytes[14], bytes[15]) + return isPrivateOrReserved(mapped) + } + if bytes[0] == 0xFF { return true } // ff00::/8 + return false + } + + // MARK: - DNS Resolution + + /// Resolves the host via DNS and checks all returned addresses. + /// Uses a 5-second timeout to prevent thread pool starvation. + /// Returns `true` if all resolved addresses are public, `false` otherwise. + private static func resolveAndCheck(host: String) async -> Bool { + do { + return try await withThrowingTaskGroup(of: Bool.self) { group in + group.addTask { + await Task.detached { + resolveDNS(host: host) + }.value } - defer { freeaddrinfo(addrList) } - - var current: UnsafeMutablePointer? = addrList - while let info = current { - let address = extractAddress(from: info.pointee) - if let address, isPrivateOrReserved(address) { - return false - } - current = info.pointee.ai_next + group.addTask { + try await Task.sleep(for: dnsTimeoutSeconds) + throw CancellationError() } + let result = try await group.next() ?? false + group.cancelAll() + return result + } + } catch { + logger.warning("DNS resolution timed out or failed for \(host)") + return false + } + } + + /// Synchronous DNS resolution + private IP check (runs on detached task). + private static func resolveDNS(host: String) -> Bool { + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + + var result: UnsafeMutablePointer? + let status = getaddrinfo(host, nil, &hints, &result) + guard status == 0, let addrList = result else { + return false + } + defer { freeaddrinfo(addrList) } - return true + var current: UnsafeMutablePointer? = addrList + while let info = current { + let address = extractAddress(from: info.pointee) + if let address, isPrivateOrReserved(address) { + return false + } + current = info.pointee.ai_next } - /// Extracts a human-readable IP address string from an addrinfo struct. - private static func extractAddress(from info: addrinfo) -> String? { - var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + return true + } - switch info.ai_family { - case AF_INET: - var addr = info.ai_addr!.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } - inet_ntop(AF_INET, &addr.sin_addr, &buffer, socklen_t(INET_ADDRSTRLEN)) - return buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + /// Extracts a human-readable IP address string from an addrinfo struct. + private static func extractAddress(from info: addrinfo) -> String? { + var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - case AF_INET6: - var addr = info.ai_addr!.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } - inet_ntop(AF_INET6, &addr.sin6_addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) - return buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + switch info.ai_family { + case AF_INET: + var addr = info.ai_addr!.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } + inet_ntop(AF_INET, &addr.sin_addr, &buffer, socklen_t(INET_ADDRSTRLEN)) + return buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } - default: - return nil - } + case AF_INET6: + var addr = info.ai_addr!.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } + inet_ntop(AF_INET6, &addr.sin6_addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) + return buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + + default: + return nil } + } } diff --git a/MC1/Views/AppSidebar.swift b/MC1/Views/AppSidebar.swift index 4980482b..6e03a9a1 100644 --- a/MC1/Views/AppSidebar.swift +++ b/MC1/Views/AppSidebar.swift @@ -1,103 +1,103 @@ -import SwiftUI import MC1Services +import SwiftUI /// The sidebar column of the iPad three-column layout: the five `AppTab` rows, bridging /// row selection to `NavigationCoordinator.selectedTab`. The radio control lives in each /// section's own toolbar, since the icon-only sidebar is too narrow to host it. struct AppSidebar: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @Binding var columnVisibility: NavigationSplitViewVisibility + @Binding var columnVisibility: NavigationSplitViewVisibility - /// List single-selection requires an optional binding; a nil selection falls back to chats. - /// Selecting a row collapses the sidebar (Mail-style) in the same write as the tab change, so - /// the section rebuilds once with the right visibility for its column count — collapsing - /// reactively after the tab change instead would render one frame at the wrong shape (the map - /// section flashing its sidebar, a list section briefly missing its content column). The collapse - /// is gated on `isSidebarWide`. - private var selection: Binding { - Binding( - get: { AppTab(rawValue: appState.navigation.selectedTab) ?? .chats }, - set: { newValue in - let tab = newValue ?? .chats - appState.navigation.selectedTab = tab.rawValue - if !appState.navigation.isSidebarWide { - columnVisibility = tab.collapsedSidebarVisibility - } - } - ) - } + /// List single-selection requires an optional binding; a nil selection falls back to chats. + /// Selecting a row collapses the sidebar (Mail-style) in the same write as the tab change, so + /// the section rebuilds once with the right visibility for its column count — collapsing + /// reactively after the tab change instead would render one frame at the wrong shape (the map + /// section flashing its sidebar, a list section briefly missing its content column). The collapse + /// is gated on `isSidebarWide`. + private var selection: Binding { + Binding( + get: { AppTab(rawValue: appState.navigation.selectedTab) ?? .chats }, + set: { newValue in + let tab = newValue ?? .chats + appState.navigation.selectedTab = tab.rawValue + if !appState.navigation.isSidebarWide { + columnVisibility = tab.collapsedSidebarVisibility + } + } + ) + } - private var unreadCount: Int { - appState.services?.notificationService.badgeCount ?? 0 - } + private var unreadCount: Int { + appState.services?.notificationService.badgeCount ?? 0 + } - var body: some View { - List(selection: selection) { - // A row `.badge` reserves a trailing accessory slot that leading-aligns this icon-only - // label, shifting the glyph off the column's centerline that every other row sits on. - // The unread count is overlaid on the centered icon instead, leaving the glyph centered. - Label(L10n.Localizable.Tabs.chats, systemImage: "message.fill") - .overlay(alignment: .topTrailing) { unreadBadge } - .accessibilityValue(unreadCount > 0 - ? L10n.Localizable.Tabs.chatsUnreadAccessibilityValue(unreadCount) - : "") - .tag(AppTab.chats) + var body: some View { + List(selection: selection) { + // A row `.badge` reserves a trailing accessory slot that leading-aligns this icon-only + // label, shifting the glyph off the column's centerline that every other row sits on. + // The unread count is overlaid on the centered icon instead, leaving the glyph centered. + Label(L10n.Localizable.Tabs.chats, systemImage: "message.fill") + .overlay(alignment: .topTrailing) { unreadBadge } + .accessibilityValue(unreadCount > 0 + ? L10n.Localizable.Tabs.chatsUnreadAccessibilityValue(unreadCount) + : "") + .tag(AppTab.chats) - Label(L10n.Localizable.Tabs.nodes, systemImage: "flipphone") - .tag(AppTab.nodes) + Label(L10n.Localizable.Tabs.nodes, systemImage: "flipphone") + .tag(AppTab.nodes) - Label(L10n.Localizable.Tabs.map, systemImage: "map.fill") - .tag(AppTab.map) + Label(L10n.Localizable.Tabs.map, systemImage: "map.fill") + .tag(AppTab.map) - Label(L10n.Localizable.Tabs.tools, systemImage: "wrench.and.screwdriver") - .tag(AppTab.tools) + Label(L10n.Localizable.Tabs.tools, systemImage: "wrench.and.screwdriver") + .tag(AppTab.tools) - Label(L10n.Localizable.Tabs.settings, systemImage: "gear") - .tag(AppTab.settings) - } - // Icon-only sidebar: each Label's title is hidden visually but stays its row's VoiceOver - // label, so the titles are load-bearing for accessibility and must remain on every row. - .labelStyle(.iconOnly) + Label(L10n.Localizable.Tabs.settings, systemImage: "gear") + .tag(AppTab.settings) } + // Icon-only sidebar: each Label's title is hidden visually but stays its row's VoiceOver + // label, so the titles are load-bearing for accessibility and must remain on every row. + .labelStyle(.iconOnly) + } - @ViewBuilder - private var unreadBadge: some View { - if unreadCount > 0 { - Text(unreadCount > Self.unreadBadgeOverflowThreshold - ? L10n.Chats.Chats.ScrollButton.Badge.overflow - : "\(unreadCount)") - .font(.caption2.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.red, in: .capsule) - .fixedSize() - // The sidebar renders row content through a vibrancy effect that blends the capsule - // fill with the background; flattening to one layer keeps the red fully opaque. - .drawingGroup() - .offset(x: 8, y: -8) - } + @ViewBuilder + private var unreadBadge: some View { + if unreadCount > 0 { + Text(unreadCount > Self.unreadBadgeOverflowThreshold + ? L10n.Chats.Chats.ScrollButton.Badge.overflow + : "\(unreadCount)") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.red, in: .capsule) + .fixedSize() + // The sidebar renders row content through a vibrancy effect that blends the capsule + // fill with the background; flattening to one layer keeps the red fully opaque. + .drawingGroup() + .offset(x: 8, y: -8) } + } - private static let unreadBadgeOverflowThreshold = 99 + private static let unreadBadgeOverflowThreshold = 99 } extension AppTab { - /// The column visibility that hides the app sidebar for this section's split shape: a - /// two-column split (Map) hides its sidebar with `.detailOnly`, while a three-column split - /// (the list sections) hides the sidebar but keeps content + detail with `.doubleColumn`. - /// A single shared visibility value cannot collapse both shapes, so it is chosen per section. - var collapsedSidebarVisibility: NavigationSplitViewVisibility { - self == .map ? .detailOnly : .doubleColumn - } + /// The column visibility that hides the app sidebar for this section's split shape: a + /// two-column split (Map) hides its sidebar with `.detailOnly`, while a three-column split + /// (the list sections) hides the sidebar but keeps content + detail with `.doubleColumn`. + /// A single shared visibility value cannot collapse both shapes, so it is chosen per section. + var collapsedSidebarVisibility: NavigationSplitViewVisibility { + self == .map ? .detailOnly : .doubleColumn + } } #Preview { - NavigationSplitView { - AppSidebar(columnVisibility: .constant(.all)) - .environment(\.appState, AppState()) - } detail: { - Text(verbatim: "Detail") - } + NavigationSplitView { + AppSidebar(columnVisibility: .constant(.all)) + .environment(\.appState, AppState()) + } detail: { + Text(verbatim: "Detail") + } } diff --git a/MC1/Views/Appearance/AppearanceView.swift b/MC1/Views/Appearance/AppearanceView.swift index 06d80f3e..cf2af6ab 100644 --- a/MC1/Views/Appearance/AppearanceView.swift +++ b/MC1/Views/Appearance/AppearanceView.swift @@ -1,101 +1,105 @@ -import SwiftUI import MC1Services +import SwiftUI struct AppearanceView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var activeTheme - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - - @State private var schemeTrigger = 0 - @State private var selectionTrigger = 0 + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var activeTheme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - private var themeService: ThemeService { appState.themeService } + @State private var schemeTrigger = 0 + @State private var selectionTrigger = 0 - private var availableThemes: [Theme] { themeService.availableToCurrentUser() } + private var themeService: ThemeService { + appState.themeService + } - private var columns: [GridItem] { - dynamicTypeSize.isAccessibilitySize - ? [GridItem(.flexible())] - : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] - } + private var availableThemes: [Theme] { + themeService.availableToCurrentUser() + } - /// "Purchase More Themes" is shown only when at least one registry theme is unavailable. - static func shouldShowBrowseMore(available: [Theme]) -> Bool { - available.count < ThemeRegistry.allThemes.count - } + private var columns: [GridItem] { + dynamicTypeSize.isAccessibilitySize + ? [GridItem(.flexible())] + : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] + } - var body: some View { - List { - schemeSection - themesSection - if Self.shouldShowBrowseMore(available: availableThemes) { - Section { - NavigationLink(L10n.Settings.Appearance.MoreThemes.link) { - SupportDevelopmentView() - } - } - .themedRowBackground(activeTheme) - } - } - .themedCanvas(activeTheme) - .navigationTitle(L10n.Settings.Appearance.title) - .navigationBarTitleDisplayMode(.inline) - .sensoryFeedback(.selection, trigger: schemeTrigger) - .sensoryFeedback(.selection, trigger: selectionTrigger) - } + /// "Purchase More Themes" is shown only when at least one registry theme is unavailable. + static func shouldShowBrowseMore(available: [Theme]) -> Bool { + available.count < ThemeRegistry.allThemes.count + } - private var schemeSection: some View { + var body: some View { + List { + schemeSection + themesSection + if Self.shouldShowBrowseMore(available: availableThemes) { Section { - Picker(L10n.Settings.Appearance.Scheme.header, selection: schemeBinding) { - ForEach(AppColorSchemePreference.allCases) { preference in - Text(schemeLabel(preference)).tag(preference) - } - } - .pickerStyle(.menu) + NavigationLink(L10n.Settings.Appearance.MoreThemes.link) { + SupportDevelopmentView() + } } .themedRowBackground(activeTheme) + } } + .themedCanvas(activeTheme) + .navigationTitle(L10n.Settings.Appearance.title) + .navigationBarTitleDisplayMode(.inline) + .sensoryFeedback(.selection, trigger: schemeTrigger) + .sensoryFeedback(.selection, trigger: selectionTrigger) + } - private var themesSection: some View { - Section { - LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { - ForEach(availableThemes) { theme in - ThemeSelectionCard( - theme: theme, - isSelected: theme.id == activeTheme.id, - onSelect: { select(theme) } - ) - } - } - .listRowInsets(ThemeCardMetrics.gridRowInsets) - .listRowBackground(Color.clear) - } header: { - Text(L10n.Settings.Appearance.Themes.header) + private var schemeSection: some View { + Section { + Picker(L10n.Settings.Appearance.Scheme.header, selection: schemeBinding) { + ForEach(AppColorSchemePreference.allCases) { preference in + Text(schemeLabel(preference)).tag(preference) } + } + .pickerStyle(.menu) } + .themedRowBackground(activeTheme) + } - private var schemeBinding: Binding { - Binding( - get: { themeService.colorSchemePreference }, - set: { - themeService.setColorSchemePreference($0) - schemeTrigger += 1 - } - ) - } - - private func schemeLabel(_ preference: AppColorSchemePreference) -> String { - switch preference { - case .system: L10n.Settings.Appearance.Scheme.system - case .light: L10n.Settings.Appearance.Scheme.light - case .dark: L10n.Settings.Appearance.Scheme.dark + private var themesSection: some View { + Section { + LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { + ForEach(availableThemes) { theme in + ThemeSelectionCard( + theme: theme, + isSelected: theme.id == activeTheme.id, + onSelect: { select(theme) } + ) } + } + .listRowInsets(ThemeCardMetrics.gridRowInsets) + .listRowBackground(Color.clear) + } header: { + Text(L10n.Settings.Appearance.Themes.header) } + } + + private var schemeBinding: Binding { + Binding( + get: { themeService.colorSchemePreference }, + set: { + themeService.setColorSchemePreference($0) + schemeTrigger += 1 + } + ) + } - private func select(_ theme: Theme) { - // The list is already filtered to accessible themes; the throw path is a defensive - // guard against a refund-driven revert landing mid-tap. - try? themeService.setCurrent(theme) - selectionTrigger += 1 + private func schemeLabel(_ preference: AppColorSchemePreference) -> String { + switch preference { + case .system: L10n.Settings.Appearance.Scheme.system + case .light: L10n.Settings.Appearance.Scheme.light + case .dark: L10n.Settings.Appearance.Scheme.dark } + } + + private func select(_ theme: Theme) { + // The list is already filtered to accessible themes; the throw path is a defensive + // guard against a refund-driven revert landing mid-tap. + try? themeService.setCurrent(theme) + selectionTrigger += 1 + } } diff --git a/MC1/Views/Appearance/Components/ThemeSelectionCard.swift b/MC1/Views/Appearance/Components/ThemeSelectionCard.swift index 24725208..be471b89 100644 --- a/MC1/Views/Appearance/Components/ThemeSelectionCard.swift +++ b/MC1/Views/Appearance/Components/ThemeSelectionCard.swift @@ -2,41 +2,41 @@ import SwiftUI /// An owned theme on the Appearance screen: swatch + name + (Selected badge | tap to apply). struct ThemeSelectionCard: View { - let theme: Theme - let isSelected: Bool - let onSelect: () -> Void + let theme: Theme + let isSelected: Bool + let onSelect: () -> Void - var body: some View { - Button(action: onSelect) { - VStack(spacing: 8) { - ThemePaletteSwatch(theme: theme) - Text(theme.localizedName) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - if isSelected { - HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { - Image(systemName: "checkmark") - Text(L10n.Settings.Appearance.Themes.selected) - } - .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) - } else { - Color.clear.frame(height: 1) - } - } - .padding(ThemeCardMetrics.contentPadding) - .frame(maxWidth: .infinity) - .overlay( - RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius) - .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: ThemeCardMetrics.selectionStrokeWidth) - ) + var body: some View { + Button(action: onSelect) { + VStack(spacing: 8) { + ThemePaletteSwatch(theme: theme) + Text(theme.localizedName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + if isSelected { + HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { + Image(systemName: "checkmark") + Text(L10n.Settings.Appearance.Themes.selected) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + } else { + Color.clear.frame(height: 1) } - .buttonStyle(.plain) - .disabled(isSelected) - .accessibilityElement(children: .ignore) - .accessibilityLabel(isSelected - ? L10n.Settings.Appearance.Accessibility.ThemeCard.selectedLabel(theme.localizedName) - : L10n.Settings.Appearance.Accessibility.ThemeCard.ownedLabel(theme.localizedName)) - .accessibilityHint(isSelected ? "" : L10n.Settings.Appearance.Accessibility.ThemeCard.ownedHint) + } + .padding(ThemeCardMetrics.contentPadding) + .frame(maxWidth: .infinity) + .overlay( + RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius) + .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: ThemeCardMetrics.selectionStrokeWidth) + ) } + .buttonStyle(.plain) + .disabled(isSelected) + .accessibilityElement(children: .ignore) + .accessibilityLabel(isSelected + ? L10n.Settings.Appearance.Accessibility.ThemeCard.selectedLabel(theme.localizedName) + : L10n.Settings.Appearance.Accessibility.ThemeCard.ownedLabel(theme.localizedName)) + .accessibilityHint(isSelected ? "" : L10n.Settings.Appearance.Accessibility.ThemeCard.ownedHint) + } } diff --git a/MC1/Views/Chats/BlockSenderContext.swift b/MC1/Views/Chats/BlockSenderContext.swift index f3cf70e5..f72f743b 100644 --- a/MC1/Views/Chats/BlockSenderContext.swift +++ b/MC1/Views/Chats/BlockSenderContext.swift @@ -2,7 +2,7 @@ import Foundation /// Context for presenting the block-sender sheet in channel conversations. struct BlockSenderContext: Identifiable { - let id = UUID() - let senderName: String - let radioID: UUID + let id = UUID() + let senderName: String + let radioID: UUID } diff --git a/MC1/Views/Chats/ChannelAvatar.swift b/MC1/Views/Chats/ChannelAvatar.swift index 40297e01..de5cd7ad 100644 --- a/MC1/Views/Chats/ChannelAvatar.swift +++ b/MC1/Views/Chats/ChannelAvatar.swift @@ -1,35 +1,35 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChannelAvatar: View { - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - let channel: ChannelDTO - let size: CGFloat + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + let channel: ChannelDTO + let size: CGFloat - var body: some View { - ZStack { - Circle() - .fill(fill) + var body: some View { + ZStack { + Circle() + .fill(fill) - Image(systemName: channel.isPublicChannel ? "globe" : (channel.name.hasPrefix("#") ? "number" : "lock")) - .font(.system(size: size * 0.4, weight: .bold)) - .foregroundStyle(glyph) - } - .frame(width: size, height: size) + Image(systemName: channel.isPublicChannel ? "globe" : (channel.name.hasPrefix("#") ? "number" : "lock")) + .font(.system(size: size * 0.4, weight: .bold)) + .foregroundStyle(glyph) } + .frame(width: size, height: size) + } - private var fill: Color { - theme.categoryAvatarColor(.channel, colorScheme: colorScheme, contrast: colorSchemeContrast) - } + private var fill: Color { + theme.categoryAvatarColor(.channel, colorScheme: colorScheme, contrast: colorSchemeContrast) + } - private var glyph: Color { - theme.avatarGlyphColor( - forFill: fill, - usesCategoryOverride: theme.usesCategoryAvatarOverride, - colorScheme: colorScheme, - contrast: colorSchemeContrast - ) - } + private var glyph: Color { + theme.avatarGlyphColor( + forFill: fill, + usesCategoryOverride: theme.usesCategoryAvatarOverride, + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + } } diff --git a/MC1/Views/Chats/ChannelConversationRow.swift b/MC1/Views/Chats/ChannelConversationRow.swift index c7f972c6..937a172f 100644 --- a/MC1/Views/Chats/ChannelConversationRow.swift +++ b/MC1/Views/Chats/ChannelConversationRow.swift @@ -1,54 +1,54 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChannelConversationRow: View { - private typealias Strings = L10n.Chats.Chats.Row - let channel: ChannelDTO - let viewModel: ChatViewModel - var referenceDate: Date? - - var body: some View { - HStack(spacing: 12) { - ChannelAvatar(channel: channel, size: 44) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text(channel.displayName) - .font(.headline) - .lineLimit(1) - - Spacer() - - NotificationLevelIndicator(level: channel.notificationLevel) - - if channel.isFavorite { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) - .font(.caption) - .accessibilityLabel(Strings.favorite) - } - - if let date = channel.lastMessageDate { - ConversationTimestamp(date: date, referenceDate: referenceDate) - } - } - - HStack { - Text(viewModel.lastMessagePreview(id: channel.id) ?? Strings.noMessages) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer() - - UnreadBadges( - unreadCount: channel.unreadCount, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: channel.notificationLevel - ) - } - } + private typealias Strings = L10n.Chats.Chats.Row + let channel: ChannelDTO + let viewModel: ChatViewModel + var referenceDate: Date? + + var body: some View { + HStack(spacing: 12) { + ChannelAvatar(channel: channel, size: 44) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(channel.displayName) + .font(.headline) + .lineLimit(1) + + Spacer() + + NotificationLevelIndicator(level: channel.notificationLevel) + + if channel.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .font(.caption) + .accessibilityLabel(Strings.favorite) + } + + if let date = channel.lastMessageDate { + ConversationTimestamp(date: date, referenceDate: referenceDate) + } + } + + HStack { + Text(viewModel.lastMessagePreview(id: channel.id) ?? Strings.noMessages) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + UnreadBadges( + unreadCount: channel.unreadCount, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: channel.notificationLevel + ) } - .padding(.vertical, 4) + } } + .padding(.vertical, 4) + } } diff --git a/MC1/Views/Chats/ChatConversationActions.swift b/MC1/Views/Chats/ChatConversationActions.swift index a9e6f88b..8aa79a4f 100644 --- a/MC1/Views/Chats/ChatConversationActions.swift +++ b/MC1/Views/Chats/ChatConversationActions.swift @@ -1,52 +1,51 @@ -import SwiftUI import MC1Services +import SwiftUI /// Layout-independent conversation actions shared by the compact `ChatsView` (stack) and the iPad /// `ChatsContentColumn` (split). Both list layouts run these service sequences identically; only the /// navigation glue around them (which stack/selection to update) differs, so that stays in each view. enum ChatConversationActions { + /// A channel deletion that failed, surfaced in a retry alert. + struct Failure { + let channel: ChannelDTO + let message: String + } - /// A channel deletion that failed, surfaced in a retry alert. - struct Failure { - let channel: ChannelDTO - let message: String - } + enum DeleteError: LocalizedError { + case servicesUnavailable - enum DeleteError: LocalizedError { - case servicesUnavailable - - var errorDescription: String? { - switch self { - case .servicesUnavailable: L10n.Chats.Chats.Error.servicesUnavailable - } - } + var errorDescription: String? { + switch self { + case .servicesUnavailable: L10n.Chats.Chats.Error.servicesUnavailable + } } + } - /// Clears a channel on the radio, then removes its delivered notifications and refreshes the badge. - @MainActor - static func deleteChannel(_ channel: ChannelDTO, appState: AppState) async throws { - guard let channelService = appState.services?.channelService else { - throw DeleteError.servicesUnavailable - } - try await channelService.clearChannel(radioID: channel.radioID, index: channel.index) - await appState.services?.notificationService.removeDeliveredNotifications( - forChannelIndex: channel.index, - radioID: channel.radioID - ) - await appState.services?.notificationService.updateBadgeCount() + /// Clears a channel on the radio, then removes its delivered notifications and refreshes the badge. + @MainActor + static func deleteChannel(_ channel: ChannelDTO, appState: AppState) async throws { + guard let channelService = appState.services?.channelService else { + throw DeleteError.servicesUnavailable } + try await channelService.clearChannel(radioID: channel.radioID, index: channel.index) + await appState.services?.notificationService.removeDeliveredNotifications( + forChannelIndex: channel.index, + radioID: channel.radioID + ) + await appState.services?.notificationService.updateBadgeCount() + } - /// Leaves a room session and removes its backing contact, then refreshes the badge. - @MainActor - static func leaveRoom(_ session: RemoteNodeSessionDTO, appState: AppState) async throws { - try await appState.services?.roomServerService.leaveRoom( - sessionID: session.id, - publicKey: session.publicKey - ) - try await appState.services?.contactService.removeContact( - radioID: session.radioID, - publicKey: session.publicKey - ) - await appState.services?.notificationService.updateBadgeCount() - } + /// Leaves a room session and removes its backing contact, then refreshes the badge. + @MainActor + static func leaveRoom(_ session: RemoteNodeSessionDTO, appState: AppState) async throws { + try await appState.services?.roomServerService.leaveRoom( + sessionID: session.id, + publicKey: session.publicKey + ) + try await appState.services?.contactService.removeContact( + radioID: session.radioID, + publicKey: session.publicKey + ) + await appState.services?.notificationService.updateBadgeCount() + } } diff --git a/MC1/Views/Chats/ChatConversationInputBar.swift b/MC1/Views/Chats/ChatConversationInputBar.swift index 89ddf7f6..1885cc41 100644 --- a/MC1/Views/Chats/ChatConversationInputBar.swift +++ b/MC1/Views/Chats/ChatConversationInputBar.swift @@ -1,59 +1,59 @@ -import SwiftUI import MC1Services +import SwiftUI /// Input bar for chat conversations, configured per conversation type. struct ChatConversationInputBar: View { - let conversationType: ChatConversationType - @Binding var composingText: String - @Binding var focusRequest: Int - let nodeNameByteCount: Int - let onSend: (String) async -> Void - let onWillSend: () -> Void + let conversationType: ChatConversationType + @Binding var composingText: String + @Binding var focusRequest: Int + let nodeNameByteCount: Int + let onSend: (String) async -> Void + let onWillSend: () -> Void - var body: some View { - switch conversationType { - case .dm: - ChatInputBar( - text: $composingText, - focusRequest: focusRequest, - placeholder: L10n.Chats.Chats.Input.Placeholder.directMessage, - maxBytes: ProtocolLimits.maxDirectMessageLength, - isEncrypted: true, - leading: { ChatShareMenu(onInsert: insertShared) } - ) { text in - onWillSend() - Task { await onSend(text) } - } + var body: some View { + switch conversationType { + case .dm: + ChatInputBar( + text: $composingText, + focusRequest: focusRequest, + placeholder: L10n.Chats.Chats.Input.Placeholder.directMessage, + maxBytes: ProtocolLimits.maxDirectMessageLength, + isEncrypted: true, + leading: { ChatShareMenu(onInsert: insertShared) } + ) { text in + onWillSend() + Task { await onSend(text) } + } - case .channel(let channel): - let maxBytes = ProtocolLimits.maxChannelMessageLength( - nodeNameByteCount: nodeNameByteCount - ) - ChatInputBar( - text: $composingText, - focusRequest: focusRequest, - placeholder: conversationType.isPublicStyleChannel - ? L10n.Chats.Chats.Channel.typePublic - : L10n.Chats.Chats.Channel.typePrivate, - maxBytes: maxBytes, - isEncrypted: channel.isEncryptedChannel, - leading: { ChatShareMenu(onInsert: insertShared) } - ) { text in - onWillSend() - Task { await onSend(text) } - } - } + case let .channel(channel): + let maxBytes = ProtocolLimits.maxChannelMessageLength( + nodeNameByteCount: nodeNameByteCount + ) + ChatInputBar( + text: $composingText, + focusRequest: focusRequest, + placeholder: conversationType.isPublicStyleChannel + ? L10n.Chats.Chats.Channel.typePublic + : L10n.Chats.Chats.Channel.typePrivate, + maxBytes: maxBytes, + isEncrypted: channel.isEncryptedChannel, + leading: { ChatShareMenu(onInsert: insertShared) } + ) { text in + onWillSend() + Task { await onSend(text) } + } } + } - /// Appends a shared token to the compose field and focuses it, matching the - /// reply and mention insertion flow. A single space separates the token from - /// existing text only when the field is non-empty and does not already end in - /// whitespace. - private func insertShared(_ shared: String) { - if !composingText.isEmpty, let last = composingText.last, !last.isWhitespace { - composingText.append(" ") - } - composingText.append(shared) - focusRequest += 1 + /// Appends a shared token to the compose field and focuses it, matching the + /// reply and mention insertion flow. A single space separates the token from + /// existing text only when the field is non-empty and does not already end in + /// whitespace. + private func insertShared(_ shared: String) { + if !composingText.isEmpty, let last = composingText.last, !last.isWhitespace { + composingText.append(" ") } + composingText.append(shared) + focusRequest += 1 + } } diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index 2fdc1232..32746b40 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -1,232 +1,232 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "ChatConversationMessagesContent") /// Unified inner content view for both DM and Channel conversations. /// Handles loading state, empty state, message table, bubble construction, and overlay buttons. struct ChatConversationMessagesContent: View { - // MARK: - Identity - - let conversationType: ChatConversationType - @Bindable var viewModel: ChatViewModel - let deviceName: String - let recentEmojisStore: RecentEmojisStore - - // MARK: - Display Preferences - - let envInputs: EnvInputs - - // MARK: - Scroll State Bindings - - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - @Binding var scrollToMentionRequest: Int - @Binding var scrollToDividerRequest: Int - @Binding var isDividerVisible: Bool - - // MARK: - Mention State - - let unseenMentionIDs: [UUID] - @Binding var offscreenMentionIDs: [UUID] - let scrollToTargetID: UUID? - let newMessagesDividerMessageID: UUID? - - // MARK: - Sheet State Bindings - - @Binding var selectedMessageForActions: MessageDTO? - @Binding var imageViewerData: ImageViewerData? - - // MARK: - Callbacks - - let onMentionSeen: (UUID) async -> Bool - let onScrollToMention: () -> Void - let onRetryMessage: (MessageDTO) -> Void - - // MARK: - Body - - var body: some View { - Group { - if viewModel.renderState.phase == .loaded, viewModel.messages.isEmpty { - emptyState - } else if viewModel.messages.isEmpty { - Color.clear - } else { - ChatMessagesTableView( - viewModel: viewModel, - contactName: conversationType.navigationTitle, - deviceName: deviceName, - configuration: bubbleConfiguration, - recentEmojisStore: recentEmojisStore, - envInputs: envInputs, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - scrollToDividerRequest: $scrollToDividerRequest, - isDividerVisible: $isDividerVisible, - selectedMessageForActions: $selectedMessageForActions, - imageViewerData: $imageViewerData, - unseenMentionIDs: unseenMentionIDs, - offscreenMentionIDs: $offscreenMentionIDs, - scrollToTargetID: scrollToTargetID, - newMessagesDividerMessageID: newMessagesDividerMessageID, - onMentionSeen: onMentionSeen, - onScrollToMention: onScrollToMention, - onRetryMessage: onRetryMessage - ) - } - } + // MARK: - Identity + + let conversationType: ChatConversationType + @Bindable var viewModel: ChatViewModel + let deviceName: String + let recentEmojisStore: RecentEmojisStore + + // MARK: - Display Preferences + + let envInputs: EnvInputs + + // MARK: - Scroll State Bindings + + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + @Binding var scrollToMentionRequest: Int + @Binding var scrollToDividerRequest: Int + @Binding var isDividerVisible: Bool + + // MARK: - Mention State + + let unseenMentionIDs: [UUID] + @Binding var offscreenMentionIDs: [UUID] + let scrollToTargetID: UUID? + let newMessagesDividerMessageID: UUID? + + // MARK: - Sheet State Bindings + + @Binding var selectedMessageForActions: MessageDTO? + @Binding var imageViewerData: ImageViewerData? + + // MARK: - Callbacks + + let onMentionSeen: (UUID) async -> Bool + let onScrollToMention: () -> Void + let onRetryMessage: (MessageDTO) -> Void + + // MARK: - Body + + var body: some View { + Group { + if viewModel.renderState.phase == .loaded, viewModel.messages.isEmpty { + emptyState + } else if viewModel.messages.isEmpty { + Color.clear + } else { + ChatMessagesTableView( + viewModel: viewModel, + contactName: conversationType.navigationTitle, + deviceName: deviceName, + configuration: bubbleConfiguration, + recentEmojisStore: recentEmojisStore, + envInputs: envInputs, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + scrollToDividerRequest: $scrollToDividerRequest, + isDividerVisible: $isDividerVisible, + selectedMessageForActions: $selectedMessageForActions, + imageViewerData: $imageViewerData, + unseenMentionIDs: unseenMentionIDs, + offscreenMentionIDs: $offscreenMentionIDs, + scrollToTargetID: scrollToTargetID, + newMessagesDividerMessageID: newMessagesDividerMessageID, + onMentionSeen: onMentionSeen, + onScrollToMention: onScrollToMention, + onRetryMessage: onRetryMessage + ) + } } - - // MARK: - Empty State - - @ViewBuilder - private var emptyState: some View { - switch conversationType { - case .dm(let contact): - DMEmptyMessagesView(contact: contact) - case .channel(let channel): - ChannelEmptyMessagesView( - channel: channel, - displayName: conversationType.navigationTitle, - isPublicStyle: conversationType.isPublicStyleChannel - ) - } + } + + // MARK: - Empty State + + @ViewBuilder + private var emptyState: some View { + switch conversationType { + case let .dm(contact): + DMEmptyMessagesView(contact: contact) + case let .channel(channel): + ChannelEmptyMessagesView( + channel: channel, + displayName: conversationType.navigationTitle, + isPublicStyle: conversationType.isPublicStyleChannel + ) } + } - // MARK: - Bubble Configuration + // MARK: - Bubble Configuration - private var bubbleConfiguration: MessageBubbleConfiguration { - switch conversationType { - case .dm: - .directMessage - case .channel: - .channel(isPublic: conversationType.isPublicStyleChannel) - } + private var bubbleConfiguration: MessageBubbleConfiguration { + switch conversationType { + case .dm: + .directMessage + case .channel: + .channel(isPublic: conversationType.isPublicStyleChannel) } + } } // MARK: - DM Empty Messages View private struct DMEmptyMessagesView: View { - let contact: ContactDTO - - var body: some View { - VStack(spacing: 16) { - ContactAvatar(contact: contact, size: 80) - - Text(contact.displayName) - .font(.title2) - .bold() - - Text(L10n.Chats.Chats.EmptyState.startConversation) - .foregroundStyle(.secondary) - - if contact.hasLocation { - Label(L10n.Chats.Chats.ContactInfo.hasLocation, systemImage: "location.fill") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + let contact: ContactDTO + + var body: some View { + VStack(spacing: 16) { + ContactAvatar(contact: contact, size: 80) + + Text(contact.displayName) + .font(.title2) + .bold() + + Text(L10n.Chats.Chats.EmptyState.startConversation) + .foregroundStyle(.secondary) + + if contact.hasLocation { + Label(L10n.Chats.Chats.ContactInfo.hasLocation, systemImage: "location.fill") + .font(.caption) + .foregroundStyle(.secondary) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } } // MARK: - Channel Empty Messages View private struct ChannelEmptyMessagesView: View { - let channel: ChannelDTO - let displayName: String - let isPublicStyle: Bool - - var body: some View { - VStack(spacing: 16) { - ChannelAvatar(channel: channel, size: 80) - - Text(displayName) - .font(.title2) - .bold() - - Text(L10n.Chats.Chats.Channel.EmptyState.noMessages) - .foregroundStyle(.secondary) - - Text(isPublicStyle - ? L10n.Chats.Chats.Channel.EmptyState.publicDescription - : L10n.Chats.Chats.Channel.EmptyState.privateDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + let channel: ChannelDTO + let displayName: String + let isPublicStyle: Bool + + var body: some View { + VStack(spacing: 16) { + ChannelAvatar(channel: channel, size: 80) + + Text(displayName) + .font(.title2) + .bold() + + Text(L10n.Chats.Chats.Channel.EmptyState.noMessages) + .foregroundStyle(.secondary) + + Text(isPublicStyle + ? L10n.Chats.Chats.Channel.EmptyState.publicDescription + : L10n.Chats.Chats.Channel.EmptyState.privateDescription) + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } } // MARK: - Previews #Preview("DM Conversation") { - NavigationStack { - ChatConversationMessagesContent( - conversationType: .dm(ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Alice" - ))), - viewModel: ChatViewModel(), - deviceName: "My Device", - recentEmojisStore: RecentEmojisStore(), - envInputs: .default, - isAtBottom: .constant(true), - unreadCount: .constant(0), - scrollToBottomRequest: .constant(0), - scrollToMentionRequest: .constant(0), - scrollToDividerRequest: .constant(0), - isDividerVisible: .constant(false), - unseenMentionIDs: [], - offscreenMentionIDs: .constant([]), - scrollToTargetID: nil, - newMessagesDividerMessageID: nil, - selectedMessageForActions: .constant(nil), - imageViewerData: .constant(nil), - onMentionSeen: { _ in true }, - onScrollToMention: {}, - onRetryMessage: { _ in } - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ChatConversationMessagesContent( + conversationType: .dm(ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Alice" + ))), + viewModel: ChatViewModel(), + deviceName: "My Device", + recentEmojisStore: RecentEmojisStore(), + envInputs: .default, + isAtBottom: .constant(true), + unreadCount: .constant(0), + scrollToBottomRequest: .constant(0), + scrollToMentionRequest: .constant(0), + scrollToDividerRequest: .constant(0), + isDividerVisible: .constant(false), + unseenMentionIDs: [], + offscreenMentionIDs: .constant([]), + scrollToTargetID: nil, + newMessagesDividerMessageID: nil, + selectedMessageForActions: .constant(nil), + imageViewerData: .constant(nil), + onMentionSeen: { _ in true }, + onScrollToMention: {}, + onRetryMessage: { _ in } + ) + } + .environment(\.appState, AppState()) } #Preview("Channel Conversation") { - NavigationStack { - ChatConversationMessagesContent( - conversationType: .channel(ChannelDTO(from: Channel( - radioID: UUID(), - index: 1, - name: "General" - ))), - viewModel: ChatViewModel(), - deviceName: "My Device", - recentEmojisStore: RecentEmojisStore(), - envInputs: .default, - isAtBottom: .constant(true), - unreadCount: .constant(0), - scrollToBottomRequest: .constant(0), - scrollToMentionRequest: .constant(0), - scrollToDividerRequest: .constant(0), - isDividerVisible: .constant(false), - unseenMentionIDs: [], - offscreenMentionIDs: .constant([]), - scrollToTargetID: nil, - newMessagesDividerMessageID: nil, - selectedMessageForActions: .constant(nil), - imageViewerData: .constant(nil), - onMentionSeen: { _ in true }, - onScrollToMention: {}, - onRetryMessage: { _ in } - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ChatConversationMessagesContent( + conversationType: .channel(ChannelDTO(from: Channel( + radioID: UUID(), + index: 1, + name: "General" + ))), + viewModel: ChatViewModel(), + deviceName: "My Device", + recentEmojisStore: RecentEmojisStore(), + envInputs: .default, + isAtBottom: .constant(true), + unreadCount: .constant(0), + scrollToBottomRequest: .constant(0), + scrollToMentionRequest: .constant(0), + scrollToDividerRequest: .constant(0), + isDividerVisible: .constant(false), + unseenMentionIDs: [], + offscreenMentionIDs: .constant([]), + scrollToTargetID: nil, + newMessagesDividerMessageID: nil, + selectedMessageForActions: .constant(nil), + imageViewerData: .constant(nil), + onMentionSeen: { _ in true }, + onScrollToMention: {}, + onRetryMessage: { _ in } + ) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/ChatConversationType.swift b/MC1/Views/Chats/ChatConversationType.swift index cdf43265..42a50cee 100644 --- a/MC1/Views/Chats/ChatConversationType.swift +++ b/MC1/Views/Chats/ChatConversationType.swift @@ -3,152 +3,152 @@ import MC1Services /// Conversation type discriminator for the unified chat view. /// Not `@MainActor` — no mutable state. `@State` on the view provides main-actor isolation. -enum ChatConversationType: Sendable { - case dm(ContactDTO) - case channel(ChannelDTO) - - // MARK: - Computed Properties - - var navigationTitle: String { - switch self { - case .dm(let contact): - contact.displayName - case .channel(let channel): - channel.displayName - } +enum ChatConversationType { + case dm(ContactDTO) + case channel(ChannelDTO) + + // MARK: - Computed Properties + + var navigationTitle: String { + switch self { + case let .dm(contact): + contact.displayName + case let .channel(channel): + channel.displayName } - - func navigationSubtitle(deviceDefaultFloodScopeName: String?) -> String { - switch self { - case .dm(let contact): - if contact.isFloodRouted { - return L10n.Chats.Chats.ConnectionStatus.floodRouting - } else { - return L10n.Chats.Chats.ConnectionStatus.direct(contact.pathHopCount) - } - case .channel(let channel): - let base = channelTypeSubtitle(for: channel) - if let region = effectiveRegionName(for: channel, deviceDefaultFloodScopeName: deviceDefaultFloodScopeName) { - let regionDisplay = (region == deviceDefaultFloodScopeName) - ? L10n.Chats.Chats.ChannelInfo.Region.scopedDefault(region) - : region - return "\(base) \u{00B7} \(regionDisplay)" - } - return base - } - } - - /// Accessibility label for the subtitle, providing a VoiceOver-friendly description - /// when a region scope is active (the middle dot separator may be read literally). - func navigationSubtitleAccessibilityLabel(deviceDefaultFloodScopeName: String?) -> String? { - switch self { - case .dm: - return nil - case .channel(let channel): - guard let region = effectiveRegionName(for: channel, deviceDefaultFloodScopeName: deviceDefaultFloodScopeName) else { - return nil - } - let typeSubtitle = channelTypeSubtitle(for: channel) - if region == deviceDefaultFloodScopeName { - return L10n.Chats.Chats.ChannelInfo.Region.defaultScopedAccessibility(typeSubtitle, region) - } - return L10n.Chats.Chats.ChannelInfo.Region.scopedAccessibility(typeSubtitle, region) - } - } - - // MARK: - Private Helpers - - private func channelTypeSubtitle(for channel: ChannelDTO) -> String { - if channel.isPublicChannel { - L10n.Chats.Chats.Channel.typePublic - } else if channel.name.hasPrefix("#") { - L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag - } else { - L10n.Chats.Chats.Channel.typePrivate - } + } + + func navigationSubtitle(deviceDefaultFloodScopeName: String?) -> String { + switch self { + case let .dm(contact): + if contact.isFloodRouted { + return L10n.Chats.Chats.ConnectionStatus.floodRouting + } else { + return L10n.Chats.Chats.ConnectionStatus.direct(contact.pathHopCount) + } + case let .channel(channel): + let base = channelTypeSubtitle(for: channel) + if let region = effectiveRegionName(for: channel, deviceDefaultFloodScopeName: deviceDefaultFloodScopeName) { + let regionDisplay = (region == deviceDefaultFloodScopeName) + ? L10n.Chats.Chats.ChannelInfo.Region.scopedDefault(region) + : region + return "\(base) \u{00B7} \(regionDisplay)" + } + return base } - - /// Resolves the region name to display alongside the channel subtitle. Delegates to - /// ``ChannelFloodScopeResolver`` so the banner stays in sync with the FloodScope - /// actually pushed to the radio. - private func effectiveRegionName( - for channel: ChannelDTO, - deviceDefaultFloodScopeName: String? - ) -> String? { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: channel.floodScope, - deviceDefaultFloodScopeName: deviceDefaultFloodScopeName, - supportsUnscopedFloodSend: false - ) - if case .scope(.region(let name)) = resolved { return name } + } + + /// Accessibility label for the subtitle, providing a VoiceOver-friendly description + /// when a region scope is active (the middle dot separator may be read literally). + func navigationSubtitleAccessibilityLabel(deviceDefaultFloodScopeName: String?) -> String? { + switch self { + case .dm: + return nil + case let .channel(channel): + guard let region = effectiveRegionName(for: channel, deviceDefaultFloodScopeName: deviceDefaultFloodScopeName) else { return nil + } + let typeSubtitle = channelTypeSubtitle(for: channel) + if region == deviceDefaultFloodScopeName { + return L10n.Chats.Chats.ChannelInfo.Region.defaultScopedAccessibility(typeSubtitle, region) + } + return L10n.Chats.Chats.ChannelInfo.Region.scopedAccessibility(typeSubtitle, region) } + } - var conversationID: UUID { - switch self { - case .dm(let contact): - contact.id - case .channel(let channel): - channel.id - } - } + // MARK: - Private Helpers - /// Stable key for the per-radio draft store. The channel case keys on the slot - /// `index`, not `conversationID` (a UUID), to align with the slot-based draft - /// cleanup on channel delete, sync prune, and backup-import relocation. - var draftConversationID: ChatConversationID { - switch self { - case .dm(let contact): - .dm(radioID: contact.radioID, contactID: contact.id) - case .channel(let channel): - .channel(radioID: channel.radioID, channelIndex: channel.index) - } + private func channelTypeSubtitle(for channel: ChannelDTO) -> String { + if channel.isPublicChannel { + L10n.Chats.Chats.Channel.typePublic + } else if channel.name.hasPrefix("#") { + L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag + } else { + L10n.Chats.Chats.Channel.typePrivate } - - var radioID: UUID { - switch self { - case .dm(let contact): - contact.radioID - case .channel(let channel): - channel.radioID - } + } + + /// Resolves the region name to display alongside the channel subtitle. Delegates to + /// ``ChannelFloodScopeResolver`` so the banner stays in sync with the FloodScope + /// actually pushed to the radio. + private func effectiveRegionName( + for channel: ChannelDTO, + deviceDefaultFloodScopeName: String? + ) -> String? { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: channel.floodScope, + deviceDefaultFloodScopeName: deviceDefaultFloodScopeName, + supportsUnscopedFloodSend: false + ) + if case let .scope(.region(name)) = resolved { return name } + return nil + } + + var conversationID: UUID { + switch self { + case let .dm(contact): + contact.id + case let .channel(channel): + channel.id } - - var isPublicStyleChannel: Bool { - switch self { - case .dm: - false - case .channel(let channel): - !channel.isEncryptedChannel - } + } + + /// Stable key for the per-radio draft store. The channel case keys on the slot + /// `index`, not `conversationID` (a UUID), to align with the slot-based draft + /// cleanup on channel delete, sync prune, and backup-import relocation. + var draftConversationID: ChatConversationID { + switch self { + case let .dm(contact): + .dm(radioID: contact.radioID, contactID: contact.id) + case let .channel(channel): + .channel(radioID: channel.radioID, channelIndex: channel.index) } - - /// Channels with this name (case-insensitive) suppress the inline map-preview - /// thumbnail, so the app doesn't flood the map API - private static let mapPreviewSuppressedChannelName = "wardriving" - - /// Whether map preview thumbnails should be hidden for this conversation, - /// independent of the global show-map-previews setting. DMs never suppress. - /// Matches case-insensitively and tolerates the leading "#" hashtag-channel - /// convention, so both "wardriving" and "#wardriving" suppress. - var suppressesMapPreviews: Bool { - guard case .channel(let channel) = self else { return false } - let trimmed = channel.name.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - return normalized.caseInsensitiveCompare(Self.mapPreviewSuppressedChannelName) == .orderedSame + } + + var radioID: UUID { + switch self { + case let .dm(contact): + contact.radioID + case let .channel(channel): + channel.radioID } - - // MARK: - Transforms - - /// Returns a copy with the contact replaced (DM only). Returns self unchanged for channels. - func replacingContact(_ contact: ContactDTO) -> ChatConversationType { - guard case .dm = self else { return self } - return .dm(contact) - } - - /// Returns a copy with the channel replaced (channel only). Returns self unchanged for DMs. - func replacingChannel(_ channel: ChannelDTO) -> ChatConversationType { - guard case .channel = self else { return self } - return .channel(channel) + } + + var isPublicStyleChannel: Bool { + switch self { + case .dm: + false + case let .channel(channel): + !channel.isEncryptedChannel } + } + + /// Channels with this name (case-insensitive) suppress the inline map-preview + /// thumbnail, so the app doesn't flood the map API + private static let mapPreviewSuppressedChannelName = "wardriving" + + /// Whether map preview thumbnails should be hidden for this conversation, + /// independent of the global show-map-previews setting. DMs never suppress. + /// Matches case-insensitively and tolerates the leading "#" hashtag-channel + /// convention, so both "wardriving" and "#wardriving" suppress. + var suppressesMapPreviews: Bool { + guard case let .channel(channel) = self else { return false } + let trimmed = channel.name.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + return normalized.caseInsensitiveCompare(Self.mapPreviewSuppressedChannelName) == .orderedSame + } + + // MARK: - Transforms + + /// Returns a copy with the contact replaced (DM only). Returns self unchanged for channels. + func replacingContact(_ contact: ContactDTO) -> ChatConversationType { + guard case .dm = self else { return self } + return .dm(contact) + } + + /// Returns a copy with the channel replaced (channel only). Returns self unchanged for DMs. + func replacingChannel(_ channel: ChannelDTO) -> ChatConversationType { + guard case .channel = self else { return self } + return .channel(channel) + } } diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index 03926d84..6e1944f5 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -1,7 +1,7 @@ -import SwiftUI -import UIKit // UIPasteboard for .copy action import MC1Services import OSLog +import SwiftUI +import UIKit // UIPasteboard for .copy action private let logger = Logger(subsystem: "com.mc1", category: "ChatConversationView") @@ -11,876 +11,868 @@ private let draftSaveDebounce: Duration = .milliseconds(500) /// Unified chat conversation view supporting both DMs and Channels. struct ChatConversationView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.linkPreviewCache) private var linkPreviewCache - @Environment(\.scenePhase) private var scenePhase - - @State private var conversationType: ChatConversationType - let parentViewModel: ChatViewModel? - - @State private var chatViewModel = ChatViewModel() - - // MARK: - Scroll State - - @State private var isAtBottom = true - @State private var unreadCount = 0 - @State private var scrollToBottomRequest = 0 - @State private var scrollToMentionRequest = 0 - @State private var unseenMentionIDs: [UUID] = [] - /// The subset of `unseenMentionIDs` currently off screen, reported up by the chat table. - /// Drives the scroll-to-mention button and is the source for its tap target. - @State private var offscreenMentionIDs: [UUID] = [] - @State private var scrollToTargetID: UUID? - @State private var mentionScrollTask: Task? - @State private var scrollToDividerRequest = 0 - @State private var isDividerVisible = false - - /// Pending debounced draft persist; cancelled and restarted on each keystroke, - /// cancelled-then-flushed synchronously on view teardown and app suspension. - @State private var draftSaveTask: Task? - - // MARK: - Sheet State - - @State private var showingInfo = false - @State private var selectedMessageForActions: MessageDTO? - @State private var blockSenderContext: BlockSenderContext? - @State private var sendDMContext: SendDMContext? - @State private var imageViewerData: ImageViewerData? - - // MARK: - Other State - - @State private var recentEmojisStore = RecentEmojisStore() - @State private var mentionSenderOrder: [String: UInt32]? - /// Focus-request token: each increment asks the composer to raise the - /// keyboard once. See `ChatComposerTextView` for why a token, not `@FocusState`. - @State private var inputFocusRequest = 0 - - // MARK: - AppStorage - - @AppStorage(AppStorageKey.showInlineImages.rawValue) private var showInlineImages = AppStorageKey.defaultShowInlineImages - @AppStorage(AppStorageKey.autoPlayGIFs.rawValue) private var autoPlayGIFs = AppStorageKey.defaultAutoPlayGIFs - @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = AppStorageKey.defaultShowIncomingPath - @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = AppStorageKey.defaultShowIncomingHopCount - @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = AppStorageKey.defaultShowIncomingRegion - @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = AppStorageKey.defaultShowIncomingSendTime - @AppStorage(AppStorageKey.linkPreviewsEnabled.rawValue) private var previewsEnabled = AppStorageKey.defaultLinkPreviewsEnabled - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - @AppStorage(AppStorageKey.showMapPreviewThumbnails.rawValue) private var showMapPreviewThumbnails = AppStorageKey.defaultShowMapPreviewThumbnails - - // MARK: - Environment - - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.appTheme) private var theme - - /// Snapshot of env-derived inputs the view model needs to construct - /// MessageItems at write time. Recomputed on every render — Equatable - /// drives `.onChange(of: envInputs)` in ChatMessagesTableView. - private var currentEnvInputs: EnvInputs { - EnvInputs( - showInlineImages: showInlineImages, - autoPlayGIFs: autoPlayGIFs, - showIncomingPath: showIncomingPath, - showIncomingHopCount: showIncomingHopCount, - showIncomingRegion: showIncomingRegion, - showIncomingSendTime: showIncomingSendTime, - previewsEnabled: previewsEnabled, - isHighContrast: colorSchemeContrast == .increased, - isDark: colorScheme == .dark, - showMapPreviews: showMapPreviewThumbnails && !conversationType.suppressesMapPreviews, - isOffline: !appState.offlineMapService.isNetworkAvailable, - currentUserName: appState.localNodeName, - themeID: theme.id, - contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) - ) - } - - // MARK: - Init - - init(conversationType: ChatConversationType, parentViewModel: ChatViewModel? = nil) { - self._conversationType = State(initialValue: conversationType) - self.parentViewModel = parentViewModel - } - - // MARK: - Body - - var body: some View { - ChatConversationMessagesContent( - conversationType: conversationType, - viewModel: chatViewModel, - deviceName: appState.localNodeName, - recentEmojisStore: recentEmojisStore, - envInputs: currentEnvInputs, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - scrollToDividerRequest: $scrollToDividerRequest, - isDividerVisible: $isDividerVisible, - unseenMentionIDs: unseenMentionIDs, - offscreenMentionIDs: $offscreenMentionIDs, - scrollToTargetID: scrollToTargetID, - newMessagesDividerMessageID: chatViewModel.newMessagesDividerMessageID, - selectedMessageForActions: $selectedMessageForActions, - imageViewerData: $imageViewerData, - onMentionSeen: { await markMentionSeen(messageID: $0) }, - onScrollToMention: { scrollToNextMention() }, - onRetryMessage: { retryMessage($0) } - ) - .mentionTapHandling( - contacts: chatViewModel.allContacts, - radioID: conversationType.radioID, - shouldSuppressOpen: { selectedMessageForActions != nil } - ) - // Banner is applied innermost so its safe-area inset stacks above the - // input bar inset that follows, placing the strip between content and - // the input bar (and lifting it with the keyboard). - .chatErrorBanner(chatViewModel: chatViewModel) - .safeAreaInset(edge: .bottom, spacing: 8) { - ChatConversationInputBar( - conversationType: conversationType, - composingText: $chatViewModel.composingText, - focusRequest: $inputFocusRequest, - nodeNameByteCount: appState.connectedDevice?.nodeName.utf8.count ?? 0, - onSend: { text in - switch conversationType { - case .dm: - await chatViewModel.sendMessage(text: text) - case .channel: - await chatViewModel.sendChannelMessage(text: text) - } - }, - onWillSend: { scrollToBottomRequest += 1 } - ) - } - .overlay(alignment: .bottom) { - ChatConversationMentionOverlay( - suggestions: mentionSuggestions, - onSelectMention: { insertMention(for: $0) } - ) - } - .navigationHeader( - title: conversationType.navigationTitle, - subtitle: conversationType.navigationSubtitle( - deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName - ), - subtitleAccessibilityLabel: conversationType.navigationSubtitleAccessibilityLabel( - deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName - ) - ) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(L10n.Chats.Chats.Common.info, systemImage: "info.circle") { - showingInfo = true - } - } - } - // Info sheet — type-specific - .sheet(isPresented: $showingInfo, onDismiss: { - switch conversationType { - case .dm: - Task { await refreshContact() } - case .channel: - Task { await refreshChannel() } - } - }, content: { - ChatConversationInfoSheet( - conversationType: conversationType, - chatViewModel: chatViewModel, - onClearChannelMessages: { - guard case .channel(let channel) = conversationType else { return } - await chatViewModel.loadChannelMessages(for: channel) - parentViewModel?.requestConversationReload() - }, - onClearDirectMessages: { - guard case .dm(let contact) = conversationType else { return } - await chatViewModel.loadMessages(for: contact) - parentViewModel?.requestConversationReload() - }, - onDeleteChannel: { - // Clear the composer so the teardown flush doesn't re-persist a draft for the - // freed slot — a channel later reusing that slot would otherwise surface it. - chatViewModel.composingText = "" - dismiss() - } - ) - }) - // Long-press / secondary-click actions sheet. Bound to a captured value, so - // messages arriving behind it never re-anchor it to a different bubble. - .sheet(item: $selectedMessageForActions) { message in - messageActionsSheet(for: message) - .environment(\.horizontalSizeClass, horizontalSizeClass) - } - // Block sender sheet — channel only - .sheet(item: $blockSenderContext) { context in - BlockSenderSheet( - senderName: context.senderName, - radioID: context.radioID - ) { blockedContactIDs in - Task { - await performBlock( - senderName: context.senderName, - radioID: context.radioID, - contactIDs: blockedContactIDs - ) - } - } - } - .sheet(item: $sendDMContext) { context in - SendDMSheet( - senderName: context.senderName, - radioID: context.radioID - ) { contact in - appState.navigation.navigateToChat(with: contact) - } - } - .fullScreenCover(item: $imageViewerData) { data in - FullScreenImageViewer(data: data) - } - .task(id: appState.servicesVersion) { - await performInitialLoad() - } - .onDisappear { - // Load-bearing on iPad: MainSidebarView pins the Chats detail stack with - // `.id(chatsSelectedRoute.conversationID)`, so a detail swap tears down this view's - // @State (including draftSaveTask) before the debounce fires — flush here. - flushDraft() - performCleanup() - } - .onChange(of: chatViewModel.composingText) { _, _ in - scheduleDraftSave() - } - .onChange(of: scenePhase) { _, newPhase in - // Notifications usually arrive while the app is backgrounded with this - // chat already on screen, so re-clear the tray when we return to the - // foreground — the open hook alone never re-fires in that case. - switch newPhase { - case .active: - Task { await clearDeliveredNotifications() } - case .background, .inactive: - // The OS can suspend the process before the debounce fires, so flush - // the draft synchronously here. - flushDraft() - @unknown default: - break - } - } - .onChange(of: activeMentionQuery != nil) { _, isActive in - if isActive { - mentionSenderOrder = chatViewModel.channelSenderOrder - } else { - mentionSenderOrder = nil - } - } - .task { - for await event in appState.messageEventStream.events() { - await chatViewModel.handle(event) - } - } - .onChange(of: chatViewModel.contactRefreshSignal) { _, _ in - Task { await refreshContact() } - } - .onChange(of: chatViewModel.lastIncomingMention) { _, newMention in - guard let mention = newMention else { return } - handleIncomingMentionIfNeeded(mention.messageID) - } - .onChange(of: appState.contactsVersion) { _, _ in - // Keep the mention-resolution snapshot fresh: a contact added after the - // chat opened must be tappable without reopening the screen. - Task { await chatViewModel.loadAllContacts(radioID: conversationType.radioID) } - } - .chatErrorAlerts(chatViewModel: chatViewModel) - // Chrome theming comes from the stack-level themedChrome on the TabView. Re-declaring it - // on this pushed destination makes the nav bar appearance re-install after the push, which - // reflows the flipped table's top rows. - .background { - if let canvas = theme.surfaces?.canvas { - canvas.ignoresSafeArea() - } - } - } - - // MARK: - Initial Load (.task) - - private func performInitialLoad() async { - // Cancel any in-flight mention paging from a previous servicesVersion - mentionScrollTask?.cancel() - mentionScrollTask = nil - - // Capture pending scroll target before loading - let pendingTarget = appState.navigation.pendingScrollToMessageID - if pendingTarget != nil { - appState.navigation.clearPendingScrollToMessage() - } - - chatViewModel.configure( - dependencies: ChatViewModel.Dependencies( - dataStore: { appState.offlineDataStore }, - messageService: { appState.services?.messageService }, - notificationService: { appState.services?.notificationService }, - channelService: { appState.services?.channelService }, - roomServerService: { appState.services?.roomServerService }, - contactService: { appState.services?.contactService }, - syncCoordinator: { appState.syncCoordinator }, - connectionState: { appState.connectionState }, - connectedDevice: { appState.connectedDevice }, - currentRadioID: { appState.currentRadioID }, - session: { appState.services?.session }, - reactionService: { appState.services?.reactionService }, - chatSendQueueService: { appState.services?.chatSendQueueService }, - inlineImageDimensionsStore: { appState.services?.inlineImageDimensionsStore }, - prefetchDataStore: { appState.services?.dataStore } - ), - onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, - linkPreviewCache: linkPreviewCache, - chatCoordinatorRegistry: appState.ensureChatCoordinatorRegistry(), - conversation: conversationType - ) - chatViewModel.applyEnvInputs(currentEnvInputs) - - switch conversationType { - case .dm(let contact): - await chatViewModel.loadMessages(for: contact) - await chatViewModel.loadConversations(radioID: contact.radioID) - await chatViewModel.loadAllContacts(radioID: contact.radioID) - chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) - - case .channel(let channel): - // Load contacts first so contactNameSet is populated before buildChannelSenders runs - await chatViewModel.loadAllContacts(radioID: channel.radioID) - await chatViewModel.loadChannelMessages(for: channel) - await chatViewModel.loadConversations(radioID: channel.radioID) - chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) - } - + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.linkPreviewCache) private var linkPreviewCache + @Environment(\.scenePhase) private var scenePhase + + @State private var conversationType: ChatConversationType + let parentViewModel: ChatViewModel? + + @State private var chatViewModel = ChatViewModel() + + // MARK: - Scroll State + + @State private var isAtBottom = true + @State private var unreadCount = 0 + @State private var scrollToBottomRequest = 0 + @State private var scrollToMentionRequest = 0 + @State private var unseenMentionIDs: [UUID] = [] + /// The subset of `unseenMentionIDs` currently off screen, reported up by the chat table. + /// Drives the scroll-to-mention button and is the source for its tap target. + @State private var offscreenMentionIDs: [UUID] = [] + @State private var scrollToTargetID: UUID? + @State private var mentionScrollTask: Task? + @State private var scrollToDividerRequest = 0 + @State private var isDividerVisible = false + + /// Pending debounced draft persist; cancelled and restarted on each keystroke, + /// cancelled-then-flushed synchronously on view teardown and app suspension. + @State private var draftSaveTask: Task? + + // MARK: - Sheet State + + @State private var showingInfo = false + @State private var selectedMessageForActions: MessageDTO? + @State private var blockSenderContext: BlockSenderContext? + @State private var sendDMContext: SendDMContext? + @State private var imageViewerData: ImageViewerData? + + // MARK: - Other State + + @State private var recentEmojisStore = RecentEmojisStore() + @State private var mentionSenderOrder: [String: UInt32]? + /// Focus-request token: each increment asks the composer to raise the + /// keyboard once. See `ChatComposerTextView` for why a token, not `@FocusState`. + @State private var inputFocusRequest = 0 + + // MARK: - AppStorage + + @AppStorage(AppStorageKey.autoPlayGIFs.rawValue) private var autoPlayGIFs = AppStorageKey.defaultAutoPlayGIFs + @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = AppStorageKey.defaultShowIncomingPath + @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = AppStorageKey.defaultShowIncomingHopCount + @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = AppStorageKey.defaultShowIncomingRegion + @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = AppStorageKey.defaultShowIncomingSendTime + @AppStorage(AppStorageKey.linkPreviewsEnabled.rawValue) private var previewsEnabled = AppStorageKey.defaultLinkPreviewsEnabled + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + @AppStorage(AppStorageKey.showMapPreviewThumbnails.rawValue) private var showMapPreviewThumbnails = AppStorageKey.defaultShowMapPreviewThumbnails + + // MARK: - Environment + + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.appTheme) private var theme + + /// Snapshot of env-derived inputs the view model needs to construct + /// MessageItems at write time. Recomputed on every render — Equatable + /// drives `.onChange(of: envInputs)` in ChatMessagesTableView. + private var currentEnvInputs: EnvInputs { + EnvInputs( + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + showIncomingRegion: showIncomingRegion, + showIncomingSendTime: showIncomingSendTime, + previewsEnabled: previewsEnabled, + isHighContrast: colorSchemeContrast == .increased, + isDark: colorScheme == .dark, + showMapPreviews: showMapPreviewThumbnails && !conversationType.suppressesMapPreviews, + isOffline: !appState.offlineMapService.isNetworkAvailable, + currentUserName: appState.localNodeName, + themeID: theme.id, + contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) + ) + } + + // MARK: - Init + + init(conversationType: ChatConversationType, parentViewModel: ChatViewModel? = nil) { + _conversationType = State(initialValue: conversationType) + self.parentViewModel = parentViewModel + } + + // MARK: - Body + + var body: some View { + ChatConversationMessagesContent( + conversationType: conversationType, + viewModel: chatViewModel, + deviceName: appState.localNodeName, + recentEmojisStore: recentEmojisStore, + envInputs: currentEnvInputs, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + scrollToDividerRequest: $scrollToDividerRequest, + isDividerVisible: $isDividerVisible, + unseenMentionIDs: unseenMentionIDs, + offscreenMentionIDs: $offscreenMentionIDs, + scrollToTargetID: scrollToTargetID, + newMessagesDividerMessageID: chatViewModel.newMessagesDividerMessageID, + selectedMessageForActions: $selectedMessageForActions, + imageViewerData: $imageViewerData, + onMentionSeen: { await markMentionSeen(messageID: $0) }, + onScrollToMention: { scrollToNextMention() }, + onRetryMessage: { retryMessage($0) } + ) + .mentionTapHandling( + contacts: chatViewModel.allContacts, + radioID: conversationType.radioID, + shouldSuppressOpen: { selectedMessageForActions != nil } + ) + // Banner is applied innermost so its safe-area inset stacks above the + // input bar inset that follows, placing the strip between content and + // the input bar (and lifting it with the keyboard). + .chatErrorBanner(chatViewModel: chatViewModel) + .safeAreaInset(edge: .bottom, spacing: 8) { + ChatConversationInputBar( + conversationType: conversationType, + composingText: $chatViewModel.composingText, + focusRequest: $inputFocusRequest, + nodeNameByteCount: appState.connectedDevice?.nodeName.utf8.count ?? 0, + onSend: { text in + switch conversationType { + case .dm: + await chatViewModel.sendMessage(text: text) + case .channel: + await chatViewModel.sendChannelMessage(text: text) + } + }, + onWillSend: { scrollToBottomRequest += 1 } + ) + } + .overlay(alignment: .bottom) { + ChatConversationMentionOverlay( + suggestions: mentionSuggestions, + onSelectMention: { insertMention(for: $0) } + ) + } + .navigationHeader( + title: conversationType.navigationTitle, + subtitle: conversationType.navigationSubtitle( + deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName + ), + subtitleAccessibilityLabel: conversationType.navigationSubtitleAccessibilityLabel( + deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName + ) + ) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.Chats.Chats.Common.info, systemImage: "info.circle") { + showingInfo = true + } + } + } + // Info sheet — type-specific + .sheet(isPresented: $showingInfo, onDismiss: { + switch conversationType { + case .dm: + Task { await refreshContact() } + case .channel: + Task { await refreshChannel() } + } + }, content: { + ChatConversationInfoSheet( + conversationType: conversationType, + chatViewModel: chatViewModel, + onClearChannelMessages: { + guard case let .channel(channel) = conversationType else { return } + await chatViewModel.loadChannelMessages(for: channel) + parentViewModel?.requestConversationReload() + }, + onClearDirectMessages: { + guard case let .dm(contact) = conversationType else { return } + await chatViewModel.loadMessages(for: contact) + parentViewModel?.requestConversationReload() + }, + onDeleteChannel: { + // Clear the composer so the teardown flush doesn't re-persist a draft for the + // freed slot — a channel later reusing that slot would otherwise surface it. + chatViewModel.composingText = "" + dismiss() + } + ) + }) + // Long-press / secondary-click actions sheet. Bound to a captured value, so + // messages arriving behind it never re-anchor it to a different bubble. + .sheet(item: $selectedMessageForActions) { message in + messageActionsSheet(for: message) + .environment(\.horizontalSizeClass, horizontalSizeClass) + } + // Block sender sheet — channel only + .sheet(item: $blockSenderContext) { context in + BlockSenderSheet( + senderName: context.senderName, + radioID: context.radioID + ) { blockedContactIDs in + Task { + await performBlock( + senderName: context.senderName, + radioID: context.radioID, + contactIDs: blockedContactIDs + ) + } + } + } + .sheet(item: $sendDMContext) { context in + SendDMSheet( + senderName: context.senderName, + radioID: context.radioID, + unverifiedNickname: context.unverifiedNickname + ) { contact in + appState.navigation.navigateToChat(with: contact) + } + } + .fullScreenCover(item: $imageViewerData) { data in + FullScreenImageViewer(data: data) + } + .task(id: appState.servicesVersion) { + await performInitialLoad() + } + .onDisappear { + // Load-bearing on iPad: MainSidebarView pins the Chats detail stack with + // `.id(chatsSelectedRoute.conversationID)`, so a detail swap tears down this view's + // @State (including draftSaveTask) before the debounce fires — flush here. + flushDraft() + performCleanup() + } + .onChange(of: chatViewModel.composingText) { _, _ in + scheduleDraftSave() + } + .onChange(of: scenePhase) { _, newPhase in + // Notifications usually arrive while the app is backgrounded with this + // chat already on screen, so re-clear the tray when we return to the + // foreground — the open hook alone never re-fires in that case. + switch newPhase { + case .active: + Task { await clearDeliveredNotifications() } + case .background, .inactive: + // The OS can suspend the process before the debounce fires, so flush + // the draft synchronously here. + flushDraft() + @unknown default: + break + } + } + .onChange(of: activeMentionQuery != nil) { _, isActive in + if isActive { + mentionSenderOrder = chatViewModel.channelSenderOrder + } else { + mentionSenderOrder = nil + } + } + .task { + for await event in appState.messageEventStream.events() { + await chatViewModel.handle(event) + } + } + .onChange(of: chatViewModel.contactRefreshSignal) { _, _ in + Task { await refreshContact() } + } + .onChange(of: chatViewModel.lastIncomingMention) { _, newMention in + guard let mention = newMention else { return } + handleIncomingMentionIfNeeded(mention.messageID) + } + .onChange(of: appState.contactsVersion) { _, _ in + // Keep the mention-resolution snapshot fresh: a contact added after the + // chat opened must be tappable without reopening the screen. + Task { await chatViewModel.loadAllContacts(radioID: conversationType.radioID) } + } + .chatErrorAlerts(chatViewModel: chatViewModel) + // Chrome theming comes from the stack-level themedChrome on the TabView. Re-declaring it + // on this pushed destination makes the nav bar appearance re-install after the push, which + // reflows the flipped table's top rows. + .background { + if let canvas = theme.surfaces?.canvas { + canvas.ignoresSafeArea() + } + } + } + + // MARK: - Initial Load (.task) + + private func performInitialLoad() async { + // Cancel any in-flight mention paging from a previous servicesVersion + mentionScrollTask?.cancel() + mentionScrollTask = nil + + // Capture pending scroll target before loading + let pendingTarget = appState.navigation.pendingScrollToMessageID + if pendingTarget != nil { + appState.navigation.clearPendingScrollToMessage() + } + + chatViewModel.configure( + dependencies: ChatViewModel.Dependencies( + dataStore: { appState.offlineDataStore }, + messageService: { appState.services?.messageService }, + notificationService: { appState.services?.notificationService }, + channelService: { appState.services?.channelService }, + roomServerService: { appState.services?.roomServerService }, + contactService: { appState.services?.contactService }, + syncCoordinator: { appState.syncCoordinator }, + connectionState: { appState.connectionState }, + connectedDevice: { appState.connectedDevice }, + currentRadioID: { appState.currentRadioID }, + session: { appState.services?.session }, + reactionService: { appState.services?.reactionService }, + chatSendQueueService: { appState.services?.chatSendQueueService }, + inlineImageDimensionsStore: { appState.services?.inlineImageDimensionsStore }, + prefetchDataStore: { appState.services?.dataStore } + ), + onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, + linkPreviewCache: linkPreviewCache, + chatCoordinatorRegistry: appState.ensureChatCoordinatorRegistry(), + conversation: conversationType + ) + chatViewModel.applyEnvInputs(currentEnvInputs) + + switch conversationType { + case let .dm(contact): + await chatViewModel.loadMessages(for: contact) + await chatViewModel.loadConversations(radioID: contact.radioID) + await chatViewModel.loadAllContacts(radioID: contact.radioID) + chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) + + case let .channel(channel): + // Load contacts first so contactNameSet is populated before buildChannelSenders runs + await chatViewModel.loadAllContacts(radioID: channel.radioID) + await chatViewModel.loadChannelMessages(for: channel) + await chatViewModel.loadConversations(radioID: channel.radioID) + chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) + } + + await loadUnseenMentions() + + // Trigger scroll to target message if pending (notification deeplink) + if let targetID = pendingTarget { + scrollToTargetID = targetID + scrollToMentionRequest += 1 + } + + // Clear any notifications for this conversation still sitting in the tray + // (delivered while the app was backgrounded). The load above already + // cleared the unread count, so the recomputed badge stays correct. + await clearDeliveredNotifications() + } + + /// Removes delivered lock-screen / Notification Center notifications for the + /// conversation the user just opened, then recomputes the app badge. + private func clearDeliveredNotifications() async { + guard let notificationService = appState.services?.notificationService else { return } + switch conversationType { + case let .dm(contact): + await notificationService.removeDeliveredNotifications(forContactID: contact.id) + case let .channel(channel): + await notificationService.removeDeliveredNotifications( + forChannelIndex: channel.index, + radioID: channel.radioID + ) + } + await notificationService.updateBadgeCount() + } + + // MARK: - Draft Persistence + + /// Restarts the debounced draft persist. The post-sleep cancellation guard is + /// required: `Task.cancel()` only sets a flag and `try?` swallows + /// `Task.sleep`'s `CancellationError`, so without it a synchronous flush that + /// already saved could be overwritten by the resuming task re-saving stale text. + private func scheduleDraftSave() { + draftSaveTask?.cancel() + let id = conversationType.draftConversationID + draftSaveTask = Task { + try? await Task.sleep(for: draftSaveDebounce) + guard !Task.isCancelled else { return } + chatViewModel.saveDraft(to: appState.draftStore, id: id) + } + } + + /// Cancels any pending debounce and persists the current draft synchronously, reading the + /// store from the view's non-optional `@Environment(\.appState)` (matching `performCleanup`). + private func flushDraft() { + draftSaveTask?.cancel() + draftSaveTask = nil + chatViewModel.saveDraft(to: appState.draftStore, id: conversationType.draftConversationID) + } + + // MARK: - Cleanup (.onDisappear) + + private func performCleanup() { + mentionScrollTask?.cancel() + mentionScrollTask = nil + + // Clear notification suppression only if this conversation still owns the + // active slot; a newer conversation's open may have already claimed it + // before this view tears down. + let service = appState.services?.notificationService + switch conversationType { + case let .dm(contact): + if service?.activeContactID == contact.id { + service?.activeContactID = nil + } + case let .channel(channel): + if service?.activeChannelIndex == channel.index, + service?.activeChannelRadioID == channel.radioID { + service?.activeChannelIndex = nil + service?.activeChannelRadioID = nil + } + } + + // Refresh parent conversation list when leaving + parentViewModel?.requestConversationReload() + } + + private func handleIncomingMentionIfNeeded(_ messageID: UUID) { + // Self-mention gating happens upstream in + // `ChatViewModel.recordIncomingMentionIfNeeded`, which only assigns + // `lastIncomingMention` when `containsSelfMention` is true. + Task { + if isAtBottom { + await markNewArrivalMentionSeen(messageID: messageID) + } else { await loadUnseenMentions() - - // Trigger scroll to target message if pending (notification deeplink) - if let targetID = pendingTarget { - scrollToTargetID = targetID - scrollToMentionRequest += 1 - } - - // Clear any notifications for this conversation still sitting in the tray - // (delivered while the app was backgrounded). The load above already - // cleared the unread count, so the recomputed badge stays correct. - await clearDeliveredNotifications() - } - - /// Removes delivered lock-screen / Notification Center notifications for the - /// conversation the user just opened, then recomputes the app badge. - private func clearDeliveredNotifications() async { - guard let notificationService = appState.services?.notificationService else { return } - switch conversationType { - case .dm(let contact): - await notificationService.removeDeliveredNotifications(forContactID: contact.id) - case .channel(let channel): - await notificationService.removeDeliveredNotifications( - forChannelIndex: channel.index, - radioID: channel.radioID - ) - } - await notificationService.updateBadgeCount() - } - - // MARK: - Draft Persistence - - /// Restarts the debounced draft persist. The post-sleep cancellation guard is - /// required: `Task.cancel()` only sets a flag and `try?` swallows - /// `Task.sleep`'s `CancellationError`, so without it a synchronous flush that - /// already saved could be overwritten by the resuming task re-saving stale text. - private func scheduleDraftSave() { - draftSaveTask?.cancel() - let id = conversationType.draftConversationID - draftSaveTask = Task { - try? await Task.sleep(for: draftSaveDebounce) - guard !Task.isCancelled else { return } - chatViewModel.saveDraft(to: appState.draftStore, id: id) - } - } - - /// Cancels any pending debounce and persists the current draft synchronously, reading the - /// store from the view's non-optional `@Environment(\.appState)` (matching `performCleanup`). - private func flushDraft() { - draftSaveTask?.cancel() - draftSaveTask = nil - chatViewModel.saveDraft(to: appState.draftStore, id: conversationType.draftConversationID) + } } + } - // MARK: - Cleanup (.onDisappear) - - private func performCleanup() { - mentionScrollTask?.cancel() - mentionScrollTask = nil - - // Clear notification suppression only if this conversation still owns the - // active slot; a newer conversation's open may have already claimed it - // before this view tears down. - let service = appState.services?.notificationService - switch conversationType { - case .dm(let contact): - if service?.activeContactID == contact.id { - service?.activeContactID = nil - } - case .channel(let channel): - if service?.activeChannelIndex == channel.index, - service?.activeChannelRadioID == channel.radioID { - service?.activeChannelIndex = nil - service?.activeChannelRadioID = nil - } - } - - // Refresh parent conversation list when leaving - parentViewModel?.requestConversationReload() - } + // MARK: - Conversation Refresh - private func handleIncomingMentionIfNeeded(_ messageID: UUID) { - // Self-mention gating happens upstream in - // `ChatViewModel.recordIncomingMentionIfNeeded`, which only assigns - // `lastIncomingMention` when `containsSelfMention` is true. - Task { - if isAtBottom { - await markNewArrivalMentionSeen(messageID: messageID) - } else { - await loadUnseenMentions() - } - } + private func refreshContact() async { + guard case let .dm(contact) = conversationType else { return } + if let updated = try? await appState.services?.dataStore.fetchContact(id: contact.id) { + conversationType = conversationType.replacingContact(updated) + chatViewModel.currentContact = updated } + } - // MARK: - Conversation Refresh - - private func refreshContact() async { - guard case .dm(let contact) = conversationType else { return } - if let updated = try? await appState.services?.dataStore.fetchContact(id: contact.id) { - conversationType = conversationType.replacingContact(updated) - chatViewModel.currentContact = updated - } + private func refreshChannel() async { + guard case let .channel(channel) = conversationType else { return } + if let updated = try? await appState.offlineDataStore?.fetchChannel(id: channel.id) { + conversationType = conversationType.replacingChannel(updated) } + } - private func refreshChannel() async { - guard case .channel(let channel) = conversationType else { return } - if let updated = try? await appState.offlineDataStore?.fetchChannel(id: channel.id) { - conversationType = conversationType.replacingChannel(updated) - } - } + // MARK: - Mention Tracking - // MARK: - Mention Tracking + private func loadUnseenMentions() async { + switch conversationType { + case let .dm(contact): + guard let dataStore = appState.services?.dataStore else { return } + do { + unseenMentionIDs = try await dataStore.fetchUnseenMentionIDs(contactID: contact.id) + } catch { + logger.error("Failed to load unseen mentions: \(error)") + } - private func loadUnseenMentions() async { - switch conversationType { - case .dm(let contact): - guard let dataStore = appState.services?.dataStore else { return } - do { - unseenMentionIDs = try await dataStore.fetchUnseenMentionIDs(contactID: contact.id) - } catch { - logger.error("Failed to load unseen mentions: \(error)") - } + case let .channel(channel): + guard let services = appState.services else { return } + do { + let allIDs = try await services.dataStore.fetchUnseenChannelMentionIDs( + radioID: channel.radioID, + channelIndex: channel.index + ) - case .channel(let channel): - guard let services = appState.services else { return } - do { - let allIDs = try await services.dataStore.fetchUnseenChannelMentionIDs( - radioID: channel.radioID, - channelIndex: channel.index - ) - - let blockedNames = await services.syncCoordinator.blockedSenderNames() - if blockedNames.isEmpty { - unseenMentionIDs = allIDs - return - } - - var filteredIDs: [UUID] = [] - for id in allIDs { - do { - if let message = try await services.dataStore.fetchMessage(id: id), - let senderName = message.senderNodeName, - blockedNames.contains(senderName) { - try await services.dataStore.markMentionSeen(messageID: id) - continue - } - } catch { - logger.error("Failed to check/filter mention \(id): \(error)") - } - filteredIDs.append(id) - } - unseenMentionIDs = filteredIDs - } catch { - logger.error("Failed to load unseen channel mentions: \(error)") - } + let blockedNames = await services.syncCoordinator.blockedSenderNames() + if blockedNames.isEmpty { + unseenMentionIDs = allIDs + return } - } - /// Marks a mention seen and reports whether the result is settled. Returns true when the - /// mention was already seen or the persist succeeded; false only when the persist failed, - /// so the caller can re-attempt rather than treat the id as handled. - @discardableResult - private func markMentionSeen(messageID: UUID) async -> Bool { - guard unseenMentionIDs.contains(messageID) else { return true } - guard await persistMentionSeen(messageID: messageID) else { return false } - unseenMentionIDs.removeAll { $0 == messageID } - return true - } - - private func markNewArrivalMentionSeen(messageID: UUID) async { - _ = await persistMentionSeen(messageID: messageID) - } - - private func persistMentionSeen(messageID: UUID) async -> Bool { - guard let dataStore = appState.services?.dataStore else { return false } - do { - try await dataStore.markMentionSeen(messageID: messageID) - switch conversationType { - case .dm(let contact): - try await dataStore.decrementUnreadMentionCount(contactID: contact.id) - parentViewModel?.requestConversationReload() - case .channel(let channel): - try await dataStore.decrementChannelUnreadMentionCount(channelID: channel.id) - parentViewModel?.requestConversationReload() + var filteredIDs: [UUID] = [] + for id in allIDs { + do { + if let message = try await services.dataStore.fetchMessage(id: id), + let senderName = message.senderNodeName, + blockedNames.contains(senderName) { + try await services.dataStore.markMentionSeen(messageID: id) + continue } - return true - } catch { - logger.error("Failed to mark mention seen: \(error)") - return false - } - } - - // MARK: - Mention Navigation - - private func scrollToNextMention() { - // Target the newest off-screen mention so the first tap lands on the latest unread the - // user hasn't reached; repeated taps walk upward through older mentions, showing the - // earliest last. An on-screen mention is already in view and would make the tap a no-op, - // so the off-screen subset is the right source. - guard let targetID = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: offscreenMentionIDs) else { return } - - if chatViewModel.items.contains(where: { $0.id == targetID }) { - issueMentionScroll(to: targetID) - return - } - - mentionScrollTask?.cancel() - mentionScrollTask = Task { - do { - let deadline = ContinuousClock.now + .seconds(10) - // Page on the authoritative coordinator-backed messages, not the lagging - // rendered items. loadOlderMessages mutates messages synchronously but only - // schedules the off-main items rebuild, so gating on items overshoots a page - // per spin and can exhaust history before a render lands, tripping the - // destructive not-found branch that hides a real unread mention. - while !chatViewModel.messages.contains(where: { $0.id == targetID }) { - guard chatViewModel.hasMoreMessages else { - logger.warning("Mention \(targetID) not found after exhausting history, removing") - if let dataStore = appState.services?.dataStore { - try? await dataStore.markMentionSeen(messageID: targetID) - } - unseenMentionIDs.removeAll { $0 == targetID } - break - } - // offscreenMentionIDs is an async mirror; a target marked seen mid-paging is - // already gone from unseenMentionIDs, so stop rather than page for a read mention. - guard unseenMentionIDs.contains(targetID) else { break } - guard ContinuousClock.now < deadline else { - logger.warning("Mention \(targetID) paging timed out") - break - } - if chatViewModel.isLoadingOlder { - try await Task.sleep(for: .milliseconds(50)) - continue - } - await chatViewModel.loadOlderMessages() - try Task.checkCancellation() - } - - // Target is in the model; wait (bounded) for the rebuild to surface it in the - // rendered items, which scrollToItem addresses by row, before scrolling. - while chatViewModel.messages.contains(where: { $0.id == targetID }), - !chatViewModel.items.contains(where: { $0.id == targetID }), - ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(50)) - try Task.checkCancellation() - } - - if chatViewModel.items.contains(where: { $0.id == targetID }) { - issueMentionScroll(to: targetID) - } - } catch is CancellationError { - // Expected when view disappears during paging - } catch { - logger.error("Failed to scroll to mention: \(error)") + } catch { + logger.error("Failed to check/filter mention \(id): \(error)") + } + filteredIDs.append(id) + } + unseenMentionIDs = filteredIDs + } catch { + logger.error("Failed to load unseen channel mentions: \(error)") + } + } + } + + /// Marks a mention seen and reports whether the result is settled. Returns true when the + /// mention was already seen or the persist succeeded; false only when the persist failed, + /// so the caller can re-attempt rather than treat the id as handled. + @discardableResult + private func markMentionSeen(messageID: UUID) async -> Bool { + guard unseenMentionIDs.contains(messageID) else { return true } + guard await persistMentionSeen(messageID: messageID) else { return false } + unseenMentionIDs.removeAll { $0 == messageID } + return true + } + + private func markNewArrivalMentionSeen(messageID: UUID) async { + _ = await persistMentionSeen(messageID: messageID) + } + + private func persistMentionSeen(messageID: UUID) async -> Bool { + guard let dataStore = appState.services?.dataStore else { return false } + do { + try await dataStore.markMentionSeen(messageID: messageID) + switch conversationType { + case let .dm(contact): + try await dataStore.decrementUnreadMentionCount(contactID: contact.id) + parentViewModel?.requestConversationReload() + case let .channel(channel): + try await dataStore.decrementChannelUnreadMentionCount(channelID: channel.id) + parentViewModel?.requestConversationReload() + } + return true + } catch { + logger.error("Failed to mark mention seen: \(error)") + return false + } + } + + // MARK: - Mention Navigation + + private func scrollToNextMention() { + // Target the newest off-screen mention so the first tap lands on the latest unread the + // user hasn't reached; repeated taps walk upward through older mentions, showing the + // earliest last. An on-screen mention is already in view and would make the tap a no-op, + // so the off-screen subset is the right source. + guard let targetID = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: offscreenMentionIDs) else { return } + + if chatViewModel.items.contains(where: { $0.id == targetID }) { + issueMentionScroll(to: targetID) + return + } + + mentionScrollTask?.cancel() + mentionScrollTask = Task { + do { + let deadline = ContinuousClock.now + .seconds(10) + // Page on the authoritative coordinator-backed messages, not the lagging + // rendered items. loadOlderMessages mutates messages synchronously but only + // schedules the off-main items rebuild, so gating on items overshoots a page + // per spin and can exhaust history before a render lands, tripping the + // destructive not-found branch that hides a real unread mention. + while !chatViewModel.messages.contains(where: { $0.id == targetID }) { + guard chatViewModel.hasMoreMessages else { + logger.warning("Mention \(targetID) not found after exhausting history, removing") + if let dataStore = appState.services?.dataStore { + try? await dataStore.markMentionSeen(messageID: targetID) } + unseenMentionIDs.removeAll { $0 == targetID } + break + } + // offscreenMentionIDs is an async mirror; a target marked seen mid-paging is + // already gone from unseenMentionIDs, so stop rather than page for a read mention. + guard unseenMentionIDs.contains(targetID) else { break } + guard ContinuousClock.now < deadline else { + logger.warning("Mention \(targetID) paging timed out") + break + } + if chatViewModel.isLoadingOlder { + try await Task.sleep(for: .milliseconds(50)) + continue + } + await chatViewModel.loadOlderMessages() + try Task.checkCancellation() + } + + // Target is in the model; wait (bounded) for the rebuild to surface it in the + // rendered items, which scrollToItem addresses by row, before scrolling. + while chatViewModel.messages.contains(where: { $0.id == targetID }), + !chatViewModel.items.contains(where: { $0.id == targetID }), + ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(50)) + try Task.checkCancellation() } - } - - /// Scrolls to a mention and advances the queue. The seen-transition is driven directly - /// rather than waiting for the row's visibility tick, which never fires when the target - /// is already centered (scrollToRow does not move contentOffset), leaving the tap a no-op. - private func issueMentionScroll(to targetID: UUID) { - scrollToTargetID = targetID - scrollToMentionRequest += 1 - Task { await markMentionSeen(messageID: targetID) } - } - - // MARK: - Mention Suggestions - - private var activeMentionQuery: String? { - MentionUtilities.detectActiveMention(in: chatViewModel.composingText) - } - - private var mentionSuggestions: [ContactDTO] { - guard let query = activeMentionQuery else { return [] } - switch conversationType { - case .dm: - return MentionUtilities.filterContacts(chatViewModel.allContacts, query: query) - case .channel: - let combined = chatViewModel.allContacts + chatViewModel.channelSenders - let order = mentionSenderOrder ?? chatViewModel.channelSenderOrder - return MentionUtilities.filterContacts(combined, query: query, senderOrder: order) - } - } - - private func insertMention(for contact: ContactDTO) { - guard let query = MentionUtilities.detectActiveMention(in: chatViewModel.composingText) else { return } - - let searchPattern = "@" + query - if let range = chatViewModel.composingText.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: contact.name) - chatViewModel.composingText.replaceSubrange(range, with: mention + " ") - } - } - // MARK: - Message Actions Sheet - - /// Builds the drift-proof message actions sheet for a captured message value. - /// Presented via `.sheet(item:)`, which binds to the value rather than a cell, - /// so incoming messages reorder the table behind the modal without re-anchoring - /// it to a different bubble. - private func messageActionsSheet(for message: MessageDTO) -> MessageActionsSheet { - let resolution = senderResolution(for: message) - return MessageActionsSheet( - message: message, - senderName: resolution.displayName, - senderMatchKind: resolution.matchKind, - recentEmojis: recentEmojisStore.recentEmojis, - onAction: { action in - dispatch(action, for: message) - } + if chatViewModel.items.contains(where: { $0.id == targetID }) { + issueMentionScroll(to: targetID) + } + } catch is CancellationError { + // Expected when view disappears during paging + } catch { + logger.error("Failed to scroll to mention: \(error)") + } + } + } + + /// Scrolls to a mention and advances the queue. The seen-transition is driven directly + /// rather than waiting for the row's visibility tick, which never fires when the target + /// is already centered (scrollToRow does not move contentOffset), leaving the tap a no-op. + private func issueMentionScroll(to targetID: UUID) { + scrollToTargetID = targetID + scrollToMentionRequest += 1 + Task { await markMentionSeen(messageID: targetID) } + } + + // MARK: - Mention Suggestions + + private var activeMentionQuery: String? { + MentionUtilities.detectActiveMention(in: chatViewModel.composingText) + } + + private var mentionSuggestions: [ContactDTO] { + guard let query = activeMentionQuery else { return [] } + switch conversationType { + case .dm: + return MentionUtilities.filterContacts(chatViewModel.allContacts, query: query) + case .channel: + let combined = chatViewModel.allContacts + chatViewModel.channelSenders + let order = mentionSenderOrder ?? chatViewModel.channelSenderOrder + return MentionUtilities.filterContacts(combined, query: query, senderOrder: order) + } + } + + private func insertMention(for contact: ContactDTO) { + guard let query = MentionUtilities.detectActiveMention(in: chatViewModel.composingText) else { return } + + let searchPattern = "@" + query + if let range = chatViewModel.composingText.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: contact.name) + chatViewModel.composingText.replaceSubrange(range, with: mention + " ") + } + } + + // MARK: - Message Actions Sheet + + /// Builds the drift-proof message actions sheet for a captured message value. + /// Presented via `.sheet(item:)`, which binds to the value rather than a cell, + /// so incoming messages reorder the table behind the modal without re-anchoring + /// it to a different bubble. + private func messageActionsSheet(for message: MessageDTO) -> MessageActionsSheet { + let resolution = senderResolution(for: message) + return MessageActionsSheet( + message: message, + senderResolution: resolution, + recentEmojis: recentEmojisStore.recentEmojis, + onAction: { action in + dispatch(action, for: message) + } + ) + } + + private func senderResolution(for message: MessageDTO) -> NodeNameResolution { + if message.isOutgoing { + return NodeNameResolution(displayName: appState.localNodeName, matchKind: .exact) + } + switch conversationType { + case let .dm(contact): + return NodeNameResolution(displayName: contact.displayName, matchKind: .exact) + case .channel: + return MessageBubbleConfiguration.resolveSenderName( + for: message, + contacts: chatViewModel.allContacts, + nicknamesByLoweredName: chatViewModel.nicknamesByLoweredName + ) + } + } + + // MARK: - Message Action Handling + + /// Dispatches a MessageAction by routing to the appropriate handler. The + /// `switch action` body preserves compile-time exhaustiveness — adding a new + /// MessageAction case forces this method to handle it. Each case calls an + /// extracted private method that captures the view-local context it needs + /// (focus state, AppStorage flags, sheet-presentation contexts). + private func dispatch(_ action: MessageAction, for message: MessageDTO) { + switch action { + case let .react(emoji): + handleReact(emoji: emoji, for: message) + case .reply: + handleReply(for: message) + case .copy: + handleCopy(for: message) + case .sendAgain: + handleSendAgain(for: message) + case .blockSender: + handleBlockSender(for: message) + case .sendDM: + handleSendDM(for: message) + case .delete: + handleDelete(for: message) + } + } + + private func handleReact(emoji: String, for message: MessageDTO) { + recentEmojisStore.recordUsage(emoji) + Task { await chatViewModel.sendReaction(emoji: emoji, to: message) } + } + + private func handleReply(for message: MessageDTO) { + let mentionName: String = switch conversationType { + case let .dm(contact): + contact.name + case .channel: + message.senderNodeName ?? L10n.Chats.Chats.Message.Sender.unknown + } + if replyWithQuote { + chatViewModel.composingText = MentionUtilities.buildReplyText(mentionName: mentionName, messageText: message.text) + } else { + chatViewModel.composingText = MentionUtilities.appendMention( + for: mentionName, + to: chatViewModel.composingText + ) + } + // Raise the keyboard only after the actions sheet has finished dismissing; + // a focus request issued while the sheet is still animating away is lost. + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + inputFocusRequest += 1 + } + } + + private func handleCopy(for message: MessageDTO) { + UIPasteboard.general.string = message.text + } + + private func handleSendAgain(for message: MessageDTO) { + Task { await chatViewModel.sendAgain(message) } + } + + private func handleBlockSender(for message: MessageDTO) { + guard case let .channel(channel) = conversationType, + let name = message.senderNodeName else { return } + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + blockSenderContext = BlockSenderContext(senderName: name, radioID: channel.radioID) + } + } + + private func handleSendDM(for message: MessageDTO) { + guard case let .channel(channel) = conversationType, + let name = message.senderNodeName else { return } + let nickname = chatViewModel.nicknamesByLoweredName[name.lowercased()] + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + sendDMContext = SendDMContext(senderName: name, radioID: channel.radioID, unverifiedNickname: nickname) + } + } + + private func handleDelete(for message: MessageDTO) { + Task { await chatViewModel.deleteMessage(message) } + } + + private func retryMessage(_ message: MessageDTO) { + Task { + switch conversationType { + case .dm: + await chatViewModel.retryMessage(message) + case .channel: + await chatViewModel.retryChannelMessage(message) + } + } + } + + // MARK: - Blocking (Channel only) + + private func performBlock(senderName: String, radioID: UUID, contactIDs: Set) async { + guard let services = appState.services else { return } + + let dto = BlockedChannelSenderDTO(name: senderName, radioID: radioID) + do { + try await services.dataStore.saveBlockedChannelSender(dto) + } catch { + logger.error("Failed to save blocked channel sender: \(error)") + return + } + + // Delete existing channel messages from the blocked sender + try? await services.dataStore.deleteChannelMessages(fromSender: senderName, radioID: radioID) + + for contactID in contactIDs { + do { + try await services.contactService.updateContactPreferences( + contactID: contactID, + isBlocked: true ) + } catch { + logger.error("Failed to block contact \(contactID): \(error)") + } } - private func senderResolution(for message: MessageDTO) -> NodeNameResolution { - if message.isOutgoing { - return NodeNameResolution(displayName: appState.localNodeName, matchKind: .exact) - } - switch conversationType { - case .dm(let contact): - return NodeNameResolution(displayName: contact.displayName, matchKind: .exact) - case .channel: - if let name = message.senderNodeName, !name.isEmpty { - return NodeNameResolution(displayName: name, matchKind: .exact) - } - if let prefix = message.senderKeyPrefix, - let result = NeighborNameResolver.resolve( - for: prefix, contacts: chatViewModel.allContacts, discoveredNodes: [], userLocation: nil) { - return result - } - return NodeNameResolution( - displayName: L10n.Chats.Chats.Message.Sender.unknown, matchKind: .unresolved) - } - } - - // MARK: - Message Action Handling - - /// Dispatches a MessageAction by routing to the appropriate handler. The - /// `switch action` body preserves compile-time exhaustiveness — adding a new - /// MessageAction case forces this method to handle it. Each case calls an - /// extracted private method that captures the view-local context it needs - /// (focus state, AppStorage flags, sheet-presentation contexts). - private func dispatch(_ action: MessageAction, for message: MessageDTO) { - switch action { - case .react(let emoji): - handleReact(emoji: emoji, for: message) - case .reply: - handleReply(for: message) - case .copy: - handleCopy(for: message) - case .sendAgain: - handleSendAgain(for: message) - case .blockSender: - handleBlockSender(for: message) - case .sendDM: - handleSendDM(for: message) - case .delete: - handleDelete(for: message) - } - } - - private func handleReact(emoji: String, for message: MessageDTO) { - recentEmojisStore.recordUsage(emoji) - Task { await chatViewModel.sendReaction(emoji: emoji, to: message) } - } - - private func handleReply(for message: MessageDTO) { - let mentionName: String - switch conversationType { - case .dm(let contact): - mentionName = contact.name - case .channel: - mentionName = message.senderNodeName ?? L10n.Chats.Chats.Message.Sender.unknown - } - if replyWithQuote { - chatViewModel.composingText = MentionUtilities.buildReplyText(mentionName: mentionName, messageText: message.text) - } else { - chatViewModel.composingText = MentionUtilities.appendMention( - for: mentionName, - to: chatViewModel.composingText - ) - } - // Raise the keyboard only after the actions sheet has finished dismissing; - // a focus request issued while the sheet is still animating away is lost. - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - inputFocusRequest += 1 - } - } - - private func handleCopy(for message: MessageDTO) { - UIPasteboard.general.string = message.text - } - - private func handleSendAgain(for message: MessageDTO) { - Task { await chatViewModel.sendAgain(message) } - } - - private func handleBlockSender(for message: MessageDTO) { - guard case .channel(let channel) = conversationType, - let name = message.senderNodeName else { return } - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - blockSenderContext = BlockSenderContext(senderName: name, radioID: channel.radioID) - } - } - - private func handleSendDM(for message: MessageDTO) { - guard case .channel(let channel) = conversationType, - let name = message.senderNodeName else { return } - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - sendDMContext = SendDMContext(senderName: name, radioID: channel.radioID) - } - } + await services.syncCoordinator.refreshBlockedContactsCache( + radioID: radioID, + dataStore: services.dataStore + ) - private func handleDelete(for message: MessageDTO) { - Task { await chatViewModel.deleteMessage(message) } - } - - private func retryMessage(_ message: MessageDTO) { - Task { - switch conversationType { - case .dm: - await chatViewModel.retryMessage(message) - case .channel: - await chatViewModel.retryChannelMessage(message) - } - } + if !contactIDs.isEmpty { + services.syncCoordinator.notifyContactsChanged() } - // MARK: - Blocking (Channel only) - - private func performBlock(senderName: String, radioID: UUID, contactIDs: Set) async { - guard let services = appState.services else { return } - - let dto = BlockedChannelSenderDTO(name: senderName, radioID: radioID) - do { - try await services.dataStore.saveBlockedChannelSender(dto) - } catch { - logger.error("Failed to save blocked channel sender: \(error)") - return - } - - // Delete existing channel messages from the blocked sender - try? await services.dataStore.deleteChannelMessages(fromSender: senderName, radioID: radioID) - - for contactID in contactIDs { - do { - try await services.contactService.updateContactPreferences( - contactID: contactID, - isBlocked: true - ) - } catch { - logger.error("Failed to block contact \(contactID): \(error)") - } - } - - await services.syncCoordinator.refreshBlockedContactsCache( - radioID: radioID, - dataStore: services.dataStore - ) - - if !contactIDs.isEmpty { - services.syncCoordinator.notifyContactsChanged() - } - - if case .channel(let channel) = conversationType { - await chatViewModel.loadChannelMessages(for: channel) - } - services.syncCoordinator.notifyConversationsChanged() + if case let .channel(channel) = conversationType { + await chatViewModel.loadChannelMessages(for: channel) } + services.syncCoordinator.notifyConversationsChanged() + } } // MARK: - Error Alerts private extension View { - /// Applies the chat modal-alert surfaces in one modifier so the conversation - /// view body stays within the type-checker's expression budget. Two modal - /// alerts: generic "Error" for open-conversation load failures (so a - /// re-open failure cannot be missed), and "Unable to Send" for queue drain - /// failures. The passive banner for pagination failures is mounted - /// separately via `chatErrorBanner` so it can sit above the input bar. - func chatErrorAlerts(chatViewModel: ChatViewModel) -> some View { - @Bindable var vm = chatViewModel - return self - .errorAlert($vm.errorMessage) - .errorAlert($vm.sendErrorMessage, title: L10n.Chats.Chats.Alert.UnableToSend.title) - } - - /// Mounts the passive error banner used for background failures (e.g. - /// older-message pagination). Applied before the input-bar safe-area inset - /// so the banner appears between the message list and the input bar, and - /// rises with the keyboard alongside the input bar. - func chatErrorBanner(chatViewModel: ChatViewModel) -> some View { - @Bindable var vm = chatViewModel - return errorBanner($vm.errorBannerMessage) - } + /// Applies the chat modal-alert surfaces in one modifier so the conversation + /// view body stays within the type-checker's expression budget. Two modal + /// alerts: generic "Error" for open-conversation load failures (so a + /// re-open failure cannot be missed), and "Unable to Send" for queue drain + /// failures. The passive banner for pagination failures is mounted + /// separately via `chatErrorBanner` so it can sit above the input bar. + func chatErrorAlerts(chatViewModel: ChatViewModel) -> some View { + @Bindable var vm = chatViewModel + return errorAlert($vm.errorMessage) + .errorAlert($vm.sendErrorMessage, title: L10n.Chats.Chats.Alert.UnableToSend.title) + } + + /// Mounts the passive error banner used for background failures (e.g. + /// older-message pagination). Applied before the input-bar safe-area inset + /// so the banner appears between the message list and the input bar, and + /// rises with the keyboard alongside the input bar. + func chatErrorBanner(chatViewModel: ChatViewModel) -> some View { + @Bindable var vm = chatViewModel + return errorBanner($vm.errorBannerMessage) + } } // MARK: - Previews #Preview("DM") { - NavigationStack { - ChatConversationView( - conversationType: .dm(ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Alice" - ))) - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ChatConversationView( + conversationType: .dm(ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Alice" + ))) + ) + } + .environment(\.appState, AppState()) } #Preview("Channel") { - NavigationStack { - ChatConversationView( - conversationType: .channel(ChannelDTO(from: Channel( - radioID: UUID(), - index: 1, - name: "General" - ))) - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ChatConversationView( + conversationType: .channel(ChannelDTO(from: Channel( + radioID: UUID(), + index: 1, + name: "General" + ))) + ) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/ChatCoordinateDetector.swift b/MC1/Views/Chats/ChatCoordinateDetector.swift index 0fdd8f9f..a2fbfdd2 100644 --- a/MC1/Views/Chats/ChatCoordinateDetector.swift +++ b/MC1/Views/Chats/ChatCoordinateDetector.swift @@ -10,67 +10,67 @@ import Foundation /// thumbnail path: the thumbnail forwards the parsed coordinate straight to the /// snapshotter camera and `navigateToMap`, neither of which re-validates. enum ChatCoordinateDetector { - struct Match { - let range: Range - let coordinate: CLLocationCoordinate2D - } + struct Match { + let range: Range + let coordinate: CLLocationCoordinate2D + } - private static let latitudeRange = -90.0...90.0 - private static let longitudeRange = -180.0...180.0 + private static let latitudeRange = -90.0...90.0 + private static let longitudeRange = -180.0...180.0 - // swiftlint:disable force_try - /// Matches a decimal-degree pair: each component is a sign-optional 1-3 digit - /// integer part with a required decimal point and at least one fractional digit. - /// The lookbehind excludes an adjacent word char or `.` so the pass does not match - /// inside a longer number or after a version prefix (`v1.2, 3.4`). The trailing - /// lookahead rejects a following version segment (`.digit`, as in `3.4.5`) or word - /// char, but allows trailing punctuation so a coordinate ending a sentence still matches. - /// `try!` is intentional: the pattern is a literal, so an init failure here - /// is a programmer error that must crash at first use rather than silently - /// disable coordinate linking (no log, no test signal). - private static let coordinateRegex = try! NSRegularExpression( - pattern: #"(? [Match] { - // Common-case fast path: a coordinate pair must contain a comma. Skips - // NSRange construction and regex engine entry for the vast majority of - // bodies, which matters during full chat rebuilds (theme toggle, env - // flip) that re-run this over hundreds of messages. - guard text.contains(",") else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - var results: [Match] = [] - for match in coordinateRegex.matches(in: text, range: nsRange) { - guard match.numberOfRanges == 3, - let fullRange = Range(match.range, in: text), - let latRange = Range(match.range(at: 1), in: text), - let lonRange = Range(match.range(at: 2), in: text), - let latitude = Double(String(text[latRange])), - let longitude = Double(String(text[lonRange])), - latitudeRange.contains(latitude), - longitudeRange.contains(longitude) else { continue } + /// All valid coordinate matches in document order. Applies the regex, the + /// range clamp, and the decimal-list guard. Already-linked-range skipping is + /// the linkifier's concern (it needs the `AttributedString`) and is not done here. + static func matches(in text: String) -> [Match] { + // Common-case fast path: a coordinate pair must contain a comma. Skips + // NSRange construction and regex engine entry for the vast majority of + // bodies, which matters during full chat rebuilds (theme toggle, env + // flip) that re-run this over hundreds of messages. + guard text.contains(",") else { return [] } + let nsRange = NSRange(text.startIndex..., in: text) + var results: [Match] = [] + for match in coordinateRegex.matches(in: text, range: nsRange) { + guard match.numberOfRanges == 3, + let fullRange = Range(match.range, in: text), + let latRange = Range(match.range(at: 1), in: text), + let lonRange = Range(match.range(at: 2), in: text), + let latitude = Double(String(text[latRange])), + let longitude = Double(String(text[lonRange])), + latitudeRange.contains(latitude), + longitudeRange.contains(longitude) else { continue } - // Decimal-list guard: a third comma-number following the pair means this - // is a numeric list (`1.0, 2.0, 3.0`), not a coordinate. Skip it. - let tail = text[fullRange.upperBound...] - if tail.range(of: #"\s*,\s*-?\d"#, options: [.regularExpression, .anchored]) != nil { - continue - } + // Decimal-list guard: a third comma-number following the pair means this + // is a numeric list (`1.0, 2.0, 3.0`), not a coordinate. Skip it. + let tail = text[fullRange.upperBound...] + if tail.range(of: #"\s*,\s*-?\d"#, options: [.regularExpression, .anchored]) != nil { + continue + } - results.append(Match( - range: fullRange, - coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - )) - } - return results + results.append(Match( + range: fullRange, + coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + )) } + return results + } - /// The first valid coordinate in document order, or nil. - static func firstCoordinate(in text: String) -> CLLocationCoordinate2D? { - matches(in: text).first?.coordinate - } + /// The first valid coordinate in document order, or nil. + static func firstCoordinate(in text: String) -> CLLocationCoordinate2D? { + matches(in: text).first?.coordinate + } } diff --git a/MC1/Views/Chats/ChatFilterPicker.swift b/MC1/Views/Chats/ChatFilterPicker.swift index 1a047253..83df51d0 100644 --- a/MC1/Views/Chats/ChatFilterPicker.swift +++ b/MC1/Views/Chats/ChatFilterPicker.swift @@ -2,15 +2,15 @@ import SwiftUI /// Pinned glass filter bar for the Chats tab. struct ChatFilterPicker: View { - @Binding var selection: ChatFilter - @Environment(\.isSearching) private var isSearching + @Binding var selection: ChatFilter + @Environment(\.isSearching) private var isSearching - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: isSearching, - pickerLabel: L10n.Chats.Chats.Filter.title, - title: { $0.localizedName } - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: isSearching, + pickerLabel: L10n.Chats.Chats.Filter.title, + title: { $0.localizedName } + ) + } } diff --git a/MC1/Views/Chats/ChatListActions.swift b/MC1/Views/Chats/ChatListActions.swift index d73c639d..34eb816d 100644 --- a/MC1/Views/Chats/ChatListActions.swift +++ b/MC1/Views/Chats/ChatListActions.swift @@ -1,6 +1,6 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let chatListActionsLogger = Logger(subsystem: "com.mc1", category: "ChatListActions") @@ -11,125 +11,125 @@ private let chatListActionsLogger = Logger(subsystem: "com.mc1", category: "Chat /// at each view's own `@State`, so the captured state stays live. @MainActor struct ChatListActions { - let viewModel: ChatViewModel - let appState: AppState - let roomToDelete: Binding - let showRoomDeleteAlert: Binding - let channelDeleteFailure: Binding - let showChannelDeleteFailed: Binding - let roomToAuthenticate: Binding - let navigate: (ChatRoute) -> Void - let clearNavigationIfActive: (ChatRoute) -> Void + let viewModel: ChatViewModel + let appState: AppState + let roomToDelete: Binding + let showRoomDeleteAlert: Binding + let channelDeleteFailure: Binding + let showChannelDeleteFailed: Binding + let roomToAuthenticate: Binding + let navigate: (ChatRoute) -> Void + let clearNavigationIfActive: (ChatRoute) -> Void - func handleDeleteConversation(_ conversation: Conversation) { - switch conversation { - case .direct(let contact): - deleteDirectConversation(contact) + func handleDeleteConversation(_ conversation: Conversation) { + switch conversation { + case let .direct(contact): + deleteDirectConversation(contact) - case .channel(let channel): - deleteChannelConversation(channel) + case let .channel(channel): + deleteChannelConversation(channel) - case .room(let session): - roomToDelete.wrappedValue = session - showRoomDeleteAlert.wrappedValue = true - } + case let .room(session): + roomToDelete.wrappedValue = session + showRoomDeleteAlert.wrappedValue = true } + } - /// Direct conversations clear via a local SwiftData write only, so the row is hidden - /// optimistically and restored if the write throws. - func deleteDirectConversation(_ contact: ContactDTO) { - guard !viewModel.isDeletePending(contact.id) else { return } - clearNavigationIfActive(.direct(contact)) - viewModel.removeConversation(.direct(contact)) + /// Direct conversations clear via a local SwiftData write only, so the row is hidden + /// optimistically and restored if the write throws. + func deleteDirectConversation(_ contact: ContactDTO) { + guard !viewModel.isDeletePending(contact.id) else { return } + clearNavigationIfActive(.direct(contact)) + viewModel.removeConversation(.direct(contact)) - Task { - do { - try await viewModel.deleteDirectConversation(for: contact) - // Confirm immediately rather than waiting for the reload; an inbound message that - // re-sets lastMessageDate mid-delete would otherwise keep the row masked forever. - viewModel.confirmDirectRemoval(contact) - viewModel.requestConversationReload() - } catch { - viewModel.restoreConversation(.direct(contact)) - viewModel.errorMessage = error.userFacingMessage - } - } + Task { + do { + try await viewModel.deleteDirectConversation(for: contact) + // Confirm immediately rather than waiting for the reload; an inbound message that + // re-sets lastMessageDate mid-delete would otherwise keep the row masked forever. + viewModel.confirmDirectRemoval(contact) + viewModel.requestConversationReload() + } catch { + viewModel.restoreConversation(.direct(contact)) + viewModel.errorMessage = error.userFacingMessage + } } + } - /// Channel deletion sends a radio command. The row stays put with a spinner until the - /// command acks, then is hidden once; a failure or timeout leaves it in place with a retry alert. - func deleteChannelConversation(_ channel: ChannelDTO) { - guard !viewModel.isDeletePending(channel.id) else { return } - viewModel.deletingIDs.insert(channel.id) - Task { - defer { viewModel.deletingIDs.remove(channel.id) } - do { - try await withTimeout(RadioCommandTimeout.delete, operationName: "clearChannel") { - try await ChatConversationActions.deleteChannel(channel, appState: appState) - } - clearNavigationIfActive(.channel(channel)) - viewModel.removeConversation(.channel(channel)) - } catch { - channelDeleteFailure.wrappedValue = ChatConversationActions.Failure( - channel: channel, - message: error.userFacingMessage - ) - showChannelDeleteFailed.wrappedValue = true - } - viewModel.requestConversationReload() + /// Channel deletion sends a radio command. The row stays put with a spinner until the + /// command acks, then is hidden once; a failure or timeout leaves it in place with a retry alert. + func deleteChannelConversation(_ channel: ChannelDTO) { + guard !viewModel.isDeletePending(channel.id) else { return } + viewModel.deletingIDs.insert(channel.id) + Task { + defer { viewModel.deletingIDs.remove(channel.id) } + do { + try await withTimeout(RadioCommandTimeout.delete, operationName: "clearChannel") { + try await ChatConversationActions.deleteChannel(channel, appState: appState) } + clearNavigationIfActive(.channel(channel)) + viewModel.removeConversation(.channel(channel)) + } catch { + channelDeleteFailure.wrappedValue = ChatConversationActions.Failure( + channel: channel, + message: error.userFacingMessage + ) + showChannelDeleteFailed.wrappedValue = true + } + viewModel.requestConversationReload() } + } - /// Room leave sends radio commands (logout, remove contact). The row keeps its spinner until - /// they ack, then is hidden once; a failure leaves it in place and the trailing reload - /// reconciles a partial failure rather than blindly re-inserting. - func deleteRoom(_ session: RemoteNodeSessionDTO) async { - guard !viewModel.isDeletePending(session.id) else { return } - viewModel.deletingIDs.insert(session.id) - defer { viewModel.deletingIDs.remove(session.id) } - do { - try await withTimeout(RadioCommandTimeout.delete, operationName: "leaveRoom") { - try await ChatConversationActions.leaveRoom(session, appState: appState) - } - clearNavigationIfActive(.room(session)) - viewModel.removeConversation(.room(session)) - } catch { - chatListActionsLogger.error("Failed to delete room: \(error)") - viewModel.errorMessage = error.userFacingMessage - } - viewModel.requestConversationReload() + /// Room leave sends radio commands (logout, remove contact). The row keeps its spinner until + /// they ack, then is hidden once; a failure leaves it in place and the trailing reload + /// reconciles a partial failure rather than blindly re-inserting. + func deleteRoom(_ session: RemoteNodeSessionDTO) async { + guard !viewModel.isDeletePending(session.id) else { return } + viewModel.deletingIDs.insert(session.id) + defer { viewModel.deletingIDs.remove(session.id) } + do { + try await withTimeout(RadioCommandTimeout.delete, operationName: "leaveRoom") { + try await ChatConversationActions.leaveRoom(session, appState: appState) + } + clearNavigationIfActive(.room(session)) + viewModel.removeConversation(.room(session)) + } catch { + chatListActionsLogger.error("Failed to delete room: \(error)") + viewModel.errorMessage = error.userFacingMessage } + viewModel.requestConversationReload() + } - func handlePendingNavigation() { - guard let contact = appState.navigation.pendingChatContact else { return } - navigate(.direct(contact)) - appState.navigation.clearPendingNavigation() - } + func handlePendingNavigation() { + guard let contact = appState.navigation.pendingChatContact else { return } + navigate(.direct(contact)) + appState.navigation.clearPendingNavigation() + } - func handlePendingChannelNavigation() { - guard let channel = appState.navigation.pendingChannel else { return } - navigate(.channel(channel)) - appState.navigation.clearPendingChannelNavigation() - } + func handlePendingChannelNavigation() { + guard let channel = appState.navigation.pendingChannel else { return } + navigate(.channel(channel)) + appState.navigation.clearPendingChannelNavigation() + } - func handlePendingRoomNavigation() { - guard let session = appState.navigation.pendingRoomSession else { return } - navigate(.room(session)) - appState.navigation.clearPendingRoomNavigation() - } + func handlePendingRoomNavigation() { + guard let session = appState.navigation.pendingRoomSession else { return } + navigate(.room(session)) + appState.navigation.clearPendingRoomNavigation() + } - /// Presents the room auth sheet for a disconnected room a notification tap - /// wants to open, reusing the same sheet a disconnected-room list tap uses. - func consumePendingRoomAuthentication() { - guard let session = appState.navigation.pendingRoomAuthentication else { return } - roomToAuthenticate.wrappedValue = session - appState.navigation.clearPendingRoomAuthentication() - } + /// Presents the room auth sheet for a disconnected room a notification tap + /// wants to open, reusing the same sheet a disconnected-room list tap uses. + func consumePendingRoomAuthentication() { + guard let session = appState.navigation.pendingRoomAuthentication else { return } + roomToAuthenticate.wrappedValue = session + appState.navigation.clearPendingRoomAuthentication() + } - func announceOfflineStateIfNeeded() { - guard appState.connectionState == .disconnected, - appState.currentRadioID != nil else { return } + func announceOfflineStateIfNeeded() { + guard appState.connectionState == .disconnected, + appState.currentRadioID != nil else { return } - AccessibilityNotification.Announcement(L10n.Chats.Chats.Accessibility.offlineAnnouncement).post() - } + AccessibilityNotification.Announcement(L10n.Chats.Chats.Accessibility.offlineAnnouncement).post() + } } diff --git a/MC1/Views/Chats/ChatsListModifiers.swift b/MC1/Views/Chats/ChatsListModifiers.swift index d242211f..8d3b89df 100644 --- a/MC1/Views/Chats/ChatsListModifiers.swift +++ b/MC1/Views/Chats/ChatsListModifiers.swift @@ -1,102 +1,102 @@ -import SwiftUI import MC1Services +import SwiftUI /// Shared modifiers applied to the conversation list in both stack and split layouts. struct ChatsListModifiers: ViewModifier { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let viewModel: ChatViewModel + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @Binding var searchText: String - @Binding var showingNewChat: Bool - @Binding var showingChannelOptions: Bool + let viewModel: ChatViewModel - let onAnnounceOfflineStateIfNeeded: () -> Void - let onHandlePendingNavigation: () -> Void - let onHandlePendingChannelNavigation: () -> Void - let onHandlePendingRoomNavigation: () -> Void + @Binding var searchText: String + @Binding var showingNewChat: Bool + @Binding var showingChannelOptions: Bool - func body(content: Content) -> some View { - content - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.title) - .searchable(text: $searchText, prompt: L10n.Chats.Chats.Search.placeholder) - .toolbar { - bleStatusToolbarItem() - ToolbarItem(placement: .automatic) { - Menu { - Button { - showingNewChat = true - } label: { - Label(L10n.Chats.Chats.Compose.newChat, systemImage: "person") - } + let onAnnounceOfflineStateIfNeeded: () -> Void + let onHandlePendingNavigation: () -> Void + let onHandlePendingChannelNavigation: () -> Void + let onHandlePendingRoomNavigation: () -> Void - Button { - showingChannelOptions = true - } label: { - Label(L10n.Chats.Chats.Compose.newChannel, systemImage: "number") - } - } label: { - Label(L10n.Chats.Chats.Compose.newMessage, systemImage: "square.and.pencil") - } - } - } - .task { - viewModel.configure( - dependencies: ChatViewModel.Dependencies( - dataStore: { appState.offlineDataStore }, - messageService: { appState.services?.messageService }, - notificationService: { appState.services?.notificationService }, - channelService: { appState.services?.channelService }, - roomServerService: { appState.services?.roomServerService }, - contactService: { appState.services?.contactService }, - syncCoordinator: { appState.syncCoordinator }, - connectionState: { appState.connectionState }, - connectedDevice: { appState.connectedDevice }, - currentRadioID: { appState.currentRadioID }, - session: { appState.services?.session }, - reactionService: { appState.services?.reactionService }, - chatSendQueueService: { appState.services?.chatSendQueueService }, - inlineImageDimensionsStore: { nil }, - prefetchDataStore: { nil } - ), - onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, - linkPreviewCache: nil, - chatCoordinatorRegistry: nil, - conversation: nil - ) - await viewModel.requestConversationReload()?.value - onAnnounceOfflineStateIfNeeded() - onHandlePendingNavigation() - onHandlePendingChannelNavigation() - onHandlePendingRoomNavigation() - } - .onChange(of: appState.navigation.pendingChatContact) { _, _ in - onHandlePendingNavigation() + func body(content: Content) -> some View { + content + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.title) + .searchable(text: $searchText, prompt: L10n.Chats.Chats.Search.placeholder) + .toolbar { + bleStatusToolbarItem() + ToolbarItem(placement: .automatic) { + Menu { + Button { + showingNewChat = true + } label: { + Label(L10n.Chats.Chats.Compose.newChat, systemImage: "person") } - .onChange(of: appState.navigation.pendingChannel) { _, _ in - onHandlePendingChannelNavigation() - } - .onChange(of: appState.navigation.pendingRoomSession) { _, _ in - onHandlePendingRoomNavigation() - } - .onChange(of: appState.servicesVersion) { _, _ in - viewModel.requestConversationReload() - } - .onChange(of: appState.conversationsVersion) { _, _ in - viewModel.requestConversationReload() - } - .onChange(of: appState.connectionState) { _, newState in - if newState == .disconnected { - viewModel.requestConversationReload() - } + + Button { + showingChannelOptions = true + } label: { + Label(L10n.Chats.Chats.Compose.newChannel, systemImage: "number") } - // Surfaces direct-message and room delete failures; the channel retry alert lives on - // ChatsConversationSheets, and only one can present since deletes happen one at a time. - .errorAlert(Binding( - get: { viewModel.errorMessage }, - set: { viewModel.errorMessage = $0 } - )) - } + } label: { + Label(L10n.Chats.Chats.Compose.newMessage, systemImage: "square.and.pencil") + } + } + } + .task { + viewModel.configure( + dependencies: ChatViewModel.Dependencies( + dataStore: { appState.offlineDataStore }, + messageService: { appState.services?.messageService }, + notificationService: { appState.services?.notificationService }, + channelService: { appState.services?.channelService }, + roomServerService: { appState.services?.roomServerService }, + contactService: { appState.services?.contactService }, + syncCoordinator: { appState.syncCoordinator }, + connectionState: { appState.connectionState }, + connectedDevice: { appState.connectedDevice }, + currentRadioID: { appState.currentRadioID }, + session: { appState.services?.session }, + reactionService: { appState.services?.reactionService }, + chatSendQueueService: { appState.services?.chatSendQueueService }, + inlineImageDimensionsStore: { nil }, + prefetchDataStore: { nil } + ), + onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, + linkPreviewCache: nil, + chatCoordinatorRegistry: nil, + conversation: nil + ) + await viewModel.requestConversationReload()?.value + onAnnounceOfflineStateIfNeeded() + onHandlePendingNavigation() + onHandlePendingChannelNavigation() + onHandlePendingRoomNavigation() + } + .onChange(of: appState.navigation.pendingChatContact) { _, _ in + onHandlePendingNavigation() + } + .onChange(of: appState.navigation.pendingChannel) { _, _ in + onHandlePendingChannelNavigation() + } + .onChange(of: appState.navigation.pendingRoomSession) { _, _ in + onHandlePendingRoomNavigation() + } + .onChange(of: appState.servicesVersion) { _, _ in + viewModel.requestConversationReload() + } + .onChange(of: appState.conversationsVersion) { _, _ in + viewModel.requestConversationReload() + } + .onChange(of: appState.connectionState) { _, newState in + if newState == .disconnected { + viewModel.requestConversationReload() + } + } + // Surfaces direct-message and room delete failures; the channel retry alert lives on + // ChatsConversationSheets, and only one can present since deletes happen one at a time. + .errorAlert(Binding( + get: { viewModel.errorMessage }, + set: { viewModel.errorMessage = $0 } + )) + } } diff --git a/MC1/Views/Chats/ChatsView.swift b/MC1/Views/Chats/ChatsView.swift index ab0c6769..1981aaea 100644 --- a/MC1/Views/Chats/ChatsView.swift +++ b/MC1/Views/Chats/ChatsView.swift @@ -1,137 +1,137 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsView: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var viewModel = ChatViewModel() - @State private var searchText = "" - @State private var selectedFilter: ChatFilter = .all - @State private var showingNewChat = false - @State private var showingChannelOptions = false + @State private var viewModel = ChatViewModel() + @State private var searchText = "" + @State private var selectedFilter: ChatFilter = .all + @State private var showingNewChat = false + @State private var showingChannelOptions = false - @State private var navigationPath = NavigationPath() - @State private var activeRoute: ChatRoute? + @State private var navigationPath = NavigationPath() + @State private var activeRoute: ChatRoute? - @State private var roomToAuthenticate: RemoteNodeSessionDTO? - @State private var roomToDelete: RemoteNodeSessionDTO? - @State private var showRoomDeleteAlert = false - @State private var showChannelDeleteFailed = false - @State private var channelDeleteFailure: ChatConversationActions.Failure? - @State private var pendingChatContact: ContactDTO? - @State private var pendingChannel: ChannelDTO? + @State private var roomToAuthenticate: RemoteNodeSessionDTO? + @State private var roomToDelete: RemoteNodeSessionDTO? + @State private var showRoomDeleteAlert = false + @State private var showChannelDeleteFailed = false + @State private var channelDeleteFailure: ChatConversationActions.Failure? + @State private var pendingChatContact: ContactDTO? + @State private var pendingChannel: ChannelDTO? - private var filteredFavorites: [Conversation] { - viewModel.favoriteConversations.filtered(by: selectedFilter, searchText: searchText) - } + private var filteredFavorites: [Conversation] { + viewModel.favoriteConversations.filtered(by: selectedFilter, searchText: searchText) + } - private var filteredOthers: [Conversation] { - viewModel.nonFavoriteConversations.filtered(by: selectedFilter, searchText: searchText) - } + private var filteredOthers: [Conversation] { + viewModel.nonFavoriteConversations.filtered(by: selectedFilter, searchText: searchText) + } - private var emptyStateMessage: (title: String, description: String, systemImage: String) { - switch selectedFilter { - case .all: - return (L10n.Chats.Chats.EmptyState.NoConversations.title, L10n.Chats.Chats.EmptyState.NoConversations.description, "message") - case .unread: - return (L10n.Chats.Chats.EmptyState.NoUnread.title, L10n.Chats.Chats.EmptyState.NoUnread.description, "checkmark.circle") - case .directMessages: - return (L10n.Chats.Chats.EmptyState.NoDirectMessages.title, L10n.Chats.Chats.EmptyState.NoDirectMessages.description, "person") - case .channels: - return (L10n.Chats.Chats.EmptyState.NoChannels.title, L10n.Chats.Chats.EmptyState.NoChannels.description, "number") - case .rooms: - return (L10n.Chats.Chats.EmptyState.NoRooms.title, L10n.Chats.Chats.EmptyState.NoRooms.description, "door.left.hand.open") - } + private var emptyStateMessage: (title: String, description: String, systemImage: String) { + switch selectedFilter { + case .all: + (L10n.Chats.Chats.EmptyState.NoConversations.title, L10n.Chats.Chats.EmptyState.NoConversations.description, "message") + case .unread: + (L10n.Chats.Chats.EmptyState.NoUnread.title, L10n.Chats.Chats.EmptyState.NoUnread.description, "checkmark.circle") + case .directMessages: + (L10n.Chats.Chats.EmptyState.NoDirectMessages.title, L10n.Chats.Chats.EmptyState.NoDirectMessages.description, "person") + case .channels: + (L10n.Chats.Chats.EmptyState.NoChannels.title, L10n.Chats.Chats.EmptyState.NoChannels.description, "number") + case .rooms: + (L10n.Chats.Chats.EmptyState.NoRooms.title, L10n.Chats.Chats.EmptyState.NoRooms.description, "door.left.hand.open") } + } - private var actions: ChatListActions { - ChatListActions( - viewModel: viewModel, - appState: appState, - roomToDelete: $roomToDelete, - showRoomDeleteAlert: $showRoomDeleteAlert, - channelDeleteFailure: $channelDeleteFailure, - showChannelDeleteFailed: $showChannelDeleteFailed, - roomToAuthenticate: $roomToAuthenticate, - navigate: { navigate(to: $0) }, - clearNavigationIfActive: clearNavigationIfActive - ) - } + private var actions: ChatListActions { + ChatListActions( + viewModel: viewModel, + appState: appState, + roomToDelete: $roomToDelete, + showRoomDeleteAlert: $showRoomDeleteAlert, + channelDeleteFailure: $channelDeleteFailure, + showChannelDeleteFailed: $showChannelDeleteFailed, + roomToAuthenticate: $roomToAuthenticate, + navigate: { navigate(to: $0) }, + clearNavigationIfActive: clearNavigationIfActive + ) + } - var body: some View { - ChatsStackLayout( - viewModel: viewModel, - navigationPath: $navigationPath, - activeRoute: $activeRoute - ) { - ChatsStackRootContent( - viewModel: viewModel, - filteredFavorites: filteredFavorites, - filteredOthers: filteredOthers, - emptyStateMessage: emptyStateMessage, - hasLoadedOnce: viewModel.hasLoadedOnce, - selectedFilter: $selectedFilter, - searchText: $searchText, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - roomToAuthenticate: $roomToAuthenticate, - navigationPath: $navigationPath, - onDeleteConversation: actions.handleDeleteConversation, - onHandlePendingNavigation: actions.handlePendingNavigation, - onHandlePendingChannelNavigation: actions.handlePendingChannelNavigation, - onHandlePendingRoomNavigation: actions.handlePendingRoomNavigation, - onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded - ) - } - .task { - actions.consumePendingRoomAuthentication() - } - .onChange(of: appState.navigation.pendingRoomAuthentication) { _, _ in - actions.consumePendingRoomAuthentication() - } - // Keep the pushed route's payload current as the snapshot recomputes; a route - // whose conversation was removed resolves to nil and the push unwinds. - .onChange(of: viewModel.snapshotGeneration) { _, _ in - activeRoute = activeRoute?.refreshedPayload(from: viewModel.allConversations) - } - .modifier(ChatsConversationSheets( - viewModel: viewModel, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - roomToAuthenticate: $roomToAuthenticate, - roomToDelete: $roomToDelete, - showRoomDeleteAlert: $showRoomDeleteAlert, - channelDeleteFailure: $channelDeleteFailure, - showChannelDeleteFailed: $showChannelDeleteFailed, - pendingChatContact: $pendingChatContact, - pendingChannel: $pendingChannel, - navigate: { navigate(to: $0) }, - deleteChannelConversation: actions.deleteChannelConversation, - deleteRoom: actions.deleteRoom - )) + var body: some View { + ChatsStackLayout( + viewModel: viewModel, + navigationPath: $navigationPath, + activeRoute: $activeRoute + ) { + ChatsStackRootContent( + viewModel: viewModel, + filteredFavorites: filteredFavorites, + filteredOthers: filteredOthers, + emptyStateMessage: emptyStateMessage, + hasLoadedOnce: viewModel.hasLoadedOnce, + selectedFilter: $selectedFilter, + searchText: $searchText, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + roomToAuthenticate: $roomToAuthenticate, + navigationPath: $navigationPath, + onDeleteConversation: actions.handleDeleteConversation, + onHandlePendingNavigation: actions.handlePendingNavigation, + onHandlePendingChannelNavigation: actions.handlePendingChannelNavigation, + onHandlePendingRoomNavigation: actions.handlePendingRoomNavigation, + onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded + ) } + .task { + actions.consumePendingRoomAuthentication() + } + .onChange(of: appState.navigation.pendingRoomAuthentication) { _, _ in + actions.consumePendingRoomAuthentication() + } + // Keep the pushed route's payload current as the snapshot recomputes; a route + // whose conversation was removed resolves to nil and the push unwinds. + .onChange(of: viewModel.snapshotGeneration) { _, _ in + activeRoute = activeRoute?.refreshedPayload(from: viewModel.allConversations) + } + .modifier(ChatsConversationSheets( + viewModel: viewModel, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + roomToAuthenticate: $roomToAuthenticate, + roomToDelete: $roomToDelete, + showRoomDeleteAlert: $showRoomDeleteAlert, + channelDeleteFailure: $channelDeleteFailure, + showChannelDeleteFailed: $showChannelDeleteFailed, + pendingChatContact: $pendingChatContact, + pendingChannel: $pendingChannel, + navigate: { navigate(to: $0) }, + deleteChannelConversation: actions.deleteChannelConversation, + deleteRoom: actions.deleteRoom + )) + } - private func navigate(to route: ChatRoute) { - if case .room(let session) = route, !session.isConnected { - roomToAuthenticate = session - return - } - - appState.navigation.tabBarVisibility = .hidden - navigationPath.removeLast(navigationPath.count) - navigationPath.append(route) + private func navigate(to route: ChatRoute) { + if case let .room(session) = route, !session.isConnected { + roomToAuthenticate = session + return } - private func clearNavigationIfActive(_ route: ChatRoute) { - if activeRoute == route { - navigationPath.removeLast(navigationPath.count) - activeRoute = nil - appState.navigation.tabBarVisibility = .visible - } + appState.navigation.tabBarVisibility = .hidden + navigationPath.removeLast(navigationPath.count) + navigationPath.append(route) + } + + private func clearNavigationIfActive(_ route: ChatRoute) { + if activeRoute == route { + navigationPath.removeLast(navigationPath.count) + activeRoute = nil + appState.navigation.tabBarVisibility = .visible } + } } #Preview { - ChatsView() - .environment(\.appState, AppState()) + ChatsView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Components/AnimatedGIFView.swift b/MC1/Views/Chats/Components/AnimatedGIFView.swift index 7098764c..c1b867ba 100644 --- a/MC1/Views/Chats/Components/AnimatedGIFView.swift +++ b/MC1/Views/Chats/Components/AnimatedGIFView.swift @@ -3,23 +3,23 @@ import UIKit /// UIViewRepresentable that renders animated GIF data using UIImageView struct AnimatedGIFView: UIViewRepresentable { - let image: UIImage + let image: UIImage - func makeUIView(context: Context) -> UIImageView { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFit - imageView.clipsToBounds = true - imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - imageView.image = image - return imageView - } + func makeUIView(context: Context) -> UIImageView { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.image = image + return imageView + } - func updateUIView(_ imageView: UIImageView, context: Context) { - imageView.image = image - } + func updateUIView(_ imageView: UIImageView, context: Context) { + imageView.image = image + } - static func dismantleUIView(_ imageView: UIImageView, coordinator: ()) { - imageView.image = nil - } + static func dismantleUIView(_ imageView: UIImageView, coordinator: ()) { + imageView.image = nil + } } diff --git a/MC1/Views/Chats/Components/BubbleActions.swift b/MC1/Views/Chats/Components/BubbleActions.swift index 85561638..0fdc3175 100644 --- a/MC1/Views/Chats/Components/BubbleActions.swift +++ b/MC1/Views/Chats/Components/BubbleActions.swift @@ -1,7 +1,7 @@ import CoreLocation +import MC1Services import SwiftUI import UIKit -import MC1Services /// Per-row action wiring for `MessageBubbleView`. Each closure is invoked /// in response to a user interaction on a bubble and forwards to the @@ -18,17 +18,17 @@ import MC1Services /// message identity, which is captured by `MessageItem.id`. @MainActor struct BubbleActions { - let onRetryMessage: (MessageDTO) -> Void - let onReaction: (String, MessageDTO) -> Void - let onLongPress: (MessageDTO) -> Void - let onImageTap: (MessageDTO) -> Void - let onRetryInlineImage: (UUID) -> Void - let onRequestPreviewFetch: (UUID) -> Void - let onManualPreviewFetch: (UUID) -> Void - let onMapPreviewTap: (CLLocationCoordinate2D) -> Void - /// Map snapshot providers, injected so the bubble resolves, requests, and - /// retries thumbnails without reaching `MapSnapshotStore.shared`. - let snapshotResolver: (MapSnapshotRequest) -> UIImage? - let requestSnapshot: (MapSnapshotRequest) -> Void - let retrySnapshot: (MapSnapshotRequest) -> Void + let onRetryMessage: (MessageDTO) -> Void + let onReaction: (String, MessageDTO) -> Void + let onLongPress: (MessageDTO) -> Void + let onImageTap: (MessageDTO) -> Void + let onRetryInlineImage: (UUID) -> Void + let onRequestPreviewFetch: (UUID) -> Void + let onManualPreviewFetch: (UUID) -> Void + let onMapPreviewTap: (CLLocationCoordinate2D) -> Void + /// Map snapshot providers, injected so the bubble resolves, requests, and + /// retries thumbnails without reaching `MapSnapshotStore.shared`. + let snapshotResolver: (MapSnapshotRequest) -> UIImage? + let requestSnapshot: (MapSnapshotRequest) -> Void + let retrySnapshot: (MapSnapshotRequest) -> Void } diff --git a/MC1/Views/Chats/Components/BubbleContentPadding.swift b/MC1/Views/Chats/Components/BubbleContentPadding.swift index 9a0457fe..47b689eb 100644 --- a/MC1/Views/Chats/Components/BubbleContentPadding.swift +++ b/MC1/Views/Chats/Components/BubbleContentPadding.swift @@ -1,8 +1,8 @@ import SwiftUI extension View { - func bubbleContentPadding() -> some View { - padding(.horizontal, 10) - .padding(.vertical, 8) - } + func bubbleContentPadding() -> some View { + padding(.horizontal, 10) + .padding(.vertical, 8) + } } diff --git a/MC1/Views/Chats/Components/BubbleFooterRow.swift b/MC1/Views/Chats/Components/BubbleFooterRow.swift index 41e3c8fd..79b8c754 100644 --- a/MC1/Views/Chats/Components/BubbleFooterRow.swift +++ b/MC1/Views/Chats/Components/BubbleFooterRow.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// SF Symbol shown beside the send time when the sender's clock was invalid and the /// app substituted a corrected value, signalling that the displayed time was adjusted. @@ -12,111 +12,111 @@ private let correctedClockBadgeSymbol = "clock.badge.exclamationmark" /// `.accessibilityElement(children: .combine)` so VoiceOver surfaces the row as /// a single rotor stop. struct BubbleFooterRow: View { - let footer: MessageFooter - let dynamicTypeSize: DynamicTypeSize + let footer: MessageFooter + let dynamicTypeSize: DynamicTypeSize - var body: some View { - if dynamicTypeSize.isAccessibilitySize { - VStack(alignment: .leading, spacing: 2) { - footerContents(allowsWrap: true) - } - .accessibilityElement(children: .combine) - } else { - HStack(spacing: 4) { - footerContents(allowsWrap: false) - } - .accessibilityElement(children: .combine) - } + var body: some View { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 2) { + footerContents(allowsWrap: true) + } + .accessibilityElement(children: .combine) + } else { + HStack(spacing: 4) { + footerContents(allowsWrap: false) + } + .accessibilityElement(children: .combine) } + } - @ViewBuilder - private func footerContents(allowsWrap: Bool) -> some View { - if let sendTime = footer.sendTimeToShow { - BubbleSendTimeFooter(date: sendTime, wasCorrected: footer.sendTimeWasCorrected) - } - if footer.showHop { - BubbleHopCountFooter(hopCount: footer.hopCount) - } - if let formattedPath = footer.formattedPath { - BubblePathFooter(formattedPath: formattedPath) - } - if let region = footer.regionToShow { - BubbleRegionFooter(regionName: region, allowsWrap: allowsWrap) - } + @ViewBuilder + private func footerContents(allowsWrap: Bool) -> some View { + if let sendTime = footer.sendTimeToShow { + BubbleSendTimeFooter(date: sendTime, wasCorrected: footer.sendTimeWasCorrected) + } + if footer.showHop { + BubbleHopCountFooter(hopCount: footer.hopCount) + } + if let formattedPath = footer.formattedPath { + BubblePathFooter(formattedPath: formattedPath) } + if let region = footer.regionToShow { + BubbleRegionFooter(regionName: region, allowsWrap: allowsWrap) + } + } } private struct BubbleSendTimeFooter: View { - let date: Date - let wasCorrected: Bool + let date: Date + let wasCorrected: Bool - private var timeText: String { - date.formatted(date: .omitted, time: .shortened) - } + private var timeText: String { + date.formatted(date: .omitted, time: .shortened) + } - private var accessibilityLabel: String { - wasCorrected - ? L10n.Chats.Chats.Message.SendTime.correctedAccessibilityLabel(timeText) - : L10n.Chats.Chats.Message.SendTime.accessibilityLabel(timeText) - } + private var accessibilityLabel: String { + wasCorrected + ? L10n.Chats.Chats.Message.SendTime.correctedAccessibilityLabel(timeText) + : L10n.Chats.Chats.Message.SendTime.accessibilityLabel(timeText) + } - var body: some View { - HStack(spacing: 4) { - if wasCorrected { - Image(systemName: correctedClockBadgeSymbol) - } - Text(timeText) - } - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) + var body: some View { + HStack(spacing: 4) { + if wasCorrected { + Image(systemName: correctedClockBadgeSymbol) + } + Text(timeText) } + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } } private struct BubbleHopCountFooter: View { - let hopCount: Int + let hopCount: Int - var body: some View { - HStack(spacing: 4) { - Image(systemName: "arrowshape.bounce.right") - Text("\(hopCount)") - } - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(hopCount)) + var body: some View { + HStack(spacing: 4) { + Image(systemName: "arrowshape.bounce.right") + Text("\(hopCount)") } + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(hopCount)) + } } private struct BubblePathFooter: View { - let formattedPath: String + let formattedPath: String - var body: some View { - HStack(spacing: 4) { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - Text(formattedPath) - } - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath)) + var body: some View { + HStack(spacing: 4) { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + Text(formattedPath) } + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath)) + } } private struct BubbleRegionFooter: View { - let regionName: String - let allowsWrap: Bool + let regionName: String + let allowsWrap: Bool - var body: some View { - HStack(spacing: 4) { - Image(systemName: "globe") - Text(regionName) - .lineLimit(allowsWrap ? nil : 1) - } - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Message.Region.accessibilityLabel(regionName)) + var body: some View { + HStack(spacing: 4) { + Image(systemName: "globe") + Text(regionName) + .lineLimit(allowsWrap ? nil : 1) } + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Message.Region.accessibilityLabel(regionName)) + } } diff --git a/MC1/Views/Chats/Components/BubbleFragmentStack.swift b/MC1/Views/Chats/Components/BubbleFragmentStack.swift index 30deb37f..9722bef1 100644 --- a/MC1/Views/Chats/Components/BubbleFragmentStack.swift +++ b/MC1/Views/Chats/Components/BubbleFragmentStack.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// The colored, clipped bubble box: text, optional footer, and optional inline /// image. Reactions, malware warnings, and link previews are emitted as @@ -10,68 +10,86 @@ import MC1Services /// invalidate body on every visible cell. Closures and dynamic-type /// environment changes propagate through the parent rebody path. struct BubbleFragmentStack: View, Equatable { - /// Corner radius for the bubble box, shared by the text-only shape-fill - /// background, the inline-image clip path, and the context-menu lift shape - /// applied by `UnifiedMessageBubble`. - static let cornerRadius: CGFloat = 16 + /// Corner radius for the bubble box, shared by the text-only shape-fill + /// background, the inline-image clip path, and the context-menu lift shape + /// applied by `UnifiedMessageBubble`. + static let cornerRadius: CGFloat = 16 - let item: MessageItem - /// The box-resident text and inline image from the shared partition. - /// Excluded from `==` (which stays `item`/`bubbleColor`): it is a pure - /// function of `item.content`, so equal items yield equal box fragments. - let layout: FragmentLayout - let bubbleColor: Color - let callbacks: MessageBubbleCallbacks - let imageResolver: (ImageReference) -> UIImage? + let item: MessageItem + /// The box-resident text and inline image from the shared partition. + /// Excluded from `==` (which stays `item`/`bubbleColor`): it is a pure + /// function of `item.content`, so equal items yield equal box fragments. + let layout: FragmentLayout + let bubbleColor: Color + let callbacks: MessageBubbleCallbacks + let imageResolver: (ImageReference) -> UIImage? - @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - nonisolated static func == (lhs: BubbleFragmentStack, rhs: BubbleFragmentStack) -> Bool { - lhs.item == rhs.item && lhs.bubbleColor == rhs.bubbleColor - } + nonisolated static func == (lhs: BubbleFragmentStack, rhs: BubbleFragmentStack) -> Bool { + lhs.item == rhs.item && lhs.bubbleColor == rhs.bubbleColor + } - private var hasFooter: Bool { - item.footer.sendTimeToShow != nil - || item.footer.showHop - || item.footer.formattedPath != nil - || item.footer.regionToShow != nil - } + private var hasFooter: Bool { + item.footer.sendTimeToShow != nil + || item.footer.showHop + || item.footer.formattedPath != nil + || item.footer.regionToShow != nil + } - var body: some View { - let stack = VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 4) { - if let textPayload = layout.textPayload { - MessageTextView(text: textPayload) - } + /// The source URL when the inline image is parked at the scope-off + /// tap-to-load placeholder. The placeholder carries its own material chrome + /// and sits inside the padded content stack, so this case renders like a + /// text-only bubble (shape fill, no edge-to-edge clip), never as an + /// edge-to-edge `InlineImageFragmentView`. + private var disabledImageURL: URL? { + if case let .disabled(url) = layout.inlineImage?.state { url } else { nil } + } - if !item.envelope.isOutgoing && hasFooter { - BubbleFooterRow(footer: item.footer, dynamicTypeSize: dynamicTypeSize) - } - } - .bubbleContentPadding() + var body: some View { + let stack = VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + if let textPayload = layout.textPayload { + MessageTextView(text: textPayload) + } - if let inlineImage = layout.inlineImage { - InlineImageFragmentView( - inlineImage: inlineImage, - isOutgoing: item.envelope.isOutgoing, - imageResolver: imageResolver, - onTap: { callbacks.onImageTap?() }, - onRetry: { callbacks.onRetryInlineImage?() } - ) - } + if let disabledImageURL { + TapToLoadPreview( + url: disabledImageURL, + isLoading: false, + onTap: { callbacks.onManualPreviewFetch?() } + ) } - if layout.inlineImage == nil { - // Text-only bubbles fill a rounded shape directly. Drawing the - // background as a shape avoids the mask `.clipShape` installs, which - // forces an offscreen render pass per bubble while scrolling. - stack.background(bubbleColor, in: .rect(cornerRadius: Self.cornerRadius)) - } else { - // Image bubbles keep the clip so the edge-to-edge image inherits the - // rounded corners. - stack - .background(bubbleColor) - .clipShape(.rect(cornerRadius: Self.cornerRadius)) + if !item.envelope.isOutgoing, hasFooter { + BubbleFooterRow(footer: item.footer, dynamicTypeSize: dynamicTypeSize) } + } + .bubbleContentPadding() + + if let inlineImage = layout.inlineImage, disabledImageURL == nil { + InlineImageFragmentView( + inlineImage: inlineImage, + isOutgoing: item.envelope.isOutgoing, + imageResolver: imageResolver, + onTap: { callbacks.onImageTap?() }, + onRetry: { callbacks.onRetryInlineImage?() } + ) + } + } + + if layout.inlineImage == nil || disabledImageURL != nil { + // Text-only bubbles (and the scope-off tap-to-load placeholder) fill a + // rounded shape directly. Drawing the background as a shape avoids the + // mask `.clipShape` installs, which forces an offscreen render pass per + // bubble while scrolling. + stack.background(bubbleColor, in: .rect(cornerRadius: Self.cornerRadius)) + } else { + // Image bubbles keep the clip so the edge-to-edge image inherits the + // rounded corners. + stack + .background(bubbleColor) + .clipShape(.rect(cornerRadius: Self.cornerRadius)) } + } } diff --git a/MC1/Views/Chats/Components/BubbleResolver.swift b/MC1/Views/Chats/Components/BubbleResolver.swift index 0e7bf401..4f085382 100644 --- a/MC1/Views/Chats/Components/BubbleResolver.swift +++ b/MC1/Views/Chats/Components/BubbleResolver.swift @@ -1,6 +1,6 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Read-only per-message lookups consumed by `MessageBubbleView`. /// @@ -17,31 +17,31 @@ import MC1Services /// during `rebuildDisplayItem`. @MainActor struct BubbleResolver { - /// Resolves the full `MessageDTO` for a `MessageItem`. Returns `nil` - /// when the message has been deleted out from under the timeline. - let message: (MessageItem) -> MessageDTO? + /// Resolves the full `MessageDTO` for a `MessageItem`. Returns `nil` + /// when the message has been deleted out from under the timeline. + let message: (MessageItem) -> MessageDTO? - /// Resolves a decoded `UIImage` for an `ImageReference` (inline, link - /// preview hero, or link preview icon). - let image: (ImageReference) -> UIImage? + /// Resolves a decoded `UIImage` for an `ImageReference` (inline, link + /// preview hero, or link preview icon). + let image: (ImageReference) -> UIImage? } extension BubbleResolver { - /// Convenience: build a resolver that proxies to a `ChatViewModel`. - /// Per-message reads route through the VM's existing storage. - init(viewModel: ChatViewModel) { - self.init( - message: { item in viewModel.message(for: item) }, - image: { ref in - switch ref.role { - case .inline: - return viewModel.decodedImage(for: ref.cacheKey) - case .linkPreviewImage: - return viewModel.decodedPreviewImage(for: ref.cacheKey) - case .linkPreviewIcon: - return viewModel.decodedPreviewIcon(for: ref.cacheKey) - } - } - ) - } + /// Convenience: build a resolver that proxies to a `ChatViewModel`. + /// Per-message reads route through the VM's existing storage. + init(viewModel: ChatViewModel) { + self.init( + message: { item in viewModel.message(for: item) }, + image: { ref in + switch ref.role { + case .inline: + viewModel.decodedImage(for: ref.cacheKey) + case .linkPreviewImage: + viewModel.decodedPreviewImage(for: ref.cacheKey) + case .linkPreviewIcon: + viewModel.decodedPreviewIcon(for: ref.cacheKey) + } + } + ) + } } diff --git a/MC1/Views/Chats/Components/BubbleStatusRow.swift b/MC1/Views/Chats/Components/BubbleStatusRow.swift index 118ddf2b..52362f50 100644 --- a/MC1/Views/Chats/Components/BubbleStatusRow.swift +++ b/MC1/Views/Chats/Components/BubbleStatusRow.swift @@ -1,96 +1,96 @@ -import SwiftUI import MC1Services +import SwiftUI /// Outgoing-message status row: retry button (when failed), failure glyph, and /// status text. The static `statusText(for:)` helper is also consumed by /// `UnifiedMessageBubble.accessibilityMessageLabel` so VoiceOver surfaces the /// same text as the visual row. struct BubbleStatusRow: View { - let item: MessageItem - let onRetry: (() -> Void)? + let item: MessageItem + let onRetry: (() -> Void)? - @State private var retryInvocationCounter: Int = 0 + @State private var retryInvocationCounter: Int = 0 - var body: some View { - HStack(spacing: 4) { - if item.footer.status == .failed, let onRetry { - Button { - retryInvocationCounter &+= 1 - onRetry() - } label: { - HStack(spacing: 2) { - Image(systemName: "arrow.clockwise") - Text(L10n.Chats.Chats.Message.Status.retry) - } - .font(.caption2) - .contentShape(.rect) - } - .buttonStyle(.plain) - .foregroundStyle(.blue) - .sensoryFeedback(.selection, trigger: retryInvocationCounter) - } + var body: some View { + HStack(spacing: 4) { + if item.footer.status == .failed, let onRetry { + Button { + retryInvocationCounter &+= 1 + onRetry() + } label: { + HStack(spacing: 2) { + Image(systemName: "arrow.clockwise") + Text(L10n.Chats.Chats.Message.Status.retry) + } + .font(.caption2) + .contentShape(.rect) + } + .buttonStyle(.plain) + .foregroundStyle(.blue) + .sensoryFeedback(.selection, trigger: retryInvocationCounter) + } - if item.footer.status == .failed { - Image(systemName: "exclamationmark.circle") - .font(.caption2) - .foregroundStyle(.red) - } + if item.footer.status == .failed { + Image(systemName: "exclamationmark.circle") + .font(.caption2) + .foregroundStyle(.red) + } - Text(Self.statusText(for: item)) - .font(.caption2) - .foregroundStyle(.secondary) - .contentTransition(.opacity) - } - .padding(.trailing, 4) + Text(Self.statusText(for: item)) + .font(.caption2) + .foregroundStyle(.secondary) + .contentTransition(.opacity) } + .padding(.trailing, 4) + } - static func statusText(for item: MessageItem) -> String { - switch item.footer.status { - case .pending, .sending: - return L10n.Chats.Chats.Message.Status.sending - case .sent: - // A DM `.sent` means only that the radio queued the packet, not that it - // was delivered; a missing end-to-end ACK later flips the row to `.failed`. - // Render it as in-progress so the user never sees a settled "Sent" that - // becomes "Failed". A channel `.sent` has no ACK and is terminal success. - guard item.footer.isChannelMessage else { - return L10n.Chats.Chats.Message.Status.sending - } - var parts: [String] = [] - if item.footer.heardRepeats > 0 { - let repeatWord = item.footer.heardRepeats == 1 - ? L10n.Chats.Chats.Message.Repeat.singular - : L10n.Chats.Chats.Message.Repeat.plural - parts.append("\(item.footer.heardRepeats) \(repeatWord)") - } - if item.footer.sendCount > 1 { - parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) - } else { - parts.append(L10n.Chats.Chats.Message.Status.sent) - } - return parts.joined(separator: " • ") - case .delivered: - var parts: [String] = [] - if item.footer.heardRepeats > 0 { - let repeatWord = item.footer.heardRepeats == 1 - ? L10n.Chats.Chats.Message.Repeat.singular - : L10n.Chats.Chats.Message.Repeat.plural - parts.append("\(item.footer.heardRepeats) \(repeatWord)") - } - parts.append(L10n.Chats.Chats.Message.Status.delivered) - if item.footer.sendCount > 1 { - parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) - } - return parts.joined(separator: " • ") - case .failed: - return L10n.Chats.Chats.Message.Status.failed - case .retrying: - let displayAttempt = item.footer.retryAttempt + 1 - let maxAttempts = item.footer.maxRetryAttempts - if maxAttempts > 0 { - return L10n.Chats.Chats.Message.Status.retryingAttempt(displayAttempt, maxAttempts) - } - return L10n.Chats.Chats.Message.Status.retrying - } + static func statusText(for item: MessageItem) -> String { + switch item.footer.status { + case .pending, .sending: + return L10n.Chats.Chats.Message.Status.sending + case .sent: + // A DM `.sent` means only that the radio queued the packet, not that it + // was delivered; a missing end-to-end ACK later flips the row to `.failed`. + // Render it as in-progress so the user never sees a settled "Sent" that + // becomes "Failed". A channel `.sent` has no ACK and is terminal success. + guard item.footer.isChannelMessage else { + return L10n.Chats.Chats.Message.Status.sending + } + var parts: [String] = [] + if item.footer.heardRepeats > 0 { + let repeatWord = item.footer.heardRepeats == 1 + ? L10n.Chats.Chats.Message.Repeat.singular + : L10n.Chats.Chats.Message.Repeat.plural + parts.append("\(item.footer.heardRepeats) \(repeatWord)") + } + if item.footer.sendCount > 1 { + parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) + } else { + parts.append(L10n.Chats.Chats.Message.Status.sent) + } + return parts.joined(separator: " • ") + case .delivered: + var parts: [String] = [] + if item.footer.heardRepeats > 0 { + let repeatWord = item.footer.heardRepeats == 1 + ? L10n.Chats.Chats.Message.Repeat.singular + : L10n.Chats.Chats.Message.Repeat.plural + parts.append("\(item.footer.heardRepeats) \(repeatWord)") + } + parts.append(L10n.Chats.Chats.Message.Status.delivered) + if item.footer.sendCount > 1 { + parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) + } + return parts.joined(separator: " • ") + case .failed: + return L10n.Chats.Chats.Message.Status.failed + case .retrying: + let displayAttempt = item.footer.retryAttempt + 1 + let maxAttempts = item.footer.maxRetryAttempts + if maxAttempts > 0 { + return L10n.Chats.Chats.Message.Status.retryingAttempt(displayAttempt, maxAttempts) + } + return L10n.Chats.Chats.Message.Status.retrying } + } } diff --git a/MC1/Views/Chats/Components/ChatCellContentFactory.swift b/MC1/Views/Chats/Components/ChatCellContentFactory.swift index cd474fb2..c08f1397 100644 --- a/MC1/Views/Chats/Components/ChatCellContentFactory.swift +++ b/MC1/Views/Chats/Components/ChatCellContentFactory.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Builds the SwiftUI body for a chat cell from a `MessageItem`. Owned by /// `ChatMessagesTableView` for the lifetime of the bound `ChatViewModel` @@ -12,36 +12,36 @@ import MC1Services /// `Sendable`; bubble views already run on the main actor. @MainActor struct ChatCellContentFactory { - let contactName: String - let deviceName: String - let configuration: MessageBubbleConfiguration - /// The active theme, injected into each hosted cell. Custom environment values do not - /// auto-cross the `UIHostingConfiguration` boundary, so the bubble fill would otherwise - /// see only `Theme.default`. The factory carries the current theme on every render, but a - /// visible cell adopts a theme change only when it is reloaded — driven by - /// `reconfigureAllItems()`, which the table fires when its tracked theme id changes — or - /// recycled; re-wiring the cell closure alone does not re-host live cells. - let theme: Theme - /// The chat-content link router, injected into each hosted cell. Like `\.appTheme`, the - /// `\.openURL` action does not cross the `UIHostingConfiguration` boundary, so a message - /// link (coordinate, mention, hashtag, contact, channel) would otherwise reach the default - /// system handler and never route through `ChatLinkRouter`. The factory carries the action - /// the surrounding `mentionTapHandling` installed; a live cell adopts a change only on - /// reconfigure or recycle, matching the theme injection. - let openURL: OpenURLAction - let resolver: BubbleResolver - let actions: BubbleActions + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + /// The active theme, injected into each hosted cell. Custom environment values do not + /// auto-cross the `UIHostingConfiguration` boundary, so the bubble fill would otherwise + /// see only `Theme.default`. The factory carries the current theme on every render, but a + /// visible cell adopts a theme change only when it is reloaded — driven by + /// `reconfigureAllItems()`, which the table fires when its tracked theme id changes — or + /// recycled; re-wiring the cell closure alone does not re-host live cells. + let theme: Theme + /// The chat-content link router, injected into each hosted cell. Like `\.appTheme`, the + /// `\.openURL` action does not cross the `UIHostingConfiguration` boundary, so a message + /// link (coordinate, mention, hashtag, contact, channel) would otherwise reach the default + /// system handler and never route through `ChatLinkRouter`. The factory carries the action + /// the surrounding `mentionTapHandling` installed; a live cell adopts a change only on + /// reconfigure or recycle, matching the theme injection. + let openURL: OpenURLAction + let resolver: BubbleResolver + let actions: BubbleActions - func makeContent(for item: MessageItem) -> some View { - MessageBubbleView( - item: item, - contactName: contactName, - deviceName: deviceName, - configuration: configuration, - resolver: resolver, - actions: actions - ) - .environment(\.appTheme, theme) - .environment(\.openURL, openURL) - } + func makeContent(for item: MessageItem) -> some View { + MessageBubbleView( + item: item, + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + resolver: resolver, + actions: actions + ) + .environment(\.appTheme, theme) + .environment(\.openURL, openURL) + } } diff --git a/MC1/Views/Chats/Components/ChatComposerProxy.swift b/MC1/Views/Chats/Components/ChatComposerProxy.swift index d512d640..660e290a 100644 --- a/MC1/Views/Chats/Components/ChatComposerProxy.swift +++ b/MC1/Views/Chats/Components/ChatComposerProxy.swift @@ -5,9 +5,9 @@ import UIKit /// send. `ChatComposerTextView` wires its text view in on creation. @MainActor final class ChatComposerProxy { - weak var textView: ChatComposerUITextView? + weak var textView: ChatComposerUITextView? - func commitPendingInput() { - textView?.commitPendingInput() - } + func commitPendingInput() { + textView?.commitPendingInput() + } } diff --git a/MC1/Views/Chats/Components/ChatComposerTextView.swift b/MC1/Views/Chats/Components/ChatComposerTextView.swift index a7ff61fc..69c168f8 100644 --- a/MC1/Views/Chats/Components/ChatComposerTextView.swift +++ b/MC1/Views/Chats/Components/ChatComposerTextView.swift @@ -20,217 +20,219 @@ import UIKit /// `maxVisibleLines`, so the field wraps to the offered width and grows downward /// instead of stretching the input bar. struct ChatComposerTextView: UIViewRepresentable { - @Binding var text: String - /// Incremented by the parent to request focus; compared against the - /// coordinator's last-applied value so each request fires exactly once. - let focusRequest: Int - let isEncrypted: Bool - /// Receives the text view on creation so the parent can finalize IME - /// composition before reading the text to send. - let proxy: ChatComposerProxy - /// Attempts a send. Returns `true` when a message was sent (Return is then - /// consumed and focus retained), `false` when gated off (Return inserts a - /// newline instead). - let onSend: () -> Bool - - func makeUIView(context: Context) -> ChatComposerUITextView { - let textView = ChatComposerUITextView(usingTextLayoutManager: false) - textView.delegate = context.coordinator - textView.onSend = onSend - textView.font = UIFont.preferredFont(forTextStyle: .body) - textView.adjustsFontForContentSizeCategory = true - textView.backgroundColor = .clear - textView.inlinePredictionType = .default - // Keep scrolling enabled at all sizes. When it is disabled, UITextView's - // private scroll-to-visible adjusts the containing scroll view instead of - // itself, which throws the caret outside the field while it collapses after - // a send. Height is driven by `sizeThatFits`, so the view only ever scrolls - // its own content once it exceeds the visible-line cap. - textView.isScrollEnabled = true - textView.alwaysBounceVertical = false - textView.textContainerInset = UIEdgeInsets( - top: ChatComposerUITextView.verticalInset, - left: 0, - bottom: ChatComposerUITextView.verticalInset, - right: 0 - ) - textView.textContainer.lineFragmentPadding = 0 - textView.setContentHuggingPriority(.defaultLow, for: .horizontal) - textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - proxy.textView = textView - - textView.accessibilityLabel = L10n.Chats.Chats.Input.accessibilityLabel - textView.accessibilityHint = L10n.Chats.Chats.Input.accessibilityHint - return textView + @Binding var text: String + /// Incremented by the parent to request focus; compared against the + /// coordinator's last-applied value so each request fires exactly once. + let focusRequest: Int + let isEncrypted: Bool + /// Receives the text view on creation so the parent can finalize IME + /// composition before reading the text to send. + let proxy: ChatComposerProxy + /// Attempts a send. Returns `true` when a message was sent (Return is then + /// consumed and focus retained), `false` when gated off (Return inserts a + /// newline instead). + let onSend: () -> Bool + + func makeUIView(context: Context) -> ChatComposerUITextView { + let textView = ChatComposerUITextView(usingTextLayoutManager: false) + textView.delegate = context.coordinator + textView.onSend = onSend + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.adjustsFontForContentSizeCategory = true + textView.backgroundColor = .clear + textView.inlinePredictionType = .default + // Keep scrolling enabled at all sizes. When it is disabled, UITextView's + // private scroll-to-visible adjusts the containing scroll view instead of + // itself, which throws the caret outside the field while it collapses after + // a send. Height is driven by `sizeThatFits`, so the view only ever scrolls + // its own content once it exceeds the visible-line cap. + textView.isScrollEnabled = true + textView.alwaysBounceVertical = false + textView.textContainerInset = UIEdgeInsets( + top: ChatComposerUITextView.verticalInset, + left: 0, + bottom: ChatComposerUITextView.verticalInset, + right: 0 + ) + textView.textContainer.lineFragmentPadding = 0 + textView.setContentHuggingPriority(.defaultLow, for: .horizontal) + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + proxy.textView = textView + + textView.accessibilityLabel = L10n.Chats.Chats.Input.accessibilityLabel + textView.accessibilityHint = L10n.Chats.Chats.Input.accessibilityHint + return textView + } + + func updateUIView(_ textView: ChatComposerUITextView, context: Context) { + context.coordinator.parent = self + textView.onSend = onSend + textView.accessibilityValue = isEncrypted + ? L10n.Chats.Chats.Input.encrypted + : L10n.Chats.Chats.Input.notEncrypted + + if textView.text != text { + if text.isEmpty { + textView.clearAfterSend() + } else { + textView.text = text + } } - func updateUIView(_ textView: ChatComposerUITextView, context: Context) { - context.coordinator.parent = self - textView.onSend = onSend - textView.accessibilityValue = isEncrypted - ? L10n.Chats.Chats.Input.encrypted - : L10n.Chats.Chats.Input.notEncrypted - - if textView.text != text { - if text.isEmpty { - textView.clearAfterSend() - } else { - textView.text = text - } - } - - // Become first responder once per token increment. There is no resign - // path, so native dismissal is left untouched. - if focusRequest != context.coordinator.lastFocusRequest { - context.coordinator.lastFocusRequest = focusRequest - if !textView.isFirstResponder { - Task { @MainActor in - guard textView.window != nil else { return } - textView.becomeFirstResponder() - } - } + // Become first responder once per token increment. There is no resign + // path, so native dismissal is left untouched. + if focusRequest != context.coordinator.lastFocusRequest { + context.coordinator.lastFocusRequest = focusRequest + if !textView.isFirstResponder { + Task { @MainActor in + guard textView.window != nil else { return } + textView.becomeFirstResponder() } + } } + } - func sizeThatFits(_ proposal: ProposedViewSize, uiView: ChatComposerUITextView, context: Context) -> CGSize? { - if let width = proposal.width, width.isFinite, width > 0 { - return CGSize(width: width, height: uiView.clampedHeight(forWidth: width)) - } - // Ideal/unbounded query: claim minimal width so the surrounding HStack - // treats the field as fully flexible and hands it the leftover width, - // rather than stretching to the text's single-line content width. - return CGSize(width: 0, height: uiView.clampedHeight(forWidth: max(uiView.bounds.width, 1))) + func sizeThatFits(_ proposal: ProposedViewSize, uiView: ChatComposerUITextView, context: Context) -> CGSize? { + if let width = proposal.width, width.isFinite, width > 0 { + return CGSize(width: width, height: uiView.clampedHeight(forWidth: width)) + } + // Ideal/unbounded query: claim minimal width so the surrounding HStack + // treats the field as fully flexible and hands it the leftover width, + // rather than stretching to the text's single-line content width. + return CGSize(width: 0, height: uiView.clampedHeight(forWidth: max(uiView.bounds.width, 1))) + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + @MainActor + final class Coordinator: NSObject, UITextViewDelegate { + var parent: ChatComposerTextView + /// Last `focusRequest` value acted on, so a request fires only once. + var lastFocusRequest: Int + + init(_ parent: ChatComposerTextView) { + self.parent = parent + lastFocusRequest = parent.focusRequest } - func makeCoordinator() -> Coordinator { Coordinator(self) } - - @MainActor - final class Coordinator: NSObject, UITextViewDelegate { - var parent: ChatComposerTextView - /// Last `focusRequest` value acted on, so a request fires only once. - var lastFocusRequest: Int - - init(_ parent: ChatComposerTextView) { - self.parent = parent - self.lastFocusRequest = parent.focusRequest - } - - func textViewDidChange(_ textView: UITextView) { - // Skip the redundant write when the text already matches the binding. - // The post-send clear edits the field from inside `updateUIView`, which - // fires this delegate synchronously; writing the binding mid-update is - // disallowed, and the value is already empty there, so guarding avoids it. - if parent.text != textView.text { - parent.text = textView.text - } - } + func textViewDidChange(_ textView: UITextView) { + // Skip the redundant write when the text already matches the binding. + // The post-send clear edits the field from inside `updateUIView`, which + // fires this delegate synchronously; writing the binding mid-update is + // disallowed, and the value is already empty there, so guarding avoids it. + if parent.text != textView.text { + parent.text = textView.text + } } + } } /// `UITextView` subclass that sends on an unmodified hardware Return and measures a /// wrapped height from one line up to `maxVisibleLines`, scrolling beyond that. final class ChatComposerUITextView: UITextView { - static let verticalInset: CGFloat = 8 - static let maxVisibleLines = 5 - - /// Key-command input for a hardware Return. - private static let returnInput = "\r" - /// Key-command input for a physical numpad Enter (Mac). - private static let numpadEnterInput = "\u{3}" - - var onSend: (() -> Bool)? - - /// Clears the field after a send through the text-input editing path. - /// - /// Assigning `text = ""` runs a private caret-reset that lives in the text - /// input system, outside `UIView`/`CATransaction` control, so it can't be - /// suppressed by `performWithoutAnimation`. Replacing the full range instead - /// moves the caret as a discrete edit, the same as deleting, which keeps it - /// from animating to the start as the field collapses. `unmarkText` first - /// commits any in-progress IME composition so the replace deletes everything. - /// - /// The `inputDelegate` notifications bracket the edit so the keyboard resyncs - /// its prediction context to the empty document; otherwise the edit bypasses the - /// text-input pipeline and leaves stale ghost-text or extends the last sent word. - func clearAfterSend() { - inputDelegate?.textWillChange(self) - inputDelegate?.selectionWillChange(self) - unmarkText() - if let fullRange = textRange(from: beginningOfDocument, to: endOfDocument) { - replace(fullRange, withText: "") - } - inputDelegate?.selectionDidChange(self) - inputDelegate?.textDidChange(self) - contentOffset = .zero - } - - /// Commits a marked IME composition and any pending autocorrect so a send - /// captures what the field shows. The input-delegate notifications flush the - /// autocorrect candidate; `unmarkText` commits the composition. Both are - /// synchronous, so the bound text is current the moment this returns. - func commitPendingInput() { - guard isFirstResponder else { return } - inputDelegate?.selectionWillChange(self) - inputDelegate?.selectionDidChange(self) - unmarkText() + static let verticalInset: CGFloat = 8 + static let maxVisibleLines = 5 + + /// Key-command input for a hardware Return. + private static let returnInput = "\r" + /// Key-command input for a physical numpad Enter (Mac). + private static let numpadEnterInput = "\u{3}" + + var onSend: (() -> Bool)? + + /// Clears the field after a send through the text-input editing path. + /// + /// Assigning `text = ""` runs a private caret-reset that lives in the text + /// input system, outside `UIView`/`CATransaction` control, so it can't be + /// suppressed by `performWithoutAnimation`. Replacing the full range instead + /// moves the caret as a discrete edit, the same as deleting, which keeps it + /// from animating to the start as the field collapses. `unmarkText` first + /// commits any in-progress IME composition so the replace deletes everything. + /// + /// The `inputDelegate` notifications bracket the edit so the keyboard resyncs + /// its prediction context to the empty document; otherwise the edit bypasses the + /// text-input pipeline and leaves stale ghost-text or extends the last sent word. + func clearAfterSend() { + inputDelegate?.textWillChange(self) + inputDelegate?.selectionWillChange(self) + unmarkText() + if let fullRange = textRange(from: beginningOfDocument, to: endOfDocument) { + replace(fullRange, withText: "") } - - private var lineHeight: CGFloat { - (font ?? UIFont.preferredFont(forTextStyle: .body)).lineHeight - } - - private var minHeight: CGFloat { - ceil(lineHeight) + textContainerInset.top + textContainerInset.bottom - } - - private var maxHeight: CGFloat { - ceil(lineHeight * CGFloat(Self.maxVisibleLines)) + textContainerInset.top + textContainerInset.bottom - } - - /// Height the text needs when wrapped to `width`, clamped to the visible-line - /// range, enabling internal scrolling once the text exceeds the cap. - func clampedHeight(forWidth width: CGFloat) -> CGFloat { - let innerWidth = max(1, width - textContainerInset.left - textContainerInset.right) - let measuringFont = font ?? UIFont.preferredFont(forTextStyle: .body) - let used = (text as NSString).boundingRect( - with: CGSize(width: innerWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: measuringFont], - context: nil - ).height - let full = ceil(used) + textContainerInset.top + textContainerInset.bottom - return min(max(full, minHeight), maxHeight) - } - - override var keyCommands: [UIKeyCommand]? { - // Register nothing during IME composition so Return commits the candidate. Shift - // and Option Return get explicit newline commands; without them the unmodified - // command's priority captures the modified press and would send. - guard markedTextRange == nil else { return nil } - let action = #selector(handleReturnCommand(_:)) - let commands = [ - UIKeyCommand(input: Self.returnInput, modifierFlags: [], action: action), - UIKeyCommand(input: Self.numpadEnterInput, modifierFlags: [], action: action), - UIKeyCommand(input: Self.returnInput, modifierFlags: .shift, action: action), - UIKeyCommand(input: Self.returnInput, modifierFlags: .alternate, action: action) - ] - commands.forEach { $0.wantsPriorityOverSystemBehavior = true } - return commands + inputDelegate?.selectionDidChange(self) + inputDelegate?.textDidChange(self) + contentOffset = .zero + } + + /// Commits a marked IME composition and any pending autocorrect so a send + /// captures what the field shows. The input-delegate notifications flush the + /// autocorrect candidate; `unmarkText` commits the composition. Both are + /// synchronous, so the bound text is current the moment this returns. + func commitPendingInput() { + guard isFirstResponder else { return } + inputDelegate?.selectionWillChange(self) + inputDelegate?.selectionDidChange(self) + unmarkText() + } + + private var lineHeight: CGFloat { + (font ?? UIFont.preferredFont(forTextStyle: .body)).lineHeight + } + + private var minHeight: CGFloat { + ceil(lineHeight) + textContainerInset.top + textContainerInset.bottom + } + + private var maxHeight: CGFloat { + ceil(lineHeight * CGFloat(Self.maxVisibleLines)) + textContainerInset.top + textContainerInset.bottom + } + + /// Height the text needs when wrapped to `width`, clamped to the visible-line + /// range, enabling internal scrolling once the text exceeds the cap. + func clampedHeight(forWidth width: CGFloat) -> CGFloat { + let innerWidth = max(1, width - textContainerInset.left - textContainerInset.right) + let measuringFont = font ?? UIFont.preferredFont(forTextStyle: .body) + let used = (text as NSString).boundingRect( + with: CGSize(width: innerWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: measuringFont], + context: nil + ).height + let full = ceil(used) + textContainerInset.top + textContainerInset.bottom + return min(max(full, minHeight), maxHeight) + } + + override var keyCommands: [UIKeyCommand]? { + // Register nothing during IME composition so Return commits the candidate. Shift + // and Option Return get explicit newline commands; without them the unmodified + // command's priority captures the modified press and would send. + guard markedTextRange == nil else { return nil } + let action = #selector(handleReturnCommand(_:)) + let commands = [ + UIKeyCommand(input: Self.returnInput, modifierFlags: [], action: action), + UIKeyCommand(input: Self.numpadEnterInput, modifierFlags: [], action: action), + UIKeyCommand(input: Self.returnInput, modifierFlags: .shift, action: action), + UIKeyCommand(input: Self.returnInput, modifierFlags: .alternate, action: action) + ] + commands.forEach { $0.wantsPriorityOverSystemBehavior = true } + return commands + } + + @objc private func handleReturnCommand(_ command: UIKeyCommand) { + // A live composition takes Return as a candidate commit, never a send or + // newline. Re-checked here because the command list is built ahead of + // dispatch and may be served from before composition began. + guard markedTextRange == nil else { + unmarkText() + return } - - @objc private func handleReturnCommand(_ command: UIKeyCommand) { - // A live composition takes Return as a candidate commit, never a send or - // newline. Re-checked here because the command list is built ahead of - // dispatch and may be served from before composition began. - guard markedTextRange == nil else { - unmarkText() - return - } - // Shift+Return and Option+Return insert a newline; an unmodified Return or - // numpad Enter sends, falling back to a newline when the send is gated off. - let insertsNewline = !command.modifierFlags.isDisjoint(with: [.shift, .alternate]) - if insertsNewline || onSend?() != true { - insertText("\n") - } + // Shift+Return and Option+Return insert a newline; an unmodified Return or + // numpad Enter sends, falling back to a newline when the send is gated off. + let insertsNewline = !command.modifierFlags.isDisjoint(with: [.shift, .alternate]) + if insertsNewline || onSend?() != true { + insertText("\n") } + } } diff --git a/MC1/Views/Chats/Components/ChatInputBar.swift b/MC1/Views/Chats/Components/ChatInputBar.swift index 38788b38..e1a15bad 100644 --- a/MC1/Views/Chats/Components/ChatInputBar.swift +++ b/MC1/Views/Chats/Components/ChatInputBar.swift @@ -1,313 +1,312 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Reusable chat input bar with configurable styling struct ChatInputBar: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Binding var text: String - /// Focus-request token forwarded to the composer; see `ChatComposerTextView`. - let focusRequest: Int - let placeholder: String - let maxBytes: Int - let isEncrypted: Bool - @ViewBuilder let leading: () -> Leading - let onSend: (String) -> Void - - @State private var isCoolingDown = false - @State private var sendInvocationCounter: Int = 0 - @State private var composerProxy = ChatComposerProxy() - - private var byteCount: Int { - text.utf8.count - } - - private var isOverLimit: Bool { - byteCount > maxBytes - } - - private var shouldShowCharacterCount: Bool { - // Show when within 20 bytes of limit or over limit - byteCount >= maxBytes - 20 - } - - var body: some View { - HStack(alignment: .bottom, spacing: 12) { - leading() - ChatInputTextField( - text: $text, - placeholder: placeholder, - focusRequest: focusRequest, - isEncrypted: isEncrypted, - proxy: composerProxy, - onSend: handleHardwareSend - ) - ChatSendButtonWithCounter( - canSend: canSend, - isOverLimit: isOverLimit, - shouldShowCharacterCount: shouldShowCharacterCount, - byteCount: byteCount, - maxBytes: maxBytes, - sendAccessibilityLabel: sendAccessibilityLabel, - sendAccessibilityHint: sendAccessibilityHint, - onSend: send - ) - } - .padding(.horizontal) - .padding(.vertical, 8) - .inputBarBackground(themedCanvas: theme.surfaces?.canvas) - .sensoryFeedback(.start, trigger: sendInvocationCounter) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Binding var text: String + /// Focus-request token forwarded to the composer; see `ChatComposerTextView`. + let focusRequest: Int + let placeholder: String + let maxBytes: Int + let isEncrypted: Bool + @ViewBuilder let leading: () -> Leading + let onSend: (String) -> Void + + @State private var isCoolingDown = false + @State private var sendInvocationCounter: Int = 0 + @State private var composerProxy = ChatComposerProxy() + + private var byteCount: Int { + text.utf8.count + } + + private var isOverLimit: Bool { + byteCount > maxBytes + } + + private var shouldShowCharacterCount: Bool { + // Show when within 20 bytes of limit or over limit + byteCount >= maxBytes - 20 + } + + var body: some View { + HStack(alignment: .bottom, spacing: 12) { + leading() + ChatInputTextField( + text: $text, + placeholder: placeholder, + focusRequest: focusRequest, + isEncrypted: isEncrypted, + proxy: composerProxy, + onSend: handleHardwareSend + ) + ChatSendButtonWithCounter( + canSend: canSend, + isOverLimit: isOverLimit, + shouldShowCharacterCount: shouldShowCharacterCount, + byteCount: byteCount, + maxBytes: maxBytes, + sendAccessibilityLabel: sendAccessibilityLabel, + sendAccessibilityHint: sendAccessibilityHint, + onSend: send + ) } - - private var sendAccessibilityLabel: String { - if isOverLimit { - return L10n.Chats.Chats.Input.tooLong - } else { - return L10n.Chats.Chats.Input.sendMessage - } + .padding(.horizontal) + .padding(.vertical, 8) + .inputBarBackground(themedCanvas: theme.surfaces?.canvas) + .sensoryFeedback(.start, trigger: sendInvocationCounter) + } + + private var sendAccessibilityLabel: String { + if isOverLimit { + L10n.Chats.Chats.Input.tooLong + } else { + L10n.Chats.Chats.Input.sendMessage } - - private var sendAccessibilityHint: String { - if isOverLimit { - return L10n.Chats.Chats.Input.removeCharacters(byteCount - maxBytes) - } else if appState.connectionState != .ready { - return L10n.Chats.Chats.Input.requiresConnection - } else if canSend { - return L10n.Chats.Chats.Input.tapToSend - } else { - return L10n.Chats.Chats.Input.typeFirst - } + } + + private var sendAccessibilityHint: String { + if isOverLimit { + L10n.Chats.Chats.Input.removeCharacters(byteCount - maxBytes) + } else if appState.connectionState != .ready { + L10n.Chats.Chats.Input.requiresConnection + } else if canSend { + L10n.Chats.Chats.Input.tapToSend + } else { + L10n.Chats.Chats.Input.typeFirst } - - private var canSend: Bool { - !isCoolingDown && - appState.connectionState == .ready && - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isOverLimit - } - - /// Sends in response to an unmodified hardware Return from the composer, - /// honoring the same gating as the send button. Returns `true` when a message - /// was sent so the composer consumes the Return; `false` when gated off so the - /// composer inserts a newline instead. The composer keeps focus on its own, so - /// no re-focus is needed here. - private func handleHardwareSend() -> Bool { - guard canSend else { return false } - send() - return true - } - - private func send() { - composerProxy.commitPendingInput() - let captured = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !captured.isEmpty else { return } - isCoolingDown = true - text = "" - sendInvocationCounter &+= 1 - onSend(captured) - Task { - try? await Task.sleep(for: .seconds(1)) - isCoolingDown = false - } + } + + private var canSend: Bool { + !isCoolingDown && + appState.connectionState == .ready && + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isOverLimit + } + + /// Sends in response to an unmodified hardware Return from the composer, + /// honoring the same gating as the send button. Returns `true` when a message + /// was sent so the composer consumes the Return; `false` when gated off so the + /// composer inserts a newline instead. The composer keeps focus on its own, so + /// no re-focus is needed here. + private func handleHardwareSend() -> Bool { + guard canSend else { return false } + send() + return true + } + + private func send() { + composerProxy.commitPendingInput() + let captured = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !captured.isEmpty else { return } + isCoolingDown = true + text = "" + sendInvocationCounter &+= 1 + onSend(captured) + Task { + try? await Task.sleep(for: .seconds(1)) + isCoolingDown = false } + } } extension ChatInputBar where Leading == EmptyView { - /// Builds an input bar with no leading accessory, preserving the original - /// call sites that pass only a trailing `onSend` closure. - init( - text: Binding, - focusRequest: Int, - placeholder: String, - maxBytes: Int, - isEncrypted: Bool, - onSend: @escaping (String) -> Void - ) { - self.init( - text: text, - focusRequest: focusRequest, - placeholder: placeholder, - maxBytes: maxBytes, - isEncrypted: isEncrypted, - leading: { EmptyView() }, - onSend: onSend - ) - } + /// Builds an input bar with no leading accessory, preserving the original + /// call sites that pass only a trailing `onSend` closure. + init( + text: Binding, + focusRequest: Int, + placeholder: String, + maxBytes: Int, + isEncrypted: Bool, + onSend: @escaping (String) -> Void + ) { + self.init( + text: text, + focusRequest: focusRequest, + placeholder: placeholder, + maxBytes: maxBytes, + isEncrypted: isEncrypted, + leading: { EmptyView() }, + onSend: onSend + ) + } } // MARK: - Extracted Views private struct ChatInputTextField: View { - @Binding var text: String - let placeholder: String - let focusRequest: Int - let isEncrypted: Bool - let proxy: ChatComposerProxy - let onSend: () -> Bool - - var body: some View { - ChatComposerTextView( - text: $text, - focusRequest: focusRequest, - isEncrypted: isEncrypted, - proxy: proxy, - onSend: onSend - ) - .frame(maxWidth: .infinity) - .overlay(alignment: .topLeading) { - if text.isEmpty { - Text(placeholder) - .font(.body) - .foregroundStyle(Color(uiColor: .placeholderText)) - .padding(.top, ChatComposerUITextView.verticalInset) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - } - .padding(.leading, 12) - .padding(.trailing, 28) - .overlay(alignment: .trailing) { - Image(systemName: isEncrypted ? "lock.fill" : "lock.open.fill") - .font(.footnote) - .foregroundStyle(isEncrypted ? .blue : .orange) - .padding(.trailing, 10) - .accessibilityHidden(true) - } - .textFieldBackground() + @Binding var text: String + let placeholder: String + let focusRequest: Int + let isEncrypted: Bool + let proxy: ChatComposerProxy + let onSend: () -> Bool + + var body: some View { + ChatComposerTextView( + text: $text, + focusRequest: focusRequest, + isEncrypted: isEncrypted, + proxy: proxy, + onSend: onSend + ) + .frame(maxWidth: .infinity) + .overlay(alignment: .topLeading) { + if text.isEmpty { + Text(placeholder) + .font(.body) + .foregroundStyle(Color(uiColor: .placeholderText)) + .padding(.top, ChatComposerUITextView.verticalInset) + .allowsHitTesting(false) + .accessibilityHidden(true) + } } + .padding(.leading, 12) + .padding(.trailing, 28) + .overlay(alignment: .trailing) { + Image(systemName: isEncrypted ? "lock.fill" : "lock.open.fill") + .font(.footnote) + .foregroundStyle(isEncrypted ? .blue : .orange) + .padding(.trailing, 10) + .accessibilityHidden(true) + } + .textFieldBackground() + } } private struct ChatSendButtonWithCounter: View { - let canSend: Bool - let isOverLimit: Bool - let shouldShowCharacterCount: Bool - let byteCount: Int - let maxBytes: Int - let sendAccessibilityLabel: String - let sendAccessibilityHint: String - let onSend: () -> Void - - var body: some View { - VStack(spacing: 4) { - ChatSendButton( - canSend: canSend, - sendAccessibilityLabel: sendAccessibilityLabel, - sendAccessibilityHint: sendAccessibilityHint, - onSend: onSend - ) - if shouldShowCharacterCount { - ChatCharacterCountLabel( - byteCount: byteCount, - maxBytes: maxBytes, - isOverLimit: isOverLimit - ) - } - } + let canSend: Bool + let isOverLimit: Bool + let shouldShowCharacterCount: Bool + let byteCount: Int + let maxBytes: Int + let sendAccessibilityLabel: String + let sendAccessibilityHint: String + let onSend: () -> Void + + var body: some View { + VStack(spacing: 4) { + ChatSendButton( + canSend: canSend, + sendAccessibilityLabel: sendAccessibilityLabel, + sendAccessibilityHint: sendAccessibilityHint, + onSend: onSend + ) + if shouldShowCharacterCount { + ChatCharacterCountLabel( + byteCount: byteCount, + maxBytes: maxBytes, + isOverLimit: isOverLimit + ) + } } + } } private struct ChatCharacterCountLabel: View { - let byteCount: Int - let maxBytes: Int - let isOverLimit: Bool - - var body: some View { - Text("\(byteCount)/\(maxBytes)") - .font(.caption2) - .monospacedDigit() - .foregroundStyle(isOverLimit ? .red : .secondary) - .accessibilityLabel(L10n.Chats.Chats.Input.characterCount(byteCount, maxBytes)) - } + let byteCount: Int + let maxBytes: Int + let isOverLimit: Bool + + var body: some View { + Text("\(byteCount)/\(maxBytes)") + .font(.caption2) + .monospacedDigit() + .foregroundStyle(isOverLimit ? .red : .secondary) + .accessibilityLabel(L10n.Chats.Chats.Input.characterCount(byteCount, maxBytes)) + } } private struct ChatSendButton: View { - let canSend: Bool - let sendAccessibilityLabel: String - let sendAccessibilityHint: String - let onSend: () -> Void - - @Environment(\.appTheme) private var theme - - private var sendButtonFont: Font { - if #available(iOS 26.0, *) { .title2 } else { .title } - } - - var body: some View { - Button(action: onSend) { - Image(systemName: "arrow.up.circle.fill") - .font(sendButtonFont) - .foregroundStyle(canSend ? theme.accentColor : .secondary) - } - .sendButtonStyle() - .disabled(!canSend) - .accessibilityLabel(sendAccessibilityLabel) - .accessibilityHint(sendAccessibilityHint) + let canSend: Bool + let sendAccessibilityLabel: String + let sendAccessibilityHint: String + let onSend: () -> Void + + @Environment(\.appTheme) private var theme + + private var sendButtonFont: Font { + if #available(iOS 26.0, *) { .title2 } else { .title } + } + + var body: some View { + Button(action: onSend) { + Image(systemName: "arrow.up.circle.fill") + .font(sendButtonFont) + .foregroundStyle(canSend ? theme.accentColor : .secondary) } + .sendButtonStyle() + .disabled(!canSend) + .accessibilityLabel(sendAccessibilityLabel) + .accessibilityHint(sendAccessibilityHint) + } } // MARK: - Platform-Conditional Styling private extension View { - @ViewBuilder - func sendButtonStyle() -> some View { - if #available(iOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.padding(.vertical, 4) - } + @ViewBuilder + func sendButtonStyle() -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.glass) + } else { + padding(.vertical, 4) } - - @ViewBuilder - func textFieldBackground() -> some View { - if #available(iOS 26.0, *) { - self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 20)) - } else { - self - .background(Color(.systemGray6)) - .clipShape(.rect(cornerRadius: 20)) - } + } + + @ViewBuilder + func textFieldBackground() -> some View { + if #available(iOS 26.0, *) { + glassEffect(.regular.interactive(), in: .rect(cornerRadius: 20)) + } else { + background(Color(.systemGray6)) + .clipShape(.rect(cornerRadius: 20)) } - - @ViewBuilder - func inputBarBackground(themedCanvas: Color?) -> some View { - if let themedCanvas { - self.background(themedCanvas) - } else if #available(iOS 26.0, *) { - self - } else { - self.background(.bar) - } + } + + @ViewBuilder + func inputBarBackground(themedCanvas: Color?) -> some View { + if let themedCanvas { + background(themedCanvas) + } else if #available(iOS 26.0, *) { + self + } else { + background(.bar) } + } } // MARK: - Preview private struct ChatInputBarPreviewHost: View { - @State private var plainText = "" - @State private var leadingText = "" - - var body: some View { - VStack(spacing: 24) { - ChatInputBar( - text: $plainText, - focusRequest: 0, - placeholder: "No leading accessory", - maxBytes: 140, - isEncrypted: true - ) { _ in } - - ChatInputBar( - text: $leadingText, - focusRequest: 0, - placeholder: "With leading accessory", - maxBytes: 140, - isEncrypted: false, - leading: { Image(systemName: "plus") } - ) { _ in } - } + @State private var plainText = "" + @State private var leadingText = "" + + var body: some View { + VStack(spacing: 24) { + ChatInputBar( + text: $plainText, + focusRequest: 0, + placeholder: "No leading accessory", + maxBytes: 140, + isEncrypted: true + ) { _ in } + + ChatInputBar( + text: $leadingText, + focusRequest: 0, + placeholder: "With leading accessory", + maxBytes: 140, + isEncrypted: false, + leading: { Image(systemName: "plus") } + ) { _ in } } + } } #Preview { - ChatInputBarPreviewHost() + ChatInputBarPreviewHost() } diff --git a/MC1/Views/Chats/Components/ChatMessagesTableView.swift b/MC1/Views/Chats/Components/ChatMessagesTableView.swift index c249d158..6841654d 100644 --- a/MC1/Views/Chats/Components/ChatMessagesTableView.swift +++ b/MC1/Views/Chats/Components/ChatMessagesTableView.swift @@ -1,165 +1,169 @@ -import SwiftUI import MC1Services +import SwiftUI /// Messages table with ChatTableView, overlay scroll buttons, and divider state management struct ChatMessagesTableView: View { - @Bindable var viewModel: ChatViewModel - let contactName: String - let deviceName: String - let configuration: MessageBubbleConfiguration - let recentEmojisStore: RecentEmojisStore - let envInputs: EnvInputs + @Bindable var viewModel: ChatViewModel + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + let recentEmojisStore: RecentEmojisStore + let envInputs: EnvInputs - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - @Binding var scrollToMentionRequest: Int - @Binding var scrollToDividerRequest: Int - @Binding var isDividerVisible: Bool - @Binding var selectedMessageForActions: MessageDTO? - @Binding var imageViewerData: ImageViewerData? + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + @Binding var scrollToMentionRequest: Int + @Binding var scrollToDividerRequest: Int + @Binding var isDividerVisible: Bool + @Binding var selectedMessageForActions: MessageDTO? + @Binding var imageViewerData: ImageViewerData? - let unseenMentionIDs: [UUID] - @Binding var offscreenMentionIDs: [UUID] - let scrollToTargetID: UUID? - let newMessagesDividerMessageID: UUID? - let onMentionSeen: (UUID) async -> Bool - let onScrollToMention: () -> Void - let onRetryMessage: (MessageDTO) -> Void + let unseenMentionIDs: [UUID] + @Binding var offscreenMentionIDs: [UUID] + let scrollToTargetID: UUID? + let newMessagesDividerMessageID: UUID? + let onMentionSeen: (UUID) async -> Bool + let onScrollToMention: () -> Void + let onRetryMessage: (MessageDTO) -> Void - @State private var hasDismissedDividerButton = false - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.openURL) private var openURL + @State private var hasDismissedDividerButton = false + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.openURL) private var openURL - private var showDividerButton: Bool { - newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerButton - } + private var showDividerButton: Bool { + newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerButton + } - var body: some View { - let mentionIDSet = Set(unseenMentionIDs) - let factory = ChatCellContentFactory( - contactName: contactName, - deviceName: deviceName, - configuration: configuration, - theme: theme, - openURL: openURL, - resolver: BubbleResolver(viewModel: viewModel), - actions: BubbleActions( - onRetryMessage: onRetryMessage, - onReaction: { emoji, message in - recentEmojisStore.recordUsage(emoji) - Task { await viewModel.sendReaction(emoji: emoji, to: message) } - }, - onLongPress: { message in selectedMessageForActions = message }, - onImageTap: { message in - if let data = viewModel.imageData(for: message.id) { - imageViewerData = ImageViewerData( - imageData: data, - isGIF: viewModel.isGIFImage(for: message.id) - ) - } - }, - onRetryInlineImage: { messageID in - Task { await viewModel.retryImageFetch(for: messageID) } - }, - onRequestPreviewFetch: { messageID in - if viewModel.shouldRequestImageFetch(for: messageID) { - viewModel.requestImageFetch(for: messageID) - } else { - viewModel.requestPreviewFetch(for: messageID) - } - }, - onManualPreviewFetch: { messageID in - Task { await viewModel.manualFetchPreview(for: messageID) } - }, - onMapPreviewTap: { coordinate in - viewModel.navigateToMap(coordinate) - }, - snapshotResolver: { MapSnapshotStore.shared.image(for: $0) }, - requestSnapshot: { MapSnapshotStore.shared.request($0) }, - retrySnapshot: { MapSnapshotStore.shared.retry($0) } + var body: some View { + let mentionIDSet = Set(unseenMentionIDs) + let factory = ChatCellContentFactory( + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + theme: theme, + openURL: openURL, + resolver: BubbleResolver(viewModel: viewModel), + actions: BubbleActions( + onRetryMessage: onRetryMessage, + onReaction: { emoji, message in + recentEmojisStore.recordUsage(emoji) + Task { await viewModel.sendReaction(emoji: emoji, to: message) } + }, + onLongPress: { message in selectedMessageForActions = message }, + onImageTap: { message in + if let data = viewModel.imageData(for: message.id) { + imageViewerData = ImageViewerData( + imageData: data, + isGIF: viewModel.isGIFImage(for: message.id) ) - ) - ChatTableView( - items: viewModel.items, - cellContent: factory.makeContent(for:), - contentBackground: theme.surfaces?.canvas, - themeID: theme.id, - appearanceToken: AppearanceToken.make( - colorScheme: colorScheme, - contrast: colorSchemeContrast, - dynamicTypeSize: dynamicTypeSize - ), - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - isUnseenMention: { item in - item.envelope.containsSelfMention - && !item.envelope.mentionSeen - && mentionIDSet.contains(item.id) - }, - unseenMentionIDs: unseenMentionIDs, - offscreenMentionIDs: $offscreenMentionIDs, - onMentionBecameVisible: { id in - await onMentionSeen(id) - }, - onSecondaryClick: { item in - if let message = viewModel.message(for: item) { - selectedMessageForActions = message - } - }, - mentionTargetID: scrollToTargetID, - scrollToDividerRequest: $scrollToDividerRequest, - dividerItemID: newMessagesDividerMessageID, - isDividerVisible: $isDividerVisible, - onNearTop: { release in - Task { @MainActor in - await viewModel.loadOlderMessages() - release() - } - }, - isLoadingOlderMessages: viewModel.isLoadingOlder - ) - .overlay(alignment: .bottomTrailing) { - VStack(spacing: 12) { - if showDividerButton { - ScrollToDividerButton( - onTap: { - scrollToDividerRequest += 1 - hasDismissedDividerButton = true - } - ) - .transition(.scale.combined(with: .opacity)) - } - - if !offscreenMentionIDs.isEmpty { - ScrollToMentionButton( - unreadMentionCount: offscreenMentionIDs.count, - onTap: { onScrollToMention() } - ) - .transition(.scale.combined(with: .opacity)) - } - - ScrollToBottomButton( - isVisible: !isAtBottom, - unreadCount: unreadCount, - onTap: { scrollToBottomRequest += 1 } - ) - } - .animation(.snappy(duration: 0.2), value: showDividerButton) - .animation(.snappy(duration: 0.2), value: offscreenMentionIDs.isEmpty) - .padding(.trailing, 16) - .padding(.bottom, 8) + } + }, + onRetryInlineImage: { messageID in + Task { await viewModel.retryImageFetch(for: messageID) } + }, + onRequestPreviewFetch: { messageID in + if viewModel.shouldRequestImageFetch(for: messageID) { + viewModel.requestImageFetch(for: messageID) + } else { + viewModel.requestPreviewFetch(for: messageID) + } + }, + onManualPreviewFetch: { messageID in + if viewModel.shouldRequestImageFetch(for: messageID) { + viewModel.manualFetchImage(for: messageID) + } else { + Task { await viewModel.manualFetchPreview(for: messageID) } + } + }, + onMapPreviewTap: { coordinate in + viewModel.navigateToMap(coordinate) + }, + snapshotResolver: { MapSnapshotStore.shared.image(for: $0) }, + requestSnapshot: { MapSnapshotStore.shared.request($0) }, + retrySnapshot: { MapSnapshotStore.shared.retry($0) } + ) + ) + ChatTableView( + items: viewModel.items, + cellContent: factory.makeContent(for:), + contentBackground: theme.surfaces?.canvas, + themeID: theme.id, + appearanceToken: AppearanceToken.make( + colorScheme: colorScheme, + contrast: colorSchemeContrast, + dynamicTypeSize: dynamicTypeSize + ), + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + isUnseenMention: { item in + item.envelope.containsSelfMention + && !item.envelope.mentionSeen + && mentionIDSet.contains(item.id) + }, + unseenMentionIDs: unseenMentionIDs, + offscreenMentionIDs: $offscreenMentionIDs, + onMentionBecameVisible: { id in + await onMentionSeen(id) + }, + onSecondaryClick: { item in + if let message = viewModel.message(for: item) { + selectedMessageForActions = message } - .onChange(of: newMessagesDividerMessageID) { _, _ in - hasDismissedDividerButton = false + }, + mentionTargetID: scrollToTargetID, + scrollToDividerRequest: $scrollToDividerRequest, + dividerItemID: newMessagesDividerMessageID, + isDividerVisible: $isDividerVisible, + onNearTop: { release in + Task { @MainActor in + await viewModel.loadOlderMessages() + release() } - .onChange(of: envInputs) { _, new in - viewModel.applyEnvInputs(new) + }, + isLoadingOlderMessages: viewModel.isLoadingOlder + ) + .overlay(alignment: .bottomTrailing) { + VStack(spacing: 12) { + if showDividerButton { + ScrollToDividerButton( + onTap: { + scrollToDividerRequest += 1 + hasDismissedDividerButton = true + } + ) + .transition(.scale.combined(with: .opacity)) + } + + if !offscreenMentionIDs.isEmpty { + ScrollToMentionButton( + unreadMentionCount: offscreenMentionIDs.count, + onTap: { onScrollToMention() } + ) + .transition(.scale.combined(with: .opacity)) } + + ScrollToBottomButton( + isVisible: !isAtBottom, + unreadCount: unreadCount, + onTap: { scrollToBottomRequest += 1 } + ) + } + .animation(.snappy(duration: 0.2), value: showDividerButton) + .animation(.snappy(duration: 0.2), value: offscreenMentionIDs.isEmpty) + .padding(.trailing, 16) + .padding(.bottom, 8) + } + .onChange(of: newMessagesDividerMessageID) { _, _ in + hasDismissedDividerButton = false + } + .onChange(of: envInputs) { _, new in + viewModel.applyEnvInputs(new) } + } } diff --git a/MC1/Views/Chats/Components/ChatScrollConstants.swift b/MC1/Views/Chats/Components/ChatScrollConstants.swift index 27189cde..817d6573 100644 --- a/MC1/Views/Chats/Components/ChatScrollConstants.swift +++ b/MC1/Views/Chats/Components/ChatScrollConstants.swift @@ -4,35 +4,35 @@ import Foundation /// enum (not nested in the generic controller) so static stored properties are /// legal. enum ChatScrollConstants { - /// Maximum times a scroll-to-target is retried while the applied snapshot - /// catches up to the items model before the target is abandoned. - static let pendingScrollMaxRetries = 3 + /// Maximum times a scroll-to-target is retried while the applied snapshot + /// catches up to the items model before the target is abandoned. + static let pendingScrollMaxRetries = 3 - /// Delay between scroll-to-target retries while waiting for the applied - /// snapshot to drain. - static let pendingScrollRetryDelay: Duration = .milliseconds(80) + /// Delay between scroll-to-target retries while waiting for the applied + /// snapshot to drain. + static let pendingScrollRetryDelay: Duration = .milliseconds(80) - /// contentOffset.y at or below which the flipped table is treated as resting - /// at the visual bottom. Small to absorb float imprecision only. - static let bottomDetectionEpsilon: CGFloat = 1 + /// contentOffset.y at or below which the flipped table is treated as resting + /// at the visual bottom. Small to absorb float imprecision only. + static let bottomDetectionEpsilon: CGFloat = 1 - /// Looser bottom threshold used right after a programmatic scroll-to-bottom, - /// whose animation may not land exactly at zero. - static let bottomLandingEpsilon: CGFloat = 10 + /// Looser bottom threshold used right after a programmatic scroll-to-bottom, + /// whose animation may not land exactly at zero. + static let bottomLandingEpsilon: CGFloat = 10 - /// Distance (in rows) from the oldest loaded message at which to trigger - /// loading the next older page. - static let nearTopTriggerDistance = 10 + /// Distance (in rows) from the oldest loaded message at which to trigger + /// loading the next older page. + static let nearTopTriggerDistance = 10 - /// Delay that lets a SwiftUI/table layout pass settle before a follow-up - /// scroll or visibility check reads positions. - static let layoutSettleDelay: Duration = .milliseconds(100) + /// Delay that lets a SwiftUI/table layout pass settle before a follow-up + /// scroll or visibility check reads positions. + static let layoutSettleDelay: Duration = .milliseconds(100) - /// Delay before replaying a pending scroll-to-target queued during an items - /// apply, giving the snapshot time to install the target row. - static let pendingScrollInitialDelay: Duration = .milliseconds(120) + /// Delay before replaying a pending scroll-to-target queued during an items + /// apply, giving the snapshot time to install the target row. + static let pendingScrollInitialDelay: Duration = .milliseconds(120) - /// Delay before an animated scroll-to-target begins, letting the target row's - /// self-sizing layout settle so the landing offset is correct. - static let scrollToTargetDelay: Duration = .milliseconds(180) + /// Delay before an animated scroll-to-target begins, letting the target row's + /// self-sizing layout settle so the landing offset is correct. + static let scrollToTargetDelay: Duration = .milliseconds(180) } diff --git a/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift b/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift index bdb7824f..dfc72a22 100644 --- a/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift +++ b/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift @@ -7,9 +7,9 @@ import UIKit /// representable class type the proxy could store directly. @MainActor final class ChatScrollDisplayLinkProxy: NSObject { - var onTick: (() -> Void)? + var onTick: (() -> Void)? - @objc func tick(_ link: CADisplayLink) { - onTick?() - } + @objc func tick(_ link: CADisplayLink) { + onTick?() + } } diff --git a/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift b/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift index 8ceb57e8..b2b68f21 100644 --- a/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift +++ b/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift @@ -1,15 +1,15 @@ import Foundation enum ChatScrollToMentionPolicy { - static func shouldScrollToBottom(mentionTargetID: UUID?, newestItemID: UUID?) -> Bool { - guard let mentionTargetID, let newestItemID else { return false } - return mentionTargetID == newestItemID - } + static func shouldScrollToBottom(mentionTargetID: UUID?, newestItemID: UUID?) -> Bool { + guard let mentionTargetID, let newestItemID else { return false } + return mentionTargetID == newestItemID + } - /// Picks which off-screen mention a button tap scrolls to. `offscreenMentions` is ordered - /// oldest-to-newest, so the newest (last) is the latest unread the user hasn't reached; - /// repeated taps consume it and walk upward through older mentions, showing the earliest last. - static func nextTarget(offscreenMentions: [UUID]) -> UUID? { - offscreenMentions.last - } + /// Picks which off-screen mention a button tap scrolls to. `offscreenMentions` is ordered + /// oldest-to-newest, so the newest (last) is the latest unread the user hasn't reached; + /// repeated taps consume it and walk upward through older mentions, showing the earliest last. + static func nextTarget(offscreenMentions: [UUID]) -> UUID? { + offscreenMentions.last + } } diff --git a/MC1/Views/Chats/Components/ChatShareMenu.swift b/MC1/Views/Chats/Components/ChatShareMenu.swift index 1e70dc3e..f4b759bd 100644 --- a/MC1/Views/Chats/Components/ChatShareMenu.swift +++ b/MC1/Views/Chats/Components/ChatShareMenu.swift @@ -9,152 +9,152 @@ import SwiftUI /// reads the compose text. The contact list is fetched lazily inside /// `ShareContactPickerSheet`, so no contact array is built here. struct ChatShareMenu: View { - let onInsert: (String) -> Void + let onInsert: (String) -> Void - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var isShowingContactPicker = false - @State private var locationTask: Task? + @State private var isShowingContactPicker = false + @State private var locationTask: Task? - /// Decimal-degree format for shared coordinates. Six fractional digits give - /// roughly 0.1 m resolution, enough to round-trip a phone or node fix. - private static let coordinateFormat = "%.6f, %.6f" + /// Decimal-degree format for shared coordinates. Six fractional digits give + /// roughly 0.1 m resolution, enough to round-trip a phone or node fix. + private static let coordinateFormat = "%.6f, %.6f" - /// Timeout for the on-tap location fix. Shorter than the service default - /// because it runs in response to a user tap and should not stall the menu. - private static let locationFixTimeout: Duration = .seconds(5) + /// Timeout for the on-tap location fix. Shorter than the service default + /// because it runs in response to a user tap and should not stall the menu. + private static let locationFixTimeout: Duration = .seconds(5) - /// Already-published phone fix, only when location access is granted. - private var publishedPhoneCoordinate: CLLocationCoordinate2D? { - guard appState.locationService.isAuthorized, - let location = appState.locationService.currentLocation else { - return nil - } - return location.coordinate + /// Already-published phone fix, only when location access is granted. + private var publishedPhoneCoordinate: CLLocationCoordinate2D? { + guard appState.locationService.isAuthorized, + let location = appState.locationService.currentLocation else { + return nil } + return location.coordinate + } - /// Connected node's last-known coordinate, when it carries a valid fix. - private var nodeCoordinate: CLLocationCoordinate2D? { - guard let device = appState.connectedDevice, device.hasLocation else { - return nil - } - return CLLocationCoordinate2D(latitude: device.latitude, longitude: device.longitude) + /// Connected node's last-known coordinate, when it carries a valid fix. + private var nodeCoordinate: CLLocationCoordinate2D? { + guard let device = appState.connectedDevice, device.hasLocation else { + return nil } - - /// A coordinate is shareable when the phone or the node can supply one, or when location - /// access is granted, since tapping then fetches a fresh fix via `requestCurrentLocation`. - nonisolated static func canShareLocation( - phoneCoordinate: CLLocationCoordinate2D?, - nodeCoordinate: CLLocationCoordinate2D?, - locationAuthorized: Bool - ) -> Bool { - phoneCoordinate != nil || nodeCoordinate != nil || locationAuthorized - } - - private var canShareLocation: Bool { - Self.canShareLocation( - phoneCoordinate: publishedPhoneCoordinate, - nodeCoordinate: nodeCoordinate, - locationAuthorized: appState.locationService.isAuthorized - ) + return CLLocationCoordinate2D(latitude: device.latitude, longitude: device.longitude) + } + + /// A coordinate is shareable when the phone or the node can supply one, or when location + /// access is granted, since tapping then fetches a fresh fix via `requestCurrentLocation`. + nonisolated static func canShareLocation( + phoneCoordinate: CLLocationCoordinate2D?, + nodeCoordinate: CLLocationCoordinate2D?, + locationAuthorized: Bool + ) -> Bool { + phoneCoordinate != nil || nodeCoordinate != nil || locationAuthorized + } + + private var canShareLocation: Bool { + Self.canShareLocation( + phoneCoordinate: publishedPhoneCoordinate, + nodeCoordinate: nodeCoordinate, + locationAuthorized: appState.locationService.isAuthorized + ) + } + + /// Own info is shareable only with a full-length key and a name that is not blank, so the + /// emitted share token always carries a usable, non-empty identity. + nonisolated static func canShareMyInfo(publicKey: Data, nodeName: String) -> Bool { + publicKey.count == ProtocolLimits.publicKeySize + && !nodeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var canShareMyInfo: Bool { + guard let device = appState.connectedDevice else { return false } + return Self.canShareMyInfo(publicKey: device.publicKey, nodeName: device.nodeName) + } + + /// Matches the send button's font so the two input-bar buttons are the same height. + private var plusButtonFont: Font { + if #available(iOS 26.0, *) { .title2 } else { .title } + } + + var body: some View { + Menu { + Button { + shareLocation() + } label: { + Label(L10n.Chats.Chats.Share.location, systemImage: "location.fill") + } + .disabled(!canShareLocation) + + Button { + isShowingContactPicker = true + } label: { + Label(L10n.Chats.Chats.Share.contact, systemImage: "person.crop.circle") + } + + Button { + shareMyInfo() + } label: { + Label(L10n.Chats.Chats.Share.myInfo, systemImage: "person.text.rectangle") + } + .disabled(!canShareMyInfo) + } label: { + Image(systemName: "plus.circle.fill") + .font(plusButtonFont) } - - /// Own info is shareable only with a full-length key and a name that is not blank, so the - /// emitted share token always carries a usable, non-empty identity. - nonisolated static func canShareMyInfo(publicKey: Data, nodeName: String) -> Bool { - publicKey.count == ProtocolLimits.publicKeySize - && !nodeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + .shareButtonStyle() + .accessibilityLabel(L10n.Chats.Chats.Input.ShareButton.accessibilityLabel) + .sheet(isPresented: $isShowingContactPicker) { + ShareContactPickerSheet(onInsert: onInsert) } - - private var canShareMyInfo: Bool { - guard let device = appState.connectedDevice else { return false } - return Self.canShareMyInfo(publicKey: device.publicKey, nodeName: device.nodeName) - } - - /// Matches the send button's font so the two input-bar buttons are the same height. - private var plusButtonFont: Font { - if #available(iOS 26.0, *) { .title2 } else { .title } - } - - var body: some View { - Menu { - Button { - shareLocation() - } label: { - Label(L10n.Chats.Chats.Share.location, systemImage: "location.fill") - } - .disabled(!canShareLocation) - - Button { - isShowingContactPicker = true - } label: { - Label(L10n.Chats.Chats.Share.contact, systemImage: "person.crop.circle") - } - - Button { - shareMyInfo() - } label: { - Label(L10n.Chats.Chats.Share.myInfo, systemImage: "person.text.rectangle") - } - .disabled(!canShareMyInfo) - } label: { - Image(systemName: "plus.circle.fill") - .font(plusButtonFont) - } - .shareButtonStyle() - .accessibilityLabel(L10n.Chats.Chats.Input.ShareButton.accessibilityLabel) - .sheet(isPresented: $isShowingContactPicker) { - ShareContactPickerSheet(onInsert: onInsert) - } - .onDisappear { locationTask?.cancel() } - } - - /// Shares a coordinate, preferring a fresh phone fix, then the last published - /// phone fix, then the node's coordinate. `requestCurrentLocation` is only - /// called when access is already granted, so no permission prompt can appear. - private func shareLocation() { - locationTask?.cancel() - locationTask = Task { - var fresh: CLLocationCoordinate2D? - if appState.locationService.isAuthorized { - fresh = (try? await appState.locationService - .requestCurrentLocation(timeout: Self.locationFixTimeout))?.coordinate - } - // The menu may have been dismissed while awaiting the fix; do not write to a gone view. - guard !Task.isCancelled else { return } - guard let coordinate = fresh ?? publishedPhoneCoordinate ?? nodeCoordinate else { return } - onInsert(String(format: Self.coordinateFormat, coordinate.latitude, coordinate.longitude)) - } - } - - private func shareMyInfo() { - guard let device = appState.connectedDevice, - Self.canShareMyInfo(publicKey: device.publicKey, nodeName: device.nodeName) else { return } - let token = ContactShareUtilities.formatShare( - publicKey: device.publicKey, - type: .chat, - name: device.nodeName - ) - onInsert(token) + .onDisappear { locationTask?.cancel() } + } + + /// Shares a coordinate, preferring a fresh phone fix, then the last published + /// phone fix, then the node's coordinate. `requestCurrentLocation` is only + /// called when access is already granted, so no permission prompt can appear. + private func shareLocation() { + locationTask?.cancel() + locationTask = Task { + var fresh: CLLocationCoordinate2D? + if appState.locationService.isAuthorized { + fresh = await (try? appState.locationService + .requestCurrentLocation(timeout: Self.locationFixTimeout))?.coordinate + } + // The menu may have been dismissed while awaiting the fix; do not write to a gone view. + guard !Task.isCancelled else { return } + guard let coordinate = fresh ?? publishedPhoneCoordinate ?? nodeCoordinate else { return } + onInsert(String(format: Self.coordinateFormat, coordinate.latitude, coordinate.longitude)) } + } + + private func shareMyInfo() { + guard let device = appState.connectedDevice, + Self.canShareMyInfo(publicKey: device.publicKey, nodeName: device.nodeName) else { return } + let token = ContactShareUtilities.formatShare( + publicKey: device.publicKey, + type: .chat, + name: device.nodeName + ) + onInsert(token) + } } // MARK: - Platform-Conditional Styling private extension View { - /// Mirrors the chat send button's treatment for visual parity: a glass button - /// on iOS 26+, and the same plain vertical padding fallback on earlier versions. - @ViewBuilder - func shareButtonStyle() -> some View { - if #available(iOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.padding(.vertical, 4) - } + /// Mirrors the chat send button's treatment for visual parity: a glass button + /// on iOS 26+, and the same plain vertical padding fallback on earlier versions. + @ViewBuilder + func shareButtonStyle() -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.glass) + } else { + padding(.vertical, 4) } + } } #Preview { - ChatShareMenu(onInsert: { _ in }) - .padding() + ChatShareMenu(onInsert: { _ in }) + .padding() } diff --git a/MC1/Views/Chats/Components/ChatTableView.swift b/MC1/Views/Chats/Components/ChatTableView.swift index 75330a2a..3e123fa9 100644 --- a/MC1/Views/Chats/Components/ChatTableView.swift +++ b/MC1/Views/Chats/Components/ChatTableView.swift @@ -1,394 +1,397 @@ -import UIKit -import SwiftUI import os +import SwiftUI +import UIKit -// Logs the bottom safe-area inset across keyboard transitions so a residual -// inset that fails to collapse after dismissal can be diagnosed. +/// Logs the bottom safe-area inset across keyboard transitions so a residual +/// inset that fails to collapse after dismissal can be diagnosed. private let chatKeyboardLogger = Logger(subsystem: "com.mc1", category: "ChatKeyboard") /// UIKit table view controller with flipped orientation for chat-style scrolling /// Newest messages appear at visual bottom, keyboard handling via native UIKit @MainActor final class ChatTableViewController: UITableViewController, UIContextMenuInteractionDelegate, UIGestureRecognizerDelegate where Item.ID == UUID { - - // MARK: - Types - - private enum Section: Hashable { - case main + // MARK: - Types + + private enum Section: Hashable { + case main + } + + private struct SnapshotApplyRequest { + var snapshot: NSDiffableDataSourceSnapshot + var animatingDifferences: Bool + var completion: (() -> Void)? + /// `true` when this request was issued by `reconfigureAllItems` (live theme switch). + /// The pending-slot coalescer carries this flag — and a re-applied + /// `reconfigureItems(allCurrentIDs)` — forward into any superseding request so the + /// reconfigure intent isn't silently dropped when latest-wins overwrites pendingSnapshot. + var reconfigureAll: Bool = false + } + + // MARK: - Properties + + private var items: [Item] = [] + /// O(1) lookup for items by ID (replaces O(n) first(where:) in cell provider) + private var itemsByID: [Item.ID: Item] = [:] + /// O(1) index lookup for scroll-to-item (replaces O(n) firstIndex(where:)) + private var itemIndexByID: [Item.ID: Int] = [:] + private var cellContentProvider: ((Item) -> CellContent)? + var defaultTableBackgroundColor: UIColor? + private var dataSource: UITableViewDiffableDataSource? + /// Snapshot scheduled while a previous apply was still running. Latest + /// wins: when a new request arrives mid-apply, it replaces this field + /// and the intermediate snapshot is skipped. Diffable data source + /// derives the visual result from the final snapshot alone, so + /// dropping intermediates is safe. + private var pendingSnapshot: SnapshotApplyRequest? + /// Completions from snapshot requests whose snapshot was superseded + /// before it landed. They still need to run because callers (notably + /// the pagination prepend path) use them to restore the anchor row's + /// viewport position after layout settles; the anchor row is part of + /// the superseding snapshot too, so measuring against the post-apply + /// layout is correct. Drained in order after the latest apply lands. + private var pendingCompletions: [() -> Void] = [] + + /// Bundled interaction/intent/apply/deferred axes for the scroll surface. + private(set) var scrollState: ChatScrollState = .idle + + /// Tracks scroll position relative to bottom + private(set) var isAtBottom: Bool = true + + /// Count of unread messages (messages added while scrolled up) + private(set) var unreadCount: Int = 0 + + /// ID of last message user has seen (for unread tracking) + private var lastSeenItemID: Item.ID? + + /// Callback when scroll state changes + var onScrollStateChanged: ((Bool, Int) -> Void)? + + /// Callback when user scrolls near the top (oldest messages). The closure receives a release + /// callback the consumer must invoke when pagination work completes (success or short-circuit) + /// so the request latch clears even when the view model's isLoadingOlder never visibly flips. + var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? + + /// Whether pagination is in progress (skip auto-scroll during pagination) + var isLoadingOlderMessages = false + + /// Suppresses duplicate onNearTop fires while the view model's isLoadingOlder propagates back through SwiftUI + private var isNearTopRequestInFlight = false + + /// Callback when a mention becomes visible. Returns whether the seen-state was + /// persisted; a false result means the id must not stay marked, so it can re-fire. + var onMentionBecameVisible: ((Item.ID) async -> Bool)? + + /// Callback when a row receives a secondary (right) click from a pointer. On Mac the click + /// reaches the context-menu system (`UIContextMenuInteraction`); on iPad a trackpad or mouse + /// secondary click arrives as an indirect-pointer tap, handled by a dedicated recognizer. Touch + /// long-press is never routed here (it opens the sheet through the bubble's own gesture), so the + /// two paths can't double-trigger. Surfaces that leave this nil install neither, so they don't + /// suppress the native context menu in exchange for nothing. + var onSecondaryClick: ((Item) -> Void)? { + didSet { + installMacSecondaryClickIfNeeded() + installIPadSecondaryClickIfNeeded() } - - private struct SnapshotApplyRequest { - var snapshot: NSDiffableDataSourceSnapshot - var animatingDifferences: Bool - var completion: (() -> Void)? - /// `true` when this request was issued by `reconfigureAllItems` (live theme switch). - /// The pending-slot coalescer carries this flag — and a re-applied - /// `reconfigureItems(allCurrentIDs)` — forward into any superseding request so the - /// reconfigure intent isn't silently dropped when latest-wins overwrites pendingSnapshot. - var reconfigureAll: Bool = false + } + + /// Guards the one-time install of the Mac secondary-click interaction. + private var hasInstalledMacSecondaryClick = false + + /// Guards the one-time install of the iPad secondary-click recognizer. + private var hasInstalledIPadSecondaryClick = false + + /// Closure to check if an item contains an unseen self-mention + var isUnseenMention: ((Item) -> Bool)? + + /// Item ID of the new messages divider (for visibility tracking) + var dividerItemID: Item.ID? + + /// Callback when the divider row's visibility changes + var onDividerVisibilityChanged: ((Bool) -> Void)? + + /// Last reported divider visibility (change detection to avoid redundant callbacks) + private var lastDividerVisible: Bool? + + /// The full set of unseen self-mention ids, the source for the off-screen subset. + var unseenMentionIDs: [Item.ID] = [] + + /// Reports the unseen mentions not currently on screen, ordered oldest-to-newest as in + /// `unseenMentionIDs`. Their count drives the scroll-to-mention button; the last (newest) is + /// its scroll target. + var onOffscreenMentionsChanged: (([Item.ID]) -> Void)? + + /// Last reported off-screen mentions (change detection to avoid redundant callbacks) + private var lastReportedOffscreenMentionIDs: [Item.ID]? + + /// Tracks mention IDs that have already been reported as visible (prevents duplicate callbacks) + private var markedMentionIDs: Set = [] + + private var pendingScrollTargetID: Item.ID? + private var pendingScrollTask: Task? + private var checkVisibleMentionsTask: Task? + + /// Latest-wins buffer for updateItems calls received mid-drag. Applying a + /// snapshot mid-drag shifts contentOffset and fights the gesture; draining + /// on drag-end lets the offset adjust on settled content. + private var deferredItemsApply: (newItems: [Item], animated: Bool)? + + /// Coalesces the four scroll-tracking callbacks + /// (`updateIsAtBottom`, `checkVisibleMentions`, `checkDividerVisibility`, + /// `checkNearTop`) to at most one invocation per display frame. + /// `scrollViewDidScroll` only sets a flag; the display link's tick drains it. + private var hasPendingScrollObservation = false + private var scrollDisplayLink: CADisplayLink? + private let scrollDisplayLinkProxy = ChatScrollDisplayLinkProxy() + + /// Target item ID for programmatic scroll, derived from the active scroll intent. + private var scrollTargetItemID: Item.ID? { + if case let .toTarget(id) = scrollState.intent { return id } + return nil + } + + /// True when an auto-scroll-to-bottom was suppressed because the user was interacting. + /// Fired on drag end so messages arriving mid-drag aren't silently dropped. + var deferredScrollToBottomPending: Bool { + scrollState.deferredScroll != nil + } + + /// Count of messages deferred while user is interacting; counted as unread if they release scrolled away. + var deferredScrollMessageCount: Int { + scrollState.deferredScroll?.targetMessageCount ?? 0 + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + // Flip the table view for chat-style bottom anchoring + tableView.transform = CGAffineTransform(scaleX: 1, y: -1) + + // UIKit keyboard handling - bypasses SwiftUI bugs + tableView.keyboardDismissMode = .onDrag + + // Visual setup + tableView.separatorStyle = .none + // estimatedRowHeight intentionally unset: UIHostingConfiguration + // self-sizing produces an exact contentSize, so pagination prepends + // don't shift contentOffset as estimates are replaced with measurements. + + // Flipped table (scaleX: 1, y: -1) inverts top/bottom, so automatic + // content-inset adjustment applies safe-area padding to the wrong edges. + // SwiftUI's .safeAreaInset already handles the input bar, so disable UIKit's. + tableView.contentInsetAdjustmentBehavior = .never + + if #available(iOS 26.0, *) { + // Clear and non-opaque allows Liquid Glass effects on nav/input bars + tableView.backgroundColor = .clear + tableView.isOpaque = false + + // Scroll edge effects don't work correctly with flipped table transform. + // Hide both - the nav bar and input bar provide their own Liquid Glass blur. + tableView.topEdgeEffect.isHidden = true + tableView.bottomEdgeEffect.isHidden = true + } else { + tableView.backgroundColor = .systemBackground } - - // MARK: - Properties - - private var items: [Item] = [] - /// O(1) lookup for items by ID (replaces O(n) first(where:) in cell provider) - private var itemsByID: [Item.ID: Item] = [:] - /// O(1) index lookup for scroll-to-item (replaces O(n) firstIndex(where:)) - private var itemIndexByID: [Item.ID: Int] = [:] - private var cellContentProvider: ((Item) -> CellContent)? - var defaultTableBackgroundColor: UIColor? - private var dataSource: UITableViewDiffableDataSource? - /// Snapshot scheduled while a previous apply was still running. Latest - /// wins: when a new request arrives mid-apply, it replaces this field - /// and the intermediate snapshot is skipped. Diffable data source - /// derives the visual result from the final snapshot alone, so - /// dropping intermediates is safe. - private var pendingSnapshot: SnapshotApplyRequest? - /// Completions from snapshot requests whose snapshot was superseded - /// before it landed. They still need to run because callers (notably - /// the pagination prepend path) use them to restore the anchor row's - /// viewport position after layout settles; the anchor row is part of - /// the superseding snapshot too, so measuring against the post-apply - /// layout is correct. Drained in order after the latest apply lands. - private var pendingCompletions: [() -> Void] = [] - - /// Bundled interaction/intent/apply/deferred axes for the scroll surface. - private(set) var scrollState: ChatScrollState = .idle - - /// Tracks scroll position relative to bottom - private(set) var isAtBottom: Bool = true - - /// Count of unread messages (messages added while scrolled up) - private(set) var unreadCount: Int = 0 - - /// ID of last message user has seen (for unread tracking) - private var lastSeenItemID: Item.ID? - - /// Callback when scroll state changes - var onScrollStateChanged: ((Bool, Int) -> Void)? - - /// Callback when user scrolls near the top (oldest messages). The closure receives a release - /// callback the consumer must invoke when pagination work completes (success or short-circuit) - /// so the request latch clears even when the view model's isLoadingOlder never visibly flips. - var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? - - /// Whether pagination is in progress (skip auto-scroll during pagination) - var isLoadingOlderMessages = false - - /// Suppresses duplicate onNearTop fires while the view model's isLoadingOlder propagates back through SwiftUI - private var isNearTopRequestInFlight = false - - /// Callback when a mention becomes visible. Returns whether the seen-state was - /// persisted; a false result means the id must not stay marked, so it can re-fire. - var onMentionBecameVisible: ((Item.ID) async -> Bool)? - - /// Callback when a row receives a secondary (right) click from a pointer. On Mac the click - /// reaches the context-menu system (`UIContextMenuInteraction`); on iPad a trackpad or mouse - /// secondary click arrives as an indirect-pointer tap, handled by a dedicated recognizer. Touch - /// long-press is never routed here (it opens the sheet through the bubble's own gesture), so the - /// two paths can't double-trigger. Surfaces that leave this nil install neither, so they don't - /// suppress the native context menu in exchange for nothing. - var onSecondaryClick: ((Item) -> Void)? { - didSet { - installMacSecondaryClickIfNeeded() - installIPadSecondaryClickIfNeeded() - } + tableView.allowsSelection = false + + // Register cell + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + + // Configure data source + configureDataSource() + + // Manual keyboard observation (UIKit auto-adjustment doesn't work in SwiftUI embed) + setupKeyboardObservers() + + // Coalesces scroll-tracking callbacks at display-frame cadence + setupScrollDisplayLink() + } + + /// Installs the Mac secondary-click interaction the first time a handler is set. Gated on Mac and + /// on a handler existing, so iPad uses its own recognizer instead and a surface without a handler + /// (a room with no actions sheet) leaves the native context menu untouched. + private func installMacSecondaryClickIfNeeded() { + guard ProcessInfo.processInfo.isiOSAppOnMac, + !hasInstalledMacSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } + hasInstalledMacSecondaryClick = true + tableView.addInteraction(UIContextMenuInteraction(delegate: self)) + } + + /// Installs the iPad secondary-click recognizer the first time a handler is set. A trackpad or + /// mouse secondary click arrives as an indirect-pointer event, not through the context-menu + /// system, so the Mac `UIContextMenuInteraction` never fires for it. `buttonMaskRequired` plus + /// the `shouldReceive` guard scope the recognizer to a sole secondary click, so a finger touch + /// (including the bubble long-press) is never delivered here and the two paths can't + /// double-trigger the sheet. + private func installIPadSecondaryClickIfNeeded() { + guard !ProcessInfo.processInfo.isiOSAppOnMac, + !hasInstalledIPadSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } + hasInstalledIPadSecondaryClick = true + let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleSecondaryClick(_:))) + recognizer.buttonMaskRequired = .secondary + recognizer.cancelsTouchesInView = false + recognizer.delegate = self + tableView.addGestureRecognizer(recognizer) + } + + /// Resolves the model item under a point in the table view's coordinate space. + private func itemForRow(at point: CGPoint) -> Item? { + guard let indexPath = tableView.indexPathForRow(at: point), + let itemID = dataSource?.itemIdentifier(for: indexPath) else { return nil } + return itemsByID[itemID] + } + + /// On the class, not an extension: a generic class can carry `@objc` conformance + /// only in its primary declaration. + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + guard let item = itemForRow(at: location) else { return nil } + onSecondaryClick?(item) + // No configuration suppresses the native menu, leaving the sheet the only actions surface. + return nil + } + + @objc private func handleSecondaryClick(_ recognizer: UITapGestureRecognizer) { + guard let item = itemForRow(at: recognizer.location(in: tableView)) else { return } + onSecondaryClick?(item) + } + + /// Scope the iPad recognizer to a sole secondary-button click. A direct touch reports an empty + /// button mask, so this rejects finger presses (and the bubble long-press), leaving an + /// indirect-pointer secondary click the only event that reaches `handleSecondaryClick`. The mask + /// is read from the event because the recognizer's own mask isn't updated with it yet here. + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool { + event.buttonMask == .secondary + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + /// Swift 6.3.2 EarlyPerfInliner crashes (infinite recursion in + /// `isCallerAndCalleeLayoutConstraintsCompatible`) when optimizing this + /// generic UITableViewController subclass's deinit under -O. Opting the + /// deinit out of optimization sidesteps the crash without changing + /// runtime behavior. Drop the attribute once a future Swift release + /// fixes the underlying inliner bug. + @_optimize(none) + isolated deinit { + NotificationCenter.default.removeObserver(self) + scrollDisplayLink?.invalidate() + checkVisibleMentionsTask?.cancel() + } + + // MARK: - Keyboard Handling + + private func setupKeyboardObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow(_:)), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + } + + private func keyboardFrameEnd(_ notification: Notification) -> CGRect? { + notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + } + + @objc private func keyboardWillHide(_ notification: Notification) { + let frameEnd = keyboardFrameEnd(notification) ?? .null + chatKeyboardLogger.debug( + "keyboardWillHide frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" + ) + } + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + let frameEnd = keyboardFrameEnd(notification) ?? .null + chatKeyboardLogger.debug( + "keyboardWillChangeFrame frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" + ) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + chatKeyboardLogger.debug( + "viewSafeAreaInsetsDidChange bottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" + ) + } + + // MARK: - Scroll Coalescing + + /// Creates the `CADisplayLink` that drains coalesced scroll observations. + /// The link retains its target (the proxy), not the controller, so the + /// `deinit` path stays clean. Starts paused; `scrollViewDidScroll` unpauses. + private func setupScrollDisplayLink() { + scrollDisplayLinkProxy.onTick = { [weak self] in + self?.coalescedScrollTick() } - - /// Guards the one-time install of the Mac secondary-click interaction. - private var hasInstalledMacSecondaryClick = false - - /// Guards the one-time install of the iPad secondary-click recognizer. - private var hasInstalledIPadSecondaryClick = false - - /// Closure to check if an item contains an unseen self-mention - var isUnseenMention: ((Item) -> Bool)? - - /// Item ID of the new messages divider (for visibility tracking) - var dividerItemID: Item.ID? - - /// Callback when the divider row's visibility changes - var onDividerVisibilityChanged: ((Bool) -> Void)? - - /// Last reported divider visibility (change detection to avoid redundant callbacks) - private var lastDividerVisible: Bool? - - /// The full set of unseen self-mention ids, the source for the off-screen subset. - var unseenMentionIDs: [Item.ID] = [] - - /// Reports the unseen mentions not currently on screen, ordered oldest-to-newest as in - /// `unseenMentionIDs`. Their count drives the scroll-to-mention button; the last (newest) is - /// its scroll target. - var onOffscreenMentionsChanged: (([Item.ID]) -> Void)? - - /// Last reported off-screen mentions (change detection to avoid redundant callbacks) - private var lastReportedOffscreenMentionIDs: [Item.ID]? - - /// Tracks mention IDs that have already been reported as visible (prevents duplicate callbacks) - private var markedMentionIDs: Set = [] - - private var pendingScrollTargetID: Item.ID? - private var pendingScrollTask: Task? - private var checkVisibleMentionsTask: Task? - - /// Latest-wins buffer for updateItems calls received mid-drag. Applying a - /// snapshot mid-drag shifts contentOffset and fights the gesture; draining - /// on drag-end lets the offset adjust on settled content. - private var deferredItemsApply: (newItems: [Item], animated: Bool)? - - /// Coalesces the four scroll-tracking callbacks - /// (`updateIsAtBottom`, `checkVisibleMentions`, `checkDividerVisibility`, - /// `checkNearTop`) to at most one invocation per display frame. - /// `scrollViewDidScroll` only sets a flag; the display link's tick drains it. - private var hasPendingScrollObservation = false - private var scrollDisplayLink: CADisplayLink? - private let scrollDisplayLinkProxy = ChatScrollDisplayLinkProxy() - - /// Target item ID for programmatic scroll, derived from the active scroll intent. - private var scrollTargetItemID: Item.ID? { - if case .toTarget(let id) = scrollState.intent { return id } - return nil + let link = CADisplayLink( + target: scrollDisplayLinkProxy, + selector: #selector(ChatScrollDisplayLinkProxy.tick(_:)) + ) + link.add(to: .main, forMode: .common) + link.isPaused = true + scrollDisplayLink = link + } + + /// Drains pending scroll observations once per display frame. If a callback + /// re-arms the flag during processing, the link stays unpaused so the next + /// frame picks it up; otherwise the link pauses to avoid waking the run loop. + private func coalescedScrollTick() { + let hadWork = hasPendingScrollObservation + hasPendingScrollObservation = false + if hadWork { + updateIsAtBottom() + let visible = visibleItems() + checkVisibleMentions(visible: visible) + reportOffscreenMentions(visible: visible) + checkDividerVisibility() + checkNearTop() } - - /// True when an auto-scroll-to-bottom was suppressed because the user was interacting. - /// Fired on drag end so messages arriving mid-drag aren't silently dropped. - var deferredScrollToBottomPending: Bool { scrollState.deferredScroll != nil } - - /// Count of messages deferred while user is interacting; counted as unread if they release scrolled away. - var deferredScrollMessageCount: Int { scrollState.deferredScroll?.targetMessageCount ?? 0 } - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - // Flip the table view for chat-style bottom anchoring - tableView.transform = CGAffineTransform(scaleX: 1, y: -1) - - // UIKit keyboard handling - bypasses SwiftUI bugs - tableView.keyboardDismissMode = .onDrag - - // Visual setup - tableView.separatorStyle = .none - // estimatedRowHeight intentionally unset: UIHostingConfiguration - // self-sizing produces an exact contentSize, so pagination prepends - // don't shift contentOffset as estimates are replaced with measurements. - - // Flipped table (scaleX: 1, y: -1) inverts top/bottom, so automatic - // content-inset adjustment applies safe-area padding to the wrong edges. - // SwiftUI's .safeAreaInset already handles the input bar, so disable UIKit's. - tableView.contentInsetAdjustmentBehavior = .never - - if #available(iOS 26.0, *) { - // Clear and non-opaque allows Liquid Glass effects on nav/input bars - tableView.backgroundColor = .clear - tableView.isOpaque = false - - // Scroll edge effects don't work correctly with flipped table transform. - // Hide both - the nav bar and input bar provide their own Liquid Glass blur. - tableView.topEdgeEffect.isHidden = true - tableView.bottomEdgeEffect.isHidden = true - } else { - tableView.backgroundColor = .systemBackground - } - tableView.allowsSelection = false - - // Register cell - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - - // Configure data source - configureDataSource() - - // Manual keyboard observation (UIKit auto-adjustment doesn't work in SwiftUI embed) - setupKeyboardObservers() - - // Coalesces scroll-tracking callbacks at display-frame cadence - setupScrollDisplayLink() - } - - /// Installs the Mac secondary-click interaction the first time a handler is set. Gated on Mac and - /// on a handler existing, so iPad uses its own recognizer instead and a surface without a handler - /// (a room with no actions sheet) leaves the native context menu untouched. - private func installMacSecondaryClickIfNeeded() { - guard ProcessInfo.processInfo.isiOSAppOnMac, - !hasInstalledMacSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } - hasInstalledMacSecondaryClick = true - tableView.addInteraction(UIContextMenuInteraction(delegate: self)) - } - - /// Installs the iPad secondary-click recognizer the first time a handler is set. A trackpad or - /// mouse secondary click arrives as an indirect-pointer event, not through the context-menu - /// system, so the Mac `UIContextMenuInteraction` never fires for it. `buttonMaskRequired` plus - /// the `shouldReceive` guard scope the recognizer to a sole secondary click, so a finger touch - /// (including the bubble long-press) is never delivered here and the two paths can't - /// double-trigger the sheet. - private func installIPadSecondaryClickIfNeeded() { - guard !ProcessInfo.processInfo.isiOSAppOnMac, - !hasInstalledIPadSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } - hasInstalledIPadSecondaryClick = true - let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleSecondaryClick(_:))) - recognizer.buttonMaskRequired = .secondary - recognizer.cancelsTouchesInView = false - recognizer.delegate = self - tableView.addGestureRecognizer(recognizer) - } - - /// Resolves the model item under a point in the table view's coordinate space. - private func itemForRow(at point: CGPoint) -> Item? { - guard let indexPath = tableView.indexPathForRow(at: point), - let itemID = dataSource?.itemIdentifier(for: indexPath) else { return nil } - return itemsByID[itemID] - } - - // On the class, not an extension: a generic class can carry `@objc` conformance - // only in its primary declaration. - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - guard let item = itemForRow(at: location) else { return nil } - onSecondaryClick?(item) - // No configuration suppresses the native menu, leaving the sheet the only actions surface. - return nil - } - - @objc private func handleSecondaryClick(_ recognizer: UITapGestureRecognizer) { - guard let item = itemForRow(at: recognizer.location(in: tableView)) else { return } - onSecondaryClick?(item) - } - - // Scope the iPad recognizer to a sole secondary-button click. A direct touch reports an empty - // button mask, so this rejects finger presses (and the bubble long-press), leaving an - // indirect-pointer secondary click the only event that reaches `handleSecondaryClick`. The mask - // is read from the event because the recognizer's own mask isn't updated with it yet here. - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool { - event.buttonMask == .secondary - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - true - } - - // Swift 6.3.2 EarlyPerfInliner crashes (infinite recursion in - // `isCallerAndCalleeLayoutConstraintsCompatible`) when optimizing this - // generic UITableViewController subclass's deinit under -O. Opting the - // deinit out of optimization sidesteps the crash without changing - // runtime behavior. Drop the attribute once a future Swift release - // fixes the underlying inliner bug. - @_optimize(none) - isolated deinit { - NotificationCenter.default.removeObserver(self) - scrollDisplayLink?.invalidate() - checkVisibleMentionsTask?.cancel() + if !hasPendingScrollObservation { + scrollDisplayLink?.isPaused = true } + } - // MARK: - Keyboard Handling - - private func setupKeyboardObservers() { - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillShow(_:)), - name: UIResponder.keyboardWillShowNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillHide(_:)), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillChangeFrame(_:)), - name: UIResponder.keyboardWillChangeFrameNotification, - object: nil - ) - } - - private func keyboardFrameEnd(_ notification: Notification) -> CGRect? { - notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect - } - - @objc private func keyboardWillHide(_ notification: Notification) { - let frameEnd = keyboardFrameEnd(notification) ?? .null - chatKeyboardLogger.debug( - "keyboardWillHide frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - } - - @objc private func keyboardWillChangeFrame(_ notification: Notification) { - let frameEnd = keyboardFrameEnd(notification) ?? .null - chatKeyboardLogger.debug( - "keyboardWillChangeFrame frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - chatKeyboardLogger.debug( - "viewSafeAreaInsetsDidChange bottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - } - - // MARK: - Scroll Coalescing - - /// Creates the `CADisplayLink` that drains coalesced scroll observations. - /// The link retains its target (the proxy), not the controller, so the - /// `deinit` path stays clean. Starts paused; `scrollViewDidScroll` unpauses. - private func setupScrollDisplayLink() { - scrollDisplayLinkProxy.onTick = { [weak self] in - self?.coalescedScrollTick() - } - let link = CADisplayLink( - target: scrollDisplayLinkProxy, - selector: #selector(ChatScrollDisplayLinkProxy.tick(_:)) - ) - link.add(to: .main, forMode: .common) - link.isPaused = true - scrollDisplayLink = link - } - - /// Drains pending scroll observations once per display frame. If a callback - /// re-arms the flag during processing, the link stays unpaused so the next - /// frame picks it up; otherwise the link pauses to avoid waking the run loop. - private func coalescedScrollTick() { - let hadWork = hasPendingScrollObservation - hasPendingScrollObservation = false - if hadWork { - updateIsAtBottom() - let visible = visibleItems() - checkVisibleMentions(visible: visible) - reportOffscreenMentions(visible: visible) - checkDividerVisibility() - checkNearTop() - } - if !hasPendingScrollObservation { - scrollDisplayLink?.isPaused = true - } - } - - #if DEBUG + #if DEBUG /// Drains pending scroll observations synchronously. Production code /// relies on the display link; this entry point lets unit tests verify /// scroll callbacks without waiting for a real frame tick. func flushScrollObservationsForTests() { - coalescedScrollTick() + coalescedScrollTick() } - #endif + #endif - #if DEBUG + #if DEBUG /// Exposes snapshot-derived scroll-row resolution so unit tests can assert that /// scroll targets are sourced from the applied snapshot (nil-safe for ids not /// yet applied) rather than the controller's leading items model. func resolvedScrollRowForTests(id: Item.ID) -> IndexPath? { - snapshotRow(for: id) + snapshotRow(for: id) } - #endif + #endif - #if DEBUG + #if DEBUG /// Advances the items model (items/itemsByID/itemIndexByID) without applying a /// diffable snapshot, reproducing the model-ahead-of-snapshot state the apply-lag /// window produces — where a model-derived row can exceed the applied row count @@ -397,13 +400,13 @@ final class ChatTableViewController CellContent) { - self.cellContentProvider = cellContent + chatKeyboardLogger.debug( + "keyboardWillShow frameEnd=\((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect ?? .null).debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" + ) + + let wasAtBottom = isAtBottom + + // SwiftUI handles frame changes for keyboard, so we don't add content inset. + // Just scroll to bottom after layout settles if we were at bottom. + if wasAtBottom { + // Set intent now to prevent scroll delegate from reacting to contentOffset + // oscillations during keyboard animation. Critical when content is shorter + // than visible area - the bouncing would otherwise cause isAtBottom to flip. + scrollState.startIntent(.toBottom) + + // Delay to let SwiftUI complete its layout pass + Task { @MainActor [weak self] in + try? await Task.sleep(for: ChatScrollConstants.layoutSettleDelay) + self?.scrollToBottom(animated: true) + } } + } + + // MARK: - Configuration + + func configure(cellContent: @escaping (Item) -> CellContent) { + cellContentProvider = cellContent + } + + // MARK: - Data Source + + /// Row for an item in the *applied* diffable snapshot, or nil if the snapshot + /// has not yet caught up with the controller's items model. updateItems mutates + /// items/itemIndexByID synchronously while the snapshot apply can lag (queued + /// behind an in-flight apply), so model-derived rows can exceed the table's + /// applied row count and abort scrollToRow. All scroll-row lookups must go + /// through here. Mirrors the snapshot-derived lookup in restorePrependAnchor. + private func snapshotRow(for id: Item.ID) -> IndexPath? { + dataSource?.indexPath(for: id) + } + + private func configureDataSource() { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemID in + guard let self, + let item = itemsByID[itemID] else { + return UITableViewCell() + } + + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + + // Flip cell back to normal orientation (must be cell, not contentView, + // because UIHostingConfiguration replaces contentView hierarchy) + cell.transform = CGAffineTransform(scaleX: 1, y: -1) + cell.backgroundColor = .clear + cell.selectionStyle = .none + // Allow long-press scale-up and shadow on message bubbles to extend + // past the cell frame instead of being clipped at cell edges. + cell.clipsToBounds = false + cell.contentView.clipsToBounds = false + + // Embed SwiftUI content + if let contentProvider = cellContentProvider { + if #available(iOS 26.0, *) { + cell.contentConfiguration = UIHostingConfiguration { + contentProvider(item) + } + .margins(.all, 0) + .minSize(width: 0, height: 0) + .background(.clear) + } else { + cell.contentConfiguration = UIHostingConfiguration { + contentProvider(item) + } + .margins(.all, 0) + .minSize(width: 0, height: 0) + } + } - // MARK: - Data Source - - /// Row for an item in the *applied* diffable snapshot, or nil if the snapshot - /// has not yet caught up with the controller's items model. updateItems mutates - /// items/itemIndexByID synchronously while the snapshot apply can lag (queued - /// behind an in-flight apply), so model-derived rows can exceed the table's - /// applied row count and abort scrollToRow. All scroll-row lookups must go - /// through here. Mirrors the snapshot-derived lookup in restorePrependAnchor. - private func snapshotRow(for id: Item.ID) -> IndexPath? { - dataSource?.indexPath(for: id) + return cell } - - private func configureDataSource() { - dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemID in - guard let self, - let item = self.itemsByID[itemID] else { - return UITableViewCell() - } - - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - - // Flip cell back to normal orientation (must be cell, not contentView, - // because UIHostingConfiguration replaces contentView hierarchy) - cell.transform = CGAffineTransform(scaleX: 1, y: -1) - cell.backgroundColor = .clear - cell.selectionStyle = .none - // Allow long-press scale-up and shadow on message bubbles to extend - // past the cell frame instead of being clipped at cell edges. - cell.clipsToBounds = false - cell.contentView.clipsToBounds = false - - // Embed SwiftUI content - if let contentProvider = self.cellContentProvider { - if #available(iOS 26.0, *) { - cell.contentConfiguration = UIHostingConfiguration { - contentProvider(item) - } - .margins(.all, 0) - .minSize(width: 0, height: 0) - .background(.clear) - } else { - cell.contentConfiguration = UIHostingConfiguration { - contentProvider(item) - } - .margins(.all, 0) - .minSize(width: 0, height: 0) - } - } - - return cell + } + + // MARK: - Update Items + + /// When true, updateItems will skip auto-scroll (caller will scroll explicitly) + private var skipAutoScroll = false + + private func applySnapshot( + _ snapshot: NSDiffableDataSourceSnapshot, + animatingDifferences: Bool, + completion: (() -> Void)? = nil, + reconfigureAll: Bool = false + ) { + var request = SnapshotApplyRequest( + snapshot: snapshot, + animatingDifferences: animatingDifferences, + completion: completion, + reconfigureAll: reconfigureAll + ) + + if scrollState.isApplyingSnapshot { + if let existing = pendingSnapshot { + // Latest-wins for the snapshot itself, but preserve the superseded + // request's completion so prepend anchor restores still fire after + // the final apply lands. + if let superseded = existing.completion { + pendingCompletions.append(superseded) } + // Carry the superseded snapshot's reconfigure intent forward into the + // incoming snapshot so latest-wins doesn't silently drop a repaint. A + // content-only reconfigure parked here would otherwise never reach an + // applied snapshot, stranding its cell on stale content. Only items still + // present in the incoming snapshot are carried; superseded-then-deleted + // items are gone regardless. + let carriedReconfigures = existing.snapshot.reconfiguredItemIdentifiers + .filter { request.snapshot.itemIdentifiers.contains($0) } + if !carriedReconfigures.isEmpty { + request.snapshot.reconfigureItems(carriedReconfigures) + } + // A reconfigure-all (live theme switch) additionally repaints rows added by + // the incoming snapshot, so extend the intent to its full identifier set. + if existing.reconfigureAll { + request.snapshot.reconfigureItems(request.snapshot.itemIdentifiers) + request.reconfigureAll = true + } + } + // The opposite ordering is intentionally not mirrored: an incoming reconfigure-all + // replacing a pending content snapshot adopts the reconfigure's (already-applied) + // identifier set, so the superseded snapshot's not-yet-applied items wait for the next + // apply. That is safe because a theme change also drives buildItems() with the full + // current set within a few frames, so the deferred rows reappear without a visible gap. + pendingSnapshot = request + return } - // MARK: - Update Items - - /// When true, updateItems will skip auto-scroll (caller will scroll explicitly) - private var skipAutoScroll = false - - private func applySnapshot( - _ snapshot: NSDiffableDataSourceSnapshot, - animatingDifferences: Bool, - completion: (() -> Void)? = nil, - reconfigureAll: Bool = false - ) { - var request = SnapshotApplyRequest( - snapshot: snapshot, - animatingDifferences: animatingDifferences, - completion: completion, - reconfigureAll: reconfigureAll - ) + applySnapshotRequest(request) + } - if scrollState.isApplyingSnapshot { - if let existing = pendingSnapshot { - // Latest-wins for the snapshot itself, but preserve the superseded - // request's completion so prepend anchor restores still fire after - // the final apply lands. - if let superseded = existing.completion { - pendingCompletions.append(superseded) - } - // Carry the superseded snapshot's reconfigure intent forward into the - // incoming snapshot so latest-wins doesn't silently drop a repaint. A - // content-only reconfigure parked here would otherwise never reach an - // applied snapshot, stranding its cell on stale content. Only items still - // present in the incoming snapshot are carried; superseded-then-deleted - // items are gone regardless. - let carriedReconfigures = existing.snapshot.reconfiguredItemIdentifiers - .filter { request.snapshot.itemIdentifiers.contains($0) } - if !carriedReconfigures.isEmpty { - request.snapshot.reconfigureItems(carriedReconfigures) - } - // A reconfigure-all (live theme switch) additionally repaints rows added by - // the incoming snapshot, so extend the intent to its full identifier set. - if existing.reconfigureAll { - request.snapshot.reconfigureItems(request.snapshot.itemIdentifiers) - request.reconfigureAll = true - } - } - // The opposite ordering is intentionally not mirrored: an incoming reconfigure-all - // replacing a pending content snapshot adopts the reconfigure's (already-applied) - // identifier set, so the superseded snapshot's not-yet-applied items wait for the next - // apply. That is safe because a theme change also drives buildItems() with the full - // current set within a few frames, so the deferred rows reappear without a visible gap. - pendingSnapshot = request - return - } - - applySnapshotRequest(request) + private func applySnapshotRequest(_ request: SnapshotApplyRequest) { + guard let dataSource else { + pendingSnapshot = nil + pendingCompletions.removeAll() + scrollState.endApplying() + return } - private func applySnapshotRequest(_ request: SnapshotApplyRequest) { - guard let dataSource else { - pendingSnapshot = nil - pendingCompletions.removeAll() - scrollState.endApplying() - return - } - - #if DEBUG - appliedReconfiguredItemIDsForTests.append(contentsOf: request.snapshot.reconfiguredItemIdentifiers) - #endif + #if DEBUG + appliedReconfiguredItemIDsForTests.append(contentsOf: request.snapshot.reconfiguredItemIdentifiers) + #endif - scrollState.startApplying() - let shouldAnimate = request.animatingDifferences && view.window != nil + scrollState.startApplying() + let shouldAnimate = request.animatingDifferences && view.window != nil - if shouldAnimate { - dataSource.apply(request.snapshot, animatingDifferences: true) { [weak self] in - Task { @MainActor [weak self] in - request.completion?() - self?.drainSnapshotQueue() - } - } - } else { - dataSource.apply(request.snapshot, animatingDifferences: false) - request.completion?() - drainSnapshotQueue() + if shouldAnimate { + dataSource.apply(request.snapshot, animatingDifferences: true) { [weak self] in + Task { @MainActor [weak self] in + request.completion?() + self?.drainSnapshotQueue() } + } + } else { + dataSource.apply(request.snapshot, animatingDifferences: false) + request.completion?() + drainSnapshotQueue() } - - private func drainSnapshotQueue() { - scrollState.endApplying() - // Loop in case a new pending snapshot is enqueued while draining; - // each iteration applies the latest request and clears the field. - while let next = pendingSnapshot { - pendingSnapshot = nil - applySnapshotRequest(next) - } - // Fire superseded completions only when truly idle. An animated apply - // re-enters `scrollState.startApplying()` and finishes its drain in an - // async callback; firing now would run the completions before the - // final layout settles. The async callback will re-enter this method - // and reach the idle branch then. - if !scrollState.isApplyingSnapshot && !pendingCompletions.isEmpty { - let completions = pendingCompletions - pendingCompletions.removeAll() - for completion in completions { - completion() - } - } + } + + private func drainSnapshotQueue() { + scrollState.endApplying() + // Loop in case a new pending snapshot is enqueued while draining; + // each iteration applies the latest request and clears the field. + while let next = pendingSnapshot { + pendingSnapshot = nil + applySnapshotRequest(next) } - - private struct PrependAnchor { - let itemID: Item.ID - /// rect.minY - contentOffset.y at capture time (viewport-relative position) - let distanceFromContentOffset: CGFloat + // Fire superseded completions only when truly idle. An animated apply + // re-enters `scrollState.startApplying()` and finishes its drain in an + // async callback; firing now would run the completions before the + // final layout settles. The async callback will re-enter this method + // and reach the idle branch then. + if !scrollState.isApplyingSnapshot, !pendingCompletions.isEmpty { + let completions = pendingCompletions + pendingCompletions.removeAll() + for completion in completions { + completion() + } } + } - private func capturePrependAnchor(in oldItems: [Item]) -> PrependAnchor? { - guard let visibleRows = tableView.indexPathsForVisibleRows, !visibleRows.isEmpty else { - return nil - } - let midIndexPath = visibleRows[visibleRows.count / 2] - let chronologicalIndex = oldItems.count - 1 - midIndexPath.row - guard chronologicalIndex >= 0, chronologicalIndex < oldItems.count else { return nil } - let rect = tableView.rectForRow(at: midIndexPath) - return PrependAnchor( - itemID: oldItems[chronologicalIndex].id, - distanceFromContentOffset: rect.minY - tableView.contentOffset.y - ) - } + private struct PrependAnchor { + let itemID: Item.ID + /// rect.minY - contentOffset.y at capture time (viewport-relative position) + let distanceFromContentOffset: CGFloat + } - private func restorePrependAnchor(_ anchor: PrependAnchor) { - // Read the row from the data source's current snapshot, not the controller's mutable items, - // because a newer updateItems call may have overwritten items/itemIndexByID while the - // queued prepend apply (whose completion fires here) was waiting to drain. - guard let dataSource, - let indexPath = dataSource.indexPath(for: anchor.itemID) else { return } - tableView.layoutIfNeeded() - let newRect = tableView.rectForRow(at: indexPath) - CATransaction.begin() - CATransaction.setDisableActions(true) - tableView.contentOffset.y = newRect.minY - anchor.distanceFromContentOffset - CATransaction.commit() + private func capturePrependAnchor(in oldItems: [Item]) -> PrependAnchor? { + guard let visibleRows = tableView.indexPathsForVisibleRows, !visibleRows.isEmpty else { + return nil + } + let midIndexPath = visibleRows[visibleRows.count / 2] + let chronologicalIndex = oldItems.count - 1 - midIndexPath.row + guard chronologicalIndex >= 0, chronologicalIndex < oldItems.count else { return nil } + let rect = tableView.rectForRow(at: midIndexPath) + return PrependAnchor( + itemID: oldItems[chronologicalIndex].id, + distanceFromContentOffset: rect.minY - tableView.contentOffset.y + ) + } + + private func restorePrependAnchor(_ anchor: PrependAnchor) { + // Read the row from the data source's current snapshot, not the controller's mutable items, + // because a newer updateItems call may have overwritten items/itemIndexByID while the + // queued prepend apply (whose completion fires here) was waiting to drain. + guard let dataSource, + let indexPath = dataSource.indexPath(for: anchor.itemID) else { return } + tableView.layoutIfNeeded() + let newRect = tableView.rectForRow(at: indexPath) + CATransaction.begin() + CATransaction.setDisableActions(true) + tableView.contentOffset.y = newRect.minY - anchor.distanceFromContentOffset + CATransaction.commit() + } + + func updateItems(_ newItems: [Item], animated: Bool = true) { + if tableView.isDragging { + deferredItemsApply = (newItems, animated) + return } - func updateItems(_ newItems: [Item], animated: Bool = true) { - if tableView.isDragging { - deferredItemsApply = (newItems, animated) - return - } - - let previousCount = items.count - let wasAtBottom = isAtBottom - let oldItems = items - items = newItems - - // Build O(1) lookup dictionaries - itemsByID = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) - itemIndexByID = Dictionary(uniqueKeysWithValues: newItems.enumerated().map { ($0.element.id, $0.offset) }) - - // Detect prepend (pagination) vs append (new messages). A pure prepend changes the - // first id but not the last; requiring an unchanged tail keeps a combined prepend+append - // from being misclassified as prepend, which would skip the unread and auto-scroll branches. - let hasNewItems = newItems.count > previousCount - let wasPrepend = previousCount > 0 && hasNewItems - && oldItems.first?.id != newItems.first?.id - && oldItems.last?.id == newItems.last?.id - - // For prepends, capture a measured anchor row so we can restore the visible - // content's screen position after the snapshot apply changes contentSize. - let prependAnchor = wasPrepend ? capturePrependAnchor(in: oldItems) : nil - - // Apply snapshot with reversed order: newest-first for flipped table - // Row 0 = newest message → appears at visual bottom after flip - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(newItems.reversed().map(\.id)) - - // Find items that changed content (same ID, different hash). - // Without reconfiguring these, diffable data source won't update cells for items with same ID. - let oldItemsByID = Dictionary(uniqueKeysWithValues: oldItems.map { ($0.id, $0) }) - let changedIDs = newItems.compactMap { newItem -> Item.ID? in - guard let oldItem = oldItemsByID[newItem.id] else { return nil } - return oldItem != newItem ? newItem.id : nil - } + let previousCount = items.count + let wasAtBottom = isAtBottom + let oldItems = items + items = newItems + + // Build O(1) lookup dictionaries + itemsByID = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) + itemIndexByID = Dictionary(uniqueKeysWithValues: newItems.enumerated().map { ($0.element.id, $0.offset) }) + + // Detect prepend (pagination) vs append (new messages). A pure prepend changes the + // first id but not the last; requiring an unchanged tail keeps a combined prepend+append + // from being misclassified as prepend, which would skip the unread and auto-scroll branches. + let hasNewItems = newItems.count > previousCount + let wasPrepend = previousCount > 0 && hasNewItems + && oldItems.first?.id != newItems.first?.id + && oldItems.last?.id == newItems.last?.id + + // For prepends, capture a measured anchor row so we can restore the visible + // content's screen position after the snapshot apply changes contentSize. + let prependAnchor = wasPrepend ? capturePrependAnchor(in: oldItems) : nil + + // Apply snapshot with reversed order: newest-first for flipped table + // Row 0 = newest message → appears at visual bottom after flip + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(newItems.reversed().map(\.id)) + + // Find items that changed content (same ID, different hash). + // Without reconfiguring these, diffable data source won't update cells for items with same ID. + let oldItemsByID = Dictionary(uniqueKeysWithValues: oldItems.map { ($0.id, $0) }) + let changedIDs = newItems.compactMap { newItem -> Item.ID? in + guard let oldItem = oldItemsByID[newItem.id] else { return nil } + return oldItem != newItem ? newItem.id : nil + } - // Two-phase apply to handle structural changes and content updates differently: - // 1. Structural changes (new/deleted items) - animate for smooth UX, except prepends - // 2. Content updates (status changes) - no animation to prevent flash - let hasStructuralChanges = newItems.count != oldItems.count || - Set(newItems.map(\.id)) != Set(oldItems.map(\.id)) - - // Skip the apply when nothing changed. Otherwise re-renders triggered by - // non-content state (e.g. ChatRenderState.isLoadingOlder toggling) reach - // applySnapshot without prepend-anchor protection and can shift - // contentOffset, producing a visible jump while scrolling. - if previousCount > 0 && !hasStructuralChanges && changedIDs.isEmpty { - return - } + // Two-phase apply to handle structural changes and content updates differently: + // 1. Structural changes (new/deleted items) - animate for smooth UX, except prepends + // 2. Content updates (status changes) - no animation to prevent flash + let hasStructuralChanges = newItems.count != oldItems.count || + Set(newItems.map(\.id)) != Set(oldItems.map(\.id)) + + // Skip the apply when nothing changed. Otherwise re-renders triggered by + // non-content state (e.g. ChatRenderState.isLoadingOlder toggling) reach + // applySnapshot without prepend-anchor protection and can shift + // contentOffset, producing a visible jump while scrolling. + if previousCount > 0, !hasStructuralChanges, changedIDs.isEmpty { + return + } - // Prepends apply non-animated so anchor restoration in the apply completion runs - // against the post-apply layout, not against a coalesced animation in flight - let restoreClosure: (() -> Void)? = prependAnchor.map { anchor in - { [weak self] in self?.restorePrependAnchor(anchor) } - } + // Prepends apply non-animated so anchor restoration in the apply completion runs + // against the post-apply layout, not against a coalesced animation in flight + let restoreClosure: (() -> Void)? = prependAnchor.map { anchor in + { [weak self] in self?.restorePrependAnchor(anchor) } + } - if hasStructuralChanges { - let animateStructural = animated && previousCount > 0 && !wasPrepend - let structuralIsLastApply = changedIDs.isEmpty - applySnapshot( - snapshot, - animatingDifferences: animateStructural, - completion: structuralIsLastApply ? restoreClosure : nil - ) - - if !changedIDs.isEmpty { - var reconfigureSnapshot = snapshot - reconfigureSnapshot.reconfigureItems(changedIDs) - applySnapshot(reconfigureSnapshot, animatingDifferences: false, completion: restoreClosure) - } - } else if !changedIDs.isEmpty { - snapshot.reconfigureItems(changedIDs) - applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) - } else { - applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) - } + if hasStructuralChanges { + let animateStructural = animated && previousCount > 0 && !wasPrepend + let structuralIsLastApply = changedIDs.isEmpty + applySnapshot( + snapshot, + animatingDifferences: animateStructural, + completion: structuralIsLastApply ? restoreClosure : nil + ) + + if !changedIDs.isEmpty { + var reconfigureSnapshot = snapshot + reconfigureSnapshot.reconfigureItems(changedIDs) + applySnapshot(reconfigureSnapshot, animatingDifferences: false, completion: restoreClosure) + } + } else if !changedIDs.isEmpty { + snapshot.reconfigureItems(changedIDs) + applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) + } else { + applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) + } - // Handle unread tracking - if !wasAtBottom && previousCount > 0 && hasNewItems && !wasPrepend { - // New messages arrived while scrolled up (not pagination) - let newMessageCount = newItems.count - previousCount - unreadCount += newMessageCount - onScrollStateChanged?(isAtBottom, unreadCount) - } else if wasAtBottom && hasNewItems && !skipAutoScroll && scrollState.intent != .toBottom && !wasPrepend { - lastSeenItemID = newItems.last?.id - if scrollState.isUserDriven { - // Defer until drag ends — scrolling mid-drag fights the gesture and bounces - let accumulatedCount = (scrollState.deferredScroll?.targetMessageCount ?? 0) + (newItems.count - previousCount) - scrollState.scheduleDeferredScroll( - DeferredScroll(targetMessageCount: accumulatedCount) - ) - } else { - scrollToBottom(animated: animated && previousCount > 0) - } - } + // Handle unread tracking + if !wasAtBottom, previousCount > 0, hasNewItems, !wasPrepend { + // New messages arrived while scrolled up (not pagination) + let newMessageCount = newItems.count - previousCount + unreadCount += newMessageCount + onScrollStateChanged?(isAtBottom, unreadCount) + } else if wasAtBottom, hasNewItems, !skipAutoScroll, scrollState.intent != .toBottom, !wasPrepend { + lastSeenItemID = newItems.last?.id + if scrollState.isUserDriven { + // Defer until drag ends — scrolling mid-drag fights the gesture and bounces + let accumulatedCount = (scrollState.deferredScroll?.targetMessageCount ?? 0) + (newItems.count - previousCount) + scrollState.scheduleDeferredScroll( + DeferredScroll(targetMessageCount: accumulatedCount) + ) + } else { + scrollToBottom(animated: animated && previousCount > 0) + } + } - // Re-evaluate visible mentions and divider after layout settles. Both initialize - // assuming a scroll will correct them; without this an on-load viewport that already - // contains the divider or a mention is never reconciled until the first scroll. - scheduleVisibleMentionsRecheck() + // Re-evaluate visible mentions and divider after layout settles. Both initialize + // assuming a scroll will correct them; without this an on-load viewport that already + // contains the divider or a mention is never reconciled until the first scroll. + scheduleVisibleMentionsRecheck() - if let pendingID = pendingScrollTargetID { - schedulePendingScroll(for: pendingID, delay: ChatScrollConstants.pendingScrollInitialDelay) - } + if let pendingID = pendingScrollTargetID { + schedulePendingScroll(for: pendingID, delay: ChatScrollConstants.pendingScrollInitialDelay) + } + } + + // MARK: - Scroll Control + + /// Called before updateItems when user sends a message. + /// Sets isAtBottom = true so updateItems won't increment unread. + func prepareForUserSend() { + isAtBottom = true + unreadCount = 0 + _ = scrollState.consumeDeferredScroll() + skipAutoScroll = true // Prevent updateItems from calling scrollToBottom (we'll do it explicitly) + } + + func scrollToBottom(animated: Bool) { + guard !items.isEmpty else { return } + + let alreadyAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon + + // Set state before scroll to prevent scroll delegate from overriding + isAtBottom = true + unreadCount = 0 + lastSeenItemID = items.last?.id + + // If already at bottom, just update state - no scroll needed. + // In a flipped table view with short content, scrollToRow miscalculates + // the target position and over-scrolls, pushing messages off screen. + if alreadyAtBottom { + scrollState.clearIntent() + onScrollStateChanged?(isAtBottom, unreadCount) + skipAutoScroll = false + return } - // MARK: - Scroll Control - - /// Called before updateItems when user sends a message. - /// Sets isAtBottom = true so updateItems won't increment unread. - func prepareForUserSend() { - isAtBottom = true - unreadCount = 0 - _ = scrollState.consumeDeferredScroll() - skipAutoScroll = true // Prevent updateItems from calling scrollToBottom (we'll do it explicitly) + // Only update intent if not already toBottom (keyboardWillShow may have set it) + if scrollState.intent != .toBottom, animated { + scrollState.startIntent(.toBottom) } - func scrollToBottom(animated: Bool) { - guard !items.isEmpty else { return } + // In flipped table with reversed data: row 0 = newest message + // Scroll row 0 to .top anchor (which is visual bottom in flipped table) + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: animated) - let alreadyAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon + if !animated { + scrollState.clearIntent() + } - // Set state before scroll to prevent scroll delegate from overriding - isAtBottom = true - unreadCount = 0 - lastSeenItemID = items.last?.id - - // If already at bottom, just update state - no scroll needed. - // In a flipped table view with short content, scrollToRow miscalculates - // the target position and over-scrolls, pushing messages off screen. - if alreadyAtBottom { - scrollState.clearIntent() - onScrollStateChanged?(isAtBottom, unreadCount) - skipAutoScroll = false - return - } + onScrollStateChanged?(isAtBottom, unreadCount) - // Only update intent if not already toBottom (keyboardWillShow may have set it) - if scrollState.intent != .toBottom && animated { - scrollState.startIntent(.toBottom) - } + // Clear skipAutoScroll after explicit scroll (it was set by prepareForUserSend) + skipAutoScroll = false + } - // In flipped table with reversed data: row 0 = newest message - // Scroll row 0 to .top anchor (which is visual bottom in flipped table) - tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: animated) + func scrollToItem(id: Item.ID, animated: Bool) { + // Use O(1) dictionary lookup instead of O(n) firstIndex + guard itemIndexByID[id] != nil else { return } - if !animated { - scrollState.clearIntent() - } + // Set target intent so the computed scrollTargetItemID picks up the post-scroll reload target + // and so checkNearTop blocks pagination during the scroll-to-target animation. + scrollState.startIntent(.toTarget(id: id)) + pendingScrollTargetID = id - onScrollStateChanged?(isAtBottom, unreadCount) + pendingScrollTask?.cancel() + pendingScrollTask = nil - // Clear skipAutoScroll after explicit scroll (it was set by prepareForUserSend) - skipAutoScroll = false + if animated { + schedulePendingScroll(for: id, delay: ChatScrollConstants.scrollToTargetDelay) + } else { + pendingScrollTargetID = nil + centerItem(id: id, animated: false) + reloadTargetCell() } + } - func scrollToItem(id: Item.ID, animated: Bool) { - // Use O(1) dictionary lookup instead of O(n) firstIndex - guard itemIndexByID[id] != nil else { return } - - // Set target intent so the computed scrollTargetItemID picks up the post-scroll reload target - // and so checkNearTop blocks pagination during the scroll-to-target animation. - scrollState.startIntent(.toTarget(id: id)) - pendingScrollTargetID = id + func scrollToItemIfNotVisible(id: Item.ID, animated: Bool) { + guard let itemIndex = itemIndexByID[id] else { return } + let rowIndex = items.count - 1 - itemIndex + let indexPath = IndexPath(row: rowIndex, section: 0) - pendingScrollTask?.cancel() - pendingScrollTask = nil - - if animated { - schedulePendingScroll(for: id, delay: ChatScrollConstants.scrollToTargetDelay) - } else { - pendingScrollTargetID = nil - centerItem(id: id, animated: false) - reloadTargetCell() - } + if let visibleRows = tableView.indexPathsForVisibleRows, + visibleRows.contains(indexPath) { + return } - func scrollToItemIfNotVisible(id: Item.ID, animated: Bool) { - guard let itemIndex = itemIndexByID[id] else { return } - let rowIndex = items.count - 1 - itemIndex - let indexPath = IndexPath(row: rowIndex, section: 0) + scrollToItem(id: id, animated: animated) + } - if let visibleRows = tableView.indexPathsForVisibleRows, - visibleRows.contains(indexPath) { - return - } + /// Reloads the scroll target cell to fix UIHostingConfiguration layout timing issues + private func reloadTargetCell() { + guard let targetID = scrollTargetItemID else { return } + scrollState.clearIntent() - scrollToItem(id: id, animated: animated) + // Force cell reconfiguration via snapshot reload + var snapshot = dataSource?.snapshot() ?? NSDiffableDataSourceSnapshot() + if snapshot.itemIdentifiers.contains(targetID) { + snapshot.reloadItems([targetID]) + applySnapshot(snapshot, animatingDifferences: false) } - - /// Reloads the scroll target cell to fix UIHostingConfiguration layout timing issues - private func reloadTargetCell() { - guard let targetID = scrollTargetItemID else { return } + } + + /// Reconfigures every current row in place so render-time, non-`MessageItem` styling + /// (the themed bubble fill) repaints on a live theme switch. `reconfigureItems` preserves + /// identity (no insert/delete, no scroll jump) and routes through the same serialized + /// `applySnapshot` path as every other apply. + func reconfigureAllItems() { + guard let dataSource else { return } + var snapshot = dataSource.snapshot() + guard !snapshot.itemIdentifiers.isEmpty else { return } + snapshot.reconfigureItems(snapshot.itemIdentifiers) + applySnapshot(snapshot, animatingDifferences: false, reconfigureAll: true) + } + + /// Returns true if the target was found in the applied snapshot and scrolled. + /// Returns false when the snapshot has not yet caught up to the items model, + /// letting the caller retry instead of silently dropping the scroll. + @discardableResult + private func centerItem(id: Item.ID, animated: Bool) -> Bool { + guard let indexPath = snapshotRow(for: id) else { return false } + tableView.layoutIfNeeded() + // Intent is already .toTarget(id:) from scrollToItem; no need to set again here. + tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) + return true + } + + private func schedulePendingScroll( + for id: Item.ID, + delay: Duration, + retriesRemaining: Int = ChatScrollConstants.pendingScrollMaxRetries + ) { + pendingScrollTask?.cancel() + pendingScrollTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self, !Task.isCancelled else { return } + guard pendingScrollTargetID == id, !scrollState.isUserDriven else { return } + pendingScrollTask = nil + if centerItem(id: id, animated: true) { + pendingScrollTargetID = nil + } else if retriesRemaining > 0 { + // The applied snapshot has not caught up to the items model yet. + // Keep the target armed and retry once it has had a chance to drain. + schedulePendingScroll( + for: id, + delay: ChatScrollConstants.pendingScrollRetryDelay, + retriesRemaining: retriesRemaining - 1 + ) + } else { + // No scrollToRow fired, so reloadTargetCell never clears the .toTarget + // intent; clear it here or checkNearTop will block pagination. + pendingScrollTargetID = nil scrollState.clearIntent() - - // Force cell reconfiguration via snapshot reload - var snapshot = dataSource?.snapshot() ?? NSDiffableDataSourceSnapshot() - if snapshot.itemIdentifiers.contains(targetID) { - snapshot.reloadItems([targetID]) - applySnapshot(snapshot, animatingDifferences: false) - } + } } + } - /// Reconfigures every current row in place so render-time, non-`MessageItem` styling - /// (the themed bubble fill) repaints on a live theme switch. `reconfigureItems` preserves - /// identity (no insert/delete, no scroll jump) and routes through the same serialized - /// `applySnapshot` path as every other apply. - func reconfigureAllItems() { - guard let dataSource else { return } - var snapshot = dataSource.snapshot() - guard !snapshot.itemIdentifiers.isEmpty else { return } - snapshot.reconfigureItems(snapshot.itemIdentifiers) - applySnapshot(snapshot, animatingDifferences: false, reconfigureAll: true) - } + // MARK: - Scroll Tracking - /// Returns true if the target was found in the applied snapshot and scrolled. - /// Returns false when the snapshot has not yet caught up to the items model, - /// letting the caller retry instead of silently dropping the scroll. - @discardableResult - private func centerItem(id: Item.ID, animated: Bool) -> Bool { - guard let indexPath = snapshotRow(for: id) else { return false } - tableView.layoutIfNeeded() - // Intent is already .toTarget(id:) from scrollToItem; no need to set again here. - tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) - return true + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + // Arm the coalescer; the display link's next tick drains the callbacks. + // Unpause only on the first hit of each burst — flipping `isPaused` is + // cheap but unnecessary when already running. + if !hasPendingScrollObservation { + hasPendingScrollObservation = true + scrollDisplayLink?.isPaused = false } - - private func schedulePendingScroll( - for id: Item.ID, - delay: Duration, - retriesRemaining: Int = ChatScrollConstants.pendingScrollMaxRetries - ) { - pendingScrollTask?.cancel() - pendingScrollTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: delay) - guard let self, !Task.isCancelled else { return } - guard self.pendingScrollTargetID == id, !self.scrollState.isUserDriven else { return } - self.pendingScrollTask = nil - if self.centerItem(id: id, animated: true) { - self.pendingScrollTargetID = nil - } else if retriesRemaining > 0 { - // The applied snapshot has not caught up to the items model yet. - // Keep the target armed and retry once it has had a chance to drain. - self.schedulePendingScroll( - for: id, - delay: ChatScrollConstants.pendingScrollRetryDelay, - retriesRemaining: retriesRemaining - 1 - ) - } else { - // No scrollToRow fired, so reloadTargetCell never clears the .toTarget - // intent; clear it here or checkNearTop will block pagination. - self.pendingScrollTargetID = nil - self.scrollState.clearIntent() - } - } + } + + /// Re-checks mention and divider state after layout settles. The settle delay lets a snapshot + /// apply or a freshly loaded `unseenMentionIDs` finish before row positions are read. + func scheduleVisibleMentionsRecheck() { + checkVisibleMentionsTask?.cancel() + checkVisibleMentionsTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: ChatScrollConstants.layoutSettleDelay) + guard let self, !Task.isCancelled else { return } + let visible = visibleItems() + checkVisibleMentions(visible: visible) + reportOffscreenMentions(visible: visible) + checkDividerVisibility() } - - // MARK: - Scroll Tracking - - override func scrollViewDidScroll(_ scrollView: UIScrollView) { - // Arm the coalescer; the display link's next tick drains the callbacks. - // Unpause only on the first hit of each burst — flipping `isPaused` is - // cheap but unnecessary when already running. - if !hasPendingScrollObservation { - hasPendingScrollObservation = true - scrollDisplayLink?.isPaused = false - } + } + + /// Items backing the currently visible rows, resolved through the applied snapshot so a model + /// that is momentarily ahead of its snapshot can't map a visible row to the wrong item. + private func visibleItems() -> [Item] { + guard let visibleIndexPaths = tableView.indexPathsForVisibleRows else { return [] } + return visibleIndexPaths.compactMap { indexPath in + guard let id = dataSource?.itemIdentifier(for: indexPath) else { return nil } + return itemsByID[id] } - - /// Re-checks mention and divider state after layout settles. The settle delay lets a snapshot - /// apply or a freshly loaded `unseenMentionIDs` finish before row positions are read. - func scheduleVisibleMentionsRecheck() { - checkVisibleMentionsTask?.cancel() - checkVisibleMentionsTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: ChatScrollConstants.layoutSettleDelay) - guard let self, !Task.isCancelled else { return } - let visible = self.visibleItems() - self.checkVisibleMentions(visible: visible) - self.reportOffscreenMentions(visible: visible) - self.checkDividerVisibility() - } + } + + /// Reports the unseen mentions not currently on screen, the subset that drives the + /// scroll-to-mention button; a mention already in view is excluded. + private func reportOffscreenMentions(visible: [Item]) { + guard let onOffscreenMentionsChanged else { return } + let offscreen: [Item.ID] + if unseenMentionIDs.isEmpty { + offscreen = [] + } else { + let visibleIDs = Set(visible.map(\.id)) + offscreen = unseenMentionIDs.filter { !visibleIDs.contains($0) } } - - /// Items backing the currently visible rows, resolved through the applied snapshot so a model - /// that is momentarily ahead of its snapshot can't map a visible row to the wrong item. - private func visibleItems() -> [Item] { - guard let visibleIndexPaths = tableView.indexPathsForVisibleRows else { return [] } - return visibleIndexPaths.compactMap { indexPath in - guard let id = dataSource?.itemIdentifier(for: indexPath) else { return nil } - return itemsByID[id] - } + if offscreen != lastReportedOffscreenMentionIDs { + lastReportedOffscreenMentionIDs = offscreen + onOffscreenMentionsChanged(offscreen) } - - /// Reports the unseen mentions not currently on screen, the subset that drives the - /// scroll-to-mention button; a mention already in view is excluded. - private func reportOffscreenMentions(visible: [Item]) { - guard let onOffscreenMentionsChanged else { return } - let offscreen: [Item.ID] - if unseenMentionIDs.isEmpty { - offscreen = [] - } else { - let visibleIDs = Set(visible.map(\.id)) - offscreen = unseenMentionIDs.filter { !visibleIDs.contains($0) } - } - if offscreen != lastReportedOffscreenMentionIDs { - lastReportedOffscreenMentionIDs = offscreen - onOffscreenMentionsChanged(offscreen) + } + + private func checkVisibleMentions(visible: [Item]) { + guard let isUnseenMention, let onMentionBecameVisible else { return } + + for item in visible { + // Report each mention once per session. Mark optimistically to debounce in-flight + // reports, then drop the mark if the async seen-persist fails, so a failed save does + // not strand a still-unread mention that a later scroll could otherwise re-fire. + if !markedMentionIDs.contains(item.id), isUnseenMention(item) { + let id = item.id + markedMentionIDs.insert(id) + Task { @MainActor [weak self] in + let persisted = await onMentionBecameVisible(id) + if !persisted { self?.markedMentionIDs.remove(id) } } + } } - - private func checkVisibleMentions(visible: [Item]) { - guard let isUnseenMention, let onMentionBecameVisible else { return } - - for item in visible { - // Report each mention once per session. Mark optimistically to debounce in-flight - // reports, then drop the mark if the async seen-persist fails, so a failed save does - // not strand a still-unread mention that a later scroll could otherwise re-fire. - if !markedMentionIDs.contains(item.id) && isUnseenMention(item) { - let id = item.id - markedMentionIDs.insert(id) - Task { @MainActor [weak self] in - let persisted = await onMentionBecameVisible(id) - if !persisted { self?.markedMentionIDs.remove(id) } - } - } - } + } + + private func checkDividerVisibility() { + guard let dividerItemID, + let indexPath = snapshotRow(for: dividerItemID), + let onDividerVisibilityChanged else { + // No divider configured or not yet in the applied snapshot — report + // not visible if we previously reported visible. + if lastDividerVisible == true { + lastDividerVisible = false + onDividerVisibilityChanged?(false) + } + return } - private func checkDividerVisibility() { - guard let dividerItemID, - let indexPath = snapshotRow(for: dividerItemID), - let onDividerVisibilityChanged else { - // No divider configured or not yet in the applied snapshot — report - // not visible if we previously reported visible. - if lastDividerVisible == true { - lastDividerVisible = false - self.onDividerVisibilityChanged?(false) - } - return - } - - let isVisible = tableView.indexPathsForVisibleRows?.contains(indexPath) ?? false + let isVisible = tableView.indexPathsForVisibleRows?.contains(indexPath) ?? false - if isVisible != lastDividerVisible { - lastDividerVisible = isVisible - onDividerVisibilityChanged(isVisible) - } + if isVisible != lastDividerVisible { + lastDividerVisible = isVisible + onDividerVisibilityChanged(isVisible) } + } - override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { - scrollState.endDragging() - finalizeScrollPosition() - fireDeferredScrollIfNeeded() - } - drainDeferredItemsApply() + override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + scrollState.endDragging() + finalizeScrollPosition() + fireDeferredScrollIfNeeded() } - - override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - scrollState.endDragging() - finalizeScrollPosition() - fireDeferredScrollIfNeeded() - drainDeferredItemsApply() + drainDeferredItemsApply() + } + + override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + scrollState.endDragging() + finalizeScrollPosition() + fireDeferredScrollIfNeeded() + drainDeferredItemsApply() + } + + private func drainDeferredItemsApply() { + guard let deferred = deferredItemsApply else { return } + deferredItemsApply = nil + updateItems(deferred.newItems, animated: deferred.animated) + } + + private func fireDeferredScrollIfNeeded() { + guard let deferred = scrollState.consumeDeferredScroll() else { return } + if isAtBottom { + scrollToBottom(animated: true) + } else { + // User dragged away mid-message — the messages they didn't see become unread + unreadCount += deferred.targetMessageCount + onScrollStateChanged?(isAtBottom, unreadCount) } + } - private func drainDeferredItemsApply() { - guard let deferred = deferredItemsApply else { return } - deferredItemsApply = nil - updateItems(deferred.newItems, animated: deferred.animated) + override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + // Capture whether the completed animation was a scroll-to-bottom before mutating intent. + let wasScrollingToBottom = scrollState.intent == .toBottom + if wasScrollingToBottom { + scrollState.clearIntent() } - private func fireDeferredScrollIfNeeded() { - guard let deferred = scrollState.consumeDeferredScroll() else { return } - if isAtBottom { - scrollToBottom(animated: true) - } else { - // User dragged away mid-message — the messages they didn't see become unread - unreadCount += deferred.targetMessageCount - onScrollStateChanged?(isAtBottom, unreadCount) - } - } + // Reload target cell after scroll completes to fix UIHostingConfiguration layout timing. + // reloadTargetCell clears any .toTarget intent. + reloadTargetCell() - override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - // Capture whether the completed animation was a scroll-to-bottom before mutating intent. - let wasScrollingToBottom = scrollState.intent == .toBottom - if wasScrollingToBottom { - scrollState.clearIntent() - } + if wasScrollingToBottom { + // We just finished a programmatic scroll-to-bottom + // Use larger threshold since animation might not land exactly at 0 + let atBottom = scrollView.contentOffset.y <= ChatScrollConstants.bottomLandingEpsilon + if atBottom { + // Confirm we're at bottom - this is authoritative + isAtBottom = true + unreadCount = 0 + onScrollStateChanged?(isAtBottom, unreadCount) + return + } + } - // Reload target cell after scroll completes to fix UIHostingConfiguration layout timing. - // reloadTargetCell clears any .toTarget intent. - reloadTargetCell() - - if wasScrollingToBottom { - // We just finished a programmatic scroll-to-bottom - // Use larger threshold since animation might not land exactly at 0 - let atBottom = scrollView.contentOffset.y <= ChatScrollConstants.bottomLandingEpsilon - if atBottom { - // Confirm we're at bottom - this is authoritative - isAtBottom = true - unreadCount = 0 - onScrollStateChanged?(isAtBottom, unreadCount) - return - } - } + // For user-initiated scrolls or if we didn't land at bottom, use normal check + updateIsAtBottom() + } - // For user-initiated scrolls or if we didn't land at bottom, use normal check - updateIsAtBottom() + private func updateIsAtBottom() { + // Don't override isAtBottom during programmatic scroll-to-bottom animation + // This prevents the scroll-to-bottom button from flickering when user sends a message + if scrollState.intent == .toBottom { + return } - private func updateIsAtBottom() { - // Don't override isAtBottom during programmatic scroll-to-bottom animation - // This prevents the scroll-to-bottom button from flickering when user sends a message - if scrollState.intent == .toBottom { - return - } - - // In flipped table, visual bottom = contentOffset.y near 0 - // Use small threshold to handle float imprecision - let newIsAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon + // In flipped table, visual bottom = contentOffset.y near 0 + // Use small threshold to handle float imprecision + let newIsAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon - if newIsAtBottom != isAtBottom { - isAtBottom = newIsAtBottom - onScrollStateChanged?(isAtBottom, unreadCount) - } + if newIsAtBottom != isAtBottom { + isAtBottom = newIsAtBottom + onScrollStateChanged?(isAtBottom, unreadCount) } - - private func finalizeScrollPosition() { - if isAtBottom { - // User scrolled to bottom, clear unread - unreadCount = 0 - lastSeenItemID = items.last?.id - onScrollStateChanged?(isAtBottom, unreadCount) - } + } + + private func finalizeScrollPosition() { + if isAtBottom { + // User scrolled to bottom, clear unread + unreadCount = 0 + lastSeenItemID = items.last?.id + onScrollStateChanged?(isAtBottom, unreadCount) } + } - /// Check if user has scrolled near the top (oldest messages) and trigger callback - private func checkNearTop() { - if scrollState.intent != .none || isLoadingOlderMessages || isNearTopRequestInFlight { - return - } - guard let visibleRows = tableView.indexPathsForVisibleRows, - let highestRow = visibleRows.map(\.row).max() else { return } - - let totalRows = items.count - let distanceFromTop = totalRows - highestRow - - // Trigger when within nearTopTriggerDistance messages of the oldest - if distanceFromTop <= ChatScrollConstants.nearTopTriggerDistance { - isNearTopRequestInFlight = true - onNearTop? { @MainActor [weak self] in - self?.isNearTopRequestInFlight = false - } - } + /// Check if user has scrolled near the top (oldest messages) and trigger callback + private func checkNearTop() { + if scrollState.intent != .none || isLoadingOlderMessages || isNearTopRequestInFlight { + return } - - override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - scrollState.enterDragging() - pendingScrollTargetID = nil - pendingScrollTask?.cancel() - pendingScrollTask = nil + guard let visibleRows = tableView.indexPathsForVisibleRows, + let highestRow = visibleRows.map(\.row).max() else { return } + + let totalRows = items.count + let distanceFromTop = totalRows - highestRow + + // Trigger when within nearTopTriggerDistance messages of the oldest + if distanceFromTop <= ChatScrollConstants.nearTopTriggerDistance { + isNearTopRequestInFlight = true + onNearTop? { @MainActor [weak self] in + self?.isNearTopRequestInFlight = false + } } + } + + override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollState.enterDragging() + pendingScrollTargetID = nil + pendingScrollTask?.cancel() + pendingScrollTask = nil + } } // MARK: - SwiftUI Wrapper /// SwiftUI wrapper for ChatTableViewController struct ChatTableView: UIViewControllerRepresentable where Item.ID == UUID { - - let items: [Item] - let cellContent: (Item) -> Content - /// Themed canvas color for themes that paint a canvas (Ember → black, paid themes → - /// asset-catalog tint); `nil` leaves the table's default system background untouched, so - /// themes without surfaces are unchanged. - var contentBackground: Color? - /// Active theme id (`Theme.id`). Drives a one-shot reconfigure of all rows on a theme change - /// so the render-time bubble fill (`\.appTheme.accentColor`, not part of `MessageItem`) repaints - /// even when no baked text changed — e.g. switching between two themes that share white text. - var themeID: String = Theme.default.id - /// Appearance fingerprint (light/dark + contrast). Like `themeID`, a change reconfigures all rows - /// once so identity colors that depend on the appearance repaint in place. - var appearanceToken: String = "" - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - @Binding var scrollToMentionRequest: Int - var isUnseenMention: ((Item) -> Bool)? - /// The current unseen self-mention ids. Feeds the controller's off-screen subset and - /// triggers a recheck when the set changes; the per-row test goes through `isUnseenMention`. - var unseenMentionIDs: [Item.ID] = [] - /// Unseen mentions currently off screen, reported up from the controller; drives the - /// scroll-to-mention button's visibility, count, and scroll target. - @Binding var offscreenMentionIDs: [Item.ID] - var onMentionBecameVisible: ((Item.ID) async -> Bool)? - var onSecondaryClick: ((Item) -> Void)? - var mentionTargetID: Item.ID? - @Binding var scrollToDividerRequest: Int - var dividerItemID: Item.ID? - @Binding var isDividerVisible: Bool - var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? - var isLoadingOlderMessages: Bool = false - - func makeUIViewController(context: Context) -> ChatTableViewController { - let controller = ChatTableViewController() - controller.defaultTableBackgroundColor = controller.tableView.backgroundColor - controller.configure { item in - cellContent(item) - } - // Callback set up in updateUIViewController - context.coordinator.lastScrollRequest = scrollToBottomRequest - controller.isUnseenMention = isUnseenMention - context.coordinator.lastMentionRequest = scrollToMentionRequest - context.coordinator.lastDividerScrollRequest = scrollToDividerRequest - return controller + let items: [Item] + let cellContent: (Item) -> Content + /// Themed canvas color for themes that paint a canvas (Ember → black, paid themes → + /// asset-catalog tint); `nil` leaves the table's default system background untouched, so + /// themes without surfaces are unchanged. + var contentBackground: Color? + /// Active theme id (`Theme.id`). Drives a one-shot reconfigure of all rows on a theme change + /// so the render-time bubble fill (`\.appTheme.accentColor`, not part of `MessageItem`) repaints + /// even when no baked text changed — e.g. switching between two themes that share white text. + var themeID: String = Theme.default.id + /// Appearance fingerprint (light/dark + contrast). Like `themeID`, a change reconfigures all rows + /// once so identity colors that depend on the appearance repaint in place. + var appearanceToken: String = "" + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + @Binding var scrollToMentionRequest: Int + var isUnseenMention: ((Item) -> Bool)? + /// The current unseen self-mention ids. Feeds the controller's off-screen subset and + /// triggers a recheck when the set changes; the per-row test goes through `isUnseenMention`. + var unseenMentionIDs: [Item.ID] = [] + /// Unseen mentions currently off screen, reported up from the controller; drives the + /// scroll-to-mention button's visibility, count, and scroll target. + @Binding var offscreenMentionIDs: [Item.ID] + var onMentionBecameVisible: ((Item.ID) async -> Bool)? + var onSecondaryClick: ((Item) -> Void)? + var mentionTargetID: Item.ID? + @Binding var scrollToDividerRequest: Int + var dividerItemID: Item.ID? + @Binding var isDividerVisible: Bool + var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? + var isLoadingOlderMessages: Bool = false + + func makeUIViewController(context: Context) -> ChatTableViewController { + let controller = ChatTableViewController() + controller.defaultTableBackgroundColor = controller.tableView.backgroundColor + controller.configure { item in + cellContent(item) + } + // Callback set up in updateUIViewController + context.coordinator.lastScrollRequest = scrollToBottomRequest + controller.isUnseenMention = isUnseenMention + context.coordinator.lastMentionRequest = scrollToMentionRequest + context.coordinator.lastDividerScrollRequest = scrollToDividerRequest + return controller + } + + func updateUIViewController(_ controller: ChatTableViewController, context: Context) { + // Update cell content provider each render cycle so reconfigured cells + // get fresh closures (e.g., onRetry callback when message status changes) + controller.configure { item in + cellContent(item) + } + controller.tableView.backgroundColor = contentBackground.map(UIColor.init) ?? controller.defaultTableBackgroundColor + + // Repaint visible bubbles whose render-time accent fill is not part of `MessageItem`: + // a theme-id change reconfigures all rows in place once. The gate skips the first + // pass (no previous id) so appearance does not trigger a needless reconfigure. + let themeChanged = context.coordinator.lastThemeID.map { $0 != themeID } ?? false + let appearanceChanged = context.coordinator.lastAppearanceToken.map { $0 != appearanceToken } ?? false + if themeChanged || appearanceChanged { + controller.reconfigureAllItems() + } + context.coordinator.lastThemeID = themeID + context.coordinator.lastAppearanceToken = appearanceToken + + // Store current binding setters in coordinator (updated each render cycle) + // This ensures deferred callbacks always use fresh bindings + context.coordinator.setIsAtBottom = { [self] in isAtBottom = $0 } + context.coordinator.setUnreadCount = { [self] in unreadCount = $0 } + + // Controller callback defers to next MainActor yield via coordinator. + // SwiftUI blocks binding updates during updateUIViewController, so we must + // defer the update to after the current update cycle completes. + controller.onScrollStateChanged = { [weak coordinator = context.coordinator] atBottom, unread in + Task { @MainActor in + coordinator?.setIsAtBottom?(atBottom) + coordinator?.setUnreadCount?(unread) + } } - func updateUIViewController(_ controller: ChatTableViewController, context: Context) { - // Update cell content provider each render cycle so reconfigured cells - // get fresh closures (e.g., onRetry callback when message status changes) - controller.configure { item in - cellContent(item) - } - controller.tableView.backgroundColor = contentBackground.map(UIColor.init) ?? controller.defaultTableBackgroundColor - - // Repaint visible bubbles whose render-time accent fill is not part of `MessageItem`: - // a theme-id change reconfigures all rows in place once. The gate skips the first - // pass (no previous id) so appearance does not trigger a needless reconfigure. - let themeChanged = context.coordinator.lastThemeID.map { $0 != themeID } ?? false - let appearanceChanged = context.coordinator.lastAppearanceToken.map { $0 != appearanceToken } ?? false - if themeChanged || appearanceChanged { - controller.reconfigureAllItems() - } - context.coordinator.lastThemeID = themeID - context.coordinator.lastAppearanceToken = appearanceToken - - // Store current binding setters in coordinator (updated each render cycle) - // This ensures deferred callbacks always use fresh bindings - context.coordinator.setIsAtBottom = { [self] in isAtBottom = $0 } - context.coordinator.setUnreadCount = { [self] in unreadCount = $0 } - - // Controller callback defers to next MainActor yield via coordinator. - // SwiftUI blocks binding updates during updateUIViewController, so we must - // defer the update to after the current update cycle completes. - controller.onScrollStateChanged = { [weak coordinator = context.coordinator] atBottom, unread in - Task { @MainActor in - coordinator?.setIsAtBottom?(atBottom) - coordinator?.setUnreadCount?(unread) - } - } - - // Update mention detection closures - controller.isUnseenMention = isUnseenMention - controller.unseenMentionIDs = unseenMentionIDs - controller.onMentionBecameVisible = onMentionBecameVisible - controller.onSecondaryClick = onSecondaryClick - - context.coordinator.setOffscreenMentionIDs = { [self] in offscreenMentionIDs = $0 } - controller.onOffscreenMentionsChanged = { [weak coordinator = context.coordinator] ids in - // Defer: SwiftUI forbids binding writes during updateUIViewController. - Task { @MainActor in - coordinator?.setOffscreenMentionIDs?(ids) - } - } - - // unseenMentionIDs can load after the first render, with no item change to trigger the - // recheck inside updateItems. Re-run it here so the off-screen set reflects the new ids. - if context.coordinator.lastUnseenMentionIDs != unseenMentionIDs { - context.coordinator.lastUnseenMentionIDs = unseenMentionIDs - controller.scheduleVisibleMentionsRecheck() - } - - // Update divider visibility tracking - controller.dividerItemID = dividerItemID - context.coordinator.setIsDividerVisible = { [self] in isDividerVisible = $0 } - controller.onDividerVisibilityChanged = { [weak coordinator = context.coordinator] visible in - Task { @MainActor in - coordinator?.setIsDividerVisible?(visible) - } - } - - // Update pagination state - controller.onNearTop = onNearTop - controller.isLoadingOlderMessages = isLoadingOlderMessages + // Update mention detection closures + controller.isUnseenMention = isUnseenMention + controller.unseenMentionIDs = unseenMentionIDs + controller.onMentionBecameVisible = onMentionBecameVisible + controller.onSecondaryClick = onSecondaryClick + + context.coordinator.setOffscreenMentionIDs = { [self] in offscreenMentionIDs = $0 } + controller.onOffscreenMentionsChanged = { [weak coordinator = context.coordinator] ids in + // Defer: SwiftUI forbids binding writes during updateUIViewController. + Task { @MainActor in + coordinator?.setOffscreenMentionIDs?(ids) + } + } - // Check for scroll-to-mention request - let shouldScrollToMention = scrollToMentionRequest != context.coordinator.lastMentionRequest - var shouldScrollMentionToBottom = false - var mentionScrollTargetID: Item.ID? + // unseenMentionIDs can load after the first render, with no item change to trigger the + // recheck inside updateItems. Re-run it here so the off-screen set reflects the new ids. + if context.coordinator.lastUnseenMentionIDs != unseenMentionIDs { + context.coordinator.lastUnseenMentionIDs = unseenMentionIDs + controller.scheduleVisibleMentionsRecheck() + } - if shouldScrollToMention { - context.coordinator.lastMentionRequest = scrollToMentionRequest - mentionScrollTargetID = mentionTargetID + // Update divider visibility tracking + controller.dividerItemID = dividerItemID + context.coordinator.setIsDividerVisible = { [self] in isDividerVisible = $0 } + controller.onDividerVisibilityChanged = { [weak coordinator = context.coordinator] visible in + Task { @MainActor in + coordinator?.setIsDividerVisible?(visible) + } + } - let newestItemID = items.last?.id - shouldScrollMentionToBottom = ChatScrollToMentionPolicy.shouldScrollToBottom( - mentionTargetID: mentionTargetID, - newestItemID: newestItemID - ) - } + // Update pagination state + controller.onNearTop = onNearTop + controller.isLoadingOlderMessages = isLoadingOlderMessages - // Check for scroll-to-divider request (new messages divider) - let shouldScrollToDivider = scrollToDividerRequest != context.coordinator.lastDividerScrollRequest - if shouldScrollToDivider { - context.coordinator.lastDividerScrollRequest = scrollToDividerRequest - } + // Check for scroll-to-mention request + let shouldScrollToMention = scrollToMentionRequest != context.coordinator.lastMentionRequest + var shouldScrollMentionToBottom = false + var mentionScrollTargetID: Item.ID? - // Check for scroll-to-bottom request before updating items - // This ensures user sends don't trigger unread badge - let shouldForceScroll = scrollToBottomRequest != context.coordinator.lastScrollRequest + if shouldScrollToMention { + context.coordinator.lastMentionRequest = scrollToMentionRequest + mentionScrollTargetID = mentionTargetID - if shouldForceScroll { - context.coordinator.lastScrollRequest = scrollToBottomRequest - // Mark as at bottom so updateItems won't increment unread - controller.prepareForUserSend() - } + let newestItemID = items.last?.id + shouldScrollMentionToBottom = ChatScrollToMentionPolicy.shouldScrollToBottom( + mentionTargetID: mentionTargetID, + newestItemID: newestItemID + ) + } - controller.updateItems(items) - - // Perform the scroll after items are updated - if shouldForceScroll { - controller.scrollToBottom(animated: true) - } else if shouldScrollToMention { - if shouldScrollMentionToBottom { - controller.scrollToBottom(animated: true) - } else if let targetID = mentionScrollTargetID { - controller.scrollToItem(id: targetID, animated: true) - } - } else if shouldScrollToDivider, let targetID = dividerItemID { - controller.scrollToItem(id: targetID, animated: true) - } + // Check for scroll-to-divider request (new messages divider) + let shouldScrollToDivider = scrollToDividerRequest != context.coordinator.lastDividerScrollRequest + if shouldScrollToDivider { + context.coordinator.lastDividerScrollRequest = scrollToDividerRequest } - func makeCoordinator() -> Coordinator { - Coordinator() + // Check for scroll-to-bottom request before updating items + // This ensures user sends don't trigger unread badge + let shouldForceScroll = scrollToBottomRequest != context.coordinator.lastScrollRequest + + if shouldForceScroll { + context.coordinator.lastScrollRequest = scrollToBottomRequest + // Mark as at bottom so updateItems won't increment unread + controller.prepareForUserSend() } - @MainActor - class Coordinator { - var lastScrollRequest: Int = 0 - var lastMentionRequest: Int = 0 - var lastDividerScrollRequest: Int = 0 - var lastThemeID: String? - var lastAppearanceToken: String? - var setIsAtBottom: ((Bool) -> Void)? - var setUnreadCount: ((Int) -> Void)? - var setIsDividerVisible: ((Bool) -> Void)? - var setOffscreenMentionIDs: (([Item.ID]) -> Void)? - var lastUnseenMentionIDs: [Item.ID] = [] + controller.updateItems(items) + + // Perform the scroll after items are updated + if shouldForceScroll { + controller.scrollToBottom(animated: true) + } else if shouldScrollToMention { + if shouldScrollMentionToBottom { + controller.scrollToBottom(animated: true) + } else if let targetID = mentionScrollTargetID { + controller.scrollToItem(id: targetID, animated: true) + } + } else if shouldScrollToDivider, let targetID = dividerItemID { + controller.scrollToItem(id: targetID, animated: true) } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + @MainActor + class Coordinator { + var lastScrollRequest: Int = 0 + var lastMentionRequest: Int = 0 + var lastDividerScrollRequest: Int = 0 + var lastThemeID: String? + var lastAppearanceToken: String? + var setIsAtBottom: ((Bool) -> Void)? + var setUnreadCount: ((Int) -> Void)? + var setIsDividerVisible: ((Bool) -> Void)? + var setOffscreenMentionIDs: (([Item.ID]) -> Void)? + var lastUnseenMentionIDs: [Item.ID] = [] + } } diff --git a/MC1/Views/Chats/Components/FallbackMatchIndicatorView.swift b/MC1/Views/Chats/Components/FallbackMatchIndicatorView.swift index c36eb94c..75671b51 100644 --- a/MC1/Views/Chats/Components/FallbackMatchIndicatorView.swift +++ b/MC1/Views/Chats/Components/FallbackMatchIndicatorView.swift @@ -3,50 +3,50 @@ import SwiftUI /// Tappable indicator showing a node name was resolved from a short prefix with multiple matches. struct FallbackMatchIndicatorView: View { - let accessibilityLabel: String - let accessibilityHint: String - let title: String - let explanation: String + let accessibilityLabel: String + let accessibilityHint: String + let title: String + let explanation: String - @State private var isShowingExplanation = false + @State private var isShowingExplanation = false - init( - accessibilityLabel: String = L10n.Chats.Chats.Path.Hop.possibleMatch, - accessibilityHint: String = L10n.Chats.Chats.Path.Hop.possibleMatchExplanation, - title: String = L10n.Chats.Chats.Path.Hop.possibleMatchTitle, - explanation: String = L10n.Chats.Chats.Path.Hop.possibleMatchExplanation - ) { - self.accessibilityLabel = accessibilityLabel - self.accessibilityHint = accessibilityHint - self.title = title - self.explanation = explanation - } + init( + accessibilityLabel: String = L10n.Chats.Chats.Path.Hop.possibleMatch, + accessibilityHint: String = L10n.Chats.Chats.Path.Hop.possibleMatchExplanation, + title: String = L10n.Chats.Chats.Path.Hop.possibleMatchTitle, + explanation: String = L10n.Chats.Chats.Path.Hop.possibleMatchExplanation + ) { + self.accessibilityLabel = accessibilityLabel + self.accessibilityHint = accessibilityHint + self.title = title + self.explanation = explanation + } - var body: some View { - Button { - isShowingExplanation = true - } label: { - Image(systemName: "questionmark.circle") - .imageScale(.small) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint(accessibilityHint) - .popover(isPresented: $isShowingExplanation) { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.headline) + var body: some View { + Button { + isShowingExplanation = true + } label: { + Image(systemName: "questionmark.circle") + .imageScale(.small) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(accessibilityHint) + .popover(isPresented: $isShowingExplanation) { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) - Text(explanation) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding() - .frame(idealWidth: 280, maxWidth: 300) - .presentationSizing(.fitted) - .presentationCompactAdaptation(.popover) - } + Text(explanation) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .frame(idealWidth: 280, maxWidth: 300) + .presentationSizing(.fitted) + .presentationCompactAdaptation(.popover) } + } } diff --git a/MC1/Views/Chats/Components/FragmentLayout.swift b/MC1/Views/Chats/Components/FragmentLayout.swift index 80e000a4..c685a803 100644 --- a/MC1/Views/Chats/Components/FragmentLayout.swift +++ b/MC1/Views/Chats/Components/FragmentLayout.swift @@ -14,34 +14,34 @@ import MC1Services /// a plain value. It is never stored on `MessageItem` and never participates in /// any bubble view's `==`, so the item-only Equatable seam stays intact. struct FragmentLayout { - /// The single text fragment that fills the bubble box, if any. - let textPayload: MessageTextPayload? + /// The single text fragment that fills the bubble box, if any. + let textPayload: MessageTextPayload? - /// The single inline image attached to the bubble box, if any. - let inlineImage: InlineImage? + /// The single inline image attached to the bubble box, if any. + let inlineImage: InlineImage? - /// Fragments rendered below the box, in document order. Excludes the - /// box-resident text and inline image. - let siblings: [MessageFragment] + /// Fragments rendered below the box, in document order. Excludes the + /// box-resident text and inline image. + let siblings: [MessageFragment] - init(content: [MessageFragment]) { - var textPayload: MessageTextPayload? - var inlineImage: InlineImage? - var siblings: [MessageFragment] = [] + init(content: [MessageFragment]) { + var textPayload: MessageTextPayload? + var inlineImage: InlineImage? + var siblings: [MessageFragment] = [] - for fragment in content { - switch fragment { - case .text(let payload): - if textPayload == nil { textPayload = payload } - case .inlineImage(let image): - if inlineImage == nil { inlineImage = image } - case .linkPreview, .mapPreview, .malwareWarning, .reactionSummary: - siblings.append(fragment) - } - } - - self.textPayload = textPayload - self.inlineImage = inlineImage - self.siblings = siblings + for fragment in content { + switch fragment { + case let .text(payload): + if textPayload == nil { textPayload = payload } + case let .inlineImage(image): + if inlineImage == nil { inlineImage = image } + case .linkPreview, .mapPreview, .malwareWarning, .reactionSummary: + siblings.append(fragment) + } } + + self.textPayload = textPayload + self.inlineImage = inlineImage + self.siblings = siblings + } } diff --git a/MC1/Views/Chats/Components/Fragments/InlineImageFragmentView.swift b/MC1/Views/Chats/Components/Fragments/InlineImageFragmentView.swift index f66946aa..18ca4e8c 100644 --- a/MC1/Views/Chats/Components/Fragments/InlineImageFragmentView.swift +++ b/MC1/Views/Chats/Components/Fragments/InlineImageFragmentView.swift @@ -1,97 +1,96 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Fragment-level view that renders the inline-image slot of a message bubble. /// Reserves correct height before bytes arrive using `InlineImage.cachedAspect` /// (falling back to 16:9), so the bubble does not jump when the image loads. struct InlineImageFragmentView: View { - let inlineImage: InlineImage - let isOutgoing: Bool - let imageResolver: (ImageReference) -> UIImage? - let onTap: () -> Void - let onRetry: () -> Void + let inlineImage: InlineImage + let isOutgoing: Bool + let imageResolver: (ImageReference) -> UIImage? + let onTap: () -> Void + let onRetry: () -> Void - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @ScaledMetric(relativeTo: .body) private var minHeight: CGFloat = RichPreviewMetrics.minHeroHeight - @ScaledMetric(relativeTo: .body) private var maxHeight: CGFloat = RichPreviewMetrics.maxHeroHeight + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ScaledMetric(relativeTo: .body) private var minHeight: CGFloat = RichPreviewMetrics.minHeroHeight + @ScaledMetric(relativeTo: .body) private var maxHeight: CGFloat = RichPreviewMetrics.maxHeroHeight - private static let crossFadeDuration: Double = 0.2 - private static let skeletonCornerRadius: CGFloat = RichPreviewMetrics.cornerRadius - private static let retryIconSpacing: CGFloat = 8 - private static let retryForegroundOpacity: Double = 0.7 + private static let crossFadeDuration: Double = 0.2 + private static let skeletonCornerRadius: CGFloat = RichPreviewMetrics.cornerRadius + private static let retryIconSpacing: CGFloat = 8 + private static let retryForegroundOpacity: Double = 0.7 - private var aspect: Double { - inlineImage.cachedAspect ?? RichPreviewMetrics.fallbackAspect - } + private var aspect: Double { + inlineImage.cachedAspect ?? RichPreviewMetrics.fallbackAspect + } - var body: some View { - // No card chrome here: the inline image is edge-to-edge in the bubble - // box, which supplies the rounding and the surface, so the reserved - // frame carries no corner clip or background of its own. - RichPreviewCard( - aspect: CGFloat(aspect), - minHeight: minHeight, - maxHeight: maxHeight - ) { - ZStack { - PreviewSkeleton() - .opacity(isLoaded ? 0 : 1) + var body: some View { + // No card chrome here: the inline image is edge-to-edge in the bubble + // box, which supplies the rounding and the surface, so the reserved + // frame carries no corner clip or background of its own. + RichPreviewCard( + aspect: CGFloat(aspect), + minHeight: minHeight, + maxHeight: maxHeight + ) { + ZStack { + PreviewSkeleton() + .opacity(isLoaded ? 0 : 1) - loadedLayer - .opacity(isLoaded ? 1 : 0) + loadedLayer + .opacity(isLoaded ? 1 : 0) - if case .failed = inlineImage.state { - retryLayer - } - } + if case .failed = inlineImage.state { + retryLayer } - .animation( - reduceMotion ? nil : .easeOut(duration: Self.crossFadeDuration), - value: isLoaded - ) + } } + .animation( + reduceMotion ? nil : .easeOut(duration: Self.crossFadeDuration), + value: isLoaded + ) + } - private var isLoaded: Bool { - if case .loaded(let ref, _) = inlineImage.state, - imageResolver(ref) != nil { - return true - } - return false + private var isLoaded: Bool { + if case let .loaded(ref, _) = inlineImage.state, + imageResolver(ref) != nil { + return true } + return false + } - @ViewBuilder - private var loadedLayer: some View { - if case .loaded(let ref, let isGIF) = inlineImage.state, - let image = imageResolver(ref) { - InlineImageView( - image: image, - isGIF: isGIF, - autoPlayGIFs: inlineImage.autoPlayGIFs, - isEmbedded: true, - onTap: onTap - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + @ViewBuilder + private var loadedLayer: some View { + if case let .loaded(ref, isGIF) = inlineImage.state, + let image = imageResolver(ref) { + InlineImageView( + image: image, + isGIF: isGIF, + autoPlayGIFs: inlineImage.autoPlayGIFs, + isEmbedded: true, + onTap: onTap + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } + } - @ViewBuilder - private var retryLayer: some View { - Button(action: onRetry) { - ZStack { - RoundedRectangle(cornerRadius: Self.skeletonCornerRadius, style: .continuous) - .fill(Color(.tertiarySystemFill)) - HStack(spacing: Self.retryIconSpacing) { - Image(systemName: "arrow.clockwise") - .foregroundStyle(isOutgoing ? .white.opacity(Self.retryForegroundOpacity) : .secondary) - Text(L10n.Chats.Chats.InlineImage.tapToRetry) - .font(.subheadline) - .foregroundStyle(isOutgoing ? .white.opacity(Self.retryForegroundOpacity) : .secondary) - } - } + private var retryLayer: some View { + Button(action: onRetry) { + ZStack { + RoundedRectangle(cornerRadius: Self.skeletonCornerRadius, style: .continuous) + .fill(Color(.tertiarySystemFill)) + HStack(spacing: Self.retryIconSpacing) { + Image(systemName: "arrow.clockwise") + .foregroundStyle(isOutgoing ? .white.opacity(Self.retryForegroundOpacity) : .secondary) + Text(L10n.Chats.Chats.InlineImage.tapToRetry) + .font(.subheadline) + .foregroundStyle(isOutgoing ? .white.opacity(Self.retryForegroundOpacity) : .secondary) } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Chats.Chats.InlineImage.failedLabel) - .accessibilityHint(L10n.Chats.Chats.InlineImage.retryHint) + } } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Chats.Chats.InlineImage.failedLabel) + .accessibilityHint(L10n.Chats.Chats.InlineImage.retryHint) + } } diff --git a/MC1/Views/Chats/Components/Fragments/LinkPreviewFragmentView.swift b/MC1/Views/Chats/Components/Fragments/LinkPreviewFragmentView.swift index cc47b2a8..5d95e3f7 100644 --- a/MC1/Views/Chats/Components/Fragments/LinkPreviewFragmentView.swift +++ b/MC1/Views/Chats/Components/Fragments/LinkPreviewFragmentView.swift @@ -1,70 +1,60 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Fragment-level view that renders the link-preview slot of a message bubble. /// Driven by a `LinkPreviewFragmentState` payload plus a closure-based image /// resolver — keeps the view free of view-model lookups so it stays a pure /// function of its inputs. struct LinkPreviewFragmentView: View { - let state: LinkPreviewFragmentState - let imageResolver: (ImageReference) -> UIImage? - let onManualPreviewFetch: (() -> Void)? + let state: LinkPreviewFragmentState + let imageResolver: (ImageReference) -> UIImage? + let onManualPreviewFetch: (() -> Void)? - @Environment(\.openURL) private var openURL - - init( - state: LinkPreviewFragmentState, - imageResolver: @escaping (ImageReference) -> UIImage?, - onManualPreviewFetch: (() -> Void)? - ) { - self.state = state - self.imageResolver = imageResolver - self.onManualPreviewFetch = onManualPreviewFetch - } + @Environment(\.openURL) private var openURL - var body: some View { - switch state.mode { - case .loaded(let preview, let imageRef, let iconRef): - if let url = state.primaryURL { - let resolvedImage = imageRef.flatMap(imageResolver) - if imageRef != nil && resolvedImage == nil { - // Image bytes still downloading — reserve hero space. - LinkPreviewLoadingCard(state: state) - } else { - LinkPreviewCard( - url: url, - title: preview.title, - image: resolvedImage, - icon: iconRef.flatMap(imageResolver), - imageWidth: preview.imageWidth, - imageHeight: preview.imageHeight, - onTap: { openURL(url) } - ) - } - } - case .loading: - LinkPreviewLoadingCard(state: state) - case .disabled(let url): - TapToLoadPreview( - url: url, - isLoading: false, - onTap: { onManualPreviewFetch?() } - ) - case .legacy(_, let title, let imageRef, let iconRef): - if let url = state.primaryURL { - LinkPreviewCard( - url: url, - title: title, - image: imageRef.flatMap(imageResolver), - icon: iconRef.flatMap(imageResolver), - imageWidth: nil, - imageHeight: nil, - onTap: { openURL(url) } - ) - } - case .idle, .noPreview: - EmptyView() + var body: some View { + switch state.mode { + case let .loaded(preview, imageRef, iconRef): + if let url = state.primaryURL { + let resolvedImage = imageRef.flatMap(imageResolver) + if imageRef != nil, resolvedImage == nil { + // Image bytes still downloading — reserve hero space. + LinkPreviewLoadingCard(state: state) + } else { + LinkPreviewCard( + url: url, + title: preview.title, + image: resolvedImage, + icon: iconRef.flatMap(imageResolver), + imageWidth: preview.imageWidth, + imageHeight: preview.imageHeight, + onTap: { openURL(url) } + ) } + } + case .loading: + LinkPreviewLoadingCard(state: state) + case let .disabled(url): + TapToLoadPreview( + url: url, + isLoading: false, + onTap: { onManualPreviewFetch?() } + ) + case let .legacy(_, title, imageRef, iconRef): + if let url = state.primaryURL { + LinkPreviewCard( + url: url, + title: title, + image: imageRef.flatMap(imageResolver), + icon: iconRef.flatMap(imageResolver), + imageWidth: nil, + imageHeight: nil, + onTap: { openURL(url) } + ) + } + case .idle, .noPreview: + EmptyView() } + } } diff --git a/MC1/Views/Chats/Components/Fragments/MapPreviewFragmentView.swift b/MC1/Views/Chats/Components/Fragments/MapPreviewFragmentView.swift index 51a5f720..b5a3a3db 100644 --- a/MC1/Views/Chats/Components/Fragments/MapPreviewFragmentView.swift +++ b/MC1/Views/Chats/Components/Fragments/MapPreviewFragmentView.swift @@ -9,106 +9,106 @@ import UIKit /// failed to render, a retry control overlays the fallback so the user can /// recover without waiting for the next offline-to-online edge. struct MapPreviewFragmentView: View { - let state: MapPreviewFragmentState - let snapshotResolver: (MapSnapshotRequest) -> UIImage? - let onTap: (CLLocationCoordinate2D) -> Void - let onRequestSnapshot: (MapSnapshotRequest) -> Void - let onRetry: (MapSnapshotRequest) -> Void + let state: MapPreviewFragmentState + let snapshotResolver: (MapSnapshotRequest) -> UIImage? + let onTap: (CLLocationCoordinate2D) -> Void + let onRequestSnapshot: (MapSnapshotRequest) -> Void + let onRetry: (MapSnapshotRequest) -> Void - private static let retryControlPadding: CGFloat = 8 + private static let retryControlPadding: CGFloat = 8 - private var request: MapSnapshotRequest { - MapSnapshotRequest( - latitude: state.latitude, - longitude: state.longitude, - isDark: state.isDark, - isOffline: state.isOffline - ) - } + private var request: MapSnapshotRequest { + MapSnapshotRequest( + latitude: state.latitude, + longitude: state.longitude, + isDark: state.isDark, + isOffline: state.isOffline + ) + } - private var coordinateText: String { - state.coordinate.formattedString - } + private var coordinateText: String { + state.coordinate.formattedString + } - /// True only when the render attempt resolved as a failure and the cache - /// has no image — i.e., the `fallback` branch is on screen. The skeleton - /// state is excluded so the retry icon never flashes during the initial - /// render-in-progress window. - private var isShowingFallback: Bool { - state.isReady && snapshotResolver(request) == nil - } + /// True only when the render attempt resolved as a failure and the cache + /// has no image — i.e., the `fallback` branch is on screen. The skeleton + /// state is excluded so the retry icon never flashes during the initial + /// render-in-progress window. + private var isShowingFallback: Bool { + state.isReady && snapshotResolver(request) == nil + } - var body: some View { - ZStack(alignment: .topTrailing) { - content - .frame(width: MapSnapshotLayout.width, height: MapSnapshotLayout.height) - .clipShape(.rect(cornerRadius: MapSnapshotLayout.cornerRadius)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { onTap(state.coordinate) } - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.Map.Map.Preview.accessibilityLabel) - .accessibilityValue(coordinateText) - .accessibilityHint(L10n.Map.Map.Preview.accessibilityHint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { onTap(state.coordinate) } + var body: some View { + ZStack(alignment: .topTrailing) { + content + .frame(width: MapSnapshotLayout.width, height: MapSnapshotLayout.height) + .clipShape(.rect(cornerRadius: MapSnapshotLayout.cornerRadius)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { onTap(state.coordinate) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Map.Map.Preview.accessibilityLabel) + .accessibilityValue(coordinateText) + .accessibilityHint(L10n.Map.Map.Preview.accessibilityHint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { onTap(state.coordinate) } - if isShowingFallback { - retryButton - .padding(Self.retryControlPadding) - } - } + if isShowingFallback { + retryButton + .padding(Self.retryControlPadding) + } } + } - private var retryButton: some View { - Button { - onRetry(request) - } label: { - Image(systemName: "arrow.clockwise.circle.fill") - .font(.title2) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.primary) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Preview.RetryButton.accessibilityLabel) - .accessibilityAddTraits(.isButton) + private var retryButton: some View { + Button { + onRetry(request) + } label: { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.primary) } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Preview.RetryButton.accessibilityLabel) + .accessibilityAddTraits(.isButton) + } - @ViewBuilder - private var content: some View { - if let image = snapshotResolver(request) { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - // No cached image: the fallback once the attempt resolved (a failed - // render), otherwise the loading skeleton. Re-request in both cases — - // `request()` is a no-op for cached/failed/in-flight, so this only does - // work after a cache eviction dropped a previously rendered snapshot. - // `.onAppear` lives on each branch so a flip between skeleton and - // fallback (e.g. failure cleared by network recovery) re-fires the - // request; SwiftUI fires `.onAppear` on a `Group` only once per Group - // lifetime, not on inner-branch swaps. - if state.isReady { - fallback - .onAppear { onRequestSnapshot(request) } - } else { - skeleton - .onAppear { onRequestSnapshot(request) } - } - } + @ViewBuilder + private var content: some View { + if let image = snapshotResolver(request) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + // No cached image: the fallback once the attempt resolved (a failed + // render), otherwise the loading skeleton. Re-request in both cases — + // `request()` is a no-op for cached/failed/in-flight, so this only does + // work after a cache eviction dropped a previously rendered snapshot. + // `.onAppear` lives on each branch so a flip between skeleton and + // fallback (e.g. failure cleared by network recovery) re-fires the + // request; SwiftUI fires `.onAppear` on a `Group` only once per Group + // lifetime, not on inner-branch swaps. + if state.isReady { + fallback + .onAppear { onRequestSnapshot(request) } + } else { + skeleton + .onAppear { onRequestSnapshot(request) } + } } + } - private var skeleton: some View { - PreviewSkeleton(cornerRadius: MapSnapshotLayout.cornerRadius) - } + private var skeleton: some View { + PreviewSkeleton(cornerRadius: MapSnapshotLayout.cornerRadius) + } - private var fallback: some View { - ZStack { - Color(.secondarySystemBackground) - Image(systemName: "mappin.circle.fill") - .font(.largeTitle) - .foregroundStyle(.secondary) - } - .accessibilityHidden(true) + private var fallback: some View { + ZStack { + Color(.secondarySystemBackground) + Image(systemName: "mappin.circle.fill") + .font(.largeTitle) + .foregroundStyle(.secondary) } + .accessibilityHidden(true) + } } diff --git a/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift b/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift index b041f0df..48ff6444 100644 --- a/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift +++ b/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift @@ -17,274 +17,274 @@ private let logger = Logger(subsystem: "com.mc1", category: "MessageBodyTextView /// actor with the live trait collection, rather than storing a non-`Sendable` `NSAttributedString` /// on the payload. struct MessageBodyTextView: UIViewRepresentable { - /// The single authored representation. The UIKit string is derived from this, never authored - /// in parallel, so every link kind's color/underline/bold/link has one source of truth. - let attributedString: AttributedString - - /// The base text color slot resolved by the bubble. Incoming bodies use `.primary` (mapped to - /// `UIColor.label` at bridge time); outgoing bodies use the filled-bubble text color, which - /// already bridges to a stable `UIColor`. - let baseColor: Color - - /// Dynamic Type token threaded from the SwiftUI environment so a category change invalidates - /// the Coordinator size cache. `updateUIView` runs on any environment change, so a reflow of - /// already-visible bubbles is driven from here in concert with the table's reconfigure. - let contentSizeCategoryToken: String - - @Environment(\.openURL) private var openURL - - func makeCoordinator() -> Coordinator { - Coordinator() + /// The single authored representation. The UIKit string is derived from this, never authored + /// in parallel, so every link kind's color/underline/bold/link has one source of truth. + let attributedString: AttributedString + + /// The base text color slot resolved by the bubble. Incoming bodies use `.primary` (mapped to + /// `UIColor.label` at bridge time); outgoing bodies use the filled-bubble text color, which + /// already bridges to a stable `UIColor`. + let baseColor: Color + + /// Dynamic Type token threaded from the SwiftUI environment so a category change invalidates + /// the Coordinator size cache. `updateUIView` runs on any environment change, so a reflow of + /// already-visible bubbles is driven from here in concert with the table's reconfigure. + let contentSizeCategoryToken: String + + @Environment(\.openURL) private var openURL + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> BubbleBodyTextView { + let textView = BubbleBodyTextView(usingTextLayoutManager: false) + textView.configureForBubble() + + textView.isEditable = false + textView.isScrollEnabled = false + textView.adjustsFontForContentSizeCategory = true + + // Non-selectable: a passive renderer installs no link, selection, or edit interactions, so + // a long-press falls through to the bubble's gesture and a secondary click falls through to + // the table's context-menu interaction. Link taps are detected by the recognizer below. + textView.isSelectable = false + textView.isUserInteractionEnabled = true + + // A long-press must not become a drag or a text loupe; both would steal the gesture the + // bubble needs. Dropping the drop interaction prevents a long-press-into-drop hijack too. + textView.textDragInteraction?.isEnabled = false + if let dropInteraction = textView.textDropInteraction { + textView.removeInteraction(dropInteraction) } - func makeUIView(context: Context) -> BubbleBodyTextView { - let textView = BubbleBodyTextView(usingTextLayoutManager: false) - textView.configureForBubble() - - textView.isEditable = false - textView.isScrollEnabled = false - textView.adjustsFontForContentSizeCategory = true - - // Non-selectable: a passive renderer installs no link, selection, or edit interactions, so - // a long-press falls through to the bubble's gesture and a secondary click falls through to - // the table's context-menu interaction. Link taps are detected by the recognizer below. - textView.isSelectable = false - textView.isUserInteractionEnabled = true - - // A long-press must not become a drag or a text loupe; both would steal the gesture the - // bubble needs. Dropping the drop interaction prevents a long-press-into-drop hijack too. - textView.textDragInteraction?.isEnabled = false - if let dropInteraction = textView.textDropInteraction { - textView.removeInteraction(dropInteraction) - } - - textView.backgroundColor = .clear - textView.textContainerInset = .zero - textView.textContainer.lineFragmentPadding = 0 - textView.contentInsetAdjustmentBehavior = .never - - // Hug content in both axes so the hosting cell self-sizes from the measured text. - textView.setContentHuggingPriority(.required, for: .vertical) - textView.setContentCompressionResistancePriority(.required, for: .vertical) - textView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - // Single tap routes a link through the injected OpenURLAction; a tap that hits no link is - // not consumed (cancelsTouchesInView = false), so the bubble's tap and long-press still fire. - let tapRecognizer = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleTap(_:)) - ) - tapRecognizer.cancelsTouchesInView = false - // Off Mac, the text view is the recognizer's delegate so the bubble's long-press wins a - // contested press (see `BubbleBodyTextView`). On Mac the secondary click routes through the - // table's context-menu interaction, which this delegate must not disturb. - if !ProcessInfo.processInfo.isiOSAppOnMac { - tapRecognizer.delegate = textView - } - textView.addGestureRecognizer(tapRecognizer) - - apply(to: textView, context: context) - return textView + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.contentInsetAdjustmentBehavior = .never + + // Hug content in both axes so the hosting cell self-sizes from the measured text. + textView.setContentHuggingPriority(.required, for: .vertical) + textView.setContentCompressionResistancePriority(.required, for: .vertical) + textView.setContentHuggingPriority(.defaultLow, for: .horizontal) + + // Single tap routes a link through the injected OpenURLAction; a tap that hits no link is + // not consumed (cancelsTouchesInView = false), so the bubble's tap and long-press still fire. + let tapRecognizer = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + ) + tapRecognizer.cancelsTouchesInView = false + // Off Mac, the text view is the recognizer's delegate so the bubble's long-press wins a + // contested press (see `BubbleBodyTextView`). On Mac the secondary click routes through the + // table's context-menu interaction, which this delegate must not disturb. + if !ProcessInfo.processInfo.isiOSAppOnMac { + tapRecognizer.delegate = textView } - - func updateUIView(_ textView: BubbleBodyTextView, context: Context) { - apply(to: textView, context: context) + textView.addGestureRecognizer(tapRecognizer) + + apply(to: textView, context: context) + return textView + } + + func updateUIView(_ textView: BubbleBodyTextView, context: Context) { + apply(to: textView, context: context) + } + + /// Bridges the SwiftUI string to UIKit against the live trait collection, refreshes the live + /// `OpenURLAction` on the Coordinator (so a recycled cell picks up the current action), and + /// invalidates the size cache when the bridged content or Dynamic Type token changes. + private func apply(to textView: BubbleBodyTextView, context: Context) { + let coordinator = context.coordinator + coordinator.openURL = openURL + + let bridged = Self.makeAttributedText( + from: attributedString, + baseColor: baseColor, + traitCollection: textView.traitCollection + ) + + let contentHash = Self.contentHash( + attributedString: attributedString, + contentSizeCategoryToken: contentSizeCategoryToken + ) + if coordinator.contentHash != contentHash { + coordinator.contentHash = contentHash + coordinator.invalidateSizeCache() } - /// Bridges the SwiftUI string to UIKit against the live trait collection, refreshes the live - /// `OpenURLAction` on the Coordinator (so a recycled cell picks up the current action), and - /// invalidates the size cache when the bridged content or Dynamic Type token changes. - private func apply(to textView: BubbleBodyTextView, context: Context) { - let coordinator = context.coordinator - coordinator.openURL = openURL - - let bridged = Self.makeAttributedText( - from: attributedString, - baseColor: baseColor, - traitCollection: textView.traitCollection - ) - - let contentHash = Self.contentHash( - attributedString: attributedString, - contentSizeCategoryToken: contentSizeCategoryToken - ) - if coordinator.contentHash != contentHash { - coordinator.contentHash = contentHash - coordinator.invalidateSizeCache() - } - - textView.attributedText = bridged + textView.attributedText = bridged + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: BubbleBodyTextView, + context: Context + ) -> CGSize? { + let width = proposal.width ?? UIView.layoutFittingCompressedSize.width + guard width.isFinite, width > 0 else { return nil } + + let coordinator = context.coordinator + let key = SizeCacheKey(width: Double(width), contentHash: coordinator.contentHash) + if let cached = coordinator.computedSizes[key] { + return cached } - func sizeThatFits( - _ proposal: ProposedViewSize, - uiView: BubbleBodyTextView, - context: Context - ) -> CGSize? { - let width = proposal.width ?? UIView.layoutFittingCompressedSize.width - guard width.isFinite, width > 0 else { return nil } - - let coordinator = context.coordinator - let key = SizeCacheKey(width: Double(width), contentHash: coordinator.contentHash) - if let cached = coordinator.computedSizes[key] { - return cached - } - - let fitting = uiView.sizeThatFits( - CGSize(width: width, height: UIView.layoutFittingCompressedSize.height) - ) - let resolved = CGSize(width: min(fitting.width, width), height: fitting.height) - - // A size-cache miss is the highest-attention surface: a stale height here would surface as a - // diffable reflow glitch, so each miss is logged for diagnosis. - logger.debug( - "size cache miss width=\(width, format: .fixed(precision: 1)) hash=\(coordinator.contentHash) measured=\(resolved.height, format: .fixed(precision: 1))" + let fitting = uiView.sizeThatFits( + CGSize(width: width, height: UIView.layoutFittingCompressedSize.height) + ) + let resolved = CGSize(width: min(fitting.width, width), height: fitting.height) + + // A size-cache miss is the highest-attention surface: a stale height here would surface as a + // diffable reflow glitch, so each miss is logged for diagnosis. + logger.debug( + "size cache miss width=\(width, format: .fixed(precision: 1)) hash=\(coordinator.contentHash) measured=\(resolved.height, format: .fixed(precision: 1))" + ) + + // Writing the cache synchronously inside sizeThatFits would mutate Coordinator state during + // layout, which is the surface this project's diffable scroll crashes are tied to. Defer it, + // and store the entry only if the content discriminator still matches: if the bridged + // content or Dynamic Type token changed between this miss and the deferred block, the key is + // dead and writing it back would leak a stale entry. + DispatchQueue.main.async { + guard coordinator.contentHash == key.contentHash else { return } + coordinator.computedSizes[key] = resolved + } + return resolved + } + + // MARK: - Render-time bridging + + /// Base text style for the whole string. Dynamic Type comes for free because the run carries + /// the text style and `adjustsFontForContentSizeCategory` is on. + private static let baseFont = UIFont.preferredFont(forTextStyle: .body) + + /// Derives the UIKit `NSAttributedString` from the SwiftUI string for the given trait + /// collection. Pure and trait-driven so the bridging test can exercise it off-screen in both + /// a light and a dark/high-contrast collection. The `.link` runs carry across from the default + /// bridge; the font, the hashtag bold trait, the foreground color, and the underline are + /// overlaid because they either do not survive the default bridge into the UIKit attribute set + /// (the SwiftUI underline lands under a SwiftUI-scope key, not `NSAttributedString.Key`) or do + /// not resolve to a stable `UIColor` (`.primary`): + /// 1. Overlay the base font across the whole string (the SwiftUI string carries no font). + /// 2. Resolve hashtag bold from `inlinePresentationIntent == .stronglyEmphasized`. + /// 3. Resolve every foreground color against the live trait collection so `.primary` and any + /// gamut-derived mention/hashtag/contact color render correctly in dark/high-contrast + /// rather than falling through to grey. The `.primary` slot maps to `UIColor.label`. + /// 4. Re-apply the underline as the UIKit `.underlineStyle` for runs the SwiftUI string + /// underlined, since SwiftUI's underline does not bridge into the UIKit key. + /// 5. Re-apply the self-mention `.backgroundColor` for runs that carry one, resolved against + /// the live trait collection. SwiftUI's `.backgroundColor` is a SwiftUI-scope attribute that + /// does not survive the default bridge into the UIKit key, so the highlight is lost without it. + static func makeAttributedText( + from attributedString: AttributedString, + baseColor: Color, + traitCollection: UITraitCollection + ) -> NSAttributedString { + let result = NSMutableAttributedString(attributedString) + let fullRange = NSRange(location: 0, length: result.length) + + result.addAttribute(.font, value: baseFont, range: fullRange) + + for run in attributedString.runs { + let nsRange = NSRange(run.range, in: attributedString) + guard nsRange.length > 0 else { continue } + + let foreground = resolvedColor(run.foregroundColor ?? baseColor, traitCollection: traitCollection) + result.addAttribute(.foregroundColor, value: foreground, range: nsRange) + + if run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true { + result.addAttribute(.font, value: boldFont(from: baseFont), range: nsRange) + } + + if run.underlineStyle != nil { + result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: nsRange) + } + + if let background = run.backgroundColor { + result.addAttribute( + .backgroundColor, + value: resolvedColor(background, traitCollection: traitCollection), + range: nsRange ) - - // Writing the cache synchronously inside sizeThatFits would mutate Coordinator state during - // layout, which is the surface this project's diffable scroll crashes are tied to. Defer it, - // and store the entry only if the content discriminator still matches: if the bridged - // content or Dynamic Type token changed between this miss and the deferred block, the key is - // dead and writing it back would leak a stale entry. - DispatchQueue.main.async { - guard coordinator.contentHash == key.contentHash else { return } - coordinator.computedSizes[key] = resolved - } - return resolved + } } - // MARK: - Render-time bridging - - /// Base text style for the whole string. Dynamic Type comes for free because the run carries - /// the text style and `adjustsFontForContentSizeCategory` is on. - private static let baseFont = UIFont.preferredFont(forTextStyle: .body) - - /// Derives the UIKit `NSAttributedString` from the SwiftUI string for the given trait - /// collection. Pure and trait-driven so the bridging test can exercise it off-screen in both - /// a light and a dark/high-contrast collection. The `.link` runs carry across from the default - /// bridge; the font, the hashtag bold trait, the foreground color, and the underline are - /// overlaid because they either do not survive the default bridge into the UIKit attribute set - /// (the SwiftUI underline lands under a SwiftUI-scope key, not `NSAttributedString.Key`) or do - /// not resolve to a stable `UIColor` (`.primary`): - /// 1. Overlay the base font across the whole string (the SwiftUI string carries no font). - /// 2. Resolve hashtag bold from `inlinePresentationIntent == .stronglyEmphasized`. - /// 3. Resolve every foreground color against the live trait collection so `.primary` and any - /// gamut-derived mention/hashtag/contact color render correctly in dark/high-contrast - /// rather than falling through to grey. The `.primary` slot maps to `UIColor.label`. - /// 4. Re-apply the underline as the UIKit `.underlineStyle` for runs the SwiftUI string - /// underlined, since SwiftUI's underline does not bridge into the UIKit key. - /// 5. Re-apply the self-mention `.backgroundColor` for runs that carry one, resolved against - /// the live trait collection. SwiftUI's `.backgroundColor` is a SwiftUI-scope attribute that - /// does not survive the default bridge into the UIKit key, so the highlight is lost without it. - static func makeAttributedText( - from attributedString: AttributedString, - baseColor: Color, - traitCollection: UITraitCollection - ) -> NSAttributedString { - let result = NSMutableAttributedString(attributedString) - let fullRange = NSRange(location: 0, length: result.length) - - result.addAttribute(.font, value: baseFont, range: fullRange) - - for run in attributedString.runs { - let nsRange = NSRange(run.range, in: attributedString) - guard nsRange.length > 0 else { continue } - - let foreground = resolvedColor(run.foregroundColor ?? baseColor, traitCollection: traitCollection) - result.addAttribute(.foregroundColor, value: foreground, range: nsRange) - - if run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true { - result.addAttribute(.font, value: boldFont(from: baseFont), range: nsRange) - } - - if run.underlineStyle != nil { - result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: nsRange) - } - - if let background = run.backgroundColor { - result.addAttribute( - .backgroundColor, - value: resolvedColor(background, traitCollection: traitCollection), - range: nsRange - ) - } - } - - return result - } + return result + } - /// Resolves a SwiftUI `Color` to a `UIColor` for the live trait collection. SwiftUI's `.primary` - /// does not bridge to a stable `UIColor`, so it is mapped to `UIColor.label` (matching - /// `BaseColorSlot.swiftUIColor`'s `.primary`); every other color resolves directly. - private static func resolvedColor(_ color: Color, traitCollection: UITraitCollection) -> UIColor { - if color == .primary { - return UIColor.label.resolvedColor(with: traitCollection) - } - return UIColor(color).resolvedColor(with: traitCollection) + /// Resolves a SwiftUI `Color` to a `UIColor` for the live trait collection. SwiftUI's `.primary` + /// does not bridge to a stable `UIColor`, so it is mapped to `UIColor.label` (matching + /// `BaseColorSlot.swiftUIColor`'s `.primary`); every other color resolves directly. + private static func resolvedColor(_ color: Color, traitCollection: UITraitCollection) -> UIColor { + if color == .primary { + return UIColor.label.resolvedColor(with: traitCollection) } - - /// Body text style metrics shared so the bold (hashtag) variant scales with Dynamic Type in - /// lockstep with the surrounding `.body` runs, rather than freezing at a single point size. - private static let bodyMetrics = UIFontMetrics(forTextStyle: .body) - - /// Adds the bold symbolic trait while preserving `.body` Dynamic Type scaling. Building the bold - /// font at a fixed `pointSize` would strip the text-style metadata, so a hashtag run would not - /// rescale with `adjustsFontForContentSizeCategory` while its neighbours do. Wrapping the bold - /// descriptor in `UIFontMetrics(forTextStyle: .body)` keeps bold and body runs scaling together. - private static func boldFont(from font: UIFont) -> UIFont { - guard let descriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) else { - return font - } - return bodyMetrics.scaledFont(for: UIFont(descriptor: descriptor, size: 0)) + return UIColor(color).resolvedColor(with: traitCollection) + } + + /// Body text style metrics shared so the bold (hashtag) variant scales with Dynamic Type in + /// lockstep with the surrounding `.body` runs, rather than freezing at a single point size. + private static let bodyMetrics = UIFontMetrics(forTextStyle: .body) + + /// Adds the bold symbolic trait while preserving `.body` Dynamic Type scaling. Building the bold + /// font at a fixed `pointSize` would strip the text-style metadata, so a hashtag run would not + /// rescale with `adjustsFontForContentSizeCategory` while its neighbours do. Wrapping the bold + /// descriptor in `UIFontMetrics(forTextStyle: .body)` keeps bold and body runs scaling together. + private static func boldFont(from font: UIFont) -> UIFont { + guard let descriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) else { + return font } - - /// Combines the SwiftUI string's hash with the Dynamic Type token. The SwiftUI string already - /// folds in every contracted attribute (link, color, underline, bold), so its hash plus the - /// category token fully discriminates the bridged output for the size cache. - private static func contentHash(attributedString: AttributedString, contentSizeCategoryToken: String) -> Int { - var hasher = Hasher() - hasher.combine(attributedString) - hasher.combine(contentSizeCategoryToken) - return hasher.finalize() + return bodyMetrics.scaledFont(for: UIFont(descriptor: descriptor, size: 0)) + } + + /// Combines the SwiftUI string's hash with the Dynamic Type token. The SwiftUI string already + /// folds in every contracted attribute (link, color, underline, bold), so its hash plus the + /// category token fully discriminates the bridged output for the size cache. + private static func contentHash(attributedString: AttributedString, contentSizeCategoryToken: String) -> Int { + var hasher = Hasher() + hasher.combine(attributedString) + hasher.combine(contentSizeCategoryToken) + return hasher.finalize() + } + + // MARK: - Coordinator + + @MainActor + final class Coordinator: NSObject { + /// `(width, contentHash)` size cache, held here rather than in `@State` to keep the cache + /// write off the SwiftUI layout path that this project's diffable crashes are tied to. + var computedSizes: [SizeCacheKey: CGSize] = [:] + + /// Discriminator for the current bridged content plus Dynamic Type token; a change clears + /// the size cache so a stale height cannot strand a reflowed bubble. + var contentHash: Int = 0 + + /// The live `OpenURLAction`, refreshed every `updateUIView` so a recycled cell taps through + /// the current action (the one `MentionTapHandler` installs with its suppression gate). + var openURL: OpenURLAction? + + func invalidateSizeCache() { + computedSizes.removeAll(keepingCapacity: true) } - // MARK: - Coordinator - - @MainActor - final class Coordinator: NSObject { - /// `(width, contentHash)` size cache, held here rather than in `@State` to keep the cache - /// write off the SwiftUI layout path that this project's diffable crashes are tied to. - var computedSizes: [SizeCacheKey: CGSize] = [:] - - /// Discriminator for the current bridged content plus Dynamic Type token; a change clears - /// the size cache so a stale height cannot strand a reflowed bubble. - var contentHash: Int = 0 - - /// The live `OpenURLAction`, refreshed every `updateUIView` so a recycled cell taps through - /// the current action (the one `MentionTapHandler` installs with its suppression gate). - var openURL: OpenURLAction? - - func invalidateSizeCache() { - computedSizes.removeAll(keepingCapacity: true) - } - - /// Routes a tap on a linked glyph through the single injected `OpenURLAction`, so the - /// suppression gate and mention/contact/channel/map routing stay unchanged. A tap that hits - /// no link does nothing and is not consumed, so the bubble's own gestures still fire. - @objc func handleTap(_ recognizer: UITapGestureRecognizer) { - guard let textView = recognizer.view as? BubbleBodyTextView, - let url = textView.linkURL(at: recognizer.location(in: textView)) else { return } - openURL?(url) - } + /// Routes a tap on a linked glyph through the single injected `OpenURLAction`, so the + /// suppression gate and mention/contact/channel/map routing stay unchanged. A tap that hits + /// no link does nothing and is not consumed, so the bubble's own gestures still fire. + @objc func handleTap(_ recognizer: UITapGestureRecognizer) { + guard let textView = recognizer.view as? BubbleBodyTextView, + let url = textView.linkURL(at: recognizer.location(in: textView)) else { return } + openURL?(url) } + } } /// `(width, contentHash)` key for the body text view's size cache. `contentHash` discriminates the /// bridged string plus the Dynamic Type token, so a width reuse across two different bodies, or the /// same body at a new Dynamic Type size, cannot return a stale height. struct SizeCacheKey: Hashable { - let width: Double - let contentHash: Int + let width: Double + let contentHash: Int } /// Passive `UITextView` subclass for a message body. It empties `linkTextAttributes` so `.link` @@ -295,45 +295,45 @@ struct SizeCacheKey: Hashable { /// long-press, so a sustained press opens the actions sheet instead of firing a link tap, while the /// table's pan recognizer is left untouched. final class BubbleBodyTextView: UITextView, UIGestureRecognizerDelegate { - /// Empties `linkTextAttributes` so `.link` runs render in their authored `.foregroundColor` - /// rather than the view's `tintColor`. Applied from `makeUIView`: `init(usingTextLayoutManager:)` - /// does not funnel through a `UITextView` designated-init override, so the policy is set here. - func configureForBubble() { - linkTextAttributes = [:] - } - - /// The link URL at a point in the view, or nil if the point is not on a linked glyph. Resolves - /// the glyph under the point with TextKit 1 and rejects the nearest-glyph over-hit that - /// `glyphIndex(for:in:)` returns for a tap past end-of-line, in trailing whitespace, or below - /// the last line, by requiring the point to lie inside that glyph's bounding rect. - func linkURL(at point: CGPoint) -> URL? { - guard textStorage.length > 0 else { return nil } - - let containerPoint = CGPoint( - x: point.x - textContainerInset.left, - y: point.y - textContainerInset.top - ) - let glyphIndex = layoutManager.glyphIndex(for: containerPoint, in: textContainer) - let glyphRect = layoutManager.boundingRect( - forGlyphRange: NSRange(location: glyphIndex, length: 1), - in: textContainer - ) - guard glyphRect.contains(containerPoint) else { return nil } - - let charIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) - guard charIndex < textStorage.length else { return nil } - - switch textStorage.attribute(.link, at: charIndex, effectiveRange: nil) { - case let url as URL: return url - case let string as String: return URL(string: string) - default: return nil - } - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - !(otherGestureRecognizer is UILongPressGestureRecognizer) + /// Empties `linkTextAttributes` so `.link` runs render in their authored `.foregroundColor` + /// rather than the view's `tintColor`. Applied from `makeUIView`: `init(usingTextLayoutManager:)` + /// does not funnel through a `UITextView` designated-init override, so the policy is set here. + func configureForBubble() { + linkTextAttributes = [:] + } + + /// The link URL at a point in the view, or nil if the point is not on a linked glyph. Resolves + /// the glyph under the point with TextKit 1 and rejects the nearest-glyph over-hit that + /// `glyphIndex(for:in:)` returns for a tap past end-of-line, in trailing whitespace, or below + /// the last line, by requiring the point to lie inside that glyph's bounding rect. + func linkURL(at point: CGPoint) -> URL? { + guard textStorage.length > 0 else { return nil } + + let containerPoint = CGPoint( + x: point.x - textContainerInset.left, + y: point.y - textContainerInset.top + ) + let glyphIndex = layoutManager.glyphIndex(for: containerPoint, in: textContainer) + let glyphRect = layoutManager.boundingRect( + forGlyphRange: NSRange(location: glyphIndex, length: 1), + in: textContainer + ) + guard glyphRect.contains(containerPoint) else { return nil } + + let charIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) + guard charIndex < textStorage.length else { return nil } + + switch textStorage.attribute(.link, at: charIndex, effectiveRange: nil) { + case let url as URL: return url + case let string as String: return URL(string: string) + default: return nil } + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + !(otherGestureRecognizer is UILongPressGestureRecognizer) + } } diff --git a/MC1/Views/Chats/Components/Fragments/MessageTextView.swift b/MC1/Views/Chats/Components/Fragments/MessageTextView.swift index 67254478..00fccc3e 100644 --- a/MC1/Views/Chats/Components/Fragments/MessageTextView.swift +++ b/MC1/Views/Chats/Components/Fragments/MessageTextView.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Fragment-level wrapper over the body text renderer, driven by a typed `MessageTextPayload` /// data carrier. Renders through `MessageBodyTextView` (a `UITextView`-backed representable) so @@ -7,32 +7,32 @@ import MC1Services /// `Text(AttributedString)` path it replaced installed its own long-press link menu that /// out-prioritized the bubble's gesture. struct MessageTextView: View { - let text: MessageTextPayload + let text: MessageTextPayload - // Reflow of already-visible bubbles on a Dynamic Type change is driven primarily by the - // AppearanceToken reconfigure re-host; this per-view token is a secondary discriminator that - // guards the in-Coordinator size cache so a recycled view cannot reuse a height from another size. - @Environment(\.dynamicTypeSize) private var dynamicTypeSize + /// Reflow of already-visible bubbles on a Dynamic Type change is driven primarily by the + /// AppearanceToken reconfigure re-host; this per-view token is a secondary discriminator that + /// guards the in-Coordinator size cache so a recycled view cannot reuse a height from another size. + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - var body: some View { - MessageBodyTextView( - attributedString: text.formatted ?? AttributedString(text.raw), - baseColor: text.baseColor.swiftUIColor, - contentSizeCategoryToken: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) - ) - } + var body: some View { + MessageBodyTextView( + attributedString: text.formatted ?? AttributedString(text.raw), + baseColor: text.baseColor.swiftUIColor, + contentSizeCategoryToken: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) + ) + } } extension BaseColorSlot { - /// Resolves the direction-tagged slot to a concrete SwiftUI `Color` at - /// render time. Outgoing bubbles render on a filled background and need - /// white text; incoming bubbles use the system primary colour. Lives in - /// the MC1 layer because MC1Services intentionally has no SwiftUI - /// dependency. - var swiftUIColor: Color { - switch self { - case .outgoing: .white - case .incoming: .primary - } + /// Resolves the direction-tagged slot to a concrete SwiftUI `Color` at + /// render time. Outgoing bubbles render on a filled background and need + /// white text; incoming bubbles use the system primary colour. Lives in + /// the MC1 layer because MC1Services intentionally has no SwiftUI + /// dependency. + var swiftUIColor: Color { + switch self { + case .outgoing: .white + case .incoming: .primary } + } } diff --git a/MC1/Views/Chats/Components/Fragments/ReactionsFragmentView.swift b/MC1/Views/Chats/Components/Fragments/ReactionsFragmentView.swift index 17643e92..6e42fd56 100644 --- a/MC1/Views/Chats/Components/Fragments/ReactionsFragmentView.swift +++ b/MC1/Views/Chats/Components/Fragments/ReactionsFragmentView.swift @@ -1,25 +1,25 @@ -import SwiftUI import MC1Services +import SwiftUI /// Fragment-level view that renders the reactions slot of a message bubble. /// Wraps `ReactionBadgesView` with the offset and bottom padding the bubble /// applies inline so the call site stays a single typed input. struct ReactionsFragmentView: View { - /// Raw summary string (`"👍:3,❤️:2"`). Non-optional because the - /// fragment is only emitted for non-nil non-empty values. - /// `ReactionBadgesView` takes `summary: String?`; Swift auto-wraps the - /// non-optional into `Optional` at the call site. - let summary: String - let onTapReaction: (String) -> Void - let onLongPress: () -> Void + /// Raw summary string (`"👍:3,❤️:2"`). Non-optional because the + /// fragment is only emitted for non-nil non-empty values. + /// `ReactionBadgesView` takes `summary: String?`; Swift auto-wraps the + /// non-optional into `Optional` at the call site. + let summary: String + let onTapReaction: (String) -> Void + let onLongPress: () -> Void - var body: some View { - ReactionBadgesView( - summary: summary, - onTapReaction: onTapReaction, - onLongPress: onLongPress - ) - .offset(y: -6) - .padding(.bottom, -6) - } + var body: some View { + ReactionBadgesView( + summary: summary, + onTapReaction: onTapReaction, + onLongPress: onLongPress + ) + .offset(y: -6) + .padding(.bottom, -6) + } } diff --git a/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift b/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift index d99c653b..c40ca19a 100644 --- a/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift +++ b/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift @@ -12,68 +12,68 @@ import UIKit /// delegate is set only off Mac; on Mac the secondary click routes through the table's context-menu /// interaction, which this delegate must not disturb. struct TapYieldingToLongPress: UIViewRepresentable { - let onTap: () -> Void + let onTap: () -> Void - func makeCoordinator() -> Coordinator { - Coordinator(onTap: onTap) - } + func makeCoordinator() -> Coordinator { + Coordinator(onTap: onTap) + } - func makeUIView(context: Context) -> UIView { - let view = UIView() - view.backgroundColor = .clear - view.addGestureRecognizer( - Self.makeRecognizer( - coordinator: context.coordinator, - isMac: ProcessInfo.processInfo.isiOSAppOnMac - ) - ) - return view - } + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + view.addGestureRecognizer( + Self.makeRecognizer( + coordinator: context.coordinator, + isMac: ProcessInfo.processInfo.isiOSAppOnMac + ) + ) + return view + } - func updateUIView(_ uiView: UIView, context: Context) { - context.coordinator.onTap = onTap - } + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.onTap = onTap + } - /// Builds the tap recognizer with the yielding policy. Pure and `isMac`-parameterized so the - /// `cancelsTouchesInView` flag and the off-Mac delegate gate can be exercised in a unit test. - static func makeRecognizer(coordinator: Coordinator, isMac: Bool) -> UITapGestureRecognizer { - let recognizer = UITapGestureRecognizer( - target: coordinator, - action: #selector(Coordinator.handleTap) - ) - recognizer.cancelsTouchesInView = false - if !isMac { - recognizer.delegate = coordinator - } - return recognizer + /// Builds the tap recognizer with the yielding policy. Pure and `isMac`-parameterized so the + /// `cancelsTouchesInView` flag and the off-Mac delegate gate can be exercised in a unit test. + static func makeRecognizer(coordinator: Coordinator, isMac: Bool) -> UITapGestureRecognizer { + let recognizer = UITapGestureRecognizer( + target: coordinator, + action: #selector(Coordinator.handleTap) + ) + recognizer.cancelsTouchesInView = false + if !isMac { + recognizer.delegate = coordinator } + return recognizer + } - @MainActor - final class Coordinator: NSObject, UIGestureRecognizerDelegate { - var onTap: () -> Void + @MainActor + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var onTap: () -> Void - init(onTap: @escaping () -> Void) { - self.onTap = onTap - } + init(onTap: @escaping () -> Void) { + self.onTap = onTap + } - @objc func handleTap() { - onTap() - } + @objc func handleTap() { + onTap() + } - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - !(otherGestureRecognizer is UILongPressGestureRecognizer) - } + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + !(otherGestureRecognizer is UILongPressGestureRecognizer) } + } } extension View { - /// Routes a quick tap to `perform` while yielding a sustained press to the bubble's long-press. - /// Use on interactive bubble fragments instead of a `Button`, whose press gesture would cancel - /// the bubble's `.onLongPressGesture` before the actions sheet can open. - func tapYieldingToLongPress(perform: @escaping () -> Void) -> some View { - overlay { TapYieldingToLongPress(onTap: perform) } - } + /// Routes a quick tap to `perform` while yielding a sustained press to the bubble's long-press. + /// Use on interactive bubble fragments instead of a `Button`, whose press gesture would cancel + /// the bubble's `.onLongPressGesture` before the actions sheet can open. + func tapYieldingToLongPress(perform: @escaping () -> Void) -> some View { + overlay { TapYieldingToLongPress(onTap: perform) } + } } diff --git a/MC1/Views/Chats/Components/FullScreenImageViewer.swift b/MC1/Views/Chats/Components/FullScreenImageViewer.swift index 5b342f29..431a8a52 100644 --- a/MC1/Views/Chats/Components/FullScreenImageViewer.swift +++ b/MC1/Views/Chats/Components/FullScreenImageViewer.swift @@ -4,240 +4,240 @@ import UniformTypeIdentifiers /// Data needed to present the full-screen image viewer struct ImageViewerData: Identifiable { - let id = UUID() - let imageData: Data - let isGIF: Bool + let id = UUID() + let imageData: Data + let isGIF: Bool } /// Full-screen image viewer with pinch-to-zoom, pan, and share struct FullScreenImageViewer: View { - let data: ImageViewerData - @Environment(\.dismiss) private var dismiss - @State private var dragOffset: CGFloat = 0 - - private var image: UIImage? { - UIImage(data: data.imageData) - } - - private var isDragging: Bool { - dragOffset != 0 - } - - private var backgroundOpacity: Double { - let progress = min(abs(dragOffset) / 300, 1) - return 1 - progress * 0.5 - } - - var body: some View { - NavigationStack { - ZStack { - Color.black.opacity(backgroundOpacity).ignoresSafeArea() - - if let image { - ZoomableImageView( - image: image, - onDragChanged: { offset in - dragOffset = offset - }, - onDragEnded: { offset, velocity in - if abs(offset) > 150 || abs(velocity) > 1000 { - dismiss() - } else { - withAnimation(.spring()) { - dragOffset = 0 - } - } - } - ) - .offset(y: dragOffset) - .ignoresSafeArea() - } - } - .toolbar(isDragging ? .hidden : .visible, for: .navigationBar) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.ImageViewer.close) { - dismiss() - } - .tint(.white) - } - ToolbarItem(placement: .primaryAction) { - ShareLink(item: ShareableImage(data: data.imageData), - preview: .init(L10n.Chats.Chats.ImageViewer.share)) - .tint(.white) + let data: ImageViewerData + @Environment(\.dismiss) private var dismiss + @State private var dragOffset: CGFloat = 0 + + private var image: UIImage? { + UIImage(data: data.imageData) + } + + private var isDragging: Bool { + dragOffset != 0 + } + + private var backgroundOpacity: Double { + let progress = min(abs(dragOffset) / 300, 1) + return 1 - progress * 0.5 + } + + var body: some View { + NavigationStack { + ZStack { + Color.black.opacity(backgroundOpacity).ignoresSafeArea() + + if let image { + ZoomableImageView( + image: image, + onDragChanged: { offset in + dragOffset = offset + }, + onDragEnded: { offset, velocity in + if abs(offset) > 150 || abs(velocity) > 1000 { + dismiss() + } else { + withAnimation(.spring()) { + dragOffset = 0 } + } } - .toolbarBackground(.hidden, for: .navigationBar) + ) + .offset(y: dragOffset) + .ignoresSafeArea() } - .accessibilityAction(.magicTap) { + } + .toolbar(isDragging ? .hidden : .visible, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.ImageViewer.close) { dismiss() + } + .tint(.white) } + ToolbarItem(placement: .primaryAction) { + ShareLink(item: ShareableImage(data: data.imageData), + preview: .init(L10n.Chats.Chats.ImageViewer.share)) + .tint(.white) + } + } + .toolbarBackground(.hidden, for: .navigationBar) + } + .accessibilityAction(.magicTap) { + dismiss() } + } } // MARK: - Zoomable Scroll View /// UIScrollView subclass that sizes its imageView on layout class ZoomableScrollView: UIScrollView { - let imageView = UIImageView() - - override func layoutSubviews() { - super.layoutSubviews() - guard bounds.width > 0, bounds.height > 0 else { return } - // Only update at 1x zoom to avoid fighting the zoom transform - if zoomScale == minimumZoomScale, imageView.frame.size != bounds.size { - imageView.frame = bounds - contentSize = bounds.size - } + let imageView = UIImageView() + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0, bounds.height > 0 else { return } + // Only update at 1x zoom to avoid fighting the zoom transform + if zoomScale == minimumZoomScale, imageView.frame.size != bounds.size { + imageView.frame = bounds + contentSize = bounds.size } + } } // MARK: - Zoomable Image View /// UIViewRepresentable wrapping UIScrollView for native zoom/pan behavior struct ZoomableImageView: UIViewRepresentable { - let image: UIImage - var onDragChanged: ((CGFloat) -> Void)? - var onDragEnded: ((CGFloat, CGFloat) -> Void)? - - func makeCoordinator() -> Coordinator { - Coordinator(onDragChanged: onDragChanged, onDragEnded: onDragEnded) + let image: UIImage + var onDragChanged: ((CGFloat) -> Void)? + var onDragEnded: ((CGFloat, CGFloat) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator(onDragChanged: onDragChanged, onDragEnded: onDragEnded) + } + + func makeUIView(context: Context) -> ZoomableScrollView { + let scrollView = ZoomableScrollView() + scrollView.minimumZoomScale = 1.0 + scrollView.maximumZoomScale = 5.0 + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.backgroundColor = .clear + scrollView.delegate = context.coordinator + + let imageView = scrollView.imageView + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.image = image + + scrollView.addSubview(imageView) + context.coordinator.imageView = imageView + context.coordinator.scrollView = scrollView + + let doubleTap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleDoubleTap(_:)) + ) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + + let dismissPan = UIPanGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleDismissPan(_:)) + ) + dismissPan.delegate = context.coordinator + scrollView.addGestureRecognizer(dismissPan) + context.coordinator.dismissPanGesture = dismissPan + + return scrollView + } + + func updateUIView(_ scrollView: ZoomableScrollView, context: Context) {} + + static func dismantleUIView(_ scrollView: ZoomableScrollView, coordinator: Coordinator) { + scrollView.imageView.image = nil + } + + final class Coordinator: NSObject, UIScrollViewDelegate, UIGestureRecognizerDelegate { + var imageView: UIImageView? + weak var scrollView: UIScrollView? + weak var dismissPanGesture: UIPanGestureRecognizer? + let onDragChanged: ((CGFloat) -> Void)? + let onDragEnded: ((CGFloat, CGFloat) -> Void)? + + init(onDragChanged: ((CGFloat) -> Void)?, onDragEnded: ((CGFloat, CGFloat) -> Void)?) { + self.onDragChanged = onDragChanged + self.onDragEnded = onDragEnded } - func makeUIView(context: Context) -> ZoomableScrollView { - let scrollView = ZoomableScrollView() - scrollView.minimumZoomScale = 1.0 - scrollView.maximumZoomScale = 5.0 - scrollView.showsVerticalScrollIndicator = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.backgroundColor = .clear - scrollView.delegate = context.coordinator - - let imageView = scrollView.imageView - imageView.contentMode = .scaleAspectFit - imageView.clipsToBounds = true - imageView.image = image - - scrollView.addSubview(imageView) - context.coordinator.imageView = imageView - context.coordinator.scrollView = scrollView - - let doubleTap = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleDoubleTap(_:)) - ) - doubleTap.numberOfTapsRequired = 2 - scrollView.addGestureRecognizer(doubleTap) - - let dismissPan = UIPanGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleDismissPan(_:)) - ) - dismissPan.delegate = context.coordinator - scrollView.addGestureRecognizer(dismissPan) - context.coordinator.dismissPanGesture = dismissPan - - return scrollView + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView } - func updateUIView(_ scrollView: ZoomableScrollView, context: Context) {} - - static func dismantleUIView(_ scrollView: ZoomableScrollView, coordinator: Coordinator) { - scrollView.imageView.image = nil + func scrollViewDidZoom(_ scrollView: UIScrollView) { + guard let imageView else { return } + let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) / 2, 0) + let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) / 2, 0) + imageView.center = CGPoint( + x: scrollView.contentSize.width / 2 + offsetX, + y: scrollView.contentSize.height / 2 + offsetY + ) } - final class Coordinator: NSObject, UIScrollViewDelegate, UIGestureRecognizerDelegate { - var imageView: UIImageView? - weak var scrollView: UIScrollView? - weak var dismissPanGesture: UIPanGestureRecognizer? - let onDragChanged: ((CGFloat) -> Void)? - let onDragEnded: ((CGFloat, CGFloat) -> Void)? - - init(onDragChanged: ((CGFloat) -> Void)?, onDragEnded: ((CGFloat, CGFloat) -> Void)?) { - self.onDragChanged = onDragChanged - self.onDragEnded = onDragEnded - } - - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - imageView - } - - func scrollViewDidZoom(_ scrollView: UIScrollView) { - guard let imageView else { return } - let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) / 2, 0) - let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) / 2, 0) - imageView.center = CGPoint( - x: scrollView.contentSize.width / 2 + offsetX, - y: scrollView.contentSize.height / 2 + offsetY - ) - } - - @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { - guard let scrollView else { return } - if scrollView.zoomScale > scrollView.minimumZoomScale { - scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) - } else { - let location = gesture.location(in: scrollView) - let zoomScale: CGFloat = 3.0 - let width = scrollView.bounds.width / zoomScale - let height = scrollView.bounds.height / zoomScale - let rect = CGRect( - x: location.x - width / 2, - y: location.y - height / 2, - width: width, - height: height - ) - scrollView.zoom(to: rect, animated: true) - } - } + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + guard let scrollView else { return } + if scrollView.zoomScale > scrollView.minimumZoomScale { + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) + } else { + let location = gesture.location(in: scrollView) + let zoomScale: CGFloat = 3.0 + let width = scrollView.bounds.width / zoomScale + let height = scrollView.bounds.height / zoomScale + let rect = CGRect( + x: location.x - width / 2, + y: location.y - height / 2, + width: width, + height: height + ) + scrollView.zoom(to: rect, animated: true) + } + } - // MARK: - Dismiss pan gesture + // MARK: - Dismiss pan gesture - @objc func handleDismissPan(_ gesture: UIPanGestureRecognizer) { - let translation = gesture.translation(in: gesture.view).y - let velocity = gesture.velocity(in: gesture.view).y + @objc func handleDismissPan(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: gesture.view).y + let velocity = gesture.velocity(in: gesture.view).y - switch gesture.state { - case .changed: - onDragChanged?(translation) - case .ended, .cancelled: - onDragEnded?(translation, velocity) - default: - break - } - } + switch gesture.state { + case .changed: + onDragChanged?(translation) + case .ended, .cancelled: + onDragEnded?(translation, velocity) + default: + break + } + } - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard gestureRecognizer === dismissPanGesture, - let pan = gestureRecognizer as? UIPanGestureRecognizer, - let scrollView else { - return true - } - guard scrollView.zoomScale <= scrollView.minimumZoomScale else { - return false - } - let velocity = pan.velocity(in: scrollView) - return abs(velocity.y) > abs(velocity.x) - } + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === dismissPanGesture, + let pan = gestureRecognizer as? UIPanGestureRecognizer, + let scrollView else { + return true + } + guard scrollView.zoomScale <= scrollView.minimumZoomScale else { + return false + } + let velocity = pan.velocity(in: scrollView) + return abs(velocity.y) > abs(velocity.x) + } - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer - ) -> Bool { - gestureRecognizer === dismissPanGesture - } + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === dismissPanGesture } + } } // MARK: - Shareable Image /// Transferable type for sharing image data. struct ShareableImage: Transferable { - let data: Data + let data: Data - static var transferRepresentation: some TransferRepresentation { - DataRepresentation(exportedContentType: .image) { item in - item.data - } + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .image) { item in + item.data } + } } diff --git a/MC1/Views/Chats/Components/InlineImageView.swift b/MC1/Views/Chats/Components/InlineImageView.swift index 6652869d..4e55e9a6 100644 --- a/MC1/Views/Chats/Components/InlineImageView.swift +++ b/MC1/Views/Chats/Components/InlineImageView.swift @@ -3,144 +3,144 @@ import UIKit /// Displays an inline image preview in the chat timeline struct InlineImageView: View { - let image: UIImage - let isGIF: Bool - let autoPlayGIFs: Bool - let isEmbedded: Bool - let onTap: () -> Void + let image: UIImage + let isGIF: Bool + let autoPlayGIFs: Bool + let isEmbedded: Bool + let onTap: () -> Void - private static let maxWidth: CGFloat = 280 - private static let maxHeight: CGFloat = 300 + private static let maxWidth: CGFloat = 280 + private static let maxHeight: CGFloat = 300 - private var displaySize: CGSize { - let w = image.size.width - let h = image.size.height - guard w > 0, h > 0 else { - return CGSize(width: Self.maxWidth, height: Self.maxHeight) - } - let aspect = w / h - var width = min(Self.maxWidth, w) - var height = width / aspect - if height > Self.maxHeight { - height = Self.maxHeight - width = height * aspect - } - return CGSize(width: width, height: height) + private var displaySize: CGSize { + let w = image.size.width + let h = image.size.height + guard w > 0, h > 0 else { + return CGSize(width: Self.maxWidth, height: Self.maxHeight) + } + let aspect = w / h + var width = min(Self.maxWidth, w) + var height = width / aspect + if height > Self.maxHeight { + height = Self.maxHeight + width = height * aspect } + return CGSize(width: width, height: height) + } - var body: some View { - if isGIF { - GIFContentView( - image: image, - autoPlayGIFs: autoPlayGIFs, - isEmbedded: isEmbedded, - displaySize: displaySize - ) - } else { - StaticImageContentView( - image: image, - isEmbedded: isEmbedded, - displaySize: displaySize, - onTap: onTap - ) - } + var body: some View { + if isGIF { + GIFContentView( + image: image, + autoPlayGIFs: autoPlayGIFs, + isEmbedded: isEmbedded, + displaySize: displaySize + ) + } else { + StaticImageContentView( + image: image, + isEmbedded: isEmbedded, + displaySize: displaySize, + onTap: onTap + ) } + } } // MARK: - GIF Content private struct GIFContentView: View { - let image: UIImage - let autoPlayGIFs: Bool - let isEmbedded: Bool - let displaySize: CGSize + let image: UIImage + let autoPlayGIFs: Bool + let isEmbedded: Bool + let displaySize: CGSize - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var isPlaying = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isPlaying = false - private var staticFrame: UIImage { - image.images?.first ?? image - } + private var staticFrame: UIImage { + image.images?.first ?? image + } - var body: some View { - Group { - if isPlaying { - AnimatedGIFView(image: image) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(false) - } else { - Image(uiImage: staticFrame) - .resizable() - .aspectRatio(contentMode: isEmbedded ? .fit : .fill) - } - } - .frame( - width: isEmbedded ? nil : displaySize.width, - height: isEmbedded ? nil : displaySize.height - ) - .overlay { - if !isPlaying { - ZStack { - Color.black.opacity(0.3) - Image(systemName: "play.circle.fill") - .font(.system(size: 44)) - .foregroundStyle(.white) - .shadow(radius: 2) - } - .allowsHitTesting(false) - } - } - .background { - if !isEmbedded { - Color.clear.background(.regularMaterial) - } - } - .clipShape(.rect(cornerRadius: isEmbedded ? 0 : 12)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { isPlaying.toggle() } - .onAppear { - isPlaying = autoPlayGIFs && !reduceMotion - } - .onChange(of: autoPlayGIFs) { _, newValue in - isPlaying = newValue && !reduceMotion - } - .onChange(of: reduceMotion) { _, newValue in - if newValue { isPlaying = false } + var body: some View { + Group { + if isPlaying { + AnimatedGIFView(image: image) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + } else { + Image(uiImage: staticFrame) + .resizable() + .aspectRatio(contentMode: isEmbedded ? .fit : .fill) + } + } + .frame( + width: isEmbedded ? nil : displaySize.width, + height: isEmbedded ? nil : displaySize.height + ) + .overlay { + if !isPlaying { + ZStack { + Color.black.opacity(0.3) + Image(systemName: "play.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.white) + .shadow(radius: 2) } - .accessibilityLabel(L10n.Chats.Chats.InlineImage.animatedAccessibility) - .accessibilityHint(L10n.Chats.Chats.InlineImage.tapHint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { isPlaying.toggle() } + .allowsHitTesting(false) + } + } + .background { + if !isEmbedded { + Color.clear.background(.regularMaterial) + } + } + .clipShape(.rect(cornerRadius: isEmbedded ? 0 : 12)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { isPlaying.toggle() } + .onAppear { + isPlaying = autoPlayGIFs && !reduceMotion } + .onChange(of: autoPlayGIFs) { _, newValue in + isPlaying = newValue && !reduceMotion + } + .onChange(of: reduceMotion) { _, newValue in + if newValue { isPlaying = false } + } + .accessibilityLabel(L10n.Chats.Chats.InlineImage.animatedAccessibility) + .accessibilityHint(L10n.Chats.Chats.InlineImage.tapHint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { isPlaying.toggle() } + } } // MARK: - Static Image Content private struct StaticImageContentView: View { - let image: UIImage - let isEmbedded: Bool - let displaySize: CGSize - let onTap: () -> Void + let image: UIImage + let isEmbedded: Bool + let displaySize: CGSize + let onTap: () -> Void - var body: some View { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: isEmbedded ? .fit : .fill) - .frame( - width: isEmbedded ? nil : displaySize.width, - height: isEmbedded ? nil : displaySize.height - ) - .background { - if !isEmbedded { - Color.clear.background(.regularMaterial) - } - } - .clipShape(.rect(cornerRadius: isEmbedded ? 0 : 12)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { onTap() } - .accessibilityLabel(L10n.Chats.Chats.InlineImage.imageAccessibility) - .accessibilityHint(L10n.Chats.Chats.InlineImage.tapHint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { onTap() } - } + var body: some View { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: isEmbedded ? .fit : .fill) + .frame( + width: isEmbedded ? nil : displaySize.width, + height: isEmbedded ? nil : displaySize.height + ) + .background { + if !isEmbedded { + Color.clear.background(.regularMaterial) + } + } + .clipShape(.rect(cornerRadius: isEmbedded ? 0 : 12)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { onTap() } + .accessibilityLabel(L10n.Chats.Chats.InlineImage.imageAccessibility) + .accessibilityHint(L10n.Chats.Chats.InlineImage.tapHint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { onTap() } + } } diff --git a/MC1/Views/Chats/Components/LinkPreviewCard.swift b/MC1/Views/Chats/Components/LinkPreviewCard.swift index 49b9322b..f6e8bc6c 100644 --- a/MC1/Views/Chats/Components/LinkPreviewCard.swift +++ b/MC1/Views/Chats/Components/LinkPreviewCard.swift @@ -4,133 +4,133 @@ import SwiftUI /// reserved from the image's aspect ratio (clamped to a min/max height) so the /// bubble does not jump when image bytes arrive after layout. struct LinkPreviewCard: View { - let url: URL - let title: String? - let image: UIImage? - let icon: UIImage? - let imageWidth: Int? - let imageHeight: Int? - let onTap: () -> Void + let url: URL + let title: String? + let image: UIImage? + let icon: UIImage? + let imageWidth: Int? + let imageHeight: Int? + let onTap: () -> Void - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @ScaledMetric(relativeTo: .body) private var minHeroHeight: CGFloat = RichPreviewMetrics.minHeroHeight - @ScaledMetric(relativeTo: .body) private var maxHeroHeight: CGFloat = RichPreviewMetrics.maxHeroHeight + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ScaledMetric(relativeTo: .body) private var minHeroHeight: CGFloat = RichPreviewMetrics.minHeroHeight + @ScaledMetric(relativeTo: .body) private var maxHeroHeight: CGFloat = RichPreviewMetrics.maxHeroHeight - private static let cardPadding: CGFloat = 10 - private static let headerSpacing: CGFloat = 8 - private static let iconSize: CGFloat = 16 - private static let iconCornerRadius: CGFloat = 4 + private static let cardPadding: CGFloat = 10 + private static let headerSpacing: CGFloat = 8 + private static let iconSize: CGFloat = 16 + private static let iconCornerRadius: CGFloat = 4 - init( - url: URL, - title: String?, - image: UIImage?, - icon: UIImage?, - imageWidth: Int? = nil, - imageHeight: Int? = nil, - onTap: @escaping () -> Void - ) { - self.url = url - self.title = title - self.image = image - self.icon = icon - self.imageWidth = imageWidth - self.imageHeight = imageHeight - self.onTap = onTap - } + init( + url: URL, + title: String?, + image: UIImage?, + icon: UIImage?, + imageWidth: Int? = nil, + imageHeight: Int? = nil, + onTap: @escaping () -> Void + ) { + self.url = url + self.title = title + self.image = image + self.icon = icon + self.imageWidth = imageWidth + self.imageHeight = imageHeight + self.onTap = onTap + } - private var domain: String { - url.host ?? url.absoluteString - } + private var domain: String { + url.host ?? url.absoluteString + } - /// Allow more lines for larger accessibility text sizes - private var titleLineLimit: Int { - dynamicTypeSize.isAccessibilitySize ? 4 : 2 - } + /// Allow more lines for larger accessibility text sizes + private var titleLineLimit: Int { + dynamicTypeSize.isAccessibilitySize ? 4 : 2 + } - private var domainLineLimit: Int { - dynamicTypeSize.isAccessibilitySize ? 2 : 1 - } + private var domainLineLimit: Int { + dynamicTypeSize.isAccessibilitySize ? 2 : 1 + } - private var heroAspect: CGFloat { - RichPreviewMetrics.heroAspect(imageWidth: imageWidth, imageHeight: imageHeight) - } + private var heroAspect: CGFloat { + RichPreviewMetrics.heroAspect(imageWidth: imageWidth, imageHeight: imageHeight) + } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - if let image { - RichPreviewCard( - aspect: heroAspect, - minHeight: minHeroHeight, - maxHeight: maxHeroHeight, - cornerStyle: .top - ) { - Image(uiImage: image) - .resizable() - .scaledToFill() - } - } - - HStack(spacing: Self.headerSpacing) { - if let icon { - Image(uiImage: icon) - .resizable() - .frame(width: Self.iconSize, height: Self.iconSize) - .clipShape(.rect(cornerRadius: Self.iconCornerRadius)) - } else { - Image(systemName: "globe") - .font(.caption) - .foregroundStyle(.secondary) - } + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let image { + RichPreviewCard( + aspect: heroAspect, + minHeight: minHeroHeight, + maxHeight: maxHeroHeight, + cornerStyle: .top + ) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } + } - VStack(alignment: .leading, spacing: 2) { - if let title, !title.isEmpty { - Text(title) - .font(.subheadline) - .bold() - .lineLimit(titleLineLimit) - .foregroundStyle(.primary) - } + HStack(spacing: Self.headerSpacing) { + if let icon { + Image(uiImage: icon) + .resizable() + .frame(width: Self.iconSize, height: Self.iconSize) + .clipShape(.rect(cornerRadius: Self.iconCornerRadius)) + } else { + Image(systemName: "globe") + .font(.caption) + .foregroundStyle(.secondary) + } - Text(domain) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(domainLineLimit) - } + VStack(alignment: .leading, spacing: 2) { + if let title, !title.isEmpty { + Text(title) + .font(.subheadline) + .bold() + .lineLimit(titleLineLimit) + .foregroundStyle(.primary) + } - Spacer() - } - .padding(Self.cardPadding) + Text(domain) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(domainLineLimit) } - .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { onTap() } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.LinkPreview.Accessibility.label(title ?? domain, domain)) - .accessibilityHint(L10n.Chats.Chats.LinkPreview.Accessibility.hint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { onTap() } + + Spacer() + } + .padding(Self.cardPadding) } + .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { onTap() } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.LinkPreview.Accessibility.label(title ?? domain, domain)) + .accessibilityHint(L10n.Chats.Chats.LinkPreview.Accessibility.hint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { onTap() } + } } #Preview("With Image") { - LinkPreviewCard( - url: URL(string: "https://apple.com/iphone")!, - title: "iPhone 16 Pro - Apple", - image: nil, - icon: nil, - onTap: {} - ) - .padding() + LinkPreviewCard( + url: URL(string: "https://apple.com/iphone")!, + title: "iPhone 16 Pro - Apple", + image: nil, + icon: nil, + onTap: {} + ) + .padding() } #Preview("Without Image") { - LinkPreviewCard( - url: URL(string: "https://example.com/article")!, - title: "An Interesting Article About Technology", - image: nil, - icon: nil, - onTap: {} - ) - .padding() + LinkPreviewCard( + url: URL(string: "https://example.com/article")!, + title: "An Interesting Article About Technology", + image: nil, + icon: nil, + onTap: {} + ) + .padding() } diff --git a/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift b/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift index b11a007e..0efac2ad 100644 --- a/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift +++ b/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift @@ -1,142 +1,140 @@ -import SwiftUI import MC1Services +import SwiftUI /// Placeholder card shown while a link preview is being resolved or while the /// hero image bytes are downloading. Driven by `LinkPreviewFragmentState.Mode` /// so the shape matches what `LinkPreviewCard` will eventually render — that /// keeps the bubble's reserved height stable across the transition. struct LinkPreviewLoadingCard: View { - let state: LinkPreviewFragmentState + let state: LinkPreviewFragmentState - @ScaledMetric(relativeTo: .body) private var minHeroHeight: CGFloat = RichPreviewMetrics.minHeroHeight - @ScaledMetric(relativeTo: .body) private var maxHeroHeight: CGFloat = RichPreviewMetrics.maxHeroHeight + @ScaledMetric(relativeTo: .body) private var minHeroHeight: CGFloat = RichPreviewMetrics.minHeroHeight + @ScaledMetric(relativeTo: .body) private var maxHeroHeight: CGFloat = RichPreviewMetrics.maxHeroHeight - private static let shimmerCornerRadius: CGFloat = 4 - private static let cardPadding: CGFloat = 10 - private static let textRowSpacing: CGFloat = 6 - private static let headerSpacing: CGFloat = 8 - private static let titleRowHeight: CGFloat = 14 - private static let domainRowHeight: CGFloat = 10 - private static let titleRowWidthFraction: CGFloat = 0.72 - private static let domainRowWidthFraction: CGFloat = 0.4 + private static let shimmerCornerRadius: CGFloat = 4 + private static let cardPadding: CGFloat = 10 + private static let textRowSpacing: CGFloat = 6 + private static let headerSpacing: CGFloat = 8 + private static let titleRowHeight: CGFloat = 14 + private static let domainRowHeight: CGFloat = 10 + private static let titleRowWidthFraction: CGFloat = 0.72 + private static let domainRowWidthFraction: CGFloat = 0.4 - var body: some View { - switch state.mode { - case .idle: - unknownImageCard(url: nil) - case .loading(let url): - unknownImageCard(url: url) - case .loaded(let preview, _, _): - confirmedImageCard(preview: preview) - default: - EmptyView() - } + var body: some View { + switch state.mode { + case .idle: + unknownImageCard(url: nil) + case let .loading(url): + unknownImageCard(url: url) + case let .loaded(preview, _, _): + confirmedImageCard(preview: preview) + default: + EmptyView() } + } - // Mode 1: shimmer hero + shimmer text rows. URL may be nil for `.idle`. - @ViewBuilder - private func unknownImageCard(url: URL?) -> some View { - let host = url?.host ?? "" - VStack(alignment: .leading, spacing: 0) { - shimmerHero(aspect: CGFloat(RichPreviewMetrics.fallbackAspect)) + /// Mode 1: shimmer hero + shimmer text rows. URL may be nil for `.idle`. + @ViewBuilder + private func unknownImageCard(url: URL?) -> some View { + let host = url?.host ?? "" + VStack(alignment: .leading, spacing: 0) { + shimmerHero(aspect: CGFloat(RichPreviewMetrics.fallbackAspect)) - VStack(alignment: .leading, spacing: Self.textRowSpacing) { - shimmerRow(height: Self.titleRowHeight, widthFraction: Self.titleRowWidthFraction) - shimmerRow(height: Self.domainRowHeight, widthFraction: Self.domainRowWidthFraction) - } - .padding(Self.cardPadding) - } - .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.Chats.Chats.Preview.loadingAccessibility(host)) - .accessibilityHint(L10n.Chats.Chats.Preview.loadingHint) + VStack(alignment: .leading, spacing: Self.textRowSpacing) { + shimmerRow(height: Self.titleRowHeight, widthFraction: Self.titleRowWidthFraction) + shimmerRow(height: Self.domainRowHeight, widthFraction: Self.domainRowWidthFraction) + } + .padding(Self.cardPadding) } + .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Chats.Chats.Preview.loadingAccessibility(host)) + .accessibilityHint(L10n.Chats.Chats.Preview.loadingHint) + } - // Mode 2: placeholder hero at the eventual size + real title + domain. - @ViewBuilder - private func confirmedImageCard(preview: LinkPreviewDataDTO) -> some View { - let url = URL(string: preview.url) - let host = url?.host ?? preview.url + /// Mode 2: placeholder hero at the eventual size + real title + domain. + @ViewBuilder + private func confirmedImageCard(preview: LinkPreviewDataDTO) -> some View { + let url = URL(string: preview.url) + let host = url?.host ?? preview.url - VStack(alignment: .leading, spacing: 0) { - shimmerHero(aspect: RichPreviewMetrics.heroAspect( - imageWidth: preview.imageWidth, - imageHeight: preview.imageHeight - )) + VStack(alignment: .leading, spacing: 0) { + shimmerHero(aspect: RichPreviewMetrics.heroAspect( + imageWidth: preview.imageWidth, + imageHeight: preview.imageHeight + )) - HStack(spacing: Self.headerSpacing) { - Image(systemName: "globe") - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: Self.headerSpacing) { + Image(systemName: "globe") + .font(.caption) + .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - if let title = preview.title, !title.isEmpty { - Text(title) - .font(.subheadline) - .bold() - .lineLimit(2) - .foregroundStyle(.primary) - } - Text(host) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - } - .padding(Self.cardPadding) + VStack(alignment: .leading, spacing: 2) { + if let title = preview.title, !title.isEmpty { + Text(title) + .font(.subheadline) + .bold() + .lineLimit(2) + .foregroundStyle(.primary) + } + Text(host) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } - .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.Chats.Chats.Preview.loadingAccessibility(host)) - .accessibilityHint(L10n.Chats.Chats.Preview.loadingHint) + + Spacer() + } + .padding(Self.cardPadding) } + .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Chats.Chats.Preview.loadingAccessibility(host)) + .accessibilityHint(L10n.Chats.Chats.Preview.loadingHint) + } - // The hero placeholder reserves the eventual image's footprint. Top corners - // are rounded; the bottom meets the in-card text rows squarely. - @ViewBuilder - private func shimmerHero(aspect: CGFloat) -> some View { - RichPreviewCard( - aspect: aspect, - minHeight: minHeroHeight, - maxHeight: maxHeroHeight, - cornerStyle: .top - ) { - PreviewSkeleton(cornerRadius: 0) - } + /// The hero placeholder reserves the eventual image's footprint. Top corners + /// are rounded; the bottom meets the in-card text rows squarely. + private func shimmerHero(aspect: CGFloat) -> some View { + RichPreviewCard( + aspect: aspect, + minHeight: minHeroHeight, + maxHeight: maxHeroHeight, + cornerStyle: .top + ) { + PreviewSkeleton(cornerRadius: 0) } + } - @ViewBuilder - private func shimmerRow(height: CGFloat, widthFraction: CGFloat) -> some View { - GeometryReader { proxy in - PreviewSkeleton(cornerRadius: Self.shimmerCornerRadius) - .frame(width: proxy.size.width * widthFraction, height: height) - } - .frame(height: height) - .accessibilityHidden(true) + private func shimmerRow(height: CGFloat, widthFraction: CGFloat) -> some View { + GeometryReader { proxy in + PreviewSkeleton(cornerRadius: Self.shimmerCornerRadius) + .frame(width: proxy.size.width * widthFraction, height: height) } + .frame(height: height) + .accessibilityHidden(true) + } } #Preview("Loading - unknown image") { - LinkPreviewLoadingCard( - state: .init(mode: .loading(URL(string: "https://example.com/article")!)) - ) - .padding() + LinkPreviewLoadingCard( + state: .init(mode: .loading(URL(string: "https://example.com/article")!)) + ) + .padding() } #Preview("Loaded - awaiting image bytes") { - LinkPreviewLoadingCard( - state: .init(mode: .loaded( - LinkPreviewDataDTO( - url: "https://apple.com/iphone", - title: "iPhone 16 Pro - Apple", - imageWidth: 1200, - imageHeight: 630 - ), - image: nil, - icon: nil - )) - ) - .padding() + LinkPreviewLoadingCard( + state: .init(mode: .loaded( + LinkPreviewDataDTO( + url: "https://apple.com/iphone", + title: "iPhone 16 Pro - Apple", + imageWidth: 1200, + imageHeight: 630 + ), + image: nil, + icon: nil + )) + ) + .padding() } diff --git a/MC1/Views/Chats/Components/MalwareWarningCard.swift b/MC1/Views/Chats/Components/MalwareWarningCard.swift index 86df0888..cf5f9fdd 100644 --- a/MC1/Views/Chats/Components/MalwareWarningCard.swift +++ b/MC1/Views/Chats/Components/MalwareWarningCard.swift @@ -3,64 +3,64 @@ import SwiftUI /// Warning card displayed when a message contains a URL from a known malware domain. /// Always shown regardless of link preview settings — this is a security alert, not a preview. struct MalwareWarningCard: View { - let url: URL + let url: URL - @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - private var domain: String { - url.host() ?? url.absoluteString - } + private var domain: String { + url.host() ?? url.absoluteString + } - private var titleLineLimit: Int { - dynamicTypeSize.isAccessibilitySize ? 4 : 2 - } + private var titleLineLimit: Int { + dynamicTypeSize.isAccessibilitySize ? 4 : 2 + } - private var domainLineLimit: Int { - dynamicTypeSize.isAccessibilitySize ? 2 : 1 - } + private var domainLineLimit: Int { + dynamicTypeSize.isAccessibilitySize ? 2 : 1 + } - var body: some View { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.shield.fill") - .foregroundStyle(.red) - .font(.title3) + var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.shield.fill") + .foregroundStyle(.red) + .font(.title3) - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Chats.Chats.MalwareWarning.title) - .font(.subheadline) - .bold() - .lineLimit(titleLineLimit) + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Chats.Chats.MalwareWarning.title) + .font(.subheadline) + .bold() + .lineLimit(titleLineLimit) - Text(domain) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(domainLineLimit) - } + Text(domain) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(domainLineLimit) + } - Spacer() - } - .padding(10) - .background( - .red.opacity(0.12), - in: .rect(cornerRadius: 12) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder(.red.opacity(0.3), lineWidth: 0.5) - ) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.MalwareWarning.accessibility(domain)) - .accessibilityAddTraits(.isStaticText) + Spacer() } + .padding(10) + .background( + .red.opacity(0.12), + in: .rect(cornerRadius: 12) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(.red.opacity(0.3), lineWidth: 0.5) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.MalwareWarning.accessibility(domain)) + .accessibilityAddTraits(.isStaticText) + } } #Preview("Malware Warning") { - MalwareWarningCard(url: URL(string: "https://evil-domain.com/malware")!) - .padding() + MalwareWarningCard(url: URL(string: "https://evil-domain.com/malware")!) + .padding() } #Preview("Dark Mode") { - MalwareWarningCard(url: URL(string: "https://evil-domain.com/malware")!) - .padding() - .preferredColorScheme(.dark) + MalwareWarningCard(url: URL(string: "https://evil-domain.com/malware")!) + .padding() + .preferredColorScheme(.dark) } diff --git a/MC1/Views/Chats/Components/MentionSuggestionRow.swift b/MC1/Views/Chats/Components/MentionSuggestionRow.swift index 9c67a0fa..ed96facd 100644 --- a/MC1/Views/Chats/Components/MentionSuggestionRow.swift +++ b/MC1/Views/Chats/Components/MentionSuggestionRow.swift @@ -1,26 +1,26 @@ -import SwiftUI import MC1Services +import SwiftUI /// A single row in the mention suggestions popup struct MentionSuggestionRow: View { - let contact: ContactDTO + let contact: ContactDTO - var body: some View { - HStack(spacing: 12) { - ContactAvatar(contact: contact, size: 32) + var body: some View { + HStack(spacing: 12) { + ContactAvatar(contact: contact, size: 32) - Text(contact.displayName) - .lineLimit(1) + Text(contact.displayName) + .lineLimit(1) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(.rect) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Mention.Accessibility.label(contact.displayName)) - .accessibilityHint(contact.publicKey.isEmpty - ? L10n.Chats.Chats.Mention.Accessibility.hintChannel - : L10n.Chats.Chats.Mention.Accessibility.hintContact) + Spacer() } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(.rect) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Mention.Accessibility.label(contact.displayName)) + .accessibilityHint(contact.publicKey.isEmpty + ? L10n.Chats.Chats.Mention.Accessibility.hintChannel + : L10n.Chats.Chats.Mention.Accessibility.hintContact) + } } diff --git a/MC1/Views/Chats/Components/MentionSuggestionView.swift b/MC1/Views/Chats/Components/MentionSuggestionView.swift index ebdc20df..f48b8371 100644 --- a/MC1/Views/Chats/Components/MentionSuggestionView.swift +++ b/MC1/Views/Chats/Components/MentionSuggestionView.swift @@ -1,51 +1,51 @@ -import SwiftUI import MC1Services +import SwiftUI /// Floating popup displaying mention suggestions struct MentionSuggestionView: View { - let contacts: [ContactDTO] - let onSelect: (ContactDTO) -> Void + let contacts: [ContactDTO] + let onSelect: (ContactDTO) -> Void - private let maxHeight: CGFloat = 200 - private let rowHeight: CGFloat = 48 // Avatar 32 + vertical padding 16 - private let maxSuggestions = 20 + private let maxHeight: CGFloat = 200 + private let rowHeight: CGFloat = 48 // Avatar 32 + vertical padding 16 + private let maxSuggestions = 20 - private var suggestions: ArraySlice { - contacts.prefix(maxSuggestions) - } + private var suggestions: ArraySlice { + contacts.prefix(maxSuggestions) + } - private var contentHeight: CGFloat { - let count = suggestions.count - let dividerHeight: CGFloat = 1 - let totalHeight = CGFloat(count) * rowHeight + CGFloat(max(0, count - 1)) * dividerHeight - return min(totalHeight, maxHeight) - } + private var contentHeight: CGFloat { + let count = suggestions.count + let dividerHeight: CGFloat = 1 + let totalHeight = CGFloat(count) * rowHeight + CGFloat(max(0, count - 1)) * dividerHeight + return min(totalHeight, maxHeight) + } - var body: some View { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(suggestions) { contact in - VStack(spacing: 0) { - Button { - onSelect(contact) - } label: { - MentionSuggestionRow(contact: contact) - } - .buttonStyle(.plain) + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(suggestions) { contact in + VStack(spacing: 0) { + Button { + onSelect(contact) + } label: { + MentionSuggestionRow(contact: contact) + } + .buttonStyle(.plain) - if contact.id != suggestions.last?.id { - Divider() - .padding(.leading, 44) - } - } - } + if contact.id != suggestions.last?.id { + Divider() + .padding(.leading, 44) } + } } - .frame(height: contentHeight) - .background(.regularMaterial) - .clipShape(.rect(cornerRadius: 12)) - .shadow(color: .black.opacity(0.15), radius: 8, y: 2) - .animation(.spring(response: 0.25, dampingFraction: 0.9), value: contacts.count) - .accessibilityLabel(L10n.Chats.Chats.Suggestions.accessibilityLabel) + } } + .frame(height: contentHeight) + .background(.regularMaterial) + .clipShape(.rect(cornerRadius: 12)) + .shadow(color: .black.opacity(0.15), radius: 8, y: 2) + .animation(.spring(response: 0.25, dampingFraction: 0.9), value: contacts.count) + .accessibilityLabel(L10n.Chats.Chats.Suggestions.accessibilityLabel) + } } diff --git a/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift b/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift index a4ebd815..cc472f39 100644 --- a/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift +++ b/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift @@ -8,15 +8,15 @@ import UIKit /// snapshot store so the bubble resolves, requests, and retries thumbnails through providers rather /// than reaching `MapSnapshotStore.shared` from the view body. struct MessageBubbleCallbacks { - var onRetry: (() -> Void)? - var onReaction: ((String) -> Void)? - var onLongPress: (() -> Void)? - var onImageTap: (() -> Void)? - var onRetryInlineImage: (() -> Void)? - var onRequestPreviewFetch: (() -> Void)? - var onManualPreviewFetch: (() -> Void)? - var onMapPreviewTap: ((CLLocationCoordinate2D) -> Void)? - var snapshotResolver: ((MapSnapshotRequest) -> UIImage?)? - var requestSnapshot: ((MapSnapshotRequest) -> Void)? - var retrySnapshot: ((MapSnapshotRequest) -> Void)? + var onRetry: (() -> Void)? + var onReaction: ((String) -> Void)? + var onLongPress: (() -> Void)? + var onImageTap: (() -> Void)? + var onRetryInlineImage: (() -> Void)? + var onRequestPreviewFetch: (() -> Void)? + var onManualPreviewFetch: (() -> Void)? + var onMapPreviewTap: ((CLLocationCoordinate2D) -> Void)? + var snapshotResolver: ((MapSnapshotRequest) -> UIImage?)? + var requestSnapshot: ((MapSnapshotRequest) -> Void)? + var retrySnapshot: ((MapSnapshotRequest) -> Void)? } diff --git a/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift b/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift index 67ebd5a5..107fdac5 100644 --- a/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift +++ b/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift @@ -1,49 +1,75 @@ import MC1Services /// View-layer formatting flags for a message bubble. -struct MessageBubbleConfiguration: Sendable { - var showSenderName: Bool +struct MessageBubbleConfiguration { + var showSenderName: Bool - static let directMessage = MessageBubbleConfiguration(showSenderName: false) + static let directMessage = MessageBubbleConfiguration(showSenderName: false) - static func channel(isPublic: Bool) -> MessageBubbleConfiguration { - MessageBubbleConfiguration(showSenderName: true) + static func channel(isPublic: Bool) -> MessageBubbleConfiguration { + MessageBubbleConfiguration(showSenderName: true) + } + + /// Builds a `loweredName -> nickname` lookup for channel sender matching. + /// Only names owned by exactly one contact are included, so an ambiguous name + /// collision never asserts a specific nickname. Keyed by locale-independent + /// `lowercased()` because a hashable key needs a fixed fold; this intentionally + /// diverges from `SenderContactMatcher`'s locale-aware `localizedCaseInsensitiveCompare`, + /// so on locale-tailored case folding (Turkish dotless-i, German ß) this badge and + /// the Block/DM actions can disagree. Accepted for the O(1) per-message lookup. + static func buildNicknameLookup(from contacts: [ContactDTO]) -> [String: String] { + var counts: [String: Int] = [:] + for contact in contacts { + counts[contact.name.lowercased(), default: 0] += 1 + } + var lookup: [String: String] = [:] + for contact in contacts { + let key = contact.name.lowercased() + guard counts[key] == 1, let nickname = contact.nickname, !nickname.isEmpty else { continue } + lookup[key] = nickname + } + return lookup + } + + /// Resolves the display name for a message's sender from the contacts list. + /// Used by `ChatViewModel+ItemBuild` to bake the resolved name into + /// `MessageItem.envelope.senderResolution` upstream. + static func resolveSenderName( + for message: MessageDTO, + contacts: [ContactDTO], + nicknamesByLoweredName: [String: String] = [:] + ) -> NodeNameResolution { + // First, try parsed sender name from channel message + if let senderName = message.senderNodeName, !senderName.isEmpty { + let nickname = nicknamesByLoweredName[senderName.lowercased()] + return NodeNameResolution(displayName: senderName, matchKind: .exact, unverifiedNickname: nickname) + } + + // Fallback: key prefix lookup + guard let prefix = message.senderKeyPrefix else { + return NodeNameResolution( + displayName: L10n.Chats.Chats.Message.Sender.unknown, + matchKind: .unresolved + ) + } + + // Try to find matching contact + if let result = NeighborNameResolver.resolve( + for: prefix, + contacts: contacts, + discoveredNodes: [], + userLocation: nil + ) { + return result } - /// Resolves the display name for a message's sender from the contacts list. - /// Used by `ChatViewModel+ItemBuild` to bake the resolved name into - /// `MessageItem.envelope.senderResolution` upstream. - static func resolveSenderName(for message: MessageDTO, contacts: [ContactDTO]) -> NodeNameResolution { - // First, try parsed sender name from channel message - if let senderName = message.senderNodeName, !senderName.isEmpty { - return NodeNameResolution(displayName: senderName, matchKind: .exact) - } - - // Fallback: key prefix lookup - guard let prefix = message.senderKeyPrefix else { - return NodeNameResolution( - displayName: L10n.Chats.Chats.Message.Sender.unknown, - matchKind: .unresolved - ) - } - - // Try to find matching contact - if let result = NeighborNameResolver.resolve( - for: prefix, - contacts: contacts, - discoveredNodes: [], - userLocation: nil - ) { - return result - } - - // Fallback to hex representation - if prefix.count >= 2 { - return NodeNameResolution(displayName: prefix.prefix(2).uppercaseHexString(), matchKind: .unresolved) - } - return NodeNameResolution( - displayName: L10n.Chats.Chats.Message.Sender.unknown, - matchKind: .unresolved - ) + // Fallback to hex representation + if prefix.count >= 2 { + return NodeNameResolution(displayName: prefix.prefix(2).uppercaseHexString(), matchKind: .unresolved) } + return NodeNameResolution( + displayName: L10n.Chats.Chats.Message.Sender.unknown, + matchKind: .unresolved + ) + } } diff --git a/MC1/Views/Chats/Components/MessageBubbleLongPress.swift b/MC1/Views/Chats/Components/MessageBubbleLongPress.swift index 43e722e3..5e839481 100644 --- a/MC1/Views/Chats/Components/MessageBubbleLongPress.swift +++ b/MC1/Views/Chats/Components/MessageBubbleLongPress.swift @@ -5,70 +5,70 @@ import SwiftUI /// (`RoomMessageBubble`) bubbles and their conversation views so the interaction /// feels identical across surfaces. enum MessageActionsPresentation { - /// Seconds the user must hold before the actions sheet opens: long enough a - /// quick tap can never reach it, short enough a deliberate hold stays responsive. - static let longPressConfirmDuration: Double = 0.6 + /// Seconds the user must hold before the actions sheet opens: long enough a + /// quick tap can never reach it, short enough a deliberate hold stays responsive. + static let longPressConfirmDuration: Double = 0.6 - /// Spring `response` (natural period) for the press-in/release scale animation. - static let longPressSpringResponse: Double = 0.7 + /// Spring `response` (natural period) for the press-in/release scale animation. + static let longPressSpringResponse: Double = 0.7 - /// Delay before the press-in scale animates, so a brief accidental tap shows - /// no lift. No delay on release so the bubble retracts immediately. - static let longPressInDelay: Double = 0.1 + /// Delay before the press-in scale animates, so a brief accidental tap shows + /// no lift. No delay on release so the bubble retracts immediately. + static let longPressInDelay: Double = 0.1 - /// Scale the bubble shrinks to while pressed, before the actions sheet opens. - static let longPressPressedScale: CGFloat = 0.95 + /// Scale the bubble shrinks to while pressed, before the actions sheet opens. + static let longPressPressedScale: CGFloat = 0.95 - /// Delay after the actions sheet dismisses before raising the keyboard or - /// presenting a follow-on sheet. A focus request issued mid-dismissal is lost, - /// and on iPad UIKit cancels a new sheet presented before the old one finishes - /// animating away, leaving the user looking at nothing. - static let dismissalDelay: Duration = .milliseconds(300) + /// Delay after the actions sheet dismisses before raising the keyboard or + /// presenting a follow-on sheet. A focus request issued mid-dismissal is lost, + /// and on iPad UIKit cancels a new sheet presented before the old one finishes + /// animating away, leaving the user looking at nothing. + static let dismissalDelay: Duration = .milliseconds(300) } extension View { - /// Attaches the bubble's actions-sheet long-press: a sustained press fires - /// `onFire`, drives the shared press state, and bumps the haptic trigger. - /// Apply to every sub-view that should open the actions sheet; the visual - /// response is rendered once by `messageBubbleLongPressEffect`. - func messageBubbleLongPressGesture( - isPressing: Binding, - trigger: Binding, - onFire: @escaping () -> Void - ) -> some View { - onLongPressGesture( - minimumDuration: MessageActionsPresentation.longPressConfirmDuration, - perform: { - trigger.wrappedValue += 1 - onFire() - }, - onPressingChanged: { isPressing.wrappedValue = $0 } - ) - } + /// Attaches the bubble's actions-sheet long-press: a sustained press fires + /// `onFire`, drives the shared press state, and bumps the haptic trigger. + /// Apply to every sub-view that should open the actions sheet; the visual + /// response is rendered once by `messageBubbleLongPressEffect`. + func messageBubbleLongPressGesture( + isPressing: Binding, + trigger: Binding, + onFire: @escaping () -> Void + ) -> some View { + onLongPressGesture( + minimumDuration: MessageActionsPresentation.longPressConfirmDuration, + perform: { + trigger.wrappedValue += 1 + onFire() + }, + onPressingChanged: { isPressing.wrappedValue = $0 } + ) + } - /// Renders the press-in shrink and haptic for a message bubble. Apply once to - /// the view that should scale; pair with `messageBubbleLongPressGesture`. - func messageBubbleLongPressEffect(isPressing: Bool, trigger: Int) -> some View { - modifier(MessageBubbleLongPressEffect(isPressing: isPressing, trigger: trigger)) - } + /// Renders the press-in shrink and haptic for a message bubble. Apply once to + /// the view that should scale; pair with `messageBubbleLongPressGesture`. + func messageBubbleLongPressEffect(isPressing: Bool, trigger: Int) -> some View { + modifier(MessageBubbleLongPressEffect(isPressing: isPressing, trigger: trigger)) + } } private struct MessageBubbleLongPressEffect: ViewModifier { - let isPressing: Bool - let trigger: Int + let isPressing: Bool + let trigger: Int - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceMotion) private var reduceMotion - func body(content: Content) -> some View { - content - .scaleEffect(isPressing ? MessageActionsPresentation.longPressPressedScale : 1.0) - .animation( - reduceMotion - ? nil - : .spring(response: MessageActionsPresentation.longPressSpringResponse) - .delay(isPressing ? MessageActionsPresentation.longPressInDelay : 0), - value: isPressing - ) - .sensoryFeedback(.impact(flexibility: .solid), trigger: trigger) - } + func body(content: Content) -> some View { + content + .scaleEffect(isPressing ? MessageActionsPresentation.longPressPressedScale : 1.0) + .animation( + reduceMotion + ? nil + : .spring(response: MessageActionsPresentation.longPressSpringResponse) + .delay(isPressing ? MessageActionsPresentation.longPressInDelay : 0), + value: isPressing + ) + .sensoryFeedback(.impact(flexibility: .solid), trigger: trigger) + } } diff --git a/MC1/Views/Chats/Components/MessageBubbleView.swift b/MC1/Views/Chats/Components/MessageBubbleView.swift index 05caa1ec..bfe5e0ef 100644 --- a/MC1/Views/Chats/Components/MessageBubbleView.swift +++ b/MC1/Views/Chats/Components/MessageBubbleView.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Renders a `UnifiedMessageBubble` for a stored `MessageItem`. Resolves /// the message DTO and per-message assets through `BubbleResolver` and @@ -8,46 +8,46 @@ import MC1Services /// `Equatable` on `MessageItem` alone so SwiftUI can skip rebodies when /// the row identity and content are unchanged. struct MessageBubbleView: View, Equatable { - let item: MessageItem - let contactName: String - let deviceName: String - let configuration: MessageBubbleConfiguration - let resolver: BubbleResolver - let actions: BubbleActions + let item: MessageItem + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + let resolver: BubbleResolver + let actions: BubbleActions - nonisolated static func == (lhs: MessageBubbleView, rhs: MessageBubbleView) -> Bool { - lhs.item == rhs.item - } + nonisolated static func == (lhs: MessageBubbleView, rhs: MessageBubbleView) -> Bool { + lhs.item == rhs.item + } - var body: some View { - if let message = resolver.message(item) { - UnifiedMessageBubble( - message: message, - contactName: contactName, - deviceName: deviceName, - configuration: configuration, - item: item, - layout: FragmentLayout(content: item.content), - imageResolver: { ref in resolver.image(ref) }, - callbacks: MessageBubbleCallbacks( - onRetry: { actions.onRetryMessage(message) }, - onReaction: { emoji in actions.onReaction(emoji, message) }, - onLongPress: { actions.onLongPress(message) }, - onImageTap: { actions.onImageTap(message) }, - onRetryInlineImage: { actions.onRetryInlineImage(message.id) }, - onRequestPreviewFetch: { actions.onRequestPreviewFetch(message.id) }, - onManualPreviewFetch: { actions.onManualPreviewFetch(message.id) }, - onMapPreviewTap: { coordinate in actions.onMapPreviewTap(coordinate) }, - snapshotResolver: actions.snapshotResolver, - requestSnapshot: actions.requestSnapshot, - retrySnapshot: actions.retrySnapshot - ) - ) - } else { - Text(L10n.Chats.Chats.Message.unavailable) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) - } + var body: some View { + if let message = resolver.message(item) { + UnifiedMessageBubble( + message: message, + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + item: item, + layout: FragmentLayout(content: item.content), + imageResolver: { ref in resolver.image(ref) }, + callbacks: MessageBubbleCallbacks( + onRetry: { actions.onRetryMessage(message) }, + onReaction: { emoji in actions.onReaction(emoji, message) }, + onLongPress: { actions.onLongPress(message) }, + onImageTap: { actions.onImageTap(message) }, + onRetryInlineImage: { actions.onRetryInlineImage(message.id) }, + onRequestPreviewFetch: { actions.onRequestPreviewFetch(message.id) }, + onManualPreviewFetch: { actions.onManualPreviewFetch(message.id) }, + onMapPreviewTap: { coordinate in actions.onMapPreviewTap(coordinate) }, + snapshotResolver: actions.snapshotResolver, + requestSnapshot: actions.requestSnapshot, + retrySnapshot: actions.retrySnapshot + ) + ) + } else { + Text(L10n.Chats.Chats.Message.unavailable) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) } + } } diff --git a/MC1/Views/Chats/Components/MessageDayDividerView.swift b/MC1/Views/Chats/Components/MessageDayDividerView.swift index 118fe73b..ebc1b1d9 100644 --- a/MC1/Views/Chats/Components/MessageDayDividerView.swift +++ b/MC1/Views/Chats/Components/MessageDayDividerView.swift @@ -4,62 +4,62 @@ import SwiftUI /// relative day so the time markers (`MessageTimestampView`) stay time-only. /// Wrapped in `TimelineView` so "Today" rolls to "Yesterday" at midnight. struct MessageDayDividerView: View { - let date: Date + let date: Date - var body: some View { - TimelineView(.everyMinute) { context in - Text(label(relativeTo: context.date)) - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 4) - } + var body: some View { + TimelineView(.everyMinute) { context in + Text(label(relativeTo: context.date)) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 4) } + } - private func label(relativeTo now: Date) -> String { - let calendar = Calendar.current + private func label(relativeTo now: Date) -> String { + let calendar = Calendar.current - if calendar.isDateInToday(date) { - return L10n.Chats.Chats.Timestamp.today - } else if calendar.isDateInYesterday(date) { - return L10n.Chats.Chats.Timestamp.yesterday - } + if calendar.isDateInToday(date) { + return L10n.Chats.Chats.Timestamp.today + } else if calendar.isDateInYesterday(date) { + return L10n.Chats.Chats.Timestamp.yesterday + } - // Weekday name for the past week, capped at 6 days so it cannot collide - // with today's weekday (which 7 days ago would share). - let dayOffset = calendar.dateComponents( - [.day], - from: calendar.startOfDay(for: date), - to: calendar.startOfDay(for: now) - ).day ?? 0 + // Weekday name for the past week, capped at 6 days so it cannot collide + // with today's weekday (which 7 days ago would share). + let dayOffset = calendar.dateComponents( + [.day], + from: calendar.startOfDay(for: date), + to: calendar.startOfDay(for: now) + ).day ?? 0 - if (2...6).contains(dayOffset) { - return date.formatted(.dateTime.weekday(.wide)) - } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { - return date.formatted(.dateTime.month(.abbreviated).day()) - } else { - return date.formatted(.dateTime.month(.abbreviated).day().year()) - } + if (2...6).contains(dayOffset) { + return date.formatted(.dateTime.weekday(.wide)) + } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { + return date.formatted(.dateTime.month(.abbreviated).day()) + } else { + return date.formatted(.dateTime.month(.abbreviated).day().year()) } + } } #Preview("Today") { - MessageDayDividerView(date: Date()) - .padding() + MessageDayDividerView(date: Date()) + .padding() } #Preview("Yesterday") { - MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -1, to: Date())!) - .padding() + MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -1, to: Date())!) + .padding() } #Preview("Weekday") { - MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -3, to: Date())!) - .padding() + MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -3, to: Date())!) + .padding() } #Preview("Last Year") { - MessageDayDividerView(date: Calendar.current.date(byAdding: .year, value: -1, to: Date())!) - .padding() + MessageDayDividerView(date: Calendar.current.date(byAdding: .year, value: -1, to: Date())!) + .padding() } diff --git a/MC1/Views/Chats/Components/MessagePathContent.swift b/MC1/Views/Chats/Components/MessagePathContent.swift index 856a4b02..209b50ca 100644 --- a/MC1/Views/Chats/Components/MessagePathContent.swift +++ b/MC1/Views/Chats/Components/MessagePathContent.swift @@ -6,81 +6,81 @@ import SwiftUI /// Inline content for message path visualization, extracted from MessagePathSheet. /// Shows sender, intermediate hops, receiver, raw path hex, and a copy button. struct MessagePathContent: View { - let message: MessageDTO - let viewModel: MessagePathViewModel - let receiverName: String - let userLocation: CLLocation? + let message: MessageDTO + let viewModel: MessagePathViewModel + let receiverName: String + let userLocation: CLLocation? - @State private var copyHapticTrigger = 0 + @State private var copyHapticTrigger = 0 - var body: some View { - if viewModel.isLoading { - ProgressView() - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if message.pathNodes == nil { - ContentUnavailableView( - L10n.Chats.Chats.Path.Unavailable.title, - systemImage: "point.topleft.down.to.point.bottomright.curvepath", - description: Text(L10n.Chats.Chats.Path.Unavailable.description) - ) - } else { - let senderResolution = viewModel.senderResolution(for: message) - let pathHops = message.pathHops + var body: some View { + if viewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if message.pathNodes == nil { + ContentUnavailableView( + L10n.Chats.Chats.Path.Unavailable.title, + systemImage: "point.topleft.down.to.point.bottomright.curvepath", + description: Text(L10n.Chats.Chats.Path.Unavailable.description) + ) + } else { + let senderResolution = viewModel.senderResolution(for: message) + let pathHops = message.pathHops - // Sender - PathHopRowView( - hopType: .sender, - nodeName: senderResolution.displayName, - nodeID: viewModel.senderNodeID(for: message), - snr: nil, - matchKind: senderResolution.matchKind - ) + // Sender + PathHopRowView( + hopType: .sender, + nodeName: senderResolution.displayName, + nodeID: viewModel.senderNodeID(for: message), + snr: nil, + matchKind: senderResolution.matchKind + ) - // Intermediate hops - ForEach(Array(pathHops.enumerated()), id: \.offset) { index, hop in - let repeaterResolution = viewModel.repeaterResolution( - for: hop.data, - userLocation: userLocation - ) - PathHopRowView( - hopType: .intermediate(index + 1), - nodeName: repeaterResolution.displayName, - nodeID: hop.hex, - snr: nil, - matchKind: repeaterResolution.matchKind - ) - } + // Intermediate hops + ForEach(Array(pathHops.enumerated()), id: \.offset) { index, hop in + let repeaterResolution = viewModel.repeaterResolution( + for: hop.data, + userLocation: userLocation + ) + PathHopRowView( + hopType: .intermediate(index + 1), + nodeName: repeaterResolution.displayName, + nodeID: hop.hex, + snr: nil, + matchKind: repeaterResolution.matchKind + ) + } - // Receiver - PathHopRowView( - hopType: .receiver, - nodeName: receiverName, - nodeID: nil, - snr: message.snr - ) + // Receiver + PathHopRowView( + hopType: .receiver, + nodeName: receiverName, + nodeID: nil, + snr: message.snr + ) - // Raw path hex + copy button - if !pathHops.isEmpty { - HStack { - Button(L10n.Chats.Chats.Path.copyButton, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = message.pathStringForClipboard - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .accessibilityLabel(L10n.Chats.Chats.Path.copyAccessibility) - .accessibilityHint(L10n.Chats.Chats.Path.copyHint) + // Raw path hex + copy button + if !pathHops.isEmpty { + HStack { + Button(L10n.Chats.Chats.Path.copyButton, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = message.pathStringForClipboard + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .accessibilityLabel(L10n.Chats.Chats.Path.copyAccessibility) + .accessibilityHint(L10n.Chats.Chats.Path.copyHint) - Text(message.pathString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + Text(message.pathString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) - Spacer() - } - .padding(.top, 8) - .sensoryFeedback(.success, trigger: copyHapticTrigger) - } + Spacer() } + .padding(.top, 8) + .sensoryFeedback(.success, trigger: copyHapticTrigger) + } } + } } diff --git a/MC1/Views/Chats/Components/MessagePathFormatter.swift b/MC1/Views/Chats/Components/MessagePathFormatter.swift index 50e016d7..3e116acc 100644 --- a/MC1/Views/Chats/Components/MessagePathFormatter.swift +++ b/MC1/Views/Chats/Components/MessagePathFormatter.swift @@ -3,34 +3,34 @@ import MC1Services /// Formats message routing path for display in message bubbles enum MessagePathFormatter { - /// Formats the routing path for display - /// - Parameter message: The message DTO containing path information - /// - Returns: Formatted path string (e.g., "Direct", "Flood", "A3,7F,42", or "A3,7F…B2,C1") - static func format(_ message: MessageDTO) -> String { - if message.isDirectRouted { - return L10n.Chats.Chats.Message.Path.direct - } - - // Destination marker: single 0xFF byte indicates direct message - if let pathNodes = message.pathNodes, - pathNodes.count == 1, - pathNodes[0] == 0xFF { - return L10n.Chats.Chats.Message.Path.direct - } + /// Formats the routing path for display + /// - Parameter message: The message DTO containing path information + /// - Returns: Formatted path string (e.g., "Direct", "Flood", "A3,7F,42", or "A3,7F…B2,C1") + static func format(_ message: MessageDTO) -> String { + if message.isDirectRouted { + return L10n.Chats.Chats.Message.Path.direct + } - let nodes = message.pathNodesHex + // Destination marker: single 0xFF byte indicates direct message + if let pathNodes = message.pathNodes, + pathNodes.count == 1, + pathNodes[0] == 0xFF { + return L10n.Chats.Chats.Message.Path.direct + } - if nodes.isEmpty { - return L10n.Chats.Chats.Message.Path.flood - } + let nodes = message.pathNodesHex - // Truncate if more than 6 nodes: show first 3 + ellipsis + last 3 - if nodes.count > 6 { - let first = nodes.prefix(3).joined(separator: ",") - let last = nodes.suffix(3).joined(separator: ",") - return "\(first)…\(last)" - } + if nodes.isEmpty { + return L10n.Chats.Chats.Message.Path.flood + } - return nodes.joined(separator: ",") + // Truncate if more than 6 nodes: show first 3 + ellipsis + last 3 + if nodes.count > 6 { + let first = nodes.prefix(3).joined(separator: ",") + let last = nodes.suffix(3).joined(separator: ",") + return "\(first)…\(last)" } + + return nodes.joined(separator: ",") + } } diff --git a/MC1/Views/Chats/Components/MessagePathMapView.swift b/MC1/Views/Chats/Components/MessagePathMapView.swift index c2f3d2d3..e5bb8ce1 100644 --- a/MC1/Views/Chats/Components/MessagePathMapView.swift +++ b/MC1/Views/Chats/Components/MessagePathMapView.swift @@ -5,229 +5,242 @@ import MC1Services import SwiftUI struct MessagePathMapView: View { - /// Span used when the path resolves to a single node, with no bounding box to fit. - private static let singleNodeSpanDelta: CLLocationDegrees = 0.05 - /// Padding around the multi-node bounding region, wider than `boundingRegion`'s - /// 1.5 default to keep the path clear of the floating map-controls toolbar. - private static let pathBoundingPaddingMultiplier: Double = 2.5 - - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme - - let message: MessageDTO - let pathViewModel: MessagePathViewModel - - @State private var cameraRegion: MKCoordinateRegion? - @State private var cameraRegionVersion = 0 - @State private var mapStyle: MapStyleSelection = .standard - @State private var isNorthLocked = false - @State private var showLabels = true - @State private var showingLayersMenu = false - @State private var isStyleLoaded = false - @State private var hasInitiallyFit = false - @State private var locatedNodes: [(point: MapPoint, coordinate: CLLocationCoordinate2D)] = [] - - private var mapPoints: [MapPoint] { locatedNodes.map(\.point) } - - private var mapLines: [MapLine] { - let coords = locatedNodes.map(\.coordinate) - guard coords.count >= 2 else { return [] } - return [MapLine(id: "message-path", coordinates: coords, style: .messagePath, opacity: 1.0)] - } - - var body: some View { - NavigationStack { - Group { - if pathViewModel.isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if locatedNodes.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.Path.Unavailable.title, - systemImage: "map", - description: Text(L10n.Chats.Chats.Path.Unavailable.description) - ) - } else { - ZStack(alignment: .bottomTrailing) { - MC1MapView( - points: mapPoints, - lines: mapLines, - mapStyle: mapStyle, - isDarkMode: colorScheme == .dark, - showLabels: showLabels, - showsUserLocation: false, - isInteractive: true, - showsScale: true, - isNorthLocked: isNorthLocked, - cameraRegion: $cameraRegion, - cameraRegionVersion: cameraRegionVersion, - onPointTap: nil, - onMapTap: nil, - onCameraRegionChange: { cameraRegion = $0 }, - isStyleLoaded: $isStyleLoaded - ) - .ignoresSafeArea() - - VStack { - Spacer() - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: centerOnUserLocation, - isNorthLocked: $isNorthLocked, - showLabels: $showLabels, - showingLayersMenu: $showingLayersMenu - ) { - if !locatedNodes.isEmpty { - Button(L10n.Chats.Chats.Path.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { - fitCameraToPath() - } - .mapControlButton(tint: .primary) - } - } - } - } - .overlay(alignment: .bottomTrailing) { - if showingLayersMenu { - LayersMenu( - selection: $mapStyle, - isPresented: $showingLayersMenu, - viewportBounds: cameraRegion?.toMLNCoordinateBounds() - ) - .padding(.trailing, 16) - .padding(.bottom, 160) - .transition(.scale.combined(with: .opacity)) - } - } - .animation(.spring(response: 0.3), value: showingLayersMenu) + /// Span used when the path resolves to a single node, with no bounding box to fit. + private static let singleNodeSpanDelta: CLLocationDegrees = 0.05 + /// Padding around the multi-node bounding region, wider than `boundingRegion`'s + /// 1.5 default to keep the path clear of the floating map-controls toolbar. + private static let pathBoundingPaddingMultiplier: Double = 2.5 + + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + let message: MessageDTO + let pathViewModel: MessagePathViewModel + + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 + @State private var mapStyle: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapNorthLocked.rawValue) private var isNorthLocked = AppStorageKey.defaultMapNorthLocked + @State private var showLabels = true + @State private var isStyleLoaded = false + @State private var isCenteredOnUser = false + @State private var hasInitiallyFit = false + @State private var locatedNodes: [(point: MapPoint, coordinate: CLLocationCoordinate2D)] = [] + + private var mapPoints: [MapPoint] { + locatedNodes.map(\.point) + } + + private var mapLines: [MapLine] { + let coords = locatedNodes.map(\.coordinate) + guard coords.count >= 2 else { return [] } + return [MapLine(id: "message-path", coordinates: coords, style: .messagePath, opacity: 1.0)] + } + + /// Length of the drawn path, over only the nodes we could place. Nil until at + /// least two nodes resolve to coordinates, so the pill's distance always + /// matches the polyline in `mapLines`. + private var totalPathDistance: CLLocationDistance? { + locatedNodes.map(\.coordinate).totalDistance() + } + + /// Total intermediate-hop count from the message's path, including hops too + /// ambiguous to plot. A `nil` `pathNodes` yields 0, which is indistinguishable + /// between a genuine direct message and firmware that omits path data. + private var hopCount: Int { + message.pathHops.count + } + + var body: some View { + NavigationStack { + Group { + if pathViewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if locatedNodes.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.Path.Unavailable.title, + systemImage: "map", + description: Text(L10n.Chats.Chats.Path.Unavailable.description) + ) + } else { + ZStack(alignment: .bottomTrailing) { + MC1MapView( + points: mapPoints, + lines: mapLines, + mapStyle: mapStyle, + isDarkMode: colorScheme == .dark, + showLabels: showLabels, + showsUserLocation: false, + isInteractive: true, + showsScale: true, + isNorthLocked: isNorthLocked, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { cameraRegion = $0 }, + isStyleLoaded: $isStyleLoaded, + isCenteredOnUser: $isCenteredOnUser + ) + .ignoresSafeArea() + + VStack { + Spacer() + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: centerOnUserLocation, + isCenteredOnUser: isCenteredOnUser, + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + mapStyleSelection: $mapStyle, + viewportBounds: cameraRegion?.toMLNCoordinateBounds() + ) { + if !locatedNodes.isEmpty { + Button(L10n.Chats.Chats.Path.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { + isCenteredOnUser = false + fitCameraToPath() } + .mapControlButton(tint: .primary) + } } + } } - .navigationTitle(L10n.Chats.Chats.Path.map) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Localizable.Common.done) { dismiss() } - } - } - .onAppear { - locatedNodes = buildLocatedNodes() - } - .onChange(of: isStyleLoaded) { _, loaded in - guard loaded, !hasInitiallyFit else { return } - hasInitiallyFit = true - fitCameraToPath() - } + } } - } - - private func fitCameraToPath() { - let coords = locatedNodes.map(\.coordinate) - if coords.count == 1 { - cameraRegion = MKCoordinateRegion( - center: coords[0], - span: MKCoordinateSpan( - latitudeDelta: Self.singleNodeSpanDelta, - longitudeDelta: Self.singleNodeSpanDelta - ) + } + .toolbar { + if !locatedNodes.isEmpty { + ToolbarItem(placement: .principal) { + PathDistanceBanner( + hopCount: hopCount, + totalPathDistance: totalPathDistance ) - } else if let region = coords.boundingRegion(paddingMultiplier: Self.pathBoundingPaddingMultiplier) { - cameraRegion = region + } } - cameraRegionVersion += 1 - } - - private func centerOnUserLocation() { - guard let location = appState.bestAvailableLocation else { - appState.locationService.requestLocation() - return + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.Common.done) { dismiss() } } - cameraRegion = MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan( - latitudeDelta: Self.singleNodeSpanDelta, - longitudeDelta: Self.singleNodeSpanDelta - ) + } + .onAppear { + locatedNodes = buildLocatedNodes() + } + .onChange(of: isStyleLoaded) { _, loaded in + guard loaded, !hasInitiallyFit else { return } + hasInitiallyFit = true + fitCameraToPath() + } + } + } + + private func fitCameraToPath() { + let coords = locatedNodes.map(\.coordinate) + if coords.count == 1 { + cameraRegion = MKCoordinateRegion( + center: coords[0], + span: MKCoordinateSpan( + latitudeDelta: Self.singleNodeSpanDelta, + longitudeDelta: Self.singleNodeSpanDelta ) - cameraRegionVersion += 1 + ) + } else if let region = coords.boundingRegion(paddingMultiplier: Self.pathBoundingPaddingMultiplier) { + cameraRegion = region } + cameraRegionVersion += 1 + } - private func buildLocatedNodes() -> [(point: MapPoint, coordinate: CLLocationCoordinate2D)] { - var nodes: [(MapPoint, CLLocationCoordinate2D)] = [] - - // Sender - if let keyPrefix = message.senderKeyPrefix, - let sender = pathViewModel.contacts.first(where: { $0.publicKeyPrefix == keyPrefix }), - sender.hasLocation { - let coord = CLLocationCoordinate2D(latitude: sender.latitude, longitude: sender.longitude) - nodes.append((MapPoint( - id: sender.id, - coordinate: coord, - pinStyle: .pointA, - label: sender.displayName, - isClusterable: false, - hopIndex: nil, - badgeText: nil - ), coord)) - } - - // Repeater hops. A 1-byte path hash can't tell apart repeaters sharing a - // first key byte, so only plot hops that resolve to a single, unambiguous - // repeater (exact match). Ambiguous hops — where several known repeaters - // share the hash — are skipped rather than guessed at by proximity, which - // is what produced the criss-crossing pile-ups. - if let pathNodes = message.pathNodes { - let size = message.pathHashSize - let hops = stride(from: 0, to: pathNodes.count, by: size).map { start -> Data in - Data(pathNodes[start..() - for (index, hashBytes) in hops.enumerated() { - let hopNumber = index + 1 - let resolvedContact = RepeaterResolver.resolve(for: hashBytes, in: pathViewModel.repeaters, userLocation: appState.bestAvailableLocation) - let resolvedNode = RepeaterResolver.resolve(for: hashBytes, in: pathViewModel.discoveredRepeaters, userLocation: appState.bestAvailableLocation) - let resolved: (node: any RepeaterResolvable, matchKind: NodeNameMatchKind)? = - resolvedContact.map { ($0.node, $0.matchKind) } ?? resolvedNode.map { ($0.node, $0.matchKind) } - guard let resolved, resolved.matchKind == .exact else { continue } - let r = resolved.node - if r.hasLocation, seenKeys.insert(r.publicKey).inserted { - let coord = CLLocationCoordinate2D(latitude: r.latitude, longitude: r.longitude) - nodes.append((MapPoint( - id: UUID(), - coordinate: coord, - pinStyle: .repeaterHop, - label: r.resolvableName, - isClusterable: false, - hopIndex: hopNumber, - badgeText: nil - ), coord)) - } - } - } + private func centerOnUserLocation() { + guard let location = appState.bestAvailableLocation else { + appState.locationService.requestLocation() + return + } + isCenteredOnUser = true + cameraRegion = MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan( + latitudeDelta: Self.singleNodeSpanDelta, + longitudeDelta: Self.singleNodeSpanDelta + ) + ) + cameraRegionVersion += 1 + } + + private func buildLocatedNodes() -> [(point: MapPoint, coordinate: CLLocationCoordinate2D)] { + var nodes: [(MapPoint, CLLocationCoordinate2D)] = [] + + // Sender + if let keyPrefix = message.senderKeyPrefix, + let sender = pathViewModel.contacts.first(where: { $0.publicKeyPrefix == keyPrefix }), + sender.hasLocation { + let coord = CLLocationCoordinate2D(latitude: sender.latitude, longitude: sender.longitude) + nodes.append((MapPoint( + id: sender.id, + coordinate: coord, + pinStyle: .pointA, + label: sender.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + ), coord)) + } - // Receiver (this device) - let receiverLocation: CLLocation? - if let device = appState.connectedDevice, device.hasLocation { - receiverLocation = CLLocation(latitude: device.latitude, longitude: device.longitude) - } else { - receiverLocation = appState.bestAvailableLocation + // Repeater hops. A 1-byte path hash can't tell apart repeaters sharing a + // first key byte, so only plot hops that resolve to a single, unambiguous + // repeater (exact match). Ambiguous hops — where several known repeaters + // share the hash — are skipped rather than guessed at by proximity, which + // is what produced the criss-crossing pile-ups. + if let pathNodes = message.pathNodes { + let size = message.pathHashSize + let hops = stride(from: 0, to: pathNodes.count, by: size).map { start -> Data in + Data(pathNodes[start..() + for (index, hashBytes) in hops.enumerated() { + let hopNumber = index + 1 + let resolvedContact = RepeaterResolver.resolve(for: hashBytes, in: pathViewModel.repeaters, userLocation: appState.bestAvailableLocation) + let resolvedNode = RepeaterResolver.resolve(for: hashBytes, in: pathViewModel.discoveredRepeaters, userLocation: appState.bestAvailableLocation) + let resolved: (node: any RepeaterResolvable, matchKind: NodeNameMatchKind)? = + resolvedContact.map { ($0.node, $0.matchKind) } ?? resolvedNode.map { ($0.node, $0.matchKind) } + guard let resolved, resolved.matchKind == .exact else { continue } + let r = resolved.node + if r.hasLocation, seenKeys.insert(r.publicKey).inserted { + let coord = CLLocationCoordinate2D(latitude: r.latitude, longitude: r.longitude) + nodes.append((MapPoint( + id: UUID(), + coordinate: coord, + pinStyle: .repeaterHop, + label: r.resolvableName, + isClusterable: false, + hopIndex: hopNumber, + badgeText: nil + ), coord)) } + } + } - if let loc = receiverLocation { - let coord = loc.coordinate - nodes.append((MapPoint( - id: UUID(), - coordinate: coord, - pinStyle: .pointB, - label: appState.connectedDevice?.nodeName, - isClusterable: false, - hopIndex: nil, - badgeText: nil - ), coord)) - } + // Receiver (this device) + let receiverLocation: CLLocation? = if let device = appState.connectedDevice, device.hasLocation { + CLLocation(latitude: device.latitude, longitude: device.longitude) + } else { + appState.bestAvailableLocation + } - return nodes + if let loc = receiverLocation { + let coord = loc.coordinate + nodes.append((MapPoint( + id: UUID(), + coordinate: coord, + pinStyle: .pointB, + label: appState.connectedDevice?.nodeName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + ), coord)) } + + return nodes + } } diff --git a/MC1/Views/Chats/Components/MessagePathViewModel.swift b/MC1/Views/Chats/Components/MessagePathViewModel.swift index a3cae8dd..27fca6fb 100644 --- a/MC1/Views/Chats/Components/MessagePathViewModel.swift +++ b/MC1/Views/Chats/Components/MessagePathViewModel.swift @@ -1,85 +1,85 @@ import CoreLocation -import OSLog import MC1Services +import OSLog import SwiftUI @Observable @MainActor final class MessagePathViewModel { - var contacts: [ContactDTO] = [] - var repeaters: [ContactDTO] = [] - var discoveredRepeaters: [DiscoveredNodeDTO] = [] - var isLoading = true - - private let logger = Logger(subsystem: "com.mc1", category: "MessagePathViewModel") + var contacts: [ContactDTO] = [] + var repeaters: [ContactDTO] = [] + var discoveredRepeaters: [DiscoveredNodeDTO] = [] + var isLoading = true - func loadContacts(services: ServiceContainer?, radioID: UUID) async { - isLoading = true - guard let services else { - isLoading = false - return - } + private let logger = Logger(subsystem: "com.mc1", category: "MessagePathViewModel") - do { - let fetched = try await services.dataStore.fetchContacts(radioID: radioID) - contacts = fetched - repeaters = fetched.filter { $0.type == .repeater } - let nodes = try await services.dataStore.fetchDiscoveredNodes(radioID: radioID) - discoveredRepeaters = nodes.filter { $0.nodeType == .repeater } - } catch { - logger.error("Failed to load contacts: \(error.localizedDescription)") - contacts = [] - repeaters = [] - discoveredRepeaters = [] - } - - isLoading = false + func loadContacts(services: ServiceContainer?, radioID: UUID) async { + isLoading = true + guard let services else { + isLoading = false + return } - func senderResolution(for message: MessageDTO) -> NodeNameResolution { - if message.isChannelMessage, let nodeName = message.senderNodeName { - return NodeNameResolution(displayName: nodeName, matchKind: .exact) - } + do { + let fetched = try await services.dataStore.fetchContacts(radioID: radioID) + contacts = fetched + repeaters = fetched.filter { $0.type == .repeater } + let nodes = try await services.dataStore.fetchDiscoveredNodes(radioID: radioID) + discoveredRepeaters = nodes.filter { $0.nodeType == .repeater } + } catch { + logger.error("Failed to load contacts: \(error.localizedDescription)") + contacts = [] + repeaters = [] + discoveredRepeaters = [] + } - if let keyPrefix = message.senderKeyPrefix, - let result = NeighborNameResolver.resolve( - for: keyPrefix, - contacts: contacts, - discoveredNodes: [], - userLocation: nil - ) { - return result - } + isLoading = false + } - return NodeNameResolution( - displayName: L10n.Chats.Chats.Path.Hop.unknown, - matchKind: .unresolved - ) + func senderResolution(for message: MessageDTO) -> NodeNameResolution { + if message.isChannelMessage, let nodeName = message.senderNodeName { + return NodeNameResolution(displayName: nodeName, matchKind: .exact) } - func senderName(for message: MessageDTO) -> String { - senderResolution(for: message).displayName + if let keyPrefix = message.senderKeyPrefix, + let result = NeighborNameResolver.resolve( + for: keyPrefix, + contacts: contacts, + discoveredNodes: [], + userLocation: nil + ) { + return result } - func senderNodeID(for message: MessageDTO) -> String? { - guard let keyPrefix = message.senderKeyPrefix, - let firstByte = keyPrefix.first else { return nil } - return String(format: "%02X", firstByte) - } + return NodeNameResolution( + displayName: L10n.Chats.Chats.Path.Hop.unknown, + matchKind: .unresolved + ) + } - func repeaterResolution(for hashBytes: Data, userLocation: CLLocation?) -> NodeNameResolution { - NeighborNameResolver.resolve( - for: hashBytes, - contacts: repeaters, - discoveredNodes: discoveredRepeaters, - userLocation: userLocation - ) ?? NodeNameResolution( - displayName: L10n.Chats.Chats.Path.Hop.unknown, - matchKind: .unresolved - ) - } + func senderName(for message: MessageDTO) -> String { + senderResolution(for: message).displayName + } - func repeaterName(for hashBytes: Data, userLocation: CLLocation?) -> String { - repeaterResolution(for: hashBytes, userLocation: userLocation).displayName - } + func senderNodeID(for message: MessageDTO) -> String? { + guard let keyPrefix = message.senderKeyPrefix, + let firstByte = keyPrefix.first else { return nil } + return String(format: "%02X", firstByte) + } + + func repeaterResolution(for hashBytes: Data, userLocation: CLLocation?) -> NodeNameResolution { + NeighborNameResolver.resolve( + for: hashBytes, + contacts: repeaters, + discoveredNodes: discoveredRepeaters, + userLocation: userLocation + ) ?? NodeNameResolution( + displayName: L10n.Chats.Chats.Path.Hop.unknown, + matchKind: .unresolved + ) + } + + func repeaterName(for hashBytes: Data, userLocation: CLLocation?) -> String { + repeaterResolution(for: hashBytes, userLocation: userLocation).displayName + } } diff --git a/MC1/Views/Chats/Components/MessageText.swift b/MC1/Views/Chats/Components/MessageText.swift index 731a4655..af3d0468 100644 --- a/MC1/Views/Chats/Components/MessageText.swift +++ b/MC1/Views/Chats/Components/MessageText.swift @@ -1,165 +1,165 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// A Text view that formats message content with tappable links and styled mentions struct MessageText: View { - let text: String - let baseColor: Color - let isOutgoing: Bool - let currentUserName: String? - let precomputedText: AttributedString? - - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.colorScheme) private var colorScheme - @Environment(\.appTheme) private var theme - - init( - _ text: String, - baseColor: Color = .primary, - isOutgoing: Bool = false, - currentUserName: String? = nil, - precomputedText: AttributedString? = nil - ) { - self.text = text - self.baseColor = baseColor - self.isOutgoing = isOutgoing - self.currentUserName = currentUserName - self.precomputedText = precomputedText - } - - var body: some View { - Text(precomputedText ?? formattedText) - } - - /// Exposes formatted text for testing - var testableFormattedText: AttributedString { - formattedText - } - - private var formattedText: AttributedString { - MessageText.buildFormattedText( - text: text, - isOutgoing: isOutgoing, - currentUserName: currentUserName, - isHighContrast: colorSchemeContrast == .increased, - outgoingTextColor: theme.outgoingTextColor, - hashtagColor: theme.hashtagColor, - identityGamut: theme.identityGamut, - identityBackgroundLuminances: theme.avatarSurfaceLuminances( - colorScheme: colorScheme, - contrast: colorSchemeContrast - ) - ).text - } - - /// Builds an AttributedString with mention, contact-share, URL, meshcore-link, hashtag, - /// and coordinate formatting in three stages: a string-shrinking pre-pass - /// (`MessageTextNormalizer`), single-pass detection into a sorted non-overlapping token - /// stream (`MessageLinkTokenizer`), then one styling step (`MessageLinkStyler`). Static so - /// it can be called from both the view and the ViewModel cache; the `(text:mapCoordinate:)` - /// return shape is the contract `makeBuildInputs` depends on. - static func buildFormattedText( - text: String, - isOutgoing: Bool, - currentUserName: String?, - isHighContrast: Bool, - outgoingTextColor: Color, - hashtagColor: Color, - identityGamut: IdentityGamut, - identityBackgroundLuminances: [Double] - ) -> (text: AttributedString, mapCoordinate: CLLocationCoordinate2D?) { - let baseColor: Color = isOutgoing ? outgoingTextColor : .primary - - let normalized = MessageTextNormalizer.normalize( - text, - context: MessageTextNormalizer.StyleContext( - baseColor: baseColor, - isOutgoing: isOutgoing, - currentUserName: currentUserName, - isHighContrast: isHighContrast, - outgoingTextColor: outgoingTextColor, - identityGamut: identityGamut, - identityBackgroundLuminances: identityBackgroundLuminances - ) - ) - - let tokenized = MessageLinkTokenizer.tokenize( - normalized: normalized.string, - preSpans: normalized.spans, - context: MessageLinkTokenizer.StyleContext( - baseColor: baseColor, - isOutgoing: isOutgoing, - outgoingTextColor: outgoingTextColor, - hashtagColor: hashtagColor - ) - ) - - let styled = MessageLinkStyler.style( - normalized: normalized.string, - tokens: tokenized.tokens, - baseColor: baseColor - ) - - return (styled, tokenized.mapCoordinate) - } - - /// Strips invisible and control Unicode scalars from an inbound contact name. Shared - /// entry point for the mention-tap path, which sanitizes the same way the linkifier does. - static func displayName(for name: String) -> String { - MessageTextNormalizer.displayName(for: name) - } + let text: String + let baseColor: Color + let isOutgoing: Bool + let currentUserName: String? + let precomputedText: AttributedString? + + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.colorScheme) private var colorScheme + @Environment(\.appTheme) private var theme + + init( + _ text: String, + baseColor: Color = .primary, + isOutgoing: Bool = false, + currentUserName: String? = nil, + precomputedText: AttributedString? = nil + ) { + self.text = text + self.baseColor = baseColor + self.isOutgoing = isOutgoing + self.currentUserName = currentUserName + self.precomputedText = precomputedText + } + + var body: some View { + Text(precomputedText ?? formattedText) + } + + /// Exposes formatted text for testing + var testableFormattedText: AttributedString { + formattedText + } + + private var formattedText: AttributedString { + MessageText.buildFormattedText( + text: text, + isOutgoing: isOutgoing, + currentUserName: currentUserName, + isHighContrast: colorSchemeContrast == .increased, + outgoingTextColor: theme.outgoingTextColor, + hashtagColor: theme.hashtagColor, + identityGamut: theme.identityGamut, + identityBackgroundLuminances: theme.avatarSurfaceLuminances( + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + ).text + } + + /// Builds an AttributedString with mention, contact-share, URL, meshcore-link, hashtag, + /// and coordinate formatting in three stages: a string-shrinking pre-pass + /// (`MessageTextNormalizer`), single-pass detection into a sorted non-overlapping token + /// stream (`MessageLinkTokenizer`), then one styling step (`MessageLinkStyler`). Static so + /// it can be called from both the view and the ViewModel cache; the `(text:mapCoordinate:)` + /// return shape is the contract `makeBuildInputs` depends on. + static func buildFormattedText( + text: String, + isOutgoing: Bool, + currentUserName: String?, + isHighContrast: Bool, + outgoingTextColor: Color, + hashtagColor: Color, + identityGamut: IdentityGamut, + identityBackgroundLuminances: [Double] + ) -> (text: AttributedString, mapCoordinate: CLLocationCoordinate2D?) { + let baseColor: Color = isOutgoing ? outgoingTextColor : .primary + + let normalized = MessageTextNormalizer.normalize( + text, + context: MessageTextNormalizer.StyleContext( + baseColor: baseColor, + isOutgoing: isOutgoing, + currentUserName: currentUserName, + isHighContrast: isHighContrast, + outgoingTextColor: outgoingTextColor, + identityGamut: identityGamut, + identityBackgroundLuminances: identityBackgroundLuminances + ) + ) + + let tokenized = MessageLinkTokenizer.tokenize( + normalized: normalized.string, + preSpans: normalized.spans, + context: MessageLinkTokenizer.StyleContext( + baseColor: baseColor, + isOutgoing: isOutgoing, + outgoingTextColor: outgoingTextColor, + hashtagColor: hashtagColor + ) + ) + + let styled = MessageLinkStyler.style( + normalized: normalized.string, + tokens: tokenized.tokens, + baseColor: baseColor + ) + + return (styled, tokenized.mapCoordinate) + } + + /// Strips invisible and control Unicode scalars from an inbound contact name. Shared + /// entry point for the mention-tap path, which sanitizes the same way the linkifier does. + static func displayName(for name: String) -> String { + MessageTextNormalizer.displayName(for: name) + } } #Preview("Plain text") { - MessageText("Hello, world!") - .padding() + MessageText("Hello, world!") + .padding() } #Preview("With mention") { - MessageText("Hey @[Alice], check this out!") - .padding() + MessageText("Hey @[Alice], check this out!") + .padding() } #Preview("With self-mention") { - MessageText("Hey @[Me], you were mentioned!", currentUserName: "Me") - .padding() + MessageText("Hey @[Me], you were mentioned!", currentUserName: "Me") + .padding() } #Preview("With link") { - MessageText("Check out https://apple.com for more info") - .padding() + MessageText("Check out https://apple.com for more info") + .padding() } #Preview("With mention and link") { - MessageText("@[Bob] look at https://example.com/article") - .padding() + MessageText("@[Bob] look at https://example.com/article") + .padding() } #Preview("Outgoing message") { - MessageText("Visit https://github.com", baseColor: .white, isOutgoing: true) - .padding() - .background(.blue) + MessageText("Visit https://github.com", baseColor: .white, isOutgoing: true) + .padding() + .background(.blue) } #Preview("Outgoing with mention") { - MessageText("Hey @[Alice], check this out!", baseColor: .white, isOutgoing: true) - .padding() - .background(.blue) + MessageText("Hey @[Alice], check this out!", baseColor: .white, isOutgoing: true) + .padding() + .background(.blue) } #Preview("Outgoing with self-mention") { - MessageText("@[MyDevice] check this!", baseColor: .white, isOutgoing: true, currentUserName: "MyDevice") - .padding() - .background(.blue) + MessageText("@[MyDevice] check this!", baseColor: .white, isOutgoing: true, currentUserName: "MyDevice") + .padding() + .background(.blue) } #Preview("With hashtag") { - MessageText("Join #general for updates") - .padding() + MessageText("Join #general for updates") + .padding() } #Preview("With hashtag and URL") { - MessageText("Check https://example.com#anchor and #general") - .padding() + MessageText("Check https://example.com#anchor and #general") + .padding() } diff --git a/MC1/Views/Chats/Components/MessageTimestampView.swift b/MC1/Views/Chats/Components/MessageTimestampView.swift index 9419f15c..0783a8e9 100644 --- a/MC1/Views/Chats/Components/MessageTimestampView.swift +++ b/MC1/Views/Chats/Components/MessageTimestampView.swift @@ -3,20 +3,20 @@ import SwiftUI /// Centered, time-only marker shown between message clusters when a time gap /// breaks grouping. The day is carried separately by `MessageDayDividerView`. struct MessageTimestampView: View { - let date: Date + let date: Date - var body: some View { - Text(date.formatted(date: .omitted, time: .shortened)) - .font(.caption2) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - } + var body: some View { + Text(date.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } } #Preview { - VStack(spacing: 20) { - MessageTimestampView(date: Date()) - MessageTimestampView(date: Date().addingTimeInterval(-3600)) // 1 hour ago - } - .padding() + VStack(spacing: 20) { + MessageTimestampView(date: Date()) + MessageTimestampView(date: Date().addingTimeInterval(-3600)) // 1 hour ago + } + .padding() } diff --git a/MC1/Views/Chats/Components/NewMessagesDividerView.swift b/MC1/Views/Chats/Components/NewMessagesDividerView.swift index 0522bab4..3c5efbf1 100644 --- a/MC1/Views/Chats/Components/NewMessagesDividerView.swift +++ b/MC1/Views/Chats/Components/NewMessagesDividerView.swift @@ -3,24 +3,24 @@ import SwiftUI /// Horizontal divider line with centered "New Messages" label. /// Rendered above the first unread message in a conversation. struct NewMessagesDividerView: View { - var body: some View { - HStack { - VStack { Divider() } - Text(L10n.Chats.Chats.Divider.newMessages) - .font(.caption2) - .bold() - .foregroundStyle(.blue) - VStack { Divider() } - } - .accessibilityLabel(L10n.Chats.Chats.Divider.newMessagesAccessibility) + var body: some View { + HStack { + VStack { Divider() } + Text(L10n.Chats.Chats.Divider.newMessages) + .font(.caption2) + .bold() + .foregroundStyle(.blue) + VStack { Divider() } } + .accessibilityLabel(L10n.Chats.Chats.Divider.newMessagesAccessibility) + } } #Preview { - VStack(spacing: 20) { - Text("Previous message") - NewMessagesDividerView() - Text("First unread message") - } - .padding() + VStack(spacing: 20) { + Text("Previous message") + NewMessagesDividerView() + Text("First unread message") + } + .padding() } diff --git a/MC1/Views/Chats/Components/PathHopRowView.swift b/MC1/Views/Chats/Components/PathHopRowView.swift index b59720fa..7e0142b0 100644 --- a/MC1/Views/Chats/Components/PathHopRowView.swift +++ b/MC1/Views/Chats/Components/PathHopRowView.swift @@ -1,112 +1,116 @@ // MC1/Views/Chats/Components/PathHopRowView.swift -import SwiftUI import MC1Services +import SwiftUI /// Type of hop in the message path. enum PathHopType { - case sender - case intermediate(Int) - case receiver + case sender + case intermediate(Int) + case receiver } /// Row displaying a single hop in the message path. struct PathHopRowView: View { - let hopType: PathHopType - let nodeName: String - let nodeID: String? - let snr: Double? - let matchKind: NodeNameMatchKind + let hopType: PathHopType + let nodeName: String + let nodeID: String? + let snr: Double? + let matchKind: NodeNameMatchKind - init( - hopType: PathHopType, - nodeName: String, - nodeID: String?, - snr: Double?, - matchKind: NodeNameMatchKind = .exact - ) { - self.hopType = hopType - self.nodeName = nodeName - self.nodeID = nodeID - self.snr = snr - self.matchKind = matchKind - } + init( + hopType: PathHopType, + nodeName: String, + nodeID: String?, + snr: Double?, + matchKind: NodeNameMatchKind = .exact + ) { + self.hopType = hopType + self.nodeName = nodeName + self.nodeID = nodeID + self.snr = snr + self.matchKind = matchKind + } - var body: some View { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - if let nodeID { - Text(nodeID) - .font(.body) - .foregroundStyle(.secondary) - .monospaced() - } + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if let nodeID { + Text(nodeID) + .font(.body) + .foregroundStyle(.secondary) + .monospaced() + } - Text(nodeName) - .font(.body) + Text(nodeName) + .font(.body) - if matchKind == .fallback { - FallbackMatchIndicatorView() - } - } + if matchKind == .fallback { + FallbackMatchIndicatorView() + } + } - Text(hopLabel) - .font(.caption) - .foregroundStyle(.secondary) - } + Text(hopLabel) + .font(.caption) + .foregroundStyle(.secondary) + } - Spacer() + Spacer() - // Show signal info only on receiver (where we have SNR) - if case .receiver = hopType, let snr { - VStack(alignment: .trailing, spacing: 2) { - Image(systemName: "cellularbars", variableValue: snrQuality.barLevel) - .foregroundStyle(snrQuality.color) + // Show signal info only on receiver (where we have SNR) + if case .receiver = hopType, let snr { + VStack(alignment: .trailing, spacing: 2) { + Image(systemName: "cellularbars", variableValue: snrQuality.barLevel) + .foregroundStyle(snrQuality.color) - Text("SNR \(snr, format: .number.precision(.fractionLength(1))) dB") - .font(.caption) - .foregroundStyle(.secondary) - } - } + Text("SNR \(snr, format: .number.precision(.fractionLength(1))) dB") + .font(.caption) + .foregroundStyle(.secondary) } - .padding(.vertical, 1) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(hopLabel): \(nodeName)") - .accessibilityValue(accessibilityValueText) + } } + .padding(.vertical, 1) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(hopLabel): \(nodeName)") + .accessibilityValue(accessibilityValueText) + } - private var hopLabel: String { - switch hopType { - case .sender: - return L10n.Chats.Chats.Path.Hop.sender - case .intermediate(let index): - return L10n.Chats.Chats.Path.Hop.number(index) - case .receiver: - return L10n.Chats.Chats.Path.Receiver.label - } + private var hopLabel: String { + switch hopType { + case .sender: + L10n.Chats.Chats.Path.Hop.sender + case let .intermediate(index): + L10n.Chats.Chats.Path.Hop.number(index) + case .receiver: + L10n.Chats.Chats.Path.Receiver.label } + } - private var accessibilityValueText: String { - var values: [String] = [] - if case .receiver = hopType, let snr { - let snrText = snr.formatted(.number.precision(.fractionLength(1))) - values.append(L10n.Chats.Chats.Path.Hop.signalQuality(signalQualityText, snrText)) - } - if let nodeID { - values.append(L10n.Chats.Chats.Path.Hop.nodeId(nodeID)) - } - return values.joined(separator: ", ") + private var accessibilityValueText: String { + var values: [String] = [] + if case .receiver = hopType, let snr { + let snrText = snr.formatted(.number.precision(.fractionLength(1))) + values.append(L10n.Chats.Chats.Path.Hop.signalQuality(signalQualityText, snrText)) } + if let nodeID { + values.append(L10n.Chats.Chats.Path.Hop.nodeId(nodeID)) + } + return values.joined(separator: ", ") + } - private var snrQuality: SNRQuality { SNRQuality(snr: snr) } + private var snrQuality: SNRQuality { + SNRQuality(snr: snr) + } - private var signalQualityText: String { snrQuality.localizedLabel } + private var signalQualityText: String { + snrQuality.localizedLabel + } } #Preview { - List { - PathHopRowView(hopType: .sender, nodeName: "AlphaNode", nodeID: "A3", snr: nil) - PathHopRowView(hopType: .intermediate(1), nodeName: "RelayNode", nodeID: "7F", snr: nil) - PathHopRowView(hopType: .receiver, nodeName: "MyDevice", nodeID: nil, snr: 6.2) - } + List { + PathHopRowView(hopType: .sender, nodeName: "AlphaNode", nodeID: "A3", snr: nil) + PathHopRowView(hopType: .intermediate(1), nodeName: "RelayNode", nodeID: "7F", snr: nil) + PathHopRowView(hopType: .receiver, nodeName: "MyDevice", nodeID: nil, snr: 6.2) + } } diff --git a/MC1/Views/Chats/Components/PreviewSkeleton.swift b/MC1/Views/Chats/Components/PreviewSkeleton.swift index 5a64e4bc..9487bd9c 100644 --- a/MC1/Views/Chats/Components/PreviewSkeleton.swift +++ b/MC1/Views/Chats/Components/PreviewSkeleton.swift @@ -8,14 +8,14 @@ import SwiftUI /// `Shimmer(isActive:)` wiring, and is accessibility-hidden because it carries /// no information of its own. struct PreviewSkeleton: View { - var cornerRadius: CGFloat = RichPreviewMetrics.cornerRadius + var cornerRadius: CGFloat = RichPreviewMetrics.cornerRadius - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceMotion) private var reduceMotion - var body: some View { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Color(.tertiarySystemFill)) - .modifier(Shimmer(isActive: !reduceMotion)) - .accessibilityHidden(true) - } + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color(.tertiarySystemFill)) + .modifier(Shimmer(isActive: !reduceMotion)) + .accessibilityHidden(true) + } } diff --git a/MC1/Views/Chats/Components/RepeatDetailsContent.swift b/MC1/Views/Chats/Components/RepeatDetailsContent.swift index 7ae7a6c1..cd59ef01 100644 --- a/MC1/Views/Chats/Components/RepeatDetailsContent.swift +++ b/MC1/Views/Chats/Components/RepeatDetailsContent.swift @@ -6,41 +6,41 @@ import SwiftUI /// Inline content for repeat details, extracted from RepeatDetailsSheet. /// Shows repeat rows, a loading spinner, or an empty state. struct RepeatDetailsContent: View { - let repeats: [MessageRepeatDTO]? - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let userLocation: CLLocation? + let repeats: [MessageRepeatDTO]? + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? - private var repeaters: [ContactDTO] { - contacts.filter { $0.type == .repeater } - } + private var repeaters: [ContactDTO] { + contacts.filter { $0.type == .repeater } + } - private var discoveredRepeaters: [DiscoveredNodeDTO] { - discoveredNodes.filter { $0.nodeType == .repeater } - } + private var discoveredRepeaters: [DiscoveredNodeDTO] { + discoveredNodes.filter { $0.nodeType == .repeater } + } - var body: some View { - if let repeats { - if repeats.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.Repeats.EmptyState.title, - systemImage: "arrow.triangle.branch", - description: Text(L10n.Chats.Chats.Repeats.EmptyState.description) - ) - } else { - ForEach(repeats) { repeatEntry in - RepeatRowView( - repeatEntry: repeatEntry, - repeaters: repeaters, - discoveredRepeaters: discoveredRepeaters, - userLocation: userLocation - ) - } - } - } else { - ProgressView() - .frame(maxWidth: .infinity, alignment: .center) - .padding() + var body: some View { + if let repeats { + if repeats.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.Repeats.EmptyState.title, + systemImage: "arrow.triangle.branch", + description: Text(L10n.Chats.Chats.Repeats.EmptyState.description) + ) + } else { + ForEach(repeats) { repeatEntry in + RepeatRowView( + repeatEntry: repeatEntry, + repeaters: repeaters, + discoveredRepeaters: discoveredRepeaters, + userLocation: userLocation + ) } + } + } else { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding() } + } } diff --git a/MC1/Views/Chats/Components/RepeatRowView.swift b/MC1/Views/Chats/Components/RepeatRowView.swift index 2c789d1d..38ee22de 100644 --- a/MC1/Views/Chats/Components/RepeatRowView.swift +++ b/MC1/Views/Chats/Components/RepeatRowView.swift @@ -5,112 +5,118 @@ import SwiftUI /// Row displaying a single heard repeat with repeater info and signal quality. struct RepeatRowView: View { - let repeatEntry: MessageRepeatDTO - let repeaters: [ContactDTO] - let discoveredRepeaters: [DiscoveredNodeDTO] - let userLocation: CLLocation? - - var body: some View { - HStack(alignment: .top) { - // Left side: Repeater ID + name, hop count - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(repeatEntry.repeaterHashFormatted) - .font(.body) - .foregroundStyle(.secondary) - .monospaced() - Text(repeaterName) - .font(.body) - } - - Text(hopCountText) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - // Right side: Signal bars and metrics - VStack(alignment: .trailing, spacing: 2) { - Image(systemName: "cellularbars", variableValue: repeatEntry.snrLevel) - .foregroundStyle(signalColor) - - Text("SNR \(repeatEntry.snrFormatted)") - .font(.caption) - .foregroundStyle(.secondary) - - Text("RSSI \(repeatEntry.rssiFormatted)") - .font(.caption) - .foregroundStyle(.secondary) - } + let repeatEntry: MessageRepeatDTO + let repeaters: [ContactDTO] + let discoveredRepeaters: [DiscoveredNodeDTO] + let userLocation: CLLocation? + + var body: some View { + HStack(alignment: .top) { + // Left side: Repeater ID + name, hop count + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(repeatEntry.repeaterHashFormatted) + .font(.body) + .foregroundStyle(.secondary) + .monospaced() + Text(repeaterName) + .font(.body) } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Repeats.Row.accessibility(repeaterName)) - .accessibilityValue(L10n.Chats.Chats.Repeats.Row.accessibilityValue(signalQuality, repeatEntry.snrFormatted, repeatEntry.rssiFormatted)) - } - // MARK: - Helpers + Text(hopCountText) + .font(.caption) + .foregroundStyle(.secondary) + } - private var snrQuality: SNRQuality { repeatEntry.snrQuality } + Spacer() - private var signalColor: Color { snrQuality.color } + // Right side: Signal bars and metrics + VStack(alignment: .trailing, spacing: 2) { + Image(systemName: "cellularbars", variableValue: repeatEntry.snrLevel) + .foregroundStyle(signalColor) - /// Signal quality description for accessibility - private var signalQuality: String { snrQuality.localizedLabel } + Text("SNR \(repeatEntry.snrFormatted)") + .font(.caption) + .foregroundStyle(.secondary) - /// Hop count text with proper pluralization - private var hopCountText: String { - let count = repeatEntry.hopCount - return count == 1 ? L10n.Chats.Chats.Repeats.Hop.singular : L10n.Chats.Chats.Repeats.Hop.plural(count) + Text("RSSI \(repeatEntry.rssiFormatted)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Repeats.Row.accessibility(repeaterName)) + .accessibilityValue(L10n.Chats.Chats.Repeats.Row.accessibilityValue(signalQuality, repeatEntry.snrFormatted, repeatEntry.rssiFormatted)) + } + + // MARK: - Helpers + + private var snrQuality: SNRQuality { + repeatEntry.snrQuality + } + + private var signalColor: Color { + snrQuality.color + } + + /// Signal quality description for accessibility + private var signalQuality: String { + snrQuality.localizedLabel + } + + /// Hop count text with proper pluralization + private var hopCountText: String { + let count = repeatEntry.hopCount + return count == 1 ? L10n.Chats.Chats.Repeats.Hop.singular : L10n.Chats.Chats.Repeats.Hop.plural(count) + } + + /// Resolve repeater name from repeaters list or show placeholder + private var repeaterName: String { + guard let repeaterHash = repeatEntry.repeaterHash else { + return L10n.Chats.Chats.Repeats.unknownRepeater } - /// Resolve repeater name from repeaters list or show placeholder - private var repeaterName: String { - guard let repeaterHash = repeatEntry.repeaterHash else { - return L10n.Chats.Chats.Repeats.unknownRepeater - } - - if let repeater = RepeaterResolver.bestMatch(for: repeaterHash, in: repeaters, userLocation: userLocation) { - return repeater.resolvableName - } - - if let node = RepeaterResolver.bestMatch(for: repeaterHash, in: discoveredRepeaters, userLocation: userLocation) { - return node.resolvableName - } + if let repeater = RepeaterResolver.bestMatch(for: repeaterHash, in: repeaters, userLocation: userLocation) { + return repeater.resolvableName + } - return L10n.Chats.Chats.Repeats.unknownRepeater + if let node = RepeaterResolver.bestMatch(for: repeaterHash, in: discoveredRepeaters, userLocation: userLocation) { + return node.resolvableName } + + return L10n.Chats.Chats.Repeats.unknownRepeater + } } #Preview { - List { - RepeatRowView( - repeatEntry: MessageRepeatDTO( - messageID: UUID(), - receivedAt: Date(), - pathNodes: Data([0xA3]), - snr: 6.2, - rssi: -85, - rxLogEntryID: nil - ), - repeaters: [], - discoveredRepeaters: [], - userLocation: nil - ) - - RepeatRowView( - repeatEntry: MessageRepeatDTO( - messageID: UUID(), - receivedAt: Date(), - pathNodes: Data([0x7F]), - snr: 2.1, - rssi: -102, - rxLogEntryID: nil - ), - repeaters: [], - discoveredRepeaters: [], - userLocation: nil - ) - } + List { + RepeatRowView( + repeatEntry: MessageRepeatDTO( + messageID: UUID(), + receivedAt: Date(), + pathNodes: Data([0xA3]), + snr: 6.2, + rssi: -85, + rxLogEntryID: nil + ), + repeaters: [], + discoveredRepeaters: [], + userLocation: nil + ) + + RepeatRowView( + repeatEntry: MessageRepeatDTO( + messageID: UUID(), + receivedAt: Date(), + pathNodes: Data([0x7F]), + snr: 2.1, + rssi: -102, + rxLogEntryID: nil + ), + repeaters: [], + discoveredRepeaters: [], + userLocation: nil + ) + } } diff --git a/MC1/Views/Chats/Components/RichPreviewCard.swift b/MC1/Views/Chats/Components/RichPreviewCard.swift index c2ca71a7..1cc80d36 100644 --- a/MC1/Views/Chats/Components/RichPreviewCard.swift +++ b/MC1/Views/Chats/Components/RichPreviewCard.swift @@ -11,35 +11,35 @@ import SwiftUI /// use glass because they must stay readable under outdoor glare and in /// high-contrast modes. struct RichPreviewCard: View { - /// Which corners receive the card radius. - enum CornerStyle { - /// Top corners only, for a hero stacked above in-card text rows. - case top - /// No clip; the enclosing view supplies the rounding. - case none - } + /// Which corners receive the card radius. + enum CornerStyle { + /// Top corners only, for a hero stacked above in-card text rows. + case top + /// No clip; the enclosing view supplies the rounding. + case none + } - let aspect: CGFloat - let minHeight: CGFloat - let maxHeight: CGFloat - var cornerStyle: CornerStyle = .none - @ViewBuilder let content: () -> Content + let aspect: CGFloat + let minHeight: CGFloat + let maxHeight: CGFloat + var cornerStyle: CornerStyle = .none + @ViewBuilder let content: () -> Content - var body: some View { - let hero = Color.clear - .aspectRatio(aspect, contentMode: .fit) - .frame(minHeight: minHeight, maxHeight: maxHeight) - .frame(maxWidth: .infinity) - .overlay { content() } + var body: some View { + let hero = Color.clear + .aspectRatio(aspect, contentMode: .fit) + .frame(minHeight: minHeight, maxHeight: maxHeight) + .frame(maxWidth: .infinity) + .overlay { content() } - switch cornerStyle { - case .top: - hero.clipShape(.rect( - topLeadingRadius: RichPreviewMetrics.cornerRadius, - topTrailingRadius: RichPreviewMetrics.cornerRadius - )) - case .none: - hero - } + switch cornerStyle { + case .top: + hero.clipShape(.rect( + topLeadingRadius: RichPreviewMetrics.cornerRadius, + topTrailingRadius: RichPreviewMetrics.cornerRadius + )) + case .none: + hero } + } } diff --git a/MC1/Views/Chats/Components/RichPreviewMetrics.swift b/MC1/Views/Chats/Components/RichPreviewMetrics.swift index 089e15d5..02a5686f 100644 --- a/MC1/Views/Chats/Components/RichPreviewMetrics.swift +++ b/MC1/Views/Chats/Components/RichPreviewMetrics.swift @@ -7,26 +7,26 @@ import CoreGraphics /// wrappers stay per-view (they need the view's environment) and read these /// named constants as their base values. enum RichPreviewMetrics { - /// Fallback hero aspect when the source provides no intrinsic dimensions. - static let fallbackAspect: Double = 16.0 / 9.0 + /// Fallback hero aspect when the source provides no intrinsic dimensions. + static let fallbackAspect: Double = 16.0 / 9.0 - /// Lower bound for a reserved hero, so a very tall image does not collapse. - static let minHeroHeight: CGFloat = 100 + /// Lower bound for a reserved hero, so a very tall image does not collapse. + static let minHeroHeight: CGFloat = 100 - /// Upper bound for a reserved hero, so a very wide image does not dominate. - static let maxHeroHeight: CGFloat = 250 + /// Upper bound for a reserved hero, so a very wide image does not dominate. + static let maxHeroHeight: CGFloat = 250 - /// Card and hero corner radius, matching the chat map thumbnail. - static let cornerRadius: CGFloat = 12 + /// Card and hero corner radius, matching the chat map thumbnail. + static let cornerRadius: CGFloat = 12 - /// Hero aspect from intrinsic pixel dimensions, falling back to - /// `fallbackAspect` when either dimension is missing or non-positive. - /// Shared by the link card and its loading placeholder so the reserved hero - /// keeps the same shape across the byte-arrival transition. - static func heroAspect(imageWidth: Int?, imageHeight: Int?) -> CGFloat { - guard let imageWidth, let imageHeight, imageWidth > 0, imageHeight > 0 else { - return CGFloat(fallbackAspect) - } - return CGFloat(imageWidth) / CGFloat(imageHeight) + /// Hero aspect from intrinsic pixel dimensions, falling back to + /// `fallbackAspect` when either dimension is missing or non-positive. + /// Shared by the link card and its loading placeholder so the reserved hero + /// keeps the same shape across the byte-arrival transition. + static func heroAspect(imageWidth: Int?, imageHeight: Int?) -> CGFloat { + guard let imageWidth, let imageHeight, imageWidth > 0, imageHeight > 0 else { + return CGFloat(fallbackAspect) } + return CGFloat(imageWidth) / CGFloat(imageHeight) + } } diff --git a/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift b/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift index fa6de1b6..3bc6d933 100644 --- a/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift +++ b/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift @@ -1,76 +1,90 @@ import Foundation /// Whether the user is actively driving the scroll surface. -enum InteractionState: Equatable, Sendable { - case idle - case dragging +enum InteractionState: Equatable { + case idle + case dragging } /// What the controller wants the scroll surface to do, if anything. -enum ScrollIntent: Equatable, Sendable { - case none - case toBottom - case toTarget(id: UUID) +enum ScrollIntent: Equatable { + case none + case toBottom + case toTarget(id: UUID) } /// Whether a snapshot apply is in flight. -enum ApplyState: Equatable, Sendable { - case idle - case applying +enum ApplyState: Equatable { + case idle + case applying } /// Captures a scroll-to-bottom request that was deferred because the user was /// dragging. Coexists with InteractionState.dragging. Resolved when interaction /// ends. -struct DeferredScroll: Equatable, Sendable { - let targetMessageCount: Int +struct DeferredScroll: Equatable { + let targetMessageCount: Int } /// Container that bundles the three axes plus DeferredScroll for atomic /// observation. Pure value-type; mutations go through helper methods. -struct ChatScrollState: Equatable, Sendable { - var interaction: InteractionState - var intent: ScrollIntent - var apply: ApplyState - var deferredScroll: DeferredScroll? - - static let idle = ChatScrollState( - interaction: .idle, intent: .none, apply: .idle, deferredScroll: nil - ) - - /// True when the controller should not start a new scroll intent because - /// the user is dragging. The dragging axis is independent of intent — - /// existing intents continue. - var isUserDriven: Bool { interaction == .dragging } - - /// True when any programmatic scroll is in flight. Coexists with - /// interaction.dragging (e.g., reloadTargetCell applies a snapshot during - /// scroll-to-target). - var isApplyingSnapshot: Bool { apply == .applying } +struct ChatScrollState: Equatable { + var interaction: InteractionState + var intent: ScrollIntent + var apply: ApplyState + var deferredScroll: DeferredScroll? + + static let idle = ChatScrollState( + interaction: .idle, intent: .none, apply: .idle, deferredScroll: nil + ) + + /// True when the controller should not start a new scroll intent because + /// the user is dragging. The dragging axis is independent of intent — + /// existing intents continue. + var isUserDriven: Bool { + interaction == .dragging + } + + /// True when any programmatic scroll is in flight. Coexists with + /// interaction.dragging (e.g., reloadTargetCell applies a snapshot during + /// scroll-to-target). + var isApplyingSnapshot: Bool { + apply == .applying + } } extension ChatScrollState { - mutating func enterDragging() { interaction = .dragging } + mutating func enterDragging() { + interaction = .dragging + } + + mutating func endDragging() { + interaction = .idle + } + + mutating func startApplying() { + apply = .applying + } - mutating func endDragging() { - interaction = .idle - } + mutating func endApplying() { + apply = .idle + } - mutating func startApplying() { apply = .applying } - mutating func endApplying() { apply = .idle } + mutating func startIntent(_ next: ScrollIntent) { + intent = next + } - mutating func startIntent(_ next: ScrollIntent) { - intent = next - } - mutating func clearIntent() { intent = .none } + mutating func clearIntent() { + intent = .none + } - /// Stores a scroll request to replay once the user stops dragging. - mutating func scheduleDeferredScroll(_ scroll: DeferredScroll) { - deferredScroll = scroll - } + /// Stores a scroll request to replay once the user stops dragging. + mutating func scheduleDeferredScroll(_ scroll: DeferredScroll) { + deferredScroll = scroll + } - mutating func consumeDeferredScroll() -> DeferredScroll? { - defer { deferredScroll = nil } - return deferredScroll - } + mutating func consumeDeferredScroll() -> DeferredScroll? { + defer { deferredScroll = nil } + return deferredScroll + } } diff --git a/MC1/Views/Chats/Components/ScrollToBottomButton.swift b/MC1/Views/Chats/Components/ScrollToBottomButton.swift index f75aee31..8404fb36 100644 --- a/MC1/Views/Chats/Components/ScrollToBottomButton.swift +++ b/MC1/Views/Chats/Components/ScrollToBottomButton.swift @@ -2,42 +2,42 @@ import SwiftUI /// Button to scroll to latest message with unread badge struct ScrollToBottomButton: View { - let isVisible: Bool - let unreadCount: Int - let onTap: () -> Void + let isVisible: Bool + let unreadCount: Int + let onTap: () -> Void - var body: some View { - Button(action: onTap) { - Image(systemName: "chevron.down") - .font(.body.bold()) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .contentShape(.circle) - .liquidGlassInteractive(in: .circle) - .overlay(alignment: .topTrailing) { - UnreadBadge(count: unreadCount, tint: .blue) - } - .opacity(isVisible ? 1 : 0) - .scaleEffect(isVisible ? 1 : 0.5) - .animation(.snappy(duration: 0.2), value: isVisible) - .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityLabel) - .accessibilityValue(unreadCount > 0 ? L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityValue(unreadCount) : "") - .accessibilityHidden(!isVisible) + var body: some View { + Button(action: onTap) { + Image(systemName: "chevron.down") + .font(.body.bold()) + .frame(width: 44, height: 44) } + .buttonStyle(.plain) + .contentShape(.circle) + .liquidGlassInteractive(in: .circle) + .overlay(alignment: .topTrailing) { + UnreadBadge(count: unreadCount, tint: .blue) + } + .opacity(isVisible ? 1 : 0) + .scaleEffect(isVisible ? 1 : 0.5) + .animation(.snappy(duration: 0.2), value: isVisible) + .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityLabel) + .accessibilityValue(unreadCount > 0 ? L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityValue(unreadCount) : "") + .accessibilityHidden(!isVisible) + } } #Preview("Visible with unread") { - ScrollToBottomButton(isVisible: true, unreadCount: 5, onTap: {}) - .padding(50) + ScrollToBottomButton(isVisible: true, unreadCount: 5, onTap: {}) + .padding(50) } #Preview("Visible no unread") { - ScrollToBottomButton(isVisible: true, unreadCount: 0, onTap: {}) - .padding(50) + ScrollToBottomButton(isVisible: true, unreadCount: 0, onTap: {}) + .padding(50) } #Preview("Hidden") { - ScrollToBottomButton(isVisible: false, unreadCount: 3, onTap: {}) - .padding(50) + ScrollToBottomButton(isVisible: false, unreadCount: 3, onTap: {}) + .padding(50) } diff --git a/MC1/Views/Chats/Components/ScrollToDividerButton.swift b/MC1/Views/Chats/Components/ScrollToDividerButton.swift index cb218c45..04a85f0b 100644 --- a/MC1/Views/Chats/Components/ScrollToDividerButton.swift +++ b/MC1/Views/Chats/Components/ScrollToDividerButton.swift @@ -2,23 +2,23 @@ import SwiftUI /// Button to scroll to the new messages divider struct ScrollToDividerButton: View { - let onTap: () -> Void + let onTap: () -> Void - var body: some View { - Button(action: onTap) { - Image(systemName: "chevron.up") - .font(.body.bold()) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .contentShape(.circle) - .liquidGlassInteractive(in: .circle) - .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityLabel) - .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityHint) + var body: some View { + Button(action: onTap) { + Image(systemName: "chevron.up") + .font(.body.bold()) + .frame(width: 44, height: 44) } + .buttonStyle(.plain) + .contentShape(.circle) + .liquidGlassInteractive(in: .circle) + .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityLabel) + .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityHint) + } } #Preview { - ScrollToDividerButton(onTap: {}) - .padding(50) + ScrollToDividerButton(onTap: {}) + .padding(50) } diff --git a/MC1/Views/Chats/Components/ScrollToMentionButton.swift b/MC1/Views/Chats/Components/ScrollToMentionButton.swift index b866ea6d..3179bd2b 100644 --- a/MC1/Views/Chats/Components/ScrollToMentionButton.swift +++ b/MC1/Views/Chats/Components/ScrollToMentionButton.swift @@ -2,33 +2,33 @@ import SwiftUI /// Button to scroll to unread mentions struct ScrollToMentionButton: View { - let unreadMentionCount: Int - let onTap: () -> Void + let unreadMentionCount: Int + let onTap: () -> Void - var body: some View { - Button(action: onTap) { - Image(systemName: "at") - .font(.body.bold()) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .contentShape(.circle) - .liquidGlassInteractive(in: .circle) - .overlay(alignment: .topTrailing) { - UnreadBadge(count: unreadMentionCount, tint: .red) - } - .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityLabel) - .accessibilityValue(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityValue(unreadMentionCount)) - .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityHint) + var body: some View { + Button(action: onTap) { + Image(systemName: "at") + .font(.body.bold()) + .frame(width: 44, height: 44) } + .buttonStyle(.plain) + .contentShape(.circle) + .liquidGlassInteractive(in: .circle) + .overlay(alignment: .topTrailing) { + UnreadBadge(count: unreadMentionCount, tint: .red) + } + .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityLabel) + .accessibilityValue(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityValue(unreadMentionCount)) + .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityHint) + } } #Preview("With multiple") { - ScrollToMentionButton(unreadMentionCount: 5, onTap: {}) - .padding(50) + ScrollToMentionButton(unreadMentionCount: 5, onTap: {}) + .padding(50) } #Preview("With one") { - ScrollToMentionButton(unreadMentionCount: 1, onTap: {}) - .padding(50) + ScrollToMentionButton(unreadMentionCount: 1, onTap: {}) + .padding(50) } diff --git a/MC1/Views/Chats/Components/SenderNameLabel.swift b/MC1/Views/Chats/Components/SenderNameLabel.swift new file mode 100644 index 00000000..c821124c --- /dev/null +++ b/MC1/Views/Chats/Components/SenderNameLabel.swift @@ -0,0 +1,55 @@ +import MC1Services +import SwiftUI + +/// Sender-name label for an incoming channel message. When the sender name +/// matched a local contact by name only, shows the nickname prominently with +/// the unverified sender name parenthesized and a "?" affordance explaining +/// that channel senders are not cryptographically verified. +struct SenderNameLabel: View { + let resolution: NodeNameResolution + var font: Font = .footnote + var nameColor: Color = .primary + + var body: some View { + HStack(spacing: 4) { + if let nickname = resolution.unverifiedNickname { + Text(nickname) + .font(font) + .bold() + .foregroundStyle(nameColor) + Text(L10n.Chats.Chats.Message.Sender.unverifiedNicknameFormat(resolution.displayName)) + .font(font) + .foregroundStyle(.secondary) + FallbackMatchIndicatorView( + accessibilityLabel: L10n.Chats.Chats.Message.Sender.unverifiedNicknameAccessibilityLabel, + accessibilityHint: L10n.Chats.Chats.Message.Sender.unverifiedNicknameExplanation, + title: L10n.Chats.Chats.Message.Sender.unverifiedNickname, + explanation: L10n.Chats.Chats.Message.Sender.unverifiedNicknameExplanation + ) + } else { + Text(resolution.displayName) + .font(font) + .bold() + .foregroundStyle(nameColor) + if resolution.isFallback { + FallbackMatchIndicatorView() + } + } + } + } +} + +#Preview { + VStack(alignment: .leading, spacing: 16) { + SenderNameLabel( + resolution: NodeNameResolution(displayName: "Alpha", matchKind: .exact, unverifiedNickname: "Rico") + ) + SenderNameLabel( + resolution: NodeNameResolution(displayName: "Bravo", matchKind: .exact) + ) + SenderNameLabel( + resolution: NodeNameResolution(displayName: "Charlie", matchKind: .fallback) + ) + } + .padding() +} diff --git a/MC1/Views/Chats/Components/Shimmer.swift b/MC1/Views/Chats/Components/Shimmer.swift index 3f55b704..dccffed3 100644 --- a/MC1/Views/Chats/Components/Shimmer.swift +++ b/MC1/Views/Chats/Components/Shimmer.swift @@ -3,39 +3,39 @@ import SwiftUI /// Animated linear-gradient overlay driven by `TimelineView(.animation)` so /// off-screen reused cells naturally pause. Static when Reduce Motion is on. struct Shimmer: ViewModifier { - let isActive: Bool + let isActive: Bool - private static let duration: Double = 1.5 - private static let highlightOpacity: Double = 0.35 - private static let frameInterval: Double = 1.0 / 60.0 + private static let duration: Double = 1.5 + private static let highlightOpacity: Double = 0.35 + private static let frameInterval: Double = 1.0 / 60.0 - func body(content: Content) -> some View { - if isActive { - content - .overlay { - TimelineView(.animation(minimumInterval: Self.frameInterval)) { context in - let elapsed = context.date.timeIntervalSinceReferenceDate - let progress = elapsed.truncatingRemainder(dividingBy: Self.duration) / Self.duration - let phase = CGFloat(progress) * 2 - 1 - GeometryReader { proxy in - let width = proxy.size.width - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .white.opacity(Self.highlightOpacity), location: 0.5), - .init(color: .clear, location: 1) - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: width) - .offset(x: phase * width) - } - } - } - .clipped() - } else { - content + func body(content: Content) -> some View { + if isActive { + content + .overlay { + TimelineView(.animation(minimumInterval: Self.frameInterval)) { context in + let elapsed = context.date.timeIntervalSinceReferenceDate + let progress = elapsed.truncatingRemainder(dividingBy: Self.duration) / Self.duration + let phase = CGFloat(progress) * 2 - 1 + GeometryReader { proxy in + let width = proxy.size.width + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .white.opacity(Self.highlightOpacity), location: 0.5), + .init(color: .clear, location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: width) + .offset(x: phase * width) + } + } } + .clipped() + } else { + content } + } } diff --git a/MC1/Views/Chats/Components/TapToLoadPreview.swift b/MC1/Views/Chats/Components/TapToLoadPreview.swift index f0a84e10..bb3ca333 100644 --- a/MC1/Views/Chats/Components/TapToLoadPreview.swift +++ b/MC1/Views/Chats/Components/TapToLoadPreview.swift @@ -2,43 +2,43 @@ import SwiftUI /// Placeholder shown when auto-resolve is disabled but previews are enabled struct TapToLoadPreview: View { - let url: URL - let isLoading: Bool - let onTap: () -> Void + let url: URL + let isLoading: Bool + let onTap: () -> Void - var body: some View { - HStack(spacing: 8) { - if isLoading { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "globe") - .foregroundStyle(.secondary) - } + var body: some View { + HStack(spacing: 8) { + if isLoading { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "globe") + .foregroundStyle(.secondary) + } - Text(isLoading ? L10n.Chats.Chats.Preview.loading : L10n.Chats.Chats.Preview.tapToLoad) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(.regularMaterial, in: .rect(cornerRadius: 12)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { if !isLoading { onTap() } } - .accessibilityLabel(isLoading ? L10n.Chats.Chats.Preview.loadingAccessibility(url.host ?? "link") : L10n.Chats.Chats.Preview.tapAccessibility(url.host ?? "link")) - .accessibilityHint(isLoading ? L10n.Chats.Chats.Preview.loadingHint : L10n.Chats.Chats.Preview.tapHint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { if !isLoading { onTap() } } - .disabled(isLoading) + Text(isLoading ? L10n.Chats.Chats.Preview.loading : L10n.Chats.Chats.Preview.tapToLoad) + .font(.subheadline) + .foregroundStyle(.secondary) } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { if !isLoading { onTap() } } + .accessibilityLabel(isLoading ? L10n.Chats.Chats.Preview.loadingAccessibility(url.host ?? "link") : L10n.Chats.Chats.Preview.tapAccessibility(url.host ?? "link")) + .accessibilityHint(isLoading ? L10n.Chats.Chats.Preview.loadingHint : L10n.Chats.Chats.Preview.tapHint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { if !isLoading { onTap() } } + .disabled(isLoading) + } } #Preview("Idle") { - TapToLoadPreview(url: URL(string: "https://example.com")!, isLoading: false, onTap: {}) - .padding() + TapToLoadPreview(url: URL(string: "https://example.com")!, isLoading: false, onTap: {}) + .padding() } #Preview("Loading") { - TapToLoadPreview(url: URL(string: "https://example.com")!, isLoading: true, onTap: {}) - .padding() + TapToLoadPreview(url: URL(string: "https://example.com")!, isLoading: true, onTap: {}) + .padding() } diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index e99a0cf5..9d1ada68 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Two-layer drop shadow shown only while the bubble is lifted: a tight contact /// shadow under the bubble plus a softer ambient one for depth. @@ -22,319 +22,321 @@ private let bubbleRowOppositeEdgeMinInset: CGFloat = 40 /// render-affecting input is encoded into `MessageItem` during /// `rebuildDisplayItem`. struct UnifiedMessageBubble: View, Equatable { - let message: MessageDTO - let contactName: String - let deviceName: String - let configuration: MessageBubbleConfiguration - let item: MessageItem - /// Box-vs-sibling partition derived once in `MessageBubbleView.body`. - /// Excluded from `==` (which stays `item`-only): it is a pure function of - /// `item.content`, so equal items yield equal layouts. - let layout: FragmentLayout - let imageResolver: (ImageReference) -> UIImage? - let callbacks: MessageBubbleCallbacks + let message: MessageDTO + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + let item: MessageItem + /// Box-vs-sibling partition derived once in `MessageBubbleView.body`. + /// Excluded from `==` (which stays `item`-only): it is a pure function of + /// `item.content`, so equal items yield equal layouts. + let layout: FragmentLayout + let imageResolver: (ImageReference) -> UIImage? + let callbacks: MessageBubbleCallbacks - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.colorScheme) private var colorScheme - @Environment(\.openURL) private var openURL - @Environment(\.appTheme) private var theme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.colorScheme) private var colorScheme + @Environment(\.openURL) private var openURL + @Environment(\.appTheme) private var theme - @State private var showingReactionDetails = false - @State private var isLongPressing = false - @State private var longPressTrigger = 0 + @State private var showingReactionDetails = false + @State private var isLongPressing = false + @State private var longPressTrigger = 0 - nonisolated static func == (lhs: UnifiedMessageBubble, rhs: UnifiedMessageBubble) -> Bool { - lhs.item == rhs.item - } + nonisolated static func == (lhs: UnifiedMessageBubble, rhs: UnifiedMessageBubble) -> Bool { + lhs.item == rhs.item + } - init( - message: MessageDTO, - contactName: String, - deviceName: String = "Me", - configuration: MessageBubbleConfiguration, - item: MessageItem, - layout: FragmentLayout, - imageResolver: @escaping (ImageReference) -> UIImage? = { _ in nil }, - callbacks: MessageBubbleCallbacks = .init() - ) { - self.message = message - self.contactName = contactName - self.deviceName = deviceName - self.configuration = configuration - self.item = item - self.layout = layout - self.imageResolver = imageResolver - self.callbacks = callbacks - } + init( + message: MessageDTO, + contactName: String, + deviceName: String = "Me", + configuration: MessageBubbleConfiguration, + item: MessageItem, + layout: FragmentLayout, + imageResolver: @escaping (ImageReference) -> UIImage? = { _ in nil }, + callbacks: MessageBubbleCallbacks = .init() + ) { + self.message = message + self.contactName = contactName + self.deviceName = deviceName + self.configuration = configuration + self.item = item + self.layout = layout + self.imageResolver = imageResolver + self.callbacks = callbacks + } - var body: some View { - VStack(spacing: 0) { - if item.grouping.showNewMessagesDivider { - NewMessagesDividerView() - .padding(.bottom, 4) - } + var body: some View { + VStack(spacing: 0) { + if item.grouping.showNewMessagesDivider { + NewMessagesDividerView() + .padding(.bottom, 4) + } - if item.grouping.showDayDivider { - MessageDayDividerView(date: item.envelope.date) - } + if item.grouping.showDayDivider { + MessageDayDividerView(date: item.envelope.date) + } - // The day divider already anchors the cluster in time, so a time-only - // marker directly beneath it would be redundant; suppress it there. - if item.grouping.showTimestamp && !item.grouping.showDayDivider { - MessageTimestampView(date: item.envelope.date) - } + // The day divider already anchors the cluster in time, so a time-only + // marker directly beneath it would be redundant; suppress it there. + if item.grouping.showTimestamp, !item.grouping.showDayDivider { + MessageTimestampView(date: item.envelope.date) + } - HStack(alignment: .bottom, spacing: 4) { - if item.envelope.isOutgoing { - Spacer(minLength: bubbleRowOppositeEdgeMinInset) - } - - VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 0) { - if !item.envelope.isOutgoing - && configuration.showSenderName - && item.grouping.showSenderName { - HStack(spacing: 4) { - Text(item.envelope.senderName) - .font(.footnote) - .bold() - .foregroundStyle(senderColor) - - if item.envelope.senderResolution.isFallback { - FallbackMatchIndicatorView() - } - } - } + HStack(alignment: .bottom, spacing: 4) { + if item.envelope.isOutgoing { + Spacer(minLength: bubbleRowOppositeEdgeMinInset) + } - bubbleActionsLongPress( - BubbleFragmentStack( - item: item, - layout: layout, - bubbleColor: resolvedBubbleColor, - callbacks: callbacks, - imageResolver: imageResolver - ) - .shadow( - color: Color.black.opacity(isLongPressing ? liftContactShadowOpacity : 0), - radius: liftContactShadowRadius, - x: 0, - y: liftContactShadowYOffset - ) - .shadow( - color: Color.black.opacity(isLongPressing ? liftAmbientShadowOpacity : 0), - radius: liftAmbientShadowRadius, - x: 0, - y: liftAmbientShadowYOffset - ) - ) + VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 0) { + if !item.envelope.isOutgoing, + configuration.showSenderName, + item.grouping.showSenderName { + SenderNameLabel(resolution: item.envelope.senderResolution, nameColor: senderColor) + } - ForEach(Array(layout.siblings.enumerated()), id: \.offset) { _, fragment in - siblingFragmentView(fragment) - } + bubbleActionsLongPress( + BubbleFragmentStack( + item: item, + layout: layout, + bubbleColor: resolvedBubbleColor, + callbacks: callbacks, + imageResolver: imageResolver + ) + .shadow( + color: Color.black.opacity(isLongPressing ? liftContactShadowOpacity : 0), + radius: liftContactShadowRadius, + x: 0, + y: liftContactShadowYOffset + ) + .shadow( + color: Color.black.opacity(isLongPressing ? liftAmbientShadowOpacity : 0), + radius: liftAmbientShadowRadius, + x: 0, + y: liftAmbientShadowYOffset + ) + ) - if item.footer.showStatusRow { - BubbleStatusRow(item: item, onRetry: callbacks.onRetry) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityMessageLabel) - .accessibilityAction { - callbacks.onLongPress?() - } - .accessibilityActions { - if item.footer.showStatusRow, - item.footer.status == .failed, - let onRetry = callbacks.onRetry { - Button(L10n.Chats.Chats.Message.Action.retry) { onRetry() } - } - if hasReactionSummary { - Button(L10n.Chats.Chats.Message.Action.viewReactions) { - showingReactionDetails = true - } - } - ForEach(MessageLinkAccessibility.actions( - previewURL: linkPreviewURL, - formatted: layout.textPayload?.formatted - )) { action in - Button(action.name) { openURL(action.url) } - } - if let inline = inlineImage { - switch inline.state { - case .loaded: - if let onImageTap = callbacks.onImageTap { - Button(L10n.Chats.Chats.Message.Action.viewImage) { onImageTap() } - } - case .failed: - if let onRetryInlineImage = callbacks.onRetryInlineImage { - Button(L10n.Chats.Chats.Message.Action.retryImage) { onRetryInlineImage() } - } - case .loading, .idle: - EmptyView() - } - } - } - .messageBubbleLongPressEffect(isPressing: isLongPressing, trigger: longPressTrigger) + ForEach(Array(layout.siblings.enumerated()), id: \.offset) { _, fragment in + siblingFragmentView(fragment) + } - if !item.envelope.isOutgoing { - Spacer(minLength: bubbleRowOppositeEdgeMinInset) - } - } + if item.footer.showStatusRow { + BubbleStatusRow(item: item, onRetry: callbacks.onRetry) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityMessageLabel) + .accessibilityAction { + callbacks.onLongPress?() } - .padding(.horizontal, 12) - .padding(.top, paddingTop) - .padding(.bottom, 0) - // Keyed on `previewFetchTaskID` rather than `.onAppear`: the URL that - // satisfies the fetch precondition is detected asynchronously and lands - // as a reconfigure after the cell has already appeared, so a one-shot - // appear trigger never starts the fetch. The id is message-scoped so - // cell reuse can't drop the trigger. - .task(id: item.previewFetchTaskID) { - if item.shouldRequestPreviewFetch { - callbacks.onRequestPreviewFetch?() + .accessibilityActions { + if item.footer.showStatusRow, + item.footer.status == .failed, + let onRetry = callbacks.onRetry { + Button(L10n.Chats.Chats.Message.Action.retry) { onRetry() } + } + if hasReactionSummary { + Button(L10n.Chats.Chats.Message.Action.viewReactions) { + showingReactionDetails = true } + } + ForEach(MessageLinkAccessibility.actions( + previewURL: linkPreviewURL, + formatted: layout.textPayload?.formatted + )) { action in + Button(action.name) { openURL(action.url) } + } + if let inline = inlineImage { + switch inline.state { + case .loaded: + if let onImageTap = callbacks.onImageTap { + Button(L10n.Chats.Chats.Message.Action.viewImage) { onImageTap() } + } + case .failed: + if let onRetryInlineImage = callbacks.onRetryInlineImage { + Button(L10n.Chats.Chats.Message.Action.retryImage) { onRetryInlineImage() } + } + case .disabled: + if let onManualPreviewFetch = callbacks.onManualPreviewFetch { + Button(L10n.Chats.Chats.Preview.tapToLoad) { onManualPreviewFetch() } + } + case .loading, .idle: + EmptyView() + } + } } - .sheet(isPresented: $showingReactionDetails) { - ReactionDetailsSheet(messageID: message.id) + .messageBubbleLongPressEffect(isPressing: isLongPressing, trigger: longPressTrigger) + + if !item.envelope.isOutgoing { + Spacer(minLength: bubbleRowOppositeEdgeMinInset) } + } } - - /// Applies the bubble's actions-sheet long-press: a sustained press fires `onLongPress`, drives - /// the lift, and bumps the haptic trigger. Shared by the box and the content-card siblings so a - /// press anywhere on the bubble opens the same sheet. - private func bubbleActionsLongPress(_ content: some View) -> some View { - content.messageBubbleLongPressGesture( - isPressing: $isLongPressing, - trigger: $longPressTrigger, - onFire: { callbacks.onLongPress?() } - ) + .padding(.horizontal, 12) + .padding(.top, paddingTop) + .padding(.bottom, 0) + // Keyed on `previewFetchTaskID` rather than `.onAppear`: the URL that + // satisfies the fetch precondition is detected asynchronously and lands + // as a reconfigure after the cell has already appeared, so a one-shot + // appear trigger never starts the fetch. The id is message-scoped so + // cell reuse can't drop the trigger. + .task(id: item.previewFetchTaskID) { + if item.shouldRequestPreviewFetch { + callbacks.onRequestPreviewFetch?() + } } - - /// Renders one fragment from `layout.siblings` (reactions, malware warning, link preview, map - /// preview). Content cards carry the bubble's long-press so a press anywhere opens the actions - /// sheet; reactions keep their own. The text and inline-image kinds never reach the sibling list - /// (they render inside `BubbleFragmentStack`), so their arm exists only to keep the switch - /// exhaustive. - @ViewBuilder - private func siblingFragmentView(_ fragment: MessageFragment) -> some View { - let content = siblingFragmentBody(fragment) - if Self.siblingWantsActionsLongPress(fragment) { - bubbleActionsLongPress(content) - } else { - content - } + .sheet(isPresented: $showingReactionDetails) { + ReactionDetailsSheet(messageID: message.id) } + } - @ViewBuilder - private func siblingFragmentBody(_ fragment: MessageFragment) -> some View { - switch fragment { - case .reactionSummary(let summary): - ReactionsFragmentView( - summary: summary, - onTapReaction: { emoji in callbacks.onReaction?(emoji) }, - onLongPress: { showingReactionDetails = true } - ) - case .malwareWarning(let url): - MalwareWarningCard(url: url) - case .linkPreview(let state): - LinkPreviewFragmentView( - state: state, - imageResolver: imageResolver, - onManualPreviewFetch: callbacks.onManualPreviewFetch - ) - case .mapPreview(let state): - MapPreviewFragmentView( - state: state, - snapshotResolver: { callbacks.snapshotResolver?($0) }, - onTap: { callbacks.onMapPreviewTap?($0) }, - onRequestSnapshot: { callbacks.requestSnapshot?($0) }, - onRetry: { callbacks.retrySnapshot?($0) } - ) - case .text, .inlineImage: - EmptyView() - } - } + /// Applies the bubble's actions-sheet long-press: a sustained press fires `onLongPress`, drives + /// the lift, and bumps the haptic trigger. Shared by the box and the content-card siblings so a + /// press anywhere on the bubble opens the same sheet. + private func bubbleActionsLongPress(_ content: some View) -> some View { + content.messageBubbleLongPressGesture( + isPressing: $isLongPressing, + trigger: $longPressTrigger, + onFire: { callbacks.onLongPress?() } + ) + } - /// Whether a sibling fragment carries the bubble's actions-sheet long-press. Content cards - /// (link, map, malware) do, so a press anywhere on the bubble opens the sheet. Reactions keep - /// their own long-press; text and inline image render in the box, which already carries it. - static func siblingWantsActionsLongPress(_ fragment: MessageFragment) -> Bool { - switch fragment { - case .linkPreview, .mapPreview, .malwareWarning: - return true - case .reactionSummary, .text, .inlineImage: - return false - } + /// Renders one fragment from `layout.siblings` (reactions, malware warning, link preview, map + /// preview). Content cards carry the bubble's long-press so a press anywhere opens the actions + /// sheet; reactions keep their own. The text and inline-image kinds never reach the sibling list + /// (they render inside `BubbleFragmentStack`), so their arm exists only to keep the switch + /// exhaustive. + @ViewBuilder + private func siblingFragmentView(_ fragment: MessageFragment) -> some View { + let content = siblingFragmentBody(fragment) + if Self.siblingWantsActionsLongPress(fragment) { + bubbleActionsLongPress(content) + } else { + content } + } - // MARK: - Computed Properties + @ViewBuilder + private func siblingFragmentBody(_ fragment: MessageFragment) -> some View { + switch fragment { + case let .reactionSummary(summary): + ReactionsFragmentView( + summary: summary, + onTapReaction: { emoji in callbacks.onReaction?(emoji) }, + onLongPress: { showingReactionDetails = true } + ) + case let .malwareWarning(url): + MalwareWarningCard(url: url) + case let .linkPreview(state): + LinkPreviewFragmentView( + state: state, + imageResolver: imageResolver, + onManualPreviewFetch: callbacks.onManualPreviewFetch + ) + case let .mapPreview(state): + MapPreviewFragmentView( + state: state, + snapshotResolver: { callbacks.snapshotResolver?($0) }, + onTap: { callbacks.onMapPreviewTap?($0) }, + onRequestSnapshot: { callbacks.requestSnapshot?($0) }, + onRetry: { callbacks.retrySnapshot?($0) } + ) + case .text, .inlineImage: + EmptyView() + } + } - private var resolvedBubbleColor: Color { - if item.envelope.isOutgoing { - if item.envelope.hasFailed { - return AppColors.Message.outgoingBubbleFailed( - highContrast: colorSchemeContrast == .increased - ) - } - return theme.accentColor - } - return theme.incomingBubbleColor + /// Whether a sibling fragment carries the bubble's actions-sheet long-press. Content cards + /// (link, map, malware) do, so a press anywhere on the bubble opens the sheet. Reactions keep + /// their own long-press; text and inline image render in the box, which already carries it. + static func siblingWantsActionsLongPress(_ fragment: MessageFragment) -> Bool { + switch fragment { + case .linkPreview, .mapPreview, .malwareWarning: + true + case .reactionSummary, .text, .inlineImage: + false } + } + + // MARK: - Computed Properties - private var senderColor: Color { - theme.identityColor( - forName: item.envelope.senderName, - colorScheme: colorScheme, - contrast: colorSchemeContrast + private var resolvedBubbleColor: Color { + if item.envelope.isOutgoing { + if item.envelope.hasFailed { + return AppColors.Message.outgoingBubbleFailed( + highContrast: colorSchemeContrast == .increased ) + } + return theme.accentColor } + return theme.incomingBubbleColor + } - private var paddingTop: CGFloat { - if item.grouping.showDirectionGap { return 6 } - if item.grouping.showSenderName { return 4 } - return item.envelope.isOutgoing ? 1 : 2 - } + private var senderColor: Color { + theme.identityColor( + forName: item.envelope.senderName, + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + } - var accessibilityMessageLabel: String { - var label = "" - if !item.envelope.isOutgoing && configuration.showSenderName { - label = "\(item.envelope.senderName): " - if item.envelope.senderResolution.isFallback { - label += "\(L10n.Chats.Chats.Message.Sender.possibleMatch), " - } - } - label += message.text - if item.envelope.isOutgoing { - label += ", \(BubbleStatusRow.statusText(for: item))" - } - if !item.envelope.isOutgoing { - if item.footer.showHop { - label += ", \(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(item.footer.hopCount))" - } - if let formattedPath = item.footer.formattedPath { - label += ", \(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath))" - } - if let region = item.footer.regionToShow { - label += ", \(L10n.Chats.Chats.Message.Region.accessibilityLabel(region))" - } + private var paddingTop: CGFloat { + if item.grouping.showDirectionGap { return 6 } + if item.grouping.showSenderName { return 4 } + return item.envelope.isOutgoing ? 1 : 2 + } + + var accessibilityMessageLabel: String { + var label = "" + if !item.envelope.isOutgoing, configuration.showSenderName { + let resolution = item.envelope.senderResolution + if let nickname = resolution.unverifiedNickname { + let rawName = L10n.Chats.Chats.Message.Sender.unverifiedNicknameFormat(item.envelope.senderName) + label = "\(nickname) \(rawName): " + label += "\(L10n.Chats.Chats.Message.Sender.unverifiedNicknameAccessibilityLabel), " + } else { + label = "\(item.envelope.senderName): " + if resolution.isFallback { + label += "\(L10n.Chats.Chats.Message.Sender.possibleMatch), " } - return label + } } + label += message.text + if item.envelope.isOutgoing { + label += ", \(BubbleStatusRow.statusText(for: item))" + } + if !item.envelope.isOutgoing { + if item.footer.showHop { + label += ", \(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(item.footer.hopCount))" + } + if let formattedPath = item.footer.formattedPath { + label += ", \(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath))" + } + if let region = item.footer.regionToShow { + label += ", \(L10n.Chats.Chats.Message.Region.accessibilityLabel(region))" + } + } + return label + } } private extension UnifiedMessageBubble { - var inlineImage: InlineImage? { - layout.inlineImage - } + var inlineImage: InlineImage? { + layout.inlineImage + } - var hasReactionSummary: Bool { - layout.siblings.contains { if case .reactionSummary = $0 { return true } else { return false } } - } + var hasReactionSummary: Bool { + layout.siblings.contains { if case .reactionSummary = $0 { true } else { false } } + } - var linkPreviewURL: URL? { - for fragment in layout.siblings { - if case .linkPreview(let state) = fragment { - return state.primaryURL - } - } - return nil + var linkPreviewURL: URL? { + for fragment in layout.siblings { + if case let .linkPreview(state) = fragment { + return state.primaryURL + } } + return nil + } } diff --git a/MC1/Views/Chats/Components/UnreadBadge.swift b/MC1/Views/Chats/Components/UnreadBadge.swift index d643e352..dbc74b73 100644 --- a/MC1/Views/Chats/Components/UnreadBadge.swift +++ b/MC1/Views/Chats/Components/UnreadBadge.swift @@ -4,21 +4,21 @@ import SwiftUI /// scroll-to-bottom (unread) and scroll-to-mention buttons; only the count and /// tint differ between them. struct UnreadBadge: View { - let count: Int - let tint: Color + let count: Int + let tint: Color - /// Counts above this render as an overflow glyph rather than the exact number. - static let badgeOverflowThreshold = 99 + /// Counts above this render as an overflow glyph rather than the exact number. + static let badgeOverflowThreshold = 99 - var body: some View { - if count > 0 { - Text(count > Self.badgeOverflowThreshold ? L10n.Chats.Chats.ScrollButton.Badge.overflow : "\(count)") - .font(.caption2.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(tint, in: .capsule) - .offset(x: 8, y: -8) - } + var body: some View { + if count > 0 { + Text(count > Self.badgeOverflowThreshold ? L10n.Chats.Chats.ScrollButton.Badge.overflow : "\(count)") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(tint, in: .capsule) + .offset(x: 8, y: -8) } + } } diff --git a/MC1/Views/Chats/ContactMatchRow.swift b/MC1/Views/Chats/ContactMatchRow.swift index 18a91992..b04a12a7 100644 --- a/MC1/Views/Chats/ContactMatchRow.swift +++ b/MC1/Views/Chats/ContactMatchRow.swift @@ -4,119 +4,119 @@ import SwiftUI /// Reusable row showing a contact matched by a channel sender's name. struct ContactMatchRow: View { - enum SelectionStyle { - case toggle(isSelected: Bool) - case tap - } + enum SelectionStyle { + case toggle(isSelected: Bool) + case tap + } + + let contact: ContactDTO + let style: SelectionStyle + let userLocation: CLLocation? + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: 12) { + leadingSelectionGlyph + + ContactAvatar(contact: contact, size: 40) + + VStack(alignment: .leading, spacing: 4) { + Text(contact.displayName) + .font(.body) + .bold() + .foregroundStyle(.primary) + + RelativeTimestampText(timestamp: contact.lastAdvertTimestamp) + + HStack(spacing: 4) { + Text(contactTypeLabel) + .font(.caption) + .foregroundStyle(.secondary) + + if contact.hasLocation { + Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") + .labelStyle(.iconOnly) + .font(.caption) + .foregroundStyle(.green) - let contact: ContactDTO - let style: SelectionStyle - let userLocation: CLLocation? - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(alignment: .top, spacing: 12) { - leadingSelectionGlyph - - ContactAvatar(contact: contact, size: 40) - - VStack(alignment: .leading, spacing: 4) { - Text(contact.displayName) - .font(.body) - .bold() - .foregroundStyle(.primary) - - RelativeTimestampText(timestamp: contact.lastAdvertTimestamp) - - HStack(spacing: 4) { - Text(contactTypeLabel) - .font(.caption) - .foregroundStyle(.secondary) - - if contact.hasLocation { - Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") - .labelStyle(.iconOnly) - .font(.caption) - .foregroundStyle(.green) - - if let distance = distanceText { - Text(distance) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - Text(L10n.Chats.Chats.ContactMatch.key(contact.publicKey.uppercaseHexString(separator: " "))) - .font(.caption) - .monospaced() - .foregroundStyle(.tertiary) - .lineLimit(2) - } - - trailingChevron + if let distance = distanceText { + Text(distance) + .font(.caption) + .foregroundStyle(.secondary) + } } - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityElement(children: .combine) - .accessibilityLabel(contact.displayName) - .accessibilityValue(accessibilityValue) - .accessibilityAddTraits(accessibilityTraits) - } + } - @ViewBuilder - private var leadingSelectionGlyph: some View { - if case .toggle(let isSelected) = style { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? .blue : .secondary) - .font(.title3) + Text(L10n.Chats.Chats.ContactMatch.key(contact.publicKey.uppercaseHexString(separator: " "))) + .font(.caption) + .monospaced() + .foregroundStyle(.tertiary) + .lineLimit(2) } - } - @ViewBuilder - private var trailingChevron: some View { - if case .tap = style { - Spacer(minLength: 0) - Image(systemName: "chevron.right") - .foregroundStyle(.tertiary) - .font(.caption) - } + trailingChevron + } + .contentShape(.rect) } - - private var accessibilityValue: String { - switch style { - case .toggle(let isSelected): - isSelected - ? L10n.Chats.Chats.ContactMatch.Accessibility.selected - : L10n.Chats.Chats.ContactMatch.Accessibility.notSelected - case .tap: - "" - } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel(contact.displayName) + .accessibilityValue(accessibilityValue) + .accessibilityAddTraits(accessibilityTraits) + } + + @ViewBuilder + private var leadingSelectionGlyph: some View { + if case let .toggle(isSelected) = style { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? .blue : .secondary) + .font(.title3) } - - private var accessibilityTraits: AccessibilityTraits { - switch style { - case .toggle: .isToggle - case .tap: .isButton - } + } + + @ViewBuilder + private var trailingChevron: some View { + if case .tap = style { + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + .font(.caption) } - - private var contactTypeLabel: String { - contact.type.localizedName + } + + private var accessibilityValue: String { + switch style { + case let .toggle(isSelected): + isSelected + ? L10n.Chats.Chats.ContactMatch.Accessibility.selected + : L10n.Chats.Chats.ContactMatch.Accessibility.notSelected + case .tap: + "" } + } - private var distanceText: String? { - guard let userLocation, contact.hasLocation else { return nil } - - let contactLocation = CLLocation( - latitude: contact.latitude, - longitude: contact.longitude - ) - let meters = userLocation.distance(from: contactLocation) - let measurement = Measurement(value: meters, unit: UnitLength.meters) - let formatted = measurement.formatted(.measurement(width: .abbreviated, usage: .road)) - return L10n.Contacts.Contacts.Row.away(formatted) + private var accessibilityTraits: AccessibilityTraits { + switch style { + case .toggle: .isToggle + case .tap: .isButton } + } + + private var contactTypeLabel: String { + contact.type.localizedName + } + + private var distanceText: String? { + guard let userLocation, contact.hasLocation else { return nil } + + let contactLocation = CLLocation( + latitude: contact.latitude, + longitude: contact.longitude + ) + let meters = userLocation.distance(from: contactLocation) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + let formatted = measurement.formatted(.measurement(width: .abbreviated, usage: .road)) + return L10n.Contacts.Contacts.Row.away(formatted) + } } diff --git a/MC1/Views/Chats/ConversationActionError.swift b/MC1/Views/Chats/ConversationActionError.swift index c8688534..aa22fb6f 100644 --- a/MC1/Views/Chats/ConversationActionError.swift +++ b/MC1/Views/Chats/ConversationActionError.swift @@ -4,11 +4,11 @@ import Foundation /// message routes through the shared `.errorAlert`. Only the connection precondition lives here; /// radio-command timeouts surface `MC1Services.withTimeout`'s `TimeoutError` directly. enum ConversationActionError: LocalizedError { - case notConnected + case notConnected - var errorDescription: String? { - switch self { - case .notConnected: L10n.Chats.Chats.Error.notConnectedToDelete - } + var errorDescription: String? { + switch self { + case .notConnected: L10n.Chats.Chats.Error.notConnectedToDelete } + } } diff --git a/MC1/Views/Chats/ConversationContextMenuModifier.swift b/MC1/Views/Chats/ConversationContextMenuModifier.swift index a102fb02..c80c1adc 100644 --- a/MC1/Views/Chats/ConversationContextMenuModifier.swift +++ b/MC1/Views/Chats/ConversationContextMenuModifier.swift @@ -3,67 +3,67 @@ import SwiftUI /// Long-press context-menu actions for a conversation row: delete, mute/unmute, favorite/unfavorite. /// Delete is gated while a removal is in flight so a rapid re-press can't double-fire. struct ConversationContextMenuModifier: ViewModifier { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let conversation: Conversation - let viewModel: ChatViewModel - let onDelete: () -> Void + let conversation: Conversation + let viewModel: ChatViewModel + let onDelete: () -> Void - private var isConnected: Bool { - appState.connectionState == .ready - } + private var isConnected: Bool { + appState.connectionState == .ready + } - private var isTogglingFavorite: Bool { - guard case .direct(let contact) = conversation else { return false } - return viewModel.togglingFavoriteID == contact.id - } + private var isTogglingFavorite: Bool { + guard case let .direct(contact) = conversation else { return false } + return viewModel.togglingFavoriteID == contact.id + } - func body(content: Content) -> some View { - content.contextMenu { - Button(role: .destructive) { - onDelete() - } label: { - Label(L10n.Chats.Chats.Action.delete, systemImage: "trash") - } - .disabled(!isConnected || viewModel.isDeletePending(conversation.id)) + func body(content: Content) -> some View { + content.contextMenu { + Button(role: .destructive) { + onDelete() + } label: { + Label(L10n.Chats.Chats.Action.delete, systemImage: "trash") + } + .disabled(!isConnected || viewModel.isDeletePending(conversation.id)) - Button { - Task { - await viewModel.toggleMute(conversation) - } - } label: { - Label( - conversation.isMuted ? L10n.Chats.Chats.Action.unmute : L10n.Chats.Chats.Action.mute, - systemImage: conversation.isMuted ? "bell" : "bell.slash" - ) - } - .disabled(!isConnected) + Button { + Task { + await viewModel.toggleMute(conversation) + } + } label: { + Label( + conversation.isMuted ? L10n.Chats.Chats.Action.unmute : L10n.Chats.Chats.Action.mute, + systemImage: conversation.isMuted ? "bell" : "bell.slash" + ) + } + .disabled(!isConnected) - Button { - Task { - await viewModel.toggleFavorite(conversation, disableAnimation: true) - } - } label: { - Label( - conversation.isFavorite ? L10n.Chats.Chats.Action.unfavorite : L10n.Chats.Chats.Action.favorite, - systemImage: conversation.isFavorite ? "star.slash" : "star.fill" - ) - } - .disabled(!isConnected || isTogglingFavorite) + Button { + Task { + await viewModel.toggleFavorite(conversation, disableAnimation: true) } + } label: { + Label( + conversation.isFavorite ? L10n.Chats.Chats.Action.unfavorite : L10n.Chats.Chats.Action.favorite, + systemImage: conversation.isFavorite ? "star.slash" : "star.fill" + ) + } + .disabled(!isConnected || isTogglingFavorite) } + } } extension View { - func conversationContextMenu( - conversation: Conversation, - viewModel: ChatViewModel, - onDelete: @escaping () -> Void - ) -> some View { - modifier(ConversationContextMenuModifier( - conversation: conversation, - viewModel: viewModel, - onDelete: onDelete - )) - } + func conversationContextMenu( + conversation: Conversation, + viewModel: ChatViewModel, + onDelete: @escaping () -> Void + ) -> some View { + modifier(ConversationContextMenuModifier( + conversation: conversation, + viewModel: viewModel, + onDelete: onDelete + )) + } } diff --git a/MC1/Views/Chats/ConversationListContent.swift b/MC1/Views/Chats/ConversationListContent.swift index 92b9137b..701f671f 100644 --- a/MC1/Views/Chats/ConversationListContent.swift +++ b/MC1/Views/Chats/ConversationListContent.swift @@ -1,253 +1,257 @@ -import SwiftUI import MC1Services +import SwiftUI /// The conversation list rendered as a `ScrollView` + `LazyVStack` rather than a `List`. /// `List` is backed by `UpdateCoalescingCollectionView`, whose batch-consistency assertion /// is violated when the selected row is deleted; a `LazyVStack` has no collection view, so /// that crash cannot occur. Row actions live in a `.contextMenu`. struct ConversationListContent: View { - enum ListMode { - case selection(Binding) - case navigation(onNavigate: (ChatRoute) -> Void, onRequestRoomAuth: (RemoteNodeSessionDTO) -> Void) - } + enum ListMode { + case selection(Binding) + case navigation(onNavigate: (ChatRoute) -> Void, onRequestRoomAuth: (RemoteNodeSessionDTO) -> Void) + } - @Environment(\.appTheme) private var theme - - private let viewModel: ChatViewModel - private let favoriteConversations: [Conversation] - private let otherConversations: [Conversation] - private let mode: ListMode - private let hasLoadedOnce: Bool - private let emptyStateMessage: (title: String, description: String, systemImage: String) - private let onDeleteConversation: (Conversation) -> Void - @Binding private var selectedFilter: ChatFilter - - /// Leading inset for the inter-row divider, aligning it under the row text past the avatar - /// (row horizontal padding 16 + avatar 44 + avatar-to-text spacing 12). - private static let rowSeparatorLeadingInset: CGFloat = 72 - - init( - viewModel: ChatViewModel, - favoriteConversations: [Conversation], - otherConversations: [Conversation], - selectedFilter: Binding, - hasLoadedOnce: Bool, - emptyStateMessage: (title: String, description: String, systemImage: String), - selection: Binding, - onDeleteConversation: @escaping (Conversation) -> Void - ) { - self.viewModel = viewModel - self.favoriteConversations = favoriteConversations - self.otherConversations = otherConversations - self._selectedFilter = selectedFilter - self.hasLoadedOnce = hasLoadedOnce - self.emptyStateMessage = emptyStateMessage - self.mode = .selection(selection) - self.onDeleteConversation = onDeleteConversation - } + @Environment(\.appTheme) private var theme - init( - viewModel: ChatViewModel, - favoriteConversations: [Conversation], - otherConversations: [Conversation], - selectedFilter: Binding, - hasLoadedOnce: Bool, - emptyStateMessage: (title: String, description: String, systemImage: String), - onNavigate: @escaping (ChatRoute) -> Void, - onRequestRoomAuth: @escaping (RemoteNodeSessionDTO) -> Void, - onDeleteConversation: @escaping (Conversation) -> Void - ) { - self.viewModel = viewModel - self.favoriteConversations = favoriteConversations - self.otherConversations = otherConversations - self._selectedFilter = selectedFilter - self.hasLoadedOnce = hasLoadedOnce - self.emptyStateMessage = emptyStateMessage - self.mode = .navigation(onNavigate: onNavigate, onRequestRoomAuth: onRequestRoomAuth) - self.onDeleteConversation = onDeleteConversation - } + private let viewModel: ChatViewModel + private let favoriteConversations: [Conversation] + private let otherConversations: [Conversation] + private let mode: ListMode + private let hasLoadedOnce: Bool + private let emptyStateMessage: (title: String, description: String, systemImage: String) + private let onDeleteConversation: (Conversation) -> Void + @Binding private var selectedFilter: ChatFilter + + /// Leading inset for the inter-row divider, aligning it under the row text past the avatar + /// (row horizontal padding 16 + avatar 44 + avatar-to-text spacing 12). + private static let rowSeparatorLeadingInset: CGFloat = 72 - var body: some View { - Group { - if !hasLoadedOnce { - loadingBody - } else { - TimelineView(.everyMinute) { context in - loadedBody(referenceDate: context.date) - } - } + init( + viewModel: ChatViewModel, + favoriteConversations: [Conversation], + otherConversations: [Conversation], + selectedFilter: Binding, + hasLoadedOnce: Bool, + emptyStateMessage: (title: String, description: String, systemImage: String), + selection: Binding, + onDeleteConversation: @escaping (Conversation) -> Void + ) { + self.viewModel = viewModel + self.favoriteConversations = favoriteConversations + self.otherConversations = otherConversations + _selectedFilter = selectedFilter + self.hasLoadedOnce = hasLoadedOnce + self.emptyStateMessage = emptyStateMessage + mode = .selection(selection) + self.onDeleteConversation = onDeleteConversation + } + + init( + viewModel: ChatViewModel, + favoriteConversations: [Conversation], + otherConversations: [Conversation], + selectedFilter: Binding, + hasLoadedOnce: Bool, + emptyStateMessage: (title: String, description: String, systemImage: String), + onNavigate: @escaping (ChatRoute) -> Void, + onRequestRoomAuth: @escaping (RemoteNodeSessionDTO) -> Void, + onDeleteConversation: @escaping (Conversation) -> Void + ) { + self.viewModel = viewModel + self.favoriteConversations = favoriteConversations + self.otherConversations = otherConversations + _selectedFilter = selectedFilter + self.hasLoadedOnce = hasLoadedOnce + self.emptyStateMessage = emptyStateMessage + mode = .navigation(onNavigate: onNavigate, onRequestRoomAuth: onRequestRoomAuth) + self.onDeleteConversation = onDeleteConversation + } + + var body: some View { + Group { + if !hasLoadedOnce { + loadingBody + } else { + TimelineView(.everyMinute) { context in + loadedBody(referenceDate: context.date) } + } } + } - private var loadingBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { } header: { pinnedFilterHeader } - } - } - .overlay { ProgressView() } + private var loadingBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section {} header: { pinnedFilterHeader } + } } + .overlay { ProgressView() } + } - private func loadedBody(referenceDate: Date) -> some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - if hasNoConversations { - emptyState - } else { - rows(referenceDate: referenceDate) - } - } header: { - pinnedFilterHeader - } - } + private func loadedBody(referenceDate: Date) -> some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + if hasNoConversations { + emptyState + } else { + rows(referenceDate: referenceDate) + } + } header: { + pinnedFilterHeader } + } } + } - /// Filter bar as the pinned section header; `pinnedFilterHeaderBackground` documents the - /// per-OS backing. - private var pinnedFilterHeader: some View { - ChatFilterPicker(selection: $selectedFilter) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) - } + /// Filter bar as the pinned section header; `pinnedFilterHeaderBackground` documents the + /// per-OS backing. + private var pinnedFilterHeader: some View { + ChatFilterPicker(selection: $selectedFilter) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } - private var hasNoConversations: Bool { - favoriteConversations.isEmpty && otherConversations.isEmpty - } + private var hasNoConversations: Bool { + favoriteConversations.isEmpty && otherConversations.isEmpty + } - private var emptyState: some View { - ContentUnavailableView { - Label(emptyStateMessage.title, systemImage: emptyStateMessage.systemImage) - } description: { - Text(emptyStateMessage.description) - } actions: { - if selectedFilter != .all { - Button(L10n.Chats.Chats.Filter.clear) { - selectedFilter = .all - } - } + private var emptyState: some View { + ContentUnavailableView { + Label(emptyStateMessage.title, systemImage: emptyStateMessage.systemImage) + } description: { + Text(emptyStateMessage.description) + } actions: { + if selectedFilter != .all { + Button(L10n.Chats.Chats.Filter.clear) { + selectedFilter = .all } - .containerRelativeFrame([.horizontal, .vertical]) + } } + .containerRelativeFrame([.horizontal, .vertical]) + } - /// One unified section, favorites first by concatenation, with an inset divider between rows. - private func rows(referenceDate: Date) -> some View { - let ordered = favoriteConversations + otherConversations - return ForEach(Array(ordered.enumerated()), id: \.element.id) { index, conversation in - rowView(conversation, referenceDate: referenceDate) - .transition(.opacity) - if index < ordered.count - 1 { - Divider().padding(.leading, Self.rowSeparatorLeadingInset) - } - } + /// One unified section, favorites first by concatenation, with an inset divider between rows. + private func rows(referenceDate: Date) -> some View { + let ordered = favoriteConversations + otherConversations + return ForEach(Array(ordered.enumerated()), id: \.element.id) { index, conversation in + rowView(conversation, referenceDate: referenceDate) + .transition(.opacity) + if index < ordered.count - 1 { + Divider().padding(.leading, Self.rowSeparatorLeadingInset) + } } + } - @ViewBuilder - private func rowView(_ conversation: Conversation, referenceDate: Date) -> some View { - switch mode { - case .selection(let selection): - ConversationSelectionRow( - conversation: conversation, - viewModel: viewModel, - referenceDate: referenceDate, - isSelected: selection.wrappedValue == ChatRoute(conversation: conversation), - onSelect: { selection.wrappedValue = ChatRoute(conversation: conversation) }, - onDelete: { onDeleteConversation(conversation) } - ) - case .navigation(let onNavigate, let onRequestRoomAuth): - ConversationNavigationRow( - conversation: conversation, - viewModel: viewModel, - referenceDate: referenceDate, - onNavigate: onNavigate, - onRequestRoomAuth: onRequestRoomAuth, - onDelete: { onDeleteConversation(conversation) } - ) - } + @ViewBuilder + private func rowView(_ conversation: Conversation, referenceDate: Date) -> some View { + switch mode { + case let .selection(selection): + ConversationSelectionRow( + conversation: conversation, + viewModel: viewModel, + referenceDate: referenceDate, + isSelected: selection.wrappedValue == ChatRoute(conversation: conversation), + onSelect: { selection.wrappedValue = ChatRoute(conversation: conversation) }, + onDelete: { onDeleteConversation(conversation) } + ) + case let .navigation(onNavigate, onRequestRoomAuth): + ConversationNavigationRow( + conversation: conversation, + viewModel: viewModel, + referenceDate: referenceDate, + onNavigate: onNavigate, + onRequestRoomAuth: onRequestRoomAuth, + onDelete: { onDeleteConversation(conversation) } + ) } + } } // MARK: - Row Layout private enum ConversationRowLayout { - static let horizontalPadding: CGFloat = 16 - static let verticalPadding: CGFloat = 6 + static let horizontalPadding: CGFloat = 16 + static let verticalPadding: CGFloat = 6 } /// Renders a conversation's row body, shared by the selection and navigation rows. private struct ConversationRowLabel: View { - let conversation: Conversation - let viewModel: ChatViewModel - let referenceDate: Date - - var body: some View { - Group { - switch conversation { - case .direct(let contact): - ConversationRow(contact: contact, viewModel: viewModel, referenceDate: referenceDate) - case .channel(let channel): - ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: referenceDate) - case .room(let session): - RoomConversationRow(session: session, referenceDate: referenceDate) - } - } - .padding(.horizontal, ConversationRowLayout.horizontalPadding) - .padding(.vertical, ConversationRowLayout.verticalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) + let conversation: Conversation + let viewModel: ChatViewModel + let referenceDate: Date + + var body: some View { + Group { + switch conversation { + case let .direct(contact): + ConversationRow(contact: contact, viewModel: viewModel, referenceDate: referenceDate) + case let .channel(channel): + ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: referenceDate) + case let .room(session): + RoomConversationRow(session: session, referenceDate: referenceDate) + } } + .padding(.horizontal, ConversationRowLayout.horizontalPadding) + .padding(.vertical, ConversationRowLayout.verticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } } // MARK: - Extracted Rows private struct ConversationSelectionRow: View { - let conversation: Conversation - let viewModel: ChatViewModel - let referenceDate: Date - let isSelected: Bool - let onSelect: () -> Void - let onDelete: () -> Void - - private var isDeleting: Bool { viewModel.deletingIDs.contains(conversation.id) } - - var body: some View { - Button(action: onSelect) { - ConversationRowLabel(conversation: conversation, viewModel: viewModel, referenceDate: referenceDate) - } - .buttonStyle(.plain) - .selectedRowHighlight(isSelected: isSelected) - .accessibilityAddTraits(isSelected ? .isSelected : []) - .deletingRowOverlay(isDeleting: isDeleting) - .conversationContextMenu(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + let conversation: Conversation + let viewModel: ChatViewModel + let referenceDate: Date + let isSelected: Bool + let onSelect: () -> Void + let onDelete: () -> Void + + private var isDeleting: Bool { + viewModel.deletingIDs.contains(conversation.id) + } + + var body: some View { + Button(action: onSelect) { + ConversationRowLabel(conversation: conversation, viewModel: viewModel, referenceDate: referenceDate) } + .buttonStyle(.plain) + .selectedRowHighlight(isSelected: isSelected) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .deletingRowOverlay(isDeleting: isDeleting) + .conversationContextMenu(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + } } private struct ConversationNavigationRow: View { - let conversation: Conversation - let viewModel: ChatViewModel - let referenceDate: Date - let onNavigate: (ChatRoute) -> Void - let onRequestRoomAuth: (RemoteNodeSessionDTO) -> Void - let onDelete: () -> Void - - private var isDeleting: Bool { viewModel.deletingIDs.contains(conversation.id) } - - var body: some View { - Button(action: tap) { - ConversationRowLabel(conversation: conversation, viewModel: viewModel, referenceDate: referenceDate) - } - .buttonStyle(.plain) - .deletingRowOverlay(isDeleting: isDeleting) - .conversationContextMenu(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + let conversation: Conversation + let viewModel: ChatViewModel + let referenceDate: Date + let onNavigate: (ChatRoute) -> Void + let onRequestRoomAuth: (RemoteNodeSessionDTO) -> Void + let onDelete: () -> Void + + private var isDeleting: Bool { + viewModel.deletingIDs.contains(conversation.id) + } + + var body: some View { + Button(action: tap) { + ConversationRowLabel(conversation: conversation, viewModel: viewModel, referenceDate: referenceDate) } + .buttonStyle(.plain) + .deletingRowOverlay(isDeleting: isDeleting) + .conversationContextMenu(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + } - private func tap() { - let route = ChatRoute(conversation: conversation) - if case .room(let session) = conversation, !session.isConnected { - onRequestRoomAuth(session) - } else { - onNavigate(route) - } + private func tap() { + let route = ChatRoute(conversation: conversation) + if case let .room(session) = conversation, !session.isConnected { + onRequestRoomAuth(session) + } else { + onNavigate(route) } + } } diff --git a/MC1/Views/Chats/ConversationRow.swift b/MC1/Views/Chats/ConversationRow.swift index fae0cfae..028e5132 100644 --- a/MC1/Views/Chats/ConversationRow.swift +++ b/MC1/Views/Chats/ConversationRow.swift @@ -1,56 +1,56 @@ -import SwiftUI import MC1Services +import SwiftUI struct ConversationRow: View { - let contact: ContactDTO - let viewModel: ChatViewModel - var referenceDate: Date? - - var body: some View { - HStack(spacing: 12) { - ContactAvatar(contact: contact, size: 44) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text(contact.displayName) - .font(.headline) - .lineLimit(1) - - Spacer() - - NotificationLevelIndicator(level: contact.isMuted ? .muted : .all) - - if viewModel.togglingFavoriteID == contact.id { - ProgressView() - .controlSize(.small) - } else if contact.isFavorite { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) - .font(.caption) - .accessibilityLabel(L10n.Chats.Chats.Row.favorite) - } - - if let date = contact.lastMessageDate { - ConversationTimestamp(date: date, referenceDate: referenceDate) - } - } - - HStack { - Text(viewModel.lastMessagePreview(id: contact.id) ?? L10n.Chats.Chats.Row.noMessages) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer() - - UnreadBadges( - unreadCount: contact.unreadCount, - unreadMentionCount: contact.unreadMentionCount, - notificationLevel: contact.isMuted ? .muted : .all - ) - } - } + let contact: ContactDTO + let viewModel: ChatViewModel + var referenceDate: Date? + + var body: some View { + HStack(spacing: 12) { + ContactAvatar(contact: contact, size: 44) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(contact.displayName) + .font(.headline) + .lineLimit(1) + + Spacer() + + NotificationLevelIndicator(level: contact.isMuted ? .muted : .all) + + if viewModel.togglingFavoriteID == contact.id { + ProgressView() + .controlSize(.small) + } else if contact.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .font(.caption) + .accessibilityLabel(L10n.Chats.Chats.Row.favorite) + } + + if let date = contact.lastMessageDate { + ConversationTimestamp(date: date, referenceDate: referenceDate) + } + } + + HStack { + Text(viewModel.lastMessagePreview(id: contact.id) ?? L10n.Chats.Chats.Row.noMessages) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + UnreadBadges( + unreadCount: contact.unreadCount, + unreadMentionCount: contact.unreadMentionCount, + notificationLevel: contact.isMuted ? .muted : .all + ) } - .padding(.vertical, 4) + } } + .padding(.vertical, 4) + } } diff --git a/MC1/Views/Chats/ConversationSnapshot.swift b/MC1/Views/Chats/ConversationSnapshot.swift index fb807750..7db68ed3 100644 --- a/MC1/Views/Chats/ConversationSnapshot.swift +++ b/MC1/Views/Chats/ConversationSnapshot.swift @@ -3,9 +3,9 @@ import Foundation /// Complete, internally consistent favorite/other split of the conversation list. /// Committed as a single observed value so the list can never diff favorites from /// one fetch generation against others from another. -struct ConversationSnapshot: Equatable, Sendable { - var favorites: [Conversation] - var others: [Conversation] +struct ConversationSnapshot: Equatable { + var favorites: [Conversation] + var others: [Conversation] - static let empty = ConversationSnapshot(favorites: [], others: []) + static let empty = ConversationSnapshot(favorites: [], others: []) } diff --git a/MC1/Views/Chats/CreatePrivateChannelView.swift b/MC1/Views/Chats/CreatePrivateChannelView.swift index 7fa281f3..9cfdee1c 100644 --- a/MC1/Views/Chats/CreatePrivateChannelView.swift +++ b/MC1/Views/Chats/CreatePrivateChannelView.swift @@ -1,264 +1,253 @@ -import SwiftUI import MC1Services -import CoreImage.CIFilterBuiltins +import SwiftUI /// View for creating a private channel with auto-generated secret and QR code struct CreatePrivateChannelView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let availableSlots: [UInt8] - let onComplete: (ChannelDTO?) -> Void - - @State private var channelName = "" - @State private var selectedSlot: UInt8 - @State private var generatedSecret: Data? - @State private var isCreating = false - @State private var createdChannel: ChannelDTO? - @State private var errorMessage: String? - @State private var copyHapticTrigger = 0 - - private var isCreated: Bool { createdChannel != nil } - - init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { - self.availableSlots = availableSlots - self.onComplete = onComplete - self._selectedSlot = State(initialValue: availableSlots.first ?? 1) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + let availableSlots: [UInt8] + let onComplete: (ChannelDTO?) -> Void + + @State private var channelName = "" + @State private var selectedSlot: UInt8 + @State private var generatedSecret: Data? + @State private var isCreating = false + @State private var createdChannel: ChannelDTO? + @State private var errorMessage: String? + @State private var copyHapticTrigger = 0 + + private var isCreated: Bool { + createdChannel != nil + } + + init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { + self.availableSlots = availableSlots + self.onComplete = onComplete + _selectedSlot = State(initialValue: availableSlots.first ?? 1) + } + + var body: some View { + Form { + if !isCreated { + CreateChannelFormContent( + channelName: $channelName, + generatedSecret: generatedSecret, + isCreating: isCreating, + errorMessage: errorMessage, + onCreate: { Task { await createChannel() } } + ) + } else { + ShareChannelContent( + channelName: channelName, + generatedSecret: generatedSecret, + copyHapticTrigger: $copyHapticTrigger + ) + } } - - var body: some View { - Form { - if !isCreated { - CreateChannelFormContent( - channelName: $channelName, - generatedSecret: generatedSecret, - isCreating: isCreating, - errorMessage: errorMessage, - onCreate: { Task { await createChannel() } } - ) - } else { - ShareChannelContent( - channelName: channelName, - generatedSecret: generatedSecret, - copyHapticTrigger: $copyHapticTrigger - ) - } - } - .themedCanvas(theme) - .navigationTitle(isCreated ? L10n.Chats.Chats.CreatePrivate.titleShare : L10n.Chats.Chats.CreatePrivate.titleCreate) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if isCreated { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Chats.Chats.Common.done) { - onComplete(createdChannel) - } - } - } + .themedCanvas(theme) + .navigationTitle(isCreated ? L10n.Chats.Chats.CreatePrivate.titleShare : L10n.Chats.Chats.CreatePrivate.titleCreate) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if isCreated { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Chats.Chats.Common.done) { + onComplete(createdChannel) + } } - .onAppear { - generateSecret() - } - .sensoryFeedback(.success, trigger: copyHapticTrigger) + } } - - // MARK: - Private Methods - - private func generateSecret() { - var bytes = [UInt8](repeating: 0, count: 16) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - generatedSecret = Data(bytes) + .onAppear { + generateSecret() + } + .sensoryFeedback(.success, trigger: copyHapticTrigger) + } + + // MARK: - Private Methods + + private func generateSecret() { + var bytes = [UInt8](repeating: 0, count: 16) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + generatedSecret = Data(bytes) + } + + private func createChannel() async { + guard let radioID = appState.connectedDevice?.radioID, + let secret = generatedSecret else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return } - private func createChannel() async { - guard let radioID = appState.connectedDevice?.radioID, - let secret = generatedSecret else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } - - isCreating = true - defer { isCreating = false } - errorMessage = nil - - do { - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } - try await channelService.setChannelWithSecret( - radioID: radioID, - index: selectedSlot, - name: channelName, - secret: secret - ) - - // Fetch the created channel to return it - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - createdChannel = channels.first { $0.index == selectedSlot } - } - } catch { - errorMessage = error.userFacingMessage - } + isCreating = true + defer { isCreating = false } + errorMessage = nil + + do { + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + try await channelService.setChannelWithSecret( + radioID: radioID, + index: selectedSlot, + name: channelName, + secret: secret + ) + + // Fetch the created channel to return it + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + createdChannel = channels.first { $0.index == selectedSlot } + } + } catch { + errorMessage = error.userFacingMessage } + } } // MARK: - Extracted Views private struct CreateChannelFormContent: View { - @Environment(\.appTheme) private var theme - @Binding var channelName: String - let generatedSecret: Data? - let isCreating: Bool - let errorMessage: String? - let onCreate: () -> Void - - var body: some View { - Group { - Section { - TextField(L10n.Chats.Chats.CreatePrivate.channelName, text: $channelName) - .textContentType(.name) - .onChange(of: channelName) { _, newValue in - if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { - channelName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - } - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.details) - } - .themedRowBackground(theme) - - Section { - if let secret = generatedSecret { - LabeledContent(L10n.Chats.Chats.ChannelInfo.secretKey) { - Text(secret.uppercaseHexString()) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.secret) - } footer: { - Text(L10n.Chats.Chats.CreatePrivate.secretFooter) + @Environment(\.appTheme) private var theme + @Binding var channelName: String + let generatedSecret: Data? + let isCreating: Bool + let errorMessage: String? + let onCreate: () -> Void + + var body: some View { + Group { + Section { + TextField(L10n.Chats.Chats.CreatePrivate.channelName, text: $channelName) + .textContentType(.name) + .onChange(of: channelName) { _, newValue in + if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { + channelName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) } - .themedRowBackground(theme) - - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } - - Section { - Button(action: onCreate) { - HStack { - Spacer() - if isCreating { - ProgressView() - } else { - Text(L10n.Chats.Chats.CreatePrivate.createButton) - } - Spacer() - } - } - .disabled(channelName.isEmpty || isCreating || generatedSecret == nil) + } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.details) + } + .themedRowBackground(theme) + + Section { + if let secret = generatedSecret { + LabeledContent(L10n.Chats.Chats.ChannelInfo.secretKey) { + Text(secret.uppercaseHexString()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.secret) + } footer: { + Text(L10n.Chats.Chats.CreatePrivate.secretFooter) + } + .themedRowBackground(theme) + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } + + Section { + Button(action: onCreate) { + HStack { + Spacer() + if isCreating { + ProgressView() + } else { + Text(L10n.Chats.Chats.CreatePrivate.createButton) } - .themedRowBackground(theme) + Spacer() + } } + .disabled(channelName.isEmpty || isCreating || generatedSecret == nil) + } + .themedRowBackground(theme) } + } } private struct ShareChannelContent: View { - @Environment(\.appTheme) private var theme - let channelName: String - let generatedSecret: Data? - @Binding var copyHapticTrigger: Int - - var body: some View { - Group { - Section { - HStack { - Spacer() - VStack(spacing: 16) { - if let qrImage = generateQRCode() { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .scaledToFit() - .frame(width: 200, height: 200) - } - - Text(channelName) - .font(.headline) - - Text(L10n.Chats.Chats.ChannelInfo.scanToJoin) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - Spacer() - } + @Environment(\.appTheme) private var theme + let channelName: String + let generatedSecret: Data? + @Binding var copyHapticTrigger: Int + + var body: some View { + Group { + Section { + HStack { + Spacer() + VStack(spacing: 16) { + if let qrImage = generateQRCode() { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .foregroundStyle(.primary) + .frame(width: 200, height: 200) } - .themedRowBackground(theme) - - Section { - if let secret = generatedSecret { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Chats.Chats.ChannelInfo.secretKey) - .font(.caption) - .foregroundStyle(.secondary) - - HStack { - Text(secret.uppercaseHexString()) - .font(.system(.body, design: .monospaced)) - Spacer() + Text(channelName) + .font(.headline) - Button(L10n.Chats.Chats.ChannelInfo.copy, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = secret.uppercaseHexString() - } - .labelStyle(.iconOnly) - } - } - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.shareManually) - } footer: { - Text(L10n.Chats.Chats.CreatePrivate.shareManuallyFooter) + Text(L10n.Chats.Chats.ChannelInfo.scanToJoin) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + Spacer() + } + } + .themedRowBackground(theme) + + Section { + if let secret = generatedSecret { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Chats.Chats.ChannelInfo.secretKey) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Text(secret.uppercaseHexString()) + .font(.system(.body, design: .monospaced)) + + Spacer() + + Button(L10n.Chats.Chats.ChannelInfo.copy, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = secret.uppercaseHexString() + } + .labelStyle(.iconOnly) } - .themedRowBackground(theme) + } } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.shareManually) + } footer: { + Text(L10n.Chats.Chats.CreatePrivate.shareManuallyFooter) + } + .themedRowBackground(theme) } + } - private func generateQRCode() -> UIImage? { - guard let secret = generatedSecret else { return nil } - - // Format: meshcore://channel/add?name=&secret= - let urlString = "meshcore://channel/add?name=\(channelName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")&secret=\(secret.uppercaseHexString())" + private func generateQRCode() -> UIImage? { + guard let secret = generatedSecret else { return nil } - let context = CIContext() - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(urlString.utf8) - filter.correctionLevel = "M" + // Format: meshcore://channel/add?name=&secret= + let urlString = "meshcore://channel/add?name=\(channelName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")&secret=\(secret.uppercaseHexString())" - guard let outputImage = filter.outputImage else { return nil } - - // Scale up for better quality - let scale = 10.0 - let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) - - guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } - - return UIImage(cgImage: cgImage) - } + return QRCodeGenerator.generate(from: urlString) + } } #Preview { - NavigationStack { - CreatePrivateChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + CreatePrivateChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/DecodedPreviewAssets.swift b/MC1/Views/Chats/DecodedPreviewAssets.swift index f4f135cb..f7f0bd64 100644 --- a/MC1/Views/Chats/DecodedPreviewAssets.swift +++ b/MC1/Views/Chats/DecodedPreviewAssets.swift @@ -2,6 +2,6 @@ import UIKit /// Decoded preview hero image and icon, stored together to batch Observable notifications struct DecodedPreviewAssets { - var image: UIImage? - var icon: UIImage? + var image: UIImage? + var icon: UIImage? } diff --git a/MC1/Views/Chats/HashtagDeeplinkSupport.swift b/MC1/Views/Chats/HashtagDeeplinkSupport.swift index 1ec55b9e..0a061989 100644 --- a/MC1/Views/Chats/HashtagDeeplinkSupport.swift +++ b/MC1/Views/Chats/HashtagDeeplinkSupport.swift @@ -2,33 +2,33 @@ import Foundation import MC1Services struct HashtagJoinRequest: Identifiable, Hashable { - let id: String + let id: String } enum HashtagDeeplinkSupport { - static let scheme = "meshcoreone" - static let host = "hashtag" + static let scheme = "meshcoreone" + static let host = "hashtag" - static func channelNameFromURL(_ url: URL) -> String? { - guard url.scheme == scheme, url.host == host else { return nil } - let path = url.pathComponents.dropFirst() // drop leading "/" - return path.first - } + static func channelNameFromURL(_ url: URL) -> String? { + guard url.scheme == scheme, url.host == host else { return nil } + let path = url.pathComponents.dropFirst() // drop leading "/" + return path.first + } - static func fullChannelName(from rawName: String) -> String? { - let normalizedName = HashtagUtilities.normalizeHashtagName(rawName) - guard HashtagUtilities.isValidHashtagName(normalizedName) else { return nil } - return "#\(normalizedName)" - } + static func fullChannelName(from rawName: String) -> String? { + let normalizedName = HashtagUtilities.normalizeHashtagName(rawName) + guard HashtagUtilities.isValidHashtagName(normalizedName) else { return nil } + return "#\(normalizedName)" + } - static func findChannelByName( - _ name: String, - radioID: UUID, - fetchChannels: @Sendable (UUID) async throws -> [ChannelDTO] - ) async throws -> ChannelDTO? { - let channels = try await fetchChannels(radioID) - return channels.first(where: { channel in - channel.name.localizedCaseInsensitiveCompare(name) == .orderedSame - }) - } + static func findChannelByName( + _ name: String, + radioID: UUID, + fetchChannels: @Sendable (UUID) async throws -> [ChannelDTO] + ) async throws -> ChannelDTO? { + let channels = try await fetchChannels(radioID) + return channels.first(where: { channel in + channel.name.localizedCaseInsensitiveCompare(name) == .orderedSame + }) + } } diff --git a/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift b/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift index 0c27fbfd..27922dd9 100644 --- a/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift +++ b/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift @@ -11,88 +11,89 @@ import SwiftUI /// `item` seam, so it is never rebuilt while scrolling; and SwiftUI realizes the action descriptors /// only when an assistive technology focuses the element, so they cost nothing when VoiceOver is off. enum MessageLinkAccessibility { - - /// One openable link surfaced as a VoiceOver custom action. - struct Action: Identifiable { - let name: String - let url: URL - var id: URL { url } + /// One openable link surfaced as a VoiceOver custom action. + struct Action: Identifiable { + let name: String + let url: URL + var id: URL { + url } + } - /// Upper bound on the actions a single message contributes, so a message packed with links - /// cannot flood the rotor. Links past the cap stay visible but are not separately activatable. - static let maxActions = 8 + /// Upper bound on the actions a single message contributes, so a message packed with links + /// cannot flood the rotor. Links past the cap stay visible but are not separately activatable. + static let maxActions = 8 - /// Openable links in document order: the preview card's URL first (if any), then each linked - /// run in the body text. Deduplicated by URL and capped at `maxActions`. - static func actions(previewURL: URL?, formatted: AttributedString?) -> [Action] { - var seen: Set = [] - var actions: [Action] = [] + /// Openable links in document order: the preview card's URL first (if any), then each linked + /// run in the body text. Deduplicated by URL and capped at `maxActions`. + static func actions(previewURL: URL?, formatted: AttributedString?) -> [Action] { + var seen: Set = [] + var actions: [Action] = [] - func append(_ url: URL) { - guard actions.count < maxActions, seen.insert(url).inserted else { return } - actions.append(Action(name: name(for: url), url: url)) - } + func append(_ url: URL) { + guard actions.count < maxActions, seen.insert(url).inserted else { return } + actions.append(Action(name: name(for: url), url: url)) + } - if let previewURL { - append(previewURL) - } - if let formatted { - for run in formatted.runs { - guard let url = run.link else { continue } - append(url) - } - } - return actions + if let previewURL { + append(previewURL) } + if let formatted { + for run in formatted.runs { + guard let url = run.link else { continue } + append(url) + } + } + return actions + } - /// A localized, target-bearing action name so a VoiceOver user can tell two links apart. Reuses - /// the same parsers the tap router does, and falls back to the generic "Open Link" when a kind - /// exposes no readable target. - private static func name(for url: URL) -> String { - let openLink = L10n.Chats.Chats.Message.Action.openLink + /// A localized, target-bearing action name so a VoiceOver user can tell two links apart. Reuses + /// the same parsers the tap router does, and falls back to the generic "Open Link" when a kind + /// exposes no readable target. + private static func name(for url: URL) -> String { + let openLink = L10n.Chats.Chats.Message.Action.openLink - switch url.scheme?.lowercased() { - case "http", "https": - guard let host = url.host(), !host.isEmpty else { return openLink } - return L10n.Chats.Chats.Message.Action.openWebLink(host) + switch url.scheme?.lowercased() { + case "http", "https": + guard let host = url.host(), !host.isEmpty else { return openLink } + return L10n.Chats.Chats.Message.Action.openWebLink(host) - case MeshCoreURLParser.scheme: - switch url.host() { - case "map": - return L10n.Chats.Chats.Message.Action.openMapLink - case "contact": - guard let name = MeshCoreURLParser.parseContactURL(url.absoluteString)?.name else { return openLink } - return L10n.Chats.Chats.Message.Action.addContact(name) - case "channel": - guard let name = MeshCoreURLParser.parseChannelURL(url.absoluteString)?.name else { return openLink } - return L10n.Chats.Chats.Message.Action.openChannel(name) - default: - return openLink - } + case MeshCoreURLParser.scheme: + switch url.host() { + case "map": + return L10n.Chats.Chats.Message.Action.openMapLink + case "contact": + guard let name = MeshCoreURLParser.parseContactURL(url.absoluteString)?.name else { return openLink } + return L10n.Chats.Chats.Message.Action.addContact(name) + case "channel": + guard let name = MeshCoreURLParser.parseChannelURL(url.absoluteString)?.name else { return openLink } + return L10n.Chats.Chats.Message.Action.openChannel(name) + default: + return openLink + } - case MentionDeeplinkSupport.scheme: - switch url.host() { - case MentionDeeplinkSupport.host: - guard let name = MentionDeeplinkSupport.name(from: url) else { return openLink } - return L10n.Chats.Chats.Message.Action.openMention(name) - case "hashtag": - guard let channel = hashtagName(from: url) else { return openLink } - return L10n.Chats.Chats.Message.Action.openHashtag(channel) - default: - return openLink - } + case MentionDeeplinkSupport.scheme: + switch url.host() { + case MentionDeeplinkSupport.host: + guard let name = MentionDeeplinkSupport.name(from: url) else { return openLink } + return L10n.Chats.Chats.Message.Action.openMention(name) + case "hashtag": + guard let channel = hashtagName(from: url) else { return openLink } + return L10n.Chats.Chats.Message.Action.openHashtag(channel) + default: + return openLink + } - default: - return openLink - } + default: + return openLink } + } - /// Last path component of a `meshcoreone://hashtag/` link, percent-decoded. - private static func hashtagName(from url: URL) -> String? { - var path = url.path(percentEncoded: true) - if path.hasPrefix("/") { path.removeFirst() } - guard !path.isEmpty, let decoded = path.removingPercentEncoding, !decoded.isEmpty else { return nil } - return decoded - } + /// Last path component of a `meshcoreone://hashtag/` link, percent-decoded. + private static func hashtagName(from url: URL) -> String? { + var path = url.path(percentEncoded: true) + if path.hasPrefix("/") { path.removeFirst() } + guard !path.isEmpty, let decoded = path.removingPercentEncoding, !decoded.isEmpty else { return nil } + return decoded + } } diff --git a/MC1/Views/Chats/Linkify/MessageLinkStyler.swift b/MC1/Views/Chats/Linkify/MessageLinkStyler.swift index 05f603ec..4f543e37 100644 --- a/MC1/Views/Chats/Linkify/MessageLinkStyler.swift +++ b/MC1/Views/Chats/Linkify/MessageLinkStyler.swift @@ -4,32 +4,31 @@ import SwiftUI /// non-overlapping `[LinkToken]`. This is the only authored attributed-string representation; /// the render-time UIKit string is derived from it, never authored in parallel. enum MessageLinkStyler { + /// Applies the base color across the whole string, then each token's link, color, + /// underline, bold, and self-mention background over its span. Tokens are non-overlapping, + /// so application order does not affect the result. + static func style(normalized: String, tokens: [LinkToken], baseColor: Color) -> AttributedString { + var result = AttributedString(normalized) + result.foregroundColor = baseColor - /// Applies the base color across the whole string, then each token's link, color, - /// underline, bold, and self-mention background over its span. Tokens are non-overlapping, - /// so application order does not affect the result. - static func style(normalized: String, tokens: [LinkToken], baseColor: Color) -> AttributedString { - var result = AttributedString(normalized) - result.foregroundColor = baseColor + for token in tokens { + guard let attrRange = Range(token.range, in: result) else { continue } - for token in tokens { - guard let attrRange = Range(token.range, in: result) else { continue } - - if let url = token.url { - result[attrRange].link = url - } - result[attrRange].foregroundColor = token.foregroundColor - if let background = token.backgroundColor { - result[attrRange].backgroundColor = background - } - if token.underline { - result[attrRange].underlineStyle = .single - } - if token.bold { - result[attrRange].inlinePresentationIntent = .stronglyEmphasized - } - } - - return result + if let url = token.url { + result[attrRange].link = url + } + result[attrRange].foregroundColor = token.foregroundColor + if let background = token.backgroundColor { + result[attrRange].backgroundColor = background + } + if token.underline { + result[attrRange].underlineStyle = .single + } + if token.bold { + result[attrRange].inlinePresentationIntent = .stronglyEmphasized + } } + + return result + } } diff --git a/MC1/Views/Chats/Linkify/MessageLinkToken.swift b/MC1/Views/Chats/Linkify/MessageLinkToken.swift index ac0cddc8..f60e89f3 100644 --- a/MC1/Views/Chats/Linkify/MessageLinkToken.swift +++ b/MC1/Views/Chats/Linkify/MessageLinkToken.swift @@ -6,43 +6,43 @@ import SwiftUI /// whether to underline, whether to embolden) is frozen on the token here, so the /// styler stays a mechanical apply step with no per-kind branching. struct LinkToken { - /// Detector kinds in their fixed overlap-resolution priority, highest first (the case - /// order is the priority). When two detections cover overlapping characters the - /// higher-priority case wins and the lower is dropped, so the merged stream is - /// non-overlapping. The order encodes how each overlapping pair resolves: a contact chip - /// or mention shadows any link, hashtag, or coordinate inside its run; a URL shadows a - /// hashtag or coordinate it contains; and a hashtag sitting inside a meshcore link wins - /// over that link, so the meshcore link is left unlinked and only the hashtag is styled. - enum Kind: Int, Comparable { - case contactShare - case mention - case url - case hashtag - case meshcoreLink - case coordinate - - static func < (lhs: Kind, rhs: Kind) -> Bool { - lhs.rawValue < rhs.rawValue - } + /// Detector kinds in their fixed overlap-resolution priority, highest first (the case + /// order is the priority). When two detections cover overlapping characters the + /// higher-priority case wins and the lower is dropped, so the merged stream is + /// non-overlapping. The order encodes how each overlapping pair resolves: a contact chip + /// or mention shadows any link, hashtag, or coordinate inside its run; a URL shadows a + /// hashtag or coordinate it contains; and a hashtag sitting inside a meshcore link wins + /// over that link, so the meshcore link is left unlinked and only the hashtag is styled. + enum Kind: Int, Comparable { + case contactShare + case mention + case url + case hashtag + case meshcoreLink + case coordinate + + static func < (lhs: Kind, rhs: Kind) -> Bool { + lhs.rawValue < rhs.rawValue } + } - let range: Range - let kind: Kind + let range: Range + let kind: Kind - /// The link to install, or nil when a span is styled but not tappable (a mention - /// whose name cannot be percent-encoded keeps its color and underline but no link). - let url: URL? + /// The link to install, or nil when a span is styled but not tappable (a mention + /// whose name cannot be percent-encoded keeps its color and underline but no link). + let url: URL? - /// Foreground color for the span. Resolved at detection time against the live - /// theme/contrast inputs, so the styler does not re-derive any color. - let foregroundColor: Color + /// Foreground color for the span. Resolved at detection time against the live + /// theme/contrast inputs, so the styler does not re-derive any color. + let foregroundColor: Color - /// Self-mention highlight, applied behind the span when present. - let backgroundColor: Color? + /// Self-mention highlight, applied behind the span when present. + let backgroundColor: Color? - let underline: Bool + let underline: Bool - /// Hashtags render bold via `inlinePresentationIntent = .stronglyEmphasized`; - /// no other kind sets it. - let bold: Bool + /// Hashtags render bold via `inlinePresentationIntent = .stronglyEmphasized`; + /// no other kind sets it. + let bold: Bool } diff --git a/MC1/Views/Chats/Linkify/MessageLinkTokenizer.swift b/MC1/Views/Chats/Linkify/MessageLinkTokenizer.swift index 0575844d..3d1b8c1c 100644 --- a/MC1/Views/Chats/Linkify/MessageLinkTokenizer.swift +++ b/MC1/Views/Chats/Linkify/MessageLinkTokenizer.swift @@ -1,6 +1,6 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// Detects every link kind in one pass over the normalized message string and merges the /// results into a single sorted, non-overlapping `[LinkToken]`. Each detector runs exactly @@ -8,207 +8,204 @@ import MC1Services /// linkifier's threaded `urlRanges`/`linkRanges`/`excludedRanges` snapshots and its /// reverse-iteration index bookkeeping. enum MessageLinkTokenizer { - - /// Style inputs threaded from the live theme/contrast environment for the kinds the - /// tokenizer detects directly (URL, meshcore link, hashtag, coordinate). Mention and - /// contact-share colors are already resolved by the pre-pass. - struct StyleContext { - let baseColor: Color - let isOutgoing: Bool - let outgoingTextColor: Color - let hashtagColor: Color - } - - struct Result { - let tokens: [LinkToken] - /// The first surviving coordinate in document order, used to drive the map preview. - let mapCoordinate: CLLocationCoordinate2D? - } - - /// One shared http/https detector. `HashtagUtilities` receives this detector's ranges - /// rather than running a second `NSDataDetector`. - private static let urlDetector: NSDataDetector? = { - try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - }() - - private static let meshCoreLinkRegex = try? NSRegularExpression(pattern: #"meshcore://[^\s<>"]+"#) - - /// A coordinate detection paired with its parsed value, so the map coordinate can be - /// read from the first surviving coordinate token after the overlap merge. - private struct CoordinateHit { - let token: LinkToken - let coordinate: CLLocationCoordinate2D - } - - static func tokenize( - normalized: String, - preSpans: [LinkToken], - context: StyleContext - ) -> Result { - // One http/https scan feeds both the URL tokens and the ranges the hashtag detector - // uses to skip a `#fragment` that lives inside a URL. - let urls = detectURLs(in: normalized) - - var hits: [LinkToken] = preSpans - hits.append(contentsOf: urls.map { urlToken(range: $0.range, url: $0.url, context: context) }) - hits.append(contentsOf: detectMeshCoreLinkTokens(in: normalized, context: context)) - hits.append(contentsOf: detectHashtagTokens(in: normalized, urlRanges: urls.map(\.range), context: context)) - - let coordinateHits = detectCoordinateHits(in: normalized, context: context) - hits.append(contentsOf: coordinateHits.map(\.token)) - - let merged = resolveOverlaps(hits) - - // The map preview follows the first coordinate that survived overlap resolution, in - // document order. A coordinate shadowed by a higher-priority token (a contact chip, - // say) does not drive the preview. - let survivingCoordinateRanges = Set(merged.lazy.filter { $0.kind == .coordinate }.map(\.range)) - let mapCoordinate = coordinateHits - .filter { survivingCoordinateRanges.contains($0.token.range) } - .min { $0.token.range.lowerBound < $1.token.range.lowerBound }? - .coordinate - - return Result(tokens: merged, mapCoordinate: mapCoordinate) + /// Style inputs threaded from the live theme/contrast environment for the kinds the + /// tokenizer detects directly (URL, meshcore link, hashtag, coordinate). Mention and + /// contact-share colors are already resolved by the pre-pass. + struct StyleContext { + let baseColor: Color + let isOutgoing: Bool + let outgoingTextColor: Color + let hashtagColor: Color + } + + struct Result { + let tokens: [LinkToken] + /// The first surviving coordinate in document order, used to drive the map preview. + let mapCoordinate: CLLocationCoordinate2D? + } + + /// One shared http/https detector. `HashtagUtilities` receives this detector's ranges + /// rather than running a second `NSDataDetector`. + private static let urlDetector: NSDataDetector? = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + + private static let meshCoreLinkRegex = try? NSRegularExpression(pattern: #"meshcore://[^\s<>"]+"#) + + /// A coordinate detection paired with its parsed value, so the map coordinate can be + /// read from the first surviving coordinate token after the overlap merge. + private struct CoordinateHit { + let token: LinkToken + let coordinate: CLLocationCoordinate2D + } + + static func tokenize( + normalized: String, + preSpans: [LinkToken], + context: StyleContext + ) -> Result { + // One http/https scan feeds both the URL tokens and the ranges the hashtag detector + // uses to skip a `#fragment` that lives inside a URL. + let urls = detectURLs(in: normalized) + + var hits: [LinkToken] = preSpans + hits.append(contentsOf: urls.map { urlToken(range: $0.range, url: $0.url, context: context) }) + hits.append(contentsOf: detectMeshCoreLinkTokens(in: normalized, context: context)) + hits.append(contentsOf: detectHashtagTokens(in: normalized, urlRanges: urls.map(\.range), context: context)) + + let coordinateHits = detectCoordinateHits(in: normalized, context: context) + hits.append(contentsOf: coordinateHits.map(\.token)) + + let merged = resolveOverlaps(hits) + + // The map preview follows the first coordinate that survived overlap resolution, in + // document order. A coordinate shadowed by a higher-priority token (a contact chip, + // say) does not drive the preview. + let survivingCoordinateRanges = Set(merged.lazy.filter { $0.kind == .coordinate }.map(\.range)) + let mapCoordinate = coordinateHits + .filter { survivingCoordinateRanges.contains($0.token.range) } + .min { $0.token.range.lowerBound < $1.token.range.lowerBound }? + .coordinate + + return Result(tokens: merged, mapCoordinate: mapCoordinate) + } + + // MARK: - Overlap resolution + + /// Accepts detections highest priority first, dropping any that overlaps one already + /// accepted, then returns the survivors in document order. Resolving by `LinkToken.Kind` + /// priority rather than by start index is what lets a higher-priority kind that begins + /// inside a lower-priority run still win the overlap: a `hashtag` fragment embedded in a + /// `meshcoreLink` keeps its hashtag styling and leaves the surrounding link unlinked. The + /// result is sorted and non-overlapping. + private static func resolveOverlaps(_ tokens: [LinkToken]) -> [LinkToken] { + let byPriority = tokens.sorted { lhs, rhs in + if lhs.kind != rhs.kind { + return lhs.kind < rhs.kind + } + return lhs.range.lowerBound < rhs.range.lowerBound } - // MARK: - Overlap resolution - - /// Accepts detections highest priority first, dropping any that overlaps one already - /// accepted, then returns the survivors in document order. Resolving by `LinkToken.Kind` - /// priority rather than by start index is what lets a higher-priority kind that begins - /// inside a lower-priority run still win the overlap: a `hashtag` fragment embedded in a - /// `meshcoreLink` keeps its hashtag styling and leaves the surrounding link unlinked. The - /// result is sorted and non-overlapping. - private static func resolveOverlaps(_ tokens: [LinkToken]) -> [LinkToken] { - let byPriority = tokens.sorted { lhs, rhs in - if lhs.kind != rhs.kind { - return lhs.kind < rhs.kind - } - return lhs.range.lowerBound < rhs.range.lowerBound - } - - var accepted: [LinkToken] = [] - for token in byPriority where !accepted.contains(where: { $0.range.overlaps(token.range) }) { - accepted.append(token) - } - return accepted.sorted { $0.range.lowerBound < $1.range.lowerBound } + var accepted: [LinkToken] = [] + for token in byPriority where !accepted.contains(where: { $0.range.overlaps(token.range) }) { + accepted.append(token) } - - // MARK: - URLs - - private struct DetectedURL { - let range: Range - let url: URL - } - - private static func detectURLs(in text: String) -> [DetectedURL] { - guard let detector = urlDetector else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - return detector.matches(in: text, options: [], range: nsRange).compactMap { match in - guard let url = match.url, - let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https", - let range = Range(match.range, in: text) else { return nil } - return DetectedURL(range: range, url: url) - } + return accepted.sorted { $0.range.lowerBound < $1.range.lowerBound } + } + + // MARK: - URLs + + private struct DetectedURL { + let range: Range + let url: URL + } + + private static func detectURLs(in text: String) -> [DetectedURL] { + guard let detector = urlDetector else { return [] } + let nsRange = NSRange(text.startIndex..., in: text) + return detector.matches(in: text, options: [], range: nsRange).compactMap { match in + guard let url = match.url, + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let range = Range(match.range, in: text) else { return nil } + return DetectedURL(range: range, url: url) } - - private static func urlToken(range: Range, url: URL, context: StyleContext) -> LinkToken { - LinkToken( - range: range, - kind: .url, - url: url, - foregroundColor: context.baseColor, - backgroundColor: nil, - underline: true, - bold: false - ) + } + + private static func urlToken(range: Range, url: URL, context: StyleContext) -> LinkToken { + LinkToken( + range: range, + kind: .url, + url: url, + foregroundColor: context.baseColor, + backgroundColor: nil, + underline: true, + bold: false + ) + } + + // MARK: - MeshCore links + + private static func detectMeshCoreLinkTokens(in text: String, context: StyleContext) -> [LinkToken] { + guard let regex = meshCoreLinkRegex else { return [] } + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { match in + guard var matchRange = Range(match.range, in: text) else { return nil } + + // Strip trailing punctuation the regex may over-capture. + while let last = text[matchRange].last, ".,;:!?)".contains(last) { + matchRange = matchRange.lowerBound.. [LinkToken] { - guard let regex = meshCoreLinkRegex else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: nsRange).compactMap { match in - guard var matchRange = Range(match.range, in: text) else { return nil } - - // Strip trailing punctuation the regex may over-capture. - while let last = text[matchRange].last, ".,;:!?)".contains(last) { - matchRange = matchRange.lowerBound..], + context: StyleContext + ) -> [LinkToken] { + let hashtags = HashtagUtilities.extractHashtags(from: text, urlRanges: urlRanges) + return hashtags.compactMap { hashtag in + let channelName = HashtagUtilities.normalizeHashtagName(hashtag.name) + guard let url = URL(string: "meshcoreone://hashtag/\(channelName)") else { return nil } + return LinkToken( + range: hashtag.range, + kind: .hashtag, + url: url, + foregroundColor: context.isOutgoing ? context.outgoingTextColor : context.hashtagColor, + backgroundColor: nil, + underline: false, + bold: true + ) } - - // MARK: - Hashtags - - private static func detectHashtagTokens( - in text: String, - urlRanges: [Range], - context: StyleContext - ) -> [LinkToken] { - let hashtags = HashtagUtilities.extractHashtags(from: text, urlRanges: urlRanges) - return hashtags.compactMap { hashtag in - let channelName = HashtagUtilities.normalizeHashtagName(hashtag.name) - guard let url = URL(string: "meshcoreone://hashtag/\(channelName)") else { return nil } - return LinkToken( - range: hashtag.range, - kind: .hashtag, - url: url, - foregroundColor: context.isOutgoing ? context.outgoingTextColor : context.hashtagColor, - backgroundColor: nil, - underline: false, - bold: true - ) - } - } - - // MARK: - Coordinates - - private static func detectCoordinateHits(in text: String, context: StyleContext) -> [CoordinateHit] { - ChatCoordinateDetector.matches(in: text).compactMap { match in - guard let url = mapURL( - latitude: match.coordinate.latitude, - longitude: match.coordinate.longitude - ) else { return nil } - let token = LinkToken( - range: match.range, - kind: .coordinate, - url: url, - foregroundColor: context.baseColor, - backgroundColor: nil, - underline: true, - bold: false - ) - return CoordinateHit(token: token, coordinate: match.coordinate) - } - } - - /// Builds `meshcore://map?lat=&lon=` with locale-independent `%.6f` values so the link - /// round-trips through `MeshCoreURLParser.parseMapURL` on every locale. A comma-decimal - /// locale's `.formatted()` would emit `37,334900`, which the parser's decimal gate rejects. - private static func mapURL(latitude: Double, longitude: Double) -> URL? { - var components = URLComponents() - components.scheme = MeshCoreURLParser.scheme - components.host = "map" - components.queryItems = [ - URLQueryItem(name: "lat", value: String(format: "%.6f", latitude)), - URLQueryItem(name: "lon", value: String(format: "%.6f", longitude)), - ] - return components.url + } + + // MARK: - Coordinates + + private static func detectCoordinateHits(in text: String, context: StyleContext) -> [CoordinateHit] { + ChatCoordinateDetector.matches(in: text).compactMap { match in + guard let url = mapURL( + latitude: match.coordinate.latitude, + longitude: match.coordinate.longitude + ) else { return nil } + let token = LinkToken( + range: match.range, + kind: .coordinate, + url: url, + foregroundColor: context.baseColor, + backgroundColor: nil, + underline: true, + bold: false + ) + return CoordinateHit(token: token, coordinate: match.coordinate) } + } + + /// Builds `meshcore://map?lat=&lon=` with locale-independent `%.6f` values so the link + /// round-trips through `MeshCoreURLParser.parseMapURL` on every locale. A comma-decimal + /// locale's `.formatted()` would emit `37,334900`, which the parser's decimal gate rejects. + private static func mapURL(latitude: Double, longitude: Double) -> URL? { + var components = URLComponents() + components.scheme = MeshCoreURLParser.scheme + components.host = "map" + components.queryItems = [ + URLQueryItem(name: "lat", value: String(format: "%.6f", latitude)), + URLQueryItem(name: "lon", value: String(format: "%.6f", longitude)), + ] + return components.url + } } diff --git a/MC1/Views/Chats/Linkify/MessageTextNormalizer.swift b/MC1/Views/Chats/Linkify/MessageTextNormalizer.swift index 25989375..decb02ff 100644 --- a/MC1/Views/Chats/Linkify/MessageTextNormalizer.swift +++ b/MC1/Views/Chats/Linkify/MessageTextNormalizer.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// The string-shrinking pre-pass of the linkifier. Two transforms rewrite the raw /// message body to a shorter display string before any range-based detection runs: @@ -11,198 +11,197 @@ import MC1Services /// later detector (URL, hashtag, meshcore, coordinate) runs on one stable string, so the /// index-desync trap of the old interleaved passes is gone by construction. enum MessageTextNormalizer { - - /// Style inputs threaded from the live theme/contrast environment, used to resolve a - /// mention's identity color at normalization time so downstream stages carry only - /// resolved colors. - struct StyleContext { - let baseColor: Color - let isOutgoing: Bool - let currentUserName: String? - let isHighContrast: Bool - let outgoingTextColor: Color - let identityGamut: IdentityGamut - let identityBackgroundLuminances: [Double] - } - - /// The normalized display string plus the spans produced by the two rewrites, with - /// ranges into the normalized string. - struct Result { - let string: String - let spans: [LinkToken] - } - - /// A pending substitution discovered on the original string, carrying the replacement - /// text and the span attributes to emit once normalized ranges are known. - private struct Replacement { - let originalRange: Range - let replacement: String - let kind: LinkToken.Kind - let url: URL? - let foregroundColor: Color - let backgroundColor: Color? + /// Style inputs threaded from the live theme/contrast environment, used to resolve a + /// mention's identity color at normalization time so downstream stages carry only + /// resolved colors. + struct StyleContext { + let baseColor: Color + let isOutgoing: Bool + let currentUserName: String? + let isHighContrast: Bool + let outgoingTextColor: Color + let identityGamut: IdentityGamut + let identityBackgroundLuminances: [Double] + } + + /// The normalized display string plus the spans produced by the two rewrites, with + /// ranges into the normalized string. + struct Result { + let string: String + let spans: [LinkToken] + } + + /// A pending substitution discovered on the original string, carrying the replacement + /// text and the span attributes to emit once normalized ranges are known. + private struct Replacement { + let originalRange: Range + let replacement: String + let kind: LinkToken.Kind + let url: URL? + let foregroundColor: Color + let backgroundColor: Color? + } + + static func normalize(_ text: String, context: StyleContext) -> Result { + let contactTokenRanges = contactShareTokenRanges(in: text) + + var replacements: [Replacement] = [] + replacements.append(contentsOf: mentionReplacements( + in: text, + context: context, + excludedRanges: contactTokenRanges + )) + replacements.append(contentsOf: contactShareReplacements(in: text, context: context)) + + // A mention can never sit inside a contact token (those mentions are excluded + // above) and contact tokens never overlap each other, so sorting by start index + // yields a clean left-to-right rewrite with no overlapping originals. + replacements.sort { $0.originalRange.lowerBound < $1.originalRange.lowerBound } + + guard !replacements.isEmpty else { + return Result(string: text, spans: []) } - static func normalize(_ text: String, context: StyleContext) -> Result { - let contactTokenRanges = contactShareTokenRanges(in: text) - - var replacements: [Replacement] = [] - replacements.append(contentsOf: mentionReplacements( - in: text, - context: context, - excludedRanges: contactTokenRanges - )) - replacements.append(contentsOf: contactShareReplacements(in: text, context: context)) - - // A mention can never sit inside a contact token (those mentions are excluded - // above) and contact tokens never overlap each other, so sorting by start index - // yields a clean left-to-right rewrite with no overlapping originals. - replacements.sort { $0.originalRange.lowerBound < $1.originalRange.lowerBound } - - guard !replacements.isEmpty else { - return Result(string: text, spans: []) - } - - var normalized = "" - var spans: [LinkToken] = [] - var cursor = text.startIndex - - for replacement in replacements { - normalized += text[cursor..] - ) -> [Replacement] { - guard let regex = MentionUtilities.mentionRegex else { return [] } - - let nsRange = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: nsRange).compactMap { match in - guard let matchRange = Range(match.range, in: text), - let nameRange = Range(match.range(at: 1), in: text) else { return nil } - - // A contact share token's name is attacker-controlled and may itself contain - // `@[name]`; skip mentions inside one so the contact-share rewrite owns that text. - if excludedRanges.contains(where: { $0.overlaps(matchRange) }) { return nil } - - let name = String(text[nameRange]) - let isSelfMention = context.currentUserName.map { - name.localizedCaseInsensitiveCompare($0) == .orderedSame - } ?? false - - let foreground: Color - var background: Color? - if context.isOutgoing { - foreground = context.baseColor - if isSelfMention { - background = context.baseColor.opacity(0.3) - } - } else { - let mentionColor = context.identityGamut.color( - forName: name, - backgroundLuminances: context.identityBackgroundLuminances, - highContrast: context.isHighContrast - ) - foreground = mentionColor - if isSelfMention { - background = mentionColor.opacity(0.15) - } - } - - return Replacement( - originalRange: matchRange, - replacement: "@\(name)", - kind: .mention, - url: MentionDeeplinkSupport.url(forName: name), - foregroundColor: foreground, - backgroundColor: background - ) + normalized += text[cursor...] + + return Result(string: normalized, spans: spans) + } + + // MARK: - Mentions + + private static func mentionReplacements( + in text: String, + context: StyleContext, + excludedRanges: [Range] + ) -> [Replacement] { + guard let regex = MentionUtilities.mentionRegex else { return [] } + + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { match in + guard let matchRange = Range(match.range, in: text), + let nameRange = Range(match.range(at: 1), in: text) else { return nil } + + // A contact share token's name is attacker-controlled and may itself contain + // `@[name]`; skip mentions inside one so the contact-share rewrite owns that text. + if excludedRanges.contains(where: { $0.overlaps(matchRange) }) { return nil } + + let name = String(text[nameRange]) + let isSelfMention = context.currentUserName.map { + name.localizedCaseInsensitiveCompare($0) == .orderedSame + } ?? false + + let foreground: Color + var background: Color? + if context.isOutgoing { + foreground = context.baseColor + if isSelfMention { + background = context.baseColor.opacity(0.3) } - } - - // MARK: - Contact share - - /// Opening delimiter of a contact share token; gates the cheap fast-path skip. - private static let tokenOpen = "<" - - private static func contactShareTokenRanges(in text: String) -> [Range] { - guard text.contains(tokenOpen), let regex = ContactShareUtilities.shareTokenRegex else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: nsRange).compactMap { Range($0.range, in: text) } - } - - private static func contactShareReplacements(in text: String, context: StyleContext) -> [Replacement] { - guard text.contains(tokenOpen), let regex = ContactShareUtilities.shareTokenRegex else { return [] } - - let nsRange = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: nsRange).compactMap { match in - guard let matchRange = Range(match.range, in: text), - let result = ContactShareUtilities.parseShare(String(text[matchRange])) else { return nil } - - // Sanitize once and carry the cleaned name through both the visible chip and - // the link URL. If sanitizing leaves nothing, keep the literal token rather - // than emit an empty, invisible chip. - let cleanName = displayName(for: result.name) - guard !cleanName.isEmpty, - let url = URL(string: ContactService.exportContactURI( - name: cleanName, - publicKey: result.publicKey, - type: result.contactType - )) else { return nil } - - return Replacement( - originalRange: matchRange, - replacement: cleanName, - kind: .contactShare, - url: url, - foregroundColor: context.baseColor, - backgroundColor: nil - ) + } else { + let mentionColor = context.identityGamut.color( + forName: name, + backgroundLuminances: context.identityBackgroundLuminances, + highContrast: context.isHighContrast + ) + foreground = mentionColor + if isSelfMention { + background = mentionColor.opacity(0.15) } + } + + return Replacement( + originalRange: matchRange, + replacement: "@\(name)", + kind: .mention, + url: MentionDeeplinkSupport.url(forName: name), + foregroundColor: foreground, + backgroundColor: background + ) } - - /// Strips invisible and control Unicode scalars from an inbound contact name. The name - /// is attacker-controlled, so the cleaned form is used for both the visible chip and the - /// add-contact link URL, keeping the confirmation sheet and the persisted contact free - /// of bidi overrides, zero-width joiners, and line breaks that could hide or reorder the - /// visible identity. - static func displayName(for name: String) -> String { - String(String.UnicodeScalarView(name.unicodeScalars.filter { !isStrippableScalar($0) })) + } + + // MARK: - Contact share + + /// Opening delimiter of a contact share token; gates the cheap fast-path skip. + private static let tokenOpen = "<" + + private static func contactShareTokenRanges(in text: String) -> [Range] { + guard text.contains(tokenOpen), let regex = ContactShareUtilities.shareTokenRegex else { return [] } + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { Range($0.range, in: text) } + } + + private static func contactShareReplacements(in text: String, context: StyleContext) -> [Replacement] { + guard text.contains(tokenOpen), let regex = ContactShareUtilities.shareTokenRegex else { return [] } + + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { match in + guard let matchRange = Range(match.range, in: text), + let result = ContactShareUtilities.parseShare(String(text[matchRange])) else { return nil } + + // Sanitize once and carry the cleaned name through both the visible chip and + // the link URL. If sanitizing leaves nothing, keep the literal token rather + // than emit an empty, invisible chip. + let cleanName = displayName(for: result.name) + guard !cleanName.isEmpty, + let url = URL(string: ContactService.exportContactURI( + name: cleanName, + publicKey: result.publicKey, + type: result.contactType + )) else { return nil } + + return Replacement( + originalRange: matchRange, + replacement: cleanName, + kind: .contactShare, + url: url, + foregroundColor: context.baseColor, + backgroundColor: nil + ) } - - private static func isStrippableScalar(_ scalar: Unicode.Scalar) -> Bool { - if scalar.properties.isBidiControl || scalar.properties.isDefaultIgnorableCodePoint { - return true - } - switch scalar.properties.generalCategory { - case .control, .format, .lineSeparator, .paragraphSeparator: - return true - default: - return false - } + } + + /// Strips invisible and control Unicode scalars from an inbound contact name. The name + /// is attacker-controlled, so the cleaned form is used for both the visible chip and the + /// add-contact link URL, keeping the confirmation sheet and the persisted contact free + /// of bidi overrides, zero-width joiners, and line breaks that could hide or reorder the + /// visible identity. + static func displayName(for name: String) -> String { + String(String.UnicodeScalarView(name.unicodeScalars.filter { !isStrippableScalar($0) })) + } + + private static func isStrippableScalar(_ scalar: Unicode.Scalar) -> Bool { + if scalar.properties.isBidiControl || scalar.properties.isDefaultIgnorableCodePoint { + return true + } + switch scalar.properties.generalCategory { + case .control, .format, .lineSeparator, .paragraphSeparator: + return true + default: + return false } + } } diff --git a/MC1/Views/Chats/Mentions/ChatConversationMentionOverlay.swift b/MC1/Views/Chats/Mentions/ChatConversationMentionOverlay.swift index 7aee67d5..3569fac1 100644 --- a/MC1/Views/Chats/Mentions/ChatConversationMentionOverlay.swift +++ b/MC1/Views/Chats/Mentions/ChatConversationMentionOverlay.swift @@ -1,30 +1,30 @@ -import SwiftUI import MC1Services +import SwiftUI /// Floating mention suggestions overlay for the chat input. struct ChatConversationMentionOverlay: View { - let suggestions: [ContactDTO] - let onSelectMention: (ContactDTO) -> Void + let suggestions: [ContactDTO] + let onSelectMention: (ContactDTO) -> Void - var body: some View { - if !suggestions.isEmpty { - VStack { - Spacer() - MentionSuggestionView(contacts: suggestions) { contact in - onSelectMention(contact) - } - .padding(.horizontal) - .padding(.bottom, 60) - .transition( - .asymmetric( - insertion: .move(edge: .bottom) - .combined(with: .opacity) - .combined(with: .scale(scale: 0.95, anchor: .bottom)), - removal: .move(edge: .bottom).combined(with: .opacity) - ) - ) - } - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: suggestions.isEmpty) + var body: some View { + if !suggestions.isEmpty { + VStack { + Spacer() + MentionSuggestionView(contacts: suggestions) { contact in + onSelectMention(contact) } + .padding(.horizontal) + .padding(.bottom, 60) + .transition( + .asymmetric( + insertion: .move(edge: .bottom) + .combined(with: .opacity) + .combined(with: .scale(scale: 0.95, anchor: .bottom)), + removal: .move(edge: .bottom).combined(with: .opacity) + ) + ) + } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: suggestions.isEmpty) } + } } diff --git a/MC1/Views/Chats/Mentions/MentionDeeplinkSupport.swift b/MC1/Views/Chats/Mentions/MentionDeeplinkSupport.swift index 4578b986..a0a61a19 100644 --- a/MC1/Views/Chats/Mentions/MentionDeeplinkSupport.swift +++ b/MC1/Views/Chats/Mentions/MentionDeeplinkSupport.swift @@ -5,40 +5,40 @@ import Foundation /// scheme, host, and percent-encoding here keeps the producer and consumer from /// diverging — a mismatch would route a mention tap to the wrong contact. enum MentionDeeplinkSupport { - static let scheme = "meshcoreone" - static let host = "mention" + static let scheme = "meshcoreone" + static let host = "mention" - /// `/` is removed from the allowed set so a name containing a slash is - /// percent-encoded into a single path component instead of splitting the - /// URL path (which would drop everything before the last slash). - private static let nameAllowed = CharacterSet.urlPathAllowed - .subtracting(CharacterSet(charactersIn: "/")) + /// `/` is removed from the allowed set so a name containing a slash is + /// percent-encoded into a single path component instead of splitting the + /// URL path (which would drop everything before the last slash). + private static let nameAllowed = CharacterSet.urlPathAllowed + .subtracting(CharacterSet(charactersIn: "/")) - /// Builds `meshcoreone://mention/` for a mention run, - /// or `nil` if the name cannot be encoded or is empty. - static func url(forName name: String) -> URL? { - guard let encoded = name.addingPercentEncoding(withAllowedCharacters: nameAllowed), - !encoded.isEmpty else { - return nil - } - return URL(string: "\(scheme)://\(host)/\(encoded)") + /// Builds `meshcoreone://mention/` for a mention run, + /// or `nil` if the name cannot be encoded or is empty. + static func url(forName name: String) -> URL? { + guard let encoded = name.addingPercentEncoding(withAllowedCharacters: nameAllowed), + !encoded.isEmpty else { + return nil } + return URL(string: "\(scheme)://\(host)/\(encoded)") + } - /// Returns the decoded mention name if `url` is a mention deep link, else - /// `nil`. Reads the still-encoded path and decodes it exactly once; - /// `lastPathComponent` is avoided because it decodes `%2F` back to `/` and - /// then re-splits, dropping everything before the slash. - static func name(from url: URL) -> String? { - guard url.scheme == scheme, url.host == host else { return nil } - var encodedPath = url.path(percentEncoded: true) - if encodedPath.hasPrefix("/") { - encodedPath.removeFirst() - } - guard !encodedPath.isEmpty, - let decoded = encodedPath.removingPercentEncoding, - !decoded.isEmpty else { - return nil - } - return decoded + /// Returns the decoded mention name if `url` is a mention deep link, else + /// `nil`. Reads the still-encoded path and decodes it exactly once; + /// `lastPathComponent` is avoided because it decodes `%2F` back to `/` and + /// then re-splits, dropping everything before the slash. + static func name(from url: URL) -> String? { + guard url.scheme == scheme, url.host == host else { return nil } + var encodedPath = url.path(percentEncoded: true) + if encodedPath.hasPrefix("/") { + encodedPath.removeFirst() } + guard !encodedPath.isEmpty, + let decoded = encodedPath.removingPercentEncoding, + !decoded.isEmpty else { + return nil + } + return decoded + } } diff --git a/MC1/Views/Chats/Mentions/MentionPickerContext.swift b/MC1/Views/Chats/Mentions/MentionPickerContext.swift index 28771264..d39ca6dc 100644 --- a/MC1/Views/Chats/Mentions/MentionPickerContext.swift +++ b/MC1/Views/Chats/Mentions/MentionPickerContext.swift @@ -7,9 +7,9 @@ import MC1Services /// surface. Single-match resolutions never construct one — they navigate /// directly. struct MentionPickerContext: Identifiable { - let id = UUID() - let name: String - let radioID: UUID - let matches: [ContactDTO] - let isSelfMention: Bool + let id = UUID() + let name: String + let radioID: UUID + let matches: [ContactDTO] + let isSelfMention: Bool } diff --git a/MC1/Views/Chats/Mentions/MentionPickerSheet.swift b/MC1/Views/Chats/Mentions/MentionPickerSheet.swift index 2049e28f..3406bbb3 100644 --- a/MC1/Views/Chats/Mentions/MentionPickerSheet.swift +++ b/MC1/Views/Chats/Mentions/MentionPickerSheet.swift @@ -1,67 +1,67 @@ -import SwiftUI import MC1Services +import SwiftUI /// Disambiguation / status sheet shown when a tapped `@mention` does not resolve /// to a single saved contact. Presented by `MentionTapHandler` on any chat /// surface that renders mention links (DMs, channels, and rooms). struct MentionPickerSheet: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let context: MentionPickerContext - let onSelect: (ContactDTO) -> Void - let onDismiss: () -> Void + let context: MentionPickerContext + let onSelect: (ContactDTO) -> Void + let onDismiss: () -> Void - private static let listSpacing: CGFloat = 12 - private static let compactDetentHeight: CGFloat = 180 + private static let listSpacing: CGFloat = 12 + private static let compactDetentHeight: CGFloat = 180 - var body: some View { - NavigationStack { - Group { - if context.isSelfMention { - ContentUnavailableView { - Label(L10n.Chats.Chats.Mention.Picker.selfTitle, - systemImage: "person.crop.circle") - } description: { - Text(L10n.Chats.Chats.Mention.Picker.selfSubtitle(context.name)) - } - } else if context.matches.isEmpty { - ContentUnavailableView { - Label(L10n.Chats.Chats.Mention.Picker.notSavedTitle, - systemImage: "person.crop.circle.badge.questionmark") - } description: { - Text(L10n.Chats.Chats.Mention.Picker.notSavedSubtitle(context.name)) - } - } else { - ScrollView { - VStack(alignment: .leading, spacing: Self.listSpacing) { - Text(L10n.Chats.Chats.Mention.Picker.matchingContacts) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + NavigationStack { + Group { + if context.isSelfMention { + ContentUnavailableView { + Label(L10n.Chats.Chats.Mention.Picker.selfTitle, + systemImage: "person.crop.circle") + } description: { + Text(L10n.Chats.Chats.Mention.Picker.selfSubtitle(context.name)) + } + } else if context.matches.isEmpty { + ContentUnavailableView { + Label(L10n.Chats.Chats.Mention.Picker.notSavedTitle, + systemImage: "person.crop.circle.badge.questionmark") + } description: { + Text(L10n.Chats.Chats.Mention.Picker.notSavedSubtitle(context.name)) + } + } else { + ScrollView { + VStack(alignment: .leading, spacing: Self.listSpacing) { + Text(L10n.Chats.Chats.Mention.Picker.matchingContacts) + .font(.subheadline) + .foregroundStyle(.secondary) - ForEach(context.matches) { match in - ContactMatchRow( - contact: match, - style: .tap, - userLocation: appState.bestAvailableLocation, - action: { onSelect(match) } - ) - } - } - .padding() - } - } - } - .navigationTitle(L10n.Chats.Chats.Mention.Picker.title(context.name)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.done) { - onDismiss() - } - } + ForEach(context.matches) { match in + ContactMatchRow( + contact: match, + style: .tap, + userLocation: appState.bestAvailableLocation, + action: { onSelect(match) } + ) + } } + .padding() + } + } + } + .navigationTitle(L10n.Chats.Chats.Mention.Picker.title(context.name)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.done) { + onDismiss() + } } - .presentationDetents([.height(Self.compactDetentHeight), .medium]) - .presentationDragIndicator(.visible) + } } + .presentationDetents([.height(Self.compactDetentHeight), .medium]) + .presentationDragIndicator(.visible) + } } diff --git a/MC1/Views/Chats/Mentions/MentionTapEvaluator.swift b/MC1/Views/Chats/Mentions/MentionTapEvaluator.swift index ce9c0503..5ae00a26 100644 --- a/MC1/Views/Chats/Mentions/MentionTapEvaluator.swift +++ b/MC1/Views/Chats/Mentions/MentionTapEvaluator.swift @@ -7,57 +7,57 @@ import MC1Services /// effect for `.navigate` outcomes and presents the picker for `.picker`. @MainActor enum MentionTapEvaluator { - enum Outcome { - case navigate(ContactDTO) - case picker(MentionPickerContext) + enum Outcome { + case navigate(ContactDTO) + case picker(MentionPickerContext) + } + + static func evaluate( + rawName: String, + contacts: [ContactDTO], + connectedDeviceName: String?, + radioID: UUID + ) -> Outcome { + let sanitized = MessageText.displayName(for: rawName) + let trimmed = sanitized.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + return .picker(MentionPickerContext( + name: sanitized, + radioID: radioID, + matches: [], + isSelfMention: false + )) } - static func evaluate( - rawName: String, - contacts: [ContactDTO], - connectedDeviceName: String?, - radioID: UUID - ) -> Outcome { - let sanitized = MessageText.displayName(for: rawName) - let trimmed = sanitized.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.isEmpty { - return .picker(MentionPickerContext( - name: sanitized, - radioID: radioID, - matches: [], - isSelfMention: false - )) - } - - let isSelf: Bool = connectedDeviceName.map { - sanitized.localizedCaseInsensitiveCompare($0) == .orderedSame - } ?? false - - if isSelf { - return .picker(MentionPickerContext( - name: sanitized, - radioID: radioID, - matches: [], - isSelfMention: true - )) - } - - let matches = SenderContactMatcher.filter( - contacts: contacts, - senderName: sanitized, - excludeBlocked: false - ) - - if matches.count == 1 { - return .navigate(matches[0]) - } - - return .picker(MentionPickerContext( - name: sanitized, - radioID: radioID, - matches: matches, - isSelfMention: false - )) + let isSelf: Bool = connectedDeviceName.map { + sanitized.localizedCaseInsensitiveCompare($0) == .orderedSame + } ?? false + + if isSelf { + return .picker(MentionPickerContext( + name: sanitized, + radioID: radioID, + matches: [], + isSelfMention: true + )) } + + let matches = SenderContactMatcher.filter( + contacts: contacts, + senderName: sanitized, + excludeBlocked: false + ) + + if matches.count == 1 { + return .navigate(matches[0]) + } + + return .picker(MentionPickerContext( + name: sanitized, + radioID: radioID, + matches: matches, + isSelfMention: false + )) + } } diff --git a/MC1/Views/Chats/Mentions/MentionTapHandler.swift b/MC1/Views/Chats/Mentions/MentionTapHandler.swift index fda7ed8c..dcc0df04 100644 --- a/MC1/Views/Chats/Mentions/MentionTapHandler.swift +++ b/MC1/Views/Chats/Mentions/MentionTapHandler.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Installs the chat-content `OpenURLAction` and the mention disambiguation /// sheet on any chat surface that renders `MessageText`. Mention links @@ -9,81 +9,81 @@ import MC1Services /// `RoomConversationView` so mention taps behave identically across all chat /// surfaces rather than dying silently where no interceptor is installed. struct MentionTapHandler: ViewModifier { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let contacts: [ContactDTO] - let radioID: UUID - /// A secondary (right) click that opens the actions sheet also activates the - /// link-preview card's `Button`, which routes its open through this same - /// action. This returns true while the sheet is presenting so that trailing - /// open is discarded instead of navigating away over the sheet. - let shouldSuppressOpen: () -> Bool + let contacts: [ContactDTO] + let radioID: UUID + /// A secondary (right) click that opens the actions sheet also activates the + /// link-preview card's `Button`, which routes its open through this same + /// action. This returns true while the sheet is presenting so that trailing + /// open is discarded instead of navigating away over the sheet. + let shouldSuppressOpen: () -> Bool - @State private var pickerContext: MentionPickerContext? - @State private var resolverTask: Task? - - func body(content: Content) -> some View { - content - .environment(\.openURL, OpenURLAction { url in - if shouldSuppressOpen() { return .discarded } - if let mentionName = MentionDeeplinkSupport.name(from: url) { - handleMentionTap(name: mentionName) - return .handled - } - return ChatLinkRouter.route(url, appState: appState) ? .handled : .systemAction - }) - .sheet(item: $pickerContext) { context in - MentionPickerSheet( - context: context, - onSelect: { contact in - pickerContext = nil - appState.navigation.navigateToContactDetail(contact) - }, - onDismiss: { pickerContext = nil } - ) - } - .onDisappear { - resolverTask?.cancel() - resolverTask = nil - } - } + @State private var pickerContext: MentionPickerContext? + @State private var resolverTask: Task? - /// Defers resolution into a `@MainActor` task so the sheet mutation lands on - /// a later runloop turn rather than inside the synchronous `openURL` - /// callback, matching `ChatLinkRouter`'s deferral convention. - private func handleMentionTap(name: String) { - resolverTask?.cancel() - resolverTask = Task { @MainActor in - let outcome = MentionTapEvaluator.evaluate( - rawName: name, - contacts: contacts, - connectedDeviceName: appState.connectedDevice?.nodeName, - radioID: radioID - ) - guard !Task.isCancelled else { return } - switch outcome { - case .navigate(let contact): - appState.navigation.navigateToContactDetail(contact) - case .picker(let context): - pickerContext = context - } + func body(content: Content) -> some View { + content + .environment(\.openURL, OpenURLAction { url in + if shouldSuppressOpen() { return .discarded } + if let mentionName = MentionDeeplinkSupport.name(from: url) { + handleMentionTap(name: mentionName) + return .handled } + return ChatLinkRouter.route(url, appState: appState) ? .handled : .systemAction + }) + .sheet(item: $pickerContext) { context in + MentionPickerSheet( + context: context, + onSelect: { contact in + pickerContext = nil + appState.navigation.navigateToContactDetail(contact) + }, + onDismiss: { pickerContext = nil } + ) + } + .onDisappear { + resolverTask?.cancel() + resolverTask = nil + } + } + + /// Defers resolution into a `@MainActor` task so the sheet mutation lands on + /// a later runloop turn rather than inside the synchronous `openURL` + /// callback, matching `ChatLinkRouter`'s deferral convention. + private func handleMentionTap(name: String) { + resolverTask?.cancel() + resolverTask = Task { @MainActor in + let outcome = MentionTapEvaluator.evaluate( + rawName: name, + contacts: contacts, + connectedDeviceName: appState.connectedDevice?.nodeName, + radioID: radioID + ) + guard !Task.isCancelled else { return } + switch outcome { + case let .navigate(contact): + appState.navigation.navigateToContactDetail(contact) + case let .picker(context): + pickerContext = context + } } + } } extension View { - /// Routes mention-link taps through `MentionTapEvaluator` and forwards every - /// other chat URL to `ChatLinkRouter`. Apply to any chat surface that renders - /// `MessageText`. - func mentionTapHandling( - contacts: [ContactDTO], - radioID: UUID, - shouldSuppressOpen: @escaping () -> Bool - ) -> some View { - modifier(MentionTapHandler( - contacts: contacts, - radioID: radioID, - shouldSuppressOpen: shouldSuppressOpen - )) - } + /// Routes mention-link taps through `MentionTapEvaluator` and forwards every + /// other chat URL to `ChatLinkRouter`. Apply to any chat surface that renders + /// `MessageText`. + func mentionTapHandling( + contacts: [ContactDTO], + radioID: UUID, + shouldSuppressOpen: @escaping () -> Bool + ) -> some View { + modifier(MentionTapHandler( + contacts: contacts, + radioID: radioID, + shouldSuppressOpen: shouldSuppressOpen + )) + } } diff --git a/MC1/Views/Chats/Navigation/ChatLinkRouter.swift b/MC1/Views/Chats/Navigation/ChatLinkRouter.swift index 2e370a1c..4d170355 100644 --- a/MC1/Views/Chats/Navigation/ChatLinkRouter.swift +++ b/MC1/Views/Chats/Navigation/ChatLinkRouter.swift @@ -1,120 +1,141 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let chatLinkRouterLogger = Logger(subsystem: "com.mc1", category: "ChatLinkRouter") /// Shared routing for chat-content URLs. Single source of truth for what counts -/// as a "chat-relevant" tap inside any chat surface. `ChatsView` calls it today; -/// `ChatConversationView` forwards non-mention URLs here once its child -/// `OpenURLAction` is wired. Mutates `appState.navigation` and spawns +/// as a "chat-relevant" tap inside any chat surface. Two caller sets: in-chat +/// surfaces (`ChatsView`, and `ChatConversationView` via the `OpenURLAction` +/// that forwards its non-mention URLs) and the app-lifecycle path +/// (`MC1App.onOpenURL` via `routeExternalOpen`, for URLs iOS hands over from a +/// scanned QR code or another app). Mutates `appState.navigation` and spawns /// fire-and-forget `Task`s for async lookups. /// -/// Returns `true` for any chat-relevant scheme so SwiftUI does not fall through -/// to the system URL handler; returns `false` only for truly external schemes -/// (`http`, `https`, `mailto`, …). +/// Returns `true` for any URL whose scheme (and host, for `meshcoreone`) the +/// router claims, so SwiftUI does not fall through to the system URL handler; +/// returns `false` for truly external schemes (`http`, `https`, `mailto`, …) +/// and for a `meshcore://` URL no parser could match. /// /// Does not handle `meshcoreone://mention/...` — that scheme is intentionally /// local to `ChatConversationView`, which intercepts mentions in its own child /// `OpenURLAction` before forwarding everything else to this router. @MainActor enum ChatLinkRouter { + static func route(_ url: URL, appState: AppState) -> Bool { + if url.scheme == MeshCoreURLParser.scheme { + return handleMeshCoreLink(url, appState: appState) + } + if url.scheme == HashtagDeeplinkSupport.scheme, + url.host == HashtagDeeplinkSupport.host { + if let channelName = HashtagDeeplinkSupport.channelNameFromURL(url) { + handleHashtagTap(name: channelName, appState: appState) + } else { + chatLinkRouterLogger.error("Hashtag URL missing host: \(url.absoluteString, privacy: .public)") + } + return true + } + return false + } - static func route(_ url: URL, appState: AppState) -> Bool { - if url.scheme == MeshCoreURLParser.scheme { - handleMeshCoreLink(url, appState: appState) - return true - } - if url.scheme == HashtagDeeplinkSupport.scheme, - url.host == HashtagDeeplinkSupport.host { - if let channelName = HashtagDeeplinkSupport.channelNameFromURL(url) { - handleHashtagTap(name: channelName, appState: appState) - } else { - chatLinkRouterLogger.error("Hashtag URL missing host: \(url.absoluteString, privacy: .public)") - } - return true - } - return false + /// Routes a URL iOS handed to the app from outside a chat (a scanned QR code + /// or a tap from another app), via `MC1App.onOpenURL`. Switches to the Chats + /// tab first so the new-contact/new-channel confirmation sheets, which only + /// present from Chats, have their host; restores the prior tab when nothing + /// was handled (`meshcoreone://status`, or a malformed `meshcore://` URL). + @discardableResult + static func routeExternalOpen(_ url: URL, appState: AppState) -> Bool { + let previousTab = appState.navigation.selectedTab + appState.navigation.selectedTab = AppTab.chats.rawValue + let handled = route(url, appState: appState) + if !handled { + appState.navigation.selectedTab = previousTab } + return handled + } - private static func handleMeshCoreLink(_ url: URL, appState: AppState) { - let urlString = url.absoluteString + private static func handleMeshCoreLink(_ url: URL, appState: AppState) -> Bool { + let urlString = url.absoluteString - if let coordinate = MeshCoreURLParser.parseMapURL(urlString) { - appState.navigation.navigateToMap(coordinate: coordinate) - } else if let contactResult = MeshCoreURLParser.parseContactURL(urlString) { - handleContactLink(contactResult, appState: appState) - } else if let channelResult = MeshCoreURLParser.parseChannelURL(urlString) { - handleChannelLink(channelResult, appState: appState) - } else { - chatLinkRouterLogger.error("Failed to parse meshcore URL: \(urlString, privacy: .public)") - } + if let coordinate = MeshCoreURLParser.parseMapURL(urlString) { + appState.navigation.navigateToMap(coordinate: coordinate) + return true + } else if let contactResult = MeshCoreURLParser.parseContactURL(urlString) { + handleContactLink(contactResult, appState: appState) + return true + } else if let channelResult = MeshCoreURLParser.parseChannelURL(urlString) { + handleChannelLink(channelResult, appState: appState) + return true + } else { + chatLinkRouterLogger.error("Failed to parse meshcore URL: \(urlString, privacy: .public)") + return false } + } - private static func handleContactLink( - _ result: MeshCoreURLParser.ContactResult, - appState: AppState - ) { - Task { @MainActor in - if result.publicKey == appState.connectedDevice?.publicKey { - return - } + private static func handleContactLink( + _ result: MeshCoreURLParser.ContactResult, + appState: AppState + ) { + Task { @MainActor in + if result.publicKey == appState.connectedDevice?.publicKey { + return + } - if let deviceID = appState.currentRadioID, - let existingContact = try? await appState.offlineDataStore?.fetchContact( - radioID: deviceID, - publicKey: result.publicKey - ) { - appState.navigation.navigateToContactDetail(existingContact) - } else { - appState.navigation.pendingContactLink = result - } - } + if let deviceID = appState.currentRadioID, + let existingContact = try? await appState.offlineDataStore?.fetchContact( + radioID: deviceID, + publicKey: result.publicKey + ) { + appState.navigation.navigateToContactDetail(existingContact) + } else { + appState.navigation.pendingContactLink = result + } } + } - private static func handleChannelLink( - _ result: MeshCoreURLParser.ChannelResult, - appState: AppState - ) { - Task { @MainActor in - if let deviceID = appState.currentRadioID, - let channels = try? await appState.offlineDataStore?.fetchChannels(radioID: deviceID), - let existingChannel = channels.first(where: { $0.secret == result.secret }) { - appState.navigation.navigateToChannel(with: existingChannel) - } else { - appState.navigation.pendingChannelLink = result - } - } + private static func handleChannelLink( + _ result: MeshCoreURLParser.ChannelResult, + appState: AppState + ) { + Task { @MainActor in + if let deviceID = appState.currentRadioID, + let channels = try? await appState.offlineDataStore?.fetchChannels(radioID: deviceID), + let existingChannel = channels.first(where: { $0.secret == result.secret }) { + appState.navigation.navigateToChannel(with: existingChannel) + } else { + appState.navigation.pendingChannelLink = result + } } + } - private static func handleHashtagTap(name: String, appState: AppState) { - Task { @MainActor in - guard let fullName = HashtagDeeplinkSupport.fullChannelName(from: name) else { - chatLinkRouterLogger.error("Invalid hashtag name in tap: \(name, privacy: .public)") - return - } + private static func handleHashtagTap(name: String, appState: AppState) { + Task { @MainActor in + guard let fullName = HashtagDeeplinkSupport.fullChannelName(from: name) else { + chatLinkRouterLogger.error("Invalid hashtag name in tap: \(name, privacy: .public)") + return + } - guard let deviceID = appState.currentRadioID else { - appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) - return - } + guard let deviceID = appState.currentRadioID else { + appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) + return + } - do { - if let channel = try await HashtagDeeplinkSupport.findChannelByName( - fullName, - radioID: deviceID, - fetchChannels: { deviceID in - try await appState.offlineDataStore?.fetchChannels(radioID: deviceID) ?? [] - } - ) { - appState.navigation.navigateToChannel(with: channel) - } else { - appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) - } - } catch { - chatLinkRouterLogger.error("Failed to fetch channels for hashtag lookup: \(error)") - appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) - } + do { + if let channel = try await HashtagDeeplinkSupport.findChannelByName( + fullName, + radioID: deviceID, + fetchChannels: { deviceID in + try await appState.offlineDataStore?.fetchChannels(radioID: deviceID) ?? [] + } + ) { + appState.navigation.navigateToChannel(with: channel) + } else { + appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) } + } catch { + chatLinkRouterLogger.error("Failed to fetch channels for hashtag lookup: \(error)") + appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) + } } + } } diff --git a/MC1/Views/Chats/Navigation/ChatRoute.swift b/MC1/Views/Chats/Navigation/ChatRoute.swift index 8b6a63d9..167d4ce1 100644 --- a/MC1/Views/Chats/Navigation/ChatRoute.swift +++ b/MC1/Views/Chats/Navigation/ChatRoute.swift @@ -2,82 +2,82 @@ import Foundation import MC1Services enum ChatRoute: Hashable { - case direct(ContactDTO) - case channel(ChannelDTO) - case room(RemoteNodeSessionDTO) + case direct(ContactDTO) + case channel(ChannelDTO) + case room(RemoteNodeSessionDTO) - enum Kind: UInt8, Hashable { - case direct - case channel - case room - } + enum Kind: UInt8, Hashable { + case direct + case channel + case room + } - var kind: Kind { - switch self { - case .direct: - return .direct - case .channel: - return .channel - case .room: - return .room - } + var kind: Kind { + switch self { + case .direct: + .direct + case .channel: + .channel + case .room: + .room } + } - var conversationID: UUID { - switch self { - case .direct(let contact): - return contact.id - case .channel(let channel): - return channel.id - case .room(let session): - return session.id - } + var conversationID: UUID { + switch self { + case let .direct(contact): + contact.id + case let .channel(channel): + channel.id + case let .room(session): + session.id } + } - static func == (lhs: ChatRoute, rhs: ChatRoute) -> Bool { - lhs.kind == rhs.kind && lhs.conversationID == rhs.conversationID - } + static func == (lhs: ChatRoute, rhs: ChatRoute) -> Bool { + lhs.kind == rhs.kind && lhs.conversationID == rhs.conversationID + } - func hash(into hasher: inout Hasher) { - hasher.combine(kind) - hasher.combine(conversationID) - } + func hash(into hasher: inout Hasher) { + hasher.combine(kind) + hasher.combine(conversationID) + } - init(conversation: Conversation) { - switch conversation { - case .direct(let contact): - self = .direct(contact) - case .channel(let channel): - self = .channel(channel) - case .room(let session): - self = .room(session) - } + init(conversation: Conversation) { + switch conversation { + case let .direct(contact): + self = .direct(contact) + case let .channel(channel): + self = .channel(channel) + case let .room(session): + self = .room(session) } + } - var roomIsConnected: Bool? { - guard case .room(let session) = self else { return nil } - return session.isConnected - } + var roomIsConnected: Bool? { + guard case let .room(session) = self else { return nil } + return session.isConnected + } - func toConversation() -> Conversation { - switch self { - case .direct(let contact): - return .direct(contact) - case .channel(let channel): - return .channel(channel) - case .room(let session): - return .room(session) - } + func toConversation() -> Conversation { + switch self { + case let .direct(contact): + .direct(contact) + case let .channel(channel): + .channel(channel) + case let .room(session): + .room(session) } + } - func refreshedPayload(from conversations: [Conversation]) -> ChatRoute? { - guard let match = conversations.first(where: { conversation in - let route = ChatRoute(conversation: conversation) - return route.kind == kind && route.conversationID == conversationID - }) else { - return nil - } - - return ChatRoute(conversation: match) + func refreshedPayload(from conversations: [Conversation]) -> ChatRoute? { + guard let match = conversations.first(where: { conversation in + let route = ChatRoute(conversation: conversation) + return route.kind == kind && route.conversationID == conversationID + }) else { + return nil } + + return ChatRoute(conversation: match) + } } diff --git a/MC1/Views/Chats/Navigation/ChatsContentColumn.swift b/MC1/Views/Chats/Navigation/ChatsContentColumn.swift index 90524a0e..0baafdd0 100644 --- a/MC1/Views/Chats/Navigation/ChatsContentColumn.swift +++ b/MC1/Views/Chats/Navigation/ChatsContentColumn.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// The iPad sidebar's Chats content column. It mirrors the regular-width (split) path of /// `ChatsView`, supplying real action closures to `ChatsSplitSidebarContent` and attaching @@ -7,155 +7,155 @@ import MC1Services /// solely in `ChatsView`. The `viewModel` is passed in so the content and detail columns /// share one instance. struct ChatsContentColumn: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel + let viewModel: ChatViewModel - @State private var searchText = "" - @State private var selectedFilter: ChatFilter = .all - @State private var showingNewChat = false - @State private var showingChannelOptions = false + @State private var searchText = "" + @State private var selectedFilter: ChatFilter = .all + @State private var showingNewChat = false + @State private var showingChannelOptions = false - /// View-local mirror of `appState.navigation.chatsSelectedRoute`; the detail column keys - /// off the latter, and `ChatsSplitSidebarContent.onChange` keeps the two in sync. Re-seeded - /// from the preserved route in `.task` because leaving and re-entering Chats rebuilds this - /// column and resets the mirror, which would otherwise un-highlight the row the detail shows. - @State private var selectedRoute: ChatRoute? - @State private var lastSelectedRoomIsConnected: Bool? + /// View-local mirror of `appState.navigation.chatsSelectedRoute`; the detail column keys + /// off the latter, and `ChatsSplitSidebarContent.onChange` keeps the two in sync. Re-seeded + /// from the preserved route in `.task` because leaving and re-entering Chats rebuilds this + /// column and resets the mirror, which would otherwise un-highlight the row the detail shows. + @State private var selectedRoute: ChatRoute? + @State private var lastSelectedRoomIsConnected: Bool? - @State private var roomToAuthenticate: RemoteNodeSessionDTO? - @State private var roomToDelete: RemoteNodeSessionDTO? - @State private var showRoomDeleteAlert = false - @State private var showChannelDeleteFailed = false - @State private var channelDeleteFailure: ChatConversationActions.Failure? - @State private var pendingChatContact: ContactDTO? - @State private var pendingChannel: ChannelDTO? + @State private var roomToAuthenticate: RemoteNodeSessionDTO? + @State private var roomToDelete: RemoteNodeSessionDTO? + @State private var showRoomDeleteAlert = false + @State private var showChannelDeleteFailed = false + @State private var channelDeleteFailure: ChatConversationActions.Failure? + @State private var pendingChatContact: ContactDTO? + @State private var pendingChannel: ChannelDTO? - private var filteredFavorites: [Conversation] { - viewModel.favoriteConversations.filtered(by: selectedFilter, searchText: searchText) - } + private var filteredFavorites: [Conversation] { + viewModel.favoriteConversations.filtered(by: selectedFilter, searchText: searchText) + } - private var filteredOthers: [Conversation] { - viewModel.nonFavoriteConversations.filtered(by: selectedFilter, searchText: searchText) - } + private var filteredOthers: [Conversation] { + viewModel.nonFavoriteConversations.filtered(by: selectedFilter, searchText: searchText) + } - private var emptyStateMessage: (title: String, description: String, systemImage: String) { - switch selectedFilter { - case .all: - return (L10n.Chats.Chats.EmptyState.NoConversations.title, L10n.Chats.Chats.EmptyState.NoConversations.description, "message") - case .unread: - return (L10n.Chats.Chats.EmptyState.NoUnread.title, L10n.Chats.Chats.EmptyState.NoUnread.description, "checkmark.circle") - case .directMessages: - return (L10n.Chats.Chats.EmptyState.NoDirectMessages.title, L10n.Chats.Chats.EmptyState.NoDirectMessages.description, "person") - case .channels: - return (L10n.Chats.Chats.EmptyState.NoChannels.title, L10n.Chats.Chats.EmptyState.NoChannels.description, "number") - case .rooms: - return (L10n.Chats.Chats.EmptyState.NoRooms.title, L10n.Chats.Chats.EmptyState.NoRooms.description, "door.left.hand.open") - } + private var emptyStateMessage: (title: String, description: String, systemImage: String) { + switch selectedFilter { + case .all: + (L10n.Chats.Chats.EmptyState.NoConversations.title, L10n.Chats.Chats.EmptyState.NoConversations.description, "message") + case .unread: + (L10n.Chats.Chats.EmptyState.NoUnread.title, L10n.Chats.Chats.EmptyState.NoUnread.description, "checkmark.circle") + case .directMessages: + (L10n.Chats.Chats.EmptyState.NoDirectMessages.title, L10n.Chats.Chats.EmptyState.NoDirectMessages.description, "person") + case .channels: + (L10n.Chats.Chats.EmptyState.NoChannels.title, L10n.Chats.Chats.EmptyState.NoChannels.description, "number") + case .rooms: + (L10n.Chats.Chats.EmptyState.NoRooms.title, L10n.Chats.Chats.EmptyState.NoRooms.description, "door.left.hand.open") } + } - private var actions: ChatListActions { - ChatListActions( - viewModel: viewModel, - appState: appState, - roomToDelete: $roomToDelete, - showRoomDeleteAlert: $showRoomDeleteAlert, - channelDeleteFailure: $channelDeleteFailure, - showChannelDeleteFailed: $showChannelDeleteFailed, - roomToAuthenticate: $roomToAuthenticate, - navigate: { navigate(to: $0) }, - clearNavigationIfActive: clearNavigationIfActive - ) - } + private var actions: ChatListActions { + ChatListActions( + viewModel: viewModel, + appState: appState, + roomToDelete: $roomToDelete, + showRoomDeleteAlert: $showRoomDeleteAlert, + channelDeleteFailure: $channelDeleteFailure, + showChannelDeleteFailed: $showChannelDeleteFailed, + roomToAuthenticate: $roomToAuthenticate, + navigate: { navigate(to: $0) }, + clearNavigationIfActive: clearNavigationIfActive + ) + } - var body: some View { - ChatsSplitSidebarContent( - viewModel: viewModel, - filteredFavorites: filteredFavorites, - filteredOthers: filteredOthers, - emptyStateMessage: emptyStateMessage, - hasLoadedOnce: viewModel.hasLoadedOnce, - selectedRoute: $selectedRoute, - selectedFilter: $selectedFilter, - searchText: $searchText, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - roomToAuthenticate: $roomToAuthenticate, - lastSelectedRoomIsConnected: $lastSelectedRoomIsConnected, - onDeleteConversation: actions.handleDeleteConversation, - onHandlePendingNavigation: actions.handlePendingNavigation, - onHandlePendingChannelNavigation: actions.handlePendingChannelNavigation, - onHandlePendingRoomNavigation: actions.handlePendingRoomNavigation, - onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded - ) - .task { - seedSelectionFromPreservedRoute() - actions.consumePendingRoomAuthentication() - } - .onChange(of: appState.navigation.pendingRoomAuthentication) { _, _ in - actions.consumePendingRoomAuthentication() - } - .onChange(of: appState.navigation.chatsSelectedRoute) { _, newRoute in - // The detail column keys off chatsSelectedRoute, but the list highlight keys off the - // view-local mirror. An external clear (a radio switch runs clearPerRadioSelection while - // Chats stays mounted, so .task never re-seeds) only nils the coordinator route, so drop - // the mirror here too to keep the list highlight and detail pane in agreement. - if newRoute == nil { - selectedRoute = nil - lastSelectedRoomIsConnected = nil - } - } - // Refresh the selected route's payload as the snapshot recomputes and re-run the - // room reauth guard; a route whose conversation was removed resolves to nil and clears. - .onChange(of: viewModel.snapshotGeneration) { _, _ in - let refreshed = selectedRoute?.refreshedPayload(from: viewModel.allConversations) - selectedRoute = refreshed + var body: some View { + ChatsSplitSidebarContent( + viewModel: viewModel, + filteredFavorites: filteredFavorites, + filteredOthers: filteredOthers, + emptyStateMessage: emptyStateMessage, + hasLoadedOnce: viewModel.hasLoadedOnce, + selectedRoute: $selectedRoute, + selectedFilter: $selectedFilter, + searchText: $searchText, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + roomToAuthenticate: $roomToAuthenticate, + lastSelectedRoomIsConnected: $lastSelectedRoomIsConnected, + onDeleteConversation: actions.handleDeleteConversation, + onHandlePendingNavigation: actions.handlePendingNavigation, + onHandlePendingChannelNavigation: actions.handlePendingChannelNavigation, + onHandlePendingRoomNavigation: actions.handlePendingRoomNavigation, + onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded + ) + .task { + seedSelectionFromPreservedRoute() + actions.consumePendingRoomAuthentication() + } + .onChange(of: appState.navigation.pendingRoomAuthentication) { _, _ in + actions.consumePendingRoomAuthentication() + } + .onChange(of: appState.navigation.chatsSelectedRoute) { _, newRoute in + // The detail column keys off chatsSelectedRoute, but the list highlight keys off the + // view-local mirror. An external clear (a radio switch runs clearPerRadioSelection while + // Chats stays mounted, so .task never re-seeds) only nils the coordinator route, so drop + // the mirror here too to keep the list highlight and detail pane in agreement. + if newRoute == nil { + selectedRoute = nil + lastSelectedRoomIsConnected = nil + } + } + // Refresh the selected route's payload as the snapshot recomputes and re-run the + // room reauth guard; a route whose conversation was removed resolves to nil and clears. + .onChange(of: viewModel.snapshotGeneration) { _, _ in + let refreshed = selectedRoute?.refreshedPayload(from: viewModel.allConversations) + selectedRoute = refreshed - if lastSelectedRoomIsConnected == true, - case .room(let session) = selectedRoute, - !session.isConnected { - roomToAuthenticate = session - selectedRoute = nil - } + if lastSelectedRoomIsConnected == true, + case let .room(session) = selectedRoute, + !session.isConnected { + roomToAuthenticate = session + selectedRoute = nil + } - lastSelectedRoomIsConnected = selectedRoute?.roomIsConnected - } - .modifier(ChatsConversationSheets( - viewModel: viewModel, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - roomToAuthenticate: $roomToAuthenticate, - roomToDelete: $roomToDelete, - showRoomDeleteAlert: $showRoomDeleteAlert, - channelDeleteFailure: $channelDeleteFailure, - showChannelDeleteFailed: $showChannelDeleteFailed, - pendingChatContact: $pendingChatContact, - pendingChannel: $pendingChannel, - navigate: { navigate(to: $0) }, - deleteChannelConversation: actions.deleteChannelConversation, - deleteRoom: actions.deleteRoom - )) + lastSelectedRoomIsConnected = selectedRoute?.roomIsConnected } + .modifier(ChatsConversationSheets( + viewModel: viewModel, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + roomToAuthenticate: $roomToAuthenticate, + roomToDelete: $roomToDelete, + showRoomDeleteAlert: $showRoomDeleteAlert, + channelDeleteFailure: $channelDeleteFailure, + showChannelDeleteFailed: $showChannelDeleteFailed, + pendingChatContact: $pendingChatContact, + pendingChannel: $pendingChannel, + navigate: { navigate(to: $0) }, + deleteChannelConversation: actions.deleteChannelConversation, + deleteRoom: actions.deleteRoom + )) + } - private func navigate(to route: ChatRoute) { - selectedRoute = route - appState.navigation.chatsSelectedRoute = route - } + private func navigate(to route: ChatRoute) { + selectedRoute = route + appState.navigation.chatsSelectedRoute = route + } - private func clearNavigationIfActive(_ route: ChatRoute) { - if appState.navigation.chatsSelectedRoute == route { - selectedRoute = nil - appState.navigation.chatsSelectedRoute = nil - } + private func clearNavigationIfActive(_ route: ChatRoute) { + if appState.navigation.chatsSelectedRoute == route { + selectedRoute = nil + appState.navigation.chatsSelectedRoute = nil } + } - /// Restores the view-local selection from the route preserved in `NavigationCoordinator` when - /// this column is rebuilt on Chats re-entry, so the list re-highlights the row the detail pane - /// is still showing and the room-reauth guard regains its `lastSelectedRoomIsConnected` baseline. - /// Skips when a selection already exists so a pending navigation that ran first is not clobbered. - private func seedSelectionFromPreservedRoute() { - guard selectedRoute == nil, let route = appState.navigation.chatsSelectedRoute else { return } - selectedRoute = route - lastSelectedRoomIsConnected = route.roomIsConnected - } + /// Restores the view-local selection from the route preserved in `NavigationCoordinator` when + /// this column is rebuilt on Chats re-entry, so the list re-highlights the row the detail pane + /// is still showing and the room-reauth guard regains its `lastSelectedRoomIsConnected` baseline. + /// Skips when a selection already exists so a pending navigation that ran first is not clobbered. + private func seedSelectionFromPreservedRoute() { + guard selectedRoute == nil, let route = appState.navigation.chatsSelectedRoute else { return } + selectedRoute = route + lastSelectedRoomIsConnected = route.roomIsConnected + } } diff --git a/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift b/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift index 5a31178b..d6caa434 100644 --- a/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift +++ b/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift @@ -1,24 +1,24 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsSplitDetailContent: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel + let viewModel: ChatViewModel - var body: some View { - switch appState.navigation.chatsSelectedRoute { - case .direct(let contact): - ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) - .id(contact.id) - case .channel(let channel): - ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) - .id(channel.id) - case .room(let session): - RoomConversationView(session: session) - .id(session.id) - case .none: - ContentUnavailableView(L10n.Chats.Chats.EmptyState.selectConversation, systemImage: "message") - } + var body: some View { + switch appState.navigation.chatsSelectedRoute { + case let .direct(contact): + ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) + .id(contact.id) + case let .channel(channel): + ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) + .id(channel.id) + case let .room(session): + RoomConversationView(session: session) + .id(session.id) + case .none: + ContentUnavailableView(L10n.Chats.Chats.EmptyState.selectConversation, systemImage: "message") } + } } diff --git a/MC1/Views/Chats/Navigation/ChatsSplitSidebarContent.swift b/MC1/Views/Chats/Navigation/ChatsSplitSidebarContent.swift index 5fb1c842..d36784c7 100644 --- a/MC1/Views/Chats/Navigation/ChatsSplitSidebarContent.swift +++ b/MC1/Views/Chats/Navigation/ChatsSplitSidebarContent.swift @@ -1,73 +1,72 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsSplitSidebarContent: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel - let filteredFavorites: [Conversation] - let filteredOthers: [Conversation] - let emptyStateMessage: (title: String, description: String, systemImage: String) - let hasLoadedOnce: Bool + let viewModel: ChatViewModel + let filteredFavorites: [Conversation] + let filteredOthers: [Conversation] + let emptyStateMessage: (title: String, description: String, systemImage: String) + let hasLoadedOnce: Bool - @Binding var selectedRoute: ChatRoute? - @Binding var selectedFilter: ChatFilter - @Binding var searchText: String - @Binding var showingNewChat: Bool - @Binding var showingChannelOptions: Bool - @Binding var roomToAuthenticate: RemoteNodeSessionDTO? - @Binding var lastSelectedRoomIsConnected: Bool? + @Binding var selectedRoute: ChatRoute? + @Binding var selectedFilter: ChatFilter + @Binding var searchText: String + @Binding var showingNewChat: Bool + @Binding var showingChannelOptions: Bool + @Binding var roomToAuthenticate: RemoteNodeSessionDTO? + @Binding var lastSelectedRoomIsConnected: Bool? - let onDeleteConversation: (Conversation) -> Void - let onHandlePendingNavigation: () -> Void - let onHandlePendingChannelNavigation: () -> Void - let onHandlePendingRoomNavigation: () -> Void - let onAnnounceOfflineStateIfNeeded: () -> Void + let onDeleteConversation: (Conversation) -> Void + let onHandlePendingNavigation: () -> Void + let onHandlePendingChannelNavigation: () -> Void + let onHandlePendingRoomNavigation: () -> Void + let onAnnounceOfflineStateIfNeeded: () -> Void - var body: some View { - ConversationListContent( - viewModel: viewModel, - favoriteConversations: filteredFavorites, - otherConversations: filteredOthers, - selectedFilter: $selectedFilter, - hasLoadedOnce: hasLoadedOnce, - emptyStateMessage: emptyStateMessage, - selection: $selectedRoute, - onDeleteConversation: onDeleteConversation - ) - .modifier(ChatsListModifiers( - viewModel: viewModel, - searchText: $searchText, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - onAnnounceOfflineStateIfNeeded: onAnnounceOfflineStateIfNeeded, - onHandlePendingNavigation: onHandlePendingNavigation, - onHandlePendingChannelNavigation: onHandlePendingChannelNavigation, - onHandlePendingRoomNavigation: onHandlePendingRoomNavigation - )) - .onChange(of: selectedRoute) { oldValue, newValue in - // Reload when navigating away from a selection. The funnel and pendingRemovalIDs - // keep a delete that cleared the selection from resurrecting the row through this reload. - if oldValue != nil { - viewModel.requestConversationReload() - } + var body: some View { + ConversationListContent( + viewModel: viewModel, + favoriteConversations: filteredFavorites, + otherConversations: filteredOthers, + selectedFilter: $selectedFilter, + hasLoadedOnce: hasLoadedOnce, + emptyStateMessage: emptyStateMessage, + selection: $selectedRoute, + onDeleteConversation: onDeleteConversation + ) + .modifier(ChatsListModifiers( + viewModel: viewModel, + searchText: $searchText, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + onAnnounceOfflineStateIfNeeded: onAnnounceOfflineStateIfNeeded, + onHandlePendingNavigation: onHandlePendingNavigation, + onHandlePendingChannelNavigation: onHandlePendingChannelNavigation, + onHandlePendingRoomNavigation: onHandlePendingRoomNavigation + )) + .onChange(of: selectedRoute) { oldValue, newValue in + // Reload when navigating away from a selection. The funnel and pendingRemovalIDs + // keep a delete that cleared the selection from resurrecting the row through this reload. + if oldValue != nil { + viewModel.requestConversationReload() + } - if case .room(let session) = newValue, !session.isConnected { - roomToAuthenticate = session - selectedRoute = nil - appState.navigation.chatsSelectedRoute = nil - lastSelectedRoomIsConnected = nil - return - } + if case let .room(session) = newValue, !session.isConnected { + roomToAuthenticate = session + selectedRoute = nil + appState.navigation.chatsSelectedRoute = nil + lastSelectedRoomIsConnected = nil + return + } - lastSelectedRoomIsConnected = newValue?.roomIsConnected + lastSelectedRoomIsConnected = newValue?.roomIsConnected - // Mirror the sidebar selection to AppState so the detail pane tracks it. Assigned - // unconditionally, including nil: the disconnected-room reload path clears the local - // selection without writing `chatsSelectedRoute`, so mirroring nil here is what dismisses - // the now-stale detail pane. - appState.navigation.chatsSelectedRoute = newValue - } + // Mirror the sidebar selection to AppState so the detail pane tracks it. Assigned + // unconditionally, including nil: the disconnected-room reload path clears the local + // selection without writing `chatsSelectedRoute`, so mirroring nil here is what dismisses + // the now-stale detail pane. + appState.navigation.chatsSelectedRoute = newValue } - + } } diff --git a/MC1/Views/Chats/Navigation/ChatsStackLayout.swift b/MC1/Views/Chats/Navigation/ChatsStackLayout.swift index 1f29b92a..ec3f0250 100644 --- a/MC1/Views/Chats/Navigation/ChatsStackLayout.swift +++ b/MC1/Views/Chats/Navigation/ChatsStackLayout.swift @@ -1,47 +1,47 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsStackLayout: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel - @Binding var navigationPath: NavigationPath - @Binding var activeRoute: ChatRoute? + let viewModel: ChatViewModel + @Binding var navigationPath: NavigationPath + @Binding var activeRoute: ChatRoute? - @ViewBuilder let rootContent: RootContent + @ViewBuilder let rootContent: RootContent - var body: some View { - NavigationStack(path: $navigationPath) { - rootContent - .navigationDestination(for: ChatRoute.self) { route in - Group { - switch route { - case .direct(let contact): - ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) - .id(contact.id) + var body: some View { + NavigationStack(path: $navigationPath) { + rootContent + .navigationDestination(for: ChatRoute.self) { route in + Group { + switch route { + case let .direct(contact): + ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) + .id(contact.id) - case .channel(let channel): - ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) - .id(channel.id) + case let .channel(channel): + ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) + .id(channel.id) - case .room(let session): - RoomConversationView(session: session) - .id(session.id) - } - } - .onAppear { - activeRoute = route - appState.navigation.tabBarVisibility = .hidden - } - } - .onChange(of: navigationPath) { _, newPath in - if newPath.isEmpty { - activeRoute = nil - appState.navigation.tabBarVisibility = .visible - viewModel.requestConversationReload() - } - } - .toolbarVisibility(appState.navigation.tabBarVisibility, for: .tabBar) + case let .room(session): + RoomConversationView(session: session) + .id(session.id) + } + } + .onAppear { + activeRoute = route + appState.navigation.tabBarVisibility = .hidden + } + } + .onChange(of: navigationPath) { _, newPath in + if newPath.isEmpty { + activeRoute = nil + appState.navigation.tabBarVisibility = .visible + viewModel.requestConversationReload() + } } + .toolbarVisibility(appState.navigation.tabBarVisibility, for: .tabBar) } + } } diff --git a/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift b/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift index 5a9519ed..9791909f 100644 --- a/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift +++ b/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift @@ -1,47 +1,47 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsStackRootContent: View { - let viewModel: ChatViewModel - let filteredFavorites: [Conversation] - let filteredOthers: [Conversation] - let emptyStateMessage: (title: String, description: String, systemImage: String) - let hasLoadedOnce: Bool + let viewModel: ChatViewModel + let filteredFavorites: [Conversation] + let filteredOthers: [Conversation] + let emptyStateMessage: (title: String, description: String, systemImage: String) + let hasLoadedOnce: Bool - @Binding var selectedFilter: ChatFilter - @Binding var searchText: String - @Binding var showingNewChat: Bool - @Binding var showingChannelOptions: Bool - @Binding var roomToAuthenticate: RemoteNodeSessionDTO? - @Binding var navigationPath: NavigationPath + @Binding var selectedFilter: ChatFilter + @Binding var searchText: String + @Binding var showingNewChat: Bool + @Binding var showingChannelOptions: Bool + @Binding var roomToAuthenticate: RemoteNodeSessionDTO? + @Binding var navigationPath: NavigationPath - let onDeleteConversation: (Conversation) -> Void - let onHandlePendingNavigation: () -> Void - let onHandlePendingChannelNavigation: () -> Void - let onHandlePendingRoomNavigation: () -> Void - let onAnnounceOfflineStateIfNeeded: () -> Void + let onDeleteConversation: (Conversation) -> Void + let onHandlePendingNavigation: () -> Void + let onHandlePendingChannelNavigation: () -> Void + let onHandlePendingRoomNavigation: () -> Void + let onAnnounceOfflineStateIfNeeded: () -> Void - var body: some View { - ConversationListContent( - viewModel: viewModel, - favoriteConversations: filteredFavorites, - otherConversations: filteredOthers, - selectedFilter: $selectedFilter, - hasLoadedOnce: hasLoadedOnce, - emptyStateMessage: emptyStateMessage, - onNavigate: { navigationPath.append($0) }, - onRequestRoomAuth: { roomToAuthenticate = $0 }, - onDeleteConversation: onDeleteConversation - ) - .modifier(ChatsListModifiers( - viewModel: viewModel, - searchText: $searchText, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - onAnnounceOfflineStateIfNeeded: onAnnounceOfflineStateIfNeeded, - onHandlePendingNavigation: onHandlePendingNavigation, - onHandlePendingChannelNavigation: onHandlePendingChannelNavigation, - onHandlePendingRoomNavigation: onHandlePendingRoomNavigation - )) - } + var body: some View { + ConversationListContent( + viewModel: viewModel, + favoriteConversations: filteredFavorites, + otherConversations: filteredOthers, + selectedFilter: $selectedFilter, + hasLoadedOnce: hasLoadedOnce, + emptyStateMessage: emptyStateMessage, + onNavigate: { navigationPath.append($0) }, + onRequestRoomAuth: { roomToAuthenticate = $0 }, + onDeleteConversation: onDeleteConversation + ) + .modifier(ChatsListModifiers( + viewModel: viewModel, + searchText: $searchText, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + onAnnounceOfflineStateIfNeeded: onAnnounceOfflineStateIfNeeded, + onHandlePendingNavigation: onHandlePendingNavigation, + onHandlePendingChannelNavigation: onHandlePendingChannelNavigation, + onHandlePendingRoomNavigation: onHandlePendingRoomNavigation + )) + } } diff --git a/MC1/Views/Chats/NewChatView.swift b/MC1/Views/Chats/NewChatView.swift index 4f0edc77..adc82dcf 100644 --- a/MC1/Views/Chats/NewChatView.swift +++ b/MC1/Views/Chats/NewChatView.swift @@ -1,95 +1,97 @@ -import SwiftUI import MC1Services +import SwiftUI struct NewChatView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - let onSelectContact: (ContactDTO) -> Void + let onSelectContact: (ContactDTO) -> Void - @State private var contacts: [ContactDTO] = [] - @State private var searchText = "" - @State private var isLoading = false + @State private var contacts: [ContactDTO] = [] + @State private var searchText = "" + @State private var isLoading = false - private var filteredContacts: [ContactDTO] { - let eligible = contacts.filter { !$0.isBlocked && $0.type != .repeater && $0.type != .room } - guard !searchText.isEmpty else { return eligible } - return eligible.filter { contact in - contact.displayName.localizedStandardContains(searchText) - } + private var filteredContacts: [ContactDTO] { + let eligible = contacts.filter { !$0.isBlocked && $0.type != .repeater && $0.type != .room } + guard !searchText.isEmpty else { return eligible } + return eligible.filter { contact in + contact.displayName.localizedStandardContains(searchText) } + } - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if contacts.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.NewChat.EmptyState.title, - systemImage: "person.2", - description: Text(L10n.Chats.Chats.NewChat.EmptyState.description) - ) - } else { - List(filteredContacts) { contact in - Button { - onSelectContact(contact) - } label: { - HStack(spacing: 12) { - ContactAvatar(contact: contact, size: 40) + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if contacts.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.NewChat.EmptyState.title, + systemImage: "person.2", + description: Text(L10n.Chats.Chats.NewChat.EmptyState.description) + ) + } else { + List(filteredContacts) { contact in + Button { + onSelectContact(contact) + } label: { + HStack(spacing: 12) { + ContactAvatar(contact: contact, size: 40) - VStack(alignment: .leading) { - Text(contact.displayName) - .font(.headline) + VStack(alignment: .leading) { + Text(contact.displayName) + .font(.headline) - Text(contactTypeLabel(for: contact)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - } - .contentShape(.rect) - } - .buttonStyle(.plain) - } - .themedCanvas(theme) - } - } - .navigationTitle(L10n.Chats.Chats.NewChat.title) - .navigationBarTitleDisplayMode(.inline) - .searchable(text: $searchText, prompt: L10n.Chats.Chats.NewChat.Search.placeholder) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - dismiss() - } + Text(contactTypeLabel(for: contact)) + .font(.caption) + .foregroundStyle(.secondary) } + + Spacer() + } + .contentShape(.rect) } - .task { - await loadContacts() - } + .buttonStyle(.plain) + } + .listStyle(.insetGrouped) + .contentMargins(.top, 0, for: .scrollContent) + .themedCanvas(theme) + } + } + .navigationTitle(L10n.Chats.Chats.NewChat.title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: L10n.Chats.Chats.NewChat.Search.placeholder) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + dismiss() + } } + } + .task { + await loadContacts() + } } + } - private func loadContacts() async { - guard let radioID = appState.connectedDevice?.radioID else { return } + private func loadContacts() async { + guard let radioID = appState.connectedDevice?.radioID else { return } - isLoading = true - contacts = (try? await appState.services?.dataStore.fetchContacts(radioID: radioID)) ?? [] - isLoading = false - } + isLoading = true + contacts = await (try? appState.services?.dataStore.fetchContacts(radioID: radioID)) ?? [] + isLoading = false + } - private func contactTypeLabel(for contact: ContactDTO) -> String { - switch contact.type { - case .chat: - return contact.isFloodRouted ? L10n.Chats.Chats.ConnectionStatus.floodRouting : L10n.Chats.Chats.NewChat.ContactType.direct - case .repeater: - return L10n.Chats.Chats.NewChat.ContactType.repeater - case .room: - return "" - } + private func contactTypeLabel(for contact: ContactDTO) -> String { + switch contact.type { + case .chat: + contact.isFloodRouted ? L10n.Chats.Chats.ConnectionStatus.floodRouting : L10n.Chats.Chats.NewChat.ContactType.direct + case .repeater: + L10n.Chats.Chats.NewChat.ContactType.repeater + case .room: + "" } + } } diff --git a/MC1/Views/Chats/NotificationLevelIndicator.swift b/MC1/Views/Chats/NotificationLevelIndicator.swift index f1b84d9e..98975848 100644 --- a/MC1/Views/Chats/NotificationLevelIndicator.swift +++ b/MC1/Views/Chats/NotificationLevelIndicator.swift @@ -1,23 +1,23 @@ -import SwiftUI import MC1Services +import SwiftUI struct NotificationLevelIndicator: View { - let level: NotificationLevel + let level: NotificationLevel - var body: some View { - switch level { - case .muted: - Image(systemName: "bell.slash") - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Chats.Chats.Row.muted) - case .mentionsOnly: - Image(systemName: "at") - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Chats.Chats.Row.mentionsOnly) - case .all: - EmptyView() - } + var body: some View { + switch level { + case .muted: + Image(systemName: "bell.slash") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Chats.Chats.Row.muted) + case .mentionsOnly: + Image(systemName: "at") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Chats.Chats.Row.mentionsOnly) + case .all: + EmptyView() } + } } diff --git a/MC1/Views/Chats/Reactions/EmojiCategoryHeader.swift b/MC1/Views/Chats/Reactions/EmojiCategoryHeader.swift index b2c37b0f..f8498d64 100644 --- a/MC1/Views/Chats/Reactions/EmojiCategoryHeader.swift +++ b/MC1/Views/Chats/Reactions/EmojiCategoryHeader.swift @@ -2,25 +2,25 @@ import SwiftUI /// Section header for emoji picker categories struct EmojiCategoryHeader: View { - let title: String + let title: String - var body: some View { - HStack { - Text(title) - .font(.subheadline) - .bold() - .foregroundStyle(.secondary) - Spacer() - } - .padding(.top, 16) - .padding(.bottom, 8) + var body: some View { + HStack { + Text(title) + .font(.subheadline) + .bold() + .foregroundStyle(.secondary) + Spacer() } + .padding(.top, 16) + .padding(.bottom, 8) + } } #Preview { - VStack { - EmojiCategoryHeader(title: "Frequently Used") - EmojiCategoryHeader(title: "Smileys & People") - } - .padding() + VStack { + EmojiCategoryHeader(title: "Frequently Used") + EmojiCategoryHeader(title: "Smileys & People") + } + .padding() } diff --git a/MC1/Views/Chats/Reactions/EmojiPickerRow.swift b/MC1/Views/Chats/Reactions/EmojiPickerRow.swift index 170e2dbc..07f86fb8 100644 --- a/MC1/Views/Chats/Reactions/EmojiPickerRow.swift +++ b/MC1/Views/Chats/Reactions/EmojiPickerRow.swift @@ -3,64 +3,64 @@ import SwiftUI /// Horizontal row of emoji buttons for quick reaction selection. /// Uses `ViewThatFits` to center when content fits, scrolling when it overflows. struct EmojiPickerRow: View { - let emojis: [String] - let onSelect: (String) -> Void - let onOpenKeyboard: () -> Void + let emojis: [String] + let onSelect: (String) -> Void + let onOpenKeyboard: () -> Void - var body: some View { - ViewThatFits(in: .horizontal) { - EmojiButtonRow(emojis: emojis, onSelect: onSelect, onOpenKeyboard: onOpenKeyboard) - .padding(.horizontal) + var body: some View { + ViewThatFits(in: .horizontal) { + EmojiButtonRow(emojis: emojis, onSelect: onSelect, onOpenKeyboard: onOpenKeyboard) + .padding(.horizontal) - ScrollView(.horizontal) { - EmojiButtonRow(emojis: emojis, onSelect: onSelect, onOpenKeyboard: onOpenKeyboard) - .padding(.horizontal) - } - .scrollIndicators(.hidden) - } - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + ScrollView(.horizontal) { + EmojiButtonRow(emojis: emojis, onSelect: onSelect, onOpenKeyboard: onOpenKeyboard) + .padding(.horizontal) + } + .scrollIndicators(.hidden) } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } } private struct EmojiButtonRow: View { - let emojis: [String] - let onSelect: (String) -> Void - let onOpenKeyboard: () -> Void - - var body: some View { - HStack(spacing: 8) { - ForEach(emojis, id: \.self) { emoji in - Button { - onSelect(emoji) - } label: { - Text(emoji) - .font(.title) - } - .buttonStyle(.plain) - .frame(width: 44, height: 44) - .background(.ultraThinMaterial, in: .circle) - .accessibilityLabel(emoji.emojiAccessibilityName) - } + let emojis: [String] + let onSelect: (String) -> Void + let onOpenKeyboard: () -> Void - Button(L10n.Chats.Reactions.moreEmojis, systemImage: "plus") { - onOpenKeyboard() - } - .font(.title2) - .foregroundStyle(.secondary) - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .frame(width: 44, height: 44) - .background(.ultraThinMaterial, in: .circle) + var body: some View { + HStack(spacing: 8) { + ForEach(emojis, id: \.self) { emoji in + Button { + onSelect(emoji) + } label: { + Text(emoji) + .font(.title) } + .buttonStyle(.plain) + .frame(width: 44, height: 44) + .background(.ultraThinMaterial, in: .circle) + .accessibilityLabel(emoji.emojiAccessibilityName) + } + + Button(L10n.Chats.Reactions.moreEmojis, systemImage: "plus") { + onOpenKeyboard() + } + .font(.title2) + .foregroundStyle(.secondary) + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .frame(width: 44, height: 44) + .background(.ultraThinMaterial, in: .circle) } + } } #Preview { - EmojiPickerRow( - emojis: ["👍", "👎", "❤️", "😂", "😮", "😢"], - onSelect: { print("Selected: \($0)") }, - onOpenKeyboard: { print("Open keyboard") } - ) - .padding() - .background(.gray.opacity(0.3)) + EmojiPickerRow( + emojis: ["👍", "👎", "❤️", "😂", "😮", "😢"], + onSelect: { print("Selected: \($0)") }, + onOpenKeyboard: { print("Open keyboard") } + ) + .padding() + .background(.gray.opacity(0.3)) } diff --git a/MC1/Views/Chats/Reactions/EmojiPickerSheet.swift b/MC1/Views/Chats/Reactions/EmojiPickerSheet.swift index aef5fc06..168cae94 100644 --- a/MC1/Views/Chats/Reactions/EmojiPickerSheet.swift +++ b/MC1/Views/Chats/Reactions/EmojiPickerSheet.swift @@ -1,73 +1,73 @@ -import SwiftUI import Emojibase +import SwiftUI /// Full emoji picker sheet with categories, search, and frequently used struct EmojiPickerSheet: View { - let onSelect: (String) -> Void + let onSelect: (String) -> Void - @Environment(\.dismiss) private var dismiss - @State private var viewModel = EmojiPickerViewModel() + @Environment(\.dismiss) private var dismiss + @State private var viewModel = EmojiPickerViewModel() - @ScaledMetric private var emojiSize: CGFloat = 44 + @ScaledMetric private var emojiSize: CGFloat = 44 - var body: some View { - NavigationStack { - ScrollView { - LazyVGrid( - columns: [GridItem(.adaptive(minimum: emojiSize))], - spacing: 8 - ) { - ForEach(viewModel.categories) { category in - Section { - ForEach(category.emojis) { emoji in - emojiButton(emoji) - } - } header: { - EmojiCategoryHeader(title: category.localizedName) - } - } - } - .padding(.horizontal) - } - .navigationTitle(L10n.Chats.Reactions.title) - .navigationBarTitleDisplayMode(.inline) - .searchable( - text: $viewModel.searchQuery, - placement: .navigationBarDrawer(displayMode: .always), - prompt: L10n.Chats.Reactions.Emoji.searchPlaceholder - ) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - dismiss() - } - } - } - .task { - await viewModel.load() + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: emojiSize))], + spacing: 8 + ) { + ForEach(viewModel.categories) { category in + Section { + ForEach(category.emojis) { emoji in + emojiButton(emoji) + } + } header: { + EmojiCategoryHeader(title: category.localizedName) } + } } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - - private func emojiButton(_ emoji: EmojiItem) -> some View { - Button { - viewModel.markAsFrequentlyUsed(emoji.unicode) - onSelect(emoji.unicode) + .padding(.horizontal) + } + .navigationTitle(L10n.Chats.Reactions.title) + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: $viewModel.searchQuery, + placement: .navigationBarDrawer(displayMode: .always), + prompt: L10n.Chats.Reactions.Emoji.searchPlaceholder + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { dismiss() - } label: { - Text(emoji.unicode) - .font(.largeTitle) - .frame(width: emojiSize, height: emojiSize) + } } - .buttonStyle(.plain) - .accessibilityLabel(emoji.label.isEmpty ? emoji.unicode : emoji.label) + } + .task { + await viewModel.load() + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + private func emojiButton(_ emoji: EmojiItem) -> some View { + Button { + viewModel.markAsFrequentlyUsed(emoji.unicode) + onSelect(emoji.unicode) + dismiss() + } label: { + Text(emoji.unicode) + .font(.largeTitle) + .frame(width: emojiSize, height: emojiSize) } + .buttonStyle(.plain) + .accessibilityLabel(emoji.label.isEmpty ? emoji.unicode : emoji.label) + } } #Preview { - EmojiPickerSheet { emoji in - print("Selected: \(emoji)") - } + EmojiPickerSheet { emoji in + print("Selected: \(emoji)") + } } diff --git a/MC1/Views/Chats/Reactions/EmojiPickerViewModel.swift b/MC1/Views/Chats/Reactions/EmojiPickerViewModel.swift index a25ede83..4b0485e1 100644 --- a/MC1/Views/Chats/Reactions/EmojiPickerViewModel.swift +++ b/MC1/Views/Chats/Reactions/EmojiPickerViewModel.swift @@ -5,32 +5,32 @@ import Observation @Observable @MainActor final class EmojiPickerViewModel { - private let provider = EmojiProvider() - private var searchTask: Task? + private let provider = EmojiProvider() + private var searchTask: Task? - var searchQuery: String = "" { - didSet { - searchTask?.cancel() - searchTask = Task { - await updateCategories() - } - } + var searchQuery: String = "" { + didSet { + searchTask?.cancel() + searchTask = Task { + await updateCategories() + } } + } - private(set) var categories: [EmojiCategoryData] = [] + private(set) var categories: [EmojiCategoryData] = [] - func load() async { - await updateCategories() - } + func load() async { + await updateCategories() + } - func markAsFrequentlyUsed(_ emoji: String) { - provider.markAsFrequentlyUsed(emoji) - } + func markAsFrequentlyUsed(_ emoji: String) { + provider.markAsFrequentlyUsed(emoji) + } - private func updateCategories() async { - let query = searchQuery.isEmpty ? nil : searchQuery - let result = await provider.categories(searchQuery: query) - guard !Task.isCancelled else { return } - categories = result - } + private func updateCategories() async { + let query = searchQuery.isEmpty ? nil : searchQuery + let result = await provider.categories(searchQuery: query) + guard !Task.isCancelled else { return } + categories = result + } } diff --git a/MC1/Views/Chats/Reactions/EmojiProvider.swift b/MC1/Views/Chats/Reactions/EmojiProvider.swift index 260ee712..d78373b0 100644 --- a/MC1/Views/Chats/Reactions/EmojiProvider.swift +++ b/MC1/Views/Chats/Reactions/EmojiProvider.swift @@ -1,165 +1,165 @@ -import Foundation -import SwiftUI import Emojibase +import Foundation import MC1Services +import SwiftUI /// View data for a single emoji in the picker struct EmojiItem: Identifiable, Equatable { - let id: String - let unicode: String - let label: String - - init(id: String, unicode: String, label: String) { - self.id = id - self.unicode = unicode - self.label = label - } - - init(emoji: Emoji, categoryID: String) { - self.id = "\(categoryID)-\(emoji.hexcode)" - self.unicode = emoji.unicode - self.label = emoji.label - } + let id: String + let unicode: String + let label: String + + init(id: String, unicode: String, label: String) { + self.id = id + self.unicode = unicode + self.label = label + } + + init(emoji: Emoji, categoryID: String) { + id = "\(categoryID)-\(emoji.hexcode)" + unicode = emoji.unicode + label = emoji.label + } } /// View data for an emoji category struct EmojiCategoryData: Identifiable { - let id: String - let emojis: [EmojiItem] - - var localizedName: String { - switch id { - case "frequent": - return L10n.Chats.Reactions.Emoji.Category.frequent - case EmojibaseCategory.people.rawValue: - return L10n.Chats.Reactions.Emoji.Category.people - case EmojibaseCategory.nature.rawValue: - return L10n.Chats.Reactions.Emoji.Category.nature - case EmojibaseCategory.foods.rawValue: - return L10n.Chats.Reactions.Emoji.Category.foods - case EmojibaseCategory.activity.rawValue: - return L10n.Chats.Reactions.Emoji.Category.activity - case EmojibaseCategory.places.rawValue: - return L10n.Chats.Reactions.Emoji.Category.places - case EmojibaseCategory.objects.rawValue: - return L10n.Chats.Reactions.Emoji.Category.objects - case EmojibaseCategory.symbols.rawValue: - return L10n.Chats.Reactions.Emoji.Category.symbols - case EmojibaseCategory.flags.rawValue: - return L10n.Chats.Reactions.Emoji.Category.flags - default: - return id.capitalized - } + let id: String + let emojis: [EmojiItem] + + var localizedName: String { + switch id { + case "frequent": + L10n.Chats.Reactions.Emoji.Category.frequent + case EmojibaseCategory.people.rawValue: + L10n.Chats.Reactions.Emoji.Category.people + case EmojibaseCategory.nature.rawValue: + L10n.Chats.Reactions.Emoji.Category.nature + case EmojibaseCategory.foods.rawValue: + L10n.Chats.Reactions.Emoji.Category.foods + case EmojibaseCategory.activity.rawValue: + L10n.Chats.Reactions.Emoji.Category.activity + case EmojibaseCategory.places.rawValue: + L10n.Chats.Reactions.Emoji.Category.places + case EmojibaseCategory.objects.rawValue: + L10n.Chats.Reactions.Emoji.Category.objects + case EmojibaseCategory.symbols.rawValue: + L10n.Chats.Reactions.Emoji.Category.symbols + case EmojibaseCategory.flags.rawValue: + L10n.Chats.Reactions.Emoji.Category.flags + default: + id.capitalized } + } } /// Loading state for emoji data enum EmojiProviderState { - case notLoaded - case loading - case loaded - case failed(Error) + case notLoaded + case loading + case loaded + case failed(Error) } /// Provides emoji data for the picker with search and frequently-used tracking @Observable @MainActor final class EmojiProvider { - private(set) var state: EmojiProviderState = .notLoaded - private var store: EmojibaseStore? - - @ObservationIgnored - @AppStorage(AppStorageKey.frequentEmojis.rawValue) private var frequentEmojisData: Data = Data() - - private static let maxFrequentEmojis = 20 - private static let categoryOrder: [EmojibaseCategory] = [ - .people, .nature, .foods, .activity, .places, .objects, .symbols, .flags - ] - - /// Loads emoji data if not already loaded - func loadIfNeeded() async { - guard case .notLoaded = state else { return } - - state = .loading - do { - let datasource = EmojibaseDatasource() - store = try await datasource.load() - state = .loaded - } catch { - state = .failed(error) - } + private(set) var state: EmojiProviderState = .notLoaded + private var store: EmojibaseStore? + + @ObservationIgnored + @AppStorage(AppStorageKey.frequentEmojis.rawValue) private var frequentEmojisData: Data = .init() + + private static let maxFrequentEmojis = 20 + private static let categoryOrder: [EmojibaseCategory] = [ + .people, .nature, .foods, .activity, .places, .objects, .symbols, .flags + ] + + /// Loads emoji data if not already loaded + func loadIfNeeded() async { + guard case .notLoaded = state else { return } + + state = .loading + do { + let datasource = EmojibaseDatasource() + store = try await datasource.load() + state = .loaded + } catch { + state = .failed(error) } + } - /// Returns categories filtered by search query - func categories(searchQuery: String?) async -> [EmojiCategoryData] { - await loadIfNeeded() + /// Returns categories filtered by search query + func categories(searchQuery: String?) async -> [EmojiCategoryData] { + await loadIfNeeded() - guard let store else { return [] } + guard let store else { return [] } - var result: [EmojiCategoryData] = [] + var result: [EmojiCategoryData] = [] - // Add frequently used section if no search query - if searchQuery == nil || searchQuery?.isEmpty == true { - let frequent = frequentlyUsedEmojis() - if !frequent.isEmpty { - let items = frequent.enumerated().map { index, unicode in - EmojiItem(id: "frequent-\(index)", unicode: unicode, label: "") - } - result.append(EmojiCategoryData(id: "frequent", emojis: items)) - } + // Add frequently used section if no search query + if searchQuery == nil || searchQuery?.isEmpty == true { + let frequent = frequentlyUsedEmojis() + if !frequent.isEmpty { + let items = frequent.enumerated().map { index, unicode in + EmojiItem(id: "frequent-\(index)", unicode: unicode, label: "") } + result.append(EmojiCategoryData(id: "frequent", emojis: items)) + } + } - // Add standard categories - for category in Self.categoryOrder { - guard let emojis = store.emojisFor(category: category) else { continue } - - let filtered: [Emoji] - if let query = searchQuery, !query.isEmpty { - let lowercasedQuery = query.lowercased() - filtered = emojis.filter { emoji in - emoji.label.localizedStandardContains(lowercasedQuery) || - emoji.shortcodes.contains { $0.localizedStandardContains(lowercasedQuery) } || - emoji.tags?.contains { $0.localizedStandardContains(lowercasedQuery) } == true - } - } else { - filtered = emojis - } - - if !filtered.isEmpty { - let items = filtered.map { EmojiItem(emoji: $0, categoryID: category.rawValue) } - result.append(EmojiCategoryData(id: category.rawValue, emojis: items)) - } + // Add standard categories + for category in Self.categoryOrder { + guard let emojis = store.emojisFor(category: category) else { continue } + + let filtered: [Emoji] + if let query = searchQuery, !query.isEmpty { + let lowercasedQuery = query.lowercased() + filtered = emojis.filter { emoji in + emoji.label.localizedStandardContains(lowercasedQuery) || + emoji.shortcodes.contains { $0.localizedStandardContains(lowercasedQuery) } || + emoji.tags?.contains { $0.localizedStandardContains(lowercasedQuery) } == true } - - return result + } else { + filtered = emojis + } + + if !filtered.isEmpty { + let items = filtered.map { EmojiItem(emoji: $0, categoryID: category.rawValue) } + result.append(EmojiCategoryData(id: category.rawValue, emojis: items)) + } } - /// Marks an emoji as frequently used - func markAsFrequentlyUsed(_ emoji: String) { - var frequent = frequentlyUsedEmojis() + return result + } - // Remove if already present (will re-add at front) - frequent.removeAll { $0 == emoji } + /// Marks an emoji as frequently used + func markAsFrequentlyUsed(_ emoji: String) { + var frequent = frequentlyUsedEmojis() - // Add to front - frequent.insert(emoji, at: 0) + // Remove if already present (will re-add at front) + frequent.removeAll { $0 == emoji } - // Trim to max size - if frequent.count > Self.maxFrequentEmojis { - frequent = Array(frequent.prefix(Self.maxFrequentEmojis)) - } + // Add to front + frequent.insert(emoji, at: 0) - // Persist - if let data = try? JSONEncoder().encode(frequent) { - frequentEmojisData = data - } + // Trim to max size + if frequent.count > Self.maxFrequentEmojis { + frequent = Array(frequent.prefix(Self.maxFrequentEmojis)) } - /// Returns the list of frequently used emojis - func frequentlyUsedEmojis() -> [String] { - guard let emojis = try? JSONDecoder().decode([String].self, from: frequentEmojisData) else { - return [] - } - return emojis + // Persist + if let data = try? JSONEncoder().encode(frequent) { + frequentEmojisData = data + } + } + + /// Returns the list of frequently used emojis + func frequentlyUsedEmojis() -> [String] { + guard let emojis = try? JSONDecoder().decode([String].self, from: frequentEmojisData) else { + return [] } + return emojis + } } diff --git a/MC1/Views/Chats/Reactions/MessageAction.swift b/MC1/Views/Chats/Reactions/MessageAction.swift index 32a7304f..ff25e5ac 100644 --- a/MC1/Views/Chats/Reactions/MessageAction.swift +++ b/MC1/Views/Chats/Reactions/MessageAction.swift @@ -1,11 +1,11 @@ /// User actions dispatched from the message actions sheet, routed to handlers /// by `ChatConversationView.dispatch(_:for:)`. enum MessageAction: Equatable { - case react(String) - case reply - case copy - case sendAgain - case sendDM - case blockSender - case delete + case react(String) + case reply + case copy + case sendAgain + case sendDM + case blockSender + case delete } diff --git a/MC1/Views/Chats/Reactions/MessageActionAvailability.swift b/MC1/Views/Chats/Reactions/MessageActionAvailability.swift index 4440b537..c16c080a 100644 --- a/MC1/Views/Chats/Reactions/MessageActionAvailability.swift +++ b/MC1/Views/Chats/Reactions/MessageActionAvailability.swift @@ -3,26 +3,26 @@ import MC1Services /// Determines which message actions are available based on message state. /// Extracted for testability and reuse across UI components. struct MessageActionAvailability { - let canReply: Bool - let canCopy: Bool - let canSendAgain: Bool - let canBlockSender: Bool - let canSendDM: Bool - let canShowRepeatDetails: Bool - let canViewPath: Bool - let canDelete: Bool + let canReply: Bool + let canCopy: Bool + let canSendAgain: Bool + let canBlockSender: Bool + let canSendDM: Bool + let canShowRepeatDetails: Bool + let canViewPath: Bool + let canDelete: Bool - init(message: MessageDTO) { - canReply = !message.isOutgoing - canCopy = true - canSendAgain = message.isOutgoing - let hasChannelSender = message.isChannelMessage && !message.isOutgoing && message.senderNodeName != nil - canBlockSender = hasChannelSender - canSendDM = hasChannelSender - canShowRepeatDetails = message.isOutgoing && message.heardRepeats > 0 - canViewPath = !message.isOutgoing - && message.isFloodRouted - && !(message.pathNodes?.isEmpty ?? true) - canDelete = true - } + init(message: MessageDTO) { + canReply = !message.isOutgoing + canCopy = true + canSendAgain = message.isOutgoing + let hasChannelSender = message.isChannelMessage && !message.isOutgoing && message.senderNodeName != nil + canBlockSender = hasChannelSender + canSendDM = hasChannelSender + canShowRepeatDetails = message.isOutgoing && message.heardRepeats > 0 + canViewPath = !message.isOutgoing + && message.isFloodRouted + && !(message.pathNodes?.isEmpty ?? true) + canDelete = true + } } diff --git a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift index 4c9d80c5..f034d0a1 100644 --- a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift +++ b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift @@ -2,162 +2,157 @@ import MC1Services import SwiftUI struct MessageActionsSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - let message: MessageDTO - let senderName: String - let senderMatchKind: NodeNameMatchKind - let recentEmojis: [String] - let onAction: (MessageAction) -> Void + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + let message: MessageDTO + let senderResolution: NodeNameResolution + let recentEmojis: [String] + let onAction: (MessageAction) -> Void - private var availability: MessageActionAvailability { - MessageActionAvailability(message: message) - } + private var availability: MessageActionAvailability { + MessageActionAvailability(message: message) + } - private func performAction(_ action: MessageAction) { - if action == .delete || action == .blockSender { - destructiveHapticTrigger += 1 - } - onAction(action) - dismiss() + private func performAction(_ action: MessageAction) { + if action == .delete || action == .blockSender { + destructiveHapticTrigger += 1 } + onAction(action) + dismiss() + } - @State private var destructiveHapticTrigger = 0 - @State private var showEmojiPicker = false - @State private var isDetailExpanded = false - @State private var repeats: [MessageRepeatDTO]? - @State private var contacts: [ContactDTO] = [] - @State private var discoveredNodes: [DiscoveredNodeDTO] = [] - @State private var pathViewModel = MessagePathViewModel() + @State private var destructiveHapticTrigger = 0 + @State private var showEmojiPicker = false + @State private var isDetailExpanded = false + @State private var repeats: [MessageRepeatDTO]? + @State private var contacts: [ContactDTO] = [] + @State private var discoveredNodes: [DiscoveredNodeDTO] = [] + @State private var pathViewModel = MessagePathViewModel() - var body: some View { - VStack(spacing: 0) { - ActionsPreviewHeader( - message: message, - senderName: senderName, - senderMatchKind: senderMatchKind - ) + var body: some View { + VStack(spacing: 0) { + ActionsPreviewHeader( + message: message, + senderResolution: senderResolution + ) - Divider() + Divider() - if !dynamicTypeSize.isAccessibilitySize { - ActionsEmojiSection( - recentEmojis: recentEmojis, - showEmojiPicker: $showEmojiPicker, - onSelectEmoji: { performAction(.react($0)) } - ) - Divider() - } + if !dynamicTypeSize.isAccessibilitySize { + ActionsEmojiSection( + recentEmojis: recentEmojis, + showEmojiPicker: $showEmojiPicker, + onSelectEmoji: { performAction(.react($0)) } + ) + Divider() + } - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 0) { - if dynamicTypeSize.isAccessibilitySize { - ActionsEmojiSection( - recentEmojis: recentEmojis, - showEmojiPicker: $showEmojiPicker, - onSelectEmoji: { performAction(.react($0)) } - ) - Divider() - } - ActionsButtonsSection( - availability: availability, - onSelectAction: performAction - ) - ActionsDetailsSection( - message: message, - availability: availability, - isDetailExpanded: $isDetailExpanded, - repeats: repeats, - contacts: contacts, - discoveredNodes: discoveredNodes, - pathViewModel: pathViewModel - ) - ActionsDestructiveSection( - availability: availability, - onSelectAction: performAction - ) - } - } - .onChange(of: isDetailExpanded) { _, expanded in - if expanded { - withAnimation(reduceMotion ? nil : .default) { - proxy.scrollTo("expandedContent", anchor: .top) - } - } - } + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + if dynamicTypeSize.isAccessibilitySize { + ActionsEmojiSection( + recentEmojis: recentEmojis, + showEmojiPicker: $showEmojiPicker, + onSelectEmoji: { performAction(.react($0)) } + ) + Divider() } + ActionsButtonsSection( + availability: availability, + onSelectAction: performAction + ) + ActionsDetailsSection( + message: message, + availability: availability, + isDetailExpanded: $isDetailExpanded, + repeats: repeats, + contacts: contacts, + discoveredNodes: discoveredNodes, + pathViewModel: pathViewModel + ) + ActionsDestructiveSection( + availability: availability, + onSelectAction: performAction + ) + } } - .presentationDetents( - (horizontalSizeClass == .regular || dynamicTypeSize.isAccessibilitySize) - ? [.large] : [.medium, .large] - ) - .presentationContentInteraction(.scrolls) - .presentationDragIndicator(.visible) - .presentationBackground(Color(.systemBackground)) - .sensoryFeedback(.warning, trigger: destructiveHapticTrigger) - .task { - guard let services = appState.services else { return } - if availability.canShowRepeatDetails { - // The three loads share no data, so run them concurrently rather - // than stacking three actor round-trips while the detail rows are blank. - async let fetchedRepeats = services.heardRepeatsService.refreshRepeats(for: message.id) - do { - async let fetchedContacts = services.dataStore.fetchContacts(radioID: message.radioID) - async let fetchedNodes = services.dataStore.fetchDiscoveredNodes(radioID: message.radioID) - contacts = try await fetchedContacts - discoveredNodes = try await fetchedNodes - } catch { - contacts = [] - discoveredNodes = [] - } - repeats = await fetchedRepeats - } else if availability.canViewPath { - await pathViewModel.loadContacts(services: services, radioID: message.radioID) + .onChange(of: isDetailExpanded) { _, expanded in + if expanded { + withAnimation(reduceMotion ? nil : .default) { + proxy.scrollTo("expandedContent", anchor: .top) } + } } + } } - + .presentationDetents( + (horizontalSizeClass == .regular || dynamicTypeSize.isAccessibilitySize) + ? [.large] : [.medium, .large] + ) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackground(Color(.systemBackground)) + .sensoryFeedback(.warning, trigger: destructiveHapticTrigger) + .task { + guard let services = appState.services else { return } + if availability.canShowRepeatDetails { + // The three loads share no data, so run them concurrently rather + // than stacking three actor round-trips while the detail rows are blank. + async let fetchedRepeats = services.heardRepeatsService.refreshRepeats(for: message.id) + do { + async let fetchedContacts = services.dataStore.fetchContacts(radioID: message.radioID) + async let fetchedNodes = services.dataStore.fetchDiscoveredNodes(radioID: message.radioID) + contacts = try await fetchedContacts + discoveredNodes = try await fetchedNodes + } catch { + contacts = [] + discoveredNodes = [] + } + repeats = await fetchedRepeats + } else if availability.canViewPath { + await pathViewModel.loadContacts(services: services, radioID: message.radioID) + } + } + } } #Preview("Outgoing Message") { - let message = Message( - radioID: UUID(), - contactID: UUID(), - text: "Hello world!", - directionRawValue: MessageDirection.outgoing.rawValue, - statusRawValue: MessageStatus.delivered.rawValue - ) - message.roundTripTime = 234 - message.heardRepeats = 2 - return MessageActionsSheet( - message: MessageDTO(from: message), - senderName: "My Device", - senderMatchKind: .exact, - recentEmojis: RecentEmojisStore.defaultEmojis, - onAction: { print("Action: \($0)") } - ) + let message = Message( + radioID: UUID(), + contactID: UUID(), + text: "Hello world!", + directionRawValue: MessageDirection.outgoing.rawValue, + statusRawValue: MessageStatus.delivered.rawValue + ) + message.roundTripTime = 234 + message.heardRepeats = 2 + return MessageActionsSheet( + message: MessageDTO(from: message), + senderResolution: NodeNameResolution(displayName: "My Device", matchKind: .exact), + recentEmojis: RecentEmojisStore.defaultEmojis, + onAction: { print("Action: \($0)") } + ) } #Preview("Incoming Message") { - let message = Message( - radioID: UUID(), - contactID: UUID(), - text: "Hey, can you meet me at the coffee shop downtown later today? I have something important to discuss.", - directionRawValue: MessageDirection.incoming.rawValue, - statusRawValue: MessageStatus.delivered.rawValue, - pathLength: 2 - ) - message.pathNodes = Data([0xA3, 0x7F]) - message.snr = 8.5 - return MessageActionsSheet( - message: MessageDTO(from: message), - senderName: "Alice", - senderMatchKind: .exact, - recentEmojis: RecentEmojisStore.defaultEmojis, - onAction: { print("Action: \($0)") } - ) + let message = Message( + radioID: UUID(), + contactID: UUID(), + text: "Hey, can you meet me at the coffee shop downtown later today? I have something important to discuss.", + directionRawValue: MessageDirection.incoming.rawValue, + statusRawValue: MessageStatus.delivered.rawValue, + pathLength: 2 + ) + message.pathNodes = Data([0xA3, 0x7F]) + message.snr = 8.5 + return MessageActionsSheet( + message: MessageDTO(from: message), + senderResolution: NodeNameResolution(displayName: "Alice", matchKind: .exact), + recentEmojis: RecentEmojisStore.defaultEmojis, + onAction: { print("Action: \($0)") } + ) } diff --git a/MC1/Views/Chats/Reactions/ReactionBadgesView.swift b/MC1/Views/Chats/Reactions/ReactionBadgesView.swift index 1a33062d..02e4ac9e 100644 --- a/MC1/Views/Chats/Reactions/ReactionBadgesView.swift +++ b/MC1/Views/Chats/Reactions/ReactionBadgesView.swift @@ -1,122 +1,122 @@ -import SwiftUI import MC1Services +import SwiftUI /// Horizontal row of reaction badges displayed below message bubbles struct ReactionBadgesView: View { - let summary: String? // Format: "👍:3,❤️:2,😂:1" - let onTapReaction: (String) -> Void - let onLongPress: () -> Void + let summary: String? // Format: "👍:3,❤️:2,😂:1" + let onTapReaction: (String) -> Void + let onLongPress: () -> Void - @State private var longPressTriggered = false + @State private var longPressTriggered = false - private var reactions: [(emoji: String, count: Int)] { - ReactionParser.parseSummary(summary) - } + private var reactions: [(emoji: String, count: Int)] { + ReactionParser.parseSummary(summary) + } - private var visibleReactions: [(emoji: String, count: Int)] { - Array(reactions.prefix(3)) - } + private var visibleReactions: [(emoji: String, count: Int)] { + Array(reactions.prefix(3)) + } - private var overflowCount: Int { - max(0, reactions.count - 3) - } + private var overflowCount: Int { + max(0, reactions.count - 3) + } - var body: some View { - if !reactions.isEmpty { - HStack(spacing: 0) { - ForEach(visibleReactions, id: \.emoji) { reaction in - Button { - onTapReaction(reaction.emoji) - } label: { - ReactionBadge(emoji: reaction.emoji, count: reaction.count) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Chats.Reactions.badge(reaction.emoji.emojiAccessibilityName, reaction.count)) - .accessibilityHint(L10n.Chats.Reactions.badgeHint) - } + var body: some View { + if !reactions.isEmpty { + HStack(spacing: 0) { + ForEach(visibleReactions, id: \.emoji) { reaction in + Button { + onTapReaction(reaction.emoji) + } label: { + ReactionBadge(emoji: reaction.emoji, count: reaction.count) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Chats.Reactions.badge(reaction.emoji.emojiAccessibilityName, reaction.count)) + .accessibilityHint(L10n.Chats.Reactions.badgeHint) + } - if overflowCount > 0 { - Button { - onLongPress() - } label: { - OverflowBadge(count: overflowCount) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Chats.Reactions.moreBadge(overflowCount)) - .accessibilityHint(L10n.Chats.Reactions.moreBadgeHint) - } - } - .simultaneousGesture( - LongPressGesture(minimumDuration: 0.3) - .onEnded { _ in - longPressTriggered.toggle() - onLongPress() - } - ) - .sensoryFeedback(.impact(weight: .medium), trigger: longPressTriggered) - .accessibilityAction(named: L10n.Chats.Reactions.viewDetails) { - onLongPress() - } + if overflowCount > 0 { + Button { + onLongPress() + } label: { + OverflowBadge(count: overflowCount) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Chats.Reactions.moreBadge(overflowCount)) + .accessibilityHint(L10n.Chats.Reactions.moreBadgeHint) } + } + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.3) + .onEnded { _ in + longPressTriggered.toggle() + onLongPress() + } + ) + .sensoryFeedback(.impact(weight: .medium), trigger: longPressTriggered) + .accessibilityAction(named: L10n.Chats.Reactions.viewDetails) { + onLongPress() + } } + } } private struct ReactionBadge: View { - @Environment(\.appTheme) private var theme - let emoji: String - let count: Int + @Environment(\.appTheme) private var theme + let emoji: String + let count: Int - var body: some View { - HStack(spacing: 4) { - Text(emoji) - .font(.subheadline) - if count > 1 { - Text(count, format: .number) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(theme.incomingBubbleColor, in: .capsule) - .overlay(Capsule().strokeBorder(theme.surfaces?.canvas ?? Color(.systemBackground), lineWidth: 2)) + var body: some View { + HStack(spacing: 4) { + Text(emoji) + .font(.subheadline) + if count > 1 { + Text(count, format: .number) + .font(.footnote) + .foregroundStyle(.secondary) + } } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(theme.incomingBubbleColor, in: .capsule) + .overlay(Capsule().strokeBorder(theme.surfaces?.canvas ?? Color(.systemBackground), lineWidth: 2)) + } } private struct OverflowBadge: View { - @Environment(\.appTheme) private var theme - let count: Int + @Environment(\.appTheme) private var theme + let count: Int - var body: some View { - Text("+\(count)") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(theme.incomingBubbleColor, in: .capsule) - .overlay(Capsule().strokeBorder(theme.surfaces?.canvas ?? Color(.systemBackground), lineWidth: 2)) - } + var body: some View { + Text("+\(count)") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(theme.incomingBubbleColor, in: .capsule) + .overlay(Capsule().strokeBorder(theme.surfaces?.canvas ?? Color(.systemBackground), lineWidth: 2)) + } } #Preview { - VStack(spacing: 20) { - ReactionBadgesView( - summary: "👍:3,❤️:2,😂:1", - onTapReaction: { _ in }, - onLongPress: {} - ) + VStack(spacing: 20) { + ReactionBadgesView( + summary: "👍:3,❤️:2,😂:1", + onTapReaction: { _ in }, + onLongPress: {} + ) - ReactionBadgesView( - summary: "👍:5,❤️:3,😂:2,😮:1,😢:1,🎉:1", - onTapReaction: { _ in }, - onLongPress: {} - ) + ReactionBadgesView( + summary: "👍:5,❤️:3,😂:2,😮:1,😢:1,🎉:1", + onTapReaction: { _ in }, + onLongPress: {} + ) - ReactionBadgesView( - summary: nil, - onTapReaction: { _ in }, - onLongPress: {} - ) - } - .padding() + ReactionBadgesView( + summary: nil, + onTapReaction: { _ in }, + onLongPress: {} + ) + } + .padding() } diff --git a/MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift b/MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift index 690aeb54..07869c6d 100644 --- a/MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift +++ b/MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift @@ -1,143 +1,143 @@ // MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift -import SwiftUI import MC1Services import OSLog +import SwiftUI /// Sheet showing who reacted with each emoji. struct ReactionDetailsSheet: View { - let messageID: UUID + let messageID: UUID - @Environment(\.appState) private var appState - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.appTheme) private var theme - private let logger = Logger(subsystem: "com.mc1", category: "ReactionDetailsSheet") + private let logger = Logger(subsystem: "com.mc1", category: "ReactionDetailsSheet") - @State private var reactions: [ReactionDTO] = [] - @State private var selectedEmoji: String? - @State private var isLoading = true + @State private var reactions: [ReactionDTO] = [] + @State private var selectedEmoji: String? + @State private var isLoading = true - private var emojiGroups: [(emoji: String, reactions: [ReactionDTO])] { - Dictionary(grouping: reactions, by: \.emoji) - .map { (emoji: $0.key, reactions: $0.value) } - .sorted { lhs, rhs in - if lhs.reactions.count != rhs.reactions.count { - return lhs.reactions.count > rhs.reactions.count - } - let lhsEarliest = lhs.reactions.map(\.receivedAt).min() ?? .distantPast - let rhsEarliest = rhs.reactions.map(\.receivedAt).min() ?? .distantPast - return lhsEarliest < rhsEarliest - } - } + private var emojiGroups: [(emoji: String, reactions: [ReactionDTO])] { + Dictionary(grouping: reactions, by: \.emoji) + .map { (emoji: $0.key, reactions: $0.value) } + .sorted { lhs, rhs in + if lhs.reactions.count != rhs.reactions.count { + return lhs.reactions.count > rhs.reactions.count + } + let lhsEarliest = lhs.reactions.map(\.receivedAt).min() ?? .distantPast + let rhsEarliest = rhs.reactions.map(\.receivedAt).min() ?? .distantPast + return lhsEarliest < rhsEarliest + } + } - private var selectedReactions: [ReactionDTO] { - emojiGroups.first { $0.emoji == selectedEmoji }?.reactions ?? [] - } + private var selectedReactions: [ReactionDTO] { + emojiGroups.first { $0.emoji == selectedEmoji }?.reactions ?? [] + } - var body: some View { - NavigationStack { - VStack(spacing: 0) { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if reactions.isEmpty { - ContentUnavailableView( - L10n.Chats.Reactions.EmptyState.title, - systemImage: "face.smiling", - description: Text(L10n.Chats.Reactions.EmptyState.description) - ) - } else { - emojiTabsView - Divider() - senderListView - } - } - .navigationTitle(L10n.Chats.Reactions.title) - .task { - await loadReactions() - } + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if reactions.isEmpty { + ContentUnavailableView( + L10n.Chats.Reactions.EmptyState.title, + systemImage: "face.smiling", + description: Text(L10n.Chats.Reactions.EmptyState.description) + ) + } else { + emojiTabsView + Divider() + senderListView } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) + } + .navigationTitle(L10n.Chats.Reactions.title) + .task { + await loadReactions() + } } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } - private var emojiTabsView: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - ForEach(emojiGroups, id: \.emoji) { group in - Button { - withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) { - selectedEmoji = group.emoji - } - } label: { - EmojiTab( - emoji: group.emoji, - count: group.reactions.count, - isSelected: selectedEmoji == group.emoji - ) - } - .buttonStyle(.plain) - } + private var emojiTabsView: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(emojiGroups, id: \.emoji) { group in + Button { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) { + selectedEmoji = group.emoji } - .padding() + } label: { + EmojiTab( + emoji: group.emoji, + count: group.reactions.count, + isSelected: selectedEmoji == group.emoji + ) + } + .buttonStyle(.plain) } - .scrollIndicators(.hidden) + } + .padding() } + .scrollIndicators(.hidden) + } - private var senderListView: some View { - List(selectedReactions) { reaction in - HStack { - Text(reaction.senderName) - Spacer() - Text(reaction.receivedAt, format: .relative(presentation: .named)) - .foregroundStyle(.secondary) - .font(.caption) - } - } - .listStyle(.plain) - .themedCanvas(theme) + private var senderListView: some View { + List(selectedReactions) { reaction in + HStack { + Text(reaction.senderName) + Spacer() + Text(reaction.receivedAt, format: .relative(presentation: .named)) + .foregroundStyle(.secondary) + .font(.caption) + } } + .listStyle(.plain) + .themedCanvas(theme) + } - private func loadReactions() async { - guard let dataStore = appState.services?.dataStore else { - isLoading = false - return - } - - do { - reactions = try await dataStore.fetchReactions(for: messageID) - if let first = emojiGroups.first { - selectedEmoji = first.emoji - } - } catch { - logger.debug("Failed to fetch reactions for message \(messageID): \(error)") - } + private func loadReactions() async { + guard let dataStore = appState.services?.dataStore else { + isLoading = false + return + } - isLoading = false + do { + reactions = try await dataStore.fetchReactions(for: messageID) + if let first = emojiGroups.first { + selectedEmoji = first.emoji + } + } catch { + logger.debug("Failed to fetch reactions for message \(messageID): \(error)") } + + isLoading = false + } } private struct EmojiTab: View { - let emoji: String - let count: Int - let isSelected: Bool + let emoji: String + let count: Int + let isSelected: Bool - var body: some View { - HStack(spacing: 4) { - Text(emoji) - Text(count, format: .number) - .font(.caption) - .bold() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(isSelected ? Color.accentColor : Color.clear, in: .capsule) - .foregroundStyle(isSelected ? .white : .primary) - .accessibilityAddTraits(isSelected ? .isSelected : []) + var body: some View { + HStack(spacing: 4) { + Text(emoji) + Text(count, format: .number) + .font(.caption) + .bold() } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? Color.accentColor : Color.clear, in: .capsule) + .foregroundStyle(isSelected ? .white : .primary) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } } #Preview { - ReactionDetailsSheet(messageID: UUID()) - .environment(\.appState, AppState()) + ReactionDetailsSheet(messageID: UUID()) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Reactions/RecentEmojisStore.swift b/MC1/Views/Chats/Reactions/RecentEmojisStore.swift index 571403f8..6b7c42de 100644 --- a/MC1/Views/Chats/Reactions/RecentEmojisStore.swift +++ b/MC1/Views/Chats/Reactions/RecentEmojisStore.swift @@ -5,30 +5,30 @@ import MC1Services @Observable @MainActor final class RecentEmojisStore { - private static let key = AppStorageKey.recentReactionEmojis.rawValue - private static let maxRecent = 6 + private static let key = AppStorageKey.recentReactionEmojis.rawValue + private static let maxRecent = 6 - /// Default emojis shown before any usage - static let defaultEmojis = ["👍", "👎", "❤️", "😂", "😮", "😢"] + /// Default emojis shown before any usage + static let defaultEmojis = ["👍", "👎", "❤️", "😂", "😮", "😢"] - /// Recently used emojis (most recent first), falls back to defaults - private(set) var recentEmojis: [String] + /// Recently used emojis (most recent first), falls back to defaults + private(set) var recentEmojis: [String] - init() { - if let stored = UserDefaults.standard.stringArray(forKey: Self.key), !stored.isEmpty { - self.recentEmojis = stored - } else { - self.recentEmojis = Self.defaultEmojis - } + init() { + if let stored = UserDefaults.standard.stringArray(forKey: Self.key), !stored.isEmpty { + recentEmojis = stored + } else { + recentEmojis = Self.defaultEmojis } + } - /// Records emoji usage, moving it to front of recent list - func recordUsage(_ emoji: String) { - var recent = recentEmojis - recent.removeAll { $0 == emoji } - recent.insert(emoji, at: 0) - recent = Array(recent.prefix(Self.maxRecent)) - recentEmojis = recent - UserDefaults.standard.set(recent, forKey: Self.key) - } + /// Records emoji usage, moving it to front of recent list + func recordUsage(_ emoji: String) { + var recent = recentEmojis + recent.removeAll { $0 == emoji } + recent.insert(emoji, at: 0) + recent = Array(recent.prefix(Self.maxRecent)) + recentEmojis = recent + UserDefaults.standard.set(recent, forKey: Self.key) + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionButton.swift b/MC1/Views/Chats/Reactions/Sections/ActionButton.swift index 8d211ec7..28f8f6d4 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionButton.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionButton.swift @@ -1,20 +1,20 @@ import SwiftUI struct ActionButton: View { - let title: String - let icon: String - var isDestructive: Bool = false - let action: () -> Void + let title: String + let icon: String + var isDestructive: Bool = false + let action: () -> Void - var body: some View { - Button(role: isDestructive ? .destructive : nil, action: action) { - HStack { - Label(title, systemImage: icon) - Spacer() - } - .padding() - .contentShape(.rect) - } - .foregroundStyle(isDestructive ? .red : .primary) + var body: some View { + Button(role: isDestructive ? .destructive : nil, action: action) { + HStack { + Label(title, systemImage: icon) + Spacer() + } + .padding() + .contentShape(.rect) } + .foregroundStyle(isDestructive ? .red : .primary) + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionInfoRow.swift b/MC1/Views/Chats/Reactions/Sections/ActionInfoRow.swift index ed8080a8..1483cefb 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionInfoRow.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionInfoRow.swift @@ -3,23 +3,23 @@ import SwiftUI /// A single read-only metadata row in a message actions sheet: a label with an /// optional leading icon. Shared across the chat and room actions sheets. struct ActionInfoRow: View { - let text: String - var icon: String? + let text: String + var icon: String? - var body: some View { - HStack { - if let icon { - Image(systemName: icon) - .foregroundStyle(.secondary) - } - Text(text) - Spacer() - } - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.vertical, 6) - .accessibilityElement(children: .combine) - .accessibilityLabel(text) + var body: some View { + HStack { + if let icon { + Image(systemName: icon) + .foregroundStyle(.secondary) + } + Text(text) + Spacer() } + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + .accessibilityLabel(text) + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift index 8e98c87e..c27e86a1 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift @@ -2,42 +2,42 @@ import MC1Services import SwiftUI struct ActionsButtonsSection: View { - let availability: MessageActionAvailability - let onSelectAction: (MessageAction) -> Void + let availability: MessageActionAvailability + let onSelectAction: (MessageAction) -> Void - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - var body: some View { - if availability.canReply { - ActionButton( - title: replyWithQuote - ? L10n.Chats.Chats.Message.Action.reply - : L10n.Chats.Chats.Message.Action.mention, - icon: "arrowshape.turn.up.left", - action: { onSelectAction(.reply) } - ) - } + var body: some View { + if availability.canReply { + ActionButton( + title: replyWithQuote + ? L10n.Chats.Chats.Message.Action.reply + : L10n.Chats.Chats.Message.Action.mention, + icon: "arrowshape.turn.up.left", + action: { onSelectAction(.reply) } + ) + } - if availability.canSendDM { - ActionButton( - title: L10n.Chats.Chats.Message.Action.sendDM, - icon: "bubble.left.and.bubble.right", - action: { onSelectAction(.sendDM) } - ) - } + if availability.canSendDM { + ActionButton( + title: L10n.Chats.Chats.Message.Action.sendDM, + icon: "bubble.left.and.bubble.right", + action: { onSelectAction(.sendDM) } + ) + } - ActionButton( - title: L10n.Chats.Chats.Message.Action.copy, - icon: "doc.on.doc", - action: { onSelectAction(.copy) } - ) + ActionButton( + title: L10n.Chats.Chats.Message.Action.copy, + icon: "doc.on.doc", + action: { onSelectAction(.copy) } + ) - if availability.canSendAgain { - ActionButton( - title: L10n.Chats.Chats.Message.Action.sendAgain, - icon: "arrow.uturn.forward", - action: { onSelectAction(.sendAgain) } - ) - } + if availability.canSendAgain { + ActionButton( + title: L10n.Chats.Chats.Message.Action.sendAgain, + icon: "arrow.uturn.forward", + action: { onSelectAction(.sendAgain) } + ) } + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsDestructiveSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsDestructiveSection.swift index 265af1db..0e7f4ea5 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsDestructiveSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsDestructiveSection.swift @@ -5,29 +5,29 @@ import SwiftUI /// separator rule in one place, instead of a hidden contract between sibling /// sections about which one draws it. struct ActionsDestructiveSection: View { - let availability: MessageActionAvailability - let onSelectAction: (MessageAction) -> Void + let availability: MessageActionAvailability + let onSelectAction: (MessageAction) -> Void - var body: some View { - if availability.canBlockSender || availability.canDelete { - Divider() - .padding(.vertical, 8) - if availability.canBlockSender { - ActionButton( - title: L10n.Chats.Chats.Message.Action.blockSender, - icon: "hand.raised", - isDestructive: true, - action: { onSelectAction(.blockSender) } - ) - } - if availability.canDelete { - ActionButton( - title: L10n.Chats.Chats.Message.Action.delete, - icon: "trash", - isDestructive: true, - action: { onSelectAction(.delete) } - ) - } - } + var body: some View { + if availability.canBlockSender || availability.canDelete { + Divider() + .padding(.vertical, 8) + if availability.canBlockSender { + ActionButton( + title: L10n.Chats.Chats.Message.Action.blockSender, + icon: "hand.raised", + isDestructive: true, + action: { onSelectAction(.blockSender) } + ) + } + if availability.canDelete { + ActionButton( + title: L10n.Chats.Chats.Message.Action.delete, + icon: "trash", + isDestructive: true, + action: { onSelectAction(.delete) } + ) + } } + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift index dc313ed0..e722eb19 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift @@ -2,227 +2,231 @@ import MC1Services import SwiftUI struct ActionsDetailsSection: View { - let message: MessageDTO - let availability: MessageActionAvailability - @Binding var isDetailExpanded: Bool - let repeats: [MessageRepeatDTO]? - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let pathViewModel: MessagePathViewModel - - @State private var showPathMap = false - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - if availability.canViewPath { - pathMapButton - } - - if availability.canShowRepeatDetails || availability.canViewPath { - ActionsExpandableDetailRow( - message: message, - availability: availability, - isDetailExpanded: $isDetailExpanded, - repeats: repeats, - contacts: contacts, - discoveredNodes: discoveredNodes, - pathViewModel: pathViewModel - ) - } - - Text(L10n.Chats.Chats.Message.Action.details) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 4) - - if message.isOutgoing { - ActionsOutgoingDetailsRows(message: message) - } else { - ActionsIncomingDetailsRows(message: message) - } - } - .sheet(isPresented: $showPathMap) { - MessagePathMapView(message: message, pathViewModel: pathViewModel) - } + let message: MessageDTO + let availability: MessageActionAvailability + @Binding var isDetailExpanded: Bool + let repeats: [MessageRepeatDTO]? + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let pathViewModel: MessagePathViewModel + + @State private var showPathMap = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if availability.canViewPath { + pathMapButton + } + + if availability.canShowRepeatDetails || availability.canViewPath { + ActionsExpandableDetailRow( + message: message, + availability: availability, + isDetailExpanded: $isDetailExpanded, + repeats: repeats, + contacts: contacts, + discoveredNodes: discoveredNodes, + pathViewModel: pathViewModel + ) + } + + Text(L10n.Chats.Chats.Message.Action.details) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 4) + + if message.isOutgoing { + ActionsOutgoingDetailsRows(message: message) + } else { + ActionsIncomingDetailsRows(message: message) + } } - - private var pathMapButton: some View { - Button { - showPathMap = true - } label: { - HStack { - Label(L10n.Chats.Chats.Path.map, systemImage: "map") - Spacer() - } - .padding() - .contentShape(.rect) - } - .foregroundStyle(.primary) + .sheet(isPresented: $showPathMap) { + MessagePathMapView(message: message, pathViewModel: pathViewModel) + } + } + + private var pathMapButton: some View { + Button { + showPathMap = true + } label: { + HStack { + Label(L10n.Chats.Chats.Path.map, systemImage: "map") + Spacer() + } + .padding() + .contentShape(.rect) } + .foregroundStyle(.primary) + } } private struct ActionsExpandableDetailRow: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - let message: MessageDTO - let availability: MessageActionAvailability - @Binding var isDetailExpanded: Bool - let repeats: [MessageRepeatDTO]? - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let pathViewModel: MessagePathViewModel - - var body: some View { - VStack(spacing: 0) { - Button { - withAnimation(reduceMotion ? nil : .default) { - isDetailExpanded.toggle() - } - } label: { - HStack { - Label( - availability.canShowRepeatDetails - ? L10n.Chats.Chats.Message.Action.repeatDetails - : L10n.Chats.Chats.Message.Action.viewPath, - systemImage: availability.canShowRepeatDetails - ? "arrow.triangle.branch" - : "point.topleft.down.to.point.bottomright.curvepath" - ) - Spacer() - Image(systemName: "chevron.right") - .rotationEffect(.degrees(isDetailExpanded ? 90 : 0)) - .foregroundStyle(.secondary) - .font(.caption) - .accessibilityHidden(true) - } - .padding() - .contentShape(.rect) - } - .foregroundStyle(.primary) - .accessibilityValue( - isDetailExpanded - ? L10n.Chats.Chats.Message.Action.expanded - : L10n.Chats.Chats.Message.Action.collapsed - ) - - if isDetailExpanded { - Divider() - .padding(.horizontal) - ActionsExpandedContent( - message: message, - availability: availability, - repeats: repeats, - contacts: contacts, - discoveredNodes: discoveredNodes, - pathViewModel: pathViewModel - ) - .padding(.horizontal) - .padding(.bottom) - .id("expandedContent") - } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let message: MessageDTO + let availability: MessageActionAvailability + @Binding var isDetailExpanded: Bool + let repeats: [MessageRepeatDTO]? + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let pathViewModel: MessagePathViewModel + + var body: some View { + VStack(spacing: 0) { + Button { + withAnimation(reduceMotion ? nil : .default) { + isDetailExpanded.toggle() } + } label: { + HStack { + Label( + availability.canShowRepeatDetails + ? L10n.Chats.Chats.Message.Action.repeatDetails + : L10n.Chats.Chats.Message.Action.viewPath, + systemImage: availability.canShowRepeatDetails + ? "arrow.triangle.branch" + : "point.topleft.down.to.point.bottomright.curvepath" + ) + Spacer() + Image(systemName: "chevron.right") + .rotationEffect(.degrees(isDetailExpanded ? 90 : 0)) + .foregroundStyle(.secondary) + .font(.caption) + .accessibilityHidden(true) + } + .padding() + .contentShape(.rect) + } + .foregroundStyle(.primary) + .accessibilityValue( + isDetailExpanded + ? L10n.Chats.Chats.Message.Action.expanded + : L10n.Chats.Chats.Message.Action.collapsed + ) + + if isDetailExpanded { + Divider() + .padding(.horizontal) + ActionsExpandedContent( + message: message, + availability: availability, + repeats: repeats, + contacts: contacts, + discoveredNodes: discoveredNodes, + pathViewModel: pathViewModel + ) + .padding(.horizontal) + .padding(.bottom) + .id("expandedContent") + } } + } } private struct ActionsExpandedContent: View { - @Environment(\.appState) private var appState - - let message: MessageDTO - let availability: MessageActionAvailability - let repeats: [MessageRepeatDTO]? - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let pathViewModel: MessagePathViewModel - - var body: some View { - if availability.canShowRepeatDetails { - RepeatDetailsContent( - repeats: repeats, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: appState.bestAvailableLocation - ) - } else if availability.canViewPath { - MessagePathContent( - message: message, - viewModel: pathViewModel, - receiverName: appState.connectedDevice?.nodeName ?? L10n.Chats.Chats.Path.Receiver.you, - userLocation: appState.bestAvailableLocation - ) - } + @Environment(\.appState) private var appState + + let message: MessageDTO + let availability: MessageActionAvailability + let repeats: [MessageRepeatDTO]? + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let pathViewModel: MessagePathViewModel + + var body: some View { + if availability.canShowRepeatDetails { + RepeatDetailsContent( + repeats: repeats, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: appState.bestAvailableLocation + ) + } else if availability.canViewPath { + MessagePathContent( + message: message, + viewModel: pathViewModel, + receiverName: appState.connectedDevice?.nodeName ?? L10n.Chats.Chats.Path.Receiver.you, + userLocation: appState.bestAvailableLocation + ) } + } } private struct ActionsOutgoingDetailsRows: View { - let message: MessageDTO + let message: MessageDTO - var body: some View { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( - message.senderDate.formatted(date: .abbreviated, time: .standard))) + var body: some View { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( + message.senderDate.formatted(date: .abbreviated, time: .standard) + )) - if let rtt = message.roundTripTime { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.roundTrip(Int(rtt))) - } + if let rtt = message.roundTripTime { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.roundTrip(Int(rtt))) + } - if message.heardRepeats > 0 { - let word = message.heardRepeats == 1 - ? L10n.Chats.Chats.Message.Repeat.singular - : L10n.Chats.Chats.Message.Repeat.plural - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.heardRepeats(message.heardRepeats, word)) - } + if message.heardRepeats > 0 { + let word = message.heardRepeats == 1 + ? L10n.Chats.Chats.Message.Repeat.singular + : L10n.Chats.Chats.Message.Repeat.plural + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.heardRepeats(message.heardRepeats, word)) } + } } private struct ActionsIncomingDetailsRows: View { - let message: MessageDTO + let message: MessageDTO - var body: some View { - ActionInfoRow( - text: L10n.Chats.Chats.Message.Info.hops(hopCountFormatted(message)), - icon: "arrowshape.bounce.right" - ) + var body: some View { + ActionInfoRow( + text: L10n.Chats.Chats.Message.Info.hops(hopCountFormatted(message)), + icon: "arrowshape.bounce.right" + ) - if let hashSize = message.pathHashSizeIfKnown { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.pathHash(hashSize)) - } + if let hashSize = message.pathHashSizeIfKnown { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.pathHash(hashSize)) + } - if message.routeType == .tcFlood { - ActionInfoRow( - text: message.regionScope.map { L10n.Chats.Chats.Message.Info.floodedUnder($0) } - ?? L10n.Chats.Chats.Message.Info.regionUnresolved, - icon: "globe" - ) - } + if message.routeType == .tcFlood { + ActionInfoRow( + text: message.regionScope.map { L10n.Chats.Chats.Message.Info.floodedUnder($0) } + ?? L10n.Chats.Chats.Message.Info.regionUnresolved, + icon: "globe" + ) + } - let sentText = L10n.Chats.Chats.Message.Info.sent( - message.senderDate.formatted(date: .abbreviated, time: .standard)) - let adjusted = message.timestampCorrected ? " " + L10n.Chats.Chats.Message.Info.adjusted : "" - ActionInfoRow(text: sentText + adjusted) + let sentText = L10n.Chats.Chats.Message.Info.sent( + message.senderDate.formatted(date: .abbreviated, time: .standard) + ) + let adjusted = message.timestampCorrected ? " " + L10n.Chats.Chats.Message.Info.adjusted : "" + ActionInfoRow(text: sentText + adjusted) - if message.timestampCorrected { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.originalSendTime( - message.wireSentDate.formatted(date: .abbreviated, time: .standard))) - } + if message.timestampCorrected { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.originalSendTime( + message.wireSentDate.formatted(date: .abbreviated, time: .standard) + )) + } - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.received( - message.createdAt.formatted(date: .abbreviated, time: .standard))) + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.received( + message.createdAt.formatted(date: .abbreviated, time: .standard) + )) - if let snr = message.snr { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.snr(snrFormatted(snr))) - } + if let snr = message.snr { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.snr(snrFormatted(snr))) } + } - private func snrFormatted(_ snr: Double) -> String { - let quality = SNRQuality(snr: snr).localizedLabel - return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB (\(quality))" - } + private func snrFormatted(_ snr: Double) -> String { + let quality = SNRQuality(snr: snr).localizedLabel + return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB (\(quality))" + } - private func hopCountFormatted(_ message: MessageDTO) -> String { - if message.isDirectRouted { - return L10n.Chats.Chats.Message.Hops.direct - } - return "\(message.hopCount)" + private func hopCountFormatted(_ message: MessageDTO) -> String { + if message.isDirectRouted { + return L10n.Chats.Chats.Message.Hops.direct } + return "\(message.hopCount)" + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsEmojiSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsEmojiSection.swift index 04a0cba9..c973508b 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsEmojiSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsEmojiSection.swift @@ -1,29 +1,29 @@ import SwiftUI struct ActionsEmojiSection: View { - let recentEmojis: [String] - @Binding var showEmojiPicker: Bool - let onSelectEmoji: (String) -> Void + let recentEmojis: [String] + @Binding var showEmojiPicker: Bool + let onSelectEmoji: (String) -> Void - @State private var pickerSelection: String? + @State private var pickerSelection: String? - var body: some View { - EmojiPickerRow( - emojis: recentEmojis, - onSelect: onSelectEmoji, - onOpenKeyboard: { showEmojiPicker = true } - ) - .padding(.vertical, 4) - // The full picker is a child of the actions sheet. Reacting also dismisses - // the actions sheet, so defer that to the picker's onDismiss: dismissing the - // parent while the child is still presented can strand the actions sheet open. - .sheet(isPresented: $showEmojiPicker, onDismiss: { - if let emoji = pickerSelection { - pickerSelection = nil - onSelectEmoji(emoji) - } - }) { - EmojiPickerSheet(onSelect: { pickerSelection = $0 }) - } + var body: some View { + EmojiPickerRow( + emojis: recentEmojis, + onSelect: onSelectEmoji, + onOpenKeyboard: { showEmojiPicker = true } + ) + .padding(.vertical, 4) + // The full picker is a child of the actions sheet. Reacting also dismisses + // the actions sheet, so defer that to the picker's onDismiss: dismissing the + // parent while the child is still presented can strand the actions sheet open. + .sheet(isPresented: $showEmojiPicker, onDismiss: { + if let emoji = pickerSelection { + pickerSelection = nil + onSelectEmoji(emoji) + } + }) { + EmojiPickerSheet(onSelect: { pickerSelection = $0 }) } + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsPreviewHeader.swift b/MC1/Views/Chats/Reactions/Sections/ActionsPreviewHeader.swift index 3f5109ba..c34e8698 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsPreviewHeader.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsPreviewHeader.swift @@ -2,82 +2,72 @@ import MC1Services import SwiftUI struct ActionsPreviewHeader: View { - let message: MessageDTO - let senderName: String - let senderMatchKind: NodeNameMatchKind + let message: MessageDTO + let senderResolution: NodeNameResolution - @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - private var senderNodeID: String? { - guard !message.isOutgoing, - let keyPrefix = message.senderKeyPrefix, - let firstByte = keyPrefix.first else { return nil } - return String(format: "%02X", firstByte) - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - ViewThatFits(in: .horizontal) { - HStack { - senderNodeIDLabel - senderLabel - Spacer() - ActionsTimestampLabel(message: message) - } + private var senderNodeID: String? { + guard !message.isOutgoing, + let keyPrefix = message.senderKeyPrefix, + let firstByte = keyPrefix.first else { return nil } + return String(format: "%02X", firstByte) + } - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - senderNodeIDLabel - senderLabel - } - ActionsTimestampLabel(message: message) - } - } - - Text(message.text) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ViewThatFits(in: .horizontal) { + HStack { + senderNodeIDLabel + SenderNameLabel(resolution: senderResolution, font: .subheadline) + Spacer() + ActionsTimestampLabel(message: message) } - .padding() - // Only collapse to a single rotor stop when there is no interactive - // descendant. The fallback-match indicator (inside senderLabel) is a - // Button with its own label/hint/popover; .combine would destroy that - // affordance. .contain preserves the indicator and adds a parent - // container that VoiceOver users can land on. - .accessibilityElement(children: senderMatchKind == .fallback ? .contain : .combine) - } - @ViewBuilder - private var senderNodeIDLabel: some View { - if let senderNodeID { - Text(senderNodeID) - .font(.subheadline) - .foregroundStyle(.secondary) - .monospaced() - .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + senderNodeIDLabel + SenderNameLabel(resolution: senderResolution, font: .subheadline) + } + ActionsTimestampLabel(message: message) } - } + } - private var senderLabel: some View { - HStack(spacing: 4) { - Text(senderName) - .font(.subheadline) - .bold() + Text(message.text) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) + } + .padding() + // Only collapse to a single rotor stop when there is no interactive + // descendant. The fallback-match and unverified-nickname indicators + // (inside SenderNameLabel) are Buttons with their own label/hint/popover; + // .combine would destroy that affordance. .contain preserves the + // indicator and adds a parent container that VoiceOver users can land on. + .accessibilityElement( + children: (senderResolution.unverifiedNickname != nil || senderResolution.isFallback) + ? .contain : .combine + ) + } - if senderMatchKind == .fallback { - FallbackMatchIndicatorView() - } - } + @ViewBuilder + private var senderNodeIDLabel: some View { + if let senderNodeID { + Text(senderNodeID) + .font(.subheadline) + .foregroundStyle(.secondary) + .monospaced() + .accessibilityHidden(true) } + } } private struct ActionsTimestampLabel: View { - let message: MessageDTO + let message: MessageDTO - var body: some View { - Text(message.date, format: .dateTime.hour().minute()) - .font(.subheadline) - .foregroundStyle(.secondary) - } + var body: some View { + Text(message.date, format: .dateTime.hour().minute()) + .font(.subheadline) + .foregroundStyle(.secondary) + } } diff --git a/MC1/Views/Chats/Reactions/String+EmojiAccessibilityName.swift b/MC1/Views/Chats/Reactions/String+EmojiAccessibilityName.swift index 33132863..355146cd 100644 --- a/MC1/Views/Chats/Reactions/String+EmojiAccessibilityName.swift +++ b/MC1/Views/Chats/Reactions/String+EmojiAccessibilityName.swift @@ -1,14 +1,14 @@ import Foundation extension String { - /// Spoken VoiceOver name for an emoji, derived from its Unicode character name - /// (e.g. "👍" becomes "thumbs up sign"). Reaction controls use this so they - /// announce a stable, app-controlled label rather than relying on VoiceOver's - /// built-in emoji pronunciation, which varies by locale and OS version. - var emojiAccessibilityName: String { - let cfstr = NSMutableString(string: self) as CFMutableString - CFStringTransform(cfstr, nil, kCFStringTransformToUnicodeName, false) - let name = cfstr as String - return name.replacing("\\N{", with: "").replacing("}", with: "").lowercased() - } + /// Spoken VoiceOver name for an emoji, derived from its Unicode character name + /// (e.g. "👍" becomes "thumbs up sign"). Reaction controls use this so they + /// announce a stable, app-controlled label rather than relying on VoiceOver's + /// built-in emoji pronunciation, which varies by locale and OS version. + var emojiAccessibilityName: String { + let cfstr = NSMutableString(string: self) as CFMutableString + CFStringTransform(cfstr, nil, kCFStringTransformToUnicodeName, false) + let name = cfstr as String + return name.replacing("\\N{", with: "").replacing("}", with: "").lowercased() + } } diff --git a/MC1/Views/Chats/Room/RoomAuthenticationSheet.swift b/MC1/Views/Chats/Room/RoomAuthenticationSheet.swift index ba7b6d08..b990a151 100644 --- a/MC1/Views/Chats/Room/RoomAuthenticationSheet.swift +++ b/MC1/Views/Chats/Room/RoomAuthenticationSheet.swift @@ -1,41 +1,41 @@ -import SwiftUI import MC1Services +import SwiftUI struct RoomAuthenticationSheet: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let session: RemoteNodeSessionDTO - let onSuccess: (RemoteNodeSessionDTO) -> Void + let session: RemoteNodeSessionDTO + let onSuccess: (RemoteNodeSessionDTO) -> Void - @State private var contact: ContactDTO? - @State private var isLoading = true + @State private var contact: ContactDTO? + @State private var isLoading = true - var body: some View { - Group { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let contact { - NodeAuthenticationSheet( - contact: contact, - role: .roomServer, - hideNodeDetails: true, - onSuccess: onSuccess - ) - } else { - ContentUnavailableView( - L10n.Chats.Chats.RoomAuth.NotFound.title, - systemImage: "exclamationmark.triangle", - description: Text(L10n.Chats.Chats.RoomAuth.NotFound.description) - ) - } - } - .task { - contact = try? await appState.services?.dataStore.fetchContact( - radioID: session.radioID, - publicKey: session.publicKey - ) - isLoading = false - } + var body: some View { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let contact { + NodeAuthenticationSheet( + contact: contact, + role: .roomServer, + hideNodeDetails: true, + onSuccess: onSuccess + ) + } else { + ContentUnavailableView( + L10n.Chats.Chats.RoomAuth.NotFound.title, + systemImage: "exclamationmark.triangle", + description: Text(L10n.Chats.Chats.RoomAuth.NotFound.description) + ) + } + } + .task { + contact = try? await appState.services?.dataStore.fetchContact( + radioID: session.radioID, + publicKey: session.publicKey + ) + isLoading = false } + } } diff --git a/MC1/Views/Chats/Room/RoomConversationRow.swift b/MC1/Views/Chats/Room/RoomConversationRow.swift index 951807b5..a5618449 100644 --- a/MC1/Views/Chats/Room/RoomConversationRow.swift +++ b/MC1/Views/Chats/Room/RoomConversationRow.swift @@ -1,63 +1,63 @@ -import SwiftUI import MC1Services +import SwiftUI struct RoomConversationRow: View { - @Environment(\.appState) private var appState - let session: RemoteNodeSessionDTO - var referenceDate: Date? - - var body: some View { - HStack(spacing: 12) { - NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 44) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text(session.name) - .font(.headline) - .lineLimit(1) - - Spacer() - - NotificationLevelIndicator(level: session.notificationLevel) - - if session.isFavorite { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) - .font(.caption) - .accessibilityLabel(L10n.Chats.Chats.Row.favorite) - } - - if let date = session.lastMessageDate { - ConversationTimestamp(date: date, referenceDate: referenceDate) - } - } - - HStack { - if session.isConnected && appState.connectionState == .ready { - Label(L10n.Chats.Chats.Room.connected, systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - } else { - Text(L10n.Chats.Chats.Room.tapToReconnect) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - - UnreadBadges( - unreadCount: session.unreadCount, - notificationLevel: session.notificationLevel - ) - } - } - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) + @Environment(\.appState) private var appState + let session: RemoteNodeSessionDTO + var referenceDate: Date? + + var body: some View { + HStack(spacing: 12) { + NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 44) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(session.name) + .font(.headline) + .lineLimit(1) + + Spacer() + + NotificationLevelIndicator(level: session.notificationLevel) + + if session.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .font(.caption) + .accessibilityLabel(L10n.Chats.Chats.Row.favorite) + } + + if let date = session.lastMessageDate { + ConversationTimestamp(date: date, referenceDate: referenceDate) + } } - .padding(.vertical, 4) - .contentShape(.rect) + + HStack { + if session.isConnected, appState.connectionState == .ready { + Label(L10n.Chats.Chats.Room.connected, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + Text(L10n.Chats.Chats.Room.tapToReconnect) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + UnreadBadges( + unreadCount: session.unreadCount, + notificationLevel: session.notificationLevel + ) + } + } + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) } + .padding(.vertical, 4) + .contentShape(.rect) + } } diff --git a/MC1/Views/Chats/Room/RoomInfoSheet.swift b/MC1/Views/Chats/Room/RoomInfoSheet.swift index 314773c2..3d9768da 100644 --- a/MC1/Views/Chats/Room/RoomInfoSheet.swift +++ b/MC1/Views/Chats/Room/RoomInfoSheet.swift @@ -1,122 +1,134 @@ -import SwiftUI import MC1Services +import SwiftUI private typealias Strings = L10n.RemoteNodes.RemoteNodes.Room struct RoomInfoSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.chatViewModel) private var viewModel - @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.chatViewModel) private var viewModel + @Environment(\.appTheme) private var theme - let session: RemoteNodeSessionDTO + let session: RemoteNodeSessionDTO - @State private var notificationLevel: NotificationLevel - @State private var isFavorite: Bool - @State private var notificationTask: Task? - @State private var favoriteTask: Task? - @State private var showTelemetry = false - @State private var showSettings = false - - init(session: RemoteNodeSessionDTO) { - self.session = session - self._notificationLevel = State(initialValue: session.notificationLevel) - self._isFavorite = State(initialValue: session.isFavorite) - } + @State private var notificationLevel: NotificationLevel + @State private var isFavorite: Bool + @State private var notificationTask: Task? + @State private var favoriteTask: Task? + @State private var showTelemetry = false + @State private var showSettings = false + @State private var headerHeight: CGFloat = 150 - var body: some View { - NavigationStack { - List { - Section { - HStack { - Spacer() - NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 80) - Spacer() - } - .listRowBackground(Color.clear) - } + init(session: RemoteNodeSessionDTO) { + self.session = session + _notificationLevel = State(initialValue: session.notificationLevel) + _isFavorite = State(initialValue: session.isFavorite) + } - ConversationQuickActionsSection( - notificationLevel: $notificationLevel, - isFavorite: $isFavorite, - availableLevels: NotificationLevel.roomLevels - ) - .onChange(of: notificationLevel) { _, newValue in - notificationTask?.cancel() - notificationTask = Task { - await viewModel?.setNotificationLevel(.room(session), level: newValue) - } - } - .onChange(of: isFavorite) { _, newValue in - favoriteTask?.cancel() - favoriteTask = Task { - await viewModel?.setFavorite(.room(session), isFavorite: newValue) - } - } - .onDisappear { - notificationTask?.cancel() - favoriteTask?.cancel() - } + var body: some View { + NavigationStack { + List { + Section { + VStack(spacing: 12) { + NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 150) - if session.isConnected { - Section { - Button { showTelemetry = true } label: { - Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - if session.isAdmin { - Button { showSettings = true } label: { - Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") - } - } - } - .themedRowBackground(theme) - } + VStack(spacing: 4) { + Text(session.name) + .font(.title2) + .bold() - Section(Strings.details) { - LabeledContent(L10n.RemoteNodes.RemoteNodes.name, value: session.name) - LabeledContent(Strings.permission, value: session.permissionLevel.localizedName) - if session.isConnected { - LabeledContent(Strings.status, value: Strings.connected) - } - } - .themedRowBackground(theme) + Text(L10n.RemoteNodes.RemoteNodes.Auth.typeRoom) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + .scrollRevealHeaderHeight(into: $headerHeight) + .listRowBackground(Color.clear) + } - if let lastConnected = session.lastConnectedDate { - Section(Strings.activity) { - LabeledContent(Strings.lastConnected) { - Text(lastConnected, format: .relative(presentation: .named)) - } - } - .themedRowBackground(theme) - } + ConversationQuickActionsSection( + isFavorite: $isFavorite, + notificationLevel: $notificationLevel, + availableLevels: NotificationLevel.roomLevels + ) + .onChange(of: notificationLevel) { _, newValue in + notificationTask?.cancel() + notificationTask = Task { + await viewModel?.setNotificationLevel(.room(session), level: newValue) + } + } + .onChange(of: isFavorite) { _, newValue in + favoriteTask?.cancel() + favoriteTask = Task { + await viewModel?.setFavorite(.room(session), isFavorite: newValue) + } + } + .onDisappear { + notificationTask?.cancel() + favoriteTask?.cancel() + } - Section(Strings.identification) { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.publicKey) - .font(.caption) - .foregroundStyle(.secondary) - Text(session.publicKeyHex) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } - .themedRowBackground(theme) + if session.isConnected { + Section { + Button { showTelemetry = true } label: { + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") } - .themedCanvas(theme) - .navigationTitle(Strings.infoTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.done) { dismiss() } - } + if session.isAdmin { + Button { showSettings = true } label: { + Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + } } + } + .themedRowBackground(theme) } - .sheet(isPresented: $showTelemetry) { - RoomStatusView(session: session) + + Section(Strings.details) { + LabeledContent(L10n.RemoteNodes.RemoteNodes.name, value: session.name) + LabeledContent(Strings.permission, value: session.permissionLevel.localizedName) + if session.isConnected { + LabeledContent(Strings.status, value: Strings.connected) + } } - .sheet(isPresented: $showSettings) { - NavigationStack { - RoomSettingsView(session: session) + .themedRowBackground(theme) + + if let lastConnected = session.lastConnectedDate { + Section(Strings.activity) { + LabeledContent(Strings.lastConnected) { + Text(lastConnected, format: .relative(presentation: .named)) } + } + .themedRowBackground(theme) + } + + Section(Strings.identification) { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.publicKey) + .font(.caption) + .foregroundStyle(.secondary) + Text(session.publicKeyHex) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationBarTitleDisplayMode(.inline) + .scrollRevealNavigationTitle(session.name, revealAfter: headerHeight) + .contentMargins(.top, 0, for: .scrollContent) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.Common.done) { dismiss() } } + } + } + .sheet(isPresented: $showTelemetry) { + RoomStatusView(session: session) + } + .sheet(isPresented: $showSettings) { + NavigationStack { + RoomSettingsView(session: session) + } } + } } diff --git a/MC1/Views/Chats/ScanChannelQRView.swift b/MC1/Views/Chats/ScanChannelQRView.swift index 2406cac8..6a57920d 100644 --- a/MC1/Views/Chats/ScanChannelQRView.swift +++ b/MC1/Views/Chats/ScanChannelQRView.swift @@ -1,309 +1,309 @@ -import SwiftUI -import VisionKit import AudioToolbox import MC1Services +import SwiftUI +import VisionKit /// View for scanning a channel QR code to join struct ScanChannelQRView: View { - @Environment(\.appState) private var appState - - let availableSlots: [UInt8] - let onComplete: (ChannelDTO?) -> Void - - @State private var scannedChannel: MeshCoreURLParser.ChannelResult? - @State private var selectedSlot: UInt8 - @State private var isJoining = false - @State private var errorMessage: String? - @State private var cameraPermissionDenied = false - - init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { - self.availableSlots = availableSlots - self.onComplete = onComplete - self._selectedSlot = State(initialValue: availableSlots.first ?? 1) - } - - var body: some View { - Group { - if let channel = scannedChannel { - ScanConfirmationContent( - scannedChannel: channel, - isJoining: isJoining, - errorMessage: errorMessage, - onJoin: { Task { await joinChannel() } }, - onScanAgain: { - scannedChannel = nil - errorMessage = nil - } - ) - } else if cameraPermissionDenied { - CameraPermissionDeniedContent() - } else { - ScannerContent( - onScanResult: handleScanResult, - cameraPermissionDenied: $cameraPermissionDenied - ) - } - } - .navigationTitle(L10n.Chats.Chats.ScanQR.title) - .navigationBarTitleDisplayMode(.inline) + @Environment(\.appState) private var appState + + let availableSlots: [UInt8] + let onComplete: (ChannelDTO?) -> Void + + @State private var scannedChannel: MeshCoreURLParser.ChannelResult? + @State private var selectedSlot: UInt8 + @State private var isJoining = false + @State private var errorMessage: String? + @State private var cameraPermissionDenied = false + + init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { + self.availableSlots = availableSlots + self.onComplete = onComplete + _selectedSlot = State(initialValue: availableSlots.first ?? 1) + } + + var body: some View { + Group { + if let channel = scannedChannel { + ScanConfirmationContent( + scannedChannel: channel, + isJoining: isJoining, + errorMessage: errorMessage, + onJoin: { Task { await joinChannel() } }, + onScanAgain: { + scannedChannel = nil + errorMessage = nil + } + ) + } else if cameraPermissionDenied { + CameraPermissionDeniedContent() + } else { + ScannerContent( + onScanResult: handleScanResult, + cameraPermissionDenied: $cameraPermissionDenied + ) + } } + .navigationTitle(L10n.Chats.Chats.ScanQR.title) + .navigationBarTitleDisplayMode(.inline) + } - // MARK: - Private Methods + // MARK: - Private Methods - private func handleScanResult(_ result: String) { - guard let parsed = MeshCoreURLParser.parseChannelURL(result) else { - errorMessage = L10n.Chats.Chats.ScanQR.Error.invalidFormat - return - } - scannedChannel = parsed + private func handleScanResult(_ result: String) { + guard let parsed = MeshCoreURLParser.parseChannelURL(result) else { + errorMessage = L10n.Chats.Chats.ScanQR.Error.invalidFormat + return + } + scannedChannel = parsed + } + + private func joinChannel() async { + guard let radioID = appState.connectedDevice?.radioID, + let channel = scannedChannel else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return } - private func joinChannel() async { - guard let radioID = appState.connectedDevice?.radioID, - let channel = scannedChannel else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } - - isJoining = true - defer { isJoining = false } - errorMessage = nil - - do { - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } - try await channelService.setChannelWithSecret( - radioID: radioID, - index: selectedSlot, - name: channel.name, - secret: channel.secret - ) - - // Fetch the joined channel to return it - var joinedChannel: ChannelDTO? - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - joinedChannel = channels.first { $0.index == selectedSlot } - } - onComplete(joinedChannel) - } catch { - errorMessage = error.userFacingMessage - } + isJoining = true + defer { isJoining = false } + errorMessage = nil + + do { + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + try await channelService.setChannelWithSecret( + radioID: radioID, + index: selectedSlot, + name: channel.name, + secret: channel.secret + ) + + // Fetch the joined channel to return it + var joinedChannel: ChannelDTO? + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + joinedChannel = channels.first { $0.index == selectedSlot } + } + onComplete(joinedChannel) + } catch { + errorMessage = error.userFacingMessage } + } } // MARK: - Extracted Views private struct ScannerContent: View { - let onScanResult: (String) -> Void - @Binding var cameraPermissionDenied: Bool - - var body: some View { - ZStack { - if QRDataScannerView.isSupported && QRDataScannerView.isAvailable { - QRDataScannerView { result in - onScanResult(result) - } onPermissionDenied: { - cameraPermissionDenied = true - } - } else { - // Fallback for unsupported devices - ContentUnavailableView( - L10n.Chats.Chats.ScanQR.NotAvailable.title, - systemImage: "qrcode.viewfinder", - description: Text(L10n.Chats.Chats.ScanQR.NotAvailable.description) - ) - } + let onScanResult: (String) -> Void + @Binding var cameraPermissionDenied: Bool + + var body: some View { + ZStack { + if QRDataScannerView.isSupported, QRDataScannerView.isAvailable { + QRDataScannerView { result in + onScanResult(result) + } onPermissionDenied: { + cameraPermissionDenied = true + } + } else { + // Fallback for unsupported devices + ContentUnavailableView( + L10n.Chats.Chats.ScanQR.NotAvailable.title, + systemImage: "qrcode.viewfinder", + description: Text(L10n.Chats.Chats.ScanQR.NotAvailable.description) + ) + } - // Overlay with scan frame - VStack { - Spacer() + // Overlay with scan frame + VStack { + Spacer() - RoundedRectangle(cornerRadius: 20) - .stroke(.white, lineWidth: 3) - .frame(width: 250, height: 250) + RoundedRectangle(cornerRadius: 20) + .stroke(.white, lineWidth: 3) + .frame(width: 250, height: 250) - Spacer() + Spacer() - Text(L10n.Chats.Chats.ScanQR.instruction) - .font(.subheadline) - .foregroundStyle(.white) - .padding() - .background(.black.opacity(0.6), in: .capsule) - .padding(.bottom, 50) - } - } - .ignoresSafeArea() + Text(L10n.Chats.Chats.ScanQR.instruction) + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.black.opacity(0.6), in: .capsule) + .padding(.bottom, 50) + } } + .ignoresSafeArea() + } } private struct ScanConfirmationContent: View { - @Environment(\.appTheme) private var theme - - let scannedChannel: MeshCoreURLParser.ChannelResult - let isJoining: Bool - let errorMessage: String? - let onJoin: () -> Void - let onScanAgain: () -> Void - - var body: some View { - Form { - Section { - LabeledContent(L10n.Chats.Chats.CreatePrivate.channelName, value: scannedChannel.name) - - LabeledContent(L10n.Chats.Chats.ChannelInfo.secretKey) { - Text(scannedChannel.secret.uppercaseHexString()) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.details) - } - .themedRowBackground(theme) - - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } - - Section { - Button(action: onJoin) { - HStack { - Spacer() - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinPrivate.joinButton) - } - Spacer() - } - } - .disabled(isJoining) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - - Button(action: onScanAgain) { - HStack { - Spacer() - Text(L10n.Chats.Chats.ScanQR.scanAgain) - Spacer() - } - } + @Environment(\.appTheme) private var theme + + let scannedChannel: MeshCoreURLParser.ChannelResult + let isJoining: Bool + let errorMessage: String? + let onJoin: () -> Void + let onScanAgain: () -> Void + + var body: some View { + Form { + Section { + LabeledContent(L10n.Chats.Chats.CreatePrivate.channelName, value: scannedChannel.name) + + LabeledContent(L10n.Chats.Chats.ChannelInfo.secretKey) { + Text(scannedChannel.secret.uppercaseHexString()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.details) + } + .themedRowBackground(theme) + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } + + Section { + Button(action: onJoin) { + HStack { + Spacer() + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinPrivate.joinButton) } - .themedRowBackground(theme) + Spacer() + } } - .themedCanvas(theme) + .disabled(isJoining) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + + Button(action: onScanAgain) { + HStack { + Spacer() + Text(L10n.Chats.Chats.ScanQR.scanAgain) + Spacer() + } + } + } + .themedRowBackground(theme) } + .themedCanvas(theme) + } } private struct CameraPermissionDeniedContent: View { - var body: some View { - VStack(spacing: 20) { - Image(systemName: "camera.fill") - .font(.system(size: 60)) - .foregroundStyle(.secondary) - - Text(L10n.Chats.Chats.ScanQR.PermissionDenied.title) - .font(.title2) - .bold() - - Text(L10n.Chats.Chats.ScanQR.PermissionDenied.message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Button(L10n.Chats.Chats.ScanQR.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - .buttonStyle(.borderedProminent) + var body: some View { + VStack(spacing: 20) { + Image(systemName: "camera.fill") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + + Text(L10n.Chats.Chats.ScanQR.PermissionDenied.title) + .font(.title2) + .bold() + + Text(L10n.Chats.Chats.ScanQR.PermissionDenied.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button(L10n.Chats.Chats.ScanQR.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) } - .padding() + } + .buttonStyle(.borderedProminent) } + .padding() + } } // MARK: - QR Scanner using DataScannerViewController struct QRDataScannerView: UIViewControllerRepresentable { - let onScan: (String) -> Void - let onPermissionDenied: () -> Void - - static var isSupported: Bool { - DataScannerViewController.isSupported + let onScan: (String) -> Void + let onPermissionDenied: () -> Void + + static var isSupported: Bool { + DataScannerViewController.isSupported + } + + static var isAvailable: Bool { + DataScannerViewController.isAvailable + } + + func makeUIViewController(context: Context) -> DataScannerViewController { + let scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.qr])], + qualityLevel: .balanced, + recognizesMultipleItems: false, + isHighFrameRateTrackingEnabled: false, + isHighlightingEnabled: true + ) + scanner.delegate = context.coordinator + return scanner + } + + func updateUIViewController(_ controller: DataScannerViewController, context: Context) { + // Start scanning when view appears + if !controller.isScanning { + try? controller.startScanning() } + } - static var isAvailable: Bool { - DataScannerViewController.isAvailable - } + static func dismantleUIViewController(_ controller: DataScannerViewController, coordinator: Coordinator) { + controller.stopScanning() + } - func makeUIViewController(context: Context) -> DataScannerViewController { - let scanner = DataScannerViewController( - recognizedDataTypes: [.barcode(symbologies: [.qr])], - qualityLevel: .balanced, - recognizesMultipleItems: false, - isHighFrameRateTrackingEnabled: false, - isHighlightingEnabled: true - ) - scanner.delegate = context.coordinator - return scanner - } + func makeCoordinator() -> Coordinator { + Coordinator(onScan: onScan) + } - func updateUIViewController(_ controller: DataScannerViewController, context: Context) { - // Start scanning when view appears - if !controller.isScanning { - try? controller.startScanning() - } - } + @MainActor + class Coordinator: NSObject, DataScannerViewControllerDelegate { + let onScan: (String) -> Void + private var hasScanned = false - static func dismantleUIViewController(_ controller: DataScannerViewController, coordinator: Coordinator) { - controller.stopScanning() + init(onScan: @escaping (String) -> Void) { + self.onScan = onScan } - func makeCoordinator() -> Coordinator { - Coordinator(onScan: onScan) + func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) { + processItem(item, scanner: dataScanner) } - @MainActor - class Coordinator: NSObject, DataScannerViewControllerDelegate { - let onScan: (String) -> Void - private var hasScanned = false - - init(onScan: @escaping (String) -> Void) { - self.onScan = onScan - } - - func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) { - processItem(item, scanner: dataScanner) - } - - func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { - // Auto-capture first QR code detected - guard !hasScanned, let item = addedItems.first else { return } - processItem(item, scanner: dataScanner) - } + func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { + // Auto-capture first QR code detected + guard !hasScanned, let item = addedItems.first else { return } + processItem(item, scanner: dataScanner) + } - private func processItem(_ item: RecognizedItem, scanner: DataScannerViewController) { - guard !hasScanned else { return } + private func processItem(_ item: RecognizedItem, scanner: DataScannerViewController) { + guard !hasScanned else { return } - if case .barcode(let barcode) = item, - let payload = barcode.payloadStringValue { - hasScanned = true - scanner.stopScanning() - AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) - onScan(payload) - } - } + if case let .barcode(barcode) = item, + let payload = barcode.payloadStringValue { + hasScanned = true + scanner.stopScanning() + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + onScan(payload) + } } + } } #Preview { - NavigationStack { - ScanChannelQRView(availableSlots: [1, 2, 3], onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + ScanChannelQRView(availableSlots: [1, 2, 3], onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/SendDMContext.swift b/MC1/Views/Chats/SendDMContext.swift index ba8b4466..da4dabd5 100644 --- a/MC1/Views/Chats/SendDMContext.swift +++ b/MC1/Views/Chats/SendDMContext.swift @@ -2,7 +2,8 @@ import Foundation /// Context for presenting the send-DM sheet from a channel message sender. struct SendDMContext: Identifiable { - let id = UUID() - let senderName: String - let radioID: UUID + let id = UUID() + let senderName: String + let radioID: UUID + let unverifiedNickname: String? } diff --git a/MC1/Views/Chats/SenderContactMatcher.swift b/MC1/Views/Chats/SenderContactMatcher.swift index e5d2b261..7049f6c2 100644 --- a/MC1/Views/Chats/SenderContactMatcher.swift +++ b/MC1/Views/Chats/SenderContactMatcher.swift @@ -7,20 +7,20 @@ import MC1Services /// features that need to act on the sender (Block Sender, Send DM) rely on /// case-insensitive name matching against the local contact list. enum SenderContactMatcher { - /// Returns contacts whose `name` matches `senderName` case-insensitively. - /// - /// - Parameters: - /// - contacts: The contact list to filter, typically fetched from `PersistenceStore`. - /// - senderName: The sender display name from the channel message. - /// - excludeBlocked: When `true`, already-blocked contacts are omitted. - static func filter( - contacts: [ContactDTO], - senderName: String, - excludeBlocked: Bool = false - ) -> [ContactDTO] { - contacts.filter { contact in - (!excludeBlocked || !contact.isBlocked) - && contact.name.localizedCaseInsensitiveCompare(senderName) == .orderedSame - } + /// Returns contacts whose `name` matches `senderName` case-insensitively. + /// + /// - Parameters: + /// - contacts: The contact list to filter, typically fetched from `PersistenceStore`. + /// - senderName: The sender display name from the channel message. + /// - excludeBlocked: When `true`, already-blocked contacts are omitted. + static func filter( + contacts: [ContactDTO], + senderName: String, + excludeBlocked: Bool = false + ) -> [ContactDTO] { + contacts.filter { contact in + (!excludeBlocked || !contact.isBlocked) + && contact.name.localizedCaseInsensitiveCompare(senderName) == .orderedSame } + } } diff --git a/MC1/Views/Chats/Sheets/AddContactConfirmationSheet.swift b/MC1/Views/Chats/Sheets/AddContactConfirmationSheet.swift index d2e81e7c..d922625b 100644 --- a/MC1/Views/Chats/Sheets/AddContactConfirmationSheet.swift +++ b/MC1/Views/Chats/Sheets/AddContactConfirmationSheet.swift @@ -1,199 +1,208 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "AddContactConfirmationSheet") /// Confirmation sheet shown when tapping a meshcore://contact/add link in a chat message @MainActor struct AddContactConfirmationSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - let contactResult: MeshCoreURLParser.ContactResult - let onComplete: (ContactDTO?) -> Void - - @State private var isAdding = false - @State private var errorMessage: String? - @State private var successTrigger = 0 - - private var isMissingDevice: Bool { - appState.connectedDevice == nil - } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - if isMissingDevice { - ContactMissingDeviceContent( - contactName: contactResult.name, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else { - ContactAddConfirmationContent( - contactResult: contactResult, - errorMessage: errorMessage, - isAdding: isAdding, - onAdd: { Task { await addContact() } } - ) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .navigationTitle(L10n.Contacts.Contacts.Add.nodeTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Contacts.Contacts.Common.cancel) { - onComplete(nil) - dismiss() - } - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + let contactResult: MeshCoreURLParser.ContactResult + let onComplete: (ContactDTO?) -> Void + + @State private var isAdding = false + @State private var errorMessage: String? + @State private var successTrigger = 0 + + private var isMissingDevice: Bool { + appState.connectedDevice == nil + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if isMissingDevice { + ContactMissingDeviceContent( + contactName: contactResult.name, + onDismiss: { + onComplete(nil) + dismiss() } - .sensoryFeedback(.success, trigger: successTrigger) - .sensoryFeedback(.error, trigger: errorMessage) + ) + } else { + ContactAddConfirmationContent( + contactResult: contactResult, + errorMessage: errorMessage, + isAdding: isAdding, + onAdd: { Task { await addContact() } } + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .navigationTitle(L10n.Contacts.Contacts.Add.nodeTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Common.cancel) { + onComplete(nil) + dismiss() + } } + } + .sensoryFeedback(.success, trigger: successTrigger) + .sensoryFeedback(.error, trigger: errorMessage) } + } - // MARK: - Private Methods + // MARK: - Private Methods - private func addContact() async { - guard let radioID = appState.connectedDevice?.radioID else { return } + private func addContact() async { + guard let radioID = appState.connectedDevice?.radioID else { return } - guard let contactService = appState.services?.contactService, - let dataStore = appState.services?.dataStore else { - errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected - return - } - - isAdding = true - errorMessage = nil - - do { - let contact = ContactFrame( - publicKey: contactResult.publicKey, - type: contactResult.contactType, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: contactResult.name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: UInt32(Date().timeIntervalSince1970) - ) - - try await contactService.addOrUpdateContact( - radioID: radioID, - contact: contact - ) - - if let addedContact = try await dataStore.fetchContact( - radioID: radioID, - publicKey: contactResult.publicKey - ) { - successTrigger += 1 - onComplete(addedContact) - dismiss() - } else { - errorMessage = L10n.Contacts.Contacts.Common.errorOccurred - } - } catch { - logger.error("Failed to add contact from link: \(error)") - errorMessage = error.userFacingMessage - } + guard let contactService = appState.services?.contactService, + let dataStore = appState.services?.dataStore else { + errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected + return + } - isAdding = false + isAdding = true + errorMessage = nil + + do { + let contact = ContactFrame( + publicKey: contactResult.publicKey, + type: contactResult.contactType, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: contactResult.name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: UInt32(Date().timeIntervalSince1970) + ) + + try await contactService.addOrUpdateContact( + radioID: radioID, + contact: contact + ) + + if let addedContact = try await dataStore.fetchContact( + radioID: radioID, + publicKey: contactResult.publicKey + ) { + successTrigger += 1 + onComplete(addedContact) + dismiss() + } else { + errorMessage = L10n.Contacts.Contacts.Common.errorOccurred + } + } catch { + logger.error("Failed to add contact from link: \(error)") + errorMessage = error.userFacingMessage } + + isAdding = false + } } // MARK: - Extracted Views private struct ContactMissingDeviceContent: View { - let contactName: String - let onDismiss: () -> Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Localizable.Common.Status.disconnected, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Contacts.Contacts.Add.Error.notConnected) - } actions: { - Button(L10n.Contacts.Contacts.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let contactName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Localizable.Common.Status.disconnected, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Contacts.Contacts.Add.Error.notConnected) + } actions: { + Button(L10n.Contacts.Contacts.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct ContactAddConfirmationContent: View { - let contactResult: MeshCoreURLParser.ContactResult - let errorMessage: String? - let isAdding: Bool - let onAdd: () -> Void - - var body: some View { - VStack(spacing: 24) { - Spacer() - - VStack(spacing: 16) { - ZStack { - Circle() - .fill(contactResult.contactType.displayColor) - .frame(width: 80, height: 80) - - Image(systemName: contactResult.contactType.iconSystemName) - .font(.system(size: 36, weight: .bold)) - .foregroundStyle(.white) - } - - Text(contactResult.name) - .font(.title) - .bold() - - Text(contactResult.contactType.localizedName) - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(contactResult.publicKey.uppercaseHexString()) - .font(.caption) - .monospaced() - .foregroundStyle(.tertiary) - .textSelection(.enabled) - } - - if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundStyle(.red) - .padding(.horizontal) - } + let contactResult: MeshCoreURLParser.ContactResult + let errorMessage: String? + let isAdding: Bool + let onAdd: () -> Void + + var body: some View { + VStack(spacing: 24) { + Spacer() + + VStack(spacing: 16) { + ZStack { + Circle() + .fill(contactResult.contactType.displayColor) + .frame(width: 80, height: 80) + + Image(systemName: contactResult.contactType.iconSystemName) + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(.white) + } - Button(action: onAdd) { - if isAdding { - ProgressView() - } else { - Text(L10n.Contacts.Contacts.Add.add) - } - } - .liquidGlassProminentButtonStyle() - .disabled(isAdding) - .padding(.horizontal, 48) - .padding(.bottom, 32) + // The name is free text the sender controls; the public key is the + // verifiable identity, so the key takes primary prominence to defeat + // name-over-key phishing from a planted QR code or link. + Text(contactResult.name) + .font(.title3) + .foregroundStyle(.secondary) + + Text(contactResult.contactType.localizedName) + .font(.caption) + .foregroundStyle(.secondary) + + VStack(spacing: 6) { + Text(L10n.Contacts.Contacts.Add.publicKey) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Text(contactResult.publicKey.uppercaseHexString(separator: " ")) + .font(.callout.monospaced()) + .multilineTextAlignment(.center) + .foregroundStyle(.primary) + .textSelection(.enabled) + .padding(.horizontal, 8) } + } + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + .padding(.horizontal) + } + + Button(action: onAdd) { + if isAdding { + ProgressView() + } else { + Text(L10n.Contacts.Contacts.Add.add) + } + } + .liquidGlassProminentButtonStyle() + .disabled(isAdding) + .padding(.horizontal, 48) + .padding(.bottom, 32) } - + } } #Preview { - let result = MeshCoreURLParser.ContactResult( - name: "TestRepeater", - publicKey: Data(repeating: 0xAA, count: 32), - contactType: .repeater - ) - AddContactConfirmationSheet(contactResult: result) { _ in } - .environment(\.appState, AppState()) - .presentationDetents([.medium, .large]) + let result = MeshCoreURLParser.ContactResult( + name: "TestRepeater", + publicKey: Data(repeating: 0xAA, count: 32), + contactType: .repeater + ) + AddContactConfirmationSheet(contactResult: result) { _ in } + .environment(\.appState, AppState()) + .presentationDetents([.medium, .large]) } diff --git a/MC1/Views/Chats/Sheets/BlockSenderSheet.swift b/MC1/Views/Chats/Sheets/BlockSenderSheet.swift index 6da23719..bd25763d 100644 --- a/MC1/Views/Chats/Sheets/BlockSenderSheet.swift +++ b/MC1/Views/Chats/Sheets/BlockSenderSheet.swift @@ -1,6 +1,6 @@ import CoreLocation -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "BlockSenderSheet") @@ -8,105 +8,105 @@ private let logger = Logger(subsystem: "com.mc1", category: "BlockSenderSheet") /// Confirmation sheet for blocking a channel sender name. /// Shows name-based limitation warning and any matching contacts the user can optionally block. struct BlockSenderSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss - let senderName: String - let radioID: UUID - let onBlock: (_ blockedContactIDs: Set) -> Void + let senderName: String + let radioID: UUID + let onBlock: (_ blockedContactIDs: Set) -> Void - @State private var matchingContacts: [ContactDTO] = [] - @State private var selectedContactIDs: Set = [] + @State private var matchingContacts: [ContactDTO] = [] + @State private var selectedContactIDs: Set = [] - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text(L10n.Chats.Chats.BlockSender.limitation) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(L10n.Chats.Chats.BlockSender.limitation) + .font(.subheadline) + .foregroundStyle(.secondary) - if !matchingContacts.isEmpty { - ContactMatchSection( - contacts: matchingContacts, - selectedIDs: $selectedContactIDs, - userLocation: appState.bestAvailableLocation - ) - } - } - .padding() - } - .navigationTitle(L10n.Chats.Chats.BlockSender.title(senderName)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.BlockSender.cancel) { - dismiss() - } - } - ToolbarItem(placement: .destructiveAction) { - Button(L10n.Chats.Chats.BlockSender.blockAnyway, role: .destructive) { - onBlock(selectedContactIDs) - dismiss() - } - } - } - .task { - await loadMatchingContacts() - } + if !matchingContacts.isEmpty { + ContactMatchSection( + contacts: matchingContacts, + selectedIDs: $selectedContactIDs, + userLocation: appState.bestAvailableLocation + ) + } + } + .padding() + } + .navigationTitle(L10n.Chats.Chats.BlockSender.title(senderName)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.BlockSender.cancel) { + dismiss() + } } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationBackground(.background) + ToolbarItem(placement: .destructiveAction) { + Button(L10n.Chats.Chats.BlockSender.blockAnyway, role: .destructive) { + onBlock(selectedContactIDs) + dismiss() + } + } + } + .task { + await loadMatchingContacts() + } } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(.background) + } - private func loadMatchingContacts() async { - guard let store = appState.offlineDataStore else { - logger.warning("No data store available for contact matching") - return - } + private func loadMatchingContacts() async { + guard let store = appState.offlineDataStore else { + logger.warning("No data store available for contact matching") + return + } - do { - let allContacts = try await store.fetchContacts(radioID: radioID) - matchingContacts = SenderContactMatcher.filter( - contacts: allContacts, - senderName: senderName, - excludeBlocked: true - ) - logger.info("Found \(matchingContacts.count) matching contacts for sender '\(senderName)'") - } catch { - logger.error("Failed to fetch contacts for matching: \(error)") - } + do { + let allContacts = try await store.fetchContacts(radioID: radioID) + matchingContacts = SenderContactMatcher.filter( + contacts: allContacts, + senderName: senderName, + excludeBlocked: true + ) + logger.info("Found \(matchingContacts.count) matching contacts for sender '\(senderName)'") + } catch { + logger.error("Failed to fetch contacts for matching: \(error)") } + } } // MARK: - Contact Match Section private struct ContactMatchSection: View { - let contacts: [ContactDTO] - @Binding var selectedIDs: Set - let userLocation: CLLocation? + let contacts: [ContactDTO] + @Binding var selectedIDs: Set + let userLocation: CLLocation? - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.Chats.Chats.BlockSender.matchingContacts) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Chats.Chats.BlockSender.matchingContacts) + .font(.subheadline) + .foregroundStyle(.secondary) - ForEach(contacts) { contact in - ContactMatchRow( - contact: contact, - style: .toggle(isSelected: selectedIDs.contains(contact.id)), - userLocation: userLocation, - action: { - if selectedIDs.contains(contact.id) { - selectedIDs.remove(contact.id) - } else { - selectedIDs.insert(contact.id) - } - } - ) + ForEach(contacts) { contact in + ContactMatchRow( + contact: contact, + style: .toggle(isSelected: selectedIDs.contains(contact.id)), + userLocation: userLocation, + action: { + if selectedIDs.contains(contact.id) { + selectedIDs.remove(contact.id) + } else { + selectedIDs.insert(contact.id) } - } + } + ) + } } + } } diff --git a/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift b/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift index e92bc6ad..4a293655 100644 --- a/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift +++ b/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift @@ -1,704 +1,689 @@ -import SwiftUI import MC1Services -import CoreImage.CIFilterBuiltins import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "ChannelInfoSheet") /// Sheet displaying channel info with sharing and deletion options struct ChannelInfoSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.chatViewModel) private var viewModel - @Environment(\.appTheme) private var theme - - let channel: ChannelDTO - let onClearMessages: () -> Void - let onDelete: () -> Void - - @State private var notificationLevel: NotificationLevel - @State private var isFavorite: Bool - @State private var isDeleting = false - @State private var isClearingMessages = false - @State private var showingDeleteConfirmation = false - @State private var showingClearMessagesConfirmation = false - @State private var errorMessage: String? - @State private var copyHapticTrigger = 0 - @State private var notificationTask: Task? - @State private var favoriteTask: Task? - @State private var isRegionExpanded = false - @State private var isDiscoveringRegions = false - @State private var discoveryMessage: String? - @State private var showingRegionManagement = false - @State private var discoveryTask: Task? - @State private var discoveredNewRegions: [String] = [] - @State private var showingDiscoveryResults = false - @State private var selectedFloodScope: ChannelFloodScope - - init(channel: ChannelDTO, onClearMessages: @escaping () -> Void, onDelete: @escaping () -> Void) { - self.channel = channel - self.onClearMessages = onClearMessages - self.onDelete = onDelete - self._notificationLevel = State(initialValue: channel.notificationLevel) - self._isFavorite = State(initialValue: channel.isFavorite) - self._selectedFloodScope = State(initialValue: channel.floodScope) - } + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.chatViewModel) private var viewModel + @Environment(\.appTheme) private var theme + + let channel: ChannelDTO + let onClearMessages: () -> Void + let onDelete: () -> Void + + @State private var notificationLevel: NotificationLevel + @State private var isFavorite: Bool + @State private var isDeleting = false + @State private var isClearingMessages = false + @State private var showingDeleteConfirmation = false + @State private var showingClearMessagesConfirmation = false + @State private var errorMessage: String? + @State private var copyHapticTrigger = 0 + @State private var notificationTask: Task? + @State private var favoriteTask: Task? + @State private var isRegionExpanded = false + @State private var isDiscoveringRegions = false + @State private var discoveryMessage: String? + @State private var showingRegionManagement = false + @State private var discoveryTask: Task? + @State private var discoveredNewRegions: [String] = [] + @State private var showingDiscoveryResults = false + @State private var selectedFloodScope: ChannelFloodScope + @State private var headerHeight: CGFloat = 150 + + init(channel: ChannelDTO, onClearMessages: @escaping () -> Void, onDelete: @escaping () -> Void) { + self.channel = channel + self.onClearMessages = onClearMessages + self.onDelete = onDelete + _notificationLevel = State(initialValue: channel.notificationLevel) + _isFavorite = State(initialValue: channel.isFavorite) + _selectedFloodScope = State(initialValue: channel.floodScope) + } + + var body: some View { + NavigationStack { + Form { + // Channel Header Section + ChannelInfoHeaderSection(channel: channel, measuredHeight: $headerHeight) + + // Quick Actions Section + ConversationQuickActionsSection( + isFavorite: $isFavorite, + notificationLevel: $notificationLevel, + availableLevels: NotificationLevel.channelLevels + ) + .onChange(of: notificationLevel) { _, newValue in + notificationTask?.cancel() + notificationTask = Task { + await viewModel?.setNotificationLevel(.channel(channel), level: newValue) + } + } + .onChange(of: isFavorite) { _, newValue in + favoriteTask?.cancel() + favoriteTask = Task { + await viewModel?.setFavorite(.channel(channel), isFavorite: newValue) + } + } + .onDisappear { + notificationTask?.cancel() + favoriteTask?.cancel() + discoveryTask?.cancel() + } - var body: some View { - NavigationStack { - Form { - // Channel Header Section - ChannelInfoHeaderSection(channel: channel) - - // Quick Actions Section - ConversationQuickActionsSection( - notificationLevel: $notificationLevel, - isFavorite: $isFavorite, - availableLevels: NotificationLevel.channelLevels - ) - .onChange(of: notificationLevel) { _, newValue in - notificationTask?.cancel() - notificationTask = Task { - await viewModel?.setNotificationLevel(.channel(channel), level: newValue) - } - } - .onChange(of: isFavorite) { _, newValue in - favoriteTask?.cancel() - favoriteTask = Task { - await viewModel?.setFavorite(.channel(channel), isFavorite: newValue) - } - } - .onDisappear { - notificationTask?.cancel() - favoriteTask?.cancel() - discoveryTask?.cancel() - } - - // Region Scope Section - ChannelInfoRegionSection( - knownRegions: knownRegions, - selectedFloodScope: selectedFloodScope, - deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName, - isExpanded: $isRegionExpanded, - onFloodScopeSelected: { scope in - Task { await selectFloodScope(scope) } - }, - onManageTapped: { - showingRegionManagement = true - } - ) - - // QR Code Section (only for private channels with secrets) - if channel.hasSecret && !channel.isPublicChannel { - ChannelInfoQRCodeSection(channel: channel) - } - - // Secret Key Section (only for private channels) - if channel.hasSecret && !channel.isPublicChannel { - ChannelInfoSecretKeySection( - channel: channel, - copyHapticTrigger: $copyHapticTrigger - ) - } - - // Error Section - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - Button(L10n.Localizable.Common.tryAgain) { - Task { await deleteChannel() } - } - } - .themedRowBackground(theme) - } - - // Actions Section - ChannelInfoActionsSection( - isActionInProgress: isDeleting || isClearingMessages, - isClearingMessages: isClearingMessages, - isDeleting: isDeleting, - showingClearMessagesConfirmation: $showingClearMessagesConfirmation, - showingDeleteConfirmation: $showingDeleteConfirmation - ) - } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.ChannelInfo.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.done) { - dismiss() - } - } - } - .navigationDestination(isPresented: $showingRegionManagement) { - RegionManagementView( - knownRegions: knownRegions, - isDiscovering: $isDiscoveringRegions, - discoveryMessage: $discoveryMessage, - onRemoveRegion: { region in - appState.connectionManager.removeKnownRegion(region) - }, - onAddRegion: { region in - appState.connectionManager.addKnownRegion(region) - }, - onDiscoverTapped: { - runDiscovery { newRegions in - discoveredNewRegions = newRegions - showingDiscoveryResults = true - } - } - ) - } - .navigationDestination(isPresented: $showingDiscoveryResults) { - RegionDiscoveryResultsView(discoveredRegions: discoveredNewRegions) { selected in - for region in selected { - appState.connectionManager.addKnownRegion(region) - } - } - } + // Region Scope Section + ChannelInfoRegionSection( + knownRegions: knownRegions, + selectedFloodScope: selectedFloodScope, + deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName, + isExpanded: $isRegionExpanded, + onFloodScopeSelected: { scope in + Task { await selectFloodScope(scope) } + }, + onManageTapped: { + showingRegionManagement = true + } + ) + + // QR Code Section (only for private channels with secrets) + if channel.hasSecret, !channel.isPublicChannel { + ChannelInfoQRCodeSection(channel: channel) } - .confirmationDialog( - L10n.Chats.Chats.ChannelInfo.ClearMessagesConfirm.title, - isPresented: $showingClearMessagesConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, role: .destructive) { - Task { - await clearMessages() - } - } - Button(L10n.Chats.Chats.Common.cancel, role: .cancel) {} - } message: { - Text(L10n.Chats.Chats.ChannelInfo.ClearMessagesConfirm.message) + + // Secret Key Section (only for private channels) + if channel.hasSecret, !channel.isPublicChannel { + ChannelInfoSecretKeySection( + channel: channel, + copyHapticTrigger: $copyHapticTrigger + ) } - .confirmationDialog( - L10n.Chats.Chats.ChannelInfo.DeleteConfirm.title, - isPresented: $showingDeleteConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Chats.Chats.ChannelInfo.deleteButton, role: .destructive) { - Task { - await deleteChannel() - } + + // Error Section + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + Button(L10n.Localizable.Common.tryAgain) { + Task { await deleteChannel() } } - Button(L10n.Chats.Chats.Common.cancel, role: .cancel) {} - } message: { - Text(L10n.Chats.Chats.ChannelInfo.DeleteConfirm.message) + } + .themedRowBackground(theme) } - .sensoryFeedback(.success, trigger: copyHapticTrigger) - } - - // MARK: - Private Methods - private func clearNotificationsForChannel(radioID: UUID) async { - await appState.services?.notificationService.removeDeliveredNotifications( - forChannelIndex: channel.index, - radioID: radioID + // Actions Section + ChannelInfoActionsSection( + isActionInProgress: isDeleting || isClearingMessages, + isClearingMessages: isClearingMessages, + isDeleting: isDeleting, + showingClearMessagesConfirmation: $showingClearMessagesConfirmation, + showingDeleteConfirmation: $showingDeleteConfirmation ) - await appState.services?.notificationService.updateBadgeCount() + } + .themedCanvas(theme) + .navigationBarTitleDisplayMode(.inline) + .scrollRevealNavigationTitle(channel.displayName, revealAfter: headerHeight) + .contentMargins(.top, 0, for: .scrollContent) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Chats.Chats.Common.done) { + dismiss() + } + } + } + .navigationDestination(isPresented: $showingRegionManagement) { + RegionManagementView( + knownRegions: knownRegions, + isDiscovering: $isDiscoveringRegions, + discoveryMessage: $discoveryMessage, + onRemoveRegion: { region in + appState.connectionManager.removeKnownRegion(region) + }, + onAddRegion: { region in + appState.connectionManager.addKnownRegion(region) + }, + onDiscoverTapped: { + runDiscovery { newRegions in + discoveredNewRegions = newRegions + showingDiscoveryResults = true + } + } + ) + } + .navigationDestination(isPresented: $showingDiscoveryResults) { + RegionDiscoveryResultsView(discoveredRegions: discoveredNewRegions) { selected in + for region in selected { + appState.connectionManager.addKnownRegion(region) + } + } + } } - - private func deleteChannel() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return + .confirmationDialog( + L10n.Chats.Chats.ChannelInfo.ClearMessagesConfirm.title, + isPresented: $showingClearMessagesConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, role: .destructive) { + Task { + await clearMessages() } - - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return + } + Button(L10n.Chats.Chats.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Chats.Chats.ChannelInfo.ClearMessagesConfirm.message) + } + .confirmationDialog( + L10n.Chats.Chats.ChannelInfo.DeleteConfirm.title, + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Chats.Chats.ChannelInfo.deleteButton, role: .destructive) { + Task { + await deleteChannel() } + } + Button(L10n.Chats.Chats.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Chats.Chats.ChannelInfo.DeleteConfirm.message) + } + .sensoryFeedback(.success, trigger: copyHapticTrigger) + } - isDeleting = true - errorMessage = nil + // MARK: - Private Methods - do { - // Clear channel on device (sends empty name + zero secret via BLE) - // and deletes from local database - try await channelService.clearChannel( - radioID: radioID, - index: channel.index - ) + private func clearNotificationsForChannel(radioID: UUID) async { + await appState.services?.notificationService.removeDeliveredNotifications( + forChannelIndex: channel.index, + radioID: radioID + ) + await appState.services?.notificationService.updateBadgeCount() + } - await clearNotificationsForChannel(radioID: radioID) + private func deleteChannel() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return + } - dismiss() - onDelete() - } catch { - errorMessage = error.userFacingMessage - isDeleting = false - } + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return } - private func clearMessages() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } + isDeleting = true + errorMessage = nil - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } + do { + // Clear channel on device (sends empty name + zero secret via BLE) + // and deletes from local database + try await channelService.clearChannel( + radioID: radioID, + index: channel.index + ) - isClearingMessages = true - errorMessage = nil + await clearNotificationsForChannel(radioID: radioID) - do { - try await channelService.clearChannelMessages( - radioID: radioID, - channelIndex: channel.index - ) + dismiss() + onDelete() + } catch { + errorMessage = error.userFacingMessage + isDeleting = false + } + } - await clearNotificationsForChannel(radioID: radioID) + private func clearMessages() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return + } - onClearMessages() - dismiss() - } catch { - errorMessage = error.userFacingMessage - isClearingMessages = false - } + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return } - private func selectFloodScope(_ scope: ChannelFloodScope) async { - let previous = selectedFloodScope - selectedFloodScope = scope - do { - try await appState.offlineDataStore?.setChannelFloodScope(channel.id, floodScope: scope) - } catch { - logger.error("Failed to save flood scope: \(error.localizedDescription)") - selectedFloodScope = previous - return - } + isClearingMessages = true + errorMessage = nil - if let session = appState.services?.session { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: scope, - deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName, - supportsUnscopedFloodSend: appState.connectedDevice?.supportsUnscopedFloodSend ?? false - ) - switch resolved { - case .unscoped: - try? await session.setFloodScopeUnscoped() - case .scope(let scope): - try? await session.setFloodScope(scope) - } - } + do { + try await channelService.clearChannelMessages( + radioID: radioID, + channelIndex: channel.index + ) + + await clearNotificationsForChannel(radioID: radioID) + + onClearMessages() + dismiss() + } catch { + errorMessage = error.userFacingMessage + isClearingMessages = false + } + } + + private func selectFloodScope(_ scope: ChannelFloodScope) async { + let previous = selectedFloodScope + selectedFloodScope = scope + do { + try await appState.offlineDataStore?.setChannelFloodScope(channel.id, floodScope: scope) + } catch { + logger.error("Failed to save flood scope: \(error.localizedDescription)") + selectedFloodScope = previous + return } - private var knownRegions: [String] { - appState.connectedDevice?.knownRegions ?? [] + if let session = appState.services?.session { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: scope, + deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName, + supportsUnscopedFloodSend: appState.connectedDevice?.supportsUnscopedFloodSend ?? false + ) + switch resolved { + case .unscoped: + try? await session.setFloodScopeUnscoped() + case let .scope(scope): + try? await session.setFloodScope(scope) + } } + } - private func runDiscovery(onNewRegions: @escaping ([String]) async -> Void) { - discoveryTask?.cancel() - discoveryTask = Task { - isDiscoveringRegions = true - discoveryMessage = nil + private var knownRegions: [String] { + appState.connectedDevice?.knownRegions ?? [] + } - let newRegions = await discoverNewRegions() + private func runDiscovery(onNewRegions: @escaping ([String]) async -> Void) { + discoveryTask?.cancel() + discoveryTask = Task { + isDiscoveringRegions = true + discoveryMessage = nil - guard !Task.isCancelled else { - isDiscoveringRegions = false - return - } + let newRegions = await discoverNewRegions() - if newRegions.isEmpty { - if discoveryMessage == nil { - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noNewRegions - } - } else { - await onNewRegions(newRegions) - } - isDiscoveringRegions = false + guard !Task.isCancelled else { + isDiscoveringRegions = false + return + } + + if newRegions.isEmpty { + if discoveryMessage == nil { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noNewRegions } + } else { + await onNewRegions(newRegions) + } + isDiscoveringRegions = false } + } - /// Broadcasts a discover probe to find nearby repeaters, then queries only those for regions - private func discoverNewRegions() async -> [String] { - guard let session = appState.services?.session, - let contactService = appState.services?.contactService else { - return [] - } + /// Broadcasts a discover probe to find nearby repeaters, then queries only those for regions + private func discoverNewRegions() async -> [String] { + guard let session = appState.services?.session, + let contactService = appState.services?.contactService else { + return [] + } - let outcome = await RegionDiscoveryService.discover( - session: session, - contactService: contactService, - dataStore: appState.offlineDataStore, - radioID: channel.radioID, - knownRegions: knownRegions, - supportsAdHocRequest: appState.connectedDevice?.supportsAdHocRepeaterRequest ?? false - ) + let outcome = await RegionDiscoveryService.discover( + session: session, + contactService: contactService, + dataStore: appState.offlineDataStore, + radioID: channel.radioID, + knownRegions: knownRegions, + supportsAdHocRequest: appState.connectedDevice?.supportsAdHocRepeaterRequest ?? false + ) - switch outcome { - case .sendFailed: - return [] - case .noRepeatersResponded: - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded - return [] - case .errorLoadingRepeaters: - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errLoadingRepeaters - return [] - case let .completed(newRegions, allRepeatersTableFull): - if newRegions.isEmpty && allRepeatersTableFull { - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errRadioContactsFull - } - return newRegions - } + switch outcome { + case .sendFailed: + return [] + case .noRepeatersResponded: + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded + return [] + case .errorLoadingRepeaters: + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errLoadingRepeaters + return [] + case let .completed(newRegions, allRepeatersTableFull): + if newRegions.isEmpty, allRepeatersTableFull { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errRadioContactsFull + } + return newRegions } + } } // MARK: - Extracted Views private struct ChannelInfoHeaderSection: View { - let channel: ChannelDTO - - private var channelTypeLabel: String { - if channel.isPublicChannel { - return L10n.Chats.Chats.ChannelInfo.ChannelType.`public` - } else if channel.name.hasPrefix("#") { - return L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag - } else { - return L10n.Chats.Chats.ChannelInfo.ChannelType.`private` - } + let channel: ChannelDTO + @Binding var measuredHeight: CGFloat + + private var channelTypeLabel: String { + if channel.isPublicChannel { + L10n.Chats.Chats.ChannelInfo.ChannelType.public + } else if channel.name.hasPrefix("#") { + L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag + } else { + L10n.Chats.Chats.ChannelInfo.ChannelType.private } + } - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 12) { - ChannelAvatar(channel: channel, size: 80) - - Text(channel.displayName) - .font(.title2) - .bold() - - Text(channelTypeLabel) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - } - .listRowBackground(Color.clear) + var body: some View { + Section { + VStack(spacing: 12) { + ChannelAvatar(channel: channel, size: 150) + + VStack(spacing: 4) { + Text(channel.displayName) + .font(.title2) + .bold() + + Text(channelTypeLabel) + .font(.subheadline) + .foregroundStyle(.secondary) } + } + .frame(maxWidth: .infinity) + .scrollRevealHeaderHeight(into: $measuredHeight) + .listRowBackground(Color.clear) } + } } private struct ChannelInfoQRCodeSection: View { - @Environment(\.appTheme) private var theme - let channel: ChannelDTO - - @State private var qrImage: UIImage? - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 12) { - if let qrImage { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .scaledToFit() - .frame(width: 180, height: 180) - } - - Text(L10n.Chats.Chats.ChannelInfo.scanToJoin) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - } header: { - Text(L10n.Chats.Chats.ChannelInfo.shareChannel) - } - .themedRowBackground(theme) - .task { - qrImage = generateQRCode() + @Environment(\.appTheme) private var theme + let channel: ChannelDTO + + @State private var qrImage: UIImage? + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 12) { + if let qrImage { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .foregroundStyle(.primary) + .frame(width: 180, height: 180) + } + + Text(L10n.Chats.Chats.ChannelInfo.scanToJoin) + .font(.caption) + .foregroundStyle(.secondary) } + Spacer() + } + } header: { + Text(L10n.Chats.Chats.ChannelInfo.shareChannel) } + .themedRowBackground(theme) + .task { + qrImage = generateQRCode() + } + } - private func generateQRCode() -> UIImage? { - // Format: meshcore://channel/add?name=&secret= - let urlString = "meshcore://channel/add?name=\(channel.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")&secret=\(channel.secret.uppercaseHexString())" + private func generateQRCode() -> UIImage? { + // Format: meshcore://channel/add?name=&secret= + let urlString = "meshcore://channel/add?name=\(channel.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")&secret=\(channel.secret.uppercaseHexString())" - let context = CIContext() - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(urlString.utf8) - filter.correctionLevel = "M" + return QRCodeGenerator.generate(from: urlString) + } +} - guard let outputImage = filter.outputImage else { return nil } +private struct ChannelInfoSecretKeySection: View { + @Environment(\.appTheme) private var theme + let channel: ChannelDTO + @Binding var copyHapticTrigger: Int - // Scale up for better quality - let scale = 10.0 - let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Chats.Chats.ChannelInfo.secretKey) + .font(.caption) + .foregroundStyle(.secondary) - guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } + HStack { + Text(channel.secret.uppercaseHexString()) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) - return UIImage(cgImage: cgImage) - } -} + Spacer() -private struct ChannelInfoSecretKeySection: View { - @Environment(\.appTheme) private var theme - let channel: ChannelDTO - @Binding var copyHapticTrigger: Int - - var body: some View { - Section { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Chats.Chats.ChannelInfo.secretKey) - .font(.caption) - .foregroundStyle(.secondary) - - HStack { - Text(channel.secret.uppercaseHexString()) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - - Spacer() - - Button(L10n.Chats.Chats.ChannelInfo.copy, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = channel.secret.uppercaseHexString() - } - .labelStyle(.iconOnly) - } - } - } header: { - Text(L10n.Chats.Chats.ChannelInfo.manualSharing) - } footer: { - Text(L10n.Chats.Chats.ChannelInfo.manualSharingFooter) + Button(L10n.Chats.Chats.ChannelInfo.copy, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = channel.secret.uppercaseHexString() + } + .labelStyle(.iconOnly) } - .themedRowBackground(theme) + } + } header: { + Text(L10n.Chats.Chats.ChannelInfo.manualSharing) + } footer: { + Text(L10n.Chats.Chats.ChannelInfo.manualSharingFooter) } + .themedRowBackground(theme) + } } private struct ChannelInfoActionsSection: View { - @Environment(\.appTheme) private var theme - let isActionInProgress: Bool - let isClearingMessages: Bool - let isDeleting: Bool - @Binding var showingClearMessagesConfirmation: Bool - @Binding var showingDeleteConfirmation: Bool - - var body: some View { - Section { - Button { - showingClearMessagesConfirmation = true - } label: { - HStack { - Spacer() - if isClearingMessages { - ProgressView() - } else { - Label(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, systemImage: "xmark.circle") - } - Spacer() - } - } - .disabled(isActionInProgress) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - - Button(role: .destructive) { - showingDeleteConfirmation = true - } label: { - HStack { - Spacer() - if isDeleting { - ProgressView() - } else { - Label(L10n.Chats.Chats.ChannelInfo.deleteButton, systemImage: "trash") - } - Spacer() - } - } - .disabled(isActionInProgress) - } footer: { - Text(L10n.Chats.Chats.ChannelInfo.deleteFooter) + @Environment(\.appTheme) private var theme + let isActionInProgress: Bool + let isClearingMessages: Bool + let isDeleting: Bool + @Binding var showingClearMessagesConfirmation: Bool + @Binding var showingDeleteConfirmation: Bool + + var body: some View { + Section { + Button(role: .destructive) { + showingClearMessagesConfirmation = true + } label: { + HStack { + Label(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, systemImage: "xmark.circle") + if isClearingMessages { + Spacer() + ProgressView() + } } - .themedRowBackground(theme) + } + .disabled(isActionInProgress) + + Button(role: .destructive) { + showingDeleteConfirmation = true + } label: { + HStack { + Label(L10n.Chats.Chats.ChannelInfo.deleteButton, systemImage: "trash") + if isDeleting { + Spacer() + ProgressView() + } + } + } + .disabled(isActionInProgress) + } footer: { + Text(L10n.Chats.Chats.ChannelInfo.deleteFooter) } + .themedRowBackground(theme) + } } private struct ChannelInfoRegionSection: View { - @Environment(\.appTheme) private var theme - let knownRegions: [String] - let selectedFloodScope: ChannelFloodScope - let deviceDefaultFloodScopeName: String? - @Binding var isExpanded: Bool - let onFloodScopeSelected: (ChannelFloodScope) -> Void - let onManageTapped: () -> Void - - private var sortedPartitioned: (public: [String], private: [String]) { - let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } - return (sorted.filter { !$0.isPrivateRegion }, sorted.filter { $0.isPrivateRegion }) + @Environment(\.appTheme) private var theme + let knownRegions: [String] + let selectedFloodScope: ChannelFloodScope + let deviceDefaultFloodScopeName: String? + @Binding var isExpanded: Bool + let onFloodScopeSelected: (ChannelFloodScope) -> Void + let onManageTapped: () -> Void + + private var sortedPartitioned: (public: [String], private: [String]) { + let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + return (sorted.filter { !$0.isPrivateRegion }, sorted.filter(\.isPrivateRegion)) + } + + private var regionValueLabel: String { + if knownRegions.isEmpty { + return L10n.Chats.Chats.ChannelInfo.Region.notConfigured } - - private var regionValueLabel: String { - if knownRegions.isEmpty { - return L10n.Chats.Chats.ChannelInfo.Region.notConfigured - } - switch selectedFloodScope { - case .inherit: - if let name = deviceDefaultFloodScopeName, !name.isEmpty { - return L10n.Chats.Chats.ChannelInfo.Region.useDefaultFormat(name) - } - return L10n.Chats.Chats.ChannelInfo.Region.allRegions - case .allRegions: - return L10n.Chats.Chats.ChannelInfo.Region.allRegions - case .region(let name): - return name - } + switch selectedFloodScope { + case .inherit: + if let name = deviceDefaultFloodScopeName, !name.isEmpty { + return L10n.Chats.Chats.ChannelInfo.Region.useDefaultFormat(name) + } + return L10n.Chats.Chats.ChannelInfo.Region.allRegions + case .allRegions: + return L10n.Chats.Chats.ChannelInfo.Region.allRegions + case let .region(name): + return name } + } - var body: some View { - Section { - DisclosureGroup(isExpanded: $isExpanded) { - if knownRegions.isEmpty { - ChannelInfoRegionEmptyContent(onManageTapped: onManageTapped) - } else { - ChannelInfoRegionPickerContent( - selectedFloodScope: selectedFloodScope, - deviceDefaultFloodScopeName: deviceDefaultFloodScopeName, - publicRegions: sortedPartitioned.public, - privateRegions: sortedPartitioned.private, - onFloodScopeSelected: onFloodScopeSelected, - onManageTapped: onManageTapped - ) - } - } label: { - ChannelInfoRegionLabel(regionValueLabel: regionValueLabel) - } + var body: some View { + Section { + DisclosureGroup(isExpanded: $isExpanded) { + if knownRegions.isEmpty { + ChannelInfoRegionEmptyContent(onManageTapped: onManageTapped) + } else { + ChannelInfoRegionPickerContent( + selectedFloodScope: selectedFloodScope, + deviceDefaultFloodScopeName: deviceDefaultFloodScopeName, + publicRegions: sortedPartitioned.public, + privateRegions: sortedPartitioned.private, + onFloodScopeSelected: onFloodScopeSelected, + onManageTapped: onManageTapped + ) } - .themedRowBackground(theme) + } label: { + ChannelInfoRegionLabel(regionValueLabel: regionValueLabel) + } } + .themedRowBackground(theme) + } } private struct ChannelInfoRegionLabel: View { - let regionValueLabel: String - - var body: some View { - HStack { - Label(L10n.Chats.Chats.ChannelInfo.region, systemImage: "globe") - Spacer() - Text(regionValueLabel) - .foregroundStyle(.secondary) - } + let regionValueLabel: String + + var body: some View { + HStack { + Label(L10n.Chats.Chats.ChannelInfo.region, systemImage: "globe") + Spacer() + Text(regionValueLabel) + .foregroundStyle(.secondary) } + } } private struct ChannelInfoRegionActions: View { - let onManageTapped: () -> Void + let onManageTapped: () -> Void - var body: some View { - Button(L10n.Chats.Chats.ChannelInfo.Region.manageRegions, systemImage: "list.bullet") { - onManageTapped() - } + var body: some View { + Button(L10n.Chats.Chats.ChannelInfo.Region.manageRegions, systemImage: "list.bullet") { + onManageTapped() } + } } private struct ChannelInfoRegionEmptyContent: View { - let onManageTapped: () -> Void + let onManageTapped: () -> Void - var body: some View { - Text(L10n.Chats.Chats.ChannelInfo.Region.explanation) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + Text(L10n.Chats.Chats.ChannelInfo.Region.explanation) + .font(.subheadline) + .foregroundStyle(.secondary) - ChannelInfoRegionActions(onManageTapped: onManageTapped) - } + ChannelInfoRegionActions(onManageTapped: onManageTapped) + } } private struct ChannelInfoRegionPickerContent: View { - let selectedFloodScope: ChannelFloodScope - let deviceDefaultFloodScopeName: String? - let publicRegions: [String] - let privateRegions: [String] - let onFloodScopeSelected: (ChannelFloodScope) -> Void - let onManageTapped: () -> Void - - private var effectiveDefault: String? { - guard let name = deviceDefaultFloodScopeName, !name.isEmpty else { return nil } - return name + let selectedFloodScope: ChannelFloodScope + let deviceDefaultFloodScopeName: String? + let publicRegions: [String] + let privateRegions: [String] + let onFloodScopeSelected: (ChannelFloodScope) -> Void + let onManageTapped: () -> Void + + private var effectiveDefault: String? { + guard let name = deviceDefaultFloodScopeName, !name.isEmpty else { return nil } + return name + } + + var body: some View { + if let defaultName = effectiveDefault { + pickerRow( + title: L10n.Chats.Chats.ChannelInfo.Region.useDefaultFormat(defaultName), + isSelected: selectedFloodScope == .inherit, + scope: .inherit + ) } - var body: some View { - if let defaultName = effectiveDefault { - pickerRow( - title: L10n.Chats.Chats.ChannelInfo.Region.useDefaultFormat(defaultName), - isSelected: selectedFloodScope == .inherit, - scope: .inherit - ) - } - - pickerRow( - title: L10n.Chats.Chats.ChannelInfo.Region.allRegions, - isSelected: selectedFloodScope == allRegionsSelection, - scope: allRegionsSelection - ) - - ForEach(publicRegions, id: \.self) { region in - pickerRow( - title: region, - isSelected: selectedFloodScope == .region(region), - scope: .region(region) - ) - } - - // Private regions (shown disabled — can't scope to these) - ForEach(privateRegions, id: \.self) { region in - HStack { - Text(region) - Spacer() - Text(L10n.Chats.Chats.ChannelInfo.Region.`private`) - .font(.caption) - .foregroundStyle(.tertiary) - } - .foregroundStyle(.secondary) - } + pickerRow( + title: L10n.Chats.Chats.ChannelInfo.Region.allRegions, + isSelected: selectedFloodScope == allRegionsSelection, + scope: allRegionsSelection + ) - ChannelInfoRegionActions(onManageTapped: onManageTapped) + ForEach(publicRegions, id: \.self) { region in + pickerRow( + title: region, + isSelected: selectedFloodScope == .region(region), + scope: .region(region) + ) } - /// When no device default is set, `.inherit` and `.allRegions` collapse into a - /// single row — writing `.inherit` is the conservative choice so a later default - /// change flows through naturally. - private var allRegionsSelection: ChannelFloodScope { - effectiveDefault != nil ? .allRegions : .inherit + // Private regions (shown disabled — can't scope to these) + ForEach(privateRegions, id: \.self) { region in + HStack { + Text(region) + Spacer() + Text(L10n.Chats.Chats.ChannelInfo.Region.private) + .font(.caption) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.secondary) } - private func pickerRow(title: String, isSelected: Bool, scope: ChannelFloodScope) -> some View { - Button { - onFloodScopeSelected(scope) - } label: { - HStack { - Text(title) - Spacer() - if isSelected { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - .contentShape(.rect) + ChannelInfoRegionActions(onManageTapped: onManageTapped) + } + + /// When no device default is set, `.inherit` and `.allRegions` collapse into a + /// single row — writing `.inherit` is the conservative choice so a later default + /// change flows through naturally. + private var allRegionsSelection: ChannelFloodScope { + effectiveDefault != nil ? .allRegions : .inherit + } + + private func pickerRow(title: String, isSelected: Bool, scope: ChannelFloodScope) -> some View { + Button { + onFloodScopeSelected(scope) + } label: { + HStack { + Text(title) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(.tint) } - .buttonStyle(.plain) + } + .contentShape(.rect) } + .buttonStyle(.plain) + } } #Preview { - ChannelInfoSheet( - channel: ChannelDTO(from: Channel( - radioID: UUID(), - index: 1, - name: "General", - secret: Data(repeating: 0xAB, count: 16) - )), - onClearMessages: {}, - onDelete: {} - ) - .environment(\.appState, AppState()) + ChannelInfoSheet( + channel: ChannelDTO(from: Channel( + radioID: UUID(), + index: 1, + name: "General", + secret: Data(repeating: 0xAB, count: 16) + )), + onClearMessages: {}, + onDelete: {} + ) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Sheets/ChannelOptionsSheet.swift b/MC1/Views/Chats/Sheets/ChannelOptionsSheet.swift index eff83209..350bcc50 100644 --- a/MC1/Views/Chats/Sheets/ChannelOptionsSheet.swift +++ b/MC1/Views/Chats/Sheets/ChannelOptionsSheet.swift @@ -1,232 +1,234 @@ -import SwiftUI import MC1Services +import SwiftUI /// Sheet presenting channel creation and joining options struct ChannelOptionsSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let onChannelCreated: ((ChannelDTO) -> Void)? - - @State private var selectedOption: ChannelOption? - @State private var availableSlots: [UInt8] = [] - @State private var hasPublicChannel = false - @State private var isLoading = true - - init(onChannelCreated: ((ChannelDTO) -> Void)? = nil) { - self.onChannelCreated = onChannelCreated - } - - enum ChannelOption: Identifiable { - case createPrivate - case joinPrivate - case joinPublic - case joinHashtag - case scanQR - - var id: Self { self } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + let onChannelCreated: ((ChannelDTO) -> Void)? + + @State private var selectedOption: ChannelOption? + @State private var availableSlots: [UInt8] = [] + @State private var hasPublicChannel = false + @State private var isLoading = true + + init(onChannelCreated: ((ChannelDTO) -> Void)? = nil) { + self.onChannelCreated = onChannelCreated + } + + enum ChannelOption: Identifiable { + case createPrivate + case joinPrivate + case joinPublic + case joinHashtag + case scanQR + + var id: Self { + self } - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView(L10n.Chats.Chats.ChannelOptions.loading) - } else { - optionsList - } - } - .navigationTitle(L10n.Chats.Chats.ChannelOptions.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - dismiss() - } - } - } - .task { - await loadChannelState() - } - .navigationDestination(item: $selectedOption) { option in - switch option { - case .createPrivate: - CreatePrivateChannelView(availableSlots: availableSlots) { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - case .joinPrivate: - JoinPrivateChannelView(availableSlots: availableSlots) { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - case .joinPublic: - JoinPublicChannelView { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - case .joinHashtag: - JoinHashtagChannelView(availableSlots: availableSlots) { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - case .scanQR: - ScanChannelQRView(availableSlots: availableSlots) { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - } - } + } + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView(L10n.Chats.Chats.ChannelOptions.loading) + } else { + optionsList } - } - - private var optionsList: some View { - List { - Section { - // Create Private Channel - Button { - selectedOption = .createPrivate - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.CreatePrivate.title, - description: L10n.Chats.Chats.ChannelOptions.CreatePrivate.description, - icon: "lock.fill", - iconColor: .blue - ) - } - .buttonStyle(.plain) - .disabled(availableSlots.isEmpty) - - // Join Private Channel - Button { - selectedOption = .joinPrivate - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.JoinPrivate.title, - description: L10n.Chats.Chats.ChannelOptions.JoinPrivate.description, - icon: "key.fill", - iconColor: .orange - ) - } - .buttonStyle(.plain) - .disabled(availableSlots.isEmpty) - - // Scan QR Code - Button { - selectedOption = .scanQR - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.ScanQR.title, - description: L10n.Chats.Chats.ChannelOptions.ScanQR.description, - icon: "qrcode.viewfinder", - iconColor: .purple - ) - } - .buttonStyle(.plain) - .disabled(availableSlots.isEmpty) - } header: { - Text(L10n.Chats.Chats.ChannelOptions.Section.`private`) - } - .themedRowBackground(theme) - - Section { - // Join Public Channel - Button { - selectedOption = .joinPublic - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.JoinPublic.title, - description: L10n.Chats.Chats.ChannelOptions.JoinPublic.description, - icon: "globe", - iconColor: .green - ) - } - .buttonStyle(.plain) - .disabled(hasPublicChannel) - - // Join Hashtag Channel - Button { - selectedOption = .joinHashtag - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.JoinHashtag.title, - description: L10n.Chats.Chats.ChannelOptions.JoinHashtag.description, - icon: "number", - iconColor: .cyan - ) - } - .buttonStyle(.plain) - .disabled(availableSlots.isEmpty) - } header: { - Text(L10n.Chats.Chats.ChannelOptions.Section.public) - } footer: { - if availableSlots.isEmpty { - Text(L10n.Chats.Chats.ChannelOptions.Footer.noSlots) - } else if hasPublicChannel { - Text(L10n.Chats.Chats.ChannelOptions.Footer.hasPublic) - } - } - .themedRowBackground(theme) + } + .navigationTitle(L10n.Chats.Chats.ChannelOptions.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + dismiss() + } + } + } + .task { + await loadChannelState() + } + .navigationDestination(item: $selectedOption) { option in + switch option { + case .createPrivate: + CreatePrivateChannelView(availableSlots: availableSlots) { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } + case .joinPrivate: + JoinPrivateChannelView(availableSlots: availableSlots) { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } + case .joinPublic: + JoinPublicChannelView { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } + case .joinHashtag: + JoinHashtagChannelView(availableSlots: availableSlots) { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } + case .scanQR: + ScanChannelQRView(availableSlots: availableSlots) { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } } - .themedCanvas(theme) + } } - - private func loadChannelState() async { - guard let radioID = appState.connectedDevice?.radioID else { - isLoading = false - return + } + + private var optionsList: some View { + List { + Section { + // Create Private Channel + Button { + selectedOption = .createPrivate + } label: { + ChannelOptionRow( + title: L10n.Chats.Chats.ChannelOptions.CreatePrivate.title, + description: L10n.Chats.Chats.ChannelOptions.CreatePrivate.description, + icon: "lock.fill", + iconColor: .blue + ) } - - do { - let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] - let usedSlots = Set(existingChannels.map(\.index)) - - // Check if public channel exists - hasPublicChannel = usedSlots.contains(0) - - // Slots 1 through (maxChannels-1) are available for user channels - // Slot 0 is reserved for public channel - let maxChannels = appState.connectedDevice?.maxChannels ?? 0 - if maxChannels > 1 { - availableSlots = (1.. 1 { + availableSlots = (1.. Void - let onClearDirectMessages: () async -> Void - let onDeleteChannel: () -> Void + let conversationType: ChatConversationType + let chatViewModel: ChatViewModel + let onClearChannelMessages: () async -> Void + let onClearDirectMessages: () async -> Void + let onDeleteChannel: () -> Void - var body: some View { - switch conversationType { - case .dm(let contact): - NavigationStack { - ContactDetailView( - contact: contact, - showFromDirectChat: true, - onClearMessages: { Task { await onClearDirectMessages() } } - ) - } + var body: some View { + switch conversationType { + case let .dm(contact): + NavigationStack { + ContactDetailView( + contact: contact, + showFromDirectChat: true, + onClearMessages: { Task { await onClearDirectMessages() } } + ) + } - case .channel(let channel): - ChannelInfoSheet( - channel: channel, - onClearMessages: { - Task { await onClearChannelMessages() } - }, - onDelete: { - onDeleteChannel() - } - ) - .environment(\.chatViewModel, chatViewModel) + case let .channel(channel): + ChannelInfoSheet( + channel: channel, + onClearMessages: { + Task { await onClearChannelMessages() } + }, + onDelete: { + onDeleteChannel() } + ) + .environment(\.chatViewModel, chatViewModel) } + } } diff --git a/MC1/Views/Chats/Sheets/ChatsConversationSheets.swift b/MC1/Views/Chats/Sheets/ChatsConversationSheets.swift index 78f3a490..0c38bba6 100644 --- a/MC1/Views/Chats/Sheets/ChatsConversationSheets.swift +++ b/MC1/Views/Chats/Sheets/ChatsConversationSheets.swift @@ -1,123 +1,123 @@ -import SwiftUI import MC1Services +import SwiftUI /// The deep-link sheets, conversation sheets, and destructive-action alerts shared by the compact /// `ChatsView` (stack) and the iPad `ChatsContentColumn` (split). Both attach an identical surface; /// only the navigation glue (`navigate` and the delete handlers) differs between /// the stack and split paths, so those are injected as closures. struct ChatsConversationSheets: ViewModifier { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel + let viewModel: ChatViewModel - @Binding var showingNewChat: Bool - @Binding var showingChannelOptions: Bool - @Binding var roomToAuthenticate: RemoteNodeSessionDTO? - @Binding var roomToDelete: RemoteNodeSessionDTO? - @Binding var showRoomDeleteAlert: Bool - @Binding var channelDeleteFailure: ChatConversationActions.Failure? - @Binding var showChannelDeleteFailed: Bool - @Binding var pendingChatContact: ContactDTO? - @Binding var pendingChannel: ChannelDTO? + @Binding var showingNewChat: Bool + @Binding var showingChannelOptions: Bool + @Binding var roomToAuthenticate: RemoteNodeSessionDTO? + @Binding var roomToDelete: RemoteNodeSessionDTO? + @Binding var showRoomDeleteAlert: Bool + @Binding var channelDeleteFailure: ChatConversationActions.Failure? + @Binding var showChannelDeleteFailed: Bool + @Binding var pendingChatContact: ContactDTO? + @Binding var pendingChannel: ChannelDTO? - let navigate: (ChatRoute) -> Void - let deleteChannelConversation: (ChannelDTO) -> Void - let deleteRoom: (RemoteNodeSessionDTO) async -> Void + let navigate: (ChatRoute) -> Void + let deleteChannelConversation: (ChannelDTO) -> Void + let deleteRoom: (RemoteNodeSessionDTO) async -> Void - func body(content: Content) -> some View { - content - .environment(\.openURL, OpenURLAction { url in - ChatLinkRouter.route(url, appState: appState) ? .handled : .systemAction - }) - .sheet(item: Binding( - get: { appState.navigation.pendingHashtag }, - set: { appState.navigation.pendingHashtag = $0 } - )) { request in - JoinHashtagFromMessageView(channelName: request.id) { channel in - appState.navigation.clearPendingHashtag() - if let channel { - navigate(.channel(channel)) - } - } - .presentationDetents([.medium]) - } - .sheet(item: Binding( - get: { appState.navigation.pendingContactLink }, - set: { appState.navigation.pendingContactLink = $0 } - )) { result in - AddContactConfirmationSheet(contactResult: result) { addedContact in - appState.navigation.clearPendingContactLink() - if let addedContact { - appState.navigation.navigateToContactDetail(addedContact) - } - } - .presentationDetents([.medium, .large]) - } - .sheet(item: Binding( - get: { appState.navigation.pendingChannelLink }, - set: { appState.navigation.pendingChannelLink = $0 } - )) { result in - JoinChannelConfirmationSheet(channelResult: result) { newChannel in - appState.navigation.clearPendingChannelLink() - if let newChannel { - navigate(.channel(newChannel)) - } - } - .presentationDetents([.medium, .large]) - } - .sheet(isPresented: $showingNewChat, onDismiss: { - if let contact = pendingChatContact { - pendingChatContact = nil - navigate(.direct(contact)) - } - }) { - NewChatView { contact in - pendingChatContact = contact - showingNewChat = false - } - } - .sheet(isPresented: $showingChannelOptions, onDismiss: { - viewModel.requestConversationReload() - if let channel = pendingChannel { - pendingChannel = nil - navigate(.channel(channel)) - } - }) { - ChannelOptionsSheet { channel in - pendingChannel = channel - } - } - .sheet(item: $roomToAuthenticate) { session in - RoomAuthenticationSheet(session: session) { authenticatedSession in - roomToAuthenticate = nil - navigate(.room(authenticatedSession)) - } - .presentationSizing(.page) - } - .alert(L10n.Chats.Chats.Alert.LeaveRoom.title, isPresented: $showRoomDeleteAlert) { - Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { - roomToDelete = nil - } - Button(L10n.Chats.Chats.Alert.LeaveRoom.confirm, role: .destructive) { - Task { - if let session = roomToDelete { await deleteRoom(session) } - roomToDelete = nil - } - } - } message: { - Text(L10n.Chats.Chats.Alert.LeaveRoom.message) - } - .alert( - L10n.Chats.Chats.ChannelInfo.DeleteFailed.title, - isPresented: $showChannelDeleteFailed, - presenting: channelDeleteFailure - ) { failure in - Button(L10n.Localizable.Common.tryAgain) { - deleteChannelConversation(failure.channel) - } - Button(L10n.Chats.Chats.Common.ok, role: .cancel) { } - } message: { failure in - Text(failure.message) - } - } + func body(content: Content) -> some View { + content + .environment(\.openURL, OpenURLAction { url in + ChatLinkRouter.route(url, appState: appState) ? .handled : .systemAction + }) + .sheet(item: Binding( + get: { appState.navigation.pendingHashtag }, + set: { appState.navigation.pendingHashtag = $0 } + )) { request in + JoinHashtagFromMessageView(channelName: request.id) { channel in + appState.navigation.clearPendingHashtag() + if let channel { + navigate(.channel(channel)) + } + } + .presentationDetents([.medium]) + } + .sheet(item: Binding( + get: { appState.navigation.pendingContactLink }, + set: { appState.navigation.pendingContactLink = $0 } + )) { result in + AddContactConfirmationSheet(contactResult: result) { addedContact in + appState.navigation.clearPendingContactLink() + if let addedContact { + appState.navigation.navigateToContactDetail(addedContact) + } + } + .presentationDetents([.medium, .large]) + } + .sheet(item: Binding( + get: { appState.navigation.pendingChannelLink }, + set: { appState.navigation.pendingChannelLink = $0 } + )) { result in + JoinChannelConfirmationSheet(channelResult: result) { newChannel in + appState.navigation.clearPendingChannelLink() + if let newChannel { + navigate(.channel(newChannel)) + } + } + .presentationDetents([.medium, .large]) + } + .sheet(isPresented: $showingNewChat, onDismiss: { + if let contact = pendingChatContact { + pendingChatContact = nil + navigate(.direct(contact)) + } + }) { + NewChatView { contact in + pendingChatContact = contact + showingNewChat = false + } + } + .sheet(isPresented: $showingChannelOptions, onDismiss: { + viewModel.requestConversationReload() + if let channel = pendingChannel { + pendingChannel = nil + navigate(.channel(channel)) + } + }) { + ChannelOptionsSheet { channel in + pendingChannel = channel + } + } + .sheet(item: $roomToAuthenticate) { session in + RoomAuthenticationSheet(session: session) { authenticatedSession in + roomToAuthenticate = nil + navigate(.room(authenticatedSession)) + } + .presentationSizing(.page) + } + .alert(L10n.Chats.Chats.Alert.LeaveRoom.title, isPresented: $showRoomDeleteAlert) { + Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { + roomToDelete = nil + } + Button(L10n.Chats.Chats.Alert.LeaveRoom.confirm, role: .destructive) { + Task { + if let session = roomToDelete { await deleteRoom(session) } + roomToDelete = nil + } + } + } message: { + Text(L10n.Chats.Chats.Alert.LeaveRoom.message) + } + .alert( + L10n.Chats.Chats.ChannelInfo.DeleteFailed.title, + isPresented: $showChannelDeleteFailed, + presenting: channelDeleteFailure + ) { failure in + Button(L10n.Localizable.Common.tryAgain) { + deleteChannelConversation(failure.channel) + } + Button(L10n.Chats.Chats.Common.ok, role: .cancel) {} + } message: { failure in + Text(failure.message) + } + } } diff --git a/MC1/Views/Chats/Sheets/JoinChannelConfirmationSheet.swift b/MC1/Views/Chats/Sheets/JoinChannelConfirmationSheet.swift index b89cd080..2acf950d 100644 --- a/MC1/Views/Chats/Sheets/JoinChannelConfirmationSheet.swift +++ b/MC1/Views/Chats/Sheets/JoinChannelConfirmationSheet.swift @@ -1,244 +1,244 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "JoinChannelConfirmationSheet") /// Confirmation sheet shown when tapping a meshcore://channel/add link in a chat message @MainActor struct JoinChannelConfirmationSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - let channelResult: MeshCoreURLParser.ChannelResult - let onComplete: (ChannelDTO?) -> Void - - @State private var isJoining = false - @State private var isLoading = true - @State private var availableSlots: [UInt8] = [] - @State private var errorMessage: String? - @State private var successTrigger = 0 - - private var isMissingDevice: Bool { - appState.connectedDevice == nil - } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - if isLoading { - ProgressView(L10n.Chats.Chats.JoinFromMessage.loading) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if isMissingDevice { - ChannelMissingDeviceContent( - channelName: channelResult.name, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else if availableSlots.isEmpty { - ChannelNoSlotsContent( - channelName: channelResult.name, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else { - ChannelJoinConfirmationContent( - channelResult: channelResult, - errorMessage: errorMessage, - isJoining: isJoining, - onJoin: { Task { await joinChannel() } } - ) - } - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + let channelResult: MeshCoreURLParser.ChannelResult + let onComplete: (ChannelDTO?) -> Void + + @State private var isJoining = false + @State private var isLoading = true + @State private var availableSlots: [UInt8] = [] + @State private var errorMessage: String? + @State private var successTrigger = 0 + + private var isMissingDevice: Bool { + appState.connectedDevice == nil + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if isLoading { + ProgressView(L10n.Chats.Chats.JoinFromMessage.loading) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .navigationTitle(L10n.Chats.Chats.JoinFromMessage.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - onComplete(nil) - dismiss() - } - } + } else if isMissingDevice { + ChannelMissingDeviceContent( + channelName: channelResult.name, + onDismiss: { + onComplete(nil) + dismiss() } - .task { - await loadAvailableSlots() + ) + } else if availableSlots.isEmpty { + ChannelNoSlotsContent( + channelName: channelResult.name, + onDismiss: { + onComplete(nil) + dismiss() } - .sensoryFeedback(.success, trigger: successTrigger) - .sensoryFeedback(.error, trigger: errorMessage) + ) + } else { + ChannelJoinConfirmationContent( + channelResult: channelResult, + errorMessage: errorMessage, + isJoining: isJoining, + onJoin: { Task { await joinChannel() } } + ) } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .navigationTitle(L10n.Chats.Chats.JoinFromMessage.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + onComplete(nil) + dismiss() + } + } + } + .task { + await loadAvailableSlots() + } + .sensoryFeedback(.success, trigger: successTrigger) + .sensoryFeedback(.error, trigger: errorMessage) } + } - // MARK: - Private Methods + // MARK: - Private Methods - private func loadAvailableSlots() async { - guard let radioID = appState.connectedDevice?.radioID else { - isLoading = false - return - } + private func loadAvailableSlots() async { + guard let radioID = appState.connectedDevice?.radioID else { + isLoading = false + return + } - do { - let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] - let usedSlots = Set(existingChannels.map(\.index)) + do { + let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] + let usedSlots = Set(existingChannels.map(\.index)) - let maxChannels = appState.connectedDevice?.maxChannels ?? 0 - if maxChannels > 1 { - availableSlots = (1.. 1 { + availableSlots = (1.. Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.JoinFromMessage.NoDevice.title, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Chats.Chats.JoinFromMessage.NoDevice.description(channelName)) - } actions: { - Button(L10n.Chats.Chats.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let channelName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.JoinFromMessage.NoDevice.title, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Chats.Chats.JoinFromMessage.NoDevice.description(channelName)) + } actions: { + Button(L10n.Chats.Chats.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct ChannelNoSlotsContent: View { - let channelName: String - let onDismiss: () -> Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.JoinFromMessage.NoSlots.title, systemImage: "number.circle.fill") - } description: { - Text(L10n.Chats.Chats.JoinFromMessage.NoSlots.description(channelName)) - } actions: { - Button(L10n.Chats.Chats.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let channelName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.JoinFromMessage.NoSlots.title, systemImage: "number.circle.fill") + } description: { + Text(L10n.Chats.Chats.JoinFromMessage.NoSlots.description(channelName)) + } actions: { + Button(L10n.Chats.Chats.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct ChannelJoinConfirmationContent: View { - let channelResult: MeshCoreURLParser.ChannelResult - let errorMessage: String? - let isJoining: Bool - let onJoin: () -> Void - - private var truncatedSecret: String { - let hex = channelResult.secret.uppercaseHexString() - guard hex.count >= 16 else { return hex } - let start = hex.prefix(8) - let end = hex.suffix(8) - return "\(start)...\(end)" - } - - var body: some View { - VStack(spacing: 24) { - Spacer() - - VStack(spacing: 16) { - ZStack { - Circle() - .fill(.cyan) - .frame(width: 80, height: 80) - - Image(systemName: "number") - .font(.system(size: 36, weight: .bold)) - .foregroundStyle(.white) - } - - Text(channelResult.name) - .font(.title) - .bold() - - Text(truncatedSecret) - .font(.caption) - .monospaced() - .foregroundStyle(.tertiary) - } - - if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundStyle(.red) - .padding(.horizontal) - } + let channelResult: MeshCoreURLParser.ChannelResult + let errorMessage: String? + let isJoining: Bool + let onJoin: () -> Void + + private var truncatedSecret: String { + let hex = channelResult.secret.uppercaseHexString() + guard hex.count >= 16 else { return hex } + let start = hex.prefix(8) + let end = hex.suffix(8) + return "\(start)...\(end)" + } + + var body: some View { + VStack(spacing: 24) { + Spacer() + + VStack(spacing: 16) { + ZStack { + Circle() + .fill(.cyan) + .frame(width: 80, height: 80) + + Image(systemName: "number") + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(.white) + } - Button(action: onJoin) { - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinPrivate.joinButton) - } - } - .liquidGlassProminentButtonStyle() - .disabled(isJoining) - .padding(.horizontal, 48) - .padding(.bottom, 32) + Text(channelResult.name) + .font(.title) + .bold() + + Text(truncatedSecret) + .font(.caption) + .monospaced() + .foregroundStyle(.tertiary) + } + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + .padding(.horizontal) + } + + Button(action: onJoin) { + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinPrivate.joinButton) } + } + .liquidGlassProminentButtonStyle() + .disabled(isJoining) + .padding(.horizontal, 48) + .padding(.bottom, 32) } + } } #Preview { - let result = MeshCoreURLParser.ChannelResult( - name: "EmergencyOps", - secret: Data(repeating: 0xBB, count: 16) - ) - JoinChannelConfirmationSheet(channelResult: result) { _ in } - .environment(\.appState, AppState()) - .presentationDetents([.medium, .large]) + let result = MeshCoreURLParser.ChannelResult( + name: "EmergencyOps", + secret: Data(repeating: 0xBB, count: 16) + ) + JoinChannelConfirmationSheet(channelResult: result) { _ in } + .environment(\.appState, AppState()) + .presentationDetents([.medium, .large]) } diff --git a/MC1/Views/Chats/Sheets/JoinHashtagChannelView.swift b/MC1/Views/Chats/Sheets/JoinHashtagChannelView.swift index 2017daa0..6b0b26a2 100644 --- a/MC1/Views/Chats/Sheets/JoinHashtagChannelView.swift +++ b/MC1/Views/Chats/Sheets/JoinHashtagChannelView.swift @@ -1,183 +1,183 @@ -import SwiftUI import MC1Services +import SwiftUI /// View for joining a hashtag channel (public, name-based) @MainActor struct JoinHashtagChannelView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let availableSlots: [UInt8] - let onComplete: (ChannelDTO?) -> Void - - @State private var channelName = "" - @State private var selectedSlot: UInt8 - @State private var isJoining = false - @State private var errorMessage: String? - @State private var existingChannels: [ChannelDTO] = [] - - private var existingChannel: ChannelDTO? { - guard !channelName.isEmpty else { return nil } - let fullName = "#\(channelName)" - return existingChannels.first { - $0.name.localizedCaseInsensitiveCompare(fullName) == .orderedSame - } - } - - init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { - self.availableSlots = availableSlots - self.onComplete = onComplete - self._selectedSlot = State(initialValue: availableSlots.first ?? 1) - } - - private var isValidName: Bool { - HashtagUtilities.isValidHashtagName(channelName) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + let availableSlots: [UInt8] + let onComplete: (ChannelDTO?) -> Void + + @State private var channelName = "" + @State private var selectedSlot: UInt8 + @State private var isJoining = false + @State private var errorMessage: String? + @State private var existingChannels: [ChannelDTO] = [] + + private var existingChannel: ChannelDTO? { + guard !channelName.isEmpty else { return nil } + let fullName = "#\(channelName)" + return existingChannels.first { + $0.name.localizedCaseInsensitiveCompare(fullName) == .orderedSame } - - var body: some View { - Form { - Section { - HStack { - Text("#") - .font(.title2) - .foregroundStyle(.secondary) - - TextField(L10n.Chats.Chats.JoinHashtag.placeholder, text: $channelName) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .onChange(of: channelName) { _, newValue in - channelName = HashtagUtilities.sanitizeHashtagNameInput(newValue) - } - } - } header: { - Text(L10n.Chats.Chats.JoinHashtag.Section.header) - } footer: { - Text(L10n.Chats.Chats.JoinHashtag.footer) - } - .themedRowBackground(theme) - - Section { - HStack { - Spacer() - VStack(spacing: 8) { - Image(systemName: "number") - .font(.system(size: 40)) - .foregroundStyle(.cyan) - - if !channelName.isEmpty { - Text("#\(channelName)") - .font(.headline) - } - - Text(L10n.Chats.Chats.JoinHashtag.encryptionDescription) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding() - Spacer() - } - } - .themedRowBackground(theme) - - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) + } + + init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { + self.availableSlots = availableSlots + self.onComplete = onComplete + _selectedSlot = State(initialValue: availableSlots.first ?? 1) + } + + private var isValidName: Bool { + HashtagUtilities.isValidHashtagName(channelName) + } + + var body: some View { + Form { + Section { + HStack { + Text("#") + .font(.title2) + .foregroundStyle(.secondary) + + TextField(L10n.Chats.Chats.JoinHashtag.placeholder, text: $channelName) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onChange(of: channelName) { _, newValue in + channelName = HashtagUtilities.sanitizeHashtagNameInput(newValue) } - - Section { - Button { - Task { - guard !isJoining else { return } - if let existing = existingChannel { - onComplete(existing) - } else { - await joinChannel() - } - } - } label: { - HStack { - Spacer() - if isJoining { - ProgressView() - } else if existingChannel != nil { - Text(L10n.Chats.Chats.JoinHashtag.goToButton(channelName)) - } else { - Text(L10n.Chats.Chats.JoinHashtag.joinButton(channelName)) - } - Spacer() - } - } - .disabled(!isValidName || (isJoining && existingChannel == nil)) - .accessibilityHint(existingChannel != nil ? L10n.Chats.Chats.JoinHashtag.existingHint : L10n.Chats.Chats.JoinHashtag.newHint) - } footer: { - if existingChannel != nil { - Label(L10n.Chats.Chats.JoinHashtag.alreadyJoined, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .accessibilityLabel(L10n.Chats.Chats.JoinHashtag.alreadyJoinedAccessibility) - .frame(maxWidth: .infinity) - } - } - .themedRowBackground(theme) } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.JoinHashtag.title) - .navigationBarTitleDisplayMode(.inline) - .task { - guard let radioID = appState.connectedDevice?.radioID, - let dataStore = appState.services?.dataStore else { return } - do { - existingChannels = try await dataStore.fetchChannels(radioID: radioID) - } catch { - // Fail open - allow creation if fetch fails + } header: { + Text(L10n.Chats.Chats.JoinHashtag.Section.header) + } footer: { + Text(L10n.Chats.Chats.JoinHashtag.footer) + } + .themedRowBackground(theme) + + Section { + HStack { + Spacer() + VStack(spacing: 8) { + Image(systemName: "number") + .font(.system(size: 40)) + .foregroundStyle(.cyan) + + if !channelName.isEmpty { + Text("#\(channelName)") + .font(.headline) } - } - } - private func joinChannel() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return + Text(L10n.Chats.Chats.JoinHashtag.encryptionDescription) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + Spacer() } + } + .themedRowBackground(theme) - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) } - - isJoining = true - errorMessage = nil - - do { - // For hashtag channels, hash the full name including "#" prefix - // to match meshcore spec: sha256("#channelname")[0:16] - try await channelService.setChannel( - radioID: radioID, - index: selectedSlot, - name: "#\(channelName)", - passphrase: "#\(channelName)" - ) - - // Fetch the joined channel to return it - var joinedChannel: ChannelDTO? - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - joinedChannel = channels.first { $0.index == selectedSlot } + .themedRowBackground(theme) + } + + Section { + Button { + Task { + guard !isJoining else { return } + if let existing = existingChannel { + onComplete(existing) + } else { + await joinChannel() + } + } + } label: { + HStack { + Spacer() + if isJoining { + ProgressView() + } else if existingChannel != nil { + Text(L10n.Chats.Chats.JoinHashtag.goToButton(channelName)) + } else { + Text(L10n.Chats.Chats.JoinHashtag.joinButton(channelName)) } - onComplete(joinedChannel) - } catch { - errorMessage = error.userFacingMessage + Spacer() + } } + .disabled(!isValidName || (isJoining && existingChannel == nil)) + .accessibilityHint(existingChannel != nil ? L10n.Chats.Chats.JoinHashtag.existingHint : L10n.Chats.Chats.JoinHashtag.newHint) + } footer: { + if existingChannel != nil { + Label(L10n.Chats.Chats.JoinHashtag.alreadyJoined, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityLabel(L10n.Chats.Chats.JoinHashtag.alreadyJoinedAccessibility) + .frame(maxWidth: .infinity) + } + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.JoinHashtag.title) + .navigationBarTitleDisplayMode(.inline) + .task { + guard let radioID = appState.connectedDevice?.radioID, + let dataStore = appState.services?.dataStore else { return } + do { + existingChannels = try await dataStore.fetchChannels(radioID: radioID) + } catch { + // Fail open - allow creation if fetch fails + } + } + } - isJoining = false + private func joinChannel() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return } + + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + + isJoining = true + errorMessage = nil + + do { + // For hashtag channels, hash the full name including "#" prefix + // to match meshcore spec: sha256("#channelname")[0:16] + try await channelService.setChannel( + radioID: radioID, + index: selectedSlot, + name: "#\(channelName)", + passphrase: "#\(channelName)" + ) + + // Fetch the joined channel to return it + var joinedChannel: ChannelDTO? + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + joinedChannel = channels.first { $0.index == selectedSlot } + } + onComplete(joinedChannel) + } catch { + errorMessage = error.userFacingMessage + } + + isJoining = false + } } #Preview { - NavigationStack { - JoinHashtagChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + JoinHashtagChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Sheets/JoinHashtagFromMessageView.swift b/MC1/Views/Chats/Sheets/JoinHashtagFromMessageView.swift index 33362541..20315303 100644 --- a/MC1/Views/Chats/Sheets/JoinHashtagFromMessageView.swift +++ b/MC1/Views/Chats/Sheets/JoinHashtagFromMessageView.swift @@ -1,255 +1,255 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "JoinHashtagFromMessageView") /// Sheet view for joining a hashtag channel tapped in a message @MainActor struct JoinHashtagFromMessageView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - let channelName: String - let onComplete: (ChannelDTO?) -> Void - - @State private var availableSlots: [UInt8] = [] - @State private var isJoining = false - @State private var isLoading = true - @State private var isMissingDevice = false - @State private var errorMessage: String? - @State private var successTrigger = 0 - - private var normalizedName: String { - HashtagUtilities.normalizeHashtagName(channelName) - } - - private var fullChannelName: String { - "#\(normalizedName)" - } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - if isLoading { - HashtagLoadingContent() - } else if isMissingDevice { - HashtagMissingDeviceContent( - fullChannelName: fullChannelName, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else if availableSlots.isEmpty { - HashtagNoSlotsContent( - fullChannelName: fullChannelName, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else { - HashtagJoinConfirmationContent( - fullChannelName: fullChannelName, - errorMessage: errorMessage, - isJoining: isJoining, - onJoin: { Task { await joinChannel() } } - ) - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + let channelName: String + let onComplete: (ChannelDTO?) -> Void + + @State private var availableSlots: [UInt8] = [] + @State private var isJoining = false + @State private var isLoading = true + @State private var isMissingDevice = false + @State private var errorMessage: String? + @State private var successTrigger = 0 + + private var normalizedName: String { + HashtagUtilities.normalizeHashtagName(channelName) + } + + private var fullChannelName: String { + "#\(normalizedName)" + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if isLoading { + HashtagLoadingContent() + } else if isMissingDevice { + HashtagMissingDeviceContent( + fullChannelName: fullChannelName, + onDismiss: { + onComplete(nil) + dismiss() } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .navigationTitle(L10n.Chats.Chats.JoinFromMessage.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - onComplete(nil) - dismiss() - } - } + ) + } else if availableSlots.isEmpty { + HashtagNoSlotsContent( + fullChannelName: fullChannelName, + onDismiss: { + onComplete(nil) + dismiss() } - .task { - await loadAvailableSlots() - } - .sensoryFeedback(.success, trigger: successTrigger) + ) + } else { + HashtagJoinConfirmationContent( + fullChannelName: fullChannelName, + errorMessage: errorMessage, + isJoining: isJoining, + onJoin: { Task { await joinChannel() } } + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .navigationTitle(L10n.Chats.Chats.JoinFromMessage.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + onComplete(nil) + dismiss() + } } + } + .task { + await loadAvailableSlots() + } + .sensoryFeedback(.success, trigger: successTrigger) } + } - // MARK: - Private Methods + // MARK: - Private Methods - private func loadAvailableSlots() async { - guard let radioID = appState.connectedDevice?.radioID else { - isMissingDevice = true - isLoading = false - return - } + private func loadAvailableSlots() async { + guard let radioID = appState.connectedDevice?.radioID else { + isMissingDevice = true + isLoading = false + return + } - isMissingDevice = false + isMissingDevice = false - do { - let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] - let usedSlots = Set(existingChannels.map(\.index)) + do { + let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] + let usedSlots = Set(existingChannels.map(\.index)) - let maxChannels = appState.connectedDevice?.maxChannels ?? 0 - if maxChannels > 1 { - availableSlots = (1.. 1 { + availableSlots = (1.. Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.JoinFromMessage.NoDevice.title, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Chats.Chats.JoinFromMessage.NoDevice.description(fullChannelName)) - } actions: { - Button(L10n.Chats.Chats.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let fullChannelName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.JoinFromMessage.NoDevice.title, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Chats.Chats.JoinFromMessage.NoDevice.description(fullChannelName)) + } actions: { + Button(L10n.Chats.Chats.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct HashtagNoSlotsContent: View { - let fullChannelName: String - let onDismiss: () -> Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.JoinFromMessage.NoSlots.title, systemImage: "number.circle.fill") - } description: { - Text(L10n.Chats.Chats.JoinFromMessage.NoSlots.description(fullChannelName)) - } actions: { - Button(L10n.Chats.Chats.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let fullChannelName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.JoinFromMessage.NoSlots.title, systemImage: "number.circle.fill") + } description: { + Text(L10n.Chats.Chats.JoinFromMessage.NoSlots.description(fullChannelName)) + } actions: { + Button(L10n.Chats.Chats.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct HashtagJoinConfirmationContent: View { - let fullChannelName: String - let errorMessage: String? - let isJoining: Bool - let onJoin: () -> Void - - var body: some View { - VStack(spacing: 24) { - Spacer() - - VStack(spacing: 16) { - ZStack { - Circle() - .fill(.cyan) - .frame(width: 80, height: 80) - - Image(systemName: "number") - .font(.system(size: 36, weight: .bold)) - .foregroundStyle(.white) - } - - Text(fullChannelName) - .font(.title) - .bold() - - Text(L10n.Chats.Chats.JoinFromMessage.description) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - - Spacer() - - if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundStyle(.red) - .padding(.horizontal) - } + let fullChannelName: String + let errorMessage: String? + let isJoining: Bool + let onJoin: () -> Void + + var body: some View { + VStack(spacing: 24) { + Spacer() + + VStack(spacing: 16) { + ZStack { + Circle() + .fill(.cyan) + .frame(width: 80, height: 80) + + Image(systemName: "number") + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(.white) + } - Button(action: onJoin) { - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinFromMessage.joinButton(fullChannelName)) - } - } - .liquidGlassProminentButtonStyle() - .disabled(isJoining) - .padding(.horizontal, 48) - .padding(.bottom, 32) + Text(fullChannelName) + .font(.title) + .bold() + + Text(L10n.Chats.Chats.JoinFromMessage.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + .padding(.horizontal) + } + + Button(action: onJoin) { + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinFromMessage.joinButton(fullChannelName)) } + } + .liquidGlassProminentButtonStyle() + .disabled(isJoining) + .padding(.horizontal, 48) + .padding(.bottom, 32) } + } } #Preview { - JoinHashtagFromMessageView(channelName: "#general") { _ in } - .environment(\.appState, AppState()) - .presentationDetents([.medium]) + JoinHashtagFromMessageView(channelName: "#general") { _ in } + .environment(\.appState, AppState()) + .presentationDetents([.medium]) } diff --git a/MC1/Views/Chats/Sheets/JoinPrivateChannelView.swift b/MC1/Views/Chats/Sheets/JoinPrivateChannelView.swift index f20917a9..5e2ca07d 100644 --- a/MC1/Views/Chats/Sheets/JoinPrivateChannelView.swift +++ b/MC1/Views/Chats/Sheets/JoinPrivateChannelView.swift @@ -1,137 +1,137 @@ -import SwiftUI import MC1Services +import SwiftUI /// View for joining a private channel by entering name and hex secret key struct JoinPrivateChannelView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - let availableSlots: [UInt8] - let onComplete: (ChannelDTO?) -> Void + let availableSlots: [UInt8] + let onComplete: (ChannelDTO?) -> Void - @State private var channelName = "" - @State private var secretKeyHex = "" - @State private var selectedSlot: UInt8 - @State private var isJoining = false - @State private var errorMessage: String? + @State private var channelName = "" + @State private var secretKeyHex = "" + @State private var selectedSlot: UInt8 + @State private var isJoining = false + @State private var errorMessage: String? - init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { - self.availableSlots = availableSlots - self.onComplete = onComplete - self._selectedSlot = State(initialValue: availableSlots.first ?? 1) - } - - private var isValidSecret: Bool { - let cleaned = secretKeyHex.replacing(" ", with: "").uppercased() - return cleaned.count == ProtocolLimits.channelSecretSize * 2 && cleaned.allSatisfy { $0.isHexDigit } - } + init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { + self.availableSlots = availableSlots + self.onComplete = onComplete + _selectedSlot = State(initialValue: availableSlots.first ?? 1) + } - var body: some View { - Form { - Section { - TextField(L10n.Chats.Chats.CreatePrivate.channelName, text: $channelName) - .textContentType(.name) - .onChange(of: channelName) { _, newValue in - if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { - channelName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - } - } + private var isValidSecret: Bool { + let cleaned = secretKeyHex.replacing(" ", with: "").uppercased() + return cleaned.count == ProtocolLimits.channelSecretSize * 2 && cleaned.allSatisfy(\.isHexDigit) + } - TextField(L10n.Chats.Chats.JoinPrivate.secretKeyPlaceholder, text: $secretKeyHex) - .textContentType(.password) - .font(.system(.body, design: .monospaced)) - .textInputAutocapitalization(.characters) - .onChange(of: secretKeyHex) { _, newValue in - // Clean and format as user types - secretKeyHex = newValue.uppercased().filter { $0.isHexDigit } - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.details) - } footer: { - if !secretKeyHex.isEmpty && !isValidSecret { - Text(L10n.Chats.Chats.JoinPrivate.Error.invalidSecret) - .foregroundStyle(.red) - } else { - Text(L10n.Chats.Chats.JoinPrivate.footer) - } + var body: some View { + Form { + Section { + TextField(L10n.Chats.Chats.CreatePrivate.channelName, text: $channelName) + .textContentType(.name) + .onChange(of: channelName) { _, newValue in + if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { + channelName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) } - .themedRowBackground(theme) + } - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } + TextField(L10n.Chats.Chats.JoinPrivate.secretKeyPlaceholder, text: $secretKeyHex) + .textContentType(.password) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.characters) + .onChange(of: secretKeyHex) { _, newValue in + // Clean and format as user types + secretKeyHex = newValue.uppercased().filter(\.isHexDigit) + } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.details) + } footer: { + if !secretKeyHex.isEmpty, !isValidSecret { + Text(L10n.Chats.Chats.JoinPrivate.Error.invalidSecret) + .foregroundStyle(.red) + } else { + Text(L10n.Chats.Chats.JoinPrivate.footer) + } + } + .themedRowBackground(theme) + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } - Section { - Button { - Task { - await joinChannel() - } - } label: { - HStack { - Spacer() - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinPrivate.joinButton) - } - Spacer() - } - } - .disabled(channelName.isEmpty || !isValidSecret || isJoining) + Section { + Button { + Task { + await joinChannel() + } + } label: { + HStack { + Spacer() + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinPrivate.joinButton) } - .themedRowBackground(theme) + Spacer() + } } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.JoinPrivate.title) - .navigationBarTitleDisplayMode(.inline) + .disabled(channelName.isEmpty || !isValidSecret || isJoining) + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.JoinPrivate.title) + .navigationBarTitleDisplayMode(.inline) + } - private func joinChannel() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } + private func joinChannel() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return + } - guard let secret = Data(hexString: secretKeyHex) else { - errorMessage = L10n.Chats.Chats.JoinPrivate.Error.invalidFormat - return - } + guard let secret = Data(hexString: secretKeyHex) else { + errorMessage = L10n.Chats.Chats.JoinPrivate.Error.invalidFormat + return + } - isJoining = true - defer { isJoining = false } - errorMessage = nil + isJoining = true + defer { isJoining = false } + errorMessage = nil - do { - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } - try await channelService.setChannelWithSecret( - radioID: radioID, - index: selectedSlot, - name: channelName, - secret: secret - ) + do { + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + try await channelService.setChannelWithSecret( + radioID: radioID, + index: selectedSlot, + name: channelName, + secret: secret + ) - // Fetch the joined channel to return it - var joinedChannel: ChannelDTO? - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - joinedChannel = channels.first { $0.index == selectedSlot } - } - onComplete(joinedChannel) - } catch { - errorMessage = error.userFacingMessage - } + // Fetch the joined channel to return it + var joinedChannel: ChannelDTO? + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + joinedChannel = channels.first { $0.index == selectedSlot } + } + onComplete(joinedChannel) + } catch { + errorMessage = error.userFacingMessage } + } } #Preview { - NavigationStack { - JoinPrivateChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + JoinPrivateChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Sheets/JoinPublicChannelView.swift b/MC1/Views/Chats/Sheets/JoinPublicChannelView.swift index 5fe502f9..4279bd26 100644 --- a/MC1/Views/Chats/Sheets/JoinPublicChannelView.swift +++ b/MC1/Views/Chats/Sheets/JoinPublicChannelView.swift @@ -1,107 +1,107 @@ -import SwiftUI import MC1Services +import SwiftUI /// View for re-adding the public channel on slot 0 struct JoinPublicChannelView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - let onComplete: (ChannelDTO?) -> Void + let onComplete: (ChannelDTO?) -> Void - @State private var isJoining = false - @State private var errorMessage: String? + @State private var isJoining = false + @State private var errorMessage: String? - var body: some View { - Form { - Section { - HStack { - Spacer() - VStack(spacing: 16) { - Image(systemName: "globe") - .font(.system(size: 60)) - .foregroundStyle(.green) + var body: some View { + Form { + Section { + HStack { + Spacer() + VStack(spacing: 16) { + Image(systemName: "globe") + .font(.system(size: 60)) + .foregroundStyle(.green) - Text(L10n.Chats.Chats.JoinPublic.channelName) - .font(.title2) - .bold() + Text(L10n.Chats.Chats.JoinPublic.channelName) + .font(.title2) + .bold() - Text(L10n.Chats.Chats.JoinPublic.description) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding() - Spacer() - } - } - .themedRowBackground(theme) + Text(L10n.Chats.Chats.JoinPublic.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + Spacer() + } + } + .themedRowBackground(theme) - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } - Section { - Button { - Task { - await joinPublicChannel() - } - } label: { - HStack { - Spacer() - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinPublic.addButton) - } - Spacer() - } - } - .disabled(isJoining) + Section { + Button { + Task { + await joinPublicChannel() + } + } label: { + HStack { + Spacer() + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinPublic.addButton) } - .themedRowBackground(theme) + Spacer() + } } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.JoinPublic.title) - .navigationBarTitleDisplayMode(.inline) + .disabled(isJoining) + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.JoinPublic.title) + .navigationBarTitleDisplayMode(.inline) + } - private func joinPublicChannel() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } - - isJoining = true - errorMessage = nil + private func joinPublicChannel() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return + } - do { - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } - try await channelService.setupPublicChannel(radioID: radioID) + isJoining = true + errorMessage = nil - // Fetch the public channel (slot 0) to return it - var publicChannel: ChannelDTO? - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - publicChannel = channels.first { $0.index == 0 } - } - onComplete(publicChannel) - } catch { - errorMessage = error.userFacingMessage - } + do { + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + try await channelService.setupPublicChannel(radioID: radioID) - isJoining = false + // Fetch the public channel (slot 0) to return it + var publicChannel: ChannelDTO? + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + publicChannel = channels.first { $0.index == 0 } + } + onComplete(publicChannel) + } catch { + errorMessage = error.userFacingMessage } + + isJoining = false + } } #Preview { - NavigationStack { - JoinPublicChannelView(onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + JoinPublicChannelView(onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Sheets/SendDMSheet.swift b/MC1/Views/Chats/Sheets/SendDMSheet.swift index 751c8f14..9c75cc68 100644 --- a/MC1/Views/Chats/Sheets/SendDMSheet.swift +++ b/MC1/Views/Chats/Sheets/SendDMSheet.swift @@ -1,5 +1,5 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "SendDMSheet") @@ -7,90 +7,98 @@ private let logger = Logger(subsystem: "com.mc1", category: "SendDMSheet") /// Picker sheet for starting a DM with a channel sender. /// Resolves the sender's display name to matching Contacts (case-insensitive) and lets the user pick one. struct SendDMSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss - let senderName: String - let radioID: UUID - let onSelect: (ContactDTO) -> Void + let senderName: String + let radioID: UUID + let unverifiedNickname: String? + let onSelect: (ContactDTO) -> Void - @State private var matchingContacts: [ContactDTO] = [] - @State private var isLoading = true + @State private var matchingContacts: [ContactDTO] = [] + @State private var isLoading = true - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if matchingContacts.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.SendDM.noMatches(senderName), - systemImage: "person.crop.circle.badge.questionmark" - ) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.Chats.Chats.SendDM.limitation) - .font(.subheadline) - .foregroundStyle(.secondary) + /// Title name mirroring the bubble's unverified-nickname presentation: nickname + /// prominent with the raw sender name parenthesized. + private var titleName: String { + guard let unverifiedNickname else { return senderName } + return "\(unverifiedNickname) \(L10n.Chats.Chats.Message.Sender.unverifiedNicknameFormat(senderName))" + } - Text(L10n.Chats.Chats.SendDM.matchingContacts) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if matchingContacts.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.SendDM.noMatches(senderName), + systemImage: "person.crop.circle.badge.questionmark" + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Chats.Chats.SendDM.limitation) + .font(.subheadline) + .foregroundStyle(.secondary) - ForEach(matchingContacts) { contact in - ContactMatchRow( - contact: contact, - style: .tap, - userLocation: appState.bestAvailableLocation, - action: { - onSelect(contact) - dismiss() - } - ) - } - } - .padding() - } - } - } - .navigationTitle(L10n.Chats.Chats.SendDM.title(senderName)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.SendDM.cancel) { - dismiss() - } - } - } - .task { - await loadMatchingContacts() + Text(L10n.Chats.Chats.SendDM.matchingContacts) + .font(.subheadline) + .foregroundStyle(.secondary) + + ForEach(matchingContacts) { contact in + ContactMatchRow( + contact: contact, + style: .tap, + userLocation: appState.bestAvailableLocation, + action: { + onSelect(contact) + dismiss() + } + ) + } } + .padding() + } } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationBackground(.background) + } + .navigationTitle(L10n.Chats.Chats.SendDM.title(titleName)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.SendDM.cancel) { + dismiss() + } + } + } + .task { + await loadMatchingContacts() + } } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(.background) + } - private func loadMatchingContacts() async { - defer { isLoading = false } + private func loadMatchingContacts() async { + defer { isLoading = false } - guard let store = appState.offlineDataStore else { - logger.warning("No data store available for contact matching") - return - } + guard let store = appState.offlineDataStore else { + logger.warning("No data store available for contact matching") + return + } - do { - let allContacts = try await store.fetchContacts(radioID: radioID) - matchingContacts = SenderContactMatcher.filter( - contacts: allContacts, - senderName: senderName, - excludeBlocked: true - ) - logger.info("Found \(matchingContacts.count) matching contacts for sender '\(senderName)'") - } catch { - logger.error("Failed to fetch contacts for matching: \(error)") - } + do { + let allContacts = try await store.fetchContacts(radioID: radioID) + matchingContacts = SenderContactMatcher.filter( + contacts: allContacts, + senderName: senderName, + excludeBlocked: true + ) + logger.info("Found \(matchingContacts.count) matching contacts for sender '\(senderName)'") + } catch { + logger.error("Failed to fetch contacts for matching: \(error)") } + } } diff --git a/MC1/Views/Chats/Sheets/ShareContactPickerSheet.swift b/MC1/Views/Chats/Sheets/ShareContactPickerSheet.swift index f41e930e..e8fd2a84 100644 --- a/MC1/Views/Chats/Sheets/ShareContactPickerSheet.swift +++ b/MC1/Views/Chats/Sheets/ShareContactPickerSheet.swift @@ -1,134 +1,134 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "ShareContactPickerSheet") private enum Layout { - static let avatarSize: CGFloat = 40 - static let rowSpacing: CGFloat = 12 - static let rowVerticalSpacing: CGFloat = 2 + static let avatarSize: CGFloat = 40 + static let rowSpacing: CGFloat = 12 + static let rowVerticalSpacing: CGFloat = 2 } /// Sheet for picking a contact to share as a MeshCore contact-share token. /// Calls `onInsert` with the formatted token and dismisses on selection. struct ShareContactPickerSheet: View { - let onInsert: (String) -> Void - - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.appTheme) private var theme - - @State private var contacts: [ContactDTO] = [] - @State private var searchText = "" - @State private var isLoading = true - @State private var errorMessage: String? - - private var filteredContacts: [ContactDTO] { - guard !searchText.isEmpty else { return contacts } - return contacts.filter { contact in - contact.name.localizedCaseInsensitiveContains(searchText) || - (contact.nickname?.localizedCaseInsensitiveContains(searchText) ?? false) || - contact.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) - } + let onInsert: (String) -> Void + + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.appTheme) private var theme + + @State private var contacts: [ContactDTO] = [] + @State private var searchText = "" + @State private var isLoading = true + @State private var errorMessage: String? + + private var filteredContacts: [ContactDTO] { + guard !searchText.isEmpty else { return contacts } + return contacts.filter { contact in + contact.name.localizedCaseInsensitiveContains(searchText) || + (contact.nickname?.localizedCaseInsensitiveContains(searchText) ?? false) || + contact.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) } - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if filteredContacts.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.ContactPicker.emptyState, - systemImage: "person.2" - ) - } else { - List(filteredContacts) { contact in - Button { - let token = ContactShareUtilities.formatShare( - publicKey: contact.publicKey, - type: contact.type, - name: contact.name - ) - onInsert(token) - dismiss() - } label: { - HStack(spacing: Layout.rowSpacing) { - ContactAvatar(contact: contact, size: Layout.avatarSize) - - VStack(alignment: .leading, spacing: Layout.rowVerticalSpacing) { - (Text(idPrefixHex(for: contact)) - .monospaced() - .foregroundStyle(.secondary) - + Text(" \(contact.displayName)")) - .font(.headline) - .accessibilityLabel(contact.displayName) - - Text(contactTypeLabel(for: contact)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - } - .contentShape(.rect) - } - .buttonStyle(.plain) - } - .themedCanvas(theme) + } + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredContacts.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.ContactPicker.emptyState, + systemImage: "person.2" + ) + } else { + List(filteredContacts) { contact in + Button { + let token = ContactShareUtilities.formatShare( + publicKey: contact.publicKey, + type: contact.type, + name: contact.name + ) + onInsert(token) + dismiss() + } label: { + HStack(spacing: Layout.rowSpacing) { + ContactAvatar(contact: contact, size: Layout.avatarSize) + + VStack(alignment: .leading, spacing: Layout.rowVerticalSpacing) { + (Text(idPrefixHex(for: contact)) + .monospaced() + .foregroundStyle(.secondary) + + Text(" \(contact.displayName)")) + .font(.headline) + .accessibilityLabel(contact.displayName) + + Text(contactTypeLabel(for: contact)) + .font(.caption) + .foregroundStyle(.secondary) } - } - .navigationTitle(L10n.Chats.Chats.ContactPicker.title) - .navigationBarTitleDisplayMode(.inline) - .searchable(text: $searchText, prompt: L10n.Chats.Chats.ContactPicker.Search.placeholder) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - dismiss() - } - } - } - .task { - await loadContacts() - } - .errorAlert($errorMessage) - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationBackground(.background) - } - private func loadContacts() async { - defer { isLoading = false } - - guard let radioID = appState.currentRadioID, - let store = appState.offlineDataStore else { - logger.warning("No radio or data store available for contact picker") - return + Spacer() + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } + .themedCanvas(theme) } - - do { - contacts = try await store.fetchContacts(radioID: radioID) - .filter { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - logger.info("Loaded \(contacts.count) contacts for share picker") - } catch { - logger.error("Failed to fetch contacts for share picker: \(error)") - errorMessage = error.userFacingMessage + } + .navigationTitle(L10n.Chats.Chats.ContactPicker.title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: L10n.Chats.Chats.ContactPicker.Search.placeholder) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + dismiss() + } } + } + .task { + await loadContacts() + } + .errorAlert($errorMessage) } - - private func idPrefixHex(for contact: ContactDTO) -> String { - let hashSize = appState.connectedDevice?.hashSize ?? 1 - return contact.publicKey.prefix(hashSize).uppercaseHexString() + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(.background) + } + + private func loadContacts() async { + defer { isLoading = false } + + guard let radioID = appState.currentRadioID, + let store = appState.offlineDataStore else { + logger.warning("No radio or data store available for contact picker") + return } - private func contactTypeLabel(for contact: ContactDTO) -> String { - contact.type.localizedName + do { + contacts = try await store.fetchContacts(radioID: radioID) + .filter { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + logger.info("Loaded \(contacts.count) contacts for share picker") + } catch { + logger.error("Failed to fetch contacts for share picker: \(error)") + errorMessage = error.userFacingMessage } + } + + private func idPrefixHex(for contact: ContactDTO) -> String { + let hashSize = appState.connectedDevice?.hashSize ?? 1 + return contact.publicKey.prefix(hashSize).uppercaseHexString() + } + + private func contactTypeLabel(for contact: ContactDTO) -> String { + contact.type.localizedName + } } #Preview("Empty state") { - ShareContactPickerSheet(onInsert: { _ in }) + ShareContactPickerSheet(onInsert: { _ in }) } diff --git a/MC1/Views/Chats/UnreadBadges.swift b/MC1/Views/Chats/UnreadBadges.swift index fe0668d4..cfd0062d 100644 --- a/MC1/Views/Chats/UnreadBadges.swift +++ b/MC1/Views/Chats/UnreadBadges.swift @@ -2,37 +2,37 @@ import MC1Services import SwiftUI struct UnreadBadges: View { - let unreadCount: Int - var unreadMentionCount: Int = 0 - var notificationLevel: NotificationLevel = .all + let unreadCount: Int + var unreadMentionCount: Int = 0 + var notificationLevel: NotificationLevel = .all - private var mentionBadgeColor: Color { - notificationLevel == .muted ? .secondary : .blue - } + private var mentionBadgeColor: Color { + notificationLevel == .muted ? .secondary : .blue + } - private var unreadBadgeColor: Color { - notificationLevel == .all ? .blue : .secondary - } + private var unreadBadgeColor: Color { + notificationLevel == .all ? .blue : .secondary + } - var body: some View { - HStack(spacing: 4) { - if unreadMentionCount > 0 { - Text("@") - .font(.caption.bold()) - .foregroundStyle(.white) - .frame(width: 18, height: 18) - .background(mentionBadgeColor, in: .circle) - } + var body: some View { + HStack(spacing: 4) { + if unreadMentionCount > 0 { + Text("@") + .font(.caption.bold()) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(mentionBadgeColor, in: .circle) + } - if unreadCount > 0 { - Text(unreadCount, format: .number) - .font(.caption2) - .bold() - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(unreadBadgeColor, in: .capsule) - } - } + if unreadCount > 0 { + Text(unreadCount, format: .number) + .font(.caption2) + .bold() + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(unreadBadgeColor, in: .capsule) + } } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift index f977abf3..a0394a01 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift @@ -2,316 +2,315 @@ import Foundation import MC1Services extension ChatViewModel { + // MARK: - Channel Messages + + /// Load messages for a channel + func loadChannelMessages(for channel: ChannelDTO) async { + // Close the per-conversation empty-state gate while the fetch is + // in flight. No-op when the coordinator is already past + // `.uninitialized` (warm rebind, refresh). + coordinator?.beginLoading() + + guard let dataStore else { + coordinator?.markLoaded() + return + } - // MARK: - Channel Messages - - /// Load messages for a channel - func loadChannelMessages(for channel: ChannelDTO) async { - // Close the per-conversation empty-state gate while the fetch is - // in flight. No-op when the coordinator is already past - // `.uninitialized` (warm rebind, refresh). - coordinator?.beginLoading() - - guard let dataStore else { - coordinator?.markLoaded() - return - } + // Clear preview state only when switching to a different conversation + if currentChannel?.id != channel.id { + clearPreviewState() + newMessagesDividerMessageID = nil + dividerComputed = false + lastSetRegionScope = .unknown + } - // Clear preview state only when switching to a different conversation - if currentChannel?.id != channel.id { - clearPreviewState() - newMessagesDividerMessageID = nil - dividerComputed = false - lastSetRegionScope = .unknown + currentChannel = channel + currentContact = nil + + // Track active channel for notification suppression + notificationService?.setActiveConversation( + channelIndex: channel.index, + channelRadioID: channel.radioID + ) + + // Sync the device's session-scoped flood key with the effective scope for this + // channel. The effective scope combines the per-channel preference with the + // device-level default — `.inherit` means "fall through to the default". + let deviceDefault = connectedDeviceProvider()?.defaultFloodScopeName + let desiredState: ChatViewModel.RegionScopeState = .pushed( + channel.floodScope, + deviceDefault: deviceDefault + ) + if lastSetRegionScope != desiredState, let session = sessionProvider() { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: channel.floodScope, + deviceDefaultFloodScopeName: deviceDefault, + supportsUnscopedFloodSend: connectedDeviceProvider()?.supportsUnscopedFloodSend ?? false + ) + do { + switch resolved { + case .unscoped: + try await session.setFloodScopeUnscoped() + case let .scope(scope): + try await session.setFloodScope(scope) } + lastSetRegionScope = desiredState + } catch is CancellationError { + // Benign: a superseding load (reconnect / conversation switch) cancelled this one. + } catch { + logger.error("Failed to set flood scope: \(error.localizedDescription)") + } + } - currentChannel = channel - currentContact = nil - - // Track active channel for notification suppression - notificationService?.setActiveConversation( - channelIndex: channel.index, - channelRadioID: channel.radioID - ) + isLoading = true + // Dual-reset: this function is shared between passive load and user-initiated + // retry paths, so both surfaces must clear at entry to avoid stale state. + errorMessage = nil + errorBannerMessage = nil - // Sync the device's session-scoped flood key with the effective scope for this - // channel. The effective scope combines the per-channel preference with the - // device-level default — `.inherit` means "fall through to the default". - let deviceDefault = connectedDeviceProvider()?.defaultFloodScopeName - let desiredState: ChatViewModel.RegionScopeState = .pushed( - channel.floodScope, - deviceDefault: deviceDefault - ) - if lastSetRegionScope != desiredState, let session = sessionProvider() { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: channel.floodScope, - deviceDefaultFloodScopeName: deviceDefault, - supportsUnscopedFloodSend: connectedDeviceProvider()?.supportsUnscopedFloodSend ?? false - ) - do { - switch resolved { - case .unscoped: - try await session.setFloodScopeUnscoped() - case .scope(let scope): - try await session.setFloodScope(scope) - } - lastSetRegionScope = desiredState - } catch is CancellationError { - // Benign: a superseding load (reconnect / conversation switch) cancelled this one. - } catch { - logger.error("Failed to set flood scope: \(error.localizedDescription)") - } - } + // Reset pagination state for new conversation + coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } - isLoading = true - // Dual-reset: this function is shared between passive load and user-initiated - // retry paths, so both surfaces must clear at entry to avoid stale state. - errorMessage = nil - errorBannerMessage = nil - - // Reset pagination state for new conversation - coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } - - do { - var fetchedMessages = try await dataStore.fetchMessages(radioID: channel.radioID, channelIndex: channel.index, limit: ChatCoordinator.pageSize, offset: 0) - let unfilteredCount = fetchedMessages.count - coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } - - // Compute divider position before filtering, using unfiltered array - computeDividerPosition(from: fetchedMessages, unreadCount: channel.unreadCount, isDM: false) - - // Hide sent reaction messages (unless failed) - fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: false) - - // Use unfiltered count to determine if more messages exist - coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } - coordinator?.replaceAll(fetchedMessages) - - buildChannelSenders(radioID: channel.radioID) - buildItems() - - // Index loaded messages for reaction matching and process any pending reactions - if let reactionService = reactionServiceProvider() { - await indexMessagesForReactions( - fetchedMessages, - scope: .channel(channel, localNodeName: connectedDeviceProvider()?.nodeName), - reactionService: reactionService, - dataStore: dataStore - ) - } - - // Clear unread count and mention badge, then notify UI to refresh chat list. - // The messages already rendered, so a bookkeeping failure here is logged - // rather than surfaced as a load error. - do { - try await dataStore.clearChannelUnreadCount(channelID: channel.id) - try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) - } catch { - logger.warning("loadChannelMessages: failed to clear unread counts - \(error.localizedDescription)") - } - syncCoordinator?.notifyConversationsChanged() - - // Update app badge - await notificationService?.updateBadgeCount() - } catch is CancellationError { - // Benign cancellation; the superseding load will refetch. - } catch { - errorMessage = error.userFacingMessage - } + do { + var fetchedMessages = try await dataStore.fetchMessages(radioID: channel.radioID, channelIndex: channel.index, limit: ChatCoordinator.pageSize, offset: 0) + let unfilteredCount = fetchedMessages.count + coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } - // Ensures the empty-state gate opens even when the fetch threw — - // `replaceAll` is the success path; this catches the failure path. - coordinator?.markLoaded() - isLoading = false - } + // Compute divider position before filtering, using unfiltered array + computeDividerPosition(from: fetchedMessages, unreadCount: channel.unreadCount, isDM: false) - // MARK: - Channel Actions + // Hide sent reaction messages (unless failed) + fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: false) - /// Send a channel message optimistically — shows immediately, sends in background. - func sendChannelMessage(text: String) async { - guard let channel = currentChannel, - let messageService, - !text.isEmpty else { - return - } + // Use unfiltered count to determine if more messages exist + coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } + coordinator?.replaceAll(fetchedMessages) - errorMessage = nil - - let message: MessageDTO - do { - message = try await messageService.createPendingChannelMessage( - text: text, - channelIndex: channel.index, - radioID: channel.radioID - ) - appendMessageIfNew(message) - schedulePrefetchForOutgoingMessage(message, isChannelMessage: true) - } catch { - errorMessage = error.userFacingMessage - return - } + buildChannelSenders(radioID: channel.radioID) + buildItems() - let envelope = ChannelMessageEnvelope( - messageID: message.id, - channelIndex: channel.index, - isResend: false, - messageText: message.text, - messageTimestamp: message.timestamp, - localNodeName: connectedDeviceProvider()?.nodeName + // Index loaded messages for reaction matching and process any pending reactions + if let reactionService = reactionServiceProvider() { + await indexMessagesForReactions( + fetchedMessages, + scope: .channel(channel, localNodeName: connectedDeviceProvider()?.nodeName), + reactionService: reactionService, + dataStore: dataStore ) - do { - try await enqueueChannel(envelope) - } catch { - logger.error("enqueueChannel failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) - } + } + + // Clear unread count and mention badge, then notify UI to refresh chat list. + // The messages already rendered, so a bookkeeping failure here is logged + // rather than surfaced as a load error. + do { + try await dataStore.clearChannelUnreadCount(channelID: channel.id) + try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) + } catch { + logger.warning("loadChannelMessages: failed to clear unread counts - \(error.localizedDescription)") + } + syncCoordinator?.notifyConversationsChanged() + + // Update app badge + await notificationService?.updateBadgeCount() + } catch is CancellationError { + // Benign cancellation; the superseding load will refetch. + } catch { + errorMessage = error.userFacingMessage } - /// Retry sending a failed channel message in place. The drain stamps a - /// fresh timestamp via `resendChannelMessage` so the retry packet hashes - /// differently from the original — the mesh dedup table is a 128-slot - /// cyclic ring with no time-based eviction, so reusing the original - /// timestamp would be silently dropped at every neighbour until 127 - /// unrelated packets evict the slot. The `retryInFlight` guard prevents - /// reentrant double-tap during the synchronous status-update + reload + - /// enqueue window. Once status flips to `.pending`, the bubble's retry - /// button hides (UI gate), so a fresh tap cannot enqueue again until the - /// channel send later fails and the row returns to `.failed`. - func retryChannelMessage(_ message: MessageDTO) async { - guard messageService != nil, - currentChannel != nil, - let channelIndex = message.channelIndex, - !retryInFlight else { return } - - retryInFlight = true - defer { retryInFlight = false } - - // Stand in for the surfaces loadChannelMessages would have reset. - errorMessage = nil - errorBannerMessage = nil - - // Capture once so the three status-update writes below all target the - // same container even if a reconnect fires between the awaits. - let dataStore = self.dataStore - - // Release any prior queue ownership before enqueuing a fresh - // envelope. Channel sends never reach `.delivered` (no end-to-end - // ACK), so the `.delivered` clobber risk that motivates the DM - // guard doesn't apply here — but the queue's `hasPendingSend` gate - // and the symmetric call shape with `retryMessage` are worth the - // extra delete. Best-effort; the gate self-corrects. - try? await dataStore?.deletePendingSendsForMessage(messageID: message.id) - - // Flip the row in place for instant "Sending" feedback rather than a - // full loadChannelMessages refetch, which would also reset paging - // state. The coordinator's applyStatusUpdate guards against - // downgrading a row that resolved concurrently. Swap to the - // unless-delivered variant for shape symmetry with `retryMessage` - // and so a stray ACK landing doesn't get clobbered if a future - // refactor introduces channel-side delivery. - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .pending) - coordinator?.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) - - let envelope = ChannelMessageEnvelope( - messageID: message.id, - channelIndex: channelIndex, - isResend: true, - messageText: message.text, - messageTimestamp: message.timestamp, - localNodeName: connectedDeviceProvider()?.nodeName - ) - do { - try await enqueueChannel(envelope) - } catch { - logger.error("enqueueChannel retry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) - } - } + // Ensures the empty-state gate opens even when the fetch threw — + // `replaceAll` is the success path; this catches the failure path. + coordinator?.markLoaded() + isLoading = false + } - // MARK: - In-Place Updates + // MARK: - Channel Actions - /// Update heard repeat count for a message in place without a full reload. - func updateHeardRepeats(for messageID: UUID, count: Int) { - updateMessage(id: messageID) { $0.heardRepeats = count } + /// Send a channel message optimistically — shows immediately, sends in background. + func sendChannelMessage(text: String) async { + guard let channel = currentChannel, + let messageService, + !text.isEmpty else { + return } - // MARK: - Channel Sender Tracking - - /// Build synthetic contacts from channel message senders not in contacts. - /// Called after loading channel messages to populate mention picker. - /// Builds into local collections first to avoid multiple @Observable updates. - private func buildChannelSenders(radioID: UUID) { - var localNames: Set = [] - var localSenders: [ContactDTO] = [] - var localOrder: [String: UInt32] = [:] - - for message in messages { - if let name = message.senderNodeName { - let trimmed = name.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, trimmed.count <= 128 else { continue } + errorMessage = nil + + let message: MessageDTO + do { + message = try await messageService.createPendingChannelMessage( + text: text, + channelIndex: channel.index, + radioID: channel.radioID + ) + appendMessageIfNew(message) + schedulePrefetchForOutgoingMessage(message, isChannelMessage: true) + } catch { + errorMessage = error.userFacingMessage + return + } - // Track latest timestamp for all senders (contacts and non-contacts) - localOrder[trimmed] = max(message.timestamp, localOrder[trimmed] ?? 0) + let envelope = ChannelMessageEnvelope( + messageID: message.id, + channelIndex: channel.index, + isResend: false, + messageText: message.text, + messageTimestamp: message.timestamp, + localNodeName: connectedDeviceProvider()?.nodeName + ) + do { + try await enqueueChannel(envelope) + } catch { + logger.error("enqueueChannel failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) + coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) + } + } + + /// Retry sending a failed channel message in place. The drain stamps a + /// fresh timestamp via `resendChannelMessage` so the retry packet hashes + /// differently from the original — the mesh dedup table is a 128-slot + /// cyclic ring with no time-based eviction, so reusing the original + /// timestamp would be silently dropped at every neighbour until 127 + /// unrelated packets evict the slot. The `retryInFlight` guard prevents + /// reentrant double-tap during the synchronous status-update + reload + + /// enqueue window. Once status flips to `.pending`, the bubble's retry + /// button hides (UI gate), so a fresh tap cannot enqueue again until the + /// channel send later fails and the row returns to `.failed`. + func retryChannelMessage(_ message: MessageDTO) async { + guard messageService != nil, + currentChannel != nil, + let channelIndex = message.channelIndex, + !retryInFlight else { return } + + retryInFlight = true + defer { retryInFlight = false } + + // Stand in for the surfaces loadChannelMessages would have reset. + errorMessage = nil + errorBannerMessage = nil + + // Capture once so the three status-update writes below all target the + // same container even if a reconnect fires between the awaits. + let dataStore = dataStore + + // Release any prior queue ownership before enqueuing a fresh + // envelope. Channel sends never reach `.delivered` (no end-to-end + // ACK), so the `.delivered` clobber risk that motivates the DM + // guard doesn't apply here — but the queue's `hasPendingSend` gate + // and the symmetric call shape with `retryMessage` are worth the + // extra delete. Best-effort; the gate self-corrects. + try? await dataStore?.deletePendingSendsForMessage(messageID: message.id) + + // Flip the row in place for instant "Sending" feedback rather than a + // full loadChannelMessages refetch, which would also reset paging + // state. The coordinator's applyStatusUpdate guards against + // downgrading a row that resolved concurrently. Swap to the + // unless-delivered variant for shape symmetry with `retryMessage` + // and so a stray ACK landing doesn't get clobbered if a future + // refactor introduces channel-side delivery. + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .pending) + coordinator?.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) + + let envelope = ChannelMessageEnvelope( + messageID: message.id, + channelIndex: channelIndex, + isResend: true, + messageText: message.text, + messageTimestamp: message.timestamp, + localNodeName: connectedDeviceProvider()?.nodeName + ) + do { + try await enqueueChannel(envelope) + } catch { + logger.error("enqueueChannel retry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) + coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) + } + } - // Build synthetic contacts only for non-contact senders - guard !contactNameSet.contains(trimmed), - !localNames.contains(trimmed) else { continue } + // MARK: - In-Place Updates - localNames.insert(trimmed) - localSenders.append(makeSyntheticContact(name: trimmed, radioID: radioID)) - } - } + /// Update heard repeat count for a message in place without a full reload. + func updateHeardRepeats(for messageID: UUID, count: Int) { + updateMessage(id: messageID) { $0.heardRepeats = count } + } - // Assign once to minimize observation updates - channelSenderNames = localNames - channelSenders = localSenders - channelSenderOrder = localOrder + // MARK: - Channel Sender Tracking - logger.info("Built \(self.channelSenders.count) synthetic contacts from channel senders") - } + /// Build synthetic contacts from channel message senders not in contacts. + /// Called after loading channel messages to populate mention picker. + /// Builds into local collections first to avoid multiple @Observable updates. + private func buildChannelSenders(radioID: UUID) { + var localNames: Set = [] + var localSenders: [ContactDTO] = [] + var localOrder: [String: UInt32] = [:] - /// Register a channel sender for the mention picker. Always max-merges the - /// timestamp into `channelSenderOrder` so older messages contribute to - /// recency ranking; inserts a synthetic contact only when the sender is - /// neither a known contact nor already tracked. - func addChannelSenderIfNew(_ name: String, radioID: UUID, timestamp: UInt32) { + for message in messages { + if let name = message.senderNodeName { let trimmed = name.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, trimmed.count <= 128 else { return } + guard !trimmed.isEmpty, trimmed.count <= 128 else { continue } - channelSenderOrder[trimmed] = max(timestamp, channelSenderOrder[trimmed] ?? 0) + // Track latest timestamp for all senders (contacts and non-contacts) + localOrder[trimmed] = max(message.timestamp, localOrder[trimmed] ?? 0) + // Build synthetic contacts only for non-contact senders guard !contactNameSet.contains(trimmed), - !channelSenderNames.contains(trimmed) else { return } + !localNames.contains(trimmed) else { continue } - channelSenderNames.insert(trimmed) - channelSenders.append(makeSyntheticContact(name: trimmed, radioID: radioID)) + localNames.insert(trimmed) + localSenders.append(makeSyntheticContact(name: trimmed, radioID: radioID)) + } } - /// Create a synthetic ContactDTO for a channel sender not in contacts. - private func makeSyntheticContact(name: String, radioID: UUID) -> ContactDTO { - ContactDTO( - id: name.stableUUID, - radioID: radioID, - publicKey: Data(), - name: name, - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0.0, - longitude: 0.0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - } + // Assign once to minimize observation updates + channelSenderNames = localNames + channelSenders = localSenders + channelSenderOrder = localOrder + + logger.info("Built \(self.channelSenders.count) synthetic contacts from channel senders") + } + + /// Register a channel sender for the mention picker. Always max-merges the + /// timestamp into `channelSenderOrder` so older messages contribute to + /// recency ranking; inserts a synthetic contact only when the sender is + /// neither a known contact nor already tracked. + func addChannelSenderIfNew(_ name: String, radioID: UUID, timestamp: UInt32) { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, trimmed.count <= 128 else { return } + + channelSenderOrder[trimmed] = max(timestamp, channelSenderOrder[trimmed] ?? 0) + + guard !contactNameSet.contains(trimmed), + !channelSenderNames.contains(trimmed) else { return } + + channelSenderNames.insert(trimmed) + channelSenders.append(makeSyntheticContact(name: trimmed, radioID: radioID)) + } + + /// Create a synthetic ContactDTO for a channel sender not in contacts. + private func makeSyntheticContact(name: String, radioID: UUID) -> ContactDTO { + ContactDTO( + id: name.stableUUID, + radioID: radioID, + publicKey: Data(), + name: name, + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0.0, + longitude: 0.0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationCache.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationCache.swift index d3e56347..30bde11d 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationCache.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationCache.swift @@ -2,67 +2,70 @@ import Foundation import MC1Services extension ChatViewModel { + // MARK: - Combined Conversations - // MARK: - Combined Conversations + /// Combined conversations (contacts + channels + rooms) - favorites first + var allConversations: [Conversation] { + conversationSnapshot.favorites + conversationSnapshot.others + } - /// Combined conversations (contacts + channels + rooms) - favorites first - var allConversations: [Conversation] { - conversationSnapshot.favorites + conversationSnapshot.others - } + /// Favorite conversations sorted by last message date + var favoriteConversations: [Conversation] { + conversationSnapshot.favorites + } - /// Favorite conversations sorted by last message date - var favoriteConversations: [Conversation] { conversationSnapshot.favorites } + /// Non-favorite conversations sorted by last message date + var nonFavoriteConversations: [Conversation] { + conversationSnapshot.others + } - /// Non-favorite conversations sorted by last message date - var nonFavoriteConversations: [Conversation] { conversationSnapshot.others } + // MARK: - Snapshot Recompute - // MARK: - Snapshot Recompute + /// Fallback date for conversations with no messages, used to sort them to the end. + static let noMessageSentinel = Date.distantPast - /// Fallback date for conversations with no messages, used to sort them to the end. - static let noMessageSentinel = Date.distantPast + /// Recomputes the observed snapshot from the fetch buffers in one synchronous pass, + /// excluding `pendingRemovalIDs` so a just-deleted row stays hidden across a racing reload. + /// Never wraps the assignment in `withAnimation`; only `removeConversation`/`restoreConversation` + /// do, so a delete animates once while reload-driven recomputes stay unanimated. + func recomputeSnapshot() { + let contactConversations = conversations + .filter { $0.type != .repeater && !$0.isBlocked && !pendingRemovalIDs.contains($0.id) } + .map { Conversation.direct($0) } + let channelConversations = channels + .filter { (!$0.name.isEmpty || $0.hasSecret) && !pendingRemovalIDs.contains($0.id) } + .map { Conversation.channel($0) } + let roomConversations = roomSessions + .filter { !pendingRemovalIDs.contains($0.id) } + .map { Conversation.room($0) } + let all = contactConversations + channelConversations + roomConversations - /// Recomputes the observed snapshot from the fetch buffers in one synchronous pass, - /// excluding `pendingRemovalIDs` so a just-deleted row stays hidden across a racing reload. - /// Never wraps the assignment in `withAnimation`; only `removeConversation`/`restoreConversation` - /// do, so a delete animates once while reload-driven recomputes stay unanimated. - func recomputeSnapshot() { - let contactConversations = conversations - .filter { $0.type != .repeater && !$0.isBlocked && !pendingRemovalIDs.contains($0.id) } - .map { Conversation.direct($0) } - let channelConversations = channels - .filter { (!$0.name.isEmpty || $0.hasSecret) && !pendingRemovalIDs.contains($0.id) } - .map { Conversation.channel($0) } - let roomConversations = roomSessions - .filter { !pendingRemovalIDs.contains($0.id) } - .map { Conversation.room($0) } - let all = contactConversations + channelConversations + roomConversations + let newSnapshot = ConversationSnapshot( + favorites: sortedByLastMessage(all.filter(\.isFavorite)), + others: sortedByLastMessage(all.filter { !$0.isFavorite }) + ) - let newSnapshot = ConversationSnapshot( - favorites: sortedByLastMessage(all.filter { $0.isFavorite }), - others: sortedByLastMessage(all.filter { !$0.isFavorite }) - ) + // Skip republishing an identical snapshot to avoid a needless re-diff. Safe because + // pendingRemovalIDs masks a deleted row, so a stale reload recomputes this same snapshot + // rather than a row-present one that would slip past the guard. + guard newSnapshot != conversationSnapshot else { return } - // Skip republishing an identical snapshot to avoid a needless re-diff. Safe because - // pendingRemovalIDs masks a deleted row, so a stale reload recomputes this same snapshot - // rather than a row-present one that would slip past the guard. - guard newSnapshot != conversationSnapshot else { return } + conversationSnapshot = newSnapshot + snapshotGeneration &+= 1 + } - conversationSnapshot = newSnapshot - snapshotGeneration &+= 1 - } + /// Drops pending ids the fresh fetch confirms are gone. Run at the top of every + /// reload commit so a confirmed deletion self-heals and the set can't leak. + func reconcilePendingRemovals() { + guard !pendingRemovalIDs.isEmpty else { return } + let presentIDs = Set(conversations.map(\.id)) + .union(channels.map(\.id)) + .union(roomSessions.map(\.id)) + pendingRemovalIDs.formIntersection(presentIDs) + } - /// Drops pending ids the fresh fetch confirms are gone. Run at the top of every - /// reload commit so a confirmed deletion self-heals and the set can't leak. - func reconcilePendingRemovals() { - guard !pendingRemovalIDs.isEmpty else { return } - let presentIDs = Set(conversations.map(\.id)) - .union(channels.map(\.id)) - .union(roomSessions.map(\.id)) - pendingRemovalIDs.formIntersection(presentIDs) - } - - /// Sorts conversations by last message date, most recent first. - func sortedByLastMessage(_ items: [Conversation]) -> [Conversation] { - items.sorted { ($0.lastMessageDate ?? Self.noMessageSentinel) > ($1.lastMessageDate ?? Self.noMessageSentinel) } - } + /// Sorts conversations by last message date, most recent first. + func sortedByLastMessage(_ items: [Conversation]) -> [Conversation] { + items.sorted { ($0.lastMessageDate ?? Self.noMessageSentinel) > ($1.lastMessageDate ?? Self.noMessageSentinel) } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift index a74962bf..1ebed75f 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift @@ -2,169 +2,172 @@ import Foundation import MC1Services extension ChatViewModel { - - // MARK: - Display Items - - /// Optimistically append a message if not already present. Called from - /// the incoming admission path after the receive-time prefetch resolves - /// or hits its timeout, and from the outgoing send paths immediately - /// after `createPendingMessage`. Preserves unread-counter math via the - /// `newItems.count` delta in `ChatTableView.updateItems`. - func appendMessageIfNew(_ message: MessageDTO) { - guard let coordinator else { return } - let previous = messages.last - - // Synchronous append: coordinator append, render item insertion, and - // channel sender bookkeeping all mutate Observable state on the main - // actor in one call frame, so SwiftUI already invalidates dependent - // views once per change cycle without an explicit transaction. - guard coordinator.append(message) else { return } - let newItem = makeItem(for: message, previous: previous) - coordinator.appendRenderItem(newItem) - if let senderName = message.senderNodeName, - let radioID = currentChannel?.radioID { - addChannelSenderIfNew(senderName, radioID: radioID, timestamp: message.timestamp) - } - - // URL detection writes `cachedURLs[messageID]` from a background task - // and lands as its own invalidation cycle. - let messageID = message.id - let text = message.text - let generation = urlDetectionGeneration - Task { [weak self] in - guard let self else { return } - await self.updateURLForDisplayItem(messageID: messageID, text: text, generation: generation) - } + // MARK: - Display Items + + /// Optimistically append a message if not already present. Called from + /// the incoming admission path after the receive-time prefetch resolves + /// or hits its timeout, and from the outgoing send paths immediately + /// after `createPendingMessage`. Preserves unread-counter math via the + /// `newItems.count` delta in `ChatTableView.updateItems`. + func appendMessageIfNew(_ message: MessageDTO) { + guard let coordinator else { return } + let previous = messages.last + + // Synchronous append: coordinator append, render item insertion, and + // channel sender bookkeeping all mutate Observable state on the main + // actor in one call frame, so SwiftUI already invalidates dependent + // views once per change cycle without an explicit transaction. + guard coordinator.append(message) else { return } + let newItem = makeItem(for: message, previous: previous) + coordinator.appendRenderItem(newItem) + if let senderName = message.senderNodeName, + let radioID = currentChannel?.radioID { + addChannelSenderIfNew(senderName, radioID: radioID, timestamp: message.timestamp) } - /// Update URL detection for a single message by ID. - /// Uses O(1) dictionary lookup to handle concurrent array modifications. - private func updateURLForDisplayItem(messageID: UUID, text: String, generation: UInt64) async { - let detectedURL = await Task.detached(priority: .userInitiated) { - LinkPreviewService.extractFirstURL(from: text) - }.value - - // Drop stale writes after a buildItems rebuild — Task.cancel only kills the latest chain link. - // Single-row rebuilds via rebuildDisplayItem(for:) do not write cachedURLs and need no - // generation gating; only this URL-detection writer does. - guard urlDetectionGeneration == generation else { return } - - // Gate every per-VM write on the message still being present so a - // delete between detection-start and detection-end can never seed - // orphan state that no cleanup path purges before conversation - // switch. - guard let coordinator, - let message = coordinator.messagesByID[messageID] else { - logger.warning("URL update for missing message id \(messageID)") - return - } - - cachedURLs[messageID] = detectedURL - - // Rehydrate decoded-image state from the singleton when this is a - // fresh chat-entry on a URL whose decode already completed in a - // prior session. Painting `.loaded` plus the dict entry in the - // same call frame means the next render skips the shimmer - // transition entirely. - rehydrateInlineImageStateIfCached(messageID: messageID, url: detectedURL) - rehydratePreviewStateIfCached(messageID: messageID, url: detectedURL) - - let previous = previousMessage(for: messageID) - coordinator.updateRenderItem(id: messageID) { _ in - makeItem(for: message, previous: previous) - } + // URL detection writes `cachedURLs[messageID]` from a background task + // and lands as its own invalidation cycle. + let messageID = message.id + let text = message.text + let generation = urlDetectionGeneration + Task { [weak self] in + guard let self else { return } + await updateURLForDisplayItem(messageID: messageID, text: text, generation: generation) } - - /// Seed `decodedImages` / `imageIsGIF` / `previewStates = .loaded` - /// atomically when the singleton has a decoded image for this URL. - /// Also restores raw bytes into `loadedImageData` for static images so - /// the full-screen viewer and share sheet (which need original - /// resolution and `Data`) keep working post-rehydration. Idempotent - /// and a no-op for non-image URLs, the inline-image AppStorage toggle - /// being off, or a per-VM state that has already advanced past `.idle`. - private func rehydrateInlineImageStateIfCached(messageID: UUID, url: URL?) { - guard envInputs.showInlineImages, - let url, - ImageURLClassifier.isImageURL(url) else { return } - let existingState = previewStates[messageID] - guard existingState == nil || existingState == .idle else { return } - let directURL = ImageURLClassifier.directImageURL(for: url) - guard let cached = InlineImageCache.shared.decoded(for: directURL) else { return } - applyDecodedImage(cached, for: messageID) + } + + /// Update URL detection for a single message by ID. + /// Uses O(1) dictionary lookup to handle concurrent array modifications. + private func updateURLForDisplayItem(messageID: UUID, text: String, generation: UInt64) async { + let detectedURL = await Task.detached(priority: .userInitiated) { + LinkPreviewService.extractFirstURL(from: text) + }.value + + // Drop stale writes after a buildItems rebuild — Task.cancel only kills the latest chain link. + // Single-row rebuilds via rebuildDisplayItem(for:) do not write cachedURLs and need no + // generation gating; only this URL-detection writer does. + guard urlDetectionGeneration == generation else { return } + + // Gate every per-VM write on the message still being present so a + // delete between detection-start and detection-end can never seed + // orphan state that no cleanup path purges before conversation + // switch. + guard let coordinator, + let message = coordinator.messagesByID[messageID] else { + logger.warning("URL update for missing message id \(messageID)") + return } - /// Seed `loadedPreviews` / `decodedPreviewAssets` / `previewStates = .loaded` - /// atomically when `DecodedPreviewCache` already holds a decoded card for - /// this URL. Painting `.loaded` in the same call frame as URL detection - /// means the bubble skips the loading shimmer on chat re-entry. Idempotent - /// and a no-op once state has advanced past `.idle`; image URLs are handled - /// by `rehydrateInlineImageStateIfCached` and have no preview entry here. - private func rehydratePreviewStateIfCached(messageID: UUID, url: URL?) { - guard let url else { return } - let existingState = previewStates[messageID] - guard existingState == nil || existingState == .idle else { return } - guard let cached = DecodedPreviewCache.shared.decoded(for: url) else { return } - loadedPreviews[messageID] = cached.dto - decodedPreviewAssets[messageID] = DecodedPreviewAssets(image: cached.hero, icon: cached.icon) - previewStates[messageID] = .loaded - } + cachedURLs[messageID] = detectedURL - /// Build MessageItems with pre-computed properties. Snapshots view-model - /// state on the main actor and delegates the per-message builder loop to - /// `ChatCoordinator.rebuildItems`, which performs the off-actor hop and - /// applies on main only when the captured `renderStateID` still matches. - func buildItems() { - guard let coordinator else { return } - urlDetectionGeneration &+= 1 - let urlGeneration = urlDetectionGeneration - - // Drop stale entries from the previous build before `makeBuildInputs` - // re-inserts. Theme toggle and offline-state flip both rebuild items - // under a new request key for the same message; without this, the old - // key's bucket lingers and a late resolution could rebuild a row whose - // current request key has changed. - mapPreviewRequestIndex.removeAll() - - var uncachedMessageIDs: [(UUID, String)] = [] - let messagesSnapshot = coordinator.messages - - let inputs: [(MessageDTO, MessageBuildInputs)] = messagesSnapshot.enumerated().map { index, message in - let previous: MessageDTO? = index > 0 ? messagesSnapshot[index - 1] : nil - - if cachedURLs[message.id] == nil - && previewStates[message.id] == nil - && loadedPreviews[message.id] == nil { - uncachedMessageIDs.append((message.id, message.text)) - } - - return (message, makeBuildInputs(for: message, previous: previous)) - } + // Rehydrate decoded-image state from the singleton when this is a + // fresh chat-entry on a URL whose decode already completed in a + // prior session. Painting `.loaded` plus the dict entry in the + // same call frame means the next render skips the shimmer + // transition entirely. + rehydrateInlineImageStateIfCached(messageID: messageID, url: detectedURL) + rehydratePreviewStateIfCached(messageID: messageID, url: detectedURL) - let messagesToDetect = uncachedMessageIDs - - coordinator.rebuildItems(inputs: inputs, envInputs: envInputs) { [weak self] in - guard let self else { return } - if !messagesToDetect.isEmpty { - self.urlDetectionTask?.cancel() - self.urlDetectionTask = Task { [weak self] in - guard let self else { return } - for (messageID, text) in messagesToDetect { - guard !Task.isCancelled, self.urlDetectionGeneration == urlGeneration else { return } - await self.updateURLForDisplayItem(messageID: messageID, text: text, generation: urlGeneration) - } - } - } - self.decodeLegacyPreviewImages() - } + let previous = previousMessage(for: messageID) + coordinator.updateRenderItem(id: messageID) { _ in + makeItem(for: message, previous: previous) + } + } + + /// Seed `decodedImages` / `imageIsGIF` / `previewStates = .loaded` + /// atomically when the singleton has a decoded image for this URL. + /// Also restores raw bytes into `loadedImageData` for static images so + /// the full-screen viewer and share sheet (which need original + /// resolution and `Data`) keep working post-rehydration. Idempotent + /// and a no-op for non-image URLs, the master toggle being off, or a + /// per-VM state that has already advanced past a tap-to-load-eligible + /// state. Master-gated only, no scope check: this reads the decoded cache + /// and performs no network fetch, so a cached image beats the tap-to-load + /// placeholder under scope-off too, matching `LinkPreviewCache.preview`'s + /// cache-before-gate ordering for cards. + private func rehydrateInlineImageStateIfCached(messageID: UUID, url: URL?) { + guard envInputs.previewsEnabled, + let url, + ImageURLClassifier.isImageURL(url) else { return } + let existingState = previewStates[messageID] + guard existingState == nil || existingState == .idle || existingState == .disabled else { return } + let directURL = ImageURLClassifier.directImageURL(for: url) + guard let cached = InlineImageCache.shared.decoded(for: directURL) else { return } + applyDecodedImage(cached, for: messageID) + } + + /// Seed `loadedPreviews` / `decodedPreviewAssets` / `previewStates = .loaded` + /// atomically when `DecodedPreviewCache` already holds a decoded card for + /// this URL. Painting `.loaded` in the same call frame as URL detection + /// means the bubble skips the loading shimmer on chat re-entry. Idempotent + /// and a no-op once state has advanced past `.idle`; image URLs are handled + /// by `rehydrateInlineImageStateIfCached` and have no preview entry here. + private func rehydratePreviewStateIfCached(messageID: UUID, url: URL?) { + guard let url else { return } + let existingState = previewStates[messageID] + guard existingState == nil || existingState == .idle else { return } + guard let cached = DecodedPreviewCache.shared.decoded(for: url) else { return } + loadedPreviews[messageID] = cached.dto + decodedPreviewAssets[messageID] = DecodedPreviewAssets(image: cached.hero, icon: cached.icon) + previewStates[messageID] = .loaded + } + + /// Build MessageItems with pre-computed properties. Snapshots view-model + /// state on the main actor and delegates the per-message builder loop to + /// `ChatCoordinator.rebuildItems`, which performs the off-actor hop and + /// applies on main only when the captured `renderStateID` still matches. + func buildItems() { + guard let coordinator else { return } + urlDetectionGeneration &+= 1 + let urlGeneration = urlDetectionGeneration + + // Drop stale entries from the previous build before `makeBuildInputs` + // re-inserts. Theme toggle and offline-state flip both rebuild items + // under a new request key for the same message; without this, the old + // key's bucket lingers and a late resolution could rebuild a row whose + // current request key has changed. + mapPreviewRequestIndex.removeAll() + + var uncachedMessageIDs: [(UUID, String)] = [] + let messagesSnapshot = coordinator.messages + + let inputs: [(MessageDTO, MessageBuildInputs)] = messagesSnapshot.enumerated().map { index, message in + let previous: MessageDTO? = index > 0 ? messagesSnapshot[index - 1] : nil + + if cachedURLs[message.id] == nil, + previewStates[message.id] == nil, + loadedPreviews[message.id] == nil { + uncachedMessageIDs.append((message.id, message.text)) + } + + return (message, makeBuildInputs(for: message, previous: previous)) } - /// Get full message DTO for a MessageItem. - /// Logs a warning if lookup fails (indicates data inconsistency). - func message(for item: MessageItem) -> MessageDTO? { - guard let message = messagesByID[item.id] else { - logger.warning("Message lookup failed for item id=\(item.id)") - return nil + let messagesToDetect = uncachedMessageIDs + + coordinator.rebuildItems(inputs: inputs, envInputs: envInputs) { [weak self] in + guard let self else { return } + if !messagesToDetect.isEmpty { + urlDetectionTask?.cancel() + urlDetectionTask = Task { [weak self] in + guard let self else { return } + for (messageID, text) in messagesToDetect { + guard !Task.isCancelled, urlDetectionGeneration == urlGeneration else { return } + await updateURLForDisplayItem(messageID: messageID, text: text, generation: urlGeneration) + } } - return message + } + decodeLegacyPreviewImages() + } + } + + /// Get full message DTO for a MessageItem. + /// Logs a warning if lookup fails (indicates data inconsistency). + func message(for item: MessageItem) -> MessageDTO? { + guard let message = messagesByID[item.id] else { + logger.warning("Message lookup failed for item id=\(item.id)") + return nil } + return message + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift index b1365599..a812c401 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift @@ -3,77 +3,76 @@ import MC1Services import SwiftUI extension ChatViewModel { + /// Fold a `MessageEvent` from `MessageEventStream` into view-model state. + /// Called on the main actor from a SwiftUI `.task` consumer in + /// `ChatConversationView`. The exhaustive switch is deliberate — a new + /// `MessageEvent` case becomes a compile error rather than a silent skip. + /// + /// The function is `async` so the incoming-message admission path can + /// await its prefetch race inline. The event stream is the canonical + /// ordering source for received messages; admitting incoming bubbles + /// via a detached `Task { ... }` would let a fast plain-text message + /// overtake a slow URL-bearing one and reorder the timeline. + func handle(_ event: MessageEvent) async { + switch event { + case let .directMessageReceived(message, contact): + guard let current = currentContact, current.id == contact.id else { return } + await admitIncomingMessage(message, isChannelMessage: false) + recordIncomingMentionIfNeeded(message) - /// Fold a `MessageEvent` from `MessageEventStream` into view-model state. - /// Called on the main actor from a SwiftUI `.task` consumer in - /// `ChatConversationView`. The exhaustive switch is deliberate — a new - /// `MessageEvent` case becomes a compile error rather than a silent skip. - /// - /// The function is `async` so the incoming-message admission path can - /// await its prefetch race inline. The event stream is the canonical - /// ordering source for received messages; admitting incoming bubbles - /// via a detached `Task { ... }` would let a fast plain-text message - /// overtake a slow URL-bearing one and reorder the timeline. - func handle(_ event: MessageEvent) async { - switch event { - case .directMessageReceived(let message, let contact): - guard let current = currentContact, current.id == contact.id else { return } - await admitIncomingMessage(message, isChannelMessage: false) - recordIncomingMentionIfNeeded(message) + case let .channelMessageReceived(message, channelIndex): + guard let channel = currentChannel, + channel.index == channelIndex, + message.radioID == channel.radioID else { return } + await admitIncomingMessage(message, isChannelMessage: true) + recordIncomingMentionIfNeeded(message) - case .channelMessageReceived(let message, let channelIndex): - guard let channel = currentChannel, - channel.index == channelIndex, - message.radioID == channel.radioID else { return } - await admitIncomingMessage(message, isChannelMessage: true) - recordIncomingMentionIfNeeded(message) + case let .messageStatusResolved(messageID, status, roundTripTime): + // Status-only resolution: apply in place so the bubble's status + // footer crossfades from "Sent" to "Delivered" rather than + // restarting on a fresh item identity. No DB fetch — the + // dispatcher writes the DB row before firing this case. + withAnimation { + coordinator?.applyStatusUpdate( + messageID: messageID, + status: status, + roundTripTime: roundTripTime + ) + } - case let .messageStatusResolved(messageID, status, roundTripTime): - // Status-only resolution: apply in place so the bubble's status - // footer crossfades from "Sent" to "Delivered" rather than - // restarting on a fresh item identity. No DB fetch — the - // dispatcher writes the DB row before firing this case. - withAnimation { - coordinator?.applyStatusUpdate( - messageID: messageID, - status: status, - roundTripTime: roundTripTime - ) - } + case let .messageRetrying(messageID, _, _): + // Payload-bearing variant routed straight to the reload chokepoint; + // not coalescer-eligible because attempt/maxAttempts are per-event. + coordinator?.enqueueReload(messageID: messageID) - case .messageRetrying(let messageID, _, _): - // Payload-bearing variant routed straight to the reload chokepoint; - // not coalescer-eligible because attempt/maxAttempts are per-event. - coordinator?.enqueueReload(messageID: messageID) + case let .messageResent(messageID), + let .messageFailed(messageID): + coordinator?.enqueueReload(messageID: messageID) - case .messageResent(let messageID), - .messageFailed(let messageID): - coordinator?.enqueueReload(messageID: messageID) + case let .heardRepeatRecorded(messageID, _), + let .reactionReceived(messageID, _): + coordinator?.enqueueReload(messageID: messageID) - case .heardRepeatRecorded(let messageID, _), - .reactionReceived(let messageID, _): - coordinator?.enqueueReload(messageID: messageID) + case let .routingChanged(contactID, _): + guard let current = currentContact, current.id == contactID else { return } + requestContactRefresh() - case .routingChanged(let contactID, _): - guard let current = currentContact, current.id == contactID else { return } - requestContactRefresh() - - case .roomMessageReceived, .roomMessageStatusUpdated, .roomMessageFailed: - // Room events go to RemoteNodes via MessageEventStream subscription - // in RoomConversationView. Enumerated explicitly so adding a new - // MessageEvent case surfaces as a non-exhaustive switch compile - // error rather than a silent skip. - break - } + case .roomMessageReceived, .roomMessageStatusUpdated, .roomMessageFailed: + // Room events go to RemoteNodes via MessageEventStream subscription + // in RoomConversationView. Enumerated explicitly so adding a new + // MessageEvent case surfaces as a non-exhaustive switch compile + // error rather than a silent skip. + break } + } - private func requestContactRefresh() { - contactRefreshSignal &+= 1 - } + private func requestContactRefresh() { + contactRefreshSignal &+= 1 + } - private func recordIncomingMentionIfNeeded(_ message: MessageDTO) { - guard message.containsSelfMention else { return } - mentionSequence &+= 1 - lastIncomingMention = MentionEvent(messageID: message.id, sequence: mentionSequence) - } + private func recordIncomingMentionIfNeeded(_ message: MessageDTO) { + guard message.containsSelfMention else { return } + mentionSequence &+= 1 + lastIncomingMention = MentionEvent(messageID: message.id, sequence: mentionSequence) + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift index 28bee13f..59e3bb80 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift @@ -1,182 +1,185 @@ import CoreLocation import Foundation -import SwiftUI import MC1Services +import SwiftUI extension ChatViewModel { - - // MARK: - Display Flags - - /// Time gap (in seconds) that breaks message grouping for timestamps and sender names. - static let messageGroupingGapSeconds = 300 - - /// Pre-computed display flags for a single message. - struct DisplayFlags { - let showTimestamp: Bool - let showDirectionGap: Bool - let showSenderName: Bool - let showDayDivider: Bool - } - - /// Computes all display flags in a single pass to avoid redundant message lookups. - /// Used by buildItems() for O(n) performance instead of O(3n). - static func computeDisplayFlags(for message: MessageDTO, previous: MessageDTO?) -> DisplayFlags { - guard let previous else { - return DisplayFlags(showTimestamp: true, showDirectionGap: false, showSenderName: true, showDayDivider: true) - } - - // Keys on send time (senderDate), not the sortDate sort key. Under block-at-reconnect - // a drained batch shares one sortDate, so grouping on it would collapse every in-block - // divider; send time keeps honest separators inside the block. The fetch's timestamp - // secondary key orders the block by send time, so headers stay monotonic within it. - let timeGap = abs(Int(message.senderDate.timeIntervalSince(previous.senderDate))) - - // Keys on senderDate (the value the divider and time marker display) so the boundary - // matches the times beneath it; a backlog synced in one session shares one receive - // day but still divides on real send days. - let dayChanged = !Calendar.current.isDate(message.senderDate, inSameDayAs: previous.senderDate) - - let showTimestamp = timeGap > messageGroupingGapSeconds - let showDirectionGap = message.direction != previous.direction - - let showSenderName: Bool - if message.contactID != nil || message.isOutgoing { - // UI suppresses the sender name for direct messages anyway; the branch - // keeps the channel-message logic from running with a missing senderNodeName. - showSenderName = true - } else if previous.isOutgoing || timeGap > messageGroupingGapSeconds { - showSenderName = true - } else if let currentName = message.senderNodeName, let previousName = previous.senderNodeName { - showSenderName = currentName != previousName - } else { - // No senderNodeName available on either side; show the name to be safe. - showSenderName = true - } - - return DisplayFlags(showTimestamp: showTimestamp, showDirectionGap: showDirectionGap, showSenderName: showSenderName, showDayDivider: dayChanged) - } - - // MARK: - Item Build - - /// Assemble `MessageBuildInputs` from current view-model state. Reads - /// `previewStates`, `cachedURLs`, `decodedImages`, etc. plus `envInputs` - /// (`@MainActor` state). The returned snapshot is deterministic w.r.t. the - /// inputs and `Sendable`, so it is safe to feed to the off-main builder. Has - /// one `@MainActor` side effect: it records the map-preview snapshot request - /// in `mapPreviewRequestIndex` so a late resolution can rebuild only the - /// affected rows — hence it must be called on the main actor (the batch - /// `buildItems()` path already does). - func makeBuildInputs(for message: MessageDTO, previous: MessageDTO?) -> MessageBuildInputs { - let flags = Self.computeDisplayFlags(for: message, previous: previous) - let cachedURL = cachedURLs[message.id].flatMap { $0 } - let inlineImageAspect: Double? = { - guard let cachedURL, - ImageURLClassifier.isImageURL(cachedURL), - let store = inlineImageDimensionsStore else { return nil } - let directURL = ImageURLClassifier.directImageURL(for: cachedURL) - return store.aspect(for: directURL) ?? store.aspect(for: cachedURL) - }() - - let theme = ThemeRegistry.theme(forID: envInputs.themeID) ?? .default - let identityBackgroundLuminances = theme.avatarSurfaceLuminances( - colorScheme: envInputs.isDark ? .dark : .light, - contrast: envInputs.isHighContrast ? .increased : .standard - ) - - let formatted = MessageText.buildFormattedText( - text: message.text, - isOutgoing: message.isOutgoing, - currentUserName: envInputs.currentUserName, - isHighContrast: envInputs.isHighContrast, - outgoingTextColor: theme.outgoingTextColor, - hashtagColor: theme.hashtagColor, - identityGamut: theme.identityGamut, - identityBackgroundLuminances: identityBackgroundLuminances - ) - - var isMapPreviewReady = false - // Gate the snapshot index on the privacy toggle so the index stays empty for - // users who turned thumbnails off — `MessageFragmentBuilder` makes the same - // check before appending the fragment, so the render request never fires. - if envInputs.showMapPreviews, let coordinate = formatted.mapCoordinate { - let request = MapSnapshotRequest( - latitude: coordinate.latitude, - longitude: coordinate.longitude, - isDark: envInputs.isDark, - isOffline: envInputs.isOffline - ) - mapPreviewRequestIndex[request, default: []].insert(message.id) - isMapPreviewReady = MapSnapshotStore.shared.isResolved(request) - } - - return MessageBuildInputs( - messageID: message.id, - previewState: previewStates[message.id] ?? .idle, - loadedPreview: loadedPreviews[message.id], - cachedURL: cachedURL, - hasInlineImageRef: decodedImages[message.id] != nil, - hasPreviewImageRef: decodedPreviewAssets[message.id]?.image != nil, - hasPreviewIconRef: decodedPreviewAssets[message.id]?.icon != nil, - imageIsGIF: imageIsGIF[message.id] ?? false, - inlineImageAspect: inlineImageAspect, - mapPreviewLatitude: formatted.mapCoordinate?.latitude, - mapPreviewLongitude: formatted.mapCoordinate?.longitude, - isMapPreviewReady: isMapPreviewReady, - formattedText: formatted.text, - baseColor: message.isOutgoing ? .outgoing : .incoming, - formattedPath: (envInputs.showIncomingPath && !message.isOutgoing) - ? MessagePathFormatter.format(message) - : nil, - senderResolution: senderResolutionFor(message), - showTimestamp: flags.showTimestamp, - showDirectionGap: flags.showDirectionGap, - showSenderName: flags.showSenderName, - showNewMessagesDivider: message.id == newMessagesDividerMessageID, - showDayDivider: flags.showDayDivider - ) + // MARK: - Display Flags + + /// Time gap (in seconds) that breaks message grouping for timestamps and sender names. + static let messageGroupingGapSeconds = 300 + + /// Pre-computed display flags for a single message. + struct DisplayFlags { + let showTimestamp: Bool + let showDirectionGap: Bool + let showSenderName: Bool + let showDayDivider: Bool + } + + /// Computes all display flags in a single pass to avoid redundant message lookups. + /// Used by buildItems() for O(n) performance instead of O(3n). + static func computeDisplayFlags(for message: MessageDTO, previous: MessageDTO?) -> DisplayFlags { + guard let previous else { + return DisplayFlags(showTimestamp: true, showDirectionGap: false, showSenderName: true, showDayDivider: true) } - /// Single-message convenience that pairs `makeBuildInputs` with the pure - /// `MessageFragmentBuilder`. Single-row callers (`appendMessageIfNew`, - /// `rebuildDisplayItem`, `updateURLForDisplayItem`) keep using this; the - /// batch path in `buildItems()` calls `makeBuildInputs` on main and then - /// invokes the builder off-actor with the resulting snapshot. - func makeItem(for message: MessageDTO, previous: MessageDTO?) -> MessageItem { - MessageFragmentBuilder.makeItem( - for: message, - inputs: makeBuildInputs(for: message, previous: previous), - envInputs: envInputs - ) + // Keys on send time (senderDate), not the sortDate sort key. Under block-at-reconnect + // a drained batch shares one sortDate, so grouping on it would collapse every in-block + // divider; send time keeps honest separators inside the block. The fetch's timestamp + // secondary key orders the block by send time, so headers stay monotonic within it. + let timeGap = abs(Int(message.senderDate.timeIntervalSince(previous.senderDate))) + + // Keys on senderDate (the value the divider and time marker display) so the boundary + // matches the times beneath it; a backlog synced in one session shares one receive + // day but still divides on real send days. + let dayChanged = !Calendar.current.isDate(message.senderDate, inSameDayAs: previous.senderDate) + + let showTimestamp = timeGap > messageGroupingGapSeconds + let showDirectionGap = message.direction != previous.direction + + let showSenderName: Bool = if message.contactID != nil || message.isOutgoing { + // UI suppresses the sender name for direct messages anyway; the branch + // keeps the channel-message logic from running with a missing senderNodeName. + true + } else if previous.isOutgoing || timeGap > messageGroupingGapSeconds { + true + } else if let currentName = message.senderNodeName, let previousName = previous.senderNodeName { + currentName != previousName + } else { + // No senderNodeName available on either side; show the name to be safe. + true } - /// Recover the previous message in display order from the canonical - /// `messages` array. Survives reordering side effects (e.g., - /// `reorderSameSenderClusters`) because it reads the current array at - /// call time, not an item-index snapshot. - func previousMessage(for messageID: UUID) -> MessageDTO? { - guard let index = messages.firstIndex(where: { $0.id == messageID }), - index > 0 else { return nil } - return messages[index - 1] + return DisplayFlags(showTimestamp: showTimestamp, showDirectionGap: showDirectionGap, showSenderName: showSenderName, showDayDivider: dayChanged) + } + + // MARK: - Item Build + + /// Assemble `MessageBuildInputs` from current view-model state. Reads + /// `previewStates`, `cachedURLs`, `decodedImages`, etc. plus `envInputs` + /// (`@MainActor` state). The returned snapshot is deterministic w.r.t. the + /// inputs and `Sendable`, so it is safe to feed to the off-main builder. Has + /// one `@MainActor` side effect: it records the map-preview snapshot request + /// in `mapPreviewRequestIndex` so a late resolution can rebuild only the + /// affected rows — hence it must be called on the main actor (the batch + /// `buildItems()` path already does). + func makeBuildInputs(for message: MessageDTO, previous: MessageDTO?) -> MessageBuildInputs { + let flags = Self.computeDisplayFlags(for: message, previous: previous) + let cachedURL = cachedURLs[message.id].flatMap(\.self) + // Extension-based image classification, minus URLs the fetch path has + // since discovered serve an HTML page. Computed once and reused for the + // aspect-ratio gate so a rerouted URL neither fetches nor reserves a frame. + let isInlineImageURL = cachedURL.map { routesToInlineImage($0) } ?? false + let inlineImageAspect: Double? = { + guard isInlineImageURL, let cachedURL, + let store = inlineImageDimensionsStore else { return nil } + let directURL = ImageURLClassifier.directImageURL(for: cachedURL) + return store.aspect(for: directURL) ?? store.aspect(for: cachedURL) + }() + + let theme = ThemeRegistry.theme(forID: envInputs.themeID) ?? .default + let identityBackgroundLuminances = theme.avatarSurfaceLuminances( + colorScheme: envInputs.isDark ? .dark : .light, + contrast: envInputs.isHighContrast ? .increased : .standard + ) + + let formatted = MessageText.buildFormattedText( + text: message.text, + isOutgoing: message.isOutgoing, + currentUserName: envInputs.currentUserName, + isHighContrast: envInputs.isHighContrast, + outgoingTextColor: theme.outgoingTextColor, + hashtagColor: theme.hashtagColor, + identityGamut: theme.identityGamut, + identityBackgroundLuminances: identityBackgroundLuminances + ) + + var isMapPreviewReady = false + // Gate the snapshot index on the privacy toggle so the index stays empty for + // users who turned thumbnails off — `MessageFragmentBuilder` makes the same + // check before appending the fragment, so the render request never fires. + if envInputs.showMapPreviews, let coordinate = formatted.mapCoordinate { + let request = MapSnapshotRequest( + latitude: coordinate.latitude, + longitude: coordinate.longitude, + isDark: envInputs.isDark, + isOffline: envInputs.isOffline + ) + mapPreviewRequestIndex[request, default: []].insert(message.id) + isMapPreviewReady = MapSnapshotStore.shared.isResolved(request) } - /// Resolve a sender display name for a message. Channels run the - /// contact-aware resolver; DMs fall back to the unknown sentinel - /// because DM bubbles never display the sender row. - /// - /// Dispatch is keyed on `message.channelIndex` rather than the view - /// model's `currentChannel`: `currentChannel` is nil during early - /// rebuilds, so keying off it would mis-route channel rows to the - /// DM path and bake the unknown sentinel into cached items. - func senderResolutionFor(_ message: MessageDTO) -> NodeNameResolution { - if message.channelIndex != nil { - return MessageBubbleConfiguration.resolveSenderName( - for: message, - contacts: allContacts - ) - } - return NodeNameResolution( - displayName: L10n.Chats.Chats.Message.Sender.unknown, - matchKind: .unresolved - ) + return MessageBuildInputs( + messageID: message.id, + previewState: previewStates[message.id] ?? .idle, + loadedPreview: loadedPreviews[message.id], + cachedURL: cachedURL, + isInlineImageURL: isInlineImageURL, + hasInlineImageRef: decodedImages[message.id] != nil, + hasPreviewImageRef: decodedPreviewAssets[message.id]?.image != nil, + hasPreviewIconRef: decodedPreviewAssets[message.id]?.icon != nil, + imageIsGIF: imageIsGIF[message.id] ?? false, + inlineImageAspect: inlineImageAspect, + mapPreviewLatitude: formatted.mapCoordinate?.latitude, + mapPreviewLongitude: formatted.mapCoordinate?.longitude, + isMapPreviewReady: isMapPreviewReady, + formattedText: formatted.text, + baseColor: message.isOutgoing ? .outgoing : .incoming, + formattedPath: (envInputs.showIncomingPath && !message.isOutgoing) + ? MessagePathFormatter.format(message) + : nil, + senderResolution: senderResolutionFor(message), + showTimestamp: flags.showTimestamp, + showDirectionGap: flags.showDirectionGap, + showSenderName: flags.showSenderName, + showNewMessagesDivider: message.id == newMessagesDividerMessageID, + showDayDivider: flags.showDayDivider + ) + } + + /// Single-message convenience that pairs `makeBuildInputs` with the pure + /// `MessageFragmentBuilder`. Single-row callers (`appendMessageIfNew`, + /// `rebuildDisplayItem`, `updateURLForDisplayItem`) keep using this; the + /// batch path in `buildItems()` calls `makeBuildInputs` on main and then + /// invokes the builder off-actor with the resulting snapshot. + func makeItem(for message: MessageDTO, previous: MessageDTO?) -> MessageItem { + MessageFragmentBuilder.makeItem( + for: message, + inputs: makeBuildInputs(for: message, previous: previous), + envInputs: envInputs + ) + } + + /// Recover the previous message in display order from the canonical + /// `messages` array. Survives reordering side effects (e.g., + /// `reorderSameSenderClusters`) because it reads the current array at + /// call time, not an item-index snapshot. + func previousMessage(for messageID: UUID) -> MessageDTO? { + guard let index = messages.firstIndex(where: { $0.id == messageID }), + index > 0 else { return nil } + return messages[index - 1] + } + + /// Resolve a sender display name for a message. Channels run the + /// contact-aware resolver; DMs fall back to the unknown sentinel + /// because DM bubbles never display the sender row. + /// + /// Dispatch is keyed on `message.channelIndex` rather than the view + /// model's `currentChannel`: `currentChannel` is nil during early + /// rebuilds, so keying off it would mis-route channel rows to the + /// DM path and bake the unknown sentinel into cached items. + func senderResolutionFor(_ message: MessageDTO) -> NodeNameResolution { + if message.channelIndex != nil { + return MessageBubbleConfiguration.resolveSenderName( + for: message, + contacts: allContacts, + nicknamesByLoweredName: nicknamesByLoweredName + ) } + return NodeNameResolution( + displayName: L10n.Chats.Chats.Message.Sender.unknown, + matchKind: .unresolved + ) + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift index 6fcfef6b..64b7998e 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift @@ -1,164 +1,163 @@ -import SwiftUI import MC1Services +import SwiftUI extension ChatViewModel { - - // MARK: - Message Actions - - /// Retry sending a failed direct message through the persistent send - /// queue. Replaces the prior multi-await sequence (delete + - /// status flip + direct service call) with a single - /// `PersistenceStore.replacePendingSendForRetry` transaction followed - /// by a no-persist `signalDMEnqueued`. The queue drain owns - /// transport-open parking and process-restart durability, so a - /// disconnect or app death mid-retry now replays from the persisted - /// `PendingSend` row on the next hydrate. - func retryMessage(_ message: MessageDTO) async { - logger.info("retryMessage called for message: \(message.id)") - - guard !retryInFlight else { return } - retryInFlight = true - defer { retryInFlight = false } - - guard let contact = currentContact, - let dataStore else { - logger.warning("retryMessage: missing currentContact or dataStore") - return - } - - errorMessage = nil - errorBannerMessage = nil - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id) - let dto = PendingSendDTO(envelope: envelope, radioID: contact.radioID) - - do { - _ = try await dataStore.replacePendingSendForRetry(messageID: message.id, dto: dto) - } catch { - logger.error("retryMessage: replacePendingSendForRetry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - sendErrorMessage = Self.copyForEnqueueFailure(error) - return - } - - coordinator?.applyStatusUpdate( - messageID: message.id, - status: .pending, - userInitiated: true - ) - - do { - try await signalDMEnqueued(envelope) - } catch { - logger.error("retryMessage: signalDMEnqueued failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - sendErrorMessage = Self.copyForEnqueueFailure(error) - } + // MARK: - Message Actions + + /// Retry sending a failed direct message through the persistent send + /// queue. Replaces the prior multi-await sequence (delete + + /// status flip + direct service call) with a single + /// `PersistenceStore.replacePendingSendForRetry` transaction followed + /// by a no-persist `signalDMEnqueued`. The queue drain owns + /// transport-open parking and process-restart durability, so a + /// disconnect or app death mid-retry now replays from the persisted + /// `PendingSend` row on the next hydrate. + func retryMessage(_ message: MessageDTO) async { + logger.info("retryMessage called for message: \(message.id)") + + guard !retryInFlight else { return } + retryInFlight = true + defer { retryInFlight = false } + + guard let contact = currentContact, + let dataStore else { + logger.warning("retryMessage: missing currentContact or dataStore") + return } - /// Resend a channel message in place, or copy text for direct messages. - /// Used for "Send Again" context menu action. - func sendAgain(_ message: MessageDTO) async { - if let channelIndex = message.channelIndex { - // Channel messages: enqueue with isResend: true so the queue drain - // refreshes the mesh timestamp via resendChannelMessage. Reusing the - // original timestamp would hash identically to the failed packet and - // be dropped by the 128-slot cyclic dedup table at every neighbor. - let envelope = ChannelMessageEnvelope( - messageID: message.id, - channelIndex: channelIndex, - isResend: true, - messageText: message.text, - messageTimestamp: message.timestamp, - localNodeName: connectedDeviceProvider()?.nodeName - ) - do { - try await enqueueChannel(envelope) - } catch { - logger.error("enqueueChannel sendAgain failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) - } - } else { - // Identity-preserving DM resend. Mirrors retryMessage: route through - // the shared PersistenceStore.replacePendingSendForRetry transaction - // so delete-existing-PendingSend + status flip + new-PendingSend - // insert land atomically, then signal the queue. retryMessage and - // sendAgain intentionally remain two separate functions: retryMessage - // debounces via retryInFlight, sendAgain via actions-sheet dismissal — - // extracting a shared body would lose that distinction. - guard let contact = currentContact, - let dataStore else { - logger.warning("sendAgain (DM): missing currentContact or dataStore") - return - } - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id, isResend: true) - let dto = PendingSendDTO(envelope: envelope, radioID: contact.radioID) - - do { - _ = try await dataStore.replacePendingSendForRetry(messageID: message.id, dto: dto) - } catch { - logger.error("sendAgain (DM): replacePendingSendForRetry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - sendErrorMessage = Self.copyForEnqueueFailure(error) - return - } - - coordinator?.applyStatusUpdate( - messageID: message.id, - status: .pending, - userInitiated: true - ) - - do { - try await signalDMEnqueued(envelope) - } catch { - logger.error("sendAgain (DM): signalDMEnqueued failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - sendErrorMessage = Self.copyForEnqueueFailure(error) - } - } - } + errorMessage = nil + errorBannerMessage = nil + + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id) + let dto = PendingSendDTO(envelope: envelope, radioID: contact.radioID) - /// Delete a single message - func deleteMessage(_ message: MessageDTO) async { - guard connectionStateProvider() == .ready else { return } - guard let dataStore else { return } - - do { - try await dataStore.deleteMessage(id: message.id) - - // Remove from all local collections - coordinator?.remove(messageID: message.id) - coordinator?.removeRenderItem(id: message.id) - - // Clean up preview state for deleted message - cleanupPreviewState(for: message.id) - - // Re-derive the conversation's last-message date from the database - // rather than the paginated in-memory window: older messages beyond - // the loaded page must keep the conversation visible. Always refresh - // the chat list afterward — deleting the newest message shifts the - // conversation's sort position and preview even when older messages - // remain, and deleting the last one removes it entirely. - if let currentContact { - try await dataStore.recomputeContactLastMessageDate(contactID: currentContact.id) - syncCoordinator?.notifyConversationsChanged() - } - } catch { - errorMessage = error.userFacingMessage - } + do { + _ = try await dataStore.replacePendingSendForRetry(messageID: message.id, dto: dto) + } catch { + logger.error("retryMessage: replacePendingSendForRetry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + sendErrorMessage = Self.copyForEnqueueFailure(error) + return } - /// Clears a direct conversation by dropping its messages and last-message date (a local - /// SwiftData write, no radio command). Throws `ConversationActionError.notConnected` rather - /// than returning silently so the caller can roll back the optimistic hide and surface an error. - func deleteDirectConversation(for contact: ContactDTO) async throws { - guard connectionStateProvider() == .ready, let dataStore else { - throw ConversationActionError.notConnected - } - - try await dataStore.deleteMessagesForContact(contactID: contact.id) - try await dataStore.clearUnreadCount(contactID: contact.id) - try await dataStore.updateContactLastMessage(contactID: contact.id, date: nil) - await notificationService?.updateBadgeCount() + coordinator?.applyStatusUpdate( + messageID: message.id, + status: .pending, + userInitiated: true + ) + + do { + try await signalDMEnqueued(envelope) + } catch { + logger.error("retryMessage: signalDMEnqueued failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + sendErrorMessage = Self.copyForEnqueueFailure(error) } + } + + /// Resend a channel message in place, or copy text for direct messages. + /// Used for "Send Again" context menu action. + func sendAgain(_ message: MessageDTO) async { + if let channelIndex = message.channelIndex { + // Channel messages: enqueue with isResend: true so the queue drain + // refreshes the mesh timestamp via resendChannelMessage. Reusing the + // original timestamp would hash identically to the failed packet and + // be dropped by the 128-slot cyclic dedup table at every neighbor. + let envelope = ChannelMessageEnvelope( + messageID: message.id, + channelIndex: channelIndex, + isResend: true, + messageText: message.text, + messageTimestamp: message.timestamp, + localNodeName: connectedDeviceProvider()?.nodeName + ) + do { + try await enqueueChannel(envelope) + } catch { + logger.error("enqueueChannel sendAgain failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) + coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) + } + } else { + // Identity-preserving DM resend. Mirrors retryMessage: route through + // the shared PersistenceStore.replacePendingSendForRetry transaction + // so delete-existing-PendingSend + status flip + new-PendingSend + // insert land atomically, then signal the queue. retryMessage and + // sendAgain intentionally remain two separate functions: retryMessage + // debounces via retryInFlight, sendAgain via actions-sheet dismissal — + // extracting a shared body would lose that distinction. + guard let contact = currentContact, + let dataStore else { + logger.warning("sendAgain (DM): missing currentContact or dataStore") + return + } + + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id, isResend: true) + let dto = PendingSendDTO(envelope: envelope, radioID: contact.radioID) + + do { + _ = try await dataStore.replacePendingSendForRetry(messageID: message.id, dto: dto) + } catch { + logger.error("sendAgain (DM): replacePendingSendForRetry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + sendErrorMessage = Self.copyForEnqueueFailure(error) + return + } + + coordinator?.applyStatusUpdate( + messageID: message.id, + status: .pending, + userInitiated: true + ) + + do { + try await signalDMEnqueued(envelope) + } catch { + logger.error("sendAgain (DM): signalDMEnqueued failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + sendErrorMessage = Self.copyForEnqueueFailure(error) + } + } + } + + /// Delete a single message + func deleteMessage(_ message: MessageDTO) async { + guard connectionStateProvider() == .ready else { return } + guard let dataStore else { return } + + do { + try await dataStore.deleteMessage(id: message.id) + + // Remove from all local collections + coordinator?.remove(messageID: message.id) + coordinator?.removeRenderItem(id: message.id) + + // Clean up preview state for deleted message + cleanupPreviewState(for: message.id) + + // Re-derive the conversation's last-message date from the database + // rather than the paginated in-memory window: older messages beyond + // the loaded page must keep the conversation visible. Always refresh + // the chat list afterward — deleting the newest message shifts the + // conversation's sort position and preview even when older messages + // remain, and deleting the last one removes it entirely. + if let currentContact { + try await dataStore.recomputeContactLastMessageDate(contactID: currentContact.id) + syncCoordinator?.notifyConversationsChanged() + } + } catch { + errorMessage = error.userFacingMessage + } + } + + /// Clears a direct conversation by dropping its messages and last-message date (a local + /// SwiftData write, no radio command). Throws `ConversationActionError.notConnected` rather + /// than returning silently so the caller can roll back the optimistic hide and surface an error. + func deleteDirectConversation(for contact: ContactDTO) async throws { + guard connectionStateProvider() == .ready, let dataStore else { + throw ConversationActionError.notConnected + } + + try await dataStore.deleteMessagesForContact(contactID: contact.id) + try await dataStore.clearUnreadCount(contactID: contact.id) + try await dataStore.updateContactLastMessage(contactID: contact.id, date: nil) + await notificationService?.updateBadgeCount() + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift index 387773b0..c7e3b00e 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift @@ -1,574 +1,574 @@ -import SwiftUI import MC1Services +import SwiftUI extension ChatViewModel { - - // MARK: - Notification Level - - /// Sets notification level for a conversation with optimistic UI update - func setNotificationLevel(_ conversation: Conversation, level: NotificationLevel) async { - guard connectionStateProvider() == .ready else { return } - let originalLevel = conversation.notificationLevel - - // Capture once so the write and the badge update target the same container. - let dataStore = self.dataStore - let notificationService = self.notificationService - - // Optimistic UI update - updateConversationNotificationLevel(conversation, level: level) - - do { - switch conversation { - case .direct(let contact): - // Contacts still use boolean muted - try await dataStore?.setContactMuted(contact.id, isMuted: level == .muted) - case .channel(let channel): - try await dataStore?.setChannelNotificationLevel(channel.id, level: level) - case .room(let session): - try await dataStore?.setSessionNotificationLevel(session.id, level: level) - } - await notificationService?.updateBadgeCount() - } catch { - // Rollback on failure - updateConversationNotificationLevel(conversation, level: originalLevel) - logger.error("Failed to set notification level: \(error)") - } + // MARK: - Notification Level + + /// Sets notification level for a conversation with optimistic UI update + func setNotificationLevel(_ conversation: Conversation, level: NotificationLevel) async { + guard connectionStateProvider() == .ready else { return } + let originalLevel = conversation.notificationLevel + + // Capture once so the write and the badge update target the same container. + let dataStore = dataStore + let notificationService = notificationService + + // Optimistic UI update + updateConversationNotificationLevel(conversation, level: level) + + do { + switch conversation { + case let .direct(contact): + // Contacts still use boolean muted + try await dataStore?.setContactMuted(contact.id, isMuted: level == .muted) + case let .channel(channel): + try await dataStore?.setChannelNotificationLevel(channel.id, level: level) + case let .room(session): + try await dataStore?.setSessionNotificationLevel(session.id, level: level) + } + await notificationService?.updateBadgeCount() + } catch { + // Rollback on failure + updateConversationNotificationLevel(conversation, level: originalLevel) + logger.error("Failed to set notification level: \(error)") } - - /// Toggles between muted and all (for swipe action) - func toggleMute(_ conversation: Conversation) async { - let newLevel: NotificationLevel = conversation.isMuted ? .all : .muted - await setNotificationLevel(conversation, level: newLevel) + } + + /// Toggles between muted and all (for swipe action) + func toggleMute(_ conversation: Conversation) async { + let newLevel: NotificationLevel = conversation.isMuted ? .all : .muted + await setNotificationLevel(conversation, level: newLevel) + } + + /// Updates the notification level in the local conversations array + private func updateConversationNotificationLevel(_ conversation: Conversation, level: NotificationLevel) { + switch conversation { + case let .direct(contact): + if let index = conversations.firstIndex(where: { $0.id == contact.id }) { + conversations[index] = conversations[index].with(isMuted: level == .muted) + } + case let .channel(channel): + if let index = channels.firstIndex(where: { $0.id == channel.id }) { + channels[index] = channels[index].with(notificationLevel: level) + } + case let .room(session): + if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { + roomSessions[index] = roomSessions[index].with(notificationLevel: level) + } } - - /// Updates the notification level in the local conversations array - private func updateConversationNotificationLevel(_ conversation: Conversation, level: NotificationLevel) { - switch conversation { - case .direct(let contact): - if let index = conversations.firstIndex(where: { $0.id == contact.id }) { - conversations[index] = conversations[index].with(isMuted: level == .muted) - } - case .channel(let channel): - if let index = channels.firstIndex(where: { $0.id == channel.id }) { - channels[index] = channels[index].with(notificationLevel: level) - } - case .room(let session): - if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { - roomSessions[index] = roomSessions[index].with(notificationLevel: level) - } - } - recomputeSnapshot() + recomputeSnapshot() + } + + // MARK: - Favorite + + /// Sets favorite state for a conversation with optimistic UI update + func setFavorite(_ conversation: Conversation, isFavorite: Bool) async { + guard connectionStateProvider() == .ready else { return } + guard conversation.isFavorite != isFavorite else { return } + + // Reuse existing toggle logic + await toggleFavorite(conversation) + } + + /// Toggles favorite state for a conversation. + /// + /// For direct messages (contacts), this pushes the change to the device and waits + /// for confirmation before updating the UI. For channels and rooms (app-only), + /// this uses optimistic updates. + /// + /// - Parameters: + /// - conversation: The conversation to toggle + /// - disableAnimation: When true, disables SwiftUI List animations to prevent + /// conflicts with swipe action dismissal animations + func toggleFavorite(_ conversation: Conversation, disableAnimation: Bool = false) async { + guard connectionStateProvider() == .ready else { return } + let originalState = conversation.isFavorite + let newState = !originalState + + switch conversation { + case let .direct(contact): + // Contacts sync with device - wait for confirmation + togglingFavoriteID = contact.id + defer { togglingFavoriteID = nil } + + do { + try await contactService?.setContactFavorite(contact.id, isFavorite: newState) + // Device confirmed - update local UI + applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) + } catch { + logger.error("Failed to toggle contact favorite: \(error)") + } + + case let .channel(channel): + // Channels are app-only - optimistic update + applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) + + do { + try await dataStore?.setChannelFavorite(channel.id, isFavorite: newState) + } catch { + // Rollback on failure + applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) + logger.error("Failed to toggle channel favorite: \(error)") + } + + case let .room(session): + // Rooms are app-only - optimistic update + applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) + + do { + try await dataStore?.setSessionFavorite(session.id, isFavorite: newState) + } catch { + // Rollback on failure + applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) + logger.error("Failed to toggle room favorite: \(error)") + } } - - // MARK: - Favorite - - /// Sets favorite state for a conversation with optimistic UI update - func setFavorite(_ conversation: Conversation, isFavorite: Bool) async { - guard connectionStateProvider() == .ready else { return } - guard conversation.isFavorite != isFavorite else { return } - - // Reuse existing toggle logic - await toggleFavorite(conversation) + } + + private func applyFavoriteUpdate(_ conversation: Conversation, isFavorite: Bool, disableAnimation: Bool) { + if disableAnimation { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + updateConversationFavoriteState(conversation, isFavorite: isFavorite) + } + } else { + updateConversationFavoriteState(conversation, isFavorite: isFavorite) } - - /// Toggles favorite state for a conversation. - /// - /// For direct messages (contacts), this pushes the change to the device and waits - /// for confirmation before updating the UI. For channels and rooms (app-only), - /// this uses optimistic updates. - /// - /// - Parameters: - /// - conversation: The conversation to toggle - /// - disableAnimation: When true, disables SwiftUI List animations to prevent - /// conflicts with swipe action dismissal animations - func toggleFavorite(_ conversation: Conversation, disableAnimation: Bool = false) async { - guard connectionStateProvider() == .ready else { return } - let originalState = conversation.isFavorite - let newState = !originalState - - switch conversation { - case .direct(let contact): - // Contacts sync with device - wait for confirmation - togglingFavoriteID = contact.id - defer { togglingFavoriteID = nil } - - do { - try await contactService?.setContactFavorite(contact.id, isFavorite: newState) - // Device confirmed - update local UI - applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) - } catch { - logger.error("Failed to toggle contact favorite: \(error)") - } - - case .channel(let channel): - // Channels are app-only - optimistic update - applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) - - do { - try await dataStore?.setChannelFavorite(channel.id, isFavorite: newState) - } catch { - // Rollback on failure - applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) - logger.error("Failed to toggle channel favorite: \(error)") - } - - case .room(let session): - // Rooms are app-only - optimistic update - applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) - - do { - try await dataStore?.setSessionFavorite(session.id, isFavorite: newState) - } catch { - // Rollback on failure - applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) - logger.error("Failed to toggle room favorite: \(error)") - } - } + } + + /// Updates the favorite state in the local buffers. `recomputeSnapshot()` runs synchronously + /// after the mutation so it stays inside any `disablesAnimations` transaction the caller opens. + private func updateConversationFavoriteState(_ conversation: Conversation, isFavorite: Bool) { + switch conversation { + case let .direct(contact): + if let index = conversations.firstIndex(where: { $0.id == contact.id }) { + conversations[index] = conversations[index].with(isFavorite: isFavorite) + } + case let .channel(channel): + if let index = channels.firstIndex(where: { $0.id == channel.id }) { + channels[index] = channels[index].with(isFavorite: isFavorite) + } + case let .room(session): + if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { + roomSessions[index] = roomSessions[index].with(isFavorite: isFavorite) + } } - - private func applyFavoriteUpdate(_ conversation: Conversation, isFavorite: Bool, disableAnimation: Bool) { - if disableAnimation { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - updateConversationFavoriteState(conversation, isFavorite: isFavorite) - } - } else { - updateConversationFavoriteState(conversation, isFavorite: isFavorite) - } + recomputeSnapshot() + } + + // MARK: - Conversation List + + /// Clears all conversation data from the view model. + /// Called when the device is forgotten or removed so the list doesn't show stale entries. + func clearConversations() { + conversations = [] + channels = [] + roomSessions = [] + pendingRemovalIDs = [] + deletingIDs = [] + allContacts = [] + nicknamesByLoweredName = [:] + channelSenders = [] + channelSenderNames = [] + channelSenderOrder = [:] + contactNameSet = [] + lastMessageCache = [:] + recomputeSnapshot() + } + + /// True while an optimistic hide or a confirmation-gated radio delete is in flight for + /// `id`. Gates the delete action so a rapid re-tap can't double-fire the same removal. + func isDeletePending(_ id: UUID) -> Bool { + pendingRemovalIDs.contains(id) || deletingIDs.contains(id) + } + + /// Hides a conversation, recording the id in `pendingRemovalIDs` so a racing reload can't + /// resurrect it; `reconcilePendingRemovals()` drops the id once the fetch confirms it's gone. + func removeConversation(_ conversation: Conversation) { + pendingRemovalIDs.insert(conversation.id) + withAnimation(.snappy) { + switch conversation { + case let .direct(contact): + conversations = conversations.filter { $0.id != contact.id } + case let .channel(channel): + channels = channels.filter { $0.id != channel.id } + case let .room(session): + roomSessions = roomSessions.filter { $0.id != session.id } + } + recomputeSnapshot() } - - /// Updates the favorite state in the local buffers. `recomputeSnapshot()` runs synchronously - /// after the mutation so it stays inside any `disablesAnimations` transaction the caller opens. - private func updateConversationFavoriteState(_ conversation: Conversation, isFavorite: Bool) { - switch conversation { - case .direct(let contact): - if let index = conversations.firstIndex(where: { $0.id == contact.id }) { - conversations[index] = conversations[index].with(isFavorite: isFavorite) - } - case .channel(let channel): - if let index = channels.firstIndex(where: { $0.id == channel.id }) { - channels[index] = channels[index].with(isFavorite: isFavorite) - } - case .room(let session): - if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { - roomSessions[index] = roomSessions[index].with(isFavorite: isFavorite) - } + } + + /// Re-admits a row after its delete failed: drops the mask and re-inserts the caller-held DTO. + /// Reusing the held DTO rather than re-fetching keeps the rollback independent of reload timing. + func restoreConversation(_ conversation: Conversation) { + pendingRemovalIDs.remove(conversation.id) + withAnimation(.snappy) { + switch conversation { + case let .direct(contact): + if !conversations.contains(where: { $0.id == contact.id }) { + conversations.append(contact) } - recomputeSnapshot() - } - - // MARK: - Conversation List - - /// Clears all conversation data from the view model. - /// Called when the device is forgotten or removed so the list doesn't show stale entries. - func clearConversations() { - conversations = [] - channels = [] - roomSessions = [] - pendingRemovalIDs = [] - deletingIDs = [] - allContacts = [] - channelSenders = [] - channelSenderNames = [] - channelSenderOrder = [:] - contactNameSet = [] - lastMessageCache = [:] - recomputeSnapshot() - } - - /// True while an optimistic hide or a confirmation-gated radio delete is in flight for - /// `id`. Gates the delete action so a rapid re-tap can't double-fire the same removal. - func isDeletePending(_ id: UUID) -> Bool { - pendingRemovalIDs.contains(id) || deletingIDs.contains(id) - } - - /// Hides a conversation, recording the id in `pendingRemovalIDs` so a racing reload can't - /// resurrect it; `reconcilePendingRemovals()` drops the id once the fetch confirms it's gone. - func removeConversation(_ conversation: Conversation) { - pendingRemovalIDs.insert(conversation.id) - withAnimation(.snappy) { - switch conversation { - case .direct(let contact): - conversations = conversations.filter { $0.id != contact.id } - case .channel(let channel): - channels = channels.filter { $0.id != channel.id } - case .room(let session): - roomSessions = roomSessions.filter { $0.id != session.id } - } - recomputeSnapshot() + case let .channel(channel): + if !channels.contains(where: { $0.id == channel.id }) { + channels.append(channel) } - } - - /// Re-admits a row after its delete failed: drops the mask and re-inserts the caller-held DTO. - /// Reusing the held DTO rather than re-fetching keeps the rollback independent of reload timing. - func restoreConversation(_ conversation: Conversation) { - pendingRemovalIDs.remove(conversation.id) - withAnimation(.snappy) { - switch conversation { - case .direct(let contact): - if !conversations.contains(where: { $0.id == contact.id }) { - conversations.append(contact) - } - case .channel(let channel): - if !channels.contains(where: { $0.id == channel.id }) { - channels.append(channel) - } - case .room(let session): - if !roomSessions.contains(where: { $0.id == session.id }) { - roomSessions.append(session) - } - } - recomputeSnapshot() + case let .room(session): + if !roomSessions.contains(where: { $0.id == session.id }) { + roomSessions.append(session) } + } + recomputeSnapshot() } - - /// Confirms a direct conversation's local clear: drops the mask and purges the fetch buffer - /// together. Purging matters because a stale reload could have re-added the contact while it - /// was masked, and a later recompute would then republish it; a re-created row still returns - /// via the next reload's fetch. - func confirmDirectRemoval(_ contact: ContactDTO) { - pendingRemovalIDs.remove(contact.id) - conversations.removeAll { $0.id == contact.id } - recomputeSnapshot() + } + + /// Confirms a direct conversation's local clear: drops the mask and purges the fetch buffer + /// together. Purging matters because a stale reload could have re-added the contact while it + /// was masked, and a later recompute would then republish it; a re-created row still returns + /// via the next reload's fetch. + func confirmDirectRemoval(_ contact: ContactDTO) { + pendingRemovalIDs.remove(contact.id) + conversations.removeAll { $0.id == contact.id } + recomputeSnapshot() + } + + /// Load conversations for a device + func loadConversations(radioID: UUID) async { + guard let dataStore else { return } + + isLoading = true + errorBannerMessage = nil + + do { + conversations = try await dataStore.fetchConversations(radioID: radioID) + recomputeSnapshot() + } catch { + errorBannerMessage = L10n.Chats.Chats.Error.loadConversationsFailed + logger.error("loadConversations failed: \(error.localizedDescription)") } - /// Load conversations for a device - func loadConversations(radioID: UUID) async { - guard let dataStore else { return } + hasLoadedOnce = true + isLoading = false + } - isLoading = true - errorBannerMessage = nil - - do { - conversations = try await dataStore.fetchConversations(radioID: radioID) - recomputeSnapshot() - } catch { - errorBannerMessage = L10n.Chats.Chats.Error.loadConversationsFailed - logger.error("loadConversations failed: \(error.localizedDescription)") - } + /// Load all contacts for mention autocomplete + func loadAllContacts(radioID: UUID) async { + guard let dataStore else { return } - hasLoadedOnce = true - isLoading = false + do { + allContacts = try await dataStore.fetchContacts(radioID: radioID) + contactNameSet = Set(allContacts.map(\.name)) + nicknamesByLoweredName = MessageBubbleConfiguration.buildNicknameLookup(from: allContacts) + } catch { + logger.warning("Failed to load contacts for mentions: \(error.localizedDescription)") } + } - /// Load all contacts for mention autocomplete - func loadAllContacts(radioID: UUID) async { - guard let dataStore else { return } + /// Load channels for a device + func loadChannels(radioID: UUID) async { + guard let dataStore else { return } - do { - allContacts = try await dataStore.fetchContacts(radioID: radioID) - contactNameSet = Set(allContacts.map(\.name)) - } catch { - logger.warning("Failed to load contacts for mentions: \(error.localizedDescription)") - } + do { + channels = try await dataStore.fetchChannels(radioID: radioID) + recomputeSnapshot() + } catch { + // Silently handle - channels are optional } - - /// Load channels for a device - func loadChannels(radioID: UUID) async { - guard let dataStore else { return } - - do { - channels = try await dataStore.fetchChannels(radioID: radioID) - recomputeSnapshot() - } catch { - // Silently handle - channels are optional - } + } + + /// Single entry point for every list reload. Cancel-and-replaces any in-flight reload so the + /// latest request wins and a superseded one returns at an `isCancelled` gate before committing. + /// Returns the new task so a caller that needs ordering (initial load) can await `.value`. + @discardableResult + func requestConversationReload() -> Task? { + reloadTask?.cancel() + guard let radioID = currentRadioIDProvider() else { + reloadTask = nil + clearConversations() + return nil } - - /// Single entry point for every list reload. Cancel-and-replaces any in-flight reload so the - /// latest request wins and a superseded one returns at an `isCancelled` gate before committing. - /// Returns the new task so a caller that needs ordering (initial load) can await `.value`. - @discardableResult - func requestConversationReload() -> Task? { - reloadTask?.cancel() - guard let radioID = currentRadioIDProvider() else { - reloadTask = nil - clearConversations() - return nil - } - let task = Task { @MainActor [weak self] in - guard let self else { return } - await self.performConversationReload(radioID: radioID) - } - reloadTask = task - return task + let task = Task { @MainActor [weak self] in + guard let self else { return } + await performConversationReload(radioID: radioID) } - - /// Fetches contacts, channels, and rooms into locals, then commits one consistent snapshot. - /// No `await` may sit between the last `isCancelled` check and the assignment, so no other - /// reload can interleave a mismatched commit on the main actor. - private func performConversationReload(radioID: UUID) async { - guard let dataStore else { return } - isLoading = true - defer { isLoading = false } - - // Only fetchConversations sets the error banner; channel/room failures stay silent. - var banner: String? - let fetchedConversations: [ContactDTO]? - do { - fetchedConversations = try await dataStore.fetchConversations(radioID: radioID) - } catch { - fetchedConversations = nil - banner = L10n.Chats.Chats.Error.loadConversationsFailed - logger.error("performConversationReload fetchConversations failed: \(error.localizedDescription)") - } - if Task.isCancelled { return } - #if DEBUG - await reloadInterleaveHook?() - if Task.isCancelled { return } - #endif - - let fetchedChannels = try? await dataStore.fetchChannels(radioID: radioID) - if Task.isCancelled { return } - let fetchedRooms = (try? await dataStore.fetchRemoteNodeSessions(radioID: radioID))? - .filter { $0.isRoom } - if Task.isCancelled { return } - - if let fetchedConversations { conversations = fetchedConversations } - if let fetchedChannels { channels = fetchedChannels } - if let fetchedRooms { roomSessions = fetchedRooms } - errorBannerMessage = banner - reconcilePendingRemovals() - recomputeSnapshot() - hasLoadedOnce = true - - // Skip the trailing preview load if this reload was superseded. - if Task.isCancelled { return } - await loadLastMessagePreviews() + reloadTask = task + return task + } + + /// Fetches contacts, channels, and rooms into locals, then commits one consistent snapshot. + /// No `await` may sit between the last `isCancelled` check and the assignment, so no other + /// reload can interleave a mismatched commit on the main actor. + private func performConversationReload(radioID: UUID) async { + guard let dataStore else { return } + isLoading = true + defer { isLoading = false } + + // Only fetchConversations sets the error banner; channel/room failures stay silent. + var banner: String? + let fetchedConversations: [ContactDTO]? + do { + fetchedConversations = try await dataStore.fetchConversations(radioID: radioID) + } catch { + fetchedConversations = nil + banner = L10n.Chats.Chats.Error.loadConversationsFailed + logger.error("performConversationReload fetchConversations failed: \(error.localizedDescription)") } - - // MARK: - Messages - - /// Load messages for a contact - func loadMessages(for contact: ContactDTO) async { - // Close the per-conversation empty-state gate while the fetch is - // in flight. No-op when the coordinator is already past - // `.uninitialized` (warm rebind, refresh). - coordinator?.beginLoading() - - guard let dataStore else { - coordinator?.markLoaded() - return - } - - // Clear preview state only when switching to a different conversation - if currentContact?.id != contact.id { - clearPreviewState() - newMessagesDividerMessageID = nil - dividerComputed = false - } - - currentContact = contact - currentChannel = nil - - // Track active conversation for notification suppression - notificationService?.setActiveConversation(contactID: contact.id) - - isLoading = true - // Dual-reset: this function is shared between passive load and user-initiated - // retry paths, so both surfaces must clear at entry to avoid stale state. - errorMessage = nil - errorBannerMessage = nil - - // Reset pagination state for new conversation - coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } - - do { - var fetchedMessages = try await dataStore.fetchMessages(contactID: contact.id, limit: ChatCoordinator.pageSize, offset: 0) - let unfilteredCount = fetchedMessages.count - coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } - - // Compute divider position before filtering, using unfiltered array - computeDividerPosition(from: fetchedMessages, unreadCount: contact.unreadCount, isDM: true) - - // Hide sent reaction messages (unless failed) - fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: true) - - // Use unfiltered count to determine if more messages exist - coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } - coordinator?.replaceAll(fetchedMessages) - - buildItems() - - // Index loaded messages for reaction matching and process any pending reactions - if let reactionService = reactionServiceProvider() { - await indexMessagesForReactions( - fetchedMessages, - scope: .direct(contact), - reactionService: reactionService, - dataStore: dataStore - ) - } - - // Clear unread count and mention badge, then notify UI to refresh chat list. - // The messages already rendered, so a bookkeeping failure here is logged - // rather than surfaced as a load error. - do { - try await dataStore.clearUnreadCount(contactID: contact.id) - try await dataStore.clearUnreadMentionCount(contactID: contact.id) - } catch { - logger.warning("loadMessages: failed to clear unread counts - \(error.localizedDescription)") - } - syncCoordinator?.notifyConversationsChanged() - - // Update app badge - await notificationService?.updateBadgeCount() - } catch is CancellationError { - // Benign cancellation; the superseding load will refetch. - } catch { - errorMessage = error.userFacingMessage - } - - // Ensures the empty-state gate opens even when the fetch threw — - // `replaceAll` is the success path; this catches the failure path. - coordinator?.markLoaded() - isLoading = false + if Task.isCancelled { return } + #if DEBUG + await reloadInterleaveHook?() + if Task.isCancelled { return } + #endif + + let fetchedChannels = try? await dataStore.fetchChannels(radioID: radioID) + if Task.isCancelled { return } + let fetchedRooms = await (try? dataStore.fetchRemoteNodeSessions(radioID: radioID))? + .filter(\.isRoom) + if Task.isCancelled { return } + + if let fetchedConversations { conversations = fetchedConversations } + if let fetchedChannels { channels = fetchedChannels } + if let fetchedRooms { roomSessions = fetchedRooms } + errorBannerMessage = banner + reconcilePendingRemovals() + recomputeSnapshot() + hasLoadedOnce = true + + // Skip the trailing preview load if this reload was superseded. + if Task.isCancelled { return } + await loadLastMessagePreviews() + } + + // MARK: - Messages + + /// Load messages for a contact + func loadMessages(for contact: ContactDTO) async { + // Close the per-conversation empty-state gate while the fetch is + // in flight. No-op when the coordinator is already past + // `.uninitialized` (warm rebind, refresh). + coordinator?.beginLoading() + + guard let dataStore else { + coordinator?.markLoaded() + return } - /// Load any saved draft for the current contact - /// Drafts are consumed (removed) after loading to prevent re-display - /// If no draft exists, this method does nothing - func loadDraftIfExists() { - guard let contact = currentContact, - let notificationService, - let draft = notificationService.consumeDraft(for: contact.id) else { - return - } - composingText = draft + // Clear preview state only when switching to a different conversation + if currentContact?.id != contact.id { + clearPreviewState() + newMessagesDividerMessageID = nil + dividerComputed = false } - /// Restores the composer for `id` on conversation entry, applying restore sources in a - /// fixed priority order so the precedence can't be reversed by reordering at the call site: - /// a pending notification quick-reply draft (assigned unconditionally) wins over an older - /// persisted disk draft (applied only when the field is still empty). - func restoreComposerDraft(from store: DraftStore, id: ChatConversationID) { - loadDraftIfExists() - loadDraft(from: store, id: id) + currentContact = contact + currentChannel = nil + + // Track active conversation for notification suppression + notificationService?.setActiveConversation(contactID: contact.id) + + isLoading = true + // Dual-reset: this function is shared between passive load and user-initiated + // retry paths, so both surfaces must clear at entry to avoid stale state. + errorMessage = nil + errorBannerMessage = nil + + // Reset pagination state for new conversation + coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } + + do { + var fetchedMessages = try await dataStore.fetchMessages(contactID: contact.id, limit: ChatCoordinator.pageSize, offset: 0) + let unfilteredCount = fetchedMessages.count + coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } + + // Compute divider position before filtering, using unfiltered array + computeDividerPosition(from: fetchedMessages, unreadCount: contact.unreadCount, isDM: true) + + // Hide sent reaction messages (unless failed) + fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: true) + + // Use unfiltered count to determine if more messages exist + coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } + coordinator?.replaceAll(fetchedMessages) + + buildItems() + + // Index loaded messages for reaction matching and process any pending reactions + if let reactionService = reactionServiceProvider() { + await indexMessagesForReactions( + fetchedMessages, + scope: .direct(contact), + reactionService: reactionService, + dataStore: dataStore + ) + } + + // Clear unread count and mention badge, then notify UI to refresh chat list. + // The messages already rendered, so a bookkeeping failure here is logged + // rather than surfaced as a load error. + do { + try await dataStore.clearUnreadCount(contactID: contact.id) + try await dataStore.clearUnreadMentionCount(contactID: contact.id) + } catch { + logger.warning("loadMessages: failed to clear unread counts - \(error.localizedDescription)") + } + syncCoordinator?.notifyConversationsChanged() + + // Update app badge + await notificationService?.updateBadgeCount() + } catch is CancellationError { + // Benign cancellation; the superseding load will refetch. + } catch { + errorMessage = error.userFacingMessage } - /// Restores the persisted composer draft for `id`, but only when the field is empty — so a - /// reconnect-driven reload can't clobber in-progress text and a quick-reply draft applied - /// first keeps precedence. The store is passed in from the view's environment, never a - /// stored dependency, so a stale configure can't silently skip the restore. - func loadDraft(from store: DraftStore, id: ChatConversationID) { - if let restored = store.draftToApply(over: composingText, for: id) { - composingText = restored - } + // Ensures the empty-state gate opens even when the fetch threw — + // `replaceAll` is the success path; this catches the failure path. + coordinator?.markLoaded() + isLoading = false + } + + /// Load any saved draft for the current contact + /// Drafts are consumed (removed) after loading to prevent re-display + /// If no draft exists, this method does nothing + func loadDraftIfExists() { + guard let contact = currentContact, + let notificationService, + let draft = notificationService.consumeDraft(for: contact.id) else { + return } - - /// Persists the current composer text as the draft for `id`, or removes it when empty. - func saveDraft(to store: DraftStore, id: ChatConversationID) { - store.setDraft(composingText, for: id) + composingText = draft + } + + /// Restores the composer for `id` on conversation entry, applying restore sources in a + /// fixed priority order so the precedence can't be reversed by reordering at the call site: + /// a pending notification quick-reply draft (assigned unconditionally) wins over an older + /// persisted disk draft (applied only when the field is still empty). + func restoreComposerDraft(from store: DraftStore, id: ChatConversationID) { + loadDraftIfExists() + loadDraft(from: store, id: id) + } + + /// Restores the persisted composer draft for `id`, but only when the field is empty — so a + /// reconnect-driven reload can't clobber in-progress text and a quick-reply draft applied + /// first keeps precedence. The store is passed in from the view's environment, never a + /// stored dependency, so a stale configure can't silently skip the restore. + func loadDraft(from store: DraftStore, id: ChatConversationID) { + if let restored = store.draftToApply(over: composingText, for: id) { + composingText = restored } - - /// Send a message to the current contact - /// This is non-blocking - message is created and shown immediately, sent in background - func sendMessage(text: String) async { - guard let contact = currentContact, - let messageService, - !text.isEmpty else { - return - } - - errorMessage = nil - - let message: MessageDTO - do { - message = try await messageService.createPendingMessage(text: text, to: contact) - appendMessageIfNew(message) - schedulePrefetchForOutgoingMessage(message, isChannelMessage: false) - syncCoordinator?.notifyConversationsChanged() - } catch { - errorMessage = error.userFacingMessage - return - } - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id) - do { - try await enqueueDM(envelope) - } catch { - logger.error("enqueueDM failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) - } + } + + /// Persists the current composer text as the draft for `id`, or removes it when empty. + func saveDraft(to store: DraftStore, id: ChatConversationID) { + store.setDraft(composingText, for: id) + } + + /// Send a message to the current contact + /// This is non-blocking - message is created and shown immediately, sent in background + func sendMessage(text: String) async { + guard let contact = currentContact, + let messageService, + !text.isEmpty else { + return } - /// Refresh messages for current contact - func refreshMessages() async { - guard let contact = currentContact else { return } - await loadMessages(for: contact) + errorMessage = nil + + let message: MessageDTO + do { + message = try await messageService.createPendingMessage(text: text, to: contact) + appendMessageIfNew(message) + schedulePrefetchForOutgoingMessage(message, isChannelMessage: false) + syncCoordinator?.notifyConversationsChanged() + } catch { + errorMessage = error.userFacingMessage + return } - // MARK: - Message Previews - - /// Get the cached last-message preview text for a conversation, keyed by its id. - func lastMessagePreview(id: UUID) -> String? { - lastMessageCache[id]?.text + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id) + do { + try await enqueueDM(envelope) + } catch { + logger.error("enqueueDM failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) + coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) } - - /// Load last message previews for all conversations. - /// Uses batch fetch methods to minimize actor hops (2 hops instead of N). - func loadLastMessagePreviews() async { - guard let dataStore else { return } - - // Batch fetch contact message previews (single actor hop) - if !conversations.isEmpty { - do { - let contactMessages = try await dataStore.fetchLastMessages(contactIDs: conversations.map(\.id), limit: 10) - for contact in conversations { - // Find the last non-reaction message (skip outgoing reactions unless failed) - let lastMessage = contactMessages[contact.id]?.last { message in - guard message.direction == .outgoing, - ReactionParser.parseDM(message.text) != nil else { - return true - } - return message.status == .failed - } - - // Evict the cached preview only when no messages remain, so a cleared DM - // (still listed via lastMessageDate) shows "No messages"; a contact whose - // recent messages are all filtered-out reactions keeps its prior preview. - if let lastMessage { - lastMessageCache[contact.id] = lastMessage - } else if contactMessages[contact.id]?.isEmpty ?? true { - lastMessageCache.removeValue(forKey: contact.id) - } - } - } catch { - logger.warning("Failed to load contact message previews: \(error)") + } + + /// Refresh messages for current contact + func refreshMessages() async { + guard let contact = currentContact else { return } + await loadMessages(for: contact) + } + + // MARK: - Message Previews + + /// Get the cached last-message preview text for a conversation, keyed by its id. + func lastMessagePreview(id: UUID) -> String? { + lastMessageCache[id]?.text + } + + /// Load last message previews for all conversations. + /// Uses batch fetch methods to minimize actor hops (2 hops instead of N). + func loadLastMessagePreviews() async { + guard let dataStore else { return } + + // Batch fetch contact message previews (single actor hop) + if !conversations.isEmpty { + do { + let contactMessages = try await dataStore.fetchLastMessages(contactIDs: conversations.map(\.id), limit: 10) + for contact in conversations { + // Find the last non-reaction message (skip outgoing reactions unless failed) + let lastMessage = contactMessages[contact.id]?.last { message in + guard message.direction == .outgoing, + ReactionParser.parseDM(message.text) != nil else { + return true } + return message.status == .failed + } + + // Evict the cached preview only when no messages remain, so a cleared DM + // (still listed via lastMessageDate) shows "No messages"; a contact whose + // recent messages are all filtered-out reactions keeps its prior preview. + if let lastMessage { + lastMessageCache[contact.id] = lastMessage + } else if contactMessages[contact.id]?.isEmpty ?? true { + lastMessageCache.removeValue(forKey: contact.id) + } } + } catch { + logger.warning("Failed to load contact message previews: \(error)") + } + } - // Batch fetch channel message previews (single actor hop) - if !channels.isEmpty { - do { - let channelParams = channels.map { (radioID: $0.radioID, channelIndex: $0.index, id: $0.id) } - let channelMessages = try await dataStore.fetchLastChannelMessages(channels: channelParams, limit: 20) - for channel in channels { - guard let messages = channelMessages[channel.id] else { continue } - - // Filter out outgoing reactions (keep failed ones visible) - let lastMessage = messages.last { message in - if message.direction == .outgoing, - ReactionParser.parse(message.text) != nil, - message.status != .failed { - return false - } - return true - } - - if let lastMessage { - lastMessageCache[channel.id] = lastMessage - } else { - lastMessageCache.removeValue(forKey: channel.id) - } - } - } catch { - logger.warning("Failed to load channel message previews: \(error)") + // Batch fetch channel message previews (single actor hop) + if !channels.isEmpty { + do { + let channelParams = channels.map { (radioID: $0.radioID, channelIndex: $0.index, id: $0.id) } + let channelMessages = try await dataStore.fetchLastChannelMessages(channels: channelParams, limit: 20) + for channel in channels { + guard let messages = channelMessages[channel.id] else { continue } + + // Filter out outgoing reactions (keep failed ones visible) + let lastMessage = messages.last { message in + if message.direction == .outgoing, + ReactionParser.parse(message.text) != nil, + message.status != .failed { + return false } + return true + } + + if let lastMessage { + lastMessageCache[channel.id] = lastMessage + } else { + lastMessageCache.removeValue(forKey: channel.id) + } } + } catch { + logger.warning("Failed to load channel message previews: \(error)") + } } - + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift index a49900e1..5722db47 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift @@ -1,115 +1,114 @@ -import SwiftUI import MC1Services +import SwiftUI extension ChatViewModel { - - // MARK: - Pagination - - /// Load older messages when user scrolls near the top - func loadOlderMessages() async { - // Guard against duplicate fetches and end of history - guard !isLoadingOlder, hasMoreMessages else { return } - guard let dataStore else { return } - - coordinator?.updateRenderState { $0.with(isLoadingOlder: true) } - - // Snapshot conversation context before any await — actor reentrancy - // means currentContact/currentChannel can change during suspensions - let contact = currentContact - let channel = currentChannel - - do { - let currentOffset = totalFetchedCount - var olderMessages: [MessageDTO] - - if let contact { - olderMessages = try await dataStore.fetchMessages( - contactID: contact.id, - limit: ChatCoordinator.pageSize, - offset: currentOffset - ) - } else if let channel { - olderMessages = try await dataStore.fetchMessages( - radioID: channel.radioID, - channelIndex: channel.index, - limit: ChatCoordinator.pageSize, - offset: currentOffset - ) - } else { - coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } - return - } - - // Use unfiltered count to determine if more messages exist - let unfilteredCount = olderMessages.count - coordinator?.updateRenderState { current in - current.with( - hasMoreMessages: unfilteredCount < ChatCoordinator.pageSize ? false : current.hasMoreMessages, - totalFetchedCount: current.totalFetchedCount + unfilteredCount - ) - } - - // Hide sent reaction messages (unless failed) - let isDM = contact != nil - olderMessages = filterOutgoingReactionMessages(olderMessages, isDM: isDM) - - // Filter out messages already in array (race condition: appendMessageIfNew can add - // a message while this fetch is in-flight, causing duplicates) - let existingIDs = Set(messages.map(\.id)) - olderMessages = olderMessages.filter { !existingIDs.contains($0.id) } - - // Prepend older messages (they're chronologically earlier). - // Re-run same-sender reordering across the page boundary to handle - // clusters that were split between the existing and newly loaded pages. - coordinator?.prepend(olderMessages) - let reordered = MessageDTO.reorderSameSenderClusters(messages) - coordinator?.replaceMessagesPreservingByID(reordered) - - // Register senders from the older page; without this, scrolling - // back to a sender who only appears in older pages leaves them - // missing from the @-autocomplete list. - if let channel { - for message in olderMessages { - if let senderName = message.senderNodeName { - addChannelSenderIfNew(senderName, radioID: channel.radioID, timestamp: message.timestamp) - } - } - } - - buildItems() - - // Clear the spinner now that the prepended messages are visible. - // Reaction indexing below awaits the actor and can take many - // hundreds of milliseconds on a busy channel; gating the spinner - // through it leaves pagination feeling locked-up. - coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } - - // Index older channel messages for reaction matching and process pending reactions - if let channel, - let reactionService = reactionServiceProvider() { - await indexMessagesForReactions( - olderMessages, - scope: .channel(channel, localNodeName: connectedDeviceProvider()?.nodeName), - reactionService: reactionService, - dataStore: dataStore - ) - } - - // Index older DM messages for reaction matching and process pending reactions - if let contact, - let reactionService = reactionServiceProvider() { - await indexMessagesForReactions( - olderMessages, - scope: .direct(contact), - reactionService: reactionService, - dataStore: dataStore - ) - } - - } catch { - coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } - errorBannerMessage = L10n.Chats.Chats.Error.loadOlderMessagesFailed - logger.error("Failed to load older messages: \(error)") + // MARK: - Pagination + + /// Load older messages when user scrolls near the top + func loadOlderMessages() async { + // Guard against duplicate fetches and end of history + guard !isLoadingOlder, hasMoreMessages else { return } + guard let dataStore else { return } + + coordinator?.updateRenderState { $0.with(isLoadingOlder: true) } + + // Snapshot conversation context before any await — actor reentrancy + // means currentContact/currentChannel can change during suspensions + let contact = currentContact + let channel = currentChannel + + do { + let currentOffset = totalFetchedCount + var olderMessages: [MessageDTO] + + if let contact { + olderMessages = try await dataStore.fetchMessages( + contactID: contact.id, + limit: ChatCoordinator.pageSize, + offset: currentOffset + ) + } else if let channel { + olderMessages = try await dataStore.fetchMessages( + radioID: channel.radioID, + channelIndex: channel.index, + limit: ChatCoordinator.pageSize, + offset: currentOffset + ) + } else { + coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } + return + } + + // Use unfiltered count to determine if more messages exist + let unfilteredCount = olderMessages.count + coordinator?.updateRenderState { current in + current.with( + hasMoreMessages: unfilteredCount < ChatCoordinator.pageSize ? false : current.hasMoreMessages, + totalFetchedCount: current.totalFetchedCount + unfilteredCount + ) + } + + // Hide sent reaction messages (unless failed) + let isDM = contact != nil + olderMessages = filterOutgoingReactionMessages(olderMessages, isDM: isDM) + + // Filter out messages already in array (race condition: appendMessageIfNew can add + // a message while this fetch is in-flight, causing duplicates) + let existingIDs = Set(messages.map(\.id)) + olderMessages = olderMessages.filter { !existingIDs.contains($0.id) } + + // Prepend older messages (they're chronologically earlier). + // Re-run same-sender reordering across the page boundary to handle + // clusters that were split between the existing and newly loaded pages. + coordinator?.prepend(olderMessages) + let reordered = MessageDTO.reorderSameSenderClusters(messages) + coordinator?.replaceMessagesPreservingByID(reordered) + + // Register senders from the older page; without this, scrolling + // back to a sender who only appears in older pages leaves them + // missing from the @-autocomplete list. + if let channel { + for message in olderMessages { + if let senderName = message.senderNodeName { + addChannelSenderIfNew(senderName, radioID: channel.radioID, timestamp: message.timestamp) + } } + } + + buildItems() + + // Clear the spinner now that the prepended messages are visible. + // Reaction indexing below awaits the actor and can take many + // hundreds of milliseconds on a busy channel; gating the spinner + // through it leaves pagination feeling locked-up. + coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } + + // Index older channel messages for reaction matching and process pending reactions + if let channel, + let reactionService = reactionServiceProvider() { + await indexMessagesForReactions( + olderMessages, + scope: .channel(channel, localNodeName: connectedDeviceProvider()?.nodeName), + reactionService: reactionService, + dataStore: dataStore + ) + } + + // Index older DM messages for reaction matching and process pending reactions + if let contact, + let reactionService = reactionServiceProvider() { + await indexMessagesForReactions( + olderMessages, + scope: .direct(contact), + reactionService: reactionService, + dataStore: dataStore + ) + } + + } catch { + coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } + errorBannerMessage = L10n.Chats.Chats.Error.loadOlderMessagesFailed + logger.error("Failed to load older messages: \(error)") } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift index a40c56da..6a2d70e9 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift @@ -1,472 +1,554 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services extension ChatViewModel { - - // MARK: - Receive-Time Prefetch - - /// Default maximum time the receive pipeline waits for a prefetch to - /// resolve before admitting the bubble at its text-only size. After - /// this, the bubble may still morph in later when the background - /// fetch lands; see `handleDimensionResolution(_:)`. - static let defaultPrefetchTimeout: Duration = .seconds(3) - - /// Admit an incoming message to the display-items array after racing - /// its URL prefetches against `prefetchTimeout` (default: 3s). - /// Messages without URLs admit immediately. Outgoing messages bypass - /// this and use the instant-render path in the send methods. - func admitIncomingMessage(_ message: MessageDTO, isChannelMessage: Bool) async { - guard let prefetcher else { - appendMessageIfNew(message) - return - } - guard !LinkPreviewService.extractAllURLs(in: message.text).isEmpty else { - appendMessageIfNew(message) - return - } - - let text = message.text - let timeout = prefetchTimeout - await withTaskGroup(of: Void.self) { group in - group.addTask { - await prefetcher.prefetch(urlsIn: text, isChannelMessage: isChannelMessage) - } - group.addTask { - try? await Task.sleep(for: timeout) - } - for await _ in group { - group.cancelAll() - break - } - } - - appendMessageIfNew(message) + // MARK: - Receive-Time Prefetch + + /// Default maximum time the receive pipeline waits for a prefetch to + /// resolve before admitting the bubble at its text-only size. After + /// this, the bubble may still morph in later when the background + /// fetch lands; see `handleDimensionResolution(_:)`. + static let defaultPrefetchTimeout: Duration = .seconds(3) + + /// Admit an incoming message to the display-items array after racing + /// its URL prefetches against `prefetchTimeout` (default: 3s). + /// Messages without URLs admit immediately. Outgoing messages bypass + /// this and use the instant-render path in the send methods. + func admitIncomingMessage(_ message: MessageDTO, isChannelMessage: Bool) async { + guard let prefetcher else { + appendMessageIfNew(message) + return } - - /// Fan-out hook for the inline image dimensions resolution stream. - /// Triggered when a probe lands after a message has already been admitted — - /// either because the receive-time prefetch hit its timeout, the user - /// retried, or the message arrived during background sync. Every message - /// whose body contains the resolved URL is rebuilt so the bubble picks up - /// the now-known `cachedAspect`. - func handleDimensionResolution(_ url: URL) async { - guard let coordinator else { return } - let target = url.absoluteString - let affected = coordinator.messages - .filter { $0.text.contains(target) } - .map(\.id) - for messageID in affected { - rebuildDisplayItem(for: messageID) - } + // Master off: no fragment is built, so the prefetcher's card-branch cache + // lookups would be pure waste per received message. Skipping also drops the + // 3s admission race for these messages. `LinkPreviewCache.preview` + // self-gates regardless, so this is an efficiency guard, not the privacy gate. + guard envInputs.previewsEnabled, + !LinkPreviewService.extractAllURLs(in: message.text).isEmpty else { + appendMessageIfNew(message) + return } - /// A snapshot finished: rebuild only the rows that show it, found via the - /// request-keyed index (O(matches)), never by scanning every loaded message. - func handleSnapshotResolution(_ request: MapSnapshotRequest) { - guard let messageIDs = mapPreviewRequestIndex[request] else { return } - for messageID in messageIDs { - rebuildDisplayItem(for: messageID) - } + let text = message.text + let timeout = prefetchTimeout + let allowImageProbes = linkPreviewPreferences.shouldAutoResolve(isChannelMessage: isChannelMessage) + await withTaskGroup(of: Void.self) { group in + group.addTask { + await prefetcher.prefetch( + urlsIn: text, + isChannelMessage: isChannelMessage, + allowImageProbes: allowImageProbes + ) + } + group.addTask { + try? await Task.sleep(for: timeout) + } + for await _ in group { + group.cancelAll() + break + } } - /// Outgoing-message exception to the withhold-and-release rule: the - /// bubble was added immediately at text-only height for instant send - /// feedback, so the prefetch runs in parallel and the cell height - /// morphs in via `rebuildDisplayItem` once the prefetch lands. The - /// `.easeOut(0.25)` cross-fade lives at the view layer. - func schedulePrefetchForOutgoingMessage(_ message: MessageDTO, isChannelMessage: Bool) { - guard let prefetcher else { return } - guard !LinkPreviewService.extractAllURLs(in: message.text).isEmpty else { return } - let messageID = message.id - let text = message.text - Task { [weak self] in - await prefetcher.prefetch(urlsIn: text, isChannelMessage: isChannelMessage) - self?.rebuildDisplayItem(for: messageID) - } + appendMessageIfNew(message) + } + + /// Fan-out hook for the inline image dimensions resolution stream. + /// Triggered when a probe lands after a message has already been admitted — + /// either because the receive-time prefetch hit its timeout, the user + /// retried, or the message arrived during background sync. Every message + /// whose body contains the resolved URL is rebuilt so the bubble picks up + /// the now-known `cachedAspect`. + func handleDimensionResolution(_ url: URL) async { + guard let coordinator else { return } + let target = url.absoluteString + let affected = coordinator.messages + .filter { $0.text.contains(target) } + .map(\.id) + for messageID in affected { + rebuildDisplayItem(for: messageID) } - - // MARK: - Preview State Management - - /// Request preview fetch for a message (called when cell becomes visible) - func requestPreviewFetch(for messageID: UUID) { - guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } - guard let url = cachedURLs[messageID].flatMap({ $0 }) else { return } - - let isChannel = currentChannel != nil - - previewFetchTasks[messageID] = Task { - await fetchPreview(for: messageID, url: url, isChannelMessage: isChannel) - } + } + + /// A snapshot finished: rebuild only the rows that show it, found via the + /// request-keyed index (O(matches)), never by scanning every loaded message. + func handleSnapshotResolution(_ request: MapSnapshotRequest) { + guard let messageIDs = mapPreviewRequestIndex[request] else { return } + for messageID in messageIDs { + rebuildDisplayItem(for: messageID) } + } + + /// Outgoing-message exception to the withhold-and-release rule: the + /// bubble was added immediately at text-only height for instant send + /// feedback, so the prefetch runs in parallel and the cell height + /// morphs in via `rebuildDisplayItem` once the prefetch lands. The + /// `.easeOut(0.25)` cross-fade lives at the view layer. + func schedulePrefetchForOutgoingMessage(_ message: MessageDTO, isChannelMessage: Bool) { + guard let prefetcher else { return } + // Master off: nothing to prefetch (see `admitIncomingMessage`). + guard envInputs.previewsEnabled, + !LinkPreviewService.extractAllURLs(in: message.text).isEmpty else { return } + let messageID = message.id + let text = message.text + let allowImageProbes = linkPreviewPreferences.shouldAutoResolve(isChannelMessage: isChannelMessage) + Task { [weak self] in + await prefetcher.prefetch( + urlsIn: text, + isChannelMessage: isChannelMessage, + allowImageProbes: allowImageProbes + ) + self?.rebuildDisplayItem(for: messageID) + } + } - /// Fetch preview for a message and update state - private func fetchPreview(for messageID: UUID, url: URL, isChannelMessage: Bool) async { - guard let dataStore, let linkPreviewCache else { return } - - // Check malware domain blocklist before fetching - if let host = url.host(), await MalwareDomainFilter.shared.isBlocked(host) { - previewStates[messageID] = .malwareWarning - rebuildDisplayItem(for: messageID) - return - } - - // Update to loading state - previewStates[messageID] = .loading - rebuildDisplayItem(for: messageID) - - // Get preview from cache (handles all tiers: memory, database, network) - let result = await linkPreviewCache.preview( - for: url, - using: dataStore, - isChannelMessage: isChannelMessage - ) - - // Check if task was cancelled (message scrolled away or conversation changed) - guard !Task.isCancelled else { - previewFetchTasks.removeValue(forKey: messageID) - return - } - - // Update state based on result - switch result { - case .loaded(let dto): - await decodeAndStorePreviewImages(from: dto, for: messageID) - previewStates[messageID] = .loaded - loadedPreviews[messageID] = dto - - case .loading: - // Still loading (duplicate request), keep current state - break + // MARK: - Preview State Management - case .noPreviewAvailable, .failed: - previewStates[messageID] = .noPreview + /// Request preview fetch for a message (called when cell becomes visible) + func requestPreviewFetch(for messageID: UUID) { + guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } + guard let url = cachedURLs[messageID].flatMap(\.self) else { return } - case .disabled: - previewStates[messageID] = .disabled - } + let isChannel = currentChannel != nil - previewFetchTasks.removeValue(forKey: messageID) - rebuildDisplayItem(for: messageID) + previewFetchTasks[messageID] = Task { + await fetchPreview(for: messageID, url: url, isChannelMessage: isChannel) } + } - /// Manually fetch preview (for tap-to-load when previews disabled) - func manualFetchPreview(for messageID: UUID) async { - guard let url = cachedURLs[messageID].flatMap({ $0 }), - let dataStore, - let linkPreviewCache else { return } - - previewStates[messageID] = .loading - rebuildDisplayItem(for: messageID) - - let result = await linkPreviewCache.manualFetch(for: url, using: dataStore) - - switch result { - case .loaded(let dto): - await decodeAndStorePreviewImages(from: dto, for: messageID) - previewStates[messageID] = .loaded - loadedPreviews[messageID] = dto - case .loading: - break - case .noPreviewAvailable, .failed, .disabled: - previewStates[messageID] = .noPreview - } + /// Fetch preview for a message and update state + private func fetchPreview(for messageID: UUID, url: URL, isChannelMessage: Bool) async { + guard let dataStore, let linkPreviewCache else { return } - rebuildDisplayItem(for: messageID) + // Check malware domain blocklist before fetching + if let host = url.host(), await MalwareDomainFilter.shared.isBlocked(host) { + previewStates[messageID] = .malwareWarning + rebuildDisplayItem(for: messageID) + return } - /// Decode preview hero image and icon off the main thread and store results - private func decodeAndStorePreviewImages(from dto: LinkPreviewDataDTO, for messageID: UUID) async { - async let heroResult: UIImage? = { - guard let data = dto.imageData else { return nil } - return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value - }() - async let iconResult: UIImage? = { - guard let data = dto.iconData else { return nil } - return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value - }() - let (hero, icon) = await (heroResult, iconResult) - - // Warm the process-lifetime cache before the per-VM apply so a - // chat-exit mid-decode still surfaces the card on the next visit, and a - // later re-entry repaints loaded without re-decoding. Cache every - // resolved preview, including title-only cards with no hero or icon, so - // those skip the loading shimmer on re-entry too. - if let url = URL(string: dto.url) { - DecodedPreviewCache.shared.store( - CachedDecodedPreview(dto: dto, hero: hero, icon: icon), - for: url - ) - } - - guard hero != nil || icon != nil else { return } - decodedPreviewAssets[messageID] = DecodedPreviewAssets(image: hero, icon: icon) + // Update to loading state + previewStates[messageID] = .loading + rebuildDisplayItem(for: messageID) + + // Get preview from cache (handles all tiers: memory, database, network) + let result = await linkPreviewCache.preview( + for: url, + using: dataStore, + isChannelMessage: isChannelMessage + ) + + // Check if task was cancelled (message scrolled away or conversation changed) + guard !Task.isCancelled else { + previewFetchTasks.removeValue(forKey: messageID) + return } - /// Update a message in place and rebuild its display item. - func updateMessage(id: UUID, mutation: (inout MessageDTO) -> Void) { - guard let coordinator, - coordinator.messagesByID[id] != nil else { return } - coordinator.update(messageID: id, mutation) - rebuildDisplayItem(for: id) - } + // Update state based on result + switch result { + case let .loaded(dto): + await decodeAndStorePreviewImages(from: dto, for: messageID) + previewStates[messageID] = .loaded + loadedPreviews[messageID] = dto - /// Rebuild a single MessageItem with current preview, image, and message - /// state. No-ops when the message is no longer present. - func rebuildDisplayItem(for messageID: UUID) { - guard let coordinator, - let message = coordinator.messagesByID[messageID] else { - logger.warning("rebuild requested for missing message id \(messageID)") - return - } - let previous = previousMessage(for: messageID) - coordinator.updateRenderItem(id: messageID) { _ in - makeItem(for: message, previous: previous) - } - } + case .loading: + // Still loading (duplicate request), keep current state + break - /// Cancel preview fetch for a message (called when cell scrolls away) - func cancelPreviewFetch(for messageID: UUID) { - previewFetchTasks[messageID]?.cancel() - previewFetchTasks.removeValue(forKey: messageID) - } + case .noPreviewAvailable, .failed: + previewStates[messageID] = .noPreview - /// Clear all preview state (called on conversation switch) - func clearPreviewState() { - previewFetchTasks.values.forEach { $0.cancel() } - previewFetchTasks.removeAll() - previewStates.removeAll() - loadedPreviews.removeAll() - decodedPreviewAssets.removeAll() - legacyPreviewDecodeInFlight.removeAll() - cachedURLs.removeAll() - mapPreviewRequestIndex.removeAll() - clearImageState() + case .disabled: + previewStates[messageID] = .disabled } - /// Clean up preview state for a specific message (called on message deletion) - func cleanupPreviewState(for messageID: UUID) { - previewStates.removeValue(forKey: messageID) - loadedPreviews.removeValue(forKey: messageID) - decodedPreviewAssets.removeValue(forKey: messageID) - previewFetchTasks[messageID]?.cancel() - previewFetchTasks.removeValue(forKey: messageID) - removeFromMapPreviewIndex(messageID) - cleanupImageState(for: messageID) + previewFetchTasks.removeValue(forKey: messageID) + rebuildDisplayItem(for: messageID) + } + + /// Manually fetch preview (for tap-to-load when previews disabled) + func manualFetchPreview(for messageID: UUID) async { + guard let url = cachedURLs[messageID].flatMap(\.self), + let dataStore, + let linkPreviewCache else { return } + + previewStates[messageID] = .loading + rebuildDisplayItem(for: messageID) + + let result = await linkPreviewCache.manualFetch(for: url, using: dataStore) + + switch result { + case let .loaded(dto): + await decodeAndStorePreviewImages(from: dto, for: messageID) + previewStates[messageID] = .loaded + loadedPreviews[messageID] = dto + case .loading: + break + case .noPreviewAvailable, .failed, .disabled: + previewStates[messageID] = .noPreview } - /// Drops a deleted message from every map-preview request bucket so a late - /// snapshot resolution does not try to rebuild a row that no longer exists. - private func removeFromMapPreviewIndex(_ messageID: UUID) { - for request in Array(mapPreviewRequestIndex.keys) { - guard var ids = mapPreviewRequestIndex[request], ids.remove(messageID) != nil else { continue } - if ids.isEmpty { - mapPreviewRequestIndex.removeValue(forKey: request) - } else { - mapPreviewRequestIndex[request] = ids - } - } + rebuildDisplayItem(for: messageID) + } + + /// Decode preview hero image and icon off the main thread and store results + private func decodeAndStorePreviewImages(from dto: LinkPreviewDataDTO, for messageID: UUID) async { + async let heroResult: UIImage? = { + guard let data = dto.imageData else { return nil } + return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value + }() + async let iconResult: UIImage? = { + guard let data = dto.iconData else { return nil } + return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value + }() + let (hero, icon) = await (heroResult, iconResult) + + // Warm the process-lifetime cache before the per-VM apply so a + // chat-exit mid-decode still surfaces the card on the next visit, and a + // later re-entry repaints loaded without re-decoding. Cache every + // resolved preview, including title-only cards with no hero or icon, so + // those skip the loading shimmer on re-entry too. + if let url = URL(string: dto.url) { + DecodedPreviewCache.shared.store( + CachedDecodedPreview(dto: dto, hero: hero, icon: icon), + for: url + ) } - // MARK: - Inline Image State Management - - /// Atomically seeds the per-VM image state from a decoded cache entry. - /// Restores `loadedImageData` from the entry's raw bytes when available - /// so the full-screen viewer and share sheet keep working after - /// rehydration. Does not rebuild the render item; the caller owns that - /// step. - func applyDecodedImage(_ cached: CachedDecodedImage, for messageID: UUID) { - decodedImages[messageID] = cached.image - imageIsGIF[messageID] = cached.isGIF - if let data = cached.data { - loadedImageData.setObject(data as NSData, forKey: messageID as NSUUID, cost: data.count) - } - previewStates[messageID] = .loaded + guard hero != nil || icon != nil else { return } + decodedPreviewAssets[messageID] = DecodedPreviewAssets(image: hero, icon: icon) + } + + /// Update a message in place and rebuild its display item. + func updateMessage(id: UUID, mutation: (inout MessageDTO) -> Void) { + guard let coordinator, + coordinator.messagesByID[id] != nil else { return } + coordinator.update(messageID: id, mutation) + rebuildDisplayItem(for: id) + } + + /// Rebuild a single MessageItem with current preview, image, and message + /// state. No-ops when the message is no longer present. + func rebuildDisplayItem(for messageID: UUID) { + guard let coordinator, + let message = coordinator.messagesByID[messageID] else { + logger.warning("rebuild requested for missing message id \(messageID)") + return } - - /// Returns the pre-decoded UIImage for a message, if available - func decodedImage(for messageID: UUID) -> UIImage? { - decodedImages[messageID] + let previous = previousMessage(for: messageID) + coordinator.updateRenderItem(id: messageID) { _ in + makeItem(for: message, previous: previous) } - - /// Returns the pre-decoded link preview hero image for a message - func decodedPreviewImage(for messageID: UUID) -> UIImage? { - decodedPreviewAssets[messageID]?.image + } + + /// Cancel preview fetch for a message (called when cell scrolls away) + func cancelPreviewFetch(for messageID: UUID) { + previewFetchTasks[messageID]?.cancel() + previewFetchTasks.removeValue(forKey: messageID) + } + + /// Clear all preview state (called on conversation switch) + func clearPreviewState() { + previewFetchTasks.values.forEach { $0.cancel() } + previewFetchTasks.removeAll() + previewStates.removeAll() + loadedPreviews.removeAll() + decodedPreviewAssets.removeAll() + legacyPreviewDecodeInFlight.removeAll() + cachedURLs.removeAll() + imageURLsServingPages.removeAll() + mapPreviewRequestIndex.removeAll() + clearImageState() + } + + /// Clean up preview state for a specific message (called on message deletion) + func cleanupPreviewState(for messageID: UUID) { + previewStates.removeValue(forKey: messageID) + loadedPreviews.removeValue(forKey: messageID) + decodedPreviewAssets.removeValue(forKey: messageID) + previewFetchTasks[messageID]?.cancel() + previewFetchTasks.removeValue(forKey: messageID) + removeFromMapPreviewIndex(messageID) + cleanupImageState(for: messageID) + } + + /// Drops a deleted message from every map-preview request bucket so a late + /// snapshot resolution does not try to rebuild a row that no longer exists. + private func removeFromMapPreviewIndex(_ messageID: UUID) { + for request in Array(mapPreviewRequestIndex.keys) { + guard var ids = mapPreviewRequestIndex[request], ids.remove(messageID) != nil else { continue } + if ids.isEmpty { + mapPreviewRequestIndex.removeValue(forKey: request) + } else { + mapPreviewRequestIndex[request] = ids + } } - - /// Returns the pre-decoded link preview icon for a message - func decodedPreviewIcon(for messageID: UUID) -> UIImage? { - decodedPreviewAssets[messageID]?.icon + } + + // MARK: - Inline Image State Management + + /// Atomically seeds the per-VM image state from a decoded cache entry. + /// Restores `loadedImageData` from the entry's raw bytes when available + /// so the full-screen viewer and share sheet keep working after + /// rehydration. Does not rebuild the render item; the caller owns that + /// step. + func applyDecodedImage(_ cached: CachedDecodedImage, for messageID: UUID) { + decodedImages[messageID] = cached.image + imageIsGIF[messageID] = cached.isGIF + if let data = cached.data { + loadedImageData.setObject(data as NSData, forKey: messageID as NSUUID, cost: data.count) } - - /// Pre-decode images for legacy messages with embedded preview data - func decodeLegacyPreviewImages() { - for message in messages where message.linkPreviewURL != nil { - let id = message.id - let existing = decodedPreviewAssets[id] - let needsImageDecode = message.linkPreviewImageData != nil && existing?.image == nil - let needsIconDecode = message.linkPreviewIconData != nil && existing?.icon == nil - guard needsImageDecode || needsIconDecode, - !legacyPreviewDecodeInFlight.contains(id) else { continue } - - let imageData = message.linkPreviewImageData - let iconData = message.linkPreviewIconData - - legacyPreviewDecodeInFlight.insert(id) - Task { [weak self] in - async let heroResult: UIImage? = if needsImageDecode, let imageData { - await Task.detached { ImageURLDetector.downsampledImage(from: imageData) }.value - } else { - existing?.image - } - async let iconResult: UIImage? = if needsIconDecode, let iconData { - await Task.detached { ImageURLDetector.downsampledImage(from: iconData) }.value - } else { - existing?.icon - } - let (hero, icon) = await (heroResult, iconResult) - if hero != nil || icon != nil { - self?.decodedPreviewAssets[id] = DecodedPreviewAssets(image: hero, icon: icon) - self?.rebuildDisplayItem(for: id) - } - self?.legacyPreviewDecodeInFlight.remove(id) - } + previewStates[messageID] = .loaded + } + + /// Returns the pre-decoded UIImage for a message, if available + func decodedImage(for messageID: UUID) -> UIImage? { + decodedImages[messageID] + } + + /// Returns the pre-decoded link preview hero image for a message + func decodedPreviewImage(for messageID: UUID) -> UIImage? { + decodedPreviewAssets[messageID]?.image + } + + /// Returns the pre-decoded link preview icon for a message + func decodedPreviewIcon(for messageID: UUID) -> UIImage? { + decodedPreviewAssets[messageID]?.icon + } + + /// Pre-decode images for legacy messages with embedded preview data + func decodeLegacyPreviewImages() { + for message in messages where message.linkPreviewURL != nil { + let id = message.id + let existing = decodedPreviewAssets[id] + let needsImageDecode = message.linkPreviewImageData != nil && existing?.image == nil + let needsIconDecode = message.linkPreviewIconData != nil && existing?.icon == nil + guard needsImageDecode || needsIconDecode, + !legacyPreviewDecodeInFlight.contains(id) else { continue } + + let imageData = message.linkPreviewImageData + let iconData = message.linkPreviewIconData + + legacyPreviewDecodeInFlight.insert(id) + Task { [weak self] in + async let heroResult: UIImage? = if needsImageDecode, let imageData { + await Task.detached { ImageURLDetector.downsampledImage(from: imageData) }.value + } else { + existing?.image + } + async let iconResult: UIImage? = if needsIconDecode, let iconData { + await Task.detached { ImageURLDetector.downsampledImage(from: iconData) }.value + } else { + existing?.icon } + let (hero, icon) = await (heroResult, iconResult) + if hero != nil || icon != nil { + self?.decodedPreviewAssets[id] = DecodedPreviewAssets(image: hero, icon: icon) + self?.rebuildDisplayItem(for: id) + } + self?.legacyPreviewDecodeInFlight.remove(id) + } } - - /// Returns whether the image for a message is a GIF - func isGIFImage(for messageID: UUID) -> Bool { - imageIsGIF[messageID] ?? false + } + + /// Returns whether the image for a message is a GIF + func isGIFImage(for messageID: UUID) -> Bool { + imageIsGIF[messageID] ?? false + } + + /// Returns the raw image data for a message, if available + func imageData(for messageID: UUID) -> Data? { + loadedImageData.object(forKey: messageID as NSUUID).map { Data(referencing: $0) } + } + + /// Clears the negative cache entry for a failed image and re-triggers the + /// fetch. A visible retry is an explicit user action, so it bypasses the + /// scope gate and fetches directly, same rationale as `manualFetchPreview`; + /// routing back through `requestImageFetch` would bounce a scope-off failure + /// to the tap-to-load placeholder instead of retrying. + func retryImageFetch(for messageID: UUID) async { + guard envInputs.previewsEnabled, + previewStates[messageID] != .malwareWarning else { return } + guard let url = cachedURLs[messageID].flatMap(\.self), + ImageURLClassifier.isImageURL(url) else { return } + + let directURL = ImageURLClassifier.directImageURL(for: url) + await InlineImageCache.shared.clearFailure(for: directURL) + + imageFetchTasks[messageID] = Task { + await fetchInlineImage(for: messageID, url: url) } - - /// Returns the raw image data for a message, if available - func imageData(for messageID: UUID) -> Data? { - loadedImageData.object(forKey: messageID as NSUUID).map { Data(referencing: $0) } + } + + /// Whether `url` routes to the inline-image fragment and fetch path: an + /// image-extension or resolvable URL the fetch path has not since found to + /// serve an HTML page. `imageURLsServingPages` covers reroutes discovered + /// this session; `InlineImageCache.servesHTMLPage` carries the verdict across + /// chat re-entry so a reloaded card is not re-classified as a stranded + /// inline-image shimmer. + func routesToInlineImage(_ url: URL) -> Bool { + guard ImageURLClassifier.isImageURL(url), + !imageURLsServingPages.contains(url.absoluteString) else { return false } + return !InlineImageCache.shared.servesHTMLPage(ImageURLClassifier.directImageURL(for: url)) + } + + /// Whether the `onRequestPreviewFetch` callback should route to image + /// fetching instead of link-preview fetching for the given message. + /// Encapsulates the cached-URL + image-URL + master-toggle gate so the cell + /// callback stays a single line. Scope is deliberately not checked here: + /// image URLs must still route to `requestImageFetch`, which owns the + /// `.disabled` transition; routing them to the preview path would fetch a + /// card for an image URL. + func shouldRequestImageFetch(for messageID: UUID) -> Bool { + guard envInputs.previewsEnabled, + let url = cachedURLs[messageID].flatMap(\.self) else { + return false } - - /// Clears the negative cache entry for a failed image and re-triggers the fetch. - func retryImageFetch(for messageID: UUID) async { - guard previewStates[messageID] != .malwareWarning else { return } - guard let url = cachedURLs[messageID].flatMap({ $0 }) else { return } - - let directURL = ImageURLClassifier.directImageURL(for: url) - await InlineImageCache.shared.clearFailure(for: directURL) - - previewStates[messageID] = .idle - rebuildDisplayItem(for: messageID) - requestImageFetch(for: messageID) + return routesToInlineImage(url) + } + + /// Request inline image fetch for a message (called when cell becomes visible) + func requestImageFetch(for messageID: UUID) { + guard envInputs.previewsEnabled else { return } + guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } + guard let url = cachedURLs[messageID].flatMap(\.self), + ImageURLClassifier.isImageURL(url) else { return } + + // Master on but auto-resolve off for this conversation type: park the + // state at `.disabled` so the cell renders the tap-to-load placeholder and + // its `.task(id:)` re-fire loop terminates, mirroring the card path's + // `LinkPreviewCache.preview` returning `.disabled`. No network fetch. + guard linkPreviewPreferences.shouldAutoResolve(isChannelMessage: currentChannel != nil) else { + previewStates[messageID] = .disabled + rebuildDisplayItem(for: messageID) + return } - /// Whether the `onRequestPreviewFetch` callback should route to image - /// fetching instead of link-preview fetching for the given message. - /// Encapsulates the cached-URL + image-URL + env-toggle gate so the cell - /// callback stays a single line. - func shouldRequestImageFetch(for messageID: UUID) -> Bool { - guard envInputs.showInlineImages, - let url = cachedURLs[messageID].flatMap({ $0 }) else { - return false - } - return ImageURLClassifier.isImageURL(url) + imageFetchTasks[messageID] = Task { + await fetchInlineImage(for: messageID, url: url) } - - /// Request inline image fetch for a message (called when cell becomes visible) - func requestImageFetch(for messageID: UUID) { - guard envInputs.showInlineImages else { return } - guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } - guard let url = cachedURLs[messageID].flatMap({ $0 }), - ImageURLClassifier.isImageURL(url) else { return } - - imageFetchTasks[messageID] = Task { - await fetchInlineImage(for: messageID, url: url) - } + } + + /// Manually fetch an inline image (tap-to-load when auto-resolve is off for + /// this conversation type). Bypasses the scope gate, like `manualFetchPreview` + /// does for cards; `fetchInlineImage` sets `.loading`, runs the malware check, + /// and handles the `.notImage` reroute. + func manualFetchImage(for messageID: UUID) { + guard envInputs.previewsEnabled, + previewStates[messageID] == .disabled, + let url = cachedURLs[messageID].flatMap(\.self), + ImageURLClassifier.isImageURL(url) else { return } + imageFetchTasks[messageID] = Task { + await fetchInlineImage(for: messageID, url: url) } + } - /// Fetch inline image data and update state - private func fetchInlineImage(for messageID: UUID, url: URL) async { - let directURL = ImageURLClassifier.directImageURL(for: url) + /// Fetch inline image data and update state + private func fetchInlineImage(for messageID: UUID, url: URL) async { + let directURL = ImageURLClassifier.directImageURL(for: url) - // Check malware domain blocklist before fetching - if let host = directURL.host(), await MalwareDomainFilter.shared.isBlocked(host) { - previewStates[messageID] = .malwareWarning - rebuildDisplayItem(for: messageID) - return - } - - previewStates[messageID] = .loading - rebuildDisplayItem(for: messageID) - let result = await InlineImageCache.shared.fetchImageData(for: directURL) - - guard !Task.isCancelled else { - imageFetchTasks.removeValue(forKey: messageID) - return - } - guard itemIndexByID[messageID] != nil else { - imageFetchTasks.removeValue(forKey: messageID) - return - } + // Check malware domain blocklist before fetching + if let host = directURL.host(), await MalwareDomainFilter.shared.isBlocked(host) { + previewStates[messageID] = .malwareWarning + rebuildDisplayItem(for: messageID) + return + } - switch result { - case .loaded(let data): - let isGIF = ImageURLDetector.isGIFData(data) - let entry: CachedDecodedImage? = await Task.detached { () -> CachedDecodedImage? in - let decoded: UIImage? = isGIF - ? ImageURLDetector.decodeGIFImage(from: data) - : ImageURLDetector.downsampledImage(from: data) - guard let decoded else { return nil } - return CachedDecodedImage( - image: decoded, - isGIF: isGIF, - data: isGIF ? nil : data - ) - }.value - // Persist before the cancellation/teardown guards so a - // scroll-away or chat-exit mid-decode still surfaces the - // pixels on the next chat re-entry. - if let entry { - InlineImageCache.shared.storeDecoded(entry, for: directURL) - } - guard !Task.isCancelled, let entry else { - imageFetchTasks.removeValue(forKey: messageID) - return - } - guard itemIndexByID[messageID] != nil else { - imageFetchTasks.removeValue(forKey: messageID) - return - } - applyDecodedImage(entry, for: messageID) - - case .loading: - break - - case .failed: - previewStates[messageID] = .noPreview - } + previewStates[messageID] = .loading + rebuildDisplayItem(for: messageID) + let result = await InlineImageCache.shared.fetchImageData(for: directURL) - imageFetchTasks.removeValue(forKey: messageID) - rebuildDisplayItem(for: messageID) + guard !Task.isCancelled else { + imageFetchTasks.removeValue(forKey: messageID) + return } - - /// Cancel image fetch for a message - func cancelImageFetch(for messageID: UUID) { - imageFetchTasks[messageID]?.cancel() - imageFetchTasks.removeValue(forKey: messageID) + guard itemIndexByID[messageID] != nil else { + imageFetchTasks.removeValue(forKey: messageID) + return } - /// Clean up image state for a specific message - private func cleanupImageState(for messageID: UUID) { - loadedImageData.removeObject(forKey: messageID as NSUUID) - decodedImages.removeValue(forKey: messageID) - imageIsGIF.removeValue(forKey: messageID) - imageFetchTasks[messageID]?.cancel() + switch result { + case let .loaded(data): + let isGIF = ImageURLDetector.isGIFData(data) + let entry: CachedDecodedImage? = await Task.detached { () -> CachedDecodedImage? in + let decoded: UIImage? = isGIF + ? ImageURLDetector.decodeGIFImage(from: data) + : ImageURLDetector.downsampledImage(from: data) + guard let decoded else { return nil } + return CachedDecodedImage( + image: decoded, + isGIF: isGIF, + data: isGIF ? nil : data + ) + }.value + // Persist before the cancellation/teardown guards so a + // scroll-away or chat-exit mid-decode still surfaces the + // pixels on the next chat re-entry. + if let entry { + InlineImageCache.shared.storeDecoded(entry, for: directURL) + } + guard !Task.isCancelled, let entry else { imageFetchTasks.removeValue(forKey: messageID) + return + } + guard itemIndexByID[messageID] != nil else { + imageFetchTasks.removeValue(forKey: messageID) + return + } + applyDecodedImage(entry, for: messageID) + + case .loading: + break + + case .notImage: + // The URL is an HTML page, not an image. Record it so the build path + // reroutes every message sharing it to the link-preview fragment, then + // drop to idle so the cell's fetch task re-fires through the preview + // path. With previews off the reroute renders text-only, so use a + // terminal state that spends no fetch. + imageURLsServingPages.insert(url.absoluteString) + let rerouteState: PreviewLoadState = envInputs.previewsEnabled ? .idle : .noPreview + previewStates[messageID] = rerouteState + // A second loaded message sharing this URL hit the in-flight dedup and + // only received .loading, leaving it shimmering as an inline image. + // Reset and rebuild each such twin so the reroute's preview fetch + // re-fires for it too. + for (twinID, twinURL) in cachedURLs { + guard twinID != messageID, twinURL == url, + previewStates[twinID] == .loading else { continue } + previewStates[twinID] = rerouteState + rebuildDisplayItem(for: twinID) + } + + case .failed: + previewStates[messageID] = .noPreview } - /// Clear all image state (called on conversation switch) - private func clearImageState() { - imageFetchTasks.values.forEach { $0.cancel() } - imageFetchTasks.removeAll() - loadedImageData.removeAllObjects() - decodedImages.removeAll() - imageIsGIF.removeAll() - } + imageFetchTasks.removeValue(forKey: messageID) + rebuildDisplayItem(for: messageID) + } + + /// Cancel image fetch for a message + func cancelImageFetch(for messageID: UUID) { + imageFetchTasks[messageID]?.cancel() + imageFetchTasks.removeValue(forKey: messageID) + } + + /// Clean up image state for a specific message + private func cleanupImageState(for messageID: UUID) { + loadedImageData.removeObject(forKey: messageID as NSUUID) + decodedImages.removeValue(forKey: messageID) + imageIsGIF.removeValue(forKey: messageID) + imageFetchTasks[messageID]?.cancel() + imageFetchTasks.removeValue(forKey: messageID) + } + + /// Clear all image state (called on conversation switch) + private func clearImageState() { + imageFetchTasks.values.forEach { $0.cancel() } + imageFetchTasks.removeAll() + loadedImageData.removeAllObjects() + decodedImages.removeAll() + imageIsGIF.removeAll() + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ReactionIndexing.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ReactionIndexing.swift index 0e7a4899..a349bac8 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ReactionIndexing.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ReactionIndexing.swift @@ -2,118 +2,116 @@ import Foundation import MC1Services extension ChatViewModel { + // MARK: - Reaction Indexing - // MARK: - Reaction Indexing + /// Conversation keying for `indexMessagesForReactions`: channels match by + /// (channel index, sender name) and persist `channelIndex`; DMs match by + /// contact and persist `contactID`. + enum ReactionIndexScope { + case channel(ChannelDTO, localNodeName: String?) + case direct(ContactDTO) + } - /// Conversation keying for `indexMessagesForReactions`: channels match by - /// (channel index, sender name) and persist `channelIndex`; DMs match by - /// contact and persist `contactID`. - enum ReactionIndexScope { - case channel(ChannelDTO, localNodeName: String?) - case direct(ContactDTO) - } - - /// Indexes a freshly fetched page of messages for reaction matching and - /// persists any pending reactions that now have their target, applying the - /// refreshed summaries to the in-memory timeline. Awaits the - /// `ReactionService` actor serially per message, so callers run it after - /// `buildItems()` to keep the visible timeline from being gated on it. - func indexMessagesForReactions( - _ fetchedMessages: [MessageDTO], - scope: ReactionIndexScope, - reactionService: ReactionService, - dataStore: DataStore - ) async { - switch scope { - case .channel(let channel, let localNodeName): - // The channel's own radioID, never the live connection's: a mid-load - // disconnect would otherwise mint a fresh UUID into persisted rows. - let radioID = channel.radioID - for message in fetchedMessages { - let senderName: String? - if message.isOutgoing { - senderName = localNodeName - } else { - senderName = message.senderNodeName - } - guard let senderName else { continue } + /// Indexes a freshly fetched page of messages for reaction matching and + /// persists any pending reactions that now have their target, applying the + /// refreshed summaries to the in-memory timeline. Awaits the + /// `ReactionService` actor serially per message, so callers run it after + /// `buildItems()` to keep the visible timeline from being gated on it. + func indexMessagesForReactions( + _ fetchedMessages: [MessageDTO], + scope: ReactionIndexScope, + reactionService: ReactionService, + dataStore: DataStore + ) async { + switch scope { + case let .channel(channel, localNodeName): + // The channel's own radioID, never the live connection's: a mid-load + // disconnect would otherwise mint a fresh UUID into persisted rows. + let radioID = channel.radioID + for message in fetchedMessages { + let senderName: String? = if message.isOutgoing { + localNodeName + } else { + message.senderNodeName + } + guard let senderName else { continue } - let pendingMatches = await reactionService.indexMessage( - id: message.id, - channelIndex: channel.index, - senderName: senderName, - text: message.text, - timestamp: message.timestamp - ) + let pendingMatches = await reactionService.indexMessage( + id: message.id, + channelIndex: channel.index, + senderName: senderName, + text: message.text, + timestamp: message.timestamp + ) - // Process any pending reactions that now have their target - for pending in pendingMatches { - let reactionDTO = ReactionDTO( - messageID: message.id, - emoji: pending.parsed.emoji, - senderName: pending.senderNodeName, - messageHash: pending.parsed.messageHash, - rawText: pending.rawText, - channelIndex: pending.channelIndex, - radioID: radioID - ) - await persistPendingReactionIfNew( - reactionDTO, - reactionService: reactionService, - dataStore: dataStore - ) - } - } + // Process any pending reactions that now have their target + for pending in pendingMatches { + let reactionDTO = ReactionDTO( + messageID: message.id, + emoji: pending.parsed.emoji, + senderName: pending.senderNodeName, + messageHash: pending.parsed.messageHash, + rawText: pending.rawText, + channelIndex: pending.channelIndex, + radioID: radioID + ) + await persistPendingReactionIfNew( + reactionDTO, + reactionService: reactionService, + dataStore: dataStore + ) + } + } - case .direct(let contact): - for message in fetchedMessages { - let pendingMatches = await reactionService.indexDMMessage( - id: message.id, - contactID: contact.id, - text: message.text, - timestamp: message.reactionTimestamp - ) + case let .direct(contact): + for message in fetchedMessages { + let pendingMatches = await reactionService.indexDMMessage( + id: message.id, + contactID: contact.id, + text: message.text, + timestamp: message.reactionTimestamp + ) - // Process any pending reactions that now have their target - for pending in pendingMatches { - let reactionDTO = ReactionDTO( - messageID: message.id, - emoji: pending.parsed.emoji, - senderName: pending.senderName, - messageHash: pending.parsed.messageHash, - rawText: pending.rawText, - contactID: contact.id, - radioID: contact.radioID - ) - await persistPendingReactionIfNew( - reactionDTO, - reactionService: reactionService, - dataStore: dataStore - ) - } - } + // Process any pending reactions that now have their target + for pending in pendingMatches { + let reactionDTO = ReactionDTO( + messageID: message.id, + emoji: pending.parsed.emoji, + senderName: pending.senderName, + messageHash: pending.parsed.messageHash, + rawText: pending.rawText, + contactID: contact.id, + radioID: contact.radioID + ) + await persistPendingReactionIfNew( + reactionDTO, + reactionService: reactionService, + dataStore: dataStore + ) } + } } + } - /// Persists `reaction` unless an identical (message, sender, emoji) row - /// already exists, then applies the refreshed summary to the in-memory message. - private func persistPendingReactionIfNew( - _ reaction: ReactionDTO, - reactionService: ReactionService, - dataStore: DataStore - ) async { - let exists = try? await dataStore.reactionExists( - messageID: reaction.messageID, - senderName: reaction.senderName, - emoji: reaction.emoji - ) - guard exists != true else { return } + /// Persists `reaction` unless an identical (message, sender, emoji) row + /// already exists, then applies the refreshed summary to the in-memory message. + private func persistPendingReactionIfNew( + _ reaction: ReactionDTO, + reactionService: ReactionService, + dataStore: DataStore + ) async { + let exists = try? await dataStore.reactionExists( + messageID: reaction.messageID, + senderName: reaction.senderName, + emoji: reaction.emoji + ) + guard exists != true else { return } - if let result = await reactionService.persistReactionAndUpdateSummary( - reaction, - using: dataStore - ) { - updateReactionSummary(for: result.messageID, summary: result.summary) - } + if let result = await reactionService.persistReactionAndUpdateSummary( + reaction, + using: dataStore + ) { + updateReactionSummary(for: result.messageID, summary: result.summary) } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Reactions.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Reactions.swift index ac5615b3..6074d392 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Reactions.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Reactions.swift @@ -2,200 +2,199 @@ import Foundation import MC1Services extension ChatViewModel { - - // MARK: - Reactions - - /// Send a reaction emoji to a message (channel or DM) - func sendReaction(emoji: String, to message: MessageDTO) async { - guard reactionServiceProvider() != nil, - messageService != nil, - let dataStore else { - return - } - - // Prevent duplicate sends from rapid taps - let reactionKey = "\(message.id)-\(emoji)" - guard !inFlightReactions.contains(reactionKey) else { - logger.debug("Reaction \(emoji) already in flight for message \(message.id), ignoring") - return - } - inFlightReactions.insert(reactionKey) - defer { inFlightReactions.remove(reactionKey) } - - let localNodeName = connectedDeviceProvider()?.nodeName ?? "Me" - - // Check if user already reacted with this emoji - if let alreadyReacted = try? await dataStore.reactionExists( - messageID: message.id, - senderName: localNodeName, - emoji: emoji - ), alreadyReacted { - logger.debug("User already reacted with \(emoji), ignoring") - return - } - - // Handle channel vs DM - if let channelIndex = message.channelIndex { - await sendChannelReaction( - emoji: emoji, - to: message, - channelIndex: channelIndex, - localNodeName: localNodeName - ) - } else if let contactID = message.contactID { - await sendDMReaction( - emoji: emoji, - to: message, - contactID: contactID, - localNodeName: localNodeName - ) - } + // MARK: - Reactions + + /// Send a reaction emoji to a message (channel or DM) + func sendReaction(emoji: String, to message: MessageDTO) async { + guard reactionServiceProvider() != nil, + messageService != nil, + let dataStore else { + return } - private func sendChannelReaction( - emoji: String, - to message: MessageDTO, - channelIndex: UInt8, - localNodeName: String - ) async { - guard let reactionService = reactionServiceProvider(), - let messageService, - let dataStore else { return } - - // Determine target sender name - let targetSenderName: String - if message.isOutgoing { - targetSenderName = localNodeName - } else { - guard let senderName = message.senderNodeName else { return } - targetSenderName = senderName - } - - let reactionText = reactionService.buildReactionText( - emoji: emoji, - targetSender: targetSenderName, - targetText: message.text, - targetTimestamp: message.reactionTimestamp - ) - - do { - _ = try await messageService.sendChannelMessage( - text: reactionText, - channelIndex: channelIndex, - radioID: message.radioID - ) - - // Optimistic local update - let messageHash = ReactionParser.generateMessageHash( - text: message.text, - timestamp: message.reactionTimestamp - ) - let reactionDTO = ReactionDTO( - messageID: message.id, - emoji: emoji, - senderName: localNodeName, - messageHash: messageHash, - rawText: reactionText, - channelIndex: channelIndex, - radioID: message.radioID - ) - if let result = await reactionService.persistReactionAndUpdateSummary( - reactionDTO, - using: dataStore - ) { - updateReactionSummary(for: result.messageID, summary: result.summary) - } - } catch { - logger.error("Failed to send channel reaction: \(error)") - errorMessage = error.userFacingMessage - } + // Prevent duplicate sends from rapid taps + let reactionKey = "\(message.id)-\(emoji)" + guard !inFlightReactions.contains(reactionKey) else { + logger.debug("Reaction \(emoji) already in flight for message \(message.id), ignoring") + return + } + inFlightReactions.insert(reactionKey) + defer { inFlightReactions.remove(reactionKey) } + + let localNodeName = connectedDeviceProvider()?.nodeName ?? "Me" + + // Check if user already reacted with this emoji + if let alreadyReacted = try? await dataStore.reactionExists( + messageID: message.id, + senderName: localNodeName, + emoji: emoji + ), alreadyReacted { + logger.debug("User already reacted with \(emoji), ignoring") + return } - private func sendDMReaction( - emoji: String, - to message: MessageDTO, - contactID: UUID, - localNodeName: String - ) async { - guard let reactionService = reactionServiceProvider(), - let messageService, - let dataStore else { return } - - // Fetch the contact for sending - guard let contact = try? await dataStore.fetchContact(id: contactID) else { - logger.error("Failed to fetch contact for DM reaction") - return - } - - let reactionText = reactionService.buildDMReactionText( - emoji: emoji, - targetText: message.text, - targetTimestamp: message.reactionTimestamp - ) - do { - // Send as DM to the contact - _ = try await messageService.sendDirectMessage( - text: reactionText, - to: contact - ) - - // Optimistic local update - let messageHash = ReactionParser.generateMessageHash( - text: message.text, - timestamp: message.reactionTimestamp - ) - let reactionDTO = ReactionDTO( - messageID: message.id, - emoji: emoji, - senderName: localNodeName, - messageHash: messageHash, - rawText: reactionText, - contactID: contactID, - radioID: message.radioID - ) - if let result = await reactionService.persistReactionAndUpdateSummary( - reactionDTO, - using: dataStore - ) { - updateReactionSummary(for: result.messageID, summary: result.summary) - } - } catch { - logger.error("Failed to send DM reaction: \(error)") - errorMessage = error.userFacingMessage - } + // Handle channel vs DM + if let channelIndex = message.channelIndex { + await sendChannelReaction( + emoji: emoji, + to: message, + channelIndex: channelIndex, + localNodeName: localNodeName + ) + } else if let contactID = message.contactID { + await sendDMReaction( + emoji: emoji, + to: message, + contactID: contactID, + localNodeName: localNodeName + ) + } + } + + private func sendChannelReaction( + emoji: String, + to message: MessageDTO, + channelIndex: UInt8, + localNodeName: String + ) async { + guard let reactionService = reactionServiceProvider(), + let messageService, + let dataStore else { return } + + // Determine target sender name + let targetSenderName: String + if message.isOutgoing { + targetSenderName = localNodeName + } else { + guard let senderName = message.senderNodeName else { return } + targetSenderName = senderName } - // MARK: - Reaction Filtering + let reactionText = reactionService.buildReactionText( + emoji: emoji, + targetSender: targetSenderName, + targetText: message.text, + targetTimestamp: message.reactionTimestamp + ) + + do { + _ = try await messageService.sendChannelMessage( + text: reactionText, + channelIndex: channelIndex, + radioID: message.radioID + ) + + // Optimistic local update + let messageHash = ReactionParser.generateMessageHash( + text: message.text, + timestamp: message.reactionTimestamp + ) + let reactionDTO = ReactionDTO( + messageID: message.id, + emoji: emoji, + senderName: localNodeName, + messageHash: messageHash, + rawText: reactionText, + channelIndex: channelIndex, + radioID: message.radioID + ) + if let result = await reactionService.persistReactionAndUpdateSummary( + reactionDTO, + using: dataStore + ) { + updateReactionSummary(for: result.messageID, summary: result.summary) + } + } catch { + logger.error("Failed to send channel reaction: \(error)") + errorMessage = error.userFacingMessage + } + } + + private func sendDMReaction( + emoji: String, + to message: MessageDTO, + contactID: UUID, + localNodeName: String + ) async { + guard let reactionService = reactionServiceProvider(), + let messageService, + let dataStore else { return } + + // Fetch the contact for sending + guard let contact = try? await dataStore.fetchContact(id: contactID) else { + logger.error("Failed to fetch contact for DM reaction") + return + } - /// Filter out outgoing reaction messages unless they failed to send. - /// Reaction messages are hidden from the UI to avoid clutter since they're displayed as badges. - /// - Parameters: - /// - messages: The messages to filter - /// - isDM: Whether these are DM messages (uses parseDM) or channel messages (uses parse) - /// - Returns: Filtered messages with successful outgoing reactions removed - func filterOutgoingReactionMessages(_ messages: [MessageDTO], isDM: Bool) -> [MessageDTO] { - messages.filter { !isHiddenOutgoingReaction($0, isDM: isDM) } + let reactionText = reactionService.buildDMReactionText( + emoji: emoji, + targetText: message.text, + targetTimestamp: message.reactionTimestamp + ) + do { + // Send as DM to the contact + _ = try await messageService.sendDirectMessage( + text: reactionText, + to: contact + ) + + // Optimistic local update + let messageHash = ReactionParser.generateMessageHash( + text: message.text, + timestamp: message.reactionTimestamp + ) + let reactionDTO = ReactionDTO( + messageID: message.id, + emoji: emoji, + senderName: localNodeName, + messageHash: messageHash, + rawText: reactionText, + contactID: contactID, + radioID: message.radioID + ) + if let result = await reactionService.persistReactionAndUpdateSummary( + reactionDTO, + using: dataStore + ) { + updateReactionSummary(for: result.messageID, summary: result.summary) + } + } catch { + logger.error("Failed to send DM reaction: \(error)") + errorMessage = error.userFacingMessage } + } - /// Whether a message is a successfully-sent outgoing reaction, which is rendered - /// as a badge and so hidden from the timeline by `filterOutgoingReactionMessages`. - /// Failed reactions stay visible so the user can retry them. - func isHiddenOutgoingReaction(_ message: MessageDTO, isDM: Bool) -> Bool { - guard message.direction == .outgoing else { return false } + // MARK: - Reaction Filtering - let isReaction = isDM - ? ReactionParser.parseDM(message.text) != nil - : ReactionParser.parse(message.text) != nil + /// Filter out outgoing reaction messages unless they failed to send. + /// Reaction messages are hidden from the UI to avoid clutter since they're displayed as badges. + /// - Parameters: + /// - messages: The messages to filter + /// - isDM: Whether these are DM messages (uses parseDM) or channel messages (uses parse) + /// - Returns: Filtered messages with successful outgoing reactions removed + func filterOutgoingReactionMessages(_ messages: [MessageDTO], isDM: Bool) -> [MessageDTO] { + messages.filter { !isHiddenOutgoingReaction($0, isDM: isDM) } + } - guard isReaction else { return false } + /// Whether a message is a successfully-sent outgoing reaction, which is rendered + /// as a badge and so hidden from the timeline by `filterOutgoingReactionMessages`. + /// Failed reactions stay visible so the user can retry them. + func isHiddenOutgoingReaction(_ message: MessageDTO, isDM: Bool) -> Bool { + guard message.direction == .outgoing else { return false } - return message.status != .failed - } + let isReaction = isDM + ? ReactionParser.parseDM(message.text) != nil + : ReactionParser.parse(message.text) != nil - // MARK: - Reaction Updates + guard isReaction else { return false } - /// Update reaction summary for a specific message inline (O(1) update) - func updateReactionSummary(for messageID: UUID, summary: String) { - updateMessage(id: messageID) { $0.reactionSummary = summary } - } + return message.status != .failed + } + + // MARK: - Reaction Updates + + /// Update reaction summary for a specific message inline (O(1) update) + func updateReactionSummary(for messageID: UUID, summary: String) { + updateMessage(id: messageID) { $0.reactionSummary = summary } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+SendQueues.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+SendQueues.swift index d43ccd17..afb96ae9 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+SendQueues.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+SendQueues.swift @@ -2,39 +2,38 @@ import Foundation import MC1Services extension ChatViewModel { - - /// Route a DM enqueue through the service-owned send queue. The - /// service persists the row and drives the drain. Throws - /// `.notConnected` if the connection has been torn down between the - /// optimistic DB write and this call, or `.persistFailed` if the - /// SwiftData write fails. The caller's catch flips the message to - /// `.failed` and surfaces the error. - func enqueueDM(_ envelope: DirectMessageEnvelope) async throws { - guard let queue = chatSendQueueServiceProvider() else { - throw ChatSendQueueServiceError.notConnected - } - try await queue.enqueueDM(envelope) + /// Route a DM enqueue through the service-owned send queue. The + /// service persists the row and drives the drain. Throws + /// `.notConnected` if the connection has been torn down between the + /// optimistic DB write and this call, or `.persistFailed` if the + /// SwiftData write fails. The caller's catch flips the message to + /// `.failed` and surfaces the error. + func enqueueDM(_ envelope: DirectMessageEnvelope) async throws { + guard let queue = chatSendQueueServiceProvider() else { + throw ChatSendQueueServiceError.notConnected } + try await queue.enqueueDM(envelope) + } - /// Route a channel enqueue through the service-owned send queue. - /// See `enqueueDM` for the error contract. - func enqueueChannel(_ envelope: ChannelMessageEnvelope) async throws { - guard let queue = chatSendQueueServiceProvider() else { - throw ChatSendQueueServiceError.notConnected - } - try await queue.enqueueChannel(envelope) + /// Route a channel enqueue through the service-owned send queue. + /// See `enqueueDM` for the error contract. + func enqueueChannel(_ envelope: ChannelMessageEnvelope) async throws { + guard let queue = chatSendQueueServiceProvider() else { + throw ChatSendQueueServiceError.notConnected } + try await queue.enqueueChannel(envelope) + } - /// Signal-only DM enqueue for the manual retry path. The caller has - /// already persisted the `PendingSend` row via - /// `PersistenceStore.replacePendingSendForRetry`, so the service must - /// not double-persist. Throws `.notConnected` if the connection has - /// been torn down between the persist and this call — the caller's - /// catch surfaces the failure via `sendErrorMessage`. - func signalDMEnqueued(_ envelope: DirectMessageEnvelope) async throws { - guard let queue = chatSendQueueServiceProvider() else { - throw ChatSendQueueServiceError.notConnected - } - await queue.signalDMEnqueued(envelope) + /// Signal-only DM enqueue for the manual retry path. The caller has + /// already persisted the `PendingSend` row via + /// `PersistenceStore.replacePendingSendForRetry`, so the service must + /// not double-persist. Throws `.notConnected` if the connection has + /// been torn down between the persist and this call — the caller's + /// catch surfaces the failure via `sendErrorMessage`. + func signalDMEnqueued(_ envelope: DirectMessageEnvelope) async throws { + guard let queue = chatSendQueueServiceProvider() else { + throw ChatSendQueueServiceError.notConnected } + await queue.signalDMEnqueued(envelope) + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel.swift b/MC1/Views/Chats/ViewModel/ChatViewModel.swift index 319aebdf..9622c854 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel.swift @@ -1,545 +1,591 @@ -import SwiftUI -import UIKit +import CoreLocation import MC1Services import OSLog -import CoreLocation +import SwiftUI +import UIKit /// ViewModel for chat operations @Observable @MainActor final class ChatViewModel { + /// Tracks the last flood scope we pushed to the device for the current channel. + /// Keyed by the input pair (per-channel preference, device default) so that a + /// change in either triggers a fresh push next time `loadChannelMessages` runs. + enum RegionScopeState: Equatable { + case unknown + case pushed(ChannelFloodScope, deviceDefault: String?) + } - /// Tracks the last flood scope we pushed to the device for the current channel. - /// Keyed by the input pair (per-channel preference, device default) so that a - /// change in either triggers a fresh push next time `loadChannelMessages` runs. - enum RegionScopeState: Equatable { - case unknown - case pushed(ChannelFloodScope, deviceDefault: String?) - } + /// Identifies an incoming self-mention with a per-mention sequence number so + /// `.onChange(of:)` fires for consecutive mentions of the same message. + /// `Equatable` is auto-synthesised from `UUID` + `UInt64`; adding a non-Equatable + /// field would break `.onChange` propagation. + struct MentionEvent: Equatable { + let messageID: UUID + let sequence: UInt64 + } - /// Identifies an incoming self-mention with a per-mention sequence number so - /// `.onChange(of:)` fires for consecutive mentions of the same message. - /// `Equatable` is auto-synthesised from `UUID` + `UInt64`; adding a non-Equatable - /// field would break `.onChange` propagation. - struct MentionEvent: Equatable, Sendable { - let messageID: UUID - let sequence: UInt64 - } + // MARK: - Properties - // MARK: - Properties + let logger = Logger(subsystem: "com.mc1", category: "ChatViewModel") - let logger = Logger(subsystem: "com.mc1", category: "ChatViewModel") + /// Observed source of truth the list diffs against. Stays observed because that + /// observation drives the diff; assigned only by `recomputeSnapshot()`. + var conversationSnapshot: ConversationSnapshot = .empty - /// Observed source of truth the list diffs against. Stays observed because that - /// observation drives the diff; assigned only by `recomputeSnapshot()`. - var conversationSnapshot: ConversationSnapshot = .empty + /// Cheap Equatable surrogate for `onChange(of:)`; bumped only in `recomputeSnapshot()`. + var snapshotGeneration: Int = 0 - /// Cheap Equatable surrogate for `onChange(of:)`; bumped only in `recomputeSnapshot()`. - var snapshotGeneration: Int = 0 + /// Non-observed fetch buffer feeding `recomputeSnapshot()`; `internal` so tests can seed it. + @ObservationIgnored var conversations: [ContactDTO] = [] - /// Non-observed fetch buffer feeding `recomputeSnapshot()`; `internal` so tests can seed it. - @ObservationIgnored var conversations: [ContactDTO] = [] + /// All contacts for mention autocomplete (includes contacts without messages) + var allContacts: [ContactDTO] = [] - /// All contacts for mention autocomplete (includes contacts without messages) - var allContacts: [ContactDTO] = [] + /// `loweredName -> nickname` for channel sender matching, rebuilt whenever + /// `allContacts` changes so per-message resolution stays O(1). + var nicknamesByLoweredName: [String: String] = [:] - /// Synthetic contacts for channel senders not in contacts - var channelSenders: [ContactDTO] = [] + /// Synthetic contacts for channel senders not in contacts + var channelSenders: [ContactDTO] = [] - /// O(1) lookup for channel sender names - var channelSenderNames: Set = [] + /// O(1) lookup for channel sender names + var channelSenderNames: Set = [] - /// Sender name → latest message timestamp (for mention sort order) - var channelSenderOrder: [String: UInt32] = [:] + /// Sender name → latest message timestamp (for mention sort order) + var channelSenderOrder: [String: UInt32] = [:] - /// O(1) lookup for contact names - var contactNameSet: Set = [] + /// O(1) lookup for contact names + var contactNameSet: Set = [] - /// Current channels with messages. Non-observed fetch buffer feeding `recomputeSnapshot()`. - @ObservationIgnored var channels: [ChannelDTO] = [] + /// Current channels with messages. Non-observed fetch buffer feeding `recomputeSnapshot()`. + @ObservationIgnored var channels: [ChannelDTO] = [] - /// Current room sessions. Non-observed fetch buffer feeding `recomputeSnapshot()`. - @ObservationIgnored var roomSessions: [RemoteNodeSessionDTO] = [] + /// Current room sessions. Non-observed fetch buffer feeding `recomputeSnapshot()`. + @ObservationIgnored var roomSessions: [RemoteNodeSessionDTO] = [] - /// Ids hidden optimistically on delete until a DB-confirming reload drops them. - /// `reconcilePendingRemovals()` self-heals this once the fetch confirms the row is gone. - @ObservationIgnored var pendingRemovalIDs: Set = [] + /// Ids hidden optimistically on delete until a DB-confirming reload drops them. + /// `reconcilePendingRemovals()` self-heals this once the fetch confirms the row is gone. + @ObservationIgnored var pendingRemovalIDs: Set = [] - /// Rows showing a delete spinner during a radio-backed delete (channel clear, room leave). - /// Observed presentation state read by the rows; never filters the snapshot. Distinct from - /// `pendingRemovalIDs`, the optimistic-hide mask. - var deletingIDs: Set = [] + /// Rows showing a delete spinner during a radio-backed delete (channel clear, room leave). + /// Observed presentation state read by the rows; never filters the snapshot. Distinct from + /// `pendingRemovalIDs`, the optimistic-hide mask. + var deletingIDs: Set = [] - /// Cancel-and-replace token for the serialized reload funnel. No view reads it. - @ObservationIgnored var reloadTask: Task? + /// Cancel-and-replace token for the serialized reload funnel. No view reads it. + @ObservationIgnored var reloadTask: Task? - #if DEBUG + #if DEBUG /// Test-only interleave hook, awaited once mid-reload so a test can suspend reload #1 /// between fetches and commit reload #2 first. Compiled out of release builds. @ObservationIgnored var reloadInterleaveHook: (@MainActor () async -> Void)? - #endif - - // MARK: - Conversation Cache Storage - - @ObservationIgnored var urlDetectionTask: Task? - /// Bumped on every buildItems rebuild. Only the URL-detection writer - /// checks this before mutating cachedURLs; single-row rebuilds via - /// rebuildDisplayItem do not write cachedURLs and do not need gating. - @ObservationIgnored var urlDetectionGeneration: UInt64 = 0 - /// Tracks the last region scope sent to the device via setFloodScope. - @ObservationIgnored var lastSetRegionScope: RegionScopeState = .unknown - - /// Per-(radio, conversation) coordinator that owns the canonical chat - /// state for this view model. Bound by `configure(...)` once the - /// conversation identity is known. Two `ChatViewModel`s on the same - /// conversation share one coordinator via the registry, so an update - /// applied from one view is visible to the other. - var coordinator: ChatCoordinator? - - /// Messages for the current conversation. Forwards to the bound - /// coordinator; empty when no coordinator is bound (e.g., conversation - /// list views). - var messages: [MessageDTO] { coordinator?.messages ?? [] } - - /// Immutable snapshot of the timeline as the view sees it. Forwards - /// to the bound coordinator. - var renderState: ChatRenderState { coordinator?.renderState ?? .empty } - - /// Environment-derived inputs (six `@AppStorage` toggles, contrast, - /// current user name) that feed `MessageItem` construction. - /// `ChatConversationView` pushes via `applyEnvInputs(_:)` before - /// `loadMessages` and on subsequent toggle changes. - var envInputs: EnvInputs = .default - - /// Update env-derived inputs and trigger a full rebuild when the value - /// changes and there are messages to rebuild. Idempotent on no-change. - func applyEnvInputs(_ new: EnvInputs) { - guard envInputs != new else { return } - // When the network transitions from unavailable to available, drop the - // sticky map-snapshot failures so renders that failed during the outage - // retry on the next rebuild. Without this, an offline-pack miss stays - // poisoned until a memory warning evicts the failed set. - if envInputs.isOffline && !new.isOffline { - MapSnapshotStore.shared.clearFailures() - } - envInputs = new - guard !messages.isEmpty else { return } - buildItems() + #endif + + // MARK: - Conversation Cache Storage + + @ObservationIgnored var urlDetectionTask: Task? + /// Bumped on every buildItems rebuild. Only the URL-detection writer + /// checks this before mutating cachedURLs; single-row rebuilds via + /// rebuildDisplayItem do not write cachedURLs and do not need gating. + @ObservationIgnored var urlDetectionGeneration: UInt64 = 0 + /// Tracks the last region scope sent to the device via setFloodScope. + @ObservationIgnored var lastSetRegionScope: RegionScopeState = .unknown + + /// Per-(radio, conversation) coordinator that owns the canonical chat + /// state for this view model. Bound by `configure(...)` once the + /// conversation identity is known. Two `ChatViewModel`s on the same + /// conversation share one coordinator via the registry, so an update + /// applied from one view is visible to the other. + var coordinator: ChatCoordinator? + + /// Messages for the current conversation. Forwards to the bound + /// coordinator; empty when no coordinator is bound (e.g., conversation + /// list views). + var messages: [MessageDTO] { + coordinator?.messages ?? [] + } + + /// Immutable snapshot of the timeline as the view sees it. Forwards + /// to the bound coordinator. + var renderState: ChatRenderState { + coordinator?.renderState ?? .empty + } + + /// Environment-derived inputs (seven `@AppStorage` toggles, contrast, + /// current user name) that feed `MessageItem` construction. + /// `ChatConversationView` pushes via `applyEnvInputs(_:)` before + /// `loadMessages` and on subsequent toggle changes. + var envInputs: EnvInputs = .default + + /// Update env-derived inputs and trigger a full rebuild when the value + /// changes and there are messages to rebuild. Idempotent on no-change. + func applyEnvInputs(_ new: EnvInputs) { + guard envInputs != new else { return } + // When the network transitions from unavailable to available, drop the + // sticky map-snapshot failures so renders that failed during the outage + // retry on the next rebuild. Without this, an offline-pack miss stays + // poisoned until a memory warning evicts the failed set. + if envInputs.isOffline, !new.isOffline { + MapSnapshotStore.shared.clearFailures() } - - /// Monotonic counter bumped when a `MessageEvent` indicates the current - /// contact (route, profile) should be re-fetched. Observed by the view. - var contactRefreshSignal: UInt64 = 0 - - /// Most recent incoming self-mention, with a per-mention sequence number - /// so consecutive mentions of the same message still fire `.onChange`. - var lastIncomingMention: MentionEvent? - - /// Internal mention counter; never observed by the view, so it stays out - /// of the `@Observable` graph. - @ObservationIgnored var mentionSequence: UInt64 = 0 - - /// Stored timeline rows. The cell-content closure consumes this directly; - /// the bubble view reads `MessageItem` fields without any per-render rebuild. - var items: [MessageItem] { renderState.items } - - /// O(1) item-index lookup by message ID, forwarded from the render state. - var itemIndexByID: [UUID: Int] { renderState.itemIndexByID } - - /// O(1) message lookup by ID (used by views to get full DTO when needed). - /// Forwards to the bound coordinator. - var messagesByID: [UUID: MessageDTO] { coordinator?.messagesByID ?? [:] } - - /// Current contact being chatted with - var currentContact: ContactDTO? - - /// Current channel being viewed - var currentChannel: ChannelDTO? - - /// Loading state - var isLoading = false - - /// True once a list-level conversation fetch has completed. Gates the - /// list-level empty-state placeholder. The per-conversation timeline - /// has its own gate on `ChatRenderState.LoadPhase`. - var hasLoadedOnce = false - - /// Error message if any - var errorMessage: String? - - /// Error state for send-only failures (queue drains, retry call site). - /// Separate from `errorMessage`, which surfaces load and fetch errors - /// with the generic "Error" alert title. `sendErrorMessage` surfaces - /// with the "Unable to Send" title so retry-context errors read as - /// user-actionable rather than generic. - /// - /// Routing: - /// - Send-queue drain errors are assigned post-`loadMessages` so the - /// load reset of `errorMessage = nil` does not clobber the alert. - /// - Load errors continue to flow to `errorMessage`. - /// - Passive load and prefetch failures route to `errorBannerMessage` for - /// the non-modal banner surface. - var sendErrorMessage: String? - - /// Error message for passive failure surfaces. Driven by `errorBanner(_:)` - /// and rendered as a tap-to-dismiss strip between the message list and the - /// input bar. Use this instead of `errorMessage` for failures the user did - /// not initiate (prefetch, scroll-triggered pagination). User-initiated - /// failures and initial-open load failures continue to surface via - /// `errorMessage` (modal "Error" alert) or `sendErrorMessage` (modal - /// "Unable to Send" alert). - var errorBannerMessage: String? - - /// Message text being composed - var composingText = "" - - /// Reentry guard for retry actions. A single `ChatViewModel` is bound to one - /// conversation at a time (`currentChannel` xor `currentContact`), so this - /// flag covers both DM and channel retry paths — they can never overlap. - @ObservationIgnored var retryInFlight = false - - /// Last message previews cache - var lastMessageCache: [UUID: MessageDTO] = [:] - - // The preview-cache block below (`previewStates`, `cachedURLs`, - // `decodedImages`, `loadedImageData`) is per-VM state, not shared - // across multiple VMs binding to the same `ChatCoordinator`. On iPad - // split view each VM rebuilds its own cache; the two views can - // diverge until the next rebuild on each side. See - // `ChatCoordinatorRegistry.coordinator(for:)` for the accepted - // trade-off context. - - /// Preview state per message (keyed by message ID) - var previewStates: [UUID: PreviewLoadState] = [:] - - /// Loaded preview data per message (keyed by message ID) - var loadedPreviews: [UUID: LinkPreviewDataDTO] = [:] - - /// In-flight preview fetch tasks (prevents duplicate fetches) - var previewFetchTasks: [UUID: Task] = [:] - - /// Total cost limit for `loadedImageData`. `NSCache` evicts entries to - /// stay under this byte budget and also responds to system memory - /// pressure on its own. - private static let imageDataCacheLimitBytes = 50 * 1024 * 1024 - - /// Raw image data per message (keyed by message ID). Backed by - /// `NSCache` so memory pressure and the configured cost limit drive - /// eviction instead of an unbounded dictionary. `@ObservationIgnored` - /// because mutations should not trigger SwiftUI redraws — consumers - /// read this via explicit method calls when an image is tapped. - @ObservationIgnored - let loadedImageData: NSCache = { - let cache = NSCache() - cache.totalCostLimit = ChatViewModel.imageDataCacheLimitBytes - return cache - }() - - /// Pre-decoded UIImage per message (avoids decoding in view body) - var decodedImages: [UUID: UIImage] = [:] - - /// Pre-decoded link preview assets (single dictionary to batch Observable notifications) - var decodedPreviewAssets: [UUID: DecodedPreviewAssets] = [:] - - /// Tracks in-flight legacy preview decode tasks to prevent duplicates - var legacyPreviewDecodeInFlight: Set = [] - - /// Whether each image message is a GIF (computed once during decode) - var imageIsGIF: [UUID: Bool] = [:] - - /// In-flight image fetch tasks - var imageFetchTasks: [UUID: Task] = [:] - - /// In-flight reaction sends (prevents duplicate reactions on rapid taps) - /// Key format: "{messageID}-{emoji}" - var inFlightReactions: Set = [] - - /// Cached URL detection results to avoid re-running NSDataDetector on rebuilds - var cachedURLs: [UUID: URL?] = [:] - - /// Maps a snapshot request to the messages that show its thumbnail, so a late - /// `resolutionStream` event rebuilds only those rows (O(matches)) instead of - /// regex-scanning every loaded message. Populated in `makeBuildInputs`, - /// cleared on conversation switch. - @ObservationIgnored var mapPreviewRequestIndex: [MapSnapshotRequest: Set] = [:] - - // MARK: - Pagination State - - /// Whether currently fetching older messages (exposed for UI binding) - var isLoadingOlder: Bool { renderState.isLoadingOlder } - - /// Whether more messages exist beyond what's loaded - var hasMoreMessages: Bool { renderState.hasMoreMessages } - - /// Total messages fetched from database (unfiltered, for accurate offset calculation) - var totalFetchedCount: Int { renderState.totalFetchedCount } - - /// Message ID that should show the "New Messages" divider above it - var newMessagesDividerMessageID: UUID? - - /// Whether the divider position has been computed for the current conversation - var dividerComputed = false - - /// Unread count above which the "New Messages" divider is shown (strictly greater). - private let newMessagesDividerThreshold = 10 - - /// Computes the divider message ID from a fetched (unfiltered) message array. - /// Must be called before filtering. Sets `dividerComputed = true`. - /// - /// Positional: the divider sits `unreadCount` rows from the end. This relies on unread - /// messages occupying the array tail, which block-at-reconnect upholds — every unread row - /// (live or drained) takes a sortDate at or after its receive/drain time, later than any - /// already-read row, so unread always sorts to the tail. Do not switch this to a - /// `first(where: { !$0.isRead })` scan: per-message `isRead` is not maintained on chat open - /// (only the unread counter is cleared), so the scan would land on the first row of the page. - /// - /// The boundary row may be a sent outgoing reaction that `filterOutgoingReactionMessages` - /// drops before the items are built; the divider id must survive that filter, so advance - /// past any hidden row to the next visible one (toward newer), which renders at the same - /// visual position. - func computeDividerPosition(from messages: [MessageDTO], unreadCount: Int, isDM: Bool) { - guard !dividerComputed, unreadCount > newMessagesDividerThreshold else { return } - var dividerIndex = max(0, messages.count - unreadCount) - while dividerIndex < messages.count, isHiddenOutgoingReaction(messages[dividerIndex], isDM: isDM) { - dividerIndex += 1 - } - if dividerIndex < messages.count { - newMessagesDividerMessageID = messages[dividerIndex].id - } - dividerComputed = true + envInputs = new + guard !messages.isEmpty else { return } + buildItems() + } + + /// Monotonic counter bumped when a `MessageEvent` indicates the current + /// contact (route, profile) should be re-fetched. Observed by the view. + var contactRefreshSignal: UInt64 = 0 + + /// Most recent incoming self-mention, with a per-mention sequence number + /// so consecutive mentions of the same message still fire `.onChange`. + var lastIncomingMention: MentionEvent? + + /// Internal mention counter; never observed by the view, so it stays out + /// of the `@Observable` graph. + @ObservationIgnored var mentionSequence: UInt64 = 0 + + /// Stored timeline rows. The cell-content closure consumes this directly; + /// the bubble view reads `MessageItem` fields without any per-render rebuild. + var items: [MessageItem] { + renderState.items + } + + /// O(1) item-index lookup by message ID, forwarded from the render state. + var itemIndexByID: [UUID: Int] { + renderState.itemIndexByID + } + + /// O(1) message lookup by ID (used by views to get full DTO when needed). + /// Forwards to the bound coordinator. + var messagesByID: [UUID: MessageDTO] { + coordinator?.messagesByID ?? [:] + } + + /// Current contact being chatted with + var currentContact: ContactDTO? + + /// Current channel being viewed + var currentChannel: ChannelDTO? + + /// Loading state + var isLoading = false + + /// True once a list-level conversation fetch has completed. Gates the + /// list-level empty-state placeholder. The per-conversation timeline + /// has its own gate on `ChatRenderState.LoadPhase`. + var hasLoadedOnce = false + + /// Error message if any + var errorMessage: String? + + /// Error state for send-only failures (queue drains, retry call site). + /// Separate from `errorMessage`, which surfaces load and fetch errors + /// with the generic "Error" alert title. `sendErrorMessage` surfaces + /// with the "Unable to Send" title so retry-context errors read as + /// user-actionable rather than generic. + /// + /// Routing: + /// - Send-queue drain errors are assigned post-`loadMessages` so the + /// load reset of `errorMessage = nil` does not clobber the alert. + /// - Load errors continue to flow to `errorMessage`. + /// - Passive load and prefetch failures route to `errorBannerMessage` for + /// the non-modal banner surface. + var sendErrorMessage: String? + + /// Error message for passive failure surfaces. Driven by `errorBanner(_:)` + /// and rendered as a tap-to-dismiss strip between the message list and the + /// input bar. Use this instead of `errorMessage` for failures the user did + /// not initiate (prefetch, scroll-triggered pagination). User-initiated + /// failures and initial-open load failures continue to surface via + /// `errorMessage` (modal "Error" alert) or `sendErrorMessage` (modal + /// "Unable to Send" alert). + var errorBannerMessage: String? + + /// Message text being composed + var composingText = "" + + /// Reentry guard for retry actions. A single `ChatViewModel` is bound to one + /// conversation at a time (`currentChannel` xor `currentContact`), so this + /// flag covers both DM and channel retry paths — they can never overlap. + @ObservationIgnored var retryInFlight = false + + /// Last message previews cache + var lastMessageCache: [UUID: MessageDTO] = [:] + + // The preview-cache block below (`previewStates`, `cachedURLs`, + // `decodedImages`, `loadedImageData`) is per-VM state, not shared + // across multiple VMs binding to the same `ChatCoordinator`. On iPad + // split view each VM rebuilds its own cache; the two views can + // diverge until the next rebuild on each side. See + // `ChatCoordinatorRegistry.coordinator(for:)` for the accepted + // trade-off context. + + /// Scope preferences (master + per-conversation-type auto-resolve) read by + /// the image fetch sites so inline images honor the same DM/channel gate the + /// card path applies inside `LinkPreviewCache`. Internal so tests can inject + /// a scratch `UserDefaults` suite instead of leaking into `.standard`. + var linkPreviewPreferences = LinkPreviewPreferences() + + /// Preview state per message (keyed by message ID) + var previewStates: [UUID: PreviewLoadState] = [:] + + /// Loaded preview data per message (keyed by message ID) + var loadedPreviews: [UUID: LinkPreviewDataDTO] = [:] + + /// In-flight preview fetch tasks (prevents duplicate fetches) + var previewFetchTasks: [UUID: Task] = [:] + + /// Total cost limit for `loadedImageData`. `NSCache` evicts entries to + /// stay under this byte budget and also responds to system memory + /// pressure on its own. + private static let imageDataCacheLimitBytes = 50 * 1024 * 1024 + + /// Raw image data per message (keyed by message ID). Backed by + /// `NSCache` so memory pressure and the configured cost limit drive + /// eviction instead of an unbounded dictionary. `@ObservationIgnored` + /// because mutations should not trigger SwiftUI redraws — consumers + /// read this via explicit method calls when an image is tapped. + @ObservationIgnored + let loadedImageData: NSCache = { + let cache = NSCache() + cache.totalCostLimit = ChatViewModel.imageDataCacheLimitBytes + return cache + }() + + /// Pre-decoded UIImage per message (avoids decoding in view body) + var decodedImages: [UUID: UIImage] = [:] + + /// Pre-decoded link preview assets (single dictionary to batch Observable notifications) + var decodedPreviewAssets: [UUID: DecodedPreviewAssets] = [:] + + /// Tracks in-flight legacy preview decode tasks to prevent duplicates + var legacyPreviewDecodeInFlight: Set = [] + + /// Whether each image message is a GIF (computed once during decode) + var imageIsGIF: [UUID: Bool] = [:] + + /// In-flight image fetch tasks + var imageFetchTasks: [UUID: Task] = [:] + + /// In-flight reaction sends (prevents duplicate reactions on rapid taps) + /// Key format: "{messageID}-{emoji}" + var inFlightReactions: Set = [] + + /// Cached URL detection results to avoid re-running NSDataDetector on rebuilds + var cachedURLs: [UUID: URL?] = [:] + + /// Image-extension URLs the fetch path has discovered serve an HTML page, + /// not image bytes (imgur, pasteboard, prnt.sc). Keyed by URL string so one + /// discovery reroutes every loaded message sharing it. Gates the synchronous + /// build path (`isInlineImageURL`, `shouldRequestImageFetch`); cleared on + /// conversation switch, so re-entering a chat re-fetches each page URL once. + var imageURLsServingPages: Set = [] + + /// Maps a snapshot request to the messages that show its thumbnail, so a late + /// `resolutionStream` event rebuilds only those rows (O(matches)) instead of + /// regex-scanning every loaded message. Populated in `makeBuildInputs`, + /// cleared on conversation switch. + @ObservationIgnored var mapPreviewRequestIndex: [MapSnapshotRequest: Set] = [:] + + // MARK: - Pagination State + + /// Whether currently fetching older messages (exposed for UI binding) + var isLoadingOlder: Bool { + renderState.isLoadingOlder + } + + /// Whether more messages exist beyond what's loaded + var hasMoreMessages: Bool { + renderState.hasMoreMessages + } + + /// Total messages fetched from database (unfiltered, for accurate offset calculation) + var totalFetchedCount: Int { + renderState.totalFetchedCount + } + + /// Message ID that should show the "New Messages" divider above it + var newMessagesDividerMessageID: UUID? + + /// Whether the divider position has been computed for the current conversation + var dividerComputed = false + + /// Unread count above which the "New Messages" divider is shown (strictly greater). + private let newMessagesDividerThreshold = 10 + + /// Computes the divider message ID from a fetched (unfiltered) message array. + /// Must be called before filtering. Sets `dividerComputed = true`. + /// + /// Positional: the divider sits `unreadCount` rows from the end. This relies on unread + /// messages occupying the array tail, which block-at-reconnect upholds — every unread row + /// (live or drained) takes a sortDate at or after its receive/drain time, later than any + /// already-read row, so unread always sorts to the tail. Do not switch this to a + /// `first(where: { !$0.isRead })` scan: per-message `isRead` is not maintained on chat open + /// (only the unread counter is cleared), so the scan would land on the first row of the page. + /// + /// The boundary row may be a sent outgoing reaction that `filterOutgoingReactionMessages` + /// drops before the items are built; the divider id must survive that filter, so advance + /// past any hidden row to the next visible one (toward newer), which renders at the same + /// visual position. + func computeDividerPosition(from messages: [MessageDTO], unreadCount: Int, isDM: Bool) { + guard !dividerComputed, unreadCount > newMessagesDividerThreshold else { return } + var dividerIndex = max(0, messages.count - unreadCount) + while dividerIndex < messages.count, isHiddenOutgoingReaction(messages[dividerIndex], isDM: isDM) { + dividerIndex += 1 } - - // MARK: - Dependencies - - /// Groups all provider closures for the `configure` call. Provider closures - /// are re-evaluated at every use so a disconnect (or a reconnect's fresh - /// per-connection services) is picked up live; a nil read means disconnected. - struct Dependencies { - var dataStore: @MainActor () -> DataStore? - var messageService: @MainActor () -> MessageService? - var notificationService: @MainActor () -> NotificationService? - var channelService: @MainActor () -> ChannelService? - var roomServerService: @MainActor () -> RoomServerService? - var contactService: @MainActor () -> ContactService? - var syncCoordinator: @MainActor () -> SyncCoordinator? - var connectionState: @MainActor () -> DeviceConnectionState - var connectedDevice: @MainActor () -> DeviceDTO? - var currentRadioID: @MainActor () -> UUID? - var session: @MainActor () -> MeshCoreSession? - var reactionService: @MainActor () -> ReactionService? - var chatSendQueueService: @MainActor () -> ChatSendQueueService? - var inlineImageDimensionsStore: @MainActor () -> InlineImageDimensionsStore? - var prefetchDataStore: @MainActor () -> (any PersistenceStoreProtocol)? + if dividerIndex < messages.count { + newMessagesDividerMessageID = messages[dividerIndex].id } - - @ObservationIgnored private var dataStoreProvider: @MainActor () -> DataStore? = { nil } - var dataStore: DataStore? { dataStoreProvider() } - - @ObservationIgnored private var messageServiceProvider: @MainActor () -> MessageService? = { nil } - var messageService: MessageService? { messageServiceProvider() } - - @ObservationIgnored private var notificationServiceProvider: @MainActor () -> NotificationService? = { nil } - var notificationService: NotificationService? { notificationServiceProvider() } - - @ObservationIgnored private var channelServiceProvider: @MainActor () -> ChannelService? = { nil } - private var channelService: ChannelService? { channelServiceProvider() } - - @ObservationIgnored private var roomServerServiceProvider: @MainActor () -> RoomServerService? = { nil } - private var roomServerService: RoomServerService? { roomServerServiceProvider() } - - @ObservationIgnored private var contactServiceProvider: @MainActor () -> ContactService? = { nil } - var contactService: ContactService? { contactServiceProvider() } - - @ObservationIgnored private var syncCoordinatorProvider: @MainActor () -> SyncCoordinator? = { nil } - var syncCoordinator: SyncCoordinator? { syncCoordinatorProvider() } - - @ObservationIgnored var connectionStateProvider: @MainActor () -> DeviceConnectionState = { .disconnected } - @ObservationIgnored var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } - @ObservationIgnored var currentRadioIDProvider: @MainActor () -> UUID? = { nil } - @ObservationIgnored var sessionProvider: @MainActor () -> MeshCoreSession? = { nil } - @ObservationIgnored var reactionServiceProvider: @MainActor () -> ReactionService? = { nil } - @ObservationIgnored var chatSendQueueServiceProvider: @MainActor () -> ChatSendQueueService? = { nil } - - @ObservationIgnored private var inlineImageDimensionsStoreProvider: @MainActor () -> InlineImageDimensionsStore? = { nil } - var inlineImageDimensionsStore: InlineImageDimensionsStore? { inlineImageDimensionsStoreProvider() } - - @ObservationIgnored private var prefetchDataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } - - /// App-lifetime cache for link previews. Passed directly from the environment; - /// held for the screen's lifetime and used in `fetchPreview` and `manualFetchPreview`. - @ObservationIgnored var linkPreviewCache: (any LinkPreviewCaching)? - - /// Navigation sink for map-thumbnail taps; nil makes the tap a no-op - /// (the always-present text link remains the baseline). - @ObservationIgnored var onNavigateToMap: ((CLLocationCoordinate2D) -> Void)? - - /// Drives receive-time prefetch of inline image dimensions and link - /// preview metadata so message bubbles render at final size on first - /// paint. Constructed in `configure(...)` when services are available; - /// nil while disconnected (offline browse never receives new messages). - @ObservationIgnored var prefetcher: InlineImagePrefetcher? - - /// Long-running subscription to `InlineImageDimensionsStore.resolutionStream`. - /// On each emitted URL, every message whose body contains that URL is - /// rebuilt so the bubble picks up the now-known `cachedAspect`. - @ObservationIgnored var dimensionResolutionTask: Task? - - /// Long-running subscription to `MapSnapshotStore.shared.resolutionStream`. - /// Started once (the store is a process-lifetime singleton). - @ObservationIgnored var snapshotResolutionTask: Task? - - /// Per-instance override of the receive-time prefetch timeout. Production - /// callers leave this at `defaultPrefetchTimeout` (3s); tests can shorten - /// it to bound their wall-clock budget. - @ObservationIgnored var prefetchTimeout: Duration = ChatViewModel.defaultPrefetchTimeout - - /// Contact ID currently having its favorite status toggled (for loading UI) - var togglingFavoriteID: UUID? - - // MARK: - Initialization - - init() {} - - /// Forwards a map-thumbnail tap to the same navigation sink the coordinate - /// text link uses. `onNavigateToMap` is optional; if nil, the tap is a - /// no-op (the always-present text link remains the baseline). - func navigateToMap(_ coordinate: CLLocationCoordinate2D) { - onNavigateToMap?(coordinate) + dividerComputed = true + } + + // MARK: - Dependencies + + /// Groups all provider closures for the `configure` call. Provider closures + /// are re-evaluated at every use so a disconnect (or a reconnect's fresh + /// per-connection services) is picked up live; a nil read means disconnected. + struct Dependencies { + var dataStore: @MainActor () -> DataStore? + var messageService: @MainActor () -> MessageService? + var notificationService: @MainActor () -> NotificationService? + var channelService: @MainActor () -> ChannelService? + var roomServerService: @MainActor () -> RoomServerService? + var contactService: @MainActor () -> ContactService? + var syncCoordinator: @MainActor () -> SyncCoordinator? + var connectionState: @MainActor () -> DeviceConnectionState + var connectedDevice: @MainActor () -> DeviceDTO? + var currentRadioID: @MainActor () -> UUID? + var session: @MainActor () -> MeshCoreSession? + var reactionService: @MainActor () -> ReactionService? + var chatSendQueueService: @MainActor () -> ChatSendQueueService? + var inlineImageDimensionsStore: @MainActor () -> InlineImageDimensionsStore? + var prefetchDataStore: @MainActor () -> (any PersistenceStoreProtocol)? + } + + @ObservationIgnored private var dataStoreProvider: @MainActor () -> DataStore? = { nil } + var dataStore: DataStore? { + dataStoreProvider() + } + + @ObservationIgnored private var messageServiceProvider: @MainActor () -> MessageService? = { nil } + var messageService: MessageService? { + messageServiceProvider() + } + + @ObservationIgnored private var notificationServiceProvider: @MainActor () -> NotificationService? = { nil } + var notificationService: NotificationService? { + notificationServiceProvider() + } + + @ObservationIgnored private var channelServiceProvider: @MainActor () -> ChannelService? = { nil } + private var channelService: ChannelService? { + channelServiceProvider() + } + + @ObservationIgnored private var roomServerServiceProvider: @MainActor () -> RoomServerService? = { nil } + private var roomServerService: RoomServerService? { + roomServerServiceProvider() + } + + @ObservationIgnored private var contactServiceProvider: @MainActor () -> ContactService? = { nil } + var contactService: ContactService? { + contactServiceProvider() + } + + @ObservationIgnored private var syncCoordinatorProvider: @MainActor () -> SyncCoordinator? = { nil } + var syncCoordinator: SyncCoordinator? { + syncCoordinatorProvider() + } + + @ObservationIgnored var connectionStateProvider: @MainActor () -> DeviceConnectionState = { .disconnected } + @ObservationIgnored var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } + @ObservationIgnored var currentRadioIDProvider: @MainActor () -> UUID? = { nil } + @ObservationIgnored var sessionProvider: @MainActor () -> MeshCoreSession? = { nil } + @ObservationIgnored var reactionServiceProvider: @MainActor () -> ReactionService? = { nil } + @ObservationIgnored var chatSendQueueServiceProvider: @MainActor () -> ChatSendQueueService? = { nil } + + @ObservationIgnored private var inlineImageDimensionsStoreProvider: @MainActor () -> InlineImageDimensionsStore? = { nil } + var inlineImageDimensionsStore: InlineImageDimensionsStore? { + inlineImageDimensionsStoreProvider() + } + + @ObservationIgnored private var prefetchDataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } + + /// App-lifetime cache for link previews. Passed directly from the environment; + /// held for the screen's lifetime and used in `fetchPreview` and `manualFetchPreview`. + @ObservationIgnored var linkPreviewCache: (any LinkPreviewCaching)? + + /// Navigation sink for map-thumbnail taps; nil makes the tap a no-op + /// (the always-present text link remains the baseline). + @ObservationIgnored var onNavigateToMap: ((CLLocationCoordinate2D) -> Void)? + + /// Drives receive-time prefetch of inline image dimensions and link + /// preview metadata so message bubbles render at final size on first + /// paint. Constructed in `configure(...)` when services are available; + /// nil while disconnected (offline browse never receives new messages). + @ObservationIgnored var prefetcher: InlineImagePrefetcher? + + /// Long-running subscription to `InlineImageDimensionsStore.resolutionStream`. + /// On each emitted URL, every message whose body contains that URL is + /// rebuilt so the bubble picks up the now-known `cachedAspect`. + @ObservationIgnored var dimensionResolutionTask: Task? + + /// Long-running subscription to `MapSnapshotStore.shared.resolutionStream`. + /// Started once (the store is a process-lifetime singleton). + @ObservationIgnored var snapshotResolutionTask: Task? + + /// Per-instance override of the receive-time prefetch timeout. Production + /// callers leave this at `defaultPrefetchTimeout` (3s); tests can shorten + /// it to bound their wall-clock budget. + @ObservationIgnored var prefetchTimeout: Duration = ChatViewModel.defaultPrefetchTimeout + + /// Contact ID currently having its favorite status toggled (for loading UI) + var togglingFavoriteID: UUID? + + // MARK: - Initialization + + init() {} + + /// Forwards a map-thumbnail tap to the same navigation sink the coordinate + /// text link uses. `onNavigateToMap` is optional; if nil, the tap is a + /// no-op (the always-present text link remains the baseline). + func navigateToMap(_ coordinate: CLLocationCoordinate2D) { + onNavigateToMap?(coordinate) + } + + /// Configure the chat view model for a conversation list or a specific conversation. + /// Conversation views also pass `linkPreviewCache`, `chatCoordinatorRegistry`, and + /// `conversation` so the per-conversation `ChatCoordinator` is bound before the + /// first view body evaluates; list views omit them. + func configure( + dependencies: Dependencies, + onNavigateToMap: ((CLLocationCoordinate2D) -> Void)?, + linkPreviewCache: (any LinkPreviewCaching)?, + chatCoordinatorRegistry: ChatCoordinatorRegistry?, + conversation: ChatConversationType? + ) { + dataStoreProvider = dependencies.dataStore + messageServiceProvider = dependencies.messageService + notificationServiceProvider = dependencies.notificationService + channelServiceProvider = dependencies.channelService + roomServerServiceProvider = dependencies.roomServerService + contactServiceProvider = dependencies.contactService + syncCoordinatorProvider = dependencies.syncCoordinator + connectionStateProvider = dependencies.connectionState + connectedDeviceProvider = dependencies.connectedDevice + currentRadioIDProvider = dependencies.currentRadioID + sessionProvider = dependencies.session + reactionServiceProvider = dependencies.reactionService + chatSendQueueServiceProvider = dependencies.chatSendQueueService + inlineImageDimensionsStoreProvider = dependencies.inlineImageDimensionsStore + prefetchDataStoreProvider = dependencies.prefetchDataStore + self.onNavigateToMap = onNavigateToMap + lastSetRegionScope = .unknown + if let linkPreviewCache { + self.linkPreviewCache = linkPreviewCache + configurePrefetcher( + linkPreviewCache: linkPreviewCache, + dimensionsStore: inlineImageDimensionsStoreProvider(), + prefetchDataStore: prefetchDataStoreProvider() + ) } - - /// Configure the chat view model for a conversation list or a specific conversation. - /// Conversation views also pass `linkPreviewCache`, `chatCoordinatorRegistry`, and - /// `conversation` so the per-conversation `ChatCoordinator` is bound before the - /// first view body evaluates; list views omit them. - func configure( - dependencies: Dependencies, - onNavigateToMap: ((CLLocationCoordinate2D) -> Void)?, - linkPreviewCache: (any LinkPreviewCaching)?, - chatCoordinatorRegistry: ChatCoordinatorRegistry?, - conversation: ChatConversationType? - ) { - self.dataStoreProvider = dependencies.dataStore - self.messageServiceProvider = dependencies.messageService - self.notificationServiceProvider = dependencies.notificationService - self.channelServiceProvider = dependencies.channelService - self.roomServerServiceProvider = dependencies.roomServerService - self.contactServiceProvider = dependencies.contactService - self.syncCoordinatorProvider = dependencies.syncCoordinator - self.connectionStateProvider = dependencies.connectionState - self.connectedDeviceProvider = dependencies.connectedDevice - self.currentRadioIDProvider = dependencies.currentRadioID - self.sessionProvider = dependencies.session - self.reactionServiceProvider = dependencies.reactionService - self.chatSendQueueServiceProvider = dependencies.chatSendQueueService - self.inlineImageDimensionsStoreProvider = dependencies.inlineImageDimensionsStore - self.prefetchDataStoreProvider = dependencies.prefetchDataStore - self.onNavigateToMap = onNavigateToMap - self.lastSetRegionScope = .unknown - if let linkPreviewCache { - self.linkPreviewCache = linkPreviewCache - configurePrefetcher( - linkPreviewCache: linkPreviewCache, - dimensionsStore: inlineImageDimensionsStoreProvider(), - prefetchDataStore: prefetchDataStoreProvider() - ) - } - bindCoordinator(registry: chatCoordinatorRegistry, conversation: conversation) + bindCoordinator(registry: chatCoordinatorRegistry, conversation: conversation) + } + + private func bindCoordinator(registry: ChatCoordinatorRegistry?, conversation: ChatConversationType?) { + guard let conversation else { return } + guard let registry else { return } + let id: ChatConversationID = switch conversation { + case let .dm(contact): + .dm(radioID: contact.radioID, contactID: contact.id) + case let .channel(channel): + .channel(radioID: channel.radioID, channelIndex: channel.index) } - - private func bindCoordinator(registry: ChatCoordinatorRegistry?, conversation: ChatConversationType?) { - guard let conversation else { return } - guard let registry else { return } - let id: ChatConversationID - switch conversation { - case .dm(let contact): - id = .dm(radioID: contact.radioID, contactID: contact.id) - case .channel(let channel): - id = .channel(radioID: channel.radioID, channelIndex: channel.index) - } - let resolved = registry.coordinator(for: id) - // The most recently bound view model owns the per-ID rebuild hook - // the coordinator invokes after `applyReloadedIDs`. With two view - // models on the same conversation (iPad split view) the rendered - // state stays consistent because both observe the shared - // coordinator's `renderState` — only the per-view-model snapshot - // inputs (preview state, decoded images) come from the bound - // rebuilder. - resolved.renderItemRebuilder = { [weak self] messageID in - self?.rebuildDisplayItem(for: messageID) - } - resolved.renderStateInvalidated = { [weak self] in - self?.handleRenderStateInvalidated() - } - coordinator = resolved + let resolved = registry.coordinator(for: id) + // The most recently bound view model owns the per-ID rebuild hook + // the coordinator invokes after `applyReloadedIDs`. With two view + // models on the same conversation (iPad split view) the rendered + // state stays consistent because both observe the shared + // coordinator's `renderState` — only the per-view-model snapshot + // inputs (preview state, decoded images) come from the bound + // rebuilder. + resolved.renderItemRebuilder = { [weak self] messageID in + self?.rebuildDisplayItem(for: messageID) } - - private func handleRenderStateInvalidated() { - buildItems() + resolved.renderStateInvalidated = { [weak self] in + self?.handleRenderStateInvalidated() } - - /// Build the receive-time prefetcher and start (or restart) the - /// dimension-resolution subscription. Called from `configure(...)` when a - /// `linkPreviewCache` is supplied. The per-connection inputs are nil while - /// disconnected (offline browse), which tears the prefetcher down. - private func configurePrefetcher( - linkPreviewCache: any LinkPreviewCaching, - dimensionsStore: InlineImageDimensionsStore?, - prefetchDataStore: (any PersistenceStoreProtocol)? - ) { - // The snapshot-resolution stream is a process-lifetime singleton with no - // dependency on per-connection services, so subscribe once regardless of - // connection state: offline browse of cached coordinate messages still - // needs late snapshot resolutions to refresh their thumbnails. - if snapshotResolutionTask == nil { - snapshotResolutionTask = Task { [weak self] in - for await request in MapSnapshotStore.shared.resolutionStream() { - guard !Task.isCancelled else { return } - self?.handleSnapshotResolution(request) - } - } - } - - guard let dimensionsStore, let prefetchDataStore else { - prefetcher = nil - dimensionResolutionTask?.cancel() - dimensionResolutionTask = nil - return + coordinator = resolved + } + + private func handleRenderStateInvalidated() { + buildItems() + } + + /// Build the receive-time prefetcher and start (or restart) the + /// dimension-resolution subscription. Called from `configure(...)` when a + /// `linkPreviewCache` is supplied. The per-connection inputs are nil while + /// disconnected (offline browse), which tears the prefetcher down. + private func configurePrefetcher( + linkPreviewCache: any LinkPreviewCaching, + dimensionsStore: InlineImageDimensionsStore?, + prefetchDataStore: (any PersistenceStoreProtocol)? + ) { + // The snapshot-resolution stream is a process-lifetime singleton with no + // dependency on per-connection services, so subscribe once regardless of + // connection state: offline browse of cached coordinate messages still + // needs late snapshot resolutions to refresh their thumbnails. + if snapshotResolutionTask == nil { + snapshotResolutionTask = Task { [weak self] in + for await request in MapSnapshotStore.shared.resolutionStream() { + guard !Task.isCancelled else { return } + self?.handleSnapshotResolution(request) } - prefetcher = InlineImagePrefetcher( - imageCache: InlineImageCache.shared, - linkPreviewCache: linkPreviewCache, - dimensionsStore: dimensionsStore, - dataStore: prefetchDataStore - ) - startObservingDimensionResolutions(store: dimensionsStore) + } } - private func startObservingDimensionResolutions(store: InlineImageDimensionsStore) { - dimensionResolutionTask?.cancel() - dimensionResolutionTask = Task { [weak self] in - for await resolvedURL in store.resolutionStream { - guard !Task.isCancelled else { return } - await self?.handleDimensionResolution(resolvedURL) - } - } + guard let dimensionsStore, let prefetchDataStore else { + prefetcher = nil + dimensionResolutionTask?.cancel() + dimensionResolutionTask = nil + return } - - deinit { - dimensionResolutionTask?.cancel() - snapshotResolutionTask?.cancel() + prefetcher = InlineImagePrefetcher( + imageCache: InlineImageCache.shared, + linkPreviewCache: linkPreviewCache, + dimensionsStore: dimensionsStore, + dataStore: prefetchDataStore + ) + startObservingDimensionResolutions(store: dimensionsStore) + } + + private func startObservingDimensionResolutions(store: InlineImageDimensionsStore) { + dimensionResolutionTask?.cancel() + dimensionResolutionTask = Task { [weak self] in + for await resolvedURL in store.resolutionStream { + guard !Task.isCancelled else { return } + await self?.handleDimensionResolution(resolvedURL) + } } + } - static func copyForEnqueueFailure(_ error: Error) -> String { - if case ChatSendQueueServiceError.notConnected = error { - return L10n.Chats.Chats.Alert.UnableToSend.message - } - return L10n.Chats.Chats.Error.sendQueuePersistFailed - } + deinit { + dimensionResolutionTask?.cancel() + snapshotResolutionTask?.cancel() + } + static func copyForEnqueueFailure(_ error: Error) -> String { + if case ChatSendQueueServiceError.notConnected = error { + return L10n.Chats.Chats.Alert.UnableToSend.message + } + return L10n.Chats.Chats.Error.sendQueuePersistFailed + } } // MARK: - Environment Key extension EnvironmentValues { - @Entry var chatViewModel: ChatViewModel? + @Entry var chatViewModel: ChatViewModel? } diff --git a/MC1/Views/Components/AsyncActionLabel.swift b/MC1/Views/Components/AsyncActionLabel.swift index c3d480e9..d1ee5930 100644 --- a/MC1/Views/Components/AsyncActionLabel.swift +++ b/MC1/Views/Components/AsyncActionLabel.swift @@ -5,24 +5,24 @@ import SwiftUI /// Used for buttons that trigger async firmware commands and need visual feedback. /// The success checkmark auto-animates in with a scale+opacity transition. struct AsyncActionLabel: View { - let isLoading: Bool - let showSuccess: Bool - @ViewBuilder let idle: () -> Idle + let isLoading: Bool + let showSuccess: Bool + @ViewBuilder let idle: () -> Idle - var body: some View { - HStack { - Spacer() - if isLoading { - ProgressView() - } else if showSuccess { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.scale.combined(with: .opacity)) - } else { - idle() - } - Spacer() - } - .animation(.default, value: showSuccess) + var body: some View { + HStack { + Spacer() + if isLoading { + ProgressView() + } else if showSuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + idle() + } + Spacer() } + .animation(.default, value: showSuccess) + } } diff --git a/MC1/Views/Components/BLEStatusIndicatorView.swift b/MC1/Views/Components/BLEStatusIndicatorView.swift index af87c679..09cc9ca6 100644 --- a/MC1/Views/Components/BLEStatusIndicatorView.swift +++ b/MC1/Views/Components/BLEStatusIndicatorView.swift @@ -1,216 +1,216 @@ +import MC1Services import OSLog import SwiftUI import TipKit -import MC1Services private let logger = Logger(subsystem: "com.mc1", category: "BLEStatus") /// BLE connection status indicator for toolbar display /// Shows connection state via color-coded icon with menu details struct BLEStatusIndicatorView: View { - @Environment(\.appState) private var appState - @State private var showingDeviceSelection = false - @State private var showingAdvancedSettings = false - @State private var isSendingAdvert = false - @State private var successFeedbackTrigger = false - @State private var errorFeedbackTrigger = false - - private let deviceMenuTip = DeviceMenuTip() - - // One always-present Menu, never an if/else between a Menu and a Button: SwiftUI - // gives a branch's two arms distinct identities, so toggling on connection state - // rebuilds the hosted toolbar item mid-update and trips a graph re-entrancy crash - // on iOS 26. Varying only the label and menu content by value updates it in place. - var body: some View { - ToolbarMenu { - menuContent - } label: { - StatusIcon(iconName: iconName, iconColor: iconColor, isAnimating: isAnimating) - } - .popoverTip(deviceMenuTip) - .dynamicTypeSize(...DynamicTypeSize.xLarge) - .sensoryFeedback(.success, trigger: successFeedbackTrigger) - .sensoryFeedback(.error, trigger: errorFeedbackTrigger) - .accessibilityLabel(L10n.Settings.BleStatus.accessibilityLabel) - .accessibilityValue(statusTitle) - .accessibilityHint(accessibilityHint) - .onChange(of: appState.connectedDevice != nil, initial: true) { _, isConnected in - DeviceMenuTip.isConnected = isConnected - } - .sheet(isPresented: $showingDeviceSelection) { - DeviceSelectionSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - .navigationDestination(isPresented: $showingAdvancedSettings) { - AdvancedSettingsView() - } + @Environment(\.appState) private var appState + @State private var showingDeviceSelection = false + @State private var showingAdvancedSettings = false + @State private var isSendingAdvert = false + @State private var successFeedbackTrigger = false + @State private var errorFeedbackTrigger = false + + private let deviceMenuTip = DeviceMenuTip() + + /// One always-present Menu, never an if/else between a Menu and a Button: SwiftUI + /// gives a branch's two arms distinct identities, so toggling on connection state + /// rebuilds the hosted toolbar item mid-update and trips a graph re-entrancy crash + /// on iOS 26. Varying only the label and menu content by value updates it in place. + var body: some View { + ToolbarMenu { + menuContent + } label: { + StatusIcon(iconName: iconName, iconColor: iconColor, isAnimating: isAnimating) } - - // MARK: - Menu Content - - @ViewBuilder - private var menuContent: some View { - if let device = appState.connectedDevice { - Section { - if device.clientRepeat { - Label(L10n.Settings.BleStatus.repeatModeActive, systemImage: "repeat") - .foregroundStyle(AppColors.Radio.repeatMode) - } - VStack(alignment: .leading) { - Label(device.nodeName, systemImage: "antenna.radiowaves.left.and.right") - if let battery = appState.batteryMonitor.deviceBattery { - let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) - Label( - "\(battery.percentage(using: ocvArray))% (\(battery.voltage, format: .number.precision(.fractionLength(2)))v)", - systemImage: battery.iconName(using: ocvArray) - ) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Button { - showingDeviceSelection = true - } label: { - Label(L10n.Settings.BleStatus.changeDevice, systemImage: "flipphone") - } - - Button(role: .destructive) { - logger.info("Disconnect tapped in BLE status menu") - Task { - await appState.disconnect(reason: .statusMenuDisconnectTap) - } - } label: { - Label(L10n.Settings.BleStatus.disconnect, systemImage: "eject") - } - } - - Section { - Button { - sendAdvert(flood: false) - } label: { - Label(L10n.Settings.BleStatus.sendZeroHopAdvert, systemImage: "dot.radiowaves.right") - } - .radioDisabled(for: appState.connectionState, or: isSendingAdvert) - .accessibilityHint(L10n.Settings.BleStatus.SendZeroHopAdvert.hint) - - Button { - sendAdvert(flood: true) - } label: { - Label(L10n.Settings.BleStatus.sendFloodAdvert, systemImage: "dot.radiowaves.left.and.right") - } - .radioDisabled(for: appState.connectionState, or: isSendingAdvert) - .accessibilityHint(L10n.Settings.BleStatus.SendFloodAdvert.hint) - } - - Section { - Button { - showingAdvancedSettings = true - } label: { - Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") - } - } - } else { - Button { - showingDeviceSelection = true - } label: { - Label(L10n.Settings.Device.connect, systemImage: "antenna.radiowaves.left.and.right") - } - } + .popoverTip(deviceMenuTip) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + .sensoryFeedback(.success, trigger: successFeedbackTrigger) + .sensoryFeedback(.error, trigger: errorFeedbackTrigger) + .accessibilityLabel(L10n.Settings.BleStatus.accessibilityLabel) + .accessibilityValue(statusTitle) + .accessibilityHint(accessibilityHint) + .onChange(of: appState.connectedDevice != nil, initial: true) { _, isConnected in + DeviceMenuTip.isConnected = isConnected } - - private var accessibilityHint: String { - appState.connectedDevice != nil - ? L10n.Settings.BleStatus.AccessibilityHint.connected - : L10n.Settings.BleStatus.AccessibilityHint.disconnected + .sheet(isPresented: $showingDeviceSelection) { + DeviceSelectionSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) } + .navigationDestination(isPresented: $showingAdvancedSettings) { + AdvancedSettingsView() + } + } - // MARK: - Computed Properties + // MARK: - Menu Content - private var iconName: String { - switch appState.connectionState { - case .disconnected: - "antenna.radiowaves.left.and.right.slash" - case .connecting, .connected, .syncing, .ready: - "antenna.radiowaves.left.and.right" + @ViewBuilder + private var menuContent: some View { + if let device = appState.connectedDevice { + Section { + if device.clientRepeat { + Label(L10n.Settings.BleStatus.repeatModeActive, systemImage: "repeat") + .foregroundStyle(AppColors.Radio.repeatMode) + } + VStack(alignment: .leading) { + Label(device.nodeName, systemImage: "antenna.radiowaves.left.and.right") + if let battery = appState.batteryMonitor.deviceBattery { + let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) + Label( + "\(battery.percentage(using: ocvArray))% (\(battery.voltage, format: .number.precision(.fractionLength(2)))v)", + systemImage: battery.iconName(using: ocvArray) + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Button { + showingDeviceSelection = true + } label: { + Label(L10n.Settings.BleStatus.changeDevice, systemImage: "flipphone") } - } - private var iconColor: Color { - if appState.connectedDevice?.clientRepeat == true { - return AppColors.Radio.repeatMode + Button(role: .destructive) { + logger.info("Disconnect tapped in BLE status menu") + Task { + await appState.disconnect(reason: .statusMenuDisconnectTap) + } + } label: { + Label(L10n.Settings.BleStatus.disconnect, systemImage: "eject") } - switch appState.connectionState { - case .disconnected: - return .secondary - case .connecting, .connected, .syncing: - return AppColors.Radio.connecting - case .ready: - return AppColors.Radio.ready + } + + Section { + Button { + sendAdvert(flood: false) + } label: { + Label(L10n.Settings.BleStatus.sendZeroHopAdvert, systemImage: "dot.radiowaves.right") } - } + .radioDisabled(for: appState.connectionState, or: isSendingAdvert) + .accessibilityHint(L10n.Settings.BleStatus.SendZeroHopAdvert.hint) - private var isAnimating: Bool { - appState.connectionState == .connecting - } + Button { + sendAdvert(flood: true) + } label: { + Label(L10n.Settings.BleStatus.sendFloodAdvert, systemImage: "dot.radiowaves.left.and.right") + } + .radioDisabled(for: appState.connectionState, or: isSendingAdvert) + .accessibilityHint(L10n.Settings.BleStatus.SendFloodAdvert.hint) + } - private var statusTitle: String { - switch appState.connectionState { - case .disconnected: - L10n.Settings.BleStatus.Status.disconnected - case .connecting: - L10n.Settings.BleStatus.Status.connecting - case .connected: - L10n.Settings.BleStatus.Status.connected - case .syncing: - L10n.Settings.BleStatus.Status.syncing - case .ready: - L10n.Settings.BleStatus.Status.ready + Section { + Button { + showingAdvancedSettings = true + } label: { + Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") } + } + } else { + Button { + showingDeviceSelection = true + } label: { + Label(L10n.Settings.Device.connect, systemImage: "antenna.radiowaves.left.and.right") + } } + } + + private var accessibilityHint: String { + appState.connectedDevice != nil + ? L10n.Settings.BleStatus.AccessibilityHint.connected + : L10n.Settings.BleStatus.AccessibilityHint.disconnected + } + + // MARK: - Computed Properties + + private var iconName: String { + switch appState.connectionState { + case .disconnected: + "antenna.radiowaves.left.and.right.slash" + case .connecting, .connected, .syncing, .ready: + "antenna.radiowaves.left.and.right" + } + } - // MARK: - Actions - - private func sendAdvert(flood: Bool) { - guard !isSendingAdvert else { return } - isSendingAdvert = true - - Task { - do { - try await appState.sendSelfAdvert(flood: flood) - successFeedbackTrigger.toggle() - } catch { - logger.error("Failed to send advert (flood=\(flood)): \(error.localizedDescription)") - errorFeedbackTrigger.toggle() - } - isSendingAdvert = false - } + private var iconColor: Color { + if appState.connectedDevice?.clientRepeat == true { + return AppColors.Radio.repeatMode + } + switch appState.connectionState { + case .disconnected: + return .secondary + case .connecting, .connected, .syncing: + return AppColors.Radio.connecting + case .ready: + return AppColors.Radio.ready + } + } + + private var isAnimating: Bool { + appState.connectionState == .connecting + } + + private var statusTitle: String { + switch appState.connectionState { + case .disconnected: + L10n.Settings.BleStatus.Status.disconnected + case .connecting: + L10n.Settings.BleStatus.Status.connecting + case .connected: + L10n.Settings.BleStatus.Status.connected + case .syncing: + L10n.Settings.BleStatus.Status.syncing + case .ready: + L10n.Settings.BleStatus.Status.ready } + } + + // MARK: - Actions + + private func sendAdvert(flood: Bool) { + guard !isSendingAdvert else { return } + isSendingAdvert = true + + Task { + do { + try await appState.sendSelfAdvert(flood: flood) + successFeedbackTrigger.toggle() + } catch { + logger.error("Failed to send advert (flood=\(flood)): \(error.localizedDescription)") + errorFeedbackTrigger.toggle() + } + isSendingAdvert = false + } + } } // MARK: - Status Icon private struct StatusIcon: View { - let iconName: String - let iconColor: Color - let isAnimating: Bool - - var body: some View { - Image(systemName: iconName) - .foregroundStyle(iconColor) - .symbolEffect(.pulse, isActive: isAnimating) - } + let iconName: String + let iconColor: Color + let isAnimating: Bool + + var body: some View { + Image(systemName: iconName) + .foregroundStyle(iconColor) + .symbolEffect(.pulse, isActive: isAnimating) + } } #Preview("Disconnected") { - NavigationStack { - Text("Content") - .toolbar { - ToolbarItem(placement: .topBarLeading) { - BLEStatusIndicatorView() - } - } - } - .environment(\.appState, AppState()) + NavigationStack { + Text("Content") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + BLEStatusIndicatorView() + } + } + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Components/BLEStatusToolbarItem.swift b/MC1/Views/Components/BLEStatusToolbarItem.swift index 5df96b3f..1d73eb64 100644 --- a/MC1/Views/Components/BLEStatusToolbarItem.swift +++ b/MC1/Views/Components/BLEStatusToolbarItem.swift @@ -8,7 +8,7 @@ import SwiftUI @MainActor @ToolbarContentBuilder func bleStatusToolbarItem(placement: ToolbarItemPlacement = .topBarLeading) -> some ToolbarContent { - ToolbarItem(placement: placement) { - BLEStatusIndicatorView() - } + ToolbarItem(placement: placement) { + BLEStatusIndicatorView() + } } diff --git a/MC1/Views/Components/ContactAvatar.swift b/MC1/Views/Components/ContactAvatar.swift index 55b2b36c..c75a8b65 100644 --- a/MC1/Views/Components/ContactAvatar.swift +++ b/MC1/Views/Components/ContactAvatar.swift @@ -1,52 +1,52 @@ -import SwiftUI import MC1Services +import SwiftUI struct ContactAvatar: View { - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - let name: String - let size: CGFloat + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + let name: String + let size: CGFloat - init(contact: ContactDTO, size: CGFloat) { - self.name = contact.displayName - self.size = size - } + init(contact: ContactDTO, size: CGFloat) { + name = contact.displayName + self.size = size + } - init(name: String, size: CGFloat) { - self.name = name - self.size = size - } + init(name: String, size: CGFloat) { + self.name = name + self.size = size + } - var body: some View { - Text(initials) - .font(.system(size: size * 0.4, weight: .semibold)) - .foregroundStyle(glyphColor) - .frame(width: size, height: size) - .background(avatarColor, in: .circle) - } + var body: some View { + Text(initials) + .font(.system(size: size * 0.4, weight: .semibold)) + .foregroundStyle(glyphColor) + .frame(width: size, height: size) + .background(avatarColor, in: .circle) + } - private var initials: String { - if let emoji = name.first(where: \.isEmoji) { - return String(emoji) - } - let words = name.split(separator: " ") - if words.count >= 2 { - return String(words[0].prefix(1) + words[1].prefix(1)).uppercased() - } - return String(name.prefix(1)).uppercased() + private var initials: String { + if let emoji = name.first(where: \.isEmoji) { + return String(emoji) } - - private var avatarColor: Color { - theme.identityColor(forName: name, colorScheme: colorScheme, contrast: colorSchemeContrast) + let words = name.split(separator: " ") + if words.count >= 2 { + return String(words[0].prefix(1) + words[1].prefix(1)).uppercased() } + return String(name.prefix(1)).uppercased() + } - private var glyphColor: Color { - theme.avatarGlyphColor( - forFill: avatarColor, - usesCategoryOverride: false, - colorScheme: colorScheme, - contrast: colorSchemeContrast - ) - } + private var avatarColor: Color { + theme.identityColor(forName: name, colorScheme: colorScheme, contrast: colorSchemeContrast) + } + + private var glyphColor: Color { + theme.avatarGlyphColor( + forFill: avatarColor, + usesCategoryOverride: false, + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + } } diff --git a/MC1/Views/Components/ConversationQuickActionsSection.swift b/MC1/Views/Components/ConversationQuickActionsSection.swift index 071644a1..50af1379 100644 --- a/MC1/Views/Components/ConversationQuickActionsSection.swift +++ b/MC1/Views/Components/ConversationQuickActionsSection.swift @@ -1,31 +1,31 @@ -import SwiftUI import MC1Services +import SwiftUI struct ConversationQuickActionsSection: View { - @Environment(\.appTheme) private var theme - @Binding var notificationLevel: NotificationLevel - @Binding var isFavorite: Bool - let availableLevels: [NotificationLevel] + @Environment(\.appTheme) private var theme + @Binding var isFavorite: Bool + @Binding var notificationLevel: NotificationLevel + let availableLevels: [NotificationLevel] - init( - notificationLevel: Binding, - isFavorite: Binding, - availableLevels: [NotificationLevel] = NotificationLevel.allCases - ) { - self._notificationLevel = notificationLevel - self._isFavorite = isFavorite - self.availableLevels = availableLevels - } + init( + isFavorite: Binding, + notificationLevel: Binding, + availableLevels: [NotificationLevel] = NotificationLevel.allCases + ) { + _isFavorite = isFavorite + _notificationLevel = notificationLevel + self.availableLevels = availableLevels + } - var body: some View { - Section { - VStack(spacing: 16) { - NotificationLevelPicker(selection: $notificationLevel, availableLevels: availableLevels) + var body: some View { + Section { + NotificationLevelPicker(selection: $notificationLevel, availableLevels: availableLevels) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - FavoriteToggleRow(isFavorite: $isFavorite) - } - .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - } - .themedRowBackground(theme) + Toggle(isOn: $isFavorite) { + Label(L10n.Chats.Chats.Action.favorite, systemImage: "star") + } } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Components/ConversationTimestamp.swift b/MC1/Views/Components/ConversationTimestamp.swift index dfc0c255..b3daeef6 100644 --- a/MC1/Views/Components/ConversationTimestamp.swift +++ b/MC1/Views/Components/ConversationTimestamp.swift @@ -1,34 +1,34 @@ import SwiftUI struct ConversationTimestamp: View { - let date: Date - var font: Font = .caption - var referenceDate: Date? + let date: Date + var font: Font = .caption + var referenceDate: Date? - var body: some View { - if let referenceDate { - Text(formattedDate(relativeTo: referenceDate)) - .font(font) - .foregroundStyle(.secondary) - } else { - TimelineView(.everyMinute) { context in - Text(formattedDate(relativeTo: context.date)) - .font(font) - .foregroundStyle(.secondary) - } - } + var body: some View { + if let referenceDate { + Text(formattedDate(relativeTo: referenceDate)) + .font(font) + .foregroundStyle(.secondary) + } else { + TimelineView(.everyMinute) { context in + Text(formattedDate(relativeTo: context.date)) + .font(font) + .foregroundStyle(.secondary) + } } + } - private func formattedDate(relativeTo now: Date) -> String { - let calendar = Calendar.current + private func formattedDate(relativeTo now: Date) -> String { + let calendar = Calendar.current - if calendar.isDate(date, inSameDayAs: now) { - return date.formatted(date: .omitted, time: .shortened) - } else if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), - calendar.isDate(date, inSameDayAs: yesterday) { - return date.formatted(.relative(presentation: .named)) - } else { - return date.formatted(.dateTime.month(.abbreviated).day()) - } + if calendar.isDate(date, inSameDayAs: now) { + return date.formatted(date: .omitted, time: .shortened) + } else if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), + calendar.isDate(date, inSameDayAs: yesterday) { + return date.formatted(.relative(presentation: .named)) + } else { + return date.formatted(.dateTime.month(.abbreviated).day()) } + } } diff --git a/MC1/Views/Components/DeletingRowOverlay.swift b/MC1/Views/Components/DeletingRowOverlay.swift index 8030f473..5ea26a3d 100644 --- a/MC1/Views/Components/DeletingRowOverlay.swift +++ b/MC1/Views/Components/DeletingRowOverlay.swift @@ -3,26 +3,26 @@ import SwiftUI /// A trailing spinner plus dim shown while a confirmation-gated delete is in flight for a row. /// Shared by the conversation and node lists. struct DeletingRowOverlay: ViewModifier { - let isDeleting: Bool + let isDeleting: Bool - private static let spinnerTrailingInset: CGFloat = 16 - private static let deletingOpacity: Double = 0.5 + private static let spinnerTrailingInset: CGFloat = 16 + private static let deletingOpacity: Double = 0.5 - func body(content: Content) -> some View { - content - .overlay(alignment: .trailing) { - if isDeleting { - ProgressView().padding(.trailing, Self.spinnerTrailingInset) - } - } - .opacity(isDeleting ? Self.deletingOpacity : 1) - .allowsHitTesting(!isDeleting) - .animation(.easeInOut, value: isDeleting) - } + func body(content: Content) -> some View { + content + .overlay(alignment: .trailing) { + if isDeleting { + ProgressView().padding(.trailing, Self.spinnerTrailingInset) + } + } + .opacity(isDeleting ? Self.deletingOpacity : 1) + .allowsHitTesting(!isDeleting) + .animation(.easeInOut, value: isDeleting) + } } extension View { - func deletingRowOverlay(isDeleting: Bool) -> some View { - modifier(DeletingRowOverlay(isDeleting: isDeleting)) - } + func deletingRowOverlay(isDeleting: Bool) -> some View { + modifier(DeletingRowOverlay(isDeleting: isDeleting)) + } } diff --git a/MC1/Views/Components/ErrorAlertModifier.swift b/MC1/Views/Components/ErrorAlertModifier.swift index 980d4e54..aba554d8 100644 --- a/MC1/Views/Components/ErrorAlertModifier.swift +++ b/MC1/Views/Components/ErrorAlertModifier.swift @@ -5,45 +5,45 @@ import SwiftUI /// `retryAction`, when provided, adds a "Try Again" button alongside OK — used by /// action contexts (product load, chat retry) where a user-initiated retry is meaningful. struct ErrorAlertModifier: ViewModifier { - @Binding var errorMessage: String? - let title: String? - let retryAction: (() -> Void)? + @Binding var errorMessage: String? + let title: String? + let retryAction: (() -> Void)? - private var resolvedTitle: String { - title ?? L10n.Settings.Alert.Error.title - } + private var resolvedTitle: String { + title ?? L10n.Settings.Alert.Error.title + } - private var isPresented: Binding { - Binding( - get: { errorMessage != nil }, - set: { if !$0 { errorMessage = nil } } - ) - } + private var isPresented: Binding { + Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + } - func body(content: Content) -> some View { - content - .alert(resolvedTitle, isPresented: isPresented) { - if let retryAction { - Button(L10n.Localizable.Common.tryAgain) { - errorMessage = nil - retryAction() - } - } - Button(L10n.Localizable.Common.ok) { - errorMessage = nil - } - } message: { - Text(errorMessage ?? "") - } - } + func body(content: Content) -> some View { + content + .alert(resolvedTitle, isPresented: isPresented) { + if let retryAction { + Button(L10n.Localizable.Common.tryAgain) { + errorMessage = nil + retryAction() + } + } + Button(L10n.Localizable.Common.ok) { + errorMessage = nil + } + } message: { + Text(errorMessage ?? "") + } + } } extension View { - func errorAlert( - _ errorMessage: Binding, - title: String? = nil, - retryAction: (() -> Void)? = nil - ) -> some View { - modifier(ErrorAlertModifier(errorMessage: errorMessage, title: title, retryAction: retryAction)) - } + func errorAlert( + _ errorMessage: Binding, + title: String? = nil, + retryAction: (() -> Void)? = nil + ) -> some View { + modifier(ErrorAlertModifier(errorMessage: errorMessage, title: title, retryAction: retryAction)) + } } diff --git a/MC1/Views/Components/ErrorBannerModifier.swift b/MC1/Views/Components/ErrorBannerModifier.swift index 5b3eea57..69560bca 100644 --- a/MC1/Views/Components/ErrorBannerModifier.swift +++ b/MC1/Views/Components/ErrorBannerModifier.swift @@ -5,51 +5,51 @@ import SwiftUI /// strip to dismiss; call sites typically clear the binding on the next /// successful load. struct ErrorBannerModifier: ViewModifier { - @Binding var errorMessage: String? + @Binding var errorMessage: String? - func body(content: Content) -> some View { - content.safeAreaInset(edge: .bottom, spacing: 0) { - if let message = errorMessage { - Button { - errorMessage = nil - } label: { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.circle") - .foregroundStyle(.red) - Text(message) - .font(.footnote) - .foregroundStyle(.primary) - .multilineTextAlignment(.leading) - Spacer(minLength: 0) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) - .contentShape(.rect) - .background( - Color(.systemRed).opacity(0.12), - in: .rect(cornerRadius: 12) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color(.systemRed).opacity(0.25), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .accessibilityLabel(message) - .accessibilityHint(L10n.Chats.Chats.Error.Banner.dismissAccessibilityHint) - } + func body(content: Content) -> some View { + content.safeAreaInset(edge: .bottom, spacing: 0) { + if let message = errorMessage { + Button { + errorMessage = nil + } label: { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.circle") + .foregroundStyle(.red) + Text(message) + .font(.footnote) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .contentShape(.rect) + .background( + Color(.systemRed).opacity(0.12), + in: .rect(cornerRadius: 12) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(.systemRed).opacity(0.25), lineWidth: 1) + ) } + .buttonStyle(.plain) + .accessibilityLabel(message) + .accessibilityHint(L10n.Chats.Chats.Error.Banner.dismissAccessibilityHint) + } } + } } extension View { - /// Mounts a passive error banner driven by a `Binding`. The banner - /// appears at the bottom safe-area inset when the binding is non-nil and - /// stays visible until the user taps it or a call site clears the binding - /// on the next successful load. Use for background failures (loads, - /// prefetch). Use `.errorAlert(...)` for user-initiated failures. - func errorBanner(_ errorMessage: Binding) -> some View { - modifier(ErrorBannerModifier(errorMessage: errorMessage)) - } + /// Mounts a passive error banner driven by a `Binding`. The banner + /// appears at the bottom safe-area inset when the binding is non-nil and + /// stays visible until the user taps it or a call site clears the binding + /// on the next successful load. Use for background failures (loads, + /// prefetch). Use `.errorAlert(...)` for user-initiated failures. + func errorBanner(_ errorMessage: Binding) -> some View { + modifier(ErrorBannerModifier(errorMessage: errorMessage)) + } } diff --git a/MC1/Views/Components/ExpandableSettingsSection.swift b/MC1/Views/Components/ExpandableSettingsSection.swift index a58c9e73..f8930e87 100644 --- a/MC1/Views/Components/ExpandableSettingsSection.swift +++ b/MC1/Views/Components/ExpandableSettingsSection.swift @@ -3,126 +3,126 @@ import SwiftUI /// A collapsible section that auto-loads data when expanded /// More iOS-native than explicit "Load" buttons struct ExpandableSettingsSection: View { - @Environment(\.appTheme) private var theme - let title: String - let icon: String + @Environment(\.appTheme) private var theme + let title: String + let icon: String - @Binding var isExpanded: Bool - let isLoaded: () -> Bool // Closure instead of binding (supports computed properties) - @Binding var isLoading: Bool - @Binding var hasError: Bool + @Binding var isExpanded: Bool + let isLoaded: () -> Bool // Closure instead of binding (supports computed properties) + @Binding var isLoading: Bool + @Binding var hasError: Bool - let onLoad: () async -> Void - let footer: String? - @ViewBuilder let content: () -> Content + let onLoad: () async -> Void + let footer: String? + @ViewBuilder let content: () -> Content - init( - title: String, - icon: String, - isExpanded: Binding, - isLoaded: @escaping () -> Bool, - isLoading: Binding, - hasError: Binding, - onLoad: @escaping () async -> Void, - footer: String? = nil, - @ViewBuilder content: @escaping () -> Content - ) { - self.title = title - self.icon = icon - self._isExpanded = isExpanded - self.isLoaded = isLoaded - self._isLoading = isLoading - self._hasError = hasError - self.onLoad = onLoad - self.footer = footer - self.content = content - } + init( + title: String, + icon: String, + isExpanded: Binding, + isLoaded: @escaping () -> Bool, + isLoading: Binding, + hasError: Binding, + onLoad: @escaping () async -> Void, + footer: String? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.icon = icon + _isExpanded = isExpanded + self.isLoaded = isLoaded + _isLoading = isLoading + _hasError = hasError + self.onLoad = onLoad + self.footer = footer + self.content = content + } - var body: some View { - Section { - DisclosureGroup(isExpanded: $isExpanded) { - // Always show content - individual fields handle nil/loading states - // with "loading..." overlays when their values haven't arrived yet - content() + var body: some View { + Section { + DisclosureGroup(isExpanded: $isExpanded) { + // Always show content - individual fields handle nil/loading states + // with "loading..." overlays when their values haven't arrived yet + content() - // Show error banner if something failed - if hasError && !isLoaded() { - VStack(spacing: 12) { - Label(L10n.Localizable.Common.Error.failedToLoad, systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - Button(L10n.Localizable.Common.tryAgain) { - Task { await onLoad() } - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets()) - } - } label: { - HStack { - Label(title, systemImage: icon) - Spacer() - if isLoading { - ProgressView() - .scaleEffect(0.8) - .padding(.trailing) - } else if isExpanded && isLoaded() { - Button { - Task { await onLoad() } - } label: { - Image(systemName: "arrow.clockwise") - .font(.caption) - } - .buttonStyle(.borderless) - .padding(.trailing) - } - } - } - } footer: { - if let footer { - Text(footer) - } - } - .themedRowBackground(theme) - .onChange(of: isExpanded) { _, expanded in - if expanded && !isLoaded() && !isLoading { - Task { await onLoad() } + // Show error banner if something failed + if hasError, !isLoaded() { + VStack(spacing: 12) { + Label(L10n.Localizable.Common.Error.failedToLoad, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + Button(L10n.Localizable.Common.tryAgain) { + Task { await onLoad() } } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) } - .task { - // Trigger initial load if section starts expanded - // (onChange only fires when value changes, not on initial render) - if isExpanded && !isLoaded() && !isLoading { - await onLoad() + } label: { + HStack { + Label(title, systemImage: icon) + Spacer() + if isLoading { + ProgressView() + .scaleEffect(0.8) + .padding(.trailing) + } else if isExpanded, isLoaded() { + Button { + Task { await onLoad() } + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption) } + .buttonStyle(.borderless) + .padding(.trailing) + } } + } + } footer: { + if let footer { + Text(footer) + } + } + .themedRowBackground(theme) + .onChange(of: isExpanded) { _, expanded in + if expanded, !isLoaded(), !isLoading { + Task { await onLoad() } + } + } + .task { + // Trigger initial load if section starts expanded + // (onChange only fires when value changes, not on initial render) + if isExpanded, !isLoaded(), !isLoading { + await onLoad() + } } + } } #Preview { - @Previewable @State var isExpanded = false - @Previewable @State var isLoading = false - @Previewable @State var hasError = false - @Previewable @State var data: String? + @Previewable @State var isExpanded = false + @Previewable @State var isLoading = false + @Previewable @State var hasError = false + @Previewable @State var data: String? - Form { - ExpandableSettingsSection( - title: "Device Info", - icon: "info.circle", - isExpanded: $isExpanded, - isLoaded: { data != nil }, - isLoading: $isLoading, - hasError: $hasError, - onLoad: { - isLoading = true - try? await Task.sleep(for: .seconds(1)) - data = "Loaded!" - isLoading = false - } - ) { - Text(data ?? "") - } + Form { + ExpandableSettingsSection( + title: "Device Info", + icon: "info.circle", + isExpanded: $isExpanded, + isLoaded: { data != nil }, + isLoading: $isLoading, + hasError: $hasError, + onLoad: { + isLoading = true + try? await Task.sleep(for: .seconds(1)) + data = "Loaded!" + isLoading = false + } + ) { + Text(data ?? "") } + } } diff --git a/MC1/Views/Components/FavoriteToggleRow.swift b/MC1/Views/Components/FavoriteToggleRow.swift deleted file mode 100644 index 114085ff..00000000 --- a/MC1/Views/Components/FavoriteToggleRow.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -struct FavoriteToggleRow: View { - @Binding var isFavorite: Bool - - var body: some View { - HStack { - Image(systemName: isFavorite ? "star.fill" : "star") - .foregroundStyle(isFavorite ? .yellow : .secondary) - - Text(L10n.Chats.Chats.Row.favorite) - - Spacer() - - Toggle("", isOn: $isFavorite) - .labelsHidden() - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Row.favorite) - .accessibilityValue(isFavorite ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) - } -} diff --git a/MC1/Views/Components/GlassFilterBar.swift b/MC1/Views/Components/GlassFilterBar.swift index a33091ee..76d7a5a4 100644 --- a/MC1/Views/Components/GlassFilterBar.swift +++ b/MC1/Views/Components/GlassFilterBar.swift @@ -23,176 +23,175 @@ private let selectedSegmentTintDark = Color.white.opacity(0.16) /// Designed to be hosted via `.safeAreaInset(edge: .top)` so list content /// scrolls behind the glass on iOS 26. struct GlassFilterBar: View -where Filter.AllCases: RandomAccessCollection { - - /// Pill sizing. `regular` is the compact default for the chat/contact filters; - /// `large` gives the roomier pills used by the node management sheet. - enum Size { - case regular - case large - - var horizontalPadding: CGFloat { - switch self { - case .regular: regularPillHorizontalPadding - case .large: largePillHorizontalPadding - } - } - - var verticalPadding: CGFloat { - switch self { - case .regular: regularPillVerticalPadding - case .large: largePillVerticalPadding - } - } - - var font: Font { - switch self { - case .regular: .subheadline - case .large: .callout - } - } - - var controlSize: ControlSize { - switch self { - case .regular: .regular - case .large: .large - } - } + where Filter.AllCases: RandomAccessCollection { + /// Pill sizing. `regular` is the compact default for the chat/contact filters; + /// `large` gives the roomier pills used by the node management sheet. + enum Size { + case regular + case large + + var horizontalPadding: CGFloat { + switch self { + case .regular: regularPillHorizontalPadding + case .large: largePillHorizontalPadding + } } - @Binding var selection: Filter - let isSearching: Bool - let pickerLabel: String - let title: @Sendable (Filter) -> String - var size: Size = .regular - - @Namespace private var glassNamespace - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.colorScheme) private var colorScheme - - private var selectedSegmentTint: Color { - colorScheme == .dark ? selectedSegmentTintDark : selectedSegmentTintLight + var verticalPadding: CGFloat { + switch self { + case .regular: regularPillVerticalPadding + case .large: largePillVerticalPadding + } } - var body: some View { - Group { - if #available(iOS 26.0, *) { - glassPills - } else { - segmentedFallback - } - } - .opacity(isSearching ? dimmedOpacity : 1.0) - .disabled(isSearching) + var font: Font { + switch self { + case .regular: .subheadline + case .large: .callout + } } - @available(iOS 26.0, *) - private var glassPills: some View { - ViewThatFits(in: .horizontal) { - pillsRow - ScrollView(.horizontal, showsIndicators: false) { - pillsRow - } - .scrollClipDisabled() - } - .frame(maxWidth: .infinity) - // Scope the pill morph to the bar so it doesn't leak a transaction into consumer - // content (a list's row transitions would otherwise animate on every filter tap). - .animation(reduceMotion ? nil : .smooth, value: selection) + var controlSize: ControlSize { + switch self { + case .regular: .regular + case .large: .large + } } - - @available(iOS 26.0, *) - private var pillsRow: some View { - GlassEffectContainer(spacing: pillSpacing) { - HStack(spacing: pillSpacing) { - ForEach(Filter.allCases, id: \.self) { filter in - pill(for: filter) - } - } - .padding(.horizontal, barHorizontalPadding) - .padding(.top, barTopPadding) - .padding(.bottom, barBottomPadding) - } + } + + @Binding var selection: Filter + let isSearching: Bool + let pickerLabel: String + let title: @Sendable (Filter) -> String + var size: Size = .regular + + @Namespace private var glassNamespace + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorScheme) private var colorScheme + + private var selectedSegmentTint: Color { + colorScheme == .dark ? selectedSegmentTintDark : selectedSegmentTintLight + } + + var body: some View { + Group { + if #available(iOS 26.0, *) { + glassPills + } else { + segmentedFallback + } } - - @available(iOS 26.0, *) - @ViewBuilder - private func pill(for filter: Filter) -> some View { - let isSelected = selection == filter - Button { - selection = filter - } label: { - Text(title(filter)) - .lineLimit(1) - .font(size.font) - .foregroundStyle(.primary) - .padding(.horizontal, size.horizontalPadding) - .padding(.vertical, size.verticalPadding) - .contentShape(.capsule) - } - .buttonStyle(.plain) - .glassEffect( - isSelected ? .regular.tint(selectedSegmentTint).interactive() : .regular.interactive(), - in: .capsule - ) - .glassEffectID(filter, in: glassNamespace) + .opacity(isSearching ? dimmedOpacity : 1.0) + .disabled(isSearching) + } + + @available(iOS 26.0, *) + private var glassPills: some View { + ViewThatFits(in: .horizontal) { + pillsRow + ScrollView(.horizontal, showsIndicators: false) { + pillsRow + } + .scrollClipDisabled() } - - private var segmentedFallback: some View { - Picker(pickerLabel, selection: $selection) { - ForEach(Filter.allCases, id: \.self) { filter in - Text(title(filter)).tag(filter) - } + .frame(maxWidth: .infinity) + // Scope the pill morph to the bar so it doesn't leak a transaction into consumer + // content (a list's row transitions would otherwise animate on every filter tap). + .animation(reduceMotion ? nil : .smooth, value: selection) + } + + @available(iOS 26.0, *) + private var pillsRow: some View { + GlassEffectContainer(spacing: pillSpacing) { + HStack(spacing: pillSpacing) { + ForEach(Filter.allCases, id: \.self) { filter in + pill(for: filter) } - .pickerStyle(.segmented) - .controlSize(size.controlSize) - .padding(.horizontal, barHorizontalPadding) - .padding(.top, segmentedTopPadding) - .padding(.bottom, barBottomPadding) + } + .padding(.horizontal, barHorizontalPadding) + .padding(.top, barTopPadding) + .padding(.bottom, barBottomPadding) + } + } + + @available(iOS 26.0, *) + @ViewBuilder + private func pill(for filter: Filter) -> some View { + let isSelected = selection == filter + Button { + selection = filter + } label: { + Text(title(filter)) + .lineLimit(1) + .font(size.font) + .foregroundStyle(.primary) + .padding(.horizontal, size.horizontalPadding) + .padding(.vertical, size.verticalPadding) + .contentShape(.capsule) + } + .buttonStyle(.plain) + .glassEffect( + isSelected ? .regular.tint(selectedSegmentTint).interactive() : .regular.interactive(), + in: .capsule + ) + .glassEffectID(filter, in: glassNamespace) + } + + private var segmentedFallback: some View { + Picker(pickerLabel, selection: $selection) { + ForEach(Filter.allCases, id: \.self) { filter in + Text(title(filter)).tag(filter) + } } + .pickerStyle(.segmented) + .controlSize(size.controlSize) + .padding(.horizontal, barHorizontalPadding) + .padding(.top, segmentedTopPadding) + .padding(.bottom, barBottomPadding) + } } extension View { - /// Backs the pinned filter header with the themed canvas on iOS 18, where the fallback - /// segmented `Picker` is transparent and would let scrolling rows show through. iOS 26 glass - /// pills carry their own material and float over the content, so no backing is applied. - @ViewBuilder - func pinnedFilterHeaderBackground(_ theme: Theme) -> some View { - if #available(iOS 26.0, *) { - self - } else { - background(theme.surfaces?.canvas ?? Color(.systemBackground)) - } + /// Backs the pinned filter header with the themed canvas on iOS 18, where the fallback + /// segmented `Picker` is transparent and would let scrolling rows show through. iOS 26 glass + /// pills carry their own material and float over the content, so no backing is applied. + @ViewBuilder + func pinnedFilterHeaderBackground(_ theme: Theme) -> some View { + if #available(iOS 26.0, *) { + self + } else { + background(theme.surfaces?.canvas ?? Color(.systemBackground)) } + } } -private enum FilterBarPreviewFilter: String, CaseIterable, Sendable { - case all, unread, dms, channels, rooms +private enum FilterBarPreviewFilter: String, CaseIterable { + case all, unread, dms, channels, rooms - var label: String { - switch self { - case .all: "All" - case .unread: "Unread" - case .dms: "DMs" - case .channels: "Channels" - case .rooms: "Rooms" - } + var label: String { + switch self { + case .all: "All" + case .unread: "Unread" + case .dms: "DMs" + case .channels: "Channels" + case .rooms: "Rooms" } + } } #Preview { - @Previewable @State var selection = FilterBarPreviewFilter.all - - List(0..<20) { row in - Text("Row \(row)") - } - .safeAreaInset(edge: .top, spacing: 0) { - GlassFilterBar( - selection: $selection, - isSearching: false, - pickerLabel: "View", - title: { $0.label } - ) - .frame(maxWidth: .infinity) - } + @Previewable @State var selection = FilterBarPreviewFilter.all + + List(0..<20) { row in + Text("Row \(row)") + } + .safeAreaInset(edge: .top, spacing: 0) { + GlassFilterBar( + selection: $selection, + isSearching: false, + pickerLabel: "View", + title: { $0.label } + ) + .frame(maxWidth: .infinity) + } } diff --git a/MC1/Views/Components/MapControlsToolbar.swift b/MC1/Views/Components/MapControlsToolbar.swift index 1685d105..880ebe65 100644 --- a/MC1/Views/Components/MapControlsToolbar.swift +++ b/MC1/Views/Components/MapControlsToolbar.swift @@ -1,115 +1,130 @@ +import MapLibre import SwiftUI /// Shared liquid-glass toolbar hosting the controls every interactive map uses -/// (north lock, location, layers, labels) plus a slot for one map-specific button. -struct MapControlsToolbar: View { - /// Centers the map on the user's location. - var onLocationTap: () -> Void +/// (location, map options) plus a slot for one map-specific button. +/// The map options control is a native menu offering the north lock, map-style +/// picker, and the labels toggle. +struct MapControlsToolbar: View { + @Environment(\.appState) private var appState - @Binding var isNorthLocked: Bool - @Binding var showLabels: Bool + /// Centers the map on the user's location. + var onLocationTap: () -> Void - /// Controls layers menu visibility. Parent view handles menu presentation. - @Binding var showingLayersMenu: Bool + /// Whether the map is currently centered on the user; fills the location marker when true. + var isCenteredOnUser: Bool = false - /// One map-specific button shown below the standard controls. - @ViewBuilder var trailingContent: () -> TrailingContent + @Binding var isNorthLocked: Bool + @Binding var showLabels: Bool + @Binding var mapStyleSelection: MapStyleSelection - var body: some View { - VStack(spacing: 0) { - northLockButton + /// Current viewport, used to gate styles that lack offline coverage for the visible area. + var viewportBounds: MLNCoordinateBounds? - Divider() - .frame(width: MapToolbarLayout.dividerWidth) + /// One map-specific button shown below the standard controls. + @ViewBuilder var additionalActions: () -> AdditionalActions - locationButton + var body: some View { + VStack(spacing: 0) { + locationButton - Divider() - .frame(width: MapToolbarLayout.dividerWidth) + Divider() + .frame(width: MapToolbarLayout.dividerWidth) - layersButton + CustomContentStack { + additionalActions() + } - Divider() - .frame(width: MapToolbarLayout.dividerWidth) + Divider() + .frame(width: MapToolbarLayout.dividerWidth) - labelsButton - - CustomContentStack { - trailingContent() - } - } - .liquidGlass(in: .rect(cornerRadius: MapToolbarLayout.cornerRadius)) - .shadow(color: .black.opacity(0.15), radius: 4, y: 2) - .padding() + mapOptionsMenu } - - // MARK: - North Lock Button - - private var northLockButton: some View { - Button( - isNorthLocked ? L10n.Map.Map.Controls.unlockNorth : L10n.Map.Map.Controls.lockNorth, - systemImage: isNorthLocked ? "location.north.line.fill" : "location.north.line" - ) { - withAnimation { - isNorthLocked.toggle() - } + .liquidGlass(in: .rect(cornerRadius: MapToolbarLayout.cornerRadius)) + .shadow(color: .black.opacity(0.15), radius: 4, y: 2) + .padding() + } + + // MARK: - Location Button + + private var locationButton: some View { + Button( + L10n.Map.Map.Controls.centerOnMyLocation, + systemImage: isCenteredOnUser ? "location.fill" : "location", + action: onLocationTap + ) + .mapControlButton(tint: .primary) + } + + // MARK: - Map Options Menu + + private var mapOptionsMenu: some View { + Menu { + Picker(L10n.Map.Map.Style.accessibilityLabel, selection: $mapStyleSelection) { + ForEach(MapStyleSelection.allCases.reversed(), id: \.self) { style in + Text(style.label) + .tag(style) + .disabled(isDisabled(style)) + .accessibilityHint(disabledReason(for: style) ?? "") } - .mapControlButton(tint: isNorthLocked ? .blue : .primary) - } - - // MARK: - Location Button - - private var locationButton: some View { - Button(L10n.Map.Map.Controls.centerOnMyLocation, systemImage: "location.fill", action: onLocationTap) - .mapControlButton(tint: .primary) - } + } - // MARK: - Layers Button + Divider() - private var layersButton: some View { - Button(L10n.Map.Map.Controls.layers, systemImage: "square.3.layers.3d.down.right") { - withAnimation(.spring(response: 0.3)) { - showingLayersMenu.toggle() - } - } - .mapControlButton(tint: .primary) + Toggle(L10n.Map.Map.Controls.showLabels, systemImage: "character.textbox", isOn: $showLabels) + Toggle(L10n.Map.Map.Controls.lockNorth, systemImage: "location.north.line", isOn: $isNorthLocked) + } label: { + Label(L10n.Map.Map.Controls.mapOptions, systemImage: "ellipsis.circle") } - - // MARK: - Labels Button - - private var labelsButton: some View { - Button( - showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, - systemImage: "character.textbox" - ) { - withAnimation { - showLabels.toggle() - } - } - .mapControlButton(tint: showLabels ? .blue : .primary) + .mapControlButton(tint: .primary) + } + + private func isDisabled(_ style: MapStyleSelection) -> Bool { + !appState.offlineMapService.isNetworkAvailable + && (style.requiresNetwork || !hasOfflineCoverage(for: style)) + } + + private func disabledReason(for style: MapStyleSelection) -> String? { + guard isDisabled(style) else { return nil } + return style.requiresNetwork + ? L10n.Map.Map.Style.requiresNetwork + : L10n.Map.Map.Style.noOfflineCoverage + } + + private func hasOfflineCoverage(for style: MapStyleSelection) -> Bool { + if let viewportBounds { + appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer, overlapping: viewportBounds) + } else { + appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer) } + } } // MARK: - Layout private enum MapToolbarLayout { - static let dividerWidth: CGFloat = 36 - static let cornerRadius: CGFloat = 8 + static var cornerRadius: CGFloat { + if #available(iOS 26.0, *) { .infinity } else { 12 } + } + + static var dividerWidth: CGFloat { + if #available(iOS 26.0, *) { 0 } else { 24 } + } } // MARK: - Custom Content Stack /// Wraps trailing content and inserts a divider before each child view. private struct CustomContentStack: View { - @ViewBuilder var content: Content - - var body: some View { - Group(subviews: content) { subviews in - ForEach(subviews) { subview in - Divider() - .frame(width: MapToolbarLayout.dividerWidth) - subview - } - } + @ViewBuilder var content: Content + + var body: some View { + Group(subviews: content) { subviews in + ForEach(subviews) { subview in + Divider() + .frame(width: MapToolbarLayout.dividerWidth) + subview + } } + } } diff --git a/MC1/Views/Components/NavigationHeaderModifier.swift b/MC1/Views/Components/NavigationHeaderModifier.swift index 81e4a3c2..483de076 100644 --- a/MC1/Views/Components/NavigationHeaderModifier.swift +++ b/MC1/Views/Components/NavigationHeaderModifier.swift @@ -4,72 +4,72 @@ import SwiftUI /// On iOS 26+, uses native `.navigationSubtitle()` which animates with the navigation transition. /// On iOS 18-25, uses a custom toolbar principal item that appears after the view renders. struct NavigationHeaderModifier: ViewModifier { - /// Minimum scale floor for the legacy iOS 18-25 subtitle, anchored on iPhone SE-class - /// width (375pt) — caption2 (~12pt) × 0.7 ≈ 8.4pt keeps long region names readable - /// rather than clipped. - private static let legacySubtitleMinimumScaleFactor: CGFloat = 0.7 + /// Minimum scale floor for the legacy iOS 18-25 subtitle, anchored on iPhone SE-class + /// width (375pt) — caption2 (~12pt) × 0.7 ≈ 8.4pt keeps long region names readable + /// rather than clipped. + private static let legacySubtitleMinimumScaleFactor: CGFloat = 0.7 - let title: String - let subtitle: String - let subtitleAccessibilityLabel: String? + let title: String + let subtitle: String + let subtitleAccessibilityLabel: String? - @State private var showHeader = false + @State private var showHeader = false - func body(content: Content) -> some View { - #if os(iOS) - if #available(iOS 26, *) { - // TODO: subtitleAccessibilityLabel is not applied here — .navigationSubtitle() - // renders in system chrome with no public API to override its accessibility label. - // VoiceOver may read separators (e.g. "·") literally. Verify with VoiceOver testing. - content - .navigationTitle(title) - .navigationSubtitle(subtitle) - .navigationBarTitleDisplayMode(.inline) - } else { - legacyHeader(content: content) - } - #else + func body(content: Content) -> some View { + #if os(iOS) + if #available(iOS 26, *) { + // TODO: subtitleAccessibilityLabel is not applied here — .navigationSubtitle() + // renders in system chrome with no public API to override its accessibility label. + // VoiceOver may read separators (e.g. "·") literally. Verify with VoiceOver testing. + content + .navigationTitle(title) + .navigationSubtitle(subtitle) + .navigationBarTitleDisplayMode(.inline) + } else { legacyHeader(content: content) - #endif - } + } + #else + legacyHeader(content: content) + #endif + } - private func legacyHeader(content: Content) -> some View { - content - .navigationTitle(title) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - if showHeader { - ToolbarItem(placement: .principal) { - VStack(spacing: 0) { - Text(title) - .font(.headline) + private func legacyHeader(content: Content) -> some View { + content + .navigationTitle(title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if showHeader { + ToolbarItem(placement: .principal) { + VStack(spacing: 0) { + Text(title) + .font(.headline) - Text(subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(Self.legacySubtitleMinimumScaleFactor) - .truncationMode(.tail) - .accessibilityLabel(subtitleAccessibilityLabel ?? subtitle) - } - } - } + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(Self.legacySubtitleMinimumScaleFactor) + .truncationMode(.tail) + .accessibilityLabel(subtitleAccessibilityLabel ?? subtitle) } - .task { - // .task runs after first render, so header appears after navigation begins - withAnimation { - showHeader = true - } - } - } + } + } + } + .task { + // .task runs after first render, so header appears after navigation begins + withAnimation { + showHeader = true + } + } + } } extension View { - /// Applies an animated navigation header with title and subtitle. - /// Uses native `.navigationSubtitle()` on iOS 26+, with animated fallback for earlier versions. - func navigationHeader(title: String, subtitle: String, subtitleAccessibilityLabel: String? = nil) -> some View { - modifier(NavigationHeaderModifier(title: title, subtitle: subtitle, subtitleAccessibilityLabel: subtitleAccessibilityLabel)) - } + /// Applies an animated navigation header with title and subtitle. + /// Uses native `.navigationSubtitle()` on iOS 26+, with animated fallback for earlier versions. + func navigationHeader(title: String, subtitle: String, subtitleAccessibilityLabel: String? = nil) -> some View { + modifier(NavigationHeaderModifier(title: title, subtitle: subtitle, subtitleAccessibilityLabel: subtitleAccessibilityLabel)) + } } diff --git a/MC1/Views/Components/NodeAvatar.swift b/MC1/Views/Components/NodeAvatar.swift index f94b68f7..c617ebf4 100644 --- a/MC1/Views/Components/NodeAvatar.swift +++ b/MC1/Views/Components/NodeAvatar.swift @@ -1,67 +1,67 @@ -import SwiftUI import MC1Services +import SwiftUI /// Avatar view for remote nodes (room servers and repeaters). All repeaters share one theme color, /// all room servers share another — distinct per category, fixed per theme. struct NodeAvatar: View { - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - let publicKey: Data - let role: RemoteNodeRole - let size: CGFloat + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + let publicKey: Data + let role: RemoteNodeRole + let size: CGFloat - var body: some View { - ZStack { - Circle() - .fill(fill) + var body: some View { + ZStack { + Circle() + .fill(fill) - Image(systemName: iconName) - .font(.system(size: size * 0.45, weight: .semibold)) - .foregroundStyle(glyph) - } - .frame(width: size, height: size) + Image(systemName: iconName) + .font(.system(size: size * 0.45, weight: .semibold)) + .foregroundStyle(glyph) } + .frame(width: size, height: size) + } - private var iconName: String { - switch role { - case .roomServer: - return "door.left.hand.closed" - case .repeater: - return "antenna.radiowaves.left.and.right" - } + private var iconName: String { + switch role { + case .roomServer: + "door.left.hand.closed" + case .repeater: + "antenna.radiowaves.left.and.right" } + } - private var category: AvatarCategory { - role == .roomServer ? .room : .repeater - } + private var category: AvatarCategory { + role == .roomServer ? .room : .repeater + } - private var fill: Color { - theme.categoryAvatarColor(category, colorScheme: colorScheme, contrast: colorSchemeContrast) - } + private var fill: Color { + theme.categoryAvatarColor(category, colorScheme: colorScheme, contrast: colorSchemeContrast) + } - private var glyph: Color { - theme.avatarGlyphColor( - forFill: fill, - usesCategoryOverride: theme.usesCategoryAvatarOverride, - colorScheme: colorScheme, - contrast: colorSchemeContrast - ) - } + private var glyph: Color { + theme.avatarGlyphColor( + forFill: fill, + usesCategoryOverride: theme.usesCategoryAvatarOverride, + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + } } #Preview("Room Server") { - NodeAvatar( - publicKey: Data(repeating: 0x42, count: 32), - role: .roomServer, - size: 60 - ) + NodeAvatar( + publicKey: Data(repeating: 0x42, count: 32), + role: .roomServer, + size: 60 + ) } #Preview("Repeater") { - NodeAvatar( - publicKey: Data(repeating: 0x55, count: 32), - role: .repeater, - size: 60 - ) + NodeAvatar( + publicKey: Data(repeating: 0x55, count: 32), + role: .repeater, + size: 60 + ) } diff --git a/MC1/Views/Components/NotificationLevelPicker.swift b/MC1/Views/Components/NotificationLevelPicker.swift index c0b24eae..0012ff4b 100644 --- a/MC1/Views/Components/NotificationLevelPicker.swift +++ b/MC1/Views/Components/NotificationLevelPicker.swift @@ -1,60 +1,60 @@ -import SwiftUI import MC1Services +import SwiftUI struct NotificationLevelPicker: View { - @Binding var selection: NotificationLevel - let availableLevels: [NotificationLevel] + @Binding var selection: NotificationLevel + let availableLevels: [NotificationLevel] - init(selection: Binding, availableLevels: [NotificationLevel] = NotificationLevel.allCases) { - self._selection = selection - self.availableLevels = availableLevels - } + init(selection: Binding, availableLevels: [NotificationLevel] = NotificationLevel.allCases) { + _selection = selection + self.availableLevels = availableLevels + } - var body: some View { - HStack(spacing: 12) { - ForEach(availableLevels, id: \.self) { level in - NotificationLevelPill( - level: level, - isSelected: selection == level - ) { - selection = level - } - } + var body: some View { + HStack(spacing: 12) { + ForEach(availableLevels, id: \.self) { level in + NotificationLevelPill( + level: level, + isSelected: selection == level + ) { + selection = level } - .accessibilityElement(children: .contain) - .accessibilityLabel(L10n.Chats.Chats.NotificationLevel.label) - .accessibilityValue(selection.localizedAccessibilityDescription) - .accessibilityHint(L10n.Chats.Chats.NotificationLevel.hint) + } } + .accessibilityElement(children: .contain) + .accessibilityLabel(L10n.Chats.Chats.NotificationLevel.label) + .accessibilityValue(selection.localizedAccessibilityDescription) + .accessibilityHint(L10n.Chats.Chats.NotificationLevel.hint) + } } private struct NotificationLevelPill: View { - let level: NotificationLevel - let isSelected: Bool - let action: () -> Void + let level: NotificationLevel + let isSelected: Bool + let action: () -> Void - var body: some View { - Button(action: action) { - VStack(spacing: 4) { - Image(systemName: level.iconName) - .font(.title3) - Text(level.localizedName) - .font(.caption) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background { - if isSelected { - Color.accentColor - } else { - Color(uiColor: .tertiarySystemFill) - } - } - .foregroundStyle(isSelected ? .white : .primary) - .clipShape(.rect(cornerRadius: 10)) + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + Image(systemName: level.iconName) + .font(.title3) + Text(level.localizedName) + .font(.caption) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background { + if isSelected { + Color.accentColor + } else { + Color(uiColor: .tertiarySystemFill) } - .buttonStyle(.plain) - .accessibilityLabel(level.localizedName) - .accessibilityAddTraits(isSelected ? .isSelected : []) + } + .foregroundStyle(isSelected ? .white : .primary) + .clipShape(.rect(cornerRadius: 10)) } + .buttonStyle(.plain) + .accessibilityLabel(level.localizedName) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } } diff --git a/MC1/Views/Components/RSSIScanTracker.swift b/MC1/Views/Components/RSSIScanTracker.swift index 4de42391..37c1c238 100644 --- a/MC1/Views/Components/RSSIScanTracker.swift +++ b/MC1/Views/Components/RSSIScanTracker.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Shared BLE scan-orchestration model for the device pickers. /// @@ -12,59 +12,59 @@ import MC1Services @Observable @MainActor final class RSSIScanTracker { - /// Currently-advertising peripherals keyed by id, each carrying its smoothed RSSI and most - /// recently advertised name. - private(set) var devices: [UUID: DiscoveredDevice] = [:] - - private var signalTiers: [UUID: RSSITuning.SignalTier] = [:] - private var lastSeen: [UUID: Date] = [:] + /// Currently-advertising peripherals keyed by id, each carrying its smoothed RSSI and most + /// recently advertised name. + private(set) var devices: [UUID: DiscoveredDevice] = [:] - /// The smoothed signal tier for a peripheral, or `nil` if it is not currently advertising. - func signalTier(for id: UUID) -> RSSITuning.SignalTier? { - signalTiers[id] - } + private var signalTiers: [UUID: RSSITuning.SignalTier] = [:] + private var lastSeen: [UUID: Date] = [:] - /// Whether the peripheral has a live, unexpired RSSI sample. - func isAdvertising(_ id: UUID) -> Bool { - devices[id] != nil - } + /// The smoothed signal tier for a peripheral, or `nil` if it is not currently advertising. + func signalTier(for id: UUID) -> RSSITuning.SignalTier? { + signalTiers[id] + } - /// Consumes a discovery stream until the calling task is cancelled, smoothing RSSI, - /// recomputing tiers, and expiring stale peripherals on the shared `RSSITuning.expiryTick` - /// cadence (after `RSSITuning.staleWindow` without a fresh advertisement). Scanning stops - /// automatically when the stream's producer is torn down on task cancellation. - func consume(_ stream: AsyncStream) async { - let expiry = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: RSSITuning.expiryTick) - self?.expireStale() - } - } - defer { expiry.cancel() } + /// Whether the peripheral has a live, unexpired RSSI sample. + func isAdvertising(_ id: UUID) -> Bool { + devices[id] != nil + } - for await discovery in stream { - guard RSSITuning.isUsable(discovery.rssi) else { continue } - ingest(discovery) - } + /// Consumes a discovery stream until the calling task is cancelled, smoothing RSSI, + /// recomputing tiers, and expiring stale peripherals on the shared `RSSITuning.expiryTick` + /// cadence (after `RSSITuning.staleWindow` without a fresh advertisement). Scanning stops + /// automatically when the stream's producer is torn down on task cancellation. + func consume(_ stream: AsyncStream) async { + let expiry = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: RSSITuning.expiryTick) + self?.expireStale() + } } + defer { expiry.cancel() } - private func ingest(_ discovery: DiscoveredDevice) { - let smoothed = RSSITuning.smooth(newRSSI: discovery.rssi, previousRSSI: devices[discovery.id]?.rssi) - // Preserve a previously-advertised name when a later packet omits it. - let name = discovery.name ?? devices[discovery.id]?.name - devices[discovery.id] = DiscoveredDevice(id: discovery.id, name: name, rssi: smoothed) - signalTiers[discovery.id] = RSSITuning.tier(currentTier: signalTiers[discovery.id], smoothedRSSI: smoothed) - lastSeen[discovery.id] = .now + for await discovery in stream { + guard RSSITuning.isUsable(discovery.rssi) else { continue } + ingest(discovery) } + } + + private func ingest(_ discovery: DiscoveredDevice) { + let smoothed = RSSITuning.smooth(newRSSI: discovery.rssi, previousRSSI: devices[discovery.id]?.rssi) + // Preserve a previously-advertised name when a later packet omits it. + let name = discovery.name ?? devices[discovery.id]?.name + devices[discovery.id] = DiscoveredDevice(id: discovery.id, name: name, rssi: smoothed) + signalTiers[discovery.id] = RSSITuning.tier(currentTier: signalTiers[discovery.id], smoothedRSSI: smoothed) + lastSeen[discovery.id] = .now + } - /// Drops peripherals not seen within `RSSITuning.staleWindow` of `now`. `now` is a seam so - /// tests can force expiry deterministically; production callers use the default. - func expireStale(asOf now: Date = .now) { - let cutoff = now.addingTimeInterval(-RSSITuning.staleWindow) - for (id, seen) in lastSeen where seen < cutoff { - lastSeen.removeValue(forKey: id) - devices.removeValue(forKey: id) - signalTiers.removeValue(forKey: id) - } + /// Drops peripherals not seen within `RSSITuning.staleWindow` of `now`. `now` is a seam so + /// tests can force expiry deterministically; production callers use the default. + func expireStale(asOf now: Date = .now) { + let cutoff = now.addingTimeInterval(-RSSITuning.staleWindow) + for (id, seen) in lastSeen where seen < cutoff { + lastSeen.removeValue(forKey: id) + devices.removeValue(forKey: id) + signalTiers.removeValue(forKey: id) } + } } diff --git a/MC1/Views/Components/RSSITuning.swift b/MC1/Views/Components/RSSITuning.swift index 54a732ec..bde67c88 100644 --- a/MC1/Views/Components/RSSITuning.swift +++ b/MC1/Views/Components/RSSITuning.swift @@ -7,85 +7,85 @@ import SwiftUI /// hysteresis. The scan and expiry orchestration that drives this math is shared via /// `RSSIScanTracker`, so neither the math nor its orchestration can drift between the pickers. enum RSSITuning { - /// Discrete signal-strength tier derived from a smoothed RSSI reading. Raw values are stable - /// (0 weak, 1 medium, 2 strong) so the glyph fill and color helpers map them directly. - enum SignalTier: Int { - case weak = 0 - case medium = 1 - case strong = 2 - } + /// Discrete signal-strength tier derived from a smoothed RSSI reading. Raw values are stable + /// (0 weak, 1 medium, 2 strong) so the glyph fill and color helpers map them directly. + enum SignalTier: Int { + case weak = 0 + case medium = 1 + case strong = 2 + } - /// RSSI (dBm) at or above which signal is shown as full strength (tier 2 / green). - static let strongThreshold = -60 - /// RSSI (dBm) at or above which signal is shown as medium (tier 1 / yellow); - /// below this is weak (tier 0 / red). - static let mediumThreshold = -80 - /// Margin (dBm) a reading must clear before the displayed tier changes, so the - /// glyph does not flicker when the signal hovers at a boundary. - static let tierHysteresis = 3 - /// Weight applied to the newest sample in the exponential RSSI smoothing - /// (`new · weight + previous · (1 − weight)`). - static let smoothingNewWeight = 0.2 - /// Sentinel RSSI CoreBluetooth reports when signal strength is unavailable. - static let unavailableRSSI = -127 - /// Cadence of the stale-peripheral expiry sweep. - static let expiryTick: Duration = .seconds(2) - /// Seconds without a fresh advertisement before a peripheral is treated as gone. - /// Shared by both pickers so a device drops (macOS scanner) or goes non-tappable - /// (iOS picker) on the same schedule; with the 2s sweep, real staleness is up to - /// `staleWindow + expiryTick`. - static let staleWindow: TimeInterval = 4 + /// RSSI (dBm) at or above which signal is shown as full strength (tier 2 / green). + static let strongThreshold = -60 + /// RSSI (dBm) at or above which signal is shown as medium (tier 1 / yellow); + /// below this is weak (tier 0 / red). + static let mediumThreshold = -80 + /// Margin (dBm) a reading must clear before the displayed tier changes, so the + /// glyph does not flicker when the signal hovers at a boundary. + static let tierHysteresis = 3 + /// Weight applied to the newest sample in the exponential RSSI smoothing + /// (`new · weight + previous · (1 − weight)`). + static let smoothingNewWeight = 0.2 + /// Sentinel RSSI CoreBluetooth reports when signal strength is unavailable. + static let unavailableRSSI = -127 + /// Cadence of the stale-peripheral expiry sweep. + static let expiryTick: Duration = .seconds(2) + /// Seconds without a fresh advertisement before a peripheral is treated as gone. + /// Shared by both pickers so a device drops (macOS scanner) or goes non-tappable + /// (iOS picker) on the same schedule; with the 2s sweep, real staleness is up to + /// `staleWindow + expiryTick`. + static let staleWindow: TimeInterval = 4 - /// Whether an RSSI reading is usable: a negative dBm value that is not the - /// `unavailableRSSI` sentinel (0 or positive readings also indicate unavailable). - static func isUsable(_ rssi: Int) -> Bool { - rssi < 0 && rssi != unavailableRSSI - } + /// Whether an RSSI reading is usable: a negative dBm value that is not the + /// `unavailableRSSI` sentinel (0 or positive readings also indicate unavailable). + static func isUsable(_ rssi: Int) -> Bool { + rssi < 0 && rssi != unavailableRSSI + } - /// Exponentially smooths a new RSSI sample against the previous smoothed value. - /// Returns the new sample unchanged when there is no prior reading. - static func smooth(newRSSI: Int, previousRSSI: Int?) -> Int { - guard let previousRSSI else { return newRSSI } - return Int(smoothingNewWeight * Double(newRSSI) + (1 - smoothingNewWeight) * Double(previousRSSI)) - } + /// Exponentially smooths a new RSSI sample against the previous smoothed value. + /// Returns the new sample unchanged when there is no prior reading. + static func smooth(newRSSI: Int, previousRSSI: Int?) -> Int { + guard let previousRSSI else { return newRSSI } + return Int(smoothingNewWeight * Double(newRSSI) + (1 - smoothingNewWeight) * Double(previousRSSI)) + } - /// Signal tier with hysteresis: a reading must clear a threshold by `tierHysteresis` dBm - /// before the displayed tier changes. The first reading (`currentTier == nil`) maps directly - /// with no hysteresis. - static func tier(currentTier: SignalTier?, smoothedRSSI: Int) -> SignalTier { - switch currentTier { - case .strong: // drop a tier only if clearly below threshold - return smoothedRSSI < strongThreshold - tierHysteresis - ? (smoothedRSSI < mediumThreshold - tierHysteresis ? .weak : .medium) : .strong - case .medium: // need margin to move up or down - if smoothedRSSI >= strongThreshold + tierHysteresis { return .strong } - if smoothedRSSI < mediumThreshold - tierHysteresis { return .weak } - return .medium - case .weak: // need margin to move up - return smoothedRSSI >= mediumThreshold + tierHysteresis - ? (smoothedRSSI >= strongThreshold + tierHysteresis ? .strong : .medium) : .weak - case nil: // first reading, no hysteresis - if smoothedRSSI >= strongThreshold { return .strong } - if smoothedRSSI >= mediumThreshold { return .medium } - return .weak - } + /// Signal tier with hysteresis: a reading must clear a threshold by `tierHysteresis` dBm + /// before the displayed tier changes. The first reading (`currentTier == nil`) maps directly + /// with no hysteresis. + static func tier(currentTier: SignalTier?, smoothedRSSI: Int) -> SignalTier { + switch currentTier { + case .strong: // drop a tier only if clearly below threshold + return smoothedRSSI < strongThreshold - tierHysteresis + ? (smoothedRSSI < mediumThreshold - tierHysteresis ? .weak : .medium) : .strong + case .medium: // need margin to move up or down + if smoothedRSSI >= strongThreshold + tierHysteresis { return .strong } + if smoothedRSSI < mediumThreshold - tierHysteresis { return .weak } + return .medium + case .weak: // need margin to move up + return smoothedRSSI >= mediumThreshold + tierHysteresis + ? (smoothedRSSI >= strongThreshold + tierHysteresis ? .strong : .medium) : .weak + case nil: // first reading, no hysteresis + if smoothedRSSI >= strongThreshold { return .strong } + if smoothedRSSI >= mediumThreshold { return .medium } + return .weak } + } - /// Fill level (0...1) for the `cellularbars` glyph at a given tier. - static func fillLevel(forTier tier: SignalTier) -> Double { - switch tier { - case .strong: 1.0 - case .medium: 0.66 - case .weak: 0.33 - } + /// Fill level (0...1) for the `cellularbars` glyph at a given tier. + static func fillLevel(forTier tier: SignalTier) -> Double { + switch tier { + case .strong: 1.0 + case .medium: 0.66 + case .weak: 0.33 } + } - /// Color for the signal glyph at a given tier. - static func color(forTier tier: SignalTier) -> Color { - switch tier { - case .strong: .green - case .medium: .yellow - case .weak: .red - } + /// Color for the signal glyph at a given tier. + static func color(forTier tier: SignalTier) -> Color { + switch tier { + case .strong: .green + case .medium: .yellow + case .weak: .red } + } } diff --git a/MC1/Views/Components/RadioCommandTimeout.swift b/MC1/Views/Components/RadioCommandTimeout.swift index 21f0b0a0..3ca2e3df 100644 --- a/MC1/Views/Components/RadioCommandTimeout.swift +++ b/MC1/Views/Components/RadioCommandTimeout.swift @@ -2,8 +2,8 @@ import Foundation /// Shared bounds for radio-backed commands issued from list actions. enum RadioCommandTimeout { - /// Upper bound for a delete that round-trips to the radio (channel clear, room leave, remove - /// node). Too short re-admits a slow-but-successful delete as a spurious error; on the gated - /// paths an expiry surfaces on a still-visible row, never a hidden one. - static let delete: Duration = .seconds(7) + /// Upper bound for a delete that round-trips to the radio (channel clear, room leave, remove + /// node). Too short re-admits a slow-but-successful delete as a spurious error; on the gated + /// paths an expiry surfaces on a still-visible row, never a hidden one. + static let delete: Duration = .seconds(7) } diff --git a/MC1/Views/Components/RadioParameterText.swift b/MC1/Views/Components/RadioParameterText.swift index 7e913f63..172a0cf0 100644 --- a/MC1/Views/Components/RadioParameterText.swift +++ b/MC1/Views/Components/RadioParameterText.swift @@ -1,18 +1,18 @@ -import SwiftUI import MC1Services +import SwiftUI /// Displays a radio parameter summary line, e.g. "915.000 MHz • BW125 kHz • SF12 • CR8". /// Uses POSIX locale to always render a period decimal separator regardless of user locale. struct RadioParameterText: View { - let frequencyMHz: Double - let bandwidthKHz: Double - let spreadingFactor: UInt8 - let codingRate: UInt8 + let frequencyMHz: Double + let bandwidthKHz: Double + let spreadingFactor: UInt8 + let codingRate: UInt8 - var body: some View { - Text(frequencyMHz.formatted(.number.precision(.fractionLength(3)).locale(.posix))) - .font(.caption.monospacedDigit()) + - Text(" MHz \u{2022} BW\(bandwidthKHz.formatted(.number.locale(.posix))) kHz \u{2022} SF\(spreadingFactor) \u{2022} CR\(codingRate)") - .font(.caption) - } + var body: some View { + Text(frequencyMHz.formatted(.number.precision(.fractionLength(3)).locale(.posix))) + .font(.caption.monospacedDigit()) + + Text(" MHz \u{2022} BW\(bandwidthKHz.formatted(.number.locale(.posix))) kHz \u{2022} SF\(spreadingFactor) \u{2022} CR\(codingRate)") + .font(.caption) + } } diff --git a/MC1/Views/Components/RegionDiscoveryResultsView.swift b/MC1/Views/Components/RegionDiscoveryResultsView.swift index f0cd75af..3e4d62d2 100644 --- a/MC1/Views/Components/RegionDiscoveryResultsView.swift +++ b/MC1/Views/Components/RegionDiscoveryResultsView.swift @@ -2,64 +2,64 @@ import SwiftUI /// Pushed view showing discovered regions with toggleable selection struct RegionDiscoveryResultsView: View { - let sortedRegions: [String] - let onAdd: ([String]) -> Void + let sortedRegions: [String] + let onAdd: ([String]) -> Void - @Environment(\.dismiss) private var dismiss - @Environment(\.appTheme) private var theme - @State private var selectedRegions: Set + @Environment(\.dismiss) private var dismiss + @Environment(\.appTheme) private var theme + @State private var selectedRegions: Set - init(discoveredRegions: [String], onAdd: @escaping ([String]) -> Void) { - let sorted = discoveredRegions.sorted() - self.sortedRegions = sorted - self.onAdd = onAdd - self._selectedRegions = State(initialValue: Set(discoveredRegions)) - } + init(discoveredRegions: [String], onAdd: @escaping ([String]) -> Void) { + let sorted = discoveredRegions.sorted() + sortedRegions = sorted + self.onAdd = onAdd + _selectedRegions = State(initialValue: Set(discoveredRegions)) + } - var body: some View { - Form { - Section { - ForEach(sortedRegions, id: \.self) { region in - Button { - toggleSelection(region) - } label: { - HStack { - Text(region) - if region.isPrivateRegion { - Text(L10n.Chats.Chats.ChannelInfo.Region.`private`) - .font(.caption) - .foregroundStyle(.tertiary) - } - Spacer() - if selectedRegions.contains(region) { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - } - .tint(.primary) - } + var body: some View { + Form { + Section { + ForEach(sortedRegions, id: \.self) { region in + Button { + toggleSelection(region) + } label: { + HStack { + Text(region) + if region.isPrivateRegion { + Text(L10n.Chats.Chats.ChannelInfo.Region.private) + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + if selectedRegions.contains(region) { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } } - .themedRowBackground(theme) + } + .tint(.primary) + } + } + .themedRowBackground(theme) - Section { - Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { - onAdd(Array(selectedRegions)) - dismiss() - } - .disabled(selectedRegions.isEmpty) - } - .themedRowBackground(theme) + Section { + Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { + onAdd(Array(selectedRegions)) + dismiss() } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.discover) + .disabled(selectedRegions.isEmpty) + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.discover) + } - private func toggleSelection(_ region: String) { - if selectedRegions.contains(region) { - selectedRegions.remove(region) - } else { - selectedRegions.insert(region) - } + private func toggleSelection(_ region: String) { + if selectedRegions.contains(region) { + selectedRegions.remove(region) + } else { + selectedRegions.insert(region) } + } } diff --git a/MC1/Views/Components/RegionManagementView.swift b/MC1/Views/Components/RegionManagementView.swift index 6d6cd9a1..ecbbdb3a 100644 --- a/MC1/Views/Components/RegionManagementView.swift +++ b/MC1/Views/Components/RegionManagementView.swift @@ -2,184 +2,184 @@ import SwiftUI /// Form-based view for managing known regions with add/delete functionality struct RegionManagementView: View { - let knownRegions: [String] - @Binding var isDiscovering: Bool - @Binding var discoveryMessage: String? - - let onRemoveRegion: (String) -> Void - let onAddRegion: (String) -> Void - let onDiscoverTapped: () -> Void - - @Environment(\.appTheme) private var theme - @State private var searchText = "" - @State private var showingAddAlert = false - @State private var newRegionName = "" - @State private var validationMessage: String? - - private var filteredRegions: [String] { - let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } - guard !searchText.isEmpty else { return sorted } - return sorted.filter { $0.localizedStandardContains(searchText) } - } - - var body: some View { - Form { - if knownRegions.isEmpty { - RegionManagementEmptyState() - } else { - KnownRegionsSection( - regions: filteredRegions, - onDelete: removeRegions - ) - } - - ActionsSection( - isDiscovering: isDiscovering, - discoveryMessage: discoveryMessage, - onDiscoverTapped: onDiscoverTapped, - onAddTapped: { - newRegionName = "" - showingAddAlert = true - } - ) - } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.manage) - .modifier(SearchableModifier(searchText: $searchText, isEnabled: knownRegions.count >= 15)) - .alert(L10n.Chats.Chats.ChannelInfo.Region.addRegionTitle, isPresented: $showingAddAlert) { - TextField(L10n.Chats.Chats.ChannelInfo.Region.addRegionPlaceholder, text: $newRegionName) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { - if let error = RegionNameValidator.validate(newRegionName, existingRegions: knownRegions) { - validationMessage = validationText(for: error) - Task { showingAddAlert = true } - return - } - validationMessage = nil - onAddRegion(newRegionName.trimmingCharacters(in: .whitespaces)) - } - Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { - validationMessage = nil - } - } message: { - if let validationMessage { - Text(validationMessage) - } + let knownRegions: [String] + @Binding var isDiscovering: Bool + @Binding var discoveryMessage: String? + + let onRemoveRegion: (String) -> Void + let onAddRegion: (String) -> Void + let onDiscoverTapped: () -> Void + + @Environment(\.appTheme) private var theme + @State private var searchText = "" + @State private var showingAddAlert = false + @State private var newRegionName = "" + @State private var validationMessage: String? + + private var filteredRegions: [String] { + let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + guard !searchText.isEmpty else { return sorted } + return sorted.filter { $0.localizedStandardContains(searchText) } + } + + var body: some View { + Form { + if knownRegions.isEmpty { + RegionManagementEmptyState() + } else { + KnownRegionsSection( + regions: filteredRegions, + onDelete: removeRegions + ) + } + + ActionsSection( + isDiscovering: isDiscovering, + discoveryMessage: discoveryMessage, + onDiscoverTapped: onDiscoverTapped, + onAddTapped: { + newRegionName = "" + showingAddAlert = true } + ) } - - private func validationText(for error: RegionNameValidator.ValidationError) -> String? { - switch error { - case .empty: nil - case .invalidCharacters: L10n.Chats.Chats.ChannelInfo.Region.invalidName - case .tooLong(let maxBytes): L10n.Chats.Chats.ChannelInfo.Region.nameTooLong(maxBytes) - case .duplicate: L10n.Chats.Chats.ChannelInfo.Region.duplicate + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.manage) + .modifier(SearchableModifier(searchText: $searchText, isEnabled: knownRegions.count >= 15)) + .alert(L10n.Chats.Chats.ChannelInfo.Region.addRegionTitle, isPresented: $showingAddAlert) { + TextField(L10n.Chats.Chats.ChannelInfo.Region.addRegionPlaceholder, text: $newRegionName) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { + if let error = RegionNameValidator.validate(newRegionName, existingRegions: knownRegions) { + validationMessage = validationText(for: error) + Task { showingAddAlert = true } + return } + validationMessage = nil + onAddRegion(newRegionName.trimmingCharacters(in: .whitespaces)) + } + Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { + validationMessage = nil + } + } message: { + if let validationMessage { + Text(validationMessage) + } } + } + + private func validationText(for error: RegionNameValidator.ValidationError) -> String? { + switch error { + case .empty: nil + case .invalidCharacters: L10n.Chats.Chats.ChannelInfo.Region.invalidName + case let .tooLong(maxBytes): L10n.Chats.Chats.ChannelInfo.Region.nameTooLong(maxBytes) + case .duplicate: L10n.Chats.Chats.ChannelInfo.Region.duplicate + } + } - private func removeRegions(at offsets: IndexSet) { - let regionsToRemove = offsets.map { filteredRegions[$0] } - for region in regionsToRemove { - onRemoveRegion(region) - } + private func removeRegions(at offsets: IndexSet) { + let regionsToRemove = offsets.map { filteredRegions[$0] } + for region in regionsToRemove { + onRemoveRegion(region) } + } } // MARK: - Extracted Views private struct RegionManagementEmptyState: View { - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.ChannelInfo.Region.noRegions, systemImage: "map") - } description: { - Text(L10n.Chats.Chats.ChannelInfo.Region.noRegionsDescription) - } + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.ChannelInfo.Region.noRegions, systemImage: "map") + } description: { + Text(L10n.Chats.Chats.ChannelInfo.Region.noRegionsDescription) } + } } private struct KnownRegionsSection: View { - @Environment(\.appTheme) private var theme - let regions: [String] - let onDelete: (IndexSet) -> Void - - var body: some View { - Section { - ForEach(regions, id: \.self) { region in - RegionRow(name: region) - } - .onDelete(perform: onDelete) - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + let regions: [String] + let onDelete: (IndexSet) -> Void + + var body: some View { + Section { + ForEach(regions, id: \.self) { region in + RegionRow(name: region) + } + .onDelete(perform: onDelete) } + .themedRowBackground(theme) + } } private struct RegionRow: View { - let name: String - - private var isPrivate: Bool { - name.isPrivateRegion - } - - var body: some View { - HStack { - Text(name) - if isPrivate { - Spacer() - Text(L10n.Chats.Chats.ChannelInfo.Region.`private`) - .font(.caption) - .foregroundStyle(.tertiary) - } - } + let name: String + + private var isPrivate: Bool { + name.isPrivateRegion + } + + var body: some View { + HStack { + Text(name) + if isPrivate { + Spacer() + Text(L10n.Chats.Chats.ChannelInfo.Region.private) + .font(.caption) + .foregroundStyle(.tertiary) + } } + } } private struct ActionsSection: View { - @Environment(\.appTheme) private var theme - let isDiscovering: Bool - let discoveryMessage: String? - let onDiscoverTapped: () -> Void - let onAddTapped: () -> Void - - var body: some View { - Section { - if isDiscovering { - HStack { - ProgressView() - Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) - .foregroundStyle(.secondary) - } - } else if let discoveryMessage { - Text(discoveryMessage) - .font(.caption) - .foregroundStyle(.secondary) - } - - Button(L10n.Chats.Chats.ChannelInfo.Region.discover, systemImage: "antenna.radiowaves.left.and.right") { - onDiscoverTapped() - } - .disabled(isDiscovering) - - Button(L10n.Chats.Chats.ChannelInfo.Region.addManually, systemImage: "plus") { - onAddTapped() - } - } footer: { - Text(L10n.Chats.Chats.ChannelInfo.Region.invalidName) + @Environment(\.appTheme) private var theme + let isDiscovering: Bool + let discoveryMessage: String? + let onDiscoverTapped: () -> Void + let onAddTapped: () -> Void + + var body: some View { + Section { + if isDiscovering { + HStack { + ProgressView() + Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) + } else if let discoveryMessage { + Text(discoveryMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + + Button(L10n.Chats.Chats.ChannelInfo.Region.discover, systemImage: "antenna.radiowaves.left.and.right") { + onDiscoverTapped() + } + .disabled(isDiscovering) + + Button(L10n.Chats.Chats.ChannelInfo.Region.addManually, systemImage: "plus") { + onAddTapped() + } + } footer: { + Text(L10n.Chats.Chats.ChannelInfo.Region.invalidName) } + .themedRowBackground(theme) + } } /// Conditionally applies `.searchable()` when the region count warrants it private struct SearchableModifier: ViewModifier { - @Binding var searchText: String - let isEnabled: Bool - - func body(content: Content) -> some View { - if isEnabled { - content.searchable(text: $searchText) - } else { - content - } + @Binding var searchText: String + let isEnabled: Bool + + func body(content: Content) -> some View { + if isEnabled { + content.searchable(text: $searchText) + } else { + content } + } } diff --git a/MC1/Views/Components/RegionNameValidator.swift b/MC1/Views/Components/RegionNameValidator.swift index b4192217..3a018403 100644 --- a/MC1/Views/Components/RegionNameValidator.swift +++ b/MC1/Views/Components/RegionNameValidator.swift @@ -2,34 +2,36 @@ import Foundation import MC1Services extension String { - /// Whether this region name represents a private region (prefixed with "$") - var isPrivateRegion: Bool { hasPrefix("$") } + /// Whether this region name represents a private region (prefixed with "$") + var isPrivateRegion: Bool { + hasPrefix("$") + } } /// Validates region names before adding them to the device's known regions list enum RegionNameValidator { - enum ValidationError: Equatable { - case empty - case invalidCharacters - case tooLong(maxBytes: Int) - case duplicate - } + enum ValidationError: Equatable { + case empty + case invalidCharacters + case tooLong(maxBytes: Int) + case duplicate + } - static func validate(_ name: String, existingRegions: [String]) -> ValidationError? { - let trimmed = name.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { return .empty } - if !trimmed.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) { - return .invalidCharacters - } - let maxBytes = ProtocolLimits.maxDefaultFloodScopeNameBytes - if trimmed.utf8.count > maxBytes { - return .tooLong(maxBytes: maxBytes) - } - if existingRegions.contains(trimmed) { return .duplicate } - return nil + static func validate(_ name: String, existingRegions: [String]) -> ValidationError? { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .empty } + if !trimmed.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) { + return .invalidCharacters } - - static func isValid(_ name: String, existingRegions: [String]) -> Bool { - validate(name, existingRegions: existingRegions) == nil + let maxBytes = ProtocolLimits.maxDefaultFloodScopeNameBytes + if trimmed.utf8.count > maxBytes { + return .tooLong(maxBytes: maxBytes) } + if existingRegions.contains(trimmed) { return .duplicate } + return nil + } + + static func isValid(_ name: String, existingRegions: [String]) -> Bool { + validate(name, existingRegions: existingRegions) == nil + } } diff --git a/MC1/Views/Components/RelativeTimestampText.swift b/MC1/Views/Components/RelativeTimestampText.swift index aeed3b17..816939f0 100644 --- a/MC1/Views/Components/RelativeTimestampText.swift +++ b/MC1/Views/Components/RelativeTimestampText.swift @@ -2,49 +2,49 @@ import SwiftUI /// Displays a relative timestamp using Apple's localized relative date formatting struct RelativeTimestampText: View { - let timestamp: UInt32 - - private static let relativeFormatter: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter - }() - - private static let weekThreshold: TimeInterval = 604_800 - private static let nowThreshold: TimeInterval = 60 - - var body: some View { - TimelineView(.everyMinute) { context in - Text(Self.format(timestamp: timestamp, relativeTo: context.date)) - .font(.caption2) - .foregroundStyle(.secondary) - } + let timestamp: UInt32 + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() + + private static let weekThreshold: TimeInterval = 604_800 + private static let nowThreshold: TimeInterval = 60 + + var body: some View { + TimelineView(.everyMinute) { context in + Text(Self.format(timestamp: timestamp, relativeTo: context.date)) + .font(.caption2) + .foregroundStyle(.secondary) } + } - /// Formats a timestamp relative to the given date. Exposed for testing. - static func format(timestamp: UInt32, relativeTo now: Date) -> String { - let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) - let interval = now.timeIntervalSince(date) + /// Formats a timestamp relative to the given date. Exposed for testing. + static func format(timestamp: UInt32, relativeTo now: Date) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let interval = now.timeIntervalSince(date) - if interval < nowThreshold { - return L10n.Chats.Chats.Timestamp.now - } - - if interval >= weekThreshold { - return date.formatted(.dateTime.month(.abbreviated).day()) - } + if interval < nowThreshold { + return L10n.Chats.Chats.Timestamp.now + } - return relativeFormatter.localizedString(for: date, relativeTo: now) + if interval >= weekThreshold { + return date.formatted(.dateTime.month(.abbreviated).day()) } + + return relativeFormatter.localizedString(for: date, relativeTo: now) + } } #Preview { - VStack(alignment: .trailing, spacing: 8) { - RelativeTimestampText(timestamp: UInt32(Date().timeIntervalSince1970)) - RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-120).timeIntervalSince1970)) - RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-3600).timeIntervalSince1970)) - RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-86400).timeIntervalSince1970)) - RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-259200).timeIntervalSince1970)) - } - .padding() + VStack(alignment: .trailing, spacing: 8) { + RelativeTimestampText(timestamp: UInt32(Date().timeIntervalSince1970)) + RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-120).timeIntervalSince1970)) + RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-3600).timeIntervalSince1970)) + RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-86400).timeIntervalSince1970)) + RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-259_200).timeIntervalSince1970)) + } + .padding() } diff --git a/MC1/Views/Components/SectionReloadButton.swift b/MC1/Views/Components/SectionReloadButton.swift index ef2446ba..689fe3e5 100644 --- a/MC1/Views/Components/SectionReloadButton.swift +++ b/MC1/Views/Components/SectionReloadButton.swift @@ -6,33 +6,33 @@ import SwiftUI /// The button also appears when `hasError` is set so a first-load failure still /// exposes a retry affordance instead of leaving only the inline error text. struct SectionReloadButton: View { - let isLoading: Bool - let isLoaded: Bool - let hasError: Bool - let isDisabled: Bool - let accessibilityLabel: String - let onReload: () async -> Void + let isLoading: Bool + let isLoaded: Bool + let hasError: Bool + let isDisabled: Bool + let accessibilityLabel: String + let onReload: () async -> Void - private enum Layout { - static let spinnerScale: CGFloat = 0.8 - } + private enum Layout { + static let spinnerScale: CGFloat = 0.8 + } - var body: some View { - if isLoading { - ProgressView() - .scaleEffect(Layout.spinnerScale) - .padding(.trailing) - } else if isLoaded || hasError { - Button { - Task { await onReload() } - } label: { - Image(systemName: "arrow.clockwise") - .font(.caption) - } - .buttonStyle(.borderless) - .padding(.trailing) - .disabled(isDisabled) - .accessibilityLabel(accessibilityLabel) - } + var body: some View { + if isLoading { + ProgressView() + .scaleEffect(Layout.spinnerScale) + .padding(.trailing) + } else if isLoaded || hasError { + Button { + Task { await onReload() } + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption) + } + .buttonStyle(.borderless) + .padding(.trailing) + .disabled(isDisabled) + .accessibilityLabel(accessibilityLabel) } + } } diff --git a/MC1/Views/Components/SelectedRowHighlight.swift b/MC1/Views/Components/SelectedRowHighlight.swift index e16b53c8..04d031a3 100644 --- a/MC1/Views/Components/SelectedRowHighlight.swift +++ b/MC1/Views/Components/SelectedRowHighlight.swift @@ -3,29 +3,29 @@ import SwiftUI /// Rounded accent fill marking the selected row in a manually-driven list (a tap `Button` rather /// than native `List(selection:)`). Shared by the conversation and node split lists. struct SelectedRowHighlight: ViewModifier { - let isSelected: Bool + let isSelected: Bool - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - private static let cornerRadius: CGFloat = 10 - private static let fillOpacity: Double = 0.18 - private static let insetVertical: CGFloat = 2 - private static let insetHorizontal: CGFloat = 8 + private static let cornerRadius: CGFloat = 10 + private static let fillOpacity: Double = 0.18 + private static let insetVertical: CGFloat = 2 + private static let insetHorizontal: CGFloat = 8 - func body(content: Content) -> some View { - content.background { - if isSelected { - RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) - .fill(theme.accentColor.opacity(Self.fillOpacity)) - .padding(.vertical, Self.insetVertical) - .padding(.horizontal, Self.insetHorizontal) - } - } + func body(content: Content) -> some View { + content.background { + if isSelected { + RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .fill(theme.accentColor.opacity(Self.fillOpacity)) + .padding(.vertical, Self.insetVertical) + .padding(.horizontal, Self.insetHorizontal) + } } + } } extension View { - func selectedRowHighlight(isSelected: Bool) -> some View { - modifier(SelectedRowHighlight(isSelected: isSelected)) - } + func selectedRowHighlight(isSelected: Bool) -> some View { + modifier(SelectedRowHighlight(isSelected: isSelected)) + } } diff --git a/MC1/Views/Components/SignalBars.swift b/MC1/Views/Components/SignalBars.swift index 9bc8b74c..29aae5a9 100644 --- a/MC1/Views/Components/SignalBars.swift +++ b/MC1/Views/Components/SignalBars.swift @@ -3,26 +3,26 @@ import SwiftUI /// Signal-strength glyph shared by the device pickers (`DeviceScannerSheet`, `DeviceSelectionSheet`) /// so both render the `cellularbars` symbol with identical `RSSITuning` fill and tint. struct SignalBars: View { - let tier: RSSITuning.SignalTier - /// When set, the glyph announces this label to VoiceOver; when `nil`, the glyph is hidden from - /// VoiceOver so the enclosing row can announce the tier itself. - var accessibilityLabel: String? + let tier: RSSITuning.SignalTier + /// When set, the glyph announces this label to VoiceOver; when `nil`, the glyph is hidden from + /// VoiceOver so the enclosing row can announce the tier itself. + var accessibilityLabel: String? - var body: some View { - Image(systemName: "cellularbars", variableValue: RSSITuning.fillLevel(forTier: tier)) - .foregroundStyle(RSSITuning.color(forTier: tier)) - .font(.body) - .accessibilityHidden(accessibilityLabel == nil) - .accessibilityLabel(accessibilityLabel ?? "") - } + var body: some View { + Image(systemName: "cellularbars", variableValue: RSSITuning.fillLevel(forTier: tier)) + .foregroundStyle(RSSITuning.color(forTier: tier)) + .font(.body) + .accessibilityHidden(accessibilityLabel == nil) + .accessibilityLabel(accessibilityLabel ?? "") + } - /// VoiceOver descriptor for a signal tier, shared by both device pickers so the - /// tier-to-text mapping and its localized strings live in one place. - static func accessibilityDescription(forTier tier: RSSITuning.SignalTier) -> String { - switch tier { - case .strong: L10n.Localizable.Accessibility.SignalStrength.strong - case .medium: L10n.Localizable.Accessibility.SignalStrength.medium - case .weak: L10n.Localizable.Accessibility.SignalStrength.weak - } + /// VoiceOver descriptor for a signal tier, shared by both device pickers so the + /// tier-to-text mapping and its localized strings live in one place. + static func accessibilityDescription(forTier tier: RSSITuning.SignalTier) -> String { + switch tier { + case .strong: L10n.Localizable.Accessibility.SignalStrength.strong + case .medium: L10n.Localizable.Accessibility.SignalStrength.medium + case .weak: L10n.Localizable.Accessibility.SignalStrength.weak } + } } diff --git a/MC1/Views/Components/SyncingPillOverlay.swift b/MC1/Views/Components/SyncingPillOverlay.swift index 5b47863d..98ac110b 100644 --- a/MC1/Views/Components/SyncingPillOverlay.swift +++ b/MC1/Views/Components/SyncingPillOverlay.swift @@ -5,70 +5,70 @@ import SwiftUI /// and spring timing stay identical across layouts. `displayedPillState` lags `statusPillState` /// by one animated step so the pill can finish its exit animation before being removed. struct SyncingPillOverlay: ViewModifier { - @Environment(\.appState) private var appState - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.appState) private var appState + @Environment(\.accessibilityReduceMotion) private var reduceMotion - let onDisconnectedTap: () -> Void + let onDisconnectedTap: () -> Void - @State private var displayedPillState: StatusPillState = .hidden + @State private var displayedPillState: StatusPillState = .hidden - private let topInset: CGFloat = 8 - private let transitionDuration: TimeInterval = 0.3 - private let offscreenOffset: CGFloat = -100 + private let topInset: CGFloat = 8 + private let transitionDuration: TimeInterval = 0.3 + private let offscreenOffset: CGFloat = -100 - private let readySpringDuration: TimeInterval = 0.4 - private let readySpringBounce = 0.15 - private let alertSpringDuration: TimeInterval = 0.35 - private let alertSpringBounce = 0.2 - private let defaultSpringDuration: TimeInterval = 0.4 + private let readySpringDuration: TimeInterval = 0.4 + private let readySpringBounce = 0.15 + private let alertSpringDuration: TimeInterval = 0.35 + private let alertSpringBounce = 0.2 + private let defaultSpringDuration: TimeInterval = 0.4 - private var pillAnimation: Animation { - if reduceMotion { return .linear(duration: 0) } + private var pillAnimation: Animation { + if reduceMotion { return .linear(duration: 0) } - switch appState.statusPillState { - case .ready: - return .spring(duration: readySpringDuration, bounce: readySpringBounce) - case .failed, .disconnected: - return .spring(duration: alertSpringDuration, bounce: alertSpringBounce) - default: - return .spring(duration: defaultSpringDuration) - } + switch appState.statusPillState { + case .ready: + return .spring(duration: readySpringDuration, bounce: readySpringBounce) + case .failed, .disconnected: + return .spring(duration: alertSpringDuration, bounce: alertSpringBounce) + default: + return .spring(duration: defaultSpringDuration) } + } - /// Animates the pill's content swap, suppressed under Reduce Motion to match `pillAnimation`. - private var contentAnimation: Animation { - reduceMotion ? .linear(duration: 0) : .spring(duration: transitionDuration) - } + /// Animates the pill's content swap, suppressed under Reduce Motion to match `pillAnimation`. + private var contentAnimation: Animation { + reduceMotion ? .linear(duration: 0) : .spring(duration: transitionDuration) + } - func body(content: Content) -> some View { - ZStack(alignment: .top) { - content + func body(content: Content) -> some View { + ZStack(alignment: .top) { + content - SyncingPillView( - state: displayedPillState, - onDisconnectedTap: onDisconnectedTap - ) - .animation(contentAnimation, value: displayedPillState) - .padding(.top, topInset) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .offset(y: appState.statusPillState == .hidden ? offscreenOffset : 0) - .opacity(appState.statusPillState == .hidden ? 0 : 1) - .animation(pillAnimation, value: appState.statusPillState) - .allowsHitTesting(appState.statusPillState != .hidden) - } - .onChange(of: appState.statusPillState, initial: true) { _, new in - if new != .hidden { - withAnimation(pillAnimation) { - displayedPillState = new - } - } + SyncingPillView( + state: displayedPillState, + onDisconnectedTap: onDisconnectedTap + ) + .animation(contentAnimation, value: displayedPillState) + .padding(.top, topInset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .offset(y: appState.statusPillState == .hidden ? offscreenOffset : 0) + .opacity(appState.statusPillState == .hidden ? 0 : 1) + .animation(pillAnimation, value: appState.statusPillState) + .allowsHitTesting(appState.statusPillState != .hidden) + } + .onChange(of: appState.statusPillState, initial: true) { _, new in + if new != .hidden { + withAnimation(pillAnimation) { + displayedPillState = new } + } } + } } extension View { - /// Pins the connection syncing pill to the top of this shell. See `SyncingPillOverlay`. - func syncingPillOverlay(onDisconnectedTap: @escaping () -> Void) -> some View { - modifier(SyncingPillOverlay(onDisconnectedTap: onDisconnectedTap)) - } + /// Pins the connection syncing pill to the top of this shell. See `SyncingPillOverlay`. + func syncingPillOverlay(onDisconnectedTap: @escaping () -> Void) -> some View { + modifier(SyncingPillOverlay(onDisconnectedTap: onDisconnectedTap)) + } } diff --git a/MC1/Views/Components/SyncingPillView.swift b/MC1/Views/Components/SyncingPillView.swift index 858755f3..c10729ee 100644 --- a/MC1/Views/Components/SyncingPillView.swift +++ b/MC1/Views/Components/SyncingPillView.swift @@ -2,89 +2,88 @@ import SwiftUI /// A pill-shaped indicator that appears at the top of the app during sync and connection operations struct SyncingPillView: View { - let state: StatusPillState - var onDisconnectedTap: (() -> Void)? + let state: StatusPillState + var onDisconnectedTap: (() -> Void)? - var body: some View { - if case .disconnected = state, let onDisconnectedTap { - Button(action: onDisconnectedTap) { - pillBody - } - .buttonStyle(.plain) - .accessibilityHint(L10n.Localizable.Common.Accessibility.connectHint) - } else { - pillBody - } + var body: some View { + if case .disconnected = state, let onDisconnectedTap { + Button(action: onDisconnectedTap) { + pillBody + } + .buttonStyle(.plain) + .accessibilityHint(L10n.Localizable.Common.Accessibility.connectHint) + } else { + pillBody } + } - private var pillBody: some View { - HStack(spacing: 8) { - icon - Text(state.displayText) - .font(.subheadline) - .fontWeight(state.isFailure ? .bold : .medium) - .foregroundStyle(state.textColor) - .contentTransition(.identity) - } - .geometryGroup() - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background { - Capsule() - .fill(backgroundStyle) - .shadow(color: .black.opacity(0.15), radius: 8, y: 4) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(state.displayText) - .accessibilityAddTraits(state.isFailure ? [] : .updatesFrequently) + private var pillBody: some View { + HStack(spacing: 8) { + icon + Text(state.displayText) + .font(.subheadline) + .fontWeight(state.isFailure ? .bold : .medium) + .foregroundStyle(state.textColor) + .contentTransition(.identity) + } + .geometryGroup() + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background { + Capsule() + .fill(backgroundStyle) + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) } + .accessibilityElement(children: .combine) + .accessibilityLabel(state.displayText) + .accessibilityAddTraits(state.isFailure ? [] : .updatesFrequently) + } - @ViewBuilder - private var icon: some View { - Group { - switch state { - case .failed: - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - case .disconnected: - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) - case .ready: - Image(systemName: "checkmark.circle") - .foregroundStyle(.green) - case .connecting, .syncing: - Image(systemName: "arrow.trianglehead.2.clockwise") - .symbolEffect(.rotate, isActive: true) - case .hidden: - EmptyView() - } - } - .font(.subheadline) - .frame(width: 16, height: 16) + private var icon: some View { + Group { + switch state { + case .failed: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + case .disconnected: + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + case .ready: + Image(systemName: "checkmark.circle") + .foregroundStyle(.green) + case .connecting, .syncing: + Image(systemName: "arrow.trianglehead.2.clockwise") + .symbolEffect(.rotate, isActive: true) + case .hidden: + EmptyView() + } } + .font(.subheadline) + .frame(width: 16, height: 16) + } - private var backgroundStyle: AnyShapeStyle { - if state.isFailure { - AnyShapeStyle(.red.opacity(0.15)) - } else { - AnyShapeStyle(.regularMaterial) - } + private var backgroundStyle: AnyShapeStyle { + if state.isFailure { + AnyShapeStyle(.red.opacity(0.15)) + } else { + AnyShapeStyle(.regularMaterial) } + } } #Preview("All States") { - ZStack { - Color.gray.opacity(0.3) - .ignoresSafeArea() + ZStack { + Color.gray.opacity(0.3) + .ignoresSafeArea() - VStack(spacing: 12) { - SyncingPillView(state: .connecting) - SyncingPillView(state: .syncing) - SyncingPillView(state: .ready) - SyncingPillView(state: .disconnected) - SyncingPillView(state: .failed(message: "Sync Failed")) - Spacer() - } - .padding(.top, 60) + VStack(spacing: 12) { + SyncingPillView(state: .connecting) + SyncingPillView(state: .syncing) + SyncingPillView(state: .ready) + SyncingPillView(state: .disconnected) + SyncingPillView(state: .failed(message: "Sync Failed")) + Spacer() } + .padding(.top, 60) + } } diff --git a/MC1/Views/Components/TintedLabel.swift b/MC1/Views/Components/TintedLabel.swift index 680495b2..bcc6d6fb 100644 --- a/MC1/Views/Components/TintedLabel.swift +++ b/MC1/Views/Components/TintedLabel.swift @@ -3,20 +3,20 @@ import SwiftUI /// A Label whose icon renders in the accent color. /// Use in NavigationSplitView sidebars where automatic icon tinting is suppressed. struct TintedLabel: View { - let title: String - let systemImage: String + let title: String + let systemImage: String - init(_ title: String, systemImage: String) { - self.title = title - self.systemImage = systemImage - } + init(_ title: String, systemImage: String) { + self.title = title + self.systemImage = systemImage + } - var body: some View { - Label { - Text(title) - } icon: { - Image(systemName: systemImage) - .foregroundStyle(.tint) - } + var body: some View { + Label { + Text(title) + } icon: { + Image(systemName: systemImage) + .foregroundStyle(.tint) } + } } diff --git a/MC1/Views/Components/WiFiAddressFields.swift b/MC1/Views/Components/WiFiAddressFields.swift index 52964102..1aafeedc 100644 --- a/MC1/Views/Components/WiFiAddressFields.swift +++ b/MC1/Views/Components/WiFiAddressFields.swift @@ -1,97 +1,97 @@ import SwiftUI enum WiFiField: Hashable { - case ipAddress, port + case ipAddress, port } /// Shared IP address and port input fields used by WiFi connection sheets. struct WiFiAddressFields: View { - @Binding var ipAddress: String - @Binding var port: String - var focusedField: FocusState.Binding - let sectionHeader: String - let sectionFooter: String - let onPortSubmit: () -> Void + @Binding var ipAddress: String + @Binding var port: String + var focusedField: FocusState.Binding + let sectionHeader: String + let sectionFooter: String + let onPortSubmit: () -> Void - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.horizontalSizeClass) private var horizontalSizeClass - private var usesFullKeyboardInput: Bool { - horizontalSizeClass == .regular - } - - var body: some View { - Section { - HStack { - TextField(L10n.Onboarding.WifiConnection.IpAddress.placeholder, text: $ipAddress) - .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .decimalPad) - .environment(\.locale, Locale(identifier: "en_US")) - .textContentType(.none) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.next) - .focused(focusedField, equals: .ipAddress) - .onChange(of: ipAddress) { _, newValue in - let replaced = newValue.replacing(",", with: ".") - if replaced != newValue { - ipAddress = replaced - } - } - .onSubmit { - focusedField.wrappedValue = .port - } + private var usesFullKeyboardInput: Bool { + horizontalSizeClass == .regular + } - if !ipAddress.isEmpty { - Button { - ipAddress = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Onboarding.WifiConnection.IpAddress.clearAccessibility) - } + var body: some View { + Section { + HStack { + TextField(L10n.Onboarding.WifiConnection.IpAddress.placeholder, text: $ipAddress) + .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .decimalPad) + .environment(\.locale, Locale(identifier: "en_US")) + .textContentType(.none) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .focused(focusedField, equals: .ipAddress) + .onChange(of: ipAddress) { _, newValue in + let replaced = newValue.replacing(",", with: ".") + if replaced != newValue { + ipAddress = replaced } + } + .onSubmit { + focusedField.wrappedValue = .port + } - HStack { - TextField(L10n.Onboarding.WifiConnection.Port.placeholder, text: $port) - .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .numberPad) - .submitLabel(.done) - .focused(focusedField, equals: .port) - .onSubmit { - onPortSubmit() - } - - if !port.isEmpty { - Button { - port = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Onboarding.WifiConnection.Port.clearAccessibility) - } - } - } header: { - Text(sectionHeader) - } footer: { - Text(sectionFooter) + if !ipAddress.isEmpty { + Button { + ipAddress = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Onboarding.WifiConnection.IpAddress.clearAccessibility) } - } + } - // MARK: - Validation + HStack { + TextField(L10n.Onboarding.WifiConnection.Port.placeholder, text: $port) + .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .numberPad) + .submitLabel(.done) + .focused(focusedField, equals: .port) + .onSubmit { + onPortSubmit() + } - static func isValidIPAddress(_ ip: String) -> Bool { - let parts = ip.split(separator: ".") - guard parts.count == 4 else { return false } - return parts.allSatisfy { part in - guard let num = Int(part) else { return false } - return num >= 0 && num <= 255 + if !port.isEmpty { + Button { + port = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Onboarding.WifiConnection.Port.clearAccessibility) } + } + } header: { + Text(sectionHeader) + } footer: { + Text(sectionFooter) } + } + + // MARK: - Validation - static func isValidPort(_ port: String) -> Bool { - guard let num = UInt16(port) else { return false } - return num > 0 + static func isValidIPAddress(_ ip: String) -> Bool { + let parts = ip.split(separator: ".") + guard parts.count == 4 else { return false } + return parts.allSatisfy { part in + guard let num = Int(part) else { return false } + return num >= 0 && num <= 255 } + } + + static func isValidPort(_ port: String) -> Bool { + guard let num = UInt16(port) else { return false } + return num > 0 + } } diff --git a/MC1/Views/Components/WiFiSheetToolbarModifier.swift b/MC1/Views/Components/WiFiSheetToolbarModifier.swift index 3f7844b5..9c5013eb 100644 --- a/MC1/Views/Components/WiFiSheetToolbarModifier.swift +++ b/MC1/Views/Components/WiFiSheetToolbarModifier.swift @@ -5,53 +5,53 @@ import SwiftUI /// Provides cancel (leading), Done for iPad (top-bar-trailing when focused), /// and Done for compact-width keyboard toolbar. struct WiFiSheetToolbarModifier: ViewModifier { - var focusedField: FocusState.Binding - let isProcessing: Bool + var focusedField: FocusState.Binding + let isProcessing: Bool - @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass - private var usesFullKeyboardInput: Bool { - horizontalSizeClass == .regular - } + private var usesFullKeyboardInput: Bool { + horizontalSizeClass == .regular + } - func body(content: Content) -> some View { - content - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - focusedField.wrappedValue = nil - dismiss() - } - .disabled(isProcessing) - } - ToolbarItem(placement: .topBarTrailing) { - if usesFullKeyboardInput, focusedField.wrappedValue != nil { - Button(L10n.Localizable.Common.done) { - focusedField.wrappedValue = nil - } - } - } - if !usesFullKeyboardInput { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.Localizable.Common.done) { - focusedField.wrappedValue = nil - } - } - } + func body(content: Content) -> some View { + content + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + focusedField.wrappedValue = nil + dismiss() + } + .disabled(isProcessing) + } + ToolbarItem(placement: .topBarTrailing) { + if usesFullKeyboardInput, focusedField.wrappedValue != nil { + Button(L10n.Localizable.Common.done) { + focusedField.wrappedValue = nil } - } + } + } + if !usesFullKeyboardInput { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.Localizable.Common.done) { + focusedField.wrappedValue = nil + } + } + } + } + } } extension View { - func wifiSheetToolbar( - focusedField: FocusState.Binding, - isProcessing: Bool - ) -> some View { - modifier(WiFiSheetToolbarModifier( - focusedField: focusedField, - isProcessing: isProcessing - )) - } + func wifiSheetToolbar( + focusedField: FocusState.Binding, + isProcessing: Bool + ) -> some View { + modifier(WiFiSheetToolbarModifier( + focusedField: focusedField, + isProcessing: isProcessing + )) + } } diff --git a/MC1/Views/Contacts/AddContactSheet.swift b/MC1/Views/Contacts/AddContactSheet.swift index aa0ffeb2..d6177422 100644 --- a/MC1/Views/Contacts/AddContactSheet.swift +++ b/MC1/Views/Contacts/AddContactSheet.swift @@ -1,314 +1,314 @@ -import SwiftUI import MC1Services import os +import SwiftUI /// Sheet for manually adding a contact or scanning a QR code struct AddContactSheet: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - @State private var selectedType: ContactType = .chat - @State private var contactName = "" - @State private var publicKeyHex = "" - @State private var showScanner = false - @State private var isSubmitting = false - @State private var errorMessage: String? - - private let logger = Logger(subsystem: "com.mc1", category: "AddContactSheet") - - // MARK: - Validation - - private var normalizedPublicKeyHex: String { - publicKeyHex.filter { $0.isHexDigit }.lowercased() - } - - private var isValidPublicKey: Bool { - normalizedPublicKeyHex.count == Constants.publicKeyHexLength - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + + @State private var selectedType: ContactType = .chat + @State private var contactName = "" + @State private var publicKeyHex = "" + @State private var showScanner = false + @State private var isSubmitting = false + @State private var errorMessage: String? + + private let logger = Logger(subsystem: "com.mc1", category: "AddContactSheet") + + // MARK: - Validation + + private var normalizedPublicKeyHex: String { + publicKeyHex.filter(\.isHexDigit).lowercased() + } + + private var isValidPublicKey: Bool { + normalizedPublicKeyHex.count == Constants.publicKeyHexLength + } + + private var canAdd: Bool { + !contactName.isEmpty && isValidPublicKey && !isSubmitting + } + + // MARK: - Body + + var body: some View { + NavigationStack { + Form { + ScannerSection(showScanner: $showScanner) + .themedRowBackground(theme) + + TypePickerSection(selectedType: $selectedType) + .themedRowBackground(theme) + + NameInputSection(contactName: $contactName) + .themedRowBackground(theme) + + PublicKeyInputSection( + publicKeyHex: $publicKeyHex, + normalizedCount: normalizedPublicKeyHex.count, + isValid: isValidPublicKey + ) + .themedRowBackground(theme) + + PasteURLSection { result in + contactName = result.name + publicKeyHex = result.publicKey.hexString + selectedType = result.contactType + errorMessage = nil + } + .themedRowBackground(theme) - private var canAdd: Bool { - !contactName.isEmpty && isValidPublicKey && !isSubmitting - } + if let errorMessage { + ErrorSection(message: errorMessage) + .themedRowBackground(theme) + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.Add.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Common.cancel) { + dismiss() + } + } - // MARK: - Body - - var body: some View { - NavigationStack { - Form { - ScannerSection(showScanner: $showScanner) - .themedRowBackground(theme) - - TypePickerSection(selectedType: $selectedType) - .themedRowBackground(theme) - - NameInputSection(contactName: $contactName) - .themedRowBackground(theme) - - PublicKeyInputSection( - publicKeyHex: $publicKeyHex, - normalizedCount: normalizedPublicKeyHex.count, - isValid: isValidPublicKey - ) - .themedRowBackground(theme) - - PasteURLSection { result in - contactName = result.name - publicKeyHex = result.publicKey.hexString - selectedType = result.contactType - errorMessage = nil - } - .themedRowBackground(theme) - - if let errorMessage { - ErrorSection(message: errorMessage) - .themedRowBackground(theme) - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.Add.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Contacts.Contacts.Common.cancel) { - dismiss() - } - } - - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Contacts.Contacts.Add.add) { - Task { - await handleAdd() - } - } - .disabled(!canAdd) - } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Add.add) { + Task { + await handleAdd() } - .navigationDestination(isPresented: $showScanner) { - ScanContactQRView { _, _ in - // Scanner handles import automatically - // Dismiss both sheets on success - showScanner = false - dismiss() - } - } - .disabled(isSubmitting) + } + .disabled(!canAdd) + } + } + .navigationDestination(isPresented: $showScanner) { + ScanContactQRView { _, _ in + // Scanner handles import automatically + // Dismiss both sheets on success + showScanner = false + dismiss() } + } + .disabled(isSubmitting) } + } - // MARK: - Actions - - @MainActor - private func handleAdd() async { - guard let services = appState.services, - let device = appState.connectedDevice else { - logger.error("Services or device not available") - errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected - return - } + // MARK: - Actions - let radioID = device.radioID - let maxContacts = device.maxContacts + @MainActor + private func handleAdd() async { + guard let services = appState.services, + let device = appState.connectedDevice else { + logger.error("Services or device not available") + errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected + return + } - guard let publicKeyData = Data(hexString: normalizedPublicKeyHex) else { - logger.error("Failed to convert hex string to data: \(normalizedPublicKeyHex)") - errorMessage = L10n.Contacts.Contacts.Add.Error.invalidFormat - return - } + let radioID = device.radioID + let maxContacts = device.maxContacts - guard publicKeyData.count == ProtocolLimits.publicKeySize else { - logger.error("Public key is not \(ProtocolLimits.publicKeySize) bytes: \(publicKeyData.count)") - errorMessage = L10n.Contacts.Contacts.Add.Error.invalidSize(ProtocolLimits.publicKeySize, Constants.publicKeyHexLength) - return - } + guard let publicKeyData = Data(hexString: normalizedPublicKeyHex) else { + logger.error("Failed to convert hex string to data: \(normalizedPublicKeyHex)") + errorMessage = L10n.Contacts.Contacts.Add.Error.invalidFormat + return + } - isSubmitting = true - errorMessage = nil - - do { - let currentTimestamp = UInt32(Date.now.timeIntervalSince1970) - - let contactFrame = ContactFrame( - publicKey: publicKeyData, - type: selectedType, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: contactName, - lastAdvertTimestamp: 0, // Never advertised - latitude: 0, - longitude: 0, - lastModified: currentTimestamp - ) - - logger.info("Adding contact: \(contactName) (\(publicKeyData.hexString))") - try await services.contactService.addOrUpdateContact(radioID: radioID, contact: contactFrame) - logger.info("Contact added successfully") + guard publicKeyData.count == ProtocolLimits.publicKeySize else { + logger.error("Public key is not \(ProtocolLimits.publicKeySize) bytes: \(publicKeyData.count)") + errorMessage = L10n.Contacts.Contacts.Add.Error.invalidSize(ProtocolLimits.publicKeySize, Constants.publicKeyHexLength) + return + } - dismiss() - } catch ContactServiceError.contactTableFull { - logger.error("Node list is full") - errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) - isSubmitting = false - } catch { - logger.error("Failed to add contact: \(error.localizedDescription)") - errorMessage = "\(L10n.Contacts.Contacts.Common.error): \(error.userFacingMessage)" - isSubmitting = false - } + isSubmitting = true + errorMessage = nil + + do { + let currentTimestamp = UInt32(Date.now.timeIntervalSince1970) + + let contactFrame = ContactFrame( + publicKey: publicKeyData, + type: selectedType, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: contactName, + lastAdvertTimestamp: 0, // Never advertised + latitude: 0, + longitude: 0, + lastModified: currentTimestamp + ) + + logger.info("Adding contact: \(contactName) (\(publicKeyData.hexString))") + try await services.contactService.addOrUpdateContact(radioID: radioID, contact: contactFrame) + logger.info("Contact added successfully") + + dismiss() + } catch ContactServiceError.contactTableFull { + logger.error("Node list is full") + errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) + isSubmitting = false + } catch { + logger.error("Failed to add contact: \(error.localizedDescription)") + errorMessage = "\(L10n.Contacts.Contacts.Common.error): \(error.userFacingMessage)" + isSubmitting = false } + } } // MARK: - Constants private enum Constants { - static let publicKeyHexLength = ProtocolLimits.publicKeySize * 2 + static let publicKeyHexLength = ProtocolLimits.publicKeySize * 2 } // MARK: - Scanner Section private struct ScannerSection: View { - @Binding var showScanner: Bool - - var body: some View { - Section { - Button { - showScanner = true - } label: { - Label(L10n.Contacts.Contacts.Add.scanQR, systemImage: "camera") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } + @Binding var showScanner: Bool + + var body: some View { + Section { + Button { + showScanner = true + } label: { + Label(L10n.Contacts.Contacts.Add.scanQR, systemImage: "camera") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) } + } } // MARK: - Type Picker Section private struct TypePickerSection: View { - @Binding var selectedType: ContactType - - var body: some View { - Section { - Picker(L10n.Contacts.Contacts.Add.type, selection: $selectedType) { - Text(L10n.Contacts.Contacts.NodeKind.chat).tag(ContactType.chat) - Text(L10n.Contacts.Contacts.NodeKind.repeater).tag(ContactType.repeater) - Text(L10n.Contacts.Contacts.NodeKind.room).tag(ContactType.room) - } - .pickerStyle(.segmented) - } header: { - Text(L10n.Contacts.Contacts.Add.type) - } + @Binding var selectedType: ContactType + + var body: some View { + Section { + Picker(L10n.Contacts.Contacts.Add.type, selection: $selectedType) { + Text(L10n.Contacts.Contacts.NodeKind.chat).tag(ContactType.chat) + Text(L10n.Contacts.Contacts.NodeKind.repeater).tag(ContactType.repeater) + Text(L10n.Contacts.Contacts.NodeKind.room).tag(ContactType.room) + } + .pickerStyle(.segmented) + } header: { + Text(L10n.Contacts.Contacts.Add.type) } + } } // MARK: - Name Input Section private struct NameInputSection: View { - @Binding var contactName: String - - var body: some View { - Section { - TextField(L10n.Contacts.Contacts.Add.contactName, text: $contactName) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .onChange(of: contactName) { _, newValue in - if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { - contactName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - } - } - } header: { - Text(L10n.Contacts.Contacts.Add.name) + @Binding var contactName: String + + var body: some View { + Section { + TextField(L10n.Contacts.Contacts.Add.contactName, text: $contactName) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .onChange(of: contactName) { _, newValue in + if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { + contactName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + } } + } header: { + Text(L10n.Contacts.Contacts.Add.name) } + } } // MARK: - Public Key Input Section private struct PublicKeyInputSection: View { - @Binding var publicKeyHex: String - let normalizedCount: Int - let isValid: Bool - - var body: some View { - Section { - TextField(L10n.Contacts.Contacts.Add.hexPlaceholder(Constants.publicKeyHexLength), text: $publicKeyHex) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.asciiCapable) - .font(.system(.body, design: .monospaced)) - .onChange(of: publicKeyHex) { _, newValue in - // Filter to hex chars only and lowercase - let filtered = newValue.filter { $0.isHexDigit }.lowercased() - if filtered != newValue { - publicKeyHex = filtered - } - } - - if !publicKeyHex.isEmpty { - HStack { - if isValid { - Label(L10n.Contacts.Contacts.Add.valid, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { - Label(L10n.Contacts.Contacts.Add.characterCount(normalizedCount, Constants.publicKeyHexLength), systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } - } - .font(.caption) - } - } header: { - Text(L10n.Contacts.Contacts.Add.publicKey) - } footer: { - Text(L10n.Contacts.Contacts.Add.publicKeyFooter(Constants.publicKeyHexLength)) + @Binding var publicKeyHex: String + let normalizedCount: Int + let isValid: Bool + + var body: some View { + Section { + TextField(L10n.Contacts.Contacts.Add.hexPlaceholder(Constants.publicKeyHexLength), text: $publicKeyHex) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .font(.system(.body, design: .monospaced)) + .onChange(of: publicKeyHex) { _, newValue in + // Filter to hex chars only and lowercase + let filtered = newValue.filter(\.isHexDigit).lowercased() + if filtered != newValue { + publicKeyHex = filtered + } + } + + if !publicKeyHex.isEmpty { + HStack { + if isValid { + Label(L10n.Contacts.Contacts.Add.valid, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label(L10n.Contacts.Contacts.Add.characterCount(normalizedCount, Constants.publicKeyHexLength), systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } } + .font(.caption) + } + } header: { + Text(L10n.Contacts.Contacts.Add.publicKey) + } footer: { + Text(L10n.Contacts.Contacts.Add.publicKeyFooter(Constants.publicKeyHexLength)) } + } } // MARK: - Error Section private struct ErrorSection: View { - let message: String + let message: String - var body: some View { - Section { - Text(message) - .foregroundStyle(.red) - .font(.caption) - } + var body: some View { + Section { + Text(message) + .foregroundStyle(.red) + .font(.caption) } + } } // MARK: - Paste URL Section private struct PasteURLSection: View { - let onParsed: (MeshCoreURLParser.ContactResult) -> Void - - @State private var showError = false - - var body: some View { - Section { - Button(L10n.Contacts.Contacts.Add.pasteURL, systemImage: "doc.on.clipboard") { - guard let clipboard = UIPasteboard.general.string, - let result = MeshCoreURLParser.parseContactURL(clipboard) else { - showError = true - return - } - showError = false - onParsed(result) - } + let onParsed: (MeshCoreURLParser.ContactResult) -> Void - if showError { - Text(L10n.Contacts.Contacts.Add.Error.invalidURL) - .foregroundStyle(.red) - .font(.caption) - } - } footer: { - Text(L10n.Contacts.Contacts.Add.pasteURLFooter) + @State private var showError = false + + var body: some View { + Section { + Button(L10n.Contacts.Contacts.Add.pasteURL, systemImage: "doc.on.clipboard") { + guard let clipboard = UIPasteboard.general.string, + let result = MeshCoreURLParser.parseContactURL(clipboard) else { + showError = true + return } + showError = false + onParsed(result) + } + + if showError { + Text(L10n.Contacts.Contacts.Add.Error.invalidURL) + .foregroundStyle(.red) + .font(.caption) + } + } footer: { + Text(L10n.Contacts.Contacts.Add.pasteURLFooter) } + } } #Preview { - AddContactSheet() - .environment(\.appState, AppState()) + AddContactSheet() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/BlockedContactsView.swift b/MC1/Views/Contacts/BlockedContactsView.swift index 30ba9e3f..d42b7b31 100644 --- a/MC1/Views/Contacts/BlockedContactsView.swift +++ b/MC1/Views/Contacts/BlockedContactsView.swift @@ -1,80 +1,80 @@ -import SwiftUI import MC1Services +import SwiftUI /// View showing only blocked contacts for management struct BlockedContactsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var contacts: [ContactDTO] = [] - @State private var isLoading = false - @State private var errorMessage: String? + @State private var contacts: [ContactDTO] = [] + @State private var isLoading = false + @State private var errorMessage: String? - var body: some View { - Group { - if isLoading { - ProgressView(L10n.Contacts.Contacts.Blocked.loading) - } else if contacts.isEmpty { - ContentUnavailableView( - L10n.Contacts.Contacts.Blocked.Empty.title, - systemImage: "hand.raised.slash", - description: Text(L10n.Contacts.Contacts.Blocked.Empty.description) - ) - } else { - blockedList - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.Blocked.title) - .errorAlert($errorMessage) - .task { - await loadBlockedContacts() - } - .onChange(of: appState.contactsVersion) { _, _ in - Task { - await loadBlockedContacts() - } - } + var body: some View { + Group { + if isLoading { + ProgressView(L10n.Contacts.Contacts.Blocked.loading) + } else if contacts.isEmpty { + ContentUnavailableView( + L10n.Contacts.Contacts.Blocked.Empty.title, + systemImage: "hand.raised.slash", + description: Text(L10n.Contacts.Contacts.Blocked.Empty.description) + ) + } else { + blockedList + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.Blocked.title) + .errorAlert($errorMessage) + .task { + await loadBlockedContacts() } + .onChange(of: appState.contactsVersion) { _, _ in + Task { + await loadBlockedContacts() + } + } + } - /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. - private static let rowSeparatorLeadingInset: CGFloat = 72 - private static let rowHorizontalPadding: CGFloat = 16 - private static let rowVerticalPadding: CGFloat = 6 + /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. + private static let rowSeparatorLeadingInset: CGFloat = 72 + private static let rowHorizontalPadding: CGFloat = 16 + private static let rowVerticalPadding: CGFloat = 6 - private var blockedList: some View { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in - NavigationLink(value: ContactRoute.detail(contact)) { - ContactRowView(contact: contact) - .padding(.horizontal, Self.rowHorizontalPadding) - .padding(.vertical, Self.rowVerticalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } - .buttonStyle(.plain) - .transition(.opacity) - if index < contacts.count - 1 { - Divider().padding(.leading, Self.rowSeparatorLeadingInset) - } - } - } + private var blockedList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in + NavigationLink(value: ContactRoute.detail(contact)) { + ContactRowView(contact: contact) + .padding(.horizontal, Self.rowHorizontalPadding) + .padding(.vertical, Self.rowVerticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .transition(.opacity) + if index < contacts.count - 1 { + Divider().padding(.leading, Self.rowSeparatorLeadingInset) + } } + } } + } - private func loadBlockedContacts() async { - guard let services = appState.services, - let radioID = appState.connectedDevice?.radioID else { return } - isLoading = true - defer { isLoading = false } + private func loadBlockedContacts() async { + guard let services = appState.services, + let radioID = appState.connectedDevice?.radioID else { return } + isLoading = true + defer { isLoading = false } - do { - contacts = try await services.dataStore.fetchBlockedContacts( - radioID: radioID - ) - } catch { - errorMessage = error.userFacingMessage - } + do { + contacts = try await services.dataStore.fetchBlockedContacts( + radioID: radioID + ) + } catch { + errorMessage = error.userFacingMessage } + } } diff --git a/MC1/Views/Contacts/ContactContextMenuModifier.swift b/MC1/Views/Contacts/ContactContextMenuModifier.swift index b362e162..cc1a8da1 100644 --- a/MC1/Views/Contacts/ContactContextMenuModifier.swift +++ b/MC1/Views/Contacts/ContactContextMenuModifier.swift @@ -1,61 +1,61 @@ -import SwiftUI import MC1Services +import SwiftUI /// Long-press context-menu actions for a node row: delete, block/unblock, favorite/unfavorite, /// matching the conversation list. Delete is gated while a removal is in flight so a rapid re-press /// can't double-fire. struct ContactContextMenuModifier: ViewModifier { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let contact: ContactDTO - let viewModel: ContactsViewModel + let contact: ContactDTO + let viewModel: ContactsViewModel - private var isConnected: Bool { - appState.connectionState == .ready - } + private var isConnected: Bool { + appState.connectionState == .ready + } - func body(content: Content) -> some View { - content.contextMenu { - Button(role: .destructive) { - Task { - await viewModel.deleteContact(contact) - } - } label: { - Label(L10n.Contacts.Contacts.Common.delete, systemImage: "trash") - } - .disabled(!isConnected || viewModel.isDeletePending(contact.id)) + func body(content: Content) -> some View { + content.contextMenu { + Button(role: .destructive) { + Task { + await viewModel.deleteContact(contact) + } + } label: { + Label(L10n.Contacts.Contacts.Common.delete, systemImage: "trash") + } + .disabled(!isConnected || viewModel.isDeletePending(contact.id)) - if contact.type == .chat { - Button { - Task { - await viewModel.toggleBlocked(contact: contact) - } - } label: { - Label( - contact.isBlocked ? L10n.Contacts.Contacts.Action.unblock : L10n.Contacts.Contacts.Action.block, - systemImage: contact.isBlocked ? "hand.raised.slash" : "hand.raised" - ) - } - .disabled(!isConnected) - } + if contact.type == .chat { + Button { + Task { + await viewModel.toggleBlocked(contact: contact) + } + } label: { + Label( + contact.isBlocked ? L10n.Contacts.Contacts.Action.unblock : L10n.Contacts.Contacts.Action.block, + systemImage: contact.isBlocked ? "hand.raised.slash" : "hand.raised" + ) + } + .disabled(!isConnected) + } - Button { - Task { - await viewModel.toggleFavorite(contact: contact) - } - } label: { - Label( - contact.isFavorite ? L10n.Contacts.Contacts.Action.unfavorite : L10n.Contacts.Contacts.Row.favorite, - systemImage: contact.isFavorite ? "star.slash" : "star.fill" - ) - } - .disabled(!isConnected || viewModel.togglingFavoriteID == contact.id) + Button { + Task { + await viewModel.toggleFavorite(contact: contact) } + } label: { + Label( + contact.isFavorite ? L10n.Contacts.Contacts.Action.unfavorite : L10n.Contacts.Contacts.Row.favorite, + systemImage: contact.isFavorite ? "star.slash" : "star.fill" + ) + } + .disabled(!isConnected || viewModel.togglingFavoriteID == contact.id) } + } } extension View { - func contactContextMenu(contact: ContactDTO, viewModel: ContactsViewModel) -> some View { - modifier(ContactContextMenuModifier(contact: contact, viewModel: viewModel)) - } + func contactContextMenu(contact: ContactDTO, viewModel: ContactsViewModel) -> some View { + modifier(ContactContextMenuModifier(contact: contact, viewModel: viewModel)) + } } diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index dc1afe19..f0110865 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -5,1216 +5,1221 @@ import UIKit /// Result of a ping operation enum PingResult { - case success(latencyMs: Int, snrThere: Double, snrBack: Double) - case error(String) + case success(latencyMs: Int, snrThere: Double, snrBack: Double) + case error(String) } /// Displays ping result with latency and bidirectional SNR struct PingResultRow: View { - let result: PingResult - - var body: some View { - switch result { - case .success(let latencyMs, let snrThere, let snrBack): - let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(2)) - Label { - Text("\(latencyMs) ms · SNR ↑ \(snrThere, format: snrFormat) dB ↓ \(snrBack, format: snrFormat) dB") - .font(.subheadline) - } icon: { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Detail.pingSuccessLabel(latencyMs, Int(snrThere), Int(snrBack))) - case .error(let message): - Label { - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - } icon: { - Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.orange) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Detail.pingFailureLabel(message)) - } + let result: PingResult + + var body: some View { + switch result { + case let .success(latencyMs, snrThere, snrBack): + let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(2)) + Label { + Text("\(latencyMs) ms · SNR ↑ \(snrThere, format: snrFormat) dB ↓ \(snrBack, format: snrFormat) dB") + .font(.subheadline) + } icon: { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Detail.pingSuccessLabel(latencyMs, Int(snrThere), Int(snrBack))) + case let .error(message): + Label { + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Detail.pingFailureLabel(message)) } + } } /// Detailed view for a single contact struct ContactDetailView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - let contact: ContactDTO - let showFromDirectChat: Bool - let onClearMessages: () -> Void - - /// Sheet types for the contact detail view - private enum ActiveSheet: Identifiable, Hashable { - case nodeAuth - case repeaterStatus(RemoteNodeSessionDTO) - case roomStatus(RemoteNodeSessionDTO) - case nodeTelemetry(ContactDTO) - case adminSettings(RemoteNodeSessionDTO) - - var id: String { - switch self { - case .nodeAuth: return "auth" - case .repeaterStatus(let session): return "status-\(session.id)" - case .roomStatus(let session): return "room-status-\(session.id)" - case .nodeTelemetry(let contact): return "telemetry-\(contact.id)" - case .adminSettings(let session): return "admin-settings-\(session.id)" - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.isPresented) private var isPresented + + let contact: ContactDTO + let showFromDirectChat: Bool + let onClearMessages: () -> Void + + /// Sheet types for the contact detail view + private enum ActiveSheet: Identifiable, Hashable { + case nodeAuth + case repeaterStatus(RemoteNodeSessionDTO) + case roomStatus(RemoteNodeSessionDTO) + case nodeTelemetry(ContactDTO) + case adminSettings(RemoteNodeSessionDTO) + + var id: String { + switch self { + case .nodeAuth: "auth" + case let .repeaterStatus(session): "status-\(session.id)" + case let .roomStatus(session): "room-status-\(session.id)" + case let .nodeTelemetry(contact): "telemetry-\(contact.id)" + case let .adminSettings(session): "admin-settings-\(session.id)" + } } - - @State private var currentContact: ContactDTO - @State private var nickname = "" - @State private var isEditingNickname = false - @State private var showingBlockAlert = false - @State private var showingDeleteAlert = false - @State private var showingClearMessagesAlert = false - @State private var isClearingMessages = false - @State private var isSaving = false - @State private var isTogglingFavorite = false - @State private var errorMessage: String? - @State private var pathViewModel = PathManagementViewModel() - @State private var showRoomJoinSheet = false - @State private var activeSheet: ActiveSheet? - @State private var pendingSheet: ActiveSheet? - // Admin access navigation state (separate from telemetry sheet flow) - @State private var showRepeaterAdminAuth = false - @State private var adminSession: RemoteNodeSessionDTO? - // QR sharing state - @State private var showQRShareSheet = false - // Ping state - @State private var isPinging = false - @State private var pingResult: PingResult? - @State private var isSharing = false - @State private var showShareSuccess = false - - init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) { - self.contact = contact - self.showFromDirectChat = showFromDirectChat - self.onClearMessages = onClearMessages - self._currentContact = State(initialValue: contact) + } + + @State private var currentContact: ContactDTO + @State private var nickname = "" + @State private var isEditingNickname = false + @State private var showingBlockAlert = false + @State private var showingDeleteAlert = false + @State private var showingClearMessagesAlert = false + @State private var isClearingMessages = false + @State private var isSaving = false + @State private var isFavorite: Bool + @State private var favoriteTask: Task? + @State private var errorMessage: String? + @State private var pathViewModel = PathManagementViewModel() + @State private var showRoomJoinSheet = false + @State private var activeSheet: ActiveSheet? + @State private var pendingSheet: ActiveSheet? + // Admin access navigation state (separate from telemetry sheet flow) + @State private var showRepeaterAdminAuth = false + @State private var adminSession: RemoteNodeSessionDTO? + /// QR sharing state + @State private var showQRShareSheet = false + // Ping state + @State private var isPinging = false + @State private var pingResult: PingResult? + @State private var isSharing = false + @State private var showShareSuccess = false + @State private var headerHeight: CGFloat = 150 + + init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) { + self.contact = contact + self.showFromDirectChat = showFromDirectChat + self.onClearMessages = onClearMessages + _currentContact = State(initialValue: contact) + _isFavorite = State(initialValue: contact.isFavorite) + } + + var body: some View { + List { + // Profile header + ContactProfileSection( + currentContact: currentContact, + contactTypeLabel: contactTypeLabel, + measuredHeight: $headerHeight + ) + + // Quick actions + ContactActionsSection( + currentContact: currentContact, + isFavorite: $isFavorite, + showFromDirectChat: showFromDirectChat, + isPinging: isPinging, + pingResult: pingResult, + onJoinRoom: { showRoomJoinSheet = true }, + onShowTelemetry: { + if currentContact.type == .chat { + activeSheet = .nodeTelemetry(currentContact) + } else { + activeSheet = .nodeAuth + } + }, + onShowAdminAccess: { + adminSession = nil + showRepeaterAdminAuth = true + }, + onPingRepeater: { Task { await pingRepeater() } }, + onShareQR: { showQRShareSheet = true }, + onShareViaAdvert: { Task { await shareContact() } }, + isSharing: isSharing, + showShareSuccess: showShareSuccess + ) + .themedRowBackground(theme) + .onChange(of: isFavorite) { _, newValue in + favoriteTask?.cancel() + favoriteTask = Task { await setFavorite(newValue) } + } + + // Info section + ContactInfoSection( + currentContact: currentContact, + nickname: $nickname, + isEditingNickname: $isEditingNickname, + isSaving: isSaving, + onSaveNickname: { Task { await saveNickname() } } + ) + .themedRowBackground(theme) + + // Location section (if available) + if currentContact.hasLocation { + ContactLocationSection(currentContact: currentContact) + .themedRowBackground(theme) + } + + // Network path controls + ContactNetworkPathSection( + currentContact: currentContact, + pathViewModel: pathViewModel + ) + .themedRowBackground(theme) + + // Technical details + ContactTechnicalSection( + currentContact: currentContact, + contactTypeLabel: contactTypeLabel + ) + .themedRowBackground(theme) + + // Danger zone + ContactDangerSection( + currentContact: currentContact, + contactTypeLabel: contactTypeLabel, + isClearingMessages: isClearingMessages, + onClearMessages: { showingClearMessagesAlert = true }, + onToggleBlock: { + if currentContact.isBlocked { + Task { await toggleBlocked() } + } else { + showingBlockAlert = true + } + }, + onDelete: { showingDeleteAlert = true } + ) + .themedRowBackground(theme) } - - var body: some View { - List { - // Profile header - ContactProfileSection( - currentContact: currentContact, - contactTypeLabel: contactTypeLabel - ) - - // Quick actions - ContactActionsSection( - currentContact: currentContact, - showFromDirectChat: showFromDirectChat, - isPinging: isPinging, - isTogglingFavorite: isTogglingFavorite, - pingResult: pingResult, - onJoinRoom: { showRoomJoinSheet = true }, - onShowTelemetry: { - if currentContact.type == .chat { - activeSheet = .nodeTelemetry(currentContact) - } else { - activeSheet = .nodeAuth - } - }, - onShowAdminAccess: { - adminSession = nil - showRepeaterAdminAuth = true - }, - onPingRepeater: { Task { await pingRepeater() } }, - onToggleFavorite: { Task { await toggleFavorite() } }, - onShareQR: { showQRShareSheet = true }, - onShareViaAdvert: { Task { await shareContact() } }, - isSharing: isSharing, - showShareSuccess: showShareSuccess - ) - .themedRowBackground(theme) - - // Info section - ContactInfoSection( - currentContact: currentContact, - nickname: $nickname, - isEditingNickname: $isEditingNickname, - isSaving: isSaving, - onSaveNickname: { Task { await saveNickname() } } - ) - .themedRowBackground(theme) - - // Location section (if available) - if currentContact.hasLocation { - ContactLocationSection(currentContact: currentContact) - .themedRowBackground(theme) - } - - // Network path controls - ContactNetworkPathSection( - currentContact: currentContact, - pathViewModel: pathViewModel - ) - .themedRowBackground(theme) - - // Technical details - ContactTechnicalSection( - currentContact: currentContact, - contactTypeLabel: contactTypeLabel - ) - .themedRowBackground(theme) - - // Danger zone - ContactDangerSection( - currentContact: currentContact, - contactTypeLabel: contactTypeLabel, - isClearingMessages: isClearingMessages, - onClearMessages: { showingClearMessagesAlert = true }, - onToggleBlock: { - if currentContact.isBlocked { - Task { await toggleBlocked() } - } else { - showingBlockAlert = true - } - }, - onDelete: { showingDeleteAlert = true } - ) - .themedRowBackground(theme) + .themedCanvas(theme) + .errorAlert($errorMessage) + .navigationBarTitleDisplayMode(.inline) + .scrollRevealNavigationTitle(currentContact.displayName, revealAfter: headerHeight) + .contentMargins(.top, 0, for: .scrollContent) + .toolbar { + if isPresented { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.Common.done) { dismiss() } } - .themedCanvas(theme) - .errorAlert($errorMessage) - .navigationTitle(contactTypeLabel) - .navigationBarTitleDisplayMode(.inline) - .alert(L10n.Contacts.Contacts.Detail.Alert.Block.title, isPresented: $showingBlockAlert) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Action.block, role: .destructive) { - Task { - await toggleBlocked() - } - } - } message: { - Text(L10n.Contacts.Contacts.Detail.Alert.Block.message(currentContact.displayName)) - } - .alert(L10n.Contacts.Contacts.Detail.Alert.Delete.title(contactTypeLabel), isPresented: $showingDeleteAlert) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { - Task { - await deleteContact() - } - } - } message: { - Text(L10n.Contacts.Contacts.Detail.Alert.Delete.message(currentContact.displayName)) - } - .alert(L10n.Contacts.Contacts.Detail.Alert.ClearMessages.title, isPresented: $showingClearMessagesAlert) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Detail.clearMessages, role: .destructive) { - Task { - await clearMessages() - } - } - } message: { - Text(L10n.Contacts.Contacts.Detail.Alert.ClearMessages.message(currentContact.displayName)) - } - .onAppear { - nickname = currentContact.nickname ?? "" - } - .task { - pathViewModel.configure( - dataStore: { appState.services?.dataStore }, - contactService: { appState.services?.contactService }, - connectedDevice: { appState.connectedDevice } - ) { - Task { @MainActor in - await refreshContact() - } - } - await pathViewModel.loadContacts(radioID: currentContact.radioID) - - // Fetch fresh contact data from device to catch external changes - // (e.g., user modified path in official MeshCore app) - if let freshContact = try? await appState.services?.contactService.getContact( - radioID: currentContact.radioID, - publicKey: currentContact.publicKey - ) { - currentContact = freshContact - } - - // React to path discovery push responses while this view is open. - // The view-scoped task cancels the subscription on dismiss; the - // stream is multicast, so a second detail column (iPad split view) - // can subscribe concurrently. - guard let advertisementService = appState.services?.advertisementService else { return } - for await event in advertisementService.events() { - if case .pathDiscoveryResponse(let response) = event { - pathViewModel.handleDiscoveryResponse(hopCount: response.outHopCount) - } - } + } + } + .alert(L10n.Contacts.Contacts.Detail.Alert.Block.title, isPresented: $showingBlockAlert) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Action.block, role: .destructive) { + Task { + await toggleBlocked() } - .onDisappear { - pathViewModel.cancelDiscovery() + } + } message: { + Text(L10n.Contacts.Contacts.Detail.Alert.Block.message(currentContact.displayName)) + } + .alert(L10n.Contacts.Contacts.Detail.Alert.Delete.title(contactTypeLabel), isPresented: $showingDeleteAlert) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { + Task { + await deleteContact() } - .sheet( - isPresented: $pathViewModel.showingPathEditor, - onDismiss: { pathViewModel.insertionIntent = nil } - ) { - PathEditingSheet(viewModel: pathViewModel, contact: currentContact) + } + } message: { + Text(L10n.Contacts.Contacts.Detail.Alert.Delete.message(currentContact.displayName)) + } + .alert(L10n.Contacts.Contacts.Detail.Alert.ClearMessages.title, isPresented: $showingClearMessagesAlert) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Detail.clearMessages, role: .destructive) { + Task { + await clearMessages() } - .alert( - L10n.Contacts.Contacts.Detail.Alert.pathError, - isPresented: Binding( - get: { pathViewModel.errorMessage != nil }, - set: { if !$0 { pathViewModel.errorMessage = nil } } - ) - ) { - Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) { } - } message: { - Text(pathViewModel.errorMessage ?? L10n.Contacts.Contacts.Common.errorOccurred) + } + } message: { + Text(L10n.Contacts.Contacts.Detail.Alert.ClearMessages.message(currentContact.displayName)) + } + .onAppear { + nickname = currentContact.nickname ?? "" + } + .task { + pathViewModel.configure( + dataStore: { appState.services?.dataStore }, + contactService: { appState.services?.contactService }, + connectedDevice: { appState.connectedDevice } + ) { + Task { @MainActor in + await refreshContact() } - .alert(L10n.Contacts.Contacts.Detail.Alert.pathDiscovery, isPresented: $pathViewModel.showDiscoveryResult) { - Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) { } - } message: { - Text(pathViewModel.discoveryResult?.description ?? "") + } + await pathViewModel.loadContacts(radioID: currentContact.radioID) + + // Fetch fresh contact data from device to catch external changes + // (e.g., user modified path in official MeshCore app) + if let freshContact = try? await appState.services?.contactService.getContact( + radioID: currentContact.radioID, + publicKey: currentContact.publicKey + ) { + currentContact = freshContact + } + + // React to path discovery push responses while this view is open. + // The view-scoped task cancels the subscription on dismiss; the + // stream is multicast, so a second detail column (iPad split view) + // can subscribe concurrently. + guard let advertisementService = appState.services?.advertisementService else { return } + for await event in advertisementService.events() { + if case let .pathDiscoveryResponse(response) = event { + pathViewModel.handleDiscoveryResponse(hopCount: response.outHopCount) } - .sheet(isPresented: $showRoomJoinSheet) { - if let role = RemoteNodeRole(contactType: currentContact.type) { - NodeAuthenticationSheet(contact: currentContact, role: role) { session in - // Navigate to Chats tab with the room conversation - appState.navigation.navigateToRoom(with: session) - } - .presentationSizing(.page) - } + } + } + .onDisappear { + pathViewModel.cancelDiscovery() + } + .sheet( + isPresented: $pathViewModel.showingPathEditor, + onDismiss: { pathViewModel.insertionIntent = nil } + ) { + PathEditingSheet(viewModel: pathViewModel, contact: currentContact) + } + .alert( + L10n.Contacts.Contacts.Detail.Alert.pathError, + isPresented: Binding( + get: { pathViewModel.errorMessage != nil }, + set: { if !$0 { pathViewModel.errorMessage = nil } } + ) + ) { + Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} + } message: { + Text(pathViewModel.errorMessage ?? L10n.Contacts.Contacts.Common.errorOccurred) + } + .alert(L10n.Contacts.Contacts.Detail.Alert.pathDiscovery, isPresented: $pathViewModel.showDiscoveryResult) { + Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} + } message: { + Text(pathViewModel.discoveryResult?.description ?? "") + } + .sheet(isPresented: $showRoomJoinSheet) { + if let role = RemoteNodeRole(contactType: currentContact.type) { + NodeAuthenticationSheet(contact: currentContact, role: role) { session in + // Navigate to Chats tab with the room conversation + appState.navigation.navigateToRoom(with: session) } - .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in - switch sheet { - case .nodeAuth: - if let role = RemoteNodeRole(contactType: currentContact.type) { - NodeAuthenticationSheet( - contact: currentContact, - role: role, - customTitle: L10n.Contacts.Contacts.Detail.telemetryAccess - ) { session in - if currentContact.type == .room { - pendingSheet = .roomStatus(session) - } else { - pendingSheet = .repeaterStatus(session) - } - activeSheet = nil // Triggers dismissal, then onDismiss fires - } - .presentationSizing(.page) - } - case .repeaterStatus(let session): - RepeaterStatusView(session: session) - case .roomStatus(let session): - RoomStatusView(session: session) - case .nodeTelemetry(let contact): - NodeTelemetryView(contact: contact) - case .adminSettings(let session): - // A sheet with its own stack, not a push onto the value/path-based Contacts stack: - // the telemetry tab's history graphs push value-based routes, and pushing this screen - // there instead would rebuild it and reset the selected tab whenever a graph is tapped. - NavigationStack { - Group { - if session.isRoom { - RoomSettingsView(session: session) - } else { - RepeaterSettingsView(session: session) - } - } - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.RemoteNodes.RemoteNodes.done) { activeSheet = nil } - } - } - } - .presentationSizing(.page) + .presentationSizing(.page) + } + } + .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in + switch sheet { + case .nodeAuth: + if let role = RemoteNodeRole(contactType: currentContact.type) { + NodeAuthenticationSheet( + contact: currentContact, + role: role, + customTitle: L10n.Contacts.Contacts.Detail.telemetryAccess + ) { session in + if currentContact.type == .room { + pendingSheet = .roomStatus(session) + } else { + pendingSheet = .repeaterStatus(session) } + activeSheet = nil // Triggers dismissal, then onDismiss fires + } + .presentationSizing(.page) } - .sheet(isPresented: $showRepeaterAdminAuth, onDismiss: { - // Trigger navigation after sheet is fully dismissed to avoid race conditions - if let session = adminSession { - if session.isAdmin { - activeSheet = .adminSettings(session) - } else if session.isRoom { - activeSheet = .roomStatus(session) - } else { - activeSheet = .repeaterStatus(session) - } + case let .repeaterStatus(session): + RepeaterStatusView(session: session) + case let .roomStatus(session): + RoomStatusView(session: session) + case let .nodeTelemetry(contact): + NodeTelemetryView(contact: contact) + case let .adminSettings(session): + // A sheet with its own stack, not a push onto the value/path-based Contacts stack: + // the telemetry tab's history graphs push value-based routes, and pushing this screen + // there instead would rebuild it and reset the selected tab whenever a graph is tapped. + NavigationStack { + Group { + if session.isRoom { + RoomSettingsView(session: session) + } else { + RepeaterSettingsView(session: session) } - }) { - if let role = RemoteNodeRole(contactType: currentContact.type) { - NodeAuthenticationSheet(contact: currentContact, role: role) { session in - adminSession = session - showRepeaterAdminAuth = false - // Navigation triggers in onDismiss above - } - .presentationSizing(.page) + } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { activeSheet = nil } } + } } - .sheet(isPresented: $showQRShareSheet) { - ContactQRShareSheet( - contactName: currentContact.name, - publicKey: currentContact.publicKey, - contactType: currentContact.type - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) + .presentationSizing(.page) + } + } + .sheet(isPresented: $showRepeaterAdminAuth, onDismiss: { + // Trigger navigation after sheet is fully dismissed to avoid race conditions + if let session = adminSession { + if session.isAdmin { + activeSheet = .adminSettings(session) + } else if session.isRoom { + activeSheet = .roomStatus(session) + } else { + activeSheet = .repeaterStatus(session) } - .navigationDestination(for: ContactRoute.TelemetryHistory.self) { route in - TelemetryHistoryOverviewView( - publicKey: route.publicKey, - radioID: route.radioID, - showNeighbors: route.showNeighbors - ) + } + }) { + if let role = RemoteNodeRole(contactType: currentContact.type) { + NodeAuthenticationSheet(contact: currentContact, role: role) { session in + adminSession = session + showRepeaterAdminAuth = false + // Navigation triggers in onDismiss above } + .presentationSizing(.page) + } } - - // MARK: - Sheet Management - - private func presentPendingSheet() { - if let next = pendingSheet { - pendingSheet = nil - activeSheet = next - } + .sheet(isPresented: $showQRShareSheet) { + ContactQRShareSheet( + contactName: currentContact.name, + publicKey: currentContact.publicKey, + contactType: currentContact.type + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } + .navigationDestination(for: ContactRoute.TelemetryHistory.self) { route in + TelemetryHistoryOverviewView( + publicKey: route.publicKey, + radioID: route.radioID, + showNeighbors: route.showNeighbors + ) + } + } - // MARK: - Actions - - private func toggleFavorite() async { - isTogglingFavorite = true - defer { isTogglingFavorite = false } + // MARK: - Sheet Management - do { - try await appState.services?.contactService.setContactFavorite( - currentContact.id, - isFavorite: !currentContact.isFavorite - ) - await refreshContact() - } catch { - errorMessage = error.userFacingMessage - } + private func presentPendingSheet() { + if let next = pendingSheet { + pendingSheet = nil + activeSheet = next } - - private func toggleBlocked() async { - do { - try await appState.services?.contactService.updateContactPreferences( - contactID: currentContact.id, - isBlocked: !currentContact.isBlocked - ) - await refreshContact() - } catch { - errorMessage = error.userFacingMessage - } + } + + // MARK: - Actions + + private func setFavorite(_ isFavorite: Bool) async { + do { + try await appState.services?.contactService.setContactFavorite( + currentContact.id, + isFavorite: isFavorite + ) + } catch { + errorMessage = error.userFacingMessage } - - private func deleteContact() async { - do { - try await appState.services?.contactService.removeContact( - radioID: currentContact.radioID, - publicKey: currentContact.publicKey - ) - dismiss() - } catch { - errorMessage = error.userFacingMessage - } + } + + private func toggleBlocked() async { + do { + try await appState.services?.contactService.updateContactPreferences( + contactID: currentContact.id, + isBlocked: !currentContact.isBlocked + ) + await refreshContact() + } catch { + errorMessage = error.userFacingMessage } - - private func clearMessages() async { - guard let contactService = appState.services?.contactService else { - errorMessage = L10n.Contacts.Contacts.Detail.Error.servicesUnavailable - return - } - - isClearingMessages = true - errorMessage = nil - - do { - try await contactService.clearContactMessages(contactID: currentContact.id) - await appState.services?.notificationService.removeDeliveredNotifications(forContactID: currentContact.id) - await appState.services?.notificationService.updateBadgeCount() - onClearMessages() - dismiss() - } catch { - errorMessage = error.userFacingMessage - isClearingMessages = false - } + } + + private func deleteContact() async { + do { + try await appState.services?.contactService.removeContact( + radioID: currentContact.radioID, + publicKey: currentContact.publicKey + ) + dismiss() + } catch { + errorMessage = error.userFacingMessage } + } - private func shareContact() async { - isSharing = true - do { - try await appState.services?.contactService.shareContact(publicKey: currentContact.publicKey) - isSharing = false - withAnimation { showShareSuccess = true } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { showShareSuccess = false } - } catch ContactServiceError.shareContactUnavailable { - isSharing = false - errorMessage = L10n.Contacts.Contacts.Detail.shareContactUnavailable - } catch { - isSharing = false - errorMessage = error.userFacingMessage - } + private func clearMessages() async { + guard let contactService = appState.services?.contactService else { + errorMessage = L10n.Contacts.Contacts.Detail.Error.servicesUnavailable + return } - private func pingRepeater() async { - guard !isPinging else { return } - isPinging = true - pingResult = nil - pingResult = await PingHelper.zeroHopPing(contact: currentContact, appState: appState) - isPinging = false + isClearingMessages = true + errorMessage = nil + + do { + try await contactService.clearContactMessages(contactID: currentContact.id) + await appState.services?.notificationService.removeDeliveredNotifications(forContactID: currentContact.id) + await appState.services?.notificationService.updateBadgeCount() + onClearMessages() + dismiss() + } catch { + errorMessage = error.userFacingMessage + isClearingMessages = false } - - private func refreshContact() async { - if let updated = try? await appState.services?.dataStore.fetchContact(id: currentContact.id) { - currentContact = updated - } + } + + private func shareContact() async { + isSharing = true + do { + try await appState.services?.contactService.shareContact(publicKey: currentContact.publicKey) + isSharing = false + withAnimation { showShareSuccess = true } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { showShareSuccess = false } + } catch ContactServiceError.shareContactUnavailable { + isSharing = false + errorMessage = L10n.Contacts.Contacts.Detail.shareContactUnavailable + } catch { + isSharing = false + errorMessage = error.userFacingMessage } - - // MARK: - Helpers - - private var contactTypeLabel: String { - currentContact.type.localizedName + } + + private func pingRepeater() async { + guard !isPinging else { return } + isPinging = true + pingResult = nil + pingResult = await PingHelper.zeroHopPing(contact: currentContact, appState: appState) + isPinging = false + } + + private func refreshContact() async { + if let updated = try? await appState.services?.dataStore.fetchContact(id: currentContact.id) { + currentContact = updated } - - private func saveNickname() async { - isSaving = true - do { - try await appState.services?.contactService.updateContactPreferences( - contactID: currentContact.id, - nickname: nickname.isEmpty ? nil : nickname - ) - await refreshContact() - } catch { - errorMessage = error.userFacingMessage - } - isEditingNickname = false - isSaving = false + } + + // MARK: - Helpers + + private var contactTypeLabel: String { + currentContact.type.localizedName + } + + private func saveNickname() async { + isSaving = true + do { + try await appState.services?.contactService.updateContactPreferences( + contactID: currentContact.id, + nickname: nickname + ) + await refreshContact() + } catch { + errorMessage = error.userFacingMessage } + isEditingNickname = false + isSaving = false + } } // MARK: - Extracted Views private struct ContactDetailAvatarView: View { - let contact: ContactDTO - - var body: some View { - switch contact.type { - case .chat: - ContactAvatar(contact: contact, size: 100) - case .repeater: - NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 100) - case .room: - NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 100) - } + let contact: ContactDTO + + var body: some View { + switch contact.type { + case .chat: + ContactAvatar(contact: contact, size: 150) + case .repeater: + NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 150) + case .room: + NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 150) } + } } private struct ContactProfileSection: View { - let currentContact: ContactDTO - let contactTypeLabel: String - - var body: some View { - Section { - VStack(spacing: 16) { - ContactDetailAvatarView(contact: currentContact) - - VStack(spacing: 4) { - Text(currentContact.displayName) - .font(.title2) - .bold() - - Text(contactTypeLabel) - .font(.subheadline) - .foregroundStyle(.secondary) - - // Status indicators - HStack(spacing: 12) { - if currentContact.isFavorite { - Label(L10n.Contacts.Contacts.Detail.favorite, systemImage: "star.fill") - .font(.caption) - .foregroundStyle(.yellow) - } - - if currentContact.isBlocked { - Label(L10n.Contacts.Contacts.Detail.blocked, systemImage: "hand.raised.fill") - .font(.caption) - .foregroundStyle(.orange) - } - - if currentContact.hasLocation { - Label(L10n.Contacts.Contacts.Detail.hasLocation, systemImage: "location.fill") - .font(.caption) - .foregroundStyle(.green) - } - } - } + let currentContact: ContactDTO + let contactTypeLabel: String + @Binding var measuredHeight: CGFloat + + var body: some View { + Section { + VStack(spacing: 12) { + ContactDetailAvatarView(contact: currentContact) + + VStack(spacing: 4) { + Text(currentContact.displayName) + .font(.title2) + .bold() + + Text(contactTypeLabel) + .font(.subheadline) + .foregroundStyle(.secondary) + + // Status indicators + VStack(spacing: 8) { + if currentContact.isBlocked { + HStack(spacing: 4) { + Image(systemName: "hand.raised.fill") + Text(L10n.Contacts.Contacts.Detail.blocked) + } + .font(.caption) + .foregroundStyle(.red) } - .frame(maxWidth: .infinity) - .listRowBackground(Color.clear) + + if currentContact.hasLocation { + HStack(spacing: 4) { + Image(systemName: "location.fill") + Text(L10n.Contacts.Contacts.Detail.hasLocation) + } + .font(.caption) + .foregroundStyle(.green) + } + } + .padding(.top, 4) } + } + .frame(maxWidth: .infinity) + .scrollRevealHeaderHeight(into: $measuredHeight) + .listRowBackground(Color.clear) } + } } private struct ContactActionsSection: View { - @Environment(\.appState) private var appState - - let currentContact: ContactDTO - let showFromDirectChat: Bool - let isPinging: Bool - let isTogglingFavorite: Bool - let pingResult: PingResult? - let onJoinRoom: () -> Void - let onShowTelemetry: () -> Void - let onShowAdminAccess: () -> Void - let onPingRepeater: () -> Void - let onToggleFavorite: () -> Void - let onShareQR: () -> Void - let onShareViaAdvert: () -> Void - let isSharing: Bool - let showShareSuccess: Bool - - var body: some View { - Section { - // Role-specific actions based on contact type - switch currentContact.type { - case .room: - Button(action: onJoinRoom) { - Label(L10n.Contacts.Contacts.Detail.joinRoom, systemImage: "door.left.hand.open") - } - .radioDisabled(for: appState.connectionState) - - NodeActionRows( - contact: currentContact, - pingLabel: L10n.Contacts.Contacts.Detail.ping, - isPinging: isPinging, - pingResult: pingResult, - connectionState: appState.connectionState, - onShowTelemetry: onShowTelemetry, - onShowAdminAccess: onShowAdminAccess, - onPing: onPingRepeater - ) - - case .repeater: - NodeActionRows( - contact: currentContact, - pingLabel: L10n.Contacts.Contacts.Detail.ping, - isPinging: isPinging, - pingResult: pingResult, - connectionState: appState.connectionState, - onShowTelemetry: onShowTelemetry, - onShowAdminAccess: onShowAdminAccess, - onPing: onPingRepeater - ) - - case .chat: - // Send message - only show when NOT from direct chat and NOT blocked - if !showFromDirectChat && !currentContact.isBlocked { - Button { - appState.navigation.navigateToChat(with: currentContact) - } label: { - Label(L10n.Contacts.Contacts.Detail.sendMessage, systemImage: "message.fill") - } - .radioDisabled(for: appState.connectionState) - } - - Button(action: onShowTelemetry) { - Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - .radioDisabled(for: appState.connectionState) - - NavigationLink(value: ContactRoute.TelemetryHistory( - publicKey: currentContact.publicKey, - radioID: currentContact.radioID, - showNeighbors: false - )) { - Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") - .foregroundStyle(.tint) - } - } - - // Toggle favorite (for all contact types) - Button(action: onToggleFavorite) { - HStack { - Label( - currentContact.isFavorite ? L10n.Contacts.Contacts.Detail.removeFromFavorites : L10n.Contacts.Contacts.Detail.addToFavorites, - systemImage: currentContact.isFavorite ? "star.slash" : "star" - ) - if isTogglingFavorite { - Spacer() - ProgressView() - } - } - } - .disabled(isTogglingFavorite) - .radioDisabled(for: appState.connectionState) + @Environment(\.appState) private var appState + + let currentContact: ContactDTO + @Binding var isFavorite: Bool + let showFromDirectChat: Bool + let isPinging: Bool + let pingResult: PingResult? + let onJoinRoom: () -> Void + let onShowTelemetry: () -> Void + let onShowAdminAccess: () -> Void + let onPingRepeater: () -> Void + let onShareQR: () -> Void + let onShareViaAdvert: () -> Void + let isSharing: Bool + let showShareSuccess: Bool + + var body: some View { + Section { + // Role-specific actions based on contact type + switch currentContact.type { + case .room: + Button(action: onJoinRoom) { + Label(L10n.Contacts.Contacts.Detail.joinRoom, systemImage: "door.left.hand.open") + } + .radioDisabled(for: appState.connectionState) + + NodeActionRows( + contact: currentContact, + pingLabel: L10n.Contacts.Contacts.Detail.ping, + isPinging: isPinging, + pingResult: pingResult, + connectionState: appState.connectionState, + onShowTelemetry: onShowTelemetry, + onShowAdminAccess: onShowAdminAccess, + onPing: onPingRepeater + ) - // Share Contact via QR - Button(action: onShareQR) { - Label(L10n.Contacts.Contacts.Detail.shareContact, systemImage: "square.and.arrow.up") - } + case .repeater: + NodeActionRows( + contact: currentContact, + pingLabel: L10n.Contacts.Contacts.Detail.ping, + isPinging: isPinging, + pingResult: pingResult, + connectionState: appState.connectionState, + onShowTelemetry: onShowTelemetry, + onShowAdminAccess: onShowAdminAccess, + onPing: onPingRepeater + ) - // Share Contact via Advert - Button(action: onShareViaAdvert) { - if isSharing || showShareSuccess { - AsyncActionLabel(isLoading: isSharing, showSuccess: showShareSuccess) { - EmptyView() - } - } else { - Label(L10n.Contacts.Contacts.Detail.shareViaAdvert, systemImage: "antenna.radiowaves.left.and.right") - } - } - .radioDisabled(for: appState.connectionState, or: isSharing || showShareSuccess) + case .chat: + // Send message - only show when NOT from direct chat and NOT blocked + if !showFromDirectChat, !currentContact.isBlocked { + Button { + appState.navigation.navigateToChat(with: currentContact) + } label: { + Label(L10n.Contacts.Contacts.Detail.sendMessage, systemImage: "message.fill") + } + .radioDisabled(for: appState.connectionState) } - } -} -private struct NodeActionRows: View { - let contact: ContactDTO - let pingLabel: String - let isPinging: Bool - let pingResult: PingResult? - let connectionState: DeviceConnectionState - let onShowTelemetry: () -> Void - let onShowAdminAccess: () -> Void - let onPing: () -> Void - - var body: some View { Button(action: onShowTelemetry) { - Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") } - .radioDisabled(for: connectionState) + .radioDisabled(for: appState.connectionState) NavigationLink(value: ContactRoute.TelemetryHistory( - publicKey: contact.publicKey, - radioID: contact.radioID + publicKey: currentContact.publicKey, + radioID: currentContact.radioID, + showNeighbors: false )) { - Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") - .foregroundStyle(.tint) - } - - Button(action: onShowAdminAccess) { - Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .foregroundStyle(.tint) } - .radioDisabled(for: connectionState) - - Button(action: onPing) { - HStack { - Label(pingLabel, systemImage: "wave.3.right") - if isPinging { - Spacer() - ProgressView() - } - } + } + + // Share Contact via QR + Button(action: onShareQR) { + Label(L10n.Contacts.Contacts.Detail.shareContact, systemImage: "square.and.arrow.up") + } + + // Share Contact via Advert + Button(action: onShareViaAdvert) { + if isSharing || showShareSuccess { + AsyncActionLabel(isLoading: isSharing, showSuccess: showShareSuccess) { + EmptyView() + } + } else { + Label(L10n.Contacts.Contacts.Detail.shareViaAdvert, systemImage: "antenna.radiowaves.left.and.right") } - .disabled(isPinging) - .radioDisabled(for: connectionState) + } + .radioDisabled(for: appState.connectionState, or: isSharing || showShareSuccess) - if let result = pingResult { - PingResultRow(result: result) - } + Toggle(isOn: $isFavorite) { + Label(L10n.Contacts.Contacts.Detail.favorite, systemImage: "star") + } } + } } -private struct ContactInfoSection: View { - let currentContact: ContactDTO - @Binding var nickname: String - @Binding var isEditingNickname: Bool - let isSaving: Bool - let onSaveNickname: () -> Void - - var body: some View { - Section { - // Nickname - HStack { - Text(L10n.Contacts.Contacts.Detail.nickname) - - Spacer() - - if isEditingNickname { - TextField(L10n.Contacts.Contacts.Detail.nickname, text: $nickname) - .textFieldStyle(.roundedBorder) - .frame(width: 150) - .onSubmit { - onSaveNickname() - } - - Button(L10n.Contacts.Contacts.Common.save) { - onSaveNickname() - } - .disabled(isSaving) - } else { - Text(currentContact.nickname ?? L10n.Contacts.Contacts.Detail.nicknameNone) - .foregroundStyle(.secondary) - - Button(L10n.Contacts.Contacts.Common.edit) { - isEditingNickname = true - } - .buttonStyle(.borderless) - } - } +private struct NodeActionRows: View { + let contact: ContactDTO + let pingLabel: String + let isPinging: Bool + let pingResult: PingResult? + let connectionState: DeviceConnectionState + let onShowTelemetry: () -> Void + let onShowAdminAccess: () -> Void + let onPing: () -> Void + + var body: some View { + Button(action: onShowTelemetry) { + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + .radioDisabled(for: connectionState) + + NavigationLink(value: ContactRoute.TelemetryHistory( + publicKey: contact.publicKey, + radioID: contact.radioID + )) { + Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .foregroundStyle(.tint) + } - // Original name - HStack { - Text(L10n.Contacts.Contacts.Detail.name) - Spacer() - Text(currentContact.name) - .foregroundStyle(.secondary) - } + Button(action: onShowAdminAccess) { + Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + } + .radioDisabled(for: connectionState) + + Button(action: onPing) { + HStack { + Label(pingLabel, systemImage: "wave.3.right") + if isPinging { + Spacer() + ProgressView() + } + } + } + .disabled(isPinging) + .radioDisabled(for: connectionState) - // Last advert - if currentContact.lastAdvertTimestamp > 0 { - HStack { - Text(L10n.Contacts.Contacts.Detail.lastAdvert) - Spacer() - ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(currentContact.lastAdvertTimestamp)), font: .body) - } - } + if let result = pingResult { + PingResultRow(result: result) + } + } +} - // Unread count - if currentContact.unreadCount > 0 { - HStack { - Text(L10n.Contacts.Contacts.Detail.unreadMessages) - Spacer() - Text(currentContact.unreadCount, format: .number) - .foregroundStyle(.blue) - } +private struct ContactInfoSection: View { + let currentContact: ContactDTO + @Binding var nickname: String + @Binding var isEditingNickname: Bool + let isSaving: Bool + let onSaveNickname: () -> Void + + var body: some View { + Section { + // Nickname + HStack { + Text(L10n.Contacts.Contacts.Detail.nickname) + + Spacer() + + if isEditingNickname { + TextField(L10n.Contacts.Contacts.Detail.nickname, text: $nickname) + .textFieldStyle(.roundedBorder) + .frame(width: 150) + .onSubmit { + onSaveNickname() } - } header: { - Text(L10n.Contacts.Contacts.Detail.info) + + Button(L10n.Contacts.Contacts.Common.save) { + onSaveNickname() + } + .disabled(isSaving) + } else { + Text(currentContact.nickname ?? L10n.Contacts.Contacts.Detail.nicknameNone) + .foregroundStyle(.secondary) + + Button(action: { + isEditingNickname = true + }) { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .accessibilityLabel(L10n.Contacts.Contacts.Common.edit) + } + } + + // Original name + HStack { + Text(L10n.Contacts.Contacts.Detail.name) + Spacer() + Text(currentContact.name) + .foregroundStyle(.secondary) + } + + // Last advert + if currentContact.lastAdvertTimestamp > 0 { + HStack { + Text(L10n.Contacts.Contacts.Detail.lastAdvert) + Spacer() + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(currentContact.lastAdvertTimestamp)), font: .body) } + } + + // Unread count + if currentContact.unreadCount > 0 { + HStack { + Text(L10n.Contacts.Contacts.Detail.unreadMessages) + Spacer() + Text(currentContact.unreadCount, format: .number) + .foregroundStyle(.blue) + } + } + } header: { + Text(L10n.Contacts.Contacts.Detail.info) } + } } private struct ContactLocationSection: View { - @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.appTheme) private var theme - - let currentContact: ContactDTO - - @State private var showFullMap = false - - var body: some View { - Section { - // Mini map - ZStack(alignment: .topTrailing) { - MC1MapView( - points: [MapPoint( - id: currentContact.id, - coordinate: currentContact.coordinate, - pinStyle: currentContact.type.pinStyle, - label: currentContact.displayName, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )], - lines: [], - mapStyle: .standard, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: false, - showsUserLocation: false, - isInteractive: false, - showsScale: false, - cameraRegion: .constant(MKCoordinateRegion( - center: currentContact.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - )), - cameraRegionVersion: currentContact.latitude.hashValue ^ currentContact.longitude.hashValue, - onPointTap: { _, _ in showFullMap = true }, - onMapTap: { _ in showFullMap = true }, - onCameraRegionChange: nil - ) - - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.caption.weight(.semibold)) - .padding(6) - .background(.regularMaterial, in: .rect(cornerRadius: 6)) - .padding(8) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - .frame(height: 200) - .clipShape(.rect(cornerRadius: 12)) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .padding(.bottom, 8) - .listRowSeparator(.hidden) - .sheet(isPresented: $showFullMap) { - ContactFullMapView(contact: currentContact) - } + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.appTheme) private var theme + + let currentContact: ContactDTO + + @State private var showFullMap = false + + var body: some View { + Section { + // Mini map + ZStack(alignment: .topTrailing) { + MC1MapView( + points: [MapPoint( + id: currentContact.id, + coordinate: currentContact.coordinate, + pinStyle: currentContact.type.pinStyle, + label: currentContact.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: false, + showsUserLocation: false, + isInteractive: false, + showsScale: false, + cameraRegion: .constant(MKCoordinateRegion( + center: currentContact.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )), + cameraRegionVersion: currentContact.latitude.hashValue ^ currentContact.longitude.hashValue, + onPointTap: { _, _ in showFullMap = true }, + onMapTap: { _ in showFullMap = true }, + onCameraRegionChange: nil + ) - // Coordinates - HStack { - Text(L10n.Contacts.Contacts.Detail.coordinates) - Spacer() - Text("\(currentContact.latitude, format: .number.precision(.fractionLength(4))), \(currentContact.longitude, format: .number.precision(.fractionLength(4)))") - .foregroundStyle(.secondary) - } - .listRowBackground( - UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10) - .fill(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground)) - ) - - // Open in Maps - Button { - openInMaps() - } label: { - Label(L10n.Contacts.Contacts.Detail.openInMaps, systemImage: "map") - } - } header: { - Text(L10n.Contacts.Contacts.Detail.location) - } + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.caption.weight(.semibold)) + .padding(6) + .background(.regularMaterial, in: .rect(cornerRadius: 6)) + .padding(8) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .frame(height: 200) + .clipShape(.rect(cornerRadius: 12)) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .padding(.bottom, 8) + .listRowSeparator(.hidden) + .sheet(isPresented: $showFullMap) { + ContactFullMapView(contact: currentContact) + } + + // Coordinates + HStack { + Text(L10n.Contacts.Contacts.Detail.coordinates) + Spacer() + Text("\(currentContact.latitude, format: .number.precision(.fractionLength(4))), \(currentContact.longitude, format: .number.precision(.fractionLength(4)))") + .foregroundStyle(.secondary) + } + .listRowBackground( + UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10) + .fill(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground)) + ) + + // Open in Maps + Button { + openInMaps() + } label: { + Label(L10n.Contacts.Contacts.Detail.openInMaps, systemImage: "map") + } + } header: { + Text(L10n.Contacts.Contacts.Detail.location) } + } - private func openInMaps() { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: currentContact.coordinate)) - mapItem.name = currentContact.displayName - mapItem.openInMaps() - } + private func openInMaps() { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: currentContact.coordinate)) + mapItem.name = currentContact.displayName + mapItem.openInMaps() + } } private struct ContactFullMapView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme - let contact: ContactDTO + let contact: ContactDTO - @State private var cameraRegion: MKCoordinateRegion? - @State private var cameraRegionVersion = 0 + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 - var body: some View { - NavigationStack { - MC1MapView( - points: [MapPoint( - id: contact.id, - coordinate: contact.coordinate, - pinStyle: contact.type.pinStyle, - label: contact.displayName, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )], - lines: [], - mapStyle: .standard, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: true, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - cameraRegion: $cameraRegion, - cameraRegionVersion: cameraRegionVersion, - onPointTap: nil, - onMapTap: nil, - onCameraRegionChange: { cameraRegion = $0 } - ) - .ignoresSafeArea() - .navigationTitle(contact.displayName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Localizable.Common.done) { dismiss() } - } - } - .onAppear { - cameraRegion = MKCoordinateRegion( - center: contact.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - ) - cameraRegionVersion = 1 - } + var body: some View { + NavigationStack { + MC1MapView( + points: [MapPoint( + id: contact.id, + coordinate: contact.coordinate, + pinStyle: contact.type.pinStyle, + label: contact.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: true, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { cameraRegion = $0 } + ) + .ignoresSafeArea() + .navigationTitle(contact.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.Common.done) { dismiss() } } + } + .onAppear { + cameraRegion = MKCoordinateRegion( + center: contact.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + ) + cameraRegionVersion = 1 + } } + } } private struct ContactNetworkPathSection: View { - @Environment(\.appState) private var appState - - let currentContact: ContactDTO - let pathViewModel: PathManagementViewModel - - private var pathDisplayWithNames: String { - let pathData = currentContact.outPath - let byteLength = currentContact.pathByteLength - let hashSize = currentContact.pathHashSize - guard byteLength > 0 else { return L10n.Contacts.Contacts.Route.direct } - - let relevantPath = pathData.prefix(byteLength) - return stride(from: 0, to: relevantPath.count, by: hashSize).map { start in - let end = min(start + hashSize, relevantPath.count) - let hopBytes = Data(relevantPath[start.. 0 else { return L10n.Contacts.Contacts.Route.direct } + + let relevantPath = pathData.prefix(byteLength) + return stride(from: 0, to: relevantPath.count, by: hashSize).map { start in + let end = min(start + hashSize, relevantPath.count) + let hopBytes = Data(relevantPath[start.. String { + if currentContact.isFloodRouted { + L10n.Contacts.Contacts.Route.flood + } else if currentContact.pathHopCount == 0 { + L10n.Contacts.Contacts.Route.direct + } else { + pathDisplay } + } - private func routeDisplayText(pathDisplay: String) -> String { - if currentContact.isFloodRouted { - return L10n.Contacts.Contacts.Route.flood - } else if currentContact.pathHopCount == 0 { - return L10n.Contacts.Contacts.Route.direct - } else { - return pathDisplay - } + private var networkPathFooterText: String { + if currentContact.isFloodRouted { + L10n.Contacts.Contacts.Detail.floodFooter + } else { + L10n.Contacts.Contacts.Detail.pathFooter } - - private var networkPathFooterText: String { - if currentContact.isFloodRouted { - return L10n.Contacts.Contacts.Detail.floodFooter - } else { - return L10n.Contacts.Contacts.Detail.pathFooter - } + } + + private func pathAccessibilityLabel(pathDisplay: String) -> String { + if currentContact.isFloodRouted { + L10n.Contacts.Contacts.Detail.routeFlood + } else if currentContact.pathHopCount == 0 { + L10n.Contacts.Contacts.Detail.routeDirect + } else { + L10n.Contacts.Contacts.Detail.routePrefix(pathDisplay) } - - private func pathAccessibilityLabel(pathDisplay: String) -> String { - if currentContact.isFloodRouted { - return L10n.Contacts.Contacts.Detail.routeFlood - } else if currentContact.pathHopCount == 0 { - return L10n.Contacts.Contacts.Detail.routeDirect - } else { - return L10n.Contacts.Contacts.Detail.routePrefix(pathDisplay) + } + + var body: some View { + let pathDisplay = pathDisplayWithNames + let isRoutePopulated = !currentContact.isFloodRouted && currentContact.pathHopCount > 0 + let routeIDPrefixes = currentContact.pathNodesHex.joined(separator: ",") + return Section { + // Current routing path + Label { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Contacts.Contacts.Detail.route) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(routeDisplayText(pathDisplay: pathDisplay)) + .font(.caption.monospaced()) + .foregroundStyle(.primary) } - } - - var body: some View { - let pathDisplay = pathDisplayWithNames - let isRoutePopulated = !currentContact.isFloodRouted && currentContact.pathHopCount > 0 - let routeIDPrefixes = currentContact.pathNodesHex.joined(separator: ",") - return Section { - // Current routing path - Label { - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Contacts.Contacts.Detail.route) - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(routeDisplayText(pathDisplay: pathDisplay)) - .font(.caption.monospaced()) - .foregroundStyle(.primary) - } - } icon: { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(pathAccessibilityLabel(pathDisplay: pathDisplay)) - .copyRouteContextMenu(route: routeIDPrefixes, enabled: isRoutePopulated) - .accessibilityAction(named: L10n.Contacts.Contacts.Detail.copyRoute) { - if isRoutePopulated { UIPasteboard.general.string = routeIDPrefixes } - } - - // Hops away: only when a deliberate or discovered out-path exists, not the passively - // heard inbound advert hops - if !currentContact.isFloodRouted { - Label { - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Contacts.Contacts.Detail.hopsAway) - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(currentContact.pathHopCount, format: .number) - .font(.caption.monospaced()) - .foregroundStyle(.primary) - } - } icon: { - Image(systemName: "arrowshape.bounce.right") - .foregroundStyle(.secondary) - } - } - - // Path Discovery button (prominent) - if pathViewModel.isDiscovering { - VStack(alignment: .leading, spacing: 4) { - HStack { - Label(L10n.Contacts.Contacts.Detail.discoveringPath, systemImage: "antenna.radiowaves.left.and.right") - Spacer() - ProgressView() - Button(L10n.Contacts.Contacts.Common.cancel) { - pathViewModel.cancelDiscovery() - } - .buttonStyle(.borderless) - .font(.subheadline) - } - - if let remaining = pathViewModel.discoverySecondsRemaining, remaining > 0 { - Text(L10n.Contacts.Contacts.Detail.secondsRemaining(remaining)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } else { - Button { - Task { - await pathViewModel.discoverPath(for: currentContact) - } - } label: { - Label(L10n.Contacts.Contacts.Detail.discoverPath, systemImage: "antenna.radiowaves.left.and.right") - } - .radioDisabled(for: appState.connectionState) - } - - // Edit Path button (secondary) - Button { - Task { - await pathViewModel.loadContacts(radioID: currentContact.radioID) - pathViewModel.initializeEditablePath(from: currentContact) - pathViewModel.showingPathEditor = true - } - } label: { - Label(L10n.Contacts.Contacts.Detail.editPath, systemImage: "pencil") - } - .radioDisabled(for: appState.connectionState) - - // Reset Path button (destructive, disabled when already flood) - Button(role: .destructive) { - Task { - await pathViewModel.resetPath(for: currentContact) - } - } label: { - HStack { - Label(L10n.Contacts.Contacts.Detail.resetPath, systemImage: "arrow.triangle.2.circlepath") - if pathViewModel.isSettingPath { - Spacer() - ProgressView() - .scaleEffect(0.8) - } - } + } icon: { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(pathAccessibilityLabel(pathDisplay: pathDisplay)) + .copyRouteContextMenu(route: routeIDPrefixes, enabled: isRoutePopulated) + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.copyRoute) { + if isRoutePopulated { UIPasteboard.general.string = routeIDPrefixes } + } + + // Hops away: only when a deliberate or discovered out-path exists, not the passively + // heard inbound advert hops + if !currentContact.isFloodRouted { + Label { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Contacts.Contacts.Detail.hopsAway) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(currentContact.pathHopCount, format: .number) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + } + } icon: { + Image(systemName: "arrowshape.bounce.right") + .foregroundStyle(.secondary) + } + } + + // Path Discovery button (prominent) + if pathViewModel.isDiscovering { + VStack(alignment: .leading, spacing: 4) { + HStack { + Label(L10n.Contacts.Contacts.Detail.discoveringPath, systemImage: "antenna.radiowaves.left.and.right") + Spacer() + ProgressView() + Button(L10n.Contacts.Contacts.Common.cancel) { + pathViewModel.cancelDiscovery() } - .radioDisabled(for: appState.connectionState, or: pathViewModel.isSettingPath || currentContact.isFloodRouted) - } header: { - Text(L10n.Contacts.Contacts.Detail.outboundPath) - } footer: { - Text(networkPathFooterText) + .buttonStyle(.borderless) + .font(.subheadline) + } + + if let remaining = pathViewModel.discoverySecondsRemaining, remaining > 0 { + Text(L10n.Contacts.Contacts.Detail.secondsRemaining(remaining)) + .font(.caption) + .foregroundStyle(.secondary) + } } + } else { + Button { + Task { + await pathViewModel.discoverPath(for: currentContact) + } + } label: { + Label(L10n.Contacts.Contacts.Detail.discoverPath, systemImage: "antenna.radiowaves.left.and.right") + } + .radioDisabled(for: appState.connectionState) + } + + // Edit Path button (secondary) + Button { + Task { + await pathViewModel.loadContacts(radioID: currentContact.radioID) + pathViewModel.initializeEditablePath(from: currentContact) + pathViewModel.showingPathEditor = true + } + } label: { + Label(L10n.Contacts.Contacts.Detail.editPath, systemImage: "pencil") + } + .radioDisabled(for: appState.connectionState) + + // Reset Path button (destructive, disabled when already flood) + Button(role: .destructive) { + Task { + await pathViewModel.resetPath(for: currentContact) + } + } label: { + HStack { + Label(L10n.Contacts.Contacts.Detail.resetPath, systemImage: "arrow.triangle.2.circlepath") + if pathViewModel.isSettingPath { + Spacer() + ProgressView() + .scaleEffect(0.8) + } + } + } + .radioDisabled(for: appState.connectionState, or: pathViewModel.isSettingPath || currentContact.isFloodRouted) + } header: { + Text(L10n.Contacts.Contacts.Detail.outboundPath) + } footer: { + Text(networkPathFooterText) } + } } private struct ContactTechnicalSection: View { - let currentContact: ContactDTO - let contactTypeLabel: String - - var body: some View { - Section { - // Public key - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Contacts.Contacts.Detail.publicKey) - .font(.caption) - .foregroundStyle(.secondary) - Text(currentContact.publicKey.uppercaseHexString(separator: " ")) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - - // Contact type - HStack { - Text(L10n.Contacts.Contacts.Detail.type) - Spacer() - Text(contactTypeLabel) - .foregroundStyle(.secondary) - } - } header: { - Text(L10n.Contacts.Contacts.Detail.technical) - } + let currentContact: ContactDTO + let contactTypeLabel: String + + var body: some View { + Section { + // Public key + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Contacts.Contacts.Detail.publicKey) + .font(.caption) + .foregroundStyle(.secondary) + Text(currentContact.publicKey.uppercaseHexString(separator: " ")) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + + // Contact type + HStack { + Text(L10n.Contacts.Contacts.Detail.type) + Spacer() + Text(contactTypeLabel) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.Contacts.Contacts.Detail.technical) } + } } private struct ContactDangerSection: View { - @Environment(\.appState) private var appState - - let currentContact: ContactDTO - let contactTypeLabel: String - let isClearingMessages: Bool - let onClearMessages: () -> Void - let onToggleBlock: () -> Void - let onDelete: () -> Void - - var body: some View { - Section { - if currentContact.type == .chat { - Button(role: .destructive, action: onClearMessages) { - HStack { - Label(L10n.Contacts.Contacts.Detail.clearMessages, systemImage: "xmark.circle") - if isClearingMessages { - Spacer() - ProgressView() - } - } - } - .disabled(isClearingMessages) - - Button(action: onToggleBlock) { - Label( - currentContact.isBlocked ? L10n.Contacts.Contacts.Detail.unblockContact : L10n.Contacts.Contacts.Detail.blockContact, - systemImage: currentContact.isBlocked ? "hand.raised.slash" : "hand.raised" - ) - } - .radioDisabled(for: appState.connectionState) - } - - Button(role: .destructive, action: onDelete) { - Label(L10n.Contacts.Contacts.Detail.deleteType(contactTypeLabel), systemImage: "trash") + @Environment(\.appState) private var appState + + let currentContact: ContactDTO + let contactTypeLabel: String + let isClearingMessages: Bool + let onClearMessages: () -> Void + let onToggleBlock: () -> Void + let onDelete: () -> Void + + var body: some View { + Section { + if currentContact.type == .chat { + Button(action: onToggleBlock) { + Label( + currentContact.isBlocked ? L10n.Contacts.Contacts.Detail.unblockContact : L10n.Contacts.Contacts.Detail.blockContact, + systemImage: currentContact.isBlocked ? "hand.raised.slash" : "hand.raised" + ) + } + .radioDisabled(for: appState.connectionState) + + Button(role: .destructive, action: onClearMessages) { + HStack { + Label(L10n.Contacts.Contacts.Detail.clearMessages, systemImage: "xmark.circle") + if isClearingMessages { + Spacer() + ProgressView() } - .radioDisabled(for: appState.connectionState) - } header: { - Text(L10n.Contacts.Contacts.Detail.dangerZone) + } } + .disabled(isClearingMessages) + } + + Button(role: .destructive, action: onDelete) { + Label(L10n.Contacts.Contacts.Detail.deleteType(contactTypeLabel), systemImage: "trash") + } + .radioDisabled(for: appState.connectionState) + } header: { + Text(L10n.Contacts.Contacts.Detail.dangerZone) } + } } #Preview("Default") { - NavigationStack { - ContactDetailView(contact: ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Alice", - latitude: 37.7749, - longitude: -122.4194, - isFavorite: true - ))) - } - .environment(\.appState, AppState()) + NavigationStack { + ContactDetailView(contact: ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Alice", + latitude: 37.7749, + longitude: -122.4194, + isFavorite: true + ))) + } + .environment(\.appState, AppState()) } #Preview("From Direct Chat") { - NavigationStack { - ContactDetailView( - contact: ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Alice", - latitude: 37.7749, - longitude: -122.4194, - isFavorite: true - )), - showFromDirectChat: true - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ContactDetailView( + contact: ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Alice", + latitude: 37.7749, + longitude: -122.4194, + isFavorite: true + )), + showFromDirectChat: true + ) + } + .environment(\.appState, AppState()) } private extension View { - /// Gates the whole `contextMenu`, not the button inside it: an always-present - /// menu with an empty body still triggers the press-and-hold lift with no items. - @ViewBuilder - func copyRouteContextMenu(route: String, enabled: Bool) -> some View { - if enabled { - contextMenu { - Button { - UIPasteboard.general.string = route - } label: { - Label(L10n.Contacts.Contacts.Detail.copyRoute, systemImage: "doc.on.doc") - } - } - } else { - self + /// Gates the whole `contextMenu`, not the button inside it: an always-present + /// menu with an empty body still triggers the press-and-hold lift with no items. + @ViewBuilder + func copyRouteContextMenu(route: String, enabled: Bool) -> some View { + if enabled { + contextMenu { + Button { + UIPasteboard.general.string = route + } label: { + Label(L10n.Contacts.Contacts.Detail.copyRoute, systemImage: "doc.on.doc") } + } + } else { + self } + } } diff --git a/MC1/Views/Contacts/ContactListActions.swift b/MC1/Views/Contacts/ContactListActions.swift index ca832b74..3795b1f9 100644 --- a/MC1/Views/Contacts/ContactListActions.swift +++ b/MC1/Views/Contacts/ContactListActions.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Layout-independent Nodes-list derived state and actions shared by the compact `ContactsListView` /// (stack) and the iPad `ContactsContentColumn` (split). Both compute the same filtered list and run @@ -7,52 +7,52 @@ import MC1Services /// fresh per body evaluation; `syncSuccessTrigger` points at each view's own `@State`. @MainActor struct ContactListActions { - let viewModel: ContactsViewModel - let appState: AppState - let syncSuccessTrigger: Binding - - /// Filters and sorts contacts, falling back to lastHeard sort when distance is selected but no - /// location is available. - func filteredContacts(searchText: String, segment: NodeSegment, sortOrder: NodeSortOrder) -> [ContactDTO] { - let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) - ? .lastHeard - : sortOrder - - return viewModel.filteredContacts( - searchText: searchText, - segment: segment, - sortOrder: effectiveSortOrder, - userLocation: appState.bestAvailableLocation - ) - } - - var searchPrompt: String { - let count = viewModel.contacts.count - return count > 0 - ? L10n.Contacts.Contacts.List.searchPromptWithCount(count) - : L10n.Contacts.Contacts.List.searchPrompt - } - - func loadContacts() async { - guard let deviceID = appState.currentRadioID else { return } - viewModel.configure( - dataStore: { [appState] in appState.offlineDataStore }, - contactService: { [appState] in appState.services?.contactService }, - advertisementService: { [appState] in appState.services?.advertisementService } - ) - await viewModel.loadContacts(radioID: deviceID) - } - - func syncContacts() async { - guard let deviceID = appState.currentRadioID else { return } - await viewModel.syncContacts(radioID: deviceID) - syncSuccessTrigger.wrappedValue.toggle() - } - - func announceOfflineStateIfNeeded() { - guard appState.connectionState == .disconnected, - appState.currentRadioID != nil else { return } - - AccessibilityNotification.Announcement(L10n.Contacts.Contacts.List.offlineAnnouncement).post() - } + let viewModel: ContactsViewModel + let appState: AppState + let syncSuccessTrigger: Binding + + /// Filters and sorts contacts, falling back to lastHeard sort when distance is selected but no + /// location is available. + func filteredContacts(searchText: String, segment: NodeSegment, sortOrder: NodeSortOrder) -> [ContactDTO] { + let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) + ? .lastHeard + : sortOrder + + return viewModel.filteredContacts( + searchText: searchText, + segment: segment, + sortOrder: effectiveSortOrder, + userLocation: appState.bestAvailableLocation + ) + } + + var searchPrompt: String { + let count = viewModel.contacts.count + return count > 0 + ? L10n.Contacts.Contacts.List.searchPromptWithCount(count) + : L10n.Contacts.Contacts.List.searchPrompt + } + + func loadContacts() async { + guard let deviceID = appState.currentRadioID else { return } + viewModel.configure( + dataStore: { [appState] in appState.offlineDataStore }, + contactService: { [appState] in appState.services?.contactService }, + advertisementService: { [appState] in appState.services?.advertisementService } + ) + await viewModel.loadContacts(radioID: deviceID) + } + + func syncContacts() async { + guard let deviceID = appState.currentRadioID else { return } + await viewModel.syncContacts(radioID: deviceID) + syncSuccessTrigger.wrappedValue.toggle() + } + + func announceOfflineStateIfNeeded() { + guard appState.connectionState == .disconnected, + appState.currentRadioID != nil else { return } + + AccessibilityNotification.Announcement(L10n.Contacts.Contacts.List.offlineAnnouncement).post() + } } diff --git a/MC1/Views/Contacts/ContactQRShareSheet.swift b/MC1/Views/Contacts/ContactQRShareSheet.swift index df437d1a..b82bee12 100644 --- a/MC1/Views/Contacts/ContactQRShareSheet.swift +++ b/MC1/Views/Contacts/ContactQRShareSheet.swift @@ -1,200 +1,190 @@ -import SwiftUI import MC1Services -import CoreImage.CIFilterBuiltins +import SwiftUI /// Sheet for sharing a contact via QR code struct ContactQRShareSheet: View { - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - let contactName: String - let publicKey: Data - let contactType: ContactType - - @State private var showCopyFeedback = false - @State private var qrImage: UIImage? - @State private var copyHapticTrigger = 0 - - private var contactURI: String { - ContactService.exportContactURI(name: contactName, publicKey: publicKey, type: contactType) - } - - var body: some View { - NavigationStack { - Form { - // QR Code Section - QRCodeSection(contactName: contactName, qrImage: qrImage) - .themedRowBackground(theme) - - // Contact Info Section - ContactInfoSection(publicKey: publicKey) - .themedRowBackground(theme) - - // Actions Section - ActionsSection( - qrImage: qrImage, - shareText: shareText, - contactName: contactName, - showCopyFeedback: $showCopyFeedback, - copyToClipboard: copyToClipboard - ) - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.Qr.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Contacts.Contacts.Common.done) { - dismiss() - } - } - } - .onAppear { - qrImage = generateQRCode() - } - .sensoryFeedback(.success, trigger: copyHapticTrigger) + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + + let contactName: String + let publicKey: Data + let contactType: ContactType + + @State private var showCopyFeedback = false + @State private var qrImage: UIImage? + @State private var copyHapticTrigger = 0 + + private var contactURI: String { + ContactService.exportContactURI(name: contactName, publicKey: publicKey, type: contactType) + } + + var body: some View { + NavigationStack { + Form { + // QR Code Section + QRCodeSection(contactName: contactName, qrImage: qrImage) + .themedRowBackground(theme) + + // Contact Info Section + ContactInfoSection(publicKey: publicKey) + .themedRowBackground(theme) + + // Actions Section + ActionsSection( + qrImage: qrImage, + shareText: shareText, + contactName: contactName, + showCopyFeedback: $showCopyFeedback, + copyToClipboard: copyToClipboard + ) + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.Qr.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Common.done) { + dismiss() + } } + } + .onAppear { + qrImage = generateQRCode() + } + .sensoryFeedback(.success, trigger: copyHapticTrigger) } - - // MARK: - Private Methods - - private func generateQRCode() -> UIImage? { - let context = CIContext() - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(contactURI.utf8) - filter.correctionLevel = Constants.qrCorrectionLevel - - guard let outputImage = filter.outputImage else { return nil } - - // Scale up for better quality - let transform = CGAffineTransform(scaleX: Constants.qrScale, y: Constants.qrScale) - let scaledImage = outputImage.transformed(by: transform) - - guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } - - return UIImage(cgImage: cgImage) - } - - private var shareText: String { - """ - \(L10n.Contacts.Contacts.Share.contactLabel(contactName)) - \(L10n.Contacts.Contacts.Share.keyLabel(publicKey.hexString)) - \(contactURI) - """ - } - - private func copyToClipboard() { - copyHapticTrigger += 1 - UIPasteboard.general.string = publicKey.hexString - showCopyFeedback = true - - Task { - try? await Task.sleep(for: Constants.copyFeedbackDuration) - showCopyFeedback = false - } + .presentationBackground(theme.surfaces?.canvas ?? Color(.systemGroupedBackground)) + } + + // MARK: - Private Methods + + private func generateQRCode() -> UIImage? { + QRCodeGenerator.generate( + from: contactURI, scale: Constants.qrScale, correctionLevel: Constants.qrCorrectionLevel + ) + } + + private var shareText: String { + """ + \(L10n.Contacts.Contacts.Share.contactLabel(contactName)) + \(L10n.Contacts.Contacts.Share.keyLabel(publicKey.hexString)) + \(contactURI) + """ + } + + private func copyToClipboard() { + copyHapticTrigger += 1 + UIPasteboard.general.string = publicKey.hexString + showCopyFeedback = true + + Task { + try? await Task.sleep(for: Constants.copyFeedbackDuration) + showCopyFeedback = false } + } } // MARK: - Constants private enum Constants { - static let qrScale = 10.0 - static let qrCorrectionLevel = "M" - static let copyFeedbackDuration = Duration.seconds(2) - static let qrCodeSize: CGFloat = 200 + static let qrScale = 10.0 + static let qrCorrectionLevel = "M" + static let copyFeedbackDuration = Duration.seconds(2) + static let qrCodeSize: CGFloat = 200 } // MARK: - QR Code Section private struct QRCodeSection: View { - let contactName: String - let qrImage: UIImage? - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 12) { - if let qrImage { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .scaledToFit() - .frame(width: Constants.qrCodeSize, height: Constants.qrCodeSize) - } - - Text(contactName) - .font(.title2) - .bold() - } - Spacer() - } + let contactName: String + let qrImage: UIImage? + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 12) { + if let qrImage { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .foregroundStyle(.primary) + .frame(width: Constants.qrCodeSize, height: Constants.qrCodeSize) + } + + Text(contactName) + .font(.title2) + .bold() } + Spacer() + } } + } } // MARK: - Contact Info Section private struct ContactInfoSection: View { - let publicKey: Data - - var body: some View { - Section { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Contacts.Contacts.Add.publicKey) - .font(.caption) - .foregroundStyle(.secondary) - - Text(publicKey.uppercaseHexString(separator: " ")) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } - } + let publicKey: Data + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Contacts.Contacts.Add.publicKey) + .font(.caption) + .foregroundStyle(.secondary) + + Text(publicKey.uppercaseHexString(separator: " ")) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } } + } } // MARK: - Actions Section private struct ActionsSection: View { - let qrImage: UIImage? - let shareText: String - let contactName: String - @Binding var showCopyFeedback: Bool - let copyToClipboard: () -> Void - - var body: some View { - Section { - Button { - copyToClipboard() - } label: { - HStack { - Spacer() - Label( - showCopyFeedback - ? L10n.Contacts.Contacts.Qr.copied - : L10n.Contacts.Contacts.Qr.copy, - systemImage: "doc.on.doc" - ) - Spacer() - } - } - .disabled(showCopyFeedback) - .alignmentGuide(.listRowSeparatorLeading) { dimensions in dimensions[.leading] } - - if let qrImage { - ShareLink( - item: shareText, - subject: Text(L10n.Contacts.Contacts.Qr.shareSubject), - preview: SharePreview(contactName, image: Image(uiImage: qrImage)) - ) { - HStack { - Spacer() - Label(L10n.Contacts.Contacts.Qr.share, systemImage: "square.and.arrow.up") - Spacer() - } - } - } + let qrImage: UIImage? + let shareText: String + let contactName: String + @Binding var showCopyFeedback: Bool + let copyToClipboard: () -> Void + + var body: some View { + Section { + Button { + copyToClipboard() + } label: { + HStack { + Spacer() + Label( + showCopyFeedback + ? L10n.Contacts.Contacts.Qr.copied + : L10n.Contacts.Contacts.Qr.copy, + systemImage: "doc.on.doc" + ) + Spacer() + } + } + .disabled(showCopyFeedback) + .alignmentGuide(.listRowSeparatorLeading) { dimensions in dimensions[.leading] } + + if let qrImage { + ShareLink( + item: shareText, + subject: Text(L10n.Contacts.Contacts.Qr.shareSubject), + preview: SharePreview(contactName, image: Image(uiImage: qrImage)) + ) { + HStack { + Spacer() + Label(L10n.Contacts.Contacts.Qr.share, systemImage: "square.and.arrow.up") + Spacer() + } } + } } + } } diff --git a/MC1/Views/Contacts/ContactRoute.swift b/MC1/Views/Contacts/ContactRoute.swift index 428512cb..e84661bd 100644 --- a/MC1/Views/Contacts/ContactRoute.swift +++ b/MC1/Views/Contacts/ContactRoute.swift @@ -6,45 +6,45 @@ import MC1Services /// Equality and hashing use the contact's stable id, so path identity survives row updates /// while the carried payload still lets the destination build before the list has loaded. enum ContactRoute: Hashable { - case detail(ContactDTO) - case blockedContacts + case detail(ContactDTO) + case blockedContacts - /// Telemetry history push destination. `ContactDetailView` registers and pushes this - /// itself because it is hosted by three different stacks (Contacts stack, iPad detail - /// column, chat info sheet), so the destination must travel with the view. - struct TelemetryHistory: Hashable { - let publicKey: Data - let radioID: UUID - var showNeighbors = true - } + /// Telemetry history push destination. `ContactDetailView` registers and pushes this + /// itself because it is hosted by three different stacks (Contacts stack, iPad detail + /// column, chat info sheet), so the destination must travel with the view. + struct TelemetryHistory: Hashable { + let publicKey: Data + let radioID: UUID + var showNeighbors = true + } - private enum Kind: UInt8, Hashable { - case detail - case blockedContacts - } + private enum Kind: UInt8, Hashable { + case detail + case blockedContacts + } - private var kind: Kind { - switch self { - case .detail: .detail - case .blockedContacts: .blockedContacts - } + private var kind: Kind { + switch self { + case .detail: .detail + case .blockedContacts: .blockedContacts } + } - static func == (lhs: ContactRoute, rhs: ContactRoute) -> Bool { - switch (lhs, rhs) { - case (.detail(let lhsContact), .detail(let rhsContact)): - lhsContact.id == rhsContact.id - case (.blockedContacts, .blockedContacts): - true - default: - false - } + static func == (lhs: ContactRoute, rhs: ContactRoute) -> Bool { + switch (lhs, rhs) { + case let (.detail(lhsContact), .detail(rhsContact)): + lhsContact.id == rhsContact.id + case (.blockedContacts, .blockedContacts): + true + default: + false } + } - func hash(into hasher: inout Hasher) { - hasher.combine(kind) - if case .detail(let contact) = self { - hasher.combine(contact.id) - } + func hash(into hasher: inout Hasher) { + hasher.combine(kind) + if case let .detail(contact) = self { + hasher.combine(contact.id) } + } } diff --git a/MC1/Views/Contacts/ContactRowView.swift b/MC1/Views/Contacts/ContactRowView.swift index 9d8881c0..ffc628cc 100644 --- a/MC1/Views/Contacts/ContactRowView.swift +++ b/MC1/Views/Contacts/ContactRowView.swift @@ -1,146 +1,146 @@ -import SwiftUI import CoreLocation import MC1Services +import SwiftUI struct ContactRowView: View { - @Environment(\.appState) private var appState - let contact: ContactDTO - let showTypeLabel: Bool - let userLocation: CLLocation? - let isTogglingFavorite: Bool - let inboundHopCount: Int? - - init( - contact: ContactDTO, - showTypeLabel: Bool = false, - userLocation: CLLocation? = nil, - isTogglingFavorite: Bool = false, - inboundHopCount: Int? = nil - ) { - self.contact = contact - self.showTypeLabel = showTypeLabel - self.userLocation = userLocation - self.isTogglingFavorite = isTogglingFavorite - self.inboundHopCount = inboundHopCount - } - - var body: some View { - HStack(spacing: 12) { - avatarView - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - (Text(idPrefixHex) - .monospaced() - .foregroundStyle(.secondary) - + Text(" \(contact.displayName)") - .fontWeight(.medium)) - .font(.body) - .accessibilityLabel(contact.displayName) - - if contact.isBlocked { - Image(systemName: "hand.raised.fill") - .font(.caption) - .foregroundStyle(.orange) - .accessibilityLabel(L10n.Contacts.Contacts.Row.blocked) - } - - Spacer() - - if isTogglingFavorite { - ProgressView() - .controlSize(.small) - } else if contact.isFavorite { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(.yellow) - .accessibilityLabel(L10n.Contacts.Contacts.Row.favorite) - } - - RelativeTimestampText(timestamp: contact.lastModified) - } - - HStack(spacing: 8) { - // Show type label only in search results - if showTypeLabel { - Text(contactTypeLabel) - .font(.caption) - .foregroundStyle(.secondary) - - Text("\u{00B7}") - .font(.caption) - .foregroundStyle(.secondary) - } - - // Route indicator - Text(routeLabel) - .font(.caption) - .foregroundStyle(.secondary) - - // Location indicator with optional distance - if contact.hasLocation { - Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") - .labelStyle(.iconOnly) - .font(.caption) - .foregroundStyle(.green) - - if let distance = distanceToContact { - Text(distance) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } + @Environment(\.appState) private var appState + let contact: ContactDTO + let showTypeLabel: Bool + let userLocation: CLLocation? + let isTogglingFavorite: Bool + let inboundHopCount: Int? + + init( + contact: ContactDTO, + showTypeLabel: Bool = false, + userLocation: CLLocation? = nil, + isTogglingFavorite: Bool = false, + inboundHopCount: Int? = nil + ) { + self.contact = contact + self.showTypeLabel = showTypeLabel + self.userLocation = userLocation + self.isTogglingFavorite = isTogglingFavorite + self.inboundHopCount = inboundHopCount + } + + var body: some View { + HStack(spacing: 12) { + avatarView + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + (Text(idPrefixHex) + .monospaced() + .foregroundStyle(.secondary) + + Text(" \(contact.displayName)") + .fontWeight(.medium)) + .font(.body) + .accessibilityLabel(contact.displayName) + + if contact.isBlocked { + Image(systemName: "hand.raised.fill") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityLabel(L10n.Contacts.Contacts.Row.blocked) + } + + Spacer() + + if isTogglingFavorite { + ProgressView() + .controlSize(.small) + } else if contact.isFavorite { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(.yellow) + .accessibilityLabel(L10n.Contacts.Contacts.Row.favorite) + } + + RelativeTimestampText(timestamp: contact.lastModified) } - .padding(.vertical, 4) - } - @ViewBuilder - private var avatarView: some View { - switch contact.type { - case .chat: - ContactAvatar(contact: contact, size: 44) - case .repeater: - NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 44) - case .room: - NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 44) + HStack(spacing: 8) { + // Show type label only in search results + if showTypeLabel { + Text(contactTypeLabel) + .font(.caption) + .foregroundStyle(.secondary) + + Text("\u{00B7}") + .font(.caption) + .foregroundStyle(.secondary) + } + + // Route indicator + Text(routeLabel) + .font(.caption) + .foregroundStyle(.secondary) + + // Location indicator with optional distance + if contact.hasLocation { + Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") + .labelStyle(.iconOnly) + .font(.caption) + .foregroundStyle(.green) + + if let distance = distanceToContact { + Text(distance) + .font(.caption) + .foregroundStyle(.secondary) + } + } } + } } - - private var idPrefixHex: String { - let hashSize = appState.connectedDevice?.hashSize ?? 1 - return contact.publicKey.prefix(hashSize).uppercaseHexString() - } - - private var contactTypeLabel: String { - contact.type.localizedName + .padding(.vertical, 4) + } + + @ViewBuilder + private var avatarView: some View { + switch contact.type { + case .chat: + ContactAvatar(contact: contact, size: 44) + case .repeater: + NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 44) + case .room: + NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 44) } - - private var routeLabel: String { - if !contact.isFloodRouted, contact.pathHopCount == 0 { - return L10n.Contacts.Contacts.Route.direct - } else if let hops = contact.displayedHopCount(inboundHopCount: inboundHopCount) { - return L10n.Contacts.Contacts.Route.hops(hops) - } else { - return L10n.Contacts.Contacts.Route.flood - } - } - - private var distanceToContact: String? { - guard let userLocation, contact.hasLocation else { return nil } - - let contactLocation = CLLocation( - latitude: contact.latitude, - longitude: contact.longitude - ) - let meters = userLocation.distance(from: contactLocation) - let measurement = Measurement(value: meters, unit: UnitLength.meters) - - let formattedDistance = measurement.formatted(.measurement( - width: .abbreviated, - usage: .road - )) - return L10n.Contacts.Contacts.Row.away(formattedDistance) + } + + private var idPrefixHex: String { + let hashSize = appState.connectedDevice?.hashSize ?? 1 + return contact.publicKey.prefix(hashSize).uppercaseHexString() + } + + private var contactTypeLabel: String { + contact.type.localizedName + } + + private var routeLabel: String { + if !contact.isFloodRouted, contact.pathHopCount == 0 { + L10n.Contacts.Contacts.Route.direct + } else if let hops = contact.displayedHopCount(inboundHopCount: inboundHopCount) { + L10n.Contacts.Contacts.Route.hops(hops) + } else { + L10n.Contacts.Contacts.Route.flood } + } + + private var distanceToContact: String? { + guard let userLocation, contact.hasLocation else { return nil } + + let contactLocation = CLLocation( + latitude: contact.latitude, + longitude: contact.longitude + ) + let meters = userLocation.distance(from: contactLocation) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + + let formattedDistance = measurement.formatted(.measurement( + width: .abbreviated, + usage: .road + )) + return L10n.Contacts.Contacts.Row.away(formattedDistance) + } } diff --git a/MC1/Views/Contacts/ContactsContentColumn.swift b/MC1/Views/Contacts/ContactsContentColumn.swift index 3eecc458..9ebe3912 100644 --- a/MC1/Views/Contacts/ContactsContentColumn.swift +++ b/MC1/Views/Contacts/ContactsContentColumn.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// The iPad sidebar's Nodes content column. It mirrors the regular-width (split) path of /// `ContactsListView`, hosting `ContactsSidebarContent` with the same toolbar, searchable, @@ -11,58 +11,58 @@ import MC1Services /// deep link (e.g. a notification tap) the first time Nodes is entered, and this view is /// instantiated whenever Nodes is selected. struct ContactsContentColumn: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var viewModel = ContactsViewModel() - @State private var searchText = "" - @State private var selectedSegment: NodeSegment = .contacts - @AppStorage(AppStorageKey.nodesSortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard - @State private var syncSuccessTrigger = false - @State private var showShareMyContact = false - @State private var showAddContact = false - @State private var showLocationDeniedAlert = false - @State private var showOfflineRefreshAlert = false + @State private var viewModel = ContactsViewModel() + @State private var searchText = "" + @State private var selectedSegment: NodeSegment = .contacts + @AppStorage(AppStorageKey.nodesSortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard + @State private var syncSuccessTrigger = false + @State private var showShareMyContact = false + @State private var showAddContact = false + @State private var showLocationDeniedAlert = false + @State private var showOfflineRefreshAlert = false - private var actions: ContactListActions { - ContactListActions(viewModel: viewModel, appState: appState, syncSuccessTrigger: $syncSuccessTrigger) - } + private var actions: ContactListActions { + ContactListActions(viewModel: viewModel, appState: appState, syncSuccessTrigger: $syncSuccessTrigger) + } - var body: some View { - @Bindable var navigation = appState.navigation + var body: some View { + @Bindable var navigation = appState.navigation - ContactsSidebarContent( - viewModel: viewModel, - filteredContacts: actions.filteredContacts(searchText: searchText, segment: selectedSegment, sortOrder: sortOrder), - isSearching: !searchText.isEmpty, - searchPrompt: actions.searchPrompt, - shouldUseSplitView: true, - selectedSegment: $selectedSegment, - selectedContact: $navigation.selectedContact, - searchText: $searchText, - sortOrder: $sortOrder, - showDiscovery: $navigation.nodesShowingDiscovery, - syncSuccessTrigger: $syncSuccessTrigger, - showShareMyContact: $showShareMyContact, - showAddContact: $showAddContact, - showLocationDeniedAlert: $showLocationDeniedAlert, - showOfflineRefreshAlert: $showOfflineRefreshAlert, - // Compact-only navigation, unused on the split path which drives selection via selectedContact. - navigationPath: .constant(NavigationPath()), - onLoadContacts: actions.loadContacts, - onSyncContacts: actions.syncContacts, - onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded - ) - .onChange(of: appState.navigation.selectedContact) { _, newContact in - if newContact != nil { - appState.navigation.nodesShowingDiscovery = false - } - } + ContactsSidebarContent( + viewModel: viewModel, + filteredContacts: actions.filteredContacts(searchText: searchText, segment: selectedSegment, sortOrder: sortOrder), + isSearching: !searchText.isEmpty, + searchPrompt: actions.searchPrompt, + shouldUseSplitView: true, + selectedSegment: $selectedSegment, + selectedContact: $navigation.selectedContact, + searchText: $searchText, + sortOrder: $sortOrder, + showDiscovery: $navigation.nodesShowingDiscovery, + syncSuccessTrigger: $syncSuccessTrigger, + showShareMyContact: $showShareMyContact, + showAddContact: $showAddContact, + showLocationDeniedAlert: $showLocationDeniedAlert, + showOfflineRefreshAlert: $showOfflineRefreshAlert, + // Compact-only navigation, unused on the split path which drives selection via selectedContact. + navigationPath: .constant(NavigationPath()), + onLoadContacts: actions.loadContacts, + onSyncContacts: actions.syncContacts, + onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded + ) + .onChange(of: appState.navigation.selectedContact) { _, newContact in + if newContact != nil { + appState.navigation.nodesShowingDiscovery = false + } } + } } #Preview { - NavigationStack { - ContactsContentColumn() - } - .environment(\.appState, AppState()) + NavigationStack { + ContactsContentColumn() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/ContactsDetailColumn.swift b/MC1/Views/Contacts/ContactsDetailColumn.swift index 4057ab6e..750ccd3d 100644 --- a/MC1/Views/Contacts/ContactsDetailColumn.swift +++ b/MC1/Views/Contacts/ContactsDetailColumn.swift @@ -1,28 +1,28 @@ -import SwiftUI import MC1Services +import SwiftUI /// The iPad sidebar's Nodes detail column. It reproduces the regular-width (split) detail /// branch of `ContactsListView`: Discovery wins when active, otherwise the selected contact, /// otherwise an empty placeholder. Selection and discovery state are read from /// `appState.navigation` so this column stays in sync with `ContactsContentColumn`. struct ContactsDetailColumn: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - var body: some View { - if appState.navigation.nodesShowingDiscovery { - DiscoveryView() - } else if let selectedContact = appState.navigation.selectedContact { - ContactDetailView(contact: selectedContact) - .id(selectedContact.id) - } else { - ContentUnavailableView(L10n.Contacts.Contacts.List.selectNode, systemImage: "flipphone") - } + var body: some View { + if appState.navigation.nodesShowingDiscovery { + DiscoveryView() + } else if let selectedContact = appState.navigation.selectedContact { + ContactDetailView(contact: selectedContact) + .id(selectedContact.id) + } else { + ContentUnavailableView(L10n.Contacts.Contacts.List.selectNode, systemImage: "flipphone") } + } } #Preview { - NavigationStack { - ContactsDetailColumn() - } - .environment(\.appState, AppState()) + NavigationStack { + ContactsDetailColumn() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/ContactsEmptyView.swift b/MC1/Views/Contacts/ContactsEmptyView.swift index 31d29aab..7ae607b4 100644 --- a/MC1/Views/Contacts/ContactsEmptyView.swift +++ b/MC1/Views/Contacts/ContactsEmptyView.swift @@ -1,34 +1,34 @@ import SwiftUI struct ContactsEmptyView: View { - let selectedSegment: NodeSegment + let selectedSegment: NodeSegment - var body: some View { - switch selectedSegment { - case .favorites: - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Favorites.title, - systemImage: "star", - description: Text(L10n.Contacts.Contacts.List.Empty.Favorites.description) - ) - case .contacts: - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Contacts.title, - systemImage: "person.2", - description: Text(L10n.Contacts.Contacts.List.Empty.Contacts.description) - ) - case .repeaters: - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Repeaters.title, - systemImage: "antenna.radiowaves.left.and.right", - description: Text(L10n.Contacts.Contacts.List.Empty.Repeaters.description) - ) - case .rooms: - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Rooms.title, - systemImage: "door.left.hand.open", - description: Text(L10n.Contacts.Contacts.List.Empty.Rooms.description) - ) - } + var body: some View { + switch selectedSegment { + case .favorites: + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Favorites.title, + systemImage: "star", + description: Text(L10n.Contacts.Contacts.List.Empty.Favorites.description) + ) + case .contacts: + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Contacts.title, + systemImage: "person.2", + description: Text(L10n.Contacts.Contacts.List.Empty.Contacts.description) + ) + case .repeaters: + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Repeaters.title, + systemImage: "antenna.radiowaves.left.and.right", + description: Text(L10n.Contacts.Contacts.List.Empty.Repeaters.description) + ) + case .rooms: + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Rooms.title, + systemImage: "door.left.hand.open", + description: Text(L10n.Contacts.Contacts.List.Empty.Rooms.description) + ) } + } } diff --git a/MC1/Views/Contacts/ContactsListContent.swift b/MC1/Views/Contacts/ContactsListContent.swift index 51942813..ffca9fb7 100644 --- a/MC1/Views/Contacts/ContactsListContent.swift +++ b/MC1/Views/Contacts/ContactsListContent.swift @@ -1,6 +1,6 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// The nodes list rendered as a `ScrollView` + `LazyVStack` rather than a `List`. `List` is backed /// by `UpdateCoalescingCollectionView`, whose batch-consistency assertion is violated when the @@ -8,181 +8,184 @@ import MC1Services /// actions live in a `.contextMenu`. One view serves both layouts: the compact stack navigates via /// `NavigationLink`, the iPad split drives a selection binding the detail column reads. struct ContactsListContent: View { - enum ListMode { - case selection(Binding) - case navigation + enum ListMode { + case selection(Binding) + case navigation + } + + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + let mode: ListMode + @Binding var selectedSegment: NodeSegment + let isSearching: Bool + let searchText: String + let filteredContacts: [ContactDTO] + let hasLoadedOnce: Bool + let viewModel: ContactsViewModel + + /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. + private static let rowSeparatorLeadingInset: CGFloat = 72 + + var body: some View { + Group { + if !hasLoadedOnce { + loadingBody + } else { + loadedBody + } } - - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let mode: ListMode - @Binding var selectedSegment: NodeSegment - let isSearching: Bool - let searchText: String - let filteredContacts: [ContactDTO] - let hasLoadedOnce: Bool - let viewModel: ContactsViewModel - - /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. - private static let rowSeparatorLeadingInset: CGFloat = 72 - - var body: some View { - Group { - if !hasLoadedOnce { - loadingBody - } else { - loadedBody - } - } - .themedCanvas(theme) + .themedCanvas(theme) + } + + private var loadingBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section {} header: { pinnedSegmentHeader } + } } - - private var loadingBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { } header: { pinnedSegmentHeader } - } + .overlay { ProgressView() } + } + + private var loadedBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + if filteredContacts.isEmpty { + emptyState + } else { + rows + } + } header: { + pinnedSegmentHeader } - .overlay { ProgressView() } + } } - - private var loadedBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - if filteredContacts.isEmpty { - emptyState - } else { - rows - } - } header: { - pinnedSegmentHeader - } - } - } + } + + /// Segment picker as the pinned section header; `pinnedFilterHeaderBackground` documents the + /// per-OS backing. + private var pinnedSegmentHeader: some View { + NodeSegmentPicker(selection: $selectedSegment, isSearching: isSearching) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } + + private var emptyState: some View { + Group { + if isSearching { + ContactsSearchEmptyView(searchText: searchText) + } else { + ContactsEmptyView(selectedSegment: selectedSegment) + } } - - /// Segment picker as the pinned section header; `pinnedFilterHeaderBackground` documents the - /// per-OS backing. - private var pinnedSegmentHeader: some View { - NodeSegmentPicker(selection: $selectedSegment, isSearching: isSearching) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) + .containerRelativeFrame([.horizontal, .vertical]) + } + + private var rows: some View { + ForEach(Array(filteredContacts.enumerated()), id: \.element.id) { index, contact in + rowView(contact) + .transition(.opacity) + if index < filteredContacts.count - 1 { + Divider().padding(.leading, Self.rowSeparatorLeadingInset) + } } - - @ViewBuilder - private var emptyState: some View { - Group { - if isSearching { - ContactsSearchEmptyView(searchText: searchText) - } else { - ContactsEmptyView(selectedSegment: selectedSegment) - } - } - .containerRelativeFrame([.horizontal, .vertical]) - } - - private var rows: some View { - ForEach(Array(filteredContacts.enumerated()), id: \.element.id) { index, contact in - rowView(contact) - .transition(.opacity) - if index < filteredContacts.count - 1 { - Divider().padding(.leading, Self.rowSeparatorLeadingInset) - } - } - } - - @ViewBuilder - private func rowView(_ contact: ContactDTO) -> some View { - switch mode { - case .selection(let selection): - ContactSelectionRow( - contact: contact, - viewModel: viewModel, - isSearching: isSearching, - userLocation: appState.bestAvailableLocation, - isSelected: selection.wrappedValue?.id == contact.id, - onSelect: { selection.wrappedValue = contact } - ) - case .navigation: - ContactNavigationRow( - contact: contact, - viewModel: viewModel, - isSearching: isSearching, - userLocation: appState.bestAvailableLocation - ) - } + } + + @ViewBuilder + private func rowView(_ contact: ContactDTO) -> some View { + switch mode { + case let .selection(selection): + ContactSelectionRow( + contact: contact, + viewModel: viewModel, + isSearching: isSearching, + userLocation: appState.bestAvailableLocation, + isSelected: selection.wrappedValue?.id == contact.id, + onSelect: { selection.wrappedValue = contact } + ) + case .navigation: + ContactNavigationRow( + contact: contact, + viewModel: viewModel, + isSearching: isSearching, + userLocation: appState.bestAvailableLocation + ) } + } } // MARK: - Row Layout private enum ContactRowLayout { - static let horizontalPadding: CGFloat = 16 - static let verticalPadding: CGFloat = 6 + static let horizontalPadding: CGFloat = 16 + static let verticalPadding: CGFloat = 6 } /// Renders a node's row body, shared by the selection and navigation rows. private struct ContactListRowLabel: View { - let contact: ContactDTO - let viewModel: ContactsViewModel - let isSearching: Bool - let userLocation: CLLocation? - - var body: some View { - ContactRowView( - contact: contact, - showTypeLabel: isSearching, - userLocation: userLocation, - isTogglingFavorite: viewModel.togglingFavoriteID == contact.id, - inboundHopCount: viewModel.inboundHopByKey[contact.publicKey] - ) - .padding(.horizontal, ContactRowLayout.horizontalPadding) - .padding(.vertical, ContactRowLayout.verticalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } + let contact: ContactDTO + let viewModel: ContactsViewModel + let isSearching: Bool + let userLocation: CLLocation? + + var body: some View { + ContactRowView( + contact: contact, + showTypeLabel: isSearching, + userLocation: userLocation, + isTogglingFavorite: viewModel.togglingFavoriteID == contact.id, + inboundHopCount: viewModel.inboundHopByKey[contact.publicKey] + ) + .padding(.horizontal, ContactRowLayout.horizontalPadding) + .padding(.vertical, ContactRowLayout.verticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } } // MARK: - Extracted Rows private struct ContactSelectionRow: View { - let contact: ContactDTO - let viewModel: ContactsViewModel - let isSearching: Bool - let userLocation: CLLocation? - let isSelected: Bool - let onSelect: () -> Void - - private var isDeleting: Bool { viewModel.deletingIDs.contains(contact.id) } - - var body: some View { - Button(action: onSelect) { - ContactListRowLabel(contact: contact, viewModel: viewModel, isSearching: isSearching, userLocation: userLocation) - } - .buttonStyle(.plain) - .selectedRowHighlight(isSelected: isSelected) - .accessibilityAddTraits(isSelected ? .isSelected : []) - .deletingRowOverlay(isDeleting: isDeleting) - .contactContextMenu(contact: contact, viewModel: viewModel) + let contact: ContactDTO + let viewModel: ContactsViewModel + let isSearching: Bool + let userLocation: CLLocation? + let isSelected: Bool + let onSelect: () -> Void + + private var isDeleting: Bool { + viewModel.deletingIDs.contains(contact.id) + } + + var body: some View { + Button(action: onSelect) { + ContactListRowLabel(contact: contact, viewModel: viewModel, isSearching: isSearching, userLocation: userLocation) } + .buttonStyle(.plain) + .selectedRowHighlight(isSelected: isSelected) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .deletingRowOverlay(isDeleting: isDeleting) + .contactContextMenu(contact: contact, viewModel: viewModel) + } } private struct ContactNavigationRow: View { - let contact: ContactDTO - let viewModel: ContactsViewModel - let isSearching: Bool - let userLocation: CLLocation? - - private var isDeleting: Bool { viewModel.deletingIDs.contains(contact.id) } - - var body: some View { - NavigationLink(value: ContactRoute.detail(contact)) { - ContactListRowLabel(contact: contact, viewModel: viewModel, isSearching: isSearching, userLocation: userLocation) - } - .buttonStyle(.plain) - .deletingRowOverlay(isDeleting: isDeleting) - .contactContextMenu(contact: contact, viewModel: viewModel) + let contact: ContactDTO + let viewModel: ContactsViewModel + let isSearching: Bool + let userLocation: CLLocation? + + private var isDeleting: Bool { + viewModel.deletingIDs.contains(contact.id) + } + + var body: some View { + NavigationLink(value: ContactRoute.detail(contact)) { + ContactListRowLabel(contact: contact, viewModel: viewModel, isSearching: isSearching, userLocation: userLocation) } + .buttonStyle(.plain) + .deletingRowOverlay(isDeleting: isDeleting) + .contactContextMenu(contact: contact, viewModel: viewModel) + } } diff --git a/MC1/Views/Contacts/ContactsListView.swift b/MC1/Views/Contacts/ContactsListView.swift index 88afa665..c453b98b 100644 --- a/MC1/Views/Contacts/ContactsListView.swift +++ b/MC1/Views/Contacts/ContactsListView.swift @@ -1,65 +1,65 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let nodesListLogger = Logger(subsystem: "com.mc1", category: "NodesListView") /// List of all contacts discovered on the mesh network struct ContactsListView: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var viewModel = ContactsViewModel() - @State private var navigationPath = NavigationPath() - @State private var searchText = "" - @State private var selectedSegment: NodeSegment = .contacts - @AppStorage(AppStorageKey.nodesSortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard - @State private var showDiscovery = false - @State private var syncSuccessTrigger = false - @State private var showShareMyContact = false - @State private var showAddContact = false - @State private var showLocationDeniedAlert = false - @State private var showOfflineRefreshAlert = false + @State private var viewModel = ContactsViewModel() + @State private var navigationPath = NavigationPath() + @State private var searchText = "" + @State private var selectedSegment: NodeSegment = .contacts + @AppStorage(AppStorageKey.nodesSortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard + @State private var showDiscovery = false + @State private var syncSuccessTrigger = false + @State private var showShareMyContact = false + @State private var showAddContact = false + @State private var showLocationDeniedAlert = false + @State private var showOfflineRefreshAlert = false - private var actions: ContactListActions { - ContactListActions(viewModel: viewModel, appState: appState, syncSuccessTrigger: $syncSuccessTrigger) - } + private var actions: ContactListActions { + ContactListActions(viewModel: viewModel, appState: appState, syncSuccessTrigger: $syncSuccessTrigger) + } - var body: some View { - NavigationStack(path: $navigationPath) { - sidebarContent - .navigationDestination(isPresented: $showDiscovery) { - DiscoveryView() - } + var body: some View { + NavigationStack(path: $navigationPath) { + sidebarContent + .navigationDestination(isPresented: $showDiscovery) { + DiscoveryView() } } + } - private var sidebarContent: some View { - ContactsSidebarContent( - viewModel: viewModel, - filteredContacts: actions.filteredContacts(searchText: searchText, segment: selectedSegment, sortOrder: sortOrder), - isSearching: !searchText.isEmpty, - searchPrompt: actions.searchPrompt, - shouldUseSplitView: false, - selectedSegment: $selectedSegment, - // Split-only: the compact stack navigates via navigationPath, so it has no selection. - selectedContact: .constant(nil), - searchText: $searchText, - sortOrder: $sortOrder, - showDiscovery: $showDiscovery, - syncSuccessTrigger: $syncSuccessTrigger, - showShareMyContact: $showShareMyContact, - showAddContact: $showAddContact, - showLocationDeniedAlert: $showLocationDeniedAlert, - showOfflineRefreshAlert: $showOfflineRefreshAlert, - navigationPath: $navigationPath, - onLoadContacts: actions.loadContacts, - onSyncContacts: actions.syncContacts, - onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded - ) - } + private var sidebarContent: some View { + ContactsSidebarContent( + viewModel: viewModel, + filteredContacts: actions.filteredContacts(searchText: searchText, segment: selectedSegment, sortOrder: sortOrder), + isSearching: !searchText.isEmpty, + searchPrompt: actions.searchPrompt, + shouldUseSplitView: false, + selectedSegment: $selectedSegment, + // Split-only: the compact stack navigates via navigationPath, so it has no selection. + selectedContact: .constant(nil), + searchText: $searchText, + sortOrder: $sortOrder, + showDiscovery: $showDiscovery, + syncSuccessTrigger: $syncSuccessTrigger, + showShareMyContact: $showShareMyContact, + showAddContact: $showAddContact, + showLocationDeniedAlert: $showLocationDeniedAlert, + showOfflineRefreshAlert: $showOfflineRefreshAlert, + navigationPath: $navigationPath, + onLoadContacts: actions.loadContacts, + onSyncContacts: actions.syncContacts, + onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded + ) + } } #Preview { - ContactsListView() - .environment(\.appState, AppState()) + ContactsListView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/ContactsSearchEmptyView.swift b/MC1/Views/Contacts/ContactsSearchEmptyView.swift index 017d1497..1471ef88 100644 --- a/MC1/Views/Contacts/ContactsSearchEmptyView.swift +++ b/MC1/Views/Contacts/ContactsSearchEmptyView.swift @@ -1,13 +1,13 @@ import SwiftUI struct ContactsSearchEmptyView: View { - let searchText: String + let searchText: String - var body: some View { - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Search.title, - systemImage: "magnifyingglass", - description: Text(L10n.Contacts.Contacts.List.Empty.Search.description(searchText)) - ) - } + var body: some View { + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Search.title, + systemImage: "magnifyingglass", + description: Text(L10n.Contacts.Contacts.List.Empty.Search.description(searchText)) + ) + } } diff --git a/MC1/Views/Contacts/ContactsSidebarContent.swift b/MC1/Views/Contacts/ContactsSidebarContent.swift index e6b0367d..17ac1635 100644 --- a/MC1/Views/Contacts/ContactsSidebarContent.swift +++ b/MC1/Views/Contacts/ContactsSidebarContent.swift @@ -1,234 +1,242 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let sidebarLogger = Logger(subsystem: "com.mc1", category: "NodesListView") struct ContactsSidebarContent: View { - @Environment(\.appState) private var appState - - @Bindable var viewModel: ContactsViewModel - let filteredContacts: [ContactDTO] - let isSearching: Bool - let searchPrompt: String - let shouldUseSplitView: Bool - - @Binding var selectedSegment: NodeSegment - @Binding var selectedContact: ContactDTO? - @Binding var searchText: String - @Binding var sortOrder: NodeSortOrder - @Binding var showDiscovery: Bool - @Binding var syncSuccessTrigger: Bool - @Binding var showShareMyContact: Bool - @Binding var showAddContact: Bool - @Binding var showLocationDeniedAlert: Bool - @Binding var showOfflineRefreshAlert: Bool - @Binding var navigationPath: NavigationPath - - let onLoadContacts: () async -> Void - let onSyncContacts: () async -> Void - let onAnnounceOfflineStateIfNeeded: () -> Void - - var body: some View { - ContactsListContent( - mode: shouldUseSplitView ? .selection($selectedContact) : .navigation, - selectedSegment: $selectedSegment, - isSearching: isSearching, - searchText: searchText, - filteredContacts: filteredContacts, - hasLoadedOnce: viewModel.hasLoadedOnce, - viewModel: viewModel - ) - .navigationTitle(L10n.Contacts.Contacts.List.title) - .navigationDestination(for: ContactRoute.self) { route in - switch route { - case .detail(let contact): - // Prefer the freshest row from the loaded list; fall back to the carried - // payload for pushes that precede a load (e.g. a notification deep link). - ContactDetailView(contact: viewModel.contacts.first { $0.id == contact.id } ?? contact) - .id(contact.id) - case .blockedContacts: - BlockedContactsView() + @Environment(\.appState) private var appState + + @Bindable var viewModel: ContactsViewModel + let filteredContacts: [ContactDTO] + let isSearching: Bool + let searchPrompt: String + let shouldUseSplitView: Bool + + @Binding var selectedSegment: NodeSegment + @Binding var selectedContact: ContactDTO? + @Binding var searchText: String + @Binding var sortOrder: NodeSortOrder + @Binding var showDiscovery: Bool + @Binding var syncSuccessTrigger: Bool + @Binding var showShareMyContact: Bool + @Binding var showAddContact: Bool + @Binding var showLocationDeniedAlert: Bool + @Binding var showOfflineRefreshAlert: Bool + @Binding var navigationPath: NavigationPath + + let onLoadContacts: () async -> Void + let onSyncContacts: () async -> Void + let onAnnounceOfflineStateIfNeeded: () -> Void + + var body: some View { + ContactsListContent( + mode: shouldUseSplitView ? .selection($selectedContact) : .navigation, + selectedSegment: $selectedSegment, + isSearching: isSearching, + searchText: searchText, + filteredContacts: filteredContacts, + hasLoadedOnce: viewModel.hasLoadedOnce, + viewModel: viewModel + ) + .navigationTitle(L10n.Contacts.Contacts.List.title) + .navigationDestination(for: ContactRoute.self) { route in + switch route { + case let .detail(contact): + // Prefer the freshest row from the loaded list; fall back to the carried + // payload for pushes that precede a load (e.g. a notification deep link). + ContactDetailView(contact: viewModel.contacts.first { $0.id == contact.id } ?? contact) + .id(contact.id) + case .blockedContacts: + BlockedContactsView() + } + } + .searchable(text: $searchText, prompt: searchPrompt) + .toolbar { + bleStatusToolbarItem() + + ToolbarItem(placement: .automatic) { + Menu { + ForEach(NodeSortOrder.allCases, id: \.self) { order in + Button { + sortOrder = order + } label: { + if sortOrder == order { + Label(order.localizedTitle, systemImage: "checkmark") + } else { + Text(order.localizedTitle) + } } + } + } label: { + Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") } - .searchable(text: $searchText, prompt: searchPrompt) - .toolbar { - bleStatusToolbarItem() - - ToolbarItem(placement: .automatic) { - Menu { - ForEach(NodeSortOrder.allCases, id: \.self) { order in - Button { - sortOrder = order - } label: { - if sortOrder == order { - Label(order.localizedTitle, systemImage: "checkmark") - } else { - Text(order.localizedTitle) - } - } - } - } label: { - Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") - } - } + } - ToolbarItem(placement: .automatic) { - Menu { - NavigationLink(value: ContactRoute.blockedContacts) { - Label(L10n.Contacts.Contacts.List.blockedContacts, systemImage: "hand.raised.fill") - } - - Divider() - - Button { - showShareMyContact = true - } label: { - Label(L10n.Contacts.Contacts.List.shareMyContact, systemImage: "square.and.arrow.up") - } - - Button { - showAddContact = true - } label: { - Label(L10n.Contacts.Contacts.List.addContact, systemImage: "plus") - } - - Divider() - - Button { - if shouldUseSplitView { - selectedContact = nil - } - showDiscovery = true - } label: { - Label(L10n.Contacts.Contacts.List.discover, systemImage: "antenna.radiowaves.left.and.right") - } - - Divider() - - Button { - Task { - if appState.connectionState != .ready { - showOfflineRefreshAlert = true - } else { - await onSyncContacts() - } - } - } label: { - Label(L10n.Contacts.Contacts.List.syncNodes, systemImage: "arrow.triangle.2.circlepath") - } - .disabled(viewModel.isSyncing) - } label: { - Label(L10n.Contacts.Contacts.List.options, systemImage: "ellipsis.circle") - } - } - } - .refreshable { - if appState.connectionState != .ready { - showOfflineRefreshAlert = true - } else { - // SwiftUI cancels a ScrollView's `.refreshable` task when observed state mutates - // mid-refresh (syncContacts sets `isSyncing`, re-evaluating this view), aborting the - // sync within a frame. Running it in a detached task shields it from that cancellation; - // awaiting the value keeps the refresh spinner up until the sync finishes. - await Task { await onSyncContacts() }.value - } - } - .alert(L10n.Contacts.Contacts.List.cannotRefresh, isPresented: $showOfflineRefreshAlert) { - Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) { } - } message: { - Text(L10n.Contacts.Contacts.List.connectToSync) - } - .sensoryFeedback(.success, trigger: syncSuccessTrigger) - .task { - sidebarLogger.info("NodesListView: task started, services=\(appState.services != nil)") - viewModel.configure( - dataStore: { [appState] in appState.offlineDataStore }, - contactService: { [appState] in appState.services?.contactService }, - advertisementService: { [appState] in appState.services?.advertisementService } - ) - await onLoadContacts() - sidebarLogger.info("NodesListView: loaded, contacts=\(viewModel.contacts.count)") - onAnnounceOfflineStateIfNeeded() - - // Request location for distance display (only if already authorized) - if appState.locationService.isAuthorized { - appState.locationService.requestLocation() - } - } - .task(id: sortOrder) { - if sortOrder == .distance { - if appState.locationService.isAuthorized { - appState.locationService.requestLocation() - } else if appState.locationService.isLocationDenied { - showLocationDeniedAlert = true - } else { - appState.locationService.requestPermissionIfNeeded() - } - } - } - .onChange(of: appState.servicesVersion) { _, _ in - Task { - await onLoadContacts() - } - } - .onChange(of: appState.contactsVersion) { _, _ in - Task { - await onLoadContacts() - } - } - .onChange(of: appState.navigation.pendingDiscoveryNavigation, initial: true) { _, shouldNavigate in - if shouldNavigate { - showDiscovery = true - appState.navigation.clearPendingDiscoveryNavigation() - } - } - .onChange(of: appState.navigation.pendingContactDetail, initial: true) { _, contact in - guard let contact else { return } + ToolbarItem(placement: .automatic) { + Menu { + NavigationLink(value: ContactRoute.blockedContacts) { + Label(L10n.Contacts.Contacts.List.blockedContacts, systemImage: "hand.raised.fill") + } + + Divider() + + Button { + showShareMyContact = true + } label: { + Label(L10n.Contacts.Contacts.List.shareMyContact, systemImage: "square.and.arrow.up") + } + Button { + showAddContact = true + } label: { + Label(L10n.Contacts.Contacts.List.addContact, systemImage: "plus") + } + + Divider() + + Button { if shouldUseSplitView { - selectedContact = contact - } else { - navigationPath.removeLast(navigationPath.count) - navigationPath.append(ContactRoute.detail(contact)) + selectedContact = nil } + showDiscovery = true + } label: { + Label(L10n.Contacts.Contacts.List.discover, systemImage: "antenna.radiowaves.left.and.right") + } - appState.navigation.clearPendingContactDetailNavigation() - } - .onChange(of: appState.locationService.authorizationStatus) { _, status in - if sortOrder == .distance { - switch status { - case .authorizedWhenInUse, .authorizedAlways: - appState.locationService.requestLocation() - case .denied, .restricted: - showLocationDeniedAlert = true - default: - break - } + Divider() + + Button { + Task { + if appState.connectionState != .ready { + showOfflineRefreshAlert = true + } else { + await onSyncContacts() + } } + } label: { + Label(L10n.Contacts.Contacts.List.syncNodes, systemImage: "arrow.triangle.2.circlepath") + } + .disabled(viewModel.isSyncing) + } label: { + Label(L10n.Contacts.Contacts.List.options, systemImage: "ellipsis.circle") } - .sheet(isPresented: $showShareMyContact) { - if let device = appState.connectedDevice { - ContactQRShareSheet( - contactName: device.nodeName, - publicKey: device.publicKey, - contactType: .chat - ) - } + } + } + .refreshable { + if appState.connectionState != .ready { + showOfflineRefreshAlert = true + } else { + // SwiftUI cancels a ScrollView's `.refreshable` task when observed state mutates + // mid-refresh (syncContacts sets `isSyncing`, re-evaluating this view), aborting the + // sync within a frame. Running it in a detached task shields it from that cancellation; + // awaiting the value keeps the refresh spinner up until the sync finishes. + await Task { await onSyncContacts() }.value + } + } + .alert(L10n.Contacts.Contacts.List.cannotRefresh, isPresented: $showOfflineRefreshAlert) { + Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} + } message: { + Text(L10n.Contacts.Contacts.List.connectToSync) + } + .sensoryFeedback(.success, trigger: syncSuccessTrigger) + .task { + sidebarLogger.info("NodesListView: task started, services=\(appState.services != nil)") + viewModel.configure( + dataStore: { [appState] in appState.offlineDataStore }, + contactService: { [appState] in appState.services?.contactService }, + advertisementService: { [appState] in appState.services?.advertisementService } + ) + await onLoadContacts() + sidebarLogger.info("NodesListView: loaded, contacts=\(viewModel.contacts.count)") + onAnnounceOfflineStateIfNeeded() + + // Request location for distance display (only if already authorized) + if appState.locationService.isAuthorized { + appState.locationService.requestLocation() + } + } + .task(id: sortOrder) { + if sortOrder == .distance { + if appState.locationService.isAuthorized { + appState.locationService.requestLocation() + } else if appState.locationService.isLocationDenied { + showLocationDeniedAlert = true + } else { + appState.locationService.requestPermissionIfNeeded() } - .sheet(isPresented: $showAddContact) { - AddContactSheet() + } + } + .onChange(of: appState.servicesVersion) { _, _ in + Task { + await onLoadContacts() + } + } + .onChange(of: appState.contactsVersion) { _, _ in + Task { + await onLoadContacts() + } + } + .onChange(of: viewModel.hasLoadedOnce) { _, loaded in + // On the first successful load, land on Favorites when any exist. Guarded on the default + // segment so a user who switched during loading isn't overridden, and keyed off the + // false->true transition so returning to the tab doesn't yank an existing selection. + if loaded, selectedSegment == .contacts, viewModel.hasFavorites { + selectedSegment = .favorites + } + } + .onChange(of: appState.navigation.pendingDiscoveryNavigation, initial: true) { _, shouldNavigate in + if shouldNavigate { + showDiscovery = true + appState.navigation.clearPendingDiscoveryNavigation() + } + } + .onChange(of: appState.navigation.pendingContactDetail, initial: true) { _, contact in + guard let contact else { return } + + if shouldUseSplitView { + selectedContact = contact + } else { + navigationPath.removeLast(navigationPath.count) + navigationPath.append(ContactRoute.detail(contact)) + } + + appState.navigation.clearPendingContactDetailNavigation() + } + .onChange(of: appState.locationService.authorizationStatus) { _, status in + if sortOrder == .distance { + switch status { + case .authorizedWhenInUse, .authorizedAlways: + appState.locationService.requestLocation() + case .denied, .restricted: + showLocationDeniedAlert = true + default: + break } - .alert(L10n.Contacts.Contacts.List.locationUnavailable, isPresented: $showLocationDeniedAlert) { - Button(L10n.Contacts.Contacts.List.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - } message: { - Text(L10n.Contacts.Contacts.List.distanceRequiresLocation) + } + } + .sheet(isPresented: $showShareMyContact) { + if let device = appState.connectedDevice { + ContactQRShareSheet( + contactName: device.nodeName, + publicKey: device.publicKey, + contactType: .chat + ) + } + } + .sheet(isPresented: $showAddContact) { + AddContactSheet() + } + .alert(L10n.Contacts.Contacts.List.locationUnavailable, isPresented: $showLocationDeniedAlert) { + Button(L10n.Contacts.Contacts.List.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) } - .errorAlert($viewModel.errorMessage, title: L10n.Contacts.Contacts.Common.error) + } + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Contacts.Contacts.List.distanceRequiresLocation) } + .errorAlert($viewModel.errorMessage, title: L10n.Contacts.Contacts.Common.error) + } } diff --git a/MC1/Views/Contacts/ContactsViewModel.swift b/MC1/Views/Contacts/ContactsViewModel.swift index 1b399fe6..bce45dcc 100644 --- a/MC1/Views/Contacts/ContactsViewModel.swift +++ b/MC1/Views/Contacts/ContactsViewModel.swift @@ -1,386 +1,397 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// Segment for the nodes picker enum NodeSegment: String, CaseIterable { - case favorites - case contacts - case repeaters - case rooms - - var localizedTitle: String { - switch self { - case .favorites: L10n.Contacts.Contacts.Segment.favorites - case .contacts: L10n.Contacts.Contacts.Segment.contacts - case .repeaters: L10n.Contacts.Contacts.Segment.repeaters - case .rooms: L10n.Contacts.Contacts.Segment.rooms - } + case favorites + case contacts + case repeaters + case rooms + + var localizedTitle: String { + switch self { + case .favorites: L10n.Contacts.Contacts.Segment.favorites + case .contacts: L10n.Contacts.Contacts.Segment.contacts + case .repeaters: L10n.Contacts.Contacts.Segment.repeaters + case .rooms: L10n.Contacts.Contacts.Segment.rooms } + } } /// Sort order for nodes list enum NodeSortOrder: String, CaseIterable { - case lastHeard - case name - case distance - case hops - - var localizedTitle: String { - switch self { - case .lastHeard: L10n.Contacts.Contacts.Sort.lastHeard - case .name: L10n.Contacts.Contacts.Sort.name - case .distance: L10n.Contacts.Contacts.Sort.distance - case .hops: L10n.Contacts.Contacts.Sort.hops - } + case lastHeard + case name + case distance + case hops + + var localizedTitle: String { + switch self { + case .lastHeard: L10n.Contacts.Contacts.Sort.lastHeard + case .name: L10n.Contacts.Contacts.Sort.name + case .distance: L10n.Contacts.Contacts.Sort.distance + case .hops: L10n.Contacts.Contacts.Sort.hops } + } } /// ViewModel for contact management @Observable @MainActor final class ContactsViewModel { + // MARK: - Properties - // MARK: - Properties + /// All contacts + var contacts: [ContactDTO] = [] - /// All contacts - var contacts: [ContactDTO] = [] + /// Inbound advert hop count per contact public key, sourced from the volatile discovered-node + /// table and refreshed on each load. A contact's "Hops" falls back to this when it has no + /// out-path; absence (evicted or never heard via advert) just leaves the fallback unavailable. + var inboundHopByKey: [Data: Int] = [:] - /// Inbound advert hop count per contact public key, sourced from the volatile discovered-node - /// table and refreshed on each load. A contact's "Hops" falls back to this when it has no - /// out-path; absence (evicted or never heard via advert) just leaves the fallback unavailable. - var inboundHopByKey: [Data: Int] = [:] + /// Loading state + var isLoading = false - /// Loading state - var isLoading = false + /// Whether data has been loaded at least once (prevents empty state flash) + var hasLoadedOnce = false - /// Whether data has been loaded at least once (prevents empty state flash) - var hasLoadedOnce = false + /// Syncing state + var isSyncing = false - /// Syncing state - var isSyncing = false + /// Sync progress (current, total) + var syncProgress: (Int, Int)? - /// Sync progress (current, total) - var syncProgress: (Int, Int)? + /// Error message if any + var errorMessage: String? - /// Error message if any - var errorMessage: String? + /// User's current location for distance sorting (optional) + var userLocation: CLLocation? - /// User's current location for distance sorting (optional) - var userLocation: CLLocation? + /// Contact ID currently having its favorite status toggled (for loading UI) + var togglingFavoriteID: UUID? - /// Contact ID currently having its favorite status toggled (for loading UI) - var togglingFavoriteID: UUID? + /// Mask of rows hidden after a confirmed delete, held until a reload sees the row gone so a + /// racing reload can't resurrect it. Observed because `filteredContacts` reads it during body + /// evaluation. Distinct from `deletingIDs`, the in-flight presentation set. + var pendingRemovalIDs: Set = [] - /// Mask of rows hidden after a confirmed delete, held until a reload sees the row gone so a - /// racing reload can't resurrect it. Observed because `filteredContacts` reads it during body - /// evaluation. Distinct from `deletingIDs`, the in-flight presentation set. - var pendingRemovalIDs: Set = [] + /// Rows with a delete command in flight, surfaced as a spinner. Distinct from + /// `pendingRemovalIDs`, the post-confirmation mask. + var deletingIDs: Set = [] - /// Rows with a delete command in flight, surfaced as a spinner. Distinct from - /// `pendingRemovalIDs`, the post-confirmation mask. - var deletingIDs: Set = [] + /// True while a delete for this row is either confirmed-but-unreloaded or in flight. + func isDeletePending(_ id: UUID) -> Bool { + pendingRemovalIDs.contains(id) || deletingIDs.contains(id) + } - /// True while a delete for this row is either confirmed-but-unreloaded or in flight. - func isDeletePending(_ id: UUID) -> Bool { - pendingRemovalIDs.contains(id) || deletingIDs.contains(id) - } + // MARK: - Dependencies - // MARK: - Dependencies + private var dataStoreProvider: @MainActor () -> DataStore? = { nil } + private var contactServiceProvider: @MainActor () -> ContactService? = { nil } + private var advertisementServiceProvider: @MainActor () -> AdvertisementService? = { nil } - private var dataStoreProvider: @MainActor () -> DataStore? = { nil } - private var contactServiceProvider: @MainActor () -> ContactService? = { nil } - private var advertisementServiceProvider: @MainActor () -> AdvertisementService? = { nil } + private var dataStore: DataStore? { + dataStoreProvider() + } - private var dataStore: DataStore? { dataStoreProvider() } - private var contactService: ContactService? { contactServiceProvider() } - private var advertisementService: AdvertisementService? { advertisementServiceProvider() } + private var contactService: ContactService? { + contactServiceProvider() + } - // MARK: - Initialization + private var advertisementService: AdvertisementService? { + advertisementServiceProvider() + } - init() {} + // MARK: - Initialization - /// Configure with the services this view model uses; a provider returning nil mirrors a disconnected state. - func configure( - dataStore: @escaping @MainActor () -> DataStore?, - contactService: @escaping @MainActor () -> ContactService?, - advertisementService: @escaping @MainActor () -> AdvertisementService? - ) { - dataStoreProvider = dataStore - contactServiceProvider = contactService - advertisementServiceProvider = advertisementService - } - - // MARK: - Load Contacts + init() {} - /// Load contacts from local database - func loadContacts(radioID: UUID) async { - guard let dataStore else { return } + /// Configure with the services this view model uses; a provider returning nil mirrors a disconnected state. + func configure( + dataStore: @escaping @MainActor () -> DataStore?, + contactService: @escaping @MainActor () -> ContactService?, + advertisementService: @escaping @MainActor () -> AdvertisementService? + ) { + dataStoreProvider = dataStore + contactServiceProvider = contactService + advertisementServiceProvider = advertisementService + } - isLoading = true - errorMessage = nil + // MARK: - Load Contacts - do { - contacts = try await dataStore.fetchContacts(radioID: radioID) - // Self-heal the mask: once a deleted row is gone from the fetch, stop masking it. - pendingRemovalIDs.formIntersection(Set(contacts.map(\.id))) - } catch { - errorMessage = error.userFacingMessage - } + /// Load contacts from local database + func loadContacts(radioID: UUID) async { + guard let dataStore else { return } - // Best-effort inbound-hop fallback from the volatile discovered-node table; a failure here - // must not fail the contact load, so it is fetched outside the throwing load path. - inboundHopByKey = (try? await dataStore.fetchDiscoveredNodes(radioID: radioID))? - .reduce(into: [:]) { map, node in - if let inbound = node.inboundHopCount { map[node.publicKey] = inbound } - } ?? [:] + isLoading = true + errorMessage = nil - hasLoadedOnce = true - isLoading = false + do { + contacts = try await dataStore.fetchContacts(radioID: radioID) + // Self-heal the mask: once a deleted row is gone from the fetch, stop masking it. + pendingRemovalIDs.formIntersection(Set(contacts.map(\.id))) + } catch { + errorMessage = error.userFacingMessage } - // MARK: - Sync Contacts - - /// Sync contacts from device - func syncContacts(radioID: UUID) async { - guard let contactService else { return } - // Claim the sync synchronously so a re-trigger while one is in flight is a no-op. - guard !isSyncing else { return } + // Best-effort inbound-hop fallback from the volatile discovered-node table; a failure here + // must not fail the contact load, so it is fetched outside the throwing load path. + inboundHopByKey = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID))? + .reduce(into: [:]) { map, node in + if let inbound = node.inboundHopCount { map[node.publicKey] = inbound } + } ?? [:] - isSyncing = true - syncProgress = nil - errorMessage = nil + hasLoadedOnce = true + isLoading = false + } - if let advertisementService { - await advertisementService.setSyncingContacts(true) - } - - // Subscribed synchronously before the sync starts so no progress event - // is missed; scoped to this sync and cancelled when it completes. - let events = contactService.events() - let progressTask = Task { [weak self] in - for await event in events { - if case .syncProgress(let received, let total) = event { - self?.syncProgress = (received, total) - } - } - } - defer { progressTask.cancel() } + // MARK: - Sync Contacts - do { - _ = try await contactService.syncContacts(radioID: radioID) + /// Sync contacts from device + func syncContacts(radioID: UUID) async { + guard let contactService else { return } + // Claim the sync synchronously so a re-trigger while one is in flight is a no-op. + guard !isSyncing else { return } - // Reload from database - await loadContacts(radioID: radioID) + isSyncing = true + syncProgress = nil + errorMessage = nil - // Clear sync progress - syncProgress = nil - } catch is CancellationError { - // Pull-to-refresh interrupted (tab switch, view teardown); not a failure to report. - } catch { - errorMessage = error.userFacingMessage - } + if let advertisementService { + await advertisementService.setSyncingContacts(true) + } - // Awaited rather than deferred to an unstructured Task so this sync's reset cannot - // land after a later sync's setSyncingContacts(true) and clear the flag mid-sync. - if let advertisementService { - await advertisementService.setSyncingContacts(false) + // Subscribed synchronously before the sync starts so no progress event + // is missed; scoped to this sync and cancelled when it completes. + let events = contactService.events() + let progressTask = Task { [weak self] in + for await event in events { + if case let .syncProgress(received, total) = event { + self?.syncProgress = (received, total) } - - isSyncing = false + } } + defer { progressTask.cancel() } - // MARK: - Contact Actions - - /// Toggle favorite status on device and update local state - func toggleFavorite(contact: ContactDTO) async { - guard let contactService else { return } + do { + _ = try await contactService.syncContacts(radioID: radioID) - togglingFavoriteID = contact.id - defer { togglingFavoriteID = nil } + // Reload from database + await loadContacts(radioID: radioID) - do { - try await contactService.setContactFavorite(contact.id, isFavorite: !contact.isFavorite) + // Clear sync progress + syncProgress = nil + } catch is CancellationError { + // Pull-to-refresh interrupted (tab switch, view teardown); not a failure to report. + } catch { + errorMessage = error.userFacingMessage + } - // Reload to get updated state - if contacts.contains(where: { $0.id == contact.id }) { - await loadContacts(radioID: contact.radioID) - } - } catch { - errorMessage = error.userFacingMessage - } + // Awaited rather than deferred to an unstructured Task so this sync's reset cannot + // land after a later sync's setSyncingContacts(true) and clear the flag mid-sync. + if let advertisementService { + await advertisementService.setSyncingContacts(false) } - /// Toggle blocked status - func toggleBlocked(contact: ContactDTO) async { - guard let contactService else { return } + isSyncing = false + } - do { - try await contactService.updateContactPreferences( - contactID: contact.id, - isBlocked: !contact.isBlocked - ) + // MARK: - Contact Actions - // Update local list - await loadContacts(radioID: contact.radioID) - } catch { - errorMessage = error.userFacingMessage - } - } + /// Toggle favorite status on device and update local state + func toggleFavorite(contact: ContactDTO) async { + guard let contactService else { return } - /// Update nickname - func updateNickname(contact: ContactDTO, nickname: String?) async { - guard let contactService else { return } + togglingFavoriteID = contact.id + defer { togglingFavoriteID = nil } - do { - try await contactService.updateContactPreferences( - contactID: contact.id, - nickname: nickname?.isEmpty == true ? nil : nickname - ) + do { + try await contactService.setContactFavorite(contact.id, isFavorite: !contact.isFavorite) - // Update local list - await loadContacts(radioID: contact.radioID) - } catch { - errorMessage = error.userFacingMessage - } + // Reload to get updated state + if contacts.contains(where: { $0.id == contact.id }) { + await loadContacts(radioID: contact.radioID) + } + } catch { + errorMessage = error.userFacingMessage } - - /// Delete a contact. Removing a node is a real radio command, so the row stays in place with a - /// spinner until the radio acks, then is hidden once; a failure or timeout leaves it untouched - /// with an error rather than bouncing it out and back. - func deleteContact(_ contact: ContactDTO) async { - guard let contactService else { - errorMessage = L10n.Contacts.Contacts.ViewModel.connectToDelete - return - } - guard !isDeletePending(contact.id) else { return } - - deletingIDs.insert(contact.id) - defer { deletingIDs.remove(contact.id) } - - do { - try await withTimeout(RadioCommandTimeout.delete, operationName: "removeContact") { - try await contactService.removeContact( - radioID: contact.radioID, - publicKey: contact.publicKey - ) - } - hideDeletedContact(contact) - } catch ContactServiceError.contactNotFound { - // The radio no longer knows this contact (e.g. a prior attempt's command landed but its - // local delete was interrupted). Clear the orphaned local row so it stops reappearing. - do { - try await contactService.removeLocalContact(contactID: contact.id, publicKey: contact.publicKey) - hideDeletedContact(contact) - } catch { - errorMessage = error.userFacingMessage - } - } catch is TimeoutError { - errorMessage = L10n.Contacts.Contacts.ViewModel.removeTimedOut - } catch { - errorMessage = error.userFacingMessage - } + } + + /// Toggle blocked status + func toggleBlocked(contact: ContactDTO) async { + guard let contactService else { return } + + do { + try await contactService.updateContactPreferences( + contactID: contact.id, + isBlocked: !contact.isBlocked + ) + + // Update local list + await loadContacts(radioID: contact.radioID) + } catch { + errorMessage = error.userFacingMessage } - - /// Masks and removes a row whose deletion the radio confirmed, in one animation. - /// The mask insert is observed (read live by `filteredContacts`), so it must land inside - /// the same transaction as the array removal or it hides the row unanimated first. - private func hideDeletedContact(_ contact: ContactDTO) { - withAnimation(.snappy) { - pendingRemovalIDs.insert(contact.id) - contacts.removeAll { $0.id == contact.id } - } + } + + /// Update nickname + func updateNickname(contact: ContactDTO, nickname: String?) async { + guard let contactService else { return } + + do { + try await contactService.updateContactPreferences( + contactID: contact.id, + nickname: nickname?.isEmpty == true ? nil : nickname + ) + + // Update local list + await loadContacts(radioID: contact.radioID) + } catch { + errorMessage = error.userFacingMessage } - - // MARK: - Filtering - - /// Returns contacts filtered by segment and sorted - func filteredContacts( - searchText: String, - segment: NodeSegment, - sortOrder: NodeSortOrder, - userLocation: CLLocation? - ) -> [ContactDTO] { - var result = contacts.filter { !pendingRemovalIDs.contains($0.id) } - - // If searching, show all types (ignore segment) - if searchText.isEmpty { - // Filter by segment - switch segment { - case .favorites: - result = result.filter(\.isFavorite) - case .contacts: - result = result.filter { $0.type == .chat } - case .repeaters: - result = result.filter { $0.type == .repeater } - case .rooms: - result = result.filter { $0.type == .room } - } - } else { - // Filter by search text only - result = result.filter { contact in - contact.displayName.localizedStandardContains(searchText) - || contact.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) - } - } - - // Sort - result = sorted(result, by: sortOrder, userLocation: userLocation) - - return result + } + + /// Delete a contact. Removing a node is a real radio command, so the row stays in place with a + /// spinner until the radio acks, then is hidden once; a failure or timeout leaves it untouched + /// with an error rather than bouncing it out and back. + func deleteContact(_ contact: ContactDTO) async { + guard let contactService else { + errorMessage = L10n.Contacts.Contacts.ViewModel.connectToDelete + return + } + guard !isDeletePending(contact.id) else { return } + + deletingIDs.insert(contact.id) + defer { deletingIDs.remove(contact.id) } + + do { + try await withTimeout(RadioCommandTimeout.delete, operationName: "removeContact") { + try await contactService.removeContact( + radioID: contact.radioID, + publicKey: contact.publicKey + ) + } + hideDeletedContact(contact) + } catch ContactServiceError.contactNotFound { + // The radio no longer knows this contact (e.g. a prior attempt's command landed but its + // local delete was interrupted). Clear the orphaned local row so it stops reappearing. + do { + try await contactService.removeLocalContact(contactID: contact.id, publicKey: contact.publicKey) + hideDeletedContact(contact) + } catch { + errorMessage = error.userFacingMessage + } + } catch is TimeoutError { + errorMessage = L10n.Contacts.Contacts.ViewModel.removeTimedOut + } catch { + errorMessage = error.userFacingMessage + } + } + + /// Masks and removes a row whose deletion the radio confirmed, in one animation. + /// The mask insert is observed (read live by `filteredContacts`), so it must land inside + /// the same transaction as the array removal or it hides the row unanimated first. + private func hideDeletedContact(_ contact: ContactDTO) { + withAnimation(.snappy) { + pendingRemovalIDs.insert(contact.id) + contacts.removeAll { $0.id == contact.id } + } + } + + // MARK: - Filtering + + /// True when any loaded contact is a favorite; drives the initial Nodes segment. + var hasFavorites: Bool { + contacts.contains(where: \.isFavorite) + } + + /// Returns contacts filtered by segment and sorted + func filteredContacts( + searchText: String, + segment: NodeSegment, + sortOrder: NodeSortOrder, + userLocation: CLLocation? + ) -> [ContactDTO] { + var result = contacts.filter { !pendingRemovalIDs.contains($0.id) } + + // If searching, show all types (ignore segment) + if searchText.isEmpty { + // Filter by segment + switch segment { + case .favorites: + result = result.filter(\.isFavorite) + case .contacts: + result = result.filter { $0.type == .chat } + case .repeaters: + result = result.filter { $0.type == .repeater } + case .rooms: + result = result.filter { $0.type == .room } + } + } else { + // Filter by search text only + result = result.filter { contact in + contact.displayName.localizedStandardContains(searchText) + || contact.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) + } } - /// Sort contacts by the given order - private func sorted( - _ contacts: [ContactDTO], - by order: NodeSortOrder, - userLocation: CLLocation? - ) -> [ContactDTO] { - switch order { - case .lastHeard: - return contacts.sorted { $0.lastModified > $1.lastModified } - case .name: - return contacts.sorted { - $0.displayName.localizedCompare($1.displayName) == .orderedAscending - } - case .distance: - return contacts.sorted { orderedByDistanceThenName($0, $1, from: userLocation) } - case .hops: - return contacts.sorted { lhs, rhs in - let lhsHops = lhs.displayedHopCount(inboundHopCount: inboundHopByKey[lhs.publicKey]) - let rhsHops = rhs.displayedHopCount(inboundHopCount: inboundHopByKey[rhs.publicKey]) - // A nil hop count (flood-routed and never heard via advert) sorts to the bottom. - if (lhsHops == nil) != (rhsHops == nil) { - return lhsHops != nil - } - if let lhsHops, let rhsHops, lhsHops != rhsHops { - return lhsHops < rhsHops - } - return orderedByDistanceThenName(lhs, rhs, from: userLocation) - } + // Sort + result = sorted(result, by: sortOrder, userLocation: userLocation) + + return result + } + + /// Sort contacts by the given order + private func sorted( + _ contacts: [ContactDTO], + by order: NodeSortOrder, + userLocation: CLLocation? + ) -> [ContactDTO] { + switch order { + case .lastHeard: + contacts.sorted { $0.lastModified > $1.lastModified } + case .name: + contacts.sorted { + $0.displayName.localizedCompare($1.displayName) == .orderedAscending + } + case .distance: + contacts.sorted { orderedByDistanceThenName($0, $1, from: userLocation) } + case .hops: + contacts.sorted { lhs, rhs in + let lhsHops = lhs.displayedHopCount(inboundHopCount: inboundHopByKey[lhs.publicKey]) + let rhsHops = rhs.displayedHopCount(inboundHopCount: inboundHopByKey[rhs.publicKey]) + // A nil hop count (flood-routed and never heard via advert) sorts to the bottom. + if (lhsHops == nil) != (rhsHops == nil) { + return lhsHops != nil + } + if let lhsHops, let rhsHops, lhsHops != rhsHops { + return lhsHops < rhsHops } + return orderedByDistanceThenName(lhs, rhs, from: userLocation) + } } - - /// Located nodes first, then nearest to `userLocation`, then by name. Falls back to name when - /// there is no user location, neither node has coordinates, or the distances tie. - private func orderedByDistanceThenName( - _ lhs: ContactDTO, - _ rhs: ContactDTO, - from userLocation: CLLocation? - ) -> Bool { - if let userLocation { - if lhs.hasLocation != rhs.hasLocation { - return lhs.hasLocation - } - if lhs.hasLocation { - let lhsDistance = CLLocation(latitude: lhs.latitude, longitude: lhs.longitude).distance(from: userLocation) - let rhsDistance = CLLocation(latitude: rhs.latitude, longitude: rhs.longitude).distance(from: userLocation) - if lhsDistance != rhsDistance { - return lhsDistance < rhsDistance - } - } + } + + /// Located nodes first, then nearest to `userLocation`, then by name. Falls back to name when + /// there is no user location, neither node has coordinates, or the distances tie. + private func orderedByDistanceThenName( + _ lhs: ContactDTO, + _ rhs: ContactDTO, + from userLocation: CLLocation? + ) -> Bool { + if let userLocation { + if lhs.hasLocation != rhs.hasLocation { + return lhs.hasLocation + } + if lhs.hasLocation { + let lhsDistance = CLLocation(latitude: lhs.latitude, longitude: lhs.longitude).distance(from: userLocation) + let rhsDistance = CLLocation(latitude: rhs.latitude, longitude: rhs.longitude).distance(from: userLocation) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance } - return lhs.displayName.localizedCompare(rhs.displayName) == .orderedAscending + } } - + return lhs.displayName.localizedCompare(rhs.displayName) == .orderedAscending + } } diff --git a/MC1/Views/Contacts/DiscoverSegmentPicker.swift b/MC1/Views/Contacts/DiscoverSegmentPicker.swift index ceacb81e..23c1345c 100644 --- a/MC1/Views/Contacts/DiscoverSegmentPicker.swift +++ b/MC1/Views/Contacts/DiscoverSegmentPicker.swift @@ -2,15 +2,15 @@ import SwiftUI /// Pinned glass filter bar for the Discovery sub-view. struct DiscoverSegmentPicker: View { - @Binding var selection: DiscoverSegment - let isSearching: Bool + @Binding var selection: DiscoverSegment + let isSearching: Bool - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: isSearching, - pickerLabel: L10n.Contacts.Contacts.Discovery.Segment.pickerLabel, - title: { $0.localizedTitle } - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: isSearching, + pickerLabel: L10n.Contacts.Contacts.Discovery.Segment.pickerLabel, + title: { $0.localizedTitle } + ) + } } diff --git a/MC1/Views/Contacts/DiscoveryView.swift b/MC1/Views/Contacts/DiscoveryView.swift index 6c709f8d..e8cc4647 100644 --- a/MC1/Views/Contacts/DiscoveryView.swift +++ b/MC1/Views/Contacts/DiscoveryView.swift @@ -1,438 +1,437 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// Shows contacts discovered via advertisement that haven't been added to the device struct DiscoveryView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel = DiscoveryViewModel() - @State private var searchText = "" - @State private var selectedSegment: DiscoverSegment = .all - @AppStorage(AppStorageKey.discoverySortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard - @State private var addingNodeID: UUID? - @State private var showClearConfirmation = false - - private var filteredNodes: [DiscoveredNodeDTO] { - let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) - ? .lastHeard - : sortOrder - - return viewModel.filteredNodes( - searchText: searchText, - segment: selectedSegment, - sortOrder: effectiveSortOrder, - userLocation: appState.bestAvailableLocation + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel = DiscoveryViewModel() + @State private var searchText = "" + @State private var selectedSegment: DiscoverSegment = .all + @AppStorage(AppStorageKey.discoverySortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard + @State private var addingNodeID: UUID? + @State private var showClearConfirmation = false + + private var filteredNodes: [DiscoveredNodeDTO] { + let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) + ? .lastHeard + : sortOrder + + return viewModel.filteredNodes( + searchText: searchText, + segment: selectedSegment, + sortOrder: effectiveSortOrder, + userLocation: appState.bestAvailableLocation + ) + } + + private var isSearching: Bool { + !searchText.isEmpty + } + + /// Segment picker as the pinned section header; `pinnedFilterHeaderBackground` documents the + /// per-OS backing. + private var pinnedFilterHeader: some View { + DiscoverSegmentPicker(selection: $selectedSegment, isSearching: isSearching) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } + + private var emptyState: some View { + Group { + if isSearching { + DiscoverySearchEmptyView(searchText: searchText) + } else { + DiscoveryEmptyView() + } + } + .containerRelativeFrame([.horizontal, .vertical]) + } + + var body: some View { + Group { + if !viewModel.hasLoadedOnce { + loadingBody + } else { + loadedBody + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.Discovery.title) + .toolbar { + ToolbarItem(placement: .automatic) { + DiscoverySortMenu(sortOrder: $sortOrder) + } + + ToolbarItem(placement: .automatic) { + DiscoveryMoreMenu( + isEmpty: viewModel.discoveredNodes.isEmpty, + showClearConfirmation: $showClearConfirmation ) + } } - - private var isSearching: Bool { - !searchText.isEmpty + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: L10n.Contacts.Contacts.Discovery.searchPrompt + ) + .onChange(of: searchText) { oldValue, newValue in + if oldValue.isEmpty, !newValue.isEmpty { + AccessibilityNotification.Announcement(L10n.Contacts.Contacts.Discovery.searchingAllTypes).post() + } } - - /// Segment picker as the pinned section header; `pinnedFilterHeaderBackground` documents the - /// per-OS backing. - private var pinnedFilterHeader: some View { - DiscoverSegmentPicker(selection: $selectedSegment, isSearching: isSearching) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) + .task { + viewModel.configure(dataStore: { [appState] in appState.offlineDataStore }) + await loadDiscoveredNodes() } - - @ViewBuilder - private var emptyState: some View { - Group { - if isSearching { - DiscoverySearchEmptyView(searchText: searchText) - } else { - DiscoveryEmptyView() - } - } - .containerRelativeFrame([.horizontal, .vertical]) + .onChange(of: appState.servicesVersion) { _, _ in + Task { + await loadDiscoveredNodes() + } } - - var body: some View { - Group { - if !viewModel.hasLoadedOnce { - loadingBody - } else { - loadedBody - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.Discovery.title) - .toolbar { - ToolbarItem(placement: .automatic) { - DiscoverySortMenu(sortOrder: $sortOrder) - } - - ToolbarItem(placement: .automatic) { - DiscoveryMoreMenu( - isEmpty: viewModel.discoveredNodes.isEmpty, - showClearConfirmation: $showClearConfirmation - ) - } - } - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: L10n.Contacts.Contacts.Discovery.searchPrompt - ) - .onChange(of: searchText) { oldValue, newValue in - if oldValue.isEmpty, !newValue.isEmpty { - AccessibilityNotification.Announcement(L10n.Contacts.Contacts.Discovery.searchingAllTypes).post() - } - } - .task { - viewModel.configure(dataStore: { [appState] in appState.offlineDataStore }) - await loadDiscoveredNodes() - } - .onChange(of: appState.servicesVersion) { _, _ in - Task { - await loadDiscoveredNodes() - } - } - .onChange(of: appState.contactsVersion) { _, _ in - Task { - await loadDiscoveredNodes() - } - } - .errorAlert($viewModel.errorMessage, title: L10n.Contacts.Contacts.Common.error) - .confirmationDialog( - L10n.Contacts.Contacts.Discovery.Clear.title, - isPresented: $showClearConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Discovery.Clear.confirm, role: .destructive) { - Task { - await clearAllDiscoveredNodes() - } - } - } message: { - Text(L10n.Contacts.Contacts.Discovery.Clear.message) + .onChange(of: appState.contactsVersion) { _, _ in + Task { + await loadDiscoveredNodes() + } + } + .errorAlert($viewModel.errorMessage, title: L10n.Contacts.Contacts.Common.error) + .confirmationDialog( + L10n.Contacts.Contacts.Discovery.Clear.title, + isPresented: $showClearConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Discovery.Clear.confirm, role: .destructive) { + Task { + await clearAllDiscoveredNodes() } + } + } message: { + Text(L10n.Contacts.Contacts.Discovery.Clear.message) } + } - /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. - private static let rowSeparatorLeadingInset: CGFloat = 72 + /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. + private static let rowSeparatorLeadingInset: CGFloat = 72 - private var loadingBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { } header: { pinnedFilterHeader } - } - } - .overlay { ProgressView() } + private var loadingBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section {} header: { pinnedFilterHeader } + } } - - private var loadedBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - if filteredNodes.isEmpty { - emptyState - } else { - rows - } - } header: { - pinnedFilterHeader - } - } + .overlay { ProgressView() } + } + + private var loadedBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + if filteredNodes.isEmpty { + emptyState + } else { + rows + } + } header: { + pinnedFilterHeader } + } } - - private var rows: some View { - ForEach(Array(filteredNodes.enumerated()), id: \.element.id) { index, node in - DiscoveryNodeRow( - node: node, - isAdded: viewModel.isAdded(node), - isAdding: addingNodeID == node.id, - onAdd: { addNode(node) }, - onDelete: { - Task { - await viewModel.deleteDiscoveredNode(node) - } - } - ) - .transition(.opacity) - if index < filteredNodes.count - 1 { - Divider().padding(.leading, Self.rowSeparatorLeadingInset) - } + } + + private var rows: some View { + ForEach(Array(filteredNodes.enumerated()), id: \.element.id) { index, node in + DiscoveryNodeRow( + node: node, + isAdded: viewModel.isAdded(node), + isAdding: addingNodeID == node.id, + onAdd: { addNode(node) }, + onDelete: { + Task { + await viewModel.deleteDiscoveredNode(node) + } } + ) + .transition(.opacity) + if index < filteredNodes.count - 1 { + Divider().padding(.leading, Self.rowSeparatorLeadingInset) + } } - - private func loadDiscoveredNodes() async { - guard let radioID = appState.connectedDevice?.radioID else { return } - viewModel.configure(dataStore: { [appState] in appState.offlineDataStore }) - await viewModel.loadDiscoveredNodes(radioID: radioID) - } - - private func addNode(_ node: DiscoveredNodeDTO) { - guard let contactService = appState.services?.contactService else { return } - - addingNodeID = node.id - Task { - do { - let frame = ContactFrame( - publicKey: node.publicKey, - type: node.nodeType, - flags: 0, - outPathLength: node.outPathLength, - outPath: node.outPath, - name: node.name, - lastAdvertTimestamp: node.lastAdvertTimestamp, - latitude: node.latitude, - longitude: node.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) - ) - try await contactService.addOrUpdateContact(radioID: node.radioID, contact: frame) - await viewModel.loadDiscoveredNodes(radioID: node.radioID) - } catch ContactServiceError.contactTableFull { - let maxContacts = appState.connectedDevice?.maxContacts - if let maxContacts { - viewModel.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) - } else { - viewModel.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFullSimple - } - } catch { - viewModel.errorMessage = error.userFacingMessage - } - addingNodeID = nil + } + + private func loadDiscoveredNodes() async { + guard let radioID = appState.connectedDevice?.radioID else { return } + viewModel.configure(dataStore: { [appState] in appState.offlineDataStore }) + await viewModel.loadDiscoveredNodes(radioID: radioID) + } + + private func addNode(_ node: DiscoveredNodeDTO) { + guard let contactService = appState.services?.contactService else { return } + + addingNodeID = node.id + Task { + do { + let frame = ContactFrame( + publicKey: node.publicKey, + type: node.nodeType, + flags: 0, + outPathLength: node.outPathLength, + outPath: node.outPath, + name: node.name, + lastAdvertTimestamp: node.lastAdvertTimestamp, + latitude: node.latitude, + longitude: node.longitude, + lastModified: UInt32(Date().timeIntervalSince1970) + ) + try await contactService.addOrUpdateContact(radioID: node.radioID, contact: frame) + await viewModel.loadDiscoveredNodes(radioID: node.radioID) + } catch ContactServiceError.contactTableFull { + let maxContacts = appState.connectedDevice?.maxContacts + if let maxContacts { + viewModel.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) + } else { + viewModel.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFullSimple } + } catch { + viewModel.errorMessage = error.userFacingMessage + } + addingNodeID = nil } + } - private func clearAllDiscoveredNodes() async { - guard let radioID = appState.connectedDevice?.radioID else { return } - await viewModel.clearAllDiscoveredNodes(radioID: radioID) + private func clearAllDiscoveredNodes() async { + guard let radioID = appState.connectedDevice?.radioID else { return } + await viewModel.clearAllDiscoveredNodes(radioID: radioID) - AccessibilityNotification.Announcement(L10n.Contacts.Contacts.Discovery.clearedAllNodes).post() - } + AccessibilityNotification.Announcement(L10n.Contacts.Contacts.Discovery.clearedAllNodes).post() + } } // MARK: - Empty View private struct DiscoveryEmptyView: View { - var body: some View { - ContentUnavailableView( - L10n.Contacts.Contacts.Discovery.Empty.title, - systemImage: "antenna.radiowaves.left.and.right", - description: Text(L10n.Contacts.Contacts.Discovery.Empty.description) - ) - } + var body: some View { + ContentUnavailableView( + L10n.Contacts.Contacts.Discovery.Empty.title, + systemImage: "antenna.radiowaves.left.and.right", + description: Text(L10n.Contacts.Contacts.Discovery.Empty.description) + ) + } } // MARK: - Search Empty View private struct DiscoverySearchEmptyView: View { - let searchText: String - - var body: some View { - ContentUnavailableView( - L10n.Contacts.Contacts.Discovery.Empty.Search.title, - systemImage: "magnifyingglass", - description: Text(L10n.Contacts.Contacts.Discovery.Empty.Search.description(searchText)) - ) - } + let searchText: String + + var body: some View { + ContentUnavailableView( + L10n.Contacts.Contacts.Discovery.Empty.Search.title, + systemImage: "magnifyingglass", + description: Text(L10n.Contacts.Contacts.Discovery.Empty.Search.description(searchText)) + ) + } } // MARK: - Row Layout private enum DiscoveryListLayout { - static let rowHorizontalPadding: CGFloat = 16 + static let rowHorizontalPadding: CGFloat = 16 } // MARK: - Node Row private struct DiscoveryNodeRow: View { - @Environment(\.appState) private var appState - let node: DiscoveredNodeDTO - let isAdded: Bool - let isAdding: Bool - let onAdd: () -> Void - let onDelete: () -> Void - - var body: some View { - HStack { - avatarView - - VStack(alignment: .leading, spacing: 2) { - Text(node.name) - .font(.body) - .bold() - - Text(node.publicKey.uppercaseHexString()) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - HStack(spacing: 4) { - Text(nodeTypeLabel) - - if node.hasLocation { - Text("·") - - Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") - .labelStyle(.iconOnly) - .foregroundStyle(.green) - - if let distance = distanceToNode { - Text(distance) - } - } - } - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 4) { - Image(systemName: "arrowshape.bounce.right") - if !node.isFloodRouted, node.pathHopCount == 0 { - Text(L10n.Contacts.Contacts.Route.direct) - } else if let hops = node.displayedHopCount { - Text("\(hops)") - - let pathNodes = node.pathNodesHex - if !node.isFloodRouted, !pathNodes.isEmpty { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - Text(formattedPath(pathNodes)) - .monospaced() - } - } else { - Text(L10n.Contacts.Contacts.Route.flood) - } - } - .font(.caption2) - .foregroundStyle(.secondary) - } - - Spacer() - - RelativeTimestampText(timestamp: node.lastAdvertTimestamp) - - if isAdded { - Button(L10n.Contacts.Contacts.Discovery.added) {} - .buttonStyle(.bordered) - .disabled(true) - .accessibilityLabel(L10n.Contacts.Contacts.Discovery.addedAccessibility) - } else { - Button(L10n.Contacts.Contacts.Discovery.add) { - onAdd() - } - .buttonStyle(.borderedProminent) - .disabled(isAdding) + @Environment(\.appState) private var appState + let node: DiscoveredNodeDTO + let isAdded: Bool + let isAdding: Bool + let onAdd: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack { + avatarView + + VStack(alignment: .leading, spacing: 2) { + Text(node.name) + .font(.body) + .bold() + + Text(node.publicKey.uppercaseHexString()) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + HStack(spacing: 4) { + Text(nodeTypeLabel) + + if node.hasLocation { + Text("·") + + Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") + .labelStyle(.iconOnly) + .foregroundStyle(.green) + + if let distance = distanceToNode { + Text(distance) } + } } - .padding(.horizontal, DiscoveryListLayout.rowHorizontalPadding) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - .contextMenu { - Button(role: .destructive) { - onDelete() - } label: { - Label(L10n.Contacts.Contacts.Discovery.remove, systemImage: "trash") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 4) { + Image(systemName: "arrowshape.bounce.right") + if !node.isFloodRouted, node.pathHopCount == 0 { + Text(L10n.Contacts.Contacts.Route.direct) + } else if let hops = node.displayedHopCount { + Text("\(hops)") + + let pathNodes = node.pathNodesHex + if !node.isFloodRouted, !pathNodes.isEmpty { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + Text(formattedPath(pathNodes)) + .monospaced() } + } else { + Text(L10n.Contacts.Contacts.Route.flood) + } } - } - - @ViewBuilder - private var avatarView: some View { - switch node.nodeType { - case .chat: - ContactAvatar(name: node.name, size: 44) - case .repeater: - NodeAvatar(publicKey: node.publicKey, role: .repeater, size: 44) - case .room: - NodeAvatar(publicKey: node.publicKey, role: .roomServer, size: 44) + .font(.caption2) + .foregroundStyle(.secondary) + } + + Spacer() + + RelativeTimestampText(timestamp: node.lastAdvertTimestamp) + + if isAdded { + Button(L10n.Contacts.Contacts.Discovery.added) {} + .buttonStyle(.bordered) + .disabled(true) + .accessibilityLabel(L10n.Contacts.Contacts.Discovery.addedAccessibility) + } else { + Button(L10n.Contacts.Contacts.Discovery.add) { + onAdd() } + .buttonStyle(.borderedProminent) + .disabled(isAdding) + } } - - private var nodeTypeLabel: String { - switch node.nodeType { - case .chat: return L10n.Contacts.Contacts.NodeKind.chat - case .repeater: return L10n.Contacts.Contacts.NodeKind.repeater - case .room: return L10n.Contacts.Contacts.NodeKind.room - } + .padding(.horizontal, DiscoveryListLayout.rowHorizontalPadding) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .contextMenu { + Button(role: .destructive) { + onDelete() + } label: { + Label(L10n.Contacts.Contacts.Discovery.remove, systemImage: "trash") + } } - - private func formattedPath(_ nodes: [String]) -> String { - if nodes.count > 6 { - let first = nodes.prefix(3).joined(separator: ",") - let last = nodes.suffix(3).joined(separator: ",") - return "\(first)…\(last)" - } - return nodes.joined(separator: ",") + } + + @ViewBuilder + private var avatarView: some View { + switch node.nodeType { + case .chat: + ContactAvatar(name: node.name, size: 44) + case .repeater: + NodeAvatar(publicKey: node.publicKey, role: .repeater, size: 44) + case .room: + NodeAvatar(publicKey: node.publicKey, role: .roomServer, size: 44) } + } - private var distanceToNode: String? { - guard let userLocation = appState.bestAvailableLocation, - node.hasLocation else { return nil } + private var nodeTypeLabel: String { + switch node.nodeType { + case .chat: L10n.Contacts.Contacts.NodeKind.chat + case .repeater: L10n.Contacts.Contacts.NodeKind.repeater + case .room: L10n.Contacts.Contacts.NodeKind.room + } + } - let nodeLocation = CLLocation( - latitude: node.latitude, - longitude: node.longitude - ) - let meters = userLocation.distance(from: nodeLocation) - let measurement = Measurement(value: meters, unit: UnitLength.meters) - - let formattedDistance = measurement.formatted(.measurement( - width: .abbreviated, - usage: .road - )) - return L10n.Contacts.Contacts.Row.away(formattedDistance) + private func formattedPath(_ nodes: [String]) -> String { + if nodes.count > 6 { + let first = nodes.prefix(3).joined(separator: ",") + let last = nodes.suffix(3).joined(separator: ",") + return "\(first)…\(last)" } + return nodes.joined(separator: ",") + } + + private var distanceToNode: String? { + guard let userLocation = appState.bestAvailableLocation, + node.hasLocation else { return nil } + + let nodeLocation = CLLocation( + latitude: node.latitude, + longitude: node.longitude + ) + let meters = userLocation.distance(from: nodeLocation) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + + let formattedDistance = measurement.formatted(.measurement( + width: .abbreviated, + usage: .road + )) + return L10n.Contacts.Contacts.Row.away(formattedDistance) + } } // MARK: - Sort Menu private struct DiscoverySortMenu: View { - @Binding var sortOrder: NodeSortOrder - - var body: some View { - Menu { - ForEach(NodeSortOrder.allCases, id: \.self) { order in - Button { - sortOrder = order - } label: { - if sortOrder == order { - Label(order.localizedTitle, systemImage: "checkmark") - } else { - Text(order.localizedTitle) - } - } - } + @Binding var sortOrder: NodeSortOrder + + var body: some View { + Menu { + ForEach(NodeSortOrder.allCases, id: \.self) { order in + Button { + sortOrder = order } label: { - Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") + if sortOrder == order { + Label(order.localizedTitle, systemImage: "checkmark") + } else { + Text(order.localizedTitle) + } } - .liquidGlassSecondaryButtonStyle() - .accessibilityLabel(L10n.Contacts.Contacts.Discovery.sortMenu) - .accessibilityHint(L10n.Contacts.Contacts.Discovery.sortMenuHint) + } + } label: { + Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") } + .liquidGlassSecondaryButtonStyle() + .accessibilityLabel(L10n.Contacts.Contacts.Discovery.sortMenu) + .accessibilityHint(L10n.Contacts.Contacts.Discovery.sortMenuHint) + } } // MARK: - More Menu private struct DiscoveryMoreMenu: View { - let isEmpty: Bool - @Binding var showClearConfirmation: Bool - - var body: some View { - Menu { - Button(role: .destructive) { - showClearConfirmation = true - } label: { - Label(L10n.Contacts.Contacts.Discovery.clear, systemImage: "trash") - } - .disabled(isEmpty) - } label: { - Label(L10n.Contacts.Contacts.Discovery.menu, systemImage: "ellipsis.circle") - } - .liquidGlassSecondaryButtonStyle() + let isEmpty: Bool + @Binding var showClearConfirmation: Bool + + var body: some View { + Menu { + Button(role: .destructive) { + showClearConfirmation = true + } label: { + Label(L10n.Contacts.Contacts.Discovery.clear, systemImage: "trash") + } + .disabled(isEmpty) + } label: { + Label(L10n.Contacts.Contacts.Discovery.menu, systemImage: "ellipsis.circle") } + .liquidGlassSecondaryButtonStyle() + } } #Preview { - NavigationStack { - DiscoveryView() - } - .environment(\.appState, AppState()) + NavigationStack { + DiscoveryView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/DiscoveryViewModel.swift b/MC1/Views/Contacts/DiscoveryViewModel.swift index de02385f..da0a4a50 100644 --- a/MC1/Views/Contacts/DiscoveryViewModel.swift +++ b/MC1/Views/Contacts/DiscoveryViewModel.swift @@ -1,205 +1,206 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// Segment for the discovery picker enum DiscoverSegment: String, CaseIterable { - case all - case contacts - case repeaters - case rooms - - var localizedTitle: String { - switch self { - case .all: L10n.Contacts.Contacts.Discovery.Segment.all - case .contacts: L10n.Contacts.Contacts.Discovery.Segment.contacts - case .repeaters: L10n.Contacts.Contacts.Discovery.Segment.repeaters - case .rooms: L10n.Contacts.Contacts.Discovery.Segment.rooms - } + case all + case contacts + case repeaters + case rooms + + var localizedTitle: String { + switch self { + case .all: L10n.Contacts.Contacts.Discovery.Segment.all + case .contacts: L10n.Contacts.Contacts.Discovery.Segment.contacts + case .repeaters: L10n.Contacts.Contacts.Discovery.Segment.repeaters + case .rooms: L10n.Contacts.Contacts.Discovery.Segment.rooms } + } } /// ViewModel for discovery view @Observable @MainActor final class DiscoveryViewModel { + // MARK: - Properties - // MARK: - Properties - - /// Discovered nodes from the mesh network - var discoveredNodes: [DiscoveredNodeDTO] = [] + /// Discovered nodes from the mesh network + var discoveredNodes: [DiscoveredNodeDTO] = [] - /// Public keys of contacts that have been added - var addedPublicKeys: Set = [] + /// Public keys of contacts that have been added + var addedPublicKeys: Set = [] - /// Loading state - var isLoading = false + /// Loading state + var isLoading = false - /// Whether data has been loaded at least once (prevents empty state flash) - var hasLoadedOnce = false + /// Whether data has been loaded at least once (prevents empty state flash) + var hasLoadedOnce = false - /// Error message to display - var errorMessage: String? + /// Error message to display + var errorMessage: String? - // MARK: - Dependencies + // MARK: - Dependencies - private var dataStoreProvider: @MainActor () -> DataStore? = { nil } - private var dataStore: DataStore? { dataStoreProvider() } + private var dataStoreProvider: @MainActor () -> DataStore? = { nil } + private var dataStore: DataStore? { + dataStoreProvider() + } - /// Temporary Discover trace; filter by category "discover-trace". Remove - /// once the "no new nodes after clear" report is closed. - private let discoverTrace = PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + /// Temporary Discover trace; filter by category "discover-trace". Remove + /// once the "no new nodes after clear" report is closed. + private let discoverTrace = PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - // MARK: - Initialization + // MARK: - Initialization - init() {} + init() {} - /// Configure with the data store this view model uses; a provider returning nil mirrors a disconnected state. - func configure(dataStore: @escaping @MainActor () -> DataStore?) { - dataStoreProvider = dataStore - } - - // MARK: - Load Nodes + /// Configure with the data store this view model uses; a provider returning nil mirrors a disconnected state. + func configure(dataStore: @escaping @MainActor () -> DataStore?) { + dataStoreProvider = dataStore + } - func loadDiscoveredNodes(radioID: UUID) async { - guard let dataStore else { return } + // MARK: - Load Nodes - isLoading = true - errorMessage = nil + func loadDiscoveredNodes(radioID: UUID) async { + guard let dataStore else { return } - do { - let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) + isLoading = true + errorMessage = nil - // Single batch query for all contact public keys (O(1) vs O(N)) - let addedKeys = try await dataStore.fetchContactPublicKeys(radioID: radioID) + do { + let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) - discoveredNodes = nodes - addedPublicKeys = addedKeys - discoverTrace.info("B4 view reload loaded=\(nodes.count) addedKeys=\(addedKeys.count) radio=\(radioID)") - } catch { - errorMessage = error.userFacingMessage - discoverTrace.error("B4 view reload FAILED radio=\(radioID): \(error.localizedDescription)") - } + // Single batch query for all contact public keys (O(1) vs O(N)) + let addedKeys = try await dataStore.fetchContactPublicKeys(radioID: radioID) - hasLoadedOnce = true - isLoading = false + discoveredNodes = nodes + addedPublicKeys = addedKeys + discoverTrace.info("B4 view reload loaded=\(nodes.count) addedKeys=\(addedKeys.count) radio=\(radioID)") + } catch { + errorMessage = error.userFacingMessage + discoverTrace.error("B4 view reload FAILED radio=\(radioID): \(error.localizedDescription)") } - // MARK: - Added State - - /// Check if a node has already been added as a contact - func isAdded(_ node: DiscoveredNodeDTO) -> Bool { - addedPublicKeys.contains(node.publicKey) - } + hasLoadedOnce = true + isLoading = false + } - // MARK: - Delete + // MARK: - Added State - func deleteDiscoveredNode(_ node: DiscoveredNodeDTO) async { - guard let dataStore else { return } + /// Check if a node has already been added as a contact + func isAdded(_ node: DiscoveredNodeDTO) -> Bool { + addedPublicKeys.contains(node.publicKey) + } - // Remove from UI immediately - discoveredNodes.removeAll { $0.id == node.id } + // MARK: - Delete - do { - try await dataStore.deleteDiscoveredNode(id: node.id) - } catch { - errorMessage = error.userFacingMessage - } - } + func deleteDiscoveredNode(_ node: DiscoveredNodeDTO) async { + guard let dataStore else { return } - func clearAllDiscoveredNodes(radioID: UUID) async { - guard let dataStore else { return } + // Remove from UI immediately + discoveredNodes.removeAll { $0.id == node.id } - do { - try await dataStore.clearDiscoveredNodes(radioID: radioID) - discoveredNodes = [] - } catch { - errorMessage = error.userFacingMessage - } + do { + try await dataStore.deleteDiscoveredNode(id: node.id) + } catch { + errorMessage = error.userFacingMessage } + } - // MARK: - Filtering - - func filteredNodes( - searchText: String, - segment: DiscoverSegment, - sortOrder: NodeSortOrder, - userLocation: CLLocation? - ) -> [DiscoveredNodeDTO] { - var result = discoveredNodes - - if searchText.isEmpty { - switch segment { - case .all: - break - case .contacts: - result = result.filter { $0.nodeType == .chat } - case .repeaters: - result = result.filter { $0.nodeType == .repeater } - case .rooms: - result = result.filter { $0.nodeType == .room } - } - } else { - result = result.filter { node in - node.name.localizedStandardContains(searchText) - || node.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) - } - } + func clearAllDiscoveredNodes(radioID: UUID) async { + guard let dataStore else { return } - return sorted(result, by: sortOrder, userLocation: userLocation) + do { + try await dataStore.clearDiscoveredNodes(radioID: radioID) + discoveredNodes = [] + } catch { + errorMessage = error.userFacingMessage + } + } + + // MARK: - Filtering + + func filteredNodes( + searchText: String, + segment: DiscoverSegment, + sortOrder: NodeSortOrder, + userLocation: CLLocation? + ) -> [DiscoveredNodeDTO] { + var result = discoveredNodes + + if searchText.isEmpty { + switch segment { + case .all: + break + case .contacts: + result = result.filter { $0.nodeType == .chat } + case .repeaters: + result = result.filter { $0.nodeType == .repeater } + case .rooms: + result = result.filter { $0.nodeType == .room } + } + } else { + result = result.filter { node in + node.name.localizedStandardContains(searchText) + || node.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) + } } - // MARK: - Sorting - - private func sorted( - _ nodes: [DiscoveredNodeDTO], - by order: NodeSortOrder, - userLocation: CLLocation? - ) -> [DiscoveredNodeDTO] { - switch order { - case .lastHeard: - return nodes.sorted { $0.lastAdvertTimestamp > $1.lastAdvertTimestamp } - case .name: - return nodes.sorted { - $0.name.localizedCompare($1.name) == .orderedAscending - } - case .distance: - return nodes.sorted { orderedByDistanceThenName($0, $1, from: userLocation) } - case .hops: - return nodes.sorted { lhs, rhs in - let lhsHops = lhs.displayedHopCount - let rhsHops = rhs.displayedHopCount - // A nil hop count (flood-routed and never heard via advert) sorts to the bottom. - if (lhsHops == nil) != (rhsHops == nil) { - return lhsHops != nil - } - if let lhsHops, let rhsHops, lhsHops != rhsHops { - return lhsHops < rhsHops - } - return orderedByDistanceThenName(lhs, rhs, from: userLocation) - } + return sorted(result, by: sortOrder, userLocation: userLocation) + } + + // MARK: - Sorting + + private func sorted( + _ nodes: [DiscoveredNodeDTO], + by order: NodeSortOrder, + userLocation: CLLocation? + ) -> [DiscoveredNodeDTO] { + switch order { + case .lastHeard: + nodes.sorted { $0.lastAdvertTimestamp > $1.lastAdvertTimestamp } + case .name: + nodes.sorted { + $0.name.localizedCompare($1.name) == .orderedAscending + } + case .distance: + nodes.sorted { orderedByDistanceThenName($0, $1, from: userLocation) } + case .hops: + nodes.sorted { lhs, rhs in + let lhsHops = lhs.displayedHopCount + let rhsHops = rhs.displayedHopCount + // A nil hop count (flood-routed and never heard via advert) sorts to the bottom. + if (lhsHops == nil) != (rhsHops == nil) { + return lhsHops != nil } + if let lhsHops, let rhsHops, lhsHops != rhsHops { + return lhsHops < rhsHops + } + return orderedByDistanceThenName(lhs, rhs, from: userLocation) + } } - - /// Located nodes first, then nearest to `userLocation`, then by name. Falls back to name when - /// there is no user location, neither node has coordinates, or the distances tie. - private func orderedByDistanceThenName( - _ lhs: DiscoveredNodeDTO, - _ rhs: DiscoveredNodeDTO, - from userLocation: CLLocation? - ) -> Bool { - if let userLocation { - if lhs.hasLocation != rhs.hasLocation { - return lhs.hasLocation - } - if lhs.hasLocation { - let lhsDistance = CLLocation(latitude: lhs.latitude, longitude: lhs.longitude).distance(from: userLocation) - let rhsDistance = CLLocation(latitude: rhs.latitude, longitude: rhs.longitude).distance(from: userLocation) - if lhsDistance != rhsDistance { - return lhsDistance < rhsDistance - } - } + } + + /// Located nodes first, then nearest to `userLocation`, then by name. Falls back to name when + /// there is no user location, neither node has coordinates, or the distances tie. + private func orderedByDistanceThenName( + _ lhs: DiscoveredNodeDTO, + _ rhs: DiscoveredNodeDTO, + from userLocation: CLLocation? + ) -> Bool { + if let userLocation { + if lhs.hasLocation != rhs.hasLocation { + return lhs.hasLocation + } + if lhs.hasLocation { + let lhsDistance = CLLocation(latitude: lhs.latitude, longitude: lhs.longitude).distance(from: userLocation) + let rhsDistance = CLLocation(latitude: rhs.latitude, longitude: rhs.longitude).distance(from: userLocation) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance } - return lhs.name.localizedCompare(rhs.name) == .orderedAscending + } } + return lhs.name.localizedCompare(rhs.name) == .orderedAscending + } } diff --git a/MC1/Views/Contacts/NodeSegmentPicker.swift b/MC1/Views/Contacts/NodeSegmentPicker.swift index 674c9a6b..9b57ff46 100644 --- a/MC1/Views/Contacts/NodeSegmentPicker.swift +++ b/MC1/Views/Contacts/NodeSegmentPicker.swift @@ -2,15 +2,15 @@ import SwiftUI /// Pinned glass filter bar for the Nodes tab. struct NodeSegmentPicker: View { - @Binding var selection: NodeSegment - let isSearching: Bool + @Binding var selection: NodeSegment + let isSearching: Bool - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: isSearching, - pickerLabel: L10n.Contacts.Contacts.Segment.pickerLabel, - title: { $0.localizedTitle } - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: isSearching, + pickerLabel: L10n.Contacts.Contacts.Segment.pickerLabel, + title: { $0.localizedTitle } + ) + } } diff --git a/MC1/Views/Contacts/ScanContactQRView.swift b/MC1/Views/Contacts/ScanContactQRView.swift index f1e8b323..a571ad1a 100644 --- a/MC1/Views/Contacts/ScanContactQRView.swift +++ b/MC1/Views/Contacts/ScanContactQRView.swift @@ -1,213 +1,213 @@ -import SwiftUI -import VisionKit import MC1Services import os +import SwiftUI +import VisionKit /// View for scanning a contact QR code to import struct ScanContactQRView: View { - @Environment(\.appState) private var appState - @Environment(\.openURL) private var openURL - @Environment(\.dismiss) private var dismiss - - let onScan: (String, Data) -> Void - - @State private var isImporting = false - @State private var errorMessage: String? - @State private var cameraPermissionDenied = false - @State private var scanSuccessTrigger = false - - private let logger = Logger(subsystem: "com.mc1", category: "ScanContactQRView") - - // MARK: - Constants - - private enum Constants { - static let scanFrameSize: CGFloat = 250 - static let overlayOpacity: CGFloat = 0.6 - static let errorOpacity: CGFloat = 0.8 - static let bottomPadding: CGFloat = 50 + @Environment(\.appState) private var appState + @Environment(\.openURL) private var openURL + @Environment(\.dismiss) private var dismiss + + let onScan: (String, Data) -> Void + + @State private var isImporting = false + @State private var errorMessage: String? + @State private var cameraPermissionDenied = false + @State private var scanSuccessTrigger = false + + private let logger = Logger(subsystem: "com.mc1", category: "ScanContactQRView") + + // MARK: - Constants + + private enum Constants { + static let scanFrameSize: CGFloat = 250 + static let overlayOpacity: CGFloat = 0.6 + static let errorOpacity: CGFloat = 0.8 + static let bottomPadding: CGFloat = 50 + } + + var body: some View { + Group { + if cameraPermissionDenied { + cameraPermissionDeniedView + } else { + scannerView + } } - - var body: some View { - Group { - if cameraPermissionDenied { - cameraPermissionDeniedView - } else { - scannerView - } + .navigationTitle(L10n.Contacts.Contacts.Scan.title) + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Scanner View + + private var scannerView: some View { + ZStack { + if QRDataScannerView.isSupported, QRDataScannerView.isAvailable { + QRDataScannerView { result in + handleScanResult(result) + } onPermissionDenied: { + cameraPermissionDenied = true } - .navigationTitle(L10n.Contacts.Contacts.Scan.title) - .navigationBarTitleDisplayMode(.inline) - } - - // MARK: - Scanner View - - private var scannerView: some View { - ZStack { - if QRDataScannerView.isSupported && QRDataScannerView.isAvailable { - QRDataScannerView { result in - handleScanResult(result) - } onPermissionDenied: { - cameraPermissionDenied = true - } - } else { - // Fallback for unsupported devices - ContentUnavailableView( - L10n.Contacts.Contacts.Scan.Unavailable.title, - systemImage: "qrcode.viewfinder", - description: Text(L10n.Contacts.Contacts.Scan.Unavailable.description) - ) - } - - // Overlay with scan frame - VStack { - Spacer() - - RoundedRectangle(cornerRadius: 20) - .stroke(.white, lineWidth: 3) - .frame(width: Constants.scanFrameSize, height: Constants.scanFrameSize) - - Spacer() - - if isImporting { - VStack(spacing: 12) { - ProgressView() - Text(L10n.Contacts.Contacts.Scan.importing) - } - .font(.subheadline) - .foregroundStyle(.white) - .padding() - .background(.black.opacity(Constants.overlayOpacity), in: .capsule) - .padding(.bottom, Constants.bottomPadding) - } else if let errorMessage { - Button { - self.errorMessage = nil - } label: { - Text(errorMessage) - .font(.subheadline) - .foregroundStyle(.white) - .padding() - .background(.red.opacity(Constants.errorOpacity), in: .capsule) - } - .buttonStyle(.plain) - .padding(.bottom, Constants.bottomPadding) - } else { - Text(L10n.Contacts.Contacts.Scan.instruction) - .font(.subheadline) - .foregroundStyle(.white) - .padding() - .background(.black.opacity(Constants.overlayOpacity), in: .capsule) - .padding(.bottom, Constants.bottomPadding) - } - } + } else { + // Fallback for unsupported devices + ContentUnavailableView( + L10n.Contacts.Contacts.Scan.Unavailable.title, + systemImage: "qrcode.viewfinder", + description: Text(L10n.Contacts.Contacts.Scan.Unavailable.description) + ) + } + + // Overlay with scan frame + VStack { + Spacer() + + RoundedRectangle(cornerRadius: 20) + .stroke(.white, lineWidth: 3) + .frame(width: Constants.scanFrameSize, height: Constants.scanFrameSize) + + Spacer() + + if isImporting { + VStack(spacing: 12) { + ProgressView() + Text(L10n.Contacts.Contacts.Scan.importing) + } + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.black.opacity(Constants.overlayOpacity), in: .capsule) + .padding(.bottom, Constants.bottomPadding) + } else if let errorMessage { + Button { + self.errorMessage = nil + } label: { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.red.opacity(Constants.errorOpacity), in: .capsule) + } + .buttonStyle(.plain) + .padding(.bottom, Constants.bottomPadding) + } else { + Text(L10n.Contacts.Contacts.Scan.instruction) + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.black.opacity(Constants.overlayOpacity), in: .capsule) + .padding(.bottom, Constants.bottomPadding) } - .sensoryFeedback(.success, trigger: scanSuccessTrigger) - .ignoresSafeArea() + } } - - // MARK: - Permission Denied View - - private var cameraPermissionDeniedView: some View { - VStack(spacing: 20) { - Image(systemName: "camera.fill") - .font(.system(size: 60)) - .foregroundStyle(.secondary) - - Text(L10n.Contacts.Contacts.Scan.Permission.title) - .font(.title2) - .bold() - - Text(L10n.Contacts.Contacts.Scan.Permission.description) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Button(L10n.Contacts.Contacts.List.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } - .buttonStyle(.borderedProminent) + .sensoryFeedback(.success, trigger: scanSuccessTrigger) + .ignoresSafeArea() + } + + // MARK: - Permission Denied View + + private var cameraPermissionDeniedView: some View { + VStack(spacing: 20) { + Image(systemName: "camera.fill") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + + Text(L10n.Contacts.Contacts.Scan.Permission.title) + .font(.title2) + .bold() + + Text(L10n.Contacts.Contacts.Scan.Permission.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button(L10n.Contacts.Contacts.List.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) } - .padding() + } + .buttonStyle(.borderedProminent) } + .padding() + } - // MARK: - Private Methods + // MARK: - Private Methods - private func handleScanResult(_ result: String) { - guard !isImporting else { return } + private func handleScanResult(_ result: String) { + guard !isImporting else { return } - guard let parsed = MeshCoreURLParser.parseContactURL(result) else { - logger.error("Invalid QR code format: \(result)") - errorMessage = L10n.Contacts.Contacts.Scan.Error.invalidFormat - return - } + guard let parsed = MeshCoreURLParser.parseContactURL(result) else { + logger.error("Invalid QR code format: \(result)") + errorMessage = L10n.Contacts.Contacts.Scan.Error.invalidFormat + return + } - scanSuccessTrigger.toggle() + scanSuccessTrigger.toggle() - // Claim the import synchronously so a second DataScanner callback can't slip - // past the guard before the async import flips the flag. - isImporting = true - errorMessage = nil + // Claim the import synchronously so a second DataScanner callback can't slip + // past the guard before the async import flips the flag. + isImporting = true + errorMessage = nil - Task { - await importContact(parsed) - } + Task { + await importContact(parsed) + } + } + + @MainActor + private func importContact(_ contact: MeshCoreURLParser.ContactResult) async { + guard let services = appState.services, + let device = appState.connectedDevice else { + logger.error("Services or device not available") + errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected + isImporting = false + return } - @MainActor - private func importContact(_ contact: MeshCoreURLParser.ContactResult) async { - guard let services = appState.services, - let device = appState.connectedDevice else { - logger.error("Services or device not available") - errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected - isImporting = false - return - } - - let radioID = device.radioID - let maxContacts = device.maxContacts - - do { - let currentTimestamp = UInt32(Date().timeIntervalSince1970) - - let contactFrame = ContactFrame( - publicKey: contact.publicKey, - type: contact.contactType, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: contact.name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: currentTimestamp - ) - - logger.info("Importing contact: \(contact.name) (\(contact.publicKey.uppercaseHexString()))") - try await services.contactService.addOrUpdateContact(radioID: radioID, contact: contactFrame) - logger.info("Contact imported successfully") - - // Reset state and dismiss before calling completion handler - isImporting = false - dismiss() - - onScan(contact.name, contact.publicKey) - } catch ContactServiceError.contactTableFull { - logger.error("Node list is full") - errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) - isImporting = false - } catch { - logger.error("Failed to import contact: \(error.localizedDescription)") - errorMessage = L10n.Contacts.Contacts.Scan.Error.importFailed(error.userFacingMessage) - isImporting = false - } + let radioID = device.radioID + let maxContacts = device.maxContacts + + do { + let currentTimestamp = UInt32(Date().timeIntervalSince1970) + + let contactFrame = ContactFrame( + publicKey: contact.publicKey, + type: contact.contactType, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: contact.name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: currentTimestamp + ) + + logger.info("Importing contact: \(contact.name) (\(contact.publicKey.uppercaseHexString()))") + try await services.contactService.addOrUpdateContact(radioID: radioID, contact: contactFrame) + logger.info("Contact imported successfully") + + // Reset state and dismiss before calling completion handler + isImporting = false + dismiss() + + onScan(contact.name, contact.publicKey) + } catch ContactServiceError.contactTableFull { + logger.error("Node list is full") + errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) + isImporting = false + } catch { + logger.error("Failed to import contact: \(error.localizedDescription)") + errorMessage = L10n.Contacts.Contacts.Scan.Error.importFailed(error.userFacingMessage) + isImporting = false } + } } #Preview { - NavigationStack { - ScanContactQRView { _, _ in } - } - .environment(\.appState, AppState()) + NavigationStack { + ScanContactQRView { _, _ in } + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/MainSidebarView.swift b/MC1/Views/MainSidebarView.swift index fbdc5713..93d5c0c6 100644 --- a/MC1/Views/MainSidebarView.swift +++ b/MC1/Views/MainSidebarView.swift @@ -1,268 +1,268 @@ -import SwiftUI import MC1Services +import SwiftUI /// The iPad sidebar shell: a sidebar selecting the active `AppTab`, plus that section's columns /// (list sections are three-column, Map is two-column). Per-section selection that must survive the /// section-switch teardown lives in `NavigationCoordinator`; the shared view models are hosted here /// so each section's content and detail columns read one instance. struct MainSidebarView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var columnVisibility: NavigationSplitViewVisibility = .all - @State private var showingDeviceSelection = false + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var showingDeviceSelection = false - /// Moves the VoiceOver cursor into the active section's content when the sidebar auto-collapses - /// on selection. The collapse removes the just-tapped sidebar row, which would otherwise strand - /// VoiceOver focus off-screen; setting this to the selected tab relocates focus to that section's - /// content (and VoiceOver reads its label). A per-section value rather than a Bool guarantees each - /// switch is a distinct change so focus re-moves every time; it is a no-op when VoiceOver is off. - @AccessibilityFocusState private var focusedColumn: AppTab? + /// Moves the VoiceOver cursor into the active section's content when the sidebar auto-collapses + /// on selection. The collapse removes the just-tapped sidebar row, which would otherwise strand + /// VoiceOver focus off-screen; setting this to the selected tab relocates focus to that section's + /// content (and VoiceOver reads its label). A per-section value rather than a Bool guarantees each + /// switch is a distinct change so focus re-moves every time; it is a no-op when VoiceOver is off. + @AccessibilityFocusState private var focusedColumn: AppTab? - // Shared Chats view model, hosted once so content + detail columns share it. - @State private var chatViewModel = ChatViewModel() + /// Shared Chats view model, hosted once so content + detail columns share it. + @State private var chatViewModel = ChatViewModel() - // Shared Line of Sight view model, so the Tools content panel and detail map stay in sync. - @State private var lineOfSightViewModel = LineOfSightViewModel() + /// Shared Line of Sight view model, so the Tools content panel and detail map stay in sync. + @State private var lineOfSightViewModel = LineOfSightViewModel() - // Shared Settings state, mirroring SettingsView's regular-width path. - @State private var settingsShowingDeviceSelection = false - @State private var settingsDemoModeManager = DemoModeManager.shared + // Shared Settings state, mirroring SettingsView's regular-width path. + @State private var settingsShowingDeviceSelection = false + @State private var settingsDemoModeManager = DemoModeManager.shared - private static let lineOfSightPanelWidthMin: CGFloat = 380 - private static let lineOfSightPanelWidthIdeal: CGFloat = 440 - private static let lineOfSightPanelWidthMax: CGFloat = 560 + private static let lineOfSightPanelWidthMin: CGFloat = 380 + private static let lineOfSightPanelWidthIdeal: CGFloat = 440 + private static let lineOfSightPanelWidthMax: CGFloat = 560 - // sidebarColumnWidth is pinned on AppSidebar in both shell shapes (via navigationSplitViewColumnWidth) - // so the sidebar renders at exactly this width whether the section is two- or three-column. Without - // the pin the two-column Map shell gives its sidebar a wider system default than the three-column - // list shell, so the sidebar visibly jumps width when switching to Map. detailColumnApproxMinWidth is - // the detail-column floor feeding the tiling breakpoint below: an approximation of the width the detail - // column settles at on our supported iPads, validated on device. Both are `nonisolated` because - // sidebarTileableMinWidth's nonisolated initializer reads them. - nonisolated static let sidebarColumnWidth: CGFloat = 64 - nonisolated static let detailColumnApproxMinWidth: CGFloat = 320 + // sidebarColumnWidth is pinned on AppSidebar in both shell shapes (via navigationSplitViewColumnWidth) + // so the sidebar renders at exactly this width whether the section is two- or three-column. Without + // the pin the two-column Map shell gives its sidebar a wider system default than the three-column + // list shell, so the sidebar visibly jumps width when switching to Map. detailColumnApproxMinWidth is + // the detail-column floor feeding the tiling breakpoint below: an approximation of the width the detail + // column settles at on our supported iPads, validated on device. Both are `nonisolated` because + // sidebarTileableMinWidth's nonisolated initializer reads them. + nonisolated static let sidebarColumnWidth: CGFloat = 64 + nonisolated static let detailColumnApproxMinWidth: CGFloat = 320 - // Content-column floor feeding the tiling breakpoint: the widest minimum any section's content - // column takes, so the breakpoint clears for every section. A deliberate, team-owned value kept as - // its own constant, not a reuse of lineOfSightPanelWidthMin, so retuning the Line of Sight panel for - // comfort cannot shift when the sidebar tiles. `nonisolated` because sidebarTileableMinWidth's - // nonisolated initializer reads it. - nonisolated static let contentColumnTileableMinWidth: CGFloat = 380 + /// Content-column floor feeding the tiling breakpoint: the widest minimum any section's content + /// column takes, so the breakpoint clears for every section. A deliberate, team-owned value kept as + /// its own constant, not a reuse of lineOfSightPanelWidthMin, so retuning the Line of Sight panel for + /// comfort cannot shift when the sidebar tiles. `nonisolated` because sidebarTileableMinWidth's + /// nonisolated initializer reads it. + nonisolated static let contentColumnTileableMinWidth: CGFloat = 380 - /// Three columns tile only when the container is at least the sum of the three column widths: the - /// pinned icon sidebar plus a content column at its min plus detail at its min. Below it the system - /// overlays the sidebar, so the not-wide branch keeps collapse-on-selection rather than leaving a - /// sidebar overlay on a too-narrow container. `nonisolated` so the @Sendable onGeometryChange - /// transform can read it; a main-actor-isolated static is not referenceable from a Sendable closure. - nonisolated static let sidebarTileableMinWidth: CGFloat = - sidebarColumnWidth + contentColumnTileableMinWidth + detailColumnApproxMinWidth + /// Three columns tile only when the container is at least the sum of the three column widths: the + /// pinned icon sidebar plus a content column at its min plus detail at its min. Below it the system + /// overlays the sidebar, so the not-wide branch keeps collapse-on-selection rather than leaving a + /// sidebar overlay on a too-narrow container. `nonisolated` so the @Sendable onGeometryChange + /// transform can read it; a main-actor-isolated static is not referenceable from a Sendable closure. + nonisolated static let sidebarTileableMinWidth: CGFloat = + sidebarColumnWidth + contentColumnTileableMinWidth + detailColumnApproxMinWidth - /// Pure mapping from container width and section to the desired sidebar visibility: wide tiles the - /// sidebar (`.all`), narrow collapses to the section's hidden shape, and a sidebar-collapsing tool - /// overrides both. `nonisolated` so the @Sendable-adjacent geometry action and the layout test can - /// both call it. - nonisolated static func sidebarVisibility( - isWide: Bool, - toolCollapsesSidebar: Bool, - sectionCollapsed: NavigationSplitViewVisibility - ) -> NavigationSplitViewVisibility { - if toolCollapsesSidebar { return sectionCollapsed } - return isWide ? .all : sectionCollapsed - } + /// Pure mapping from container width and section to the desired sidebar visibility: wide tiles the + /// sidebar (`.all`), narrow collapses to the section's hidden shape, and a sidebar-collapsing tool + /// overrides both. `nonisolated` so the @Sendable-adjacent geometry action and the layout test can + /// both call it. + nonisolated static func sidebarVisibility( + isWide: Bool, + toolCollapsesSidebar: Bool, + sectionCollapsed: NavigationSplitViewVisibility + ) -> NavigationSplitViewVisibility { + if toolCollapsesSidebar { return sectionCollapsed } + return isWide ? .all : sectionCollapsed + } - private var selectedTab: AppTab { - AppTab(rawValue: appState.navigation.selectedTab) ?? .chats - } + private var selectedTab: AppTab { + AppTab(rawValue: appState.navigation.selectedTab) ?? .chats + } - /// True while the Tools section is showing a tool whose `prefersCollapsedSidebar` is set. - private var isSidebarCollapsingToolOpen: Bool { - selectedTab == .tools && appState.navigation.selectedTool?.prefersCollapsedSidebar == true - } + /// True while the Tools section is showing a tool whose `prefersCollapsedSidebar` is set. + private var isSidebarCollapsingToolOpen: Bool { + selectedTab == .tools && appState.navigation.selectedTool?.prefersCollapsedSidebar == true + } - private func desiredSidebarVisibility(isWide: Bool) -> NavigationSplitViewVisibility { - Self.sidebarVisibility( - isWide: isWide, - toolCollapsesSidebar: isSidebarCollapsingToolOpen, - sectionCollapsed: selectedTab.collapsedSidebarVisibility - ) - } + private func desiredSidebarVisibility(isWide: Bool) -> NavigationSplitViewVisibility { + Self.sidebarVisibility( + isWide: isWide, + toolCollapsesSidebar: isSidebarCollapsingToolOpen, + sectionCollapsed: selectedTab.collapsedSidebarVisibility + ) + } - /// Width override for the Tools content column: applied only when the Line of Sight analysis panel - /// is open, where the column must widen to fit the RF figures. In the plain tool-list state this is - /// nil so the column inherits the system-default content width, matching the Chats/Nodes/Settings - /// columns, which set no width modifier. - private var toolsContentWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat)? { - appState.navigation.selectedTool == .lineOfSight - ? (Self.lineOfSightPanelWidthMin, Self.lineOfSightPanelWidthIdeal, Self.lineOfSightPanelWidthMax) - : nil - } + /// Width override for the Tools content column: applied only when the Line of Sight analysis panel + /// is open, where the column must widen to fit the RF figures. In the plain tool-list state this is + /// nil so the column inherits the system-default content width, matching the Chats/Nodes/Settings + /// columns, which set no width modifier. + private var toolsContentWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat)? { + appState.navigation.selectedTool == .lineOfSight + ? (Self.lineOfSightPanelWidthMin, Self.lineOfSightPanelWidthIdeal, Self.lineOfSightPanelWidthMax) + : nil + } - var body: some View { - shell - // Width is read at the shell because a content column misreports horizontalSizeClass as - // .compact on regular iPad. The transform stays the bare comparison (allocation-free); the - // action fires once on first layout and once per threshold crossing (the Bool dedups - // intermediate resize frames). This action is the single width-driven writer of - // columnVisibility and runs on first layout, so visibility is decided once measurement exists. - .onGeometryChange(for: Bool.self) { proxy in - proxy.size.width >= Self.sidebarTileableMinWidth - } action: { isWide in - appState.navigation.isSidebarWide = isWide - // Wide reveals the tiled sidebar; narrow collapses to the section's hidden shape so a - // too-narrow container shows a single column rather than a sidebar overlay. selectedTab - // is read live so the collapsed shape matches the currently mounted shell. - columnVisibility = desiredSidebarVisibility(isWide: isWide) - } - .navigationSplitViewStyle(.balanced) - .themedChrome(theme) - .syncingPillOverlay(onDisconnectedTap: { showingDeviceSelection = true }) - .onChange(of: appState.navigation.selectedTab) { _, newValue in - clearToolSelectionWhenLeavingTools() - let newTab = AppTab(rawValue: newValue) ?? .chats - // Backstop for programmatic tab changes (deep links, notification taps) that bypass the - // sidebar's selection setter. Width-gated and idempotent so it never re-collapses a wide - // sidebar or duplicates a write the setter already made. - if !appState.navigation.isSidebarWide && columnVisibility != newTab.collapsedSidebarVisibility { - columnVisibility = newTab.collapsedSidebarVisibility - } - // The collapse drops the tapped sidebar row, so steer VoiceOver into the new content. - focusedColumn = newTab - // The radio sits in the section toolbar on every tab, so donate the tip when one waits. - if appState.navigation.pendingDeviceMenuTipDonation { - Task { - await appState.donateDeviceMenuTip() - } - } - } - .onChange(of: appState.navigation.selectedTool) { _, _ in - // A tool's sidebar preference can change the desired visibility, so re-derive it whenever - // the selected tool changes. - columnVisibility = desiredSidebarVisibility(isWide: appState.navigation.isSidebarWide) - } - .onChange(of: appState.connectedDevice) { _, newDevice in - // An explicit (status-menu) disconnect tears down the connection without firing - // onConnectionLost, so clearPerRadioSelection never runs for it. Clear a now-dead - // radio-only tool and per-device settings page here; a radio-to-radio switch (device - // stays non-nil) is handled by clearPerRadioSelection instead. - if newDevice == nil { - appState.navigation.clearPerDeviceSelection() - } - } - .sheet(isPresented: $showingDeviceSelection) { - DeviceSelectionSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - } + var body: some View { + shell + // Width is read at the shell because a content column misreports horizontalSizeClass as + // .compact on regular iPad. The transform stays the bare comparison (allocation-free); the + // action fires once on first layout and once per threshold crossing (the Bool dedups + // intermediate resize frames). This action is the single width-driven writer of + // columnVisibility and runs on first layout, so visibility is decided once measurement exists. + .onGeometryChange(for: Bool.self) { proxy in + proxy.size.width >= Self.sidebarTileableMinWidth + } action: { isWide in + appState.navigation.isSidebarWide = isWide + // Wide reveals the tiled sidebar; narrow collapses to the section's hidden shape so a + // too-narrow container shows a single column rather than a sidebar overlay. selectedTab + // is read live so the collapsed shape matches the currently mounted shell. + columnVisibility = desiredSidebarVisibility(isWide: isWide) + } + .navigationSplitViewStyle(.balanced) + .themedChrome(theme) + .syncingPillOverlay(onDisconnectedTap: { showingDeviceSelection = true }) + .onChange(of: appState.navigation.selectedTab) { _, newValue in + clearToolSelectionWhenLeavingTools() + let newTab = AppTab(rawValue: newValue) ?? .chats + // Backstop for programmatic tab changes (deep links, notification taps) that bypass the + // sidebar's selection setter. Width-gated and idempotent so it never re-collapses a wide + // sidebar or duplicates a write the setter already made. + if !appState.navigation.isSidebarWide, columnVisibility != newTab.collapsedSidebarVisibility { + columnVisibility = newTab.collapsedSidebarVisibility + } + // The collapse drops the tapped sidebar row, so steer VoiceOver into the new content. + focusedColumn = newTab + // The radio sits in the section toolbar on every tab, so donate the tip when one waits. + if appState.navigation.pendingDeviceMenuTipDonation { + Task { + await appState.donateDeviceMenuTip() + } + } + } + .onChange(of: appState.navigation.selectedTool) { _, _ in + // A tool's sidebar preference can change the desired visibility, so re-derive it whenever + // the selected tool changes. + columnVisibility = desiredSidebarVisibility(isWide: appState.navigation.isSidebarWide) + } + .onChange(of: appState.connectedDevice) { _, newDevice in + // An explicit (status-menu) disconnect tears down the connection without firing + // onConnectionLost, so clearPerRadioSelection never runs for it. Clear a now-dead + // radio-only tool and per-device settings page here; a radio-to-radio switch (device + // stays non-nil) is handled by clearPerRadioSelection instead. + if newDevice == nil { + appState.navigation.clearPerDeviceSelection() + } + } + .sheet(isPresented: $showingDeviceSelection) { + DeviceSelectionSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } - // MARK: - Section shell + // MARK: - Section shell - @ViewBuilder - private var shell: some View { - switch selectedTab { - case .map: - NavigationSplitView(columnVisibility: $columnVisibility) { - AppSidebar(columnVisibility: $columnVisibility) - .navigationSplitViewColumnWidth(Self.sidebarColumnWidth) - } detail: { - // The map already draws full-bleed (its canvas ignores the safe area), so it - // extends under the floating sidebar and status bar with real tiles. The - // backgroundExtensionEffect used for static section backgrounds would instead - // mirror the map's edges into the safe area, so it is deliberately omitted here. - MapView() - .accessibilityFocused($focusedColumn, equals: .map) - } - case .chats, .nodes, .settings, .tools: - NavigationSplitView(columnVisibility: $columnVisibility) { - AppSidebar(columnVisibility: $columnVisibility) - .navigationSplitViewColumnWidth(Self.sidebarColumnWidth) - } content: { - contentColumn - } detail: { - detailColumn - } - } + @ViewBuilder + private var shell: some View { + switch selectedTab { + case .map: + NavigationSplitView(columnVisibility: $columnVisibility) { + AppSidebar(columnVisibility: $columnVisibility) + .navigationSplitViewColumnWidth(Self.sidebarColumnWidth) + } detail: { + // The map already draws full-bleed (its canvas ignores the safe area), so it + // extends under the floating sidebar and status bar with real tiles. The + // backgroundExtensionEffect used for static section backgrounds would instead + // mirror the map's edges into the safe area, so it is deliberately omitted here. + MapView() + .accessibilityFocused($focusedColumn, equals: .map) + } + case .chats, .nodes, .settings, .tools: + NavigationSplitView(columnVisibility: $columnVisibility) { + AppSidebar(columnVisibility: $columnVisibility) + .navigationSplitViewColumnWidth(Self.sidebarColumnWidth) + } content: { + contentColumn + } detail: { + detailColumn + } } + } - // MARK: - Content column + // MARK: - Content column - @ViewBuilder - private var contentColumn: some View { - switch selectedTab { - case .chats: - NavigationStack { - ChatsContentColumn(viewModel: chatViewModel) - .accessibilityFocused($focusedColumn, equals: .chats) - } - .modifier(SidebarContentColumnBackground(theme: theme)) - case .nodes: - NavigationStack { - ContactsContentColumn() - .accessibilityFocused($focusedColumn, equals: .nodes) - } - .modifier(SidebarContentColumnBackground(theme: theme)) - case .settings: - SettingsListContent( - showingDeviceSelection: $settingsShowingDeviceSelection, - demoModeManager: settingsDemoModeManager, - isSidebar: true - ) - .accessibilityFocused($focusedColumn, equals: .settings) - .modifier(SidebarContentColumnBackground(theme: theme)) - case .tools: - // Unlike the other sections, Tools applies SidebarContentColumnBackground itself (on the - // tool list only) so the Line of Sight analysis panel keeps its opaque background. - ToolsContentColumn(lineOfSightViewModel: lineOfSightViewModel) - .accessibilityFocused($focusedColumn, equals: .tools) - .modifier(OptionalColumnWidth(width: toolsContentWidth)) - case .map: - // Map renders through the two-column shell, never this column. - EmptyView() - } + @ViewBuilder + private var contentColumn: some View { + switch selectedTab { + case .chats: + NavigationStack { + ChatsContentColumn(viewModel: chatViewModel) + .accessibilityFocused($focusedColumn, equals: .chats) + } + .modifier(SidebarContentColumnBackground(theme: theme)) + case .nodes: + NavigationStack { + ContactsContentColumn() + .accessibilityFocused($focusedColumn, equals: .nodes) + } + .modifier(SidebarContentColumnBackground(theme: theme)) + case .settings: + SettingsListContent( + showingDeviceSelection: $settingsShowingDeviceSelection, + demoModeManager: settingsDemoModeManager, + isSidebar: true + ) + .accessibilityFocused($focusedColumn, equals: .settings) + .modifier(SidebarContentColumnBackground(theme: theme)) + case .tools: + // Unlike the other sections, Tools applies SidebarContentColumnBackground itself (on the + // tool list only) so the Line of Sight analysis panel keeps its opaque background. + ToolsContentColumn(lineOfSightViewModel: lineOfSightViewModel) + .accessibilityFocused($focusedColumn, equals: .tools) + .modifier(OptionalColumnWidth(width: toolsContentWidth)) + case .map: + // Map renders through the two-column shell, never this column. + EmptyView() } + } - // MARK: - Detail column + // MARK: - Detail column - @ViewBuilder - private var detailColumn: some View { - switch selectedTab { - case .chats: - NavigationStack { - ChatsSplitDetailContent(viewModel: chatViewModel) - } - .id(appState.navigation.chatsSelectedRoute?.conversationID) - case .nodes: - NavigationStack { - ContactsDetailColumn() - } - .id(appState.navigation.selectedContact?.id) - case .settings: - NavigationStack { - if let setting = appState.navigation.selectedSetting { - SettingsDetailView(detail: setting) - } else { - ContentUnavailableView(L10n.Settings.selectSetting, systemImage: "gear") - } - } - .id(appState.navigation.selectedSetting) - case .tools: - ToolsDetailColumn(lineOfSightViewModel: lineOfSightViewModel) - case .map: - // Map renders through the two-column shell, never this column. - EmptyView() + @ViewBuilder + private var detailColumn: some View { + switch selectedTab { + case .chats: + NavigationStack { + ChatsSplitDetailContent(viewModel: chatViewModel) + } + .id(appState.navigation.chatsSelectedRoute?.conversationID) + case .nodes: + NavigationStack { + ContactsDetailColumn() + } + .id(appState.navigation.selectedContact?.id) + case .settings: + NavigationStack { + if let setting = appState.navigation.selectedSetting { + SettingsDetailView(detail: setting) + } else { + ContentUnavailableView(L10n.Settings.selectSetting, systemImage: "gear") } + } + .id(appState.navigation.selectedSetting) + case .tools: + ToolsDetailColumn(lineOfSightViewModel: lineOfSightViewModel) + case .map: + // Map renders through the two-column shell, never this column. + EmptyView() } + } - // MARK: - Tool selection lifecycle + // MARK: - Tool selection lifecycle - /// Tools selection persists in `NavigationCoordinator`; clear it when leaving the Tools tab so - /// returning lands on the tool list rather than a previously open tool. - private func clearToolSelectionWhenLeavingTools() { - if selectedTab != .tools { - appState.navigation.selectedTool = nil - } + /// Tools selection persists in `NavigationCoordinator`; clear it when leaving the Tools tab so + /// returning lands on the tool list rather than a previously open tool. + private func clearToolSelectionWhenLeavingTools() { + if selectedTab != .tools { + appState.navigation.selectedTool = nil } + } } /// Applies a content-column width override only when one is supplied, leaving the column at the @@ -271,18 +271,18 @@ struct MainSidebarView: View { /// rather than animating, which is acceptable because the only caller swaps the column's content on /// the same toggle and drives it from external state. private struct OptionalColumnWidth: ViewModifier { - let width: (min: CGFloat, ideal: CGFloat, max: CGFloat)? + let width: (min: CGFloat, ideal: CGFloat, max: CGFloat)? - func body(content: Content) -> some View { - if let width { - content.navigationSplitViewColumnWidth(min: width.min, ideal: width.ideal, max: width.max) - } else { - content - } + func body(content: Content) -> some View { + if let width { + content.navigationSplitViewColumnWidth(min: width.min, ideal: width.ideal, max: width.max) + } else { + content } + } } #Preview { - MainSidebarView() - .environment(\.appState, AppState()) + MainSidebarView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/ContactCalloutContent.swift b/MC1/Views/Map/ContactCalloutContent.swift index 3757c51a..68cd3150 100644 --- a/MC1/Views/Map/ContactCalloutContent.swift +++ b/MC1/Views/Map/ContactCalloutContent.swift @@ -1,93 +1,93 @@ -import SwiftUI import MC1Services +import SwiftUI /// SwiftUI content displayed in a popover callout when a map pin is tapped struct ContactCalloutContent: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - let contact: ContactDTO - let onDetail: () -> Void - let onMessage: () -> Void + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + let contact: ContactDTO + let onDetail: () -> Void + let onMessage: () -> Void - var body: some View { - VStack(alignment: .leading, spacing: 8) { - (Text(idPrefixHex) - .monospaced() - .foregroundStyle(.secondary) - + Text(" \(contact.displayName)")) - .font(.headline) - .accessibilityLabel(contact.displayName) + var body: some View { + VStack(alignment: .leading, spacing: 8) { + (Text(idPrefixHex) + .monospaced() + .foregroundStyle(.secondary) + + Text(" \(contact.displayName)")) + .font(.headline) + .accessibilityLabel(contact.displayName) - HStack(spacing: 6) { - Image(systemName: contact.type.iconSystemName) - .foregroundStyle(contact.type.displayColor) - Text(typeDisplayName) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .combine) + HStack(spacing: 6) { + Image(systemName: contact.type.iconSystemName) + .foregroundStyle(contact.type.displayColor) + Text(typeDisplayName) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) - Divider() + Divider() - // Action buttons - same width - VStack(spacing: 6) { - Button(L10n.Map.Map.Callout.details, systemImage: "info.circle", action: onDetail) - .buttonStyle(.bordered) - .accessibilityHint(contact.displayName) + // Action buttons - same width + VStack(spacing: 6) { + Button(L10n.Map.Map.Callout.details, systemImage: "info.circle", action: onDetail) + .buttonStyle(.bordered) + .accessibilityHint(contact.displayName) - if contact.type == .chat { - Button { - dismiss() - onMessage() - } label: { - Label(L10n.Map.Map.Callout.message, systemImage: "message.fill") - } - .buttonStyle(.bordered) - .accessibilityHint(contact.displayName) - } - } - .frame(maxWidth: .infinity) + if contact.type == .chat { + Button { + dismiss() + onMessage() + } label: { + Label(L10n.Map.Map.Callout.message, systemImage: "message.fill") + } + .buttonStyle(.bordered) + .accessibilityHint(contact.displayName) } - .padding(12) - .frame(minWidth: 160) + } + .frame(maxWidth: .infinity) } + .padding(12) + .frame(minWidth: 160) + } - // MARK: - Computed Properties + // MARK: - Computed Properties - private var idPrefixHex: String { - let hashSize = appState.connectedDevice?.hashSize ?? 1 - return contact.publicKey.prefix(hashSize).uppercaseHexString() - } + private var idPrefixHex: String { + let hashSize = appState.connectedDevice?.hashSize ?? 1 + return contact.publicKey.prefix(hashSize).uppercaseHexString() + } - private var typeDisplayName: String { - switch contact.type { - case .chat: - L10n.Map.Map.Callout.NodeKind.contact - case .repeater: - L10n.Map.Map.Callout.NodeKind.repeater - case .room: - L10n.Map.Map.Callout.NodeKind.room - } + private var typeDisplayName: String { + switch contact.type { + case .chat: + L10n.Map.Map.Callout.NodeKind.contact + case .repeater: + L10n.Map.Map.Callout.NodeKind.repeater + case .room: + L10n.Map.Map.Callout.NodeKind.room } + } } // MARK: - Preview #Preview { - ContactCalloutContent( - contact: ContactDTO( - from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x01, count: 32), - name: "Alice", - typeRawValue: 0, - latitude: 37.7749, - longitude: -122.4194, - isFavorite: true - ) - ), - onDetail: {}, - onMessage: {} - ) - .background(Color(.systemBackground)) + ContactCalloutContent( + contact: ContactDTO( + from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x01, count: 32), + name: "Alice", + typeRawValue: 0, + latitude: 37.7749, + longitude: -122.4194, + isFavorite: true + ) + ), + onDetail: {}, + onMessage: {} + ) + .background(Color(.systemBackground)) } diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift index 15301864..5447d27f 100644 --- a/MC1/Views/Map/ContactDetailSheet.swift +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -1,354 +1,353 @@ -import SwiftUI import MC1Services +import SwiftUI // MARK: - Contact Detail Sheet struct ContactDetailSheet: View { - let contact: ContactDTO - let onMessage: () -> Void - let onDelete: () -> Void - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - init(contact: ContactDTO, onMessage: @escaping () -> Void, onDelete: @escaping () -> Void) { - self.contact = contact - self.onMessage = onMessage - self.onDelete = onDelete - _isFavorite = State(initialValue: contact.isFavorite) + let contact: ContactDTO + let onMessage: () -> Void + let onDelete: () -> Void + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + init(contact: ContactDTO, onMessage: @escaping () -> Void, onDelete: @escaping () -> Void) { + self.contact = contact + self.onMessage = onMessage + self.onDelete = onDelete + _isFavorite = State(initialValue: contact.isFavorite) + } + + /// Sheet types for repeater flows + private enum ActiveSheet: Identifiable, Hashable { + case telemetryAuth + case telemetryStatus(RemoteNodeSessionDTO) + case adminAuth + case adminSettings(RemoteNodeSessionDTO) + case roomJoin + + var id: String { + switch self { + case .telemetryAuth: "telemetryAuth" + case let .telemetryStatus(s): "telemetryStatus-\(s.id)" + case .adminAuth: "adminAuth" + case let .adminSettings(s): "adminSettings-\(s.id)" + case .roomJoin: "roomJoin" + } } + } + + @State private var activeSheet: ActiveSheet? + @State private var pendingSheet: ActiveSheet? + @State private var isPinging = false + @State private var pingResult: PingResult? + @State private var showingDeleteAlert = false + @State private var isDeleting = false + @State private var isFavorite: Bool + @State private var isTogglingFavorite = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + List { + // Basic info section + Section(L10n.Map.Map.Detail.Section.contactInfo) { + LabeledContent(L10n.Map.Map.Detail.name, value: contact.displayName) + + LabeledContent(L10n.Map.Map.Detail.type) { + HStack { + Image(systemName: contact.type.iconSystemName) + Text(typeDisplayName) + } + .foregroundStyle(contact.type.displayColor) + } + + if isFavorite { + LabeledContent(L10n.Map.Map.Detail.status) { + HStack { + Image(systemName: "star.fill") + Text(L10n.Map.Map.Detail.favorite) + } + .foregroundStyle(.orange) + } + } - /// Sheet types for repeater flows - private enum ActiveSheet: Identifiable, Hashable { - case telemetryAuth - case telemetryStatus(RemoteNodeSessionDTO) - case adminAuth - case adminSettings(RemoteNodeSessionDTO) - case roomJoin - - var id: String { - switch self { - case .telemetryAuth: "telemetryAuth" - case .telemetryStatus(let s): "telemetryStatus-\(s.id)" - case .adminAuth: "adminAuth" - case .adminSettings(let s): "adminSettings-\(s.id)" - case .roomJoin: "roomJoin" + if contact.lastAdvertTimestamp > 0 { + LabeledContent(L10n.Map.Map.Detail.lastAdvert) { + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) } + } + + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Map.Map.Detail.publicKey) + .font(.caption) + .foregroundStyle(.secondary) + Text(contact.publicKey.uppercaseHexString(separator: " ")) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } } - } - @State private var activeSheet: ActiveSheet? - @State private var pendingSheet: ActiveSheet? - @State private var isPinging = false - @State private var pingResult: PingResult? - @State private var showingDeleteAlert = false - @State private var isDeleting = false - @State private var isFavorite: Bool - @State private var isTogglingFavorite = false - @State private var errorMessage: String? - - var body: some View { - NavigationStack { - List { - // Basic info section - Section(L10n.Map.Map.Detail.Section.contactInfo) { - LabeledContent(L10n.Map.Map.Detail.name, value: contact.displayName) - - LabeledContent(L10n.Map.Map.Detail.type) { - HStack { - Image(systemName: contact.type.iconSystemName) - Text(typeDisplayName) - } - .foregroundStyle(contact.type.displayColor) - } - - if isFavorite { - LabeledContent(L10n.Map.Map.Detail.status) { - HStack { - Image(systemName: "star.fill") - Text(L10n.Map.Map.Detail.favorite) - } - .foregroundStyle(.orange) - } - } - - if contact.lastAdvertTimestamp > 0 { - LabeledContent(L10n.Map.Map.Detail.lastAdvert) { - ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) - } - } - - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Map.Map.Detail.publicKey) - .font(.caption) - .foregroundStyle(.secondary) - Text(contact.publicKey.uppercaseHexString(separator: " ")) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } + // Location section + Section(L10n.Map.Map.Detail.Section.location) { + LabeledContent(L10n.Map.Map.Detail.latitude) { + Text(contact.latitude, format: .number.precision(.fractionLength(6))) + } - // Location section - Section(L10n.Map.Map.Detail.Section.location) { - LabeledContent(L10n.Map.Map.Detail.latitude) { - Text(contact.latitude, format: .number.precision(.fractionLength(6))) - } + LabeledContent(L10n.Map.Map.Detail.longitude) { + Text(contact.longitude, format: .number.precision(.fractionLength(6))) + } + } - LabeledContent(L10n.Map.Map.Detail.longitude) { - Text(contact.longitude, format: .number.precision(.fractionLength(6))) - } - } + // Path info section + Section(L10n.Map.Map.Detail.Section.outboundPath) { + if contact.isFloodRouted { + LabeledContent(L10n.Map.Map.Detail.routing, value: L10n.Map.Map.Detail.routingFlood) + } else { + let hopCount = contact.pathHopCount + LabeledContent(L10n.Map.Map.Detail.pathLength, value: hopCount == 1 ? L10n.Map.Map.Detail.hopSingular : L10n.Map.Map.Detail.hops(hopCount)) + } + } - // Path info section - Section(L10n.Map.Map.Detail.Section.outboundPath) { - if contact.isFloodRouted { - LabeledContent(L10n.Map.Map.Detail.routing, value: L10n.Map.Map.Detail.routingFlood) - } else { - let hopCount = contact.pathHopCount - LabeledContent(L10n.Map.Map.Detail.pathLength, value: hopCount == 1 ? L10n.Map.Map.Detail.hopSingular : L10n.Map.Map.Detail.hops(hopCount)) - } - } + // Actions section + Section { + switch contact.type { + case .repeater: + Button { + activeSheet = .telemetryAuth + } label: { + Label(L10n.Map.Map.Detail.Action.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + .radioDisabled(for: appState.connectionState) - // Actions section - Section { - switch contact.type { - case .repeater: - Button { - activeSheet = .telemetryAuth - } label: { - Label(L10n.Map.Map.Detail.Action.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - .radioDisabled(for: appState.connectionState) - - Button { - activeSheet = .adminAuth - } label: { - Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") - } - .radioDisabled(for: appState.connectionState) - - pingButton - - case .room: - Button { - activeSheet = .roomJoin - } label: { - Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") - } - .radioDisabled(for: appState.connectionState) - - pingButton - - case .chat: - Button { - dismiss() - onMessage() - } label: { - Label(L10n.Map.Map.Detail.Action.sendMessage, systemImage: "message.fill") - } - .radioDisabled(for: appState.connectionState) - } - } + Button { + activeSheet = .adminAuth + } label: { + Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") + } + .radioDisabled(for: appState.connectionState) - // Favorite section - Section { - Button { - Task { await toggleFavorite() } - } label: { - HStack { - Label( - isFavorite ? L10n.Contacts.Contacts.Detail.removeFromFavorites : L10n.Contacts.Contacts.Detail.addToFavorites, - systemImage: isFavorite ? "star.slash" : "star" - ) - if isTogglingFavorite { - Spacer() - ProgressView() - } - } - } - .disabled(isTogglingFavorite) - .radioDisabled(for: appState.connectionState) - } + pingButton - // Delete section - Section { - Button(role: .destructive) { - showingDeleteAlert = true - } label: { - HStack { - Label(L10n.Contacts.Contacts.Common.delete, systemImage: "trash") - if isDeleting { - Spacer() - ProgressView() - } - } - } - .disabled(isDeleting) - .radioDisabled(for: appState.connectionState) - } - } - .navigationTitle(contact.displayName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Map.Map.Common.done) { - dismiss() - } - } + case .room: + Button { + activeSheet = .roomJoin + } label: { + Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") } - .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in - switch sheet { - case .telemetryAuth: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet( - contact: contact, - role: role, - customTitle: L10n.Map.Map.Detail.Action.telemetryAccessTitle - ) { session in - pendingSheet = .telemetryStatus(session) - activeSheet = nil - } - .presentationSizing(.page) - } - - case .telemetryStatus(let session): - RepeaterStatusView(session: session) - - case .adminAuth: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet(contact: contact, role: role) { session in - pendingSheet = .adminSettings(session) - activeSheet = nil - } - .presentationSizing(.page) - } - - case .adminSettings(let session): - NavigationStack { - RepeaterSettingsView(session: session) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Map.Map.Common.done) { - activeSheet = nil - } - } - } - } - .presentationSizing(.page) - - case .roomJoin: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet(contact: contact, role: role) { session in - activeSheet = nil - dismiss() - appState.navigation.navigateToRoom(with: session) - } - .presentationSizing(.page) - } - } - } - .alert( - L10n.Contacts.Contacts.Detail.Alert.Delete.title(typeDisplayName), - isPresented: $showingDeleteAlert - ) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { - Task { await deleteContact() } - } - } message: { - Text(L10n.Contacts.Contacts.Detail.Alert.Delete.message(contact.displayName)) + .radioDisabled(for: appState.connectionState) + + pingButton + + case .chat: + Button { + dismiss() + onMessage() + } label: { + Label(L10n.Map.Map.Detail.Action.sendMessage, systemImage: "message.fill") } - .errorAlert($errorMessage) + .radioDisabled(for: appState.connectionState) + } } - } - // MARK: - Favorite - - private func toggleFavorite() async { - isTogglingFavorite = true - defer { isTogglingFavorite = false } - let newValue = !isFavorite - do { - try await appState.services?.contactService.setContactFavorite(contact.id, isFavorite: newValue) - isFavorite = newValue - } catch { - errorMessage = error.userFacingMessage + // Favorite section + Section { + Button { + Task { await toggleFavorite() } + } label: { + HStack { + Label( + isFavorite ? L10n.Contacts.Contacts.Detail.removeFromFavorites : L10n.Contacts.Contacts.Detail.addToFavorites, + systemImage: isFavorite ? "star.slash" : "star" + ) + if isTogglingFavorite { + Spacer() + ProgressView() + } + } + } + .disabled(isTogglingFavorite) + .radioDisabled(for: appState.connectionState) } - } - // MARK: - Delete - - private func deleteContact() async { - guard let contactService = appState.services?.contactService else { return } - isDeleting = true - defer { isDeleting = false } - do { - try await contactService.removeContact(radioID: contact.radioID, publicKey: contact.publicKey) - dismiss() - onDelete() - } catch ContactServiceError.contactNotFound { - do { - try await contactService.removeLocalContact(contactID: contact.id, publicKey: contact.publicKey) - dismiss() - onDelete() - } catch { - errorMessage = error.userFacingMessage + // Delete section + Section { + Button(role: .destructive) { + showingDeleteAlert = true + } label: { + HStack { + Label(L10n.Contacts.Contacts.Common.delete, systemImage: "trash") + if isDeleting { + Spacer() + ProgressView() + } } - } catch { - errorMessage = error.userFacingMessage + } + .disabled(isDeleting) + .radioDisabled(for: appState.connectionState) } - } + } + .navigationTitle(contact.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + dismiss() + } + } + } + .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in + switch sheet { + case .telemetryAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet( + contact: contact, + role: role, + customTitle: L10n.Map.Map.Detail.Action.telemetryAccessTitle + ) { session in + pendingSheet = .telemetryStatus(session) + activeSheet = nil + } + .presentationSizing(.page) + } - // MARK: - Ping + case let .telemetryStatus(session): + RepeaterStatusView(session: session) - @ViewBuilder - private var pingButton: some View { - Button { - Task { await pingContact() } - } label: { - HStack { - Label(L10n.Contacts.Contacts.Detail.ping, systemImage: "wave.3.right") - if isPinging { - Spacer() - ProgressView() + case .adminAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + pendingSheet = .adminSettings(session) + activeSheet = nil + } + .presentationSizing(.page) + } + + case let .adminSettings(session): + NavigationStack { + RepeaterSettingsView(session: session) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + activeSheet = nil + } } + } + } + .presentationSizing(.page) + + case .roomJoin: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + activeSheet = nil + dismiss() + appState.navigation.navigateToRoom(with: session) } + .presentationSizing(.page) + } } - .disabled(isPinging) - .radioDisabled(for: appState.connectionState) - - if let result = pingResult { - PingResultRow(result: result) + } + .alert( + L10n.Contacts.Contacts.Detail.Alert.Delete.title(typeDisplayName), + isPresented: $showingDeleteAlert + ) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { + Task { await deleteContact() } } + } message: { + Text(L10n.Contacts.Contacts.Detail.Alert.Delete.message(contact.displayName)) + } + .errorAlert($errorMessage) } - - private func pingContact() async { - guard !isPinging else { return } - isPinging = true - pingResult = nil - pingResult = await PingHelper.zeroHopPing(contact: contact, appState: appState) - isPinging = false + } + + // MARK: - Favorite + + private func toggleFavorite() async { + isTogglingFavorite = true + defer { isTogglingFavorite = false } + let newValue = !isFavorite + do { + try await appState.services?.contactService.setContactFavorite(contact.id, isFavorite: newValue) + isFavorite = newValue + } catch { + errorMessage = error.userFacingMessage } - - // MARK: - Sheet Management - - private func presentPendingSheet() { - if let next = pendingSheet { - pendingSheet = nil - activeSheet = next - } + } + + // MARK: - Delete + + private func deleteContact() async { + guard let contactService = appState.services?.contactService else { return } + isDeleting = true + defer { isDeleting = false } + do { + try await contactService.removeContact(radioID: contact.radioID, publicKey: contact.publicKey) + dismiss() + onDelete() + } catch ContactServiceError.contactNotFound { + do { + try await contactService.removeLocalContact(contactID: contact.id, publicKey: contact.publicKey) + dismiss() + onDelete() + } catch { + errorMessage = error.userFacingMessage + } + } catch { + errorMessage = error.userFacingMessage } - - // MARK: - Computed Properties - - private var typeDisplayName: String { - switch contact.type { - case .chat: - L10n.Map.Map.NodeKind.chatContact - case .repeater: - L10n.Map.Map.NodeKind.repeater - case .room: - L10n.Map.Map.NodeKind.room + } + + // MARK: - Ping + + @ViewBuilder + private var pingButton: some View { + Button { + Task { await pingContact() } + } label: { + HStack { + Label(L10n.Contacts.Contacts.Detail.ping, systemImage: "wave.3.right") + if isPinging { + Spacer() + ProgressView() } + } } + .disabled(isPinging) + .radioDisabled(for: appState.connectionState) + if let result = pingResult { + PingResultRow(result: result) + } + } + + private func pingContact() async { + guard !isPinging else { return } + isPinging = true + pingResult = nil + pingResult = await PingHelper.zeroHopPing(contact: contact, appState: appState) + isPinging = false + } + + // MARK: - Sheet Management + + private func presentPendingSheet() { + if let next = pendingSheet { + pendingSheet = nil + activeSheet = next + } + } + + // MARK: - Computed Properties + + private var typeDisplayName: String { + switch contact.type { + case .chat: + L10n.Map.Map.NodeKind.chatContact + case .repeater: + L10n.Map.Map.NodeKind.repeater + case .room: + L10n.Map.Map.NodeKind.room + } + } } diff --git a/MC1/Views/Map/DroppedPinCallout.swift b/MC1/Views/Map/DroppedPinCallout.swift index 042fc02c..418e363e 100644 --- a/MC1/Views/Map/DroppedPinCallout.swift +++ b/MC1/Views/Map/DroppedPinCallout.swift @@ -1,41 +1,41 @@ -import SwiftUI import MapKit +import SwiftUI /// Popover callout for a chat-dropped map pin: the coordinate, an Apple Maps hand-off, /// and a clear action. Sized like the contact callout. struct DroppedPinCallout: View { - let coordinate: CLLocationCoordinate2D - let onClear: () -> Void + let coordinate: CLLocationCoordinate2D + let onClear: () -> Void - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(coordinate.formattedString) - .font(.headline) - .monospaced() + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(coordinate.formattedString) + .font(.headline) + .monospaced() - Divider() + Divider() - VStack(spacing: 6) { - Button(L10n.Contacts.Contacts.Detail.openInMaps, systemImage: "map") { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coordinate)) - mapItem.openInMaps() - } - .buttonStyle(.bordered) - - Button(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark", action: onClear) - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity) + VStack(spacing: 6) { + Button(L10n.Contacts.Contacts.Detail.openInMaps, systemImage: "map") { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coordinate)) + mapItem.openInMaps() } - .padding(12) - .frame(minWidth: 160) + .buttonStyle(.bordered) + + Button(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark", action: onClear) + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity) } + .padding(12) + .frame(minWidth: 160) + } } #Preview { - DroppedPinCallout( - coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.00902), - onClear: {} - ) - .background(Color(.systemBackground)) + DroppedPinCallout( + coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.00902), + onClear: {} + ) + .background(Color(.systemBackground)) } diff --git a/MC1/Views/Map/LayersMenu.swift b/MC1/Views/Map/LayersMenu.swift deleted file mode 100644 index 4f577cd9..00000000 --- a/MC1/Views/Map/LayersMenu.swift +++ /dev/null @@ -1,75 +0,0 @@ -import MapLibre -import SwiftUI - -/// Dropdown menu for selecting map layers -struct LayersMenu: View { - @Environment(\.appState) private var appState - @Binding var selection: MapStyleSelection - @Binding var isPresented: Bool - var viewportBounds: MLNCoordinateBounds? - - var body: some View { - VStack(spacing: 0) { - ForEach(MapStyleSelection.allCases, id: \.self) { style in - let isDisabled = !appState.offlineMapService.isNetworkAvailable - && (style.requiresNetwork - || !hasOfflineCoverage(for: style)) - - Button { - selection = style - withAnimation { - isPresented = false - } - } label: { - HStack { - Text(style.label) - .foregroundStyle(isDisabled ? .secondary : .primary) - Spacer() - if selection == style { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .disabled(isDisabled) - .accessibilityHint(isDisabled ? disabledReason(for: style) : "") - - if style != MapStyleSelection.allCases.last { - Divider() - } - } - } - .frame(width: 140) - .liquidGlass(in: .rect(cornerRadius: 12)) - .shadow(color: .black.opacity(0.2), radius: 8, y: 4) - .accessibilityElement(children: .contain) - .accessibilityLabel(L10n.Map.Map.Style.accessibilityLabel) - } - - private func hasOfflineCoverage(for style: MapStyleSelection) -> Bool { - if let viewportBounds { - appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer, overlapping: viewportBounds) - } else { - appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer) - } - } - - private func disabledReason(for style: MapStyleSelection) -> String { - if style.requiresNetwork { - L10n.Map.Map.Style.requiresNetwork - } else { - L10n.Map.Map.Style.noOfflineCoverage - } - } -} - -#Preview { - LayersMenu( - selection: .constant(.standard), - isPresented: .constant(true) - ) - .padding() - .environment(\.appState, AppState()) -} diff --git a/MC1/Views/Map/LocationPickerView.swift b/MC1/Views/Map/LocationPickerView.swift index 62c3a473..0dc0441b 100644 --- a/MC1/Views/Map/LocationPickerView.swift +++ b/MC1/Views/Map/LocationPickerView.swift @@ -1,302 +1,302 @@ -import SwiftUI -import MapKit import CoreLocation +import MapKit import MC1Services +import SwiftUI /// Map-based location picker for setting node position struct LocationPickerView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme - - // Configuration - private let initialCoordinate: CLLocationCoordinate2D? - private let onSave: (CLLocationCoordinate2D) async throws -> Void - - // Stable marker identity - private let markerID = UUID() - - // UI State - @State private var cameraRegion: MKCoordinateRegion? - @State private var cameraRegionVersion = 0 - @State private var selectedCoordinate: CLLocationCoordinate2D? - @State private var isSaving = false - @State private var errorMessage: String? - - /// Generic initializer for any location-setting context - init( - initialCoordinate: CLLocationCoordinate2D? = nil, - onSave: @escaping (CLLocationCoordinate2D) async throws -> Void - ) { - self.initialCoordinate = initialCoordinate - self.onSave = onSave - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + + // Configuration + private let initialCoordinate: CLLocationCoordinate2D? + private let onSave: (CLLocationCoordinate2D) async throws -> Void + + /// Stable marker identity + private let markerID = UUID() + + // UI State + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 + @State private var selectedCoordinate: CLLocationCoordinate2D? + @State private var isSaving = false + @State private var errorMessage: String? + + /// Generic initializer for any location-setting context + init( + initialCoordinate: CLLocationCoordinate2D? = nil, + onSave: @escaping (CLLocationCoordinate2D) async throws -> Void + ) { + self.initialCoordinate = initialCoordinate + self.onSave = onSave + } + + var body: some View { + NavigationStack { + ZStack { + MC1MapView( + points: markerPoints, + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: true, + isInteractive: true, + showsScale: false, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + onPointTap: nil, + onMapTap: { coord in selectedCoordinate = coord }, + onCameraRegionChange: { region in cameraRegion = region } + ) - var body: some View { - NavigationStack { - ZStack { - MC1MapView( - points: markerPoints, - lines: [], - mapStyle: .standard, - isDarkMode: colorScheme == .dark, - showLabels: false, - showsUserLocation: true, - isInteractive: true, - showsScale: false, - cameraRegion: $cameraRegion, - cameraRegionVersion: cameraRegionVersion, - onPointTap: nil, - onMapTap: { coord in selectedCoordinate = coord }, - onCameraRegionChange: { region in cameraRegion = region } - ) - - // Center crosshair for precise placement - Image(systemName: "plus") - .font(.title) - .foregroundStyle(.secondary) - - // Coordinate display and actions - VStack { - Spacer() - - if let coord = selectedCoordinate { - VStack(spacing: 4) { - CoordinateText( - label: L10n.Settings.LocationPicker.latitude, - value: coord.latitude - ) - CoordinateText( - label: L10n.Settings.LocationPicker.longitude, - value: coord.longitude - ) - } - .font(.caption.monospacedDigit()) - .padding() - .background { - if #available(iOS 26.0, *) { - Color.clear - } else { - RoundedRectangle(cornerRadius: 8) - .fill(.ultraThinMaterial) - } - } - .modifier(CoordinateGlassModifier()) - } - - Group { - if #available(iOS 26.0, *) { - GlassEffectContainer { - makeButtonContent() - } - } else { - makeButtonContent() - } - } - .padding() - .padding(.bottom, 16) - } + // Center crosshair for precise placement + Image(systemName: "plus") + .font(.title) + .foregroundStyle(.secondary) + + // Coordinate display and actions + VStack { + Spacer() + + if let coord = selectedCoordinate { + VStack(spacing: 4) { + CoordinateText( + label: L10n.Settings.LocationPicker.latitude, + value: coord.latitude + ) + CoordinateText( + label: L10n.Settings.LocationPicker.longitude, + value: coord.longitude + ) } - .navigationTitle(L10n.Settings.LocationPicker.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Localizable.Common.save) { saveLocation() } - .radioDisabled(for: appState.connectionState, or: isSaving) - } + .font(.caption.monospacedDigit()) + .padding() + .background { + if #available(iOS 26.0, *) { + Color.clear + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.ultraThinMaterial) + } } - .onAppear { - loadCurrentLocation() + .modifier(CoordinateGlassModifier()) + } + + Group { + if #available(iOS 26.0, *) { + GlassEffectContainer { + makeButtonContent() + } + } else { + makeButtonContent() } - .onChange(of: appState.locationService.currentLocation) { _, newLocation in - // Only react if we haven't set a camera region yet (no saved location case) - guard let newLocation, - initialCoordinate == nil - || (initialCoordinate?.latitude == 0 && initialCoordinate?.longitude == 0), - cameraRegion == nil else { return } - - cameraRegion = MKCoordinateRegion( - center: newLocation.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - ) - cameraRegionVersion += 1 - } - .errorAlert($errorMessage) + } + .padding() + .padding(.bottom, 16) } - } - - private var markerPoints: [MapPoint] { - guard let coord = selectedCoordinate else { return [] } - return [MapPoint( - id: markerID, - coordinate: coord, - pinStyle: .pointA, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )] - } - - private func loadCurrentLocation() { - // Case 1: Existing saved location - if let coord = initialCoordinate, - CLLocationCoordinate2DIsValid(coord), - coord.latitude != 0 || coord.longitude != 0 { - selectedCoordinate = coord - cameraRegion = MKCoordinateRegion( - center: coord, - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - ) - cameraRegionVersion += 1 - return + } + .navigationTitle(L10n.Settings.LocationPicker.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { dismiss() } } - - // Invalid coordinates (not nil, not zero, but out of range) — show default map - if let coord = initialCoordinate, - coord.latitude != 0 || coord.longitude != 0 { - return - } - - // Case 2: No saved location (nil or 0,0), check user location - let locationService = appState.locationService - if locationService.isAuthorized { - if let userLocation = locationService.currentLocation { - cameraRegion = MKCoordinateRegion( - center: userLocation.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - ) - cameraRegionVersion += 1 - } else if !locationService.isRequestingLocation { - locationService.requestLocation() - } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.Common.save) { saveLocation() } + .radioDisabled(for: appState.connectionState, or: isSaving) } + } + .onAppear { + loadCurrentLocation() + } + .onChange(of: appState.locationService.currentLocation) { _, newLocation in + // Only react if we haven't set a camera region yet (no saved location case) + guard let newLocation, + initialCoordinate == nil + || (initialCoordinate?.latitude == 0 && initialCoordinate?.longitude == 0), + cameraRegion == nil else { return } + + cameraRegion = MKCoordinateRegion( + center: newLocation.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + cameraRegionVersion += 1 + } + .errorAlert($errorMessage) } - - private func dropPinAtCenter() { - if let region = cameraRegion { - selectedCoordinate = region.center - } + } + + private var markerPoints: [MapPoint] { + guard let coord = selectedCoordinate else { return [] } + return [MapPoint( + id: markerID, + coordinate: coord, + pinStyle: .pointA, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )] + } + + private func loadCurrentLocation() { + // Case 1: Existing saved location + if let coord = initialCoordinate, + CLLocationCoordinate2DIsValid(coord), + coord.latitude != 0 || coord.longitude != 0 { + selectedCoordinate = coord + cameraRegion = MKCoordinateRegion( + center: coord, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + cameraRegionVersion += 1 + return } - private func saveLocation() { - let coord = selectedCoordinate ?? CLLocationCoordinate2D(latitude: 0, longitude: 0) - - isSaving = true - Task { - do { - try await onSave(coord) - dismiss() - } catch { - errorMessage = error.userFacingMessage - } - isSaving = false - } + // Invalid coordinates (not nil, not zero, but out of range) — show default map + if let coord = initialCoordinate, + coord.latitude != 0 || coord.longitude != 0 { + return } - private func makeButtonContent() -> some View { - ButtonContent( - hasSelectedCoordinate: selectedCoordinate != nil, - onClear: { selectedCoordinate = nil }, - onDropPin: { dropPinAtCenter() } + // Case 2: No saved location (nil or 0,0), check user location + let locationService = appState.locationService + if locationService.isAuthorized { + if let userLocation = locationService.currentLocation { + cameraRegion = MKCoordinateRegion( + center: userLocation.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) ) + cameraRegionVersion += 1 + } else if !locationService.isRequestingLocation { + locationService.requestLocation() + } } + } + + private func dropPinAtCenter() { + if let region = cameraRegion { + selectedCoordinate = region.center + } + } + + private func saveLocation() { + let coord = selectedCoordinate ?? CLLocationCoordinate2D(latitude: 0, longitude: 0) + + isSaving = true + Task { + do { + try await onSave(coord) + dismiss() + } catch { + errorMessage = error.userFacingMessage + } + isSaving = false + } + } + + private func makeButtonContent() -> some View { + ButtonContent( + hasSelectedCoordinate: selectedCoordinate != nil, + onClear: { selectedCoordinate = nil }, + onDropPin: { dropPinAtCenter() } + ) + } } private struct ButtonContent: View { - let hasSelectedCoordinate: Bool - let onClear: () -> Void - let onDropPin: () -> Void - - var body: some View { - HStack(spacing: 12) { - if hasSelectedCoordinate { - Button(L10n.Settings.LocationPicker.clearLocation, role: .destructive) { - onClear() - } - .liquidGlassSecondaryButtonStyle() - .controlSize(.regular) - } - - Button(L10n.Settings.LocationPicker.dropPin) { - onDropPin() - } - .liquidGlassProminentButtonStyle() - .controlSize(.regular) + let hasSelectedCoordinate: Bool + let onClear: () -> Void + let onDropPin: () -> Void + + var body: some View { + HStack(spacing: 12) { + if hasSelectedCoordinate { + Button(L10n.Settings.LocationPicker.clearLocation, role: .destructive) { + onClear() } + .liquidGlassSecondaryButtonStyle() + .controlSize(.regular) + } + + Button(L10n.Settings.LocationPicker.dropPin) { + onDropPin() + } + .liquidGlassProminentButtonStyle() + .controlSize(.regular) } + } } // MARK: - Local Device Convenience extension LocationPickerView { - /// Convenience initializer for local device location setting (Settings screen) - /// Retry handling for SettingsServiceError is handled at the call site since it requires RetryAlertState - static func forLocalDevice(appState: AppState) -> LocationPickerView { - let device = appState.connectedDevice - let initialCoord = device.map { - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) - } - let devicePreferenceStore = DevicePreferenceStore() - - return LocationPickerView(initialCoordinate: initialCoord) { coordinate in - guard let settingsService = appState.services?.settingsService else { - throw SettingsServiceError.notConnected - } - let previousDevice = appState.connectedDevice - let verifiedInfo = try await settingsService.setManualLocationVerified( - latitude: coordinate.latitude, - longitude: coordinate.longitude - ) - - guard let previousDevice else { return } - - let wasUsingDeviceGPSAutoUpdate = - devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: previousDevice.id) && - devicePreferenceStore.gpsSource(deviceID: previousDevice.id) == .device - - if wasUsingDeviceGPSAutoUpdate { - devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: previousDevice.id) - } - - if previousDevice.sharesLocationPublicly, - previousDevice.advertLocationPolicyMode == .share { - let telemetryModes = TelemetryModes( - base: verifiedInfo.telemetryModeBase, - location: verifiedInfo.telemetryModeLocation, - environment: verifiedInfo.telemetryModeEnvironment - ) - _ = try await settingsService.setOtherParamsVerified( - autoAddContacts: !verifiedInfo.manualAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicy: .prefs, - multiAcks: verifiedInfo.multiAcks - ) - } - } + /// Convenience initializer for local device location setting (Settings screen) + /// Retry handling for SettingsServiceError is handled at the call site since it requires RetryAlertState + static func forLocalDevice(appState: AppState) -> LocationPickerView { + let device = appState.connectedDevice + let initialCoord = device.map { + CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } + let devicePreferenceStore = DevicePreferenceStore() + + return LocationPickerView(initialCoordinate: initialCoord) { coordinate in + guard let settingsService = appState.services?.settingsService else { + throw SettingsServiceError.notConnected + } + let previousDevice = appState.connectedDevice + let verifiedInfo = try await settingsService.setManualLocationVerified( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + + guard let previousDevice else { return } + + let wasUsingDeviceGPSAutoUpdate = + devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: previousDevice.id) && + devicePreferenceStore.gpsSource(deviceID: previousDevice.id) == .device + + if wasUsingDeviceGPSAutoUpdate { + devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: previousDevice.id) + } + + if previousDevice.sharesLocationPublicly, + previousDevice.advertLocationPolicyMode == .share { + let telemetryModes = TelemetryModes( + base: verifiedInfo.telemetryModeBase, + location: verifiedInfo.telemetryModeLocation, + environment: verifiedInfo.telemetryModeEnvironment + ) + _ = try await settingsService.setOtherParamsVerified( + autoAddContacts: !verifiedInfo.manualAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: .prefs, + multiAcks: verifiedInfo.multiAcks + ) + } + } + } } // MARK: - Liquid Glass Modifiers private struct CoordinateGlassModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.glassEffect(.regular, in: .rect(cornerRadius: 8)) - } else { - content - } + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.glassEffect(.regular, in: .rect(cornerRadius: 8)) + } else { + content } + } } private struct CoordinateText: View { - let label: String - let value: Double + let label: String + let value: Double - var body: some View { - Text("\(label) \(value, format: .number.precision(.fractionLength(6)))") - } + var body: some View { + Text("\(label) \(value, format: .number.precision(.fractionLength(6)))") + } } diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 39fce98d..ceb59194 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -10,441 +10,440 @@ private nonisolated(unsafe) let mapFontNames = NSExpression(forConstantValue: [" // MARK: - Layer and source identifiers enum MapLayerID { - static let clusterCircles = "cluster-circles" - static let clusterLabels = "cluster-labels" - static let unclusteredIcons = "unclustered-icons" - static let nameLabels = "name-labels" - static let badgeText = "badge-text" - static let fixedIcons = "fixed-icons" - static let fixedNameLabels = "fixed-name-labels" - static let fixedBadgeText = "fixed-badge-text" - static let lineLOS = "line-los" - static let lineLOSCasing = "line-los-casing" - static let lineTraceUntraced = "line-trace-untraced" - static let lineTraceWeak = "line-trace-weak" - static let lineTraceMedium = "line-trace-medium" - static let lineTraceGood = "line-trace-good" - static let lineTraceUntracedCasing = "line-trace-untraced-casing" - static let lineTraceWeakCasing = "line-trace-weak-casing" - static let lineTraceMediumCasing = "line-trace-medium-casing" - static let lineTraceGoodCasing = "line-trace-good-casing" - static let lineMessagePath = "line-message-path" - static let lineMessagePathCasing = "line-message-path-casing" - static let satelliteLayer = "satellite-layer" - static let topoLayer = "topo-layer" + static let clusterCircles = "cluster-circles" + static let clusterLabels = "cluster-labels" + static let unclusteredIcons = "unclustered-icons" + static let nameLabels = "name-labels" + static let badgeText = "badge-text" + static let fixedIcons = "fixed-icons" + static let fixedNameLabels = "fixed-name-labels" + static let fixedBadgeText = "fixed-badge-text" + static let lineLOS = "line-los" + static let lineLOSCasing = "line-los-casing" + static let lineTraceUntraced = "line-trace-untraced" + static let lineTraceWeak = "line-trace-weak" + static let lineTraceMedium = "line-trace-medium" + static let lineTraceGood = "line-trace-good" + static let lineTraceUntracedCasing = "line-trace-untraced-casing" + static let lineTraceWeakCasing = "line-trace-weak-casing" + static let lineTraceMediumCasing = "line-trace-medium-casing" + static let lineTraceGoodCasing = "line-trace-good-casing" + static let lineMessagePath = "line-message-path" + static let lineMessagePathCasing = "line-message-path-casing" + static let satelliteLayer = "satellite-layer" + static let topoLayer = "topo-layer" } enum MapSourceID { - static let points = "points" - static let fixedPoints = "fixed-points" - static let lines = "lines" - static let satelliteTiles = "satellite-tiles" - static let topoTiles = "topo-tiles" + static let points = "points" + static let fixedPoints = "fixed-points" + static let lines = "lines" + static let satelliteTiles = "satellite-tiles" + static let topoTiles = "topo-tiles" } extension MC1MapView.Coordinator { - - // MARK: - Update point source data - - /// Point sources and layers use deferred creation: they are created here - /// on first data arrival, not during style load. This avoids a MapLibre - /// bug where sources initialized without features ignore later `.shape` - /// updates. - func updatePointSource(mapView: MLNMapView) { - guard let style = mapView.style else { return } - - var clusterablePoints: [MapPoint] = [] - var fixedPoints: [MapPoint] = [] - for point in currentPoints { - if point.isClusterable { - clusterablePoints.append(point) - } else { - fixedPoints.append(point) - } - } - - // Clustered (contact) source — rebuild only when the clusterable subset changed, - // so toggling a fixed pin (the chat-dropped pin) does not re-cluster all contacts. - if clusterablePoints != lastAppliedClusterablePoints { - if let source = clusterSource { - source.shape = MLNShapeCollectionFeature( - shapes: clusterablePoints.map { pointFeature(for: $0) } - ) - } else if !clusterablePoints.isEmpty { - let features = clusterablePoints.map { pointFeature(for: $0) } - let source = MLNShapeSource( - identifier: MapSourceID.points, - features: features, - options: [ - .clustered: true, - .clusterRadius: 44, - .maximumZoomLevelForClustering: 14, - ] - ) - style.addSource(source) - self.clusterSource = source - addClusteredPointLayers(source: source, style: style) - } - lastAppliedClusterablePoints = clusterablePoints - } - - // Fixed source — rebuild only when the fixed subset changed. - if fixedPoints != lastAppliedFixedPoints { - if let source = fixedSource { - source.shape = MLNShapeCollectionFeature( - shapes: fixedPoints.map { pointFeature(for: $0) } - ) - } else if !fixedPoints.isEmpty { - let features = fixedPoints.map { pointFeature(for: $0) } - let source = MLNShapeSource(identifier: MapSourceID.fixedPoints, features: features, options: nil) - style.addSource(source) - self.fixedSource = source - addFixedPointLayers(source: source, style: style) - } - lastAppliedFixedPoints = fixedPoints - } - } - - func updateLabelVisibility(mapView: MLNMapView, showLabels: Bool) { - for layerId in [MapLayerID.nameLabels, MapLayerID.fixedNameLabels] { - guard let layer = mapView.style?.layer(withIdentifier: layerId) as? MLNSymbolStyleLayer else { continue } - layer.isVisible = showLabels - } + // MARK: - Update point source data + + /// Point sources and layers use deferred creation: they are created here + /// on first data arrival, not during style load. This avoids a MapLibre + /// bug where sources initialized without features ignore later `.shape` + /// updates. + func updatePointSource(mapView: MLNMapView) { + guard let style = mapView.style else { return } + + var clusterablePoints: [MapPoint] = [] + var fixedPoints: [MapPoint] = [] + for point in currentPoints { + if point.isClusterable { + clusterablePoints.append(point) + } else { + fixedPoints.append(point) + } } - // MARK: - Clustered point layers - - private func addClusteredPointLayers(source: MLNShapeSource, style: MLNStyle) { - // Cluster circles - let circleLayer = MLNCircleStyleLayer(identifier: MapLayerID.clusterCircles, source: source) - circleLayer.predicate = NSPredicate(format: "cluster == YES") - let radiusStops: [NSNumber: NSNumber] = [0: 18, 50: 24, 100: 30, 200: 38] - circleLayer.circleRadius = NSExpression( - forMLNStepping: NSExpression(forKeyPath: "point_count"), - from: NSExpression(forConstantValue: 18), - stops: NSExpression(forConstantValue: radiusStops) + // Clustered (contact) source — rebuild only when the clusterable subset changed, + // so toggling a fixed pin (the chat-dropped pin) does not re-cluster all contacts. + if clusterablePoints != lastAppliedClusterablePoints { + if let source = clusterSource { + source.shape = MLNShapeCollectionFeature( + shapes: clusterablePoints.map { pointFeature(for: $0) } + ) + } else if !clusterablePoints.isEmpty { + let features = clusterablePoints.map { pointFeature(for: $0) } + let source = MLNShapeSource( + identifier: MapSourceID.points, + features: features, + options: [ + .clustered: true, + .clusterRadius: 44, + .maximumZoomLevelForClustering: 14, + ] ) - circleLayer.circleColor = NSExpression(forConstantValue: UIColor.systemBlue) - circleLayer.circleOpacity = NSExpression(forConstantValue: 0.85) - circleLayer.circleStrokeColor = NSExpression(forConstantValue: UIColor.white.withAlphaComponent(0.8)) - circleLayer.circleStrokeWidth = NSExpression(forConstantValue: 2) - style.addLayer(circleLayer) - - // Cluster count labels - let clusterLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.clusterLabels, source: source) - clusterLabelLayer.predicate = NSPredicate(format: "cluster == YES") - clusterLabelLayer.text = NSExpression(format: "CAST(point_count, 'NSString')") - clusterLabelLayer.textColor = NSExpression(forConstantValue: UIColor.white) - clusterLabelLayer.textFontSize = NSExpression(forConstantValue: 13) - clusterLabelLayer.textFontNames = mapFontNames - clusterLabelLayer.textAllowsOverlap = NSExpression(forConstantValue: true) - clusterLabelLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) - style.addLayer(clusterLabelLayer) - - // Unclustered pin icons - let iconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.unclusteredIcons, source: source) - iconLayer.predicate = NSPredicate(format: "cluster != YES") - iconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") - iconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") - iconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) - iconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) - iconLayer.text = nil - style.addLayer(iconLayer) - - // Name labels (above pins) with pill background - let nameLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.nameLabels, source: source) - nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND labelSpriteName != nil") - configureNameLabelLayer(nameLabelLayer) - style.addLayer(nameLabelLayer) - - // Stats badge text (trace path midpoints) with pill background - let badgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.badgeText, source: source) - badgeLayer.predicate = NSPredicate(format: "cluster != YES AND badgeText != nil") - configureBadgeLayer(badgeLayer) - style.addLayer(badgeLayer) - } - - // MARK: - Fixed point layers - - private func addFixedPointLayers(source: MLNShapeSource, style: MLNStyle) { - let fixedIconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedIcons, source: source) - fixedIconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") - fixedIconLayer.iconAnchor = NSExpression(forKeyPath: "anchorType") - fixedIconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) - fixedIconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) - fixedIconLayer.text = nil - style.addLayer(fixedIconLayer) - - let fixedNameLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedNameLabels, source: source) - fixedNameLayer.predicate = NSPredicate(format: "labelSpriteName != nil") - configureNameLabelLayer(fixedNameLayer) - style.addLayer(fixedNameLayer) - - let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedBadgeText, source: source) - fixedBadgeLayer.predicate = NSPredicate(format: "badgeText != nil") - configureBadgeLayer(fixedBadgeLayer) - style.addLayer(fixedBadgeLayer) - } - - // MARK: - Line layers - - func setupLineLayers(style: MLNStyle) { - guard style.source(withIdentifier: MapSourceID.lines) == nil else { return } - let source = MLNShapeSource(identifier: MapSourceID.lines, features: [], options: nil) style.addSource(source) - - let losCasing = MLNLineStyleLayer(identifier: MapLayerID.lineLOSCasing, source: source) - losCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) - losCasing.lineColor = NSExpression(forConstantValue: UIColor.white) - losCasing.lineOpacity = NSExpression(forConstantValue: 0.8) - losCasing.lineWidth = NSExpression(forConstantValue: 6) - losCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) - losCasing.lineJoin = NSExpression(forConstantValue: "round") - losCasing.lineCap = NSExpression(forConstantValue: "round") - style.addLayer(losCasing) - - let losLayer = MLNLineStyleLayer(identifier: MapLayerID.lineLOS, source: source) - losLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) - losLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) - losLayer.lineWidth = NSExpression(forConstantValue: 3) - losLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) - losLayer.lineJoin = NSExpression(forConstantValue: "round") - losLayer.lineCap = NSExpression(forConstantValue: "round") - losLayer.lineOpacity = NSExpression(forKeyPath: "segmentOpacity") - style.addLayer(losLayer) - - let white = NSExpression(forConstantValue: UIColor.white) - let casingOpacity = NSExpression(forConstantValue: 0.8) - let roundJoin = NSExpression(forConstantValue: "round") - let roundCap = NSExpression(forConstantValue: "round") - - let untracedCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntracedCasing, source: source) - untracedCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) - untracedCasing.lineColor = white - untracedCasing.lineOpacity = casingOpacity - untracedCasing.lineWidth = NSExpression(forConstantValue: 5) - untracedCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) - untracedCasing.lineJoin = roundJoin - untracedCasing.lineCap = roundCap - style.addLayer(untracedCasing) - - let untracedLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntraced, source: source) - untracedLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) - untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) - untracedLayer.lineWidth = NSExpression(forConstantValue: 2) - untracedLayer.lineDashPattern = NSExpression(forConstantValue: [1.75, 3.25]) - untracedLayer.lineJoin = roundJoin - untracedLayer.lineCap = roundCap - style.addLayer(untracedLayer) - - let weakCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeakCasing, source: source) - weakCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) - weakCasing.lineColor = white - weakCasing.lineOpacity = casingOpacity - weakCasing.lineWidth = NSExpression(forConstantValue: 6) - weakCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) - weakCasing.lineJoin = roundJoin - weakCasing.lineCap = roundCap - style.addLayer(weakCasing) - - let weakLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeak, source: source) - weakLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) - weakLayer.lineColor = NSExpression(forConstantValue: SNRQuality.poor.uiColor) - weakLayer.lineWidth = NSExpression(forConstantValue: 3) - weakLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) - weakLayer.lineJoin = roundJoin - weakLayer.lineCap = roundCap - style.addLayer(weakLayer) - - let mediumCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMediumCasing, source: source) - mediumCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) - mediumCasing.lineColor = white - mediumCasing.lineOpacity = casingOpacity - mediumCasing.lineWidth = NSExpression(forConstantValue: 6) - mediumCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) - mediumCasing.lineJoin = roundJoin - mediumCasing.lineCap = roundCap - style.addLayer(mediumCasing) - - let mediumLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMedium, source: source) - mediumLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) - mediumLayer.lineColor = NSExpression(forConstantValue: SNRQuality.fair.uiColor) - mediumLayer.lineWidth = NSExpression(forConstantValue: 3) - mediumLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) - mediumLayer.lineJoin = roundJoin - mediumLayer.lineCap = roundCap - style.addLayer(mediumLayer) - - // Good: width 4, solid → casing width 7 - let goodCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGoodCasing, source: source) - goodCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) - goodCasing.lineColor = white - goodCasing.lineOpacity = casingOpacity - goodCasing.lineWidth = NSExpression(forConstantValue: 7) - goodCasing.lineJoin = roundJoin - goodCasing.lineCap = roundCap - style.addLayer(goodCasing) - - let goodLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGood, source: source) - goodLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) - goodLayer.lineColor = NSExpression(forConstantValue: SNRQuality.good.uiColor) - goodLayer.lineWidth = NSExpression(forConstantValue: 4) - style.addLayer(goodLayer) - - // Message path: solid blue with a white casing (no dashes) — a direct - // node-to-node route, visually distinct from the dashed LOS line. - let messagePathCasing = MLNLineStyleLayer(identifier: MapLayerID.lineMessagePathCasing, source: source) - messagePathCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.messagePath.rawValue) - messagePathCasing.lineColor = white - messagePathCasing.lineOpacity = casingOpacity - messagePathCasing.lineWidth = NSExpression(forConstantValue: 6) - messagePathCasing.lineJoin = roundJoin - messagePathCasing.lineCap = roundCap - style.addLayer(messagePathCasing) - - let messagePathLayer = MLNLineStyleLayer(identifier: MapLayerID.lineMessagePath, source: source) - messagePathLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.messagePath.rawValue) - messagePathLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) - messagePathLayer.lineWidth = NSExpression(forConstantValue: 3) - messagePathLayer.lineJoin = roundJoin - messagePathLayer.lineCap = roundCap - style.addLayer(messagePathLayer) + clusterSource = source + addClusteredPointLayers(source: source, style: style) + } + lastAppliedClusterablePoints = clusterablePoints } - func updateLineSource(mapView: MLNMapView) { - guard let source = mapView.style?.source(withIdentifier: MapSourceID.lines) as? MLNShapeSource else { return } - - let features = currentLines.map { line -> MLNPolylineFeature in - var coords = line.coordinates - let feature = MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count)) - feature.attributes = [ - "lineStyle": line.style.rawValue, - "segmentOpacity": line.opacity, - ] - return feature - } - source.shape = MLNShapeCollectionFeature(shapes: features) - } - - // MARK: - Raster tile sources - - func setupRasterSources(style: MLNStyle, mapView: MLNMapView) { - guard style.source(withIdentifier: MapSourceID.satelliteTiles) == nil else { - updateRasterLayerVisibility(mapView: mapView) - return - } - let satSource = MLNRasterTileSource( - identifier: MapSourceID.satelliteTiles, - tileURLTemplates: [MapTileURLs.esriWorldImagery], - options: [ - .tileSize: 256, - .maximumZoomLevel: 19, - .attributionHTMLString: "Esri", - ] - ) - style.addSource(satSource) - let satLayer = MLNRasterStyleLayer(identifier: MapLayerID.satelliteLayer, source: satSource) - satLayer.isVisible = false - style.addLayer(satLayer) - - let topoSource = MLNRasterTileSource( - identifier: MapSourceID.topoTiles, - tileURLTemplates: [MapTileURLs.openTopoMapA, MapTileURLs.openTopoMapB, MapTileURLs.openTopoMapC], - options: [ - .tileSize: 256, - .maximumZoomLevel: 17, - .attributionHTMLString: "OpenTopoMap", - ] + // Fixed source — rebuild only when the fixed subset changed. + if fixedPoints != lastAppliedFixedPoints { + if let source = fixedSource { + source.shape = MLNShapeCollectionFeature( + shapes: fixedPoints.map { pointFeature(for: $0) } ) - style.addSource(topoSource) - let topoLayer = MLNRasterStyleLayer(identifier: MapLayerID.topoLayer, source: topoSource) - topoLayer.isVisible = false - style.addLayer(topoLayer) - - updateRasterLayerVisibility(mapView: mapView) + } else if !fixedPoints.isEmpty { + let features = fixedPoints.map { pointFeature(for: $0) } + let source = MLNShapeSource(identifier: MapSourceID.fixedPoints, features: features, options: nil) + style.addSource(source) + fixedSource = source + addFixedPointLayers(source: source, style: style) + } + lastAppliedFixedPoints = fixedPoints } + } - func updateRasterLayerVisibility(mapView: MLNMapView) { - guard let style = mapView.style else { return } - style.layer(withIdentifier: MapLayerID.satelliteLayer)?.isVisible = currentMapStyle == .satellite - style.layer(withIdentifier: MapLayerID.topoLayer)?.isVisible = currentMapStyle == .topo + func updateLabelVisibility(mapView: MLNMapView, showLabels: Bool) { + for layerId in [MapLayerID.nameLabels, MapLayerID.fixedNameLabels] { + guard let layer = mapView.style?.layer(withIdentifier: layerId) as? MLNSymbolStyleLayer else { continue } + layer.isVisible = showLabels } - - // MARK: - Shared layer configuration - - private func configureNameLabelLayer(_ layer: MLNSymbolStyleLayer) { - layer.iconImageName = NSExpression(forKeyPath: "labelSpriteName") - layer.iconAnchor = NSExpression(forConstantValue: "bottom") - layer.iconOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -46))) - layer.symbolSortKey = NSExpression(forKeyPath: "hopIndex") - layer.iconAllowsOverlap = NSExpression(forConstantValue: true) - layer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + } + + // MARK: - Clustered point layers + + private func addClusteredPointLayers(source: MLNShapeSource, style: MLNStyle) { + // Cluster circles + let circleLayer = MLNCircleStyleLayer(identifier: MapLayerID.clusterCircles, source: source) + circleLayer.predicate = NSPredicate(format: "cluster == YES") + let radiusStops: [NSNumber: NSNumber] = [0: 18, 50: 24, 100: 30, 200: 38] + circleLayer.circleRadius = NSExpression( + forMLNStepping: NSExpression(forKeyPath: "point_count"), + from: NSExpression(forConstantValue: 18), + stops: NSExpression(forConstantValue: radiusStops) + ) + circleLayer.circleColor = NSExpression(forConstantValue: UIColor.systemBlue) + circleLayer.circleOpacity = NSExpression(forConstantValue: 0.85) + circleLayer.circleStrokeColor = NSExpression(forConstantValue: UIColor.white.withAlphaComponent(0.8)) + circleLayer.circleStrokeWidth = NSExpression(forConstantValue: 2) + style.addLayer(circleLayer) + + // Cluster count labels + let clusterLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.clusterLabels, source: source) + clusterLabelLayer.predicate = NSPredicate(format: "cluster == YES") + clusterLabelLayer.text = NSExpression(format: "CAST(point_count, 'NSString')") + clusterLabelLayer.textColor = NSExpression(forConstantValue: UIColor.white) + clusterLabelLayer.textFontSize = NSExpression(forConstantValue: 13) + clusterLabelLayer.textFontNames = mapFontNames + clusterLabelLayer.textAllowsOverlap = NSExpression(forConstantValue: true) + clusterLabelLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) + style.addLayer(clusterLabelLayer) + + // Unclustered pin icons + let iconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.unclusteredIcons, source: source) + iconLayer.predicate = NSPredicate(format: "cluster != YES") + iconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") + iconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") + iconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + iconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + iconLayer.text = nil + style.addLayer(iconLayer) + + // Name labels (above pins) with pill background + let nameLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.nameLabels, source: source) + nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND labelSpriteName != nil") + configureNameLabelLayer(nameLabelLayer) + style.addLayer(nameLabelLayer) + + // Stats badge text (trace path midpoints) with pill background + let badgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.badgeText, source: source) + badgeLayer.predicate = NSPredicate(format: "cluster != YES AND badgeText != nil") + configureBadgeLayer(badgeLayer) + style.addLayer(badgeLayer) + } + + // MARK: - Fixed point layers + + private func addFixedPointLayers(source: MLNShapeSource, style: MLNStyle) { + let fixedIconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedIcons, source: source) + fixedIconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") + fixedIconLayer.iconAnchor = NSExpression(forKeyPath: "anchorType") + fixedIconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + fixedIconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + fixedIconLayer.text = nil + style.addLayer(fixedIconLayer) + + let fixedNameLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedNameLabels, source: source) + fixedNameLayer.predicate = NSPredicate(format: "labelSpriteName != nil") + configureNameLabelLayer(fixedNameLayer) + style.addLayer(fixedNameLayer) + + let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedBadgeText, source: source) + fixedBadgeLayer.predicate = NSPredicate(format: "badgeText != nil") + configureBadgeLayer(fixedBadgeLayer) + style.addLayer(fixedBadgeLayer) + } + + // MARK: - Line layers + + func setupLineLayers(style: MLNStyle) { + guard style.source(withIdentifier: MapSourceID.lines) == nil else { return } + let source = MLNShapeSource(identifier: MapSourceID.lines, features: [], options: nil) + style.addSource(source) + + let losCasing = MLNLineStyleLayer(identifier: MapLayerID.lineLOSCasing, source: source) + losCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) + losCasing.lineColor = NSExpression(forConstantValue: UIColor.white) + losCasing.lineOpacity = NSExpression(forConstantValue: 0.8) + losCasing.lineWidth = NSExpression(forConstantValue: 6) + losCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) + losCasing.lineJoin = NSExpression(forConstantValue: "round") + losCasing.lineCap = NSExpression(forConstantValue: "round") + style.addLayer(losCasing) + + let losLayer = MLNLineStyleLayer(identifier: MapLayerID.lineLOS, source: source) + losLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) + losLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) + losLayer.lineWidth = NSExpression(forConstantValue: 3) + losLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + losLayer.lineJoin = NSExpression(forConstantValue: "round") + losLayer.lineCap = NSExpression(forConstantValue: "round") + losLayer.lineOpacity = NSExpression(forKeyPath: "segmentOpacity") + style.addLayer(losLayer) + + let white = NSExpression(forConstantValue: UIColor.white) + let casingOpacity = NSExpression(forConstantValue: 0.8) + let roundJoin = NSExpression(forConstantValue: "round") + let roundCap = NSExpression(forConstantValue: "round") + + let untracedCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntracedCasing, source: source) + untracedCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) + untracedCasing.lineColor = white + untracedCasing.lineOpacity = casingOpacity + untracedCasing.lineWidth = NSExpression(forConstantValue: 5) + untracedCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) + untracedCasing.lineJoin = roundJoin + untracedCasing.lineCap = roundCap + style.addLayer(untracedCasing) + + let untracedLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntraced, source: source) + untracedLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) + untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) + untracedLayer.lineWidth = NSExpression(forConstantValue: 2) + untracedLayer.lineDashPattern = NSExpression(forConstantValue: [1.75, 3.25]) + untracedLayer.lineJoin = roundJoin + untracedLayer.lineCap = roundCap + style.addLayer(untracedLayer) + + let weakCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeakCasing, source: source) + weakCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) + weakCasing.lineColor = white + weakCasing.lineOpacity = casingOpacity + weakCasing.lineWidth = NSExpression(forConstantValue: 6) + weakCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) + weakCasing.lineJoin = roundJoin + weakCasing.lineCap = roundCap + style.addLayer(weakCasing) + + let weakLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeak, source: source) + weakLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) + weakLayer.lineColor = NSExpression(forConstantValue: SNRQuality.poor.uiColor) + weakLayer.lineWidth = NSExpression(forConstantValue: 3) + weakLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + weakLayer.lineJoin = roundJoin + weakLayer.lineCap = roundCap + style.addLayer(weakLayer) + + let mediumCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMediumCasing, source: source) + mediumCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) + mediumCasing.lineColor = white + mediumCasing.lineOpacity = casingOpacity + mediumCasing.lineWidth = NSExpression(forConstantValue: 6) + mediumCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) + mediumCasing.lineJoin = roundJoin + mediumCasing.lineCap = roundCap + style.addLayer(mediumCasing) + + let mediumLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMedium, source: source) + mediumLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) + mediumLayer.lineColor = NSExpression(forConstantValue: SNRQuality.fair.uiColor) + mediumLayer.lineWidth = NSExpression(forConstantValue: 3) + mediumLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + mediumLayer.lineJoin = roundJoin + mediumLayer.lineCap = roundCap + style.addLayer(mediumLayer) + + // Good: width 4, solid → casing width 7 + let goodCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGoodCasing, source: source) + goodCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) + goodCasing.lineColor = white + goodCasing.lineOpacity = casingOpacity + goodCasing.lineWidth = NSExpression(forConstantValue: 7) + goodCasing.lineJoin = roundJoin + goodCasing.lineCap = roundCap + style.addLayer(goodCasing) + + let goodLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGood, source: source) + goodLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) + goodLayer.lineColor = NSExpression(forConstantValue: SNRQuality.good.uiColor) + goodLayer.lineWidth = NSExpression(forConstantValue: 4) + style.addLayer(goodLayer) + + // Message path: solid blue with a white casing (no dashes) — a direct + // node-to-node route, visually distinct from the dashed LOS line. + let messagePathCasing = MLNLineStyleLayer(identifier: MapLayerID.lineMessagePathCasing, source: source) + messagePathCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.messagePath.rawValue) + messagePathCasing.lineColor = white + messagePathCasing.lineOpacity = casingOpacity + messagePathCasing.lineWidth = NSExpression(forConstantValue: 6) + messagePathCasing.lineJoin = roundJoin + messagePathCasing.lineCap = roundCap + style.addLayer(messagePathCasing) + + let messagePathLayer = MLNLineStyleLayer(identifier: MapLayerID.lineMessagePath, source: source) + messagePathLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.messagePath.rawValue) + messagePathLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) + messagePathLayer.lineWidth = NSExpression(forConstantValue: 3) + messagePathLayer.lineJoin = roundJoin + messagePathLayer.lineCap = roundCap + style.addLayer(messagePathLayer) + } + + func updateLineSource(mapView: MLNMapView) { + guard let source = mapView.style?.source(withIdentifier: MapSourceID.lines) as? MLNShapeSource else { return } + + let features = currentLines.map { line -> MLNPolylineFeature in + var coords = line.coordinates + let feature = MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count)) + feature.attributes = [ + "lineStyle": line.style.rawValue, + "segmentOpacity": line.opacity, + ] + return feature } + source.shape = MLNShapeCollectionFeature(shapes: features) + } - private func configureBadgeLayer(_ layer: MLNSymbolStyleLayer) { - layer.text = NSExpression(forKeyPath: "badgeText") - layer.textFontSize = NSExpression(forConstantValue: 11) - layer.textFontNames = mapFontNames - layer.textColor = NSExpression(forConstantValue: UIColor.black) - layer.textAllowsOverlap = NSExpression(forConstantValue: true) - layer.textIgnoresPlacement = NSExpression(forConstantValue: true) - layer.iconImageName = NSExpression(forConstantValue: "pill-bg") - layer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) - layer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) - } + // MARK: - Raster tile sources - // MARK: - Private helpers - - private func pointFeature(for point: MapPoint) -> MLNPointFeature { - let feature = MLNPointFeature() - feature.coordinate = point.coordinate - var attributes: [String: Any] = [ - "pointId": point.id.uuidString, - "spriteName": spriteName(for: point), - "anchorType": iconAnchor(for: point), - ] - if let label = point.label { - attributes["labelSpriteName"] = "\(PinSpriteRenderer.labelSpritePrefix)\(label)" - } - if let hopIndex = point.hopIndex { attributes["hopIndex"] = hopIndex } - if let badgeText = point.badgeText { attributes["badgeText"] = badgeText } - feature.attributes = attributes - return feature + func setupRasterSources(style: MLNStyle, mapView: MLNMapView) { + guard style.source(withIdentifier: MapSourceID.satelliteTiles) == nil else { + updateRasterLayerVisibility(mapView: mapView) + return } - - private func iconAnchor(for point: MapPoint) -> String { - switch point.pinStyle { - case .crosshair, .obstruction: "center" - default: "bottom" - } + let satSource = MLNRasterTileSource( + identifier: MapSourceID.satelliteTiles, + tileURLTemplates: [MapTileURLs.esriWorldImagery], + options: [ + .tileSize: 256, + .maximumZoomLevel: 19, + .attributionHTMLString: "Esri", + ] + ) + style.addSource(satSource) + let satLayer = MLNRasterStyleLayer(identifier: MapLayerID.satelliteLayer, source: satSource) + satLayer.isVisible = false + style.addLayer(satLayer) + + let topoSource = MLNRasterTileSource( + identifier: MapSourceID.topoTiles, + tileURLTemplates: [MapTileURLs.openTopoMapA, MapTileURLs.openTopoMapB, MapTileURLs.openTopoMapC], + options: [ + .tileSize: 256, + .maximumZoomLevel: 17, + .attributionHTMLString: "OpenTopoMap", + ] + ) + style.addSource(topoSource) + let topoLayer = MLNRasterStyleLayer(identifier: MapLayerID.topoLayer, source: topoSource) + topoLayer.isVisible = false + style.addLayer(topoLayer) + + updateRasterLayerVisibility(mapView: mapView) + } + + func updateRasterLayerVisibility(mapView: MLNMapView) { + guard let style = mapView.style else { return } + style.layer(withIdentifier: MapLayerID.satelliteLayer)?.isVisible = currentMapStyle == .satellite + style.layer(withIdentifier: MapLayerID.topoLayer)?.isVisible = currentMapStyle == .topo + } + + // MARK: - Shared layer configuration + + private func configureNameLabelLayer(_ layer: MLNSymbolStyleLayer) { + layer.iconImageName = NSExpression(forKeyPath: "labelSpriteName") + layer.iconAnchor = NSExpression(forConstantValue: "bottom") + layer.iconOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -46))) + layer.symbolSortKey = NSExpression(forKeyPath: "hopIndex") + layer.iconAllowsOverlap = NSExpression(forConstantValue: true) + layer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + } + + private func configureBadgeLayer(_ layer: MLNSymbolStyleLayer) { + layer.text = NSExpression(forKeyPath: "badgeText") + layer.textFontSize = NSExpression(forConstantValue: 11) + layer.textFontNames = mapFontNames + layer.textColor = NSExpression(forConstantValue: UIColor.black) + layer.textAllowsOverlap = NSExpression(forConstantValue: true) + layer.textIgnoresPlacement = NSExpression(forConstantValue: true) + layer.iconImageName = NSExpression(forConstantValue: "pill-bg") + layer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) + layer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) + } + + // MARK: - Private helpers + + private func pointFeature(for point: MapPoint) -> MLNPointFeature { + let feature = MLNPointFeature() + feature.coordinate = point.coordinate + var attributes: [String: Any] = [ + "pointId": point.id.uuidString, + "spriteName": spriteName(for: point), + "anchorType": iconAnchor(for: point), + ] + if let label = point.label { + attributes["labelSpriteName"] = "\(PinSpriteRenderer.labelSpritePrefix)\(label)" } - - private func spriteName(for point: MapPoint) -> String { - switch point.pinStyle { - case .contactChat: "pin-chat" - case .contactRepeater: "pin-repeater" - case .contactRoom: "pin-room" - case .repeater: "pin-repeater" - case .repeaterRingBlue: "pin-repeater-ring-blue" - case .repeaterRingGreen: "pin-repeater-ring-green" - case .repeaterRingWhite: - if let hop = point.hopIndex { - "pin-repeater-ring-white-hop-\(min(hop, PinSpriteRenderer.maxHopBadge))" - } else { - "pin-repeater-ring-white" - } - case .repeaterHop: - if let hop = point.hopIndex { - "pin-repeater-hop-\(min(hop, PinSpriteRenderer.maxHopBadge))" - } else { - "pin-repeater" - } - case .pointA: "pin-point-a" - case .pointB: "pin-point-b" - case .crosshair: "pin-crosshair" - case .obstruction: "pin-obstruction" - case .droppedPin: "pin-dropped" - case .badge: "pin-badge" - } + if let hopIndex = point.hopIndex { attributes["hopIndex"] = hopIndex } + if let badgeText = point.badgeText { attributes["badgeText"] = badgeText } + feature.attributes = attributes + return feature + } + + private func iconAnchor(for point: MapPoint) -> String { + switch point.pinStyle { + case .crosshair, .obstruction: "center" + default: "bottom" + } + } + + private func spriteName(for point: MapPoint) -> String { + switch point.pinStyle { + case .contactChat: "pin-chat" + case .contactRepeater: "pin-repeater" + case .contactRoom: "pin-room" + case .repeater: "pin-repeater" + case .repeaterRingBlue: "pin-repeater-ring-blue" + case .repeaterRingGreen: "pin-repeater-ring-green" + case .repeaterRingWhite: + if let hop = point.hopIndex { + "pin-repeater-ring-white-hop-\(min(hop, PinSpriteRenderer.maxHopBadge))" + } else { + "pin-repeater-ring-white" + } + case .repeaterHop: + if let hop = point.hopIndex { + "pin-repeater-hop-\(min(hop, PinSpriteRenderer.maxHopBadge))" + } else { + "pin-repeater" + } + case .pointA: "pin-point-a" + case .pointB: "pin-point-b" + case .crosshair: "pin-crosshair" + case .obstruction: "pin-obstruction" + case .droppedPin: "pin-dropped" + case .badge: "pin-badge" } + } } diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 39a68107..a6c41977 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -1,5 +1,5 @@ -import MapLibre import MapKit +import MapLibre import ObjectiveC import OSLog import SwiftUI @@ -15,532 +15,565 @@ private let logger = Logger(subsystem: "com.mc1", category: "MapPins") /// MapLibre's internal Metal UIView so the wrong scale is never stored. /// Upstream issue: https://github.com/maplibre/maplibre-native/issues/3214 private enum MetalLayerScaleFix { + @MainActor + static func apply(to mapView: MLNMapView) { + guard let metalView = findMetalView(in: mapView) else { return } + + let selector = NSSelectorFromString("setDrawableSize:") + guard metalView.responds(to: selector) else { return } + + guard let originalClass: AnyClass = object_getClass(metalView) else { return } + let name = "_MC1FixedScale_\(NSStringFromClass(originalClass))" + + let fixedClass: AnyClass + if let existing = objc_getClass(name) as? AnyClass { + fixedClass = existing + } else { + guard let subclass = objc_allocateClassPair(originalClass, name, 0) else { return } + addDrawableSizeOverride(to: subclass, originalClass: originalClass) + addContentScaleFactorOverride(to: subclass, originalClass: originalClass) + objc_registerClassPair(subclass) + fixedClass = subclass + } - @MainActor - static func apply(to mapView: MLNMapView) { - guard let metalView = findMetalView(in: mapView) else { return } - - let selector = NSSelectorFromString("setDrawableSize:") - guard metalView.responds(to: selector) else { return } - - guard let originalClass: AnyClass = object_getClass(metalView) else { return } - let name = "_MC1FixedScale_\(NSStringFromClass(originalClass))" - - let fixedClass: AnyClass - if let existing = objc_getClass(name) as? AnyClass { - fixedClass = existing - } else { - guard let subclass = objc_allocateClassPair(originalClass, name, 0) else { return } - addDrawableSizeOverride(to: subclass, originalClass: originalClass) - addContentScaleFactorOverride(to: subclass, originalClass: originalClass) - objc_registerClassPair(subclass) - fixedClass = subclass - } + object_setClass(metalView, fixedClass) + } - object_setClass(metalView, fixedClass) + @MainActor + private static func findMetalView(in view: UIView) -> UIView? { + for subview in view.subviews where subview.layer is CAMetalLayer { + return subview } - - @MainActor - private static func findMetalView(in view: UIView) -> UIView? { - for subview in view.subviews where subview.layer is CAMetalLayer { - return subview - } - return nil + return nil + } + + @MainActor + private static func findMapView(from metalView: UIView) -> MLNMapView? { + var parent: UIView? = metalView.superview + while let v = parent, !(v is MLNMapView) { + parent = v.superview } + return parent as? MLNMapView + } + + private static func addDrawableSizeOverride( + to subclass: AnyClass, + originalClass: AnyClass + ) { + let selector = NSSelectorFromString("setDrawableSize:") + guard let original = class_getInstanceMethod(originalClass, selector) else { return } + let originalIMP = method_getImplementation(original) + typealias SetDrawableSizeFn = @convention(c) @Sendable (AnyObject, Selector, CGSize) -> Void + let callOriginal = unsafeBitCast(originalIMP, to: SetDrawableSizeFn.self) + + let block: @convention(block) (UIView, CGSize) -> Void = { metalView, proposedSize in + dispatchPrecondition(condition: .onQueue(.main)) + MainActor.assumeIsolated { + guard let mapView = findMapView(from: metalView), + mapView.bounds.size.width > 0, + mapView.bounds.size.height > 0, + let screen = mapView.window?.screen else { + callOriginal(metalView, selector, proposedSize) + return + } - @MainActor - private static func findMapView(from metalView: UIView) -> MLNMapView? { - var parent: UIView? = metalView.superview - while let v = parent, !(v is MLNMapView) { parent = v.superview } - return parent as? MLNMapView - } + let correctScale = screen.nativeScale + let correctSize = CGSize( + width: mapView.bounds.width * correctScale, + height: mapView.bounds.height * correctScale + ) - private static func addDrawableSizeOverride( - to subclass: AnyClass, - originalClass: AnyClass - ) { - let selector = NSSelectorFromString("setDrawableSize:") - guard let original = class_getInstanceMethod(originalClass, selector) else { return } - let originalIMP = method_getImplementation(original) - typealias SetDrawableSizeFn = @convention(c) @Sendable (AnyObject, Selector, CGSize) -> Void - let callOriginal = unsafeBitCast(originalIMP, to: SetDrawableSizeFn.self) - - let block: @convention(block) (UIView, CGSize) -> Void = { metalView, proposedSize in - dispatchPrecondition(condition: .onQueue(.main)) - MainActor.assumeIsolated { - guard let mapView = findMapView(from: metalView), - mapView.bounds.size.width > 0, - mapView.bounds.size.height > 0, - let screen = mapView.window?.screen else { - callOriginal(metalView, selector, proposedSize) - return - } - - let correctScale = screen.nativeScale - let correctSize = CGSize( - width: mapView.bounds.width * correctScale, - height: mapView.bounds.height * correctScale - ) - - if let layer = metalView.layer as? CAMetalLayer, - layer.drawableSize == correctSize { - return - } - - callOriginal(metalView, selector, correctSize) - } + if let layer = metalView.layer as? CAMetalLayer, + layer.drawableSize == correctSize { + return } - let imp = imp_implementationWithBlock(block) - class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + callOriginal(metalView, selector, correctSize) + } } - private static func addContentScaleFactorOverride( - to subclass: AnyClass, - originalClass: AnyClass - ) { - let selector = NSSelectorFromString("setContentScaleFactor:") - guard let original = class_getInstanceMethod(originalClass, selector) else { return } - let originalIMP = method_getImplementation(original) - typealias SetScaleFn = @convention(c) @Sendable (AnyObject, Selector, CGFloat) -> Void - let callOriginal = unsafeBitCast(originalIMP, to: SetScaleFn.self) - - let block: @convention(block) (UIView, CGFloat) -> Void = { metalView, _ in - dispatchPrecondition(condition: .onQueue(.main)) - MainActor.assumeIsolated { - guard let mapView = findMapView(from: metalView), - let screen = mapView.window?.screen else { - return - } - - let correctScale = screen.nativeScale - if metalView.contentScaleFactor == correctScale { - return - } - - callOriginal(metalView, selector, correctScale) - } + let imp = imp_implementationWithBlock(block) + class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + } + + private static func addContentScaleFactorOverride( + to subclass: AnyClass, + originalClass: AnyClass + ) { + let selector = NSSelectorFromString("setContentScaleFactor:") + guard let original = class_getInstanceMethod(originalClass, selector) else { return } + let originalIMP = method_getImplementation(original) + typealias SetScaleFn = @convention(c) @Sendable (AnyObject, Selector, CGFloat) -> Void + let callOriginal = unsafeBitCast(originalIMP, to: SetScaleFn.self) + + let block: @convention(block) (UIView, CGFloat) -> Void = { metalView, _ in + dispatchPrecondition(condition: .onQueue(.main)) + MainActor.assumeIsolated { + guard let mapView = findMapView(from: metalView), + let screen = mapView.window?.screen else { + return + } + + let correctScale = screen.nativeScale + if metalView.contentScaleFactor == correctScale { + return } - let imp = imp_implementationWithBlock(block) - class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + callOriginal(metalView, selector, correctScale) + } } + + let imp = imp_implementationWithBlock(block) + class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + } } /// Applies the isa-swizzle once the view is attached to a window. private final class ScaledMLNMapView: MLNMapView { - override func didMoveToWindow() { - super.didMoveToWindow() - guard window != nil else { return } - MetalLayerScaleFix.apply(to: self) - } + override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil else { return } + MetalLayerScaleFix.apply(to: self) + } } struct MC1MapView: UIViewRepresentable { - // Data - let points: [MapPoint] - let lines: [MapLine] - let mapStyle: MapStyleSelection - let isDarkMode: Bool - var isOffline: Bool = false - - // Configuration - let showLabels: Bool - let showsUserLocation: Bool - let isInteractive: Bool - let showsScale: Bool - var isNorthLocked: Bool = false - - // Camera - @Binding var cameraRegion: MKCoordinateRegion? - let cameraRegionVersion: Int - var cameraEdgePadding: UIEdgeInsets = .zero - var cameraBottomSheetFraction: CGFloat? - - // Output callbacks - let onPointTap: ((MapPoint, CGPoint) -> Void)? - let onMapTap: ((CLLocationCoordinate2D) -> Void)? - let onCameraRegionChange: ((MKCoordinateRegion) -> Void)? - - // Optional features - var isStyleLoaded: Binding = .constant(true) - - func makeCoordinator() -> Coordinator { - Coordinator() + // Data + let points: [MapPoint] + let lines: [MapLine] + let mapStyle: MapStyleSelection + let isDarkMode: Bool + var isOffline: Bool = false + + // Configuration + let showLabels: Bool + let showsUserLocation: Bool + let isInteractive: Bool + let showsScale: Bool + var isNorthLocked: Bool = false + + // Camera + @Binding var cameraRegion: MKCoordinateRegion? + let cameraRegionVersion: Int + var cameraEdgePadding: UIEdgeInsets = .zero + var cameraBottomSheetFraction: CGFloat? + + // Output callbacks + let onPointTap: ((MapPoint, CGPoint) -> Void)? + let onMapTap: ((CLLocationCoordinate2D) -> Void)? + var onMapLongPress: ((CLLocationCoordinate2D) -> Void)? + let onCameraRegionChange: ((MKCoordinateRegion) -> Void)? + + /// Optional features + var isStyleLoaded: Binding = .constant(true) + + /// Reports whether the camera is currently centered on the user's location. + var isCenteredOnUser: Binding = .constant(false) + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> MLNMapView { + let mapView = context.coordinator.mapView + mapView.delegate = context.coordinator + + mapView.showsUserLocation = showsUserLocation + mapView.compassViewPosition = .topRight + mapView.compassViewMargins = CGPoint(x: 8, y: 8) + mapView.attributionButtonPosition = .bottomLeft + mapView.attributionButtonMargins = CGPoint(x: 4, y: 30) + + if showsScale { + mapView.showsScale = true } - func makeUIView(context: Context) -> MLNMapView { - let mapView = context.coordinator.mapView - mapView.delegate = context.coordinator - - mapView.showsUserLocation = showsUserLocation - mapView.compassViewPosition = .topRight - mapView.compassViewMargins = CGPoint(x: 8, y: 8) - mapView.attributionButtonPosition = .bottomLeft - mapView.attributionButtonMargins = CGPoint(x: 4, y: 30) - - if showsScale { - mapView.showsScale = true - } - - if !isInteractive { - mapView.isScrollEnabled = false - mapView.isZoomEnabled = false - mapView.isRotateEnabled = false - mapView.isPitchEnabled = false - mapView.compassView.isHidden = true - } - - // Disable quick-zoom (tap-then-hold-drag) gesture - mapView.gestureRecognizers? - .compactMap { $0 as? UILongPressGestureRecognizer } - .filter { $0.numberOfTapsRequired == 1 && $0.minimumPressDuration == 0 } - .forEach { $0.isEnabled = false } + if !isInteractive { + mapView.isScrollEnabled = false + mapView.isZoomEnabled = false + mapView.isRotateEnabled = false + mapView.isPitchEnabled = false + mapView.compassView.isHidden = true + } - // Tap gesture for feature queries - let tap = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleTap(_:)) - ) - tap.delegate = context.coordinator - mapView.addGestureRecognizer(tap) + // Disable quick-zoom (tap-then-hold-drag) gesture + mapView.gestureRecognizers? + .compactMap { $0 as? UILongPressGestureRecognizer } + .filter { $0.numberOfTapsRequired == 1 && $0.minimumPressDuration == 0 } + .forEach { $0.isEnabled = false } + + // Tap gesture for feature queries + let tap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + ) + tap.delegate = context.coordinator + mapView.addGestureRecognizer(tap) + + // Long-press gesture for dropping a pin at the pressed coordinate + let longPress = UILongPressGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleLongPress(_:)) + ) + longPress.delegate = context.coordinator + mapView.addGestureRecognizer(longPress) + + return mapView + } + + static func dismantleUIView(_ mapView: MLNMapView, coordinator: Coordinator) { + coordinator.pendingRegionTask?.cancel() + mapView.delegate = nil + } + + func updateUIView(_ mapView: MLNMapView, context: Context) { + let coordinator = context.coordinator + coordinator.isUpdatingFromSwiftUI = true + defer { coordinator.isUpdatingFromSwiftUI = false } + + // Refresh callbacks + coordinator.onPointTap = onPointTap + coordinator.onMapTap = onMapTap + coordinator.onMapLongPress = onMapLongPress + coordinator.onCameraRegionChange = onCameraRegionChange + coordinator.setIsStyleLoaded = { isStyleLoaded.wrappedValue = $0 } + coordinator.setIsCenteredOnUser = { isCenteredOnUser.wrappedValue = $0 } + coordinator.currentPoints = points + coordinator.currentLines = lines + + // Style URL change — compare against our tracked value, not mapView.styleURL + // which MapLibre may transiently nil during layout/rotation. + let newStyleURL = mapStyle.styleURL(isDarkMode: isDarkMode, isOffline: isOffline) + if coordinator.lastAppliedStyleURL != newStyleURL { + coordinator.lastAppliedStyleURL = newStyleURL + coordinator.isStyleLoaded = false + mapView.styleURL = newStyleURL + } + coordinator.currentMapStyle = mapStyle - return mapView + // User location + if mapView.showsUserLocation != showsUserLocation { + mapView.showsUserLocation = showsUserLocation } - static func dismantleUIView(_ mapView: MLNMapView, coordinator: Coordinator) { - coordinator.pendingRegionTask?.cancel() - mapView.delegate = nil + // North lock + if isInteractive { + mapView.isRotateEnabled = !isNorthLocked + if isNorthLocked, mapView.direction != 0 { + mapView.setDirection(0, animated: true) + } } - func updateUIView(_ mapView: MLNMapView, context: Context) { - let coordinator = context.coordinator - coordinator.isUpdatingFromSwiftUI = true - defer { coordinator.isUpdatingFromSwiftUI = false } - - // Refresh callbacks - coordinator.onPointTap = onPointTap - coordinator.onMapTap = onMapTap - coordinator.onCameraRegionChange = onCameraRegionChange - coordinator.setIsStyleLoaded = { isStyleLoaded.wrappedValue = $0 } - coordinator.currentPoints = points - coordinator.currentLines = lines - - // Style URL change — compare against our tracked value, not mapView.styleURL - // which MapLibre may transiently nil during layout/rotation. - let newStyleURL = mapStyle.styleURL(isDarkMode: isDarkMode, isOffline: isOffline) - if coordinator.lastAppliedStyleURL != newStyleURL { - coordinator.lastAppliedStyleURL = newStyleURL - coordinator.isStyleLoaded = false - mapView.styleURL = newStyleURL - } - coordinator.currentMapStyle = mapStyle + // Update data layers (only when style is loaded and not mid-gesture). + // Compare against lastApplied* so updates arriving during a gesture + // are applied once the gesture ends. + if coordinator.isStyleLoaded, !coordinator.isUserInteracting { + if coordinator.lastAppliedMapStyle != mapStyle { + coordinator.updateRasterLayerVisibility(mapView: mapView) + coordinator.lastAppliedMapStyle = mapStyle + } + if coordinator.lastAppliedPoints != points { + coordinator.updatePointSource(mapView: mapView) + coordinator.lastAppliedPoints = points + } + if coordinator.lastAppliedLines != lines { + coordinator.updateLineSource(mapView: mapView) + coordinator.lastAppliedLines = lines + } + if coordinator.currentShowLabels != showLabels { + coordinator.currentShowLabels = showLabels + coordinator.updateLabelVisibility(mapView: mapView, showLabels: showLabels) + } + } - // User location - if mapView.showsUserLocation != showsUserLocation { - mapView.showsUserLocation = showsUserLocation - } + // Camera region (version-number pattern) + updateCameraRegion(in: mapView, coordinator: coordinator) + } - // North lock - if isInteractive { - mapView.isRotateEnabled = !isNorthLocked - if isNorthLocked && mapView.direction != 0 { - mapView.setDirection(0, animated: true) - } - } + /// Maximum absolute latitude MapLibre's `mbgl::LatLng` accepts; it throws an + /// uncaught `std::domain_error` (aborting the app) for any value beyond ±90. + private static let latitudeLimit = 90.0 - // Update data layers (only when style is loaded and not mid-gesture). - // Compare against lastApplied* so updates arriving during a gesture - // are applied once the gesture ends. - if coordinator.isStyleLoaded, !coordinator.isUserInteracting { - if coordinator.lastAppliedMapStyle != mapStyle { - coordinator.updateRasterLayerVisibility(mapView: mapView) - coordinator.lastAppliedMapStyle = mapStyle - } - if coordinator.lastAppliedPoints != points { - coordinator.updatePointSource(mapView: mapView) - coordinator.lastAppliedPoints = points - } - if coordinator.lastAppliedLines != lines { - coordinator.updateLineSource(mapView: mapView) - coordinator.lastAppliedLines = lines - } - if coordinator.currentShowLabels != showLabels { - coordinator.currentShowLabels = showLabels - coordinator.updateLabelVisibility(mapView: mapView, showLabels: showLabels) - } - } + private func updateCameraRegion(in mapView: MLNMapView, coordinator: Coordinator) { + guard let region = cameraRegion else { return } + guard cameraRegionVersion != coordinator.lastAppliedRegionVersion else { return } - // Camera region (version-number pattern) - updateCameraRegion(in: mapView, coordinator: coordinator) + guard CLLocationCoordinate2DIsValid(region.center) else { + coordinator.lastAppliedRegionVersion = cameraRegionVersion + return } - /// Maximum absolute latitude MapLibre's `mbgl::LatLng` accepts; it throws an - /// uncaught `std::domain_error` (aborting the app) for any value beyond ±90. - private static let latitudeLimit = 90.0 - - private func updateCameraRegion(in mapView: MLNMapView, coordinator: Coordinator) { - guard let region = cameraRegion else { return } - guard cameraRegionVersion != coordinator.lastAppliedRegionVersion else { return } - - guard CLLocationCoordinate2DIsValid(region.center) else { - coordinator.lastAppliedRegionVersion = cameraRegionVersion - return - } - - // Corners are center ± span/2, so a non-finite span makes MapLibre's LatLng - // constructor throw and abort the process — and the latitude clamp below can't - // catch it because Swift's max/min propagate NaN. Skip the update when non-finite. - guard region.span.latitudeDelta.isFinite, - region.span.longitudeDelta.isFinite else { - coordinator.lastAppliedRegionVersion = cameraRegionVersion - return - } + // Corners are center ± span/2, so a non-finite span makes MapLibre's LatLng + // constructor throw and abort the process — and the latitude clamp below can't + // catch it because Swift's max/min propagate NaN. Skip the update when non-finite. + guard region.span.latitudeDelta.isFinite, + region.span.longitudeDelta.isFinite else { + coordinator.lastAppliedRegionVersion = cameraRegionVersion + return + } - let isInflated = mapView.window.map { mapView.bounds.height > $0.bounds.height * 1.5 } ?? false - let animated = coordinator.lastAppliedRegionVersion > 0 && !isInflated - coordinator.lastAppliedRegionVersion = cameraRegionVersion - - // Clamp latitude so a near-pole center can't push a corner past ±90 (another - // LatLng abort). Longitude is left unclamped because MapLibre wraps it. - let limit = Self.latitudeLimit - let bounds = MLNCoordinateBounds( - sw: CLLocationCoordinate2D( - latitude: max(-limit, region.center.latitude - region.span.latitudeDelta / 2), - longitude: region.center.longitude - region.span.longitudeDelta / 2 - ), - ne: CLLocationCoordinate2D( - latitude: min(limit, region.center.latitude + region.span.latitudeDelta / 2), - longitude: region.center.longitude + region.span.longitudeDelta / 2 - ) - ) - var padding = cameraEdgePadding - if let sheetFraction = cameraBottomSheetFraction { - let insets = mapView.safeAreaInsets - padding.top = max(padding.top, insets.top + 20) - padding.left = max(padding.left, insets.left + 20) - if sheetFraction > 0 { - let stableHeight = mapView.window?.bounds.height ?? mapView.bounds.height - padding.bottom = max(padding.bottom, stableHeight * sheetFraction) - } - } + let isInflated = mapView.window.map { mapView.bounds.height > $0.bounds.height * 1.5 } ?? false + let animated = coordinator.lastAppliedRegionVersion > 0 && !isInflated + coordinator.lastAppliedRegionVersion = cameraRegionVersion + + // Clamp latitude so a near-pole center can't push a corner past ±90 (another + // LatLng abort). Longitude is left unclamped because MapLibre wraps it. + let limit = Self.latitudeLimit + let bounds = MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: max(-limit, region.center.latitude - region.span.latitudeDelta / 2), + longitude: region.center.longitude - region.span.longitudeDelta / 2 + ), + ne: CLLocationCoordinate2D( + latitude: min(limit, region.center.latitude + region.span.latitudeDelta / 2), + longitude: region.center.longitude + region.span.longitudeDelta / 2 + ) + ) + var padding = cameraEdgePadding + if let sheetFraction = cameraBottomSheetFraction { + let insets = mapView.safeAreaInsets + padding.top = max(padding.top, insets.top + 20) + padding.left = max(padding.left, insets.left + 20) + if sheetFraction > 0 { + let stableHeight = mapView.window?.bounds.height ?? mapView.bounds.height + padding.bottom = max(padding.bottom, stableHeight * sheetFraction) + } + } - if let windowSize = mapView.window?.bounds.size, - mapView.bounds.height > windowSize.height * 1.5 { - let centerLat = (bounds.sw.latitude + bounds.ne.latitude) / 2 - let centerLon = (bounds.sw.longitude + bounds.ne.longitude) / 2 - let latSpanMeters = abs(bounds.ne.latitude - bounds.sw.latitude) * 111_000 - let lonSpanMeters = abs(bounds.ne.longitude - bounds.sw.longitude) * 111_000 - * cos(centerLat * .pi / 180) - - let usableWidth = max(1, Double(windowSize.width) - Double(padding.left + padding.right)) - let usableHeight = max(1, Double(windowSize.height) - Double(padding.top + padding.bottom)) - - let mppForLat = latSpanMeters / usableHeight - let mppForLon = lonSpanMeters / usableWidth - let requiredMPP = max(mppForLat, mppForLon) - - let currentMPP = mapView.metersPerPoint(atLatitude: centerLat) - let targetZoom = mapView.zoomLevel + log2(currentMPP / requiredMPP) - - let pixelOffset = (Double(padding.top) - Double(padding.bottom)) / 2 - let offsetDeg = pixelOffset * requiredMPP / 111_000 - let center = CLLocationCoordinate2D( - latitude: min(limit, max(-limit, centerLat + offsetDeg)), - longitude: centerLon - ) - - mapView.setCenter(center, zoomLevel: targetZoom, animated: false) - } else { - mapView.setVisibleCoordinateBounds( - bounds, - edgePadding: padding, - animated: animated, - completionHandler: nil - ) - } + if let windowSize = mapView.window?.bounds.size, + mapView.bounds.height > windowSize.height * 1.5 { + let centerLat = (bounds.sw.latitude + bounds.ne.latitude) / 2 + let centerLon = (bounds.sw.longitude + bounds.ne.longitude) / 2 + let latSpanMeters = abs(bounds.ne.latitude - bounds.sw.latitude) * 111_000 + let lonSpanMeters = abs(bounds.ne.longitude - bounds.sw.longitude) * 111_000 + * cos(centerLat * .pi / 180) + + let usableWidth = max(1, Double(windowSize.width) - Double(padding.left + padding.right)) + let usableHeight = max(1, Double(windowSize.height) - Double(padding.top + padding.bottom)) + + let mppForLat = latSpanMeters / usableHeight + let mppForLon = lonSpanMeters / usableWidth + let requiredMPP = max(mppForLat, mppForLon) + + let currentMPP = mapView.metersPerPoint(atLatitude: centerLat) + let targetZoom = mapView.zoomLevel + log2(currentMPP / requiredMPP) + + let pixelOffset = (Double(padding.top) - Double(padding.bottom)) / 2 + let offsetDeg = pixelOffset * requiredMPP / 111_000 + let center = CLLocationCoordinate2D( + latitude: min(limit, max(-limit, centerLat + offsetDeg)), + longitude: centerLon + ) + + mapView.setCenter(center, zoomLevel: targetZoom, animated: false) + } else { + mapView.setVisibleCoordinateBounds( + bounds, + edgePadding: padding, + animated: animated, + completionHandler: nil + ) } + } } // MARK: - Coordinator extension MC1MapView { - @MainActor - class Coordinator: NSObject, @preconcurrency MLNMapViewDelegate, UIGestureRecognizerDelegate { - // Non-zero frame avoids MapLibre zero-size Metal init (issue #67). - let mapView: MLNMapView = ScaledMLNMapView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) - - // Callbacks - var onPointTap: ((MapPoint, CGPoint) -> Void)? - var onMapTap: ((CLLocationCoordinate2D) -> Void)? - var onCameraRegionChange: ((MKCoordinateRegion) -> Void)? - var setIsStyleLoaded: ((Bool) -> Void)? - - // State - var isUserInteracting = false - var isUpdatingFromSwiftUI = false - var isStyleLoaded = false - var lastAppliedRegionVersion = 0 - var pendingRegionTask: Task? - var currentShowLabels = true - var lastAppliedStyleURL: URL? - var currentMapStyle: MapStyleSelection? - var lastAppliedMapStyle: MapStyleSelection? - var currentPoints: [MapPoint] = [] - var currentLines: [MapLine] = [] - var lastAppliedPoints: [MapPoint] = [] - var lastAppliedClusterablePoints: [MapPoint] = [] - var lastAppliedFixedPoints: [MapPoint] = [] - var lastAppliedLines: [MapLine] = [] - var clusterSource: MLNShapeSource? - var fixedSource: MLNShapeSource? - - // MARK: - Style loading - - func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { - isStyleLoaded = true - setIsStyleLoaded?(true) - - // Clear stale source/state references from the previous style. - // Reset currentShowLabels to the new layer default (visible) so - // updateUIView detects the mismatch and reapplies the user's preference. - // A reload rebuilds the raster layers with isVisible == false, so clear - // lastAppliedMapStyle to force updateUIView to re-apply the selected overlay. - clusterSource = nil - fixedSource = nil - lastAppliedPoints = [] - lastAppliedClusterablePoints = [] - lastAppliedFixedPoints = [] - lastAppliedLines = [] - lastAppliedMapStyle = nil - currentShowLabels = true - - PinSpriteRenderer.renderAll(into: style) - setupRasterSources(style: style, mapView: mapView) - setupLineLayers(style: style) - - updatePointSource(mapView: mapView) - updateLineSource(mapView: mapView) - } + @MainActor + class Coordinator: NSObject, @preconcurrency MLNMapViewDelegate, UIGestureRecognizerDelegate { + /// Non-zero frame avoids MapLibre zero-size Metal init (issue #67). + let mapView: MLNMapView = ScaledMLNMapView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + + // Callbacks + var onPointTap: ((MapPoint, CGPoint) -> Void)? + var onMapTap: ((CLLocationCoordinate2D) -> Void)? + var onMapLongPress: ((CLLocationCoordinate2D) -> Void)? + var onCameraRegionChange: ((MKCoordinateRegion) -> Void)? + var setIsStyleLoaded: ((Bool) -> Void)? + var setIsCenteredOnUser: ((Bool) -> Void)? + + // State + var isUserInteracting = false + var isUpdatingFromSwiftUI = false + var isStyleLoaded = false + var lastAppliedRegionVersion = 0 + var pendingRegionTask: Task? + var currentShowLabels = true + var lastAppliedStyleURL: URL? + var currentMapStyle: MapStyleSelection? + var lastAppliedMapStyle: MapStyleSelection? + var currentPoints: [MapPoint] = [] + var currentLines: [MapLine] = [] + var lastAppliedPoints: [MapPoint] = [] + var lastAppliedClusterablePoints: [MapPoint] = [] + var lastAppliedFixedPoints: [MapPoint] = [] + var lastAppliedLines: [MapLine] = [] + var clusterSource: MLNShapeSource? + var fixedSource: MLNShapeSource? + + // MARK: - Style loading + + func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { + isStyleLoaded = true + setIsStyleLoaded?(true) + + // Clear stale source/state references from the previous style. + // Reset currentShowLabels to the new layer default (visible) so + // updateUIView detects the mismatch and reapplies the user's preference. + // A reload rebuilds the raster layers with isVisible == false, so clear + // lastAppliedMapStyle to force updateUIView to re-apply the selected overlay. + clusterSource = nil + fixedSource = nil + lastAppliedPoints = [] + lastAppliedClusterablePoints = [] + lastAppliedFixedPoints = [] + lastAppliedLines = [] + lastAppliedMapStyle = nil + currentShowLabels = true + + PinSpriteRenderer.renderAll(into: style) + setupRasterSources(style: style, mapView: mapView) + setupLineLayers(style: style) + + updatePointSource(mapView: mapView) + updateLineSource(mapView: mapView) + } - func mapView(_ mapView: MLNMapView, didFailToLoadImage imageName: String) -> UIImage? { - if let style = mapView.style, - let image = PinSpriteRenderer.renderOnDemand(name: imageName, into: style) { - return image - } - logger.error("didFailToLoadImage: \(imageName)") - return nil - } + func mapView(_ mapView: MLNMapView, didFailToLoadImage imageName: String) -> UIImage? { + if let style = mapView.style, + let image = PinSpriteRenderer.renderOnDemand(name: imageName, into: style) { + return image + } + logger.error("didFailToLoadImage: \(imageName)") + return nil + } - // MARK: - Region changes + // MARK: - Region changes - private static let userGestureReasons: MLNCameraChangeReason = [ - .gesturePan, .gesturePinch, .gestureZoomIn, .gestureZoomOut, - .gestureRotate, .gestureTilt, .gestureOneFingerZoom - ] + private static let userGestureReasons: MLNCameraChangeReason = [ + .gesturePan, .gesturePinch, .gestureZoomIn, .gestureZoomOut, + .gestureRotate, .gestureTilt, .gestureOneFingerZoom + ] - func mapViewRegionIsChanging(_ mapView: MLNMapView) { - isUserInteracting = true - } + func mapViewRegionIsChanging(_ mapView: MLNMapView) { + isUserInteracting = true + } - func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { - isUserInteracting = false - guard !isUpdatingFromSwiftUI else { return } - - let isUserGesture = !reason.isDisjoint(with: Self.userGestureReasons) - guard isUserGesture else { return } - - // Debounce: cancel previous pending write-back - pendingRegionTask?.cancel() - pendingRegionTask = Task { - try? await Task.sleep(for: .milliseconds(50)) - guard !Task.isCancelled else { return } - let region = mapView.mlnRegion - self.onCameraRegionChange?(region) - } - } + func mapView(_ mapView: MLNMapView, regionWillChangeWith reason: MLNCameraChangeReason, animated: Bool) { + // A user drag/zoom/rotate moves the camera off the user's location, so clear the + // centered-on-user flag. Programmatic recenters keep it — the location button sets + // it back to true when it centers the map. + guard !reason.isDisjoint(with: Self.userGestureReasons) else { return } + let report = setIsCenteredOnUser + DispatchQueue.main.async { report?(false) } + } - // MARK: - Gesture recognizer delegate + func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { + isUserInteracting = false + guard !isUpdatingFromSwiftUI else { return } + + let isUserGesture = !reason.isDisjoint(with: Self.userGestureReasons) + guard isUserGesture else { return } + + // Debounce: cancel previous pending write-back + pendingRegionTask?.cancel() + pendingRegionTask = Task { + try? await Task.sleep(for: .milliseconds(50)) + guard !Task.isCancelled else { return } + let region = mapView.mlnRegion + self.onCameraRegionChange?(region) + } + } - nonisolated func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer - ) -> Bool { - true - } + // MARK: - Gesture recognizer delegate - // MARK: - Tap handling - - @objc func handleTap(_ sender: UITapGestureRecognizer) { - guard sender.state == .ended else { return } - let point = sender.location(in: mapView) - let clusterRect = CGRect(x: point.x - 22, y: point.y - 22, width: 44, height: 44) - logger.debug("handleTap at \(point.x, privacy: .public), \(point.y, privacy: .public)") - - // 1. Check cluster layers - let clusterFeatures = mapView.visibleFeatures( - in: clusterRect, - styleLayerIdentifiers: [MapLayerID.clusterCircles] - ) - if let cluster = clusterFeatures.first(where: { $0 is MLNPointFeatureCluster }) as? MLNPointFeatureCluster, - let source = mapView.style?.source(withIdentifier: MapSourceID.points) as? MLNShapeSource { - let zoom = source.zoomLevel(forExpanding: cluster) - guard zoom >= 0 else { return } - mapView.setCenter(cluster.coordinate, zoomLevel: zoom + 2.0, animated: true) - return - } - - // 2. Check point and name label layers (both clustered and fixed) - let pointFeatures = mapView.visibleFeatures( - at: point, - styleLayerIdentifiers: [ - MapLayerID.unclusteredIcons, MapLayerID.fixedIcons, - MapLayerID.nameLabels, MapLayerID.fixedNameLabels - ] - ) - logger.debug("pointFeatures: \(pointFeatures.count, privacy: .public), clusterFeatures: \(clusterFeatures.count, privacy: .public)") - if let feature = pointFeatures.first, - let idString = feature.attribute(forKey: "pointId") as? String, - let id = UUID(uuidString: idString), - let mapPoint = currentPoints.first(where: { $0.id == id }) { - logger.debug("Matched pin: \(mapPoint.label ?? "unnamed", privacy: .public)") - let pinScreenPos = mapView.convert(mapPoint.coordinate, toPointTo: mapView) - let calloutAnchor = CGPoint(x: pinScreenPos.x, y: pinScreenPos.y - PinSpriteRenderer.standardHeight) - onPointTap?(mapPoint, calloutAnchor) - return - } - - // 3. Check badge text layers — dismiss any open callout but don't select - let badgeFeatures = mapView.visibleFeatures( - at: point, - styleLayerIdentifiers: [MapLayerID.badgeText, MapLayerID.fixedBadgeText] - ) - if badgeFeatures.first != nil { - let coordinate = mapView.convert(point, toCoordinateFrom: mapView) - onMapTap?(coordinate) - return - } - - // 4. Map background tap - let coordinate = mapView.convert(point, toCoordinateFrom: mapView) - onMapTap?(coordinate) - } + nonisolated func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { + true + } + + // MARK: - Tap handling + + @objc func handleTap(_ sender: UITapGestureRecognizer) { + guard sender.state == .ended else { return } + let point = sender.location(in: mapView) + let clusterRect = CGRect(x: point.x - 22, y: point.y - 22, width: 44, height: 44) + logger.debug("handleTap at \(point.x, privacy: .public), \(point.y, privacy: .public)") + + // 1. Check cluster layers + let clusterFeatures = mapView.visibleFeatures( + in: clusterRect, + styleLayerIdentifiers: [MapLayerID.clusterCircles] + ) + if let cluster = clusterFeatures.first(where: { $0 is MLNPointFeatureCluster }) as? MLNPointFeatureCluster, + let source = mapView.style?.source(withIdentifier: MapSourceID.points) as? MLNShapeSource { + let zoom = source.zoomLevel(forExpanding: cluster) + guard zoom >= 0 else { return } + mapView.setCenter(cluster.coordinate, zoomLevel: zoom + 2.0, animated: true) + return + } + + // 2. Check point and name label layers (both clustered and fixed) + let pointFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: [ + MapLayerID.unclusteredIcons, MapLayerID.fixedIcons, + MapLayerID.nameLabels, MapLayerID.fixedNameLabels + ] + ) + logger.debug("pointFeatures: \(pointFeatures.count, privacy: .public), clusterFeatures: \(clusterFeatures.count, privacy: .public)") + if let feature = pointFeatures.first, + let idString = feature.attribute(forKey: "pointId") as? String, + let id = UUID(uuidString: idString), + let mapPoint = currentPoints.first(where: { $0.id == id }) { + logger.debug("Matched pin: \(mapPoint.label ?? "unnamed", privacy: .public)") + let pinScreenPos = mapView.convert(mapPoint.coordinate, toPointTo: mapView) + let calloutAnchor = CGPoint(x: pinScreenPos.x, y: pinScreenPos.y - PinSpriteRenderer.standardHeight) + onPointTap?(mapPoint, calloutAnchor) + return + } + + // 3. Check badge text layers — dismiss any open callout but don't select + let badgeFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: [MapLayerID.badgeText, MapLayerID.fixedBadgeText] + ) + if badgeFeatures.first != nil { + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapTap?(coordinate) + return + } + + // 4. Map background tap + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapTap?(coordinate) + } + + @objc func handleLongPress(_ sender: UILongPressGestureRecognizer) { + guard sender.state == .began else { return } + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapLongPress?(coordinate) } + } } // MARK: - MLNMapView region helper extension MLNMapView { - var mlnRegion: MKCoordinateRegion { - let bounds = visibleCoordinateBounds - let center = CLLocationCoordinate2D( - latitude: (bounds.sw.latitude + bounds.ne.latitude) / 2, - longitude: (bounds.sw.longitude + bounds.ne.longitude) / 2 - ) - let span = MKCoordinateSpan( - latitudeDelta: bounds.ne.latitude - bounds.sw.latitude, - longitudeDelta: bounds.ne.longitude - bounds.sw.longitude - ) - return MKCoordinateRegion(center: center, span: span) - } + var mlnRegion: MKCoordinateRegion { + let bounds = visibleCoordinateBounds + let center = CLLocationCoordinate2D( + latitude: (bounds.sw.latitude + bounds.ne.latitude) / 2, + longitude: (bounds.sw.longitude + bounds.ne.longitude) / 2 + ) + let span = MKCoordinateSpan( + latitudeDelta: bounds.ne.latitude - bounds.sw.latitude, + longitudeDelta: bounds.ne.longitude - bounds.sw.longitude + ) + return MKCoordinateRegion(center: center, span: span) + } } diff --git a/MC1/Views/Map/MapCameraStore.swift b/MC1/Views/Map/MapCameraStore.swift index c6a72b7e..8446c736 100644 --- a/MC1/Views/Map/MapCameraStore.swift +++ b/MC1/Views/Map/MapCameraStore.swift @@ -5,26 +5,25 @@ import MapKit /// MapLibre (invalid coordinate, non-finite or non-positive span), so a malformed /// restoration archive can never feed `MLNCoordinateBounds` a process-aborting value. enum MapCameraStore { + /// Component count of the `lat,lon,latDelta,lonDelta` encoding. + private static let componentCount = 4 - /// Component count of the `lat,lon,latDelta,lonDelta` encoding. - private static let componentCount = 4 + static func encode(_ region: MKCoordinateRegion) -> String { + "\(region.center.latitude),\(region.center.longitude)," + + "\(region.span.latitudeDelta),\(region.span.longitudeDelta)" + } - static func encode(_ region: MKCoordinateRegion) -> String { - "\(region.center.latitude),\(region.center.longitude)," - + "\(region.span.latitudeDelta),\(region.span.longitudeDelta)" - } - - static func decode(_ string: String) -> MKCoordinateRegion? { - let parts = string.split(separator: ",").compactMap { Double($0) } - guard parts.count == componentCount else { return nil } + static func decode(_ string: String) -> MKCoordinateRegion? { + let parts = string.split(separator: ",").compactMap { Double($0) } + guard parts.count == componentCount else { return nil } - let center = CLLocationCoordinate2D(latitude: parts[0], longitude: parts[1]) - let span = MKCoordinateSpan(latitudeDelta: parts[2], longitudeDelta: parts[3]) - guard CLLocationCoordinate2DIsValid(center), - span.latitudeDelta.isFinite, span.longitudeDelta.isFinite, - span.latitudeDelta > 0, span.longitudeDelta > 0 else { - return nil - } - return MKCoordinateRegion(center: center, span: span) + let center = CLLocationCoordinate2D(latitude: parts[0], longitude: parts[1]) + let span = MKCoordinateSpan(latitudeDelta: parts[2], longitudeDelta: parts[3]) + guard CLLocationCoordinate2DIsValid(center), + span.latitudeDelta.isFinite, span.longitudeDelta.isFinite, + span.latitudeDelta > 0, span.longitudeDelta > 0 else { + return nil } + return MKCoordinateRegion(center: center, span: span) + } } diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift index bab510be..1caea0f4 100644 --- a/MC1/Views/Map/MapCanvasView.swift +++ b/MC1/Views/Map/MapCanvasView.swift @@ -1,132 +1,119 @@ import MapKit import MapLibre -import SwiftUI import MC1Services +import SwiftUI -/// Canvas wrapping the map content with offline badge, floating controls, and layers menu overlay +/// Canvas wrapping the map content with an offline badge and floating controls. struct MapCanvasView: View { - @Environment(\.appState) private var appState - @Bindable var viewModel: MapViewModel - @Binding var mapStyleSelection: MapStyleSelection - @Binding var showLabels: Bool - @Binding var selectedCalloutContact: ContactDTO? - @Binding var selectedPointScreenPosition: CGPoint? - @Binding var isStyleLoaded: Bool - let onShowContactDetail: (ContactDTO) -> Void - let onNavigateToChat: (ContactDTO) -> Void - let onCenterOnUser: () -> Void - let onClearSelection: () -> Void - let onPersistCamera: (MKCoordinateRegion) -> Void + @Environment(\.appState) private var appState + @Bindable var viewModel: MapViewModel + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showLabels: Bool + @Binding var isNorthLocked: Bool + @Binding var selectedCalloutContact: ContactDTO? + @Binding var selectedPointScreenPosition: CGPoint? + @Binding var isStyleLoaded: Bool + let onShowContactDetail: (ContactDTO) -> Void + let onNavigateToChat: (ContactDTO) -> Void + let onCenterOnUser: () -> Bool + let onClearSelection: () -> Void + let onPersistCamera: (MKCoordinateRegion) -> Void - var body: some View { - ZStack { - MapContentView( - viewModel: viewModel, - mapStyleSelection: mapStyleSelection, - showLabels: showLabels, - selectedCalloutContact: $selectedCalloutContact, - selectedPointScreenPosition: $selectedPointScreenPosition, - isStyleLoaded: $isStyleLoaded, - onShowContactDetail: onShowContactDetail, - onNavigateToChat: onNavigateToChat, - onPersistCamera: onPersistCamera - ) - .ignoresSafeArea() + @State private var isCenteredOnUser = false - // Offline badge - if !appState.offlineMapService.isNetworkAvailable { - OfflineBadge() - } + var body: some View { + ZStack { + MapContentView( + viewModel: viewModel, + mapStyleSelection: mapStyleSelection, + showLabels: showLabels, + isNorthLocked: isNorthLocked, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + isCenteredOnUser: $isCenteredOnUser, + onShowContactDetail: onShowContactDetail, + onNavigateToChat: onNavigateToChat, + onPersistCamera: onPersistCamera + ) + .ignoresSafeArea() - // Floating controls - VStack { - Spacer() - MapCanvasControls( - isNorthLocked: $viewModel.isNorthLocked, - showingLayersMenu: $viewModel.showingLayersMenu, - showLabels: $showLabels, - contactsEmpty: viewModel.contactsWithLocation.isEmpty, - onLocationTap: { onCenterOnUser() }, - onClearSelection: onClearSelection, - onCenterAll: { viewModel.centerOnAllContacts() } - ) - } + // Offline badge + if !appState.offlineMapService.isNetworkAvailable { + OfflineBadge() + } - // Layers menu overlay - if viewModel.showingLayersMenu { - Button { - withAnimation { - viewModel.showingLayersMenu = false - } - } label: { - Color.black.opacity(0.3) - .ignoresSafeArea() - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) - - VStack { - Spacer() - HStack { - Spacer() - LayersMenu( - selection: $mapStyleSelection, - isPresented: $viewModel.showingLayersMenu, - viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() - ) - .padding(.trailing, 72) - .padding(.bottom) - } - } - } - } + // Floating controls + VStack { + Spacer() + MapCanvasControls( + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + mapStyleSelection: $mapStyleSelection, + isCenteredOnUser: isCenteredOnUser, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds(), + contactsEmpty: viewModel.contactsWithLocation.isEmpty, + onLocationTap: { + isCenteredOnUser = onCenterOnUser() + }, + onClearSelection: onClearSelection, + onCenterAll: { + isCenteredOnUser = false + viewModel.centerOnAllContacts() + } + ) + } } - + } } // MARK: - Map Controls private struct MapCanvasControls: View { - @Binding var isNorthLocked: Bool - @Binding var showingLayersMenu: Bool - @Binding var showLabels: Bool - let contactsEmpty: Bool - let onLocationTap: () -> Void - let onClearSelection: () -> Void - let onCenterAll: () -> Void + @Binding var isNorthLocked: Bool + @Binding var showLabels: Bool + @Binding var mapStyleSelection: MapStyleSelection + let isCenteredOnUser: Bool + let viewportBounds: MLNCoordinateBounds? + let contactsEmpty: Bool + let onLocationTap: () -> Void + let onClearSelection: () -> Void + let onCenterAll: () -> Void - var body: some View { - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: onLocationTap, - isNorthLocked: $isNorthLocked, - showLabels: $showLabels, - showingLayersMenu: $showingLayersMenu - ) { - CenterAllButton( - isEmpty: contactsEmpty, - onClearSelection: onClearSelection, - onCenterAll: onCenterAll - ) - } - } + var body: some View { + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: onLocationTap, + isCenteredOnUser: isCenteredOnUser, + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + mapStyleSelection: $mapStyleSelection, + viewportBounds: viewportBounds + ) { + CenterAllButton( + isEmpty: contactsEmpty, + onClearSelection: onClearSelection, + onCenterAll: onCenterAll + ) + } } + } } // MARK: - Control Buttons private struct CenterAllButton: View { - let isEmpty: Bool - let onClearSelection: () -> Void - let onCenterAll: () -> Void + let isEmpty: Bool + let onClearSelection: () -> Void + let onCenterAll: () -> Void - var body: some View { - Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { - onClearSelection() - onCenterAll() - } - .mapControlButton(tint: isEmpty ? .secondary : .primary) - .disabled(isEmpty) + var body: some View { + Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { + onClearSelection() + onCenterAll() } + .mapControlButton(tint: isEmpty ? .secondary : .primary) + .disabled(isEmpty) + } } diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift index 3a9adbc3..8bcc7866 100644 --- a/MC1/Views/Map/MapContentView.swift +++ b/MC1/Views/Map/MapContentView.swift @@ -1,119 +1,121 @@ -import SwiftUI -import MC1Services import CoreLocation import MapKit +import MC1Services +import SwiftUI /// Map content displaying MC1MapView with contact points and popover callouts struct MapContentView: View { - @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme - @Bindable var viewModel: MapViewModel - let mapStyleSelection: MapStyleSelection - let showLabels: Bool - @Binding var selectedCalloutContact: ContactDTO? - @Binding var selectedPointScreenPosition: CGPoint? - @Binding var isStyleLoaded: Bool - let onShowContactDetail: (ContactDTO) -> Void - let onNavigateToChat: (ContactDTO) -> Void - let onPersistCamera: (MKCoordinateRegion) -> Void + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + @Bindable var viewModel: MapViewModel + let mapStyleSelection: MapStyleSelection + let showLabels: Bool + let isNorthLocked: Bool + @Binding var selectedCalloutContact: ContactDTO? + @Binding var selectedPointScreenPosition: CGPoint? + @Binding var isStyleLoaded: Bool + @Binding var isCenteredOnUser: Bool + let onShowContactDetail: (ContactDTO) -> Void + let onNavigateToChat: (ContactDTO) -> Void + let onPersistCamera: (MKCoordinateRegion) -> Void - @State private var selectedDroppedPin: DroppedPinSelection? + @State private var selectedDroppedPin: DroppedPinSelection? - var body: some View { - MC1MapView( - points: viewModel.mapPoints, - lines: [], - mapStyle: mapStyleSelection, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: showLabels, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - isNorthLocked: viewModel.isNorthLocked, - cameraRegion: $viewModel.cameraRegion, - cameraRegionVersion: viewModel.cameraRegionVersion, - onPointTap: { point, screenPosition in - if point.pinStyle == .droppedPin { - selectedCalloutContact = nil - selectedDroppedPin = DroppedPinSelection(coordinate: point.coordinate) - } else { - selectedDroppedPin = nil - selectedCalloutContact = viewModel.contactsWithLocation.first { $0.id == point.id } - } - selectedPointScreenPosition = screenPosition - }, - onMapTap: { _ in - selectedCalloutContact = nil - selectedDroppedPin = nil - selectedPointScreenPosition = nil - }, - onCameraRegionChange: { region in - viewModel.cameraRegion = region - onPersistCamera(region) - if selectedCalloutContact != nil || selectedDroppedPin != nil { - selectedCalloutContact = nil - selectedDroppedPin = nil - selectedPointScreenPosition = nil - } - }, - isStyleLoaded: $isStyleLoaded - ) - .popover( - item: $selectedCalloutContact, - attachmentAnchor: .rect(.rect(CGRect( - origin: selectedPointScreenPosition ?? .zero, - size: CGSize(width: 1, height: 1) - ))), - arrowEdge: .bottom - ) { contact in - ContactCalloutContent( - contact: contact, - onDetail: { onShowContactDetail(contact) }, - onMessage: { onNavigateToChat(contact) } - ) - .presentationCompactAdaptation(.popover) - } - .popover( - item: $selectedDroppedPin, - attachmentAnchor: .rect(.rect(CGRect( - origin: selectedPointScreenPosition ?? .zero, - size: CGSize(width: 1, height: 1) - ))), - arrowEdge: .bottom - ) { selection in - DroppedPinCallout(coordinate: selection.coordinate) { - viewModel.clearFocusedPin() - selectedDroppedPin = nil - } - .presentationCompactAdaptation(.popover) + var body: some View { + MC1MapView( + points: viewModel.mapPoints, + lines: [], + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: isNorthLocked, + cameraRegion: $viewModel.cameraRegion, + cameraRegionVersion: viewModel.cameraRegionVersion, + onPointTap: { point, screenPosition in + if point.pinStyle == .droppedPin { + selectedCalloutContact = nil + selectedDroppedPin = DroppedPinSelection(coordinate: point.coordinate) + } else { + selectedDroppedPin = nil + selectedCalloutContact = viewModel.contactsWithLocation.first { $0.id == point.id } } - .overlay { - if !isStyleLoaded { - ProgressView() - .scaleEffect(1.5) - } else if viewModel.isLoading { - MapLoadingOverlay() - } + selectedPointScreenPosition = screenPosition + }, + onMapTap: { _ in + selectedCalloutContact = nil + selectedDroppedPin = nil + selectedPointScreenPosition = nil + }, + onCameraRegionChange: { region in + viewModel.cameraRegion = region + onPersistCamera(region) + if selectedCalloutContact != nil || selectedDroppedPin != nil { + selectedCalloutContact = nil + selectedDroppedPin = nil + selectedPointScreenPosition = nil } + }, + isStyleLoaded: $isStyleLoaded, + isCenteredOnUser: $isCenteredOnUser + ) + .popover( + item: $selectedCalloutContact, + attachmentAnchor: .rect(.rect(CGRect( + origin: selectedPointScreenPosition ?? .zero, + size: CGSize(width: 1, height: 1) + ))), + arrowEdge: .bottom + ) { contact in + ContactCalloutContent( + contact: contact, + onDetail: { onShowContactDetail(contact) }, + onMessage: { onNavigateToChat(contact) } + ) + .presentationCompactAdaptation(.popover) } - + .popover( + item: $selectedDroppedPin, + attachmentAnchor: .rect(.rect(CGRect( + origin: selectedPointScreenPosition ?? .zero, + size: CGSize(width: 1, height: 1) + ))), + arrowEdge: .bottom + ) { selection in + DroppedPinCallout(coordinate: selection.coordinate) { + viewModel.clearFocusedPin() + selectedDroppedPin = nil + } + .presentationCompactAdaptation(.popover) + } + .overlay { + if !isStyleLoaded { + ProgressView() + .scaleEffect(1.5) + } else if viewModel.isLoading { + MapLoadingOverlay() + } + } + } } // MARK: - Loading Overlay private struct MapLoadingOverlay: View { - var body: some View { - ZStack { - Color.black.opacity(0.1) - ProgressView() - .padding() - .background(.regularMaterial, in: .rect(cornerRadius: 8)) - } + var body: some View { + ZStack { + Color.black.opacity(0.1) + ProgressView() + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 8)) } + } } private struct DroppedPinSelection: Identifiable { - let id = UUID() - let coordinate: CLLocationCoordinate2D + let id = UUID() + let coordinate: CLLocationCoordinate2D } diff --git a/MC1/Views/Map/MapLine+SNR.swift b/MC1/Views/Map/MapLine+SNR.swift new file mode 100644 index 00000000..82bb2621 --- /dev/null +++ b/MC1/Views/Map/MapLine+SNR.swift @@ -0,0 +1,70 @@ +import CoreLocation +import Foundation +import MC1Services + +extension MapLine.LineStyle { + /// Maps an SNR value to a trace line style. `SNRQuality` owns the thresholds, so no bare + /// SNR literals are re-encoded here. + static func forSNR(_ snr: Double?) -> MapLine.LineStyle { + switch SNRQuality(snr: snr) { + case .excellent, .good: .traceGood + case .fair: .traceMedium + case .poor: .traceWeak + case .unknown: .traceUntraced + } + } +} + +extension MapLine { + /// Builds the midpoint badge label shared by the trace and neighbor SNR maps, of the form + /// " · dB", with the `dB` unit routed through `L10n`. The space before the unit + /// is composed here so whitespace-trimming tooling can't strip it out of the localized value. + static func snrBadgeText(distance: Double, snr: Double) -> String { + let distFormatted = Measurement(value: distance, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .road)) + let snrFormatted = snr.formatted(.number.precision(.fractionLength(1))) + return "\(distFormatted) · \(snrFormatted) \(L10n.RemoteNodes.RemoteNodes.Status.snrBadgeUnit)" + } + + /// The midpoint distance/SNR badge pin for the link between two coordinates, shared by the trace + /// and neighbor SNR maps. Callers pass `id` so the trace map keeps its deterministic + /// `UUID(hopIndex:)` for diffing while the neighbor map mints a fresh one. + static func snrBadge( + id: UUID, + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D, + snr: Double + ) -> MapPoint { + let distance = CLLocation(latitude: from.latitude, longitude: from.longitude) + .distance(from: CLLocation(latitude: to.latitude, longitude: to.longitude)) + return MapPoint( + id: id, + coordinate: midpoint(from: from, to: to), + pinStyle: .badge, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: snrBadgeText(distance: distance, snr: snr) + ) + } + + /// Geographic midpoint of two coordinates, shifting one longitude by 360° before averaging when + /// the pair straddles the antimeridian so the badge lands between them rather than on the + /// opposite hemisphere. + private static func midpoint( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D + ) -> CLLocationCoordinate2D { + var lon1 = from.longitude + var lon2 = to.longitude + if abs(lon1 - lon2) > 180 { + if lon1 < lon2 { lon1 += 360 } else { lon2 += 360 } + } + var midLongitude = (lon1 + lon2) / 2 + if midLongitude > 180 { midLongitude -= 360 } + return CLLocationCoordinate2D( + latitude: (from.latitude + to.latitude) / 2, + longitude: midLongitude + ) + } +} diff --git a/MC1/Views/Map/MapLine.swift b/MC1/Views/Map/MapLine.swift index ed8d6843..40060254 100644 --- a/MC1/Views/Map/MapLine.swift +++ b/MC1/Views/Map/MapLine.swift @@ -1,28 +1,28 @@ import CoreLocation struct MapLine: Identifiable, Equatable { - let id: String - let coordinates: [CLLocationCoordinate2D] - let style: LineStyle - let opacity: Double - var pathIndex: Int? + let id: String + let coordinates: [CLLocationCoordinate2D] + let style: LineStyle + let opacity: Double + var pathIndex: Int? - enum LineStyle: String, Hashable { - case los - case traceUntraced - case traceWeak - case traceMedium - case traceGood - case messagePath - } + enum LineStyle: String, Hashable { + case los + case traceUntraced + case traceWeak + case traceMedium + case traceGood + case messagePath + } - static func == (lhs: MapLine, rhs: MapLine) -> Bool { - lhs.id == rhs.id - && lhs.style == rhs.style - && lhs.opacity == rhs.opacity - && lhs.coordinates.count == rhs.coordinates.count - && zip(lhs.coordinates, rhs.coordinates).allSatisfy { - $0.latitude == $1.latitude && $0.longitude == $1.longitude - } - } + static func == (lhs: MapLine, rhs: MapLine) -> Bool { + lhs.id == rhs.id + && lhs.style == rhs.style + && lhs.opacity == rhs.opacity + && lhs.coordinates.count == rhs.coordinates.count + && zip(lhs.coordinates, rhs.coordinates).allSatisfy { + $0.latitude == $1.latitude && $0.longitude == $1.longitude + } + } } diff --git a/MC1/Views/Map/MapPoint.swift b/MC1/Views/Map/MapPoint.swift index e394b3b6..8b1fbc6f 100644 --- a/MC1/Views/Map/MapPoint.swift +++ b/MC1/Views/Map/MapPoint.swift @@ -1,40 +1,40 @@ import CoreLocation struct MapPoint: Identifiable, Equatable { - let id: UUID - let coordinate: CLLocationCoordinate2D - let pinStyle: PinStyle - let label: String? - let isClusterable: Bool + let id: UUID + let coordinate: CLLocationCoordinate2D + let pinStyle: PinStyle + let label: String? + let isClusterable: Bool - enum PinStyle: String, Hashable { - case contactChat - case contactRepeater - case contactRoom - case repeater - case repeaterRingBlue - case repeaterRingGreen - case repeaterRingWhite - case repeaterHop - case pointA - case pointB - case crosshair - case obstruction - case badge - case droppedPin - } + enum PinStyle: String, Hashable { + case contactChat + case contactRepeater + case contactRoom + case repeater + case repeaterRingBlue + case repeaterRingGreen + case repeaterRingWhite + case repeaterHop + case pointA + case pointB + case crosshair + case obstruction + case badge + case droppedPin + } - let hopIndex: Int? - let badgeText: String? + let hopIndex: Int? + let badgeText: String? - static func == (lhs: MapPoint, rhs: MapPoint) -> Bool { - lhs.id == rhs.id - && lhs.coordinate.latitude == rhs.coordinate.latitude - && lhs.coordinate.longitude == rhs.coordinate.longitude - && lhs.pinStyle == rhs.pinStyle - && lhs.label == rhs.label - && lhs.isClusterable == rhs.isClusterable - && lhs.hopIndex == rhs.hopIndex - && lhs.badgeText == rhs.badgeText - } + static func == (lhs: MapPoint, rhs: MapPoint) -> Bool { + lhs.id == rhs.id + && lhs.coordinate.latitude == rhs.coordinate.latitude + && lhs.coordinate.longitude == rhs.coordinate.longitude + && lhs.pinStyle == rhs.pinStyle + && lhs.label == rhs.label + && lhs.isClusterable == rhs.isClusterable + && lhs.hopIndex == rhs.hopIndex + && lhs.badgeText == rhs.badgeText + } } diff --git a/MC1/Views/Map/MapSnapshotLayout.swift b/MC1/Views/Map/MapSnapshotLayout.swift index 4d7bfafc..a5390575 100644 --- a/MC1/Views/Map/MapSnapshotLayout.swift +++ b/MC1/Views/Map/MapSnapshotLayout.swift @@ -5,10 +5,10 @@ import CoreGraphics /// not part of `MapSnapshotRequest` — it never varies, so it must not shard the /// cache. enum MapSnapshotLayout { - static let width: CGFloat = 250 - static let height: CGFloat = 150 - static let cornerRadius: CGFloat = 12 - /// `MLNMapSnapshotOptions` has no MapKit-style span; zoom is the framing - /// control. Approximate framing — the exact pin is shown on tap. Tune visually. - static let zoomLevel: Double = 14 + static let width: CGFloat = 250 + static let height: CGFloat = 150 + static let cornerRadius: CGFloat = 12 + /// `MLNMapSnapshotOptions` has no MapKit-style span; zoom is the framing + /// control. Approximate framing — the exact pin is shown on tap. Tune visually. + static let zoomLevel: Double = 14 } diff --git a/MC1/Views/Map/MapSnapshotRenderer.swift b/MC1/Views/Map/MapSnapshotRenderer.swift index 6c88a790..d4e603ba 100644 --- a/MC1/Views/Map/MapSnapshotRenderer.swift +++ b/MC1/Views/Map/MapSnapshotRenderer.swift @@ -10,71 +10,71 @@ import UIKit /// only used briefly to build options and is suspended during the GL work. @MainActor final class MapSnapshotRenderer: MapSnapshotRendering { - func render(_ request: MapSnapshotRequest) async -> UIImage? { - let sprite = PinSpriteRenderer.droppedPinSprite() - let size = CGSize(width: MapSnapshotLayout.width, height: MapSnapshotLayout.height) - let latitude = request.latitude - let longitude = request.longitude - let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - let styleURL = MapStyleSelection.standard.styleURL( - isDarkMode: request.isDark, - isOffline: request.isOffline - ) + func render(_ request: MapSnapshotRequest) async -> UIImage? { + let sprite = PinSpriteRenderer.droppedPinSprite() + let size = CGSize(width: MapSnapshotLayout.width, height: MapSnapshotLayout.height) + let latitude = request.latitude + let longitude = request.longitude + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + let styleURL = MapStyleSelection.standard.styleURL( + isDarkMode: request.isDark, + isOffline: request.isOffline + ) - let camera = MLNMapCamera( - lookingAtCenter: coordinate, - altitude: 0, - pitch: 0, - heading: 0 - ) - let options = MLNMapSnapshotOptions(styleURL: styleURL, camera: camera, size: size) - options.zoomLevel = MapSnapshotLayout.zoomLevel - options.showsLogo = false - // Attribution is suppressed on the thumbnail; the full Map tab the user - // taps into shows the OSM/MapLibre attribution control. - options.showsAttribution = false + let camera = MLNMapCamera( + lookingAtCenter: coordinate, + altitude: 0, + pitch: 0, + heading: 0 + ) + let options = MLNMapSnapshotOptions(styleURL: styleURL, camera: camera, size: size) + options.zoomLevel = MapSnapshotLayout.zoomLevel + options.showsLogo = false + // Attribution is suppressed on the thumbnail; the full Map tab the user + // taps into shows the OSM/MapLibre attribution control. + options.showsAttribution = false - let snapshotter = MLNMapSnapshotter(options: options) - // The `snapshotter.start(...)` call below retains `snapshotter` for the - // duration of the underlying GL work — no extra anchor is needed to - // keep it alive across the `await` suspension. - let snapshotterRef = SnapshotterRef(snapshotter) + let snapshotter = MLNMapSnapshotter(options: options) + // The `snapshotter.start(...)` call below retains `snapshotter` for the + // duration of the underlying GL work — no extra anchor is needed to + // keep it alive across the `await` suspension. + let snapshotterRef = SnapshotterRef(snapshotter) - // `@Sendable` breaks `@MainActor` inheritance from the enclosing - // `withTaskCancellationHandler` operation closure; without it the - // runtime executor-isolation assertion (`dispatch_assert_queue_fail`) - // trips when MapLibre invokes the overlay handler off-main. - let overlayHandler: @Sendable (MLNMapSnapshotOverlay) -> Void = { overlay in - let point = overlay.point( - for: CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - ) - UIGraphicsPushContext(overlay.context) - sprite.draw(in: CGRect( - x: point.x - sprite.size.width / 2, - y: point.y - sprite.size.height, - width: sprite.size.width, - height: sprite.size.height - )) - UIGraphicsPopContext() - } + // `@Sendable` breaks `@MainActor` inheritance from the enclosing + // `withTaskCancellationHandler` operation closure; without it the + // runtime executor-isolation assertion (`dispatch_assert_queue_fail`) + // trips when MapLibre invokes the overlay handler off-main. + let overlayHandler: @Sendable (MLNMapSnapshotOverlay) -> Void = { overlay in + let point = overlay.point( + for: CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + UIGraphicsPushContext(overlay.context) + sprite.draw(in: CGRect( + x: point.x - sprite.size.width / 2, + y: point.y - sprite.size.height, + width: sprite.size.width, + height: sprite.size.height + )) + UIGraphicsPopContext() + } - return await withTaskCancellationHandler { - await withCheckedContinuation { (continuation: CheckedContinuation) in - snapshotter.start( - overlayHandler: overlayHandler, - completionHandler: { snapshot, _ in - continuation.resume(returning: snapshot?.image) - } - ) - } - } onCancel: { [snapshotterRef] in - // The cancel handler runs on whichever actor triggered cancellation; - // hop to the main actor so `MLNMapSnapshotter` (non-`Sendable`) is - // touched only from its owning context. Cancellation flows back to - // the awaiter via the `completionHandler` resuming with `nil`. - Task { @MainActor in snapshotterRef.snapshotter.cancel() } - } + return await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + snapshotter.start( + overlayHandler: overlayHandler, + completionHandler: { snapshot, _ in + continuation.resume(returning: snapshot?.image) + } + ) + } + } onCancel: { [snapshotterRef] in + // The cancel handler runs on whichever actor triggered cancellation; + // hop to the main actor so `MLNMapSnapshotter` (non-`Sendable`) is + // touched only from its owning context. Cancellation flows back to + // the awaiter via the `completionHandler` resuming with `nil`. + Task { @MainActor in snapshotterRef.snapshotter.cancel() } } + } } /// `@unchecked Sendable` shuttle so the cancellation closure (which is @@ -82,6 +82,8 @@ final class MapSnapshotRenderer: MapSnapshotRendering { /// across actors. The closure only reads the property and immediately hops /// back to the main actor before touching it. private final class SnapshotterRef: @unchecked Sendable { - let snapshotter: MLNMapSnapshotter - init(_ snapshotter: MLNMapSnapshotter) { self.snapshotter = snapshotter } + let snapshotter: MLNMapSnapshotter + init(_ snapshotter: MLNMapSnapshotter) { + self.snapshotter = snapshotter + } } diff --git a/MC1/Views/Map/MapSnapshotRendering.swift b/MC1/Views/Map/MapSnapshotRendering.swift index a9f589bc..eeea955e 100644 --- a/MC1/Views/Map/MapSnapshotRendering.swift +++ b/MC1/Views/Map/MapSnapshotRendering.swift @@ -2,7 +2,7 @@ import UIKit @MainActor protocol MapSnapshotRendering { - /// Renders a static map thumbnail with the dropped pin composited at the - /// coordinate, or nil on failure. Heavy work runs off-main internally. - func render(_ request: MapSnapshotRequest) async -> UIImage? + /// Renders a static map thumbnail with the dropped pin composited at the + /// coordinate, or nil on failure. Heavy work runs off-main internally. + func render(_ request: MapSnapshotRequest) async -> UIImage? } diff --git a/MC1/Views/Map/MapSnapshotRequest.swift b/MC1/Views/Map/MapSnapshotRequest.swift index 710db832..c221a64e 100644 --- a/MC1/Views/Map/MapSnapshotRequest.swift +++ b/MC1/Views/Map/MapSnapshotRequest.swift @@ -9,34 +9,34 @@ import Foundation /// versa). Render size is a constant (`MapSnapshotLayout`), deliberately /// not in the key. struct MapSnapshotRequest: Hashable { - let latitude: Double - let longitude: Double - let isDark: Bool - let isOffline: Bool + let latitude: Double + let longitude: Double + let isDark: Bool + let isOffline: Bool - private static let coordinatePrecision = 100_000.0 + private static let coordinatePrecision = 100_000.0 - init(latitude: Double, longitude: Double, isDark: Bool, isOffline: Bool) { - // Normalize -0.0 to 0.0. -0.0 and 0.0 are `Hashable`-equal (so they dedupe - // as one request and share an index entry), but string interpolation in - // `cacheKey` renders them "-0.0" vs "0.0" — two distinct cache slots. - // Collapsing the sign keeps the cache key consistent with equality. - let roundedLatitude = (latitude * Self.coordinatePrecision).rounded() / Self.coordinatePrecision - let roundedLongitude = (longitude * Self.coordinatePrecision).rounded() / Self.coordinatePrecision - self.latitude = roundedLatitude == 0 ? 0 : roundedLatitude - self.longitude = roundedLongitude == 0 ? 0 : roundedLongitude - // `MapStyleSelection.styleURL(isDarkMode:isOffline:)` collapses - // `useDark = isDarkMode && !isOffline`, so offline renders never use - // the dark style. Collapsing `isDark` to `false` when offline keeps - // the cache, in-flight, failed, and resolvedKeys sets from sharding - // identical offline images across two slots. - self.isDark = isOffline ? false : isDark - self.isOffline = isOffline - } + init(latitude: Double, longitude: Double, isDark: Bool, isOffline: Bool) { + // Normalize -0.0 to 0.0. -0.0 and 0.0 are `Hashable`-equal (so they dedupe + // as one request and share an index entry), but string interpolation in + // `cacheKey` renders them "-0.0" vs "0.0" — two distinct cache slots. + // Collapsing the sign keeps the cache key consistent with equality. + let roundedLatitude = (latitude * Self.coordinatePrecision).rounded() / Self.coordinatePrecision + let roundedLongitude = (longitude * Self.coordinatePrecision).rounded() / Self.coordinatePrecision + self.latitude = roundedLatitude == 0 ? 0 : roundedLatitude + self.longitude = roundedLongitude == 0 ? 0 : roundedLongitude + // `MapStyleSelection.styleURL(isDarkMode:isOffline:)` collapses + // `useDark = isDarkMode && !isOffline`, so offline renders never use + // the dark style. Collapsing `isDark` to `false` when offline keeps + // the cache, in-flight, failed, and resolvedKeys sets from sharding + // identical offline images across two slots. + self.isDark = isOffline ? false : isDark + self.isOffline = isOffline + } - /// `NSString` key for the backing `NSCache`, mirroring `InlineImageCache`'s - /// `url.absoluteString as NSString` pattern. - var cacheKey: NSString { - "\(latitude),\(longitude),\(isDark),\(isOffline)" as NSString - } + /// `NSString` key for the backing `NSCache`, mirroring `InlineImageCache`'s + /// `url.absoluteString as NSString` pattern. + var cacheKey: NSString { + "\(latitude),\(longitude),\(isDark),\(isOffline)" as NSString + } } diff --git a/MC1/Views/Map/MapStyleSelection.swift b/MC1/Views/Map/MapStyleSelection.swift index 40cfdc50..e080eb52 100644 --- a/MC1/Views/Map/MapStyleSelection.swift +++ b/MC1/Views/Map/MapStyleSelection.swift @@ -2,43 +2,43 @@ import SwiftUI /// Map style options for the Map tab enum MapStyleSelection: String, CaseIterable, Hashable { - case standard - case satellite - case topo + case standard + case satellite + case topo - var label: String { - switch self { - case .standard: L10n.Map.Map.Style.standard - case .satellite: L10n.Map.Map.Style.satellite - case .topo: L10n.Map.Map.Style.topo - } + var label: String { + switch self { + case .standard: L10n.Map.Map.Style.standard + case .satellite: L10n.Map.Map.Style.satellite + case .topo: L10n.Map.Map.Style.topo } + } - var requiresNetwork: Bool { - switch self { - case .standard: false - case .satellite: true - case .topo: false - } + var requiresNetwork: Bool { + switch self { + case .standard: false + case .satellite: true + case .topo: false } + } - var offlineMapLayer: OfflineMapLayer { - switch self { - case .standard: .base - case .satellite: .base - case .topo: .topo - } + var offlineMapLayer: OfflineMapLayer { + switch self { + case .standard: .base + case .satellite: .base + case .topo: .topo } + } - /// All styles use the same base vector style; satellite/topo add raster overlays at runtime. - /// When offline, always returns Liberty — offline packs are downloaded against that style - /// and MapLibre serves cached tiles only for the exact style URL used during download. - func styleURL(isDarkMode: Bool, isOffline: Bool = false) -> URL { - let useDark = isDarkMode && !isOffline - let url = useDark ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty - guard let result = URL(string: url) else { - fatalError("Invalid map tile URL constant: \(url)") - } - return result + /// All styles use the same base vector style; satellite/topo add raster overlays at runtime. + /// When offline, always returns Liberty — offline packs are downloaded against that style + /// and MapLibre serves cached tiles only for the exact style URL used during download. + func styleURL(isDarkMode: Bool, isOffline: Bool = false) -> URL { + let useDark = isDarkMode && !isOffline + let url = useDark ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty + guard let result = URL(string: url) else { + fatalError("Invalid map tile URL constant: \(url)") } + return result + } } diff --git a/MC1/Views/Map/MapTileURLs.swift b/MC1/Views/Map/MapTileURLs.swift index 466a3476..ca8aa9db 100644 --- a/MC1/Views/Map/MapTileURLs.swift +++ b/MC1/Views/Map/MapTileURLs.swift @@ -1,8 +1,8 @@ enum MapTileURLs { - static let openFreeMapLiberty = "https://tiles.openfreemap.org/styles/liberty" - static let openFreeMapDark = "https://tiles.openfreemap.org/styles/dark" - static let esriWorldImagery = "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" - static let openTopoMapA = "https://a.tile.opentopomap.org/{z}/{x}/{y}.png" - static let openTopoMapB = "https://b.tile.opentopomap.org/{z}/{x}/{y}.png" - static let openTopoMapC = "https://c.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openFreeMapLiberty = "https://tiles.openfreemap.org/styles/liberty" + static let openFreeMapDark = "https://tiles.openfreemap.org/styles/dark" + static let esriWorldImagery = "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + static let openTopoMapA = "https://a.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openTopoMapB = "https://b.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openTopoMapC = "https://c.tile.opentopomap.org/{z}/{x}/{y}.png" } diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index 6f99dfd2..323345a1 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -1,121 +1,121 @@ -import SwiftUI import MapKit import MC1Services +import SwiftUI /// Map view displaying contacts with their locations struct MapView: View { - @Environment(\.appState) private var appState - @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard - @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels - @SceneStorage(SceneStorageKey.mapCameraRegion.rawValue) private var savedCameraRegion = "" - @State private var viewModel = MapViewModel() - @State private var selectedCalloutContact: ContactDTO? - @State private var selectedPointScreenPosition: CGPoint? - @State private var selectedContactForDetail: ContactDTO? - @State private var isStyleLoaded = false + @Environment(\.appState) private var appState + @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels + @AppStorage(AppStorageKey.mapNorthLocked.rawValue) private var isNorthLocked = AppStorageKey.defaultMapNorthLocked + @SceneStorage(SceneStorageKey.mapCameraRegion.rawValue) private var savedCameraRegion = "" + @State private var viewModel = MapViewModel() + @State private var selectedCalloutContact: ContactDTO? + @State private var selectedPointScreenPosition: CGPoint? + @State private var selectedContactForDetail: ContactDTO? + @State private var isStyleLoaded = false - var body: some View { - NavigationStack { - MapCanvasView( - viewModel: viewModel, - mapStyleSelection: $mapStyleSelection, - showLabels: $showLabels, - selectedCalloutContact: $selectedCalloutContact, - selectedPointScreenPosition: $selectedPointScreenPosition, - isStyleLoaded: $isStyleLoaded, - onShowContactDetail: { showContactDetail($0) }, - onNavigateToChat: { navigateToChat(with: $0) }, - onCenterOnUser: { centerOnUserLocation() }, - onClearSelection: { clearSelection() }, - onPersistCamera: { savedCameraRegion = MapCameraStore.encode($0) } - ) - .toolbar { - bleStatusToolbarItem() - ToolbarItem(placement: .topBarTrailing) { - MapRefreshButton(viewModel: viewModel) - } - } - .task { - appState.locationService.requestPermissionIfNeeded() - appState.locationService.requestLocation() - viewModel.configure( - dataStore: { [appState] in appState.offlineDataStore }, - radioID: { [appState] in appState.currentRadioID } - ) - await viewModel.loadContactsWithLocation() - // On first appearance the view model has no camera region; restoring here - // keeps the map where the user left it across launches instead of re-framing. - viewModel.applyInitialCamera( - saved: MapCameraStore.decode(savedCameraRegion), - hasPendingFocus: appState.navigation.pendingMapFocus != nil - ) - } - .onChange(of: appState.navigation.pendingMapFocus, initial: true) { _, request in - guard let request else { return } - viewModel.focusOnCoordinate(request.coordinate) - appState.navigation.clearPendingMapFocus() - } - .sheet(item: $selectedContactForDetail) { contact in - ContactDetailSheet( - contact: contact, - onMessage: { navigateToChat(with: contact) }, - onDelete: { Task { await viewModel.loadContactsWithLocation() } } - ) - .presentationDetents([.large]) - } - .liquidGlassToolbarBackground() + var body: some View { + NavigationStack { + MapCanvasView( + viewModel: viewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels, + isNorthLocked: $isNorthLocked, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + onShowContactDetail: { showContactDetail($0) }, + onNavigateToChat: { navigateToChat(with: $0) }, + onCenterOnUser: { centerOnUserLocation() }, + onClearSelection: { clearSelection() }, + onPersistCamera: { savedCameraRegion = MapCameraStore.encode($0) } + ) + .toolbar { + bleStatusToolbarItem() + ToolbarItem(placement: .topBarTrailing) { + MapRefreshButton(viewModel: viewModel) } + } + .task { + appState.locationService.requestPermissionIfNeeded() + appState.locationService.requestLocation() + viewModel.configure( + dataStore: { [appState] in appState.offlineDataStore }, + radioID: { [appState] in appState.currentRadioID } + ) + await viewModel.loadContactsWithLocation() + // On first appearance the view model has no camera region; restoring here + // keeps the map where the user left it across launches instead of re-framing. + viewModel.applyInitialCamera( + saved: MapCameraStore.decode(savedCameraRegion), + hasPendingFocus: appState.navigation.pendingMapFocus != nil + ) + } + .onChange(of: appState.navigation.pendingMapFocus, initial: true) { _, request in + guard let request else { return } + viewModel.focusOnCoordinate(request.coordinate) + appState.navigation.clearPendingMapFocus() + } + .sheet(item: $selectedContactForDetail) { contact in + ContactDetailSheet( + contact: contact, + onMessage: { navigateToChat(with: contact) }, + onDelete: { Task { await viewModel.loadContactsWithLocation() } } + ) + .presentationDetents([.large]) + } + .liquidGlassToolbarBackground() } + } - // MARK: - Actions + // MARK: - Actions - private func clearSelection() { - selectedCalloutContact = nil - selectedPointScreenPosition = nil - } + private func clearSelection() { + selectedCalloutContact = nil + selectedPointScreenPosition = nil + } - private func navigateToChat(with contact: ContactDTO) { - clearSelection() - appState.navigation.navigateToChat(with: contact) - } + private func navigateToChat(with contact: ContactDTO) { + clearSelection() + appState.navigation.navigateToChat(with: contact) + } - private func showContactDetail(_ contact: ContactDTO) { - clearSelection() - selectedContactForDetail = contact - } + private func showContactDetail(_ contact: ContactDTO) { + clearSelection() + selectedContactForDetail = contact + } - private func centerOnUserLocation() { - guard let location = appState.bestAvailableLocation else { return } - let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - viewModel.setCameraRegion(MKCoordinateRegion(center: location.coordinate, span: span)) - } + private func centerOnUserLocation() -> Bool { + appState.centerOnUserLocation { viewModel.setCameraRegion($0) } + } } // MARK: - Map Refresh Button private struct MapRefreshButton: View { - var viewModel: MapViewModel + var viewModel: MapViewModel - var body: some View { - Button(L10n.Map.Map.Controls.refresh, systemImage: "arrow.clockwise") { - Task { - await viewModel.loadContactsWithLocation() - } - } - .labelStyle(.iconOnly) - .disabled(viewModel.isLoading) - .opacity(viewModel.isLoading ? 0 : 1) - .overlay { - if viewModel.isLoading { - ProgressView() - } - } + var body: some View { + Button(L10n.Map.Map.Controls.refresh, systemImage: "arrow.clockwise") { + Task { + await viewModel.loadContactsWithLocation() + } + } + .labelStyle(.iconOnly) + .disabled(viewModel.isLoading) + .opacity(viewModel.isLoading ? 0 : 1) + .overlay { + if viewModel.isLoading { + ProgressView() + } } + } } // MARK: - Preview #Preview { - MapView() - .environment(\.appState, AppState()) + MapView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/MapViewModel.swift b/MC1/Views/Map/MapViewModel.swift index ec2bcb70..da8fc586 100644 --- a/MC1/Views/Map/MapViewModel.swift +++ b/MC1/Views/Map/MapViewModel.swift @@ -1,157 +1,155 @@ -import SwiftUI import MapKit import MC1Services +import SwiftUI /// ViewModel for map contact locations @Observable @MainActor final class MapViewModel { + // MARK: - Properties - // MARK: - Properties - - /// All contacts with valid locations - var contactsWithLocation: [ContactDTO] = [] - - /// Map points derived from contacts — stored to avoid reallocation on every body eval. - private(set) var mapPoints: [MapPoint] = [] - - /// A user-dropped pin from a chat coordinate tap. Folded into `mapPoints` so it - /// survives contact refreshes. Exactly one exists at a time (single-pin invariant). - private(set) var focusedPin: MapPoint? - - /// Loading state - var isLoading = false - - /// Error message if any - var errorMessage: String? + /// All contacts with valid locations + var contactsWithLocation: [ContactDTO] = [] - /// Camera region for map centering - var cameraRegion: MKCoordinateRegion? + /// Map points derived from contacts — stored to avoid reallocation on every body eval. + private(set) var mapPoints: [MapPoint] = [] - /// Version counter for the camera region, incremented to signal a new camera target - private(set) var cameraRegionVersion = 0 + /// A user-dropped pin from a chat coordinate tap. Folded into `mapPoints` so it + /// survives contact refreshes. Exactly one exists at a time (single-pin invariant). + private(set) var focusedPin: MapPoint? - /// Whether the map bearing is locked to true north - var isNorthLocked = false + /// Loading state + var isLoading = false - /// Whether the layers menu is showing - var showingLayersMenu = false + /// Error message if any + var errorMessage: String? - // MARK: - Dependencies + /// Camera region for map centering + var cameraRegion: MKCoordinateRegion? - private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } - private var radioIDProvider: @MainActor () -> UUID? = { nil } + /// Version counter for the camera region, incremented to signal a new camera target + private(set) var cameraRegionVersion = 0 - private var dataStore: PersistenceStore? { dataStoreProvider() } - private var radioID: UUID? { radioIDProvider() } + // MARK: - Dependencies - // MARK: - Initialization + private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } + private var radioIDProvider: @MainActor () -> UUID? = { nil } - /// Stable id so the dropped pin does not churn across rebuilds and `onPointTap` can identify it. - private static let focusedPinID = UUID() + private var dataStore: PersistenceStore? { + dataStoreProvider() + } - /// Span for "exactly here" framing (about 1 km across). - private static let focusSpan = 0.01 + private var radioID: UUID? { + radioIDProvider() + } - init() {} + // MARK: - Initialization - /// Configure with the data store and radio this view model uses; a provider returning nil mirrors a disconnected state. - func configure( - dataStore: @escaping @MainActor () -> PersistenceStore?, - radioID: @escaping @MainActor () -> UUID? - ) { - dataStoreProvider = dataStore - radioIDProvider = radioID - } - - // MARK: - Load Contacts + /// Stable id so the dropped pin does not churn across rebuilds and `onPointTap` can identify it. + private static let focusedPinID = UUID() - /// Load contacts with valid locations from the database - func loadContactsWithLocation() async { - guard let dataStore, let radioID else { return } + /// Span for "exactly here" framing (about 1 km across). + private static let focusSpan = 0.01 - isLoading = true - errorMessage = nil + init() {} - do { - let allContacts = try await dataStore.fetchContacts(radioID: radioID) - contactsWithLocation = allContacts.filter(\.hasLocation) - rebuildMapPoints() - } catch { - errorMessage = error.userFacingMessage - } + /// Configure with the data store and radio this view model uses; a provider returning nil mirrors a disconnected state. + func configure( + dataStore: @escaping @MainActor () -> PersistenceStore?, + radioID: @escaping @MainActor () -> UUID? + ) { + dataStoreProvider = dataStore + radioIDProvider = radioID + } - isLoading = false - } + // MARK: - Load Contacts - // MARK: - Map Points - - private func rebuildMapPoints() { - mapPoints = contactsWithLocation.map { contact in - MapPoint( - id: contact.id, - coordinate: contact.coordinate, - pinStyle: contact.type.pinStyle, - label: contact.displayName, - isClusterable: true, - hopIndex: nil, - badgeText: nil - ) - } - if let focusedPin { - mapPoints.append(focusedPin) - } - } + /// Load contacts with valid locations from the database + func loadContactsWithLocation() async { + guard let dataStore, let radioID else { return } - // MARK: - Map Interaction + isLoading = true + errorMessage = nil - func setCameraRegion(_ region: MKCoordinateRegion?) { - cameraRegion = region - cameraRegionVersion += 1 + do { + let allContacts = try await dataStore.fetchContacts(radioID: radioID) + contactsWithLocation = allContacts.filter(\.hasLocation) + rebuildMapPoints() + } catch { + errorMessage = error.userFacingMessage } - /// Drop a distinct pin at `coordinate`, fold it into `mapPoints`, and center the camera on it. - func focusOnCoordinate(_ coordinate: CLLocationCoordinate2D) { - focusedPin = MapPoint( - id: Self.focusedPinID, - coordinate: coordinate, - pinStyle: .droppedPin, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - ) - rebuildMapPoints() - let span = MKCoordinateSpan(latitudeDelta: Self.focusSpan, longitudeDelta: Self.focusSpan) - setCameraRegion(MKCoordinateRegion(center: coordinate, span: span)) + isLoading = false + } + + // MARK: - Map Points + + private func rebuildMapPoints() { + mapPoints = contactsWithLocation.map { contact in + MapPoint( + id: contact.id, + coordinate: contact.coordinate, + pinStyle: contact.type.pinStyle, + label: contact.displayName, + isClusterable: true, + hopIndex: nil, + badgeText: nil + ) } - - /// Remove the dropped pin. - func clearFocusedPin() { - focusedPin = nil - rebuildMapPoints() + if let focusedPin { + mapPoints.append(focusedPin) } - - /// Center map to show all contacts - func centerOnAllContacts() { - guard !contactsWithLocation.isEmpty else { - cameraRegion = nil - return - } - - let coordinates = contactsWithLocation.map(\.coordinate) - setCameraRegion(coordinates.boundingRegion()) + } + + // MARK: - Map Interaction + + func setCameraRegion(_ region: MKCoordinateRegion?) { + cameraRegion = region + cameraRegionVersion += 1 + } + + /// Drop a distinct pin at `coordinate`, fold it into `mapPoints`, and center the camera on it. + func focusOnCoordinate(_ coordinate: CLLocationCoordinate2D) { + focusedPin = MapPoint( + id: Self.focusedPinID, + coordinate: coordinate, + pinStyle: .droppedPin, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + ) + rebuildMapPoints() + let span = MKCoordinateSpan(latitudeDelta: Self.focusSpan, longitudeDelta: Self.focusSpan) + setCameraRegion(MKCoordinateRegion(center: coordinate, span: span)) + } + + /// Remove the dropped pin. + func clearFocusedPin() { + focusedPin = nil + rebuildMapPoints() + } + + /// Center map to show all contacts + func centerOnAllContacts() { + guard !contactsWithLocation.isEmpty else { + cameraRegion = nil + return } - /// On entering the map, restore the user's saved camera if present, otherwise frame all - /// contacts. A focus or dropped-pin target sets `cameraRegion` synchronously before this - /// runs, so a non-nil region or an existing pin means a target already owns the framing. - func applyInitialCamera(saved: MKCoordinateRegion?, hasPendingFocus: Bool) { - guard !hasPendingFocus, focusedPin == nil, cameraRegion == nil else { return } - if let saved { - setCameraRegion(saved) - } else { - centerOnAllContacts() - } + let coordinates = contactsWithLocation.map(\.coordinate) + setCameraRegion(coordinates.boundingRegion()) + } + + /// On entering the map, restore the user's saved camera if present, otherwise frame all + /// contacts. A focus or dropped-pin target sets `cameraRegion` synchronously before this + /// runs, so a non-nil region or an existing pin means a target already owns the framing. + func applyInitialCamera(saved: MKCoordinateRegion?, hasPendingFocus: Bool) { + guard !hasPendingFocus, focusedPin == nil, cameraRegion == nil else { return } + if let saved { + setCameraRegion(saved) + } else { + centerOnAllContacts() } + } } diff --git a/MC1/Views/Map/OfflineBadge.swift b/MC1/Views/Map/OfflineBadge.swift index f54a1d19..f1879c9b 100644 --- a/MC1/Views/Map/OfflineBadge.swift +++ b/MC1/Views/Map/OfflineBadge.swift @@ -4,19 +4,19 @@ import SwiftUI // MARK: - Offline Badge struct OfflineBadge: View { - var body: some View { - Text(L10n.Map.Map.OfflineBadge.label) - .font(.caption) - .bold() - .padding(.horizontal) - .padding(.vertical, 6) - .background(.ultraThinMaterial, in: .capsule) - .accessibilityAddTraits(.isStaticText) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .padding(.trailing) - .padding(.top) - .onAppear { - AccessibilityNotification.Announcement(L10n.Map.Map.OfflineBadge.label).post() - } - } + var body: some View { + Text(L10n.Map.Map.OfflineBadge.label) + .font(.caption) + .bold() + .padding(.horizontal) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + .accessibilityAddTraits(.isStaticText) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.trailing) + .padding(.top) + .onAppear { + AccessibilityNotification.Announcement(L10n.Map.Map.OfflineBadge.label).post() + } + } } diff --git a/MC1/Views/Map/PathDistanceBanner.swift b/MC1/Views/Map/PathDistanceBanner.swift new file mode 100644 index 00000000..cab37494 --- /dev/null +++ b/MC1/Views/Map/PathDistanceBanner.swift @@ -0,0 +1,27 @@ +import MapKit +import SwiftUI + +/// Path summary shown in place of the map sheet's navigation title: hop count +/// and, when at least two nodes have coordinates, the drawn-path distance. +struct PathDistanceBanner: View { + private static let horizontalPadding: CGFloat = 16 + private static let verticalPadding: CGFloat = 8 + + let hopCount: Int + let totalPathDistance: CLLocationDistance? + + var body: some View { + HStack(spacing: 4) { + Text(L10n.Contacts.Contacts.Trace.Map.hops(hopCount)) + if let distance = totalPathDistance { + Text("•") + Text(Measurement(value: distance, unit: UnitLength.meters), + format: .measurement(width: .abbreviated, usage: .road)) + } + } + .font(.subheadline.weight(.medium)) + .padding(.horizontal, Self.horizontalPadding) + .padding(.vertical, Self.verticalPadding) + .liquidGlass(in: .capsule) + } +} diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index 40548e0b..343332c9 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -3,452 +3,452 @@ import UIKit @MainActor enum PinSpriteRenderer { - /// Height of a standard pin sprite in points (circle + triangle pointer). - /// Used by the map Coordinator to position callout anchors above the pin icon. - static let standardHeight: CGFloat = 43 // 36 (circle) + 10 (triangle) - 3 (overlap) - - static let labelSpritePrefix = "label-" - - /// Largest hop number a pin badge will render. Hops beyond this clamp to the - /// cap rather than failing to resolve a sprite. MeshCore paths top out at 64 hops. - static let maxHopBadge = 64 - - private static var cachedImages: [String: UIImage]? - - /// Single cached dropped-pin sprite for the chat map thumbnail. Distinct from - /// `cachedImages`, which is only populated after the Map tab loads its GL - /// style; the chat snapshot path never loads that style. - private static var cachedDroppedPin: UIImage? - - /// The dropped-pin sprite (systemPink circle + `mappin`), rendered once and - /// reused. Coordinate-independent, so the chat thumbnail composites the exact - /// pin the Map tab drops. Must be called on the main actor. - static func droppedPinSprite() -> UIImage { - if let cached = cachedDroppedPin { return cached } - guard let spec = allSpecs.first(where: { $0.name == "pin-dropped" }) else { - return UIImage() - } - let image = render(spec) - cachedDroppedPin = image - return image + /// Height of a standard pin sprite in points (circle + triangle pointer). + /// Used by the map Coordinator to position callout anchors above the pin icon. + static let standardHeight: CGFloat = 43 // 36 (circle) + 10 (triangle) - 3 (overlap) + + static let labelSpritePrefix = "label-" + + /// Largest hop number a pin badge will render. Hops beyond this clamp to the + /// cap rather than failing to resolve a sprite. MeshCore paths top out at 64 hops. + static let maxHopBadge = 64 + + private static var cachedImages: [String: UIImage]? + + /// Single cached dropped-pin sprite for the chat map thumbnail. Distinct from + /// `cachedImages`, which is only populated after the Map tab loads its GL + /// style; the chat snapshot path never loads that style. + private static var cachedDroppedPin: UIImage? + + /// The dropped-pin sprite (systemPink circle + `mappin`), rendered once and + /// reused. Coordinate-independent, so the chat thumbnail composites the exact + /// pin the Map tab drops. Must be called on the main actor. + static func droppedPinSprite() -> UIImage { + if let cached = cachedDroppedPin { return cached } + guard let spec = allSpecs.first(where: { $0.name == "pin-dropped" }) else { + return UIImage() } - - /// Registers base pin sprites into the style. Hop-ring variants are rendered - /// lazily via `renderOnDemand(name:into:)` when MapLibre requests a missing image. - static func renderAll(into style: MLNStyle) { - var rendered: [String: UIImage] = [:] - for spec in allSpecs { - rendered[spec.name] = render(spec) - } - rendered["pin-badge"] = UIGraphicsImageRenderer( - size: CGSize(width: 1, height: 1), format: .preferred() - ).image { _ in } - rendered["pill-bg"] = renderPillBackground() - cachedImages = rendered - - for (name, image) in rendered { - style.setImage(image, forName: name) - } + let image = render(spec) + cachedDroppedPin = image + return image + } + + /// Registers base pin sprites into the style. Hop-ring variants are rendered + /// lazily via `renderOnDemand(name:into:)` when MapLibre requests a missing image. + static func renderAll(into style: MLNStyle) { + var rendered: [String: UIImage] = [:] + for spec in allSpecs { + rendered[spec.name] = render(spec) } - - /// Renders a hop-ring sprite on demand when MapLibre requests a missing image name. - /// Returns the rendered image so the caller can pass it back to MapLibre as - /// the immediate fallback, avoiding a single-frame blink. - static func renderOnDemand(name: String, into style: MLNStyle) -> UIImage? { - if let cached = cachedImages?[name] { - style.setImage(cached, forName: name) - return cached - } - - let image: UIImage - if name.hasPrefix("pin-repeater-ring-white-hop-") { - guard let hopString = name.split(separator: "-").last, - let hop = Int(hopString), - (1...maxHopBadge).contains(hop), - let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) else { - return nil - } - image = render(ringWhiteSpec, hopIndex: hop) - } else if name.hasPrefix("pin-repeater-hop-") { - guard let hopString = name.split(separator: "-").last, - let hop = Int(hopString), - (1...maxHopBadge).contains(hop), - let repeaterSpec = allSpecs.first(where: { $0.name == "pin-repeater" }) else { - return nil - } - image = render(repeaterSpec, hopIndex: hop) - } else if name.hasPrefix(labelSpritePrefix) { - let text = String(name.dropFirst(labelSpritePrefix.count)) - guard !text.isEmpty else { return nil } - image = renderLabelSprite(text: text) - } else { - return nil - } - - cachedImages?[name] = image - style.setImage(image, forName: name) - return image + rendered["pin-badge"] = UIGraphicsImageRenderer( + size: CGSize(width: 1, height: 1), format: .preferred() + ).image { _ in } + rendered["pill-bg"] = renderPillBackground() + cachedImages = rendered + + for (name, image) in rendered { + style.setImage(image, forName: name) } - - // MARK: - Sprite specifications - - private enum RenderStyle { - case standard - case crosshair - case obstruction + } + + /// Renders a hop-ring sprite on demand when MapLibre requests a missing image name. + /// Returns the rendered image so the caller can pass it back to MapLibre as + /// the immediate fallback, avoiding a single-frame blink. + static func renderOnDemand(name: String, into style: MLNStyle) -> UIImage? { + if let cached = cachedImages?[name] { + style.setImage(cached, forName: name) + return cached } - private struct SpriteSpec { - let name: String - let circleColor: UIColor - let iconName: String? // SF Symbol name - let text: String? // e.g. "A", "B" for point pins - let ringColor: UIColor? // selection ring - let renderStyle: RenderStyle + let image: UIImage + if name.hasPrefix("pin-repeater-ring-white-hop-") { + guard let hopString = name.split(separator: "-").last, + let hop = Int(hopString), + (1...maxHopBadge).contains(hop), + let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) else { + return nil + } + image = render(ringWhiteSpec, hopIndex: hop) + } else if name.hasPrefix("pin-repeater-hop-") { + guard let hopString = name.split(separator: "-").last, + let hop = Int(hopString), + (1...maxHopBadge).contains(hop), + let repeaterSpec = allSpecs.first(where: { $0.name == "pin-repeater" }) else { + return nil + } + image = render(repeaterSpec, hopIndex: hop) + } else if name.hasPrefix(labelSpritePrefix) { + let text = String(name.dropFirst(labelSpritePrefix.count)) + guard !text.isEmpty else { return nil } + image = renderLabelSprite(text: text) + } else { + return nil } - private static let allSpecs: [SpriteSpec] = [ - // Main map contacts - SpriteSpec(name: "pin-chat", circleColor: UIColor(red: 204 / 255, green: 122 / 255, blue: 92 / 255, alpha: 1), - iconName: "person.fill", text: nil, ringColor: nil, renderStyle: .standard), - SpriteSpec(name: "pin-repeater", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: nil, renderStyle: .standard), - SpriteSpec(name: "pin-room", circleColor: UIColor(red: 1, green: 136 / 255, blue: 0, alpha: 1), - iconName: "person.3.fill", text: nil, ringColor: nil, renderStyle: .standard), - - // LOS/TracePath repeater states - SpriteSpec(name: "pin-repeater-ring-blue", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemBlue, renderStyle: .standard), - SpriteSpec(name: "pin-repeater-ring-green", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemGreen, renderStyle: .standard), - SpriteSpec(name: "pin-repeater-ring-white", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .white, renderStyle: .standard), - - // LOS point pins - SpriteSpec(name: "pin-point-a", circleColor: .systemBlue, - iconName: nil, text: "A", ringColor: nil, renderStyle: .standard), - SpriteSpec(name: "pin-point-b", circleColor: .systemGreen, - iconName: nil, text: "B", ringColor: nil, renderStyle: .standard), - - // LOS crosshair target - SpriteSpec(name: "pin-crosshair", circleColor: .systemPurple, - iconName: nil, text: "R", ringColor: nil, renderStyle: .crosshair), - - // LOS obstruction marker - SpriteSpec(name: "pin-obstruction", circleColor: .systemRed, - iconName: nil, text: nil, ringColor: nil, renderStyle: .obstruction), - - // Chat-dropped coordinate pin - SpriteSpec(name: "pin-dropped", circleColor: .systemPink, - iconName: "mappin", text: nil, ringColor: nil, renderStyle: .standard), - ] - - // MARK: - Rendering - - private static func render(_ spec: SpriteSpec, hopIndex: Int? = nil) -> UIImage { - switch spec.renderStyle { - case .crosshair: return renderCrosshair(spec) - case .obstruction: return renderObstruction() - case .standard: break - } - - let circleSize: CGFloat = 36 - let iconSize: CGFloat = 16 - let triangleSize: CGFloat = 10 - let ringPadding: CGFloat = spec.ringColor != nil ? 4 : 0 - let ringSize: CGFloat = spec.ringColor != nil ? 44 : 0 - // A hop badge overhangs the circle's right edge by 4pt; widen the canvas - // (symmetrically, to keep the triangle tip horizontally centered on the - // coordinate) so a ringless badged pin isn't clipped. - let badgeRoom: CGFloat = hopIndex != nil ? 44 : 0 - let totalWidth = max(circleSize, ringSize, badgeRoom) - let totalHeight = circleSize + triangleSize - 3 + ringPadding - - let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalWidth, height: totalHeight), format: .preferred()) - return renderer.image { ctx in - let cgContext = ctx.cgContext - let centerX = totalWidth / 2 - - // Selection ring, centered on the circle head so it encircles the - // round portion rather than the triangle tail. - if let ringColor = spec.ringColor { - let circleCenterY = ringPadding + circleSize / 2 - let ringRect = CGRect( - x: centerX - ringSize / 2, - y: circleCenterY - ringSize / 2, - width: ringSize, - height: ringSize - ) - ringColor.setStroke() - cgContext.setLineWidth(3) - cgContext.strokeEllipse(in: ringRect.insetBy(dx: 1.5, dy: 1.5)) - } - - // Circle shadow - cgContext.saveGState() - cgContext.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor) - let circleRect = CGRect( - x: centerX - circleSize / 2, - y: ringPadding, - width: circleSize, - height: circleSize - ) - spec.circleColor.setFill() - cgContext.fillEllipse(in: circleRect) - cgContext.restoreGState() - - // Circle (again without shadow for crisp edge) - spec.circleColor.setFill() - cgContext.fillEllipse(in: circleRect) - - // Icon or text - if let iconName = spec.iconName { - let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .regular) - if let icon = UIImage(systemName: iconName, withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) { - let iconRect = CGRect( - x: centerX - icon.size.width / 2, - y: circleRect.midY - icon.size.height / 2, - width: icon.size.width, - height: icon.size.height - ) - icon.draw(in: iconRect) - } - } else if let text = spec.text { - let font = UIFont.systemFont(ofSize: 14, weight: .bold) - let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] - let size = (text as NSString).size(withAttributes: attrs) - let textRect = CGRect( - x: centerX - size.width / 2, - y: circleRect.midY - size.height / 2, - width: size.width, - height: size.height - ) - (text as NSString).draw(in: textRect, withAttributes: attrs) - } - - // Triangle pointer - let triangleTop = circleRect.maxY - 3 - let path = UIBezierPath() - path.move(to: CGPoint(x: centerX - triangleSize / 2, y: triangleTop)) - path.addLine(to: CGPoint(x: centerX + triangleSize / 2, y: triangleTop)) - path.addLine(to: CGPoint(x: centerX, y: triangleTop + triangleSize)) - path.close() - spec.circleColor.setFill() - path.fill() - - // Hop badge overlay - if let hopIndex { - let badgeSize: CGFloat = 18 - let badgeX = circleRect.maxX + 4 - badgeSize - let badgeY = circleRect.minY - let badgeRect = CGRect(x: badgeX, y: badgeY, width: badgeSize, height: badgeSize) - - UIColor.systemBlue.setFill() - cgContext.fillEllipse(in: badgeRect) - - let text = "\(hopIndex)" - let font = UIFont.systemFont(ofSize: 11, weight: .bold) - let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] - let textSize = (text as NSString).size(withAttributes: attrs) - let textRect = CGRect( - x: badgeRect.midX - textSize.width / 2, - y: badgeRect.midY - textSize.height / 2, - width: textSize.width, - height: textSize.height - ) - (text as NSString).draw(in: textRect, withAttributes: attrs) - } - } + cachedImages?[name] = image + style.setImage(image, forName: name) + return image + } + + // MARK: - Sprite specifications + + private enum RenderStyle { + case standard + case crosshair + case obstruction + } + + private struct SpriteSpec { + let name: String + let circleColor: UIColor + let iconName: String? // SF Symbol name + let text: String? // e.g. "A", "B" for point pins + let ringColor: UIColor? // selection ring + let renderStyle: RenderStyle + } + + private static let allSpecs: [SpriteSpec] = [ + // Main map contacts + SpriteSpec(name: "pin-chat", circleColor: UIColor(red: 204 / 255, green: 122 / 255, blue: 92 / 255, alpha: 1), + iconName: "person.fill", text: nil, ringColor: nil, renderStyle: .standard), + SpriteSpec(name: "pin-repeater", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: nil, renderStyle: .standard), + SpriteSpec(name: "pin-room", circleColor: UIColor(red: 1, green: 136 / 255, blue: 0, alpha: 1), + iconName: "person.3.fill", text: nil, ringColor: nil, renderStyle: .standard), + + // LOS/TracePath repeater states + SpriteSpec(name: "pin-repeater-ring-blue", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemBlue, renderStyle: .standard), + SpriteSpec(name: "pin-repeater-ring-green", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemGreen, renderStyle: .standard), + SpriteSpec(name: "pin-repeater-ring-white", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .white, renderStyle: .standard), + + // LOS point pins + SpriteSpec(name: "pin-point-a", circleColor: .systemBlue, + iconName: nil, text: "A", ringColor: nil, renderStyle: .standard), + SpriteSpec(name: "pin-point-b", circleColor: .systemGreen, + iconName: nil, text: "B", ringColor: nil, renderStyle: .standard), + + // LOS crosshair target + SpriteSpec(name: "pin-crosshair", circleColor: .systemPurple, + iconName: nil, text: "R", ringColor: nil, renderStyle: .crosshair), + + // LOS obstruction marker + SpriteSpec(name: "pin-obstruction", circleColor: .systemRed, + iconName: nil, text: nil, ringColor: nil, renderStyle: .obstruction), + + // Chat-dropped coordinate pin + SpriteSpec(name: "pin-dropped", circleColor: .systemPink, + iconName: "mappin", text: nil, ringColor: nil, renderStyle: .standard), + ] + + // MARK: - Rendering + + private static func render(_ spec: SpriteSpec, hopIndex: Int? = nil) -> UIImage { + switch spec.renderStyle { + case .crosshair: return renderCrosshair(spec) + case .obstruction: return renderObstruction() + case .standard: break } - // MARK: - Pill sprites - - /// Semi-transparent stretchable pill for stats badges. - /// Registered as a resizable image so MapLibre's `iconTextFit` can stretch - /// the flat center while preserving the rounded caps. - private static func renderPillBackground() -> UIImage { - let cornerRadius: CGFloat = 4 - let size: CGFloat = 2 * cornerRadius + 2 - let shadowPadding: CGFloat = 1 - let totalSize = size + shadowPadding * 2 - let capInset = cornerRadius + shadowPadding - - let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) - let image = renderer.image { ctx in - let cgContext = ctx.cgContext - let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: size, height: size) - let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) - - // Shadow pass - cgContext.saveGState() - cgContext.setShadow( - offset: CGSize(width: 0, height: 0.5), - blur: 1, - color: UIColor.black.withAlphaComponent(0.15).cgColor - ) - UIColor.white.setFill() - pillPath.fill() - cgContext.restoreGState() - - // Light fill for readability in both light and dark mode - UIColor.white.withAlphaComponent(0.85).setFill() - pillPath.fill() + let circleSize: CGFloat = 36 + let iconSize: CGFloat = 16 + let triangleSize: CGFloat = 10 + let ringPadding: CGFloat = spec.ringColor != nil ? 4 : 0 + let ringSize: CGFloat = spec.ringColor != nil ? 44 : 0 + // A hop badge overhangs the circle's right edge by 4pt; widen the canvas + // (symmetrically, to keep the triangle tip horizontally centered on the + // coordinate) so a ringless badged pin isn't clipped. + let badgeRoom: CGFloat = hopIndex != nil ? 44 : 0 + let totalWidth = max(circleSize, ringSize, badgeRoom) + let totalHeight = circleSize + triangleSize - 3 + ringPadding + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalWidth, height: totalHeight), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let centerX = totalWidth / 2 + + // Selection ring, centered on the circle head so it encircles the + // round portion rather than the triangle tail. + if let ringColor = spec.ringColor { + let circleCenterY = ringPadding + circleSize / 2 + let ringRect = CGRect( + x: centerX - ringSize / 2, + y: circleCenterY - ringSize / 2, + width: ringSize, + height: ringSize + ) + ringColor.setStroke() + cgContext.setLineWidth(3) + cgContext.strokeEllipse(in: ringRect.insetBy(dx: 1.5, dy: 1.5)) + } + + // Circle shadow + cgContext.saveGState() + cgContext.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor) + let circleRect = CGRect( + x: centerX - circleSize / 2, + y: ringPadding, + width: circleSize, + height: circleSize + ) + spec.circleColor.setFill() + cgContext.fillEllipse(in: circleRect) + cgContext.restoreGState() + + // Circle (again without shadow for crisp edge) + spec.circleColor.setFill() + cgContext.fillEllipse(in: circleRect) + + // Icon or text + if let iconName = spec.iconName { + let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .regular) + if let icon = UIImage(systemName: iconName, withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) { + let iconRect = CGRect( + x: centerX - icon.size.width / 2, + y: circleRect.midY - icon.size.height / 2, + width: icon.size.width, + height: icon.size.height + ) + icon.draw(in: iconRect) } - - return image.resizableImage( - withCapInsets: UIEdgeInsets(top: capInset, left: capInset, bottom: capInset, right: capInset), - resizingMode: .stretch + } else if let text = spec.text { + let font = UIFont.systemFont(ofSize: 14, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let size = (text as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: centerX - size.width / 2, + y: circleRect.midY - size.height / 2, + width: size.width, + height: size.height ) - } - - private static func renderLabelSprite(text: String) -> UIImage { - let font = UIFont.systemFont(ofSize: 12, weight: .bold) - let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.black] + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + + // Triangle pointer + let triangleTop = circleRect.maxY - 3 + let path = UIBezierPath() + path.move(to: CGPoint(x: centerX - triangleSize / 2, y: triangleTop)) + path.addLine(to: CGPoint(x: centerX + triangleSize / 2, y: triangleTop)) + path.addLine(to: CGPoint(x: centerX, y: triangleTop + triangleSize)) + path.close() + spec.circleColor.setFill() + path.fill() + + // Hop badge overlay + if let hopIndex { + let badgeSize: CGFloat = 18 + let badgeX = circleRect.maxX + 4 - badgeSize + let badgeY = circleRect.minY + let badgeRect = CGRect(x: badgeX, y: badgeY, width: badgeSize, height: badgeSize) + + UIColor.systemBlue.setFill() + cgContext.fillEllipse(in: badgeRect) + + let text = "\(hopIndex)" + let font = UIFont.systemFont(ofSize: 11, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] let textSize = (text as NSString).size(withAttributes: attrs) - - let horizontalPadding: CGFloat = 6 - let verticalPadding: CGFloat = 4 - let cornerRadius: CGFloat = 4 - let shadowPadding: CGFloat = 1 - - let pillWidth = textSize.width + horizontalPadding * 2 - let pillHeight = textSize.height + verticalPadding * 2 - let totalWidth = pillWidth + shadowPadding * 2 - let totalHeight = pillHeight + shadowPadding * 2 - - let renderer = UIGraphicsImageRenderer( - size: CGSize(width: totalWidth, height: totalHeight), - format: .preferred() + let textRect = CGRect( + x: badgeRect.midX - textSize.width / 2, + y: badgeRect.midY - textSize.height / 2, + width: textSize.width, + height: textSize.height ) - return renderer.image { ctx in - let cgContext = ctx.cgContext - let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: pillWidth, height: pillHeight) - let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) - - cgContext.saveGState() - cgContext.setShadow( - offset: CGSize(width: 0, height: 0.5), - blur: 1, - color: UIColor.black.withAlphaComponent(0.15).cgColor - ) - UIColor.white.setFill() - pillPath.fill() - cgContext.restoreGState() - - UIColor.white.withAlphaComponent(0.85).setFill() - pillPath.fill() - - let textRect = CGRect( - x: shadowPadding + (pillWidth - textSize.width) / 2, - y: shadowPadding + (pillHeight - textSize.height) / 2, - width: textSize.width, - height: textSize.height - ) - (text as NSString).draw(in: textRect, withAttributes: attrs) - } + (text as NSString).draw(in: textRect, withAttributes: attrs) + } } - - private static func renderObstruction() -> UIImage { - let size: CGFloat = 20 - let padding: CGFloat = 3 - let totalSize = size + padding * 2 - let armLength: CGFloat = size / 2 - 1 - let casingWidth: CGFloat = 6 - let strokeWidth: CGFloat = 2.5 - - let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) - return renderer.image { ctx in - let cgContext = ctx.cgContext - let center = CGPoint(x: totalSize / 2, y: totalSize / 2) - - // Draw white casing (thick white stroke behind the red X) - cgContext.setStrokeColor(UIColor.white.cgColor) - cgContext.setLineWidth(casingWidth) - cgContext.setLineCap(.round) - - cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) - cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) - cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) - cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) - cgContext.strokePath() - - // Draw red X on top - cgContext.setStrokeColor(UIColor.systemRed.cgColor) - cgContext.setLineWidth(strokeWidth) - cgContext.setLineCap(.round) - - cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) - cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) - cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) - cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) - cgContext.strokePath() - } + } + + // MARK: - Pill sprites + + /// Semi-transparent stretchable pill for stats badges. + /// Registered as a resizable image so MapLibre's `iconTextFit` can stretch + /// the flat center while preserving the rounded caps. + private static func renderPillBackground() -> UIImage { + let cornerRadius: CGFloat = 4 + let size: CGFloat = 2 * cornerRadius + 2 + let shadowPadding: CGFloat = 1 + let totalSize = size + shadowPadding * 2 + let capInset = cornerRadius + shadowPadding + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) + let image = renderer.image { ctx in + let cgContext = ctx.cgContext + let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: size, height: size) + let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) + + // Shadow pass + cgContext.saveGState() + cgContext.setShadow( + offset: CGSize(width: 0, height: 0.5), + blur: 1, + color: UIColor.black.withAlphaComponent(0.15).cgColor + ) + UIColor.white.setFill() + pillPath.fill() + cgContext.restoreGState() + + // Light fill for readability in both light and dark mode + UIColor.white.withAlphaComponent(0.85).setFill() + pillPath.fill() } - private static func renderCrosshair(_ spec: SpriteSpec) -> UIImage { - let casingWidth: CGFloat = 6 - let capInset = casingWidth / 2 - let size: CGFloat = 44 + capInset * 2 - let gapRadius: CGFloat = 4 - let outerRadius: CGFloat = 22 - let badgeHeight: CGFloat = 20 - let badgeGap: CGFloat = 2 - // Top padding so the crosshair center sits at the image's vertical midpoint - let topPadding = badgeHeight + badgeGap - let totalHeight = topPadding + size + badgeGap + badgeHeight - - let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: totalHeight), format: .preferred()) - return renderer.image { ctx in - let cgContext = ctx.cgContext - let center = CGPoint(x: size / 2, y: topPadding + size / 2) - - // White casing behind crosshair lines - cgContext.setStrokeColor(UIColor.white.cgColor) - cgContext.setLineWidth(6) - cgContext.setLineCap(.round) - - cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) - cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) - cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) - cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) - cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) - cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) - cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) - cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) - cgContext.strokePath() - - // Crosshair lines - cgContext.setStrokeColor(UIColor.systemPurple.cgColor) - cgContext.setLineWidth(2) - cgContext.setLineCap(.round) - - cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) - cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) - cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) - cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) - cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) - cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) - cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) - cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) - cgContext.strokePath() - - // "R" badge - let badgeWidth: CGFloat = 20 - let badgeRect = CGRect(x: center.x - badgeWidth / 2, y: topPadding + size + badgeGap, width: badgeWidth, height: badgeHeight) - let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 9) - UIColor.systemPurple.setFill() - badgePath.fill() - - let font = UIFont.systemFont(ofSize: 11, weight: .bold) - let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] - let textSize = ("R" as NSString).size(withAttributes: attrs) - let textRect = CGRect( - x: badgeRect.midX - textSize.width / 2, - y: badgeRect.midY - textSize.height / 2, - width: textSize.width, - height: textSize.height - ) - ("R" as NSString).draw(in: textRect, withAttributes: attrs) - } + return image.resizableImage( + withCapInsets: UIEdgeInsets(top: capInset, left: capInset, bottom: capInset, right: capInset), + resizingMode: .stretch + ) + } + + private static func renderLabelSprite(text: String) -> UIImage { + let font = UIFont.systemFont(ofSize: 12, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.black] + let textSize = (text as NSString).size(withAttributes: attrs) + + let horizontalPadding: CGFloat = 6 + let verticalPadding: CGFloat = 4 + let cornerRadius: CGFloat = 4 + let shadowPadding: CGFloat = 1 + + let pillWidth = textSize.width + horizontalPadding * 2 + let pillHeight = textSize.height + verticalPadding * 2 + let totalWidth = pillWidth + shadowPadding * 2 + let totalHeight = pillHeight + shadowPadding * 2 + + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: totalWidth, height: totalHeight), + format: .preferred() + ) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: pillWidth, height: pillHeight) + let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) + + cgContext.saveGState() + cgContext.setShadow( + offset: CGSize(width: 0, height: 0.5), + blur: 1, + color: UIColor.black.withAlphaComponent(0.15).cgColor + ) + UIColor.white.setFill() + pillPath.fill() + cgContext.restoreGState() + + UIColor.white.withAlphaComponent(0.85).setFill() + pillPath.fill() + + let textRect = CGRect( + x: shadowPadding + (pillWidth - textSize.width) / 2, + y: shadowPadding + (pillHeight - textSize.height) / 2, + width: textSize.width, + height: textSize.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + } + + private static func renderObstruction() -> UIImage { + let size: CGFloat = 20 + let padding: CGFloat = 3 + let totalSize = size + padding * 2 + let armLength: CGFloat = size / 2 - 1 + let casingWidth: CGFloat = 6 + let strokeWidth: CGFloat = 2.5 + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let center = CGPoint(x: totalSize / 2, y: totalSize / 2) + + // Draw white casing (thick white stroke behind the red X) + cgContext.setStrokeColor(UIColor.white.cgColor) + cgContext.setLineWidth(casingWidth) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) + cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) + cgContext.strokePath() + + // Draw red X on top + cgContext.setStrokeColor(UIColor.systemRed.cgColor) + cgContext.setLineWidth(strokeWidth) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) + cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) + cgContext.strokePath() + } + } + + private static func renderCrosshair(_ spec: SpriteSpec) -> UIImage { + let casingWidth: CGFloat = 6 + let capInset = casingWidth / 2 + let size: CGFloat = 44 + capInset * 2 + let gapRadius: CGFloat = 4 + let outerRadius: CGFloat = 22 + let badgeHeight: CGFloat = 20 + let badgeGap: CGFloat = 2 + // Top padding so the crosshair center sits at the image's vertical midpoint + let topPadding = badgeHeight + badgeGap + let totalHeight = topPadding + size + badgeGap + badgeHeight + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: totalHeight), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let center = CGPoint(x: size / 2, y: topPadding + size / 2) + + // White casing behind crosshair lines + cgContext.setStrokeColor(UIColor.white.cgColor) + cgContext.setLineWidth(6) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) + cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) + cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) + cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) + cgContext.strokePath() + + // Crosshair lines + cgContext.setStrokeColor(UIColor.systemPurple.cgColor) + cgContext.setLineWidth(2) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) + cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) + cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) + cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) + cgContext.strokePath() + + // "R" badge + let badgeWidth: CGFloat = 20 + let badgeRect = CGRect(x: center.x - badgeWidth / 2, y: topPadding + size + badgeGap, width: badgeWidth, height: badgeHeight) + let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 9) + UIColor.systemPurple.setFill() + badgePath.fill() + + let font = UIFont.systemFont(ofSize: 11, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let textSize = ("R" as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: badgeRect.midX - textSize.width / 2, + y: badgeRect.midY - textSize.height / 2, + width: textSize.width, + height: textSize.height + ) + ("R" as NSString).draw(in: textRect, withAttributes: attrs) } + } } diff --git a/MC1/Views/Onboarding/DeviceScanView.swift b/MC1/Views/Onboarding/DeviceScanView.swift index db751252..023c6bf6 100644 --- a/MC1/Views/Onboarding/DeviceScanView.swift +++ b/MC1/Views/Onboarding/DeviceScanView.swift @@ -1,242 +1,239 @@ -import SwiftUI import MC1Services +import SwiftUI struct DeviceScanView: View { - @Environment(\.appState) private var appState - @State private var showTroubleshooting = false - @State private var showingWiFiConnection = false - @State private var showingNoDeviceSheet = false - @State private var pairingSuccessTrigger = false - @State private var failureHapticTrigger = false - @State private var demoModeUnlockTrigger = false - @State private var didInitiatePairing = false - @State private var showDemoModeAlert = false - @State private var otherAppDeviceID: UUID? - private var demoModeManager = DemoModeManager.shared - - private var hasConnectedDevice: Bool { - appState.connectionState == .ready - } + @Environment(\.appState) private var appState + @State private var showTroubleshooting = false + @State private var showingWiFiConnection = false + @State private var showingNoDeviceSheet = false + @State private var pairingSuccessTrigger = false + @State private var failureHapticTrigger = false + @State private var demoModeUnlockTrigger = false + @State private var didInitiatePairing = false + @State private var showDemoModeAlert = false + @State private var otherAppDeviceID: UUID? + private var demoModeManager = DemoModeManager.shared + + private var hasConnectedDevice: Bool { + appState.connectionState == .ready + } + + var body: some View { + VStack(spacing: OnboardingMetrics.largeSpacing) { + VStack(spacing: OnboardingMetrics.mediumSpacing) { + PulsingAntenna() + + Text(L10n.Onboarding.DeviceScan.title) + .font(.largeTitle) + .bold() + .accessibilityAddTraits(.isHeader) + .simultaneousGesture( + TapGesture(count: 3).onEnded { unlockDemoMode() } + ) + + if !hasConnectedDevice { + Text(L10n.Onboarding.DeviceScan.subtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } - var body: some View { - VStack(spacing: OnboardingMetrics.largeSpacing) { - VStack(spacing: OnboardingMetrics.mediumSpacing) { - PulsingAntenna() - - Text(L10n.Onboarding.DeviceScan.title) - .font(.largeTitle) - .bold() - .accessibilityAddTraits(.isHeader) - .simultaneousGesture( - TapGesture(count: 3).onEnded { unlockDemoMode() } - ) - - if !hasConnectedDevice { - Text(L10n.Onboarding.DeviceScan.subtitle) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } + Spacer() - Spacer() + if hasConnectedDevice, !didInitiatePairing { + VStack(spacing: OnboardingMetrics.mediumSpacing) { + Text(L10n.Onboarding.DeviceScan.alreadyPaired) + .font(.title2) + .multilineTextAlignment(.center) + } + .padding() + } + + Spacer() + + VStack(spacing: OnboardingMetrics.mediumSpacing) { + if hasConnectedDevice { + Button { + appState.onboarding.onboardingPath.append(.region) + } label: { + Text(L10n.Onboarding.DeviceScan.continue) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + } else { + primaryCTA - if hasConnectedDevice && !didInitiatePairing { - VStack(spacing: OnboardingMetrics.mediumSpacing) { - Text(L10n.Onboarding.DeviceScan.alreadyPaired) - .font(.title2) - .multilineTextAlignment(.center) - } - .padding() + ViewThatFits { + HStack(spacing: OnboardingMetrics.largeSpacing) { + secondaryButtons } - - Spacer() - VStack(spacing: OnboardingMetrics.mediumSpacing) { - if hasConnectedDevice { - Button { - appState.onboarding.onboardingPath.append(.region) - } label: { - Text(L10n.Onboarding.DeviceScan.continue) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - } else { - primaryCTA - - ViewThatFits { - HStack(spacing: OnboardingMetrics.largeSpacing) { - secondaryButtons - } - VStack(spacing: OnboardingMetrics.mediumSpacing) { - secondaryButtons - } - } - .frame(minHeight: OnboardingMetrics.minHitTarget) - - Button(L10n.Onboarding.DeviceScan.noDeviceYet) { - showingNoDeviceSheet = true - } - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.vertical, 8) - .frame(minHeight: OnboardingMetrics.minHitTarget) - } + secondaryButtons } - .padding(.horizontal) - .padding(.bottom) - } - .sensoryFeedback(.success, trigger: pairingSuccessTrigger) - .sensoryFeedback(.success, trigger: demoModeUnlockTrigger) - .sensoryFeedback(.error, trigger: failureHapticTrigger) - .onChange(of: appState.connectionUI.otherAppWarningDeviceID) { _, newValue in - // retryFailedPairingConnect surfaces other-app failures via the ConnectionUI alert - // without going through startPairing's local catch, so we mirror the warning ID - // into local state to keep the recovery CTA pinned to "Retry connection" after - // the user dismisses the alert. - if let id = newValue { otherAppDeviceID = id } - } - .sheet(isPresented: $showTroubleshooting) { - TroubleshootingSheet() - } - .sheet(isPresented: $showingWiFiConnection) { - WiFiConnectionSheet() - } - .sheet(isPresented: $showingNoDeviceSheet) { - NoDeviceSheet() - } - .alert(L10n.Onboarding.DeviceScan.DemoModeAlert.title, isPresented: $showDemoModeAlert) { - Button(L10n.Localizable.Common.ok) { } - } message: { - Text(L10n.Onboarding.DeviceScan.DemoModeAlert.message) + } + .frame(minHeight: OnboardingMetrics.minHitTarget) + + Button(L10n.Onboarding.DeviceScan.noDeviceYet) { + showingNoDeviceSheet = true + } + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + .frame(minHeight: OnboardingMetrics.minHitTarget) } + } + .padding(.horizontal) + .padding(.bottom) } - - @ViewBuilder - private var primaryCTA: some View { - #if targetEnvironment(simulator) - Button { connectSimulator() } label: { ctaLabel(systemImage: "laptopcomputer.and.iphone", - text: L10n.Onboarding.DeviceScan.connectSimulator) } - .liquidGlassProminentButtonStyle() - .disabled(appState.connectionUI.isBusy) - #else - if demoModeManager.isEnabled { - Button { connectSimulator() } label: { ctaLabel(systemImage: "play.circle.fill", - text: L10n.Onboarding.DeviceScan.continueDemo) } - .liquidGlassProminentButtonStyle() - .disabled(appState.connectionUI.isBusy) - } else if let deviceID = otherAppDeviceID { - Button { retryConnection(deviceID: deviceID) } label: { ctaLabel(systemImage: "arrow.clockwise.circle.fill", - text: L10n.Onboarding.DeviceScan.retryConnection) } - .liquidGlassProminentButtonStyle() - .disabled(appState.connectionUI.isBusy) - } else { - Button { startPairing() } label: { ctaLabel(systemImage: "plus.circle.fill", - text: L10n.Onboarding.DeviceScan.addDevice) } - .liquidGlassProminentButtonStyle() - .disabled(appState.connectionUI.isBusy) - } - #endif + .sensoryFeedback(.success, trigger: pairingSuccessTrigger) + .sensoryFeedback(.success, trigger: demoModeUnlockTrigger) + .sensoryFeedback(.error, trigger: failureHapticTrigger) + .onChange(of: appState.connectionUI.otherAppWarningDeviceID) { _, newValue in + // retryFailedPairingConnect surfaces other-app failures via the ConnectionUI alert + // without going through startPairing's local catch, so we mirror the warning ID + // into local state to keep the recovery CTA pinned to "Retry connection" after + // the user dismisses the alert. + if let id = newValue { otherAppDeviceID = id } } - - @ViewBuilder - private var secondaryButtons: some View { - Button(L10n.Onboarding.DeviceScan.connectViaWifi) { showingWiFiConnection = true } - .font(.subheadline) - .foregroundStyle(.secondary) - - Button(L10n.Onboarding.DeviceScan.deviceNotAppearing) { showTroubleshooting = true } - .font(.subheadline) - .foregroundStyle(.secondary) + .sheet(isPresented: $showTroubleshooting) { + TroubleshootingSheet() } - - @ViewBuilder - private func ctaLabel(systemImage: String, text: String) -> some View { - HStack(spacing: 8) { - if appState.connectionUI.isBusy { - ProgressView().controlSize(.small) - Text(L10n.Onboarding.DeviceScan.connecting) - } else { - Image(systemName: systemImage) - Text(text) - } - } - .font(.headline) - .frame(maxWidth: .infinity) - .padding() + .sheet(isPresented: $showingWiFiConnection) { + WiFiConnectionSheet() } - - private func startPairing() { - appState.connectionUI.isBusy = true - didInitiatePairing = true - appState.connectionUI.failedPairingDeviceID = nil - - Task { @MainActor in - defer { appState.connectionUI.isBusy = false } - do { - try await appState.connectionManager.pairNewDevice() - await appState.wireServicesIfConnected() - pairingSuccessTrigger.toggle() - appState.onboarding.onboardingPath.append(.region) - } catch DevicePairingError.cancelled { - } catch DevicePairingError.alreadyInProgress { - } catch let pairingError as PairingError { - if case .deviceConnectedToOtherApp(let deviceID) = pairingError { - otherAppDeviceID = deviceID - } - failureHapticTrigger.toggle() - appState.connectionUI.presentPairingFailure(pairingError) - } catch { - appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } - } + .sheet(isPresented: $showingNoDeviceSheet) { + NoDeviceSheet() } - - private func retryConnection(deviceID: UUID) { - appState.connectionUI.isBusy = true - Task { @MainActor in - defer { appState.connectionUI.isBusy = false } - do { - try await appState.connectionManager.connect(to: deviceID, forceReconnect: true) - await appState.wireServicesIfConnected() - pairingSuccessTrigger.toggle() - appState.onboarding.onboardingPath.append(.region) - } catch BLEError.deviceConnectedToOtherApp { - appState.connectionUI.otherAppWarningDeviceID = deviceID - } catch { - appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } - } + .alert(L10n.Onboarding.DeviceScan.DemoModeAlert.title, isPresented: $showDemoModeAlert) { + Button(L10n.Localizable.Common.ok) {} + } message: { + Text(L10n.Onboarding.DeviceScan.DemoModeAlert.message) } - - private func connectSimulator() { - appState.connectionUI.isBusy = true - didInitiatePairing = true - Task { @MainActor in - defer { appState.connectionUI.isBusy = false } - do { - try await appState.connectionManager.simulatorConnect() - await appState.wireServicesIfConnected() - pairingSuccessTrigger.toggle() - appState.onboarding.onboardingPath.append(.region) - } catch { - appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } + } + + @ViewBuilder + private var primaryCTA: some View { + #if targetEnvironment(simulator) + Button { connectSimulator() } label: { ctaLabel(systemImage: "laptopcomputer.and.iphone", + text: L10n.Onboarding.DeviceScan.connectSimulator) } + .liquidGlassProminentButtonStyle() + .disabled(appState.connectionUI.isBusy) + #else + if demoModeManager.isEnabled { + Button { connectSimulator() } label: { ctaLabel(systemImage: "play.circle.fill", + text: L10n.Onboarding.DeviceScan.continueDemo) } + .liquidGlassProminentButtonStyle() + .disabled(appState.connectionUI.isBusy) + } else if let deviceID = otherAppDeviceID { + Button { retryConnection(deviceID: deviceID) } label: { ctaLabel(systemImage: "arrow.clockwise.circle.fill", + text: L10n.Onboarding.DeviceScan.retryConnection) } + .liquidGlassProminentButtonStyle() + .disabled(appState.connectionUI.isBusy) + } else { + Button { startPairing() } label: { ctaLabel(systemImage: "plus.circle.fill", + text: L10n.Onboarding.DeviceScan.addDevice) } + .liquidGlassProminentButtonStyle() + .disabled(appState.connectionUI.isBusy) + } + #endif + } + + @ViewBuilder + private var secondaryButtons: some View { + Button(L10n.Onboarding.DeviceScan.connectViaWifi) { showingWiFiConnection = true } + .font(.subheadline) + .foregroundStyle(.secondary) + + Button(L10n.Onboarding.DeviceScan.deviceNotAppearing) { showTroubleshooting = true } + .font(.subheadline) + .foregroundStyle(.secondary) + } + + private func ctaLabel(systemImage: String, text: String) -> some View { + HStack(spacing: 8) { + if appState.connectionUI.isBusy { + ProgressView().controlSize(.small) + Text(L10n.Onboarding.DeviceScan.connecting) + } else { + Image(systemName: systemImage) + Text(text) + } + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + + private func startPairing() { + appState.connectionUI.isBusy = true + didInitiatePairing = true + appState.connectionUI.failedPairingDeviceID = nil + + Task { @MainActor in + defer { appState.connectionUI.isBusy = false } + do { + try await appState.connectionManager.pairNewDevice() + await appState.wireServicesIfConnected() + pairingSuccessTrigger.toggle() + appState.onboarding.onboardingPath.append(.region) + } catch DevicePairingError.cancelled { + } catch DevicePairingError.alreadyInProgress { + } catch let pairingError as PairingError { + if case let .deviceConnectedToOtherApp(deviceID) = pairingError { + otherAppDeviceID = deviceID } + failureHapticTrigger.toggle() + appState.connectionUI.presentFreshPairingFailure(pairingError) + } catch { + appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) + } } - - /// 3-tap easter egg for App Store reviewers — preserved per CLAUDE.md `demo-mode-is-for-app-store-reviewers`. - private func unlockDemoMode() { - demoModeManager.unlock() - demoModeUnlockTrigger.toggle() - showDemoModeAlert = true + } + + private func retryConnection(deviceID: UUID) { + appState.connectionUI.isBusy = true + Task { @MainActor in + defer { appState.connectionUI.isBusy = false } + do { + try await appState.connectionManager.connect(to: deviceID, forceReconnect: true) + await appState.wireServicesIfConnected() + pairingSuccessTrigger.toggle() + appState.onboarding.onboardingPath.append(.region) + } catch { + appState.connectionUI.presentSavedDeviceConnectFailure(deviceID: deviceID, error: error) + } + } + } + + private func connectSimulator() { + appState.connectionUI.isBusy = true + didInitiatePairing = true + Task { @MainActor in + defer { appState.connectionUI.isBusy = false } + do { + try await appState.connectionManager.simulatorConnect() + await appState.wireServicesIfConnected() + pairingSuccessTrigger.toggle() + appState.onboarding.onboardingPath.append(.region) + } catch { + appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) + } } + } + + /// 3-tap easter egg for App Store reviewers — preserved per CLAUDE.md `demo-mode-is-for-app-store-reviewers`. + private func unlockDemoMode() { + demoModeManager.unlock() + demoModeUnlockTrigger.toggle() + showDemoModeAlert = true + } } #Preview { - DeviceScanView() - .environment(\.appState, AppState()) + DeviceScanView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/DeviceScannerSheet.swift b/MC1/Views/Onboarding/DeviceScannerSheet.swift index a91da3c6..d0931593 100644 --- a/MC1/Views/Onboarding/DeviceScannerSheet.swift +++ b/MC1/Views/Onboarding/DeviceScannerSheet.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// In-app BLE device picker used on macOS "Designed for iPad", where AccessorySetupKit's /// system picker is unavailable. @@ -10,125 +10,125 @@ import MC1Services /// the user taps a device (`select`) or cancels (`cancel`). The selected UUID then flows /// through the same `connect(to:)` ceremony as the AccessorySetupKit path on iOS. struct DeviceScannerSheet: View { - @Environment(\.appState) private var appState - let picker: BluetoothScanPairingService + @Environment(\.appState) private var appState + let picker: BluetoothScanPairingService - @State private var tracker = RSSIScanTracker() + @State private var tracker = RSSIScanTracker() - /// Stable ordering: by name, then id. Avoids row reshuffling as RSSI fluctuates. - private var sortedDevices: [DiscoveredDevice] { - tracker.devices.values.sorted { lhs, rhs in - let lName = lhs.name ?? "" - let rName = rhs.name ?? "" - if lName != rName { return lName.localizedCaseInsensitiveCompare(rName) == .orderedAscending } - return lhs.id.uuidString < rhs.id.uuidString - } + /// Stable ordering: by name, then id. Avoids row reshuffling as RSSI fluctuates. + private var sortedDevices: [DiscoveredDevice] { + tracker.devices.values.sorted { lhs, rhs in + let lName = lhs.name ?? "" + let rName = rhs.name ?? "" + if lName != rName { return lName.localizedCaseInsensitiveCompare(rName) == .orderedAscending } + return lhs.id.uuidString < rhs.id.uuidString } + } - var body: some View { - NavigationStack { - Group { - switch appState.connectionManager.bluetoothAvailability { - case .poweredOff: - bluetoothRemedy( - title: L10n.Onboarding.DeviceScanner.BluetoothOff.title, - message: L10n.Onboarding.DeviceScanner.BluetoothOff.message - ) - case .unauthorized: - bluetoothRemedy( - title: L10n.Onboarding.DeviceScanner.BluetoothUnauthorized.title, - message: L10n.Onboarding.DeviceScanner.BluetoothUnauthorized.message - ) - case .ready: - if sortedDevices.isEmpty { - scanningState - } else { - deviceList - } - } - } - .navigationTitle(L10n.Onboarding.DeviceScanner.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - picker.cancel() - } - } - } - .task { await tracker.consume(appState.connectionManager.startBLEScanning()) } + var body: some View { + NavigationStack { + Group { + switch appState.connectionManager.bluetoothAvailability { + case .poweredOff: + bluetoothRemedy( + title: L10n.Onboarding.DeviceScanner.BluetoothOff.title, + message: L10n.Onboarding.DeviceScanner.BluetoothOff.message + ) + case .unauthorized: + bluetoothRemedy( + title: L10n.Onboarding.DeviceScanner.BluetoothUnauthorized.title, + message: L10n.Onboarding.DeviceScanner.BluetoothUnauthorized.message + ) + case .ready: + if sortedDevices.isEmpty { + scanningState + } else { + deviceList + } } + } + .navigationTitle(L10n.Onboarding.DeviceScanner.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + picker.cancel() + } + } + } + .task { await tracker.consume(appState.connectionManager.startBLEScanning()) } } + } - /// Scanning empty state with an inline spinner, so the picker reads as actively searching - /// rather than stalled before the first peripheral resolves. - private var scanningState: some View { - ContentUnavailableView { - Label(L10n.Onboarding.DeviceScanner.scanning, - systemImage: "antenna.radiowaves.left.and.right") - } description: { - ProgressView() - .controlSize(.small) - } + /// Scanning empty state with an inline spinner, so the picker reads as actively searching + /// rather than stalled before the first peripheral resolves. + private var scanningState: some View { + ContentUnavailableView { + Label(L10n.Onboarding.DeviceScanner.scanning, + systemImage: "antenna.radiowaves.left.and.right") + } description: { + ProgressView() + .controlSize(.small) } + } - private var deviceList: some View { - List(sortedDevices) { device in - Button { - picker.select(device.id) - } label: { - DeviceScannerRow( - name: device.name ?? L10n.Onboarding.DeviceScanner.unknownDevice, - signalTier: tracker.signalTier(for: device.id) ?? .weak - ) - .contentShape(.rect) - } - .buttonStyle(.plain) - } + private var deviceList: some View { + List(sortedDevices) { device in + Button { + picker.select(device.id) + } label: { + DeviceScannerRow( + name: device.name ?? L10n.Onboarding.DeviceScanner.unknownDevice, + signalTier: tracker.signalTier(for: device.id) ?? .weak + ) + .contentShape(.rect) + } + .buttonStyle(.plain) } + } - /// Remedy state for the macOS scanner when Bluetooth is off or unauthorized: scanning cannot - /// surface any peripheral, so the picker explains what to fix instead of spinning indefinitely. - private func bluetoothRemedy(title: String, message: String) -> some View { - ContentUnavailableView { - Label(title, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(message) - } + /// Remedy state for the macOS scanner when Bluetooth is off or unauthorized: scanning cannot + /// surface any peripheral, so the picker explains what to fix instead of spinning indefinitely. + private func bluetoothRemedy(title: String, message: String) -> some View { + ContentUnavailableView { + Label(title, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(message) } + } } // MARK: - Row private struct DeviceScannerRow: View { - let name: String - let signalTier: RSSITuning.SignalTier + let name: String + let signalTier: RSSITuning.SignalTier - var body: some View { - HStack(spacing: 12) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.title2) - .foregroundStyle(.green) - .frame(width: 40, height: 40) - .background(.green.opacity(0.1), in: .circle) - .accessibilityHidden(true) + var body: some View { + HStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.title2) + .foregroundStyle(.green) + .frame(width: 40, height: 40) + .background(.green.opacity(0.1), in: .circle) + .accessibilityHidden(true) - Text(name) - .font(.headline) + Text(name) + .font(.headline) - Spacer() + Spacer() - SignalBars(tier: signalTier, accessibilityLabel: signalDescription) - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) + SignalBars(tier: signalTier, accessibilityLabel: signalDescription) } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + } - /// VoiceOver descriptor for the signal glyph, so the only in-range/barely-reachable - /// differentiator is announced rather than silent (the combined element otherwise reads - /// the device name alone). - private var signalDescription: String { - SignalBars.accessibilityDescription(forTier: signalTier) - } + /// VoiceOver descriptor for the signal glyph, so the only in-range/barely-reachable + /// differentiator is announced rather than silent (the combined element otherwise reads + /// the device name alone). + private var signalDescription: String { + SignalBars.accessibilityDescription(forTier: signalTier) + } } diff --git a/MC1/Views/Onboarding/MeshAnimationView.swift b/MC1/Views/Onboarding/MeshAnimationView.swift index 13912860..3037fbea 100644 --- a/MC1/Views/Onboarding/MeshAnimationView.swift +++ b/MC1/Views/Onboarding/MeshAnimationView.swift @@ -2,173 +2,173 @@ import SwiftUI /// Animated mesh network visualization showing interconnected nodes with a traveling message. struct MeshAnimationView: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private struct Node: Identifiable { - let id = UUID() - var position: CGPoint - let isUserNode: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private struct Node: Identifiable { + let id = UUID() + var position: CGPoint + let isUserNode: Bool + } + + private struct Edge: Identifiable { + let id = UUID() + let from: Int + let to: Int + } + + // MARK: - Animation Constants + + private let nodeRadius: CGFloat = 8 + private let userNodeRadius: CGFloat = 12 + private let messageRadius: CGFloat = 4 + private let edgeLineWidth: CGFloat = 1.5 + + private let edgeFadeFrequency: Double = 0.8 + private let edgeMinOpacity: Double = 0.3 + private let edgeFadeAmplitude: Double = 0.3 + private let edgePhaseOffset: Double = 0.7 + + private let nodePulseFrequency: Double = 1.2 + private let nodePulseAmplitude: Double = 0.15 + private let nodePhaseOffset: Double = 0.5 + + private let messageCycleDuration: Double = 4.0 + + private let nodes: [Node] = [ + Node(position: CGPoint(x: 0.2, y: 0.3), isUserNode: false), + Node(position: CGPoint(x: 0.5, y: 0.15), isUserNode: false), + Node(position: CGPoint(x: 0.8, y: 0.25), isUserNode: false), + Node(position: CGPoint(x: 0.15, y: 0.7), isUserNode: false), + Node(position: CGPoint(x: 0.5, y: 0.55), isUserNode: true), + Node(position: CGPoint(x: 0.85, y: 0.75), isUserNode: false), + ] + + private let edges: [Edge] = [ + Edge(from: 0, to: 1), + Edge(from: 1, to: 2), + Edge(from: 0, to: 4), + Edge(from: 1, to: 4), + Edge(from: 3, to: 4), + Edge(from: 4, to: 5), + Edge(from: 2, to: 5), + ] + + private let messagePath: [Int] = [3, 4, 1, 2] + + var body: some View { + Group { + if reduceMotion { + Canvas { context, size in + drawMesh(context: context, size: size, time: 0) + } + } else { + TimelineView(.animation(minimumInterval: 1 / 30)) { timeline in + Canvas { context, size in + let time = timeline.date.timeIntervalSinceReferenceDate + drawMesh(context: context, size: size, time: time) + } + } + } } - - private struct Edge: Identifiable { - let id = UUID() - let from: Int - let to: Int + .frame(height: 150) + .accessibilityLabel(L10n.Onboarding.MeshAnimation.accessibilityLabel) + .accessibilityAddTraits(.isImage) + } + + private func drawMesh(context: GraphicsContext, size: CGSize, time: Double) { + // Draw edges with fading effect + for (index, edge) in edges.enumerated() { + let fromNode = nodes[edge.from] + let toNode = nodes[edge.to] + + let fromPoint = CGPoint( + x: fromNode.position.x * size.width, + y: fromNode.position.y * size.height + ) + let toPoint = CGPoint( + x: toNode.position.x * size.width, + y: toNode.position.y * size.height + ) + + let phase = Double(index) * edgePhaseOffset + let opacity = edgeMinOpacity + edgeFadeAmplitude * sin(time * edgeFadeFrequency + phase) + + var path = Path() + path.move(to: fromPoint) + path.addLine(to: toPoint) + + context.stroke( + path, + with: .color(Color.accentColor.opacity(opacity)), + lineWidth: edgeLineWidth + ) } - // MARK: - Animation Constants - - private let nodeRadius: CGFloat = 8 - private let userNodeRadius: CGFloat = 12 - private let messageRadius: CGFloat = 4 - private let edgeLineWidth: CGFloat = 1.5 - - private let edgeFadeFrequency: Double = 0.8 - private let edgeMinOpacity: Double = 0.3 - private let edgeFadeAmplitude: Double = 0.3 - private let edgePhaseOffset: Double = 0.7 - - private let nodePulseFrequency: Double = 1.2 - private let nodePulseAmplitude: Double = 0.15 - private let nodePhaseOffset: Double = 0.5 - - private let messageCycleDuration: Double = 4.0 - - private let nodes: [Node] = [ - Node(position: CGPoint(x: 0.2, y: 0.3), isUserNode: false), - Node(position: CGPoint(x: 0.5, y: 0.15), isUserNode: false), - Node(position: CGPoint(x: 0.8, y: 0.25), isUserNode: false), - Node(position: CGPoint(x: 0.15, y: 0.7), isUserNode: false), - Node(position: CGPoint(x: 0.5, y: 0.55), isUserNode: true), - Node(position: CGPoint(x: 0.85, y: 0.75), isUserNode: false), - ] - - private let edges: [Edge] = [ - Edge(from: 0, to: 1), - Edge(from: 1, to: 2), - Edge(from: 0, to: 4), - Edge(from: 1, to: 4), - Edge(from: 3, to: 4), - Edge(from: 4, to: 5), - Edge(from: 2, to: 5), - ] - - private let messagePath: [Int] = [3, 4, 1, 2] - - var body: some View { - Group { - if reduceMotion { - Canvas { context, size in - drawMesh(context: context, size: size, time: 0) - } - } else { - TimelineView(.animation(minimumInterval: 1/30)) { timeline in - Canvas { context, size in - let time = timeline.date.timeIntervalSinceReferenceDate - drawMesh(context: context, size: size, time: time) - } - } - } - } - .frame(height: 150) - .accessibilityLabel(L10n.Onboarding.MeshAnimation.accessibilityLabel) - .accessibilityAddTraits(.isImage) + // Draw nodes with pulse effect + for (index, node) in nodes.enumerated() { + let center = CGPoint( + x: node.position.x * size.width, + y: node.position.y * size.height + ) + + let phase = Double(index) * nodePhaseOffset + let scale = 1.0 + nodePulseAmplitude * sin(time * nodePulseFrequency + phase) + let baseRadius = node.isUserNode ? userNodeRadius : nodeRadius + let radius = baseRadius * scale + + let rect = CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + ) + + context.fill( + Path(ellipseIn: rect), + with: .color(Color.accentColor.opacity(node.isUserNode ? 1.0 : 0.7)) + ) } - private func drawMesh(context: GraphicsContext, size: CGSize, time: Double) { - // Draw edges with fading effect - for (index, edge) in edges.enumerated() { - let fromNode = nodes[edge.from] - let toNode = nodes[edge.to] - - let fromPoint = CGPoint( - x: fromNode.position.x * size.width, - y: fromNode.position.y * size.height - ) - let toPoint = CGPoint( - x: toNode.position.x * size.width, - y: toNode.position.y * size.height - ) - - let phase = Double(index) * edgePhaseOffset - let opacity = edgeMinOpacity + edgeFadeAmplitude * sin(time * edgeFadeFrequency + phase) - - var path = Path() - path.move(to: fromPoint) - path.addLine(to: toPoint) - - context.stroke( - path, - with: .color(Color.accentColor.opacity(opacity)), - lineWidth: edgeLineWidth - ) - } - - // Draw nodes with pulse effect - for (index, node) in nodes.enumerated() { - let center = CGPoint( - x: node.position.x * size.width, - y: node.position.y * size.height - ) - - let phase = Double(index) * nodePhaseOffset - let scale = 1.0 + nodePulseAmplitude * sin(time * nodePulseFrequency + phase) - let baseRadius = node.isUserNode ? userNodeRadius : nodeRadius - let radius = baseRadius * scale - - let rect = CGRect( - x: center.x - radius, - y: center.y - radius, - width: radius * 2, - height: radius * 2 - ) - - context.fill( - Path(ellipseIn: rect), - with: .color(Color.accentColor.opacity(node.isUserNode ? 1.0 : 0.7)) - ) - } - - // Draw traveling message - let progress = (time.truncatingRemainder(dividingBy: messageCycleDuration)) / messageCycleDuration - let segmentCount = messagePath.count - 1 - let totalProgress = progress * Double(segmentCount) - let segmentIndex = min(Int(totalProgress), segmentCount - 1) - let segmentProgress = totalProgress - Double(segmentIndex) - - if segmentIndex < segmentCount { - let fromNode = nodes[messagePath[segmentIndex]] - let toNode = nodes[messagePath[segmentIndex + 1]] - - let fromPoint = CGPoint( - x: fromNode.position.x * size.width, - y: fromNode.position.y * size.height - ) - let toPoint = CGPoint( - x: toNode.position.x * size.width, - y: toNode.position.y * size.height - ) - - let messageX = fromPoint.x + (toPoint.x - fromPoint.x) * segmentProgress - let messageY = fromPoint.y + (toPoint.y - fromPoint.y) * segmentProgress - - let messageRect = CGRect( - x: messageX - messageRadius, - y: messageY - messageRadius, - width: messageRadius * 2, - height: messageRadius * 2 - ) - - context.fill( - Path(ellipseIn: messageRect), - with: .color(Color.accentColor) - ) - } + // Draw traveling message + let progress = (time.truncatingRemainder(dividingBy: messageCycleDuration)) / messageCycleDuration + let segmentCount = messagePath.count - 1 + let totalProgress = progress * Double(segmentCount) + let segmentIndex = min(Int(totalProgress), segmentCount - 1) + let segmentProgress = totalProgress - Double(segmentIndex) + + if segmentIndex < segmentCount { + let fromNode = nodes[messagePath[segmentIndex]] + let toNode = nodes[messagePath[segmentIndex + 1]] + + let fromPoint = CGPoint( + x: fromNode.position.x * size.width, + y: fromNode.position.y * size.height + ) + let toPoint = CGPoint( + x: toNode.position.x * size.width, + y: toNode.position.y * size.height + ) + + let messageX = fromPoint.x + (toPoint.x - fromPoint.x) * segmentProgress + let messageY = fromPoint.y + (toPoint.y - fromPoint.y) * segmentProgress + + let messageRect = CGRect( + x: messageX - messageRadius, + y: messageY - messageRadius, + width: messageRadius * 2, + height: messageRadius * 2 + ) + + context.fill( + Path(ellipseIn: messageRect), + with: .color(Color.accentColor) + ) } + } } #Preview { - MeshAnimationView() - .padding() - .background(.black.opacity(0.1)) + MeshAnimationView() + .padding() + .background(.black.opacity(0.1)) } diff --git a/MC1/Views/Onboarding/NoDeviceSheet.swift b/MC1/Views/Onboarding/NoDeviceSheet.swift index d0afea45..497a00a4 100644 --- a/MC1/Views/Onboarding/NoDeviceSheet.swift +++ b/MC1/Views/Onboarding/NoDeviceSheet.swift @@ -4,58 +4,58 @@ import SwiftUI /// Confirms exiting onboarding into the empty main app — does NOT unlock demo /// mode and does NOT route through `.region`/`.preset`. struct NoDeviceSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @State private var dismissTrigger = false + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @State private var dismissTrigger = false - var body: some View { - VStack(spacing: OnboardingMetrics.largeSpacing) { - Text(L10n.Onboarding.NoDevice.Sheet.title) - .font(.title2) - .bold() - .accessibilityHeading(.h1) - .padding(.top, OnboardingMetrics.sheetTopPadding) + var body: some View { + VStack(spacing: OnboardingMetrics.largeSpacing) { + Text(L10n.Onboarding.NoDevice.Sheet.title) + .font(.title2) + .bold() + .accessibilityHeading(.h1) + .padding(.top, OnboardingMetrics.sheetTopPadding) - Text(L10n.Onboarding.NoDevice.Sheet.body) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) + Text(L10n.Onboarding.NoDevice.Sheet.body) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) - Spacer() + Spacer() - VStack(spacing: OnboardingMetrics.mediumSpacing) { - Button { - dismissTrigger.toggle() - appState.completeOnboarding() - dismiss() - } label: { - Text(L10n.Onboarding.NoDevice.Sheet.confirm) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .frame(minHeight: OnboardingMetrics.minHitTarget) + VStack(spacing: OnboardingMetrics.mediumSpacing) { + Button { + dismissTrigger.toggle() + appState.completeOnboarding() + dismiss() + } label: { + Text(L10n.Onboarding.NoDevice.Sheet.confirm) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .frame(minHeight: OnboardingMetrics.minHitTarget) - Button(L10n.Onboarding.NoDevice.Sheet.cancel) { - dismiss() - } - .buttonStyle(.bordered) - .tint(.secondary) - .frame(minHeight: OnboardingMetrics.minHitTarget) - } - .padding(.horizontal) - .padding(.bottom, OnboardingMetrics.largeSpacing) + Button(L10n.Onboarding.NoDevice.Sheet.cancel) { + dismiss() } - .sensoryFeedback(.selection, trigger: dismissTrigger) - .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium]) - .presentationDragIndicator(.visible) + .buttonStyle(.bordered) + .tint(.secondary) + .frame(minHeight: OnboardingMetrics.minHitTarget) + } + .padding(.horizontal) + .padding(.bottom, OnboardingMetrics.largeSpacing) } + .sensoryFeedback(.selection, trigger: dismissTrigger) + .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium]) + .presentationDragIndicator(.visible) + } } #Preview { - Color.clear.sheet(isPresented: .constant(true)) { NoDeviceSheet() } - .environment(\.appState, AppState()) + Color.clear.sheet(isPresented: .constant(true)) { NoDeviceSheet() } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/OnboardingMetrics.swift b/MC1/Views/Onboarding/OnboardingMetrics.swift index 7a94856a..d42e5ac0 100644 --- a/MC1/Views/Onboarding/OnboardingMetrics.swift +++ b/MC1/Views/Onboarding/OnboardingMetrics.swift @@ -3,16 +3,16 @@ import Foundation /// Named layout constants for onboarding screens. Avoids bare numeric literals /// scattered through `WelcomeView`, `PermissionsView`, `DeviceScanView`, etc. enum OnboardingMetrics { - static let heroSize: CGFloat = 130 - static let iconSize: CGFloat = 60 - static let cardCornerRadius: CGFloat = 12 - static let compactSpacing: CGFloat = 4 - static let titleStackSpacing: CGFloat = 8 - static let mediumSpacing: CGFloat = 12 - static let cardSpacing: CGFloat = 16 - static let contentPadding: CGFloat = 20 - static let largeSpacing: CGFloat = 24 - static let sheetTopPadding: CGFloat = 32 - static let minHitTarget: CGFloat = 44 - static let headerTopPadding: CGFloat = 40 + static let heroSize: CGFloat = 130 + static let iconSize: CGFloat = 60 + static let cardCornerRadius: CGFloat = 12 + static let compactSpacing: CGFloat = 4 + static let titleStackSpacing: CGFloat = 8 + static let mediumSpacing: CGFloat = 12 + static let cardSpacing: CGFloat = 16 + static let contentPadding: CGFloat = 20 + static let largeSpacing: CGFloat = 24 + static let sheetTopPadding: CGFloat = 32 + static let minHitTarget: CGFloat = 44 + static let headerTopPadding: CGFloat = 40 } diff --git a/MC1/Views/Onboarding/PermissionCard.swift b/MC1/Views/Onboarding/PermissionCard.swift index 1f4e6530..3ea7b30e 100644 --- a/MC1/Views/Onboarding/PermissionCard.swift +++ b/MC1/Views/Onboarding/PermissionCard.swift @@ -1,80 +1,80 @@ import SwiftUI struct PermissionCard: View { - @Environment(\.openURL) private var openURL + @Environment(\.openURL) private var openURL - let icon: String - let title: String - let description: String - let isGranted: Bool - let isDenied: Bool - var isOptional: Bool = false - let action: () -> Void + let icon: String + let title: String + let description: String + let isGranted: Bool + let isDenied: Bool + var isOptional: Bool = false + let action: () -> Void - var body: some View { - HStack(spacing: 16) { - // Icon - Image(systemName: icon) - .font(.title2) - .foregroundStyle(iconColor) - .frame(width: 44, height: 44) - .background(iconColor.opacity(0.1), in: .circle) + var body: some View { + HStack(spacing: 16) { + // Icon + Image(systemName: icon) + .font(.title2) + .foregroundStyle(iconColor) + .frame(width: 44, height: 44) + .background(iconColor.opacity(0.1), in: .circle) - // Text - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(title) - .font(.headline) + // Text + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title) + .font(.headline) - if isOptional { - Text(L10n.Onboarding.Permissions.optional) - .font(.caption) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.secondary.opacity(0.2), in: .capsule) - .foregroundStyle(.secondary) - } - } + if isOptional { + Text(L10n.Onboarding.Permissions.optional) + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.secondary.opacity(0.2), in: .capsule) + .foregroundStyle(.secondary) + } + } - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - } + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } - Spacer() + Spacer() - // Status/Action - if isGranted { - Image(systemName: "checkmark.circle.fill") - .font(.title2) - .foregroundStyle(.green) - } else if isDenied { - Button(L10n.Onboarding.Permissions.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } - .buttonStyle(.bordered) - .controlSize(.small) - } else { - Button(L10n.Onboarding.Permissions.request) { - action() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } + // Status/Action + if isGranted { + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundStyle(.green) + } else if isDenied { + Button(L10n.Onboarding.Permissions.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + Button(L10n.Onboarding.Permissions.request) { + action() } - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } } + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + } - private var iconColor: Color { - if isGranted { - return .green - } else if isDenied { - return .orange - } else { - return .accentColor - } + private var iconColor: Color { + if isGranted { + .green + } else if isDenied { + .orange + } else { + .accentColor } + } } diff --git a/MC1/Views/Onboarding/PermissionsCoordinator.swift b/MC1/Views/Onboarding/PermissionsCoordinator.swift index d207934b..c0d74d08 100644 --- a/MC1/Views/Onboarding/PermissionsCoordinator.swift +++ b/MC1/Views/Onboarding/PermissionsCoordinator.swift @@ -6,61 +6,61 @@ import UserNotifications @Observable @MainActor final class PermissionsCoordinator: NSObject, CLLocationManagerDelegate { - var locationAuthorization: CLAuthorizationStatus = .notDetermined - var notificationAuthorization: UNAuthorizationStatus = .notDetermined + var locationAuthorization: CLAuthorizationStatus = .notDetermined + var notificationAuthorization: UNAuthorizationStatus = .notDetermined - private var locationManager: CLLocationManager? + private var locationManager: CLLocationManager? - override init() { - super.init() - // Create location manager early to check current authorization - locationManager = CLLocationManager() - locationManager?.delegate = self - locationAuthorization = locationManager?.authorizationStatus ?? .notDetermined + override init() { + super.init() + // Create location manager early to check current authorization + locationManager = CLLocationManager() + locationManager?.delegate = self + locationAuthorization = locationManager?.authorizationStatus ?? .notDetermined - // Check notification authorization - Task { - await checkNotificationAuthorization() - } + // Check notification authorization + Task { + await checkNotificationAuthorization() } + } - func requestLocation() { - locationManager?.requestWhenInUseAuthorization() - } + func requestLocation() { + locationManager?.requestWhenInUseAuthorization() + } - func requestNotifications() { - Task { - do { - let granted = try await UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .sound, .badge] - ) - notificationAuthorization = granted ? .authorized : .denied - } catch { - notificationAuthorization = .denied - } - } + func requestNotifications() { + Task { + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound, .badge] + ) + notificationAuthorization = granted ? .authorized : .denied + } catch { + notificationAuthorization = .denied + } } + } - func checkPermissions() { - if let lm = locationManager { - locationAuthorization = lm.authorizationStatus - } - Task { - await checkNotificationAuthorization() - } + func checkPermissions() { + if let lm = locationManager { + locationAuthorization = lm.authorizationStatus } - - private func checkNotificationAuthorization() async { - let settings = await UNUserNotificationCenter.current().notificationSettings() - notificationAuthorization = settings.authorizationStatus + Task { + await checkNotificationAuthorization() } + } + + private func checkNotificationAuthorization() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + notificationAuthorization = settings.authorizationStatus + } - // MARK: - CLLocationManagerDelegate + // MARK: - CLLocationManagerDelegate - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - Task { @MainActor in - self.locationAuthorization = status - } + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + self.locationAuthorization = status } + } } diff --git a/MC1/Views/Onboarding/PermissionsView.swift b/MC1/Views/Onboarding/PermissionsView.swift index 6a2c1e09..44b8699e 100644 --- a/MC1/Views/Onboarding/PermissionsView.swift +++ b/MC1/Views/Onboarding/PermissionsView.swift @@ -1,100 +1,100 @@ -import SwiftUI import CoreLocation +import SwiftUI struct PermissionsView: View { - @Environment(\.appState) private var appState - @Environment(\.scenePhase) private var scenePhase - @State private var coordinator = PermissionsCoordinator() - @State private var permissionGrantTrigger = false + @Environment(\.appState) private var appState + @Environment(\.scenePhase) private var scenePhase + @State private var coordinator = PermissionsCoordinator() + @State private var permissionGrantTrigger = false - var body: some View { - VStack(spacing: OnboardingMetrics.sheetTopPadding) { - VStack(spacing: OnboardingMetrics.mediumSpacing) { - Image(systemName: "checkmark.shield.fill") - .font(.system(size: OnboardingMetrics.iconSize)) - .foregroundStyle(.tint) + var body: some View { + VStack(spacing: OnboardingMetrics.sheetTopPadding) { + VStack(spacing: OnboardingMetrics.mediumSpacing) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: OnboardingMetrics.iconSize)) + .foregroundStyle(.tint) - Text(L10n.Onboarding.Permissions.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) + Text(L10n.Onboarding.Permissions.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) - Text(L10n.Onboarding.Permissions.subtitle) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - .padding(.top, OnboardingMetrics.headerTopPadding) + Text(L10n.Onboarding.Permissions.subtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding(.top, OnboardingMetrics.headerTopPadding) - GeometryReader { proxy in - ScrollView { - VStack(spacing: 0) { - Spacer(minLength: 0) + GeometryReader { proxy in + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: 0) - LiquidGlassContainer(spacing: OnboardingMetrics.contentPadding) { - VStack(spacing: OnboardingMetrics.cardSpacing) { - PermissionCard( - icon: "bell.fill", - title: L10n.Onboarding.Permissions.Notifications.title, - description: L10n.Onboarding.Permissions.Notifications.description, - isGranted: coordinator.notificationAuthorization == .authorized, - isDenied: coordinator.notificationAuthorization == .denied, - action: coordinator.requestNotifications - ) + LiquidGlassContainer(spacing: OnboardingMetrics.contentPadding) { + VStack(spacing: OnboardingMetrics.cardSpacing) { + PermissionCard( + icon: "bell.fill", + title: L10n.Onboarding.Permissions.Notifications.title, + description: L10n.Onboarding.Permissions.Notifications.description, + isGranted: coordinator.notificationAuthorization == .authorized, + isDenied: coordinator.notificationAuthorization == .denied, + action: coordinator.requestNotifications + ) - PermissionCard( - icon: "location.fill", - title: L10n.Onboarding.Permissions.Location.title, - description: L10n.Onboarding.Permissions.Location.description, - isGranted: coordinator.locationAuthorization == .authorizedWhenInUse - || coordinator.locationAuthorization == .authorizedAlways, - isDenied: coordinator.locationAuthorization == .denied, - action: coordinator.requestLocation - ) - } - } - .padding(.horizontal) - - Spacer(minLength: 0) - } - .frame(minHeight: proxy.size.height) - } - .scrollBounceBehavior(.basedOnSize) + PermissionCard( + icon: "location.fill", + title: L10n.Onboarding.Permissions.Location.title, + description: L10n.Onboarding.Permissions.Location.description, + isGranted: coordinator.locationAuthorization == .authorizedWhenInUse + || coordinator.locationAuthorization == .authorizedAlways, + isDenied: coordinator.locationAuthorization == .denied, + action: coordinator.requestLocation + ) + } } - - Button { - appState.onboarding.onboardingPath.append(.pair) - } label: { - Text(L10n.Onboarding.Permissions.continue) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() .padding(.horizontal) - .padding(.bottom) - } - .sensoryFeedback(.success, trigger: permissionGrantTrigger) - .onChange(of: coordinator.locationAuthorization) { _, new in - if new == .authorizedWhenInUse || new == .authorizedAlways { - permissionGrantTrigger.toggle() - } - } - .onChange(of: coordinator.notificationAuthorization) { _, new in - if new == .authorized { - permissionGrantTrigger.toggle() - } - } - .onChange(of: scenePhase) { - if scenePhase == .active { - coordinator.checkPermissions() - } + + Spacer(minLength: 0) + } + .frame(minHeight: proxy.size.height) } + .scrollBounceBehavior(.basedOnSize) + } + + Button { + appState.onboarding.onboardingPath.append(.pair) + } label: { + Text(L10n.Onboarding.Permissions.continue) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .padding(.horizontal) + .padding(.bottom) + } + .sensoryFeedback(.success, trigger: permissionGrantTrigger) + .onChange(of: coordinator.locationAuthorization) { _, new in + if new == .authorizedWhenInUse || new == .authorizedAlways { + permissionGrantTrigger.toggle() + } + } + .onChange(of: coordinator.notificationAuthorization) { _, new in + if new == .authorized { + permissionGrantTrigger.toggle() + } + } + .onChange(of: scenePhase) { + if scenePhase == .active { + coordinator.checkPermissions() + } } + } } #Preview { - PermissionsView() - .environment(\.appState, AppState()) + PermissionsView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/PresetStepView.swift b/MC1/Views/Onboarding/PresetStepView.swift index 93dfb649..c8063842 100644 --- a/MC1/Views/Onboarding/PresetStepView.swift +++ b/MC1/Views/Onboarding/PresetStepView.swift @@ -1,253 +1,254 @@ -import SwiftUI import MC1Services +import SwiftUI /// Onboarding step 5. Lands on the region's recommended preset when one /// exists; falls back to locale-sorted alternatives when `regionSelection` is nil. struct PresetStepView: View { - @Environment(\.appState) private var appState - - @State private var selectedID: String? - @State private var isApplying = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var commitTrigger = false - @State private var forceShowPicker = false - - private var region: RegionSelection? { appState.regionSelection } - - private var recommended: RadioPreset? { - guard let region else { return nil } - return RadioPresets.recommended(for: region) + @Environment(\.appState) private var appState + + @State private var selectedID: String? + @State private var isApplying = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var commitTrigger = false + @State private var forceShowPicker = false + + private var region: RegionSelection? { + appState.regionSelection + } + + private var recommended: RadioPreset? { + guard let region else { return nil } + return RadioPresets.recommended(for: region) + } + + private var alternatives: [RadioPreset] { + let base: [RadioPreset] = if let region, !RadioPresets.presets(for: region).isEmpty { + RadioPresets.presets(for: region) + } else { + RadioPresets.presetsForLocale() } - - private var alternatives: [RadioPreset] { - let base: [RadioPreset] - if let region, !RadioPresets.presets(for: region).isEmpty { - base = RadioPresets.presets(for: region) - } else { - base = RadioPresets.presetsForLocale() - } - return base - .filter { RadioPresets.isSelectable($0, in: region) } - .sorted { $0.name < $1.name } + return base + .filter { RadioPresets.isSelectable($0, in: region) } + .sorted { $0.name < $1.name } + } + + private var visiblePresets: [RadioPreset] { + var result: [RadioPreset] = [] + if let recommended { + result.append(recommended) } - - private var visiblePresets: [RadioPreset] { - var result: [RadioPreset] = [] - if let recommended { - result.append(recommended) - } - result.append(contentsOf: alternatives.filter { $0.id != recommended?.id }) - return result + result.append(contentsOf: alternatives.filter { $0.id != recommended?.id }) + return result + } + + private var currentDevicePreset: RadioPreset? { + guard let device = appState.connectedDevice else { return nil } + return RadioPresets.matchingPreset( + frequencyKHz: device.frequency, + bandwidthKHz: device.bandwidth, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + } + + private var alreadyConfigured: Bool { + guard !forceShowPicker, let recommended, let currentDevicePreset else { return false } + return recommended.id == currentDevicePreset.id + } + + private var canApply: Bool { + appState.services?.settingsService != nil + } + + var body: some View { + Group { + if alreadyConfigured, let recommended { + alreadyConfiguredState(preset: recommended) + } else { + pickerState + } } - - private var currentDevicePreset: RadioPreset? { - guard let device = appState.connectedDevice else { return nil } - return RadioPresets.matchingPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - } - - private var alreadyConfigured: Bool { - guard !forceShowPicker, let recommended, let currentDevicePreset else { return false } - return recommended.id == currentDevicePreset.id - } - - private var canApply: Bool { - appState.services?.settingsService != nil - } - - var body: some View { - Group { - if alreadyConfigured, let recommended { - alreadyConfiguredState(preset: recommended) - } else { - pickerState - } + .sensoryFeedback(.success, trigger: commitTrigger) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .onAppear { selectedID = recommended?.id ?? alternatives.first?.id } + } + + private func alreadyConfiguredState(preset: RadioPreset) -> some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.system(size: OnboardingMetrics.iconSize)) + .foregroundStyle(.tint) + Text(L10n.Onboarding.Preset.AlreadyConfigured.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + Text(L10n.Onboarding.Preset.AlreadyConfigured.subtitle( + preset.name, + region.map { RegionalAreas.displayName(for: $0) } ?? "" + )) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Spacer() + + VStack(spacing: OnboardingMetrics.mediumSpacing) { + Button { + commitTrigger.toggle() + appState.completeOnboarding() + } label: { + Text(L10n.Onboarding.Preset.AlreadyConfigured.done) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() } - .sensoryFeedback(.success, trigger: commitTrigger) - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .onAppear { selectedID = recommended?.id ?? alternatives.first?.id } - } + .liquidGlassProminentButtonStyle() - private func alreadyConfiguredState(preset: RadioPreset) -> some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - Spacer() - Image(systemName: "checkmark.circle.fill") - .font(.system(size: OnboardingMetrics.iconSize)) - .foregroundStyle(.tint) - Text(L10n.Onboarding.Preset.AlreadyConfigured.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - Text(L10n.Onboarding.Preset.AlreadyConfigured.subtitle( - preset.name, - region.map { RegionalAreas.displayName(for: $0) } ?? "" - )) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Spacer() - - VStack(spacing: OnboardingMetrics.mediumSpacing) { - Button { - commitTrigger.toggle() - appState.completeOnboarding() - } label: { - Text(L10n.Onboarding.Preset.AlreadyConfigured.done) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - - Button(L10n.Onboarding.Preset.AlreadyConfigured.choose) { - forceShowPicker = true - } - .buttonStyle(.bordered) - .tint(.accentColor) - .frame(minHeight: OnboardingMetrics.minHitTarget) - } - .padding(.horizontal) - .padding(.bottom) + Button(L10n.Onboarding.Preset.AlreadyConfigured.choose) { + forceShowPicker = true } + .buttonStyle(.bordered) + .tint(.accentColor) + .frame(minHeight: OnboardingMetrics.minHitTarget) + } + .padding(.horizontal) + .padding(.bottom) } - - private var pickerState: some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - VStack(spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Preset.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - if let region { - Text(L10n.Onboarding.Preset.Subtitle.recommended(RegionalAreas.displayName(for: region))) - .font(.body) - .foregroundStyle(.secondary) - } else { - Text(L10n.Onboarding.Preset.Subtitle.locale) - .font(.body) - .foregroundStyle(.secondary) - } - } - .padding(.top, OnboardingMetrics.headerTopPadding) - - ScrollView { - VStack(spacing: OnboardingMetrics.mediumSpacing) { - ForEach(visiblePresets) { preset in - rowCard(preset) - } - if visiblePresets.count > 1 { - Text( - (try? AttributedString(markdown: L10n.Onboarding.Preset.discordHelp)) - ?? AttributedString(L10n.Onboarding.Preset.discordHelp) - ) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.top, OnboardingMetrics.mediumSpacing) - } - } - .padding(.horizontal) - } - - Spacer() - - Button { - if let id = selectedID { - apply(id: id) - } - } label: { - Text(applyCTAText) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .disabled(isApplying || selectedID == nil || !canApply) - .padding(.horizontal) - .padding(.bottom) + } + + private var pickerState: some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + VStack(spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Preset.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + if let region { + Text(L10n.Onboarding.Preset.Subtitle.recommended(RegionalAreas.displayName(for: region))) + .font(.body) + .foregroundStyle(.secondary) + } else { + Text(L10n.Onboarding.Preset.Subtitle.locale) + .font(.body) + .foregroundStyle(.secondary) } - } - - private var applyCTAText: String { - guard let preset = alternatives.first(where: { $0.id == selectedID }) ?? recommended else { - return L10n.Onboarding.Preset.continue + } + .padding(.top, OnboardingMetrics.headerTopPadding) + + ScrollView { + VStack(spacing: OnboardingMetrics.mediumSpacing) { + ForEach(visiblePresets) { preset in + rowCard(preset) + } + if visiblePresets.count > 1 { + Text( + (try? AttributedString(markdown: L10n.Onboarding.Preset.discordHelp)) + ?? AttributedString(L10n.Onboarding.Preset.discordHelp) + ) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.top, OnboardingMetrics.mediumSpacing) + } } - return L10n.Onboarding.Preset.use(preset.name) - } + .padding(.horizontal) + } - private func rowCard(_ preset: RadioPreset) -> some View { - Button { - selectedID = preset.id - } label: { - HStack { - VStack(alignment: .leading, spacing: OnboardingMetrics.compactSpacing) { - Text(preset.name) - .font(.body) - Text("\(preset.frequencyMHz, format: .number.precision(.fractionLength(3)).locale(.posix)) MHz") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if selectedID == preset.id { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - .padding() - .frame(maxWidth: .infinity, minHeight: OnboardingMetrics.minHitTarget) - .contentShape(.rect) + Spacer() + + Button { + if let id = selectedID { + apply(id: id) } - .buttonStyle(.plain) - .accessibilityElement(children: .combine) - .accessibilityHint(L10n.Onboarding.Preset.Row.accessibilityHint) + } label: { + Text(applyCTAText) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .disabled(isApplying || selectedID == nil || !canApply) + .padding(.horizontal) + .padding(.bottom) } + } - private func apply(id: String) { - guard let preset = alternatives.first(where: { $0.id == id }) ?? recommended else { return } - // Mock device has no radio to configure. - if appState.connectedDevice?.id == MockDataProvider.simulatorDeviceID { - commitTrigger.toggle() - appState.completeOnboarding() - return - } - guard let settingsService = appState.services?.settingsService else { - // Defensive: CTA is disabled when services is nil, but if reconnect ends mid-tap - // we surface the error rather than swallowing it silently. - errorMessage = L10n.Onboarding.Preset.Error.notConnected - return + private var applyCTAText: String { + guard let preset = alternatives.first(where: { $0.id == selectedID }) ?? recommended else { + return L10n.Onboarding.Preset.continue + } + return L10n.Onboarding.Preset.use(preset.name) + } + + private func rowCard(_ preset: RadioPreset) -> some View { + Button { + selectedID = preset.id + } label: { + HStack { + VStack(alignment: .leading, spacing: OnboardingMetrics.compactSpacing) { + Text(preset.name) + .font(.body) + Text("\(preset.frequencyMHz, format: .number.precision(.fractionLength(3)).locale(.posix)) MHz") + .font(.caption) + .foregroundStyle(.secondary) } - isApplying = true - Task { - do { - _ = try await settingsService.applyRadioPresetVerified(preset) - retryAlert.reset() - commitTrigger.toggle() - appState.completeOnboarding() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { apply(id: id) }, - onMaxRetriesExceeded: { - errorMessage = L10n.Settings.Alert.Retry.fallbackMessage - } - ) - } catch { - errorMessage = error.userFacingMessage - } - isApplying = false + Spacer() + if selectedID == preset.id { + Image(systemName: "checkmark") + .foregroundStyle(.tint) } + } + .padding() + .frame(maxWidth: .infinity, minHeight: OnboardingMetrics.minHitTarget) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityHint(L10n.Onboarding.Preset.Row.accessibilityHint) + } + + private func apply(id: String) { + guard let preset = alternatives.first(where: { $0.id == id }) ?? recommended else { return } + // Mock device has no radio to configure. + if appState.connectedDevice?.id == MockDataProvider.simulatorDeviceID { + commitTrigger.toggle() + appState.completeOnboarding() + return + } + guard let settingsService = appState.services?.settingsService else { + // Defensive: CTA is disabled when services is nil, but if reconnect ends mid-tap + // we surface the error rather than swallowing it silently. + errorMessage = L10n.Onboarding.Preset.Error.notConnected + return + } + isApplying = true + Task { + do { + _ = try await settingsService.applyRadioPresetVerified(preset) + retryAlert.reset() + commitTrigger.toggle() + appState.completeOnboarding() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { apply(id: id) }, + onMaxRetriesExceeded: { + errorMessage = L10n.Settings.Alert.Retry.fallbackMessage + } + ) + } catch { + errorMessage = error.userFacingMessage + } + isApplying = false } + } } #Preview { - PresetStepView() - .environment(\.appState, AppState()) + PresetStepView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/PulsingAntenna.swift b/MC1/Views/Onboarding/PulsingAntenna.swift index ef110e0c..2cfba623 100644 --- a/MC1/Views/Onboarding/PulsingAntenna.swift +++ b/MC1/Views/Onboarding/PulsingAntenna.swift @@ -3,14 +3,14 @@ import SwiftUI /// Hero icon for the Pair onboarding step. Pulses by default; honors /// `accessibilityReduceMotion` by rendering statically. struct PulsingAntenna: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceMotion) private var reduceMotion - var body: some View { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: OnboardingMetrics.heroSize / 2)) - .foregroundStyle(.tint) - .frame(height: OnboardingMetrics.heroSize) - .symbolEffect(.pulse, isActive: !reduceMotion) - .accessibilityHidden(true) - } + var body: some View { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: OnboardingMetrics.heroSize / 2)) + .foregroundStyle(.tint) + .frame(height: OnboardingMetrics.heroSize) + .symbolEffect(.pulse, isActive: !reduceMotion) + .accessibilityHidden(true) + } } diff --git a/MC1/Views/Onboarding/RegionPickerView.swift b/MC1/Views/Onboarding/RegionPickerView.swift index 163c5f4f..118fbe1a 100644 --- a/MC1/Views/Onboarding/RegionPickerView.swift +++ b/MC1/Views/Onboarding/RegionPickerView.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Country + state/province picker used in onboarding step 4 and Settings → Region. /// Hides the State/Province row for countries with no sub-region presets. @@ -9,144 +9,149 @@ import MC1Services /// own "Continue" CTA below the picker for navigation; Settings relies on the /// system back chevron and the inline write. struct RegionPickerView: View { - @Binding var selection: RegionSelection? - - @State private var showingCountrySheet = false - @State private var showingSubdivisionSheet = false - - private static let emptyValue = "—" - - private var country: String? { selection?.countryCode } - private var subdivision: String? { selection?.administrativeAreaCode } - - private var availableSubdivisions: [RegionalAreas.Subdivision] { - RegionalAreas.subdivisions(for: country) - } - - var body: some View { - Form { - Section { - Button { - showingCountrySheet = true - } label: { - LabeledContent(L10n.Onboarding.Region.country) { - Text(countryDisplay) - } - } - // Hide single-entry pickers — a "pick" with one option is dead-end UX. - if availableSubdivisions.count > 1 { - Button { - showingSubdivisionSheet = true - } label: { - LabeledContent(L10n.Onboarding.Region.administrativeArea) { - Text(subdivisionDisplay) - } - } - } - } - } - .sheet(isPresented: $showingCountrySheet) { - CountryPickerSheet(selectedCountry: country) { newCountry in - selectCountry(newCountry) - } + @Binding var selection: RegionSelection? + + @State private var showingCountrySheet = false + @State private var showingSubdivisionSheet = false + + private static let emptyValue = "—" + + private var country: String? { + selection?.countryCode + } + + private var subdivision: String? { + selection?.administrativeAreaCode + } + + private var availableSubdivisions: [RegionalAreas.Subdivision] { + RegionalAreas.subdivisions(for: country) + } + + var body: some View { + Form { + Section { + Button { + showingCountrySheet = true + } label: { + LabeledContent(L10n.Onboarding.Region.country) { + Text(countryDisplay) + } } - .sheet(isPresented: $showingSubdivisionSheet) { - SubdivisionPickerSheet(country: country, selectedSubdivision: subdivision) { newSubdivision in - selectSubdivision(newSubdivision) + // Hide single-entry pickers — a "pick" with one option is dead-end UX. + if availableSubdivisions.count > 1 { + Button { + showingSubdivisionSheet = true + } label: { + LabeledContent(L10n.Onboarding.Region.administrativeArea) { + Text(subdivisionDisplay) } + } } + } } - - private var countryDisplay: String { - guard let country else { return Self.emptyValue } - return Locale.current.localizedString(forRegionCode: country) ?? country - } - - private var subdivisionDisplay: String { - guard let subdivision else { return Self.emptyValue } - return RegionalAreas.subdivisionDisplayName(subdivision) ?? subdivision - } - - private func selectCountry(_ newCountry: String) { - // Drop subdivision when country changes so a stale id (e.g. "US-CA") - // can't ride on a new country (e.g. "CA") into the persisted selection. - selection = RegionSelection( - countryCode: newCountry, - administrativeAreaCode: nil, - countyKey: nil, - source: .manual - ) + .sheet(isPresented: $showingCountrySheet) { + CountryPickerSheet(selectedCountry: country) { newCountry in + selectCountry(newCountry) + } } - - private func selectSubdivision(_ newSubdivision: String?) { - guard let country else { return } - selection = RegionSelection( - countryCode: country, - administrativeAreaCode: newSubdivision, - countyKey: nil, - source: .manual - ) + .sheet(isPresented: $showingSubdivisionSheet) { + SubdivisionPickerSheet(country: country, selectedSubdivision: subdivision) { newSubdivision in + selectSubdivision(newSubdivision) + } } + } + + private var countryDisplay: String { + guard let country else { return Self.emptyValue } + return Locale.current.localizedString(forRegionCode: country) ?? country + } + + private var subdivisionDisplay: String { + guard let subdivision else { return Self.emptyValue } + return RegionalAreas.subdivisionDisplayName(subdivision) ?? subdivision + } + + private func selectCountry(_ newCountry: String) { + // Drop subdivision when country changes so a stale id (e.g. "US-CA") + // can't ride on a new country (e.g. "CA") into the persisted selection. + selection = RegionSelection( + countryCode: newCountry, + administrativeAreaCode: nil, + countyKey: nil, + source: .manual + ) + } + + private func selectSubdivision(_ newSubdivision: String?) { + guard let country else { return } + selection = RegionSelection( + countryCode: country, + administrativeAreaCode: newSubdivision, + countyKey: nil, + source: .manual + ) + } } private struct CountryPickerSheet: View { - @Environment(\.dismiss) private var dismiss - let selectedCountry: String? - let onSelect: (String) -> Void - - @State private var search = "" - - private var filtered: [RegionalAreas.Country] { - let all = RegionalAreas.countriesSortedByLocalizedName - guard !search.isEmpty else { return all } - return all.filter { $0.localizedName.localizedStandardContains(search) } - } - - var body: some View { - NavigationStack { - List(filtered) { entry in - Button { - onSelect(entry.id) - dismiss() - } label: { - HStack { - Text(entry.localizedName) - Spacer() - if entry.id == selectedCountry { Image(systemName: "checkmark") } - } - } - } - .searchable(text: $search) - .navigationTitle(L10n.Onboarding.Region.country) + @Environment(\.dismiss) private var dismiss + let selectedCountry: String? + let onSelect: (String) -> Void + + @State private var search = "" + + private var filtered: [RegionalAreas.Country] { + let all = RegionalAreas.countriesSortedByLocalizedName + guard !search.isEmpty else { return all } + return all.filter { $0.localizedName.localizedStandardContains(search) } + } + + var body: some View { + NavigationStack { + List(filtered) { entry in + Button { + onSelect(entry.id) + dismiss() + } label: { + HStack { + Text(entry.localizedName) + Spacer() + if entry.id == selectedCountry { Image(systemName: "checkmark") } + } } + } + .searchable(text: $search) + .navigationTitle(L10n.Onboarding.Region.country) } + } } private struct SubdivisionPickerSheet: View { - @Environment(\.dismiss) private var dismiss - let country: String? - let selectedSubdivision: String? - let onSelect: (String) -> Void - - private var rows: [RegionalAreas.Subdivision] { - RegionalAreas.subdivisions(for: country) - } - - var body: some View { - NavigationStack { - List(rows) { row in - Button { - onSelect(row.id) - dismiss() - } label: { - HStack { - Text(RegionalAreas.subdivisionDisplayName(row.id) ?? row.id) - Spacer() - if row.id == selectedSubdivision { Image(systemName: "checkmark") } - } - } - } - .navigationTitle(L10n.Onboarding.Region.administrativeArea) + @Environment(\.dismiss) private var dismiss + let country: String? + let selectedSubdivision: String? + let onSelect: (String) -> Void + + private var rows: [RegionalAreas.Subdivision] { + RegionalAreas.subdivisions(for: country) + } + + var body: some View { + NavigationStack { + List(rows) { row in + Button { + onSelect(row.id) + dismiss() + } label: { + HStack { + Text(RegionalAreas.subdivisionDisplayName(row.id) ?? row.id) + Spacer() + if row.id == selectedSubdivision { Image(systemName: "checkmark") } + } } + } + .navigationTitle(L10n.Onboarding.Region.administrativeArea) } + } } diff --git a/MC1/Views/Onboarding/RegionStepView.swift b/MC1/Views/Onboarding/RegionStepView.swift index 07095a22..6a500ed8 100644 --- a/MC1/Views/Onboarding/RegionStepView.swift +++ b/MC1/Views/Onboarding/RegionStepView.swift @@ -1,186 +1,185 @@ -import SwiftUI import MC1Services +import SwiftUI /// Onboarding step 4. Resolves region from location when authorized; falls /// silently to the manual picker on any failure (denied, timeout, no network). struct RegionStepView: View { - @Environment(\.appState) private var appState - - @State private var resolved: RegionSelection? - @State private var isResolving = true - @State private var showManualPicker = false - @State private var manualSelection: RegionSelection? - @State private var commitTrigger = false - @State private var resolveAttempt = 0 - @State private var pendingUserRetry = false - @State private var errorMessage: String? - - private var locationGranted: Bool { - appState.locationService.isAuthorized + @Environment(\.appState) private var appState + + @State private var resolved: RegionSelection? + @State private var isResolving = true + @State private var showManualPicker = false + @State private var manualSelection: RegionSelection? + @State private var commitTrigger = false + @State private var resolveAttempt = 0 + @State private var pendingUserRetry = false + @State private var errorMessage: String? + + private var locationGranted: Bool { + appState.locationService.isAuthorized + } + + var body: some View { + Group { + if showManualPicker || !locationGranted { + manualPickerState + } else if let resolved { + detectedState(region: resolved) + } else if isResolving { + resolvingState + } else { + manualPickerState + } } - - var body: some View { - Group { - if showManualPicker || !locationGranted { - manualPickerState - } else if let resolved { - detectedState(region: resolved) - } else if isResolving { - resolvingState - } else { - manualPickerState - } - } - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - .sensoryFeedback(.success, trigger: commitTrigger) - .errorAlert($errorMessage) - .task(id: resolveAttempt) { - guard locationGranted, !showManualPicker else { - isResolving = false - return - } - isResolving = true - resolved = await appState.regionResolver.resolve() - isResolving = false - let wasUserRetry = pendingUserRetry - pendingUserRetry = false - if resolved == nil { - if wasUserRetry { - errorMessage = L10n.Onboarding.Region.UseMyLocation.failure - } - showManualPicker = true - } - } - .onChange(of: locationGranted) { _, _ in - // Permission flipped (user granted/revoked from Settings); re-fire the resolver. - resolveAttempt += 1 + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .sensoryFeedback(.success, trigger: commitTrigger) + .errorAlert($errorMessage) + .task(id: resolveAttempt) { + guard locationGranted, !showManualPicker else { + isResolving = false + return + } + isResolving = true + resolved = await appState.regionResolver.resolve() + isResolving = false + let wasUserRetry = pendingUserRetry + pendingUserRetry = false + if resolved == nil { + if wasUserRetry { + errorMessage = L10n.Onboarding.Region.UseMyLocation.failure } + showManualPicker = true + } } - - private var resolvingState: some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - Spacer() - ProgressView().controlSize(.large) - Text(L10n.Onboarding.Region.resolving) - .font(.body) - .foregroundStyle(.secondary) - Spacer() - } + .onChange(of: locationGranted) { _, _ in + // Permission flipped (user granted/revoked from Settings); re-fire the resolver. + resolveAttempt += 1 } - - private func detectedState(region: RegionSelection) -> some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - VStack(spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Region.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - Text(L10n.Onboarding.Region.subtitle) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding(.top, OnboardingMetrics.headerTopPadding) - - Spacer() - - VStack(spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Region.Detected.tag) - .font(.caption.weight(.medium)) - .foregroundStyle(.tint) - - Text(RegionalAreas.displayName(for: region)) - .font(.title2.weight(.semibold)) - - Text(L10n.Onboarding.Region.Detected.source) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(OnboardingMetrics.contentPadding) - .frame(maxWidth: .infinity) - .liquidGlass(in: .rect(cornerRadius: OnboardingMetrics.cardCornerRadius)) - .padding(.horizontal) - .accessibilityElement(children: .combine) - - Button(L10n.Onboarding.Region.chooseAnother) { - showManualPicker = true - } - .font(.subheadline) - .buttonStyle(.bordered) - .tint(.accentColor) - .frame(minHeight: OnboardingMetrics.minHitTarget) - - Spacer() - - Button { - appState.regionSelection = region - commitTrigger.toggle() - appState.onboarding.onboardingPath.append(.preset) - } label: { - Text(L10n.Onboarding.Region.useThisRegion) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .padding(.horizontal) - .padding(.bottom) - } + } + + private var resolvingState: some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + Spacer() + ProgressView().controlSize(.large) + Text(L10n.Onboarding.Region.resolving) + .font(.body) + .foregroundStyle(.secondary) + Spacer() } - - private var manualPickerState: some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - VStack(spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Region.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - Text(L10n.Onboarding.Region.subtitle) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding(.top, OnboardingMetrics.headerTopPadding) - - RegionPickerView(selection: $manualSelection) - - if locationGranted { - Button(L10n.Onboarding.Region.useMyLocation) { - // Clear any prior resolve so the view shows the spinner while the - // re-resolve runs, instead of flashing the previous detected region. - resolved = nil - showManualPicker = false - isResolving = true - pendingUserRetry = true - resolveAttempt += 1 - } - .font(.subheadline) - .foregroundStyle(.tint) - .frame(minHeight: OnboardingMetrics.minHitTarget) - } - - Button { - guard let manualSelection else { return } - appState.regionSelection = manualSelection - commitTrigger.toggle() - appState.onboarding.onboardingPath.append(.preset) - } label: { - Text(L10n.Onboarding.Region.continue) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .disabled(manualSelection == nil) - .padding(.horizontal) - .padding(.bottom) + } + + private func detectedState(region: RegionSelection) -> some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + VStack(spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Region.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + Text(L10n.Onboarding.Region.subtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, OnboardingMetrics.headerTopPadding) + + Spacer() + + VStack(spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Region.Detected.tag) + .font(.caption.weight(.medium)) + .foregroundStyle(.tint) + + Text(RegionalAreas.displayName(for: region)) + .font(.title2.weight(.semibold)) + + Text(L10n.Onboarding.Region.Detected.source) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(OnboardingMetrics.contentPadding) + .frame(maxWidth: .infinity) + .liquidGlass(in: .rect(cornerRadius: OnboardingMetrics.cardCornerRadius)) + .padding(.horizontal) + .accessibilityElement(children: .combine) + + Button(L10n.Onboarding.Region.chooseAnother) { + showManualPicker = true + } + .font(.subheadline) + .buttonStyle(.bordered) + .tint(.accentColor) + .frame(minHeight: OnboardingMetrics.minHitTarget) + + Spacer() + + Button { + appState.regionSelection = region + commitTrigger.toggle() + appState.onboarding.onboardingPath.append(.preset) + } label: { + Text(L10n.Onboarding.Region.useThisRegion) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .padding(.horizontal) + .padding(.bottom) + } + } + + private var manualPickerState: some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + VStack(spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Region.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + Text(L10n.Onboarding.Region.subtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, OnboardingMetrics.headerTopPadding) + + RegionPickerView(selection: $manualSelection) + + if locationGranted { + Button(L10n.Onboarding.Region.useMyLocation) { + // Clear any prior resolve so the view shows the spinner while the + // re-resolve runs, instead of flashing the previous detected region. + resolved = nil + showManualPicker = false + isResolving = true + pendingUserRetry = true + resolveAttempt += 1 } + .font(.subheadline) + .foregroundStyle(.tint) + .frame(minHeight: OnboardingMetrics.minHitTarget) + } + + Button { + guard let manualSelection else { return } + appState.regionSelection = manualSelection + commitTrigger.toggle() + appState.onboarding.onboardingPath.append(.preset) + } label: { + Text(L10n.Onboarding.Region.continue) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .disabled(manualSelection == nil) + .padding(.horizontal) + .padding(.bottom) } - + } } #Preview { - RegionStepView() - .environment(\.appState, AppState()) + RegionStepView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/TroubleshootingSheet.swift b/MC1/Views/Onboarding/TroubleshootingSheet.swift index 3e233801..02efd030 100644 --- a/MC1/Views/Onboarding/TroubleshootingSheet.swift +++ b/MC1/Views/Onboarding/TroubleshootingSheet.swift @@ -1,114 +1,114 @@ -import SwiftUI import MC1Services +import SwiftUI import UIKit /// Troubleshooting sheet for when devices don't appear in the ASK picker /// Per Apple Developer Forums: Factory-reset devices won't appear until the stale /// system pairing is removed via removeAccessory() struct TroubleshootingSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.openURL) private var openURL - @State private var isClearing = false - - var body: some View { - NavigationStack { - List { - Section { - Label(L10n.Onboarding.Troubleshooting.BasicChecks.powerOn, systemImage: "power") - Label(L10n.Onboarding.Troubleshooting.BasicChecks.moveCloser, systemImage: "iphone.radiowaves.left.and.right") - Label(L10n.Onboarding.Troubleshooting.BasicChecks.notConnectedElsewhere, systemImage: "app.dashed") - Label(L10n.Onboarding.Troubleshooting.BasicChecks.restart, systemImage: "arrow.clockwise") - } header: { - Text(L10n.Onboarding.Troubleshooting.BasicChecks.header) - } - - Section { - VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Troubleshooting.FactoryReset.explanation) - .font(.subheadline) - .foregroundStyle(.secondary) + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @State private var isClearing = false - Text(L10n.Onboarding.Troubleshooting.FactoryReset.confirmationNote) - .font(.caption) - .foregroundStyle(.secondary) + var body: some View { + NavigationStack { + List { + Section { + Label(L10n.Onboarding.Troubleshooting.BasicChecks.powerOn, systemImage: "power") + Label(L10n.Onboarding.Troubleshooting.BasicChecks.moveCloser, systemImage: "iphone.radiowaves.left.and.right") + Label(L10n.Onboarding.Troubleshooting.BasicChecks.notConnectedElsewhere, systemImage: "app.dashed") + Label(L10n.Onboarding.Troubleshooting.BasicChecks.restart, systemImage: "arrow.clockwise") + } header: { + Text(L10n.Onboarding.Troubleshooting.BasicChecks.header) + } - Button { - clearStalePairings() - } label: { - HStack { - if isClearing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "trash") - } - Text(L10n.Onboarding.Troubleshooting.FactoryReset.clearPairing) - } - } - .disabled(isClearing || appState.connectionManager.pairedAccessoriesCount == 0) - } - } header: { - Text(L10n.Onboarding.Troubleshooting.FactoryReset.header) - } footer: { - if appState.connectionManager.pairedAccessoriesCount == 0 { - Text(L10n.Onboarding.Troubleshooting.FactoryReset.noPairings) - } else { - Text(L10n.Onboarding.Troubleshooting.FactoryReset.pairingsFound(appState.connectionManager.pairedAccessoriesCount)) - } - } + Section { + VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Troubleshooting.FactoryReset.explanation) + .font(.subheadline) + .foregroundStyle(.secondary) - Section { - VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Troubleshooting.SystemSettings.manageAccessories) - .font(.subheadline) - Text(L10n.Onboarding.Troubleshooting.SystemSettings.path) - .font(.subheadline) - .foregroundStyle(.secondary) - Button { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } label: { - Label( - L10n.Onboarding.Troubleshooting.SystemSettings.openSettings, - systemImage: "gear" - ) - } - } - } header: { - Text(L10n.Onboarding.Troubleshooting.SystemSettings.header) - } + Text(L10n.Onboarding.Troubleshooting.FactoryReset.confirmationNote) + .font(.caption) + .foregroundStyle(.secondary) - Section { - Text(L10n.Onboarding.Troubleshooting.StillNotAppearing.body) - .font(.subheadline) - .foregroundStyle(.secondary) - } header: { - Text(L10n.Onboarding.Troubleshooting.StillNotAppearing.header) + Button { + clearStalePairings() + } label: { + HStack { + if isClearing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "trash") } + Text(L10n.Onboarding.Troubleshooting.FactoryReset.clearPairing) + } } - .navigationTitle(L10n.Onboarding.Troubleshooting.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(L10n.Localizable.Common.done) { - dismiss() - } - } + .disabled(isClearing || appState.connectionManager.pairedAccessoriesCount == 0) + } + } header: { + Text(L10n.Onboarding.Troubleshooting.FactoryReset.header) + } footer: { + if appState.connectionManager.pairedAccessoriesCount == 0 { + Text(L10n.Onboarding.Troubleshooting.FactoryReset.noPairings) + } else { + Text(L10n.Onboarding.Troubleshooting.FactoryReset.pairingsFound(appState.connectionManager.pairedAccessoriesCount)) + } + } + + Section { + VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Troubleshooting.SystemSettings.manageAccessories) + .font(.subheadline) + Text(L10n.Onboarding.Troubleshooting.SystemSettings.path) + .font(.subheadline) + .foregroundStyle(.secondary) + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } label: { + Label( + L10n.Onboarding.Troubleshooting.SystemSettings.openSettings, + systemImage: "gear" + ) } + } + } header: { + Text(L10n.Onboarding.Troubleshooting.SystemSettings.header) + } + + Section { + Text(L10n.Onboarding.Troubleshooting.StillNotAppearing.body) + .font(.subheadline) + .foregroundStyle(.secondary) + } header: { + Text(L10n.Onboarding.Troubleshooting.StillNotAppearing.header) } + } + .navigationTitle(L10n.Onboarding.Troubleshooting.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.Localizable.Common.done) { + dismiss() + } + } + } } + } - private func clearStalePairings() { - isClearing = true + private func clearStalePairings() { + isClearing = true - Task { - defer { isClearing = false } + Task { + defer { isClearing = false } - await appState.connectionManager.clearStalePairings() + await appState.connectionManager.clearStalePairings() - dismiss() - } + dismiss() } + } } diff --git a/MC1/Views/Onboarding/WelcomeView.swift b/MC1/Views/Onboarding/WelcomeView.swift index 81a15020..a3217e17 100644 --- a/MC1/Views/Onboarding/WelcomeView.swift +++ b/MC1/Views/Onboarding/WelcomeView.swift @@ -1,47 +1,47 @@ import SwiftUI struct WelcomeView: View { - @Environment(\.appState) private var appState - - var body: some View { - VStack(spacing: OnboardingMetrics.cardSpacing * 2) { - Spacer() - - MeshAnimationView() - .frame(height: OnboardingMetrics.heroSize) - .padding(.horizontal) - - VStack(spacing: 12) { - Text(L10n.Onboarding.Welcome.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - - Text(L10n.Onboarding.Welcome.subtitle) - .font(.title3) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - Spacer() - - Button { - appState.onboarding.onboardingPath.append(.permissions) - } label: { - Text(L10n.Onboarding.Welcome.getStarted) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .padding(.horizontal) - .padding(.bottom) - } + @Environment(\.appState) private var appState + + var body: some View { + VStack(spacing: OnboardingMetrics.cardSpacing * 2) { + Spacer() + + MeshAnimationView() + .frame(height: OnboardingMetrics.heroSize) + .padding(.horizontal) + + VStack(spacing: 12) { + Text(L10n.Onboarding.Welcome.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + + Text(L10n.Onboarding.Welcome.subtitle) + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + Spacer() + + Button { + appState.onboarding.onboardingPath.append(.permissions) + } label: { + Text(L10n.Onboarding.Welcome.getStarted) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .padding(.horizontal) + .padding(.bottom) } + } } #Preview { - WelcomeView() - .environment(\.appState, AppState()) + WelcomeView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/WiFiConnectionSheet.swift b/MC1/Views/Onboarding/WiFiConnectionSheet.swift index 944727e4..b510c58a 100644 --- a/MC1/Views/Onboarding/WiFiConnectionSheet.swift +++ b/MC1/Views/Onboarding/WiFiConnectionSheet.swift @@ -1,6 +1,6 @@ +import MC1Services import Network import SwiftUI -import MC1Services // MARK: - Local Network Permission Trigger @@ -12,163 +12,162 @@ import MC1Services /// /// Based on Apple's TN3179: Understanding local network privacy. private func triggerLocalNetworkPrivacyAlert() { - let addresses = selectedLinkLocalIPv6Addresses() - for address in addresses { - let sock6 = socket(AF_INET6, SOCK_DGRAM, 0) - guard sock6 >= 0 else { return } - defer { close(sock6) } - - withUnsafePointer(to: address) { sa6 in - sa6.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - _ = connect(sock6, sa, socklen_t(sa.pointee.sa_len)) >= 0 - } - } + let addresses = selectedLinkLocalIPv6Addresses() + for address in addresses { + let sock6 = socket(AF_INET6, SOCK_DGRAM, 0) + guard sock6 >= 0 else { return } + defer { close(sock6) } + + withUnsafePointer(to: address) { sa6 in + sa6.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + _ = connect(sock6, sa, socklen_t(sa.pointee.sa_len)) >= 0 + } } + } } /// Returns a selection of IPv6 addresses to connect to. private func selectedLinkLocalIPv6Addresses() -> [sockaddr_in6] { - let r1 = (0..<8).map { _ in UInt8.random(in: 0...255) } - let r2 = (0..<8).map { _ in UInt8.random(in: 0...255) } - return Array(ipv6AddressesOfBroadcastCapableInterfaces() - .filter { isIPv6AddressLinkLocal($0) } - .map { var addr = $0; addr.sin6_port = UInt16(9).bigEndian; return addr } - .map { [setIPv6LinkLocalAddressHostPart(of: $0, to: r1), setIPv6LinkLocalAddressHostPart(of: $0, to: r2)] } - .joined()) + let r1 = (0..<8).map { _ in UInt8.random(in: 0...255) } + let r2 = (0..<8).map { _ in UInt8.random(in: 0...255) } + return Array(ipv6AddressesOfBroadcastCapableInterfaces() + .filter { isIPv6AddressLinkLocal($0) } + .map { var addr = $0; addr.sin6_port = UInt16(9).bigEndian; return addr } + .map { [setIPv6LinkLocalAddressHostPart(of: $0, to: r1), setIPv6LinkLocalAddressHostPart(of: $0, to: r2)] } + .joined()) } private func setIPv6LinkLocalAddressHostPart(of address: sockaddr_in6, to hostPart: [UInt8]) -> sockaddr_in6 { - precondition(hostPart.count == 8) - var result = address - withUnsafeMutableBytes(of: &result.sin6_addr) { buf in - buf[8...].copyBytes(from: hostPart) - } - return result + precondition(hostPart.count == 8) + var result = address + withUnsafeMutableBytes(of: &result.sin6_addr) { buf in + buf[8...].copyBytes(from: hostPart) + } + return result } private func isIPv6AddressLinkLocal(_ address: sockaddr_in6) -> Bool { - address.sin6_addr.__u6_addr.__u6_addr8.0 == 0xfe - && (address.sin6_addr.__u6_addr.__u6_addr8.1 & 0xc0) == 0x80 + address.sin6_addr.__u6_addr.__u6_addr8.0 == 0xFE + && (address.sin6_addr.__u6_addr.__u6_addr8.1 & 0xC0) == 0x80 } private func ipv6AddressesOfBroadcastCapableInterfaces() -> [sockaddr_in6] { - var addrList: UnsafeMutablePointer? - let err = getifaddrs(&addrList) - guard err == 0, let start = addrList else { return [] } - defer { freeifaddrs(start) } - return sequence(first: start, next: { $0.pointee.ifa_next }) - .compactMap { i -> sockaddr_in6? in - guard - (i.pointee.ifa_flags & UInt32(bitPattern: IFF_BROADCAST)) != 0, - let sa = i.pointee.ifa_addr, - sa.pointee.sa_family == AF_INET6, - sa.pointee.sa_len >= MemoryLayout.size - else { return nil } - return UnsafeRawPointer(sa).load(as: sockaddr_in6.self) - } + var addrList: UnsafeMutablePointer? + let err = getifaddrs(&addrList) + guard err == 0, let start = addrList else { return [] } + defer { freeifaddrs(start) } + return sequence(first: start, next: { $0.pointee.ifa_next }) + .compactMap { i -> sockaddr_in6? in + guard + (i.pointee.ifa_flags & UInt32(bitPattern: IFF_BROADCAST)) != 0, + let sa = i.pointee.ifa_addr, + sa.pointee.sa_family == AF_INET6, + sa.pointee.sa_len >= MemoryLayout.size + else { return nil } + return UnsafeRawPointer(sa).load(as: sockaddr_in6.self) + } } /// Sheet for entering WiFi connection details (IP address and port). struct WiFiConnectionSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - @State private var ipAddress = "" - @State private var port = "5000" - @State private var isConnecting = false - @State private var errorMessage: String? - - @FocusState private var focusedField: WiFiField? - - private var isValidInput: Bool { - WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) - } - - private var usesFullKeyboardInput: Bool { - horizontalSizeClass == .regular - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @State private var ipAddress = "" + @State private var port = "5000" + @State private var isConnecting = false + @State private var errorMessage: String? + + @FocusState private var focusedField: WiFiField? + + private var isValidInput: Bool { + WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) + } + + private var usesFullKeyboardInput: Bool { + horizontalSizeClass == .regular + } + + var body: some View { + NavigationStack { + Form { + WiFiAddressFields( + ipAddress: $ipAddress, + port: $port, + focusedField: $focusedField, + sectionHeader: L10n.Onboarding.WifiConnection.ConnectionDetails.header, + sectionFooter: L10n.Onboarding.WifiConnection.ConnectionDetails.footer, + onPortSubmit: { connect() } + ) + + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } - var body: some View { - NavigationStack { - Form { - WiFiAddressFields( - ipAddress: $ipAddress, - port: $port, - focusedField: $focusedField, - sectionHeader: L10n.Onboarding.WifiConnection.ConnectionDetails.header, - sectionFooter: L10n.Onboarding.WifiConnection.ConnectionDetails.footer, - onPortSubmit: { connect() } - ) - - if let errorMessage { - Section { - Label(errorMessage, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - } - - Section { - Button { - connect() - } label: { - HStack { - Spacer() - if isConnecting { - ProgressView() - .controlSize(.small) - Text(L10n.Onboarding.WifiConnection.connecting) - } else { - Text(L10n.Onboarding.WifiConnection.connect) - } - Spacer() - } - } - .disabled(!isValidInput || isConnecting) - } - } - .navigationTitle(L10n.Onboarding.WifiConnection.title) - .navigationBarTitleDisplayMode(.inline) - .wifiSheetToolbar(focusedField: $focusedField, isProcessing: isConnecting) - .interactiveDismissDisabled(isConnecting) - .onAppear { - if !usesFullKeyboardInput { - focusedField = .ipAddress - } - triggerLocalNetworkPrivacyAlert() + Section { + Button { + connect() + } label: { + HStack { + Spacer() + if isConnecting { + ProgressView() + .controlSize(.small) + Text(L10n.Onboarding.WifiConnection.connecting) + } else { + Text(L10n.Onboarding.WifiConnection.connect) + } + Spacer() } + } + .disabled(!isValidInput || isConnecting) + } + } + .navigationTitle(L10n.Onboarding.WifiConnection.title) + .navigationBarTitleDisplayMode(.inline) + .wifiSheetToolbar(focusedField: $focusedField, isProcessing: isConnecting) + .interactiveDismissDisabled(isConnecting) + .onAppear { + if !usesFullKeyboardInput { + focusedField = .ipAddress } - .presentationSizing(.page) + triggerLocalNetworkPrivacyAlert() + } } + .presentationSizing(.page) + } - private func connect() { - focusedField = nil + private func connect() { + focusedField = nil - guard let portNumber = UInt16(port) else { - errorMessage = L10n.Onboarding.WifiConnection.Error.invalidPort - return - } - - isConnecting = true - errorMessage = nil - - Task { - do { - try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) - await appState.wireServicesIfConnected() - dismiss() - // Navigate directly to radio settings - appState.onboarding.onboardingPath.append(.region) - } catch { - errorMessage = error.userFacingMessage - isConnecting = false - } - } + guard let portNumber = UInt16(port) else { + errorMessage = L10n.Onboarding.WifiConnection.Error.invalidPort + return } + isConnecting = true + errorMessage = nil + + Task { + do { + try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) + await appState.wireServicesIfConnected() + dismiss() + // Navigate directly to radio settings + appState.onboarding.onboardingPath.append(.region) + } catch { + errorMessage = error.userFacingMessage + isConnecting = false + } + } + } } #Preview { - WiFiConnectionSheet() - .environment(\.appState, AppState()) + WiFiConnectionSheet() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/PathEditing/AddHopPickerPresentation.swift b/MC1/Views/PathEditing/AddHopPickerPresentation.swift index 10de00e7..d43283db 100644 --- a/MC1/Views/PathEditing/AddHopPickerPresentation.swift +++ b/MC1/Views/PathEditing/AddHopPickerPresentation.swift @@ -1,33 +1,33 @@ import SwiftUI extension View { - /// Presents the shared `AddHopPickerView` for the given intent binding. - /// - /// On the iPad app on Mac a `navigationDestination` push inside a split-view - /// detail column renders without the system back button, stranding the user on - /// the picker with no way back. Only a caller inside such a column knows it is - /// affected, so it opts in via `inDetailColumn`; there the picker is presented - /// as a sheet that carries its own dismiss control. Callers presenting from - /// their own `NavigationStack` (e.g. a modal sheet) keep the push, where the - /// system back button works on every platform. - @ViewBuilder - func addHopPicker( - for intent: Binding, - source: any HopPickerSource, - inDetailColumn: Bool = false - ) -> some View { - if inDetailColumn, ProcessInfo.processInfo.isiOSAppOnMac { - sheet(item: intent) { intent in - NavigationStack { - AddHopPickerView(viewModel: source, intent: intent, presentsOwnDismiss: true) - } - .presentationSizing(.page) - .presentationDragIndicator(.visible) - } - } else { - navigationDestination(item: intent) { intent in - AddHopPickerView(viewModel: source, intent: intent) - } + /// Presents the shared `AddHopPickerView` for the given intent binding. + /// + /// On the iPad app on Mac a `navigationDestination` push inside a split-view + /// detail column renders without the system back button, stranding the user on + /// the picker with no way back. Only a caller inside such a column knows it is + /// affected, so it opts in via `inDetailColumn`; there the picker is presented + /// as a sheet that carries its own dismiss control. Callers presenting from + /// their own `NavigationStack` (e.g. a modal sheet) keep the push, where the + /// system back button works on every platform. + @ViewBuilder + func addHopPicker( + for intent: Binding, + source: any HopPickerSource, + inDetailColumn: Bool = false + ) -> some View { + if inDetailColumn, ProcessInfo.processInfo.isiOSAppOnMac { + sheet(item: intent) { intent in + NavigationStack { + AddHopPickerView(viewModel: source, intent: intent, presentsOwnDismiss: true) } + .presentationSizing(.page) + .presentationDragIndicator(.visible) + } + } else { + navigationDestination(item: intent) { intent in + AddHopPickerView(viewModel: source, intent: intent) + } } + } } diff --git a/MC1/Views/PathEditing/AddHopPickerView.swift b/MC1/Views/PathEditing/AddHopPickerView.swift index ecc17d5b..5d7eff94 100644 --- a/MC1/Views/PathEditing/AddHopPickerView.swift +++ b/MC1/Views/PathEditing/AddHopPickerView.swift @@ -1,6 +1,6 @@ import Accessibility -import SwiftUI import MC1Services +import SwiftUI import UIKit /// Shared full-screen Add-Hop picker, pushed via `.navigationDestination(item:)` @@ -8,472 +8,488 @@ import UIKit /// builder (`TracePathListView`). Finds a node fast via name substring or hex /// prefix; a comma in the search field switches to bulk code entry. struct AddHopPickerView: View { - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - let viewModel: any HopPickerSource - let intent: AddHopIntent - /// Set when the picker is presented modally (a sheet) rather than pushed onto a - /// navigation stack, so it surfaces its own dismiss control in place of the - /// system back button. See `addHopPicker(for:source:)`. - var presentsOwnDismiss = false - - @State private var searchText = "" - @State private var filter: AddHopFilter = .all - @State private var addHapticTrigger = 0 - - /// Recent keys frozen at presentation. Tapping a row records the node into the - /// view model's live recents (for the next time the picker opens), but the - /// section partitioning reads this snapshot so a just-added row keeps its place - /// instead of jumping into the Recent section mid-interaction. - @State private var sessionRecentKeys: [Data] - - init(viewModel: any HopPickerSource, intent: AddHopIntent, presentsOwnDismiss: Bool = false) { - self.viewModel = viewModel - self.intent = intent - self.presentsOwnDismiss = presentsOwnDismiss - _sessionRecentKeys = State(initialValue: viewModel.recentPublicKeys) - } - - /// A comma in the query switches the picker to bulk code entry. - private var isBulkMode: Bool { searchText.contains(",") } - - var body: some View { - List { - if viewModel.isPathFull { - Section { - maxHopsReachedView - .listRowBackground(Color.clear) - } - } else if isBulkMode { - bulkAddContent - } else { - resultsContent - } - } - .listStyle(.insetGrouped) - .themedCanvas(theme) - .environment(\.editMode, .constant(.inactive)) // override parent's .active - .navigationHeader( - title: L10n.Contacts.Contacts.PathEdit.addHop, - subtitle: viewModel.isPathFull ? "" : bannerText - ) - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: L10n.Contacts.Contacts.PathEdit.searchPrompt - ) - .onChange(of: searchText) { _, newValue in - // A pasted bulk path can use a different hash width than the current - // default; adopt it before the bulk preview re-renders so the codes - // read as addable instead of invalid. - if newValue.contains(",") { viewModel.adoptHashSize(forPastedCodes: newValue) } - } - .safeAreaInset(edge: .top, spacing: 0) { - AddHopSegmentPicker(selection: $filter) - } - .sensoryFeedback(.impact(weight: .light), trigger: addHapticTrigger) - .toolbar { - if presentsOwnDismiss { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Contacts.Contacts.Common.done) { dismiss() } - } - } - ToolbarItem(placement: .primaryAction) { - // Pastes clipboard text into the search field; a comma in the - // pasted value switches the picker to bulk code entry. - Button(L10n.Contacts.Contacts.PathEdit.paste, systemImage: "doc.on.clipboard") { - guard let pasted = UIPasteboard.general.string? - .trimmingCharacters(in: .whitespacesAndNewlines), - !pasted.isEmpty else { return } - searchText = pasted - } - } - } - } - - @ViewBuilder - private var resultsContent: some View { - let results = buildResults() - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.recent, results: results.recent) - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.favorites, results: results.favorites) - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.contacts, results: results.contacts) - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.discovered, results: results.discovered) - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.rooms, results: results.rooms) - if results.isEmpty { - Section { - emptyResultsView - .listRowBackground(Color.clear) - } - } - } - - @ViewBuilder - private func repeaterSection(_ title: String, results: [PickerNode]) -> some View { - if !results.isEmpty { - Section(title) { - ForEach(results, id: \.id) { node in - PickerRowView( - node: node, - intent: intent, - viewModel: viewModel, - addHapticTrigger: $addHapticTrigger - ) - } - } - .themedRowBackground(theme) - } - } - - // MARK: - Bulk add - - /// Bulk code entry: parse the comma-separated query, preview per-code validity, - /// and add every resolvable code on tap, then clear the search. - @ViewBuilder - private var bulkAddContent: some View { - let classifications = viewModel.classifyCodes(searchText) - let addableCodes = classifications.filter(\.willBeAdded).map(\.code) - Section { - ForEach(classifications) { classification in - BulkCodeRow(classification: classification) - } - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + let viewModel: any HopPickerSource + let intent: AddHopIntent + /// Set when the picker is presented modally (a sheet) rather than pushed onto a + /// navigation stack, so it surfaces its own dismiss control in place of the + /// system back button. See `addHopPicker(for:source:)`. + var presentsOwnDismiss = false + + @State private var searchText = "" + @State private var filter: AddHopFilter = .all + @State private var addHapticTrigger = 0 + + /// Recent keys frozen at presentation. Tapping a row records the node into the + /// view model's live recents (for the next time the picker opens), but the + /// section partitioning reads this snapshot so a just-added row keeps its place + /// instead of jumping into the Recent section mid-interaction. + @State private var sessionRecentKeys: [Data] + + init(viewModel: any HopPickerSource, intent: AddHopIntent, presentsOwnDismiss: Bool = false) { + self.viewModel = viewModel + self.intent = intent + self.presentsOwnDismiss = presentsOwnDismiss + _sessionRecentKeys = State(initialValue: viewModel.recentPublicKeys) + } + + /// A comma in the query switches the picker to bulk code entry. + private var isBulkMode: Bool { + searchText.contains(",") + } + + var body: some View { + List { + if viewModel.isPathFull { Section { - Button { - let result = viewModel.addCodes(searchText) - if !result.added.isEmpty { - addHapticTrigger += 1 - AccessibilityNotification.Announcement(bannerText).post() - } - searchText = "" - } label: { - Text(addableCodes.isEmpty - ? L10n.Contacts.Contacts.PathEdit.BulkAdd.empty - : L10n.Contacts.Contacts.PathEdit.BulkAdd.action(addableCodes.joined(separator: ", "))) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(addableCodes.isEmpty) + maxHopsReachedView .listRowBackground(Color.clear) } + } else if isBulkMode { + bulkAddContent + } else { + resultsContent + } } - - // MARK: - Subtitle text - - private var bannerText: String { - Self.bannerText(for: viewModel, intent: intent) + .listStyle(.insetGrouped) + .themedCanvas(theme) + .environment(\.editMode, .constant(.inactive)) // override parent's .active + .navigationHeader( + title: L10n.Contacts.Contacts.PathEdit.addHop, + subtitle: viewModel.isPathFull ? "" : bannerText + ) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: L10n.Contacts.Contacts.PathEdit.searchPrompt + ) + .onChange(of: searchText) { _, newValue in + // A pasted bulk path can use a different hash width than the current + // default; adopt it before the bulk preview re-renders so the codes + // read as addable instead of invalid. + if newValue.contains(",") { viewModel.adoptHashSize(forPastedCodes: newValue) } } - - /// Shared text source so the navigation subtitle and the row-tap - /// announcement (posted from `PickerRowView.handleTap`) read identically. - @MainActor - static func bannerText(for viewModel: any HopPickerSource, intent: AddHopIntent) -> String { - if viewModel.isPathFull { - return L10n.Contacts.Contacts.PathEdit.MaxHops.reached + .safeAreaInset(edge: .top, spacing: 0) { + AddHopSegmentPicker(selection: $filter) + } + .sensoryFeedback(.impact(weight: .light), trigger: addHapticTrigger) + .toolbar { + if presentsOwnDismiss { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Common.done) { dismiss() } } - switch intent { - case .append: - return L10n.Contacts.Contacts.PathEdit.positionAppend(viewModel.currentHopCount + 1) + } + ToolbarItem(placement: .primaryAction) { + // Pastes clipboard text into the search field; a comma in the + // pasted value switches the picker to bulk code entry. + Button(L10n.Contacts.Contacts.PathEdit.paste, systemImage: "doc.on.clipboard") { + guard let pasted = UIPasteboard.general.string? + .trimmingCharacters(in: .whitespacesAndNewlines), + !pasted.isEmpty else { return } + searchText = pasted } + } } - - // MARK: - Result builders - - /// Results for all five sections, built once per body so row rendering and - /// the empty-state guard read from the same materialized state. - private struct PickerResults { - var recent: [PickerNode] = [] - var favorites: [PickerNode] = [] - var contacts: [PickerNode] = [] - var discovered: [PickerNode] = [] - var rooms: [PickerNode] = [] - - var isEmpty: Bool { - recent.isEmpty && favorites.isEmpty && contacts.isEmpty && discovered.isEmpty && rooms.isEmpty + } + + @ViewBuilder + private var resultsContent: some View { + let results = buildResults() + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.recent, results: results.recent) + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.favorites, results: results.favorites) + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.contacts, results: results.contacts) + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.discovered, results: results.discovered) + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.rooms, results: results.rooms) + if results.isEmpty { + Section { + emptyResultsView + .listRowBackground(Color.clear) + } + } + } + + @ViewBuilder + private func repeaterSection(_ title: String, results: [PickerNode]) -> some View { + if !results.isEmpty { + Section(title) { + ForEach(results, id: \.id) { node in + PickerRowView( + node: node, + intent: intent, + viewModel: viewModel, + addHapticTrigger: $addHapticTrigger + ) } + } + .themedRowBackground(theme) } - - private func buildResults() -> PickerResults { - // Cross-section dedup only matters in `.all`, where every section is visible - // at once. A single-section filter shows nothing else, so excluding recent or - // contact keys there would hide a node that has no other section to appear in. - let isUnfiltered = filter == .all - let recentKeys = isUnfiltered ? Set(sessionRecentKeys) : [] - let contactKeys = isUnfiltered ? Set(viewModel.availableRepeaters.map(\.publicKey)) : [] - var results = PickerResults() - if showsRecent { results.recent = recentResults() } - if showsFavorites { results.favorites = favoriteResults(excluding: recentKeys) } - if showsContacts { results.contacts = contactResults(excluding: recentKeys) } - if showsDiscovered { results.discovered = discoveredResults(recentKeys: recentKeys, contactKeys: contactKeys) } - if showsRooms { results.rooms = roomResults(excluding: recentKeys) } - return results + } + + // MARK: - Bulk add + + /// Bulk code entry: parse the comma-separated query, preview per-code validity, + /// and add every resolvable code on tap, then clear the search. + @ViewBuilder + private var bulkAddContent: some View { + let classifications = viewModel.classifyCodes(searchText) + let addableCodes = classifications.filter(\.willBeAdded).map(\.code) + Section { + ForEach(classifications) { classification in + BulkCodeRow(classification: classification) + } } - - /// Recent hits resolved against contacts + discovered nodes, preserving LRU - /// order. Filtered against the current search query. - private func recentResults() -> [PickerNode] { - let resolved = sessionRecentKeys.compactMap { pubkey -> PickerNode? in - if let contact = viewModel.availableRepeaters.first(where: { $0.publicKey == pubkey }) { - return .contact(contact) - } - if let discovered = viewModel.discoveredRepeaters.first(where: { $0.publicKey == pubkey }) { - return .discovered(discovered) - } - return nil + .themedRowBackground(theme) + Section { + Button { + let result = viewModel.addCodes(searchText) + if !result.added.isEmpty { + addHapticTrigger += 1 + AccessibilityNotification.Announcement(bannerText).post() } - return HopNodeMatching.filtered(resolved, by: searchText) + searchText = "" + } label: { + Text(addableCodes.isEmpty + ? L10n.Contacts.Contacts.PathEdit.BulkAdd.empty + : L10n.Contacts.Contacts.PathEdit.BulkAdd.action(addableCodes.joined(separator: ", "))) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(addableCodes.isEmpty) + .listRowBackground(Color.clear) } + } - /// Favorite contacts minus anything already in Recent. - private func favoriteResults(excluding keySet: Set) -> [PickerNode] { - let nodes = viewModel.availableRepeaters - .filter { $0.isFavorite && !keySet.contains($0.publicKey) } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - .map { PickerNode.contact($0) } - return HopNodeMatching.filtered(nodes, by: searchText) - } + // MARK: - Subtitle text - /// Non-favorite contact repeaters minus Recent. - private func contactResults(excluding keySet: Set) -> [PickerNode] { - let nodes = viewModel.availableRepeaters - .filter { !$0.isFavorite && !keySet.contains($0.publicKey) } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - .map { PickerNode.contact($0) } - return HopNodeMatching.filtered(nodes, by: searchText) - } + private var bannerText: String { + Self.bannerText(for: viewModel, intent: intent) + } - /// Discovered repeaters minus any pubkey already present as a contact and - /// anything in Recent. - private func discoveredResults(recentKeys: Set, contactKeys: Set) -> [PickerNode] { - let nodes = viewModel.discoveredRepeaters - .filter { !contactKeys.contains($0.publicKey) && !recentKeys.contains($0.publicKey) } - .sorted { $0.resolvableName.localizedCaseInsensitiveCompare($1.resolvableName) == .orderedAscending } - .map { PickerNode.discovered($0) } - return HopNodeMatching.filtered(nodes, by: searchText) + /// Shared text source so the navigation subtitle and the row-tap + /// announcement (posted from `PickerRowView.handleTap`) read identically. + @MainActor + static func bannerText(for viewModel: any HopPickerSource, intent: AddHopIntent) -> String { + if viewModel.isPathFull { + return L10n.Contacts.Contacts.PathEdit.MaxHops.reached } - - /// Rooms (contact type == .room) — never double-listed. - private func roomResults(excluding keySet: Set) -> [PickerNode] { - let nodes = viewModel.availableRooms - .filter { !keySet.contains($0.publicKey) } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - .map { PickerNode.contact($0) } - return HopNodeMatching.filtered(nodes, by: searchText) + switch intent { + case .append: + return L10n.Contacts.Contacts.PathEdit.positionAppend(viewModel.currentHopCount + 1) } + } - // MARK: - Visibility per filter + // MARK: - Result builders - private var showsRecent: Bool { filter == .all || filter == .recent } - private var showsFavorites: Bool { filter == .all || filter == .favorites } - private var showsContacts: Bool { filter == .all } - private var showsDiscovered: Bool { filter == .all || filter == .discovered } - private var showsRooms: Bool { filter == .all } + /// Results for all five sections, built once per body so row rendering and + /// the empty-state guard read from the same materialized state. + private struct PickerResults { + var recent: [PickerNode] = [] + var favorites: [PickerNode] = [] + var contacts: [PickerNode] = [] + var discovered: [PickerNode] = [] + var rooms: [PickerNode] = [] - // MARK: - Row + empty state - - /// Empty-state copy for the active filter. The curated subset filters get - /// their own wording; `.all` and `.discovered` share the generic discovery - /// copy, which is accurate for both. - private var emptyStateTitle: String { - switch filter { - case .favorites: L10n.Contacts.Contacts.PathEdit.NoFavorites.title - case .recent: L10n.Contacts.Contacts.PathEdit.NoRecent.title - case .all, .discovered: L10n.Contacts.Contacts.PathEdit.NoRepeaters.title - } + var isEmpty: Bool { + recent.isEmpty && favorites.isEmpty && contacts.isEmpty && discovered.isEmpty && rooms.isEmpty } - - private var emptyStateDescription: String { - switch filter { - case .favorites: L10n.Contacts.Contacts.PathEdit.NoFavorites.description - case .recent: L10n.Contacts.Contacts.PathEdit.NoRecent.description - case .all, .discovered: L10n.Contacts.Contacts.PathEdit.NoRepeaters.description - } + } + + private func buildResults() -> PickerResults { + // Cross-section dedup only matters in `.all`, where every section is visible + // at once. A single-section filter shows nothing else, so excluding recent or + // contact keys there would hide a node that has no other section to appear in. + let isUnfiltered = filter == .all + let recentKeys = isUnfiltered ? Set(sessionRecentKeys) : [] + let contactKeys = isUnfiltered ? Set(viewModel.availableRepeaters.map(\.publicKey)) : [] + var results = PickerResults() + if showsRecent { results.recent = recentResults() } + if showsFavorites { results.favorites = favoriteResults(excluding: recentKeys) } + if showsContacts { results.contacts = contactResults(excluding: recentKeys) } + if showsDiscovered { results.discovered = discoveredResults(recentKeys: recentKeys, contactKeys: contactKeys) } + if showsRooms { results.rooms = roomResults(excluding: recentKeys) } + return results + } + + /// Recent hits resolved against contacts + discovered nodes, preserving LRU + /// order. Filtered against the current search query. + private func recentResults() -> [PickerNode] { + let resolved = sessionRecentKeys.compactMap { pubkey -> PickerNode? in + if let contact = viewModel.availableRepeaters.first(where: { $0.publicKey == pubkey }) { + return .contact(contact) + } + if let discovered = viewModel.discoveredRepeaters.first(where: { $0.publicKey == pubkey }) { + return .discovered(discovered) + } + return nil + } + return HopNodeMatching.filtered(resolved, by: searchText) + } + + /// Favorite contacts minus anything already in Recent. + private func favoriteResults(excluding keySet: Set) -> [PickerNode] { + let nodes = viewModel.availableRepeaters + .filter { $0.isFavorite && !keySet.contains($0.publicKey) } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + .map { PickerNode.contact($0) } + return HopNodeMatching.filtered(nodes, by: searchText) + } + + /// Non-favorite contact repeaters minus Recent. + private func contactResults(excluding keySet: Set) -> [PickerNode] { + let nodes = viewModel.availableRepeaters + .filter { !$0.isFavorite && !keySet.contains($0.publicKey) } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + .map { PickerNode.contact($0) } + return HopNodeMatching.filtered(nodes, by: searchText) + } + + /// Discovered repeaters minus any pubkey already present as a contact and + /// anything in Recent. + private func discoveredResults(recentKeys: Set, contactKeys: Set) -> [PickerNode] { + let nodes = viewModel.discoveredRepeaters + .filter { !contactKeys.contains($0.publicKey) && !recentKeys.contains($0.publicKey) } + .sorted { $0.resolvableName.localizedCaseInsensitiveCompare($1.resolvableName) == .orderedAscending } + .map { PickerNode.discovered($0) } + return HopNodeMatching.filtered(nodes, by: searchText) + } + + /// Rooms (contact type == .room) — never double-listed. + private func roomResults(excluding keySet: Set) -> [PickerNode] { + let nodes = viewModel.availableRooms + .filter { !keySet.contains($0.publicKey) } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + .map { PickerNode.contact($0) } + return HopNodeMatching.filtered(nodes, by: searchText) + } + + // MARK: - Visibility per filter + + private var showsRecent: Bool { + filter == .all || filter == .recent + } + + private var showsFavorites: Bool { + filter == .all || filter == .favorites + } + + private var showsContacts: Bool { + filter == .all + } + + private var showsDiscovered: Bool { + filter == .all || filter == .discovered + } + + private var showsRooms: Bool { + filter == .all + } + + // MARK: - Row + empty state + + /// Empty-state copy for the active filter. The curated subset filters get + /// their own wording; `.all` and `.discovered` share the generic discovery + /// copy, which is accurate for both. + private var emptyStateTitle: String { + switch filter { + case .favorites: L10n.Contacts.Contacts.PathEdit.NoFavorites.title + case .recent: L10n.Contacts.Contacts.PathEdit.NoRecent.title + case .all, .discovered: L10n.Contacts.Contacts.PathEdit.NoRepeaters.title } + } - @ViewBuilder - private var emptyResultsView: some View { - if searchText.isEmpty { - ContentUnavailableView( - emptyStateTitle, - systemImage: "antenna.radiowaves.left.and.right.slash", - description: Text(emptyStateDescription) - ) + private var emptyStateDescription: String { + switch filter { + case .favorites: L10n.Contacts.Contacts.PathEdit.NoFavorites.description + case .recent: L10n.Contacts.Contacts.PathEdit.NoRecent.description + case .all, .discovered: L10n.Contacts.Contacts.PathEdit.NoRepeaters.description + } + } + + @ViewBuilder + private var emptyResultsView: some View { + if searchText.isEmpty { + ContentUnavailableView( + emptyStateTitle, + systemImage: "antenna.radiowaves.left.and.right.slash", + description: Text(emptyStateDescription) + ) + } else { + let roomsWouldMatch = filter != .all && viewModel.availableRooms.contains { room in + HopNodeMatching.matches(.contact(room), query: searchText) + } + ContentUnavailableView { + Label( + L10n.Contacts.Contacts.PathEdit.NoRepeaters.title, + systemImage: "magnifyingglass" + ) + } description: { + if roomsWouldMatch { + Text(L10n.Contacts.Contacts.PathEdit.Search.NoMatches.descriptionWithRoomsHint) } else { - let roomsWouldMatch = filter != .all && viewModel.availableRooms.contains { room in - HopNodeMatching.matches(.contact(room), query: searchText) - } - ContentUnavailableView { - Label( - L10n.Contacts.Contacts.PathEdit.NoRepeaters.title, - systemImage: "magnifyingglass" - ) - } description: { - if roomsWouldMatch { - Text(L10n.Contacts.Contacts.PathEdit.Search.NoMatches.descriptionWithRoomsHint) - } else { - Text(L10n.Contacts.Contacts.PathEdit.Search.NoMatches.description) - } - } + Text(L10n.Contacts.Contacts.PathEdit.Search.NoMatches.description) } + } } - - private var maxHopsReachedView: some View { - ContentUnavailableView { - Label( - L10n.Contacts.Contacts.PathEdit.MaxHops.reached, - systemImage: "checkmark.circle" - ) - } description: { - Text(L10n.Contacts.Contacts.PathEdit.MaxHops.description(viewModel.hopLimit ?? 0)) - } + } + + private var maxHopsReachedView: some View { + ContentUnavailableView { + Label( + L10n.Contacts.Contacts.PathEdit.MaxHops.reached, + systemImage: "checkmark.circle" + ) + } description: { + Text(L10n.Contacts.Contacts.PathEdit.MaxHops.description(viewModel.hopLimit ?? 0)) } + } } // MARK: - Picker row private struct PickerRowView: View { - let node: PickerNode - let intent: AddHopIntent - let viewModel: any HopPickerSource - @Binding var addHapticTrigger: Int - - @State private var showSuccess = false - @State private var resetTask: Task? - - private static let successDuration: Duration = .seconds(1.5) - - var body: some View { - Button(action: handleTap) { - HStack(spacing: PathEditMetrics.rowContentSpacing) { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: PathEditMetrics.badgeSpacing) { - Text(node.displayName) - .font(.body) - if node.isFavorite { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(.yellow) - .accessibilityHidden(true) - } - if node.isDiscovered { - NodeKindBadge( - text: L10n.Contacts.Contacts.NodeKind.discovered, - color: .blue - ) - } - if node.isRoom { - NodeKindBadge( - text: L10n.Contacts.Contacts.NodeKind.room, - color: .orange - ) - } - } - Text(node.publicKeyHex) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - trailingIcon + let node: PickerNode + let intent: AddHopIntent + let viewModel: any HopPickerSource + @Binding var addHapticTrigger: Int + + @State private var showSuccess = false + @State private var resetTask: Task? + + private static let successDuration: Duration = .seconds(1.5) + + var body: some View { + Button(action: handleTap) { + HStack(spacing: PathEditMetrics.rowContentSpacing) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: PathEditMetrics.badgeSpacing) { + Text(node.displayName) + .font(.body) + if node.isFavorite { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(.yellow) + .accessibilityHidden(true) } - .frame(minHeight: PathEditMetrics.tapTarget) - .contentShape(Rectangle()) + if node.isDiscovered { + NodeKindBadge( + text: L10n.Contacts.Contacts.NodeKind.discovered, + color: .blue + ) + } + if node.isRoom { + NodeKindBadge( + text: L10n.Contacts.Contacts.NodeKind.room, + color: .orange + ) + } + } + Text(node.publicKeyHex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) } - .buttonStyle(.plain) - .accessibilityLabel(rowAccessibilityLabel) + Spacer() + trailingIcon + } + .frame(minHeight: PathEditMetrics.tapTarget) + .contentShape(Rectangle()) } - - @ViewBuilder - private var trailingIcon: some View { - if showSuccess { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.scale.combined(with: .opacity)) - } else { - Image(systemName: "plus.circle") - .foregroundStyle(.tint) - .transition(.opacity) - } + .buttonStyle(.plain) + .accessibilityLabel(rowAccessibilityLabel) + } + + @ViewBuilder + private var trailingIcon: some View { + if showSuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Image(systemName: "plus.circle") + .foregroundStyle(.tint) + .transition(.opacity) } - - private func handleTap() { - guard !viewModel.isPathFull else { return } - addHapticTrigger += 1 - viewModel.appendHop(node.underlying) - let updatedBanner = AddHopPickerView.bannerText(for: viewModel, intent: intent) - AccessibilityNotification.Announcement(updatedBanner).post() - resetTask?.cancel() - resetTask = Task { - withAnimation { showSuccess = true } - try? await Task.sleep(for: Self.successDuration) - if !Task.isCancelled { - withAnimation { showSuccess = false } - } - } + } + + private func handleTap() { + guard !viewModel.isPathFull else { return } + addHapticTrigger += 1 + viewModel.appendHop(node.underlying) + let updatedBanner = AddHopPickerView.bannerText(for: viewModel, intent: intent) + AccessibilityNotification.Announcement(updatedBanner).post() + resetTask?.cancel() + resetTask = Task { + withAnimation { showSuccess = true } + try? await Task.sleep(for: Self.successDuration) + if !Task.isCancelled { + withAnimation { showSuccess = false } + } } + } - private var rowAccessibilityLabel: String { - L10n.Contacts.Contacts.PathEdit.addToPathAsHop(node.displayName, viewModel.currentHopCount + 1) - } + private var rowAccessibilityLabel: String { + L10n.Contacts.Contacts.PathEdit.addToPathAsHop(node.displayName, viewModel.currentHopCount + 1) + } } // MARK: - Bulk code row /// One parsed code in the bulk-add preview, showing the per-code outcome. private struct BulkCodeRow: View { - let classification: HopCodeClassification - - var body: some View { - HStack(spacing: PathEditMetrics.rowContentSpacing) { - Text(rowText) - .font(.callout) - Spacer() - trailingIcon - } - .frame(minHeight: PathEditMetrics.tapTarget) - .accessibilityElement(children: .combine) + let classification: HopCodeClassification + + var body: some View { + HStack(spacing: PathEditMetrics.rowContentSpacing) { + Text(rowText) + .font(.callout) + Spacer() + trailingIcon } - - /// Only problem statuses get a glyph. Rows that will be added rely on the - /// single "Add codes" button, so they show no add-like affordance. - @ViewBuilder - private var trailingIcon: some View { - if let icon = iconName, let tint = iconTint { - Image(systemName: icon) - .foregroundStyle(tint) - } + .frame(minHeight: PathEditMetrics.tapTarget) + .accessibilityElement(children: .combine) + } + + /// Only problem statuses get a glyph. Rows that will be added rely on the + /// single "Add codes" button, so they show no add-like affordance. + @ViewBuilder + private var trailingIcon: some View { + if let icon = iconName, let tint = iconTint { + Image(systemName: icon) + .foregroundStyle(tint) } - - private var rowText: String { - let code = classification.code - switch classification.status { - case .willAdd: return L10n.Contacts.Contacts.PathEdit.BulkAdd.willAdd(code) - case .alreadyInPath: return L10n.Contacts.Contacts.CodeInput.Error.alreadyInPath(code) - case .notFound: return L10n.Contacts.Contacts.CodeInput.Error.notFound(code) - case .invalidFormat: return L10n.Contacts.Contacts.CodeInput.Error.invalidFormat(code) - case .pathFull: return L10n.Contacts.Contacts.PathEdit.BulkAdd.pathFull(code) - } + } + + private var rowText: String { + let code = classification.code + switch classification.status { + case .willAdd: return L10n.Contacts.Contacts.PathEdit.BulkAdd.willAdd(code) + case .alreadyInPath: return L10n.Contacts.Contacts.CodeInput.Error.alreadyInPath(code) + case .notFound: return L10n.Contacts.Contacts.CodeInput.Error.notFound(code) + case .invalidFormat: return L10n.Contacts.Contacts.CodeInput.Error.invalidFormat(code) + case .pathFull: return L10n.Contacts.Contacts.PathEdit.BulkAdd.pathFull(code) } - - private var iconName: String? { - switch classification.status { - case .willAdd: return nil - case .alreadyInPath: return "checkmark.circle.fill" - case .notFound: return "questionmark.circle" - case .invalidFormat: return "exclamationmark.triangle.fill" - case .pathFull: return "nosign" - } + } + + private var iconName: String? { + switch classification.status { + case .willAdd: nil + case .alreadyInPath: "checkmark.circle.fill" + case .notFound: "questionmark.circle" + case .invalidFormat: "exclamationmark.triangle.fill" + case .pathFull: "nosign" } - - private var iconTint: Color? { - switch classification.status { - case .willAdd: return nil - case .alreadyInPath: return .secondary - case .notFound: return .orange - case .invalidFormat: return .red - case .pathFull: return .secondary - } + } + + private var iconTint: Color? { + switch classification.status { + case .willAdd: nil + case .alreadyInPath: .secondary + case .notFound: .orange + case .invalidFormat: .red + case .pathFull: .secondary } + } } diff --git a/MC1/Views/PathEditing/AddHopSegmentPicker.swift b/MC1/Views/PathEditing/AddHopSegmentPicker.swift index 1c4ebfa8..dedb9a2e 100644 --- a/MC1/Views/PathEditing/AddHopSegmentPicker.swift +++ b/MC1/Views/PathEditing/AddHopSegmentPicker.swift @@ -6,15 +6,15 @@ import SwiftUI /// isn't reliable. While searching, the picker is muted and disabled (searches /// behave as if `All` were selected, matching `DiscoverSegmentPicker`). struct AddHopSegmentPicker: View { - @Binding var selection: AddHopFilter - @Environment(\.isSearching) private var isSearching + @Binding var selection: AddHopFilter + @Environment(\.isSearching) private var isSearching - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: isSearching, - pickerLabel: L10n.Contacts.Contacts.PathEdit.filterPickerLabel, - title: { $0.localizedLabel } - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: isSearching, + pickerLabel: L10n.Contacts.Contacts.PathEdit.filterPickerLabel, + title: { $0.localizedLabel } + ) + } } diff --git a/MC1/Views/PathEditing/CodeInputResult.swift b/MC1/Views/PathEditing/CodeInputResult.swift index 3ec15b2e..8a3678d8 100644 --- a/MC1/Views/PathEditing/CodeInputResult.swift +++ b/MC1/Views/PathEditing/CodeInputResult.swift @@ -1,29 +1,29 @@ /// Result of parsing and adding repeater codes struct CodeInputResult { - var added: [String] = [] - var notFound: [String] = [] - var alreadyInPath: [String] = [] - var invalidFormat: [String] = [] + var added: [String] = [] + var notFound: [String] = [] + var alreadyInPath: [String] = [] + var invalidFormat: [String] = [] - var hasErrors: Bool { - !notFound.isEmpty || !alreadyInPath.isEmpty || !invalidFormat.isEmpty - } - - var errorMessage: String? { - guard hasErrors else { return nil } + var hasErrors: Bool { + !notFound.isEmpty || !alreadyInPath.isEmpty || !invalidFormat.isEmpty + } - var parts: [String] = [] + var errorMessage: String? { + guard hasErrors else { return nil } - if !invalidFormat.isEmpty { - parts.append(L10n.Contacts.Contacts.CodeInput.Error.invalidFormat(invalidFormat.joined(separator: ", "))) - } - if !notFound.isEmpty { - parts.append(L10n.Contacts.Contacts.CodeInput.Error.notFound(notFound.joined(separator: ", "))) - } - if !alreadyInPath.isEmpty { - parts.append(L10n.Contacts.Contacts.CodeInput.Error.alreadyInPath(alreadyInPath.joined(separator: ", "))) - } + var parts: [String] = [] - return parts.joined(separator: " · ") + if !invalidFormat.isEmpty { + parts.append(L10n.Contacts.Contacts.CodeInput.Error.invalidFormat(invalidFormat.joined(separator: ", "))) + } + if !notFound.isEmpty { + parts.append(L10n.Contacts.Contacts.CodeInput.Error.notFound(notFound.joined(separator: ", "))) } + if !alreadyInPath.isEmpty { + parts.append(L10n.Contacts.Contacts.CodeInput.Error.alreadyInPath(alreadyInPath.joined(separator: ", "))) + } + + return parts.joined(separator: " · ") + } } diff --git a/MC1/Views/PathEditing/HopCodeClassification.swift b/MC1/Views/PathEditing/HopCodeClassification.swift index ac385895..31099090 100644 --- a/MC1/Views/PathEditing/HopCodeClassification.swift +++ b/MC1/Views/PathEditing/HopCodeClassification.swift @@ -2,26 +2,28 @@ import Foundation /// Outcome of a single hex code parsed from a bulk-add entry. enum HopCodeStatus { - /// Valid, resolves to a node, and fits within the hop cap. Carries the - /// prebuilt hop so callers append without re-parsing or re-resolving. - case willAdd(PathHop) - case alreadyInPath - case notFound // valid hex but no matching node - case invalidFormat // wrong length or non-hex - case pathFull // valid and resolvable but past the hop cap + /// Valid, resolves to a node, and fits within the hop cap. Carries the + /// prebuilt hop so callers append without re-parsing or re-resolving. + case willAdd(PathHop) + case alreadyInPath + case notFound // valid hex but no matching node + case invalidFormat // wrong length or non-hex + case pathFull // valid and resolvable but past the hop cap } /// One parsed code from a bulk-add entry, with its status. struct HopCodeClassification: Identifiable { - /// The uppercased code, unique after de-duplication; also the stable id. - let code: String - let status: HopCodeStatus + /// The uppercased code, unique after de-duplication; also the stable id. + let code: String + let status: HopCodeStatus - var id: String { code } + var id: String { + code + } - /// True when tapping "Add" will append this code to the path. - var willBeAdded: Bool { - if case .willAdd = status { return true } - return false - } + /// True when tapping "Add" will append this code to the path. + var willBeAdded: Bool { + if case .willAdd = status { return true } + return false + } } diff --git a/MC1/Views/PathEditing/HopCodeParser.swift b/MC1/Views/PathEditing/HopCodeParser.swift index 356d1df4..848e5da9 100644 --- a/MC1/Views/PathEditing/HopCodeParser.swift +++ b/MC1/Views/PathEditing/HopCodeParser.swift @@ -5,64 +5,64 @@ import Foundation /// for both the bulk-add preview and the actual add, so the panel can never show /// a status that differs from what tapping "Add" produces. enum HopCodeParser { - /// Classify each code in `input` without mutating any path. - /// - /// - Parameters: - /// - hashSize: bytes per hop; a code must be exactly `hashSize * 2` hex digits. - /// - existingHashes: hash-byte prefixes already in the path (for `.alreadyInPath`). - /// - remainingCapacity: hops still addable, or `nil` when unlimited; resolvable - /// codes beyond it become `.pathFull`. - /// - resolve: maps a hash prefix to a node's full public key and display name, - /// or `nil` when no node matches. - static func classify( - input: String, - hashSize: Int, - existingHashes: Set, - remainingCapacity: Int?, - resolve: (Data) -> (publicKey: Data, name: String?)? - ) -> [HopCodeClassification] { - let codes = input - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces).uppercased() } - .filter { !$0.isEmpty } + /// Classify each code in `input` without mutating any path. + /// + /// - Parameters: + /// - hashSize: bytes per hop; a code must be exactly `hashSize * 2` hex digits. + /// - existingHashes: hash-byte prefixes already in the path (for `.alreadyInPath`). + /// - remainingCapacity: hops still addable, or `nil` when unlimited; resolvable + /// codes beyond it become `.pathFull`. + /// - resolve: maps a hash prefix to a node's full public key and display name, + /// or `nil` when no node matches. + static func classify( + input: String, + hashSize: Int, + existingHashes: Set, + remainingCapacity: Int?, + resolve: (Data) -> (publicKey: Data, name: String?)? + ) -> [HopCodeClassification] { + let codes = input + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).uppercased() } + .filter { !$0.isEmpty } - var seen = Set() - let uniqueCodes = codes.filter { seen.insert($0).inserted } + var seen = Set() + let uniqueCodes = codes.filter { seen.insert($0).inserted } - var pathHashes = existingHashes - var added = 0 + var pathHashes = existingHashes + var added = 0 - return uniqueCodes.map { code in - guard let hashData = parseHex(code, hashSize: hashSize) else { - return HopCodeClassification(code: code, status: .invalidFormat) - } - if pathHashes.contains(hashData) { - return HopCodeClassification(code: code, status: .alreadyInPath) - } - guard let resolved = resolve(hashData) else { - return HopCodeClassification(code: code, status: .notFound) - } - if let remainingCapacity, added >= remainingCapacity { - return HopCodeClassification(code: code, status: .pathFull) - } - added += 1 - pathHashes.insert(hashData) - let hop = PathHop(hashBytes: hashData, publicKey: resolved.publicKey, resolvedName: resolved.name) - return HopCodeClassification(code: code, status: .willAdd(hop)) - } + return uniqueCodes.map { code in + guard let hashData = parseHex(code, hashSize: hashSize) else { + return HopCodeClassification(code: code, status: .invalidFormat) + } + if pathHashes.contains(hashData) { + return HopCodeClassification(code: code, status: .alreadyInPath) + } + guard let resolved = resolve(hashData) else { + return HopCodeClassification(code: code, status: .notFound) + } + if let remainingCapacity, added >= remainingCapacity { + return HopCodeClassification(code: code, status: .pathFull) + } + added += 1 + pathHashes.insert(hashData) + let hop = PathHop(hashBytes: hashData, publicKey: resolved.publicKey, resolvedName: resolved.name) + return HopCodeClassification(code: code, status: .willAdd(hop)) } + } - /// Parse a hex `code` into exactly `hashSize` bytes, or `nil` if malformed. - private static func parseHex(_ code: String, hashSize: Int) -> Data? { - guard code.count == hashSize * 2, code.allSatisfy(\.isHexDigit) else { return nil } - var data = Data() - var idx = code.startIndex - while idx < code.endIndex { - let next = code.index(idx, offsetBy: 2) - guard let byte = UInt8(code[idx.. Data? { + guard code.count == hashSize * 2, code.allSatisfy(\.isHexDigit) else { return nil } + var data = Data() + var idx = code.startIndex + while idx < code.endIndex { + let next = code.index(idx, offsetBy: 2) + guard let byte = UInt8(code[idx.. Bool { - !query.isEmpty && query.allSatisfy(\.isHexDigit) - } + /// True when `query` is non-empty and every character is a hex digit. + /// All-digit names like "1234" therefore match both name and pubkey branches — + /// acceptable since both surfaces produce the same row. + static func isHexQuery(_ query: String) -> Bool { + !query.isEmpty && query.allSatisfy(\.isHexDigit) + } - /// True if `query` is empty, matches the node name as a substring, or (for a - /// hex query) prefixes the public-key hex. Name matching uses - /// `range(of:options:)` with `[.caseInsensitive, .diacriticInsensitive]` so - /// Turkish İ/ı and NFC/NFD Cyrillic fold correctly regardless of runtime locale. - static func matches(_ node: PickerNode, query: String) -> Bool { - guard !query.isEmpty else { return true } - let nameHit = node.displayName.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil - if isHexQuery(query) { - return nameHit || node.publicKeyHex.lowercased().hasPrefix(query.lowercased()) - } - return nameHit + /// True if `query` is empty, matches the node name as a substring, or (for a + /// hex query) prefixes the public-key hex. Name matching uses + /// `range(of:options:)` with `[.caseInsensitive, .diacriticInsensitive]` so + /// Turkish İ/ı and NFC/NFD Cyrillic fold correctly regardless of runtime locale. + static func matches(_ node: PickerNode, query: String) -> Bool { + guard !query.isEmpty else { return true } + let nameHit = node.displayName.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil + if isHexQuery(query) { + return nameHit || node.publicKeyHex.lowercased().hasPrefix(query.lowercased()) } + return nameHit + } - /// Filter `nodes` to those matching `query`, preserving order. - static func filtered(_ nodes: [PickerNode], by query: String) -> [PickerNode] { - guard !query.isEmpty else { return nodes } - return nodes.filter { matches($0, query: query) } - } + /// Filter `nodes` to those matching `query`, preserving order. + static func filtered(_ nodes: [PickerNode], by query: String) -> [PickerNode] { + guard !query.isEmpty else { return nodes } + return nodes.filter { matches($0, query: query) } + } } diff --git a/MC1/Views/PathEditing/HopPickerSource.swift b/MC1/Views/PathEditing/HopPickerSource.swift index 22d8ba7a..aa829f89 100644 --- a/MC1/Views/PathEditing/HopPickerSource.swift +++ b/MC1/Views/PathEditing/HopPickerSource.swift @@ -7,38 +7,38 @@ import MC1Services /// either concretely. @MainActor protocol HopPickerSource: AnyObject { - var availableRepeaters: [ContactDTO] { get } - var availableRooms: [ContactDTO] { get } - var discoveredRepeaters: [DiscoveredNodeDTO] { get } - var recentPublicKeys: [Data] { get } + var availableRepeaters: [ContactDTO] { get } + var availableRooms: [ContactDTO] { get } + var discoveredRepeaters: [DiscoveredNodeDTO] { get } + var recentPublicKeys: [Data] { get } - /// Hops currently in the path being built. - var currentHopCount: Int { get } - /// Maximum hops the path can hold, or `nil` when unlimited (trace). - var hopLimit: Int? { get } - /// Whether no further hops can be added. Defaults to `currentHopCount >= hopLimit`. - var isPathFull: Bool { get } + /// Hops currently in the path being built. + var currentHopCount: Int { get } + /// Maximum hops the path can hold, or `nil` when unlimited (trace). + var hopLimit: Int? { get } + /// Whether no further hops can be added. Defaults to `currentHopCount >= hopLimit`. + var isPathFull: Bool { get } - /// Append a single node to the path and record it as recent. - func appendHop(_ node: some RepeaterResolvable) - /// Add every resolvable code from a comma-separated bulk entry, honoring the hop cap. - func addCodes(_ input: String) -> CodeInputResult - /// Classify a comma-separated bulk entry per code without mutating the path, - /// for the bulk-add preview panel. - func classifyCodes(_ input: String) -> [HopCodeClassification] + /// Append a single node to the path and record it as recent. + func appendHop(_ node: some RepeaterResolvable) + /// Add every resolvable code from a comma-separated bulk entry, honoring the hop cap. + func addCodes(_ input: String) -> CodeInputResult + /// Classify a comma-separated bulk entry per code without mutating the path, + /// for the bulk-add preview panel. + func classifyCodes(_ input: String) -> [HopCodeClassification] - /// Adopt a hash size inferred from a bulk paste when the source supports a - /// per-entry override (trace). Fixed-width sources ignore it. - func adoptHashSize(forPastedCodes input: String) + /// Adopt a hash size inferred from a bulk paste when the source supports a + /// per-entry override (trace). Fixed-width sources ignore it. + func adoptHashSize(forPastedCodes input: String) } extension HopPickerSource { - /// A path with no `hopLimit` is never full; otherwise it fills at the cap. - var isPathFull: Bool { - guard let hopLimit else { return false } - return currentHopCount >= hopLimit - } + /// A path with no `hopLimit` is never full; otherwise it fills at the cap. + var isPathFull: Bool { + guard let hopLimit else { return false } + return currentHopCount >= hopLimit + } - /// Sources with a fixed hash size (the contact path editor) take no action. - func adoptHashSize(forPastedCodes input: String) {} + /// Sources with a fixed hash size (the contact path editor) take no action. + func adoptHashSize(forPastedCodes input: String) {} } diff --git a/MC1/Views/PathEditing/NodeKindBadge.swift b/MC1/Views/PathEditing/NodeKindBadge.swift index 5e7c9276..63f4f0c6 100644 --- a/MC1/Views/PathEditing/NodeKindBadge.swift +++ b/MC1/Views/PathEditing/NodeKindBadge.swift @@ -2,15 +2,15 @@ import SwiftUI /// Capsule badge for labeling node types (room, discovered, etc.) struct NodeKindBadge: View { - let text: String - let color: Color + let text: String + let color: Color - var body: some View { - Text(text) - .font(.caption2.weight(.medium)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(color.opacity(0.15), in: .capsule) - .foregroundStyle(color) - } + var body: some View { + Text(text) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15), in: .capsule) + .foregroundStyle(color) + } } diff --git a/MC1/Views/PathEditing/PathEditCTALabel.swift b/MC1/Views/PathEditing/PathEditCTALabel.swift index 28ad5eca..921f11a1 100644 --- a/MC1/Views/PathEditing/PathEditCTALabel.swift +++ b/MC1/Views/PathEditing/PathEditCTALabel.swift @@ -6,19 +6,19 @@ import SwiftUI /// collapsing the SF Symbol to zero width; the spacers center the intrinsic-width /// pair within the stretched frame. struct PathEditCTALabel: View { - let title: String - let systemImage: String + let title: String + let systemImage: String - var body: some View { - HStack(spacing: PathEditMetrics.ctaIconSpacing) { - Spacer() - Image(systemName: systemImage) - .font(.body.weight(.semibold)) - .frame(width: PathEditMetrics.ctaIconSize, height: PathEditMetrics.ctaIconSize) - Text(title) - .font(.body.weight(.semibold)) - Spacer() - } - .frame(maxWidth: .infinity) + var body: some View { + HStack(spacing: PathEditMetrics.ctaIconSpacing) { + Spacer() + Image(systemName: systemImage) + .font(.body.weight(.semibold)) + .frame(width: PathEditMetrics.ctaIconSize, height: PathEditMetrics.ctaIconSize) + Text(title) + .font(.body.weight(.semibold)) + Spacer() } + .frame(maxWidth: .infinity) + } } diff --git a/MC1/Views/PathEditing/PathEditMetrics.swift b/MC1/Views/PathEditing/PathEditMetrics.swift index c70607d9..469949a8 100644 --- a/MC1/Views/PathEditing/PathEditMetrics.swift +++ b/MC1/Views/PathEditing/PathEditMetrics.swift @@ -4,21 +4,21 @@ import SwiftUI /// `AddHopSegmentPicker`. Top-level `private` in Swift is file-scoped, so this /// type must be `internal` to be referenced from the other files. enum PathEditMetrics { - /// 48pt tap target — glove-safe, above HIG's 44pt minimum. - static let tapTarget: CGFloat = 48 - static let rowInset: CGFloat = 16 - static let rowVerticalPadding: CGFloat = 8 - static let rowContentSpacing: CGFloat = 12 - static let badgeSpacing: CGFloat = 6 - static let segmentPickerVerticalInset: CGFloat = 8 - static let disabledOpacity: Double = 0.5 + /// 48pt tap target — glove-safe, above HIG's 44pt minimum. + static let tapTarget: CGFloat = 48 + static let rowInset: CGFloat = 16 + static let rowVerticalPadding: CGFloat = 8 + static let rowContentSpacing: CGFloat = 12 + static let badgeSpacing: CGFloat = 6 + static let segmentPickerVerticalInset: CGFloat = 8 + static let disabledOpacity: Double = 0.5 - /// Add Hop CTA label: icon box size and icon-to-text spacing. - static let ctaIconSize: CGFloat = 22 - static let ctaIconSpacing: CGFloat = 8 + /// Add Hop CTA label: icon box size and icon-to-text spacing. + static let ctaIconSize: CGFloat = 22 + static let ctaIconSpacing: CGFloat = 8 - /// Row insets for the full-width Add Hop / routing CTA buttons. - static var ctaRowInsets: EdgeInsets { - EdgeInsets(top: rowVerticalPadding, leading: rowInset, bottom: rowVerticalPadding, trailing: rowInset) - } + /// Row insets for the full-width Add Hop / routing CTA buttons. + static var ctaRowInsets: EdgeInsets { + EdgeInsets(top: rowVerticalPadding, leading: rowInset, bottom: rowVerticalPadding, trailing: rowInset) + } } diff --git a/MC1/Views/PathEditing/PathEditingSheet.swift b/MC1/Views/PathEditing/PathEditingSheet.swift index 0262e76a..af79cef7 100644 --- a/MC1/Views/PathEditing/PathEditingSheet.swift +++ b/MC1/Views/PathEditing/PathEditingSheet.swift @@ -5,266 +5,266 @@ import SwiftUI /// 1. Main screen (this view) — ordered hops, primary CTA, empty-state actions /// 2. Add Hop picker — pushed via navigationDestination(item:) (see `AddHopPickerView`) struct PathEditingSheet: View { - /// Firmware path-length sentinel: 0x00 = direct routing (no repeaters). - /// Distinct from the flood sentinel 0xFF and from `encodePathLen` output, - /// which only uses values where the top two bits encode the hash mode. - private static let directRoutingPathLength: UInt8 = 0x00 + /// Firmware path-length sentinel: 0x00 = direct routing (no repeaters). + /// Distinct from the flood sentinel 0xFF and from `encodePathLen` output, + /// which only uses values where the top two bits encode the hash mode. + private static let directRoutingPathLength: UInt8 = 0x00 - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @Bindable var viewModel: PathManagementViewModel - let contact: ContactDTO + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Bindable var viewModel: PathManagementViewModel + let contact: ContactDTO - @State private var dragHapticTrigger = 0 - @State private var deleteHapticTrigger = 0 - @State private var saveCompletedToken = 0 - @State private var routingConfirmedToken = 0 + @State private var dragHapticTrigger = 0 + @State private var deleteHapticTrigger = 0 + @State private var saveCompletedToken = 0 + @State private var routingConfirmedToken = 0 - @State private var showingDirectConfirmation = false - @State private var showingFloodConfirmation = false + @State private var showingDirectConfirmation = false + @State private var showingFloodConfirmation = false - var body: some View { - NavigationStack { - List { - headerSection - .themedRowBackground(theme) - if viewModel.editablePath.isEmpty { - emptyStateSection - } else { - currentPathSection - .themedRowBackground(theme) - addHopCtaSection - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.PathEdit.title) - .navigationBarTitleDisplayMode(.inline) - .environment(\.editMode, .constant(.active)) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Contacts.Contacts.Common.cancel) { dismiss() } - } - if !viewModel.editablePath.isEmpty { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Contacts.Contacts.Common.save) { - Task { - await viewModel.saveEditedPath(for: contact) - guard viewModel.errorMessage == nil else { return } - saveCompletedToken += 1 - dismiss() - } - } - } - } - } - .addHopPicker(for: $viewModel.insertionIntent, source: viewModel) - .sensoryFeedback(.impact(weight: .light), trigger: dragHapticTrigger) - .sensoryFeedback(.impact(weight: .medium), trigger: deleteHapticTrigger) - .sensoryFeedback(.success, trigger: saveCompletedToken) - .sensoryFeedback(.selection, trigger: routingConfirmedToken) - .alert( - L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.title, - isPresented: $showingDirectConfirmation - ) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} - Button(L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.confirm, role: .destructive) { - Task { - await viewModel.setPath( - for: contact, - path: Data(), - pathLength: Self.directRoutingPathLength - ) - guard viewModel.errorMessage == nil else { return } - routingConfirmedToken += 1 - dismiss() - } - } - } message: { - Text(L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.message( - contact.displayName, - contact.displayName - )) - } - .alert( - L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.title, - isPresented: $showingFloodConfirmation - ) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} - Button(L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.confirm, role: .destructive) { - Task { - await viewModel.resetPath(for: contact) - guard viewModel.errorMessage == nil else { return } - routingConfirmedToken += 1 - dismiss() - } - } - } message: { - Text(L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.message( - contact.displayName - )) + var body: some View { + NavigationStack { + List { + headerSection + .themedRowBackground(theme) + if viewModel.editablePath.isEmpty { + emptyStateSection + } else { + currentPathSection + .themedRowBackground(theme) + addHopCtaSection + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.PathEdit.title) + .navigationBarTitleDisplayMode(.inline) + .environment(\.editMode, .constant(.active)) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Common.cancel) { dismiss() } + } + if !viewModel.editablePath.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Common.save) { + Task { + await viewModel.saveEditedPath(for: contact) + guard viewModel.errorMessage == nil else { return } + saveCompletedToken += 1 + dismiss() + } } + } + } + } + .addHopPicker(for: $viewModel.insertionIntent, source: viewModel) + .sensoryFeedback(.impact(weight: .light), trigger: dragHapticTrigger) + .sensoryFeedback(.impact(weight: .medium), trigger: deleteHapticTrigger) + .sensoryFeedback(.success, trigger: saveCompletedToken) + .sensoryFeedback(.selection, trigger: routingConfirmedToken) + .alert( + L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.title, + isPresented: $showingDirectConfirmation + ) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.confirm, role: .destructive) { + Task { + await viewModel.setPath( + for: contact, + path: Data(), + pathLength: Self.directRoutingPathLength + ) + guard viewModel.errorMessage == nil else { return } + routingConfirmedToken += 1 + dismiss() + } } - .presentationDragIndicator(.visible) - .presentationSizing(.page) + } message: { + Text(L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.message( + contact.displayName, + contact.displayName + )) + } + .alert( + L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.title, + isPresented: $showingFloodConfirmation + ) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.confirm, role: .destructive) { + Task { + await viewModel.resetPath(for: contact) + guard viewModel.errorMessage == nil else { return } + routingConfirmedToken += 1 + dismiss() + } + } + } message: { + Text(L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.message( + contact.displayName + )) + } } + .presentationDragIndicator(.visible) + .presentationSizing(.page) + } - // MARK: - Sections + // MARK: - Sections - private var headerSection: some View { - Section { - Text(L10n.Contacts.Contacts.PathEdit.description(contact.displayName)) - .font(.subheadline) - .foregroundStyle(.secondary) - } + private var headerSection: some View { + Section { + Text(L10n.Contacts.Contacts.PathEdit.description(contact.displayName)) + .font(.subheadline) + .foregroundStyle(.secondary) } + } - /// Ordered hops with drag-to-reorder and swipe-to-delete. - /// Uses `.onDelete` (not `.swipeActions`) because the list is in - /// `.editMode == .active` for the always-visible drag handles, and - /// active edit mode suppresses `.swipeActions`. `.onDelete` provides - /// both the leading minus-circle and the trailing swipe gesture. - private var currentPathSection: some View { - Section { - ForEach(Array(viewModel.editablePath.enumerated()), id: \.element.id) { index, hop in - PathHopRow( - hop: hop, - index: index, - totalCount: viewModel.editablePath.count - ) - } - .onMove { source, destination in - dragHapticTrigger += 1 - viewModel.moveRepeater(from: source, to: destination) - } - .onDelete { indexSet in - deleteHapticTrigger += 1 - for index in indexSet.sorted().reversed() { - viewModel.removeRepeater(at: index) - } - } - } header: { - Text(L10n.Contacts.Contacts.PathEdit.currentPath) - } footer: { - Text(L10n.Contacts.Contacts.PathEdit.reorderHint) + /// Ordered hops with drag-to-reorder and swipe-to-delete. + /// Uses `.onDelete` (not `.swipeActions`) because the list is in + /// `.editMode == .active` for the always-visible drag handles, and + /// active edit mode suppresses `.swipeActions`. `.onDelete` provides + /// both the leading minus-circle and the trailing swipe gesture. + private var currentPathSection: some View { + Section { + ForEach(Array(viewModel.editablePath.enumerated()), id: \.element.id) { index, hop in + PathHopRow( + hop: hop, + index: index, + totalCount: viewModel.editablePath.count + ) + } + .onMove { source, destination in + dragHapticTrigger += 1 + viewModel.moveRepeater(from: source, to: destination) + } + .onDelete { indexSet in + deleteHapticTrigger += 1 + for index in indexSet.sorted().reversed() { + viewModel.removeRepeater(at: index) } + } + } header: { + Text(L10n.Contacts.Contacts.PathEdit.currentPath) + } footer: { + Text(L10n.Contacts.Contacts.PathEdit.reorderHint) } + } - private var addHopCtaSection: some View { - Section { - Button { - viewModel.insertionIntent = .append - } label: { - PathEditCTALabel( - title: viewModel.isPathFull - ? L10n.Contacts.Contacts.PathEdit.MaxHops.reached - : L10n.Contacts.Contacts.PathEdit.addHop, - systemImage: viewModel.isPathFull ? "checkmark.circle" : "plus.circle.fill" - ) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(viewModel.isPathFull) - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - } footer: { - if viewModel.isPathFull { - Text(L10n.Contacts.Contacts.PathEdit.MaxHops.footer(viewModel.maxHopCount)) - } - } + private var addHopCtaSection: some View { + Section { + Button { + viewModel.insertionIntent = .append + } label: { + PathEditCTALabel( + title: viewModel.isPathFull + ? L10n.Contacts.Contacts.PathEdit.MaxHops.reached + : L10n.Contacts.Contacts.PathEdit.addHop, + systemImage: viewModel.isPathFull ? "checkmark.circle" : "plus.circle.fill" + ) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(viewModel.isPathFull) + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + } footer: { + if viewModel.isPathFull { + Text(L10n.Contacts.Contacts.PathEdit.MaxHops.footer(viewModel.maxHopCount)) + } } + } - @ViewBuilder - private var emptyStateSection: some View { - Section { - ContentUnavailableView { - Label( - L10n.Contacts.Contacts.PathEdit.Empty.title, - systemImage: "antenna.radiowaves.left.and.right.slash" - ) - } description: { - Text(L10n.Contacts.Contacts.PathEdit.Empty.description(contact.displayName)) - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - } + @ViewBuilder + private var emptyStateSection: some View { + Section { + ContentUnavailableView { + Label( + L10n.Contacts.Contacts.PathEdit.Empty.title, + systemImage: "antenna.radiowaves.left.and.right.slash" + ) + } description: { + Text(L10n.Contacts.Contacts.PathEdit.Empty.description(contact.displayName)) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } - Section { - Button { - viewModel.insertionIntent = .append - } label: { - PathEditCTALabel( - title: L10n.Contacts.Contacts.PathEdit.addHop, - systemImage: "plus.circle.fill" - ) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + Section { + Button { + viewModel.insertionIntent = .append + } label: { + PathEditCTALabel( + title: L10n.Contacts.Contacts.PathEdit.addHop, + systemImage: "plus.circle.fill" + ) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) - Button { - showingDirectConfirmation = true - } label: { - PathEditCTALabel( - title: L10n.Contacts.Contacts.PathEdit.useDirectRouting, - systemImage: "person.wave.2" - ) - } - .buttonStyle(.bordered) - .controlSize(.large) - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + Button { + showingDirectConfirmation = true + } label: { + PathEditCTALabel( + title: L10n.Contacts.Contacts.PathEdit.useDirectRouting, + systemImage: "person.wave.2" + ) + } + .buttonStyle(.bordered) + .controlSize(.large) + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) - Button { - showingFloodConfirmation = true - } label: { - PathEditCTALabel( - title: L10n.Contacts.Contacts.PathEdit.useFloodRouting, - systemImage: "dot.radiowaves.left.and.right" - ) - } - .buttonStyle(.bordered) - .controlSize(.large) - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } + Button { + showingFloodConfirmation = true + } label: { + PathEditCTALabel( + title: L10n.Contacts.Contacts.PathEdit.useFloodRouting, + systemImage: "dot.radiowaves.left.and.right" + ) + } + .buttonStyle(.bordered) + .controlSize(.large) + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } + } } // MARK: - Row views /// Row displaying a single hop in the path with an index capsule. private struct PathHopRow: View { - let hop: PathHop - let index: Int - let totalCount: Int + let hop: PathHop + let index: Int + let totalCount: Int - var body: some View { - VStack(alignment: .leading, spacing: 2) { - if let name = hop.resolvedName { - Text(name).font(.body) - Text(hop.hashHex) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } else { - Text(hop.hashHex).font(.body.monospaced()) - } - } - .frame(minHeight: PathEditMetrics.tapTarget) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityDescription) - .accessibilityHint(L10n.Contacts.Contacts.PathEdit.hopHint) + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let name = hop.resolvedName { + Text(name).font(.body) + Text(hop.hashHex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } else { + Text(hop.hashHex).font(.body.monospaced()) + } } + .frame(minHeight: PathEditMetrics.tapTarget) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityDescription) + .accessibilityHint(L10n.Contacts.Contacts.PathEdit.hopHint) + } - private var accessibilityDescription: String { - if let name = hop.resolvedName { - return L10n.Contacts.Contacts.PathEdit.hopWithName(index + 1, totalCount, name) - } else { - return L10n.Contacts.Contacts.PathEdit.hopWithHex(index + 1, totalCount, hop.hashHex) - } + private var accessibilityDescription: String { + if let name = hop.resolvedName { + L10n.Contacts.Contacts.PathEdit.hopWithName(index + 1, totalCount, name) + } else { + L10n.Contacts.Contacts.PathEdit.hopWithHex(index + 1, totalCount, hop.hashHex) } + } } diff --git a/MC1/Views/PathEditing/PathManagementViewModel.swift b/MC1/Views/PathEditing/PathManagementViewModel.swift index 52a1e13d..f36cd52d 100644 --- a/MC1/Views/PathEditing/PathManagementViewModel.swift +++ b/MC1/Views/PathEditing/PathManagementViewModel.swift @@ -1,6 +1,6 @@ -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "PathManagement") @@ -8,651 +8,661 @@ private let logger = Logger(subsystem: "com.mc1", category: "PathManagement") /// Modeled as an enum (rather than `Bool` or `Void`) so the item binding has a /// concrete `Identifiable`/`Hashable` type. Only `.append` exists today. enum AddHopIntent: Hashable, Identifiable { - case append + case append - var id: Self { self } + var id: Self { + self + } } /// Sections the Add-Hop picker can narrow to. `.all` shows every section. enum AddHopFilter: String, CaseIterable, Identifiable { - case all, favorites, recent, discovered + case all, favorites, recent, discovered - var id: String { rawValue } + var id: String { + rawValue + } - var localizedLabel: String { - switch self { - case .all: L10n.Contacts.Contacts.PathEdit.Filter.all - case .favorites: L10n.Contacts.Contacts.PathEdit.Filter.favorites - case .recent: L10n.Contacts.Contacts.PathEdit.Filter.recent - case .discovered: L10n.Contacts.Contacts.PathEdit.Filter.discovered - } + var localizedLabel: String { + switch self { + case .all: L10n.Contacts.Contacts.PathEdit.Filter.all + case .favorites: L10n.Contacts.Contacts.PathEdit.Filter.favorites + case .recent: L10n.Contacts.Contacts.PathEdit.Filter.recent + case .discovered: L10n.Contacts.Contacts.PathEdit.Filter.discovered } + } } /// Represents a single hop in the routing path with stable identity for SwiftUI struct PathHop: Identifiable, Equatable { - let id = UUID() - var hashBytes: Data // Public key prefix bytes (1–3 bytes depending on hash mode) - var publicKey: Data? // Full 32-byte key when known (for unambiguous matching) - var resolvedName: String? // Contact name if resolved, nil if unknown + let id = UUID() + var hashBytes: Data // Public key prefix bytes (1–3 bytes depending on hash mode) + var publicKey: Data? // Full 32-byte key when known (for unambiguous matching) + var resolvedName: String? // Contact name if resolved, nil if unknown - var hashHex: String { - hashBytes.uppercaseHexString() - } + var hashHex: String { + hashBytes.uppercaseHexString() + } - var displayText: String { - if let name = resolvedName { - return "\(name) (\(hashHex))" - } - return hashHex + var displayText: String { + if let name = resolvedName { + return "\(name) (\(hashHex))" } + return hashHex + } } /// Result of a path discovery operation enum PathDiscoveryResult: Equatable { - case success(hopCount: Int) - case noPathFound - case failed(String) - - var description: String { - switch self { - case .success(let hopCount): - if hopCount == 0 { - return L10n.Contacts.Contacts.PathDiscovery.direct - } else if hopCount == 1 { - return L10n.Contacts.Contacts.PathDiscovery.Hops.singular - } else { - return L10n.Contacts.Contacts.PathDiscovery.Hops.plural(hopCount) - } - case .noPathFound: - return L10n.Contacts.Contacts.PathDiscovery.noResponse - case .failed(let message): - return L10n.Contacts.Contacts.PathDiscovery.failed(message) - } - } + case success(hopCount: Int) + case noPathFound + case failed(String) + + var description: String { + switch self { + case let .success(hopCount): + if hopCount == 0 { + L10n.Contacts.Contacts.PathDiscovery.direct + } else if hopCount == 1 { + L10n.Contacts.Contacts.PathDiscovery.Hops.singular + } else { + L10n.Contacts.Contacts.PathDiscovery.Hops.plural(hopCount) + } + case .noPathFound: + L10n.Contacts.Contacts.PathDiscovery.noResponse + case let .failed(message): + L10n.Contacts.Contacts.PathDiscovery.failed(message) + } + } } @Observable @MainActor final class PathManagementViewModel { - // MARK: - State - - var isDiscovering = false - var isSettingPath = false - var discoveryResult: PathDiscoveryResult? - var showDiscoveryResult = false - var errorMessage: String? - - // Path editing state - var showingPathEditor = false - /// Drives navigationDestination(item:) pushing the Add Hop picker. - var insertionIntent: AddHopIntent? - var editablePath: [PathHop] = [] // Current path being edited (stable identifiers) - - /// LRU of recently inserted public keys, most-recent first, capped at 8. - /// Radio-scoped: cleared and reloaded when `loadContacts(radioID:)` fires. - var recentPublicKeys: [Data] = [] - - /// Captured radio scope for persistence. Set by `loadRecentKeys(for:)`. - private var currentRadioID: UUID? - - /// Firmware `outPath` budget: 64 bytes. `encodePathLen` also clamps the - /// hop-count field to 6 bits (0…63). The effective cap is whichever is - /// smaller. - private static let pathByteBudget = 64 - private static let pathHopFieldCap = 63 - - var availableRepeaters: [ContactDTO] = [] // Known repeaters to add - var availableRooms: [ContactDTO] = [] // Known rooms (may act as repeaters) - var allContacts: [ContactDTO] = [] // All contacts for name resolution - var discoveredNodes: [DiscoveredNodeDTO] = [] - - /// Combined repeaters and rooms for resolution - var availableNodes: [ContactDTO] { - availableRepeaters + availableRooms - } - - /// Discovered repeaters available to add - var discoveredRepeaters: [DiscoveredNodeDTO] { - discoveredNodes.filter { $0.nodeType == .repeater } - } - - // Discovery cancellation - private var discoveryTask: Task? - - // Discovery countdown state - var discoverySecondsRemaining: Int? - private var countdownTask: Task? - private var discoveryStartTime: Date? - private var discoveryTimeoutSeconds: Double? - - // MARK: - Dependencies - - // Provider closures are re-evaluated at every use so a disconnect (or a - // container rebuild) between actions is observed live, never a stale snapshot. - private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } - private var contactServiceProvider: @MainActor () -> ContactService? = { nil } - private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } - private let recents: RecentHopsStore - - init(defaults: UserDefaults = .standard) { - self.recents = RecentHopsStore(defaults: defaults) - } - - /// Current hash size from device configuration (1, 2, or 3 bytes per hop). - /// Clamped to `1...3` so a firmware `pathHashMode` of 3 (reserved) never trips - /// `encodePathLen`'s `1...3` precondition on save. - var hashSize: Int { - let raw = connectedDeviceProvider()?.hashSize ?? 1 - return min(max(raw, 1), 3) - } - - /// Maximum number of hops the path can hold under the current `hashSize`. - /// Bounded by both the 6-bit hop-count field (0…63) and the 64-byte path - /// payload. Past this cap, firmware silently truncates. - var maxHopCount: Int { - min(Self.pathHopFieldCap, Self.pathByteBudget / hashSize) - } - - /// True when `editablePath.count` has hit `maxHopCount`. Callers (the - /// Add Hop CTA + picker) disable insert affordances when this is true. - var isPathFull: Bool { - editablePath.count >= maxHopCount - } - - // MARK: - Callbacks - - /// Called when path discovery completes and contact should be refreshed - var onContactNeedsRefresh: (() -> Void)? - - // MARK: - Configuration - - /// Each provider is read live at its point of use; a provider returning - /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. - func configure( - dataStore: @escaping @MainActor () -> PersistenceStore?, - contactService: @escaping @MainActor () -> ContactService?, - connectedDevice: @escaping @MainActor () -> DeviceDTO?, - onContactNeedsRefresh: @escaping () -> Void + // MARK: - State + + var isDiscovering = false + var isSettingPath = false + var discoveryResult: PathDiscoveryResult? + var showDiscoveryResult = false + var errorMessage: String? + + /// Path editing state + var showingPathEditor = false + /// Drives navigationDestination(item:) pushing the Add Hop picker. + var insertionIntent: AddHopIntent? + var editablePath: [PathHop] = [] // Current path being edited (stable identifiers) + + /// LRU of recently inserted public keys, most-recent first, capped at 8. + /// Radio-scoped: cleared and reloaded when `loadContacts(radioID:)` fires. + var recentPublicKeys: [Data] = [] + + /// Captured radio scope for persistence. Set by `loadRecentKeys(for:)`. + private var currentRadioID: UUID? + + /// Firmware `outPath` budget: 64 bytes. `encodePathLen` also clamps the + /// hop-count field to 6 bits (0…63). The effective cap is whichever is + /// smaller. + private static let pathByteBudget = 64 + private static let pathHopFieldCap = 63 + + var availableRepeaters: [ContactDTO] = [] // Known repeaters to add + var availableRooms: [ContactDTO] = [] // Known rooms (may act as repeaters) + var allContacts: [ContactDTO] = [] // All contacts for name resolution + var discoveredNodes: [DiscoveredNodeDTO] = [] + + /// Combined repeaters and rooms for resolution + var availableNodes: [ContactDTO] { + availableRepeaters + availableRooms + } + + /// Discovered repeaters available to add + var discoveredRepeaters: [DiscoveredNodeDTO] { + discoveredNodes.filter { $0.nodeType == .repeater } + } + + /// Discovery cancellation + private var discoveryTask: Task? + + // Discovery countdown state + var discoverySecondsRemaining: Int? + private var countdownTask: Task? + private var discoveryStartTime: Date? + private var discoveryTimeoutSeconds: Double? + + // MARK: - Dependencies + + // Provider closures are re-evaluated at every use so a disconnect (or a + // container rebuild) between actions is observed live, never a stale snapshot. + private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } + private var contactServiceProvider: @MainActor () -> ContactService? = { nil } + private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } + private let recents: RecentHopsStore + + init(defaults: UserDefaults = .standard) { + recents = RecentHopsStore(defaults: defaults) + } + + /// Current hash size from device configuration (1, 2, or 3 bytes per hop). + /// Clamped to `1...3` so a firmware `pathHashMode` of 3 (reserved) never trips + /// `encodePathLen`'s `1...3` precondition on save. + var hashSize: Int { + let raw = connectedDeviceProvider()?.hashSize ?? 1 + return min(max(raw, 1), 3) + } + + /// Maximum number of hops the path can hold under the current `hashSize`. + /// Bounded by both the 6-bit hop-count field (0…63) and the 64-byte path + /// payload. Past this cap, firmware silently truncates. + var maxHopCount: Int { + min(Self.pathHopFieldCap, Self.pathByteBudget / hashSize) + } + + /// True when `editablePath.count` has hit `maxHopCount`. Callers (the + /// Add Hop CTA + picker) disable insert affordances when this is true. + var isPathFull: Bool { + editablePath.count >= maxHopCount + } + + // MARK: - Callbacks + + /// Called when path discovery completes and contact should be refreshed + var onContactNeedsRefresh: (() -> Void)? + + // MARK: - Configuration + + /// Each provider is read live at its point of use; a provider returning + /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. + func configure( + dataStore: @escaping @MainActor () -> PersistenceStore?, + contactService: @escaping @MainActor () -> ContactService?, + connectedDevice: @escaping @MainActor () -> DeviceDTO?, + onContactNeedsRefresh: @escaping () -> Void + ) { + dataStoreProvider = dataStore + contactServiceProvider = contactService + connectedDeviceProvider = connectedDevice + self.onContactNeedsRefresh = onContactNeedsRefresh + } + + // MARK: - Name Resolution + + /// Resolve path hash bytes to a contact name if possible + /// Returns the contact name if exactly one contact matches, otherwise falls back to discovered nodes + func resolveHashToName(_ hashBytes: Data) -> String? { + let matches = allContacts.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } + if matches.count == 1 { + return matches[0].resolvableName + } + // Fall back to discovered nodes with same single-match rule + if matches.isEmpty { + let discoveredMatches = discoveredNodes.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } + if discoveredMatches.count == 1 { + return discoveredMatches[0].resolvableName + } + } + return nil // Ambiguous (multiple matches) or unknown + } + + /// Resolve path hash bytes to a node's full 32-byte public key if exactly one + /// match exists. Populating this on hops loaded from storage lets + /// `saveEditedPath` re-encode the path at the device's current `hashSize` + /// even if the stored path used a smaller size. + func resolveHashToPublicKey(_ hashBytes: Data) -> Data? { + let matches = allContacts.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } + if matches.count == 1 { + return matches[0].publicKey + } + if matches.isEmpty { + let discoveredMatches = discoveredNodes.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } + if discoveredMatches.count == 1 { + return discoveredMatches[0].publicKey + } + } + return nil + } + + /// Create a PathHop from hash bytes, resolving the name and full public key + /// when exactly one contact or discovered node matches the prefix. + func createPathHop(from hashBytes: Data) -> PathHop { + PathHop( + hashBytes: hashBytes, + publicKey: resolveHashToPublicKey(hashBytes), + resolvedName: resolveHashToName(hashBytes) + ) + } + + /// Load all contacts for name resolution and filter repeaters for adding. + /// Always refreshes recents for the given `radioID` so switching radios on + /// a reused view model picks up the right scope even if the contacts cache + /// lets us skip the network fetch. + func loadContacts(radioID: UUID, forceReload: Bool = false) async { + guard let dataStore = dataStoreProvider() else { return } + + loadRecentKeys(for: radioID) + + // Skip if already loaded + if !forceReload, !allContacts.isEmpty { + return + } + + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + allContacts = contacts + availableRepeaters = contacts.filter { $0.type == .repeater } + availableRooms = contacts.filter { $0.type == .room } + let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) + discoveredNodes = nodes + } catch { + allContacts = [] + availableRepeaters = [] + availableRooms = [] + discoveredNodes = [] + } + } + + /// Initialize editable path from contact's current path with name resolution. + /// Resets `insertionIntent` so a stale value from a prior sheet presentation + /// can't auto-push the Add Hop picker on open. + /// + /// Hops are normalized to the device's current ``hashSize`` so the editor + /// never shows mixed-width hops after a `pathHashMode` change between edits. + /// Resolvable hops re-slice from their full `publicKey`; unresolvable wider + /// hops narrow via truncation; unresolvable narrower hops are left as-is so + /// ``saveRejection`` can flag them on save. + func initializeEditablePath(from contact: ContactDTO) { + insertionIntent = nil + let byteLength = contact.pathByteLength + let storedHashSize = contact.pathHashSize + let pathData = contact.outPath.prefix(byteLength) + let targetHashSize = hashSize + editablePath = stride(from: 0, to: pathData.count, by: storedHashSize).map { start in + let end = min(start + storedHashSize, pathData.count) + let bytes = Data(pathData[start.. PathHop { + precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") + if let publicKey = hop.publicKey { + return PathHop( + hashBytes: Data(publicKey.prefix(targetHashSize)), + publicKey: publicKey, + resolvedName: hop.resolvedName + ) + } + if hop.hashBytes.count > targetHashSize { + return PathHop( + hashBytes: Data(hop.hashBytes.prefix(targetHashSize)), + publicKey: nil, + resolvedName: hop.resolvedName + ) + } + return hop + } + + /// Append a node to the path. No-op when `isPathFull`. Records the pubkey + /// in recents. The picker stays on screen so users can add several hops in + /// a row; `insertionIntent` is cleared only when the user taps back + /// (SwiftUI writes `nil` through the navigation binding). + func insert(_ node: some RepeaterResolvable, at intent: AddHopIntent) { + guard !isPathFull else { return } + let hashBytes = Data(node.publicKey.prefix(hashSize)) + let hop = PathHop( + hashBytes: hashBytes, + publicKey: node.publicKey, + resolvedName: node.resolvableName + ) + switch intent { + case .append: + editablePath.append(hop) + } + recordRecent(node.publicKey) + } + + // MARK: - Recents persistence + + /// Internal — tests invoke this directly via `@testable import MC1` to seed + /// a specific radio scope without building a full `AppState`. + func loadRecentKeys(for radioID: UUID) { + currentRadioID = radioID + recentPublicKeys = recents.load(for: radioID) + } + + /// LRU insert via ``RecentHopsStore``, persisting under the captured radio scope. + func recordRecent(_ publicKey: Data) { + guard let radioID = currentRadioID else { return } + recentPublicKeys = recents.record(publicKey, into: recentPublicKeys, for: radioID) + } + + /// Remove a repeater from the path at index + func removeRepeater(at index: Int) { + guard editablePath.indices.contains(index) else { return } + editablePath.remove(at: index) + } + + /// Move a repeater within the path + func moveRepeater(from source: IndexSet, to destination: Int) { + editablePath.move(fromOffsets: source, toOffset: destination) + } + + /// Reason a candidate edit cannot be saved, or `nil` if encoding is safe. + /// Pure so tests can drive every branch without an `AppState`. + /// + /// - Precondition: `targetHashSize` is 1, 2, or 3. Matches `encodePathLen`'s + /// contract so bypassing the production call site (which clamps via + /// `hashSize`) still trips a clear assertion rather than a deep crash. + nonisolated static func saveRejection( + for hops: [PathHop], + targetHashSize: Int, + maxHopCount: Int + ) -> String? { + precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") + // `Data.prefix(n)` returns `min(count, n)` bytes — no padding. A hop + // whose stored `hashBytes` are narrower than `targetHashSize` and has no + // full `publicKey` to widen from would serialise short, and firmware + // would mis-parse the path against the declared `pathLength`. + let hopNeedsResize = hops.contains { hop in + hop.publicKey == nil && hop.hashBytes.count < targetHashSize + } + if hopNeedsResize { + return L10n.Contacts.Contacts.PathManagement.Error.hopResizeRequired + } + if hops.count > maxHopCount { + return L10n.Contacts.Contacts.PathManagement.Error.tooManyHops(maxHopCount) + } + return nil + } + + /// Encode the path payload at the device's current hash size. Prefers the + /// full 32-byte public key when available; falls back to stored + /// `hashBytes`. Callers must have called `saveRejection` first to ensure + /// the fallback never truncates below `targetHashSize`. + /// + /// - Precondition: `targetHashSize` is 1, 2, or 3. + nonisolated static func encodeEditablePath( + _ hops: [PathHop], + targetHashSize: Int + ) -> (path: Data, length: UInt8) { + precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") + let pathData = Data(hops.flatMap { hop -> Data in + if let publicKey = hop.publicKey { + return publicKey.prefix(targetHashSize) + } + return hop.hashBytes.prefix(targetHashSize) + }) + let pathLength = encodePathLen(hashSize: targetHashSize, hopCount: hops.count) + return (pathData, pathLength) + } + + /// Save the edited path to the contact. Hops may have been loaded from a + /// stored path with a smaller `hashSize` than the device currently reports + /// (user changed `pathHashMode`). `saveRejection` catches the cases where + /// we can't safely re-encode; otherwise `encodeEditablePath` re-encodes + /// every hop at the current `hashSize`. + func saveEditedPath(for contact: ContactDTO) async { + guard let contactService = contactServiceProvider() else { return } + + errorMessage = nil + let targetHashSize = hashSize + + if let rejection = Self.saveRejection( + for: editablePath, + targetHashSize: targetHashSize, + maxHopCount: maxHopCount ) { - dataStoreProvider = dataStore - contactServiceProvider = contactService - connectedDeviceProvider = connectedDevice - self.onContactNeedsRefresh = onContactNeedsRefresh - } - - // MARK: - Name Resolution - - /// Resolve path hash bytes to a contact name if possible - /// Returns the contact name if exactly one contact matches, otherwise falls back to discovered nodes - func resolveHashToName(_ hashBytes: Data) -> String? { - let matches = allContacts.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } - if matches.count == 1 { - return matches[0].resolvableName - } - // Fall back to discovered nodes with same single-match rule - if matches.isEmpty { - let discoveredMatches = discoveredNodes.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } - if discoveredMatches.count == 1 { - return discoveredMatches[0].resolvableName - } - } - return nil // Ambiguous (multiple matches) or unknown + errorMessage = rejection + return } - /// Resolve path hash bytes to a node's full 32-byte public key if exactly one - /// match exists. Populating this on hops loaded from storage lets - /// `saveEditedPath` re-encode the path at the device's current `hashSize` - /// even if the stored path used a smaller size. - func resolveHashToPublicKey(_ hashBytes: Data) -> Data? { - let matches = allContacts.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } - if matches.count == 1 { - return matches[0].publicKey - } - if matches.isEmpty { - let discoveredMatches = discoveredNodes.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } - if discoveredMatches.count == 1 { - return discoveredMatches[0].publicKey - } - } - return nil - } + isSettingPath = true - /// Create a PathHop from hash bytes, resolving the name and full public key - /// when exactly one contact or discovered node matches the prefix. - func createPathHop(from hashBytes: Data) -> PathHop { - PathHop( - hashBytes: hashBytes, - publicKey: resolveHashToPublicKey(hashBytes), - resolvedName: resolveHashToName(hashBytes) - ) + do { + let encoded = Self.encodeEditablePath(editablePath, targetHashSize: targetHashSize) + try await contactService.setPath( + radioID: contact.radioID, + publicKey: contact.publicKey, + path: encoded.path, + pathLength: encoded.length + ) + onContactNeedsRefresh?() + } catch { + errorMessage = L10n.Contacts.Contacts.PathManagement.Error.saveFailed(error.userFacingMessage) } - /// Load all contacts for name resolution and filter repeaters for adding. - /// Always refreshes recents for the given `radioID` so switching radios on - /// a reused view model picks up the right scope even if the contacts cache - /// lets us skip the network fetch. - func loadContacts(radioID: UUID, forceReload: Bool = false) async { - guard let dataStore = dataStoreProvider() else { return } + isSettingPath = false + } - loadRecentKeys(for: radioID) + // MARK: - Path Operations - // Skip if already loaded - if !forceReload && !allContacts.isEmpty { - return - } + /// Initiate path discovery for a contact (with cancel support). + /// Reports `.noPathFound` if the remote node doesn't respond before the + /// firmware-suggested timeout elapses. + func discoverPath(for contact: ContactDTO) async { + guard let contactService = contactServiceProvider() else { return } - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - allContacts = contacts - availableRepeaters = contacts.filter { $0.type == .repeater } - availableRooms = contacts.filter { $0.type == .room } - let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) - discoveredNodes = nodes - } catch { - allContacts = [] - availableRepeaters = [] - availableRooms = [] - discoveredNodes = [] - } - } + // Cancel any existing discovery + discoveryTask?.cancel() - /// Initialize editable path from contact's current path with name resolution. - /// Resets `insertionIntent` so a stale value from a prior sheet presentation - /// can't auto-push the Add Hop picker on open. - /// - /// Hops are normalized to the device's current ``hashSize`` so the editor - /// never shows mixed-width hops after a `pathHashMode` change between edits. - /// Resolvable hops re-slice from their full `publicKey`; unresolvable wider - /// hops narrow via truncation; unresolvable narrower hops are left as-is so - /// ``saveRejection`` can flag them on save. - func initializeEditablePath(from contact: ContactDTO) { - insertionIntent = nil - let byteLength = contact.pathByteLength - let storedHashSize = contact.pathHashSize - let pathData = contact.outPath.prefix(byteLength) - let targetHashSize = hashSize - editablePath = stride(from: 0, to: pathData.count, by: storedHashSize).map { start in - let end = min(start + storedHashSize, pathData.count) - let bytes = Data(pathData[start.. PathHop { - precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") - if let publicKey = hop.publicKey { - return PathHop( - hashBytes: Data(publicKey.prefix(targetHashSize)), - publicKey: publicKey, - resolvedName: hop.resolvedName - ) - } - if hop.hashBytes.count > targetHashSize { - return PathHop( - hashBytes: Data(hop.hashBytes.prefix(targetHashSize)), - publicKey: nil, - resolvedName: hop.resolvedName - ) - } - return hop - } - - /// Append a node to the path. No-op when `isPathFull`. Records the pubkey - /// in recents. The picker stays on screen so users can add several hops in - /// a row; `insertionIntent` is cleared only when the user taps back - /// (SwiftUI writes `nil` through the navigation binding). - func insert(_ node: some RepeaterResolvable, at intent: AddHopIntent) { - guard !isPathFull else { return } - let hashBytes = Data(node.publicKey.prefix(hashSize)) - let hop = PathHop( - hashBytes: hashBytes, - publicKey: node.publicKey, - resolvedName: node.resolvableName + discoveryTask = Task { @MainActor in + do { + let sentResponse = try await contactService.sendPathDiscovery( + radioID: contact.radioID, + publicKey: contact.publicKey ) - switch intent { - case .append: - editablePath.append(hop) - } - recordRecent(node.publicKey) - } - - // MARK: - Recents persistence - - /// Internal — tests invoke this directly via `@testable import MC1` to seed - /// a specific radio scope without building a full `AppState`. - func loadRecentKeys(for radioID: UUID) { - currentRadioID = radioID - recentPublicKeys = recents.load(for: radioID) - } - - /// LRU insert via ``RecentHopsStore``, persisting under the captured radio scope. - func recordRecent(_ publicKey: Data) { - guard let radioID = currentRadioID else { return } - recentPublicKeys = recents.record(publicKey, into: recentPublicKeys, for: radioID) - } - - /// Remove a repeater from the path at index - func removeRepeater(at index: Int) { - guard editablePath.indices.contains(index) else { return } - editablePath.remove(at: index) - } - - /// Move a repeater within the path - func moveRepeater(from source: IndexSet, to destination: Int) { - editablePath.move(fromOffsets: source, toOffset: destination) - } - - /// Reason a candidate edit cannot be saved, or `nil` if encoding is safe. - /// Pure so tests can drive every branch without an `AppState`. - /// - /// - Precondition: `targetHashSize` is 1, 2, or 3. Matches `encodePathLen`'s - /// contract so bypassing the production call site (which clamps via - /// `hashSize`) still trips a clear assertion rather than a deep crash. - nonisolated static func saveRejection( - for hops: [PathHop], - targetHashSize: Int, - maxHopCount: Int - ) -> String? { - precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") - // `Data.prefix(n)` returns `min(count, n)` bytes — no padding. A hop - // whose stored `hashBytes` are narrower than `targetHashSize` and has no - // full `publicKey` to widen from would serialise short, and firmware - // would mis-parse the path against the declared `pathLength`. - let hopNeedsResize = hops.contains { hop in - hop.publicKey == nil && hop.hashBytes.count < targetHashSize - } - if hopNeedsResize { - return L10n.Contacts.Contacts.PathManagement.Error.hopResizeRequired - } - if hops.count > maxHopCount { - return L10n.Contacts.Contacts.PathManagement.Error.tooManyHops(maxHopCount) - } - return nil - } - - /// Encode the path payload at the device's current hash size. Prefers the - /// full 32-byte public key when available; falls back to stored - /// `hashBytes`. Callers must have called `saveRejection` first to ensure - /// the fallback never truncates below `targetHashSize`. - /// - /// - Precondition: `targetHashSize` is 1, 2, or 3. - nonisolated static func encodeEditablePath( - _ hops: [PathHop], - targetHashSize: Int - ) -> (path: Data, length: UInt8) { - precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") - let pathData = Data(hops.flatMap { hop -> Data in - if let publicKey = hop.publicKey { - return publicKey.prefix(targetHashSize) - } - return hop.hashBytes.prefix(targetHashSize) - }) - let pathLength = encodePathLen(hashSize: targetHashSize, hopCount: hops.count) - return (pathData, pathLength) - } - - /// Save the edited path to the contact. Hops may have been loaded from a - /// stored path with a smaller `hashSize` than the device currently reports - /// (user changed `pathHashMode`). `saveRejection` catches the cases where - /// we can't safely re-encode; otherwise `encodeEditablePath` re-encodes - /// every hop at the current `hashSize`. - func saveEditedPath(for contact: ContactDTO) async { - guard let contactService = contactServiceProvider() else { return } - - errorMessage = nil - let targetHashSize = hashSize - - if let rejection = Self.saveRejection( - for: editablePath, - targetHashSize: targetHashSize, - maxHopCount: maxHopCount - ) { - errorMessage = rejection - return - } - - isSettingPath = true - - do { - let encoded = Self.encodeEditablePath(editablePath, targetHashSize: targetHashSize) - try await contactService.setPath( - radioID: contact.radioID, - publicKey: contact.publicKey, - path: encoded.path, - pathLength: encoded.length - ) - onContactNeedsRefresh?() - } catch { - errorMessage = L10n.Contacts.Contacts.PathManagement.Error.saveFailed(error.userFacingMessage) - } - - isSettingPath = false - } - - // MARK: - Path Operations - - /// Initiate path discovery for a contact (with cancel support). - /// Reports `.noPathFound` if the remote node doesn't respond before the - /// firmware-suggested timeout elapses. - func discoverPath(for contact: ContactDTO) async { - guard let contactService = contactServiceProvider() else { return } - - // Cancel any existing discovery - discoveryTask?.cancel() - - isDiscovering = true - discoveryResult = nil - errorMessage = nil - - discoveryTask = Task { @MainActor in - do { - let sentResponse = try await contactService.sendPathDiscovery( - radioID: contact.radioID, - publicKey: contact.publicKey - ) - - let candidateSeconds = FirmwareSuggestedTimeout.candidateSeconds(suggestedTimeoutMs: sentResponse.suggestedTimeoutMs) - let timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentResponse.suggestedTimeoutMs) - if timeoutSeconds == FirmwareSuggestedTimeout.defaultSeconds && candidateSeconds != timeoutSeconds { - logger.warning( - "Path discovery timeout fallback applied: raw=\(sentResponse.suggestedTimeoutMs)ms, candidate=\(candidateSeconds)s, fallback=\(timeoutSeconds)s" - ) - } else { - logger.info("Path discovery timeout: \(timeoutSeconds)s (firmware suggested: \(sentResponse.suggestedTimeoutMs)ms)") - } - - // Start countdown timer - self.discoveryTimeoutSeconds = timeoutSeconds - self.discoveryStartTime = Date.now - self.discoverySecondsRemaining = Int(timeoutSeconds) - self.startCountdownTask() - - // Wait for push notification with firmware-suggested timeout. - // AdvertisementService handler calls handleDiscoveryResponse() - // which cancels this task early if a response arrives; sleep throws. - try await Task.sleep(for: .seconds(timeoutSeconds)) - - // Timeout: the remote node did not respond - discoveryResult = .noPathFound - showDiscoveryResult = true - } catch is CancellationError { - // Whoever cancelled us (user cancel, push-response handler, or a - // fresh discoverPath re-entry) already resolved state. Bail so - // this task's tail doesn't clobber the newer run's state. - return - } catch { - discoveryResult = .failed(error.userFacingMessage) - showDiscoveryResult = true - } - - isDiscovering = false - cleanupCountdownState() - } - } - - /// Start the countdown task that updates remaining seconds every 5 seconds. - /// Cancels any prior countdown first so a `discoverPath` re-entry can't - /// leave an orphan task writing `discoverySecondsRemaining` alongside the - /// new one — the outer `discoveryTask` cancel doesn't propagate to this - /// sibling. - private func startCountdownTask() { - countdownTask?.cancel() - countdownTask = Task { - while !Task.isCancelled, let timeout = discoveryTimeoutSeconds, let startTime = discoveryStartTime { - do { - try await Task.sleep(for: .seconds(5)) - } catch { - break - } - // Re-check after sleep: cancellation can fire between iterations - // without sleep throwing. Without this, a stale task could write - // a stale remaining value over a fresh discovery's state. - guard !Task.isCancelled else { break } - let elapsed = Date.now.timeIntervalSince(startTime) - let remaining = max(0, Int(timeout - elapsed)) - discoverySecondsRemaining = remaining - } - } - } - - /// Clean up countdown state when discovery ends - private func cleanupCountdownState() { - countdownTask?.cancel() - countdownTask = nil - discoverySecondsRemaining = nil - discoveryStartTime = nil - discoveryTimeoutSeconds = nil - } - /// Cancel an in-progress path discovery - func cancelDiscovery() { - discoveryTask?.cancel() - discoveryTask = nil - isDiscovering = false - cleanupCountdownState() - } - - /// Called when a path discovery response is received via push notification. - /// - /// `hopCount` is `nil` when the wire's `out_path_len` byte used the reserved - /// hash-size mode and couldn't be decoded. Treat that as "no valid path - /// returned" rather than silently reporting a direct route. - func handleDiscoveryResponse(hopCount: Int?) { - discoveryTask?.cancel() - isDiscovering = false - cleanupCountdownState() - - if let hopCount { - discoveryResult = .success(hopCount: hopCount) + let timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds( + suggestedTimeoutMs: sentResponse.suggestedTimeoutMs, + profile: .flood + ) + if sentResponse.suggestedTimeoutMs == 0 { + logger.warning( + "Path discovery timeout default applied: firmware suggested no timeout, using \(timeoutSeconds)s" + ) } else { - discoveryResult = .noPathFound - } - showDiscoveryResult = true - - // Signal that contact data should be refreshed to show new path - onContactNeedsRefresh?() - } - - /// Reset the path for a contact (force flood routing) - func resetPath(for contact: ContactDTO) async { - guard let contactService = contactServiceProvider() else { return } - - isSettingPath = true - errorMessage = nil - - do { - try await contactService.resetPath( - radioID: contact.radioID, - publicKey: contact.publicKey - ) - onContactNeedsRefresh?() - } catch { - errorMessage = L10n.Contacts.Contacts.PathManagement.Error.resetFailed(error.userFacingMessage) + logger.info("Path discovery timeout: \(timeoutSeconds)s (firmware suggested: \(sentResponse.suggestedTimeoutMs)ms)") } - isSettingPath = false - } - - /// Set a specific path for a contact - func setPath(for contact: ContactDTO, path: Data, pathLength: UInt8) async { - guard let contactService = contactServiceProvider() else { return } + // Start countdown timer + self.discoveryTimeoutSeconds = timeoutSeconds + self.discoveryStartTime = Date.now + self.discoverySecondsRemaining = Int(timeoutSeconds) + self.startCountdownTask() - isSettingPath = true - errorMessage = nil + // Wait for push notification with firmware-suggested timeout. + // AdvertisementService handler calls handleDiscoveryResponse() + // which cancels this task early if a response arrives; sleep throws. + try await Task.sleep(for: .seconds(timeoutSeconds)) + // Timeout: the remote node did not respond + discoveryResult = .noPathFound + showDiscoveryResult = true + } catch is CancellationError { + // Whoever cancelled us (user cancel, push-response handler, or a + // fresh discoverPath re-entry) already resolved state. Bail so + // this task's tail doesn't clobber the newer run's state. + return + } catch { + discoveryResult = .failed(error.userFacingMessage) + showDiscoveryResult = true + } + + isDiscovering = false + cleanupCountdownState() + } + } + + /// Start the countdown task that updates remaining seconds every 5 seconds. + /// Cancels any prior countdown first so a `discoverPath` re-entry can't + /// leave an orphan task writing `discoverySecondsRemaining` alongside the + /// new one — the outer `discoveryTask` cancel doesn't propagate to this + /// sibling. + private func startCountdownTask() { + countdownTask?.cancel() + countdownTask = Task { + while !Task.isCancelled, let timeout = discoveryTimeoutSeconds, let startTime = discoveryStartTime { do { - try await contactService.setPath( - radioID: contact.radioID, - publicKey: contact.publicKey, - path: path, - pathLength: pathLength - ) - onContactNeedsRefresh?() + try await Task.sleep(for: .seconds(5)) } catch { - errorMessage = L10n.Contacts.Contacts.PathManagement.Error.setFailed(error.userFacingMessage) + break } - - isSettingPath = false - } + // Re-check after sleep: cancellation can fire between iterations + // without sleep throwing. Without this, a stale task could write + // a stale remaining value over a fresh discovery's state. + guard !Task.isCancelled else { break } + let elapsed = Date.now.timeIntervalSince(startTime) + let remaining = max(0, Int(timeout - elapsed)) + discoverySecondsRemaining = remaining + } + } + } + + /// Clean up countdown state when discovery ends + private func cleanupCountdownState() { + countdownTask?.cancel() + countdownTask = nil + discoverySecondsRemaining = nil + discoveryStartTime = nil + discoveryTimeoutSeconds = nil + } + + /// Cancel an in-progress path discovery + func cancelDiscovery() { + discoveryTask?.cancel() + discoveryTask = nil + isDiscovering = false + cleanupCountdownState() + } + + /// Called when a path discovery response is received via push notification. + /// + /// `hopCount` is `nil` when the wire's `out_path_len` byte used the reserved + /// hash-size mode and couldn't be decoded. Treat that as "no valid path + /// returned" rather than silently reporting a direct route. + func handleDiscoveryResponse(hopCount: Int?) { + discoveryTask?.cancel() + isDiscovering = false + cleanupCountdownState() + + if let hopCount { + discoveryResult = .success(hopCount: hopCount) + } else { + discoveryResult = .noPathFound + } + showDiscoveryResult = true + + // Signal that contact data should be refreshed to show new path + onContactNeedsRefresh?() + } + + /// Reset the path for a contact (force flood routing) + func resetPath(for contact: ContactDTO) async { + guard let contactService = contactServiceProvider() else { return } + + isSettingPath = true + errorMessage = nil + + do { + try await contactService.resetPath( + radioID: contact.radioID, + publicKey: contact.publicKey + ) + onContactNeedsRefresh?() + } catch { + errorMessage = L10n.Contacts.Contacts.PathManagement.Error.resetFailed(error.userFacingMessage) + } + + isSettingPath = false + } + + /// Set a specific path for a contact + func setPath(for contact: ContactDTO, path: Data, pathLength: UInt8) async { + guard let contactService = contactServiceProvider() else { return } + + isSettingPath = true + errorMessage = nil + + do { + try await contactService.setPath( + radioID: contact.radioID, + publicKey: contact.publicKey, + path: path, + pathLength: pathLength + ) + onContactNeedsRefresh?() + } catch { + errorMessage = L10n.Contacts.Contacts.PathManagement.Error.setFailed(error.userFacingMessage) + } + + isSettingPath = false + } } // MARK: - HopPickerSource extension PathManagementViewModel: HopPickerSource { - var currentHopCount: Int { editablePath.count } - - /// Contact paths are capped; expose the cap so the shared picker can show the - /// max-hops state and stop bulk-add cleanly. - var hopLimit: Int? { maxHopCount } - - func appendHop(_ node: some RepeaterResolvable) { - insert(node, at: .append) - } - - func classifyCodes(_ input: String) -> [HopCodeClassification] { - let existing = Set(editablePath.map(\.hashBytes)) - let remaining = max(0, maxHopCount - editablePath.count) - return HopCodeParser.classify( - input: input, - hashSize: hashSize, - existingHashes: existing, - remainingCapacity: remaining - ) { hash in - guard let publicKey = resolveHashToPublicKey(hash) else { return nil } - return (publicKey, resolveHashToName(hash)) - } - } - - /// Bulk-add resolvable codes, honoring the hop cap. Codes past the cap are - /// classified `.pathFull` and skipped, so the path stops cleanly at `maxHopCount`. - @discardableResult - func addCodes(_ input: String) -> CodeInputResult { - var result = CodeInputResult() - for entry in classifyCodes(input) { - switch entry.status { - case .willAdd(let hop): - editablePath.append(hop) - if let publicKey = hop.publicKey { recordRecent(publicKey) } - result.added.append(entry.code) - case .alreadyInPath: result.alreadyInPath.append(entry.code) - case .notFound: result.notFound.append(entry.code) - case .invalidFormat: result.invalidFormat.append(entry.code) - case .pathFull: break - } - } - return result - } + var currentHopCount: Int { + editablePath.count + } + + /// Contact paths are capped; expose the cap so the shared picker can show the + /// max-hops state and stop bulk-add cleanly. + var hopLimit: Int? { + maxHopCount + } + + func appendHop(_ node: some RepeaterResolvable) { + insert(node, at: .append) + } + + func classifyCodes(_ input: String) -> [HopCodeClassification] { + let existing = Set(editablePath.map(\.hashBytes)) + let remaining = max(0, maxHopCount - editablePath.count) + return HopCodeParser.classify( + input: input, + hashSize: hashSize, + existingHashes: existing, + remainingCapacity: remaining + ) { hash in + guard let publicKey = resolveHashToPublicKey(hash) else { return nil } + return (publicKey, resolveHashToName(hash)) + } + } + + /// Bulk-add resolvable codes, honoring the hop cap. Codes past the cap are + /// classified `.pathFull` and skipped, so the path stops cleanly at `maxHopCount`. + @discardableResult + func addCodes(_ input: String) -> CodeInputResult { + var result = CodeInputResult() + for entry in classifyCodes(input) { + switch entry.status { + case let .willAdd(hop): + editablePath.append(hop) + if let publicKey = hop.publicKey { recordRecent(publicKey) } + result.added.append(entry.code) + case .alreadyInPath: result.alreadyInPath.append(entry.code) + case .notFound: result.notFound.append(entry.code) + case .invalidFormat: result.invalidFormat.append(entry.code) + case .pathFull: break + } + } + return result + } } diff --git a/MC1/Views/PathEditing/PickerNode.swift b/MC1/Views/PathEditing/PickerNode.swift index 4f3a4d2e..d42aaf0a 100644 --- a/MC1/Views/PathEditing/PickerNode.swift +++ b/MC1/Views/PathEditing/PickerNode.swift @@ -3,58 +3,58 @@ import MC1Services /// Unified node type for the repeater picker list enum PickerNode: Identifiable { - case contact(ContactDTO) - case discovered(DiscoveredNodeDTO) - - var id: UUID { - switch self { - case .contact(let c): c.id - case .discovered(let d): d.id - } - } + case contact(ContactDTO) + case discovered(DiscoveredNodeDTO) - var displayName: String { - switch self { - case .contact(let c): c.displayName - case .discovered(let d): d.name - } + var id: UUID { + switch self { + case let .contact(c): c.id + case let .discovered(d): d.id } + } - var publicKeyHex: String { - switch self { - case .contact(let c): c.publicKey.uppercaseHexString() - case .discovered(let d): d.publicKey.uppercaseHexString() - } + var displayName: String { + switch self { + case let .contact(c): c.displayName + case let .discovered(d): d.name } + } - var isRoom: Bool { - switch self { - case .contact(let c): c.type == .room - case .discovered: false - } + var publicKeyHex: String { + switch self { + case let .contact(c): c.publicKey.uppercaseHexString() + case let .discovered(d): d.publicKey.uppercaseHexString() } + } - var isDiscovered: Bool { - switch self { - case .contact: false - case .discovered: true - } + var isRoom: Bool { + switch self { + case let .contact(c): c.type == .room + case .discovered: false } + } - /// True iff this node is a contact flagged as a user favorite. - /// Discovered nodes are never favorites — `DiscoveredNodeDTO` has no `isFavorite` field. - var isFavorite: Bool { - switch self { - case .contact(let c): c.isFavorite - case .discovered: false - } + var isDiscovered: Bool { + switch self { + case .contact: false + case .discovered: true + } + } + + /// True iff this node is a contact flagged as a user favorite. + /// Discovered nodes are never favorites — `DiscoveredNodeDTO` has no `isFavorite` field. + var isFavorite: Bool { + switch self { + case let .contact(c): c.isFavorite + case .discovered: false } + } - /// The underlying DTO for passing to ViewModel methods - var underlying: any RepeaterResolvable { - switch self { - case .contact(let c): c - case .discovered(let d): d - } + /// The underlying DTO for passing to ViewModel methods + var underlying: any RepeaterResolvable { + switch self { + case let .contact(c): c + case let .discovered(d): d } + } } diff --git a/MC1/Views/PathEditing/RecentHopsStore.swift b/MC1/Views/PathEditing/RecentHopsStore.swift index 47974661..dad05d40 100644 --- a/MC1/Views/PathEditing/RecentHopsStore.swift +++ b/MC1/Views/PathEditing/RecentHopsStore.swift @@ -8,38 +8,38 @@ import MC1Services /// The owning view model holds the observable `[Data]` array; this type owns only /// load/persist and the LRU transform so SwiftUI observation stays on the view model. struct RecentHopsStore { - /// Frozen storage-key prefix. Existing contact-side recents are persisted under - /// this exact key and must keep loading after the trace side adopts the store. - private static let keyPrefix = "pathEdit.recentPublicKeys." - static let limit = 8 + /// Frozen storage-key prefix. Existing contact-side recents are persisted under + /// this exact key and must keep loading after the trace side adopts the store. + private static let keyPrefix = "pathEdit.recentPublicKeys." + static let limit = 8 - private let defaults: UserDefaults + private let defaults: UserDefaults - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } - static func defaultsKey(for radioID: UUID) -> String { - "\(keyPrefix)\(radioID.uuidString)" - } + static func defaultsKey(for radioID: UUID) -> String { + "\(keyPrefix)\(radioID.uuidString)" + } - /// Load the persisted recents for `radioID`, newest first. - func load(for radioID: UUID) -> [Data] { - let hexList = defaults.stringArray(forKey: Self.defaultsKey(for: radioID)) ?? [] - return hexList.compactMap(Data.init(hexString:)) - } + /// Load the persisted recents for `radioID`, newest first. + func load(for radioID: UUID) -> [Data] { + let hexList = defaults.stringArray(forKey: Self.defaultsKey(for: radioID)) ?? [] + return hexList.compactMap(Data.init(hexString:)) + } - /// LRU-insert `pubkey` into `current`, persist for `radioID`, and return the new list. - /// Moves an existing key to the front rather than duplicating; trims to ``limit``. - /// Stores lowercase hex (`Data.hexString`), which `Data(hexString:)` round-trips case-insensitively. - func record(_ pubkey: Data, into current: [Data], for radioID: UUID) -> [Data] { - var updated = current - updated.removeAll { $0 == pubkey } - updated.insert(pubkey, at: 0) - if updated.count > Self.limit { - updated = Array(updated.prefix(Self.limit)) - } - defaults.set(updated.map(\.hexString), forKey: Self.defaultsKey(for: radioID)) - return updated + /// LRU-insert `pubkey` into `current`, persist for `radioID`, and return the new list. + /// Moves an existing key to the front rather than duplicating; trims to ``limit``. + /// Stores lowercase hex (`Data.hexString`), which `Data(hexString:)` round-trips case-insensitively. + func record(_ pubkey: Data, into current: [Data], for radioID: UUID) -> [Data] { + var updated = current + updated.removeAll { $0 == pubkey } + updated.insert(pubkey, at: 0) + if updated.count > Self.limit { + updated = Array(updated.prefix(Self.limit)) } + defaults.set(updated.map(\.hexString), forKey: Self.defaultsKey(for: radioID)) + return updated + } } diff --git a/MC1/Views/RemoteNodes/ChannelGroup.swift b/MC1/Views/RemoteNodes/ChannelGroup.swift index ad8a7073..3db2cece 100644 --- a/MC1/Views/RemoteNodes/ChannelGroup.swift +++ b/MC1/Views/RemoteNodes/ChannelGroup.swift @@ -1,52 +1,56 @@ import MC1Services struct ChannelGroup: Identifiable { - let channel: Int - let charts: [TelemetryChartGroup] - var id: Int { channel } - - /// Builds channel groups from telemetry entries in the given snapshots, - /// grouping by channel and sensor type, sorted by chart priority then alphabetically. - static func groups(from snapshots: [NodeStatusSnapshotDTO]) -> [ChannelGroup] { - let allEntries = snapshots.flatMap { snapshot in - (snapshot.telemetryEntries ?? []).map { (snapshot: snapshot, entry: $0) } - } - - guard !allEntries.isEmpty else { return [] } - - var channelTypeGroups: [Int: [String: TelemetryChartGroup]] = [:] - - for item in allEntries { - let channel = item.entry.channel - let type = item.entry.type - let sensorType = LPPSensorType(name: type) - let point = MetricChartView.DataPoint( - id: item.snapshot.id, - date: item.snapshot.timestamp, - value: sensorType?.convertedValue(item.entry.value) ?? item.entry.value - ) - - channelTypeGroups[channel, default: [:]][type, default: TelemetryChartGroup( - key: "\(channel)-\(type)", title: sensorType?.localizedName ?? type, sensorType: sensorType, dataPoints: [] - )].dataPoints.append(point) - } - - return channelTypeGroups.keys.sorted().map { channel in - let charts = channelTypeGroups[channel]!.values.sorted { lhs, rhs in - let lhsPriority = lhs.sensorType?.chartSortPriority ?? 1 - let rhsPriority = rhs.sensorType?.chartSortPriority ?? 1 - if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } - return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending - } - return ChannelGroup(channel: channel, charts: charts) - } + let channel: Int + let charts: [TelemetryChartGroup] + var id: Int { + channel + } + + /// Builds channel groups from telemetry entries in the given snapshots, + /// grouping by channel and sensor type, sorted by chart priority then alphabetically. + static func groups(from snapshots: [NodeStatusSnapshotDTO]) -> [ChannelGroup] { + let allEntries = snapshots.flatMap { snapshot in + (snapshot.telemetryEntries ?? []).map { (snapshot: snapshot, entry: $0) } } + + guard !allEntries.isEmpty else { return [] } + + var channelTypeGroups: [Int: [String: TelemetryChartGroup]] = [:] + + for item in allEntries { + let channel = item.entry.channel + let type = item.entry.type + let sensorType = LPPSensorType(name: type) + let point = MetricChartView.DataPoint( + id: item.snapshot.id, + date: item.snapshot.timestamp, + value: sensorType?.convertedValue(item.entry.value) ?? item.entry.value + ) + + channelTypeGroups[channel, default: [:]][type, default: TelemetryChartGroup( + key: "\(channel)-\(type)", title: sensorType?.localizedName ?? type, sensorType: sensorType, dataPoints: [] + )].dataPoints.append(point) + } + + return channelTypeGroups.keys.sorted().map { channel in + let charts = channelTypeGroups[channel]!.values.sorted { lhs, rhs in + let lhsPriority = lhs.sensorType?.chartSortPriority ?? 1 + let rhsPriority = rhs.sensorType?.chartSortPriority ?? 1 + if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + return ChannelGroup(channel: channel, charts: charts) + } + } } struct TelemetryChartGroup: Identifiable { - let key: String - let title: String - let sensorType: LPPSensorType? - var dataPoints: [MetricChartView.DataPoint] - var id: String { key } + let key: String + let title: String + let sensorType: LPPSensorType? + var dataPoints: [MetricChartView.DataPoint] + var id: String { + key + } } diff --git a/MC1/Views/RemoteNodes/DisappearedNeighborRow.swift b/MC1/Views/RemoteNodes/DisappearedNeighborRow.swift index 97acfa5a..035df6b9 100644 --- a/MC1/Views/RemoteNodes/DisappearedNeighborRow.swift +++ b/MC1/Views/RemoteNodes/DisappearedNeighborRow.swift @@ -2,31 +2,31 @@ import MC1Services import SwiftUI struct DisappearedNeighborRow: View { - let neighbor: NeighborSnapshotEntry - let displayName: String - let matchKind: NodeNameMatchKind + let neighbor: NeighborSnapshotEntry + let displayName: String + let matchKind: NodeNameMatchKind - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(displayName) - if matchKind == .fallback { - FallbackMatchIndicatorView( - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, - accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, - title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, - explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation - ) - } - } - Text(L10n.RemoteNodes.RemoteNodes.History.notSeen) - .font(.caption2) - } - Spacer() - Text(L10n.RemoteNodes.RemoteNodes.Status.snrFormat(neighbor.snr.formatted(.number.precision(.fractionLength(1))))) - .font(.caption) + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(displayName) + if matchKind == .fallback { + FallbackMatchIndicatorView( + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, + accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, + title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, + explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation + ) + } } - .foregroundStyle(.tertiary) + Text(L10n.RemoteNodes.RemoteNodes.History.notSeen) + .font(.caption2) + } + Spacer() + Text(L10n.RemoteNodes.RemoteNodes.Status.snrFormat(neighbor.snr.formatted(.number.precision(.fractionLength(1))))) + .font(.caption) } + .foregroundStyle(.tertiary) + } } diff --git a/MC1/Views/RemoteNodes/HistoryTimeRangePicker.swift b/MC1/Views/RemoteNodes/HistoryTimeRangePicker.swift index bbdcd83e..f45c858a 100644 --- a/MC1/Views/RemoteNodes/HistoryTimeRangePicker.swift +++ b/MC1/Views/RemoteNodes/HistoryTimeRangePicker.swift @@ -2,42 +2,42 @@ import SwiftUI /// Time range for filtering history charts. enum HistoryTimeRange: String, CaseIterable { - case week, month, threeMonths, all + case week, month, threeMonths, all - static let `default`: HistoryTimeRange = .month + static let `default`: HistoryTimeRange = .month - var label: String { - switch self { - case .week: L10n.RemoteNodes.RemoteNodes.History.week - case .month: L10n.RemoteNodes.RemoteNodes.History.month - case .threeMonths: L10n.RemoteNodes.RemoteNodes.History.threeMonths - case .all: L10n.RemoteNodes.RemoteNodes.History.all - } + var label: String { + switch self { + case .week: L10n.RemoteNodes.RemoteNodes.History.week + case .month: L10n.RemoteNodes.RemoteNodes.History.month + case .threeMonths: L10n.RemoteNodes.RemoteNodes.History.threeMonths + case .all: L10n.RemoteNodes.RemoteNodes.History.all } + } - var startDate: Date? { - switch self { - case .week: Calendar.current.date(byAdding: .day, value: -7, to: .now) - case .month: Calendar.current.date(byAdding: .month, value: -1, to: .now) - case .threeMonths: Calendar.current.date(byAdding: .month, value: -3, to: .now) - case .all: nil - } + var startDate: Date? { + switch self { + case .week: Calendar.current.date(byAdding: .day, value: -7, to: .now) + case .month: Calendar.current.date(byAdding: .month, value: -1, to: .now) + case .threeMonths: Calendar.current.date(byAdding: .month, value: -3, to: .now) + case .all: nil } + } } /// Segmented picker for selecting a history time range, styled for use in a List section. struct HistoryTimeRangePicker: View { - @Binding var selection: HistoryTimeRange + @Binding var selection: HistoryTimeRange - var body: some View { - Section { - Picker(L10n.RemoteNodes.RemoteNodes.History.timeRange, selection: $selection) { - ForEach(HistoryTimeRange.allCases, id: \.self) { range in - Text(range.label).tag(range) - } - } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) + var body: some View { + Section { + Picker(L10n.RemoteNodes.RemoteNodes.History.timeRange, selection: $selection) { + ForEach(HistoryTimeRange.allCases, id: \.self) { range in + Text(range.label).tag(range) } + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) } + } } diff --git a/MC1/Views/RemoteNodes/MetricChartView.swift b/MC1/Views/RemoteNodes/MetricChartView.swift index 94f5ae05..b441b181 100644 --- a/MC1/Views/RemoteNodes/MetricChartView.swift +++ b/MC1/Views/RemoteNodes/MetricChartView.swift @@ -2,206 +2,361 @@ import Charts import MC1Services import SwiftUI -/// Reusable mini-chart for a single time-series metric. +/// Reusable mini-chart for one or more time-series metrics. +/// +/// Single-series charts render exactly as a one-line chart (no legend). Passing +/// more than one series switches to an overlaid multi-series layout with a +/// categorical foreground scale and legend. struct MetricChartView: View { - let title: String - let unit: String - let dataPoints: [DataPoint] - let accentColor: Color - var yAxisDomain: ClosedRange? - - @State private var selectedDate: Date? - - private var selectedPoint: DataPoint? { - guard let selectedDate else { return nil } - return dataPoints.min { abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate)) } + let title: String + let unit: String + let series: [Series] + var yAxisDomain: ClosedRange? + + @State private var selectedDate: Date? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + MetricChartHeader( + title: title, unit: unit, series: series, + selections: selections, scrubDate: scrubDate + ) + + if !hasEnoughData { + MetricChartEmptyState(value: emptyStateValue, unit: unit) + } else { + MetricChartContent( + title: title, series: drawnSeries, yAxisDomain: yAxisDomain, + selectedDate: $selectedDate, ruleDate: scrubDate, isMultiSeries: isMultiSeries + ) + } } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - MetricChartHeader(title: title, unit: unit, selectedPoint: selectedPoint, accentColor: accentColor) - - if dataPoints.count < 2 { - MetricChartEmptyState(value: dataPoints.first?.value, unit: unit) - } else { - MetricChartContent(title: title, dataPoints: dataPoints, accentColor: accentColor, yAxisDomain: yAxisDomain, selectedDate: $selectedDate, selectedPoint: selectedPoint) - } - } - } - - struct DataPoint: Identifiable { - let id: UUID - let date: Date - let value: Double + } + + private var isMultiSeries: Bool { + series.count > 1 + } + + private var hasEnoughData: Bool { + series.contains { $0.dataPoints.count >= 2 } + } + + /// Series that actually carry points. Empty series draw nothing, so dropping + /// them keeps the categorical scale and legend free of phantom entries. + private var drawnSeries: [Series] { + series.filter { !$0.dataPoints.isEmpty } + } + + /// The single number shown when there are too few points to draw a line, summed + /// across series so an overlaid Direct/Flood chart reports the total, not just Direct. + private var emptyStateValue: Double? { + let firstValues = drawnSeries.compactMap { $0.dataPoints.first?.value } + return firstValues.isEmpty ? nil : firstValues.reduce(0, +) + } + + /// The scrub position snapped to the nearest plotted point's date, so the readout + /// and rule line land on real samples rather than arbitrary times between them. + private var scrubDate: Date? { + guard let selectedDate else { return nil } + return series.flatMap { $0.dataPoints.map(\.date) }.min(by: { + abs($0.timeIntervalSince(selectedDate)) < abs($1.timeIntervalSince(selectedDate)) + }) + } + + /// The nearest point in each series to the snapped scrub date, or empty when not + /// scrubbing. The header shows one value per series. + private var selections: [SeriesSelection] { + guard let scrubDate else { return [] } + return series.compactMap { s in + guard let point = s.dataPoints.min(by: { + abs($0.date.timeIntervalSince(scrubDate)) < abs($1.date.timeIntervalSince(scrubDate)) + }) else { return nil } + return SeriesSelection(series: s, point: point) } + } + + struct DataPoint: Identifiable { + let id: UUID + let date: Date + let value: Double + } + + /// One plotted line in an overlaid chart. `color` drives the direct style for + /// single-series charts and the categorical scale mapping for multi-series. + struct Series { + let name: String + let color: Color + let dataPoints: [DataPoint] + } + + /// A series paired with its nearest point to the current scrub position. + struct SeriesSelection { + let series: Series + let point: DataPoint + } + + /// Single-series convenience initializer wrapping one accent-colored series. + init( + title: String, + unit: String, + dataPoints: [DataPoint], + accentColor: Color, + yAxisDomain: ClosedRange? = nil + ) { + self.title = title + self.unit = unit + self.series = [Series(name: title, color: accentColor, dataPoints: dataPoints)] + self.yAxisDomain = yAxisDomain + } + + /// Multi-series initializer for overlaid charts. + init( + title: String, + unit: String, + series: [Series], + yAxisDomain: ClosedRange? = nil + ) { + self.title = title + self.unit = unit + self.series = series + self.yAxisDomain = yAxisDomain + } } -/// Header row that shows the title, and selected value + timestamp when scrubbing. +/// Header row that shows the title, and selected value(s) + timestamp when scrubbing. private struct MetricChartHeader: View { - let title: String - let unit: String - let selectedPoint: MetricChartView.DataPoint? - let accentColor: Color - - var body: some View { - HStack(alignment: .firstTextBaseline) { - Text(title) - .font(.subheadline) - .bold() - - Spacer() - - if let selectedPoint { - Text("\(selectedPoint.value, format: .number) \(unit)") - .bold() - .foregroundStyle(accentColor) - + Text(" ") - + Text(selectedPoint.date, format: .dateTime.month(.abbreviated).day().hour().minute()) - .foregroundStyle(.secondary) - } + let title: String + let unit: String + let series: [MetricChartView.Series] + let selections: [MetricChartView.SeriesSelection] + let scrubDate: Date? + + private var isMultiSeries: Bool { + series.count > 1 + } + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.subheadline) + .bold() + + Spacer() + + if isMultiSeries { + multiSeriesReadout + } else { + singleSeriesReadout + } + } + .font(.caption) + .animation(.none, value: selections.map(\.point.id)) + } + + @ViewBuilder + private var singleSeriesReadout: some View { + if let selection = selections.first { + Text("\(selection.point.value, format: .number) \(unit)") + .bold() + .foregroundStyle(selection.series.color) + + Text(" ") + + Text(selection.point.date, format: .dateTime.month(.abbreviated).day().hour().minute()) + .foregroundStyle(.secondary) + } + } + + /// Each series' nearest value, color-coded, with the shared scrub timestamp. + @ViewBuilder + private var multiSeriesReadout: some View { + if !selections.isEmpty { + VStack(alignment: .trailing, spacing: 2) { + ForEach(selections, id: \.series.name) { selection in + HStack(spacing: 4) { + Text(selection.series.name) + .foregroundStyle(.secondary) + Text("\(selection.point.value, format: .number)") + .bold() + .foregroundStyle(selection.series.color) + } } - .font(.caption) - .animation(.none, value: selectedPoint?.id) + if let scrubDate { + Text(scrubDate, format: .dateTime.month(.abbreviated).day().hour().minute()) + .foregroundStyle(.secondary) + } + } } + } } /// Chart content with line and point marks. private struct MetricChartContent: View { - let title: String - let dataPoints: [MetricChartView.DataPoint] - let accentColor: Color - let yAxisDomain: ClosedRange? - @Binding var selectedDate: Date? - let selectedPoint: MetricChartView.DataPoint? - - @State private var isScrubbing = false - - var body: some View { - chart - .chartOverlay { proxy in - GeometryReader { geo in - let plotOriginX = proxy.plotFrame.map { geo[$0].origin.x } ?? 0 - Rectangle() - .fill(.clear) - .contentShape(Rectangle()) - .gesture( - ChartScrubGesture( - selectedDate: $selectedDate, - isScrubbing: $isScrubbing, - proxy: proxy, - plotOriginX: plotOriginX - ) - ) - } - } - .sensoryFeedback(.impact, trigger: isScrubbing) { old, new in !old && new } - .preference(key: ChartScrubbingPreferenceKey.self, value: isScrubbing) - .chartYAxis { - AxisMarks(position: .leading) - } - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 4)) { _ in - AxisGridLine() - AxisTick() - AxisValueLabel(format: .dateTime.month(.abbreviated).day()) - } - } - .accessibilityLabel(title) - .frame(height: 180) - } - - @ViewBuilder - private var chart: some View { - let base = Chart { - ForEach(dataPoints) { point in - LineMark( - x: .value("Time", point.date), - y: .value(title, point.value) - ) - .interpolationMethod(.linear) - .foregroundStyle(accentColor.opacity(0.5)) - - PointMark( - x: .value("Time", point.date), - y: .value(title, point.value) - ) - .foregroundStyle(accentColor) - .symbolSize(30) - } - - if let selectedPoint { - RuleMark(x: .value("Selected", selectedPoint.date)) - .foregroundStyle(.secondary.opacity(0.3)) - .lineStyle(StrokeStyle(dash: [4, 4])) - .zIndex(-1) - } + let title: String + let series: [MetricChartView.Series] + let yAxisDomain: ClosedRange? + @Binding var selectedDate: Date? + let ruleDate: Date? + let isMultiSeries: Bool + + @State private var isScrubbing = false + + private var foregroundStyleFor: (String) -> Color { + let lookup = Dictionary(uniqueKeysWithValues: series.map { ($0.name, $0.color) }) + return { (name: String) -> Color in lookup[name] ?? .gray } + } + + var body: some View { + chart + .chartOverlay { proxy in + GeometryReader { geo in + let plotOriginX = proxy.plotFrame.map { geo[$0].origin.x } ?? 0 + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .gesture( + ChartScrubGesture( + selectedDate: $selectedDate, + isScrubbing: $isScrubbing, + proxy: proxy, + plotOriginX: plotOriginX + ) + ) } - - if let yAxisDomain { - base.chartYScale(domain: yAxisDomain) - } else { - base + } + .sensoryFeedback(.impact, trigger: isScrubbing) { old, new in !old && new } + .preference(key: ChartScrubbingPreferenceKey.self, value: isScrubbing) + .chartYAxis { + AxisMarks(position: .leading) + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine() + AxisTick() + AxisValueLabel(format: .dateTime.month(.abbreviated).day()) + } + } + .accessibilityLabel(title) + .frame(height: 180) + } + + @ViewBuilder + private var chart: some View { + let base = Chart { + ForEach(series, id: \.name) { s in + ForEach(s.dataPoints) { point in + if isMultiSeries { + LineMark( + x: .value("Time", point.date), + y: .value(title, point.value) + ) + .interpolationMethod(.linear) + .foregroundStyle(by: .value("Series", s.name)) + + PointMark( + x: .value("Time", point.date), + y: .value(title, point.value) + ) + .foregroundStyle(by: .value("Series", s.name)) + .symbolSize(30) + } else { + LineMark( + x: .value("Time", point.date), + y: .value(title, point.value) + ) + .interpolationMethod(.linear) + .foregroundStyle(s.color.opacity(0.5)) + + PointMark( + x: .value("Time", point.date), + y: .value(title, point.value) + ) + .foregroundStyle(s.color) + .symbolSize(30) + } } + } + + if let ruleDate { + RuleMark(x: .value("Selected", ruleDate)) + .foregroundStyle(.secondary.opacity(0.3)) + .lineStyle(StrokeStyle(dash: [4, 4])) + .zIndex(-1) + } } + + if isMultiSeries { + if let yAxisDomain { + base.chartYScale(domain: yAxisDomain) + .chartForegroundStyleScale(mapping: foregroundStyleFor) + .chartLegend(.visible) + } else { + base.chartForegroundStyleScale(mapping: foregroundStyleFor) + .chartLegend(.visible) + } + } else if let yAxisDomain { + base.chartYScale(domain: yAxisDomain) + } else { + base + } + } } // MARK: - Shared Packet-Count Domain extension [MetricChartView.DataPoint] { - /// Returns a common Y-axis domain spanning `0 ... max * 1.05` across all given arrays, - /// or `nil` when there is no positive max (empty or all-zero data). - static func sharedDomain(for arrays: [[MetricChartView.DataPoint]]) -> ClosedRange? { - let maxVal = arrays.flatMap(\.self).map(\.value).max() - guard let maxVal, maxVal > 0 else { return nil } - return 0 ... maxVal * 1.05 - } + /// Returns a common Y-axis domain spanning `0 ... max * 1.05` across all given arrays, + /// or `nil` when there is no positive max (empty or all-zero data). + static func sharedDomain(for arrays: [[MetricChartView.DataPoint]]) -> ClosedRange? { + let maxVal = arrays.flatMap(\.self).map(\.value).max() + guard let maxVal, maxVal > 0 else { return nil } + return 0...maxVal * 1.05 + } } // MARK: - OCV Chart Domain -extension Array where Element == Int { - /// Computes a chart Y-axis domain in volts from millivolt OCV values, with a ±buffer. - /// Unions the OCV range with actual data points so outliers are never clipped. - func voltageChartDomain( - dataPoints: [MetricChartView.DataPoint] = [], - bufferMV: Int = 500 - ) -> ClosedRange? { - guard let ocvMin = self.min(), let ocvMax = self.max() else { return nil } - var lo = Double(ocvMin) / 1000.0 - var hi = Double(ocvMax) / 1000.0 - let values = dataPoints.map(\.value) - if let dataMin = values.min() { lo = Swift.min(lo, dataMin) } - if let dataMax = values.max() { hi = Swift.max(hi, dataMax) } - let buffer = Double(bufferMV) / 1000.0 - return Swift.max(0, lo - buffer) ... hi + buffer - } +extension [Int] { + /// Computes a chart Y-axis domain in volts from millivolt OCV values, with a ±buffer. + /// Unions the OCV range with actual data points so outliers are never clipped. + func voltageChartDomain( + dataPoints: [MetricChartView.DataPoint] = [], + bufferMV: Int = 500 + ) -> ClosedRange? { + guard let ocvMin = self.min(), let ocvMax = self.max() else { return nil } + var lo = Double(ocvMin) / 1000.0 + var hi = Double(ocvMax) / 1000.0 + let values = dataPoints.map(\.value) + if let dataMin = values.min() { lo = Swift.min(lo, dataMin) } + if let dataMax = values.max() { hi = Swift.max(hi, dataMax) } + let buffer = Double(bufferMV) / 1000.0 + return Swift.max(0, lo - buffer)...hi + buffer + } } // MARK: - Chart Scrubbing Scroll Lock private struct ChartScrubbingPreferenceKey: PreferenceKey { - static let defaultValue = false - static func reduce(value: inout Bool, nextValue: () -> Bool) { - value = value || nextValue() - } + static let defaultValue = false + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = value || nextValue() + } } extension View { - /// Apply to a `List` or `ScrollView` containing `MetricChartView`s to disable - /// scrolling while the user is long-press scrubbing a chart. - func chartScrubbingScrollLock() -> some View { - modifier(ChartScrubbingScrollLockModifier()) - } + /// Apply to a `List` or `ScrollView` containing `MetricChartView`s to disable + /// scrolling while the user is long-press scrubbing a chart. + func chartScrubbingScrollLock() -> some View { + modifier(ChartScrubbingScrollLockModifier()) + } } private struct ChartScrubbingScrollLockModifier: ViewModifier { - @State private var isScrubbing = false + @State private var isScrubbing = false - func body(content: Content) -> some View { - content - .onPreferenceChange(ChartScrubbingPreferenceKey.self) { isScrubbing = $0 } - .scrollDisabled(isScrubbing) - } + func body(content: Content) -> some View { + content + .onPreferenceChange(ChartScrubbingPreferenceKey.self) { isScrubbing = $0 } + .scrollDisabled(isScrubbing) + } } // MARK: - Chart Scrub Gesture @@ -212,66 +367,66 @@ private struct ChartScrubbingScrollLockModifier: ViewModifier { /// The UIKit recognizer's delegate allows proper simultaneous recognition, /// and its `.changed` state reports continuous location updates after recognition. private struct ChartScrubGesture: UIGestureRecognizerRepresentable { - @Binding var selectedDate: Date? - @Binding var isScrubbing: Bool - let proxy: ChartProxy - let plotOriginX: CGFloat - - func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { - let recognizer = UILongPressGestureRecognizer() - recognizer.minimumPressDuration = 0.25 - recognizer.delegate = context.coordinator - return recognizer - } - - func handleUIGestureRecognizerAction(_ recognizer: UILongPressGestureRecognizer, context: Context) { - switch recognizer.state { - case .began: - isScrubbing = true - fallthrough - case .changed: - let x = context.converter.localLocation.x - plotOriginX - if let date: Date = proxy.value(atX: x) { - selectedDate = date - } - case .ended, .cancelled, .failed: - isScrubbing = false - selectedDate = nil - default: - break - } - } - - func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { - Coordinator() + @Binding var selectedDate: Date? + @Binding var isScrubbing: Bool + let proxy: ChartProxy + let plotOriginX: CGFloat + + func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { + let recognizer = UILongPressGestureRecognizer() + recognizer.minimumPressDuration = 0.25 + recognizer.delegate = context.coordinator + return recognizer + } + + func handleUIGestureRecognizerAction(_ recognizer: UILongPressGestureRecognizer, context: Context) { + switch recognizer.state { + case .began: + isScrubbing = true + fallthrough + case .changed: + let x = context.converter.localLocation.x - plotOriginX + if let date: Date = proxy.value(atX: x) { + selectedDate = date + } + case .ended, .cancelled, .failed: + isScrubbing = false + selectedDate = nil + default: + break } - - final class Coordinator: NSObject, UIGestureRecognizerDelegate { - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - !(otherGestureRecognizer is UIScreenEdgePanGestureRecognizer) - } + } + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { + Coordinator() + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + !(otherGestureRecognizer is UIScreenEdgePanGestureRecognizer) } + } } /// Empty state shown when fewer than 2 data points exist. private struct MetricChartEmptyState: View { - let value: Double? - let unit: String - - var body: some View { - VStack { - if let value { - Text("\(value.formatted()) \(unit)") - .font(.title2) - } - Text(L10n.RemoteNodes.RemoteNodes.History.checkBack) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .frame(height: 80) + let value: Double? + let unit: String + + var body: some View { + VStack { + if let value { + Text("\(value.formatted()) \(unit)") + .font(.title2) + } + Text(L10n.RemoteNodes.RemoteNodes.History.checkBack) + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity) + .frame(height: 80) + } } diff --git a/MC1/Views/RemoteNodes/NeighborRow.swift b/MC1/Views/RemoteNodes/NeighborRow.swift index b34093b4..3ab1c65b 100644 --- a/MC1/Views/RemoteNodes/NeighborRow.swift +++ b/MC1/Views/RemoteNodes/NeighborRow.swift @@ -2,96 +2,104 @@ import MC1Services import SwiftUI struct NeighborRow: View { - let neighbor: NeighbourInfo - let displayName: String - let matchKind: NodeNameMatchKind - let previousNeighbor: NeighborSnapshotEntry? - let hasPreviousSnapshot: Bool + let neighbor: NeighbourInfo + let displayName: String + let matchKind: NodeNameMatchKind + let previousNeighbor: NeighborSnapshotEntry? + let isNew: Bool - init( - neighbor: NeighbourInfo, - displayName: String, - matchKind: NodeNameMatchKind, - previousNeighbor: NeighborSnapshotEntry? = nil, - hasPreviousSnapshot: Bool = false - ) { - self.neighbor = neighbor - self.displayName = displayName - self.matchKind = matchKind - self.previousNeighbor = previousNeighbor - self.hasPreviousSnapshot = hasPreviousSnapshot - } + init( + neighbor: NeighbourInfo, + displayName: String, + matchKind: NodeNameMatchKind, + previousNeighbor: NeighborSnapshotEntry? = nil, + isNew: Bool = false + ) { + self.neighbor = neighbor + self.displayName = displayName + self.matchKind = matchKind + self.previousNeighbor = previousNeighbor + self.isNew = isNew + } - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(displayName) + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(displayName) - if hasPreviousSnapshot && previousNeighbor == nil { - Text(L10n.RemoteNodes.RemoteNodes.History.new) - .font(.caption2) - .bold() - .foregroundStyle(.green) - } + if isNew { + Text(L10n.RemoteNodes.RemoteNodes.History.new) + .font(.caption2) + .bold() + .foregroundStyle(.green) + } - if matchKind == .fallback { - FallbackMatchIndicatorView( - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, - accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, - title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, - explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation - ) - } - } + if matchKind == .fallback { + FallbackMatchIndicatorView( + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, + accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, + title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, + explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation + ) + } + } - HStack(spacing: 4) { - Text(firstKeyByte) - .font(.system(.caption2, design: .monospaced)) - Text("·") - Text(lastSeenText) - .font(.caption2) - } - .foregroundStyle(.secondary) - } + HStack(spacing: 4) { + Text(firstKeyByte) + .font(.system(.caption2, design: .monospaced)) + Text("·") + Text(lastSeenText) + .font(.caption2) + } + .foregroundStyle(.secondary) + } - Spacer() + Spacer() - VStack(alignment: .trailing, spacing: 2) { - Image(systemName: "cellularbars", variableValue: snrLevel) - .foregroundStyle(snrColor) + VStack(alignment: .trailing, spacing: 2) { + Image(systemName: "cellularbars", variableValue: snrLevel) + .foregroundStyle(snrColor) - Text(L10n.RemoteNodes.RemoteNodes.Status.snrFormat(neighbor.snr.formatted(.number.precision(.fractionLength(1))))) - .font(.caption) - .foregroundStyle(.secondary) + Text(L10n.RemoteNodes.RemoteNodes.Status.snrFormat(neighbor.snr.formatted(.number.precision(.fractionLength(1))))) + .font(.caption) + .foregroundStyle(.secondary) - if let previous = previousNeighbor { - let snrDelta = neighbor.snr - previous.snr - if abs(snrDelta) >= 0.1 { - StatusDeltaView(delta: snrDelta, higherIsBetter: true, unit: " dB", fractionDigits: 1) - } - } - } + if let previous = previousNeighbor { + let snrDelta = neighbor.snr - previous.snr + if abs(snrDelta) >= 0.1 { + StatusDeltaView(delta: snrDelta, higherIsBetter: true, unit: " dB", fractionDigits: 1) + } } + } } + } - private var firstKeyByte: String { - guard let firstByte = neighbor.publicKeyPrefix.first else { return "" } - return Data([firstByte]).uppercaseHexString() - } + private var firstKeyByte: String { + guard let firstByte = neighbor.publicKeyPrefix.first else { return "" } + return Data([firstByte]).uppercaseHexString() + } - private var lastSeenText: String { - let seconds = neighbor.secondsAgo - if seconds < 60 { - return L10n.RemoteNodes.RemoteNodes.Status.secondsAgo(seconds) - } else if seconds < 3600 { - return L10n.RemoteNodes.RemoteNodes.Status.minutesAgo(seconds / 60) - } else { - return L10n.RemoteNodes.RemoteNodes.Status.hoursAgo(seconds / 3600) - } + private var lastSeenText: String { + let seconds = neighbor.secondsAgo + if seconds < 60 { + return L10n.RemoteNodes.RemoteNodes.Status.secondsAgo(seconds) + } else if seconds < 3600 { + return L10n.RemoteNodes.RemoteNodes.Status.minutesAgo(seconds / 60) + } else { + return L10n.RemoteNodes.RemoteNodes.Status.hoursAgo(seconds / 3600) } + } + + private var snrQuality: SNRQuality { + SNRQuality(snr: neighbor.snr) + } + + private var snrLevel: Double { + snrQuality.barLevel + } - private var snrQuality: SNRQuality { SNRQuality(snr: neighbor.snr) } - private var snrLevel: Double { snrQuality.barLevel } - private var snrColor: Color { snrQuality.color } + private var snrColor: Color { + snrQuality.color + } } diff --git a/MC1/Views/RemoteNodes/NeighborSNRChartView.swift b/MC1/Views/RemoteNodes/NeighborSNRChartView.swift index b11df1a1..e76ec063 100644 --- a/MC1/Views/RemoteNodes/NeighborSNRChartView.swift +++ b/MC1/Views/RemoteNodes/NeighborSNRChartView.swift @@ -2,44 +2,44 @@ import MC1Services import SwiftUI struct NeighborSNRChartView: View { - @Environment(\.appTheme) private var theme - let name: String - let neighborPrefix: Data - let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] + @Environment(\.appTheme) private var theme + let name: String + let neighborPrefix: Data + let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] - @State private var allDataPoints: [MetricChartView.DataPoint] = [] - @State private var timeRange: HistoryTimeRange = .default + @State private var allDataPoints: [MetricChartView.DataPoint] = [] + @State private var timeRange: HistoryTimeRange = .default - private var filteredDataPoints: [MetricChartView.DataPoint] { - guard let start = timeRange.startDate else { return allDataPoints } - return allDataPoints.filter { $0.date >= start } - } + private var filteredDataPoints: [MetricChartView.DataPoint] { + guard let start = timeRange.startDate else { return allDataPoints } + return allDataPoints.filter { $0.date >= start } + } - var body: some View { - List { - HistoryTimeRangePicker(selection: $timeRange) + var body: some View { + List { + HistoryTimeRangePicker(selection: $timeRange) - Section { - MetricChartView( - title: name, - unit: "dB", - dataPoints: filteredDataPoints, - accentColor: .blue - ) - } - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(name) - .liquidGlassToolbarBackground() - .task { - let snapshots = await fetchSnapshots() - allDataPoints = snapshots.compactMap { snapshot in - guard let neighbors = snapshot.neighborSnapshots, - let match = neighbors.first(where: { $0.publicKeyPrefix == neighborPrefix }) - else { return nil } - return MetricChartView.DataPoint(id: snapshot.id, date: snapshot.timestamp, value: match.snr) - } - } + Section { + MetricChartView( + title: name, + unit: "dB", + dataPoints: filteredDataPoints, + accentColor: .blue + ) + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(name) + .liquidGlassToolbarBackground() + .task { + let snapshots = await fetchSnapshots() + allDataPoints = snapshots.compactMap { snapshot in + guard let neighbors = snapshot.neighborSnapshots, + let match = neighbors.first(where: { $0.publicKeyPrefix == neighborPrefix }) + else { return nil } + return MetricChartView.DataPoint(id: snapshot.id, date: snapshot.timestamp, value: match.snr) + } } + } } diff --git a/MC1/Views/RemoteNodes/NodeAuthPathViewModel.swift b/MC1/Views/RemoteNodes/NodeAuthPathViewModel.swift index 2a5c1d09..6fb97100 100644 --- a/MC1/Views/RemoteNodes/NodeAuthPathViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeAuthPathViewModel.swift @@ -6,34 +6,34 @@ import SwiftUI @Observable @MainActor final class NodeAuthPathViewModel { - private(set) var hops: [ResolvedPathHop] = [] + private(set) var hops: [ResolvedPathHop] = [] - private let logger = Logger(subsystem: "com.mc1", category: "NodeAuthPathViewModel") + private let logger = Logger(subsystem: "com.mc1", category: "NodeAuthPathViewModel") - func load( - contact: ContactDTO, - services: ServiceContainer?, - radioID: UUID, - userLocation: CLLocation? - ) async { - guard let services else { - hops = [] - return - } + func load( + contact: ContactDTO, + services: ServiceContainer?, + radioID: UUID, + userLocation: CLLocation? + ) async { + guard let services else { + hops = [] + return + } - do { - let contacts = try await services.dataStore.fetchContacts(radioID: radioID) - let discoveredNodes = try await services.dataStore.fetchDiscoveredNodes(radioID: radioID) + do { + let contacts = try await services.dataStore.fetchContacts(radioID: radioID) + let discoveredNodes = try await services.dataStore.fetchDiscoveredNodes(radioID: radioID) - hops = NeighborNameResolver.resolvePath( - contact.pathHops, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - } catch { - logger.error("Failed to resolve path hops: \(error.localizedDescription)") - hops = [] - } + hops = NeighborNameResolver.resolvePath( + contact.pathHops, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + } catch { + logger.error("Failed to resolve path hops: \(error.localizedDescription)") + hops = [] } + } } diff --git a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift index 02a958b2..1eb547b4 100644 --- a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift +++ b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift @@ -1,455 +1,455 @@ +import MC1Services import os import SwiftUI -import MC1Services private let logger = Logger(subsystem: "com.mc1", category: "NodeAuthenticationSheet") /// Reusable password entry sheet for both room servers and repeaters struct NodeAuthenticationSheet: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - let contact: ContactDTO - let role: RemoteNodeRole - /// When true, hides the Node Details section (used when re-joining known rooms from chat list) - let hideNodeDetails: Bool - /// Optional custom title. If nil, uses default based on role ("Join Room" or "Admin Access") - let customTitle: String? - let onSuccess: (RemoteNodeSessionDTO) -> Void - - @State private var password: String = "" - @State private var rememberPassword = true - @State private var useFloodRouting: Bool - @State private var didResetPath = false - @State private var isAuthenticating = false - @State private var errorMessage: String? - @State private var hasSavedPassword = false - @State private var pathViewModel = NodeAuthPathViewModel() - - // Countdown state - @State private var authSecondsRemaining: Int? - @State private var authStartTime: Date? - @State private var authTimeoutSeconds: Int? - @State private var countdownTask: Task? - @State private var authenticationTask: Task? - - private let maxPasswordLength = 15 - - init( - contact: ContactDTO, - role: RemoteNodeRole, - hideNodeDetails: Bool = false, - customTitle: String? = nil, - onSuccess: @escaping (RemoteNodeSessionDTO) -> Void - ) { - self.contact = contact - self.role = role - self.hideNodeDetails = hideNodeDetails - self.customTitle = customTitle - self.onSuccess = onSuccess - self._useFloodRouting = State(initialValue: contact.isFloodRouted) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + + let contact: ContactDTO + let role: RemoteNodeRole + /// When true, hides the Node Details section (used when re-joining known rooms from chat list) + let hideNodeDetails: Bool + /// Optional custom title. If nil, uses default based on role ("Join Room" or "Admin Access") + let customTitle: String? + let onSuccess: (RemoteNodeSessionDTO) -> Void + + @State private var password: String = "" + @State private var rememberPassword = true + @State private var useFloodRouting: Bool + @State private var didResetPath = false + @State private var isAuthenticating = false + @State private var errorMessage: String? + @State private var hasSavedPassword = false + @State private var pathViewModel = NodeAuthPathViewModel() + + // Countdown state + @State private var authSecondsRemaining: Int? + @State private var authStartTime: Date? + @State private var authTimeoutSeconds: Int? + @State private var countdownTask: Task? + @State private var authenticationTask: Task? + + private let maxPasswordLength = 15 + + init( + contact: ContactDTO, + role: RemoteNodeRole, + hideNodeDetails: Bool = false, + customTitle: String? = nil, + onSuccess: @escaping (RemoteNodeSessionDTO) -> Void + ) { + self.contact = contact + self.role = role + self.hideNodeDetails = hideNodeDetails + self.customTitle = customTitle + self.onSuccess = onSuccess + _useFloodRouting = State(initialValue: contact.isFloodRouted) + } + + var body: some View { + NavigationStack { + Form { + if !hideNodeDetails { + makeNodeDetailsSection() + } + makeAuthenticationSection() + makePathSection() + makeConnectButton() + } + .themedCanvas(theme) + .navigationTitle(customTitle ?? (role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.management)) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.RemoteNodes.RemoteNodes.Auth.cancel) { + authenticationTask?.cancel() + dismiss() + } + } + } + .task { + if let remoteNodeService = appState.services?.remoteNodeService, + let saved = await remoteNodeService.retrievePassword(forContact: contact) { + password = saved + hasSavedPassword = true + } + } + .task { + guard !contact.isFloodRouted, contact.pathHopCount > 0, + let radioID = appState.connectedDevice?.radioID else { return } + await pathViewModel.load( + contact: contact, + services: appState.services, + radioID: radioID, + userLocation: appState.bestAvailableLocation + ) + } + .sensoryFeedback(.error, trigger: errorMessage) + .onDisappear { + authenticationTask?.cancel() + cleanupCountdownState() + } } + } - var body: some View { - NavigationStack { - Form { - if !hideNodeDetails { - makeNodeDetailsSection() - } - makeAuthenticationSection() - makePathSection() - makeConnectButton() - } - .themedCanvas(theme) - .navigationTitle(customTitle ?? (role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.management)) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.RemoteNodes.RemoteNodes.Auth.cancel) { - authenticationTask?.cancel() - dismiss() - } - } - } - .task { - if let remoteNodeService = appState.services?.remoteNodeService, - let saved = await remoteNodeService.retrievePassword(forContact: contact) { - password = saved - hasSavedPassword = true - } - } - .task { - guard !contact.isFloodRouted, contact.pathHopCount > 0, - let radioID = appState.connectedDevice?.radioID else { return } - await pathViewModel.load( - contact: contact, - services: appState.services, - radioID: radioID, - userLocation: appState.bestAvailableLocation - ) - } - .sensoryFeedback(.error, trigger: errorMessage) - .onDisappear { - authenticationTask?.cancel() - cleanupCountdownState() - } + // MARK: - Sections + + private func makeNodeDetailsSection() -> some View { + NodeDetailsSection( + displayName: contact.displayName, + role: role + ) + } + + private func makeAuthenticationSection() -> some View { + AuthenticationSection( + password: $password, + rememberPassword: $rememberPassword, + errorMessage: $errorMessage, + authSecondsRemaining: $authSecondsRemaining, + role: role, + maxPasswordLength: maxPasswordLength + ) + } + + private func makePathSection() -> some View { + PathSection( + contact: contact, + useFloodRouting: $useFloodRouting, + pathViewModel: pathViewModel + ) + } + + private func makeConnectButton() -> some View { + ConnectButton( + role: role, + isAuthenticating: isAuthenticating, + onAuthenticate: { authenticate() } + ) + } + + // MARK: - Authentication + + private func authenticate() { + // Clear any previous error + errorMessage = nil + isAuthenticating = true + authenticationTask?.cancel() + cleanupCountdownState() + + authenticationTask = Task { + do { + guard let device = appState.connectedDevice else { + throw RemoteNodeError.notConnected } - } - // MARK: - Sections + guard let services = appState.services else { + throw RemoteNodeError.notConnected + } - private func makeNodeDetailsSection() -> some View { - NodeDetailsSection( - displayName: contact.displayName, - role: role - ) - } + // Reset the firmware's stored path so the login packet is flood-routed. + // Only needed once per session — subsequent retries skip the BLE round-trip. + let pathLength: UInt8 + if useFloodRouting && !contact.isFloodRouted && !didResetPath { + try await services.contactService.resetPath( + radioID: device.radioID, + publicKey: contact.publicKey + ) + didResetPath = true + pathLength = PacketBuilder.floodPathSentinel + } else if useFloodRouting { + pathLength = PacketBuilder.floodPathSentinel + } else { + pathLength = contact.outPathLength + } - private func makeAuthenticationSection() -> some View { - AuthenticationSection( - password: $password, - rememberPassword: $rememberPassword, - errorMessage: $errorMessage, - authSecondsRemaining: $authSecondsRemaining, - role: role, - maxPasswordLength: maxPasswordLength - ) - } + let session: RemoteNodeSessionDTO + // MeshCore repeaters and rooms only support 15-character passwords, truncate if needed + let passwordToUse = password.count > maxPasswordLength + ? String(password.prefix(maxPasswordLength)) + : password + + // Callback to start countdown when firmware timeout is known + let onTimeoutKnown: @Sendable (Int) async -> Void = { [self] seconds in + await MainActor.run { + authTimeoutSeconds = seconds + authStartTime = Date.now + authSecondsRemaining = seconds + startCountdownTask() + } + } - private func makePathSection() -> some View { - PathSection( + if role == .roomServer { + session = try await services.roomServerService.joinRoom( + radioID: device.radioID, contact: contact, - useFloodRouting: $useFloodRouting, - pathViewModel: pathViewModel - ) - } + password: passwordToUse, + rememberPassword: rememberPassword, + pathLength: pathLength, + onTimeoutKnown: onTimeoutKnown + ) + } else { + session = try await services.repeaterAdminService.connectAsAdmin( + radioID: device.radioID, + contact: contact, + password: passwordToUse, + rememberPassword: rememberPassword, + pathLength: pathLength, + onTimeoutKnown: onTimeoutKnown + ) + } - private func makeConnectButton() -> some View { - ConnectButton( - role: role, - isAuthenticating: isAuthenticating, - onAuthenticate: { authenticate() } - ) + // Delete saved password if user unchecked "Remember Password" + if hasSavedPassword, !rememberPassword { + do { + try await services.remoteNodeService.deletePassword(forContact: contact) + } catch { + logger.warning("Failed to delete saved password: \(error)") + } + } + + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + dismiss() + onSuccess(session) + } + } catch is CancellationError { + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + isAuthenticating = false + } + } catch RemoteNodeError.timeout { + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + isAuthenticating = false + } + } catch { + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + errorMessage = error.userFacingMessage + isAuthenticating = false + } + } } + } - // MARK: - Authentication + // MARK: - Countdown - private func authenticate() { - // Clear any previous error - errorMessage = nil - isAuthenticating = true - authenticationTask?.cancel() - cleanupCountdownState() + private static let countdownTickInterval: Duration = .seconds(1) - authenticationTask = Task { - do { - guard let device = appState.connectedDevice else { - throw RemoteNodeError.notConnected - } - - guard let services = appState.services else { - throw RemoteNodeError.notConnected - } - - // Reset the firmware's stored path so the login packet is flood-routed. - // Only needed once per session — subsequent retries skip the BLE round-trip. - let pathLength: UInt8 - if useFloodRouting && !contact.isFloodRouted && !didResetPath { - try await services.contactService.resetPath( - radioID: device.radioID, - publicKey: contact.publicKey - ) - didResetPath = true - pathLength = PacketBuilder.floodPathSentinel - } else if useFloodRouting { - pathLength = PacketBuilder.floodPathSentinel - } else { - pathLength = contact.outPathLength - } - - let session: RemoteNodeSessionDTO - // MeshCore repeaters and rooms only support 15-character passwords, truncate if needed - let passwordToUse = password.count > maxPasswordLength - ? String(password.prefix(maxPasswordLength)) - : password - - // Callback to start countdown when firmware timeout is known - let onTimeoutKnown: @Sendable (Int) async -> Void = { [self] seconds in - await MainActor.run { - self.authTimeoutSeconds = seconds - self.authStartTime = Date.now - self.authSecondsRemaining = seconds - self.startCountdownTask() - } - } - - if role == .roomServer { - session = try await services.roomServerService.joinRoom( - radioID: device.radioID, - contact: contact, - password: passwordToUse, - rememberPassword: rememberPassword, - pathLength: pathLength, - onTimeoutKnown: onTimeoutKnown - ) - } else { - session = try await services.repeaterAdminService.connectAsAdmin( - radioID: device.radioID, - contact: contact, - password: passwordToUse, - rememberPassword: rememberPassword, - pathLength: pathLength, - onTimeoutKnown: onTimeoutKnown - ) - } - - // Delete saved password if user unchecked "Remember Password" - if hasSavedPassword && !rememberPassword { - do { - try await services.remoteNodeService.deletePassword(forContact: contact) - } catch { - logger.warning("Failed to delete saved password: \(error)") - } - } - - await MainActor.run { - authenticationTask = nil - cleanupCountdownState() - dismiss() - onSuccess(session) - } - } catch is CancellationError { - await MainActor.run { - authenticationTask = nil - cleanupCountdownState() - isAuthenticating = false - } - } catch RemoteNodeError.timeout { - await MainActor.run { - authenticationTask = nil - cleanupCountdownState() - errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut - isAuthenticating = false - } - } catch { - await MainActor.run { - authenticationTask = nil - cleanupCountdownState() - errorMessage = error.userFacingMessage - isAuthenticating = false - } - } + private func startCountdownTask() { + countdownTask = Task { + while !Task.isCancelled, let timeout = authTimeoutSeconds, let startTime = authStartTime { + do { + try await Task.sleep(for: Self.countdownTickInterval) + } catch { + break } - } - // MARK: - Countdown - - private static let countdownTickInterval: Duration = .seconds(1) - - private func startCountdownTask() { - countdownTask = Task { - while !Task.isCancelled, let timeout = authTimeoutSeconds, let startTime = authStartTime { - do { - try await Task.sleep(for: Self.countdownTickInterval) - } catch { - break - } - - let elapsed = Date.now.timeIntervalSince(startTime) - let remaining = max(0, timeout - Int(elapsed)) - authSecondsRemaining = remaining - if remaining == 0 { - break - } - } + let elapsed = Date.now.timeIntervalSince(startTime) + let remaining = max(0, timeout - Int(elapsed)) + authSecondsRemaining = remaining + if remaining == 0 { + break } + } } - - private func cleanupCountdownState() { - countdownTask?.cancel() - countdownTask = nil - authSecondsRemaining = nil - authStartTime = nil - authTimeoutSeconds = nil - } + } + + private func cleanupCountdownState() { + countdownTask?.cancel() + countdownTask = nil + authSecondsRemaining = nil + authStartTime = nil + authTimeoutSeconds = nil + } } // MARK: - Node Details Section private struct NodeDetailsSection: View { - @Environment(\.appTheme) private var theme - let displayName: String - let role: RemoteNodeRole - - var body: some View { - Section { - LabeledContent(L10n.RemoteNodes.RemoteNodes.Auth.name, value: displayName) - LabeledContent(L10n.RemoteNodes.RemoteNodes.Auth.type, value: role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.typeRoom : L10n.RemoteNodes.RemoteNodes.Auth.typeRepeater) - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Auth.nodeDetails) - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + let displayName: String + let role: RemoteNodeRole + + var body: some View { + Section { + LabeledContent(L10n.RemoteNodes.RemoteNodes.Auth.name, value: displayName) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Auth.type, value: role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.typeRoom : L10n.RemoteNodes.RemoteNodes.Auth.typeRepeater) + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Auth.nodeDetails) } + .themedRowBackground(theme) + } } // MARK: - Authentication Section private struct AuthenticationSection: View { - /// Countdown values at which VoiceOver gets a time-remaining announcement. - private static let announcementThresholds = [30, 15, 10] - /// Below this many seconds, every countdown tick is announced. - private static let finalCountdownSeconds = 5 - - @Environment(\.appTheme) private var theme - @Binding var password: String - @Binding var rememberPassword: Bool - @Binding var errorMessage: String? - @Binding var authSecondsRemaining: Int? - let role: RemoteNodeRole - let maxPasswordLength: Int - - var body: some View { - Section { - SecureField(L10n.RemoteNodes.RemoteNodes.Auth.password, text: $password) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - Toggle(L10n.RemoteNodes.RemoteNodes.Auth.rememberPassword, isOn: $rememberPassword) - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Auth.authentication) - } footer: { - if let errorMessage { - Label(errorMessage, systemImage: "exclamationmark.circle.fill") - .foregroundStyle(.orange) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Auth.errorPrefix(errorMessage)) - } else if password.count > maxPasswordLength { - Text(role == .repeater ? L10n.RemoteNodes.RemoteNodes.Auth.passwordTooLongRepeaters(maxPasswordLength) : L10n.RemoteNodes.RemoteNodes.Auth.passwordTooLongRooms(maxPasswordLength)) - } else if let remaining = authSecondsRemaining, remaining > 0 { - Text(L10n.RemoteNodes.RemoteNodes.Auth.secondsRemaining(remaining)) - } else { - Text(" ") - .accessibilityHidden(true) - } - } - .themedRowBackground(theme) - .onChange(of: password) { - if errorMessage != nil { - errorMessage = nil - } - } - .onChange(of: authSecondsRemaining) { oldValue, newValue in - guard let remaining = newValue, remaining > 0 else { return } - // Threshold-crossing rather than exact equality so a skipped tick - // can't silently drop an announcement. - let crossedThreshold = Self.announcementThresholds.contains { threshold in - remaining <= threshold && (oldValue ?? Int.max) > threshold - } - let shouldAnnounce = oldValue == nil || crossedThreshold || remaining <= Self.finalCountdownSeconds - if shouldAnnounce { - AccessibilityNotification.Announcement(L10n.RemoteNodes.RemoteNodes.Auth.secondsRemainingAnnouncement(remaining)).post() - } - } + /// Countdown values at which VoiceOver gets a time-remaining announcement. + private static let announcementThresholds = [30, 15, 10] + /// Below this many seconds, every countdown tick is announced. + private static let finalCountdownSeconds = 5 + + @Environment(\.appTheme) private var theme + @Binding var password: String + @Binding var rememberPassword: Bool + @Binding var errorMessage: String? + @Binding var authSecondsRemaining: Int? + let role: RemoteNodeRole + let maxPasswordLength: Int + + var body: some View { + Section { + SecureField(L10n.RemoteNodes.RemoteNodes.Auth.password, text: $password) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Toggle(L10n.RemoteNodes.RemoteNodes.Auth.rememberPassword, isOn: $rememberPassword) + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Auth.authentication) + } footer: { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Auth.errorPrefix(errorMessage)) + } else if password.count > maxPasswordLength { + Text(role == .repeater ? L10n.RemoteNodes.RemoteNodes.Auth.passwordTooLongRepeaters(maxPasswordLength) : L10n.RemoteNodes.RemoteNodes.Auth.passwordTooLongRooms(maxPasswordLength)) + } else if let remaining = authSecondsRemaining, remaining > 0 { + Text(L10n.RemoteNodes.RemoteNodes.Auth.secondsRemaining(remaining)) + } else { + Text(" ") + .accessibilityHidden(true) + } + } + .themedRowBackground(theme) + .onChange(of: password) { + if errorMessage != nil { + errorMessage = nil + } } + .onChange(of: authSecondsRemaining) { oldValue, newValue in + guard let remaining = newValue, remaining > 0 else { return } + // Threshold-crossing rather than exact equality so a skipped tick + // can't silently drop an announcement. + let crossedThreshold = Self.announcementThresholds.contains { threshold in + remaining <= threshold && (oldValue ?? Int.max) > threshold + } + let shouldAnnounce = oldValue == nil || crossedThreshold || remaining <= Self.finalCountdownSeconds + if shouldAnnounce { + AccessibilityNotification.Announcement(L10n.RemoteNodes.RemoteNodes.Auth.secondsRemainingAnnouncement(remaining)).post() + } + } + } } // MARK: - Path Section private struct PathSection: View { - @Environment(\.appTheme) private var theme - let contact: ContactDTO - @Binding var useFloodRouting: Bool - let pathViewModel: NodeAuthPathViewModel - - @State private var isPathExpanded = false - - private var hasStoredPath: Bool { - !contact.isFloodRouted - } - - var body: some View { - Section { - if hasStoredPath && !useFloodRouting { - if !pathViewModel.hops.isEmpty { - DisclosureGroup(isExpanded: $isPathExpanded) { - ForEach(pathViewModel.hops) { hop in - NodePathHopRow(hex: hop.hex, resolution: hop.resolution) - } - } label: { - NodePathSummaryLabel(contact: contact) - } - } else { - NodePathSummaryLabel(contact: contact) - } - } else if !hasStoredPath { - Label { - Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteSet) - .foregroundStyle(.secondary) - } icon: { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - .accessibilityHidden(true) - } - - Toggle(L10n.RemoteNodes.RemoteNodes.Auth.floodRouting, isOn: $useFloodRouting) - .disabled(!hasStoredPath) - .accessibilityHint(hasStoredPath ? "" : L10n.RemoteNodes.RemoteNodes.Auth.noRouteFooter) - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Auth.path) - } footer: { - if hasStoredPath { - Text(L10n.RemoteNodes.RemoteNodes.Auth.pathFooter) - } else { - Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteFooter) + @Environment(\.appTheme) private var theme + let contact: ContactDTO + @Binding var useFloodRouting: Bool + let pathViewModel: NodeAuthPathViewModel + + @State private var isPathExpanded = false + + private var hasStoredPath: Bool { + !contact.isFloodRouted + } + + var body: some View { + Section { + if hasStoredPath, !useFloodRouting { + if !pathViewModel.hops.isEmpty { + DisclosureGroup(isExpanded: $isPathExpanded) { + ForEach(pathViewModel.hops) { hop in + NodePathHopRow(hex: hop.hex, resolution: hop.resolution) } + } label: { + NodePathSummaryLabel(contact: contact) + } + } else { + NodePathSummaryLabel(contact: contact) } - .themedRowBackground(theme) - .animation(.default, value: useFloodRouting) + } else if !hasStoredPath { + Label { + Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteSet) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .accessibilityHidden(true) + } + + Toggle(L10n.RemoteNodes.RemoteNodes.Auth.floodRouting, isOn: $useFloodRouting) + .disabled(!hasStoredPath) + .accessibilityHint(hasStoredPath ? "" : L10n.RemoteNodes.RemoteNodes.Auth.noRouteFooter) + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Auth.path) + } footer: { + if hasStoredPath { + Text(L10n.RemoteNodes.RemoteNodes.Auth.pathFooter) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteFooter) + } } + .themedRowBackground(theme) + .animation(.default, value: useFloodRouting) + } } // MARK: - Connect Button private struct ConnectButton: View { - @Environment(\.appTheme) private var theme - let role: RemoteNodeRole - let isAuthenticating: Bool - let onAuthenticate: () -> Void - - private var buttonLabel: String { - role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.connect - } - - var body: some View { - Section { - Button { - onAuthenticate() - } label: { - if isAuthenticating { - ProgressView() - .frame(maxWidth: .infinity) - } else { - Text(buttonLabel) - .frame(maxWidth: .infinity) - } - } - .disabled(isAuthenticating) + @Environment(\.appTheme) private var theme + let role: RemoteNodeRole + let isAuthenticating: Bool + let onAuthenticate: () -> Void + + private var buttonLabel: String { + role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.connect + } + + var body: some View { + Section { + Button { + onAuthenticate() + } label: { + if isAuthenticating { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text(buttonLabel) + .frame(maxWidth: .infinity) } - .themedRowBackground(theme) + } + .disabled(isAuthenticating) } + .themedRowBackground(theme) + } } #Preview { - NodeAuthenticationSheet( - contact: ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - typeRawValue: ContactType.room.rawValue - )), - role: .roomServer, - onSuccess: { _ in } - ) - .environment(\.appState, AppState()) + NodeAuthenticationSheet( + contact: ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + typeRawValue: ContactType.room.rawValue + )), + role: .roomServer, + onSuccess: { _ in } + ) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/RemoteNodes/NodeCLIView.swift b/MC1/Views/RemoteNodes/NodeCLIView.swift index 193a4504..b808969d 100644 --- a/MC1/Views/RemoteNodes/NodeCLIView.swift +++ b/MC1/Views/RemoteNodes/NodeCLIView.swift @@ -4,79 +4,79 @@ import UIKit /// Hosts the terminal for a single managed node, owning the per-instance local /// state the terminal needs and wiring callbacks to `NodeCLIViewModel`. struct NodeCLIView: View { - @Bindable var viewModel: NodeCLIViewModel + @Bindable var viewModel: NodeCLIViewModel - @State private var isKeyboardFocused = false - @State private var scrollPosition = ScrollPosition(edge: .bottom) - @State private var cursorPosition: Int = 0 + @State private var isKeyboardFocused = false + @State private var scrollPosition = ScrollPosition(edge: .bottom) + @State private var cursorPosition: Int = 0 - var body: some View { - CLITerminalView( - outputLines: viewModel.outputLines, - promptText: viewModel.promptText, - ghostText: viewModel.ghostText, - tabSuggestions: viewModel.tabSuggestions, - tabSelectionIndex: viewModel.tabSelectionIndex, - isWaitingForResponse: viewModel.isWaitingForResponse, - showSessionsButton: false, - currentInput: $viewModel.currentInput, - isKeyboardFocused: $isKeyboardFocused, - scrollPosition: $scrollPosition, - cursorPosition: $cursorPosition, - onSubmit: { - if viewModel.applySelectedSuggestion() { - cursorPosition = viewModel.currentInput.count - } else { - viewModel.executeCommand(viewModel.currentInput) - } - }, - onHistoryUp: { - viewModel.historyUp() - cursorPosition = viewModel.currentInput.count - }, - onHistoryDown: { - viewModel.historyDown() - cursorPosition = viewModel.currentInput.count - }, - onRightArrowAtEnd: { - if !viewModel.ghostText.isEmpty { - viewModel.acceptGhostText() - cursorPosition = viewModel.currentInput.count - } - }, - onTabComplete: { - viewModel.tabComplete() - cursorPosition = viewModel.currentInput.count - }, - onMoveLeft: { - if cursorPosition > 0 { cursorPosition -= 1 } - }, - onMoveRight: { - if !viewModel.ghostText.isEmpty && cursorPosition >= viewModel.currentInput.count { - viewModel.acceptGhostText() - cursorPosition = viewModel.currentInput.count - } else if cursorPosition < viewModel.currentInput.count { - cursorPosition += 1 - } - }, - onPaste: { - viewModel.pasteFromClipboard(at: cursorPosition) - cursorPosition = min( - cursorPosition + (UIPasteboard.general.string?.count ?? 0), - viewModel.currentInput.count - ) - }, - onSessions: {}, - onCancel: { viewModel.cancelCurrentCommand() }, - onDismiss: { isKeyboardFocused = false }, - onClear: { viewModel.executeCommand("clear") }, - onUpdateGhostText: { cursorAtEnd in viewModel.updateGhostText(cursorAtEnd: cursorAtEnd) }, - onClearTabState: { viewModel.clearTabState() }, - onGetResponseBlock: { viewModel.getResponseBlock(containing: $0) } + var body: some View { + CLITerminalView( + outputLines: viewModel.outputLines, + promptText: viewModel.promptText, + ghostText: viewModel.ghostText, + tabSuggestions: viewModel.tabSuggestions, + tabSelectionIndex: viewModel.tabSelectionIndex, + isWaitingForResponse: viewModel.isWaitingForResponse, + showSessionsButton: false, + currentInput: $viewModel.currentInput, + isKeyboardFocused: $isKeyboardFocused, + scrollPosition: $scrollPosition, + cursorPosition: $cursorPosition, + onSubmit: { + if viewModel.applySelectedSuggestion() { + cursorPosition = viewModel.currentInput.count + } else { + viewModel.executeCommand(viewModel.currentInput) + } + }, + onHistoryUp: { + viewModel.historyUp() + cursorPosition = viewModel.currentInput.count + }, + onHistoryDown: { + viewModel.historyDown() + cursorPosition = viewModel.currentInput.count + }, + onRightArrowAtEnd: { + if !viewModel.ghostText.isEmpty { + viewModel.acceptGhostText() + cursorPosition = viewModel.currentInput.count + } + }, + onTabComplete: { + viewModel.tabComplete() + cursorPosition = viewModel.currentInput.count + }, + onMoveLeft: { + if cursorPosition > 0 { cursorPosition -= 1 } + }, + onMoveRight: { + if !viewModel.ghostText.isEmpty, cursorPosition >= viewModel.currentInput.count { + viewModel.acceptGhostText() + cursorPosition = viewModel.currentInput.count + } else if cursorPosition < viewModel.currentInput.count { + cursorPosition += 1 + } + }, + onPaste: { + viewModel.pasteFromClipboard(at: cursorPosition) + cursorPosition = min( + cursorPosition + (UIPasteboard.general.string?.count ?? 0), + viewModel.currentInput.count ) - // Restore the cursor to the end of surviving input when the view is - // recreated by a Settings/CLI segment toggle; cursorPosition is - // view-local @State and resets to 0, but currentInput lives on the VM. - .onAppear { cursorPosition = viewModel.currentInput.count } - } + }, + onSessions: {}, + onCancel: { viewModel.cancelCurrentCommand() }, + onDismiss: { isKeyboardFocused = false }, + onClear: { viewModel.executeCommand("clear") }, + onUpdateGhostText: { cursorAtEnd in viewModel.updateGhostText(cursorAtEnd: cursorAtEnd) }, + onClearTabState: { viewModel.clearTabState() }, + onGetResponseBlock: { viewModel.getResponseBlock(containing: $0) } + ) + // Restore the cursor to the end of surviving input when the view is + // recreated by a Settings/CLI segment toggle; cursorPosition is + // view-local @State and resets to 0, but currentInput lives on the VM. + .onAppear { cursorPosition = viewModel.currentInput.count } + } } diff --git a/MC1/Views/RemoteNodes/NodeCLIViewModel.swift b/MC1/Views/RemoteNodes/NodeCLIViewModel.swift index a8887df9..c768da14 100644 --- a/MC1/Views/RemoteNodes/NodeCLIViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeCLIViewModel.swift @@ -5,286 +5,286 @@ import UIKit @Observable @MainActor final class NodeCLIViewModel { - private static let maxOutputLines = 1000 - private static let maxHistoryEntries = 100 - private static let rebootTimeout: Duration = .seconds(2) - private static let defaultCommandTimeout: Duration = .seconds(10) - - // MARK: - Terminal State - - private(set) var outputLines: [CLIOutputLine] = [] - private(set) var commandHistory: [String] = [] - private(set) var historyIndex: Int? - var currentInput: String = "" - var isWaitingForResponse = false - - // MARK: - Completion State - - let completionEngine = CLICompletionEngine() - var ghostText: String = "" - var tabSuggestions: [String]? - var tabSelectionIndex: Int? + private static let maxOutputLines = 1000 + private static let maxHistoryEntries = 100 + private static let rebootTimeout: Duration = .seconds(2) + private static let defaultCommandTimeout: Duration = .seconds(10) + + // MARK: - Terminal State + + private(set) var outputLines: [CLIOutputLine] = [] + private(set) var commandHistory: [String] = [] + private(set) var historyIndex: Int? + var currentInput: String = "" + var isWaitingForResponse = false + + // MARK: - Completion State + + let completionEngine = CLICompletionEngine() + var ghostText: String = "" + var tabSuggestions: [String]? + var tabSelectionIndex: Int? + + // MARK: - Dependencies + + private var sessionName: String = "" + private var sendRawCommand: (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? + private var currentCommandTask: Task? + private var hasConfigured = false + + // MARK: - Prompt + + var promptText: String { + if isWaitingForResponse { return "" } + return "@\(sessionName)\(L10n.Tools.Tools.Cli.promptSuffix) " + } + + // MARK: - Setup + + /// Configures the node CLI with its display name and send closure. + /// Idempotent: the connection banner is appended only on the first call, + /// so toggling the Settings/CLI segment does not re-banner. + func configure( + sessionName: String, + sendRawCommand: @escaping @MainActor (_ command: String, _ timeout: Duration) async throws -> String + ) { + self.sessionName = sessionName + self.sendRawCommand = sendRawCommand + guard !hasConfigured else { return } + hasConfigured = true + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.bannerConnected(sessionName), type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.bannerHint, type: .response) + appendOutput("", type: .response) + } + + // MARK: - Command Execution + + func executeCommand(_ command: String) { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isWaitingForResponse else { return } + let promptPrefix = promptText.trimmingCharacters(in: .whitespaces) + + guard !trimmed.isEmpty else { + appendOutput(promptPrefix, type: .command) + return + } - // MARK: - Dependencies + addToHistory(trimmed) + appendOutput("\(promptPrefix) \(trimmed)", type: .command) - private var sessionName: String = "" - private var sendRawCommand: (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? - private var currentCommandTask: Task? - private var hasConfigured = false + let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) + let cmd = parts[0].lowercased() + let args = parts.count > 1 ? parts[1] : "" - // MARK: - Prompt + currentCommandTask = Task { await handleCommand(cmd, args: args, raw: trimmed) } + currentInput = "" + } - var promptText: String { - if isWaitingForResponse { return "" } - return "@\(sessionName)\(L10n.Tools.Tools.Cli.promptSuffix) " + func cancelCurrentCommand() { + currentCommandTask?.cancel() + currentCommandTask = nil + if isWaitingForResponse { + isWaitingForResponse = false + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.cancelled, type: .error) } + } - // MARK: - Setup - - /// Configures the node CLI with its display name and send closure. - /// Idempotent: the connection banner is appended only on the first call, - /// so toggling the Settings/CLI segment does not re-banner. - func configure( - sessionName: String, - sendRawCommand: @escaping @MainActor (_ command: String, _ timeout: Duration) async throws -> String - ) { - self.sessionName = sessionName - self.sendRawCommand = sendRawCommand - guard !hasConfigured else { return } - hasConfigured = true - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.bannerConnected(sessionName), type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.bannerHint, type: .response) - appendOutput("", type: .response) + private func handleCommand(_ cmd: String, args: String, raw: String) async { + switch cmd { + case "help": showHelp() + case "clear" where args.isEmpty: outputLines.removeAll() + default: await sendCommand(raw) } - - // MARK: - Command Execution - - func executeCommand(_ command: String) { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !isWaitingForResponse else { return } - let promptPrefix = promptText.trimmingCharacters(in: .whitespaces) - - guard !trimmed.isEmpty else { - appendOutput(promptPrefix, type: .command) - return - } - - addToHistory(trimmed) - appendOutput("\(promptPrefix) \(trimmed)", type: .command) - - let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) - let cmd = parts[0].lowercased() - let args = parts.count > 1 ? parts[1] : "" - - currentCommandTask = Task { await handleCommand(cmd, args: args, raw: trimmed) } - currentInput = "" + } + + private func sendCommand(_ command: String) async { + guard let sendRawCommand else { return } + + // Reboot does not reply; treat both success and timeout as success. + let normalized = command.lowercased() + if normalized == "reboot" || normalized == "reboot now" { + isWaitingForResponse = true + defer { isWaitingForResponse = false } + do { + _ = try await sendRawCommand(command, Self.rebootTimeout) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.rebootSent, type: .success) + } catch RemoteNodeError.timeout { + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.rebootSent, type: .success) + } catch is CancellationError { + } catch { + appendOutput(error.localizedDescription, type: .error) + } + return } - func cancelCurrentCommand() { - currentCommandTask?.cancel() - currentCommandTask = nil - if isWaitingForResponse { - isWaitingForResponse = false - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.cancelled, type: .error) - } + isWaitingForResponse = true + defer { isWaitingForResponse = false } + do { + let response = try await sendRawCommand(command, Self.defaultCommandTimeout) + guard !Task.isCancelled else { return } + appendOutput(response, type: .response) + } catch is CancellationError { + } catch { + appendOutput(error.localizedDescription, type: .error) } - - private func handleCommand(_ cmd: String, args: String, raw: String) async { - switch cmd { - case "help": showHelp() - case "clear" where args.isEmpty: outputLines.removeAll() - default: await sendCommand(raw) - } + } + + private func showHelp() { + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpHeader, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpHelp, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpClear, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpClearStats, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpReboot, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpPassthrough, type: .response) + } + + // MARK: - History + + private func addToHistory(_ command: String) { + commandHistory.append(command) + if commandHistory.count > Self.maxHistoryEntries { + commandHistory.removeFirst() } - - private func sendCommand(_ command: String) async { - guard let sendRawCommand else { return } - - // Reboot does not reply; treat both success and timeout as success. - let normalized = command.lowercased() - if normalized == "reboot" || normalized == "reboot now" { - isWaitingForResponse = true - defer { isWaitingForResponse = false } - do { - _ = try await sendRawCommand(command, Self.rebootTimeout) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.rebootSent, type: .success) - } catch RemoteNodeError.timeout { - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.rebootSent, type: .success) - } catch is CancellationError { - } catch { - appendOutput(error.localizedDescription, type: .error) - } - return - } - - isWaitingForResponse = true - defer { isWaitingForResponse = false } - do { - let response = try await sendRawCommand(command, Self.defaultCommandTimeout) - guard !Task.isCancelled else { return } - appendOutput(response, type: .response) - } catch is CancellationError { - } catch { - appendOutput(error.localizedDescription, type: .error) - } + historyIndex = nil + } + + func historyUp() { + guard !commandHistory.isEmpty else { return } + if let index = historyIndex { + if index > 0 { historyIndex = index - 1 } + } else { + historyIndex = commandHistory.count - 1 } - - private func showHelp() { - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpHeader, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpHelp, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpClear, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpClearStats, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpReboot, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpPassthrough, type: .response) + if let index = historyIndex { currentInput = commandHistory[index] } + } + + func historyDown() { + guard let index = historyIndex else { return } + if index < commandHistory.count - 1 { + historyIndex = index + 1 + currentInput = commandHistory[index + 1] + } else { + historyIndex = nil + currentInput = "" } + } - // MARK: - History + // MARK: - Output - private func addToHistory(_ command: String) { - commandHistory.append(command) - if commandHistory.count > Self.maxHistoryEntries { - commandHistory.removeFirst() - } - historyIndex = nil + func appendOutput(_ text: String, type: CLIOutputType) { + outputLines.append(CLIOutputLine(text: text, type: type)) + if outputLines.count > Self.maxOutputLines { + outputLines.removeFirst() } - - func historyUp() { - guard !commandHistory.isEmpty else { return } - if let index = historyIndex { - if index > 0 { historyIndex = index - 1 } - } else { - historyIndex = commandHistory.count - 1 - } - if let index = historyIndex { currentInput = commandHistory[index] } + } + + /// Returns the full response block containing the given line, stripping + /// prompt and MeshCore "> " prefixes. Twin of `CLIToolViewModel.getResponseBlock(containing:)`; + /// keep the two in sync. + func getResponseBlock(containing line: CLIOutputLine) -> String { + guard let index = outputLines.firstIndex(where: { $0.id == line.id }) else { + return line.text } - - func historyDown() { - guard let index = historyIndex else { return } - if index < commandHistory.count - 1 { - historyIndex = index + 1 - currentInput = commandHistory[index + 1] - } else { - historyIndex = nil - currentInput = "" - } + if line.type == .command { + if let range = line.text.range(of: "> ") { + return String(line.text[range.upperBound...]) + } + return line.text } - - // MARK: - Output - - func appendOutput(_ text: String, type: CLIOutputType) { - outputLines.append(CLIOutputLine(text: text, type: type)) - if outputLines.count > Self.maxOutputLines { - outputLines.removeFirst() - } + var startIndex = index + while startIndex > 0, outputLines[startIndex - 1].type != .command { + startIndex -= 1 } - - /// Returns the full response block containing the given line, stripping - /// prompt and MeshCore "> " prefixes. Twin of `CLIToolViewModel.getResponseBlock(containing:)`; - /// keep the two in sync. - func getResponseBlock(containing line: CLIOutputLine) -> String { - guard let index = outputLines.firstIndex(where: { $0.id == line.id }) else { - return line.text - } - if line.type == .command { - if let range = line.text.range(of: "> ") { - return String(line.text[range.upperBound...]) - } - return line.text - } - var startIndex = index - while startIndex > 0 && outputLines[startIndex - 1].type != .command { - startIndex -= 1 - } - var endIndex = index - while endIndex < outputLines.count - 1 && outputLines[endIndex + 1].type != .command { - endIndex += 1 - } - return outputLines[startIndex...endIndex] - .map { $0.text.hasPrefix("> ") ? String($0.text.dropFirst(2)) : $0.text } - .joined(separator: "\n") - } - - func pasteFromClipboard(at cursorPosition: Int) { - guard let text = UIPasteboard.general.string else { return } - let safePosition = min(cursorPosition, currentInput.count) - let index = currentInput.index(currentInput.startIndex, offsetBy: safePosition) - currentInput.insert(contentsOf: text, at: index) + var endIndex = index + while endIndex < outputLines.count - 1, outputLines[endIndex + 1].type != .command { + endIndex += 1 } + return outputLines[startIndex...endIndex] + .map { $0.text.hasPrefix("> ") ? String($0.text.dropFirst(2)) : $0.text } + .joined(separator: "\n") + } + + func pasteFromClipboard(at cursorPosition: Int) { + guard let text = UIPasteboard.general.string else { return } + let safePosition = min(cursorPosition, currentInput.count) + let index = currentInput.index(currentInput.startIndex, offsetBy: safePosition) + currentInput.insert(contentsOf: text, at: index) + } } // MARK: - Completion extension NodeCLIViewModel { - /// Node CLI always operates on a remote session, so completion uses the - /// remote command set (`isLocal: false`) and excludes the app-CLI - /// session-management commands the node firmware can't handle. - func updateGhostText(cursorAtEnd: Bool) { - guard !currentInput.isEmpty, cursorAtEnd else { ghostText = ""; return } - let suggestions = completionEngine.completions(for: currentInput, isLocal: false, includeSessionCommands: false) - guard let first = suggestions.first else { ghostText = ""; return } - let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) - let lastPart = parts.last.map(String.init) ?? "" - ghostText = first.lowercased().hasPrefix(lastPart.lowercased()) - ? String(first.dropFirst(lastPart.count)) - : "" + /// Node CLI always operates on a remote session, so completion uses the + /// remote command set (`isLocal: false`) and excludes the app-CLI + /// session-management commands the node firmware can't handle. + func updateGhostText(cursorAtEnd: Bool) { + guard !currentInput.isEmpty, cursorAtEnd else { ghostText = ""; return } + let suggestions = completionEngine.completions(for: currentInput, isLocal: false, includeSessionCommands: false) + guard let first = suggestions.first else { ghostText = ""; return } + let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) + let lastPart = parts.last.map(String.init) ?? "" + ghostText = first.lowercased().hasPrefix(lastPart.lowercased()) + ? String(first.dropFirst(lastPart.count)) + : "" + } + + func acceptGhostText() { + guard !ghostText.isEmpty else { return } + currentInput += ghostText + ghostText = "" + } + + @discardableResult + func tabComplete() -> [String]? { + if let suggestions = tabSuggestions, !suggestions.isEmpty { + if let currentIndex = tabSelectionIndex { + tabSelectionIndex = (currentIndex + 1) % suggestions.count + } else { + tabSelectionIndex = 0 + } + return suggestions } - - func acceptGhostText() { - guard !ghostText.isEmpty else { return } - currentInput += ghostText - ghostText = "" - } - - @discardableResult - func tabComplete() -> [String]? { - if let suggestions = tabSuggestions, !suggestions.isEmpty { - if let currentIndex = tabSelectionIndex { - tabSelectionIndex = (currentIndex + 1) % suggestions.count - } else { - tabSelectionIndex = 0 - } - return suggestions - } - let suggestions = completionEngine.completions(for: currentInput, isLocal: false, includeSessionCommands: false) - guard !suggestions.isEmpty else { - tabSuggestions = nil - tabSelectionIndex = nil - return nil - } - if suggestions.count == 1 { - applyCompletion(suggestions[0]) - return nil - } - tabSuggestions = suggestions - tabSelectionIndex = nil - return suggestions + let suggestions = completionEngine.completions(for: currentInput, isLocal: false, includeSessionCommands: false) + guard !suggestions.isEmpty else { + tabSuggestions = nil + tabSelectionIndex = nil + return nil } - - func applySelectedSuggestion() -> Bool { - guard let suggestions = tabSuggestions, - let index = tabSelectionIndex, - index < suggestions.count else { - return false - } - applyCompletion(suggestions[index]) - clearTabState() - return true + if suggestions.count == 1 { + applyCompletion(suggestions[0]) + return nil } - - func clearTabState() { - tabSuggestions = nil - tabSelectionIndex = nil + tabSuggestions = suggestions + tabSelectionIndex = nil + return suggestions + } + + func applySelectedSuggestion() -> Bool { + guard let suggestions = tabSuggestions, + let index = tabSelectionIndex, + index < suggestions.count else { + return false } - - private func applyCompletion(_ suggestion: String) { - let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) - if parts.count <= 1 { - currentInput = suggestion + " " - } else { - var newParts = parts.dropLast().map(String.init) - newParts.append(suggestion) - currentInput = newParts.joined(separator: " ") + " " - } - ghostText = "" + applyCompletion(suggestions[index]) + clearTabState() + return true + } + + func clearTabState() { + tabSuggestions = nil + tabSelectionIndex = nil + } + + private func applyCompletion(_ suggestion: String) { + let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) + if parts.count <= 1 { + currentInput = suggestion + " " + } else { + var newParts = parts.dropLast().map(String.init) + newParts.append(suggestion) + currentInput = newParts.joined(separator: " ") + " " } + ghostText = "" + } } diff --git a/MC1/Views/RemoteNodes/NodeManagementTab.swift b/MC1/Views/RemoteNodes/NodeManagementTab.swift index fa92e05e..dce0c4ba 100644 --- a/MC1/Views/RemoteNodes/NodeManagementTab.swift +++ b/MC1/Views/RemoteNodes/NodeManagementTab.swift @@ -3,16 +3,16 @@ import Foundation /// Top-of-page segments on the repeater/room admin management page. /// No raw value: the selection is in-memory `@State`, never persisted, so /// `CaseIterable` (with the synthesized `Hashable`) is all the picker needs. -enum NodeManagementTab: CaseIterable, Sendable { - case settings - case cli - case telemetry +enum NodeManagementTab: CaseIterable { + case settings + case cli + case telemetry - var label: String { - switch self { - case .settings: L10n.RemoteNodes.RemoteNodes.Settings.Tab.settings - case .cli: L10n.RemoteNodes.RemoteNodes.Settings.Tab.cli - case .telemetry: L10n.RemoteNodes.RemoteNodes.Settings.Tab.telemetry - } + var label: String { + switch self { + case .settings: L10n.RemoteNodes.RemoteNodes.Settings.Tab.settings + case .cli: L10n.RemoteNodes.RemoteNodes.Settings.Tab.cli + case .telemetry: L10n.RemoteNodes.RemoteNodes.Settings.Tab.telemetry } + } } diff --git a/MC1/Views/RemoteNodes/NodeManagementTabPicker.swift b/MC1/Views/RemoteNodes/NodeManagementTabPicker.swift index f6d4094e..044a8833 100644 --- a/MC1/Views/RemoteNodes/NodeManagementTabPicker.swift +++ b/MC1/Views/RemoteNodes/NodeManagementTabPicker.swift @@ -3,15 +3,15 @@ import SwiftUI /// Pinned glass segment switcher for the repeater/room admin management page; /// floating Liquid Glass pills on iOS 26, a segmented `Picker` on iOS 18. struct NodeManagementTabPicker: View { - @Binding var selection: NodeManagementTab + @Binding var selection: NodeManagementTab - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: false, - pickerLabel: L10n.RemoteNodes.RemoteNodes.Settings.Tab.picker, - title: { $0.label }, - size: .large - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: false, + pickerLabel: L10n.RemoteNodes.RemoteNodes.Settings.Tab.picker, + title: { $0.label }, + size: .large + ) + } } diff --git a/MC1/Views/RemoteNodes/NodeRoutePathSection.swift b/MC1/Views/RemoteNodes/NodeRoutePathSection.swift index fe900d29..96e41231 100644 --- a/MC1/Views/RemoteNodes/NodeRoutePathSection.swift +++ b/MC1/Views/RemoteNodes/NodeRoutePathSection.swift @@ -7,89 +7,89 @@ import SwiftUI /// toggle. Names are resolved synchronously from the contact/discovered-node lists the host already /// holds, so this is a dumb leaf with no view model of its own. struct NodeRoutePathSection: View { - @Environment(\.appTheme) private var theme - let contact: ContactDTO - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let userLocation: CLLocation? + @Environment(\.appTheme) private var theme + let contact: ContactDTO + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? - @State private var isExpanded = false + @State private var isExpanded = false - private var resolvedHops: [ResolvedPathHop] { - NeighborNameResolver.resolvePath( - contact.pathHops, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - } + private var resolvedHops: [ResolvedPathHop] { + NeighborNameResolver.resolvePath( + contact.pathHops, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + } - var body: some View { - Section { - content - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Auth.path) - } - .themedRowBackground(theme) + var body: some View { + Section { + content + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Auth.path) } + .themedRowBackground(theme) + } - @ViewBuilder - private var content: some View { - if contact.isFloodRouted { - Label { - Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteSet) - .foregroundStyle(.secondary) - } icon: { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - } else { - let hops = resolvedHops - if hops.isEmpty { - NodePathSummaryLabel(contact: contact) - } else { - DisclosureGroup(isExpanded: $isExpanded) { - ForEach(hops) { hop in - NodePathHopRow(hex: hop.hex, resolution: hop.resolution) - } - } label: { - NodePathSummaryLabel(contact: contact) - } - } + @ViewBuilder + private var content: some View { + if contact.isFloodRouted { + Label { + Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteSet) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + } else { + let hops = resolvedHops + if hops.isEmpty { + NodePathSummaryLabel(contact: contact) + } else { + DisclosureGroup(isExpanded: $isExpanded) { + ForEach(hops) { hop in + NodePathHopRow(hex: hop.hex, resolution: hop.resolution) + } + } label: { + NodePathSummaryLabel(contact: contact) } + } } + } } // MARK: - Path Summary Label /// The collapsed one-line route summary (`A3 → 7F → 42`, or `Direct`). struct NodePathSummaryLabel: View { - let contact: ContactDTO + let contact: ContactDTO - private var pathDisplayText: String { - contact.pathHopCount == 0 ? L10n.Contacts.Contacts.Route.direct : contact.pathString - } + private var pathDisplayText: String { + contact.pathHopCount == 0 ? L10n.Contacts.Contacts.Route.direct : contact.pathString + } - private var pathAccessibilityLabel: String { - contact.pathHopCount == 0 - ? L10n.Contacts.Contacts.Detail.routeDirect - : L10n.Contacts.Contacts.Detail.routePrefix(pathDisplayText) - } + private var pathAccessibilityLabel: String { + contact.pathHopCount == 0 + ? L10n.Contacts.Contacts.Detail.routeDirect + : L10n.Contacts.Contacts.Detail.routePrefix(pathDisplayText) + } - var body: some View { - Label { - Text(pathDisplayText) - .font(.caption.monospaced()) - .foregroundStyle(.primary) - .lineLimit(nil) - } icon: { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - .accessibilityLabel(pathAccessibilityLabel) + var body: some View { + Label { + Text(pathDisplayText) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + .lineLimit(nil) + } icon: { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .foregroundStyle(.secondary) + .accessibilityHidden(true) } + .accessibilityLabel(pathAccessibilityLabel) + } } // MARK: - Path Hop Row @@ -97,26 +97,26 @@ struct NodePathSummaryLabel: View { /// A single expanded hop: hash hex plus the resolved repeater name, with a possible-match indicator /// when the name was matched only by a short prefix. struct NodePathHopRow: View { - let hex: String - let resolution: NodeNameResolution + let hex: String + let resolution: NodeNameResolution - var body: some View { - HStack(spacing: 6) { - Text(hex) - .font(.body.monospaced()) - .foregroundStyle(.secondary) + var body: some View { + HStack(spacing: 6) { + Text(hex) + .font(.body.monospaced()) + .foregroundStyle(.secondary) - Text(resolution.displayName) + Text(resolution.displayName) - if resolution.matchKind == .fallback { - FallbackMatchIndicatorView( - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, - accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, - title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, - explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation - ) - } - } - .accessibilityElement(children: .combine) + if resolution.matchKind == .fallback { + FallbackMatchIndicatorView( + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, + accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, + title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, + explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation + ) + } } + .accessibilityElement(children: .combine) + } } diff --git a/MC1/Views/RemoteNodes/NodeSettingsError.swift b/MC1/Views/RemoteNodes/NodeSettingsError.swift index 00bc5967..0899c844 100644 --- a/MC1/Views/RemoteNodes/NodeSettingsError.swift +++ b/MC1/Views/RemoteNodes/NodeSettingsError.swift @@ -2,11 +2,11 @@ import Foundation /// Shared error type for the repeater and room settings screens. enum NodeSettingsError: LocalizedError { - case noService + case noService - var errorDescription: String? { - switch self { - case .noService: return L10n.RemoteNodes.RemoteNodes.Settings.noService - } + var errorDescription: String? { + switch self { + case .noService: L10n.RemoteNodes.RemoteNodes.Settings.noService } + } } diff --git a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift index 89f35c14..4c1fb98f 100644 --- a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift @@ -1,6 +1,6 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "NodeSettingsViewModel") @@ -10,660 +10,710 @@ private let logger = Logger(subsystem: "com.mc1", category: "NodeSettingsViewMod @Observable @MainActor final class NodeSettingsViewModel { + // MARK: - Session - // MARK: - Session + var session: RemoteNodeSessionDTO? - var session: RemoteNodeSessionDTO? + // MARK: - Device Info - // MARK: - Device Info + var firmwareVersion: String? + private var deviceTimeUTC: String? + var isLoadingDeviceInfo = false + var deviceInfoError = false + var deviceInfoLoaded: Bool { + deviceTimeUTC != nil + } - var firmwareVersion: String? - private var deviceTimeUTC: String? - var isLoadingDeviceInfo = false - var deviceInfoError = false - var deviceInfoLoaded: Bool { deviceTimeUTC != nil } + var deviceTime: String? { + guard let utcString = deviceTimeUTC else { return nil } + return Self.convertUTCToLocal(utcString) + } - var deviceTime: String? { - guard let utcString = deviceTimeUTC else { return nil } - return Self.convertUTCToLocal(utcString) + static func convertUTCToLocal(_ utcString: String) -> String { + guard let date = NodeSettingsResponseParser.utcDate(fromClockResponse: utcString) else { + return utcString } - static func convertUTCToLocal(_ utcString: String) -> String { - guard let date = NodeSettingsResponseParser.utcDate(fromClockResponse: utcString) else { - return utcString - } - - let timeString = date.formatted(date: .omitted, time: .shortened) - let dateString = date.formatted(.dateTime.year(.twoDigits).month(.twoDigits).day(.twoDigits)) - return "\(timeString) - \(dateString)" + let timeString = date.formatted(date: .omitted, time: .shortened) + let dateString = date.formatted(.dateTime.year(.twoDigits).month(.twoDigits).day(.twoDigits)) + return "\(timeString) - \(dateString)" + } + + // MARK: - Identity + + var name: String? + var latitude: Double? + var longitude: Double? + private(set) var originalName: String? + private(set) var originalLatitude: Double? + private(set) var originalLongitude: Double? + var isLoadingIdentity = false + var identityError = false + var identityLoaded: Bool { + originalLatitude != nil || originalLongitude != nil + } + + var identitySettingsModified: Bool { + (name != nil && name != originalName) || + (latitude != nil && latitude != originalLatitude) || + (longitude != nil && longitude != originalLongitude) + } + + var nameError: String? + var latitudeError: String? + var longitudeError: String? + + // MARK: - Radio + + var frequency: Double? + var bandwidth: Double? + var spreadingFactor: Int? + var codingRate: Int? + var txPower: Int? + var isLoadingRadio = false + var radioError = false + var radioLoaded: Bool { + frequency != nil || txPower != nil + } + + var radioSettingsModified = false + + // MARK: - Contact Info + + /// Firmware limit on the `set owner.info` value length. + static let ownerInfoMaxLength = 119 + + var ownerInfo: String? + private(set) var originalOwnerInfo: String? + var isLoadingContactInfo = false + var contactInfoError = false + var contactInfoLoaded: Bool { + originalOwnerInfo != nil + } + + /// Gated on `contactInfoLoaded` so the text field's empty pre-fetch value can't + /// enable Apply and wipe the node's owner info before the current value arrives. + var contactInfoSettingsModified: Bool { + contactInfoLoaded && ownerInfo != originalOwnerInfo + } + + var ownerInfoCharCount: Int { + (ownerInfo ?? "").count + } + + var isOwnerInfoTooLong: Bool { + ownerInfoCharCount > Self.ownerInfoMaxLength + } + + // MARK: - Security + + var newPassword: String = "" + var confirmPassword: String = "" + + // MARK: - Expansion State + + var isDeviceInfoExpanded = false + var isRadioExpanded = false + var isIdentityExpanded = false + var isContactInfoExpanded = false + var isSecurityExpanded = false + + // MARK: - Global State + + var isApplying = false + var isRebooting = false + var errorMessage: String? + var successMessage: String? + var showSuccessAlert = false + var identityApplySuccess = false + var contactInfoApplySuccess = false + var changePasswordSuccess = false + var isSendingAdvert = false + + // MARK: - Service Closures + + private var sendCommandClosure: ((UUID, String, Duration) async throws -> String)? + private var sendRawCommandClosure: ((UUID, String, Duration) async throws -> String)? + + /// Called when firmware version or node info needs pre-fetching. + /// Repeater sets this to binary requestOwnerInfo; Room sets this to CLI `ver`. + var onPreFetchNodeInfo: (() async -> Void)? + + // MARK: - Configuration + + func configure( + session: RemoteNodeSessionDTO, + sendCommand: @escaping (UUID, String, Duration) async throws -> String, + sendRawCommand: @escaping (UUID, String, Duration) async throws -> String + ) { + self.session = session + sendCommandClosure = sendCommand + sendRawCommandClosure = sendRawCommand + } + + /// Set name and owner info from an external source (e.g., binary protocol pre-fetch) + func setNodeInfo(firmwareVersion: String?, name: String?, ownerInfo: String?) { + if let firmwareVersion { self.firmwareVersion = firmwareVersion } + if let name { + self.name = name + originalName = name } - - // MARK: - Identity - - var name: String? - var latitude: Double? - var longitude: Double? - private(set) var originalName: String? - private(set) var originalLatitude: Double? - private(set) var originalLongitude: Double? - var isLoadingIdentity = false - var identityError = false - var identityLoaded: Bool { originalLatitude != nil || originalLongitude != nil } - - var identitySettingsModified: Bool { - (name != nil && name != originalName) || - (latitude != nil && latitude != originalLatitude) || - (longitude != nil && longitude != originalLongitude) + if let ownerInfo { + self.ownerInfo = ownerInfo + originalOwnerInfo = ownerInfo + } + } + + func cleanup() { + sendCommandClosure = nil + sendRawCommandClosure = nil + onPreFetchNodeInfo = nil + } + + // MARK: - CLI Transport + + func sendAndWait( + _ command: String, + timeout: Duration = .seconds(5), + rawMatching: Bool = false + ) async throws -> String { + guard let session, let sendCmd = rawMatching ? sendRawCommandClosure : sendCommandClosure else { + throw NodeSettingsError.noService } - // MARK: - Radio - - var frequency: Double? - var bandwidth: Double? - var spreadingFactor: Int? - var codingRate: Int? - var txPower: Int? - var isLoadingRadio = false - var radioError = false - var radioLoaded: Bool { frequency != nil || txPower != nil } - var radioSettingsModified = false - - // MARK: - Contact Info + let response = try await sendCmd(session.id, command, timeout) + logger.debug("Command '\(command)' response: \(response.prefix(50))") + return response + } - /// Firmware limit on the `set owner.info` value length. - static let ownerInfoMaxLength = 119 + // MARK: - Fetch Methods - var ownerInfo: String? - private(set) var originalOwnerInfo: String? - var isLoadingContactInfo = false - var contactInfoError = false - var contactInfoLoaded: Bool { originalOwnerInfo != nil } + func fetchDeviceInfo() async { + isLoadingDeviceInfo = true + deviceInfoError = false - /// Gated on `contactInfoLoaded` so the text field's empty pre-fetch value can't - /// enable Apply and wipe the node's owner info before the current value arrives. - var contactInfoSettingsModified: Bool { - contactInfoLoaded && ownerInfo != originalOwnerInfo + if firmwareVersion == nil { + await onPreFetchNodeInfo?() } - var ownerInfoCharCount: Int { - (ownerInfo ?? "").count + if firmwareVersion == nil { + do { + let response = try await sendAndWait("ver") + if case let .version(version) = CLIResponse.parse(response, forQuery: "ver") { + firmwareVersion = version + } + } catch { + if case RemoteNodeError.timeout = error { + deviceInfoError = true + } + logger.warning("Failed to get firmware version: \(error)") + } } - var isOwnerInfoTooLong: Bool { - ownerInfoCharCount > Self.ownerInfoMaxLength + do { + let response = try await sendAndWait("clock") + if case let .deviceTime(time) = CLIResponse.parse(response, forQuery: "clock") { + deviceTimeUTC = time + } + } catch { + if case RemoteNodeError.timeout = error { + deviceInfoError = true + } + logger.warning("Failed to get device time: \(error)") } - // MARK: - Security - - var newPassword: String = "" - var confirmPassword: String = "" - - // MARK: - Expansion State - - var isDeviceInfoExpanded = false - var isRadioExpanded = false - var isIdentityExpanded = false - var isContactInfoExpanded = false - var isSecurityExpanded = false - - // MARK: - Global State - - var isApplying = false - var isRebooting = false - var errorMessage: String? - var successMessage: String? - var showSuccessAlert = false - var identityApplySuccess = false - var contactInfoApplySuccess = false - var changePasswordSuccess = false - var isSendingAdvert = false + isLoadingDeviceInfo = false + } - // MARK: - Service Closures + func fetchIdentity() async { + isLoadingIdentity = true + identityError = false + var hadTimeout = false - private var sendCommandClosure: ((UUID, String, Duration) async throws -> String)? - private var sendRawCommandClosure: ((UUID, String, Duration) async throws -> String)? - - /// Called when firmware version or node info needs pre-fetching. - /// Repeater sets this to binary requestOwnerInfo; Room sets this to CLI `ver`. - var onPreFetchNodeInfo: (() async -> Void)? + if originalName == nil { + await onPreFetchNodeInfo?() + } - // MARK: - Configuration + if originalName == nil { + do { + let response = try await sendAndWait("get name") + if case let .name(n) = CLIResponse.parse(response, forQuery: "get name") { + name = n + originalName = n + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get name: \(error)") + } + } - func configure( - session: RemoteNodeSessionDTO, - sendCommand: @escaping (UUID, String, Duration) async throws -> String, - sendRawCommand: @escaping (UUID, String, Duration) async throws -> String - ) { - self.session = session - self.sendCommandClosure = sendCommand - self.sendRawCommandClosure = sendRawCommand + do { + let response = try await sendAndWait("get lat") + if case let .latitude(lat) = CLIResponse.parse(response, forQuery: "get lat") { + latitude = lat + originalLatitude = lat + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get latitude: \(error)") } - /// Set name and owner info from an external source (e.g., binary protocol pre-fetch) - func setNodeInfo(firmwareVersion: String?, name: String?, ownerInfo: String?) { - if let firmwareVersion { self.firmwareVersion = firmwareVersion } - if let name { - self.name = name - self.originalName = name - } - if let ownerInfo { - self.ownerInfo = ownerInfo - self.originalOwnerInfo = ownerInfo - } + do { + let response = try await sendAndWait("get lon") + if case let .longitude(lon) = CLIResponse.parse(response, forQuery: "get lon") { + longitude = lon + originalLongitude = lon + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get longitude: \(error)") } - func cleanup() { - sendCommandClosure = nil - sendRawCommandClosure = nil - onPreFetchNodeInfo = nil + if hadTimeout { + identityError = true } - // MARK: - CLI Transport + isLoadingIdentity = false + } + + func fetchRadioSettings() async { + isLoadingRadio = true + radioError = false + var hadTimeout = false + + do { + let response = try await sendAndWait("get tx") + if case let .txPower(power) = CLIResponse.parse(response, forQuery: "get tx") { + txPower = power + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get TX power: \(error)") + } - func sendAndWait( - _ command: String, - timeout: Duration = .seconds(5), - rawMatching: Bool = false - ) async throws -> String { - guard let session, let sendCmd = rawMatching ? sendRawCommandClosure : sendCommandClosure else { - throw NodeSettingsError.noService - } + do { + let response = try await sendAndWait("get radio") + if case let .radio(freq, bw, sf, cr) = CLIResponse.parse(response, forQuery: "get radio") { + frequency = freq + bandwidth = bw + spreadingFactor = sf + codingRate = cr + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get radio settings: \(error)") + } - let response = try await sendCmd(session.id, command, timeout) - logger.debug("Command '\(command)' response: \(response.prefix(50))") - return response + if hadTimeout { + radioError = true } - // MARK: - Fetch Methods + isLoadingRadio = false + } - func fetchDeviceInfo() async { - isLoadingDeviceInfo = true - deviceInfoError = false + func fetchContactInfo() async { + if originalOwnerInfo == nil { + await onPreFetchNodeInfo?() + } + if originalOwnerInfo != nil { return } + + isLoadingContactInfo = true + contactInfoError = false + + do { + let response = try await sendAndWait("get owner.info") + if case let .ownerInfo(info) = CLIResponse.parse(response, forQuery: "get owner.info") { + let displayText = NodeSettingsResponseParser.displayOwnerInfo(fromWire: info) + ownerInfo = displayText + originalOwnerInfo = displayText + } + } catch { + if case RemoteNodeError.timeout = error { + contactInfoError = true + } + logger.warning("Failed to get owner info: \(error)") + } - if firmwareVersion == nil { - await onPreFetchNodeInfo?() - } + isLoadingContactInfo = false + } - if firmwareVersion == nil { - do { - let response = try await sendAndWait("ver") - if case .version(let version) = CLIResponse.parse(response, forQuery: "ver") { - self.firmwareVersion = version - } - } catch { - if case RemoteNodeError.timeout = error { - deviceInfoError = true - } - logger.warning("Failed to get firmware version: \(error)") - } - } + // MARK: - Success Flash - do { - let response = try await sendAndWait("clock") - if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { - self.deviceTimeUTC = time - } - } catch { - if case RemoteNodeError.timeout = error { - deviceInfoError = true - } - logger.warning("Failed to get device time: \(error)") - } + /// How long an Apply button shows its success state before returning to idle. + static let successFlashDuration: Duration = .seconds(1.5) - isLoadingDeviceInfo = false + /// Drop the section's applying flag and flash its success indicator for + /// `successFlashDuration`. The closures target the section's own state, which + /// may live on this shared view model or on the owning view model. + func flashSuccess(setApplying: (Bool) -> Void, setSuccess: (Bool) -> Void) async { + withAnimation { + setApplying(false) + setSuccess(true) } + try? await Task.sleep(for: Self.successFlashDuration) + withAnimation { setSuccess(false) } + } - func fetchIdentity() async { - isLoadingIdentity = true - identityError = false - var hadTimeout = false - - if originalName == nil { - await onPreFetchNodeInfo?() - } - - if originalName == nil { - do { - let response = try await sendAndWait("get name") - if case .name(let n) = CLIResponse.parse(response, forQuery: "get name") { - self.name = n - self.originalName = n - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get name: \(error)") - } - } + // MARK: - Apply Methods - do { - let response = try await sendAndWait("get lat") - if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { - self.latitude = lat - self.originalLatitude = lat - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get latitude: \(error)") - } + func applyRadioSettings() async { + guard let frequency, let bandwidth, let spreadingFactor, let codingRate, let txPower else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioNotLoaded + return + } - do { - let response = try await sendAndWait("get lon") - if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { - self.longitude = lon - self.originalLongitude = lon - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get longitude: \(error)") - } + isApplying = true + errorMessage = nil + + do { + var allSucceeded = true + + let radioCommand = "set radio \(frequency),\(bandwidth),\(spreadingFactor),\(codingRate)" + let radioResponse = try await sendAndWait(radioCommand) + if case .ok = CLIResponse.parse(radioResponse) { + } else { + allSucceeded = false + } + + let txCommand = "set tx \(txPower)" + let txResponse = try await sendAndWait(txCommand) + if case .ok = CLIResponse.parse(txResponse) { + } else { + allSucceeded = false + } + + if allSucceeded { + radioSettingsModified = false + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioAppliedSuccess + showSuccessAlert = true + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioApplyFailed + } + } catch { + errorMessage = error.userFacingMessage + } - if hadTimeout { - identityError = true - } + isApplying = false + } + + func applyIdentitySettings() async { + let validation = Self.validateIdentityFields(name: name, latitude: latitude, longitude: longitude) + nameError = validation.name + latitudeError = validation.latitude + longitudeError = validation.longitude + if validation.hasErrors { return } + + isApplying = true + errorMessage = nil + + do { + var allSucceeded = true + + if let name, name != originalName { + let response = try await sendAndWait("set name \(name)") + if case .ok = CLIResponse.parse(response) { + originalName = name + } else { + allSucceeded = false + } + } + + if let latitude, latitude != originalLatitude { + let response = try await sendAndWait("set lat \(latitude)") + if case .ok = CLIResponse.parse(response) { + originalLatitude = latitude + } else { + allSucceeded = false + } + } + + if let longitude, longitude != originalLongitude { + let response = try await sendAndWait("set lon \(longitude)") + if case .ok = CLIResponse.parse(response) { + originalLongitude = longitude + } else { + allSucceeded = false + } + } + + if allSucceeded { + await flashSuccess( + setApplying: { isApplying = $0 }, + setSuccess: { identityApplySuccess = $0 } + ) + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + errorMessage = error.userFacingMessage + } - isLoadingIdentity = false + isApplying = false + } + + func applyContactInfoSettings() async { + isApplying = true + errorMessage = nil + + do { + let pipeText = NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: ownerInfo ?? "") + let response = try await sendAndWait("set owner.info \(pipeText)") + if case .ok = CLIResponse.parse(response) { + originalOwnerInfo = ownerInfo + await flashSuccess( + setApplying: { isApplying = $0 }, + setSuccess: { contactInfoApplySuccess = $0 } + ) + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + errorMessage = error.userFacingMessage } - func fetchRadioSettings() async { - isLoadingRadio = true - radioError = false - var hadTimeout = false + isApplying = false + } - do { - let response = try await sendAndWait("get tx") - if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { - self.txPower = power - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get TX power: \(error)") - } + // MARK: - Location Picker - do { - let response = try await sendAndWait("get radio") - if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { - self.frequency = freq - self.bandwidth = bw - self.spreadingFactor = sf - self.codingRate = cr - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get radio settings: \(error)") - } + func setLocationFromPicker(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } - if hadTimeout { - radioError = true - } + // MARK: - Security - isLoadingRadio = false + func changePassword() async { + guard !newPassword.isEmpty else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordEmpty + return } - - func fetchContactInfo() async { - if originalOwnerInfo == nil { - await onPreFetchNodeInfo?() - } - if originalOwnerInfo != nil { return } - - isLoadingContactInfo = true - contactInfoError = false - - do { - let response = try await sendAndWait("get owner.info") - if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { - let displayText = NodeSettingsResponseParser.displayOwnerInfo(fromWire: info) - self.ownerInfo = displayText - self.originalOwnerInfo = displayText - } - } catch { - if case RemoteNodeError.timeout = error { - contactInfoError = true - } - logger.warning("Failed to get owner info: \(error)") - } - - isLoadingContactInfo = false + guard newPassword == confirmPassword else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordMismatch + return } - // MARK: - Success Flash - - /// How long an Apply button shows its success state before returning to idle. - static let successFlashDuration: Duration = .seconds(1.5) - - /// Drop the section's applying flag and flash its success indicator for - /// `successFlashDuration`. The closures target the section's own state, which - /// may live on this shared view model or on the owning view model. - func flashSuccess(setApplying: (Bool) -> Void, setSuccess: (Bool) -> Void) async { - withAnimation { - setApplying(false) - setSuccess(true) - } - try? await Task.sleep(for: Self.successFlashDuration) - withAnimation { setSuccess(false) } + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("password \(newPassword)", rawMatching: true) + if NodeSettingsResponseParser.isPasswordChangeSuccessful(response) { + newPassword = "" + confirmPassword = "" + await flashSuccess( + setApplying: { isApplying = $0 }, + setSuccess: { changePasswordSuccess = $0 } + ) + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangeFailed + } + } catch { + errorMessage = error.userFacingMessage } - // MARK: - Apply Methods + isApplying = false + } - func applyRadioSettings() async { - guard let frequency, let bandwidth, let spreadingFactor, let codingRate, let txPower else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioNotLoaded - return - } - - isApplying = true - errorMessage = nil - - do { - var allSucceeded = true - - let radioCommand = "set radio \(frequency),\(bandwidth),\(spreadingFactor),\(codingRate)" - let radioResponse = try await sendAndWait(radioCommand) - if case .ok = CLIResponse.parse(radioResponse) { - } else { - allSucceeded = false - } - - let txCommand = "set tx \(txPower)" - let txResponse = try await sendAndWait(txCommand) - if case .ok = CLIResponse.parse(txResponse) { - } else { - allSucceeded = false - } - - if allSucceeded { - radioSettingsModified = false - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioAppliedSuccess - showSuccessAlert = true - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioApplyFailed - } - } catch { - errorMessage = error.userFacingMessage - } + // MARK: - Device Actions - isApplying = false - } - - func applyIdentitySettings() async { - isApplying = true - errorMessage = nil - - do { - var allSucceeded = true - - if let name, name != originalName { - let response = try await sendAndWait("set name \(name)") - if case .ok = CLIResponse.parse(response) { - originalName = name - } else { - allSucceeded = false - } - } - - if let latitude, latitude != originalLatitude { - let response = try await sendAndWait("set lat \(latitude)") - if case .ok = CLIResponse.parse(response) { - originalLatitude = latitude - } else { - allSucceeded = false - } - } - - if let longitude, longitude != originalLongitude { - let response = try await sendAndWait("set lon \(longitude)") - if case .ok = CLIResponse.parse(response) { - originalLongitude = longitude - } else { - allSucceeded = false - } - } - - if allSucceeded { - await flashSuccess( - setApplying: { isApplying = $0 }, - setSuccess: { identityApplySuccess = $0 } - ) - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.userFacingMessage - } + func reboot() async { + guard session != nil else { return } - isApplying = false - } - - func applyContactInfoSettings() async { - isApplying = true - errorMessage = nil - - do { - let pipeText = NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: ownerInfo ?? "") - let response = try await sendAndWait("set owner.info \(pipeText)") - if case .ok = CLIResponse.parse(response) { - originalOwnerInfo = ownerInfo - await flashSuccess( - setApplying: { isApplying = $0 }, - setSuccess: { contactInfoApplySuccess = $0 } - ) - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.userFacingMessage - } + isRebooting = true + errorMessage = nil - isApplying = false + do { + _ = try await sendAndWait("reboot") + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.rebootSent + showSuccessAlert = true + } catch { + errorMessage = error.userFacingMessage } - // MARK: - Location Picker - - func setLocationFromPicker(latitude: Double, longitude: Double) { - self.latitude = latitude - self.longitude = longitude + isRebooting = false + } + + func forceAdvert() async { + isSendingAdvert = true + defer { isSendingAdvert = false } + do { + _ = try await sendAndWait("advert") + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.advertSent + showSuccessAlert = true + } catch { + errorMessage = error.userFacingMessage } - - // MARK: - Security - - func changePassword() async { - guard !newPassword.isEmpty else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordEmpty - return - } - guard newPassword == confirmPassword else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordMismatch - return - } - - isApplying = true - errorMessage = nil - - do { - let response = try await sendAndWait("password \(newPassword)", rawMatching: true) - if NodeSettingsResponseParser.isPasswordChangeSuccessful(response) { - newPassword = "" - confirmPassword = "" - await flashSuccess( - setApplying: { isApplying = $0 }, - setSuccess: { changePasswordSuccess = $0 } - ) - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangeFailed - } - } catch { - errorMessage = error.userFacingMessage - } - - isApplying = false + } + + func syncTime() async { + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("clock sync") + switch NodeSettingsResponseParser.classifyClockSyncResponse(response) { + case .synced: + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced + showSuccessAlert = true + case .clockAhead: + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.clockAheadError + case let .failed(message): + errorMessage = message.isEmpty ? L10n.RemoteNodes.RemoteNodes.Settings.syncTimeFailed : message + case .unexpected: + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.unexpectedResponse(response) + } + } catch { + errorMessage = error.userFacingMessage } - // MARK: - Device Actions - - func reboot() async { - guard session != nil else { return } + isApplying = false + } - isRebooting = true - errorMessage = nil + // MARK: - Shared Validation - do { - _ = try await sendAndWait("reboot") - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.rebootSent - showSuccessAlert = true - } catch { - errorMessage = error.userFacingMessage - } + /// Firmware-accepted ranges for the behavior fields; 0 means disabled for + /// the two intervals and is validated separately. + static let advertIntervalMinutesRange = 60...240 + static let floodIntervalHoursRange = 3...168 + static let floodMaxHopsRange = 0...64 - isRebooting = false + struct BehaviorValidationErrors { + var advertInterval: String? + var floodInterval: String? + var floodMaxHops: String? + var hasErrors: Bool { + advertInterval != nil || floodInterval != nil || floodMaxHops != nil } - - func forceAdvert() async { - isSendingAdvert = true - defer { isSendingAdvert = false } - do { - _ = try await sendAndWait("advert") - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.advertSent - showSuccessAlert = true - } catch { - errorMessage = error.userFacingMessage - } + } + + static func validateBehaviorFields( + advertInterval: Int?, + floodInterval: Int?, + floodMaxHops: Int? + ) -> BehaviorValidationErrors { + var errors = BehaviorValidationErrors() + if let interval = advertInterval, interval != 0, !advertIntervalMinutesRange.contains(interval) { + errors.advertInterval = L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalValidation } - - func syncTime() async { - isApplying = true - errorMessage = nil - - do { - let response = try await sendAndWait("clock sync") - switch NodeSettingsResponseParser.classifyClockSyncResponse(response) { - case .synced: - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced - showSuccessAlert = true - case .clockAhead: - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.clockAheadError - case .failed(let message): - errorMessage = message.isEmpty ? L10n.RemoteNodes.RemoteNodes.Settings.syncTimeFailed : message - case .unexpected: - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.unexpectedResponse(response) - } - } catch { - errorMessage = error.userFacingMessage - } - - isApplying = false + if let interval = floodInterval, interval != 0, !floodIntervalHoursRange.contains(interval) { + errors.floodInterval = L10n.RemoteNodes.RemoteNodes.Settings.floodIntervalValidation } - - // MARK: - Shared Validation - - /// Firmware-accepted ranges for the behavior fields; 0 means disabled for - /// the two intervals and is validated separately. - static let advertIntervalMinutesRange = 60...240 - static let floodIntervalHoursRange = 3...168 - static let floodMaxHopsRange = 0...64 - - struct BehaviorValidationErrors { - var advertInterval: String? - var floodInterval: String? - var floodMaxHops: String? - var hasErrors: Bool { advertInterval != nil || floodInterval != nil || floodMaxHops != nil } + if let hops = floodMaxHops, !floodMaxHopsRange.contains(hops) { + errors.floodMaxHops = L10n.RemoteNodes.RemoteNodes.Settings.floodMaxValidation } + return errors + } - static func validateBehaviorFields( - advertInterval: Int?, - floodInterval: Int?, - floodMaxHops: Int? - ) -> BehaviorValidationErrors { - var errors = BehaviorValidationErrors() - if let interval = advertInterval, interval != 0 && !advertIntervalMinutesRange.contains(interval) { - errors.advertInterval = L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalValidation - } - if let interval = floodInterval, interval != 0 && !floodIntervalHoursRange.contains(interval) { - errors.floodInterval = L10n.RemoteNodes.RemoteNodes.Settings.floodIntervalValidation - } - if let hops = floodMaxHops, !floodMaxHopsRange.contains(hops) { - errors.floodMaxHops = L10n.RemoteNodes.RemoteNodes.Settings.floodMaxValidation - } - return errors + struct IdentityValidationErrors { + var name: String? + var latitude: String? + var longitude: String? + var hasErrors: Bool { + name != nil || latitude != nil || longitude != nil } - - // MARK: - Late Response Handling - - /// The settings fields a late response may still fill, ordered so numeric - /// fields are tried before the free-form name (which matches any text). - private var missingLateResponseFields: [NodeSettingsResponseParser.SettingsField] { - var fields: [NodeSettingsResponseParser.SettingsField] = [] - if !isLoadingRadio && radioError { - if frequency == nil { fields.append(.radio) } - if txPower == nil { fields.append(.txPower) } - } - if !isLoadingDeviceInfo && deviceInfoError { - if firmwareVersion == nil { fields.append(.firmwareVersion) } - if deviceTimeUTC == nil { fields.append(.deviceTime) } - } - if !isLoadingIdentity && identityError { - if originalLatitude == nil { fields.append(.latitude) } - if originalLongitude == nil { fields.append(.longitude) } - if originalName == nil { fields.append(.name) } - } - if !isLoadingContactInfo && contactInfoError { - if originalOwnerInfo == nil { fields.append(.ownerInfo) } - } - return fields + } + + /// Rejects out-of-range coordinates rather than clamping, so a mistyped value surfaces to the + /// user instead of firmware silently normalizing it. Ranges and the name byte cap come from + /// `PacketBuilder` and `ProtocolLimits`, matching the binary write path. + static func validateIdentityFields( + name: String?, + latitude: Double?, + longitude: Double? + ) -> IdentityValidationErrors { + var errors = IdentityValidationErrors() + if let name, name.utf8.count > ProtocolLimits.maxUsableNameBytes { + errors.name = L10n.RemoteNodes.RemoteNodes.Settings.nameValidation(ProtocolLimits.maxUsableNameBytes) } - - /// Handle late CLI responses for shared sections. - /// Returns `true` if the response was consumed. - func handleCommonLateResponse(_ response: String) -> Bool { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: response, - checking: missingLateResponseFields - ) - guard let value else { return false } - - switch value { - case .radio(let frequency, let bandwidth, let spreadingFactor, let codingRate): - self.frequency = frequency - self.bandwidth = bandwidth - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.radioError = false - logger.info("Late response: received radio settings") - case .txPower(let power): - self.txPower = power - self.radioError = false - logger.info("Late response: received TX power") - case .firmwareVersion(let version): - self.firmwareVersion = version - self.deviceInfoError = false - logger.info("Late response: received firmware version") - case .deviceTime(let time): - self.deviceTimeUTC = time - self.deviceInfoError = false - logger.info("Late response: received device time") - case .latitude(let latitude): - self.latitude = latitude - self.originalLatitude = latitude - self.identityError = false - logger.info("Late response: received latitude") - case .longitude(let longitude): - self.longitude = longitude - self.originalLongitude = longitude - self.identityError = false - logger.info("Late response: received longitude") - case .name(let name): - self.name = name - self.originalName = name - self.identityError = false - logger.info("Late response: received name") - case .ownerInfo(let info): - let displayText = NodeSettingsResponseParser.displayOwnerInfo(fromWire: info) - self.ownerInfo = displayText - self.originalOwnerInfo = displayText - self.contactInfoError = false - logger.info("Late response: received owner info") - } - return true + if let latitude, !latitude.isFinite || !PacketBuilder.latitudeRange.contains(latitude) { + errors.latitude = L10n.RemoteNodes.RemoteNodes.Settings.latitudeValidation + } + if let longitude, !longitude.isFinite || !PacketBuilder.longitudeRange.contains(longitude) { + errors.longitude = L10n.RemoteNodes.RemoteNodes.Settings.longitudeValidation + } + return errors + } + + // MARK: - Late Response Handling + + /// The settings fields a late response may still fill, ordered so numeric + /// fields are tried before the free-form name (which matches any text). + private var missingLateResponseFields: [NodeSettingsResponseParser.SettingsField] { + var fields: [NodeSettingsResponseParser.SettingsField] = [] + if !isLoadingRadio, radioError { + if frequency == nil { fields.append(.radio) } + if txPower == nil { fields.append(.txPower) } + } + if !isLoadingDeviceInfo, deviceInfoError { + if firmwareVersion == nil { fields.append(.firmwareVersion) } + if deviceTimeUTC == nil { fields.append(.deviceTime) } + } + if !isLoadingIdentity, identityError { + if originalLatitude == nil { fields.append(.latitude) } + if originalLongitude == nil { fields.append(.longitude) } + if originalName == nil { fields.append(.name) } + } + if !isLoadingContactInfo, contactInfoError { + if originalOwnerInfo == nil { fields.append(.ownerInfo) } + } + return fields + } + + /// Handle late CLI responses for shared sections. + /// Returns `true` if the response was consumed. + func handleCommonLateResponse(_ response: String) -> Bool { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: response, + checking: missingLateResponseFields + ) + guard let value else { return false } + + switch value { + case let .radio(frequency, bandwidth, spreadingFactor, codingRate): + self.frequency = frequency + self.bandwidth = bandwidth + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + radioError = false + logger.info("Late response: received radio settings") + case let .txPower(power): + txPower = power + radioError = false + logger.info("Late response: received TX power") + case let .firmwareVersion(version): + firmwareVersion = version + deviceInfoError = false + logger.info("Late response: received firmware version") + case let .deviceTime(time): + deviceTimeUTC = time + deviceInfoError = false + logger.info("Late response: received device time") + case let .latitude(latitude): + self.latitude = latitude + originalLatitude = latitude + identityError = false + logger.info("Late response: received latitude") + case let .longitude(longitude): + self.longitude = longitude + originalLongitude = longitude + identityError = false + logger.info("Late response: received longitude") + case let .name(name): + self.name = name + originalName = name + identityError = false + logger.info("Late response: received name") + case let .ownerInfo(info): + let displayText = NodeSettingsResponseParser.displayOwnerInfo(fromWire: info) + ownerInfo = displayText + originalOwnerInfo = displayText + contactInfoError = false + logger.info("Late response: received owner info") } + return true + } } diff --git a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift index d9cbf70a..0f9dbdc9 100644 --- a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift +++ b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift @@ -3,41 +3,46 @@ import SwiftUI /// Drill-down view showing historical charts for status metrics (battery, SNR, RSSI, noise floor). struct NodeStatusHistoryView: View { - @Environment(\.appTheme) private var theme - let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] - let ocvArray: [Int] + @Environment(\.appTheme) private var theme + let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] + let ocvArray: [Int] - @State private var snapshots: [NodeStatusSnapshotDTO] = [] - @State private var timeRange: HistoryTimeRange = .default + @State private var snapshots: [NodeStatusSnapshotDTO] = [] + @State private var timeRange: HistoryTimeRange = .default - private var filteredSnapshots: [NodeStatusSnapshotDTO] { - guard let start = timeRange.startDate else { return snapshots } - return snapshots.filter { $0.timestamp >= start } - } - - var body: some View { - List { - HistoryTimeRangePicker(selection: $timeRange) + private var filteredSnapshots: [NodeStatusSnapshotDTO] { + guard let start = timeRange.startDate else { return snapshots } + return snapshots.filter { $0.timestamp >= start } + } - RadioMetricCharts(snapshots: filteredSnapshots, ocvArray: ocvArray) { chart in - Section { - chart - } - .themedRowBackground(theme) - } + var body: some View { + List { + HistoryTimeRangePicker(selection: $timeRange) - Section { - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) - } - .themedRowBackground(theme) + RadioMetricCharts(snapshots: filteredSnapshots, ocvArray: ocvArray) { chart in + Section { + chart } - .themedCanvas(theme) - .chartScrubbingScrollLock() - .navigationTitle(L10n.RemoteNodes.RemoteNodes.History.title) - .liquidGlassToolbarBackground() - .task { - snapshots = await fetchSnapshots() + .themedRowBackground(theme) + } packetSection: { group in + Section(L10n.RemoteNodes.RemoteNodes.History.packets) { + group } + .themedRowBackground(theme) + } + + Section {} footer: { + Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) + } + .themedRowBackground(theme) + } + .listSectionSpacing(.compact) + .themedCanvas(theme) + .chartScrubbingScrollLock() + .navigationTitle(L10n.RemoteNodes.RemoteNodes.History.title) + .liquidGlassToolbarBackground() + .task { + snapshots = await fetchSnapshots() } + } } diff --git a/MC1/Views/RemoteNodes/NodeStatusRoute.swift b/MC1/Views/RemoteNodes/NodeStatusRoute.swift index 0fa6cee9..6d11d649 100644 --- a/MC1/Views/RemoteNodes/NodeStatusRoute.swift +++ b/MC1/Views/RemoteNodes/NodeStatusRoute.swift @@ -4,30 +4,30 @@ import SwiftUI /// History drill-downs pushed from the shared node status sections. Value-based so each /// push rebuilds the destination instead of reusing stale `@State` from a prior visit. enum NodeStatusRoute: Hashable { - case statusHistory - case telemetryHistory - case neighborChart(name: String, neighborPrefix: Data) + case statusHistory + case telemetryHistory + case neighborChart(name: String, neighborPrefix: Data) } extension View { - /// Registers the `NodeStatusRoute` destinations on the enclosing navigation stack, - /// resolving each route against the host's `NodeStatusViewModel`. Apply to the `List` - /// hosting the shared status sections, not to rows inside it. - @MainActor - func nodeStatusDestinations(helper: NodeStatusViewModel) -> some View { - navigationDestination(for: NodeStatusRoute.self) { route in - switch route { - case .statusHistory: - NodeStatusHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) - case .telemetryHistory: - TelemetryHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) - case .neighborChart(let name, let neighborPrefix): - NeighborSNRChartView( - name: name, - neighborPrefix: neighborPrefix, - fetchSnapshots: helper.fetchHistory - ) - } - } + /// Registers the `NodeStatusRoute` destinations on the enclosing navigation stack, + /// resolving each route against the host's `NodeStatusViewModel`. Apply to the `List` + /// hosting the shared status sections, not to rows inside it. + @MainActor + func nodeStatusDestinations(helper: NodeStatusViewModel) -> some View { + navigationDestination(for: NodeStatusRoute.self) { route in + switch route { + case .statusHistory: + NodeStatusHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) + case .telemetryHistory: + TelemetryHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) + case let .neighborChart(name, neighborPrefix): + NeighborSNRChartView( + name: name, + neighborPrefix: neighborPrefix, + fetchSnapshots: helper.fetchHistory + ) + } } + } } diff --git a/MC1/Views/RemoteNodes/NodeStatusViewModel.swift b/MC1/Views/RemoteNodes/NodeStatusViewModel.swift index b5400423..de6e3c92 100644 --- a/MC1/Views/RemoteNodes/NodeStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeStatusViewModel.swift @@ -1,5 +1,5 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "NodeStatusViewModel") @@ -10,493 +10,529 @@ private let logger = Logger(subsystem: "com.mc1", category: "NodeStatusViewModel @Observable @MainActor final class NodeStatusViewModel { + // MARK: - Properties - // MARK: - Properties - - /// Current session - var session: RemoteNodeSessionDTO? - - /// Public key for direct telemetry (no remote session). - /// Used for chat nodes that don't require login. - private var directPublicKey: Data? - - /// The public key to use for requests and history — prefers session, falls back to direct. - var effectivePublicKey: Data? { - session?.publicKey ?? directPublicKey - } - - /// 6-byte prefix for response matching. - var effectivePublicKeyPrefix: Data? { - session?.publicKeyPrefix ?? directPublicKey?.prefix(6) - } - - /// Last received status - var status: RemoteNodeStatus? - - /// Last received telemetry - var telemetry: TelemetryResponse? - - /// Cached decoded data points to avoid repeated LPP decoding. - private(set) var cachedDataPoints: [LPPDataPoint] = [] - - /// Loading states - var isLoadingStatus = false - var isLoadingTelemetry = false - - /// Whether a status response has been applied since the sheet opened. - /// Drives lazy loading of the status counters section. - var statusLoaded = false - - /// Whether the status disclosure group is expanded - var statusExpanded = false - - /// Whether telemetry has been loaded at least once (for refresh logic) - var telemetryLoaded = false + /// Current session + var session: RemoteNodeSessionDTO? - /// Whether the telemetry disclosure group is expanded - var telemetryExpanded = false + /// Public key for direct telemetry (no remote session). + /// Used for chat nodes that don't require login. + private var directPublicKey: Data? - /// Error text owned by the status counters section, scoped so a status - /// failure surfaces only under the status section once sections load independently. - var statusSectionError: String? + /// The public key to use for requests and history — prefers session, falls back to direct. + var effectivePublicKey: Data? { + session?.publicKey ?? directPublicKey + } - /// Error text owned by the telemetry section, scoped so a telemetry - /// failure surfaces only under the telemetry section once sections load independently. - var telemetrySectionError: String? + /// 6-byte prefix for response matching. + var effectivePublicKeyPrefix: Data? { + session?.publicKeyPrefix ?? directPublicKey?.prefix(6) + } - // MARK: - OCV Curve Properties + /// Last received status + var status: RemoteNodeStatus? - var isBatteryCurveExpanded = false - var selectedOCVPreset: OCVPreset = .liIon - var ocvValues: [Int] = OCVPreset.liIon.ocvArray - var ocvError: String? - private var contactID: UUID? - - // MARK: - Dependencies - - private var contactServiceProvider: @MainActor () -> ContactService? = { nil } - var contactService: ContactService? { contactServiceProvider() } - private var nodeSnapshotServiceProvider: @MainActor () -> NodeSnapshotService? = { nil } - var nodeSnapshotService: NodeSnapshotService? { nodeSnapshotServiceProvider() } - - // MARK: - Snapshot State - - /// Previous status-bearing snapshot for the status deltas, sourced from the last - /// snapshot that actually captured status so a neighbor- or telemetry-only row - /// can't blank the delta. - private(set) var previousStatusSnapshot: NodeStatusSnapshotDTO? - - /// Previous neighbor-bearing snapshot for the neighbor SNR delta. Owned by the - /// neighbor load path so the delta does not depend on the status section being - /// expanded, and sourced from the last snapshot that actually captured neighbors. - private(set) var previousNeighborSnapshot: NodeStatusSnapshotDTO? - - // MARK: - Initialization - - func configure( - contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? - ) { - self.contactServiceProvider = contactService - self.nodeSnapshotServiceProvider = nodeSnapshotService - } - - /// Configure for direct telemetry access (no login session). - /// Used for chat nodes that can be queried without authentication. - func configureForDirectTelemetry(publicKey: Data) { - self.directPublicKey = publicKey - } - - // MARK: - Transient Retry Machinery - - private static let requestTimeout: Duration = RemoteOperationTimeoutPolicy.binaryMaximum - - private static let transientRetryDelays: [Duration] = [ - .milliseconds(500), - .seconds(1), - .seconds(2), - ] - - func isTransientError(_ error: Error) -> Bool { - let meshError: MeshCoreError - switch error { - case let remoteError as RemoteNodeError: - guard case .sessionError(let inner) = remoteError else { return false } - meshError = inner - case let binaryError as BinaryProtocolError: - guard case .sessionError(let inner) = binaryError else { return false } - meshError = inner - default: - return false + /// Last received telemetry + var telemetry: TelemetryResponse? + + /// Cached decoded data points to avoid repeated LPP decoding. + private(set) var cachedDataPoints: [LPPDataPoint] = [] + + /// Loading states + var isLoadingStatus = false + var isLoadingTelemetry = false + + /// Whether a status response has been applied since the sheet opened. + /// Drives lazy loading of the status counters section. + var statusLoaded = false + + /// Whether the status disclosure group is expanded + var statusExpanded = false + + /// Whether telemetry has been loaded at least once (for refresh logic) + var telemetryLoaded = false + + /// Whether the telemetry disclosure group is expanded + var telemetryExpanded = false + + /// Error text owned by the status counters section, scoped so a status + /// failure surfaces only under the status section once sections load independently. + var statusSectionError: String? + + /// Error text owned by the telemetry section, scoped so a telemetry + /// failure surfaces only under the telemetry section once sections load independently. + var telemetrySectionError: String? + + // MARK: - OCV Curve Properties + + var isBatteryCurveExpanded = false + var selectedOCVPreset: OCVPreset = .liIon + var ocvValues: [Int] = OCVPreset.liIon.ocvArray + var ocvError: String? + private var contactID: UUID? + + // MARK: - Dependencies + + private var contactServiceProvider: @MainActor () -> ContactService? = { nil } + var contactService: ContactService? { + contactServiceProvider() + } + + private var nodeSnapshotServiceProvider: @MainActor () -> NodeSnapshotService? = { nil } + var nodeSnapshotService: NodeSnapshotService? { + nodeSnapshotServiceProvider() + } + + // MARK: - Snapshot State + + /// Previous status-bearing snapshot for the status deltas, sourced from the last + /// snapshot that actually captured status so a neighbor- or telemetry-only row + /// can't blank the delta. + private(set) var previousStatusSnapshot: NodeStatusSnapshotDTO? + + /// Previous neighbor-bearing snapshot for the neighbor SNR delta. Owned by the + /// neighbor load path so the delta does not depend on the status section being + /// expanded, and sourced from the last snapshot that actually captured neighbors. + private(set) var previousNeighborSnapshot: NodeStatusSnapshotDTO? + + /// Every neighbor prefix seen across stored history before the current reading, + /// for the "New" badge. A prefix absent here means the neighbor is first-seen. + private(set) var seenNeighborPrefixes: Set = [] + + // MARK: - Initialization + + func configure( + contactService: @escaping @MainActor () -> ContactService?, + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? + ) { + contactServiceProvider = contactService + nodeSnapshotServiceProvider = nodeSnapshotService + } + + /// Configure for direct telemetry access (no login session). + /// Used for chat nodes that can be queried without authentication. + func configureForDirectTelemetry(publicKey: Data) { + directPublicKey = publicKey + } + + // MARK: - Transient Retry Machinery + + private static let requestTimeout: Duration = RemoteOperationTimeoutPolicy.binaryMaximum + + private static let transientRetryDelays: [Duration] = [ + .milliseconds(500), + .seconds(1), + .seconds(2), + ] + + func isTransientError(_ error: Error) -> Bool { + let meshError: MeshCoreError + switch error { + case let remoteError as RemoteNodeError: + guard case let .sessionError(inner) = remoteError else { return false } + meshError = inner + case let binaryError as BinaryProtocolError: + guard case let .sessionError(inner) = binaryError else { return false } + meshError = inner + default: + return false + } + guard case let .deviceError(code) = meshError else { return false } + return code == FirmwareDeviceErrorCode.remoteNodeNoResponseYet + } + + private func remainingBudget(until deadline: ContinuousClock.Instant) -> Duration? { + let remaining = deadline - .now + return remaining > .zero ? remaining : nil + } + + private func waitForRetry(delay: Duration, until deadline: ContinuousClock.Instant) async throws { + guard let remaining = remainingBudget(until: deadline) else { + throw RemoteNodeError.timeout + } + try await Task.sleep(for: min(delay, remaining)) + } + + func performWithTransientRetries( + operationName: String, + operation: @escaping @Sendable (Duration) async throws -> T + ) async throws -> T { + let deadline = ContinuousClock.now.advanced(by: Self.requestTimeout) + var delayIterator = Self.transientRetryDelays.makeIterator() + + while true { + guard let timeout = remainingBudget(until: deadline) else { + logger.warning("\(operationName, privacy: .public) request exhausted its shared timeout budget") + throw RemoteNodeError.timeout + } + + do { + return try await operation(timeout) + } catch { + guard isTransientError(error), let delay = delayIterator.next() else { + throw error } - guard case .deviceError(let code) = meshError else { return false } - return code == FirmwareDeviceErrorCode.remoteNodeNoResponseYet - } - - private func remainingBudget(until deadline: ContinuousClock.Instant) -> Duration? { - let remaining = deadline - .now - return remaining > .zero ? remaining : nil - } - - private func waitForRetry(delay: Duration, until deadline: ContinuousClock.Instant) async throws { - guard let remaining = remainingBudget(until: deadline) else { - throw RemoteNodeError.timeout - } - try await Task.sleep(for: min(delay, remaining)) - } - - func performWithTransientRetries( - operationName: String, - operation: @escaping @Sendable (Duration) async throws -> T - ) async throws -> T { - let deadline = ContinuousClock.now.advanced(by: Self.requestTimeout) - var delayIterator = Self.transientRetryDelays.makeIterator() - - while true { - guard let timeout = remainingBudget(until: deadline) else { - logger.warning("\(operationName, privacy: .public) request exhausted its shared timeout budget") - throw RemoteNodeError.timeout + try await waitForRetry(delay: delay, until: deadline) + } + } + } + + /// Drive a section request through the shared retry budget, owning the + /// loading flag and section-error scaffold that the admin status view models + /// otherwise repeat verbatim. The `setLoading`/`setError` closures target the + /// section's own state (some live on this helper, some on the view model); + /// `onSuccess` applies the response. A `RemoteNodeError.timeout` surfaces the + /// shared timed-out string, any other error its user-facing message. + func runRetryingSectionRequest( + operationName: String, + setLoading: @MainActor (Bool) -> Void, + setError: @MainActor (String?) -> Void, + operation: @escaping @Sendable (Duration) async throws -> T, + onSuccess: @MainActor (T) async -> Void + ) async { + setLoading(true) + setError(nil) + do { + let response = try await performWithTransientRetries(operationName: operationName, operation: operation) + await onSuccess(response) + } catch RemoteNodeError.timeout { + setError(L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut) + } catch { + setError(error.userFacingMessage) + } + setLoading(false) + } + + // MARK: - Status Response Handling + + /// Handle a status response, saving a snapshot with role-specific fields. + /// `rxAirtimeSeconds` and `receiveErrors` are present in all wire frames + /// but rooms pass `nil` to skip persistence of repeater-specific metrics. + func handleStatusResponse( + _ response: RemoteNodeStatus, + rxAirtimeSeconds: UInt32? = nil, + receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) async { + guard let expectedPrefix = session?.publicKeyPrefix, + response.publicKeyPrefix == expectedPrefix else { + return + } + status = response + statusLoaded = true + isLoadingStatus = false + statusSectionError = nil + + guard let nodeSnapshotService, let session else { return } + + previousStatusSnapshot = await nodeSnapshotService.previousStatusSnapshot( + for: session.publicKey, + before: .now + ) + + let metrics = NodeStatusMetrics( + status: response, + rxAirtimeSeconds: rxAirtimeSeconds, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + _ = await nodeSnapshotService.recordSnapshot( + nodePublicKey: session.publicKey, + status: metrics + ) + } + + /// Capture neighbor data onto the node's current in-window snapshot, creating + /// one if none exists yet. Safe to call before a status response: the store + /// enriches the latest in-window row or inserts a neighbor-bearing snapshot. + func enrichNeighbors(_ entries: [NeighborSnapshotEntry]) async { + guard let nodeSnapshotService, let nodePublicKey = effectivePublicKey else { return } + // Capture the prior baseline before persisting this reading, so the delta and + // the "New" badge reflect history rather than the current capture. Both come + // from one await so a render never pairs a fresh baseline with a stale seen set. + let baseline = await nodeSnapshotService.neighborBaseline(for: nodePublicKey) + previousNeighborSnapshot = baseline.previous + seenNeighborPrefixes = baseline.seenPrefixes + _ = await nodeSnapshotService.recordSnapshot( + nodePublicKey: nodePublicKey, + neighbors: entries + ) + } + + // MARK: - Telemetry Response Handling + + func handleTelemetryResponse(_ response: TelemetryResponse) async { + guard let expectedPrefix = effectivePublicKeyPrefix, + response.publicKeyPrefix == expectedPrefix else { + return + } + telemetry = response + cachedDataPoints = response.dataPoints.filter { $0.channel != 0 } + isLoadingTelemetry = false + telemetryLoaded = true + telemetrySectionError = nil + + let entries: [TelemetrySnapshotEntry] = cachedDataPoints.compactMap { dp in + let numericValue: Double? = switch dp.value { + case let .float(value): + value + case let .integer(value): + Double(value) + default: + nil + } + guard let value = numericValue else { return nil } + return TelemetrySnapshotEntry(channel: Int(dp.channel), type: dp.typeName, value: value) + } + guard !entries.isEmpty, + let nodeSnapshotService, + let nodePublicKey = effectivePublicKey else { return } + + _ = await nodeSnapshotService.recordSnapshot( + nodePublicKey: nodePublicKey, + telemetry: entries + ) + } + + // MARK: - Telemetry Grouping + + var hasMultipleChannels: Bool { + let channels = Set(cachedDataPoints.map(\.channel)) + return channels.count > 1 + } + + var groupedDataPoints: [(channel: UInt8, dataPoints: [LPPDataPoint])] { + Dictionary(grouping: cachedDataPoints, by: \.channel) + .sorted { $0.key < $1.key } + .map { (channel: $0.key, dataPoints: $0.value) } + } + + // MARK: - Display Formatters + + static let emDash = "—" + private static let secondsPerMinute: UInt32 = 60 + private static let secondsPerHour: UInt32 = 3600 + private static let secondsPerDay: UInt32 = 86400 + + var uptimeDisplay: String { + guard let uptime = status?.uptimeSeconds else { return Self.emDash } + return Self.formatDuration(uptime) + } + + var airtimeDisplay: String { + guard let status else { return Self.emDash } + let tx = Self.formatDuration(status.airtime) + let rx = Self.formatDuration(status.rxAirtime) + return "TX \(tx) / RX \(rx)" + } + + private static let airtimePercentFractionDigits = 1 + + var airtimePercentDisplay: String { + guard let status, status.uptimeSeconds > 0 else { return Self.emDash } + let denom = Double(status.uptimeSeconds) + let txPercent = Double(status.airtime) / denom * 100 + let rxPercent = Double(status.rxAirtime) / denom * 100 + return "TX \(Self.formatPercent(txPercent)) / RX \(Self.formatPercent(rxPercent))" + } + + private static func formatPercent(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(airtimePercentFractionDigits))) + "%" + } + + private static func formatDuration(_ seconds: UInt32) -> String { + let days = Int(seconds / secondsPerDay) + let hours = Int((seconds % secondsPerDay) / secondsPerHour) + let minutes = Int((seconds % secondsPerHour) / secondsPerMinute) + + if days > 0 { + if days == 1 { + return L10n.RemoteNodes.RemoteNodes.Status.uptime1Day(hours, minutes) + } else { + return L10n.RemoteNodes.RemoteNodes.Status.uptimeDays(days, hours, minutes) + } + } else if hours > 0 { + return L10n.RemoteNodes.RemoteNodes.Status.uptimeHours(hours, minutes) + } + return L10n.RemoteNodes.RemoteNodes.Status.uptimeMinutes(minutes) + } + + var batteryDisplay: String { + guard let mv = status?.batteryMillivolts else { return Self.emDash } + let volts = Double(mv) / 1000.0 + let battery = BatteryInfo(level: Int(mv)) + let percent = battery.percentage(using: ocvValues) + return "\(volts.formatted(.number.precision(.fractionLength(3))))V (\(percent)%)" + } + + var lastRSSIDisplay: String { + guard let rssi = status?.lastRSSI else { return Self.emDash } + return "\(rssi) dBm" + } + + var lastSNRDisplay: String { + guard let snr = status?.lastSNR else { return Self.emDash } + return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB" + } + + var noiseFloorDisplay: String { + guard let nf = status?.noiseFloor else { return Self.emDash } + return "\(nf) dBm" + } + + var packetsSentDisplay: String { + guard let count = status?.packetsSent else { return Self.emDash } + return count.formatted() + } + + var packetsReceivedDisplay: String { + guard let count = status?.packetsReceived else { return Self.emDash } + return count.formatted() + } + + var sentDirectDisplay: String { + guard let count = status?.sentDirect else { return Self.emDash } + return count.formatted() + } + + var sentFloodDisplay: String { + guard let count = status?.sentFlood else { return Self.emDash } + return count.formatted() + } + + var receivedDirectDisplay: String { + guard let count = status?.receivedDirect else { return Self.emDash } + return count.formatted() + } + + var receivedFloodDisplay: String { + guard let count = status?.receivedFlood else { return Self.emDash } + return count.formatted() + } + + /// Combined direct + flood duplicates as a single total, matching the live card layout. + var duplicatesDisplay: String { + guard let status else { return Self.emDash } + return (status.directDuplicates + status.floodDuplicates).formatted() + } + + // MARK: - Delta Display + + var previousSnapshotTimestamp: String? { + guard let prev = previousStatusSnapshot else { return nil } + let interval = prev.timestamp.distance(to: .now) + let secondsPerHour = TimeInterval(Self.secondsPerHour) + let secondsPerDay = TimeInterval(Self.secondsPerDay) + if interval < secondsPerHour { + return L10n.RemoteNodes.RemoteNodes.History.vsMinutesAgo(Int(interval / 60)) + } else if interval < secondsPerDay { + return L10n.RemoteNodes.RemoteNodes.History.vsHoursAgo(Int(interval / secondsPerHour)) + } else { + return L10n.RemoteNodes.RemoteNodes.History.vsDate(prev.timestamp.formatted(.dateTime.month().day())) + } + } + + var batteryDeltaMV: Int? { + guard let current = status?.batteryMillivolts, + let previous = previousStatusSnapshot?.batteryMillivolts else { return nil } + return Int(current) - Int(previous) + } + + var snrDelta: Double? { + guard let current = status?.lastSNR, + let previous = previousStatusSnapshot?.lastSNR else { return nil } + return current - previous + } + + var rssiDelta: Int? { + guard let current = status?.lastRSSI, + let previous = previousStatusSnapshot?.lastRSSI else { return nil } + return Int(current) - Int(previous) + } + + var noiseFloorDelta: Int? { + guard let current = status?.noiseFloor, + let previous = previousStatusSnapshot?.noiseFloor else { return nil } + return Int(current) - Int(previous) + } + + // MARK: - History + + func fetchHistory() async -> [NodeStatusSnapshotDTO] { + guard let nodeSnapshotService, let publicKey = effectivePublicKey else { + logger.warning("fetchHistory: nodeSnapshotService or public key is nil") + return [] + } + return await nodeSnapshotService.fetchSnapshots(for: publicKey) + } + + // MARK: - OCV Settings + + /// Load OCV settings for a contact by public key. Skips reload if already loaded. + func loadOCVSettings(publicKey: Data, radioID: UUID) async { + guard contactID == nil else { return } + guard let contactService else { return } + + do { + if let contact = try await contactService.getContact(radioID: radioID, publicKey: publicKey) { + contactID = contact.id + + if let presetName = contact.ocvPreset { + if presetName == OCVPreset.custom.rawValue, let customString = contact.customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + ocvValues = parsed + selectedOCVPreset = .custom + return } - - do { - return try await operation(timeout) - } catch { - guard isTransientError(error), let delay = delayIterator.next() else { - throw error - } - try await waitForRetry(delay: delay, until: deadline) - } - } - } - - /// Drive a section request through the shared retry budget, owning the - /// loading flag and section-error scaffold that the admin status view models - /// otherwise repeat verbatim. The `setLoading`/`setError` closures target the - /// section's own state (some live on this helper, some on the view model); - /// `onSuccess` applies the response. A `RemoteNodeError.timeout` surfaces the - /// shared timed-out string, any other error its user-facing message. - func runRetryingSectionRequest( - operationName: String, - setLoading: @MainActor (Bool) -> Void, - setError: @MainActor (String?) -> Void, - operation: @escaping @Sendable (Duration) async throws -> T, - onSuccess: @MainActor (T) async -> Void - ) async { - setLoading(true) - setError(nil) - do { - let response = try await performWithTransientRetries(operationName: operationName, operation: operation) - await onSuccess(response) - } catch RemoteNodeError.timeout { - setError(L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut) - } catch { - setError(error.userFacingMessage) - } - setLoading(false) - } - - // MARK: - Status Response Handling - - /// Handle a status response, saving a snapshot with role-specific fields. - /// `rxAirtimeSeconds` and `receiveErrors` are present in all wire frames - /// but rooms pass `nil` to skip persistence of repeater-specific metrics. - func handleStatusResponse( - _ response: RemoteNodeStatus, - rxAirtimeSeconds: UInt32? = nil, - receiveErrors: UInt32? = nil, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) async { - guard let expectedPrefix = session?.publicKeyPrefix, - response.publicKeyPrefix == expectedPrefix else { + } + if let preset = OCVPreset(rawValue: presetName) { + selectedOCVPreset = preset + ocvValues = preset.ocvArray return + } } - self.status = response - self.statusLoaded = true - self.isLoadingStatus = false - self.statusSectionError = nil - - guard let nodeSnapshotService, let session else { return } - self.previousStatusSnapshot = await nodeSnapshotService.previousStatusSnapshot( - for: session.publicKey, - before: .now - ) - - let metrics = NodeStatusMetrics( - status: response, - rxAirtimeSeconds: rxAirtimeSeconds, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - _ = await nodeSnapshotService.recordSnapshot( - nodePublicKey: session.publicKey, - status: metrics - ) + selectedOCVPreset = .liIon + ocvValues = OCVPreset.liIon.ocvArray + } + } catch { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvLoadFailed } + } - /// Capture neighbor data onto the node's current in-window snapshot, creating - /// one if none exists yet. Safe to call before a status response: the store - /// enriches the latest in-window row or inserts a neighbor-bearing snapshot. - func enrichNeighbors(_ entries: [NeighborSnapshotEntry]) async { - guard let nodeSnapshotService, let nodePublicKey = effectivePublicKey else { return } - // Capture the prior neighbor-bearing snapshot before persisting this reading, - // so the delta baseline is the previous distinct capture, not this one. - self.previousNeighborSnapshot = await nodeSnapshotService.previousNeighborSnapshot(for: nodePublicKey) - _ = await nodeSnapshotService.recordSnapshot( - nodePublicKey: nodePublicKey, - neighbors: entries - ) + func saveOCVSettings(preset: OCVPreset, values: [Int]) async { + guard let contactService, + let contactID else { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveNoContact + return } - // MARK: - Telemetry Response Handling - - func handleTelemetryResponse(_ response: TelemetryResponse) async { - guard let expectedPrefix = effectivePublicKeyPrefix, - response.publicKeyPrefix == expectedPrefix else { - return - } - self.telemetry = response - self.cachedDataPoints = response.dataPoints.filter { $0.channel != 0 } - self.isLoadingTelemetry = false - self.telemetryLoaded = true - self.telemetrySectionError = nil - - let entries: [TelemetrySnapshotEntry] = cachedDataPoints.compactMap { dp in - let numericValue: Double? - switch dp.value { - case .float(let value): - numericValue = value - case .integer(let value): - numericValue = Double(value) - default: - numericValue = nil - } - guard let value = numericValue else { return nil } - return TelemetrySnapshotEntry(channel: Int(dp.channel), type: dp.typeName, value: value) - } - guard !entries.isEmpty, - let nodeSnapshotService, - let nodePublicKey = effectivePublicKey else { return } + ocvError = nil - _ = await nodeSnapshotService.recordSnapshot( - nodePublicKey: nodePublicKey, - telemetry: entries + do { + if preset == .custom { + let customString = values.map(String.init).joined(separator: ",") + try await contactService.updateContactOCVSettings( + contactID: contactID, + preset: OCVPreset.custom.rawValue, + customArray: customString ) - } - - // MARK: - Telemetry Grouping - - var hasMultipleChannels: Bool { - let channels = Set(cachedDataPoints.map(\.channel)) - return channels.count > 1 - } - - var groupedDataPoints: [(channel: UInt8, dataPoints: [LPPDataPoint])] { - Dictionary(grouping: cachedDataPoints, by: \.channel) - .sorted { $0.key < $1.key } - .map { (channel: $0.key, dataPoints: $0.value) } - } - - // MARK: - Display Formatters - - static let emDash = "—" - private static let secondsPerMinute: UInt32 = 60 - private static let secondsPerHour: UInt32 = 3_600 - private static let secondsPerDay: UInt32 = 86_400 - - var uptimeDisplay: String { - guard let uptime = status?.uptimeSeconds else { return Self.emDash } - return Self.formatDuration(uptime) - } - - var airtimeDisplay: String { - guard let status else { return Self.emDash } - let tx = Self.formatDuration(status.airtime) - let rx = Self.formatDuration(status.rxAirtime) - return "TX \(tx) / RX \(rx)" - } - - private static let airtimePercentFractionDigits = 1 - - var airtimePercentDisplay: String { - guard let status, status.uptimeSeconds > 0 else { return Self.emDash } - let denom = Double(status.uptimeSeconds) - let txPercent = Double(status.airtime) / denom * 100 - let rxPercent = Double(status.rxAirtime) / denom * 100 - return "TX \(Self.formatPercent(txPercent)) / RX \(Self.formatPercent(rxPercent))" - } - - private static func formatPercent(_ value: Double) -> String { - value.formatted(.number.precision(.fractionLength(airtimePercentFractionDigits))) + "%" - } - - private static func formatDuration(_ seconds: UInt32) -> String { - let days = Int(seconds / secondsPerDay) - let hours = Int((seconds % secondsPerDay) / secondsPerHour) - let minutes = Int((seconds % secondsPerHour) / secondsPerMinute) - - if days > 0 { - if days == 1 { - return L10n.RemoteNodes.RemoteNodes.Status.uptime1Day(hours, minutes) - } else { - return L10n.RemoteNodes.RemoteNodes.Status.uptimeDays(days, hours, minutes) - } - } else if hours > 0 { - return L10n.RemoteNodes.RemoteNodes.Status.uptimeHours(hours, minutes) - } - return L10n.RemoteNodes.RemoteNodes.Status.uptimeMinutes(minutes) - } - - var batteryDisplay: String { - guard let mv = status?.batteryMillivolts else { return Self.emDash } - let volts = Double(mv) / 1000.0 - let battery = BatteryInfo(level: Int(mv)) - let percent = battery.percentage(using: ocvValues) - return "\(volts.formatted(.number.precision(.fractionLength(3))))V (\(percent)%)" - } - - var lastRSSIDisplay: String { - guard let rssi = status?.lastRSSI else { return Self.emDash } - return "\(rssi) dBm" - } - - var lastSNRDisplay: String { - guard let snr = status?.lastSNR else { return Self.emDash } - return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB" - } - - var noiseFloorDisplay: String { - guard let nf = status?.noiseFloor else { return Self.emDash } - return "\(nf) dBm" - } - - var packetsSentDisplay: String { - guard let count = status?.packetsSent else { return Self.emDash } - return count.formatted() - } - - var packetsReceivedDisplay: String { - guard let count = status?.packetsReceived else { return Self.emDash } - return count.formatted() - } - - // MARK: - Delta Display - - var previousSnapshotTimestamp: String? { - guard let prev = previousStatusSnapshot else { return nil } - let interval = prev.timestamp.distance(to: .now) - let secondsPerHour = TimeInterval(Self.secondsPerHour) - let secondsPerDay = TimeInterval(Self.secondsPerDay) - if interval < secondsPerHour { - return L10n.RemoteNodes.RemoteNodes.History.vsMinutesAgo(Int(interval / 60)) - } else if interval < secondsPerDay { - return L10n.RemoteNodes.RemoteNodes.History.vsHoursAgo(Int(interval / secondsPerHour)) - } else { - return L10n.RemoteNodes.RemoteNodes.History.vsDate(prev.timestamp.formatted(.dateTime.month().day())) - } - } - - var batteryDeltaMV: Int? { - guard let current = status?.batteryMillivolts, - let previous = previousStatusSnapshot?.batteryMillivolts else { return nil } - return Int(current) - Int(previous) - } - - var snrDelta: Double? { - guard let current = status?.lastSNR, - let previous = previousStatusSnapshot?.lastSNR else { return nil } - return current - previous - } - - var rssiDelta: Int? { - guard let current = status?.lastRSSI, - let previous = previousStatusSnapshot?.lastRSSI else { return nil } - return Int(current) - Int(previous) - } - - var noiseFloorDelta: Int? { - guard let current = status?.noiseFloor, - let previous = previousStatusSnapshot?.noiseFloor else { return nil } - return Int(current) - Int(previous) - } - - // MARK: - History - - func fetchHistory() async -> [NodeStatusSnapshotDTO] { - guard let nodeSnapshotService, let publicKey = effectivePublicKey else { - logger.warning("fetchHistory: nodeSnapshotService or public key is nil") - return [] - } - return await nodeSnapshotService.fetchSnapshots(for: publicKey) - } - - // MARK: - OCV Settings - - /// Load OCV settings for a contact by public key. Skips reload if already loaded. - func loadOCVSettings(publicKey: Data, radioID: UUID) async { - guard contactID == nil else { return } - guard let contactService else { return } - - do { - if let contact = try await contactService.getContact(radioID: radioID, publicKey: publicKey) { - contactID = contact.id - - if let presetName = contact.ocvPreset { - if presetName == OCVPreset.custom.rawValue, let customString = contact.customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - ocvValues = parsed - selectedOCVPreset = .custom - return - } - } - if let preset = OCVPreset(rawValue: presetName) { - selectedOCVPreset = preset - ocvValues = preset.ocvArray - return - } - } - - selectedOCVPreset = .liIon - ocvValues = OCVPreset.liIon.ocvArray - } - } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvLoadFailed - } - } - - func saveOCVSettings(preset: OCVPreset, values: [Int]) async { - guard let contactService, - let contactID else { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveNoContact - return - } - - ocvError = nil - - do { - if preset == .custom { - let customString = values.map(String.init).joined(separator: ",") - try await contactService.updateContactOCVSettings( - contactID: contactID, - preset: OCVPreset.custom.rawValue, - customArray: customString - ) - } else { - try await contactService.updateContactOCVSettings( - contactID: contactID, - preset: preset.rawValue, - customArray: nil - ) - } + } else { + try await contactService.updateContactOCVSettings( + contactID: contactID, + preset: preset.rawValue, + customArray: nil + ) + } - selectedOCVPreset = preset - ocvValues = values - } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveFailed(error.userFacingMessage) - } + selectedOCVPreset = preset + ocvValues = values + } catch { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveFailed(error.userFacingMessage) } + } } diff --git a/MC1/Views/RemoteNodes/NodeTelemetryView.swift b/MC1/Views/RemoteNodes/NodeTelemetryView.swift index f9a86232..53d2a79c 100644 --- a/MC1/Views/RemoteNodes/NodeTelemetryView.swift +++ b/MC1/Views/RemoteNodes/NodeTelemetryView.swift @@ -2,60 +2,60 @@ import MC1Services import SwiftUI struct NodeTelemetryView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss - let contact: ContactDTO - @State private var viewModel = NodeTelemetryViewModel() + let contact: ContactDTO + @State private var viewModel = NodeTelemetryViewModel() - var body: some View { - NavigationStack { - List { - NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: appState.connectionState) { - await viewModel.requestTelemetry() - } - } - .nodeStatusDestinations(helper: viewModel.helper) - .themedCanvas(theme) - .navigationTitle(contact.displayName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } - } + var body: some View { + NavigationStack { + List { + NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: appState.connectionState) { + await viewModel.requestTelemetry() + } + } + .nodeStatusDestinations(helper: viewModel.helper) + .themedCanvas(theme) + .navigationTitle(contact.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } + } - ToolbarItem(placement: .primaryAction) { - Button { - Task { await viewModel.requestTelemetry() } - } label: { - Image(systemName: "arrow.clockwise") - } - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.refresh) - .radioDisabled( - for: appState.connectionState, - or: viewModel.helper.isLoadingTelemetry - ) - } - } - .task { - viewModel.configure( - binaryProtocolService: { appState.services?.binaryProtocolService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService }, - contact: contact - ) + ToolbarItem(placement: .primaryAction) { + Button { + Task { await viewModel.requestTelemetry() } + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.refresh) + .radioDisabled( + for: appState.connectionState, + or: viewModel.helper.isLoadingTelemetry + ) + } + } + .task { + viewModel.configure( + binaryProtocolService: { appState.services?.binaryProtocolService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService }, + contact: contact + ) - viewModel.helper.telemetryExpanded = true + viewModel.helper.telemetryExpanded = true - if let radioID = appState.connectedDevice?.radioID { - await viewModel.helper.loadOCVSettings( - publicKey: contact.publicKey, - radioID: radioID - ) - } - } + if let radioID = appState.connectedDevice?.radioID { + await viewModel.helper.loadOCVSettings( + publicKey: contact.publicKey, + radioID: radioID + ) } - .presentationDetents([.large]) + } } + .presentationDetents([.large]) + } } diff --git a/MC1/Views/RemoteNodes/NodeTelemetryViewModel.swift b/MC1/Views/RemoteNodes/NodeTelemetryViewModel.swift index 2b2e7f09..4dd79660 100644 --- a/MC1/Views/RemoteNodes/NodeTelemetryViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeTelemetryViewModel.swift @@ -1,5 +1,5 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "NodeTelemetryVM") @@ -7,61 +7,63 @@ private let logger = Logger(subsystem: "com.mc1", category: "NodeTelemetryVM") @Observable @MainActor final class NodeTelemetryViewModel { + // MARK: - Shared Helper - // MARK: - Shared Helper + var helper = NodeStatusViewModel() - var helper = NodeStatusViewModel() + // MARK: - Dependencies - // MARK: - Dependencies + private var binaryProtocolServiceProvider: @MainActor () -> BinaryProtocolService? = { nil } + var binaryProtocolService: BinaryProtocolService? { + binaryProtocolServiceProvider() + } - private var binaryProtocolServiceProvider: @MainActor () -> BinaryProtocolService? = { nil } - var binaryProtocolService: BinaryProtocolService? { binaryProtocolServiceProvider() } - private var publicKey: Data? + private var publicKey: Data? - // MARK: - Initialization + // MARK: - Initialization - /// Nil services mirror a disconnected state; requests then no-op. - func configure( - binaryProtocolService: @escaping @MainActor () -> BinaryProtocolService?, - contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService?, - contact: ContactDTO - ) { - self.binaryProtocolServiceProvider = binaryProtocolService - self.publicKey = contact.publicKey - helper.configure( - contactService: contactService, - nodeSnapshotService: nodeSnapshotService - ) - helper.configureForDirectTelemetry(publicKey: contact.publicKey) - } + /// Nil services mirror a disconnected state; requests then no-op. + func configure( + binaryProtocolService: @escaping @MainActor () -> BinaryProtocolService?, + contactService: @escaping @MainActor () -> ContactService?, + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService?, + contact: ContactDTO + ) { + binaryProtocolServiceProvider = binaryProtocolService + publicKey = contact.publicKey + helper.configure( + contactService: contactService, + nodeSnapshotService: nodeSnapshotService + ) + helper.configureForDirectTelemetry(publicKey: contact.publicKey) + } - // MARK: - Telemetry + // MARK: - Telemetry - func requestTelemetry() async { - guard let binaryProtocolService = binaryProtocolService, let publicKey else { return } + func requestTelemetry() async { + guard let binaryProtocolService, let publicKey else { return } - await helper.runRetryingSectionRequest( - operationName: "telemetry", - setLoading: { self.helper.isLoadingTelemetry = $0 }, - setError: { self.helper.telemetrySectionError = $0 }, - operation: { [binaryProtocolService, publicKey] _ in - // BinaryProtocolService relies on the session's own timeout; it has no - // timeout parameter, and reports a timeout as a wrapped session error, - // so map that to the telemetry-specific "may be disabled" copy. - do { - return try await binaryProtocolService.requestTelemetry(from: publicKey) - } catch BinaryProtocolError.sessionError(MeshCoreError.timeout) { - throw RemoteNodeError.timeout - } - }, - onSuccess: { await self.helper.handleTelemetryResponse($0) } - ) - - // The shared NodeStatusViewModel renders a generic timed-out string; telemetry has a more - // specific cause worth surfacing, so refine that one case. - if helper.telemetrySectionError == L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut { - helper.telemetrySectionError = L10n.RemoteNodes.RemoteNodes.Status.telemetryTimedOut + await helper.runRetryingSectionRequest( + operationName: "telemetry", + setLoading: { self.helper.isLoadingTelemetry = $0 }, + setError: { self.helper.telemetrySectionError = $0 }, + operation: { [binaryProtocolService, publicKey] _ in + // BinaryProtocolService relies on the session's own timeout; it has no + // timeout parameter, and reports a timeout as a wrapped session error, + // so map that to the telemetry-specific "may be disabled" copy. + do { + return try await binaryProtocolService.requestTelemetry(from: publicKey) + } catch BinaryProtocolError.sessionError(MeshCoreError.timeout) { + throw RemoteNodeError.timeout } + }, + onSuccess: { await self.helper.handleTelemetryResponse($0) } + ) + + // The shared NodeStatusViewModel renders a generic timed-out string; telemetry has a more + // specific cause worth surfacing, so refine that one case. + if helper.telemetrySectionError == L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut { + helper.telemetrySectionError = L10n.RemoteNodes.RemoteNodes.Status.telemetryTimedOut } + } } diff --git a/MC1/Views/RemoteNodes/RadioMetricCharts.swift b/MC1/Views/RemoteNodes/RadioMetricCharts.swift index 0ab31220..8f19eba4 100644 --- a/MC1/Views/RemoteNodes/RadioMetricCharts.swift +++ b/MC1/Views/RemoteNodes/RadioMetricCharts.swift @@ -6,103 +6,162 @@ import SwiftUI /// data are skipped. Hosts supply `chartContainer` to wrap each chart in their own row /// chrome (a themed `Section` in the drill-down list, a bare row inside the overview's /// disclosure group). -struct RadioMetricCharts: View { - let snapshots: [NodeStatusSnapshotDTO] - let ocvArray: [Int] - @ViewBuilder let chartContainer: (MetricChartView) -> ChartContainer - - var body: some View { - let batteryPoints = snapshots.compactMap { s in - s.batteryMillivolts.map { - MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0) / 1000.0) - } - } - chart( - title: L10n.RemoteNodes.RemoteNodes.History.battery, unit: "V", color: .mint, - dataPoints: batteryPoints, - yAxisDomain: ocvArray.voltageChartDomain(dataPoints: batteryPoints) - ) +struct RadioMetricCharts: View { + let snapshots: [NodeStatusSnapshotDTO] + let ocvArray: [Int] + @ViewBuilder let chartContainer: (MetricChartView) -> ChartContainer + @ViewBuilder let packetSection: (PacketChartsGroup) -> PacketSection - chart( - title: L10n.RemoteNodes.RemoteNodes.History.snr, unit: "dB", color: .blue, - dataPoints: snapshots.compactMap { s in - s.lastSNR.map { .init(id: s.id, date: s.timestamp, value: $0) } - } - ) + var body: some View { + let batteryPoints = snapshots.compactMap { s in + s.batteryMillivolts.map { + MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0) / 1000.0) + } + } + chart( + title: L10n.RemoteNodes.RemoteNodes.History.battery, unit: "V", color: .mint, + dataPoints: batteryPoints, + yAxisDomain: ocvArray.voltageChartDomain(dataPoints: batteryPoints) + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.History.rssi, unit: "dBm", color: .purple, - dataPoints: snapshots.compactMap { s in - s.lastRSSI.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } - } - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.History.snr, unit: "dB", color: .blue, + dataPoints: snapshots.compactMap { s in + s.lastSNR.map { .init(id: s.id, date: s.timestamp, value: $0) } + } + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.History.noiseFloor, unit: "dBm", color: .indigo, - dataPoints: snapshots.compactMap { s in - s.noiseFloor.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } - } - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.History.rssi, unit: "dBm", color: .purple, + dataPoints: snapshots.compactMap { s in + s.lastRSSI.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) - let packetsSentPoints = snapshots.compactMap { s in - s.packetsSent.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let packetsReceivedPoints = snapshots.compactMap { s in - s.packetsReceived.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let receiveErrorPoints = snapshots.compactMap { s in - s.receiveErrors.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let postsReceivedPoints = snapshots.compactMap { s in - s.postedCount.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let postsPushedPoints = snapshots.compactMap { s in - s.postPushCount.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let packetDomain = [MetricChartView.DataPoint].sharedDomain(for: [ - packetsSentPoints, packetsReceivedPoints, receiveErrorPoints - ]) - - chart( - title: L10n.RemoteNodes.RemoteNodes.History.packetsSent, unit: "", color: .green, - dataPoints: packetsSentPoints, yAxisDomain: packetDomain - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.History.noiseFloor, unit: "dBm", color: .indigo, + dataPoints: snapshots.compactMap { s in + s.noiseFloor.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.History.packetsReceived, unit: "", color: .orange, - dataPoints: packetsReceivedPoints, yAxisDomain: packetDomain - ) + let sentDirectPoints = packets(for: \.sentDirect) + let sentFloodPoints = packets(for: \.sentFlood) + let receivedDirectPoints = packets(for: \.receivedDirect) + let receivedFloodPoints = packets(for: \.receivedFlood) + let directDuplicatePoints = packets(for: \.directDuplicates) + let floodDuplicatePoints = packets(for: \.floodDuplicates) + let receiveErrorPoints = packets(for: \.receiveErrors) + let postsReceivedPoints = snapshots.compactMap { s in + s.postedCount.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } + } + let postsPushedPoints = snapshots.compactMap { s in + s.postPushCount.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } + } - chart( - title: L10n.RemoteNodes.RemoteNodes.History.receiveErrors, unit: "", color: .red, - dataPoints: receiveErrorPoints, yAxisDomain: packetDomain - ) + // Every packet chart shares one Y-axis domain spanning all series, so a user can see at + // a glance which counter is climbing fastest. + let packetDomain = [MetricChartView.DataPoint].sharedDomain(for: [ + sentDirectPoints, sentFloodPoints, + receivedDirectPoints, receivedFloodPoints, + directDuplicatePoints, floodDuplicatePoints, + receiveErrorPoints, + ]) + let packetCharts: [MetricChartView] = [ + overlaySeriesChart( + title: L10n.RemoteNodes.RemoteNodes.History.packetsSent, + direct: sentDirectPoints, flood: sentFloodPoints, yAxisDomain: packetDomain + ), + overlaySeriesChart( + title: L10n.RemoteNodes.RemoteNodes.History.packetsReceived, + direct: receivedDirectPoints, flood: receivedFloodPoints, yAxisDomain: packetDomain + ), + overlaySeriesChart( + title: L10n.RemoteNodes.RemoteNodes.History.duplicates, + direct: directDuplicatePoints, flood: floodDuplicatePoints, yAxisDomain: packetDomain + ), + receiveErrorPoints.isEmpty ? nil : MetricChartView( + title: L10n.RemoteNodes.RemoteNodes.History.receiveErrors, unit: "", + dataPoints: receiveErrorPoints, accentColor: .red, + yAxisDomain: packetDomain + ), + ].compactMap(\.self) - chart( - title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, unit: "", color: .purple, - dataPoints: postsReceivedPoints - ) + if !packetCharts.isEmpty { + packetSection(PacketChartsGroup(charts: packetCharts)) + } + + chart( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, unit: "", color: .purple, + dataPoints: postsReceivedPoints + ) + + chart( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, unit: "", color: .cyan, + dataPoints: postsPushedPoints + ) + } + + /// Builds the point array for a cumulative `UInt32?` packet counter across snapshots. + private func packets(for keyPath: KeyPath) -> [MetricChartView.DataPoint] { + snapshots.compactMap { s in + s[keyPath: keyPath].map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } + } + } + + /// The standard Direct/Flood pair for an overlaid packet chart. + private func directFloodSeries( + direct: [MetricChartView.DataPoint], + flood: [MetricChartView.DataPoint] + ) -> [MetricChartView.Series] { + [ + .init(name: L10n.RemoteNodes.RemoteNodes.History.direct, color: .blue, dataPoints: direct), + .init(name: L10n.RemoteNodes.RemoteNodes.History.flood, color: .orange, dataPoints: flood), + ] + } - chart( - title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, unit: "", color: .cyan, - dataPoints: postsPushedPoints + @ViewBuilder + private func chart( + title: String, unit: String, color: Color, + dataPoints: [MetricChartView.DataPoint], + yAxisDomain: ClosedRange? = nil + ) -> some View { + if !dataPoints.isEmpty { + chartContainer( + MetricChartView( + title: title, unit: unit, + dataPoints: dataPoints, accentColor: color, + yAxisDomain: yAxisDomain ) + ) } + } + + /// An overlaid Direct/Flood packet chart, or nil when neither series carries data. + /// Empty series are dropped. The caller passes the domain shared across every packet + /// chart so they all read on one scale. + private func overlaySeriesChart( + title: String, + direct: [MetricChartView.DataPoint], + flood: [MetricChartView.DataPoint], + yAxisDomain: ClosedRange? + ) -> MetricChartView? { + let series = directFloodSeries(direct: direct, flood: flood).filter { !$0.dataPoints.isEmpty } + guard !series.isEmpty else { return nil } + return MetricChartView( + title: title, unit: "", series: series, + yAxisDomain: yAxisDomain + ) + } +} + +/// The packet-count charts stacked under a host-supplied `Packets` section header. +struct PacketChartsGroup: View { + let charts: [MetricChartView] - @ViewBuilder - private func chart( - title: String, unit: String, color: Color, - dataPoints: [MetricChartView.DataPoint], - yAxisDomain: ClosedRange? = nil - ) -> some View { - if !dataPoints.isEmpty { - chartContainer( - MetricChartView( - title: title, unit: unit, - dataPoints: dataPoints, accentColor: color, - yAxisDomain: yAxisDomain - ) - ) - } + var body: some View { + ForEach(Array(charts.enumerated()), id: \.offset) { _, chart in + chart } + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift new file mode 100644 index 00000000..0dafebd2 --- /dev/null +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift @@ -0,0 +1,124 @@ +import CoreLocation +import MapKit +import MC1Services + +/// Builds the pins, lines, and camera region for the neighbor SNR map from data already in hand. +/// It is a pure builder rather than a view model: the content is a function of the session, its +/// neighbors, and the resolver inputs, with no per-connection dependency and nothing live to read. +/// `PlottedNeighbors` holds non-`Sendable` MapKit types, so it is built and consumed on `@MainActor`. +enum NeighborSNRMapBuilder { + struct PlottedNeighbors { + let points: [MapPoint] + let lines: [MapLine] + let region: MKCoordinateRegion? + let unplottable: [UnplottableNeighbor] + } + + /// A neighbor that could not be placed reliably (ambiguous match, no location, an invalid + /// coordinate, or unresolved), carried with its resolved name and confidence for the list card. + struct UnplottableNeighbor { + let neighbor: NeighbourInfo + let displayName: String + let matchKind: NodeNameMatchKind + } + + private static let lineOpacity = 1.0 + + static func build( + session: RemoteNodeSessionDTO, + neighbors: [NeighbourInfo], + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> PlottedNeighbors { + var points: [MapPoint] = [] + var lines: [MapLine] = [] + var unplottable: [UnplottableNeighbor] = [] + var plottedCoordinates: [CLLocationCoordinate2D] = [] + + let centerCoordinate = session.coordinate + if let centerCoordinate { + points.append(MapPoint( + id: UUID(), + coordinate: centerCoordinate, + pinStyle: .repeaterRingWhite, + label: session.name, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + plottedCoordinates.append(centerCoordinate) + } + + for (index, neighbor) in neighbors.enumerated() { + guard let resolved = NeighborNameResolver.resolveLocated( + for: neighbor.publicKeyPrefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) else { + unplottable.append(UnplottableNeighbor( + neighbor: neighbor, + displayName: NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix), + matchKind: .unresolved + )) + continue + } + + // Only an exact identity match with a trustworthy coordinate is plotted; the validity + // guard lives here because `DiscoveredNodeDTO.hasLocation` checks only non-(0,0) and + // would otherwise admit an out-of-range point. + guard resolved.matchKind == .exact, + let coordinate = resolved.coordinate, + isPlottable(coordinate) else { + unplottable.append(UnplottableNeighbor( + neighbor: neighbor, + displayName: resolved.displayName, + matchKind: resolved.matchKind + )) + continue + } + + points.append(MapPoint( + id: UUID(), + coordinate: coordinate, + pinStyle: .repeater, + label: resolved.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + plottedCoordinates.append(coordinate) + + // A line and distance/SNR badge are defensible only when the center is also located; + // without an anchor the neighbor pin still stands alone. + guard let centerCoordinate else { continue } + + lines.append(MapLine( + id: "neighbor-\(index)", + coordinates: [centerCoordinate, coordinate], + style: .forSNR(neighbor.snr), + opacity: lineOpacity, + pathIndex: nil + )) + + points.append(MapLine.snrBadge( + id: UUID(), + from: centerCoordinate, + to: coordinate, + snr: neighbor.snr + )) + } + + return PlottedNeighbors( + points: points, + lines: lines, + region: plottedCoordinates.boundingRegion(), + unplottable: unplottable + ) + } + + private static func isPlottable(_ coordinate: CLLocationCoordinate2D) -> Bool { + CLLocationCoordinate2DIsValid(coordinate) + } +} diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift new file mode 100644 index 00000000..4da4cfc9 --- /dev/null +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift @@ -0,0 +1,172 @@ +import CoreLocation +import MapKit +import MapLibre +import MC1Services +import SwiftUI + +/// Map of a repeater's neighbors: the repeater at center, each exact-match located neighbor pinned +/// with an SNR-colored link line. Neighbors that can't be placed are counted in a top pill that +/// pushes their list. The content is a pure function of data already in hand, so it carries plain +/// screen-lifetime parameters and holds only camera `@State` rather than a view model. +/// +/// Pushed onto the host's navigation stack rather than presented modally: stacking a cover or +/// sheet on the telemetry sheet that hosts it collapses both presentations. The no-location list is +/// likewise a push, not a sheet, for the same reason. +struct NeighborSNRMapView: View { + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + + let session: RemoteNodeSessionDTO + let neighbors: [NeighbourInfo] + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? + + @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels + @AppStorage(AppStorageKey.mapNorthLocked.rawValue) private var isNorthLocked = AppStorageKey.defaultMapNorthLocked + + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 + @State private var isCenteredOnUser = false + @State private var plotted: NeighborSNRMapBuilder.PlottedNeighbors? + @State private var showingNoLocationList = false + @State private var isStyleLoaded = false + + var body: some View { + ZStack(alignment: .bottom) { + MC1MapView( + points: plotted?.points ?? [], + lines: plotted?.lines ?? [], + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: isNorthLocked, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + cameraBottomSheetFraction: 0, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { cameraRegion = $0 }, + isStyleLoaded: $isStyleLoaded, + isCenteredOnUser: $isCenteredOnUser + ) + .ignoresSafeArea() + + toolbarOverlay + } + .overlay(alignment: .top) { + if let unplottable = plotted?.unplottable, !unplottable.isEmpty { + noLocationPill(count: unplottable.count) + } + } + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsMapTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $showingNoLocationList) { + if let unplottable = plotted?.unplottable { + NeighborsNoLocationList(unplottable: unplottable) + } + } + .onAppear { + guard plotted == nil else { return } + let built = NeighborSNRMapBuilder.build( + session: session, + neighbors: neighbors, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + withAnimation { plotted = built } + setCameraRegion(built.region) + } + // The map gates camera moves until its style finishes loading, which usually lands after + // the on-appear fit. Re-issue the fit on that signal so the nodes frame deterministically + // rather than depending on an incidental re-render. + .onChange(of: isStyleLoaded) { _, loaded in + if loaded { setCameraRegion(plotted?.region) } + } + } + + /// Glanceable count of neighbors that couldn't be placed; tapping pushes their list. Styled like + /// the trace path map's top banner so the two maps read the same. + private func noLocationPill(count: Int) -> some View { + Button { + showingNoLocationList = true + } label: { + HStack(spacing: 6) { + Image(systemName: "mappin.slash") + Text(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(count)) + } + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .liquidGlass(in: .capsule) + } + .buttonStyle(.plain) + .safeAreaPadding(.top) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + /// Fixed bottom-right map controls. + private var toolbarOverlay: some View { + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: centerOnMyLocation, + isCenteredOnUser: isCenteredOnUser, + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + mapStyleSelection: $mapStyleSelection, + viewportBounds: cameraRegion?.toMLNCoordinateBounds() + ) { + centerAllButton + } + } + } + + /// Centers and fits the camera in one step: `MC1MapView` ignores a region whose version + /// still matches the last applied one, so the region and the version bump must move together. + private func setCameraRegion(_ region: MKCoordinateRegion?) { + guard let region else { return } + cameraRegion = region + cameraRegionVersion += 1 + } + + private func centerOnMyLocation() { + isCenteredOnUser = appState.centerOnUserLocation { setCameraRegion($0) } + } + + /// Re-fits the camera to the repeater and its plotted neighbors. Disabled when nothing is plottable. + private var centerAllButton: some View { + Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { + isCenteredOnUser = false + setCameraRegion(plotted?.region) + } + .mapControlButton(tint: plotted?.region == nil ? .secondary : .primary) + .disabled(plotted?.region == nil) + } +} + +// MARK: - No Location List + +/// Pushed list of neighbors that could not be placed reliably (ambiguous, unlocated, or +/// unresolved). Reuses `NeighborRow`, including its "?" fallback-match affordance. +private struct NeighborsNoLocationList: View { + let unplottable: [NeighborSNRMapBuilder.UnplottableNeighbor] + + var body: some View { + List(unplottable, id: \.neighbor.publicKeyPrefix) { item in + NeighborRow( + neighbor: item.neighbor, + displayName: item.displayName, + matchKind: item.matchKind + ) + } + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(unplottable.count)) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift index 48f83352..62337dce 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift @@ -1,470 +1,470 @@ -import SwiftUI -import MC1Services import CoreLocation +import MC1Services +import SwiftUI struct RepeaterSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @FocusState private var focusedField: NodeSettingsField? - - let session: RemoteNodeSessionDTO - @State private var viewModel = RepeaterSettingsViewModel() - @State private var statusViewModel = RepeaterStatusViewModel() - @State private var managementTab: NodeManagementTab = .settings - @State private var cliViewModel = NodeCLIViewModel() - @State private var showRebootConfirmation = false - @State private var showingLocationPicker = false - @State private var telemetryConfigured = false - @State private var contacts: [ContactDTO] = [] - @State private var discoveredNodes: [DiscoveredNodeDTO] = [] - /// The node's contact, kept live so the route section reflects the path the firmware learns after - /// a flood login (delivered asynchronously as a contact update). - @State private var routeContact: ContactDTO? - - var body: some View { - // ZStack, not Group: a stable container keeps the navigation title hosted on one - // view across segment switches. Group would re-host it on each branch, animating - // a nav-bar item transition. - ZStack { - switch managementTab { - case .settings: settingsForm - case .cli: NodeCLIView(viewModel: cliViewModel) - case .telemetry: - RepeaterStatusContent( - viewModel: statusViewModel, - session: session, - connectionState: appState.connectionState, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: appState.bestAvailableLocation, - connectedDeviceID: appState.connectedDevice?.radioID, - routePathContact: routeContact - ) - } - } - .animation(nil, value: managementTab) - .navigationTitle(L10n.RemoteNodes.RemoteNodes.Settings.title) - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .top, spacing: 0) { - if session.isAdmin { - NodeManagementTabPicker(selection: $managementTab) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) - } - } - .task { - await viewModel.configure( - repeaterAdminService: { appState.services?.repeaterAdminService }, - session: session - ) - if let send = viewModel.makeNodeCLISendClosure(session: session) { - cliViewModel.configure(sessionName: session.name, sendRawCommand: send) - } - // Loaded up front (not just on Telemetry reveal) so the Settings-tab route section can - // resolve hop hashes to repeater names. - if let radioID = appState.connectedDevice?.radioID, - let dataStore = appState.services?.dataStore { - contacts = (try? await dataStore.fetchContacts(radioID: radioID)) ?? [] - discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] - } - await refreshRouteContact() - } - .onChange(of: appState.contactsVersion) { - Task { await refreshRouteContact() } - } - .onChange(of: managementTab) { _, newTab in - guard newTab == .telemetry, !telemetryConfigured else { return } - telemetryConfigured = true - // Configure the status VM on first Telemetry reveal rather than on open: - // its handlers populate only the status/telemetry/neighbours slots, leaving the - // settings VM's CLI handler intact for the Settings/CLI surface. Guarded by - // telemetryConfigured because a segment switch recreates only the content subtree, - // so this must not re-run or duplicate handler registration. - statusViewModel.configure( - repeaterAdminService: { appState.services?.repeaterAdminService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } - ) - Task { - await statusViewModel.registerHandlers() - if let radioID = appState.connectedDevice?.radioID { - await statusViewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) - } - } - } - .onDisappear { - statusViewModel.stopDiscovery() - Task { - await statusViewModel.clearStatusHandlers() - await viewModel.cleanup() - } - } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) { } - } message: { - Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) - } - .sheet(isPresented: $showingLocationPicker) { - LocationPickerView( - initialCoordinate: CLLocationCoordinate2D( - latitude: viewModel.helper.latitude ?? 0, - longitude: viewModel.helper.longitude ?? 0 - ) - ) { coordinate in - viewModel.helper.setLocationFromPicker( - latitude: coordinate.latitude, - longitude: coordinate.longitude - ) - } - } - } - - private var settingsForm: some View { - Form { - NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) - makeRadioSettingsSection() - makeBehaviorSection() - makeRegionsSection() - makeIdentitySection() - makeContactInfoSection() - makeSecuritySection() - makeDeviceInfoSection() - makeActionsSection() - if let routeContact { - NodeRoutePathSection( - contact: routeContact, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: appState.bestAvailableLocation - ) - } - } - .themedCanvas(theme) - .nodeManagementHeaderTopMargin() - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { - focusedField = nil - } - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @FocusState private var focusedField: NodeSettingsField? + + let session: RemoteNodeSessionDTO + @State private var viewModel = RepeaterSettingsViewModel() + @State private var statusViewModel = RepeaterStatusViewModel() + @State private var managementTab: NodeManagementTab = .settings + @State private var cliViewModel = NodeCLIViewModel() + @State private var showRebootConfirmation = false + @State private var showingLocationPicker = false + @State private var telemetryConfigured = false + @State private var contacts: [ContactDTO] = [] + @State private var discoveredNodes: [DiscoveredNodeDTO] = [] + /// The node's contact, kept live so the route section reflects the path the firmware learns after + /// a flood login (delivered asynchronously as a contact update). + @State private var routeContact: ContactDTO? + + var body: some View { + // ZStack, not Group: a stable container keeps the navigation title hosted on one + // view across segment switches. Group would re-host it on each branch, animating + // a nav-bar item transition. + ZStack { + switch managementTab { + case .settings: settingsForm + case .cli: NodeCLIView(viewModel: cliViewModel) + case .telemetry: + RepeaterStatusContent( + viewModel: statusViewModel, + session: session, + connectionState: appState.connectionState, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: appState.bestAvailableLocation, + connectedDeviceID: appState.connectedDevice?.radioID, + routePathContact: routeContact + ) + } } - - // MARK: - Subviews - - private func makeDeviceInfoSection() -> some View { - NodeDeviceInfoSection(settings: viewModel.helper) + .animation(nil, value: managementTab) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Settings.title) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .top, spacing: 0) { + if session.isAdmin { + NodeManagementTabPicker(selection: $managementTab) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } } - - private func makeRadioSettingsSection() -> some View { - NodeRadioSettingsSection( - settings: viewModel.helper, - focusedField: $focusedField - ) + .task { + await viewModel.configure( + repeaterAdminService: { appState.services?.repeaterAdminService }, + session: session + ) + if let send = viewModel.makeNodeCLISendClosure(session: session) { + cliViewModel.configure(sessionName: session.name, sendRawCommand: send) + } + // Loaded up front (not just on Telemetry reveal) so the Settings-tab route section can + // resolve hop hashes to repeater names. + if let radioID = appState.connectedDevice?.radioID, + let dataStore = appState.services?.dataStore { + contacts = await (try? dataStore.fetchContacts(radioID: radioID)) ?? [] + discoveredNodes = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] + } + await refreshRouteContact() } - - private func makeIdentitySection() -> some View { - RemoteNodeIdentitySection( - settings: viewModel.helper, - focusedField: $focusedField, - onPickLocation: { showingLocationPicker = true } - ) + .onChange(of: appState.contactsVersion) { + Task { await refreshRouteContact() } } - - private func makeContactInfoSection() -> some View { - NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) + .onChange(of: managementTab) { _, newTab in + guard newTab == .telemetry, !telemetryConfigured else { return } + telemetryConfigured = true + // Configure the status VM on first Telemetry reveal rather than on open: + // its handlers populate only the status/telemetry/neighbours slots, leaving the + // settings VM's CLI handler intact for the Settings/CLI surface. Guarded by + // telemetryConfigured because a segment switch recreates only the content subtree, + // so this must not re-run or duplicate handler registration. + statusViewModel.configure( + repeaterAdminService: { appState.services?.repeaterAdminService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService } + ) + Task { + await statusViewModel.registerHandlers() + if let radioID = appState.connectedDevice?.radioID { + await statusViewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) + } + } } - - private func makeBehaviorSection() -> some View { - BehaviorSection(viewModel: viewModel, focusedField: $focusedField) + .onDisappear { + statusViewModel.stopDiscovery() + Task { + await statusViewModel.clearStatusHandlers() + await viewModel.cleanup() + } } - - private func makeRegionsSection() -> some View { - RegionsSection(viewModel: viewModel) + .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) {} + } message: { + Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) } - - private func makeSecuritySection() -> some View { - NodeSecuritySection(settings: viewModel.helper) + .sheet(isPresented: $showingLocationPicker) { + LocationPickerView( + initialCoordinate: CLLocationCoordinate2D( + latitude: viewModel.helper.latitude ?? 0, + longitude: viewModel.helper.longitude ?? 0 + ) + ) { coordinate in + viewModel.helper.setLocationFromPicker( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + } } - - private func makeActionsSection() -> some View { - NodeActionsSection( - settings: viewModel.helper, - showRebootConfirmation: $showRebootConfirmation + } + + private var settingsForm: some View { + Form { + NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) + makeRadioSettingsSection() + makeBehaviorSection() + makeRegionsSection() + makeIdentitySection() + makeContactInfoSection() + makeSecuritySection() + makeDeviceInfoSection() + makeActionsSection() + if let routeContact { + NodeRoutePathSection( + contact: routeContact, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: appState.bestAvailableLocation ) + } } - - private func refreshRouteContact() async { - guard let dataStore = appState.services?.dataStore else { return } - if let updated = (try? await dataStore.fetchContact( - radioID: session.radioID, - publicKey: session.publicKey - )).flatMap({ $0 }) { - routeContact = updated + .themedCanvas(theme) + .nodeManagementHeaderTopMargin() + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { + focusedField = nil } + } } + } + + // MARK: - Subviews + + private func makeDeviceInfoSection() -> some View { + NodeDeviceInfoSection(settings: viewModel.helper) + } + + private func makeRadioSettingsSection() -> some View { + NodeRadioSettingsSection( + settings: viewModel.helper, + focusedField: $focusedField + ) + } + + private func makeIdentitySection() -> some View { + RemoteNodeIdentitySection( + settings: viewModel.helper, + focusedField: $focusedField, + onPickLocation: { showingLocationPicker = true } + ) + } + + private func makeContactInfoSection() -> some View { + NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) + } + + private func makeBehaviorSection() -> some View { + BehaviorSection(viewModel: viewModel, focusedField: $focusedField) + } + + private func makeRegionsSection() -> some View { + RegionsSection(viewModel: viewModel) + } + + private func makeSecuritySection() -> some View { + NodeSecuritySection(settings: viewModel.helper) + } + + private func makeActionsSection() -> some View { + NodeActionsSection( + settings: viewModel.helper, + showRebootConfirmation: $showRebootConfirmation + ) + } + + private func refreshRouteContact() async { + guard let dataStore = appState.services?.dataStore else { return } + if let updated = await (try? dataStore.fetchContact( + radioID: session.radioID, + publicKey: session.publicKey + )).flatMap(\.self) { + routeContact = updated + } + } } // MARK: - Behavior Section private struct BehaviorSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.behavior, - icon: "slider.horizontal.3", - isExpanded: $viewModel.isBehaviorExpanded, - isLoaded: { viewModel.behaviorLoaded }, - isLoading: $viewModel.isLoadingBehavior, - hasError: $viewModel.behaviorError, - onLoad: { await viewModel.fetchBehaviorSettings() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.behaviorFooter - ) { - Toggle(L10n.RemoteNodes.RemoteNodes.Settings.repeaterMode, isOn: Binding( - get: { viewModel.repeaterEnabled ?? false }, - set: { viewModel.repeaterEnabled = $0 } - )) - .disabled(viewModel.repeaterEnabled == nil) - .accessibilityValue( - viewModel.repeaterEnabled == nil - ? (viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad) - : (viewModel.repeaterEnabled == true ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) - ) - .overlay(alignment: .trailing) { - if viewModel.repeaterEnabled == nil { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - .padding(.trailing, 60) - .accessibilityHidden(true) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) - Spacer() - if let interval = viewModel.advertIntervalMinutes { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( - get: { interval }, - set: { viewModel.advertIntervalMinutes = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .advertInterval) - Text(L10n.RemoteNodes.RemoteNodes.Settings.min) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } - - if let error = viewModel.advertIntervalError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) - Spacer() - if let interval = viewModel.floodAdvertIntervalHours { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( - get: { interval }, - set: { viewModel.floodAdvertIntervalHours = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .floodAdvertInterval) - Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } - - if let error = viewModel.floodAdvertIntervalError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) - Spacer() - if let hops = viewModel.floodMaxHops { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( - get: { hops }, - set: { viewModel.floodMaxHops = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .floodMaxHops) - Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } - - if let error = viewModel.floodMaxHopsError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - - Button { - Task { await viewModel.applyBehaviorSettings() } - } label: { - AsyncActionLabel(isLoading: viewModel.helper.isApplying, showSuccess: viewModel.behaviorApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyBehaviorSettings) - .foregroundStyle(viewModel.behaviorSettingsModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .disabled(viewModel.helper.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) + @Bindable var viewModel: RepeaterSettingsViewModel + var focusedField: FocusState.Binding + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.behavior, + icon: "slider.horizontal.3", + isExpanded: $viewModel.isBehaviorExpanded, + isLoaded: { viewModel.behaviorLoaded }, + isLoading: $viewModel.isLoadingBehavior, + hasError: $viewModel.behaviorError, + onLoad: { await viewModel.fetchBehaviorSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.behaviorFooter + ) { + Toggle(L10n.RemoteNodes.RemoteNodes.Settings.repeaterMode, isOn: Binding( + get: { viewModel.repeaterEnabled ?? false }, + set: { viewModel.repeaterEnabled = $0 } + )) + .disabled(viewModel.repeaterEnabled == nil) + .accessibilityValue( + viewModel.repeaterEnabled == nil + ? (viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad) + : (viewModel.repeaterEnabled == true ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) + ) + .overlay(alignment: .trailing) { + if viewModel.repeaterEnabled == nil { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + .padding(.trailing, 60) + .accessibilityHidden(true) + } + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) + Spacer() + if let interval = viewModel.advertIntervalMinutes { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( + get: { interval }, + set: { viewModel.advertIntervalMinutes = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .advertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.min) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) } + } + + if let error = viewModel.advertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) + Spacer() + if let interval = viewModel.floodAdvertIntervalHours { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( + get: { interval }, + set: { viewModel.floodAdvertIntervalHours = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodAdvertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } + + if let error = viewModel.floodAdvertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) + Spacer() + if let hops = viewModel.floodMaxHops { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( + get: { hops }, + set: { viewModel.floodMaxHops = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodMaxHops) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } + + if let error = viewModel.floodMaxHopsError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Button { + Task { await viewModel.applyBehaviorSettings() } + } label: { + AsyncActionLabel(isLoading: viewModel.helper.isApplying, showSuccess: viewModel.behaviorApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyBehaviorSettings) + .foregroundStyle(viewModel.behaviorSettingsModified ? Color.accentColor : .secondary) + .transition(.opacity) + } + } + .disabled(viewModel.helper.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) } + } } // MARK: - Regions Section private struct RegionsSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - - /// Regions sorted: wildcard first, then alphabetical - private var sortedRegions: [RepeaterRegionEntry] { - viewModel.regions.sorted { lhs, rhs in - if lhs.isWildcard { return true } - if rhs.isWildcard { return false } - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - } - - /// Display name for a region entry - private func displayName(for region: RepeaterRegionEntry) -> String { - region.isWildcard - ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTrafficWildcard - : region.name + @Bindable var viewModel: RepeaterSettingsViewModel + + /// Regions sorted: wildcard first, then alphabetical + private var sortedRegions: [RepeaterRegionEntry] { + viewModel.regions.sorted { lhs, rhs in + if lhs.isWildcard { return true } + if rhs.isWildcard { return false } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending } - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.regions, - icon: "globe", - isExpanded: $viewModel.isRegionsExpanded, - isLoaded: { viewModel.regionsLoaded }, - isLoading: $viewModel.isLoadingRegions, - hasError: $viewModel.regionsError, - onLoad: { await viewModel.fetchRegions() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.regionsFooter - ) { - if viewModel.regionsLoaded && viewModel.regions.isEmpty { - Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.empty) - .foregroundStyle(.secondary) - } - - // Home region picker - if !viewModel.regions.isEmpty { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.Regions.homeRegion, selection: Binding( - get: { - viewModel.regions.first(where: \.isHome)?.name - ?? RepeaterSettingsViewModel.wildcardName - }, - set: { newValue in - Task { await viewModel.setHomeRegion(name: newValue) } - } - )) { - ForEach(sortedRegions) { region in - Text(displayName(for: region)) - .tag(region.name) - } - } - .pickerStyle(.menu) - .tint(.primary) - } - - // Region list with flood toggles - ForEach(sortedRegions) { region in - Toggle( - displayName(for: region), - isOn: Binding( - get: { region.floodAllowed }, - set: { _ in - Task { await viewModel.toggleRegionFlood(name: region.name) } - } - ) - ) - .accessibilityLabel( - region.isWildcard - ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTraffic - : region.name - ) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.Regions.floodToggleHint) - .disabled(viewModel.helper.isApplying) - } - .onDelete { offsets in - let sorted = sortedRegions - for offset in offsets { - let region = sorted[offset] - guard !region.isWildcard else { continue } - Task { await viewModel.removeRegion(name: region.name) } - } - } - - // Add region button - Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { - viewModel.isAddingRegion = true - } - .disabled(viewModel.helper.isApplying) - - // Save to device button - if viewModel.regionsLoaded { - Button { - Task { await viewModel.saveRegions() } - } label: { - AsyncActionLabel(isLoading: viewModel.helper.isApplying, showSuccess: viewModel.regionsSaveSuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveToDevice) - .foregroundStyle(viewModel.hasUnsavedRegionChanges ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .disabled(viewModel.helper.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) - } + } + + /// Display name for a region entry + private func displayName(for region: RepeaterRegionEntry) -> String { + region.isWildcard + ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTrafficWildcard + : region.name + } + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.regions, + icon: "globe", + isExpanded: $viewModel.isRegionsExpanded, + isLoaded: { viewModel.regionsLoaded }, + isLoading: $viewModel.isLoadingRegions, + hasError: $viewModel.regionsError, + onLoad: { await viewModel.fetchRegions() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.regionsFooter + ) { + if viewModel.regionsLoaded, viewModel.regions.isEmpty { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.empty) + .foregroundStyle(.secondary) + } + + // Home region picker + if !viewModel.regions.isEmpty { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.Regions.homeRegion, selection: Binding( + get: { + viewModel.regions.first(where: \.isHome)?.name + ?? RepeaterSettingsViewModel.wildcardName + }, + set: { newValue in + Task { await viewModel.setHomeRegion(name: newValue) } + } + )) { + ForEach(sortedRegions) { region in + Text(displayName(for: region)) + .tag(region.name) + } } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $viewModel.isAddingRegion) { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.Regions.regionName, text: $viewModel.newRegionName) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion) { - Task { await viewModel.addRegion(name: viewModel.newRegionName) } - } - Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { - viewModel.newRegionName = "" + .pickerStyle(.menu) + .tint(.primary) + } + + // Region list with flood toggles + ForEach(sortedRegions) { region in + Toggle( + displayName(for: region), + isOn: Binding( + get: { region.floodAllowed }, + set: { _ in + Task { await viewModel.toggleRegionFlood(name: region.name) } } + ) + ) + .accessibilityLabel( + region.isWildcard + ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTraffic + : region.name + ) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.Regions.floodToggleHint) + .disabled(viewModel.helper.isApplying) + } + .onDelete { offsets in + let sorted = sortedRegions + for offset in offsets { + let region = sorted[offset] + guard !region.isWildcard else { continue } + Task { await viewModel.removeRegion(name: region.name) } + } + } + + // Add region button + Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { + viewModel.isAddingRegion = true + } + .disabled(viewModel.helper.isApplying) + + // Save to device button + if viewModel.regionsLoaded { + Button { + Task { await viewModel.saveRegions() } + } label: { + AsyncActionLabel(isLoading: viewModel.helper.isApplying, showSuccess: viewModel.regionsSaveSuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveToDevice) + .foregroundStyle(viewModel.hasUnsavedRegionChanges ? Color.accentColor : .secondary) + .transition(.opacity) + } } + .disabled(viewModel.helper.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) + } + } + .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $viewModel.isAddingRegion) { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.Regions.regionName, text: $viewModel.newRegionName) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion) { + Task { await viewModel.addRegion(name: viewModel.newRegionName) } + } + Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { + viewModel.newRegionName = "" + } } + } } #Preview { - NavigationStack { - RepeaterSettingsView( - session: RemoteNodeSessionDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Mountain Peak Repeater", - role: .repeater, - latitude: 37.7749, - longitude: -122.4194, - isConnected: true, - permissionLevel: .admin - ) - ) - .environment(\.appState, AppState()) - } + NavigationStack { + RepeaterSettingsView( + session: RemoteNodeSessionDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Mountain Peak Repeater", + role: .repeater, + latitude: 37.7749, + longitude: -122.4194, + isConnected: true, + permissionLevel: .admin + ) + ) + .environment(\.appState, AppState()) + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift index 0be87380..5ca556a1 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift @@ -1,513 +1,526 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI @Observable @MainActor final class RepeaterSettingsViewModel { - - // MARK: - Shared Helper - - var helper = NodeSettingsViewModel() - - // MARK: - Repeater-Only: Behavior Settings - - var advertIntervalMinutes: Int? - var floodAdvertIntervalHours: Int? - var floodMaxHops: Int? - var repeaterEnabled: Bool? - private var originalAdvertIntervalMinutes: Int? - private var originalFloodAdvertIntervalHours: Int? - private var originalFloodMaxHops: Int? - private var originalRepeaterEnabled: Bool? - var isLoadingBehavior = false - var behaviorError = false - var behaviorLoaded: Bool { repeaterEnabled != nil || advertIntervalMinutes != nil } - - var advertIntervalError: String? - var floodAdvertIntervalError: String? - var floodMaxHopsError: String? - - var behaviorApplySuccess = false - - var behaviorSettingsModified: Bool { - (repeaterEnabled != nil && repeaterEnabled != originalRepeaterEnabled) || - (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || - (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || - (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + // MARK: - Shared Helper + + var helper = NodeSettingsViewModel() + + // MARK: - Repeater-Only: Behavior Settings + + var advertIntervalMinutes: Int? + var floodAdvertIntervalHours: Int? + var floodMaxHops: Int? + var repeaterEnabled: Bool? + private var originalAdvertIntervalMinutes: Int? + private var originalFloodAdvertIntervalHours: Int? + private var originalFloodMaxHops: Int? + private var originalRepeaterEnabled: Bool? + var isLoadingBehavior = false + var behaviorError = false + var behaviorLoaded: Bool { + repeaterEnabled != nil || advertIntervalMinutes != nil + } + + var advertIntervalError: String? + var floodAdvertIntervalError: String? + var floodMaxHopsError: String? + + var behaviorApplySuccess = false + + var behaviorSettingsModified: Bool { + (repeaterEnabled != nil && repeaterEnabled != originalRepeaterEnabled) || + (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || + (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || + (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + } + + // MARK: - Repeater-Only: Region Settings + + nonisolated static let wildcardName = "*" + var regions: [RepeaterRegionEntry] = [] + private var originalRegions: [RepeaterRegionEntry]? + var isLoadingRegions = false + var regionsError = false + var regionsLoaded: Bool { + originalRegions != nil + } + + var hasUnsavedRegionChanges = false + var isAddingRegion = false + var newRegionName = "" + var regionsSaveSuccess = false + + // MARK: - Expansion State (repeater-only sections) + + var isBehaviorExpanded = false + var isRegionsExpanded = false + + // MARK: - Dependencies + + private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } + var repeaterAdminService: RepeaterAdminService? { + repeaterAdminServiceProvider() + } + + private let logger = Logger(subsystem: "com.mc1", category: "RepeaterSettings") + + // MARK: - Cleanup + + func cleanup() async { + await repeaterAdminService?.setCLIHandler { _, _ in } + helper.cleanup() + } + + // MARK: - Configuration + + /// Nil service mirrors a disconnected state; commands then no-op. + func configure(repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, session: RemoteNodeSessionDTO) async { + repeaterAdminServiceProvider = repeaterAdminService + + guard let repeaterAdminService = repeaterAdminService() else { return } + + helper.configure( + session: session, + sendCommand: { [repeaterAdminService] id, cmd, timeout in + try await repeaterAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) + }, + sendRawCommand: { [repeaterAdminService] id, cmd, timeout in + try await repeaterAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) + } + ) + + helper.name = session.name + + helper.onPreFetchNodeInfo = { [weak self] in + await self?.fetchNodeInfo() } - // MARK: - Repeater-Only: Region Settings - - nonisolated static let wildcardName = "*" - var regions: [RepeaterRegionEntry] = [] - private var originalRegions: [RepeaterRegionEntry]? - var isLoadingRegions = false - var regionsError = false - var regionsLoaded: Bool { originalRegions != nil } - var hasUnsavedRegionChanges = false - var isAddingRegion = false - var newRegionName = "" - var regionsSaveSuccess = false - - // MARK: - Expansion State (repeater-only sections) - - var isBehaviorExpanded = false - var isRegionsExpanded = false - - // MARK: - Dependencies - - private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } - var repeaterAdminService: RepeaterAdminService? { repeaterAdminServiceProvider() } - private let logger = Logger(subsystem: "com.mc1", category: "RepeaterSettings") - - // MARK: - Cleanup - - func cleanup() async { - await repeaterAdminService?.setCLIHandler { _, _ in } - helper.cleanup() + // Register CLI handler for late responses + await repeaterAdminService.setCLIHandler { [weak self] message, _ in + await MainActor.run { + self?.handleLateResponse(message.text) + } } - // MARK: - Configuration - - /// Nil service mirrors a disconnected state; commands then no-op. - func configure(repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, session: RemoteNodeSessionDTO) async { - self.repeaterAdminServiceProvider = repeaterAdminService - - guard let repeaterAdminService = repeaterAdminService() else { return } - - helper.configure( - session: session, - sendCommand: { [repeaterAdminService] id, cmd, timeout in - try await repeaterAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) - }, - sendRawCommand: { [repeaterAdminService] id, cmd, timeout in - try await repeaterAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) - } - ) - - helper.name = session.name - - helper.onPreFetchNodeInfo = { [weak self] in - await self?.fetchNodeInfo() + // Detached so configure returns immediately and the node CLI send + // closure wires without waiting on the owner-info round-trip (matches + // RoomSettingsViewModel's detached device-info fetch). + Task { await fetchNodeInfo() } + } + + /// Builds the node-CLI send closure, pre-binding this session's id and + /// capturing the private admin service (a thin pass-through to + /// `RemoteNodeService.sendRawCLICommand`). Returns nil if not configured. + func makeNodeCLISendClosure( + session: RemoteNodeSessionDTO + ) -> (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? { + guard let repeaterAdminService else { return nil } + return { [repeaterAdminService, sessionID = session.id] command, timeout in + try await repeaterAdminService.sendRawCommand( + sessionID: sessionID, command: command, timeout: timeout + ) + } + } + + private var isLoadingNodeInfo = false + + private func fetchNodeInfo() async { + guard !isLoadingNodeInfo, let session = helper.session, let repeaterAdminService else { return } + isLoadingNodeInfo = true + defer { isLoadingNodeInfo = false } + do { + let response = try await repeaterAdminService.requestOwnerInfo(sessionID: session.id) + helper.setNodeInfo( + firmwareVersion: response.firmwareVersion, + name: response.nodeName, + ownerInfo: response.ownerInfo + ) + } catch { + logger.warning("Failed to fetch node info via binary: \(error)") + } + } + + // MARK: - Late Response Handling + + private func handleLateResponse(_ response: String) { + // Try shared sections first + if helper.handleCommonLateResponse(response) { return } + + // Behavior settings + if !isLoadingBehavior, behaviorError { + if originalRepeaterEnabled == nil { + if case let .repeatMode(enabled) = CLIResponse.parse(response, forQuery: "get repeat") { + repeaterEnabled = enabled + originalRepeaterEnabled = enabled + behaviorError = false + logger.info("Late response: received repeat mode") + return } - - // Register CLI handler for late responses - await repeaterAdminService.setCLIHandler { [weak self] message, _ in - await MainActor.run { - self?.handleLateResponse(message.text) - } + } + + if let result = NodeSettingsResponseParser.behaviorLateResponse( + response, + hasAdvertInterval: originalAdvertIntervalMinutes != nil, + hasFloodInterval: originalFloodAdvertIntervalHours != nil, + hasFloodMaxHops: originalFloodMaxHops != nil + ) { + switch result { + case let .advertInterval(interval): + advertIntervalMinutes = interval + originalAdvertIntervalMinutes = interval + case let .floodAdvertInterval(interval): + floodAdvertIntervalHours = interval + originalFloodAdvertIntervalHours = interval + case let .floodMax(hops): + floodMaxHops = hops + originalFloodMaxHops = hops } - - // Detached so configure returns immediately and the node CLI send - // closure wires without waiting on the owner-info round-trip (matches - // RoomSettingsViewModel's detached device-info fetch). - Task { await fetchNodeInfo() } + behaviorError = false + return + } } - /// Builds the node-CLI send closure, pre-binding this session's id and - /// capturing the private admin service (a thin pass-through to - /// `RemoteNodeService.sendRawCLICommand`). Returns nil if not configured. - func makeNodeCLISendClosure( - session: RemoteNodeSessionDTO - ) -> (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? { - guard let repeaterAdminService else { return nil } - return { [repeaterAdminService, sessionID = session.id] command, timeout in - try await repeaterAdminService.sendRawCommand( - sessionID: sessionID, command: command, timeout: timeout) + // Regions + if !isLoadingRegions, regionsError { + if originalRegions == nil { + let parsed = Self.parseRegionTree(response) + if !parsed.isEmpty { + regions = parsed + originalRegions = parsed + regionsError = false + logger.info("Late response: received region tree (\(parsed.count) regions)") + return } + } } - - private var isLoadingNodeInfo = false - - private func fetchNodeInfo() async { - guard !isLoadingNodeInfo, let session = helper.session, let repeaterAdminService else { return } - isLoadingNodeInfo = true - defer { isLoadingNodeInfo = false } - do { - let response = try await repeaterAdminService.requestOwnerInfo(sessionID: session.id) - helper.setNodeInfo( - firmwareVersion: response.firmwareVersion, - name: response.nodeName, - ownerInfo: response.ownerInfo - ) - } catch { - logger.warning("Failed to fetch node info via binary: \(error)") - } + } + + // MARK: - Behavior Fetch/Apply + + func fetchBehaviorSettings() async { + isLoadingBehavior = true + behaviorError = false + var hadTimeout = false + + do { + let response = try await helper.sendAndWait("get repeat") + if case let .repeatMode(enabled) = CLIResponse.parse(response, forQuery: "get repeat") { + repeaterEnabled = enabled + originalRepeaterEnabled = enabled + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get repeat mode: \(error)") } - // MARK: - Late Response Handling - - private func handleLateResponse(_ response: String) { - // Try shared sections first - if helper.handleCommonLateResponse(response) { return } - - // Behavior settings - if !isLoadingBehavior && behaviorError { - if originalRepeaterEnabled == nil { - if case .repeatMode(let enabled) = CLIResponse.parse(response, forQuery: "get repeat") { - self.repeaterEnabled = enabled - self.originalRepeaterEnabled = enabled - self.behaviorError = false - logger.info("Late response: received repeat mode") - return - } - } - - if let result = NodeSettingsResponseParser.behaviorLateResponse( - response, - hasAdvertInterval: originalAdvertIntervalMinutes != nil, - hasFloodInterval: originalFloodAdvertIntervalHours != nil, - hasFloodMaxHops: originalFloodMaxHops != nil - ) { - switch result { - case .advertInterval(let interval): - self.advertIntervalMinutes = interval - self.originalAdvertIntervalMinutes = interval - case .floodAdvertInterval(let interval): - self.floodAdvertIntervalHours = interval - self.originalFloodAdvertIntervalHours = interval - case .floodMax(let hops): - self.floodMaxHops = hops - self.originalFloodMaxHops = hops - } - self.behaviorError = false - return - } - } + do { + let response = try await helper.sendAndWait("get advert.interval") + if case let .advertInterval(minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { + advertIntervalMinutes = minutes + originalAdvertIntervalMinutes = minutes + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get advert interval: \(error)") + } - // Regions - if !isLoadingRegions && regionsError { - if originalRegions == nil { - let parsed = Self.parseRegionTree(response) - if !parsed.isEmpty { - self.regions = parsed - self.originalRegions = parsed - self.regionsError = false - logger.info("Late response: received region tree (\(parsed.count) regions)") - return - } - } - } + do { + let response = try await helper.sendAndWait("get flood.advert.interval") + if case let .floodAdvertInterval(hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + floodAdvertIntervalHours = hours + originalFloodAdvertIntervalHours = hours + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood advert interval: \(error)") } - // MARK: - Behavior Fetch/Apply + do { + let response = try await helper.sendAndWait("get flood.max") + if case let .floodMax(hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + floodMaxHops = hops + originalFloodMaxHops = hops + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood max: \(error)") + } - func fetchBehaviorSettings() async { - isLoadingBehavior = true - behaviorError = false - var hadTimeout = false - - do { - let response = try await helper.sendAndWait("get repeat") - if case .repeatMode(let enabled) = CLIResponse.parse(response, forQuery: "get repeat") { - self.repeaterEnabled = enabled - self.originalRepeaterEnabled = enabled - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get repeat mode: \(error)") - } + if hadTimeout { + behaviorError = true + } - do { - let response = try await helper.sendAndWait("get advert.interval") - if case .advertInterval(let minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { - self.advertIntervalMinutes = minutes - self.originalAdvertIntervalMinutes = minutes - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get advert interval: \(error)") + isLoadingBehavior = false + } + + func applyBehaviorSettings() async { + let validation = NodeSettingsViewModel.validateBehaviorFields( + advertInterval: advertIntervalMinutes, + floodInterval: floodAdvertIntervalHours, + floodMaxHops: floodMaxHops + ) + advertIntervalError = validation.advertInterval + floodAdvertIntervalError = validation.floodInterval + floodMaxHopsError = validation.floodMaxHops + + if validation.hasErrors { return } + + helper.isApplying = true + helper.errorMessage = nil + + do { + var allSucceeded = true + + if let repeaterEnabled, repeaterEnabled != originalRepeaterEnabled { + let response = try await helper.sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") + if case .ok = CLIResponse.parse(response) { + originalRepeaterEnabled = repeaterEnabled + } else { + allSucceeded = false } - - do { - let response = try await helper.sendAndWait("get flood.advert.interval") - if case .floodAdvertInterval(let hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { - self.floodAdvertIntervalHours = hours - self.originalFloodAdvertIntervalHours = hours - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get flood advert interval: \(error)") + } + + if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { + let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") + if case .ok = CLIResponse.parse(response) { + originalAdvertIntervalMinutes = advertIntervalMinutes + } else { + allSucceeded = false } - - do { - let response = try await helper.sendAndWait("get flood.max") - if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { - self.floodMaxHops = hops - self.originalFloodMaxHops = hops - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get flood max: \(error)") + } + + if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { + let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") + if case .ok = CLIResponse.parse(response) { + originalFloodAdvertIntervalHours = floodAdvertIntervalHours + } else { + allSucceeded = false } - - if hadTimeout { - behaviorError = true + } + + if let floodMaxHops, floodMaxHops != originalFloodMaxHops { + let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") + if case .ok = CLIResponse.parse(response) { + originalFloodMaxHops = floodMaxHops + } else { + allSucceeded = false } + } - isLoadingBehavior = false - } - - func applyBehaviorSettings() async { - let validation = NodeSettingsViewModel.validateBehaviorFields( - advertInterval: advertIntervalMinutes, - floodInterval: floodAdvertIntervalHours, - floodMaxHops: floodMaxHops + if allSucceeded { + await helper.flashSuccess( + setApplying: { helper.isApplying = $0 }, + setSuccess: { behaviorApplySuccess = $0 } ) - advertIntervalError = validation.advertInterval - floodAdvertIntervalError = validation.floodInterval - floodMaxHopsError = validation.floodMaxHops - - if validation.hasErrors { return } - - helper.isApplying = true - helper.errorMessage = nil - - do { - var allSucceeded = true - - if let repeaterEnabled, repeaterEnabled != originalRepeaterEnabled { - let response = try await helper.sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") - if case .ok = CLIResponse.parse(response) { - originalRepeaterEnabled = repeaterEnabled - } else { - allSucceeded = false - } - } - - if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { - let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") - if case .ok = CLIResponse.parse(response) { - originalAdvertIntervalMinutes = advertIntervalMinutes - } else { - allSucceeded = false - } - } - - if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { - let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") - if case .ok = CLIResponse.parse(response) { - originalFloodAdvertIntervalHours = floodAdvertIntervalHours - } else { - allSucceeded = false - } - } - - if let floodMaxHops, floodMaxHops != originalFloodMaxHops { - let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") - if case .ok = CLIResponse.parse(response) { - originalFloodMaxHops = floodMaxHops - } else { - allSucceeded = false - } - } - - if allSucceeded { - await helper.flashSuccess( - setApplying: { helper.isApplying = $0 }, - setSuccess: { behaviorApplySuccess = $0 } - ) - return - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - helper.errorMessage = error.userFacingMessage - } - - helper.isApplying = false + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + helper.errorMessage = error.userFacingMessage } - // MARK: - Region Methods - - func fetchRegions() async { - isLoadingRegions = true - regionsError = false - - do { - let treeResponse = try await helper.sendAndWait("region", timeout: .seconds(10), rawMatching: true) - let parsed = Self.parseRegionTree(treeResponse) - self.regions = parsed - self.originalRegions = parsed - } catch { - if case RemoteNodeError.timeout = error { - regionsError = true - } - logger.warning("Failed to fetch regions: \(error)") - } - - isLoadingRegions = false + helper.isApplying = false + } + + // MARK: - Region Methods + + func fetchRegions() async { + isLoadingRegions = true + regionsError = false + + do { + let treeResponse = try await helper.sendAndWait("region", timeout: .seconds(10), rawMatching: true) + let parsed = Self.parseRegionTree(treeResponse) + regions = parsed + originalRegions = parsed + } catch { + if case RemoteNodeError.timeout = error { + regionsError = true + } + logger.warning("Failed to fetch regions: \(error)") } - static func parseRegionTree(_ response: String) -> [RepeaterRegionEntry] { - var entries: [RepeaterRegionEntry] = [] - let lines = response.split(separator: "\n", omittingEmptySubsequences: true) - - for line in lines { - var text = String(line) - text = String(text.drop(while: { $0 == " " })) - guard !text.isEmpty else { continue } - - let floodAllowed: Bool - if text.hasSuffix(" F") { - floodAllowed = true - text = String(text.dropLast(2)) - } else { - floodAllowed = false - } - - let isHome: Bool - if text.hasSuffix("^") { - isHome = true - text = String(text.dropLast(1)) - } else { - isHome = false - } - - guard !text.isEmpty else { continue } - - entries.append(RepeaterRegionEntry( - name: text, - floodAllowed: floodAllowed, - isHome: isHome - )) - } - - return entries + isLoadingRegions = false + } + + static func parseRegionTree(_ response: String) -> [RepeaterRegionEntry] { + var entries: [RepeaterRegionEntry] = [] + let lines = response.split(separator: "\n", omittingEmptySubsequences: true) + + for line in lines { + var text = String(line) + text = String(text.drop(while: { $0 == " " })) + guard !text.isEmpty else { continue } + + let floodAllowed: Bool + if text.hasSuffix(" F") { + floodAllowed = true + text = String(text.dropLast(2)) + } else { + floodAllowed = false + } + + let isHome: Bool + if text.hasSuffix("^") { + isHome = true + text = String(text.dropLast(1)) + } else { + isHome = false + } + + guard !text.isEmpty else { continue } + + entries.append(RepeaterRegionEntry( + name: text, + floodAllowed: floodAllowed, + isHome: isHome + )) } - func toggleRegionFlood(name: String) async { - guard let index = regions.firstIndex(where: { $0.name == name }) else { return } - let currentlyAllowed = regions[index].floodAllowed - let command = currentlyAllowed ? "region denyf \(name)" : "region allowf \(name)" - - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait(command) - if case .ok = CLIResponse.parse(response) { - regions[index].floodAllowed = !currentlyAllowed - hasUnsavedRegionChanges = true - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion - } - } catch { - helper.errorMessage = error.userFacingMessage - } - - helper.isApplying = false + return entries + } + + func toggleRegionFlood(name: String) async { + guard let index = regions.firstIndex(where: { $0.name == name }) else { return } + let currentlyAllowed = regions[index].floodAllowed + let command = currentlyAllowed ? "region denyf \(name)" : "region allowf \(name)" + + helper.isApplying = true + helper.errorMessage = nil + + do { + let response = try await helper.sendAndWait(command) + if case .ok = CLIResponse.parse(response) { + regions[index].floodAllowed = !currentlyAllowed + hasUnsavedRegionChanges = true + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + } + } catch { + helper.errorMessage = error.userFacingMessage } - func setHomeRegion(name: String) async { - let command = "region home \(name)" - - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait(command, rawMatching: true) - if response.contains("home is now") { - for i in regions.indices { - regions[i].isHome = (regions[i].name == name) - } - hasUnsavedRegionChanges = true - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion - } - } catch { - helper.errorMessage = error.userFacingMessage - } + helper.isApplying = false + } - helper.isApplying = false - } + func setHomeRegion(name: String) async { + let command = "region home \(name)" - func addRegion(name: String) async { - let trimmed = name.trimmingCharacters(in: .whitespaces) - if let validationError = RegionNameValidator.validate(trimmed, existingRegions: regions.map(\.name)) { - switch validationError { - case .empty: return - case .invalidCharacters, .tooLong, .duplicate: - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed - } - return - } + helper.isApplying = true + helper.errorMessage = nil - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait("region put \(trimmed)") - if case .ok = CLIResponse.parse(response) { - regions.append(RepeaterRegionEntry( - name: trimmed, - floodAllowed: false, - isHome: false - )) - hasUnsavedRegionChanges = true - newRegionName = "" - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed - } - } catch { - helper.errorMessage = error.userFacingMessage + do { + let response = try await helper.sendAndWait(command, rawMatching: true) + if response.contains("home is now") { + for i in regions.indices { + regions[i].isHome = (regions[i].name == name) } + hasUnsavedRegionChanges = true + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + } + } catch { + helper.errorMessage = error.userFacingMessage + } - helper.isApplying = false + helper.isApplying = false + } + + func addRegion(name: String) async { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if let validationError = RegionNameValidator.validate(trimmed, existingRegions: regions.map(\.name)) { + switch validationError { + case .empty: return + case .invalidCharacters, .tooLong, .duplicate: + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + } + return } - func removeRegion(name: String) async { - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait("region remove \(name)") - if case .ok = CLIResponse.parse(response) { - regions.removeAll { $0.name == name } - hasUnsavedRegionChanges = true - } else if response.contains("not empty") { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed - } - } catch { - helper.errorMessage = error.userFacingMessage - } + helper.isApplying = true + helper.errorMessage = nil + + do { + let response = try await helper.sendAndWait("region put \(trimmed)") + if case .ok = CLIResponse.parse(response) { + regions.append(RepeaterRegionEntry( + name: trimmed, + floodAllowed: false, + isHome: false + )) + hasUnsavedRegionChanges = true + newRegionName = "" + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + } + } catch { + helper.errorMessage = error.userFacingMessage + } - helper.isApplying = false + helper.isApplying = false + } + + func removeRegion(name: String) async { + helper.isApplying = true + helper.errorMessage = nil + + do { + let response = try await helper.sendAndWait("region remove \(name)") + if case .ok = CLIResponse.parse(response) { + regions.removeAll { $0.name == name } + hasUnsavedRegionChanges = true + } else if response.contains("not empty") { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed + } + } catch { + helper.errorMessage = error.userFacingMessage } - func saveRegions() async { - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait("region save") - if case .ok = CLIResponse.parse(response) { - hasUnsavedRegionChanges = false - await helper.flashSuccess( - setApplying: { helper.isApplying = $0 }, - setSuccess: { regionsSaveSuccess = $0 } - ) - return - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed - } - } catch { - helper.errorMessage = error.userFacingMessage - } + helper.isApplying = false + } - helper.isApplying = false + func saveRegions() async { + helper.isApplying = true + helper.errorMessage = nil + + do { + let response = try await helper.sendAndWait("region save") + if case .ok = CLIResponse.parse(response) { + hasUnsavedRegionChanges = false + await helper.flashSuccess( + setApplying: { helper.isApplying = $0 }, + setSuccess: { regionsSaveSuccess = $0 } + ) + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed + } + } catch { + helper.errorMessage = error.userFacingMessage } + + helper.isApplying = false + } } // MARK: - Region Entry struct RepeaterRegionEntry: Identifiable, Equatable { - var id: String { name } - let name: String - var floodAllowed: Bool - var isHome: Bool - var isWildcard: Bool { name == RepeaterSettingsViewModel.wildcardName } + var id: String { + name + } + + let name: String + var floodAllowed: Bool + var isHome: Bool + var isWildcard: Bool { + name == RepeaterSettingsViewModel.wildcardName + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift index d6e20db5..12a70fea 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift @@ -9,255 +9,277 @@ import SwiftUI /// view model so switching hosts or segments preserves it. The content view does not own the discovery /// lifecycle either: only the guest host stops discovery on dismiss. struct RepeaterStatusContent: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - let viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let userLocation: CLLocation? - let connectedDeviceID: UUID? - /// Contact whose login route is shown at the bottom; nil hides the route section. - var routePathContact: ContactDTO? + let viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? + let connectedDeviceID: UUID? + /// Contact whose login route is shown at the bottom; nil hides the route section. + var routePathContact: ContactDTO? - var body: some View { - List { - NodeStatusHeaderSection(session: session) - StatusSection(viewModel: viewModel, session: session, connectionState: connectionState) - NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { - await viewModel.requestTelemetry(for: session) - } - NeighborsSection( - viewModel: viewModel, - session: session, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation, - connectionState: connectionState - ) - OwnerInfoSection(viewModel: viewModel, session: session, connectionState: connectionState) - NodeBatteryCurveDisclosureSection( - helper: viewModel.helper, - session: session, - connectionState: connectionState, - connectedDeviceID: connectedDeviceID - ) - if let routePathContact { - NodeRoutePathSection( - contact: routePathContact, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - } - } - .nodeStatusDestinations(helper: viewModel.helper) - .themedCanvas(theme) - .nodeManagementHeaderTopMargin() - .scrollDismissesKeyboard(.interactively) + var body: some View { + List { + NodeStatusHeaderSection(session: session) + StatusSection(viewModel: viewModel, session: session, connectionState: connectionState) + NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { + await viewModel.requestTelemetry(for: session) + } + NeighborsSection( + viewModel: viewModel, + session: session, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation, + connectionState: connectionState + ) + OwnerInfoSection(viewModel: viewModel, session: session, connectionState: connectionState) + NodeBatteryCurveDisclosureSection( + helper: viewModel.helper, + session: session, + connectionState: connectionState, + connectedDeviceID: connectedDeviceID + ) + if let routePathContact { + NodeRoutePathSection( + contact: routePathContact, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + } } + .nodeStatusDestinations(helper: viewModel.helper) + .navigationDestination(for: NeighborMapRoute.self) { _ in + NeighborSNRMapView( + session: session, + neighbors: viewModel.neighbors, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + } + .themedCanvas(theme) + .nodeManagementHeaderTopMargin() + .scrollDismissesKeyboard(.interactively) + } } +/// Value-based push identity for the neighbors map. Carries no payload: the destination +/// reads the live neighbor, contact, and location data in scope where it is registered, +/// keeping heavy arrays out of the navigation path. +private struct NeighborMapRoute: Hashable {} + // MARK: - Owner Info Section private struct OwnerInfoSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState + @Environment(\.appTheme) private var theme + @Bindable var viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.ownerInfoExpanded) { - if viewModel.isLoadingOwnerInfo { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let error = viewModel.ownerInfoError { - Text(error) - .foregroundStyle(.orange) - } else if let info = viewModel.ownerInfo, !info.isEmpty { - Text(info) - } else { - Text(L10n.RemoteNodes.RemoteNodes.Status.noOwnerInfo) - .foregroundStyle(.secondary) - } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Status.ownerInfo) - Spacer() - SectionReloadButton( - isLoading: viewModel.isLoadingOwnerInfo, - isLoaded: viewModel.ownerInfoLoaded, - hasError: viewModel.ownerInfoError != nil, - isDisabled: connectionState != .ready, - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadOwnerInfo, - onReload: { await viewModel.requestOwnerInfo(for: session) } - ) - } - } - .onChange(of: viewModel.ownerInfoExpanded) { _, isExpanded in - if isExpanded && !viewModel.ownerInfoLoaded && !viewModel.isLoadingOwnerInfo { - Task { - await viewModel.requestOwnerInfo(for: session) - } - } - } + var body: some View { + Section { + DisclosureGroup(isExpanded: $viewModel.ownerInfoExpanded) { + if viewModel.isLoadingOwnerInfo { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let error = viewModel.ownerInfoError { + Text(error) + .foregroundStyle(.orange) + } else if let info = viewModel.ownerInfo, !info.isEmpty { + Text(info) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Status.noOwnerInfo) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Status.ownerInfo) + Spacer() + SectionReloadButton( + isLoading: viewModel.isLoadingOwnerInfo, + isLoaded: viewModel.ownerInfoLoaded, + hasError: viewModel.ownerInfoError != nil, + isDisabled: connectionState != .ready, + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadOwnerInfo, + onReload: { await viewModel.requestOwnerInfo(for: session) } + ) + } + } + .onChange(of: viewModel.ownerInfoExpanded) { _, isExpanded in + if isExpanded, !viewModel.ownerInfoLoaded, !viewModel.isLoadingOwnerInfo { + Task { + await viewModel.requestOwnerInfo(for: session) + } + } + } } + .themedRowBackground(theme) + } } // MARK: - Status Section private struct StatusSection: View { - let viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState + let viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState - var body: some View { - NodeStatusSection(helper: viewModel.helper, connectionState: connectionState) { - await viewModel.requestStatus(for: session) - } rows: { - StatusRows(viewModel: viewModel) - } + var body: some View { + NodeStatusSection(helper: viewModel.helper, connectionState: connectionState) { + await viewModel.requestStatus(for: session) + } rows: { + StatusRows(viewModel: viewModel) } + } } // MARK: - Status Rows private struct StatusRows: View { - let viewModel: RepeaterStatusViewModel + let viewModel: RepeaterStatusViewModel - var body: some View { - NodeCommonStatusRows(helper: viewModel.helper) - - if let receiveErrors = viewModel.receiveErrorsDisplay { - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.receiveErrors, value: receiveErrors) - } - } + var body: some View { + NodeCommonStatusRows(helper: viewModel.helper) + NodePacketStatusRows( + helper: viewModel.helper, + receiveErrorsDisplay: viewModel.receiveErrorsDisplay + ) + } } // MARK: - Neighbors Section private struct NeighborsSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let userLocation: CLLocation? - let connectionState: DeviceConnectionState + @Environment(\.appTheme) private var theme + @Bindable var viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? + let connectionState: DeviceConnectionState - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.neighborsExpanded) { - if viewModel.isLoadingNeighbors && !viewModel.isDiscovering { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let error = viewModel.neighborsSectionError, !viewModel.isDiscovering { - Text(error) - .foregroundStyle(.orange) - } else if viewModel.neighbors.isEmpty && !viewModel.isDiscovering { - Text(L10n.RemoteNodes.RemoteNodes.Status.noNeighbors) - .foregroundStyle(.secondary) - } else { - ForEach(viewModel.neighbors, id: \.publicKeyPrefix) { neighbor in - let resolution = NeighborNameResolver.resolve( - for: neighbor.publicKeyPrefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - NavigationLink(value: NodeStatusRoute.neighborChart( - name: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, - neighborPrefix: neighbor.publicKeyPrefix - )) { - NeighborRow( - neighbor: neighbor, - displayName: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, - matchKind: resolution?.matchKind ?? .unresolved, - previousNeighbor: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots?.first { - $0.publicKeyPrefix == neighbor.publicKeyPrefix - }, - hasPreviousSnapshot: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots != nil - ) - } - } + var body: some View { + Section { + DisclosureGroup(isExpanded: $viewModel.neighborsExpanded) { + if viewModel.isLoadingNeighbors, !viewModel.isDiscovering { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let error = viewModel.neighborsSectionError, !viewModel.isDiscovering { + Text(error) + .foregroundStyle(.orange) + } else if viewModel.neighbors.isEmpty, !viewModel.isDiscovering { + Text(L10n.RemoteNodes.RemoteNodes.Status.noNeighbors) + .foregroundStyle(.secondary) + } else { + if !viewModel.neighbors.isEmpty { + NavigationLink(value: NeighborMapRoute()) { + Label(L10n.RemoteNodes.RemoteNodes.Status.viewOnMap, systemImage: "map") + } + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.Accessibility.viewNeighborsOnMap) + } - if let previousNeighbors = viewModel.helper.previousNeighborSnapshot?.neighborSnapshots { - let currentPrefixes = Set(viewModel.neighbors.map(\.publicKeyPrefix)) - let disappeared = previousNeighbors.filter { !currentPrefixes.contains($0.publicKeyPrefix) } - ForEach(disappeared, id: \.publicKeyPrefix) { old in - let resolution = NeighborNameResolver.resolve( - for: old.publicKeyPrefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - DisappearedNeighborRow( - neighbor: old, - displayName: resolution?.displayName ?? NeighborNameResolver.fallbackName(for: old.publicKeyPrefix), - matchKind: resolution?.matchKind ?? .unresolved - ) - } - } - } + ForEach(viewModel.neighbors, id: \.publicKeyPrefix) { neighbor in + let resolution = NeighborNameResolver.resolve( + for: neighbor.publicKeyPrefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + NavigationLink(value: NodeStatusRoute.neighborChart( + name: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, + neighborPrefix: neighbor.publicKeyPrefix + )) { + NeighborRow( + neighbor: neighbor, + displayName: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, + matchKind: resolution?.matchKind ?? .unresolved, + previousNeighbor: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots?.first { + $0.publicKeyPrefix == neighbor.publicKeyPrefix + }, + isNew: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots != nil + && !viewModel.helper.seenNeighborPrefixes.contains(neighbor.publicKeyPrefix) + ) + } + } - if session.isAdmin { - Button { - if viewModel.isDiscovering { - viewModel.stopDiscovery() - } else { - viewModel.startDiscovery(for: session) - } - } label: { - HStack { - if viewModel.isDiscovering { - ProgressView() - .controlSize(.small) - Text(L10n.RemoteNodes.RemoteNodes.Status.discoveringSeconds(viewModel.discoverySecondsRemaining)) - } else { - Label(L10n.RemoteNodes.RemoteNodes.Status.discoverNeighbors, systemImage: "antenna.radiowaves.left.and.right") - } - } - } - .radioDisabled(for: connectionState, or: viewModel.isLoadingNeighbors && !viewModel.isDiscovering) - } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Status.neighbors) - Spacer() - if viewModel.neighborsLoaded { - Text("\(viewModel.neighbors.count)") - .foregroundStyle(.secondary) - } - SectionReloadButton( - isLoading: viewModel.isLoadingNeighbors && !viewModel.isDiscovering, - isLoaded: viewModel.neighborsLoaded, - hasError: viewModel.neighborsSectionError != nil, - isDisabled: connectionState != .ready || viewModel.isDiscovering, - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadNeighbors, - onReload: { await viewModel.requestNeighbors(for: session) } - ) - } + if let previousNeighbors = viewModel.helper.previousNeighborSnapshot?.neighborSnapshots { + let currentPrefixes = Set(viewModel.neighbors.map(\.publicKeyPrefix)) + let disappeared = previousNeighbors.filter { !currentPrefixes.contains($0.publicKeyPrefix) } + ForEach(disappeared, id: \.publicKeyPrefix) { old in + let resolution = NeighborNameResolver.resolve( + for: old.publicKeyPrefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + DisappearedNeighborRow( + neighbor: old, + displayName: resolution?.displayName ?? NeighborNameResolver.fallbackName(for: old.publicKeyPrefix), + matchKind: resolution?.matchKind ?? .unresolved + ) + } + } + } + + if session.isAdmin { + Button { + if viewModel.isDiscovering { + viewModel.stopDiscovery() + } else { + viewModel.startDiscovery(for: session) } - .onChange(of: viewModel.neighborsExpanded) { _, isExpanded in - if isExpanded && !viewModel.neighborsLoaded && !viewModel.isLoadingNeighbors { - Task { - await viewModel.requestNeighbors(for: session) - } - } + } label: { + HStack { + if viewModel.isDiscovering { + ProgressView() + .controlSize(.small) + Text(L10n.RemoteNodes.RemoteNodes.Status.discoveringSeconds(viewModel.discoverySecondsRemaining)) + } else { + Label(L10n.RemoteNodes.RemoteNodes.Status.discoverNeighbors, systemImage: "antenna.radiowaves.left.and.right") + } } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.neighborsFooter) + } + .radioDisabled(for: connectionState, or: viewModel.isLoadingNeighbors && !viewModel.isDiscovering) + } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Status.neighbors) + Spacer() + if viewModel.neighborsLoaded { + Text("\(viewModel.neighbors.count)") + .foregroundStyle(.secondary) + } + SectionReloadButton( + isLoading: viewModel.isLoadingNeighbors && !viewModel.isDiscovering, + isLoaded: viewModel.neighborsLoaded, + hasError: viewModel.neighborsSectionError != nil, + isDisabled: connectionState != .ready || viewModel.isDiscovering, + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadNeighbors, + onReload: { await viewModel.requestNeighbors(for: session) } + ) + } + } + .onChange(of: viewModel.neighborsExpanded) { _, isExpanded in + if isExpanded, !viewModel.neighborsLoaded, !viewModel.isLoadingNeighbors { + Task { + await viewModel.requestNeighbors(for: session) + } } - .themedRowBackground(theme) + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.neighborsFooter) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift index 7cb78965..418757bc 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift @@ -3,98 +3,98 @@ import SwiftUI /// Guest standalone sheet for repeater stats, telemetry, and neighbors. struct RepeaterStatusView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss - let session: RemoteNodeSessionDTO - @State private var viewModel = RepeaterStatusViewModel() - @State private var contacts: [ContactDTO] = [] - @State private var discoveredNodes: [DiscoveredNodeDTO] = [] - /// The node's contact, kept live so the route section reflects the path the firmware learns after - /// a flood login (delivered asynchronously as a contact update). - @State private var routeContact: ContactDTO? + let session: RemoteNodeSessionDTO + @State private var viewModel = RepeaterStatusViewModel() + @State private var contacts: [ContactDTO] = [] + @State private var discoveredNodes: [DiscoveredNodeDTO] = [] + /// The node's contact, kept live so the route section reflects the path the firmware learns after + /// a flood login (delivered asynchronously as a contact update). + @State private var routeContact: ContactDTO? - var body: some View { - NavigationStack { - RepeaterStatusContent( - viewModel: viewModel, - session: session, - connectionState: appState.connectionState, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: appState.bestAvailableLocation, - connectedDeviceID: appState.connectedDevice?.radioID, - routePathContact: routeContact - ) - .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } - } - - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.RemoteNodes.RemoteNodes.done) { - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - } - } - } - .task { - viewModel.configure( - repeaterAdminService: { appState.services?.repeaterAdminService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } - ) - await viewModel.registerHandlers() + var body: some View { + NavigationStack { + RepeaterStatusContent( + viewModel: viewModel, + session: session, + connectionState: appState.connectionState, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: appState.bestAvailableLocation, + connectedDeviceID: appState.connectedDevice?.radioID, + routePathContact: routeContact + ) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } + } - // Pre-load OCV settings and contacts for neighbor matching - if let radioID = appState.connectedDevice?.radioID { - await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) - if let dataStore = appState.services?.dataStore { - contacts = (try? await dataStore.fetchContacts(radioID: radioID)) ?? [] - discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] - } - } - await refreshRouteContact() - } - .onChange(of: appState.contactsVersion) { - Task { await refreshRouteContact() } - } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.done) { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } } - .onDisappear { - viewModel.stopDiscovery() - Task { await viewModel.cleanup() } + } + .task { + viewModel.configure( + repeaterAdminService: { appState.services?.repeaterAdminService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService } + ) + await viewModel.registerHandlers() + + // Pre-load OCV settings and contacts for neighbor matching + if let radioID = appState.connectedDevice?.radioID { + await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) + if let dataStore = appState.services?.dataStore { + contacts = await (try? dataStore.fetchContacts(radioID: radioID)) ?? [] + discoveredNodes = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] + } } - .presentationDetents([.large]) + await refreshRouteContact() + } + .onChange(of: appState.contactsVersion) { + Task { await refreshRouteContact() } + } + } + .onDisappear { + viewModel.stopDiscovery() + Task { await viewModel.cleanup() } } + .presentationDetents([.large]) + } - private func refreshRouteContact() async { - guard let dataStore = appState.services?.dataStore else { return } - if let updated = (try? await dataStore.fetchContact( - radioID: session.radioID, - publicKey: session.publicKey - )).flatMap({ $0 }) { - routeContact = updated - } + private func refreshRouteContact() async { + guard let dataStore = appState.services?.dataStore else { return } + if let updated = await (try? dataStore.fetchContact( + radioID: session.radioID, + publicKey: session.publicKey + )).flatMap(\.self) { + routeContact = updated } + } } #Preview { - RepeaterStatusView( - session: RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Repeater", - role: .repeater, - isConnected: true, - permissionLevel: .admin - ) + RepeaterStatusView( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Repeater", + role: .repeater, + isConnected: true, + permissionLevel: .admin ) - .environment(\.appState, AppState()) + ) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift index becfa06d..24bf58ea 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift @@ -1,5 +1,5 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "RepeaterStatusVM") @@ -8,244 +8,251 @@ private let logger = Logger(subsystem: "com.mc1", category: "RepeaterStatusVM") @Observable @MainActor final class RepeaterStatusViewModel { + // MARK: - Shared Helper - // MARK: - Shared Helper + var helper = NodeStatusViewModel() - var helper = NodeStatusViewModel() + // MARK: - Repeater-Only Properties - // MARK: - Repeater-Only Properties + /// Neighbor entries + var neighbors: [NeighbourInfo] = [] - /// Neighbor entries - var neighbors: [NeighbourInfo] = [] + /// Loading states + var isLoadingNeighbors = false - /// Loading states - var isLoadingNeighbors = false + /// Whether neighbors have been loaded at least once (for refresh logic) + var neighborsLoaded = false - /// Whether neighbors have been loaded at least once (for refresh logic) - var neighborsLoaded = false + /// Whether the neighbors disclosure group is expanded + var neighborsExpanded = false - /// Whether the neighbors disclosure group is expanded - var neighborsExpanded = false + /// Error scoped to the neighbors section, kept separate from other sections' errors. + var neighborsSectionError: String? - /// Error scoped to the neighbors section, kept separate from other sections' errors. - var neighborsSectionError: String? + /// Discovery state + var isDiscovering: Bool { + discoverTask != nil + } - /// Discovery state - var isDiscovering: Bool { discoverTask != nil } - var discoverySecondsRemaining = 0 - private var discoverTask: Task? + var discoverySecondsRemaining = 0 + private var discoverTask: Task? - private static let discoveryDuration = 60 - private static let pollIntervalTicks = 5 - private static let discoverCommand = "discover.neighbors" + private static let discoveryDuration = 60 + private static let pollIntervalTicks = 5 + private static let discoverCommand = "discover.neighbors" - /// Owner info text - var ownerInfo: String? + /// Owner info text + var ownerInfo: String? - /// Owner info loading/state - var isLoadingOwnerInfo = false - var ownerInfoLoaded: Bool { ownerInfo != nil } - var ownerInfoExpanded = false - var ownerInfoError: String? + /// Owner info loading/state + var isLoadingOwnerInfo = false + var ownerInfoLoaded: Bool { + ownerInfo != nil + } - // MARK: - Dependencies + var ownerInfoExpanded = false + var ownerInfoError: String? - private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } - var repeaterAdminService: RepeaterAdminService? { repeaterAdminServiceProvider() } + // MARK: - Dependencies - // MARK: - Initialization + private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } + var repeaterAdminService: RepeaterAdminService? { + repeaterAdminServiceProvider() + } - init() {} + // MARK: - Initialization - /// Nil services mirror a disconnected state; requests then no-op. - func configure( - repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, - contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? - ) { - self.repeaterAdminServiceProvider = repeaterAdminService - helper.configure( - contactService: contactService, - nodeSnapshotService: nodeSnapshotService - ) - } + init() {} - /// Reads the live service from the provider so a reconnect-minted instance - /// is used at call time. Sets only the slots this view model owns; the admin - /// service is shared with the settings/CLI view model, so clearing here would - /// drop its CLI handler and silently break late CLI-response delivery. - func registerHandlers() async { - guard let repeaterAdminService = repeaterAdminService else { return } + /// Nil services mirror a disconnected state; requests then no-op. + func configure( + repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, + contactService: @escaping @MainActor () -> ContactService?, + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? + ) { + repeaterAdminServiceProvider = repeaterAdminService + helper.configure( + contactService: contactService, + nodeSnapshotService: nodeSnapshotService + ) + } - await repeaterAdminService.setStatusHandler { [weak self] status in - await self?.handleStatusResponse(status) - } + /// Reads the live service from the provider so a reconnect-minted instance + /// is used at call time. Sets only the slots this view model owns; the admin + /// service is shared with the settings/CLI view model, so clearing here would + /// drop its CLI handler and silently break late CLI-response delivery. + func registerHandlers() async { + guard let repeaterAdminService else { return } - await repeaterAdminService.setNeighboursHandler { [weak self] response in - await self?.handleNeighboursResponse(response) - } - - await repeaterAdminService.setTelemetryHandler { [weak self] response in - await self?.helper.handleTelemetryResponse(response) - } + await repeaterAdminService.setStatusHandler { [weak self] status in + await self?.handleStatusResponse(status) } - /// Clear every handler slot on the shared admin service. Only for true - /// surface teardown (sheet dismiss); calling it on a segment switch would - /// wipe the CLI handler the settings view model relies on. - func cleanup() async { - guard let repeaterAdminService = repeaterAdminService else { return } - await repeaterAdminService.clearHandlers() + await repeaterAdminService.setNeighboursHandler { [weak self] response in + await self?.handleNeighboursResponse(response) } - /// Clear only this view model's status/neighbours/telemetry handler slots, leaving the - /// settings view model's CLI handler intact. For the merged admin surface's status-segment teardown. - func clearStatusHandlers() async { - guard let repeaterAdminService = repeaterAdminService else { return } - await repeaterAdminService.clearStatusHandlers() + await repeaterAdminService.setTelemetryHandler { [weak self] response in + await self?.helper.handleTelemetryResponse(response) } - - // MARK: - Status - - func requestStatus(for session: RemoteNodeSessionDTO) async { - guard let repeaterAdminService else { return } - if helper.session == nil { helper.session = session } - - await helper.runRetryingSectionRequest( - operationName: "status", - setLoading: { self.helper.isLoadingStatus = $0 }, - setError: { self.helper.statusSectionError = $0 }, - operation: { [repeaterAdminService] timeout in - try await repeaterAdminService.requestStatus(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.handleStatusResponse($0) } - ) + } + + /// Clear every handler slot on the shared admin service. Only for true + /// surface teardown (sheet dismiss); calling it on a segment switch would + /// wipe the CLI handler the settings view model relies on. + func cleanup() async { + guard let repeaterAdminService else { return } + await repeaterAdminService.clearHandlers() + } + + /// Clear only this view model's status/neighbours/telemetry handler slots, leaving the + /// settings view model's CLI handler intact. For the merged admin surface's status-segment teardown. + func clearStatusHandlers() async { + guard let repeaterAdminService else { return } + await repeaterAdminService.clearStatusHandlers() + } + + // MARK: - Status + + func requestStatus(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "status", + setLoading: { self.helper.isLoadingStatus = $0 }, + setError: { self.helper.statusSectionError = $0 }, + operation: { [repeaterAdminService] timeout in + try await repeaterAdminService.requestStatus(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.handleStatusResponse($0) } + ) + } + + private func handleStatusResponse(_ response: RemoteNodeStatus) async { + await helper.handleStatusResponse( + response, + rxAirtimeSeconds: response.repeaterRxAirtimeSeconds, + receiveErrors: response.receiveErrors + ) + } + + // MARK: - Neighbors + + func requestNeighbors(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "neighbors", + setLoading: { self.isLoadingNeighbors = $0 }, + setError: { self.neighborsSectionError = $0 }, + operation: { [repeaterAdminService] timeout in + try await repeaterAdminService.fetchAllNeighbors(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.handleNeighboursResponse($0) } + ) + } + + func handleNeighboursResponse(_ response: NeighboursResponse) async { + neighbors = response.neighbours + isLoadingNeighbors = false + neighborsLoaded = true + + let entries = response.neighbours.map { + NeighborSnapshotEntry(publicKeyPrefix: $0.publicKeyPrefix, snr: $0.snr, secondsAgo: $0.secondsAgo) } + await helper.enrichNeighbors(entries) + } - private func handleStatusResponse(_ response: RemoteNodeStatus) async { - await helper.handleStatusResponse( - response, - rxAirtimeSeconds: response.repeaterRxAirtimeSeconds, - receiveErrors: response.receiveErrors - ) - } + // MARK: - Discovery - // MARK: - Neighbors + func startDiscovery(for session: RemoteNodeSessionDTO) { + guard let repeaterAdminService, !isDiscovering else { return } - func requestNeighbors(for session: RemoteNodeSessionDTO) async { - guard let repeaterAdminService else { return } - if helper.session == nil { helper.session = session } + discoverySecondsRemaining = Self.discoveryDuration - await helper.runRetryingSectionRequest( - operationName: "neighbors", - setLoading: { self.isLoadingNeighbors = $0 }, - setError: { self.neighborsSectionError = $0 }, - operation: { [repeaterAdminService] timeout in - try await repeaterAdminService.fetchAllNeighbors(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.handleNeighboursResponse($0) } + discoverTask = Task { + do { + _ = try await repeaterAdminService.sendCommand( + sessionID: session.id, + command: Self.discoverCommand ) - } - - func handleNeighboursResponse(_ response: NeighboursResponse) async { - self.neighbors = response.neighbours - self.isLoadingNeighbors = false - self.neighborsLoaded = true - - let entries = response.neighbours.map { - NeighborSnapshotEntry(publicKeyPrefix: $0.publicKeyPrefix, snr: $0.snr, secondsAgo: $0.secondsAgo) - } - await helper.enrichNeighbors(entries) - } - - // MARK: - Discovery - - func startDiscovery(for session: RemoteNodeSessionDTO) { - guard let repeaterAdminService, !isDiscovering else { return } - - discoverySecondsRemaining = Self.discoveryDuration - - discoverTask = Task { - do { - _ = try await repeaterAdminService.sendCommand( - sessionID: session.id, - command: Self.discoverCommand - ) - } catch { - neighborsSectionError = error.userFacingMessage - discoverySecondsRemaining = 0 - discoverTask = nil - return - } - - let startTime = Date.now - var tickCount = 0 - - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { break } - - let elapsed = Int(Date.now.timeIntervalSince(startTime)) - let remaining = max(0, Self.discoveryDuration - elapsed) - discoverySecondsRemaining = remaining - - tickCount += 1 - if tickCount.isMultiple(of: Self.pollIntervalTicks) { - await requestNeighbors(for: session) - } - - if remaining <= 0 { break } - } - - discoverySecondsRemaining = 0 - discoverTask = nil - } - } - - func stopDiscovery() { - discoverTask?.cancel() - discoverTask = nil + } catch { + neighborsSectionError = error.userFacingMessage discoverySecondsRemaining = 0 - } - - // MARK: - Telemetry - - func requestTelemetry(for session: RemoteNodeSessionDTO) async { - guard let repeaterAdminService else { return } - if helper.session == nil { helper.session = session } + discoverTask = nil + return + } - await helper.runRetryingSectionRequest( - operationName: "telemetry", - setLoading: { self.helper.isLoadingTelemetry = $0 }, - setError: { self.helper.telemetrySectionError = $0 }, - operation: { [repeaterAdminService] timeout in - try await repeaterAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.helper.handleTelemetryResponse($0) } - ) - } + let startTime = Date.now + var tickCount = 0 - // MARK: - Owner Info + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { break } - func requestOwnerInfo(for session: RemoteNodeSessionDTO) async { - guard let repeaterAdminService else { return } - if helper.session == nil { helper.session = session } + let elapsed = Int(Date.now.timeIntervalSince(startTime)) + let remaining = max(0, Self.discoveryDuration - elapsed) + discoverySecondsRemaining = remaining - await helper.runRetryingSectionRequest( - operationName: "ownerInfo", - setLoading: { self.isLoadingOwnerInfo = $0 }, - setError: { self.ownerInfoError = $0 }, - operation: { [repeaterAdminService] timeout in - try await repeaterAdminService.requestOwnerInfo(sessionID: session.id, timeout: timeout) - }, - onSuccess: { self.ownerInfo = $0.ownerInfo } - ) - } + tickCount += 1 + if tickCount.isMultiple(of: Self.pollIntervalTicks) { + await requestNeighbors(for: session) + } - // MARK: - Repeater-Only Display + if remaining <= 0 { break } + } - var receiveErrorsDisplay: String? { - guard let count = helper.status?.receiveErrors, count > 0 else { return nil } - return count.formatted() + discoverySecondsRemaining = 0 + discoverTask = nil } + } + + func stopDiscovery() { + discoverTask?.cancel() + discoverTask = nil + discoverySecondsRemaining = 0 + } + + // MARK: - Telemetry + + func requestTelemetry(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "telemetry", + setLoading: { self.helper.isLoadingTelemetry = $0 }, + setError: { self.helper.telemetrySectionError = $0 }, + operation: { [repeaterAdminService] timeout in + try await repeaterAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.helper.handleTelemetryResponse($0) } + ) + } + + // MARK: - Owner Info + + func requestOwnerInfo(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "ownerInfo", + setLoading: { self.isLoadingOwnerInfo = $0 }, + setError: { self.ownerInfoError = $0 }, + operation: { [repeaterAdminService] timeout in + try await repeaterAdminService.requestOwnerInfo(sessionID: session.id, timeout: timeout) + }, + onSuccess: { self.ownerInfo = $0.ownerInfo } + ) + } + + // MARK: - Repeater-Only Display + + var receiveErrorsDisplay: String? { + guard let count = helper.status?.receiveErrors, count > 0 else { return nil } + return count.formatted() + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index 5dc5c483..d47f6488 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -1,442 +1,451 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Full room chat interface struct RoomConversationView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.scenePhase) private var scenePhase - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + @Environment(\.appTheme) private var theme - @State private var session: RemoteNodeSessionDTO - @State private var viewModel = RoomConversationViewModel() - @State private var chatViewModel = ChatViewModel() - @State private var showingRoomInfo = false - @State private var roomToAuthenticate: RemoteNodeSessionDTO? - @State private var selectedRoomMessage: RoomMessageDTO? - @State private var sendDMContext: SendDMContext? - @State private var inputFocusRequest = 0 - @State private var isAtBottom = true - @State private var unreadCount = 0 - @State private var scrollToBottomRequest = 0 + @State private var session: RemoteNodeSessionDTO + @State private var viewModel = RoomConversationViewModel() + @State private var chatViewModel = ChatViewModel() + @State private var showingRoomInfo = false + @State private var roomToAuthenticate: RemoteNodeSessionDTO? + @State private var selectedRoomMessage: RoomMessageDTO? + @State private var sendDMContext: SendDMContext? + @State private var inputFocusRequest = 0 + @State private var isAtBottom = true + @State private var unreadCount = 0 + @State private var scrollToBottomRequest = 0 - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - init(session: RemoteNodeSessionDTO) { - self._session = State(initialValue: session) - } + init(session: RemoteNodeSessionDTO) { + _session = State(initialValue: session) + } - var body: some View { - makeMessagesView() - .mentionTapHandling( - contacts: chatViewModel.allContacts, - radioID: session.radioID, - shouldSuppressOpen: { selectedRoomMessage != nil } - ) - .safeAreaInset(edge: .bottom, spacing: 0) { - if !session.isConnected { - makeDisconnectedBanner() - } else if session.canPost { - makeInputBar() - } else { - makeReadOnlyBanner() - } - } - .animation(.default, value: session.isConnected) - .navigationHeader(title: session.name, subtitle: connectionStatus) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(L10n.RemoteNodes.RemoteNodes.Room.infoTitle, systemImage: "info.circle") { - showingRoomInfo = true - } - } - } - .sheet(isPresented: $showingRoomInfo) { - RoomInfoSheet(session: session) - .environment(\.chatViewModel, chatViewModel) - } - .sheet(item: $roomToAuthenticate) { sessionToAuth in - RoomAuthenticationSheet(session: sessionToAuth) { authenticatedSession in - roomToAuthenticate = nil - session = authenticatedSession - } - .presentationSizing(.page) - } - .sheet(item: $selectedRoomMessage) { message in - RoomMessageActionsSheet( - message: message, - availability: RoomMessageActionAvailability(message: message, session: session), - onAction: { dispatch($0, for: message) } - ) - } - .sheet(item: $sendDMContext) { context in - SendDMSheet(senderName: context.senderName, radioID: context.radioID) { contact in - appState.navigation.navigateToChat(with: contact) - } - } - .task { - viewModel.configure( - roomServerService: { appState.services?.roomServerService }, - dataStore: { appState.services?.dataStore }, - syncCoordinator: { appState.syncCoordinator }, - notificationService: { appState.services?.notificationService } - ) - chatViewModel.configure( - dependencies: ChatViewModel.Dependencies( - dataStore: { appState.offlineDataStore }, - messageService: { appState.services?.messageService }, - notificationService: { appState.services?.notificationService }, - channelService: { appState.services?.channelService }, - roomServerService: { appState.services?.roomServerService }, - contactService: { appState.services?.contactService }, - syncCoordinator: { appState.syncCoordinator }, - connectionState: { appState.connectionState }, - connectedDevice: { appState.connectedDevice }, - currentRadioID: { appState.currentRadioID }, - session: { appState.services?.session }, - reactionService: { appState.services?.reactionService }, - chatSendQueueService: { appState.services?.chatSendQueueService }, - inlineImageDimensionsStore: { nil }, - prefetchDataStore: { nil } - ), - onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, - linkPreviewCache: nil, - chatCoordinatorRegistry: nil, - conversation: nil - ) - await chatViewModel.loadAllContacts(radioID: session.radioID) - await viewModel.loadMessages(for: session) - } - .onChange(of: appState.contactsVersion) { _, _ in - // Keep the mention-resolution snapshot fresh: a contact added after the - // room opened must be tappable without reopening the screen. - Task { await chatViewModel.loadAllContacts(radioID: session.radioID) } - } - .task(id: appState.servicesVersion) { - // Track the active room so foreground banners for it are suppressed. - // Keyed on servicesVersion so a reconnect, which mints a fresh - // NotificationService, re-asserts this on the new instance. - appState.services?.notificationService.setActiveConversation(roomSessionID: session.id) - } - .task { - for await event in appState.messageEventStream.events() { - await viewModel.handleEvent(event) - } - } - .onChange(of: appState.sessionStateChangeCount) { _, _ in - Task { - await viewModel.refreshSession() - if let updated = viewModel.session { - session = updated - } - } - } - .task(id: session.isConnected) { - guard session.isConnected else { return } - await appState.services?.remoteNodeService.startSessionKeepAlive( - sessionID: session.id, publicKey: session.publicKey - ) - } - .onChange(of: session.isConnected) { _, isConnected in - if isConnected { - AccessibilityNotification.Announcement( - L10n.RemoteNodes.RemoteNodes.Room.reconnected - ).post() - } - } - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - // Re-clear the tray: notifications usually arrive while - // backgrounded with this room already on screen. - Task { - await appState.services?.notificationService - .removeDeliveredNotifications(forRoomSessionID: session.id) - await appState.services?.notificationService.updateBadgeCount() - } - if session.isConnected { - Task { - await appState.services?.remoteNodeService.startSessionKeepAlive( - sessionID: session.id, publicKey: session.publicKey - ) - } - } - } - } - .onDisappear { - // Only clear if this room still owns the active slot; a newer room's - // .task may have already claimed it before this view tears down. - if appState.services?.notificationService.activeRoomSessionID == session.id { - appState.services?.notificationService.activeRoomSessionID = nil - } - Task { - await appState.services?.remoteNodeService.stopSessionKeepAlive( - sessionID: session.id - ) - } + var body: some View { + makeMessagesView() + .mentionTapHandling( + contacts: chatViewModel.allContacts, + radioID: session.radioID, + shouldSuppressOpen: { selectedRoomMessage != nil } + ) + .safeAreaInset(edge: .bottom, spacing: 0) { + if !session.isConnected { + makeDisconnectedBanner() + } else if session.canPost { + makeInputBar() + } else { + makeReadOnlyBanner() + } + } + .animation(.default, value: session.isConnected) + .navigationHeader(title: session.name, subtitle: connectionStatus) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.RemoteNodes.RemoteNodes.Room.infoTitle, systemImage: "info.circle") { + showingRoomInfo = true + } + } + } + .sheet(isPresented: $showingRoomInfo) { + RoomInfoSheet(session: session) + .environment(\.chatViewModel, chatViewModel) + } + .sheet(item: $roomToAuthenticate) { sessionToAuth in + RoomAuthenticationSheet(session: sessionToAuth) { authenticatedSession in + roomToAuthenticate = nil + session = authenticatedSession + } + .presentationSizing(.page) + } + .sheet(item: $selectedRoomMessage) { message in + RoomMessageActionsSheet( + message: message, + availability: RoomMessageActionAvailability(message: message, session: session), + onAction: { dispatch($0, for: message) } + ) + } + .sheet(item: $sendDMContext) { context in + SendDMSheet( + senderName: context.senderName, + radioID: context.radioID, + unverifiedNickname: context.unverifiedNickname + ) { contact in + appState.navigation.navigateToChat(with: contact) + } + } + .task { + viewModel.configure( + roomServerService: { appState.services?.roomServerService }, + dataStore: { appState.services?.dataStore }, + syncCoordinator: { appState.syncCoordinator }, + notificationService: { appState.services?.notificationService } + ) + chatViewModel.configure( + dependencies: ChatViewModel.Dependencies( + dataStore: { appState.offlineDataStore }, + messageService: { appState.services?.messageService }, + notificationService: { appState.services?.notificationService }, + channelService: { appState.services?.channelService }, + roomServerService: { appState.services?.roomServerService }, + contactService: { appState.services?.contactService }, + syncCoordinator: { appState.syncCoordinator }, + connectionState: { appState.connectionState }, + connectedDevice: { appState.connectedDevice }, + currentRadioID: { appState.currentRadioID }, + session: { appState.services?.session }, + reactionService: { appState.services?.reactionService }, + chatSendQueueService: { appState.services?.chatSendQueueService }, + inlineImageDimensionsStore: { nil }, + prefetchDataStore: { nil } + ), + onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, + linkPreviewCache: nil, + chatCoordinatorRegistry: nil, + conversation: nil + ) + await chatViewModel.loadAllContacts(radioID: session.radioID) + await viewModel.loadMessages(for: session) + } + .onChange(of: appState.contactsVersion) { _, _ in + // Keep the mention-resolution snapshot fresh: a contact added after the + // room opened must be tappable without reopening the screen. + Task { await chatViewModel.loadAllContacts(radioID: session.radioID) } + } + .task(id: appState.servicesVersion) { + // Track the active room so foreground banners for it are suppressed. + // Keyed on servicesVersion so a reconnect, which mints a fresh + // NotificationService, re-asserts this on the new instance. + appState.services?.notificationService.setActiveConversation(roomSessionID: session.id) + } + .task { + for await event in appState.messageEventStream.events() { + await viewModel.handleEvent(event) + } + } + .onChange(of: appState.sessionStateChangeCount) { _, _ in + Task { + await viewModel.refreshSession() + if let updated = viewModel.session { + session = updated + } + } + } + .task(id: session.isConnected) { + guard session.isConnected else { return } + await appState.services?.remoteNodeService.startSessionKeepAlive( + sessionID: session.id, publicKey: session.publicKey + ) + } + .onChange(of: session.isConnected) { _, isConnected in + if isConnected { + AccessibilityNotification.Announcement( + L10n.RemoteNodes.RemoteNodes.Room.reconnected + ).post() + } + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + // Re-clear the tray: notifications usually arrive while + // backgrounded with this room already on screen. + Task { + await appState.services?.notificationService + .removeDeliveredNotifications(forRoomSessionID: session.id) + await appState.services?.notificationService.updateBadgeCount() + } + if session.isConnected { + Task { + await appState.services?.remoteNodeService.startSessionKeepAlive( + sessionID: session.id, publicKey: session.publicKey + ) } - } - - private var connectionStatus: String { - if session.isConnected { - return session.permissionLevel.localizedName + } + } + } + .onDisappear { + // Only clear if this room still owns the active slot; a newer room's + // .task may have already claimed it before this view tears down. + if appState.services?.notificationService.activeRoomSessionID == session.id { + appState.services?.notificationService.activeRoomSessionID = nil + } + Task { + await appState.services?.remoteNodeService.stopSessionKeepAlive( + sessionID: session.id + ) } - return L10n.RemoteNodes.RemoteNodes.Room.disconnected + } + } + + private var connectionStatus: String { + if session.isConnected { + return session.permissionLevel.localizedName } + return L10n.RemoteNodes.RemoteNodes.Room.disconnected + } - // MARK: - Subviews + // MARK: - Subviews - private func makeMessagesView() -> some View { - MessagesView( - hasLoadedOnce: viewModel.hasLoadedOnce, - messages: viewModel.messages, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - session: session, - theme: theme, - onRetry: { id in - Task { await viewModel.retryMessage(id: id) } - }, - onLongPress: { selectedRoomMessage = $0 } - ) - } + private func makeMessagesView() -> some View { + MessagesView( + hasLoadedOnce: viewModel.hasLoadedOnce, + messages: viewModel.messages, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + session: session, + theme: theme, + onRetry: { id in + Task { await viewModel.retryMessage(id: id) } + }, + onLongPress: { selectedRoomMessage = $0 } + ) + } - private func makeInputBar() -> some View { - ChatInputBar( - text: $viewModel.composingText, - focusRequest: inputFocusRequest, - placeholder: L10n.RemoteNodes.RemoteNodes.Room.publicMessage, - maxBytes: ProtocolLimits.maxDirectMessageLength, - isEncrypted: false - ) { text in - scrollToBottomRequest += 1 - Task { await viewModel.sendMessage(text: text) } - } + private func makeInputBar() -> some View { + ChatInputBar( + text: $viewModel.composingText, + focusRequest: inputFocusRequest, + placeholder: L10n.RemoteNodes.RemoteNodes.Room.publicMessage, + maxBytes: ProtocolLimits.maxDirectMessageLength, + isEncrypted: false + ) { text in + scrollToBottomRequest += 1 + Task { await viewModel.sendMessage(text: text) } } + } - private func makeReadOnlyBanner() -> some View { - RoomStatusBanner( - icon: "eye", - title: L10n.RemoteNodes.RemoteNodes.Room.viewOnlyBanner, - hint: L10n.RemoteNodes.RemoteNodes.Room.viewOnlyHint, - style: AnyShapeStyle(.secondary), - isBold: false, - action: { roomToAuthenticate = session } - ) - } + private func makeReadOnlyBanner() -> some View { + RoomStatusBanner( + icon: "eye", + title: L10n.RemoteNodes.RemoteNodes.Room.viewOnlyBanner, + hint: L10n.RemoteNodes.RemoteNodes.Room.viewOnlyHint, + style: AnyShapeStyle(.secondary), + isBold: false, + action: { roomToAuthenticate = session } + ) + } - private func makeDisconnectedBanner() -> some View { - RoomStatusBanner( - icon: "exclamationmark.triangle.fill", - title: L10n.RemoteNodes.RemoteNodes.Room.disconnectedBanner, - hint: L10n.RemoteNodes.RemoteNodes.Room.disconnectedHint, - style: AnyShapeStyle(.orange), - isBold: true, - action: { roomToAuthenticate = session } - ) - } + private func makeDisconnectedBanner() -> some View { + RoomStatusBanner( + icon: "exclamationmark.triangle.fill", + title: L10n.RemoteNodes.RemoteNodes.Room.disconnectedBanner, + hint: L10n.RemoteNodes.RemoteNodes.Room.disconnectedHint, + style: AnyShapeStyle(.orange), + isBold: true, + action: { roomToAuthenticate = session } + ) + } } // MARK: - Message Actions extension RoomConversationView { - private func dispatch(_ action: RoomMessageAction, for message: RoomMessageDTO) { - switch action { - case .copy: - UIPasteboard.general.string = message.text - case .reply: - handleReply(for: message) - case .sendDM: - handleSendDM(for: message) - case .sendAgain: - Task { await viewModel.sendMessage(text: message.text) } - } + private func dispatch(_ action: RoomMessageAction, for message: RoomMessageDTO) { + switch action { + case .copy: + UIPasteboard.general.string = message.text + case .reply: + handleReply(for: message) + case .sendDM: + handleSendDM(for: message) + case .sendAgain: + Task { await viewModel.sendMessage(text: message.text) } } + } - private func handleReply(for message: RoomMessageDTO) { - if replyWithQuote { - viewModel.composingText = MentionUtilities.buildReplyText( - mentionName: message.authorDisplayName, messageText: message.text) - } else { - viewModel.composingText = MentionUtilities.appendMention( - for: message.authorDisplayName, - to: viewModel.composingText - ) - } - // Raise the keyboard only after the actions sheet has finished dismissing; - // a focus request issued while it is still animating away is lost. - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - inputFocusRequest += 1 - } + private func handleReply(for message: RoomMessageDTO) { + if replyWithQuote { + viewModel.composingText = MentionUtilities.buildReplyText( + mentionName: message.authorDisplayName, messageText: message.text + ) + } else { + viewModel.composingText = MentionUtilities.appendMention( + for: message.authorDisplayName, + to: viewModel.composingText + ) } + // Raise the keyboard only after the actions sheet has finished dismissing; + // a focus request issued while it is still animating away is lost. + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + inputFocusRequest += 1 + } + } - private func handleSendDM(for message: RoomMessageDTO) { - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - sendDMContext = SendDMContext(senderName: message.authorDisplayName, radioID: session.radioID) - } + private func handleSendDM(for message: RoomMessageDTO) { + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + sendDMContext = SendDMContext( + senderName: message.authorDisplayName, + radioID: session.radioID, + unverifiedNickname: nil + ) } + } } // MARK: - Messages View private struct MessagesView: View { - let hasLoadedOnce: Bool - let messages: [RoomMessageDTO] - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - let session: RemoteNodeSessionDTO - let theme: Theme - let onRetry: (UUID) -> Void - let onLongPress: (RoomMessageDTO) -> Void + let hasLoadedOnce: Bool + let messages: [RoomMessageDTO] + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + let session: RemoteNodeSessionDTO + let theme: Theme + let onRetry: (UUID) -> Void + let onLongPress: (RoomMessageDTO) -> Void - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.openURL) private var openURL + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.openURL) private var openURL - var body: some View { - Group { - if !hasLoadedOnce { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if messages.isEmpty { - EmptyMessagesView(session: session) - } else { - let timestampVisibleIDs = Self.timestampVisibleIDs(in: messages) - ChatTableView( - items: messages, - cellContent: { message in - messageBubble(for: message, showTimestamp: timestampVisibleIDs.contains(message.id)) - .environment(\.appTheme, theme) - .environment(\.openURL, openURL) - }, - contentBackground: theme.surfaces?.canvas, - themeID: theme.id, - appearanceToken: AppearanceToken.make( - colorScheme: colorScheme, - contrast: colorSchemeContrast, - dynamicTypeSize: dynamicTypeSize - ), - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: .constant(0), - offscreenMentionIDs: .constant([]), - onSecondaryClick: onLongPress, - scrollToDividerRequest: .constant(0), - isDividerVisible: .constant(false) - ) - .overlay(alignment: .bottomTrailing) { - ScrollToBottomButton( - isVisible: !isAtBottom, - unreadCount: unreadCount, - onTap: { scrollToBottomRequest += 1 } - ) - .padding(.trailing, 16) - .padding(.bottom, 8) - } - } + var body: some View { + Group { + if !hasLoadedOnce { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if messages.isEmpty { + EmptyMessagesView(session: session) + } else { + let timestampVisibleIDs = Self.timestampVisibleIDs(in: messages) + ChatTableView( + items: messages, + cellContent: { message in + messageBubble(for: message, showTimestamp: timestampVisibleIDs.contains(message.id)) + .environment(\.appTheme, theme) + .environment(\.openURL, openURL) + }, + contentBackground: theme.surfaces?.canvas, + themeID: theme.id, + appearanceToken: AppearanceToken.make( + colorScheme: colorScheme, + contrast: colorSchemeContrast, + dynamicTypeSize: dynamicTypeSize + ), + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: .constant(0), + offscreenMentionIDs: .constant([]), + onSecondaryClick: onLongPress, + scrollToDividerRequest: .constant(0), + isDividerVisible: .constant(false) + ) + .overlay(alignment: .bottomTrailing) { + ScrollToBottomButton( + isVisible: !isAtBottom, + unreadCount: unreadCount, + onTap: { scrollToBottomRequest += 1 } + ) + .padding(.trailing, 16) + .padding(.bottom, 8) } - .themedCanvas(theme) + } } + .themedCanvas(theme) + } - private func messageBubble(for message: RoomMessageDTO, showTimestamp: Bool) -> some View { - RoomMessageBubble( - message: message, - showTimestamp: showTimestamp, - onRetry: message.status == .failed ? { - onRetry(message.id) - } : nil, - onLongPress: onLongPress - ) - } + private func messageBubble(for message: RoomMessageDTO, showTimestamp: Bool) -> some View { + RoomMessageBubble( + message: message, + showTimestamp: showTimestamp, + onRetry: message.status == .failed ? { + onRetry(message.id) + } : nil, + onLongPress: onLongPress + ) + } - /// Single pass over the array producing the set of message IDs whose timestamp is shown, - /// so each cell does an O(1) lookup instead of an O(n) `firstIndex` per body evaluation. - private static func timestampVisibleIDs(in messages: [RoomMessageDTO]) -> Set { - var visible = Set() - for index in messages.indices where RoomConversationViewModel.shouldShowTimestamp(at: index, in: messages) { - visible.insert(messages[index].id) - } - return visible + /// Single pass over the array producing the set of message IDs whose timestamp is shown, + /// so each cell does an O(1) lookup instead of an O(n) `firstIndex` per body evaluation. + private static func timestampVisibleIDs(in messages: [RoomMessageDTO]) -> Set { + var visible = Set() + for index in messages.indices where RoomConversationViewModel.shouldShowTimestamp(at: index, in: messages) { + visible.insert(messages[index].id) } + return visible + } } // MARK: - Empty Messages View private struct EmptyMessagesView: View { - let session: RemoteNodeSessionDTO + let session: RemoteNodeSessionDTO - var body: some View { - VStack(spacing: 16) { - NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 80) + var body: some View { + VStack(spacing: 16) { + NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 80) - Text(session.name) - .font(.title2) - .bold() + Text(session.name) + .font(.title2) + .bold() - Text(L10n.RemoteNodes.RemoteNodes.Room.noMessagesYet) - .foregroundStyle(.secondary) + Text(L10n.RemoteNodes.RemoteNodes.Room.noMessagesYet) + .foregroundStyle(.secondary) - if session.canPost { - Text(L10n.RemoteNodes.RemoteNodes.Room.beFirstToPost) - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + if session.canPost { + Text(L10n.RemoteNodes.RemoteNodes.Room.beFirstToPost) + .font(.caption) + .foregroundStyle(.tertiary) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } } // MARK: - Room Status Banner private struct RoomStatusBanner: View { - let icon: String - let title: String - let hint: String - let style: AnyShapeStyle - let isBold: Bool - let action: () -> Void + let icon: String + let title: String + let hint: String + let style: AnyShapeStyle + let isBold: Bool + let action: () -> Void - var body: some View { - Button(action: action) { - VStack(spacing: 2) { - HStack { - Image(systemName: icon) - Text(title) - } - .bold(isBold) - Text(hint) - .font(.caption) - } - .font(.subheadline) - .foregroundStyle(style) - .frame(maxWidth: .infinity) - .padding() - .background(.bar) + var body: some View { + Button(action: action) { + VStack(spacing: 2) { + HStack { + Image(systemName: icon) + Text(title) } - .accessibilityLabel(title) - .accessibilityHint(hint) + .bold(isBold) + Text(hint) + .font(.caption) + } + .font(.subheadline) + .foregroundStyle(style) + .frame(maxWidth: .infinity) + .padding() + .background(.bar) } + .accessibilityLabel(title) + .accessibilityHint(hint) + } } #Preview { - NavigationStack { - RoomConversationView( - session: RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - role: .roomServer, - isConnected: true, - permissionLevel: .readWrite - ) - ) - } - .environment(\.appState, AppState()) + NavigationStack { + RoomConversationView( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .readWrite + ) + ) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift index 20a76dc7..5b4d59aa 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift @@ -1,221 +1,231 @@ -import SwiftUI import MC1Services +import SwiftUI /// ViewModel for room conversation operations @Observable @MainActor final class RoomConversationViewModel { - - // MARK: - Properties - - /// Current room session - var session: RemoteNodeSessionDTO? - - /// Room messages - var messages: [RoomMessageDTO] = [] - - /// Loading state - var isLoading = false - - /// Whether data has been loaded at least once (prevents empty state flash) - var hasLoadedOnce = false - - /// Error message if any - var errorMessage: String? - - /// Message text being composed - var composingText = "" - - /// Whether a message is being sent - var isSending = false - - // MARK: - Dependencies - - private var roomServerServiceProvider: @MainActor () -> RoomServerService? = { nil } - var roomServerService: RoomServerService? { roomServerServiceProvider() } - private var dataStoreProvider: @MainActor () -> DataStore? = { nil } - var dataStore: DataStore? { dataStoreProvider() } - private var syncCoordinatorProvider: @MainActor () -> SyncCoordinator? = { nil } - var syncCoordinator: SyncCoordinator? { syncCoordinatorProvider() } - private var notificationServiceProvider: @MainActor () -> NotificationService? = { nil } - var notificationService: NotificationService? { notificationServiceProvider() } - - /// Pending coalesced reload spawned by `handleEvent`. Non-nil while a reload - /// is scheduled but not yet fired, so a burst of room events triggers a - /// single `loadMessages` instead of one per event. - private var reloadTask: Task? - - /// Debounce window before a coalesced reload fires. Long enough to batch - /// the typical LoRa-paced room event burst, short enough that user-visible - /// state still feels fresh. - private static let reloadDebounce: Duration = .milliseconds(50) - - // MARK: - Initialization - - init() {} - - /// Nil services mirror a disconnected state; operations then no-op. - func configure( - roomServerService: @escaping @MainActor () -> RoomServerService?, - dataStore: @escaping @MainActor () -> DataStore?, - syncCoordinator: @escaping @MainActor () -> SyncCoordinator?, - notificationService: @escaping @MainActor () -> NotificationService? - ) { - self.roomServerServiceProvider = roomServerService - self.dataStoreProvider = dataStore - self.syncCoordinatorProvider = syncCoordinator - self.notificationServiceProvider = notificationService + // MARK: - Properties + + /// Current room session + var session: RemoteNodeSessionDTO? + + /// Room messages + var messages: [RoomMessageDTO] = [] + + /// Loading state + var isLoading = false + + /// Whether data has been loaded at least once (prevents empty state flash) + var hasLoadedOnce = false + + /// Error message if any + var errorMessage: String? + + /// Message text being composed + var composingText = "" + + /// Whether a message is being sent + var isSending = false + + // MARK: - Dependencies + + private var roomServerServiceProvider: @MainActor () -> RoomServerService? = { nil } + var roomServerService: RoomServerService? { + roomServerServiceProvider() + } + + private var dataStoreProvider: @MainActor () -> DataStore? = { nil } + var dataStore: DataStore? { + dataStoreProvider() + } + + private var syncCoordinatorProvider: @MainActor () -> SyncCoordinator? = { nil } + var syncCoordinator: SyncCoordinator? { + syncCoordinatorProvider() + } + + private var notificationServiceProvider: @MainActor () -> NotificationService? = { nil } + var notificationService: NotificationService? { + notificationServiceProvider() + } + + /// Pending coalesced reload spawned by `handleEvent`. Non-nil while a reload + /// is scheduled but not yet fired, so a burst of room events triggers a + /// single `loadMessages` instead of one per event. + private var reloadTask: Task? + + /// Debounce window before a coalesced reload fires. Long enough to batch + /// the typical LoRa-paced room event burst, short enough that user-visible + /// state still feels fresh. + private static let reloadDebounce: Duration = .milliseconds(50) + + // MARK: - Initialization + + init() {} + + /// Nil services mirror a disconnected state; operations then no-op. + func configure( + roomServerService: @escaping @MainActor () -> RoomServerService?, + dataStore: @escaping @MainActor () -> DataStore?, + syncCoordinator: @escaping @MainActor () -> SyncCoordinator?, + notificationService: @escaping @MainActor () -> NotificationService? + ) { + roomServerServiceProvider = roomServerService + dataStoreProvider = dataStore + syncCoordinatorProvider = syncCoordinator + notificationServiceProvider = notificationService + } + + // MARK: - Messages + + /// Load messages for the current session + func loadMessages(for session: RemoteNodeSessionDTO) async { + guard let roomServerService else { return } + let notificationService = notificationService + let syncCoordinator = syncCoordinator + + self.session = session + isLoading = true + errorMessage = nil + + do { + messages = try await roomServerService.fetchMessages(sessionID: session.id) + + // Clear unread count, remove any delivered notifications for this + // room still in the tray, and update the badge + try await roomServerService.markAsRead(sessionID: session.id) + await notificationService?.removeDeliveredNotifications(forRoomSessionID: session.id) + await notificationService?.updateBadgeCount() + syncCoordinator?.notifyConversationsChanged() + } catch { + errorMessage = error.userFacingMessage } - // MARK: - Messages - - /// Load messages for the current session - func loadMessages(for session: RemoteNodeSessionDTO) async { - guard let roomServerService = roomServerService else { return } - let notificationService = notificationService - let syncCoordinator = syncCoordinator - - self.session = session - isLoading = true - errorMessage = nil - - do { - messages = try await roomServerService.fetchMessages(sessionID: session.id) - - // Clear unread count, remove any delivered notifications for this - // room still in the tray, and update the badge - try await roomServerService.markAsRead(sessionID: session.id) - await notificationService?.removeDeliveredNotifications(forRoomSessionID: session.id) - await notificationService?.updateBadgeCount() - syncCoordinator?.notifyConversationsChanged() - } catch { - errorMessage = error.userFacingMessage - } - - hasLoadedOnce = true - isLoading = false + hasLoadedOnce = true + isLoading = false + } + + /// Optimistically append a message if not already present. + /// Called synchronously before async reload to ensure ChatTableView + /// sees the new count immediately for unread tracking. + func appendMessageIfNew(_ message: RoomMessageDTO) { + guard !messages.contains(where: { $0.id == message.id }) else { return } + messages.append(message) + } + + /// Send a message to the current room + func sendMessage(text: String) async { + guard let session, + let roomServerService, + !text.isEmpty else { + composingText = text + return } - /// Optimistically append a message if not already present. - /// Called synchronously before async reload to ensure ChatTableView - /// sees the new count immediately for unread tracking. - func appendMessageIfNew(_ message: RoomMessageDTO) { - guard !messages.contains(where: { $0.id == message.id }) else { return } - messages.append(message) - } - - /// Send a message to the current room - func sendMessage(text: String) async { - guard let session, - let roomServerService, - !text.isEmpty else { - composingText = text - return - } - - isSending = true - errorMessage = nil + isSending = true + errorMessage = nil - do { - let message = try await roomServerService.postMessage(sessionID: session.id, text: text) + do { + let message = try await roomServerService.postMessage(sessionID: session.id, text: text) - // Add to local array - messages.append(message) - } catch { - errorMessage = error.userFacingMessage - } - - isSending = false + // Add to local array + messages.append(message) + } catch { + errorMessage = error.userFacingMessage } - /// Refresh session state from database - func refreshSession() async { - guard let session, let dataStore else { return } + isSending = false + } - if let updated = try? await dataStore.fetchRemoteNodeSession(id: session.id) { - self.session = updated - } - } + /// Refresh session state from database + func refreshSession() async { + guard let session, let dataStore else { return } - /// Fold a `MessageEvent` from `MessageEventStream` into view-model state. - /// Called on the main actor from a SwiftUI `.task` consumer in - /// `RoomConversationView`. The exhaustive switch is deliberate — a new - /// `MessageEvent` case becomes a compile error rather than a silent skip. - func handleEvent(_ event: MessageEvent) async { - guard let session else { return } - - switch event { - case .roomMessageReceived(let message, let sessionID): - // Optimistic append first so the ChatTableView sees the new count - // immediately for unread tracking, then coalesce the reload so a - // burst of incoming room messages triggers one DB sync, not N. - guard sessionID == session.id else { return } - appendMessageIfNew(message) - scheduleCoalescedReload() - - case .roomMessageStatusUpdated(let messageID): - if messages.contains(where: { $0.id == messageID }) { - scheduleCoalescedReload() - } - - case .roomMessageFailed(let messageID): - if messages.contains(where: { $0.id == messageID }) { - scheduleCoalescedReload() - } - - case .directMessageReceived, .channelMessageReceived, - .messageStatusResolved, .messageResent, .messageFailed, .messageRetrying, - .heardRepeatRecorded, .reactionReceived, .routingChanged: - // Non-Room events are not Room-scoped. Enumerated explicitly so - // adding a new MessageEvent case surfaces as a non-exhaustive - // switch compile error rather than a silent skip. - break - } + if let updated = try? await dataStore.fetchRemoteNodeSession(id: session.id) { + self.session = updated } - - /// Schedules a debounced reload so bursts of room events trigger one - /// `loadMessages` instead of one per event. No-ops if a reload is - /// already pending. - private func scheduleCoalescedReload() { - guard reloadTask == nil else { return } - reloadTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: Self.reloadDebounce) - guard let self else { return } - self.reloadTask = nil - guard let session = self.session else { return } - await self.loadMessages(for: session) - } + } + + /// Fold a `MessageEvent` from `MessageEventStream` into view-model state. + /// Called on the main actor from a SwiftUI `.task` consumer in + /// `RoomConversationView`. The exhaustive switch is deliberate — a new + /// `MessageEvent` case becomes a compile error rather than a silent skip. + func handleEvent(_ event: MessageEvent) async { + guard let session else { return } + + switch event { + case let .roomMessageReceived(message, sessionID): + // Optimistic append first so the ChatTableView sees the new count + // immediately for unread tracking, then coalesce the reload so a + // burst of incoming room messages triggers one DB sync, not N. + guard sessionID == session.id else { return } + appendMessageIfNew(message) + scheduleCoalescedReload() + + case let .roomMessageStatusUpdated(messageID): + if messages.contains(where: { $0.id == messageID }) { + scheduleCoalescedReload() + } + + case let .roomMessageFailed(messageID): + if messages.contains(where: { $0.id == messageID }) { + scheduleCoalescedReload() + } + + case .directMessageReceived, .channelMessageReceived, + .messageStatusResolved, .messageResent, .messageFailed, .messageRetrying, + .heardRepeatRecorded, .reactionReceived, .routingChanged: + // Non-Room events are not Room-scoped. Enumerated explicitly so + // adding a new MessageEvent case surfaces as a non-exhaustive + // switch compile error rather than a silent skip. + break } - - /// Retry sending a failed room message - func retryMessage(id: UUID) async { - guard let roomServerService else { return } - - do { - let updatedMessage = try await roomServerService.retryMessage(id: id) - // Update local array - if let index = messages.firstIndex(where: { $0.id == id }) { - messages[index] = updatedMessage - } - } catch { - errorMessage = error.userFacingMessage - } + } + + /// Schedules a debounced reload so bursts of room events trigger one + /// `loadMessages` instead of one per event. No-ops if a reload is + /// already pending. + private func scheduleCoalescedReload() { + guard reloadTask == nil else { return } + reloadTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: Self.reloadDebounce) + guard let self else { return } + reloadTask = nil + guard let session else { return } + await loadMessages(for: session) + } + } + + /// Retry sending a failed room message + func retryMessage(id: UUID) async { + guard let roomServerService else { return } + + do { + let updatedMessage = try await roomServerService.retryMessage(id: id) + // Update local array + if let index = messages.firstIndex(where: { $0.id == id }) { + messages[index] = updatedMessage + } + } catch { + errorMessage = error.userFacingMessage } + } - // MARK: - Timestamp Helpers + // MARK: - Timestamp Helpers - /// Time gap (in seconds) that breaks message grouping for timestamps. - static let messageGroupingGapSeconds = 300 + /// Time gap (in seconds) that breaks message grouping for timestamps. + static let messageGroupingGapSeconds = 300 - /// Determines if a timestamp should be shown for a message at the given index. - /// Shows timestamp for first message or when there's a gap > 5 minutes. - static func shouldShowTimestamp(at index: Int, in messages: [RoomMessageDTO]) -> Bool { - guard index > 0 else { return true } + /// Determines if a timestamp should be shown for a message at the given index. + /// Shows timestamp for first message or when there's a gap > 5 minutes. + static func shouldShowTimestamp(at index: Int, in messages: [RoomMessageDTO]) -> Bool { + guard index > 0 else { return true } - let currentMessage = messages[index] - let previousMessage = messages[index - 1] + let currentMessage = messages[index] + let previousMessage = messages[index - 1] - let gap = abs(Int(currentMessage.timestamp) - Int(previousMessage.timestamp)) - return gap > messageGroupingGapSeconds - } + let gap = abs(Int(currentMessage.timestamp) - Int(previousMessage.timestamp)) + return gap > messageGroupingGapSeconds + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift index 4cce1a71..228af92b 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift @@ -1,7 +1,7 @@ /// An action a user can take on a room message from its long-press sheet. enum RoomMessageAction { - case copy - case reply - case sendDM - case sendAgain + case copy + case reply + case sendDM + case sendAgain } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift index a0803c00..7d884bea 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift @@ -4,13 +4,13 @@ import MC1Services /// Computed once at presentation so the buttons stay stable if the session's /// permission changes while the sheet is open. struct RoomMessageActionAvailability { - let canReply: Bool - let canSendDM: Bool - let canSendAgain: Bool + let canReply: Bool + let canSendDM: Bool + let canSendAgain: Bool - init(message: RoomMessageDTO, session: RemoteNodeSessionDTO) { - canReply = !message.isFromSelf && session.canPost - canSendDM = !message.isFromSelf && message.authorName != nil - canSendAgain = message.isFromSelf - } + init(message: RoomMessageDTO, session: RemoteNodeSessionDTO) { + canReply = !message.isFromSelf && session.canPost + canSendDM = !message.isFromSelf && message.authorName != nil + canSendAgain = message.isFromSelf + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift index e574c7ef..76eff048 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift @@ -5,183 +5,186 @@ import SwiftUI /// chat `MessageActionsSheet` layout but carries no reactions and only the /// metadata a server-pushed room message actually has. struct RoomMessageActionsSheet: View { - let message: RoomMessageDTO - let availability: RoomMessageActionAvailability - let onAction: (RoomMessageAction) -> Void - - @Environment(\.dismiss) private var dismiss - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - - var body: some View { + let message: RoomMessageDTO + let availability: RoomMessageActionAvailability + let onAction: (RoomMessageAction) -> Void + + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + + var body: some View { + VStack(spacing: 0) { + header + Divider() + ScrollView { VStack(spacing: 0) { - header - Divider() - ScrollView { - VStack(spacing: 0) { - buttons - details - } - } + buttons + details } - .presentationDetents( - (horizontalSizeClass == .regular || dynamicTypeSize.isAccessibilitySize) - ? [.large] : [.medium, .large] - ) - .presentationContentInteraction(.scrolls) - .presentationDragIndicator(.visible) - .presentationBackground(Color(.systemBackground)) + } } - - private func performAction(_ action: RoomMessageAction) { - onAction(action) - dismiss() + .presentationDetents( + (horizontalSizeClass == .regular || dynamicTypeSize.isAccessibilitySize) + ? [.large] : [.medium, .large] + ) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackground(Color(.systemBackground)) + } + + private func performAction(_ action: RoomMessageAction) { + onAction(action) + dismiss() + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(message.authorDisplayName) + .font(.subheadline) + .bold() + Spacer() + Text(message.date, format: .dateTime.hour().minute()) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Text(message.text) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) } - - private var header: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(message.authorDisplayName) - .font(.subheadline) - .bold() - Spacer() - Text(message.date, format: .dateTime.hour().minute()) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Text(message.text) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var buttons: some View { + if availability.canReply { + ActionButton( + title: replyWithQuote + ? L10n.Chats.Chats.Message.Action.reply + : L10n.Chats.Chats.Message.Action.mention, + icon: "arrowshape.turn.up.left", + action: { performAction(.reply) } + ) } - @ViewBuilder - private var buttons: some View { - if availability.canReply { - ActionButton( - title: replyWithQuote - ? L10n.Chats.Chats.Message.Action.reply - : L10n.Chats.Chats.Message.Action.mention, - icon: "arrowshape.turn.up.left", - action: { performAction(.reply) } - ) - } - - if availability.canSendDM { - ActionButton( - title: L10n.Chats.Chats.Message.Action.sendDM, - icon: "bubble.left.and.bubble.right", - action: { performAction(.sendDM) } - ) - } - - ActionButton( - title: L10n.Chats.Chats.Message.Action.copy, - icon: "doc.on.doc", - action: { performAction(.copy) } - ) - - if availability.canSendAgain { - ActionButton( - title: L10n.Chats.Chats.Message.Action.sendAgain, - icon: "arrow.uturn.forward", - action: { performAction(.sendAgain) } - ) - } + if availability.canSendDM { + ActionButton( + title: L10n.Chats.Chats.Message.Action.sendDM, + icon: "bubble.left.and.bubble.right", + action: { performAction(.sendDM) } + ) } - private var details: some View { - VStack(alignment: .leading, spacing: 0) { - Text(L10n.Chats.Chats.Message.Action.details) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 4) - - if message.isFromSelf { - outgoingRows - } else { - incomingRows - } - } - } + ActionButton( + title: L10n.Chats.Chats.Message.Action.copy, + icon: "doc.on.doc", + action: { performAction(.copy) } + ) - @ViewBuilder - private var incomingRows: some View { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( - message.date.formatted(date: .abbreviated, time: .standard))) - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.received( - message.createdAt.formatted(date: .abbreviated, time: .standard))) - ActionInfoRow(text: message.localizedStatusText) + if availability.canSendAgain { + ActionButton( + title: L10n.Chats.Chats.Message.Action.sendAgain, + icon: "arrow.uturn.forward", + action: { performAction(.sendAgain) } + ) } - - @ViewBuilder - private var outgoingRows: some View { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( - message.date.formatted(date: .abbreviated, time: .standard))) - ActionInfoRow(text: message.localizedStatusText) - - if let roundTripTime = message.roundTripTime { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.roundTrip(Int(roundTripTime))) - } + } + + private var details: some View { + VStack(alignment: .leading, spacing: 0) { + Text(L10n.Chats.Chats.Message.Action.details) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 4) + + if message.isFromSelf { + outgoingRows + } else { + incomingRows + } } + } + + @ViewBuilder + private var incomingRows: some View { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( + message.date.formatted(date: .abbreviated, time: .standard) + )) + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.received( + message.createdAt.formatted(date: .abbreviated, time: .standard) + )) + ActionInfoRow(text: message.localizedStatusText) + } + + @ViewBuilder + private var outgoingRows: some View { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( + message.date.formatted(date: .abbreviated, time: .standard) + )) + ActionInfoRow(text: message.localizedStatusText) + + if let roundTripTime = message.roundTripTime { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.roundTrip(Int(roundTripTime))) + } + } } #Preview("Incoming") { - let message = RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x55, count: 4), - authorName: "Alice", - text: "Anyone near the north trailhead? Roads are washed out past the bridge.", - timestamp: UInt32(Date().timeIntervalSince1970) - ) - let session = RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - role: .roomServer, - isConnected: true, - permissionLevel: .readWrite + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x55, count: 4), + authorName: "Alice", + text: "Anyone near the north trailhead? Roads are washed out past the bridge.", + timestamp: UInt32(Date().timeIntervalSince1970) + ) + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .readWrite + ) + return Color.clear.sheet(isPresented: .constant(true)) { + RoomMessageActionsSheet( + message: message, + availability: RoomMessageActionAvailability(message: message, session: session), + onAction: { _ in } ) - return Color.clear.sheet(isPresented: .constant(true)) { - RoomMessageActionsSheet( - message: message, - availability: RoomMessageActionAvailability(message: message, session: session), - onAction: { _ in } - ) - } + } } #Preview("Outgoing") { - let message = RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x42, count: 4), - authorName: "Me", - text: "Copy that, heading your way.", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: true, - status: .delivered, - roundTripTime: 842 + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x42, count: 4), + authorName: "Me", + text: "Copy that, heading your way.", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: true, + status: .delivered, + roundTripTime: 842 + ) + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .readWrite + ) + return Color.clear.sheet(isPresented: .constant(true)) { + RoomMessageActionsSheet( + message: message, + availability: RoomMessageActionAvailability(message: message, session: session), + onAction: { _ in } ) - let session = RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - role: .roomServer, - isConnected: true, - permissionLevel: .readWrite - ) - return Color.clear.sheet(isPresented: .constant(true)) { - RoomMessageActionsSheet( - message: message, - availability: RoomMessageActionAvailability(message: message, session: session), - onAction: { _ in } - ) - } + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift index 76ede30f..8cd13c97 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift @@ -1,235 +1,237 @@ -import SwiftUI import MC1Services +import SwiftUI /// Message bubble for room server messages struct RoomMessageBubble: View { - let message: RoomMessageDTO - let showTimestamp: Bool - var onRetry: (() -> Void)? - var onLongPress: ((RoomMessageDTO) -> Void)? + let message: RoomMessageDTO + let showTimestamp: Bool + var onRetry: (() -> Void)? + var onLongPress: ((RoomMessageDTO) -> Void)? - @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @State private var isLongPressing = false - @State private var longPressTrigger = 0 + @State private var isLongPressing = false + @State private var longPressTrigger = 0 - private var isFromSelf: Bool { message.isFromSelf } + private var isFromSelf: Bool { + message.isFromSelf + } - var body: some View { - VStack(spacing: 4) { - if showTimestamp { - makeTimestampView() - } + var body: some View { + VStack(spacing: 4) { + if showTimestamp { + makeTimestampView() + } - HStack(alignment: .bottom, spacing: 8) { - if isFromSelf { - Spacer(minLength: 60) - } + HStack(alignment: .bottom, spacing: 8) { + if isFromSelf { + Spacer(minLength: 60) + } - VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 2) { - makeBubbleContent() - makeStatusIndicator() - } + VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 2) { + makeBubbleContent() + makeStatusIndicator() + } - if !isFromSelf { - Spacer(minLength: 60) - } - } - .padding(.horizontal) + if !isFromSelf { + Spacer(minLength: 60) } + } + .padding(.horizontal) } + } - // MARK: - Subviews + // MARK: - Subviews - private func makeTimestampView() -> some View { - TimestampView(date: message.date) - } - - private func makeBubbleContent() -> some View { - BubbleContent( - message: message, - isFromSelf: isFromSelf, - highContrast: colorSchemeContrast == .increased - ) - .messageBubbleLongPressGesture( - isPressing: $isLongPressing, - trigger: $longPressTrigger, - onFire: { onLongPress?(message) } - ) - .messageBubbleLongPressEffect(isPressing: isLongPressing, trigger: longPressTrigger) - } + private func makeTimestampView() -> some View { + TimestampView(date: message.date) + } - private func makeStatusIndicator() -> some View { - StatusIndicator( - message: message, - isFromSelf: isFromSelf, - statusText: message.localizedStatusText, - accessibilityStatusLabel: message.accessibilityStatusLabel, - onRetry: onRetry - ) - } + private func makeBubbleContent() -> some View { + BubbleContent( + message: message, + isFromSelf: isFromSelf, + highContrast: colorSchemeContrast == .increased + ) + .messageBubbleLongPressGesture( + isPressing: $isLongPressing, + trigger: $longPressTrigger, + onFire: { onLongPress?(message) } + ) + .messageBubbleLongPressEffect(isPressing: isLongPressing, trigger: longPressTrigger) + } + + private func makeStatusIndicator() -> some View { + StatusIndicator( + message: message, + isFromSelf: isFromSelf, + statusText: message.localizedStatusText, + accessibilityStatusLabel: message.accessibilityStatusLabel, + onRetry: onRetry + ) + } } // MARK: - Timestamp View private struct TimestampView: View { - let date: Date - - var body: some View { - Text(date, format: .dateTime.month().day().hour().minute()) - .font(.caption2) - .foregroundStyle(.secondary) - .padding(.vertical, 8) - } + let date: Date + + var body: some View { + Text(date, format: .dateTime.month().day().hour().minute()) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + } } // MARK: - Bubble Content private struct BubbleContent: View { - let message: RoomMessageDTO - let isFromSelf: Bool - let highContrast: Bool - - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - private var bubbleBackground: Color { - if isFromSelf { - if message.status == .failed { - return AppColors.Message.outgoingBubbleFailed(highContrast: highContrast) - } - return theme.accentColor // matches UnifiedMessageBubble.resolvedBubbleColor - } - return theme.incomingBubbleColor + let message: RoomMessageDTO + let isFromSelf: Bool + let highContrast: Bool + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + private var bubbleBackground: Color { + if isFromSelf { + if message.status == .failed { + return AppColors.Message.outgoingBubbleFailed(highContrast: highContrast) + } + return theme.accentColor // matches UnifiedMessageBubble.resolvedBubbleColor } - - private var textColor: Color { - isFromSelf ? theme.outgoingTextColor : .primary - } - - var body: some View { - VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 4) { - if !isFromSelf { - Text(message.authorDisplayName) - .font(.footnote) - .bold() - .foregroundStyle(theme.identityColor( - forName: message.authorDisplayName, - colorScheme: colorScheme, - contrast: highContrast ? .increased : .standard - )) - .padding(.horizontal, 12) - } - - MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(bubbleBackground) - .clipShape(.rect(cornerRadius: 16, style: .continuous)) - } + return theme.incomingBubbleColor + } + + private var textColor: Color { + isFromSelf ? theme.outgoingTextColor : .primary + } + + var body: some View { + VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 4) { + if !isFromSelf { + Text(message.authorDisplayName) + .font(.footnote) + .bold() + .foregroundStyle(theme.identityColor( + forName: message.authorDisplayName, + colorScheme: colorScheme, + contrast: highContrast ? .increased : .standard + )) + .padding(.horizontal, 12) + } + + MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(bubbleBackground) + .clipShape(.rect(cornerRadius: 16, style: .continuous)) } + } } // MARK: - Status Indicator private struct StatusIndicator: View { - let message: RoomMessageDTO - let isFromSelf: Bool - let statusText: String - let accessibilityStatusLabel: String - let onRetry: (() -> Void)? - - var body: some View { - if isFromSelf { - HStack(spacing: 4) { - if message.status == .failed, let onRetry { - Button { - onRetry() - } label: { - HStack(spacing: 2) { - Image(systemName: "arrow.clockwise") - Text(L10n.Chats.Chats.Message.Status.retry) - } - .font(.caption2) - .foregroundStyle(.blue) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Chats.Chats.Message.Status.retry) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Room.Message.retryHint) - } - - Text(statusText) - .font(.caption2) - .foregroundStyle(.secondary) - - if message.status == .failed { - Image(systemName: "exclamationmark.circle") - .font(.caption2) - .foregroundStyle(.red) - } + let message: RoomMessageDTO + let isFromSelf: Bool + let statusText: String + let accessibilityStatusLabel: String + let onRetry: (() -> Void)? + + var body: some View { + if isFromSelf { + HStack(spacing: 4) { + if message.status == .failed, let onRetry { + Button { + onRetry() + } label: { + HStack(spacing: 2) { + Image(systemName: "arrow.clockwise") + Text(L10n.Chats.Chats.Message.Status.retry) } - .padding(.trailing, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityStatusLabel) + .font(.caption2) + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Chats.Chats.Message.Status.retry) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Room.Message.retryHint) + } + + Text(statusText) + .font(.caption2) + .foregroundStyle(.secondary) + + if message.status == .failed { + Image(systemName: "exclamationmark.circle") + .font(.caption2) + .foregroundStyle(.red) } + } + .padding(.trailing, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityStatusLabel) } + } } #Preview("Self Message") { - RoomMessageBubble( - message: RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x42, count: 4), - authorName: "Me", - text: "Hello from me!", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: true - ), - showTimestamp: true - ) + RoomMessageBubble( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x42, count: 4), + authorName: "Me", + text: "Hello from me!", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: true + ), + showTimestamp: true + ) } #Preview("Other Message") { - RoomMessageBubble( - message: RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x55, count: 4), - authorName: "Alice", - text: "Hello from Alice!", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: false - ), - showTimestamp: true - ) + RoomMessageBubble( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x55, count: 4), + authorName: "Alice", + text: "Hello from Alice!", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: false + ), + showTimestamp: true + ) } #Preview("Pending Message") { - RoomMessageBubble( - message: RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x42, count: 4), - authorName: "Me", - text: "Sending...", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: true, - status: .pending - ), - showTimestamp: true - ) + RoomMessageBubble( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x42, count: 4), + authorName: "Me", + text: "Sending...", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: true, + status: .pending + ), + showTimestamp: true + ) } #Preview("Failed Message") { - RoomMessageBubble( - message: RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x42, count: 4), - authorName: "Me", - text: "This failed to send", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: true, - status: .failed - ), - showTimestamp: true, - onRetry: { print("Retry tapped") } - ) + RoomMessageBubble( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x42, count: 4), + authorName: "Me", + text: "This failed to send", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: true, + status: .failed + ), + showTimestamp: true, + onRetry: { print("Retry tapped") } + ) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageDTO+Status.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageDTO+Status.swift index 36a20aeb..2d5414ac 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageDTO+Status.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageDTO+Status.swift @@ -3,29 +3,29 @@ import MC1Services /// Localized status text lives in the app target because `L10n` is generated /// here, not in `MC1Services` where `RoomMessageDTO` is declared. extension RoomMessageDTO { - var localizedStatusText: String { - switch status { - case .pending, .sending: - return L10n.Chats.Chats.Message.Status.sending - case .sent: - return L10n.Chats.Chats.Message.Status.sent - case .delivered: - return L10n.Chats.Chats.Message.Status.delivered - case .failed: - return L10n.Chats.Chats.Message.Status.failed - case .retrying: - return L10n.Chats.Chats.Message.Status.retrying - } + var localizedStatusText: String { + switch status { + case .pending, .sending: + L10n.Chats.Chats.Message.Status.sending + case .sent: + L10n.Chats.Chats.Message.Status.sent + case .delivered: + L10n.Chats.Chats.Message.Status.delivered + case .failed: + L10n.Chats.Chats.Message.Status.failed + case .retrying: + L10n.Chats.Chats.Message.Status.retrying } + } - var accessibilityStatusLabel: String { - switch status { - case .failed: - return L10n.RemoteNodes.RemoteNodes.Room.Message.Status.failedLabel - case .pending, .sending, .retrying: - return L10n.RemoteNodes.RemoteNodes.Room.Message.Status.sendingLabel - default: - return L10n.RemoteNodes.RemoteNodes.Room.Message.Status.deliveredLabel - } + var accessibilityStatusLabel: String { + switch status { + case .failed: + L10n.RemoteNodes.RemoteNodes.Room.Message.Status.failedLabel + case .pending, .sending, .retrying: + L10n.RemoteNodes.RemoteNodes.Room.Message.Status.sendingLabel + default: + L10n.RemoteNodes.RemoteNodes.Room.Message.Status.deliveredLabel } + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift b/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift index 4b199213..3d353b16 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift @@ -1,328 +1,328 @@ -import SwiftUI -import MC1Services import CoreLocation +import MC1Services +import SwiftUI struct RoomSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @FocusState private var focusedField: NodeSettingsField? + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @FocusState private var focusedField: NodeSettingsField? - let session: RemoteNodeSessionDTO - @State private var viewModel = RoomSettingsViewModel() - @State private var statusViewModel = RoomStatusViewModel() - @State private var managementTab: NodeManagementTab = .settings - @State private var cliViewModel = NodeCLIViewModel() - @State private var showRebootConfirmation = false - @State private var showingLocationPicker = false - @State private var telemetryConfigured = false + let session: RemoteNodeSessionDTO + @State private var viewModel = RoomSettingsViewModel() + @State private var statusViewModel = RoomStatusViewModel() + @State private var managementTab: NodeManagementTab = .settings + @State private var cliViewModel = NodeCLIViewModel() + @State private var showRebootConfirmation = false + @State private var showingLocationPicker = false + @State private var telemetryConfigured = false - var body: some View { - // ZStack, not Group: a stable container keeps the navigation title hosted on one - // view across segment switches. Group would re-host it on each branch, animating - // a nav-bar item transition. - ZStack { - switch managementTab { - case .settings: settingsForm - case .cli: NodeCLIView(viewModel: cliViewModel) - case .telemetry: - RoomStatusContent( - viewModel: statusViewModel, - session: session, - connectionState: appState.connectionState, - connectedDeviceID: appState.connectedDevice?.radioID - ) - } - } - .animation(nil, value: managementTab) - .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomSettings.title) - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .top, spacing: 0) { - if session.isAdmin { - NodeManagementTabPicker(selection: $managementTab) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) - } - } - .task { - await viewModel.configure( - roomAdminService: { appState.services?.roomAdminService }, - session: session - ) - if let send = viewModel.makeNodeCLISendClosure(session: session) { - cliViewModel.configure(sessionName: session.name, sendRawCommand: send) - } - } - .onChange(of: managementTab) { _, newTab in - guard newTab == .telemetry, !telemetryConfigured else { return } - telemetryConfigured = true - // Configure the status VM on first Telemetry reveal rather than on open: - // its handlers populate only the status/telemetry slots, leaving the settings VM's - // CLI handler intact for the Settings/CLI surface. Guarded by telemetryConfigured - // because a segment switch recreates only the content subtree, so this must not - // re-run or duplicate handler registration. - statusViewModel.configure( - roomAdminService: { appState.services?.roomAdminService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } - ) - Task { - await statusViewModel.registerHandlers() - if let radioID = appState.connectedDevice?.radioID { - await statusViewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) - } - } - } - .onDisappear { - Task { - await statusViewModel.clearStatusHandlers() - await viewModel.cleanup() - } - } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) { } - } message: { - Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) - } - .sheet(isPresented: $showingLocationPicker) { - LocationPickerView( - initialCoordinate: CLLocationCoordinate2D( - latitude: viewModel.helper.latitude ?? 0, - longitude: viewModel.helper.longitude ?? 0 - ) - ) { coordinate in - viewModel.helper.setLocationFromPicker( - latitude: coordinate.latitude, - longitude: coordinate.longitude - ) - } + var body: some View { + // ZStack, not Group: a stable container keeps the navigation title hosted on one + // view across segment switches. Group would re-host it on each branch, animating + // a nav-bar item transition. + ZStack { + switch managementTab { + case .settings: settingsForm + case .cli: NodeCLIView(viewModel: cliViewModel) + case .telemetry: + RoomStatusContent( + viewModel: statusViewModel, + session: session, + connectionState: appState.connectionState, + connectedDeviceID: appState.connectedDevice?.radioID + ) + } + } + .animation(nil, value: managementTab) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomSettings.title) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .top, spacing: 0) { + if session.isAdmin { + NodeManagementTabPicker(selection: $managementTab) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } + } + .task { + await viewModel.configure( + roomAdminService: { appState.services?.roomAdminService }, + session: session + ) + if let send = viewModel.makeNodeCLISendClosure(session: session) { + cliViewModel.configure(sessionName: session.name, sendRawCommand: send) + } + } + .onChange(of: managementTab) { _, newTab in + guard newTab == .telemetry, !telemetryConfigured else { return } + telemetryConfigured = true + // Configure the status VM on first Telemetry reveal rather than on open: + // its handlers populate only the status/telemetry slots, leaving the settings VM's + // CLI handler intact for the Settings/CLI surface. Guarded by telemetryConfigured + // because a segment switch recreates only the content subtree, so this must not + // re-run or duplicate handler registration. + statusViewModel.configure( + roomAdminService: { appState.services?.roomAdminService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService } + ) + Task { + await statusViewModel.registerHandlers() + if let radioID = appState.connectedDevice?.radioID { + await statusViewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) } + } } + .onDisappear { + Task { + await statusViewModel.clearStatusHandlers() + await viewModel.cleanup() + } + } + .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) {} + } message: { + Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) + } + .sheet(isPresented: $showingLocationPicker) { + LocationPickerView( + initialCoordinate: CLLocationCoordinate2D( + latitude: viewModel.helper.latitude ?? 0, + longitude: viewModel.helper.longitude ?? 0 + ) + ) { coordinate in + viewModel.helper.setLocationFromPicker( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + } + } + } - private var settingsForm: some View { - Form { - NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) - RoomAccessSection(viewModel: viewModel, focusedField: $focusedField) - NodeRadioSettingsSection( - settings: viewModel.helper, - focusedField: $focusedField, - radioRestartWarning: L10n.RemoteNodes.RemoteNodes.RoomSettings.radioRestartWarning - ) - RoomBehaviorSection(viewModel: viewModel, focusedField: $focusedField) - RemoteNodeIdentitySection( - settings: viewModel.helper, - focusedField: $focusedField, - onPickLocation: { showingLocationPicker = true } - ) - NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) - NodeSecuritySection(settings: viewModel.helper) - NodeDeviceInfoSection(settings: viewModel.helper) - NodeActionsSection( - settings: viewModel.helper, - showRebootConfirmation: $showRebootConfirmation, - rebootConfirmTitle: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootConfirmTitle, - rebootMessage: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootMessage - ) - } - .themedCanvas(theme) - .nodeManagementHeaderTopMargin() - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { - focusedField = nil - } - } + private var settingsForm: some View { + Form { + NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) + RoomAccessSection(viewModel: viewModel, focusedField: $focusedField) + NodeRadioSettingsSection( + settings: viewModel.helper, + focusedField: $focusedField, + radioRestartWarning: L10n.RemoteNodes.RemoteNodes.RoomSettings.radioRestartWarning + ) + RoomBehaviorSection(viewModel: viewModel, focusedField: $focusedField) + RemoteNodeIdentitySection( + settings: viewModel.helper, + focusedField: $focusedField, + onPickLocation: { showingLocationPicker = true } + ) + NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) + NodeSecuritySection(settings: viewModel.helper) + NodeDeviceInfoSection(settings: viewModel.helper) + NodeActionsSection( + settings: viewModel.helper, + showRebootConfirmation: $showRebootConfirmation, + rebootConfirmTitle: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootConfirmTitle, + rebootMessage: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootMessage + ) + } + .themedCanvas(theme) + .nodeManagementHeaderTopMargin() + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { + focusedField = nil } + } } + } } // MARK: - Room Access Section private struct RoomAccessSection: View { - @Bindable var viewModel: RoomSettingsViewModel - var focusedField: FocusState.Binding + @Bindable var viewModel: RoomSettingsViewModel + var focusedField: FocusState.Binding - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsSection, - icon: "person.badge.key", - isExpanded: $viewModel.isRoomAccessExpanded, - isLoaded: { viewModel.roomAccessLoaded }, - isLoading: $viewModel.isLoadingRoomAccess, - hasError: $viewModel.roomAccessError, - onLoad: { await viewModel.fetchRoomAccess() }, - footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsFooter - ) { - SecureField(L10n.RemoteNodes.RemoteNodes.RoomSettings.guestPassword, text: Binding( - get: { viewModel.guestPassword ?? "" }, - set: { viewModel.guestPassword = $0 } - )) - .focused(focusedField, equals: .guestPassword) - .disabled(viewModel.guestPassword == nil) - .overlay(alignment: .trailing) { - if viewModel.guestPassword == nil { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingRoomAccess, hasError: viewModel.roomAccessError) - .padding(.trailing, 8) - } - } + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsSection, + icon: "person.badge.key", + isExpanded: $viewModel.isRoomAccessExpanded, + isLoaded: { viewModel.roomAccessLoaded }, + isLoading: $viewModel.isLoadingRoomAccess, + hasError: $viewModel.roomAccessError, + onLoad: { await viewModel.fetchRoomAccess() }, + footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsFooter + ) { + SecureField(L10n.RemoteNodes.RemoteNodes.RoomSettings.guestPassword, text: Binding( + get: { viewModel.guestPassword ?? "" }, + set: { viewModel.guestPassword = $0 } + )) + .focused(focusedField, equals: .guestPassword) + .disabled(viewModel.guestPassword == nil) + .overlay(alignment: .trailing) { + if viewModel.guestPassword == nil { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingRoomAccess, hasError: viewModel.roomAccessError) + .padding(.trailing, 8) + } + } - Toggle(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnly, isOn: Binding( - get: { viewModel.allowReadOnly ?? false }, - set: { viewModel.allowReadOnly = $0 } - )) - .disabled(viewModel.allowReadOnly == nil) - .accessibilityValue( - viewModel.allowReadOnly == nil - ? (viewModel.isLoadingRoomAccess ? L10n.RemoteNodes.RemoteNodes.Settings.loading : L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad) - : (viewModel.allowReadOnly == true ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) - ) - .overlay(alignment: .trailing) { - if viewModel.allowReadOnly == nil { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingRoomAccess, hasError: viewModel.roomAccessError) - .padding(.trailing, 60) - .accessibilityHidden(true) - } - } + Toggle(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnly, isOn: Binding( + get: { viewModel.allowReadOnly ?? false }, + set: { viewModel.allowReadOnly = $0 } + )) + .disabled(viewModel.allowReadOnly == nil) + .accessibilityValue( + viewModel.allowReadOnly == nil + ? (viewModel.isLoadingRoomAccess ? L10n.RemoteNodes.RemoteNodes.Settings.loading : L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad) + : (viewModel.allowReadOnly == true ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) + ) + .overlay(alignment: .trailing) { + if viewModel.allowReadOnly == nil { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingRoomAccess, hasError: viewModel.roomAccessError) + .padding(.trailing, 60) + .accessibilityHidden(true) + } + } - Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnlyFooter) - .font(.caption) - .foregroundStyle(.secondary) + Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnlyFooter) + .font(.caption) + .foregroundStyle(.secondary) - Button { - Task { await viewModel.applyRoomAccess() } - } label: { - AsyncActionLabel(isLoading: viewModel.isApplyingRoomAccess, showSuccess: viewModel.roomAccessApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.applyRoomSettings) - .foregroundStyle(viewModel.roomAccessModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .disabled(viewModel.isApplyingRoomAccess || viewModel.roomAccessApplySuccess || !viewModel.roomAccessModified) + Button { + Task { await viewModel.applyRoomAccess() } + } label: { + AsyncActionLabel(isLoading: viewModel.isApplyingRoomAccess, showSuccess: viewModel.roomAccessApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.applyRoomSettings) + .foregroundStyle(viewModel.roomAccessModified ? Color.accentColor : .secondary) + .transition(.opacity) } + } + .disabled(viewModel.isApplyingRoomAccess || viewModel.roomAccessApplySuccess || !viewModel.roomAccessModified) } + } } // MARK: - Room Behavior Section private struct RoomBehaviorSection: View { - @Bindable var viewModel: RoomSettingsViewModel - var focusedField: FocusState.Binding + @Bindable var viewModel: RoomSettingsViewModel + var focusedField: FocusState.Binding - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.behavior, - icon: "slider.horizontal.3", - isExpanded: $viewModel.isBehaviorExpanded, - isLoaded: { viewModel.behaviorLoaded }, - isLoading: $viewModel.isLoadingBehavior, - hasError: $viewModel.behaviorError, - onLoad: { await viewModel.fetchBehaviorSettings() }, - footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.behaviorFooter - ) { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) - Spacer() - if let interval = viewModel.advertIntervalMinutes { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( - get: { interval }, - set: { viewModel.advertIntervalMinutes = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .advertInterval) - Text(L10n.RemoteNodes.RemoteNodes.Settings.min) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.behavior, + icon: "slider.horizontal.3", + isExpanded: $viewModel.isBehaviorExpanded, + isLoaded: { viewModel.behaviorLoaded }, + isLoading: $viewModel.isLoadingBehavior, + hasError: $viewModel.behaviorError, + onLoad: { await viewModel.fetchBehaviorSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.behaviorFooter + ) { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) + Spacer() + if let interval = viewModel.advertIntervalMinutes { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( + get: { interval }, + set: { viewModel.advertIntervalMinutes = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .advertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.min) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } - if let error = viewModel.advertIntervalError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } + if let error = viewModel.advertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) - Spacer() - if let interval = viewModel.floodAdvertIntervalHours { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( - get: { interval }, - set: { viewModel.floodAdvertIntervalHours = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .floodAdvertInterval) - Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) + Spacer() + if let interval = viewModel.floodAdvertIntervalHours { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( + get: { interval }, + set: { viewModel.floodAdvertIntervalHours = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodAdvertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } - if let error = viewModel.floodAdvertIntervalError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } + if let error = viewModel.floodAdvertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) - Spacer() - if let hops = viewModel.floodMaxHops { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( - get: { hops }, - set: { viewModel.floodMaxHops = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .floodMaxHops) - Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) + Spacer() + if let hops = viewModel.floodMaxHops { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( + get: { hops }, + set: { viewModel.floodMaxHops = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodMaxHops) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } - if let error = viewModel.floodMaxHopsError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } + if let error = viewModel.floodMaxHopsError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } - Button { - Task { await viewModel.applyBehaviorSettings() } - } label: { - AsyncActionLabel(isLoading: viewModel.isApplyingBehavior, showSuccess: viewModel.behaviorApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyBehaviorSettings) - .foregroundStyle(viewModel.behaviorModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .disabled(viewModel.isApplyingBehavior || viewModel.behaviorApplySuccess || !viewModel.behaviorModified) + Button { + Task { await viewModel.applyBehaviorSettings() } + } label: { + AsyncActionLabel(isLoading: viewModel.isApplyingBehavior, showSuccess: viewModel.behaviorApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyBehaviorSettings) + .foregroundStyle(viewModel.behaviorModified ? Color.accentColor : .secondary) + .transition(.opacity) } + } + .disabled(viewModel.isApplyingBehavior || viewModel.behaviorApplySuccess || !viewModel.behaviorModified) } + } } #Preview { - NavigationStack { - RoomSettingsView( - session: RemoteNodeSessionDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Community Room", - role: .roomServer, - latitude: 37.7749, - longitude: -122.4194, - isConnected: true, - permissionLevel: .admin - ) - ) - .environment(\.appState, AppState()) - } + NavigationStack { + RoomSettingsView( + session: RemoteNodeSessionDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Community Room", + role: .roomServer, + latitude: 37.7749, + longitude: -122.4194, + isConnected: true, + permissionLevel: .admin + ) + ) + .environment(\.appState, AppState()) + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomSettingsViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomSettingsViewModel.swift index 3e3f4091..b3414a83 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomSettingsViewModel.swift @@ -1,340 +1,346 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI @Observable @MainActor final class RoomSettingsViewModel { - - // MARK: - Shared Helper - - var helper = NodeSettingsViewModel() - - // MARK: - Room Access (guest password + read-only) - - var guestPassword: String? - var allowReadOnly: Bool? - private var originalGuestPassword: String? - private var originalAllowReadOnly: Bool? - var isLoadingRoomAccess = false - var roomAccessError = false - var isApplyingRoomAccess = false - var roomAccessApplySuccess = false - var isRoomAccessExpanded = false - - var roomAccessLoaded: Bool { guestPassword != nil || allowReadOnly != nil } - - var roomAccessModified: Bool { - (guestPassword != nil && guestPassword != originalGuestPassword) || - (allowReadOnly != nil && allowReadOnly != originalAllowReadOnly) + // MARK: - Shared Helper + + var helper = NodeSettingsViewModel() + + // MARK: - Room Access (guest password + read-only) + + var guestPassword: String? + var allowReadOnly: Bool? + private var originalGuestPassword: String? + private var originalAllowReadOnly: Bool? + var isLoadingRoomAccess = false + var roomAccessError = false + var isApplyingRoomAccess = false + var roomAccessApplySuccess = false + var isRoomAccessExpanded = false + + var roomAccessLoaded: Bool { + guestPassword != nil || allowReadOnly != nil + } + + var roomAccessModified: Bool { + (guestPassword != nil && guestPassword != originalGuestPassword) || + (allowReadOnly != nil && allowReadOnly != originalAllowReadOnly) + } + + // MARK: - Behavior (advert intervals + flood) + + var advertIntervalMinutes: Int? + var floodAdvertIntervalHours: Int? + var floodMaxHops: Int? + private var originalAdvertIntervalMinutes: Int? + private var originalFloodAdvertIntervalHours: Int? + private var originalFloodMaxHops: Int? + var isLoadingBehavior = false + var behaviorError = false + var isApplyingBehavior = false + var behaviorApplySuccess = false + var isBehaviorExpanded = false + + var advertIntervalError: String? + var floodAdvertIntervalError: String? + var floodMaxHopsError: String? + + var behaviorLoaded: Bool { + advertIntervalMinutes != nil || floodAdvertIntervalHours != nil || floodMaxHops != nil + } + + var behaviorModified: Bool { + (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || + (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || + (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + } + + // MARK: - Dependencies + + private var roomAdminServiceProvider: @MainActor () -> RoomAdminService? = { nil } + var roomAdminService: RoomAdminService? { + roomAdminServiceProvider() + } + + private let logger = Logger(subsystem: "com.mc1", category: "RoomSettings") + + // MARK: - Cleanup + + func cleanup() async { + await roomAdminService?.setCLIHandler { _, _ in } + helper.cleanup() + } + + // MARK: - Configuration + + /// Nil service mirrors a disconnected state; commands then no-op. + func configure(roomAdminService: @escaping @MainActor () -> RoomAdminService?, session: RemoteNodeSessionDTO) async { + roomAdminServiceProvider = roomAdminService + + guard let roomAdminService = roomAdminService() else { return } + + helper.configure( + session: session, + sendCommand: { [roomAdminService] id, cmd, timeout in + try await roomAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) + }, + sendRawCommand: { [roomAdminService] id, cmd, timeout in + try await roomAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) + } + ) + + helper.setNodeInfo(firmwareVersion: nil, name: session.name, ownerInfo: nil) + + // Room doesn't have binary protocol for node info — firmware fetched via CLI + helper.onPreFetchNodeInfo = nil + + // Register CLI handler for late responses + await roomAdminService.setCLIHandler { [weak self] message, _ in + await MainActor.run { + self?.handleLateResponse(message.text) + } } - // MARK: - Behavior (advert intervals + flood) - - var advertIntervalMinutes: Int? - var floodAdvertIntervalHours: Int? - var floodMaxHops: Int? - private var originalAdvertIntervalMinutes: Int? - private var originalFloodAdvertIntervalHours: Int? - private var originalFloodMaxHops: Int? - var isLoadingBehavior = false - var behaviorError = false - var isApplyingBehavior = false - var behaviorApplySuccess = false - var isBehaviorExpanded = false - - var advertIntervalError: String? - var floodAdvertIntervalError: String? - var floodMaxHopsError: String? - - var behaviorLoaded: Bool { advertIntervalMinutes != nil || floodAdvertIntervalHours != nil || floodMaxHops != nil } - - var behaviorModified: Bool { - (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || - (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || - (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + Task { await helper.fetchDeviceInfo() } + } + + /// Builds the node-CLI send closure, pre-binding this session's id and + /// capturing the private admin service (a thin pass-through to + /// `RemoteNodeService.sendRawCLICommand`). Returns nil if not configured. + func makeNodeCLISendClosure( + session: RemoteNodeSessionDTO + ) -> (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? { + guard let roomAdminService else { return nil } + return { [roomAdminService, sessionID = session.id] command, timeout in + try await roomAdminService.sendRawCommand( + sessionID: sessionID, command: command, timeout: timeout + ) } - - // MARK: - Dependencies - - private var roomAdminServiceProvider: @MainActor () -> RoomAdminService? = { nil } - var roomAdminService: RoomAdminService? { roomAdminServiceProvider() } - private let logger = Logger(subsystem: "com.mc1", category: "RoomSettings") - - // MARK: - Cleanup - - func cleanup() async { - await roomAdminService?.setCLIHandler { _, _ in } - helper.cleanup() + } + + // MARK: - Late Response Handling + + private func handleLateResponse(_ response: String) { + // Try shared sections first + if helper.handleCommonLateResponse(response) { return } + + // Behavior settings + if !isLoadingBehavior, behaviorError { + if let result = NodeSettingsResponseParser.behaviorLateResponse( + response, + hasAdvertInterval: originalAdvertIntervalMinutes != nil, + hasFloodInterval: originalFloodAdvertIntervalHours != nil, + hasFloodMaxHops: originalFloodMaxHops != nil + ) { + switch result { + case let .advertInterval(interval): + advertIntervalMinutes = interval + originalAdvertIntervalMinutes = interval + case let .floodAdvertInterval(interval): + floodAdvertIntervalHours = interval + originalFloodAdvertIntervalHours = interval + case let .floodMax(hops): + floodMaxHops = hops + originalFloodMaxHops = hops + } + behaviorError = false + return + } + } + } + + // MARK: - Room Access Fetch/Apply + + func fetchRoomAccess() async { + isLoadingRoomAccess = true + roomAccessError = false + + do { + let response = try await helper.sendAndWait("get guest.password", rawMatching: true) + let parsed = CLIResponse.parse(response, forQuery: "get guest.password") + switch parsed { + case .ok, .error, .unknownCommand: + guestPassword = "" + originalGuestPassword = "" + default: + let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) + let value = trimmed.hasPrefix("> ") ? String(trimmed.dropFirst(2)) : trimmed + guestPassword = value + originalGuestPassword = value + } + } catch { + if case RemoteNodeError.timeout = error { roomAccessError = true } + logger.warning("Failed to get guest password: \(error)") } - // MARK: - Configuration - - /// Nil service mirrors a disconnected state; commands then no-op. - func configure(roomAdminService: @escaping @MainActor () -> RoomAdminService?, session: RemoteNodeSessionDTO) async { - self.roomAdminServiceProvider = roomAdminService + do { + let response = try await helper.sendAndWait("get allow.read.only", rawMatching: true) + let parsed = CLIResponse.parse(response, forQuery: "get allow.read.only") + switch parsed { + case let .raw(value): + let isOn = value.lowercased() == "on" + allowReadOnly = isOn + originalAllowReadOnly = isOn + default: + break + } + } catch { + if case RemoteNodeError.timeout = error { roomAccessError = true } + logger.warning("Failed to get allow read only: \(error)") + } - guard let roomAdminService = roomAdminService() else { return } + isLoadingRoomAccess = false + } - helper.configure( - session: session, - sendCommand: { [roomAdminService] id, cmd, timeout in - try await roomAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) - }, - sendRawCommand: { [roomAdminService] id, cmd, timeout in - try await roomAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) - } - ) + func applyRoomAccess() async { + isApplyingRoomAccess = true + helper.errorMessage = nil - helper.setNodeInfo(firmwareVersion: nil, name: session.name, ownerInfo: nil) + do { + var allSucceeded = true - // Room doesn't have binary protocol for node info — firmware fetched via CLI - helper.onPreFetchNodeInfo = nil - - // Register CLI handler for late responses - await roomAdminService.setCLIHandler { [weak self] message, _ in - await MainActor.run { - self?.handleLateResponse(message.text) - } + if let guestPassword, guestPassword != originalGuestPassword { + let response = try await helper.sendAndWait("set guest.password \(guestPassword)") + if case .ok = CLIResponse.parse(response) { + originalGuestPassword = guestPassword + } else { + allSucceeded = false + } + } + + if let allowReadOnly, allowReadOnly != originalAllowReadOnly { + let response = try await helper.sendAndWait("set allow.read.only \(allowReadOnly ? "on" : "off")") + if case .ok = CLIResponse.parse(response) { + originalAllowReadOnly = allowReadOnly + } else { + allSucceeded = false } + } - Task { await helper.fetchDeviceInfo() } + if allSucceeded { + await helper.flashSuccess( + setApplying: { isApplyingRoomAccess = $0 }, + setSuccess: { roomAccessApplySuccess = $0 } + ) + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + helper.errorMessage = error.userFacingMessage } - /// Builds the node-CLI send closure, pre-binding this session's id and - /// capturing the private admin service (a thin pass-through to - /// `RemoteNodeService.sendRawCLICommand`). Returns nil if not configured. - func makeNodeCLISendClosure( - session: RemoteNodeSessionDTO - ) -> (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? { - guard let roomAdminService else { return nil } - return { [roomAdminService, sessionID = session.id] command, timeout in - try await roomAdminService.sendRawCommand( - sessionID: sessionID, command: command, timeout: timeout) - } + isApplyingRoomAccess = false + } + + // MARK: - Behavior Fetch/Apply + + func fetchBehaviorSettings() async { + isLoadingBehavior = true + behaviorError = false + var hadTimeout = false + + do { + let response = try await helper.sendAndWait("get advert.interval") + if case let .advertInterval(minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { + advertIntervalMinutes = minutes + originalAdvertIntervalMinutes = minutes + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get advert interval: \(error)") } - // MARK: - Late Response Handling - - private func handleLateResponse(_ response: String) { - // Try shared sections first - if helper.handleCommonLateResponse(response) { return } - - // Behavior settings - if !isLoadingBehavior && behaviorError { - if let result = NodeSettingsResponseParser.behaviorLateResponse( - response, - hasAdvertInterval: originalAdvertIntervalMinutes != nil, - hasFloodInterval: originalFloodAdvertIntervalHours != nil, - hasFloodMaxHops: originalFloodMaxHops != nil - ) { - switch result { - case .advertInterval(let interval): - self.advertIntervalMinutes = interval - self.originalAdvertIntervalMinutes = interval - case .floodAdvertInterval(let interval): - self.floodAdvertIntervalHours = interval - self.originalFloodAdvertIntervalHours = interval - case .floodMax(let hops): - self.floodMaxHops = hops - self.originalFloodMaxHops = hops - } - self.behaviorError = false - return - } - } + do { + let response = try await helper.sendAndWait("get flood.advert.interval") + if case let .floodAdvertInterval(hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + floodAdvertIntervalHours = hours + originalFloodAdvertIntervalHours = hours + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood advert interval: \(error)") } - // MARK: - Room Access Fetch/Apply - - func fetchRoomAccess() async { - isLoadingRoomAccess = true - roomAccessError = false - - do { - let response = try await helper.sendAndWait("get guest.password", rawMatching: true) - let parsed = CLIResponse.parse(response, forQuery: "get guest.password") - switch parsed { - case .ok, .error, .unknownCommand: - self.guestPassword = "" - self.originalGuestPassword = "" - default: - let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) - let value = trimmed.hasPrefix("> ") ? String(trimmed.dropFirst(2)) : trimmed - self.guestPassword = value - self.originalGuestPassword = value - } - } catch { - if case RemoteNodeError.timeout = error { roomAccessError = true } - logger.warning("Failed to get guest password: \(error)") - } - - do { - let response = try await helper.sendAndWait("get allow.read.only", rawMatching: true) - let parsed = CLIResponse.parse(response, forQuery: "get allow.read.only") - switch parsed { - case .raw(let value): - let isOn = value.lowercased() == "on" - self.allowReadOnly = isOn - self.originalAllowReadOnly = isOn - default: - break - } - } catch { - if case RemoteNodeError.timeout = error { roomAccessError = true } - logger.warning("Failed to get allow read only: \(error)") - } - - isLoadingRoomAccess = false + do { + let response = try await helper.sendAndWait("get flood.max") + if case let .floodMax(hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + floodMaxHops = hops + originalFloodMaxHops = hops + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood max: \(error)") } - func applyRoomAccess() async { - isApplyingRoomAccess = true - helper.errorMessage = nil - - do { - var allSucceeded = true - - if let guestPassword, guestPassword != originalGuestPassword { - let response = try await helper.sendAndWait("set guest.password \(guestPassword)") - if case .ok = CLIResponse.parse(response) { - originalGuestPassword = guestPassword - } else { - allSucceeded = false - } - } - - if let allowReadOnly, allowReadOnly != originalAllowReadOnly { - let response = try await helper.sendAndWait("set allow.read.only \(allowReadOnly ? "on" : "off")") - if case .ok = CLIResponse.parse(response) { - originalAllowReadOnly = allowReadOnly - } else { - allSucceeded = false - } - } - - if allSucceeded { - await helper.flashSuccess( - setApplying: { isApplyingRoomAccess = $0 }, - setSuccess: { roomAccessApplySuccess = $0 } - ) - return - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - helper.errorMessage = error.userFacingMessage - } - - isApplyingRoomAccess = false + if hadTimeout { + behaviorError = true } - // MARK: - Behavior Fetch/Apply - - func fetchBehaviorSettings() async { - isLoadingBehavior = true - behaviorError = false - var hadTimeout = false - - do { - let response = try await helper.sendAndWait("get advert.interval") - if case .advertInterval(let minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { - self.advertIntervalMinutes = minutes - self.originalAdvertIntervalMinutes = minutes - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get advert interval: \(error)") + isLoadingBehavior = false + } + + func applyBehaviorSettings() async { + let validation = NodeSettingsViewModel.validateBehaviorFields( + advertInterval: advertIntervalMinutes, + floodInterval: floodAdvertIntervalHours, + floodMaxHops: floodMaxHops + ) + advertIntervalError = validation.advertInterval + floodAdvertIntervalError = validation.floodInterval + floodMaxHopsError = validation.floodMaxHops + + if validation.hasErrors { return } + + isApplyingBehavior = true + helper.errorMessage = nil + + do { + var allSucceeded = true + + if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { + let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") + if case .ok = CLIResponse.parse(response) { + originalAdvertIntervalMinutes = advertIntervalMinutes + } else { + allSucceeded = false } - - do { - let response = try await helper.sendAndWait("get flood.advert.interval") - if case .floodAdvertInterval(let hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { - self.floodAdvertIntervalHours = hours - self.originalFloodAdvertIntervalHours = hours - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get flood advert interval: \(error)") + } + + if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { + let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") + if case .ok = CLIResponse.parse(response) { + originalFloodAdvertIntervalHours = floodAdvertIntervalHours + } else { + allSucceeded = false } - - do { - let response = try await helper.sendAndWait("get flood.max") - if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { - self.floodMaxHops = hops - self.originalFloodMaxHops = hops - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get flood max: \(error)") - } - - if hadTimeout { - behaviorError = true + } + + if let floodMaxHops, floodMaxHops != originalFloodMaxHops { + let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") + if case .ok = CLIResponse.parse(response) { + originalFloodMaxHops = floodMaxHops + } else { + allSucceeded = false } + } - isLoadingBehavior = false - } - - func applyBehaviorSettings() async { - let validation = NodeSettingsViewModel.validateBehaviorFields( - advertInterval: advertIntervalMinutes, - floodInterval: floodAdvertIntervalHours, - floodMaxHops: floodMaxHops + if allSucceeded { + await helper.flashSuccess( + setApplying: { isApplyingBehavior = $0 }, + setSuccess: { behaviorApplySuccess = $0 } ) - advertIntervalError = validation.advertInterval - floodAdvertIntervalError = validation.floodInterval - floodMaxHopsError = validation.floodMaxHops - - if validation.hasErrors { return } - - isApplyingBehavior = true - helper.errorMessage = nil - - do { - var allSucceeded = true - - if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { - let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") - if case .ok = CLIResponse.parse(response) { - originalAdvertIntervalMinutes = advertIntervalMinutes - } else { - allSucceeded = false - } - } - - if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { - let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") - if case .ok = CLIResponse.parse(response) { - originalFloodAdvertIntervalHours = floodAdvertIntervalHours - } else { - allSucceeded = false - } - } - - if let floodMaxHops, floodMaxHops != originalFloodMaxHops { - let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") - if case .ok = CLIResponse.parse(response) { - originalFloodMaxHops = floodMaxHops - } else { - allSucceeded = false - } - } - - if allSucceeded { - await helper.flashSuccess( - setApplying: { isApplyingBehavior = $0 }, - setSuccess: { behaviorApplySuccess = $0 } - ) - return - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - helper.errorMessage = error.userFacingMessage - } - - isApplyingBehavior = false + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + helper.errorMessage = error.userFacingMessage } + isApplyingBehavior = false + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift index d8b951de..bbe121be 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift @@ -7,58 +7,59 @@ import SwiftUI /// never create, replace, or reset it; expand/loaded/loading/error state all live on the view model so /// switching hosts or segments preserves it. struct RoomStatusContent: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - let viewModel: RoomStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState - let connectedDeviceID: UUID? + let viewModel: RoomStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState + let connectedDeviceID: UUID? - var body: some View { - List { - NodeStatusHeaderSection(session: session) - RoomStatusSection(viewModel: viewModel, session: session, connectionState: connectionState) - NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { - await viewModel.requestTelemetry(for: session) - } - NodeBatteryCurveDisclosureSection( - helper: viewModel.helper, - session: session, - connectionState: connectionState, - connectedDeviceID: connectedDeviceID - ) - } - .nodeStatusDestinations(helper: viewModel.helper) - .themedCanvas(theme) - .nodeManagementHeaderTopMargin() - .scrollDismissesKeyboard(.interactively) + var body: some View { + List { + NodeStatusHeaderSection(session: session) + RoomStatusSection(viewModel: viewModel, session: session, connectionState: connectionState) + NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { + await viewModel.requestTelemetry(for: session) + } + NodeBatteryCurveDisclosureSection( + helper: viewModel.helper, + session: session, + connectionState: connectionState, + connectedDeviceID: connectedDeviceID + ) } + .nodeStatusDestinations(helper: viewModel.helper) + .themedCanvas(theme) + .nodeManagementHeaderTopMargin() + .scrollDismissesKeyboard(.interactively) + } } // MARK: - Status Section private struct RoomStatusSection: View { - let viewModel: RoomStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState + let viewModel: RoomStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState - var body: some View { - NodeStatusSection(helper: viewModel.helper, connectionState: connectionState) { - await viewModel.requestStatus(for: session) - } rows: { - RoomStatusRows(viewModel: viewModel) - } + var body: some View { + NodeStatusSection(helper: viewModel.helper, connectionState: connectionState) { + await viewModel.requestStatus(for: session) + } rows: { + RoomStatusRows(viewModel: viewModel) } + } } // MARK: - Status Rows private struct RoomStatusRows: View { - let viewModel: RoomStatusViewModel + let viewModel: RoomStatusViewModel - var body: some View { - NodeCommonStatusRows(helper: viewModel.helper) - LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, value: viewModel.postsReceivedDisplay) - LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, value: viewModel.postsPushedDisplay) - } + var body: some View { + NodeCommonStatusRows(helper: viewModel.helper) + NodePacketStatusRows(helper: viewModel.helper) + LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, value: viewModel.postsReceivedDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, value: viewModel.postsPushedDisplay) + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift index a1bf0394..d7fb522a 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift @@ -3,70 +3,70 @@ import SwiftUI /// Guest standalone sheet for room server stats, telemetry, and battery curve. struct RoomStatusView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss - let session: RemoteNodeSessionDTO - @State private var viewModel = RoomStatusViewModel() + let session: RemoteNodeSessionDTO + @State private var viewModel = RoomStatusViewModel() - var body: some View { - NavigationStack { - RoomStatusContent( - viewModel: viewModel, - session: session, - connectionState: appState.connectionState, - connectedDeviceID: appState.connectedDevice?.radioID - ) - .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomStatus.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } - } - - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.RemoteNodes.RemoteNodes.done) { - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - } - } - } - .task { - viewModel.configure( - roomAdminService: { appState.services?.roomAdminService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } - ) - await viewModel.registerHandlers() + var body: some View { + NavigationStack { + RoomStatusContent( + viewModel: viewModel, + session: session, + connectionState: appState.connectionState, + connectedDeviceID: appState.connectedDevice?.radioID + ) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomStatus.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } + } - // Pre-load OCV settings - if let radioID = appState.connectedDevice?.radioID { - await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) - } - } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.done) { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } } - .onDisappear { - Task { await viewModel.cleanup() } + } + .task { + viewModel.configure( + roomAdminService: { appState.services?.roomAdminService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService } + ) + await viewModel.registerHandlers() + + // Pre-load OCV settings + if let radioID = appState.connectedDevice?.radioID { + await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) } - .presentationDetents([.large]) + } } + .onDisappear { + Task { await viewModel.cleanup() } + } + .presentationDetents([.large]) + } } #Preview { - RoomStatusView( - session: RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - role: .roomServer, - isConnected: true, - permissionLevel: .admin - ) + RoomStatusView( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .admin ) - .environment(\.appState, AppState()) + ) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusViewModel.swift index 47727f65..d5b6787c 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusViewModel.swift @@ -5,115 +5,116 @@ import SwiftUI @Observable @MainActor final class RoomStatusViewModel { - - // MARK: - Shared Helper - - var helper = NodeStatusViewModel() - - // MARK: - Dependencies - - private var roomAdminServiceProvider: @MainActor () -> RoomAdminService? = { nil } - var roomAdminService: RoomAdminService? { roomAdminServiceProvider() } - - // MARK: - Initialization - - init() {} - - /// Nil services mirror a disconnected state; requests then no-op. - func configure( - roomAdminService: @escaping @MainActor () -> RoomAdminService?, - contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? - ) { - self.roomAdminServiceProvider = roomAdminService - helper.configure( - contactService: contactService, - nodeSnapshotService: nodeSnapshotService - ) - } - - /// Reads the live service from the provider so a reconnect-minted instance - /// is used at call time. Sets only the slots this view model owns; the admin - /// service is shared with the settings/CLI view model, so clearing here would - /// drop its CLI handler and silently break late CLI-response delivery. - func registerHandlers() async { - guard let roomAdminService = roomAdminService else { return } - - await roomAdminService.setStatusHandler { [weak self] status in - await self?.handleStatusResponse(status) - } - - await roomAdminService.setTelemetryHandler { [weak self] response in - await self?.helper.handleTelemetryResponse(response) - } - } - - /// Clear every handler slot on the shared admin service. Only for true - /// surface teardown (sheet dismiss); calling it on a segment switch would - /// wipe the CLI handler the settings view model relies on. - func cleanup() async { - guard let roomAdminService = roomAdminService else { return } - await roomAdminService.clearHandlers() - } - - /// Clear only this view model's status/telemetry handler slots, leaving the settings view - /// model's CLI handler intact. For the merged admin surface's status-segment teardown. - func clearStatusHandlers() async { - guard let roomAdminService = roomAdminService else { return } - await roomAdminService.clearStatusHandlers() - } - - // MARK: - Status - - func requestStatus(for session: RemoteNodeSessionDTO) async { - guard let roomAdminService else { return } - if helper.session == nil { helper.session = session } - - await helper.runRetryingSectionRequest( - operationName: "status", - setLoading: { self.helper.isLoadingStatus = $0 }, - setError: { self.helper.statusSectionError = $0 }, - operation: { [roomAdminService] timeout in - try await roomAdminService.requestStatus(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.handleStatusResponse($0) } - ) - } - - private func handleStatusResponse(_ response: RemoteNodeStatus) async { - await helper.handleStatusResponse( - response, - postedCount: response.roomServerPostedCount, - postPushCount: response.roomServerPostPushCount - ) - } - - // MARK: - Telemetry - - func requestTelemetry(for session: RemoteNodeSessionDTO) async { - guard let roomAdminService else { return } - if helper.session == nil { helper.session = session } - - await helper.runRetryingSectionRequest( - operationName: "telemetry", - setLoading: { self.helper.isLoadingTelemetry = $0 }, - setError: { self.helper.telemetrySectionError = $0 }, - operation: { [roomAdminService] timeout in - try await roomAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.helper.handleTelemetryResponse($0) } - ) - } - - // MARK: - Room-Only Display - - var postsReceivedDisplay: String { - guard let count = helper.status?.roomServerPostedCount else { return NodeStatusViewModel.emDash } - return count.formatted() + // MARK: - Shared Helper + + var helper = NodeStatusViewModel() + + // MARK: - Dependencies + + private var roomAdminServiceProvider: @MainActor () -> RoomAdminService? = { nil } + var roomAdminService: RoomAdminService? { + roomAdminServiceProvider() + } + + // MARK: - Initialization + + init() {} + + /// Nil services mirror a disconnected state; requests then no-op. + func configure( + roomAdminService: @escaping @MainActor () -> RoomAdminService?, + contactService: @escaping @MainActor () -> ContactService?, + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? + ) { + roomAdminServiceProvider = roomAdminService + helper.configure( + contactService: contactService, + nodeSnapshotService: nodeSnapshotService + ) + } + + /// Reads the live service from the provider so a reconnect-minted instance + /// is used at call time. Sets only the slots this view model owns; the admin + /// service is shared with the settings/CLI view model, so clearing here would + /// drop its CLI handler and silently break late CLI-response delivery. + func registerHandlers() async { + guard let roomAdminService else { return } + + await roomAdminService.setStatusHandler { [weak self] status in + await self?.handleStatusResponse(status) } - var postsPushedDisplay: String { - guard let count = helper.status?.roomServerPostPushCount else { return NodeStatusViewModel.emDash } - return count.formatted() + await roomAdminService.setTelemetryHandler { [weak self] response in + await self?.helper.handleTelemetryResponse(response) } + } + + /// Clear every handler slot on the shared admin service. Only for true + /// surface teardown (sheet dismiss); calling it on a segment switch would + /// wipe the CLI handler the settings view model relies on. + func cleanup() async { + guard let roomAdminService else { return } + await roomAdminService.clearHandlers() + } + + /// Clear only this view model's status/telemetry handler slots, leaving the settings view + /// model's CLI handler intact. For the merged admin surface's status-segment teardown. + func clearStatusHandlers() async { + guard let roomAdminService else { return } + await roomAdminService.clearStatusHandlers() + } + + // MARK: - Status + + func requestStatus(for session: RemoteNodeSessionDTO) async { + guard let roomAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "status", + setLoading: { self.helper.isLoadingStatus = $0 }, + setError: { self.helper.statusSectionError = $0 }, + operation: { [roomAdminService] timeout in + try await roomAdminService.requestStatus(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.handleStatusResponse($0) } + ) + } + + private func handleStatusResponse(_ response: RemoteNodeStatus) async { + await helper.handleStatusResponse( + response, + postedCount: response.roomServerPostedCount, + postPushCount: response.roomServerPostPushCount + ) + } + + // MARK: - Telemetry + + func requestTelemetry(for session: RemoteNodeSessionDTO) async { + guard let roomAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "telemetry", + setLoading: { self.helper.isLoadingTelemetry = $0 }, + setError: { self.helper.telemetrySectionError = $0 }, + operation: { [roomAdminService] timeout in + try await roomAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.helper.handleTelemetryResponse($0) } + ) + } + + // MARK: - Room-Only Display + + var postsReceivedDisplay: String { + guard let count = helper.status?.roomServerPostedCount else { return NodeStatusViewModel.emDash } + return count.formatted() + } + + var postsPushedDisplay: String { + guard let count = helper.status?.roomServerPostPushCount else { return NodeStatusViewModel.emDash } + return count.formatted() + } } diff --git a/MC1/Views/RemoteNodes/SettingsLoadPlaceholder.swift b/MC1/Views/RemoteNodes/SettingsLoadPlaceholder.swift index 2c075361..6624bae7 100644 --- a/MC1/Views/RemoteNodes/SettingsLoadPlaceholder.swift +++ b/MC1/Views/RemoteNodes/SettingsLoadPlaceholder.swift @@ -4,16 +4,16 @@ import SwiftUI /// fetch resolves: "Loading…" while in flight, "Failed to load" on error, an em /// dash otherwise. struct SettingsLoadPlaceholder: View { - let isLoading: Bool - let hasError: Bool + let isLoading: Bool + let hasError: Bool - var body: some View { - Text( - isLoading - ? L10n.RemoteNodes.RemoteNodes.Settings.loading - : (hasError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : NodeStatusViewModel.emDash) - ) - .font(.caption) - .foregroundStyle(.secondary) - } + var body: some View { + Text( + isLoading + ? L10n.RemoteNodes.RemoteNodes.Settings.loading + : (hasError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : NodeStatusViewModel.emDash) + ) + .font(.caption) + .foregroundStyle(.secondary) + } } diff --git a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift index f744386c..83b83189 100644 --- a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift @@ -4,8 +4,8 @@ import SwiftUI // MARK: - Unified Focus Field enum NodeSettingsField: Hashable { - case frequency, txPower, advertInterval, floodAdvertInterval, floodMaxHops - case identityName, contactInfo, guestPassword + case frequency, txPower, advertInterval, floodAdvertInterval, floodMaxHops + case identityName, contactInfo, guestPassword } // MARK: - Settings Header @@ -13,434 +13,452 @@ enum NodeSettingsField: Hashable { private let nodeHeaderTopContentMargin: CGFloat = 8 extension View { - /// Trims the grouped scroll view's default top inset so the node header avatar sits close to - /// the pinned management tab picker. Applied to the settings `Form` and telemetry `List` that - /// host `NodeSettingsHeaderSection` / `NodeStatusHeaderSection`. - func nodeManagementHeaderTopMargin() -> some View { - contentMargins(.top, nodeHeaderTopContentMargin, for: .scrollContent) - } + /// Trims the grouped scroll view's default top inset so the node header avatar sits close to + /// the pinned management tab picker. Applied to the settings `Form` and telemetry `List` that + /// host `NodeSettingsHeaderSection` / `NodeStatusHeaderSection`. + func nodeManagementHeaderTopMargin() -> some View { + contentMargins(.top, nodeHeaderTopContentMargin, for: .scrollContent) + } } struct NodeSettingsHeaderSection: View { - let publicKey: Data - let name: String - let role: RemoteNodeRole - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 8) { - NodeAvatar(publicKey: publicKey, role: role, size: 60) - Text(name) - .font(.headline) - } - Spacer() - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) + let publicKey: Data + let name: String + let role: RemoteNodeRole + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + NodeAvatar(publicKey: publicKey, role: role, size: 60) + Text(name) + .font(.headline) } - .listSectionSpacing(.compact) + Spacer() + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) } + .listSectionSpacing(.compact) + } } // MARK: - Device Info Section struct NodeDeviceInfoSection: View { - @Bindable var settings: NodeSettingsViewModel - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfo, - icon: "info.circle", - isExpanded: $settings.isDeviceInfoExpanded, - isLoaded: { settings.deviceInfoLoaded }, - isLoading: $settings.isLoadingDeviceInfo, - hasError: $settings.deviceInfoError, - onLoad: { await settings.fetchDeviceInfo() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfoFooter - ) { - LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: settings.firmwareVersion ?? NodeStatusViewModel.emDash) - LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: settings.deviceTime ?? NodeStatusViewModel.emDash) - } + @Bindable var settings: NodeSettingsViewModel + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfo, + icon: "info.circle", + isExpanded: $settings.isDeviceInfoExpanded, + isLoaded: { settings.deviceInfoLoaded }, + isLoading: $settings.isLoadingDeviceInfo, + hasError: $settings.deviceInfoError, + onLoad: { await settings.fetchDeviceInfo() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfoFooter + ) { + LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: settings.firmwareVersion ?? NodeStatusViewModel.emDash) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: settings.deviceTime ?? NodeStatusViewModel.emDash) } + } } // MARK: - Radio Settings Section struct NodeRadioSettingsSection: View { - @Bindable var settings: NodeSettingsViewModel - var focusedField: FocusState.Binding - var radioRestartWarning: String = L10n.RemoteNodes.RemoteNodes.Settings.radioRestartWarning - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.radioParameters, - icon: "antenna.radiowaves.left.and.right", - isExpanded: $settings.isRadioExpanded, - isLoaded: { settings.radioLoaded }, - isLoading: $settings.isLoadingRadio, - hasError: $settings.radioError, - onLoad: { await settings.fetchRadioSettings() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.radioFooter - ) { - if settings.radioSettingsModified { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - Text(radioRestartWarning) - .font(.subheadline) - } - .padding() - .frame(maxWidth: .infinity) - .background(.yellow.opacity(0.1)) - .clipShape(.rect(cornerRadius: 8)) - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.frequencyMHz) - Spacer() - if let frequency = settings.frequency { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.mhz, value: Binding( - get: { frequency }, - set: { settings.frequency = $0 } - ), format: .number.precision(.fractionLength(3)).locale(.posix)) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 100) - .focused(focusedField, equals: .frequency) - .onChange(of: settings.frequency) { _, _ in - settings.radioSettingsModified = true - } - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - .frame(width: 100, alignment: .trailing) - } - } - - if let bandwidth = settings.bandwidth { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz, selection: Binding( - get: { bandwidth }, - set: { settings.bandwidth = $0 } - )) { - ForEach(RadioOptions.bandwidthsKHz, id: \.self) { bwKHz in - Text(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000))) - .tag(bwKHz) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000)))) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthHint) - .onChange(of: settings.bandwidth) { _, _ in - settings.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz) - Spacer() - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - } - } - - if let spreadingFactor = settings.spreadingFactor { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor, selection: Binding( - get: { spreadingFactor }, - set: { settings.spreadingFactor = $0 } - )) { - ForEach(RadioOptions.spreadingFactors, id: \.self) { sf in - Text(sf, format: .number) - .tag(sf) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.spreadingFactorLabel(sf)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactorHint) - .onChange(of: settings.spreadingFactor) { _, _ in - settings.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor) - Spacer() - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - } - } - - if let codingRate = settings.codingRate { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.codingRate, selection: Binding( - get: { codingRate }, - set: { settings.codingRate = $0 } - )) { - ForEach(RadioOptions.codingRates, id: \.self) { cr in - Text("\(cr)") - .tag(cr) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.codingRateLabel(cr)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.codingRateHint) - .onChange(of: settings.codingRate) { _, _ in - settings.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.codingRate) - Spacer() - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.txPowerDbm) - Spacer() - if let txPower = settings.txPower { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.dbm, value: Binding( - get: { txPower }, - set: { settings.txPower = $0 } - ), format: .number) - .keyboardType(.numbersAndPunctuation) - .multilineTextAlignment(.trailing) - .frame(width: 80) - .focused(focusedField, equals: .txPower) - .onChange(of: settings.txPower) { _, _ in - settings.radioSettingsModified = true - } - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - .frame(width: 80, alignment: .trailing) - } + @Bindable var settings: NodeSettingsViewModel + var focusedField: FocusState.Binding + var radioRestartWarning: String = L10n.RemoteNodes.RemoteNodes.Settings.radioRestartWarning + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.radioParameters, + icon: "antenna.radiowaves.left.and.right", + isExpanded: $settings.isRadioExpanded, + isLoaded: { settings.radioLoaded }, + isLoading: $settings.isLoadingRadio, + hasError: $settings.radioError, + onLoad: { await settings.fetchRadioSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.radioFooter + ) { + if settings.radioSettingsModified { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(radioRestartWarning) + .font(.subheadline) + } + .padding() + .frame(maxWidth: .infinity) + .background(.yellow.opacity(0.1)) + .clipShape(.rect(cornerRadius: 8)) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.frequencyMHz) + Spacer() + if let frequency = settings.frequency { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.mhz, value: Binding( + get: { frequency }, + set: { settings.frequency = $0 } + ), format: .number.precision(.fractionLength(3)).locale(.posix)) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 100) + .focused(focusedField, equals: .frequency) + .onChange(of: settings.frequency) { _, _ in + settings.radioSettingsModified = true } - - Button { - Task { await settings.applyRadioSettings() } - } label: { - AsyncActionLabel(isLoading: settings.isApplying, showSuccess: false) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyRadioSettings) - .foregroundStyle(settings.radioSettingsModified ? Color.accentColor : .secondary) - .transition(.opacity) - } + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + .frame(width: 100, alignment: .trailing) + } + } + + if let bandwidth = settings.bandwidth { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz, selection: Binding( + get: { bandwidth }, + set: { settings.bandwidth = $0 } + )) { + ForEach(RadioOptions.bandwidthsKHz, id: \.self) { bwKHz in + Text(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000))) + .tag(bwKHz) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000)))) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthHint) + .onChange(of: settings.bandwidth) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz) + Spacer() + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + } + } + + if let spreadingFactor = settings.spreadingFactor { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor, selection: Binding( + get: { spreadingFactor }, + set: { settings.spreadingFactor = $0 } + )) { + ForEach(RadioOptions.spreadingFactors, id: \.self) { sf in + Text(sf, format: .number) + .tag(sf) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.spreadingFactorLabel(sf)) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactorHint) + .onChange(of: settings.spreadingFactor) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor) + Spacer() + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + } + } + + if let codingRate = settings.codingRate { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.codingRate, selection: Binding( + get: { codingRate }, + set: { settings.codingRate = $0 } + )) { + ForEach(RadioOptions.codingRates, id: \.self) { cr in + Text("\(cr)") + .tag(cr) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.codingRateLabel(cr)) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.codingRateHint) + .onChange(of: settings.codingRate) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.codingRate) + Spacer() + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + } + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.txPowerDbm) + Spacer() + if let txPower = settings.txPower { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.dbm, value: Binding( + get: { txPower }, + set: { settings.txPower = $0 } + ), format: .number) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .focused(focusedField, equals: .txPower) + .onChange(of: settings.txPower) { _, _ in + settings.radioSettingsModified = true } - .disabled(!settings.radioSettingsModified || settings.isApplying) + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + .frame(width: 80, alignment: .trailing) + } + } + + Button { + Task { await settings.applyRadioSettings() } + } label: { + AsyncActionLabel(isLoading: settings.isApplying, showSuccess: false) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyRadioSettings) + .foregroundStyle(settings.radioSettingsModified ? Color.accentColor : .secondary) + .transition(.opacity) } + } + .disabled(!settings.radioSettingsModified || settings.isApplying) } + } } // MARK: - Identity Section struct RemoteNodeIdentitySection: View { - @Bindable var settings: NodeSettingsViewModel - var focusedField: FocusState.Binding - var onPickLocation: () -> Void - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.identityLocation, - icon: "person.text.rectangle", - isExpanded: $settings.isIdentityExpanded, - isLoaded: { settings.identityLoaded }, - isLoading: $settings.isLoadingIdentity, - hasError: $settings.identityError, - onLoad: { await settings.fetchIdentity() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.identityFooter - ) { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.name) - Spacer() - if let name = settings.name { - TextField(L10n.RemoteNodes.RemoteNodes.name, text: Binding( - get: { name }, - set: { settings.name = $0 } - )) - .multilineTextAlignment(.trailing) - .focused(focusedField, equals: .identityName) - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.latitude) - Spacer() - if let latitude = settings.latitude { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.latitude, value: Binding( - get: { latitude }, - set: { settings.latitude = $0 } - ), format: .number.precision(.fractionLength(6))) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 140) - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.longitude) - Spacer() - if let longitude = settings.longitude { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.longitude, value: Binding( - get: { longitude }, - set: { settings.longitude = $0 } - ), format: .number.precision(.fractionLength(6))) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 140) - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) - } - } - - Button(L10n.RemoteNodes.RemoteNodes.Settings.pickOnMap, systemImage: "map") { - onPickLocation() - } - - Button { - Task { await settings.applyIdentitySettings() } - } label: { - AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.identityApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyIdentitySettings) - } - } - .disabled(!settings.identitySettingsModified || settings.isApplying) + @Bindable var settings: NodeSettingsViewModel + var focusedField: FocusState.Binding + var onPickLocation: () -> Void + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.identityLocation, + icon: "person.text.rectangle", + isExpanded: $settings.isIdentityExpanded, + isLoaded: { settings.identityLoaded }, + isLoading: $settings.isLoadingIdentity, + hasError: $settings.identityError, + onLoad: { await settings.fetchIdentity() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.identityFooter + ) { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.name) + Spacer() + if let name = settings.name { + TextField(L10n.RemoteNodes.RemoteNodes.name, text: Binding( + get: { name }, + set: { settings.name = $0 } + )) + .multilineTextAlignment(.trailing) + .focused(focusedField, equals: .identityName) + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) + } + } + + if let error = settings.nameError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.latitude) + Spacer() + if let latitude = settings.latitude { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.latitude, value: Binding( + get: { latitude }, + set: { settings.latitude = $0 } + ), format: .number.precision(.fractionLength(6)).locale(.posix)) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(width: 140) + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) + } + } + + if let error = settings.latitudeError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.longitude) + Spacer() + if let longitude = settings.longitude { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.longitude, value: Binding( + get: { longitude }, + set: { settings.longitude = $0 } + ), format: .number.precision(.fractionLength(6)).locale(.posix)) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(width: 140) + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) } + } + + if let error = settings.longitudeError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Button(L10n.RemoteNodes.RemoteNodes.Settings.pickOnMap, systemImage: "map") { + onPickLocation() + } + + Button { + Task { await settings.applyIdentitySettings() } + } label: { + AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.identityApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyIdentitySettings) + } + } + .disabled(!settings.identitySettingsModified || settings.isApplying) } + } } // MARK: - Contact Info Section struct NodeContactInfoSection: View { - @Bindable var settings: NodeSettingsViewModel - var focusedField: FocusState.Binding - @State private var contactText = "" - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.contactInfo, - icon: "person.crop.rectangle", - isExpanded: $settings.isContactInfoExpanded, - isLoaded: { settings.contactInfoLoaded }, - isLoading: $settings.isLoadingContactInfo, - hasError: $settings.contactInfoError, - onLoad: { await settings.fetchContactInfo() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.contactInfoFooter - ) { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, text: $contactText, axis: .vertical) - .lineLimit(3...6) - .focused(focusedField, equals: .contactInfo) - .overlay(alignment: .bottomTrailing) { - Text("\(settings.ownerInfoCharCount)/\(NodeSettingsViewModel.ownerInfoMaxLength)") - .font(.caption2) - .foregroundStyle(settings.isOwnerInfoTooLong ? .red : .secondary) - .padding(4) - } - .onChange(of: settings.ownerInfo, initial: true) { _, newValue in - contactText = newValue ?? "" - } - .onChange(of: contactText) { _, newValue in - settings.ownerInfo = newValue - } - - Button { - Task { await settings.applyContactInfoSettings() } - } label: { - AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.contactInfoApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) - } - } - .disabled(!settings.contactInfoSettingsModified || settings.isApplying || settings.isOwnerInfoTooLong) + @Bindable var settings: NodeSettingsViewModel + var focusedField: FocusState.Binding + @State private var contactText = "" + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.contactInfo, + icon: "person.crop.rectangle", + isExpanded: $settings.isContactInfoExpanded, + isLoaded: { settings.contactInfoLoaded }, + isLoading: $settings.isLoadingContactInfo, + hasError: $settings.contactInfoError, + onLoad: { await settings.fetchContactInfo() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.contactInfoFooter + ) { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, text: $contactText, axis: .vertical) + .lineLimit(3...6) + .focused(focusedField, equals: .contactInfo) + .overlay(alignment: .bottomTrailing) { + Text("\(settings.ownerInfoCharCount)/\(NodeSettingsViewModel.ownerInfoMaxLength)") + .font(.caption2) + .foregroundStyle(settings.isOwnerInfoTooLong ? .red : .secondary) + .padding(4) + } + .onChange(of: settings.ownerInfo, initial: true) { _, newValue in + contactText = newValue ?? "" } + .onChange(of: contactText) { _, newValue in + settings.ownerInfo = newValue + } + + Button { + Task { await settings.applyContactInfoSettings() } + } label: { + AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.contactInfoApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) + } + } + .disabled(!settings.contactInfoSettingsModified || settings.isApplying || settings.isOwnerInfoTooLong) } + } } // MARK: - Security Section struct NodeSecuritySection: View { - @Environment(\.appTheme) private var theme - @Bindable var settings: NodeSettingsViewModel - - var body: some View { - Section { - DisclosureGroup(isExpanded: $settings.isSecurityExpanded) { - SecureField(L10n.RemoteNodes.RemoteNodes.Settings.newPassword, text: $settings.newPassword) - SecureField(L10n.RemoteNodes.RemoteNodes.Settings.confirmPassword, text: $settings.confirmPassword) - - Button { - Task { await settings.changePassword() } - } label: { - AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.changePasswordSuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.changePassword) - } - } - .disabled(settings.isApplying || settings.changePasswordSuccess || settings.newPassword.isEmpty || settings.newPassword != settings.confirmPassword) - } label: { - Label(L10n.RemoteNodes.RemoteNodes.Settings.security, systemImage: "lock") - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Settings.securityFooter) + @Environment(\.appTheme) private var theme + @Bindable var settings: NodeSettingsViewModel + + var body: some View { + Section { + DisclosureGroup(isExpanded: $settings.isSecurityExpanded) { + SecureField(L10n.RemoteNodes.RemoteNodes.Settings.newPassword, text: $settings.newPassword) + SecureField(L10n.RemoteNodes.RemoteNodes.Settings.confirmPassword, text: $settings.confirmPassword) + + Button { + Task { await settings.changePassword() } + } label: { + AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.changePasswordSuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.changePassword) + } } - .themedRowBackground(theme) + .disabled(settings.isApplying || settings.changePasswordSuccess || settings.newPassword.isEmpty || settings.newPassword != settings.confirmPassword) + } label: { + Label(L10n.RemoteNodes.RemoteNodes.Settings.security, systemImage: "lock") + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Settings.securityFooter) } + .themedRowBackground(theme) + } } // MARK: - Actions Section struct NodeActionsSection: View { - @Environment(\.appTheme) private var theme - let settings: NodeSettingsViewModel - @Binding var showRebootConfirmation: Bool - var rebootConfirmTitle: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootConfirmTitle - var rebootMessage: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootMessage - - var body: some View { - Section(L10n.RemoteNodes.RemoteNodes.Settings.deviceActions) { - Button { - Task { await settings.forceAdvert() } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.sendAdvert) - if settings.isSendingAdvert { - Spacer() - ProgressView() - } - } - } - .disabled(settings.isSendingAdvert) - - Button { - Task { await settings.syncTime() } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.syncTime) - if settings.isApplying { - Spacer() - ProgressView() - } - } - } - .disabled(settings.isApplying) - - Button(L10n.RemoteNodes.RemoteNodes.Settings.rebootDevice, role: .destructive) { - showRebootConfirmation = true - } - .disabled(settings.isRebooting) - .confirmationDialog(rebootConfirmTitle, isPresented: $showRebootConfirmation) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.reboot, role: .destructive) { - Task { await settings.reboot() } - } - Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { } - } message: { - Text(rebootMessage) - } - - if let error = settings.errorMessage { - Text(error) - .foregroundStyle(.orange) - .font(.caption) - } + @Environment(\.appTheme) private var theme + let settings: NodeSettingsViewModel + @Binding var showRebootConfirmation: Bool + var rebootConfirmTitle: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootConfirmTitle + var rebootMessage: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootMessage + + var body: some View { + Section(L10n.RemoteNodes.RemoteNodes.Settings.deviceActions) { + Button { + Task { await settings.forceAdvert() } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.sendAdvert) + if settings.isSendingAdvert { + Spacer() + ProgressView() + } + } + } + .disabled(settings.isSendingAdvert) + + Button { + Task { await settings.syncTime() } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.syncTime) + if settings.isApplying { + Spacer() + ProgressView() + } + } + } + .disabled(settings.isApplying) + + Button(L10n.RemoteNodes.RemoteNodes.Settings.rebootDevice, role: .destructive) { + showRebootConfirmation = true + } + .disabled(settings.isRebooting) + .confirmationDialog(rebootConfirmTitle, isPresented: $showRebootConfirmation) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.reboot, role: .destructive) { + Task { await settings.reboot() } } - .themedRowBackground(theme) + Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) {} + } message: { + Text(rebootMessage) + } + + if let error = settings.errorMessage { + Text(error) + .foregroundStyle(.orange) + .font(.caption) + } } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift b/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift index 8b2c9b0e..eca704ae 100644 --- a/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift @@ -4,304 +4,329 @@ import SwiftUI // MARK: - Status Header struct NodeStatusHeaderSection: View { - let session: RemoteNodeSessionDTO - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 8) { - NodeAvatar(publicKey: session.publicKey, role: session.role, size: 60) - - Text(session.name) - .font(.headline) - - if session.permissionLevel == .guest { - Text(L10n.RemoteNodes.RemoteNodes.Status.guestMode) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - Spacer() - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) + let session: RemoteNodeSessionDTO + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + NodeAvatar(publicKey: session.publicKey, role: session.role, size: 60) + + Text(session.name) + .font(.headline) + + if session.permissionLevel == .guest { + Text(L10n.RemoteNodes.RemoteNodes.Status.guestMode) + .font(.subheadline) + .foregroundStyle(.secondary) + } } - .listSectionSpacing(.compact) + Spacer() + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) } + .listSectionSpacing(.compact) + } } // MARK: - Common Status Rows struct NodeCommonStatusRows: View { - let helper: NodeStatusViewModel - - var body: some View { - NodeMetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.battery, - value: helper.batteryDisplay, - delta: helper.batteryDeltaMV.map { Double($0) / 1000.0 }, - higherIsBetter: true, unit: " V", fractionDigits: 3 - ) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.uptime, value: helper.uptimeDisplay) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.airtime, value: helper.airtimeDisplay) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.airtimePercent, value: helper.airtimePercentDisplay) - - NodeMetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.lastRssi, - value: helper.lastRSSIDisplay, - delta: helper.rssiDelta.map(Double.init), - higherIsBetter: true, unit: " dBm", fractionDigits: 0 - ) - - NodeMetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.lastSnr, - value: helper.lastSNRDisplay, - delta: helper.snrDelta, - higherIsBetter: true, unit: " dB", fractionDigits: 1 - ) - - NodeMetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.noiseFloor, - value: helper.noiseFloorDisplay, - delta: helper.noiseFloorDelta.map(Double.init), - higherIsBetter: false, unit: " dBm", fractionDigits: 0 - ) + let helper: NodeStatusViewModel + + var body: some View { + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.battery, + value: helper.batteryDisplay, + delta: helper.batteryDeltaMV.map { Double($0) / 1000.0 }, + higherIsBetter: true, unit: " V", fractionDigits: 3 + ) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.uptime, value: helper.uptimeDisplay) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.airtime, value: helper.airtimeDisplay) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.airtimePercent, value: helper.airtimePercentDisplay) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.lastRssi, + value: helper.lastRSSIDisplay, + delta: helper.rssiDelta.map(Double.init), + higherIsBetter: true, unit: " dBm", fractionDigits: 0 + ) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.lastSnr, + value: helper.lastSNRDisplay, + delta: helper.snrDelta, + higherIsBetter: true, unit: " dB", fractionDigits: 1 + ) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.noiseFloor, + value: helper.noiseFloorDisplay, + delta: helper.noiseFloorDelta.map(Double.init), + higherIsBetter: false, unit: " dBm", fractionDigits: 0 + ) + } +} - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsSent, value: helper.packetsSentDisplay) - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsReceived, value: helper.packetsReceivedDisplay) +// MARK: - Packet Status Section + +/// The packet counters grouped under a `Packets` header: sent/received totals, their +/// Direct/Flood breakdown, duplicates, and the optional receive-error count (repeaters +/// only). The header supplies the shared noun so each row label stays a short leaf. +struct NodePacketStatusRows: View { + let helper: NodeStatusViewModel + var receiveErrorsDisplay: String? + + var body: some View { + Section { + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsSent, value: helper.packetsSentDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsReceived, value: helper.packetsReceivedDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.sentDirect, value: helper.sentDirectDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.sentFlood, value: helper.sentFloodDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.receivedDirect, value: helper.receivedDirectDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.receivedFlood, value: helper.receivedFloodDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.duplicates, value: helper.duplicatesDisplay) + if let receiveErrorsDisplay { + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.receiveErrors, value: receiveErrorsDisplay) + } + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Status.packets) + .fontWeight(.semibold) } + } } // MARK: - Status Section struct NodeStatusSection: View { - @Environment(\.appTheme) private var theme - @Bindable var helper: NodeStatusViewModel - let connectionState: DeviceConnectionState - let onLoad: () async -> Void - @ViewBuilder let rows: () -> Rows - - var body: some View { - Section { - DisclosureGroup(isExpanded: $helper.statusExpanded) { - if helper.isLoadingStatus && helper.status == nil { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let errorMessage = helper.statusSectionError, helper.status == nil { - Text(errorMessage) - .foregroundStyle(.orange) - } else if helper.status != nil { - rows() - - if let timestamp = helper.previousSnapshotTimestamp { - Text(timestamp) - .font(.caption) - .foregroundStyle(.secondary) - } - - NavigationLink(value: NodeStatusRoute.statusHistory) { - Text(L10n.RemoteNodes.RemoteNodes.History.title) - } - } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Status.statusSection) - Spacer() - SectionReloadButton( - isLoading: helper.isLoadingStatus, - isLoaded: helper.statusLoaded, - hasError: helper.statusSectionError != nil, - isDisabled: connectionState != .ready, - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadStatus, - onReload: onLoad - ) - } - } - .onChange(of: helper.statusExpanded) { _, isExpanded in - if isExpanded && !helper.statusLoaded && !helper.isLoadingStatus { - Task { - await onLoad() - } - } - } + @Environment(\.appTheme) private var theme + @Bindable var helper: NodeStatusViewModel + let connectionState: DeviceConnectionState + let onLoad: () async -> Void + @ViewBuilder let rows: () -> Rows + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.statusExpanded) { + if helper.isLoadingStatus, helper.status == nil { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let errorMessage = helper.statusSectionError, helper.status == nil { + Text(errorMessage) + .foregroundStyle(.orange) + } else if helper.status != nil { + rows() + + if let timestamp = helper.previousSnapshotTimestamp { + Text(timestamp) + .font(.caption) + .foregroundStyle(.secondary) + } + + NavigationLink(value: NodeStatusRoute.statusHistory) { + Text(L10n.RemoteNodes.RemoteNodes.History.title) + } + } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Status.statusSection) + Spacer() + SectionReloadButton( + isLoading: helper.isLoadingStatus, + isLoaded: helper.statusLoaded, + hasError: helper.statusSectionError != nil, + isDisabled: connectionState != .ready, + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadStatus, + onReload: onLoad + ) } - .themedRowBackground(theme) + } + .onChange(of: helper.statusExpanded) { _, isExpanded in + if isExpanded, !helper.statusLoaded, !helper.isLoadingStatus { + Task { + await onLoad() + } + } + } } + .themedRowBackground(theme) + } } // MARK: - Metric Row struct NodeMetricRow: View { - let label: String - let value: String - let delta: Double? - let higherIsBetter: Bool - let unit: String - let fractionDigits: Int - - var body: some View { - LabeledContent { - VStack(alignment: .trailing, spacing: 2) { - Text(value) - if let delta { - StatusDeltaView(delta: delta, higherIsBetter: higherIsBetter, unit: unit, fractionDigits: fractionDigits) - } - } - } label: { - Text(label) + let label: String + let value: String + let delta: Double? + let higherIsBetter: Bool + let unit: String + let fractionDigits: Int + + var body: some View { + LabeledContent { + VStack(alignment: .trailing, spacing: 2) { + Text(value) + if let delta { + StatusDeltaView(delta: delta, higherIsBetter: higherIsBetter, unit: unit, fractionDigits: fractionDigits) } + } + } label: { + Text(label) } + } } // MARK: - Telemetry Row struct NodeTelemetryRow: View { - let dataPoint: LPPDataPoint - let ocvArray: [Int] - - var body: some View { - if dataPoint.type == .voltage, case .float(let voltage) = dataPoint.value { - let millivolts = Int(voltage * 1000) - let battery = BatteryInfo(level: millivolts) - let percentage = battery.percentage(using: ocvArray) - - LabeledContent(dataPoint.type.localizedName) { - VStack(alignment: .trailing, spacing: 2) { - Text(dataPoint.formattedValue) - Text("\(percentage)%") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } else { - LabeledContent(dataPoint.type.localizedName, value: dataPoint.formattedValue) + let dataPoint: LPPDataPoint + let ocvArray: [Int] + + var body: some View { + if dataPoint.type == .voltage, case let .float(voltage) = dataPoint.value { + let millivolts = Int(voltage * 1000) + let battery = BatteryInfo(level: millivolts) + let percentage = battery.percentage(using: ocvArray) + + LabeledContent(dataPoint.type.localizedName) { + VStack(alignment: .trailing, spacing: 2) { + Text(dataPoint.formattedValue) + Text("\(percentage)%") + .font(.caption) + .foregroundStyle(.secondary) } + } + } else { + LabeledContent(dataPoint.type.localizedName, value: dataPoint.formattedValue) } + } } // MARK: - Battery Curve Disclosure Section struct NodeBatteryCurveDisclosureSection: View { - @Environment(\.appTheme) private var theme - @Bindable var helper: NodeStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState - let connectedDeviceID: UUID? - - var body: some View { - Section { - DisclosureGroup(isExpanded: $helper.isBatteryCurveExpanded) { - BatteryCurveSection( - availablePresets: OCVPreset.nodePresets, - headerText: "", - footerText: "", - selectedPreset: $helper.selectedOCVPreset, - voltageValues: $helper.ocvValues, - onSave: helper.saveOCVSettings, - isDisabled: connectionState != .ready - ) - - if let error = helper.ocvError { - Text(error) - .font(.caption) - .foregroundStyle(.orange) - } - } label: { - Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurve) - } - .onChange(of: helper.isBatteryCurveExpanded) { _, isExpanded in - if isExpanded, let deviceID = connectedDeviceID { - Task { - await helper.loadOCVSettings(publicKey: session.publicKey, radioID: deviceID) - } - } - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurveFooter) + @Environment(\.appTheme) private var theme + @Bindable var helper: NodeStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState + let connectedDeviceID: UUID? + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.isBatteryCurveExpanded) { + BatteryCurveSection( + availablePresets: OCVPreset.nodePresets, + headerText: "", + footerText: "", + selectedPreset: $helper.selectedOCVPreset, + voltageValues: $helper.ocvValues, + onSave: helper.saveOCVSettings, + isDisabled: connectionState != .ready + ) + + if let error = helper.ocvError { + Text(error) + .font(.caption) + .foregroundStyle(.orange) + } + } label: { + Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurve) + } + .onChange(of: helper.isBatteryCurveExpanded) { _, isExpanded in + if isExpanded, let deviceID = connectedDeviceID { + Task { + await helper.loadOCVSettings(publicKey: session.publicKey, radioID: deviceID) + } } - .themedRowBackground(theme) + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurveFooter) } + .themedRowBackground(theme) + } } // MARK: - Telemetry Disclosure Section struct NodeTelemetryDisclosureSection: View { - @Environment(\.appTheme) private var theme - @Bindable var helper: NodeStatusViewModel - let connectionState: DeviceConnectionState - let onRequestTelemetry: () async -> Void - - var body: some View { - Section { - DisclosureGroup(isExpanded: $helper.telemetryExpanded) { - if helper.isLoadingTelemetry { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let errorMessage = helper.telemetrySectionError, helper.telemetry == nil { - Text(errorMessage) - .foregroundStyle(.orange) - } else if helper.telemetry != nil { - if helper.cachedDataPoints.isEmpty { - Text(L10n.RemoteNodes.RemoteNodes.Status.noSensorData) - .foregroundStyle(.secondary) - } else if helper.hasMultipleChannels { - ForEach(helper.groupedDataPoints, id: \.channel) { group in - Section { - ForEach(group.dataPoints, id: \.self) { dataPoint in - NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) - } - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Status.channel(Int(group.channel))) - .fontWeight(.semibold) - } - } - } else { - ForEach(helper.cachedDataPoints, id: \.self) { dataPoint in - NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) - } - } - - NavigationLink(value: NodeStatusRoute.telemetryHistory) { - Text(L10n.RemoteNodes.RemoteNodes.History.title) - } - } else { - Text(L10n.RemoteNodes.RemoteNodes.Status.noTelemetryData) - .foregroundStyle(.secondary) - } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Status.telemetry) - Spacer() - SectionReloadButton( - isLoading: helper.isLoadingTelemetry, - isLoaded: helper.telemetryLoaded, - hasError: helper.telemetrySectionError != nil, - isDisabled: connectionState != .ready, - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadTelemetry, - onReload: onRequestTelemetry - ) + @Environment(\.appTheme) private var theme + @Bindable var helper: NodeStatusViewModel + let connectionState: DeviceConnectionState + let onRequestTelemetry: () async -> Void + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.telemetryExpanded) { + if helper.isLoadingTelemetry { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let errorMessage = helper.telemetrySectionError, helper.telemetry == nil { + Text(errorMessage) + .foregroundStyle(.orange) + } else if helper.telemetry != nil { + if helper.cachedDataPoints.isEmpty { + Text(L10n.RemoteNodes.RemoteNodes.Status.noSensorData) + .foregroundStyle(.secondary) + } else if helper.hasMultipleChannels { + ForEach(helper.groupedDataPoints, id: \.channel) { group in + Section { + ForEach(group.dataPoints, id: \.self) { dataPoint in + NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) } + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Status.channel(Int(group.channel))) + .fontWeight(.semibold) + } } - .onChange(of: helper.telemetryExpanded) { _, isExpanded in - if isExpanded && !helper.telemetryLoaded && !helper.isLoadingTelemetry { - Task { - await onRequestTelemetry() - } - } + } else { + ForEach(helper.cachedDataPoints, id: \.self) { dataPoint in + NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.telemetryFooter) + } + + NavigationLink(value: NodeStatusRoute.telemetryHistory) { + Text(L10n.RemoteNodes.RemoteNodes.History.title) + } + } else { + Text(L10n.RemoteNodes.RemoteNodes.Status.noTelemetryData) + .foregroundStyle(.secondary) + } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Status.telemetry) + Spacer() + SectionReloadButton( + isLoading: helper.isLoadingTelemetry, + isLoaded: helper.telemetryLoaded, + hasError: helper.telemetrySectionError != nil, + isDisabled: connectionState != .ready, + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadTelemetry, + onReload: onRequestTelemetry + ) + } + } + .onChange(of: helper.telemetryExpanded) { _, isExpanded in + if isExpanded, !helper.telemetryLoaded, !helper.isLoadingTelemetry { + Task { + await onRequestTelemetry() + } } - .themedRowBackground(theme) + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.telemetryFooter) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/RemoteNodes/StatusDeltaView.swift b/MC1/Views/RemoteNodes/StatusDeltaView.swift index 27ed8e53..4fb550c1 100644 --- a/MC1/Views/RemoteNodes/StatusDeltaView.swift +++ b/MC1/Views/RemoteNodes/StatusDeltaView.swift @@ -2,42 +2,42 @@ import SwiftUI /// Displays a trend arrow and delta value next to a status metric. struct StatusDeltaView: View { - let delta: Double - /// Whether higher values are better (true for battery/SNR, false for noise floor) - let higherIsBetter: Bool - let unit: String - /// Number of decimal places to display (0 for integers like mV/dBm, 1 for floats like SNR) - let fractionDigits: Int + let delta: Double + /// Whether higher values are better (true for battery/SNR, false for noise floor) + let higherIsBetter: Bool + let unit: String + /// Number of decimal places to display (0 for integers like mV/dBm, 1 for floats like SNR) + let fractionDigits: Int - var body: some View { - HStack(spacing: 2) { - Image(systemName: delta > 0 ? "arrow.up" : "arrow.down") - .imageScale(.small) - Text("\(abs(delta).formatted(.number.precision(.fractionLength(fractionDigits))))\(unit)") - } - .font(.caption) - .foregroundStyle(deltaColor) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityDescription) + var body: some View { + HStack(spacing: 2) { + Image(systemName: delta > 0 ? "arrow.up" : "arrow.down") + .imageScale(.small) + Text("\(abs(delta).formatted(.number.precision(.fractionLength(fractionDigits))))\(unit)") } + .font(.caption) + .foregroundStyle(deltaColor) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityDescription) + } - private var isImprovement: Bool { - higherIsBetter ? delta > 0 : delta < 0 - } + private var isImprovement: Bool { + higherIsBetter ? delta > 0 : delta < 0 + } - private var deltaColor: Color { - if abs(delta) < 0.01 { return .secondary } - return isImprovement ? .green : .orange - } + private var deltaColor: Color { + if abs(delta) < 0.01 { return .secondary } + return isImprovement ? .green : .orange + } - private var accessibilityDescription: String { - let direction = delta > 0 - ? L10n.RemoteNodes.RemoteNodes.History.A11y.increased - : L10n.RemoteNodes.RemoteNodes.History.A11y.decreased - let quality = isImprovement - ? L10n.RemoteNodes.RemoteNodes.History.A11y.improved - : L10n.RemoteNodes.RemoteNodes.History.A11y.degraded - let formatted = abs(delta).formatted(.number.precision(.fractionLength(fractionDigits))) - return L10n.RemoteNodes.RemoteNodes.History.A11y.deltaDescription(quality, direction, formatted, unit) - } + private var accessibilityDescription: String { + let direction = delta > 0 + ? L10n.RemoteNodes.RemoteNodes.History.A11y.increased + : L10n.RemoteNodes.RemoteNodes.History.A11y.decreased + let quality = isImprovement + ? L10n.RemoteNodes.RemoteNodes.History.A11y.improved + : L10n.RemoteNodes.RemoteNodes.History.A11y.degraded + let formatted = abs(delta).formatted(.number.precision(.fractionLength(fractionDigits))) + return L10n.RemoteNodes.RemoteNodes.History.A11y.deltaDescription(quality, direction, formatted, unit) + } } diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift index 39538d9a..049cb4b5 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift @@ -3,211 +3,208 @@ import SwiftUI /// Offline-accessible overview of all historical telemetry charts for a repeater. struct TelemetryHistoryOverviewView: View { - let publicKey: Data - let radioID: UUID - let showNeighbors: Bool - - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel = TelemetryHistoryOverviewViewModel() - @State private var radioExpanded = true - @State private var sensorsExpanded: Bool - @State private var neighborsExpanded = false - - init(publicKey: Data, radioID: UUID, showNeighbors: Bool = true) { - self.publicKey = publicKey - self.radioID = radioID - self.showNeighbors = showNeighbors - self._sensorsExpanded = State(initialValue: !showNeighbors) - } - - var body: some View { - let filtered = viewModel.filteredSnapshots - List { - if !viewModel.hasSnapshots { - emptyState - } else { - HistoryTimeRangePicker(selection: $viewModel.timeRange) - radioSection(filtered: filtered) - sensorsSection(filtered: filtered) - if showNeighbors { - neighborsSection(filtered: filtered) - } - retentionFooter - } - } - .themedCanvas(theme) - .chartScrubbingScrollLock() - .navigationTitle(L10n.RemoteNodes.RemoteNodes.History.overviewTitle) - .liquidGlassToolbarBackground() - .task { - guard let store = appState.offlineDataStore else { return } - await viewModel.loadData( - dataStore: store, publicKey: publicKey, radioID: radioID - ) + let publicKey: Data + let radioID: UUID + let showNeighbors: Bool + + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel = TelemetryHistoryOverviewViewModel() + @State private var radioExpanded = true + @State private var sensorsExpanded: Bool + @State private var neighborsExpanded = false + + init(publicKey: Data, radioID: UUID, showNeighbors: Bool = true) { + self.publicKey = publicKey + self.radioID = radioID + self.showNeighbors = showNeighbors + _sensorsExpanded = State(initialValue: !showNeighbors) + } + + var body: some View { + let filtered = viewModel.filteredSnapshots + List { + if !viewModel.hasSnapshots { + emptyState + } else { + HistoryTimeRangePicker(selection: $viewModel.timeRange) + radioSection(filtered: filtered) + sensorsSection(filtered: filtered) + if showNeighbors { + neighborsSection(filtered: filtered) } + retentionFooter + } } - - // MARK: - Empty State - - @ViewBuilder - private var emptyState: some View { - ContentUnavailableView( - L10n.RemoteNodes.RemoteNodes.History.overviewTitle, - systemImage: "chart.line.uptrend.xyaxis", - description: Text(L10n.RemoteNodes.RemoteNodes.History.noSnapshotsMessage) - ) + .listSectionSpacing(.compact) + .themedCanvas(theme) + .chartScrubbingScrollLock() + .navigationTitle(L10n.RemoteNodes.RemoteNodes.History.overviewTitle) + .liquidGlassToolbarBackground() + .task { + guard let store = appState.offlineDataStore else { return } + await viewModel.loadData( + dataStore: store, publicKey: publicKey, radioID: radioID + ) } - - // MARK: - Radio Section - - @ViewBuilder - private func radioSection(filtered: [NodeStatusSnapshotDTO]) -> some View { - let hasRadioData = filtered.contains { - $0.batteryMillivolts != nil || $0.lastSNR != nil || - $0.lastRSSI != nil || $0.noiseFloor != nil || - $0.packetsSent != nil || $0.packetsReceived != nil || - $0.receiveErrors != nil || - $0.postedCount != nil || $0.postPushCount != nil - } - - if hasRadioData { - Section { - DisclosureGroup( - L10n.RemoteNodes.RemoteNodes.History.radioSection, - isExpanded: $radioExpanded - ) { - RadioMetricCharts(snapshots: filtered, ocvArray: viewModel.ocvArray) { chart in - chart - } - } + } + + // MARK: - Empty State + + private var emptyState: some View { + ContentUnavailableView( + L10n.RemoteNodes.RemoteNodes.History.overviewTitle, + systemImage: "chart.line.uptrend.xyaxis", + description: Text(L10n.RemoteNodes.RemoteNodes.History.noSnapshotsMessage) + ) + } + + // MARK: - Radio Section + + @ViewBuilder + private func radioSection(filtered: [NodeStatusSnapshotDTO]) -> some View { + let hasRadioData = viewModel.hasRadioData(in: filtered) + + if hasRadioData { + Section { + DisclosureGroup( + L10n.RemoteNodes.RemoteNodes.History.radioSection, + isExpanded: $radioExpanded + ) { + RadioMetricCharts(snapshots: filtered, ocvArray: viewModel.ocvArray) { chart in + chart + } packetSection: { group in + Section(L10n.RemoteNodes.RemoteNodes.History.packets) { + group } - .themedRowBackground(theme) + } } + } + .themedRowBackground(theme) } - - // MARK: - Sensors Section - - @ViewBuilder - private func sensorsSection(filtered: [NodeStatusSnapshotDTO]) -> some View { - if viewModel.hasTelemetryData(in: filtered) { - let groups = ChannelGroup.groups(from: filtered) - Section { - DisclosureGroup( - L10n.RemoteNodes.RemoteNodes.History.sensorsSection, - isExpanded: $sensorsExpanded - ) { - if groups.count > 1 { - ForEach(groups) { group in - Section(L10n.RemoteNodes.RemoteNodes.Status.channel(group.channel)) { - ForEach(group.charts) { chart in - chartView(for: chart) - } - } - } - } else if let group = groups.first { - ForEach(group.charts) { chart in - chartView(for: chart) - } - } + } + + // MARK: - Sensors Section + + @ViewBuilder + private func sensorsSection(filtered: [NodeStatusSnapshotDTO]) -> some View { + if viewModel.hasTelemetryData(in: filtered) { + let groups = ChannelGroup.groups(from: filtered) + Section { + DisclosureGroup( + L10n.RemoteNodes.RemoteNodes.History.sensorsSection, + isExpanded: $sensorsExpanded + ) { + if groups.count > 1 { + ForEach(groups) { group in + Section(L10n.RemoteNodes.RemoteNodes.Status.channel(group.channel)) { + ForEach(group.charts) { chart in + chartView(for: chart) } + } } - .themedRowBackground(theme) - } else if viewModel.hasSnapshots { - Section { - Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( - L10n.RemoteNodes.RemoteNodes.History.sensorsSection - )) - .font(.subheadline) - .foregroundStyle(.secondary) + } else if let group = groups.first { + ForEach(group.charts) { chart in + chartView(for: chart) } - .themedRowBackground(theme) + } } + } + .themedRowBackground(theme) + } else if viewModel.hasSnapshots { + Section { + Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( + L10n.RemoteNodes.RemoteNodes.History.sensorsSection + )) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .themedRowBackground(theme) } - - // MARK: - Neighbors Section - - @ViewBuilder - private func neighborsSection(filtered: [NodeStatusSnapshotDTO]) -> some View { - if viewModel.hasNeighborData(in: filtered) { - let neighborCharts = buildNeighborCharts(from: filtered) - Section { - DisclosureGroup( - L10n.RemoteNodes.RemoteNodes.History.neighborsSection, - isExpanded: $neighborsExpanded - ) { - ForEach(neighborCharts, id: \.prefix) { neighbor in - MetricChartView( - title: neighbor.name, - unit: "dB", - dataPoints: neighbor.dataPoints, - accentColor: .blue - ) - } - } - } - .themedRowBackground(theme) - } else if viewModel.hasSnapshots { - Section { - Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( - L10n.RemoteNodes.RemoteNodes.History.neighborsSection - )) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .themedRowBackground(theme) + } + + // MARK: - Neighbors Section + + @ViewBuilder + private func neighborsSection(filtered: [NodeStatusSnapshotDTO]) -> some View { + if viewModel.hasNeighborData(in: filtered) { + let neighborCharts = buildNeighborCharts(from: filtered) + Section { + DisclosureGroup( + L10n.RemoteNodes.RemoteNodes.History.neighborsSection, + isExpanded: $neighborsExpanded + ) { + ForEach(neighborCharts, id: \.prefix) { neighbor in + MetricChartView( + title: neighbor.name, + unit: "dB", + dataPoints: neighbor.dataPoints, + accentColor: .blue + ) + } } + } + .themedRowBackground(theme) + } else if viewModel.hasSnapshots { + Section { + Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( + L10n.RemoteNodes.RemoteNodes.History.neighborsSection + )) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .themedRowBackground(theme) } - - // MARK: - Helpers - - private func chartView(for chart: TelemetryChartGroup) -> MetricChartView { - MetricChartView( - title: chart.title, - unit: chart.sensorType?.localizedUnitSymbol ?? "", - dataPoints: chart.dataPoints, - accentColor: chart.sensorType?.chartColor ?? .cyan, - yAxisDomain: chart.sensorType == .voltage ? viewModel.ocvArray.voltageChartDomain(dataPoints: chart.dataPoints) : nil + } + + // MARK: - Helpers + + private func chartView(for chart: TelemetryChartGroup) -> MetricChartView { + MetricChartView( + title: chart.title, + unit: chart.sensorType?.localizedUnitSymbol ?? "", + dataPoints: chart.dataPoints, + accentColor: chart.sensorType?.chartColor ?? .cyan, + yAxisDomain: chart.sensorType == .voltage ? viewModel.ocvArray.voltageChartDomain(dataPoints: chart.dataPoints) : nil + ) + } + + private func buildNeighborCharts(from filtered: [NodeStatusSnapshotDTO]) -> [NeighborChart] { + var charts: [Data: NeighborChart] = [:] + for snapshot in filtered { + for neighbor in snapshot.neighborSnapshots ?? [] { + let point = MetricChartView.DataPoint( + id: snapshot.id, date: snapshot.timestamp, value: neighbor.snr ) - } - - private func buildNeighborCharts(from filtered: [NodeStatusSnapshotDTO]) -> [NeighborChart] { - var charts: [Data: NeighborChart] = [:] - for snapshot in filtered { - for neighbor in snapshot.neighborSnapshots ?? [] { - let point = MetricChartView.DataPoint( - id: snapshot.id, date: snapshot.timestamp, value: neighbor.snr - ) - if charts[neighbor.publicKeyPrefix] != nil { - charts[neighbor.publicKeyPrefix]!.dataPoints.append(point) - } else { - let hexName = neighbor.publicKeyPrefix - .map { String(format: "%02X", $0) }.joined() - let resolvedName = viewModel.resolveNeighborName(prefix: neighbor.publicKeyPrefix) ?? hexName - charts[neighbor.publicKeyPrefix] = NeighborChart( - prefix: neighbor.publicKeyPrefix, - name: resolvedName, - dataPoints: [point] - ) - } - } + if charts[neighbor.publicKeyPrefix] != nil { + charts[neighbor.publicKeyPrefix]!.dataPoints.append(point) + } else { + let hexName = neighbor.publicKeyPrefix + .map { String(format: "%02X", $0) }.joined() + let resolvedName = viewModel.resolveNeighborName(prefix: neighbor.publicKeyPrefix) ?? hexName + charts[neighbor.publicKeyPrefix] = NeighborChart( + prefix: neighbor.publicKeyPrefix, + name: resolvedName, + dataPoints: [point] + ) } - return charts.values.sorted { $0.name < $1.name } + } } + return charts.values.sorted { $0.name < $1.name } + } - private var retentionFooter: some View { - Section { - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) - } - .themedRowBackground(theme) + private var retentionFooter: some View { + Section {} footer: { + Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) } + .themedRowBackground(theme) + } } // MARK: - Private Types private struct NeighborChart { - let prefix: Data - let name: String - var dataPoints: [MetricChartView.DataPoint] + let prefix: Data + let name: String + var dataPoints: [MetricChartView.DataPoint] } diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift index bd8896f5..2560c2ad 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift @@ -5,75 +5,89 @@ import SwiftUI @Observable @MainActor final class TelemetryHistoryOverviewViewModel { - - // MARK: - State - - private(set) var snapshots: [NodeStatusSnapshotDTO] = [] - private(set) var ocvArray: [Int] = OCVPreset.liIon.ocvArray - private(set) var contacts: [ContactDTO] = [] - private(set) var discoveredNodes: [DiscoveredNodeDTO] = [] - var timeRange: HistoryTimeRange = .default - - // MARK: - Computed - - var filteredSnapshots: [NodeStatusSnapshotDTO] { - guard let start = timeRange.startDate else { return snapshots } - return snapshots.filter { $0.timestamp >= start } + // MARK: - State + + private(set) var snapshots: [NodeStatusSnapshotDTO] = [] + private(set) var ocvArray: [Int] = OCVPreset.liIon.ocvArray + private(set) var contacts: [ContactDTO] = [] + private(set) var discoveredNodes: [DiscoveredNodeDTO] = [] + var timeRange: HistoryTimeRange = .default + + // MARK: - Computed + + var filteredSnapshots: [NodeStatusSnapshotDTO] { + guard let start = timeRange.startDate else { return snapshots } + return snapshots.filter { $0.timestamp >= start } + } + + var hasSnapshots: Bool { + !snapshots.isEmpty + } + + var hasNeighborData: Bool { + hasNeighborData(in: filteredSnapshots) + } + + var hasTelemetryData: Bool { + hasTelemetryData(in: filteredSnapshots) + } + + var channelGroups: [ChannelGroup] { + ChannelGroup.groups(from: filteredSnapshots) + } + + func hasNeighborData(in snapshots: [NodeStatusSnapshotDTO]) -> Bool { + snapshots.contains { $0.neighborSnapshots?.isEmpty == false } + } + + func hasTelemetryData(in snapshots: [NodeStatusSnapshotDTO]) -> Bool { + snapshots.contains { $0.telemetryEntries?.isEmpty == false } + } + + func hasRadioData(in snapshots: [NodeStatusSnapshotDTO]) -> Bool { + snapshots.contains { + $0.batteryMillivolts != nil || $0.lastSNR != nil || + $0.lastRSSI != nil || $0.noiseFloor != nil || + $0.packetsSent != nil || $0.packetsReceived != nil || + $0.receiveErrors != nil || + $0.sentDirect != nil || $0.sentFlood != nil || + $0.receivedDirect != nil || $0.receivedFlood != nil || + $0.directDuplicates != nil || $0.floodDuplicates != nil || + $0.postedCount != nil || $0.postPushCount != nil } + } - var hasSnapshots: Bool { !snapshots.isEmpty } + // MARK: - Loading - var hasNeighborData: Bool { - hasNeighborData(in: filteredSnapshots) + func loadData(dataStore: PersistenceStore, publicKey: Data, radioID: UUID) async { + do { + snapshots = try await dataStore.fetchNodeStatusSnapshots( + nodePublicKey: publicKey, since: nil + ) + } catch { + snapshots = [] } - var hasTelemetryData: Bool { - hasTelemetryData(in: filteredSnapshots) + do { + if let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) { + ocvArray = contact.activeOCVArray + } + } catch { + // Keep default liIon } - var channelGroups: [ChannelGroup] { - ChannelGroup.groups(from: filteredSnapshots) - } - - func hasNeighborData(in snapshots: [NodeStatusSnapshotDTO]) -> Bool { - snapshots.contains { $0.neighborSnapshots?.isEmpty == false } - } - - func hasTelemetryData(in snapshots: [NodeStatusSnapshotDTO]) -> Bool { - snapshots.contains { $0.telemetryEntries?.isEmpty == false } - } - - // MARK: - Loading - - func loadData(dataStore: PersistenceStore, publicKey: Data, radioID: UUID) async { - do { - snapshots = try await dataStore.fetchNodeStatusSnapshots( - nodePublicKey: publicKey, since: nil - ) - } catch { - snapshots = [] - } - - do { - if let contact = try await dataStore.fetchContact( - radioID: radioID, publicKey: publicKey - ) { - ocvArray = contact.activeOCVArray - } - } catch { - // Keep default liIon - } - - contacts = (try? await dataStore.fetchContacts(radioID: radioID)) ?? [] - discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] - } - - func resolveNeighborName(prefix: Data) -> String? { - NeighborNameResolver.resolveName( - for: prefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: nil - ) - } + contacts = await (try? dataStore.fetchContacts(radioID: radioID)) ?? [] + discoveredNodes = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] + } + + func resolveNeighborName(prefix: Data) -> String? { + NeighborNameResolver.resolveName( + for: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: nil + ) + } } diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryView.swift b/MC1/Views/RemoteNodes/TelemetryHistoryView.swift index f60ea361..4cba736b 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryView.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryView.swift @@ -3,60 +3,60 @@ import SwiftUI /// Drill-down view showing historical charts for telemetry metrics grouped by channel and type. struct TelemetryHistoryView: View { - @Environment(\.appTheme) private var theme - let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] - let ocvArray: [Int] + @Environment(\.appTheme) private var theme + let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] + let ocvArray: [Int] - @State private var snapshots: [NodeStatusSnapshotDTO] = [] - @State private var timeRange: HistoryTimeRange = .default + @State private var snapshots: [NodeStatusSnapshotDTO] = [] + @State private var timeRange: HistoryTimeRange = .default - private var filteredSnapshots: [NodeStatusSnapshotDTO] { - guard let start = timeRange.startDate else { return snapshots } - return snapshots.filter { $0.timestamp >= start } - } + private var filteredSnapshots: [NodeStatusSnapshotDTO] { + guard let start = timeRange.startDate else { return snapshots } + return snapshots.filter { $0.timestamp >= start } + } - var body: some View { - List { - HistoryTimeRangePicker(selection: $timeRange) + var body: some View { + List { + HistoryTimeRangePicker(selection: $timeRange) - let groups = ChannelGroup.groups(from: filteredSnapshots) - if groups.count > 1 { - ForEach(groups) { channelGroup in - Section { - ForEach(channelGroup.charts) { chart in - chartView(for: chart) - } - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Status.channel(channelGroup.channel)) - } - .themedRowBackground(theme) - } - } else if let singleGroup = groups.first { - ForEach(singleGroup.charts) { chart in - Section { - chartView(for: chart) - } - .themedRowBackground(theme) - } + let groups = ChannelGroup.groups(from: filteredSnapshots) + if groups.count > 1 { + ForEach(groups) { channelGroup in + Section { + ForEach(channelGroup.charts) { chart in + chartView(for: chart) } + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Status.channel(channelGroup.channel)) + } + .themedRowBackground(theme) } - .themedCanvas(theme) - .chartScrubbingScrollLock() - .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.telemetry) - .liquidGlassToolbarBackground() - .task { - snapshots = await fetchSnapshots() + } else if let singleGroup = groups.first { + ForEach(singleGroup.charts) { chart in + Section { + chartView(for: chart) + } + .themedRowBackground(theme) } + } } - - private func chartView(for chart: TelemetryChartGroup) -> MetricChartView { - MetricChartView( - title: chart.title, - unit: chart.sensorType?.localizedUnitSymbol ?? "", - dataPoints: chart.dataPoints, - accentColor: chart.sensorType?.chartColor ?? .cyan, - yAxisDomain: chart.sensorType == .voltage ? ocvArray.voltageChartDomain(dataPoints: chart.dataPoints) : nil - ) + .listSectionSpacing(.compact) + .themedCanvas(theme) + .chartScrubbingScrollLock() + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.telemetry) + .liquidGlassToolbarBackground() + .task { + snapshots = await fetchSnapshots() } + } + private func chartView(for chart: TelemetryChartGroup) -> MetricChartView { + MetricChartView( + title: chart.title, + unit: chart.sensorType?.localizedUnitSymbol ?? "", + dataPoints: chart.dataPoints, + accentColor: chart.sensorType?.chartColor ?? .cyan, + yAxisDomain: chart.sensorType == .voltage ? ocvArray.voltageChartDomain(dataPoints: chart.dataPoints) : nil + ) + } } diff --git a/MC1/Views/Settings/AdvancedSettingsView.swift b/MC1/Views/Settings/AdvancedSettingsView.swift index 53561fe5..8285bc01 100644 --- a/MC1/Views/Settings/AdvancedSettingsView.swift +++ b/MC1/Views/Settings/AdvancedSettingsView.swift @@ -1,171 +1,171 @@ -import SwiftUI import MC1Services +import SwiftUI /// Advanced settings sheet for power users struct AdvancedSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - @State private var selectedOCVPreset: OCVPreset = .liIon - @State private var ocvValues: [Int] = OCVPreset.liIon.ocvArray - @State private var showingImportKeySheet = false - @State private var showingRegenerateSheet = false - - var body: some View { - List { - // Manual Radio Configuration - AdvancedRadioSection() - - // Path Hash Mode (firmware v10+) - if appState.connectedDevice?.supportsPathHashMode == true { - PathHashModeSection() - } - - // Default Flood Scope (firmware v11+) - if appState.connectedDevice?.supportsDefaultFloodScope == true { - DefaultFloodScopeSection() - } - - // Nodes Settings - NodesSettingsSection() - - // Auto-Remove Old Nodes - StaleNodeCleanupSection() - - // Telemetry Settings - TelemetrySettingsSection() - - // Direct Messages Settings - DirectMessagesSettingsSection() - - // Battery Curve - BatteryCurveSection( - availablePresets: OCVPreset.selectablePresets, - headerText: L10n.Settings.BatteryCurve.header, - footerText: L10n.Settings.BatteryCurve.footer, - selectedPreset: $selectedOCVPreset, - voltageValues: $ocvValues, - onSave: saveOCVToDevice, - isDisabled: appState.connectionState != .ready - ) - - // Config Export/Import - ConfigExportImportSection() - - // Device Actions - DeviceActionsSection() - - // Identity - DeviceIdentitySection( - showingImportKeySheet: $showingImportKeySheet, - showingRegenerateSheet: $showingRegenerateSheet - ) - - // Danger Zone - DangerZoneSection() - } - .themedCanvas(theme) - .settingsSubpageDestinations() - .sheet(isPresented: $showingImportKeySheet) { - ImportKeySheet() - } - .sheet(isPresented: $showingRegenerateSheet) { - RegenerateIdentitySheet() - } - .scrollDismissesKeyboard(.interactively) - .navigationTitle(L10n.Settings.AdvancedSettings.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.Localizable.Common.done) { - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - } - } - } - .task(id: refreshTaskID) { - await refreshDeviceSettings() - } - .task(id: appState.connectedDevice?.id) { - loadOCVFromDevice() - } - .onChange(of: appState.connectedDevice) { _, newDevice in - if newDevice == nil { - dismiss() - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + + @State private var selectedOCVPreset: OCVPreset = .liIon + @State private var ocvValues: [Int] = OCVPreset.liIon.ocvArray + @State private var showingImportKeySheet = false + @State private var showingRegenerateSheet = false + + var body: some View { + List { + // Manual Radio Configuration + AdvancedRadioSection() + + // Path Hash Mode (firmware v10+) + if appState.connectedDevice?.supportsPathHashMode == true { + PathHashModeSection() + } + + // Default Flood Scope (firmware v11+) + if appState.connectedDevice?.supportsDefaultFloodScope == true { + DefaultFloodScopeSection() + } + + // Nodes Settings + NodesSettingsSection() + + // Auto-Remove Old Nodes + StaleNodeCleanupSection() + + // Telemetry Settings + TelemetrySettingsSection() + + // Direct Messages Settings + DirectMessagesSettingsSection() + + // Battery Curve + BatteryCurveSection( + availablePresets: OCVPreset.selectablePresets, + headerText: L10n.Settings.BatteryCurve.header, + footerText: L10n.Settings.BatteryCurve.footer, + selectedPreset: $selectedOCVPreset, + voltageValues: $ocvValues, + onSave: saveOCVToDevice, + isDisabled: appState.connectionState != .ready + ) + + // Config Export/Import + ConfigExportImportSection() + + // Device Actions + DeviceActionsSection() + + // Identity + DeviceIdentitySection( + showingImportKeySheet: $showingImportKeySheet, + showingRegenerateSheet: $showingRegenerateSheet + ) + + // Danger Zone + DangerZoneSection() } - - private var refreshTaskID: String { - let deviceID = appState.connectedDevice?.id.uuidString ?? "none" - let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" - return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)" + .themedCanvas(theme) + .settingsSubpageDestinations() + .sheet(isPresented: $showingImportKeySheet) { + ImportKeySheet() } - - /// Fetch fresh device settings to ensure cache is up-to-date - private func refreshDeviceSettings() async { - // Wait until contact/channel sync contention is over before sending startup reads. - guard appState.canRunSettingsStartupReads, - let settingsService = appState.services?.settingsService else { return } - _ = try? await settingsService.getSelfInfo() - - // Only refresh autoAddConfig on v1.12+ firmware - if appState.connectedDevice?.supportsAutoAddConfig == true { - try? await settingsService.refreshAutoAddConfig() - } - - if appState.connectedDevice?.supportsDefaultFloodScope == true { - _ = try? await settingsService.getDefaultFloodScope() + .sheet(isPresented: $showingRegenerateSheet) { + RegenerateIdentitySheet() + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle(L10n.Settings.AdvancedSettings.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.Localizable.Common.done) { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) } + } + } + .task(id: refreshTaskID) { + await refreshDeviceSettings() + } + .task(id: appState.connectedDevice?.id) { + loadOCVFromDevice() + } + .onChange(of: appState.connectedDevice) { _, newDevice in + if newDevice == nil { + dismiss() + } + } + } + + private var refreshTaskID: String { + let deviceID = appState.connectedDevice?.id.uuidString ?? "none" + let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" + return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)" + } + + /// Fetch fresh device settings to ensure cache is up-to-date + private func refreshDeviceSettings() async { + // Wait until contact/channel sync contention is over before sending startup reads. + guard appState.canRunSettingsStartupReads, + let settingsService = appState.services?.settingsService else { return } + _ = try? await settingsService.getSelfInfo() + + // Only refresh autoAddConfig on v1.12+ firmware + if appState.connectedDevice?.supportsAutoAddConfig == true { + try? await settingsService.refreshAutoAddConfig() } - private func loadOCVFromDevice() { - guard let device = appState.connectedDevice else { return } - - if let presetName = device.ocvPreset { - if presetName == OCVPreset.custom.rawValue, let customString = device.customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - ocvValues = parsed - selectedOCVPreset = .custom - return - } - } - if let preset = OCVPreset(rawValue: presetName) { - selectedOCVPreset = preset - ocvValues = preset.ocvArray - return - } + if appState.connectedDevice?.supportsDefaultFloodScope == true { + _ = try? await settingsService.getDefaultFloodScope() + } + } + + private func loadOCVFromDevice() { + guard let device = appState.connectedDevice else { return } + + if let presetName = device.ocvPreset { + if presetName == OCVPreset.custom.rawValue, let customString = device.customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + ocvValues = parsed + selectedOCVPreset = .custom + return } - - selectedOCVPreset = .liIon - ocvValues = OCVPreset.liIon.ocvArray + } + if let preset = OCVPreset(rawValue: presetName) { + selectedOCVPreset = preset + ocvValues = preset.ocvArray + return + } } - private func saveOCVToDevice(preset: OCVPreset, values: [Int]) async { - guard let deviceService = appState.services?.deviceService, - let deviceID = appState.connectedDevice?.id else { return } - - if preset == .custom { - let customString = values.map(String.init).joined(separator: ",") - try? await deviceService.updateOCVSettings( - deviceID: deviceID, - preset: OCVPreset.custom.rawValue, - customArray: customString - ) - } else { - try? await deviceService.updateOCVSettings( - deviceID: deviceID, - preset: preset.rawValue, - customArray: nil - ) - } + selectedOCVPreset = .liIon + ocvValues = OCVPreset.liIon.ocvArray + } + + private func saveOCVToDevice(preset: OCVPreset, values: [Int]) async { + guard let deviceService = appState.services?.deviceService, + let deviceID = appState.connectedDevice?.id else { return } + + if preset == .custom { + let customString = values.map(String.init).joined(separator: ",") + try? await deviceService.updateOCVSettings( + deviceID: deviceID, + preset: OCVPreset.custom.rawValue, + customArray: customString + ) + } else { + try? await deviceService.updateOCVSettings( + deviceID: deviceID, + preset: preset.rawValue, + customArray: nil + ) } + } } diff --git a/MC1/Views/Settings/AppBackupDocument.swift b/MC1/Views/Settings/AppBackupDocument.swift index aa2000fa..1067c4b8 100644 --- a/MC1/Views/Settings/AppBackupDocument.swift +++ b/MC1/Views/Settings/AppBackupDocument.swift @@ -2,26 +2,28 @@ import SwiftUI import UniformTypeIdentifiers extension UTType { - static let mc1Backup = UTType(exportedAs: "com.pocketmesh.mc1.backup") + static let mc1Backup = UTType(exportedAs: "com.pocketmesh.mc1.backup") } struct AppBackupDocument: FileDocument { - static var readableContentTypes: [UTType] { [.mc1Backup] } + static var readableContentTypes: [UTType] { + [.mc1Backup] + } - let data: Data + let data: Data - init(data: Data) { - self.data = data - } + init(data: Data) { + self.data = data + } - init(configuration: ReadConfiguration) throws { - guard let data = configuration.file.regularFileContents else { - throw CocoaError(.fileReadCorruptFile) - } - self.data = data + init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) } + self.data = data + } - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - FileWrapper(regularFileWithContents: data) - } + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } } diff --git a/MC1/Views/Settings/AppBackupError+Localized.swift b/MC1/Views/Settings/AppBackupError+Localized.swift index 392f2a35..07706cf0 100644 --- a/MC1/Views/Settings/AppBackupError+Localized.swift +++ b/MC1/Views/Settings/AppBackupError+Localized.swift @@ -2,28 +2,28 @@ import Foundation import MC1Services extension AppBackupError { - /// L10n-routed user-facing message for backup errors. The package-level - /// `errorDescription` provides an English fallback so MC1Services stays - /// independent of the app target's L10n; views should prefer this property. - var userFacingMessage: String { - switch self { - case .invalidFile: - return L10n.Settings.Settings.Backup.Error.invalidFile - case .fileTooLarge(let actualBytes, let maxBytes): - let actualMB = actualBytes / 1_048_576 - let maxMB = maxBytes / 1_048_576 - return L10n.Settings.Settings.Backup.Error.fileTooLarge(actualMB, maxMB) - case .decompressedTooLarge(let maxBytes): - let maxMB = maxBytes / 1_048_576 - return L10n.Settings.Settings.Backup.Error.decompressedTooLarge(maxMB) - case .unsupportedVersion(let found, let maxSupported): - return L10n.Settings.Settings.Backup.Error.unsupportedVersion(found, maxSupported) - case .corruptedManifest: - return L10n.Settings.Settings.Backup.Error.corruptedManifest - case .exportFailed(let underlying): - return L10n.Settings.Settings.Backup.Error.exportFailed(underlying.userFacingMessage) - case .importFailed(let underlying): - return L10n.Settings.Settings.Backup.Error.importFailed(underlying.userFacingMessage) - } + /// L10n-routed user-facing message for backup errors. The package-level + /// `errorDescription` provides an English fallback so MC1Services stays + /// independent of the app target's L10n; views should prefer this property. + var userFacingMessage: String { + switch self { + case .invalidFile: + return L10n.Settings.Settings.Backup.Error.invalidFile + case let .fileTooLarge(actualBytes, maxBytes): + let actualMB = actualBytes / 1_048_576 + let maxMB = maxBytes / 1_048_576 + return L10n.Settings.Settings.Backup.Error.fileTooLarge(actualMB, maxMB) + case let .decompressedTooLarge(maxBytes): + let maxMB = maxBytes / 1_048_576 + return L10n.Settings.Settings.Backup.Error.decompressedTooLarge(maxMB) + case let .unsupportedVersion(found, maxSupported): + return L10n.Settings.Settings.Backup.Error.unsupportedVersion(found, maxSupported) + case .corruptedManifest: + return L10n.Settings.Settings.Backup.Error.corruptedManifest + case let .exportFailed(underlying): + return L10n.Settings.Settings.Backup.Error.exportFailed(underlying.userFacingMessage) + case let .importFailed(underlying): + return L10n.Settings.Settings.Backup.Error.importFailed(underlying.userFacingMessage) } + } } diff --git a/MC1/Views/Settings/AppBackupViewModel.swift b/MC1/Views/Settings/AppBackupViewModel.swift index 56104eb7..e91768ec 100644 --- a/MC1/Views/Settings/AppBackupViewModel.swift +++ b/MC1/Views/Settings/AppBackupViewModel.swift @@ -7,320 +7,320 @@ import os /// (e.g. `isImporting && importResult != nil`) unrepresentable, and /// separates the sheet-scoped error surface from the top-level alert. enum ImportState { - case idle - case parsing - case preview(AppBackupEnvelope) - case importing - case success(ImportResult) - case failed(String) - case cancelled + case idle + case parsing + case preview(AppBackupEnvelope) + case importing + case success(ImportResult) + case failed(String) + case cancelled } /// Export flow state. Single owner of the export lifecycle so the /// `.fileExporter` binding can be a pure read of the current case and /// `handleExportResult` is the only code that transitions state. enum ExportState { - case idle - case exporting - case pending(AppBackupViewModel.PendingExport) - case success(AppBackupViewModel.ExportSuccessSummary) + case idle + case exporting + case pending(AppBackupViewModel.PendingExport) + case success(AppBackupViewModel.ExportSuccessSummary) } @Observable @MainActor final class AppBackupViewModel { - // MARK: - Nested types - - /// Snapshot of an in-flight export waiting for the system save picker to return. - struct PendingExport { - let data: Data - let manifest: BackupManifest - } - - struct ExportSuccessSummary: Identifiable, Equatable { - let id = UUID() - let filename: String - let byteCount: Int - let manifest: BackupManifest - } - - // MARK: - Export state - - var exportState: ExportState = .idle - var errorMessage: String? - - var isExporting: Bool { - if case .exporting = exportState { true } else { false } + // MARK: - Nested types + + /// Snapshot of an in-flight export waiting for the system save picker to return. + struct PendingExport { + let data: Data + let manifest: BackupManifest + } + + struct ExportSuccessSummary: Identifiable, Equatable { + let id = UUID() + let filename: String + let byteCount: Int + let manifest: BackupManifest + } + + // MARK: - Export state + + var exportState: ExportState = .idle + var errorMessage: String? + + var isExporting: Bool { + if case .exporting = exportState { true } else { false } + } + + var pendingExport: PendingExport? { + if case let .pending(pending) = exportState { pending } else { nil } + } + + var exportSummary: ExportSuccessSummary? { + if case let .success(summary) = exportState { summary } else { nil } + } + + // MARK: - Import state + + var importState: ImportState = .idle + /// Latched to `true` the instant the user taps Cancel during an active import, + /// so the UI can acknowledge the tap before the import task reaches a + /// cancellation point and actually tears down. + var isCancellingImport = false + private var parseTask: Task? + private var currentParseID: UUID? + private var importTask: Task? + + /// Computed view of the import flow. `.parsing` is treated as pre-sheet + /// so the spinner in the import row is the only visible cue until a + /// preview is ready; the sheet appears once state advances past parsing. + var isImportSheetActive: Bool { + switch importState { + case .idle, .parsing: false + case .preview, .importing, .success, .failed, .cancelled: true } - - var pendingExport: PendingExport? { - if case .pending(let pending) = exportState { pending } else { nil } - } - - var exportSummary: ExportSuccessSummary? { - if case .success(let summary) = exportState { summary } else { nil } - } - - // MARK: - Import state - - var importState: ImportState = .idle - /// Latched to `true` the instant the user taps Cancel during an active import, - /// so the UI can acknowledge the tap before the import task reaches a - /// cancellation point and actually tears down. - var isCancellingImport = false - private var parseTask: Task? - private var currentParseID: UUID? - private var importTask: Task? - - /// Computed view of the import flow. `.parsing` is treated as pre-sheet - /// so the spinner in the import row is the only visible cue until a - /// preview is ready; the sheet appears once state advances past parsing. - var isImportSheetActive: Bool { - switch importState { - case .idle, .parsing: false - case .preview, .importing, .success, .failed, .cancelled: true - } - } - - var isParsing: Bool { - if case .parsing = importState { true } else { false } - } - - var isImporting: Bool { - if case .importing = importState { true } else { false } - } - - var isCancelled: Bool { - if case .cancelled = importState { true } else { false } - } - - var previewEnvelope: AppBackupEnvelope? { - if case .preview(let env) = importState { env } else { nil } - } - - var importResult: ImportResult? { - if case .success(let result) = importState { result } else { nil } - } - - /// Error surfaced inside the import sheet (import-phase failure). Kept - /// separate from `errorMessage`, which is reserved for top-level alerts - /// shown when no sheet is active. - var sheetErrorMessage: String? { - if case .failed(let msg) = importState { msg } else { nil } - } - - // MARK: - Dependencies - - private let connectionManager: ConnectionManager - private let onImportRestoredData: (@MainActor () -> Void)? - private let onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? - private let backupService = AppBackupService() - private let logger = Logger(subsystem: "com.mc1", category: "AppBackupViewModel") - - init( - connectionManager: ConnectionManager, - onImportRestoredData: (@MainActor () -> Void)? = nil, - onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? = nil - ) { - self.connectionManager = connectionManager - self.onImportRestoredData = onImportRestoredData - self.onChannelDraftSlotsAffected = onChannelDraftSlotsAffected - } - - // MARK: - Export - - func performExport() { - guard case .idle = exportState else { return } - exportState = .exporting - errorMessage = nil - - Task { - do { - let result = try await backupService.export( - persistenceStore: preferredPersistenceStore() - ) - exportState = .pending(PendingExport(data: result.data, manifest: result.manifest)) - } catch { - exportState = .idle - errorMessage = error.userFacingMessage - logger.error("Export failed: \(error.localizedDescription)") - } - } - } - - func handleExportResult(_ result: Result) { - guard case .pending(let pending) = exportState else { return } - - switch result { - case .success(let url): - exportState = .success(ExportSuccessSummary( - filename: url.lastPathComponent, - byteCount: pending.data.count, - manifest: pending.manifest - )) - case .failure(let error): - exportState = .idle - guard !isUserCancelled(error) else { return } - errorMessage = error.userFacingMessage - } - } - - func dismissExportSuccess() { - guard case .success = exportState else { return } + } + + var isParsing: Bool { + if case .parsing = importState { true } else { false } + } + + var isImporting: Bool { + if case .importing = importState { true } else { false } + } + + var isCancelled: Bool { + if case .cancelled = importState { true } else { false } + } + + var previewEnvelope: AppBackupEnvelope? { + if case let .preview(env) = importState { env } else { nil } + } + + var importResult: ImportResult? { + if case let .success(result) = importState { result } else { nil } + } + + /// Error surfaced inside the import sheet (import-phase failure). Kept + /// separate from `errorMessage`, which is reserved for top-level alerts + /// shown when no sheet is active. + var sheetErrorMessage: String? { + if case let .failed(msg) = importState { msg } else { nil } + } + + // MARK: - Dependencies + + private let connectionManager: ConnectionManager + private let onImportRestoredData: (@MainActor () -> Void)? + private let onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? + private let backupService = AppBackupService() + private let logger = Logger(subsystem: "com.mc1", category: "AppBackupViewModel") + + init( + connectionManager: ConnectionManager, + onImportRestoredData: (@MainActor () -> Void)? = nil, + onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? = nil + ) { + self.connectionManager = connectionManager + self.onImportRestoredData = onImportRestoredData + self.onChannelDraftSlotsAffected = onChannelDraftSlotsAffected + } + + // MARK: - Export + + func performExport() { + guard case .idle = exportState else { return } + exportState = .exporting + errorMessage = nil + + Task { + do { + let result = try await backupService.export( + persistenceStore: preferredPersistenceStore() + ) + exportState = .pending(PendingExport(data: result.data, manifest: result.manifest)) + } catch { exportState = .idle + errorMessage = error.userFacingMessage + logger.error("Export failed: \(error.localizedDescription)") + } } - - // MARK: - Import - - func handleFileSelected(result: Result) { - switch result { - case .success(let url): - loadAndParseBackup(from: url) - case .failure(let error): - guard !isUserCancelled(error) else { return } - errorMessage = error.userFacingMessage - } - } - - func loadAndParseBackup(from url: URL) { - parseTask?.cancel() - importState = .parsing - let taskID = UUID() - currentParseID = taskID - let didAccessSecurityScope = url.startAccessingSecurityScopedResource() - - parseTask = Task.detached(priority: .userInitiated) { [weak self, url, didAccessSecurityScope] in - defer { - if didAccessSecurityScope { - url.stopAccessingSecurityScopedResource() - } - } - do { - // .mappedIfSafe lets the OS page-cache the read rather than - // copying the whole file into the heap. - let data = try Data(contentsOf: url, options: .mappedIfSafe) - try Task.checkCancellation() - let envelope = try parseBackup(data: data) - try Task.checkCancellation() - await self?.applyParseSuccess(envelope, for: taskID) - } catch is CancellationError { - // The new invocation owns importState; don't clobber it - return - } catch { - await self?.applyParseFailure(error.userFacingMessage, for: taskID) - } - } - } - - private func applyParseSuccess(_ envelope: AppBackupEnvelope, for taskID: UUID) { - guard currentParseID == taskID else { return } - importState = .preview(envelope) - errorMessage = nil + } + + func handleExportResult(_ result: Result) { + guard case let .pending(pending) = exportState else { return } + + switch result { + case let .success(url): + exportState = .success(ExportSuccessSummary( + filename: url.lastPathComponent, + byteCount: pending.data.count, + manifest: pending.manifest + )) + case let .failure(error): + exportState = .idle + guard !isUserCancelled(error) else { return } + errorMessage = error.userFacingMessage } - - /// Parse failure happens before the sheet opens, so surface via the top-level - /// alert, not the in-sheet error view. - private func applyParseFailure(_ message: String, for taskID: UUID) { - guard currentParseID == taskID else { return } - importState = .idle - errorMessage = message + } + + func dismissExportSuccess() { + guard case .success = exportState else { return } + exportState = .idle + } + + // MARK: - Import + + func handleFileSelected(result: Result) { + switch result { + case let .success(url): + loadAndParseBackup(from: url) + case let .failure(error): + guard !isUserCancelled(error) else { return } + errorMessage = error.userFacingMessage } - - func performImport() { - guard case .preview(let envelope) = importState else { return } - // Re-check the connection at the commit point, not only via the disabled import row: - // a foreground auto-reconnect can connect the radio while the user reads the preview. - // Bind the import to a standalone store so its rollback-on-failure runs on its own - // per-actor ModelContext and can never discard the live sync store's pending writes. - guard !connectionManager.connectionState.isConnected else { - dismissImportSheet() - return - } - let store = connectionManager.createStandalonePersistenceStore() - isCancellingImport = false - importState = .importing - - importTask = Task { - defer { importTask = nil } - do { - let result = try await backupService.importBackup( - envelope: envelope, - into: store - ) - importState = .success(result) - // Channel relocation during import can change which channel occupies a - // slot; clear drafts for the affected slots before they are re-entered. - if !result.channelSlotsAffectedByImport.isEmpty { - onChannelDraftSlotsAffected?(result.channelSlotsAffectedByImport) - } - if result.hasRestoredChanges { - // The import wrote directly to the persistence store, bypassing the - // sync-path callbacks that normally bump contacts/conversations - // versions. Fire the notifier so any mounted chat/contact tabs - // reload their data rather than show the pre-restore snapshot. - onImportRestoredData?() - // SyncCoordinator caches blocked names at connect time; restored - // BlockedChannelSender rows and blocked contacts would otherwise be - // ignored by the incoming-message filter until reconnect. - if let services = connectionManager.services, - let radioID = connectionManager.lastConnectedRadioID { - await services.syncCoordinator.refreshBlockedContactsCache( - radioID: radioID, - dataStore: services.dataStore - ) - } - } - } catch is CancellationError { - // Rollback is guaranteed by the `defer` in `importBackupDatabase`. - // Surface a terminal cancelled state so the user sees that nothing - // was changed instead of the sheet closing silently. - importState = .cancelled - } catch { - importState = .failed(error.userFacingMessage) - logger.error("Import failed: \(error.localizedDescription)") - } + } + + func loadAndParseBackup(from url: URL) { + parseTask?.cancel() + importState = .parsing + let taskID = UUID() + currentParseID = taskID + let didAccessSecurityScope = url.startAccessingSecurityScopedResource() + + parseTask = Task.detached(priority: .userInitiated) { [weak self, url, didAccessSecurityScope] in + defer { + if didAccessSecurityScope { + url.stopAccessingSecurityScopedResource() } + } + do { + // .mappedIfSafe lets the OS page-cache the read rather than + // copying the whole file into the heap. + let data = try Data(contentsOf: url, options: .mappedIfSafe) + try Task.checkCancellation() + let envelope = try parseBackup(data: data) + try Task.checkCancellation() + await self?.applyParseSuccess(envelope, for: taskID) + } catch is CancellationError { + // The new invocation owns importState; don't clobber it + return + } catch { + await self?.applyParseFailure(error.userFacingMessage, for: taskID) + } } - - func cancelImport() { - guard !isCancellingImport else { return } - isCancellingImport = true - importTask?.cancel() - } - - func dismissImportSheet() { - parseTask?.cancel() - parseTask = nil - currentParseID = nil - importState = .idle - isCancellingImport = false - errorMessage = nil + } + + private func applyParseSuccess(_ envelope: AppBackupEnvelope, for taskID: UUID) { + guard currentParseID == taskID else { return } + importState = .preview(envelope) + errorMessage = nil + } + + /// Parse failure happens before the sheet opens, so surface via the top-level + /// alert, not the in-sheet error view. + private func applyParseFailure(_ message: String, for taskID: UUID) { + guard currentParseID == taskID else { return } + importState = .idle + errorMessage = message + } + + func performImport() { + guard case let .preview(envelope) = importState else { return } + // Re-check the connection at the commit point, not only via the disabled import row: + // a foreground auto-reconnect can connect the radio while the user reads the preview. + // Bind the import to a standalone store so its rollback-on-failure runs on its own + // per-actor ModelContext and can never discard the live sync store's pending writes. + guard !connectionManager.connectionState.isConnected else { + dismissImportSheet() + return } - - // MARK: - Export file name - - var defaultExportFilename: String { - let timestamp = Date.now.formatted( - .iso8601 - .year() - .month() - .day() - .dateSeparator(.dash) - .dateTimeSeparator(.space) - .time(includingFractionalSeconds: false) - .timeSeparator(.omitted) + let store = connectionManager.createStandalonePersistenceStore() + isCancellingImport = false + importState = .importing + + importTask = Task { + defer { importTask = nil } + do { + let result = try await backupService.importBackup( + envelope: envelope, + into: store ) - return L10n.Settings.Settings.Backup.Export.defaultFilename(timestamp) - } - - private func preferredPersistenceStore() -> PersistenceStore { - // Reuse the live store actor when services are active so backup work serializes - // with the app's authoritative writer instead of creating a second store actor. - connectionManager.services?.dataStore ?? connectionManager.createStandalonePersistenceStore() - } - - private func isUserCancelled(_ error: any Error) -> Bool { - let nsError = error as NSError - return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + importState = .success(result) + // Channel relocation during import can change which channel occupies a + // slot; clear drafts for the affected slots before they are re-entered. + if !result.channelSlotsAffectedByImport.isEmpty { + onChannelDraftSlotsAffected?(result.channelSlotsAffectedByImport) + } + if result.hasRestoredChanges { + // The import wrote directly to the persistence store, bypassing the + // sync-path callbacks that normally bump contacts/conversations + // versions. Fire the notifier so any mounted chat/contact tabs + // reload their data rather than show the pre-restore snapshot. + onImportRestoredData?() + // SyncCoordinator caches blocked names at connect time; restored + // BlockedChannelSender rows and blocked contacts would otherwise be + // ignored by the incoming-message filter until reconnect. + if let services = connectionManager.services, + let radioID = connectionManager.lastConnectedRadioID { + await services.syncCoordinator.refreshBlockedContactsCache( + radioID: radioID, + dataStore: services.dataStore + ) + } + } + } catch is CancellationError { + // Rollback is guaranteed by the `defer` in `importBackupDatabase`. + // Surface a terminal cancelled state so the user sees that nothing + // was changed instead of the sheet closing silently. + importState = .cancelled + } catch { + importState = .failed(error.userFacingMessage) + logger.error("Import failed: \(error.localizedDescription)") + } } + } + + func cancelImport() { + guard !isCancellingImport else { return } + isCancellingImport = true + importTask?.cancel() + } + + func dismissImportSheet() { + parseTask?.cancel() + parseTask = nil + currentParseID = nil + importState = .idle + isCancellingImport = false + errorMessage = nil + } + + // MARK: - Export file name + + var defaultExportFilename: String { + let timestamp = Date.now.formatted( + .iso8601 + .year() + .month() + .day() + .dateSeparator(.dash) + .dateTimeSeparator(.space) + .time(includingFractionalSeconds: false) + .timeSeparator(.omitted) + ) + return L10n.Settings.Settings.Backup.Export.defaultFilename(timestamp) + } + + private func preferredPersistenceStore() -> PersistenceStore { + // Reuse the live store actor when services are active so backup work serializes + // with the app's authoritative writer instead of creating a second store actor. + connectionManager.services?.dataStore ?? connectionManager.createStandalonePersistenceStore() + } + + private func isUserCancelled(_ error: any Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + } } diff --git a/MC1/Views/Settings/BackupModelKind+Label.swift b/MC1/Views/Settings/BackupModelKind+Label.swift index 41ee9d02..114b3a37 100644 --- a/MC1/Views/Settings/BackupModelKind+Label.swift +++ b/MC1/Views/Settings/BackupModelKind+Label.swift @@ -1,22 +1,22 @@ import MC1Services extension BackupModelKind { - /// Localized display label used by backup preview, import-success, and - /// export-success rows. Lives in the MC1 target because `L10n` is - /// generated here by SwiftGen and is not visible from MC1Services. - var label: String { - switch self { - case .messages: L10n.Settings.Settings.Backup.Import.Preview.messages - case .contacts: L10n.Settings.Settings.Backup.Import.Preview.contacts - case .channels: L10n.Settings.Settings.Backup.Import.Preview.channels - case .devices: L10n.Settings.Settings.Backup.Import.Preview.devices - case .roomMessages: L10n.Settings.Settings.Backup.Import.Preview.roomMessages - case .reactions: L10n.Settings.Settings.Backup.Import.Preview.reactions - case .messageRepeats: L10n.Settings.Settings.Backup.Import.Preview.messageRepeats - case .savedTracePaths: L10n.Settings.Settings.Backup.Import.Preview.savedPaths - case .remoteNodeSessions: L10n.Settings.Settings.Backup.Import.Preview.remoteNodeSessions - case .blockedChannelSenders: L10n.Settings.Settings.Backup.Import.Preview.blockedSenders - case .nodeStatusSnapshots: L10n.Settings.Settings.Backup.Import.Preview.nodeStatusSnapshots - } + /// Localized display label used by backup preview, import-success, and + /// export-success rows. Lives in the MC1 target because `L10n` is + /// generated here by SwiftGen and is not visible from MC1Services. + var label: String { + switch self { + case .messages: L10n.Settings.Settings.Backup.Import.Preview.messages + case .contacts: L10n.Settings.Settings.Backup.Import.Preview.contacts + case .channels: L10n.Settings.Settings.Backup.Import.Preview.channels + case .devices: L10n.Settings.Settings.Backup.Import.Preview.devices + case .roomMessages: L10n.Settings.Settings.Backup.Import.Preview.roomMessages + case .reactions: L10n.Settings.Settings.Backup.Import.Preview.reactions + case .messageRepeats: L10n.Settings.Settings.Backup.Import.Preview.messageRepeats + case .savedTracePaths: L10n.Settings.Settings.Backup.Import.Preview.savedPaths + case .remoteNodeSessions: L10n.Settings.Settings.Backup.Import.Preview.remoteNodeSessions + case .blockedChannelSenders: L10n.Settings.Settings.Backup.Import.Preview.blockedSenders + case .nodeStatusSnapshots: L10n.Settings.Settings.Backup.Import.Preview.nodeStatusSnapshots } + } } diff --git a/MC1/Views/Settings/BackupRestoreView.swift b/MC1/Views/Settings/BackupRestoreView.swift index 3770636a..f89b861e 100644 --- a/MC1/Views/Settings/BackupRestoreView.swift +++ b/MC1/Views/Settings/BackupRestoreView.swift @@ -1,149 +1,149 @@ -import SwiftUI import MC1Services +import SwiftUI struct BackupRestoreView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel: AppBackupViewModel - @State private var showExportConfirmation = false - @State private var showFileImporter = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel: AppBackupViewModel + @State private var showExportConfirmation = false + @State private var showFileImporter = false - init( - connectionManager: ConnectionManager, - onImportRestoredData: (@MainActor () -> Void)? = nil, - onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? = nil - ) { - _viewModel = State(initialValue: AppBackupViewModel( - connectionManager: connectionManager, - onImportRestoredData: onImportRestoredData, - onChannelDraftSlotsAffected: onChannelDraftSlotsAffected - )) - } + init( + connectionManager: ConnectionManager, + onImportRestoredData: (@MainActor () -> Void)? = nil, + onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? = nil + ) { + _viewModel = State(initialValue: AppBackupViewModel( + connectionManager: connectionManager, + onImportRestoredData: onImportRestoredData, + onChannelDraftSlotsAffected: onChannelDraftSlotsAffected + )) + } - var body: some View { - List { - Section { - exportRow - importRow - } header: { - Text(L10n.Settings.Settings.Backup.FileBackup.header) - } footer: { - VStack(alignment: .leading, spacing: 8) { - if appState.connectionState.isConnected { - Text(L10n.Settings.Settings.Backup.Import.disabledWhenConnected) - } - Text(L10n.Settings.Settings.Backup.FileBackup.footer) - } - } - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Settings.Backup.title) - .alert(L10n.Settings.Settings.Backup.Export.Alert.title, isPresented: $showExportConfirmation) { - Button(L10n.Settings.Settings.Backup.Export.Alert.cancel, role: .cancel) {} - Button(L10n.Settings.Settings.Backup.Export.Alert.export) { - viewModel.performExport() - } - } message: { - Text(L10n.Settings.Settings.Backup.Export.Alert.message) + var body: some View { + List { + Section { + exportRow + importRow + } header: { + Text(L10n.Settings.Settings.Backup.FileBackup.header) + } footer: { + VStack(alignment: .leading, spacing: 8) { + if appState.connectionState.isConnected { + Text(L10n.Settings.Settings.Backup.Import.disabledWhenConnected) + } + Text(L10n.Settings.Settings.Backup.FileBackup.footer) } - .fileExporter( - isPresented: Binding( - get: { viewModel.pendingExport != nil }, - // A dismissal that only writes the binding must still resolve the pending - // export, or the export row stays wedged for the rest of the session. - // handleExportResult is idempotent, so a later onCompletion is a no-op. - set: { isPresented in - if !isPresented, viewModel.pendingExport != nil { - viewModel.handleExportResult(.failure(CocoaError(.userCancelled))) - } - } - ), - document: exportDocument, - contentType: .mc1Backup, - defaultFilename: viewModel.defaultExportFilename - ) { result in - viewModel.handleExportResult(result) - } - .fileImporter( - isPresented: $showFileImporter, - allowedContentTypes: [.mc1Backup] - ) { result in - viewModel.handleFileSelected(result: result) - } - .sheet(isPresented: Binding( - get: { viewModel.isImportSheetActive }, - set: { if !$0 { viewModel.dismissImportSheet() } } - )) { - ImportPreviewSheet(viewModel: viewModel) - } - .sheet(item: Binding( - get: { viewModel.exportSummary }, - set: { if $0 == nil { viewModel.dismissExportSuccess() } } - )) { summary in - NavigationStack { - ExportSuccessContent(summary: summary, onDismiss: viewModel.dismissExportSuccess) - } + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Settings.Backup.title) + .alert(L10n.Settings.Settings.Backup.Export.Alert.title, isPresented: $showExportConfirmation) { + Button(L10n.Settings.Settings.Backup.Export.Alert.cancel, role: .cancel) {} + Button(L10n.Settings.Settings.Backup.Export.Alert.export) { + viewModel.performExport() + } + } message: { + Text(L10n.Settings.Settings.Backup.Export.Alert.message) + } + .fileExporter( + isPresented: Binding( + get: { viewModel.pendingExport != nil }, + // A dismissal that only writes the binding must still resolve the pending + // export, or the export row stays wedged for the rest of the session. + // handleExportResult is idempotent, so a later onCompletion is a no-op. + set: { isPresented in + if !isPresented, viewModel.pendingExport != nil { + viewModel.handleExportResult(.failure(CocoaError(.userCancelled))) + } } - .errorAlert($viewModel.errorMessage) + ), + document: exportDocument, + contentType: .mc1Backup, + defaultFilename: viewModel.defaultExportFilename + ) { result in + viewModel.handleExportResult(result) + } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.mc1Backup] + ) { result in + viewModel.handleFileSelected(result: result) + } + .sheet(isPresented: Binding( + get: { viewModel.isImportSheetActive }, + set: { if !$0 { viewModel.dismissImportSheet() } } + )) { + ImportPreviewSheet(viewModel: viewModel) + } + .sheet(item: Binding( + get: { viewModel.exportSummary }, + set: { if $0 == nil { viewModel.dismissExportSuccess() } } + )) { summary in + NavigationStack { + ExportSuccessContent(summary: summary, onDismiss: viewModel.dismissExportSuccess) + } } + .errorAlert($viewModel.errorMessage) + } - // MARK: - Rows + // MARK: - Rows - private var exportRow: some View { - Button { - showExportConfirmation = true - } label: { - HStack { - if viewModel.isExporting { - ProgressView() - Text(L10n.Settings.Settings.Backup.Export.progress) - } else { - Label(L10n.Settings.Settings.Backup.Export.title, systemImage: "square.and.arrow.up") - } - Spacer() - Text(L10n.Settings.Settings.Backup.Export.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } + private var exportRow: some View { + Button { + showExportConfirmation = true + } label: { + HStack { + if viewModel.isExporting { + ProgressView() + Text(L10n.Settings.Settings.Backup.Export.progress) + } else { + Label(L10n.Settings.Settings.Backup.Export.title, systemImage: "square.and.arrow.up") } - .disabled(viewModel.isExporting || viewModel.isImporting || viewModel.isParsing) + Spacer() + Text(L10n.Settings.Settings.Backup.Export.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } } + .disabled(viewModel.isExporting || viewModel.isImporting || viewModel.isParsing) + } - @ViewBuilder - private var importRow: some View { - let isRadioConnected = appState.connectionState.isConnected - let isBusy = viewModel.isExporting || viewModel.isImporting || viewModel.isParsing - let content = Button { - showFileImporter = true - } label: { - HStack { - if viewModel.isParsing { - ProgressView() - Text(L10n.Settings.Settings.Backup.Import.parsing) - } else { - Label(L10n.Settings.Settings.Backup.Import.title, systemImage: "square.and.arrow.down") - } - Spacer() - Text(L10n.Settings.Settings.Backup.Import.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - if isRadioConnected { - content - .disabled(true) - .foregroundStyle(.secondary) - .accessibilityHint(L10n.Settings.Settings.Backup.Import.disabledWhenConnected) + @ViewBuilder + private var importRow: some View { + let isRadioConnected = appState.connectionState.isConnected + let isBusy = viewModel.isExporting || viewModel.isImporting || viewModel.isParsing + let content = Button { + showFileImporter = true + } label: { + HStack { + if viewModel.isParsing { + ProgressView() + Text(L10n.Settings.Settings.Backup.Import.parsing) } else { - content.disabled(isBusy) + Label(L10n.Settings.Settings.Backup.Import.title, systemImage: "square.and.arrow.down") } + Spacer() + Text(L10n.Settings.Settings.Backup.Import.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } } + if isRadioConnected { + content + .disabled(true) + .foregroundStyle(.secondary) + .accessibilityHint(L10n.Settings.Settings.Backup.Import.disabledWhenConnected) + } else { + content.disabled(isBusy) + } + } - // MARK: - Helpers + // MARK: - Helpers - private var exportDocument: AppBackupDocument? { - guard let data = viewModel.pendingExport?.data else { return nil } - return AppBackupDocument(data: data) - } + private var exportDocument: AppBackupDocument? { + guard let data = viewModel.pendingExport?.data else { return nil } + return AppBackupDocument(data: data) + } } diff --git a/MC1/Views/Settings/BlockedChannelSendersView.swift b/MC1/Views/Settings/BlockedChannelSendersView.swift index 9395d655..4de050b3 100644 --- a/MC1/Views/Settings/BlockedChannelSendersView.swift +++ b/MC1/Views/Settings/BlockedChannelSendersView.swift @@ -1,95 +1,95 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "BlockedChannelSendersView") /// Settings screen listing blocked channel sender names with swipe-to-unblock. struct BlockedChannelSendersView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var blockedSenders: [BlockedChannelSenderDTO] = [] - @State private var isLoading = false + @State private var blockedSenders: [BlockedChannelSenderDTO] = [] + @State private var isLoading = false - var body: some View { - Group { - if isLoading { - ProgressView() - } else if blockedSenders.isEmpty { - ContentUnavailableView( - L10n.Settings.Blocking.ChannelSenders.Empty.title, - systemImage: "hand.raised.slash", - description: Text(L10n.Settings.Blocking.ChannelSenders.Empty.description) - ) - } else { - List { - ForEach(blockedSenders) { sender in - VStack(alignment: .leading, spacing: 4) { - Text(sender.name) - .font(.body) - Text(sender.dateBlocked, format: .dateTime.month().day().year()) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .onDelete(perform: unblock) - .themedRowBackground(theme) - } - .themedCanvas(theme) - } - } - .navigationTitle(L10n.Settings.Blocking.ChannelSenders.title) - .toolbar { - if !blockedSenders.isEmpty { - EditButton() + var body: some View { + Group { + if isLoading { + ProgressView() + } else if blockedSenders.isEmpty { + ContentUnavailableView( + L10n.Settings.Blocking.ChannelSenders.Empty.title, + systemImage: "hand.raised.slash", + description: Text(L10n.Settings.Blocking.ChannelSenders.Empty.description) + ) + } else { + List { + ForEach(blockedSenders) { sender in + VStack(alignment: .leading, spacing: 4) { + Text(sender.name) + .font(.body) + Text(sender.dateBlocked, format: .dateTime.month().day().year()) + .font(.caption) + .foregroundStyle(.secondary) } + } + .onDelete(perform: unblock) + .themedRowBackground(theme) } - .task { - await loadBlockedSenders() - } + .themedCanvas(theme) + } + } + .navigationTitle(L10n.Settings.Blocking.ChannelSenders.title) + .toolbar { + if !blockedSenders.isEmpty { + EditButton() + } } + .task { + await loadBlockedSenders() + } + } - private func loadBlockedSenders() async { - guard let services = appState.services, - let radioID = appState.connectedDevice?.radioID else { return } - isLoading = true - defer { isLoading = false } + private func loadBlockedSenders() async { + guard let services = appState.services, + let radioID = appState.connectedDevice?.radioID else { return } + isLoading = true + defer { isLoading = false } - do { - blockedSenders = try await services.dataStore.fetchBlockedChannelSenders( - radioID: radioID - ) - } catch { - logger.error("Failed to load blocked channel senders: \(error)") - blockedSenders = [] - } + do { + blockedSenders = try await services.dataStore.fetchBlockedChannelSenders( + radioID: radioID + ) + } catch { + logger.error("Failed to load blocked channel senders: \(error)") + blockedSenders = [] } + } - private func unblock(at offsets: IndexSet) { - let sendersToUnblock = offsets.map { blockedSenders[$0] } - blockedSenders.remove(atOffsets: offsets) + private func unblock(at offsets: IndexSet) { + let sendersToUnblock = offsets.map { blockedSenders[$0] } + blockedSenders.remove(atOffsets: offsets) - Task { - guard let services = appState.services, - let radioID = appState.connectedDevice?.radioID else { return } - - for sender in sendersToUnblock { - do { - try await services.dataStore.deleteBlockedChannelSender( - radioID: radioID, - name: sender.name - ) - } catch { - logger.error("Failed to delete blocked sender '\(sender.name)': \(error)") - } - } + Task { + guard let services = appState.services, + let radioID = appState.connectedDevice?.radioID else { return } - await services.syncCoordinator.refreshBlockedContactsCache( - radioID: radioID, - dataStore: services.dataStore - ) - services.syncCoordinator.notifyConversationsChanged() + for sender in sendersToUnblock { + do { + try await services.dataStore.deleteBlockedChannelSender( + radioID: radioID, + name: sender.name + ) + } catch { + logger.error("Failed to delete blocked sender '\(sender.name)': \(error)") } + } + + await services.syncCoordinator.refreshBlockedContactsCache( + radioID: radioID, + dataStore: services.dataStore + ) + services.syncCoordinator.notifyConversationsChanged() } + } } diff --git a/MC1/Views/Settings/ChatSettingsView.swift b/MC1/Views/Settings/ChatSettingsView.swift index 73fdf91a..21f20f93 100644 --- a/MC1/Views/Settings/ChatSettingsView.swift +++ b/MC1/Views/Settings/ChatSettingsView.swift @@ -2,28 +2,28 @@ import MC1Services import SwiftUI struct ChatSettingsView: View { - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + @Environment(\.appTheme) private var theme + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - var body: some View { - List { - Section { - Toggle(isOn: $replyWithQuote) { - TintedLabel(L10n.Settings.ReplyWithQuote.toggle, systemImage: "text.quote") - } - } footer: { - Text(L10n.Settings.ReplyWithQuote.footer) - } - .themedRowBackground(theme) - - LinkPreviewSettingsSection() - MapPreviewSettingsSection() - MessagesSettingsSection() - BlockingSection() + var body: some View { + List { + Section { + Toggle(isOn: $replyWithQuote) { + TintedLabel(L10n.Settings.ReplyWithQuote.toggle, systemImage: "text.quote") } - .themedCanvas(theme) - .settingsSubpageDestinations() - .navigationTitle(L10n.Settings.ChatSettings.title) - .navigationBarTitleDisplayMode(.inline) + } footer: { + Text(L10n.Settings.ReplyWithQuote.footer) + } + .themedRowBackground(theme) + + LinkPreviewSettingsSection() + MapPreviewSettingsSection() + MessagesSettingsSection() + BlockingSection() } + .themedCanvas(theme) + .settingsSubpageDestinations() + .navigationTitle(L10n.Settings.ChatSettings.title) + .navigationBarTitleDisplayMode(.inline) + } } diff --git a/MC1/Views/Settings/Components/BatteryCurveChart.swift b/MC1/Views/Settings/Components/BatteryCurveChart.swift index 633a55ff..d52cd0dd 100644 --- a/MC1/Views/Settings/Components/BatteryCurveChart.swift +++ b/MC1/Views/Settings/Components/BatteryCurveChart.swift @@ -3,72 +3,75 @@ import SwiftUI /// Visual representation of an OCV discharge curve struct BatteryCurveChart: View { - let ocvArray: [Int] + let ocvArray: [Int] - private var dataPoints: [DataPoint] { - ocvArray.enumerated().map { index, voltage in - DataPoint( - voltage: Double(voltage) / 1000.0, // Convert to volts - percent: (10 - index) * 10 // 100, 90, 80, ... 0 - ) - } + private var dataPoints: [DataPoint] { + ocvArray.enumerated().map { index, voltage in + DataPoint( + voltage: Double(voltage) / 1000.0, // Convert to volts + percent: (10 - index) * 10 // 100, 90, 80, ... 0 + ) } + } - /// Y axis lower bound: min voltage - 100mV, rounded down to nearest 0.1V - private var yAxisMin: Double { - guard let minMV = ocvArray.min() else { return 1.0 } - return (Double(minMV - 100) / 1000.0).rounded(.down, precision: 1) - } + /// Y axis lower bound: min voltage - 100mV, rounded down to nearest 0.1V + private var yAxisMin: Double { + guard let minMV = ocvArray.min() else { return 1.0 } + return (Double(minMV - 100) / 1000.0).rounded(.down, precision: 1) + } - /// Y axis upper bound: max voltage + 100mV, rounded up to nearest 0.1V - private var yAxisMax: Double { - guard let maxMV = ocvArray.max() else { return 4.5 } - return (Double(maxMV + 100) / 1000.0).rounded(.up, precision: 1) - } + /// Y axis upper bound: max voltage + 100mV, rounded up to nearest 0.1V + private var yAxisMax: Double { + guard let maxMV = ocvArray.max() else { return 4.5 } + return (Double(maxMV + 100) / 1000.0).rounded(.up, precision: 1) + } - var body: some View { - Chart(dataPoints) { point in - AreaMark( - x: .value("Percent", point.percent), - yStart: .value("Baseline", yAxisMin), - yEnd: .value("Voltage", point.voltage) - ) - .foregroundStyle(.blue.opacity(0.2)) - .interpolationMethod(.monotone) + var body: some View { + Chart(dataPoints) { point in + AreaMark( + x: .value("Percent", point.percent), + yStart: .value("Baseline", yAxisMin), + yEnd: .value("Voltage", point.voltage) + ) + .foregroundStyle(.blue.opacity(0.2)) + .interpolationMethod(.monotone) - LineMark( - x: .value("Percent", point.percent), - y: .value("Voltage", point.voltage) - ) - .foregroundStyle(.blue) - .interpolationMethod(.monotone) - } - .chartXScale(domain: 0...100) - .chartXAxis { - AxisMarks(values: [0, 25, 50, 75, 100]) - } - .chartYScale(domain: yAxisMin...yAxisMax) - .chartXAxisLabel(L10n.Settings.Chart.percent) - .chartYAxisLabel(L10n.Settings.Chart.voltage) - .accessibilityLabel(L10n.Settings.Chart.accessibility) - .frame(height: 150) + LineMark( + x: .value("Percent", point.percent), + y: .value("Voltage", point.voltage) + ) + .foregroundStyle(.blue) + .interpolationMethod(.monotone) + } + .chartXScale(domain: 0...100) + .chartXAxis { + AxisMarks(values: [0, 25, 50, 75, 100]) } + .chartYScale(domain: yAxisMin...yAxisMax) + .chartXAxisLabel(L10n.Settings.Chart.percent) + .chartYAxisLabel(L10n.Settings.Chart.voltage) + .accessibilityLabel(L10n.Settings.Chart.accessibility) + .frame(height: 150) + } } private struct DataPoint: Identifiable { - var id: Int { percent } - let voltage: Double - let percent: Int + var id: Int { + percent + } + + let voltage: Double + let percent: Int } private extension Double { - func rounded(_ rule: FloatingPointRoundingRule, precision: Int) -> Double { - let multiplier = pow(10.0, Double(precision)) - return (self * multiplier).rounded(rule) / multiplier - } + func rounded(_ rule: FloatingPointRoundingRule, precision: Int) -> Double { + let multiplier = pow(10.0, Double(precision)) + return (self * multiplier).rounded(rule) / multiplier + } } #Preview { - BatteryCurveChart(ocvArray: [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100]) - .padding() + BatteryCurveChart(ocvArray: [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100]) + .padding() } diff --git a/MC1/Views/Settings/ConnectionSettingsView.swift b/MC1/Views/Settings/ConnectionSettingsView.swift index 5e2ba63e..176a8ad4 100644 --- a/MC1/Views/Settings/ConnectionSettingsView.swift +++ b/MC1/Views/Settings/ConnectionSettingsView.swift @@ -2,27 +2,27 @@ import SwiftUI /// Sub-page wrapping BluetoothSection or WiFiSection based on transport type struct ConnectionSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingWiFiEditSheet = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingWiFiEditSheet = false - private var isWiFi: Bool { - appState.connectionManager.currentTransportType == .wifi - } + private var isWiFi: Bool { + appState.connectionManager.currentTransportType == .wifi + } - var body: some View { - List { - if isWiFi { - WiFiSection(showingEditSheet: $showingWiFiEditSheet) - } else { - BluetoothSection() - } - } - .themedCanvas(theme) - .navigationTitle(isWiFi ? L10n.Settings.Wifi.header : L10n.Settings.Bluetooth.header) - .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showingWiFiEditSheet) { - WiFiEditSheet() - } + var body: some View { + List { + if isWiFi { + WiFiSection(showingEditSheet: $showingWiFiEditSheet) + } else { + BluetoothSection() + } + } + .themedCanvas(theme) + .navigationTitle(isWiFi ? L10n.Settings.Wifi.header : L10n.Settings.Bluetooth.header) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingWiFiEditSheet) { + WiFiEditSheet() } + } } diff --git a/MC1/Views/Settings/DeviceInfoView.swift b/MC1/Views/Settings/DeviceInfoView.swift index 0e0ac8cd..7c6583ed 100644 --- a/MC1/Views/Settings/DeviceInfoView.swift +++ b/MC1/Views/Settings/DeviceInfoView.swift @@ -1,344 +1,344 @@ -import SwiftUI import MC1Services import MeshCore import OSLog +import SwiftUI private let deviceInfoLogger = Logger(subsystem: "com.mc1", category: "DeviceInfoView") /// Detailed device information screen struct DeviceInfoView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var showShareSheet = false - - @State private var nodeName: String = "" - @State private var isEditingName = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isSaving = false - - var body: some View { - List { - if let device = appState.connectedDevice { - // Device identity - Section { - DeviceIdentityHeader(device: device) - } header: { - Text(L10n.Settings.Device.header) - } - .themedRowBackground(theme) - - // Node settings (name, public key, share) - Section { - HStack { - TintedLabel(L10n.Settings.Node.name, systemImage: "person.text.rectangle") - Spacer() - Button(device.nodeName) { - nodeName = device.nodeName - isEditingName = true - } - .foregroundStyle(.secondary) - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - NavigationLink(value: SettingsSubpage.publicKey(device.publicKey)) { - Label(L10n.Settings.DeviceInfo.publicKey, systemImage: "key") - } - - Button { - showShareSheet = true - } label: { - Label(L10n.Settings.DeviceInfo.shareContact, systemImage: "square.and.arrow.up") - } - } header: { - Text(L10n.Settings.Node.header) - } footer: { - Text(L10n.Settings.Node.footer) - } - .alert(L10n.Settings.Node.Alert.EditName.title, isPresented: $isEditingName) { - TextField(L10n.Settings.Node.name, text: $nodeName) - .onChange(of: nodeName) { _, newValue in - if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { - nodeName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - } - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Localizable.Common.save) { - saveNodeName() - } - } - .themedRowBackground(theme) - - // Connection status - Section { - HStack { - Label( - L10n.Settings.DeviceInfo.Connection.status, - systemImage: "antenna.radiowaves.left.and.right" - ) - Spacer() - HStack(spacing: 4) { - Circle() - .fill(.green) - .frame(width: 8, height: 8) - Text(L10n.Settings.Device.connected) - .foregroundStyle(.secondary) - } - } - } header: { - Text(L10n.Settings.DeviceInfo.Connection.header) - } - .themedRowBackground(theme) - - // Battery and storage - Section { - if let battery = appState.batteryMonitor.deviceBattery { - let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) - HStack { - Label( - L10n.Settings.DeviceInfo.battery, - systemImage: battery.iconName(using: ocvArray) - ) - .symbolRenderingMode(.multicolor) - Spacer() - Text(battery.percentage(using: ocvArray), format: .percent) - .foregroundStyle(battery.levelColor(using: ocvArray)) - Text("(\(formatVoltage(battery.voltage)))") - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.storageUsed, systemImage: "internaldrive") - Spacer() - Text(formatStorage(used: battery.usedStorageKB ?? 0, total: battery.totalStorageKB ?? 0)) - .foregroundStyle(.secondary) - } - - StorageBar(used: battery.usedStorageKB ?? 0, total: battery.totalStorageKB ?? 0) - } else { - HStack { - Label(L10n.Settings.DeviceInfo.batteryAndStorage, systemImage: "battery.100") - Spacer() - ProgressView() - } - } - } header: { - Text(L10n.Settings.DeviceInfo.PowerStorage.header) - } - .themedRowBackground(theme) - - // Firmware info - Section { - HStack { - Label(L10n.Settings.DeviceInfo.firmwareVersion, systemImage: "memorychip") - Spacer() - Text( - device.firmwareVersionString.isEmpty - ? L10n.Settings.DeviceInfo.firmwareVersionFormat(device.firmwareVersion) - : device.firmwareVersionString - ) - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.buildDate, systemImage: "calendar") - Spacer() - Text( - device.buildDate.isEmpty - ? L10n.Settings.DeviceInfo.unknown - : device.buildDate - ) - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.manufacturer, systemImage: "building.2") - Spacer() - Text( - device.manufacturerName.isEmpty - ? L10n.Settings.DeviceInfo.unknown - : device.manufacturerName - ) - .foregroundStyle(.secondary) - } - } header: { - Text(L10n.Settings.DeviceInfo.Firmware.header) - } - .themedRowBackground(theme) - - // Capabilities - Section { - HStack { - Label(L10n.Settings.DeviceInfo.maxNodes, systemImage: "person.2") - Spacer() - Text("\(device.maxContacts)") - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.maxChannels, systemImage: "person.3") - Spacer() - Text("\(device.maxChannels)") - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.maxTxPower, systemImage: "bolt") - Spacer() - Text(L10n.Settings.DeviceInfo.txPowerFormat(device.maxTxPower)) - .foregroundStyle(.secondary) - } - } header: { - Text(L10n.Settings.DeviceInfo.Capabilities.header) - } - .themedRowBackground(theme) - - } else { - ContentUnavailableView( - L10n.Settings.DeviceInfo.NoDevice.title, - systemImage: "antenna.radiowaves.left.and.right.slash", - description: Text(L10n.Settings.DeviceInfo.NoDevice.description) - ) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var showShareSheet = false + + @State private var nodeName: String = "" + @State private var isEditingName = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isSaving = false + + var body: some View { + List { + if let device = appState.connectedDevice { + // Device identity + Section { + DeviceIdentityHeader(device: device) + } header: { + Text(L10n.Settings.Device.header) + } + .themedRowBackground(theme) + + // Node settings (name, public key, share) + Section { + HStack { + TintedLabel(L10n.Settings.Node.name, systemImage: "person.text.rectangle") + Spacer() + Button(device.nodeName) { + nodeName = device.nodeName + isEditingName = true } + .foregroundStyle(.secondary) + } + .radioDisabled(for: appState.connectionState, or: isSaving) + + NavigationLink(value: SettingsSubpage.publicKey(device.publicKey)) { + Label(L10n.Settings.DeviceInfo.publicKey, systemImage: "key") + } + + Button { + showShareSheet = true + } label: { + Label(L10n.Settings.DeviceInfo.shareContact, systemImage: "square.and.arrow.up") + } + } header: { + Text(L10n.Settings.Node.header) + } footer: { + Text(L10n.Settings.Node.footer) } - .themedCanvas(theme) - .settingsSubpageDestinations() - .navigationTitle(L10n.Settings.DeviceInfo.title) - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .refreshable { - await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + .alert(L10n.Settings.Node.Alert.EditName.title, isPresented: $isEditingName) { + TextField(L10n.Settings.Node.name, text: $nodeName) + .onChange(of: nodeName) { _, newValue in + if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { + nodeName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + } + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Localizable.Common.save) { + saveNodeName() + } } - .task { - deviceInfoLogger.info("DeviceInfoView: appeared, connectedDevice=\(appState.connectedDevice != nil)") - await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + .themedRowBackground(theme) + + // Connection status + Section { + HStack { + Label( + L10n.Settings.DeviceInfo.Connection.status, + systemImage: "antenna.radiowaves.left.and.right" + ) + Spacer() + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 8, height: 8) + Text(L10n.Settings.Device.connected) + .foregroundStyle(.secondary) + } + } + } header: { + Text(L10n.Settings.DeviceInfo.Connection.header) } - .sheet(isPresented: $showShareSheet) { - if let device = appState.connectedDevice { - ContactQRShareSheet( - contactName: device.nodeName, - publicKey: device.publicKey, - contactType: .chat - ) + .themedRowBackground(theme) + + // Battery and storage + Section { + if let battery = appState.batteryMonitor.deviceBattery { + let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) + HStack { + Label( + L10n.Settings.DeviceInfo.battery, + systemImage: battery.iconName(using: ocvArray) + ) + .symbolRenderingMode(.multicolor) + Spacer() + Text(battery.percentage(using: ocvArray), format: .percent) + .foregroundStyle(battery.levelColor(using: ocvArray)) + Text("(\(formatVoltage(battery.voltage)))") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + Label(L10n.Settings.DeviceInfo.storageUsed, systemImage: "internaldrive") + Spacer() + Text(formatStorage(used: battery.usedStorageKB ?? 0, total: battery.totalStorageKB ?? 0)) + .foregroundStyle(.secondary) } + + StorageBar(used: battery.usedStorageKB ?? 0, total: battery.totalStorageKB ?? 0) + } else { + HStack { + Label(L10n.Settings.DeviceInfo.batteryAndStorage, systemImage: "battery.100") + Spacer() + ProgressView() + } + } + } header: { + Text(L10n.Settings.DeviceInfo.PowerStorage.header) } - } + .themedRowBackground(theme) - /// Firmware reports storage in binary kilobytes. - private static let bytesPerKilobyte = 1024 + // Firmware info + Section { + HStack { + Label(L10n.Settings.DeviceInfo.firmwareVersion, systemImage: "memorychip") + Spacer() + Text( + device.firmwareVersionString.isEmpty + ? L10n.Settings.DeviceInfo.firmwareVersionFormat(device.firmwareVersion) + : device.firmwareVersionString + ) + .foregroundStyle(.secondary) + } + + HStack { + Label(L10n.Settings.DeviceInfo.buildDate, systemImage: "calendar") + Spacer() + Text( + device.buildDate.isEmpty + ? L10n.Settings.DeviceInfo.unknown + : device.buildDate + ) + .foregroundStyle(.secondary) + } + + HStack { + Label(L10n.Settings.DeviceInfo.manufacturer, systemImage: "building.2") + Spacer() + Text( + device.manufacturerName.isEmpty + ? L10n.Settings.DeviceInfo.unknown + : device.manufacturerName + ) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.Settings.DeviceInfo.Firmware.header) + } + .themedRowBackground(theme) - private func formatStorage(used: Int, total: Int) -> String { - let style = ByteCountFormatStyle(style: .memory) - let usedBytes = Int64(used) * Int64(Self.bytesPerKilobyte) - let totalBytes = Int64(total) * Int64(Self.bytesPerKilobyte) - return "\(usedBytes.formatted(style)) / \(totalBytes.formatted(style))" - } + // Capabilities + Section { + HStack { + Label(L10n.Settings.DeviceInfo.maxNodes, systemImage: "person.2") + Spacer() + Text("\(device.maxContacts)") + .foregroundStyle(.secondary) + } - private func formatVoltage(_ volts: Double) -> String { - Measurement(value: volts, unit: UnitElectricPotentialDifference.volts) - .formatted(.measurement( - width: .abbreviated, - usage: .asProvided, - numberFormatStyle: .number.precision(.fractionLength(2)) - )) - } + HStack { + Label(L10n.Settings.DeviceInfo.maxChannels, systemImage: "person.3") + Spacer() + Text("\(device.maxChannels)") + .foregroundStyle(.secondary) + } - private func saveNodeName() { - let name = nodeName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, - let settingsService = appState.services?.settingsService else { return } - - isSaving = true - Task { - do { - _ = try await settingsService.setNodeNameVerified(name) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { saveNodeName() }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isSaving = false + HStack { + Label(L10n.Settings.DeviceInfo.maxTxPower, systemImage: "bolt") + Spacer() + Text(L10n.Settings.DeviceInfo.txPowerFormat(device.maxTxPower)) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.Settings.DeviceInfo.Capabilities.header) } + .themedRowBackground(theme) + + } else { + ContentUnavailableView( + L10n.Settings.DeviceInfo.NoDevice.title, + systemImage: "antenna.radiowaves.left.and.right.slash", + description: Text(L10n.Settings.DeviceInfo.NoDevice.description) + ) + } + } + .themedCanvas(theme) + .settingsSubpageDestinations() + .navigationTitle(L10n.Settings.DeviceInfo.title) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .refreshable { + await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) } + .task { + deviceInfoLogger.info("DeviceInfoView: appeared, connectedDevice=\(appState.connectedDevice != nil)") + await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + } + .sheet(isPresented: $showShareSheet) { + if let device = appState.connectedDevice { + ContactQRShareSheet( + contactName: device.nodeName, + publicKey: device.publicKey, + contactType: .chat + ) + } + } + } + + /// Firmware reports storage in binary kilobytes. + private static let bytesPerKilobyte = 1024 + + private func formatStorage(used: Int, total: Int) -> String { + let style = ByteCountFormatStyle(style: .memory) + let usedBytes = Int64(used) * Int64(Self.bytesPerKilobyte) + let totalBytes = Int64(total) * Int64(Self.bytesPerKilobyte) + return "\(usedBytes.formatted(style)) / \(totalBytes.formatted(style))" + } + + private func formatVoltage(_ volts: Double) -> String { + Measurement(value: volts, unit: UnitElectricPotentialDifference.volts) + .formatted(.measurement( + width: .abbreviated, + usage: .asProvided, + numberFormatStyle: .number.precision(.fractionLength(2)) + )) + } + + private func saveNodeName() { + let name = nodeName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, + let settingsService = appState.services?.settingsService else { return } + + isSaving = true + Task { + do { + _ = try await settingsService.setNodeNameVerified(name) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { saveNodeName() }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + } + isSaving = false + } + } } // MARK: - Device Identity Header private struct DeviceIdentityHeader: View { - let device: DeviceDTO - - var body: some View { - HStack(spacing: 16) { - Image(systemName: "cpu.fill") - .font(.system(size: 40)) - .foregroundStyle(.tint) - .frame(width: 60, height: 60) - .background(.tint.opacity(0.1), in: .circle) - - VStack(alignment: .leading, spacing: 4) { - Text(device.nodeName) - .font(.title2) - .bold() - - Text( - device.manufacturerName.isEmpty - ? L10n.Settings.DeviceInfo.defaultManufacturer - : device.manufacturerName - ) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - } - .padding(.vertical, 8) + let device: DeviceDTO + + var body: some View { + HStack(spacing: 16) { + Image(systemName: "cpu.fill") + .font(.system(size: 40)) + .foregroundStyle(.tint) + .frame(width: 60, height: 60) + .background(.tint.opacity(0.1), in: .circle) + + VStack(alignment: .leading, spacing: 4) { + Text(device.nodeName) + .font(.title2) + .bold() + + Text( + device.manufacturerName.isEmpty + ? L10n.Settings.DeviceInfo.defaultManufacturer + : device.manufacturerName + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() } + .padding(.vertical, 8) + } } // MARK: - Storage Bar private struct StorageBar: View { - let used: Int - let total: Int - - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(.secondary.opacity(0.2)) - - RoundedRectangle(cornerRadius: 4) - .fill(usageColor) - .frame(width: geometry.size.width * usageRatio) - } - } - .frame(height: 8) + let used: Int + let total: Int + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(.secondary.opacity(0.2)) + + RoundedRectangle(cornerRadius: 4) + .fill(usageColor) + .frame(width: geometry.size.width * usageRatio) + } } - - private var usageRatio: CGFloat { - guard total > 0 else { return 0 } - return CGFloat(used) / CGFloat(total) - } - - private var usageColor: Color { - switch usageRatio { - case 0..<0.7: return .green - case 0.7..<0.9: return .orange - default: return .red - } + .frame(height: 8) + } + + private var usageRatio: CGFloat { + guard total > 0 else { return 0 } + return CGFloat(used) / CGFloat(total) + } + + private var usageColor: Color { + switch usageRatio { + case 0..<0.7: .green + case 0.7..<0.9: .orange + default: .red } + } } #Preview { - NavigationStack { - DeviceInfoView() - .environment(\.appState, AppState()) - } + NavigationStack { + DeviceInfoView() + .environment(\.appState, AppState()) + } } diff --git a/MC1/Views/Settings/DeviceSelectionSheet.swift b/MC1/Views/Settings/DeviceSelectionSheet.swift index b319dcac..6d16ad54 100644 --- a/MC1/Views/Settings/DeviceSelectionSheet.swift +++ b/MC1/Views/Settings/DeviceSelectionSheet.swift @@ -1,48 +1,48 @@ +import MC1Services import os import SwiftUI -import MC1Services private let logger = Logger(subsystem: "com.mc1", category: "DeviceSelectionSheet") /// Represents a device that can be selected for connection private enum SelectableDevice: Identifiable, Equatable { - case saved(DeviceDTO) - case accessory(id: UUID, name: String) + case saved(DeviceDTO) + case accessory(id: UUID, name: String) - var id: UUID { - switch self { - case .saved(let device): device.id - case .accessory(let id, _): id - } + var id: UUID { + switch self { + case let .saved(device): device.id + case let .accessory(id, _): id } + } - var name: String { - switch self { - case .saved(let device): device.nodeName - case .accessory(_, let name): name - } - } - - /// The primary connection method for display purposes. - /// WiFi methods are preferred over Bluetooth when available. - var primaryConnectionMethod: ConnectionMethod? { - switch self { - case .saved(let device): - // Prefer WiFi if available - device.connectionMethods.first { $0.isWiFi } ?? device.connectionMethods.first - case .accessory: - nil - } + var name: String { + switch self { + case let .saved(device): device.nodeName + case let .accessory(_, name): name } - - /// Whether this device will connect over WiFi (its preferred method is WiFi). - /// Such rows stay tappable regardless of BLE advertisement, since the connect - /// path routes them to WiFi; only BLE-reachable-only rows gate on a live signal. - /// A radio reachable over both transports is included here — its peripheral UUID - /// may differ from `id`, so a BLE-signal gate would strand a WiFi-connectable row. - var connectsViaWiFi: Bool { - primaryConnectionMethod?.isWiFi == true + } + + /// The primary connection method for display purposes. + /// WiFi methods are preferred over Bluetooth when available. + var primaryConnectionMethod: ConnectionMethod? { + switch self { + case let .saved(device): + // Prefer WiFi if available + device.connectionMethods.first { $0.isWiFi } ?? device.connectionMethods.first + case .accessory: + nil } + } + + /// Whether this device will connect over WiFi (its preferred method is WiFi). + /// Such rows stay tappable regardless of BLE advertisement, since the connect + /// path routes them to WiFi; only BLE-reachable-only rows gate on a live signal. + /// A radio reachable over both transports is included here — its peripheral UUID + /// may differ from `id`, so a BLE-signal gate would strand a WiFi-connectable row. + var connectsViaWiFi: Bool { + primaryConnectionMethod?.isWiFi == true + } } /// Filters saved `Device` rows to those the user can actually reach from this phone. @@ -54,370 +54,368 @@ private enum SelectableDevice: Identifiable, Equatable { /// when the user later pairs the radio, but they must not appear in the picker until /// then — they look like saved devices but no tap can connect them. enum DeviceSelectionFilter { - static func isConnectable(_ device: DeviceDTO, pairedAccessoryIDs: Set, hasSystemPairingRegistry: Bool = true) -> Bool { - if device.connectionMethods.contains(where: \.isWiFi) { return true } - guard hasSystemPairingRegistry else { - // No AccessorySetupKit registry to validate against (macOS). A real saved BLE - // device retains its `.bluetooth` connection method, while demoted ghosts and - // backup shadows have it stripped — so the method itself is the reachability signal. - return device.connectionMethods.contains(where: \.isBluetooth) - } - return pairedAccessoryIDs.contains(device.id) + static func isConnectable(_ device: DeviceDTO, pairedAccessoryIDs: Set, hasSystemPairingRegistry: Bool = true) -> Bool { + if device.connectionMethods.contains(where: \.isWiFi) { return true } + guard hasSystemPairingRegistry else { + // No AccessorySetupKit registry to validate against (macOS). A real saved BLE + // device retains its `.bluetooth` connection method, while demoted ghosts and + // backup shadows have it stripped — so the method itself is the reachability signal. + return device.connectionMethods.contains(where: \.isBluetooth) } + return pairedAccessoryIDs.contains(device.id) + } } /// Sheet for selecting and reconnecting to previously paired devices struct DeviceSelectionSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - - @State private var devices: [SelectableDevice] = [] - @State private var showingWiFiConnection = false - @State private var editingWiFiDevice: SelectableDevice? - @State private var devicesConnectedElsewhere: Set = [] - @State private var tracker = RSSIScanTracker() - - var body: some View { - NavigationStack { - Group { - if devices.isEmpty { - makeEmptyStateView() - } else { - makeDeviceListView() - } - } - .navigationTitle(L10n.Settings.DeviceSelection.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - dismiss() - } - } - } - .task { - await loadDevices() - await startBLEScanning() - } + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var devices: [SelectableDevice] = [] + @State private var showingWiFiConnection = false + @State private var editingWiFiDevice: SelectableDevice? + @State private var devicesConnectedElsewhere: Set = [] + @State private var tracker = RSSIScanTracker() + + var body: some View { + NavigationStack { + Group { + if devices.isEmpty { + makeEmptyStateView() + } else { + makeDeviceListView() } - } - - // MARK: - Subviews - - private func makeDeviceListView() -> some View { - DeviceListView( - devices: devices, - devicesConnectedElsewhere: devicesConnectedElsewhere, - tracker: tracker, - showingWiFiConnection: $showingWiFiConnection, - editingWiFiDevice: $editingWiFiDevice, - onConnect: { connectToDevice($0) }, - onDelete: { deleteDevice($0) }, - onScanForNew: { scanForNewDevice() } - ) - } - - private func makeEmptyStateView() -> some View { - EmptyStateView( - showingWiFiConnection: $showingWiFiConnection, - onScanForNew: { scanForNewDevice() } - ) - } - - // MARK: - Actions - - private func startBLEScanning() async { - await tracker.consume(appState.connectionManager.startBLEScanning()) - } - - private func loadDevices() async { - // Try to load from SwiftData first - let pairedAccessoryIDs = Set(appState.connectionManager.pairedAccessoryInfos.map(\.id)) - let hasSystemPairingRegistry = appState.connectionManager.hasSystemPairingRegistry - do { - let savedDevices = try await appState.connectionManager.fetchSavedDevices() - let connectableDevices = savedDevices.filter { - DeviceSelectionFilter.isConnectable($0, pairedAccessoryIDs: pairedAccessoryIDs, hasSystemPairingRegistry: hasSystemPairingRegistry) - } - if !connectableDevices.isEmpty { - devices = connectableDevices.map { .saved($0) } - - // Check which devices are connected elsewhere (BLE only) - var connectedElsewhere: Set = [] - for device in connectableDevices { - // Skip WiFi-only devices - let hasBluetooth = device.connectionMethods.isEmpty || - device.connectionMethods.contains { !$0.isWiFi } - if hasBluetooth { - if await appState.connectionManager.isDeviceConnectedToOtherApp(device.id) { - connectedElsewhere.insert(device.id) - } - } - } - devicesConnectedElsewhere = connectedElsewhere - return - } - } catch { - logger.error("Failed to load devices: \(error)") + } + .navigationTitle(L10n.Settings.DeviceSelection.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + dismiss() + } } - - // Fall back to ASK accessories when the filter drops every saved row (or the DB is empty) - let accessories = appState.connectionManager.pairedAccessoryInfos - devices = accessories.map { .accessory(id: $0.id, name: $0.name) } - - // Check which accessories are connected elsewhere + } + .task { + await loadDevices() + await startBLEScanning() + } + } + } + + // MARK: - Subviews + + private func makeDeviceListView() -> some View { + DeviceListView( + devices: devices, + devicesConnectedElsewhere: devicesConnectedElsewhere, + tracker: tracker, + showingWiFiConnection: $showingWiFiConnection, + editingWiFiDevice: $editingWiFiDevice, + onConnect: { connectToDevice($0) }, + onDelete: { deleteDevice($0) }, + onScanForNew: { scanForNewDevice() } + ) + } + + private func makeEmptyStateView() -> some View { + EmptyStateView( + showingWiFiConnection: $showingWiFiConnection, + onScanForNew: { scanForNewDevice() } + ) + } + + // MARK: - Actions + + private func startBLEScanning() async { + await tracker.consume(appState.connectionManager.startBLEScanning()) + } + + private func loadDevices() async { + // Try to load from SwiftData first + let pairedAccessoryIDs = Set(appState.connectionManager.pairedAccessoryInfos.map(\.id)) + let hasSystemPairingRegistry = appState.connectionManager.hasSystemPairingRegistry + do { + let savedDevices = try await appState.connectionManager.fetchSavedDevices() + let connectableDevices = savedDevices.filter { + DeviceSelectionFilter.isConnectable($0, pairedAccessoryIDs: pairedAccessoryIDs, hasSystemPairingRegistry: hasSystemPairingRegistry) + } + if !connectableDevices.isEmpty { + devices = connectableDevices.map { .saved($0) } + + // Check which devices are connected elsewhere (BLE only) var connectedElsewhere: Set = [] - for accessory in accessories where await appState.connectionManager.isDeviceConnectedToOtherApp(accessory.id) { - connectedElsewhere.insert(accessory.id) + for device in connectableDevices { + // Skip WiFi-only devices + let hasBluetooth = device.connectionMethods.isEmpty || + device.connectionMethods.contains { !$0.isWiFi } + if hasBluetooth { + if await appState.connectionManager.isDeviceConnectedToOtherApp(device.id) { + connectedElsewhere.insert(device.id) + } + } } devicesConnectedElsewhere = connectedElsewhere + return + } + } catch { + logger.error("Failed to load devices: \(error)") } - private func scanForNewDevice() { - dismiss() - Task { - await appState.connectionManager.stopBLEScanning() - await appState.disconnect(reason: .switchingDevice) - // Trigger ASK picker flow via AppState - appState.startDeviceScan() - } - } + // Fall back to ASK accessories when the filter drops every saved row (or the DB is empty) + let accessories = appState.connectionManager.pairedAccessoryInfos + devices = accessories.map { .accessory(id: $0.id, name: $0.name) } - private func connectToDevice(_ device: SelectableDevice) { - dismiss() - Task { - logger.info("[UI] User tapped Connect for device: \(device.id.uuidString.prefix(8)), name: \(device.name)") - do { - if case .wifi(let host, let port, _) = device.primaryConnectionMethod { - try await appState.connectViaWiFi(host: host, port: port, forceFullSync: true) - } else { - try await appState.connectionManager.connect(to: device.id, forceFullSync: true, forceReconnect: true) - } - } catch BLEError.deviceConnectedToOtherApp { - appState.connectionUI.otherAppWarningDeviceID = device.id - } catch { - appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } - } + // Check which accessories are connected elsewhere + var connectedElsewhere: Set = [] + for accessory in accessories where await appState.connectionManager.isDeviceConnectedToOtherApp(accessory.id) { + connectedElsewhere.insert(accessory.id) } - - private func deleteDevice(_ device: SelectableDevice) { - guard case .saved(let deviceDTO) = device else { return } - - Task { - do { - try await appState.connectionManager.deleteDevice(id: deviceDTO.id) - devices.removeAll { $0.id == device.id } - } catch { - logger.error("Failed to delete device: \(error)") - } + devicesConnectedElsewhere = connectedElsewhere + } + + private func scanForNewDevice() { + dismiss() + Task { + await appState.connectionManager.stopBLEScanning() + await appState.disconnect(reason: .switchingDevice) + // Trigger ASK picker flow via AppState + appState.startDeviceScan() + } + } + + private func connectToDevice(_ device: SelectableDevice) { + dismiss() + Task { + logger.info("[UI] User tapped Connect for device: \(device.id.uuidString.prefix(8)), name: \(device.name)") + do { + if case let .wifi(host, port, _) = device.primaryConnectionMethod { + try await appState.connectViaWiFi(host: host, port: port, forceFullSync: true) + } else { + try await appState.connectionManager.connect(to: device.id, forceFullSync: true, forceReconnect: true) } + } catch { + appState.connectionUI.presentSavedDeviceConnectFailure(deviceID: device.id, error: error) + } } + } + + private func deleteDevice(_ device: SelectableDevice) { + guard case let .saved(deviceDTO) = device else { return } + + Task { + do { + try await appState.connectionManager.deleteDevice(id: deviceDTO.id) + devices.removeAll { $0.id == device.id } + } catch { + logger.error("Failed to delete device: \(error)") + } + } + } } // MARK: - Device List View private struct DeviceListView: View { - @Environment(\.appTheme) private var theme - let devices: [SelectableDevice] - let devicesConnectedElsewhere: Set - let tracker: RSSIScanTracker - @Binding var showingWiFiConnection: Bool - @Binding var editingWiFiDevice: SelectableDevice? - let onConnect: (SelectableDevice) -> Void - let onDelete: (SelectableDevice) -> Void - let onScanForNew: () -> Void - - var body: some View { - List { - Section { - ForEach(devices) { device in - let tier = device.connectsViaWiFi ? nil : tracker.signalTier(for: device.id) - let isDisabledByBLE = !device.connectsViaWiFi && !tracker.isAdvertising(device.id) - Button { - guard !isDisabledByBLE else { return } - onConnect(device) - } label: { - DeviceRow( - device: device, - connectsViaWiFi: device.connectsViaWiFi, - isConnectedElsewhere: devicesConnectedElsewhere.contains(device.id), - signalTier: tier - ) - .contentShape(.rect) - } - .buttonStyle(.plain) - .contextMenu { - Button(role: .destructive) { - onDelete(device) - } label: { - Label(L10n.Localizable.Common.delete, systemImage: "trash") - } - - if device.primaryConnectionMethod?.isWiFi == true { - Button { - editingWiFiDevice = device - } label: { - Label(L10n.Localizable.Common.edit, systemImage: "pencil") - } - } - } - } - } header: { - Text(L10n.Settings.DeviceSelection.previouslyPaired) + @Environment(\.appTheme) private var theme + let devices: [SelectableDevice] + let devicesConnectedElsewhere: Set + let tracker: RSSIScanTracker + @Binding var showingWiFiConnection: Bool + @Binding var editingWiFiDevice: SelectableDevice? + let onConnect: (SelectableDevice) -> Void + let onDelete: (SelectableDevice) -> Void + let onScanForNew: () -> Void + + var body: some View { + List { + Section { + ForEach(devices) { device in + let tier = device.connectsViaWiFi ? nil : tracker.signalTier(for: device.id) + let isDisabledByBLE = !device.connectsViaWiFi && !tracker.isAdvertising(device.id) + Button { + guard !isDisabledByBLE else { return } + onConnect(device) + } label: { + DeviceRow( + device: device, + connectsViaWiFi: device.connectsViaWiFi, + isConnectedElsewhere: devicesConnectedElsewhere.contains(device.id), + signalTier: tier + ) + .contentShape(.rect) + } + .buttonStyle(.plain) + .contextMenu { + Button(role: .destructive) { + onDelete(device) + } label: { + Label(L10n.Localizable.Common.delete, systemImage: "trash") } - .themedRowBackground(theme) - - Section { - Button { - showingWiFiConnection = true - } label: { - Label(L10n.Settings.DeviceSelection.connectViaWifi, systemImage: "wifi.circle") - } - - Button { - onScanForNew() - } label: { - Label(L10n.Settings.DeviceSelection.scanBluetooth, systemImage: "antenna.radiowaves.left.and.right") - } + + if device.primaryConnectionMethod?.isWiFi == true { + Button { + editingWiFiDevice = device + } label: { + Label(L10n.Localizable.Common.edit, systemImage: "pencil") + } } - .themedRowBackground(theme) + } } - .themedCanvas(theme) - .sheet(isPresented: $showingWiFiConnection) { - WiFiConnectionSheet() + } header: { + Text(L10n.Settings.DeviceSelection.previouslyPaired) + } + .themedRowBackground(theme) + + Section { + Button { + showingWiFiConnection = true + } label: { + Label(L10n.Settings.DeviceSelection.connectViaWifi, systemImage: "wifi.circle") } - .sheet(item: $editingWiFiDevice) { device in - if case .wifi(let host, let port, _) = device.primaryConnectionMethod { - WiFiEditSheet(initialHost: host, initialPort: port) - } + + Button { + onScanForNew() + } label: { + Label(L10n.Settings.DeviceSelection.scanBluetooth, systemImage: "antenna.radiowaves.left.and.right") } + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .sheet(isPresented: $showingWiFiConnection) { + WiFiConnectionSheet() + } + .sheet(item: $editingWiFiDevice) { device in + if case let .wifi(host, port, _) = device.primaryConnectionMethod { + WiFiEditSheet(initialHost: host, initialPort: port) + } + } + } } // MARK: - Empty State View private struct EmptyStateView: View { - @Binding var showingWiFiConnection: Bool - let onScanForNew: () -> Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Settings.DeviceSelection.noPairedDevices, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - VStack(spacing: 20) { - Text(L10n.Settings.DeviceSelection.noPairedDescription) - - VStack(spacing: 12) { - Button(L10n.Settings.DeviceSelection.connectViaWifi, systemImage: "wifi.circle") { - showingWiFiConnection = true - } - .liquidGlassProminentButtonStyle() - - Button( - L10n.Settings.DeviceSelection.scanForDevices, - systemImage: "antenna.radiowaves.left.and.right" - ) { - onScanForNew() - } - .liquidGlassProminentButtonStyle() - } - } - } - .sheet(isPresented: $showingWiFiConnection) { - WiFiConnectionSheet() + @Binding var showingWiFiConnection: Bool + let onScanForNew: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Settings.DeviceSelection.noPairedDevices, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + VStack(spacing: 20) { + Text(L10n.Settings.DeviceSelection.noPairedDescription) + + VStack(spacing: 12) { + Button(L10n.Settings.DeviceSelection.connectViaWifi, systemImage: "wifi.circle") { + showingWiFiConnection = true + } + .liquidGlassProminentButtonStyle() + + Button( + L10n.Settings.DeviceSelection.scanForDevices, + systemImage: "antenna.radiowaves.left.and.right" + ) { + onScanForNew() + } + .liquidGlassProminentButtonStyle() } + } + } + .sheet(isPresented: $showingWiFiConnection) { + WiFiConnectionSheet() } + } } // MARK: - Device Row private struct DeviceRow: View { - let device: SelectableDevice - let connectsViaWiFi: Bool - let isConnectedElsewhere: Bool - let signalTier: RSSITuning.SignalTier? - - private var isUnreachable: Bool { - !connectsViaWiFi && signalTier == nil + let device: SelectableDevice + let connectsViaWiFi: Bool + let isConnectedElsewhere: Bool + let signalTier: RSSITuning.SignalTier? + + private var isUnreachable: Bool { + !connectsViaWiFi && signalTier == nil + } + + private var transportIcon: String { + guard let method = device.primaryConnectionMethod else { + return "antenna.radiowaves.left.and.right" } + return method.isWiFi ? "wifi" : "antenna.radiowaves.left.and.right" + } - private var transportIcon: String { - guard let method = device.primaryConnectionMethod else { - return "antenna.radiowaves.left.and.right" - } - return method.isWiFi ? "wifi" : "antenna.radiowaves.left.and.right" - } - - private var transportColor: Color { - guard let method = device.primaryConnectionMethod else { - return .green - } - return method.isWiFi ? .blue : .green + private var transportColor: Color { + guard let method = device.primaryConnectionMethod else { + return .green } + return method.isWiFi ? .blue : .green + } - private var connectionDescription: String { - if let method = device.primaryConnectionMethod, method.isWiFi { - return method.shortDescription - } - return L10n.Settings.DeviceSelection.bluetooth + private var connectionDescription: String { + if let method = device.primaryConnectionMethod, method.isWiFi { + return method.shortDescription } - - var body: some View { - HStack(spacing: 12) { - Image(systemName: transportIcon) - .font(.title2) - .foregroundStyle(transportColor) - .frame(width: 40, height: 40) - .background(transportColor.opacity(0.1), in: .circle) - - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(.headline) - - if isConnectedElsewhere { - Label( - L10n.Settings.DeviceSelection.connectedElsewhere, - systemImage: "exclamationmark.triangle.fill" - ) - .font(.caption) - .foregroundStyle(.orange) - } else { - Text(connectionDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - if let tier = signalTier { - SignalBars(tier: tier) - } + return L10n.Settings.DeviceSelection.bluetooth + } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: transportIcon) + .font(.title2) + .foregroundStyle(transportColor) + .frame(width: 40, height: 40) + .background(transportColor.opacity(0.1), in: .circle) + + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .font(.headline) + + if isConnectedElsewhere { + Label( + L10n.Settings.DeviceSelection.connectedElsewhere, + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) + } else { + Text(connectionDescription) + .font(.caption) + .foregroundStyle(.secondary) } - .padding(.vertical, 4) - .contentShape(.rect) - .opacity(isConnectedElsewhere || isUnreachable ? 0.4 : 1.0) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilityLabel(isConnectedElsewhere - ? L10n.Settings.DeviceSelection.Accessibility.connectedElsewhereLabel(device.name) - : L10n.Settings.DeviceSelection.Accessibility.deviceLabel(device.name, connectionDescription)) - .accessibilityValue(signalDescription) - .accessibilityHint(isConnectedElsewhere - ? L10n.Settings.DeviceSelection.Accessibility.connectedElsewhereHint - : isUnreachable - ? L10n.Settings.DeviceSelection.Accessibility.outOfRangeHint - : L10n.Settings.DeviceSelection.Accessibility.selectHint) - } + } - // MARK: - Signal Tier Helpers + Spacer() - /// VoiceOver descriptor for the signal tier, announced as the row's accessibility value so - /// it survives the explicit `accessibilityLabel` above (which overrides combined children). - /// Empty when the device is out of range so VoiceOver announces no value. - private var signalDescription: String { - guard let tier = signalTier else { return "" } - return SignalBars.accessibilityDescription(forTier: tier) + if let tier = signalTier { + SignalBars(tier: tier) + } } + .padding(.vertical, 4) + .contentShape(.rect) + .opacity(isConnectedElsewhere || isUnreachable ? 0.4 : 1.0) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(isConnectedElsewhere + ? L10n.Settings.DeviceSelection.Accessibility.connectedElsewhereLabel(device.name) + : L10n.Settings.DeviceSelection.Accessibility.deviceLabel(device.name, connectionDescription)) + .accessibilityValue(signalDescription) + .accessibilityHint(isConnectedElsewhere + ? L10n.Settings.DeviceSelection.Accessibility.connectedElsewhereHint + : isUnreachable + ? L10n.Settings.DeviceSelection.Accessibility.outOfRangeHint + : L10n.Settings.DeviceSelection.Accessibility.selectHint) + } + + // MARK: - Signal Tier Helpers + + /// VoiceOver descriptor for the signal tier, announced as the row's accessibility value so + /// it survives the explicit `accessibilityLabel` above (which overrides combined children). + /// Empty when the device is out of range so VoiceOver announces no value. + private var signalDescription: String { + guard let tier = signalTier else { return "" } + return SignalBars.accessibilityDescription(forTier: tier) + } } diff --git a/MC1/Views/Settings/ExportSuccessContent.swift b/MC1/Views/Settings/ExportSuccessContent.swift index f25ea012..f0d53e7d 100644 --- a/MC1/Views/Settings/ExportSuccessContent.swift +++ b/MC1/Views/Settings/ExportSuccessContent.swift @@ -5,77 +5,77 @@ import SwiftUI /// `ImportSuccessContent` minus the "already here" section — export /// has no skip concept. Lives in the MC1 target because it uses `L10n`. struct ExportSuccessContent: View { - @Environment(\.appTheme) private var theme - let summary: AppBackupViewModel.ExportSuccessSummary - let onDismiss: @MainActor () -> Void + @Environment(\.appTheme) private var theme + let summary: AppBackupViewModel.ExportSuccessSummary + let onDismiss: @MainActor () -> Void - var body: some View { - List { - heroSection - .themedRowBackground(theme) - includedSection - .themedRowBackground(theme) - doneSection - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Settings.Backup.Export.Success.title) - .navigationBarTitleDisplayMode(.inline) - .onAppear { - AccessibilityNotification.Announcement( - L10n.Settings.Settings.Backup.Export.Success.announcement(summary.filename) - ).post() - } - .sensoryFeedback(.success, trigger: summary.id) + var body: some View { + List { + heroSection + .themedRowBackground(theme) + includedSection + .themedRowBackground(theme) + doneSection } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Settings.Backup.Export.Success.title) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + AccessibilityNotification.Announcement( + L10n.Settings.Settings.Backup.Export.Success.announcement(summary.filename) + ).post() + } + .sensoryFeedback(.success, trigger: summary.id) + } - private var heroSection: some View { - Section { - VStack(spacing: 12) { - Image(systemName: "checkmark.circle.fill") - .font(.system(.largeTitle)) - .imageScale(.large) - .foregroundStyle(.green) - .accessibilityHidden(true) - Text(L10n.Settings.Settings.Backup.Export.Success.title) - .font(.headline) - VStack(spacing: 2) { - Text(summary.filename) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - Text(summary.byteCount.formatted(.byteCount(style: .file))) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) + private var heroSection: some View { + Section { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(.largeTitle)) + .imageScale(.large) + .foregroundStyle(.green) + .accessibilityHidden(true) + Text(L10n.Settings.Settings.Backup.Export.Success.title) + .font(.headline) + VStack(spacing: 2) { + Text(summary.filename) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Text(summary.byteCount.formatted(.byteCount(style: .file))) + .font(.footnote) + .foregroundStyle(.secondary) } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) } + } - private var includedSection: some View { - Section(L10n.Settings.Settings.Backup.Export.Success.includedSection) { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - let count = summary.manifest.count(for: kind) - if count > 0 { - LabeledContent(kind.label, value: "\(count)") - } - } + private var includedSection: some View { + Section(L10n.Settings.Settings.Backup.Export.Success.includedSection) { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + let count = summary.manifest.count(for: kind) + if count > 0 { + LabeledContent(kind.label, value: "\(count)") } + } } + } - private var doneSection: some View { - Section { - Button { - onDismiss() - } label: { - Text(L10n.Settings.Settings.Backup.Export.Success.done) - .frame(maxWidth: .infinity) - } - .liquidGlassProminentButtonStyle() - .controlSize(.large) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - .listRowBackground(Color.clear) - } + private var doneSection: some View { + Section { + Button { + onDismiss() + } label: { + Text(L10n.Settings.Settings.Backup.Export.Success.done) + .frame(maxWidth: .infinity) + } + .liquidGlassProminentButtonStyle() + .controlSize(.large) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) } + } } diff --git a/MC1/Views/Settings/ImportPreviewSheet.swift b/MC1/Views/Settings/ImportPreviewSheet.swift index 90409945..16d893d6 100644 --- a/MC1/Views/Settings/ImportPreviewSheet.swift +++ b/MC1/Views/Settings/ImportPreviewSheet.swift @@ -1,205 +1,204 @@ -import SwiftUI import MC1Services +import SwiftUI struct ImportPreviewSheet: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: AppBackupViewModel - - var body: some View { - NavigationStack { - Group { - if viewModel.isImporting { - importingView - } else if let result = viewModel.importResult { - ImportSuccessContent(viewModel: viewModel, result: result) - } else if let error = viewModel.sheetErrorMessage { - errorView(message: error) - } else if viewModel.isCancelled { - cancelledView - } else if let envelope = viewModel.previewEnvelope { - previewView(envelope: envelope) - } - } - .navigationTitle(L10n.Settings.Settings.Backup.Import.Preview.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Settings.Settings.Backup.Import.Preview.cancel) { - if viewModel.isImporting { - viewModel.cancelImport() - } else { - viewModel.dismissImportSheet() - } - } - .disabled(viewModel.isCancellingImport) - } - } - .interactiveDismissDisabled(viewModel.isImporting) - .sensoryFeedback(trigger: viewModel.importResult) { _, newValue in - guard let result = newValue else { return nil } - return result.hasRestoredChanges ? .success : nil - } - .sensoryFeedback(.error, trigger: viewModel.sheetErrorMessage) { _, newValue in newValue != nil } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: AppBackupViewModel + + var body: some View { + NavigationStack { + Group { + if viewModel.isImporting { + importingView + } else if let result = viewModel.importResult { + ImportSuccessContent(viewModel: viewModel, result: result) + } else if let error = viewModel.sheetErrorMessage { + errorView(message: error) + } else if viewModel.isCancelled { + cancelledView + } else if let envelope = viewModel.previewEnvelope { + previewView(envelope: envelope) } - } - - // MARK: - Preview - - private func previewView(envelope: AppBackupEnvelope) -> some View { - List { - Section(L10n.Settings.Settings.Backup.Import.Preview.details) { - LabeledContent(L10n.Settings.Settings.Backup.Import.Preview.exported, value: envelope.exportDate.formatted(date: .abbreviated, time: .shortened)) - LabeledContent(L10n.Settings.Settings.Backup.Import.Preview.appVersion, value: "\(envelope.appVersion) (\(envelope.appBuild))") - } - .themedRowBackground(theme) - - Section(L10n.Settings.Settings.Backup.Import.Preview.contents) { - manifestRows(envelope.manifest) - } - .themedRowBackground(theme) - - Section { - Label { - Text(L10n.Settings.Settings.Backup.Import.Preview.info) - } icon: { - Image(systemName: "info.circle.fill") - .foregroundStyle(.tint) - } - } - .themedRowBackground(theme) - - Section { - Button { - viewModel.performImport() - } label: { - Text(L10n.Settings.Settings.Backup.Import.Preview.button) - .frame(maxWidth: .infinity) - } - .liquidGlassProminentButtonStyle() - .controlSize(.large) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - .listRowBackground(Color.clear) + } + .navigationTitle(L10n.Settings.Settings.Backup.Import.Preview.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Settings.Settings.Backup.Import.Preview.cancel) { + if viewModel.isImporting { + viewModel.cancelImport() + } else { + viewModel.dismissImportSheet() } + } + .disabled(viewModel.isCancellingImport) } - .themedCanvas(theme) + } + .interactiveDismissDisabled(viewModel.isImporting) + .sensoryFeedback(trigger: viewModel.importResult) { _, newValue in + guard let result = newValue else { return nil } + return result.hasRestoredChanges ? .success : nil + } + .sensoryFeedback(.error, trigger: viewModel.sheetErrorMessage) { _, newValue in newValue != nil } } - - // MARK: - Importing - - private var importingView: some View { - VStack(spacing: 16) { - ProgressView() - .controlSize(.large) - Text(viewModel.isCancellingImport - ? L10n.Settings.Settings.Backup.Import.cancelling - : L10n.Settings.Settings.Backup.Import.progress) - .foregroundStyle(.secondary) + } + + // MARK: - Preview + + private func previewView(envelope: AppBackupEnvelope) -> some View { + List { + Section(L10n.Settings.Settings.Backup.Import.Preview.details) { + LabeledContent(L10n.Settings.Settings.Backup.Import.Preview.exported, value: envelope.exportDate.formatted(date: .abbreviated, time: .shortened)) + LabeledContent(L10n.Settings.Settings.Backup.Import.Preview.appVersion, value: "\(envelope.appVersion) (\(envelope.appBuild))") + } + .themedRowBackground(theme) + + Section(L10n.Settings.Settings.Backup.Import.Preview.contents) { + manifestRows(envelope.manifest) + } + .themedRowBackground(theme) + + Section { + Label { + Text(L10n.Settings.Settings.Backup.Import.Preview.info) + } icon: { + Image(systemName: "info.circle.fill") + .foregroundStyle(.tint) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.progress).post() + } + .themedRowBackground(theme) + + Section { + Button { + viewModel.performImport() + } label: { + Text(L10n.Settings.Settings.Backup.Import.Preview.button) + .frame(maxWidth: .infinity) } + .liquidGlassProminentButtonStyle() + .controlSize(.large) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + } } - - // MARK: - Cancelled - - private var cancelledView: some View { - List { - Section { - VStack(spacing: 12) { - Image(systemName: "info.circle.fill") - .font(.system(.largeTitle)) - .imageScale(.large) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - Text(L10n.Settings.Settings.Backup.Import.Cancelled.title) - .font(.headline) - Text(L10n.Settings.Settings.Backup.Import.Cancelled.message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .themedRowBackground(theme) - - Section { - Button { - viewModel.dismissImportSheet() - } label: { - HStack { - Spacer() - Text(L10n.Settings.Settings.Backup.Import.Success.done) - .bold() - Spacer() - } - } - } - .themedRowBackground(theme) + .themedCanvas(theme) + } + + // MARK: - Importing + + private var importingView: some View { + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + Text(viewModel.isCancellingImport + ? L10n.Settings.Settings.Backup.Import.cancelling + : L10n.Settings.Settings.Backup.Import.progress) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.progress).post() + } + } + + // MARK: - Cancelled + + private var cancelledView: some View { + List { + Section { + VStack(spacing: 12) { + Image(systemName: "info.circle.fill") + .font(.system(.largeTitle)) + .imageScale(.large) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(L10n.Settings.Settings.Backup.Import.Cancelled.title) + .font(.headline) + Text(L10n.Settings.Settings.Backup.Import.Cancelled.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } - .themedCanvas(theme) - .onAppear { - AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.Cancelled.title).post() + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .themedRowBackground(theme) + + Section { + Button { + viewModel.dismissImportSheet() + } label: { + HStack { + Spacer() + Text(L10n.Settings.Settings.Backup.Import.Success.done) + .bold() + Spacer() + } } + } + .themedRowBackground(theme) } - - // MARK: - Error - - private func errorView(message: String) -> some View { - List { - Section { - VStack(spacing: 12) { - Image(systemName: "xmark.circle.fill") - .font(.system(.largeTitle)) - .imageScale(.large) - .foregroundStyle(.red) - .accessibilityHidden(true) - Text(L10n.Settings.Settings.Backup.Import.Error.title) - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .themedRowBackground(theme) - - Section { - Button { - viewModel.dismissImportSheet() - } label: { - HStack { - Spacer() - Text(L10n.Settings.Settings.Backup.Import.Error.dismiss) - .bold() - Spacer() - } - } - } - .themedRowBackground(theme) + .themedCanvas(theme) + .onAppear { + AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.Cancelled.title).post() + } + } + + // MARK: - Error + + private func errorView(message: String) -> some View { + List { + Section { + VStack(spacing: 12) { + Image(systemName: "xmark.circle.fill") + .font(.system(.largeTitle)) + .imageScale(.large) + .foregroundStyle(.red) + .accessibilityHidden(true) + Text(L10n.Settings.Settings.Backup.Import.Error.title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } - .themedCanvas(theme) - .onAppear { - AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.Error.title).post() + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .themedRowBackground(theme) + + Section { + Button { + viewModel.dismissImportSheet() + } label: { + HStack { + Spacer() + Text(L10n.Settings.Settings.Backup.Import.Error.dismiss) + .bold() + Spacer() + } } + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .onAppear { + AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.Error.title).post() + } + } - // MARK: - Count rows + // MARK: - Count rows - @ViewBuilder - private func manifestRows(_ manifest: BackupManifest) -> some View { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - manifestRow(kind.label, count: manifest.count(for: kind)) - } + private func manifestRows(_ manifest: BackupManifest) -> some View { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + manifestRow(kind.label, count: manifest.count(for: kind)) } + } - @ViewBuilder - private func manifestRow(_ label: String, count: Int) -> some View { - if count > 0 { - LabeledContent(label, value: "\(count)") - } + @ViewBuilder + private func manifestRow(_ label: String, count: Int) -> some View { + if count > 0 { + LabeledContent(label, value: "\(count)") } + } } diff --git a/MC1/Views/Settings/ImportSuccessContent.swift b/MC1/Views/Settings/ImportSuccessContent.swift index 87195173..1790bfa4 100644 --- a/MC1/Views/Settings/ImportSuccessContent.swift +++ b/MC1/Views/Settings/ImportSuccessContent.swift @@ -5,161 +5,171 @@ import SwiftUI /// from `ImportPreviewSheet` so the success rendering can stay under the /// project's ~300-line-per-file guideline and keep its own disclosure state. struct ImportSuccessContent: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: AppBackupViewModel - let result: ImportResult - @State private var isAlreadyHereExpanded = false - @State private var isDroppedExpanded = false - - private var didAdd: Bool { result.totalInserted > 0 } - private var didMerge: Bool { result.totalMerged > 0 } - private var hasSkipped: Bool { result.totalSkipped > 0 } - private var hasDropped: Bool { result.totalDropped > 0 } - - private var heroTitle: String { - result.hasRestoredChanges - ? L10n.Settings.Settings.Backup.Import.Success.title - : L10n.Settings.Settings.Backup.Import.NothingToImport.title - } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: AppBackupViewModel + let result: ImportResult + @State private var isAlreadyHereExpanded = false + @State private var isDroppedExpanded = false - private var heroSubtitle: String { - if didAdd { - return L10n.Settings.Settings.Backup.Import.Success.subtitleAdded(result.totalInserted) - } - if didMerge { - return L10n.Settings.Settings.Backup.Import.Success.subtitleRefreshed(result.totalMerged) - } - return L10n.Settings.Settings.Backup.Import.NothingToImport.subtitle - } + private var didAdd: Bool { + result.totalInserted > 0 + } - private var heroIconName: String { - result.hasRestoredChanges ? "checkmark.circle.fill" : "info.circle.fill" - } + private var didMerge: Bool { + result.totalMerged > 0 + } + + private var hasSkipped: Bool { + result.totalSkipped > 0 + } + + private var hasDropped: Bool { + result.totalDropped > 0 + } - private var heroIconColor: Color { - result.hasRestoredChanges ? .green : .secondary + private var heroTitle: String { + result.hasRestoredChanges + ? L10n.Settings.Settings.Backup.Import.Success.title + : L10n.Settings.Settings.Backup.Import.NothingToImport.title + } + + private var heroSubtitle: String { + if didAdd { + return L10n.Settings.Settings.Backup.Import.Success.subtitleAdded(result.totalInserted) + } + if didMerge { + return L10n.Settings.Settings.Backup.Import.Success.subtitleRefreshed(result.totalMerged) } + return L10n.Settings.Settings.Backup.Import.NothingToImport.subtitle + } - var body: some View { - List { - heroSection - .themedRowBackground(theme) - if didAdd { - addedSection - .themedRowBackground(theme) - } - if hasSkipped { - alreadyHereSection - .themedRowBackground(theme) - } - if hasDropped { - droppedSection - .themedRowBackground(theme) - } - doneSection - } - .themedCanvas(theme) - .onAppear { - AccessibilityNotification.Announcement("\(heroTitle). \(heroSubtitle)").post() - } + private var heroIconName: String { + result.hasRestoredChanges ? "checkmark.circle.fill" : "info.circle.fill" + } + + private var heroIconColor: Color { + result.hasRestoredChanges ? .green : .secondary + } + + var body: some View { + List { + heroSection + .themedRowBackground(theme) + if didAdd { + addedSection + .themedRowBackground(theme) + } + if hasSkipped { + alreadyHereSection + .themedRowBackground(theme) + } + if hasDropped { + droppedSection + .themedRowBackground(theme) + } + doneSection + } + .themedCanvas(theme) + .onAppear { + AccessibilityNotification.Announcement("\(heroTitle). \(heroSubtitle)").post() } + } - private var heroSection: some View { - Section { - VStack(spacing: 12) { - Image(systemName: heroIconName) - .font(.system(.largeTitle)) - .imageScale(.large) - .foregroundStyle(heroIconColor) - .accessibilityHidden(true) - Text(heroTitle) - .font(.headline) - Text(heroSubtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } + private var heroSection: some View { + Section { + VStack(spacing: 12) { + Image(systemName: heroIconName) + .font(.system(.largeTitle)) + .imageScale(.large) + .foregroundStyle(heroIconColor) + .accessibilityHidden(true) + Text(heroTitle) + .font(.headline) + Text(heroSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) } + } - private var addedSection: some View { - Section(L10n.Settings.Settings.Backup.Import.Success.addedSection) { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - let inserted = result.counts[kind]?.inserted ?? 0 - if inserted > 0 { - LabeledContent(kind.label, value: "\(inserted)") - } - } + private var addedSection: some View { + Section(L10n.Settings.Settings.Backup.Import.Success.addedSection) { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + let inserted = result.counts[kind]?.inserted ?? 0 + if inserted > 0 { + LabeledContent(kind.label, value: "\(inserted)") } + } } + } - private var alreadyHereSection: some View { - Section { - DisclosureGroup(isExpanded: $isAlreadyHereExpanded) { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - let skipped = result.counts[kind]?.skipped ?? 0 - if skipped > 0 { - LabeledContent(kind.label, value: "\(skipped)") - } - } - } label: { - Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereSummary(result.totalSkipped)) - } - } header: { - Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereSection) - } footer: { - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereFooter) - if didMerge { - Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereRefreshed(result.totalMerged)) - } - } + private var alreadyHereSection: some View { + Section { + DisclosureGroup(isExpanded: $isAlreadyHereExpanded) { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + let skipped = result.counts[kind]?.skipped ?? 0 + if skipped > 0 { + LabeledContent(kind.label, value: "\(skipped)") + } + } + } label: { + Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereSummary(result.totalSkipped)) + } + } header: { + Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereSection) + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereFooter) + if didMerge { + Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereRefreshed(result.totalMerged)) } + } } + } - private var droppedSection: some View { - Section { - DisclosureGroup(isExpanded: $isDroppedExpanded) { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - let dropped = result.counts[kind]?.dropped ?? 0 - if dropped > 0 { - LabeledContent(kind.label, value: "\(dropped)") - } - } - } label: { - Text(L10n.Settings.Settings.Backup.Import.Success.droppedSummary(result.totalDropped)) - } - } header: { - Text(L10n.Settings.Settings.Backup.Import.Success.droppedSection) - } footer: { - Text(L10n.Settings.Settings.Backup.Import.Success.droppedFooter) + private var droppedSection: some View { + Section { + DisclosureGroup(isExpanded: $isDroppedExpanded) { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + let dropped = result.counts[kind]?.dropped ?? 0 + if dropped > 0 { + LabeledContent(kind.label, value: "\(dropped)") + } } + } label: { + Text(L10n.Settings.Settings.Backup.Import.Success.droppedSummary(result.totalDropped)) + } + } header: { + Text(L10n.Settings.Settings.Backup.Import.Success.droppedSection) + } footer: { + Text(L10n.Settings.Settings.Backup.Import.Success.droppedFooter) } + } - @ViewBuilder - private var doneSection: some View { - Section { - Group { - if result.hasRestoredChanges { - doneButton.liquidGlassProminentButtonStyle() - } else { - doneButton.liquidGlassButtonStyle() - } - } - .controlSize(.large) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - .listRowBackground(Color.clear) + private var doneSection: some View { + Section { + Group { + if result.hasRestoredChanges { + doneButton.liquidGlassProminentButtonStyle() + } else { + doneButton.liquidGlassButtonStyle() } + } + .controlSize(.large) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) } + } - private var doneButton: some View { - Button { - viewModel.dismissImportSheet() - } label: { - Text(L10n.Settings.Settings.Backup.Import.Success.done) - .frame(maxWidth: .infinity) - } + private var doneButton: some View { + Button { + viewModel.dismissImportSheet() + } label: { + Text(L10n.Settings.Settings.Backup.Import.Success.done) + .frame(maxWidth: .infinity) } + } } diff --git a/MC1/Views/Settings/LocationSettingsView.swift b/MC1/Views/Settings/LocationSettingsView.swift index 2e08748d..8e61ed88 100644 --- a/MC1/Views/Settings/LocationSettingsView.swift +++ b/MC1/Views/Settings/LocationSettingsView.swift @@ -2,19 +2,19 @@ import SwiftUI /// Sub-page wrapping LocationSettingsSection for the settings navigation struct LocationSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingLocationPicker = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingLocationPicker = false - var body: some View { - List { - LocationSettingsSection(showingLocationPicker: $showingLocationPicker) - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Location.header) - .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showingLocationPicker) { - LocationPickerView.forLocalDevice(appState: appState) - } + var body: some View { + List { + LocationSettingsSection(showingLocationPicker: $showingLocationPicker) } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Location.header) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingLocationPicker) { + LocationPickerView.forLocalDevice(appState: appState) + } + } } diff --git a/MC1/Views/Settings/NodeConfig/NodeConfigExportView.swift b/MC1/Views/Settings/NodeConfig/NodeConfigExportView.swift index d1ff46b0..271c6a2e 100644 --- a/MC1/Views/Settings/NodeConfig/NodeConfigExportView.swift +++ b/MC1/Views/Settings/NodeConfig/NodeConfigExportView.swift @@ -1,127 +1,127 @@ -import SwiftUI import MC1Services +import SwiftUI struct NodeConfigExportView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel = NodeConfigExportViewModel() - - var body: some View { - List { - Section { - Toggle( - L10n.Settings.ConfigExport.selectAll, - isOn: Binding( - get: { viewModel.sections.allSelected }, - set: { newValue in - if newValue { - viewModel.sections.selectAll() - } else { - viewModel.sections.deselectAll() - } - } - ) - ) - .bold() + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel = NodeConfigExportViewModel() - ExportToggleRow( - L10n.Settings.ConfigExport.nodeIdentity, - description: L10n.Settings.ConfigExport.NodeIdentity.description, - isOn: $viewModel.sections.nodeIdentity - ) - ExportToggleRow( - L10n.Settings.ConfigExport.radioSettings, - description: L10n.Settings.ConfigExport.RadioSettings.description, - isOn: $viewModel.sections.radioSettings - ) - ExportToggleRow( - L10n.Settings.ConfigExport.positionSettings, - description: L10n.Settings.ConfigExport.PositionSettings.description, - isOn: $viewModel.sections.positionSettings - ) - ExportToggleRow( - L10n.Settings.ConfigExport.otherSettings, - description: L10n.Settings.ConfigExport.OtherSettings.description, - isOn: $viewModel.sections.otherSettings - ) - ExportToggleRow( - L10n.Settings.ConfigExport.channels, - description: L10n.Settings.ConfigExport.Channels.description, - isOn: $viewModel.sections.channels - ) - ExportToggleRow( - L10n.Settings.ConfigExport.contacts, - description: L10n.Settings.ConfigExport.Contacts.description, - isOn: $viewModel.sections.contacts - ) + var body: some View { + List { + Section { + Toggle( + L10n.Settings.ConfigExport.selectAll, + isOn: Binding( + get: { viewModel.sections.allSelected }, + set: { newValue in + if newValue { + viewModel.sections.selectAll() + } else { + viewModel.sections.deselectAll() + } } - .themedRowBackground(theme) + ) + ) + .bold() - Section { - Button { - Task { - await viewModel.exportConfig( - nodeConfigService: appState.services?.nodeConfigService, - deviceNodeName: appState.connectedDevice?.nodeName - ) - } - } label: { - HStack { - Text(viewModel.sections.allSelected - ? L10n.Settings.ConfigExport.exportFull - : L10n.Settings.ConfigExport.exportSelected) - Spacer() - if viewModel.isExporting { - ProgressView() - } - } - } - .disabled(viewModel.isExporting || !viewModel.sections.anySectionSelected) - } - .themedRowBackground(theme) + ExportToggleRow( + L10n.Settings.ConfigExport.nodeIdentity, + description: L10n.Settings.ConfigExport.NodeIdentity.description, + isOn: $viewModel.sections.nodeIdentity + ) + ExportToggleRow( + L10n.Settings.ConfigExport.radioSettings, + description: L10n.Settings.ConfigExport.RadioSettings.description, + isOn: $viewModel.sections.radioSettings + ) + ExportToggleRow( + L10n.Settings.ConfigExport.positionSettings, + description: L10n.Settings.ConfigExport.PositionSettings.description, + isOn: $viewModel.sections.positionSettings + ) + ExportToggleRow( + L10n.Settings.ConfigExport.otherSettings, + description: L10n.Settings.ConfigExport.OtherSettings.description, + isOn: $viewModel.sections.otherSettings + ) + ExportToggleRow( + L10n.Settings.ConfigExport.channels, + description: L10n.Settings.ConfigExport.Channels.description, + isOn: $viewModel.sections.channels + ) + ExportToggleRow( + L10n.Settings.ConfigExport.contacts, + description: L10n.Settings.ConfigExport.Contacts.description, + isOn: $viewModel.sections.contacts + ) + } + .themedRowBackground(theme) - if let error = viewModel.errorMessage { - Section { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - .themedRowBackground(theme) + Section { + Button { + Task { + await viewModel.exportConfig( + nodeConfigService: appState.services?.nodeConfigService, + deviceNodeName: appState.connectedDevice?.nodeName + ) + } + } label: { + HStack { + Text(viewModel.sections.allSelected + ? L10n.Settings.ConfigExport.exportFull + : L10n.Settings.ConfigExport.exportSelected) + Spacer() + if viewModel.isExporting { + ProgressView() } + } } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.ConfigExport.title) - .fileExporter( - isPresented: $viewModel.showFileExporter, - document: viewModel.exportedDocument, - contentType: .json, - defaultFilename: viewModel.exportedDocument?.filename - ) { result in - if case .failure(let error) = result { - viewModel.errorMessage = error.userFacingMessage - } + .disabled(viewModel.isExporting || !viewModel.sections.anySectionSelected) + } + .themedRowBackground(theme) + + if let error = viewModel.errorMessage { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) } + .themedRowBackground(theme) + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.ConfigExport.title) + .fileExporter( + isPresented: $viewModel.showFileExporter, + document: viewModel.exportedDocument, + contentType: .json, + defaultFilename: viewModel.exportedDocument?.filename + ) { result in + if case let .failure(error) = result { + viewModel.errorMessage = error.userFacingMessage + } } + } } private struct ExportToggleRow: View { - let title: String - let description: String - @Binding var isOn: Bool + let title: String + let description: String + @Binding var isOn: Bool - init(_ title: String, description: String, isOn: Binding) { - self.title = title - self.description = description - self._isOn = isOn - } + init(_ title: String, description: String, isOn: Binding) { + self.title = title + self.description = description + _isOn = isOn + } - var body: some View { - Toggle(isOn: $isOn) { - VStack(alignment: .leading) { - Text(title) - Text(description) - .font(.footnote) - .foregroundStyle(.secondary) - } - } + var body: some View { + Toggle(isOn: $isOn) { + VStack(alignment: .leading) { + Text(title) + Text(description) + .font(.footnote) + .foregroundStyle(.secondary) + } } + } } diff --git a/MC1/Views/Settings/NodeConfig/NodeConfigExportViewModel.swift b/MC1/Views/Settings/NodeConfig/NodeConfigExportViewModel.swift index 9e0f80f9..a9d7095f 100644 --- a/MC1/Views/Settings/NodeConfig/NodeConfigExportViewModel.swift +++ b/MC1/Views/Settings/NodeConfig/NodeConfigExportViewModel.swift @@ -1,77 +1,79 @@ -import SwiftUI import MC1Services -import UniformTypeIdentifiers import OSLog +import SwiftUI +import UniformTypeIdentifiers @Observable @MainActor final class NodeConfigExportViewModel { - var sections = ConfigSections() - var isExporting = false - var exportedDocument: NodeConfigDocument? - var showFileExporter = false - var errorMessage: String? - - private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigExportVM") + var sections = ConfigSections() + var isExporting = false + var exportedDocument: NodeConfigDocument? + var showFileExporter = false + var errorMessage: String? - /// A nil service mirrors a disconnected state and is a no-op. - func exportConfig(nodeConfigService: NodeConfigService?, deviceNodeName: String?) async { - guard let service = nodeConfigService else { return } + private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigExportVM") - isExporting = true - errorMessage = nil + /// A nil service mirrors a disconnected state and is a no-op. + func exportConfig(nodeConfigService: NodeConfigService?, deviceNodeName: String?) async { + guard let service = nodeConfigService else { return } - do { - let config = try await service.exportConfig(sections: sections) + isExporting = true + errorMessage = nil - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(config) + do { + let config = try await service.exportConfig(sections: sections) - let nodeName = deviceNodeName ?? config.name ?? "unknown" - let sanitized = nodeName - .replacing(/[^a-zA-Z0-9_-]/, with: "_") - .replacing(/^_+|_+$/, with: "") + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) - let timestamp = Date.now.formatted( - .verbatim( - "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits)-\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased))\(minute: .twoDigits)\(second: .twoDigits)", - locale: Locale(identifier: "en_US_POSIX"), - timeZone: .current, - calendar: .init(identifier: .gregorian) - ) - ) - let filename = "\(sanitized)_meshcore_config_\(timestamp)" + let nodeName = deviceNodeName ?? config.name ?? "unknown" + let sanitized = nodeName + .replacing(/[^a-zA-Z0-9_-]/, with: "_") + .replacing(/^_+|_+$/, with: "") - exportedDocument = NodeConfigDocument(data: data, filename: filename) - showFileExporter = true - } catch { - logger.error("Export failed: \(error.localizedDescription)") - errorMessage = error.userFacingMessage - } + let timestamp = Date.now.formatted( + .verbatim( + "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits)-\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased))\(minute: .twoDigits)\(second: .twoDigits)", + locale: Locale(identifier: "en_US_POSIX"), + timeZone: .current, + calendar: .init(identifier: .gregorian) + ) + ) + let filename = "\(sanitized)_meshcore_config_\(timestamp)" - isExporting = false + exportedDocument = NodeConfigDocument(data: data, filename: filename) + showFileExporter = true + } catch { + logger.error("Export failed: \(error.localizedDescription)") + errorMessage = error.userFacingMessage } + + isExporting = false + } } /// Wraps exported JSON data for `.fileExporter` struct NodeConfigDocument: FileDocument { - static var readableContentTypes: [UTType] { [.json] } + static var readableContentTypes: [UTType] { + [.json] + } - let data: Data - let filename: String + let data: Data + let filename: String - init(data: Data, filename: String) { - self.data = data - self.filename = filename - } + init(data: Data, filename: String) { + self.data = data + self.filename = filename + } - init(configuration: ReadConfiguration) throws { - data = configuration.file.regularFileContents ?? Data() - filename = "config" - } + init(configuration: ReadConfiguration) throws { + data = configuration.file.regularFileContents ?? Data() + filename = "config" + } - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - FileWrapper(regularFileWithContents: data) - } + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } } diff --git a/MC1/Views/Settings/NodeConfig/NodeConfigImportView.swift b/MC1/Views/Settings/NodeConfig/NodeConfigImportView.swift index 31aaf6fd..483d7c9d 100644 --- a/MC1/Views/Settings/NodeConfig/NodeConfigImportView.swift +++ b/MC1/Views/Settings/NodeConfig/NodeConfigImportView.swift @@ -1,327 +1,329 @@ -import SwiftUI import MC1Services +import SwiftUI struct NodeConfigImportView: View { - @Environment(\.appState) private var appState - @State private var viewModel = NodeConfigImportViewModel() - - var body: some View { - Group { - if let config = viewModel.importedConfig { - ImportPreviewList(viewModel: viewModel, config: config) - } else { - SelectFileList(viewModel: viewModel) - } - } - .navigationTitle(L10n.Settings.ConfigImport.title) - .onDisappear { viewModel.handleDismissal() } - .fileImporter( - isPresented: $viewModel.showFilePicker, - allowedContentTypes: [.json] - ) { result in - switch result { - case .success(let url): - viewModel.parseFile(at: url) - Task { await viewModel.loadCurrentDeviceState(settingsService: appState.services?.settingsService) } - case .failure(let error): - viewModel.errorMessage = error.userFacingMessage - } - } - .alert( - viewModel.confirmTitle, - isPresented: $viewModel.showConfirmation - ) { - Button(viewModel.applyButtonLabel) { - viewModel.applyConfig( - nodeConfigService: appState.services?.nodeConfigService, - settingsService: appState.services?.settingsService, - radioID: appState.connectedDevice?.radioID - ) - } - Button(L10n.Localizable.Common.cancel, role: .cancel) {} - } message: { - Text(viewModel.confirmMessage(deviceName: appState.connectedDevice?.nodeName ?? L10n.Settings.ConfigImport.thisDevice)) - } + @Environment(\.appState) private var appState + @State private var viewModel = NodeConfigImportViewModel() + + var body: some View { + Group { + if let config = viewModel.importedConfig { + ImportPreviewList(viewModel: viewModel, config: config) + } else { + SelectFileList(viewModel: viewModel) + } + } + .navigationTitle(L10n.Settings.ConfigImport.title) + .onDisappear { viewModel.handleDismissal() } + .fileImporter( + isPresented: $viewModel.showFilePicker, + allowedContentTypes: [.json] + ) { result in + switch result { + case let .success(url): + viewModel.parseFile(at: url) + Task { await viewModel.loadCurrentDeviceState(settingsService: appState.services?.settingsService) } + case let .failure(error): + viewModel.errorMessage = error.userFacingMessage + } } + .alert( + viewModel.confirmTitle, + isPresented: $viewModel.showConfirmation + ) { + Button(viewModel.applyButtonLabel) { + viewModel.applyConfig( + nodeConfigService: appState.services?.nodeConfigService, + settingsService: appState.services?.settingsService, + radioID: appState.connectedDevice?.radioID + ) + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + } message: { + Text(viewModel.confirmMessage(deviceName: appState.connectedDevice?.nodeName ?? L10n.Settings.ConfigImport.thisDevice)) + } + } } // MARK: - Select File private struct SelectFileList: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - - var body: some View { - List { - Section { - Button { - viewModel.showFilePicker = true - } label: { - HStack { - Text(L10n.Settings.ConfigImport.selectFile) - if viewModel.isParsing { - Spacer() - ProgressView() - } - } - } - .disabled(viewModel.isParsing) - } - .themedRowBackground(theme) - - if let error = viewModel.errorMessage { - Section { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + + var body: some View { + List { + Section { + Button { + viewModel.showFilePicker = true + } label: { + HStack { + Text(L10n.Settings.ConfigImport.selectFile) + if viewModel.isParsing { + Spacer() + ProgressView() } + } + } + .disabled(viewModel.isParsing) + } + .themedRowBackground(theme) + + if let error = viewModel.errorMessage { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) } - .themedCanvas(theme) + .themedRowBackground(theme) + } } + .themedCanvas(theme) + } } // MARK: - Import Preview private struct ImportPreviewList: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let config: MeshCoreNodeConfig - - var body: some View { - List { - if config.name != nil || config.privateKey != nil { - NodeIdentitySection(viewModel: viewModel, config: config) - } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let config: MeshCoreNodeConfig - if let radio = config.radioSettings { - RadioSettingsSection(viewModel: viewModel, radio: radio, currentRadio: viewModel.currentRadio) - } + var body: some View { + List { + if config.name != nil || config.privateKey != nil { + NodeIdentitySection(viewModel: viewModel, config: config) + } - if let position = config.positionSettings { - PositionSection(viewModel: viewModel, position: position, currentPosition: viewModel.currentPosition) - } + if let radio = config.radioSettings { + RadioSettingsSection(viewModel: viewModel, radio: radio, currentRadio: viewModel.currentRadio) + } - if config.otherSettings != nil { - Section { - Toggle(L10n.Settings.ConfigExport.otherSettings, isOn: $viewModel.sections.otherSettings) - } - .themedRowBackground(theme) - } + if let position = config.positionSettings { + PositionSection(viewModel: viewModel, position: position, currentPosition: viewModel.currentPosition) + } - if let channels = config.channels { - ChannelsSection(viewModel: viewModel, channels: channels) - } + if config.otherSettings != nil { + Section { + Toggle(L10n.Settings.ConfigExport.otherSettings, isOn: $viewModel.sections.otherSettings) + } + .themedRowBackground(theme) + } - if let contacts = config.contacts { - ContactsSection(viewModel: viewModel, contacts: contacts) - } + if let channels = config.channels { + ChannelsSection(viewModel: viewModel, channels: channels) + } - Section { - Label(L10n.Settings.ConfigImport.proximityWarning, systemImage: "antenna.radiowaves.left.and.right") - .font(.footnote) - .foregroundStyle(.secondary) - } - .themedRowBackground(theme) + if let contacts = config.contacts { + ContactsSection(viewModel: viewModel, contacts: contacts) + } - ApplySection(viewModel: viewModel) + Section { + Label(L10n.Settings.ConfigImport.proximityWarning, systemImage: "antenna.radiowaves.left.and.right") + .font(.footnote) + .foregroundStyle(.secondary) + } + .themedRowBackground(theme) - if let error = viewModel.errorMessage { - Section { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } + ApplySection(viewModel: viewModel) - if viewModel.importComplete && !viewModel.isApplying { - Section { - Label(L10n.Settings.ConfigImport.importSuccess, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } - .themedRowBackground(theme) - } + if let error = viewModel.errorMessage { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) } - .themedCanvas(theme) - // Lock the section toggles while the preview round-trip is in flight, so the selection - // the confirmation copy was computed from matches the selection the apply uses. - .disabled(viewModel.isPreparingConfirmation) + .themedRowBackground(theme) + } + + if viewModel.importComplete, !viewModel.isApplying { + Section { + Label(L10n.Settings.ConfigImport.importSuccess, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + .themedRowBackground(theme) + } } + .themedCanvas(theme) + // Lock the section toggles while the preview round-trip is in flight, so the selection + // the confirmation copy was computed from matches the selection the apply uses. + .disabled(viewModel.isPreparingConfirmation) + } } // MARK: - Section Views private struct NodeIdentitySection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let config: MeshCoreNodeConfig + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let config: MeshCoreNodeConfig - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.nodeIdentity) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.nodeIdentity) - if let newName = config.name { - DiffRow( - current: viewModel.currentName ?? "\u{2014}", - new: newName - ) - } - if config.privateKey != nil { - Label(L10n.Settings.ConfigImport.privateKeyWarning, systemImage: "exclamationmark.shield") - .font(.footnote) - .foregroundStyle(.orange) - } - } - } + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.nodeIdentity) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.nodeIdentity) + if let newName = config.name { + DiffRow( + current: viewModel.currentName ?? "\u{2014}", + new: newName + ) + } + if config.privateKey != nil { + Label(L10n.Settings.ConfigImport.privateKeyWarning, systemImage: "exclamationmark.shield") + .font(.footnote) + .foregroundStyle(.orange) + } } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct RadioSettingsSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let radio: MeshCoreNodeConfig.RadioSettings - let currentRadio: MeshCoreNodeConfig.RadioSettings? - - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.radioSettings) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.radioSettings) - DiffRow( - current: currentRadio.map { RadioFormatter.format($0) } ?? "\u{2014}", - new: RadioFormatter.format(radio) - ) - if let current = currentRadio, current != radio { - Label(L10n.Settings.ConfigImport.radioWarning, systemImage: "exclamationmark.triangle") - .font(.footnote) - .foregroundStyle(.orange) - } - } - } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let radio: MeshCoreNodeConfig.RadioSettings + let currentRadio: MeshCoreNodeConfig.RadioSettings? + + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.radioSettings) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.radioSettings) + DiffRow( + current: currentRadio.map { RadioFormatter.format($0) } ?? "\u{2014}", + new: RadioFormatter.format(radio) + ) + if let current = currentRadio, current != radio { + Label(L10n.Settings.ConfigImport.radioWarning, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct PositionSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let position: MeshCoreNodeConfig.PositionSettings - let currentPosition: MeshCoreNodeConfig.PositionSettings? - - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.positionSettings) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.positionSettings) - DiffRow( - current: currentPosition.map { "\($0.latitude), \($0.longitude)" } ?? "\u{2014}", - new: "\(position.latitude), \(position.longitude)" - ) - } - } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let position: MeshCoreNodeConfig.PositionSettings + let currentPosition: MeshCoreNodeConfig.PositionSettings? + + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.positionSettings) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.positionSettings) + DiffRow( + current: currentPosition.map { "\($0.latitude), \($0.longitude)" } ?? "\u{2014}", + new: "\(position.latitude), \(position.longitude)" + ) } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct ChannelsSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let channels: [MeshCoreNodeConfig.ChannelConfig] + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let channels: [MeshCoreNodeConfig.ChannelConfig] - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.channels) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.channels) - Text(L10n.Settings.ConfigImport.channelCount(channels.count)) - .font(.footnote) - .foregroundStyle(.secondary) - } - } + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.channels) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.channels) + Text(L10n.Settings.ConfigImport.channelCount(channels.count)) + .font(.footnote) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct ContactsSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let contacts: [MeshCoreNodeConfig.ContactConfig] + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let contacts: [MeshCoreNodeConfig.ContactConfig] - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.contacts) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.contacts) - Text(L10n.Settings.ConfigImport.contactCount(contacts.count)) - .font(.footnote) - .foregroundStyle(.secondary) - } - } + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.contacts) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.contacts) + Text(L10n.Settings.ConfigImport.contactCount(contacts.count)) + .font(.footnote) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct ApplySection: View { - @Environment(\.appTheme) private var theme - @Environment(\.appState) private var appState - @Bindable var viewModel: NodeConfigImportViewModel - - var body: some View { - Section { - if viewModel.isApplying { - VStack { - ProgressView(value: viewModel.applyProgress) - Text(viewModel.applyStepDescription) - .font(.footnote) - .foregroundStyle(.secondary) - Button(L10n.Localizable.Common.cancel, role: .cancel) { - viewModel.cancelImport() - } - } - } else if !viewModel.importComplete { - Button(viewModel.applyButtonLabel) { - viewModel.prepareConfirmation(nodeConfigService: appState.services?.nodeConfigService) - } - .disabled(viewModel.isPreparingConfirmation) - } + @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Bindable var viewModel: NodeConfigImportViewModel + + var body: some View { + Section { + if viewModel.isApplying { + VStack { + ProgressView(value: viewModel.applyProgress) + Text(viewModel.applyStepDescription) + .font(.footnote) + .foregroundStyle(.secondary) + Button(L10n.Localizable.Common.cancel, role: .cancel) { + viewModel.cancelImport() + } } - .themedRowBackground(theme) + } else if !viewModel.importComplete { + Button(viewModel.applyButtonLabel) { + viewModel.prepareConfirmation(nodeConfigService: appState.services?.nodeConfigService) + } + .disabled(viewModel.isPreparingConfirmation) + } } + .themedRowBackground(theme) + } } // MARK: - Diff Row private struct DiffRow: View { - let current: String - let new: String - - private var hasChanged: Bool { current != new } - - var body: some View { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigImport.current(current)) - .font(.footnote) - .foregroundStyle(.secondary) - Text(L10n.Settings.ConfigImport.new(new)) - .font(.footnote) - .foregroundStyle(hasChanged ? .primary : .secondary) - } + let current: String + let new: String + + private var hasChanged: Bool { + current != new + } + + var body: some View { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigImport.current(current)) + .font(.footnote) + .foregroundStyle(.secondary) + Text(L10n.Settings.ConfigImport.new(new)) + .font(.footnote) + .foregroundStyle(hasChanged ? .primary : .secondary) } + } } // MARK: - Radio Formatter private enum RadioFormatter { - static func format(_ radio: MeshCoreNodeConfig.RadioSettings) -> String { - let freqMHz = (Double(radio.frequency) / 1000).formatted(.number.precision(.fractionLength(0...3)).locale(.posix)) - let bwKHz = (Double(radio.bandwidth) / 1000).formatted(.number.precision(.fractionLength(0...1)).locale(.posix)) - return "\(freqMHz) MHz, BW \(bwKHz) kHz, SF \(radio.spreadingFactor), CR \(radio.codingRate)" - } + static func format(_ radio: MeshCoreNodeConfig.RadioSettings) -> String { + let freqMHz = (Double(radio.frequency) / 1000).formatted(.number.precision(.fractionLength(0...3)).locale(.posix)) + let bwKHz = (Double(radio.bandwidth) / 1000).formatted(.number.precision(.fractionLength(0...1)).locale(.posix)) + return "\(freqMHz) MHz, BW \(bwKHz) kHz, SF \(radio.spreadingFactor), CR \(radio.codingRate)" + } } diff --git a/MC1/Views/Settings/NodeConfig/NodeConfigImportViewModel.swift b/MC1/Views/Settings/NodeConfig/NodeConfigImportViewModel.swift index e066d8a7..3e4c30b8 100644 --- a/MC1/Views/Settings/NodeConfig/NodeConfigImportViewModel.swift +++ b/MC1/Views/Settings/NodeConfig/NodeConfigImportViewModel.swift @@ -1,318 +1,318 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI @Observable @MainActor final class NodeConfigImportViewModel { - // Parse state - var importedConfig: MeshCoreNodeConfig? - var errorMessage: String? - var showFilePicker = false - var isParsing = false - - // Section selection - var sections = ConfigSections() - - // Current device state for diff - var currentName: String? - var currentRadio: MeshCoreNodeConfig.RadioSettings? - var currentPosition: MeshCoreNodeConfig.PositionSettings? - - // Apply state - var isApplying = false - var applyProgress: Double = 0 - var applyStepDescription = "" - var importComplete = false - var showConfirmation = false - var isPreparingConfirmation = false - - /// Set from a non-destructive ``NodeConfigService/previewImport(_:sections:)`` pass before the - /// confirmation alert. True when at least one selected channel would replace an already-configured - /// slot, so the channels section is not purely additive and the confirmation copy must say so. - private var channelsWouldOverwrite = false - - /// True once the import has reported progress, i.e. at least one destructive write reached the - /// device. Distinguishes "cancelled before anything changed" from "cancelled mid-write." - private var didApplyAnyWrite = false - - private var importTask: Task? - - /// Handle for the in-flight file parse, plus the ID guard that lets a newer parse - /// (or a dismissal) invalidate a stale result that raced past cancellation. - private var parseTask: Task? - private var currentParseID: UUID? - - /// Handle for the in-flight preview, which does a real BLE round-trip via `previewImport`. - /// Stored so it can be cancelled if the user leaves the screen or supersedes it. - private var previewTask: Task? - - private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigImportVM") - - // MARK: - Dynamic confirmation text - - private var hasOverwriteSections: Bool { - sections.radioSettings || sections.nodeIdentity || sections.positionSettings - || sections.otherSettings || (sections.channels && channelsWouldOverwrite) + // Parse state + var importedConfig: MeshCoreNodeConfig? + var errorMessage: String? + var showFilePicker = false + var isParsing = false + + /// Section selection + var sections = ConfigSections() + + // Current device state for diff + var currentName: String? + var currentRadio: MeshCoreNodeConfig.RadioSettings? + var currentPosition: MeshCoreNodeConfig.PositionSettings? + + // Apply state + var isApplying = false + var applyProgress: Double = 0 + var applyStepDescription = "" + var importComplete = false + var showConfirmation = false + var isPreparingConfirmation = false + + /// Set from a non-destructive ``NodeConfigService/previewImport(_:sections:)`` pass before the + /// confirmation alert. True when at least one selected channel would replace an already-configured + /// slot, so the channels section is not purely additive and the confirmation copy must say so. + private var channelsWouldOverwrite = false + + /// True once the import has reported progress, i.e. at least one destructive write reached the + /// device. Distinguishes "cancelled before anything changed" from "cancelled mid-write." + private var didApplyAnyWrite = false + + private var importTask: Task? + + /// Handle for the in-flight file parse, plus the ID guard that lets a newer parse + /// (or a dismissal) invalidate a stale result that raced past cancellation. + private var parseTask: Task? + private var currentParseID: UUID? + + /// Handle for the in-flight preview, which does a real BLE round-trip via `previewImport`. + /// Stored so it can be cancelled if the user leaves the screen or supersedes it. + private var previewTask: Task? + + private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigImportVM") + + // MARK: - Dynamic confirmation text + + private var hasOverwriteSections: Bool { + sections.radioSettings || sections.nodeIdentity || sections.positionSettings + || sections.otherSettings || (sections.channels && channelsWouldOverwrite) + } + + private var hasAdditiveSections: Bool { + sections.channels || sections.contacts + } + + var confirmTitle: String { + switch (hasOverwriteSections, hasAdditiveSections) { + case (false, true): L10n.Settings.ConfigImport.confirmTitleAdd + case (true, false): L10n.Settings.ConfigImport.confirmTitleOverwrite + default: L10n.Settings.ConfigImport.confirmTitle } + } - private var hasAdditiveSections: Bool { - sections.channels || sections.contacts + var applyButtonLabel: String { + switch (hasOverwriteSections, hasAdditiveSections) { + case (false, true): L10n.Settings.ConfigImport.applyButtonAdd + case (true, false): L10n.Settings.ConfigImport.applyButtonOverwrite + default: L10n.Settings.ConfigImport.applyButton } - - var confirmTitle: String { - switch (hasOverwriteSections, hasAdditiveSections) { - case (false, true): L10n.Settings.ConfigImport.confirmTitleAdd - case (true, false): L10n.Settings.ConfigImport.confirmTitleOverwrite - default: L10n.Settings.ConfigImport.confirmTitle - } + } + + func confirmMessage(deviceName: String) -> String { + switch (hasOverwriteSections, hasAdditiveSections) { + case (false, true): L10n.Settings.ConfigImport.confirmMessageAdd(deviceName) + case (true, false): L10n.Settings.ConfigImport.confirmMessageOverwrite(deviceName) + case (true, true): L10n.Settings.ConfigImport.confirmMessageMixed(deviceName) + default: L10n.Settings.ConfigImport.confirmMessage(deviceName) } - - var applyButtonLabel: String { - switch (hasOverwriteSections, hasAdditiveSections) { - case (false, true): L10n.Settings.ConfigImport.applyButtonAdd - case (true, false): L10n.Settings.ConfigImport.applyButtonOverwrite - default: L10n.Settings.ConfigImport.applyButton + } + + /// Parse a JSON file from a security-scoped URL. The read and decode run detached + /// because the URL can point at an undownloaded iCloud Drive file, where a + /// synchronous read on the main actor blocks the UI for the whole download. + func parseFile(at url: URL) { + parseTask?.cancel() + isParsing = true + errorMessage = nil + let taskID = UUID() + currentParseID = taskID + let didAccessSecurityScope = url.startAccessingSecurityScopedResource() + + parseTask = Task.detached(priority: .userInitiated) { [weak self, url, didAccessSecurityScope] in + defer { + if didAccessSecurityScope { + url.stopAccessingSecurityScopedResource() } + } + do { + // .mappedIfSafe lets the OS page-cache the read rather than + // copying the whole file into the heap. + let data = try Data(contentsOf: url, options: .mappedIfSafe) + try Task.checkCancellation() + let config = try JSONDecoder().decode(MeshCoreNodeConfig.self, from: data) + try Task.checkCancellation() + await self?.applyParseSuccess(config, for: taskID) + } catch is CancellationError { + // The new invocation or the dismissal owns the parse state; don't clobber it. + return + } catch { + await self?.applyParseFailure(error, for: taskID) + } } - - func confirmMessage(deviceName: String) -> String { - switch (hasOverwriteSections, hasAdditiveSections) { - case (false, true): L10n.Settings.ConfigImport.confirmMessageAdd(deviceName) - case (true, false): L10n.Settings.ConfigImport.confirmMessageOverwrite(deviceName) - case (true, true): L10n.Settings.ConfigImport.confirmMessageMixed(deviceName) - default: L10n.Settings.ConfigImport.confirmMessage(deviceName) - } + } + + private func applyParseSuccess(_ config: MeshCoreNodeConfig, for taskID: UUID) { + guard currentParseID == taskID else { return } + isParsing = false + importedConfig = config + errorMessage = nil + + // Auto-select only sections present in the file + sections.nodeIdentity = config.name != nil || config.publicKey != nil || config.privateKey != nil + sections.radioSettings = config.radioSettings != nil + sections.positionSettings = config.positionSettings != nil && !(config.positionSettings?.isZero ?? true) + sections.otherSettings = config.otherSettings != nil + sections.channels = config.channels != nil + sections.contacts = config.contacts != nil + } + + private func applyParseFailure(_ error: Error, for taskID: UUID) { + guard currentParseID == taskID else { return } + isParsing = false + errorMessage = error.userFacingMessage + logger.error("Failed to parse config: \(error.localizedDescription)") + } + + /// Load current device values for diff display. A nil service mirrors a disconnected state. + func loadCurrentDeviceState(settingsService: SettingsService?) async { + guard let settingsService else { return } + do { + let selfInfo = try await settingsService.getSelfInfo() + currentName = selfInfo.name + currentRadio = NodeConfigService.buildRadioSettings(from: selfInfo) + currentPosition = MeshCoreNodeConfig.PositionSettings( + latitude: String(selfInfo.latitude), + longitude: String(selfInfo.longitude) + ) + } catch { + logger.error("Failed to load device state: \(error.localizedDescription)") } - - /// Parse a JSON file from a security-scoped URL. The read and decode run detached - /// because the URL can point at an undownloaded iCloud Drive file, where a - /// synchronous read on the main actor blocks the UI for the whole download. - func parseFile(at url: URL) { - parseTask?.cancel() - isParsing = true + } + + /// Runs the non-destructive planner to classify the import (overwrite vs additive) and reject a + /// malformed config up front, then presents the confirmation alert. Surfacing a planner error here + /// means a poison file is caught before the user even confirms, and before any write. + func prepareConfirmation(nodeConfigService: NodeConfigService?) { + guard !isPreparingConfirmation, !isApplying else { return } + guard let config = importedConfig, + let service = nodeConfigService else { return } + + errorMessage = nil + isPreparingConfirmation = true + previewTask = Task { + defer { isPreparingConfirmation = false } + do { + let preview = try await service.previewImport(config, sections: sections) + // If the user left the screen while the round-trip was in flight, the dismissal has + // already reset the UI; presenting the confirmation now would pop an alert over a + // stale, off-screen state, so honour the cancellation even when the BLE call won the + // race and returned before observing it. + guard !Task.isCancelled else { return } + channelsWouldOverwrite = preview.channelsOverwriteExisting errorMessage = nil - let taskID = UUID() - currentParseID = taskID - let didAccessSecurityScope = url.startAccessingSecurityScopedResource() - - parseTask = Task.detached(priority: .userInitiated) { [weak self, url, didAccessSecurityScope] in - defer { - if didAccessSecurityScope { - url.stopAccessingSecurityScopedResource() - } - } - do { - // .mappedIfSafe lets the OS page-cache the read rather than - // copying the whole file into the heap. - let data = try Data(contentsOf: url, options: .mappedIfSafe) - try Task.checkCancellation() - let config = try JSONDecoder().decode(MeshCoreNodeConfig.self, from: data) - try Task.checkCancellation() - await self?.applyParseSuccess(config, for: taskID) - } catch is CancellationError { - // The new invocation or the dismissal owns the parse state; don't clobber it. - return - } catch { - await self?.applyParseFailure(error, for: taskID) - } - } - } - - private func applyParseSuccess(_ config: MeshCoreNodeConfig, for taskID: UUID) { - guard currentParseID == taskID else { return } - isParsing = false - importedConfig = config - errorMessage = nil - - // Auto-select only sections present in the file - sections.nodeIdentity = config.name != nil || config.publicKey != nil || config.privateKey != nil - sections.radioSettings = config.radioSettings != nil - sections.positionSettings = config.positionSettings != nil && !(config.positionSettings?.isZero ?? true) - sections.otherSettings = config.otherSettings != nil - sections.channels = config.channels != nil - sections.contacts = config.contacts != nil - } - - private func applyParseFailure(_ error: Error, for taskID: UUID) { - guard currentParseID == taskID else { return } - isParsing = false + showConfirmation = true + } catch is CancellationError { + // The user left the screen mid-preview — leave the UI untouched. + } catch { errorMessage = error.userFacingMessage - logger.error("Failed to parse config: \(error.localizedDescription)") + logger.error("Import preview failed: \(error.localizedDescription)") + } } - - /// Load current device values for diff display. A nil service mirrors a disconnected state. - func loadCurrentDeviceState(settingsService: SettingsService?) async { - guard let settingsService else { return } - do { - let selfInfo = try await settingsService.getSelfInfo() - currentName = selfInfo.name - currentRadio = NodeConfigService.buildRadioSettings(from: selfInfo) - currentPosition = MeshCoreNodeConfig.PositionSettings( - latitude: String(selfInfo.latitude), - longitude: String(selfInfo.longitude) - ) - } catch { - logger.error("Failed to load device state: \(error.localizedDescription)") + } + + /// Apply the imported config to the device. Nil parameters mirror a disconnected state. + func applyConfig(nodeConfigService: NodeConfigService?, settingsService: SettingsService?, radioID: UUID?) { + guard !isApplying else { return } + guard let config = importedConfig, + let service = nodeConfigService, + let radioID else { return } + + isApplying = true + applyProgress = 0 + errorMessage = nil + importComplete = false + didApplyAnyWrite = false + + importTask = Task { + // Deliver progress through one stream consumed in order on the main actor, so updates + // can't race or arrive out of sequence the way a per-update `Task { @MainActor }` would. + let (progressStream, progressContinuation) = AsyncStream.makeStream(of: ImportProgress.self) + let consumer = Task { @MainActor in + for await progress in progressStream { + didApplyAnyWrite = true + applyProgress = Double(progress.current) / Double(max(1, progress.total)) + applyStepDescription = Self.description(for: progress.step) } - } - - /// Runs the non-destructive planner to classify the import (overwrite vs additive) and reject a - /// malformed config up front, then presents the confirmation alert. Surfacing a planner error here - /// means a poison file is caught before the user even confirms, and before any write. - func prepareConfirmation(nodeConfigService: NodeConfigService?) { - guard !isPreparingConfirmation, !isApplying else { return } - guard let config = importedConfig, - let service = nodeConfigService else { return } - - errorMessage = nil - isPreparingConfirmation = true - previewTask = Task { - defer { isPreparingConfirmation = false } - do { - let preview = try await service.previewImport(config, sections: sections) - // If the user left the screen while the round-trip was in flight, the dismissal has - // already reset the UI; presenting the confirmation now would pop an alert over a - // stale, off-screen state, so honour the cancellation even when the BLE call won the - // race and returned before observing it. - guard !Task.isCancelled else { return } - channelsWouldOverwrite = preview.channelsOverwriteExisting - errorMessage = nil - showConfirmation = true - } catch is CancellationError { - // The user left the screen mid-preview — leave the UI untouched. - } catch { - errorMessage = error.userFacingMessage - logger.error("Import preview failed: \(error.localizedDescription)") - } + } + + do { + try await service.importConfig( + config, + sections: sections, + radioID: radioID + ) { progress in + progressContinuation.yield(progress) } - } - - /// Apply the imported config to the device. Nil parameters mirror a disconnected state. - func applyConfig(nodeConfigService: NodeConfigService?, settingsService: SettingsService?, radioID: UUID?) { - guard !isApplying else { return } - guard let config = importedConfig, - let service = nodeConfigService, - let radioID else { return } - - isApplying = true - applyProgress = 0 - errorMessage = nil - importComplete = false - didApplyAnyWrite = false - - importTask = Task { - // Deliver progress through one stream consumed in order on the main actor, so updates - // can't race or arrive out of sequence the way a per-update `Task { @MainActor }` would. - let (progressStream, progressContinuation) = AsyncStream.makeStream(of: ImportProgress.self) - let consumer = Task { @MainActor in - for await progress in progressStream { - didApplyAnyWrite = true - applyProgress = Double(progress.current) / Double(max(1, progress.total)) - applyStepDescription = Self.description(for: progress.step) - } - } - - do { - try await service.importConfig( - config, - sections: sections, - radioID: radioID - ) { progress in - progressContinuation.yield(progress) - } - progressContinuation.finish() - await consumer.value - // Refresh cached device state so Settings UI reflects imported values - if let settingsService { - try? await settingsService.refreshDeviceInfo() - } - isApplying = false - importComplete = true - try? await Task.sleep(for: .seconds(1.5)) - guard !Task.isCancelled else { return } - resetToFileSelection() - } catch is CancellationError { - progressContinuation.finish() - await consumer.value - isApplying = false - // "No progress reported" means no destructive write landed, so the device is untouched. - errorMessage = Self.cancellationMessage(didApplyAnyWrite: didApplyAnyWrite) - } catch { - progressContinuation.finish() - await consumer.value - isApplying = false - errorMessage = Self.failureMessage(for: error, didApplyAnyWrite: didApplyAnyWrite) - logger.error("Import failed: \(error.localizedDescription)") - } + progressContinuation.finish() + await consumer.value + // Refresh cached device state so Settings UI reflects imported values + if let settingsService { + try? await settingsService.refreshDeviceInfo() } - } - - func cancelImport() { - importTask?.cancel() - } - - /// Called when the screen goes away. Cancels any pending preview round-trip and, unless a - /// destructive import is in flight, resets back to the file picker so re-entering the screen - /// starts fresh instead of re-showing a stale preview or error. An in-flight import is left - /// running with its progress state intact: it is a destructive, safety-critical operation with - /// its own explicit cancel control, so a transient view disappearance must neither abort it - /// mid-write nor hide its result from a user who returns. - func handleDismissal() { - parseTask?.cancel() - currentParseID = nil - previewTask?.cancel() - guard !isApplying else { return } + isApplying = false + importComplete = true + try? await Task.sleep(for: .seconds(1.5)) + guard !Task.isCancelled else { return } resetToFileSelection() - } - - /// Maps a service-layer ``ImportStep`` to a localized progress description, keeping `L10n` - /// in the app layer so the service stays localization-free. - private static func description(for step: ImportStep) -> String { - switch step { - case .position: L10n.Settings.ConfigImport.stepPosition - case .otherParameters: L10n.Settings.ConfigImport.stepOtherParameters - case .privateKey: L10n.Settings.ConfigImport.stepPrivateKey - case .nodeName: L10n.Settings.ConfigImport.stepNodeName - case .radioParameters: L10n.Settings.ConfigImport.stepRadioParameters - case .txPower: L10n.Settings.ConfigImport.stepTxPower - case .channel(let name): L10n.Settings.ConfigImport.stepChannel(name) - case .contact(let name): L10n.Settings.ConfigImport.stepContact(name) - } - } - - /// Localized message for a cancelled import; distinguishes a clean cancel from one where a - /// destructive write already landed on the device. - static func cancellationMessage(didApplyAnyWrite: Bool) -> String { - didApplyAnyWrite - ? L10n.Settings.ConfigImport.cancelledPartial - : L10n.Settings.ConfigImport.cancelled - } - - /// Localized message for a failed import; distinguishes a clean failure from one where a - /// destructive write already landed. - static func failureMessage(for error: Error, didApplyAnyWrite: Bool) -> String { - didApplyAnyWrite - ? L10n.Settings.ConfigImport.failedPartial(error.userFacingMessage) - : error.userFacingMessage - } - - /// Reset to the initial file-selection state so the user can import another file. - private func resetToFileSelection() { - importedConfig = nil - errorMessage = nil - isParsing = false - sections = ConfigSections() - currentName = nil - currentRadio = nil - currentPosition = nil - applyProgress = 0 - applyStepDescription = "" - importComplete = false + } catch is CancellationError { + progressContinuation.finish() + await consumer.value isApplying = false - isPreparingConfirmation = false - showConfirmation = false - channelsWouldOverwrite = false - didApplyAnyWrite = false + // "No progress reported" means no destructive write landed, so the device is untouched. + errorMessage = Self.cancellationMessage(didApplyAnyWrite: didApplyAnyWrite) + } catch { + progressContinuation.finish() + await consumer.value + isApplying = false + errorMessage = Self.failureMessage(for: error, didApplyAnyWrite: didApplyAnyWrite) + logger.error("Import failed: \(error.localizedDescription)") + } + } + } + + func cancelImport() { + importTask?.cancel() + } + + /// Called when the screen goes away. Cancels any pending preview round-trip and, unless a + /// destructive import is in flight, resets back to the file picker so re-entering the screen + /// starts fresh instead of re-showing a stale preview or error. An in-flight import is left + /// running with its progress state intact: it is a destructive, safety-critical operation with + /// its own explicit cancel control, so a transient view disappearance must neither abort it + /// mid-write nor hide its result from a user who returns. + func handleDismissal() { + parseTask?.cancel() + currentParseID = nil + previewTask?.cancel() + guard !isApplying else { return } + resetToFileSelection() + } + + /// Maps a service-layer ``ImportStep`` to a localized progress description, keeping `L10n` + /// in the app layer so the service stays localization-free. + private static func description(for step: ImportStep) -> String { + switch step { + case .position: L10n.Settings.ConfigImport.stepPosition + case .otherParameters: L10n.Settings.ConfigImport.stepOtherParameters + case .privateKey: L10n.Settings.ConfigImport.stepPrivateKey + case .nodeName: L10n.Settings.ConfigImport.stepNodeName + case .radioParameters: L10n.Settings.ConfigImport.stepRadioParameters + case .txPower: L10n.Settings.ConfigImport.stepTxPower + case let .channel(name): L10n.Settings.ConfigImport.stepChannel(name) + case let .contact(name): L10n.Settings.ConfigImport.stepContact(name) } + } + + /// Localized message for a cancelled import; distinguishes a clean cancel from one where a + /// destructive write already landed on the device. + static func cancellationMessage(didApplyAnyWrite: Bool) -> String { + didApplyAnyWrite + ? L10n.Settings.ConfigImport.cancelledPartial + : L10n.Settings.ConfigImport.cancelled + } + + /// Localized message for a failed import; distinguishes a clean failure from one where a + /// destructive write already landed. + static func failureMessage(for error: Error, didApplyAnyWrite: Bool) -> String { + didApplyAnyWrite + ? L10n.Settings.ConfigImport.failedPartial(error.userFacingMessage) + : error.userFacingMessage + } + + /// Reset to the initial file-selection state so the user can import another file. + private func resetToFileSelection() { + importedConfig = nil + errorMessage = nil + isParsing = false + sections = ConfigSections() + currentName = nil + currentRadio = nil + currentPosition = nil + applyProgress = 0 + applyStepDescription = "" + importComplete = false + isApplying = false + isPreparingConfirmation = false + showConfirmation = false + channelsWouldOverwrite = false + didApplyAnyWrite = false + } } diff --git a/MC1/Views/Settings/NotificationSettingsView.swift b/MC1/Views/Settings/NotificationSettingsView.swift index 61f590f3..9221cd0f 100644 --- a/MC1/Views/Settings/NotificationSettingsView.swift +++ b/MC1/Views/Settings/NotificationSettingsView.swift @@ -1,14 +1,14 @@ import SwiftUI struct NotificationSettingsView: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - List { - NotificationSettingsSection() - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Notifications.header) - .navigationBarTitleDisplayMode(.inline) + var body: some View { + List { + NotificationSettingsSection() } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Notifications.header) + .navigationBarTitleDisplayMode(.inline) + } } diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift index 61fe1c99..c29ad94d 100644 --- a/MC1/Views/Settings/OfflineMapSettingsView.swift +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -3,452 +3,450 @@ import MapLibre import SwiftUI struct OfflineMapSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingRegionPicker = false - @State private var errorMessage: String? - - var body: some View { - Group { - if appState.offlineMapService.packs.isEmpty { - ContentUnavailableView { - Label(L10n.Settings.OfflineMaps.emptyTitle, systemImage: "map") - } description: { - Text(L10n.Settings.OfflineMaps.emptyDescription) - } actions: { - Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "arrow.down.circle") { - showingRegionPicker = true - } - .buttonStyle(.bordered) - } - } else { - List { - PacksSection() - StorageSection() - } - .themedCanvas(theme) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "plus") { - showingRegionPicker = true - } - } - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingRegionPicker = false + @State private var errorMessage: String? + + var body: some View { + Group { + if appState.offlineMapService.packs.isEmpty { + ContentUnavailableView { + Label(L10n.Settings.OfflineMaps.emptyTitle, systemImage: "map") + } description: { + Text(L10n.Settings.OfflineMaps.emptyDescription) + } actions: { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "arrow.down.circle") { + showingRegionPicker = true + } + .buttonStyle(.bordered) } - .navigationTitle(L10n.Settings.OfflineMaps.title) - .sheet(isPresented: $showingRegionPicker) { - RegionPickerSheet() + } else { + List { + PacksSection() + StorageSection() } - .onChange(of: appState.offlineMapService.lastPackError) { _, newValue in - if let newValue { - errorMessage = newValue - appState.offlineMapService.clearLastPackError() + .themedCanvas(theme) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "plus") { + showingRegionPicker = true } + } } - .errorAlert($errorMessage) + } } - + .navigationTitle(L10n.Settings.OfflineMaps.title) + .sheet(isPresented: $showingRegionPicker) { + RegionPickerSheet() + } + .onChange(of: appState.offlineMapService.lastPackError) { _, newValue in + if let newValue { + errorMessage = newValue + appState.offlineMapService.clearLastPackError() + } + } + .errorAlert($errorMessage) + } } // MARK: - Packs Section private struct PacksSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - var body: some View { - Section { - ForEach(appState.offlineMapService.packs) { pack in - OfflinePackRow(pack: pack) - } - .onDelete { indexSet in - if let index = indexSet.first { - let pack = appState.offlineMapService.packs[index] - Task { await appState.offlineMapService.deletePack(pack) } - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + var body: some View { + Section { + ForEach(appState.offlineMapService.packs) { pack in + OfflinePackRow(pack: pack) + } + .onDelete { indexSet in + if let index = indexSet.first { + let pack = appState.offlineMapService.packs[index] + Task { await appState.offlineMapService.deletePack(pack) } } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } // MARK: - Storage Section private struct StorageSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - var body: some View { - Section { - LabeledContent(L10n.Settings.OfflineMaps.storageUsed) { - Text(appState.offlineMapService.databaseSize, format: .byteCount(style: .file)) - } - } header: { - Text(L10n.Settings.OfflineMaps.storage) - } footer: { - Text(L10n.Settings.OfflineMaps.storageFooter) - } - .themedRowBackground(theme) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + var body: some View { + Section { + LabeledContent(L10n.Settings.OfflineMaps.storageUsed) { + Text(appState.offlineMapService.databaseSize, format: .byteCount(style: .file)) + } + } header: { + Text(L10n.Settings.OfflineMaps.storage) + } footer: { + Text(L10n.Settings.OfflineMaps.storageFooter) } + .themedRowBackground(theme) + } } // MARK: - Offline Pack Row private struct OfflinePackRow: View { - @Environment(\.appState) private var appState - let pack: OfflinePack - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(pack.name) - Text("— \(pack.layer.label)") - .foregroundStyle(.secondary) - } + @Environment(\.appState) private var appState + let pack: OfflinePack + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(pack.name) + Text("— \(pack.layer.label)") + .foregroundStyle(.secondary) + } + + HStack { + if pack.isComplete { + Text(L10n.Settings.OfflineMaps.complete) + .foregroundStyle(.secondary) + } else if pack.isPaused { + Text(L10n.Settings.OfflineMaps.paused) + .foregroundStyle(.secondary) + } else { + Text(L10n.Settings.OfflineMaps.downloading) + .foregroundStyle(.secondary) + } - HStack { - if pack.isComplete { - Text(L10n.Settings.OfflineMaps.complete) - .foregroundStyle(.secondary) - } else if pack.isPaused { - Text(L10n.Settings.OfflineMaps.paused) - .foregroundStyle(.secondary) - } else { - Text(L10n.Settings.OfflineMaps.downloading) - .foregroundStyle(.secondary) - } - - Spacer() - - VStack(alignment: .trailing) { - Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) - if let speed = pack.downloadSpeed, speed > 0 { - Text("\(speed, format: .byteCount(style: .file))/s") - } - } - .foregroundStyle(.secondary) - } - .font(.caption) + Spacer() - if !pack.isComplete { - HStack { - ProgressView(value: pack.completedFraction) - - Button( - pack.isPaused - ? L10n.Settings.OfflineMaps.resume - : L10n.Settings.OfflineMaps.pause, - systemImage: pack.isPaused ? "play.fill" : "pause.fill" - ) { - if pack.isPaused { - appState.offlineMapService.resumePack(pack) - } else { - appState.offlineMapService.pausePack(pack) - } - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - } + VStack(alignment: .trailing) { + Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) + if let speed = pack.downloadSpeed, speed > 0 { + Text("\(speed, format: .byteCount(style: .file))/s") + } + } + .foregroundStyle(.secondary) + } + .font(.caption) + + if !pack.isComplete { + HStack { + ProgressView(value: pack.completedFraction) + + Button( + pack.isPaused + ? L10n.Settings.OfflineMaps.resume + : L10n.Settings.OfflineMaps.pause, + systemImage: pack.isPaused ? "play.fill" : "pause.fill" + ) { + if pack.isPaused { + appState.offlineMapService.resumePack(pack) + } else { + appState.offlineMapService.pausePack(pack) } + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) } + } } + } } // MARK: - Region Picker Sheet private struct RegionPickerSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme - - @State private var regionName = "" - @State private var cameraRegion: MKCoordinateRegion? - @State private var isDownloading = false - @State private var errorMessage: String? - @State private var mapSize: CGSize = .zero - @State private var includeTopo = false - @State private var isStyleLoaded = false - @State private var debouncedRegion: MKCoordinateRegion? - @State private var debounceTask: Task? - @State private var availableBytes: Int64? - - private static let selectionPadding: CGFloat = 40 - - var body: some View { - NavigationStack { - ZStack { - MC1MapView( - points: [], - lines: [], - mapStyle: .standard, - isDarkMode: colorScheme == .dark, - showLabels: false, - showsUserLocation: true, - isInteractive: true, - showsScale: false, - isNorthLocked: true, - cameraRegion: $cameraRegion, - cameraRegionVersion: 0, - onPointTap: nil, - onMapTap: nil, - onCameraRegionChange: { region in - cameraRegion = region - debounceTask?.cancel() - debounceTask = Task { - try? await Task.sleep(for: .milliseconds(200)) - guard !Task.isCancelled else { return } - debouncedRegion = region - } - }, - isStyleLoaded: $isStyleLoaded - ) - - // Selection rectangle overlay - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color.accentColor, lineWidth: 2) - .padding(Self.selectionPadding) - .allowsHitTesting(false) - } - .onGeometryChange(for: CGSize.self) { proxy in - proxy.size - } action: { newValue in - mapSize = newValue - } - .navigationTitle(L10n.Settings.OfflineMaps.pickRegion) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Settings.OfflineMaps.cancel) { - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Settings.OfflineMaps.download) { - downloadRegion() - } - .disabled( - regionName.isEmpty || isDownloading || exceedsAvailableSpace - || !appState.offlineMapService.isNetworkAvailable - || selectionBounds == nil - ) - } - } - .safeAreaInset(edge: .bottom) { - RegionPickerBottomCard( - regionName: $regionName, - includeTopo: $includeTopo, - estimatedDownloadBytes: estimatedDownloadBytes, - exceedsAvailableSpace: exceedsAvailableSpace, - isNetworkAvailable: appState.offlineMapService.isNetworkAvailable - ) + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + @State private var regionName = "" + @State private var cameraRegion: MKCoordinateRegion? + @State private var isDownloading = false + @State private var errorMessage: String? + @State private var mapSize: CGSize = .zero + @State private var includeTopo = false + @State private var isStyleLoaded = false + @State private var debouncedRegion: MKCoordinateRegion? + @State private var debounceTask: Task? + @State private var availableBytes: Int64? + + private static let selectionPadding: CGFloat = 40 + + var body: some View { + NavigationStack { + ZStack { + MC1MapView( + points: [], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: true, + isInteractive: true, + showsScale: false, + isNorthLocked: true, + cameraRegion: $cameraRegion, + cameraRegionVersion: 0, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { region in + cameraRegion = region + debounceTask?.cancel() + debounceTask = Task { + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + debouncedRegion = region } - .errorAlert($errorMessage) - .onAppear { refreshAvailableBytes() } - .onChange(of: debouncedRegion?.center.latitude) { _, _ in refreshAvailableBytes() } - .onChange(of: debouncedRegion?.center.longitude) { _, _ in refreshAvailableBytes() } - } - } - - // MARK: - Download Estimate - - private var selectedLayers: Set { - var layers: Set = [.base] - if includeTopo { layers.insert(.topo) } - return layers - } + }, + isStyleLoaded: $isStyleLoaded + ) - private var estimatedDownloadBytes: Int64? { - guard let bounds = selectionBounds else { return nil } - return selectedLayers.reduce(Int64(0)) { total, layer in - total + OfflineMapService.estimatedDownloadSize(bounds: bounds, minZoom: 10, maxZoom: Int(layer.maxDownloadZoom), layer: layer) + // Selection rectangle overlay + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.accentColor, lineWidth: 2) + .padding(Self.selectionPadding) + .allowsHitTesting(false) + } + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newValue in + mapSize = newValue + } + .navigationTitle(L10n.Settings.OfflineMaps.pickRegion) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Settings.OfflineMaps.cancel) { + dismiss() + } } - } - - private var exceedsAvailableSpace: Bool { - guard let estimated = estimatedDownloadBytes, - let available = availableBytes else { return false } - return estimated > available - } - - private func refreshAvailableBytes() { - let values = try? URL.documentsDirectory.resourceValues( - forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Settings.OfflineMaps.download) { + downloadRegion() + } + .disabled( + regionName.isEmpty || isDownloading || exceedsAvailableSpace + || !appState.offlineMapService.isNetworkAvailable + || selectionBounds == nil + ) + } + } + .safeAreaInset(edge: .bottom) { + RegionPickerBottomCard( + regionName: $regionName, + includeTopo: $includeTopo, + estimatedDownloadBytes: estimatedDownloadBytes, + exceedsAvailableSpace: exceedsAvailableSpace, + isNetworkAvailable: appState.offlineMapService.isNetworkAvailable ) - availableBytes = values?.volumeAvailableCapacityForImportantUsage + } + .errorAlert($errorMessage) + .onAppear { refreshAvailableBytes() } + .onChange(of: debouncedRegion?.center.latitude) { _, _ in refreshAvailableBytes() } + .onChange(of: debouncedRegion?.center.longitude) { _, _ in refreshAvailableBytes() } } + } - // MARK: - Bounds - - private var selectionBounds: MLNCoordinateBounds? { - guard let region = debouncedRegion, - mapSize.width > 0, mapSize.height > 0 else { return nil } + // MARK: - Download Estimate - let lonFraction = Self.selectionPadding / (mapSize.width / 2) - let latFraction = Self.selectionPadding / (mapSize.height / 2) + private var selectedLayers: Set { + var layers: Set = [.base] + if includeTopo { layers.insert(.topo) } + return layers + } - let latInset = region.span.latitudeDelta * latFraction - let lonInset = region.span.longitudeDelta * lonFraction - - return MLNCoordinateBounds( - sw: CLLocationCoordinate2D( - latitude: region.center.latitude - (region.span.latitudeDelta / 2 - latInset), - longitude: region.center.longitude - (region.span.longitudeDelta / 2 - lonInset) - ), - ne: CLLocationCoordinate2D( - latitude: region.center.latitude + (region.span.latitudeDelta / 2 - latInset), - longitude: region.center.longitude + (region.span.longitudeDelta / 2 - lonInset) - ) - ) + private var estimatedDownloadBytes: Int64? { + guard let bounds = selectionBounds else { return nil } + return selectedLayers.reduce(Int64(0)) { total, layer in + total + OfflineMapService.estimatedDownloadSize(bounds: bounds, minZoom: 10, maxZoom: Int(layer.maxDownloadZoom), layer: layer) } - - // MARK: - Download - - private func downloadRegion() { - guard let bounds = selectionBounds else { return } - isDownloading = true - - let layers = selectedLayers - - Task { - defer { isDownloading = false } - do { - try await appState.offlineMapService.downloadRegion( - name: regionName, - bounds: bounds, - layers: layers - ) - dismiss() - } catch { - errorMessage = error.userFacingMessage - } - } + } + + private var exceedsAvailableSpace: Bool { + guard let estimated = estimatedDownloadBytes, + let available = availableBytes else { return false } + return estimated > available + } + + private func refreshAvailableBytes() { + let values = try? URL.documentsDirectory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + availableBytes = values?.volumeAvailableCapacityForImportantUsage + } + + // MARK: - Bounds + + private var selectionBounds: MLNCoordinateBounds? { + guard let region = debouncedRegion, + mapSize.width > 0, mapSize.height > 0 else { return nil } + + let lonFraction = Self.selectionPadding / (mapSize.width / 2) + let latFraction = Self.selectionPadding / (mapSize.height / 2) + + let latInset = region.span.latitudeDelta * latFraction + let lonInset = region.span.longitudeDelta * lonFraction + + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: region.center.latitude - (region.span.latitudeDelta / 2 - latInset), + longitude: region.center.longitude - (region.span.longitudeDelta / 2 - lonInset) + ), + ne: CLLocationCoordinate2D( + latitude: region.center.latitude + (region.span.latitudeDelta / 2 - latInset), + longitude: region.center.longitude + (region.span.longitudeDelta / 2 - lonInset) + ) + ) + } + + // MARK: - Download + + private func downloadRegion() { + guard let bounds = selectionBounds else { return } + isDownloading = true + + let layers = selectedLayers + + Task { + defer { isDownloading = false } + do { + try await appState.offlineMapService.downloadRegion( + name: regionName, + bounds: bounds, + layers: layers + ) + dismiss() + } catch { + errorMessage = error.userFacingMessage + } } + } } // MARK: - Region Picker Bottom Card private struct RegionPickerBottomCard: View { - @Binding var regionName: String - @Binding var includeTopo: Bool - let estimatedDownloadBytes: Int64? - let exceedsAvailableSpace: Bool - let isNetworkAvailable: Bool - - /// Warn when estimated download exceeds 500 MB. - private static let largeDownloadThreshold: Int64 = 500_000_000 - - @State private var footerMinHeight: CGFloat = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - TextField(L10n.Settings.OfflineMaps.regionName, text: $regionName) - .textFieldStyle(.plain) - - Divider() - - VStack(alignment: .leading) { - Text(L10n.Settings.OfflineMaps.layers) - .font(.caption) - .foregroundStyle(.secondary) - - Toggle(L10n.Settings.OfflineMaps.Layer.topo, isOn: $includeTopo) + @Binding var regionName: String + @Binding var includeTopo: Bool + let estimatedDownloadBytes: Int64? + let exceedsAvailableSpace: Bool + let isNetworkAvailable: Bool + + /// Warn when estimated download exceeds 500 MB. + private static let largeDownloadThreshold: Int64 = 500_000_000 + + @State private var footerMinHeight: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + TextField(L10n.Settings.OfflineMaps.regionName, text: $regionName) + .textFieldStyle(.plain) + + Divider() + + VStack(alignment: .leading) { + Text(L10n.Settings.OfflineMaps.layers) + .font(.caption) + .foregroundStyle(.secondary) + + Toggle(L10n.Settings.OfflineMaps.Layer.topo, isOn: $includeTopo) + } + .toggleStyle(.switch) + .controlSize(.mini) + + Divider() + + footerContent + .frame(minHeight: footerMinHeight, alignment: .top) + .background { + // Measure the tallest possible footer state (estimate + warning) + // to reserve stable space regardless of current state or Dynamic Type size. + tallestFooterState + .hidden() + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + footerMinHeight = height } - .toggleStyle(.switch) - .controlSize(.mini) - - Divider() - - footerContent - .frame(minHeight: footerMinHeight, alignment: .top) - .background { - // Measure the tallest possible footer state (estimate + warning) - // to reserve stable space regardless of current state or Dynamic Type size. - tallestFooterState - .hidden() - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - } action: { height in - footerMinHeight = height - } - } } - .padding() - .background(.regularMaterial, in: .rect(cornerRadius: 12)) - .padding(.horizontal) - .padding(.bottom, 4) } - - @ViewBuilder - private var footerContent: some View { - VStack(alignment: .leading, spacing: 12) { - if !isNetworkAvailable { - HStack { - Image(systemName: "wifi.slash") - .foregroundStyle(.red) - Text(L10n.Settings.OfflineMaps.noNetwork) - .foregroundStyle(.red) - } - .font(.caption) - } else if let bytes = estimatedDownloadBytes { - let isLarge = bytes > Self.largeDownloadThreshold - - HStack { - if exceedsAvailableSpace { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) - } else if isLarge { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } - - Text(L10n.Settings.OfflineMaps.estimatedSize( - bytes.formatted(.byteCount(style: .file)) - )) - .foregroundStyle(exceedsAvailableSpace ? .red : isLarge ? .orange : .secondary) - } - .font(.caption) - - if exceedsAvailableSpace { - Text(L10n.Settings.OfflineMaps.exceedsStorage) - .font(.caption) - .foregroundStyle(.red) - } else if isLarge { - Text(L10n.Settings.OfflineMaps.largeTileWarning) - .font(.caption) - .foregroundStyle(.orange) - } else { - Text(L10n.Settings.OfflineMaps.downloadHint) - .font(.caption) - .foregroundStyle(.secondary) - } - } else { - Text(L10n.Settings.OfflineMaps.downloadHint) - .font(.caption) - .foregroundStyle(.secondary) - } + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + .padding(.horizontal) + .padding(.bottom, 4) + } + + private var footerContent: some View { + VStack(alignment: .leading, spacing: 12) { + if !isNetworkAvailable { + HStack { + Image(systemName: "wifi.slash") + .foregroundStyle(.red) + Text(L10n.Settings.OfflineMaps.noNetwork) + .foregroundStyle(.red) } - } + .font(.caption) + } else if let bytes = estimatedDownloadBytes { + let isLarge = bytes > Self.largeDownloadThreshold + + HStack { + if exceedsAvailableSpace { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } else if isLarge { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + + Text(L10n.Settings.OfflineMaps.estimatedSize( + bytes.formatted(.byteCount(style: .file)) + )) + .foregroundStyle(exceedsAvailableSpace ? .red : isLarge ? .orange : .secondary) + } + .font(.caption) - /// The tallest possible single footer state: estimate line + the longest warning. - /// Rendered hidden to measure the height needed at the current Dynamic Type size. - private var tallestFooterState: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "xmark.circle.fill") - Text(L10n.Settings.OfflineMaps.estimatedSize("999 GB")) - } + if exceedsAvailableSpace { + Text(L10n.Settings.OfflineMaps.exceedsStorage) .font(.caption) - - Text(L10n.Settings.OfflineMaps.exceedsStorage) - .font(.caption) + .foregroundStyle(.red) + } else if isLarge { + Text(L10n.Settings.OfflineMaps.largeTileWarning) + .font(.caption) + .foregroundStyle(.orange) + } else { + Text(L10n.Settings.OfflineMaps.downloadHint) + .font(.caption) + .foregroundStyle(.secondary) } + } else { + Text(L10n.Settings.OfflineMaps.downloadHint) + .font(.caption) + .foregroundStyle(.secondary) + } } + } + + /// The tallest possible single footer state: estimate line + the longest warning. + /// Rendered hidden to measure the height needed at the current Dynamic Type size. + private var tallestFooterState: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "xmark.circle.fill") + Text(L10n.Settings.OfflineMaps.estimatedSize("999 GB")) + } + .font(.caption) + + Text(L10n.Settings.OfflineMaps.exceedsStorage) + .font(.caption) + } + } } #Preview { - NavigationStack { - OfflineMapSettingsView() - .environment(\.appState, AppState()) - } + NavigationStack { + OfflineMapSettingsView() + .environment(\.appState, AppState()) + } } diff --git a/MC1/Views/Settings/PublicKeyView.swift b/MC1/Views/Settings/PublicKeyView.swift index 76ea1923..55e55a3a 100644 --- a/MC1/Views/Settings/PublicKeyView.swift +++ b/MC1/Views/Settings/PublicKeyView.swift @@ -1,47 +1,47 @@ -import SwiftUI import MC1Services +import SwiftUI /// Read-only display of a device public key with copy support, pushed from `DeviceInfoView` /// via `SettingsSubpage.publicKey`. struct PublicKeyView: View { - let publicKey: Data + let publicKey: Data - @Environment(\.appTheme) private var theme - @State private var copyHapticTrigger = 0 + @Environment(\.appTheme) private var theme + @State private var copyHapticTrigger = 0 - var body: some View { - List { - Section { - Text(publicKey.uppercaseHexString(separator: " ")) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } header: { - Text(L10n.Settings.PublicKey.header) - } footer: { - Text(L10n.Settings.PublicKey.footer) - } - .themedRowBackground(theme) + var body: some View { + List { + Section { + Text(publicKey.uppercaseHexString(separator: " ")) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } header: { + Text(L10n.Settings.PublicKey.header) + } footer: { + Text(L10n.Settings.PublicKey.footer) + } + .themedRowBackground(theme) - Section { - Button { - copyHapticTrigger += 1 - UIPasteboard.general.string = publicKey.uppercaseHexString() - } label: { - Label(L10n.Settings.PublicKey.copy, systemImage: "doc.on.doc") - } - - // Base64 representation - Text(publicKey.base64EncodedString()) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } header: { - Text(L10n.Settings.PublicKey.Base64.header) - } - .themedRowBackground(theme) + Section { + Button { + copyHapticTrigger += 1 + UIPasteboard.general.string = publicKey.uppercaseHexString() + } label: { + Label(L10n.Settings.PublicKey.copy, systemImage: "doc.on.doc") } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.PublicKey.title) - .sensoryFeedback(.success, trigger: copyHapticTrigger) + + // Base64 representation + Text(publicKey.base64EncodedString()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } header: { + Text(L10n.Settings.PublicKey.Base64.header) + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.PublicKey.title) + .sensoryFeedback(.success, trigger: copyHapticTrigger) + } } diff --git a/MC1/Views/Settings/RadioSettingsView.swift b/MC1/Views/Settings/RadioSettingsView.swift index 07260565..651022c1 100644 --- a/MC1/Views/Settings/RadioSettingsView.swift +++ b/MC1/Views/Settings/RadioSettingsView.swift @@ -2,14 +2,14 @@ import SwiftUI /// Sub-page wrapping RadioPresetSection for the settings navigation struct RadioSettingsView: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - List { - RadioPresetSection() - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Radio.header) - .navigationBarTitleDisplayMode(.inline) + var body: some View { + List { + RadioPresetSection() } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Radio.header) + .navigationBarTitleDisplayMode(.inline) + } } diff --git a/MC1/Views/Settings/Sections/AboutSection.swift b/MC1/Views/Settings/Sections/AboutSection.swift index f1e7b023..34e58206 100644 --- a/MC1/Views/Settings/Sections/AboutSection.swift +++ b/MC1/Views/Settings/Sections/AboutSection.swift @@ -2,75 +2,75 @@ import SwiftUI /// About and links section struct AboutSection: View { - @Environment(\.appTheme) private var theme - let isSidebar: Bool + @Environment(\.appTheme) private var theme + let isSidebar: Bool - var body: some View { - Section { - #if SIDELOAD - Link(destination: URL(string: "https://github.com/sponsors/Avi0n")!) { - HStack { - TintedLabel(L10n.Settings.Support.title, systemImage: "heart") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) - #else - SettingsDetailRow(detail: .support) { - TintedLabel(L10n.Settings.Support.title, systemImage: "heart") - } - #endif - - Link(destination: URL(string: "https://meshcore.io")!) { - HStack { - TintedLabel(L10n.Settings.About.website, systemImage: "globe") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) + var body: some View { + Section { + #if SIDELOAD + Link(destination: URL(string: "https://github.com/sponsors/Avi0n")!) { + HStack { + TintedLabel(L10n.Settings.Support.title, systemImage: "heart") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) + #else + SettingsDetailRow(detail: .support) { + TintedLabel(L10n.Settings.Support.title, systemImage: "heart") + } + #endif - Link(destination: URL(string: "https://map.meshcore.io/")!) { - HStack { - TintedLabel(L10n.Settings.About.onlineMap, systemImage: "map") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) + Link(destination: URL(string: "https://meshcore.io")!) { + HStack { + TintedLabel(L10n.Settings.About.website, systemImage: "globe") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) - Link(destination: URL(string: "https://github.com/Avi0n/MeshCoreOne")!) { - HStack { - TintedLabel(L10n.Settings.About.github, systemImage: "chevron.left.forwardslash.chevron.right") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) + Link(destination: URL(string: "https://map.meshcore.io/")!) { + HStack { + TintedLabel(L10n.Settings.About.onlineMap, systemImage: "map") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) - Link(destination: URL(string: "https://meshcoreone.com/privacy.html")!) { - HStack { - TintedLabel(L10n.Settings.About.privacyPolicy, systemImage: "hand.raised") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) + Link(destination: URL(string: "https://github.com/Avi0n/MeshCoreOne")!) { + HStack { + TintedLabel(L10n.Settings.About.github, systemImage: "chevron.left.forwardslash.chevron.right") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) - } header: { - Text(L10n.Settings.About.header) + Link(destination: URL(string: "https://meshcoreone.com/privacy.html")!) { + HStack { + TintedLabel(L10n.Settings.About.privacyPolicy, systemImage: "hand.raised") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) } - .themedRowBackground(theme, flatten: isSidebar) + } + .foregroundStyle(.primary) + + } header: { + Text(L10n.Settings.About.header) } + .themedRowBackground(theme, flatten: isSidebar) + } } diff --git a/MC1/Views/Settings/Sections/ActivityView.swift b/MC1/Views/Settings/Sections/ActivityView.swift index 94b70d87..6fb95ff3 100644 --- a/MC1/Views/Settings/Sections/ActivityView.swift +++ b/MC1/Views/Settings/Sections/ActivityView.swift @@ -5,11 +5,11 @@ import UIKit /// from SwiftUI state. `ShareLink` cannot be triggered after asynchronous work, so the /// debug-log export drives this via `.sheet(item:)` once the file is generated. struct ActivityView: UIViewControllerRepresentable { - let activityItems: [Any] + let activityItems: [Any] - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } diff --git a/MC1/Views/Settings/Sections/AdvancedRadioSection.swift b/MC1/Views/Settings/Sections/AdvancedRadioSection.swift index 6e3c2c19..2556d715 100644 --- a/MC1/Views/Settings/Sections/AdvancedRadioSection.swift +++ b/MC1/Views/Settings/Sections/AdvancedRadioSection.swift @@ -1,251 +1,275 @@ -import SwiftUI import MC1Services +import SwiftUI /// Manual radio parameter configuration struct AdvancedRadioSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var frequency: Double? // MHz - @State private var bandwidth: UInt32? // Hz - @State private var spreadingFactor: Int? - @State private var codingRate: Int? - @State private var txPower: Int? // dBm - @State private var clientRepeat: Bool? - @State private var hasLoaded = false - @State private var isApplying = false - @State private var showSuccess = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @FocusState private var focusedField: RadioField? - - private enum RadioField: Hashable { - case frequency - case txPower - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var frequency: Double? // MHz + @State private var bandwidth: UInt32? // Hz + @State private var spreadingFactor: Int? + @State private var codingRate: Int? + @State private var txPower: Int? // dBm + @State private var clientRepeat: Bool? + @State private var hasLoaded = false + @State private var isApplying = false + @State private var showSuccess = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @FocusState private var focusedField: RadioField? - private var settingsModified: Bool { - guard let device = appState.connectedDevice else { return false } - return clientRepeat != appState.connectedDevice?.clientRepeat || - frequency != Double(device.frequency) / 1000.0 || - bandwidth != RadioOptions.nearestBandwidth(to: device.bandwidth) || - spreadingFactor != Int(device.spreadingFactor) || - codingRate != Int(device.codingRate) || - txPower != Int(device.txPower) - } + private enum RadioField: Hashable { + case frequency + case txPower + } - private var canApply: Bool { - appState.connectionState == .ready && settingsModified && !isApplying && !showSuccess - } + private var settingsModified: Bool { + guard let device = appState.connectedDevice else { return false } + return clientRepeat != appState.connectedDevice?.clientRepeat || + frequency != Double(device.frequency) / 1000.0 || + bandwidth != RadioOptions.nearestBandwidth(to: device.bandwidth) || + spreadingFactor != Int(device.spreadingFactor) || + codingRate != Int(device.codingRate) || + txPower != Int(device.txPower) + } - /// Combined hash of all radio settings for change detection - private var deviceRadioSettingsHash: Int { - var hasher = Hasher() - hasher.combine(appState.connectedDevice?.frequency) - hasher.combine(appState.connectedDevice?.bandwidth) - hasher.combine(appState.connectedDevice?.spreadingFactor) - hasher.combine(appState.connectedDevice?.codingRate) - hasher.combine(appState.connectedDevice?.txPower) - hasher.combine(appState.connectedDevice?.clientRepeat) - return hasher.finalize() - } + private var canApply: Bool { + appState.connectionState == .ready && settingsModified && !isApplying && !showSuccess + } - var body: some View { - Section { - if !hasLoaded { - ProgressView() - .frame(maxWidth: .infinity) - } else { - HStack { - Text(L10n.Settings.AdvancedRadio.frequency) - Spacer() - TextField( - L10n.Settings.AdvancedRadio.frequencyPlaceholder, - value: $frequency, - format: .number.precision(.fractionLength(3)).locale(.posix) - ) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 100) - .focused($focusedField, equals: .frequency) - } + /// Combined hash of all radio settings for change detection + private var deviceRadioSettingsHash: Int { + var hasher = Hasher() + hasher.combine(appState.connectedDevice?.frequency) + hasher.combine(appState.connectedDevice?.bandwidth) + hasher.combine(appState.connectedDevice?.spreadingFactor) + hasher.combine(appState.connectedDevice?.codingRate) + hasher.combine(appState.connectedDevice?.txPower) + hasher.combine(appState.connectedDevice?.clientRepeat) + return hasher.finalize() + } - Picker(L10n.Settings.AdvancedRadio.bandwidth, selection: $bandwidth) { - ForEach(RadioOptions.bandwidthsHz, id: \.self) { bwHz in - Text(RadioOptions.formatBandwidth(bwHz)) - .tag(bwHz as UInt32?) - .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(bwHz))) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.bandwidthHint) - - Picker(L10n.Settings.AdvancedRadio.spreadingFactor, selection: $spreadingFactor) { - ForEach(RadioOptions.spreadingFactors, id: \.self) { spreadFactorOption in - Text(spreadFactorOption, format: .number) - .tag(spreadFactorOption as Int?) - .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.spreadingFactorLabel(spreadFactorOption)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.spreadingFactorHint) - - Picker(L10n.Settings.AdvancedRadio.codingRate, selection: $codingRate) { - ForEach(RadioOptions.codingRates, id: \.self) { codeRateOption in - Text("\(codeRateOption)") - .tag(codeRateOption as Int?) - .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.codingRateLabel(codeRateOption)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.codingRateHint) - - HStack { - Text(L10n.Settings.AdvancedRadio.txPower) - Spacer() - TextField(L10n.Settings.AdvancedRadio.txPowerPlaceholder, value: $txPower, format: .number) - .keyboardType(.numbersAndPunctuation) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused($focusedField, equals: .txPower) - } + var body: some View { + Section { + if !hasLoaded { + ProgressView() + .frame(maxWidth: .infinity) + } else { + HStack { + Text(L10n.Settings.AdvancedRadio.frequency) + Spacer() + TextField( + L10n.Settings.AdvancedRadio.frequencyPlaceholder, + value: $frequency, + format: .number.precision(.fractionLength(3)).locale(.posix) + ) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 100) + .focused($focusedField, equals: .frequency) + } - if appState.connectedDevice?.supportsClientRepeat == true { - Toggle(isOn: Binding( - get: { clientRepeat ?? false }, - set: { clientRepeat = $0 } - )) { - Text(L10n.Settings.AdvancedRadio.repeatMode) - Text(L10n.Settings.AdvancedRadio.RepeatMode.footer) - } - .accessibilityHint(L10n.Settings.Radio.RepeatMode.accessibilityHint) - .disabled(!hasLoaded) - } + Picker(L10n.Settings.AdvancedRadio.bandwidth, selection: $bandwidth) { + ForEach(RadioOptions.bandwidthsHz, id: \.self) { bwHz in + Text(RadioOptions.formatBandwidth(bwHz)) + .tag(bwHz as UInt32?) + .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(bwHz))) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.bandwidthHint) - Button { - applySettings() - } label: { - AsyncActionLabel(isLoading: isApplying, showSuccess: showSuccess) { - Text(L10n.Settings.AdvancedRadio.apply) - .foregroundStyle(canApply ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .radioDisabled(for: appState.connectionState, or: isApplying || showSuccess || !settingsModified) - } - } header: { - Text(L10n.Settings.AdvancedRadio.header) - } footer: { - Text(L10n.Settings.AdvancedRadio.footer) + Picker(L10n.Settings.AdvancedRadio.spreadingFactor, selection: $spreadingFactor) { + ForEach(RadioOptions.spreadingFactors, id: \.self) { spreadFactorOption in + Text(spreadFactorOption, format: .number) + .tag(spreadFactorOption as Int?) + .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.spreadingFactorLabel(spreadFactorOption)) + } } - .themedRowBackground(theme) - .onAppear { - loadCurrentSettings() + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.spreadingFactorHint) + + Picker(L10n.Settings.AdvancedRadio.codingRate, selection: $codingRate) { + ForEach(RadioOptions.codingRates, id: \.self) { codeRateOption in + Text("\(codeRateOption)") + .tag(codeRateOption as Int?) + .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.codingRateLabel(codeRateOption)) + } } - .onChange(of: deviceRadioSettingsHash) { _, _ in - loadCurrentSettings() + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.codingRateHint) + + HStack { + Text(L10n.Settings.AdvancedRadio.txPower) + Spacer() + TextField(L10n.Settings.AdvancedRadio.txPowerPlaceholder, value: $txPower, format: .number) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused($focusedField, equals: .txPower) } - .errorAlert($errorMessage) - .retryAlert(retryAlert) + + if appState.connectedDevice?.supportsClientRepeat == true { + Toggle(isOn: Binding( + get: { clientRepeat ?? false }, + set: { newValue in + clientRepeat = newValue + if newValue { snapFrequencyForRepeat() } else { restoreFrequencyAfterRepeat() } + } + )) { + Text(L10n.Settings.AdvancedRadio.repeatMode) + Text(L10n.Settings.AdvancedRadio.RepeatMode.footer) + } + .accessibilityHint(L10n.Settings.Radio.RepeatMode.accessibilityHint) + .disabled(!hasLoaded) + } + + Button { + applySettings() + } label: { + AsyncActionLabel(isLoading: isApplying, showSuccess: showSuccess) { + Text(L10n.Settings.AdvancedRadio.apply) + .foregroundStyle(canApply ? Color.accentColor : .secondary) + .transition(.opacity) + } + } + .radioDisabled(for: appState.connectionState, or: isApplying || showSuccess || !settingsModified) + } + } header: { + Text(L10n.Settings.AdvancedRadio.header) + } footer: { + Text(L10n.Settings.AdvancedRadio.footer) } + .themedRowBackground(theme) + .onAppear { + loadCurrentSettings() + } + .onChange(of: deviceRadioSettingsHash) { _, _ in + // Skip reloads while our own apply settles the device model. Frequency and clientRepeat arrive + // as separate events, and reloading on that intermediate state flickers the frequency field; + // the local fields already hold the applied values. + guard !isApplying, !showSuccess else { return } + loadCurrentSettings() + } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } + + /// Repeat Mode requires an exact firmware-approved frequency, so an off-band value is snapped to + /// the nearest one; without this, enabling the toggle would submit a frequency the firmware rejects. + private func snapFrequencyForRepeat() { + guard let freqMHz = frequency else { return } + let currentKHz = UInt32((freqMHz * 1000).rounded()) + guard RadioPresets.matchingRepeatPreset(frequencyKHz: currentKHz) == nil, + let nearest = RadioPresets.nearestRepeatPreset(toFrequencyKHz: currentKHz) else { return } + frequency = nearest.frequencyMHz + } + + /// Restores the frequency the radio used before Repeat Mode so disabling the toggle returns to the + /// original band instead of stranding it on the repeat frequency; falls back to the current one. + private func restoreFrequencyAfterRepeat() { + guard let device = appState.connectedDevice else { return } + frequency = Double(device.preRepeatFrequency ?? device.frequency) / 1000.0 + } - private func loadCurrentSettings() { - guard let device = appState.connectedDevice else { return } - frequency = Double(device.frequency) / 1000.0 - // Use nearestBandwidth to handle devices with non-standard bandwidth values - // or firmware float precision issues (e.g., 7799 Hz instead of 7800 Hz) - bandwidth = RadioOptions.nearestBandwidth(to: device.bandwidth) - spreadingFactor = Int(device.spreadingFactor) - codingRate = Int(device.codingRate) - txPower = Int(device.txPower) - clientRepeat = device.clientRepeat - hasLoaded = true + private func loadCurrentSettings() { + guard let device = appState.connectedDevice else { return } + frequency = Double(device.frequency) / 1000.0 + // Use nearestBandwidth to handle devices with non-standard bandwidth values + // or firmware float precision issues (e.g., 7799 Hz instead of 7800 Hz) + bandwidth = RadioOptions.nearestBandwidth(to: device.bandwidth) + spreadingFactor = Int(device.spreadingFactor) + codingRate = Int(device.codingRate) + txPower = Int(device.txPower) + clientRepeat = device.clientRepeat + hasLoaded = true + } + + private func applySettings() { + guard let freqMHz = frequency, + let bandwidthHz = bandwidth, + let spreadFactor = spreadingFactor, + let codeRate = codingRate, + let power = txPower, + let settingsService = appState.services?.settingsService else { + errorMessage = L10n.Settings.AdvancedRadio.invalidInput + return + } + + // Pickers enforce bandwidth, SF, and CR; frequency and TX power are free-text, so + // validate them with non-trapping conversions before scaling into the wire fields. + let scaledFreqKHz = (freqMHz * 1000).rounded() + let freqInRange = freqMHz.isFinite + && scaledFreqKHz >= Double(PacketBuilder.frequencyRangeKHz.lowerBound) + && scaledFreqKHz <= Double(PacketBuilder.frequencyRangeKHz.upperBound) + let maxTxPower = appState.connectedDevice?.maxTxPower ?? PacketBuilder.txPowerFloor + guard freqInRange, + let frequencyKHz = UInt32(exactly: scaledFreqKHz), + let spreadFactorByte = UInt8(exactly: spreadFactor), + let codeRateByte = UInt8(exactly: codeRate), + let powerLevel = Int8(exactly: power), + powerLevel >= PacketBuilder.txPowerFloor, + powerLevel <= maxTxPower else { + errorMessage = L10n.Settings.AdvancedRadio.invalidInput + return } - private func applySettings() { - guard let freqMHz = frequency, - let bandwidthHz = bandwidth, - let spreadFactor = spreadingFactor, - let codeRate = codingRate, - let power = txPower, - let settingsService = appState.services?.settingsService else { - errorMessage = L10n.Settings.AdvancedRadio.invalidInput - return + isApplying = true + Task { + do { + // Save pre-repeat settings when enabling repeat mode + let wasRepeat = appState.connectedDevice?.clientRepeat ?? false + let willRepeat = clientRepeat ?? false + if !wasRepeat, willRepeat { + appState.connectionManager.savePreRepeatSettings() } - // Pickers enforce bandwidth, SF, and CR; frequency and TX power are free-text, so - // validate them with non-trapping conversions before scaling into the wire fields. - let scaledFreqKHz = (freqMHz * 1000).rounded() - let freqInRange = freqMHz.isFinite - && scaledFreqKHz >= Double(PacketBuilder.frequencyRangeKHz.lowerBound) - && scaledFreqKHz <= Double(PacketBuilder.frequencyRangeKHz.upperBound) - let maxTxPower = appState.connectedDevice?.maxTxPower ?? PacketBuilder.txPowerFloor - guard freqInRange, - let frequencyKHz = UInt32(exactly: scaledFreqKHz), - let spreadFactorByte = UInt8(exactly: spreadFactor), - let codeRateByte = UInt8(exactly: codeRate), - let powerLevel = Int8(exactly: power), - powerLevel >= PacketBuilder.txPowerFloor, - powerLevel <= maxTxPower else { - errorMessage = L10n.Settings.AdvancedRadio.invalidInput - return + // Set radio params first + _ = try await settingsService.setRadioParamsVerified( + frequencyKHz: frequencyKHz, + // Note: Parameter is misleadingly named "bandwidthKHz" but expects Hz. + // bandwidthHz is already UInt32 Hz from the picker, pass directly. + bandwidthKHz: bandwidthHz, + spreadingFactor: spreadFactorByte, + codingRate: codeRateByte, + clientRepeat: clientRepeat + ) + + // Clear pre-repeat settings when disabling repeat mode + if wasRepeat, !willRepeat { + appState.connectionManager.clearPreRepeatSettings() } - isApplying = true - Task { - do { - // Save pre-repeat settings when enabling repeat mode - let wasRepeat = appState.connectedDevice?.clientRepeat ?? false - let willRepeat = clientRepeat ?? false - if !wasRepeat && willRepeat { - appState.connectionManager.savePreRepeatSettings() - } - - // Set radio params first - _ = try await settingsService.setRadioParamsVerified( - frequencyKHz: frequencyKHz, - // Note: Parameter is misleadingly named "bandwidthKHz" but expects Hz. - // bandwidthHz is already UInt32 Hz from the picker, pass directly. - bandwidthKHz: bandwidthHz, - spreadingFactor: spreadFactorByte, - codingRate: codeRateByte, - clientRepeat: clientRepeat - ) - - // Clear pre-repeat settings when disabling repeat mode - if wasRepeat && !willRepeat { - appState.connectionManager.clearPreRepeatSettings() - } - - // Then set TX power - _ = try await settingsService.setTxPowerVerified(powerLevel) - - focusedField = nil // Dismiss keyboard on success - retryAlert.reset() - isApplying = false // Clear before showing success - - // Show success checkmark briefly - withAnimation { - showSuccess = true - } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { - showSuccess = false - } - return // Skip the isApplying = false at the end - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { applySettings() }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isApplying = false + // Then set TX power + _ = try await settingsService.setTxPowerVerified(powerLevel) + + focusedField = nil // Dismiss keyboard on success + retryAlert.reset() + isApplying = false // Clear before showing success + + // Show success checkmark briefly + withAnimation { + showSuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { + showSuccess = false } + return // Skip the isApplying = false at the end + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { applySettings() }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + } + isApplying = false } + } } diff --git a/MC1/Views/Settings/Sections/AppearanceSection.swift b/MC1/Views/Settings/Sections/AppearanceSection.swift index 07d3de37..7c13cfed 100644 --- a/MC1/Views/Settings/Sections/AppearanceSection.swift +++ b/MC1/Views/Settings/Sections/AppearanceSection.swift @@ -3,18 +3,18 @@ import SwiftUI /// Settings → Appearance row. Trailing detail shows the active theme's name (matching the /// region/mapStyle row pattern). Placed near the top, with other display-style settings. struct AppearanceSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var activeTheme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var activeTheme - var body: some View { - SettingsDetailRow(detail: .appearance) { - HStack { - TintedLabel(L10n.Settings.Appearance.title, systemImage: "paintpalette") - Spacer() - Text(activeTheme.localizedName) - .foregroundStyle(.secondary) - } - } - .accessibilityValue(activeTheme.localizedName) + var body: some View { + SettingsDetailRow(detail: .appearance) { + HStack { + TintedLabel(L10n.Settings.Appearance.title, systemImage: "paintpalette") + Spacer() + Text(activeTheme.localizedName) + .foregroundStyle(.secondary) + } } + .accessibilityValue(activeTheme.localizedName) + } } diff --git a/MC1/Views/Settings/Sections/BatteryCurveSection.swift b/MC1/Views/Settings/Sections/BatteryCurveSection.swift index ff0de798..570803d3 100644 --- a/MC1/Views/Settings/Sections/BatteryCurveSection.swift +++ b/MC1/Views/Settings/Sections/BatteryCurveSection.swift @@ -1,209 +1,209 @@ -import SwiftUI import MC1Services +import SwiftUI /// Battery curve configuration section /// Pure UI component - caller provides data and save callback struct BatteryCurveSection: View { - @Environment(\.appTheme) private var theme - let availablePresets: [OCVPreset] - let headerText: String - let footerText: String - - @Binding var selectedPreset: OCVPreset - @Binding var voltageValues: [Int] - - let onSave: (OCVPreset, [Int]) async -> Void - var isDisabled: Bool = false - - @State private var isEditingValues = false - @State private var validationError: String? - - var body: some View { - Section { - // Preset picker - Picker(L10n.Settings.BatteryCurve.preset, selection: $selectedPreset) { - ForEach(availablePresets, id: \.self) { preset in - Text(preset.displayName).tag(preset) - } - if selectedPreset == .custom && !availablePresets.contains(.custom) { - Text(L10n.Settings.BatteryCurve.custom).tag(OCVPreset.custom) - } - } - .onChange(of: selectedPreset) { _, newValue in - if newValue != .custom { - voltageValues = newValue.ocvArray - Task { - await onSave(newValue, newValue.ocvArray) - } - } - } - .disabled(isDisabled) - - BatteryCurveChart(ocvArray: voltageValues) - - // Edit values disclosure - DisclosureGroup(L10n.Settings.BatteryCurve.editValues, isExpanded: $isEditingValues) { - VoltageFieldsGrid( - voltageValues: $voltageValues, - validationError: $validationError, - onCommit: handleCommit - ) - } - .disabled(isDisabled) - - // Validation error - if let error = validationError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - } header: { - if !headerText.isEmpty { - Text(headerText) - } - } footer: { - if !footerText.isEmpty { - Text(footerText) - } + @Environment(\.appTheme) private var theme + let availablePresets: [OCVPreset] + let headerText: String + let footerText: String + + @Binding var selectedPreset: OCVPreset + @Binding var voltageValues: [Int] + + let onSave: (OCVPreset, [Int]) async -> Void + var isDisabled: Bool = false + + @State private var isEditingValues = false + @State private var validationError: String? + + var body: some View { + Section { + // Preset picker + Picker(L10n.Settings.BatteryCurve.preset, selection: $selectedPreset) { + ForEach(availablePresets, id: \.self) { preset in + Text(preset.displayName).tag(preset) } - .themedRowBackground(theme) - } - - private func handleCommit() { - if let error = validateVoltageValues() { - validationError = error - return + if selectedPreset == .custom, !availablePresets.contains(.custom) { + Text(L10n.Settings.BatteryCurve.custom).tag(OCVPreset.custom) } - validationError = nil - - // A commit whose values still match the selected preset is a no-op (e.g. focus - // left a field just after a preset switch replaced the values); reclassifying - // it as a custom curve would flip the picker and re-save identical values. - if selectedPreset != .custom && voltageValues == selectedPreset.ocvArray { - return + } + .onChange(of: selectedPreset) { _, newValue in + if newValue != .custom { + voltageValues = newValue.ocvArray + Task { + await onSave(newValue, newValue.ocvArray) + } } + } + .disabled(isDisabled) + + BatteryCurveChart(ocvArray: voltageValues) + + // Edit values disclosure + DisclosureGroup(L10n.Settings.BatteryCurve.editValues, isExpanded: $isEditingValues) { + VoltageFieldsGrid( + voltageValues: $voltageValues, + validationError: $validationError, + onCommit: handleCommit + ) + } + .disabled(isDisabled) + + // Validation error + if let error = validationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + if !headerText.isEmpty { + Text(headerText) + } + } footer: { + if !footerText.isEmpty { + Text(footerText) + } + } + .themedRowBackground(theme) + } - selectedPreset = .custom - Task { - await onSave(.custom, voltageValues) - } + private func handleCommit() { + if let error = validateVoltageValues() { + validationError = error + return } + validationError = nil - private func validateVoltageValues() -> String? { - for (index, value) in voltageValues.enumerated() { - if !OCVPreset.validMillivoltRange.contains(value) { - return L10n.Settings.BatteryCurve.Validation.outOfRange((10 - index) * 10) - } - } + // A commit whose values still match the selected preset is a no-op (e.g. focus + // left a field just after a preset switch replaced the values); reclassifying + // it as a custom curve would flip the picker and re-save identical values. + if selectedPreset != .custom, voltageValues == selectedPreset.ocvArray { + return + } - for (current, next) in zip(voltageValues, voltageValues.dropFirst()) where current <= next { - return L10n.Settings.BatteryCurve.Validation.notDescending - } + selectedPreset = .custom + Task { + await onSave(.custom, voltageValues) + } + } + + private func validateVoltageValues() -> String? { + for (index, value) in voltageValues.enumerated() { + if !OCVPreset.validMillivoltRange.contains(value) { + return L10n.Settings.BatteryCurve.Validation.outOfRange((10 - index) * 10) + } + } - return nil + for (current, next) in zip(voltageValues, voltageValues.dropFirst()) where current <= next { + return L10n.Settings.BatteryCurve.Validation.notDescending } + + return nil + } } /// Two-column grid of voltage input fields struct VoltageFieldsGrid: View { - @Binding var voltageValues: [Int] - @Binding var validationError: String? - let onCommit: () -> Void - - private let columns = [ - GridItem(.flexible()), - GridItem(.flexible()) - ] - - var body: some View { - LazyVGrid(columns: columns, spacing: 12) { - ForEach(0..<11, id: \.self) { index in - VoltageField( - percent: (10 - index) * 10, - value: $voltageValues[index], - hasError: fieldHasError(at: index), - onCommit: onCommit - ) - } - } - .padding(.vertical, 8) - } - - private func fieldHasError(at index: Int) -> Bool { - let value = voltageValues[index] - if !OCVPreset.validMillivoltRange.contains(value) { return true } - if index > 0 && voltageValues[index - 1] <= value { return true } - if index < 10 && value <= voltageValues[index + 1] { return true } - return false + @Binding var voltageValues: [Int] + @Binding var validationError: String? + let onCommit: () -> Void + + private let columns = [ + GridItem(.flexible()), + GridItem(.flexible()) + ] + + var body: some View { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(0..<11, id: \.self) { index in + VoltageField( + percent: (10 - index) * 10, + value: $voltageValues[index], + hasError: fieldHasError(at: index), + onCommit: onCommit + ) + } } + .padding(.vertical, 8) + } + + private func fieldHasError(at index: Int) -> Bool { + let value = voltageValues[index] + if !OCVPreset.validMillivoltRange.contains(value) { return true } + if index > 0, voltageValues[index - 1] <= value { return true } + if index < 10, value <= voltageValues[index + 1] { return true } + return false + } } /// Individual voltage input field. Commits when focus leaves the field (or on submit), /// not per keystroke, so transient mid-edit values never reach the device. struct VoltageField: View { - let percent: Int - @Binding var value: Int - let hasError: Bool - let onCommit: () -> Void - - @FocusState private var isFocused: Bool - @State private var valueWhenFocused: Int? - - var body: some View { - HStack { - Text("\(percent)%") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 40, alignment: .trailing) - - TextField("", value: $value, format: .number) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - .focused($isFocused) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(hasError ? .red : .clear, lineWidth: 1) - ) - .onChange(of: isFocused) { _, focused in - if focused { - valueWhenFocused = value - } else { - commitEdit() - valueWhenFocused = nil - } - } - .onSubmit(commitEdit) - .accessibilityLabel(L10n.Settings.BatteryCurve.Accessibility.voltageLabel(percent)) - .accessibilityValue(L10n.Settings.BatteryCurve.Accessibility.voltageValue(value)) - .accessibilityHint(L10n.Settings.BatteryCurve.Accessibility.voltageHint) - - Text(L10n.Settings.BatteryCurve.mv) - .font(.caption) - .foregroundStyle(.secondary) + let percent: Int + @Binding var value: Int + let hasError: Bool + let onCommit: () -> Void + + @FocusState private var isFocused: Bool + @State private var valueWhenFocused: Int? + + var body: some View { + HStack { + Text("\(percent)%") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 40, alignment: .trailing) + + TextField("", value: $value, format: .number) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .focused($isFocused) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(hasError ? .red : .clear, lineWidth: 1) + ) + .onChange(of: isFocused) { _, focused in + if focused { + valueWhenFocused = value + } else { + commitEdit() + valueWhenFocused = nil + } } + .onSubmit(commitEdit) + .accessibilityLabel(L10n.Settings.BatteryCurve.Accessibility.voltageLabel(percent)) + .accessibilityValue(L10n.Settings.BatteryCurve.Accessibility.voltageValue(value)) + .accessibilityHint(L10n.Settings.BatteryCurve.Accessibility.voltageHint) + + Text(L10n.Settings.BatteryCurve.mv) + .font(.caption) + .foregroundStyle(.secondary) } + } - private func commitEdit() { - guard valueWhenFocused != value else { return } - valueWhenFocused = value - onCommit() - } + private func commitEdit() { + guard valueWhenFocused != value else { return } + valueWhenFocused = value + onCommit() + } } #Preview { - @Previewable @State var preset: OCVPreset = .liIon - @Previewable @State var values: [Int] = OCVPreset.liIon.ocvArray - - NavigationStack { - List { - BatteryCurveSection( - availablePresets: OCVPreset.selectablePresets, - headerText: "Battery Curve", - footerText: "Configure the voltage-to-percentage curve for your device's battery.", - selectedPreset: $preset, - voltageValues: $values, - onSave: { _, _ in } - ) - } + @Previewable @State var preset: OCVPreset = .liIon + @Previewable @State var values: [Int] = OCVPreset.liIon.ocvArray + + NavigationStack { + List { + BatteryCurveSection( + availablePresets: OCVPreset.selectablePresets, + headerText: "Battery Curve", + footerText: "Configure the voltage-to-percentage curve for your device's battery.", + selectedPreset: $preset, + voltageValues: $values, + onSave: { _, _ in } + ) } + } } diff --git a/MC1/Views/Settings/Sections/BlockingSection.swift b/MC1/Views/Settings/Sections/BlockingSection.swift index d3ff006b..6fa72ba5 100644 --- a/MC1/Views/Settings/Sections/BlockingSection.swift +++ b/MC1/Views/Settings/Sections/BlockingSection.swift @@ -3,20 +3,20 @@ import SwiftUI /// Settings section for managing blocked channel users and contacts. struct BlockingSection: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - Section { - NavigationLink(value: SettingsSubpage.blockedChannelSenders) { - TintedLabel(L10n.Settings.Blocking.channelSenders, systemImage: "person.crop.circle.badge.xmark") - } + var body: some View { + Section { + NavigationLink(value: SettingsSubpage.blockedChannelSenders) { + TintedLabel(L10n.Settings.Blocking.channelSenders, systemImage: "person.crop.circle.badge.xmark") + } - NavigationLink(value: SettingsSubpage.blockedContacts) { - TintedLabel(L10n.Settings.Blocking.contacts, systemImage: "hand.raised.slash") - } - } header: { - Text(L10n.Settings.Blocking.header) - } - .themedRowBackground(theme) + NavigationLink(value: SettingsSubpage.blockedContacts) { + TintedLabel(L10n.Settings.Blocking.contacts, systemImage: "hand.raised.slash") + } + } header: { + Text(L10n.Settings.Blocking.header) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Settings/Sections/BluetoothSection.swift b/MC1/Views/Settings/Sections/BluetoothSection.swift index 82f4f3f8..472ec5a4 100644 --- a/MC1/Views/Settings/Sections/BluetoothSection.swift +++ b/MC1/Views/Settings/Sections/BluetoothSection.swift @@ -1,285 +1,285 @@ -import SwiftUI import MC1Services +import SwiftUI /// Firmware BLE pairing PIN protocol values. The firmware treats both the factory /// default and zero as "no custom PIN configured". private enum BLEPin { - static let firmwareDefault: UInt32 = 123456 - static let unset: UInt32 = 0 - /// A custom PIN must be exactly six digits. - static let customRange: ClosedRange = 100000...999999 + static let firmwareDefault: UInt32 = 123_456 + static let unset: UInt32 = 0 + /// A custom PIN must be exactly six digits. + static let customRange: ClosedRange = 100_000...999_999 } /// Bluetooth PIN configuration struct BluetoothSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var pinType: BluetoothPinType = .default - @State private var customPin: String = "" - @State private var showingPinEntry = false - @State private var showingChangePinEntry = false - @State private var showingRemoveConfirmation = false - @State private var isChangingPin = false - @State private var errorMessage: String? - @State private var hasInitialized = false - @State private var isPinVisible = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var pinType: BluetoothPinType = .default + @State private var customPin: String = "" + @State private var showingPinEntry = false + @State private var showingChangePinEntry = false + @State private var showingRemoveConfirmation = false + @State private var isChangingPin = false + @State private var errorMessage: String? + @State private var hasInitialized = false + @State private var isPinVisible = false - // Track what the user intended before confirmation dialogs - @State private var pendingPinType: BluetoothPinType? - @State private var isRenaming = false + // Track what the user intended before confirmation dialogs + @State private var pendingPinType: BluetoothPinType? + @State private var isRenaming = false - enum BluetoothPinType: String, CaseIterable { - case `default` = "Default" - case custom = "Custom PIN" + enum BluetoothPinType: String, CaseIterable { + case `default` = "Default" + case custom = "Custom PIN" - var localizedName: String { - switch self { - case .default: L10n.Settings.Bluetooth.PinType.default - case .custom: L10n.Settings.Bluetooth.PinType.custom - } - } + var localizedName: String { + switch self { + case .default: L10n.Settings.Bluetooth.PinType.default + case .custom: L10n.Settings.Bluetooth.PinType.custom + } } + } - private var currentPinType: BluetoothPinType { - guard let device = appState.connectedDevice else { return .default } - if device.blePin == BLEPin.unset || device.blePin == BLEPin.firmwareDefault { - return .default - } else { - return .custom - } + private var currentPinType: BluetoothPinType { + guard let device = appState.connectedDevice else { return .default } + if device.blePin == BLEPin.unset || device.blePin == BLEPin.firmwareDefault { + return .default + } else { + return .custom } + } - var body: some View { - Section { - Picker(L10n.Settings.Bluetooth.pinType, selection: $pinType) { - ForEach(BluetoothPinType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type) - } - } - .onChange(of: pinType) { oldValue, newValue in - guard hasInitialized else { return } - handlePinTypeChange(from: oldValue, to: newValue) - } - .radioDisabled(for: appState.connectionState, or: isChangingPin) - - if pinType == .custom, let device = appState.connectedDevice, device.blePin > 0 { - Button { - isPinVisible.toggle() - } label: { - HStack { - Text(L10n.Settings.Bluetooth.currentPin) - Spacer() - Group { - if isPinVisible { - Text(device.blePin, format: .number.grouping(.never)) - } else { - Text("••••••") - } - } - .font(.body.monospacedDigit()) - .foregroundStyle(.secondary) - - Image(systemName: isPinVisible ? "eye" : "eye.slash") - .foregroundStyle(.tertiary) - .imageScale(.small) - } - .contentShape(.rect) - } - .buttonStyle(.plain) + var body: some View { + Section { + Picker(L10n.Settings.Bluetooth.pinType, selection: $pinType) { + ForEach(BluetoothPinType.allCases, id: \.self) { type in + Text(type.localizedName).tag(type) + } + } + .onChange(of: pinType) { oldValue, newValue in + guard hasInitialized else { return } + handlePinTypeChange(from: oldValue, to: newValue) + } + .radioDisabled(for: appState.connectionState, or: isChangingPin) - Button(L10n.Settings.Bluetooth.changePin) { - customPin = "" - showingChangePinEntry = true - } - .radioDisabled(for: appState.connectionState) + if pinType == .custom, let device = appState.connectedDevice, device.blePin > 0 { + Button { + isPinVisible.toggle() + } label: { + HStack { + Text(L10n.Settings.Bluetooth.currentPin) + Spacer() + Group { + if isPinVisible { + Text(device.blePin, format: .number.grouping(.never)) + } else { + Text("••••••") + } } + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) - if appState.connectionState == .ready, - let deviceID = appState.connectedDevice?.id, - appState.connectionManager.supportsDeviceRename, - appState.connectionManager.hasAccessory(for: deviceID) { - Button { - renameDevice() - } label: { - HStack { - Text(L10n.Settings.Bluetooth.changeDisplayName) - Spacer() - if isRenaming { - ProgressView() - .controlSize(.small) - } - } - } - .radioDisabled(for: appState.connectionState, or: isRenaming) - } - } header: { - Text(L10n.Settings.Bluetooth.header) - } footer: { - if pinType == .default { - Text(L10n.Settings.Bluetooth.defaultPinFooter) - } + Image(systemName: isPinVisible ? "eye" : "eye.slash") + .foregroundStyle(.tertiary) + .imageScale(.small) + } + .contentShape(.rect) } - .themedRowBackground(theme) - .onAppear { - isPinVisible = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } + .buttonStyle(.plain) + + Button(L10n.Settings.Bluetooth.changePin) { + customPin = "" + showingChangePinEntry = true } - .alert(L10n.Settings.Bluetooth.Alert.SetPin.title, isPresented: $showingPinEntry) { - TextField(L10n.Settings.Bluetooth.pinPlaceholder, text: $customPin) - .keyboardType(.numberPad) - Button(L10n.Localizable.Common.cancel, role: .cancel) { - // Revert to previous pin type without triggering onChange loop - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - } - Button(L10n.Settings.Bluetooth.setPin) { - setCustomPin() + .radioDisabled(for: appState.connectionState) + } + + if appState.connectionState == .ready, + let deviceID = appState.connectedDevice?.id, + appState.connectionManager.supportsDeviceRename, + appState.connectionManager.hasAccessory(for: deviceID) { + Button { + renameDevice() + } label: { + HStack { + Text(L10n.Settings.Bluetooth.changeDisplayName) + Spacer() + if isRenaming { + ProgressView() + .controlSize(.small) } - } message: { - Text(L10n.Settings.Bluetooth.Alert.SetPin.message) + } } - .alert(L10n.Settings.Bluetooth.Alert.ChangePin.title, isPresented: $showingChangePinEntry) { - TextField(L10n.Settings.Bluetooth.pinPlaceholder, text: $customPin) - .keyboardType(.numberPad) - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.Bluetooth.changePin) { - setCustomPin() - } - } message: { - Text(L10n.Settings.Bluetooth.Alert.ChangePin.message) + .radioDisabled(for: appState.connectionState, or: isRenaming) + } + } header: { + Text(L10n.Settings.Bluetooth.header) + } footer: { + if pinType == .default { + Text(L10n.Settings.Bluetooth.defaultPinFooter) + } + } + .themedRowBackground(theme) + .onAppear { + isPinVisible = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true + } + } + .alert(L10n.Settings.Bluetooth.Alert.SetPin.title, isPresented: $showingPinEntry) { + TextField(L10n.Settings.Bluetooth.pinPlaceholder, text: $customPin) + .keyboardType(.numberPad) + Button(L10n.Localizable.Common.cancel, role: .cancel) { + // Revert to previous pin type without triggering onChange loop + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true } - .alert(L10n.Settings.Bluetooth.Alert.ChangePinType.title, isPresented: $showingRemoveConfirmation) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { - // Revert to previous pin type without triggering onChange loop - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - } - Button(L10n.Settings.Bluetooth.Alert.change) { - applyPendingPinType() - } - } message: { - Text(L10n.Settings.Bluetooth.Alert.ChangePinType.message) + } + Button(L10n.Settings.Bluetooth.setPin) { + setCustomPin() + } + } message: { + Text(L10n.Settings.Bluetooth.Alert.SetPin.message) + } + .alert(L10n.Settings.Bluetooth.Alert.ChangePin.title, isPresented: $showingChangePinEntry) { + TextField(L10n.Settings.Bluetooth.pinPlaceholder, text: $customPin) + .keyboardType(.numberPad) + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.Bluetooth.changePin) { + setCustomPin() + } + } message: { + Text(L10n.Settings.Bluetooth.Alert.ChangePin.message) + } + .alert(L10n.Settings.Bluetooth.Alert.ChangePinType.title, isPresented: $showingRemoveConfirmation) { + Button(L10n.Localizable.Common.cancel, role: .cancel) { + // Revert to previous pin type without triggering onChange loop + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true } - .errorAlert($errorMessage) + } + Button(L10n.Settings.Bluetooth.Alert.change) { + applyPendingPinType() + } + } message: { + Text(L10n.Settings.Bluetooth.Alert.ChangePinType.message) } + .errorAlert($errorMessage) + } - private func handlePinTypeChange(from oldValue: BluetoothPinType, to newValue: BluetoothPinType) { - // Skip if picker is being synced to device's current state (handles initialization race condition) - guard newValue != currentPinType else { return } + private func handlePinTypeChange(from oldValue: BluetoothPinType, to newValue: BluetoothPinType) { + // Skip if picker is being synced to device's current state (handles initialization race condition) + guard newValue != currentPinType else { return } - // If changing to custom, show PIN entry - if newValue == .custom && oldValue != .custom { - showingPinEntry = true - return - } + // If changing to custom, show PIN entry + if newValue == .custom, oldValue != .custom { + showingPinEntry = true + return + } - // If changing from custom to default, show confirmation - if oldValue == .custom && newValue == .default { - pendingPinType = newValue - showingRemoveConfirmation = true - } + // If changing from custom to default, show confirmation + if oldValue == .custom, newValue == .default { + pendingPinType = newValue + showingRemoveConfirmation = true } + } - private func applyPendingPinType() { - guard let pending = pendingPinType else { return } - pendingPinType = nil + private func applyPendingPinType() { + guard let pending = pendingPinType else { return } + pendingPinType = nil - let pinValue: UInt32 = pending == .default ? BLEPin.firmwareDefault : BLEPin.unset + let pinValue: UInt32 = pending == .default ? BLEPin.firmwareDefault : BLEPin.unset - isChangingPin = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } + isChangingPin = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected + } - // Send PIN change command (written to RAM until reboot) - try await settingsService.setBlePin(pinValue) + // Send PIN change command (written to RAM until reboot) + try await settingsService.setBlePin(pinValue) - // Reboot device to apply PIN change - iOS auto-reconnects - // Timeout is expected since device disconnects before write callback - do { - try await settingsService.reboot() - } catch BLEError.operationTimeout { - // Expected - device reboots before BLE write callback arrives - } - } catch { - errorMessage = error.userFacingMessage - // Revert - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - } - isChangingPin = false + // Reboot device to apply PIN change - iOS auto-reconnects + // Timeout is expected since device disconnects before write callback + do { + try await settingsService.reboot() + } catch BLEError.operationTimeout { + // Expected - device reboots before BLE write callback arrives + } + } catch { + errorMessage = error.userFacingMessage + // Revert + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true } + } + isChangingPin = false } + } - private func setCustomPin() { - guard let pin = UInt32(customPin), BLEPin.customRange.contains(pin) else { - errorMessage = L10n.Settings.Bluetooth.Error.invalidPin - customPin = "" - // Revert - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - return - } + private func setCustomPin() { + guard let pin = UInt32(customPin), BLEPin.customRange.contains(pin) else { + errorMessage = L10n.Settings.Bluetooth.Error.invalidPin + customPin = "" + // Revert + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true + } + return + } - isChangingPin = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } + isChangingPin = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected + } - // Send PIN change command (written to RAM until reboot) - try await settingsService.setBlePin(pin) + // Send PIN change command (written to RAM until reboot) + try await settingsService.setBlePin(pin) - // Reboot device to apply PIN change - iOS auto-reconnects - // Timeout is expected since device disconnects before write callback - do { - try await settingsService.reboot() - } catch BLEError.operationTimeout { - // Expected - device reboots before BLE write callback arrives - } - } catch { - errorMessage = error.userFacingMessage - // Revert - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - } - isChangingPin = false - customPin = "" + // Reboot device to apply PIN change - iOS auto-reconnects + // Timeout is expected since device disconnects before write callback + do { + try await settingsService.reboot() + } catch BLEError.operationTimeout { + // Expected - device reboots before BLE write callback arrives } + } catch { + errorMessage = error.userFacingMessage + // Revert + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true + } + } + isChangingPin = false + customPin = "" } + } - private func renameDevice() { - isRenaming = true - Task { @MainActor in - defer { isRenaming = false } + private func renameDevice() { + isRenaming = true + Task { @MainActor in + defer { isRenaming = false } - do { - try await appState.connectionManager.renameCurrentDevice() - } catch { - // User cancelled or rename failed - no error to show - // Rename is optional and user can try again - } - } + do { + try await appState.connectionManager.renameCurrentDevice() + } catch { + // User cancelled or rename failed - no error to show + // Rename is optional and user can try again + } } + } } diff --git a/MC1/Views/Settings/Sections/ConfigExportImportSection.swift b/MC1/Views/Settings/Sections/ConfigExportImportSection.swift index 7c17721e..d4800cdd 100644 --- a/MC1/Views/Settings/Sections/ConfigExportImportSection.swift +++ b/MC1/Views/Settings/Sections/ConfigExportImportSection.swift @@ -1,30 +1,30 @@ -import SwiftUI import MC1Services +import SwiftUI struct ConfigExportImportSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - private var isDisabled: Bool { - appState.connectionState != .ready - } + private var isDisabled: Bool { + appState.connectionState != .ready + } - var body: some View { - Section { - NavigationLink(value: SettingsSubpage.configExport) { - TintedLabel(L10n.Settings.ConfigExport.export, systemImage: "square.and.arrow.up") - } - .disabled(isDisabled) + var body: some View { + Section { + NavigationLink(value: SettingsSubpage.configExport) { + TintedLabel(L10n.Settings.ConfigExport.export, systemImage: "square.and.arrow.up") + } + .disabled(isDisabled) - NavigationLink(value: SettingsSubpage.configImport) { - TintedLabel(L10n.Settings.ConfigImport.importConfig, systemImage: "square.and.arrow.down") - } - .disabled(isDisabled) - } header: { - Text(L10n.Settings.ConfigExport.sectionTitle) - } footer: { - Text(L10n.Settings.ConfigExport.sectionFooter) - } - .themedRowBackground(theme) + NavigationLink(value: SettingsSubpage.configImport) { + TintedLabel(L10n.Settings.ConfigImport.importConfig, systemImage: "square.and.arrow.down") + } + .disabled(isDisabled) + } header: { + Text(L10n.Settings.ConfigExport.sectionTitle) + } footer: { + Text(L10n.Settings.ConfigExport.sectionFooter) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Settings/Sections/DangerZoneSection.swift b/MC1/Views/Settings/Sections/DangerZoneSection.swift index 21515393..b0933091 100644 --- a/MC1/Views/Settings/Sections/DangerZoneSection.swift +++ b/MC1/Views/Settings/Sections/DangerZoneSection.swift @@ -1,122 +1,122 @@ -import SwiftUI import MC1Services +import SwiftUI /// Destructive device actions struct DangerZoneSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var viewModel = DangerZoneViewModel() + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var viewModel = DangerZoneViewModel() - var body: some View { - Section { - Button(role: .destructive) { - Task { await viewModel.fetchUnfavoritedCount() } - } label: { - if viewModel.isRemovingUnfavorited { - HStack { - ProgressView() - Text(L10n.Settings.DangerZone.removing) - } - } else if viewModel.showRemoveSuccess { - Label(L10n.Settings.DangerZone.removed, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { - Label(L10n.Settings.DangerZone.removeUnfavorited, systemImage: "person.2.slash") - } - } - .radioDisabled( - for: appState.connectionState, - or: viewModel.isRemovingUnfavorited || viewModel.showRemoveSuccess - ) + var body: some View { + Section { + Button(role: .destructive) { + Task { await viewModel.fetchUnfavoritedCount() } + } label: { + if viewModel.isRemovingUnfavorited { + HStack { + ProgressView() + Text(L10n.Settings.DangerZone.removing) + } + } else if viewModel.showRemoveSuccess { + Label(L10n.Settings.DangerZone.removed, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label(L10n.Settings.DangerZone.removeUnfavorited, systemImage: "person.2.slash") + } + } + .radioDisabled( + for: appState.connectionState, + or: viewModel.isRemovingUnfavorited || viewModel.showRemoveSuccess + ) - Button(role: .destructive) { - viewModel.showingForgetConfirmation = true - } label: { - Label(L10n.Settings.DangerZone.forgetDevice, systemImage: "trash") - } + Button(role: .destructive) { + viewModel.showingForgetConfirmation = true + } label: { + Label(L10n.Settings.DangerZone.forgetDevice, systemImage: "trash") + } - Button(role: .destructive) { - viewModel.showingResetAlert = true - } label: { - if viewModel.isResetting { - HStack { - ProgressView() - Text(L10n.Settings.DangerZone.resetting) - } - } else { - Label(L10n.Settings.DangerZone.factoryReset, systemImage: "exclamationmark.triangle") - } - } - .radioDisabled(for: appState.connectionState, or: viewModel.isResetting) - } header: { - Text(L10n.Settings.DangerZone.header) - } footer: { - Text(L10n.Settings.DangerZone.footer) - } - .themedRowBackground(theme) - .task { - viewModel.configure( - settingsService: { appState.services?.settingsService }, - connectedDevice: { appState.connectedDevice }, - connectionManager: appState.connectionManager - ) - } - .confirmationDialog( - L10n.Settings.DangerZone.Dialog.Forget.title, - isPresented: $viewModel.showingForgetConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Settings.DangerZone.Dialog.Forget.keepData, role: .destructive) { - forgetDevice(deleteData: false) - } - Button(L10n.Settings.DangerZone.Dialog.Forget.deleteAll, role: .destructive) { - forgetDevice(deleteData: true) - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - } message: { - Text(L10n.Settings.DangerZone.Dialog.Forget.message) - } - .alert(L10n.Settings.DangerZone.Alert.Reset.title, isPresented: $viewModel.showingResetAlert) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.DangerZone.Alert.Reset.confirm, role: .destructive) { - Task { - if await viewModel.factoryReset() { - dismiss() - } - } - } - } message: { - Text(L10n.Settings.DangerZone.Alert.Reset.message) - } - .alert( - L10n.Settings.DangerZone.Alert.RemoveUnfavorited.title, - isPresented: $viewModel.showingRemoveUnfavoritedAlert - ) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.DangerZone.Alert.RemoveUnfavorited.confirm, role: .destructive) { - viewModel.removeUnfavoritedNodes() - } - } message: { - Text(L10n.Settings.DangerZone.Alert.RemoveUnfavorited.message(viewModel.unfavoritedCount)) - } - .alert( - L10n.Settings.DangerZone.Alert.RemoveUnfavorited.resultTitle, - isPresented: $viewModel.showRemoveResult - ) { - Button(L10n.Localizable.Common.ok) { } - } message: { - Text(viewModel.removeResult ?? "") + Button(role: .destructive) { + viewModel.showingResetAlert = true + } label: { + if viewModel.isResetting { + HStack { + ProgressView() + Text(L10n.Settings.DangerZone.resetting) + } + } else { + Label(L10n.Settings.DangerZone.factoryReset, systemImage: "exclamationmark.triangle") } - .onDisappear { viewModel.cancelPendingRemoval() } - .errorAlert($viewModel.errorMessage) + } + .radioDisabled(for: appState.connectionState, or: viewModel.isResetting) + } header: { + Text(L10n.Settings.DangerZone.header) + } footer: { + Text(L10n.Settings.DangerZone.footer) } - - private func forgetDevice(deleteData: Bool) { + .themedRowBackground(theme) + .task { + viewModel.configure( + settingsService: { appState.services?.settingsService }, + connectedDevice: { appState.connectedDevice }, + connectionManager: appState.connectionManager + ) + } + .confirmationDialog( + L10n.Settings.DangerZone.Dialog.Forget.title, + isPresented: $viewModel.showingForgetConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Settings.DangerZone.Dialog.Forget.keepData, role: .destructive) { + forgetDevice(deleteData: false) + } + Button(L10n.Settings.DangerZone.Dialog.Forget.deleteAll, role: .destructive) { + forgetDevice(deleteData: true) + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Settings.DangerZone.Dialog.Forget.message) + } + .alert(L10n.Settings.DangerZone.Alert.Reset.title, isPresented: $viewModel.showingResetAlert) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.DangerZone.Alert.Reset.confirm, role: .destructive) { Task { - if await viewModel.forgetDevice(deleteData: deleteData) { - dismiss() - } + if await viewModel.factoryReset() { + dismiss() + } } + } + } message: { + Text(L10n.Settings.DangerZone.Alert.Reset.message) + } + .alert( + L10n.Settings.DangerZone.Alert.RemoveUnfavorited.title, + isPresented: $viewModel.showingRemoveUnfavoritedAlert + ) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.DangerZone.Alert.RemoveUnfavorited.confirm, role: .destructive) { + viewModel.removeUnfavoritedNodes() + } + } message: { + Text(L10n.Settings.DangerZone.Alert.RemoveUnfavorited.message(viewModel.unfavoritedCount)) + } + .alert( + L10n.Settings.DangerZone.Alert.RemoveUnfavorited.resultTitle, + isPresented: $viewModel.showRemoveResult + ) { + Button(L10n.Localizable.Common.ok) {} + } message: { + Text(viewModel.removeResult ?? "") + } + .onDisappear { viewModel.cancelPendingRemoval() } + .errorAlert($viewModel.errorMessage) + } + + private func forgetDevice(deleteData: Bool) { + Task { + if await viewModel.forgetDevice(deleteData: deleteData) { + dismiss() + } } + } } diff --git a/MC1/Views/Settings/Sections/DangerZoneViewModel.swift b/MC1/Views/Settings/Sections/DangerZoneViewModel.swift index a163469d..d36f1f49 100644 --- a/MC1/Views/Settings/Sections/DangerZoneViewModel.swift +++ b/MC1/Views/Settings/Sections/DangerZoneViewModel.swift @@ -1,125 +1,131 @@ -import SwiftUI import MC1Services +import SwiftUI @Observable @MainActor final class DangerZoneViewModel { - var showingForgetConfirmation = false - var showingResetAlert = false - var isResetting = false - var errorMessage: String? - var showingRemoveUnfavoritedAlert = false - var isRemovingUnfavorited = false - var showRemoveSuccess = false - var unfavoritedCount = 0 - var showRemoveResult = false - var removeResult: String? - - private var removeTask: Task? - - // MARK: - Dependencies - - private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } - var settingsService: SettingsService? { settingsServiceProvider() } - private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } - var connectedDevice: DeviceDTO? { connectedDeviceProvider() } - private var connectionManager: ConnectionManager? - - /// Grace period for the radio to reboot after a factory reset before local cleanup. - private static let resetRebootGracePeriod: Duration = .seconds(1) - /// How long the transient "Removed" confirmation stays on the button label. - private static let removeSuccessDisplayDuration: Duration = .seconds(1.5) - - func configure( - settingsService: @escaping @MainActor () -> SettingsService?, - connectedDevice: @escaping @MainActor () -> DeviceDTO?, - connectionManager: ConnectionManager - ) { - self.settingsServiceProvider = settingsService - self.connectedDeviceProvider = connectedDevice - self.connectionManager = connectionManager - } + var showingForgetConfirmation = false + var showingResetAlert = false + var isResetting = false + var errorMessage: String? + var showingRemoveUnfavoritedAlert = false + var isRemovingUnfavorited = false + var showRemoveSuccess = false + var unfavoritedCount = 0 + var showRemoveResult = false + var removeResult: String? + + private var removeTask: Task? + + // MARK: - Dependencies + + private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } + var settingsService: SettingsService? { + settingsServiceProvider() + } + + private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } + var connectedDevice: DeviceDTO? { + connectedDeviceProvider() + } + + private var connectionManager: ConnectionManager? + + /// Grace period for the radio to reboot after a factory reset before local cleanup. + private static let resetRebootGracePeriod: Duration = .seconds(1) + /// How long the transient "Removed" confirmation stays on the button label. + private static let removeSuccessDisplayDuration: Duration = .seconds(1.5) - func cancelPendingRemoval() { - removeTask?.cancel() + func configure( + settingsService: @escaping @MainActor () -> SettingsService?, + connectedDevice: @escaping @MainActor () -> DeviceDTO?, + connectionManager: ConnectionManager + ) { + settingsServiceProvider = settingsService + connectedDeviceProvider = connectedDevice + self.connectionManager = connectionManager + } + + func cancelPendingRemoval() { + removeTask?.cancel() + } + + /// Returns true when the device was forgotten and the hosting page should dismiss. + func forgetDevice(deleteData: Bool) async -> Bool { + guard let connectionManager else { return false } + do { + try await connectionManager.forgetDevice(deleteData: deleteData) + return true + } catch { + errorMessage = error.userFacingMessage + return false } + } - /// Returns true when the device was forgotten and the hosting page should dismiss. - func forgetDevice(deleteData: Bool) async -> Bool { - guard let connectionManager else { return false } - do { - try await connectionManager.forgetDevice(deleteData: deleteData) - return true - } catch { - errorMessage = error.userFacingMessage - return false - } + /// Returns true when the reset flow finished and the hosting page should dismiss. + /// A nil service or device ID mirrors a disconnected state. + func factoryReset() async -> Bool { + guard let settingsService, + let deviceID = connectedDevice?.id, + let connectionManager else { + errorMessage = L10n.Settings.DangerZone.Error.servicesUnavailable + return false } - /// Returns true when the reset flow finished and the hosting page should dismiss. - /// A nil service or device ID mirrors a disconnected state. - func factoryReset() async -> Bool { - guard let settingsService = settingsService, - let deviceID = connectedDevice?.id, - let connectionManager else { - errorMessage = L10n.Settings.DangerZone.Error.servicesUnavailable - return false - } + isResetting = true + defer { isResetting = false } - isResetting = true - defer { isResetting = false } + // Send reset command. The device typically reboots before responding, + // so a timeout/connection error here is expected, not a failure. + do { + try await settingsService.factoryReset() + try await Task.sleep(for: Self.resetRebootGracePeriod) + } catch { + // Expected: device reboots before sending OK response + } - // Send reset command. The device typically reboots before responding, - // so a timeout/connection error here is expected, not a failure. - do { - try await settingsService.factoryReset() - try await Task.sleep(for: Self.resetRebootGracePeriod) - } catch { - // Expected: device reboots before sending OK response - } + // Always clean up: remove from ASK, disconnect, delete from SwiftData + await connectionManager.forgetDevice(id: deviceID) + return true + } - // Always clean up: remove from ASK, disconnect, delete from SwiftData - await connectionManager.forgetDevice(id: deviceID) - return true + func fetchUnfavoritedCount() async { + guard let connectionManager else { return } + do { + unfavoritedCount = try await connectionManager.unfavoritedNodeCount() + if unfavoritedCount == 0 { + removeResult = L10n.Settings.DangerZone.Alert.RemoveUnfavorited.noneFound + showRemoveResult = true + } else { + showingRemoveUnfavoritedAlert = true + } + } catch { + errorMessage = error.userFacingMessage } + } - func fetchUnfavoritedCount() async { - guard let connectionManager else { return } - do { - unfavoritedCount = try await connectionManager.unfavoritedNodeCount() - if unfavoritedCount == 0 { - removeResult = L10n.Settings.DangerZone.Alert.RemoveUnfavorited.noneFound - showRemoveResult = true - } else { - showingRemoveUnfavoritedAlert = true - } - } catch { - errorMessage = error.userFacingMessage + func removeUnfavoritedNodes() { + guard let connectionManager else { return } + isRemovingUnfavorited = true + removeTask = Task { + defer { isRemovingUnfavorited = false } + do { + let result = try await connectionManager.removeUnfavoritedNodes() + isRemovingUnfavorited = false + if result.removed == result.total { + withAnimation { showRemoveSuccess = true } + try await Task.sleep(for: Self.removeSuccessDisplayDuration) + withAnimation { showRemoveSuccess = false } + } else { + removeResult = L10n.Settings.DangerZone.Alert.RemoveUnfavorited + .partial(result.removed, result.total) + showRemoveResult = true } - } - - func removeUnfavoritedNodes() { - guard let connectionManager else { return } - isRemovingUnfavorited = true - removeTask = Task { - defer { isRemovingUnfavorited = false } - do { - let result = try await connectionManager.removeUnfavoritedNodes() - isRemovingUnfavorited = false - if result.removed == result.total { - withAnimation { showRemoveSuccess = true } - try await Task.sleep(for: Self.removeSuccessDisplayDuration) - withAnimation { showRemoveSuccess = false } - } else { - removeResult = L10n.Settings.DangerZone.Alert.RemoveUnfavorited - .partial(result.removed, result.total) - showRemoveResult = true - } - } catch { - if !(error is CancellationError) { - errorMessage = error.userFacingMessage - } - } + } catch { + if !(error is CancellationError) { + errorMessage = error.userFacingMessage } + } } + } } diff --git a/MC1/Views/Settings/Sections/DefaultFloodScopeSection.swift b/MC1/Views/Settings/Sections/DefaultFloodScopeSection.swift index 2adf5328..0ac348cc 100644 --- a/MC1/Views/Settings/Sections/DefaultFloodScopeSection.swift +++ b/MC1/Views/Settings/Sections/DefaultFloodScopeSection.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Section for configuring the device's persisted default flood scope (firmware v11+). /// @@ -8,190 +8,190 @@ import MC1Services /// Selections are sent via ``SettingsService/setDefaultFloodScopeVerified(name:)`` /// and the accepted value is cached in ``DeviceDTO/defaultFloodScopeName``. struct DefaultFloodScopeSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var isApplying = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isDiscovering = false - @State private var discoveryMessage: String? - @State private var discoveryTask: Task? - @State private var showingRegionManagement = false - - var body: some View { - Section { - disabledRow - - ForEach(sortedKnownRegions, id: \.self) { region in - scopeRow(region) - } - - discoveryStatusRow - discoverButton - manageRegionsButton - } header: { - Text(L10n.Settings.DefaultFloodScope.header) - } footer: { - Text(L10n.Settings.DefaultFloodScope.footer) - } - .themedRowBackground(theme) - .radioDisabled(for: appState.connectionState, or: isApplying) - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .onDisappear { discoveryTask?.cancel() } - .navigationDestination(isPresented: $showingRegionManagement) { - RegionManagementView( - knownRegions: appState.connectedDevice?.knownRegions ?? [], - isDiscovering: $isDiscovering, - discoveryMessage: $discoveryMessage, - onRemoveRegion: { region in appState.connectionManager.removeKnownRegion(region) }, - onAddRegion: { region in appState.connectionManager.addKnownRegion(region) }, - onDiscoverTapped: runDiscovery - ) - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var isApplying = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isDiscovering = false + @State private var discoveryMessage: String? + @State private var discoveryTask: Task? + @State private var showingRegionManagement = false + + var body: some View { + Section { + disabledRow + + ForEach(sortedKnownRegions, id: \.self) { region in + scopeRow(region) + } + + discoveryStatusRow + discoverButton + manageRegionsButton + } header: { + Text(L10n.Settings.DefaultFloodScope.header) + } footer: { + Text(L10n.Settings.DefaultFloodScope.footer) + } + .themedRowBackground(theme) + .radioDisabled(for: appState.connectionState, or: isApplying) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .onDisappear { discoveryTask?.cancel() } + .navigationDestination(isPresented: $showingRegionManagement) { + RegionManagementView( + knownRegions: appState.connectedDevice?.knownRegions ?? [], + isDiscovering: $isDiscovering, + discoveryMessage: $discoveryMessage, + onRemoveRegion: { region in appState.connectionManager.removeKnownRegion(region) }, + onAddRegion: { region in appState.connectionManager.addKnownRegion(region) }, + onDiscoverTapped: runDiscovery + ) } + } - // MARK: - Rows + // MARK: - Rows - private var disabledRow: some View { - Button { - apply(name: nil) - } label: { - row(title: L10n.Settings.DefaultFloodScope.disabled, selected: currentScope == nil) - } - .buttonStyle(.plain) + private var disabledRow: some View { + Button { + apply(name: nil) + } label: { + row(title: L10n.Settings.DefaultFloodScope.disabled, selected: currentScope == nil) } - - private func scopeRow(_ region: String) -> some View { - Button { - apply(name: region) - } label: { - row(title: region, selected: currentScope == region) - } - .buttonStyle(.plain) + .buttonStyle(.plain) + } + + private func scopeRow(_ region: String) -> some View { + Button { + apply(name: region) + } label: { + row(title: region, selected: currentScope == region) } - - @ViewBuilder - private var discoveryStatusRow: some View { - if isDiscovering { - HStack { - ProgressView() - Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) - .foregroundStyle(.secondary) - } - } else if let discoveryMessage { - Text(discoveryMessage) - .font(.caption) - .foregroundStyle(.secondary) - } + .buttonStyle(.plain) + } + + @ViewBuilder + private var discoveryStatusRow: some View { + if isDiscovering { + HStack { + ProgressView() + Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) + .foregroundStyle(.secondary) + } + } else if let discoveryMessage { + Text(discoveryMessage) + .font(.caption) + .foregroundStyle(.secondary) } - - private var discoverButton: some View { - Button( - L10n.Chats.Chats.ChannelInfo.Region.discover, - systemImage: "antenna.radiowaves.left.and.right", - action: runDiscovery - ) - .disabled(isDiscovering) + } + + private var discoverButton: some View { + Button( + L10n.Chats.Chats.ChannelInfo.Region.discover, + systemImage: "antenna.radiowaves.left.and.right", + action: runDiscovery + ) + .disabled(isDiscovering) + } + + private var manageRegionsButton: some View { + Button(L10n.Chats.Chats.ChannelInfo.Region.manageRegions, systemImage: "list.bullet") { + showingRegionManagement = true } - - private var manageRegionsButton: some View { - Button(L10n.Chats.Chats.ChannelInfo.Region.manageRegions, systemImage: "list.bullet") { - showingRegionManagement = true - } + } + + private func row(title: String, selected: Bool) -> some View { + HStack { + Text(title) + .foregroundStyle(Color.primary) + Spacer() + if selected { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } } + .contentShape(.rect) + } - private func row(title: String, selected: Bool) -> some View { - HStack { - Text(title) - .foregroundStyle(Color.primary) - Spacer() - if selected { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - .contentShape(.rect) - } + // MARK: - Derived state - // MARK: - Derived state + private var currentScope: String? { + appState.connectedDevice?.defaultFloodScopeName + } - private var currentScope: String? { - appState.connectedDevice?.defaultFloodScopeName - } + private var sortedKnownRegions: [String] { + (appState.connectedDevice?.knownRegions ?? []) + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } - private var sortedKnownRegions: [String] { - (appState.connectedDevice?.knownRegions ?? []) - .sorted { $0.localizedStandardCompare($1) == .orderedAscending } - } + // MARK: - Actions - // MARK: - Actions - - private func apply(name: String?) { - isApplying = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } - _ = try await settingsService.setDefaultFloodScopeVerified(name: name) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { apply(name: name) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isApplying = false + private func apply(name: String?) { + isApplying = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected } + _ = try await settingsService.setDefaultFloodScopeVerified(name: name) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { apply(name: name) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + } + isApplying = false } - - private func runDiscovery() { - discoveryTask?.cancel() - discoveryTask = Task { - isDiscovering = true - discoveryMessage = nil - defer { isDiscovering = false } - - guard let session = appState.services?.session, - let contactService = appState.services?.contactService, - let radioID = appState.connectedDevice?.radioID else { - return - } - - let outcome = await RegionDiscoveryService.discover( - session: session, - contactService: contactService, - dataStore: appState.offlineDataStore, - radioID: radioID, - knownRegions: appState.connectedDevice?.knownRegions ?? [], - supportsAdHocRequest: appState.connectedDevice?.supportsAdHocRepeaterRequest ?? false - ) - - guard !Task.isCancelled else { return } - - switch outcome { - case .sendFailed: - break - case .noRepeatersResponded: - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded - case .errorLoadingRepeaters: - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errLoadingRepeaters - case let .completed(newRegions, allRepeatersTableFull): - if newRegions.isEmpty && allRepeatersTableFull { - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errRadioContactsFull - } else if newRegions.isEmpty { - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noNewRegions - } else { - for region in newRegions { - appState.connectionManager.addKnownRegion(region) - } - } - } + } + + private func runDiscovery() { + discoveryTask?.cancel() + discoveryTask = Task { + isDiscovering = true + discoveryMessage = nil + defer { isDiscovering = false } + + guard let session = appState.services?.session, + let contactService = appState.services?.contactService, + let radioID = appState.connectedDevice?.radioID else { + return + } + + let outcome = await RegionDiscoveryService.discover( + session: session, + contactService: contactService, + dataStore: appState.offlineDataStore, + radioID: radioID, + knownRegions: appState.connectedDevice?.knownRegions ?? [], + supportsAdHocRequest: appState.connectedDevice?.supportsAdHocRepeaterRequest ?? false + ) + + guard !Task.isCancelled else { return } + + switch outcome { + case .sendFailed: + break + case .noRepeatersResponded: + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded + case .errorLoadingRepeaters: + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errLoadingRepeaters + case let .completed(newRegions, allRepeatersTableFull): + if newRegions.isEmpty, allRepeatersTableFull { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errRadioContactsFull + } else if newRegions.isEmpty { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noNewRegions + } else { + for region in newRegions { + appState.connectionManager.addKnownRegion(region) + } } + } } + } } diff --git a/MC1/Views/Settings/Sections/DeviceActionsSection.swift b/MC1/Views/Settings/Sections/DeviceActionsSection.swift index 72c5d54e..b1374764 100644 --- a/MC1/Views/Settings/Sections/DeviceActionsSection.swift +++ b/MC1/Views/Settings/Sections/DeviceActionsSection.swift @@ -1,57 +1,57 @@ -import SwiftUI import MC1Services +import SwiftUI /// Device maintenance actions (reboot) struct DeviceActionsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingRebootAlert = false - @State private var isRebooting = false - @State private var errorMessage: String? + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingRebootAlert = false + @State private var isRebooting = false + @State private var errorMessage: String? - var body: some View { - Section { - Button { - showingRebootAlert = true - } label: { - if isRebooting { - HStack { - ProgressView() - Text(L10n.Settings.DeviceActions.rebooting) - } - } else { - Label(L10n.Settings.DeviceActions.rebootDevice, systemImage: "arrow.clockwise") - } - } - .radioDisabled(for: appState.connectionState, or: isRebooting) - } header: { - Text(L10n.Settings.DeviceActions.header) - } - .themedRowBackground(theme) - .alert(L10n.Settings.DeviceActions.Alert.Reboot.title, isPresented: $showingRebootAlert) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.DeviceActions.Alert.Reboot.confirm) { - rebootDevice() - } - } message: { - Text(L10n.Settings.DeviceActions.Alert.Reboot.message) + var body: some View { + Section { + Button { + showingRebootAlert = true + } label: { + if isRebooting { + HStack { + ProgressView() + Text(L10n.Settings.DeviceActions.rebooting) + } + } else { + Label(L10n.Settings.DeviceActions.rebootDevice, systemImage: "arrow.clockwise") } - .errorAlert($errorMessage) + } + .radioDisabled(for: appState.connectionState, or: isRebooting) + } header: { + Text(L10n.Settings.DeviceActions.header) } + .themedRowBackground(theme) + .alert(L10n.Settings.DeviceActions.Alert.Reboot.title, isPresented: $showingRebootAlert) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.DeviceActions.Alert.Reboot.confirm) { + rebootDevice() + } + } message: { + Text(L10n.Settings.DeviceActions.Alert.Reboot.message) + } + .errorAlert($errorMessage) + } - private func rebootDevice() { - guard let settingsService = appState.services?.settingsService else { return } + private func rebootDevice() { + guard let settingsService = appState.services?.settingsService else { return } - isRebooting = true - Task { - defer { isRebooting = false } - do { - try await settingsService.reboot() - } catch BLEError.operationTimeout { - // Expected - device reboots before BLE write callback arrives - } catch { - errorMessage = error.userFacingMessage - } - } + isRebooting = true + Task { + defer { isRebooting = false } + do { + try await settingsService.reboot() + } catch BLEError.operationTimeout { + // Expected - device reboots before BLE write callback arrives + } catch { + errorMessage = error.userFacingMessage + } } + } } diff --git a/MC1/Views/Settings/Sections/DeviceIdentitySection.swift b/MC1/Views/Settings/Sections/DeviceIdentitySection.swift index 5e01484a..e61a5a4b 100644 --- a/MC1/Views/Settings/Sections/DeviceIdentitySection.swift +++ b/MC1/Views/Settings/Sections/DeviceIdentitySection.swift @@ -2,29 +2,29 @@ import SwiftUI /// Section for managing the device's cryptographic identity struct DeviceIdentitySection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Binding var showingImportKeySheet: Bool - @Binding var showingRegenerateSheet: Bool + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Binding var showingImportKeySheet: Bool + @Binding var showingRegenerateSheet: Bool - var body: some View { - Section { - Button { - showingImportKeySheet = true - } label: { - Label(L10n.Settings.ImportKey.title, systemImage: "square.and.arrow.down") - } - .radioDisabled(for: appState.connectionState) + var body: some View { + Section { + Button { + showingImportKeySheet = true + } label: { + Label(L10n.Settings.ImportKey.title, systemImage: "square.and.arrow.down") + } + .radioDisabled(for: appState.connectionState) - Button { - showingRegenerateSheet = true - } label: { - Label(L10n.Settings.RegenerateIdentity.title, systemImage: "arrow.triangle.2.circlepath") - } - .radioDisabled(for: appState.connectionState) - } header: { - Text(L10n.Settings.RegenerateIdentity.header) - } - .themedRowBackground(theme) + Button { + showingRegenerateSheet = true + } label: { + Label(L10n.Settings.RegenerateIdentity.title, systemImage: "arrow.triangle.2.circlepath") + } + .radioDisabled(for: appState.connectionState) + } header: { + Text(L10n.Settings.RegenerateIdentity.header) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Settings/Sections/DiagnosticsSection.swift b/MC1/Views/Settings/Sections/DiagnosticsSection.swift index 1d3bf91a..e2643fba 100644 --- a/MC1/Views/Settings/Sections/DiagnosticsSection.swift +++ b/MC1/Views/Settings/Sections/DiagnosticsSection.swift @@ -3,77 +3,77 @@ import SwiftUI /// Settings section for diagnostic tools including log export and clearing struct DiagnosticsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Binding var exportedFile: ExportedLogFile? - let isSidebar: Bool - @State private var isExporting = false - @State private var showingClearLogsAlert = false - @State private var errorMessage: String? + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Binding var exportedFile: ExportedLogFile? + let isSidebar: Bool + @State private var isExporting = false + @State private var showingClearLogsAlert = false + @State private var errorMessage: String? - var body: some View { - Section { - Button { - exportLogs() - } label: { - HStack { - TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "square.and.arrow.up") - Spacer() - if isExporting { - ProgressView() - } - } - } - .disabled(isExporting) - - Button(role: .destructive) { - showingClearLogsAlert = true - } label: { - Label(L10n.Settings.Diagnostics.clearLogs, systemImage: "trash") - } - } header: { - Text(L10n.Settings.Diagnostics.header) - } footer: { - Text(L10n.Settings.Diagnostics.footer) - } - .themedRowBackground(theme, flatten: isSidebar) - .alert(L10n.Settings.Diagnostics.Alert.Clear.title, isPresented: $showingClearLogsAlert) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.Diagnostics.Alert.Clear.confirm, role: .destructive) { - clearDebugLogs() - } - } message: { - Text(L10n.Settings.Diagnostics.Alert.Clear.message) + var body: some View { + Section { + Button { + exportLogs() + } label: { + HStack { + TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "square.and.arrow.up") + Spacer() + if isExporting { + ProgressView() + } } - .errorAlert($errorMessage) + } + .disabled(isExporting) + + Button(role: .destructive) { + showingClearLogsAlert = true + } label: { + Label(L10n.Settings.Diagnostics.clearLogs, systemImage: "trash") + } + } header: { + Text(L10n.Settings.Diagnostics.header) + } footer: { + Text(L10n.Settings.Diagnostics.footer) } + .themedRowBackground(theme, flatten: isSidebar) + .alert(L10n.Settings.Diagnostics.Alert.Clear.title, isPresented: $showingClearLogsAlert) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.Diagnostics.Alert.Clear.confirm, role: .destructive) { + clearDebugLogs() + } + } message: { + Text(L10n.Settings.Diagnostics.Alert.Clear.message) + } + .errorAlert($errorMessage) + } - private func exportLogs() { - let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() - isExporting = true + private func exportLogs() { + let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + isExporting = true - Task { @MainActor in - if let url = await LogExportService.createExportFile( - appState: appState, - persistenceStore: dataStore - ) { - exportedFile = ExportedLogFile(url: url) - } else { - errorMessage = L10n.Settings.Diagnostics.Error.exportFailed - } - isExporting = false - } + Task { @MainActor in + if let url = await LogExportService.createExportFile( + appState: appState, + persistenceStore: dataStore + ) { + exportedFile = ExportedLogFile(url: url) + } else { + errorMessage = L10n.Settings.Diagnostics.Error.exportFailed + } + isExporting = false } + } - private func clearDebugLogs() { - let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + private func clearDebugLogs() { + let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() - Task { - do { - try await dataStore.clearDebugLogEntries() - } catch { - errorMessage = error.userFacingMessage - } - } + Task { + do { + try await dataStore.clearDebugLogEntries() + } catch { + errorMessage = error.userFacingMessage + } } + } } diff --git a/MC1/Views/Settings/Sections/DirectMessagesSettingsSection.swift b/MC1/Views/Settings/Sections/DirectMessagesSettingsSection.swift index 03f1ce45..4899f85c 100644 --- a/MC1/Views/Settings/Sections/DirectMessagesSettingsSection.swift +++ b/MC1/Views/Settings/Sections/DirectMessagesSettingsSection.swift @@ -1,70 +1,72 @@ -import SwiftUI import MC1Services +import SwiftUI /// Settings section for direct message acknowledgment count struct DirectMessagesSettingsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isSaving = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isSaving = false - private var device: DeviceDTO? { appState.connectedDevice } + private var device: DeviceDTO? { + appState.connectedDevice + } - var body: some View { - Section { - Picker(L10n.Settings.DirectMessages.acknowledgments, selection: acksBinding) { - Text("1").tag(1) - Text("2").tag(2) - } - .pickerStyle(.menu) - .radioDisabled(for: appState.connectionState, or: isSaving) - } header: { - Text(L10n.Settings.DirectMessages.header) - } footer: { - Text(L10n.Settings.DirectMessages.footer) - } - .themedRowBackground(theme) - .errorAlert($errorMessage) - .retryAlert(retryAlert) + var body: some View { + Section { + Picker(L10n.Settings.DirectMessages.acknowledgments, selection: acksBinding) { + Text("1").tag(1) + Text("2").tag(2) + } + .pickerStyle(.menu) + .radioDisabled(for: appState.connectionState, or: isSaving) + } header: { + Text(L10n.Settings.DirectMessages.header) + } footer: { + Text(L10n.Settings.DirectMessages.footer) } + .themedRowBackground(theme) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } - // MARK: - Binding + // MARK: - Binding - private var acksBinding: Binding { - Binding( - get: { Int(device?.multiAcks ?? 0) + 1 }, - set: { saveMultiAcks(UInt8($0 - 1)) } - ) - } + private var acksBinding: Binding { + Binding( + get: { Int(device?.multiAcks ?? 0) + 1 }, + set: { saveMultiAcks(UInt8($0 - 1)) } + ) + } - // MARK: - Save + // MARK: - Save - private func saveMultiAcks(_ value: UInt8) { - guard let device, let settingsService = appState.services?.settingsService else { return } + private func saveMultiAcks(_ value: UInt8) { + guard let device, let settingsService = appState.services?.settingsService else { return } - isSaving = true - Task { - do { - _ = try await settingsService.setOtherParamsVerified(from: device, multiAcks: value) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { saveMultiAcks(value) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isSaving = false - } + isSaving = true + Task { + do { + _ = try await settingsService.setOtherParamsVerified(from: device, multiAcks: value) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { saveMultiAcks(value) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + } + isSaving = false } + } } #Preview { - Form { - DirectMessagesSettingsSection() - } + Form { + DirectMessagesSettingsSection() + } } diff --git a/MC1/Views/Settings/Sections/ExportedLogFile.swift b/MC1/Views/Settings/Sections/ExportedLogFile.swift index dc776557..2db51fdb 100644 --- a/MC1/Views/Settings/Sections/ExportedLogFile.swift +++ b/MC1/Views/Settings/Sections/ExportedLogFile.swift @@ -2,6 +2,6 @@ import Foundation /// Identifiable wrapper so a generated debug-log export file can drive `.sheet(item:)`. struct ExportedLogFile: Identifiable { - let id = UUID() - let url: URL + let id = UUID() + let url: URL } diff --git a/MC1/Views/Settings/Sections/ImportKeySheet.swift b/MC1/Views/Settings/Sections/ImportKeySheet.swift index 0dd23391..1e8ae43e 100644 --- a/MC1/Views/Settings/Sections/ImportKeySheet.swift +++ b/MC1/Views/Settings/Sections/ImportKeySheet.swift @@ -3,99 +3,99 @@ import SwiftUI /// Sheet for importing an existing Ed25519 private key onto the device struct ImportKeySheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var viewModel = ImportKeyViewModel() + @State private var viewModel = ImportKeyViewModel() - var body: some View { - NavigationStack { - Form { - explanationSection - .themedRowBackground(theme) - keyInputSection - .themedRowBackground(theme) - importSection - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.ImportKey.Sheet.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - dismiss() - } - .disabled(viewModel.isImporting) - } - } - .interactiveDismissDisabled(viewModel.isImporting) - .alert( - L10n.Settings.RegenerateIdentity.Alert.Replace.title, - isPresented: $viewModel.showingReplaceAlert - ) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.RegenerateIdentity.Alert.Replace.confirm, role: .destructive) { - Task { - if await viewModel.importKey() { - dismiss() - } - } - } - } message: { - Text(L10n.Settings.RegenerateIdentity.Alert.Replace.message) - } - .errorAlert($viewModel.errorMessage) - .sensoryFeedback(.success, trigger: viewModel.successTrigger) - .task { - viewModel.configure(settingsService: { appState.services?.settingsService }) + var body: some View { + NavigationStack { + Form { + explanationSection + .themedRowBackground(theme) + keyInputSection + .themedRowBackground(theme) + importSection + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.ImportKey.Sheet.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + dismiss() + } + .disabled(viewModel.isImporting) + } + } + .interactiveDismissDisabled(viewModel.isImporting) + .alert( + L10n.Settings.RegenerateIdentity.Alert.Replace.title, + isPresented: $viewModel.showingReplaceAlert + ) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.RegenerateIdentity.Alert.Replace.confirm, role: .destructive) { + Task { + if await viewModel.importKey() { + dismiss() } + } } + } message: { + Text(L10n.Settings.RegenerateIdentity.Alert.Replace.message) + } + .errorAlert($viewModel.errorMessage) + .sensoryFeedback(.success, trigger: viewModel.successTrigger) + .task { + viewModel.configure(settingsService: { appState.services?.settingsService }) + } } + } - // MARK: - Sections + // MARK: - Sections - private var explanationSection: some View { - Section { - Text(L10n.Settings.ImportKey.Sheet.explanation) - .foregroundStyle(.secondary) - } + private var explanationSection: some View { + Section { + Text(L10n.Settings.ImportKey.Sheet.explanation) + .foregroundStyle(.secondary) } + } - private var keyInputSection: some View { - Section { - TextField(L10n.Settings.ImportKey.KeyInput.placeholder, text: $viewModel.hexInput, axis: .vertical) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .font(.system(.body, design: .monospaced)) - .lineLimit(3...6) - .onChange(of: viewModel.hexInput) { _, newValue in - viewModel.sanitizeInput(newValue) - } - } header: { - Text(L10n.Settings.ImportKey.KeyInput.label) + private var keyInputSection: some View { + Section { + TextField(L10n.Settings.ImportKey.KeyInput.placeholder, text: $viewModel.hexInput, axis: .vertical) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .font(.system(.body, design: .monospaced)) + .lineLimit(3...6) + .onChange(of: viewModel.hexInput) { _, newValue in + viewModel.sanitizeInput(newValue) } + } header: { + Text(L10n.Settings.ImportKey.KeyInput.label) } + } - private var importSection: some View { - Section { - Button { - viewModel.validateAndConfirm() - } label: { - HStack { - Spacer() - if viewModel.isImporting { - ProgressView() - .controlSize(.small) - Text(L10n.Settings.ImportKey.importing) - } else { - Text(L10n.Settings.ImportKey.import) - } - Spacer() - } - } - .disabled(viewModel.isImporting || viewModel.hexInput.isEmpty) + private var importSection: some View { + Section { + Button { + viewModel.validateAndConfirm() + } label: { + HStack { + Spacer() + if viewModel.isImporting { + ProgressView() + .controlSize(.small) + Text(L10n.Settings.ImportKey.importing) + } else { + Text(L10n.Settings.ImportKey.import) + } + Spacer() } + } + .disabled(viewModel.isImporting || viewModel.hexInput.isEmpty) } + } } diff --git a/MC1/Views/Settings/Sections/ImportKeyViewModel.swift b/MC1/Views/Settings/Sections/ImportKeyViewModel.swift index c7c74497..6f498acb 100644 --- a/MC1/Views/Settings/Sections/ImportKeyViewModel.swift +++ b/MC1/Views/Settings/Sections/ImportKeyViewModel.swift @@ -1,81 +1,83 @@ -import SwiftUI import MC1Services +import SwiftUI @Observable @MainActor final class ImportKeyViewModel { - var hexInput = "" - var isImporting = false - var showingReplaceAlert = false - var errorMessage: String? - var successTrigger = 0 + var hexInput = "" + var isImporting = false + var showingReplaceAlert = false + var errorMessage: String? + var successTrigger = 0 - private var validatedKeyData: Data? + private var validatedKeyData: Data? - /// Restricts the pasted key text to hex digits. - func sanitizeInput(_ newValue: String) { - let filtered = String(newValue.uppercased().filter { $0.isASCII && $0.isHexDigit }) - if filtered != newValue { - hexInput = filtered - } + /// Restricts the pasted key text to hex digits. + func sanitizeInput(_ newValue: String) { + let filtered = String(newValue.uppercased().filter { $0.isASCII && $0.isHexDigit }) + if filtered != newValue { + hexInput = filtered } + } - func validateAndConfirm() { - // Parse hex and validate length - guard let keyData = Data(hexString: hexInput), - keyData.count == ProtocolLimits.privateKeySize else { - errorMessage = L10n.Settings.ImportKey.Error.invalidHex - return - } - - // Validate Ed25519 clamping - do { - try KeyGenerationService.validateExpandedKey(keyData) - } catch { - errorMessage = L10n.Settings.ImportKey.Error.invalidKey - return - } + func validateAndConfirm() { + // Parse hex and validate length + guard let keyData = Data(hexString: hexInput), + keyData.count == ProtocolLimits.privateKeySize else { + errorMessage = L10n.Settings.ImportKey.Error.invalidHex + return + } - validatedKeyData = keyData - showingReplaceAlert = true + // Validate Ed25519 clamping + do { + try KeyGenerationService.validateExpandedKey(keyData) + } catch { + errorMessage = L10n.Settings.ImportKey.Error.invalidKey + return } - // MARK: - Dependencies + validatedKeyData = keyData + showingReplaceAlert = true + } - private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } - var settingsService: SettingsService? { settingsServiceProvider() } + // MARK: - Dependencies - func configure(settingsService: @escaping @MainActor () -> SettingsService?) { - self.settingsServiceProvider = settingsService - } + private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } + var settingsService: SettingsService? { + settingsServiceProvider() + } + + func configure(settingsService: @escaping @MainActor () -> SettingsService?) { + settingsServiceProvider = settingsService + } - /// Returns true when the key was imported and the sheet should dismiss. - /// A nil service mirrors a disconnected state and is a no-op. - func importKey() async -> Bool { - guard let keyData = validatedKeyData, - let settingsService = settingsService else { return false } + /// Returns true when the key was imported and the sheet should dismiss. + /// A nil service mirrors a disconnected state and is a no-op. + func importKey() async -> Bool { + guard let keyData = validatedKeyData, + let settingsService else { return false } - isImporting = true - defer { isImporting = false } - do { - try await settingsService.importPrivateKey(keyData) - try await settingsService.refreshDeviceInfo() - successTrigger += 1 - return true - } catch let error as SettingsServiceError { - if case .sessionError(let meshError) = error, - case .featureDisabled = meshError { - errorMessage = L10n.Settings.RegenerateIdentity.Error.featureDisabled - } else if case .sessionError(let meshError) = error, - case .deviceError = meshError { - errorMessage = L10n.Settings.RegenerateIdentity.Error.deviceRejected - } else { - errorMessage = error.userFacingMessage - } - return false - } catch { - errorMessage = error.userFacingMessage - return false - } + isImporting = true + defer { isImporting = false } + do { + try await settingsService.importPrivateKey(keyData) + try await settingsService.refreshDeviceInfo() + successTrigger += 1 + return true + } catch let error as SettingsServiceError { + if case let .sessionError(meshError) = error, + case .featureDisabled = meshError { + errorMessage = L10n.Settings.RegenerateIdentity.Error.featureDisabled + } else if case let .sessionError(meshError) = error, + case .deviceError = meshError { + errorMessage = L10n.Settings.RegenerateIdentity.Error.deviceRejected + } else { + errorMessage = error.userFacingMessage + } + return false + } catch { + errorMessage = error.userFacingMessage + return false } + } } diff --git a/MC1/Views/Settings/Sections/LinkPreviewSettingsSection.swift b/MC1/Views/Settings/Sections/LinkPreviewSettingsSection.swift index 0c55aa58..9c362919 100644 --- a/MC1/Views/Settings/Sections/LinkPreviewSettingsSection.swift +++ b/MC1/Views/Settings/Sections/LinkPreviewSettingsSection.swift @@ -1,51 +1,44 @@ import MC1Services import SwiftUI -/// Settings section for link preview preferences +/// Settings section for the link-content master toggle. One master +/// (`linkPreviewsEnabled`) governs both link-preview cards and inline images; +/// the DM/channel scope sub-toggles and GIF autoplay nest beneath it. struct LinkPreviewSettingsSection: View { - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.linkPreviewsEnabled.rawValue) private var previewsEnabled = AppStorageKey.defaultLinkPreviewsEnabled - @AppStorage(AppStorageKey.linkPreviewsAutoResolveDM.rawValue) private var autoResolveDM = AppStorageKey.defaultLinkPreviewsAutoResolveDM - @AppStorage(AppStorageKey.linkPreviewsAutoResolveChannels.rawValue) private var autoResolveChannels = AppStorageKey.defaultLinkPreviewsAutoResolveChannels - @AppStorage(AppStorageKey.showInlineImages.rawValue) private var showInlineImages = AppStorageKey.defaultShowInlineImages - @AppStorage(AppStorageKey.autoPlayGIFs.rawValue) private var autoPlayGIFs = AppStorageKey.defaultAutoPlayGIFs + @Environment(\.appTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @AppStorage(AppStorageKey.linkPreviewsEnabled.rawValue) private var previewsEnabled = AppStorageKey.defaultLinkPreviewsEnabled + @AppStorage(AppStorageKey.linkPreviewsAutoResolveDM.rawValue) private var autoResolveDM = AppStorageKey.defaultLinkPreviewsAutoResolveDM + @AppStorage(AppStorageKey.linkPreviewsAutoResolveChannels.rawValue) private var autoResolveChannels = AppStorageKey.defaultLinkPreviewsAutoResolveChannels + @AppStorage(AppStorageKey.autoPlayGIFs.rawValue) private var autoPlayGIFs = AppStorageKey.defaultAutoPlayGIFs - var body: some View { - Section { - Toggle(isOn: $previewsEnabled) { - TintedLabel(L10n.Settings.LinkPreviews.toggle, systemImage: "link") - } + var body: some View { + Section { + Toggle(isOn: $previewsEnabled) { + TintedLabel(L10n.Settings.LinkPreviews.toggle, systemImage: "link") + } - if previewsEnabled { - Toggle(L10n.Settings.LinkPreviews.showInDMs, isOn: $autoResolveDM) - Toggle(L10n.Settings.LinkPreviews.showInChannels, isOn: $autoResolveChannels) - } - } header: { - Text(L10n.Settings.LinkPreviews.header) - } footer: { - Text(L10n.Settings.LinkPreviews.footer) + if previewsEnabled { + Toggle(L10n.Settings.LinkPreviews.showInDMs, isOn: $autoResolveDM) + Toggle(L10n.Settings.LinkPreviews.showInChannels, isOn: $autoResolveChannels) + Toggle(L10n.Settings.InlineImages.autoPlayGifs, isOn: $autoPlayGIFs) + } + } header: { + Text(L10n.Settings.LinkPreviews.header) + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Settings.LinkPreviews.footer) + if previewsEnabled, reduceMotion { + Text(L10n.Settings.LinkPreviews.reduceMotionNote) } - .themedRowBackground(theme) - - Section { - Toggle(isOn: $showInlineImages) { - TintedLabel(L10n.Settings.InlineImages.toggle, systemImage: "photo") - } - - if showInlineImages { - Toggle(isOn: $autoPlayGIFs) { - TintedLabel(L10n.Settings.InlineImages.autoPlayGifs, systemImage: "play.square") - } - } - } footer: { - Text(L10n.Settings.InlineImages.footer) - } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } #Preview { - Form { - LinkPreviewSettingsSection() - } + Form { + LinkPreviewSettingsSection() + } } diff --git a/MC1/Views/Settings/Sections/LocationSettingsSection.swift b/MC1/Views/Settings/Sections/LocationSettingsSection.swift index 3d17ae28..f5650469 100644 --- a/MC1/Views/Settings/Sections/LocationSettingsSection.swift +++ b/MC1/Views/Settings/Sections/LocationSettingsSection.swift @@ -1,346 +1,346 @@ +import MC1Services import OSLog import SwiftUI -import MC1Services private let logger = Logger(subsystem: "com.mc1", category: "LocationSettings") /// Location settings: set location, share publicly, auto-update from GPS struct LocationSettingsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @Environment(\.openURL) private var openURL - @Binding var showingLocationPicker: Bool - @State private var shareLocation = false - @State private var autoUpdateLocation = false - @State private var gpsSource: GPSSource = .phone - @State private var deviceHasGPS = false - @State private var deviceGPSEnabled = false - @State private var errorMessage: String? - @State private var showLocationDeniedAlert = false - @State private var retryAlert = RetryAlertState() - @State private var isSaving = false - @State private var didLoad = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @Binding var showingLocationPicker: Bool + @State private var shareLocation = false + @State private var autoUpdateLocation = false + @State private var gpsSource: GPSSource = .phone + @State private var deviceHasGPS = false + @State private var deviceGPSEnabled = false + @State private var errorMessage: String? + @State private var showLocationDeniedAlert = false + @State private var retryAlert = RetryAlertState() + @State private var isSaving = false + @State private var didLoad = false - private let devicePreferenceStore = DevicePreferenceStore() + private let devicePreferenceStore = DevicePreferenceStore() - private var shouldPollDeviceGPS: Bool { - autoUpdateLocation && gpsSource == .device && deviceGPSEnabled - } + private var shouldPollDeviceGPS: Bool { + autoUpdateLocation && gpsSource == .device && deviceGPSEnabled + } - var body: some View { - Group { - Section { - Button { - showingLocationPicker = true - } label: { - HStack { - TintedLabel(L10n.Settings.Node.setLocation, systemImage: "mappin.and.ellipse") - Spacer() - if let device = appState.connectedDevice, - device.latitude != 0 || device.longitude != 0 { - Text(L10n.Settings.Node.locationSet) - .foregroundStyle(.secondary) - } else { - Text(L10n.Settings.Node.locationNotSet) - .foregroundStyle(.tertiary) - } - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) - .radioDisabled(for: appState.connectionState, or: isSaving || autoUpdateLocation) + var body: some View { + Group { + Section { + Button { + showingLocationPicker = true + } label: { + HStack { + TintedLabel(L10n.Settings.Node.setLocation, systemImage: "mappin.and.ellipse") + Spacer() + if let device = appState.connectedDevice, + device.latitude != 0 || device.longitude != 0 { + Text(L10n.Settings.Node.locationSet) + .foregroundStyle(.secondary) + } else { + Text(L10n.Settings.Node.locationNotSet) + .foregroundStyle(.tertiary) + } + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) + .radioDisabled(for: appState.connectionState, or: isSaving || autoUpdateLocation) - Toggle(isOn: $shareLocation) { - TintedLabel(L10n.Settings.Node.shareLocationPublicly, systemImage: "location") - } - .onChange(of: shareLocation) { _, newValue in - guard didLoad else { return } - updateShareLocation(newValue) - } - .radioDisabled(for: appState.connectionState, or: isSaving) + Toggle(isOn: $shareLocation) { + TintedLabel(L10n.Settings.Node.shareLocationPublicly, systemImage: "location") + } + .onChange(of: shareLocation) { _, newValue in + guard didLoad else { return } + updateShareLocation(newValue) + } + .radioDisabled(for: appState.connectionState, or: isSaving) - Toggle(isOn: $autoUpdateLocation) { - TintedLabel(L10n.Settings.Location.autoUpdate, systemImage: "location.circle") - } - .onChange(of: autoUpdateLocation) { _, newValue in - handleAutoUpdateChange(newValue) - } - .radioDisabled(for: appState.connectionState, or: isSaving) + Toggle(isOn: $autoUpdateLocation) { + TintedLabel(L10n.Settings.Location.autoUpdate, systemImage: "location.circle") + } + .onChange(of: autoUpdateLocation) { _, newValue in + handleAutoUpdateChange(newValue) + } + .radioDisabled(for: appState.connectionState, or: isSaving) - if autoUpdateLocation { - if deviceHasGPS { - Picker(L10n.Settings.Location.gpsSource, selection: $gpsSource) { - Text(L10n.Settings.Location.GpsSource.phone).tag(GPSSource.phone) - Text(L10n.Settings.Location.GpsSource.device).tag(GPSSource.device) - } - .onChange(of: gpsSource) { oldValue, newValue in - handleGPSSourceChange(from: oldValue, to: newValue) - } - .radioDisabled(for: appState.connectionState, or: isSaving) - } else { - LabeledContent(L10n.Settings.Location.gpsSource) { - Text(L10n.Settings.Location.GpsSource.phone) - .foregroundStyle(.secondary) - } - } - } - } header: { - Text(L10n.Settings.Location.header) - } footer: { - Text(L10n.Settings.Location.footer) + if autoUpdateLocation { + if deviceHasGPS { + Picker(L10n.Settings.Location.gpsSource, selection: $gpsSource) { + Text(L10n.Settings.Location.GpsSource.phone).tag(GPSSource.phone) + Text(L10n.Settings.Location.GpsSource.device).tag(GPSSource.device) } - .themedRowBackground(theme) - - if deviceHasGPS { - Section { - Toggle(isOn: deviceGPSBinding) { - TintedLabel(L10n.Settings.Location.DeviceGps.toggle, systemImage: "location.circle") - } - .radioDisabled(for: appState.connectionState, or: isSaving) - } header: { - Text(L10n.Settings.Location.DeviceGps.header) - } footer: { - Text(L10n.Settings.Location.DeviceGps.footer) - } - .themedRowBackground(theme) + .onChange(of: gpsSource) { oldValue, newValue in + handleGPSSourceChange(from: oldValue, to: newValue) } - } - .task(id: startupTaskID) { - loadPreferences() - guard appState.canRunSettingsStartupReads else { - logger.debug("Deferring location settings startup reads until sync is less contended") - return + .radioDisabled(for: appState.connectionState, or: isSaving) + } else { + LabeledContent(L10n.Settings.Location.gpsSource) { + Text(L10n.Settings.Location.GpsSource.phone) + .foregroundStyle(.secondary) } - await loadDeviceGPSState() + } } - .task(id: shouldPollDeviceGPS) { - guard shouldPollDeviceGPS, - let settingsService = appState.services?.settingsService else { return } - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(3)) - guard let device = appState.connectedDevice, - device.latitude == 0, device.longitude == 0 else { break } - try? await settingsService.refreshDeviceInfo() - } + } header: { + Text(L10n.Settings.Location.header) + } footer: { + Text(L10n.Settings.Location.footer) + } + .themedRowBackground(theme) + + if deviceHasGPS { + Section { + Toggle(isOn: deviceGPSBinding) { + TintedLabel(L10n.Settings.Location.DeviceGps.toggle, systemImage: "location.circle") + } + .radioDisabled(for: appState.connectionState, or: isSaving) + } header: { + Text(L10n.Settings.Location.DeviceGps.header) + } footer: { + Text(L10n.Settings.Location.DeviceGps.footer) } - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .alert(L10n.Onboarding.Permissions.LocationAlert.title, isPresented: $showLocationDeniedAlert) { - Button(L10n.Onboarding.Permissions.LocationAlert.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - } message: { - Text(L10n.Onboarding.Permissions.LocationAlert.message) + .themedRowBackground(theme) + } + } + .task(id: startupTaskID) { + loadPreferences() + guard appState.canRunSettingsStartupReads else { + logger.debug("Deferring location settings startup reads until sync is less contended") + return + } + await loadDeviceGPSState() + } + .task(id: shouldPollDeviceGPS) { + guard shouldPollDeviceGPS, + let settingsService = appState.services?.settingsService else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + guard let device = appState.connectedDevice, + device.latitude == 0, device.longitude == 0 else { break } + try? await settingsService.refreshDeviceInfo() + } + } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .alert(L10n.Onboarding.Permissions.LocationAlert.title, isPresented: $showLocationDeniedAlert) { + Button(L10n.Onboarding.Permissions.LocationAlert.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) } + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Onboarding.Permissions.LocationAlert.message) } + } - private var startupTaskID: String { - let deviceID = appState.connectedDevice?.id.uuidString ?? "none" - let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" - return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)-picker:\(showingLocationPicker)" - } + private var startupTaskID: String { + let deviceID = appState.connectedDevice?.id.uuidString ?? "none" + let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" + return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)-picker:\(showingLocationPicker)" + } - private var deviceGPSBinding: Binding { - Binding( - get: { deviceGPSEnabled }, - set: { updateDeviceGPSToggle($0) } - ) - } + private var deviceGPSBinding: Binding { + Binding( + get: { deviceGPSEnabled }, + set: { updateDeviceGPSToggle($0) } + ) + } - private func loadPreferences() { - if let device = appState.connectedDevice { - shareLocation = device.sharesLocationPublicly - autoUpdateLocation = devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: device.id) - gpsSource = devicePreferenceStore.gpsSource(deviceID: device.id) - } - didLoad = true + private func loadPreferences() { + if let device = appState.connectedDevice { + shareLocation = device.sharesLocationPublicly + autoUpdateLocation = devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: device.id) + gpsSource = devicePreferenceStore.gpsSource(deviceID: device.id) } + didLoad = true + } - private func loadDeviceGPSState() async { - guard appState.canRunSettingsStartupReads else { return } - guard let settingsService = appState.services?.settingsService else { return } - do { - let state = try await settingsService.getDeviceGPSState() - deviceHasGPS = state.isSupported - deviceGPSEnabled = state.isEnabled - if let deviceID = appState.connectedDevice?.id, - state.isEnabled, - !devicePreferenceStore.hasSetGPSSource(deviceID: deviceID) { - gpsSource = .device - devicePreferenceStore.setGPSSource(.device, deviceID: deviceID) - } - if state.isEnabled { - try? await settingsService.refreshDeviceInfo() - } - } catch { - deviceHasGPS = false - deviceGPSEnabled = false - } + private func loadDeviceGPSState() async { + guard appState.canRunSettingsStartupReads else { return } + guard let settingsService = appState.services?.settingsService else { return } + do { + let state = try await settingsService.getDeviceGPSState() + deviceHasGPS = state.isSupported + deviceGPSEnabled = state.isEnabled + if let deviceID = appState.connectedDevice?.id, + state.isEnabled, + !devicePreferenceStore.hasSetGPSSource(deviceID: deviceID) { + gpsSource = .device + devicePreferenceStore.setGPSSource(.device, deviceID: deviceID) + } + if state.isEnabled { + try? await settingsService.refreshDeviceInfo() + } + } catch { + deviceHasGPS = false + deviceGPSEnabled = false } + } - private func handleAutoUpdateChange(_ newValue: Bool) { - guard let deviceID = appState.connectedDevice?.id else { return } - if newValue, gpsSource == .phone, appState.locationService.isLocationDenied { + private func handleAutoUpdateChange(_ newValue: Bool) { + guard let deviceID = appState.connectedDevice?.id else { return } + if newValue, gpsSource == .phone, appState.locationService.isLocationDenied { + autoUpdateLocation = false + showLocationDeniedAlert = true + return + } + devicePreferenceStore.setAutoUpdateLocationEnabled(newValue, deviceID: deviceID) + if newValue, gpsSource == .phone { + appState.locationService.requestPermissionIfNeeded() + } + if gpsSource == .device { + if newValue { + saveDeviceGPS( + true, + onFailure: { autoUpdateLocation = false - showLocationDeniedAlert = true - return - } - devicePreferenceStore.setAutoUpdateLocationEnabled(newValue, deviceID: deviceID) - if newValue, gpsSource == .phone { - appState.locationService.requestPermissionIfNeeded() - } - if gpsSource == .device { - if newValue { - saveDeviceGPS( - true, - onFailure: { - autoUpdateLocation = false - devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) - } - ) - } else if deviceGPSEnabled { - saveDeviceGPS(false) - } - } - if shareLocation { - updateShareLocation(shareLocation) - } + devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) + } + ) + } else if deviceGPSEnabled { + saveDeviceGPS(false) + } } + if shareLocation { + updateShareLocation(shareLocation) + } + } - private func handleGPSSourceChange(from oldValue: GPSSource, to newValue: GPSSource) { - guard let deviceID = appState.connectedDevice?.id else { return } - if newValue == .phone, appState.locationService.isLocationDenied { - gpsSource = oldValue - showLocationDeniedAlert = true - return - } + private func handleGPSSourceChange(from oldValue: GPSSource, to newValue: GPSSource) { + guard let deviceID = appState.connectedDevice?.id else { return } + if newValue == .phone, appState.locationService.isLocationDenied { + gpsSource = oldValue + showLocationDeniedAlert = true + return + } - devicePreferenceStore.setGPSSource(newValue, deviceID: deviceID) - if newValue == .phone { - appState.locationService.requestPermissionIfNeeded() - if deviceGPSEnabled { - saveDeviceGPS(false) - } - } else if autoUpdateLocation { - saveDeviceGPS( - true, - onFailure: { - gpsSource = oldValue - devicePreferenceStore.setGPSSource(oldValue, deviceID: deviceID) - } - ) + devicePreferenceStore.setGPSSource(newValue, deviceID: deviceID) + if newValue == .phone { + appState.locationService.requestPermissionIfNeeded() + if deviceGPSEnabled { + saveDeviceGPS(false) + } + } else if autoUpdateLocation { + saveDeviceGPS( + true, + onFailure: { + gpsSource = oldValue + devicePreferenceStore.setGPSSource(oldValue, deviceID: deviceID) } + ) + } - if shareLocation { - updateShareLocation(shareLocation) - } + if shareLocation { + updateShareLocation(shareLocation) } + } - private func updateDeviceGPSToggle(_ enabled: Bool) { - guard let deviceID = appState.connectedDevice?.id else { return } - let shouldDisableAutoUpdate = !enabled && autoUpdateLocation && gpsSource == .device - saveDeviceGPS(enabled) { - if shouldDisableAutoUpdate { - autoUpdateLocation = false - devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) - try await applyDeviceGPSDisabledSharePolicyIfNeeded() - } - } + private func updateDeviceGPSToggle(_ enabled: Bool) { + guard let deviceID = appState.connectedDevice?.id else { return } + let shouldDisableAutoUpdate = !enabled && autoUpdateLocation && gpsSource == .device + saveDeviceGPS(enabled) { + if shouldDisableAutoUpdate { + autoUpdateLocation = false + devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) + try await applyDeviceGPSDisabledSharePolicyIfNeeded() + } } + } - private func updateShareLocation(_ share: Bool) { - guard let device = appState.connectedDevice, - let settingsService = appState.services?.settingsService else { return } - let policy = selectedAdvertLocationPolicy(share: share) + private func updateShareLocation(_ share: Bool) { + guard let device = appState.connectedDevice, + let settingsService = appState.services?.settingsService else { return } + let policy = selectedAdvertLocationPolicy(share: share) - if device.advertLocationPolicyMode == policy { - return - } + if device.advertLocationPolicyMode == policy { + return + } - isSaving = true - Task { - do { - try await applyShareLocationPolicy(policy, using: device, settingsService: settingsService) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - shareLocation = !share - retryAlert.show( - message: error.userFacingMessage, - onRetry: { updateShareLocation(share) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - shareLocation = !share - errorMessage = error.userFacingMessage - } - isSaving = false - } + isSaving = true + Task { + do { + try await applyShareLocationPolicy(policy, using: device, settingsService: settingsService) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + shareLocation = !share + retryAlert.show( + message: error.userFacingMessage, + onRetry: { updateShareLocation(share) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + shareLocation = !share + errorMessage = error.userFacingMessage + } + isSaving = false } + } - private func selectedAdvertLocationPolicy(share: Bool) -> AdvertLocationPolicy { - guard share else { return .none } - if autoUpdateLocation, deviceHasGPS, gpsSource == .device { - return .share - } - return .prefs + private func selectedAdvertLocationPolicy(share: Bool) -> AdvertLocationPolicy { + guard share else { return .none } + if autoUpdateLocation, deviceHasGPS, gpsSource == .device { + return .share } + return .prefs + } - private func saveDeviceGPS( - _ enabled: Bool, - onSuccess: (@MainActor () async throws -> Void)? = nil, - onFailure: (@MainActor () -> Void)? = nil - ) { - guard let settingsService = appState.services?.settingsService else { return } + private func saveDeviceGPS( + _ enabled: Bool, + onSuccess: (@MainActor () async throws -> Void)? = nil, + onFailure: (@MainActor () -> Void)? = nil + ) { + guard let settingsService = appState.services?.settingsService else { return } - let previousEnabled = deviceGPSEnabled - isSaving = true + let previousEnabled = deviceGPSEnabled + isSaving = true - Task { - do { - let state = try await settingsService.setDeviceGPSEnabledVerified(enabled) - deviceHasGPS = state.isSupported - deviceGPSEnabled = state.isEnabled - if let onSuccess { - try await onSuccess() - } - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - deviceGPSEnabled = previousEnabled - onFailure?() - retryAlert.show( - message: error.userFacingMessage, - onRetry: { saveDeviceGPS(enabled, onSuccess: onSuccess, onFailure: onFailure) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - deviceGPSEnabled = previousEnabled - onFailure?() - errorMessage = error.userFacingMessage - } - isSaving = false + Task { + do { + let state = try await settingsService.setDeviceGPSEnabledVerified(enabled) + deviceHasGPS = state.isSupported + deviceGPSEnabled = state.isEnabled + if let onSuccess { + try await onSuccess() } + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + deviceGPSEnabled = previousEnabled + onFailure?() + retryAlert.show( + message: error.userFacingMessage, + onRetry: { saveDeviceGPS(enabled, onSuccess: onSuccess, onFailure: onFailure) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + deviceGPSEnabled = previousEnabled + onFailure?() + errorMessage = error.userFacingMessage + } + isSaving = false } + } - private func applyDeviceGPSDisabledSharePolicyIfNeeded() async throws { - guard shareLocation, - let device = appState.connectedDevice, - device.advertLocationPolicyMode == .share, - let settingsService = appState.services?.settingsService else { return } + private func applyDeviceGPSDisabledSharePolicyIfNeeded() async throws { + guard shareLocation, + let device = appState.connectedDevice, + device.advertLocationPolicyMode == .share, + let settingsService = appState.services?.settingsService else { return } - try await applyShareLocationPolicy(.prefs, using: device, settingsService: settingsService) - } + try await applyShareLocationPolicy(.prefs, using: device, settingsService: settingsService) + } - private func applyShareLocationPolicy( - _ policy: AdvertLocationPolicy, - using device: DeviceDTO, - settingsService: SettingsService - ) async throws { - _ = try await settingsService.setOtherParamsVerified(from: device, advertLocationPolicy: policy) - } + private func applyShareLocationPolicy( + _ policy: AdvertLocationPolicy, + using device: DeviceDTO, + settingsService: SettingsService + ) async throws { + _ = try await settingsService.setOtherParamsVerified(from: device, advertLocationPolicy: policy) + } } diff --git a/MC1/Views/Settings/Sections/MapPreviewSettingsSection.swift b/MC1/Views/Settings/Sections/MapPreviewSettingsSection.swift index cdae14dd..56ab2705 100644 --- a/MC1/Views/Settings/Sections/MapPreviewSettingsSection.swift +++ b/MC1/Views/Settings/Sections/MapPreviewSettingsSection.swift @@ -6,26 +6,26 @@ import SwiftUI /// when off, `MessageFragmentBuilder` skips the fragment entirely and the /// coordinate text in the message body remains tappable. struct MapPreviewSettingsSection: View { - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.showMapPreviewThumbnails.rawValue) - private var showMapPreviewThumbnails = AppStorageKey.defaultShowMapPreviewThumbnails + @Environment(\.appTheme) private var theme + @AppStorage(AppStorageKey.showMapPreviewThumbnails.rawValue) + private var showMapPreviewThumbnails = AppStorageKey.defaultShowMapPreviewThumbnails - var body: some View { - Section { - Toggle(isOn: $showMapPreviewThumbnails) { - TintedLabel(L10n.Settings.MapPreviews.toggle, systemImage: "map") - } - } header: { - Text(L10n.Settings.MapPreviews.header) - } footer: { - Text(L10n.Settings.MapPreviews.footer) - } - .themedRowBackground(theme) + var body: some View { + Section { + Toggle(isOn: $showMapPreviewThumbnails) { + TintedLabel(L10n.Settings.MapPreviews.toggle, systemImage: "map") + } + } header: { + Text(L10n.Settings.MapPreviews.header) + } footer: { + Text(L10n.Settings.MapPreviews.footer) } + .themedRowBackground(theme) + } } #Preview { - Form { - MapPreviewSettingsSection() - } + Form { + MapPreviewSettingsSection() + } } diff --git a/MC1/Views/Settings/Sections/MessagesSettingsSection.swift b/MC1/Views/Settings/Sections/MessagesSettingsSection.swift index 3ec78dc6..9d7837ac 100644 --- a/MC1/Views/Settings/Sections/MessagesSettingsSection.swift +++ b/MC1/Views/Settings/Sections/MessagesSettingsSection.swift @@ -3,29 +3,29 @@ import SwiftUI /// Chat settings section for incoming message routing info display struct MessagesSettingsSection: View { - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = false - @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = false - @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = false - @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = false + @Environment(\.appTheme) private var theme + @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = false + @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = false + @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = false + @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = false - var body: some View { - Section { - Toggle(L10n.Settings.Messages.showIncomingSendTime, isOn: $showIncomingSendTime) - Toggle(L10n.Settings.Messages.showIncomingPath, isOn: $showIncomingPath) - Toggle(L10n.Settings.Messages.showIncomingHopCount, isOn: $showIncomingHopCount) - Toggle(L10n.Settings.Messages.showIncomingRegion, isOn: $showIncomingRegion) - } header: { - Text(L10n.Settings.Messages.header) - } footer: { - Text(L10n.Settings.Messages.footer) - } - .themedRowBackground(theme) + var body: some View { + Section { + Toggle(L10n.Settings.Messages.showIncomingSendTime, isOn: $showIncomingSendTime) + Toggle(L10n.Settings.Messages.showIncomingPath, isOn: $showIncomingPath) + Toggle(L10n.Settings.Messages.showIncomingHopCount, isOn: $showIncomingHopCount) + Toggle(L10n.Settings.Messages.showIncomingRegion, isOn: $showIncomingRegion) + } header: { + Text(L10n.Settings.Messages.header) + } footer: { + Text(L10n.Settings.Messages.footer) } + .themedRowBackground(theme) + } } #Preview { - Form { - MessagesSettingsSection() - } + Form { + MessagesSettingsSection() + } } diff --git a/MC1/Views/Settings/Sections/NoDeviceSection.swift b/MC1/Views/Settings/Sections/NoDeviceSection.swift index 0bf635c5..b77d6477 100644 --- a/MC1/Views/Settings/Sections/NoDeviceSection.swift +++ b/MC1/Views/Settings/Sections/NoDeviceSection.swift @@ -2,22 +2,22 @@ import SwiftUI /// Shown when no device is connected struct NoDeviceSection: View { - @Environment(\.appTheme) private var theme - @Binding var showingDeviceSelection: Bool - let isSidebar: Bool + @Environment(\.appTheme) private var theme + @Binding var showingDeviceSelection: Bool + let isSidebar: Bool - var body: some View { - Section { - Button { - showingDeviceSelection = true - } label: { - TintedLabel(L10n.Settings.Device.connect, systemImage: "antenna.radiowaves.left.and.right") - } - } header: { - Text(L10n.Settings.Device.header) - } footer: { - Text(L10n.Settings.Device.noDeviceConnected) - } - .themedRowBackground(theme, flatten: isSidebar) + var body: some View { + Section { + Button { + showingDeviceSelection = true + } label: { + TintedLabel(L10n.Settings.Device.connect, systemImage: "antenna.radiowaves.left.and.right") + } + } header: { + Text(L10n.Settings.Device.header) + } footer: { + Text(L10n.Settings.Device.noDeviceConnected) } + .themedRowBackground(theme, flatten: isSidebar) + } } diff --git a/MC1/Views/Settings/Sections/NodesSettingsSection.swift b/MC1/Views/Settings/Sections/NodesSettingsSection.swift index 83cf4aa5..8fc8d7d9 100644 --- a/MC1/Views/Settings/Sections/NodesSettingsSection.swift +++ b/MC1/Views/Settings/Sections/NodesSettingsSection.swift @@ -1,237 +1,239 @@ -import SwiftUI import MC1Services +import SwiftUI /// Auto-add mode and type settings for node discovery struct NodesSettingsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isApplying = false - @State private var showSuccess = false - - // Local state for editing - @State private var autoAddMode: AutoAddMode = .manual - @State private var autoAddContacts = false - @State private var autoAddRepeaters = false - @State private var autoAddRoomServers = false - @State private var overwriteOldest = false - @State private var autoAddMaxHops: UInt8 = 0 - - private var device: DeviceDTO? { appState.connectedDevice } - - /// Whether the device supports v1.12+ auto-add config features - private var supportsAutoAddConfig: Bool { - device?.supportsAutoAddConfig ?? false - } - - private var supportsAutoAddMaxHops: Bool { - device?.supportsAutoAddMaxHops ?? false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isApplying = false + @State private var showSuccess = false + + // Local state for editing + @State private var autoAddMode: AutoAddMode = .manual + @State private var autoAddContacts = false + @State private var autoAddRepeaters = false + @State private var autoAddRoomServers = false + @State private var overwriteOldest = false + @State private var autoAddMaxHops: UInt8 = 0 + + private var device: DeviceDTO? { + appState.connectedDevice + } + + /// Whether the device supports v1.12+ auto-add config features + private var supportsAutoAddConfig: Bool { + device?.supportsAutoAddConfig ?? false + } + + private var supportsAutoAddMaxHops: Bool { + device?.supportsAutoAddMaxHops ?? false + } + + private var settingsModified: Bool { + guard let device else { return false } + if device.supportsAutoAddConfig { + return autoAddMode != device.autoAddMode || + autoAddContacts != device.autoAddContacts || + autoAddRepeaters != device.autoAddRepeaters || + autoAddRoomServers != device.autoAddRoomServers || + overwriteOldest != device.overwriteOldest || + autoAddMaxHops != device.autoAddMaxHops + } else { + let deviceMode: AutoAddMode = device.manualAddContacts ? .manual : .all + return autoAddMode != deviceMode } - - private var settingsModified: Bool { - guard let device else { return false } - if device.supportsAutoAddConfig { - return autoAddMode != device.autoAddMode || - autoAddContacts != device.autoAddContacts || - autoAddRepeaters != device.autoAddRepeaters || - autoAddRoomServers != device.autoAddRoomServers || - overwriteOldest != device.overwriteOldest || - autoAddMaxHops != device.autoAddMaxHops - } else { - let deviceMode: AutoAddMode = device.manualAddContacts ? .manual : .all - return autoAddMode != deviceMode + } + + private var canApply: Bool { + appState.connectionState == .ready && settingsModified && !isApplying && !showSuccess + } + + /// Combined hash of all node settings for change detection + private var deviceNodeSettingsHash: Int { + var hasher = Hasher() + hasher.combine(appState.connectedDevice?.autoAddMode) + hasher.combine(appState.connectedDevice?.autoAddContacts) + hasher.combine(appState.connectedDevice?.autoAddRepeaters) + hasher.combine(appState.connectedDevice?.autoAddRoomServers) + hasher.combine(appState.connectedDevice?.overwriteOldest) + hasher.combine(appState.connectedDevice?.supportsAutoAddConfig) + hasher.combine(appState.connectedDevice?.autoAddMaxHops) + hasher.combine(appState.connectedDevice?.supportsAutoAddMaxHops) + return hasher.finalize() + } + + var body: some View { + Section { + Picker(L10n.Settings.Nodes.autoAddMode, selection: $autoAddMode) { + Text(L10n.Settings.Nodes.AutoAddMode.manual).tag(AutoAddMode.manual) + if supportsAutoAddConfig { + Text(L10n.Settings.Nodes.AutoAddMode.selectedTypes).tag(AutoAddMode.selectedTypes) } - } - - private var canApply: Bool { - appState.connectionState == .ready && settingsModified && !isApplying && !showSuccess - } - - /// Combined hash of all node settings for change detection - private var deviceNodeSettingsHash: Int { - var hasher = Hasher() - hasher.combine(appState.connectedDevice?.autoAddMode) - hasher.combine(appState.connectedDevice?.autoAddContacts) - hasher.combine(appState.connectedDevice?.autoAddRepeaters) - hasher.combine(appState.connectedDevice?.autoAddRoomServers) - hasher.combine(appState.connectedDevice?.overwriteOldest) - hasher.combine(appState.connectedDevice?.supportsAutoAddConfig) - hasher.combine(appState.connectedDevice?.autoAddMaxHops) - hasher.combine(appState.connectedDevice?.supportsAutoAddMaxHops) - return hasher.finalize() - } - - var body: some View { - Section { - Picker(L10n.Settings.Nodes.autoAddMode, selection: $autoAddMode) { - Text(L10n.Settings.Nodes.AutoAddMode.manual).tag(AutoAddMode.manual) - if supportsAutoAddConfig { - Text(L10n.Settings.Nodes.AutoAddMode.selectedTypes).tag(AutoAddMode.selectedTypes) - } - Text(L10n.Settings.Nodes.AutoAddMode.all).tag(AutoAddMode.all) - } - .pickerStyle(.menu) - .onChange(of: autoAddMode) { _, newValue in - // Mode changes reveal or hide the type and hop rows below the picker. - if newValue != .manual { - AccessibilityNotification.LayoutChanged().post() - } - } - - if supportsAutoAddConfig && autoAddMode == .selectedTypes { - Toggle(L10n.Settings.Nodes.autoAddContacts, isOn: $autoAddContacts) - Toggle(L10n.Settings.Nodes.autoAddRepeaters, isOn: $autoAddRepeaters) - Toggle(L10n.Settings.Nodes.autoAddRoomServers, isOn: $autoAddRoomServers) - } - - if supportsAutoAddMaxHops && autoAddMode != .manual { - Picker(L10n.Settings.Nodes.maxHops, selection: $autoAddMaxHops) { - Text(L10n.Settings.Nodes.MaxHops.noLimit).tag(UInt8(0)) - Text(L10n.Settings.Nodes.MaxHops.directOnly).tag(UInt8(1)) - Text(L10n.Settings.Nodes.MaxHops.oneHop).tag(UInt8(2)) - ForEach(Array(2...6), id: \.self) { hops in - Text(L10n.Settings.Nodes.MaxHops.hops(hops)).tag(UInt8(hops + 1)) - } - } - .pickerStyle(.menu) - } - - if supportsAutoAddConfig { - Toggle(isOn: $overwriteOldest) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Nodes.overwriteOldest) - Text(L10n.Settings.Nodes.overwriteOldestDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - Button { - applySettings() - } label: { - AsyncActionLabel(isLoading: isApplying, showSuccess: showSuccess) { - Text(L10n.Settings.AdvancedRadio.apply) - .foregroundStyle(canApply ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .radioDisabled(for: appState.connectionState, or: isApplying || showSuccess || !settingsModified) - } header: { - Text(L10n.Settings.Nodes.header) - } footer: { - Text(footerDescription) + Text(L10n.Settings.Nodes.AutoAddMode.all).tag(AutoAddMode.all) + } + .pickerStyle(.menu) + .onChange(of: autoAddMode) { _, newValue in + // Mode changes reveal or hide the type and hop rows below the picker. + if newValue != .manual { + AccessibilityNotification.LayoutChanged().post() } - .themedRowBackground(theme) - .radioDisabled(for: appState.connectionState, or: isApplying) - .onAppear { - loadFromDevice() + } + + if supportsAutoAddConfig, autoAddMode == .selectedTypes { + Toggle(L10n.Settings.Nodes.autoAddContacts, isOn: $autoAddContacts) + Toggle(L10n.Settings.Nodes.autoAddRepeaters, isOn: $autoAddRepeaters) + Toggle(L10n.Settings.Nodes.autoAddRoomServers, isOn: $autoAddRoomServers) + } + + if supportsAutoAddMaxHops, autoAddMode != .manual { + Picker(L10n.Settings.Nodes.maxHops, selection: $autoAddMaxHops) { + Text(L10n.Settings.Nodes.MaxHops.noLimit).tag(UInt8(0)) + Text(L10n.Settings.Nodes.MaxHops.directOnly).tag(UInt8(1)) + Text(L10n.Settings.Nodes.MaxHops.oneHop).tag(UInt8(2)) + ForEach(Array(2...6), id: \.self) { hops in + Text(L10n.Settings.Nodes.MaxHops.hops(hops)).tag(UInt8(hops + 1)) + } } - .onChange(of: deviceNodeSettingsHash) { _, _ in - loadFromDevice() + .pickerStyle(.menu) + } + + if supportsAutoAddConfig { + Toggle(isOn: $overwriteOldest) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Nodes.overwriteOldest) + Text(L10n.Settings.Nodes.overwriteOldestDescription) + .font(.caption) + .foregroundStyle(.secondary) + } } - .errorAlert($errorMessage) - .retryAlert(retryAlert) - } - - private var footerDescription: String { - var description = autoAddModeDescription - if supportsAutoAddMaxHops && autoAddMode != .manual && autoAddMaxHops > 0 { - description += "\n" + L10n.Settings.Nodes.MaxHops.footerActive + } + + Button { + applySettings() + } label: { + AsyncActionLabel(isLoading: isApplying, showSuccess: showSuccess) { + Text(L10n.Settings.AdvancedRadio.apply) + .foregroundStyle(canApply ? Color.accentColor : .secondary) + .transition(.opacity) } - return description + } + .radioDisabled(for: appState.connectionState, or: isApplying || showSuccess || !settingsModified) + } header: { + Text(L10n.Settings.Nodes.header) + } footer: { + Text(footerDescription) } - - private var autoAddModeDescription: String { - switch autoAddMode { - case .manual: - return L10n.Settings.Nodes.AutoAddMode.manualDescription - case .selectedTypes: - return L10n.Settings.Nodes.AutoAddMode.selectedTypesDescription - case .all: - return L10n.Settings.Nodes.AutoAddMode.allDescription - } + .themedRowBackground(theme) + .radioDisabled(for: appState.connectionState, or: isApplying) + .onAppear { + loadFromDevice() + } + .onChange(of: deviceNodeSettingsHash) { _, _ in + loadFromDevice() + } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } + + private var footerDescription: String { + var description = autoAddModeDescription + if supportsAutoAddMaxHops, autoAddMode != .manual, autoAddMaxHops > 0 { + description += "\n" + L10n.Settings.Nodes.MaxHops.footerActive + } + return description + } + + private var autoAddModeDescription: String { + switch autoAddMode { + case .manual: + L10n.Settings.Nodes.AutoAddMode.manualDescription + case .selectedTypes: + L10n.Settings.Nodes.AutoAddMode.selectedTypesDescription + case .all: + L10n.Settings.Nodes.AutoAddMode.allDescription } + } + + private func loadFromDevice() { + guard let device else { return } + + if device.supportsAutoAddConfig { + autoAddMode = device.autoAddMode + autoAddContacts = device.autoAddContacts + autoAddRepeaters = device.autoAddRepeaters + autoAddRoomServers = device.autoAddRoomServers + overwriteOldest = device.overwriteOldest + autoAddMaxHops = device.autoAddMaxHops + } else { + // Older firmware only supports manual/all toggle via manualAddContacts + autoAddMode = device.manualAddContacts ? .manual : .all + autoAddContacts = false + autoAddRepeaters = false + autoAddRoomServers = false + overwriteOldest = false + } + } + + private func applySettings() { + guard !isApplying else { return } + guard let device, let settingsService = appState.services?.settingsService else { return } + + isApplying = true + Task { + do { + // Protocol: manualAddContacts=true for .manual and .selectedTypes, false only for .all + let manualAdd = autoAddMode != .all - private func loadFromDevice() { - guard let device else { return } + // Save manualAddContacts (works on all firmware versions) + _ = try await settingsService.setOtherParamsVerified(from: device, autoAddContacts: !manualAdd) + // Save autoAddConfig only on v1.12+ firmware if device.supportsAutoAddConfig { - autoAddMode = device.autoAddMode - autoAddContacts = device.autoAddContacts - autoAddRepeaters = device.autoAddRepeaters - autoAddRoomServers = device.autoAddRoomServers - overwriteOldest = device.overwriteOldest - autoAddMaxHops = device.autoAddMaxHops - } else { - // Older firmware only supports manual/all toggle via manualAddContacts - autoAddMode = device.manualAddContacts ? .manual : .all - autoAddContacts = false - autoAddRepeaters = false - autoAddRoomServers = false - overwriteOldest = false + var config: UInt8 = 0 + if overwriteOldest { config |= AutoAddConfig.overwriteOldestBit } + + switch autoAddMode { + case .manual: + break + case .selectedTypes: + if autoAddContacts { config |= AutoAddConfig.contactsBit } + if autoAddRepeaters { config |= AutoAddConfig.repeatersBit } + if autoAddRoomServers { config |= AutoAddConfig.roomServersBit } + case .all: + break + } + + _ = try await settingsService.setAutoAddConfigVerified( + AutoAddConfig(bitmask: config, maxHops: autoAddMaxHops) + ) } - } - private func applySettings() { - guard !isApplying else { return } - guard let device, let settingsService = appState.services?.settingsService else { return } - - isApplying = true - Task { - do { - // Protocol: manualAddContacts=true for .manual and .selectedTypes, false only for .all - let manualAdd = autoAddMode != .all - - // Save manualAddContacts (works on all firmware versions) - _ = try await settingsService.setOtherParamsVerified(from: device, autoAddContacts: !manualAdd) - - // Save autoAddConfig only on v1.12+ firmware - if device.supportsAutoAddConfig { - var config: UInt8 = 0 - if overwriteOldest { config |= AutoAddConfig.overwriteOldestBit } - - switch autoAddMode { - case .manual: - break - case .selectedTypes: - if autoAddContacts { config |= AutoAddConfig.contactsBit } - if autoAddRepeaters { config |= AutoAddConfig.repeatersBit } - if autoAddRoomServers { config |= AutoAddConfig.roomServersBit } - case .all: - break - } - - _ = try await settingsService.setAutoAddConfigVerified( - AutoAddConfig(bitmask: config, maxHops: autoAddMaxHops) - ) - } - - retryAlert.reset() - isApplying = false - - withAnimation { - showSuccess = true - } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { - showSuccess = false - } - return - } catch let error as SettingsServiceError where error.isRetryable { - loadFromDevice() - retryAlert.show( - message: error.userFacingMessage, - onRetry: { applySettings() }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - loadFromDevice() - errorMessage = error.userFacingMessage - } - isApplying = false + retryAlert.reset() + isApplying = false + + withAnimation { + showSuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { + showSuccess = false } + return + } catch let error as SettingsServiceError where error.isRetryable { + loadFromDevice() + retryAlert.show( + message: error.userFacingMessage, + onRetry: { applySettings() }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + loadFromDevice() + errorMessage = error.userFacingMessage + } + isApplying = false } + } } diff --git a/MC1/Views/Settings/Sections/NotificationSettingsSection.swift b/MC1/Views/Settings/Sections/NotificationSettingsSection.swift index c953e610..06906e91 100644 --- a/MC1/Views/Settings/Sections/NotificationSettingsSection.swift +++ b/MC1/Views/Settings/Sections/NotificationSettingsSection.swift @@ -4,99 +4,99 @@ import UserNotifications /// Notification toggle settings struct NotificationSettingsSection: View { - @Environment(\.appTheme) private var theme - @Environment(\.openURL) private var openURL - @Environment(\.scenePhase) private var scenePhase - @State private var preferences = NotificationPreferencesStore() - @State private var authorizationStatus: UNAuthorizationStatus = .notDetermined + @Environment(\.appTheme) private var theme + @Environment(\.openURL) private var openURL + @Environment(\.scenePhase) private var scenePhase + @State private var preferences = NotificationPreferencesStore() + @State private var authorizationStatus: UNAuthorizationStatus = .notDetermined - var body: some View { - @Bindable var preferences = preferences - Section { - switch authorizationStatus { - case .notDetermined: - Button { - Task { - await requestAuthorization() - } - } label: { - TintedLabel(L10n.Settings.Notifications.enable, systemImage: "bell.badge") - } - - case .denied: - VStack(alignment: .leading, spacing: 8) { - Label(L10n.Settings.Notifications.disabled, systemImage: "bell.slash") - .foregroundStyle(.secondary) + var body: some View { + @Bindable var preferences = preferences + Section { + switch authorizationStatus { + case .notDetermined: + Button { + Task { + await requestAuthorization() + } + } label: { + TintedLabel(L10n.Settings.Notifications.enable, systemImage: "bell.badge") + } - Button(L10n.Settings.Notifications.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } - .font(.subheadline) - } + case .denied: + VStack(alignment: .leading, spacing: 8) { + Label(L10n.Settings.Notifications.disabled, systemImage: "bell.slash") + .foregroundStyle(.secondary) - default: - Toggle(isOn: $preferences.contactMessagesEnabled) { - TintedLabel(L10n.Settings.Notifications.contactMessages, systemImage: "message") - } - Toggle(isOn: $preferences.channelMessagesEnabled) { - TintedLabel(L10n.Settings.Notifications.channelMessages, systemImage: "person.3") - } - Toggle(isOn: $preferences.roomMessagesEnabled) { - TintedLabel(L10n.Settings.Notifications.roomMessages, systemImage: "bubble.left.and.bubble.right") - } - Toggle(isOn: $preferences.reactionNotificationsEnabled) { - TintedLabel(L10n.Settings.Notifications.reactions, systemImage: "face.smiling") - } - Toggle(isOn: $preferences.lowBatteryEnabled) { - TintedLabel(L10n.Settings.Notifications.lowBattery, systemImage: "battery.25") - } - Toggle(isOn: Binding( - get: { preferences.newContactDiscoveredEnabled }, - set: { newValue in - withAnimation { preferences.newContactDiscoveredEnabled = newValue } - } - )) { - TintedLabel(L10n.Settings.Notifications.newContactDiscovered, systemImage: "person.badge.plus") - } - if preferences.newContactDiscoveredEnabled { - Toggle(isOn: $preferences.discoveryContactEnabled) { - TintedLabel(L10n.Settings.Notifications.discoveryContact, systemImage: "person") - } - .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) - Toggle(isOn: $preferences.discoveryRepeaterEnabled) { - TintedLabel(L10n.Settings.Notifications.discoveryRepeater, systemImage: "antenna.radiowaves.left.and.right") - } - .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) - Toggle(isOn: $preferences.discoveryRoomEnabled) { - TintedLabel(L10n.Settings.Notifications.discoveryRoom, systemImage: "door.left.hand.open") - } - .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) - } + Button(L10n.Settings.Notifications.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) } + } + .font(.subheadline) } - .themedRowBackground(theme) - .task { - await refreshAuthorizationStatus() + + default: + Toggle(isOn: $preferences.contactMessagesEnabled) { + TintedLabel(L10n.Settings.Notifications.contactMessages, systemImage: "message") } - .onChange(of: scenePhase) { - if scenePhase == .active { - Task { - await refreshAuthorizationStatus() - } - } + Toggle(isOn: $preferences.channelMessagesEnabled) { + TintedLabel(L10n.Settings.Notifications.channelMessages, systemImage: "person.3") + } + Toggle(isOn: $preferences.roomMessagesEnabled) { + TintedLabel(L10n.Settings.Notifications.roomMessages, systemImage: "bubble.left.and.bubble.right") + } + Toggle(isOn: $preferences.reactionNotificationsEnabled) { + TintedLabel(L10n.Settings.Notifications.reactions, systemImage: "face.smiling") + } + Toggle(isOn: $preferences.lowBatteryEnabled) { + TintedLabel(L10n.Settings.Notifications.lowBattery, systemImage: "battery.25") } + Toggle(isOn: Binding( + get: { preferences.newContactDiscoveredEnabled }, + set: { newValue in + withAnimation { preferences.newContactDiscoveredEnabled = newValue } + } + )) { + TintedLabel(L10n.Settings.Notifications.newContactDiscovered, systemImage: "person.badge.plus") + } + if preferences.newContactDiscoveredEnabled { + Toggle(isOn: $preferences.discoveryContactEnabled) { + TintedLabel(L10n.Settings.Notifications.discoveryContact, systemImage: "person") + } + .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) + Toggle(isOn: $preferences.discoveryRepeaterEnabled) { + TintedLabel(L10n.Settings.Notifications.discoveryRepeater, systemImage: "antenna.radiowaves.left.and.right") + } + .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) + Toggle(isOn: $preferences.discoveryRoomEnabled) { + TintedLabel(L10n.Settings.Notifications.discoveryRoom, systemImage: "door.left.hand.open") + } + .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) + } + } } - - private func refreshAuthorizationStatus() async { - let settings = await UNUserNotificationCenter.current().notificationSettings() - authorizationStatus = settings.authorizationStatus + .themedRowBackground(theme) + .task { + await refreshAuthorizationStatus() } - - private func requestAuthorization() async { - let granted = try? await UNUserNotificationCenter.current() - .requestAuthorization(options: [.alert, .sound, .badge]) - authorizationStatus = (granted == true) ? .authorized : .denied + .onChange(of: scenePhase) { + if scenePhase == .active { + Task { + await refreshAuthorizationStatus() + } + } } + } + + private func refreshAuthorizationStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + authorizationStatus = settings.authorizationStatus + } + + private func requestAuthorization() async { + let granted = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) + authorizationStatus = (granted == true) ? .authorized : .denied + } } diff --git a/MC1/Views/Settings/Sections/PathHashModeSection.swift b/MC1/Views/Settings/Sections/PathHashModeSection.swift index 889c85c4..89d63174 100644 --- a/MC1/Views/Settings/Sections/PathHashModeSection.swift +++ b/MC1/Views/Settings/Sections/PathHashModeSection.swift @@ -1,72 +1,72 @@ -import SwiftUI import MC1Services +import SwiftUI /// Picker for configuring the path hash size on firmware v10+ devices. struct PathHashModeSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var selectedMode: UInt8? - @State private var isApplying = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var selectedMode: UInt8? + @State private var isApplying = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() - var body: some View { - Section { - Picker(L10n.Settings.PathHashMode.label, selection: Binding( - get: { selectedMode ?? appState.connectedDevice?.pathHashMode ?? 0 }, - set: { newMode in - guard newMode != selectedMode else { return } - selectedMode = newMode - applyMode(newMode) - } - )) { - Text(L10n.Settings.PathHashMode.oneByte).tag(UInt8(0)) - Text(L10n.Settings.PathHashMode.twoBytes).tag(UInt8(1)) - Text(L10n.Settings.PathHashMode.threeBytes).tag(UInt8(2)) - } - .pickerStyle(.menu) - .tint(.primary) - .radioDisabled(for: appState.connectionState, or: isApplying) - } header: { - Text(L10n.Settings.PathHashMode.header) - } footer: { - Text(L10n.Settings.PathHashMode.footer) - } - .themedRowBackground(theme) - .onAppear { - selectedMode = appState.connectedDevice?.pathHashMode ?? 0 + var body: some View { + Section { + Picker(L10n.Settings.PathHashMode.label, selection: Binding( + get: { selectedMode ?? appState.connectedDevice?.pathHashMode ?? 0 }, + set: { newMode in + guard newMode != selectedMode else { return } + selectedMode = newMode + applyMode(newMode) } - .onChange(of: appState.connectedDevice?.pathHashMode) { _, newValue in - if let newValue { - selectedMode = newValue - } - } - .errorAlert($errorMessage) - .retryAlert(retryAlert) + )) { + Text(L10n.Settings.PathHashMode.oneByte).tag(UInt8(0)) + Text(L10n.Settings.PathHashMode.twoBytes).tag(UInt8(1)) + Text(L10n.Settings.PathHashMode.threeBytes).tag(UInt8(2)) + } + .pickerStyle(.menu) + .tint(.primary) + .radioDisabled(for: appState.connectionState, or: isApplying) + } header: { + Text(L10n.Settings.PathHashMode.header) + } footer: { + Text(L10n.Settings.PathHashMode.footer) + } + .themedRowBackground(theme) + .onAppear { + selectedMode = appState.connectedDevice?.pathHashMode ?? 0 + } + .onChange(of: appState.connectedDevice?.pathHashMode) { _, newValue in + if let newValue { + selectedMode = newValue + } } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } - private func applyMode(_ mode: UInt8) { - isApplying = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } - _ = try await settingsService.setPathHashModeVerified(mode) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - selectedMode = appState.connectedDevice?.pathHashMode ?? 0 - retryAlert.show( - message: error.userFacingMessage, - onRetry: { applyMode(mode) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - selectedMode = appState.connectedDevice?.pathHashMode ?? 0 - } - isApplying = false + private func applyMode(_ mode: UInt8) { + isApplying = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected } + _ = try await settingsService.setPathHashModeVerified(mode) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + selectedMode = appState.connectedDevice?.pathHashMode ?? 0 + retryAlert.show( + message: error.userFacingMessage, + onRetry: { applyMode(mode) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + selectedMode = appState.connectedDevice?.pathHashMode ?? 0 + } + isApplying = false } + } } diff --git a/MC1/Views/Settings/Sections/RadioPresetSection.swift b/MC1/Views/Settings/Sections/RadioPresetSection.swift index 160a32e6..11e48fb3 100644 --- a/MC1/Views/Settings/Sections/RadioPresetSection.swift +++ b/MC1/Views/Settings/Sections/RadioPresetSection.swift @@ -1,327 +1,335 @@ -import SwiftUI import MC1Services +import SwiftUI /// Radio preset selector with region-based filtering struct RadioPresetSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var selectedPresetID: String? - @State private var isApplying = false - @State private var errorMessage: String? - @State private var hasInitialized = false - @State private var retryAlert = RetryAlertState() - @State private var isRepeatEnabled: Bool = false - @State private var isApplyingRepeat = false - @State private var showRepeatConfirmation = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var selectedPresetID: String? + @State private var isApplying = false + @State private var errorMessage: String? + @State private var hasInitialized = false + @State private var retryAlert = RetryAlertState() + @State private var isRepeatEnabled: Bool = false + @State private var isApplyingRepeat = false + @State private var showRepeatConfirmation = false + @State private var isProgrammaticRepeatToggle = false - private var startupTaskID: String { - let deviceID = appState.connectedDevice?.id.uuidString ?? "none" - let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" - return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)" - } + private var startupTaskID: String { + let deviceID = appState.connectedDevice?.id.uuidString ?? "none" + let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" + return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)" + } - private var presets: [RadioPreset] { - let region = appState.regionSelection - let activeID = currentPreset?.id - return RadioPresets.presetsForLocale().filter { - RadioPresets.isSelectable($0, in: region) || $0.id == activeID - } + private var presets: [RadioPreset] { + let region = appState.regionSelection + let activeID = currentPreset?.id + return RadioPresets.presetsForLocale().filter { + RadioPresets.isSelectable($0, in: region) || $0.id == activeID } + } - private var repeatPresets: [RadioPreset] { - RadioPresets.repeatPresets - } + private var repeatPresets: [RadioPreset] { + RadioPresets.repeatPresets + } - private var currentPreset: RadioPreset? { - guard let device = appState.connectedDevice else { return nil } - return RadioPresets.matchingPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - } + private var currentPreset: RadioPreset? { + guard let device = appState.connectedDevice else { return nil } + return RadioPresets.matchingPreset( + frequencyKHz: device.frequency, + bandwidthKHz: device.bandwidth, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + } - /// Finds the repeat preset closest to the device's current frequency. - private var closestRepeatPreset: RadioPreset? { - guard let device = appState.connectedDevice else { return nil } - let deviceFreqKHz = device.frequency - return repeatPresets.min(by: { - abs(Int($0.frequencyKHz) - Int(deviceFreqKHz)) < abs(Int($1.frequencyKHz) - Int(deviceFreqKHz)) - }) - } + /// Finds the repeat preset closest to the device's current frequency. + private var closestRepeatPreset: RadioPreset? { + guard let device = appState.connectedDevice else { return nil } + return RadioPresets.nearestRepeatPreset(toFrequencyKHz: device.frequency) + } - /// Matches the device's current radio params against repeat presets. - private var currentRepeatPreset: RadioPreset? { - guard let device = appState.connectedDevice else { return nil } - return RadioPresets.matchingRepeatPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - } + /// Matches the device's current radio params against repeat presets. + private var currentRepeatPreset: RadioPreset? { + guard let device = appState.connectedDevice else { return nil } + return RadioPresets.matchingRepeatPreset(frequencyKHz: device.frequency) + } - private var mismatchHint: String? { - guard let region = appState.regionSelection, - let current = currentPreset else { return nil } - let regionPresets = RadioPresets.presets(for: region).map(\.id) - guard !regionPresets.contains(current.id) else { return nil } - return L10n.Settings.Radio.mismatchHint( - current.name, RegionalAreas.displayName(for: region) - ) - } + private var mismatchHint: String? { + guard let region = appState.regionSelection, + let current = currentPreset else { return nil } + let regionPresets = RadioPresets.presets(for: region).map(\.id) + guard !regionPresets.contains(current.id) else { return nil } + return L10n.Settings.Radio.mismatchHint( + current.name, RegionalAreas.displayName(for: region) + ) + } - private var currentMatchingPresetID: String? { - isRepeatEnabled ? currentRepeatPreset?.id : currentPreset?.id - } + private var currentMatchingPresetID: String? { + isRepeatEnabled ? currentRepeatPreset?.id : currentPreset?.id + } - var body: some View { - Section { - Picker(L10n.Settings.Radio.preset, selection: $selectedPresetID) { - if isRepeatEnabled { - Text(L10n.Settings.BatteryCurve.custom).tag(nil as String?) - ForEach(repeatPresets) { preset in - Section(preset.repeatSectionHeader ?? "") { - Text(preset.name).tag(preset.id as String?) - } - } - } else { - // Only show Custom when device is not using a preset - if currentPreset == nil { - Text(L10n.Settings.BatteryCurve.custom).tag(nil as String?) - } - - ForEach(RadioRegion.allCases, id: \.self) { region in - let regionPresets = presets.filter { $0.region == region } - if !regionPresets.isEmpty { - Section(region.rawValue) { - ForEach(regionPresets) { preset in - Text(preset.name).tag(preset.id as String?) - } - } - } - } - } - } - .onChange(of: selectedPresetID) { _, newValue in - // Skip the initial value set from onAppear - guard hasInitialized else { return } - // Apply if user selected a preset (newValue is non-nil) - guard let newID = newValue else { return } - applyPreset(id: newID) - } - .radioDisabled(for: appState.connectionState, or: isApplying || isApplyingRepeat) - - let detailPresets = isRepeatEnabled ? repeatPresets : presets - if let preset = detailPresets.first(where: { $0.id == selectedPresetID }) { - RadioParameterText( - frequencyMHz: preset.frequencyMHz, - bandwidthKHz: preset.bandwidthKHz, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate - ) - .foregroundStyle(.secondary) - } else if let device = appState.connectedDevice { - RadioParameterText( - frequencyMHz: Double(device.frequency) / 1000.0, - bandwidthKHz: Double(device.bandwidth) / 1000.0, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - .foregroundStyle(.secondary) + var body: some View { + Section { + Picker(L10n.Settings.Radio.preset, selection: $selectedPresetID) { + if isRepeatEnabled { + Text(L10n.Settings.BatteryCurve.custom).tag(nil as String?) + ForEach(repeatPresets) { preset in + Section(preset.repeatSectionHeader ?? "") { + Text(preset.name).tag(preset.id as String?) } + } + } else { + // Only show Custom when device is not using a preset + if currentPreset == nil { + Text(L10n.Settings.BatteryCurve.custom).tag(nil as String?) + } - if appState.connectedDevice?.supportsClientRepeat == true { - Toggle(isOn: $isRepeatEnabled) { - Text(L10n.Settings.Radio.repeatMode) - Text(L10n.Settings.Radio.RepeatMode.footer) - } - .accessibilityHint(L10n.Settings.Radio.RepeatMode.accessibilityHint) - .onChange(of: isRepeatEnabled) { _, newValue in - guard hasInitialized else { return } - if newValue { - hasInitialized = false - isRepeatEnabled = false - Task { @MainActor in - hasInitialized = true - } - showRepeatConfirmation = true - } else { - disableRepeatMode() - } - } - .disabled(isApplying || isApplyingRepeat) - } - } header: { - Text(L10n.Settings.Radio.header) - } footer: { - VStack(alignment: .leading, spacing: 6) { - Text(L10n.Settings.Radio.footer) - if let region = appState.regionSelection { - Text(L10n.Settings.Radio.regionFooter(RegionalAreas.displayName(for: region))) - } - if let mismatch = mismatchHint { - Text(mismatch) - .foregroundStyle(.orange) + ForEach(RadioRegion.allCases, id: \.self) { region in + let regionPresets = presets.filter { $0.region == region } + if !regionPresets.isEmpty { + Section(region.rawValue) { + ForEach(regionPresets) { preset in + Text(preset.name).tag(preset.id as String?) } + } } + } } - .themedRowBackground(theme) - .onAppear { - isRepeatEnabled = appState.connectedDevice?.clientRepeat ?? false - selectedPresetID = currentMatchingPresetID - // Mark as initialized after setting initial value - // Using task to defer to next run loop, after onChange processes - Task { @MainActor in - hasInitialized = true - } + } + .onChange(of: selectedPresetID) { _, newValue in + // Skip the initial value set from onAppear + guard hasInitialized else { return } + // Apply if user selected a preset (newValue is non-nil) + guard let newID = newValue else { return } + applyPreset(id: newID) + } + .radioDisabled(for: appState.connectionState, or: isApplying || isApplyingRepeat) + + let detailPresets = isRepeatEnabled ? repeatPresets : presets + if let preset = detailPresets.first(where: { $0.id == selectedPresetID }) { + // In Repeat Mode only the frequency is applied, so preview the device's kept bandwidth/SF/CR. + let device = isRepeatEnabled ? appState.connectedDevice : nil + RadioParameterText( + frequencyMHz: preset.frequencyMHz, + bandwidthKHz: device.map { Double($0.bandwidth) / 1000.0 } ?? preset.bandwidthKHz, + spreadingFactor: device?.spreadingFactor ?? preset.spreadingFactor, + codingRate: device?.codingRate ?? preset.codingRate + ) + .foregroundStyle(.secondary) + } else if let device = appState.connectedDevice { + RadioParameterText( + frequencyMHz: Double(device.frequency) / 1000.0, + bandwidthKHz: Double(device.bandwidth) / 1000.0, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + .foregroundStyle(.secondary) + } + + if appState.connectedDevice?.supportsClientRepeat == true { + Toggle(isOn: $isRepeatEnabled) { + Text(L10n.Settings.Radio.repeatMode) + Text(L10n.Settings.Radio.RepeatMode.footer) } - .task(id: startupTaskID) { - guard appState.canRunSettingsStartupReads, - let settingsService = appState.services?.settingsService else { return } - _ = try? await settingsService.getSelfInfo() + .accessibilityHint(L10n.Settings.Radio.RepeatMode.accessibilityHint) + .onChange(of: isRepeatEnabled) { _, newValue in + // A programmatic flip is consumed here so only a user's tap drives confirm/disable. + if isProgrammaticRepeatToggle { + isProgrammaticRepeatToggle = false + return + } + if newValue { + // Hold the toggle off until the user confirms; the revert is programmatic. + setRepeatToggle(false) + showRepeatConfirmation = true + } else { + disableRepeatMode() + } } - .onChange(of: currentPreset?.id) { _, newPresetID in - // Sync picker when device settings change externally (e.g., from Advanced Settings) - guard !isRepeatEnabled else { return } - hasInitialized = false - selectedPresetID = newPresetID - Task { @MainActor in - hasInitialized = true - } + .disabled(isApplying || isApplyingRepeat) + } + } header: { + Text(L10n.Settings.Radio.header) + } footer: { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.Settings.Radio.footer) + if let region = appState.regionSelection { + Text(L10n.Settings.Radio.regionFooter(RegionalAreas.displayName(for: region))) } - .onChange(of: currentRepeatPreset?.id) { _, newPresetID in - guard isRepeatEnabled else { return } - hasInitialized = false - selectedPresetID = newPresetID - Task { @MainActor in - hasInitialized = true - } + if let mismatch = mismatchHint { + Text(mismatch) + .foregroundStyle(.orange) } - .onChange(of: appState.connectedDevice?.clientRepeat) { _, newValue in - let newRepeatEnabled = newValue ?? false - if newRepeatEnabled != isRepeatEnabled { - hasInitialized = false - isRepeatEnabled = newRepeatEnabled - selectedPresetID = currentMatchingPresetID - Task { @MainActor in - hasInitialized = true - } - } - } - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .alert(L10n.Settings.Radio.RepeatMode.Confirm.title, isPresented: $showRepeatConfirmation) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.Radio.RepeatMode.Confirm.enable) { - enableRepeatMode() - } - } message: { - Text(L10n.Settings.Radio.RepeatMode.Confirm.message) + } + } + .themedRowBackground(theme) + .onAppear { + setRepeatToggle(appState.connectedDevice?.clientRepeat ?? false) + selectedPresetID = currentMatchingPresetID + // Mark as initialized after setting initial value + // Using task to defer to next run loop, after onChange processes + Task { @MainActor in + hasInitialized = true + } + } + .task(id: startupTaskID) { + guard appState.canRunSettingsStartupReads, + let settingsService = appState.services?.settingsService else { return } + _ = try? await settingsService.getSelfInfo() + } + .onChange(of: currentPreset?.id) { _, newPresetID in + // Sync picker when device settings change externally (e.g., from Advanced Settings) + guard !isRepeatEnabled else { return } + hasInitialized = false + selectedPresetID = newPresetID + Task { @MainActor in + hasInitialized = true + } + } + .onChange(of: currentRepeatPreset?.id) { _, newPresetID in + guard isRepeatEnabled else { return } + hasInitialized = false + selectedPresetID = newPresetID + Task { @MainActor in + hasInitialized = true + } + } + .onChange(of: appState.connectedDevice?.clientRepeat) { _, newValue in + let newRepeatEnabled = newValue ?? false + if newRepeatEnabled != isRepeatEnabled { + hasInitialized = false + setRepeatToggle(newRepeatEnabled) + selectedPresetID = currentMatchingPresetID + Task { @MainActor in + hasInitialized = true } + } + } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .alert(L10n.Settings.Radio.RepeatMode.Confirm.title, isPresented: $showRepeatConfirmation) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.Radio.RepeatMode.Confirm.enable) { + enableRepeatMode() + } + } message: { + Text(L10n.Settings.Radio.RepeatMode.Confirm.message) } + } - private func applyPreset(id: String) { - let allPresets = isRepeatEnabled ? repeatPresets : presets - guard let preset = allPresets.first(where: { $0.id == id }) else { return } + private func applyPreset(id: String) { + let allPresets = isRepeatEnabled ? repeatPresets : presets + guard let preset = allPresets.first(where: { $0.id == id }) else { return } - isApplying = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } - if isRepeatEnabled { - _ = try await settingsService.setRadioParamsVerified( - frequencyKHz: preset.frequencyKHz, - bandwidthKHz: preset.bandwidthHz, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate, - clientRepeat: true - ) - } else { - _ = try await settingsService.applyRadioPresetVerified(preset) - } - retryAlert.reset() - } catch { - selectedPresetID = currentMatchingPresetID - if let retryable = error as? SettingsServiceError, retryable.isRetryable { - retryAlert.show( - message: retryable.userFacingMessage, - onRetry: { applyPreset(id: id) }, - onMaxRetriesExceeded: { dismiss() } - ) - } else { - errorMessage = error.userFacingMessage - } - } - isApplying = false + isApplying = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected } + if isRepeatEnabled { + guard let device = appState.connectedDevice else { + throw ConnectionError.notConnected + } + // Repeat Mode changes only the frequency; bandwidth/SF/CR stay as the device's current values. + _ = try await settingsService.setRadioParamsVerified( + frequencyKHz: preset.frequencyKHz, + bandwidthKHz: device.bandwidth, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate, + clientRepeat: true + ) + } else { + _ = try await settingsService.applyRadioPresetVerified(preset) + } + retryAlert.reset() + } catch { + selectedPresetID = currentMatchingPresetID + if let retryable = error as? SettingsServiceError, retryable.isRetryable { + retryAlert.show( + message: retryable.userFacingMessage, + onRetry: { applyPreset(id: id) }, + onMaxRetriesExceeded: { dismiss() } + ) + } else { + errorMessage = error.userFacingMessage + } + } + isApplying = false } + } - private func enableRepeatMode() { - guard let preset = closestRepeatPreset else { return } + private func enableRepeatMode() { + guard let preset = closestRepeatPreset else { return } - // Persist current radio settings to Device model before switching - appState.connectionManager.savePreRepeatSettings() + // Persist current radio settings to Device model before switching + appState.connectionManager.savePreRepeatSettings() - // Swap picker to repeat presets and select closest frequency - hasInitialized = false - isRepeatEnabled = true - selectedPresetID = preset.id - Task { @MainActor in - hasInitialized = true - applyPreset(id: preset.id) - } + // Swap picker to repeat presets and select closest frequency + hasInitialized = false + setRepeatToggle(true) + selectedPresetID = preset.id + Task { @MainActor in + hasInitialized = true + applyPreset(id: preset.id) } + } - private func disableRepeatMode() { - guard let device = appState.connectedDevice else { return } - isApplyingRepeat = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } + /// Flips the Repeat Mode toggle programmatically. The toggle's onChange consumes the flag + /// so the confirm/disable handler runs only for a user's tap, never for our own writes. + private func setRepeatToggle(_ value: Bool) { + guard isRepeatEnabled != value else { return } + isProgrammaticRepeatToggle = true + isRepeatEnabled = value + } - // Restore saved radio settings, or fall back to current params - let freq = device.preRepeatFrequency ?? device.frequency - let bw = device.preRepeatBandwidth ?? device.bandwidth - let sf = device.preRepeatSpreadingFactor ?? device.spreadingFactor - let cr = device.preRepeatCodingRate ?? device.codingRate + private func disableRepeatMode() { + guard let device = appState.connectedDevice else { return } + isApplyingRepeat = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected + } - _ = try await settingsService.setRadioParamsVerified( - frequencyKHz: freq, - bandwidthKHz: bw, - spreadingFactor: sf, - codingRate: cr, - clientRepeat: false - ) + // Restore saved radio settings, or fall back to current params + let freq = device.preRepeatFrequency ?? device.frequency + let bw = device.preRepeatBandwidth ?? device.bandwidth + let sf = device.preRepeatSpreadingFactor ?? device.spreadingFactor + let cr = device.preRepeatCodingRate ?? device.codingRate - // Clear persisted pre-repeat settings - appState.connectionManager.clearPreRepeatSettings() + _ = try await settingsService.setRadioParamsVerified( + frequencyKHz: freq, + bandwidthKHz: bw, + spreadingFactor: sf, + codingRate: cr, + clientRepeat: false + ) - // Swap picker back to normal presets - hasInitialized = false - selectedPresetID = currentPreset?.id - Task { @MainActor in - hasInitialized = true - } - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - isRepeatEnabled = true // Revert - retryAlert.show( - message: error.userFacingMessage, - onRetry: { disableRepeatMode() }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - isRepeatEnabled = true // Revert - } - isApplyingRepeat = false + // Clear persisted pre-repeat settings + appState.connectionManager.clearPreRepeatSettings() + + // Swap picker back to normal presets + hasInitialized = false + selectedPresetID = currentPreset?.id + Task { @MainActor in + hasInitialized = true } + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + setRepeatToggle(true) // Revert + retryAlert.show( + message: error.userFacingMessage, + onRetry: { disableRepeatMode() }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + setRepeatToggle(true) // Revert + } + isApplyingRepeat = false } + } } diff --git a/MC1/Views/Settings/Sections/RegenerateIdentitySheet.swift b/MC1/Views/Settings/Sections/RegenerateIdentitySheet.swift index 8f8a57eb..bf809288 100644 --- a/MC1/Views/Settings/Sections/RegenerateIdentitySheet.swift +++ b/MC1/Views/Settings/Sections/RegenerateIdentitySheet.swift @@ -3,158 +3,158 @@ import SwiftUI /// Sheet for generating a new Ed25519 identity and importing it to the device struct RegenerateIdentitySheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var viewModel = RegenerateIdentityViewModel() + @State private var viewModel = RegenerateIdentityViewModel() - var body: some View { - NavigationStack { - Form { - explanationSection - .themedRowBackground(theme) - prefixSection - .themedRowBackground(theme) - generateSection - .themedRowBackground(theme) - if let generatedKey = viewModel.generatedKey { - keyPreviewSection(generatedKey) - .themedRowBackground(theme) - replaceSection - .themedRowBackground(theme) - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.RegenerateIdentity.Sheet.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - dismiss() - } - .disabled(viewModel.isBusy) - } - } - .interactiveDismissDisabled(viewModel.isBusy) - .alert( - L10n.Settings.RegenerateIdentity.Alert.Replace.title, - isPresented: $viewModel.showingReplaceAlert - ) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.RegenerateIdentity.Alert.Replace.confirm, role: .destructive) { - Task { - if await viewModel.replaceIdentity() { - dismiss() - } - } - } - } message: { - Text(L10n.Settings.RegenerateIdentity.Alert.Replace.message) - } - .errorAlert($viewModel.errorMessage) - .sensoryFeedback(.success, trigger: viewModel.successTrigger) - .task { - viewModel.configure(settingsService: { appState.services?.settingsService }) - } + var body: some View { + NavigationStack { + Form { + explanationSection + .themedRowBackground(theme) + prefixSection + .themedRowBackground(theme) + generateSection + .themedRowBackground(theme) + if let generatedKey = viewModel.generatedKey { + keyPreviewSection(generatedKey) + .themedRowBackground(theme) + replaceSection + .themedRowBackground(theme) } - .onDisappear { - viewModel.cancelGeneration() + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.RegenerateIdentity.Sheet.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + dismiss() + } + .disabled(viewModel.isBusy) } + } + .interactiveDismissDisabled(viewModel.isBusy) + .alert( + L10n.Settings.RegenerateIdentity.Alert.Replace.title, + isPresented: $viewModel.showingReplaceAlert + ) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.RegenerateIdentity.Alert.Replace.confirm, role: .destructive) { + Task { + if await viewModel.replaceIdentity() { + dismiss() + } + } + } + } message: { + Text(L10n.Settings.RegenerateIdentity.Alert.Replace.message) + } + .errorAlert($viewModel.errorMessage) + .sensoryFeedback(.success, trigger: viewModel.successTrigger) + .task { + viewModel.configure(settingsService: { appState.services?.settingsService }) + } + } + .onDisappear { + viewModel.cancelGeneration() } + } - // MARK: - Sections + // MARK: - Sections - private var explanationSection: some View { - Section { - Text(L10n.Settings.RegenerateIdentity.Sheet.explanation) - .foregroundStyle(.secondary) - } + private var explanationSection: some View { + Section { + Text(L10n.Settings.RegenerateIdentity.Sheet.explanation) + .foregroundStyle(.secondary) } + } - private var prefixSection: some View { - Section { - DisclosureGroup(L10n.Settings.RegenerateIdentity.Prefix.label) { - TextField(L10n.Settings.RegenerateIdentity.Prefix.placeholder, text: $viewModel.hexPrefix) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) - .onChange(of: viewModel.hexPrefix) { _, newValue in - viewModel.sanitizePrefix(newValue) - } + private var prefixSection: some View { + Section { + DisclosureGroup(L10n.Settings.RegenerateIdentity.Prefix.label) { + TextField(L10n.Settings.RegenerateIdentity.Prefix.placeholder, text: $viewModel.hexPrefix) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .onChange(of: viewModel.hexPrefix) { _, newValue in + viewModel.sanitizePrefix(newValue) + } - if let prefixError = viewModel.prefixError { - Label(prefixError, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - .font(.footnote) - .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) - } - } - } footer: { - Text(L10n.Settings.RegenerateIdentity.Prefix.footer) + if let prefixError = viewModel.prefixError { + Label(prefixError, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + .font(.footnote) + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) } + } + } footer: { + Text(L10n.Settings.RegenerateIdentity.Prefix.footer) } + } - private var generateSection: some View { - Section { - Button { - viewModel.generateKey() - } label: { - HStack { - Spacer() - if viewModel.isGenerating { - ProgressView() - .controlSize(.small) - .accessibilityLabel(L10n.Settings.RegenerateIdentity.generating) - Text(L10n.Settings.RegenerateIdentity.generating) - } else { - Text(L10n.Settings.RegenerateIdentity.generate) - } - Spacer() - } - } - .disabled(viewModel.isBusy) + private var generateSection: some View { + Section { + Button { + viewModel.generateKey() + } label: { + HStack { + Spacer() + if viewModel.isGenerating { + ProgressView() + .controlSize(.small) + .accessibilityLabel(L10n.Settings.RegenerateIdentity.generating) + Text(L10n.Settings.RegenerateIdentity.generating) + } else { + Text(L10n.Settings.RegenerateIdentity.generate) + } + Spacer() } + } + .disabled(viewModel.isBusy) } + } - private func keyPreviewSection(_ key: RegenerateIdentityViewModel.GeneratedKey) -> some View { - Group { - Section { - Text(key.publicKeyHex) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .accessibilityLabel(key.accessibilityLabel) - } header: { - Text(L10n.Settings.PublicKey.header) - } - Section { - Text(key.privateKeyHex) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } header: { - Text(L10n.Settings.PrivateKey.header) - } - } + private func keyPreviewSection(_ key: RegenerateIdentityViewModel.GeneratedKey) -> some View { + Group { + Section { + Text(key.publicKeyHex) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .accessibilityLabel(key.accessibilityLabel) + } header: { + Text(L10n.Settings.PublicKey.header) + } + Section { + Text(key.privateKeyHex) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } header: { + Text(L10n.Settings.PrivateKey.header) + } } + } - private var replaceSection: some View { - Section { - Button { - viewModel.showingReplaceAlert = true - } label: { - HStack { - Spacer() - if viewModel.isImporting { - ProgressView() - .controlSize(.small) - Text(L10n.Settings.RegenerateIdentity.importing) - } else { - Text(L10n.Settings.RegenerateIdentity.replace) - } - Spacer() - } - } - .disabled(viewModel.isBusy) + private var replaceSection: some View { + Section { + Button { + viewModel.showingReplaceAlert = true + } label: { + HStack { + Spacer() + if viewModel.isImporting { + ProgressView() + .controlSize(.small) + Text(L10n.Settings.RegenerateIdentity.importing) + } else { + Text(L10n.Settings.RegenerateIdentity.replace) + } + Spacer() } + } + .disabled(viewModel.isBusy) } + } } diff --git a/MC1/Views/Settings/Sections/RegenerateIdentityViewModel.swift b/MC1/Views/Settings/Sections/RegenerateIdentityViewModel.swift index df95e790..31c80cd2 100644 --- a/MC1/Views/Settings/Sections/RegenerateIdentityViewModel.swift +++ b/MC1/Views/Settings/Sections/RegenerateIdentityViewModel.swift @@ -1,122 +1,126 @@ -import SwiftUI import MC1Services +import SwiftUI @Observable @MainActor final class RegenerateIdentityViewModel { - var hexPrefix = "" - var isGenerating = false - var isImporting = false - var generatedKey: GeneratedKey? - var showingReplaceAlert = false - var errorMessage: String? - var prefixError: String? - var successTrigger = 0 + var hexPrefix = "" + var isGenerating = false + var isImporting = false + var generatedKey: GeneratedKey? + var showingReplaceAlert = false + var errorMessage: String? + var prefixError: String? + var successTrigger = 0 - private var generateTask: Task? + private var generateTask: Task? - var isBusy: Bool { isGenerating || isImporting } + var isBusy: Bool { + isGenerating || isImporting + } - /// Maximum vanity-prefix length in hex digits (two key bytes). - private static let maxPrefixLength = 4 - /// Key prefixes the firmware reserves for special addressing. - private static let reservedPrefixes = ["00", "FF"] + /// Maximum vanity-prefix length in hex digits (two key bytes). + private static let maxPrefixLength = 4 + /// Key prefixes the firmware reserves for special addressing. + private static let reservedPrefixes = ["00", "FF"] - struct GeneratedKey { - let expandedKey: Data - let publicKeyHex: String - let privateKeyHex: String - let accessibilityLabel: String - } + struct GeneratedKey { + let expandedKey: Data + let publicKeyHex: String + let privateKeyHex: String + let accessibilityLabel: String + } - func cancelGeneration() { - generateTask?.cancel() - } + func cancelGeneration() { + generateTask?.cancel() + } - /// Restricts the vanity prefix to hex digits and clears stale validation feedback. - func sanitizePrefix(_ newValue: String) { - let filtered = String( - newValue.uppercased() - .filter { $0.isASCII && $0.isHexDigit } - .prefix(Self.maxPrefixLength) - ) - if filtered != newValue { - hexPrefix = filtered - } - prefixError = nil + /// Restricts the vanity prefix to hex digits and clears stale validation feedback. + func sanitizePrefix(_ newValue: String) { + let filtered = String( + newValue.uppercased() + .filter { $0.isASCII && $0.isHexDigit } + .prefix(Self.maxPrefixLength) + ) + if filtered != newValue { + hexPrefix = filtered } + prefixError = nil + } - func generateKey() { - prefixError = nil + func generateKey() { + prefixError = nil - // Validate prefix - let upper = hexPrefix.uppercased() - if upper.count >= 2, Self.reservedPrefixes.contains(where: upper.hasPrefix) { - prefixError = L10n.Settings.RegenerateIdentity.Prefix.Error.reserved - return - } + // Validate prefix + let upper = hexPrefix.uppercased() + if upper.count >= 2, Self.reservedPrefixes.contains(where: upper.hasPrefix) { + prefixError = L10n.Settings.RegenerateIdentity.Prefix.Error.reserved + return + } - isGenerating = true - generateTask = Task { - defer { isGenerating = false } - do { - let result = try await KeyGenerationService.generateIdentity( - hexPrefix: upper.isEmpty ? nil : upper - ) - withAnimation { - generatedKey = GeneratedKey( - expandedKey: result.expandedPrivateKey, - publicKeyHex: result.publicKey.uppercaseHexString(separator: " "), - privateKeyHex: result.expandedPrivateKey.uppercaseHexString(separator: " "), - accessibilityLabel: result.publicKey - .map { String(format: "%02X", $0) } - .joined(separator: ", ") - ) - } - } catch is CancellationError { - // Sheet dismissed during generation - } catch { - errorMessage = error.userFacingMessage - } + isGenerating = true + generateTask = Task { + defer { isGenerating = false } + do { + let result = try await KeyGenerationService.generateIdentity( + hexPrefix: upper.isEmpty ? nil : upper + ) + withAnimation { + generatedKey = GeneratedKey( + expandedKey: result.expandedPrivateKey, + publicKeyHex: result.publicKey.uppercaseHexString(separator: " "), + privateKeyHex: result.expandedPrivateKey.uppercaseHexString(separator: " "), + accessibilityLabel: result.publicKey + .map { String(format: "%02X", $0) } + .joined(separator: ", ") + ) } + } catch is CancellationError { + // Sheet dismissed during generation + } catch { + errorMessage = error.userFacingMessage + } } + } - // MARK: - Dependencies + // MARK: - Dependencies - private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } - var settingsService: SettingsService? { settingsServiceProvider() } + private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } + var settingsService: SettingsService? { + settingsServiceProvider() + } - func configure(settingsService: @escaping @MainActor () -> SettingsService?) { - self.settingsServiceProvider = settingsService - } + func configure(settingsService: @escaping @MainActor () -> SettingsService?) { + settingsServiceProvider = settingsService + } - /// Returns true when the new identity was imported and the sheet should dismiss. - /// A nil service mirrors a disconnected state and is a no-op. - func replaceIdentity() async -> Bool { - guard let expandedKey = generatedKey?.expandedKey, - let settingsService = settingsService else { return false } + /// Returns true when the new identity was imported and the sheet should dismiss. + /// A nil service mirrors a disconnected state and is a no-op. + func replaceIdentity() async -> Bool { + guard let expandedKey = generatedKey?.expandedKey, + let settingsService else { return false } - isImporting = true - defer { isImporting = false } - do { - try await settingsService.importPrivateKey(expandedKey) - try await settingsService.refreshDeviceInfo() - successTrigger += 1 - return true - } catch let error as SettingsServiceError { - if case .sessionError(let meshError) = error, - case .featureDisabled = meshError { - errorMessage = L10n.Settings.RegenerateIdentity.Error.featureDisabled - } else if case .sessionError(let meshError) = error, - case .deviceError = meshError { - errorMessage = L10n.Settings.RegenerateIdentity.Error.deviceRejected - } else { - errorMessage = error.userFacingMessage - } - return false - } catch { - errorMessage = error.userFacingMessage - return false - } + isImporting = true + defer { isImporting = false } + do { + try await settingsService.importPrivateKey(expandedKey) + try await settingsService.refreshDeviceInfo() + successTrigger += 1 + return true + } catch let error as SettingsServiceError { + if case let .sessionError(meshError) = error, + case .featureDisabled = meshError { + errorMessage = L10n.Settings.RegenerateIdentity.Error.featureDisabled + } else if case let .sessionError(meshError) = error, + case .deviceError = meshError { + errorMessage = L10n.Settings.RegenerateIdentity.Error.deviceRejected + } else { + errorMessage = error.userFacingMessage + } + return false + } catch { + errorMessage = error.userFacingMessage + return false } + } } diff --git a/MC1/Views/Settings/Sections/RetryAlertModifier.swift b/MC1/Views/Settings/Sections/RetryAlertModifier.swift index 44e00ef6..71db9f23 100644 --- a/MC1/Views/Settings/Sections/RetryAlertModifier.swift +++ b/MC1/Views/Settings/Sections/RetryAlertModifier.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Maximum number of retries before showing final error private let maxRetries = 3 @@ -8,71 +8,71 @@ private let maxRetries = 3 @Observable @MainActor final class RetryAlertState { - var isPresented: Bool = false - var message: String = "" - var retryCount: Int = 0 - var onRetry: (() -> Void)? - var onMaxRetriesExceeded: (() -> Void)? + var isPresented: Bool = false + var message: String = "" + var retryCount: Int = 0 + var onRetry: (() -> Void)? + var onMaxRetriesExceeded: (() -> Void)? - /// Show a retry alert, incrementing the retry count - func show(message: String, onRetry: @escaping () -> Void, onMaxRetriesExceeded: @escaping () -> Void) { - self.retryCount += 1 - self.message = message - self.onRetry = onRetry - self.onMaxRetriesExceeded = onMaxRetriesExceeded - self.isPresented = true - } + /// Show a retry alert, incrementing the retry count + func show(message: String, onRetry: @escaping () -> Void, onMaxRetriesExceeded: @escaping () -> Void) { + retryCount += 1 + self.message = message + self.onRetry = onRetry + self.onMaxRetriesExceeded = onMaxRetriesExceeded + isPresented = true + } - /// Reset retry count (call when operation succeeds or user cancels) - func reset() { - retryCount = 0 - onRetry = nil - onMaxRetriesExceeded = nil - } + /// Reset retry count (call when operation succeeds or user cancels) + func reset() { + retryCount = 0 + onRetry = nil + onMaxRetriesExceeded = nil + } - /// Whether max retries have been exceeded - var isMaxRetriesExceeded: Bool { - retryCount >= maxRetries - } + /// Whether max retries have been exceeded + var isMaxRetriesExceeded: Bool { + retryCount >= maxRetries + } } /// ViewModifier for presenting retry alerts with retry counting struct RetryAlertModifier: ViewModifier { - @Bindable var state: RetryAlertState + @Bindable var state: RetryAlertState - func body(content: Content) -> some View { - content - .alert( - state.isMaxRetriesExceeded - ? L10n.Settings.Alert.Retry.unableToSave - : L10n.Settings.Alert.Retry.connectionError, - isPresented: $state.isPresented - ) { - if state.isMaxRetriesExceeded { - Button(L10n.Localizable.Common.ok) { - state.onMaxRetriesExceeded?() - state.reset() - } - } else { - Button(L10n.Settings.Alert.Retry.retry) { - state.onRetry?() - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { - state.reset() - } - } - } message: { - if state.isMaxRetriesExceeded { - Text(L10n.Settings.Alert.Retry.ensureConnected) - } else { - Text(state.message) - } - } - } + func body(content: Content) -> some View { + content + .alert( + state.isMaxRetriesExceeded + ? L10n.Settings.Alert.Retry.unableToSave + : L10n.Settings.Alert.Retry.connectionError, + isPresented: $state.isPresented + ) { + if state.isMaxRetriesExceeded { + Button(L10n.Localizable.Common.ok) { + state.onMaxRetriesExceeded?() + state.reset() + } + } else { + Button(L10n.Settings.Alert.Retry.retry) { + state.onRetry?() + } + Button(L10n.Localizable.Common.cancel, role: .cancel) { + state.reset() + } + } + } message: { + if state.isMaxRetriesExceeded { + Text(L10n.Settings.Alert.Retry.ensureConnected) + } else { + Text(state.message) + } + } + } } extension View { - func retryAlert(_ state: RetryAlertState) -> some View { - modifier(RetryAlertModifier(state: state)) - } + func retryAlert(_ state: RetryAlertState) -> some View { + modifier(RetryAlertModifier(state: state)) + } } diff --git a/MC1/Views/Settings/Sections/StaleNodeCleanupSection.swift b/MC1/Views/Settings/Sections/StaleNodeCleanupSection.swift index 67d5035e..809ef9d6 100644 --- a/MC1/Views/Settings/Sections/StaleNodeCleanupSection.swift +++ b/MC1/Views/Settings/Sections/StaleNodeCleanupSection.swift @@ -3,69 +3,68 @@ import SwiftUI /// Settings section for automatic cleanup of stale non-favorite nodes struct StaleNodeCleanupSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.autoDeleteStaleNodesDays.rawValue) - private var threshold: Int = AppStorageKey.defaultAutoDeleteStaleNodesDays - @AppStorage(AppStorageKey.lastStaleCleanupDate.rawValue) - private var lastCleanupTimestamp: Double = AppStorageKey.defaultLastStaleCleanupDate + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @AppStorage(AppStorageKey.autoDeleteStaleNodesDays.rawValue) + private var threshold: Int = AppStorageKey.defaultAutoDeleteStaleNodesDays + @AppStorage(AppStorageKey.lastStaleCleanupDate.rawValue) + private var lastCleanupTimestamp: Double = AppStorageKey.defaultLastStaleCleanupDate - @State private var isEnabled = false + @State private var isEnabled = false - var body: some View { - Section { - Toggle(L10n.Settings.Nodes.StaleCleanup.header, isOn: $isEnabled) + var body: some View { + Section { + Toggle(L10n.Settings.Nodes.StaleCleanup.header, isOn: $isEnabled) - if isEnabled { - Picker(L10n.Settings.Nodes.StaleCleanup.threshold, selection: $threshold) { - Text(L10n.Settings.Nodes.StaleCleanup.select).tag(0) - Text(L10n.Settings.Nodes.StaleCleanup.days(7)).tag(7) - Text(L10n.Settings.Nodes.StaleCleanup.days(14)).tag(14) - Text(L10n.Settings.Nodes.StaleCleanup.days(30)).tag(30) - Text(L10n.Settings.Nodes.StaleCleanup.days(90)).tag(90) - } - .pickerStyle(.menu) - - if lastCleanupTimestamp > 0, threshold > 0 { - Text(L10n.Settings.Nodes.StaleCleanup.lastRun( - Date(timeIntervalSinceReferenceDate: lastCleanupTimestamp) - .formatted(.relative(presentation: .named)) - )) - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - } footer: { - if !isEnabled { - Text(L10n.Settings.Nodes.StaleCleanup.footerDisabled) - } else if threshold == 0 { - Text(L10n.Settings.Nodes.StaleCleanup.footerSelect) - } else if appState.connectionState == .ready { - Text(L10n.Settings.Nodes.StaleCleanup.footerEnabled(threshold)) - } else { - Text(L10n.Settings.Nodes.StaleCleanup.footerDisconnected) - } - } - .themedRowBackground(theme) - .radioDisabled(for: appState.connectionState) - .onAppear { - isEnabled = threshold > 0 - } - .onChange(of: isEnabled) { _, newValue in - if !newValue { - threshold = 0 - } + if isEnabled { + Picker(L10n.Settings.Nodes.StaleCleanup.threshold, selection: $threshold) { + Text(L10n.Settings.Nodes.StaleCleanup.select).tag(0) + Text(L10n.Settings.Nodes.StaleCleanup.days(7)).tag(7) + Text(L10n.Settings.Nodes.StaleCleanup.days(14)).tag(14) + Text(L10n.Settings.Nodes.StaleCleanup.days(30)).tag(30) + Text(L10n.Settings.Nodes.StaleCleanup.days(90)).tag(90) } - .onChange(of: threshold) { _, newThreshold in - guard newThreshold > 0, appState.connectionState == .ready else { return } - appState.performStaleNodeCleanup(force: true) + .pickerStyle(.menu) + + if lastCleanupTimestamp > 0, threshold > 0 { + Text(L10n.Settings.Nodes.StaleCleanup.lastRun( + Date(timeIntervalSinceReferenceDate: lastCleanupTimestamp) + .formatted(.relative(presentation: .named)) + )) + .foregroundStyle(.secondary) + .font(.subheadline) } + } + } footer: { + if !isEnabled { + Text(L10n.Settings.Nodes.StaleCleanup.footerDisabled) + } else if threshold == 0 { + Text(L10n.Settings.Nodes.StaleCleanup.footerSelect) + } else if appState.connectionState == .ready { + Text(L10n.Settings.Nodes.StaleCleanup.footerEnabled(threshold)) + } else { + Text(L10n.Settings.Nodes.StaleCleanup.footerDisconnected) + } } - + .themedRowBackground(theme) + .radioDisabled(for: appState.connectionState) + .onAppear { + isEnabled = threshold > 0 + } + .onChange(of: isEnabled) { _, newValue in + if !newValue { + threshold = 0 + } + } + .onChange(of: threshold) { _, newThreshold in + guard newThreshold > 0, appState.connectionState == .ready else { return } + appState.performStaleNodeCleanup(force: true) + } + } } #Preview { - Form { - StaleNodeCleanupSection() - } + Form { + StaleNodeCleanupSection() + } } diff --git a/MC1/Views/Settings/Sections/TelemetrySettingsSection.swift b/MC1/Views/Settings/Sections/TelemetrySettingsSection.swift index eb5a2f18..0e7e3945 100644 --- a/MC1/Views/Settings/Sections/TelemetrySettingsSection.swift +++ b/MC1/Views/Settings/Sections/TelemetrySettingsSection.swift @@ -1,158 +1,160 @@ -import SwiftUI import MC1Services +import SwiftUI /// Firmware telemetry permission levels, shared by the base, location, and environment modes. private enum TelemetryMode { - static let off: UInt8 = 0 - static let trustedOnly: UInt8 = 1 - static let everyone: UInt8 = 2 + static let off: UInt8 = 0 + static let trustedOnly: UInt8 = 1 + static let everyone: UInt8 = 2 } /// Telemetry sharing configuration struct TelemetrySettingsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isSaving = false - - private var device: DeviceDTO? { appState.connectedDevice } - - var body: some View { - Section { - Toggle(isOn: telemetryEnabledBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Telemetry.allowRequests) - Text(L10n.Settings.Telemetry.allowRequestsDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - if device?.telemetryModeBase ?? TelemetryMode.off > TelemetryMode.off { - Toggle(isOn: locationEnabledBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Telemetry.includeLocation) - Text(L10n.Settings.Telemetry.includeLocationDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - Toggle(isOn: environmentEnabledBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Telemetry.includeEnvironment) - Text(L10n.Settings.Telemetry.includeEnvironmentDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - Toggle(isOn: filterByTrustedBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Telemetry.trustedOnly) - Text(L10n.Settings.Telemetry.trustedOnlyDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - if isFilterByTrusted { - NavigationLink(value: SettingsSubpage.trustedContacts) { - Text(L10n.Settings.Telemetry.manageTrusted) - } - } - } - } header: { - Text(L10n.Settings.Telemetry.header) - } footer: { - Text(L10n.Settings.Telemetry.footer) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isSaving = false + + private var device: DeviceDTO? { + appState.connectedDevice + } + + var body: some View { + Section { + Toggle(isOn: telemetryEnabledBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Telemetry.allowRequests) + Text(L10n.Settings.Telemetry.allowRequestsDescription) + .font(.caption) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) - .errorAlert($errorMessage) - .retryAlert(retryAlert) - } - - // MARK: - Bindings - - private var isFilterByTrusted: Bool { - device?.telemetryModeBase == TelemetryMode.trustedOnly - } - - /// Mode value for "enabled" telemetry: trusted-only if trusted filtering is active, everyone otherwise - private var enabledMode: UInt8 { - isFilterByTrusted ? TelemetryMode.trustedOnly : TelemetryMode.everyone - } + } + .radioDisabled(for: appState.connectionState, or: isSaving) + + if device?.telemetryModeBase ?? TelemetryMode.off > TelemetryMode.off { + Toggle(isOn: locationEnabledBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Telemetry.includeLocation) + Text(L10n.Settings.Telemetry.includeLocationDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .radioDisabled(for: appState.connectionState, or: isSaving) + + Toggle(isOn: environmentEnabledBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Telemetry.includeEnvironment) + Text(L10n.Settings.Telemetry.includeEnvironmentDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .radioDisabled(for: appState.connectionState, or: isSaving) + + Toggle(isOn: filterByTrustedBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Telemetry.trustedOnly) + Text(L10n.Settings.Telemetry.trustedOnlyDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .radioDisabled(for: appState.connectionState, or: isSaving) - private var telemetryEnabledBinding: Binding { - Binding( - get: { device?.telemetryModeBase ?? TelemetryMode.off > TelemetryMode.off }, - set: { saveTelemetry(base: $0 ? enabledMode : TelemetryMode.off) } - ) + if isFilterByTrusted { + NavigationLink(value: SettingsSubpage.trustedContacts) { + Text(L10n.Settings.Telemetry.manageTrusted) + } + } + } + } header: { + Text(L10n.Settings.Telemetry.header) + } footer: { + Text(L10n.Settings.Telemetry.footer) } - - private var locationEnabledBinding: Binding { - Binding( - get: { device?.telemetryModeLoc ?? TelemetryMode.off > TelemetryMode.off }, - set: { saveTelemetry(location: $0 ? enabledMode : TelemetryMode.off) } + .themedRowBackground(theme) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } + + // MARK: - Bindings + + private var isFilterByTrusted: Bool { + device?.telemetryModeBase == TelemetryMode.trustedOnly + } + + /// Mode value for "enabled" telemetry: trusted-only if trusted filtering is active, everyone otherwise + private var enabledMode: UInt8 { + isFilterByTrusted ? TelemetryMode.trustedOnly : TelemetryMode.everyone + } + + private var telemetryEnabledBinding: Binding { + Binding( + get: { device?.telemetryModeBase ?? TelemetryMode.off > TelemetryMode.off }, + set: { saveTelemetry(base: $0 ? enabledMode : TelemetryMode.off) } + ) + } + + private var locationEnabledBinding: Binding { + Binding( + get: { device?.telemetryModeLoc ?? TelemetryMode.off > TelemetryMode.off }, + set: { saveTelemetry(location: $0 ? enabledMode : TelemetryMode.off) } + ) + } + + private var environmentEnabledBinding: Binding { + Binding( + get: { device?.telemetryModeEnv ?? TelemetryMode.off > TelemetryMode.off }, + set: { saveTelemetry(environment: $0 ? enabledMode : TelemetryMode.off) } + ) + } + + private var filterByTrustedBinding: Binding { + Binding( + get: { device?.telemetryModeBase == TelemetryMode.trustedOnly }, + set: { newValue in + let mode: UInt8 = newValue ? TelemetryMode.trustedOnly : TelemetryMode.everyone + saveTelemetry( + base: (device?.telemetryModeBase ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off, + location: (device?.telemetryModeLoc ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off, + environment: (device?.telemetryModeEnv ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off ) - } - - private var environmentEnabledBinding: Binding { - Binding( - get: { device?.telemetryModeEnv ?? TelemetryMode.off > TelemetryMode.off }, - set: { saveTelemetry(environment: $0 ? enabledMode : TelemetryMode.off) } + } + ) + } + + // MARK: - Save + + private func saveTelemetry( + base: UInt8? = nil, + location: UInt8? = nil, + environment: UInt8? = nil + ) { + guard let device, let settingsService = appState.services?.settingsService else { return } + + isSaving = true + Task { + do { + let modes = TelemetryModes( + base: base ?? device.telemetryModeBase, + location: location ?? device.telemetryModeLoc, + environment: environment ?? device.telemetryModeEnv ) - } - - private var filterByTrustedBinding: Binding { - Binding( - get: { device?.telemetryModeBase == TelemetryMode.trustedOnly }, - set: { newValue in - let mode: UInt8 = newValue ? TelemetryMode.trustedOnly : TelemetryMode.everyone - saveTelemetry( - base: (device?.telemetryModeBase ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off, - location: (device?.telemetryModeLoc ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off, - environment: (device?.telemetryModeEnv ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off - ) - } + _ = try await settingsService.setOtherParamsVerified(from: device, telemetryModes: modes) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { saveTelemetry(base: base, location: location, environment: environment) }, + onMaxRetriesExceeded: { dismiss() } ) + } catch { + errorMessage = error.userFacingMessage + } + isSaving = false } - - // MARK: - Save - - private func saveTelemetry( - base: UInt8? = nil, - location: UInt8? = nil, - environment: UInt8? = nil - ) { - guard let device, let settingsService = appState.services?.settingsService else { return } - - isSaving = true - Task { - do { - let modes = TelemetryModes( - base: base ?? device.telemetryModeBase, - location: location ?? device.telemetryModeLoc, - environment: environment ?? device.telemetryModeEnv - ) - _ = try await settingsService.setOtherParamsVerified(from: device, telemetryModes: modes) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { saveTelemetry(base: base, location: location, environment: environment) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isSaving = false - } - } + } } diff --git a/MC1/Views/Settings/Sections/WiFiEditSheet.swift b/MC1/Views/Settings/Sections/WiFiEditSheet.swift index 3b5cb4bf..e6453faf 100644 --- a/MC1/Views/Settings/Sections/WiFiEditSheet.swift +++ b/MC1/Views/Settings/Sections/WiFiEditSheet.swift @@ -1,137 +1,136 @@ -import SwiftUI import MC1Services +import SwiftUI /// Sheet for editing WiFi connection parameters. /// Pre-populates with current connection details and allows updating them. struct WiFiEditSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - /// Optional initial values for editing a saved (non-connected) device - var initialHost: String? - var initialPort: UInt16? - - @State private var ipAddress = "" - @State private var port = "5000" - @State private var isReconnecting = false - @State private var errorMessage: String? - - @FocusState private var focusedField: WiFiField? - - private var currentConnection: ConnectionMethod? { - appState.connectedDevice?.connectionMethods.first { $0.isWiFi } - } - - private var originalHost: String? { - if let initialHost { return initialHost } - if case .wifi(let host, _, _) = currentConnection { return host } - return nil - } - - private var originalPort: UInt16? { - if let initialPort { return initialPort } - if case .wifi(_, let port, _) = currentConnection { return port } - return nil - } - - private var isValidInput: Bool { - WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) - } - - private var hasChanges: Bool { - guard let host = originalHost, let currentPort = originalPort else { return true } - return ipAddress != host || port != String(currentPort) - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + /// Optional initial values for editing a saved (non-connected) device + var initialHost: String? + var initialPort: UInt16? + + @State private var ipAddress = "" + @State private var port = "5000" + @State private var isReconnecting = false + @State private var errorMessage: String? + + @FocusState private var focusedField: WiFiField? + + private var currentConnection: ConnectionMethod? { + appState.connectedDevice?.connectionMethods.first { $0.isWiFi } + } + + private var originalHost: String? { + if let initialHost { return initialHost } + if case let .wifi(host, _, _) = currentConnection { return host } + return nil + } + + private var originalPort: UInt16? { + if let initialPort { return initialPort } + if case let .wifi(_, port, _) = currentConnection { return port } + return nil + } + + private var isValidInput: Bool { + WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) + } + + private var hasChanges: Bool { + guard let host = originalHost, let currentPort = originalPort else { return true } + return ipAddress != host || port != String(currentPort) + } + + var body: some View { + NavigationStack { + Form { + WiFiAddressFields( + ipAddress: $ipAddress, + port: $port, + focusedField: $focusedField, + sectionHeader: L10n.Settings.WifiEdit.connectionDetails, + sectionFooter: L10n.Settings.WifiEdit.footer, + onPortSubmit: { saveChanges() } + ) + + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } - var body: some View { - NavigationStack { - Form { - WiFiAddressFields( - ipAddress: $ipAddress, - port: $port, - focusedField: $focusedField, - sectionHeader: L10n.Settings.WifiEdit.connectionDetails, - sectionFooter: L10n.Settings.WifiEdit.footer, - onPortSubmit: { saveChanges() } - ) - - if let errorMessage { - Section { - Label(errorMessage, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } - - Section { - Button { - saveChanges() - } label: { - HStack { - Spacer() - if isReconnecting { - ProgressView() - .controlSize(.small) - Text(L10n.Settings.WifiEdit.reconnecting) - } else { - Text(L10n.Settings.WifiEdit.saveChanges) - } - Spacer() - } - } - .disabled(!isValidInput || !hasChanges || isReconnecting) - } - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.WifiEdit.title) - .navigationBarTitleDisplayMode(.inline) - .wifiSheetToolbar(focusedField: $focusedField, isProcessing: isReconnecting) - .interactiveDismissDisabled(isReconnecting) - .onAppear { - populateCurrentValues() + Section { + Button { + saveChanges() + } label: { + HStack { + Spacer() + if isReconnecting { + ProgressView() + .controlSize(.small) + Text(L10n.Settings.WifiEdit.reconnecting) + } else { + Text(L10n.Settings.WifiEdit.saveChanges) + } + Spacer() } + } + .disabled(!isValidInput || !hasChanges || isReconnecting) } - .presentationSizing(.page) + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.WifiEdit.title) + .navigationBarTitleDisplayMode(.inline) + .wifiSheetToolbar(focusedField: $focusedField, isProcessing: isReconnecting) + .interactiveDismissDisabled(isReconnecting) + .onAppear { + populateCurrentValues() + } } + .presentationSizing(.page) + } - private func populateCurrentValues() { - if let host = originalHost { - ipAddress = host - } - if let currentPort = originalPort { - port = String(currentPort) - } + private func populateCurrentValues() { + if let host = originalHost { + ipAddress = host } + if let currentPort = originalPort { + port = String(currentPort) + } + } - private func saveChanges() { - focusedField = nil - - guard let portNumber = UInt16(port) else { - errorMessage = L10n.Settings.WifiEdit.Error.invalidPort - return - } + private func saveChanges() { + focusedField = nil - isReconnecting = true - errorMessage = nil - - Task { - do { - // Disconnect from current connection, then connect to new address - await appState.disconnect(reason: .wifiAddressChange) - try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) - dismiss() - } catch { - errorMessage = error.userFacingMessage - isReconnecting = false - } - } + guard let portNumber = UInt16(port) else { + errorMessage = L10n.Settings.WifiEdit.Error.invalidPort + return } + isReconnecting = true + errorMessage = nil + + Task { + do { + // Disconnect from current connection, then connect to new address + await appState.disconnect(reason: .wifiAddressChange) + try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) + dismiss() + } catch { + errorMessage = error.userFacingMessage + isReconnecting = false + } + } + } } #Preview { - WiFiEditSheet() - .environment(\.appState, AppState()) + WiFiEditSheet() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Settings/Sections/WiFiSection.swift b/MC1/Views/Settings/Sections/WiFiSection.swift index 6a328506..f5f35451 100644 --- a/MC1/Views/Settings/Sections/WiFiSection.swift +++ b/MC1/Views/Settings/Sections/WiFiSection.swift @@ -1,39 +1,39 @@ -import SwiftUI import MC1Services +import SwiftUI /// WiFi connection settings - shown when connected via WiFi instead of Bluetooth. struct WiFiSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Binding var showingEditSheet: Bool + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Binding var showingEditSheet: Bool - private var currentConnection: ConnectionMethod? { - appState.connectedDevice?.connectionMethods.first { $0.isWiFi } - } + private var currentConnection: ConnectionMethod? { + appState.connectedDevice?.connectionMethods.first { $0.isWiFi } + } - var body: some View { - Section { - if case .wifi(let host, let port, _) = currentConnection { - LabeledContent(L10n.Settings.Wifi.address, value: host) - LabeledContent(L10n.Settings.Wifi.port, value: "\(port)") - } + var body: some View { + Section { + if case let .wifi(host, port, _) = currentConnection { + LabeledContent(L10n.Settings.Wifi.address, value: host) + LabeledContent(L10n.Settings.Wifi.port, value: "\(port)") + } - Button(L10n.Settings.Wifi.editConnection) { - showingEditSheet = true - } - } header: { - Text(L10n.Settings.Wifi.header) - } footer: { - Text(L10n.Settings.Wifi.footer) - } - .themedRowBackground(theme) + Button(L10n.Settings.Wifi.editConnection) { + showingEditSheet = true + } + } header: { + Text(L10n.Settings.Wifi.header) + } footer: { + Text(L10n.Settings.Wifi.footer) } + .themedRowBackground(theme) + } } #Preview { - @Previewable @State var showingEditSheet = false - List { - WiFiSection(showingEditSheet: $showingEditSheet) - } - .environment(\.appState, AppState()) + @Previewable @State var showingEditSheet = false + List { + WiFiSection(showingEditSheet: $showingEditSheet) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Settings/SettingsDetail.swift b/MC1/Views/Settings/SettingsDetail.swift index de3b4487..a16f6aaa 100644 --- a/MC1/Views/Settings/SettingsDetail.swift +++ b/MC1/Views/Settings/SettingsDetail.swift @@ -5,26 +5,26 @@ import SwiftUI /// (`SettingsListContent` list selection + `SettingsDetailView` detail), and persisted as the /// active selection on `NavigationCoordinator`. enum SettingsDetail: Hashable { - case deviceInfo - case radio - case location - case connection - case advanced - case notifications - case chats - case appearance - case offlineMaps - case backup - case support + case deviceInfo + case radio + case location + case connection + case advanced + case notifications + case chats + case appearance + case offlineMaps + case backup + case support - /// The My Device rows only exist while a radio is connected; clearing their selection on - /// disconnect or a radio switch keeps the detail pane from stranding a now-gone device page. - var requiresDevice: Bool { - switch self { - case .deviceInfo, .radio, .location, .connection, .advanced: - true - case .notifications, .chats, .appearance, .offlineMaps, .backup, .support: - false - } + /// The My Device rows only exist while a radio is connected; clearing their selection on + /// disconnect or a radio switch keeps the detail pane from stranding a now-gone device page. + var requiresDevice: Bool { + switch self { + case .deviceInfo, .radio, .location, .connection, .advanced: + true + case .notifications, .chats, .appearance, .offlineMaps, .backup, .support: + false } + } } diff --git a/MC1/Views/Settings/SettingsDetailRow.swift b/MC1/Views/Settings/SettingsDetailRow.swift index 0f6a6cb5..adf5df7d 100644 --- a/MC1/Views/Settings/SettingsDetailRow.swift +++ b/MC1/Views/Settings/SettingsDetailRow.swift @@ -5,12 +5,12 @@ import SwiftUI /// `List(selection:)` binding (`NavigationCoordinator.selectedSetting`, read by `SettingsDetailView` /// in the detail column). struct SettingsDetailRow: View { - let detail: SettingsDetail - @ViewBuilder let label: () -> Label + let detail: SettingsDetail + @ViewBuilder let label: () -> Label - var body: some View { - NavigationLink(value: detail) { - label() - } + var body: some View { + NavigationLink(value: detail) { + label() } + } } diff --git a/MC1/Views/Settings/SettingsDetailView.swift b/MC1/Views/Settings/SettingsDetailView.swift index e4cbd3dc..9788f0c3 100644 --- a/MC1/Views/Settings/SettingsDetailView.swift +++ b/MC1/Views/Settings/SettingsDetailView.swift @@ -4,39 +4,39 @@ import SwiftUI /// pages, used by the compact `SettingsView` (`navigationDestination`) and the iPad split's detail /// column (`MainSidebarView`). struct SettingsDetailView: View { - @Environment(\.appState) private var appState - let detail: SettingsDetail + @Environment(\.appState) private var appState + let detail: SettingsDetail - var body: some View { - switch detail { - case .deviceInfo: - DeviceInfoView() - case .radio: - RadioSettingsView() - case .location: - LocationSettingsView() - case .connection: - ConnectionSettingsView() - case .advanced: - AdvancedSettingsView() - case .notifications: - NotificationSettingsView() - case .chats: - ChatSettingsView() - case .appearance: - AppearanceView() - case .offlineMaps: - OfflineMapSettingsView() - case .backup: - BackupRestoreView( - connectionManager: appState.connectionManager, - onImportRestoredData: { [appState] in appState.notifyDataRestored() }, - onChannelDraftSlotsAffected: { [appState] slotsByRadio in - appState.draftStore.clearChannelDrafts(slotsByRadio: slotsByRadio) - } - ) - case .support: - SupportDevelopmentView() + var body: some View { + switch detail { + case .deviceInfo: + DeviceInfoView() + case .radio: + RadioSettingsView() + case .location: + LocationSettingsView() + case .connection: + ConnectionSettingsView() + case .advanced: + AdvancedSettingsView() + case .notifications: + NotificationSettingsView() + case .chats: + ChatSettingsView() + case .appearance: + AppearanceView() + case .offlineMaps: + OfflineMapSettingsView() + case .backup: + BackupRestoreView( + connectionManager: appState.connectionManager, + onImportRestoredData: { [appState] in appState.notifyDataRestored() }, + onChannelDraftSlotsAffected: { [appState] slotsByRadio in + appState.draftStore.clearChannelDrafts(slotsByRadio: slotsByRadio) } + ) + case .support: + SupportDevelopmentView() } + } } diff --git a/MC1/Views/Settings/SettingsListContent.swift b/MC1/Views/Settings/SettingsListContent.swift index 3e995acf..cce5fe1c 100644 --- a/MC1/Views/Settings/SettingsListContent.swift +++ b/MC1/Views/Settings/SettingsListContent.swift @@ -1,257 +1,251 @@ -import SwiftUI import MC1Services +import SwiftUI import TipKit /// The settings list itself, shared by the compact `SettingsView` (stack) and the iPad /// `MainSidebarView` content column (split). `isSidebar` switches the iPad-only selection binding /// and themed-row treatment; the compact stack pushes via value-based `NavigationLink` instead. struct SettingsListContent: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.openURL) private var openURL - @Binding var showingDeviceSelection: Bool - @Bindable var demoModeManager: DemoModeManager - /// `true` when this list is the iPad split-view sidebar column, whose `.sidebar` style draws - /// rows transparent so the column canvas shows through and the native selection highlight shows. - let isSidebar: Bool - @State private var exportedLogFile: ExportedLogFile? - private let liveActivityTip = LiveActivityTip() - - /// Drives the iPad split's selected settings page. The compact stack leaves it nil — inside a - /// `NavigationStack` a value-based `NavigationLink` drives the stack path (via `SettingsView`'s - /// `navigationDestination`), not the list selection — so persistent selection stays iPad-only. - private var settingSelection: Binding { - Binding( - get: { appState.navigation.selectedSetting }, - set: { appState.navigation.selectedSetting = $0 } - ) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.openURL) private var openURL + @Binding var showingDeviceSelection: Bool + @Bindable var demoModeManager: DemoModeManager + /// `true` when this list is the iPad split-view sidebar column, whose `.sidebar` style draws + /// rows transparent so the column canvas shows through and the native selection highlight shows. + let isSidebar: Bool + @State private var exportedLogFile: ExportedLogFile? + private let liveActivityTip = LiveActivityTip() + + /// Drives the iPad split's selected settings page. The compact stack leaves it nil — inside a + /// `NavigationStack` a value-based `NavigationLink` drives the stack path (via `SettingsView`'s + /// `navigationDestination`), not the list selection — so persistent selection stays iPad-only. + private var settingSelection: Binding { + Binding( + get: { appState.navigation.selectedSetting }, + set: { appState.navigation.selectedSetting = $0 } + ) + } + + var body: some View { + settingsList + .themedCanvas(theme) + .navigationTitle(L10n.Settings.title) + .toolbar { + bleStatusToolbarItem() + } + .sheet(isPresented: $showingDeviceSelection) { + DeviceSelectionSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + .sheet(item: $exportedLogFile) { file in + ActivityView(activityItems: [file.url]) + } + } + + /// Only the iPad sidebar column carries a selection binding, where it drives the detail pane. The + /// compact stack must omit it: a `List(selection:)` turns a row tap into a selection instead of + /// letting the value-based `NavigationLink` push, which would break compact navigation. + @ViewBuilder + private var settingsList: some View { + if isSidebar { + List(selection: settingSelection) { sections } + .listStyle(.sidebar) + } else { + List { sections } } - - var body: some View { - settingsList - .themedCanvas(theme) - .navigationTitle(L10n.Settings.title) - .toolbar { - bleStatusToolbarItem() - } - .sheet(isPresented: $showingDeviceSelection) { - DeviceSelectionSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - .sheet(item: $exportedLogFile) { file in - ActivityView(activityItems: [file.url]) - } + } + + @ViewBuilder + private var sections: some View { + if let device = appState.connectedDevice { + MyDeviceSection(device: device, isSidebar: isSidebar) + } else { + NoDeviceSection(showingDeviceSelection: $showingDeviceSelection, isSidebar: isSidebar) } - /// Only the iPad sidebar column carries a selection binding, where it drives the detail pane. The - /// compact stack must omit it: a `List(selection:)` turns a row tap into a selection instead of - /// letting the value-based `NavigationLink` push, which would break compact navigation. - @ViewBuilder - private var settingsList: some View { - if isSidebar { - List(selection: settingSelection) { sections } - .listStyle(.sidebar) - } else { - List { sections } - } - } + Section { + SettingsDetailRow(detail: .notifications) { + TintedLabel(L10n.Settings.Notifications.header, systemImage: "bell.badge") + } - @ViewBuilder - private var sections: some View { - if let device = appState.connectedDevice { - MyDeviceSection(device: device, isSidebar: isSidebar) - } else { - NoDeviceSection(showingDeviceSelection: $showingDeviceSelection, isSidebar: isSidebar) - } + SettingsDetailRow(detail: .chats) { + TintedLabel(L10n.Settings.ChatSettings.title, systemImage: "bubble.left.and.bubble.right") + } - Section { - SettingsDetailRow(detail: .notifications) { - TintedLabel(L10n.Settings.Notifications.header, systemImage: "bell.badge") - } + AppearanceSection() - SettingsDetailRow(detail: .chats) { - TintedLabel(L10n.Settings.ChatSettings.title, systemImage: "bubble.left.and.bubble.right") - } + TipView(liveActivityTip, arrowEdge: .bottom) - AppearanceSection() - - TipView(liveActivityTip, arrowEdge: .bottom) - - Toggle(isOn: Binding( - get: { appState.liveActivityManager.isEnabled }, - set: { newValue in - appState.liveActivityManager.isEnabled = newValue - Task { - if !newValue { - await appState.liveActivityManager.endActivity() - } else if appState.connectionState.isConnected { - await appState.wireServicesIfConnected() - } - } - } - )) { - TintedLabel(L10n.Settings.LiveActivity.title, systemImage: "platter.filled.bottom.and.arrow.down.iphone") + Toggle(isOn: Binding( + get: { appState.liveActivityManager.isEnabled }, + set: { newValue in + appState.liveActivityManager.isEnabled = newValue + Task { + if !newValue { + await appState.liveActivityManager.endActivity() + } else if appState.connectionState.isConnected { + await appState.wireServicesIfConnected() } + } + } + )) { + TintedLabel(L10n.Settings.LiveActivity.title, systemImage: "platter.filled.bottom.and.arrow.down.iphone") + } - Button { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } label: { - SettingsRow( - L10n.Settings.Language.title, - systemImage: "globe", - detail: currentLanguageDisplayName - ) - } + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } label: { + SettingsRow( + L10n.Settings.Language.title, + systemImage: "globe", + detail: currentLanguageDisplayName + ) + } - SettingsDetailRow(detail: .offlineMaps) { - TintedLabel(L10n.Settings.OfflineMaps.title, systemImage: "map.fill") - } + SettingsDetailRow(detail: .offlineMaps) { + TintedLabel(L10n.Settings.OfflineMaps.title, systemImage: "map.fill") + } - SettingsDetailRow(detail: .backup) { - TintedLabel(L10n.Settings.Settings.Backup.title, systemImage: "archivebox.fill") - } - } header: { - Text(L10n.Settings.AppSettings.header) - } - .themedRowBackground(theme, flatten: isSidebar) + SettingsDetailRow(detail: .backup) { + TintedLabel(L10n.Settings.Settings.Backup.title, systemImage: "archivebox.fill") + } + } header: { + Text(L10n.Settings.AppSettings.header) + } + .themedRowBackground(theme, flatten: isSidebar) - AboutSection(isSidebar: isSidebar) + AboutSection(isSidebar: isSidebar) - DiagnosticsSection(exportedFile: $exportedLogFile, isSidebar: isSidebar) + DiagnosticsSection(exportedFile: $exportedLogFile, isSidebar: isSidebar) - if demoModeManager.isUnlocked { - Section { - Toggle(L10n.Settings.DemoMode.enabled, isOn: $demoModeManager.isEnabled) - } header: { - Text(L10n.Settings.DemoMode.header) - } footer: { - Text(L10n.Settings.DemoMode.footer) - } - .themedRowBackground(theme, flatten: isSidebar) - } + if demoModeManager.isUnlocked { + Section { + Toggle(L10n.Settings.DemoMode.enabled, isOn: $demoModeManager.isEnabled) + } header: { + Text(L10n.Settings.DemoMode.header) + } footer: { + Text(L10n.Settings.DemoMode.footer) + } + .themedRowBackground(theme, flatten: isSidebar) + } - #if DEBUG - Section { - Button { - appState.onboarding.resetOnboarding() - } label: { - TintedLabel("Reset Onboarding", systemImage: "arrow.counterclockwise") - } - } header: { - Text("Debug") - } - .themedRowBackground(theme, flatten: isSidebar) - #endif - - Section { - } footer: { - let version = Bundle.main.appVersion - let build = Bundle.main.appBuild - VStack { - Text(L10n.Settings.version(version)) - Text(L10n.Settings.build(build)) - } - .padding(.top, -8) - .padding(.bottom) - .frame(maxWidth: .infinity) + #if DEBUG + Section { + Button { + appState.onboarding.resetOnboarding() + } label: { + TintedLabel("Reset Onboarding", systemImage: "arrow.counterclockwise") } - .themedRowBackground(theme, flatten: isSidebar) + } header: { + Text("Debug") + } + .themedRowBackground(theme, flatten: isSidebar) + #endif + + Section {} footer: { + let version = Bundle.main.appVersion + let build = Bundle.main.appBuild + VStack { + Text(L10n.Settings.version(version)) + Text(L10n.Settings.build(build)) + } + .padding(.top, -8) + .padding(.bottom) + .frame(maxWidth: .infinity) } + .themedRowBackground(theme, flatten: isSidebar) + } - private var currentLanguageDisplayName: String { - let code = Bundle.main.preferredLocalizations.first ?? "en" - return Locale.current.localizedString(forLanguageCode: code) ?? code - } + private var currentLanguageDisplayName: String { + let code = Bundle.main.preferredLocalizations.first ?? "en" + return Locale.current.localizedString(forLanguageCode: code) ?? code + } } // MARK: - My Device Section private struct MyDeviceSection: View { - let device: DeviceDTO - let isSidebar: Bool - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - var body: some View { - Section { - SettingsDetailRow(detail: .deviceInfo) { - SettingsRow(L10n.Settings.DeviceInfo.title, systemImage: "cpu", detail: device.nodeName) - } - .accessibilityValue(device.nodeName) - - SettingsDetailRow(detail: .radio) { - SettingsRow(L10n.Settings.Radio.header, systemImage: "antenna.radiowaves.left.and.right", detail: radioDetailText) - } - .accessibilityValue(radioDetailText) - - SettingsDetailRow(detail: .location) { - SettingsRow(L10n.Settings.Location.header, systemImage: "location", detail: locationDetailText) - } - .accessibilityValue(locationDetailText) - - SettingsDetailRow(detail: .connection) { - let isWiFi = appState.connectionManager.currentTransportType == .wifi - TintedLabel( - isWiFi ? L10n.Settings.Wifi.header : L10n.Settings.Bluetooth.header, - systemImage: isWiFi ? "wifi" : "wave.3.right" - ) - } - - SettingsDetailRow(detail: .advanced) { - TintedLabel(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape.2") - } - } header: { - Text(L10n.Settings.MyDevice.header) - } - .themedRowBackground(theme, flatten: isSidebar) - } - - private var radioDetailText: String { - let preset = device.clientRepeat - ? RadioPresets.matchingRepeatPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - : RadioPresets.matchingPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - return preset?.name ?? L10n.Settings.BatteryCurve.custom - } + let device: DeviceDTO + let isSidebar: Bool + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + var body: some View { + Section { + SettingsDetailRow(detail: .deviceInfo) { + SettingsRow(L10n.Settings.DeviceInfo.title, systemImage: "cpu", detail: device.nodeName) + } + .accessibilityValue(device.nodeName) + + SettingsDetailRow(detail: .radio) { + SettingsRow(L10n.Settings.Radio.header, systemImage: "antenna.radiowaves.left.and.right", detail: radioDetailText) + } + .accessibilityValue(radioDetailText) + + SettingsDetailRow(detail: .location) { + SettingsRow(L10n.Settings.Location.header, systemImage: "location", detail: locationDetailText) + } + .accessibilityValue(locationDetailText) + + SettingsDetailRow(detail: .connection) { + let isWiFi = appState.connectionManager.currentTransportType == .wifi + TintedLabel( + isWiFi ? L10n.Settings.Wifi.header : L10n.Settings.Bluetooth.header, + systemImage: isWiFi ? "wifi" : "wave.3.right" + ) + } - private var locationDetailText: String { - device.sharesLocationPublicly - ? L10n.Settings.Location.sharingPublicly - : L10n.Settings.Location.notSharing + SettingsDetailRow(detail: .advanced) { + TintedLabel(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape.2") + } + } header: { + Text(L10n.Settings.MyDevice.header) } + .themedRowBackground(theme, flatten: isSidebar) + } + + private var radioDetailText: String { + let preset = device.clientRepeat + ? RadioPresets.matchingRepeatPreset(frequencyKHz: device.frequency) + : RadioPresets.matchingPreset( + frequencyKHz: device.frequency, + bandwidthKHz: device.bandwidth, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + return preset?.name ?? L10n.Settings.BatteryCurve.custom + } + + private var locationDetailText: String { + device.sharesLocationPublicly + ? L10n.Settings.Location.sharingPublicly + : L10n.Settings.Location.notSharing + } } // MARK: - Settings Row with Detail Text private struct SettingsRow: View { - let title: String - let systemImage: String - let detail: String - - init(_ title: String, systemImage: String, detail: String) { - self.title = title - self.systemImage = systemImage - self.detail = detail - } - - var body: some View { - HStack { - TintedLabel(title, systemImage: systemImage) - Spacer() - Text(detail) - .foregroundStyle(.secondary) - } + let title: String + let systemImage: String + let detail: String + + init(_ title: String, systemImage: String, detail: String) { + self.title = title + self.systemImage = systemImage + self.detail = detail + } + + var body: some View { + HStack { + TintedLabel(title, systemImage: systemImage) + Spacer() + Text(detail) + .foregroundStyle(.secondary) } + } } diff --git a/MC1/Views/Settings/SettingsSubpage.swift b/MC1/Views/Settings/SettingsSubpage.swift index 29d5fa3f..c504d42c 100644 --- a/MC1/Views/Settings/SettingsSubpage.swift +++ b/MC1/Views/Settings/SettingsSubpage.swift @@ -4,36 +4,36 @@ import SwiftUI /// deeper. Value-based so each push rebuilds the destination instead of reusing stale /// `@State` from a prior visit. enum SettingsSubpage: Hashable { - case publicKey(Data) - case configExport - case configImport - case blockedChannelSenders - case blockedContacts - case trustedContacts + case publicKey(Data) + case configExport + case configImport + case blockedChannelSenders + case blockedContacts + case trustedContacts } extension View { - /// Registers the `SettingsSubpage` destinations on the enclosing navigation stack. Each - /// hosting page applies this to its own `List` so the pushes resolve in every stack that - /// hosts the page (the compact Settings stack, the iPad detail column, and the status-menu - /// push of `AdvancedSettingsView`). - @MainActor - func settingsSubpageDestinations() -> some View { - navigationDestination(for: SettingsSubpage.self) { subpage in - switch subpage { - case .publicKey(let publicKey): - PublicKeyView(publicKey: publicKey) - case .configExport: - NodeConfigExportView() - case .configImport: - NodeConfigImportView() - case .blockedChannelSenders: - BlockedChannelSendersView() - case .blockedContacts: - BlockedContactsView() - case .trustedContacts: - TrustedContactsPickerView() - } - } + /// Registers the `SettingsSubpage` destinations on the enclosing navigation stack. Each + /// hosting page applies this to its own `List` so the pushes resolve in every stack that + /// hosts the page (the compact Settings stack, the iPad detail column, and the status-menu + /// push of `AdvancedSettingsView`). + @MainActor + func settingsSubpageDestinations() -> some View { + navigationDestination(for: SettingsSubpage.self) { subpage in + switch subpage { + case let .publicKey(publicKey): + PublicKeyView(publicKey: publicKey) + case .configExport: + NodeConfigExportView() + case .configImport: + NodeConfigImportView() + case .blockedChannelSenders: + BlockedChannelSendersView() + case .blockedContacts: + BlockedContactsView() + case .trustedContacts: + TrustedContactsPickerView() + } } + } } diff --git a/MC1/Views/Settings/SettingsView.swift b/MC1/Views/Settings/SettingsView.swift index 790a705a..018bdbe5 100644 --- a/MC1/Views/Settings/SettingsView.swift +++ b/MC1/Views/Settings/SettingsView.swift @@ -1,28 +1,28 @@ -import SwiftUI import MC1Services +import SwiftUI /// Main settings screen for the compact (iPhone) tab shell — navigation-link rows for device /// settings plus always-visible app settings. The iPad regular-width layout renders settings /// through `MainSidebarView` → `SettingsListContent`, never this view. struct SettingsView: View { - @State private var showingDeviceSelection = false - private var demoModeManager = DemoModeManager.shared + @State private var showingDeviceSelection = false + private var demoModeManager = DemoModeManager.shared - var body: some View { - NavigationStack { - SettingsListContent( - showingDeviceSelection: $showingDeviceSelection, - demoModeManager: demoModeManager, - isSidebar: false - ) - .navigationDestination(for: SettingsDetail.self) { detail in - SettingsDetailView(detail: detail) - } - } + var body: some View { + NavigationStack { + SettingsListContent( + showingDeviceSelection: $showingDeviceSelection, + demoModeManager: demoModeManager, + isSidebar: false + ) + .navigationDestination(for: SettingsDetail.self) { detail in + SettingsDetailView(detail: detail) + } } + } } #Preview { - SettingsView() - .environment(\.appState, AppState()) + SettingsView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Settings/TrustedContactsPickerView.swift b/MC1/Views/Settings/TrustedContactsPickerView.swift index c240a505..47eb5f39 100644 --- a/MC1/Views/Settings/TrustedContactsPickerView.swift +++ b/MC1/Views/Settings/TrustedContactsPickerView.swift @@ -1,145 +1,145 @@ -import SwiftUI import MC1Services +import SwiftUI /// Picker for selecting trusted contacts for telemetry struct TrustedContactsPickerView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var contacts: [ContactDTO] = [] - @State private var searchText = "" - @State private var showFavoritesOnly = false - @State private var pendingTrustedIDs: Set = [] - @State private var initialTrustedIDs: Set = [] - @State private var isApplying = false - @State private var errorMessage: String? - @State private var successTrigger = 0 + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var contacts: [ContactDTO] = [] + @State private var searchText = "" + @State private var showFavoritesOnly = false + @State private var pendingTrustedIDs: Set = [] + @State private var initialTrustedIDs: Set = [] + @State private var isApplying = false + @State private var errorMessage: String? + @State private var successTrigger = 0 - private var settingsModified: Bool { - pendingTrustedIDs != initialTrustedIDs - } + private var settingsModified: Bool { + pendingTrustedIDs != initialTrustedIDs + } - private var canApply: Bool { - appState.connectionState == .ready && settingsModified && !isApplying - } + private var canApply: Bool { + appState.connectionState == .ready && settingsModified && !isApplying + } - private var filteredContacts: [ContactDTO] { - var result = contacts - if showFavoritesOnly { - result = result.filter(\.isFavorite) - } - if !searchText.isEmpty { - result = result.filter { $0.displayName.localizedStandardContains(searchText) } - } - return result + private var filteredContacts: [ContactDTO] { + var result = contacts + if showFavoritesOnly { + result = result.filter(\.isFavorite) } + if !searchText.isEmpty { + result = result.filter { $0.displayName.localizedStandardContains(searchText) } + } + return result + } - var body: some View { - List { - Section { - Toggle(L10n.Settings.TrustedContacts.favoritesOnly, isOn: $showFavoritesOnly) - } - .themedRowBackground(theme) + var body: some View { + List { + Section { + Toggle(L10n.Settings.TrustedContacts.favoritesOnly, isOn: $showFavoritesOnly) + } + .themedRowBackground(theme) - Section { - if filteredContacts.isEmpty { - if !searchText.isEmpty || showFavoritesOnly { - ContentUnavailableView( - L10n.Settings.TrustedContacts.noResults, - systemImage: "magnifyingglass", - description: Text(L10n.Settings.TrustedContacts.noResultsDescription(searchText)) - ) - } else { - ContentUnavailableView( - L10n.Settings.TrustedContacts.noContacts, - systemImage: "person.2.slash", - description: Text(L10n.Settings.TrustedContacts.noContactsDescription) - ) - } - } else { - ForEach(filteredContacts) { contact in - Button { - if pendingTrustedIDs.contains(contact.id) { - pendingTrustedIDs.remove(contact.id) - } else { - pendingTrustedIDs.insert(contact.id) - } - } label: { - HStack { - Text(contact.displayName) - Spacer() - if pendingTrustedIDs.contains(contact.id) { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - } - .foregroundStyle(.primary) - } - } - } - .themedRowBackground(theme) - } - .themedCanvas(theme) - .disabled(isApplying) - .searchable(text: $searchText, prompt: L10n.Settings.TrustedContacts.searchPrompt) - .navigationTitle(L10n.Settings.TrustedContacts.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { applyChanges() } label: { - Text(L10n.Settings.TrustedContacts.apply) + Section { + if filteredContacts.isEmpty { + if !searchText.isEmpty || showFavoritesOnly { + ContentUnavailableView( + L10n.Settings.TrustedContacts.noResults, + systemImage: "magnifyingglass", + description: Text(L10n.Settings.TrustedContacts.noResultsDescription(searchText)) + ) + } else { + ContentUnavailableView( + L10n.Settings.TrustedContacts.noContacts, + systemImage: "person.2.slash", + description: Text(L10n.Settings.TrustedContacts.noContactsDescription) + ) + } + } else { + ForEach(filteredContacts) { contact in + Button { + if pendingTrustedIDs.contains(contact.id) { + pendingTrustedIDs.remove(contact.id) + } else { + pendingTrustedIDs.insert(contact.id) + } + } label: { + HStack { + Text(contact.displayName) + Spacer() + if pendingTrustedIDs.contains(contact.id) { + Image(systemName: "checkmark") + .foregroundStyle(.tint) } - .disabled(!canApply) + } } + .foregroundStyle(.primary) + } } - .sensoryFeedback(.success, trigger: successTrigger) - .errorAlert($errorMessage) - .task { - await loadContacts() + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .disabled(isApplying) + .searchable(text: $searchText, prompt: L10n.Settings.TrustedContacts.searchPrompt) + .navigationTitle(L10n.Settings.TrustedContacts.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { applyChanges() } label: { + Text(L10n.Settings.TrustedContacts.apply) } + .disabled(!canApply) + } + } + .sensoryFeedback(.success, trigger: successTrigger) + .errorAlert($errorMessage) + .task { + await loadContacts() } + } - private func loadContacts() async { - guard let radioID = appState.connectedDevice?.radioID, - let contactService = appState.services?.contactService else { return } - do { - contacts = try await contactService.getContacts(radioID: radioID) - .filter { $0.type == .chat } - let trusted = Set( - contacts - .filter { ContactService.hasTelemetryPermissions(flags: $0.flags) } - .map(\.id) - ) - initialTrustedIDs = trusted - pendingTrustedIDs = trusted - } catch { - // User can navigate back and retry - } + private func loadContacts() async { + guard let radioID = appState.connectedDevice?.radioID, + let contactService = appState.services?.contactService else { return } + do { + contacts = try await contactService.getContacts(radioID: radioID) + .filter { $0.type == .chat } + let trusted = Set( + contacts + .filter { ContactService.hasTelemetryPermissions(flags: $0.flags) } + .map(\.id) + ) + initialTrustedIDs = trusted + pendingTrustedIDs = trusted + } catch { + // User can navigate back and retry } + } - private func applyChanges() { - guard !isApplying else { return } - guard let contactService = appState.services?.contactService else { return } + private func applyChanges() { + guard !isApplying else { return } + guard let contactService = appState.services?.contactService else { return } - isApplying = true - Task { - do { - let toGrant = pendingTrustedIDs.subtracting(initialTrustedIDs) - let toRevoke = initialTrustedIDs.subtracting(pendingTrustedIDs) + isApplying = true + Task { + do { + let toGrant = pendingTrustedIDs.subtracting(initialTrustedIDs) + let toRevoke = initialTrustedIDs.subtracting(pendingTrustedIDs) - for id in toGrant { - try await contactService.setTelemetryPermissions(id, granted: true) - } - for id in toRevoke { - try await contactService.setTelemetryPermissions(id, granted: false) - } - - initialTrustedIDs = pendingTrustedIDs - successTrigger += 1 - } catch { - errorMessage = error.userFacingMessage - } - isApplying = false + for id in toGrant { + try await contactService.setTelemetryPermissions(id, granted: true) } + for id in toRevoke { + try await contactService.setTelemetryPermissions(id, granted: false) + } + + initialTrustedIDs = pendingTrustedIDs + successTrigger += 1 + } catch { + errorMessage = error.userFacingMessage + } + isApplying = false } + } } diff --git a/MC1/Views/Sidebar/SidebarBackgroundExtension.swift b/MC1/Views/Sidebar/SidebarBackgroundExtension.swift index 5645b3b8..12ca8ff9 100644 --- a/MC1/Views/Sidebar/SidebarBackgroundExtension.swift +++ b/MC1/Views/Sidebar/SidebarBackgroundExtension.swift @@ -6,19 +6,19 @@ import SwiftUI let sidebarContentLeadingInset: CGFloat = 16 extension View { - /// Extends this view's background under the adjacent floating Liquid Glass sidebar on - /// iOS 26+ by mirroring and blurring its leading-edge pixels into the sidebar's safe-area - /// region. Below iOS 26 the sidebar is an opaque column and the modifier is omitted. - /// - /// Apply only to a static section background, not to fast-scrolling rows: the effect is a - /// GPU duplicate plus mirror plus blur, and Apple advises using it on a single instance of - /// background content with consideration of performance. - @ViewBuilder - func sidebarBackgroundExtension() -> some View { - if #available(iOS 26, *) { - self.backgroundExtensionEffect() - } else { - self - } + /// Extends this view's background under the adjacent floating Liquid Glass sidebar on + /// iOS 26+ by mirroring and blurring its leading-edge pixels into the sidebar's safe-area + /// region. Below iOS 26 the sidebar is an opaque column and the modifier is omitted. + /// + /// Apply only to a static section background, not to fast-scrolling rows: the effect is a + /// GPU duplicate plus mirror plus blur, and Apple advises using it on a single instance of + /// background content with consideration of performance. + @ViewBuilder + func sidebarBackgroundExtension() -> some View { + if #available(iOS 26, *) { + backgroundExtensionEffect() + } else { + self } + } } diff --git a/MC1/Views/Sidebar/SidebarContentColumnBackground.swift b/MC1/Views/Sidebar/SidebarContentColumnBackground.swift index 24e1a024..0c926398 100644 --- a/MC1/Views/Sidebar/SidebarContentColumnBackground.swift +++ b/MC1/Views/Sidebar/SidebarContentColumnBackground.swift @@ -10,22 +10,21 @@ import SwiftUI /// no-op; the floating sidebar simply overlays the unmodified system surface there. The leading /// inset keeps row content clear of the floating glass in both cases. struct SidebarContentColumnBackground: ViewModifier { - let theme: Theme + let theme: Theme - @ViewBuilder - func body(content: Content) -> some View { - if let canvas = theme.surfaces?.canvas { - content - .scrollContentBackground(.hidden) - .safeAreaPadding(.leading, sidebarContentLeadingInset) - .background { - canvas - .ignoresSafeArea() - .sidebarBackgroundExtension() - } - } else { - content - .safeAreaPadding(.leading, sidebarContentLeadingInset) + func body(content: Content) -> some View { + if let canvas = theme.surfaces?.canvas { + content + .scrollContentBackground(.hidden) + .safeAreaPadding(.leading, sidebarContentLeadingInset) + .background { + canvas + .ignoresSafeArea() + .sidebarBackgroundExtension() } + } else { + content + .safeAreaPadding(.leading, sidebarContentLeadingInset) } + } } diff --git a/MC1/Views/Support/Components/ContributionRow.swift b/MC1/Views/Support/Components/ContributionRow.swift index 6afdf3fc..4d49abfe 100644 --- a/MC1/Views/Support/Components/ContributionRow.swift +++ b/MC1/Views/Support/Components/ContributionRow.swift @@ -4,71 +4,71 @@ import SwiftUI /// confirm first to mitigate a fat-finger purchase. On success a checkmark briefly replaces the /// price. "Contribution" naming avoids the TipKit identifier collision. struct ContributionRow: View { - let displayName: String - let displayPrice: String? - let requiresConfirmation: Bool - let onPurchase: () async -> Bool + let displayName: String + let displayPrice: String? + let requiresConfirmation: Bool + let onPurchase: () async -> Bool - @State private var showingConfirmation = false - @State private var showingThanks = false - @State private var isPurchasing = false + @State private var showingConfirmation = false + @State private var showingThanks = false + @State private var isPurchasing = false - var body: some View { - HStack(alignment: .center, spacing: 12) { - Text(displayName).font(.body) - Spacer() - priceButton - } - .confirmationDialog( - L10n.Settings.Support.Contributions.highValueConfirm(displayName), - isPresented: $showingConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Settings.Support.Contributions.confirmButton(displayPrice ?? displayName)) { - Task { await runPurchase() } - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.Settings.Support.Accessibility.ContributionRow.label(displayName, displayPrice ?? "")) - .accessibilityHint(L10n.Settings.Support.Accessibility.ContributionRow.hint) + var body: some View { + HStack(alignment: .center, spacing: 12) { + Text(displayName).font(.body) + Spacer() + priceButton + } + .confirmationDialog( + L10n.Settings.Support.Contributions.highValueConfirm(displayName), + isPresented: $showingConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Settings.Support.Contributions.confirmButton(displayPrice ?? displayName)) { + Task { await runPurchase() } + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Settings.Support.Accessibility.ContributionRow.label(displayName, displayPrice ?? "")) + .accessibilityHint(L10n.Settings.Support.Accessibility.ContributionRow.hint) + } - @ViewBuilder - private var priceButton: some View { - if showingThanks { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.opacity) + @ViewBuilder + private var priceButton: some View { + if showingThanks { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.opacity) + } else { + Button { + if requiresConfirmation { + showingConfirmation = true } else { - Button { - if requiresConfirmation { - showingConfirmation = true - } else { - Task { await runPurchase() } - } - } label: { - if isPurchasing { - ProgressView() - } else { - Text(displayPrice ?? "—").font(.callout.weight(.semibold)) - } - } - .buttonStyle(.bordered) - .disabled(displayPrice == nil || isPurchasing) + Task { await runPurchase() } } + } label: { + if isPurchasing { + ProgressView() + } else { + Text(displayPrice ?? "—").font(.callout.weight(.semibold)) + } + } + .buttonStyle(.bordered) + .disabled(displayPrice == nil || isPurchasing) } + } - private func runPurchase() async { - isPurchasing = true - let succeeded = await onPurchase() - isPurchasing = false - guard succeeded else { return } - withAnimation { showingThanks = true } - AccessibilityNotification.Announcement( - L10n.Settings.Support.Accessibility.tipConfirmAnnouncement - ).post() - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { showingThanks = false } - } + private func runPurchase() async { + isPurchasing = true + let succeeded = await onPurchase() + isPurchasing = false + guard succeeded else { return } + withAnimation { showingThanks = true } + AccessibilityNotification.Announcement( + L10n.Settings.Support.Accessibility.tipConfirmAnnouncement + ).post() + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { showingThanks = false } + } } diff --git a/MC1/Views/Support/Components/ThemeBundleCard.swift b/MC1/Views/Support/Components/ThemeBundleCard.swift index 85736ca4..b429b6b6 100644 --- a/MC1/Views/Support/Components/ThemeBundleCard.swift +++ b/MC1/Views/Support/Components/ThemeBundleCard.swift @@ -1,72 +1,72 @@ -import SwiftUI import MC1Services +import SwiftUI /// Full-width bundle card: the single purchase control for every theme. Shown only while the /// user does not already own the whole set, so it never renders an "owned" state. struct ThemeBundleCard: View { - let isPending: Bool - let displayPrice: String? - let onPurchase: () async -> Void + let isPending: Bool + let displayPrice: String? + let onPurchase: () async -> Void - @Environment(\.appTheme) private var theme - @State private var isPurchasing = false + @Environment(\.appTheme) private var theme + @State private var isPurchasing = false - var body: some View { - VStack(alignment: .center, spacing: 10) { - Text(L10n.Settings.Support.Bundle.title) - .font(.headline) - Text(L10n.Settings.Support.Bundle.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - action - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .center) - .background(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint(isActionable ? L10n.Settings.Support.Accessibility.BundleCard.lockedHint : "") - // Only carry the button trait when the price has loaded and no purchase is in flight, so a - // loading or pending card is not announced as something the user can activate. - .accessibilityAddTraits(isActionable ? .isButton : []) + var body: some View { + VStack(alignment: .center, spacing: 10) { + Text(L10n.Settings.Support.Bundle.title) + .font(.headline) + Text(L10n.Settings.Support.Bundle.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + action } + .padding(12) + .frame(maxWidth: .infinity, alignment: .center) + .background(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(isActionable ? L10n.Settings.Support.Accessibility.BundleCard.lockedHint : "") + // Only carry the button trait when the price has loaded and no purchase is in flight, so a + // loading or pending card is not announced as something the user can activate. + .accessibilityAddTraits(isActionable ? .isButton : []) + } - /// True once the card is an activatable purchase control (price loaded, nothing pending). - private var isActionable: Bool { - !isPending && displayPrice != nil - } + /// True once the card is an activatable purchase control (price loaded, nothing pending). + private var isActionable: Bool { + !isPending && displayPrice != nil + } - private var accessibilityLabel: String { - if !isPending, displayPrice == nil { - return L10n.Settings.Support.Accessibility.BundleCard.loadingLabel - } - return L10n.Settings.Support.Accessibility.BundleCard.lockedLabel(displayPrice ?? "") + private var accessibilityLabel: String { + if !isPending, displayPrice == nil { + return L10n.Settings.Support.Accessibility.BundleCard.loadingLabel } + return L10n.Settings.Support.Accessibility.BundleCard.lockedLabel(displayPrice ?? "") + } - @ViewBuilder - private var action: some View { - if isPurchasing { - ProgressView() - .frame(maxWidth: .infinity) - } else if isPending { - Label(L10n.Settings.Support.Pending.button, systemImage: "clock.badge.questionmark") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - } else { - Button { - Task { - isPurchasing = true - await onPurchase() - isPurchasing = false - } - } label: { - Text(displayPrice ?? "—") - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(displayPrice == nil) + @ViewBuilder + private var action: some View { + if isPurchasing { + ProgressView() + .frame(maxWidth: .infinity) + } else if isPending { + Label(L10n.Settings.Support.Pending.button, systemImage: "clock.badge.questionmark") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } else { + Button { + Task { + isPurchasing = true + await onPurchase() + isPurchasing = false } + } label: { + Text(displayPrice ?? "—") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(displayPrice == nil) } + } } diff --git a/MC1/Views/Support/Components/ThemePreviewCard.swift b/MC1/Views/Support/Components/ThemePreviewCard.swift index b495f63e..cf3e1d50 100644 --- a/MC1/Views/Support/Components/ThemePreviewCard.swift +++ b/MC1/Views/Support/Components/ThemePreviewCard.swift @@ -4,42 +4,42 @@ import SwiftUI /// Non-interactive — the bundle card is the only purchase control; applying happens on the /// Appearance screen. struct ThemePreviewCard: View { - let theme: Theme - let isOwned: Bool + let theme: Theme + let isOwned: Bool - var body: some View { - VStack(spacing: 8) { - ZStack(alignment: .topTrailing) { - ThemePaletteSwatch(theme: theme) - if !isOwned { - Image(systemName: "lock.fill") - .font(.caption) - .padding(6) - .background(.ultraThinMaterial, in: Circle()) - .padding(6) - } - } - Text(theme.localizedName) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - if isOwned { - HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { - Image(systemName: "checkmark") - Text(L10n.Settings.Support.Themes.owned) - } - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } + var body: some View { + VStack(spacing: 8) { + ZStack(alignment: .topTrailing) { + ThemePaletteSwatch(theme: theme) + if !isOwned { + Image(systemName: "lock.fill") + .font(.caption) + .padding(6) + .background(.ultraThinMaterial, in: Circle()) + .padding(6) } - .padding(ThemeCardMetrics.contentPadding) - .frame(maxWidth: .infinity) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) + } + Text(theme.localizedName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + if isOwned { + HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { + Image(systemName: "checkmark") + Text(L10n.Settings.Support.Themes.owned) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } } + .padding(ThemeCardMetrics.contentPadding) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } - private var accessibilityLabel: String { - isOwned - ? L10n.Settings.Support.Accessibility.ThemeCard.ownedLabel(theme.localizedName) - : L10n.Settings.Support.Accessibility.ThemeCard.lockedLabel(theme.localizedName) - } + private var accessibilityLabel: String { + isOwned + ? L10n.Settings.Support.Accessibility.ThemeCard.ownedLabel(theme.localizedName) + : L10n.Settings.Support.Accessibility.ThemeCard.lockedLabel(theme.localizedName) + } } diff --git a/MC1/Views/Support/Sections/ContributionsSection.swift b/MC1/Views/Support/Sections/ContributionsSection.swift index 517eee03..04f3903b 100644 --- a/MC1/Views/Support/Sections/ContributionsSection.swift +++ b/MC1/Views/Support/Sections/ContributionsSection.swift @@ -1,43 +1,45 @@ -import SwiftUI -import StoreKit import MC1Services +import StoreKit +import SwiftUI struct ContributionsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.purchase) private var purchase + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.purchase) private var purchase - private var storeState: StoreState { appState.storeState } + private var storeState: StoreState { + appState.storeState + } - /// Ordered tip product IDs (low to high) with their high-value flag. - private var contributions: [(id: String, highValue: Bool)] { - [ - (StoreCatalog.Tip.coffee, false), - (StoreCatalog.Tip.lunch, false), - (StoreCatalog.Tip.dinner, false), - (StoreCatalog.Tip.generous, false), - (StoreCatalog.Tip.massive, true), - (StoreCatalog.Tip.epic, true) - ] - } + /// Ordered tip product IDs (low to high) with their high-value flag. + private var contributions: [(id: String, highValue: Bool)] { + [ + (StoreCatalog.Tip.coffee, false), + (StoreCatalog.Tip.lunch, false), + (StoreCatalog.Tip.dinner, false), + (StoreCatalog.Tip.generous, false), + (StoreCatalog.Tip.massive, true), + (StoreCatalog.Tip.epic, true) + ] + } - var body: some View { - Section { - ForEach(contributions, id: \.id) { contribution in - if let product = storeState.service.product(for: contribution.id) { - ContributionRow( - displayName: product.displayName, - displayPrice: product.displayPrice, - requiresConfirmation: contribution.highValue, - onPurchase: { await storeState.purchase(productID: contribution.id) { try await purchase($0) } } - ) - } - } - } header: { - Text(L10n.Settings.Support.Contributions.title) - } footer: { - Text(L10n.Settings.Support.Contributions.footer) + var body: some View { + Section { + ForEach(contributions, id: \.id) { contribution in + if let product = storeState.service.product(for: contribution.id) { + ContributionRow( + displayName: product.displayName, + displayPrice: product.displayPrice, + requiresConfirmation: contribution.highValue, + onPurchase: { await storeState.purchase(productID: contribution.id) { try await purchase($0) } } + ) } - .themedRowBackground(theme) + } + } header: { + Text(L10n.Settings.Support.Contributions.title) + } footer: { + Text(L10n.Settings.Support.Contributions.footer) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Support/Sections/PendingPurchaseBanner.swift b/MC1/Views/Support/Sections/PendingPurchaseBanner.swift index a73ff9d1..28e9a668 100644 --- a/MC1/Views/Support/Sections/PendingPurchaseBanner.swift +++ b/MC1/Views/Support/Sections/PendingPurchaseBanner.swift @@ -2,14 +2,14 @@ import SwiftUI /// Non-blocking "Awaiting approval" banner; rendered only when a pending purchase exists. struct PendingPurchaseBanner: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - Section { - Label(L10n.Settings.Support.Pending.banner, systemImage: "clock.badge.questionmark") - .font(.footnote) - .foregroundStyle(.secondary) - } - .themedRowBackground(theme) + var body: some View { + Section { + Label(L10n.Settings.Support.Pending.banner, systemImage: "clock.badge.questionmark") + .font(.footnote) + .foregroundStyle(.secondary) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Support/Sections/RefundLinkSection.swift b/MC1/Views/Support/Sections/RefundLinkSection.swift index 640a3c0e..acb1a876 100644 --- a/MC1/Views/Support/Sections/RefundLinkSection.swift +++ b/MC1/Views/Support/Sections/RefundLinkSection.swift @@ -1,46 +1,48 @@ -import SwiftUI -import StoreKit import MC1Services +import StoreKit +import SwiftUI /// "Request a refund" link for the owned theme bundle transaction. Resolved via /// `Transaction.latest(for:)`; hidden when the bundle is not owned. Only the bundle is /// purchasable, so only the bundle is refundable; individual themes and tips are excluded /// (Apple provides no in-app refund flow for consumables). struct RefundLinkSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var refundTransactionID: StoreKit.Transaction.ID? - @State private var showingRefundSheet = false + @State private var refundTransactionID: StoreKit.Transaction.ID? + @State private var showingRefundSheet = false - private var storeState: StoreState { appState.storeState } + private var storeState: StoreState { + appState.storeState + } - var body: some View { - Group { - if let transactionID = refundTransactionID { - Section { - Button(L10n.Settings.Support.Refund.link) { showingRefundSheet = true } - .refundRequestSheet(for: transactionID, isPresented: $showingRefundSheet) - } - .themedRowBackground(theme) - } - } - .task(id: storeState.service.ownedThemeIDs) { - refundTransactionID = await latestRefundableTransactionID() + var body: some View { + Group { + if let transactionID = refundTransactionID { + Section { + Button(L10n.Settings.Support.Refund.link) { showingRefundSheet = true } + .refundRequestSheet(for: transactionID, isPresented: $showingRefundSheet) } + .themedRowBackground(theme) + } } - - /// The verified, non-revoked bundle transaction's ID, or nil if none. Only the bundle is - /// purchasable, so it is the only refundable product. `Transaction.latest(for:)` keeps - /// refunded/revoked rows (sets `revocationDate` but doesn't drop them), so the filter must - /// exclude any non-nil `revocationDate` — otherwise the "Request a refund" link stays visible - /// after a refund and points at the just-revoked transaction, which then returns - /// `RefundRequestError.duplicateRequest`. - /// Internal (not private) for `@testable` access from `RefundLinkSectionTests`. - func latestRefundableTransactionID() async -> StoreKit.Transaction.ID? { - guard case .verified(let transaction) = await Transaction.latest(for: StoreCatalog.Theme.bundleAll), - transaction.revocationDate == nil - else { return nil } - return transaction.id + .task(id: storeState.service.ownedThemeIDs) { + refundTransactionID = await latestRefundableTransactionID() } + } + + /// The verified, non-revoked bundle transaction's ID, or nil if none. Only the bundle is + /// purchasable, so it is the only refundable product. `Transaction.latest(for:)` keeps + /// refunded/revoked rows (sets `revocationDate` but doesn't drop them), so the filter must + /// exclude any non-nil `revocationDate` — otherwise the "Request a refund" link stays visible + /// after a refund and points at the just-revoked transaction, which then returns + /// `RefundRequestError.duplicateRequest`. + /// Internal (not private) for `@testable` access from `RefundLinkSectionTests`. + func latestRefundableTransactionID() async -> StoreKit.Transaction.ID? { + guard case let .verified(transaction) = await Transaction.latest(for: StoreCatalog.Theme.bundleAll), + transaction.revocationDate == nil + else { return nil } + return transaction.id + } } diff --git a/MC1/Views/Support/Sections/SupportContactSection.swift b/MC1/Views/Support/Sections/SupportContactSection.swift index 181b0c0b..c499ec66 100644 --- a/MC1/Views/Support/Sections/SupportContactSection.swift +++ b/MC1/Views/Support/Sections/SupportContactSection.swift @@ -2,28 +2,28 @@ import SwiftUI /// Required by App Store Review §1.5. Mirrors the developer support address already in About. struct SupportContactSection: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - private enum Support { - static let email = "info@meshcoreone.com" - static let mailtoURL = URL(string: "mailto:\(email)") - } + private enum Support { + static let email = "info@meshcoreone.com" + static let mailtoURL = URL(string: "mailto:\(email)") + } - var body: some View { - Section { - if let mailtoURL = Support.mailtoURL { - Link(destination: mailtoURL) { - HStack { - TintedLabel(L10n.Settings.Support.Contact.link, systemImage: "envelope") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) - } + var body: some View { + Section { + if let mailtoURL = Support.mailtoURL { + Link(destination: mailtoURL) { + HStack { + TintedLabel(L10n.Settings.Support.Contact.link, systemImage: "envelope") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } } - .themedRowBackground(theme) + .foregroundStyle(.primary) + } } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Support/Sections/SupportHeaderSection.swift b/MC1/Views/Support/Sections/SupportHeaderSection.swift index cdde10c3..bdc719b5 100644 --- a/MC1/Views/Support/Sections/SupportHeaderSection.swift +++ b/MC1/Views/Support/Sections/SupportHeaderSection.swift @@ -1,14 +1,14 @@ import SwiftUI struct SupportHeaderSection: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - Section { - Text(L10n.Settings.Support.Header.body) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .themedRowBackground(theme) + var body: some View { + Section { + Text(L10n.Settings.Support.Header.body) + .font(.subheadline) + .foregroundStyle(.secondary) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Support/Sections/ThemesPurchaseSection.swift b/MC1/Views/Support/Sections/ThemesPurchaseSection.swift index 82be7da4..a78e478a 100644 --- a/MC1/Views/Support/Sections/ThemesPurchaseSection.swift +++ b/MC1/Views/Support/Sections/ThemesPurchaseSection.swift @@ -1,77 +1,79 @@ -import SwiftUI -import StoreKit import MC1Services +import StoreKit +import SwiftUI struct ThemesPurchaseSection: View { - @Environment(\.appState) private var appState - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.purchase) private var purchase + @Environment(\.appState) private var appState + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.purchase) private var purchase - private var storeState: StoreState { appState.storeState } + private var storeState: StoreState { + appState.storeState + } - private var purchasableThemes: [Theme] { - ThemeRegistry.allThemes.filter { $0.productID != nil } - } - - private var ownsEveryTheme: Bool { - storeState.service.ownedThemeIDs.isSuperset(of: StoreCatalog.Theme.bundledThemeIDs) - } + private var purchasableThemes: [Theme] { + ThemeRegistry.allThemes.filter { $0.productID != nil } + } - private var columns: [GridItem] { - dynamicTypeSize.isAccessibilitySize - ? [GridItem(.flexible())] - : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] - } + private var ownsEveryTheme: Bool { + storeState.service.ownedThemeIDs.isSuperset(of: StoreCatalog.Theme.bundledThemeIDs) + } - var body: some View { - Section { - if ownsEveryTheme { - allUnlockedCard - } else { - LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { - ForEach(purchasableThemes) { theme in - ThemePreviewCard(theme: theme, isOwned: isOwned(theme)) - } - } - .listRowInsets(ThemeCardMetrics.gridRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + private var columns: [GridItem] { + dynamicTypeSize.isAccessibilitySize + ? [GridItem(.flexible())] + : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] + } - ThemeBundleCard( - isPending: storeState.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll, - displayPrice: storeState.service.product(for: StoreCatalog.Theme.bundleAll)?.displayPrice, - onPurchase: { await storeState.purchase(productID: StoreCatalog.Theme.bundleAll) { try await purchase($0) } } - ) - .listRowInsets(ThemeCardMetrics.gridRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - } header: { - Text(L10n.Settings.Support.Themes.title) - } footer: { - if !appState.themeService.availableToCurrentUser().filter({ $0.productID != nil }).isEmpty { - Text(L10n.Settings.Support.Themes.purchasedFooter) - } + var body: some View { + Section { + if ownsEveryTheme { + allUnlockedCard + } else { + LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { + ForEach(purchasableThemes) { theme in + ThemePreviewCard(theme: theme, isOwned: isOwned(theme)) + } } - } + .listRowInsets(ThemeCardMetrics.gridRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) - private var allUnlockedCard: some View { - VStack(spacing: ThemeCardMetrics.allUnlockedSpacing) { - Text(verbatim: "🎉") - .font(.system(size: ThemeCardMetrics.allUnlockedEmojiSize)) - Text(L10n.Settings.Support.Themes.allUnlocked) - .font(.headline) - } - .frame(maxWidth: .infinity) - .padding(.vertical, ThemeCardMetrics.allUnlockedVerticalPadding) + ThemeBundleCard( + isPending: storeState.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll, + displayPrice: storeState.service.product(for: StoreCatalog.Theme.bundleAll)?.displayPrice, + onPurchase: { await storeState.purchase(productID: StoreCatalog.Theme.bundleAll) { try await purchase($0) } } + ) .listRowInsets(ThemeCardMetrics.gridRowInsets) .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .accessibilityElement(children: .combine) + } + } header: { + Text(L10n.Settings.Support.Themes.title) + } footer: { + if !appState.themeService.availableToCurrentUser().filter({ $0.productID != nil }).isEmpty { + Text(L10n.Settings.Support.Themes.purchasedFooter) + } } + } - private func isOwned(_ theme: Theme) -> Bool { - guard let productID = theme.productID else { return true } - return storeState.service.ownedThemeIDs.contains(productID) + private var allUnlockedCard: some View { + VStack(spacing: ThemeCardMetrics.allUnlockedSpacing) { + Text(verbatim: "🎉") + .font(.system(size: ThemeCardMetrics.allUnlockedEmojiSize)) + Text(L10n.Settings.Support.Themes.allUnlocked) + .font(.headline) } + .frame(maxWidth: .infinity) + .padding(.vertical, ThemeCardMetrics.allUnlockedVerticalPadding) + .listRowInsets(ThemeCardMetrics.gridRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .accessibilityElement(children: .combine) + } + + private func isOwned(_ theme: Theme) -> Bool { + guard let productID = theme.productID else { return true } + return storeState.service.ownedThemeIDs.contains(productID) + } } diff --git a/MC1/Views/Support/SupportDevelopmentView.swift b/MC1/Views/Support/SupportDevelopmentView.swift index b750b531..56a488d7 100644 --- a/MC1/Views/Support/SupportDevelopmentView.swift +++ b/MC1/Views/Support/SupportDevelopmentView.swift @@ -1,72 +1,71 @@ -import SwiftUI import MC1Services +import SwiftUI struct SupportDevelopmentView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var hasRetriedThisAppearance = false - @State private var restoreSuccessTrigger = 0 + @State private var hasRetriedThisAppearance = false + @State private var restoreSuccessTrigger = 0 - var body: some View { - // A `@Bindable` local is required to project `$storeState.errorMessage` from the - // environment-owned `@Observable` (a plain computed property cannot supply a Binding). - @Bindable var storeState = appState.storeState + var body: some View { + // A `@Bindable` local is required to project `$storeState.errorMessage` from the + // environment-owned `@Observable` (a plain computed property cannot supply a Binding). + @Bindable var storeState = appState.storeState - return List { - SupportHeaderSection() + return List { + SupportHeaderSection() - if storeState.pendingPurchase != nil { - PendingPurchaseBanner() - } + if storeState.pendingPurchase != nil { + PendingPurchaseBanner() + } - ThemesPurchaseSection() - ContributionsSection() - restoreSection(storeState) - RefundLinkSection() - SupportContactSection() - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Support.title) - .navigationBarTitleDisplayMode(.inline) - .errorAlert($storeState.errorMessage, retryAction: { Task { await storeState.service.load() } }) - .sensoryFeedback(.success, trigger: restoreSuccessTrigger) - .onChange(of: storeState.service.ownedThemeIDs) { - storeState.reconcilePendingPurchase() - } - .onChange(of: storeState.restoreState) { _, newValue in - if newValue == .completed { restoreSuccessTrigger += 1 } - } - .onAppear { - // Clear a pending banner whose entitlement already arrived while this screen was - // off-screen: the `.onChange(of: ownedThemeIDs)` above fires only on a live change, - // not on re-entry, so an Ask-to-Buy approval received elsewhere would otherwise leave - // a stale "Awaiting approval" banner until app close. - storeState.reconcilePendingPurchase() - if storeState.service.loadState == .failed, !hasRetriedThisAppearance { - hasRetriedThisAppearance = true - Task { await storeState.service.load() } - } - } - .onDisappear { hasRetriedThisAppearance = false } + ThemesPurchaseSection() + ContributionsSection() + restoreSection(storeState) + RefundLinkSection() + SupportContactSection() + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Support.title) + .navigationBarTitleDisplayMode(.inline) + .errorAlert($storeState.errorMessage, retryAction: { Task { await storeState.service.load() } }) + .sensoryFeedback(.success, trigger: restoreSuccessTrigger) + .onChange(of: storeState.service.ownedThemeIDs) { + storeState.reconcilePendingPurchase() + } + .onChange(of: storeState.restoreState) { _, newValue in + if newValue == .completed { restoreSuccessTrigger += 1 } + } + .onAppear { + // Clear a pending banner whose entitlement already arrived while this screen was + // off-screen: the `.onChange(of: ownedThemeIDs)` above fires only on a live change, + // not on re-entry, so an Ask-to-Buy approval received elsewhere would otherwise leave + // a stale "Awaiting approval" banner until app close. + storeState.reconcilePendingPurchase() + if storeState.service.loadState == .failed, !hasRetriedThisAppearance { + hasRetriedThisAppearance = true + Task { await storeState.service.load() } + } } + .onDisappear { hasRetriedThisAppearance = false } + } - @ViewBuilder - private func restoreSection(_ storeState: StoreState) -> some View { - Section { - Button { - Task { await storeState.restorePurchases() } - } label: { - HStack { - Text(storeState.restoreState == .syncing - ? L10n.Settings.Support.Restore.syncing - : L10n.Settings.Support.Restore.button) - Spacer() - if storeState.restoreState == .syncing { ProgressView() } - } - } - .disabled(storeState.restoreState == .syncing) + private func restoreSection(_ storeState: StoreState) -> some View { + Section { + Button { + Task { await storeState.restorePurchases() } + } label: { + HStack { + Text(storeState.restoreState == .syncing + ? L10n.Settings.Support.Restore.syncing + : L10n.Settings.Support.Restore.button) + Spacer() + if storeState.restoreState == .syncing { ProgressView() } } - .themedRowBackground(theme) + } + .disabled(storeState.restoreState == .syncing) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Tools/CLI/CLICompletionEngine.swift b/MC1/Views/Tools/CLI/CLICompletionEngine.swift index 1f23f33a..98542c10 100644 --- a/MC1/Views/Tools/CLI/CLICompletionEngine.swift +++ b/MC1/Views/Tools/CLI/CLICompletionEngine.swift @@ -3,254 +3,253 @@ import Foundation @Observable @MainActor final class CLICompletionEngine { + // MARK: - Command Definitions - // MARK: - Command Definitions + private static let builtInCommands = [ + "help", "clear" + ] - private static let builtInCommands = [ - "help", "clear" - ] + /// App-CLI session management; the node CLI is single-session and passes + /// these straight to firmware, which has no such commands. + private static let sessionCommands = [ + "session", "logout" + ] - // App-CLI session management; the node CLI is single-session and passes - // these straight to firmware, which has no such commands. - private static let sessionCommands = [ - "session", "logout" - ] + private static let localOnlyCommands = [ + "login", "nodes", "channels" + ] - private static let localOnlyCommands = [ - "login", "nodes", "channels" - ] + /// Per MeshCore CLI Reference - commands available via remote session + private static let repeaterCommands = [ + "ver", "board", "clock", "clkreboot", + "neighbors", "get", "set", "password", + "log", "reboot", "advert", "advert.zerohop", "setperm", "tempradio", "neighbor.remove", + "region", "gps", "powersaving", "clear", "discover.neighbors", + "start" + ] - // Per MeshCore CLI Reference - commands available via remote session - private static let repeaterCommands = [ - "ver", "board", "clock", "clkreboot", - "neighbors", "get", "set", "password", - "log", "reboot", "advert", "advert.zerohop", "setperm", "tempradio", "neighbor.remove", - "region", "gps", "powersaving", "clear", "discover.neighbors", - "start" - ] + private static let sessionSubcommands = ["list", "local"] - private static let sessionSubcommands = ["list", "local"] + private static let logSubcommands = ["start", "stop", "erase"] - private static let logSubcommands = ["start", "stop", "erase"] + private static let clearSubcommands = ["stats"] - private static let clearSubcommands = ["stats"] + private static let clockSubcommands = ["sync"] - private static let clockSubcommands = ["sync"] + /// Per MeshCore CLI Reference - region subcommands + private static let regionSubcommands = [ + "load", "get", "put", "remove", "allowf", "denyf", "home", "save", "list" + ] - // Per MeshCore CLI Reference - region subcommands - private static let regionSubcommands = [ - "load", "get", "put", "remove", "allowf", "denyf", "home", "save", "list" - ] + /// Per MeshCore CLI Reference - gps subcommands + private static let gpsSubcommands = ["on", "off", "sync", "setloc", "advert"] - // Per MeshCore CLI Reference - gps subcommands - private static let gpsSubcommands = ["on", "off", "sync", "setloc", "advert"] + private static let gpsAdvertValues = ["none", "share", "prefs"] - private static let gpsAdvertValues = ["none", "share", "prefs"] + private static let startSubcommands = ["ota"] - private static let startSubcommands = ["ota"] + private static let regionListValues = ["allowed", "denied"] - private static let regionListValues = ["allowed", "denied"] + private static let onOffValues = ["on", "off"] - private static let onOffValues = ["on", "off"] + private static let multiAcksValues = ["0", "1"] - private static let multiAcksValues = ["0", "1"] + private static let bridgeSourceValues = ["tx", "rx"] - private static let bridgeSourceValues = ["tx", "rx"] + /// Per MeshCore CLI Reference - all get/set parameters + private static let getSetParams = [ + "acl", "name", "radio", "tx", "repeat", "lat", "lon", + "af", "dutycycle", "flood.max", "flood.max.advert", "flood.max.unscoped", + "int.thresh", "agc.reset.interval", + "multi.acks", "advert.interval", "flood.advert.interval", + "guest.password", "allow.read.only", + "rxdelay", "txdelay", "direct.txdelay", + "bridge.enabled", "bridge.delay", "bridge.source", + "bridge.baud", "bridge.secret", "bridge.type", + "adc.multiplier", "public.key", "prv.key", "role", "freq", + "path.hash.mode", "loop.detect", "bootloader.ver", + "owner.info", "radio.rxgain", "bridge.channel", + "pwrmgt.support", "pwrmgt.source", "pwrmgt.bootreason", "pwrmgt.bootmv" + ] - // Per MeshCore CLI Reference - all get/set parameters - private static let getSetParams = [ - "acl", "name", "radio", "tx", "repeat", "lat", "lon", - "af", "dutycycle", "flood.max", "flood.max.advert", "flood.max.unscoped", - "int.thresh", "agc.reset.interval", - "multi.acks", "advert.interval", "flood.advert.interval", - "guest.password", "allow.read.only", - "rxdelay", "txdelay", "direct.txdelay", - "bridge.enabled", "bridge.delay", "bridge.source", - "bridge.baud", "bridge.secret", "bridge.type", - "adc.multiplier", "public.key", "prv.key", "role", "freq", - "path.hash.mode", "loop.detect", "bootloader.ver", - "owner.info", "radio.rxgain", "bridge.channel", - "pwrmgt.support", "pwrmgt.source", "pwrmgt.bootreason", "pwrmgt.bootmv" - ] + // Serial-only params excluded from remote session completions + private static let serialOnlyGetParams: Set = ["prv.key", "acl"] + private static let serialOnlySetParams: Set = ["freq"] - // Serial-only params excluded from remote session completions - private static let serialOnlyGetParams: Set = ["prv.key", "acl"] - private static let serialOnlySetParams: Set = ["freq"] + private static let pathHashModeValues = ["0", "1", "2"] - private static let pathHashModeValues = ["0", "1", "2"] + private static let loopDetectValues = ["off", "minimal", "moderate", "strict"] - private static let loopDetectValues = ["off", "minimal", "moderate", "strict"] + // MARK: - Node Names - // MARK: - Node Names + private(set) var nodeNames: [String] = [] - private(set) var nodeNames: [String] = [] + func updateNodeNames(_ names: [String]) { + nodeNames = names + } - func updateNodeNames(_ names: [String]) { - nodeNames = names - } - - // MARK: - Completion Logic - - func completions(for input: String, isLocal: Bool, includeSessionCommands: Bool = true) -> [String] { - let trimmed = input.trimmingCharacters(in: .whitespaces) + // MARK: - Completion Logic - // Empty or just spaces - return all applicable commands - if trimmed.isEmpty { - return availableCommands(isLocal: isLocal, includeSessionCommands: includeSessionCommands).sorted() - } + func completions(for input: String, isLocal: Bool, includeSessionCommands: Bool = true) -> [String] { + let trimmed = input.trimmingCharacters(in: .whitespaces) - let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false).map(String.init) - let command = parts[0].lowercased() + // Empty or just spaces - return all applicable commands + if trimmed.isEmpty { + return availableCommands(isLocal: isLocal, includeSessionCommands: includeSessionCommands).sorted() + } - // Single word - complete command name - if parts.count == 1 && !input.hasSuffix(" ") { - return availableCommands(isLocal: isLocal, includeSessionCommands: includeSessionCommands) - .filter { $0.hasPrefix(command) } - .sorted() - } + let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false).map(String.init) + let command = parts[0].lowercased() - // Command with space - complete arguments - let argPrefix = parts.count > 1 ? parts[1].lowercased() : "" - let endsWithSpace = input.hasSuffix(" ") - return completeArguments(for: command, parts: parts, prefix: argPrefix, endsWithSpace: endsWithSpace) + // Single word - complete command name + if parts.count == 1 && !input.hasSuffix(" ") { + return availableCommands(isLocal: isLocal, includeSessionCommands: includeSessionCommands) + .filter { $0.hasPrefix(command) } + .sorted() } - private func completeArguments( - for command: String, - parts: [String], - prefix: String, - endsWithSpace: Bool - ) -> [String] { - // Determine which argument position we're completing - // parts.count includes command, so parts.count - 1 = number of args started - // If endsWithSpace, we're starting a NEW argument (position = parts.count) - // If !endsWithSpace, we're still typing the CURRENT argument (position = parts.count - 1) - let argPosition = endsWithSpace ? parts.count : parts.count - 1 - - switch command { - case "session", "login", "log", "powersaving", "clear", "clock", "start": - // 1-arg commands: only complete when argPosition == 1 - guard argPosition == 1 else { return [] } - return completeFirstArg(for: command, prefix: prefix) - - case "get": - if argPosition == 1 { - return Self.getSetParams - .filter { !Self.serialOnlyGetParams.contains($0) } - .filter { $0.hasPrefix(prefix) }.sorted() - } - return [] - - case "set": - if argPosition == 1 { - return Self.getSetParams - .filter { !Self.serialOnlySetParams.contains($0) } - .filter { $0.hasPrefix(prefix) }.sorted() - } - if argPosition == 2, parts.count >= 2 { - return completeSetValue(param: parts[1].lowercased(), prefix: parts.count > 2 ? parts[2].lowercased() : "") - } - return [] - - case "gps": - return completeGpsArgs(argPosition: argPosition, parts: parts, prefix: prefix) - - case "region": - return completeRegionArgs(argPosition: argPosition, parts: parts, prefix: prefix) - - default: - return [] - } + // Command with space - complete arguments + let argPrefix = parts.count > 1 ? parts[1].lowercased() : "" + let endsWithSpace = input.hasSuffix(" ") + return completeArguments(for: command, parts: parts, prefix: argPrefix, endsWithSpace: endsWithSpace) + } + + private func completeArguments( + for command: String, + parts: [String], + prefix: String, + endsWithSpace: Bool + ) -> [String] { + // Determine which argument position we're completing + // parts.count includes command, so parts.count - 1 = number of args started + // If endsWithSpace, we're starting a NEW argument (position = parts.count) + // If !endsWithSpace, we're still typing the CURRENT argument (position = parts.count - 1) + let argPosition = endsWithSpace ? parts.count : parts.count - 1 + + switch command { + case "session", "login", "log", "powersaving", "clear", "clock", "start": + // 1-arg commands: only complete when argPosition == 1 + guard argPosition == 1 else { return [] } + return completeFirstArg(for: command, prefix: prefix) + + case "get": + if argPosition == 1 { + return Self.getSetParams + .filter { !Self.serialOnlyGetParams.contains($0) } + .filter { $0.hasPrefix(prefix) }.sorted() + } + return [] + + case "set": + if argPosition == 1 { + return Self.getSetParams + .filter { !Self.serialOnlySetParams.contains($0) } + .filter { $0.hasPrefix(prefix) }.sorted() + } + if argPosition == 2, parts.count >= 2 { + return completeSetValue(param: parts[1].lowercased(), prefix: parts.count > 2 ? parts[2].lowercased() : "") + } + return [] + + case "gps": + return completeGpsArgs(argPosition: argPosition, parts: parts, prefix: prefix) + + case "region": + return completeRegionArgs(argPosition: argPosition, parts: parts, prefix: prefix) + + default: + return [] } - - private func completeFirstArg(for command: String, prefix: String) -> [String] { - switch command { - case "session": - return completeSessionArgs(prefix: prefix) - case "login": - return completeLoginArgs(prefix: prefix) - case "log": - return Self.logSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case "powersaving": - return Self.onOffValues.filter { $0.hasPrefix(prefix) }.sorted() - case "clear": - return Self.clearSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case "clock": - return Self.clockSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case "start": - return Self.startSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - default: - return [] - } + } + + private func completeFirstArg(for command: String, prefix: String) -> [String] { + switch command { + case "session": + completeSessionArgs(prefix: prefix) + case "login": + completeLoginArgs(prefix: prefix) + case "log": + Self.logSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case "powersaving": + Self.onOffValues.filter { $0.hasPrefix(prefix) }.sorted() + case "clear": + Self.clearSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case "clock": + Self.clockSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case "start": + Self.startSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + default: + [] } + } - private func availableCommands(isLocal: Bool, includeSessionCommands: Bool) -> [String] { - var commands = Self.builtInCommands - - if includeSessionCommands { - commands.append(contentsOf: Self.sessionCommands) - } + private func availableCommands(isLocal: Bool, includeSessionCommands: Bool) -> [String] { + var commands = Self.builtInCommands - if isLocal { - commands.append(contentsOf: Self.localOnlyCommands) - } else { - commands.append(contentsOf: Self.repeaterCommands) - } - - return commands + if includeSessionCommands { + commands.append(contentsOf: Self.sessionCommands) } - private func completeSessionArgs(prefix: String) -> [String] { - var suggestions = Self.sessionSubcommands.filter { $0.hasPrefix(prefix) } - suggestions.append(contentsOf: nodeNames.filter { $0.lowercased().hasPrefix(prefix) }) - return suggestions.sorted() + if isLocal { + commands.append(contentsOf: Self.localOnlyCommands) + } else { + commands.append(contentsOf: Self.repeaterCommands) } - private func completeLoginArgs(prefix: String) -> [String] { - return nodeNames.filter { $0.lowercased().hasPrefix(prefix) }.sorted() + return commands + } + + private func completeSessionArgs(prefix: String) -> [String] { + var suggestions = Self.sessionSubcommands.filter { $0.hasPrefix(prefix) } + suggestions.append(contentsOf: nodeNames.filter { $0.lowercased().hasPrefix(prefix) }) + return suggestions.sorted() + } + + private func completeLoginArgs(prefix: String) -> [String] { + nodeNames.filter { $0.lowercased().hasPrefix(prefix) }.sorted() + } + + private func completeSetValue(param: String, prefix: String) -> [String] { + switch param { + case "path.hash.mode": + Self.pathHashModeValues.filter { $0.hasPrefix(prefix) }.sorted() + case "loop.detect": + Self.loopDetectValues.filter { $0.hasPrefix(prefix) }.sorted() + case "repeat", "allow.read.only", "bridge.enabled", "radio.rxgain": + Self.onOffValues.filter { $0.hasPrefix(prefix) }.sorted() + case "multi.acks": + Self.multiAcksValues.filter { $0.hasPrefix(prefix) }.sorted() + case "bridge.source": + Self.bridgeSourceValues.filter { $0.hasPrefix(prefix) }.sorted() + default: + [] } - - private func completeSetValue(param: String, prefix: String) -> [String] { - switch param { - case "path.hash.mode": - return Self.pathHashModeValues.filter { $0.hasPrefix(prefix) }.sorted() - case "loop.detect": - return Self.loopDetectValues.filter { $0.hasPrefix(prefix) }.sorted() - case "repeat", "allow.read.only", "bridge.enabled", "radio.rxgain": - return Self.onOffValues.filter { $0.hasPrefix(prefix) }.sorted() - case "multi.acks": - return Self.multiAcksValues.filter { $0.hasPrefix(prefix) }.sorted() - case "bridge.source": - return Self.bridgeSourceValues.filter { $0.hasPrefix(prefix) }.sorted() - default: - return [] - } + } + + private func completeRegionArgs(argPosition: Int, parts: [String], prefix: String) -> [String] { + switch argPosition { + case 1: + return Self.regionSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case 2 where parts.count >= 2 && parts[1].lowercased() == "list": + let valuePrefix = parts.count > 2 ? parts[2].lowercased() : "" + return Self.regionListValues.filter { $0.hasPrefix(valuePrefix) }.sorted() + default: + return [] } - - private func completeRegionArgs(argPosition: Int, parts: [String], prefix: String) -> [String] { - switch argPosition { - case 1: - return Self.regionSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case 2 where parts.count >= 2 && parts[1].lowercased() == "list": - let valuePrefix = parts.count > 2 ? parts[2].lowercased() : "" - return Self.regionListValues.filter { $0.hasPrefix(valuePrefix) }.sorted() - default: - return [] - } - } - - private func completeGpsArgs(argPosition: Int, parts: [String], prefix: String) -> [String] { - switch argPosition { - case 1: - // First argument: subcommand - return Self.gpsSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case 2 where parts.count >= 2 && parts[1].lowercased() == "advert": - // Second argument for "gps advert": value - let valuePrefix = parts.count > 2 ? parts[2].lowercased() : "" - return Self.gpsAdvertValues.filter { $0.hasPrefix(valuePrefix) }.sorted() - default: - // Command complete or non-advert subcommand (no second arg) - return [] - } + } + + private func completeGpsArgs(argPosition: Int, parts: [String], prefix: String) -> [String] { + switch argPosition { + case 1: + // First argument: subcommand + return Self.gpsSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case 2 where parts.count >= 2 && parts[1].lowercased() == "advert": + // Second argument for "gps advert": value + let valuePrefix = parts.count > 2 ? parts[2].lowercased() : "" + return Self.gpsAdvertValues.filter { $0.hasPrefix(valuePrefix) }.sorted() + default: + // Command complete or non-advert subcommand (no second arg) + return [] } + } } diff --git a/MC1/Views/Tools/CLI/CLIInputAccessoryView.swift b/MC1/Views/Tools/CLI/CLIInputAccessoryView.swift index 4fdb0113..005629e4 100644 --- a/MC1/Views/Tools/CLI/CLIInputAccessoryView.swift +++ b/MC1/Views/Tools/CLI/CLIInputAccessoryView.swift @@ -1,114 +1,114 @@ import SwiftUI struct CLIInputAccessoryView: View { - let isWaiting: Bool - var showSessionsButton: Bool = true - let onHistoryUp: () -> Void - let onHistoryDown: () -> Void - let onTabComplete: () -> Void - let onMoveLeft: () -> Void - let onMoveRight: () -> Void - let onPaste: () -> Void - let onSessions: () -> Void - let onCancel: () -> Void - let onDismiss: () -> Void + let isWaiting: Bool + var showSessionsButton: Bool = true + let onHistoryUp: () -> Void + let onHistoryDown: () -> Void + let onTabComplete: () -> Void + let onMoveLeft: () -> Void + let onMoveRight: () -> Void + let onPaste: () -> Void + let onSessions: () -> Void + let onCancel: () -> Void + let onDismiss: () -> Void - @State private var showCancel = false + @State private var showCancel = false - var body: some View { - HStack(spacing: 0) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - Button(action: onTabComplete) { - Image(systemName: "arrow.right.to.line") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.tabComplete) + var body: some View { + HStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + Button(action: onTabComplete) { + Image(systemName: "arrow.right.to.line") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.tabComplete) - Button(action: onHistoryUp) { - Image(systemName: "arrow.up") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.historyUp) + Button(action: onHistoryUp) { + Image(systemName: "arrow.up") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.historyUp) - Button(action: onHistoryDown) { - Image(systemName: "arrow.down") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.historyDown) + Button(action: onHistoryDown) { + Image(systemName: "arrow.down") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.historyDown) - Button(action: onMoveLeft) { - Image(systemName: "arrow.left") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.cursorLeft) + Button(action: onMoveLeft) { + Image(systemName: "arrow.left") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.cursorLeft) - Button(action: onMoveRight) { - Image(systemName: "arrow.right") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.cursorRight) + Button(action: onMoveRight) { + Image(systemName: "arrow.right") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.cursorRight) - Button(action: onPaste) { - Image(systemName: "doc.on.clipboard") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.paste) + Button(action: onPaste) { + Image(systemName: "doc.on.clipboard") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.paste) - if showSessionsButton { - Divider() - .frame(height: 20) + if showSessionsButton { + Divider() + .frame(height: 20) - Button(action: onSessions) { - Image(systemName: "rectangle.stack") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.sessions) - } - - Button(action: onCancel) { - Image(systemName: "stop.circle") - .foregroundStyle(showCancel ? .red : .secondary) - } - .disabled(!showCancel) - .accessibilityLabel(L10n.Tools.Tools.Cli.cancelOperation) - } - .font(.body) + Button(action: onSessions) { + Image(systemName: "rectangle.stack") } + .accessibilityLabel(L10n.Tools.Tools.Cli.sessions) + } - Spacer() - - Button(action: onDismiss) { - Image(systemName: "keyboard.chevron.compact.down") - } - .font(.body) - .accessibilityLabel(L10n.Tools.Tools.Cli.dismiss) + Button(action: onCancel) { + Image(systemName: "stop.circle") + .foregroundStyle(showCancel ? .red : .secondary) + } + .disabled(!showCancel) + .accessibilityLabel(L10n.Tools.Tools.Cli.cancelOperation) } - .padding(.horizontal) - .frame(height: 44) - .task(id: isWaiting) { - if isWaiting { - try? await Task.sleep(for: .milliseconds(150)) - showCancel = true - } else { - showCancel = false - } - } - .background { - if #available(iOS 26.0, *) { - Rectangle().fill(.clear).glassEffect() - } else { - Rectangle().fill(.ultraThinMaterial) - } - } - .padding(.horizontal, 4) + .font(.body) + } + + Spacer() + + Button(action: onDismiss) { + Image(systemName: "keyboard.chevron.compact.down") + } + .font(.body) + .accessibilityLabel(L10n.Tools.Tools.Cli.dismiss) + } + .padding(.horizontal) + .frame(height: 44) + .task(id: isWaiting) { + if isWaiting { + try? await Task.sleep(for: .milliseconds(150)) + showCancel = true + } else { + showCancel = false + } + } + .background { + if #available(iOS 26.0, *) { + Rectangle().fill(.clear).glassEffect() + } else { + Rectangle().fill(.ultraThinMaterial) + } } + .padding(.horizontal, 4) + } } #Preview { - CLIInputAccessoryView( - isWaiting: false, - onHistoryUp: {}, - onHistoryDown: {}, - onTabComplete: {}, - onMoveLeft: {}, - onMoveRight: {}, - onPaste: {}, - onSessions: {}, - onCancel: {}, - onDismiss: {} - ) + CLIInputAccessoryView( + isWaiting: false, + onHistoryUp: {}, + onHistoryDown: {}, + onTabComplete: {}, + onMoveLeft: {}, + onMoveRight: {}, + onPaste: {}, + onSessions: {}, + onCancel: {}, + onDismiss: {} + ) } diff --git a/MC1/Views/Tools/CLI/CLIOutputLine.swift b/MC1/Views/Tools/CLI/CLIOutputLine.swift index 22cc5b22..f67ade9e 100644 --- a/MC1/Views/Tools/CLI/CLIOutputLine.swift +++ b/MC1/Views/Tools/CLI/CLIOutputLine.swift @@ -1,24 +1,24 @@ import SwiftUI -enum CLIOutputType: Sendable { - case command // User-entered command (echoed) - case success // Success/acknowledgment - case error // Error message - case response // Node response data +enum CLIOutputType { + case command // User-entered command (echoed) + case success // Success/acknowledgment + case error // Error message + case response // Node response data - var color: Color { - switch self { - case .command: .secondary - case .success: .green - case .error: .orange - case .response: .primary - } + var color: Color { + switch self { + case .command: .secondary + case .success: .green + case .error: .orange + case .response: .primary } + } } -struct CLIOutputLine: Identifiable, Sendable { - let id = UUID() - let text: String - let type: CLIOutputType - let timestamp = Date() +struct CLIOutputLine: Identifiable { + let id = UUID() + let text: String + let type: CLIOutputType + let timestamp = Date() } diff --git a/MC1/Views/Tools/CLI/CLISession.swift b/MC1/Views/Tools/CLI/CLISession.swift index 38264b2d..43425677 100644 --- a/MC1/Views/Tools/CLI/CLISession.swift +++ b/MC1/Views/Tools/CLI/CLISession.swift @@ -1,16 +1,16 @@ import Foundation -struct CLISession: Identifiable, Hashable, Sendable { - let id: UUID - let name: String - let isLocal: Bool - let pathLength: UInt8 +struct CLISession: Identifiable, Hashable { + let id: UUID + let name: String + let isLocal: Bool + let pathLength: UInt8 - static func local(deviceName: String) -> CLISession { - CLISession(id: UUID(), name: deviceName, isLocal: true, pathLength: 0) - } + static func local(deviceName: String) -> CLISession { + CLISession(id: UUID(), name: deviceName, isLocal: true, pathLength: 0) + } - static func remote(id: UUID, name: String, pathLength: UInt8) -> CLISession { - CLISession(id: id, name: name, isLocal: false, pathLength: pathLength) - } + static func remote(id: UUID, name: String, pathLength: UInt8) -> CLISession { + CLISession(id: id, name: name, isLocal: false, pathLength: pathLength) + } } diff --git a/MC1/Views/Tools/CLI/CLITerminalView.swift b/MC1/Views/Tools/CLI/CLITerminalView.swift index c5d357c8..1374d861 100644 --- a/MC1/Views/Tools/CLI/CLITerminalView.swift +++ b/MC1/Views/Tools/CLI/CLITerminalView.swift @@ -8,258 +8,265 @@ private let terminalFont = Font.caption.monospaced() /// Holds no view model: all state arrives as values/bindings and all events /// leave via callbacks, so each host wires its own view model. struct CLITerminalView: View { - let outputLines: [CLIOutputLine] - let promptText: String - let ghostText: String - let tabSuggestions: [String]? - let tabSelectionIndex: Int? - let isWaitingForResponse: Bool - let showSessionsButton: Bool + let outputLines: [CLIOutputLine] + let promptText: String + let ghostText: String + let tabSuggestions: [String]? + let tabSelectionIndex: Int? + let isWaitingForResponse: Bool + let showSessionsButton: Bool - @Binding var currentInput: String - @Binding var isKeyboardFocused: Bool - @Binding var scrollPosition: ScrollPosition - @Binding var cursorPosition: Int + @Binding var currentInput: String + @Binding var isKeyboardFocused: Bool + @Binding var scrollPosition: ScrollPosition + @Binding var cursorPosition: Int - let onSubmit: () -> Void - let onHistoryUp: () -> Void - let onHistoryDown: () -> Void - let onRightArrowAtEnd: () -> Void - let onTabComplete: () -> Void - let onMoveLeft: () -> Void - let onMoveRight: () -> Void - let onPaste: () -> Void - let onSessions: () -> Void - let onCancel: () -> Void - let onDismiss: () -> Void - let onClear: () -> Void - let onUpdateGhostText: (_ cursorAtEnd: Bool) -> Void - let onClearTabState: () -> Void - let onGetResponseBlock: (CLIOutputLine) -> String + let onSubmit: () -> Void + let onHistoryUp: () -> Void + let onHistoryDown: () -> Void + let onRightArrowAtEnd: () -> Void + let onTabComplete: () -> Void + let onMoveLeft: () -> Void + let onMoveRight: () -> Void + let onPaste: () -> Void + let onSessions: () -> Void + let onCancel: () -> Void + let onDismiss: () -> Void + let onClear: () -> Void + let onUpdateGhostText: (_ cursorAtEnd: Bool) -> Void + let onClearTabState: () -> Void + let onGetResponseBlock: (CLIOutputLine) -> String - var body: some View { - ZStack(alignment: .bottomTrailing) { - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(outputLines) { line in - Text(line.text) - .font(terminalFont) - .foregroundStyle(line.type.color) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .id(line.id) - .contextMenu { - Button { - UIPasteboard.general.string = onGetResponseBlock(line) - } label: { - Label(L10n.Tools.Tools.RxLog.copy, systemImage: "doc.on.doc") - } - } - } - - inlinePrompt - .id("prompt") - - if let suggestions = tabSuggestions { - let columns = [GridItem(.adaptive(minimum: 120), alignment: .leading)] - LazyVGrid(columns: columns, alignment: .leading, spacing: 4) { - ForEach(Array(suggestions.enumerated()), id: \.offset) { index, suggestion in - Text(suggestion) - .font(terminalFont) - .foregroundStyle(index == tabSelectionIndex ? .primary : .secondary) - .accessibilityAddTraits(index == tabSelectionIndex ? .isSelected : []) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background { - if index == tabSelectionIndex { - RoundedRectangle(cornerRadius: 4) - .fill(Color.accentColor.opacity(0.3)) - } - } - } - } - .id("suggestions") - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - .padding(.top, 8) - .padding(.bottom, 8) - } - .scrollIndicators(.hidden) - .scrollDismissesKeyboard(.never) - .scrollPosition($scrollPosition) - .onChange(of: outputLines.count) { _, _ in - scrollPosition.scrollTo(edge: .bottom) - } - .onChange(of: isKeyboardFocused) { _, focused in - if focused { - Task { - try? await Task.sleep(for: .milliseconds(100)) - scrollPosition.scrollTo(edge: .bottom) - } - } - } - .onChange(of: currentInput) { _, _ in - onUpdateGhostText(cursorAtEnd) - onClearTabState() - } - .onChange(of: cursorPosition) { _, _ in - onUpdateGhostText(cursorAtEnd) - } - .onChange(of: tabSuggestions) { _, newSuggestions in - if newSuggestions != nil { - scrollPosition.scrollTo(edge: .bottom) + var body: some View { + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(outputLines) { line in + Text(line.text) + .font(terminalFont) + .foregroundStyle(line.type.color) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .id(line.id) + .contextMenu { + Button { + UIPasteboard.general.string = onGetResponseBlock(line) + } label: { + Label(L10n.Tools.Tools.RxLog.copy, systemImage: "doc.on.doc") } - } + } + } - HiddenTextViewFocusable( - text: $currentInput, - isFocused: $isKeyboardFocused, - cursorPosition: $cursorPosition, - onSubmit: onSubmit, - onHistoryUp: onHistoryUp, - onHistoryDown: onHistoryDown, - onRightArrowAtEnd: onRightArrowAtEnd, - onTabComplete: onTabComplete - ) - .frame(width: 1, height: 1) - .opacity(0.01) + inlinePrompt + .id("prompt") - if scrollPosition.isPositionedByUser { - CLIScrollToBottomButton { - scrollPosition.scrollTo(edge: .bottom) - } - } - } - .background(Color(.secondarySystemBackground)) - .contentShape(.rect) - // onTapGesture is intentional: a Button in .background can't receive - // taps through the ScrollView, and this is a non-semantic "tap anywhere - // to focus keyboard" gesture, not a discrete button action. - .onTapGesture { - isKeyboardFocused = true - } - .safeAreaInset(edge: .bottom) { - if isKeyboardFocused { - CLIInputAccessoryView( - isWaiting: isWaitingForResponse, - showSessionsButton: showSessionsButton, - onHistoryUp: onHistoryUp, - onHistoryDown: onHistoryDown, - onTabComplete: onTabComplete, - onMoveLeft: onMoveLeft, - onMoveRight: onMoveRight, - onPaste: onPaste, - onSessions: onSessions, - onCancel: onCancel, - onDismiss: onDismiss - ) - } - } - .onKeyPress(.upArrow) { - onHistoryUp() - return .handled - } - .onKeyPress(.downArrow) { - onHistoryDown() - return .handled - } - .onKeyPress(.tab, phases: [.down]) { _ in - onTabComplete() - return .handled - } - .onKeyPress(.escape) { - if tabSelectionIndex != nil { - onClearTabState() - return .handled - } - if isWaitingForResponse { - onCancel() - } else { - isKeyboardFocused = false + if let suggestions = tabSuggestions { + let columns = [GridItem(.adaptive(minimum: 120), alignment: .leading)] + LazyVGrid(columns: columns, alignment: .leading, spacing: 4) { + ForEach(Array(suggestions.enumerated()), id: \.offset) { index, suggestion in + Text(suggestion) + .font(terminalFont) + .foregroundStyle(index == tabSelectionIndex ? .primary : .secondary) + .accessibilityAddTraits(index == tabSelectionIndex ? .isSelected : []) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background { + if index == tabSelectionIndex { + RoundedRectangle(cornerRadius: 4) + .fill(Color.accentColor.opacity(0.3)) + } + } + } } - return .handled + .id("suggestions") + } } - .onKeyPress(phases: [.down]) { keyPress in - if keyPress.key == "k" && keyPress.modifiers.contains(.command) { - onClear() - return .handled - } - return .ignored + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.top, 8) + .padding(.bottom, 8) + } + .scrollIndicators(.hidden) + .scrollDismissesKeyboard(.never) + .scrollPosition($scrollPosition) + .onChange(of: outputLines.count) { _, _ in + scrollPosition.scrollTo(edge: .bottom) + } + .onChange(of: isKeyboardFocused) { _, focused in + if focused { + Task { + try? await Task.sleep(for: .milliseconds(100)) + scrollPosition.scrollTo(edge: .bottom) + } } - .onAppear { - isKeyboardFocused = true + } + .onChange(of: currentInput) { _, _ in + onUpdateGhostText(cursorAtEnd) + onClearTabState() + } + .onChange(of: cursorPosition) { _, _ in + onUpdateGhostText(cursorAtEnd) + } + .onChange(of: tabSuggestions) { _, newSuggestions in + if newSuggestions != nil { + scrollPosition.scrollTo(edge: .bottom) } - .onDisappear { - // Clear the focus request when leaving so the gated accessory bar - // can't persist across navigation and re-mount on return. - isKeyboardFocused = false + } + + HiddenTextViewFocusable( + text: $currentInput, + isFocused: $isKeyboardFocused, + cursorPosition: $cursorPosition, + onSubmit: onSubmit, + onHistoryUp: onHistoryUp, + onHistoryDown: onHistoryDown, + onRightArrowAtEnd: onRightArrowAtEnd, + onTabComplete: onTabComplete + ) + .frame(width: 1, height: 1) + .opacity(0.01) + + if scrollPosition.isPositionedByUser { + CLIScrollToBottomButton { + scrollPosition.scrollTo(edge: .bottom) } + } + } + .background(Color(.secondarySystemBackground)) + .contentShape(.rect) + // onTapGesture is intentional: a Button in .background can't receive + // taps through the ScrollView, and this is a non-semantic "tap anywhere + // to focus keyboard" gesture, not a discrete button action. + .onTapGesture { + isKeyboardFocused = true + } + .safeAreaInset(edge: .bottom) { + if isKeyboardFocused { + CLIInputAccessoryView( + isWaiting: isWaitingForResponse, + showSessionsButton: showSessionsButton, + onHistoryUp: onHistoryUp, + onHistoryDown: onHistoryDown, + onTabComplete: onTabComplete, + onMoveLeft: onMoveLeft, + onMoveRight: onMoveRight, + onPaste: onPaste, + onSessions: onSessions, + onCancel: onCancel, + onDismiss: onDismiss + ) + .padding(.bottom, { + if #available(iOS 26.0, *) { + 8 + } else { + 0 + } + }()) + } + } + .onKeyPress(.upArrow) { + onHistoryUp() + return .handled + } + .onKeyPress(.downArrow) { + onHistoryDown() + return .handled + } + .onKeyPress(.tab, phases: [.down]) { _ in + onTabComplete() + return .handled + } + .onKeyPress(.escape) { + if tabSelectionIndex != nil { + onClearTabState() + return .handled + } + if isWaitingForResponse { + onCancel() + } else { + isKeyboardFocused = false + } + return .handled } + .onKeyPress(phases: [.down]) { keyPress in + if keyPress.key == "k", keyPress.modifiers.contains(.command) { + onClear() + return .handled + } + return .ignored + } + .onAppear { + isKeyboardFocused = true + } + .onDisappear { + // Clear the focus request when leaving so the gated accessory bar + // can't persist across navigation and re-mount on return. + isKeyboardFocused = false + } + } - private var inlinePrompt: some View { - HStack(spacing: 0) { - if !isWaitingForResponse { - Text(promptText) - .font(terminalFont) - .foregroundStyle(.primary) - .accessibilityLabel(L10n.Tools.Tools.Cli.commandPrompt) - .accessibilityValue(promptText) + private var inlinePrompt: some View { + HStack(spacing: 0) { + if !isWaitingForResponse { + Text(promptText) + .font(terminalFont) + .foregroundStyle(.primary) + .accessibilityLabel(L10n.Tools.Tools.Cli.commandPrompt) + .accessibilityValue(promptText) - Text(textBeforeCursor) - .font(terminalFont) - .accessibilityLabel(L10n.Tools.Tools.Cli.commandInput) + Text(textBeforeCursor) + .font(terminalFont) + .accessibilityLabel(L10n.Tools.Tools.Cli.commandInput) - if isKeyboardFocused { - Rectangle() - .fill(Color.primary) - .frame(width: 2, height: 14) - } + if isKeyboardFocused { + Rectangle() + .fill(Color.primary) + .frame(width: 2, height: 14) + } - Text(textAfterCursor) - .font(terminalFont) + Text(textAfterCursor) + .font(terminalFont) - if cursorAtEnd { - Text(ghostText) - .font(terminalFont) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - } else { - Rectangle() - .fill(Color.primary) - .frame(width: 8, height: 14) - } + if cursorAtEnd { + Text(ghostText) + .font(terminalFont) + .foregroundStyle(.secondary) + .accessibilityHidden(true) } + } else { + Rectangle() + .fill(Color.primary) + .frame(width: 8, height: 14) + } } + } - private var textBeforeCursor: String { - let index = currentInput.index(currentInput.startIndex, offsetBy: min(cursorPosition, currentInput.count)) - return String(currentInput[..= currentInput.count - } + private var cursorAtEnd: Bool { + cursorPosition >= currentInput.count + } } private struct CLIScrollToBottomButton: View { - let action: () -> Void + let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: "arrow.down.circle.fill") - .font(.title) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.primary) - } - .accessibilityLabel(L10n.Tools.Tools.Cli.jumpToBottom) - .padding() + var body: some View { + Button(action: action) { + Image(systemName: "arrow.down.circle.fill") + .font(.title) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.primary) } + .accessibilityLabel(L10n.Tools.Tools.Cli.jumpToBottom) + .padding() + } } diff --git a/MC1/Views/Tools/CLI/CLIToolView.swift b/MC1/Views/Tools/CLI/CLIToolView.swift index e5df3af4..b3fc1031 100644 --- a/MC1/Views/Tools/CLI/CLIToolView.swift +++ b/MC1/Views/Tools/CLI/CLIToolView.swift @@ -1,153 +1,153 @@ -import SwiftUI import MC1Services +import SwiftUI import UIKit struct CLIToolView: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var isKeyboardFocused = false - @State private var scrollPosition = ScrollPosition(edge: .bottom) - @State private var cursorPosition: Int = 0 - - var body: some View { - content - .onAppear { - if appState.cliToolViewModel == nil { - appState.cliToolViewModel = CLIToolViewModel() - } else { - // Restore cursor to end of existing input when returning to CLI - cursorPosition = appState.cliToolViewModel?.currentInput.count ?? 0 - } - } - } + @State private var isKeyboardFocused = false + @State private var scrollPosition = ScrollPosition(edge: .bottom) + @State private var cursorPosition: Int = 0 - @ViewBuilder - private var content: some View { - if let viewModel = appState.cliToolViewModel { - CLIToolContent( - viewModel: viewModel, - appState: appState, - isKeyboardFocused: $isKeyboardFocused, - scrollPosition: $scrollPosition, - cursorPosition: $cursorPosition - ) + var body: some View { + content + .onAppear { + if appState.cliToolViewModel == nil { + appState.cliToolViewModel = CLIToolViewModel() } else { - ProgressView() + // Restore cursor to end of existing input when returning to CLI + cursorPosition = appState.cliToolViewModel?.currentInput.count ?? 0 } + } + } + + @ViewBuilder + private var content: some View { + if let viewModel = appState.cliToolViewModel { + CLIToolContent( + viewModel: viewModel, + appState: appState, + isKeyboardFocused: $isKeyboardFocused, + scrollPosition: $scrollPosition, + cursorPosition: $cursorPosition + ) + } else { + ProgressView() } + } } private struct CLIToolContent: View { - @Bindable var viewModel: CLIToolViewModel - let appState: AppState - @Binding var isKeyboardFocused: Bool - @Binding var scrollPosition: ScrollPosition - @Binding var cursorPosition: Int + @Bindable var viewModel: CLIToolViewModel + let appState: AppState + @Binding var isKeyboardFocused: Bool + @Binding var scrollPosition: ScrollPosition + @Binding var cursorPosition: Int - var body: some View { - Group { - if appState.services?.repeaterAdminService == nil { - disconnectedState - } else { - terminalView - } - } - .navigationTitle(L10n.Tools.Tools.cli) - .navigationBarTitleDisplayMode(.inline) - .liquidGlassToolbarBackground() - .task(id: appState.servicesVersion) { - viewModel.configure( - repeaterAdminService: { [appState] in appState.services?.repeaterAdminService }, - remoteNodeService: { [appState] in appState.services?.remoteNodeService }, - dataStore: { [appState] in appState.services?.dataStore }, - radioID: { [appState] in appState.connectedDevice?.radioID }, - localDeviceName: appState.connectedDevice?.nodeName ?? L10n.Tools.Tools.Cli.defaultDevice - ) - } + var body: some View { + Group { + if appState.services?.repeaterAdminService == nil { + disconnectedState + } else { + terminalView + } + } + .navigationTitle(L10n.Tools.Tools.cli) + .navigationBarTitleDisplayMode(.inline) + .liquidGlassToolbarBackground() + .task(id: appState.servicesVersion) { + viewModel.configure( + repeaterAdminService: { [appState] in appState.services?.repeaterAdminService }, + remoteNodeService: { [appState] in appState.services?.remoteNodeService }, + dataStore: { [appState] in appState.services?.dataStore }, + radioID: { [appState] in appState.connectedDevice?.radioID }, + localDeviceName: appState.connectedDevice?.nodeName ?? L10n.Tools.Tools.Cli.defaultDevice + ) } + } - // MARK: - Disconnected State + // MARK: - Disconnected State - private var disconnectedState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.Cli.notConnected, systemImage: "terminal") - } description: { - Text(L10n.Tools.Tools.Cli.notConnectedDescription) - } + private var disconnectedState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.Cli.notConnected, systemImage: "terminal") + } description: { + Text(L10n.Tools.Tools.Cli.notConnectedDescription) } + } - // MARK: - Terminal View + // MARK: - Terminal View - private var terminalView: some View { - CLITerminalView( - outputLines: viewModel.outputLines, - promptText: viewModel.promptText, - ghostText: viewModel.ghostText, - tabSuggestions: viewModel.tabSuggestions, - tabSelectionIndex: viewModel.tabSelectionIndex, - isWaitingForResponse: viewModel.isWaitingForResponse, - showSessionsButton: true, - currentInput: $viewModel.currentInput, - isKeyboardFocused: $isKeyboardFocused, - scrollPosition: $scrollPosition, - cursorPosition: $cursorPosition, - onSubmit: { - if viewModel.applySelectedSuggestion() { - cursorPosition = viewModel.currentInput.count - } else { - viewModel.executeCommand(viewModel.currentInput) - } - }, - onHistoryUp: { - viewModel.historyUp() - cursorPosition = viewModel.currentInput.count - }, - onHistoryDown: { - viewModel.historyDown() - cursorPosition = viewModel.currentInput.count - }, - onRightArrowAtEnd: { - if !viewModel.ghostText.isEmpty { - viewModel.acceptGhostText() - cursorPosition = viewModel.currentInput.count - } - }, - onTabComplete: { - viewModel.tabComplete() - cursorPosition = viewModel.currentInput.count - }, - onMoveLeft: { - if cursorPosition > 0 { cursorPosition -= 1 } - }, - onMoveRight: { - if !viewModel.ghostText.isEmpty && cursorPosition >= viewModel.currentInput.count { - viewModel.acceptGhostText() - cursorPosition = viewModel.currentInput.count - } else if cursorPosition < viewModel.currentInput.count { - cursorPosition += 1 - } - }, - onPaste: { - viewModel.pasteFromClipboard(at: cursorPosition) - cursorPosition = min( - cursorPosition + (UIPasteboard.general.string?.count ?? 0), - viewModel.currentInput.count - ) - }, - onSessions: { viewModel.executeCommand("session list") }, - onCancel: { viewModel.cancelCurrentCommand() }, - onDismiss: { isKeyboardFocused = false }, - onClear: { viewModel.executeCommand("clear") }, - onUpdateGhostText: { cursorAtEnd in viewModel.updateGhostText(cursorAtEnd: cursorAtEnd) }, - onClearTabState: { viewModel.clearTabState() }, - onGetResponseBlock: { viewModel.getResponseBlock(containing: $0) } + private var terminalView: some View { + CLITerminalView( + outputLines: viewModel.outputLines, + promptText: viewModel.promptText, + ghostText: viewModel.ghostText, + tabSuggestions: viewModel.tabSuggestions, + tabSelectionIndex: viewModel.tabSelectionIndex, + isWaitingForResponse: viewModel.isWaitingForResponse, + showSessionsButton: true, + currentInput: $viewModel.currentInput, + isKeyboardFocused: $isKeyboardFocused, + scrollPosition: $scrollPosition, + cursorPosition: $cursorPosition, + onSubmit: { + if viewModel.applySelectedSuggestion() { + cursorPosition = viewModel.currentInput.count + } else { + viewModel.executeCommand(viewModel.currentInput) + } + }, + onHistoryUp: { + viewModel.historyUp() + cursorPosition = viewModel.currentInput.count + }, + onHistoryDown: { + viewModel.historyDown() + cursorPosition = viewModel.currentInput.count + }, + onRightArrowAtEnd: { + if !viewModel.ghostText.isEmpty { + viewModel.acceptGhostText() + cursorPosition = viewModel.currentInput.count + } + }, + onTabComplete: { + viewModel.tabComplete() + cursorPosition = viewModel.currentInput.count + }, + onMoveLeft: { + if cursorPosition > 0 { cursorPosition -= 1 } + }, + onMoveRight: { + if !viewModel.ghostText.isEmpty, cursorPosition >= viewModel.currentInput.count { + viewModel.acceptGhostText() + cursorPosition = viewModel.currentInput.count + } else if cursorPosition < viewModel.currentInput.count { + cursorPosition += 1 + } + }, + onPaste: { + viewModel.pasteFromClipboard(at: cursorPosition) + cursorPosition = min( + cursorPosition + (UIPasteboard.general.string?.count ?? 0), + viewModel.currentInput.count ) - } + }, + onSessions: { viewModel.executeCommand("session list") }, + onCancel: { viewModel.cancelCurrentCommand() }, + onDismiss: { isKeyboardFocused = false }, + onClear: { viewModel.executeCommand("clear") }, + onUpdateGhostText: { cursorAtEnd in viewModel.updateGhostText(cursorAtEnd: cursorAtEnd) }, + onClearTabState: { viewModel.clearTabState() }, + onGetResponseBlock: { viewModel.getResponseBlock(containing: $0) } + ) + } } #Preview { - NavigationStack { - CLIToolView() - } - .environment(\.appState, AppState()) + NavigationStack { + CLIToolView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/CLI/CLIToolViewModel+Completion.swift b/MC1/Views/Tools/CLI/CLIToolViewModel+Completion.swift index 599ad4f8..6e42e445 100644 --- a/MC1/Views/Tools/CLI/CLIToolViewModel+Completion.swift +++ b/MC1/Views/Tools/CLI/CLIToolViewModel+Completion.swift @@ -4,134 +4,133 @@ import OSLog // MARK: - Ghost Text Completion extension CLIToolViewModel { - - /// Updates ghost text based on current input. - /// - Parameter cursorAtEnd: Whether cursor is at end of input. Ghost text only shows when true. - func updateGhostText(cursorAtEnd: Bool) { - // No ghost text during password entry - guard pendingLoginContact == nil else { - ghostText = "" - return - } - - // No ghost text for empty input or cursor not at end - guard !currentInput.isEmpty, cursorAtEnd else { - ghostText = "" - return - } - - let isLocal = activeSession?.isLocal ?? true - let suggestions = completionEngine.completions(for: currentInput, isLocal: isLocal) - - guard let first = suggestions.first else { - ghostText = "" - return - } - - // Extract suffix: if input is "hel" and match is "help", ghost is "p" - // Handle argument completion: "session li" -> first might be "list" - let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) - let lastPart = parts.last.map(String.init) ?? "" - - if first.lowercased().hasPrefix(lastPart.lowercased()) { - ghostText = String(first.dropFirst(lastPart.count)) - } else { - ghostText = "" - } + /// Updates ghost text based on current input. + /// - Parameter cursorAtEnd: Whether cursor is at end of input. Ghost text only shows when true. + func updateGhostText(cursorAtEnd: Bool) { + // No ghost text during password entry + guard pendingLoginContact == nil else { + ghostText = "" + return } - /// Accepts the current ghost text, appending it to input. - func acceptGhostText() { - guard !ghostText.isEmpty else { return } - currentInput += ghostText - ghostText = "" + // No ghost text for empty input or cursor not at end + guard !currentInput.isEmpty, cursorAtEnd else { + ghostText = "" + return } - /// Handles tab press for completion. - /// - Returns: Array of suggestions if multiple matches, nil otherwise - @discardableResult - func tabComplete() -> [String]? { - guard pendingLoginContact == nil else { return nil } - - // If already showing suggestions, cycle selection - if let suggestions = tabSuggestions, !suggestions.isEmpty { - if let currentIndex = tabSelectionIndex { - tabSelectionIndex = (currentIndex + 1) % suggestions.count - } else { - tabSelectionIndex = 0 - } - return suggestions - } - - // Generate new suggestions - let isLocal = activeSession?.isLocal ?? true - let suggestions = completionEngine.completions(for: currentInput, isLocal: isLocal) - - guard !suggestions.isEmpty else { - tabSuggestions = nil - tabSelectionIndex = nil - return nil - } - - if suggestions.count == 1 { - applyCompletion(suggestions[0]) - return nil - } - - tabSuggestions = suggestions - tabSelectionIndex = nil - return suggestions - } + let isLocal = activeSession?.isLocal ?? true + let suggestions = completionEngine.completions(for: currentInput, isLocal: isLocal) - /// Applies the selected suggestion if in selection mode. - /// - Returns: true if suggestion was applied, false if not in selection mode - func applySelectedSuggestion() -> Bool { - guard let suggestions = tabSuggestions, - let index = tabSelectionIndex, - index < suggestions.count else { - return false - } - applyCompletion(suggestions[index]) - clearTabState() - return true + guard let first = suggestions.first else { + ghostText = "" + return } - /// Clears tab completion state (suggestions and selection). - func clearTabState() { - tabSuggestions = nil - tabSelectionIndex = nil + // Extract suffix: if input is "hel" and match is "help", ghost is "p" + // Handle argument completion: "session li" -> first might be "list" + let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) + let lastPart = parts.last.map(String.init) ?? "" + + if first.lowercased().hasPrefix(lastPart.lowercased()) { + ghostText = String(first.dropFirst(lastPart.count)) + } else { + ghostText = "" + } + } + + /// Accepts the current ghost text, appending it to input. + func acceptGhostText() { + guard !ghostText.isEmpty else { return } + currentInput += ghostText + ghostText = "" + } + + /// Handles tab press for completion. + /// - Returns: Array of suggestions if multiple matches, nil otherwise + @discardableResult + func tabComplete() -> [String]? { + guard pendingLoginContact == nil else { return nil } + + // If already showing suggestions, cycle selection + if let suggestions = tabSuggestions, !suggestions.isEmpty { + if let currentIndex = tabSelectionIndex { + tabSelectionIndex = (currentIndex + 1) % suggestions.count + } else { + tabSelectionIndex = 0 + } + return suggestions } - private func applyCompletion(_ suggestion: String) { - let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) - - if parts.count <= 1 { - currentInput = suggestion + " " - } else { - var newParts = parts.dropLast().map(String.init) - newParts.append(suggestion) - currentInput = newParts.joined(separator: " ") + " " - } - ghostText = "" + // Generate new suggestions + let isLocal = activeSession?.isLocal ?? true + let suggestions = completionEngine.completions(for: currentInput, isLocal: isLocal) + + guard !suggestions.isEmpty else { + tabSuggestions = nil + tabSelectionIndex = nil + return nil } - /// Clears ghost text and tab state when switching sessions. - func clearCompletionState() { - ghostText = "" - clearTabState() + if suggestions.count == 1 { + applyCompletion(suggestions[0]) + return nil } - func updateNodeNamesForCompletion() async { - guard let dataStore, let radioID else { return } - - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - let names = contacts - .filter { $0.type == .repeater || $0.type == .room } - .map(\.name) - completionEngine.updateNodeNames(names) - } catch { - Self.logger.error("Failed to fetch contacts for completion: \(error)") - } + tabSuggestions = suggestions + tabSelectionIndex = nil + return suggestions + } + + /// Applies the selected suggestion if in selection mode. + /// - Returns: true if suggestion was applied, false if not in selection mode + func applySelectedSuggestion() -> Bool { + guard let suggestions = tabSuggestions, + let index = tabSelectionIndex, + index < suggestions.count else { + return false + } + applyCompletion(suggestions[index]) + clearTabState() + return true + } + + /// Clears tab completion state (suggestions and selection). + func clearTabState() { + tabSuggestions = nil + tabSelectionIndex = nil + } + + private func applyCompletion(_ suggestion: String) { + let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) + + if parts.count <= 1 { + currentInput = suggestion + " " + } else { + var newParts = parts.dropLast().map(String.init) + newParts.append(suggestion) + currentInput = newParts.joined(separator: " ") + " " + } + ghostText = "" + } + + /// Clears ghost text and tab state when switching sessions. + func clearCompletionState() { + ghostText = "" + clearTabState() + } + + func updateNodeNamesForCompletion() async { + guard let dataStore, let radioID else { return } + + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + let names = contacts + .filter { $0.type == .repeater || $0.type == .room } + .map(\.name) + completionEngine.updateNodeNames(names) + } catch { + Self.logger.error("Failed to fetch contacts for completion: \(error)") } + } } diff --git a/MC1/Views/Tools/CLI/CLIToolViewModel+Sessions.swift b/MC1/Views/Tools/CLI/CLIToolViewModel+Sessions.swift index bf34401d..3c343116 100644 --- a/MC1/Views/Tools/CLI/CLIToolViewModel+Sessions.swift +++ b/MC1/Views/Tools/CLI/CLIToolViewModel+Sessions.swift @@ -1,345 +1,344 @@ import Foundation -import OSLog import MC1Services +import OSLog // MARK: - Session and Login Management extension CLIToolViewModel { - func handleSessionCommand(_ args: String) { - let subcommand = args.trimmingCharacters(in: .whitespaces).lowercased() - - if subcommand.isEmpty || subcommand == "list" { - showSessionList() - } else if subcommand == "local" { - switchToLocal() - } else { - switchToSession(named: args.trimmingCharacters(in: .whitespaces)) - } + func handleSessionCommand(_ args: String) { + let subcommand = args.trimmingCharacters(in: .whitespaces).lowercased() + + if subcommand.isEmpty || subcommand == "list" { + showSessionList() + } else if subcommand == "local" { + switchToLocal() + } else { + switchToSession(named: args.trimmingCharacters(in: .whitespaces)) } + } - func showSessionList() { - appendOutput(L10n.Tools.Tools.Cli.sessionListHeader, type: .response) + func showSessionList() { + appendOutput(L10n.Tools.Tools.Cli.sessionListHeader, type: .response) - let localMarker = (activeSession?.isLocal == true) ? "*" : " " - appendOutput(" \(localMarker) 1. \(localDeviceName) (\(L10n.Tools.Tools.Cli.sessionLocal))", type: .response) + let localMarker = (activeSession?.isLocal == true) ? "*" : " " + appendOutput(" \(localMarker) 1. \(localDeviceName) (\(L10n.Tools.Tools.Cli.sessionLocal))", type: .response) - for (index, session) in remoteSessions.enumerated() { - let marker = (activeSession?.id == session.id) ? "*" : " " - appendOutput(" \(marker) \(index + 2). @\(session.name)", type: .response) - } + for (index, session) in remoteSessions.enumerated() { + let marker = (activeSession?.id == session.id) ? "*" : " " + appendOutput(" \(marker) \(index + 2). @\(session.name)", type: .response) + } + } + + func switchToLocal() { + clearCompletionState() + activeSession = .local(deviceName: localDeviceName) + appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) \(localDeviceName)", type: .success) + } + + func switchToSession(named name: String) { + clearCompletionState() + // Check if input is a number + if let number = Int(name) { + if number == 1 { + switchToLocal() + return + } + let remoteIndex = number - 2 + if remoteIndex >= 0, remoteIndex < remoteSessions.count { + let session = remoteSessions[remoteIndex] + activeSession = session + appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) @\(session.name)", type: .success) + return + } + appendOutput("\(L10n.Tools.Tools.Cli.sessionNotFound) \(name)", type: .error) + return + } + // Match by name + let predicate: (CLISession) -> Bool = { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame } + if let session = remoteSessions.first(where: predicate) { + activeSession = session + appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) @\(session.name)", type: .success) + } else { + appendOutput("\(L10n.Tools.Tools.Cli.sessionNotFound) \(name)", type: .error) } + } - func switchToLocal() { - clearCompletionState() - activeSession = .local(deviceName: localDeviceName) - appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) \(localDeviceName)", type: .success) + func handleLogin(_ args: String) async { + guard activeSession?.isLocal == true else { + appendOutput(L10n.Tools.Tools.Cli.loginFromLocalOnly, type: .error) + return } - func switchToSession(named name: String) { - clearCompletionState() - // Check if input is a number - if let number = Int(name) { - if number == 1 { - switchToLocal() - return - } - let remoteIndex = number - 2 - if remoteIndex >= 0 && remoteIndex < remoteSessions.count { - let session = remoteSessions[remoteIndex] - activeSession = session - appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) @\(session.name)", type: .success) - return - } - appendOutput("\(L10n.Tools.Tools.Cli.sessionNotFound) \(name)", type: .error) - return - } + // Parse --forget or -f flag (must be first argument) + let trimmed = args.trimmingCharacters(in: .whitespaces) + let forgetPassword: Bool + let nodeName: String + + if trimmed.hasPrefix("--forget ") { + forgetPassword = true + nodeName = String(trimmed.dropFirst("--forget ".count)).trimmingCharacters(in: .whitespaces) + } else if trimmed.hasPrefix("-f ") { + forgetPassword = true + nodeName = String(trimmed.dropFirst("-f ".count)).trimmingCharacters(in: .whitespaces) + } else { + forgetPassword = false + nodeName = trimmed + } - // Match by name - let predicate: (CLISession) -> Bool = { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame } - if let session = remoteSessions.first(where: predicate) { - activeSession = session - appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) @\(session.name)", type: .success) - } else { - appendOutput("\(L10n.Tools.Tools.Cli.sessionNotFound) \(name)", type: .error) - } + guard !nodeName.isEmpty else { + appendOutput(L10n.Tools.Tools.Cli.loginUsage, type: .error) + return } - func handleLogin(_ args: String) async { - guard activeSession?.isLocal == true else { - appendOutput(L10n.Tools.Tools.Cli.loginFromLocalOnly, type: .error) - return - } + guard let dataStore, let radioID, let remoteNodeService else { + appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) + return + } - // Parse --forget or -f flag (must be first argument) - let trimmed = args.trimmingCharacters(in: .whitespaces) - let forgetPassword: Bool - let nodeName: String - - if trimmed.hasPrefix("--forget ") { - forgetPassword = true - nodeName = String(trimmed.dropFirst("--forget ".count)).trimmingCharacters(in: .whitespaces) - } else if trimmed.hasPrefix("-f ") { - forgetPassword = true - nodeName = String(trimmed.dropFirst("-f ".count)).trimmingCharacters(in: .whitespaces) - } else { - forgetPassword = false - nodeName = trimmed - } + // Find contact by name (repeaters and room servers only) + let contact: ContactDTO + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + guard let found = contacts.first(where: { + $0.name.localizedCaseInsensitiveCompare(nodeName) == .orderedSame + && ($0.type == .repeater || $0.type == .room) + }) else { + appendOutput("\(L10n.Tools.Tools.Cli.nodeNotFound) \(nodeName)", type: .error) + return + } + contact = found + } catch { + Self.logger.error("Failed to fetch contacts: \(error)") + appendOutput("\(L10n.Tools.Tools.Cli.nodeNotFound) \(nodeName)", type: .error) + return + } - guard !nodeName.isEmpty else { - appendOutput(L10n.Tools.Tools.Cli.loginUsage, type: .error) - return - } + // Clear saved password if --forget flag was used + if forgetPassword { + try? await remoteNodeService.deletePassword(forContact: contact) + } - guard let dataStore, let radioID, let remoteNodeService else { - appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) - return - } + // Check for stored password (will be nil if --forget was used) + if let storedPassword = await remoteNodeService.retrievePassword(forContact: contact) { + await completeLogin(contact: contact, password: storedPassword) + } else { + // Prompt for password + pendingLoginContact = contact + } + } - // Find contact by name (repeaters and room servers only) - let contact: ContactDTO - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - guard let found = contacts.first(where: { - $0.name.localizedCaseInsensitiveCompare(nodeName) == .orderedSame - && ($0.type == .repeater || $0.type == .room) - }) else { - appendOutput("\(L10n.Tools.Tools.Cli.nodeNotFound) \(nodeName)", type: .error) - return - } - contact = found - } catch { - Self.logger.error("Failed to fetch contacts: \(error)") - appendOutput("\(L10n.Tools.Tools.Cli.nodeNotFound) \(nodeName)", type: .error) - return - } + func completeLogin(contact: ContactDTO, password: String) async { + guard let radioID, let remoteNodeService else { + appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) + return + } - // Clear saved password if --forget flag was used - if forgetPassword { - try? await remoteNodeService.deletePassword(forContact: contact) - } + defer { stopCountdown() } + + do { + // Create or reuse session + let remoteSession = try await remoteNodeService.createSession( + radioID: radioID, + contact: contact + ) - // Check for stored password (will be nil if --forget was used) - if let storedPassword = await remoteNodeService.retrievePassword(forContact: contact) { - await completeLogin(contact: contact, password: storedPassword) - } else { - // Prompt for password - pendingLoginContact = contact + guard !Task.isCancelled else { return } + + // Login with countdown + let loginResult = try await remoteNodeService.login( + sessionID: remoteSession.id, + password: password, + pathLength: contact.outPathLength, + onTimeoutKnown: { [weak self] seconds in + await self?.startCountdown(seconds) } + ) + + guard !Task.isCancelled else { return } + + guard loginResult.success else { + appendOutput( + "\(L10n.Tools.Tools.Cli.loginFailed) \(L10n.Tools.Tools.Cli.loginFailedAuth)", + type: .error + ) + return + } + + // Store password after successful login + try? await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) + + // Success - create CLI session + let cliSession = CLISession.remote( + id: remoteSession.id, + name: contact.name, + pathLength: contact.outPathLength + ) + remoteSessions.append(cliSession) + activeSession = cliSession + appendOutput("\(L10n.Tools.Tools.Cli.loginSuccess) @\(contact.name)", type: .success) + + } catch let error as RemoteNodeError { + switch error { + case .passwordNotFound: + appendOutput(L10n.Tools.Tools.Cli.passwordRequired, type: .error) + case .timeout: + appendOutput(L10n.Tools.Tools.Cli.timeout, type: .error) + case let .loginFailed(reason): + appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(reason)", type: .error) + case .cancelled: + appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) + default: + appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(error.localizedDescription)", type: .error) + } + } catch is CancellationError { + // Already handled by cancelCurrentCommand + } catch { + appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(error.localizedDescription)", type: .error) } + } - func completeLogin(contact: ContactDTO, password: String) async { - guard let radioID, let remoteNodeService else { - appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) - return - } + func handleLogout() async { + guard let session = activeSession, !session.isLocal else { + appendOutput(L10n.Tools.Tools.Cli.notLoggedIn, type: .error) + return + } - defer { stopCountdown() } - - do { - // Create or reuse session - let remoteSession = try await remoteNodeService.createSession( - radioID: radioID, - contact: contact - ) - - guard !Task.isCancelled else { return } - - // Login with countdown - let loginResult = try await remoteNodeService.login( - sessionID: remoteSession.id, - password: password, - pathLength: contact.outPathLength, - onTimeoutKnown: { [weak self] seconds in - await self?.startCountdown(seconds) - } - ) - - guard !Task.isCancelled else { return } - - guard loginResult.success else { - appendOutput( - "\(L10n.Tools.Tools.Cli.loginFailed) \(L10n.Tools.Tools.Cli.loginFailedAuth)", - type: .error - ) - return - } - - // Store password after successful login - try? await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) - - // Success - create CLI session - let cliSession = CLISession.remote( - id: remoteSession.id, - name: contact.name, - pathLength: contact.outPathLength - ) - remoteSessions.append(cliSession) - activeSession = cliSession - appendOutput("\(L10n.Tools.Tools.Cli.loginSuccess) @\(contact.name)", type: .success) - - } catch let error as RemoteNodeError { - switch error { - case .passwordNotFound: - appendOutput(L10n.Tools.Tools.Cli.passwordRequired, type: .error) - case .timeout: - appendOutput(L10n.Tools.Tools.Cli.timeout, type: .error) - case .loginFailed(let reason): - appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(reason)", type: .error) - case .cancelled: - appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) - default: - appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(error.localizedDescription)", type: .error) - } - } catch is CancellationError { - // Already handled by cancelCurrentCommand - } catch { - appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(error.localizedDescription)", type: .error) - } + // Logout via RemoteNodeService (errors ignored per protocol design) + if let remoteNodeService { + try? await remoteNodeService.logout(sessionID: session.id) } - func handleLogout() async { - guard let session = activeSession, !session.isLocal else { - appendOutput(L10n.Tools.Tools.Cli.notLoggedIn, type: .error) - return - } + remoteSessions.removeAll { $0.id == session.id } + activeSession = .local(deviceName: localDeviceName) + appendOutput(L10n.Tools.Tools.Cli.logoutSuccess, type: .success) + } - // Logout via RemoteNodeService (errors ignored per protocol design) - if let remoteNodeService { - try? await remoteNodeService.logout(sessionID: session.id) - } + // MARK: - Remote Commands - remoteSessions.removeAll { $0.id == session.id } - activeSession = .local(deviceName: localDeviceName) - appendOutput(L10n.Tools.Tools.Cli.logoutSuccess, type: .success) + func sendRemoteCommand(_ command: String) async { + guard let session = activeSession, + !session.isLocal, + let service = repeaterAdminService else { + appendOutput(L10n.Tools.Tools.Cli.notLoggedIn, type: .error) + return } - // MARK: - Remote Commands + // Reboot won't return a response - treat timeout as success + if command.lowercased().hasPrefix("reboot") { + do { + _ = try await service.sendRawCommand( + sessionID: session.id, + command: command, + timeout: .seconds(2) + ) + appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) + } catch RemoteNodeError.timeout { + appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) + } catch { + appendOutput(error.localizedDescription, type: .error) + } + return + } - func sendRemoteCommand(_ command: String) async { - guard let session = activeSession, - !session.isLocal, - let service = repeaterAdminService else { - appendOutput(L10n.Tools.Tools.Cli.notLoggedIn, type: .error) - return - } + do { + let response = try await service.sendRawCommand(sessionID: session.id, command: command) + + guard !Task.isCancelled else { return } + + appendOutput(response, type: .response) + } catch is CancellationError { + // Already handled by cancelCurrentCommand + } catch let error as RemoteNodeError { + if case .timeout = error { + appendOutput(L10n.Tools.Tools.Cli.commandTimeout, type: .error) + } else { + appendOutput("\(error.localizedDescription)", type: .error) + } + } catch { + appendOutput("\(error.localizedDescription)", type: .error) + } + } - // Reboot won't return a response - treat timeout as success - if command.lowercased().hasPrefix("reboot") { - do { - _ = try await service.sendRawCommand( - sessionID: session.id, - command: command, - timeout: .seconds(2) - ) - appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) - } catch RemoteNodeError.timeout { - appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) - } catch { - appendOutput(error.localizedDescription, type: .error) - } - return - } + // MARK: - Local Command Handlers - do { - let response = try await service.sendRawCommand(sessionID: session.id, command: command) - - guard !Task.isCancelled else { return } - - appendOutput(response, type: .response) - } catch is CancellationError { - // Already handled by cancelCurrentCommand - } catch let error as RemoteNodeError { - if case .timeout = error { - appendOutput(L10n.Tools.Tools.Cli.commandTimeout, type: .error) - } else { - appendOutput("\(error.localizedDescription)", type: .error) - } - } catch { - appendOutput("\(error.localizedDescription)", type: .error) - } + func handleNodesCommand() async { + guard let dataStore, let radioID else { + appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) + return } - // MARK: - Local Command Handlers - - func handleNodesCommand() async { - guard let dataStore, let radioID else { - appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) - return - } + let contacts: [ContactDTO] + do { + contacts = try await dataStore.fetchContacts(radioID: radioID) + } catch { + Self.logger.error("Failed to fetch contacts: \(error)") + appendOutput(L10n.Tools.Tools.Cli.noNodes, type: .response) + return + } - let contacts: [ContactDTO] - do { - contacts = try await dataStore.fetchContacts(radioID: radioID) - } catch { - Self.logger.error("Failed to fetch contacts: \(error)") - appendOutput(L10n.Tools.Tools.Cli.noNodes, type: .response) - return + let nodes = contacts + .filter { $0.type == .repeater || $0.type == .room } + .sorted { lhs, rhs in + if lhs.type != rhs.type { + return lhs.type == .repeater } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } - let nodes = contacts - .filter { $0.type == .repeater || $0.type == .room } - .sorted { lhs, rhs in - if lhs.type != rhs.type { - return lhs.type == .repeater - } - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - - guard !nodes.isEmpty else { - appendOutput(L10n.Tools.Tools.Cli.noNodes, type: .response) - return - } + guard !nodes.isEmpty else { + appendOutput(L10n.Tools.Tools.Cli.noNodes, type: .response) + return + } - appendOutput(L10n.Tools.Tools.Cli.nodesHeader(nodes.count), type: .response) - for node in nodes { - let typeLabel = node.type == .repeater - ? L10n.Contacts.Contacts.NodeKind.repeater - : L10n.Contacts.Contacts.NodeKind.room - let route = formatRoute(node) - let padded = node.name.padding(toLength: 20, withPad: " ", startingAt: 0) - let label = typeLabel.padding(toLength: 10, withPad: " ", startingAt: 0) - appendOutput(" \(padded) \(label)\(route)", type: .response) - } + appendOutput(L10n.Tools.Tools.Cli.nodesHeader(nodes.count), type: .response) + for node in nodes { + let typeLabel = node.type == .repeater + ? L10n.Contacts.Contacts.NodeKind.repeater + : L10n.Contacts.Contacts.NodeKind.room + let route = formatRoute(node) + let padded = node.name.padding(toLength: 20, withPad: " ", startingAt: 0) + let label = typeLabel.padding(toLength: 10, withPad: " ", startingAt: 0) + appendOutput(" \(padded) \(label)\(route)", type: .response) } + } - func handleChannelsCommand() async { - guard let dataStore, let radioID else { - appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) - return - } + func handleChannelsCommand() async { + guard let dataStore, let radioID else { + appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) + return + } - let channels: [ChannelDTO] - do { - channels = try await dataStore.fetchChannels(radioID: radioID) - } catch { - Self.logger.error("Failed to fetch channels: \(error)") - appendOutput(L10n.Tools.Tools.Cli.noChannels, type: .response) - return - } + let channels: [ChannelDTO] + do { + channels = try await dataStore.fetchChannels(radioID: radioID) + } catch { + Self.logger.error("Failed to fetch channels: \(error)") + appendOutput(L10n.Tools.Tools.Cli.noChannels, type: .response) + return + } - guard !channels.isEmpty else { - appendOutput(L10n.Tools.Tools.Cli.noChannels, type: .response) - return - } + guard !channels.isEmpty else { + appendOutput(L10n.Tools.Tools.Cli.noChannels, type: .response) + return + } - let sorted = channels.sorted { $0.index < $1.index } + let sorted = channels.sorted { $0.index < $1.index } - appendOutput(L10n.Tools.Tools.Cli.channelsHeader(sorted.count), type: .response) - for channel in sorted { - let name = channel.name.isEmpty ? "(\(L10n.Tools.Tools.Cli.channelEmpty))" : channel.name - appendOutput(" [\(channel.index)] \(name)", type: .response) - } + appendOutput(L10n.Tools.Tools.Cli.channelsHeader(sorted.count), type: .response) + for channel in sorted { + let name = channel.name.isEmpty ? "(\(L10n.Tools.Tools.Cli.channelEmpty))" : channel.name + appendOutput(" [\(channel.index)] \(name)", type: .response) } - - private func formatRoute(_ contact: ContactDTO) -> String { - if contact.isFloodRouted { - return L10n.Contacts.Contacts.Route.flood - } else if contact.pathHopCount == 0 { - return L10n.Contacts.Contacts.Route.direct - } else { - return L10n.Contacts.Contacts.Route.hops(contact.pathHopCount) - } + } + + private func formatRoute(_ contact: ContactDTO) -> String { + if contact.isFloodRouted { + L10n.Contacts.Contacts.Route.flood + } else if contact.pathHopCount == 0 { + L10n.Contacts.Contacts.Route.direct + } else { + L10n.Contacts.Contacts.Route.hops(contact.pathHopCount) } + } } diff --git a/MC1/Views/Tools/CLI/CLIToolViewModel.swift b/MC1/Views/Tools/CLI/CLIToolViewModel.swift index 6af543be..1646ecf3 100644 --- a/MC1/Views/Tools/CLI/CLIToolViewModel.swift +++ b/MC1/Views/Tools/CLI/CLIToolViewModel.swift @@ -1,413 +1,424 @@ import Foundation -import OSLog import MC1Services +import OSLog import UIKit @Observable @MainActor final class CLIToolViewModel { - private static let maxOutputLines = 1000 - private static let maxHistoryEntries = 100 - static let logger = Logger(subsystem: "com.mc1", category: "CLIToolViewModel") - - // MARK: - State + private static let maxOutputLines = 1000 + private static let maxHistoryEntries = 100 + static let logger = Logger(subsystem: "com.mc1", category: "CLIToolViewModel") - private(set) var outputLines: [CLIOutputLine] = [] - private(set) var commandHistory: [String] = [] - private(set) var historyIndex: Int? - var activeSession: CLISession? - var remoteSessions: [CLISession] = [] - var isWaitingForResponse = false - private var hasShownWelcome = false + // MARK: - State - var currentInput: String = "" - private var countdownTask: Task? - var remainingSeconds: Int? - var pendingLoginContact: ContactDTO? + private(set) var outputLines: [CLIOutputLine] = [] + private(set) var commandHistory: [String] = [] + private(set) var historyIndex: Int? + var activeSession: CLISession? + var remoteSessions: [CLISession] = [] + var isWaitingForResponse = false + private var hasShownWelcome = false - // MARK: - Completion State + var currentInput: String = "" + private var countdownTask: Task? + var remainingSeconds: Int? + var pendingLoginContact: ContactDTO? - let completionEngine = CLICompletionEngine() - var ghostText: String = "" - private var nodeNamesTask: Task? + // MARK: - Completion State - /// Current tab completion suggestions (nil when hidden) - var tabSuggestions: [String]? + let completionEngine = CLICompletionEngine() + var ghostText: String = "" + private var nodeNamesTask: Task? - /// Selected index within suggestions (nil = not in selection mode) - var tabSelectionIndex: Int? + /// Current tab completion suggestions (nil when hidden) + var tabSuggestions: [String]? - // MARK: - Task Management + /// Selected index within suggestions (nil = not in selection mode) + var tabSelectionIndex: Int? - private var currentCommandTask: Task? + // MARK: - Task Management - // MARK: - Dependencies + private var currentCommandTask: Task? - private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } - private var remoteNodeServiceProvider: @MainActor () -> RemoteNodeService? = { nil } - private var dataStoreProvider: @MainActor () -> PersistenceStoreProtocol? = { nil } - private var radioIDProvider: @MainActor () -> UUID? = { nil } + // MARK: - Dependencies - // Both old and new providers read the same live state, so change detection - // needs the instance seen at the previous configure. Weak, so a torn-down - // container's deallocated service reads as a change. - private weak var lastConfiguredAdminService: RepeaterAdminService? + private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } + private var remoteNodeServiceProvider: @MainActor () -> RemoteNodeService? = { nil } + private var dataStoreProvider: @MainActor () -> PersistenceStoreProtocol? = { nil } + private var radioIDProvider: @MainActor () -> UUID? = { nil } - var repeaterAdminService: RepeaterAdminService? { repeaterAdminServiceProvider() } - var remoteNodeService: RemoteNodeService? { remoteNodeServiceProvider() } - var dataStore: PersistenceStoreProtocol? { dataStoreProvider() } - var radioID: UUID? { radioIDProvider() } + /// Both old and new providers read the same live state, so change detection + /// needs the instance seen at the previous configure. Weak, so a torn-down + /// container's deallocated service reads as a change. + private weak var lastConfiguredAdminService: RepeaterAdminService? - var localDeviceName: String = "" + var repeaterAdminService: RepeaterAdminService? { + repeaterAdminServiceProvider() + } - // MARK: - Prompt + var remoteNodeService: RemoteNodeService? { + remoteNodeServiceProvider() + } - var promptText: String { - if let seconds = remainingSeconds { - return L10n.Tools.Tools.Cli.loggingIn(seconds) - } + var dataStore: PersistenceStoreProtocol? { + dataStoreProvider() + } - if isWaitingForResponse { - return "" - } + var radioID: UUID? { + radioIDProvider() + } - if pendingLoginContact != nil { - return "\(L10n.Tools.Tools.Cli.passwordPrompt) " - } + var localDeviceName: String = "" - guard let session = activeSession else { - return "\(L10n.Tools.Tools.Cli.disconnected)\(L10n.Tools.Tools.Cli.promptSuffix) " - } + // MARK: - Prompt - if session.isLocal { - return "\(session.name)\(L10n.Tools.Tools.Cli.promptSuffix) " - } else { - return "@\(session.name)\(L10n.Tools.Tools.Cli.promptSuffix) " - } + var promptText: String { + if let seconds = remainingSeconds { + return L10n.Tools.Tools.Cli.loggingIn(seconds) } - // MARK: - Setup - - func configure( - repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, - remoteNodeService: @escaping @MainActor () -> RemoteNodeService?, - dataStore: @escaping @MainActor () -> PersistenceStoreProtocol?, - radioID: @escaping @MainActor () -> UUID?, - localDeviceName: String - ) { - self.localDeviceName = localDeviceName - remoteNodeServiceProvider = remoteNodeService - dataStoreProvider = dataStore - radioIDProvider = radioID - - let incomingService = repeaterAdminService() - repeaterAdminServiceProvider = repeaterAdminService - - if lastConfiguredAdminService !== incomingService { - if incomingService != nil && activeSession == nil { - activeSession = .local(deviceName: localDeviceName) - showWelcomeBanner() - } else if incomingService == nil { - activeSession = nil - remoteSessions.removeAll() - } - } - lastConfiguredAdminService = incomingService - - // Update node names for completion - nodeNamesTask?.cancel() - nodeNamesTask = Task { - await updateNodeNamesForCompletion() - } + if isWaitingForResponse { + return "" } - func cleanup() { - currentCommandTask?.cancel() - currentCommandTask = nil - nodeNamesTask?.cancel() - nodeNamesTask = nil - stopCountdown() + if pendingLoginContact != nil { + return "\(L10n.Tools.Tools.Cli.passwordPrompt) " } - /// Resets state for new connection while preserving command history - func reset() { - cleanup() - outputLines = [] - currentInput = "" - ghostText = "" - activeSession = nil - remoteSessions = [] - isWaitingForResponse = false - pendingLoginContact = nil - hasShownWelcome = false - clearTabState() + guard let session = activeSession else { + return "\(L10n.Tools.Tools.Cli.disconnected)\(L10n.Tools.Tools.Cli.promptSuffix) " } - func stopCountdown() { - countdownTask?.cancel() - countdownTask = nil - remainingSeconds = nil + if session.isLocal { + return "\(session.name)\(L10n.Tools.Tools.Cli.promptSuffix) " + } else { + return "@\(session.name)\(L10n.Tools.Tools.Cli.promptSuffix) " } - - func startCountdown(_ seconds: Int) { - remainingSeconds = seconds - countdownTask = Task { - var remaining = seconds - while remaining > 0 && !Task.isCancelled { - try? await Task.sleep(for: .seconds(1)) - if !Task.isCancelled { - remaining -= 1 - remainingSeconds = remaining - } - } - } + } + + // MARK: - Setup + + func configure( + repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, + remoteNodeService: @escaping @MainActor () -> RemoteNodeService?, + dataStore: @escaping @MainActor () -> PersistenceStoreProtocol?, + radioID: @escaping @MainActor () -> UUID?, + localDeviceName: String + ) { + self.localDeviceName = localDeviceName + remoteNodeServiceProvider = remoteNodeService + dataStoreProvider = dataStore + radioIDProvider = radioID + + let incomingService = repeaterAdminService() + repeaterAdminServiceProvider = repeaterAdminService + + if lastConfiguredAdminService !== incomingService { + if incomingService != nil, activeSession == nil { + activeSession = .local(deviceName: localDeviceName) + showWelcomeBanner() + } else if incomingService == nil { + activeSession = nil + remoteSessions.removeAll() + } } + lastConfiguredAdminService = incomingService - private func showWelcomeBanner() { - guard !hasShownWelcome else { return } - hasShownWelcome = true - - appendOutput(L10n.Tools.Tools.Cli.welcomeLine1, type: .response) - appendOutput(L10n.Tools.Tools.Cli.welcomeConnected(localDeviceName), type: .response) - appendOutput(L10n.Tools.Tools.Cli.welcomeHint, type: .response) - appendOutput("", type: .response) + // Update node names for completion + nodeNamesTask?.cancel() + nodeNamesTask = Task { + await updateNodeNamesForCompletion() } - - // MARK: - Command Execution - - func executeCommand(_ command: String) { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !isWaitingForResponse else { return } - let promptPrefix = promptText.trimmingCharacters(in: .whitespaces) - - // Handle password input for pending login - if let contact = pendingLoginContact { - // Echo masked password - appendOutput("\(promptPrefix) ****", type: .command) - pendingLoginContact = nil - currentInput = "" - - // Empty password cancels login - guard !trimmed.isEmpty else { - appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) - return - } - - // Claim the busy state synchronously so a second submit can't pass - // the guard before the spawned task starts running. - isWaitingForResponse = true - var task: Task! - task = Task { - defer { clearWaitingIfCurrent(task) } - await completeLogin(contact: contact, password: trimmed) - } - currentCommandTask = task - return - } - - // Echo prompt when empty input is submitted, matching terminal behavior - guard !trimmed.isEmpty else { - appendOutput(promptPrefix, type: .command) - return - } - - addToHistory(trimmed) - appendOutput("\(promptPrefix) \(trimmed)", type: .command) - - // Parse and execute - let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) - let cmd = parts[0].lowercased() - let args = parts.count > 1 ? parts[1] : "" - - // Claim the busy state synchronously so a second submit can't pass the - // guard before the spawned task starts running. - isWaitingForResponse = true - var task: Task! - task = Task { - defer { clearWaitingIfCurrent(task) } - await handleCommand(cmd, args: args, raw: trimmed) + } + + func cleanup() { + currentCommandTask?.cancel() + currentCommandTask = nil + nodeNamesTask?.cancel() + nodeNamesTask = nil + stopCountdown() + } + + /// Resets state for new connection while preserving command history + func reset() { + cleanup() + outputLines = [] + currentInput = "" + ghostText = "" + activeSession = nil + remoteSessions = [] + isWaitingForResponse = false + pendingLoginContact = nil + hasShownWelcome = false + clearTabState() + } + + func stopCountdown() { + countdownTask?.cancel() + countdownTask = nil + remainingSeconds = nil + } + + func startCountdown(_ seconds: Int) { + remainingSeconds = seconds + countdownTask = Task { + var remaining = seconds + while remaining > 0, !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + if !Task.isCancelled { + remaining -= 1 + remainingSeconds = remaining } - currentCommandTask = task - - currentInput = "" + } } - - func cancelCurrentCommand() { - currentCommandTask?.cancel() - currentCommandTask = nil - if isWaitingForResponse { - isWaitingForResponse = false - appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) - } + } + + private func showWelcomeBanner() { + guard !hasShownWelcome else { return } + hasShownWelcome = true + + appendOutput(L10n.Tools.Tools.Cli.welcomeLine1, type: .response) + appendOutput(L10n.Tools.Tools.Cli.welcomeConnected(localDeviceName), type: .response) + appendOutput(L10n.Tools.Tools.Cli.welcomeHint, type: .response) + appendOutput("", type: .response) + } + + // MARK: - Command Execution + + func executeCommand(_ command: String) { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isWaitingForResponse else { return } + let promptPrefix = promptText.trimmingCharacters(in: .whitespaces) + + // Handle password input for pending login + if let contact = pendingLoginContact { + // Echo masked password + appendOutput("\(promptPrefix) ****", type: .command) + pendingLoginContact = nil + currentInput = "" + + // Empty password cancels login + guard !trimmed.isEmpty else { + appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) + return + } + + // Claim the busy state synchronously so a second submit can't pass + // the guard before the spawned task starts running. + isWaitingForResponse = true + var task: Task! + task = Task { + defer { clearWaitingIfCurrent(task) } + await completeLogin(contact: contact, password: trimmed) + } + currentCommandTask = task + return } - private func clearWaitingIfCurrent(_ task: Task) { - // A cancelled older command can resume after a newer one claimed the - // busy flag; only the task still owning currentCommandTask may clear it. - guard currentCommandTask == task else { return } - currentCommandTask = nil - isWaitingForResponse = false + // Echo prompt when empty input is submitted, matching terminal behavior + guard !trimmed.isEmpty else { + appendOutput(promptPrefix, type: .command) + return } - private func addToHistory(_ command: String) { - commandHistory.append(command) - if commandHistory.count > Self.maxHistoryEntries { - commandHistory.removeFirst() - } - historyIndex = nil + addToHistory(trimmed) + appendOutput("\(promptPrefix) \(trimmed)", type: .command) + + // Parse and execute + let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) + let cmd = parts[0].lowercased() + let args = parts.count > 1 ? parts[1] : "" + + // Claim the busy state synchronously so a second submit can't pass the + // guard before the spawned task starts running. + isWaitingForResponse = true + var task: Task! + task = Task { + defer { clearWaitingIfCurrent(task) } + await handleCommand(cmd, args: args, raw: trimmed) } + currentCommandTask = task - private func handleCommand(_ cmd: String, args: String, raw: String) async { - // Handle "s1", "s2", etc. as shorthand for "session 1", "session 2" - if let number = parseSessionShortcut(cmd) { - handleSessionCommand(String(number)) - return - } + currentInput = "" + } - switch cmd { - case "help": showHelp() - case "clear" where activeSession?.isLocal == true || args.isEmpty: clearOutput() - case "session": handleSessionCommand(args) - case "login": await handleLogin(args) - case "logout": await handleLogout() - case "nodes" where activeSession?.isLocal == true: await handleNodesCommand() - case "channels" where activeSession?.isLocal == true: await handleChannelsCommand() - default: await handleUnknownCommand(cmd, raw: raw) - } + func cancelCurrentCommand() { + currentCommandTask?.cancel() + currentCommandTask = nil + if isWaitingForResponse { + isWaitingForResponse = false + appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) } - - private func parseSessionShortcut(_ cmd: String) -> Int? { - guard cmd.hasPrefix("s"), cmd.count > 1 else { return nil } - return Int(cmd.dropFirst()) + } + + private func clearWaitingIfCurrent(_ task: Task) { + // A cancelled older command can resume after a newer one claimed the + // busy flag; only the task still owning currentCommandTask may clear it. + guard currentCommandTask == task else { return } + currentCommandTask = nil + isWaitingForResponse = false + } + + private func addToHistory(_ command: String) { + commandHistory.append(command) + if commandHistory.count > Self.maxHistoryEntries { + commandHistory.removeFirst() } - - private func handleUnknownCommand(_ cmd: String, raw: String) async { - if activeSession?.isLocal == true { - appendOutput("\(L10n.Tools.Tools.Cli.unknownCommand) \(cmd)", type: .error) - } else if activeSession != nil { - await sendRemoteCommand(raw) - } else { - appendOutput("\(L10n.Tools.Tools.Cli.unknownCommand) \(cmd)", type: .error) - } + historyIndex = nil + } + + private func handleCommand(_ cmd: String, args: String, raw: String) async { + // Handle "s1", "s2", etc. as shorthand for "session 1", "session 2" + if let number = parseSessionShortcut(cmd) { + handleSessionCommand(String(number)) + return } - // MARK: - Built-in Commands - - private func showHelp() { - appendOutput(L10n.Tools.Tools.Cli.helpHeader, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpLogin, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpLogout, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpSessionList, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpSessionLocal, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpSessionName, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpSessionShortcut, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpClear, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpHelp, type: .response) - - if activeSession?.isLocal == true { - appendOutput(L10n.Tools.Tools.Cli.helpNodes, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpChannels, type: .response) - } else if activeSession != nil { - appendOutput("", type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterHeader, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList1, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList2, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList3, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList4, type: .response) - } + switch cmd { + case "help": showHelp() + case "clear" where activeSession?.isLocal == true || args.isEmpty: clearOutput() + case "session": handleSessionCommand(args) + case "login": await handleLogin(args) + case "logout": await handleLogout() + case "nodes" where activeSession?.isLocal == true: await handleNodesCommand() + case "channels" where activeSession?.isLocal == true: await handleChannelsCommand() + default: await handleUnknownCommand(cmd, raw: raw) } - - private func clearOutput() { - outputLines.removeAll() + } + + private func parseSessionShortcut(_ cmd: String) -> Int? { + guard cmd.hasPrefix("s"), cmd.count > 1 else { return nil } + return Int(cmd.dropFirst()) + } + + private func handleUnknownCommand(_ cmd: String, raw: String) async { + if activeSession?.isLocal == true { + appendOutput("\(L10n.Tools.Tools.Cli.unknownCommand) \(cmd)", type: .error) + } else if activeSession != nil { + await sendRemoteCommand(raw) + } else { + appendOutput("\(L10n.Tools.Tools.Cli.unknownCommand) \(cmd)", type: .error) + } + } + + // MARK: - Built-in Commands + + private func showHelp() { + appendOutput(L10n.Tools.Tools.Cli.helpHeader, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpLogin, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpLogout, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpSessionList, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpSessionLocal, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpSessionName, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpSessionShortcut, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpClear, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpHelp, type: .response) + + if activeSession?.isLocal == true { + appendOutput(L10n.Tools.Tools.Cli.helpNodes, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpChannels, type: .response) + } else if activeSession != nil { + appendOutput("", type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterHeader, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList1, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList2, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList3, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList4, type: .response) } + } - // MARK: - History Navigation + private func clearOutput() { + outputLines.removeAll() + } - func historyUp() { - guard !commandHistory.isEmpty else { return } + // MARK: - History Navigation - if let index = historyIndex { - if index > 0 { - historyIndex = index - 1 - } - } else { - historyIndex = commandHistory.count - 1 - } + func historyUp() { + guard !commandHistory.isEmpty else { return } - if let index = historyIndex { - currentInput = commandHistory[index] - } + if let index = historyIndex { + if index > 0 { + historyIndex = index - 1 + } + } else { + historyIndex = commandHistory.count - 1 } - func historyDown() { - guard let index = historyIndex else { return } - - if index < commandHistory.count - 1 { - historyIndex = index + 1 - currentInput = commandHistory[index + 1] - } else { - historyIndex = nil - currentInput = "" - } + if let index = historyIndex { + currentInput = commandHistory[index] } + } - // MARK: - Output Management - - func appendOutput(_ text: String, type: CLIOutputType) { - let line = CLIOutputLine(text: text, type: type) - outputLines.append(line) + func historyDown() { + guard let index = historyIndex else { return } - if outputLines.count > Self.maxOutputLines { - outputLines.removeFirst() - } + if index < commandHistory.count - 1 { + historyIndex = index + 1 + currentInput = commandHistory[index + 1] + } else { + historyIndex = nil + currentInput = "" } + } - // MARK: - Clipboard + // MARK: - Output Management - /// Returns the full response block containing the given line. - /// A response block is all consecutive non-command lines. - func getResponseBlock(containing line: CLIOutputLine) -> String { - guard let index = outputLines.firstIndex(where: { $0.id == line.id }) else { - return line.text - } + func appendOutput(_ text: String, type: CLIOutputType) { + let line = CLIOutputLine(text: text, type: type) + outputLines.append(line) - // Command lines: strip prompt prefix (e.g., "local > " or "@node > ") - if line.type == .command { - if let range = line.text.range(of: "> ") { - return String(line.text[range.upperBound...]) - } - return line.text - } + if outputLines.count > Self.maxOutputLines { + outputLines.removeFirst() + } + } - // Find start of block (walk back until we hit a .command or start) - var startIndex = index - while startIndex > 0 && outputLines[startIndex - 1].type != .command { - startIndex -= 1 - } + // MARK: - Clipboard - // Find end of block (walk forward until we hit a .command or end) - var endIndex = index - while endIndex < outputLines.count - 1 && outputLines[endIndex + 1].type != .command { - endIndex += 1 - } + /// Returns the full response block containing the given line. + /// A response block is all consecutive non-command lines. + func getResponseBlock(containing line: CLIOutputLine) -> String { + guard let index = outputLines.firstIndex(where: { $0.id == line.id }) else { + return line.text + } - return outputLines[startIndex...endIndex] - .map { line in - // Strip "> " prefix from MeshCore CLI responses - if line.text.hasPrefix("> ") { - return String(line.text.dropFirst(2)) - } - return line.text - } - .joined(separator: "\n") + // Command lines: strip prompt prefix (e.g., "local > " or "@node > ") + if line.type == .command { + if let range = line.text.range(of: "> ") { + return String(line.text[range.upperBound...]) + } + return line.text } - /// Inserts clipboard text at the given cursor position. - func pasteFromClipboard(at cursorPosition: Int) { - guard let text = UIPasteboard.general.string else { return } + // Find start of block (walk back until we hit a .command or start) + var startIndex = index + while startIndex > 0, outputLines[startIndex - 1].type != .command { + startIndex -= 1 + } - let safePosition = min(cursorPosition, currentInput.count) - let index = currentInput.index(currentInput.startIndex, offsetBy: safePosition) - currentInput.insert(contentsOf: text, at: index) + // Find end of block (walk forward until we hit a .command or end) + var endIndex = index + while endIndex < outputLines.count - 1, outputLines[endIndex + 1].type != .command { + endIndex += 1 } + + return outputLines[startIndex...endIndex] + .map { line in + // Strip "> " prefix from MeshCore CLI responses + if line.text.hasPrefix("> ") { + return String(line.text.dropFirst(2)) + } + return line.text + } + .joined(separator: "\n") + } + + /// Inserts clipboard text at the given cursor position. + func pasteFromClipboard(at cursorPosition: Int) { + guard let text = UIPasteboard.general.string else { return } + + let safePosition = min(cursorPosition, currentInput.count) + let index = currentInput.index(currentInput.startIndex, offsetBy: safePosition) + currentInput.insert(contentsOf: text, at: index) + } } diff --git a/MC1/Views/Tools/CLI/HiddenTextView.swift b/MC1/Views/Tools/CLI/HiddenTextView.swift index 3c30bd16..370d9aa4 100644 --- a/MC1/Views/Tools/CLI/HiddenTextView.swift +++ b/MC1/Views/Tools/CLI/HiddenTextView.swift @@ -4,267 +4,266 @@ import UIKit /// Invisible UITextView that captures keyboard input while allowing visual rendering elsewhere. /// Tracks cursor position and supports cursor movement via callbacks. struct HiddenTextViewFocusable: UIViewRepresentable { - @Binding var text: String - @Binding var isFocused: Bool - @Binding var cursorPosition: Int - var onSubmit: () -> Void - var onHistoryUp: () -> Void - var onHistoryDown: () -> Void - var onRightArrowAtEnd: () -> Void - var onTabComplete: () -> Void - - func makeUIView(context: Context) -> FocusableTextView { - let textView = FocusableTextView() - textView.customDelegate = context.coordinator - textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) - textView.autocorrectionType = .no - textView.autocapitalizationType = .none - textView.spellCheckingType = .no - textView.returnKeyType = .default - - // Make invisible but still interactive - textView.backgroundColor = .clear - textView.textColor = .clear - textView.tintColor = .clear - - return textView + @Binding var text: String + @Binding var isFocused: Bool + @Binding var cursorPosition: Int + var onSubmit: () -> Void + var onHistoryUp: () -> Void + var onHistoryDown: () -> Void + var onRightArrowAtEnd: () -> Void + var onTabComplete: () -> Void + + func makeUIView(context: Context) -> FocusableTextView { + let textView = FocusableTextView() + textView.customDelegate = context.coordinator + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + textView.autocorrectionType = .no + textView.autocapitalizationType = .none + textView.spellCheckingType = .no + textView.returnKeyType = .default + + // Make invisible but still interactive + textView.backgroundColor = .clear + textView.textColor = .clear + textView.tintColor = .clear + + return textView + } + + func updateUIView(_ textView: FocusableTextView, context: Context) { + // Update text if changed externally + if textView.text != text { + textView.text = text } - func updateUIView(_ textView: FocusableTextView, context: Context) { - // Update text if changed externally - if textView.text != text { - textView.text = text - } - - // Sync cursor position from SwiftUI to UITextView - let clampedPosition = min(cursorPosition, textView.text.count) - if let startPosition = textView.position(from: textView.beginningOfDocument, offset: clampedPosition) { - let currentOffset = textView.selectedTextRange.map { - textView.offset(from: textView.beginningOfDocument, to: $0.start) - } ?? 0 + // Sync cursor position from SwiftUI to UITextView + let clampedPosition = min(cursorPosition, textView.text.count) + if let startPosition = textView.position(from: textView.beginningOfDocument, offset: clampedPosition) { + let currentOffset = textView.selectedTextRange.map { + textView.offset(from: textView.beginningOfDocument, to: $0.start) + } ?? 0 - if currentOffset != clampedPosition { - textView.selectedTextRange = textView.textRange(from: startPosition, to: startPosition) - } - } + if currentOffset != clampedPosition { + textView.selectedTextRange = textView.textRange(from: startPosition, to: startPosition) + } + } - // Manage focus. The accessory bar is gated on `isFocused`, which the - // delegate clears only via `textViewDidEndEditing`. That never fires when - // the responder request can't be honored (view not yet in a window), so - // reconcile the binding here to keep the bar from stranding with no keyboard. - if isFocused && !textView.isFirstResponder { - Task { @MainActor in - guard textView.window != nil, textView.becomeFirstResponder() else { - isFocused = false - return - } - } - } else if !isFocused && textView.isFirstResponder { - textView.resignFirstResponder() + // Manage focus. The accessory bar is gated on `isFocused`, which the + // delegate clears only via `textViewDidEndEditing`. That never fires when + // the responder request can't be honored (view not yet in a window), so + // reconcile the binding here to keep the bar from stranding with no keyboard. + if isFocused, !textView.isFirstResponder { + Task { @MainActor in + guard textView.window != nil, textView.becomeFirstResponder() else { + isFocused = false + return } + } + } else if !isFocused, textView.isFirstResponder { + textView.resignFirstResponder() } - - func makeCoordinator() -> Coordinator { - Coordinator( - text: $text, - isFocused: $isFocused, - cursorPosition: $cursorPosition, - onSubmit: onSubmit, - onHistoryUp: onHistoryUp, - onHistoryDown: onHistoryDown, - onRightArrowAtEnd: onRightArrowAtEnd, - onTabComplete: onTabComplete - ) + } + + func makeCoordinator() -> Coordinator { + Coordinator( + text: $text, + isFocused: $isFocused, + cursorPosition: $cursorPosition, + onSubmit: onSubmit, + onHistoryUp: onHistoryUp, + onHistoryDown: onHistoryDown, + onRightArrowAtEnd: onRightArrowAtEnd, + onTabComplete: onTabComplete + ) + } + + class Coordinator: NSObject, FocusableTextViewDelegate { + @Binding var text: String + @Binding var isFocused: Bool + @Binding var cursorPosition: Int + let onSubmit: () -> Void + let onHistoryUp: () -> Void + let onHistoryDown: () -> Void + let onRightArrowAtEnd: () -> Void + let onTabComplete: () -> Void + + init( + text: Binding, + isFocused: Binding, + cursorPosition: Binding, + onSubmit: @escaping () -> Void, + onHistoryUp: @escaping () -> Void, + onHistoryDown: @escaping () -> Void, + onRightArrowAtEnd: @escaping () -> Void, + onTabComplete: @escaping () -> Void + ) { + _text = text + _isFocused = isFocused + _cursorPosition = cursorPosition + self.onSubmit = onSubmit + self.onHistoryUp = onHistoryUp + self.onHistoryDown = onHistoryDown + self.onRightArrowAtEnd = onRightArrowAtEnd + self.onTabComplete = onTabComplete } - class Coordinator: NSObject, FocusableTextViewDelegate { - @Binding var text: String - @Binding var isFocused: Bool - @Binding var cursorPosition: Int - let onSubmit: () -> Void - let onHistoryUp: () -> Void - let onHistoryDown: () -> Void - let onRightArrowAtEnd: () -> Void - let onTabComplete: () -> Void - - init( - text: Binding, - isFocused: Binding, - cursorPosition: Binding, - onSubmit: @escaping () -> Void, - onHistoryUp: @escaping () -> Void, - onHistoryDown: @escaping () -> Void, - onRightArrowAtEnd: @escaping () -> Void, - onTabComplete: @escaping () -> Void - ) { - _text = text - _isFocused = isFocused - _cursorPosition = cursorPosition - self.onSubmit = onSubmit - self.onHistoryUp = onHistoryUp - self.onHistoryDown = onHistoryDown - self.onRightArrowAtEnd = onRightArrowAtEnd - self.onTabComplete = onTabComplete - } - - func textViewDidChange(_ textView: UITextView) { - let currentText = textView.text ?? "" - if let newlineIndex = currentText.firstIndex(of: "\n") { - // A paste can deliver the command and its newline in one change; - // sync the binding before clearing so onSubmit reads the full text. - text = String(currentText[.., with event: UIPressesEvent?) { + guard let key = presses.first?.key else { + super.pressesBegan(presses, with: event) + return } - @objc private func handleTab() { - customDelegate?.tabComplete() - } - - // MARK: - Hardware Keyboard Press Handling - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let key = presses.first?.key else { - super.pressesBegan(presses, with: event) - return - } - - switch key.keyCode { - case .keyboardRightArrow: - // Check if cursor is at end of text - let cursorOffset: Int - if let selectedRange = selectedTextRange { - cursorOffset = offset(from: beginningOfDocument, to: selectedRange.start) - } else { - cursorOffset = text.count - } - - if cursorOffset >= text.count { - // Cursor at end - notify delegate for ghost text acceptance - customDelegate?.rightArrowAtEnd() - // Don't call super - prevent default behavior - return - } else { - // Cursor not at end - let UITextView handle normal cursor movement - super.pressesBegan(presses, with: event) - } - - default: - super.pressesBegan(presses, with: event) - } + switch key.keyCode { + case .keyboardRightArrow: + // Check if cursor is at end of text + let cursorOffset: Int = if let selectedRange = selectedTextRange { + offset(from: beginningOfDocument, to: selectedRange.start) + } else { + text.count + } + + if cursorOffset >= text.count { + // Cursor at end - notify delegate for ghost text acceptance + customDelegate?.rightArrowAtEnd() + // Don't call super - prevent default behavior + return + } else { + // Cursor not at end - let UITextView handle normal cursor movement + super.pressesBegan(presses, with: event) + } + + default: + super.pressesBegan(presses, with: event) } + } - // Public methods for programmatic cursor movement (called from accessory bar buttons) - func moveCursorLeft() { - customDelegate?.moveCursor(by: -1, in: self) - } + /// Public methods for programmatic cursor movement (called from accessory bar buttons) + func moveCursorLeft() { + customDelegate?.moveCursor(by: -1, in: self) + } - func moveCursorRight() { - customDelegate?.moveCursor(by: 1, in: self) - } + func moveCursorRight() { + customDelegate?.moveCursor(by: 1, in: self) + } - func moveCursorToEnd() { - customDelegate?.moveCursorToEnd(in: self) - } + func moveCursorToEnd() { + customDelegate?.moveCursorToEnd(in: self) + } } diff --git a/MC1/Views/Tools/LineOfSight/AddRepeaterRowView.swift b/MC1/Views/Tools/LineOfSight/AddRepeaterRowView.swift index 3bdcbabb..8e64ad03 100644 --- a/MC1/Views/Tools/LineOfSight/AddRepeaterRowView.swift +++ b/MC1/Views/Tools/LineOfSight/AddRepeaterRowView.swift @@ -1,34 +1,34 @@ import SwiftUI struct AddRepeaterRowView: View { - let onAdd: () -> Void + let onAdd: () -> Void - var body: some View { - Button { - onAdd() - } label: { - HStack { - // Purple R marker (matches full row) - Circle() - .fill(.purple) - .frame(width: 24, height: 24) - .overlay { - Text("R") - .font(.caption) - .bold() - .foregroundStyle(.white) - } + var body: some View { + Button { + onAdd() + } label: { + HStack { + // Purple R marker (matches full row) + Circle() + .fill(.purple) + .frame(width: 24, height: 24) + .overlay { + Text("R") + .font(.caption) + .bold() + .foregroundStyle(.white) + } - Text(L10n.Tools.Tools.LineOfSight.addRepeater) - .font(.subheadline) + Text(L10n.Tools.Tools.LineOfSight.addRepeater) + .font(.subheadline) - Spacer() + Spacer() - Image(systemName: "plus.circle.fill") - .foregroundStyle(.purple) - } - .padding(.vertical, 8) - } - .liquidGlassSecondaryButtonStyle() + Image(systemName: "plus.circle.fill") + .foregroundStyle(.purple) + } + .padding(.vertical, 8) } + .liquidGlassSecondaryButtonStyle() + } } diff --git a/MC1/Views/Tools/LineOfSight/AnalysisErrorView.swift b/MC1/Views/Tools/LineOfSight/AnalysisErrorView.swift index 129562b9..7eddb49c 100644 --- a/MC1/Views/Tools/LineOfSight/AnalysisErrorView.swift +++ b/MC1/Views/Tools/LineOfSight/AnalysisErrorView.swift @@ -1,30 +1,30 @@ import SwiftUI struct AnalysisErrorView: View { - let message: String - let hasRepeater: Bool - let onRetry: () -> Void + let message: String + let hasRepeater: Bool + let onRetry: () -> Void - var body: some View { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) + var body: some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) - Text(L10n.Tools.Tools.LineOfSight.analysisFailed) - .font(.headline) + Text(L10n.Tools.Tools.LineOfSight.analysisFailed) + .font(.headline) - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) - Button(L10n.Tools.Tools.LineOfSight.retry) { - onRetry() - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity) - .padding() + Button(L10n.Tools.Tools.LineOfSight.retry) { + onRetry() + } + .buttonStyle(.bordered) } + .frame(maxWidth: .infinity) + .padding() + } } diff --git a/MC1/Views/Tools/LineOfSight/ChartCoordinateSpace.swift b/MC1/Views/Tools/LineOfSight/ChartCoordinateSpace.swift index 7563b12e..f1a6264c 100644 --- a/MC1/Views/Tools/LineOfSight/ChartCoordinateSpace.swift +++ b/MC1/Views/Tools/LineOfSight/ChartCoordinateSpace.swift @@ -2,39 +2,39 @@ import SwiftUI /// Transforms data coordinates (meters) to canvas pixel coordinates struct ChartCoordinateSpace { - let canvasSize: CGSize - let padding: EdgeInsets - let xRange: ClosedRange // meters - let yRange: ClosedRange // meters + let canvasSize: CGSize + let padding: EdgeInsets + let xRange: ClosedRange // meters + let yRange: ClosedRange // meters - private var plotWidth: CGFloat { - canvasSize.width - padding.leading - padding.trailing - } + private var plotWidth: CGFloat { + canvasSize.width - padding.leading - padding.trailing + } - private var plotHeight: CGFloat { - canvasSize.height - padding.top - padding.bottom - } + private var plotHeight: CGFloat { + canvasSize.height - padding.top - padding.bottom + } - /// Convert x data value (meters) to pixel x coordinate - func xPixel(_ xMeters: Double) -> CGFloat { - let fraction = (xMeters - xRange.lowerBound) / (xRange.upperBound - xRange.lowerBound) - return padding.leading + fraction * plotWidth - } + /// Convert x data value (meters) to pixel x coordinate + func xPixel(_ xMeters: Double) -> CGFloat { + let fraction = (xMeters - xRange.lowerBound) / (xRange.upperBound - xRange.lowerBound) + return padding.leading + fraction * plotWidth + } - /// Convert y data value (meters) to pixel y coordinate (inverted for Canvas) - func yPixel(_ yMeters: Double) -> CGFloat { - let fraction = (yMeters - yRange.lowerBound) / (yRange.upperBound - yRange.lowerBound) - // Invert: Canvas origin is top-left, data origin is bottom-left - return canvasSize.height - padding.bottom - fraction * plotHeight - } + /// Convert y data value (meters) to pixel y coordinate (inverted for Canvas) + func yPixel(_ yMeters: Double) -> CGFloat { + let fraction = (yMeters - yRange.lowerBound) / (yRange.upperBound - yRange.lowerBound) + // Invert: Canvas origin is top-left, data origin is bottom-left + return canvasSize.height - padding.bottom - fraction * plotHeight + } - /// Convert data point (meters) to canvas pixel point - func point(x: Double, y: Double) -> CGPoint { - CGPoint(x: xPixel(x), y: yPixel(y)) - } + /// Convert data point (meters) to canvas pixel point + func point(x: Double, y: Double) -> CGPoint { + CGPoint(x: xPixel(x), y: yPixel(y)) + } - /// Format x value (meters) as km string for axis labels - func xLabel(_ xMeters: Double) -> String { - (xMeters / 1000).formatted(.number.precision(.fractionLength(1))) - } + /// Format x value (meters) as km string for axis labels + func xLabel(_ xMeters: Double) -> String { + (xMeters / 1000).formatted(.number.precision(.fractionLength(1))) + } } diff --git a/MC1/Views/Tools/LineOfSight/ClearanceStatus+UI.swift b/MC1/Views/Tools/LineOfSight/ClearanceStatus+UI.swift index e09ff7d0..500ff80a 100644 --- a/MC1/Views/Tools/LineOfSight/ClearanceStatus+UI.swift +++ b/MC1/Views/Tools/LineOfSight/ClearanceStatus+UI.swift @@ -2,33 +2,33 @@ import MC1Services import SwiftUI extension ClearanceStatus { - var color: Color { - switch self { - case .clear: .green - case .marginal: .yellow - case .partialObstruction: .orange - case .blocked: .red - } + var color: Color { + switch self { + case .clear: .green + case .marginal: .yellow + case .partialObstruction: .orange + case .blocked: .red } + } - var iconName: String { - switch self { - case .clear: "checkmark.circle.fill" - case .marginal, .partialObstruction: "exclamationmark.triangle.fill" - case .blocked: "xmark.octagon.fill" - } + var iconName: String { + switch self { + case .clear: "checkmark.circle.fill" + case .marginal, .partialObstruction: "exclamationmark.triangle.fill" + case .blocked: "xmark.octagon.fill" } + } - var localizedName: String { - switch self { - case .clear: L10n.Tools.Tools.LineOfSight.Status.clear - case .marginal: L10n.Tools.Tools.LineOfSight.Status.marginal - case .partialObstruction: L10n.Tools.Tools.LineOfSight.Status.partialObstruction - case .blocked: L10n.Tools.Tools.LineOfSight.Status.blocked - } + var localizedName: String { + switch self { + case .clear: L10n.Tools.Tools.LineOfSight.Status.clear + case .marginal: L10n.Tools.Tools.LineOfSight.Status.marginal + case .partialObstruction: L10n.Tools.Tools.LineOfSight.Status.partialObstruction + case .blocked: L10n.Tools.Tools.LineOfSight.Status.blocked } + } - static var blockedSubtitle: String { - L10n.Tools.Tools.LineOfSight.Status.blockedSubtitle - } + static var blockedSubtitle: String { + L10n.Tools.Tools.LineOfSight.Status.blockedSubtitle + } } diff --git a/MC1/Views/Tools/LineOfSight/Components/ClearanceStatusView.swift b/MC1/Views/Tools/LineOfSight/Components/ClearanceStatusView.swift index 254d6bac..d356d59e 100644 --- a/MC1/Views/Tools/LineOfSight/Components/ClearanceStatusView.swift +++ b/MC1/Views/Tools/LineOfSight/Components/ClearanceStatusView.swift @@ -3,49 +3,49 @@ import SwiftUI /// Displays clearance status with appropriate icon, color, and clearance percentage struct ClearanceStatusView: View { - let status: ClearanceStatus - let clearancePercent: Double - - var body: some View { - HStack(spacing: 6) { - Image(systemName: status.iconName) - .foregroundStyle(status.color) - .font(.body.weight(.semibold)) - - Text(status.localizedName) - .font(.headline) - - // Show clearance percentage only for non-blocked statuses - if status != .blocked { - Text("·") - .foregroundStyle(.secondary) - Text(L10n.Tools.Tools.LineOfSight.clearancePercent(clampedPercent)) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } + let status: ClearanceStatus + let clearancePercent: Double + + var body: some View { + HStack(spacing: 6) { + Image(systemName: status.iconName) + .foregroundStyle(status.color) + .font(.body.weight(.semibold)) + + Text(status.localizedName) + .font(.headline) + + // Show clearance percentage only for non-blocked statuses + if status != .blocked { + Text("·") + .foregroundStyle(.secondary) + Text(L10n.Tools.Tools.LineOfSight.clearancePercent(clampedPercent)) + .font(.subheadline) + .foregroundStyle(.secondary) + } } + } - /// Clamp percent to 0-100 range for display - private var clampedPercent: Int { - Int(max(0, min(100, clearancePercent))) - } + /// Clamp percent to 0-100 range for display + private var clampedPercent: Int { + Int(max(0, min(100, clearancePercent))) + } } // MARK: - Preview #Preview("Clear") { - ClearanceStatusView(status: .clear, clearancePercent: 92) + ClearanceStatusView(status: .clear, clearancePercent: 92) } #Preview("Marginal") { - ClearanceStatusView(status: .marginal, clearancePercent: 72) + ClearanceStatusView(status: .marginal, clearancePercent: 72) } #Preview("Partial Obstruction") { - ClearanceStatusView(status: .partialObstruction, clearancePercent: 47) + ClearanceStatusView(status: .partialObstruction, clearancePercent: 47) } #Preview("Blocked") { - ClearanceStatusView(status: .blocked, clearancePercent: -15) + ClearanceStatusView(status: .blocked, clearancePercent: -15) } diff --git a/MC1/Views/Tools/LineOfSight/Components/LOSFormatters.swift b/MC1/Views/Tools/LineOfSight/Components/LOSFormatters.swift index 3649a375..7cadb239 100644 --- a/MC1/Views/Tools/LineOfSight/Components/LOSFormatters.swift +++ b/MC1/Views/Tools/LineOfSight/Components/LOSFormatters.swift @@ -2,63 +2,62 @@ import Foundation /// Formatting utilities for Line of Sight results display enum LOSFormatters { - - /// Formats diffraction loss for display - /// - Parameter loss: Diffraction loss in dB - /// - Returns: Formatted string like "+ 8.4 dB" or nil if loss is negligible (< 0.1 dB) - static func formatDiffractionLoss(_ loss: Double) -> String? { - guard abs(loss) >= 0.1 else { return nil } - return "+ \(loss.formatted(.number.precision(.fractionLength(1)))) dB" - } - - /// Formats total path loss for display - /// - Parameter loss: Path loss in dB - /// - Returns: Formatted string like "126.6 dB" - static func formatPathLoss(_ loss: Double) -> String { - "\(loss.formatted(.number.precision(.fractionLength(1)))) dB" - } - - /// Formats clearance percentage for display - /// - Parameter percent: Clearance percentage (may be outside 0-100 range) - /// - Returns: Integer percentage clamped to 0-100 range - static func formatClearancePercent(_ percent: Double) -> Int { - Int(max(0, min(100, percent))) - } - - /// Formats distance using the locale's measurement system - /// - Parameter meters: Distance in meters to format - /// - Returns: Formatted string like "12.4 km" or "7.7 mi" - static func formatDistance(_ meters: Double) -> String { - Measurement(value: meters, unit: UnitLength.meters) - .formatted(.measurement(width: .abbreviated, usage: .road)) - } - - /// Formats frequency with a locale-aware unit symbol - /// - Parameter mhz: Frequency in MHz - /// - Returns: Formatted string like "906 MHz" or "915.5 MHz" - static func formatFrequency(_ mhz: Double) -> String { - let fractionDigits = mhz.truncatingRemainder(dividingBy: 1) == 0 ? 0 : 1 - return Measurement(value: mhz, unit: UnitFrequency.megahertz) - .formatted(.measurement( - width: .abbreviated, - usage: .asProvided, - numberFormatStyle: .number.precision(.fractionLength(fractionDigits)) - )) - } - - /// Formats k-factor for display - /// - Parameter k: Refraction k-factor - /// - Returns: Formatted string like "k=1.33" - static func formatKFactor(_ k: Double) -> String { - "k=\(k.formatted(.number.precision(.fractionLength(2))))" - } - - /// Formats complete assumptions line - /// - Parameters: - /// - frequencyMHz: Operating frequency in MHz - /// - k: Refraction k-factor - /// - Returns: Localized string like "906 MHz, k=1.33, 60% 1st Fresnel threshold" - static func formatAssumptions(frequencyMHz: Double, k: Double) -> String { - L10n.Tools.Tools.LineOfSight.assumptions(formatFrequency(frequencyMHz), formatKFactor(k)) - } + /// Formats diffraction loss for display + /// - Parameter loss: Diffraction loss in dB + /// - Returns: Formatted string like "+ 8.4 dB" or nil if loss is negligible (< 0.1 dB) + static func formatDiffractionLoss(_ loss: Double) -> String? { + guard abs(loss) >= 0.1 else { return nil } + return "+ \(loss.formatted(.number.precision(.fractionLength(1)))) dB" + } + + /// Formats total path loss for display + /// - Parameter loss: Path loss in dB + /// - Returns: Formatted string like "126.6 dB" + static func formatPathLoss(_ loss: Double) -> String { + "\(loss.formatted(.number.precision(.fractionLength(1)))) dB" + } + + /// Formats clearance percentage for display + /// - Parameter percent: Clearance percentage (may be outside 0-100 range) + /// - Returns: Integer percentage clamped to 0-100 range + static func formatClearancePercent(_ percent: Double) -> Int { + Int(max(0, min(100, percent))) + } + + /// Formats distance using the locale's measurement system + /// - Parameter meters: Distance in meters to format + /// - Returns: Formatted string like "12.4 km" or "7.7 mi" + static func formatDistance(_ meters: Double) -> String { + Measurement(value: meters, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .road)) + } + + /// Formats frequency with a locale-aware unit symbol + /// - Parameter mhz: Frequency in MHz + /// - Returns: Formatted string like "906 MHz" or "915.5 MHz" + static func formatFrequency(_ mhz: Double) -> String { + let fractionDigits = mhz.truncatingRemainder(dividingBy: 1) == 0 ? 0 : 1 + return Measurement(value: mhz, unit: UnitFrequency.megahertz) + .formatted(.measurement( + width: .abbreviated, + usage: .asProvided, + numberFormatStyle: .number.precision(.fractionLength(fractionDigits)) + )) + } + + /// Formats k-factor for display + /// - Parameter k: Refraction k-factor + /// - Returns: Formatted string like "k=1.33" + static func formatKFactor(_ k: Double) -> String { + "k=\(k.formatted(.number.precision(.fractionLength(2))))" + } + + /// Formats complete assumptions line + /// - Parameters: + /// - frequencyMHz: Operating frequency in MHz + /// - k: Refraction k-factor + /// - Returns: Localized string like "906 MHz, k=1.33, 60% 1st Fresnel threshold" + static func formatAssumptions(frequencyMHz: Double, k: Double) -> String { + L10n.Tools.Tools.LineOfSight.assumptions(formatFrequency(frequencyMHz), formatKFactor(k)) + } } diff --git a/MC1/Views/Tools/LineOfSight/Components/ResultsCardView.swift b/MC1/Views/Tools/LineOfSight/Components/ResultsCardView.swift index 41899d36..85a020aa 100644 --- a/MC1/Views/Tools/LineOfSight/Components/ResultsCardView.swift +++ b/MC1/Views/Tools/LineOfSight/Components/ResultsCardView.swift @@ -3,411 +3,411 @@ import SwiftUI /// Expandable card showing analysis results with progressive disclosure struct ResultsCardView: View { - let result: PathAnalysisResult - @Binding var isExpanded: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.Tools.Tools.LineOfSight.results) - .font(.headline) - - VStack(alignment: .leading, spacing: 0) { - // Collapsed summary (always visible) - collapsedContent - - // Expanded details - if isExpanded { - Divider() - .padding(.vertical, 12) - - expandedContent - } - } - .padding() + let result: PathAnalysisResult + @Binding var isExpanded: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Tools.Tools.LineOfSight.results) + .font(.headline) + + VStack(alignment: .leading, spacing: 0) { + // Collapsed summary (always visible) + collapsedContent + + // Expanded details + if isExpanded { + Divider() + .padding(.vertical, 12) + + expandedContent } + } + .padding() } + } - // MARK: - Collapsed Content - - private var collapsedContent: some View { - VStack(alignment: .leading, spacing: 8) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - } label: { - HStack { - ClearanceStatusView( - status: result.clearanceStatus, - clearancePercent: result.worstClearancePercent - ) - - Spacer() - - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundStyle(.secondary) - } - .contentShape(.rect) - } - .buttonStyle(.plain) - - // Blocked subtitle - if result.clearanceStatus == .blocked { - Text(ClearanceStatus.blockedSubtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - - Divider() - - HStack { - Text(LOSFormatters.formatDistance(result.distanceMeters)) - .font(.subheadline) - .foregroundStyle(.secondary) - - Spacer() - - Text(LOSFormatters.formatPathLoss(result.totalPathLoss) + " " + L10n.Tools.Tools.LineOfSight.loss) - .font(.subheadline) - .foregroundStyle(.secondary) - } + // MARK: - Collapsed Content + + private var collapsedContent: some View { + VStack(alignment: .leading, spacing: 8) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() } - } + } label: { + HStack { + ClearanceStatusView( + status: result.clearanceStatus, + clearancePercent: result.worstClearancePercent + ) - // MARK: - Expanded Content + Spacer() - private var expandedContent: some View { - VStack(alignment: .leading, spacing: 16) { - pathLossBreakdown - clearanceDetails - assumptionsFootnote + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) } + .contentShape(.rect) + } + .buttonStyle(.plain) + + // Blocked subtitle + if result.clearanceStatus == .blocked { + Text(ClearanceStatus.blockedSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + Text(LOSFormatters.formatDistance(result.distanceMeters)) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + Text(LOSFormatters.formatPathLoss(result.totalPathLoss) + " " + L10n.Tools.Tools.LineOfSight.loss) + .font(.subheadline) + .foregroundStyle(.secondary) + } } + } - // MARK: - Path Loss Breakdown - - private var pathLossBreakdown: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.LineOfSight.pathLossBreakdown) - .font(.subheadline) - .bold() - - Grid(alignment: .leading, verticalSpacing: 6) { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.freeSpaceLoss) - .foregroundStyle(.secondary) - Spacer() - Text(LOSFormatters.formatPathLoss(result.freeSpacePathLoss)) - .monospacedDigit() - } - - if let diffractionText = LOSFormatters.formatDiffractionLoss(result.peakDiffractionLoss) { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.diffractionLoss) - .foregroundStyle(.secondary) - Spacer() - Text(diffractionText) - .monospacedDigit() - } - } - - Divider() - .gridCellColumns(2) - - GridRow { - Text(L10n.Tools.Tools.LineOfSight.total) - .bold() - Spacer() - Text(LOSFormatters.formatPathLoss(result.totalPathLoss)) - .monospacedDigit() - .bold() - } - } - .font(.subheadline) - } + // MARK: - Expanded Content + + private var expandedContent: some View { + VStack(alignment: .leading, spacing: 16) { + pathLossBreakdown + clearanceDetails + assumptionsFootnote } + } + + // MARK: - Path Loss Breakdown + + private var pathLossBreakdown: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.LineOfSight.pathLossBreakdown) + .font(.subheadline) + .bold() + + Grid(alignment: .leading, verticalSpacing: 6) { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.freeSpaceLoss) + .foregroundStyle(.secondary) + Spacer() + Text(LOSFormatters.formatPathLoss(result.freeSpacePathLoss)) + .monospacedDigit() + } - // MARK: - Clearance Details - - private var clearanceDetails: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.LineOfSight.clearance) - .font(.subheadline) - .bold() - - Grid(alignment: .leading, verticalSpacing: 6) { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.worstClearanceShort) - .foregroundStyle(.secondary) - Spacer() - Text("\(LOSFormatters.formatClearancePercent(result.worstClearancePercent))%") - .monospacedDigit() - } - - GridRow { - Text(L10n.Tools.Tools.LineOfSight.obstructionsFound) - .foregroundStyle(.secondary) - Spacer() - Text("\(result.obstructionPoints.count)") - .monospacedDigit() - } - } - .font(.subheadline) + if let diffractionText = LOSFormatters.formatDiffractionLoss(result.peakDiffractionLoss) { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.diffractionLoss) + .foregroundStyle(.secondary) + Spacer() + Text(diffractionText) + .monospacedDigit() + } } + + Divider() + .gridCellColumns(2) + + GridRow { + Text(L10n.Tools.Tools.LineOfSight.total) + .bold() + Spacer() + Text(LOSFormatters.formatPathLoss(result.totalPathLoss)) + .monospacedDigit() + .bold() + } + } + .font(.subheadline) } + } - // MARK: - Assumptions Footnote + // MARK: - Clearance Details - private var assumptionsFootnote: some View { - HStack(spacing: 6) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) + private var clearanceDetails: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.LineOfSight.clearance) + .font(.subheadline) + .bold() - Text(LOSFormatters.formatAssumptions( - frequencyMHz: result.frequencyMHz, - k: result.refractionK - )) + Grid(alignment: .leading, verticalSpacing: 6) { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.worstClearanceShort) .foregroundStyle(.secondary) + Spacer() + Text("\(LOSFormatters.formatClearancePercent(result.worstClearancePercent))%") + .monospacedDigit() } - .font(.caption) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) + + GridRow { + Text(L10n.Tools.Tools.LineOfSight.obstructionsFound) + .foregroundStyle(.secondary) + Spacer() + Text("\(result.obstructionPoints.count)") + .monospacedDigit() + } + } + .font(.subheadline) + } + } + + // MARK: - Assumptions Footnote + + private var assumptionsFootnote: some View { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + + Text(LOSFormatters.formatAssumptions( + frequencyMHz: result.frequencyMHz, + k: result.refractionK + )) + .foregroundStyle(.secondary) } + .font(.caption) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + } } // MARK: - Preview #Preview("Clear Path") { - let result = PathAnalysisResult( - distanceMeters: 12400, - freeSpacePathLoss: 118.2, - peakDiffractionLoss: 0, - totalPathLoss: 118.2, - clearanceStatus: .clear, - worstClearancePercent: 92, - obstructionPoints: [], - frequencyMHz: 906, - refractionK: 1.33 - ) - - return ResultsCardView(result: result, isExpanded: .constant(false)) - .padding() + let result = PathAnalysisResult( + distanceMeters: 12400, + freeSpacePathLoss: 118.2, + peakDiffractionLoss: 0, + totalPathLoss: 118.2, + clearanceStatus: .clear, + worstClearancePercent: 92, + obstructionPoints: [], + frequencyMHz: 906, + refractionK: 1.33 + ) + + return ResultsCardView(result: result, isExpanded: .constant(false)) + .padding() } #Preview("Partial Obstruction - Expanded") { - let result = PathAnalysisResult( - distanceMeters: 12400, - freeSpacePathLoss: 118.2, - peakDiffractionLoss: 8.4, - totalPathLoss: 126.6, - clearanceStatus: .partialObstruction, - worstClearancePercent: 47, - obstructionPoints: [ - ObstructionPoint(distanceFromAMeters: 5000, obstructionHeightMeters: 12, fresnelClearancePercent: 47), - ObstructionPoint(distanceFromAMeters: 7200, obstructionHeightMeters: 8, fresnelClearancePercent: 55) - ], - frequencyMHz: 906, - refractionK: 1.33 - ) - - return ResultsCardView(result: result, isExpanded: .constant(true)) - .padding() + let result = PathAnalysisResult( + distanceMeters: 12400, + freeSpacePathLoss: 118.2, + peakDiffractionLoss: 8.4, + totalPathLoss: 126.6, + clearanceStatus: .partialObstruction, + worstClearancePercent: 47, + obstructionPoints: [ + ObstructionPoint(distanceFromAMeters: 5000, obstructionHeightMeters: 12, fresnelClearancePercent: 47), + ObstructionPoint(distanceFromAMeters: 7200, obstructionHeightMeters: 8, fresnelClearancePercent: 55) + ], + frequencyMHz: 906, + refractionK: 1.33 + ) + + return ResultsCardView(result: result, isExpanded: .constant(true)) + .padding() } #Preview("Blocked - Expanded") { - let result = PathAnalysisResult( - distanceMeters: 8500, - freeSpacePathLoss: 112.5, - peakDiffractionLoss: 22.3, - totalPathLoss: 134.8, - clearanceStatus: .blocked, - worstClearancePercent: -15, - obstructionPoints: [ - ObstructionPoint(distanceFromAMeters: 4200, obstructionHeightMeters: 35, fresnelClearancePercent: -15) - ], - frequencyMHz: 915, - refractionK: 1.33 - ) - - return ResultsCardView(result: result, isExpanded: .constant(true)) - .padding() + let result = PathAnalysisResult( + distanceMeters: 8500, + freeSpacePathLoss: 112.5, + peakDiffractionLoss: 22.3, + totalPathLoss: 134.8, + clearanceStatus: .blocked, + worstClearancePercent: -15, + obstructionPoints: [ + ObstructionPoint(distanceFromAMeters: 4200, obstructionHeightMeters: 35, fresnelClearancePercent: -15) + ], + frequencyMHz: 915, + refractionK: 1.33 + ) + + return ResultsCardView(result: result, isExpanded: .constant(true)) + .padding() } // MARK: - Relay Results Card View /// Card view for relay path analysis results showing dual-segment analysis struct RelayResultsCardView: View { - let result: RelayPathAnalysisResult - @Binding var isExpanded: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.Tools.Tools.LineOfSight.results) - .font(.headline) - - VStack(alignment: .leading, spacing: 0) { - collapsedContent - - if isExpanded { - Divider() - .padding(.vertical, 12) - expandedContent - } - } - .padding() + let result: RelayPathAnalysisResult + @Binding var isExpanded: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Tools.Tools.LineOfSight.results) + .font(.headline) + + VStack(alignment: .leading, spacing: 0) { + collapsedContent + + if isExpanded { + Divider() + .padding(.vertical, 12) + expandedContent } + } + .padding() } + } - // MARK: - Collapsed Content - - private var collapsedContent: some View { - VStack(alignment: .leading, spacing: 8) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - } label: { - HStack { - ClearanceStatusView( - status: result.overallStatus, - clearancePercent: min( - result.segmentAR.worstClearancePercent, - result.segmentRB.worstClearancePercent - ) - ) - Spacer() - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundStyle(.secondary) - } - .contentShape(.rect) - } - .buttonStyle(.plain) - - Divider() - - // Segment summary - VStack(alignment: .leading, spacing: 4) { - segmentRow(segment: result.segmentAR) - segmentRow(segment: result.segmentRB) - } - - Divider() - - HStack { - Text("\(L10n.Tools.Tools.LineOfSight.total): \(LOSFormatters.formatDistance(result.totalDistanceMeters))") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } + // MARK: - Collapsed Content + + private var collapsedContent: some View { + VStack(alignment: .leading, spacing: 8) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack { + ClearanceStatusView( + status: result.overallStatus, + clearancePercent: min( + result.segmentAR.worstClearancePercent, + result.segmentRB.worstClearancePercent + ) + ) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) } + .contentShape(.rect) + } + .buttonStyle(.plain) + + Divider() + + // Segment summary + VStack(alignment: .leading, spacing: 4) { + segmentRow(segment: result.segmentAR) + segmentRow(segment: result.segmentRB) + } + + Divider() + + HStack { + Text("\(L10n.Tools.Tools.LineOfSight.total): \(LOSFormatters.formatDistance(result.totalDistanceMeters))") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } } + } - private func segmentRow(segment: SegmentAnalysisResult) -> some View { - HStack { - Circle() - .fill(segment.clearanceStatus.color) - .frame(width: 8, height: 8) + private func segmentRow(segment: SegmentAnalysisResult) -> some View { + HStack { + Circle() + .fill(segment.clearanceStatus.color) + .frame(width: 8, height: 8) - Text("\(segment.startLabel) \u{2192} \(segment.endLabel)") - .font(.caption) + Text("\(segment.startLabel) \u{2192} \(segment.endLabel)") + .font(.caption) - Text(segment.clearanceStatus.localizedName) - .font(.caption) - .foregroundStyle(.secondary) + Text(segment.clearanceStatus.localizedName) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Text(LOSFormatters.formatDistance(segment.distanceMeters)) - .font(.caption) - .foregroundStyle(.secondary) - } + Text(LOSFormatters.formatDistance(segment.distanceMeters)) + .font(.caption) + .foregroundStyle(.secondary) } + } - // MARK: - Expanded Content + // MARK: - Expanded Content - private var expandedContent: some View { - VStack(alignment: .leading, spacing: 16) { - segmentDetailView(segment: result.segmentAR) - segmentDetailView(segment: result.segmentRB) - } + private var expandedContent: some View { + VStack(alignment: .leading, spacing: 16) { + segmentDetailView(segment: result.segmentAR) + segmentDetailView(segment: result.segmentRB) } + } - private func segmentDetailView(segment: SegmentAnalysisResult) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("\(segment.startLabel) \u{2192} \(segment.endLabel)") - .font(.subheadline) - .bold() - - Grid(alignment: .leading, verticalSpacing: 4) { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.status) - .foregroundStyle(.secondary) - Spacer() - Text(segment.clearanceStatus.localizedName) - } - GridRow { - Text(L10n.Tools.Tools.LineOfSight.distance) - .foregroundStyle(.secondary) - Spacer() - Text(LOSFormatters.formatDistance(segment.distanceMeters)) - } - GridRow { - Text(L10n.Tools.Tools.LineOfSight.worstClearanceShort) - .foregroundStyle(.secondary) - Spacer() - Text("\(LOSFormatters.formatClearancePercent(segment.worstClearancePercent))%") - } - } - .font(.caption) + private func segmentDetailView(segment: SegmentAnalysisResult) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("\(segment.startLabel) \u{2192} \(segment.endLabel)") + .font(.subheadline) + .bold() + + Grid(alignment: .leading, verticalSpacing: 4) { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.status) + .foregroundStyle(.secondary) + Spacer() + Text(segment.clearanceStatus.localizedName) + } + GridRow { + Text(L10n.Tools.Tools.LineOfSight.distance) + .foregroundStyle(.secondary) + Spacer() + Text(LOSFormatters.formatDistance(segment.distanceMeters)) + } + GridRow { + Text(L10n.Tools.Tools.LineOfSight.worstClearanceShort) + .foregroundStyle(.secondary) + Spacer() + Text("\(LOSFormatters.formatClearancePercent(segment.worstClearancePercent))%") } - .padding() + } + .font(.caption) } + .padding() + } } // MARK: - Relay Results Previews #Preview("Relay - Clear Path") { - let result = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", - endLabel: "R", - clearanceStatus: .clear, - distanceMeters: 5200, - worstClearancePercent: 85 - ), - segmentRB: SegmentAnalysisResult( - startLabel: "R", - endLabel: "B", - clearanceStatus: .clear, - distanceMeters: 7200, - worstClearancePercent: 92 - ) + let result = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", + endLabel: "R", + clearanceStatus: .clear, + distanceMeters: 5200, + worstClearancePercent: 85 + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", + endLabel: "B", + clearanceStatus: .clear, + distanceMeters: 7200, + worstClearancePercent: 92 ) + ) - return RelayResultsCardView(result: result, isExpanded: .constant(false)) - .padding() + return RelayResultsCardView(result: result, isExpanded: .constant(false)) + .padding() } #Preview("Relay - Mixed Clearance - Expanded") { - let result = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", - endLabel: "R", - clearanceStatus: .clear, - distanceMeters: 5200, - worstClearancePercent: 85 - ), - segmentRB: SegmentAnalysisResult( - startLabel: "R", - endLabel: "B", - clearanceStatus: .partialObstruction, - distanceMeters: 7200, - worstClearancePercent: 45 - ) + let result = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", + endLabel: "R", + clearanceStatus: .clear, + distanceMeters: 5200, + worstClearancePercent: 85 + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", + endLabel: "B", + clearanceStatus: .partialObstruction, + distanceMeters: 7200, + worstClearancePercent: 45 ) + ) - return RelayResultsCardView(result: result, isExpanded: .constant(true)) - .padding() + return RelayResultsCardView(result: result, isExpanded: .constant(true)) + .padding() } diff --git a/MC1/Views/Tools/LineOfSight/FresnelZoneRenderer.swift b/MC1/Views/Tools/LineOfSight/FresnelZoneRenderer.swift index bc3e1066..8b50f9cd 100644 --- a/MC1/Views/Tools/LineOfSight/FresnelZoneRenderer.swift +++ b/MC1/Views/Tools/LineOfSight/FresnelZoneRenderer.swift @@ -2,115 +2,125 @@ import MC1Services import SwiftUI enum FresnelZoneRenderer { - - /// Calculate LOS height at a given distance along the path - /// - Parameters: - /// - atDistance: Distance from point A in meters - /// - totalDistance: Total path distance in meters - /// - heightA: Antenna height at A (ground + antenna) in meters - /// - heightB: Antenna height at B (ground + antenna) in meters - /// - Returns: LOS height in meters above sea level - static func losHeight( - atDistance: Double, - totalDistance: Double, - heightA: Double, - heightB: Double - ) -> Double { - guard totalDistance > 0 else { return heightA } - let fraction = atDistance / totalDistance - return heightA + fraction * (heightB - heightA) - } - - /// Build profile samples from elevation data - /// - Parameters: - /// - elevationProfile: Array of elevation samples from terrain API (can be a segment slice) - /// - pointAHeight: Antenna height at segment start in meters above ground - /// - pointBHeight: Antenna height at segment end in meters above ground - /// - frequencyMHz: Operating frequency for Fresnel zone calculation - /// - refractionK: Effective earth radius factor for earth bulge calculation - /// - Returns: Array of ProfileSample with computed LOS heights and Fresnel radii - /// - /// Note: When passing a segment slice (e.g., R→B), the samples may have non-zero - /// distanceFromAMeters values. This method calculates Fresnel zones relative to - /// the segment boundaries while preserving original x-coordinates for rendering. - static func buildProfileSamples( - from elevationProfile: [ElevationSample], - pointAHeight: Double, - pointBHeight: Double, - frequencyMHz: Double, - refractionK: Double - ) -> [ProfileSample] { - guard let first = elevationProfile.first, - let last = elevationProfile.last else { return [] } - - // Calculate segment-relative distances for Fresnel/LOS calculations - // This handles both full paths (offset=0) and segment slices (offset>0) - let segmentOffset = first.distanceFromAMeters - let segmentLength = last.distanceFromAMeters - segmentOffset - - let heightA = first.elevation + pointAHeight - let heightB = last.elevation + pointBHeight - - return elevationProfile.map { sample in - // Use segment-relative distances for physics calculations - let distanceFromSegmentStart = sample.distanceFromAMeters - segmentOffset - let distanceToSegmentEnd = segmentLength - distanceFromSegmentStart - - let yLOS = losHeight( - atDistance: distanceFromSegmentStart, - totalDistance: segmentLength, - heightA: heightA, - heightB: heightB - ) - - let radius = RFCalculator.fresnelRadius( - frequencyMHz: frequencyMHz, - distanceToAMeters: distanceFromSegmentStart, - distanceToBMeters: distanceToSegmentEnd - ) - - let earthBulge = RFCalculator.earthBulge( - distanceToAMeters: distanceFromSegmentStart, - distanceToBMeters: distanceToSegmentEnd, - refractionK: refractionK - ) - - // Preserve original x-coordinate for correct rendering position - return ProfileSample( - x: sample.distanceFromAMeters, - yTerrain: sample.elevation + earthBulge, - yLOS: yLOS, - fresnelRadius: radius - ) - } + /// Calculate LOS height at a given distance along the path + /// - Parameters: + /// - atDistance: Distance from point A in meters + /// - totalDistance: Total path distance in meters + /// - heightA: Antenna height at A (ground + antenna) in meters + /// - heightB: Antenna height at B (ground + antenna) in meters + /// - Returns: LOS height in meters above sea level + static func losHeight( + atDistance: Double, + totalDistance: Double, + heightA: Double, + heightB: Double + ) -> Double { + guard totalDistance > 0 else { return heightA } + let fraction = atDistance / totalDistance + return heightA + fraction * (heightB - heightA) + } + + /// Build profile samples from elevation data + /// - Parameters: + /// - elevationProfile: Array of elevation samples from terrain API (can be a segment slice) + /// - pointAHeight: Antenna height at segment start in meters above ground + /// - pointBHeight: Antenna height at segment end in meters above ground + /// - frequencyMHz: Operating frequency for Fresnel zone calculation + /// - refractionK: Effective earth radius factor for earth bulge calculation + /// - Returns: Array of ProfileSample with computed LOS heights and Fresnel radii + /// + /// Note: When passing a segment slice (e.g., R→B), the samples may have non-zero + /// distanceFromAMeters values. This method calculates Fresnel zones relative to + /// the segment boundaries while preserving original x-coordinates for rendering. + static func buildProfileSamples( + from elevationProfile: [ElevationSample], + pointAHeight: Double, + pointBHeight: Double, + frequencyMHz: Double, + refractionK: Double + ) -> [ProfileSample] { + guard let first = elevationProfile.first, + let last = elevationProfile.last else { return [] } + + // Calculate segment-relative distances for Fresnel/LOS calculations + // This handles both full paths (offset=0) and segment slices (offset>0) + let segmentOffset = first.distanceFromAMeters + let segmentLength = last.distanceFromAMeters - segmentOffset + + let heightA = first.elevation + pointAHeight + let heightB = last.elevation + pointBHeight + + return elevationProfile.map { sample in + // Use segment-relative distances for physics calculations + let distanceFromSegmentStart = sample.distanceFromAMeters - segmentOffset + let distanceToSegmentEnd = segmentLength - distanceFromSegmentStart + + let yLOS = losHeight( + atDistance: distanceFromSegmentStart, + totalDistance: segmentLength, + heightA: heightA, + heightB: heightB + ) + + let radius = RFCalculator.fresnelRadius( + frequencyMHz: frequencyMHz, + distanceToAMeters: distanceFromSegmentStart, + distanceToBMeters: distanceToSegmentEnd + ) + + let earthBulge = RFCalculator.earthBulge( + distanceToAMeters: distanceFromSegmentStart, + distanceToBMeters: distanceToSegmentEnd, + refractionK: refractionK + ) + + // Preserve original x-coordinate for correct rendering position + return ProfileSample( + x: sample.distanceFromAMeters, + yTerrain: sample.elevation + earthBulge, + yLOS: yLOS, + fresnelRadius: radius + ) } - + } } /// Sample point with all computed values for rendering struct ProfileSample { - let x: Double // distance from A in meters - let yTerrain: Double // terrain elevation in meters - let yLOS: Double // line of sight height in meters - let fresnelRadius: Double - - var yTop: Double { yLOS + fresnelRadius } - var yBottom: Double { yLOS - fresnelRadius } - - // Inner 60% zone boundaries (ideal clearance threshold) - var yTop60: Double { yLOS + fresnelRadius * 0.6 } - var yBottom60: Double { yLOS - fresnelRadius * 0.6 } - - /// Visible bottom of inner 60% zone (clamped) - var yVisibleBottom60: Double { - min(max(yTerrain, yBottom60), yTop60) - } - - /// Whether terrain intrudes past the 60% Fresnel clearance threshold at this point - var isObstructed: Bool { yTerrain > yBottom60 } - - /// Visible bottom of Fresnel zone (clamped to avoid path inversion) - var yVisibleBottom: Double { - min(max(yTerrain, yBottom), yTop) - } + let x: Double // distance from A in meters + let yTerrain: Double // terrain elevation in meters + let yLOS: Double // line of sight height in meters + let fresnelRadius: Double + + var yTop: Double { + yLOS + fresnelRadius + } + + var yBottom: Double { + yLOS - fresnelRadius + } + + /// Inner 60% zone boundaries (ideal clearance threshold) + var yTop60: Double { + yLOS + fresnelRadius * 0.6 + } + + var yBottom60: Double { + yLOS - fresnelRadius * 0.6 + } + + /// Visible bottom of inner 60% zone (clamped) + var yVisibleBottom60: Double { + min(max(yTerrain, yBottom60), yTop60) + } + + /// Whether terrain intrudes past the 60% Fresnel clearance threshold at this point + var isObstructed: Bool { + yTerrain > yBottom60 + } + + /// Visible bottom of Fresnel zone (clamped to avoid path inversion) + var yVisibleBottom: Double { + min(max(yTerrain, yBottom), yTop) + } } diff --git a/MC1/Views/Tools/LineOfSight/HeightEditorGrid.swift b/MC1/Views/Tools/LineOfSight/HeightEditorGrid.swift index c925607c..189b0ae5 100644 --- a/MC1/Views/Tools/LineOfSight/HeightEditorGrid.swift +++ b/MC1/Views/Tools/LineOfSight/HeightEditorGrid.swift @@ -1,77 +1,77 @@ import SwiftUI struct HeightEditorGrid: View { - let groundElevation: Double? - @Binding var additionalHeight: Double - let range: ClosedRange - var onHeightChanged: (() -> Void)? + let groundElevation: Double? + @Binding var additionalHeight: Double + let range: ClosedRange + var onHeightChanged: (() -> Void)? - private var heightStep: Double { - Locale.current.measurementSystem != .metric ? 0.3048 : 1.0 - } + private var heightStep: Double { + Locale.current.measurementSystem != .metric ? 0.3048 : 1.0 + } - var body: some View { - Grid(alignment: .leading, verticalSpacing: 8) { - if let groundElevation { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.groundElevation) - .font(.caption) - .foregroundStyle(.secondary) + var body: some View { + Grid(alignment: .leading, verticalSpacing: 8) { + if let groundElevation { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.groundElevation) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Text(Measurement(value: groundElevation, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) - .font(.caption) - .monospacedDigit() - } - } else { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.groundElevation) - .font(.caption) - .foregroundStyle(.secondary) + Text(Measurement(value: groundElevation, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + } + } else { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.groundElevation) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - ProgressView() - .controlSize(.mini) - } - } + ProgressView() + .controlSize(.mini) + } + } - GridRow { - Text(L10n.Tools.Tools.LineOfSight.additionalHeight) - .font(.caption) - .foregroundStyle(.secondary) + GridRow { + Text(L10n.Tools.Tools.LineOfSight.additionalHeight) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Stepper(value: $additionalHeight, in: range, step: heightStep) { - Text(Measurement(value: additionalHeight, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) - .font(.caption) - .monospacedDigit() - } - .controlSize(.small) - .onChange(of: additionalHeight) { - onHeightChanged?() - } - } + Stepper(value: $additionalHeight, in: range, step: heightStep) { + Text(Measurement(value: additionalHeight, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + } + .controlSize(.small) + .onChange(of: additionalHeight) { + onHeightChanged?() + } + } - if let groundElevation { - Divider() - .gridCellColumns(2) + if let groundElevation { + Divider() + .gridCellColumns(2) - GridRow { - Text(L10n.Tools.Tools.LineOfSight.totalHeight) - .font(.caption) - .bold() + GridRow { + Text(L10n.Tools.Tools.LineOfSight.totalHeight) + .font(.caption) + .bold() - Spacer() + Spacer() - Text(Measurement(value: groundElevation + additionalHeight, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) - .font(.caption) - .monospacedDigit() - .bold() - } - } + Text(Measurement(value: groundElevation + additionalHeight, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + .bold() } + } } + } } diff --git a/MC1/Views/Tools/LineOfSight/LineOfSightLayoutMode.swift b/MC1/Views/Tools/LineOfSight/LineOfSightLayoutMode.swift index e2e1076f..b49e2e87 100644 --- a/MC1/Views/Tools/LineOfSight/LineOfSightLayoutMode.swift +++ b/MC1/Views/Tools/LineOfSight/LineOfSightLayoutMode.swift @@ -1,5 +1,5 @@ enum LineOfSightLayoutMode { - case map - case panel - case mapWithSheet + case map + case panel + case mapWithSheet } diff --git a/MC1/Views/Tools/LineOfSight/LineOfSightView.swift b/MC1/Views/Tools/LineOfSight/LineOfSightView.swift index a465725b..e71fbc25 100644 --- a/MC1/Views/Tools/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/Tools/LineOfSight/LineOfSightView.swift @@ -11,493 +11,475 @@ private let analysisSheetDetentExpanded: PresentationDetent = .large /// Full-screen map view for analyzing line-of-sight between two points struct LineOfSightView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - - @State private var viewModel: LineOfSightViewModel - @State private var sheetDetent: PresentationDetent = analysisSheetDetentCollapsed - @State private var enableHalfDetent = false - @State private var showAnalysisSheet: Bool - @State private var editingPoint: PointID? - @State private var isDropPinMode = false - @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard - @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels - @State private var sheetBottomInset: CGFloat = 220 - @State private var isResultsExpanded = false - @State private var isRFSettingsExpanded = false - @State private var showingMapStyleMenu = false - @State private var copyHapticTrigger = 0 - - private let layoutMode: LineOfSightLayoutMode - - // One-time drag hint tooltip for repeater marker - @AppStorage(AppStorageKey.hasSeenRepeaterDragHint.rawValue) private var hasSeenDragHint = AppStorageKey.defaultHasSeenRepeaterDragHint - @State private var showDragHint = false - @State private var repeaterMarkerCenter: CGPoint? - @State private var isNavigatingBack = false - - private var isRelocating: Bool { viewModel.relocatingPoint != nil } - - private var shouldShowExpandedAnalysis: Bool { - sheetDetent != analysisSheetDetentCollapsed + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var viewModel: LineOfSightViewModel + @State private var sheetDetent: PresentationDetent = analysisSheetDetentCollapsed + @State private var enableHalfDetent = false + @State private var showAnalysisSheet: Bool + @State private var editingPoint: PointID? + @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels + @AppStorage(AppStorageKey.mapNorthLocked.rawValue) private var isNorthLocked = AppStorageKey.defaultMapNorthLocked + @State private var sheetBottomInset: CGFloat = 220 + @State private var isResultsExpanded = false + @State private var isRFSettingsExpanded = false + @State private var copyHapticTrigger = 0 + + private let layoutMode: LineOfSightLayoutMode + + // One-time drag hint tooltip for repeater marker + @AppStorage(AppStorageKey.hasSeenRepeaterDragHint.rawValue) private var hasSeenDragHint = AppStorageKey.defaultHasSeenRepeaterDragHint + @State private var showDragHint = false + @State private var repeaterMarkerCenter: CGPoint? + @State private var isNavigatingBack = false + + private var isRelocating: Bool { + viewModel.relocatingPoint != nil + } + + private var shouldShowExpandedAnalysis: Bool { + sheetDetent != analysisSheetDetentCollapsed + } + + private var mapOverlayBottomPadding: CGFloat { + showAnalysisSheet ? sheetBottomInset : 0 + } + + private var availableSheetDetents: Set { + if enableHalfDetent { + [analysisSheetDetentCollapsed, analysisSheetDetentHalf, analysisSheetDetentExpanded] + } else { + [analysisSheetDetentCollapsed, analysisSheetDetentExpanded] } + } - private var mapOverlayBottomPadding: CGFloat { - showAnalysisSheet ? sheetBottomInset : 0 - } - - private var availableSheetDetents: Set { - if enableHalfDetent { - [analysisSheetDetentCollapsed, analysisSheetDetentHalf, analysisSheetDetentExpanded] - } else { - [analysisSheetDetentCollapsed, analysisSheetDetentExpanded] - } - } + // MARK: - Initialization - // MARK: - Initialization + init(preselectedContact: ContactDTO? = nil) { + _viewModel = State(initialValue: LineOfSightViewModel(preselectedContact: preselectedContact)) + layoutMode = .mapWithSheet + _showAnalysisSheet = State(initialValue: true) + } - init(preselectedContact: ContactDTO? = nil) { - _viewModel = State(initialValue: LineOfSightViewModel(preselectedContact: preselectedContact)) - layoutMode = .mapWithSheet - _showAnalysisSheet = State(initialValue: true) - } + init(viewModel: LineOfSightViewModel, layoutMode: LineOfSightLayoutMode) { + _viewModel = State(initialValue: viewModel) + self.layoutMode = layoutMode + _showAnalysisSheet = State(initialValue: layoutMode == .mapWithSheet) + } - init(viewModel: LineOfSightViewModel, layoutMode: LineOfSightLayoutMode) { - _viewModel = State(initialValue: viewModel) - self.layoutMode = layoutMode - _showAnalysisSheet = State(initialValue: layoutMode == .mapWithSheet) - } + // MARK: - Body - // MARK: - Body + var body: some View { + switch layoutMode { + case .panel: + ScrollView { + analysisSheetContent + } + .scrollDismissesKeyboard(.immediately) - var body: some View { - switch layoutMode { - case .panel: - ScrollView { - analysisSheetContent - } - .scrollDismissesKeyboard(.immediately) + case .map: + mapCanvasWithBehaviors(showSheet: false) - case .map: - mapCanvasWithBehaviors(showSheet: false) + case .mapWithSheet: + mapCanvasWithBehaviors(showSheet: true) + } + } + + @ViewBuilder + private func mapCanvasWithBehaviors(showSheet: Bool) -> some View { + let base = LOSMapCanvasView( + viewModel: viewModel, + appState: appState, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels, + isNorthLocked: $isNorthLocked, + mapOverlayBottomPadding: mapOverlayBottomPadding, + cameraBottomSheetFraction: showSheet ? 0.25 : 0, + onRepeaterTap: { handleRepeaterTap($0) }, + onMapTap: { handleMapTap(at: $0) }, + onMapLongPress: { handleMapLongPress(at: $0) } + ) + .onChange(of: viewModel.pointA) { oldValue, newValue in + if oldValue == nil, newValue != nil, viewModel.pointB != nil { + if showSheet { + enableHalfDetent = true + withAnimation { + sheetDetent = analysisSheetDetentHalf + } + } + } - case .mapWithSheet: - mapCanvasWithBehaviors(showSheet: true) + if showSheet, newValue == nil, viewModel.pointB == nil { + withAnimation { + sheetDetent = analysisSheetDetentCollapsed } + } } - - @ViewBuilder - private func mapCanvasWithBehaviors(showSheet: Bool) -> some View { - let base = LOSMapCanvasView( - viewModel: viewModel, - appState: appState, - mapStyleSelection: $mapStyleSelection, - showingMapStyleMenu: $showingMapStyleMenu, - showLabels: $showLabels, - isDropPinMode: $isDropPinMode, - mapOverlayBottomPadding: mapOverlayBottomPadding, - cameraBottomSheetFraction: showSheet ? 0.25 : 0, - onRepeaterTap: { handleRepeaterTap($0) }, - onMapTap: { handleMapTap(at: $0) } - ) - .onChange(of: viewModel.pointA) { oldValue, newValue in - if oldValue == nil, newValue != nil, viewModel.pointB != nil { - if showSheet { - enableHalfDetent = true - withAnimation { - sheetDetent = analysisSheetDetentHalf - } - } - } - - if showSheet, newValue == nil, viewModel.pointB == nil { - withAnimation { - sheetDetent = analysisSheetDetentCollapsed - } - } - } - .onChange(of: viewModel.pointB) { oldValue, newValue in - if oldValue == nil, newValue != nil, viewModel.pointA != nil { - if showSheet { - enableHalfDetent = true - withAnimation { - sheetDetent = analysisSheetDetentHalf - } - } - } - - if showSheet, newValue == nil, viewModel.pointA == nil { - withAnimation { - sheetDetent = analysisSheetDetentCollapsed - } - } - } - .onChange(of: sheetDetent) { oldValue, newValue in - guard showSheet else { return } - - if isRelocating, newValue != analysisSheetDetentCollapsed { - viewModel.relocatingPoint = nil - } - - // Disable half detent once user drags away from it - if oldValue == analysisSheetDetentHalf, newValue != analysisSheetDetentHalf { - enableHalfDetent = false - } - } - .onChange(of: viewModel.repeaterPoint) { oldValue, newValue in - if oldValue == nil, - newValue != nil, - newValue?.isOnPath == true, - !hasSeenDragHint { - withAnimation(.easeIn(duration: 0.3)) { - showDragHint = true - } - hasSeenDragHint = true - Task { - try? await Task.sleep(for: .seconds(5)) - withAnimation(.easeOut(duration: 0.3)) { - showDragHint = false - } - } - } - } - .onChange(of: viewModel.analysisStatus) { _, newStatus in - handleAnalysisStatusChange(newStatus, showSheet: showSheet) - } - .task { - appState.locationService.requestPermissionIfNeeded() - viewModel.configure( - dataStore: { [appState] in appState.offlineDataStore }, - radioID: { [appState] in appState.currentRadioID }, - deviceFrequencyKHz: appState.connectedDevice?.frequency - ) - viewModel.showLabels = showLabels - await viewModel.loadRepeaters() - viewModel.centerOnAllRepeaters() - } - .onChange(of: showLabels) { _, newValue in - viewModel.showLabels = newValue - } - + .onChange(of: viewModel.pointB) { oldValue, newValue in + if oldValue == nil, newValue != nil, viewModel.pointA != nil { if showSheet { - base - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - dismissLineOfSight() - } label: { - Label(L10n.Tools.Tools.LineOfSight.back, systemImage: "chevron.left") - .labelStyle(.titleAndIcon) - } - .accessibilityLabel(L10n.Tools.Tools.LineOfSight.back) - } - } - .liquidGlassToolbarBackground() - .onDisappear { - showAnalysisSheet = false - } - .sheet(isPresented: $showAnalysisSheet) { - analysisSheet - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - proxy.safeAreaInsets.bottom + 15 - } action: { inset in - if sheetDetent == analysisSheetDetentCollapsed { - sheetBottomInset = max(0, inset) - } - } - .presentationDetents(availableSheetDetents, selection: $sheetDetent) - .presentationDragIndicator(.visible) - .presentationBackgroundInteraction(.enabled) - .presentationBackground(.regularMaterial) - .interactiveDismissDisabled() - } - } else { - base - .liquidGlassToolbarBackground() + enableHalfDetent = true + withAnimation { + sheetDetent = analysisSheetDetentHalf + } } - } + } - @MainActor - private func dismissLineOfSight() { - guard !isNavigatingBack else { return } - isNavigatingBack = true + if showSheet, newValue == nil, viewModel.pointA == nil { + withAnimation { + sheetDetent = analysisSheetDetentCollapsed + } + } + } + .onChange(of: sheetDetent) { oldValue, newValue in + guard showSheet else { return } - showAnalysisSheet = false + if isRelocating, newValue != analysisSheetDetentCollapsed { viewModel.relocatingPoint = nil + } - // Yield to let showAnalysisSheet = false commit before dismiss fires, - // avoiding a sheet-dismissal animation conflict. - Task { @MainActor in - await Task.yield() - dismiss() + // Disable half detent once user drags away from it + if oldValue == analysisSheetDetentHalf, newValue != analysisSheetDetentHalf { + enableHalfDetent = false + } + } + .onChange(of: viewModel.repeaterPoint) { oldValue, newValue in + if oldValue == nil, + newValue != nil, + newValue?.isOnPath == true, + !hasSeenDragHint { + withAnimation(.easeIn(duration: 0.3)) { + showDragHint = true + } + hasSeenDragHint = true + Task { + try? await Task.sleep(for: .seconds(5)) + withAnimation(.easeOut(duration: 0.3)) { + showDragHint = false + } } + } + } + .onChange(of: viewModel.analysisStatus) { _, newStatus in + handleAnalysisStatusChange(newStatus, showSheet: showSheet) + } + .task { + appState.locationService.requestPermissionIfNeeded() + viewModel.configure( + dataStore: { [appState] in appState.offlineDataStore }, + radioID: { [appState] in appState.currentRadioID }, + deviceFrequencyKHz: appState.connectedDevice?.frequency + ) + viewModel.showLabels = showLabels + await viewModel.loadRepeaters() + viewModel.centerOnAllRepeaters() + } + .onChange(of: showLabels) { _, newValue in + viewModel.showLabels = newValue } - // MARK: - Analysis Sheet - - private var analysisSheet: some View { - NavigationStack { - ScrollView { - analysisSheetContent + if showSheet { + base + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismissLineOfSight() + } label: { + Label(L10n.Tools.Tools.LineOfSight.back, systemImage: "chevron.left") + .labelStyle(.titleAndIcon) } - .scrollDismissesKeyboard(.immediately) - .toolbar(.hidden, for: .navigationBar) + .accessibilityLabel(L10n.Tools.Tools.LineOfSight.back) + } } + .liquidGlassToolbarBackground() + .onDisappear { + showAnalysisSheet = false + } + .sheet(isPresented: $showAnalysisSheet) { + analysisSheet + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height - proxy.safeAreaInsets.bottom - 16 + } action: { inset in + if sheetDetent == analysisSheetDetentCollapsed { + sheetBottomInset = max(0, inset) + } + } + .presentationDetents(availableSheetDetents, selection: $sheetDetent) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled) + .presentationBackground(.regularMaterial) + .interactiveDismissDisabled() + } + } else { + base + .liquidGlassToolbarBackground() } + } - private var analysisSheetContent: some View { - VStack(alignment: .leading, spacing: 16) { - PointsSummarySectionView( - viewModel: viewModel, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: { withAnimation { sheetDetent = analysisSheetDetentCollapsed } } - ) - - // Before analysis: show analyze button, then RF settings - if viewModel.canAnalyze, !hasAnalysisResult { - analyzeButtonSection - RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) - } + @MainActor + private func dismissLineOfSight() { + guard !isNavigatingBack else { return } + isNavigatingBack = true - // After analysis: show button, results, terrain, then RF settings - if case .result(let result) = viewModel.analysisStatus { - analyzeButtonSection + showAnalysisSheet = false + viewModel.relocatingPoint = nil - resultSummarySection(result) + // Yield to let showAnalysisSheet = false commit before dismiss fires, + // avoiding a sheet-dismissal animation conflict. + Task { @MainActor in + await Task.yield() + dismiss() + } + } - if shouldShowExpandedAnalysis { - TerrainProfileSectionView( - viewModel: viewModel, - showDragHint: $showDragHint, - repeaterMarkerCenter: $repeaterMarkerCenter - ) - RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) - } - } + // MARK: - Analysis Sheet - // Relay analysis: show relay-specific results card - if case .relayResult(let result) = viewModel.analysisStatus { - analyzeButtonSection + private var analysisSheet: some View { + NavigationStack { + ScrollView { + analysisSheetContent + } + .scrollDismissesKeyboard(.immediately) + .toolbar(.hidden, for: .navigationBar) + } + } + + private var analysisSheetContent: some View { + VStack(alignment: .leading, spacing: 16) { + PointsSummarySectionView( + viewModel: viewModel, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: { withAnimation { sheetDetent = analysisSheetDetentCollapsed } } + ) + + // Before analysis: show analyze button, then RF settings + if viewModel.canAnalyze, !hasAnalysisResult { + analyzeButtonSection + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) + } + + // After analysis: show button, results, terrain, then RF settings + if case let .result(result) = viewModel.analysisStatus { + analyzeButtonSection + + resultSummarySection(result) + + if shouldShowExpandedAnalysis { + TerrainProfileSectionView( + viewModel: viewModel, + showDragHint: $showDragHint, + repeaterMarkerCenter: $repeaterMarkerCenter + ) + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) + } + } - RelayResultsCardView(result: result, isExpanded: $isResultsExpanded) + // Relay analysis: show relay-specific results card + if case let .relayResult(result) = viewModel.analysisStatus { + analyzeButtonSection - if shouldShowExpandedAnalysis { - TerrainProfileSectionView( - viewModel: viewModel, - showDragHint: $showDragHint, - repeaterMarkerCenter: $repeaterMarkerCenter - ) - RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) - } - } + RelayResultsCardView(result: result, isExpanded: $isResultsExpanded) - if case .error(let message) = viewModel.analysisStatus { - AnalysisErrorView( - message: message, - hasRepeater: viewModel.repeaterPoint != nil, - onRetry: { - if viewModel.repeaterPoint != nil { - viewModel.analyzeWithRepeater() - } else { - viewModel.analyze() - } - } - ) - } + if shouldShowExpandedAnalysis { + TerrainProfileSectionView( + viewModel: viewModel, + showDragHint: $showDragHint, + repeaterMarkerCenter: $repeaterMarkerCenter + ) + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } - .padding() - } + } - // MARK: - Analyze Button Section - - private var analyzeButtonSection: some View { - AnalyzeButton( - viewModel: viewModel, - hasAnalysisResult: hasAnalysisResult, - onAnalyze: { - withAnimation { sheetDetent = analysisSheetDetentExpanded } + if case let .error(message) = viewModel.analysisStatus { + AnalysisErrorView( + message: message, + hasRepeater: viewModel.repeaterPoint != nil, + onRetry: { + if viewModel.repeaterPoint != nil { + viewModel.analyzeWithRepeater() + } else { + viewModel.analyze() } + } ) + } } + .padding() + } + + // MARK: - Analyze Button Section + + private var analyzeButtonSection: some View { + AnalyzeButton( + viewModel: viewModel, + hasAnalysisResult: hasAnalysisResult, + onAnalyze: { + withAnimation { sheetDetent = analysisSheetDetentExpanded } + } + ) + } - // MARK: - Result Summary Section - - @ViewBuilder - private func resultSummarySection(_ result: PathAnalysisResult) -> some View { - ResultsCardView(result: result, isExpanded: $isResultsExpanded) - } + // MARK: - Result Summary Section - // MARK: - Computed Properties + private func resultSummarySection(_ result: PathAnalysisResult) -> some View { + ResultsCardView(result: result, isExpanded: $isResultsExpanded) + } - private var analysisResult: PathAnalysisResult? { - if case .result(let result) = viewModel.analysisStatus { - return result - } - return nil - } + // MARK: - Computed Properties - private var hasAnalysisResult: Bool { - if case .result = viewModel.analysisStatus { return true } - if case .relayResult = viewModel.analysisStatus { return true } - return false + private var analysisResult: PathAnalysisResult? { + if case let .result(result) = viewModel.analysisStatus { + return result } + return nil + } - // MARK: - Helper Methods + private var hasAnalysisResult: Bool { + if case .result = viewModel.analysisStatus { return true } + if case .relayResult = viewModel.analysisStatus { return true } + return false + } - private func handleMapTap(at coordinate: CLLocationCoordinate2D) { - // Handle relocation mode - if let relocating = viewModel.relocatingPoint { - handleRelocation(to: coordinate, for: relocating) - return - } + // MARK: - Helper Methods - // Handle drop pin mode - guard isDropPinMode else { return } - viewModel.selectPoint(at: coordinate) - isDropPinMode = false + private func handleMapTap(at coordinate: CLLocationCoordinate2D) { + if let relocating = viewModel.relocatingPoint { + handleRelocation(to: coordinate, for: relocating) } + } - private func handleRelocation(to coordinate: CLLocationCoordinate2D, for pointID: PointID) { - switch pointID { - case .pointA: - viewModel.setPointA(coordinate: coordinate, contact: nil) - case .pointB: - viewModel.setPointB(coordinate: coordinate, contact: nil) - case .repeater: - viewModel.setRepeaterOffPath(coordinate: coordinate) - } - - // Clear results and show Analyze button - viewModel.clearAnalysisResults() - viewModel.relocatingPoint = nil - enableHalfDetent = true - withAnimation { - sheetDetent = analysisSheetDetentHalf - } + private func handleMapLongPress(at coordinate: CLLocationCoordinate2D) { + if let relocating = viewModel.relocatingPoint { + handleRelocation(to: coordinate, for: relocating) + return + } + viewModel.selectPoint(at: coordinate) + } + + private func handleRelocation(to coordinate: CLLocationCoordinate2D, for pointID: PointID) { + switch pointID { + case .pointA: + viewModel.setPointA(coordinate: coordinate, contact: nil) + case .pointB: + viewModel.setPointB(coordinate: coordinate, contact: nil) + case .repeater: + viewModel.setRepeaterOffPath(coordinate: coordinate) } - private func handleAnalysisStatusChange(_ status: AnalysisStatus, showSheet: Bool) { - switch status { - case .result: - if showSheet { - sheetDetent = analysisSheetDetentExpanded - } - case .relayResult: - break - default: - return - } - - if viewModel.shouldAutoZoomOnNextResult { - viewModel.shouldAutoZoomOnNextResult = false - viewModel.zoomToShowBothPoints() - } + // Clear results and show Analyze button + viewModel.clearAnalysisResults() + viewModel.relocatingPoint = nil + enableHalfDetent = true + withAnimation { + sheetDetent = analysisSheetDetentHalf + } + } + + private func handleAnalysisStatusChange(_ status: AnalysisStatus, showSheet: Bool) { + switch status { + case .result: + if showSheet { + sheetDetent = analysisSheetDetentExpanded + } + case .relayResult: + break + default: + return } - private func handleRepeaterTap(_ contact: ContactDTO) { - viewModel.toggleContact(contact) + if viewModel.shouldAutoZoomOnNextResult { + viewModel.shouldAutoZoomOnNextResult = false + viewModel.zoomToShowBothPoints() } + } + + private func handleRepeaterTap(_ contact: ContactDTO) { + viewModel.toggleContact(contact) + } } // MARK: - Map Canvas View private struct LOSMapCanvasView: View { - @Bindable var viewModel: LineOfSightViewModel - let appState: AppState - @Environment(\.colorScheme) private var colorScheme - @Binding var mapStyleSelection: MapStyleSelection - @Binding var showingMapStyleMenu: Bool - @Binding var showLabels: Bool - @Binding var isDropPinMode: Bool - let mapOverlayBottomPadding: CGFloat - let cameraBottomSheetFraction: CGFloat? - let onRepeaterTap: (ContactDTO) -> Void - let onMapTap: (CLLocationCoordinate2D) -> Void - - var body: some View { - ZStack { - MC1MapView( - points: viewModel.mapPoints, - lines: viewModel.mapLines, - mapStyle: mapStyleSelection, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: showLabels, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - isNorthLocked: viewModel.isNorthLocked, - cameraRegion: $viewModel.cameraRegion, - cameraRegionVersion: viewModel.cameraRegionVersion, - cameraBottomSheetFraction: cameraBottomSheetFraction, - onPointTap: { point, _ in - if let repeater = viewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { - onRepeaterTap(repeater) - } - }, - onMapTap: onMapTap, - onCameraRegionChange: { region in - viewModel.cameraRegion = region - }, - ) - .ignoresSafeArea() - - VStack { - Spacer() - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: { - Task { - if let location = try? await appState.locationService.requestCurrentLocation() { - viewModel.setCameraRegion(MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - )) - } - } - }, - isNorthLocked: $viewModel.isNorthLocked, - showLabels: $showLabels, - showingLayersMenu: $showingMapStyleMenu - ) { - Button(isDropPinMode ? L10n.Tools.Tools.LineOfSight.cancelDropPin : L10n.Tools.Tools.LineOfSight.dropPin, systemImage: isDropPinMode ? "mappin.slash" : "mappin") { - isDropPinMode.toggle() - } - .mapControlButton(tint: isDropPinMode ? .blue : .primary) - } - } - } - .padding(.bottom, mapOverlayBottomPadding) - - if showingMapStyleMenu { - Button { - withAnimation { showingMapStyleMenu = false } - } label: { - Color.black.opacity(0.3).ignoresSafeArea() - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) - - VStack { - Spacer() - HStack { - Spacer() - LayersMenu( - selection: $mapStyleSelection, - isPresented: $showingMapStyleMenu, - viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() - ) - .padding(.trailing) - } + @Bindable var viewModel: LineOfSightViewModel + let appState: AppState + @Environment(\.colorScheme) private var colorScheme + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showLabels: Bool + @Binding var isNorthLocked: Bool + let mapOverlayBottomPadding: CGFloat + let cameraBottomSheetFraction: CGFloat? + let onRepeaterTap: (ContactDTO) -> Void + let onMapTap: (CLLocationCoordinate2D) -> Void + let onMapLongPress: (CLLocationCoordinate2D) -> Void + + @State private var isCenteredOnUser = false + + var body: some View { + ZStack { + MC1MapView( + points: viewModel.mapPoints, + lines: viewModel.mapLines, + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: isNorthLocked, + cameraRegion: $viewModel.cameraRegion, + cameraRegionVersion: viewModel.cameraRegionVersion, + cameraBottomSheetFraction: cameraBottomSheetFraction, + onPointTap: { point, _ in + if let repeater = viewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { + onRepeaterTap(repeater) + } + }, + onMapTap: onMapTap, + onMapLongPress: onMapLongPress, + onCameraRegionChange: { region in + viewModel.cameraRegion = region + }, + isCenteredOnUser: $isCenteredOnUser + ) + .ignoresSafeArea() + + VStack { + Spacer() + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: { + // Unlike other maps, LOS needs a precise fresh fix (no device-GPS fallback, + // tighter span), so it fetches live rather than using AppState.centerOnUserLocation. + Task { + if let location = try? await appState.locationService.requestCurrentLocation() { + viewModel.setCameraRegion(MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + )) + isCenteredOnUser = true } - .padding(.bottom, mapOverlayBottomPadding) - } + } + }, + isCenteredOnUser: isCenteredOnUser, + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + mapStyleSelection: $mapStyleSelection, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() + ) { + EmptyView() + } } + } + .padding(.bottom, mapOverlayBottomPadding) } - + } } // MARK: - Frequency Input Row @@ -506,132 +488,132 @@ private struct LOSMapCanvasView: View { /// This is necessary because @FocusState doesn't work properly when declared in a parent view /// and used in sheet content. struct FrequencyInputRow: View { - @Bindable var viewModel: LineOfSightViewModel - @FocusState private var isFocused: Bool - @State private var text: String = "" - - var body: some View { - HStack { - Label(L10n.Tools.Tools.LineOfSight.frequency, systemImage: "antenna.radiowaves.left.and.right") - .foregroundStyle(.secondary) - Spacer() - TextField(L10n.Tools.Tools.LineOfSight.mhz, text: $text) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 80) - .focused($isFocused) - .onChange(of: text) { _, newValue in - let replaced = newValue.replacing(",", with: ".") - if replaced != newValue { - text = replaced - } - } - .onChange(of: isFocused) { _, focused in - if focused { - // Sync text from view model when gaining focus - text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) - } else { - // Commit when focus is lost - commitEdit() - } - } - - Text(L10n.Tools.Tools.LineOfSight.mhz) - .foregroundStyle(.secondary) - - if isFocused { - Button { - commitEdit() - isFocused = false - } label: { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.title2) - } - .buttonStyle(.plain) - } + @Bindable var viewModel: LineOfSightViewModel + @FocusState private var isFocused: Bool + @State private var text: String = "" + + var body: some View { + HStack { + Label(L10n.Tools.Tools.LineOfSight.frequency, systemImage: "antenna.radiowaves.left.and.right") + .foregroundStyle(.secondary) + Spacer() + TextField(L10n.Tools.Tools.LineOfSight.mhz, text: $text) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .focused($isFocused) + .onChange(of: text) { _, newValue in + let replaced = newValue.replacing(",", with: ".") + if replaced != newValue { + text = replaced + } } - .onAppear { + .onChange(of: isFocused) { _, focused in + if focused { + // Sync text from view model when gaining focus text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) + } else { + // Commit when focus is lost + commitEdit() + } } - } - private func commitEdit() { - guard let parsed = viewModel.parseFrequency(text) else { - // Reject empty, unparseable, or non-positive input by restoring - // the last valid value rather than passing it to analysis. - text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) - return + Text(L10n.Tools.Tools.LineOfSight.mhz) + .foregroundStyle(.secondary) + + if isFocused { + Button { + commitEdit() + isFocused = false + } label: { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title2) } - viewModel.frequencyMHz = parsed - viewModel.commitFrequencyChange() + .buttonStyle(.plain) + } + } + .onAppear { + text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) } + } + + private func commitEdit() { + guard let parsed = viewModel.parseFrequency(text) else { + // Reject empty, unparseable, or non-positive input by restoring + // the last valid value rather than passing it to analysis. + text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) + return + } + viewModel.frequencyMHz = parsed + viewModel.commitFrequencyChange() + } } // MARK: - Analyze Button private struct AnalyzeButton: View { - var viewModel: LineOfSightViewModel - let hasAnalysisResult: Bool - let onAnalyze: () -> Void - - var body: some View { - Button { - viewModel.shouldAutoZoomOnNextResult = true - onAnalyze() - if viewModel.repeaterPoint != nil { - viewModel.analyzeWithRepeater() - } else { - viewModel.analyze() - } - } label: { - if viewModel.isAnalyzing { - HStack { - ProgressView() - .controlSize(.small) - Text(L10n.Tools.Tools.LineOfSight.analyzing) - } - .frame(maxWidth: .infinity) - } else { - Label(L10n.Tools.Tools.LineOfSight.analyze, systemImage: "waveform.path") - .frame(maxWidth: .infinity) - } + var viewModel: LineOfSightViewModel + let hasAnalysisResult: Bool + let onAnalyze: () -> Void + + var body: some View { + Button { + viewModel.shouldAutoZoomOnNextResult = true + onAnalyze() + if viewModel.repeaterPoint != nil { + viewModel.analyzeWithRepeater() + } else { + viewModel.analyze() + } + } label: { + if viewModel.isAnalyzing { + HStack { + ProgressView() + .controlSize(.small) + Text(L10n.Tools.Tools.LineOfSight.analyzing) } - .liquidGlassProminentButtonStyle() - .controlSize(.large) - .disabled(viewModel.isAnalyzing) + .frame(maxWidth: .infinity) + } else { + Label(L10n.Tools.Tools.LineOfSight.analyze, systemImage: "waveform.path") + .frame(maxWidth: .infinity) + } } + .liquidGlassProminentButtonStyle() + .controlSize(.large) + .disabled(viewModel.isAnalyzing) + } } // MARK: - Preview #Preview("Empty") { - LineOfSightView() - .environment(\.appState, AppState()) + LineOfSightView() + .environment(\.appState, AppState()) } #Preview("With Contact") { - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x01, count: 32), - name: "Test Contact", - typeRawValue: 0, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 37.7749, - longitude: -122.4194, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - - LineOfSightView(preselectedContact: contact) - .environment(\.appState, AppState()) + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x01, count: 32), + name: "Test Contact", + typeRawValue: 0, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 37.7749, + longitude: -122.4194, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + + LineOfSightView(preselectedContact: contact) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/Tools/LineOfSight/LineOfSightViewModel.swift index aa3c1c44..7bef4292 100644 --- a/MC1/Views/Tools/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/Tools/LineOfSight/LineOfSightViewModel.swift @@ -1,8 +1,8 @@ import CoreLocation import MapKit import MC1Services -import SwiftUI import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "LineOfSight") @@ -10,15 +10,17 @@ private let logger = Logger(subsystem: "com.mc1", category: "LineOfSight") /// Identifies which point for editing in the UI enum PointID: Hashable { - case pointA - case pointB - case repeater + case pointA + case pointB + case repeater } // MARK: - PointID Identifiable Conformance extension PointID: Identifiable { - var id: Self { self } + var id: Self { + self + } } // MARK: - Repeater Point @@ -26,95 +28,95 @@ extension PointID: Identifiable { /// A repeater point for relay analysis. /// Can be on-path (from slider, uses cached profile) or off-path (relocated, needs fresh profiles). struct RepeaterPoint: Equatable { - /// The repeater's location - var coordinate: CLLocationCoordinate2D - - /// Ground elevation at coordinate (fetched async) - var groundElevation: Double? - - /// Additional height above ground in meters - var additionalHeight: Double - - /// True if repeater is on the A-B path (from slider), false if relocated off-path - var isOnPath: Bool - - /// Path fraction (only meaningful when isOnPath is true) - /// Used for terrain profile slider positioning - var pathFraction: Double { - didSet { - let clamped = pathFraction.clamped(to: 0.05...0.95) - if clamped != pathFraction { pathFraction = clamped } - } - } - - init(coordinate: CLLocationCoordinate2D, groundElevation: Double? = nil, additionalHeight: Double = 10, isOnPath: Bool = true, pathFraction: Double = 0.5) { - self.coordinate = coordinate - self.groundElevation = groundElevation - self.additionalHeight = additionalHeight - self.isOnPath = isOnPath - self.pathFraction = pathFraction.clamped(to: 0.05...0.95) - } - - static func == (lhs: RepeaterPoint, rhs: RepeaterPoint) -> Bool { - lhs.coordinate.latitude == rhs.coordinate.latitude && - lhs.coordinate.longitude == rhs.coordinate.longitude && - lhs.groundElevation == rhs.groundElevation && - lhs.additionalHeight == rhs.additionalHeight && - lhs.isOnPath == rhs.isOnPath && - lhs.pathFraction == rhs.pathFraction - } + /// The repeater's location + var coordinate: CLLocationCoordinate2D + + /// Ground elevation at coordinate (fetched async) + var groundElevation: Double? + + /// Additional height above ground in meters + var additionalHeight: Double + + /// True if repeater is on the A-B path (from slider), false if relocated off-path + var isOnPath: Bool + + /// Path fraction (only meaningful when isOnPath is true) + /// Used for terrain profile slider positioning + var pathFraction: Double { + didSet { + let clamped = pathFraction.clamped(to: 0.05...0.95) + if clamped != pathFraction { pathFraction = clamped } + } + } + + init(coordinate: CLLocationCoordinate2D, groundElevation: Double? = nil, additionalHeight: Double = 10, isOnPath: Bool = true, pathFraction: Double = 0.5) { + self.coordinate = coordinate + self.groundElevation = groundElevation + self.additionalHeight = additionalHeight + self.isOnPath = isOnPath + self.pathFraction = pathFraction.clamped(to: 0.05...0.95) + } + + static func == (lhs: RepeaterPoint, rhs: RepeaterPoint) -> Bool { + lhs.coordinate.latitude == rhs.coordinate.latitude && + lhs.coordinate.longitude == rhs.coordinate.longitude && + lhs.groundElevation == rhs.groundElevation && + lhs.additionalHeight == rhs.additionalHeight && + lhs.isOnPath == rhs.isOnPath && + lhs.pathFraction == rhs.pathFraction + } } private extension Comparable { - func clamped(to range: ClosedRange) -> Self { - min(max(self, range.lowerBound), range.upperBound) - } + func clamped(to range: ClosedRange) -> Self { + min(max(self, range.lowerBound), range.upperBound) + } } // MARK: - Selected Point /// A selected point for line of sight analysis struct SelectedPoint: Identifiable, Equatable { - let id = UUID() - let coordinate: CLLocationCoordinate2D - let contact: ContactDTO? - var groundElevation: Double? - var additionalHeight: Double = 7 - - var totalHeight: Double? { - groundElevation.map { $0 + additionalHeight } - } - - var displayName: String { - contact?.displayName ?? L10n.Tools.Tools.LineOfSight.droppedPin - } - - var isLoadingElevation: Bool { - groundElevation == nil - } - - static func == (lhs: SelectedPoint, rhs: SelectedPoint) -> Bool { - lhs.id == rhs.id && - lhs.additionalHeight == rhs.additionalHeight && - lhs.groundElevation == rhs.groundElevation - } + let id = UUID() + let coordinate: CLLocationCoordinate2D + let contact: ContactDTO? + var groundElevation: Double? + var additionalHeight: Double = 7 + + var totalHeight: Double? { + groundElevation.map { $0 + additionalHeight } + } + + var displayName: String { + contact?.displayName ?? L10n.Tools.Tools.LineOfSight.droppedPin + } + + var isLoadingElevation: Bool { + groundElevation == nil + } + + static func == (lhs: SelectedPoint, rhs: SelectedPoint) -> Bool { + lhs.id == rhs.id && + lhs.additionalHeight == rhs.additionalHeight && + lhs.groundElevation == rhs.groundElevation + } } // MARK: - Analysis Status /// Current status of path analysis enum AnalysisStatus: Equatable { - case idle - case result(PathAnalysisResult) - case relayResult(RelayPathAnalysisResult) - case error(String) + case idle + case result(PathAnalysisResult) + case relayResult(RelayPathAnalysisResult) + case error(String) } // MARK: - Repeater Selection Info /// Pre-computed selection state for a repeater annotation struct LOSRepeaterSelectionInfo { - let selectedAs: PointID? + let selectedAs: PointID? } // MARK: - View Model @@ -122,1117 +124,1124 @@ struct LOSRepeaterSelectionInfo { @Observable @MainActor final class LineOfSightViewModel { + // MARK: - Stable Map IDs - // MARK: - Stable Map IDs - - let pointAMapID = UUID() - let pointBMapID = UUID() - let repeaterTargetMapID = UUID() - - // MARK: - Point Selection State - - var pointA: SelectedPoint? { - didSet { rebuildSelectionState() } - } - var pointB: SelectedPoint? { - didSet { rebuildSelectionState() } - } - var relocatingPoint: PointID? { - didSet { rebuildMapLines() } - } - var shouldAutoZoomOnNextResult = false - - // MARK: - Camera State (MKMapView) - - var cameraRegion: MKCoordinateRegion? - private(set) var cameraRegionVersion = 0 - - // MARK: - RF Parameters - - /// Operating frequency in MHz - call `commitFrequencyChange()` after editing - var frequencyMHz: Double = 906.0 - - /// Refraction k-factor - auto-triggers re-analysis on change - var refractionK: Double = 1.0 { - didSet { - if oldValue != refractionK { - reanalyzeWithCachedProfileIfNeeded() - } - } - } - - /// Commits frequency change and triggers re-analysis with cached profile - func commitFrequencyChange() { - reanalyzeWithCachedProfileIfNeeded() - } - - // MARK: - Frequency Parsing - - /// Parses a user-entered frequency string into a positive MHz value. - /// - /// Accepts both "." and "," as the decimal separator so comma-decimal - /// locales round-trip, and parses with a fixed separator rather than the - /// current locale's. Returns nil for empty, unparseable, or non-positive - /// input so callers can ignore it instead of feeding analysis a bad value. - func parseFrequency(_ text: String) -> Double? { - let normalized = text.replacing(",", with: ".") - guard let value = Double(normalized), value > 0 else { return nil } - return value - } - - /// Renders a frequency value for editing using a locale-stable format, - /// so the displayed text always round-trips back through `parseFrequency`. - func formatFrequencyForEditing(_ value: Double) -> String { - if value.truncatingRemainder(dividingBy: 1) == 0 { - return String(Int(value)) - } - return String(format: "%.1f", value) - } - - // MARK: - Map Display State - - var isNorthLocked = false - var showLabels: Bool = true { - didSet { rebuildMapPoints() } - } - private(set) var mapPoints: [MapPoint] = [] - private(set) var mapLines: [MapLine] = [] - - // MARK: - Repeaters State - - private(set) var repeatersWithLocation: [ContactDTO] = [] { - didSet { rebuildSelectionState() } - } - - // MARK: - Repeater State - - /// The active repeater point (nil when not in use) - var repeaterPoint: RepeaterPoint? { - didSet { - rebuildMapPoints() - rebuildMapLines() - } - } - - /// Whether repeater row should be visible (analysis shows marginal or worse) - var shouldShowRepeaterRow: Bool { - // Always show if repeater exists (even after relocation clears results) - if repeaterPoint != nil { - return true - } - // Show placeholder when direct analysis is marginal or worse - return shouldShowRepeaterPlaceholder - } - - /// Whether to show the "Add Repeater" placeholder (no repeater exists, but analysis suggests one would help) - var shouldShowRepeaterPlaceholder: Bool { - guard case .result(let result) = analysisStatus else { - return false - } - // Only show if there are obstruction points (required by addRepeater()) - return result.clearanceStatus != .clear && !result.obstructionPoints.isEmpty - } - - /// Ground elevation at repeater position. - /// For on-path: interpolated from cached A→B profile. - /// For off-path: uses the fetched elevation stored in repeaterPoint. - var repeaterGroundElevation: Double? { - guard let repeaterPoint else { return nil } - if repeaterPoint.isOnPath { - return elevationAt(pathFraction: repeaterPoint.pathFraction) - } else { - return repeaterPoint.groundElevation - } - } - - /// Path fraction for repeater visualization in terrain profile - /// For on-path: uses pathFraction directly - /// For off-path: computes from A→R distance / total distance - var repeaterVisualizationPathFraction: Double? { - guard let repeaterPoint else { return nil } - if repeaterPoint.isOnPath { - return repeaterPoint.pathFraction - } - // For off-path, compute from stored profiles - guard let arLast = elevationProfileAR.last, - let rbLast = elevationProfileRB.last, - rbLast.distanceFromAMeters > 0 else { return nil } - return arLast.distanceFromAMeters / rbLast.distanceFromAMeters - } - - /// Segment A→R distance in meters (nil when on-path or no repeater) - var segmentARDistanceMeters: Double? { - guard let repeaterPoint, !repeaterPoint.isOnPath else { return nil } - guard case .relayResult(let result) = analysisStatus else { return nil } - return result.segmentAR.distanceMeters - } - - /// Segment R→B distance in meters (nil when on-path or no repeater) - var segmentRBDistanceMeters: Double? { - guard let repeaterPoint, !repeaterPoint.isOnPath else { return nil } - guard case .relayResult(let result) = analysisStatus else { return nil } - return result.segmentRB.distanceMeters - } - - // MARK: - Analysis State - - private(set) var analysisStatus: AnalysisStatus = .idle { - didSet { - rebuildMapPoints() - rebuildMapLines() - } - } - private(set) var isAnalyzing = false - private(set) var elevationProfile: [ElevationSample] = [] - - /// Profile samples for primary segment (A→B or A→R when repeater active) - private(set) var profileSamples: [ProfileSample] = [] - - /// Profile samples for R→B segment (empty when no repeater) - private(set) var profileSamplesRB: [ProfileSample] = [] - - /// Elevation profile A→R for off-path repeater - private(set) var elevationProfileAR: [ElevationSample] = [] - - /// Elevation profile R→B for off-path repeater - private(set) var elevationProfileRB: [ElevationSample] = [] - - /// Tracks whether any point elevation fetch failed (using sea level fallback) - private(set) var elevationFetchFailed = false - - // MARK: - Task Management - - private var analysisTask: Task? - private var pointAElevationTask: Task? - private var pointBElevationTask: Task? - private var repeaterElevationTask: Task? - - isolated deinit { - analysisTask?.cancel() - pointAElevationTask?.cancel() - pointBElevationTask?.cancel() - repeaterElevationTask?.cancel() - } - - // MARK: - Dependencies - - private let elevationService: ElevationServiceProtocol - private var dataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } - private var radioIDProvider: @MainActor () -> UUID? = { nil } - - private var dataStore: (any PersistenceStoreProtocol)? { dataStoreProvider() } - private var radioID: UUID? { radioIDProvider() } + let pointAMapID = UUID() + let pointBMapID = UUID() + let repeaterTargetMapID = UUID() - // MARK: - Computed Properties - - var canAnalyze: Bool { - pointA?.groundElevation != nil && pointB?.groundElevation != nil - } - - /// Pre-computed selection state for all repeaters. Rebuilt via `rebuildSelectionState()`. - private(set) var selectionState: [UUID: LOSRepeaterSelectionInfo] = [:] - - private func rebuildSelectionState() { - var result = [UUID: LOSRepeaterSelectionInfo]() - result.reserveCapacity(repeatersWithLocation.count) - - let pointAContactID = pointA?.contact?.id - let pointBContactID = pointB?.contact?.id - - for contact in repeatersWithLocation { - let selectedAs: PointID? - if contact.id == pointAContactID { - selectedAs = .pointA - } else if contact.id == pointBContactID { - selectedAs = .pointB - } else { - selectedAs = nil - } - result[contact.id] = LOSRepeaterSelectionInfo(selectedAs: selectedAs) - } - selectionState = result - rebuildMapPoints() - rebuildMapLines() - } + // MARK: - Point Selection State - // MARK: - Map Data Rebuild - - func rebuildMapPoints() { - var points: [MapPoint] = [] - - for repeater in repeatersWithLocation { - let selectedAs = selectionState[repeater.id]?.selectedAs - let style: MapPoint.PinStyle = switch selectedAs { - case .pointA: .repeaterRingBlue - case .pointB: .repeaterRingGreen - case .repeater, nil: .repeater - } - points.append(MapPoint( - id: repeater.id, - coordinate: repeater.coordinate, - pinStyle: style, - label: showLabels ? repeater.displayName : nil, - isClusterable: selectedAs == nil, - hopIndex: nil, - badgeText: nil - )) - } + var pointA: SelectedPoint? { + didSet { rebuildSelectionState() } + } - if let pointA, pointA.contact == nil { - points.append(MapPoint( - id: pointAMapID, - coordinate: pointA.coordinate, - pinStyle: .pointA, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } + var pointB: SelectedPoint? { + didSet { rebuildSelectionState() } + } - if let pointB, pointB.contact == nil { - points.append(MapPoint( - id: pointBMapID, - coordinate: pointB.coordinate, - pinStyle: .pointB, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } + var relocatingPoint: PointID? { + didSet { rebuildMapLines() } + } - if let target = repeaterPoint { - points.append(MapPoint( - id: repeaterTargetMapID, - coordinate: target.coordinate, - pinStyle: .crosshair, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } + var shouldAutoZoomOnNextResult = false - if repeaterPoint == nil, - case .result(let result) = analysisStatus, - result.clearanceStatus != .clear { - for obstruction in result.peakObstructionPerRegion { - let pathFraction = obstruction.distanceFromAMeters / result.distanceMeters - if let coordinate = coordinateAt(pathFraction: pathFraction) { - points.append(MapPoint( - id: obstruction.id, - coordinate: coordinate, - pinStyle: .obstruction, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } - } - } + // MARK: - Camera State (MKMapView) - mapPoints = points - } + var cameraRegion: MKCoordinateRegion? + private(set) var cameraRegionVersion = 0 - func rebuildMapLines() { - guard let a = pointA?.coordinate, - let b = pointB?.coordinate else { - mapLines = [] - return - } + // MARK: - RF Parameters - let activeOpacity = 0.7 - let dimOpacity = 0.3 - - if let r = repeaterPoint?.coordinate { - let opacityAR = relocatingPoint == .pointA ? dimOpacity : activeOpacity - let opacityRB = relocatingPoint == .pointB ? dimOpacity : activeOpacity - mapLines = [ - MapLine(id: "los-ar", coordinates: [a, r], - style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityAR), - MapLine(id: "los-rb", coordinates: [r, b], - style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityRB) - ] - } else { - let opacity = relocatingPoint != nil ? dimOpacity : activeOpacity - mapLines = [MapLine(id: "los-ab", coordinates: [a, b], style: .los, opacity: opacity)] - } - } - - // MARK: - Camera Methods - - func centerOnAllRepeaters() { - let coordinates = repeatersWithLocation.map(\.coordinate) - setCameraRegion(fitting: coordinates) - } - - func zoomToShowBothPoints() { - guard let pointA, let pointB else { return } - setCameraRegion(fitting: [pointA.coordinate, pointB.coordinate]) - } - - func setCameraRegion(_ region: MKCoordinateRegion) { - cameraRegion = region - cameraRegionVersion += 1 - } - - private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { - guard let region = coordinates.boundingRegion() else { return } - setCameraRegion(region) - } - - /// Returns the elevation profile to display in terrain visualization. - /// For on-path or no repeater: returns cached A-B profile. - /// For off-path: returns concatenated A→R→B profiles. - var terrainElevationProfile: [ElevationSample] { - guard let repeaterPoint, !repeaterPoint.isOnPath else { - return elevationProfile - } - - // For off-path repeater, concatenate A→R and R→B profiles - // Note: elevationProfileRB already has distances adjusted (offset by AR distance) - // from analyzeWithRepeaterOffPath(), so we use it directly without re-adjusting - guard !elevationProfileAR.isEmpty, !elevationProfileRB.isEmpty else { - return elevationProfile // Fallback if off-path profiles not yet loaded - } - - // dropFirst() removes the duplicate point at R (already in elevationProfileAR) - return elevationProfileAR + Array(elevationProfileRB.dropFirst()) - } - - // MARK: - Initialization - - init(elevationService: ElevationServiceProtocol = ElevationService()) { - self.elevationService = elevationService - } - - convenience init(preselectedContact: ContactDTO?) { - self.init() - if let contact = preselectedContact, contact.hasLocation { - let coordinate = CLLocationCoordinate2D( - latitude: contact.latitude, - longitude: contact.longitude - ) - setPointA(coordinate: coordinate, contact: contact) - } - } - - // MARK: - Configuration - - /// Configure with the data store and radio this view model uses; a provider returning nil mirrors a disconnected state. - /// Pass the connected device's frequency in kHz as a one-shot seed for the analysis frequency field. - func configure( - dataStore: @escaping @MainActor () -> (any PersistenceStoreProtocol)?, - radioID: @escaping @MainActor () -> UUID?, - deviceFrequencyKHz: UInt32? = nil - ) { - dataStoreProvider = dataStore - radioIDProvider = radioID - - // Device frequency is stored in kHz; analysis works in MHz. - // Treated as a one-shot seed: the user edits frequencyMHz independently after this. - if let deviceFrequencyKHz { - self.frequencyMHz = Double(deviceFrequencyKHz) / 1000.0 - } - } - - // MARK: - Load Repeaters - - func loadRepeaters() async { - guard let dataStore, let radioID else { return } - - do { - let allContacts = try await dataStore.fetchContacts(radioID: radioID) - repeatersWithLocation = allContacts.filter { $0.hasLocation && $0.type == .repeater } - } catch { - logger.error("Failed to load repeaters: \(error.localizedDescription)") - } - } + /// Operating frequency in MHz - call `commitFrequencyChange()` after editing + var frequencyMHz: Double = 906.0 - // MARK: - Point Selection - - /// Auto-assigns coordinate to A if empty, then B if A exists - func selectPoint(at coordinate: CLLocationCoordinate2D, from contact: ContactDTO? = nil) { - if pointA == nil { - setPointA(coordinate: coordinate, contact: contact) - } else if pointB == nil { - setPointB(coordinate: coordinate, contact: contact) - } else { - // Both points set, replace B - setPointB(coordinate: coordinate, contact: contact) - } - } - - func setPointA(coordinate: CLLocationCoordinate2D, contact: ContactDTO? = nil) { - // Cancel any pending elevation fetch for point A - pointAElevationTask?.cancel() - pointAElevationTask = nil - - // Reset analysis when points change - invalidateAnalysis() - - pointA = SelectedPoint( - coordinate: coordinate, - contact: contact, - groundElevation: nil - ) - - // Fetch elevation asynchronously - pointAElevationTask = Task { - await fetchElevationForPointA() - } - } - - func setPointB(coordinate: CLLocationCoordinate2D, contact: ContactDTO? = nil) { - // Check if B is same location as A - if let pointA = pointA, - pointA.coordinate.latitude == coordinate.latitude, - pointA.coordinate.longitude == coordinate.longitude { - logger.warning("Cannot set point B to same location as point A") - return - } - - // Cancel any pending elevation fetch for point B - pointBElevationTask?.cancel() - pointBElevationTask = nil - - // Reset analysis when points change - invalidateAnalysis() - - pointB = SelectedPoint( + /// Refraction k-factor - auto-triggers re-analysis on change + var refractionK: Double = 1.0 { + didSet { + if oldValue != refractionK { + reanalyzeWithCachedProfileIfNeeded() + } + } + } + + /// Commits frequency change and triggers re-analysis with cached profile + func commitFrequencyChange() { + reanalyzeWithCachedProfileIfNeeded() + } + + // MARK: - Frequency Parsing + + /// Parses a user-entered frequency string into a positive MHz value. + /// + /// Accepts both "." and "," as the decimal separator so comma-decimal + /// locales round-trip, and parses with a fixed separator rather than the + /// current locale's. Returns nil for empty, unparseable, or non-positive + /// input so callers can ignore it instead of feeding analysis a bad value. + func parseFrequency(_ text: String) -> Double? { + let normalized = text.replacing(",", with: ".") + guard let value = Double(normalized), value > 0 else { return nil } + return value + } + + /// Renders a frequency value for editing using a locale-stable format, + /// so the displayed text always round-trips back through `parseFrequency`. + func formatFrequencyForEditing(_ value: Double) -> String { + if value.truncatingRemainder(dividingBy: 1) == 0 { + return String(Int(value)) + } + return String(format: "%.1f", value) + } + + // MARK: - Map Display State + + var showLabels: Bool = true { + didSet { rebuildMapPoints() } + } + + private(set) var mapPoints: [MapPoint] = [] + private(set) var mapLines: [MapLine] = [] + + // MARK: - Repeaters State + + private(set) var repeatersWithLocation: [ContactDTO] = [] { + didSet { rebuildSelectionState() } + } + + // MARK: - Repeater State + + /// The active repeater point (nil when not in use) + var repeaterPoint: RepeaterPoint? { + didSet { + rebuildMapPoints() + rebuildMapLines() + } + } + + /// Whether repeater row should be visible (analysis shows marginal or worse) + var shouldShowRepeaterRow: Bool { + // Always show if repeater exists (even after relocation clears results) + if repeaterPoint != nil { + return true + } + // Show placeholder when direct analysis is marginal or worse + return shouldShowRepeaterPlaceholder + } + + /// Whether to show the "Add Repeater" placeholder (no repeater exists, but analysis suggests one would help) + var shouldShowRepeaterPlaceholder: Bool { + guard case let .result(result) = analysisStatus else { + return false + } + // Only show if there are obstruction points (required by addRepeater()) + return result.clearanceStatus != .clear && !result.obstructionPoints.isEmpty + } + + /// Ground elevation at repeater position. + /// For on-path: interpolated from cached A→B profile. + /// For off-path: uses the fetched elevation stored in repeaterPoint. + var repeaterGroundElevation: Double? { + guard let repeaterPoint else { return nil } + if repeaterPoint.isOnPath { + return elevationAt(pathFraction: repeaterPoint.pathFraction) + } else { + return repeaterPoint.groundElevation + } + } + + /// Path fraction for repeater visualization in terrain profile + /// For on-path: uses pathFraction directly + /// For off-path: computes from A→R distance / total distance + var repeaterVisualizationPathFraction: Double? { + guard let repeaterPoint else { return nil } + if repeaterPoint.isOnPath { + return repeaterPoint.pathFraction + } + // For off-path, compute from stored profiles + guard let arLast = elevationProfileAR.last, + let rbLast = elevationProfileRB.last, + rbLast.distanceFromAMeters > 0 else { return nil } + return arLast.distanceFromAMeters / rbLast.distanceFromAMeters + } + + /// Segment A→R distance in meters (nil when on-path or no repeater) + var segmentARDistanceMeters: Double? { + guard let repeaterPoint, !repeaterPoint.isOnPath else { return nil } + guard case let .relayResult(result) = analysisStatus else { return nil } + return result.segmentAR.distanceMeters + } + + /// Segment R→B distance in meters (nil when on-path or no repeater) + var segmentRBDistanceMeters: Double? { + guard let repeaterPoint, !repeaterPoint.isOnPath else { return nil } + guard case let .relayResult(result) = analysisStatus else { return nil } + return result.segmentRB.distanceMeters + } + + // MARK: - Analysis State + + private(set) var analysisStatus: AnalysisStatus = .idle { + didSet { + rebuildMapPoints() + rebuildMapLines() + } + } + + private(set) var isAnalyzing = false + private(set) var elevationProfile: [ElevationSample] = [] + + /// Profile samples for primary segment (A→B or A→R when repeater active) + private(set) var profileSamples: [ProfileSample] = [] + + /// Profile samples for R→B segment (empty when no repeater) + private(set) var profileSamplesRB: [ProfileSample] = [] + + /// Elevation profile A→R for off-path repeater + private(set) var elevationProfileAR: [ElevationSample] = [] + + /// Elevation profile R→B for off-path repeater + private(set) var elevationProfileRB: [ElevationSample] = [] + + /// Tracks whether any point elevation fetch failed (using sea level fallback) + private(set) var elevationFetchFailed = false + + // MARK: - Task Management + + private var analysisTask: Task? + private var pointAElevationTask: Task? + private var pointBElevationTask: Task? + private var repeaterElevationTask: Task? + + isolated deinit { + analysisTask?.cancel() + pointAElevationTask?.cancel() + pointBElevationTask?.cancel() + repeaterElevationTask?.cancel() + } + + // MARK: - Dependencies + + private let elevationService: ElevationServiceProtocol + private var dataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } + private var radioIDProvider: @MainActor () -> UUID? = { nil } + + private var dataStore: (any PersistenceStoreProtocol)? { + dataStoreProvider() + } + + private var radioID: UUID? { + radioIDProvider() + } + + // MARK: - Computed Properties + + var canAnalyze: Bool { + pointA?.groundElevation != nil && pointB?.groundElevation != nil + } + + /// Pre-computed selection state for all repeaters. Rebuilt via `rebuildSelectionState()`. + private(set) var selectionState: [UUID: LOSRepeaterSelectionInfo] = [:] + + private func rebuildSelectionState() { + var result = [UUID: LOSRepeaterSelectionInfo]() + result.reserveCapacity(repeatersWithLocation.count) + + let pointAContactID = pointA?.contact?.id + let pointBContactID = pointB?.contact?.id + + for contact in repeatersWithLocation { + let selectedAs: PointID? = if contact.id == pointAContactID { + .pointA + } else if contact.id == pointBContactID { + .pointB + } else { + nil + } + result[contact.id] = LOSRepeaterSelectionInfo(selectedAs: selectedAs) + } + selectionState = result + rebuildMapPoints() + rebuildMapLines() + } + + // MARK: - Map Data Rebuild + + func rebuildMapPoints() { + var points: [MapPoint] = [] + + for repeater in repeatersWithLocation { + let selectedAs = selectionState[repeater.id]?.selectedAs + let style: MapPoint.PinStyle = switch selectedAs { + case .pointA: .repeaterRingBlue + case .pointB: .repeaterRingGreen + case .repeater, nil: .repeater + } + points.append(MapPoint( + id: repeater.id, + coordinate: repeater.coordinate, + pinStyle: style, + label: showLabels ? repeater.displayName : nil, + isClusterable: selectedAs == nil, + hopIndex: nil, + badgeText: nil + )) + } + + if let pointA, pointA.contact == nil { + points.append(MapPoint( + id: pointAMapID, + coordinate: pointA.coordinate, + pinStyle: .pointA, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let pointB, pointB.contact == nil { + points.append(MapPoint( + id: pointBMapID, + coordinate: pointB.coordinate, + pinStyle: .pointB, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let target = repeaterPoint { + points.append(MapPoint( + id: repeaterTargetMapID, + coordinate: target.coordinate, + pinStyle: .crosshair, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if repeaterPoint == nil, + case let .result(result) = analysisStatus, + result.clearanceStatus != .clear { + for obstruction in result.peakObstructionPerRegion { + let pathFraction = obstruction.distanceFromAMeters / result.distanceMeters + if let coordinate = coordinateAt(pathFraction: pathFraction) { + points.append(MapPoint( + id: obstruction.id, coordinate: coordinate, - contact: contact, - groundElevation: nil - ) - - // Fetch elevation asynchronously - pointBElevationTask = Task { - await fetchElevationForPointB() + pinStyle: .obstruction, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) } - } + } + } + + mapPoints = points + } + + func rebuildMapLines() { + guard let a = pointA?.coordinate, + let b = pointB?.coordinate else { + mapLines = [] + return + } - // MARK: - Height Adjustment - - func updateAdditionalHeight(for point: PointID, meters: Double) { - let clampedHeight = max(0.0, meters) - - switch point { - case .pointA: - guard pointA != nil else { return } - pointA?.additionalHeight = clampedHeight - case .pointB: - guard pointB != nil else { return } - pointB?.additionalHeight = clampedHeight - case .repeater: - // Repeater height is handled separately via updateRepeaterHeight - updateRepeaterHeight(meters: clampedHeight) - return - } + let activeOpacity = 0.7 + let dimOpacity = 0.3 + + if let r = repeaterPoint?.coordinate { + let opacityAR = relocatingPoint == .pointA ? dimOpacity : activeOpacity + let opacityRB = relocatingPoint == .pointB ? dimOpacity : activeOpacity + mapLines = [ + MapLine(id: "los-ar", coordinates: [a, r], + style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityAR), + MapLine(id: "los-rb", coordinates: [r, b], + style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityRB) + ] + } else { + let opacity = relocatingPoint != nil ? dimOpacity : activeOpacity + mapLines = [MapLine(id: "los-ab", coordinates: [a, b], style: .los, opacity: opacity)] + } + } + + // MARK: - Camera Methods + + func centerOnAllRepeaters() { + let coordinates = repeatersWithLocation.map(\.coordinate) + setCameraRegion(fitting: coordinates) + } + + func zoomToShowBothPoints() { + guard let pointA, let pointB else { return } + setCameraRegion(fitting: [pointA.coordinate, pointB.coordinate]) + } - // Height change invalidates analysis - invalidateAnalysis() + func setCameraRegion(_ region: MKCoordinateRegion) { + cameraRegion = region + cameraRegionVersion += 1 + } + + private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { + guard let region = coordinates.boundingRegion() else { return } + setCameraRegion(region) + } + + /// Returns the elevation profile to display in terrain visualization. + /// For on-path or no repeater: returns cached A-B profile. + /// For off-path: returns concatenated A→R→B profiles. + var terrainElevationProfile: [ElevationSample] { + guard let repeaterPoint, !repeaterPoint.isOnPath else { + return elevationProfile + } + + // For off-path repeater, concatenate A→R and R→B profiles + // Note: elevationProfileRB already has distances adjusted (offset by AR distance) + // from analyzeWithRepeaterOffPath(), so we use it directly without re-adjusting + guard !elevationProfileAR.isEmpty, !elevationProfileRB.isEmpty else { + return elevationProfile // Fallback if off-path profiles not yet loaded + } + + // dropFirst() removes the duplicate point at R (already in elevationProfileAR) + return elevationProfileAR + Array(elevationProfileRB.dropFirst()) + } + + // MARK: - Initialization + + init(elevationService: ElevationServiceProtocol = ElevationService()) { + self.elevationService = elevationService + } + + convenience init(preselectedContact: ContactDTO?) { + self.init() + if let contact = preselectedContact, contact.hasLocation { + let coordinate = CLLocationCoordinate2D( + latitude: contact.latitude, + longitude: contact.longitude + ) + setPointA(coordinate: coordinate, contact: contact) } + } - // MARK: - Contact Toggle Selection - - /// Toggle a contact as a selected point - /// - If contact is already selected as A or B, clear that point - /// - Otherwise, auto-assign to A (if empty) or B - func toggleContact(_ contact: ContactDTO) { - let coordinate = contact.coordinate - - // Check if already selected as point A - if let pointA, pointA.contact?.id == contact.id { - clearPointA() - return - } - - // Check if already selected as point B - if let pointB, pointB.contact?.id == contact.id { - clearPointB() - return - } + // MARK: - Configuration - // Auto-assign using existing logic - selectPoint(at: coordinate, from: contact) + /// Configure with the data store and radio this view model uses; a provider returning nil mirrors a disconnected state. + /// Pass the connected device's frequency in kHz as a one-shot seed for the analysis frequency field. + func configure( + dataStore: @escaping @MainActor () -> (any PersistenceStoreProtocol)?, + radioID: @escaping @MainActor () -> UUID?, + deviceFrequencyKHz: UInt32? = nil + ) { + dataStoreProvider = dataStore + radioIDProvider = radioID + + // Device frequency is stored in kHz; analysis works in MHz. + // Treated as a one-shot seed: the user edits frequencyMHz independently after this. + if let deviceFrequencyKHz { + frequencyMHz = Double(deviceFrequencyKHz) / 1000.0 + } + } + + // MARK: - Load Repeaters + + func loadRepeaters() async { + guard let dataStore, let radioID else { return } + + do { + let allContacts = try await dataStore.fetchContacts(radioID: radioID) + repeatersWithLocation = allContacts.filter { $0.hasLocation && $0.type == .repeater } + } catch { + logger.error("Failed to load repeaters: \(error.localizedDescription)") + } + } + + // MARK: - Point Selection + + /// Auto-assigns coordinate to A if empty, then B if A exists + func selectPoint(at coordinate: CLLocationCoordinate2D, from contact: ContactDTO? = nil) { + if pointA == nil { + setPointA(coordinate: coordinate, contact: contact) + } else if pointB == nil { + setPointB(coordinate: coordinate, contact: contact) + } else { + // Both points set, replace B + setPointB(coordinate: coordinate, contact: contact) } + } + + func setPointA(coordinate: CLLocationCoordinate2D, contact: ContactDTO? = nil) { + // Cancel any pending elevation fetch for point A + pointAElevationTask?.cancel() + pointAElevationTask = nil + + // Reset analysis when points change + invalidateAnalysis() - // MARK: - Clear Methods - - func clear() { - pointAElevationTask?.cancel() - pointBElevationTask?.cancel() - analysisTask?.cancel() - repeaterElevationTask?.cancel() - - pointAElevationTask = nil - pointBElevationTask = nil - analysisTask = nil - repeaterElevationTask = nil - - pointA = nil - pointB = nil - repeaterPoint = nil - elevationFetchFailed = false + pointA = SelectedPoint( + coordinate: coordinate, + contact: contact, + groundElevation: nil + ) + + // Fetch elevation asynchronously + pointAElevationTask = Task { + await fetchElevationForPointA() + } + } + + func setPointB(coordinate: CLLocationCoordinate2D, contact: ContactDTO? = nil) { + // Check if B is same location as A + if let pointA, + pointA.coordinate.latitude == coordinate.latitude, + pointA.coordinate.longitude == coordinate.longitude { + logger.warning("Cannot set point B to same location as point A") + return + } + + // Cancel any pending elevation fetch for point B + pointBElevationTask?.cancel() + pointBElevationTask = nil + + // Reset analysis when points change + invalidateAnalysis() + + pointB = SelectedPoint( + coordinate: coordinate, + contact: contact, + groundElevation: nil + ) + + // Fetch elevation asynchronously + pointBElevationTask = Task { + await fetchElevationForPointB() + } + } + + // MARK: - Height Adjustment + + func updateAdditionalHeight(for point: PointID, meters: Double) { + let clampedHeight = max(0.0, meters) + + switch point { + case .pointA: + guard pointA != nil else { return } + pointA?.additionalHeight = clampedHeight + case .pointB: + guard pointB != nil else { return } + pointB?.additionalHeight = clampedHeight + case .repeater: + // Repeater height is handled separately via updateRepeaterHeight + updateRepeaterHeight(meters: clampedHeight) + return + } + + // Height change invalidates analysis + invalidateAnalysis() + } + + // MARK: - Contact Toggle Selection + + /// Toggle a contact as a selected point + /// - If contact is already selected as A or B, clear that point + /// - Otherwise, auto-assign to A (if empty) or B + func toggleContact(_ contact: ContactDTO) { + let coordinate = contact.coordinate + + // Check if already selected as point A + if let pointA, pointA.contact?.id == contact.id { + clearPointA() + return + } + + // Check if already selected as point B + if let pointB, pointB.contact?.id == contact.id { + clearPointB() + return + } + + // Auto-assign using existing logic + selectPoint(at: coordinate, from: contact) + } + + // MARK: - Clear Methods + + func clear() { + pointAElevationTask?.cancel() + pointBElevationTask?.cancel() + analysisTask?.cancel() + repeaterElevationTask?.cancel() + + pointAElevationTask = nil + pointBElevationTask = nil + analysisTask = nil + repeaterElevationTask = nil + + pointA = nil + pointB = nil + repeaterPoint = nil + elevationFetchFailed = false + isAnalyzing = false + analysisStatus = .idle + elevationProfile = [] + elevationProfileAR = [] + elevationProfileRB = [] + } + + func clearPointA() { + pointAElevationTask?.cancel() + pointAElevationTask = nil + + pointA = nil + repeaterPoint = nil + invalidateAnalysis() + } + + func clearPointB() { + pointBElevationTask?.cancel() + pointBElevationTask = nil + + pointB = nil + repeaterPoint = nil + invalidateAnalysis() + } + + // MARK: - Repeater Methods + + /// Adds repeater at the worst obstruction point + func addRepeater() { + guard case let .result(result) = analysisStatus, + let worstPoint = result.worstObstructionPoint else { + return + } + + let pathFraction = worstPoint.distanceFromAMeters / result.distanceMeters + + // Get coordinate and elevation from cached profile + guard let coordinate = coordinateAt(pathFraction: pathFraction), + let elevation = elevationAt(pathFraction: pathFraction) else { return } + + repeaterPoint = RepeaterPoint( + coordinate: coordinate, + groundElevation: elevation, + additionalHeight: 10, + isOnPath: true, + pathFraction: pathFraction + ) + } + + /// Updates repeater position along the path (for on-path repeaters) + func updateRepeaterPosition(pathFraction: Double) { + guard var repeater = repeaterPoint, repeater.isOnPath else { return } + + // Update path fraction and derive coordinate/elevation from cached profile + repeater.pathFraction = pathFraction + if let coordinate = coordinateAt(pathFraction: pathFraction) { + repeater.coordinate = coordinate + } + if let elevation = elevationAt(pathFraction: pathFraction) { + repeater.groundElevation = elevation + } + + repeaterPoint = repeater + } + + /// Updates repeater height above ground + func updateRepeaterHeight(meters: Double) { + guard repeaterPoint != nil else { return } + repeaterPoint?.additionalHeight = max(0.0, meters) + } + + /// Sets repeater to an off-path location + /// - Parameter coordinate: The new coordinate for the repeater + func setRepeaterOffPath(coordinate: CLLocationCoordinate2D) { + let existingHeight = repeaterPoint?.additionalHeight ?? 10 + + repeaterPoint = RepeaterPoint( + coordinate: coordinate, + groundElevation: nil, // Will be fetched + additionalHeight: existingHeight, + isOnPath: false, + pathFraction: 0.5 // Not used for off-path + ) + + // Invalidate cached off-path profiles (coordinates changed) + elevationProfileAR = [] + elevationProfileRB = [] + + // Fetch elevation for the new coordinate + repeaterElevationTask?.cancel() + repeaterElevationTask = Task { + do { + let elevation = try await elevationService.fetchElevation(at: coordinate) + guard !Task.isCancelled else { return } + repeaterPoint?.groundElevation = elevation + } catch { + guard !Task.isCancelled else { return } + logger.error("Failed to fetch repeater elevation: \(error.localizedDescription)") + } + } + } + + /// Removes repeater and reverts to single-path analysis + func clearRepeater() { + // Cancel any in-flight off-path elevation fetch so it can't write a stale + // coordinate's elevation into a repeater added after this clear. + repeaterElevationTask?.cancel() + repeaterElevationTask = nil + + repeaterPoint = nil + reanalyzeWithCachedProfileIfNeeded() + } + + /// Analyzes the path with the current repeater position + func analyzeWithRepeater() { + guard let repeaterPoint, + let pointA, + let pointB else { return } + + if repeaterPoint.isOnPath { + analyzeWithRepeaterOnPath() + } else if !elevationProfileAR.isEmpty, !elevationProfileRB.isEmpty { + // Cached profiles exist — no network fetch needed + applyRelayAnalysis( + profileAR: elevationProfileAR, + profileRB: elevationProfileRB, + pointAHeight: pointA.additionalHeight, + repeaterHeight: repeaterPoint.additionalHeight, + pointBHeight: pointB.additionalHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + } else { + analysisTask?.cancel() + analysisTask = Task { + await analyzeWithRepeaterOffPath() + } + } + } + + /// On-path analysis using cached elevation profile + private func analyzeWithRepeaterOnPath() { + guard let repeaterPoint, + let pointA, + let pointB, + elevationProfile.count >= 2 else { return } + + let pathFraction = repeaterPoint.pathFraction + + // Calculate split index + let splitIndex = Int(pathFraction * Double(elevationProfile.count - 1)) + guard splitIndex > 0, splitIndex < elevationProfile.count - 1 else { return } + + applyRelayAnalysis( + profileAR: Array(elevationProfile[0...splitIndex]), + profileRB: Array(elevationProfile[splitIndex...]), + pointAHeight: pointA.additionalHeight, + repeaterHeight: repeaterPoint.additionalHeight, + pointBHeight: pointB.additionalHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + } + + /// Off-path analysis - fetches A→R and R→B profiles + private func analyzeWithRepeaterOffPath() async { + guard let repeaterPoint, + let pointA, + let pointB else { return } + + isAnalyzing = true + + do { + let pointACoord = pointA.coordinate + let repeaterCoord = repeaterPoint.coordinate + let pointBCoord = pointB.coordinate + + // Fetch A→R profile + let distanceAR = RFCalculator.distance(from: pointACoord, to: repeaterCoord) + let sampleCountAR = ElevationService.optimalSampleCount(distanceMeters: distanceAR) + let sampleCoordsAR = ElevationService.sampleCoordinates( + from: pointACoord, + to: repeaterCoord, + sampleCount: sampleCountAR + ) + // Fetch R→B profile (computed before async let to avoid capture issues) + let distanceRB = RFCalculator.distance(from: repeaterCoord, to: pointBCoord) + let sampleCountRB = ElevationService.optimalSampleCount(distanceMeters: distanceRB) + let sampleCoordsRB = ElevationService.sampleCoordinates( + from: repeaterCoord, + to: pointBCoord, + sampleCount: sampleCountRB + ) + + // Fetch both elevation profiles in parallel + async let profileARTask = elevationService.fetchElevations(along: sampleCoordsAR) + async let profileRBTask = elevationService.fetchElevations(along: sampleCoordsRB) + let (profileAR, profileRB) = try await (profileARTask, profileRBTask) + + // A superseded task must not overwrite newer results + if Task.isCancelled { isAnalyzing = false - analysisStatus = .idle - elevationProfile = [] - elevationProfileAR = [] - elevationProfileRB = [] - } - - func clearPointA() { - pointAElevationTask?.cancel() - pointAElevationTask = nil - - pointA = nil - repeaterPoint = nil - invalidateAnalysis() - } - - func clearPointB() { - pointBElevationTask?.cancel() - pointBElevationTask = nil - - pointB = nil - repeaterPoint = nil - invalidateAnalysis() - } - - // MARK: - Repeater Methods - - /// Adds repeater at the worst obstruction point - func addRepeater() { - guard case .result(let result) = analysisStatus, - let worstPoint = result.worstObstructionPoint else { - return - } - - let pathFraction = worstPoint.distanceFromAMeters / result.distanceMeters - - // Get coordinate and elevation from cached profile - guard let coordinate = coordinateAt(pathFraction: pathFraction), - let elevation = elevationAt(pathFraction: pathFraction) else { return } - - repeaterPoint = RepeaterPoint( - coordinate: coordinate, - groundElevation: elevation, - additionalHeight: 10, - isOnPath: true, - pathFraction: pathFraction + return + } + + // Offset R→B profile distances to continue from A→R endpoint + // (fetchElevations returns distances relative to segment start, not global A) + let profileRBAdjusted = profileRB.map { sample in + ElevationSample( + coordinate: sample.coordinate, + elevation: sample.elevation, + distanceFromAMeters: sample.distanceFromAMeters + distanceAR ) - } - - /// Updates repeater position along the path (for on-path repeaters) - func updateRepeaterPosition(pathFraction: Double) { - guard var repeater = repeaterPoint, repeater.isOnPath else { return } - - // Update path fraction and derive coordinate/elevation from cached profile - repeater.pathFraction = pathFraction - if let coordinate = coordinateAt(pathFraction: pathFraction) { - repeater.coordinate = coordinate - } - if let elevation = elevationAt(pathFraction: pathFraction) { - repeater.groundElevation = elevation - } - - repeaterPoint = repeater - } - - /// Updates repeater height above ground - func updateRepeaterHeight(meters: Double) { - guard repeaterPoint != nil else { return } - repeaterPoint?.additionalHeight = max(0.0, meters) - } - - /// Sets repeater to an off-path location - /// - Parameter coordinate: The new coordinate for the repeater - func setRepeaterOffPath(coordinate: CLLocationCoordinate2D) { - let existingHeight = repeaterPoint?.additionalHeight ?? 10 - - repeaterPoint = RepeaterPoint( - coordinate: coordinate, - groundElevation: nil, // Will be fetched - additionalHeight: existingHeight, - isOnPath: false, - pathFraction: 0.5 // Not used for off-path + } + + applyRelayAnalysis( + profileAR: profileAR, + profileRB: profileRBAdjusted, + pointAHeight: pointA.additionalHeight, + repeaterHeight: repeaterPoint.additionalHeight, + pointBHeight: pointB.additionalHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + + // Store profiles for terrain visualization + elevationProfileAR = profileAR + elevationProfileRB = profileRBAdjusted + + isAnalyzing = false + + } catch { + // A cancelled fetch is not a user-facing analysis failure + if Task.isCancelled { return } + isAnalyzing = false + analysisStatus = .error(error.localizedDescription) + logger.error("Off-path analysis failed: \(error.localizedDescription)") + } + } + + /// Shared relay analysis: analyzes both segments and updates profile samples and status + private func applyRelayAnalysis( + profileAR: [ElevationSample], + profileRB: [ElevationSample], + pointAHeight: Double, + repeaterHeight: Double, + pointBHeight: Double, + frequencyMHz: Double, + refractionK: Double + ) { + let arResult = RFCalculator.analyzePathSegment( + elevationProfile: profileAR[...], + startHeightMeters: pointAHeight, + endHeightMeters: repeaterHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + + let rbResult = RFCalculator.analyzePathSegment( + elevationProfile: profileRB[...], + startHeightMeters: repeaterHeight, + endHeightMeters: pointBHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + + let relayResult = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", + endLabel: "R", + clearanceStatus: arResult.clearanceStatus, + distanceMeters: arResult.distanceMeters, + worstClearancePercent: arResult.worstClearancePercent + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", + endLabel: "B", + clearanceStatus: rbResult.clearanceStatus, + distanceMeters: rbResult.distanceMeters, + worstClearancePercent: rbResult.worstClearancePercent + ) + ) + + profileSamples = FresnelZoneRenderer.buildProfileSamples( + from: profileAR, + pointAHeight: pointAHeight, + pointBHeight: repeaterHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + profileSamplesRB = FresnelZoneRenderer.buildProfileSamples( + from: profileRB, + pointAHeight: repeaterHeight, + pointBHeight: pointBHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + + analysisStatus = .relayResult(relayResult) + } + + // MARK: - Analysis + + /// Clears analysis results without clearing points + func clearAnalysisResults() { + isAnalyzing = false + analysisStatus = .idle + shouldAutoZoomOnNextResult = false + } + + func analyze() { + guard let pointA, + let pointB, + pointA.groundElevation != nil, + pointB.groundElevation != nil else { + logger.warning("Cannot analyze: missing point elevations") + return + } + + // Cancel any existing analysis + analysisTask?.cancel() + + isAnalyzing = true + + // Capture values for use in task + let pointACoord = pointA.coordinate + let pointBCoord = pointB.coordinate + let pointAHeight = pointA.additionalHeight + let pointBHeight = pointB.additionalHeight + let freq = frequencyMHz + let k = refractionK + + analysisTask = Task { + do { + let distance = RFCalculator.distance(from: pointACoord, to: pointBCoord) + let sampleCount = ElevationService.optimalSampleCount(distanceMeters: distance) + let sampleCoordinates = ElevationService.sampleCoordinates( + from: pointACoord, + to: pointBCoord, + sampleCount: sampleCount ) + let profile = try await elevationService.fetchElevations(along: sampleCoordinates) + if Task.isCancelled { return } - // Invalidate cached off-path profiles (coordinates changed) - elevationProfileAR = [] - elevationProfileRB = [] - - // Fetch elevation for the new coordinate - repeaterElevationTask?.cancel() - repeaterElevationTask = Task { - do { - let elevation = try await elevationService.fetchElevation(at: coordinate) - guard !Task.isCancelled else { return } - repeaterPoint?.groundElevation = elevation - } catch { - guard !Task.isCancelled else { return } - logger.error("Failed to fetch repeater elevation: \(error.localizedDescription)") - } - } - } + let result = await Task.detached { + RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: pointAHeight, + pointBHeightMeters: pointBHeight, + frequencyMHz: freq, + refractionK: k + ) + }.value - /// Removes repeater and reverts to single-path analysis - func clearRepeater() { - // Cancel any in-flight off-path elevation fetch so it can't write a stale - // coordinate's elevation into a repeater added after this clear. - repeaterElevationTask?.cancel() - repeaterElevationTask = nil - - repeaterPoint = nil - reanalyzeWithCachedProfileIfNeeded() - } - - /// Analyzes the path with the current repeater position - func analyzeWithRepeater() { - guard let repeaterPoint, - let pointA, - let pointB else { return } - - if repeaterPoint.isOnPath { - analyzeWithRepeaterOnPath() - } else if !elevationProfileAR.isEmpty, !elevationProfileRB.isEmpty { - // Cached profiles exist — no network fetch needed - applyRelayAnalysis( - profileAR: elevationProfileAR, - profileRB: elevationProfileRB, - pointAHeight: pointA.additionalHeight, - repeaterHeight: repeaterPoint.additionalHeight, - pointBHeight: pointB.additionalHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - } else { - analysisTask?.cancel() - analysisTask = Task { - await analyzeWithRepeaterOffPath() - } - } - } - - /// On-path analysis using cached elevation profile - private func analyzeWithRepeaterOnPath() { - guard let repeaterPoint, - let pointA, - let pointB, - elevationProfile.count >= 2 else { return } - - let pathFraction = repeaterPoint.pathFraction - - // Calculate split index - let splitIndex = Int(pathFraction * Double(elevationProfile.count - 1)) - guard splitIndex > 0, splitIndex < elevationProfile.count - 1 else { return } - - applyRelayAnalysis( - profileAR: Array(elevationProfile[0...splitIndex]), - profileRB: Array(elevationProfile[splitIndex...]), - pointAHeight: pointA.additionalHeight, - repeaterHeight: repeaterPoint.additionalHeight, - pointBHeight: pointB.additionalHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - } - - /// Off-path analysis - fetches A→R and R→B profiles - private func analyzeWithRepeaterOffPath() async { - guard let repeaterPoint, - let pointA, - let pointB else { return } - - isAnalyzing = true - - do { - let pointACoord = pointA.coordinate - let repeaterCoord = repeaterPoint.coordinate - let pointBCoord = pointB.coordinate - - // Fetch A→R profile - let distanceAR = RFCalculator.distance(from: pointACoord, to: repeaterCoord) - let sampleCountAR = ElevationService.optimalSampleCount(distanceMeters: distanceAR) - let sampleCoordsAR = ElevationService.sampleCoordinates( - from: pointACoord, - to: repeaterCoord, - sampleCount: sampleCountAR - ) - // Fetch R→B profile (computed before async let to avoid capture issues) - let distanceRB = RFCalculator.distance(from: repeaterCoord, to: pointBCoord) - let sampleCountRB = ElevationService.optimalSampleCount(distanceMeters: distanceRB) - let sampleCoordsRB = ElevationService.sampleCoordinates( - from: repeaterCoord, - to: pointBCoord, - sampleCount: sampleCountRB - ) - - // Fetch both elevation profiles in parallel - async let profileARTask = elevationService.fetchElevations(along: sampleCoordsAR) - async let profileRBTask = elevationService.fetchElevations(along: sampleCoordsRB) - let (profileAR, profileRB) = try await (profileARTask, profileRBTask) - - // A superseded task must not overwrite newer results - if Task.isCancelled { - isAnalyzing = false - return - } - - // Offset R→B profile distances to continue from A→R endpoint - // (fetchElevations returns distances relative to segment start, not global A) - let profileRBAdjusted = profileRB.map { sample in - ElevationSample( - coordinate: sample.coordinate, - elevation: sample.elevation, - distanceFromAMeters: sample.distanceFromAMeters + distanceAR - ) - } - - applyRelayAnalysis( - profileAR: profileAR, - profileRB: profileRBAdjusted, - pointAHeight: pointA.additionalHeight, - repeaterHeight: repeaterPoint.additionalHeight, - pointBHeight: pointB.additionalHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - - // Store profiles for terrain visualization - elevationProfileAR = profileAR - elevationProfileRB = profileRBAdjusted - - isAnalyzing = false - - } catch { - // A cancelled fetch is not a user-facing analysis failure - if Task.isCancelled { return } - isAnalyzing = false - analysisStatus = .error(error.localizedDescription) - logger.error("Off-path analysis failed: \(error.localizedDescription)") - } - } - - /// Shared relay analysis: analyzes both segments and updates profile samples and status - private func applyRelayAnalysis( - profileAR: [ElevationSample], - profileRB: [ElevationSample], - pointAHeight: Double, - repeaterHeight: Double, - pointBHeight: Double, - frequencyMHz: Double, - refractionK: Double - ) { - let arResult = RFCalculator.analyzePathSegment( - elevationProfile: profileAR[...], - startHeightMeters: pointAHeight, - endHeightMeters: repeaterHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - - let rbResult = RFCalculator.analyzePathSegment( - elevationProfile: profileRB[...], - startHeightMeters: repeaterHeight, - endHeightMeters: pointBHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - - let relayResult = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", - endLabel: "R", - clearanceStatus: arResult.clearanceStatus, - distanceMeters: arResult.distanceMeters, - worstClearancePercent: arResult.worstClearancePercent - ), - segmentRB: SegmentAnalysisResult( - startLabel: "R", - endLabel: "B", - clearanceStatus: rbResult.clearanceStatus, - distanceMeters: rbResult.distanceMeters, - worstClearancePercent: rbResult.worstClearancePercent - ) - ) + if Task.isCancelled { return } + elevationProfile = profile profileSamples = FresnelZoneRenderer.buildProfileSamples( - from: profileAR, - pointAHeight: pointAHeight, - pointBHeight: repeaterHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK + from: profile, + pointAHeight: pointAHeight, + pointBHeight: pointBHeight, + frequencyMHz: freq, + refractionK: k ) - profileSamplesRB = FresnelZoneRenderer.buildProfileSamples( - from: profileRB, - pointAHeight: repeaterHeight, - pointBHeight: pointBHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - - analysisStatus = .relayResult(relayResult) - } - - // MARK: - Analysis - - /// Clears analysis results without clearing points - func clearAnalysisResults() { + profileSamplesRB = [] isAnalyzing = false - analysisStatus = .idle - shouldAutoZoomOnNextResult = false - } - - func analyze() { - guard let pointA = pointA, - let pointB = pointB, - pointA.groundElevation != nil, - pointB.groundElevation != nil else { - logger.warning("Cannot analyze: missing point elevations") - return - } - - // Cancel any existing analysis - analysisTask?.cancel() - - isAnalyzing = true - - // Capture values for use in task - let pointACoord = pointA.coordinate - let pointBCoord = pointB.coordinate - let pointAHeight = pointA.additionalHeight - let pointBHeight = pointB.additionalHeight - let freq = frequencyMHz - let k = refractionK - - analysisTask = Task { - do { - let distance = RFCalculator.distance(from: pointACoord, to: pointBCoord) - let sampleCount = ElevationService.optimalSampleCount(distanceMeters: distance) - let sampleCoordinates = ElevationService.sampleCoordinates( - from: pointACoord, - to: pointBCoord, - sampleCount: sampleCount - ) - let profile = try await elevationService.fetchElevations(along: sampleCoordinates) - if Task.isCancelled { return } - - let result = await Task.detached { - RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: pointAHeight, - pointBHeightMeters: pointBHeight, - frequencyMHz: freq, - refractionK: k - ) - }.value - - if Task.isCancelled { return } - - elevationProfile = profile - profileSamples = FresnelZoneRenderer.buildProfileSamples( - from: profile, - pointAHeight: pointAHeight, - pointBHeight: pointBHeight, - frequencyMHz: freq, - refractionK: k - ) - profileSamplesRB = [] - isAnalyzing = false - analysisStatus = .result(result) - logger.info("Analysis complete: \(result.clearanceStatus.rawValue), \(result.distanceKm)km") - - } catch { - if Task.isCancelled { return } - isAnalyzing = false - analysisStatus = .error(error.localizedDescription) - logger.error("Analysis failed: \(error.localizedDescription)") - } - } - } - - // MARK: - Private Methods + analysisStatus = .result(result) + logger.info("Analysis complete: \(result.clearanceStatus.rawValue), \(result.distanceKm)km") - /// Invalidates analysis results but preserves cached elevation profile - /// Use when RF settings change (frequency, k-factor) - private func invalidateAnalysisOnly() { - analysisTask?.cancel() - analysisTask = nil + } catch { + if Task.isCancelled { return } isAnalyzing = false - analysisStatus = .idle - shouldAutoZoomOnNextResult = false - } - - /// Invalidates analysis and clears cached elevation profile - /// Use when points change (requires new elevation data) - private func invalidateAnalysis() { - invalidateAnalysisOnly() - repeaterElevationTask?.cancel() - repeaterElevationTask = nil - elevationProfile = [] - elevationProfileAR = [] - elevationProfileRB = [] - profileSamples = [] - profileSamplesRB = [] - elevationFetchFailed = false - repeaterPoint = nil // Repeater is invalid without profile - } - - /// Re-runs analysis using cached elevation profile when RF settings change - private func reanalyzeWithCachedProfileIfNeeded() { - // Only re-analyze if we have a cached profile and both points. - // Clear isAnalyzing on the bail path so a flag left set by a now-stale - // analysis (this method runs from a mid-analysis RF-setting change) can't - // strand the spinner. - guard !elevationProfile.isEmpty, - let pointA = pointA, - let pointB = pointB, - pointA.groundElevation != nil, - pointB.groundElevation != nil else { - isAnalyzing = false - return - } - - // If repeater is active, use relay analysis to preserve mode. - // Clear isAnalyzing first: the on-path/cached relay paths resolve - // synchronously, and the off-path path re-sets the flag itself. - if repeaterPoint != nil { - isAnalyzing = false - analyzeWithRepeater() - return - } - - // Cancel any existing analysis; this task takes over the isAnalyzing flag - analysisTask?.cancel() - isAnalyzing = true - - // Capture values for use in task - let profile = elevationProfile - let pointAHeight = pointA.additionalHeight - let pointBHeight = pointB.additionalHeight - let freq = frequencyMHz - let k = refractionK - - analysisTask = Task { - defer { isAnalyzing = false } - - let result = await Task.detached { - RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: pointAHeight, - pointBHeightMeters: pointBHeight, - frequencyMHz: freq, - refractionK: k - ) - }.value - - if Task.isCancelled { return } - - profileSamples = FresnelZoneRenderer.buildProfileSamples( - from: profile, - pointAHeight: pointAHeight, - pointBHeight: pointBHeight, - frequencyMHz: freq, - refractionK: k - ) - profileSamplesRB = [] - analysisStatus = .result(result) - logger.debug("Re-analyzed with cached profile: \(freq) MHz, k=\(k)") - } - } - - private func fetchElevationForPointA() async { - guard let coordinate = pointA?.coordinate else { return } - - do { - let elevation = try await elevationService.fetchElevation(at: coordinate) - if Task.isCancelled { return } - pointA?.groundElevation = elevation - logger.debug("Point A elevation: \(elevation)m") - } catch { - if Task.isCancelled { return } - logger.error("Failed to fetch point A elevation: \(error.localizedDescription)") - // Set to 0 as fallback so analysis can proceed (sea level approximation) - pointA?.groundElevation = 0 - elevationFetchFailed = true - } - } - - private func fetchElevationForPointB() async { - guard let coordinate = pointB?.coordinate else { return } - - do { - let elevation = try await elevationService.fetchElevation(at: coordinate) - if Task.isCancelled { return } - pointB?.groundElevation = elevation - logger.debug("Point B elevation: \(elevation)m") - } catch { - if Task.isCancelled { return } - logger.error("Failed to fetch point B elevation: \(error.localizedDescription)") - // Set to 0 as fallback so analysis can proceed (sea level approximation) - pointB?.groundElevation = 0 - elevationFetchFailed = true - } - } - - // MARK: - Elevation Interpolation - - /// Returns interpolation indices and factor for a given path fraction - /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range - /// - Returns: Tuple of (lowerIndex, upperIndex, interpolationFactor) or nil if profile has fewer than 2 samples - private func interpolationIndices(for pathFraction: Double) -> (lower: Int, upper: Int, t: Double)? { - guard elevationProfile.count >= 2 else { return nil } - - let clamped = pathFraction.clamped(to: 0.0...1.0) - let index = clamped * Double(elevationProfile.count - 1) - let lowerIndex = Int(index) - let upperIndex = min(lowerIndex + 1, elevationProfile.count - 1) - let t = index - Double(lowerIndex) - - return (lowerIndex, upperIndex, t) - } - - /// Interpolates ground elevation at a given path fraction - /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range - /// - Returns: Interpolated ground elevation in meters, or nil if profile has fewer than 2 samples - func elevationAt(pathFraction: Double) -> Double? { - guard let indices = interpolationIndices(for: pathFraction) else { return nil } - - let lowerElevation = elevationProfile[indices.lower].elevation - let upperElevation = elevationProfile[indices.upper].elevation - - return lowerElevation + indices.t * (upperElevation - lowerElevation) - } - - /// Interpolates coordinate at a given path fraction - /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range - /// - Returns: Interpolated coordinate, or nil if profile has fewer than 2 samples - func coordinateAt(pathFraction: Double) -> CLLocationCoordinate2D? { - guard let indices = interpolationIndices(for: pathFraction) else { return nil } - - let lower = elevationProfile[indices.lower].coordinate - let upper = elevationProfile[indices.upper].coordinate - - return CLLocationCoordinate2D( - latitude: lower.latitude + indices.t * (upper.latitude - lower.latitude), - longitude: lower.longitude + indices.t * (upper.longitude - lower.longitude) + analysisStatus = .error(error.localizedDescription) + logger.error("Analysis failed: \(error.localizedDescription)") + } + } + } + + // MARK: - Private Methods + + /// Invalidates analysis results but preserves cached elevation profile + /// Use when RF settings change (frequency, k-factor) + private func invalidateAnalysisOnly() { + analysisTask?.cancel() + analysisTask = nil + isAnalyzing = false + analysisStatus = .idle + shouldAutoZoomOnNextResult = false + } + + /// Invalidates analysis and clears cached elevation profile + /// Use when points change (requires new elevation data) + private func invalidateAnalysis() { + invalidateAnalysisOnly() + repeaterElevationTask?.cancel() + repeaterElevationTask = nil + elevationProfile = [] + elevationProfileAR = [] + elevationProfileRB = [] + profileSamples = [] + profileSamplesRB = [] + elevationFetchFailed = false + repeaterPoint = nil // Repeater is invalid without profile + } + + /// Re-runs analysis using cached elevation profile when RF settings change + private func reanalyzeWithCachedProfileIfNeeded() { + // Only re-analyze if we have a cached profile and both points. + // Clear isAnalyzing on the bail path so a flag left set by a now-stale + // analysis (this method runs from a mid-analysis RF-setting change) can't + // strand the spinner. + guard !elevationProfile.isEmpty, + let pointA, + let pointB, + pointA.groundElevation != nil, + pointB.groundElevation != nil else { + isAnalyzing = false + return + } + + // If repeater is active, use relay analysis to preserve mode. + // Clear isAnalyzing first: the on-path/cached relay paths resolve + // synchronously, and the off-path path re-sets the flag itself. + if repeaterPoint != nil { + isAnalyzing = false + analyzeWithRepeater() + return + } + + // Cancel any existing analysis; this task takes over the isAnalyzing flag + analysisTask?.cancel() + isAnalyzing = true + + // Capture values for use in task + let profile = elevationProfile + let pointAHeight = pointA.additionalHeight + let pointBHeight = pointB.additionalHeight + let freq = frequencyMHz + let k = refractionK + + analysisTask = Task { + defer { isAnalyzing = false } + + let result = await Task.detached { + RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: pointAHeight, + pointBHeightMeters: pointBHeight, + frequencyMHz: freq, + refractionK: k ) - } - - // MARK: - Testing Helpers - - #if DEBUG + }.value + + if Task.isCancelled { return } + + profileSamples = FresnelZoneRenderer.buildProfileSamples( + from: profile, + pointAHeight: pointAHeight, + pointBHeight: pointBHeight, + frequencyMHz: freq, + refractionK: k + ) + profileSamplesRB = [] + analysisStatus = .result(result) + logger.debug("Re-analyzed with cached profile: \(freq) MHz, k=\(k)") + } + } + + private func fetchElevationForPointA() async { + guard let coordinate = pointA?.coordinate else { return } + + do { + let elevation = try await elevationService.fetchElevation(at: coordinate) + if Task.isCancelled { return } + pointA?.groundElevation = elevation + logger.debug("Point A elevation: \(elevation)m") + } catch { + if Task.isCancelled { return } + logger.error("Failed to fetch point A elevation: \(error.localizedDescription)") + // Set to 0 as fallback so analysis can proceed (sea level approximation) + pointA?.groundElevation = 0 + elevationFetchFailed = true + } + } + + private func fetchElevationForPointB() async { + guard let coordinate = pointB?.coordinate else { return } + + do { + let elevation = try await elevationService.fetchElevation(at: coordinate) + if Task.isCancelled { return } + pointB?.groundElevation = elevation + logger.debug("Point B elevation: \(elevation)m") + } catch { + if Task.isCancelled { return } + logger.error("Failed to fetch point B elevation: \(error.localizedDescription)") + // Set to 0 as fallback so analysis can proceed (sea level approximation) + pointB?.groundElevation = 0 + elevationFetchFailed = true + } + } + + // MARK: - Elevation Interpolation + + /// Returns interpolation indices and factor for a given path fraction + /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range + /// - Returns: Tuple of (lowerIndex, upperIndex, interpolationFactor) or nil if profile has fewer than 2 samples + private func interpolationIndices(for pathFraction: Double) -> (lower: Int, upper: Int, t: Double)? { + guard elevationProfile.count >= 2 else { return nil } + + let clamped = pathFraction.clamped(to: 0.0...1.0) + let index = clamped * Double(elevationProfile.count - 1) + let lowerIndex = Int(index) + let upperIndex = min(lowerIndex + 1, elevationProfile.count - 1) + let t = index - Double(lowerIndex) + + return (lowerIndex, upperIndex, t) + } + + /// Interpolates ground elevation at a given path fraction + /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range + /// - Returns: Interpolated ground elevation in meters, or nil if profile has fewer than 2 samples + func elevationAt(pathFraction: Double) -> Double? { + guard let indices = interpolationIndices(for: pathFraction) else { return nil } + + let lowerElevation = elevationProfile[indices.lower].elevation + let upperElevation = elevationProfile[indices.upper].elevation + + return lowerElevation + indices.t * (upperElevation - lowerElevation) + } + + /// Interpolates coordinate at a given path fraction + /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range + /// - Returns: Interpolated coordinate, or nil if profile has fewer than 2 samples + func coordinateAt(pathFraction: Double) -> CLLocationCoordinate2D? { + guard let indices = interpolationIndices(for: pathFraction) else { return nil } + + let lower = elevationProfile[indices.lower].coordinate + let upper = elevationProfile[indices.upper].coordinate + + return CLLocationCoordinate2D( + latitude: lower.latitude + indices.t * (upper.latitude - lower.latitude), + longitude: lower.longitude + indices.t * (upper.longitude - lower.longitude) + ) + } + + // MARK: - Testing Helpers + + #if DEBUG /// Testing helper to set analysis status directly func setAnalysisStatusForTesting(_ result: PathAnalysisResult) { - analysisStatus = .result(result) + analysisStatus = .result(result) } /// Testing helper to set elevation profile directly func setElevationProfileForTesting(_ profile: [ElevationSample]) { - elevationProfile = profile + elevationProfile = profile } - #endif + #endif } diff --git a/MC1/Views/Tools/LineOfSight/PointHeightEditorView.swift b/MC1/Views/Tools/LineOfSight/PointHeightEditorView.swift index 62b6ef1b..34abc9ba 100644 --- a/MC1/Views/Tools/LineOfSight/PointHeightEditorView.swift +++ b/MC1/Views/Tools/LineOfSight/PointHeightEditorView.swift @@ -1,18 +1,18 @@ import SwiftUI struct PointHeightEditorView: View { - var viewModel: LineOfSightViewModel - let point: SelectedPoint - let pointID: PointID + var viewModel: LineOfSightViewModel + let point: SelectedPoint + let pointID: PointID - var body: some View { - HeightEditorGrid( - groundElevation: point.groundElevation, - additionalHeight: Binding( - get: { point.additionalHeight }, - set: { viewModel.updateAdditionalHeight(for: pointID, meters: $0) } - ), - range: 0.0...200.0 - ) - } + var body: some View { + HeightEditorGrid( + groundElevation: point.groundElevation, + additionalHeight: Binding( + get: { point.additionalHeight }, + set: { viewModel.updateAdditionalHeight(for: pointID, meters: $0) } + ), + range: 0.0...200.0 + ) + } } diff --git a/MC1/Views/Tools/LineOfSight/PointRowButtonsView.swift b/MC1/Views/Tools/LineOfSight/PointRowButtonsView.swift index b3754a6a..576c9203 100644 --- a/MC1/Views/Tools/LineOfSight/PointRowButtonsView.swift +++ b/MC1/Views/Tools/LineOfSight/PointRowButtonsView.swift @@ -3,101 +3,101 @@ import MapKit import SwiftUI struct PointRowButtonsView: View { - var viewModel: LineOfSightViewModel - let pointID: PointID - let isEditing: Bool - @Binding var copyHapticTrigger: Int - @Binding var editingPoint: PointID? - let onRelocate: () -> Void - let onClear: () -> Void + var viewModel: LineOfSightViewModel + let pointID: PointID + let isEditing: Bool + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + let onClear: () -> Void - private let iconButtonSize: CGFloat = 22 + private let iconButtonSize: CGFloat = 22 - private var coordinate: CLLocationCoordinate2D? { - switch pointID { - case .pointA: viewModel.pointA?.coordinate - case .pointB: viewModel.pointB?.coordinate - case .repeater: viewModel.repeaterPoint?.coordinate - } + private var coordinate: CLLocationCoordinate2D? { + switch pointID { + case .pointA: viewModel.pointA?.coordinate + case .pointB: viewModel.pointB?.coordinate + case .repeater: viewModel.repeaterPoint?.coordinate } + } - var body: some View { - // Share menu - Menu { - if let coord = coordinate { - Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) - mapItem.name = switch pointID { - case .pointA: L10n.Tools.Tools.LineOfSight.pointA - case .pointB: L10n.Tools.Tools.LineOfSight.pointB - case .repeater: L10n.Tools.Tools.LineOfSight.repeaterLocation - } - mapItem.openInMaps() - } - - Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = coord.formattedString - } - - ShareLink(item: coord.formattedString) { - Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) + var body: some View { + // Share menu + Menu { + if let coord = coordinate { + Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) + mapItem.name = switch pointID { + case .pointA: L10n.Tools.Tools.LineOfSight.pointA + case .pointB: L10n.Tools.Tools.LineOfSight.pointB + case .repeater: L10n.Tools.Tools.LineOfSight.repeaterLocation + } + mapItem.openInMaps() } - .liquidGlassSecondaryButtonStyle() - .sensoryFeedback(.success, trigger: copyHapticTrigger) - .controlSize(.small) - // Relocate button (toggles on/off) - Button { - if viewModel.relocatingPoint == pointID { - viewModel.relocatingPoint = nil - } else { - viewModel.relocatingPoint = pointID - onRelocate() - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) + Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = coord.formattedString } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) - .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != pointID) - // Edit/Done toggle - Button { - withAnimation { - editingPoint = isEditing ? nil : pointID - } - } label: { - Group { - if isEditing { - Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") - .labelStyle(.iconOnly) - } else { - Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") - .labelStyle(.iconOnly) - .rotationEffect(.degrees(90)) - } - } - .frame(width: iconButtonSize, height: iconButtonSize) + ShareLink(item: coord.formattedString) { + Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) + } + } label: { + Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .sensoryFeedback(.success, trigger: copyHapticTrigger) + .controlSize(.small) - // Clear button - Button(action: onClear) { - Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) + // Relocate button (toggles on/off) + Button { + if viewModel.relocatingPoint == pointID { + viewModel.relocatingPoint = nil + } else { + viewModel.relocatingPoint = pointID + onRelocate() + } + } label: { + Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != pointID) + + // Edit/Done toggle + Button { + withAnimation { + editingPoint = isEditing ? nil : pointID + } + } label: { + Group { + if isEditing { + Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") + .labelStyle(.iconOnly) + } else { + Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") + .labelStyle(.iconOnly) + .rotationEffect(.degrees(90)) } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) + } + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + + // Clear button + Button(action: onClear) { + Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + } } diff --git a/MC1/Views/Tools/LineOfSight/PointRowView.swift b/MC1/Views/Tools/LineOfSight/PointRowView.swift index 908aede2..1b88684e 100644 --- a/MC1/Views/Tools/LineOfSight/PointRowView.swift +++ b/MC1/Views/Tools/LineOfSight/PointRowView.swift @@ -1,86 +1,88 @@ import SwiftUI struct PointRowView: View { - var viewModel: LineOfSightViewModel - let label: String - let color: Color - let point: SelectedPoint? - let pointID: PointID - @Binding var copyHapticTrigger: Int - @Binding var editingPoint: PointID? - let onRelocate: () -> Void - let onClear: () -> Void + var viewModel: LineOfSightViewModel + let label: String + let color: Color + let point: SelectedPoint? + let pointID: PointID + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + let onClear: () -> Void - private var isEditing: Bool { editingPoint == pointID } + private var isEditing: Bool { + editingPoint == pointID + } - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header row (always visible) - HStack { - // Point marker - Circle() - .fill(point != nil ? color : .gray.opacity(0.3)) - .frame(width: 24, height: 24) - .overlay { - Text(label) - .font(.caption) - .bold() - .foregroundStyle(.white) - } + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row (always visible) + HStack { + // Point marker + Circle() + .fill(point != nil ? color : .gray.opacity(0.3)) + .frame(width: 24, height: 24) + .overlay { + Text(label) + .font(.caption) + .bold() + .foregroundStyle(.white) + } - // Point info - if let point { - VStack(alignment: .leading, spacing: 2) { - Text(point.displayName) - .font(.subheadline) - .lineLimit(1) + // Point info + if let point { + VStack(alignment: .leading, spacing: 2) { + Text(point.displayName) + .font(.subheadline) + .lineLimit(1) - if point.isLoadingElevation { - HStack(spacing: 4) { - ProgressView() - .controlSize(.mini) - Text(L10n.Tools.Tools.LineOfSight.loadingElevation) - .font(.caption) - .foregroundStyle(.secondary) - } - } else if let elevation = point.groundElevation { - Text(Measurement( - value: elevation + point.additionalHeight, - unit: UnitLength.meters - ).formatted()) - .font(.caption) - .foregroundStyle(.secondary) - } - } + if point.isLoadingElevation { + HStack(spacing: 4) { + ProgressView() + .controlSize(.mini) + Text(L10n.Tools.Tools.LineOfSight.loadingElevation) + .font(.caption) + .foregroundStyle(.secondary) + } + } else if let elevation = point.groundElevation { + Text(Measurement( + value: elevation + point.additionalHeight, + unit: UnitLength.meters + ).formatted()) + .font(.caption) + .foregroundStyle(.secondary) + } + } - Spacer() + Spacer() - PointRowButtonsView( - viewModel: viewModel, - pointID: pointID, - isEditing: isEditing, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate, - onClear: onClear - ) - } else { - Text(L10n.Tools.Tools.LineOfSight.notSelected) - .font(.subheadline) - .foregroundStyle(.secondary) + PointRowButtonsView( + viewModel: viewModel, + pointID: pointID, + isEditing: isEditing, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: onClear + ) + } else { + Text(L10n.Tools.Tools.LineOfSight.notSelected) + .font(.subheadline) + .foregroundStyle(.secondary) - Spacer() - } - } + Spacer() + } + } - // Expanded editor (when editing) - if isEditing, let point { - Divider() + // Expanded editor (when editing) + if isEditing, let point { + Divider() - PointHeightEditorView(viewModel: viewModel, point: point, pointID: pointID) - } - } - .padding(12) - .animation(.easeInOut(duration: 0.2), value: isEditing) + PointHeightEditorView(viewModel: viewModel, point: point, pointID: pointID) + } } + .padding(12) + .animation(.easeInOut(duration: 0.2), value: isEditing) + } } diff --git a/MC1/Views/Tools/LineOfSight/PointsSummarySectionView.swift b/MC1/Views/Tools/LineOfSight/PointsSummarySectionView.swift index 60247239..842cd334 100644 --- a/MC1/Views/Tools/LineOfSight/PointsSummarySectionView.swift +++ b/MC1/Views/Tools/LineOfSight/PointsSummarySectionView.swift @@ -1,115 +1,117 @@ import SwiftUI struct PointsSummarySectionView: View { - var viewModel: LineOfSightViewModel - @Binding var copyHapticTrigger: Int - @Binding var editingPoint: PointID? - let onRelocate: () -> Void + var viewModel: LineOfSightViewModel + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void - private var isRelocating: Bool { viewModel.relocatingPoint != nil } + private var isRelocating: Bool { + viewModel.relocatingPoint != nil + } - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header with optional cancel button - HStack { - Text(L10n.Tools.Tools.LineOfSight.points) - .font(.headline) + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header with optional cancel button + HStack { + Text(L10n.Tools.Tools.LineOfSight.points) + .font(.headline) - Spacer() + Spacer() - if isRelocating { - Button(L10n.Tools.Tools.LineOfSight.cancel) { - viewModel.relocatingPoint = nil - } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) - } - } + if isRelocating { + Button(L10n.Tools.Tools.LineOfSight.cancel) { + viewModel.relocatingPoint = nil + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + } + } - // Show relocating message OR point rows - if let relocatingPoint = viewModel.relocatingPoint { - relocatingMessageView(for: relocatingPoint) - } else { - // Point A row - PointRowView( - viewModel: viewModel, - label: "A", - color: .blue, - point: viewModel.pointA, - pointID: .pointA, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate, - onClear: { viewModel.clearPointA() } - ) + // Show relocating message OR point rows + if let relocatingPoint = viewModel.relocatingPoint { + relocatingMessageView(for: relocatingPoint) + } else { + // Point A row + PointRowView( + viewModel: viewModel, + label: "A", + color: .blue, + point: viewModel.pointA, + pointID: .pointA, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearPointA() } + ) - // Repeater row (placeholder or full, positioned between A and B) - // Inline check for repeaterPoint to ensure SwiftUI properly tracks the dependency - if viewModel.repeaterPoint != nil { - RepeaterRowView( - viewModel: viewModel, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate - ) - } else if viewModel.shouldShowRepeaterPlaceholder { - AddRepeaterRowView { - viewModel.addRepeater() - viewModel.analyzeWithRepeater() - } - } + // Repeater row (placeholder or full, positioned between A and B) + // Inline check for repeaterPoint to ensure SwiftUI properly tracks the dependency + if viewModel.repeaterPoint != nil { + RepeaterRowView( + viewModel: viewModel, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate + ) + } else if viewModel.shouldShowRepeaterPlaceholder { + AddRepeaterRowView { + viewModel.addRepeater() + viewModel.analyzeWithRepeater() + } + } - // Point B row - PointRowView( - viewModel: viewModel, - label: "B", - color: .green, - point: viewModel.pointB, - pointID: .pointB, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate, - onClear: { viewModel.clearPointB() } - ) + // Point B row + PointRowView( + viewModel: viewModel, + label: "B", + color: .green, + point: viewModel.pointB, + pointID: .pointB, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearPointB() } + ) - if viewModel.pointA == nil || viewModel.pointB == nil { - Text(L10n.Tools.Tools.LineOfSight.selectPointsHint) - .font(.caption) - .foregroundStyle(.secondary) - } + if viewModel.pointA == nil || viewModel.pointB == nil { + Text(L10n.Tools.Tools.LineOfSight.longPressPointsHint) + .font(.caption) + .foregroundStyle(.secondary) + } - if viewModel.elevationFetchFailed { - Label( - L10n.Tools.Tools.LineOfSight.elevationUnavailable, - systemImage: "exclamationmark.triangle.fill" - ) - .font(.caption) - .foregroundStyle(.orange) - } - } + if viewModel.elevationFetchFailed { + Label( + L10n.Tools.Tools.LineOfSight.elevationUnavailable, + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) } + } } + } - @ViewBuilder - private func relocatingMessageView(for pointID: PointID) -> some View { - let pointName: String = switch pointID { - case .pointA: L10n.Tools.Tools.LineOfSight.pointA - case .pointB: L10n.Tools.Tools.LineOfSight.pointB - case .repeater: L10n.Tools.Tools.LineOfSight.repeater - } + @ViewBuilder + private func relocatingMessageView(for pointID: PointID) -> some View { + let pointName: String = switch pointID { + case .pointA: L10n.Tools.Tools.LineOfSight.pointA + case .pointB: L10n.Tools.Tools.LineOfSight.pointB + case .repeater: L10n.Tools.Tools.LineOfSight.repeater + } - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.LineOfSight.relocating(pointName)) - .font(.subheadline) - .bold() + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.LineOfSight.relocating(pointName)) + .font(.subheadline) + .bold() - Text(L10n.Tools.Tools.LineOfSight.tapMapInstruction) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(L10n.Tools.Tools.LineOfSight.relocating(pointName)) \(L10n.Tools.Tools.LineOfSight.tapMapInstruction)") + Text(L10n.Tools.Tools.LineOfSight.tapMapInstruction) + .font(.caption) + .foregroundStyle(.secondary) } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(L10n.Tools.Tools.LineOfSight.relocating(pointName)) \(L10n.Tools.Tools.LineOfSight.tapMapInstruction)") + } } diff --git a/MC1/Views/Tools/LineOfSight/RFSettingsSectionView.swift b/MC1/Views/Tools/LineOfSight/RFSettingsSectionView.swift index 4855b08f..5470715c 100644 --- a/MC1/Views/Tools/LineOfSight/RFSettingsSectionView.swift +++ b/MC1/Views/Tools/LineOfSight/RFSettingsSectionView.swift @@ -1,38 +1,38 @@ import SwiftUI struct RFSettingsSectionView: View { - @Bindable var viewModel: LineOfSightViewModel - @Binding var isRFSettingsExpanded: Bool + @Bindable var viewModel: LineOfSightViewModel + @Binding var isRFSettingsExpanded: Bool - var body: some View { - DisclosureGroup(isExpanded: $isRFSettingsExpanded) { - VStack(spacing: 12) { - // Frequency input - extracted to separate view for @FocusState to work in sheet - FrequencyInputRow(viewModel: viewModel) + var body: some View { + DisclosureGroup(isExpanded: $isRFSettingsExpanded) { + VStack(spacing: 12) { + // Frequency input - extracted to separate view for @FocusState to work in sheet + FrequencyInputRow(viewModel: viewModel) - Divider() + Divider() - // Refraction k-factor picker - HStack { - Label(L10n.Tools.Tools.LineOfSight.refraction, systemImage: "globe") - .foregroundStyle(.secondary) - Spacer() - Picker("", selection: Binding( - get: { viewModel.refractionK }, - set: { viewModel.refractionK = $0 } - )) { - Text(L10n.Tools.Tools.LineOfSight.Refraction.none).tag(1.0) - Text(L10n.Tools.Tools.LineOfSight.Refraction.standard).tag(4.0 / 3.0) - Text(L10n.Tools.Tools.LineOfSight.Refraction.ducting).tag(4.0) - } - .pickerStyle(.menu) - } - } - .padding(.top, 8) - } label: { - Label(L10n.Tools.Tools.LineOfSight.rfSettings, systemImage: "antenna.radiowaves.left.and.right") - .font(.headline) + // Refraction k-factor picker + HStack { + Label(L10n.Tools.Tools.LineOfSight.refraction, systemImage: "globe") + .foregroundStyle(.secondary) + Spacer() + Picker("", selection: Binding( + get: { viewModel.refractionK }, + set: { viewModel.refractionK = $0 } + )) { + Text(L10n.Tools.Tools.LineOfSight.Refraction.none).tag(1.0) + Text(L10n.Tools.Tools.LineOfSight.Refraction.standard).tag(4.0 / 3.0) + Text(L10n.Tools.Tools.LineOfSight.Refraction.ducting).tag(4.0) + } + .pickerStyle(.menu) } - .tint(.primary) + } + .padding(.top, 8) + } label: { + Label(L10n.Tools.Tools.LineOfSight.rfSettings, systemImage: "antenna.radiowaves.left.and.right") + .font(.headline) } + .tint(.primary) + } } diff --git a/MC1/Views/Tools/LineOfSight/RepeaterHeightEditorView.swift b/MC1/Views/Tools/LineOfSight/RepeaterHeightEditorView.swift index cf14d92a..44e1378b 100644 --- a/MC1/Views/Tools/LineOfSight/RepeaterHeightEditorView.swift +++ b/MC1/Views/Tools/LineOfSight/RepeaterHeightEditorView.swift @@ -1,18 +1,18 @@ import SwiftUI struct RepeaterHeightEditorView: View { - var viewModel: LineOfSightViewModel - let repeaterPoint: RepeaterPoint + var viewModel: LineOfSightViewModel + let repeaterPoint: RepeaterPoint - var body: some View { - HeightEditorGrid( - groundElevation: viewModel.repeaterGroundElevation, - additionalHeight: Binding( - get: { repeaterPoint.additionalHeight }, - set: { viewModel.updateRepeaterHeight(meters: $0) } - ), - range: 0.0...200.0, - onHeightChanged: { viewModel.analyzeWithRepeater() } - ) - } + var body: some View { + HeightEditorGrid( + groundElevation: viewModel.repeaterGroundElevation, + additionalHeight: Binding( + get: { repeaterPoint.additionalHeight }, + set: { viewModel.updateRepeaterHeight(meters: $0) } + ), + range: 0.0...200.0, + onHeightChanged: { viewModel.analyzeWithRepeater() } + ) + } } diff --git a/MC1/Views/Tools/LineOfSight/RepeaterRowView.swift b/MC1/Views/Tools/LineOfSight/RepeaterRowView.swift index 386de089..9cb604a6 100644 --- a/MC1/Views/Tools/LineOfSight/RepeaterRowView.swift +++ b/MC1/Views/Tools/LineOfSight/RepeaterRowView.swift @@ -1,64 +1,66 @@ import SwiftUI struct RepeaterRowView: View { - var viewModel: LineOfSightViewModel - @Binding var copyHapticTrigger: Int - @Binding var editingPoint: PointID? - let onRelocate: () -> Void + var viewModel: LineOfSightViewModel + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void - private var isEditing: Bool { editingPoint == .repeater } + private var isEditing: Bool { + editingPoint == .repeater + } - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header row - HStack { - // Repeater marker (purple) - Circle() - .fill(.purple) - .frame(width: 24, height: 24) - .overlay { - Text("R") - .font(.caption) - .bold() - .foregroundStyle(.white) - } + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row + HStack { + // Repeater marker (purple) + Circle() + .fill(.purple) + .frame(width: 24, height: 24) + .overlay { + Text("R") + .font(.caption) + .bold() + .foregroundStyle(.white) + } - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Tools.Tools.LineOfSight.repeater) - .font(.subheadline) - .lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Tools.Tools.LineOfSight.repeater) + .font(.subheadline) + .lineLimit(1) - if let elevation = viewModel.repeaterGroundElevation { - let totalHeight = elevation + (viewModel.repeaterPoint?.additionalHeight ?? 0.0) - Text(Measurement( - value: totalHeight, - unit: UnitLength.meters - ).formatted()) - .font(.caption) - .foregroundStyle(.secondary) - } - } + if let elevation = viewModel.repeaterGroundElevation { + let totalHeight = elevation + (viewModel.repeaterPoint?.additionalHeight ?? 0.0) + Text(Measurement( + value: totalHeight, + unit: UnitLength.meters + ).formatted()) + .font(.caption) + .foregroundStyle(.secondary) + } + } - Spacer() + Spacer() - PointRowButtonsView( - viewModel: viewModel, - pointID: .repeater, - isEditing: isEditing, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate, - onClear: { viewModel.clearRepeater() } - ) - } + PointRowButtonsView( + viewModel: viewModel, + pointID: .repeater, + isEditing: isEditing, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearRepeater() } + ) + } - // Expanded editor - if isEditing, let repeaterPoint = viewModel.repeaterPoint { - Divider() - RepeaterHeightEditorView(viewModel: viewModel, repeaterPoint: repeaterPoint) - } - } - .padding(12) - .animation(.easeInOut(duration: 0.2), value: isEditing) + // Expanded editor + if isEditing, let repeaterPoint = viewModel.repeaterPoint { + Divider() + RepeaterHeightEditorView(viewModel: viewModel, repeaterPoint: repeaterPoint) + } } + .padding(12) + .animation(.easeInOut(duration: 0.2), value: isEditing) + } } diff --git a/MC1/Views/Tools/LineOfSight/TerrainProfileCanvas.swift b/MC1/Views/Tools/LineOfSight/TerrainProfileCanvas.swift index 724f7433..334e1d89 100644 --- a/MC1/Views/Tools/LineOfSight/TerrainProfileCanvas.swift +++ b/MC1/Views/Tools/LineOfSight/TerrainProfileCanvas.swift @@ -3,769 +3,764 @@ import SwiftUI /// Canvas-based terrain profile visualization with Fresnel zone struct TerrainProfileCanvas: View { - let elevationProfile: [ElevationSample] + let elevationProfile: [ElevationSample] - /// Profile samples for primary segment (A→B or A→R when repeater active) - let profileSamples: [ProfileSample] + /// Profile samples for primary segment (A→B or A→R when repeater active) + let profileSamples: [ProfileSample] - /// Profile samples for R→B segment (empty when no repeater) - var profileSamplesRB: [ProfileSample] = [] + /// Profile samples for R→B segment (empty when no repeater) + var profileSamplesRB: [ProfileSample] = [] - // Optional repeater parameters - var repeaterPathFraction: Double? - var repeaterHeight: Double? + // Optional repeater parameters + var repeaterPathFraction: Double? + var repeaterHeight: Double? - /// Callback to update repeater position during drag - var onRepeaterDrag: ((Double) -> Void)? + /// Callback to update repeater position during drag + var onRepeaterDrag: ((Double) -> Void)? - /// Callback to report repeater marker center position (for tooltip positioning) - var onRepeaterMarkerPosition: ((CGPoint) -> Void)? + /// Callback to report repeater marker center position (for tooltip positioning) + var onRepeaterMarkerPosition: ((CGPoint) -> Void)? - /// Segment distances for off-path repeater visualization (nil when on-path or no repeater) - var segmentARDistanceMeters: Double? - var segmentRBDistanceMeters: Double? + /// Segment distances for off-path repeater visualization (nil when on-path or no repeater) + var segmentARDistanceMeters: Double? + var segmentRBDistanceMeters: Double? - /// Whether the repeater is off the direct A→B path - private var isOffPath: Bool { - segmentARDistanceMeters != nil && segmentRBDistanceMeters != nil - } - - // Track drag state - @State private var isDragging = false - @State private var canvasSize: CGSize = .zero - - // Nudge animation state (one-time affordance when repeater first added) - @State private var hasAnimatedNudge = false - @State private var nudgeOffset: CGFloat = 0 - - // Repeater marker center (for tooltip positioning) - @State private var repeaterMarkerCenter: CGPoint? - - // MARK: - Colors (Colorblind-Safe) - - private let terrainFill = Color(red: 0.76, green: 0.70, blue: 0.60) - private let terrainStroke = Color(red: 0.55, green: 0.47, blue: 0.36) - private let fresnelOuter = Color.teal.opacity(0.25) - private let fresnelInner = Color.teal.opacity(0.50) - private let fresnelObstructed = Color.red.opacity(0.7) - private let fresnelBoundary = Color.teal.opacity(0.6) - private let losLineColor = Color.primary - private let gridColor = Color.gray.opacity(0.3) - private let repeaterColor = Color.purple - - // MARK: - Layout Constants + /// Whether the repeater is off the direct A→B path + private var isOffPath: Bool { + segmentARDistanceMeters != nil && segmentRBDistanceMeters != nil + } - private let padding = EdgeInsets(top: 24, leading: 45, bottom: 28, trailing: 16) - private let chartHeight: CGFloat = 200 + // Track drag state + @State private var isDragging = false + @State private var canvasSize: CGSize = .zero - // MARK: - Computed Properties + // Nudge animation state (one-time affordance when repeater first added) + @State private var hasAnimatedNudge = false + @State private var nudgeOffset: CGFloat = 0 - private var xRange: ClosedRange { - guard let last = elevationProfile.last else { return 0...1 } - return 0...max(1, last.distanceFromAMeters) - } - - private var yRange: ClosedRange { - let allSamples = profileSamples + profileSamplesRB - guard !allSamples.isEmpty else { return 0...100 } + /// Repeater marker center (for tooltip positioning) + @State private var repeaterMarkerCenter: CGPoint? - var minY = Double.infinity - var maxY = -Double.infinity + // MARK: - Colors (Colorblind-Safe) - for sample in allSamples { - minY = min(minY, sample.yTerrain) - maxY = max(maxY, sample.yTop) - } + private let terrainFill = Color(red: 0.76, green: 0.70, blue: 0.60) + private let terrainStroke = Color(red: 0.55, green: 0.47, blue: 0.36) + private let fresnelOuter = Color.teal.opacity(0.25) + private let fresnelInner = Color.teal.opacity(0.50) + private let fresnelObstructed = Color.red.opacity(0.7) + private let fresnelBoundary = Color.teal.opacity(0.6) + private let losLineColor = Color.primary + private let gridColor = Color.gray.opacity(0.3) + private let repeaterColor = Color.purple - guard minY.isFinite, maxY.isFinite, maxY > minY else { return 0...100 } + // MARK: - Layout Constants - let range = maxY - minY - return (minY - range * 0.1)...(maxY + range * 0.2) - } + private let padding = EdgeInsets(top: 24, leading: 45, bottom: 28, trailing: 16) + private let chartHeight: CGFloat = 200 - /// Calculated repeater marker center position in canvas coordinates - private var calculatedMarkerCenter: CGPoint? { - guard canvasSize != .zero, - let junctionSample = profileSamples.last, - repeaterPathFraction != nil else { return nil } + // MARK: - Computed Properties - let coords = ChartCoordinateSpace( - canvasSize: canvasSize, - padding: padding, - xRange: xRange, - yRange: yRange - ) - - let basePoint = coords.point(x: junctionSample.x, y: junctionSample.yLOS) - return CGPoint(x: basePoint.x + nudgeOffset, y: basePoint.y) - } + private var xRange: ClosedRange { + guard let last = elevationProfile.last else { return 0...1 } + return 0...max(1, last.distanceFromAMeters) + } - // MARK: - Body + private var yRange: ClosedRange { + let allSamples = profileSamples + profileSamplesRB + guard !allSamples.isEmpty else { return 0...100 } - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if elevationProfile.isEmpty { - emptyState - } else { - chartCanvas - legendView - if isOffPath { - indirectRouteLabel - } - attributionText - } - } - } + var minY = Double.infinity + var maxY = -Double.infinity - private var emptyState: some View { - ContentUnavailableView( - L10n.Tools.Tools.LineOfSight.noData, - systemImage: "chart.xyaxis.line", - description: Text(L10n.Tools.Tools.LineOfSight.selectTwoPoints) - ) - .frame(height: chartHeight) - } - - private var chartCanvas: some View { - Canvas { context, size in - let coords = ChartCoordinateSpace( - canvasSize: size, - padding: padding, - xRange: xRange, - yRange: yRange - ) - - drawGrid(context: context, coords: coords) - drawFresnelZone(context: context, coords: coords) - drawFresnelBoundary(context: context, coords: coords) - drawTerrain(context: context, coords: coords) - drawObstructions(context: context, coords: coords) - drawLOSLine(context: context, coords: coords) - drawEndpointMarkers(context: context, coords: coords) - - // Draw vertical separator at R junction for off-path repeaters (behind R marker) - if let arDistance = segmentARDistanceMeters, isOffPath { - drawJunctionSeparator(context: context, coords: coords, atDistance: arDistance) - } - - // Draw repeater marker if present - if let pathFraction = repeaterPathFraction, - let height = repeaterHeight, - elevationProfile.count >= 2 { - // Interpolate ground elevation at repeater position - let index = pathFraction * Double(elevationProfile.count - 1) - let lowerIndex = Int(index) - let upperIndex = min(lowerIndex + 1, elevationProfile.count - 1) - let t = index - Double(lowerIndex) - let groundElevation = elevationProfile[lowerIndex].elevation + - t * (elevationProfile[upperIndex].elevation - elevationProfile[lowerIndex].elevation) - - drawRepeaterMarker( - context: context, - coords: coords, - pathFraction: pathFraction, - repeaterElevation: groundElevation, - height: height, - nudgeOffset: nudgeOffset - ) - } - } - .frame(height: chartHeight) - .onGeometryChange(for: CGSize.self) { proxy in - proxy.size - } action: { size in - canvasSize = size - } - .gesture(repeaterDragGesture, including: onRepeaterDrag != nil ? .all : .subviews) - .onChange(of: repeaterPathFraction, initial: true) { oldValue, newValue in - // Trigger one-time nudge animation when repeater is first added (on-path only) - if oldValue == nil, newValue != nil, !hasAnimatedNudge, !isOffPath { - hasAnimatedNudge = true - triggerNudgeAnimation() - } - } - .onChange(of: calculatedMarkerCenter) { _, newCenter in - if let center = newCenter { - onRepeaterMarkerPosition?(center) - } - } + for sample in allSamples { + minY = min(minY, sample.yTerrain) + maxY = max(maxY, sample.yTop) } - private var repeaterDragGesture: some Gesture { - DragGesture(minimumDistance: 0) - .onChanged { value in - guard repeaterPathFraction != nil else { return } - handleDrag(at: value.location) - } - .onEnded { _ in - isDragging = false - } - } + guard minY.isFinite, maxY.isFinite, maxY > minY else { return 0...100 } - private func handleDrag(at location: CGPoint) { - // Convert X position to path fraction - let chartWidth = canvasSize.width - padding.leading - padding.trailing - let relativeX = (location.x - padding.leading) / chartWidth - let pathFraction = max(0.05, min(0.95, relativeX)) + let range = maxY - minY + return (minY - range * 0.1)...(maxY + range * 0.2) + } - if !isDragging { - isDragging = true - } + /// Calculated repeater marker center position in canvas coordinates + private var calculatedMarkerCenter: CGPoint? { + guard canvasSize != .zero, + let junctionSample = profileSamples.last, + repeaterPathFraction != nil else { return nil } - onRepeaterDrag?(pathFraction) - } + let coords = ChartCoordinateSpace( + canvasSize: canvasSize, + padding: padding, + xRange: xRange, + yRange: yRange + ) - /// Performs a one-time horizontal nudge animation to hint at draggability - private func triggerNudgeAnimation() { - // Animate: shift right 4pt, then back to original position - withAnimation(.easeOut(duration: 0.15)) { - nudgeOffset = 4 - } - withAnimation(.easeInOut(duration: 0.2).delay(0.15)) { - nudgeOffset = 0 - } - } + let basePoint = coords.point(x: junctionSample.x, y: junctionSample.yLOS) + return CGPoint(x: basePoint.x + nudgeOffset, y: basePoint.y) + } - private var legendView: some View { - HStack(spacing: 16) { - legendItem(color: terrainStroke, label: L10n.Tools.Tools.LineOfSight.Legend.terrain) - legendItem(color: losLineColor, label: L10n.Tools.Tools.LineOfSight.Legend.los) - legendItem(color: fresnelOuter, label: L10n.Tools.Tools.LineOfSight.Legend.clear) - legendItem(color: fresnelObstructed, label: L10n.Tools.Tools.LineOfSight.Legend.obstructed) - if repeaterPathFraction != nil { - legendItem(color: repeaterColor, label: L10n.Tools.Tools.LineOfSight.repeater) - } - } - .font(.caption2) - .foregroundStyle(.secondary) - } + // MARK: - Body - private func legendItem(color: Color, label: String) -> some View { - HStack(spacing: 4) { - Circle() - .fill(color) - .frame(width: 8, height: 8) - Text(label) + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if elevationProfile.isEmpty { + emptyState + } else { + chartCanvas + legendView + if isOffPath { + indirectRouteLabel } + attributionText + } } + } - private var indirectRouteLabel: some View { - Text(L10n.Tools.Tools.LineOfSight.indirectRoute) - .font(.caption2) - .foregroundStyle(.secondary) - } - - private var attributionText: some View { - Text(L10n.Tools.Tools.LineOfSight.elevationAttribution) - .font(.caption2) - .foregroundStyle(.tertiary) - } + private var emptyState: some View { + ContentUnavailableView( + L10n.Tools.Tools.LineOfSight.noData, + systemImage: "chart.xyaxis.line", + description: Text(L10n.Tools.Tools.LineOfSight.selectTwoPoints) + ) + .frame(height: chartHeight) + } + + private var chartCanvas: some View { + Canvas { context, size in + let coords = ChartCoordinateSpace( + canvasSize: size, + padding: padding, + xRange: xRange, + yRange: yRange + ) + + drawGrid(context: context, coords: coords) + drawFresnelZone(context: context, coords: coords) + drawFresnelBoundary(context: context, coords: coords) + drawTerrain(context: context, coords: coords) + drawObstructions(context: context, coords: coords) + drawLOSLine(context: context, coords: coords) + drawEndpointMarkers(context: context, coords: coords) + + // Draw vertical separator at R junction for off-path repeaters (behind R marker) + if let arDistance = segmentARDistanceMeters, isOffPath { + drawJunctionSeparator(context: context, coords: coords, atDistance: arDistance) + } + + // Draw repeater marker if present + if let pathFraction = repeaterPathFraction, + let height = repeaterHeight, + elevationProfile.count >= 2 { + // Interpolate ground elevation at repeater position + let index = pathFraction * Double(elevationProfile.count - 1) + let lowerIndex = Int(index) + let upperIndex = min(lowerIndex + 1, elevationProfile.count - 1) + let t = index - Double(lowerIndex) + let groundElevation = elevationProfile[lowerIndex].elevation + + t * (elevationProfile[upperIndex].elevation - elevationProfile[lowerIndex].elevation) + + drawRepeaterMarker( + context: context, + coords: coords, + pathFraction: pathFraction, + repeaterElevation: groundElevation, + height: height, + nudgeOffset: nudgeOffset + ) + } + } + .frame(height: chartHeight) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { size in + canvasSize = size + } + .gesture(repeaterDragGesture, including: onRepeaterDrag != nil ? .all : .subviews) + .onChange(of: repeaterPathFraction, initial: true) { oldValue, newValue in + // Trigger one-time nudge animation when repeater is first added (on-path only) + if oldValue == nil, newValue != nil, !hasAnimatedNudge, !isOffPath { + hasAnimatedNudge = true + triggerNudgeAnimation() + } + } + .onChange(of: calculatedMarkerCenter) { _, newCenter in + if let center = newCenter { + onRepeaterMarkerPosition?(center) + } + } + } + + private var repeaterDragGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + guard repeaterPathFraction != nil else { return } + handleDrag(at: value.location) + } + .onEnded { _ in + isDragging = false + } + } + + private func handleDrag(at location: CGPoint) { + // Convert X position to path fraction + let chartWidth = canvasSize.width - padding.leading - padding.trailing + let relativeX = (location.x - padding.leading) / chartWidth + let pathFraction = max(0.05, min(0.95, relativeX)) + + if !isDragging { + isDragging = true + } + + onRepeaterDrag?(pathFraction) + } + + /// Performs a one-time horizontal nudge animation to hint at draggability + private func triggerNudgeAnimation() { + // Animate: shift right 4pt, then back to original position + withAnimation(.easeOut(duration: 0.15)) { + nudgeOffset = 4 + } + withAnimation(.easeInOut(duration: 0.2).delay(0.15)) { + nudgeOffset = 0 + } + } + + private var legendView: some View { + HStack(spacing: 16) { + legendItem(color: terrainStroke, label: L10n.Tools.Tools.LineOfSight.Legend.terrain) + legendItem(color: losLineColor, label: L10n.Tools.Tools.LineOfSight.Legend.los) + legendItem(color: fresnelOuter, label: L10n.Tools.Tools.LineOfSight.Legend.clear) + legendItem(color: fresnelObstructed, label: L10n.Tools.Tools.LineOfSight.Legend.obstructed) + if repeaterPathFraction != nil { + legendItem(color: repeaterColor, label: L10n.Tools.Tools.LineOfSight.repeater) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + private func legendItem(color: Color, label: String) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(label) + } + } + + private var indirectRouteLabel: some View { + Text(L10n.Tools.Tools.LineOfSight.indirectRoute) + .font(.caption2) + .foregroundStyle(.secondary) + } + + private var attributionText: some View { + Text(L10n.Tools.Tools.LineOfSight.elevationAttribution) + .font(.caption2) + .foregroundStyle(.tertiary) + } } // MARK: - Axis Helpers extension TerrainProfileCanvas { - - /// Calculate a "nice" step value for axis ticks - /// Returns a step that produces clean numbers like 10, 25, 50, 100, etc. - private func niceStep(for range: Double, targetDivisions: Int) -> Double { - guard range > 0, targetDivisions > 0 else { return 1 } - - let roughStep = range / Double(targetDivisions) - let magnitude = pow(10, floor(log10(roughStep))) - let normalized = roughStep / magnitude - - // Snap to nice values: 1, 2, 2.5, 5, 10 - let niceNormalized: Double - if normalized <= 1 { - niceNormalized = 1 - } else if normalized <= 2 { - niceNormalized = 2 - } else if normalized <= 2.5 { - niceNormalized = 2.5 - } else if normalized <= 5 { - niceNormalized = 5 - } else { - niceNormalized = 10 - } - - return niceNormalized * magnitude - } - - /// Generate tick values that start at a nice boundary - private func tickValues(for range: ClosedRange, step: Double) -> [Double] { - guard step > 0 else { return [] } - - let start = ceil(range.lowerBound / step) * step - var ticks: [Double] = [] - var current = start - - while current <= range.upperBound { - ticks.append(current) - current += step - } - - return ticks - } + /// Calculate a "nice" step value for axis ticks + /// Returns a step that produces clean numbers like 10, 25, 50, 100, etc. + private func niceStep(for range: Double, targetDivisions: Int) -> Double { + guard range > 0, targetDivisions > 0 else { return 1 } + + let roughStep = range / Double(targetDivisions) + let magnitude = pow(10, floor(log10(roughStep))) + let normalized = roughStep / magnitude + + // Snap to nice values: 1, 2, 2.5, 5, 10 + let niceNormalized: Double = if normalized <= 1 { + 1 + } else if normalized <= 2 { + 2 + } else if normalized <= 2.5 { + 2.5 + } else if normalized <= 5 { + 5 + } else { + 10 + } + + return niceNormalized * magnitude + } + + /// Generate tick values that start at a nice boundary + private func tickValues(for range: ClosedRange, step: Double) -> [Double] { + guard step > 0 else { return [] } + + let start = ceil(range.lowerBound / step) * step + var ticks: [Double] = [] + var current = start + + while current <= range.upperBound { + ticks.append(current) + current += step + } + + return ticks + } } // MARK: - Draw Functions extension TerrainProfileCanvas { + private func drawGrid(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Calculate nice step values + let yStep = niceStep(for: yRange.upperBound - yRange.lowerBound, targetDivisions: 4) + let xStep = niceStep(for: xRange.upperBound - xRange.lowerBound, targetDivisions: 5) + + let yTicks = tickValues(for: yRange, step: yStep) + let xTicks = tickValues(for: xRange, step: xStep) + + // Draw horizontal grid lines + let gridPath = Path { path in + for y in yTicks { + let startPoint = coords.point(x: xRange.lowerBound, y: y) + let endPoint = coords.point(x: xRange.upperBound, y: y) + path.move(to: startPoint) + path.addLine(to: endPoint) + } + } + + context.stroke( + gridPath, + with: .color(gridColor), + style: StrokeStyle(lineWidth: 0.5, dash: [4, 4]) + ) - private func drawGrid(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Calculate nice step values - let yStep = niceStep(for: yRange.upperBound - yRange.lowerBound, targetDivisions: 4) - let xStep = niceStep(for: xRange.upperBound - xRange.lowerBound, targetDivisions: 5) - - let yTicks = tickValues(for: yRange, step: yStep) - let xTicks = tickValues(for: xRange, step: xStep) - - // Draw horizontal grid lines - let gridPath = Path { path in - for y in yTicks { - let startPoint = coords.point(x: xRange.lowerBound, y: y) - let endPoint = coords.point(x: xRange.upperBound, y: y) - path.move(to: startPoint) - path.addLine(to: endPoint) - } - } - - context.stroke( - gridPath, - with: .color(gridColor), - style: StrokeStyle(lineWidth: 0.5, dash: [4, 4]) - ) - - // Y-axis labels (elevation in meters) - for (index, y) in yTicks.enumerated() { - let labelPoint = coords.point(x: xRange.lowerBound, y: y) - let isLast = index == yTicks.count - 1 - let labelText = isLast ? "\(Int(y)) m" : "\(Int(y))" - let label = Text(labelText) - .font(.system(size: 9)) - .foregroundStyle(.secondary) - context.draw(label, at: CGPoint(x: labelPoint.x - 8, y: labelPoint.y), anchor: .trailing) - } - - // X-axis labels (distance in km) - for (index, x) in xTicks.enumerated() { - let labelPoint = coords.point(x: x, y: yRange.lowerBound) - let kmValue = x / 1000 - let isLast = index == xTicks.count - 1 - - // Format based on step size - use integers for whole km values - let labelText: String - if xStep >= 1000 && kmValue.truncatingRemainder(dividingBy: 1) == 0 { - labelText = isLast ? "\(Int(kmValue)) km" : "\(Int(kmValue))" - } else { - labelText = isLast - ? "\(kmValue.formatted(.number.precision(.fractionLength(1)))) km" - : kmValue.formatted(.number.precision(.fractionLength(1))) - } - - let label = Text(labelText) - .font(.system(size: 9)) - .foregroundStyle(.secondary) - context.draw(label, at: CGPoint(x: labelPoint.x, y: labelPoint.y + 10), anchor: .top) - } - } - - private func drawFresnelZone(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Draw primary segment (A→B or A→R) - drawFresnelForSamples(context: context, coords: coords, samples: profileSamples) - - // Draw secondary segment (R→B) if present - if !profileSamplesRB.isEmpty { - drawFresnelForSamples(context: context, coords: coords, samples: profileSamplesRB) - } + // Y-axis labels (elevation in meters) + for (index, y) in yTicks.enumerated() { + let labelPoint = coords.point(x: xRange.lowerBound, y: y) + let isLast = index == yTicks.count - 1 + let labelText = isLast ? "\(Int(y)) m" : "\(Int(y))" + let label = Text(labelText) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + context.draw(label, at: CGPoint(x: labelPoint.x - 8, y: labelPoint.y), anchor: .trailing) + } + + // X-axis labels (distance in km) + for (index, x) in xTicks.enumerated() { + let labelPoint = coords.point(x: x, y: yRange.lowerBound) + let kmValue = x / 1000 + let isLast = index == xTicks.count - 1 + + // Format based on step size - use integers for whole km values + let labelText: String = if xStep >= 1000, kmValue.truncatingRemainder(dividingBy: 1) == 0 { + isLast ? "\(Int(kmValue)) km" : "\(Int(kmValue))" + } else { + isLast + ? "\(kmValue.formatted(.number.precision(.fractionLength(1)))) km" + : kmValue.formatted(.number.precision(.fractionLength(1))) + } + + let label = Text(labelText) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + context.draw(label, at: CGPoint(x: labelPoint.x, y: labelPoint.y + 10), anchor: .top) } - - private func drawFresnelForSamples( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - guard samples.count >= 2 else { return } - - drawOuterFresnelFill(context: context, coords: coords, samples: samples) - drawInnerFresnelFill(context: context, coords: coords, samples: samples) + } + + private func drawFresnelZone(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Draw primary segment (A→B or A→R) + drawFresnelForSamples(context: context, coords: coords, samples: profileSamples) + + // Draw secondary segment (R→B) if present + if !profileSamplesRB.isEmpty { + drawFresnelForSamples(context: context, coords: coords, samples: profileSamplesRB) + } + } + + private func drawFresnelForSamples( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + guard samples.count >= 2 else { return } + + drawOuterFresnelFill(context: context, coords: coords, samples: samples) + drawInnerFresnelFill(context: context, coords: coords, samples: samples) + } + + private func drawObstructions(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Draw obstructions for primary segment + drawObstructionOverlay(context: context, coords: coords, samples: profileSamples) + + // Draw obstructions for secondary segment if present + if !profileSamplesRB.isEmpty { + drawObstructionOverlay(context: context, coords: coords, samples: profileSamplesRB) + } + } + + private func drawOuterFresnelFill( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + var path = Path() + + // Top edge: left to right + if let first = samples.first { + path.move(to: coords.point(x: first.x, y: first.yTop)) + } + for sample in samples.dropFirst() { + path.addLine(to: coords.point(x: sample.x, y: sample.yTop)) + } + + // Bottom edge: right to left (clamped to terrain) + for sample in samples.reversed() { + path.addLine(to: coords.point(x: sample.x, y: sample.yVisibleBottom)) + } + + path.closeSubpath() + context.fill(path, with: .color(fresnelOuter)) + } + + private func drawInnerFresnelFill( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + var path = Path() + + // Top edge: left to right + if let first = samples.first { + path.move(to: coords.point(x: first.x, y: first.yTop60)) + } + for sample in samples.dropFirst() { + path.addLine(to: coords.point(x: sample.x, y: sample.yTop60)) + } + + // Bottom edge: right to left (clamped to terrain) + for sample in samples.reversed() { + path.addLine(to: coords.point(x: sample.x, y: sample.yVisibleBottom60)) + } + + path.closeSubpath() + context.fill(path, with: .color(fresnelInner)) + } + + private func drawObstructionOverlay( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + // Find contiguous obstructed regions and draw overlay + var inObstructedRegion = false + var regionStart = 0 + + for (index, sample) in samples.enumerated() { + if sample.isObstructed, !inObstructedRegion { + // Start of obstructed region + inObstructedRegion = true + regionStart = index + } else if !sample.isObstructed, inObstructedRegion { + // End of obstructed region + drawObstructedRegion( + context: context, + coords: coords, + samples: samples, + startIndex: regionStart, + endIndex: index - 1 + ) + inObstructedRegion = false + } } - private func drawObstructions(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Draw obstructions for primary segment - drawObstructionOverlay(context: context, coords: coords, samples: profileSamples) - - // Draw obstructions for secondary segment if present - if !profileSamplesRB.isEmpty { - drawObstructionOverlay(context: context, coords: coords, samples: profileSamplesRB) - } + // Handle region that extends to end + if inObstructedRegion { + drawObstructedRegion( + context: context, + coords: coords, + samples: samples, + startIndex: regionStart, + endIndex: samples.count - 1 + ) } + } - private func drawOuterFresnelFill( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - var path = Path() + private func drawObstructedRegion( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample], + startIndex: Int, + endIndex: Int + ) { + guard startIndex <= endIndex else { return } - // Top edge: left to right - if let first = samples.first { - path.move(to: coords.point(x: first.x, y: first.yTop)) - } - for sample in samples.dropFirst() { - path.addLine(to: coords.point(x: sample.x, y: sample.yTop)) - } + let regionSamples = Array(samples[startIndex...endIndex]) + guard let first = regionSamples.first, let last = regionSamples.last else { return } - // Bottom edge: right to left (clamped to terrain) - for sample in samples.reversed() { - path.addLine(to: coords.point(x: sample.x, y: sample.yVisibleBottom)) - } - - path.closeSubpath() - context.fill(path, with: .color(fresnelOuter)) + // Ensure a minimum pixel width so single-sample obstructions are visible + let minWidth: CGFloat = 4 + var leftX = coords.xPixel(first.x) + var rightX = coords.xPixel(last.x) + if rightX - leftX < minWidth { + let center = (leftX + rightX) / 2 + leftX = center - minWidth / 2 + rightX = center + minWidth / 2 } - private func drawInnerFresnelFill( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - var path = Path() + let topY = coords.yPixel(yRange.upperBound) + let bottomY = coords.yPixel(yRange.lowerBound) - // Top edge: left to right - if let first = samples.first { - path.move(to: coords.point(x: first.x, y: first.yTop60)) - } - for sample in samples.dropFirst() { - path.addLine(to: coords.point(x: sample.x, y: sample.yTop60)) - } + var path = Path() + path.move(to: CGPoint(x: leftX, y: topY)) + path.addLine(to: CGPoint(x: rightX, y: topY)) + path.addLine(to: CGPoint(x: rightX, y: bottomY)) + path.addLine(to: CGPoint(x: leftX, y: bottomY)) + path.closeSubpath() - // Bottom edge: right to left (clamped to terrain) - for sample in samples.reversed() { - path.addLine(to: coords.point(x: sample.x, y: sample.yVisibleBottom60)) - } + context.fill(path, with: .color(fresnelObstructed)) + } - path.closeSubpath() - context.fill(path, with: .color(fresnelInner)) - } - - private func drawObstructionOverlay( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - // Find contiguous obstructed regions and draw overlay - var inObstructedRegion = false - var regionStart = 0 - - for (index, sample) in samples.enumerated() { - if sample.isObstructed && !inObstructedRegion { - // Start of obstructed region - inObstructedRegion = true - regionStart = index - } else if !sample.isObstructed && inObstructedRegion { - // End of obstructed region - drawObstructedRegion( - context: context, - coords: coords, - samples: samples, - startIndex: regionStart, - endIndex: index - 1 - ) - inObstructedRegion = false - } - } + private func drawFresnelBoundary(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Draw boundary for primary segment + drawFresnelBoundaryForSamples(context: context, coords: coords, samples: profileSamples) - // Handle region that extends to end - if inObstructedRegion { - drawObstructedRegion( - context: context, - coords: coords, - samples: samples, - startIndex: regionStart, - endIndex: samples.count - 1 - ) - } + // Draw boundary for secondary segment if present + if !profileSamplesRB.isEmpty { + drawFresnelBoundaryForSamples(context: context, coords: coords, samples: profileSamplesRB) } + } - private func drawObstructedRegion( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample], - startIndex: Int, - endIndex: Int - ) { - guard startIndex <= endIndex else { return } - - let regionSamples = Array(samples[startIndex...endIndex]) - guard let first = regionSamples.first, let last = regionSamples.last else { return } - - // Ensure a minimum pixel width so single-sample obstructions are visible - let minWidth: CGFloat = 4 - var leftX = coords.xPixel(first.x) - var rightX = coords.xPixel(last.x) - if rightX - leftX < minWidth { - let center = (leftX + rightX) / 2 - leftX = center - minWidth / 2 - rightX = center + minWidth / 2 - } - - let topY = coords.yPixel(yRange.upperBound) - let bottomY = coords.yPixel(yRange.lowerBound) - - var path = Path() - path.move(to: CGPoint(x: leftX, y: topY)) - path.addLine(to: CGPoint(x: rightX, y: topY)) - path.addLine(to: CGPoint(x: rightX, y: bottomY)) - path.addLine(to: CGPoint(x: leftX, y: bottomY)) - path.closeSubpath() + private func drawFresnelBoundaryForSamples( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + guard samples.count >= 2 else { return } - context.fill(path, with: .color(fresnelObstructed)) + // Top boundary (theoretical ellipse top) + var topPath = Path() + if let first = samples.first { + topPath.move(to: coords.point(x: first.x, y: first.yTop)) } - - private func drawFresnelBoundary(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Draw boundary for primary segment - drawFresnelBoundaryForSamples(context: context, coords: coords, samples: profileSamples) - - // Draw boundary for secondary segment if present - if !profileSamplesRB.isEmpty { - drawFresnelBoundaryForSamples(context: context, coords: coords, samples: profileSamplesRB) - } + for sample in samples.dropFirst() { + topPath.addLine(to: coords.point(x: sample.x, y: sample.yTop)) } - private func drawFresnelBoundaryForSamples( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - guard samples.count >= 2 else { return } - - // Top boundary (theoretical ellipse top) - var topPath = Path() - if let first = samples.first { - topPath.move(to: coords.point(x: first.x, y: first.yTop)) - } - for sample in samples.dropFirst() { - topPath.addLine(to: coords.point(x: sample.x, y: sample.yTop)) - } - - // Bottom boundary (theoretical ellipse bottom) - var bottomPath = Path() - if let first = samples.first { - bottomPath.move(to: coords.point(x: first.x, y: first.yBottom)) - } - for sample in samples.dropFirst() { - bottomPath.addLine(to: coords.point(x: sample.x, y: sample.yBottom)) - } - - let style = StrokeStyle(lineWidth: 1, dash: [4, 3]) - context.stroke(topPath, with: .color(fresnelBoundary), style: style) - context.stroke(bottomPath, with: .color(fresnelBoundary), style: style) + // Bottom boundary (theoretical ellipse bottom) + var bottomPath = Path() + if let first = samples.first { + bottomPath.move(to: coords.point(x: first.x, y: first.yBottom)) } - - private func drawTerrain(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Combine samples for full terrain (avoid duplicate at junction) - let allSamples: [ProfileSample] - if profileSamplesRB.isEmpty { - allSamples = profileSamples - } else { - allSamples = profileSamples + profileSamplesRB.dropFirst() - } - - guard allSamples.count >= 2 else { return } - - // Build terrain fill path - var fillPath = Path() - - // Start at bottom-left - let bottomLeft = coords.point(x: xRange.lowerBound, y: yRange.lowerBound) - fillPath.move(to: bottomLeft) - - // Trace terrain line - for sample in allSamples { - fillPath.addLine(to: coords.point(x: sample.x, y: sample.yTerrain)) - } - - // Close at bottom-right and back - let bottomRight = coords.point(x: xRange.upperBound, y: yRange.lowerBound) - fillPath.addLine(to: bottomRight) - fillPath.closeSubpath() - - // Fill terrain - context.fill(fillPath, with: .color(terrainFill)) - - // Build terrain stroke path (just the top edge) - var strokePath = Path() - if let first = allSamples.first { - strokePath.move(to: coords.point(x: first.x, y: first.yTerrain)) - } - for sample in allSamples.dropFirst() { - strokePath.addLine(to: coords.point(x: sample.x, y: sample.yTerrain)) - } - - // Stroke terrain outline - context.stroke( - strokePath, - with: .color(terrainStroke), - style: StrokeStyle(lineWidth: 1.5) - ) + for sample in samples.dropFirst() { + bottomPath.addLine(to: coords.point(x: sample.x, y: sample.yBottom)) } - private func drawLOSLine(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Draw primary segment LOS line (A→B or A→R) - if let first = profileSamples.first, let last = profileSamples.last { - var path = Path() - path.move(to: coords.point(x: first.x, y: first.yLOS)) - path.addLine(to: coords.point(x: last.x, y: last.yLOS)) - context.stroke(path, with: .color(losLineColor), style: StrokeStyle(lineWidth: 2)) - } + let style = StrokeStyle(lineWidth: 1, dash: [4, 3]) + context.stroke(topPath, with: .color(fresnelBoundary), style: style) + context.stroke(bottomPath, with: .color(fresnelBoundary), style: style) + } - // Draw secondary segment LOS line (R→B) if present - if let first = profileSamplesRB.first, let last = profileSamplesRB.last { - var path = Path() - path.move(to: coords.point(x: first.x, y: first.yLOS)) - path.addLine(to: coords.point(x: last.x, y: last.yLOS)) - context.stroke(path, with: .color(losLineColor), style: StrokeStyle(lineWidth: 2)) - } + private func drawTerrain(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Combine samples for full terrain (avoid duplicate at junction) + let allSamples: [ProfileSample] = if profileSamplesRB.isEmpty { + profileSamples + } else { + profileSamples + profileSamplesRB.dropFirst() } - private func drawEndpointMarkers(context: GraphicsContext, coords: ChartCoordinateSpace) { - guard let sampleA = profileSamples.first else { return } - - // B is from R→B segment if present, otherwise from primary segment - let sampleB = profileSamplesRB.last ?? profileSamples.last - guard let sampleB else { return } - - let markerRadius: CGFloat = 6 - - // Point A marker (blue) - let pointA = coords.point(x: sampleA.x, y: sampleA.yLOS) - let circleA = Path(ellipseIn: CGRect( - x: pointA.x - markerRadius, - y: pointA.y - markerRadius, - width: markerRadius * 2, - height: markerRadius * 2 - )) - context.fill(circleA, with: .color(.blue)) - - // Point B marker (green) - let pointB = coords.point(x: sampleB.x, y: sampleB.yLOS) - let circleB = Path(ellipseIn: CGRect( - x: pointB.x - markerRadius, - y: pointB.y - markerRadius, - width: markerRadius * 2, - height: markerRadius * 2 - )) - context.fill(circleB, with: .color(.green)) - - // Labels inside markers (matching repeater style) - let labelA = Text("A") - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(.white) - context.draw(labelA, at: pointA, anchor: .center) - - let labelB = Text("B") - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(.white) - context.draw(labelB, at: pointB, anchor: .center) - } - - private func drawRepeaterMarker( - context: GraphicsContext, - coords: ChartCoordinateSpace, - pathFraction: Double, - repeaterElevation: Double, - height: Double, - nudgeOffset: CGFloat - ) { - // Get the LOS intersection point from profileSamples (junction of A→R and R→B) - // The repeater marker should be at the LOS line intersection, not ground level - guard let junctionSample = profileSamples.last else { return } - - let x = junctionSample.x - let losY = junctionSample.yLOS - let groundY = junctionSample.yTerrain - - // Draw vertical line from ground to LOS intersection (apply nudge offset) - let groundPoint = coords.point(x: x, y: groundY) - let losPoint = coords.point(x: x, y: losY) - let nudgedGroundPoint = CGPoint(x: groundPoint.x + nudgeOffset, y: groundPoint.y) - let nudgedLosPoint = CGPoint(x: losPoint.x + nudgeOffset, y: losPoint.y) - - var linePath = Path() - linePath.move(to: nudgedGroundPoint) - linePath.addLine(to: nudgedLosPoint) - context.stroke(linePath, with: .color(repeaterColor), lineWidth: 2) - - // Draw repeater marker circle at LOS intersection (16pt radius = 32pt diameter) - let markerRadius: CGFloat = 16 - let markerRect = CGRect( - x: nudgedLosPoint.x - markerRadius, - y: nudgedLosPoint.y - markerRadius, - width: markerRadius * 2, - height: markerRadius * 2 - ) - let markerPath = Circle().path(in: markerRect) + guard allSamples.count >= 2 else { return } - // Draw shadow first - context.drawLayer { shadowContext in - shadowContext.addFilter(.shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 2)) - shadowContext.fill(markerPath, with: .color(repeaterColor)) - } + // Build terrain fill path + var fillPath = Path() - // Draw the actual marker on top - context.fill(markerPath, with: .color(repeaterColor)) + // Start at bottom-left + let bottomLeft = coords.point(x: xRange.lowerBound, y: yRange.lowerBound) + fillPath.move(to: bottomLeft) - // Draw "R" label (larger font to match increased marker size) - let text = Text("R") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) - context.draw(text, at: nudgedLosPoint, anchor: .center) + // Trace terrain line + for sample in allSamples { + fillPath.addLine(to: coords.point(x: sample.x, y: sample.yTerrain)) } - private func drawJunctionSeparator( - context: GraphicsContext, - coords: ChartCoordinateSpace, - atDistance distance: Double - ) { - // Draw vertical dashed line at the junction point - let topPoint = coords.point(x: distance, y: yRange.upperBound) - let bottomPoint = coords.point(x: distance, y: yRange.lowerBound) + // Close at bottom-right and back + let bottomRight = coords.point(x: xRange.upperBound, y: yRange.lowerBound) + fillPath.addLine(to: bottomRight) + fillPath.closeSubpath() - var path = Path() - path.move(to: topPoint) - path.addLine(to: bottomPoint) + // Fill terrain + context.fill(fillPath, with: .color(terrainFill)) - let style = StrokeStyle(lineWidth: 1.5, dash: [6, 4]) - context.stroke(path, with: .color(.primary.opacity(0.35)), style: style) + // Build terrain stroke path (just the top edge) + var strokePath = Path() + if let first = allSamples.first { + strokePath.move(to: coords.point(x: first.x, y: first.yTerrain)) } -} - -#Preview("Canvas Profile") { - let sampleProfile: [ElevationSample] = (0...20).map { i in - let distance = Double(i) * 500 - let baseElevation = 100.0 - let hillFactor = sin(Double(i) / 20.0 * .pi) * 150 - - return ElevationSample( - coordinate: .init(latitude: 37.7749 + Double(i) * 0.001, longitude: -122.4194), - elevation: baseElevation + hillFactor, - distanceFromAMeters: distance - ) + for sample in allSamples.dropFirst() { + strokePath.addLine(to: coords.point(x: sample.x, y: sample.yTerrain)) } - let samples = FresnelZoneRenderer.buildProfileSamples( - from: sampleProfile, - pointAHeight: 10, - pointBHeight: 15, - frequencyMHz: 906, - refractionK: 4.0 / 3.0 + // Stroke terrain outline + context.stroke( + strokePath, + with: .color(terrainStroke), + style: StrokeStyle(lineWidth: 1.5) ) + } + + private func drawLOSLine(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Draw primary segment LOS line (A→B or A→R) + if let first = profileSamples.first, let last = profileSamples.last { + var path = Path() + path.move(to: coords.point(x: first.x, y: first.yLOS)) + path.addLine(to: coords.point(x: last.x, y: last.yLOS)) + context.stroke(path, with: .color(losLineColor), style: StrokeStyle(lineWidth: 2)) + } + + // Draw secondary segment LOS line (R→B) if present + if let first = profileSamplesRB.first, let last = profileSamplesRB.last { + var path = Path() + path.move(to: coords.point(x: first.x, y: first.yLOS)) + path.addLine(to: coords.point(x: last.x, y: last.yLOS)) + context.stroke(path, with: .color(losLineColor), style: StrokeStyle(lineWidth: 2)) + } + } + + private func drawEndpointMarkers(context: GraphicsContext, coords: ChartCoordinateSpace) { + guard let sampleA = profileSamples.first else { return } + + // B is from R→B segment if present, otherwise from primary segment + let sampleB = profileSamplesRB.last ?? profileSamples.last + guard let sampleB else { return } + + let markerRadius: CGFloat = 6 + + // Point A marker (blue) + let pointA = coords.point(x: sampleA.x, y: sampleA.yLOS) + let circleA = Path(ellipseIn: CGRect( + x: pointA.x - markerRadius, + y: pointA.y - markerRadius, + width: markerRadius * 2, + height: markerRadius * 2 + )) + context.fill(circleA, with: .color(.blue)) + + // Point B marker (green) + let pointB = coords.point(x: sampleB.x, y: sampleB.yLOS) + let circleB = Path(ellipseIn: CGRect( + x: pointB.x - markerRadius, + y: pointB.y - markerRadius, + width: markerRadius * 2, + height: markerRadius * 2 + )) + context.fill(circleB, with: .color(.green)) + + // Labels inside markers (matching repeater style) + let labelA = Text("A") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white) + context.draw(labelA, at: pointA, anchor: .center) + + let labelB = Text("B") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white) + context.draw(labelB, at: pointB, anchor: .center) + } + + private func drawRepeaterMarker( + context: GraphicsContext, + coords: ChartCoordinateSpace, + pathFraction: Double, + repeaterElevation: Double, + height: Double, + nudgeOffset: CGFloat + ) { + // Get the LOS intersection point from profileSamples (junction of A→R and R→B) + // The repeater marker should be at the LOS line intersection, not ground level + guard let junctionSample = profileSamples.last else { return } + + let x = junctionSample.x + let losY = junctionSample.yLOS + let groundY = junctionSample.yTerrain + + // Draw vertical line from ground to LOS intersection (apply nudge offset) + let groundPoint = coords.point(x: x, y: groundY) + let losPoint = coords.point(x: x, y: losY) + let nudgedGroundPoint = CGPoint(x: groundPoint.x + nudgeOffset, y: groundPoint.y) + let nudgedLosPoint = CGPoint(x: losPoint.x + nudgeOffset, y: losPoint.y) + + var linePath = Path() + linePath.move(to: nudgedGroundPoint) + linePath.addLine(to: nudgedLosPoint) + context.stroke(linePath, with: .color(repeaterColor), lineWidth: 2) + + // Draw repeater marker circle at LOS intersection (16pt radius = 32pt diameter) + let markerRadius: CGFloat = 16 + let markerRect = CGRect( + x: nudgedLosPoint.x - markerRadius, + y: nudgedLosPoint.y - markerRadius, + width: markerRadius * 2, + height: markerRadius * 2 + ) + let markerPath = Circle().path(in: markerRect) + + // Draw shadow first + context.drawLayer { shadowContext in + shadowContext.addFilter(.shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 2)) + shadowContext.fill(markerPath, with: .color(repeaterColor)) + } + + // Draw the actual marker on top + context.fill(markerPath, with: .color(repeaterColor)) + + // Draw "R" label (larger font to match increased marker size) + let text = Text("R") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + context.draw(text, at: nudgedLosPoint, anchor: .center) + } + + private func drawJunctionSeparator( + context: GraphicsContext, + coords: ChartCoordinateSpace, + atDistance distance: Double + ) { + // Draw vertical dashed line at the junction point + let topPoint = coords.point(x: distance, y: yRange.upperBound) + let bottomPoint = coords.point(x: distance, y: yRange.lowerBound) + + var path = Path() + path.move(to: topPoint) + path.addLine(to: bottomPoint) + + let style = StrokeStyle(lineWidth: 1.5, dash: [6, 4]) + context.stroke(path, with: .color(.primary.opacity(0.35)), style: style) + } +} - return TerrainProfileCanvas( - elevationProfile: sampleProfile, - profileSamples: samples +#Preview("Canvas Profile") { + let sampleProfile: [ElevationSample] = (0...20).map { i in + let distance = Double(i) * 500 + let baseElevation = 100.0 + let hillFactor = sin(Double(i) / 20.0 * .pi) * 150 + + return ElevationSample( + coordinate: .init(latitude: 37.7749 + Double(i) * 0.001, longitude: -122.4194), + elevation: baseElevation + hillFactor, + distanceFromAMeters: distance ) - .padding() + } + + let samples = FresnelZoneRenderer.buildProfileSamples( + from: sampleProfile, + pointAHeight: 10, + pointBHeight: 15, + frequencyMHz: 906, + refractionK: 4.0 / 3.0 + ) + + return TerrainProfileCanvas( + elevationProfile: sampleProfile, + profileSamples: samples + ) + .padding() } diff --git a/MC1/Views/Tools/LineOfSight/TerrainProfileSectionView.swift b/MC1/Views/Tools/LineOfSight/TerrainProfileSectionView.swift index 0866ccd6..cc26725d 100644 --- a/MC1/Views/Tools/LineOfSight/TerrainProfileSectionView.swift +++ b/MC1/Views/Tools/LineOfSight/TerrainProfileSectionView.swift @@ -1,58 +1,58 @@ import SwiftUI struct TerrainProfileSectionView: View { - var viewModel: LineOfSightViewModel - @Binding var showDragHint: Bool - @Binding var repeaterMarkerCenter: CGPoint? + var viewModel: LineOfSightViewModel + @Binding var showDragHint: Bool + @Binding var repeaterMarkerCenter: CGPoint? - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text(L10n.Tools.Tools.LineOfSight.terrainProfile) - .font(.headline) + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.Tools.Tools.LineOfSight.terrainProfile) + .font(.headline) - Spacer() + Spacer() - Label( - L10n.Tools.Tools.LineOfSight.earthCurvature(LOSFormatters.formatKFactor(viewModel.refractionK)), - systemImage: "globe" - ) - .font(.caption2) - .foregroundStyle(.secondary) - } + Label( + L10n.Tools.Tools.LineOfSight.earthCurvature(LOSFormatters.formatKFactor(viewModel.refractionK)), + systemImage: "globe" + ) + .font(.caption2) + .foregroundStyle(.secondary) + } - TerrainProfileCanvas( - elevationProfile: viewModel.terrainElevationProfile, - profileSamples: viewModel.profileSamples, - profileSamplesRB: viewModel.profileSamplesRB, - // Show repeater marker for both on-path and off-path - repeaterPathFraction: viewModel.repeaterVisualizationPathFraction, - repeaterHeight: viewModel.repeaterPoint.map { $0.additionalHeight }, - // Only enable drag for on-path repeaters - onRepeaterDrag: viewModel.repeaterPoint?.isOnPath == true ? { pathFraction in - viewModel.updateRepeaterPosition(pathFraction: pathFraction) - viewModel.analyzeWithRepeater() - } : nil, - onRepeaterMarkerPosition: { center in - repeaterMarkerCenter = center - }, - // Off-path segment distances for separator and labels - segmentARDistanceMeters: viewModel.segmentARDistanceMeters, - segmentRBDistanceMeters: viewModel.segmentRBDistanceMeters - ) - .overlay { - if showDragHint, let center = repeaterMarkerCenter { - Text(L10n.Tools.Tools.LineOfSight.dragToAdjust) - .font(.caption) - .foregroundStyle(.primary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.regularMaterial, in: .capsule) - .shadow(color: .black.opacity(0.15), radius: 4, y: 2) - .transition(.opacity.combined(with: .scale)) - .position(x: center.x, y: center.y + 30) - } - } + TerrainProfileCanvas( + elevationProfile: viewModel.terrainElevationProfile, + profileSamples: viewModel.profileSamples, + profileSamplesRB: viewModel.profileSamplesRB, + // Show repeater marker for both on-path and off-path + repeaterPathFraction: viewModel.repeaterVisualizationPathFraction, + repeaterHeight: viewModel.repeaterPoint.map(\.additionalHeight), + // Only enable drag for on-path repeaters + onRepeaterDrag: viewModel.repeaterPoint?.isOnPath == true ? { pathFraction in + viewModel.updateRepeaterPosition(pathFraction: pathFraction) + viewModel.analyzeWithRepeater() + } : nil, + onRepeaterMarkerPosition: { center in + repeaterMarkerCenter = center + }, + // Off-path segment distances for separator and labels + segmentARDistanceMeters: viewModel.segmentARDistanceMeters, + segmentRBDistanceMeters: viewModel.segmentRBDistanceMeters + ) + .overlay { + if showDragHint, let center = repeaterMarkerCenter { + Text(L10n.Tools.Tools.LineOfSight.dragToAdjust) + .font(.caption) + .foregroundStyle(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.regularMaterial, in: .capsule) + .shadow(color: .black.opacity(0.15), radius: 4, y: 2) + .transition(.opacity.combined(with: .scale)) + .position(x: center.x, y: center.y + 30) } + } } + } } diff --git a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryRowView.swift b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryRowView.swift index 35afb0e2..d71317a6 100644 --- a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryRowView.swift +++ b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryRowView.swift @@ -2,97 +2,97 @@ import MC1Services import SwiftUI struct NodeDiscoveryRowView: View { - let result: NodeDiscoveryResult - var isAdded = false - var isAdding = false - var onAdd: (() -> Void)? - - var body: some View { - HStack { - avatarView - .padding(.trailing, 4) + let result: NodeDiscoveryResult + var isAdded = false + var isAdding = false + var onAdd: (() -> Void)? - VStack(alignment: .leading, spacing: 2) { - Text(result.name) - .font(.body) - .bold() - .lineLimit(1) + var body: some View { + HStack { + avatarView + .padding(.trailing, 4) - hexLine + VStack(alignment: .leading, spacing: 2) { + Text(result.name) + .font(.body) + .bold() + .lineLimit(1) - MetricsLine(result: result) - } + hexLine - if let onAdd { - Spacer() - if isAdded { - Button(L10n.Contacts.Contacts.Discovery.added) {} - .buttonStyle(.bordered) - .disabled(true) - .accessibilityLabel(L10n.Contacts.Contacts.Discovery.addedAccessibility) - } else { - Button(L10n.Contacts.Contacts.Discovery.add) { - onAdd() - } - .buttonStyle(.borderedProminent) - .disabled(isAdding) - } - } - } - .padding(.vertical, 4) - } + MetricsLine(result: result) + } - @ViewBuilder - private var avatarView: some View { - if result.scanFilter == .repeaters { - NodeAvatar(publicKey: result.publicKey, role: .repeater, size: 40) + if let onAdd { + Spacer() + if isAdded { + Button(L10n.Contacts.Contacts.Discovery.added) {} + .buttonStyle(.bordered) + .disabled(true) + .accessibilityLabel(L10n.Contacts.Contacts.Discovery.addedAccessibility) } else { - ZStack { - Circle().fill(.gray.opacity(0.3)) - Image(systemName: "sensor") - .font(.body.weight(.semibold)) - .foregroundStyle(.secondary) - } - .frame(width: 40, height: 40) + Button(L10n.Contacts.Contacts.Discovery.add) { + onAdd() + } + .buttonStyle(.borderedProminent) + .disabled(isAdding) } + } } + .padding(.vertical, 4) + } - private var hexLine: some View { - let hex = result.publicKey.map { String(format: "%02X", $0) }.joined() - return Text(hex) - .font(.caption) - .monospaced() - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.middle) + @ViewBuilder + private var avatarView: some View { + if result.scanFilter == .repeaters { + NodeAvatar(publicKey: result.publicKey, role: .repeater, size: 40) + } else { + ZStack { + Circle().fill(.gray.opacity(0.3)) + Image(systemName: "sensor") + .font(.body.weight(.semibold)) + .foregroundStyle(.secondary) + } + .frame(width: 40, height: 40) } + } + + private var hexLine: some View { + let hex = result.publicKey.map { String(format: "%02X", $0) }.joined() + return Text(hex) + .font(.caption) + .monospaced() + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } } // MARK: - Metrics Line extension NodeDiscoveryRowView { - private struct MetricsLine: View { - let result: NodeDiscoveryResult + private struct MetricsLine: View { + let result: NodeDiscoveryResult - var body: some View { - let nodeTypeLabel = result.scanFilter == .repeaters - ? L10n.Tools.Tools.NodeDiscovery.typeRepeater - : L10n.Tools.Tools.NodeDiscovery.typeSensor + var body: some View { + let nodeTypeLabel = result.scanFilter == .repeaters + ? L10n.Tools.Tools.NodeDiscovery.typeRepeater + : L10n.Tools.Tools.NodeDiscovery.typeSensor - let snrDown = L10n.Tools.Tools.NodeDiscovery.snrDown( - result.snr.formatted(.number.precision(.fractionLength(1))) - ) - let snrUp = L10n.Tools.Tools.NodeDiscovery.snrUp( - result.snrIn.formatted(.number.precision(.fractionLength(1))) - ) - let rssiText = L10n.Tools.Tools.NodeDiscovery.rssi( - result.rssi.formatted() - ) + let snrDown = L10n.Tools.Tools.NodeDiscovery.snrDown( + result.snr.formatted(.number.precision(.fractionLength(1))) + ) + let snrUp = L10n.Tools.Tools.NodeDiscovery.snrUp( + result.snrIn.formatted(.number.precision(.fractionLength(1))) + ) + let rssiText = L10n.Tools.Tools.NodeDiscovery.rssi( + result.rssi.formatted() + ) - Text("\(nodeTypeLabel) · \(snrDown) \(snrUp) · \(rssiText)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + Text("\(nodeTypeLabel) · \(snrDown) \(snrUp) · \(rssiText)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } + } } diff --git a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryView.swift b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryView.swift index 9e5fe8e8..b54cf823 100644 --- a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryView.swift +++ b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryView.swift @@ -2,211 +2,211 @@ import MC1Services import SwiftUI struct NodeDiscoveryView: View { - @Environment(\.appState) private var appState - @State private var viewModel = NodeDiscoveryViewModel() - - private var isConnected: Bool { - appState.services?.session != nil + @Environment(\.appState) private var appState + @State private var viewModel = NodeDiscoveryViewModel() + + private var isConnected: Bool { + appState.services?.session != nil + } + + var body: some View { + Group { + if !isConnected { + disconnectedState + } else if !viewModel.isScanning, viewModel.sortedResults.isEmpty, viewModel.errorMessage == nil { + initialState + } else { + ResultsList(viewModel: viewModel) + } } - - var body: some View { - Group { - if !isConnected { - disconnectedState - } else if !viewModel.isScanning && viewModel.sortedResults.isEmpty && viewModel.errorMessage == nil { - initialState - } else { - ResultsList(viewModel: viewModel) - } - } - .safeAreaInset(edge: .bottom) { - if isConnected { - ScanButtonBar(viewModel: viewModel) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - SortMenu(viewModel: viewModel) - } - } - .errorAlert($viewModel.errorMessage, title: L10n.Tools.Tools.NodeDiscovery.errorTitle) - .sensoryFeedback(.impact(weight: .medium), trigger: viewModel.scanStartHapticTrigger) - .sensoryFeedback(.success, trigger: viewModel.scanSuccessHapticTrigger) - .sensoryFeedback(.warning, trigger: viewModel.scanEmptyHapticTrigger) - .sensoryFeedback(.success, trigger: viewModel.addSuccessHapticTrigger) - .sensoryFeedback(.error, trigger: viewModel.addErrorHapticTrigger) - .task(id: appState.servicesVersion) { - viewModel.configure(dependencies: NodeDiscoveryViewModel.Dependencies( - session: { [appState] in appState.services?.session }, - dataStore: { [appState] in appState.offlineDataStore }, - radioID: { [appState] in appState.connectedDevice?.radioID }, - contactService: { [appState] in appState.services?.contactService }, - maxContacts: { [appState] in appState.connectedDevice?.maxContacts } - )) - } - .onDisappear { - viewModel.stopScan() - } - .onChange(of: viewModel.filter) { _, _ in - viewModel.stopScan() - } + .safeAreaInset(edge: .bottom) { + if isConnected { + ScanButtonBar(viewModel: viewModel) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + SortMenu(viewModel: viewModel) + } + } + .errorAlert($viewModel.errorMessage, title: L10n.Tools.Tools.NodeDiscovery.errorTitle) + .sensoryFeedback(.impact(weight: .medium), trigger: viewModel.scanStartHapticTrigger) + .sensoryFeedback(.success, trigger: viewModel.scanSuccessHapticTrigger) + .sensoryFeedback(.warning, trigger: viewModel.scanEmptyHapticTrigger) + .sensoryFeedback(.success, trigger: viewModel.addSuccessHapticTrigger) + .sensoryFeedback(.error, trigger: viewModel.addErrorHapticTrigger) + .task(id: appState.servicesVersion) { + viewModel.configure(dependencies: NodeDiscoveryViewModel.Dependencies( + session: { [appState] in appState.services?.session }, + dataStore: { [appState] in appState.offlineDataStore }, + radioID: { [appState] in appState.connectedDevice?.radioID }, + contactService: { [appState] in appState.services?.contactService }, + maxContacts: { [appState] in appState.connectedDevice?.maxContacts } + )) + } + .onDisappear { + viewModel.stopScan() + } + .onChange(of: viewModel.filter) { _, _ in + viewModel.stopScan() } + } } // MARK: - States extension NodeDiscoveryView { - private var disconnectedState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Tools.Tools.NodeDiscovery.notConnectedDescription(viewModel.filter.localizedTitle)) - } + private var disconnectedState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Tools.Tools.NodeDiscovery.notConnectedDescription(viewModel.filter.localizedTitle)) } + } - private var initialState: some View { - VStack { - NodeDiscoverySegmentPicker(selection: $viewModel.filter, isScanning: viewModel.isScanning) + private var initialState: some View { + VStack { + NodeDiscoverySegmentPicker(selection: $viewModel.filter, isScanning: viewModel.isScanning) - Spacer() + Spacer() - ContentUnavailableView { - Label(L10n.Tools.Tools.NodeDiscovery.scanPrompt(viewModel.filter.localizedTitle), systemImage: "magnifyingglass") - } description: { - Text(L10n.Tools.Tools.NodeDiscovery.scanPromptDescription(viewModel.filter.localizedTitle)) - } + ContentUnavailableView { + Label(L10n.Tools.Tools.NodeDiscovery.scanPrompt(viewModel.filter.localizedTitle), systemImage: "magnifyingglass") + } description: { + Text(L10n.Tools.Tools.NodeDiscovery.scanPromptDescription(viewModel.filter.localizedTitle)) + } - Spacer() - } + Spacer() } + } } // MARK: - Results List extension NodeDiscoveryView { - private struct ResultsList: View { - @Bindable var viewModel: NodeDiscoveryViewModel - @Environment(\.appTheme) private var theme - - var body: some View { - List { - Section { - NodeDiscoverySegmentPicker(selection: $viewModel.filter, isScanning: viewModel.isScanning) - } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listSectionSeparator(.hidden) - - if viewModel.sortedResults.isEmpty && !viewModel.isScanning { - ContentUnavailableView { - Label(L10n.Tools.Tools.NodeDiscovery.noResults(viewModel.filter.localizedTitle), systemImage: "magnifyingglass") - } description: { - Text(L10n.Tools.Tools.NodeDiscovery.noResultsDescription(viewModel.filter.localizedTitle)) - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } else { - ForEach(viewModel.sortedResults) { result in - NodeDiscoveryRowView( - result: result, - isAdded: viewModel.isAdded(publicKey: result.publicKey), - isAdding: viewModel.addingPublicKey == result.publicKey, - onAdd: ContactType(rawValue: result.nodeType) != nil - ? { viewModel.addNode(result) } - : nil - ) - } - } - } - .listStyle(.plain) - .themedCanvas(theme) + private struct ResultsList: View { + @Bindable var viewModel: NodeDiscoveryViewModel + @Environment(\.appTheme) private var theme + + var body: some View { + List { + Section { + NodeDiscoverySegmentPicker(selection: $viewModel.filter, isScanning: viewModel.isScanning) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listSectionSeparator(.hidden) + + if viewModel.sortedResults.isEmpty, !viewModel.isScanning { + ContentUnavailableView { + Label(L10n.Tools.Tools.NodeDiscovery.noResults(viewModel.filter.localizedTitle), systemImage: "magnifyingglass") + } description: { + Text(L10n.Tools.Tools.NodeDiscovery.noResultsDescription(viewModel.filter.localizedTitle)) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else { + ForEach(viewModel.sortedResults) { result in + NodeDiscoveryRowView( + result: result, + isAdded: viewModel.isAdded(publicKey: result.publicKey), + isAdding: viewModel.addingPublicKey == result.publicKey, + onAdd: ContactType(rawValue: result.nodeType) != nil + ? { viewModel.addNode(result) } + : nil + ) + } } + } + .listStyle(.plain) + .themedCanvas(theme) } + } } // MARK: - Scan Button extension NodeDiscoveryView { - private struct ScanButtonBar: View { - let viewModel: NodeDiscoveryViewModel - - var body: some View { - LiquidGlassContainer { - Button { - if viewModel.isScanning { - viewModel.stopScan() - } else { - viewModel.scan() - } - } label: { - if viewModel.isScanning { - HStack { - ProgressView().controlSize(.small) - Text(L10n.Tools.Tools.NodeDiscovery.stopButton) - } - } else { - Text(L10n.Tools.Tools.NodeDiscovery.scanButton(viewModel.filter.localizedTitle)) - } - } - .liquidGlassProminentButtonStyle() + private struct ScanButtonBar: View { + let viewModel: NodeDiscoveryViewModel + + var body: some View { + LiquidGlassContainer { + Button { + if viewModel.isScanning { + viewModel.stopScan() + } else { + viewModel.scan() + } + } label: { + if viewModel.isScanning { + HStack { + ProgressView().controlSize(.small) + Text(L10n.Tools.Tools.NodeDiscovery.stopButton) } - .padding(.bottom, 24) + } else { + Text(L10n.Tools.Tools.NodeDiscovery.scanButton(viewModel.filter.localizedTitle)) + } } + .liquidGlassProminentButtonStyle() + } + .padding(.bottom, 24) } + } } // MARK: - Sort Menu extension NodeDiscoveryView { - private struct SortMenu: View { - let viewModel: NodeDiscoveryViewModel - - var body: some View { - Menu { - ForEach(NodeDiscoverySortOrder.allCases, id: \.self) { order in - Button { - viewModel.sortOrder = order - } label: { - if viewModel.sortOrder == order { - Label(order.localizedTitle, systemImage: "checkmark") - } else { - Text(order.localizedTitle) - } - } - } - } label: { - Label(L10n.Tools.Tools.NodeDiscovery.sortMenu, systemImage: "arrow.up.arrow.down") + private struct SortMenu: View { + let viewModel: NodeDiscoveryViewModel + + var body: some View { + Menu { + ForEach(NodeDiscoverySortOrder.allCases, id: \.self) { order in + Button { + viewModel.sortOrder = order + } label: { + if viewModel.sortOrder == order { + Label(order.localizedTitle, systemImage: "checkmark") + } else { + Text(order.localizedTitle) } - .liquidGlassButtonStyle() - .accessibilityLabel(L10n.Tools.Tools.NodeDiscovery.sortMenu) - .accessibilityHint(L10n.Tools.Tools.NodeDiscovery.sortMenuHint) + } } + } label: { + Label(L10n.Tools.Tools.NodeDiscovery.sortMenu, systemImage: "arrow.up.arrow.down") + } + .liquidGlassButtonStyle() + .accessibilityLabel(L10n.Tools.Tools.NodeDiscovery.sortMenu) + .accessibilityHint(L10n.Tools.Tools.NodeDiscovery.sortMenuHint) } + } } // MARK: - Segment Picker struct NodeDiscoverySegmentPicker: View { - @Binding var selection: NodeDiscoveryFilter - let isScanning: Bool - - var body: some View { - Picker(L10n.Tools.Tools.NodeDiscovery.repeaters, selection: $selection) { - ForEach(NodeDiscoveryFilter.allCases, id: \.self) { filter in - Text(filter.localizedTitle).tag(filter) - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - .padding(.vertical, 8) - .opacity(isScanning ? 0.5 : 1.0) - .disabled(isScanning) + @Binding var selection: NodeDiscoveryFilter + let isScanning: Bool + + var body: some View { + Picker(L10n.Tools.Tools.NodeDiscovery.repeaters, selection: $selection) { + ForEach(NodeDiscoveryFilter.allCases, id: \.self) { filter in + Text(filter.localizedTitle).tag(filter) + } } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + .opacity(isScanning ? 0.5 : 1.0) + .disabled(isScanning) + } } #Preview { - NavigationStack { - NodeDiscoveryView() - } - .environment(\.appState, AppState()) + NavigationStack { + NodeDiscoveryView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryViewModel.swift b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryViewModel.swift index cd90c4d7..ea0928a4 100644 --- a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryViewModel.swift +++ b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryViewModel.swift @@ -1,53 +1,53 @@ +import MC1Services import MeshCore import OSLog -import MC1Services import SwiftUI // MARK: - Filter & Sort enum NodeDiscoveryFilter: String, CaseIterable { - case repeaters - case sensors + case repeaters + case sensors - var filterValue: UInt8 { - switch self { - case .repeaters: 0x04 - case .sensors: 0x10 - } + var filterValue: UInt8 { + switch self { + case .repeaters: 0x04 + case .sensors: 0x10 } + } - var localizedTitle: String { - switch self { - case .repeaters: L10n.Tools.Tools.NodeDiscovery.repeaters - case .sensors: L10n.Tools.Tools.NodeDiscovery.sensors - } + var localizedTitle: String { + switch self { + case .repeaters: L10n.Tools.Tools.NodeDiscovery.repeaters + case .sensors: L10n.Tools.Tools.NodeDiscovery.sensors } + } } enum NodeDiscoverySortOrder: String, CaseIterable { - case snr - case name + case snr + case name - var localizedTitle: String { - switch self { - case .snr: L10n.Tools.Tools.NodeDiscovery.sortSignal - case .name: L10n.Tools.Tools.NodeDiscovery.sortName - } + var localizedTitle: String { + switch self { + case .snr: L10n.Tools.Tools.NodeDiscovery.sortSignal + case .name: L10n.Tools.Tools.NodeDiscovery.sortName } + } } // MARK: - Result -struct NodeDiscoveryResult: Identifiable, Sendable { - let id = UUID() - let name: String - let publicKey: Data - let nodeType: UInt8 - let snr: Double - let snrIn: Double - let rssi: Int - let scanFilter: NodeDiscoveryFilter - let receivedAt: Date +struct NodeDiscoveryResult: Identifiable { + let id = UUID() + let name: String + let publicKey: Data + let nodeType: UInt8 + let snr: Double + let snrIn: Double + let rssi: Int + let scanFilter: NodeDiscoveryFilter + let receivedAt: Date } // MARK: - View Model @@ -55,240 +55,254 @@ struct NodeDiscoveryResult: Identifiable, Sendable { @Observable @MainActor final class NodeDiscoveryViewModel { - private static let logger = Logger(subsystem: "com.mc1", category: "NodeDiscoveryViewModel") - private static let scanDuration: Duration = .seconds(15) - - // MARK: - Published state - - var results: [NodeDiscoveryResult] = [] - var isScanning = false - var errorMessage: String? - var filter: NodeDiscoveryFilter = .repeaters - var sortOrder: NodeDiscoverySortOrder = .snr - - var scanStartHapticTrigger = 0 - var scanSuccessHapticTrigger = 0 - var scanEmptyHapticTrigger = 0 - - var addedPublicKeys: Set = [] - var addingPublicKey: Data? - var addSuccessHapticTrigger = 0 - var addErrorHapticTrigger = 0 - - // MARK: - Dependencies - - struct Dependencies { - var session: @MainActor () -> MeshCoreSession? - var dataStore: @MainActor () -> PersistenceStore? - var radioID: @MainActor () -> UUID? - var contactService: @MainActor () -> ContactService? - var maxContacts: @MainActor () -> UInt16? - } + private static let logger = Logger(subsystem: "com.mc1", category: "NodeDiscoveryViewModel") + private static let scanDuration: Duration = .seconds(15) - private var deps = Dependencies( - session: { nil }, - dataStore: { nil }, - radioID: { nil }, - contactService: { nil }, - maxContacts: { nil } - ) + // MARK: - Published state - private var session: MeshCoreSession? { deps.session() } - private var dataStore: PersistenceStore? { deps.dataStore() } - private var radioID: UUID? { deps.radioID() } - private var contactService: ContactService? { deps.contactService() } - private var maxContacts: UInt16? { deps.maxContacts() } + var results: [NodeDiscoveryResult] = [] + var isScanning = false + var errorMessage: String? + var filter: NodeDiscoveryFilter = .repeaters + var sortOrder: NodeDiscoverySortOrder = .snr - // MARK: - Tasks + var scanStartHapticTrigger = 0 + var scanSuccessHapticTrigger = 0 + var scanEmptyHapticTrigger = 0 - private var scanTask: Task? - private var timeoutTask: Task? + var addedPublicKeys: Set = [] + var addingPublicKey: Data? + var addSuccessHapticTrigger = 0 + var addErrorHapticTrigger = 0 - // MARK: - Name resolution cache + // MARK: - Dependencies - private var namesByKey: [Data: String] = [:] + struct Dependencies { + var session: @MainActor () -> MeshCoreSession? + var dataStore: @MainActor () -> PersistenceStore? + var radioID: @MainActor () -> UUID? + var contactService: @MainActor () -> ContactService? + var maxContacts: @MainActor () -> UInt16? + } - // MARK: - Configuration + private var deps = Dependencies( + session: { nil }, + dataStore: { nil }, + radioID: { nil }, + contactService: { nil }, + maxContacts: { nil } + ) - /// Configure with the session, store, and services this view model uses; a provider returning nil mirrors a disconnected state. - func configure(dependencies: Dependencies) { - deps = dependencies - } + private var session: MeshCoreSession? { + deps.session() + } - // MARK: - Scan + private var dataStore: PersistenceStore? { + deps.dataStore() + } - func scan() { - guard let session else { - errorMessage = L10n.Tools.Tools.NodeDiscovery.notConnectedDescription(filter.localizedTitle) - return - } + private var radioID: UUID? { + deps.radioID() + } - guard let radioID else { return } - - stopScan() - results.removeAll { $0.scanFilter == filter } - errorMessage = nil - isScanning = true - scanStartHapticTrigger += 1 - - scanTask = Task { [weak self] in - guard let self else { return } - - do { - // Pre-load name resolution data and existing contact keys - await self.loadNameResolutionData(radioID: radioID) - - // Send discovery request - let tag = try await session.sendNodeDiscoverRequest( - filter: self.filter.filterValue, - prefixOnly: false - ) - let tagData = withUnsafeBytes(of: tag.littleEndian) { Data($0) } - - // Start timeout that cancels the scan task - self.timeoutTask = Task { [weak self] in - try? await Task.sleep(for: Self.scanDuration) - self?.scanTask?.cancel() - } - - // Listen for responses - let events = await session.events() - for await event in events { - guard !Task.isCancelled else { break } - - if case .discoverResponse(let response) = event, - response.tag == tagData { - self.appendOrUpdateResult(from: response) - } - } - } catch is CancellationError { - // Normal timeout cancellation — not an error - } catch { - Self.logger.error("Node discovery failed: \(error.localizedDescription)") - self.errorMessage = error.userFacingMessage - } - - self.finishScan() - } - } + private var contactService: ContactService? { + deps.contactService() + } - func stopScan() { - timeoutTask?.cancel() - timeoutTask = nil - scanTask?.cancel() - scanTask = nil - if isScanning { - finishScan() - } - } + private var maxContacts: UInt16? { + deps.maxContacts() + } - // MARK: - Sorted results + // MARK: - Tasks - var sortedResults: [NodeDiscoveryResult] { - let filtered = results.filter { $0.scanFilter == filter } - return switch sortOrder { - case .snr: - filtered.sorted { $0.snr > $1.snr } - case .name: - filtered.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - } - } + private var scanTask: Task? + private var timeoutTask: Task? - // MARK: - Private - - private func loadNameResolutionData(radioID: UUID) async { - guard let dataStore else { return } - do { - // Load discovered nodes first, then contacts — contacts take priority - let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) - namesByKey = Dictionary( - nodes.map { ($0.publicKey, $0.name) }, - uniquingKeysWith: { first, _ in first } - ) - let contacts = try await dataStore.fetchContacts(radioID: radioID) - for contact in contacts { - namesByKey[contact.publicKey] = contact.name - } - addedPublicKeys = Set(contacts.map(\.publicKey)) - } catch { - Self.logger.error("Failed to load name resolution data: \(error.localizedDescription)") - } - } + // MARK: - Name resolution cache - private func resolveName(for publicKey: Data) -> String { - if let name = namesByKey[publicKey] { - return name - } - let hexPrefix = publicKey.prefix(4).map { String(format: "%02X", $0) }.joined() - return "\(L10n.Tools.Tools.NodeDiscovery.unknownNode) (\(hexPrefix))" + private var namesByKey: [Data: String] = [:] + + // MARK: - Configuration + + /// Configure with the session, store, and services this view model uses; a provider returning nil mirrors a disconnected state. + func configure(dependencies: Dependencies) { + deps = dependencies + } + + // MARK: - Scan + + func scan() { + guard let session else { + errorMessage = L10n.Tools.Tools.NodeDiscovery.notConnectedDescription(filter.localizedTitle) + return } - private func appendOrUpdateResult(from response: DiscoverResponse) { - let result = NodeDiscoveryResult( - name: resolveName(for: response.publicKey), - publicKey: response.publicKey, - nodeType: response.nodeType, - snr: response.snr, - snrIn: response.snrIn, - rssi: response.rssi, - scanFilter: filter, - receivedAt: Date() + guard let radioID else { return } + + stopScan() + results.removeAll { $0.scanFilter == filter } + errorMessage = nil + isScanning = true + scanStartHapticTrigger += 1 + + scanTask = Task { [weak self] in + guard let self else { return } + + do { + // Pre-load name resolution data and existing contact keys + await loadNameResolutionData(radioID: radioID) + + // Send discovery request + let tag = try await session.sendNodeDiscoverRequest( + filter: filter.filterValue, + prefixOnly: false ) - if let existingIndex = results.firstIndex(where: { $0.publicKey == response.publicKey && $0.scanFilter == filter }) { - results[existingIndex] = result - } else { - results.append(result) + let tagData = withUnsafeBytes(of: tag.littleEndian) { Data($0) } + + // Start timeout that cancels the scan task + timeoutTask = Task { [weak self] in + try? await Task.sleep(for: Self.scanDuration) + self?.scanTask?.cancel() } - } - private func finishScan() { - isScanning = false - if results.contains(where: { $0.scanFilter == filter }) { - scanSuccessHapticTrigger += 1 - } else { - scanEmptyHapticTrigger += 1 + // Listen for responses + let events = await session.events() + for await event in events { + guard !Task.isCancelled else { break } + + if case let .discoverResponse(response) = event, + response.tag == tagData { + appendOrUpdateResult(from: response) + } } + } catch is CancellationError { + // Normal timeout cancellation — not an error + } catch { + Self.logger.error("Node discovery failed: \(error.localizedDescription)") + errorMessage = error.userFacingMessage + } + + finishScan() + } + } + + func stopScan() { + timeoutTask?.cancel() + timeoutTask = nil + scanTask?.cancel() + scanTask = nil + if isScanning { + finishScan() } + } - // MARK: - Add Node + // MARK: - Sorted results - func isAdded(publicKey: Data) -> Bool { - addedPublicKeys.contains(publicKey) + var sortedResults: [NodeDiscoveryResult] { + let filtered = results.filter { $0.scanFilter == filter } + return switch sortOrder { + case .snr: + filtered.sorted { $0.snr > $1.snr } + case .name: + filtered.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + } + + // MARK: - Private + + private func loadNameResolutionData(radioID: UUID) async { + guard let dataStore else { return } + do { + // Load discovered nodes first, then contacts — contacts take priority + let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) + namesByKey = Dictionary( + nodes.map { ($0.publicKey, $0.name) }, + uniquingKeysWith: { first, _ in first } + ) + let contacts = try await dataStore.fetchContacts(radioID: radioID) + for contact in contacts { + namesByKey[contact.publicKey] = contact.name + } + addedPublicKeys = Set(contacts.map(\.publicKey)) + } catch { + Self.logger.error("Failed to load name resolution data: \(error.localizedDescription)") } + } - func addNode(_ result: NodeDiscoveryResult) { - guard let contactService, let radioID else { return } - - addingPublicKey = result.publicKey - Task { [weak self] in - do { - let contact = ContactFrame( - publicKey: result.publicKey, - type: ContactType(rawValue: result.nodeType) ?? .repeater, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: result.name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - try await contactService.addOrUpdateContact(radioID: radioID, contact: contact) - self?.addedPublicKeys.insert(result.publicKey) - self?.addSuccessHapticTrigger += 1 - } catch ContactServiceError.contactTableFull { - if let maxContacts = self?.maxContacts { - self?.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) - } else { - self?.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFullSimple - } - self?.addErrorHapticTrigger += 1 - } catch { - self?.errorMessage = error.userFacingMessage - self?.addErrorHapticTrigger += 1 - } - self?.addingPublicKey = nil + private func resolveName(for publicKey: Data) -> String { + if let name = namesByKey[publicKey] { + return name + } + let hexPrefix = publicKey.prefix(4).map { String(format: "%02X", $0) }.joined() + return "\(L10n.Tools.Tools.NodeDiscovery.unknownNode) (\(hexPrefix))" + } + + private func appendOrUpdateResult(from response: DiscoverResponse) { + let result = NodeDiscoveryResult( + name: resolveName(for: response.publicKey), + publicKey: response.publicKey, + nodeType: response.nodeType, + snr: response.snr, + snrIn: response.snrIn, + rssi: response.rssi, + scanFilter: filter, + receivedAt: Date() + ) + if let existingIndex = results.firstIndex(where: { $0.publicKey == response.publicKey && $0.scanFilter == filter }) { + results[existingIndex] = result + } else { + results.append(result) + } + } + + private func finishScan() { + isScanning = false + if results.contains(where: { $0.scanFilter == filter }) { + scanSuccessHapticTrigger += 1 + } else { + scanEmptyHapticTrigger += 1 + } + } + + // MARK: - Add Node + + func isAdded(publicKey: Data) -> Bool { + addedPublicKeys.contains(publicKey) + } + + func addNode(_ result: NodeDiscoveryResult) { + guard let contactService, let radioID else { return } + + addingPublicKey = result.publicKey + Task { [weak self] in + do { + let contact = ContactFrame( + publicKey: result.publicKey, + type: ContactType(rawValue: result.nodeType) ?? .repeater, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: result.name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + try await contactService.addOrUpdateContact(radioID: radioID, contact: contact) + self?.addedPublicKeys.insert(result.publicKey) + self?.addSuccessHapticTrigger += 1 + } catch ContactServiceError.contactTableFull { + if let maxContacts = self?.maxContacts { + self?.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) + } else { + self?.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFullSimple } + self?.addErrorHapticTrigger += 1 + } catch { + self?.errorMessage = error.userFacingMessage + self?.addErrorHapticTrigger += 1 + } + self?.addingPublicKey = nil } + } } diff --git a/MC1/Views/Tools/NoiseFloorView.swift b/MC1/Views/Tools/NoiseFloorView.swift index 18fdbb8d..dc78ace6 100644 --- a/MC1/Views/Tools/NoiseFloorView.swift +++ b/MC1/Views/Tools/NoiseFloorView.swift @@ -1,319 +1,319 @@ -import SwiftUI import Charts +import SwiftUI struct NoiseFloorView: View { - @Environment(\.appState) private var appState - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var viewModel = NoiseFloorViewModel() - @State private var chartStartTime = Date() - - private var isConnected: Bool { - appState.services?.session != nil + @Environment(\.appState) private var appState + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var viewModel = NoiseFloorViewModel() + @State private var chartStartTime = Date() + + private var isConnected: Bool { + appState.services?.session != nil + } + + var body: some View { + Group { + if !isConnected { + disconnectedState + } else if viewModel.readings.isEmpty { + collectingState + } else { + mainContent + } } - - var body: some View { - Group { - if !isConnected { - disconnectedState - } else if viewModel.readings.isEmpty { - collectingState - } else { - mainContent - } - } - .task(id: appState.servicesVersion) { - chartStartTime = Date() - viewModel.startPolling { appState.services?.session } - } - .onDisappear { - viewModel.stopPolling() - } + .task(id: appState.servicesVersion) { + chartStartTime = Date() + viewModel.startPolling { appState.services?.session } + } + .onDisappear { + viewModel.stopPolling() } + } } // MARK: - Empty States extension NoiseFloorView { - private var disconnectedState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Tools.Tools.NoiseFloor.notConnectedDescription) - } + private var disconnectedState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Tools.Tools.NoiseFloor.notConnectedDescription) } + } - private var collectingState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.NoiseFloor.collectingData, systemImage: "antenna.radiowaves.left.and.right") - } description: { - Text(L10n.Tools.Tools.NoiseFloor.collectingDataDescription) - } + private var collectingState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.NoiseFloor.collectingData, systemImage: "antenna.radiowaves.left.and.right") + } description: { + Text(L10n.Tools.Tools.NoiseFloor.collectingDataDescription) } + } } // MARK: - Main Content extension NoiseFloorView { - private var mainContent: some View { - VStack(spacing: 16) { - if let error = viewModel.errorMessage { - ErrorBanner(message: error) - } + private var mainContent: some View { + VStack(spacing: 16) { + if let error = viewModel.errorMessage { + ErrorBanner(message: error) + } - ChartSection(viewModel: viewModel, startTime: chartStartTime) - - if horizontalSizeClass == .compact { - VStack(spacing: 16) { - CurrentReadingSection(viewModel: viewModel) - StatisticsSection(viewModel: viewModel) - } - } else { - HStack(spacing: 16) { - CurrentReadingSection(viewModel: viewModel) - .frame(maxWidth: .infinity, maxHeight: .infinity) - StatisticsSection(viewModel: viewModel) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .fixedSize(horizontal: false, vertical: true) - } + ChartSection(viewModel: viewModel, startTime: chartStartTime) + + if horizontalSizeClass == .compact { + VStack(spacing: 16) { + CurrentReadingSection(viewModel: viewModel) + StatisticsSection(viewModel: viewModel) + } + } else { + HStack(spacing: 16) { + CurrentReadingSection(viewModel: viewModel) + .frame(maxWidth: .infinity, maxHeight: .infinity) + StatisticsSection(viewModel: viewModel) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .padding() + .fixedSize(horizontal: false, vertical: true) + } } + .padding() + } } // MARK: - Error Banner private struct ErrorBanner: View { - let message: String - - var body: some View { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - Text(message) - .font(.subheadline) - Spacer() - } - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) + let message: String + + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(message) + .font(.subheadline) + Spacer() } + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + } } // MARK: - Current Reading Section private struct CurrentReadingSection: View { - let viewModel: NoiseFloorViewModel + let viewModel: NoiseFloorViewModel - private var displayValue: Int16 { - viewModel.currentReading?.noiseFloor ?? 0 - } + private var displayValue: Int16 { + viewModel.currentReading?.noiseFloor ?? 0 + } - var body: some View { - VStack(spacing: 8) { - Spacer(minLength: 0) + var body: some View { + VStack(spacing: 8) { + Spacer(minLength: 0) - Text(displayValue, format: .number) - .font(.largeTitle) - .fontDesign(.rounded) - .monospacedDigit() + Text(displayValue, format: .number) + .font(.largeTitle) + .fontDesign(.rounded) + .monospacedDigit() - Text(L10n.Tools.Tools.NoiseFloor.dBm) - .font(.title3) - .foregroundStyle(.secondary) + Text(L10n.Tools.Tools.NoiseFloor.dBm) + .font(.title3) + .foregroundStyle(.secondary) - qualityBadge + qualityBadge - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) - .accessibilityAddTraits(.updatesFrequently) + Spacer(minLength: 0) } - - @ViewBuilder - private var qualityBadge: some View { - let quality = viewModel.qualityLevel - if quality != .unknown { - HStack(spacing: 4) { - Image(systemName: quality.icon) - Text(quality.label) - } - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(quality.color.opacity(0.2), in: .capsule) - .foregroundStyle(quality.color) - } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.updatesFrequently) + } + + @ViewBuilder + private var qualityBadge: some View { + let quality = viewModel.qualityLevel + if quality != .unknown { + HStack(spacing: 4) { + Image(systemName: quality.icon) + Text(quality.label) + } + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(quality.color.opacity(0.2), in: .capsule) + .foregroundStyle(quality.color) } + } - private var accessibilityLabel: String { - guard let reading = viewModel.currentReading else { - return L10n.Tools.Tools.NoiseFloor.noReading - } - let quality = viewModel.qualityLevel - return "\(reading.noiseFloor) \(L10n.Tools.Tools.NoiseFloor.dBm), \(quality.label)" + private var accessibilityLabel: String { + guard let reading = viewModel.currentReading else { + return L10n.Tools.Tools.NoiseFloor.noReading } + let quality = viewModel.qualityLevel + return "\(reading.noiseFloor) \(L10n.Tools.Tools.NoiseFloor.dBm), \(quality.label)" + } } // MARK: - Chart Section private struct ChartSection: View { - let viewModel: NoiseFloorViewModel - let startTime: Date + let viewModel: NoiseFloorViewModel + let startTime: Date - private var trendDescription: String { - let readings = viewModel.readings - guard readings.count >= 4 else { return L10n.Tools.Tools.NoiseFloor.trendStable } + private var trendDescription: String { + let readings = viewModel.readings + guard readings.count >= 4 else { return L10n.Tools.Tools.NoiseFloor.trendStable } - let halfCount = readings.count / 2 - let firstHalf = readings.prefix(halfCount) - let secondHalf = readings.suffix(halfCount) + let halfCount = readings.count / 2 + let firstHalf = readings.prefix(halfCount) + let secondHalf = readings.suffix(halfCount) - let firstAvg = firstHalf.map { Int($0.noiseFloor) }.reduce(0, +) / max(1, halfCount) - let secondAvg = secondHalf.map { Int($0.noiseFloor) }.reduce(0, +) / max(1, halfCount) + let firstAvg = firstHalf.map { Int($0.noiseFloor) }.reduce(0, +) / max(1, halfCount) + let secondAvg = secondHalf.map { Int($0.noiseFloor) }.reduce(0, +) / max(1, halfCount) - if secondAvg > firstAvg + 3 { - return L10n.Tools.Tools.NoiseFloor.trendIncreasing - } else if secondAvg < firstAvg - 3 { - return L10n.Tools.Tools.NoiseFloor.trendDecreasing - } - return L10n.Tools.Tools.NoiseFloor.trendStable + if secondAvg > firstAvg + 3 { + return L10n.Tools.Tools.NoiseFloor.trendIncreasing + } else if secondAvg < firstAvg - 3 { + return L10n.Tools.Tools.NoiseFloor.trendDecreasing } + return L10n.Tools.Tools.NoiseFloor.trendStable + } - private var chartAccessibilityLabel: String { - let count = viewModel.readings.count - guard count > 0, let stats = viewModel.statistics else { - return L10n.Tools.Tools.NoiseFloor.chartAccessibilityEmpty - } - - return L10n.Tools.Tools.NoiseFloor.chartAccessibility(count, Int(stats.min), Int(stats.max), Int(stats.average), trendDescription) + private var chartAccessibilityLabel: String { + let count = viewModel.readings.count + guard count > 0, let stats = viewModel.statistics else { + return L10n.Tools.Tools.NoiseFloor.chartAccessibilityEmpty } - /// Visible chart window duration in seconds. - private let chartWindowSeconds: Double = 300 + return L10n.Tools.Tools.NoiseFloor.chartAccessibility(count, Int(stats.min), Int(stats.max), Int(stats.average), trendDescription) + } - private var chartDomain: ClosedRange { - guard let lastReading = viewModel.readings.last else { - return 0...chartWindowSeconds - } - let latestElapsed = lastReading.timestamp.timeIntervalSince(startTime) - if latestElapsed <= chartWindowSeconds { - return 0...chartWindowSeconds - } - return (latestElapsed - chartWindowSeconds)...latestElapsed - } + /// Visible chart window duration in seconds. + private let chartWindowSeconds: Double = 300 - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.noiseFloor) - .font(.headline) - - Chart(viewModel.readings) { reading in - let elapsed = reading.timestamp.timeIntervalSince(startTime) - LineMark( - x: .value("Time", elapsed), - y: .value("dBm", reading.noiseFloor) - ) - .foregroundStyle(.blue.gradient) - - AreaMark( - x: .value("Time", elapsed), - yStart: .value("Min", -130), - yEnd: .value("dBm", reading.noiseFloor) - ) - .foregroundStyle(.blue.opacity(0.1)) - } - .chartYScale(domain: -130 ... -60) - .chartXScale(domain: chartDomain) - .chartXAxis { - AxisMarks(values: .stride(by: 60)) { value in - AxisGridLine() - AxisValueLabel { - if let seconds = value.as(Double.self) { - let minute = Int(seconds) / 60 - Text("\(minute):00") - .monospacedDigit() - } - } - } - } - .chartPlotStyle { content in - content.clipped() + private var chartDomain: ClosedRange { + guard let lastReading = viewModel.readings.last else { + return 0...chartWindowSeconds + } + let latestElapsed = lastReading.timestamp.timeIntervalSince(startTime) + if latestElapsed <= chartWindowSeconds { + return 0...chartWindowSeconds + } + return (latestElapsed - chartWindowSeconds)...latestElapsed + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.noiseFloor) + .font(.headline) + + Chart(viewModel.readings) { reading in + let elapsed = reading.timestamp.timeIntervalSince(startTime) + LineMark( + x: .value("Time", elapsed), + y: .value("dBm", reading.noiseFloor) + ) + .foregroundStyle(.blue.gradient) + + AreaMark( + x: .value("Time", elapsed), + yStart: .value("Min", -130), + yEnd: .value("dBm", reading.noiseFloor) + ) + .foregroundStyle(.blue.opacity(0.1)) + } + .chartYScale(domain: -130 ... -60) + .chartXScale(domain: chartDomain) + .chartXAxis { + AxisMarks(values: .stride(by: 60)) { value in + AxisGridLine() + AxisValueLabel { + if let seconds = value.as(Double.self) { + let minute = Int(seconds) / 60 + Text("\(minute):00") + .monospacedDigit() } - .frame(maxHeight: .infinity) - .accessibilityLabel(chartAccessibilityLabel) + } } - .frame(maxHeight: .infinity) - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) + } + .chartPlotStyle { content in + content.clipped() + } + .frame(maxHeight: .infinity) + .accessibilityLabel(chartAccessibilityLabel) } + .frame(maxHeight: .infinity) + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + } } // MARK: - Statistics Section private struct StatisticsSection: View { - let viewModel: NoiseFloorViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(L10n.Tools.Tools.NoiseFloor.statistics) - .font(.headline) - - if let stats = viewModel.statistics { - Grid(alignment: .leading, verticalSpacing: 6) { - statRow(label: L10n.Tools.Tools.NoiseFloor.minimum, value: Int(stats.min), unit: L10n.Tools.Tools.NoiseFloor.dBm) - statRow(label: L10n.Tools.Tools.NoiseFloor.average, value: stats.average, unit: L10n.Tools.Tools.NoiseFloor.dBm, precision: 1) - statRow(label: L10n.Tools.Tools.NoiseFloor.maximum, value: Int(stats.max), unit: L10n.Tools.Tools.NoiseFloor.dBm) - - Divider() - .gridCellColumns(4) - - if let reading = viewModel.currentReading { - statRow(label: L10n.Tools.Tools.NoiseFloor.lastRssi, value: Int(reading.lastRSSI), unit: L10n.Tools.Tools.NoiseFloor.dBm) - statRow(label: L10n.Tools.Tools.NoiseFloor.lastSnr, value: reading.lastSNR, unit: L10n.Tools.Tools.NoiseFloor.db, precision: 1) - } - } - .font(.subheadline) - } - - Spacer(minLength: 0) + let viewModel: NoiseFloorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(L10n.Tools.Tools.NoiseFloor.statistics) + .font(.headline) + + if let stats = viewModel.statistics { + Grid(alignment: .leading, verticalSpacing: 6) { + statRow(label: L10n.Tools.Tools.NoiseFloor.minimum, value: Int(stats.min), unit: L10n.Tools.Tools.NoiseFloor.dBm) + statRow(label: L10n.Tools.Tools.NoiseFloor.average, value: stats.average, unit: L10n.Tools.Tools.NoiseFloor.dBm, precision: 1) + statRow(label: L10n.Tools.Tools.NoiseFloor.maximum, value: Int(stats.max), unit: L10n.Tools.Tools.NoiseFloor.dBm) + + Divider() + .gridCellColumns(4) + + if let reading = viewModel.currentReading { + statRow(label: L10n.Tools.Tools.NoiseFloor.lastRssi, value: Int(reading.lastRSSI), unit: L10n.Tools.Tools.NoiseFloor.dBm) + statRow(label: L10n.Tools.Tools.NoiseFloor.lastSnr, value: reading.lastSNR, unit: L10n.Tools.Tools.NoiseFloor.db, precision: 1) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) - } + .font(.subheadline) + } - private func statRow(label: String, value: Int, unit: String) -> some View { - GridRow { - Text(label) - .foregroundStyle(.secondary) - Spacer() - Text(value, format: .number) - .monospacedDigit() - Text(unit) - .foregroundStyle(.secondary) - } + Spacer(minLength: 0) } - - private func statRow(label: String, value: Double, unit: String, precision: Int) -> some View { - GridRow { - Text(label) - .foregroundStyle(.secondary) - Spacer() - Text(value, format: .number.precision(.fractionLength(precision))) - .monospacedDigit() - Text(unit) - .foregroundStyle(.secondary) - } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + } + + private func statRow(label: String, value: Int, unit: String) -> some View { + GridRow { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value, format: .number) + .monospacedDigit() + Text(unit) + .foregroundStyle(.secondary) } + } + + private func statRow(label: String, value: Double, unit: String, precision: Int) -> some View { + GridRow { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value, format: .number.precision(.fractionLength(precision))) + .monospacedDigit() + Text(unit) + .foregroundStyle(.secondary) + } + } } #Preview { - NavigationStack { - NoiseFloorView() - } - .environment(\.appState, AppState()) + NavigationStack { + NoiseFloorView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/NoiseFloorViewModel.swift b/MC1/Views/Tools/NoiseFloorViewModel.swift index c91ed937..bed15e2c 100644 --- a/MC1/Views/Tools/NoiseFloorViewModel.swift +++ b/MC1/Views/Tools/NoiseFloorViewModel.swift @@ -1,156 +1,156 @@ -import SwiftUI -import MeshCore import MC1Services +import MeshCore +import SwiftUI struct NoiseFloorReading: Identifiable { - let id: UUID - let timestamp: Date - let noiseFloor: Int16 - let lastRSSI: Int8 - let lastSNR: Double + let id: UUID + let timestamp: Date + let noiseFloor: Int16 + let lastRSSI: Int8 + let lastSNR: Double } struct NoiseFloorStatistics { - let min: Int16 - let max: Int16 - let average: Double + let min: Int16 + let max: Int16 + let average: Double } enum NoiseFloorQuality: Equatable { - case excellent - case good - case fair - case poor - case unknown - - static func from(noiseFloor: Int16) -> NoiseFloorQuality { - switch noiseFloor { - case ...(-100): return .excellent - case ...(-90): return .good - case ...(-80): return .fair - default: return .poor - } + case excellent + case good + case fair + case poor + case unknown + + static func from(noiseFloor: Int16) -> NoiseFloorQuality { + switch noiseFloor { + case ...(-100): .excellent + case ...(-90): .good + case ...(-80): .fair + default: .poor } - - var label: String { - switch self { - case .excellent: L10n.Tools.Tools.NoiseFloor.Quality.excellent - case .good: L10n.Tools.Tools.NoiseFloor.Quality.good - case .fair: L10n.Tools.Tools.NoiseFloor.Quality.fair - case .poor: L10n.Tools.Tools.NoiseFloor.Quality.poor - case .unknown: L10n.Tools.Tools.NoiseFloor.Quality.unknown - } + } + + var label: String { + switch self { + case .excellent: L10n.Tools.Tools.NoiseFloor.Quality.excellent + case .good: L10n.Tools.Tools.NoiseFloor.Quality.good + case .fair: L10n.Tools.Tools.NoiseFloor.Quality.fair + case .poor: L10n.Tools.Tools.NoiseFloor.Quality.poor + case .unknown: L10n.Tools.Tools.NoiseFloor.Quality.unknown } - - var color: Color { - switch self { - case .excellent: .green - case .good: .blue - case .fair: .orange - case .poor: .red - case .unknown: .secondary - } + } + + var color: Color { + switch self { + case .excellent: .green + case .good: .blue + case .fair: .orange + case .poor: .red + case .unknown: .secondary } - - var icon: String { - switch self { - case .excellent: "checkmark.circle.fill" - case .good: "circle.fill" - case .fair: "exclamationmark.circle.fill" - case .poor: "xmark.circle.fill" - case .unknown: "questionmark.circle" - } + } + + var icon: String { + switch self { + case .excellent: "checkmark.circle.fill" + case .good: "circle.fill" + case .fair: "exclamationmark.circle.fill" + case .poor: "xmark.circle.fill" + case .unknown: "questionmark.circle" } + } } @Observable @MainActor final class NoiseFloorViewModel { - var currentReading: NoiseFloorReading? - var readings: [NoiseFloorReading] = [] - var isPolling = false - var errorMessage: String? - - private let maxReadings = 200 - private let pollingInterval: Duration = .seconds(1.5) - - // Re-evaluated each poll tick so a disconnect mid-poll surfaces immediately. - private var sessionProvider: @MainActor () -> MeshCoreSession? = { nil } - private var pollingTask: Task? - // Ignored so the statistics getter can fill the cache during view body - // evaluation without mutating observed state; readings drives invalidation. - @ObservationIgnored private var cachedStatistics: NoiseFloorStatistics? - - var statistics: NoiseFloorStatistics? { - if let cached = cachedStatistics { return cached } - guard !readings.isEmpty else { return nil } - let values = readings.map { $0.noiseFloor } - let computed = NoiseFloorStatistics( - min: values.min()!, - max: values.max()!, - average: Double(values.reduce(0) { $0 + Int($1) }) / Double(values.count) - ) - cachedStatistics = computed - return computed + var currentReading: NoiseFloorReading? + var readings: [NoiseFloorReading] = [] + var isPolling = false + var errorMessage: String? + + private let maxReadings = 200 + private let pollingInterval: Duration = .seconds(1.5) + + // Re-evaluated each poll tick so a disconnect mid-poll surfaces immediately. + private var sessionProvider: @MainActor () -> MeshCoreSession? = { nil } + private var pollingTask: Task? + /// Ignored so the statistics getter can fill the cache during view body + /// evaluation without mutating observed state; readings drives invalidation. + @ObservationIgnored private var cachedStatistics: NoiseFloorStatistics? + + var statistics: NoiseFloorStatistics? { + if let cached = cachedStatistics { return cached } + guard !readings.isEmpty else { return nil } + let values = readings.map(\.noiseFloor) + let computed = NoiseFloorStatistics( + min: values.min()!, + max: values.max()!, + average: Double(values.reduce(0) { $0 + Int($1) }) / Double(values.count) + ) + cachedStatistics = computed + return computed + } + + var qualityLevel: NoiseFloorQuality { + guard let reading = currentReading else { return .unknown } + return NoiseFloorQuality.from(noiseFloor: reading.noiseFloor) + } + + func appendReading(_ reading: NoiseFloorReading) { + currentReading = reading + readings.append(reading) + if readings.count > maxReadings { + readings.removeFirst() } + cachedStatistics = nil + errorMessage = nil + } - var qualityLevel: NoiseFloorQuality { - guard let reading = currentReading else { return .unknown } - return NoiseFloorQuality.from(noiseFloor: reading.noiseFloor) - } + func startPolling(sessionProvider: @escaping @MainActor () -> MeshCoreSession?) { + self.sessionProvider = sessionProvider + guard pollingTask == nil else { return } + isPolling = true - func appendReading(_ reading: NoiseFloorReading) { - currentReading = reading - readings.append(reading) - if readings.count > maxReadings { - readings.removeFirst() - } - cachedStatistics = nil - errorMessage = nil - } - - func startPolling(sessionProvider: @escaping @MainActor () -> MeshCoreSession?) { - self.sessionProvider = sessionProvider - guard pollingTask == nil else { return } - isPolling = true - - pollingTask = Task { [weak self] in - while true { - do { - guard let self else { break } - await self.fetchReading() - try await Task.sleep(for: self.pollingInterval) - } catch { - break - } - } + pollingTask = Task { [weak self] in + while true { + do { + guard let self else { break } + await fetchReading() + try await Task.sleep(for: pollingInterval) + } catch { + break } + } } - - func stopPolling() { - pollingTask?.cancel() - pollingTask = nil - isPolling = false + } + + func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + isPolling = false + } + + private func fetchReading() async { + guard let session = sessionProvider() else { + errorMessage = L10n.Tools.Tools.NoiseFloor.Error.disconnected + return } - private func fetchReading() async { - guard let session = sessionProvider() else { - errorMessage = L10n.Tools.Tools.NoiseFloor.Error.disconnected - return - } - - do { - let stats = try await session.getStatsRadio() - let reading = NoiseFloorReading( - id: UUID(), - timestamp: .now, - noiseFloor: stats.noiseFloor, - lastRSSI: stats.lastRSSI, - lastSNR: stats.lastSNR - ) - appendReading(reading) - } catch { - self.errorMessage = L10n.Tools.Tools.NoiseFloor.Error.unableToRead - } + do { + let stats = try await session.getStatsRadio() + let reading = NoiseFloorReading( + id: UUID(), + timestamp: .now, + noiseFloor: stats.noiseFloor, + lastRSSI: stats.lastRSSI, + lastSNR: stats.lastSNR + ) + appendReading(reading) + } catch { + errorMessage = L10n.Tools.Tools.NoiseFloor.Error.unableToRead } + } } diff --git a/MC1/Views/Tools/RxLogView.swift b/MC1/Views/Tools/RxLogView.swift index 25662ec3..78772ad2 100644 --- a/MC1/Views/Tools/RxLogView.swift +++ b/MC1/Views/Tools/RxLogView.swift @@ -1,591 +1,591 @@ -import SwiftUI -import UIKit import MC1Services import MeshCore +import SwiftUI +import UIKit struct RxLogView: View { - @Environment(\.appState) private var appState - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.appTheme) private var theme - - @State private var viewModel = RxLogViewModel() - @State private var expandedHashes: Set = [] - @State private var groupDuplicates = false - - var body: some View { - Group { - if appState.services?.rxLogService == nil { - disconnectedState - } else if viewModel.entries.isEmpty { - emptyState - } else { - entryList - } - } - .navigationTitle(L10n.Tools.Tools.rxLog) - .toolbar { - toolbarContent + @Environment(\.appState) private var appState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.appTheme) private var theme + + @State private var viewModel = RxLogViewModel() + @State private var expandedHashes: Set = [] + @State private var groupDuplicates = false + + var body: some View { + Group { + if appState.services?.rxLogService == nil { + disconnectedState + } else if viewModel.entries.isEmpty { + emptyState + } else { + entryList + } + } + .navigationTitle(L10n.Tools.Tools.rxLog) + .toolbar { + toolbarContent + } + .task(id: appState.servicesVersion) { + viewModel.configure( + rxLogService: { [appState] in appState.services?.rxLogService }, + dataStore: { [appState] in appState.services?.dataStore }, + radioID: { [appState] in appState.currentRadioID } + ) + await viewModel.subscribe() + await viewModel.loadNodeNames() + } + .onChange(of: appState.contactsVersion) { + Task { await viewModel.loadNodeNames() } + } + .onDisappear { + viewModel.unsubscribe() + } + } + + // MARK: - Empty State + + private var emptyState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.RxLog.listening, systemImage: "antenna.radiowaves.left.and.right") + } description: { + Text(L10n.Tools.Tools.RxLog.listeningDescription) + } + } + + private var disconnectedState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Tools.Tools.RxLog.notConnectedDescription) + } + } + + // MARK: - Entry List + + private var entryList: some View { + List { + Section { + ForEach(displayEntries, id: \.id) { entry in + RxLogRowView( + entry: entry, + isExpanded: expandedBinding(for: entry.packetHash), + groupCount: groupDuplicates ? viewModel.groupCounts[entry.packetHash, default: 1] : 1, + localPublicKeyPrefix: appState.connectedDevice?.publicKeyPrefix, + nodeNames: viewModel.nodeNames + ) } - .task(id: appState.servicesVersion) { - viewModel.configure( - rxLogService: { [appState] in appState.services?.rxLogService }, - dataStore: { [appState] in appState.services?.dataStore }, - radioID: { [appState] in appState.currentRadioID } - ) - await viewModel.subscribe() - await viewModel.loadNodeNames() - } - .onChange(of: appState.contactsVersion) { - Task { await viewModel.loadNodeNames() } + } header: { + liveStatusHeader + .textCase(nil) + } + .themedRowBackground(theme) + } + .listStyle(.insetGrouped) + .themedCanvas(theme) + } + + private func expandedBinding(for hash: String) -> Binding { + Binding( + get: { expandedHashes.contains(hash) }, + set: { isExpanded in + if isExpanded { + expandedHashes.insert(hash) + } else { + expandedHashes.remove(hash) } - .onDisappear { - viewModel.unsubscribe() + } + ) + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + filterMenu + overflowMenu + } + } + } + + private var isConnected: Bool { + appState.services?.rxLogService != nil + } + + private var liveStatusHeader: some View { + HStack(spacing: 8) { + statusPill + + Text(L10n.Tools.Tools.RxLog.packetsCount(viewModel.entries.count)) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + } + .modifier(GlassContainerModifier()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(isConnected ? L10n.Tools.Tools.RxLog.live : L10n.Tools.Tools.RxLog.offline), \(L10n.Tools.Tools.RxLog.packetsCount(viewModel.entries.count))") + } + + private var statusPill: some View { + HStack(spacing: 6) { + Circle() + .fill(isConnected ? .green : .gray) + .frame(width: 8, height: 8) + .modifier(PulseAnimationModifier(isActive: isConnected && !reduceMotion)) + + Text(isConnected ? L10n.Tools.Tools.RxLog.live : L10n.Tools.Tools.RxLog.offline) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .modifier(GlassEffectModifier()) + } + + private var filterMenu: some View { + Menu { + Section(L10n.Tools.Tools.RxLog.routeType) { + ForEach(RxLogViewModel.RouteFilter.allCases, id: \.self) { filter in + Button { + viewModel.setRouteFilter(filter) + } label: { + HStack { + Text(filter.displayName) + if viewModel.routeFilter == filter { + Image(systemName: "checkmark") + } + } + } } - } - - // MARK: - Empty State + } - private var emptyState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.RxLog.listening, systemImage: "antenna.radiowaves.left.and.right") - } description: { - Text(L10n.Tools.Tools.RxLog.listeningDescription) + Section(L10n.Tools.Tools.RxLog.decryptStatus) { + ForEach(RxLogViewModel.DecryptFilter.allCases, id: \.self) { filter in + Button { + viewModel.setDecryptFilter(filter) + } label: { + HStack { + Text(filter.displayName) + if viewModel.decryptFilter == filter { + Image(systemName: "checkmark") + } + } + } } - } - - private var disconnectedState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Tools.Tools.RxLog.notConnectedDescription) + } + } label: { + Label(L10n.Tools.Tools.RxLog.filter, systemImage: viewModel.routeFilter == .all && viewModel.decryptFilter == .all + ? "line.3.horizontal.decrease.circle" + : "line.3.horizontal.decrease.circle.fill") + } + .liquidGlassSecondaryButtonStyle() + } + + @State private var showClearConfirmation = false + + private var overflowMenu: some View { + Menu { + Button { + groupDuplicates.toggle() + } label: { + HStack { + Text(L10n.Tools.Tools.RxLog.groupDuplicates) + if groupDuplicates { Image(systemName: "checkmark") } } - } - - // MARK: - Entry List - - private var entryList: some View { - List { - Section { - ForEach(displayEntries, id: \.id) { entry in - RxLogRowView( - entry: entry, - isExpanded: expandedBinding(for: entry.packetHash), - groupCount: groupDuplicates ? viewModel.groupCounts[entry.packetHash, default: 1] : 1, - localPublicKeyPrefix: appState.connectedDevice?.publicKeyPrefix, - nodeNames: viewModel.nodeNames - ) - } - } header: { - liveStatusHeader - .textCase(nil) - } - .themedRowBackground(theme) + } + + Divider() + + Button(role: .destructive) { + showClearConfirmation = true + } label: { + Label(L10n.Tools.Tools.RxLog.deleteLogs, systemImage: "trash") + } + } label: { + Label(L10n.Tools.Tools.RxLog.more, systemImage: "ellipsis.circle") + } + .liquidGlassSecondaryButtonStyle() + .confirmationDialog(L10n.Tools.Tools.RxLog.deleteConfirmation, isPresented: $showClearConfirmation, titleVisibility: .visible) { + Button(L10n.Tools.Tools.RxLog.delete, role: .destructive) { + clearLog() + } + } + } + + // MARK: - Helpers + + private var displayEntries: [RxLogEntryDTO] { + let filtered = viewModel.filteredEntries + if groupDuplicates { + var seen = Set() + return filtered.filter { entry in + if seen.contains(entry.packetHash) { + return false } - .listStyle(.insetGrouped) - .themedCanvas(theme) + seen.insert(entry.packetHash) + return true + } } + return filtered + } - private func expandedBinding(for hash: String) -> Binding { - Binding( - get: { expandedHashes.contains(hash) }, - set: { isExpanded in - if isExpanded { - expandedHashes.insert(hash) - } else { - expandedHashes.remove(hash) - } - } - ) + private func clearLog() { + Task { + await viewModel.clearLog() } + expandedHashes.removeAll() + } +} - // MARK: - Toolbar +// MARK: - Row View - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 16) { - filterMenu - overflowMenu - } +struct RxLogRowView: View { + let entry: RxLogEntryDTO + @Binding var isExpanded: Bool + let groupCount: Int + let localPublicKeyPrefix: Data? + let nodeNames: [Data: String] + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + expandedContent + } label: { + collapsedContent + } + .sensoryFeedback(.selection, trigger: isExpanded) + } + + // MARK: - Collapsed Content (3 lines) + + private var collapsedContent: some View { + VStack(alignment: .leading, spacing: 4) { + // Line 1: Route type, time, signal bars + HStack { + Text(entry.routeTypeSimple) + .font(.caption.bold()) + .foregroundStyle(entry.isFlood ? .green : .blue) + + Text(entry.receivedAt, format: .dateTime.month(.twoDigits).day(.twoDigits).hour().minute().second()) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + + Spacer() + + if entry.snr != nil { + Image(systemName: "cellularbars", variableValue: entry.snrLevel) + .foregroundStyle(entry.snrQuality.color) + .accessibilityLabel(L10n.Tools.Tools.RxLog.signalStrength(entry.snrQuality.localizedLabel)) } - } - - private var isConnected: Bool { - appState.services?.rxLogService != nil - } - - private var liveStatusHeader: some View { - HStack(spacing: 8) { - statusPill - - Text(L10n.Tools.Tools.RxLog.packetsCount(viewModel.entries.count)) - .font(.subheadline) - .foregroundStyle(.secondary) - - Spacer() + } + + // Line 2: Path visualization + From/To for direct text messages + HStack(spacing: 4) { + Text(pathDisplayString) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + if isDirectTextMessage, let sender = entry.senderPrefix, let recipient = entry.recipientPrefix { + Text("·") + .foregroundStyle(.tertiary) + Text("\(resolveHashLabel(sender)) → \(resolveHashLabel(recipient))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // Line 3: Message preview or packet info, SNR, duplicate count + HStack { + if let text = entry.decodedText { + Text("\"\(text)\"") + .font(.caption) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } else { + let versionSuffix = entry.payloadVersion > 0 ? " v\(entry.payloadVersion)" : "" + Text("\(entry.payloadType.displayName)\(versionSuffix) · \(entry.rawPayload.count) \(L10n.Tools.Tools.RxLog.bytes)") + .font(.caption) + .foregroundStyle(.secondary) } - .modifier(GlassContainerModifier()) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(isConnected ? L10n.Tools.Tools.RxLog.live : L10n.Tools.Tools.RxLog.offline), \(L10n.Tools.Tools.RxLog.packetsCount(viewModel.entries.count))") - } - private var statusPill: some View { - HStack(spacing: 6) { - Circle() - .fill(isConnected ? .green : .gray) - .frame(width: 8, height: 8) - .modifier(PulseAnimationModifier(isActive: isConnected && !reduceMotion)) + Spacer() - Text(isConnected ? L10n.Tools.Tools.RxLog.live : L10n.Tools.Tools.RxLog.offline) - .font(.subheadline) - .foregroundStyle(.secondary) + if let snrString = entry.snrDisplayString { + Text(snrString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .modifier(GlassEffectModifier()) - } - - private var filterMenu: some View { - Menu { - Section(L10n.Tools.Tools.RxLog.routeType) { - ForEach(RxLogViewModel.RouteFilter.allCases, id: \.self) { filter in - Button { - viewModel.setRouteFilter(filter) - } label: { - HStack { - Text(filter.displayName) - if viewModel.routeFilter == filter { - Image(systemName: "checkmark") - } - } - } - } - } - Section(L10n.Tools.Tools.RxLog.decryptStatus) { - ForEach(RxLogViewModel.DecryptFilter.allCases, id: \.self) { filter in - Button { - viewModel.setDecryptFilter(filter) - } label: { - HStack { - Text(filter.displayName) - if viewModel.decryptFilter == filter { - Image(systemName: "checkmark") - } - } - } - } - } - } label: { - Label(L10n.Tools.Tools.RxLog.filter, systemImage: viewModel.routeFilter == .all && viewModel.decryptFilter == .all - ? "line.3.horizontal.decrease.circle" - : "line.3.horizontal.decrease.circle.fill") + if groupCount > 1 { + Text("×\(groupCount)") + .font(.caption.bold()) + .foregroundStyle(.orange) + .accessibilityLabel(L10n.Tools.Tools.RxLog.receivedTimes(groupCount)) } - .liquidGlassSecondaryButtonStyle() + } } + } - @State private var showClearConfirmation = false - - private var overflowMenu: some View { - Menu { - Button { - groupDuplicates.toggle() - } label: { - HStack { - Text(L10n.Tools.Tools.RxLog.groupDuplicates) - if groupDuplicates { Image(systemName: "checkmark") } - } - } + // MARK: - Path Display - Divider() + private var isTrace: Bool { + entry.payloadType == .trace + } - Button(role: .destructive) { - showClearConfirmation = true - } label: { - Label(L10n.Tools.Tools.RxLog.deleteLogs, systemImage: "trash") - } - } label: { - Label(L10n.Tools.Tools.RxLog.more, systemImage: "ellipsis.circle") - } - .liquidGlassSecondaryButtonStyle() - .confirmationDialog(L10n.Tools.Tools.RxLog.deleteConfirmation, isPresented: $showClearConfirmation, titleVisibility: .visible) { - Button(L10n.Tools.Tools.RxLog.delete, role: .destructive) { - clearLog() - } - } + private var pathDisplayString: String { + if entry.pathNodes.isEmpty { + return L10n.Tools.Tools.RxLog.direct } - // MARK: - Helpers - - private var displayEntries: [RxLogEntryDTO] { - let filtered = viewModel.filteredEntries - if groupDuplicates { - var seen = Set() - return filtered.filter { entry in - if seen.contains(entry.packetHash) { - return false - } - seen.insert(entry.packetHash) - return true - } - } - return filtered + if isTrace { + let routeParts = traceRouteIdParts + if !routeParts.isEmpty { + return truncatedJoin(routeParts, separator: " → ") + } + let count = entry.hopCount + let hopLabel = count == 1 ? L10n.Tools.Tools.RxLog.hopSingular : L10n.Tools.Tools.RxLog.hopPlural + return "\(count) \(hopLabel)" } - private func clearLog() { - Task { - await viewModel.clearLog() - } - expandedHashes.removeAll() - } -} + return truncatedJoin(hopIdParts, separator: " → ") + } -// MARK: - Row View - -struct RxLogRowView: View { - let entry: RxLogEntryDTO - @Binding var isExpanded: Bool - let groupCount: Int - let localPublicKeyPrefix: Data? - let nodeNames: [Data: String] - - var body: some View { - DisclosureGroup(isExpanded: $isExpanded) { - expandedContent - } label: { - collapsedContent - } - .sensoryFeedback(.selection, trigger: isExpanded) + private var pathDetailString: String { + if entry.pathNodes.isEmpty { + return L10n.Tools.Tools.RxLog.direct } + let hopCount = entry.hopCount + let hopLabel = hopCount == 1 ? L10n.Tools.Tools.RxLog.hopSingular : L10n.Tools.Tools.RxLog.hopPlural + return "\(hopCount) \(hopLabel) [\(hopIdParts.joined(separator: ", "))]" + } - // MARK: - Collapsed Content (3 lines) - - private var collapsedContent: some View { - VStack(alignment: .leading, spacing: 4) { - // Line 1: Route type, time, signal bars - HStack { - Text(entry.routeTypeSimple) - .font(.caption.bold()) - .foregroundStyle(entry.isFlood ? .green : .blue) - - Text(entry.receivedAt, format: .dateTime.month(.twoDigits).day(.twoDigits).hour().minute().second()) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + /// For TRACE packets: public key prefix IDs from traceTargetHashes. + private var traceRouteIdParts: [String] { + guard let targetHashes = entry.traceTargetHashes else { return [] } + return targetHashes.map { $0.uppercaseHexString() } + } - Spacer() + /// For non-TRACE packets: public key prefix IDs for each hop, chunked by hashSize. + private var hopIdParts: [String] { + let hashSize = entry.pathHashSize + return stride(from: 0, to: entry.pathNodes.count, by: hashSize).map { start in + let end = min(start + hashSize, entry.pathNodes.count) + let chunk = entry.pathNodes[start.. 0 ? " v\(entry.payloadVersion)" : "" - Text("\(entry.payloadType.displayName)\(versionSuffix) · \(entry.rawPayload.count) \(L10n.Tools.Tools.RxLog.bytes)") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - if let snrString = entry.snrDisplayString { - Text(snrString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } - - if groupCount > 1 { - Text("×\(groupCount)") - .font(.caption.bold()) - .foregroundStyle(.orange) - .accessibilityLabel(L10n.Tools.Tools.RxLog.receivedTimes(groupCount)) - } - } - } + return Data(chunk).uppercaseHexString() } + } - // MARK: - Path Display - - private var isTrace: Bool { - entry.payloadType == .trace + /// Join parts with separator, truncating to first 3 … last 3 if > 6 elements. + private func truncatedJoin(_ parts: [String], separator: String) -> String { + if parts.count > 6 { + let first = parts.prefix(3).joined(separator: separator) + let last = parts.suffix(3).joined(separator: separator) + return "\(first) \(separator) … \(separator) \(last)" } + return parts.joined(separator: separator) + } - private var pathDisplayString: String { - if entry.pathNodes.isEmpty { - return L10n.Tools.Tools.RxLog.direct - } - - if isTrace { - let routeParts = traceRouteIdParts - if !routeParts.isEmpty { - return truncatedJoin(routeParts, separator: " → ") - } - let count = entry.hopCount - let hopLabel = count == 1 ? L10n.Tools.Tools.RxLog.hopSingular : L10n.Tools.Tools.RxLog.hopPlural - return "\(count) \(hopLabel)" - } + private var isDirectTextMessage: Bool { + (entry.routeType == .direct || entry.routeType == .tcDirect) && entry.payloadType == .textMessage + } - return truncatedJoin(hopIdParts, separator: " → ") + private func resolveHashLabel(_ hashBytes: Data) -> String { + if let prefix = localPublicKeyPrefix, prefix.prefix(hashBytes.count) == hashBytes { + return L10n.Tools.Tools.RxLog.pathYou } - - private var pathDetailString: String { - if entry.pathNodes.isEmpty { - return L10n.Tools.Tools.RxLog.direct - } - let hopCount = entry.hopCount - let hopLabel = hopCount == 1 ? L10n.Tools.Tools.RxLog.hopSingular : L10n.Tools.Tools.RxLog.hopPlural - return "\(hopCount) \(hopLabel) [\(hopIdParts.joined(separator: ", "))]" + if let name = nodeNames[hashBytes] { + return name } + return hashBytes.uppercaseHexString() + } - /// For TRACE packets: public key prefix IDs from traceTargetHashes. - private var traceRouteIdParts: [String] { - guard let targetHashes = entry.traceTargetHashes else { return [] } - return targetHashes.map { $0.uppercaseHexString() } - } + // MARK: - Expanded Content - /// For non-TRACE packets: public key prefix IDs for each hop, chunked by hashSize. - private var hopIdParts: [String] { - let hashSize = entry.pathHashSize - return stride(from: 0, to: entry.pathNodes.count, by: hashSize).map { start in - let end = min(start + hashSize, entry.pathNodes.count) - let chunk = entry.pathNodes[start.. 6 elements. - private func truncatedJoin(_ parts: [String], separator: String) -> String { - if parts.count > 6 { - let first = parts.prefix(3).joined(separator: separator) - let last = parts.suffix(3).joined(separator: separator) - return "\(first) \(separator) … \(separator) \(last)" - } - return parts.joined(separator: separator) - } + DetailRow(label: L10n.Tools.Tools.RxLog.hashLabel, value: entry.packetHash, truncate: true) - private var isDirectTextMessage: Bool { - (entry.routeType == .direct || entry.routeType == .tcDirect) && entry.payloadType == .textMessage - } + if isDirectTextMessage, let sender = entry.senderPrefix, let recipient = entry.recipientPrefix { + DetailRow(label: L10n.Tools.Tools.RxLog.fromLabel, value: resolveHashLabel(sender)) + DetailRow(label: L10n.Tools.Tools.RxLog.toLabel, value: resolveHashLabel(recipient)) + } - private func resolveHashLabel(_ hashBytes: Data) -> String { - if let prefix = localPublicKeyPrefix, prefix.prefix(hashBytes.count) == hashBytes { - return L10n.Tools.Tools.RxLog.pathYou + // Channel message: show channel info + if entry.decryptStatus == .success { + if entry.channelIndex != nil, let channelHashByte = entry.packetPayload.first { + DetailRow(label: L10n.Tools.Tools.RxLog.channelHashLabel, value: String(format: "%02x", channelHashByte)) } - if let name = nodeNames[hashBytes] { - return name + if let channelName = entry.channelName { + DetailRow(label: L10n.Tools.Tools.RxLog.channelNameLabel, value: channelName) } - return hashBytes.uppercaseHexString() - } - - // MARK: - Expanded Content - - private var expandedContent: some View { - VStack(alignment: .leading, spacing: 4) { - if let rssi = entry.rssi { - DetailRow(label: L10n.Tools.Tools.RxLog.rssiLabel, value: "\(rssi) dBm") - } - if let snr = entry.snr { - DetailRow(label: L10n.Tools.Tools.RxLog.snrLabel, value: snr.formatted(.number.precision(.fractionLength(1))) + " dB") - } - - DetailRow(label: L10n.Tools.Tools.RxLog.typeLabel, value: entry.payloadType.displayName) - DetailRow(label: L10n.Tools.Tools.RxLog.sizeLabel, value: "\(entry.rawPayload.count) \(L10n.Tools.Tools.RxLog.bytes)") - - if isTrace { - let idParts = traceRouteIdParts - if !idParts.isEmpty { - DetailRow(label: L10n.Tools.Tools.RxLog.traceRouteLabel, value: idParts.joined(separator: " → "), wrapping: true) - } - } else { - DetailRow(label: L10n.Tools.Tools.RxLog.pathLabel, value: pathDetailString, wrapping: true) - } - - DetailRow(label: L10n.Tools.Tools.RxLog.hashLabel, value: entry.packetHash, truncate: true) - - if isDirectTextMessage, let sender = entry.senderPrefix, let recipient = entry.recipientPrefix { - DetailRow(label: L10n.Tools.Tools.RxLog.fromLabel, value: resolveHashLabel(sender)) - DetailRow(label: L10n.Tools.Tools.RxLog.toLabel, value: resolveHashLabel(recipient)) - } - - // Channel message: show channel info - if entry.decryptStatus == .success { - if entry.channelIndex != nil, let channelHashByte = entry.packetPayload.first { - DetailRow(label: L10n.Tools.Tools.RxLog.channelHashLabel, value: String(format: "%02x", channelHashByte)) - } - if let channelName = entry.channelName { - DetailRow(label: L10n.Tools.Tools.RxLog.channelNameLabel, value: channelName) - } - if let transportCode = entry.transportCode, !transportCode.isEmpty { - DetailRow( - label: L10n.Tools.Tools.RxLog.regionLabel, - value: entry.regionScope ?? L10n.Tools.Tools.RxLog.regionUnresolved - ) - } - if let text = entry.decodedText { - HStack(alignment: .top) { - Text(L10n.Tools.Tools.RxLog.textLabel) - .font(.caption) - .foregroundStyle(.secondary) - Text(text) - .font(.caption) - } - } - } - - RawPayloadSection(payload: entry.rawPayload) + if let transportCode = entry.transportCode, !transportCode.isEmpty { + DetailRow( + label: L10n.Tools.Tools.RxLog.regionLabel, + value: entry.regionScope ?? L10n.Tools.Tools.RxLog.regionUnresolved + ) + } + if let text = entry.decodedText { + HStack(alignment: .top) { + Text(L10n.Tools.Tools.RxLog.textLabel) + .font(.caption) + .foregroundStyle(.secondary) + Text(text) + .font(.caption) + } } + } + + RawPayloadSection(payload: entry.rawPayload) } + } } // MARK: - Detail Row private struct DetailRow: View { - let label: String - let value: String - var truncate: Bool = false - var wrapping: Bool = false - - var body: some View { - HStack { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.caption.monospaced()) - .lineLimit(wrapping ? nil : 1) - .truncationMode(truncate ? .middle : .tail) - } - } + let label: String + let value: String + var truncate: Bool = false + var wrapping: Bool = false + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.caption.monospaced()) + .lineLimit(wrapping ? nil : 1) + .truncationMode(truncate ? .middle : .tail) + } + } } // MARK: - Raw Payload Section private struct RawPayloadSection: View { - let payload: Data - - @State private var copied = false - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(L10n.Tools.Tools.RxLog.rawPayload) - .font(.caption.bold()) - .foregroundStyle(.secondary) - - Spacer() - - Button(L10n.Tools.Tools.RxLog.copy, systemImage: copied ? "checkmark" : "doc.on.doc", action: copyToClipboard) - .font(.caption) - .foregroundStyle(copied ? .green : .secondary) - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .sensoryFeedback(.success, trigger: copied) { _, newValue in newValue } - } - - Text(hexString) - .font(.caption2.monospaced()) - .lineLimit(3) - .truncationMode(.tail) - } - } - - private var hexString: String { - payload.map { String(format: "%02X", $0) }.joined(separator: " ") - } - - private func copyToClipboard() { - UIPasteboard.general.string = hexString - copied = true - Task { - try? await Task.sleep(for: .seconds(2)) - copied = false - } - } + let payload: Data + + @State private var copied = false + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(L10n.Tools.Tools.RxLog.rawPayload) + .font(.caption.bold()) + .foregroundStyle(.secondary) + + Spacer() + + Button(L10n.Tools.Tools.RxLog.copy, systemImage: copied ? "checkmark" : "doc.on.doc", action: copyToClipboard) + .font(.caption) + .foregroundStyle(copied ? .green : .secondary) + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .sensoryFeedback(.success, trigger: copied) { _, newValue in newValue } + } + + Text(hexString) + .font(.caption2.monospaced()) + .lineLimit(3) + .truncationMode(.tail) + } + } + + private var hexString: String { + payload.map { String(format: "%02X", $0) }.joined(separator: " ") + } + + private func copyToClipboard() { + UIPasteboard.general.string = hexString + copied = true + Task { + try? await Task.sleep(for: .seconds(2)) + copied = false + } + } } // MARK: - Glass Effect Modifiers private struct GlassEffectModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.glassEffect() - } else { - content.background(.ultraThinMaterial, in: .capsule) - } + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.glassEffect() + } else { + content.background(.ultraThinMaterial, in: .capsule) } + } } private struct GlassContainerModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - GlassEffectContainer { content } - } else { - content - } + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + GlassEffectContainer { content } + } else { + content } + } } // MARK: - Pulse Animation private struct PulseAnimationModifier: ViewModifier { - let isActive: Bool - - @State private var isPulsing = false - - func body(content: Content) -> some View { - content - .opacity(isActive && isPulsing ? 0.4 : 1.0) - .animation( - isActive ? .easeInOut(duration: 1.0).repeatForever(autoreverses: true) : .default, - value: isPulsing - ) - .onAppear { - if isActive { - isPulsing = true - } - } - .onChange(of: isActive) { _, newValue in - isPulsing = newValue - } - } + let isActive: Bool + + @State private var isPulsing = false + + func body(content: Content) -> some View { + content + .opacity(isActive && isPulsing ? 0.4 : 1.0) + .animation( + isActive ? .easeInOut(duration: 1.0).repeatForever(autoreverses: true) : .default, + value: isPulsing + ) + .onAppear { + if isActive { + isPulsing = true + } + } + .onChange(of: isActive) { _, newValue in + isPulsing = newValue + } + } } #Preview { - NavigationStack { - RxLogView() - } - .environment(\.appState, AppState()) + NavigationStack { + RxLogView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/RxLogViewModel.swift b/MC1/Views/Tools/RxLogViewModel.swift index 95be1c39..ed859ce4 100644 --- a/MC1/Views/Tools/RxLogViewModel.swift +++ b/MC1/Views/Tools/RxLogViewModel.swift @@ -4,207 +4,215 @@ import MC1Services @Observable @MainActor final class RxLogViewModel { - enum RouteFilter: String, CaseIterable { - case all - case floodOnly - case directOnly - - var displayName: String { - switch self { - case .all: L10n.Tools.Tools.RxLog.Filter.all - case .floodOnly: L10n.Tools.Tools.RxLog.Filter.floodOnly - case .directOnly: L10n.Tools.Tools.RxLog.Filter.directOnly - } - } + enum RouteFilter: String, CaseIterable { + case all + case floodOnly + case directOnly + + var displayName: String { + switch self { + case .all: L10n.Tools.Tools.RxLog.Filter.all + case .floodOnly: L10n.Tools.Tools.RxLog.Filter.floodOnly + case .directOnly: L10n.Tools.Tools.RxLog.Filter.directOnly + } } - - enum DecryptFilter: String, CaseIterable { - case all - case decrypted - case failed - - var displayName: String { - switch self { - case .all: L10n.Tools.Tools.RxLog.Filter.all - case .decrypted: L10n.Tools.Tools.RxLog.Filter.decrypted - case .failed: L10n.Tools.Tools.RxLog.Filter.failed - } - } + } + + enum DecryptFilter: String, CaseIterable { + case all + case decrypted + case failed + + var displayName: String { + switch self { + case .all: L10n.Tools.Tools.RxLog.Filter.all + case .decrypted: L10n.Tools.Tools.RxLog.Filter.decrypted + case .failed: L10n.Tools.Tools.RxLog.Filter.failed + } } - - private(set) var entries: [RxLogEntryDTO] = [] - private(set) var groupCounts: [String: Int] = [:] - private(set) var routeFilter: RouteFilter = .all - private(set) var decryptFilter: DecryptFilter = .all - - /// Maps path hash bytes (1, 2, or 3 byte prefixes) to contact display names. - /// Only populated for prefixes that uniquely identify a single contact. - private(set) var nodeNames: [Data: String] = [:] - - private var streamTask: Task? - - // MARK: - Dependencies - - private var rxLogServiceProvider: @MainActor () -> RxLogService? = { nil } - private var dataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } - private var radioIDProvider: @MainActor () -> UUID? = { nil } - - private var rxLogService: RxLogService? { rxLogServiceProvider() } - private var dataStore: (any PersistenceStoreProtocol)? { dataStoreProvider() } - private var radioID: UUID? { radioIDProvider() } - - // The provider reads live state, so change detection needs the instance - // seen at the previous subscribe. Weak, so a torn-down container's - // deallocated service reads as a change. - private weak var subscribedService: RxLogService? - - /// Each provider is read live at its point of use; a provider returning - /// nil mirrors a disconnected state, so unconfigured calls are no-ops. - func configure( - rxLogService: @escaping @MainActor () -> RxLogService?, - dataStore: @escaping @MainActor () -> (any PersistenceStoreProtocol)?, - radioID: @escaping @MainActor () -> UUID? - ) { - rxLogServiceProvider = rxLogService - dataStoreProvider = dataStore - radioIDProvider = radioID + } + + private(set) var entries: [RxLogEntryDTO] = [] + private(set) var groupCounts: [String: Int] = [:] + private(set) var routeFilter: RouteFilter = .all + private(set) var decryptFilter: DecryptFilter = .all + + /// Maps path hash bytes (1, 2, or 3 byte prefixes) to contact display names. + /// Only populated for prefixes that uniquely identify a single contact. + private(set) var nodeNames: [Data: String] = [:] + + private var streamTask: Task? + + // MARK: - Dependencies + + private var rxLogServiceProvider: @MainActor () -> RxLogService? = { nil } + private var dataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } + private var radioIDProvider: @MainActor () -> UUID? = { nil } + + private var rxLogService: RxLogService? { + rxLogServiceProvider() + } + + private var dataStore: (any PersistenceStoreProtocol)? { + dataStoreProvider() + } + + private var radioID: UUID? { + radioIDProvider() + } + + /// The provider reads live state, so change detection needs the instance + /// seen at the previous subscribe. Weak, so a torn-down container's + /// deallocated service reads as a change. + private weak var subscribedService: RxLogService? + + /// Each provider is read live at its point of use; a provider returning + /// nil mirrors a disconnected state, so unconfigured calls are no-ops. + func configure( + rxLogService: @escaping @MainActor () -> RxLogService?, + dataStore: @escaping @MainActor () -> (any PersistenceStoreProtocol)?, + radioID: @escaping @MainActor () -> UUID? + ) { + rxLogServiceProvider = rxLogService + dataStoreProvider = dataStore + radioIDProvider = radioID + } + + func setRouteFilter(_ filter: RouteFilter) { + routeFilter = filter + } + + func setDecryptFilter(_ filter: DecryptFilter) { + decryptFilter = filter + } + + /// Entries filtered by current filter settings. + var filteredEntries: [RxLogEntryDTO] { + entries.filter { entry in + // Route filter + switch routeFilter { + case .all: break + case .floodOnly: + guard entry.isFlood else { return false } + case .directOnly: + guard !entry.isFlood else { return false } + } + + // Decrypt filter + switch decryptFilter { + case .all: break + case .decrypted: + guard entry.decryptStatus == .success else { return false } + case .failed: + guard entry.decryptStatus == .hmacFailed + || entry.decryptStatus == .decryptFailed + || entry.decryptStatus == .noMatchingKey + || entry.decryptStatus == .dmNoMatchingKey else { return false } + } + + return true } + } - func setRouteFilter(_ filter: RouteFilter) { - routeFilter = filter - } - - func setDecryptFilter(_ filter: DecryptFilter) { - decryptFilter = filter - } - - /// Entries filtered by current filter settings. - var filteredEntries: [RxLogEntryDTO] { - entries.filter { entry in - // Route filter - switch routeFilter { - case .all: break - case .floodOnly: - guard entry.isFlood else { return false } - case .directOnly: - guard !entry.isFlood else { return false } - } - - // Decrypt filter - switch decryptFilter { - case .all: break - case .decrypted: - guard entry.decryptStatus == .success else { return false } - case .failed: - guard entry.decryptStatus == .hmacFailed - || entry.decryptStatus == .decryptFailed - || entry.decryptStatus == .noMatchingKey - || entry.decryptStatus == .dmNoMatchingKey else { return false } - } - - return true - } - } - - /// Subscribe to the live RxLogService for updates while view is visible. - func subscribe() async { - // Cancel any existing stream task so a re-subscribe (a `.task(id:)` re-fire - // against the same service) can't leave two streams appending each packet twice. - unsubscribe() - - guard let service = rxLogService else { return } - - // If service changed, reset state - if subscribedService !== service { - entries.removeAll() - groupCounts.removeAll() - } - subscribedService = service + /// Subscribe to the live RxLogService for updates while view is visible. + func subscribe() async { + // Cancel any existing stream task so a re-subscribe (a `.task(id:)` re-fire + // against the same service) can't leave two streams appending each packet twice. + unsubscribe() - entries = await service.loadExistingEntries() - rebuildGroupCounts() + guard let service = rxLogService else { return } - streamTask = Task { - for await entry in service.entryStream() { - guard !Task.isCancelled else { break } - appendEntry(entry) - } - } + // If service changed, reset state + if subscribedService !== service { + entries.removeAll() + groupCounts.removeAll() } + subscribedService = service - /// Stop listening to updates. - func unsubscribe() { - streamTask?.cancel() - streamTask = nil - } + entries = await service.loadExistingEntries() + rebuildGroupCounts() - /// Clear all log entries. - func clearLog() async { - await rxLogService?.clearEntries() - entries.removeAll() - groupCounts.removeAll() + streamTask = Task { + for await entry in service.entryStream() { + guard !Task.isCancelled else { break } + appendEntry(entry) + } } - - // MARK: - Incremental Updates - - private func appendEntry(_ entry: RxLogEntryDTO) { - // Insert at front to maintain newest-first order (matching DB fetch sort) - entries.insert(entry, at: 0) - groupCounts[entry.packetHash, default: 0] += 1 - - // Prune oldest (now at end) if over cap - if entries.count > 1000 { - let removed = entries.removeLast() - groupCounts[removed.packetHash, default: 1] -= 1 - if groupCounts[removed.packetHash] == 0 { - groupCounts.removeValue(forKey: removed.packetHash) - } - } + } + + /// Stop listening to updates. + func unsubscribe() { + streamTask?.cancel() + streamTask = nil + } + + /// Clear all log entries. + func clearLog() async { + await rxLogService?.clearEntries() + entries.removeAll() + groupCounts.removeAll() + } + + // MARK: - Incremental Updates + + private func appendEntry(_ entry: RxLogEntryDTO) { + // Insert at front to maintain newest-first order (matching DB fetch sort) + entries.insert(entry, at: 0) + groupCounts[entry.packetHash, default: 0] += 1 + + // Prune oldest (now at end) if over cap + if entries.count > 1000 { + let removed = entries.removeLast() + groupCounts[removed.packetHash, default: 1] -= 1 + if groupCounts[removed.packetHash] == 0 { + groupCounts.removeValue(forKey: removed.packetHash) + } } - - private func rebuildGroupCounts() { - groupCounts = Dictionary(grouping: entries, by: \.packetHash) - .mapValues(\.count) - } - - // MARK: - Node Name Resolution - - /// Load contact names for path hop resolution. Leaves `nodeNames` - /// unchanged while disconnected. - func loadNodeNames() async { - guard let dataStore, let radioID else { return } - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - nodeNames = Self.buildNodeNameMap(from: contacts) - } catch { - nodeNames = [:] - } + } + + private func rebuildGroupCounts() { + groupCounts = Dictionary(grouping: entries, by: \.packetHash) + .mapValues(\.count) + } + + // MARK: - Node Name Resolution + + /// Load contact names for path hop resolution. Leaves `nodeNames` + /// unchanged while disconnected. + func loadNodeNames() async { + guard let dataStore, let radioID else { return } + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + nodeNames = Self.buildNodeNameMap(from: contacts) + } catch { + nodeNames = [:] } + } - /// Build a map from public key prefixes (1, 2, 3 bytes) to display names. - /// Only stores entries where the prefix uniquely identifies a single contact. - static func buildNodeNameMap(from contacts: [ContactDTO]) -> [Data: String] { - var map: [Data: String] = [:] - - for prefixLength in 1...3 { - var prefixCounts: [Data: (name: String, count: Int)] = [:] + /// Build a map from public key prefixes (1, 2, 3 bytes) to display names. + /// Only stores entries where the prefix uniquely identifies a single contact. + static func buildNodeNameMap(from contacts: [ContactDTO]) -> [Data: String] { + var map: [Data: String] = [:] - for contact in contacts { - guard contact.publicKey.count >= prefixLength else { continue } - let prefix = contact.publicKey.prefix(prefixLength) + for prefixLength in 1...3 { + var prefixCounts: [Data: (name: String, count: Int)] = [:] - if let existing = prefixCounts[prefix] { - prefixCounts[prefix] = (existing.name, existing.count + 1) - } else { - prefixCounts[prefix] = (contact.displayName, 1) - } - } + for contact in contacts { + guard contact.publicKey.count >= prefixLength else { continue } + let prefix = contact.publicKey.prefix(prefixLength) - for (prefix, entry) in prefixCounts where entry.count == 1 { - map[prefix] = entry.name - } + if let existing = prefixCounts[prefix] { + prefixCounts[prefix] = (existing.name, existing.count + 1) + } else { + prefixCounts[prefix] = (contact.displayName, 1) } + } - return map + for (prefix, entry) in prefixCounts where entry.count == 1 { + map[prefix] = entry.name + } } + + return map + } } diff --git a/MC1/Views/Tools/ToolDestinationView.swift b/MC1/Views/Tools/ToolDestinationView.swift index 791a0506..ce20ac85 100644 --- a/MC1/Views/Tools/ToolDestinationView.swift +++ b/MC1/Views/Tools/ToolDestinationView.swift @@ -5,17 +5,17 @@ import SwiftUI /// between the two — the compact stack shows the combined map-with-sheet layout from a fresh view /// model, while the split shows only the map driven by the shared one — so its view is injected. struct ToolDestinationView: View { - let tool: ToolSelection - @ViewBuilder let lineOfSight: () -> LineOfSight + let tool: ToolSelection + @ViewBuilder let lineOfSight: () -> LineOfSight - var body: some View { - switch tool { - case .tracePath: TracePathView() - case .lineOfSight: lineOfSight() - case .rxLog: RxLogView() - case .noiseFloor: NoiseFloorView() - case .nodeDiscovery: NodeDiscoveryView() - case .cli: CLIToolView() - } + var body: some View { + switch tool { + case .tracePath: TracePathView() + case .lineOfSight: lineOfSight() + case .rxLog: RxLogView() + case .noiseFloor: NoiseFloorView() + case .nodeDiscovery: NodeDiscoveryView() + case .cli: CLIToolView() } + } } diff --git a/MC1/Views/Tools/ToolSelection.swift b/MC1/Views/Tools/ToolSelection.swift index 4f723bc1..fd036738 100644 --- a/MC1/Views/Tools/ToolSelection.swift +++ b/MC1/Views/Tools/ToolSelection.swift @@ -4,45 +4,45 @@ import SwiftUI /// `NavigationLink`) and the iPad split columns (`ToolsContentColumn` list selection + /// `ToolsDetailColumn` detail), and persisted as the active selection on `NavigationCoordinator`. enum ToolSelection: Hashable, CaseIterable { - case tracePath - case lineOfSight - case rxLog - case noiseFloor - case nodeDiscovery - case cli + case tracePath + case lineOfSight + case rxLog + case noiseFloor + case nodeDiscovery + case cli - var title: String { - switch self { - case .tracePath: L10n.Tools.Tools.tracePath - case .lineOfSight: L10n.Tools.Tools.lineOfSight - case .rxLog: L10n.Tools.Tools.rxLog - case .noiseFloor: L10n.Tools.Tools.noiseFloor - case .nodeDiscovery: L10n.Tools.Tools.nodeDiscovery - case .cli: L10n.Tools.Tools.cli - } + var title: String { + switch self { + case .tracePath: L10n.Tools.Tools.tracePath + case .lineOfSight: L10n.Tools.Tools.lineOfSight + case .rxLog: L10n.Tools.Tools.rxLog + case .noiseFloor: L10n.Tools.Tools.noiseFloor + case .nodeDiscovery: L10n.Tools.Tools.nodeDiscovery + case .cli: L10n.Tools.Tools.cli } + } - var systemImage: String { - switch self { - case .tracePath: "point.3.connected.trianglepath.dotted" - case .lineOfSight: "eye" - case .rxLog: "waveform.badge.magnifyingglass" - case .noiseFloor: "waveform" - case .nodeDiscovery: "dot.radiowaves.left.and.right" - case .cli: "terminal" - } + var systemImage: String { + switch self { + case .tracePath: "point.3.connected.trianglepath.dotted" + case .lineOfSight: "eye" + case .rxLog: "waveform.badge.magnifyingglass" + case .noiseFloor: "waveform" + case .nodeDiscovery: "dot.radiowaves.left.and.right" + case .cli: "terminal" } + } - /// Line of Sight runs its analysis offline; every other tool needs a connected radio. - var requiresRadio: Bool { - self != .lineOfSight - } + /// Line of Sight runs its analysis offline; every other tool needs a connected radio. + var requiresRadio: Bool { + self != .lineOfSight + } - /// Tools that collapse the iPad section's sidebar when open, reclaiming its width. Line of Sight - /// swaps the content column for its analysis panel beside the detail map; Trace Path keeps the tool - /// list in the content column and gives its own list/map view the freed width in the detail column. - /// Other tools keep the sidebar's normal width-driven behavior. - var prefersCollapsedSidebar: Bool { - self == .lineOfSight || self == .tracePath - } + /// Tools that collapse the iPad section's sidebar when open, reclaiming its width. Line of Sight + /// swaps the content column for its analysis panel beside the detail map; Trace Path keeps the tool + /// list in the content column and gives its own list/map view the freed width in the detail column. + /// Other tools keep the sidebar's normal width-driven behavior. + var prefersCollapsedSidebar: Bool { + self == .lineOfSight || self == .tracePath + } } diff --git a/MC1/Views/Tools/ToolsContentColumn.swift b/MC1/Views/Tools/ToolsContentColumn.swift index 3119496e..6dd41b5d 100644 --- a/MC1/Views/Tools/ToolsContentColumn.swift +++ b/MC1/Views/Tools/ToolsContentColumn.swift @@ -6,72 +6,72 @@ import SwiftUI /// with a back control returning to the list. The panel carries readable RF figures, so it keeps /// an opaque background — the floating-glass background extension is applied to the list only. struct ToolsContentColumn: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - let lineOfSightViewModel: LineOfSightViewModel + let lineOfSightViewModel: LineOfSightViewModel - private var selection: Binding { - Binding( - get: { appState.navigation.selectedTool }, - set: { setSelectedTool($0) } - ) - } + private var selection: Binding { + Binding( + get: { appState.navigation.selectedTool }, + set: { setSelectedTool($0) } + ) + } - /// Selecting or leaving Line of Sight swaps this column's `NavigationStack` root, which animates - /// the navigation bar; suppressing the animation on that transition stops the radio control from - /// leaving a stuck ghost over the title while the leading slot reconfigures to the back button. - /// Selections that only drive the detail column (every other tool) keep their default animation. - private func setSelectedTool(_ tool: ToolSelection?) { - let togglesPanel = tool == .lineOfSight || appState.navigation.selectedTool == .lineOfSight - guard togglesPanel else { - appState.navigation.selectedTool = tool - return - } - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { appState.navigation.selectedTool = tool } + /// Selecting or leaving Line of Sight swaps this column's `NavigationStack` root, which animates + /// the navigation bar; suppressing the animation on that transition stops the radio control from + /// leaving a stuck ghost over the title while the leading slot reconfigures to the back button. + /// Selections that only drive the detail column (every other tool) keep their default animation. + private func setSelectedTool(_ tool: ToolSelection?) { + let togglesPanel = tool == .lineOfSight || appState.navigation.selectedTool == .lineOfSight + guard togglesPanel else { + appState.navigation.selectedTool = tool + return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { appState.navigation.selectedTool = tool } + } - var body: some View { - NavigationStack { - if appState.navigation.selectedTool == .lineOfSight { - lineOfSightPanel - } else { - toolList - } - } + var body: some View { + NavigationStack { + if appState.navigation.selectedTool == .lineOfSight { + lineOfSightPanel + } else { + toolList + } } + } - private var toolList: some View { - List(selection: selection) { - ForEach(ToolSelection.allCases, id: \.self) { tool in - Label(tool.title, systemImage: tool.systemImage) - .tag(Optional(tool)) - } - } - .listStyle(.sidebar) - .navigationTitle(L10n.Tools.Tools.title) - .modifier(SidebarContentColumnBackground(theme: theme)) - .toolbar { - bleStatusToolbarItem() - } + private var toolList: some View { + List(selection: selection) { + ForEach(ToolSelection.allCases, id: \.self) { tool in + Label(tool.title, systemImage: tool.systemImage) + .tag(Optional(tool)) + } } - - private var lineOfSightPanel: some View { - LineOfSightView(viewModel: lineOfSightViewModel, layoutMode: .panel) - .navigationTitle(L10n.Tools.Tools.lineOfSight) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - setSelectedTool(nil) - } label: { - Label(L10n.Tools.Tools.title, systemImage: "chevron.backward") - } - } - // The back button owns the leading slot here, so the radio moves to the trailing edge. - bleStatusToolbarItem(placement: .topBarTrailing) - } + .listStyle(.sidebar) + .navigationTitle(L10n.Tools.Tools.title) + .modifier(SidebarContentColumnBackground(theme: theme)) + .toolbar { + bleStatusToolbarItem() } + } + + private var lineOfSightPanel: some View { + LineOfSightView(viewModel: lineOfSightViewModel, layoutMode: .panel) + .navigationTitle(L10n.Tools.Tools.lineOfSight) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + setSelectedTool(nil) + } label: { + Label(L10n.Tools.Tools.title, systemImage: "chevron.backward") + } + } + // The back button owns the leading slot here, so the radio moves to the trailing edge. + bleStatusToolbarItem(placement: .topBarTrailing) + } + } } diff --git a/MC1/Views/Tools/ToolsDetailColumn.swift b/MC1/Views/Tools/ToolsDetailColumn.swift index 37e4ff89..e6293fc2 100644 --- a/MC1/Views/Tools/ToolsDetailColumn.swift +++ b/MC1/Views/Tools/ToolsDetailColumn.swift @@ -6,38 +6,38 @@ import SwiftUI /// The map self-manages its safe area, so no blanket `ignoresSafeArea` is applied here (that would /// push the other tools' titles under the status bar). struct ToolsDetailColumn: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let lineOfSightViewModel: LineOfSightViewModel + let lineOfSightViewModel: LineOfSightViewModel - private var selectedTool: ToolSelection? { - appState.navigation.selectedTool - } + private var selectedTool: ToolSelection? { + appState.navigation.selectedTool + } - var body: some View { - NavigationStack { - detail - .navigationTitle(navigationTitle) - .navigationBarTitleDisplayMode(.inline) - } - .id(selectedTool) - .liquidGlassToolbarBackground() + var body: some View { + NavigationStack { + detail + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) } + .id(selectedTool) + .liquidGlassToolbarBackground() + } - @ViewBuilder - private var detail: some View { - if let selectedTool { - ToolDestinationView(tool: selectedTool) { - LineOfSightView(viewModel: lineOfSightViewModel, layoutMode: .map) - } - } else { - ContentUnavailableView(L10n.Tools.Tools.selectTool, systemImage: "wrench.and.screwdriver") - } + @ViewBuilder + private var detail: some View { + if let selectedTool { + ToolDestinationView(tool: selectedTool) { + LineOfSightView(viewModel: lineOfSightViewModel, layoutMode: .map) + } + } else { + ContentUnavailableView(L10n.Tools.Tools.selectTool, systemImage: "wrench.and.screwdriver") } + } - /// Line of Sight owns its own inline chrome over the map, so it renders titleless. - private var navigationTitle: String { - guard let selectedTool, selectedTool != .lineOfSight else { return "" } - return selectedTool.title - } + /// Line of Sight owns its own inline chrome over the map, so it renders titleless. + private var navigationTitle: String { + guard let selectedTool, selectedTool != .lineOfSight else { return "" } + return selectedTool.title + } } diff --git a/MC1/Views/Tools/ToolsView.swift b/MC1/Views/Tools/ToolsView.swift index 4642de73..b36d9b36 100644 --- a/MC1/Views/Tools/ToolsView.swift +++ b/MC1/Views/Tools/ToolsView.swift @@ -4,31 +4,31 @@ import SwiftUI /// regular-width layout routes Tools through `MainSidebarView`'s split (`ToolsContentColumn` + /// `ToolsDetailColumn`) instead, so this view is only reached in compact width. struct ToolsView: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - NavigationStack { - List { - ForEach(ToolSelection.allCases, id: \.self) { tool in - NavigationLink(value: tool) { - Label(tool.title, systemImage: tool.systemImage) - } - } - .themedRowBackground(theme) - } - .navigationDestination(for: ToolSelection.self) { tool in - ToolDestinationView(tool: tool) { LineOfSightView() } - } - .themedCanvas(theme) - .navigationTitle(L10n.Tools.Tools.title) - .toolbar { - bleStatusToolbarItem() - } + var body: some View { + NavigationStack { + List { + ForEach(ToolSelection.allCases, id: \.self) { tool in + NavigationLink(value: tool) { + Label(tool.title, systemImage: tool.systemImage) + } } + .themedRowBackground(theme) + } + .navigationDestination(for: ToolSelection.self) { tool in + ToolDestinationView(tool: tool) { LineOfSightView() } + } + .themedCanvas(theme) + .navigationTitle(L10n.Tools.Tools.title) + .toolbar { + bleStatusToolbarItem() + } } + } } #Preview { - ToolsView() - .environment(\.appState, AppState()) + ToolsView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/TracePath/BatchRTTRow.swift b/MC1/Views/Tools/TracePath/BatchRTTRow.swift index b20c5d85..107645f2 100644 --- a/MC1/Views/Tools/TracePath/BatchRTTRow.swift +++ b/MC1/Views/Tools/TracePath/BatchRTTRow.swift @@ -1,28 +1,28 @@ -import SwiftUI import MC1Services +import SwiftUI /// Row displaying batch RTT statistics (average, min, max) struct BatchRTTRow: View { - @Bindable var viewModel: TracePathViewModel + @Bindable var viewModel: TracePathViewModel - var body: some View { - if let avg = viewModel.averageRTT, - let min = viewModel.minRTT, - let max = viewModel.maxRTT { - HStack { - Text(L10n.Contacts.Contacts.Results.avgRoundTrip) - .foregroundStyle(.secondary) - Spacer() - VStack(alignment: .trailing) { - Text("\(avg) ms") - .font(.body.monospacedDigit()) - Text("(\(min) – \(max))") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Results.avgRTTLabel(avg, min, max)) + var body: some View { + if let avg = viewModel.averageRTT, + let min = viewModel.minRTT, + let max = viewModel.maxRTT { + HStack { + Text(L10n.Contacts.Contacts.Results.avgRoundTrip) + .foregroundStyle(.secondary) + Spacer() + VStack(alignment: .trailing) { + Text("\(avg) ms") + .font(.body.monospacedDigit()) + Text("(\(min) – \(max))") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Results.avgRTTLabel(avg, min, max)) } + } } diff --git a/MC1/Views/Tools/TracePath/BatchSizeChip.swift b/MC1/Views/Tools/TracePath/BatchSizeChip.swift index 4694085d..4727a898 100644 --- a/MC1/Views/Tools/TracePath/BatchSizeChip.swift +++ b/MC1/Views/Tools/TracePath/BatchSizeChip.swift @@ -1,22 +1,24 @@ import SwiftUI struct BatchSizeChip: View { - let size: Int - @Binding var selectedSize: Int + let size: Int + @Binding var selectedSize: Int - private var isSelected: Bool { selectedSize == size } + private var isSelected: Bool { + selectedSize == size + } - var body: some View { - Button { - selectedSize = size - } label: { - Text("\(size)×") - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.2), in: .capsule) - .foregroundStyle(isSelected ? .white : .primary) - } - .buttonStyle(.plain) + var body: some View { + Button { + selectedSize = size + } label: { + Text("\(size)×") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.2), in: .capsule) + .foregroundStyle(isSelected ? .white : .primary) } + .buttonStyle(.plain) + } } diff --git a/MC1/Views/Tools/TracePath/ComparisonRowView.swift b/MC1/Views/Tools/TracePath/ComparisonRowView.swift index 76d334a4..dd6db894 100644 --- a/MC1/Views/Tools/TracePath/ComparisonRowView.swift +++ b/MC1/Views/Tools/TracePath/ComparisonRowView.swift @@ -1,59 +1,59 @@ -import SwiftUI import MC1Services +import SwiftUI /// Row comparing current trace RTT with a previous saved path run struct ComparisonRowView: View { - let currentMs: Int - let previousRun: TracePathRunDTO - @Bindable var viewModel: TracePathViewModel - - var body: some View { - let diff = currentMs - previousRun.roundTripMs - let percentChange = previousRun.roundTripMs > 0 - ? Double(diff) / Double(previousRun.roundTripMs) * 100 - : 0 - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(L10n.Contacts.Contacts.PathDetail.roundTrip) - .foregroundStyle(.secondary) - Spacer() - Text("\(currentMs) ms") - .font(.body.monospacedDigit()) - - // Change indicator - if diff != 0 { - Text(diff > 0 ? "\u{25B2}" : "\u{25BC}") - .foregroundStyle(diff > 0 ? .red : .green) - .accessibilityLabel(diff > 0 - ? L10n.Contacts.Contacts.Results.Comparison.increased - : L10n.Contacts.Contacts.Results.Comparison.decreased) - Text(abs(percentChange), format: .number.precision(.fractionLength(0))) - .font(.caption.monospacedDigit()) - + Text("%") - .font(.caption) - } - } - - Text(L10n.Contacts.Contacts.Results.comparison(previousRun.roundTripMs, previousRun.date.formatted(date: .abbreviated, time: .omitted))) - .font(.caption) - .foregroundStyle(.secondary) + let currentMs: Int + let previousRun: TracePathRunDTO + @Bindable var viewModel: TracePathViewModel + + var body: some View { + let diff = currentMs - previousRun.roundTripMs + let percentChange = previousRun.roundTripMs > 0 + ? Double(diff) / Double(previousRun.roundTripMs) * 100 + : 0 + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(L10n.Contacts.Contacts.PathDetail.roundTrip) + .foregroundStyle(.secondary) + Spacer() + Text("\(currentMs) ms") + .font(.body.monospacedDigit()) + + // Change indicator + if diff != 0 { + Text(diff > 0 ? "\u{25B2}" : "\u{25BC}") + .foregroundStyle(diff > 0 ? .red : .green) + .accessibilityLabel(diff > 0 + ? L10n.Contacts.Contacts.Results.Comparison.increased + : L10n.Contacts.Contacts.Results.Comparison.decreased) + Text(abs(percentChange), format: .number.precision(.fractionLength(0))) + .font(.caption.monospacedDigit()) + + Text("%") + .font(.caption) } + } + + Text(L10n.Contacts.Contacts.Results.comparison(previousRun.roundTripMs, previousRun.date.formatted(date: .abbreviated, time: .omitted))) + .font(.caption) + .foregroundStyle(.secondary) + } - // Sparkline with history link - if let savedPath = viewModel.activeSavedPath, !savedPath.recentRTTs.isEmpty { - HStack { - MiniSparkline(values: savedPath.recentRTTs) - .frame(height: 20) + // Sparkline with history link + if let savedPath = viewModel.activeSavedPath, !savedPath.recentRTTs.isEmpty { + HStack { + MiniSparkline(values: savedPath.recentRTTs) + .frame(height: 20) - Spacer() + Spacer() - NavigationLink(value: TracePathRoute.savedPathDetail(savedPath)) { - Text(L10n.Contacts.Contacts.Results.viewRuns(savedPath.runCount)) - .font(.caption) - } - } - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + NavigationLink(value: TracePathRoute.savedPathDetail(savedPath)) { + Text(L10n.Contacts.Contacts.Results.viewRuns(savedPath.runCount)) + .font(.caption) } + } + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } } + } } diff --git a/MC1/Views/Tools/TracePath/DistanceInfoSheetView.swift b/MC1/Views/Tools/TracePath/DistanceInfoSheetView.swift index d829f80a..eb07001c 100644 --- a/MC1/Views/Tools/TracePath/DistanceInfoSheetView.swift +++ b/MC1/Views/Tools/TracePath/DistanceInfoSheetView.swift @@ -1,62 +1,62 @@ -import SwiftUI import MC1Services +import SwiftUI /// Sheet explaining distance calculation details and limitations struct DistanceInfoSheetView: View { - @Environment(\.appTheme) private var theme - let result: TraceResult - @Bindable var viewModel: TracePathViewModel - @Binding var showingDistanceInfo: Bool + @Environment(\.appTheme) private var theme + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Binding var showingDistanceInfo: Bool - var body: some View { - NavigationStack { - List { - if viewModel.isDistanceUsingFallback { - Section { - Text(L10n.Contacts.Contacts.Results.partialDistanceExplanation) - } header: { - Label(L10n.Contacts.Contacts.Results.partialDistanceHeader, systemImage: "location.slash") - } - .themedRowBackground(theme) - Section { - Text(L10n.Contacts.Contacts.Results.fullPathTip) - } header: { - Label(L10n.Contacts.Contacts.Results.fullPathHeader, systemImage: "lightbulb") - } - .themedRowBackground(theme) - } else if result.hops.filter({ !$0.isStartNode && !$0.isEndNode }).count < 2 { - Section { - Text(L10n.Contacts.Contacts.Results.needsRepeaters) - } - .themedRowBackground(theme) - } else if viewModel.repeatersWithoutLocation.isEmpty { - Section { - Text(L10n.Contacts.Contacts.Results.distanceError) - } - .themedRowBackground(theme) - } else { - Section { - Text(L10n.Contacts.Contacts.Results.missingLocations) - } - .themedRowBackground(theme) - Section(L10n.Contacts.Contacts.Results.repeatersWithoutLocations) { - ForEach(viewModel.repeatersWithoutLocation, id: \.self) { name in - Text(name) - } - } - .themedRowBackground(theme) - } - } - .themedCanvas(theme) - .navigationTitle(viewModel.isDistanceUsingFallback ? L10n.Contacts.Contacts.Results.distanceInfoTitlePartial : L10n.Contacts.Contacts.Results.distanceInfoTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(L10n.Contacts.Contacts.Common.done) { - showingDistanceInfo = false - } - } + var body: some View { + NavigationStack { + List { + if viewModel.isDistanceUsingFallback { + Section { + Text(L10n.Contacts.Contacts.Results.partialDistanceExplanation) + } header: { + Label(L10n.Contacts.Contacts.Results.partialDistanceHeader, systemImage: "location.slash") + } + .themedRowBackground(theme) + Section { + Text(L10n.Contacts.Contacts.Results.fullPathTip) + } header: { + Label(L10n.Contacts.Contacts.Results.fullPathHeader, systemImage: "lightbulb") + } + .themedRowBackground(theme) + } else if result.hops.count(where: { !$0.isStartNode && !$0.isEndNode }) < 2 { + Section { + Text(L10n.Contacts.Contacts.Results.needsRepeaters) + } + .themedRowBackground(theme) + } else if viewModel.repeatersWithoutLocation.isEmpty { + Section { + Text(L10n.Contacts.Contacts.Results.distanceError) + } + .themedRowBackground(theme) + } else { + Section { + Text(L10n.Contacts.Contacts.Results.missingLocations) + } + .themedRowBackground(theme) + Section(L10n.Contacts.Contacts.Results.repeatersWithoutLocations) { + ForEach(viewModel.repeatersWithoutLocation, id: \.self) { name in + Text(name) } + } + .themedRowBackground(theme) + } + } + .themedCanvas(theme) + .navigationTitle(viewModel.isDistanceUsingFallback ? L10n.Contacts.Contacts.Results.distanceInfoTitlePartial : L10n.Contacts.Contacts.Results.distanceInfoTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.Contacts.Contacts.Common.done) { + showingDistanceInfo = false + } } + } } + } } diff --git a/MC1/Views/Tools/TracePath/Map/PathActionsSectionView.swift b/MC1/Views/Tools/TracePath/Map/PathActionsSectionView.swift index ce0f8d28..ac3b74ee 100644 --- a/MC1/Views/Tools/TracePath/Map/PathActionsSectionView.swift +++ b/MC1/Views/Tools/TracePath/Map/PathActionsSectionView.swift @@ -1,91 +1,90 @@ -import SwiftUI import MC1Services +import SwiftUI /// Section with path configuration toggles, copy, and clear actions struct PathActionsSectionView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Bindable var viewModel: TracePathViewModel - @Binding var showingClearConfirmation: Bool - @Binding var copyHapticTrigger: Int + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Bindable var viewModel: TracePathViewModel + @Binding var showingClearConfirmation: Bool + @Binding var copyHapticTrigger: Int - var body: some View { - Section { - if !viewModel.outboundPath.isEmpty { - Toggle(isOn: $viewModel.autoReturnPath) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Contacts.Contacts.Trace.List.autoReturn) - Text(L10n.Contacts.Contacts.Trace.List.autoReturnDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } + var body: some View { + Section { + if !viewModel.outboundPath.isEmpty { + Toggle(isOn: $viewModel.autoReturnPath) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Contacts.Contacts.Trace.List.autoReturn) + Text(L10n.Contacts.Contacts.Trace.List.autoReturnDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } - Toggle(isOn: $viewModel.batchEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Contacts.Contacts.Trace.List.batchTrace) - Text(L10n.Contacts.Contacts.Trace.List.batchTraceDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } + Toggle(isOn: $viewModel.batchEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Contacts.Contacts.Trace.List.batchTrace) + Text(L10n.Contacts.Contacts.Trace.List.batchTraceDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } - if viewModel.batchEnabled { - HStack(spacing: 12) { - Text(L10n.Contacts.Contacts.Trace.List.traces) - .foregroundStyle(.secondary) - Spacer() - BatchSizeChip(size: 3, selectedSize: $viewModel.batchSize) - BatchSizeChip(size: 5, selectedSize: $viewModel.batchSize) - BatchSizeChip(size: 10, selectedSize: $viewModel.batchSize) - } - } + if viewModel.batchEnabled { + HStack(spacing: 12) { + Text(L10n.Contacts.Contacts.Trace.List.traces) + .foregroundStyle(.secondary) + Spacer() + BatchSizeChip(size: 3, selectedSize: $viewModel.batchSize) + BatchSizeChip(size: 5, selectedSize: $viewModel.batchSize) + } + } - if appState.connectedDevice?.supportsTraceHashSizeOverride == true { - VStack(alignment: .leading, spacing: 4) { - Picker(L10n.Contacts.Contacts.Trace.List.hashSize, selection: Binding( - get: { viewModel.effectiveTraceMode }, - set: { viewModel.setTraceHashMode($0) } - )) { - Text(L10n.Contacts.Contacts.Trace.List.hashSizeOneByte).tag(UInt8(0)) - Text(L10n.Contacts.Contacts.Trace.List.hashSizeTwoBytes).tag(UInt8(1)) - Text(L10n.Contacts.Contacts.Trace.List.hashSizeFourBytes).tag(UInt8(2)) - } - .pickerStyle(.menu) - .tint(.primary) + if appState.connectedDevice?.supportsTraceHashSizeOverride == true { + VStack(alignment: .leading, spacing: 4) { + Picker(L10n.Contacts.Contacts.Trace.List.hashSize, selection: Binding( + get: { viewModel.effectiveTraceMode }, + set: { viewModel.setTraceHashMode($0) } + )) { + Text(L10n.Contacts.Contacts.Trace.List.hashSizeOneByte).tag(UInt8(0)) + Text(L10n.Contacts.Contacts.Trace.List.hashSizeTwoBytes).tag(UInt8(1)) + Text(L10n.Contacts.Contacts.Trace.List.hashSizeFourBytes).tag(UInt8(2)) + } + .pickerStyle(.menu) + .tint(.primary) - if viewModel.effectiveTraceMode > 0 { - Text(L10n.Contacts.Contacts.Trace.List.hashSizeFooter) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } + if viewModel.effectiveTraceMode > 0 { + Text(L10n.Contacts.Contacts.Trace.List.hashSizeFooter) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } - HStack { - Text(viewModel.fullPathString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + HStack { + Text(viewModel.fullPathString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - viewModel.copyPathToClipboard() - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - } + Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + viewModel.copyPathToClipboard() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } - Button(L10n.Contacts.Contacts.Trace.clearPath, systemImage: "trash", role: .destructive) { - showingClearConfirmation = true - } - } - } footer: { - if !viewModel.outboundPath.isEmpty { - Text(L10n.Contacts.Contacts.Trace.List.rangeWarning) - } + Button(L10n.Contacts.Contacts.Trace.clearPath, systemImage: "trash", role: .destructive) { + showingClearConfirmation = true } - .themedRowBackground(theme) + } + } footer: { + if !viewModel.outboundPath.isEmpty { + Text(L10n.Contacts.Contacts.Trace.List.rangeWarning) + } } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Tools/TracePath/Map/RunTraceSectionView.swift b/MC1/Views/Tools/TracePath/Map/RunTraceSectionView.swift index 448c93e6..92b7d3ce 100644 --- a/MC1/Views/Tools/TracePath/Map/RunTraceSectionView.swift +++ b/MC1/Views/Tools/TracePath/Map/RunTraceSectionView.swift @@ -1,75 +1,75 @@ -import SwiftUI import MC1Services +import SwiftUI /// Section with the run trace button and running state indicator struct RunTraceSectionView: View { - @Environment(\.appState) private var appState - var viewModel: TracePathViewModel - @Binding var showJumpToPath: Bool + @Environment(\.appState) private var appState + var viewModel: TracePathViewModel + @Binding var showJumpToPath: Bool - var body: some View { - Section { - HStack { - Spacer() - if viewModel.isRunning { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - if viewModel.batchEnabled { - Text(L10n.Contacts.Contacts.Trace.List.runningBatch(viewModel.currentTraceIndex, viewModel.batchSize)) - } else { - Text(L10n.Contacts.Contacts.Trace.List.runningTrace) - } - } - .frame(minWidth: 160) - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(.regularMaterial, in: .capsule) - .overlay { - Capsule() - .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) - } - .accessibilityLabel(viewModel.batchEnabled - ? L10n.Contacts.Contacts.Trace.List.runningBatchLabel(viewModel.currentTraceIndex, viewModel.batchSize) - : L10n.Contacts.Contacts.Trace.List.runningLabel) - .accessibilityHint(L10n.Contacts.Contacts.Trace.List.runningHint) - } else { - Button { - Task { - if viewModel.batchEnabled { - await viewModel.runBatchTrace() - } else { - await viewModel.runTrace() - } - } - } label: { - Text(L10n.Contacts.Contacts.Trace.List.runTrace) - .frame(minWidth: 160) - .padding(.vertical, 4) - } - .liquidGlassProminentButtonStyle() - .radioDisabled(for: appState.connectionState, or: !viewModel.canRunTraceWhenConnected) - .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.runTraceLabel) - .accessibilityHint(viewModel.batchEnabled - ? L10n.Contacts.Contacts.Trace.List.batchHint(viewModel.batchSize) - : L10n.Contacts.Contacts.Trace.List.singleHint) - } - Spacer() - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .id("runTrace") - .onAppear { - withAnimation(.easeInOut(duration: 0.2)) { - showJumpToPath = false - } + var body: some View { + Section { + HStack { + Spacer() + if viewModel.isRunning { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + if viewModel.batchEnabled { + Text(L10n.Contacts.Contacts.Trace.List.runningBatch(viewModel.currentTraceIndex, viewModel.batchSize)) + } else { + Text(L10n.Contacts.Contacts.Trace.List.runningTrace) } - .onDisappear { - withAnimation(.easeInOut(duration: 0.2)) { - showJumpToPath = true - } + } + .frame(minWidth: 160) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(.regularMaterial, in: .capsule) + .overlay { + Capsule() + .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) + } + .accessibilityLabel(viewModel.batchEnabled + ? L10n.Contacts.Contacts.Trace.List.runningBatchLabel(viewModel.currentTraceIndex, viewModel.batchSize) + : L10n.Contacts.Contacts.Trace.List.runningLabel) + .accessibilityHint(L10n.Contacts.Contacts.Trace.List.runningHint) + } else { + Button { + Task { + if viewModel.batchEnabled { + await viewModel.runBatchTrace() + } else { + await viewModel.runTrace() + } } + } label: { + Text(L10n.Contacts.Contacts.Trace.List.runTrace) + .frame(minWidth: 160) + .padding(.vertical, 4) + } + .liquidGlassProminentButtonStyle() + .radioDisabled(for: appState.connectionState, or: !viewModel.canRunTraceWhenConnected) + .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.runTraceLabel) + .accessibilityHint(viewModel.batchEnabled + ? L10n.Contacts.Contacts.Trace.List.batchHint(viewModel.batchSize) + : L10n.Contacts.Contacts.Trace.List.singleHint) + } + Spacer() + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .id("runTrace") + .onAppear { + withAnimation(.easeInOut(duration: 0.2)) { + showJumpToPath = false + } + } + .onDisappear { + withAnimation(.easeInOut(duration: 0.2)) { + showJumpToPath = true } - .listSectionSeparator(.hidden) + } } + .listSectionSeparator(.hidden) + } } diff --git a/MC1/Views/Tools/TracePath/Map/TracePathFloatingButtonsView.swift b/MC1/Views/Tools/TracePath/Map/TracePathFloatingButtonsView.swift index fcfcfb5f..b22df832 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathFloatingButtonsView.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathFloatingButtonsView.swift @@ -1,76 +1,76 @@ -import SwiftUI import MC1Services +import SwiftUI /// Floating action buttons for trace path map (clear, run trace, view results) struct TracePathFloatingButtonsView: View { - var mapViewModel: TracePathMapViewModel - @Binding var showingClearConfirmation: Bool - @Binding var presentedResult: TraceResult? - var buttonNamespace: Namespace.ID + var mapViewModel: TracePathMapViewModel + @Binding var showingClearConfirmation: Bool + @Binding var presentedResult: TraceResult? + var buttonNamespace: Namespace.ID - var body: some View { - VStack { - Spacer() + var body: some View { + VStack { + Spacer() - LiquidGlassContainer(spacing: 12) { - HStack(spacing: 12) { - if mapViewModel.hasPath { - // Clear button - Button { - showingClearConfirmation = true - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.clear) - } - .liquidGlassButtonStyle() - .liquidGlassID("clear", in: buttonNamespace) - .confirmationDialog( - L10n.Contacts.Contacts.Trace.clearPath, - isPresented: $showingClearConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { - mapViewModel.clearPath() - } - } message: { - Text(L10n.Contacts.Contacts.Trace.clearPathMessage) - } - - // Run Trace button - Button { - Task { - await mapViewModel.runTrace() - } - } label: { - if mapViewModel.isRunning { - HStack { - ProgressView() - .controlSize(.small) - Text(L10n.Contacts.Contacts.Trace.List.runningTrace) - } - } else { - Text(L10n.Contacts.Contacts.Trace.List.runTrace) - } - } - .liquidGlassProminentButtonStyle() - .liquidGlassID("trace", in: buttonNamespace) - .disabled(!mapViewModel.canRunTrace) + LiquidGlassContainer(spacing: 12) { + HStack(spacing: 12) { + if mapViewModel.hasPath { + // Clear button + Button { + showingClearConfirmation = true + } label: { + Text(L10n.Contacts.Contacts.Trace.Map.clear) + } + .liquidGlassButtonStyle() + .liquidGlassID("clear", in: buttonNamespace) + .confirmationDialog( + L10n.Contacts.Contacts.Trace.clearPath, + isPresented: $showingClearConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { + mapViewModel.clearPath() + } + } message: { + Text(L10n.Contacts.Contacts.Trace.clearPathMessage) + } - // View Results button - if let result = mapViewModel.result, result.success { - Button { - presentedResult = result - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.viewResults) - } - .liquidGlassButtonStyle() - .liquidGlassID("viewResults", in: buttonNamespace) - } - } + // Run Trace button + Button { + Task { + await mapViewModel.runTrace() + } + } label: { + if mapViewModel.isRunning { + HStack { + ProgressView() + .controlSize(.small) + Text(L10n.Contacts.Contacts.Trace.List.runningTrace) } + } else { + Text(L10n.Contacts.Contacts.Trace.List.runTrace) + } + } + .liquidGlassProminentButtonStyle() + .liquidGlassID("trace", in: buttonNamespace) + .disabled(!mapViewModel.canRunTrace) + + // View Results button + if let result = mapViewModel.result, result.success { + Button { + presentedResult = result + } label: { + Text(L10n.Contacts.Contacts.Trace.Map.viewResults) + } + .liquidGlassButtonStyle() + .liquidGlassID("viewResults", in: buttonNamespace) } - .animation(.spring(response: 0.3), value: mapViewModel.hasPath) - .animation(.spring(response: 0.3), value: mapViewModel.result?.id) - .padding(.bottom, 24) + } } + } + .animation(.spring(response: 0.3), value: mapViewModel.hasPath) + .animation(.spring(response: 0.3), value: mapViewModel.result?.id) + .padding(.bottom, 24) } + } } diff --git a/MC1/Views/Tools/TracePath/Map/TracePathMapToolbarView.swift b/MC1/Views/Tools/TracePath/Map/TracePathMapToolbarView.swift index 02b51dba..f08978de 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathMapToolbarView.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathMapToolbarView.swift @@ -1,57 +1,42 @@ import MapKit import MapLibre -import SwiftUI import MC1Services +import SwiftUI /// Map controls toolbar for trace path map view (location, labels, layers) struct TracePathMapToolbarView: View { - @Environment(\.appState) private var appState - @Bindable var mapViewModel: TracePathMapViewModel - @Binding var mapStyleSelection: MapStyleSelection - @Binding var showLabels: Bool + @Environment(\.appState) private var appState + @Bindable var mapViewModel: TracePathMapViewModel + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showLabels: Bool + @Binding var isNorthLocked: Bool + @Binding var isCenteredOnUser: Bool - var body: some View { - VStack { - Spacer() - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: { - if let location = appState.bestAvailableLocation { - mapViewModel.setCameraRegion(MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - )) - } else { - appState.locationService.requestLocation() - } - }, - isNorthLocked: $mapViewModel.isNorthLocked, - showLabels: $showLabels, - showingLayersMenu: $mapViewModel.showingLayersMenu - ) { - // Center on path - if mapViewModel.hasPath { - Button(L10n.Contacts.Contacts.Trace.Map.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { - mapViewModel.centerOnPath() - } - .mapControlButton(tint: .primary) - } - } - } - } - .overlay(alignment: .bottomTrailing) { - if mapViewModel.showingLayersMenu { - LayersMenu( - selection: $mapStyleSelection, - isPresented: $mapViewModel.showingLayersMenu, - viewportBounds: mapViewModel.cameraRegion?.toMLNCoordinateBounds() - ) - .padding(.trailing, 16) - .padding(.bottom, 160) - .transition(.scale.combined(with: .opacity)) + var body: some View { + VStack { + Spacer() + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: { + isCenteredOnUser = appState.centerOnUserLocation { mapViewModel.setCameraRegion($0) } + }, + isCenteredOnUser: isCenteredOnUser, + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + mapStyleSelection: $mapStyleSelection, + viewportBounds: mapViewModel.cameraRegion?.toMLNCoordinateBounds() + ) { + // Center on path + if mapViewModel.hasPath { + Button(L10n.Contacts.Contacts.Trace.Map.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { + isCenteredOnUser = false + mapViewModel.centerOnPath() } + .mapControlButton(tint: .primary) + } } - .animation(.spring(response: 0.3), value: mapViewModel.showingLayersMenu) + } } + } } diff --git a/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift b/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift index 1072075c..61fdd58b 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift @@ -1,176 +1,149 @@ import MapKit -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "TracePathMapView") /// Map-based view for building and visualizing trace paths struct TracePathMapView: View { - @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme - @Bindable var traceViewModel: TracePathViewModel - @Binding var presentedResult: TraceResult? - @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard - @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels - @State private var mapViewModel = TracePathMapViewModel() - - @State private var showingSavePrompt = false - @State private var saveName = "" - @State private var showingClearConfirmation = false - @State private var showingSaveSuccess = false - @State private var errorMessage: String? - @State private var pinTapHaptic = 0 - @State private var rejectedTapHaptic = 0 - - @Namespace private var buttonNamespace - - var body: some View { - ZStack { - mapContent - - // Results banner at top - if let result = mapViewModel.result, result.success { - TracePathResultsBanner( - result: result, - totalPathDistance: traceViewModel.totalPathDistance - ) - } - - // Floating buttons - TracePathFloatingButtonsView( - mapViewModel: mapViewModel, - showingClearConfirmation: $showingClearConfirmation, - presentedResult: $presentedResult, - buttonNamespace: buttonNamespace - ) - - // Map controls toolbar - TracePathMapToolbarView( - mapViewModel: mapViewModel, - mapStyleSelection: $mapStyleSelection, - showLabels: $showLabels - ) - } - .onAppear { - mapViewModel.configure( - traceViewModel: traceViewModel, - userLocation: appState.bestAvailableLocation - ) - mapViewModel.showLabels = showLabels - mapViewModel.rebuildOverlays() - mapViewModel.performInitialCentering() - } - .onChange(of: showLabels) { _, newValue in - mapViewModel.showLabels = newValue - } - .onChange(of: appState.bestAvailableLocation) { old, new in - guard old?.coordinate.latitude != new?.coordinate.latitude - || old?.coordinate.longitude != new?.coordinate.longitude else { return } - mapViewModel.updateUserLocation(new) - } - .onChange(of: traceViewModel.availableNodes) { _, _ in - mapViewModel.rebuildPathState() - if !mapViewModel.hasInitiallyCenteredOnRepeaters && !mapViewModel.repeatersWithLocation.isEmpty { - mapViewModel.performInitialCentering() - } - } - .onChange(of: traceViewModel.resultID) { _, _ in - mapViewModel.updateOverlaysWithResults() - } - .alert(L10n.Contacts.Contacts.Trace.Map.saveTitle, isPresented: $showingSavePrompt) { - TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $saveName) - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { - saveName = "" - } - Button(L10n.Contacts.Contacts.Common.save) { - Task { - let success = await mapViewModel.savePath(name: saveName) - saveName = "" - if success { - showingSaveSuccess = true - } else { - errorMessage = L10n.Contacts.Contacts.Trace.Map.saveFailedMessage - } - } - } - } message: { - Text(L10n.Contacts.Contacts.Trace.Map.saveMessage) - } - .sensoryFeedback(.impact(weight: .light), trigger: pinTapHaptic) - .sensoryFeedback(.warning, trigger: rejectedTapHaptic) - .alert(L10n.Contacts.Contacts.Trace.Map.savedTitle, isPresented: $showingSaveSuccess) { - Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} - } message: { - Text(L10n.Contacts.Contacts.Trace.Map.savedMessage) - } - .errorAlert($errorMessage, title: L10n.Contacts.Contacts.Trace.Map.saveFailedTitle) - } - - // MARK: - Map Content - - private var mapContent: some View { - MC1MapView( - points: mapViewModel.mapPoints, - lines: mapViewModel.mapLines, - mapStyle: mapStyleSelection, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: showLabels, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - isNorthLocked: mapViewModel.isNorthLocked, - cameraRegion: $mapViewModel.cameraRegion, - cameraRegionVersion: mapViewModel.cameraRegionVersion, - cameraBottomSheetFraction: 0, - onPointTap: { point, _ in - if let repeater = mapViewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { - let result = mapViewModel.handleRepeaterTap(repeater) - if result == .rejectedMiddleHop { - rejectedTapHaptic += 1 - } else { - pinTapHaptic += 1 - } - } - }, - onMapTap: nil, - onCameraRegionChange: { region in - mapViewModel.cameraRegion = region - }, + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + @Bindable var traceViewModel: TracePathViewModel + @Binding var presentedResult: TraceResult? + @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels + @AppStorage(AppStorageKey.mapNorthLocked.rawValue) private var isNorthLocked = AppStorageKey.defaultMapNorthLocked + @State private var mapViewModel = TracePathMapViewModel() + + @State private var showingSavePrompt = false + @State private var saveName = "" + @State private var showingClearConfirmation = false + @State private var showingSaveSuccess = false + @State private var errorMessage: String? + @State private var pinTapHaptic = 0 + @State private var rejectedTapHaptic = 0 + @State private var isCenteredOnUser = false + + @Namespace private var buttonNamespace + + var body: some View { + ZStack { + mapContent + + // Results banner at top + if let result = mapViewModel.result, result.success { + PathDistanceBanner( + hopCount: result.hops.count - 2, + totalPathDistance: traceViewModel.totalPathDistance ) - .ignoresSafeArea() + } + + // Floating buttons + TracePathFloatingButtonsView( + mapViewModel: mapViewModel, + showingClearConfirmation: $showingClearConfirmation, + presentedResult: $presentedResult, + buttonNamespace: buttonNamespace + ) + + // Map controls toolbar + TracePathMapToolbarView( + mapViewModel: mapViewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels, + isNorthLocked: $isNorthLocked, + isCenteredOnUser: $isCenteredOnUser + ) } - -} - -// MARK: - Results Banner - -private struct TracePathResultsBanner: View { - let result: TraceResult - let totalPathDistance: Double? - - var body: some View { - VStack { - HStack { - let hopCount = result.hops.count - 2 - Text(L10n.Contacts.Contacts.Trace.Map.hops(hopCount)) - - if let distance = totalPathDistance { - Text("•") - Text(Measurement(value: distance, unit: UnitLength.meters), - format: .measurement(width: .abbreviated, usage: .road)) - } - } - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .liquidGlass(in: .capsule) - - Spacer() + .onAppear { + mapViewModel.configure( + traceViewModel: traceViewModel, + userLocation: appState.bestAvailableLocation + ) + mapViewModel.showLabels = showLabels + mapViewModel.rebuildOverlays() + mapViewModel.performInitialCentering() + } + .onChange(of: showLabels) { _, newValue in + mapViewModel.showLabels = newValue + } + .onChange(of: appState.bestAvailableLocation) { old, new in + guard old?.coordinate.latitude != new?.coordinate.latitude + || old?.coordinate.longitude != new?.coordinate.longitude else { return } + mapViewModel.updateUserLocation(new) + } + .onChange(of: traceViewModel.availableNodes) { _, _ in + mapViewModel.rebuildPathState() + if !mapViewModel.hasInitiallyCenteredOnRepeaters, !mapViewModel.repeatersWithLocation.isEmpty { + mapViewModel.performInitialCentering() + } + } + .onChange(of: traceViewModel.resultID) { _, _ in + mapViewModel.updateOverlaysWithResults() + } + .alert(L10n.Contacts.Contacts.Trace.Map.saveTitle, isPresented: $showingSavePrompt) { + TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $saveName) + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { + saveName = "" + } + Button(L10n.Contacts.Contacts.Common.save) { + Task { + let success = await mapViewModel.savePath(name: saveName) + saveName = "" + if success { + showingSaveSuccess = true + } else { + errorMessage = L10n.Contacts.Contacts.Trace.Map.saveFailedMessage + } } - .safeAreaPadding(.top) - .transition(.move(edge: .top).combined(with: .opacity)) - .animation(.spring(response: 0.3), value: result.id) + } + } message: { + Text(L10n.Contacts.Contacts.Trace.Map.saveMessage) } + .sensoryFeedback(.impact(weight: .light), trigger: pinTapHaptic) + .sensoryFeedback(.warning, trigger: rejectedTapHaptic) + .alert(L10n.Contacts.Contacts.Trace.Map.savedTitle, isPresented: $showingSaveSuccess) { + Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} + } message: { + Text(L10n.Contacts.Contacts.Trace.Map.savedMessage) + } + .errorAlert($errorMessage, title: L10n.Contacts.Contacts.Trace.Map.saveFailedTitle) + } + + // MARK: - Map Content + + private var mapContent: some View { + MC1MapView( + points: mapViewModel.mapPoints, + lines: mapViewModel.mapLines, + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: isNorthLocked, + cameraRegion: $mapViewModel.cameraRegion, + cameraRegionVersion: mapViewModel.cameraRegionVersion, + cameraBottomSheetFraction: 0, + onPointTap: { point, _ in + if let repeater = mapViewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { + let result = mapViewModel.handleRepeaterTap(repeater) + if result == .rejectedMiddleHop { + rejectedTapHaptic += 1 + } else { + pinTapHaptic += 1 + } + } + }, + onMapTap: nil, + onCameraRegionChange: { region in + mapViewModel.cameraRegion = region + }, + isCenteredOnUser: $isCenteredOnUser + ) + .ignoresSafeArea() + } } diff --git a/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift b/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift index 402429b7..8c2df4ac 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift @@ -1,8 +1,8 @@ import CoreLocation import MapKit -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "TracePathMap") @@ -10,408 +10,381 @@ private let logger = Logger(subsystem: "com.mc1", category: "TracePathMap") @Observable @MainActor final class TracePathMapViewModel { - - // MARK: - Map State - - var cameraRegion: MKCoordinateRegion? - /// Incremented when code intentionally moves the camera (not from user gesture sync) - private(set) var cameraRegionVersion = 0 - var showLabels: Bool = true { - didSet { rebuildMapPoints() } - } - var isNorthLocked = false - var showingLayersMenu: Bool = false - - /// Tracks whether initial centering on repeaters has been performed - private(set) var hasInitiallyCenteredOnRepeaters = false - - // MARK: - Path Overlays - - private(set) var mapLines: [MapLine] = [] - private(set) var badgePoints: [MapPoint] = [] - private(set) var mapPoints: [MapPoint] = [] - - // MARK: - Dependencies - - private weak var traceViewModel: TracePathViewModel? - private var userLocation: CLLocation? - private var lastRebuildLocation: CLLocation? - - // MARK: - Path State - - struct RepeaterPathInfo { - let inPath: Bool - let hopIndex: Int? - let isLastHop: Bool - } - - /// Pre-computed path membership for all repeaters, keyed by repeater ID. - /// Stored to avoid reallocation on every body eval. Rebuilt via `rebuildPathState()`. - private(set) var pathState: [UUID: RepeaterPathInfo] = [:] - - // MARK: - Computed Properties - - /// Repeaters and rooms to display on map - var repeatersWithLocation: [ContactDTO] { - traceViewModel?.availableNodes.filter { $0.hasLocation } ?? [] + // MARK: - Map State + + var cameraRegion: MKCoordinateRegion? + /// Incremented when code intentionally moves the camera (not from user gesture sync) + private(set) var cameraRegionVersion = 0 + var showLabels: Bool = true { + didSet { rebuildMapPoints() } + } + + /// Tracks whether initial centering on repeaters has been performed + private(set) var hasInitiallyCenteredOnRepeaters = false + + // MARK: - Path Overlays + + private(set) var mapLines: [MapLine] = [] + private(set) var badgePoints: [MapPoint] = [] + private(set) var mapPoints: [MapPoint] = [] + + // MARK: - Dependencies + + private weak var traceViewModel: TracePathViewModel? + private var userLocation: CLLocation? + private var lastRebuildLocation: CLLocation? + + // MARK: - Path State + + struct RepeaterPathInfo { + let inPath: Bool + let hopIndex: Int? + let isLastHop: Bool + } + + /// Pre-computed path membership for all repeaters, keyed by repeater ID. + /// Stored to avoid reallocation on every body eval. Rebuilt via `rebuildPathState()`. + private(set) var pathState: [UUID: RepeaterPathInfo] = [:] + + // MARK: - Computed Properties + + /// Repeaters and rooms to display on map + var repeatersWithLocation: [ContactDTO] { + traceViewModel?.availableNodes.filter(\.hasLocation) ?? [] + } + + /// Whether a path has been built (at least one hop) + var hasPath: Bool { + !(traceViewModel?.outboundPath.isEmpty ?? true) + } + + /// Whether trace can be run (when connected) + var canRunTrace: Bool { + traceViewModel?.canRunTraceWhenConnected ?? false + } + + /// Whether trace is currently running + var isRunning: Bool { + traceViewModel?.isRunning ?? false + } + + /// Whether a successful result exists that can be saved + var canSave: Bool { + traceViewModel?.canSavePath ?? false + } + + /// Current trace result + var result: TraceResult? { + traceViewModel?.result + } + + // MARK: - Configuration + + func configure(traceViewModel: TracePathViewModel, userLocation: CLLocation?) { + self.traceViewModel = traceViewModel + self.userLocation = userLocation + } + + func updateUserLocation(_ location: CLLocation?) { + userLocation = location + + // Only rebuild if the path is non-empty and user moved meaningfully + guard traceViewModel?.outboundPath.isEmpty == false else { return } + if let location, let last = lastRebuildLocation, location.distance(from: last) < 10 { return } + lastRebuildLocation = location + rebuildOverlays() + } + + // MARK: - Path State Rebuild + + /// Rebuilds stored `pathState` and `mapPoints`. Call when path, available nodes, or user location changes. + func rebuildPathState() { + let repeaters = repeatersWithLocation + + var pathLookup: [UUID: (index: Int, isLast: Bool)] = [:] + if let path = traceViewModel?.outboundPath { + for (index, hop) in path.enumerated() { + if let repeater = findRepeater(for: hop) { + pathLookup[repeater.id] = (index: index + 1, isLast: index == path.count - 1) + } + } } - /// Whether a path has been built (at least one hop) - var hasPath: Bool { - !(traceViewModel?.outboundPath.isEmpty ?? true) + var state: [UUID: RepeaterPathInfo] = [:] + state.reserveCapacity(repeaters.count) + for repeater in repeaters { + if let info = pathLookup[repeater.id] { + state[repeater.id] = RepeaterPathInfo(inPath: true, hopIndex: info.index, isLastHop: info.isLast) + } else { + state[repeater.id] = RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) + } } - - /// Whether trace can be run (when connected) - var canRunTrace: Bool { - traceViewModel?.canRunTraceWhenConnected ?? false + pathState = state + rebuildMapPoints(repeaters: repeaters) + } + + private func rebuildMapPoints(repeaters: [ContactDTO]? = nil) { + let nodes = repeaters ?? repeatersWithLocation + var points: [MapPoint] = [] + for repeater in nodes { + let info = pathState[repeater.id] + let inPath = info?.inPath ?? false + points.append(MapPoint( + id: repeater.id, + coordinate: repeater.coordinate, + pinStyle: inPath ? .repeaterRingWhite : .repeater, + label: showLabels ? repeater.displayName : nil, + isClusterable: false, + hopIndex: info?.hopIndex, + badgeText: nil + )) } - - /// Whether trace is currently running - var isRunning: Bool { - traceViewModel?.isRunning ?? false + points.append(contentsOf: badgePoints) + mapPoints = points + } + + // MARK: - Path Building + + /// Find the repeater or room for a hop using full public key or RepeaterResolver fallback. + private func findRepeater(for hop: PathHop) -> ContactDTO? { + RepeaterResolver.bestMatch(for: hop, in: traceViewModel?.availableNodes ?? [], userLocation: userLocation) + } + + enum RepeaterTapResult { + case added + case removed + case rejectedMiddleHop + case ignored + } + + /// Handle tap on a repeater, returns the result of the tap action + @discardableResult + func handleRepeaterTap(_ repeater: ContactDTO) -> RepeaterTapResult { + guard let traceViewModel else { return .ignored } + + let info = pathState[repeater.id] + let result: RepeaterTapResult + if info?.isLastHop == true { + if let lastIndex = traceViewModel.outboundPath.indices.last { + traceViewModel.removeRepeater(at: lastIndex) + } + result = .removed + } else if info?.inPath != true { + traceViewModel.addNode(repeater) + result = .added + } else { + result = .rejectedMiddleHop } - /// Whether a successful result exists that can be saved - var canSave: Bool { - traceViewModel?.canSavePath ?? false - } + rebuildOverlays() + return result + } - /// Current trace result - var result: TraceResult? { - traceViewModel?.result - } + /// Clear the path + func clearPath() { + traceViewModel?.clearPath() + clearOverlays() + rebuildPathState() + } - // MARK: - Configuration + // MARK: - Trace Execution - func configure(traceViewModel: TracePathViewModel, userLocation: CLLocation?) { - self.traceViewModel = traceViewModel - self.userLocation = userLocation - } + func runTrace() async { + centerOnPath() + traceViewModel?.batchEnabled = false + await traceViewModel?.runTrace() + } - func updateUserLocation(_ location: CLLocation?) { - self.userLocation = location + func savePath(name: String) async -> Bool { + await traceViewModel?.savePath(name: name) ?? false + } - // Only rebuild if the path is non-empty and user moved meaningfully - guard traceViewModel?.outboundPath.isEmpty == false else { return } - if let location, let last = lastRebuildLocation, location.distance(from: last) < 10 { return } - lastRebuildLocation = location - rebuildOverlays() - } + func generatePathName() -> String { + traceViewModel?.generatePathName() ?? L10n.Contacts.Contacts.Trace.Map.defaultPathName + } - // MARK: - Path State Rebuild + // MARK: - Overlay Management - /// Rebuilds stored `pathState` and `mapPoints`. Call when path, available nodes, or user location changes. - func rebuildPathState() { - let repeaters = repeatersWithLocation + /// Rebuild map lines based on current path + func rebuildOverlays() { + clearOverlays() + rebuildPathState() - var pathLookup: [UUID: (index: Int, isLast: Bool)] = [:] - if let path = traceViewModel?.outboundPath { - for (index, hop) in path.enumerated() { - if let repeater = findRepeater(for: hop) { - pathLookup[repeater.id] = (index: index + 1, isLast: index == path.count - 1) - } - } - } + guard let traceViewModel, + !traceViewModel.outboundPath.isEmpty else { return } - var state: [UUID: RepeaterPathInfo] = [:] - state.reserveCapacity(repeaters.count) - for repeater in repeaters { - if let info = pathLookup[repeater.id] { - state[repeater.id] = RepeaterPathInfo(inPath: true, hopIndex: info.index, isLastHop: info.isLast) - } else { - state[repeater.id] = RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) - } - } - pathState = state - rebuildMapPoints(repeaters: repeaters) + var previousCoordinate: CLLocationCoordinate2D? + if let userLocation { + previousCoordinate = userLocation.coordinate } - private func rebuildMapPoints(repeaters: [ContactDTO]? = nil) { - let nodes = repeaters ?? repeatersWithLocation - var points: [MapPoint] = [] - for repeater in nodes { - let info = pathState[repeater.id] - let inPath = info?.inPath ?? false - points.append(MapPoint( - id: repeater.id, - coordinate: repeater.coordinate, - pinStyle: inPath ? .repeaterRingWhite : .repeater, - label: showLabels ? repeater.displayName : nil, - isClusterable: false, - hopIndex: info?.hopIndex, - badgeText: nil - )) - } - points.append(contentsOf: badgePoints) - mapPoints = points - } + for (index, hop) in traceViewModel.outboundPath.enumerated() { + guard let repeater = findRepeater(for: hop), + repeater.hasLocation else { continue } - // MARK: - Path Building + let hopCoordinate = CLLocationCoordinate2D( + latitude: repeater.latitude, + longitude: repeater.longitude + ) - /// Find the repeater or room for a hop using full public key or RepeaterResolver fallback. - private func findRepeater(for hop: PathHop) -> ContactDTO? { - RepeaterResolver.bestMatch(for: hop, in: traceViewModel?.availableNodes ?? [], userLocation: userLocation) - } + guard CLLocationCoordinate2DIsValid(hopCoordinate) else { continue } - enum RepeaterTapResult { - case added - case removed - case rejectedMiddleHop - case ignored - } + if let prevCoord = previousCoordinate, CLLocationCoordinate2DIsValid(prevCoord) { + mapLines.append(MapLine( + id: "trace-\(index)", + coordinates: [prevCoord, hopCoordinate], + style: .traceUntraced, + opacity: 1.0, + pathIndex: index + )) + } - /// Handle tap on a repeater, returns the result of the tap action - @discardableResult - func handleRepeaterTap(_ repeater: ContactDTO) -> RepeaterTapResult { - guard let traceViewModel else { return .ignored } - - let info = pathState[repeater.id] - let result: RepeaterTapResult - if info?.isLastHop == true { - if let lastIndex = traceViewModel.outboundPath.indices.last { - traceViewModel.removeRepeater(at: lastIndex) - } - result = .removed - } else if info?.inPath != true { - traceViewModel.addNode(repeater) - result = .added - } else { - result = .rejectedMiddleHop + previousCoordinate = hopCoordinate + } + } + + /// Update lines with trace results and add badge points at segment midpoints + func updateOverlaysWithResults() { + guard let result = traceViewModel?.result, result.success else { return } + + badgePoints.removeAll() + + var updatedLines: [MapLine] = [] + for line in mapLines { + guard let pathIndex = line.pathIndex else { + updatedLines.append(line) + continue + } + let hopIndex = pathIndex + 1 + if hopIndex < result.hops.count { + let hop = result.hops[hopIndex] + let style = MapLine.LineStyle.forSNR(hop.snr) + + updatedLines.append(MapLine( + id: line.id, + coordinates: line.coordinates, + style: style, + opacity: 1.0, + pathIndex: pathIndex + )) + + // Badge at midpoint + if line.coordinates.count >= 2 { + badgePoints.append(MapLine.snrBadge( + id: UUID(hopIndex: hopIndex), + from: line.coordinates[0], + to: line.coordinates[1], + snr: hop.snr + )) } - - rebuildOverlays() - return result + } else { + updatedLines.append(line) + } } - /// Clear the path - func clearPath() { - traceViewModel?.clearPath() - clearOverlays() - rebuildPathState() - } + mapLines = updatedLines + rebuildMapPoints() + } - // MARK: - Trace Execution + /// Clear all overlays + func clearOverlays() { + mapLines.removeAll() + badgePoints.removeAll() + } - func runTrace() async { - centerOnPath() - traceViewModel?.batchEnabled = false - await traceViewModel?.runTrace() - } + // MARK: - Camera - func savePath(name: String) async -> Bool { - await traceViewModel?.savePath(name: name) ?? false - } + /// Center map on all path points + func centerOnPath() { + var coordinates: [CLLocationCoordinate2D] = [] - func generatePathName() -> String { - traceViewModel?.generatePathName() ?? L10n.Contacts.Contacts.Trace.Map.defaultPathName + if let userLocation { + coordinates.append(userLocation.coordinate) } - // MARK: - Overlay Management - - /// Rebuild map lines based on current path - func rebuildOverlays() { - clearOverlays() - rebuildPathState() - - guard let traceViewModel, - !traceViewModel.outboundPath.isEmpty else { return } - - var previousCoordinate: CLLocationCoordinate2D? - if let userLocation { - previousCoordinate = userLocation.coordinate - } - - for (index, hop) in traceViewModel.outboundPath.enumerated() { - guard let repeater = findRepeater(for: hop), - repeater.hasLocation else { continue } - - let hopCoordinate = CLLocationCoordinate2D( - latitude: repeater.latitude, - longitude: repeater.longitude - ) - - guard CLLocationCoordinate2DIsValid(hopCoordinate) else { continue } - - if let prevCoord = previousCoordinate, CLLocationCoordinate2DIsValid(prevCoord) { - mapLines.append(MapLine( - id: "trace-\(index)", - coordinates: [prevCoord, hopCoordinate], - style: .traceUntraced, - opacity: 1.0, - pathIndex: index - )) - } - - previousCoordinate = hopCoordinate - } + for line in mapLines { + coordinates.append(contentsOf: line.coordinates) } - /// Update lines with trace results and add badge points at segment midpoints - func updateOverlaysWithResults() { - guard let result = traceViewModel?.result, result.success else { return } - - badgePoints.removeAll() - - var updatedLines: [MapLine] = [] - for line in mapLines { - guard let pathIndex = line.pathIndex else { - updatedLines.append(line) - continue - } - let hopIndex = pathIndex + 1 - if hopIndex < result.hops.count { - let hop = result.hops[hopIndex] - let style = lineStyle(for: hop.snr) - - updatedLines.append(MapLine( - id: line.id, - coordinates: line.coordinates, - style: style, - opacity: 1.0, - pathIndex: pathIndex - )) - - // Badge at midpoint - if line.coordinates.count >= 2 { - let mid = CLLocationCoordinate2D( - latitude: (line.coordinates[0].latitude + line.coordinates[1].latitude) / 2, - longitude: (line.coordinates[0].longitude + line.coordinates[1].longitude) / 2 - ) - let distance = CLLocation(latitude: line.coordinates[0].latitude, longitude: line.coordinates[0].longitude) - .distance(from: CLLocation(latitude: line.coordinates[1].latitude, longitude: line.coordinates[1].longitude)) - let distFormatted = Measurement(value: distance, unit: UnitLength.meters) - .formatted(.measurement(width: .abbreviated, usage: .road)) - let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) - - badgePoints.append(MapPoint( - id: UUID(hopIndex: hopIndex), - coordinate: mid, - pinStyle: .badge, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: "\(distFormatted) · \(snrFormatted) dB" - )) - } - } else { - updatedLines.append(line) - } - } + setCameraRegion(fitting: coordinates) + } - mapLines = updatedLines - rebuildMapPoints() + /// Center map to show all repeaters + func centerOnAllRepeaters() { + let repeaters = repeatersWithLocation + guard !repeaters.isEmpty else { + cameraRegion = nil + return } - // MARK: - Signal Quality - - private func lineStyle(for snr: Double?) -> MapLine.LineStyle { - switch SNRQuality(snr: snr) { - case .excellent, .good: .traceGood - case .fair: .traceMedium - case .poor: .traceWeak - case .unknown: .traceUntraced - } + let coordinates = repeaters.map(\.coordinate) + setCameraRegion(fitting: coordinates) + hasInitiallyCenteredOnRepeaters = true + } + + /// Perform initial centering based on current state + /// Centers on path if one exists, otherwise centers on all repeaters + func performInitialCentering() { + if hasPath { + centerOnPathRepeaters() + } else { + centerOnAllRepeaters() } + } - /// Clear all overlays - func clearOverlays() { - mapLines.removeAll() - badgePoints.removeAll() + /// Center map on path repeaters directly (doesn't depend on overlays) + private func centerOnPathRepeaters() { + guard let traceViewModel else { + centerOnAllRepeaters() + return } - // MARK: - Camera - - /// Center map on all path points - func centerOnPath() { - var coordinates: [CLLocationCoordinate2D] = [] - - if let userLocation { - coordinates.append(userLocation.coordinate) - } - - for line in mapLines { - coordinates.append(contentsOf: line.coordinates) - } + var coordinates: [CLLocationCoordinate2D] = [] - setCameraRegion(fitting: coordinates) + if let userLocation { + coordinates.append(userLocation.coordinate) } - /// Center map to show all repeaters - func centerOnAllRepeaters() { - let repeaters = repeatersWithLocation - guard !repeaters.isEmpty else { - cameraRegion = nil - return - } - - let coordinates = repeaters.map(\.coordinate) - setCameraRegion(fitting: coordinates) - hasInitiallyCenteredOnRepeaters = true + for hop in traceViewModel.outboundPath { + guard let repeater = findRepeater(for: hop), + repeater.hasLocation else { + continue + } + + let coord = CLLocationCoordinate2D( + latitude: repeater.latitude, + longitude: repeater.longitude + ) + if CLLocationCoordinate2DIsValid(coord) { + coordinates.append(coord) + } } - /// Perform initial centering based on current state - /// Centers on path if one exists, otherwise centers on all repeaters - func performInitialCentering() { - if hasPath { - centerOnPathRepeaters() - } else { - centerOnAllRepeaters() - } + guard !coordinates.isEmpty else { + centerOnAllRepeaters() + return } - /// Center map on path repeaters directly (doesn't depend on overlays) - private func centerOnPathRepeaters() { - guard let traceViewModel else { - centerOnAllRepeaters() - return - } + setCameraRegion(fitting: coordinates) + hasInitiallyCenteredOnRepeaters = true + } - var coordinates: [CLLocationCoordinate2D] = [] - - if let userLocation { - coordinates.append(userLocation.coordinate) - } - - for hop in traceViewModel.outboundPath { - guard let repeater = findRepeater(for: hop), - repeater.hasLocation else { - continue - } - - let coord = CLLocationCoordinate2D( - latitude: repeater.latitude, - longitude: repeater.longitude - ) - if CLLocationCoordinate2DIsValid(coord) { - coordinates.append(coord) - } - } + func setCameraRegion(_ region: MKCoordinateRegion) { + cameraRegion = region + cameraRegionVersion += 1 + } - guard !coordinates.isEmpty else { - centerOnAllRepeaters() - return - } - - setCameraRegion(fitting: coordinates) - hasInitiallyCenteredOnRepeaters = true - } - - func setCameraRegion(_ region: MKCoordinateRegion) { - cameraRegion = region - cameraRegionVersion += 1 - } - - private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { - guard let region = coordinates.boundingRegion() else { return } - setCameraRegion(region) - } + private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { + guard let region = coordinates.boundingRegion() else { return } + setCameraRegion(region) + } } private extension UUID { - /// Deterministic UUID for badge points keyed by hop index. - init(hopIndex: Int) { - let hex = String(hopIndex, radix: 16) - let padded = String(repeating: "0", count: max(0, 12 - hex.count)) + hex - self = UUID(uuidString: "00000000-0000-0000-0000-\(padded)") ?? UUID() - } + /// Deterministic UUID for badge points keyed by hop index. + init(hopIndex: Int) { + let hex = String(hopIndex, radix: 16) + let padded = String(repeating: "0", count: max(0, 12 - hex.count)) + hex + self = UUID(uuidString: "00000000-0000-0000-0000-\(padded)") ?? UUID() + } } diff --git a/MC1/Views/Tools/TracePath/MiniSparkline.swift b/MC1/Views/Tools/TracePath/MiniSparkline.swift index 34d423a8..97cc426f 100644 --- a/MC1/Views/Tools/TracePath/MiniSparkline.swift +++ b/MC1/Views/Tools/TracePath/MiniSparkline.swift @@ -1,35 +1,35 @@ import SwiftUI struct MiniSparkline: View { - let values: [Int] + let values: [Int] - var body: some View { - GeometryReader { geometry in - if let minVal = values.min(), let maxVal = values.max(), maxVal > minVal { - Path { path in - let range = Double(maxVal - minVal) - let stepX = geometry.size.width / Double(max(values.count - 1, 1)) + var body: some View { + GeometryReader { geometry in + if let minVal = values.min(), let maxVal = values.max(), maxVal > minVal { + Path { path in + let range = Double(maxVal - minVal) + let stepX = geometry.size.width / Double(max(values.count - 1, 1)) - for (index, value) in values.enumerated() { - let x = Double(index) * stepX - let y = geometry.size.height - (Double(value - minVal) / range * geometry.size.height) + for (index, value) in values.enumerated() { + let x = Double(index) * stepX + let y = geometry.size.height - (Double(value - minVal) / range * geometry.size.height) - if index == 0 { - path.move(to: CGPoint(x: x, y: y)) - } else { - path.addLine(to: CGPoint(x: x, y: y)) - } - } - } - .stroke(Color.accentColor, lineWidth: 1.5) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) } else { - // Flat line for constant values - Path { path in - path.move(to: CGPoint(x: 0, y: geometry.size.height / 2)) - path.addLine(to: CGPoint(x: geometry.size.width, y: geometry.size.height / 2)) - } - .stroke(Color.accentColor, lineWidth: 1.5) + path.addLine(to: CGPoint(x: x, y: y)) } + } } + .stroke(Color.accentColor, lineWidth: 1.5) + } else { + // Flat line for constant values + Path { path in + path.move(to: CGPoint(x: 0, y: geometry.size.height / 2)) + path.addLine(to: CGPoint(x: geometry.size.width, y: geometry.size.height / 2)) + } + .stroke(Color.accentColor, lineWidth: 1.5) + } } + } } diff --git a/MC1/Views/Tools/TracePath/SavePathRowView.swift b/MC1/Views/Tools/TracePath/SavePathRowView.swift index 52590a3a..c5a0d91b 100644 --- a/MC1/Views/Tools/TracePath/SavePathRowView.swift +++ b/MC1/Views/Tools/TracePath/SavePathRowView.swift @@ -1,58 +1,58 @@ -import SwiftUI import MC1Services +import SwiftUI /// Row allowing the user to save the current trace path struct SavePathRowView: View { - @Bindable var viewModel: TracePathViewModel - @Binding var saveHapticTrigger: Int + @Bindable var viewModel: TracePathViewModel + @Binding var saveHapticTrigger: Int - @State private var showingSaveDialog = false - @State private var savePathName = "" + @State private var showingSaveDialog = false + @State private var savePathName = "" - var body: some View { - if showingSaveDialog { - VStack(alignment: .leading, spacing: 8) { - TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $savePathName) - .textFieldStyle(.roundedBorder) + var body: some View { + if showingSaveDialog { + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $savePathName) + .textFieldStyle(.roundedBorder) - HStack { - Button(L10n.Contacts.Contacts.Common.cancel) { - showingSaveDialog = false - savePathName = "" - } - .buttonStyle(.bordered) + HStack { + Button(L10n.Contacts.Contacts.Common.cancel) { + showingSaveDialog = false + savePathName = "" + } + .buttonStyle(.bordered) - Spacer() + Spacer() - Button(L10n.Contacts.Contacts.Common.save) { - Task { - let success = await viewModel.savePath(name: savePathName) - if success { - saveHapticTrigger += 1 - } - showingSaveDialog = false - savePathName = "" - } - } - .buttonStyle(.borderedProminent) - .disabled(savePathName.trimmingCharacters(in: .whitespaces).isEmpty || !viewModel.canSavePath) - } + Button(L10n.Contacts.Contacts.Common.save) { + Task { + let success = await viewModel.savePath(name: savePathName) + if success { + saveHapticTrigger += 1 + } + showingSaveDialog = false + savePathName = "" } - .padding(.vertical, 4) - } else { - Button { - savePathName = viewModel.generatePathName() - showingSaveDialog = true - } label: { - HStack { - Label(L10n.Contacts.Contacts.Results.savePath, systemImage: "bookmark") - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(.secondary) - } - } - .foregroundStyle(.primary) - .disabled(!viewModel.canSavePath) + } + .buttonStyle(.borderedProminent) + .disabled(savePathName.trimmingCharacters(in: .whitespaces).isEmpty || !viewModel.canSavePath) + } + } + .padding(.vertical, 4) + } else { + Button { + savePathName = viewModel.generatePathName() + showingSaveDialog = true + } label: { + HStack { + Label(L10n.Contacts.Contacts.Results.savePath, systemImage: "bookmark") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) } + } + .foregroundStyle(.primary) + .disabled(!viewModel.canSavePath) } + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathDetailView.swift b/MC1/Views/Tools/TracePath/SavedPathDetailView.swift index abd5066d..91d13675 100644 --- a/MC1/Views/Tools/TracePath/SavedPathDetailView.swift +++ b/MC1/Views/Tools/TracePath/SavedPathDetailView.swift @@ -1,193 +1,193 @@ -import SwiftUI import Charts import MC1Services +import SwiftUI struct SavedPathDetailView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel: SavedPathDetailViewModel - - init(savedPath: SavedTracePathDTO) { - _viewModel = State(initialValue: SavedPathDetailViewModel(savedPath: savedPath)) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel: SavedPathDetailViewModel + + init(savedPath: SavedTracePathDTO) { + _viewModel = State(initialValue: SavedPathDetailViewModel(savedPath: savedPath)) + } + + var body: some View { + List { + pathSection + .themedRowBackground(theme) + performanceSection + .themedRowBackground(theme) + historySection + .themedRowBackground(theme) } - - var body: some View { - List { - pathSection - .themedRowBackground(theme) - performanceSection - .themedRowBackground(theme) - historySection - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationDestination(for: TracePathRoute.RunDetail.self) { route in - RunDetailView(run: route.run) - } - .navigationTitle(viewModel.savedPath.name) - .navigationBarTitleDisplayMode(.inline) - .task { - viewModel.configure(dataStore: { appState.services?.dataStore }) - } - .refreshable { - await viewModel.refresh() - } + .themedCanvas(theme) + .navigationDestination(for: TracePathRoute.RunDetail.self) { route in + RunDetailView(run: route.run) + } + .navigationTitle(viewModel.savedPath.name) + .navigationBarTitleDisplayMode(.inline) + .task { + viewModel.configure(dataStore: { appState.services?.dataStore }) } + .refreshable { + await viewModel.refresh() + } + } - // MARK: - Path Section + // MARK: - Path Section - private var pathSection: some View { - Section(L10n.Contacts.Contacts.PathDetail.path) { - PathChipsView(pathData: viewModel.savedPath.pathBytes, hashSize: viewModel.hashSize) + private var pathSection: some View { + Section(L10n.Contacts.Contacts.PathDetail.path) { + PathChipsView(pathData: viewModel.savedPath.pathBytes, hashSize: viewModel.hashSize) + } + } + + // MARK: - Performance Section + + private var performanceSection: some View { + Section(L10n.Contacts.Contacts.PathDetail.performance) { + // Chart + if viewModel.successfulRuns.count >= 2 { + Chart(viewModel.successfulRuns) { run in + LineMark( + x: .value("Date", run.date), + y: .value("RTT", run.roundTripMs) + ) + .foregroundStyle(.blue) + + PointMark( + x: .value("Date", run.date), + y: .value("RTT", run.roundTripMs) + ) + .foregroundStyle(.blue) } + .frame(height: 150) + .chartYAxisLabel(L10n.Contacts.Contacts.PathDetail.roundTripMs) + } + + // Summary stats + HStack { + StatView(label: L10n.Contacts.Contacts.PathDetail.avg, value: viewModel.averageRoundTrip.map { "\($0) ms" } ?? "-") + Divider() + StatView(label: L10n.Contacts.Contacts.PathDetail.best, value: viewModel.bestRoundTrip.map { "\($0) ms" } ?? "-") + Divider() + StatView(label: L10n.Contacts.Contacts.PathDetail.success, value: viewModel.successRateText) + } + .frame(height: 50) } - - // MARK: - Performance Section - - private var performanceSection: some View { - Section(L10n.Contacts.Contacts.PathDetail.performance) { - // Chart - if viewModel.successfulRuns.count >= 2 { - Chart(viewModel.successfulRuns) { run in - LineMark( - x: .value("Date", run.date), - y: .value("RTT", run.roundTripMs) - ) - .foregroundStyle(.blue) - - PointMark( - x: .value("Date", run.date), - y: .value("RTT", run.roundTripMs) - ) - .foregroundStyle(.blue) - } - .frame(height: 150) - .chartYAxisLabel(L10n.Contacts.Contacts.PathDetail.roundTripMs) + } + + // MARK: - History Section + + private var historySection: some View { + Section(L10n.Contacts.Contacts.PathDetail.history) { + ForEach(viewModel.sortedRuns) { run in + NavigationLink(value: TracePathRoute.RunDetail(run: run)) { + HStack { + VStack(alignment: .leading) { + Text(run.date.formatted(date: .abbreviated, time: .shortened)) + if run.success { + Text("\(run.roundTripMs) ms") + .font(.caption) + .foregroundStyle(.secondary) + } } - // Summary stats - HStack { - StatView(label: L10n.Contacts.Contacts.PathDetail.avg, value: viewModel.averageRoundTrip.map { "\($0) ms" } ?? "-") - Divider() - StatView(label: L10n.Contacts.Contacts.PathDetail.best, value: viewModel.bestRoundTrip.map { "\($0) ms" } ?? "-") - Divider() - StatView(label: L10n.Contacts.Contacts.PathDetail.success, value: viewModel.successRateText) - } - .frame(height: 50) - } - } + Spacer() - // MARK: - History Section - - private var historySection: some View { - Section(L10n.Contacts.Contacts.PathDetail.history) { - ForEach(viewModel.sortedRuns) { run in - NavigationLink(value: TracePathRoute.RunDetail(run: run)) { - HStack { - VStack(alignment: .leading) { - Text(run.date.formatted(date: .abbreviated, time: .shortened)) - if run.success { - Text("\(run.roundTripMs) ms") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - if !run.success { - Text(L10n.Contacts.Contacts.PathDetail.failed) - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.red.opacity(0.2), in: .capsule) - .foregroundStyle(.red) - } - } - } + if !run.success { + Text(L10n.Contacts.Contacts.PathDetail.failed) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.red.opacity(0.2), in: .capsule) + .foregroundStyle(.red) } + } } + } } + } } // MARK: - Supporting Views private struct PathChipsView: View { - let pathData: Data - let hashSize: Int + let pathData: Data + let hashSize: Int - private var hopHexStrings: [String] { - stride(from: 0, to: pathData.count, by: hashSize).map { start in - let end = min(start + hashSize, pathData.count) - return pathData[start.. 0 { - Image(systemName: "arrow.right") - .font(.caption2) - .foregroundStyle(.secondary) - } - - Text(hex) - .font(.caption.monospaced()) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.fill.tertiary, in: .capsule) - } - } + } + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 4) { + ForEach(Array(hopHexStrings.enumerated()), id: \.offset) { index, hex in + if index > 0 { + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Text(hex) + .font(.caption.monospaced()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.fill.tertiary, in: .capsule) } - .scrollIndicators(.hidden) + } } + .scrollIndicators(.hidden) + } } private struct StatView: View { - let label: String - let value: String - - var body: some View { - VStack { - Text(value) - .font(.headline.monospacedDigit()) - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) + let label: String + let value: String + + var body: some View { + VStack { + Text(value) + .font(.headline.monospacedDigit()) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity) + } } private struct RunDetailView: View { - @Environment(\.appTheme) private var theme - let run: TracePathRunDTO - - var body: some View { - List { - Section(L10n.Contacts.Contacts.PathDetail.overview) { - LabeledContent(L10n.Contacts.Contacts.PathDetail.date, value: run.date.formatted()) - LabeledContent(L10n.Contacts.Contacts.PathDetail.roundTrip, value: "\(run.roundTripMs) ms") - LabeledContent(L10n.Contacts.Contacts.PathDetail.status, value: run.success ? L10n.Contacts.Contacts.PathDetail.success : L10n.Contacts.Contacts.PathDetail.failed) - } - .themedRowBackground(theme) - - if run.success && !run.hopsSNR.isEmpty { - Section(L10n.Contacts.Contacts.PathDetail.perHopSNR) { - ForEach(Array(run.hopsSNR.enumerated()), id: \.offset) { index, snr in - LabeledContent(L10n.Contacts.Contacts.PathDetail.hop(index + 1)) { - Text(snr, format: .number.precision(.fractionLength(2))) - + Text(" dB") - } - } - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + let run: TracePathRunDTO + + var body: some View { + List { + Section(L10n.Contacts.Contacts.PathDetail.overview) { + LabeledContent(L10n.Contacts.Contacts.PathDetail.date, value: run.date.formatted()) + LabeledContent(L10n.Contacts.Contacts.PathDetail.roundTrip, value: "\(run.roundTripMs) ms") + LabeledContent(L10n.Contacts.Contacts.PathDetail.status, value: run.success ? L10n.Contacts.Contacts.PathDetail.success : L10n.Contacts.Contacts.PathDetail.failed) + } + .themedRowBackground(theme) + + if run.success, !run.hopsSNR.isEmpty { + Section(L10n.Contacts.Contacts.PathDetail.perHopSNR) { + ForEach(Array(run.hopsSNR.enumerated()), id: \.offset) { index, snr in + LabeledContent(L10n.Contacts.Contacts.PathDetail.hop(index + 1)) { + Text(snr, format: .number.precision(.fractionLength(2))) + + Text(" dB") } + } } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.PathDetail.runDetails) - .navigationBarTitleDisplayMode(.inline) + .themedRowBackground(theme) + } } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.PathDetail.runDetails) + .navigationBarTitleDisplayMode(.inline) + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathDetailViewModel.swift b/MC1/Views/Tools/TracePath/SavedPathDetailViewModel.swift index 40e5695f..d865551c 100644 --- a/MC1/Views/Tools/TracePath/SavedPathDetailViewModel.swift +++ b/MC1/Views/Tools/TracePath/SavedPathDetailViewModel.swift @@ -1,74 +1,73 @@ -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "SavedPathDetail") @Observable @MainActor final class SavedPathDetailViewModel { + // MARK: - State - // MARK: - State + var savedPath: SavedTracePathDTO + var isLoading = false - var savedPath: SavedTracePathDTO - var isLoading = false + // MARK: - Computed - // MARK: - Computed + var sortedRuns: [TracePathRunDTO] { + savedPath.runs.sorted { $0.date > $1.date } + } - var sortedRuns: [TracePathRunDTO] { - savedPath.runs.sorted { $0.date > $1.date } - } + var successfulRuns: [TracePathRunDTO] { + sortedRuns.filter(\.success) + } - var successfulRuns: [TracePathRunDTO] { - sortedRuns.filter { $0.success } - } + var bestRoundTrip: Int? { + successfulRuns.map(\.roundTripMs).min() + } - var bestRoundTrip: Int? { - successfulRuns.map(\.roundTripMs).min() - } + var averageRoundTrip: Int? { + savedPath.averageRoundTripMs + } - var averageRoundTrip: Int? { - savedPath.averageRoundTripMs - } + var successRateText: String { + "\(savedPath.successRate)%" + } - var successRateText: String { - "\(savedPath.successRate)%" - } + // MARK: - Initialization - // MARK: - Initialization + init(savedPath: SavedTracePathDTO) { + self.savedPath = savedPath + } - init(savedPath: SavedTracePathDTO) { - self.savedPath = savedPath - } + // MARK: - Dependencies - // MARK: - Dependencies + private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } - private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } + /// Hash size per hop from when the path was saved (1, 2, or 4 bytes) + var hashSize: Int { + savedPath.hashSize + } - /// Hash size per hop from when the path was saved (1, 2, or 4 bytes) - var hashSize: Int { - savedPath.hashSize - } - - /// The provider is read live at its point of use; a provider returning - /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. - func configure(dataStore: @escaping @MainActor () -> PersistenceStore?) { - dataStoreProvider = dataStore - } + /// The provider is read live at its point of use; a provider returning + /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. + func configure(dataStore: @escaping @MainActor () -> PersistenceStore?) { + dataStoreProvider = dataStore + } - // MARK: - Actions + // MARK: - Actions - func refresh() async { - guard let dataStore = dataStoreProvider() else { return } + func refresh() async { + guard let dataStore = dataStoreProvider() else { return } - isLoading = true - do { - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - savedPath = updated - } - } catch { - logger.error("Failed to refresh: \(error.localizedDescription)") - } - isLoading = false + isLoading = true + do { + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + savedPath = updated + } + } catch { + logger.error("Failed to refresh: \(error.localizedDescription)") } + isLoading = false + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathRow.swift b/MC1/Views/Tools/TracePath/SavedPathRow.swift index 8a2f3f12..3ba28e36 100644 --- a/MC1/Views/Tools/TracePath/SavedPathRow.swift +++ b/MC1/Views/Tools/TracePath/SavedPathRow.swift @@ -1,86 +1,86 @@ -import SwiftUI import MC1Services +import SwiftUI struct SavedPathRow: View { - let path: SavedTracePathDTO + let path: SavedTracePathDTO - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(path.name) - .font(.body) + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(path.name) + .font(.body) - HStack(spacing: 8) { - // Run count and recency - Text(subtitleText) - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: 8) { + // Run count and recency + Text(subtitleText) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - // Health indicator - healthDot + // Health indicator + healthDot - // Mini sparkline - if !path.recentRTTs.isEmpty { - MiniSparkline(values: path.recentRTTs) - .frame(width: 50, height: 16) - .accessibilityLabel(sparklineAccessibilityLabel) - } - } + // Mini sparkline + if !path.recentRTTs.isEmpty { + MiniSparkline(values: path.recentRTTs) + .frame(width: 50, height: 16) + .accessibilityLabel(sparklineAccessibilityLabel) } - .padding(.vertical, 4) + } } + .padding(.vertical, 4) + } - private var subtitleText: String { - var parts: [String] = [] + private var subtitleText: String { + var parts: [String] = [] - // Run count - let runText = path.runCount == 1 - ? L10n.Contacts.Contacts.SavedPaths.Runs.singular - : L10n.Contacts.Contacts.SavedPaths.Runs.plural(path.runCount) - parts.append(runText) - - // Last run - if let lastDate = path.lastRunDate { - parts.append(L10n.Contacts.Contacts.SavedPaths.lastRun(lastDate.relativeFormatted)) - } + // Run count + let runText = path.runCount == 1 + ? L10n.Contacts.Contacts.SavedPaths.Runs.singular + : L10n.Contacts.Contacts.SavedPaths.Runs.plural(path.runCount) + parts.append(runText) - return parts.joined(separator: " · ") + // Last run + if let lastDate = path.lastRunDate { + parts.append(L10n.Contacts.Contacts.SavedPaths.lastRun(lastDate.relativeFormatted)) } - @ViewBuilder - private var healthDot: some View { - let rate = path.successRate - let healthDescription = rate >= 90 - ? L10n.Contacts.Contacts.SavedPaths.Health.healthy - : rate >= 50 - ? L10n.Contacts.Contacts.SavedPaths.Health.degraded - : L10n.Contacts.Contacts.SavedPaths.Health.poor - Circle() - .fill(rate >= 90 ? .green : rate >= 50 ? .yellow : .red) - .frame(width: 8, height: 8) - .accessibilityLabel(L10n.Contacts.Contacts.SavedPaths.healthLabel(healthDescription, rate)) - } + return parts.joined(separator: " · ") + } - private var sparklineAccessibilityLabel: String { - let rtts = path.recentRTTs - guard !rtts.isEmpty else { return L10n.Contacts.Contacts.SavedPaths.noResponseData } + @ViewBuilder + private var healthDot: some View { + let rate = path.successRate + let healthDescription = rate >= 90 + ? L10n.Contacts.Contacts.SavedPaths.Health.healthy + : rate >= 50 + ? L10n.Contacts.Contacts.SavedPaths.Health.degraded + : L10n.Contacts.Contacts.SavedPaths.Health.poor + Circle() + .fill(rate >= 90 ? .green : rate >= 50 ? .yellow : .red) + .frame(width: 8, height: 8) + .accessibilityLabel(L10n.Contacts.Contacts.SavedPaths.healthLabel(healthDescription, rate)) + } - let avgRTT = rtts.reduce(0, +) / rtts.count - let trend: String - if rtts.count >= 2 { - let firstHalf = rtts.prefix(rtts.count / 2).reduce(0, +) / max(1, rtts.count / 2) - let secondHalf = rtts.suffix(rtts.count / 2).reduce(0, +) / max(1, rtts.count / 2) - if secondHalf > firstHalf + 50 { - trend = L10n.Contacts.Contacts.SavedPaths.Trend.increasing - } else if secondHalf < firstHalf - 50 { - trend = L10n.Contacts.Contacts.SavedPaths.Trend.decreasing - } else { - trend = L10n.Contacts.Contacts.SavedPaths.Trend.stable - } - } else { - trend = L10n.Contacts.Contacts.SavedPaths.Trend.stable - } - return L10n.Contacts.Contacts.SavedPaths.responseTimes(avgRTT, trend) + private var sparklineAccessibilityLabel: String { + let rtts = path.recentRTTs + guard !rtts.isEmpty else { return L10n.Contacts.Contacts.SavedPaths.noResponseData } + + let avgRTT = rtts.reduce(0, +) / rtts.count + let trend: String + if rtts.count >= 2 { + let firstHalf = rtts.prefix(rtts.count / 2).reduce(0, +) / max(1, rtts.count / 2) + let secondHalf = rtts.suffix(rtts.count / 2).reduce(0, +) / max(1, rtts.count / 2) + if secondHalf > firstHalf + 50 { + trend = L10n.Contacts.Contacts.SavedPaths.Trend.increasing + } else if secondHalf < firstHalf - 50 { + trend = L10n.Contacts.Contacts.SavedPaths.Trend.decreasing + } else { + trend = L10n.Contacts.Contacts.SavedPaths.Trend.stable + } + } else { + trend = L10n.Contacts.Contacts.SavedPaths.Trend.stable } + return L10n.Contacts.Contacts.SavedPaths.responseTimes(avgRTT, trend) + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathsSheet.swift b/MC1/Views/Tools/TracePath/SavedPathsSheet.swift index 09d7d1ef..f475fac2 100644 --- a/MC1/Views/Tools/TracePath/SavedPathsSheet.swift +++ b/MC1/Views/Tools/TracePath/SavedPathsSheet.swift @@ -1,134 +1,134 @@ -import SwiftUI import MC1Services +import SwiftUI /// Sheet displaying saved trace paths for selection struct SavedPathsSheet: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var viewModel = SavedPathsViewModel() + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var viewModel = SavedPathsViewModel() - /// Callback when a path is selected - var onSelect: (SavedTracePathDTO) -> Void - /// Callback when a path is deleted - var onDelete: ((UUID) -> Void)? + /// Callback when a path is selected + var onSelect: (SavedTracePathDTO) -> Void + /// Callback when a path is deleted + var onDelete: ((UUID) -> Void)? - @State private var pathToDelete: SavedTracePathDTO? - @State private var pathToRename: SavedTracePathDTO? - @State private var renameText = "" + @State private var pathToDelete: SavedTracePathDTO? + @State private var pathToRename: SavedTracePathDTO? + @State private var renameText = "" - var body: some View { - NavigationStack { - Group { - if viewModel.savedPaths.isEmpty && !viewModel.isLoading { - emptyState - } else { - pathsList - } - } - .navigationTitle(L10n.Contacts.Contacts.SavedPaths.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Contacts.Contacts.Common.done) { dismiss() } - } - } - .task { - viewModel.configure( - dataStore: { appState.services?.dataStore }, - connectedDevice: { appState.connectedDevice } - ) - await viewModel.loadSavedPaths() - } - .errorAlert($viewModel.errorMessage) - .confirmationDialog( - L10n.Contacts.Contacts.SavedPaths.deleteTitle, - isPresented: .init( - get: { pathToDelete != nil }, - set: { if !$0 { pathToDelete = nil } } - ), - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { - if let path = pathToDelete { - let pathId = path.id - Task { - await viewModel.deletePath(path) - onDelete?(pathId) - } - } - } - } message: { - if let path = pathToDelete { - Text(L10n.Contacts.Contacts.SavedPaths.deleteMessage(path.name)) - } - } - .alert(L10n.Contacts.Contacts.SavedPaths.renameTitle, isPresented: .init( - get: { pathToRename != nil }, - set: { if !$0 { pathToRename = nil } } - )) { - TextField(L10n.Contacts.Contacts.Detail.name, text: $renameText) - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Common.save) { - if let path = pathToRename { - Task { await viewModel.renamePath(path, to: renameText) } - } - } + var body: some View { + NavigationStack { + Group { + if viewModel.savedPaths.isEmpty, !viewModel.isLoading { + emptyState + } else { + pathsList + } + } + .navigationTitle(L10n.Contacts.Contacts.SavedPaths.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Common.done) { dismiss() } + } + } + .task { + viewModel.configure( + dataStore: { appState.services?.dataStore }, + connectedDevice: { appState.connectedDevice } + ) + await viewModel.loadSavedPaths() + } + .errorAlert($viewModel.errorMessage) + .confirmationDialog( + L10n.Contacts.Contacts.SavedPaths.deleteTitle, + isPresented: .init( + get: { pathToDelete != nil }, + set: { if !$0 { pathToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { + if let path = pathToDelete { + let pathId = path.id + Task { + await viewModel.deletePath(path) + onDelete?(pathId) } + } + } + } message: { + if let path = pathToDelete { + Text(L10n.Contacts.Contacts.SavedPaths.deleteMessage(path.name)) + } + } + .alert(L10n.Contacts.Contacts.SavedPaths.renameTitle, isPresented: .init( + get: { pathToRename != nil }, + set: { if !$0 { pathToRename = nil } } + )) { + TextField(L10n.Contacts.Contacts.Detail.name, text: $renameText) + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Common.save) { + if let path = pathToRename { + Task { await viewModel.renamePath(path, to: renameText) } + } } - .presentationDetents([.medium, .large]) + } } + .presentationDetents([.medium, .large]) + } - // MARK: - Empty State + // MARK: - Empty State - private var emptyState: some View { - ContentUnavailableView { - Label(L10n.Contacts.Contacts.SavedPaths.Empty.title, systemImage: "bookmark") - } description: { - Text(L10n.Contacts.Contacts.SavedPaths.Empty.description) - } + private var emptyState: some View { + ContentUnavailableView { + Label(L10n.Contacts.Contacts.SavedPaths.Empty.title, systemImage: "bookmark") + } description: { + Text(L10n.Contacts.Contacts.SavedPaths.Empty.description) } + } - // MARK: - Paths List + // MARK: - Paths List - /// `SavedPathRow` has no avatar, so the row text and the inter-row divider share this inset. - private static let rowHorizontalPadding: CGFloat = 16 + /// `SavedPathRow` has no avatar, so the row text and the inter-row divider share this inset. + private static let rowHorizontalPadding: CGFloat = 16 - private var pathsList: some View { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(viewModel.savedPaths.enumerated()), id: \.element.id) { index, path in - Button { - onSelect(path) - dismiss() - } label: { - SavedPathRow(path: path) - .padding(.horizontal, Self.rowHorizontalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } - .buttonStyle(.plain) - .contextMenu { - Button(L10n.Contacts.Contacts.SavedPaths.rename, systemImage: "pencil") { - renameText = path.name - pathToRename = path - } - Button(L10n.Contacts.Contacts.Common.delete, systemImage: "trash", role: .destructive) { - pathToDelete = path - } - } - .transition(.opacity) - if index < viewModel.savedPaths.count - 1 { - Divider().padding(.leading, Self.rowHorizontalPadding) - } - } + private var pathsList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(viewModel.savedPaths.enumerated()), id: \.element.id) { index, path in + Button { + onSelect(path) + dismiss() + } label: { + SavedPathRow(path: path) + .padding(.horizontal, Self.rowHorizontalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .contextMenu { + Button(L10n.Contacts.Contacts.SavedPaths.rename, systemImage: "pencil") { + renameText = path.name + pathToRename = path } - } - .themedCanvas(theme) - .overlay { - if viewModel.isLoading { - ProgressView() + Button(L10n.Contacts.Contacts.Common.delete, systemImage: "trash", role: .destructive) { + pathToDelete = path } + } + .transition(.opacity) + if index < viewModel.savedPaths.count - 1 { + Divider().padding(.leading, Self.rowHorizontalPadding) + } } + } + } + .themedCanvas(theme) + .overlay { + if viewModel.isLoading { + ProgressView() + } } + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathsViewModel.swift b/MC1/Views/Tools/TracePath/SavedPathsViewModel.swift index 688fa03d..a201cc9a 100644 --- a/MC1/Views/Tools/TracePath/SavedPathsViewModel.swift +++ b/MC1/Views/Tools/TracePath/SavedPathsViewModel.swift @@ -1,80 +1,79 @@ -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "SavedPaths") @Observable @MainActor final class SavedPathsViewModel { + // MARK: - State - // MARK: - State + var savedPaths: [SavedTracePathDTO] = [] + var isLoading = false + var errorMessage: String? - var savedPaths: [SavedTracePathDTO] = [] - var isLoading = false - var errorMessage: String? + // MARK: - Dependencies - // MARK: - Dependencies + private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } + private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } - private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } - private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } + // MARK: - Configuration - // MARK: - Configuration - - /// Each provider is read live at its point of use; a provider returning - /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. - func configure( - dataStore: @escaping @MainActor () -> PersistenceStore?, - connectedDevice: @escaping @MainActor () -> DeviceDTO? - ) { - dataStoreProvider = dataStore - connectedDeviceProvider = connectedDevice - } + /// Each provider is read live at its point of use; a provider returning + /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. + func configure( + dataStore: @escaping @MainActor () -> PersistenceStore?, + connectedDevice: @escaping @MainActor () -> DeviceDTO? + ) { + dataStoreProvider = dataStore + connectedDeviceProvider = connectedDevice + } - // MARK: - Data Loading + // MARK: - Data Loading - func loadSavedPaths() async { - guard let radioID = connectedDeviceProvider()?.radioID, - let dataStore = dataStoreProvider() else { return } + func loadSavedPaths() async { + guard let radioID = connectedDeviceProvider()?.radioID, + let dataStore = dataStoreProvider() else { return } - isLoading = true - errorMessage = nil + isLoading = true + errorMessage = nil - do { - savedPaths = try await dataStore.fetchSavedTracePaths(radioID: radioID) - logger.info("Loaded \(self.savedPaths.count) saved paths") - } catch { - logger.error("Failed to load saved paths: \(error.localizedDescription)") - errorMessage = L10n.Tools.Tools.SavedPaths.loadFailed - } - - isLoading = false + do { + savedPaths = try await dataStore.fetchSavedTracePaths(radioID: radioID) + logger.info("Loaded \(self.savedPaths.count) saved paths") + } catch { + logger.error("Failed to load saved paths: \(error.localizedDescription)") + errorMessage = L10n.Tools.Tools.SavedPaths.loadFailed } - // MARK: - Actions + isLoading = false + } - func renamePath(_ path: SavedTracePathDTO, to newName: String) async { - guard let dataStore = dataStoreProvider() else { return } + // MARK: - Actions - do { - try await dataStore.updateSavedTracePathName(id: path.id, name: newName) - await loadSavedPaths() - } catch { - logger.error("Failed to rename path: \(error.localizedDescription)") - errorMessage = L10n.Tools.Tools.SavedPaths.renameFailed - } - } + func renamePath(_ path: SavedTracePathDTO, to newName: String) async { + guard let dataStore = dataStoreProvider() else { return } - func deletePath(_ path: SavedTracePathDTO) async { - guard let dataStore = dataStoreProvider() else { return } - - do { - try await dataStore.deleteSavedTracePath(id: path.id) - savedPaths.removeAll { $0.id == path.id } - logger.info("Deleted saved path: \(path.name)") - } catch { - logger.error("Failed to delete path: \(error.localizedDescription)") - errorMessage = L10n.Tools.Tools.SavedPaths.deleteFailed - } + do { + try await dataStore.updateSavedTracePathName(id: path.id, name: newName) + await loadSavedPaths() + } catch { + logger.error("Failed to rename path: \(error.localizedDescription)") + errorMessage = L10n.Tools.Tools.SavedPaths.renameFailed + } + } + + func deletePath(_ path: SavedTracePathDTO) async { + guard let dataStore = dataStoreProvider() else { return } + + do { + try await dataStore.deleteSavedTracePath(id: path.id) + savedPaths.removeAll { $0.id == path.id } + logger.info("Deleted saved path: \(path.name)") + } catch { + logger.error("Failed to delete path: \(error.localizedDescription)") + errorMessage = L10n.Tools.Tools.SavedPaths.deleteFailed } + } } diff --git a/MC1/Views/Tools/TracePath/TotalDistanceRow.swift b/MC1/Views/Tools/TracePath/TotalDistanceRow.swift index ddaf7719..dea2e5de 100644 --- a/MC1/Views/Tools/TracePath/TotalDistanceRow.swift +++ b/MC1/Views/Tools/TracePath/TotalDistanceRow.swift @@ -1,57 +1,57 @@ -import SwiftUI import MC1Services +import SwiftUI /// Row displaying total path distance with optional info sheet struct TotalDistanceRow: View { - @Bindable var viewModel: TracePathViewModel - let result: TraceResult - @Binding var showingDistanceInfo: Bool + @Bindable var viewModel: TracePathViewModel + let result: TraceResult + @Binding var showingDistanceInfo: Bool - var body: some View { - HStack { - Text(L10n.Contacts.Contacts.Results.totalDistance) - .foregroundStyle(.secondary) - Spacer() + var body: some View { + HStack { + Text(L10n.Contacts.Contacts.Results.totalDistance) + .foregroundStyle(.secondary) + Spacer() - if let distance = viewModel.totalPathDistance { - HStack { - Text(formatDistance(distance)) - .font(.body.monospacedDigit()) - if viewModel.isDistanceUsingFallback { - Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { - showingDistanceInfo = true - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.partialDistanceLabel) - .accessibilityHint(L10n.Contacts.Contacts.Results.partialDistanceHint) - } - } - } else { - HStack { - Text(L10n.Contacts.Contacts.Results.unavailable) - .foregroundStyle(.secondary) - Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { - showingDistanceInfo = true - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.distanceUnavailableLabel) - .accessibilityHint(L10n.Contacts.Contacts.Results.distanceInfoHint) - } + if let distance = viewModel.totalPathDistance { + HStack { + Text(formatDistance(distance)) + .font(.body.monospacedDigit()) + if viewModel.isDistanceUsingFallback { + Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { + showingDistanceInfo = true } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.partialDistanceLabel) + .accessibilityHint(L10n.Contacts.Contacts.Results.partialDistanceHint) + } } - .sheet(isPresented: $showingDistanceInfo) { - DistanceInfoSheetView(result: result, viewModel: viewModel, showingDistanceInfo: $showingDistanceInfo) - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) + } else { + HStack { + Text(L10n.Contacts.Contacts.Results.unavailable) + .foregroundStyle(.secondary) + Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { + showingDistanceInfo = true + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.distanceUnavailableLabel) + .accessibilityHint(L10n.Contacts.Contacts.Results.distanceInfoHint) } + } } - - private func formatDistance(_ meters: Double) -> String { - let measurement = Measurement(value: meters, unit: UnitLength.meters) - return measurement.formatted(.measurement(width: .abbreviated, usage: .road)) + .sheet(isPresented: $showingDistanceInfo) { + DistanceInfoSheetView(result: result, viewModel: viewModel, showingDistanceInfo: $showingDistanceInfo) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) } + } + + private func formatDistance(_ meters: Double) -> String { + let measurement = Measurement(value: meters, unit: UnitLength.meters) + return measurement.formatted(.measurement(width: .abbreviated, usage: .road)) + } } diff --git a/MC1/Views/Tools/TracePath/TraceHop.swift b/MC1/Views/Tools/TracePath/TraceHop.swift index 5705bc21..c1469003 100644 --- a/MC1/Views/Tools/TracePath/TraceHop.swift +++ b/MC1/Views/Tools/TracePath/TraceHop.swift @@ -3,27 +3,29 @@ import MC1Services /// Represents a single hop in a trace result struct TraceHop: Identifiable { - let id = UUID() - let hashBytes: Data? // nil for start/end node (local device) - let resolvedName: String? // From contacts lookup - let snr: Double - let isStartNode: Bool - let isEndNode: Bool - let latitude: Double? - let longitude: Double? + let id = UUID() + let hashBytes: Data? // nil for start/end node (local device) + let resolvedName: String? // From contacts lookup + let snr: Double + let isStartNode: Bool + let isEndNode: Bool + let latitude: Double? + let longitude: Double? - /// Display string for hash (shows all bytes) - var hashDisplayString: String? { - hashBytes?.map { $0.hexString }.joined() - } + /// Display string for hash (shows all bytes) + var hashDisplayString: String? { + hashBytes?.map(\.hexString).joined() + } - /// Whether this hop has a valid (non-zero) location. - /// Uses OR logic to match ContactDTO.hasLocation - if either coordinate is non-zero, - /// we have some location data. (0,0) is "Null Island" and extremely unlikely. - var hasLocation: Bool { - guard let lat = latitude, let lon = longitude else { return false } - return lat != 0 || lon != 0 - } + /// Whether this hop has a valid (non-zero) location. + /// Uses OR logic to match ContactDTO.hasLocation - if either coordinate is non-zero, + /// we have some location data. (0,0) is "Null Island" and extremely unlikely. + var hasLocation: Bool { + guard let lat = latitude, let lon = longitude else { return false } + return lat != 0 || lon != 0 + } - var snrQuality: SNRQuality { SNRQuality(snr: snr) } + var snrQuality: SNRQuality { + SNRQuality(snr: snr) + } } diff --git a/MC1/Views/Tools/TracePath/TracePathHopRow.swift b/MC1/Views/Tools/TracePath/TracePathHopRow.swift index da42611e..9a857173 100644 --- a/MC1/Views/Tools/TracePath/TracePathHopRow.swift +++ b/MC1/Views/Tools/TracePath/TracePathHopRow.swift @@ -1,24 +1,24 @@ import SwiftUI struct TracePathHopRow: View { - let hop: PathHop - let hopNumber: Int + let hop: PathHop + let hopNumber: Int - var body: some View { - VStack(alignment: .leading) { - if let name = hop.resolvedName { - Text(name) - Text(hop.hashHex) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } else { - Text(hop.hashHex) - .font(.body.monospaced()) - } - } - .frame(minHeight: 44) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.hopLabel(hopNumber, hop.resolvedName ?? hop.hashHex)) - .accessibilityHint(L10n.Contacts.Contacts.Trace.List.hopHint) + var body: some View { + VStack(alignment: .leading) { + if let name = hop.resolvedName { + Text(name) + Text(hop.hashHex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } else { + Text(hop.hashHex) + .font(.body.monospaced()) + } } + .frame(minHeight: 44) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.hopLabel(hopNumber, hop.resolvedName ?? hop.hashHex)) + .accessibilityHint(L10n.Contacts.Contacts.Trace.List.hopHint) + } } diff --git a/MC1/Views/Tools/TracePath/TracePathListView.swift b/MC1/Views/Tools/TracePath/TracePathListView.swift index fde17bb5..1f94f81c 100644 --- a/MC1/Views/Tools/TracePath/TracePathListView.swift +++ b/MC1/Views/Tools/TracePath/TracePathListView.swift @@ -1,120 +1,120 @@ -import SwiftUI import MC1Services +import SwiftUI /// List-based view for building trace paths. Hops are added through the shared /// `AddHopPickerView`, pushed onto the Tools navigation stack via the Add Hop CTA. struct TracePathListView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Bindable var viewModel: TracePathViewModel + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Bindable var viewModel: TracePathViewModel - @Binding var dragHapticTrigger: Int - @Binding var copyHapticTrigger: Int - @Binding var showingClearConfirmation: Bool - @Binding var presentedResult: TraceResult? - @Binding var showJumpToPath: Bool + @Binding var dragHapticTrigger: Int + @Binding var copyHapticTrigger: Int + @Binding var showingClearConfirmation: Bool + @Binding var presentedResult: TraceResult? + @Binding var showJumpToPath: Bool - /// Drives the pushed Add Hop picker via `.navigationDestination(item:)`. - @State private var insertionIntent: AddHopIntent? + /// Drives the pushed Add Hop picker via `.navigationDestination(item:)`. + @State private var insertionIntent: AddHopIntent? - var body: some View { - List { - if viewModel.outboundPath.isEmpty { - emptyStateSection - } else { - outboundPathSection - .themedRowBackground(theme) - addHopCtaSection - } - PathActionsSectionView( - viewModel: viewModel, - showingClearConfirmation: $showingClearConfirmation, - copyHapticTrigger: $copyHapticTrigger - ) - RunTraceSectionView( - viewModel: viewModel, - showJumpToPath: $showJumpToPath - ) + var body: some View { + List { + if viewModel.outboundPath.isEmpty { + emptyStateSection + } else { + outboundPathSection + .themedRowBackground(theme) + addHopCtaSection + } + PathActionsSectionView( + viewModel: viewModel, + showingClearConfirmation: $showingClearConfirmation, + copyHapticTrigger: $copyHapticTrigger + ) + RunTraceSectionView( + viewModel: viewModel, + showJumpToPath: $showJumpToPath + ) - Color.clear - .frame(height: 1) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets()) - .id("bottom") - } - .themedCanvas(theme) - .environment(\.editMode, .constant(.active)) - .addHopPicker(for: $insertionIntent, source: viewModel, inDetailColumn: true) + Color.clear + .frame(height: 1) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + .id("bottom") } + .themedCanvas(theme) + .environment(\.editMode, .constant(.active)) + .addHopPicker(for: $insertionIntent, source: viewModel, inDetailColumn: true) + } - // MARK: - Empty state + // MARK: - Empty state - @ViewBuilder - private var emptyStateSection: some View { - Section { - ContentUnavailableView { - Label( - L10n.Contacts.Contacts.PathEdit.Empty.title, - systemImage: "antenna.radiowaves.left.and.right.slash" - ) - } description: { - Text(L10n.Contacts.Contacts.Trace.List.emptyPath) - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - } + @ViewBuilder + private var emptyStateSection: some View { + Section { + ContentUnavailableView { + Label( + L10n.Contacts.Contacts.PathEdit.Empty.title, + systemImage: "antenna.radiowaves.left.and.right.slash" + ) + } description: { + Text(L10n.Contacts.Contacts.Trace.List.emptyPath) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } - Section { - addHopButton - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } + Section { + addHopButton + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } + } - // MARK: - Add Hop CTA + // MARK: - Add Hop CTA - private var addHopCtaSection: some View { - Section { - addHopButton - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - } + private var addHopCtaSection: some View { + Section { + addHopButton + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) } + } - private var addHopButton: some View { - Button { - insertionIntent = .append - } label: { - PathEditCTALabel( - title: L10n.Contacts.Contacts.PathEdit.addHop, - systemImage: "plus.circle.fill" - ) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) + private var addHopButton: some View { + Button { + insertionIntent = .append + } label: { + PathEditCTALabel( + title: L10n.Contacts.Contacts.PathEdit.addHop, + systemImage: "plus.circle.fill" + ) } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } - // MARK: - Outbound Path Section + // MARK: - Outbound Path Section - private var outboundPathSection: some View { - Section { - ForEach(Array(viewModel.outboundPath.enumerated()), id: \.element.id) { index, hop in - TracePathHopRow(hop: hop, hopNumber: index + 1) - } - .onMove { source, destination in - dragHapticTrigger += 1 - viewModel.moveRepeater(from: source, to: destination) - } - .onDelete { indexSet in - for index in indexSet.sorted().reversed() { - viewModel.removeRepeater(at: index) - } - } - } header: { - Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) + private var outboundPathSection: some View { + Section { + ForEach(Array(viewModel.outboundPath.enumerated()), id: \.element.id) { index, hop in + TracePathHopRow(hop: hop, hopNumber: index + 1) + } + .onMove { source, destination in + dragHapticTrigger += 1 + viewModel.moveRepeater(from: source, to: destination) + } + .onDelete { indexSet in + for index in indexSet.sorted().reversed() { + viewModel.removeRepeater(at: index) } + } + } header: { + Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) } + } } diff --git a/MC1/Views/Tools/TracePath/TracePathRoute.swift b/MC1/Views/Tools/TracePath/TracePathRoute.swift index 02123365..dc5b2af9 100644 --- a/MC1/Views/Tools/TracePath/TracePathRoute.swift +++ b/MC1/Views/Tools/TracePath/TracePathRoute.swift @@ -5,11 +5,11 @@ import MC1Services /// comparison row can drill into a saved path's history. Value-based so each push rebuilds the /// destination instead of reusing stale `@State` from a prior visit. enum TracePathRoute: Hashable { - case savedPathDetail(SavedTracePathDTO) + case savedPathDetail(SavedTracePathDTO) - /// Run drill-down push destination. `SavedPathDetailView` registers and pushes this itself - /// so the destination travels with the view regardless of which stack hosts it. - struct RunDetail: Hashable { - let run: TracePathRunDTO - } + /// Run drill-down push destination. `SavedPathDetailView` registers and pushes this itself + /// so the destination travels with the view regardless of which stack hosts it. + struct RunDetail: Hashable { + let run: TracePathRunDTO + } } diff --git a/MC1/Views/Tools/TracePath/TracePathView.swift b/MC1/Views/Tools/TracePath/TracePathView.swift index 913b373c..25cfedaf 100644 --- a/MC1/Views/Tools/TracePath/TracePathView.swift +++ b/MC1/Views/Tools/TracePath/TracePathView.swift @@ -1,204 +1,202 @@ -import SwiftUI import MC1Services +import SwiftUI /// View mode for trace path building enum TracePathViewMode: String, CaseIterable { - case list - case map + case list + case map - var label: String { - switch self { - case .list: L10n.Contacts.Contacts.Trace.Mode.list - case .map: L10n.Contacts.Contacts.Trace.Mode.map - } + var label: String { + switch self { + case .list: L10n.Contacts.Contacts.Trace.Mode.list + case .map: L10n.Contacts.Contacts.Trace.Mode.map } + } } /// View for building and executing network path traces struct TracePathView: View { - @Environment(\.appState) private var appState - @State private var viewModel = TracePathViewModel() + @Environment(\.appState) private var appState + @State private var viewModel = TracePathViewModel() - // Haptic feedback triggers - @State private var dragHapticTrigger = 0 - @State private var copyHapticTrigger = 0 - @State private var jumpHapticTrigger = 0 + // Haptic feedback triggers + @State private var dragHapticTrigger = 0 + @State private var copyHapticTrigger = 0 + @State private var jumpHapticTrigger = 0 - @State private var showingSavedPaths = false - @State private var presentedResult: TraceResult? - @State private var showingClearConfirmation = false + @State private var showingSavedPaths = false + @State private var presentedResult: TraceResult? + @State private var showingClearConfirmation = false - @State private var showJumpToPath = true - @State private var pathLoadedFromSheet = false - @AppStorage(AppStorageKey.tracePathViewMode.rawValue) private var viewMode: TracePathViewMode = .list + @State private var showJumpToPath = true + @State private var pathLoadedFromSheet = false + @AppStorage(AppStorageKey.tracePathViewMode.rawValue) private var viewMode: TracePathViewMode = .list - var body: some View { - ZStack { - switch viewMode { - case .list: - listView - case .map: - TracePathMapView(traceViewModel: viewModel, presentedResult: $presentedResult) - } - } - .animation(nil, value: viewMode) - .navigationTitle(L10n.Contacts.Contacts.Trace.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - Picker(L10n.Contacts.Contacts.Trace.viewMode, selection: $viewMode) { - ForEach(TracePathViewMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) - } - } - .pickerStyle(.segmented) - .fixedSize() - } - ToolbarItem(placement: .primaryAction) { - Button(L10n.Contacts.Contacts.Trace.saved, systemImage: "bookmark") { - showingSavedPaths = true - } - } - } - .sensoryFeedback(.impact(weight: .light), trigger: dragHapticTrigger) - .sensoryFeedback(.success, trigger: copyHapticTrigger) - .sensoryFeedback(.error, trigger: viewModel.errorHapticTrigger) - .sheet(isPresented: $showingSavedPaths) { - SavedPathsSheet { selectedPath in - viewModel.loadSavedPath(selectedPath) - pathLoadedFromSheet = true - } onDelete: { deletedPathId in - viewModel.handleSavedPathDeleted(id: deletedPathId) - } - } - .onChange(of: viewModel.resultID) { _, newID in - guard newID != nil else { return } - if let result = viewModel.result, result.success { - if viewMode == .list { - presentedResult = result - } - } - } - .sheet(item: $presentedResult, onDismiss: { - if viewModel.isBatchInProgress { - viewModel.cancelBatchTrace() - } - }) { result in - TraceResultsSheet(result: result, viewModel: viewModel) - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - } - .confirmationDialog( - L10n.Contacts.Contacts.Trace.clearPath, - isPresented: $showingClearConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { - viewModel.clearPath() - } - } message: { - Text(L10n.Contacts.Contacts.Trace.clearPathMessage) - } - .alert(L10n.Contacts.Contacts.Trace.failed, isPresented: Binding( - get: { viewModel.errorMessage != nil }, - set: { if !$0 { viewModel.clearError() } } - )) { - Button(L10n.Contacts.Contacts.Common.ok) { - viewModel.clearError() - } - } message: { - if let error = viewModel.errorMessage { - Text(error) - } + var body: some View { + ZStack { + switch viewMode { + case .list: + listView + case .map: + TracePathMapView(traceViewModel: viewModel, presentedResult: $presentedResult) + } + } + .animation(nil, value: viewMode) + .navigationTitle(L10n.Contacts.Contacts.Trace.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Picker(L10n.Contacts.Contacts.Trace.viewMode, selection: $viewMode) { + ForEach(TracePathViewMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } } - .task(id: appState.servicesVersion) { - // Keyed on servicesVersion: a late connect or reconnect rebuilds the - // ServiceContainer, so the listener must re-subscribe to the fresh - // AdvertisementService or trace responses are silently dropped. - viewModel.configure(dependencies: TracePathViewModel.Dependencies( - dataStore: { appState.services?.dataStore }, - session: { appState.services?.session }, - advertisementService: { appState.services?.advertisementService }, - connectedDevice: { appState.connectedDevice }, - bestAvailableLocation: { appState.bestAvailableLocation } - )) - viewModel.startListening() - if let radioID = appState.connectedDevice?.radioID { - await viewModel.loadContacts(radioID: radioID) - } + .pickerStyle(.segmented) + .fixedSize() + } + ToolbarItem(placement: .primaryAction) { + Button(L10n.Contacts.Contacts.Trace.saved, systemImage: "bookmark") { + showingSavedPaths = true } - .onDisappear { - viewModel.stopListening() + } + } + .sensoryFeedback(.impact(weight: .light), trigger: dragHapticTrigger) + .sensoryFeedback(.success, trigger: copyHapticTrigger) + .sensoryFeedback(.error, trigger: viewModel.errorHapticTrigger) + .sheet(isPresented: $showingSavedPaths) { + SavedPathsSheet { selectedPath in + viewModel.loadSavedPath(selectedPath) + pathLoadedFromSheet = true + } onDelete: { deletedPathId in + viewModel.handleSavedPathDeleted(id: deletedPathId) + } + } + .onChange(of: viewModel.resultID) { _, newID in + guard newID != nil else { return } + if let result = viewModel.result, result.success { + if viewMode == .list { + presentedResult = result } + } + } + .sheet(item: $presentedResult, onDismiss: { + if viewModel.isBatchInProgress { + viewModel.cancelBatchTrace() + } + }) { result in + TraceResultsSheet(result: result, viewModel: viewModel) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .confirmationDialog( + L10n.Contacts.Contacts.Trace.clearPath, + isPresented: $showingClearConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { + viewModel.clearPath() + } + } message: { + Text(L10n.Contacts.Contacts.Trace.clearPathMessage) + } + .alert(L10n.Contacts.Contacts.Trace.failed, isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.clearError() } } + )) { + Button(L10n.Contacts.Contacts.Common.ok) { + viewModel.clearError() + } + } message: { + if let error = viewModel.errorMessage { + Text(error) + } } + .task(id: appState.servicesVersion) { + // Keyed on servicesVersion: a late connect or reconnect rebuilds the + // ServiceContainer, so the listener must re-subscribe to the fresh + // AdvertisementService or trace responses are silently dropped. + viewModel.configure(dependencies: TracePathViewModel.Dependencies( + dataStore: { appState.services?.dataStore }, + session: { appState.services?.session }, + advertisementService: { appState.services?.advertisementService }, + connectedDevice: { appState.connectedDevice }, + bestAvailableLocation: { appState.bestAvailableLocation } + )) + viewModel.startListening() + if let radioID = appState.connectedDevice?.radioID { + await viewModel.loadContacts(radioID: radioID) + } + } + .onDisappear { + viewModel.stopListening() + } + } - @ViewBuilder - private var listView: some View { - ScrollViewReader { proxy in - TracePathListView( - viewModel: viewModel, - dragHapticTrigger: $dragHapticTrigger, - copyHapticTrigger: $copyHapticTrigger, - showingClearConfirmation: $showingClearConfirmation, - presentedResult: $presentedResult, - showJumpToPath: $showJumpToPath - ) - .scrollDismissesKeyboard(.interactively) - .overlay(alignment: .bottom) { - jumpToPathButton(proxy: proxy) - } - .onChange(of: showingSavedPaths) { wasShowing, isShowing in - if wasShowing && !isShowing && pathLoadedFromSheet { - pathLoadedFromSheet = false - withAnimation { - proxy.scrollTo("runTrace", anchor: .bottom) - } - } - } + private var listView: some View { + ScrollViewReader { proxy in + TracePathListView( + viewModel: viewModel, + dragHapticTrigger: $dragHapticTrigger, + copyHapticTrigger: $copyHapticTrigger, + showingClearConfirmation: $showingClearConfirmation, + presentedResult: $presentedResult, + showJumpToPath: $showJumpToPath + ) + .scrollDismissesKeyboard(.interactively) + .overlay(alignment: .bottom) { + jumpToPathButton(proxy: proxy) + } + .onChange(of: showingSavedPaths) { wasShowing, isShowing in + if wasShowing, !isShowing, pathLoadedFromSheet { + pathLoadedFromSheet = false + withAnimation { + proxy.scrollTo("runTrace", anchor: .bottom) + } } + } } + } - // MARK: - Jump to Path Button + // MARK: - Jump to Path Button - @ViewBuilder - private func jumpToPathButton(proxy: ScrollViewProxy) -> some View { - JumpToPathButton(isVisible: showJumpToPath) { - jumpHapticTrigger += 1 - withAnimation { - showJumpToPath = false - proxy.scrollTo("runTrace", anchor: .bottom) - } - } - .padding(.bottom) - .sensoryFeedback(.selection, trigger: jumpHapticTrigger) + private func jumpToPathButton(proxy: ScrollViewProxy) -> some View { + JumpToPathButton(isVisible: showJumpToPath) { + jumpHapticTrigger += 1 + withAnimation { + showJumpToPath = false + proxy.scrollTo("runTrace", anchor: .bottom) + } } + .padding(.bottom) + .sensoryFeedback(.selection, trigger: jumpHapticTrigger) + } } // MARK: - Jump to Path Button /// Floating pill button to scroll to the Run Trace button private struct JumpToPathButton: View { - let isVisible: Bool - let onTap: () -> Void + let isVisible: Bool + let onTap: () -> Void - var body: some View { - Button(action: onTap) { - Label(L10n.Contacts.Contacts.Trace.runBelow, systemImage: "arrow.down") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color.accentColor, in: .capsule) - .liquidGlass(in: .capsule) - .shadow(color: .black.opacity(0.2), radius: 8, y: 4) - } - .buttonStyle(.plain) - .opacity(isVisible ? 1 : 0) - .scaleEffect(isVisible ? 1 : 0.8) - .allowsHitTesting(isVisible) - .animation(.snappy(duration: 0.2), value: isVisible) - .accessibilityLabel(L10n.Contacts.Contacts.Trace.jumpLabel) - .accessibilityHint(L10n.Contacts.Contacts.Trace.jumpHint) - .accessibilityHidden(!isVisible) + var body: some View { + Button(action: onTap) { + Label(L10n.Contacts.Contacts.Trace.runBelow, systemImage: "arrow.down") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.accentColor, in: .capsule) + .liquidGlass(in: .capsule) + .shadow(color: .black.opacity(0.2), radius: 8, y: 4) } + .buttonStyle(.plain) + .opacity(isVisible ? 1 : 0) + .scaleEffect(isVisible ? 1 : 0.8) + .allowsHitTesting(isVisible) + .animation(.snappy(duration: 0.2), value: isVisible) + .accessibilityLabel(L10n.Contacts.Contacts.Trace.jumpLabel) + .accessibilityHint(L10n.Contacts.Contacts.Trace.jumpHint) + .accessibilityHidden(!isVisible) + } } diff --git a/MC1/Views/Tools/TracePath/TracePathViewModel.swift b/MC1/Views/Tools/TracePath/TracePathViewModel.swift index 5039222f..ac24d586 100644 --- a/MC1/Views/Tools/TracePath/TracePathViewModel.swift +++ b/MC1/Views/Tools/TracePath/TracePathViewModel.swift @@ -1,1256 +1,1278 @@ import CoreLocation -import SwiftUI -import UIKit -import MeshCore import MC1Services +import MeshCore import os.log +import SwiftUI +import UIKit private let logger = Logger(subsystem: "com.mc1", category: "TracePath") @Observable @MainActor final class TracePathViewModel { - - // MARK: - Path Building State - - var outboundPath: [PathHop] = [] - var availableRepeaters: [ContactDTO] = [] - var availableRooms: [ContactDTO] = [] - var autoReturnPath = true - private var allContacts: [ContactDTO] = [] - var discoveredRepeaters: [DiscoveredNodeDTO] = [] - - /// Recently added hop public keys, newest first. Source for the shared - /// picker's "Recent" section; persisted per radio via ``RecentHopsStore``. - var recentPublicKeys: [Data] = [] - private let recents: RecentHopsStore - private var currentRadioID: UUID? - - init(defaults: UserDefaults = .standard) { - self.recents = RecentHopsStore(defaults: defaults) - } - - /// Combined repeaters and rooms for resolution (hex codes, map pins, etc.) - var availableNodes: [ContactDTO] { - availableRepeaters + availableRooms - } - - // MARK: - Execution State - - var isRunning = false - var result: TraceResult? - var resultID: UUID? // Set to new UUID only on successful trace - var errorMessage: String? - var errorAutoClearDelay: Duration = .seconds(4) - private var errorAutoClearTask: Task? - var errorHapticTrigger = 0 // Incremented on each error for haptic feedback - - /// Buffer between consecutive batch traces to avoid network flooding. - private static let interTraceBufferMs = 500 - - // MARK: - Batch Trace State - - var batchEnabled = false { - didSet { - if !batchEnabled { - clearBatchState() - } - } - } - var batchSize = 5 - var currentTraceIndex = 0 - var completedResults: [TraceResult] = [] - - /// Flag to signal batch loop should stop (since we await in the calling context) - private var batchCancelled = false - - /// Continuation for awaiting trace response in batch mode - private var traceContinuation: CheckedContinuation? - - var isBatchInProgress: Bool { - batchEnabled && currentTraceIndex > 0 && currentTraceIndex <= batchSize + // MARK: - Path Building State + + var outboundPath: [PathHop] = [] + var availableRepeaters: [ContactDTO] = [] + var availableRooms: [ContactDTO] = [] + var autoReturnPath = true + private var allContacts: [ContactDTO] = [] + var discoveredRepeaters: [DiscoveredNodeDTO] = [] + + /// Recently added hop public keys, newest first. Source for the shared + /// picker's "Recent" section; persisted per radio via ``RecentHopsStore``. + var recentPublicKeys: [Data] = [] + private let recents: RecentHopsStore + private var currentRadioID: UUID? + + init(defaults: UserDefaults = .standard) { + recents = RecentHopsStore(defaults: defaults) + } + + /// Combined repeaters and rooms for resolution (hex codes, map pins, etc.) + var availableNodes: [ContactDTO] { + availableRepeaters + availableRooms + } + + // MARK: - Execution State + + var isRunning = false + var result: TraceResult? + var resultID: UUID? // Set to new UUID only on successful trace + var errorMessage: String? + var errorAutoClearDelay: Duration = .seconds(4) + private var errorAutoClearTask: Task? + var errorHapticTrigger = 0 // Incremented on each error for haptic feedback + + /// Buffer between consecutive batch traces to avoid network flooding. + private static let interTraceBufferMs = 500 + + // MARK: - Batch Trace State + + var batchEnabled = false { + didSet { + if !batchEnabled { + clearBatchState() + } } - - var isBatchComplete: Bool { - batchEnabled && completedResults.count == batchSize + } + + var batchSize = 3 + var currentTraceIndex = 0 + var completedResults: [TraceResult] = [] + + /// Flag to signal batch loop should stop (since we await in the calling context) + private var batchCancelled = false + + /// Continuation for awaiting trace response in batch mode + private var traceContinuation: CheckedContinuation? + + var isBatchInProgress: Bool { + batchEnabled && currentTraceIndex > 0 && currentTraceIndex <= batchSize + } + + var isBatchComplete: Bool { + batchEnabled && completedResults.count == batchSize + } + + var successfulResults: [TraceResult] { + completedResults.filter(\.success) + } + + var successCount: Int { + successfulResults.count + } + + /// Clear batch execution state + func clearBatchState() { + currentTraceIndex = 0 + completedResults = [] + } + + // MARK: - Batch Aggregates + + var averageRTT: Int? { + let rtts = successfulResults.map(\.durationMs) + guard !rtts.isEmpty else { return nil } + return rtts.reduce(0, +) / rtts.count + } + + var minRTT: Int? { + successfulResults.map(\.durationMs).min() + } + + var maxRTT: Int? { + successfulResults.map(\.durationMs).max() + } + + /// Returns aggregate stats for a hop at the given index (0 = start node, 1+ = intermediate/end) + /// Returns nil for start node (index 0) as it has no received SNR + func hopStats(at index: Int) -> (avg: Double, min: Double, max: Double)? { + guard index > 0 else { return nil } // Start node has no SNR + + let snrValues = successfulResults.compactMap { result -> Double? in + guard index < result.hops.count else { return nil } + let hop = result.hops[index] + guard !hop.isStartNode else { return nil } + return hop.snr } - var successfulResults: [TraceResult] { - completedResults.filter { $0.success } + guard !snrValues.isEmpty else { return nil } + + let avg = snrValues.reduce(0, +) / Double(snrValues.count) + let min = snrValues.min() ?? 0 + let max = snrValues.max() ?? 0 + + return (avg, min, max) + } + + /// Returns the SNR for a hop from the most recent successful result + func latestHopSNR(at index: Int) -> Double? { + guard let latest = successfulResults.last, + index < latest.hops.count else { return nil } + return latest.hops[index].snr + } + + // MARK: - Saved Path State + + var activeSavedPath: SavedTracePathDTO? + var isRunningSavedPath: Bool { + activeSavedPath != nil + } + + /// Returns the second-most-recent successful run for comparison display + var previousRun: TracePathRunDTO? { + let successfulRuns = activeSavedPath?.runs + .filter(\.success) + .sorted(by: { $0.date > $1.date }) ?? [] + return successfulRuns.count >= 2 ? successfulRuns[1] : nil + } + + // MARK: - Trace Correlation + + private var pendingTag: UInt32? + private var pendingDeviceID: UUID? // Track which device initiated trace + private var traceStartTime: Date? + private var traceTask: Task? + + // MARK: - Path Hash Tracking (for save validation) + + private var pendingPathHash: [UInt8]? + // resultPathHash removed - now derived from result.tracedPathBytes + + // MARK: - Event Subscription + + private var traceEventsTask: Task? + + /// Start listening for trace responses on the current connection's + /// `AdvertisementService`. Requires `configure` first. The owning + /// `ServiceContainer` is rebuilt on every connection and finishes + /// its event stream on teardown, so the hosting view re-invokes this per + /// container (keyed on `AppState.servicesVersion`); while disconnected + /// there is no service yet and the call is a no-op until then. + func startListening() { + traceEventsTask?.cancel() + guard let advertisementService else { return } + let events = advertisementService.events() + traceEventsTask = Task { [weak self] in + for await event in events { + guard case let .traceResponse(traceInfo, radioID) = event else { continue } + guard let self else { return } + handleTraceResponse(traceInfo, radioID: radioID) + } } - - var successCount: Int { - successfulResults.count + } + + /// Stop listening for trace responses + func stopListening() { + traceEventsTask?.cancel() + traceEventsTask = nil + } + + // MARK: - Dependencies + + struct Dependencies { + // Provider closures are re-evaluated at every use so a disconnect (or a + // container rebuild) mid-trace is observed live, never a stale snapshot. + var dataStore: @MainActor () -> PersistenceStore? + var session: @MainActor () -> MeshCoreSession? + var advertisementService: @MainActor () -> AdvertisementService? + var connectedDevice: @MainActor () -> DeviceDTO? + var bestAvailableLocation: @MainActor () -> CLLocation? + } + + private var deps = Dependencies( + dataStore: { nil }, + session: { nil }, + advertisementService: { nil }, + connectedDevice: { nil }, + bestAvailableLocation: { nil } + ) + + private var dataStore: PersistenceStore? { + deps.dataStore() + } + + private var session: MeshCoreSession? { + deps.session() + } + + private var advertisementService: AdvertisementService? { + deps.advertisementService() + } + + private var connectedDevice: DeviceDTO? { + deps.connectedDevice() + } + + private var bestAvailableLocation: CLLocation? { + deps.bestAvailableLocation() + } + + // MARK: - Computed Properties + + /// Full path data: outbound + optional mirrored return (minus last hop to avoid duplicate) + var fullPathData: Data { + let outbound = outboundPath.map(\.hashBytes) + guard !outbound.isEmpty else { return Data() } + + if autoReturnPath { + let returnPath = outbound.reversed().dropFirst() + return Data((outbound + returnPath).flatMap(\.self)) + } else { + return Data(outbound.flatMap(\.self)) } - - /// Clear batch execution state - func clearBatchState() { - currentTraceIndex = 0 - completedResults = [] + } + + /// Full path as byte array for backward compatibility + var fullPathBytes: [UInt8] { + Array(fullPathData) + } + + /// Per-trace hash size override (path_sz code 0/1/2). `nil` follows the + /// radio's configured `pathHashMode`. Honored by firmware v1.11+. + var traceHashMode: UInt8? + + /// Path_sz code used for this trace's flags byte and hop widths: the + /// override when set, otherwise the radio's configured `pathHashMode`. + var effectiveTraceMode: UInt8 { + traceHashMode ?? (connectedDevice?.pathHashMode ?? 0) + } + + /// Trace hash size in bytes per hop (1, 2, or 4), derived from the effective + /// mode. Trace uses power-of-2 encoding (`1 << mode`), unlike the linear + /// 1/2/3-byte routing hash size. + var hashSize: Int { + 1 << Int(effectiveTraceMode) + } + + /// Comma-separated path string for display/copy, chunked by hash size + var fullPathString: String { + let data = fullPathData + let size = outboundPath.first?.hashBytes.count ?? hashSize + return stride(from: 0, to: data.count, by: size).map { start in + let end = min(start + size, data.count) + return data[start..= 2 else { return nil } - var minRTT: Int? { - successfulResults.map(\.durationMs).min() + // Priority 1: Full path including device legs + if let fullDistance = calculateDistance(for: result.hops) { + return fullDistance } - var maxRTT: Int? { - successfulResults.map(\.durationMs).max() - } + // Priority 2: Intermediate repeaters only (device has no location) + let repeaters = result.hops.filter { !$0.isStartNode && !$0.isEndNode } + return calculateDistance(for: repeaters) + } - /// Returns aggregate stats for a hop at the given index (0 = start node, 1+ = intermediate/end) - /// Returns nil for start node (index 0) as it has no received SNR - func hopStats(at index: Int) -> (avg: Double, min: Double, max: Double)? { - guard index > 0 else { return nil } // Start node has no SNR + /// Calculate total distance for a sequence of hops, or nil if any lacks location + private func calculateDistance(for hops: [TraceHop]) -> Double? { + guard hops.count >= 2 else { return nil } - let snrValues = successfulResults.compactMap { result -> Double? in - guard index < result.hops.count else { return nil } - let hop = result.hops[index] - guard !hop.isStartNode else { return nil } - return hop.snr - } + var totalMeters: Double = 0 - guard !snrValues.isEmpty else { return nil } + for index in 0..<(hops.count - 1) { + let current = hops[index] + let next = hops[index + 1] - let avg = snrValues.reduce(0, +) / Double(snrValues.count) - let min = snrValues.min() ?? 0 - let max = snrValues.max() ?? 0 + guard current.hasLocation, next.hasLocation, + let curLat = current.latitude, let curLon = current.longitude, + let nextLat = next.latitude, let nextLon = next.longitude else { + return nil + } - return (avg, min, max) + let from = CLLocation(latitude: curLat, longitude: curLon) + let to = CLLocation(latitude: nextLat, longitude: nextLon) + totalMeters += from.distance(from: to) } - /// Returns the SNR for a hop from the most recent successful result - func latestHopSNR(at index: Int) -> Double? { - guard let latest = successfulResults.last, - index < latest.hops.count else { return nil } - return latest.hops[index].snr - } + return totalMeters + } - // MARK: - Saved Path State + /// Names of intermediate repeaters that lack location data + var repeatersWithoutLocation: [String] { + guard let result else { return [] } - var activeSavedPath: SavedTracePathDTO? - var isRunningSavedPath: Bool { activeSavedPath != nil } - /// Returns the second-most-recent successful run for comparison display - var previousRun: TracePathRunDTO? { - let successfulRuns = activeSavedPath?.runs - .filter { $0.success } - .sorted(by: { $0.date > $1.date }) ?? [] - return successfulRuns.count >= 2 ? successfulRuns[1] : nil - } + return result.hops + .filter { !$0.isStartNode && !$0.isEndNode && !$0.hasLocation } + .map { $0.resolvedName ?? $0.hashDisplayString ?? "Unknown" } + } - // MARK: - Trace Correlation - - private var pendingTag: UInt32? - private var pendingDeviceID: UUID? // Track which device initiated trace - private var traceStartTime: Date? - private var traceTask: Task? - - // MARK: - Path Hash Tracking (for save validation) - - private var pendingPathHash: [UInt8]? - // resultPathHash removed - now derived from result.tracedPathBytes - - // MARK: - Event Subscription - - private var traceEventsTask: Task? - - /// Start listening for trace responses on the current connection's - /// `AdvertisementService`. Requires `configure` first. The owning - /// `ServiceContainer` is rebuilt on every connection and finishes - /// its event stream on teardown, so the hosting view re-invokes this per - /// container (keyed on `AppState.servicesVersion`); while disconnected - /// there is no service yet and the call is a no-op until then. - func startListening() { - traceEventsTask?.cancel() - guard let advertisementService else { return } - let events = advertisementService.events() - traceEventsTask = Task { [weak self] in - for await event in events { - guard case .traceResponse(let traceInfo, let radioID) = event else { continue } - guard let self else { return } - self.handleTraceResponse(traceInfo, radioID: radioID) - } - } - } + /// Whether the distance calculation used intermediate-only fallback (device has no location) + var isDistanceUsingFallback: Bool { + guard let result, result.success, totalPathDistance != nil else { return false } - /// Stop listening for trace responses - func stopListening() { - traceEventsTask?.cancel() - traceEventsTask = nil - } + guard let startNode = result.hops.first, let endNode = result.hops.last else { return false } - // MARK: - Dependencies + // If device nodes lack location, we used the intermediate-only fallback + return !startNode.hasLocation || !endNode.hasLocation + } - struct Dependencies { - // Provider closures are re-evaluated at every use so a disconnect (or a - // container rebuild) mid-trace is observed live, never a stale snapshot. - var dataStore: @MainActor () -> PersistenceStore? - var session: @MainActor () -> MeshCoreSession? - var advertisementService: @MainActor () -> AdvertisementService? - var connectedDevice: @MainActor () -> DeviceDTO? - var bestAvailableLocation: @MainActor () -> CLLocation? - } + // MARK: - Configuration - private var deps = Dependencies( - dataStore: { nil }, - session: { nil }, - advertisementService: { nil }, - connectedDevice: { nil }, - bestAvailableLocation: { nil } - ) + /// Each provider is read live at its point of use; a provider returning + /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. + func configure(dependencies: Dependencies) { + deps = dependencies + } - private var dataStore: PersistenceStore? { deps.dataStore() } - private var session: MeshCoreSession? { deps.session() } - private var advertisementService: AdvertisementService? { deps.advertisementService() } - private var connectedDevice: DeviceDTO? { deps.connectedDevice() } - private var bestAvailableLocation: CLLocation? { deps.bestAvailableLocation() } + // MARK: - Error Handling - // MARK: - Computed Properties + func setError(_ message: String) { + errorAutoClearTask?.cancel() - /// Full path data: outbound + optional mirrored return (minus last hop to avoid duplicate) - var fullPathData: Data { - let outbound = outboundPath.map { $0.hashBytes } - guard !outbound.isEmpty else { return Data() } + errorMessage = message + errorHapticTrigger += 1 - if autoReturnPath { - let returnPath = outbound.reversed().dropFirst() - return Data((outbound + returnPath).flatMap { $0 }) - } else { - return Data(outbound.flatMap { $0 }) - } + errorAutoClearTask = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(for: errorAutoClearDelay) + guard !Task.isCancelled else { return } + errorMessage = nil } + } - /// Full path as byte array for backward compatibility - var fullPathBytes: [UInt8] { - Array(fullPathData) - } + func clearError() { + errorAutoClearTask?.cancel() + errorAutoClearTask = nil + errorMessage = nil + } - /// Per-trace hash size override (path_sz code 0/1/2). `nil` follows the - /// radio's configured `pathHashMode`. Honored by firmware v1.11+. - var traceHashMode: UInt8? + // MARK: - Hash Resolution - /// Path_sz code used for this trace's flags byte and hop widths: the - /// override when set, otherwise the radio's configured `pathHashMode`. - var effectiveTraceMode: UInt8 { - traceHashMode ?? (connectedDevice?.pathHashMode ?? 0) - } + /// Resolve hash bytes to the best matching node name (contacts first, then discovered) + func resolveHashToName(_ hashBytes: Data) -> String? { + resolveNode(for: hashBytes)?.resolvableName + } - /// Trace hash size in bytes per hop (1, 2, or 4), derived from the effective - /// mode. Trace uses power-of-2 encoding (`1 << mode`), unlike the linear - /// 1/2/3-byte routing hash size. - var hashSize: Int { 1 << Int(effectiveTraceMode) } - - /// Comma-separated path string for display/copy, chunked by hash size - var fullPathString: String { - let data = fullPathData - let size = outboundPath.first?.hashBytes.count ?? hashSize - return stride(from: 0, to: data.count, by: size).map { start in - let end = min(start + size, data.count) - return data[start.. (any RepeaterResolvable)? { + if let contact = RepeaterResolver.bestMatch(for: hashBytes, in: availableNodes, userLocation: bestAvailableLocation) { + return contact } + return RepeaterResolver.bestMatch(for: hashBytes, in: discoveredRepeaters, userLocation: bestAvailableLocation) + } - /// Can run trace if path has at least one hop and not currently running - var canRunTraceWhenConnected: Bool { - !outboundPath.isEmpty && !isRunning + /// Try contacts first, then discovered nodes, using full PathHop for exact key match. + private func resolveNode(for hop: PathHop) -> (any RepeaterResolvable)? { + if let contact = RepeaterResolver.bestMatch(for: hop, in: availableNodes, userLocation: bestAvailableLocation) { + return contact } - - /// Can save path if result is successful and path hasn't changed since trace ran - var canSavePath: Bool { - if batchEnabled { - guard !completedResults.isEmpty else { return false } - guard let firstSuccess = successfulResults.first else { return false } - return fullPathBytes == firstSuccess.tracedPathBytes - } else { - guard let result, result.success else { return false } - return fullPathBytes == result.tracedPathBytes - } + return RepeaterResolver.bestMatch(for: hop, in: discoveredRepeaters, userLocation: bestAvailableLocation) + } + + // MARK: - Data Loading + + /// Load contacts for name resolution and available repeaters + func loadContacts(radioID: UUID) async { + currentRadioID = radioID + recentPublicKeys = recents.load(for: radioID) + // Drop an override carried from a prior radio the current one can't + // honor, so the trace falls back to its pathHashMode. + if connectedDevice?.supportsTraceHashSizeOverride != true { + traceHashMode = nil } - - // MARK: - Distance Calculation - - /// Total path distance in meters, using a priority cascade: - /// 1. Full path (including device legs) if device has location - /// 2. Intermediate repeaters only if device lacks location - /// 3. Nil if fewer than 2 hops with valid location - var totalPathDistance: Double? { - guard let result, result.success else { return nil } - guard result.hops.count >= 2 else { return nil } - - // Priority 1: Full path including device legs - if let fullDistance = calculateDistance(for: result.hops) { - return fullDistance - } - - // Priority 2: Intermediate repeaters only (device has no location) - let repeaters = result.hops.filter { !$0.isStartNode && !$0.isEndNode } - return calculateDistance(for: repeaters) + guard let dataStore else { return } + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + allContacts = contacts + availableRepeaters = contacts.filter { $0.type == .repeater } + availableRooms = contacts.filter { $0.type == .room } + let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) + discoveredRepeaters = nodes.filter { $0.nodeType == .repeater } + } catch { + logger.error("Failed to load contacts: \(error.localizedDescription)") + allContacts = [] + availableRepeaters = [] + availableRooms = [] + discoveredRepeaters = [] } - - /// Calculate total distance for a sequence of hops, or nil if any lacks location - private func calculateDistance(for hops: [TraceHop]) -> Double? { - guard hops.count >= 2 else { return nil } - - var totalMeters: Double = 0 - - for index in 0..<(hops.count - 1) { - let current = hops[index] - let next = hops[index + 1] - - guard current.hasLocation, next.hasLocation, - let curLat = current.latitude, let curLon = current.longitude, - let nextLat = next.latitude, let nextLon = next.longitude else { - return nil - } - - let from = CLLocation(latitude: curLat, longitude: curLon) - let to = CLLocation(latitude: nextLat, longitude: nextLon) - totalMeters += from.distance(from: to) - } - - return totalMeters + } + + // MARK: - Path Manipulation + + /// Add a node to the outbound path + func addNode(_ node: some RepeaterResolvable) { + clearError() + let hashBytes = Data(node.publicKey.prefix(hashSize)) + let hop = PathHop(hashBytes: hashBytes, publicKey: node.publicKey, resolvedName: node.resolvableName) + outboundPath.append(hop) + activeSavedPath = nil + pendingPathHash = nil + result = nil + } + + /// Apply a per-trace hash size override and rebuild every hop to a uniform + /// width. Hops with a public key re-prefix exactly; key-less hops (from a + /// saved path with an unresolved hop) have nothing to widen from and are + /// zero-padded. The width must be uniform because the trace flags byte + /// declares a single hash size for the whole path. + func setTraceHashMode(_ mode: UInt8) { + clearError() + traceHashMode = mode + let size = hashSize + outboundPath = outboundPath.map { hop in + var hop = hop + let source = hop.publicKey ?? hop.hashBytes + var bytes = Data(source.prefix(size)) + if bytes.count < size { + bytes.append(Data(repeating: 0, count: size - bytes.count)) + } + hop.hashBytes = bytes + return hop } - - /// Names of intermediate repeaters that lack location data - var repeatersWithoutLocation: [String] { - guard let result else { return [] } - - return result.hops - .filter { !$0.isStartNode && !$0.isEndNode && !$0.hasLocation } - .map { $0.resolvedName ?? $0.hashDisplayString ?? "Unknown" } + activeSavedPath = nil + pendingPathHash = nil + result = nil + } + + /// Parse comma-separated hex codes and add matching repeaters to the path. + /// Shares ``HopCodeParser`` with the bulk-add preview so the two never diverge. + @discardableResult + func addRepeatersFromCodes(_ input: String) -> CodeInputResult { + var result = CodeInputResult() + for entry in classifyCodes(input) { + switch entry.status { + case let .willAdd(hop): + outboundPath.append(hop) + if let publicKey = hop.publicKey { recordRecent(publicKey) } + result.added.append(entry.code) + case .alreadyInPath: result.alreadyInPath.append(entry.code) + case .notFound: result.notFound.append(entry.code) + case .invalidFormat: result.invalidFormat.append(entry.code) + case .pathFull: assertionFailure("trace paths are uncapped, so classifyCodes never yields .pathFull") + } } - /// Whether the distance calculation used intermediate-only fallback (device has no location) - var isDistanceUsingFallback: Bool { - guard let result, result.success, totalPathDistance != nil else { return false } - - guard let startNode = result.hops.first, let endNode = result.hops.last else { return false } - - // If device nodes lack location, we used the intermediate-only fallback - return !startNode.hasLocation || !endNode.hasLocation + // Clear saved path reference if we added anything + if !result.added.isEmpty { + activeSavedPath = nil + pendingPathHash = nil + self.result = nil + clearError() } - // MARK: - Configuration - - /// Each provider is read live at its point of use; a provider returning - /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. - func configure(dependencies: Dependencies) { - deps = dependencies + return result + } + + /// Remove a repeater from the path + func removeRepeater(at index: Int) { + clearError() + guard outboundPath.indices.contains(index) else { return } + outboundPath.remove(at: index) + activeSavedPath = nil + pendingPathHash = nil + result = nil + } + + /// Move a repeater within the path + func moveRepeater(from source: IndexSet, to destination: Int) { + clearError() + outboundPath.move(fromOffsets: source, toOffset: destination) + activeSavedPath = nil + pendingPathHash = nil + result = nil + } + + /// Copy full path string to clipboard + func copyPathToClipboard() { + UIPasteboard.general.string = fullPathString + } + + /// Generate a default name from the path (e.g., "Tower → ... → Ridge") + func generatePathName() -> String { + let names = outboundPath.compactMap(\.resolvedName) + switch names.count { + case 0: + return L10n.Contacts.Contacts.PathName.prefix(String(fullPathString.prefix(8))) + case 1: + return names[0] + case 2: + return L10n.Contacts.Contacts.PathName.twoEndpoints(names[0], names[1]) + default: + return L10n.Contacts.Contacts.PathName.multipleEndpoints(names[0], names[names.count - 1]) } + } + + /// Extract SNR values from intermediate hops (excluding start and end nodes) + private func extractHopsSNR(from result: TraceResult) -> [Double] { + result.hops + .filter { !$0.isStartNode && !$0.isEndNode } + .map(\.snr) + } + + /// Save the current path with the given name + /// - Returns: `true` if save succeeded, `false` otherwise + @discardableResult + func savePath(name: String) async -> Bool { + guard let radioID = connectedDevice?.radioID, + let dataStore else { return false } + + // For batch mode, save all completed results + if batchEnabled, !completedResults.isEmpty { + guard let firstSuccess = successfulResults.first else { return false } + + // Create initial run from first successful result + let initialRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: true, + roundTripMs: firstSuccess.durationMs, + hopsSNR: extractHopsSNR(from: firstSuccess) + ) + + do { + let savedPath = try await dataStore.createSavedTracePath( + radioID: radioID, + name: name, + pathBytes: Data(firstSuccess.tracedPathBytes), + hashSize: hashSize, + initialRun: initialRun + ) - // MARK: - Error Handling - - func setError(_ message: String) { - errorAutoClearTask?.cancel() - - errorMessage = message - errorHapticTrigger += 1 + // Append remaining results as additional runs + for (index, batchResult) in completedResults.enumerated() { + // Skip the first successful result (already saved as initial) + if batchResult.id == firstSuccess.id { continue } - errorAutoClearTask = Task { @MainActor [weak self] in - guard let self else { return } - try? await Task.sleep(for: errorAutoClearDelay) - guard !Task.isCancelled else { return } - errorMessage = nil + let run = TracePathRunDTO( + id: UUID(), + date: Date().addingTimeInterval(Double(index)), + success: batchResult.success, + roundTripMs: batchResult.durationMs, + hopsSNR: extractHopsSNR(from: batchResult) + ) + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: run) } - } - - func clearError() { - errorAutoClearTask?.cancel() - errorAutoClearTask = nil - errorMessage = nil - } - - // MARK: - Hash Resolution - - /// Resolve hash bytes to the best matching node name (contacts first, then discovered) - func resolveHashToName(_ hashBytes: Data) -> String? { - resolveNode(for: hashBytes)?.resolvableName - } - /// Try contacts first, then discovered nodes. Returns the best match from either source. - private func resolveNode(for hashBytes: Data) -> (any RepeaterResolvable)? { - if let contact = RepeaterResolver.bestMatch(for: hashBytes, in: availableNodes, userLocation: bestAvailableLocation) { - return contact + // Refresh to get all runs + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + activeSavedPath = updated } - return RepeaterResolver.bestMatch(for: hashBytes, in: discoveredRepeaters, userLocation: bestAvailableLocation) + logger.info("Saved batch path: \(name) with \(self.completedResults.count) runs") + return true + } catch { + logger.error("Failed to save batch path: \(error.localizedDescription)") + return false + } } - /// Try contacts first, then discovered nodes, using full PathHop for exact key match. - private func resolveNode(for hop: PathHop) -> (any RepeaterResolvable)? { - if let contact = RepeaterResolver.bestMatch(for: hop, in: availableNodes, userLocation: bestAvailableLocation) { - return contact - } - return RepeaterResolver.bestMatch(for: hop, in: discoveredRepeaters, userLocation: bestAvailableLocation) - } + // Single trace mode (original behavior) + guard let result, result.success else { return false } - // MARK: - Data Loading + let initialRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: true, + roundTripMs: result.durationMs, + hopsSNR: extractHopsSNR(from: result) + ) - /// Load contacts for name resolution and available repeaters - func loadContacts(radioID: UUID) async { - currentRadioID = radioID - recentPublicKeys = recents.load(for: radioID) - // Drop an override carried from a prior radio the current one can't - // honor, so the trace falls back to its pathHashMode. - if connectedDevice?.supportsTraceHashSizeOverride != true { - traceHashMode = nil - } - guard let dataStore else { return } - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - allContacts = contacts - availableRepeaters = contacts.filter { $0.type == .repeater } - availableRooms = contacts.filter { $0.type == .room } - let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) - discoveredRepeaters = nodes.filter { $0.nodeType == .repeater } - } catch { - logger.error("Failed to load contacts: \(error.localizedDescription)") - allContacts = [] - availableRepeaters = [] - availableRooms = [] - discoveredRepeaters = [] - } + do { + let savedPath = try await dataStore.createSavedTracePath( + radioID: radioID, + name: name, + pathBytes: Data(result.tracedPathBytes), + hashSize: hashSize, + initialRun: initialRun + ) + activeSavedPath = savedPath + logger.info("Saved path: \(name)") + return true + } catch { + logger.error("Failed to save path: \(error.localizedDescription)") + return false } - - // MARK: - Path Manipulation - - /// Add a node to the outbound path - func addNode(_ node: some RepeaterResolvable) { - clearError() - let hashBytes = Data(node.publicKey.prefix(hashSize)) - let hop = PathHop(hashBytes: hashBytes, publicKey: node.publicKey, resolvedName: node.resolvableName) - outboundPath.append(hop) - activeSavedPath = nil - pendingPathHash = nil - result = nil + } + + /// Load a saved path into the builder + func loadSavedPath(_ savedPath: SavedTracePathDTO) { + // Clear existing path + outboundPath.removeAll() + result = nil + pendingPathHash = nil + + // Reconstruct outbound path from saved bytes + // The saved pathBytes contains full path (outbound + return) + // We need to extract just the outbound portion + let fullPath = savedPath.pathBytes + let size = savedPath.hashSize + guard !fullPath.isEmpty else { return } + + // Match the trace hash mode to the saved width (size is 1/2/4 -> code + // 0/1/2), but only on a radio that honors the per-trace override; + // otherwise the trace follows the configured pathHashMode. + if connectedDevice?.supportsTraceHashSizeOverride == true { + traceHashMode = UInt8(size.trailingZeroBitCount) + } else { + traceHashMode = nil } - /// Apply a per-trace hash size override and rebuild every hop to a uniform - /// width. Hops with a public key re-prefix exactly; key-less hops (from a - /// saved path with an unresolved hop) have nothing to widen from and are - /// zero-padded. The width must be uniform because the trace flags byte - /// declares a single hash size for the whole path. - func setTraceHashMode(_ mode: UInt8) { - clearError() - traceHashMode = mode - let size = hashSize - outboundPath = outboundPath.map { hop in - var hop = hop - let source = hop.publicKey ?? hop.hashBytes - var bytes = Data(source.prefix(size)) - if bytes.count < size { - bytes.append(Data(repeating: 0, count: size - bytes.count)) - } - hop.hashBytes = bytes - return hop - } - activeSavedPath = nil - pendingPathHash = nil - result = nil + // Calculate total hops, then outbound is first half (rounded up) + let totalHops = fullPath.count / size + let outboundHopCount = (totalHops + 1) / 2 + let outboundByteCount = outboundHopCount * size + + for start in stride(from: 0, to: min(outboundByteCount, fullPath.count), by: size) { + let end = min(start + size, fullPath.count) + let hashBytes = Data(fullPath[start.. CodeInputResult { - var result = CodeInputResult() - for entry in classifyCodes(input) { - switch entry.status { - case .willAdd(let hop): - outboundPath.append(hop) - if let publicKey = hop.publicKey { recordRecent(publicKey) } - result.added.append(entry.code) - case .alreadyInPath: result.alreadyInPath.append(entry.code) - case .notFound: result.notFound.append(entry.code) - case .invalidFormat: result.invalidFormat.append(entry.code) - case .pathFull: assertionFailure("trace paths are uncapped, so classifyCodes never yields .pathFull") - } - } + activeSavedPath = savedPath + logger.info("Loaded saved path: \(savedPath.name) with \(self.outboundPath.count) hops") + } + + /// Clear the path (resets to empty state) + func clearPath() { + clearError() + activeSavedPath = nil + outboundPath.removeAll() + result = nil + pendingPathHash = nil + traceHashMode = nil + } + + /// Clear active saved path reference if it matches the deleted path + func handleSavedPathDeleted(id: UUID) { + guard activeSavedPath?.id == id else { return } + activeSavedPath = nil + logger.info("Cleared active saved path reference after deletion") + } + + /// Find a saved path matching the current path bytes + /// Returns the most recently used match if multiple exist + private func findMatchingSavedPath() async -> SavedTracePathDTO? { + guard let radioID = connectedDevice?.radioID, + let dataStore else { return nil } + + let pathBytes = fullPathBytes + guard !pathBytes.isEmpty else { return nil } + + do { + let savedPaths = try await dataStore.fetchSavedTracePaths(radioID: radioID) + let matches = savedPaths.filter { $0.pathHashBytes == pathBytes } + + // Return most recently used (by latest run date) + return matches.max { path1, path2 in + let date1 = path1.runs.map(\.date).max() ?? .distantPast + let date2 = path2.runs.map(\.date).max() ?? .distantPast + return date1 < date2 + } + } catch { + logger.error("Failed to fetch saved paths for matching: \(error.localizedDescription)") + return nil + } + } - // Clear saved path reference if we added anything - if !result.added.isEmpty { - activeSavedPath = nil - pendingPathHash = nil - self.result = nil - clearError() - } + // MARK: - Trace Execution - return result - } + /// Execute the trace and wait for response + func runTrace() async { + guard let session, + !outboundPath.isEmpty else { return } - /// Remove a repeater from the path - func removeRepeater(at index: Int) { - clearError() - guard outboundPath.indices.contains(index) else { return } - outboundPath.remove(at: index) - activeSavedPath = nil - pendingPathHash = nil - result = nil - } + // Cancel any pending trace + traceTask?.cancel() - /// Move a repeater within the path - func moveRepeater(from source: IndexSet, to destination: Int) { - clearError() - outboundPath.move(fromOffsets: source, toOffset: destination) - activeSavedPath = nil - pendingPathHash = nil - result = nil - } + // Clear previous results and errors + resultID = nil + clearError() - /// Copy full path string to clipboard - func copyPathToClipboard() { - UIPasteboard.general.string = fullPathString + // Match to saved path if not already running one + if activeSavedPath == nil { + if let matchedPath = await findMatchingSavedPath() { + activeSavedPath = matchedPath + logger.info("Matched path to saved path: \(matchedPath.name)") + } } - /// Generate a default name from the path (e.g., "Tower → ... → Ridge") - func generatePathName() -> String { - let names = outboundPath.compactMap { $0.resolvedName } - switch names.count { - case 0: - return L10n.Contacts.Contacts.PathName.prefix(String(fullPathString.prefix(8))) - case 1: - return names[0] - case 2: - return L10n.Contacts.Contacts.PathName.twoEndpoints(names[0], names[1]) - default: - return L10n.Contacts.Contacts.PathName.multipleEndpoints(names[0], names[names.count - 1]) + isRunning = true + result = nil + pendingPathHash = fullPathBytes + + // Generate random tag for correlation + let tag = UInt32.random(in: 0...UInt32.max) + pendingTag = tag + pendingDeviceID = connectedDevice?.radioID // Capture device + traceStartTime = Date() + + // Build path data + let pathData = Data(fullPathBytes) + + // Send trace command + let timeoutSeconds: Double + do { + let sentInfo = try await session.sendTrace( + tag: tag, + authCode: 0, // Not used for basic trace + flags: effectiveTraceMode, + path: pathData + ) + timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentInfo.suggestedTimeoutMs, profile: .flood) + logger.info("Sent trace with tag \(tag), path: \(self.fullPathString), timeout: \(timeoutSeconds)s") + } catch { + logger.error("Failed to send trace: \(error.localizedDescription)") + setError(L10n.Contacts.Contacts.Trace.Error.sendFailed) + pendingPathHash = nil + + // Record failed run for saved paths + if let savedPath = activeSavedPath, + let dataStore { + let failedRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: false, + roundTripMs: 0, + hopsSNR: [] + ) + Task { @MainActor [weak self] in + do { + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + self?.activeSavedPath = updated + } + } catch { + logger.error("Failed to record send failure: \(error.localizedDescription)") + } } - } + } - /// Extract SNR values from intermediate hops (excluding start and end nodes) - private func extractHopsSNR(from result: TraceResult) -> [Double] { - result.hops - .filter { !$0.isStartNode && !$0.isEndNode } - .map { $0.snr } + isRunning = false + pendingTag = nil + pendingDeviceID = nil + return } - /// Save the current path with the given name - /// - Returns: `true` if save succeeded, `false` otherwise - @discardableResult - func savePath(name: String) async -> Bool { - guard let radioID = connectedDevice?.radioID, - let dataStore else { return false } - - // For batch mode, save all completed results - if batchEnabled && !completedResults.isEmpty { - guard let firstSuccess = successfulResults.first else { return false } - - // Create initial run from first successful result - let initialRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: true, - roundTripMs: firstSuccess.durationMs, - hopsSNR: extractHopsSNR(from: firstSuccess) + // Wait for response with timeout + traceTask = Task { @MainActor in + do { + try await Task.sleep(for: .seconds(timeoutSeconds)) + + // Timeout - no response received + if !Task.isCancelled, pendingTag == tag { + logger.warning("Trace timeout for tag \(tag) after \(timeoutSeconds)s") + setError(L10n.Contacts.Contacts.Trace.Error.noResponse) + pendingPathHash = nil + + // Record failed run for saved paths + if let savedPath = activeSavedPath, + let dataStore { + let failedRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: false, + roundTripMs: 0, + hopsSNR: [] ) - do { - let savedPath = try await dataStore.createSavedTracePath( - radioID: radioID, - name: name, - pathBytes: Data(firstSuccess.tracedPathBytes), - hashSize: hashSize, - initialRun: initialRun - ) - - // Append remaining results as additional runs - for (index, batchResult) in completedResults.enumerated() { - // Skip the first successful result (already saved as initial) - if batchResult.id == firstSuccess.id { continue } - - let run = TracePathRunDTO( - id: UUID(), - date: Date().addingTimeInterval(Double(index)), - success: batchResult.success, - roundTripMs: batchResult.durationMs, - hopsSNR: extractHopsSNR(from: batchResult) - ) - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: run) - } - - // Refresh to get all runs - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - activeSavedPath = updated - } - logger.info("Saved batch path: \(name) with \(self.completedResults.count) runs") - return true + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + activeSavedPath = updated + } } catch { - logger.error("Failed to save batch path: \(error.localizedDescription)") - return false + logger.error("Failed to record timeout: \(error.localizedDescription)") } - } - - // Single trace mode (original behavior) - guard let result, result.success else { return false } - - let initialRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: true, - roundTripMs: result.durationMs, - hopsSNR: extractHopsSNR(from: result) - ) + } - do { - let savedPath = try await dataStore.createSavedTracePath( - radioID: radioID, - name: name, - pathBytes: Data(result.tracedPathBytes), - hashSize: hashSize, - initialRun: initialRun - ) - activeSavedPath = savedPath - logger.info("Saved path: \(name)") - return true - } catch { - logger.error("Failed to save path: \(error.localizedDescription)") - return false + isRunning = false + pendingTag = nil + pendingDeviceID = nil } + } catch { + // Task cancelled (response received) + } } + } - /// Load a saved path into the builder - func loadSavedPath(_ savedPath: SavedTracePathDTO) { - // Clear existing path - outboundPath.removeAll() - result = nil - pendingPathHash = nil - - // Reconstruct outbound path from saved bytes - // The saved pathBytes contains full path (outbound + return) - // We need to extract just the outbound portion - let fullPath = savedPath.pathBytes - let size = savedPath.hashSize - guard !fullPath.isEmpty else { return } - - // Match the trace hash mode to the saved width (size is 1/2/4 -> code - // 0/1/2), but only on a radio that honors the per-trace override; - // otherwise the trace follows the configured pathHashMode. - if connectedDevice?.supportsTraceHashSizeOverride == true { - traceHashMode = UInt8(size.trailingZeroBitCount) - } else { - traceHashMode = nil - } - - // Calculate total hops, then outbound is first half (rounded up) - let totalHops = fullPath.count / size - let outboundHopCount = (totalHops + 1) / 2 - let outboundByteCount = outboundHopCount * size - - for start in stride(from: 0, to: min(outboundByteCount, fullPath.count), by: size) { - let end = min(start + size, fullPath.count) - let hashBytes = Data(fullPath[start.. SavedTracePathDTO? { - guard let radioID = connectedDevice?.radioID, - let dataStore else { return nil } - - let pathBytes = fullPathBytes - guard !pathBytes.isEmpty else { return nil } + isRunning = true + result = nil - do { - let savedPaths = try await dataStore.fetchSavedTracePaths(radioID: radioID) - let matches = savedPaths.filter { $0.pathHashBytes == pathBytes } - - // Return most recently used (by latest run date) - return matches.max { path1, path2 in - let date1 = path1.runs.map(\.date).max() ?? .distantPast - let date2 = path2.runs.map(\.date).max() ?? .distantPast - return date1 < date2 - } - } catch { - logger.error("Failed to fetch saved paths for matching: \(error.localizedDescription)") - return nil - } - } + // Execute traces sequentially + for traceIndex in 1...batchSize { + // Check cancellation before starting the next trace + if batchCancelled { break } - // MARK: - Trace Execution + currentTraceIndex = traceIndex - /// Execute the trace and wait for response - func runTrace() async { - guard let session, - !outboundPath.isEmpty else { return } - - // Cancel any pending trace - traceTask?.cancel() - - // Clear previous results and errors - resultID = nil - clearError() - - // Match to saved path if not already running one - if activeSavedPath == nil { - if let matchedPath = await findMatchingSavedPath() { - activeSavedPath = matchedPath - logger.info("Matched path to saved path: \(matchedPath.name)") - } - } - - isRunning = true - result = nil - pendingPathHash = fullPathBytes - - // Generate random tag for correlation - let tag = UInt32.random(in: 0...UInt32.max) - pendingTag = tag - pendingDeviceID = connectedDevice?.radioID // Capture device - traceStartTime = Date() - - // Build path data - let pathData = Data(fullPathBytes) - - // Send trace command - let timeoutSeconds: Double - do { - let sentInfo = try await session.sendTrace( - tag: tag, - authCode: 0, // Not used for basic trace - flags: effectiveTraceMode, - path: pathData - ) - timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentInfo.suggestedTimeoutMs) - logger.info("Sent trace with tag \(tag), path: \(self.fullPathString), timeout: \(timeoutSeconds)s") - } catch { - logger.error("Failed to send trace: \(error.localizedDescription)") - setError(L10n.Contacts.Contacts.Trace.Error.sendFailed) - pendingPathHash = nil + // Run single trace and wait for result + await executeSingleTrace(session: session) - // Record failed run for saved paths - if let savedPath = activeSavedPath, - let dataStore { - let failedRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: false, - roundTripMs: 0, - hopsSNR: [] - ) - Task { @MainActor [weak self] in - do { - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - self?.activeSavedPath = updated - } - } catch { - logger.error("Failed to record send failure: \(error.localizedDescription)") - } - } - } - - isRunning = false - pendingTag = nil - pendingDeviceID = nil - return - } - - // Wait for response with timeout - traceTask = Task { @MainActor in - do { - try await Task.sleep(for: .seconds(timeoutSeconds)) - - // Timeout - no response received - if !Task.isCancelled && pendingTag == tag { - logger.warning("Trace timeout for tag \(tag) after \(timeoutSeconds)s") - setError(L10n.Contacts.Contacts.Trace.Error.noResponse) - pendingPathHash = nil - - // Record failed run for saved paths - if let savedPath = activeSavedPath, - let dataStore { - let failedRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: false, - roundTripMs: 0, - hopsSNR: [] - ) - do { - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - activeSavedPath = updated - } - } catch { - logger.error("Failed to record timeout: \(error.localizedDescription)") - } - } - - isRunning = false - pendingTag = nil - pendingDeviceID = nil - } - } catch { - // Task cancelled (response received) - } + // Check if we got a successful result to show the sheet + if let latestResult = completedResults.last, latestResult.success { + // First successful result triggers sheet presentation + if successCount == 1 { + result = latestResult + resultID = UUID() + } else { + // Update result for subsequent successful traces + result = latestResult } + } + + // Small buffer between traces (unless this is the last one) + if traceIndex < batchSize { + // Check cancellation before sleeping + if batchCancelled { break } + try? await Task.sleep(for: .milliseconds(Self.interTraceBufferMs)) + // Check cancellation after sleeping + if batchCancelled { break } + } } - // MARK: - Batch Trace Execution - - /// Execute multiple traces in batch mode - func runBatchTrace() async { - guard batchEnabled else { - await runTrace() - return - } - - // Reset batch state before any early returns - clearBatchState() - batchCancelled = false - resultID = nil - clearError() - - guard let session, - !outboundPath.isEmpty else { return } - - // Match to saved path if not already running one - if activeSavedPath == nil { - if let matchedPath = await findMatchingSavedPath() { - activeSavedPath = matchedPath - logger.info("Matched path to saved path: \(matchedPath.name)") - } - } - - isRunning = true - result = nil - - // Execute traces sequentially - for traceIndex in 1...batchSize { - // Check cancellation before starting the next trace - if batchCancelled { break } - - currentTraceIndex = traceIndex - - // Run single trace and wait for result - await executeSingleTrace(session: session) - - // Check if we got a successful result to show the sheet - if let latestResult = completedResults.last, latestResult.success { - // First successful result triggers sheet presentation - if successCount == 1 { - result = latestResult - resultID = UUID() - } else { - // Update result for subsequent successful traces - result = latestResult - } - } - - // Small buffer between traces (unless this is the last one) - if traceIndex < batchSize { - // Check cancellation before sleeping - if batchCancelled { break } - try? await Task.sleep(for: .milliseconds(Self.interTraceBufferMs)) - // Check cancellation after sleeping - if batchCancelled { break } - } - } + isRunning = false + currentTraceIndex = 0 - isRunning = false - currentTraceIndex = 0 - - // If batch completed but all traces failed, show error - if isBatchComplete && successCount == 0 { - setError(L10n.Contacts.Contacts.Trace.Error.allFailed(batchSize)) - } + // If batch completed but all traces failed, show error + if isBatchComplete, successCount == 0 { + setError(L10n.Contacts.Contacts.Trace.Error.allFailed(batchSize)) + } + } + + /// Execute a single trace within a batch, storing result in completedResults + private func executeSingleTrace(session: MeshCoreSession) async { + pendingPathHash = fullPathBytes + + // Generate random tag for correlation + let tag = UInt32.random(in: 0...UInt32.max) + pendingTag = tag + pendingDeviceID = connectedDevice?.radioID + traceStartTime = Date() + + let pathData = Data(fullPathBytes) + + let timeoutSeconds: Double + do { + let sentInfo = try await session.sendTrace( + tag: tag, + authCode: 0, + flags: effectiveTraceMode, + path: pathData + ) + timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentInfo.suggestedTimeoutMs, profile: .flood) + logger.info("Sent batch trace \(self.currentTraceIndex)/\(self.batchSize) with tag \(tag), timeout: \(timeoutSeconds)s") + } catch { + logger.error("Failed to send trace: \(error.localizedDescription)") + let failedResult = TraceResult.sendFailed( + L10n.Contacts.Contacts.Trace.Error.sendFailed, + attemptedPath: pendingPathHash ?? [], + hashSize: hashSize + ) + completedResults.append(failedResult) + recordFailedRun() + pendingPathHash = nil + pendingTag = nil + return } - /// Execute a single trace within a batch, storing result in completedResults - private func executeSingleTrace(session: MeshCoreSession) async { - pendingPathHash = fullPathBytes - - // Generate random tag for correlation - let tag = UInt32.random(in: 0...UInt32.max) - pendingTag = tag - pendingDeviceID = connectedDevice?.radioID - traceStartTime = Date() - - let pathData = Data(fullPathBytes) + // Wait for response with timeout using continuation + // Store continuation so handleTraceResponse can resume it immediately + await withCheckedContinuation { (continuation: CheckedContinuation) in + traceContinuation = continuation - let timeoutSeconds: Double + traceTask = Task { @MainActor in do { - let sentInfo = try await session.sendTrace( - tag: tag, - authCode: 0, - flags: effectiveTraceMode, - path: pathData - ) - timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentInfo.suggestedTimeoutMs) - logger.info("Sent batch trace \(self.currentTraceIndex)/\(self.batchSize) with tag \(tag), timeout: \(timeoutSeconds)s") - } catch { - logger.error("Failed to send trace: \(error.localizedDescription)") - let failedResult = TraceResult.sendFailed( - L10n.Contacts.Contacts.Trace.Error.sendFailed, - attemptedPath: pendingPathHash ?? [], - hashSize: hashSize - ) - completedResults.append(failedResult) + try await Task.sleep(for: .seconds(timeoutSeconds)) + + // Timeout - resume continuation if still waiting + if traceContinuation != nil, pendingTag == tag { + logger.warning("Batch trace timeout for tag \(tag) after \(timeoutSeconds)s") + let timeoutResult = TraceResult.timeout(attemptedPath: pendingPathHash ?? [], hashSize: hashSize) + completedResults.append(timeoutResult) recordFailedRun() pendingPathHash = nil pendingTag = nil - return - } - // Wait for response with timeout using continuation - // Store continuation so handleTraceResponse can resume it immediately - await withCheckedContinuation { (continuation: CheckedContinuation) in - traceContinuation = continuation - - traceTask = Task { @MainActor in - do { - try await Task.sleep(for: .seconds(timeoutSeconds)) - - // Timeout - resume continuation if still waiting - if traceContinuation != nil && pendingTag == tag { - logger.warning("Batch trace timeout for tag \(tag) after \(timeoutSeconds)s") - let timeoutResult = TraceResult.timeout(attemptedPath: pendingPathHash ?? [], hashSize: hashSize) - completedResults.append(timeoutResult) - recordFailedRun() - pendingPathHash = nil - pendingTag = nil - - // Resume continuation atomically (only if not already resumed by handleTraceResponse) - if let continuation = traceContinuation { - traceContinuation = nil - continuation.resume() - } - } - } catch { - // Cancelled - handleTraceResponse already resumed continuation - } + // Resume continuation atomically (only if not already resumed by handleTraceResponse) + if let continuation = traceContinuation { + traceContinuation = nil + continuation.resume() } + } + } catch { + // Cancelled - handleTraceResponse already resumed continuation } + } } + } + + /// Record a failed run for saved paths + private func recordFailedRun() { + guard let savedPath = activeSavedPath, + let dataStore else { return } + + let failedRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: false, + roundTripMs: 0, + hopsSNR: [] + ) - /// Record a failed run for saved paths - private func recordFailedRun() { - guard let savedPath = activeSavedPath, - let dataStore else { return } - - let failedRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: false, - roundTripMs: 0, - hopsSNR: [] - ) - - Task { @MainActor [weak self] in - do { - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - self?.activeSavedPath = updated - } - } catch { - logger.error("Failed to record run: \(error.localizedDescription)") - } + Task { @MainActor [weak self] in + do { + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + self?.activeSavedPath = updated } + } catch { + logger.error("Failed to record run: \(error.localizedDescription)") + } } + } - /// Cancel any running batch trace - func cancelBatchTrace() { - // Set cancel flag for batch loop - batchCancelled = true + /// Cancel any running batch trace + func cancelBatchTrace() { + // Set cancel flag for batch loop + batchCancelled = true - // Cancel current trace timeout task - traceTask?.cancel() - traceTask = nil + // Cancel current trace timeout task + traceTask?.cancel() + traceTask = nil - // Clear continuation if waiting (prevent leaked continuation) - if let continuation = traceContinuation { - traceContinuation = nil - continuation.resume() - } + // Clear continuation if waiting (prevent leaked continuation) + if let continuation = traceContinuation { + traceContinuation = nil + continuation.resume() + } - isRunning = false - currentTraceIndex = 0 - pendingTag = nil - pendingDeviceID = nil - pendingPathHash = nil + isRunning = false + currentTraceIndex = 0 + pendingTag = nil + pendingDeviceID = nil + pendingPathHash = nil + } + + /// Handle trace response from event stream + func handleTraceResponse(_ traceInfo: TraceInfo, radioID: UUID?) { + guard traceInfo.tag == pendingTag else { + logger.debug("Ignoring trace response with non-matching tag \(traceInfo.tag)") + return } - /// Handle trace response from event stream - func handleTraceResponse(_ traceInfo: TraceInfo, radioID: UUID?) { - guard traceInfo.tag == pendingTag else { - logger.debug("Ignoring trace response with non-matching tag \(traceInfo.tag)") - return - } + // Validate device ID if both are available; skip if either is nil + if let pending = pendingDeviceID, let received = radioID, pending != received { + logger.warning("Ignoring trace response from different device") + return + } - // Validate device ID if both are available; skip if either is nil - if let pending = pendingDeviceID, let received = radioID, pending != received { - logger.warning("Ignoring trace response from different device") - return - } + // Cancel timeout + traceTask?.cancel() + traceTask = nil - // Cancel timeout - traceTask?.cancel() - traceTask = nil + // Calculate duration + let durationMs = if let startTime = traceStartTime { + Int(Date().timeIntervalSince(startTime) * 1000) + } else { + 0 + } - // Calculate duration - let durationMs: Int - if let startTime = traceStartTime { - durationMs = Int(Date().timeIntervalSince(startTime) * 1000) + // Build hops from response using receiver attribution model: + // Each node's SNR shows what it measured when receiving. + // This answers "how well did this node receive the signal?" + var hops: [TraceHop] = [] + let deviceName = connectedDevice?.nodeName ?? L10n.Contacts.Contacts.Results.Hop.myDevice + let path = traceInfo.path + + let deviceLocation = bestAvailableLocation + let deviceLat = deviceLocation?.coordinate.latitude + let deviceLon = deviceLocation?.coordinate.longitude + + // Start node has no SNR (it transmitted first, didn't receive anything) + hops.append(TraceHop( + hashBytes: nil, + resolvedName: deviceName, + snr: 0, + isStartNode: true, + isEndNode: false, + latitude: deviceLat, + longitude: deviceLon + )) + + // Intermediate hops - each shows SNR it measured when receiving + for node in path where node.hashBytes != nil { + let resolvedName: String? + var latitude: Double? + var longitude: Double? + + if let bytes = node.hashBytes { + let matchingHop = outboundPath.first(where: { $0.hashBytes == bytes }) + let match = matchingHop.flatMap { resolveNode(for: $0) } ?? resolveNode(for: bytes) + + if let match { + resolvedName = match.resolvableName + if match.hasLocation { + latitude = match.latitude + longitude = match.longitude + } } else { - durationMs = 0 + resolvedName = matchingHop?.resolvedName } + } else { + resolvedName = nil + } + + hops.append(TraceHop( + hashBytes: node.hashBytes, + resolvedName: resolvedName, + snr: node.snr, + isStartNode: false, + isEndNode: false, + latitude: latitude, + longitude: longitude + )) + } - // Build hops from response using receiver attribution model: - // Each node's SNR shows what it measured when receiving. - // This answers "how well did this node receive the signal?" - var hops: [TraceHop] = [] - let deviceName = connectedDevice?.nodeName ?? L10n.Contacts.Contacts.Results.Hop.myDevice - let path = traceInfo.path - - let deviceLocation = bestAvailableLocation - let deviceLat = deviceLocation?.coordinate.latitude - let deviceLon = deviceLocation?.coordinate.longitude - - // Start node has no SNR (it transmitted first, didn't receive anything) - hops.append(TraceHop( - hashBytes: nil, - resolvedName: deviceName, - snr: 0, - isStartNode: true, - isEndNode: false, - latitude: deviceLat, - longitude: deviceLon - )) - - // Intermediate hops - each shows SNR it measured when receiving - for node in path where node.hashBytes != nil { - let resolvedName: String? - var latitude: Double? - var longitude: Double? - - if let bytes = node.hashBytes { - let matchingHop = outboundPath.first(where: { $0.hashBytes == bytes }) - let match = matchingHop.flatMap({ resolveNode(for: $0) }) ?? resolveNode(for: bytes) - - if let match { - resolvedName = match.resolvableName - if match.hasLocation { - latitude = match.latitude - longitude = match.longitude - } - } else { - resolvedName = matchingHop?.resolvedName - } - } else { - resolvedName = nil - } - - hops.append(TraceHop( - hashBytes: node.hashBytes, - resolvedName: resolvedName, - snr: node.snr, - isStartNode: false, - isEndNode: false, - latitude: latitude, - longitude: longitude - )) - } - - // End node shows SNR it measured when receiving - let endSnr = path.last?.snr ?? 0 - hops.append(TraceHop( - hashBytes: nil, - resolvedName: deviceName, - snr: endSnr, - isStartNode: false, - isEndNode: true, - latitude: deviceLat, - longitude: deviceLon - )) - - result = TraceResult( - hops: hops, - durationMs: durationMs, - success: true, - errorMessage: nil, - tracedPathBytes: pendingPathHash ?? [], - hashSize: hashSize - ) - - // In batch mode, store result and resume continuation - if batchEnabled, let result { - completedResults.append(result) - } else { - resultID = UUID() - isRunning = false - } + // End node shows SNR it measured when receiving + let endSnr = path.last?.snr ?? 0 + hops.append(TraceHop( + hashBytes: nil, + resolvedName: deviceName, + snr: endSnr, + isStartNode: false, + isEndNode: true, + latitude: deviceLat, + longitude: deviceLon + )) + + result = TraceResult( + hops: hops, + durationMs: durationMs, + success: true, + errorMessage: nil, + tracedPathBytes: pendingPathHash ?? [], + hashSize: hashSize + ) - // Resume continuation if waiting (enables immediate batch progression) - if let continuation = traceContinuation { - traceContinuation = nil - traceTask?.cancel() - continuation.resume() - } + // In batch mode, store result and resume continuation + if batchEnabled, let result { + completedResults.append(result) + } else { + resultID = UUID() + isRunning = false + } - pendingPathHash = nil - pendingTag = nil - pendingDeviceID = nil - traceStartTime = nil - - // Auto-append run if this is a saved path - if let savedPath = activeSavedPath, - let dataStore { - let hopsSNR = hops - .filter { !$0.isStartNode && !$0.isEndNode } - .map { $0.snr } - let runDTO = TracePathRunDTO( - id: UUID(), - date: Date(), - success: true, - roundTripMs: durationMs, - hopsSNR: hopsSNR - ) + // Resume continuation if waiting (enables immediate batch progression) + if let continuation = traceContinuation { + traceContinuation = nil + traceTask?.cancel() + continuation.resume() + } - Task { @MainActor [weak self] in - do { - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: runDTO) - // Refresh saved path to get updated runs - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - self?.activeSavedPath = updated - } - logger.info("Appended run to saved path") - } catch { - logger.error("Failed to append run: \(error.localizedDescription)") - } - } + pendingPathHash = nil + pendingTag = nil + pendingDeviceID = nil + traceStartTime = nil + + // Auto-append run if this is a saved path + if let savedPath = activeSavedPath, + let dataStore { + let hopsSNR = hops + .filter { !$0.isStartNode && !$0.isEndNode } + .map(\.snr) + let runDTO = TracePathRunDTO( + id: UUID(), + date: Date(), + success: true, + roundTripMs: durationMs, + hopsSNR: hopsSNR + ) + + Task { @MainActor [weak self] in + do { + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: runDTO) + // Refresh saved path to get updated runs + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + self?.activeSavedPath = updated + } + logger.info("Appended run to saved path") + } catch { + logger.error("Failed to append run: \(error.localizedDescription)") } - - logger.info("Trace completed: \(hops.count) hops, \(durationMs)ms") + } } - // MARK: - Testing Support + logger.info("Trace completed: \(hops.count) hops, \(durationMs)ms") + } + + // MARK: - Testing Support - #if DEBUG + #if DEBUG /// Test helper to set pending tag without running a full trace func setPendingTagForTesting(_ tag: UInt32) { - pendingTag = tag + pendingTag = tag } /// Test helper to set pending device ID func setPendingDeviceIDForTesting(_ radioID: UUID?) { - pendingDeviceID = radioID + pendingDeviceID = radioID } /// Test helper to set pending path hash func setPendingPathHashForTesting(_ pathHash: [UInt8]?) { - pendingPathHash = pathHash + pendingPathHash = pathHash } /// Test helper to set contacts for hash resolution func setContactsForTesting(_ contacts: [ContactDTO]) { - allContacts = contacts - availableRepeaters = contacts.filter { $0.type == .repeater } - availableRooms = contacts.filter { $0.type == .room } + allContacts = contacts + availableRepeaters = contacts.filter { $0.type == .repeater } + availableRooms = contacts.filter { $0.type == .room } } - #endif + #endif } // MARK: - HopPickerSource extension TracePathViewModel: HopPickerSource { - var currentHopCount: Int { outboundPath.count } - - /// Trace paths are uncapped; `nil` tells the shared picker not to gate adds - /// (the `HopPickerSource` default then makes `isPathFull` always `false`). - var hopLimit: Int? { nil } - - func appendHop(_ node: some RepeaterResolvable) { - addNode(node) - recordRecent(node.publicKey) - } - - func addCodes(_ input: String) -> CodeInputResult { - addRepeatersFromCodes(input) - } - - func classifyCodes(_ input: String) -> [HopCodeClassification] { - let existing = Set(outboundPath.map(\.hashBytes)) - return HopCodeParser.classify( - input: input, - hashSize: hashSize, - existingHashes: existing, - remainingCapacity: nil - ) { hash in - guard let match = resolveNode(for: hash) else { return nil } - return (match.publicKey, match.resolvableName) - } + var currentHopCount: Int { + outboundPath.count + } + + /// Trace paths are uncapped; `nil` tells the shared picker not to gate adds + /// (the `HopPickerSource` default then makes `isPathFull` always `false`). + var hopLimit: Int? { + nil + } + + func appendHop(_ node: some RepeaterResolvable) { + addNode(node) + recordRecent(node.publicKey) + } + + func addCodes(_ input: String) -> CodeInputResult { + addRepeatersFromCodes(input) + } + + func classifyCodes(_ input: String) -> [HopCodeClassification] { + let existing = Set(outboundPath.map(\.hashBytes)) + return HopCodeParser.classify( + input: input, + hashSize: hashSize, + existingHashes: existing, + remainingCapacity: nil + ) { hash in + guard let match = resolveNode(for: hash) else { return nil } + return (match.publicKey, match.resolvableName) } - - /// Trace hop widths the firmware accepts, in bytes (power-of-2 encoding). - private static let validTraceHashSizes = [1, 2, 4] - - /// When a pasted bulk entry's codes all share one valid trace width that the - /// radio can honor and differs from the active width, switch to it so the - /// codes parse instead of failing as invalid. Skips mixed-width, malformed, - /// or already-matching input so it never wipes a result redundantly. - func adoptHashSize(forPastedCodes input: String) { - guard connectedDevice?.supportsTraceHashSizeOverride == true, - let mode = Self.inferredTraceHashMode(from: input), - mode != effectiveTraceMode else { return } - setTraceHashMode(mode) - } - - /// The single trace hash mode (0/1/2 for 1/2/4 bytes) implied by a - /// comma-separated bulk paste, or `nil` when the codes are empty, non-hex, - /// odd-length, mixed-width, or not a valid power-of-2 trace width. - static func inferredTraceHashMode(from input: String) -> UInt8? { - let tokens = input - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - guard !tokens.isEmpty else { return nil } - - var width: Int? - for token in tokens { - guard token.count.isMultiple(of: 2), token.allSatisfy(\.isHexDigit) else { return nil } - let bytes = token.count / 2 - if let width, width != bytes { return nil } - width = bytes - } - guard let width, validTraceHashSizes.contains(width) else { return nil } - return UInt8(width.trailingZeroBitCount) - } - - func recordRecent(_ publicKey: Data) { - guard let radioID = currentRadioID else { return } - recentPublicKeys = recents.record(publicKey, into: recentPublicKeys, for: radioID) + } + + /// Trace hop widths the firmware accepts, in bytes (power-of-2 encoding). + private static let validTraceHashSizes = [1, 2, 4] + + /// When a pasted bulk entry's codes all share one valid trace width that the + /// radio can honor and differs from the active width, switch to it so the + /// codes parse instead of failing as invalid. Skips mixed-width, malformed, + /// or already-matching input so it never wipes a result redundantly. + func adoptHashSize(forPastedCodes input: String) { + guard connectedDevice?.supportsTraceHashSizeOverride == true, + let mode = Self.inferredTraceHashMode(from: input), + mode != effectiveTraceMode else { return } + setTraceHashMode(mode) + } + + /// The single trace hash mode (0/1/2 for 1/2/4 bytes) implied by a + /// comma-separated bulk paste, or `nil` when the codes are empty, non-hex, + /// odd-length, mixed-width, or not a valid power-of-2 trace width. + static func inferredTraceHashMode(from input: String) -> UInt8? { + let tokens = input + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !tokens.isEmpty else { return nil } + + var width: Int? + for token in tokens { + guard token.count.isMultiple(of: 2), token.allSatisfy(\.isHexDigit) else { return nil } + let bytes = token.count / 2 + if let width, width != bytes { return nil } + width = bytes } + guard let width, validTraceHashSizes.contains(width) else { return nil } + return UInt8(width.trailingZeroBitCount) + } + + func recordRecent(_ publicKey: Data) { + guard let radioID = currentRadioID else { return } + recentPublicKeys = recents.record(publicKey, into: recentPublicKeys, for: radioID) + } } diff --git a/MC1/Views/Tools/TracePath/TraceResult.swift b/MC1/Views/Tools/TracePath/TraceResult.swift index e23f1e75..6a8ae3b5 100644 --- a/MC1/Views/Tools/TracePath/TraceResult.swift +++ b/MC1/Views/Tools/TracePath/TraceResult.swift @@ -2,30 +2,30 @@ import Foundation /// Result of a trace operation struct TraceResult: Identifiable { - let id = UUID() - let hops: [TraceHop] - let durationMs: Int - let success: Bool - let errorMessage: String? - let tracedPathBytes: [UInt8] // Path that was actually traced - let hashSize: Int // Bytes per hop (1, 2, or 4) + let id = UUID() + let hops: [TraceHop] + let durationMs: Int + let success: Bool + let errorMessage: String? + let tracedPathBytes: [UInt8] // Path that was actually traced + let hashSize: Int // Bytes per hop (1, 2, or 4) - /// Comma-separated path string for display/copy, chunked by hash size - var tracedPathString: String { - let data = Data(tracedPathBytes) - return stride(from: 0, to: data.count, by: hashSize).map { start in - let end = min(start + hashSize, data.count) - return data[start.. TraceResult { - TraceResult(hops: [], durationMs: 0, success: false, - errorMessage: L10n.Contacts.Contacts.Trace.Error.noResponse, tracedPathBytes: attemptedPath, hashSize: hashSize) - } + static func timeout(attemptedPath: [UInt8], hashSize: Int) -> TraceResult { + TraceResult(hops: [], durationMs: 0, success: false, + errorMessage: L10n.Contacts.Contacts.Trace.Error.noResponse, tracedPathBytes: attemptedPath, hashSize: hashSize) + } - static func sendFailed(_ message: String, attemptedPath: [UInt8], hashSize: Int) -> TraceResult { - TraceResult(hops: [], durationMs: 0, success: false, - errorMessage: message, tracedPathBytes: attemptedPath, hashSize: hashSize) - } + static func sendFailed(_ message: String, attemptedPath: [UInt8], hashSize: Int) -> TraceResult { + TraceResult(hops: [], durationMs: 0, success: false, + errorMessage: message, tracedPathBytes: attemptedPath, hashSize: hashSize) + } } diff --git a/MC1/Views/Tools/TracePath/TraceResultHopRow.swift b/MC1/Views/Tools/TracePath/TraceResultHopRow.swift index 4667bd4f..d8117b00 100644 --- a/MC1/Views/Tools/TracePath/TraceResultHopRow.swift +++ b/MC1/Views/Tools/TracePath/TraceResultHopRow.swift @@ -1,95 +1,101 @@ -import SwiftUI import MC1Services +import SwiftUI // MARK: - Result Hop Row /// Row for displaying a hop in the trace results struct TraceResultHopRow: View { - let hop: TraceHop - let hopIndex: Int - var batchStats: (avg: Double, min: Double, max: Double)? - var latestSNR: Double? - var isBatchInProgress: Bool = false + let hop: TraceHop + let hopIndex: Int + var batchStats: (avg: Double, min: Double, max: Double)? + var latestSNR: Double? + var isBatchInProgress: Bool = false - /// SNR value to use for signal bars (latest during progress, average when complete) - private var displaySNR: Double { - if isBatchInProgress { - return latestSNR ?? hop.snr - } else if let stats = batchStats { - return stats.avg - } else { - return hop.snr - } + /// SNR value to use for signal bars (latest during progress, average when complete) + private var displaySNR: Double { + if isBatchInProgress { + latestSNR ?? hop.snr + } else if let stats = batchStats { + stats.avg + } else { + hop.snr } + } - private var snrQuality: SNRQuality { SNRQuality(snr: displaySNR) } + private var snrQuality: SNRQuality { + SNRQuality(snr: displaySNR) + } - private var signalLevel: Double { snrQuality.barLevel } + private var signalLevel: Double { + snrQuality.barLevel + } - private var signalColor: Color { snrQuality.color } + private var signalColor: Color { + snrQuality.color + } - var body: some View { - HStack { - VStack(alignment: .leading) { - // Node identifier - if hop.isStartNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - Text(L10n.Contacts.Contacts.Results.Hop.started) - .font(.caption) - .foregroundStyle(.secondary) - } else if hop.isEndNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - .foregroundStyle(.green) - Text(L10n.Contacts.Contacts.Results.Hop.received) - .font(.caption) - .foregroundStyle(.secondary) - } else if let hashDisplay = hop.hashDisplayString { - HStack { - Text(hashDisplay) - .font(.body.monospaced()) - .foregroundStyle(.secondary) - if let name = hop.resolvedName { - Text(name) - } - } - Text(L10n.Contacts.Contacts.Results.Hop.repeated) - .font(.caption) - .foregroundStyle(.secondary) - } - - // SNR display - batch mode shows avg with range, single shows plain SNR - if !hop.isStartNode { - if let stats = batchStats { - let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) - Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - } else { - Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) - .font(.caption) - .foregroundStyle(.secondary) - } - } + var body: some View { + HStack { + VStack(alignment: .leading) { + // Node identifier + if hop.isStartNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + Text(L10n.Contacts.Contacts.Results.Hop.started) + .font(.caption) + .foregroundStyle(.secondary) + } else if hop.isEndNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + .foregroundStyle(.green) + Text(L10n.Contacts.Contacts.Results.Hop.received) + .font(.caption) + .foregroundStyle(.secondary) + } else if let hashDisplay = hop.hashDisplayString { + HStack { + Text(hashDisplay) + .font(.body.monospaced()) + .foregroundStyle(.secondary) + if let name = hop.resolvedName { + Text(name) } + } + Text(L10n.Contacts.Contacts.Results.Hop.repeated) + .font(.caption) + .foregroundStyle(.secondary) + } - Spacer() - - // Signal strength indicator (not for start node - it didn't receive) - if !hop.isStartNode { - Image(systemName: "cellularbars", variableValue: signalLevel) - .foregroundStyle(signalColor) - .font(.title2) - } + // SNR display - batch mode shows avg with range, single shows plain SNR + if !hop.isStartNode { + if let stats = batchStats { + let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) + Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + } else { + Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) + .font(.caption) + .foregroundStyle(.secondary) + } } - .padding(.vertical, 4) + } + + Spacer() + + // Signal strength indicator (not for start node - it didn't receive) + if !hop.isStartNode { + Image(systemName: "cellularbars", variableValue: signalLevel) + .foregroundStyle(signalColor) + .font(.title2) + } } + .padding(.vertical, 4) + } } diff --git a/MC1/Views/Tools/TracePath/TraceResultsSectionView.swift b/MC1/Views/Tools/TracePath/TraceResultsSectionView.swift index 639b6fca..4357cee2 100644 --- a/MC1/Views/Tools/TracePath/TraceResultsSectionView.swift +++ b/MC1/Views/Tools/TracePath/TraceResultsSectionView.swift @@ -1,80 +1,87 @@ -import SwiftUI import MC1Services +import SwiftUI /// Section displaying trace result hops, RTT info, distance, and save action struct TraceResultsSectionView: View { - @Environment(\.appTheme) private var theme - let result: TraceResult - @Bindable var viewModel: TracePathViewModel - @Binding var saveHapticTrigger: Int - @Binding var showingDistanceInfo: Bool + @Environment(\.appTheme) private var theme + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Binding var saveHapticTrigger: Int + @Binding var showingDistanceInfo: Bool - var body: some View { - Section { - if result.success { - ForEach(Array(result.hops.enumerated()), id: \.element.id) { index, hop in - TraceResultHopRow( - hop: hop, - hopIndex: index, - batchStats: viewModel.batchEnabled ? viewModel.hopStats(at: index) : nil, - latestSNR: viewModel.batchEnabled ? viewModel.latestHopSNR(at: index) : nil, - isBatchInProgress: viewModel.isBatchInProgress - ) - } + var body: some View { + Section { + if result.success { + ForEach(Array(result.hops.enumerated()), id: \.element.id) { index, hop in + TraceResultHopRow( + hop: hop, + hopIndex: index, + batchStats: viewModel.batchEnabled ? viewModel.hopStats(at: index) : nil, + latestSNR: viewModel.batchEnabled ? viewModel.latestHopSNR(at: index) : nil, + isBatchInProgress: viewModel.isBatchInProgress + ) + } - // Batch status row (progress or completion) - if viewModel.batchEnabled && (viewModel.isBatchInProgress || viewModel.isBatchComplete) { - HStack { - if viewModel.isBatchComplete { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(L10n.Contacts.Contacts.Results.batchSuccess(viewModel.successCount, viewModel.batchSize)) - .foregroundStyle(.secondary) - } else { - ProgressView() - .controlSize(.small) - Text(L10n.Contacts.Contacts.Results.batchProgress(viewModel.currentTraceIndex, viewModel.batchSize)) - .foregroundStyle(.secondary) - } - Spacer() - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel( - viewModel.isBatchComplete - ? L10n.Contacts.Contacts.Results.batchCompleteLabel(viewModel.successCount, viewModel.batchSize) - : L10n.Contacts.Contacts.Results.batchProgressLabel(viewModel.currentTraceIndex, viewModel.batchSize) - ) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - } + // Batch status row (progress or completion) + if viewModel.batchEnabled, viewModel.isBatchInProgress || viewModel.isBatchComplete { + let successPercent = viewModel.batchSize > 0 + ? (viewModel.successCount * 100) / viewModel.batchSize + : 0 + HStack { + if viewModel.isBatchComplete { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(L10n.Contacts.Contacts.Results.batchSuccess( + viewModel.successCount, viewModel.batchSize, successPercent + )) + .foregroundStyle(.secondary) + } else { + ProgressView() + .controlSize(.small) + Text(L10n.Contacts.Contacts.Results.batchProgress(viewModel.currentTraceIndex, viewModel.batchSize)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel( + viewModel.isBatchComplete + ? L10n.Contacts.Contacts.Results.batchCompleteLabel( + viewModel.successCount, viewModel.batchSize, successPercent + ) + : L10n.Contacts.Contacts.Results.batchProgressLabel(viewModel.currentTraceIndex, viewModel.batchSize) + ) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + } - // Duration row with batch or single display - if viewModel.batchEnabled && viewModel.successCount > 0 { - BatchRTTRow(viewModel: viewModel) - } else if viewModel.isRunningSavedPath, let previous = viewModel.previousRun { - ComparisonRowView(currentMs: result.durationMs, previousRun: previous, viewModel: viewModel) - } else { - HStack { - Text(L10n.Contacts.Contacts.PathDetail.roundTrip) - .foregroundStyle(.secondary) - Spacer() - Text("\(result.durationMs) ms") - .font(.body.monospacedDigit()) - } - } + // Duration row with batch or single display + if viewModel.batchEnabled, viewModel.successCount > 0 { + BatchRTTRow(viewModel: viewModel) + } else if viewModel.isRunningSavedPath, let previous = viewModel.previousRun { + ComparisonRowView(currentMs: result.durationMs, previousRun: previous, viewModel: viewModel) + } else { + HStack { + Text(L10n.Contacts.Contacts.PathDetail.roundTrip) + .foregroundStyle(.secondary) + Spacer() + Text("\(result.durationMs) ms") + .font(.body.monospacedDigit()) + } + } - // Total distance row - TotalDistanceRow(viewModel: viewModel, result: result, showingDistanceInfo: $showingDistanceInfo) + // Total distance row + TotalDistanceRow(viewModel: viewModel, result: result, showingDistanceInfo: $showingDistanceInfo) - // Save path action (only for successful traces when not running a saved path) - if !viewModel.isRunningSavedPath { - SavePathRowView(viewModel: viewModel, saveHapticTrigger: $saveHapticTrigger) - } - } else if let error = result.errorMessage { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - } + // Save path action (only for successful traces when not running a saved path) + if !viewModel.isRunningSavedPath { + SavePathRowView(viewModel: viewModel, saveHapticTrigger: $saveHapticTrigger) } - .themedRowBackground(theme) + } else if let error = result.errorMessage { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Tools/TracePath/TraceResultsSheet.swift b/MC1/Views/Tools/TracePath/TraceResultsSheet.swift index f48580b4..a3b5e311 100644 --- a/MC1/Views/Tools/TracePath/TraceResultsSheet.swift +++ b/MC1/Views/Tools/TracePath/TraceResultsSheet.swift @@ -1,71 +1,71 @@ -import SwiftUI import MC1Services +import SwiftUI /// Full-screen sheet displaying trace results struct TraceResultsSheet: View { - let result: TraceResult - @Bindable var viewModel: TracePathViewModel - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss - // Save dialog state - @State private var saveHapticTrigger = 0 - @State private var copyHapticTrigger = 0 - @State private var showingDistanceInfo = false + // Save dialog state + @State private var saveHapticTrigger = 0 + @State private var copyHapticTrigger = 0 + @State private var showingDistanceInfo = false - var body: some View { - NavigationStack { - List { - TraceResultsSectionView( - result: result, - viewModel: viewModel, - saveHapticTrigger: $saveHapticTrigger, - showingDistanceInfo: $showingDistanceInfo - ) - roundTripPathSection - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationDestination(for: TracePathRoute.self) { route in - switch route { - case .savedPathDetail(let savedPath): - SavedPathDetailView(savedPath: savedPath) - } - } - .navigationTitle(L10n.Contacts.Contacts.Results.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(L10n.Contacts.Contacts.Results.dismiss, systemImage: "xmark") { - dismiss() - } - } - } - .sensoryFeedback(.success, trigger: saveHapticTrigger) - .sensoryFeedback(.success, trigger: copyHapticTrigger) + var body: some View { + NavigationStack { + List { + TraceResultsSectionView( + result: result, + viewModel: viewModel, + saveHapticTrigger: $saveHapticTrigger, + showingDistanceInfo: $showingDistanceInfo + ) + roundTripPathSection + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationDestination(for: TracePathRoute.self) { route in + switch route { + case let .savedPathDetail(savedPath): + SavedPathDetailView(savedPath: savedPath) + } + } + .navigationTitle(L10n.Contacts.Contacts.Results.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.Contacts.Contacts.Results.dismiss, systemImage: "xmark") { + dismiss() + } } + } + .sensoryFeedback(.success, trigger: saveHapticTrigger) + .sensoryFeedback(.success, trigger: copyHapticTrigger) } + } - // MARK: - Outbound Path Section + // MARK: - Outbound Path Section - private var roundTripPathSection: some View { - Section { - HStack { - Text(result.tracedPathString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + private var roundTripPathSection: some View { + Section { + HStack { + Text(result.tracedPathString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = result.tracedPathString - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - } - } header: { - Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) + Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = result.tracedPathString } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + } header: { + Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) } + } } diff --git a/MC1/Views/WhatsNew/WhatsNewSheet.swift b/MC1/Views/WhatsNew/WhatsNewSheet.swift index 88e5113d..5215a549 100644 --- a/MC1/Views/WhatsNew/WhatsNewSheet.swift +++ b/MC1/Views/WhatsNew/WhatsNewSheet.swift @@ -3,105 +3,105 @@ import SwiftUI /// The "What's New" sheet. Non-safety chrome, so glass on the Continue button is /// allowed; Continue and swipe-to-dismiss share one dismiss path that persists the baseline. struct WhatsNewSheet: View { - let release: WhatsNewRelease + let release: WhatsNewRelease - @Environment(\.dismiss) private var dismiss + @Environment(\.dismiss) private var dismiss - fileprivate enum Metrics { - static let titleTopPadding: CGFloat = 32 - static let rowSpacing: CGFloat = 28 - static let titleToRowsSpacing: CGFloat = 36 - static let symbolColumnWidth: CGFloat = 44 - static let symbolToTextSpacing: CGFloat = 16 - static let titleToBodySpacing: CGFloat = 4 - } + fileprivate enum Metrics { + static let titleTopPadding: CGFloat = 32 + static let rowSpacing: CGFloat = 28 + static let titleToRowsSpacing: CGFloat = 36 + static let symbolColumnWidth: CGFloat = 44 + static let symbolToTextSpacing: CGFloat = 16 + static let titleToBodySpacing: CGFloat = 4 + } - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: Metrics.titleToRowsSpacing) { - Text(L10n.WhatsNew.WhatsNew.title) - .font(.largeTitle.bold()) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .center) - .accessibilityHeading(.h1) - .padding(.top, Metrics.titleTopPadding) + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Metrics.titleToRowsSpacing) { + Text(L10n.WhatsNew.WhatsNew.title) + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .accessibilityHeading(.h1) + .padding(.top, Metrics.titleTopPadding) - VStack(alignment: .leading, spacing: Metrics.rowSpacing) { - ForEach(release.items) { item in - WhatsNewRow(item: item) - } - } - } - .padding() - } - .safeAreaInset(edge: .bottom) { - Button { - dismiss() - } label: { - Text(L10n.WhatsNew.WhatsNew.continueButton) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .padding() + VStack(alignment: .leading, spacing: Metrics.rowSpacing) { + ForEach(release.items) { item in + WhatsNewRow(item: item) + } } - .presentationDragIndicator(.hidden) + } + .padding() + } + .safeAreaInset(edge: .bottom) { + Button { + dismiss() + } label: { + Text(L10n.WhatsNew.WhatsNew.continueButton) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .padding() } + .presentationDragIndicator(.hidden) + } } /// A decorative SF Symbol plus the feature's title and description; VoiceOver reads /// the pair as one element. private struct WhatsNewRow: View { - let item: WhatsNewItem + let item: WhatsNewItem - var body: some View { - HStack(alignment: .top, spacing: WhatsNewSheet.Metrics.symbolToTextSpacing) { - Image(systemName: item.symbol) - .font(.title) - .foregroundStyle(.tint) - .frame(width: WhatsNewSheet.Metrics.symbolColumnWidth) - .accessibilityHidden(true) + var body: some View { + HStack(alignment: .top, spacing: WhatsNewSheet.Metrics.symbolToTextSpacing) { + Image(systemName: item.symbol) + .font(.title) + .foregroundStyle(.tint) + .frame(width: WhatsNewSheet.Metrics.symbolColumnWidth) + .accessibilityHidden(true) - VStack(alignment: .leading, spacing: WhatsNewSheet.Metrics.titleToBodySpacing) { - Text(item.title) - .font(.headline) - Text(item.description) - .font(.subheadline) - .foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: WhatsNewSheet.Metrics.titleToBodySpacing) { + Text(item.title) + .font(.headline) + Text(item.description) + .font(.subheadline) + .foregroundStyle(.secondary) + } - Spacer(minLength: 0) - } - .accessibilityElement(children: .combine) + Spacer(minLength: 0) } + .accessibilityElement(children: .combine) + } } #Preview { - Color.clear.sheet(isPresented: .constant(true)) { - WhatsNewSheet(release: .preview) - } + Color.clear.sheet(isPresented: .constant(true)) { + WhatsNewSheet(release: .preview) + } } private extension WhatsNewRelease { - static let preview = WhatsNewRelease( - version: WhatsNewVersion(major: 1, minor: 1), - items: [ - WhatsNewItem( - symbol: "sparkles", - title: "Faster Messaging", - description: "Messages now send and sync more quickly across your mesh." - ), - WhatsNewItem( - symbol: "antenna.radiowaves.left.and.right", - title: "Stronger Multi-Hop Routing", - description: "Improved route handling keeps distant nodes connected." - ), - WhatsNewItem( - symbol: "lock.shield", - title: "Private by Default", - description: "Your messages stay encrypted end to end, on device." - ) - ] - ) + static let preview = WhatsNewRelease( + version: WhatsNewVersion(major: 1, minor: 1), + items: [ + WhatsNewItem( + symbol: "sparkles", + title: "Faster Messaging", + description: "Messages now send and sync more quickly across your mesh." + ), + WhatsNewItem( + symbol: "antenna.radiowaves.left.and.right", + title: "Stronger Multi-Hop Routing", + description: "Improved route handling keeps distant nodes connected." + ), + WhatsNewItem( + symbol: "lock.shield", + title: "Private by Default", + description: "Your messages stay encrypted end to end, on device." + ) + ] + ) } diff --git a/MC1Services/Package.swift b/MC1Services/Package.swift index bb3070bd..c2978fe8 100644 --- a/MC1Services/Package.swift +++ b/MC1Services/Package.swift @@ -2,25 +2,25 @@ import PackageDescription let package = Package( - name: "MC1Services", - platforms: [.iOS(.v18), .macOS(.v15)], - products: [ - .library(name: "MC1Services", targets: ["MC1Services"]) - ], - dependencies: [ - .package(path: "../MeshCore") - ], - targets: [ - .target( - name: "MC1Services", - dependencies: ["MeshCore"] - ), - .testTarget( - name: "MC1ServicesTests", - dependencies: [ - "MC1Services", - .product(name: "MeshCoreTestSupport", package: "MeshCore") - ] - ) - ] + name: "MC1Services", + platforms: [.iOS(.v18), .macOS(.v15)], + products: [ + .library(name: "MC1Services", targets: ["MC1Services"]) + ], + dependencies: [ + .package(path: "../MeshCore") + ], + targets: [ + .target( + name: "MC1Services", + dependencies: ["MeshCore"] + ), + .testTarget( + name: "MC1ServicesTests", + dependencies: [ + "MC1Services", + .product(name: "MeshCoreTestSupport", package: "MeshCore") + ] + ) + ] ) diff --git a/MC1Services/Sources/MC1Services/Connection/BLEReconnectionCoordinator.swift b/MC1Services/Sources/MC1Services/Connection/BLEReconnectionCoordinator.swift index 34322df4..a2006e1c 100644 --- a/MC1Services/Sources/MC1Services/Connection/BLEReconnectionCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Connection/BLEReconnectionCoordinator.swift @@ -9,270 +9,278 @@ import OSLog /// from session rebuild logic. @MainActor final class BLEReconnectionCoordinator { + private let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEReconnectionCoordinator") - private let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEReconnectionCoordinator") + weak var delegate: BLEReconnectionDelegate? - weak var delegate: BLEReconnectionDelegate? + /// The device ID for the reconnect cycle currently claimed by + /// `handleEnteringAutoReconnect`. Completions are accepted only when this matches + /// the completing device. A nil value (entry suppressed, or manually superseded) + /// rejects late completions to prevent them from racing a new flow. + var reconnectingDeviceID: UUID? { + activeCycle?.deviceID + } - /// The device ID for the reconnect cycle currently claimed by - /// `handleEnteringAutoReconnect`. Completions are accepted only when this matches - /// the completing device. A nil value (entry suppressed, or manually superseded) - /// rejects late completions to prevent them from racing a new flow. - var reconnectingDeviceID: UUID? { - activeCycle?.deviceID - } + private struct ReconnectCycle { + let deviceID: UUID + let generation: Int + var uiTimedOut: Bool + } - private struct ReconnectCycle { - let deviceID: UUID - let generation: Int - var uiTimedOut: Bool - } + private var activeCycle: ReconnectCycle? - private var activeCycle: ReconnectCycle? + private var timeoutTask: Task? - private var timeoutTask: Task? + /// Incremented each time a reconnection cycle starts, used to detect stale rebuilds and retries. + private(set) var reconnectGeneration = 0 - /// Incremented each time a reconnection cycle starts, used to detect stale rebuilds and retries. - private(set) var reconnectGeneration = 0 + /// UI timeout duration before transitioning from "connecting" to "disconnected". + /// iOS auto-reconnect continues in the background even after this fires. + private let uiTimeoutDuration: TimeInterval - /// UI timeout duration before transitioning from "connecting" to "disconnected". - /// iOS auto-reconnect continues in the background even after this fires. - private let uiTimeoutDuration: TimeInterval + /// When the current reconnect UI window started. Used to bound re-arms. + private var reconnectUIWindowStart: Date? - /// When the current reconnect UI window started. Used to bound re-arms. - private var reconnectUIWindowStart: Date? + /// Maximum time the UI can stay in `.connecting` state, even if BLE is still + /// auto-reconnecting. Prevents indefinite connecting UI when transport is stuck. + private let maxConnectingUIWindow: TimeInterval - /// Maximum time the UI can stay in `.connecting` state, even if BLE is still - /// auto-reconnecting. Prevents indefinite connecting UI when transport is stuck. - private let maxConnectingUIWindow: TimeInterval + init(uiTimeoutDuration: TimeInterval = 15, maxConnectingUIWindow: TimeInterval = 60) { + self.uiTimeoutDuration = uiTimeoutDuration + self.maxConnectingUIWindow = maxConnectingUIWindow + } - init(uiTimeoutDuration: TimeInterval = 15, maxConnectingUIWindow: TimeInterval = 60) { - self.uiTimeoutDuration = uiTimeoutDuration - self.maxConnectingUIWindow = maxConnectingUIWindow + /// Handles the device entering iOS auto-reconnect phase. + /// Tears down session layer and starts a UI timeout. + func handleEnteringAutoReconnect(deviceID: UUID) async { + guard let delegate else { return } + logger.info("[BLE] handleEnteringAutoReconnect: device=\(deviceID.uuidString.prefix(8)), connectionState=\(String(describing: delegate.connectionState))") + + guard delegate.connectionIntent.wantsConnection else { + logger.info("Ignoring auto-reconnect: user disconnected") + await delegate.disconnectTransport() + return } - /// Handles the device entering iOS auto-reconnect phase. - /// Tears down session layer and starts a UI timeout. - func handleEnteringAutoReconnect(deviceID: UUID) async { - guard let delegate else { return } - logger.info("[BLE] handleEnteringAutoReconnect: device=\(deviceID.uuidString.prefix(8)), connectionState=\(String(describing: delegate.connectionState))") - - guard delegate.connectionIntent.wantsConnection else { - logger.info("Ignoring auto-reconnect: user disconnected") - await delegate.disconnectTransport() - return - } - - // C3 fix: set connecting state BEFORE awaiting teardown so that - // handleReconnectionComplete() sees .connecting even if it runs - // during the teardown await. - delegate.setConnectionState(.connecting) - reconnectGeneration += 1 - activeCycle = ReconnectCycle(deviceID: deviceID, generation: reconnectGeneration, uiTimedOut: false) - reconnectUIWindowStart = Date() - - // Tear down session layer (it's invalid now) - await delegate.teardownSessionForReconnect() - - // Start UI timeout - logger.info("[BLE] Arming UI timeout: \(uiTimeoutDuration)s for device \(deviceID.uuidString.prefix(8))") - armTimeout(deviceID: deviceID, generation: reconnectGeneration) + // C3 fix: set connecting state BEFORE awaiting teardown so that + // handleReconnectionComplete() sees .connecting even if it runs + // during the teardown await. + delegate.setConnectionState(.connecting) + reconnectGeneration += 1 + activeCycle = ReconnectCycle(deviceID: deviceID, generation: reconnectGeneration, uiTimedOut: false) + reconnectUIWindowStart = Date() + + // Reflect the drop before teardown so the Live Activity write runs at the + // front of the bounded disconnect wake window, not behind session teardown. + // The reconnect cycle is already claimed, so this can't strand a completion. + await delegate.notifyAutoReconnectStarted() + + // Tear down session layer (it's invalid now) + await delegate.teardownSessionForReconnect() + + // Start UI timeout + logger.info("[BLE] Arming UI timeout: \(uiTimeoutDuration)s for device \(deviceID.uuidString.prefix(8))") + armTimeout(deviceID: deviceID, generation: reconnectGeneration) + } + + /// Handles iOS auto-reconnect completion. Cancels the UI timeout + /// and delegates session rebuild to ConnectionManager. + func handleReconnectionComplete(deviceID: UUID) async { + guard let delegate else { return } + logger.info("[BLE] handleReconnectionComplete: device=\(deviceID.uuidString.prefix(8))") + + guard delegate.connectionIntent.wantsConnection else { + cancelTimeout() + logger.info("Ignoring reconnection: user disconnected") + clearActiveCycle(invalidateGeneration: true) + await delegate.disconnectTransport() + return } - /// Handles iOS auto-reconnect completion. Cancels the UI timeout - /// and delegates session rebuild to ConnectionManager. - func handleReconnectionComplete(deviceID: UUID) async { - guard let delegate else { return } - logger.info("[BLE] handleReconnectionComplete: device=\(deviceID.uuidString.prefix(8))") - - guard delegate.connectionIntent.wantsConnection else { - cancelTimeout() - logger.info("Ignoring reconnection: user disconnected") - clearActiveCycle(invalidateGeneration: true) - await delegate.disconnectTransport() - return - } - - // Reject completions for cycles we didn't claim — an orphaned completion - // would race the new flow. Don't cancel the active timeout — the current - // reconnect retains its fallback. - guard activeCycle?.deviceID == deviceID else { - let claim = reconnectingDeviceID?.uuidString.prefix(8) ?? "no claim" - logger.warning("[BLE] Ignoring auto-reconnect completion for \(deviceID.uuidString.prefix(8)): \(claim)") - return - } - - let completedAfterUITimeout = activeCycle?.uiTimedOut == true - if completedAfterUITimeout { - logger.info("[BLE] Accepting auto-reconnect completion after UI timeout for \(deviceID.uuidString.prefix(8))") - } - - // This completion is for our device — safe to cancel timeout - cancelTimeout() - clearActiveCycle(invalidateGeneration: false) - - // Accept both disconnected (normal) and connecting (auto-reconnect in progress) - let state = delegate.connectionState - guard state == .disconnected || state == .connecting else { - logger.info("Ignoring reconnection: already \(String(describing: state))") - return - } - - reconnectGeneration += 1 - let expectedGeneration = reconnectGeneration - - delegate.setConnectionState(.connecting) - - do { - try await delegate.rebuildSession(deviceID: deviceID) - } catch { - logger.warning("[BLE] Auto-reconnect session rebuild failed: \(error.localizedDescription) - retrying in 2s") - await retryRebuild(deviceID: deviceID, expectedGeneration: expectedGeneration) - } + // Reject completions for cycles we didn't claim — an orphaned completion + // would race the new flow. Don't cancel the active timeout — the current + // reconnect retains its fallback. + guard activeCycle?.deviceID == deviceID else { + let claim = reconnectingDeviceID?.uuidString.prefix(8) ?? "no claim" + logger.warning("[BLE] Ignoring auto-reconnect completion for \(deviceID.uuidString.prefix(8)): \(claim)") + return } - /// Restarts the UI timeout without tearing down the session. - /// Used when user taps Connect while iOS auto-reconnect is already in progress. - func restartTimeout(deviceID: UUID) { - if activeCycle?.deviceID != deviceID { - reconnectGeneration += 1 - activeCycle = ReconnectCycle(deviceID: deviceID, generation: reconnectGeneration, uiTimedOut: false) - } else { - activeCycle?.uiTimedOut = false - } - reconnectUIWindowStart = Date() - guard let generation = activeCycle?.generation else { return } - armTimeout(deviceID: deviceID, generation: generation) + let completedAfterUITimeout = activeCycle?.uiTimedOut == true + if completedAfterUITimeout { + logger.info("[BLE] Accepting auto-reconnect completion after UI timeout for \(deviceID.uuidString.prefix(8))") } - private func armTimeout(deviceID: UUID, generation: Int) { - cancelTimeout() - timeoutTask = Task { [weak self, uiTimeoutDuration] in - try? await Task.sleep(for: .seconds(uiTimeoutDuration)) - guard !Task.isCancelled, let self else { return } - await self.handleUITimeout(deviceID: deviceID, generation: generation) - } + // This completion is for our device — safe to cancel timeout + cancelTimeout() + clearActiveCycle(invalidateGeneration: false) + + // Accept both disconnected (normal) and connecting (auto-reconnect in progress) + let state = delegate.connectionState + guard state == .disconnected || state == .connecting else { + logger.info("Ignoring reconnection: already \(String(describing: state))") + return } - /// Cancels the UI timeout timer. - func cancelTimeout() { - if timeoutTask != nil { - logger.debug("[BLE] Cancelling UI timeout") - } - timeoutTask?.cancel() - timeoutTask = nil + reconnectGeneration += 1 + let expectedGeneration = reconnectGeneration + + delegate.setConnectionState(.connecting) + + do { + try await delegate.rebuildSession(deviceID: deviceID) + } catch { + logger.warning("[BLE] Auto-reconnect session rebuild failed: \(error.localizedDescription) - retrying in 2s") + await retryRebuild(deviceID: deviceID, expectedGeneration: expectedGeneration) + } + } + + /// Restarts the UI timeout without tearing down the session. + /// Used when user taps Connect while iOS auto-reconnect is already in progress. + func restartTimeout(deviceID: UUID) { + if activeCycle?.deviceID != deviceID { + reconnectGeneration += 1 + activeCycle = ReconnectCycle(deviceID: deviceID, generation: reconnectGeneration, uiTimedOut: false) + } else { + activeCycle?.uiTimedOut = false } + reconnectUIWindowStart = Date() + guard let generation = activeCycle?.generation else { return } + armTimeout(deviceID: deviceID, generation: generation) + } + + private func armTimeout(deviceID: UUID, generation: Int) { + cancelTimeout() + timeoutTask = Task { [weak self, uiTimeoutDuration] in + try? await Task.sleep(for: .seconds(uiTimeoutDuration)) + guard !Task.isCancelled, let self else { return } + await handleUITimeout(deviceID: deviceID, generation: generation) + } + } - /// Clears the reconnecting device ID, used when manual connect supersedes auto-reconnect. - func clearReconnectingDevice() { - clearActiveCycle(invalidateGeneration: true) + /// Cancels the UI timeout timer. + func cancelTimeout() { + if timeoutTask != nil { + logger.debug("[BLE] Cancelling UI timeout") + } + timeoutTask?.cancel() + timeoutTask = nil + } + + /// Clears the reconnecting device ID, used when manual connect supersedes auto-reconnect. + func clearReconnectingDevice() { + clearActiveCycle(invalidateGeneration: true) + } + + private func clearActiveCycle(invalidateGeneration: Bool) { + guard activeCycle != nil else { return } + activeCycle = nil + reconnectUIWindowStart = nil + if invalidateGeneration { + reconnectGeneration += 1 } + } + + /// Retries a failed session rebuild after a short delay, aborting if the reconnect + /// generation has changed or the user disconnected during the wait. + private func retryRebuild(deviceID: UUID, expectedGeneration: Int) async { + guard let delegate else { return } + + try? await Task.sleep(for: .seconds(2)) - private func clearActiveCycle(invalidateGeneration: Bool) { - guard activeCycle != nil else { return } - activeCycle = nil - reconnectUIWindowStart = nil - if invalidateGeneration { - reconnectGeneration += 1 - } + guard expectedGeneration == reconnectGeneration else { + logger.info("New reconnect cycle started during rebuild retry delay - aborting stale retry") + return + } + guard delegate.connectionIntent.wantsConnection else { + logger.info("User disconnected during rebuild retry delay") + await delegate.handleReconnectionFailure() + return } - /// Retries a failed session rebuild after a short delay, aborting if the reconnect - /// generation has changed or the user disconnected during the wait. - private func retryRebuild(deviceID: UUID, expectedGeneration: Int) async { - guard let delegate else { return } - - try? await Task.sleep(for: .seconds(2)) - - guard expectedGeneration == reconnectGeneration else { - logger.info("New reconnect cycle started during rebuild retry delay - aborting stale retry") - return - } - guard delegate.connectionIntent.wantsConnection else { - logger.info("User disconnected during rebuild retry delay") - await delegate.handleReconnectionFailure() - return - } - - do { - try await delegate.rebuildSession(deviceID: deviceID) - logger.info("[BLE] Auto-reconnect session rebuild succeeded on retry") - } catch { - logger.error("[BLE] Auto-reconnect session rebuild failed on retry: \(error.localizedDescription)") - await delegate.handleReconnectionFailure() - } + do { + try await delegate.rebuildSession(deviceID: deviceID) + logger.info("[BLE] Auto-reconnect session rebuild succeeded on retry") + } catch { + logger.error("[BLE] Auto-reconnect session rebuild failed on retry: \(error.localizedDescription)") + await delegate.handleReconnectionFailure() + } + } + + private func handleUITimeout(deviceID: UUID, generation: Int) async { + guard let delegate, delegate.connectionState == .connecting else { return } + guard let activeCycle, + activeCycle.deviceID == deviceID, + activeCycle.generation == generation else { return } + let elapsed = Date().timeIntervalSince(reconnectUIWindowStart ?? Date()) + logger.info("[BLE] handleUITimeout: device=\(deviceID.uuidString.prefix(8)), elapsed=\(elapsed.formatted(.number.precision(.fractionLength(1))))s") + + // If BLE transport is still actively auto-reconnecting and we haven't + // exceeded the max connecting window, re-arm the timeout instead of + // forcing disconnected state. This handles the case where the timeout + // was armed before suspension and fires immediately on resume. + let transportAutoReconnecting = await delegate.isTransportAutoReconnecting() + + // Re-validate after the await: handleReconnectionComplete can run to + // completion during the suspension, and this timeout must not force + // a freshly rebuilt session back to disconnected. + guard delegate.connectionState == .connecting, + let currentCycle = self.activeCycle, + currentCycle.deviceID == deviceID, + currentCycle.generation == generation else { return } + + if transportAutoReconnecting, elapsed < maxConnectingUIWindow { + logger.info("[BLE] UI timeout fired but BLE still auto-reconnecting, re-arming (elapsed: \(elapsed.formatted(.number.precision(.fractionLength(1))))s)") + armTimeout(deviceID: deviceID, generation: generation) + return } - private func handleUITimeout(deviceID: UUID, generation: Int) async { - guard let delegate, delegate.connectionState == .connecting else { return } - guard let activeCycle, - activeCycle.deviceID == deviceID, - activeCycle.generation == generation else { return } - let elapsed = Date().timeIntervalSince(reconnectUIWindowStart ?? Date()) - logger.info("[BLE] handleUITimeout: device=\(deviceID.uuidString.prefix(8)), elapsed=\(elapsed.formatted(.number.precision(.fractionLength(1))))s") - - // If BLE transport is still actively auto-reconnecting and we haven't - // exceeded the max connecting window, re-arm the timeout instead of - // forcing disconnected state. This handles the case where the timeout - // was armed before suspension and fires immediately on resume. - let transportAutoReconnecting = await delegate.isTransportAutoReconnecting() - - // Re-validate after the await: handleReconnectionComplete can run to - // completion during the suspension, and this timeout must not force - // a freshly rebuilt session back to disconnected. - guard delegate.connectionState == .connecting, - let currentCycle = self.activeCycle, - currentCycle.deviceID == deviceID, - currentCycle.generation == generation else { return } - - if transportAutoReconnecting, elapsed < maxConnectingUIWindow { - logger.info("[BLE] UI timeout fired but BLE still auto-reconnecting, re-arming (elapsed: \(elapsed.formatted(.number.precision(.fractionLength(1))))s)") - armTimeout(deviceID: deviceID, generation: generation) - return - } - - if transportAutoReconnecting { - self.activeCycle?.uiTimedOut = true - } else { - clearActiveCycle(invalidateGeneration: true) - } - logger.warning( - "[BLE] Auto-reconnect UI timeout (\(uiTimeoutDuration)s) fired - transitioning UI to disconnected (iOS reconnect continues in background)" - ) - delegate.setConnectionState(.disconnected) - delegate.setConnectedDevice(nil) - await delegate.notifyConnectionLost() + if transportAutoReconnecting { + self.activeCycle?.uiTimedOut = true + } else { + clearActiveCycle(invalidateGeneration: true) } + logger.warning( + "[BLE] Auto-reconnect UI timeout (\(uiTimeoutDuration)s) fired - transitioning UI to disconnected (iOS reconnect continues in background)" + ) + delegate.setConnectionState(.disconnected) + delegate.setConnectedDevice(nil) + await delegate.notifyConnectionLost() + } } /// Delegate protocol for BLEReconnectionCoordinator. /// ConnectionManager implements this to provide session management. @MainActor protocol BLEReconnectionDelegate: AnyObject { - var connectionIntent: ConnectionIntent { get } - var connectionState: DeviceConnectionState { get } + var connectionIntent: ConnectionIntent { get } + var connectionState: DeviceConnectionState { get } + + /// Sets the connection state (used by coordinator for state transitions). + func setConnectionState(_ state: DeviceConnectionState) - /// Sets the connection state (used by coordinator for state transitions). - func setConnectionState(_ state: DeviceConnectionState) + /// Sets the connected device (used by coordinator to clear on timeout). + func setConnectedDevice(_ device: DeviceDTO?) - /// Sets the connected device (used by coordinator to clear on timeout). - func setConnectedDevice(_ device: DeviceDTO?) + /// Tears down the current session and services for reconnection. + func teardownSessionForReconnect() async - /// Tears down the current session and services for reconnection. - func teardownSessionForReconnect() async + /// Rebuilds the session after iOS auto-reconnect completes. + func rebuildSession(deviceID: UUID) async throws - /// Rebuilds the session after iOS auto-reconnect completes. - func rebuildSession(deviceID: UUID) async throws + /// Disconnects the BLE transport (used when user disconnected during reconnect). + func disconnectTransport() async - /// Disconnects the BLE transport (used when user disconnected during reconnect). - func disconnectTransport() async + /// Notifies the UI layer that the link dropped and iOS auto-reconnect has begun, + /// after the cycle is claimed and before session teardown. + func notifyAutoReconnectStarted() async - /// Notifies the UI layer of connection loss. - func notifyConnectionLost() async + /// Notifies the UI layer of connection loss. + func notifyConnectionLost() async - /// Handles reconnection failure (cleanup session, disconnect transport). - func handleReconnectionFailure() async + /// Handles reconnection failure (cleanup session, disconnect transport). + func handleReconnectionFailure() async - /// Returns whether the BLE transport is currently in auto-reconnecting phase. - func isTransportAutoReconnecting() async -> Bool + /// Returns whether the BLE transport is currently in auto-reconnecting phase. + func isTransportAutoReconnecting() async -> Bool } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionIntent.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionIntent.swift index 8de791d6..a359ec0e 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionIntent.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionIntent.swift @@ -4,48 +4,47 @@ import Foundation /// Represents the user's connection intent, replacing three separate flags: /// `shouldBeConnected`, `userExplicitlyDisconnected`, and `pendingForceFullSync`. -enum ConnectionIntent: Sendable, Equatable { +enum ConnectionIntent: Equatable { + /// No active connection intent (initial state) + case none - /// No active connection intent (initial state) - case none + /// User explicitly disconnected — suppress auto-reconnect and disconnected pill + case userDisconnected - /// User explicitly disconnected — suppress auto-reconnect and disconnected pill - case userDisconnected + /// User wants to be connected + case wantsConnection(forceFullSync: Bool = false) - /// User wants to be connected - case wantsConnection(forceFullSync: Bool = false) + // MARK: - Persistence - // MARK: - Persistence + private static let persistenceKey = PersistenceKeys.userExplicitlyDisconnected - private static let persistenceKey = PersistenceKeys.userExplicitlyDisconnected - - /// Persists the `userDisconnected` state to UserDefaults. - /// Only `.userDisconnected` is persisted; other states are transient. - func persist(to defaults: UserDefaults = .standard) { - switch self { - case .userDisconnected: - defaults.set(true, forKey: Self.persistenceKey) - case .none, .wantsConnection: - defaults.removeObject(forKey: Self.persistenceKey) - } - } - - /// Restores intent from UserDefaults on launch. - /// Returns `.userDisconnected` if persisted, otherwise `.none`. - static func restored(from defaults: UserDefaults = .standard) -> ConnectionIntent { - defaults.bool(forKey: persistenceKey) ? .userDisconnected : .none - } - - // MARK: - Convenience - - /// Whether the user wants to be connected - var wantsConnection: Bool { - if case .wantsConnection = self { return true } - return false - } - - /// Whether the user explicitly disconnected - var isUserDisconnected: Bool { - self == .userDisconnected + /// Persists the `userDisconnected` state to UserDefaults. + /// Only `.userDisconnected` is persisted; other states are transient. + func persist(to defaults: UserDefaults = .standard) { + switch self { + case .userDisconnected: + defaults.set(true, forKey: Self.persistenceKey) + case .none, .wantsConnection: + defaults.removeObject(forKey: Self.persistenceKey) } + } + + /// Restores intent from UserDefaults on launch. + /// Returns `.userDisconnected` if persisted, otherwise `.none`. + static func restored(from defaults: UserDefaults = .standard) -> ConnectionIntent { + defaults.bool(forKey: persistenceKey) ? .userDisconnected : .none + } + + // MARK: - Convenience + + /// Whether the user wants to be connected + var wantsConnection: Bool { + if case .wantsConnection = self { return true } + return false + } + + /// Whether the user explicitly disconnected + var isUserDisconnected: Bool { + self == .userDisconnected + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLE.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLE.swift index df37144e..17bb6fe3 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLE.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLE.swift @@ -2,693 +2,696 @@ import MeshCore extension ConnectionManager { - - /// The connection methods recorded for a freshly connected BLE device. Centralized so the - /// BLE connect and device-switch paths persist an identical `.bluetooth` descriptor rather - /// than repeating the literal at each call site. - static func bleConnectionMethods(for deviceID: UUID) -> [ConnectionMethod] { - [.bluetooth(peripheralUUID: deviceID, displayName: nil)] + /// The connection methods recorded for a freshly connected BLE device. Centralized so the + /// BLE connect and device-switch paths persist an identical `.bluetooth` descriptor rather + /// than repeating the literal at each call site. + static func bleConnectionMethods(for deviceID: UUID) -> [ConnectionMethod] { + [.bluetooth(peripheralUUID: deviceID, displayName: nil)] + } + + // MARK: - BLE Diagnostics + + /// Returns a best-effort snapshot of the BLE state machine for debug exports. + public func currentBLEDiagnosticsSummary() async -> String { + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + let isConnected = await stateMachine.isConnected + let isAutoReconnecting = await stateMachine.isAutoReconnecting + let connectedDeviceShort = await stateMachine.connectedDeviceID?.uuidString.prefix(8) ?? "none" + let sessionPresent = session != nil + let servicesPresent = services != nil + let eventMonitoringActive = services?.isEventMonitoringActive ?? false + let autoFetchActive: Bool = if let services { + await services.messagePollingService.isAutoFetching + } else { + false } - - // MARK: - BLE Diagnostics - - /// Returns a best-effort snapshot of the BLE state machine for debug exports. - public func currentBLEDiagnosticsSummary() async -> String { - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - let isConnected = await stateMachine.isConnected - let isAutoReconnecting = await stateMachine.isAutoReconnecting - let connectedDeviceShort = await stateMachine.connectedDeviceID?.uuidString.prefix(8) ?? "none" - let sessionPresent = session != nil - let servicesPresent = services != nil - let eventMonitoringActive = services?.isEventMonitoringActive ?? false - let autoFetchActive: Bool - if let services { - autoFetchActive = await services.messagePollingService.isAutoFetching - } else { - autoFetchActive = false - } - let activeReconnectDeviceShort = reconnectionCoordinator.reconnectingDeviceID?.uuidString.prefix(8) ?? "none" - let sessionRebuildDeviceShort = sessionRebuildDeviceID?.uuidString.prefix(8) ?? "none" - return - "BLE: state=\(bleState), " + - "phase=\(blePhase), " + - "peripheralState=\(blePeripheralState), " + - "isConnected=\(isConnected), " + - "isAutoReconnecting=\(isAutoReconnecting), " + - "connectedDevice=\(connectedDeviceShort), " + - "sessionPresent=\(sessionPresent), " + - "servicesPresent=\(servicesPresent), " + - "eventMonitoringActive=\(eventMonitoringActive), " + - "autoFetchActive=\(autoFetchActive), " + - "activeReconnectDevice=\(activeReconnectDeviceShort), " + - "sessionRebuildDevice=\(sessionRebuildDeviceShort)" - } - - // MARK: - BLE Device Checks - - /// Checks if a device is connected to the system by another app. - /// Returns false during auto-reconnect or when the device is already connected by us. - /// - Parameter deviceID: The UUID of the device to check - /// - Returns: `true` if device appears connected to another app - public func isDeviceConnectedToOtherApp(_ deviceID: UUID) async -> Bool { - let isAutoReconnecting = await stateMachine.isAutoReconnecting - let smIsConnected = await stateMachine.isConnected - let smConnectedDeviceID = await stateMachine.connectedDeviceID - let systemConnected = await stateMachine.isDeviceConnectedToSystem(deviceID) - let allSystemConnected = await stateMachine.systemConnectedPeripheralIDs() - - logger.info( - "[OtherAppCheck] device=\(deviceID.uuidString.prefix(8)), " + - "connectionState=\(String(describing: self.connectionState)), " + - "isAutoReconnecting=\(isAutoReconnecting), " + - "smIsConnected=\(smIsConnected), " + - "smConnectedDevice=\(smConnectedDeviceID?.uuidString.prefix(8) ?? "nil"), " + - "isDeviceConnectedToSystem=\(systemConnected), " + - "allSystemConnectedCount=\(allSystemConnected.count), " + - "allSystemConnected=\(allSystemConnected.map { String($0.uuidString.prefix(8)) })" - ) - - // Don't check during auto-reconnect - that's our own connection - guard !isAutoReconnecting else { return false } - - // Don't check if we're already connected (switching devices) - guard connectionState == .disconnected else { return false } - - // Don't report our own connection as "another app" (state restoration may have completed) - if smIsConnected, smConnectedDeviceID == deviceID { - return false - } - - return systemConnected - } - - /// Attempts to adopt a system-connected BLE link for the *last connected* device. - /// - /// iOS can keep a BLE link alive across app termination (notably after app updates) while state - /// restoration does not fire for the new process. In that case, CoreBluetooth may report the - /// radio as system-connected even though our state machine is `.idle`. - /// - /// Rather than treating this as "connected elsewhere" and blocking reconnect, we can adopt the - /// existing link by running the restoration discovery chain against the connected peripheral. - /// - /// - Returns: `true` if an adoption attempt was started. - func startAdoptingLastSystemConnectedPeripheralIfAvailable( - deviceID: UUID, - context: String - ) async -> Bool { - guard deviceID == lastConnectedDeviceID else { return false } - guard currentTransportType == nil || currentTransportType == .bluetooth else { return false } - guard connectionState == .disconnected else { return false } - guard connectionIntent.wantsConnection else { return false } - - // Don't interfere with iOS auto-reconnect or an active BLE connection. - guard !(await stateMachine.isAutoReconnecting) else { return false } - if await stateMachine.isConnected, await stateMachine.connectedDeviceID == deviceID { - return false - } - - // Adoption is only valid from an idle BLE state machine. If restoration or another - // discovery chain is already in progress, let that flow own the reconnect. - let blePhase = await stateMachine.currentPhaseName - guard blePhase == "idle" else { return false } - - // Avoid doing teardown/UI transitions when there is no system-level link. - guard await stateMachine.isDeviceConnectedToSystem(deviceID) else { return false } - - let bleState = await stateMachine.centralManagerStateName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - logger.warning( - "[BLE] \(context): device appears system-connected while disconnected; attempting adoption - " + - "device=\(deviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState)" - ) - - // Prepare session layer + UI timeout window before starting adoption. - await reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - let started = await stateMachine.startAdoptingSystemConnectedPeripheral(deviceID) - guard started else { - logger.warning("[BLE] \(context): startAdoptingSystemConnectedPeripheral returned false after system-connected preflight") - // Undo UI timeout + state changes so downstream health checks remain accurate. - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() - if connectionState == .connecting { - connectionState = .disconnected - } - return false - } - - persistDisconnectDiagnostic( - "source=\(context).adoptSystemConnectedPeripheral, " + - "device=\(deviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "intent=\(connectionIntent)" - ) - return true + let activeReconnectDeviceShort = reconnectionCoordinator.reconnectingDeviceID?.uuidString.prefix(8) ?? "none" + let sessionRebuildDeviceShort = sessionRebuildDeviceID?.uuidString.prefix(8) ?? "none" + return + "BLE: state=\(bleState), " + + "phase=\(blePhase), " + + "peripheralState=\(blePeripheralState), " + + "isConnected=\(isConnected), " + + "isAutoReconnecting=\(isAutoReconnecting), " + + "connectedDevice=\(connectedDeviceShort), " + + "sessionPresent=\(sessionPresent), " + + "servicesPresent=\(servicesPresent), " + + "eventMonitoringActive=\(eventMonitoringActive), " + + "autoFetchActive=\(autoFetchActive), " + + "activeReconnectDevice=\(activeReconnectDeviceShort), " + + "sessionRebuildDevice=\(sessionRebuildDeviceShort)" + } + + // MARK: - BLE Device Checks + + /// Checks if a device is connected to the system by another app. + /// Returns false during auto-reconnect or when the device is already connected by us. + /// - Parameter deviceID: The UUID of the device to check + /// - Returns: `true` if device appears connected to another app + public func isDeviceConnectedToOtherApp(_ deviceID: UUID) async -> Bool { + let isAutoReconnecting = await stateMachine.isAutoReconnecting + let smIsConnected = await stateMachine.isConnected + let smConnectedDeviceID = await stateMachine.connectedDeviceID + let systemConnected = await stateMachine.isDeviceConnectedToSystem(deviceID) + let allSystemConnected = await stateMachine.systemConnectedPeripheralIDs() + + logger.info( + "[OtherAppCheck] device=\(deviceID.uuidString.prefix(8)), " + + "connectionState=\(String(describing: connectionState)), " + + "isAutoReconnecting=\(isAutoReconnecting), " + + "smIsConnected=\(smIsConnected), " + + "smConnectedDevice=\(smConnectedDeviceID?.uuidString.prefix(8) ?? "nil"), " + + "isDeviceConnectedToSystem=\(systemConnected), " + + "allSystemConnectedCount=\(allSystemConnected.count), " + + "allSystemConnected=\(allSystemConnected.map { String($0.uuidString.prefix(8)) })" + ) + + // Don't check during auto-reconnect - that's our own connection + guard !isAutoReconnecting else { return false } + + // Don't check if we're already connected (switching devices) + guard connectionState == .disconnected else { return false } + + // Don't report our own connection as "another app" (state restoration may have completed) + if smIsConnected, smConnectedDeviceID == deviceID { + return false } - // MARK: - BLE Scanning - - /// Starts scanning for nearby BLE devices and returns an AsyncStream of `DiscoveredDevice`. - /// Scanning is orthogonal to the connection lifecycle — works while connected. - /// Cancel the consuming task to stop scanning automatically. - public func startBLEScanning() -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: DiscoveredDevice.self) - bleScanTask?.cancel() - bleScanRequestID &+= 1 - let requestID = bleScanRequestID - - bleScanTask = Task { @MainActor [weak self] in - guard let self else { return } - guard !Task.isCancelled, requestID == self.bleScanRequestID else { return } - - await self.stateMachine.setDeviceDiscoveredHandler { @Sendable deviceID, name, rssi in - _ = continuation.yield(DiscoveredDevice(id: deviceID, name: name, rssi: rssi)) - } - - guard !Task.isCancelled, requestID == self.bleScanRequestID else { return } - await self.stateMachine.startScanning() - } - - continuation.onTermination = { [weak self] _ in - Task { @MainActor in - guard let self else { return } - guard self.bleScanRequestID == requestID else { return } - self.bleScanRequestID &+= 1 - self.bleScanTask?.cancel() - self.bleScanTask = nil - await self.stateMachine.setDeviceDiscoveredHandler { _, _, _ in } - await self.stateMachine.stopScanning() - } - } - - return stream + return systemConnected + } + + /// Attempts to adopt a system-connected BLE link for the *last connected* device. + /// + /// iOS can keep a BLE link alive across app termination (notably after app updates) while state + /// restoration does not fire for the new process. In that case, CoreBluetooth may report the + /// radio as system-connected even though our state machine is `.idle`. + /// + /// Rather than treating this as "connected elsewhere" and blocking reconnect, we can adopt the + /// existing link by running the restoration discovery chain against the connected peripheral. + /// + /// - Returns: `true` if an adoption attempt was started. + func startAdoptingLastSystemConnectedPeripheralIfAvailable( + deviceID: UUID, + context: String + ) async -> Bool { + guard deviceID == lastConnectedDeviceID else { return false } + guard currentTransportType == nil || currentTransportType == .bluetooth else { return false } + guard connectionState == .disconnected else { return false } + guard connectionIntent.wantsConnection else { return false } + + // Don't interfere with iOS auto-reconnect or an active BLE connection. + guard await !(stateMachine.isAutoReconnecting) else { return false } + if await stateMachine.isConnected, await stateMachine.connectedDeviceID == deviceID { + return false } - /// Manually stops BLE scanning. - public func stopBLEScanning() async { - bleScanRequestID &+= 1 - bleScanTask?.cancel() - bleScanTask = nil - await stateMachine.setDeviceDiscoveredHandler { _, _, _ in } - await stateMachine.stopScanning() + // Adoption is only valid from an idle BLE state machine. If restoration or another + // discovery chain is already in progress, let that flow own the reconnect. + let blePhase = await stateMachine.currentPhaseName + guard blePhase == "idle" else { return false } + + // Avoid doing teardown/UI transitions when there is no system-level link. + guard await stateMachine.isDeviceConnectedToSystem(deviceID) else { return false } + + let bleState = await stateMachine.centralManagerStateName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + logger.warning( + "[BLE] \(context): device appears system-connected while disconnected; attempting adoption - " + + "device=\(deviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState)" + ) + + // Prepare session layer + UI timeout window before starting adoption. + await reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) + + let started = await stateMachine.startAdoptingSystemConnectedPeripheral(deviceID) + guard started else { + logger.warning("[BLE] \(context): startAdoptingSystemConnectedPeripheral returned false after system-connected preflight") + // Undo UI timeout + state changes so downstream health checks remain accurate. + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + if connectionState == .connecting { + connectionState = .disconnected + } + return false } - // MARK: - Reconnection Watchdog - - /// Starts a watchdog that periodically retries connection when the user wants to be - /// connected but the device is stuck in disconnected state (e.g., after auto-reconnect failure). - /// Uses exponential backoff: 30s → 60s → 120s (capped). - func startReconnectionWatchdog() { - stopReconnectionWatchdog() + persistDisconnectDiagnostic( + "source=\(context).adoptSystemConnectedPeripheral, " + + "device=\(deviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "intent=\(connectionIntent)" + ) + return true + } + + // MARK: - BLE Scanning + + /// Starts scanning for nearby BLE devices and returns an AsyncStream of `DiscoveredDevice`. + /// Scanning is orthogonal to the connection lifecycle — works while connected. + /// Cancel the consuming task to stop scanning automatically. + public func startBLEScanning() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: DiscoveredDevice.self) + bleScanTask?.cancel() + bleScanRequestID &+= 1 + let requestID = bleScanRequestID + + bleScanTask = Task { @MainActor [weak self] in + guard let self else { return } + guard !Task.isCancelled, requestID == bleScanRequestID else { return } + + await stateMachine.setDeviceDiscoveredHandler { @Sendable deviceID, name, rssi in + _ = continuation.yield(DiscoveredDevice(id: deviceID, name: name, rssi: rssi)) + } + + guard !Task.isCancelled, requestID == bleScanRequestID else { return } + await stateMachine.startScanning() + } - reconnectionWatchdogTask = Task { - var delay: Duration = .seconds(30) - let maxDelay: Duration = .seconds(120) + continuation.onTermination = { [weak self] _ in + Task { @MainActor in + guard let self else { return } + guard self.bleScanRequestID == requestID else { return } + self.bleScanRequestID &+= 1 + self.bleScanTask?.cancel() + self.bleScanTask = nil + await self.stateMachine.setDeviceDiscoveredHandler { _, _, _ in } + await self.stateMachine.stopScanning() + } + } - while !Task.isCancelled { - try? await Task.sleep(for: delay) - guard !Task.isCancelled else { return } + return stream + } - guard connectionIntent.wantsConnection, - connectionState == .disconnected else { - logger.info("[BLE] Watchdog exiting: intent or state changed") - return - } + /// Manually stops BLE scanning. + public func stopBLEScanning() async { + bleScanRequestID &+= 1 + bleScanTask?.cancel() + bleScanTask = nil + await stateMachine.setDeviceDiscoveredHandler { _, _, _ in } + await stateMachine.stopScanning() + } - if await stateMachine.isBluetoothPoweredOff { - logger.info("[BLE] Watchdog skipping: Bluetooth powered off") - delay = min(delay * 2, maxDelay) - continue - } + // MARK: - Reconnection Watchdog - if await stateMachine.isAutoReconnecting { - logger.info("[BLE] Watchdog skipping: iOS auto-reconnect in progress") - delay = min(delay * 2, maxDelay) - continue - } + /// Starts a watchdog that periodically retries connection when the user wants to be + /// connected but the device is stuck in disconnected state (e.g., after auto-reconnect failure). + /// Uses exponential backoff: 30s → 60s → 120s (capped). + func startReconnectionWatchdog() { + stopReconnectionWatchdog() - logger.info("[BLE] Watchdog attempting reconnection (delay was \(delay))") - await checkBLEConnectionHealth() + reconnectionWatchdogTask = Task { + var delay: Duration = .seconds(30) + let maxDelay: Duration = .seconds(120) - delay = min(delay * 2, maxDelay) - } - } - } + while !Task.isCancelled { + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } - /// Stops the reconnection watchdog - func stopReconnectionWatchdog() { - reconnectionWatchdogTask?.cancel() - reconnectionWatchdogTask = nil - } - - // MARK: - BLE Connection Health - - /// Attempts BLE reconnection if user expects to be connected but iOS auto-reconnect gave up. - /// Called when the app returns to foreground (`appDidBecomeActive`) and from the reconnection - /// watchdog loop. - /// - /// The guards below run as an ordered priority ladder, each ending in an early return so the - /// first matching situation wins. Order is load-bearing: cheap stand-down checks (wrong - /// transport, pairing, no intent, reconnect already in flight) come first so the expensive - /// state-machine queries below them only run when a reconnect is actually warranted; the live - /// transport check precedes the disconnected paths so an already-healthy link is reconciled - /// rather than torn down; stale-state cleanup runs before the adopt/other-app/reconnect - /// strategies so they start from a clean `connectionState`. Reordering can either tear down a - /// good connection or attempt a redundant reconnect. - public func checkBLEConnectionHealth() async { - // Only check BLE connections - guard currentTransportType == nil || currentTransportType == .bluetooth else { return } - - if shouldDeferOpportunisticReconnect { - logger.info("[BLE] Foreground health check standing down: pairing in progress") - return - } - - let deviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - logger.info(""" - [BLE] Foreground health check - \ - connectionIntent: \(connectionIntent), \ - lastDevice: \(deviceShort), \ - connectionState: \(String(describing: connectionState)), \ - bleState: \(bleState), \ - blePhase: \(blePhase) - """) - - // Check if user expects to be connected guard connectionIntent.wantsConnection, - let deviceID = lastConnectedDeviceID else { return } - - if activeReconnectDeviceID == deviceID { - logger.info("[BLE] Skipping foreground reconnect: reconnect/session rebuild already in progress for \(deviceID.uuidString.prefix(8))") - return - } - - // Check actual BLE state. A live BLE transport is only healthy if the - // MC1 session, services, and listeners are alive too. - let bleConnected = await stateMachine.isConnected - if bleConnected { - await reconcileConnectedBLEAppStack(deviceID: deviceID) - return + connectionState == .disconnected else { + logger.info("[BLE] Watchdog exiting: intent or state changed") + return } - // Don't interfere if iOS auto-reconnect is still in progress - if await stateMachine.isAutoReconnecting { - logger.info("[BLE] Skipping foreground reconnect: iOS auto-reconnect still in progress") - return - } - - // Don't attempt reconnection when Bluetooth is off if await stateMachine.isBluetoothPoweredOff { - logger.info("[BLE] Skipping foreground reconnect: Bluetooth is powered off") - return + logger.info("[BLE] Watchdog skipping: Bluetooth powered off") + delay = min(delay * 2, maxDelay) + continue } - // Detect stale connection state: app thinks connected but BLE is actually disconnected - // This happens when iOS terminates the BLE connection while app is suspended - if connectionState.isConnected { - logger.warning("[BLE] Detected stale connection state on foreground: connectionState=\(String(describing: connectionState)) but BLE disconnected, triggering cleanup") - await handleConnectionLoss(deviceID: deviceID, error: nil) - } - - // If iOS kept a system-level BLE link alive (common across app updates) but our state machine is idle, - // adopt the existing connection rather than treating it as "connected elsewhere". - if await startAdoptingLastSystemConnectedPeripheralIfAvailable( - deviceID: deviceID, - context: "checkBLEConnectionHealth" - ) { - return + if await stateMachine.isAutoReconnecting { + logger.info("[BLE] Watchdog skipping: iOS auto-reconnect in progress") + delay = min(delay * 2, maxDelay) + continue } - // Don't reconnect if device is connected to another app - if await isDeviceConnectedToOtherApp(deviceID) { - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - persistDisconnectDiagnostic( - "source=checkBLEConnectionHealth.otherAppConnected, " + - "device=\(deviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "intent=\(connectionIntent)" - ) - - // Ensure retries continue even when this method is called directly - // (outside appDidBecomeActive's watchdog re-arm path). - if reconnectionWatchdogTask == nil { - startReconnectionWatchdog() - } - logger.info("[BLE] Skipping foreground reconnect: device connected to another app") - return - } + logger.info("[BLE] Watchdog attempting reconnection (delay was \(delay))") + await checkBLEConnectionHealth() - logger.info("[BLE] Attempting foreground reconnection to \(deviceID.uuidString.prefix(8))") - await attemptOpportunisticReconnect(deviceID: deviceID, reason: "foreground health check") + delay = min(delay * 2, maxDelay) + } + } + } + + /// Stops the reconnection watchdog + func stopReconnectionWatchdog() { + reconnectionWatchdogTask?.cancel() + reconnectionWatchdogTask = nil + } + + // MARK: - BLE Connection Health + + /// Attempts BLE reconnection if user expects to be connected but iOS auto-reconnect gave up. + /// Called when the app returns to foreground (`appDidBecomeActive`) and from the reconnection + /// watchdog loop. + /// + /// The guards below run as an ordered priority ladder, each ending in an early return so the + /// first matching situation wins. Order is load-bearing: cheap stand-down checks (wrong + /// transport, pairing, no intent, reconnect already in flight) come first so the expensive + /// state-machine queries below them only run when a reconnect is actually warranted; the live + /// transport check precedes the disconnected paths so an already-healthy link is reconciled + /// rather than torn down; stale-state cleanup runs before the adopt/other-app/reconnect + /// strategies so they start from a clean `connectionState`. Reordering can either tear down a + /// good connection or attempt a redundant reconnect. + public func checkBLEConnectionHealth() async { + // Only check BLE connections + guard currentTransportType == nil || currentTransportType == .bluetooth else { return } + + if shouldDeferOpportunisticReconnect { + logger.info("[BLE] Foreground health check standing down: pairing in progress") + return } - private func reconcileConnectedBLEAppStack(deviceID: UUID) async { - if await stateMachine.isAutoReconnecting { - logger.info("[BLE] Skipping connected-transport recovery: iOS auto-reconnect still in progress") - return - } + let deviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + logger.info(""" + [BLE] Foreground health check - \ + connectionIntent: \(connectionIntent), \ + lastDevice: \(deviceShort), \ + connectionState: \(String(describing: connectionState)), \ + bleState: \(bleState), \ + blePhase: \(blePhase) + """) + + // Check if user expects to be connected + guard connectionIntent.wantsConnection, + let deviceID = lastConnectedDeviceID else { return } + + if activeReconnectDeviceID == deviceID { + logger.info("[BLE] Skipping foreground reconnect: reconnect/session rebuild already in progress for \(deviceID.uuidString.prefix(8))") + return + } - let bleConnectedDeviceID = await stateMachine.connectedDeviceID - if let bleConnectedDeviceID, bleConnectedDeviceID != deviceID { - logger.warning( - "[BLE] Connected transport belongs to \(bleConnectedDeviceID.uuidString.prefix(8)); expected \(deviceID.uuidString.prefix(8))" - ) - return - } + // Check actual BLE state. A live BLE transport is only healthy if the + // MC1 session, services, and listeners are alive too. + let bleConnected = await stateMachine.isConnected + if bleConnected { + await reconcileConnectedBLEAppStack(deviceID: deviceID) + return + } - guard let services, - session != nil, - let connectedDevice, - connectedDevice.id == deviceID else { - await rebuildConnectedBLEAppStack(deviceID: deviceID) - return - } + // Don't interfere if iOS auto-reconnect is still in progress + if await stateMachine.isAutoReconnecting { + logger.info("[BLE] Skipping foreground reconnect: iOS auto-reconnect still in progress") + return + } - guard connectionState.isOperational else { - logger.info( - "[BLE] Connected transport app stack present while state is \(String(describing: connectionState)); leaving active connection flow in place" - ) - return - } + // Don't attempt reconnection when Bluetooth is off + if await stateMachine.isBluetoothPoweredOff { + logger.info("[BLE] Skipping foreground reconnect: Bluetooth is powered off") + return + } - await reconcileConnectedBLEListeners( - services: services, - radioID: connectedDevice.radioID - ) + // Detect stale connection state: app thinks connected but BLE is actually disconnected + // This happens when iOS terminates the BLE connection while app is suspended + if connectionState.isConnected { + logger.warning("[BLE] Detected stale connection state on foreground: connectionState=\(String(describing: connectionState)) but BLE disconnected, triggering cleanup") + await handleConnectionLoss(deviceID: deviceID, error: nil) } - private func rebuildConnectedBLEAppStack(deviceID: UUID) async { - logger.warning( - "[BLE] Connected BLE transport has missing app stack; rebuilding session for \(deviceID.uuidString.prefix(8))" - ) - connectionState = .connecting + // If iOS kept a system-level BLE link alive (common across app updates) but our state machine is idle, + // adopt the existing connection rather than treating it as "connected elsewhere". + if await startAdoptingLastSystemConnectedPeripheralIfAvailable( + deviceID: deviceID, + context: "checkBLEConnectionHealth" + ) { + return + } - do { - try await rebuildSessionForHealthCheck(deviceID: deviceID) - } catch { - logger.warning("[BLE] Connected-transport session rebuild failed: \(error.localizedDescription)") - await handleReconnectionFailure() - } + // Don't reconnect if device is connected to another app + if await isDeviceConnectedToOtherApp(deviceID) { + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + persistDisconnectDiagnostic( + "source=checkBLEConnectionHealth.otherAppConnected, " + + "device=\(deviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "intent=\(connectionIntent)" + ) + + // Ensure retries continue even when this method is called directly + // (outside appDidBecomeActive's watchdog re-arm path). + if reconnectionWatchdogTask == nil { + startReconnectionWatchdog() + } + logger.info("[BLE] Skipping foreground reconnect: device connected to another app") + return } - private func rebuildSessionForHealthCheck(deviceID: UUID) async throws { - #if DEBUG - if let rebuildSessionForHealthCheckOverride { - try await rebuildSessionForHealthCheckOverride(deviceID) - return - } - #endif + logger.info("[BLE] Attempting foreground reconnection to \(deviceID.uuidString.prefix(8))") + await attemptOpportunisticReconnect(deviceID: deviceID, reason: "foreground health check") + } - try await rebuildSession(deviceID: deviceID) + private func reconcileConnectedBLEAppStack(deviceID: UUID) async { + if await stateMachine.isAutoReconnecting { + logger.info("[BLE] Skipping connected-transport recovery: iOS auto-reconnect still in progress") + return } - private func reconcileConnectedBLEListeners( - services: ServiceContainer, - radioID: UUID - ) async { - let eventMonitoringActive = services.isEventMonitoringActive - let autoFetchActive = await services.messagePollingService.isAutoFetching + let bleConnectedDeviceID = await stateMachine.connectedDeviceID + if let bleConnectedDeviceID, bleConnectedDeviceID != deviceID { + logger.warning( + "[BLE] Connected transport belongs to \(bleConnectedDeviceID.uuidString.prefix(8)); expected \(deviceID.uuidString.prefix(8))" + ) + return + } - guard !eventMonitoringActive || !autoFetchActive else { return } + guard let services, + session != nil, + let connectedDevice, + connectedDevice.id == deviceID else { + await rebuildConnectedBLEAppStack(deviceID: deviceID) + return + } - logger.warning( - "[BLE] Connected BLE app stack missing listeners; restarting event pipeline for \(radioID.uuidString.prefix(8))" - ) + guard connectionState.isOperational else { + logger.info( + "[BLE] Connected transport app stack present while state is \(String(describing: connectionState)); leaving active connection flow in place" + ) + return + } - if !eventMonitoringActive { - await services.startEventMonitoring(radioID: radioID) - } else { - await services.messagePollingService.startAutoFetch(radioID: radioID) - } + await reconcileConnectedBLEListeners( + services: services, + radioID: connectedDevice.radioID + ) + } + + private func rebuildConnectedBLEAppStack(deviceID: UUID) async { + logger.warning( + "[BLE] Connected BLE transport has missing app stack; rebuilding session for \(deviceID.uuidString.prefix(8))" + ) + connectionState = .connecting + + do { + try await rebuildSessionForHealthCheck(deviceID: deviceID) + } catch { + logger.warning("[BLE] Connected-transport session rebuild failed: \(error.localizedDescription)") + await handleReconnectionFailure() } + } - // MARK: - BLE Connection - - /// Connects with retry logic for reconnection scenarios - func connectWithRetry(deviceID: UUID, maxAttempts: Int) async throws { - var lastError: Error = ConnectionError.connectionFailed("Unknown error") - - for attempt in 1...maxAttempts { - guard connectingDeviceID == deviceID else { throw CancellationError() } - - do { - try await performConnection(deviceID: deviceID) - - recordConnectionSuccess() - if attempt > 1 { - logger.info("Reconnection succeeded on attempt \(attempt)") - } - return - - } catch { - lastError = error - guard connectingDeviceID == deviceID else { throw error } - - // BLE precondition failures won't resolve between retries. - // Exit without retrying or tripping the circuit breaker so that - // onBluetoothPoweredOn can reconnect cleanly when BLE comes back. - // Auth failures also bypass retry — the bond is bad, the user has to - // intervene; four sequential auth failures just delays the recovery alert. - if let bleError = error as? BLEError { - switch bleError { - case .bluetoothPoweredOff, .bluetoothUnavailable, .bluetoothUnauthorized: - throw error - case .authenticationFailed: - // The peripheral connected before auth failed, leaving the - // BLE state machine in a non-idle phase. Tear down before - // bypassing retries so subsequent connects start fresh. - await cleanupResources() - await transport.disconnect() - throw error - default: - break - } - } - - if isDeviceNotFoundError(error) { - await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "connectWithRetry attempt \(attempt)") - } - - // Diagnostic: Log BLE state on each failed attempt - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - let backoffDelay = attempt < maxAttempts ? 0.3 * pow(2.0, Double(attempt - 1)) : 0.0 - let backoffStr = backoffDelay.formatted(.number.precision(.fractionLength(2))) - logger.warning( - "[BLE] Reconnection attempt \(attempt)/\(maxAttempts) failed - error: \(error.localizedDescription), blePhase: \(blePhase), blePeripheralState: \(blePeripheralState), nextBackoff: \(backoffStr)s" - ) - - // Clean up resources but keep state as .connecting - await cleanupResources() - await transport.disconnect() - - if attempt < maxAttempts { - // Backoff delay - state remains .connecting - let baseDelay = 0.3 * pow(2.0, Double(attempt - 1)) - let jitter = Double.random(in: 0...0.1) * baseDelay - try await Task.sleep(for: .seconds(baseDelay + jitter)) - } - } - } + private func rebuildSessionForHealthCheck(deviceID: UUID) async throws { + #if DEBUG + if let rebuildSessionForHealthCheckOverride { + try await rebuildSessionForHealthCheckOverride(deviceID) + return + } + #endif - // All retries exhausted - trip circuit breaker, then throw - recordConnectionFailure() + try await rebuildSession(deviceID: deviceID) + } - // Diagnostic: Log final failure state - let finalBlePhase = await stateMachine.currentPhaseName - let finalBlePeripheralState = await stateMachine.currentPeripheralState ?? "none" - logger.error( - "[BLE] All \(maxAttempts) reconnection attempts exhausted - lastError: \(lastError.localizedDescription), blePhase: \(finalBlePhase), blePeripheralState: \(finalBlePeripheralState)" - ) + private func reconcileConnectedBLEListeners( + services: ServiceContainer, + radioID: UUID + ) async { + let eventMonitoringActive = services.isEventMonitoringActive + let autoFetchActive = await services.messagePollingService.isAutoFetching - throw lastError - } + guard !eventMonitoringActive || !autoFetchActive else { return } - /// Performs the actual connection to a device - func performConnection(deviceID: UUID) async throws { - // Note: connectionState is already .connecting (set by caller) + logger.warning( + "[BLE] Connected BLE app stack missing listeners; restarting event pipeline for \(radioID.uuidString.prefix(8))" + ) - // Stop any existing session to prevent multiple receive loops racing for transport data - await session?.stop() - session = nil + if !eventMonitoringActive { + await services.startEventMonitoring(radioID: radioID) + } else { + await services.messagePollingService.startAutoFetch(radioID: radioID) + } + } - // Set device ID and connect - await transport.setDeviceID(deviceID) - try await transport.connect() + // MARK: - BLE Connection - logger.info("[BLE] State → .connected (transport connected for device: \(deviceID.uuidString.prefix(8)))") - connectionState = .connected + /// Connects with retry logic for reconnection scenarios + func connectWithRetry(deviceID: UUID, maxAttempts: Int) async throws { + var lastError: Error = ConnectionError.connectionFailed("Unknown error") - // Create session - let newSession = MeshCoreSession(transport: transport) - self.session = newSession + for attempt in 1...maxAttempts { + guard connectingDeviceID == deviceID else { throw CancellationError() } - let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) + do { + try await performConnection(deviceID: deviceID) - // Configure BLE write pacing based on device platform - await configureBLEPacing(for: deviceCapabilities) + recordConnectionSuccess() + if attempt > 1 { + logger.info("Reconnection succeeded on attempt \(attempt)") + } + return - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: newSession, - selfInfo: meshCoreSelfInfo, - capabilities: deviceCapabilities, - connectionMethods: Self.bleConnectionMethods(for: deviceID) + } catch { + lastError = error + guard connectingDeviceID == deviceID else { throw error } + + // BLE precondition failures won't resolve between retries. + // Exit without retrying or tripping the circuit breaker so that + // onBluetoothPoweredOn can reconnect cleanly when BLE comes back. + // Auth failures also bypass retry — the bond is bad, the user has to + // intervene; four sequential auth failures just delays the recovery alert. + if let bleError = error as? BLEError { + switch bleError { + case .bluetoothPoweredOff, .bluetoothUnavailable, .bluetoothUnauthorized: + throw error + case .authenticationFailed: + // The peripheral connected before auth failed, leaving the + // BLE state machine in a non-idle phase. Tear down before + // bypassing retries so subsequent connects start fresh. + await cleanupResources() + await transport.disconnect() + throw error + default: + break + } + } + + if isDeviceNotFoundError(error) { + await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "connectWithRetry attempt \(attempt)") + } + + // Diagnostic: Log BLE state on each failed attempt + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + let backoffDelay = attempt < maxAttempts ? 0.3 * pow(2.0, Double(attempt - 1)) : 0.0 + let backoffStr = backoffDelay.formatted(.number.precision(.fractionLength(2))) + logger.warning( + "[BLE] Reconnection attempt \(attempt)/\(maxAttempts) failed - error: \(error.localizedDescription), blePhase: \(blePhase), blePeripheralState: \(blePeripheralState), nextBackoff: \(backoffStr)s" ) - // Persist connection for auto-reconnect - persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) - - // Notify observers before sync starts so they can wire callbacks - // (e.g., AppState needs to set sync activity callbacks for the syncing pill) - await onConnectionReady?() - let shouldForceFullSync: Bool - if case .wantsConnection(let force) = connectionIntent { - shouldForceFullSync = force - if force { connectionIntent = .wantsConnection() } - } else { - shouldForceFullSync = false - } - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, forceFullSync: shouldForceFullSync) + // Clean up resources but keep state as .connecting + await cleanupResources() + await transport.disconnect() - guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .bluetooth) else { - await newSession.stop() - return + if attempt < maxAttempts { + // Backoff delay - state remains .connecting + let baseDelay = 0.3 * pow(2.0, Double(attempt - 1)) + let jitter = Double.random(in: 0...0.1) * baseDelay + try await Task.sleep(for: .seconds(baseDelay + jitter)) } - - stopReconnectionWatchdog() - logger.info("Connection complete - device ready") + } } - // MARK: - BLE Diagnostics Helpers - - func logDeviceNotFoundDiagnostics(deviceID: UUID, context: String) async { - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - let lastDeviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" - let registeredDevices = pairing.registeredDeviceInfos() - let pairedSummary = registeredDevices.prefix(5).map { info in - "\(info.name)(\(info.id.uuidString.prefix(8)))" - } - let pairedSummaryText = pairedSummary.isEmpty ? "none" : pairedSummary.joined(separator: ", ") - - logger.warning( - // swiftlint:disable:next line_length - "[BLE] Device not found diagnostics (\(context)) - device: \(deviceID.uuidString.prefix(8)), lastDevice: \(lastDeviceShort), connectionIntent: \(connectionIntent), bleState: \(bleState), blePhase: \(blePhase), pairingSessionActive: \(pairing.isSessionActive), pairedCount: \(registeredDevices.count), paired: \(pairedSummaryText)" - ) + // All retries exhausted - trip circuit breaker, then throw + recordConnectionFailure() + + // Diagnostic: Log final failure state + let finalBlePhase = await stateMachine.currentPhaseName + let finalBlePeripheralState = await stateMachine.currentPeripheralState ?? "none" + logger.error( + "[BLE] All \(maxAttempts) reconnection attempts exhausted - lastError: \(lastError.localizedDescription), blePhase: \(finalBlePhase), blePeripheralState: \(finalBlePeripheralState)" + ) + + throw lastError + } + + /// Performs the actual connection to a device + func performConnection(deviceID: UUID) async throws { + // Note: connectionState is already .connecting (set by caller) + + // Stop any existing session to prevent multiple receive loops racing for transport data + await session?.stop() + session = nil + + // Set device ID and connect + await transport.setDeviceID(deviceID) + try await transport.connect() + + logger.info("[BLE] State → .connected (transport connected for device: \(deviceID.uuidString.prefix(8)))") + connectionState = .connected + + // Create session + let newSession = MeshCoreSession(transport: transport) + session = newSession + + let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) + + // Configure BLE write pacing based on device platform + await configureBLEPacing(for: deviceCapabilities) + + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: newSession, + selfInfo: meshCoreSelfInfo, + capabilities: deviceCapabilities, + connectionMethods: Self.bleConnectionMethods(for: deviceID) + ) + + // Persist connection for auto-reconnect + persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) + + // Notify observers before sync starts so they can wire callbacks + // (e.g., AppState needs to set sync activity callbacks for the syncing pill) + await onConnectionReady?() + let shouldForceFullSync: Bool + if case let .wantsConnection(force) = connectionIntent { + shouldForceFullSync = force + if force { connectionIntent = .wantsConnection() } + } else { + shouldForceFullSync = false } + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, forceFullSync: shouldForceFullSync) - func isDeviceNotFoundError(_ error: Error) -> Bool { - if case ConnectionError.deviceNotFound = error { return true } - if case BLEError.deviceNotFound = error { return true } - return false + guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .bluetooth) else { + await newSession.stop() + return } - // MARK: - Connection Loss Handling - - /// Handles unexpected connection loss - func handleConnectionLoss(deviceID: UUID, error: Error?) async { - // Don't clobber a newer connection attempt - if connectionState == .connecting { - let activeID = connectingDeviceID ?? reconnectionCoordinator.reconnectingDeviceID - if let activeID, activeID != deviceID { - logger.info("[BLE] Ignoring connection loss for \(deviceID.uuidString.prefix(8)) — connecting to \(activeID.uuidString.prefix(8))") - return - } - } - - let stateBeforeLoss = connectionState - var errorInfo = "none" - if let error = error as NSError? { - errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" - } - logger.warning("[BLE] Connection lost: \(deviceID.uuidString.prefix(8)), currentState: \(String(describing: connectionState)), error: \(errorInfo)") - - // Cancel any pending auto-reconnect timeout and clear device identity - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() + stopReconnectionWatchdog() + logger.info("Connection complete - device ready") + } - cancelResyncLoop() + // MARK: - BLE Diagnostics Helpers - // Capture and clear synchronously, mirroring teardownSessionForReconnect: - // a same-device rebuild can install a new container during the awaits - // below, and re-reading self.services would tear that new container down. - let oldServices = services - services = nil - session = nil + func logDeviceNotFoundDiagnostics(deviceID: UUID, context: String) async { + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + let lastDeviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" + let registeredDevices = pairing.registeredDeviceInfos() + let pairedSummary = registeredDevices.prefix(5).map { info in + "\(info.name)(\(info.id.uuidString.prefix(8)))" + } + let pairedSummaryText = pairedSummary.isEmpty ? "none" : pairedSummary.joined(separator: ", ") + + logger.warning( + // swiftlint:disable:next line_length + "[BLE] Device not found diagnostics (\(context)) - device: \(deviceID.uuidString.prefix(8)), lastDevice: \(lastDeviceShort), connectionIntent: \(connectionIntent), bleState: \(bleState), blePhase: \(blePhase), pairingSessionActive: \(pairing.isSessionActive), pairedCount: \(registeredDevices.count), paired: \(pairedSummaryText)" + ) + } + + func isDeviceNotFoundError(_ error: Error) -> Bool { + if case ConnectionError.deviceNotFound = error { return true } + if case BLEError.deviceNotFound = error { return true } + return false + } + + // MARK: - Connection Loss Handling + + /// Handles unexpected connection loss + func handleConnectionLoss(deviceID: UUID, error: Error?) async { + // Don't clobber a newer connection attempt + if connectionState == .connecting { + let activeID = connectingDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + if let activeID, activeID != deviceID { + logger.info("[BLE] Ignoring connection loss for \(deviceID.uuidString.prefix(8)) — connecting to \(activeID.uuidString.prefix(8))") + return + } + } - logger.warning("[BLE] State → .disconnected (connection loss for device: \(deviceID.uuidString.prefix(8)))") - connectionState = .disconnected - if connectingDeviceID == deviceID { connectingDeviceID = nil } - connectedDevice = nil - allowedRepeatFreqRanges = [] - - if let oldServices { - // Mark room sessions disconnected before tearing down services - _ = await oldServices.remoteNodeService.handleBLEDisconnection() - await oldServices.tearDown() - // Reset sync state to prevent stuck "Syncing" pill - await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) - } + let stateBeforeLoss = connectionState + var errorInfo = "none" + if let error = error as NSError? { + errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" + } + logger.warning("[BLE] Connection lost: \(deviceID.uuidString.prefix(8)), currentState: \(String(describing: connectionState)), error: \(errorInfo)") + + // Cancel any pending auto-reconnect timeout and clear device identity + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + + cancelResyncLoop() + + // Capture and clear synchronously, mirroring teardownSessionForReconnect: + // a same-device rebuild can install a new container during the awaits + // below, and re-reading self.services would tear that new container down. + let oldServices = services + services = nil + session = nil + + logger.warning("[BLE] State → .disconnected (connection loss for device: \(deviceID.uuidString.prefix(8)))") + connectionState = .disconnected + if connectingDeviceID == deviceID { connectingDeviceID = nil } + connectedDevice = nil + allowedRepeatFreqRanges = [] + + if let oldServices { + // Mark room sessions disconnected before tearing down services + _ = await oldServices.remoteNodeService.handleBLEDisconnection() + await oldServices.tearDown() + // Reset sync state to prevent stuck "Syncing" pill + await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + } - persistDisconnectDiagnostic( - "source=handleConnectionLoss, " + - "device=\(deviceID.uuidString.prefix(8)), " + - "stateBefore=\(String(describing: stateBeforeLoss)), " + - "error=\(errorInfo), " + - "intent=\(connectionIntent)" - ) + persistDisconnectDiagnostic( + "source=handleConnectionLoss, " + + "device=\(deviceID.uuidString.prefix(8)), " + + "stateBefore=\(String(describing: stateBeforeLoss)), " + + "error=\(errorInfo), " + + "intent=\(connectionIntent)" + ) - // Keep transport reference for iOS auto-reconnect to use + // Keep transport reference for iOS auto-reconnect to use - // Notify UI layer of connection loss - await onConnectionLost?() + // Notify UI layer of connection loss + await onConnectionLost?() - // iOS auto-reconnect handles normal disconnects via reconnectionCoordinator - // Bluetooth power-cycle handled via onBluetoothPoweredOn callback - // Watchdog provides fallback retry if both fail - if connectionIntent.wantsConnection { - startReconnectionWatchdog() - } + // An invalidated bond can't heal through auto-reconnect or the watchdog; + // surface the guided pairing-failure recovery instead of retrying silently. + if let error, case BLEError.authenticationFailed = error { + surfaceAuthenticationFailure(deviceID: deviceID) } - /// Publishes user-actionable Bluetooth availability for the pickers and logs the change. - /// Disconnect logic is not duplicated here — BLEStateMachine already handles - /// `.poweredOff` via `cancelCurrentOperation` which fires `onDisconnection`. - func handleBluetoothStateChange(_ state: CBManagerState) { - let stateName: String - switch state { - case .unknown: stateName = "unknown" - case .resetting: stateName = "resetting" - case .unsupported: stateName = "unsupported" - case .unauthorized: stateName = "unauthorized" - case .poweredOff: stateName = "poweredOff" - case .poweredOn: stateName = "poweredOn" - @unknown default: stateName = "unknown(\(state.rawValue))" - } - bluetoothAvailability = Self.bluetoothAvailability(for: state) - logger.info("[BLE] Bluetooth state changed: \(stateName), connectionState: \(String(describing: self.connectionState)), connectionIntent: \(self.connectionIntent)") - } - - /// Maps a raw `CBManagerState` to the user-actionable availability the pickers display. Only the - /// two states a person can resolve get a distinct case; transient and unsupported states stay - /// `.ready` so the picker keeps scanning rather than offering a remedy that does nothing. - private static func bluetoothAvailability(for state: CBManagerState) -> BluetoothAvailability { - switch state { - case .poweredOff: .poweredOff - case .unauthorized: .unauthorized - default: .ready - } + // iOS auto-reconnect handles normal disconnects via reconnectionCoordinator + // Bluetooth power-cycle handled via onBluetoothPoweredOn callback + // Watchdog provides fallback retry if both fail + if connectionIntent.wantsConnection { + startReconnectionWatchdog() + } + } + + /// Publishes user-actionable Bluetooth availability for the pickers and logs the change. + /// Disconnect logic is not duplicated here — BLEStateMachine already handles + /// `.poweredOff` via `cancelCurrentOperation` which fires `onDisconnection`. + func handleBluetoothStateChange(_ state: CBManagerState) { + let stateName = switch state { + case .unknown: "unknown" + case .resetting: "resetting" + case .unsupported: "unsupported" + case .unauthorized: "unauthorized" + case .poweredOff: "poweredOff" + case .poweredOn: "poweredOn" + @unknown default: "unknown(\(state.rawValue))" + } + bluetoothAvailability = Self.bluetoothAvailability(for: state) + logger.info("[BLE] Bluetooth state changed: \(stateName), connectionState: \(String(describing: connectionState)), connectionIntent: \(connectionIntent)") + } + + /// Maps a raw `CBManagerState` to the user-actionable availability the pickers display. Only the + /// two states a person can resolve get a distinct case; transient and unsupported states stay + /// `.ready` so the picker keeps scanning rather than offering a remedy that does nothing. + private static func bluetoothAvailability(for state: CBManagerState) -> BluetoothAvailability { + switch state { + case .poweredOff: .poweredOff + case .unauthorized: .unauthorized + default: .ready } + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLEReconnectionDelegate.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLEReconnectionDelegate.swift index d0103a08..1539eb18 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLEReconnectionDelegate.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLEReconnectionDelegate.swift @@ -4,253 +4,282 @@ import MeshCore // MARK: - BLEReconnectionDelegate extension ConnectionManager: BLEReconnectionDelegate { + func setConnectionState(_ state: DeviceConnectionState) { + let previousState = connectionState + connectionState = state + if state == .disconnected, previousState != .disconnected { + let transportName = switch currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" + } + persistDisconnectDiagnostic( + "source=reconnectionCoordinator.setConnectionState, " + + "previousState=\(String(describing: previousState)), " + + "transport=\(transportName), " + + "intent=\(connectionIntent)" + ) + } + } + + func setConnectedDevice(_ device: DeviceDTO?) { + connectedDevice = device + } + + func teardownSessionForReconnect() async { + // Capture and clear synchronously so a concurrent rebuildSession can + // assume nil-and-rebuild without racing the terminal writes that used + // to land at the end of this method. Subsequent awaits operate on the + // captured local — they no longer touch self.services / self.session. + let oldServices = services + let oldSession = session + services = nil + session = nil + + // Stop the old session (keeping the transport for the pending reconnect) + // so its receive and auto-fetch loops end now instead of parking on the + // finished dispatcher stream until the object deallocates. + await oldSession?.stop(disconnectTransport: false) + + if let oldServices { + sessionsAwaitingReauth = await oldServices.remoteNodeService.handleBLEDisconnection() + await oldServices.tearDown() + } + cancelResyncLoop() - func setConnectionState(_ state: DeviceConnectionState) { - let previousState = connectionState - connectionState = state - if state == .disconnected, previousState != .disconnected { - let transportName = switch currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" - } - persistDisconnectDiagnostic( - "source=reconnectionCoordinator.setConnectionState, " + - "previousState=\(String(describing: previousState)), " + - "transport=\(transportName), " + - "intent=\(connectionIntent)" - ) - } + // Reset sync state on the captured services to prevent stuck "Syncing" pill + if let oldServices { + await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + } + } + + /// Background execution note: iOS provides ~10s of background execution time. + /// Session rebuild (transport + session.start) should complete within this window. + /// Full sync is deferred until performInitialSync returns to foreground via onConnectionEstablished. + func rebuildSession(deviceID: UUID) async throws { + logger.info("[BLE] Rebuilding session for auto-reconnect: \(deviceID.uuidString.prefix(8))") + let expectedGeneration = reconnectionCoordinator.reconnectGeneration + sessionRebuildDeviceID = deviceID + defer { + if sessionRebuildDeviceID == deviceID { + sessionRebuildDeviceID = nil + } } - func setConnectedDevice(_ device: DeviceDTO?) { - connectedDevice = device + // The auto-reconnect link is already live, so surface .connected up front, + // matching fresh connect: the syncing pill stays visible through rebuild and + // initial sync instead of a .connecting window the reconnect UI never bounds. + connectionState = .connected + + // Session teardown in this rebuild never disconnects the transport: the + // link belongs to the reconnect cycle (or, when superseded, to a newer + // one), and an explicit disconnect here cancels the OS pending connect + // that is recovering it. User-initiated disconnects sever the transport + // through disconnect(reason:) independently. + + // Stop any existing session to prevent multiple receive loops racing for transport data + await session?.stop(disconnectTransport: false) + session = nil + + let newSession = MeshCoreSession(transport: transport) + session = newSession + + do { + try await withTimeout(.seconds(10), operationName: "session.start") { + try await newSession.start(reconnectingAttempt: 1, disconnectTransportOnFailure: false) + } + } catch { + logger.warning("[BLE] rebuildSession: session.start() failed: \(error.localizedDescription)") + throw error } - func teardownSessionForReconnect() async { - // Capture and clear synchronously so a concurrent rebuildSession can - // assume nil-and-rebuild without racing the terminal writes that used - // to land at the end of this method. Subsequent awaits operate on the - // captured local — they no longer touch self.services / self.session. - let oldServices = services - services = nil - session = nil - - if let oldServices { - sessionsAwaitingReauth = await oldServices.remoteNodeService.handleBLEDisconnection() - await oldServices.tearDown() - } - cancelResyncLoop() - - // Reset sync state on the captured services to prevent stuck "Syncing" pill - if let oldServices { - await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) - } + // Check after await — user may have disconnected or a new reconnect cycle may have started + guard connectionIntent.wantsConnection else { + logger.info("User disconnected during session setup") + await newSession.stop(disconnectTransport: false) + connectionState = .disconnected + return + } + guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { + logger.info("[BLE] rebuildSession superseded by new reconnect cycle during session setup") + await newSession.stop(disconnectTransport: false) + return } - // Background execution note: iOS provides ~10s of background execution time. - // Session rebuild (transport + session.start) should complete within this window. - // Full sync is deferred until performInitialSync returns to foreground via onConnectionEstablished. - func rebuildSession(deviceID: UUID) async throws { - logger.info("[BLE] Rebuilding session for auto-reconnect: \(deviceID.uuidString.prefix(8))") - let expectedGeneration = reconnectionCoordinator.reconnectGeneration - sessionRebuildDeviceID = deviceID - defer { - if sessionRebuildDeviceID == deviceID { - sessionRebuildDeviceID = nil - } - } - - // Stop any existing session to prevent multiple receive loops racing for transport data - await session?.stop() - session = nil - - let newSession = MeshCoreSession(transport: transport) - self.session = newSession - - do { - try await newSession.start(reconnectingAttempt: 1) - } catch { - logger.warning("[BLE] rebuildSession: session.start() failed: \(error.localizedDescription)") - throw error - } - - // Check after await — user may have disconnected or a new reconnect cycle may have started - guard connectionIntent.wantsConnection else { - logger.info("User disconnected during session setup") - await newSession.stop() - connectionState = .disconnected - return - } - guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { - logger.info("[BLE] rebuildSession superseded by new reconnect cycle during session setup") - await newSession.stop() - return - } - - guard let selfInfo = await newSession.currentSelfInfo else { - logger.warning("[BLE] rebuildSession: selfInfo is nil after start()") - throw ConnectionError.initializationFailed("No self info") - } - let capabilities: DeviceCapabilities - do { - capabilities = try await newSession.queryDevice() - } catch { - logger.warning("[BLE] rebuildSession: queryDevice() failed: \(error.localizedDescription)") - throw error - } - - // Configure BLE write pacing based on device platform - await configureBLEPacing(for: capabilities) - - // Check after await - guard connectionIntent.wantsConnection else { - logger.info("User disconnected during device query") - await newSession.stop() - connectionState = .disconnected - return - } - guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { - logger.info("[BLE] rebuildSession superseded by new reconnect cycle during device query") - await newSession.stop() - return - } - - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: newSession, - selfInfo: selfInfo, - capabilities: capabilities - ) - - // Check after await — user may have disconnected or new reconnect cycle started - guard connectionIntent.wantsConnection else { - logger.info("User disconnected during service wiring") - await newSession.stop() - await newServices.tearDown() - services = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - connectionState = .disconnected - return - } - guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { - logger.info("[BLE] rebuildSession superseded by new reconnect cycle during service wiring") - await newSession.stop() - await newServices.tearDown() - services = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - return - } - - // Notify observers before sync starts so they can wire callbacks - await onConnectionReady?() - // onConnectionReady can suspend; a reentrant main-actor disconnect or - // reconnect-UI timeout may clear connectedDevice or replace services during - // that await. Recheck so an aborted reconnect bails here instead of syncing - // a torn-down session. - guard connectionIntent.wantsConnection, - reconnectionCoordinator.reconnectGeneration == expectedGeneration, - self.services === newServices, - connectedDevice != nil - else { - logger.info("[BLE] rebuildSession aborted after onConnectionReady: reconnect state changed") - await newSession.stop() - await newServices.tearDown() - services = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - return - } - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, context: "[BLE] iOS auto-reconnect") - - // Caller-specific guard: generation check for superseded reconnects - guard connectionIntent.wantsConnection, - reconnectionCoordinator.reconnectGeneration == expectedGeneration, - self.services === newServices - else { - await newSession.stop() - return - } - - if syncSucceeded { - // Re-authenticate room sessions (sends BLE commands — skip on failure path). - let sessionIDs = sessionsAwaitingReauth - await newServices.remoteNodeService.handleBLEReconnection(sessionIDs: sessionIDs) - - guard connectionIntent.wantsConnection, - reconnectionCoordinator.reconnectGeneration == expectedGeneration, - self.services === newServices - else { - // IDs preserved for next reconnect cycle — new IDs may have - // arrived during handleBLEReconnection if BLE dropped mid-reauth. - await newSession.stop() - return - } - - // Only clear consumed IDs after confirming this cycle is still authoritative. - // Any IDs appended during the await (via teardownSessionForReconnect) survive. - sessionsAwaitingReauth.subtract(sessionIDs) - } - - guard await promoteToReady( - syncSucceeded: syncSucceeded, - expectedServices: newServices, - transportType: .bluetooth, - additionalGuard: { [reconnectionCoordinator] in - reconnectionCoordinator.reconnectGeneration == expectedGeneration - } - ) else { - await newSession.stop() - return - } - - recordConnectionSuccess() - stopReconnectionWatchdog() - logger.info("[BLE] iOS auto-reconnect: session ready, device: \(deviceID.uuidString.prefix(8))") + guard let selfInfo = await newSession.currentSelfInfo else { + logger.warning("[BLE] rebuildSession: selfInfo is nil after start()") + throw ConnectionError.initializationFailed("No self info") } + let capabilities: DeviceCapabilities + do { + capabilities = try await withTimeout(.seconds(10), operationName: "queryDevice") { + try await newSession.queryDevice() + } + } catch { + logger.warning("[BLE] rebuildSession: queryDevice() failed: \(error.localizedDescription)") + throw error + } + + // Configure BLE write pacing based on device platform + await configureBLEPacing(for: capabilities) - func disconnectTransport() async { - await transport.disconnect() + // Check after await + guard connectionIntent.wantsConnection else { + logger.info("User disconnected during device query") + await newSession.stop(disconnectTransport: false) + connectionState = .disconnected + return + } + guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { + logger.info("[BLE] rebuildSession superseded by new reconnect cycle during device query") + await newSession.stop(disconnectTransport: false) + return + } + + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: newSession, + selfInfo: selfInfo, + capabilities: capabilities + ) + + // Check after await — user may have disconnected or new reconnect cycle started + guard connectionIntent.wantsConnection else { + logger.info("User disconnected during service wiring") + await newSession.stop(disconnectTransport: false) + await newServices.tearDown() + services = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + connectionState = .disconnected + return + } + guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { + logger.info("[BLE] rebuildSession superseded by new reconnect cycle during service wiring") + await newSession.stop(disconnectTransport: false) + await newServices.tearDown() + services = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + return + } + + // Notify observers before sync starts so they can wire callbacks + await onConnectionReady?() + // onConnectionReady can suspend; a reentrant main-actor disconnect or + // reconnect-UI timeout may clear connectedDevice or replace services during + // that await. Recheck so an aborted reconnect bails here instead of syncing + // a torn-down session. + guard connectionIntent.wantsConnection, + reconnectionCoordinator.reconnectGeneration == expectedGeneration, + services === newServices, + connectedDevice != nil + else { + logger.info("[BLE] rebuildSession aborted after onConnectionReady: reconnect state changed") + await newSession.stop(disconnectTransport: false) + await newServices.tearDown() + services = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + return + } + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, context: "[BLE] iOS auto-reconnect") + + // Caller-specific guard: generation check for superseded reconnects + guard connectionIntent.wantsConnection, + reconnectionCoordinator.reconnectGeneration == expectedGeneration, + services === newServices + else { + await newSession.stop(disconnectTransport: false) + return } - func notifyConnectionLost() async { - await onConnectionLost?() + if syncSucceeded { + // Re-authenticate room sessions (sends BLE commands — skip on failure path). + let sessionIDs = sessionsAwaitingReauth + await newServices.remoteNodeService.handleBLEReconnection(sessionIDs: sessionIDs) + + guard connectionIntent.wantsConnection, + reconnectionCoordinator.reconnectGeneration == expectedGeneration, + services === newServices + else { + // IDs preserved for next reconnect cycle — new IDs may have + // arrived during handleBLEReconnection if BLE dropped mid-reauth. + await newSession.stop(disconnectTransport: false) + return + } + + // Only clear consumed IDs after confirming this cycle is still authoritative. + // Any IDs appended during the await (via teardownSessionForReconnect) survive. + sessionsAwaitingReauth.subtract(sessionIDs) } - func isTransportAutoReconnecting() async -> Bool { - await stateMachine.isAutoReconnecting + guard await promoteToReady( + syncSucceeded: syncSucceeded, + expectedServices: newServices, + transportType: .bluetooth, + additionalGuard: { [reconnectionCoordinator] in + reconnectionCoordinator.reconnectGeneration == expectedGeneration + } + ) else { + await newSession.stop(disconnectTransport: false) + return } - func handleReconnectionFailure() async { - logger.error("[BLE] Auto-reconnect session rebuild failed") - - // Capture and clear synchronously, mirroring teardownSessionForReconnect: - // a concurrent rebuild can install a new session and container during the - // awaits below, and re-reading self.session / self.services would tear - // the replacements down. - let oldSession = session - let oldServices = services - session = nil - services = nil - connectionState = .disconnected - connectedDevice = nil - allowedRepeatFreqRanges = [] - - await oldSession?.stop() - await oldServices?.tearDown() - await transport.disconnect() - - // Same callback contract as handleConnectionLoss and the UI-timeout - // path in BLEReconnectionCoordinator: route through notifyConnectionLost() - // so AppState tears down its observers and the Live Activity transitions - // to disconnected. Without this the LA stays in stale "connected" state. - await notifyConnectionLost() - - // Start watchdog to periodically retry if user still wants connection - if connectionIntent.wantsConnection { - startReconnectionWatchdog() - } + recordConnectionSuccess() + stopReconnectionWatchdog() + logger.info("[BLE] iOS auto-reconnect: session ready, device: \(deviceID.uuidString.prefix(8))") + } + + func disconnectTransport() async { + await transport.disconnect() + } + + func notifyAutoReconnectStarted() async { + await onAutoReconnectStarted?() + } + + func notifyConnectionLost() async { + await onConnectionLost?() + // Reaching UI connection-loss while intent still wants a connection means + // the automatic reconnect paths have been abandoned; the watchdog is the + // remaining recovery. It stands down by itself while iOS auto-reconnect + // is in progress, so arming it alongside a live pending connect is safe. + if connectionIntent.wantsConnection, + connectionState == .disconnected, + currentTransportType == nil || currentTransportType == .bluetooth { + startReconnectionWatchdog() } + } + + func isTransportAutoReconnecting() async -> Bool { + await stateMachine.isAutoReconnecting + } + + func handleReconnectionFailure() async { + logger.error("[BLE] Auto-reconnect session rebuild failed") + + // Capture and clear synchronously, mirroring teardownSessionForReconnect: + // a concurrent rebuild can install a new session and container during the + // awaits below, and re-reading self.session / self.services would tear + // the replacements down. + let oldSession = session + let oldServices = services + session = nil + services = nil + connectionState = .disconnected + connectedDevice = nil + allowedRepeatFreqRanges = [] + + await oldSession?.stop() + await oldServices?.tearDown() + await transport.disconnect() + + // Same callback contract as handleConnectionLoss and the UI-timeout + // path in BLEReconnectionCoordinator: route through notifyConnectionLost() + // so AppState tears down its observers and the Live Activity transitions + // to disconnected, and the watchdog restarts while intent wants a + // connection. Without this the LA stays in stale "connected" state. + await notifyConnectionLost() + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift index 2e1f3ce5..b792bc18 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift @@ -1,771 +1,804 @@ import Foundation import MeshCore -extension ConnectionManager { - var activeConnectionAttemptDeviceID: UUID? { - connectingDeviceID ?? sessionRebuildDeviceID ?? reconnectionCoordinator.reconnectingDeviceID +public extension ConnectionManager { + internal var activeConnectionAttemptDeviceID: UUID? { + connectingDeviceID ?? sessionRebuildDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + } + + internal var activeReconnectDeviceID: UUID? { + sessionRebuildDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + } + + // MARK: - App Lifecycle + + /// Called when the app enters background. Pauses foreground-only BLE operations. + func appDidEnterBackground() async { + let transportName = switch currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" } - - var activeReconnectDeviceID: UUID? { - sessionRebuildDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + logger.info( + "[BLE] Lifecycle transition: entering background, " + + "transport: \(transportName), " + + "connectionIntent: \(connectionIntent), " + + "connectionState: \(String(describing: connectionState))" + ) + await stateMachine.appDidEnterBackground() + stopReconnectionWatchdog() + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + logger.info( + "[BLE] Lifecycle transition complete: backgrounded, " + + "bleState: \(bleState), " + + "blePhase: \(blePhase)" + ) + } + + /// Called when the app becomes active. Reconciles BLE state and restarts + /// foreground operations. + func appDidBecomeActive() async { + let transportName = switch currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" } - - // MARK: - App Lifecycle - - /// Called when the app enters background. Pauses foreground-only BLE operations. - public func appDidEnterBackground() async { - let transportName = switch currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" - } - logger.info( - "[BLE] Lifecycle transition: entering background, " + - "transport: \(transportName), " + - "connectionIntent: \(connectionIntent), " + - "connectionState: \(String(describing: connectionState))" - ) - await stateMachine.appDidEnterBackground() - stopReconnectionWatchdog() - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - logger.info( - "[BLE] Lifecycle transition complete: backgrounded, " + - "bleState: \(bleState), " + - "blePhase: \(blePhase)" - ) + logger.info( + "[BLE] Lifecycle transition: becoming active, " + + "transport: \(transportName), " + + "connectionIntent: \(connectionIntent), " + + "connectionState: \(String(describing: connectionState))" + ) + await stateMachine.appDidBecomeActive() + await checkBLEConnectionHealth() + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + logger.info( + "[BLE] Lifecycle transition complete: active health check finished, " + + "connectionState: \(String(describing: connectionState)), " + + "bleState: \(bleState), " + + "blePhase: \(blePhase)" + ) + + guard currentTransportType == nil || currentTransportType == .bluetooth else { return } + guard connectionIntent.wantsConnection, connectionState == .disconnected else { return } + + if shouldDeferOpportunisticReconnect { + logger.info("[BLE] ConnectionManager: not re-arming watchdog on foreground (pairing in progress)") + return } - /// Called when the app becomes active. Reconciles BLE state and restarts - /// foreground operations. - public func appDidBecomeActive() async { - let transportName = switch currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" - } - logger.info( - "[BLE] Lifecycle transition: becoming active, " + - "transport: \(transportName), " + - "connectionIntent: \(connectionIntent), " + - "connectionState: \(String(describing: connectionState))" - ) - await stateMachine.appDidBecomeActive() - await checkBLEConnectionHealth() - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - logger.info( - "[BLE] Lifecycle transition complete: active health check finished, " + - "connectionState: \(String(describing: connectionState)), " + - "bleState: \(bleState), " + - "blePhase: \(blePhase)" - ) - - guard currentTransportType == nil || currentTransportType == .bluetooth else { return } - guard connectionIntent.wantsConnection, connectionState == .disconnected else { return } - - if shouldDeferOpportunisticReconnect { - logger.info("[BLE] ConnectionManager: not re-arming watchdog on foreground (pairing in progress)") - return - } - - if await stateMachine.isAutoReconnecting { - logger.info("[BLE] ConnectionManager: not re-arming watchdog on foreground (iOS auto-reconnect in progress)") - return - } - - startReconnectionWatchdog() - logger.info("[BLE] ConnectionManager: re-armed watchdog on foreground while disconnected") + if await stateMachine.isAutoReconnecting { + logger.info("[BLE] ConnectionManager: not re-arming watchdog on foreground (iOS auto-reconnect in progress)") + return } - // MARK: - Sync Health + startReconnectionWatchdog() + logger.info("[BLE] ConnectionManager: re-armed watchdog on foreground while disconnected") + } - /// Triggers resync if connected but sync state is failed. - /// Called when app returns to foreground. - public func checkSyncHealth() async { - guard connectionState.isOperational, - connectionIntent.wantsConnection, - let services, - let radioID = connectedDevice?.radioID else { return } + // MARK: - Sync Health - let syncCoordinator = services.syncCoordinator - let syncState = syncCoordinator.state - guard case .failed = syncState else { return } + /// Triggers resync if connected but sync state is failed. + /// Called when app returns to foreground. + func checkSyncHealth() async { + guard connectionState.isOperational, + connectionIntent.wantsConnection, + let services, + let radioID = connectedDevice?.radioID else { return } - guard resyncTask == nil else { - logger.info("Resync loop already running, skipping foreground trigger") - return - } + let syncCoordinator = services.syncCoordinator + let syncState = syncCoordinator.state + guard case .failed = syncState else { return } - logger.info("Foreground return: sync state is failed, starting resync loop") - startResyncLoop(radioID: radioID, services: services, transportType: currentTransportType ?? .bluetooth) + guard resyncTask == nil else { + logger.info("Resync loop already running, skipping foreground trigger") + return } - // MARK: - Activation - - /// Activates the connection manager on app launch. - /// Call this once during app initialization. - public func activate() async { - // Ensure lifecycle handlers are installed before anything below can - // construct the CBCentralManager; creating the central can fire - // restoration callbacks synchronously, and a missed handler would - // strand the restored link without a session rebuild. - await wireTransportHandlers() - - let lastDeviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" - let bleState = await stateMachine.centralManagerStateName - logger.info(""" - Activating ConnectionManager - \ - connectionIntent: \(connectionIntent), \ - lastConnectedDeviceID: \(lastDeviceShort), \ - connectionState: \(String(describing: connectionState)), \ - bleState: \(bleState) - """) - - // Reset stale room session connections from previous app launch - let resetStore = createStandalonePersistenceStore() - try? await resetStore.resetAllRemoteNodeSessionConnections() - - // Populate radioID on existing devices and backfill deduplication keys (one-time migration) - do { - try await resetStore.performRadioIDMigration() - } catch { - logger.error("radioID migration failed: \(error)") - } + logger.info("Foreground return: sync state is failed, starting resync loop") + startResyncLoop(radioID: radioID, services: services, transportType: currentTransportType ?? .bluetooth) + } + + // MARK: - Activation + + /// Activates the connection manager on app launch. + /// Call this once during app initialization. + func activate() async { + // Ensure lifecycle handlers are installed before anything below can + // construct the CBCentralManager; creating the central can fire + // restoration callbacks synchronously, and a missed handler would + // strand the restored link without a session rebuild. + await wireTransportHandlers() + + let lastDeviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" + let bleState = await stateMachine.centralManagerStateName + logger.info(""" + Activating ConnectionManager - \ + connectionIntent: \(connectionIntent), \ + lastConnectedDeviceID: \(lastDeviceShort), \ + connectionState: \(String(describing: connectionState)), \ + bleState: \(bleState) + """) + + // Reset stale room session connections from previous app launch + let resetStore = createStandalonePersistenceStore() + try? await resetStore.resetAllRemoteNodeSessionConnections() + + // Populate radioID on existing devices and backfill deduplication keys (one-time migration) + do { + try await resetStore.performRadioIDMigration() + } catch { + logger.error("radioID migration failed: \(error)") + } - // Promote legacy per-channel region overrides to `.specific` mode so the - // corrective flood-scope semantics don't reinterpret them as `.inherit`. - do { - try await resetStore.performChannelFloodScopeMigration() - } catch { - logger.error("channel flood-scope migration failed: \(error)") - } + // Promote legacy per-channel region overrides to `.specific` mode so the + // corrective flood-scope semantics don't reinterpret them as `.inherit`. + do { + try await resetStore.performChannelFloodScopeMigration() + } catch { + logger.error("channel flood-scope migration failed: \(error)") + } - // Zero accumulated unread counts on repeater-type contacts and repeater-role - // sessions so the badge stops including invisible records. - do { - try await resetStore.performRepeaterUnreadCountMigration() - } catch { - logger.error("repeater unread-count migration failed: \(error)") - } + // Zero accumulated unread counts on repeater-type contacts and repeater-role + // sessions so the badge stops including invisible records. + do { + try await resetStore.performRepeaterUnreadCountMigration() + } catch { + logger.error("repeater unread-count migration failed: \(error)") + } - // Backfill sortDate from createdAt on pre-existing messages so date-header - // grouping keeps their current display order. - do { - try await resetStore.performSortDateBackfillMigration() - } catch { - logger.error("sortDate backfill migration failed: \(error)") - } + // Backfill sortDate from createdAt on pre-existing messages so date-header + // grouping keeps their current display order. + do { + try await resetStore.performSortDateBackfillMigration() + } catch { + logger.error("sortDate backfill migration failed: \(error)") + } - // Re-normalize sortDate to createdAt for installs whose backfill already ran - // before the block-at-reconnect change, un-burying any rows an interim build - // sorted by send time. Must run before stateMachine.activate() so no - // restoration-driven sync writes a fresh anchor before this resets the baseline. - do { - try await resetStore.performSortDateResetMigration() - } catch { - logger.error("sortDate reset migration failed: \(error)") - } + // Re-normalize sortDate to createdAt for installs whose backfill already ran + // before the block-at-reconnect change, un-burying any rows an interim build + // sorted by send time. Must run before stateMachine.activate() so no + // restoration-driven sync writes a fresh anchor before this resets the baseline. + do { + try await resetStore.performSortDateResetMigration() + } catch { + logger.error("sortDate reset migration failed: \(error)") + } - #if targetEnvironment(simulator) - // Skip auto-reconnect if user explicitly disconnected - if connectionIntent.isUserDisconnected { - logger.info("Simulator: skipping auto-reconnect - user previously disconnected") - return - } - // On simulator, skip ASK entirely and auto-reconnect to simulator device - if let lastDeviceID = lastConnectedDeviceID, - lastDeviceID == MockDataProvider.simulatorDeviceID { - logger.info("Simulator: auto-reconnecting to mock device") - connectionIntent = .wantsConnection() - do { - try await simulatorConnect() - } catch { - logger.warning("Simulator auto-reconnect failed: \(error.localizedDescription)") - } - return - } - // Simulator doesn't support real BLE devices - show connection UI for simulator pairing + #if targetEnvironment(simulator) + // Skip auto-reconnect if user explicitly disconnected + if connectionIntent.isUserDisconnected { + logger.info("Simulator: skipping auto-reconnect - user previously disconnected") return - #else - // Activate the pairing session early; on iOS this is AccessorySetupKit, required for - // ASK events and iOS 26 state restoration. On macOS this is a no-op. + } + // On simulator, skip ASK entirely and auto-reconnect to simulator device + if let lastDeviceID = lastConnectedDeviceID, + lastDeviceID == MockDataProvider.simulatorDeviceID { + logger.info("Simulator: auto-reconnecting to mock device") + connectionIntent = .wantsConnection() do { - try await pairing.activate() + try await simulatorConnect() } catch { - logger.error("Failed to activate pairing session: \(error.localizedDescription)") - // Don't return - WiFi doesn't need the pairing session - } - - // Skip auto-reconnect if user explicitly disconnected - if connectionIntent.isUserDisconnected { - logger.info("Skipping auto-reconnect: user previously disconnected") - return + logger.warning("Simulator auto-reconnect failed: \(error.localizedDescription)") } + return + } + // Simulator doesn't support real BLE devices - show connection UI for simulator pairing + return + #else + // Activate the pairing session early; on iOS this is AccessorySetupKit, required for + // ASK events and iOS 26 state restoration. On macOS this is a no-op. + do { + try await pairing.activate() + } catch { + logger.error("Failed to activate pairing session: \(error.localizedDescription)") + // Don't return - WiFi doesn't need the pairing session + } + + // Skip auto-reconnect if user explicitly disconnected + if connectionIntent.isUserDisconnected { + logger.info("Skipping auto-reconnect: user previously disconnected") + return + } - // Auto-reconnect to last device if available - if let lastDeviceID = lastConnectedDeviceID { - logger.info("Attempting auto-reconnect to last device: \(lastDeviceID)") - - // Set intent before checking state - connectionIntent = .wantsConnection() - - // Check if last device was WiFi - try WiFi first - let dataStore = PersistenceStore(modelContainer: modelContainer) - if let device = try? await dataStore.fetchDevice(id: lastDeviceID), - let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { - if case .wifi(let host, let port, _) = wifiMethod { - logger.info("Auto-reconnecting via WiFi to \(host):\(port)") - do { - try await connectViaWiFi(host: host, port: port) - return - } catch { - logger.warning("WiFi auto-reconnect failed: \(error.localizedDescription)") - // Fall through to try BLE - } - } - } - - // Activate BLE state machine before checking BLE state restoration status. - // Must be after: ASK activation (line 700), explicit disconnect guard (line 709). - // Must be before: isAutoReconnecting check, isDeviceConnectedToSystem. - await stateMachine.activate() - - // If state machine is already auto-reconnecting (from state restoration), - // let it complete rather than fighting with it - if await stateMachine.isAutoReconnecting { - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - logger.info( - "State restoration in progress - blePhase: \(blePhase), blePeripheralState: \(blePeripheralState), waiting for auto-reconnect" - ) - return - } - - if await stateMachine.isConnected, await stateMachine.connectedDeviceID == lastDeviceID { - logger.info("State restoration complete - device already connected, waiting for session setup") - return - } - - // If iOS kept the BLE link alive (common across app updates) but restoration didn't fire, - // adopt the system-connected peripheral rather than treating it as "connected elsewhere". - if await startAdoptingLastSystemConnectedPeripheralIfAvailable( - deviceID: lastDeviceID, - context: "activate" - ) { - return - } + // Auto-reconnect to last device if available + if let lastDeviceID = lastConnectedDeviceID { + logger.info("Attempting auto-reconnect to last device: \(lastDeviceID)") - // Check if device is connected to another app before auto-reconnect - // Silently skip per HIG: minimize interruptions on app launch - if await isDeviceConnectedToOtherApp(lastDeviceID) { - logger.info("Auto-reconnect skipped: device connected to another app") - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - persistDisconnectDiagnostic( - "source=activate.autoReconnectSkippedOtherApp, " + - "device=\(lastDeviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "intent=\(connectionIntent)" - ) - - // Keep intent so we can retry on foreground/watchdog, but avoid - // fighting another app's connection on launch. - startReconnectionWatchdog() - return - } + // Set intent before checking state + connectionIntent = .wantsConnection() + // Check if last device was WiFi - try WiFi first + let dataStore = PersistenceStore(modelContainer: modelContainer) + if let device = try? await dataStore.fetchDevice(id: lastDeviceID), + let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { + if case let .wifi(host, port, _) = wifiMethod { + logger.info("Auto-reconnecting via WiFi to \(host):\(port)") do { - try await connect(to: lastDeviceID) + try await connectViaWiFi(host: host, port: port) + return } catch { - logger.warning("Auto-reconnect failed: \(error.localizedDescription)") - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - persistDisconnectDiagnostic( - "source=activate.autoReconnectFailed, " + - "device=\(lastDeviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "error=\(error.localizedDescription), " + - "intent=\(connectionIntent)" - ) - startReconnectionWatchdog() - // Don't propagate - auto-reconnect failure is not fatal - } - } else { - logger.info("No last connected device - skipping auto-reconnect") - } - #endif - } - - // MARK: - Connection - - /// Connects to a previously paired device. - /// - /// This method handles all connection scenarios: - /// - If disconnected: connects to the device - /// - If already connected to this device: no-op - /// - If connected to a different device: switches to the new device - /// - /// - Parameters: - /// - deviceID: The UUID of the device to connect to - /// - forceFullSync: Whether to force a full sync instead of incremental - /// - forceReconnect: When `true`, marks the connect as user-initiated: it bypasses the - /// circuit breaker, and on a platform without a system pairing registry (macOS) it also - /// bounds the retry budget to `unverifiedConnectAttempts`, so a tap on an out-of-range - /// radio fails fast instead of hanging the full budget. Background reconnects pass - /// `false` to keep the full budget for unattended recovery. - /// - Throws: Connection errors - public func connect(to deviceID: UUID, forceFullSync: Bool = false, forceReconnect: Bool = false) async throws { - // Honor cancellation before any state mutation. Without this checkpoint - // a cancelled `pairNewDevice` task whose preceding awaits all swallowed - // cancellation would still drive a real BLE connect to completion. - try Task.checkCancellation() - - // Circuit breaker: prevent rapid reconnection loops after repeated failures - guard shouldAllowConnection(force: forceReconnect) else { - logger.info("[BLE] Circuit breaker open, rejecting connection to \(deviceID.uuidString.prefix(8))") - throw BLEError.connectionFailed("Connection blocked by circuit breaker (cooling down)") - } - - if activeReconnectDeviceID == deviceID { - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - if sessionRebuildDeviceID != deviceID { - reconnectionCoordinator.restartTimeout(deviceID: deviceID) + logger.warning("WiFi auto-reconnect failed: \(error.localizedDescription)") + // Fall through to try BLE } - logger.info("[BLE] Reconnect already in progress for \(deviceID.uuidString.prefix(8)), deferring duplicate connect request") - return + } } - // Prevent concurrent connection attempts - if connectionState == .connecting { - let currentDeviceID = activeConnectionAttemptDeviceID - - if currentDeviceID == deviceID { - if connectingDeviceID == nil { - // Auto-reconnect same device — refresh UI timeout - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - reconnectionCoordinator.restartTimeout(deviceID: deviceID) - } - logger.info("Connection already in progress for \(deviceID.uuidString.prefix(8)), ignoring") - return - } - - // Different device — cancel current and fall through - logger.info("Cancelling connection to \(currentDeviceID?.uuidString.prefix(8) ?? "unknown") to connect to \(deviceID.uuidString.prefix(8))") - connectingDeviceID = nil - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() - cancelResyncLoop() - stopReconnectionWatchdog() - await cleanupResources() - await transport.disconnect() - connectionState = .disconnected - } + // Activate BLE state machine before checking BLE state restoration status. + // Must be after: ASK activation (line 700), explicit disconnect guard (line 709). + // Must be before: isAutoReconnecting check, isDeviceConnectedToSystem. + await stateMachine.activate() - // Handle already-connected cases - if connectionState != .disconnected { - if connectedDevice?.id == deviceID { - logger.info("Already connected to device: \(deviceID)") - return - } - // Connected to different device - switch to new one - logger.info("Switching from current device to: \(deviceID)") - try await switchDevice(to: deviceID) - return - } - - // Claim the attempt before the pre-connect awaits below. Two connect - // calls interleaving at those suspension points would otherwise both - // pass the guards above and build duplicate sessions over one - // transport. The newest claim wins; a superseded call observes the - // mismatch after each await and bails. - if connectingDeviceID == deviceID { - logger.info("Connection already claimed for \(deviceID.uuidString.prefix(8)), ignoring") - return + // If state machine is already auto-reconnecting (from state restoration), + // let it complete rather than fighting with it + if await stateMachine.isAutoReconnecting { + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + logger.info( + "State restoration in progress - blePhase: \(blePhase), blePeripheralState: \(blePeripheralState), waiting for auto-reconnect" + ) + return } - connectingDeviceID = deviceID - // Handle state restoration auto-reconnect - let transportAutoReconnecting = await stateMachine.isAutoReconnecting - guard connectingDeviceID == deviceID else { throw CancellationError() } - if transportAutoReconnecting { - let restoringDeviceID = await stateMachine.connectedDeviceID - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - guard connectingDeviceID == deviceID else { throw CancellationError() } - - if restoringDeviceID != deviceID { - logger.info("Cancelling state restoration auto-reconnect to \(restoringDeviceID?.uuidString ?? "unknown") to connect to \(deviceID)") - await transport.disconnect() - guard connectingDeviceID == deviceID else { throw CancellationError() } - } else { - // Same device - let auto-reconnect complete instead of racing with it. - // The reconnection handler will create the session when auto-reconnect succeeds. - // Release the claim: the reconnect cycle owns identity tracking from here. - connectingDeviceID = nil - // Preserve user intent so the watchdog can retry if auto-reconnect fails. - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - // Show connecting UI so the user sees their tap did something - if connectionState != .connecting { - connectionState = .connecting - } - // Re-arm timeout in case the previous one already fired - reconnectionCoordinator.restartTimeout(deviceID: deviceID) - logger.warning( - "[BLE] Deferring to iOS auto-reconnect for device \(deviceID.uuidString.prefix(8)) - connectionState: \(String(describing: connectionState)), blePhase: \(blePhase), blePeripheralState: \(blePeripheralState)" - ) - return - } + if await stateMachine.isConnected, await stateMachine.connectedDeviceID == lastDeviceID { + logger.info("State restoration complete - device already connected, waiting for session setup") + return } - // If the user is reconnecting to the last radio and iOS still has a system-level BLE link - // (common after app updates), adopt the existing link rather than blocking as "connected elsewhere". - if deviceID == lastConnectedDeviceID { - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - - if await startAdoptingLastSystemConnectedPeripheralIfAvailable( - deviceID: deviceID, - context: "connect(to:)" - ) { - // Adoption owns the reconnect cycle now; release the claim. - if connectingDeviceID == deviceID { connectingDeviceID = nil } - return - } - guard connectingDeviceID == deviceID else { throw CancellationError() } + // If iOS kept the BLE link alive (common across app updates) but restoration didn't fire, + // adopt the system-connected peripheral rather than treating it as "connected elsewhere". + if await startAdoptingLastSystemConnectedPeripheralIfAvailable( + deviceID: lastDeviceID, + context: "activate" + ) { + return } - // Check for other app connection before changing state - if await isDeviceConnectedToOtherApp(deviceID) { - if connectingDeviceID == deviceID { connectingDeviceID = nil } - throw BLEError.deviceConnectedToOtherApp + // Check if device is connected to another app before auto-reconnect + // Silently skip per HIG: minimize interruptions on app launch + if await isDeviceConnectedToOtherApp(lastDeviceID) { + logger.info("Auto-reconnect skipped: device connected to another app") + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + persistDisconnectDiagnostic( + "source=activate.autoReconnectSkippedOtherApp, " + + "device=\(lastDeviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "intent=\(connectionIntent)" + ) + + // Keep intent so we can retry on foreground/watchdog, but avoid + // fighting another app's connection on launch. + startReconnectionWatchdog() + return } - guard connectingDeviceID == deviceID else { throw CancellationError() } - - // Clear intentional disconnect flag before changing state, - // so the didSet invariant check sees consistent state - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - - // Set connecting state for immediate UI feedback - // (connectingDeviceID was already claimed above) - connectionState = .connecting - - logger.info("Connecting to device: \(deviceID)") - - // Cancel any pending auto-reconnect timeout and clear device identity - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() do { - // Validate device is still registered with the pairing registry. macOS reports - // no active session, so this guard is skipped and CoreBluetooth resolves the device. - if pairing.isSessionActive { - let isConnectable = pairing.isDeviceConnectable(deviceID) - - if !isConnectable { - await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "connect(to:) paired accessories mismatch") - throw ConnectionError.deviceNotFound - } - } - - // Attempt connection with retry. Without a system pairing registry (macOS), - // CoreBluetooth cannot pre-reject an absent saved peripheral, so a user-initiated - // tap on an out-of-range radio would hang the full retry budget. Bound user taps - // there; background reconnects (forceReconnect == false) keep the full budget so - // unattended recovery still gets every attempt. - let maxAttempts = (forceReconnect && !pairing.hasSystemPairingRegistry) - ? Self.unverifiedConnectAttempts - : Self.defaultConnectAttempts - try await connectWithRetry(deviceID: deviceID, maxAttempts: maxAttempts) + try await connect(to: lastDeviceID) } catch { - guard connectingDeviceID == deviceID else { - logger.info("Connection to \(deviceID.uuidString.prefix(8)) superseded") - throw error - } - if error is CancellationError { - logger.info("Connection cancelled") - } else { - logger.warning("Connection failed: \(error.localizedDescription)") - } - connectingDeviceID = nil - connectionState = .disconnected - throw error + logger.warning("Auto-reconnect failed: \(error.localizedDescription)") + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + persistDisconnectDiagnostic( + "source=activate.autoReconnectFailed, " + + "device=\(lastDeviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "error=\(error.localizedDescription), " + + "intent=\(connectionIntent)" + ) + // A setup-phase auth failure arrives here as a thrown error and never + // fires onDisconnection, so surface the guided recovery now rather than + // waiting for the watchdog's first tick. The latch keeps the watchdog + // from re-presenting it. + if case BLEError.authenticationFailed = error { + surfaceAuthenticationFailure(deviceID: lastDeviceID) + } + startReconnectionWatchdog() + // Don't propagate - auto-reconnect failure is not fatal } - connectingDeviceID = nil + } else { + logger.info("No last connected device - skipping auto-reconnect") + } + #endif + } + + // MARK: - Connection + + /// Connects to a previously paired device. + /// + /// This method handles all connection scenarios: + /// - If disconnected: connects to the device + /// - If already connected to this device: no-op + /// - If connected to a different device: switches to the new device + /// + /// - Parameters: + /// - deviceID: The UUID of the device to connect to + /// - forceFullSync: Whether to force a full sync instead of incremental + /// - forceReconnect: When `true`, marks the connect as user-initiated: it bypasses the + /// circuit breaker, and on a platform without a system pairing registry (macOS) it also + /// bounds the retry budget to `unverifiedConnectAttempts`, so a tap on an out-of-range + /// radio fails fast instead of hanging the full budget. Background reconnects pass + /// `false` to keep the full budget for unattended recovery. + /// - Throws: Connection errors + func connect(to deviceID: UUID, forceFullSync: Bool = false, forceReconnect: Bool = false) async throws { + // Honor cancellation before any state mutation. Without this checkpoint + // a cancelled `pairNewDevice` task whose preceding awaits all swallowed + // cancellation would still drive a real BLE connect to completion. + try Task.checkCancellation() + + // Circuit breaker: prevent rapid reconnection loops after repeated failures + guard shouldAllowConnection(force: forceReconnect) else { + logger.info("[BLE] Circuit breaker open, rejecting connection to \(deviceID.uuidString.prefix(8))") + throw BLEError.connectionFailed("Connection blocked by circuit breaker (cooling down)") } - /// Disconnects from the current device. - /// - Parameter reason: The reason for disconnecting (for debugging) - public func disconnect(reason: DisconnectReason = .userInitiated) async { - let initialState = String(describing: connectionState) - let transportName = switch currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" + if activeReconnectDeviceID == deviceID { + if forceReconnect, connectionState == .disconnected, sessionRebuildDeviceID != deviceID { + await abandonStuckReconnect(deviceID: deviceID) + } else { + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() + if sessionRebuildDeviceID != deviceID { + reconnectionCoordinator.restartTimeout(deviceID: deviceID) } - let activeDevice = connectedDevice?.id.uuidString.prefix(8) ?? "none" - - logger.info( - "Disconnecting from device (" + - "reason: \(reason.rawValue), " + - "transport: \(transportName), " + - "device: \(activeDevice), " + - "initialState: \(initialState), " + - "intent: \(connectionIntent)" + - ")" - ) - - // Cancel any pending auto-reconnect timeout and clear device identity - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() - connectingDeviceID = nil + logger.info("[BLE] Reconnect already in progress for \(deviceID.uuidString.prefix(8)), deferring duplicate connect request") + return + } + } - // Cancel any WiFi reconnection in progress - cancelWiFiReconnection() - - // Stop WiFi heartbeat - stopWiFiHeartbeat() - - // Stop reconnection watchdog - stopReconnectionWatchdog() - - cancelResyncLoop() - cancelChannelRetry() - - // Only clear user intent and clean-channel state for explicit disconnects. - // Transient reasons preserve both so the next reconnect can skip redundant channel sync. - switch reason { - case .userInitiated, .statusMenuDisconnectTap, .forgetDevice, .deviceRemovedFromSettings, .factoryReset, .switchingDevice: - connectionIntent = .userDisconnected - persistIntent() - lastCleanChannelSync = nil - lastAttemptedChannelSync = nil - case .resyncFailed, .wifiAddressChange, .wifiReconnectPrep, .pairingFailed: - // Preserve .wantsConnection so health check can retry - break - } + // Prevent concurrent connection attempts + if connectionState == .connecting { + let currentDeviceID = activeConnectionAttemptDeviceID - // Capture and clear synchronously, mirroring teardownSessionForReconnect: - // a racing connect can install a new container during the awaits below, - // and re-reading self.services would tear that new container down. - let oldServices = services - let oldSession = session - services = nil - session = nil - logger.info("[BLE] disconnect: state → .disconnected") - connectionState = .disconnected - connectingDeviceID = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - - if let oldServices { - // Mark room sessions disconnected before tearing down services - _ = await oldServices.remoteNodeService.handleBLEDisconnection() - sessionsAwaitingReauth = [] - await oldServices.tearDown() - // Reset sync state and clear notification suppression (safety net) - await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + if currentDeviceID == deviceID { + if connectingDeviceID == nil { + // Auto-reconnect same device — refresh UI timeout + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() + reconnectionCoordinator.restartTimeout(deviceID: deviceID) } + logger.info("Connection already in progress for \(deviceID.uuidString.prefix(8)), ignoring") + return + } + + // Different device — cancel current and fall through + logger.info("Cancelling connection to \(currentDeviceID?.uuidString.prefix(8) ?? "unknown") to connect to \(deviceID.uuidString.prefix(8))") + connectingDeviceID = nil + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + cancelResyncLoop() + stopReconnectionWatchdog() + await cleanupResources() + await transport.disconnect() + connectionState = .disconnected + } - // Stop session - await oldSession?.stop() + // Handle already-connected cases + if connectionState != .disconnected { + if connectedDevice?.id == deviceID { + logger.info("Already connected to device: \(deviceID)") + return + } + // Connected to different device - switch to new one + logger.info("Switching from current device to: \(deviceID)") + try await switchDevice(to: deviceID) + return + } - // Disconnect appropriate transport based on current type - if let wifiTransport { - await wifiTransport.disconnect() - self.wifiTransport = nil - } else { - await transport.disconnect() + // Claim the attempt before the pre-connect awaits below. Two connect + // calls interleaving at those suspension points would otherwise both + // pass the guards above and build duplicate sessions over one + // transport. The newest claim wins; a superseded call observes the + // mismatch after each await and bails. + if connectingDeviceID == deviceID { + logger.info("Connection already claimed for \(deviceID.uuidString.prefix(8)), ignoring") + return + } + connectingDeviceID = deviceID + + // Handle state restoration auto-reconnect + let transportAutoReconnecting = await stateMachine.isAutoReconnecting + guard connectingDeviceID == deviceID else { throw CancellationError() } + if transportAutoReconnecting { + let restoringDeviceID = await stateMachine.connectedDeviceID + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + guard connectingDeviceID == deviceID else { throw CancellationError() } + + if restoringDeviceID != deviceID { + logger.info("Cancelling state restoration auto-reconnect to \(restoringDeviceID?.uuidString ?? "unknown") to connect to \(deviceID)") + await transport.disconnect() + guard connectingDeviceID == deviceID else { throw CancellationError() } + } else if forceReconnect, connectionState == .disconnected { + // Reached when the coordinator's reconnect cycle was already cleared while + // the state machine itself is still mid auto-reconnect for this device. + await abandonStuckReconnect( + deviceID: deviceID, + detail: " - blePhase: \(blePhase), blePeripheralState: \(blePeripheralState)" + ) + guard connectingDeviceID == deviceID else { throw CancellationError() } + } else { + // Same device - let auto-reconnect complete instead of racing with it. + // The reconnection handler will create the session when auto-reconnect succeeds. + // Release the claim: the reconnect cycle owns identity tracking from here. + connectingDeviceID = nil + // Preserve user intent so the watchdog can retry if auto-reconnect fails. + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() + // Show connecting UI so the user sees their tap did something + if connectionState != .connecting { + connectionState = .connecting } - - // Clear transport type - currentTransportType = nil - - persistDisconnectDiagnostic( - "source=disconnect(reason), " + - "reason=\(reason.rawValue), " + - "transport=\(transportName), " + - "device=\(activeDevice), " + - "initialState=\(initialState), " + - "finalState=\(String(describing: connectionState)), " + - "intent=\(connectionIntent)" + // Re-arm timeout in case the previous one already fired + reconnectionCoordinator.restartTimeout(deviceID: deviceID) + logger.warning( + "[BLE] Deferring to iOS auto-reconnect for device \(deviceID.uuidString.prefix(8)) - connectionState: \(String(describing: connectionState)), blePhase: \(blePhase), blePeripheralState: \(blePeripheralState)" ) + return + } + } - logger.info( - "Disconnected (" + - "reason: \(reason.rawValue), " + - "transport: \(transportName), " + - "device: \(activeDevice), " + - "initialState: \(initialState), " + - "finalState: \(String(describing: connectionState)), " + - "intent: \(connectionIntent)" + - ")" - ) + // If the user is reconnecting to the last radio and iOS still has a system-level BLE link + // (common after app updates), adopt the existing link rather than blocking as "connected elsewhere". + if deviceID == lastConnectedDeviceID { + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() + + if await startAdoptingLastSystemConnectedPeripheralIfAvailable( + deviceID: deviceID, + context: "connect(to:)" + ) { + // Adoption owns the reconnect cycle now; release the claim. + if connectingDeviceID == deviceID { connectingDeviceID = nil } + return + } + guard connectingDeviceID == deviceID else { throw CancellationError() } } - // MARK: - Simulator + // Check for other app connection before changing state + if await isDeviceConnectedToOtherApp(deviceID) { + if connectingDeviceID == deviceID { connectingDeviceID = nil } + throw BLEError.deviceConnectedToOtherApp + } + guard connectingDeviceID == deviceID else { throw CancellationError() } - /// Connects to the simulator device with mock data. - /// Used for simulator builds and demo mode on device. - public func simulatorConnect() async throws { - logger.info("Starting simulator connection") + // Clear intentional disconnect flag before changing state, + // so the didSet invariant check sees consistent state + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() - connectionIntent = .wantsConnection() - persistIntent() - connectingDeviceID = MockDataProvider.simulatorDeviceID - connectionState = .connecting + // Set connecting state for immediate UI feedback + // (connectingDeviceID was already claimed above) + connectionState = .connecting - do { - // Connect simulator mode - await simulatorMode.connect() - - // Create services with a placeholder session - // Note: We need a MeshCoreSession but won't actually use it for communication - // The mock data is seeded directly into the persistence store - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - self.session = session - - // Create services - let newServices = ServiceContainer( - session: session, - modelContainer: modelContainer, - radioID: MockDataProvider.simulatorDeviceID, - appStateProvider: appStateProvider, - connectionStateEvents: connectionStateEvents, - initialConnectionState: connectionState - ) - await wireCleanChannelSyncCallback(on: newServices) - await newServices.chatSendQueueService.hydrate() - self.services = newServices - - // Seed mock data - try await simulatorMode.seedDataStore(newServices.dataStore) - - // Set connected device - self.connectedDevice = MockDataProvider.simulatorDevice - - // Persist for auto-reconnect - persistConnection( - deviceID: MockDataProvider.simulatorDeviceID, - radioID: MockDataProvider.simulatorDeviceID, - deviceName: "MeshCore One Sim" - ) - - // Notify observers - await onConnectionReady?() - - connectingDeviceID = nil - connectionState = .ready - await onDeviceSynced?() - logger.info("Simulator connection complete") - } catch { - connectingDeviceID = nil - await cleanupConnection() - throw error - } - } + logger.info("Connecting to device: \(deviceID)") - // MARK: - Device Switching + // Cancel any pending auto-reconnect timeout and clear device identity + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() - /// Switches to a different device. - /// - /// - Parameter deviceID: UUID of the new device to connect to - public func switchDevice(to deviceID: UUID) async throws { - logger.info("Switching to device: \(deviceID)") - lastCleanChannelSync = nil - lastAttemptedChannelSync = nil + do { + // Validate device is still registered with the pairing registry. macOS reports + // no active session, so this guard is skipped and CoreBluetooth resolves the device. + if pairing.isSessionActive { + let isConnectable = pairing.isDeviceConnectable(deviceID) - // Update intent - connectionIntent = .wantsConnection() - persistIntent() + if !isConnectable { + await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "connect(to:) paired accessories mismatch") + throw ConnectionError.deviceNotFound + } + } + + // Attempt connection with retry. Without a system pairing registry (macOS), + // CoreBluetooth cannot pre-reject an absent saved peripheral, so a user-initiated + // tap on an out-of-range radio would hang the full retry budget. Bound user taps + // there; background reconnects (forceReconnect == false) keep the full budget so + // unattended recovery still gets every attempt. + let maxAttempts = (forceReconnect && !pairing.hasSystemPairingRegistry) + ? Self.unverifiedConnectAttempts + : Self.defaultConnectAttempts + try await connectWithRetry(deviceID: deviceID, maxAttempts: maxAttempts) + } catch { + guard connectingDeviceID == deviceID else { + logger.info("Connection to \(deviceID.uuidString.prefix(8)) superseded") + throw error + } + if error is CancellationError { + logger.info("Connection cancelled") + } else { + logger.warning("Connection failed: \(error.localizedDescription)") + } + connectingDeviceID = nil + connectionState = .disconnected + throw error + } + connectingDeviceID = nil + } + + /// Break-glass teardown for a user-initiated (`forceReconnect`) connect that found + /// a stuck reconnect: the UI already abandoned the cycle (pill shows "Disconnected") + /// but the OS pending connect never resolved, e.g. the bond was invalidated. + /// Deferring again would restart the same doomed wait forever, so cancel the cycle + /// and tear down the pending connect; the caller falls through to a fresh attempt + /// that surfaces the real CoreBluetooth error instead. + private func abandonStuckReconnect(deviceID: UUID, detail: String = "") async { + logger.warning("[BLE] Break-glass: abandoning stuck reconnect for \(deviceID.uuidString.prefix(8)) to allow a fresh connect attempt\(detail)") + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + await transport.disconnect() + } + + /// Disconnects from the current device. + /// - Parameter reason: The reason for disconnecting (for debugging) + func disconnect(reason: DisconnectReason = .userInitiated) async { + let initialState = String(describing: connectionState) + let transportName = switch currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" + } + let activeDevice = connectedDevice?.id.uuidString.prefix(8) ?? "none" + + logger.info( + "Disconnecting from device (" + + "reason: \(reason.rawValue), " + + "transport: \(transportName), " + + "device: \(activeDevice), " + + "initialState: \(initialState), " + + "intent: \(connectionIntent)" + + ")" + ) + + // Cancel any pending auto-reconnect timeout and clear device identity + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + connectingDeviceID = nil + + // Cancel any WiFi reconnection in progress + cancelWiFiReconnection() + + // Stop WiFi heartbeat + stopWiFiHeartbeat() + + // Stop reconnection watchdog + stopReconnectionWatchdog() + + cancelResyncLoop() + cancelChannelRetry() + + // Only clear user intent and clean-channel state for explicit disconnects. + // Transient reasons preserve both so the next reconnect can skip redundant channel sync. + switch reason { + case .userInitiated, .statusMenuDisconnectTap, .forgetDevice, .deviceRemovedFromSettings, .factoryReset, .switchingDevice: + connectionIntent = .userDisconnected + persistIntent() + surfacedAuthFailureDeviceID = nil + lastCleanChannelSync = nil + lastAttemptedChannelSync = nil + case .resyncFailed, .wifiAddressChange, .wifiReconnectPrep, .pairingFailed: + // Preserve .wantsConnection so health check can retry + break + } - do { - // Validate device is registered with the pairing registry. Skipped on macOS, - // which has no active session; CoreBluetooth resolves the device on connect. - if pairing.isSessionActive { - let isConnectable = pairing.isDeviceConnectable(deviceID) - if !isConnectable { - await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "switchDevice paired accessories mismatch") - throw ConnectionError.deviceNotFound - } - } + // Capture and clear synchronously, mirroring teardownSessionForReconnect: + // a racing connect can install a new container during the awaits below, + // and re-reading self.services would tear that new container down. + let oldServices = services + let oldSession = session + services = nil + session = nil + logger.info("[BLE] disconnect: state → .disconnected") + connectionState = .disconnected + connectingDeviceID = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + + if let oldServices { + // Mark room sessions disconnected before tearing down services + _ = await oldServices.remoteNodeService.handleBLEDisconnection() + sessionsAwaitingReauth = [] + await oldServices.tearDown() + // Reset sync state and clear notification suppression (safety net) + await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + } - // Cancel any resync loop from the old device before teardown - cancelResyncLoop() + // Stop session + await oldSession?.stop() - // Reset sync state before destroying services to prevent stuck "Syncing" pill - if let services { - await services.syncCoordinator.onDisconnected(notificationService: services.notificationService) - } + // Disconnect appropriate transport based on current type + if let wifiTransport { + await wifiTransport.disconnect() + self.wifiTransport = nil + } else { + await transport.disconnect() + } - // Stop current services - await services?.tearDown() - // tearDown above cancelled the old container's connection-state - // subscription; nilling drops the dead reference before the - // connect ceremony installs its replacement. - self.services = nil - await session?.stop() - - // Switch transport - logger.info("[BLE] switchDevice: state → .connecting for device: \(deviceID.uuidString.prefix(8))") - connectionState = .connecting - try await transport.switchDevice(to: deviceID) - logger.info("[BLE] switchDevice: state → .connected for device: \(deviceID.uuidString.prefix(8))") - connectionState = .connected - - // Re-create session with existing transport - let newSession = MeshCoreSession(transport: transport) - self.session = newSession - - let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) - - // Configure BLE write pacing based on device platform - await configureBLEPacing(for: deviceCapabilities) - - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: newSession, - selfInfo: meshCoreSelfInfo, - capabilities: deviceCapabilities, - connectionMethods: Self.bleConnectionMethods(for: deviceID) - ) - - // Persist connection for auto-reconnect - persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) - - // Notify observers before sync starts so they can wire callbacks - await onConnectionReady?() - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, context: "Device switch", forceFullSync: true) - - guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .bluetooth) else { return } - - stopReconnectionWatchdog() - logger.info("Device switch complete - device ready") - } catch { - // Without this, a partial switch would leave services, session, and - // connectedDevice pointing at the old device with state stuck on - // .connecting or .connected. pairNewDevice routes through here when - // an old radio is still connected, so the failure alert's recovery - // actions need clean state. - await cleanupConnection() - await transport.disconnect() - // cleanupConnection tore down the session without firing onConnectionLost, - // so observers (and the radio's Live Activity) would otherwise be stranded - // on the old device's "connected" state. Route through the same callback the - // transport-loss and reconnect-failure paths use. - await onConnectionLost?() - throw error + // Clear transport type + currentTransportType = nil + + persistDisconnectDiagnostic( + "source=disconnect(reason), " + + "reason=\(reason.rawValue), " + + "transport=\(transportName), " + + "device=\(activeDevice), " + + "initialState=\(initialState), " + + "finalState=\(String(describing: connectionState)), " + + "intent=\(connectionIntent)" + ) + + logger.info( + "Disconnected (" + + "reason: \(reason.rawValue), " + + "transport: \(transportName), " + + "device: \(activeDevice), " + + "initialState: \(initialState), " + + "finalState: \(String(describing: connectionState)), " + + "intent: \(connectionIntent)" + + ")" + ) + } + + // MARK: - Simulator + + /// Connects to the simulator device with mock data. + /// Used for simulator builds and demo mode on device. + func simulatorConnect() async throws { + logger.info("Starting simulator connection") + + connectionIntent = .wantsConnection() + persistIntent() + connectingDeviceID = MockDataProvider.simulatorDeviceID + connectionState = .connecting + + do { + // Connect simulator mode + await simulatorMode.connect() + + // Create services with a placeholder session + // Note: We need a MeshCoreSession but won't actually use it for communication + // The mock data is seeded directly into the persistence store + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + self.session = session + + // Create services + let newServices = ServiceContainer( + session: session, + modelContainer: modelContainer, + radioID: MockDataProvider.simulatorDeviceID, + appStateProvider: appStateProvider, + connectionStateEvents: connectionStateEvents, + initialConnectionState: connectionState + ) + await wireCleanChannelSyncCallback(on: newServices) + await newServices.chatSendQueueService.hydrate() + services = newServices + + // Seed mock data + try await simulatorMode.seedDataStore(newServices.dataStore) + + // Set connected device + connectedDevice = MockDataProvider.simulatorDevice + + // Persist for auto-reconnect + persistConnection( + deviceID: MockDataProvider.simulatorDeviceID, + radioID: MockDataProvider.simulatorDeviceID, + deviceName: "MeshCore One Sim" + ) + + // Notify observers + await onConnectionReady?() + + connectingDeviceID = nil + connectionState = .ready + await onDeviceSynced?() + logger.info("Simulator connection complete") + } catch { + connectingDeviceID = nil + await cleanupConnection() + throw error + } + } + + // MARK: - Device Switching + + /// Switches to a different device. + /// + /// - Parameter deviceID: UUID of the new device to connect to + func switchDevice(to deviceID: UUID) async throws { + logger.info("Switching to device: \(deviceID)") + lastCleanChannelSync = nil + lastAttemptedChannelSync = nil + + // Update intent + connectionIntent = .wantsConnection() + persistIntent() + + do { + // Validate device is registered with the pairing registry. Skipped on macOS, + // which has no active session; CoreBluetooth resolves the device on connect. + if pairing.isSessionActive { + let isConnectable = pairing.isDeviceConnectable(deviceID) + if !isConnectable { + await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "switchDevice paired accessories mismatch") + throw ConnectionError.deviceNotFound } + } + + // Cancel any resync loop from the old device before teardown + cancelResyncLoop() + + // Reset sync state before destroying services to prevent stuck "Syncing" pill + if let services { + await services.syncCoordinator.onDisconnected(notificationService: services.notificationService) + } + + // Stop current services + await services?.tearDown() + // tearDown above cancelled the old container's connection-state + // subscription; nilling drops the dead reference before the + // connect ceremony installs its replacement. + services = nil + await session?.stop() + + // Switch transport + logger.info("[BLE] switchDevice: state → .connecting for device: \(deviceID.uuidString.prefix(8))") + connectionState = .connecting + try await transport.switchDevice(to: deviceID) + logger.info("[BLE] switchDevice: state → .connected for device: \(deviceID.uuidString.prefix(8))") + connectionState = .connected + + // Re-create session with existing transport + let newSession = MeshCoreSession(transport: transport) + session = newSession + + let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) + + // Configure BLE write pacing based on device platform + await configureBLEPacing(for: deviceCapabilities) + + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: newSession, + selfInfo: meshCoreSelfInfo, + capabilities: deviceCapabilities, + connectionMethods: Self.bleConnectionMethods(for: deviceID) + ) + + // Persist connection for auto-reconnect + persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) + + // Notify observers before sync starts so they can wire callbacks + await onConnectionReady?() + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, context: "Device switch", forceFullSync: true) + + guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .bluetooth) else { return } + + stopReconnectionWatchdog() + logger.info("Device switch complete - device ready") + } catch { + // Without this, a partial switch would leave services, session, and + // connectedDevice pointing at the old device with state stuck on + // .connecting or .connected. pairNewDevice routes through here when + // an old radio is still connected, so the failure alert's recovery + // actions need clean state. + await cleanupConnection() + await transport.disconnect() + // cleanupConnection tore down the session without firing onConnectionLost, + // so observers (and the radio's Live Activity) would otherwise be stranded + // on the old device's "connected" state. Route through the same callback the + // transport-loss and reconnect-failure paths use. + await onConnectionLost?() + throw error } + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift index 7d2e1ae1..3f20504e 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift @@ -3,586 +3,630 @@ import MeshCore // MARK: - Pairing -extension ConnectionManager { - - /// Discovers a new device through the platform pairing service (AccessorySetupKit on - /// iOS, an in-app CoreBluetooth scan picker on macOS), then connects through the shared - /// `connect(to:)` ceremony. The connect path coordinates with in-flight auto-reconnects, - /// switch-device handling, and the circuit breaker, which `connectAfterPairing`'s direct - /// `performConnection` call used to bypass. - /// - /// **Cancellation behavior** (per `await`): - /// - `pairing.discoverDevice()` — `withTaskCancellationHandler` resumes the - /// continuation with `CancellationError`. On iOS the system picker may stay visible - /// (no public ASK API to dismiss programmatically without invalidating the - /// session); if the user completes pairing in the orphaned picker, - /// `accessoryAdded` removes the bond immediately. The device is not registered - /// when this point is reached, so no cleanup needed here. - /// - `waitForOtherAppReconnection` — checks `Task.isCancelled` at the top of - /// each iteration and short-circuits to `false`. The subsequent - /// `try await connect(to:)` then surfaces the cancellation via its - /// entry-point `Task.checkCancellation()`. - /// - `connect(to:)` — runs `Task.checkCancellation()` before any state mutation - /// so a cancelled task cannot drive a real BLE connect through to success. - /// Propagates `CancellationError` normally; we catch it explicitly and - /// re-throw without re-wrapping so the UI alert path stays quiet. - /// - /// Hard quit: process death; defer doesn't fire; the in-memory flag resets to - /// `false` on next launch. No persistent state corruption. - /// - /// - Throws: - /// - `DevicePairingError.alreadyInProgress` on re-entry. - /// - `PairingError.deviceConnectedToOtherApp` when another app holds the radio. - /// - `PairingError.connectionFailed` for any other connection failure (auth, - /// timeout, transport error). The wrapped `underlying` is checked by - /// `PairingError.isAuthenticationFailure` so the auth alert path keeps working. - /// - `CancellationError` if the surrounding task is cancelled mid-flight. - public func pairNewDevice() async throws { - logger.info("Starting device pairing") - guard !isPairingInProgress else { - throw DevicePairingError.alreadyInProgress - } - isPairingInProgress = true - defer { isPairingInProgress = false } +public extension ConnectionManager { + /// Discovers a new device through the platform pairing service (AccessorySetupKit on + /// iOS, an in-app CoreBluetooth scan picker on macOS), then connects through the shared + /// `connect(to:)` ceremony. The connect path coordinates with in-flight auto-reconnects, + /// switch-device handling, and the circuit breaker, which `connectAfterPairing`'s direct + /// `performConnection` call used to bypass. + /// + /// **Cancellation behavior** (per `await`): + /// - `pairing.discoverDevice()` — `withTaskCancellationHandler` resumes the + /// continuation with `CancellationError`. On iOS the system picker may stay visible + /// (no public ASK API to dismiss programmatically without invalidating the + /// session); if the user completes pairing in the orphaned picker, + /// `accessoryAdded` removes the bond immediately. The device is not registered + /// when this point is reached, so no cleanup needed here. + /// - `waitForOtherAppReconnection` — checks `Task.isCancelled` at the top of + /// each iteration and short-circuits to `false`. The subsequent + /// `try await connect(to:)` then surfaces the cancellation via its + /// entry-point `Task.checkCancellation()`. + /// - `connect(to:)` — runs `Task.checkCancellation()` before any state mutation + /// so a cancelled task cannot drive a real BLE connect through to success. + /// Propagates `CancellationError` normally; we catch it explicitly and + /// re-throw without re-wrapping so the UI alert path stays quiet. + /// + /// Hard quit: process death; defer doesn't fire; the in-memory flag resets to + /// `false` on next launch. No persistent state corruption. + /// + /// - Throws: + /// - `DevicePairingError.alreadyInProgress` on re-entry. + /// - `PairingError.deviceConnectedToOtherApp` when another app holds the radio. + /// - `PairingError.connectionFailed` for any other connection failure (auth, + /// timeout, transport error). The wrapped `underlying` is checked by + /// `PairingError.isAuthenticationFailure` so the auth alert path keeps working. + /// - `CancellationError` if the surrounding task is cancelled mid-flight. + func pairNewDevice() async throws { + logger.info("Starting device pairing") + guard !isPairingInProgress else { + throw DevicePairingError.alreadyInProgress + } + isPairingInProgress = true + defer { isPairingInProgress = false } - connectionIntent = .wantsConnection() - persistIntent() + connectionIntent = .wantsConnection() + persistIntent() - await stopBLEScanning() + await stopBLEScanning() - let deviceID = try await pairing.discoverDevice() + // Enumeration must follow activation: the system registry reads empty until the + // session is active. Sweeping strays before the picker keeps its confirmation + // dialogs in the context of the pairing the user just started. + try await pairing.activate() + await removeStrandedAssociations() - if await waitForOtherAppReconnection(deviceID) { - throw PairingError.deviceConnectedToOtherApp(deviceID: deviceID) - } + let deviceID = try await pairing.discoverDevice() - do { - try await connect(to: deviceID, forceFullSync: true, forceReconnect: true) - } catch BLEError.deviceConnectedToOtherApp { - // No `cleanupPartialPairing` here — the bond is good; the user retries - // after dismissing the other app via the otherAppWarningDeviceID alert. - // Removing the bond would force a fresh pair instead. - throw PairingError.deviceConnectedToOtherApp(deviceID: deviceID) - } catch is CancellationError { - await cleanupPartialPairing(deviceID: deviceID) - throw CancellationError() - } catch { - // Edge case: a domain error bubbled up while the surrounding task was - // also cancelled. Without this guard the user sees "Couldn't connect" - // instead of silent cancellation. Re-throw as CancellationError so the - // alert path doesn't fire. - if Task.isCancelled { - await cleanupPartialPairing(deviceID: deviceID) - throw CancellationError() - } - logger.error("Connection after pairing failed: \(error.localizedDescription)") - throw PairingError.connectionFailed(deviceID: deviceID, underlying: error) - } + if await waitForOtherAppReconnection(deviceID) { + throw PairingError.deviceConnectedToOtherApp(deviceID: deviceID) } - /// Removes a partially-paired device from the system registry after `connect(to:)` was - /// cancelled mid-flight. On iOS the system has the device; we don't. Without this cleanup, - /// iOS retains a paired bond with no app-level state, surfacing as a phantom device in - /// Settings → Bluetooth that won't show up in the picker again. No-op on macOS. - private func cleanupPartialPairing(deviceID: UUID) async { - logger.info("Pairing cancelled — removing device \(deviceID.uuidString.prefix(8)) from pairing registry") - try? await pairing.removeDevice(deviceID) - // Defensive backstop — connectWithRetry and switchDevice both reset state - // on throw, so this is normally a no-op. Kept so a future cancellation - // path that lands here without doing so still leaves a clean UI. - connectionState = .disconnected + do { + try await connect(to: deviceID, forceFullSync: true, forceReconnect: true) + } catch BLEError.deviceConnectedToOtherApp { + // No `cleanupPartialPairing` here — the bond is good; the user retries + // after dismissing the other app via the otherAppWarningDeviceID alert. + // Removing the bond would force a fresh pair instead. + throw PairingError.deviceConnectedToOtherApp(deviceID: deviceID) + } catch is CancellationError { + await cleanupPartialPairing(deviceID: deviceID) + throw CancellationError() + } catch { + // Edge case: a domain error bubbled up while the surrounding task was + // also cancelled. Without this guard the user sees "Couldn't connect" + // instead of silent cancellation. Re-throw as CancellationError so the + // alert path doesn't fire. + if Task.isCancelled { + await cleanupPartialPairing(deviceID: deviceID) + throw CancellationError() + } + logger.error("Connection after pairing failed: \(error.localizedDescription)") + throw PairingError.connectionFailed(deviceID: deviceID, underlying: error) + } + } + + /// Removes a partially-paired device from the system registry when pairing is cancelled + /// mid-flight before a usable connection exists. On iOS the system has the device; we don't. + /// Without this cleanup, iOS retains a paired bond with no app-level state, surfacing as a + /// phantom device in Settings → Bluetooth that won't show up in the picker again. No-op on macOS. + private func cleanupPartialPairing(deviceID: UUID) async { + logger.info("Removing partially-paired device \(deviceID.uuidString.prefix(8)) from pairing registry") + try? await pairing.removeDevice(deviceID) + // Defensive backstop — connectWithRetry and switchDevice both reset state + // on throw, so this is normally a no-op. Kept so a future cancellation + // path that lands here without doing so still leaves a clean UI. + connectionState = .disconnected + } + + /// Sweeps system pairing associations that no longer map to a saved device, run once at + /// the start of each pairing attempt before the picker appears. A fresh pairing that failed + /// authentication leaves its association behind when the user declines the system removal + /// dialog, and iOS then hides it from the picker, so it can only be cleared here, while the + /// user is actively pairing. Saved radios (their `id` matches a `Device` row), demoted ghosts + /// (fresh random ids whose associations were already removed), and the live connection are + /// never touched. A removal may present a system confirmation the user can decline; a decline + /// or any other failure leaves that association in place and the flow proceeds to the picker + /// regardless. No-op on platforms without a system pairing registry. + private func removeStrandedAssociations() async { + guard pairing.hasSystemPairingRegistry else { return } + + var protectedIDs = Set() + if let connectedID = connectedDevice?.id { protectedIDs.insert(connectedID) } + if let attemptID = activeConnectionAttemptDeviceID { protectedIDs.insert(attemptID) } + + let dataStore = PersistenceStore(modelContainer: modelContainer) + + for info in pairing.registeredDeviceInfos() where !protectedIDs.contains(info.id) { + let existingDevice: DeviceDTO? + do { + existingDevice = try await dataStore.fetchDevice(id: info.id) + } catch { + logger.warning("Skipping association \(info.id.uuidString.prefix(8)); device lookup failed: \(error.localizedDescription)") + continue + } + guard existingDevice == nil else { continue } + + do { + try await pairing.removeDevice(info.id) + logger.info("Removed stranded pairing association \(info.id.uuidString.prefix(8)) with no device record") + } catch { + logger.warning("Failed to remove stranded association \(info.id.uuidString.prefix(8)): \(error.localizedDescription)") + } + } + } + + /// Removes a device that failed to connect after pairing, for the guided + /// "remove and retry" recovery. Demotes the row to a ghost rather than deleting + /// it, preserving the publicKey ↔ radioID bridge: an established radio that lost + /// its bond re-pairs onto the same radioID and its contacts, messages, and + /// channels reattach. A fresh pairing has no row to demote, so this is a no-op there. + /// - Parameter deviceID: The device ID from `PairingError.connectionFailed` + func removeFailedPairing(deviceID: UUID) async { + logger.info("Removing failed pairing for device: \(deviceID)") + + await transport.disconnect() + + do { + try await pairing.removeDevice(deviceID) + } catch { + logger.warning("Failed to remove from pairing registry: \(error.localizedDescription)") } - /// Removes a device that failed to connect after pairing. - /// Call this when user explicitly chooses to remove and retry. - /// No data cascade — fresh pairings have no associated data. - /// - Parameter deviceID: The device ID from `PairingError.connectionFailed` - public func removeFailedPairing(deviceID: UUID) async { - logger.info("Removing failed pairing for device: \(deviceID)") - - await transport.disconnect() - - do { - try await pairing.removeDevice(deviceID) - } catch { - logger.warning("Failed to remove from pairing registry: \(error.localizedDescription)") - } - - let dataStore = PersistenceStore(modelContainer: modelContainer) - try? await dataStore.deleteDevice(id: deviceID) + let dataStore = PersistenceStore(modelContainer: modelContainer) + try? await dataStore.demoteDeviceToGhost(id: deviceID) - if lastConnectedDeviceID == deviceID { - clearPersistedConnection() - } + if lastConnectedDeviceID == deviceID { + clearPersistedConnection() } + } + + // MARK: - Other-App Detection + + /// Polls for other-app reconnection after ASK pairing disrupts existing BLE connections. + /// ASK pairing severs the other app's BLE link; it auto-reconnects seconds later via + /// `CBConnectPeripheralOptionEnableAutoReconnect`. This method gives it time to reappear. + /// - Parameter deviceID: The UUID of the newly paired device + /// - Returns: `true` if the device was detected as connected to another app + internal func waitForOtherAppReconnection(_ deviceID: UUID) async -> Bool { + #if DEBUG + if let strategy = otherAppWaitStrategyOverride { + return await strategy(deviceID) + } + #endif + return await defaultWaitForOtherAppReconnection(deviceID) + } + + private func defaultWaitForOtherAppReconnection(_ deviceID: UUID) async -> Bool { + let maxChecks = 6 + let interval: Duration = .milliseconds(400) + + for check in 1...maxChecks { + // Short-circuit on cancellation so the caller's `connect(to:)` checkpoint + // surfaces the CancellationError without grinding through every iteration. + if Task.isCancelled { + logger.info("[OtherAppCheck] Cancelled at check \(check)/\(maxChecks)") + return false + } - // MARK: - Other-App Detection - - /// Polls for other-app reconnection after ASK pairing disrupts existing BLE connections. - /// ASK pairing severs the other app's BLE link; it auto-reconnects seconds later via - /// `CBConnectPeripheralOptionEnableAutoReconnect`. This method gives it time to reappear. - /// - Parameter deviceID: The UUID of the newly paired device - /// - Returns: `true` if the device was detected as connected to another app - func waitForOtherAppReconnection(_ deviceID: UUID) async -> Bool { - #if DEBUG - if let strategy = otherAppWaitStrategyOverride { - return await strategy(deviceID) - } - #endif - return await defaultWaitForOtherAppReconnection(deviceID) - } - - private func defaultWaitForOtherAppReconnection(_ deviceID: UUID) async -> Bool { - let maxChecks = 6 - let interval: Duration = .milliseconds(400) - - for check in 1...maxChecks { - // Short-circuit on cancellation so the caller's `connect(to:)` checkpoint - // surfaces the CancellationError without grinding through every iteration. - if Task.isCancelled { - logger.info("[OtherAppCheck] Cancelled at check \(check)/\(maxChecks)") - return false - } - - let connected = await stateMachine.isDeviceConnectedToSystem(deviceID) - if connected { - logger.info("[OtherAppCheck] Detected other-app connection on check \(check)/\(maxChecks)") - return true - } - - if check < maxChecks { - try? await Task.sleep(for: interval) - } - } + let connected = await stateMachine.isDeviceConnectedToSystem(deviceID) + if connected { + logger.info("[OtherAppCheck] Detected other-app connection on check \(check)/\(maxChecks)") + return true + } - logger.info("[OtherAppCheck] No other-app connection detected after \(maxChecks) checks") - return false + if check < maxChecks { + try? await Task.sleep(for: interval) + } } - // MARK: - Forget Device + logger.info("[OtherAppCheck] No other-app connection detected after \(maxChecks) checks") + return false + } - /// Forgets the device, removing it from paired accessories and local storage. - /// - Parameter deleteData: If `true`, also deletes all associated data (contacts, messages, channels, trace paths). - /// - Throws: `ConnectionError.notConnected` if no device is connected - public func forgetDevice(deleteData: Bool) async throws { - guard let deviceID = connectedDevice?.id else { - throw ConnectionError.notConnected - } + // MARK: - Forget Device - guard pairing.isDeviceConnectable(deviceID) else { - throw ConnectionError.deviceNotFound - } + /// Forgets the device, removing it from paired accessories and local storage. + /// - Parameter deleteData: If `true`, also deletes all associated data (contacts, messages, channels, trace paths). + /// - Throws: `ConnectionError.notConnected` if no device is connected + func forgetDevice(deleteData: Bool) async throws { + guard let deviceID = connectedDevice?.id else { + throw ConnectionError.notConnected + } - logger.info("Forgetting device: \(deviceID), deleteData: \(deleteData)") + guard pairing.isDeviceConnectable(deviceID) else { + throw ConnectionError.deviceNotFound + } - await disconnect(reason: .forgetDevice) - try await pairing.removeDevice(deviceID) + logger.info("Forgetting device: \(deviceID), deleteData: \(deleteData)") - let dataStore = PersistenceStore(modelContainer: modelContainer) - do { - if deleteData { - try await dataStore.deleteDeviceAndData(id: deviceID) - } else { - try await dataStore.demoteDeviceToGhost(id: deviceID) - } - } catch { - logger.warning("Failed to demote device in SwiftData: \(error.localizedDescription)") - } + await disconnect(reason: .forgetDevice) + try await pairing.removeDevice(deviceID) - clearPersistedConnection() - logger.info("Device forgotten") + let dataStore = PersistenceStore(modelContainer: modelContainer) + do { + if deleteData { + try await dataStore.deleteDeviceAndData(id: deviceID) + } else { + try await dataStore.demoteDeviceToGhost(id: deviceID) + } + } catch { + logger.warning("Failed to demote device in SwiftData: \(error.localizedDescription)") } - /// Forgets a device by ID, removing it from paired accessories and local storage. - /// Deletes both the device record and all associated data (factory reset path). - /// Best-effort cleanup — does not throw. - public func forgetDevice(id: UUID) async { - logger.info("Forgetting device by ID: \(id)") + clearPersistedConnection() + logger.info("Device forgotten") + } - await disconnect(reason: .factoryReset) + /// Forgets a device by ID, removing it from paired accessories and local storage. + /// Deletes both the device record and all associated data (factory reset path). + /// Best-effort cleanup — does not throw. + func forgetDevice(id: UUID) async { + logger.info("Forgetting device by ID: \(id)") - do { - try await pairing.removeDevice(id) - } catch { - logger.warning("Failed to remove device from pairing registry: \(error.localizedDescription)") - } + await disconnect(reason: .factoryReset) - let dataStore = PersistenceStore(modelContainer: modelContainer) - do { - try await dataStore.deleteDeviceAndData(id: id) - } catch { - logger.warning("Failed to delete device data from SwiftData: \(error.localizedDescription)") - } + do { + try await pairing.removeDevice(id) + } catch { + logger.warning("Failed to remove device from pairing registry: \(error.localizedDescription)") + } - // Drop the auto-reconnect / resume signal if we just forgot the device this install - // last connected to, so it can't trigger a phantom reconnect or onboarding resume. - if lastConnectedDeviceID == id { - clearPersistedConnection() - } + let dataStore = PersistenceStore(modelContainer: modelContainer) + do { + try await dataStore.deleteDeviceAndData(id: id) + } catch { + logger.warning("Failed to delete device data from SwiftData: \(error.localizedDescription)") + } - logger.info("Device forgotten by ID: \(id)") + // Drop the auto-reconnect / resume signal if we just forgot the device this install + // last connected to, so it can't trigger a phantom reconnect or onboarding resume. + if lastConnectedDeviceID == id { + clearPersistedConnection() } - // MARK: - Node Management + logger.info("Device forgotten by ID: \(id)") + } - /// Returns the number of non-favorite contacts for the current device. - public func unfavoritedNodeCount() async throws -> Int { - guard let radioID = connectedDevice?.radioID else { - throw ConnectionError.notConnected - } + // MARK: - Node Management - let dataStore = PersistenceStore(modelContainer: modelContainer) - let allContacts = try await dataStore.fetchContacts(radioID: radioID) - return allContacts.filter { !$0.isFavorite }.count + /// Returns the number of non-favorite contacts for the current device. + func unfavoritedNodeCount() async throws -> Int { + guard let radioID = connectedDevice?.radioID else { + throw ConnectionError.notConnected } - /// Removes all non-favorite contacts from the device and app, along with their messages. - /// - Returns: Count of removed vs total non-favorite contacts - /// - Throws: `ConnectionError.notConnected` if no device is connected - public func removeUnfavoritedNodes() async throws -> RemoveUnfavoritedResult { - try await removeContacts(matching: { !$0.isFavorite }) + let dataStore = PersistenceStore(modelContainer: modelContainer) + let allContacts = try await dataStore.fetchContacts(radioID: radioID) + return allContacts.count(where: { !$0.isFavorite }) + } + + /// Removes all non-favorite contacts from the device and app, along with their messages. + /// - Returns: Count of removed vs total non-favorite contacts + /// - Throws: `ConnectionError.notConnected` if no device is connected + func removeUnfavoritedNodes() async throws -> RemoveUnfavoritedResult { + try await removeContacts(matching: { !$0.isFavorite }) + } + + /// Removes non-favorite contacts whose `lastModified` timestamp is older than the given threshold. + /// - Parameter days: Number of days. Contacts not heard from in this many days are removed. + /// - Returns: Count of removed vs total stale contacts + /// - Throws: `ConnectionError.notConnected` if no device is connected + func removeStaleNodes(olderThanDays days: Int) async throws -> RemoveUnfavoritedResult { + let cutoff = UInt32(Date().addingTimeInterval(-Double(days) * 86400).timeIntervalSince1970) + return try await removeContacts(matching: { !$0.isFavorite && $0.lastModified < cutoff }) { contact in + let ageDays = (Int(Date().timeIntervalSince1970) - Int(contact.lastModified)) / 86400 + let keyPrefix = contact.publicKeyHex.prefix(8) + self.logger.info("Auto-removed stale node '\(contact.name)' [\(keyPrefix)] (last heard \(ageDays)d ago)") + } + } + + /// Shared implementation for removing contacts matching a predicate. + /// - Parameters: + /// - predicate: Filter applied to all contacts to determine which to remove. + /// - onRemove: Optional callback invoked after each successful removal (for per-contact logging). + /// - Returns: Count of removed vs total matching contacts + private func removeContacts( + matching predicate: (ContactDTO) -> Bool, + onRemove: ((_ contact: ContactDTO) -> Void)? = nil + ) async throws -> RemoveUnfavoritedResult { + guard let radioID = connectedDevice?.radioID else { + throw ConnectionError.notConnected } - /// Removes non-favorite contacts whose `lastModified` timestamp is older than the given threshold. - /// - Parameter days: Number of days. Contacts not heard from in this many days are removed. - /// - Returns: Count of removed vs total stale contacts - /// - Throws: `ConnectionError.notConnected` if no device is connected - public func removeStaleNodes(olderThanDays days: Int) async throws -> RemoveUnfavoritedResult { - let cutoff = UInt32(Date().addingTimeInterval(-Double(days) * 86400).timeIntervalSince1970) - return try await removeContacts(matching: { !$0.isFavorite && $0.lastModified < cutoff }) { contact in - let ageDays = (Int(Date().timeIntervalSince1970) - Int(contact.lastModified)) / 86400 - let keyPrefix = contact.publicKeyHex.prefix(8) - self.logger.info("Auto-removed stale node '\(contact.name)' [\(keyPrefix)] (last heard \(ageDays)d ago)") - } + guard let services else { + throw ConnectionError.notConnected } - /// Shared implementation for removing contacts matching a predicate. - /// - Parameters: - /// - predicate: Filter applied to all contacts to determine which to remove. - /// - onRemove: Optional callback invoked after each successful removal (for per-contact logging). - /// - Returns: Count of removed vs total matching contacts - private func removeContacts( - matching predicate: (ContactDTO) -> Bool, - onRemove: ((_ contact: ContactDTO) -> Void)? = nil - ) async throws -> RemoveUnfavoritedResult { - guard let radioID = connectedDevice?.radioID else { - throw ConnectionError.notConnected - } + let dataStore = PersistenceStore(modelContainer: modelContainer) + let allContacts = try await dataStore.fetchContacts(radioID: radioID) + let targets = allContacts.filter(predicate) - guard let services else { - throw ConnectionError.notConnected - } + if targets.isEmpty { + return RemoveUnfavoritedResult(removed: 0, total: 0) + } - let dataStore = PersistenceStore(modelContainer: modelContainer) - let allContacts = try await dataStore.fetchContacts(radioID: radioID) - let targets = allContacts.filter(predicate) + var removedCount = 0 - if targets.isEmpty { - return RemoveUnfavoritedResult(removed: 0, total: 0) - } + for contact in targets { + try Task.checkCancellation() - var removedCount = 0 - - for contact in targets { - try Task.checkCancellation() - - do { - try await services.contactService.removeContact( - radioID: radioID, - publicKey: contact.publicKey - ) - removedCount += 1 - onRemove?(contact) - } catch ContactServiceError.contactNotFound { - do { - try await services.contactService.removeLocalContact( - contactID: contact.id, - publicKey: contact.publicKey - ) - removedCount += 1 - logger.info("Contact not found on device, cleaned up locally: \(contact.name)") - } catch is CancellationError { - throw CancellationError() - } catch { - logger.warning("Failed to clean up local data for \(contact.name): \(error.localizedDescription)") - } - } catch is CancellationError { - throw CancellationError() - } catch { - logger.warning("Failed to remove contact \(contact.name): \(error.localizedDescription)") - return RemoveUnfavoritedResult(removed: removedCount, total: targets.count) - } + do { + try await services.contactService.removeContact( + radioID: radioID, + publicKey: contact.publicKey + ) + removedCount += 1 + onRemove?(contact) + } catch ContactServiceError.contactNotFound { + do { + try await services.contactService.removeLocalContact( + contactID: contact.id, + publicKey: contact.publicKey + ) + removedCount += 1 + logger.info("Contact not found on device, cleaned up locally: \(contact.name)") + } catch is CancellationError { + throw CancellationError() + } catch { + logger.warning("Failed to clean up local data for \(contact.name): \(error.localizedDescription)") } - + } catch is CancellationError { + throw CancellationError() + } catch { + logger.warning("Failed to remove contact \(contact.name): \(error.localizedDescription)") return RemoveUnfavoritedResult(removed: removedCount, total: targets.count) + } } - // MARK: - Stale Pairings + return RemoveUnfavoritedResult(removed: removedCount, total: targets.count) + } - /// Clears all stale pairings from the system registry (AccessorySetupKit on iOS). - /// Use when a device has been factory-reset but iOS still has the old pairing. No-op on macOS. - public func clearStalePairings() async { - logger.info("Clearing stale pairings") - await pairing.clearStaleRegistrations() - logger.info("Stale pairings cleared") - } + // MARK: - Stale Pairings - // MARK: - Device Updates + /// Clears all stale pairings from the system registry (AccessorySetupKit on iOS). + /// Use when a device has been factory-reset but iOS still has the old pairing. No-op on macOS. + func clearStalePairings() async { + logger.info("Clearing stale pairings") + await pairing.clearStaleRegistrations() + logger.info("Stale pairings cleared") + } - /// Updates the connected device with new settings from SelfInfo. - /// Called by SettingsService after device settings are successfully changed. - /// Also persists to SwiftData so changes appear in Connect Device sheet. - public func updateDevice(from selfInfo: MeshCore.SelfInfo) { - guard let device = connectedDevice else { return } - let updated = device.updating(from: selfInfo) - connectedDevice = updated + // MARK: - Device Updates - // Persist to SwiftData - Task { - try? await services?.dataStore.saveDevice(updated) - } - } + /// Updates the connected device with new settings from SelfInfo. + /// Called by SettingsService after device settings are successfully changed. + /// Also persists to SwiftData so changes appear in Connect Device sheet. + func updateDevice(from selfInfo: MeshCore.SelfInfo) { + guard let device = connectedDevice else { return } + let updated = device.updating(from: selfInfo) + connectedDevice = updated - /// Updates the connected device with a new DeviceDTO. - /// Called by DeviceService after local device settings are successfully changed. - public func updateDevice(with deviceDTO: DeviceDTO) { - connectedDevice = deviceDTO + // Persist to SwiftData + Task { + try? await services?.dataStore.saveDevice(updated) } + } + + /// Updates the connected device with a new DeviceDTO. + /// Called by DeviceService after local device settings are successfully changed. + func updateDevice(with deviceDTO: DeviceDTO) { + connectedDevice = deviceDTO + } + + /// Updates the connected device's auto-add config. + /// Called by SettingsService after auto-add config is successfully changed. + func updateAutoAddConfig(_ config: MeshCore.AutoAddConfig) { + guard let device = connectedDevice else { return } + let updated = device.copy { + $0.autoAddConfig = config.bitmask + $0.autoAddMaxHops = config.maxHops + } + connectedDevice = updated - /// Updates the connected device's auto-add config. - /// Called by SettingsService after auto-add config is successfully changed. - public func updateAutoAddConfig(_ config: MeshCore.AutoAddConfig) { - guard let device = connectedDevice else { return } - let updated = device.copy { - $0.autoAddConfig = config.bitmask - $0.autoAddMaxHops = config.maxHops - } - connectedDevice = updated - - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist auto-add config: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist auto-add config: \(error)") } } + } - /// Updates the connected device's client repeat state. - /// Called by SettingsService after client repeat is successfully changed. - public func updateClientRepeat(_ enabled: Bool) { - guard let device = connectedDevice else { return } - let updated = device.copy { $0.clientRepeat = enabled } - connectedDevice = updated + /// Updates the connected device's client repeat state. + /// Called by SettingsService after client repeat is successfully changed. + func updateClientRepeat(_ enabled: Bool) { + guard let device = connectedDevice else { return } + let updated = device.copy { $0.clientRepeat = enabled } + connectedDevice = updated - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist client repeat state: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist client repeat state: \(error)") } } + } - /// Updates the connected device's path hash mode. - /// Called by SettingsService after path hash mode is successfully changed. - public func updatePathHashMode(_ mode: UInt8) { - guard let device = connectedDevice else { return } - let updated = device.copy { $0.pathHashMode = mode } - connectedDevice = updated + /// Updates the connected device's path hash mode. + /// Called by SettingsService after path hash mode is successfully changed. + func updatePathHashMode(_ mode: UInt8) { + guard let device = connectedDevice else { return } + let updated = device.copy { $0.pathHashMode = mode } + connectedDevice = updated - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist path hash mode: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist path hash mode: \(error)") } } + } - /// Updates the connected device's cached default flood scope name. - /// Called by SettingsService after a `getDefaultFloodScope` read or a successful write. - public func updateDefaultFloodScopeName(_ name: String?) { - guard let device = connectedDevice else { return } - let updated = device.copy { $0.defaultFloodScopeName = name } - connectedDevice = updated + /// Updates the connected device's cached default flood scope name. + /// Called by SettingsService after a `getDefaultFloodScope` read or a successful write. + func updateDefaultFloodScopeName(_ name: String?) { + guard let device = connectedDevice else { return } + let updated = device.copy { $0.defaultFloodScopeName = name } + connectedDevice = updated - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist default flood scope: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist default flood scope: \(error)") } } - - /// Appends a region to the connected device's known-regions list and persists. - /// No-ops if the region is already present. - public func addKnownRegion(_ region: String) { - guard let device = connectedDevice, - !device.knownRegions.contains(region) else { return } - let updated = device.copy { $0.knownRegions.append(region) } - connectedDevice = updated - Task { - do { try await services?.dataStore.addDeviceKnownRegion(radioID: updated.radioID, region: region) } catch { logger.error("Failed to add known region: \(error)") } - await services?.rxLogService.updateKnownRegions(updated.knownRegions) - } + } + + /// Appends a region to the connected device's known-regions list and persists. + /// No-ops if the region is already present. + func addKnownRegion(_ region: String) { + guard let device = connectedDevice, + !device.knownRegions.contains(region) else { return } + let updated = device.copy { $0.knownRegions.append(region) } + connectedDevice = updated + Task { + do { try await services?.dataStore.addDeviceKnownRegion(radioID: updated.radioID, region: region) } catch { logger.error("Failed to add known region: \(error)") } + await services?.rxLogService.updateKnownRegions(updated.knownRegions) } - - /// Removes a region from the connected device's known-regions list and persists. - /// If the removed region is the device's current default flood scope, also clears - /// the scope on the radio so firmware state doesn't dangle on a deleted name. - public func removeKnownRegion(_ region: String) { - guard let device = connectedDevice else { return } - let wasDefaultFloodScope = device.defaultFloodScopeName == region - let updated = device.copy { - $0.knownRegions.removeAll { $0 == region } - if wasDefaultFloodScope { - $0.defaultFloodScopeName = nil - } - } - connectedDevice = updated - Task { - do { - try await services?.dataStore.removeDeviceKnownRegion(radioID: updated.radioID, region: region) - } catch { - logger.error("Failed to remove known region: \(error)") - } - - if wasDefaultFloodScope, let settingsService = services?.settingsService { - do { - _ = try await settingsService.setDefaultFloodScopeVerified(name: nil) - } catch { - logger.error("Failed to clear default flood scope after region removal: \(error)") - } - } - - await services?.rxLogService.updateKnownRegions(updated.knownRegions) - } + } + + /// Removes a region from the connected device's known-regions list and persists. + /// If the removed region is the device's current default flood scope, also clears + /// the scope on the radio so firmware state doesn't dangle on a deleted name. + func removeKnownRegion(_ region: String) { + guard let device = connectedDevice else { return } + let wasDefaultFloodScope = device.defaultFloodScopeName == region + let updated = device.copy { + $0.knownRegions.removeAll { $0 == region } + if wasDefaultFloodScope { + $0.defaultFloodScopeName = nil + } } - - /// Saves the connected device's current radio settings as pre-repeat settings. - /// Called before enabling repeat mode so settings can be restored later. - public func savePreRepeatSettings() { - guard let device = connectedDevice else { return } - let updated = device.savingPreRepeatSettings() - connectedDevice = updated - - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist pre-repeat settings: \(error)") } + connectedDevice = updated + Task { + do { + try await services?.dataStore.removeDeviceKnownRegion(radioID: updated.radioID, region: region) + } catch { + logger.error("Failed to remove known region: \(error)") + } + + if wasDefaultFloodScope, let settingsService = services?.settingsService { + do { + _ = try await settingsService.setDefaultFloodScopeVerified(name: nil) + } catch { + logger.error("Failed to clear default flood scope after region removal: \(error)") } + } + + await services?.rxLogService.updateKnownRegions(updated.knownRegions) } + } - /// Clears the connected device's pre-repeat settings after restoration. - public func clearPreRepeatSettings() { - guard let device = connectedDevice else { return } - let updated = device.clearingPreRepeatSettings() - connectedDevice = updated + /// Saves the connected device's current radio settings as pre-repeat settings. + /// Called before enabling repeat mode so settings can be restored later. + func savePreRepeatSettings() { + guard let device = connectedDevice else { return } + let updated = device.savingPreRepeatSettings() + connectedDevice = updated - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist cleared pre-repeat settings: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist pre-repeat settings: \(error)") } } + } - // MARK: - Accessory Management + /// Clears the connected device's pre-repeat settings after restoration. + func clearPreRepeatSettings() { + guard let device = connectedDevice else { return } + let updated = device.clearingPreRepeatSettings() + connectedDevice = updated - /// Checks if a device is registered with the system pairing registry. - /// - Parameter deviceID: The Bluetooth UUID of the device - /// - Returns: `true` if the device is available for connection (always `true` on macOS). - public func hasAccessory(for deviceID: UUID) -> Bool { - pairing.isDeviceConnectable(deviceID) + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist cleared pre-repeat settings: \(error)") } } - - /// Fetches all previously paired devices from storage. - /// Available even when disconnected, for device selection UI. - public func fetchSavedDevices() async throws -> [DeviceDTO] { - logger.info("fetchSavedDevices called, connectionState: \(String(describing: self.connectionState))") - let dataStore = PersistenceStore(modelContainer: modelContainer) - let devices = try await dataStore.fetchDevices() - logger.info("fetchSavedDevices returning \(devices.count) devices") - return devices + } + + // MARK: - Accessory Management + + /// Checks if a device is registered with the system pairing registry. + /// - Parameter deviceID: The Bluetooth UUID of the device + /// - Returns: `true` if the device is available for connection (always `true` on macOS). + func hasAccessory(for deviceID: UUID) -> Bool { + pairing.isDeviceConnectable(deviceID) + } + + /// Fetches all previously paired devices from storage. + /// Available even when disconnected, for device selection UI. + func fetchSavedDevices() async throws -> [DeviceDTO] { + logger.info("fetchSavedDevices called, connectionState: \(String(describing: connectionState))") + let dataStore = PersistenceStore(modelContainer: modelContainer) + let devices = try await dataStore.fetchDevices() + logger.info("fetchSavedDevices returning \(devices.count) devices") + return devices + } + + /// Deletes a previously paired device record from storage. + /// Demotes to ghost record — preserves publicKey ↔ radioID bridge for data recovery on re-pair. + /// - Parameter id: The device UUID to demote + func deleteDevice(id: UUID) async throws { + logger.info("deleteDevice called for device: \(id)") + let dataStore = PersistenceStore(modelContainer: modelContainer) + try await dataStore.demoteDeviceToGhost(id: id) + + // Drop the auto-reconnect / onboarding-resume signal when the deleted row is the one + // this install last connected to, mirroring the sibling forget paths. Without this the + // macOS no-validation connect path grinds the full retry budget toward a device the + // user removed, and `OnboardingState.suggestedStartingPath` still resumes onto it. + if lastConnectedDeviceID == id { + clearPersistedConnection() } - /// Deletes a previously paired device record from storage. - /// Demotes to ghost record — preserves publicKey ↔ radioID bridge for data recovery on re-pair. - /// - Parameter id: The device UUID to demote - public func deleteDevice(id: UUID) async throws { - logger.info("deleteDevice called for device: \(id)") - let dataStore = PersistenceStore(modelContainer: modelContainer) - try await dataStore.demoteDeviceToGhost(id: id) - - // Drop the auto-reconnect / onboarding-resume signal when the deleted row is the one - // this install last connected to, mirroring the sibling forget paths. Without this the - // macOS no-validation connect path grinds the full retry budget toward a device the - // user removed, and `OnboardingState.suggestedStartingPath` still resumes onto it. - if lastConnectedDeviceID == id { - clearPersistedConnection() - } - - logger.info("deleteDevice completed for device: \(id)") + logger.info("deleteDevice completed for device: \(id)") + } + + /// Returns devices registered with the system pairing registry (AccessorySetupKit on iOS). + /// Use as fallback when SwiftData has no device records. Empty on macOS. + var pairedAccessoryInfos: [(id: UUID, name: String)] { + pairing.registeredDeviceInfos() + } + + /// Renames the currently connected device via the system rename UI (AccessorySetupKit on iOS). + /// No-op on macOS, where there is no system rename surface. + /// - Throws: `ConnectionError.notConnected` if no device is connected + func renameCurrentDevice() async throws { + guard let deviceID = connectedDevice?.id else { + throw ConnectionError.notConnected } - /// Returns devices registered with the system pairing registry (AccessorySetupKit on iOS). - /// Use as fallback when SwiftData has no device records. Empty on macOS. - public var pairedAccessoryInfos: [(id: UUID, name: String)] { - pairing.registeredDeviceInfos() + guard pairing.isDeviceConnectable(deviceID) else { + throw ConnectionError.deviceNotFound } - /// Renames the currently connected device via the system rename UI (AccessorySetupKit on iOS). - /// No-op on macOS, where there is no system rename surface. - /// - Throws: `ConnectionError.notConnected` if no device is connected - public func renameCurrentDevice() async throws { - guard let deviceID = connectedDevice?.id else { - throw ConnectionError.notConnected - } - - guard pairing.isDeviceConnectable(deviceID) else { - throw ConnectionError.deviceNotFound - } - - try await pairing.renameDevice(deviceID) - } + try await pairing.renameDevice(deviceID) + } } // MARK: - DevicePairingDelegate extension ConnectionManager: DevicePairingDelegate { - public func devicePairing( - _ service: any DevicePairingService, - didRemoveDeviceWithID bluetoothID: UUID - ) { - logger.info("Device removed from pairing registry: \(bluetoothID)") - - Task { - if connectedDevice?.id == bluetoothID { - await disconnect(reason: .deviceRemovedFromSettings) - } - - // Demote to ghost — preserve publicKey ↔ radioID bridge - let dataStore = PersistenceStore(modelContainer: modelContainer) - do { - try await dataStore.demoteDeviceToGhost(id: bluetoothID) - } catch { - logger.warning("Failed to demote device in SwiftData: \(error.localizedDescription)") - } - } - - // Clear persisted connection if it was this device - if lastConnectedDeviceID == bluetoothID { - clearPersistedConnection() - } + public func devicePairing( + _ service: any DevicePairingService, + didRemoveDeviceWithID bluetoothID: UUID + ) { + logger.info("Device removed from pairing registry: \(bluetoothID)") + + Task { + if connectedDevice?.id == bluetoothID { + await disconnect(reason: .deviceRemovedFromSettings) + } + + // Demote to ghost — preserve publicKey ↔ radioID bridge + let dataStore = PersistenceStore(modelContainer: modelContainer) + do { + try await dataStore.demoteDeviceToGhost(id: bluetoothID) + } catch { + logger.warning("Failed to demote device in SwiftData: \(error.localizedDescription)") + } } - public func devicePairing( - _ service: any DevicePairingService, - didFailPairingForDeviceWithID bluetoothID: UUID - ) { - // Clean up device record so the device can appear in picker again. - // No data cascade — failed pairings have no associated data. - logger.info("Pairing failed for device: \(bluetoothID)") - - Task { - if connectedDevice?.id == bluetoothID { - await disconnect(reason: .pairingFailed) - } - - // Delete device record only — no data exists for a failed pairing - let dataStore = PersistenceStore(modelContainer: modelContainer) - do { - try await dataStore.deleteDevice(id: bluetoothID) - logger.info("Deleted device record after failed pairing") - } catch { - logger.info("No device record to delete: \(error.localizedDescription)") - } - } + // Clear persisted connection if it was this device + if lastConnectedDeviceID == bluetoothID { + clearPersistedConnection() + } + } + + public func devicePairing( + _ service: any DevicePairingService, + didFailPairingForDeviceWithID bluetoothID: UUID + ) { + // Clean up device record so the device can appear in picker again. + // No data cascade — failed pairings have no associated data. + logger.info("Pairing failed for device: \(bluetoothID)") + + Task { + if connectedDevice?.id == bluetoothID { + await disconnect(reason: .pairingFailed) + } + + // Delete device record only — no data exists for a failed pairing + let dataStore = PersistenceStore(modelContainer: modelContainer) + do { + try await dataStore.deleteDevice(id: bluetoothID) + logger.info("Deleted device record after failed pairing") + } catch { + logger.info("No device record to delete: \(error.localizedDescription)") + } + } - // Clear persisted connection if it was this device - if lastConnectedDeviceID == bluetoothID { - clearPersistedConnection() - } + // Clear persisted connection if it was this device + if lastConnectedDeviceID == bluetoothID { + clearPersistedConnection() } + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift index 1acc13fd..e27bafbc 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift @@ -2,388 +2,387 @@ import Foundation import MeshCore extension ConnectionManager { + // MARK: - WiFi Heartbeat - // MARK: - WiFi Heartbeat - - /// Starts periodic heartbeat to detect dead WiFi connections. - /// ESP32's TCP stack doesn't respond to TCP keepalives, so we use application-level probes. - private func startWiFiHeartbeat() { - stopWiFiHeartbeat() - - wifiHeartbeatTask = Task { [weak self] in - while !Task.isCancelled { - do { - try await Task.sleep(for: Self.wifiHeartbeatInterval) - } catch { - break - } - - guard let self, - self.currentTransportType == .wifi, - self.connectionState.isOperational, - let session = self.session else { break } - - if self.shouldPauseWiFiHeartbeatProbe { - self.logger.info("WiFi heartbeat skipped while sync activity is active") - continue - } - - // Probe connection with lightweight command - do { - _ = try await session.getTime() - } catch { - if self.shouldPauseWiFiHeartbeatProbe { - self.logger.info("WiFi heartbeat timeout during sync activity treated as inconclusive") - continue - } - self.logger.warning("WiFi heartbeat failed: \(error.localizedDescription)") - await self.handleWiFiDisconnection(error: error) - break - } - } + /// Starts periodic heartbeat to detect dead WiFi connections. + /// ESP32's TCP stack doesn't respond to TCP keepalives, so we use application-level probes. + private func startWiFiHeartbeat() { + stopWiFiHeartbeat() + + wifiHeartbeatTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: Self.wifiHeartbeatInterval) + } catch { + break } - } - var shouldPauseWiFiHeartbeatProbe: Bool { - connectionState == .syncing || resyncTask != nil || channelRetryTask != nil - } + guard let self, + currentTransportType == .wifi, + connectionState.isOperational, + let session else { break } - /// Stops the WiFi heartbeat loop - func stopWiFiHeartbeat() { - wifiHeartbeatTask?.cancel() - wifiHeartbeatTask = nil - } + if shouldPauseWiFiHeartbeatProbe { + logger.info("WiFi heartbeat skipped while sync activity is active") + continue + } - // MARK: - WiFi Disconnection Handling + // Probe connection with lightweight command + do { + _ = try await session.getTime() + } catch { + if shouldPauseWiFiHeartbeatProbe { + logger.info("WiFi heartbeat timeout during sync activity treated as inconclusive") + continue + } + logger.warning("WiFi heartbeat failed: \(error.localizedDescription)") + await handleWiFiDisconnection(error: error) + break + } + } + } + } + + var shouldPauseWiFiHeartbeatProbe: Bool { + connectionState == .syncing || resyncTask != nil || channelRetryTask != nil + } + + /// Stops the WiFi heartbeat loop + func stopWiFiHeartbeat() { + wifiHeartbeatTask?.cancel() + wifiHeartbeatTask = nil + } + + // MARK: - WiFi Disconnection Handling + + /// Handles unexpected WiFi connection loss + private func handleWiFiDisconnection(error: Error?) async { + // User-initiated disconnect - don't reconnect + guard connectionIntent.wantsConnection else { return } + + // Only handle WiFi disconnections + guard currentTransportType == .wifi else { return } + + // Prevent re-entrant calls: multiple disconnection callbacks can fire + // simultaneously from the transport handler and heartbeat. The flag + // covers the window between entry and startWiFiReconnection() where + // await suspension points could allow interleaving on @MainActor. + guard !isHandlingWiFiDisconnection, wifiReconnectTask == nil else { + logger.info("WiFi disconnection already being handled, ignoring duplicate") + return + } + isHandlingWiFiDisconnection = true + defer { isHandlingWiFiDisconnection = false } + + logger.warning("WiFi connection lost: \(error?.localizedDescription ?? "unknown")") + + // Stop heartbeat before teardown + stopWiFiHeartbeat() + + cancelResyncLoop() + cancelChannelRetry() + + // Capture and clear synchronously, mirroring teardownSessionForReconnect: + // a reconnect can install a new container during the awaits below, and + // re-reading self.services would tear that new container down. + let oldServices = services + services = nil + session = nil + + if let oldServices { + // Mark room sessions disconnected before tearing down services + _ = await oldServices.remoteNodeService.handleBLEDisconnection() + // Reset sync state before destroying services to prevent stuck "Syncing" pill + await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + await oldServices.tearDown() + } - /// Handles unexpected WiFi connection loss - private func handleWiFiDisconnection(error: Error?) async { - // User-initiated disconnect - don't reconnect - guard connectionIntent.wantsConnection else { return } + // Show connecting state (pulsing indicator) + logger.info("[WiFi] State → .connecting (WiFi disconnection, starting reconnection)") + connectionState = .connecting - // Only handle WiFi disconnections - guard currentTransportType == .wifi else { return } + // Start reconnection attempts + startWiFiReconnection() + } - // Prevent re-entrant calls: multiple disconnection callbacks can fire - // simultaneously from the transport handler and heartbeat. The flag - // covers the window between entry and startWiFiReconnection() where - // await suspension points could allow interleaving on @MainActor. - guard !isHandlingWiFiDisconnection, wifiReconnectTask == nil else { - logger.info("WiFi disconnection already being handled, ignoring duplicate") - return - } - isHandlingWiFiDisconnection = true - defer { isHandlingWiFiDisconnection = false } - - logger.warning("WiFi connection lost: \(error?.localizedDescription ?? "unknown")") - - // Stop heartbeat before teardown - stopWiFiHeartbeat() - - cancelResyncLoop() - cancelChannelRetry() - - // Capture and clear synchronously, mirroring teardownSessionForReconnect: - // a reconnect can install a new container during the awaits below, and - // re-reading self.services would tear that new container down. - let oldServices = services - services = nil - session = nil - - if let oldServices { - // Mark room sessions disconnected before tearing down services - _ = await oldServices.remoteNodeService.handleBLEDisconnection() - // Reset sync state before destroying services to prevent stuck "Syncing" pill - await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) - await oldServices.tearDown() - } + // MARK: - WiFi Reconnection - // Show connecting state (pulsing indicator) - logger.info("[WiFi] State → .connecting (WiFi disconnection, starting reconnection)") - connectionState = .connecting + /// Starts the WiFi reconnection retry loop + private func startWiFiReconnection() { + // If a reconnect task is already running, don't start another + if wifiReconnectTask != nil { + logger.info("WiFi reconnection already in progress, skipping") + return + } - // Start reconnection attempts - startWiFiReconnection() + // Rate limiting: prevent rapid reconnection attempts + if let lastStart = lastWiFiReconnectStartTime, + Date().timeIntervalSince(lastStart) < Self.wifiReconnectCooldown { + logger.warning("Suppressing WiFi reconnection: too soon after last attempt") + Task { await cleanupConnection() } + return } + lastWiFiReconnectStartTime = Date() + + wifiReconnectAttempt = 0 + wifiReconnectTask?.cancel() + + wifiReconnectTask = Task { + // Only self-nil when not cancelled: startWiFiReconnection cancels the + // old task before assigning a new one, and an unconditional defer in + // the cancelled old task would destroy the replacement's handle (the + // same pattern resyncTask and channelRetryTask document). + defer { + if !Task.isCancelled { + wifiReconnectTask = nil + wifiReconnectAttempt = 0 + } + } - // MARK: - WiFi Reconnection + let startTime = ContinuousClock.now - /// Starts the WiFi reconnection retry loop - private func startWiFiReconnection() { - // If a reconnect task is already running, don't start another - if wifiReconnectTask != nil { - logger.info("WiFi reconnection already in progress, skipping") - return + while !Task.isCancelled, connectionIntent.wantsConnection { + // Check if we've exceeded 30 second window + let elapsed = ContinuousClock.now - startTime + if elapsed > Self.wifiMaxReconnectDuration { + logger.info("WiFi reconnection timeout after 30s") + await cleanupConnection() + return } - // Rate limiting: prevent rapid reconnection attempts - if let lastStart = lastWiFiReconnectStartTime, - Date().timeIntervalSince(lastStart) < Self.wifiReconnectCooldown { - logger.warning("Suppressing WiFi reconnection: too soon after last attempt") - Task { await cleanupConnection() } - return - } - lastWiFiReconnectStartTime = Date() - - wifiReconnectAttempt = 0 - wifiReconnectTask?.cancel() - - wifiReconnectTask = Task { - // Only self-nil when not cancelled: startWiFiReconnection cancels the - // old task before assigning a new one, and an unconditional defer in - // the cancelled old task would destroy the replacement's handle (the - // same pattern resyncTask and channelRetryTask document). - defer { - if !Task.isCancelled { - wifiReconnectTask = nil - wifiReconnectAttempt = 0 - } - } - - let startTime = ContinuousClock.now - - while !Task.isCancelled && connectionIntent.wantsConnection { - // Check if we've exceeded 30 second window - let elapsed = ContinuousClock.now - startTime - if elapsed > Self.wifiMaxReconnectDuration { - logger.info("WiFi reconnection timeout after 30s") - await cleanupConnection() - return - } - - wifiReconnectAttempt += 1 - logger.info("WiFi reconnect attempt \(self.wifiReconnectAttempt)") - - do { - try await reconnectWiFi() - logger.info("WiFi reconnection succeeded") - return - } catch { - logger.warning("WiFi reconnect failed: \(error.localizedDescription)") - } - - // Exponential backoff: 0.5s, 1s, 2s, 4s (capped) - let delay = min(0.5 * pow(2.0, Double(wifiReconnectAttempt - 1)), 4.0) - try? await Task.sleep(for: .seconds(delay)) - } - } - } + wifiReconnectAttempt += 1 + logger.info("WiFi reconnect attempt \(self.wifiReconnectAttempt)") - /// Attempts to reconnect to the WiFi device using stored connection info - private func reconnectWiFi() async throws { - guard let wifiTransport, - let (host, port) = await wifiTransport.connectionInfo else { - throw ConnectionError.connectionFailed("No WiFi connection info") + do { + try await reconnectWiFi() + logger.info("WiFi reconnection succeeded") + return + } catch { + logger.warning("WiFi reconnect failed: \(error.localizedDescription)") } - // Stop any existing session to prevent receive loops racing for transport data - await session?.stop() - session = nil + // Exponential backoff: 0.5s, 1s, 2s, 4s (capped) + let delay = min(0.5 * pow(2.0, Double(wifiReconnectAttempt - 1)), 4.0) + try? await Task.sleep(for: .seconds(delay)) + } + } + } + + /// Attempts to reconnect to the WiFi device using stored connection info + private func reconnectWiFi() async throws { + guard let wifiTransport, + let (host, port) = await wifiTransport.connectionInfo else { + throw ConnectionError.connectionFailed("No WiFi connection info") + } - // Disconnect old transport cleanly - await wifiTransport.disconnect() + // Stop any existing session to prevent receive loops racing for transport data + await session?.stop() + session = nil - // Create fresh transport with same connection info - let newTransport = WiFiTransport() - await newTransport.setConnectionInfo(host: host, port: port) - self.wifiTransport = newTransport + // Disconnect old transport cleanly + await wifiTransport.disconnect() - // Connect - try await newTransport.connect() - connectionState = .connected + // Create fresh transport with same connection info + let newTransport = WiFiTransport() + await newTransport.setConnectionInfo(host: host, port: port) + self.wifiTransport = newTransport - // Re-establish session - let newSession = MeshCoreSession(transport: newTransport) - self.session = newSession - try await newSession.start() + // Connect + try await newTransport.connect() + connectionState = .connected - guard let selfInfo = await newSession.currentSelfInfo else { - throw ConnectionError.initializationFailed("No self info") - } + // Re-establish session + let newSession = MeshCoreSession(transport: newTransport) + session = newSession + try await newSession.start() - let deviceID = DeviceIdentity.deriveUUID(from: selfInfo.publicKey) - try await completeWiFiReconnection( - session: newSession, - transport: newTransport, - deviceID: deviceID - ) + guard let selfInfo = await newSession.currentSelfInfo else { + throw ConnectionError.initializationFailed("No self info") } - /// Completes WiFi reconnection by re-establishing services - private func completeWiFiReconnection( - session: MeshCoreSession, - transport: WiFiTransport, - deviceID: UUID - ) async throws { - let capabilities = try await session.queryDevice() - detectAndStorePlatform(model: capabilities.model, transportType: .wifi) - guard let selfInfo = await session.currentSelfInfo else { - throw ConnectionError.initializationFailed("No self info") - } + let deviceID = DeviceIdentity.deriveUUID(from: selfInfo.publicKey) + try await completeWiFiReconnection( + session: newSession, + transport: newTransport, + deviceID: deviceID + ) + } + + /// Completes WiFi reconnection by re-establishing services + private func completeWiFiReconnection( + session: MeshCoreSession, + transport: WiFiTransport, + deviceID: UUID + ) async throws { + let capabilities = try await session.queryDevice() + detectAndStorePlatform(model: capabilities.model, transportType: .wifi) + guard let selfInfo = await session.currentSelfInfo else { + throw ConnectionError.initializationFailed("No self info") + } - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: session, - selfInfo: selfInfo, - capabilities: capabilities - ) - - // Wire disconnection handler on new transport - await transport.setDisconnectionHandler { [weak self] error in - Task { @MainActor in - await self?.handleWiFiDisconnection(error: error) - } - } + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: session, + selfInfo: selfInfo, + capabilities: capabilities + ) + + // Wire disconnection handler on new transport + await transport.setDisconnectionHandler { [weak self] error in + Task { @MainActor in + await self?.handleWiFiDisconnection(error: error) + } + } - await onConnectionReady?() - // onConnectionReady can suspend; a reentrant main-actor WiFi disconnect may - // clear connectedDevice or replace services during that await. Recheck so an - // aborted reconnect bails here instead of syncing a torn-down session. - guard connectionIntent.wantsConnection, - self.services === newServices, - connectedDevice != nil - else { - logger.info("[WiFi] reconnect aborted after onConnectionReady: connection state changed") - await session.stop() - await newServices.tearDown() - services = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - return - } - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, transportType: .wifi, context: "WiFi reconnect") + await onConnectionReady?() + // onConnectionReady can suspend; a reentrant main-actor WiFi disconnect may + // clear connectedDevice or replace services during that await. Recheck so an + // aborted reconnect bails here instead of syncing a torn-down session. + guard connectionIntent.wantsConnection, + services === newServices, + connectedDevice != nil + else { + logger.info("[WiFi] reconnect aborted after onConnectionReady: connection state changed") + await session.stop() + await newServices.tearDown() + services = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + return + } + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, transportType: .wifi, context: "WiFi reconnect") - guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .wifi) else { return } + guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .wifi) else { return } - stopReconnectionWatchdog() - if connectionState == .ready { - startWiFiHeartbeat() - } + stopReconnectionWatchdog() + if connectionState == .ready { + startWiFiHeartbeat() } + } - // MARK: - WiFi Connection Health + // MARK: - WiFi Connection Health - /// Checks if the WiFi connection is still alive (call on app foreground) - public func checkWiFiConnectionHealth() async { - // If a reconnect task is already running, let it finish - if wifiReconnectTask != nil { - logger.info("WiFi reconnection already in progress on foreground") - return - } + /// Checks if the WiFi connection is still alive (call on app foreground) + public func checkWiFiConnectionHealth() async { + // If a reconnect task is already running, let it finish + if wifiReconnectTask != nil { + logger.info("WiFi reconnection already in progress on foreground") + return + } - // Case 1: We think we're connected but the transport died while backgrounded - if currentTransportType == .wifi, - connectionState.isOperational, - let wifiTransport { - let isConnected = await wifiTransport.isConnected - if !isConnected { - logger.info("WiFi connection died while backgrounded") - await handleWiFiDisconnection(error: nil) - return - } - } + // Case 1: We think we're connected but the transport died while backgrounded + if currentTransportType == .wifi, + connectionState.isOperational, + let wifiTransport { + let isConnected = await wifiTransport.isConnected + if !isConnected { + logger.info("WiFi connection died while backgrounded") + await handleWiFiDisconnection(error: nil) + return + } + } - // Case 2: Connection was lost and cleanup already ran while backgrounded, - // but user still wants to be connected — attempt fresh reconnection - if connectionState == .disconnected, - connectionIntent.wantsConnection, - let lastDeviceID = lastConnectedDeviceID { - let dataStore = PersistenceStore(modelContainer: modelContainer) - if let device = try? await dataStore.fetchDevice(id: lastDeviceID), - let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { - if case .wifi(let host, let port, _) = wifiMethod { - logger.info("WiFi foreground reconnect to \(host):\(port)") - do { - try await connectViaWiFi(host: host, port: port) - } catch { - logger.warning("WiFi foreground reconnect failed: \(error.localizedDescription)") - } - } - } + // Case 2: Connection was lost and cleanup already ran while backgrounded, + // but user still wants to be connected — attempt fresh reconnection + if connectionState == .disconnected, + connectionIntent.wantsConnection, + let lastDeviceID = lastConnectedDeviceID { + let dataStore = PersistenceStore(modelContainer: modelContainer) + if let device = try? await dataStore.fetchDevice(id: lastDeviceID), + let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { + if case let .wifi(host, port, _) = wifiMethod { + logger.info("WiFi foreground reconnect to \(host):\(port)") + do { + try await connectViaWiFi(host: host, port: port) + } catch { + logger.warning("WiFi foreground reconnect failed: \(error.localizedDescription)") + } } + } + } + } + + // MARK: - WiFi Connection + + /// Connects to a device via WiFi/TCP. + /// + /// - Parameters: + /// - host: The hostname or IP address of the device + /// - port: The TCP port to connect to + /// - forceFullSync: When true, performs a complete sync ignoring cached timestamps + /// - Throws: Connection or session errors + public func connectViaWiFi(host: String, port: UInt16, forceFullSync: Bool = false) async throws { + logger.info("Connecting via WiFi to \(host):\(port)") + + // Disconnect existing connection if any + if connectionState != .disconnected { + await disconnect(reason: .wifiReconnectPrep) } - // MARK: - WiFi Connection - - /// Connects to a device via WiFi/TCP. - /// - /// - Parameters: - /// - host: The hostname or IP address of the device - /// - port: The TCP port to connect to - /// - forceFullSync: When true, performs a complete sync ignoring cached timestamps - /// - Throws: Connection or session errors - public func connectViaWiFi(host: String, port: UInt16, forceFullSync: Bool = false) async throws { - logger.info("Connecting via WiFi to \(host):\(port)") - - // Disconnect existing connection if any - if connectionState != .disconnected { - await disconnect(reason: .wifiReconnectPrep) - } + connectionIntent = .wantsConnection() + persistIntent() + connectionState = .connecting - connectionIntent = .wantsConnection() - persistIntent() - connectionState = .connecting + do { + // Create and configure WiFi transport + let newWiFiTransport = WiFiTransport() + await newWiFiTransport.setConnectionInfo(host: host, port: port) + wifiTransport = newWiFiTransport - do { - // Create and configure WiFi transport - let newWiFiTransport = WiFiTransport() - await newWiFiTransport.setConnectionInfo(host: host, port: port) - wifiTransport = newWiFiTransport + // Connect the transport + try await newWiFiTransport.connect() - // Connect the transport - try await newWiFiTransport.connect() + connectionState = .connected - connectionState = .connected + // Create session (same as BLE) + let newSession = MeshCoreSession(transport: newWiFiTransport) + session = newSession - // Create session (same as BLE) - let newSession = MeshCoreSession(transport: newWiFiTransport) - self.session = newSession + let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) + detectAndStorePlatform(model: deviceCapabilities.model, transportType: .wifi) - let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) - detectAndStorePlatform(model: deviceCapabilities.model, transportType: .wifi) + // Derive device ID from public key (WiFi devices don't have Bluetooth UUIDs) + let deviceID = DeviceIdentity.deriveUUID(from: meshCoreSelfInfo.publicKey) - // Derive device ID from public key (WiFi devices don't have Bluetooth UUIDs) - let deviceID = DeviceIdentity.deriveUUID(from: meshCoreSelfInfo.publicKey) + let wifiMethod = ConnectionMethod.wifi(host: host, port: port, displayName: nil) + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: newSession, + selfInfo: meshCoreSelfInfo, + capabilities: deviceCapabilities, + connectionMethods: [wifiMethod] + ) - let wifiMethod = ConnectionMethod.wifi(host: host, port: port, displayName: nil) - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: newSession, - selfInfo: meshCoreSelfInfo, - capabilities: deviceCapabilities, - connectionMethods: [wifiMethod] - ) + // Persist connection for potential future use + persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) - // Persist connection for potential future use - persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) + await onConnectionReady?() + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, transportType: .wifi, forceFullSync: forceFullSync) - await onConnectionReady?() - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, transportType: .wifi, forceFullSync: forceFullSync) - - // Wire disconnection handler before promotion — needed even if promotion fails - await newWiFiTransport.setDisconnectionHandler { [weak self] error in - Task { @MainActor in - await self?.handleWiFiDisconnection(error: error) - } - } + // Wire disconnection handler before promotion — needed even if promotion fails + await newWiFiTransport.setDisconnectionHandler { [weak self] error in + Task { @MainActor in + await self?.handleWiFiDisconnection(error: error) + } + } - guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .wifi) else { return } + guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .wifi) else { return } - stopReconnectionWatchdog() - if connectionState == .ready { - startWiFiHeartbeat() - } - logger.info("WiFi connection complete - device ready") + stopReconnectionWatchdog() + if connectionState == .ready { + startWiFiHeartbeat() + } + logger.info("WiFi connection complete - device ready") - } catch { - // Cleanup on failure - if let wifiTransport { - await wifiTransport.disconnect() - self.wifiTransport = nil - } - currentTransportType = nil - await cleanupConnection() - throw error - } + } catch { + // Cleanup on failure + if let wifiTransport { + await wifiTransport.disconnect() + self.wifiTransport = nil + } + currentTransportType = nil + await cleanupConnection() + throw error } + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift index f20fbd34..ce65f9c4 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift @@ -1,8 +1,8 @@ @preconcurrency import CoreBluetooth import Foundation -import SwiftData import MeshCore import OSLog +import SwiftData /// Manages the connection lifecycle for mesh devices. /// @@ -44,323 +44,372 @@ import OSLog @Observable @MainActor public final class ConnectionManager { - - // MARK: - Logging - - let logger = PersistentLogger(subsystem: "com.mc1", category: "ConnectionManager") - - // MARK: - Observable State - - /// Broadcasts every `connectionState` change. Per-connection consumers - /// (`ServiceContainer`) subscribe at construction and end their - /// subscription by task cancellation in `tearDown()`. `ConnectionManager` - /// never calls `finish()`: it lives for the app lifetime, and the next - /// connection's container subscribes to this same broadcaster. - let connectionStateEvents = EventBroadcaster() - - /// Current connection state - public internal(set) var connectionState: DeviceConnectionState = .disconnected { - didSet { - connectionStateEvents.yield(connectionState) - #if DEBUG - assertStateInvariants() - #endif - } + // MARK: - Logging + + let logger = PersistentLogger(subsystem: "com.mc1", category: "ConnectionManager") + + // MARK: - Observable State + + /// Broadcasts every `connectionState` change. Per-connection consumers + /// (`ServiceContainer`) subscribe at construction and end their + /// subscription by task cancellation in `tearDown()`. `ConnectionManager` + /// never calls `finish()`: it lives for the app lifetime, and the next + /// connection's container subscribes to this same broadcaster. + let connectionStateEvents = EventBroadcaster() + + /// Current connection state + public internal(set) var connectionState: DeviceConnectionState = .disconnected { + didSet { + connectionStateEvents.yield(connectionState) + #if DEBUG + assertStateInvariants() + #endif } - - /// Connected device info (nil when disconnected) - public internal(set) var connectedDevice: DeviceDTO? - - /// Allowed repeat frequency ranges from connected device (empty when disconnected or unsupported) - public var allowedRepeatFreqRanges: [MeshCore.FrequencyRange] = [] - - /// Services container (nil when disconnected) - public internal(set) var services: ServiceContainer? - - /// Current transport type (bluetooth or wifi) - public internal(set) var currentTransportType: TransportType? - - /// Current user-actionable Bluetooth availability, updated as `CBCentralManager` reports state - /// changes. The macOS scan picker reads this to swap its scanning state for a remedy when - /// Bluetooth is off or unauthorized; iOS surfaces the same conditions through its system picker. - public internal(set) var bluetoothAvailability: BluetoothAvailability = .ready - - /// Detected device platform, used for sync throttling config. - /// Survives reconnects (lives on ConnectionManager, not ServiceContainer). - private(set) var detectedPlatform: DevicePlatform = .unknown - - /// Records the last fully-clean channel sync, keyed by device. - /// Only set when channel sync completes with zero errors (including retries). - /// Survives transient disconnects; cleared on explicit disconnect or device change. - var lastCleanChannelSync: (radioID: UUID, completedAt: Date)? - - /// Records the last attempted channel sync, including partial/failed attempts. - /// Used to cool down immediate channel-only retry loops. - var lastAttemptedChannelSync: (radioID: UUID, attemptedAt: Date)? - - /// The user's connection intent. Replaces shouldBeConnected, userExplicitlyDisconnected, and pendingForceFullSync. - var connectionIntent: ConnectionIntent = .none - - /// The device being actively connected via connect(to:). - /// Nil during auto-reconnect (tracked by reconnectionCoordinator.reconnectingDeviceID instead). - var connectingDeviceID: UUID? - - /// The device whose MeshCore session is currently being rebuilt after a BLE auto-reconnect. - /// Used to suppress duplicate reconnect attempts while session startup is still in flight. - var sessionRebuildDeviceID: UUID? - - /// True for the duration of pairNewDevice() — suppresses opportunistic - /// reconnect paths so they can't race the pairing's `connect(to:)` for the - /// BLE state machine's single in-flight connect slot. - var isPairingInProgress = false - - /// Single source of truth for "stand down, an explicit connect flow is running." - /// Opportunistic reconnect call sites consult this; or new conditions in here - /// when the next contention class shows up, so every site picks them up. - var shouldDeferOpportunisticReconnect: Bool { - isPairingInProgress + } + + /// Connected device info (nil when disconnected) + public internal(set) var connectedDevice: DeviceDTO? + + /// Allowed repeat frequency ranges from connected device (empty when disconnected or unsupported) + public var allowedRepeatFreqRanges: [MeshCore.FrequencyRange] = [] + + /// Services container (nil when disconnected) + public internal(set) var services: ServiceContainer? + + /// Current transport type (bluetooth or wifi) + public internal(set) var currentTransportType: TransportType? + + /// Current user-actionable Bluetooth availability, updated as `CBCentralManager` reports state + /// changes. The macOS scan picker reads this to swap its scanning state for a remedy when + /// Bluetooth is off or unauthorized; iOS surfaces the same conditions through its system picker. + public internal(set) var bluetoothAvailability: BluetoothAvailability = .ready + + /// Detected device platform, used for sync throttling config. + /// Survives reconnects (lives on ConnectionManager, not ServiceContainer). + private(set) var detectedPlatform: DevicePlatform = .unknown + + /// Records the last fully-clean channel sync, keyed by device. + /// Only set when channel sync completes with zero errors (including retries). + /// Survives transient disconnects; cleared on explicit disconnect or device change. + var lastCleanChannelSync: (radioID: UUID, completedAt: Date)? + + /// Records the last attempted channel sync, including partial/failed attempts. + /// Used to cool down immediate channel-only retry loops. + var lastAttemptedChannelSync: (radioID: UUID, attemptedAt: Date)? + + /// The user's connection intent. Replaces shouldBeConnected, userExplicitlyDisconnected, and pendingForceFullSync. + var connectionIntent: ConnectionIntent = .none + + /// The device being actively connected via connect(to:). + /// Nil during auto-reconnect (tracked by reconnectionCoordinator.reconnectingDeviceID instead). + var connectingDeviceID: UUID? + + /// The device whose MeshCore session is currently being rebuilt after a BLE auto-reconnect. + /// Used to suppress duplicate reconnect attempts while session startup is still in flight. + var sessionRebuildDeviceID: UUID? + + /// Device whose pairing-failure recovery has already been surfaced this failure + /// episode. While the bond stays invalid every watchdog retry fails the same way, + /// so without this latch the recovery alert would re-interrupt after each retry. + /// Cleared on ready promotion and on explicit disconnect, opening a new episode. + var surfacedAuthFailureDeviceID: UUID? + + /// True for the duration of pairNewDevice() — suppresses opportunistic + /// reconnect paths so they can't race the pairing's `connect(to:)` for the + /// BLE state machine's single in-flight connect slot. + var isPairingInProgress = false + + /// Single source of truth for "stand down, an explicit connect flow is running." + /// Opportunistic reconnect call sites consult this; or new conditions in here + /// when the next contention class shows up, so every site picks them up. + var shouldDeferOpportunisticReconnect: Bool { + isPairingInProgress + } + + /// Single chokepoint for opportunistic reconnect attempts. Consults the defer + /// predicate and dispatches to `connect(to:)`. New opportunistic-reconnect + /// sites must route through this helper rather than duplicating the gate logic. + /// The auto-reconnect-handler closure (`setAutoReconnectingHandler`) operates + /// on `.autoReconnecting` phases rather than initiating a `.connecting`, so it + /// applies the same gate predicate at its own call site instead. + /// - Parameters: + /// - deviceID: device to reconnect to. The caller is responsible for + /// vetting that the user wants this device connected before invoking. + /// - reason: short string for log correlation across call sites. + func attemptOpportunisticReconnect(deviceID: UUID, reason: String) async { + guard !shouldDeferOpportunisticReconnect else { + logger.info("[BLE] Opportunistic reconnect skipped (\(reason)): pairing in progress") + return } - - /// Single chokepoint for opportunistic reconnect attempts. Consults the defer - /// predicate and dispatches to `connect(to:)`. New opportunistic-reconnect - /// sites must route through this helper rather than duplicating the gate logic. - /// The auto-reconnect-handler closure (`setAutoReconnectingHandler`) operates - /// on `.autoReconnecting` phases rather than initiating a `.connecting`, so it - /// applies the same gate predicate at its own call site instead. - /// - Parameters: - /// - deviceID: device to reconnect to. The caller is responsible for - /// vetting that the user wants this device connected before invoking. - /// - reason: short string for log correlation across call sites. - func attemptOpportunisticReconnect(deviceID: UUID, reason: String) async { - guard !shouldDeferOpportunisticReconnect else { - logger.info("[BLE] Opportunistic reconnect skipped (\(reason)): pairing in progress") - return - } - do { - try await connect(to: deviceID) - } catch { - logger.warning("[BLE] Opportunistic reconnect (\(reason)) failed: \(error.localizedDescription)") - } - } - - // MARK: - Callbacks - - /// Called when connection is ready and services are available. - /// Use this to wire up UI observation of services. - /// Installed by `AppState` during initialization. - public var onConnectionReady: (() async -> Void)? - - /// Called when connection is lost (disconnection, BLE power off, etc). - /// Use this to update UI state when services become unavailable. - /// Installed by `AppState` during initialization. - public var onConnectionLost: (() async -> Void)? - - /// Called after initial sync completes and connectionState becomes `.ready`. - /// Use this for work that depends on up-to-date synced data (e.g. stale node cleanup). - /// Installed by `AppState` during initialization. - public var onDeviceSynced: (() async -> Void)? - - /// Provider for app foreground/background state detection. - /// Installed by `AppState` during initialization. - public var appStateProvider: AppStateProvider? - - /// Number of devices registered with the system pairing registry (for troubleshooting UI). - /// iOS reports AccessorySetupKit accessories; macOS reports 0 (no system registry). - public var pairedAccessoriesCount: Int { - pairing.registeredDeviceCount + do { + try await connect(to: deviceID) + } catch { + logger.warning("[BLE] Opportunistic reconnect (\(reason)) failed: \(error.localizedDescription)") + // An invalidated bond can't be resolved by retrying in the background; + // route it to the same guided pairing-failure recovery a fresh connect + // attempt would show, rather than leaving the user on a silent watchdog loop. + if case BLEError.authenticationFailed = error { + surfaceAuthenticationFailure(deviceID: deviceID) + } } - - /// Whether the connected device can be renamed through a system rename surface. - /// `true` on iOS (AccessorySetupKit rename sheet); `false` on macOS, where the UI must - /// hide the rename action rather than offer a control that silently does nothing. - public var supportsDeviceRename: Bool { - pairing.supportsSystemRename + } + + /// Fires `onAuthenticationFailure` at most once per failure episode, tracked by + /// `surfacedAuthFailureDeviceID`. All auth-failure surfacing routes through here + /// so the latch covers every producer path. + func surfaceAuthenticationFailure(deviceID: UUID) { + guard surfacedAuthFailureDeviceID != deviceID else { + logger.info("[BLE] Pairing-failure recovery already surfaced for \(deviceID.uuidString.prefix(8)), suppressing repeat") + return } + surfacedAuthFailureDeviceID = deviceID + onAuthenticationFailure?(deviceID) + } + + /// Clears the auth-failure surfacing latch so the next failure episode for the + /// same device re-presents the guided recovery. + public func clearSurfacedAuthenticationFailure() { + surfacedAuthFailureDeviceID = nil + } + + // MARK: - Callbacks + + /// Called when connection is ready and services are available. + /// Use this to wire up UI observation of services. + /// Installed by `AppState` during initialization. + public var onConnectionReady: (() async -> Void)? + + /// Called when connection is lost (disconnection, BLE power off, etc). + /// Use this to update UI state when services become unavailable. + /// Installed by `AppState` during initialization. + public var onConnectionLost: (() async -> Void)? + + /// Called when a live link drops and iOS auto-reconnect begins; + /// `connectionState` stays `.connecting` and `onConnectionLost` fires only if + /// the reconnect is later lost or given up. Fires after the reconnect cycle is + /// claimed and before session teardown, inside the CoreBluetooth disconnect + /// wake window, so the Live Activity reflects the loss ahead of the heavy + /// teardown work and before the app suspends. + /// Installed by `AppState` during initialization. + public var onAutoReconnectStarted: (() async -> Void)? + + /// Called when a background reconnect attempt fails because the peer + /// invalidated its bond. Use this to present guided pairing-failure recovery, + /// since the watchdog will otherwise keep retrying a bond that can't heal itself. + /// Fires at most once per failure episode (see `surfacedAuthFailureDeviceID`). + /// Installed by `AppState` during initialization. + public var onAuthenticationFailure: ((UUID) -> Void)? + + /// Called after initial sync completes and connectionState becomes `.ready`. + /// Use this for work that depends on up-to-date synced data (e.g. stale node cleanup). + /// Installed by `AppState` during initialization. + public var onDeviceSynced: (() async -> Void)? + + /// Provider for app foreground/background state detection. + /// Installed by `AppState` during initialization. + public var appStateProvider: AppStateProvider? + + /// Number of devices registered with the system pairing registry (for troubleshooting UI). + /// iOS reports AccessorySetupKit accessories; macOS reports 0 (no system registry). + public var pairedAccessoriesCount: Int { + pairing.registeredDeviceCount + } + + /// Whether the connected device can be renamed through a system rename surface. + /// `true` on iOS (AccessorySetupKit rename sheet); `false` on macOS, where the UI must + /// hide the rename action rather than offer a control that silently does nothing. + public var supportsDeviceRename: Bool { + pairing.supportsSystemRename + } + + /// Whether this platform has an app-visible system pairing registry (AccessorySetupKit). + /// `true` on iOS; `false` on macOS "Designed for iPad". The device picker uses this to + /// decide whether registry membership or the stored connection method is the reachability + /// signal, and the connect path uses it to bound a user-initiated connect's retry budget. + public var hasSystemPairingRegistry: Bool { + pairing.hasSystemPairingRegistry + } + + /// Creates a standalone persistence store for operations that don't require services + public func createStandalonePersistenceStore() -> PersistenceStore { + PersistenceStore(modelContainer: modelContainer) + } + + // MARK: - Internal Components + + let modelContainer: ModelContainer + private let defaults: UserDefaults + private let lastConnectionStore: LastConnectionStore + let transport: any iOSMeshTransport + var wifiTransport: WiFiTransport? + var session: MeshCoreSession? + /// Device discovery + system pairing-registry seam. Resolves to AccessorySetupKit on + /// iOS, or an in-app CoreBluetooth scan picker on macOS "Designed for iPad". + let pairing: any DevicePairingService + + /// Non-nil only on macOS, where the scan picker UI must be presented by the view layer. + /// nil on iOS, where AccessorySetupKit presents its own system picker. + public var bluetoothScanPicker: BluetoothScanPairingService? { + pairing as? BluetoothScanPairingService + } + + /// Shared BLE state machine to manage connection lifecycle. + /// This prevents state restoration race conditions that cause "API MISUSE" errors. + let stateMachine: any BLEStateMachineProtocol + + /// Coordinates iOS auto-reconnect lifecycle (timeouts, teardown, rebuild). + let reconnectionCoordinator = BLEReconnectionCoordinator() + + // MARK: - WiFi Reconnection + + /// Task handling WiFi reconnection attempts + var wifiReconnectTask: Task? + + /// Current reconnection attempt number + var wifiReconnectAttempt = 0 + + /// Maximum duration for WiFi reconnection attempts (30 seconds) + static let wifiMaxReconnectDuration: Duration = .seconds(30) + + /// Last reconnection start time (for rate limiting rapid disconnects) + var lastWiFiReconnectStartTime: Date? + + /// Minimum interval between reconnection attempts (prevents flapping) + static let wifiReconnectCooldown: TimeInterval = 35 + + // MARK: - WiFi Heartbeat + + /// Task for periodic WiFi connection health checks + var wifiHeartbeatTask: Task? + + /// Interval between WiFi heartbeat probes (seconds) + static let wifiHeartbeatInterval: Duration = .seconds(30) + + /// Task coordinating BLE scan startup to avoid start/stop races with stream termination. + var bleScanTask: Task? + + /// Monotonic token used to invalidate stale BLE scan requests. + var bleScanRequestID: UInt64 = 0 + + // MARK: - Resync State + + // Stored state for the sync retry loops; the loops themselves live in the + // SyncRetry extension, which cannot add instance storage. + + /// Current resync attempt count (reset on success or disconnect) + var resyncAttemptCount = 0 + + /// Task managing the resync retry loop + var resyncTask: Task? + + /// Task managing delayed channel-only retry after a partial channel phase. + var channelRetryTask: Task? + + /// Callback when resync fails after all attempts (triggers "Sync Failed" pill) + /// Note: @Sendable @MainActor ensures safe cross-isolation callback + /// Installed by `ConnectionUIState.wireCallbacks`. + public var onResyncFailed: (@Sendable @MainActor () -> Void)? + + // MARK: - Circuit Breaker + + /// Prevents rapid reconnection loops after repeated failures. + /// Closed → Open (30s cooldown) → Half-Open (single probe). + private enum CircuitBreakerState { + case closed + case open(since: Date) + case halfOpen + } + + private var circuitBreaker: CircuitBreakerState = .closed + private static let circuitBreakerCooldown: TimeInterval = 30 + + /// Connect-retry budget for a single `connect(to:)` call. + /// `default` applies to every attempt on a platform with a system pairing registry, and to + /// all background reconnects. `unverified` bounds a *user-initiated* connect on a platform + /// without a registry (macOS), where CoreBluetooth cannot pre-reject an absent cached + /// peripheral, so the full budget would otherwise leave the user staring at a ~40s spinner. + static let defaultConnectAttempts = 4 + static let unverifiedConnectAttempts = 2 - /// Whether this platform has an app-visible system pairing registry (AccessorySetupKit). - /// `true` on iOS; `false` on macOS "Designed for iPad". The device picker uses this to - /// decide whether registry membership or the stored connection method is the reachability - /// signal, and the connect path uses it to bound a user-initiated connect's retry budget. - public var hasSystemPairingRegistry: Bool { - pairing.hasSystemPairingRegistry - } + /// Checks whether a connection attempt should proceed. + /// Returns `true` if the circuit breaker allows it. + /// - Parameter force: When `true`, bypasses the circuit breaker (user-initiated reconnect) + func shouldAllowConnection(force: Bool) -> Bool { + if force { return true } - /// Creates a standalone persistence store for operations that don't require services - public func createStandalonePersistenceStore() -> PersistenceStore { - PersistenceStore(modelContainer: modelContainer) + switch circuitBreaker { + case .closed: + return true + case let .open(since): + if Date().timeIntervalSince(since) >= Self.circuitBreakerCooldown { + circuitBreaker = .halfOpen + logger.info("[BLE] Circuit breaker: open → halfOpen (cooldown elapsed)") + return true + } + return false + case .halfOpen: + // Half-open: allow a single probe attempt to determine if the connection can be restored. + return true } - - // MARK: - Internal Components - - let modelContainer: ModelContainer - private let defaults: UserDefaults - private let lastConnectionStore: LastConnectionStore - let transport: any iOSMeshTransport - var wifiTransport: WiFiTransport? - var session: MeshCoreSession? - /// Device discovery + system pairing-registry seam. Resolves to AccessorySetupKit on - /// iOS, or an in-app CoreBluetooth scan picker on macOS "Designed for iPad". - let pairing: any DevicePairingService - - /// Non-nil only on macOS, where the scan picker UI must be presented by the view layer. - /// nil on iOS, where AccessorySetupKit presents its own system picker. - public var bluetoothScanPicker: BluetoothScanPairingService? { - pairing as? BluetoothScanPairingService + } + + /// Records a connection failure for circuit breaker tracking. + /// Trips the breaker to `.open` when called after all retries are exhausted. + func recordConnectionFailure() { + switch circuitBreaker { + case .closed: + circuitBreaker = .open(since: Date()) + logger.warning("[BLE] Circuit breaker: closed → open (retries exhausted)") + case .halfOpen: + circuitBreaker = .open(since: Date()) + logger.warning("[BLE] Circuit breaker: halfOpen → open (probe failed)") + case .open: + break } + } - /// Shared BLE state machine to manage connection lifecycle. - /// This prevents state restoration race conditions that cause "API MISUSE" errors. - let stateMachine: any BLEStateMachineProtocol - - /// Coordinates iOS auto-reconnect lifecycle (timeouts, teardown, rebuild). - let reconnectionCoordinator = BLEReconnectionCoordinator() - - // MARK: - WiFi Reconnection - - /// Task handling WiFi reconnection attempts - var wifiReconnectTask: Task? - - /// Current reconnection attempt number - var wifiReconnectAttempt = 0 - - /// Maximum duration for WiFi reconnection attempts (30 seconds) - static let wifiMaxReconnectDuration: Duration = .seconds(30) - - /// Last reconnection start time (for rate limiting rapid disconnects) - var lastWiFiReconnectStartTime: Date? - - /// Minimum interval between reconnection attempts (prevents flapping) - static let wifiReconnectCooldown: TimeInterval = 35 - - // MARK: - WiFi Heartbeat - - /// Task for periodic WiFi connection health checks - var wifiHeartbeatTask: Task? - - /// Interval between WiFi heartbeat probes (seconds) - static let wifiHeartbeatInterval: Duration = .seconds(30) - - /// Task coordinating BLE scan startup to avoid start/stop races with stream termination. - var bleScanTask: Task? + /// Records a successful connection, resetting the circuit breaker. + func recordConnectionSuccess() { + if case .closed = circuitBreaker { return } + circuitBreaker = .closed + logger.info("[BLE] Circuit breaker: → closed (connection succeeded)") + } - /// Monotonic token used to invalidate stale BLE scan requests. - var bleScanRequestID: UInt64 = 0 + // MARK: - Reconnection Watchdog - // MARK: - Resync State + /// Task managing the reconnection watchdog (retries when stuck disconnected) + var reconnectionWatchdogTask: Task? - // Stored state for the sync retry loops; the loops themselves live in the - // SyncRetry extension, which cannot add instance storage. + /// Session IDs that need re-authentication after BLE reconnect. + /// Populated by `handleBLEDisconnection()`, consumed by `rebuildSession()`. + /// Empty after app restart, so rooms show "Tap to reconnect" instead of auto-connecting. + var sessionsAwaitingReauth: Set = [] - /// Current resync attempt count (reset on success or disconnect) - var resyncAttemptCount = 0 + // MARK: - Simulator Support - /// Task managing the resync retry loop - var resyncTask: Task? + /// Simulator connection mode (used for demo mode on device) + let simulatorMode = SimulatorConnectionMode() - /// Task managing delayed channel-only retry after a partial channel phase. - var channelRetryTask: Task? - - /// Callback when resync fails after all attempts (triggers "Sync Failed" pill) - /// Note: @Sendable @MainActor ensures safe cross-isolation callback - /// Installed by `ConnectionUIState.wireCallbacks`. - public var onResyncFailed: (@Sendable @MainActor () -> Void)? - - // MARK: - Circuit Breaker - - /// Prevents rapid reconnection loops after repeated failures. - /// Closed → Open (30s cooldown) → Half-Open (single probe). - private enum CircuitBreakerState { - case closed - case open(since: Date) - case halfOpen - } - - private var circuitBreaker: CircuitBreakerState = .closed - private static let circuitBreakerCooldown: TimeInterval = 30 - - /// Connect-retry budget for a single `connect(to:)` call. - /// `default` applies to every attempt on a platform with a system pairing registry, and to - /// all background reconnects. `unverified` bounds a *user-initiated* connect on a platform - /// without a registry (macOS), where CoreBluetooth cannot pre-reject an absent cached - /// peripheral, so the full budget would otherwise leave the user staring at a ~40s spinner. - static let defaultConnectAttempts = 4 - static let unverifiedConnectAttempts = 2 - - /// Checks whether a connection attempt should proceed. - /// Returns `true` if the circuit breaker allows it. - /// - Parameter force: When `true`, bypasses the circuit breaker (user-initiated reconnect) - func shouldAllowConnection(force: Bool) -> Bool { - if force { return true } - - switch circuitBreaker { - case .closed: - return true - case .open(let since): - if Date().timeIntervalSince(since) >= Self.circuitBreakerCooldown { - circuitBreaker = .halfOpen - logger.info("[BLE] Circuit breaker: open → halfOpen (cooldown elapsed)") - return true - } - return false - case .halfOpen: - // Half-open: allow a single probe attempt to determine if the connection can be restored. - return true - } + // Whether running in simulator mode + #if targetEnvironment(simulator) + public var isSimulatorMode: Bool { + true } - - /// Records a connection failure for circuit breaker tracking. - /// Trips the breaker to `.open` when called after all retries are exhausted. - func recordConnectionFailure() { - switch circuitBreaker { - case .closed: - circuitBreaker = .open(since: Date()) - logger.warning("[BLE] Circuit breaker: closed → open (retries exhausted)") - case .halfOpen: - circuitBreaker = .open(since: Date()) - logger.warning("[BLE] Circuit breaker: halfOpen → open (probe failed)") - case .open: - break - } - } - - /// Records a successful connection, resetting the circuit breaker. - func recordConnectionSuccess() { - if case .closed = circuitBreaker { return } - circuitBreaker = .closed - logger.info("[BLE] Circuit breaker: → closed (connection succeeded)") + #else + public var isSimulatorMode: Bool { + false } + #endif - // MARK: - Reconnection Watchdog - - /// Task managing the reconnection watchdog (retries when stuck disconnected) - var reconnectionWatchdogTask: Task? - - /// Session IDs that need re-authentication after BLE reconnect. - /// Populated by `handleBLEDisconnection()`, consumed by `rebuildSession()`. - /// Empty after app restart, so rooms show "Tap to reconnect" instead of auto-connecting. - var sessionsAwaitingReauth: Set = [] + // MARK: - Last Device Persistence - // MARK: - Simulator Support - - /// Simulator connection mode (used for demo mode on device) - let simulatorMode = SimulatorConnectionMode() - - /// Whether running in simulator mode - #if targetEnvironment(simulator) - public var isSimulatorMode: Bool { true } - #else - public var isSimulatorMode: Bool { false } - #endif - - // MARK: - Last Device Persistence - - #if DEBUG + #if DEBUG /// Test override for lastConnectedDeviceID - internal var testLastConnectedDeviceID: UUID? + var testLastConnectedDeviceID: UUID? /// True when the BLE reconnection watchdog task is active. - internal var isReconnectionWatchdogRunning: Bool { - reconnectionWatchdogTask != nil + var isReconnectionWatchdogRunning: Bool { + reconnectionWatchdogTask != nil } /// Strategy injection for `waitForOtherAppReconnection`. Tests use this to @@ -368,799 +417,825 @@ public final class ConnectionManager { /// deterministically before releasing the wait. typealias OtherAppWaitStrategy = @Sendable (UUID) async -> Bool - internal var otherAppWaitStrategyOverride: OtherAppWaitStrategy? + var otherAppWaitStrategyOverride: OtherAppWaitStrategy? typealias HealthCheckSessionRebuildOverride = @MainActor (UUID) async throws -> Void - internal var rebuildSessionForHealthCheckOverride: HealthCheckSessionRebuildOverride? - #endif - - /// The last connected device ID (for auto-reconnect) - public var lastConnectedDeviceID: UUID? { - #if DEBUG - if let testID = testLastConnectedDeviceID { - return testID - } - #endif - return lastConnectionStore.deviceID - } - - /// The last connected radio ID (for offline data scoping) - public var lastConnectedRadioID: UUID? { - lastConnectionStore.radioID - } + var rebuildSessionForHealthCheckOverride: HealthCheckSessionRebuildOverride? + #endif - /// The last connected device name (for offline display when disconnected) - public var lastConnectedDeviceName: String? { - lastConnectionStore.deviceName - } - - /// Records a successful connection for future restoration - func persistConnection(deviceID: UUID, radioID: UUID, deviceName: String) { - lastConnectionStore.persist(deviceID: deviceID, radioID: radioID, deviceName: deviceName) - } - - /// Clears the persisted connection - func clearPersistedConnection() { - lastConnectionStore.clear() + /// The last connected device ID (for auto-reconnect) + public var lastConnectedDeviceID: UUID? { + #if DEBUG + if let testID = testLastConnectedDeviceID { + return testID + } + #endif + return lastConnectionStore.deviceID + } + + /// The last connected radio ID (for offline data scoping) + public var lastConnectedRadioID: UUID? { + lastConnectionStore.radioID + } + + /// The last connected device name (for offline display when disconnected) + public var lastConnectedDeviceName: String? { + lastConnectionStore.deviceName + } + + /// Records a successful connection for future restoration + func persistConnection(deviceID: UUID, radioID: UUID, deviceName: String) { + lastConnectionStore.persist(deviceID: deviceID, radioID: radioID, deviceName: deviceName) + } + + /// Clears the persisted connection + func clearPersistedConnection() { + lastConnectionStore.clear() + } + + /// Whether the disconnected pill should be suppressed (user explicitly disconnected) + public var shouldSuppressDisconnectedPill: Bool { + connectionIntent.isUserDisconnected + } + + /// Most recent disconnect diagnostic summary persisted across app launches. + public var lastDisconnectDiagnostic: String? { + lastConnectionStore.disconnectDiagnostic + } + + /// Current high-level connection intent, exported for diagnostics. + public var connectionIntentSummary: String { + switch connectionIntent { + case .none: + "none" + case .userDisconnected: + "userDisconnected" + case let .wantsConnection(forceFullSync): + forceFullSync ? "wantsConnection(forceFullSync: true)" : "wantsConnection" } - - /// Whether the disconnected pill should be suppressed (user explicitly disconnected) - public var shouldSuppressDisconnectedPill: Bool { - connectionIntent.isUserDisconnected + } + + /// Whether the last connection was a simulator connection + public var wasSimulatorConnection: Bool { + lastConnectedDeviceID == MockDataProvider.simulatorDeviceID + } + + /// Whether a WiFi disconnection is currently being handled (prevents interleaving + /// across await suspension points before wifiReconnectTask is set). + var isHandlingWiFiDisconnection = false + + // MARK: - Cancellation Helpers + + /// Cancels any in-progress WiFi reconnection attempts + func cancelWiFiReconnection() { + wifiReconnectTask?.cancel() + wifiReconnectTask = nil + wifiReconnectAttempt = 0 + } + + // MARK: - Initialization + + /// Creates a new connection manager. + /// - Parameters: + /// - modelContainer: The SwiftData model container for persistence + /// - stateMachine: Optional BLE state machine for testing. If nil, creates a real BLEStateMachine. + /// - transport: Optional iOS mesh transport for testing. If nil, creates an `iOSBLETransport` against the chosen state machine. + /// - pairing: Optional pairing service for testing. If nil, `DevicePairingFactory` selects the platform implementation. + public init( + modelContainer: ModelContainer, + defaults: UserDefaults = .standard, + stateMachine: (any BLEStateMachineProtocol)? = nil, + transport: (any iOSMeshTransport)? = nil, + pairing: (any DevicePairingService)? = nil + ) { + self.modelContainer = modelContainer + self.defaults = defaults + lastConnectionStore = LastConnectionStore(defaults: defaults) + connectionIntent = .restored(from: defaults) + + // Use provided state machine or create default + let bleStateMachine = stateMachine ?? BLEStateMachine() + self.stateMachine = bleStateMachine + + if let injected = transport { + self.transport = injected + } else if let concrete = bleStateMachine as? BLEStateMachine { + self.transport = iOSBLETransport(stateMachine: concrete) + } else { + // Test mode without an injected transport: create a dummy (unused when mocking BLE) + self.transport = iOSBLETransport(stateMachine: BLEStateMachine()) } - /// Most recent disconnect diagnostic summary persisted across app launches. - public var lastDisconnectDiagnostic: String? { - lastConnectionStore.disconnectDiagnostic + self.pairing = pairing ?? DevicePairingFactory.make() + + self.pairing.delegate = self + reconnectionCoordinator.delegate = self + + // Best-effort early wiring. activate() awaits the same idempotent + // method before constructing the CBCentralManager, which is the + // ordering guarantee; this Task just installs handlers promptly for + // flows that touch the state machine before activate() runs. + Task { await self.wireTransportHandlers() } + } + + /// Wires the transport and state-machine lifecycle handlers. + /// + /// Must complete before anything constructs the CBCentralManager: + /// creating the central can synchronously fire state-restoration + /// callbacks, and a missed auto-reconnect or reconnection handler at + /// launch leaves the restored link without a session rebuild. Idempotent; + /// re-installing the same handlers is harmless. + func wireTransportHandlers() async { + let stateMachine = stateMachine + let transport = transport + + // Handle disconnection events + await transport.setDisconnectionHandler { [weak self] deviceID, error in + Task { @MainActor in + guard let self else { return } + await self.handleConnectionLoss(deviceID: deviceID, error: error) + } } - /// Current high-level connection intent, exported for diagnostics. - public var connectionIntentSummary: String { - switch connectionIntent { - case .none: - return "none" - case .userDisconnected: - return "userDisconnected" - case .wantsConnection(let forceFullSync): - return forceFullSync ? "wantsConnection(forceFullSync: true)" : "wantsConnection" + // Handle entering auto-reconnecting phase + await stateMachine.setAutoReconnectingHandler { [weak self] (deviceID: UUID, errorInfo: String) in + Task { @MainActor in + guard let self else { return } + + if self.shouldDeferOpportunisticReconnect { + self.logger.info( + "[BLE] Auto-reconnect entry suppressed for \(deviceID.uuidString.prefix(8)) (pairing in progress) — tearing down stale session" + ) + // Skip the reconnect-cycle claim and UI timeout (pairing's + // connect(to:) ceremony owns the next state transitions), + // but tear down the prior session so a pairing early-exit + // path doesn't strand the UI on stale `.ready` state. + await self.handleConnectionLoss(deviceID: deviceID, error: nil) + return } - } - - /// Whether the last connection was a simulator connection - public var wasSimulatorConnection: Bool { - lastConnectedDeviceID == MockDataProvider.simulatorDeviceID - } - - /// Whether a WiFi disconnection is currently being handled (prevents interleaving - /// across await suspension points before wifiReconnectTask is set). - var isHandlingWiFiDisconnection = false - - // MARK: - Cancellation Helpers - /// Cancels any in-progress WiFi reconnection attempts - func cancelWiFiReconnection() { - wifiReconnectTask?.cancel() - wifiReconnectTask = nil - wifiReconnectAttempt = 0 - } - - // MARK: - Initialization - - /// Creates a new connection manager. - /// - Parameters: - /// - modelContainer: The SwiftData model container for persistence - /// - stateMachine: Optional BLE state machine for testing. If nil, creates a real BLEStateMachine. - /// - transport: Optional iOS mesh transport for testing. If nil, creates an `iOSBLETransport` against the chosen state machine. - /// - pairing: Optional pairing service for testing. If nil, `DevicePairingFactory` selects the platform implementation. - public init( - modelContainer: ModelContainer, - defaults: UserDefaults = .standard, - stateMachine: (any BLEStateMachineProtocol)? = nil, - transport: (any iOSMeshTransport)? = nil, - pairing: (any DevicePairingService)? = nil - ) { - self.modelContainer = modelContainer - self.defaults = defaults - self.lastConnectionStore = LastConnectionStore(defaults: defaults) - self.connectionIntent = .restored(from: defaults) - - // Use provided state machine or create default - let bleStateMachine = stateMachine ?? BLEStateMachine() - self.stateMachine = bleStateMachine - - if let injected = transport { - self.transport = injected - } else if let concrete = bleStateMachine as? BLEStateMachine { - self.transport = iOSBLETransport(stateMachine: concrete) - } else { - // Test mode without an injected transport: create a dummy (unused when mocking BLE) - self.transport = iOSBLETransport(stateMachine: BLEStateMachine()) + if let manualConnectDeviceID = self.connectingDeviceID { + guard manualConnectDeviceID == deviceID else { + self.logger.info( + "[BLE] Auto-reconnect entry for \(deviceID.uuidString.prefix(8)) standing down: manual connect in flight for \(manualConnectDeviceID.uuidString.prefix(8))" + ) + return + } + // The dropped link belongs to the in-flight manual connect. Release + // its claim so the retry loop bails out instead of disconnecting the + // transport — that would cancel the OS pending connect recovering + // this link and leave a reconnect-cycle claim no completion can ever + // clear, silently disabling the watchdog, the foreground health + // check, and power-on recovery. The cycle claimed below owns + // teardown, the UI timeout, and the session rebuild from here. + self.logger.info("[BLE] Auto-reconnect entry adopting in-flight manual connect for \(deviceID.uuidString.prefix(8))") + self.connectingDeviceID = nil } - self.pairing = pairing ?? DevicePairingFactory.make() - - self.pairing.delegate = self - reconnectionCoordinator.delegate = self + // Snapshot pre-claim state before entering — handleEnteringAutoReconnect + // mutates connectionState to .connecting before its first await. + let initialState = String(describing: self.connectionState) + let transportName = switch self.currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" + } - // Best-effort early wiring. activate() awaits the same idempotent - // method before constructing the CBCentralManager, which is the - // ordering guarantee; this Task just installs handlers promptly for - // flows that touch the state machine before activate() runs. - Task { await self.wireTransportHandlers() } + // Claim before the state-machine queries below. Without this, + // a state-restoration adoption where iOS callbacks land within + // microseconds of each other can fire onReconnection while these + // awaits are still queued, and the strict completion guard would + // drop the completion since reconnectingDeviceID is still nil. + await self.reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) + + let bleState = await self.stateMachine.centralManagerStateName + let blePhase = await self.stateMachine.currentPhaseName + let blePeripheralState = await self.stateMachine.currentPeripheralState ?? "none" + + self.persistDisconnectDiagnostic( + "source=bleStateMachine.autoReconnectingHandler, " + + "device=\(deviceID.uuidString.prefix(8)), " + + "transport=\(transportName), " + + "initialState=\(initialState), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "error=\(errorInfo), " + + "intent=\(self.connectionIntent)" + ) + } } - /// Wires the transport and state-machine lifecycle handlers. - /// - /// Must complete before anything constructs the CBCentralManager: - /// creating the central can synchronously fire state-restoration - /// callbacks, and a missed auto-reconnect or reconnection handler at - /// launch leaves the restored link without a session rebuild. Idempotent; - /// re-installing the same handlers is harmless. - func wireTransportHandlers() async { - let stateMachine = self.stateMachine - let transport = self.transport - - // Handle disconnection events - await transport.setDisconnectionHandler { [weak self] deviceID, error in - Task { @MainActor in - guard let self else { return } - await self.handleConnectionLoss(deviceID: deviceID, error: error) - } - } + // Handle iOS auto-reconnect completion + // Using transport.setReconnectionHandler ensures the transport captures + // the data stream internally before calling our handler + await transport.setReconnectionHandler { [weak self] deviceID in + Task { @MainActor in + guard let self else { return } + await self.reconnectionCoordinator.handleReconnectionComplete(deviceID: deviceID) + } + } - // Handle entering auto-reconnecting phase - await stateMachine.setAutoReconnectingHandler { [weak self] (deviceID: UUID, errorInfo: String) in - Task { @MainActor in - guard let self else { return } - - if self.shouldDeferOpportunisticReconnect { - self.logger.info( - "[BLE] Auto-reconnect entry suppressed for \(deviceID.uuidString.prefix(8)) (pairing in progress) — tearing down stale session" - ) - // Skip the reconnect-cycle claim and UI timeout (pairing's - // connect(to:) ceremony owns the next state transitions), - // but tear down the prior session so a pairing early-exit - // path doesn't strand the UI on stale `.ready` state. - await self.handleConnectionLoss(deviceID: deviceID, error: nil) - return - } - - // Snapshot pre-claim state before entering — handleEnteringAutoReconnect - // mutates connectionState to .connecting before its first await. - let initialState = String(describing: self.connectionState) - let transportName = switch self.currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" - } - - // Claim before the state-machine queries below. Without this, - // a state-restoration adoption where iOS callbacks land within - // microseconds of each other can fire onReconnection while these - // awaits are still queued, and the strict completion guard would - // drop the completion since reconnectingDeviceID is still nil. - await self.reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - let bleState = await self.stateMachine.centralManagerStateName - let blePhase = await self.stateMachine.currentPhaseName - let blePeripheralState = await self.stateMachine.currentPeripheralState ?? "none" - - self.persistDisconnectDiagnostic( - "source=bleStateMachine.autoReconnectingHandler, " + - "device=\(deviceID.uuidString.prefix(8)), " + - "transport=\(transportName), " + - "initialState=\(initialState), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "error=\(errorInfo), " + - "intent=\(self.connectionIntent)" - ) - } + // Handle Bluetooth power-cycle recovery + await stateMachine.setBluetoothPoweredOnHandler { [weak self] in + Task { @MainActor in + guard let self, + self.connectionIntent.wantsConnection, + self.connectionState == .disconnected, + let deviceID = self.lastConnectedDeviceID else { return } + + if self.shouldDeferOpportunisticReconnect { + self.logger.info("[BLE] Bluetooth powered on: standing down (pairing in progress)") + return } - // Handle iOS auto-reconnect completion - // Using transport.setReconnectionHandler ensures the transport captures - // the data stream internally before calling our handler - await transport.setReconnectionHandler { [weak self] deviceID in - Task { @MainActor in - guard let self else { return } - await self.reconnectionCoordinator.handleReconnectionComplete(deviceID: deviceID) - } + let blePhase = await self.stateMachine.currentPhaseName + let bleConnectedDeviceID = await self.stateMachine.connectedDeviceID + if blePhase != "idle" || bleConnectedDeviceID == deviceID { + self.logger.info( + "[BLE] Bluetooth powered on: BLE already owns reconnect flow for \(deviceID.uuidString.prefix(8)) " + + "(phase: \(blePhase), bleConnectedDevice: \(bleConnectedDeviceID?.uuidString.prefix(8) ?? "none"))" + ) + return } - // Handle Bluetooth power-cycle recovery - await stateMachine.setBluetoothPoweredOnHandler { [weak self] in - Task { @MainActor in - guard let self, - self.connectionIntent.wantsConnection, - self.connectionState == .disconnected, - let deviceID = self.lastConnectedDeviceID else { return } - - if self.shouldDeferOpportunisticReconnect { - self.logger.info("[BLE] Bluetooth powered on: standing down (pairing in progress)") - return - } - - let blePhase = await self.stateMachine.currentPhaseName - let bleConnectedDeviceID = await self.stateMachine.connectedDeviceID - if blePhase != "idle" || bleConnectedDeviceID == deviceID { - self.logger.info( - "[BLE] Bluetooth powered on: BLE already owns reconnect flow for \(deviceID.uuidString.prefix(8)) " + - "(phase: \(blePhase), bleConnectedDevice: \(bleConnectedDeviceID?.uuidString.prefix(8) ?? "none"))" - ) - return - } - - if self.activeReconnectDeviceID == deviceID { - self.logger.info("[BLE] Bluetooth powered on: reconnect/session rebuild already in progress for \(deviceID.uuidString.prefix(8))") - return - } - - self.logger.info("[BLE] Bluetooth powered on: attempting reconnection to \(deviceID.uuidString.prefix(8))") - await self.attemptOpportunisticReconnect(deviceID: deviceID, reason: "Bluetooth powered on") - } + if self.activeReconnectDeviceID == deviceID { + self.logger.info("[BLE] Bluetooth powered on: reconnect/session rebuild already in progress for \(deviceID.uuidString.prefix(8))") + return } - // Handle Bluetooth state changes for diagnostics - await stateMachine.setBluetoothStateChangeHandler { [weak self] state in - Task { @MainActor in - guard let self else { return } - self.handleBluetoothStateChange(state) - } - } + self.logger.info("[BLE] Bluetooth powered on: attempting reconnection to \(deviceID.uuidString.prefix(8))") + await self.attemptOpportunisticReconnect(deviceID: deviceID, reason: "Bluetooth powered on") + } } - // MARK: - Session Helpers - - /// Starts a session and queries device capabilities. - func initializeSession( - _ session: MeshCoreSession - ) async throws -> (SelfInfo, DeviceCapabilities) { - do { - try await withTimeout(.seconds(10), operationName: "session.start") { - try await session.start() - } - } catch { - logger.warning("[BLE] session.start() timed out or failed: \(error.localizedDescription)") - throw error - } - - guard let selfInfo = await session.currentSelfInfo else { - logger.warning("[BLE] selfInfo is nil after session.start()") - throw ConnectionError.initializationFailed("Failed to get device self info") - } - do { - let capabilities = try await withTimeout(.seconds(10), operationName: "queryDevice") { - try await session.queryDevice() - } - return (selfInfo, capabilities) - } catch { - logger.warning("[BLE] queryDevice() timed out or failed: \(error.localizedDescription)") - throw error - } + // Handle Bluetooth state changes for diagnostics + await stateMachine.setBluetoothStateChangeHandler { [weak self] state in + Task { @MainActor in + guard let self else { return } + self.handleBluetoothStateChange(state) + } } - - // MARK: - Service Wiring Helpers - - /// Wires the clean-channel-sync callback on a new ServiceContainer so that - /// `lastCleanChannelSync` is updated when a channel phase completes without errors. - /// Called from every path that creates a new ServiceContainer. - func wireCleanChannelSyncCallback(on services: ServiceContainer) async { - await services.syncCoordinator.setCleanChannelSyncCallback { [weak self] radioID in - await MainActor.run { - self?.lastCleanChannelSync = (radioID: radioID, completedAt: Date()) - } - } - await services.syncCoordinator.setChannelSyncAttemptedCallback { [weak self] radioID in - await MainActor.run { - self?.lastAttemptedChannelSync = (radioID: radioID, attemptedAt: Date()) - } - } + } + + // MARK: - Session Helpers + + /// Starts a session and queries device capabilities. + func initializeSession( + _ session: MeshCoreSession + ) async throws -> (SelfInfo, DeviceCapabilities) { + do { + try await withTimeout(.seconds(10), operationName: "session.start") { + try await session.start() + } + } catch { + logger.warning("[BLE] session.start() timed out or failed: \(error.localizedDescription)") + throw error } - // MARK: - Connection Ceremony - - /// Builds a fresh ServiceContainer, fetches device configuration from the radio - /// and database, builds and persists the device record, and updates self. - /// - /// Each connection path (BLE, WiFi, reconnect, device switch) calls this after - /// its transport and session are established. Post-ceremony work (sync, promote, - /// cleanup) remains in each caller since it genuinely varies. - /// - /// - Returns: The wired `ServiceContainer` for the caller's sync phase. - func buildServicesAndSaveDevice( - deviceID: UUID, - session: MeshCoreSession, - selfInfo: SelfInfo, - capabilities: DeviceCapabilities, - connectionMethods: [ConnectionMethod] = [] - ) async throws -> (services: ServiceContainer, radioID: UUID) { - // Kick off `getAutoAddConfig` up-front so the BLE roundtrip overlaps - // with the local DB fetches and container wiring below. - async let autoAddConfigResult = session.getAutoAddConfig() - - // Resolve the radio before constructing the container so the - // chat send queue can scope its `PendingSend` hydration to the - // right radio from frame zero. Falls back to publicKey lookup - // (backup import) and finally to a fresh UUID for first-time - // pairings. - let standaloneStore = PersistenceStore(modelContainer: modelContainer) - let existingDevice = try? await standaloneStore.fetchDevice(id: deviceID) - let deviceByPublicKey: DeviceDTO? - if existingDevice == nil { - deviceByPublicKey = try? await standaloneStore.fetchDevice(publicKey: selfInfo.publicKey) - } else { - deviceByPublicKey = nil - } - let effectiveExisting = existingDevice ?? deviceByPublicKey - let resolvedRadioID = effectiveExisting?.radioID ?? UUID() - - let newServices = ServiceContainer( - session: session, - modelContainer: modelContainer, - radioID: resolvedRadioID, - appStateProvider: appStateProvider, - connectionStateEvents: connectionStateEvents, - initialConnectionState: connectionState - ) - await wireCleanChannelSyncCallback(on: newServices) - await newServices.nodeConfigService.setOnPostIdentityImport { [weak self, weak newServices] in - guard let self, let services = newServices else { return nil } - return try await self.reconcileIdentity(expectedServices: services, deviceID: deviceID) - } - - let autoAddConfig = (try? await autoAddConfigResult) ?? MeshCore.AutoAddConfig(bitmask: 0) - - let repeatFreqRanges: [MeshCore.FrequencyRange] = capabilities.clientRepeat - ? (try? await session.getRepeatFreq()) ?? [] - : [] - - let device = createDevice( - deviceID: deviceID, - radioID: resolvedRadioID, - selfInfo: selfInfo, - capabilities: capabilities, - autoAddConfig: autoAddConfig, - existingDevice: effectiveExisting, - connectionMethods: connectionMethods - ) - - let deviceDTO = DeviceDTO(from: device) - // Persist before warmUp so purgeOrphanPendingSends sees the in-progress - // radio's Device row and does not classify its PendingSends as orphans. - try await newServices.dataStore.saveDevice(deviceDTO) - - // Clean up orphaned Device row from backup import - if let oldDevice = deviceByPublicKey, oldDevice.id != deviceID { - try? await newServices.dataStore.deleteDevice(id: oldDevice.id) - } - - // Run startup-time DB hygiene before hydrating the send queue. - // warmUp's inner operations (purgeOrphanPendingSends and - // purgeLegacyAttemptCountRows) are best-effort; failure is non-fatal. - do { - try await newServices.warmUp() - } catch { - logger.warning("ServiceContainer.warmUp failed: \(error.localizedDescription)") - } - - // Hydrate the chat send queue before exposing the container so - // view-model `configure` calls see a hydrated service from the - // first read. - await newServices.chatSendQueueService.hydrate() - - // Restore `self.session` together with `self.services`: an interleaved - // `handleConnectionLoss` nils both atomically, and `promoteToReady`'s - // state invariants require both. - self.session = session - self.services = newServices - - self.connectedDevice = deviceDTO - self.allowedRepeatFreqRanges = repeatFreqRanges - - return (newServices, deviceDTO.radioID) + guard let selfInfo = await session.currentSelfInfo else { + logger.warning("[BLE] selfInfo is nil after session.start()") + throw ConnectionError.initializationFailed("Failed to get device self info") } - - // MARK: - Ready Promotion - - /// Promotes connection to `.ready` if the connection is still alive and owned by the expected services. - /// Skips post-sync work (time sync, onDeviceSynced) when sync failed to avoid BLE pressure. - /// Returns `true` if `.ready` was set, `false` if promotion was suppressed. - /// - /// - Parameter additionalGuard: Caller-specific invariant checked at every guard point, - /// including after async operations like `syncDeviceTimeIfNeeded()`. This cannot be an - /// inline check at the call site because the invariant must hold both before and after - /// the internal awaits — a competing reconnect cycle could start during time sync, - /// and promoting a stale session to `.ready` would shadow the new one. - /// Currently only `rebuildSession` uses this (reconnect-generation check). - @discardableResult - func promoteToReady( - syncSucceeded: Bool, - expectedServices: ServiceContainer, - transportType: TransportType, - additionalGuard: (() -> Bool)? = nil - ) async -> Bool { - guard connectionIntent.wantsConnection else { - logger.warning("Promotion suppressed: user disconnected") - return false - } - guard self.services === expectedServices else { - logger.warning("Promotion suppressed: services replaced or nil") - return false - } - guard additionalGuard?() ?? true else { - logger.warning("Promotion suppressed: caller guard failed (e.g. reconnect generation)") - return false - } - - currentTransportType = transportType - connectionState = syncSucceeded ? .ready : .syncing - - // Skip time sync on BLE failure to avoid pressure on a saturated link. - // WiFi/TCP has no such constraint, so always correct the clock there. - if syncSucceeded || transportType == .wifi { - await syncDeviceTimeIfNeeded() - guard connectionIntent.wantsConnection else { - logger.warning("Promotion suppressed after time sync: user disconnected") - return false - } - guard self.services === expectedServices else { - logger.warning("Promotion suppressed after time sync: services replaced or nil") - return false - } - guard additionalGuard?() ?? true else { - logger.warning("Promotion suppressed after time sync: caller guard failed (e.g. reconnect generation)") - return false - } - } - - if syncSucceeded { await onDeviceSynced?() } - return true + do { + let capabilities = try await withTimeout(.seconds(10), operationName: "queryDevice") { + try await session.queryDevice() + } + return (selfInfo, capabilities) + } catch { + logger.warning("[BLE] queryDevice() timed out or failed: \(error.localizedDescription)") + throw error + } + } + + // MARK: - Service Wiring Helpers + + /// Wires the clean-channel-sync callback on a new ServiceContainer so that + /// `lastCleanChannelSync` is updated when a channel phase completes without errors. + /// Called from every path that creates a new ServiceContainer. + func wireCleanChannelSyncCallback(on services: ServiceContainer) async { + await services.syncCoordinator.setCleanChannelSyncCallback { [weak self] radioID in + await MainActor.run { + self?.lastCleanChannelSync = (radioID: radioID, completedAt: Date()) + } + } + await services.syncCoordinator.setChannelSyncAttemptedCallback { [weak self] radioID in + await MainActor.run { + self?.lastAttemptedChannelSync = (radioID: radioID, attemptedAt: Date()) + } + } + } + + // MARK: - Connection Ceremony + + /// Builds a fresh ServiceContainer, fetches device configuration from the radio + /// and database, builds and persists the device record, and updates self. + /// + /// Each connection path (BLE, WiFi, reconnect, device switch) calls this after + /// its transport and session are established. Post-ceremony work (sync, promote, + /// cleanup) remains in each caller since it genuinely varies. + /// + /// - Returns: The wired `ServiceContainer` for the caller's sync phase. + func buildServicesAndSaveDevice( + deviceID: UUID, + session: MeshCoreSession, + selfInfo: SelfInfo, + capabilities: DeviceCapabilities, + connectionMethods: [ConnectionMethod] = [] + ) async throws -> (services: ServiceContainer, radioID: UUID) { + // Kick off `getAutoAddConfig` up-front so the BLE roundtrip overlaps + // with the local DB fetches and container wiring below. + async let autoAddConfigResult = session.getAutoAddConfig() + + // Resolve the radio before constructing the container so the + // chat send queue can scope its `PendingSend` hydration to the + // right radio from frame zero. Falls back to publicKey lookup + // (backup import) and finally to a fresh UUID for first-time + // pairings. + let standaloneStore = PersistenceStore(modelContainer: modelContainer) + let existingDevice = try? await standaloneStore.fetchDevice(id: deviceID) + let deviceByPublicKey: DeviceDTO? = if existingDevice == nil { + try? await standaloneStore.fetchDevice(publicKey: selfInfo.publicKey) + } else { + nil + } + let effectiveExisting = existingDevice ?? deviceByPublicKey + let resolvedRadioID = effectiveExisting?.radioID ?? UUID() + + let newServices = ServiceContainer( + session: session, + modelContainer: modelContainer, + radioID: resolvedRadioID, + appStateProvider: appStateProvider, + connectionStateEvents: connectionStateEvents, + initialConnectionState: connectionState + ) + await wireCleanChannelSyncCallback(on: newServices) + await newServices.nodeConfigService.setOnPostIdentityImport { [weak self, weak newServices] in + guard let self, let services = newServices else { return nil } + return try await reconcileIdentity(expectedServices: services, deviceID: deviceID) } - /// Re-evaluates the connected device's identity after `NodeConfigService.importIdentity` - /// has restored a privateKey on the radio. If the radio is now reporting a different - /// `publicKey` than the local Device row, attempts to reconcile against any ghost - /// carrying that publicKey (left by a prior "remove from MC1"). - /// - /// Mirrors the `promoteToReady` lifecycle pattern: takes the `ServiceContainer` - /// the caller expects to still own, plus the `deviceID` it expects to still be - /// connected to. Bails out (returns `nil`) if either invariant fails before or - /// after the `currentSelfInfo` await — a competing reconnect cycle could otherwise - /// reconcile state onto the wrong connection. - /// - /// - Parameters: - /// - expectedServices: The `ServiceContainer` captured at the start of the - /// config-import operation. If `self.services` no longer points to it, the - /// reconcile is silently skipped. - /// - deviceID: The BLE peripheral UUID the import was started against. - /// - Returns: The new `radioID` if reconciliation reassigned the device, - /// otherwise `nil` (no publicKey change, no matching ghost, or a guard tripped). - @discardableResult - public func reconcileIdentity( - expectedServices: ServiceContainer, - deviceID: UUID - ) async throws -> UUID? { - guard self.services === expectedServices else { - logger.info("reconcileIdentity skipped: services replaced before currentSelfInfo") - return nil - } - guard let preDevice = self.connectedDevice, preDevice.id == deviceID else { - logger.info("reconcileIdentity skipped: connectedDevice changed before currentSelfInfo") - return nil - } - - let selfInfo: MeshCore.SelfInfo - do { - guard let info = await expectedServices.session.currentSelfInfo else { - logger.warning("reconcileIdentity failed: session.currentSelfInfo is nil") - return nil - } - selfInfo = info - } - - guard self.services === expectedServices else { - logger.info("reconcileIdentity skipped: services replaced after currentSelfInfo") - return nil - } - guard self.connectedDevice?.id == deviceID else { - logger.info("reconcileIdentity skipped: connectedDevice changed after currentSelfInfo") - return nil - } - - // No publicKey-equality short-circuit: after a partial-import + app restart, - // `buildServicesAndSaveDevice` finds the Device by BLE UUID and overwrites - // `Device.publicKey` with the restored key via `Device.apply(dto:)`. On the - // user's retry, `selfInfo.publicKey == connectedDevice.publicKey` even though - // `radioID` is still stale. Always ask `reconcileGhostIdentity` — its - // predicate handles the no-ghost case by returning nil, so the cost of an - // unconditional query is one cheap DB lookup per import. - - let newRadioID: UUID? - do { - newRadioID = try await expectedServices.dataStore.reconcileGhostIdentity( - currentDeviceID: deviceID, - newPublicKey: selfInfo.publicKey - ) - } catch { - logger.warning("reconcileIdentity: reconcileGhostIdentity failed: \(error.localizedDescription)") - return nil - } + let autoAddConfig = await (try? autoAddConfigResult) ?? MeshCore.AutoAddConfig(bitmask: 0) + + let repeatFreqRanges: [MeshCore.FrequencyRange] = await capabilities.clientRepeat + ? (try? session.getRepeatFreq()) ?? [] + : [] + + let device = createDevice( + deviceID: deviceID, + radioID: resolvedRadioID, + selfInfo: selfInfo, + capabilities: capabilities, + autoAddConfig: autoAddConfig, + existingDevice: effectiveExisting, + connectionMethods: connectionMethods + ) + + let deviceDTO = DeviceDTO(from: device) + // Persist before warmUp so purgeOrphanPendingSends sees the in-progress + // radio's Device row and does not classify its PendingSends as orphans. + try await newServices.dataStore.saveDevice(deviceDTO) + + // Clean up orphaned Device row from backup import + if let oldDevice = deviceByPublicKey, oldDevice.id != deviceID { + try? await newServices.dataStore.deleteDevice(id: oldDevice.id) + } - guard let newRadioID else { - logger.info("reconcileIdentity: publicKey changed but no ghost matched") - return nil - } + // Run startup-time DB hygiene before hydrating the send queue. + // warmUp's inner operations (purgeOrphanPendingSends and + // purgeLegacyAttemptCountRows) are best-effort; failure is non-fatal. + do { + try await newServices.warmUp() + } catch { + logger.warning("ServiceContainer.warmUp failed: \(error.localizedDescription)") + } - // Final guard: the DB save just ran on a background actor; the connection - // could still have churned. Only mutate live state if the captured container - // is still authoritative. - guard self.services === expectedServices, self.connectedDevice?.id == deviceID else { - logger.info("reconcileIdentity: state churned after DB save; skipping in-memory refresh") - return newRadioID - } - if let refreshed = try? await expectedServices.dataStore.fetchDevice(id: deviceID), - self.services === expectedServices, - self.connectedDevice?.id == deviceID { - self.connectedDevice = refreshed - persistConnection( - deviceID: refreshed.id, - radioID: refreshed.radioID, - deviceName: refreshed.nodeName - ) - } + // Hydrate the chat send queue before exposing the container so + // view-model `configure` calls see a hydrated service from the + // first read. + await newServices.chatSendQueueService.hydrate() + + // Restore `self.session` together with `self.services`: an interleaved + // `handleConnectionLoss` nils both atomically, and `promoteToReady`'s + // state invariants require both. + self.session = session + services = newServices + + connectedDevice = deviceDTO + allowedRepeatFreqRanges = repeatFreqRanges + + return (newServices, deviceDTO.radioID) + } + + // MARK: - Ready Promotion + + /// Promotes connection to `.ready` if the connection is still alive and owned by the expected services. + /// Skips post-sync work (time sync, onDeviceSynced) when sync failed to avoid BLE pressure. + /// Returns `true` if `.ready` was set, `false` if promotion was suppressed. + /// + /// - Parameter additionalGuard: Caller-specific invariant checked at every guard point, + /// including after async operations like `syncDeviceTimeIfNeeded()`. This cannot be an + /// inline check at the call site because the invariant must hold both before and after + /// the internal awaits — a competing reconnect cycle could start during time sync, + /// and promoting a stale session to `.ready` would shadow the new one. + /// Currently only `rebuildSession` uses this (reconnect-generation check). + @discardableResult + func promoteToReady( + syncSucceeded: Bool, + expectedServices: ServiceContainer, + transportType: TransportType, + additionalGuard: (() -> Bool)? = nil + ) async -> Bool { + guard connectionIntent.wantsConnection else { + logger.warning("Promotion suppressed: user disconnected") + return false + } + guard services === expectedServices else { + logger.warning("Promotion suppressed: services replaced or nil") + return false + } + guard additionalGuard?() ?? true else { + logger.warning("Promotion suppressed: caller guard failed (e.g. reconnect generation)") + return false + } - logger.info("Reconciled identity to ghost radioID after key import: \(newRadioID)") - return newRadioID + currentTransportType = transportType + connectionState = syncSucceeded ? .ready : .syncing + surfacedAuthFailureDeviceID = nil + + // Skip time sync on BLE failure to avoid pressure on a saturated link. + // WiFi/TCP has no such constraint, so always correct the clock there. + if syncSucceeded || transportType == .wifi { + await syncDeviceTimeIfNeeded() + guard connectionIntent.wantsConnection else { + logger.warning("Promotion suppressed after time sync: user disconnected") + return false + } + guard services === expectedServices else { + logger.warning("Promotion suppressed after time sync: services replaced or nil") + return false + } + guard additionalGuard?() ?? true else { + logger.warning("Promotion suppressed after time sync: caller guard failed (e.g. reconnect generation)") + return false + } } - /// Syncs the device clock if it drifts more than 60 seconds from the phone. - /// Safe to call after sync — only affects future device-originated timestamps. - func syncDeviceTimeIfNeeded() async { - guard let session else { return } - do { - let deviceTime = try await withTimeout(.seconds(5), operationName: "getTime") { - try await session.getTime() - } - let timeDifference = abs(deviceTime.timeIntervalSinceNow) - if timeDifference > 60 { - try await withTimeout(.seconds(5), operationName: "setTime") { - try await session.setTime(Date()) - } - logger.info("Synced device time (was off by \(Int(timeDifference))s)") - } else { - logger.info("Device time in sync (drift: \(Int(timeDifference))s)") - } - } catch { - logger.warning("Failed to sync device time: \(error.localizedDescription)") - } + if syncSucceeded { await onDeviceSynced?() } + return true + } + + /// Re-evaluates the connected device's identity after `NodeConfigService.importIdentity` + /// has restored a privateKey on the radio. If the radio is now reporting a different + /// `publicKey` than the local Device row, attempts to reconcile against any ghost + /// carrying that publicKey (left by a prior "remove from MC1"). + /// + /// Mirrors the `promoteToReady` lifecycle pattern: takes the `ServiceContainer` + /// the caller expects to still own, plus the `deviceID` it expects to still be + /// connected to. Bails out (returns `nil`) if either invariant fails before or + /// after the `currentSelfInfo` await — a competing reconnect cycle could otherwise + /// reconcile state onto the wrong connection. + /// + /// - Parameters: + /// - expectedServices: The `ServiceContainer` captured at the start of the + /// config-import operation. If `self.services` no longer points to it, the + /// reconcile is silently skipped. + /// - deviceID: The BLE peripheral UUID the import was started against. + /// - Returns: The new `radioID` if reconciliation reassigned the device, + /// otherwise `nil` (no publicKey change, no matching ghost, or a guard tripped). + @discardableResult + public func reconcileIdentity( + expectedServices: ServiceContainer, + deviceID: UUID + ) async throws -> UUID? { + guard services === expectedServices else { + logger.info("reconcileIdentity skipped: services replaced before currentSelfInfo") + return nil + } + guard let preDevice = connectedDevice, preDevice.id == deviceID else { + logger.info("reconcileIdentity skipped: connectedDevice changed before currentSelfInfo") + return nil } - /// Creates a Device from MeshCore types - /// - /// `radioID` is the resolved partition UUID for this device. Callers in - /// the connect path must pass the same UUID they used to construct - /// `ServiceContainer(radioID:)` so the chat send queue's `PendingSend` - /// scope and the persisted `Device.radioID` cannot diverge on first pair. - /// A bare `?? UUID()` fallback here would mint a second UUID that the - /// container would never see. - func createDevice( - deviceID: UUID, - radioID: UUID, - selfInfo: MeshCore.SelfInfo, - capabilities: MeshCore.DeviceCapabilities, - autoAddConfig: MeshCore.AutoAddConfig, - existingDevice: DeviceDTO? = nil, - connectionMethods: [ConnectionMethod] = [] - ) -> Device { - // Merge new connection methods with existing ones, replacing by transport type - var mergedMethods = existingDevice?.connectionMethods ?? [] - for method in connectionMethods { - if method.isWiFi { - mergedMethods.removeAll { $0.isWiFi } - } else if method.isBluetooth { - mergedMethods.removeAll { $0.isBluetooth } - } - mergedMethods.append(method) - } + let selfInfo: MeshCore.SelfInfo + do { + guard let info = await expectedServices.session.currentSelfInfo else { + logger.warning("reconcileIdentity failed: session.currentSelfInfo is nil") + return nil + } + selfInfo = info + } - let device = Device( - id: deviceID, - radioID: radioID, - publicKey: selfInfo.publicKey, - nodeName: selfInfo.name, - firmwareVersion: capabilities.firmwareVersion, - firmwareVersionString: capabilities.version, - manufacturerName: capabilities.model, - buildDate: capabilities.firmwareBuild, - maxContacts: UInt16(capabilities.maxContacts), - maxChannels: UInt8(min(capabilities.maxChannels, 255)), - frequency: UInt32(selfInfo.radioFrequency * 1000), // Convert MHz to kHz - bandwidth: UInt32(selfInfo.radioBandwidth * 1000), // Convert kHz to Hz - spreadingFactor: selfInfo.radioSpreadingFactor, - codingRate: selfInfo.radioCodingRate, - txPower: selfInfo.txPower, - maxTxPower: selfInfo.maxTxPower, - latitude: selfInfo.latitude, - longitude: selfInfo.longitude, - blePin: capabilities.blePin, - clientRepeat: capabilities.clientRepeat, - pathHashMode: capabilities.pathHashMode, - defaultFloodScopeName: existingDevice?.defaultFloodScopeName, - preRepeatFrequency: existingDevice?.preRepeatFrequency, - preRepeatBandwidth: existingDevice?.preRepeatBandwidth, - preRepeatSpreadingFactor: existingDevice?.preRepeatSpreadingFactor, - preRepeatCodingRate: existingDevice?.preRepeatCodingRate, - manualAddContacts: selfInfo.manualAddContacts, - autoAddConfig: autoAddConfig.bitmask, - autoAddMaxHops: autoAddConfig.maxHops, - multiAcks: selfInfo.multiAcks, - telemetryModeBase: selfInfo.telemetryModeBase, - telemetryModeLoc: selfInfo.telemetryModeLocation, - telemetryModeEnv: selfInfo.telemetryModeEnvironment, - advertLocationPolicy: selfInfo.advertisementLocationPolicy, - lastConnected: Date(), - lastContactSync: existingDevice?.lastContactSync ?? 0, - isActive: true, - ocvPreset: existingDevice?.ocvPreset - ?? OCVPreset.preset(forManufacturer: capabilities.model)?.rawValue, - customOCVArrayString: existingDevice?.customOCVArrayString, - connectionMethods: mergedMethods, - knownRegions: existingDevice?.knownRegions ?? [] - ) + guard services === expectedServices else { + logger.info("reconcileIdentity skipped: services replaced after currentSelfInfo") + return nil + } + guard connectedDevice?.id == deviceID else { + logger.info("reconcileIdentity skipped: connectedDevice changed after currentSelfInfo") + return nil + } - // If repeat mode was disabled externally, clear orphaned pre-repeat settings - if !capabilities.clientRepeat && existingDevice?.preRepeatFrequency != nil { - device.preRepeatFrequency = nil - device.preRepeatBandwidth = nil - device.preRepeatSpreadingFactor = nil - device.preRepeatCodingRate = nil - } + // No publicKey-equality short-circuit: after a partial-import + app restart, + // `buildServicesAndSaveDevice` finds the Device by BLE UUID and overwrites + // `Device.publicKey` with the restored key via `Device.apply(dto:)`. On the + // user's retry, `selfInfo.publicKey == connectedDevice.publicKey` even though + // `radioID` is still stale. Always ask `reconcileGhostIdentity` — its + // predicate handles the no-ghost case by returning nil, so the cost of an + // unconditional query is one cheap DB lookup per import. + + let newRadioID: UUID? + do { + newRadioID = try await expectedServices.dataStore.reconcileGhostIdentity( + currentDeviceID: deviceID, + newPublicKey: selfInfo.publicKey + ) + } catch { + logger.warning("reconcileIdentity: reconcileGhostIdentity failed: \(error.localizedDescription)") + return nil + } - return device + guard let newRadioID else { + logger.info("reconcileIdentity: publicKey changed but no ghost matched") + return nil } - /// Configures BLE write pacing based on detected device platform. - /// - Parameter capabilities: The device capabilities from queryDevice() - func configureBLEPacing(for capabilities: MeshCore.DeviceCapabilities) async { - detectAndStorePlatform(model: capabilities.model, transportType: .bluetooth) - let pacing = detectedPlatform.recommendedWritePacing - await stateMachine.setWritePacingDelay(pacing) - if pacing > 0 { - logger.info("[BLE] Platform detected: \(capabilities.model) -> \(detectedPlatform), write pacing: \(pacing)s") - } + // Final guard: the DB save just ran on a background actor; the connection + // could still have churned. Only mutate live state if the captured container + // is still authoritative. + guard services === expectedServices, connectedDevice?.id == deviceID else { + logger.info("reconcileIdentity: state churned after DB save; skipping in-memory refresh") + return newRadioID + } + if let refreshed = try? await expectedServices.dataStore.fetchDevice(id: deviceID), + services === expectedServices, + connectedDevice?.id == deviceID { + connectedDevice = refreshed + persistConnection( + deviceID: refreshed.id, + radioID: refreshed.radioID, + deviceName: refreshed.nodeName + ) } - /// Detects and stores the device platform from its model string, used for - /// channel-sync throttling and (over BLE) write pacing. - /// - /// A WiFi radio is always ESP32-class — no nRF52 part ships a WiFi radio, and the - /// WiFi companion firmware is ESP32-only — so an unrecognized model on WiFi resolves - /// to `.esp32` rather than `.unknown`. Without this, WiFi radios would keep the - /// no-skip `.unknown` config and re-read all channels on every sync. BLE keeps the - /// conservative `.unknown` fallback, which drives its ESP32-safe write pacing. - /// - /// Lives here rather than in the BLE/WiFi extensions because `detectedPlatform` has a - /// `private(set)` setter that only this file can write. - func detectAndStorePlatform(model: String, transportType: TransportType) { - var platform = DevicePlatform.detect(from: model) - if transportType == .wifi, platform == .unknown { - platform = .esp32 - } - detectedPlatform = platform - if transportType == .wifi { - logger.info("[WiFi] Platform detected: \(model) -> \(platform)") + logger.info("Reconciled identity to ghost radioID after key import: \(newRadioID)") + return newRadioID + } + + /// Syncs the device clock if it drifts more than 60 seconds from the phone. + /// Safe to call after sync — only affects future device-originated timestamps. + func syncDeviceTimeIfNeeded() async { + guard let session else { return } + do { + let deviceTime = try await withTimeout(.seconds(5), operationName: "getTime") { + try await session.getTime() + } + let timeDifference = abs(deviceTime.timeIntervalSinceNow) + if timeDifference > 60 { + try await withTimeout(.seconds(5), operationName: "setTime") { + try await session.setTime(Date()) } + logger.info("Synced device time (was off by \(Int(timeDifference))s)") + } else { + logger.info("Device time in sync (drift: \(Int(timeDifference))s)") + } + } catch { + logger.warning("Failed to sync device time: \(error.localizedDescription)") } - - // MARK: - Cleanup - - /// Cleans up session and services without changing connection state (used during retries) - func cleanupResources() async { - await session?.stop() - await services?.tearDown() - session = nil - services = nil + } + + /// Creates a Device from MeshCore types + /// + /// `radioID` is the resolved partition UUID for this device. Callers in + /// the connect path must pass the same UUID they used to construct + /// `ServiceContainer(radioID:)` so the chat send queue's `PendingSend` + /// scope and the persisted `Device.radioID` cannot diverge on first pair. + /// A bare `?? UUID()` fallback here would mint a second UUID that the + /// container would never see. + func createDevice( + deviceID: UUID, + radioID: UUID, + selfInfo: MeshCore.SelfInfo, + capabilities: MeshCore.DeviceCapabilities, + autoAddConfig: MeshCore.AutoAddConfig, + existingDevice: DeviceDTO? = nil, + connectionMethods: [ConnectionMethod] = [] + ) -> Device { + // Merge new connection methods with existing ones, replacing by transport type + var mergedMethods = existingDevice?.connectionMethods ?? [] + for method in connectionMethods { + if method.isWiFi { + mergedMethods.removeAll { $0.isWiFi } + } else if method.isBluetooth { + mergedMethods.removeAll { $0.isBluetooth } + } + mergedMethods.append(method) } - /// Full cleanup including state reset (used on explicit disconnect) - func cleanupConnection() async { - logger.info("[BLE] cleanupConnection: state → .disconnected") - connectionState = .disconnected - connectingDeviceID = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - await cleanupResources() + let device = Device( + id: deviceID, + radioID: radioID, + publicKey: selfInfo.publicKey, + nodeName: selfInfo.name, + firmwareVersion: capabilities.firmwareVersion, + firmwareVersionString: capabilities.version, + manufacturerName: capabilities.model, + buildDate: capabilities.firmwareBuild, + maxContacts: UInt16(capabilities.maxContacts), + maxChannels: UInt8(min(capabilities.maxChannels, 255)), + frequency: UInt32(selfInfo.radioFrequency * 1000), // Convert MHz to kHz + bandwidth: UInt32(selfInfo.radioBandwidth * 1000), // Convert kHz to Hz + spreadingFactor: selfInfo.radioSpreadingFactor, + codingRate: selfInfo.radioCodingRate, + txPower: selfInfo.txPower, + maxTxPower: selfInfo.maxTxPower, + latitude: selfInfo.latitude, + longitude: selfInfo.longitude, + blePin: capabilities.blePin, + clientRepeat: capabilities.clientRepeat, + pathHashMode: capabilities.pathHashMode, + defaultFloodScopeName: existingDevice?.defaultFloodScopeName, + preRepeatFrequency: existingDevice?.preRepeatFrequency, + preRepeatBandwidth: existingDevice?.preRepeatBandwidth, + preRepeatSpreadingFactor: existingDevice?.preRepeatSpreadingFactor, + preRepeatCodingRate: existingDevice?.preRepeatCodingRate, + manualAddContacts: selfInfo.manualAddContacts, + autoAddConfig: autoAddConfig.bitmask, + autoAddMaxHops: autoAddConfig.maxHops, + multiAcks: selfInfo.multiAcks, + telemetryModeBase: selfInfo.telemetryModeBase, + telemetryModeLoc: selfInfo.telemetryModeLocation, + telemetryModeEnv: selfInfo.telemetryModeEnvironment, + advertLocationPolicy: selfInfo.advertisementLocationPolicy, + lastConnected: Date(), + lastContactSync: existingDevice?.lastContactSync ?? 0, + isActive: true, + ocvPreset: existingDevice?.ocvPreset + ?? OCVPreset.preset(forManufacturer: capabilities.model)?.rawValue, + customOCVArrayString: existingDevice?.customOCVArrayString, + connectionMethods: mergedMethods, + knownRegions: existingDevice?.knownRegions ?? [] + ) + + // If repeat mode was disabled externally, clear orphaned pre-repeat settings + if !capabilities.clientRepeat, existingDevice?.preRepeatFrequency != nil { + device.preRepeatFrequency = nil + device.preRepeatBandwidth = nil + device.preRepeatSpreadingFactor = nil + device.preRepeatCodingRate = nil } - func persistDisconnectDiagnostic(_ summary: String) { - lastConnectionStore.persistDisconnectDiagnostic(summary) + return device + } + + /// Configures BLE write pacing based on detected device platform. + /// - Parameter capabilities: The device capabilities from queryDevice() + func configureBLEPacing(for capabilities: MeshCore.DeviceCapabilities) async { + detectAndStorePlatform(model: capabilities.model, transportType: .bluetooth) + let pacing = detectedPlatform.recommendedWritePacing + await stateMachine.setWritePacingDelay(pacing) + if pacing > 0 { + logger.info("[BLE] Platform detected: \(capabilities.model) -> \(detectedPlatform), write pacing: \(pacing)s") } - - func persistIntent() { - connectionIntent.persist(to: defaults) + } + + /// Detects and stores the device platform from its model string, used for + /// channel-sync throttling and (over BLE) write pacing. + /// + /// A WiFi radio is always ESP32-class — no nRF52 part ships a WiFi radio, and the + /// WiFi companion firmware is ESP32-only — so an unrecognized model on WiFi resolves + /// to `.esp32` rather than `.unknown`. Without this, WiFi radios would keep the + /// no-skip `.unknown` config and re-read all channels on every sync. BLE keeps the + /// conservative `.unknown` fallback, which drives its ESP32-safe write pacing. + /// + /// Lives here rather than in the BLE/WiFi extensions because `detectedPlatform` has a + /// `private(set)` setter that only this file can write. + func detectAndStorePlatform(model: String, transportType: TransportType) { + var platform = DevicePlatform.detect(from: model) + if transportType == .wifi, platform == .unknown { + platform = .esp32 } - - // MARK: - State Invariants - - #if DEBUG + detectedPlatform = platform + if transportType == .wifi { + logger.info("[WiFi] Platform detected: \(model) -> \(platform)") + } + } + + // MARK: - Cleanup + + /// Cleans up session and services without changing connection state (used during retries) + func cleanupResources() async { + // stop() at its default disconnects the transport — the explicit-disconnect + // and device-switch paths rely on this call to sever the link. + await session?.stop() + await services?.tearDown() + session = nil + services = nil + } + + /// Full cleanup including state reset (used on explicit disconnect) + func cleanupConnection() async { + logger.info("[BLE] cleanupConnection: state → .disconnected") + connectionState = .disconnected + connectingDeviceID = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + await cleanupResources() + } + + func persistDisconnectDiagnostic(_ summary: String) { + lastConnectionStore.persistDisconnectDiagnostic(summary) + } + + func persistIntent() { + connectionIntent.persist(to: defaults) + } + + // MARK: - State Invariants + + #if DEBUG private var suppressInvariantChecks = false private func assertStateInvariants() { - guard !suppressInvariantChecks else { return } - switch connectionState { - case .ready, .syncing: - assert(services != nil, "Invariant: \(connectionState) requires services") - assert(session != nil, "Invariant: \(connectionState) requires session") - assert(connectedDevice != nil, "Invariant: \(connectionState) requires connectedDevice") - case .connected, .disconnected, .connecting: - break - } - if connectionIntent.isUserDisconnected { - assert(connectionState == .disconnected, "Invariant: .userDisconnected requires .disconnected state") - } + guard !suppressInvariantChecks else { return } + switch connectionState { + case .ready, .syncing: + assert(services != nil, "Invariant: \(connectionState) requires services") + assert(session != nil, "Invariant: \(connectionState) requires session") + assert(connectedDevice != nil, "Invariant: \(connectionState) requires connectedDevice") + case .connected, .disconnected, .connecting: + break + } + if connectionIntent.isUserDisconnected { + assert(connectionState == .disconnected, "Invariant: .userDisconnected requires .disconnected state") + } } - #endif + #endif - // MARK: - Test Helpers + // MARK: - Test Helpers + + #if DEBUG + /// Sets the circuit breaker to `.open` with a chosen timestamp so tests can + /// cross the cooldown boundary without waiting out the real cooldown. + func setCircuitBreakerOpenForTesting(since: Date) { + circuitBreaker = .open(since: since) + } - #if DEBUG /// Sets internal state for testing. Only available in DEBUG builds. - internal func setTestState( - connectionState: DeviceConnectionState? = nil, - services: ServiceContainer?? = nil, - session: MeshCoreSession?? = nil, - connectedDevice: DeviceDTO?? = nil, - currentTransportType: TransportType?? = nil, - connectionIntent: ConnectionIntent? = nil, - connectingDeviceID: UUID?? = nil, - sessionRebuildDeviceID: UUID?? = nil, - isPairingInProgress: Bool? = nil, - detectedPlatform: DevicePlatform? = nil, - lastCleanChannelSync: (radioID: UUID, completedAt: Date)?? = nil, - lastAttemptedChannelSync: (radioID: UUID, attemptedAt: Date)?? = nil + func setTestState( + connectionState: DeviceConnectionState? = nil, + services: ServiceContainer?? = nil, + session: MeshCoreSession?? = nil, + connectedDevice: DeviceDTO?? = nil, + currentTransportType: TransportType?? = nil, + connectionIntent: ConnectionIntent? = nil, + connectingDeviceID: UUID?? = nil, + sessionRebuildDeviceID: UUID?? = nil, + isPairingInProgress: Bool? = nil, + detectedPlatform: DevicePlatform? = nil, + lastCleanChannelSync: (radioID: UUID, completedAt: Date)?? = nil, + lastAttemptedChannelSync: (radioID: UUID, attemptedAt: Date)?? = nil ) { - suppressInvariantChecks = true - defer { suppressInvariantChecks = false } - - if let state = connectionState { - self.connectionState = state - } - if let svc = services { - self.services = svc - } - if let sess = session { - self.session = sess - } - if let device = connectedDevice { - self.connectedDevice = device - } - if let transport = currentTransportType { - self.currentTransportType = transport - } - if let intent = connectionIntent { - self.connectionIntent = intent - } - if let deviceID = connectingDeviceID { - self.connectingDeviceID = deviceID - } - if let deviceID = sessionRebuildDeviceID { - self.sessionRebuildDeviceID = deviceID - } - if let pairing = isPairingInProgress { - self.isPairingInProgress = pairing - } - if let platform = detectedPlatform { - self.detectedPlatform = platform - } - if let cleanSync = lastCleanChannelSync { - self.lastCleanChannelSync = cleanSync - } - if let attemptedSync = lastAttemptedChannelSync { - self.lastAttemptedChannelSync = attemptedSync - } + suppressInvariantChecks = true + defer { suppressInvariantChecks = false } + + if let state = connectionState { + self.connectionState = state + } + if let svc = services { + self.services = svc + } + if let sess = session { + self.session = sess + } + if let device = connectedDevice { + self.connectedDevice = device + } + if let transport = currentTransportType { + self.currentTransportType = transport + } + if let intent = connectionIntent { + self.connectionIntent = intent + } + if let deviceID = connectingDeviceID { + self.connectingDeviceID = deviceID + } + if let deviceID = sessionRebuildDeviceID { + self.sessionRebuildDeviceID = deviceID + } + if let pairing = isPairingInProgress { + self.isPairingInProgress = pairing + } + if let platform = detectedPlatform { + self.detectedPlatform = platform + } + if let cleanSync = lastCleanChannelSync { + self.lastCleanChannelSync = cleanSync + } + if let attemptedSync = lastAttemptedChannelSync { + self.lastAttemptedChannelSync = attemptedSync + } } - #endif + #endif } diff --git a/MC1Services/Sources/MC1Services/Connection/DeviceConnectionState.swift b/MC1Services/Sources/MC1Services/Connection/DeviceConnectionState.swift index a2f8abe5..7cbcf5ba 100644 --- a/MC1Services/Sources/MC1Services/Connection/DeviceConnectionState.swift +++ b/MC1Services/Sources/MC1Services/Connection/DeviceConnectionState.swift @@ -5,31 +5,31 @@ /// transport-link state the session publishes; `ConnectionManager` translates /// transport events into the rung modeled here. public enum DeviceConnectionState: Sendable { - case disconnected - case connecting - case connected - case syncing - case ready + case disconnected + case connecting + case connected + case syncing + case ready - /// True when session and services are available and the transport is alive. - /// Used by internal infrastructure (resync loop, health checks, heartbeat). - /// UI code should check `== .ready` to gate user interactions. - public var isOperational: Bool { - self == .syncing || self == .ready - } + /// True when session and services are available and the transport is alive. + /// Used by internal infrastructure (resync loop, health checks, heartbeat). + /// UI code should check `== .ready` to gate user interactions. + public var isOperational: Bool { + self == .syncing || self == .ready + } - /// True when a transport link is established (session may or may not be synced). - public var isConnected: Bool { - switch self { - case .connected, .syncing, .ready: true - case .disconnected, .connecting: false - } + /// True when a transport link is established (session may or may not be synced). + public var isConnected: Bool { + switch self { + case .connected, .syncing, .ready: true + case .disconnected, .connecting: false } + } - /// True only once initial sync has cleared, so the chat send queue may drain without - /// contending with sync's contact/channel/message reads on the radio's link. `.connected` - /// (link up, sync not yet run) deliberately excludes the queue from the vulnerable window. - public var canDrainSendQueue: Bool { - self == .ready - } + /// True only once initial sync has cleared, so the chat send queue may drain without + /// contending with sync's contact/channel/message reads on the radio's link. `.connected` + /// (link up, sync not yet run) deliberately excludes the queue from the vulnerable window. + public var canDrainSendQueue: Bool { + self == .ready + } } diff --git a/MC1Services/Sources/MC1Services/Connection/DevicePlatform.swift b/MC1Services/Sources/MC1Services/Connection/DevicePlatform.swift index 38ebc3e6..6e3c9876 100644 --- a/MC1Services/Sources/MC1Services/Connection/DevicePlatform.swift +++ b/MC1Services/Sources/MC1Services/Connection/DevicePlatform.swift @@ -1,94 +1,94 @@ import Foundation /// Device platform type for BLE write pacing configuration -enum DevicePlatform: Sendable { - case esp32 - case nrf52 - case unknown +enum DevicePlatform { + case esp32 + case nrf52 + case unknown - /// Recommended write pacing delay for this platform - var recommendedWritePacing: TimeInterval { - switch self { - case .esp32: return 0.060 // 60ms required by ESP32 BLE stack - case .nrf52: return 0.025 // Light pacing to avoid RX queue pressure - case .unknown: return 0.060 // Conservative ESP32-safe default for unrecognized devices - } + /// Recommended write pacing delay for this platform + var recommendedWritePacing: TimeInterval { + switch self { + case .esp32: 0.060 // 60ms required by ESP32 BLE stack + case .nrf52: 0.025 // Light pacing to avoid RX queue pressure + case .unknown: 0.060 // Conservative ESP32-safe default for unrecognized devices } + } - /// Detects the device platform from the model string for BLE write pacing. - /// - /// Uses specific model substrings rather than vendor prefixes, because vendors like - /// Heltec, RAK, Seeed, and Elecrow ship devices on multiple chip families. - /// Unrecognized devices fall to `.unknown` (conservative 60ms pacing). - static func detect(from model: String) -> DevicePlatform { - for rule in platformRules { - if model.localizedStandardContains(rule.substring) { - return rule.platform - } - } - return .unknown + /// Detects the device platform from the model string for BLE write pacing. + /// + /// Uses specific model substrings rather than vendor prefixes, because vendors like + /// Heltec, RAK, Seeed, and Elecrow ship devices on multiple chip families. + /// Unrecognized devices fall to `.unknown` (conservative 60ms pacing). + static func detect(from model: String) -> DevicePlatform { + for rule in platformRules { + if model.localizedStandardContains(rule.substring) { + return rule.platform + } } + return .unknown + } - // Ordering matters: first match wins in detect(from:). More specific patterns must precede general ones within each platform group. - private static let platformRules: [(substring: String, platform: DevicePlatform)] = [ - // ESP32 — Heltec - ("Heltec V2", .esp32), - ("Heltec V3", .esp32), - ("Heltec V4", .esp32), - ("Heltec Tracker", .esp32), - ("Heltec E290", .esp32), - ("Heltec E213", .esp32), - ("Heltec T190", .esp32), - ("Heltec CT62", .esp32), - // ESP32 — LilyGo - ("T-Beam", .esp32), - ("T-Deck", .esp32), - ("T-LoRa", .esp32), - ("TLora", .esp32), - // ESP32 — Seeed - ("Xiao S3 WIO", .esp32), - ("Xiao C3", .esp32), - ("Xiao C6", .esp32), - // ESP32 — RAK - ("RAK 3112", .esp32), - // ESP32 — M5Stack - ("Unit C6L", .esp32), - // ESP32 — Other - ("Station G2", .esp32), - ("Meshadventurer", .esp32), - ("Generic ESP32", .esp32), - ("ThinkNode M2", .esp32), - ("ThinkNode M5", .esp32), - // nRF52 — Heltec - ("MeshPocket", .nrf52), - ("Mesh Pocket", .nrf52), - ("T114", .nrf52), - ("Mesh Solar", .nrf52), - // nRF52 — Seeed - ("Xiao-nrf52", .nrf52), - ("Xiao_nrf52", .nrf52), - ("WM1110", .nrf52), - ("Wio Tracker", .nrf52), - ("T1000-E", .nrf52), - ("SenseCap Solar", .nrf52), - // nRF52 — RAK - ("WisMesh Tag", .nrf52), - ("RAK 4631", .nrf52), - ("RAK 3401", .nrf52), - // nRF52 — LilyGo - ("T-Echo", .nrf52), - // nRF52 — Elecrow - ("ThinkNode-M1", .nrf52), - ("ThinkNode M3", .nrf52), - ("ThinkNode-M6", .nrf52), - // nRF52 — GAT562 - ("GAT562", .nrf52), - // nRF52 — Other - ("Ikoka", .nrf52), - ("ProMicro", .nrf52), - ("Minewsemi", .nrf52), - ("Meshtiny", .nrf52), - ("Keepteen", .nrf52), - ("Nano G2 Ultra", .nrf52), - ] + /// Ordering matters: first match wins in detect(from:). More specific patterns must precede general ones within each platform group. + private static let platformRules: [(substring: String, platform: DevicePlatform)] = [ + // ESP32 — Heltec + ("Heltec V2", .esp32), + ("Heltec V3", .esp32), + ("Heltec V4", .esp32), + ("Heltec Tracker", .esp32), + ("Heltec E290", .esp32), + ("Heltec E213", .esp32), + ("Heltec T190", .esp32), + ("Heltec CT62", .esp32), + // ESP32 — LilyGo + ("T-Beam", .esp32), + ("T-Deck", .esp32), + ("T-LoRa", .esp32), + ("TLora", .esp32), + // ESP32 — Seeed + ("Xiao S3 WIO", .esp32), + ("Xiao C3", .esp32), + ("Xiao C6", .esp32), + // ESP32 — RAK + ("RAK 3112", .esp32), + // ESP32 — M5Stack + ("Unit C6L", .esp32), + // ESP32 — Other + ("Station G2", .esp32), + ("Meshadventurer", .esp32), + ("Generic ESP32", .esp32), + ("ThinkNode M2", .esp32), + ("ThinkNode M5", .esp32), + // nRF52 — Heltec + ("MeshPocket", .nrf52), + ("Mesh Pocket", .nrf52), + ("T114", .nrf52), + ("Mesh Solar", .nrf52), + // nRF52 — Seeed + ("Xiao-nrf52", .nrf52), + ("Xiao_nrf52", .nrf52), + ("WM1110", .nrf52), + ("Wio Tracker", .nrf52), + ("T1000-E", .nrf52), + ("SenseCap Solar", .nrf52), + // nRF52 — RAK + ("WisMesh Tag", .nrf52), + ("RAK 4631", .nrf52), + ("RAK 3401", .nrf52), + // nRF52 — LilyGo + ("T-Echo", .nrf52), + // nRF52 — Elecrow + ("ThinkNode-M1", .nrf52), + ("ThinkNode M3", .nrf52), + ("ThinkNode-M6", .nrf52), + // nRF52 — GAT562 + ("GAT562", .nrf52), + // nRF52 — Other + ("Ikoka", .nrf52), + ("ProMicro", .nrf52), + ("Minewsemi", .nrf52), + ("Meshtiny", .nrf52), + ("Keepteen", .nrf52), + ("Nano G2 Ultra", .nrf52), + ] } diff --git a/MC1Services/Sources/MC1Services/Connection/DisconnectReason.swift b/MC1Services/Sources/MC1Services/Connection/DisconnectReason.swift index d0a6e747..53a758e9 100644 --- a/MC1Services/Sources/MC1Services/Connection/DisconnectReason.swift +++ b/MC1Services/Sources/MC1Services/Connection/DisconnectReason.swift @@ -2,14 +2,14 @@ import Foundation /// Reasons for disconnecting from a device (for debugging) public enum DisconnectReason: String, Sendable { - case userInitiated = "user initiated disconnect" - case statusMenuDisconnectTap = "status menu disconnect tapped" - case switchingDevice = "switching to new device" - case factoryReset = "device factory reset" - case wifiAddressChange = "WiFi address changed" - case resyncFailed = "resync failed after 3 attempts" - case forgetDevice = "user forgot device" - case deviceRemovedFromSettings = "device removed from iOS Settings" - case pairingFailed = "device pairing failed" - case wifiReconnectPrep = "preparing for WiFi reconnect" + case userInitiated = "user initiated disconnect" + case statusMenuDisconnectTap = "status menu disconnect tapped" + case switchingDevice = "switching to new device" + case factoryReset = "device factory reset" + case wifiAddressChange = "WiFi address changed" + case resyncFailed = "resync failed after 3 attempts" + case forgetDevice = "user forgot device" + case deviceRemovedFromSettings = "device removed from iOS Settings" + case pairingFailed = "device pairing failed" + case wifiReconnectPrep = "preparing for WiFi reconnect" } diff --git a/MC1Services/Sources/MC1Services/Connection/LastConnectionStore.swift b/MC1Services/Sources/MC1Services/Connection/LastConnectionStore.swift index 8f0971fd..eb377c50 100644 --- a/MC1Services/Sources/MC1Services/Connection/LastConnectionStore.swift +++ b/MC1Services/Sources/MC1Services/Connection/LastConnectionStore.swift @@ -9,56 +9,55 @@ import Foundation /// `ConnectionIntent` persistence is deliberately separate: intent is the /// "does the user want to be connected" axis, not last-device state. struct LastConnectionStore { - - private let defaults: UserDefaults - - init(defaults: UserDefaults) { - self.defaults = defaults - } - - /// The last connected device ID (for auto-reconnect). - var deviceID: UUID? { - guard let uuidString = defaults.string(forKey: PersistenceKeys.lastConnectedDeviceID) else { - return nil - } - return UUID(uuidString: uuidString) - } - - /// The last connected radio ID (for offline data scoping). - var radioID: UUID? { - guard let uuidString = defaults.string(forKey: PersistenceKeys.lastConnectedRadioID) else { - return nil - } - return UUID(uuidString: uuidString) - } - - /// The last connected device name (for offline display when disconnected). - var deviceName: String? { - defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) - } - - /// Records a successful connection for future restoration. - func persist(deviceID: UUID, radioID: UUID, deviceName: String) { - defaults.set(deviceID.uuidString, forKey: PersistenceKeys.lastConnectedDeviceID) - defaults.set(radioID.uuidString, forKey: PersistenceKeys.lastConnectedRadioID) - defaults.set(deviceName, forKey: PersistenceKeys.lastConnectedDeviceName) - } - - /// Clears the persisted connection record. - func clear() { - defaults.removeObject(forKey: PersistenceKeys.lastConnectedDeviceID) - defaults.removeObject(forKey: PersistenceKeys.lastConnectedRadioID) - defaults.removeObject(forKey: PersistenceKeys.lastConnectedDeviceName) - } - - /// Most recent disconnect diagnostic summary persisted across app launches. - var disconnectDiagnostic: String? { - defaults.string(forKey: PersistenceKeys.lastDisconnectDiagnostic) - } - - /// Persists a disconnect diagnostic prefixed with the current ISO8601 timestamp. - func persistDisconnectDiagnostic(_ summary: String) { - let timestamp = Date().ISO8601Format() - defaults.set("\(timestamp) \(summary)", forKey: PersistenceKeys.lastDisconnectDiagnostic) - } + private let defaults: UserDefaults + + init(defaults: UserDefaults) { + self.defaults = defaults + } + + /// The last connected device ID (for auto-reconnect). + var deviceID: UUID? { + guard let uuidString = defaults.string(forKey: PersistenceKeys.lastConnectedDeviceID) else { + return nil + } + return UUID(uuidString: uuidString) + } + + /// The last connected radio ID (for offline data scoping). + var radioID: UUID? { + guard let uuidString = defaults.string(forKey: PersistenceKeys.lastConnectedRadioID) else { + return nil + } + return UUID(uuidString: uuidString) + } + + /// The last connected device name (for offline display when disconnected). + var deviceName: String? { + defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) + } + + /// Records a successful connection for future restoration. + func persist(deviceID: UUID, radioID: UUID, deviceName: String) { + defaults.set(deviceID.uuidString, forKey: PersistenceKeys.lastConnectedDeviceID) + defaults.set(radioID.uuidString, forKey: PersistenceKeys.lastConnectedRadioID) + defaults.set(deviceName, forKey: PersistenceKeys.lastConnectedDeviceName) + } + + /// Clears the persisted connection record. + func clear() { + defaults.removeObject(forKey: PersistenceKeys.lastConnectedDeviceID) + defaults.removeObject(forKey: PersistenceKeys.lastConnectedRadioID) + defaults.removeObject(forKey: PersistenceKeys.lastConnectedDeviceName) + } + + /// Most recent disconnect diagnostic summary persisted across app launches. + var disconnectDiagnostic: String? { + defaults.string(forKey: PersistenceKeys.lastDisconnectDiagnostic) + } + + /// Persists a disconnect diagnostic prefixed with the current ISO8601 timestamp. + func persistDisconnectDiagnostic(_ summary: String) { + let timestamp = Date().ISO8601Format() + defaults.set("\(timestamp) \(summary)", forKey: PersistenceKeys.lastDisconnectDiagnostic) + } } diff --git a/MC1Services/Sources/MC1Services/Connection/RemoveUnfavoritedResult.swift b/MC1Services/Sources/MC1Services/Connection/RemoveUnfavoritedResult.swift index d57347bf..5e1e79ae 100644 --- a/MC1Services/Sources/MC1Services/Connection/RemoveUnfavoritedResult.swift +++ b/MC1Services/Sources/MC1Services/Connection/RemoveUnfavoritedResult.swift @@ -2,6 +2,6 @@ import Foundation /// Result of removing unfavorited nodes from the device public struct RemoveUnfavoritedResult: Sendable { - public let removed: Int - public let total: Int + public let removed: Int + public let total: Int } diff --git a/MC1Services/Sources/MC1Services/Connection/TransportType.swift b/MC1Services/Sources/MC1Services/Connection/TransportType.swift index 28acc899..c3544bf1 100644 --- a/MC1Services/Sources/MC1Services/Connection/TransportType.swift +++ b/MC1Services/Sources/MC1Services/Connection/TransportType.swift @@ -2,6 +2,6 @@ import Foundation /// Transport type for the mesh connection public enum TransportType: Sendable { - case bluetooth - case wifi + case bluetooth + case wifi } diff --git a/MC1Services/Sources/MC1Services/DTOs/LinkPreviewDataDTO.swift b/MC1Services/Sources/MC1Services/DTOs/LinkPreviewDataDTO.swift index 069f2101..6ec957cf 100644 --- a/MC1Services/Sources/MC1Services/DTOs/LinkPreviewDataDTO.swift +++ b/MC1Services/Sources/MC1Services/DTOs/LinkPreviewDataDTO.swift @@ -2,43 +2,43 @@ import Foundation /// Data transfer object for link preview data public struct LinkPreviewDataDTO: Identifiable, Sendable, Hashable { - /// URL is the ID (unique key) - public let id: String - public let url: String - public let title: String? - public let imageData: Data? - public let iconData: Data? - public let imageWidth: Int? - public let imageHeight: Int? - public let fetchedAt: Date + /// URL is the ID (unique key) + public let id: String + public let url: String + public let title: String? + public let imageData: Data? + public let iconData: Data? + public let imageWidth: Int? + public let imageHeight: Int? + public let fetchedAt: Date - public init( - url: String, - title: String? = nil, - imageData: Data? = nil, - iconData: Data? = nil, - imageWidth: Int? = nil, - imageHeight: Int? = nil, - fetchedAt: Date = Date() - ) { - self.id = url - self.url = url - self.title = title - self.imageData = imageData - self.iconData = iconData - self.imageWidth = imageWidth - self.imageHeight = imageHeight - self.fetchedAt = fetchedAt - } + public init( + url: String, + title: String? = nil, + imageData: Data? = nil, + iconData: Data? = nil, + imageWidth: Int? = nil, + imageHeight: Int? = nil, + fetchedAt: Date = Date() + ) { + id = url + self.url = url + self.title = title + self.imageData = imageData + self.iconData = iconData + self.imageWidth = imageWidth + self.imageHeight = imageHeight + self.fetchedAt = fetchedAt + } - init(from model: LinkPreviewData) { - self.id = model.url - self.url = model.url - self.title = model.title - self.imageData = model.imageData - self.iconData = model.iconData - self.imageWidth = model.imageWidth - self.imageHeight = model.imageHeight - self.fetchedAt = model.fetchedAt - } + init(from model: LinkPreviewData) { + id = model.url + url = model.url + title = model.title + imageData = model.imageData + iconData = model.iconData + imageWidth = model.imageWidth + imageHeight = model.imageHeight + fetchedAt = model.fetchedAt + } } diff --git a/MC1Services/Sources/MC1Services/Errors/AppBackupError.swift b/MC1Services/Sources/MC1Services/Errors/AppBackupError.swift index 91836b13..e9bec2a1 100644 --- a/MC1Services/Sources/MC1Services/Errors/AppBackupError.swift +++ b/MC1Services/Sources/MC1Services/Errors/AppBackupError.swift @@ -2,30 +2,30 @@ import Foundation /// Errors that can occur during app backup export or import. public enum AppBackupError: Error, LocalizedError, Sendable { - case invalidFile - case fileTooLarge(actualBytes: Int, maxBytes: Int) - case decompressedTooLarge(maxBytes: Int) - case unsupportedVersion(found: Int, maxSupported: Int) - case corruptedManifest - case exportFailed(underlying: any Error) - case importFailed(underlying: any Error) + case invalidFile + case fileTooLarge(actualBytes: Int, maxBytes: Int) + case decompressedTooLarge(maxBytes: Int) + case unsupportedVersion(found: Int, maxSupported: Int) + case corruptedManifest + case exportFailed(underlying: any Error) + case importFailed(underlying: any Error) - public var errorDescription: String? { - switch self { - case .invalidFile: - "The backup file is invalid or could not be read." - case .fileTooLarge(let actualBytes, let maxBytes): - "The backup file is too large to import (\(actualBytes / 1_048_576) MB; limit is \(maxBytes / 1_048_576) MB)." - case .decompressedTooLarge(let maxBytes): - "The backup file expands past the safe size limit (\(maxBytes / 1_048_576) MB uncompressed)." - case .unsupportedVersion(let found, let maxSupported): - "This backup was created with a newer format (version \(found)). This app supports up to version \(maxSupported). Please update the app and try again." - case .corruptedManifest: - "The backup file appears to be corrupted. The declared item counts do not match the actual data." - case .exportFailed(let underlying): - "Failed to create backup: \(underlying.localizedDescription)" - case .importFailed(let underlying): - "Failed to import backup: \(underlying.localizedDescription)" - } + public var errorDescription: String? { + switch self { + case .invalidFile: + "The backup file is invalid or could not be read." + case let .fileTooLarge(actualBytes, maxBytes): + "The backup file is too large to import (\(actualBytes / 1_048_576) MB; limit is \(maxBytes / 1_048_576) MB)." + case let .decompressedTooLarge(maxBytes): + "The backup file expands past the safe size limit (\(maxBytes / 1_048_576) MB uncompressed)." + case let .unsupportedVersion(found, maxSupported): + "This backup was created with a newer format (version \(found)). This app supports up to version \(maxSupported). Please update the app and try again." + case .corruptedManifest: + "The backup file appears to be corrupted. The declared item counts do not match the actual data." + case let .exportFailed(underlying): + "Failed to create backup: \(underlying.localizedDescription)" + case let .importFailed(underlying): + "Failed to import backup: \(underlying.localizedDescription)" } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/BLEError.swift b/MC1Services/Sources/MC1Services/Errors/BLEError.swift index 7e2ebb84..b89ec73c 100644 --- a/MC1Services/Sources/MC1Services/Errors/BLEError.swift +++ b/MC1Services/Sources/MC1Services/Errors/BLEError.swift @@ -4,55 +4,55 @@ import Foundation /// Errors that can occur during BLE operations public enum BLEError: Error, Sendable { - case bluetoothUnavailable - case bluetoothUnauthorized - case bluetoothPoweredOff - case deviceNotFound - case connectionFailed(String) - case connectionTimeout - case notConnected - case characteristicNotFound - case writeError(String) - case invalidResponse - case operationTimeout - case authenticationFailed - case pairingFailed(String) - case deviceConnectedToOtherApp + case bluetoothUnavailable + case bluetoothUnauthorized + case bluetoothPoweredOff + case deviceNotFound + case connectionFailed(String) + case connectionTimeout + case notConnected + case characteristicNotFound + case writeError(String) + case invalidResponse + case operationTimeout + case authenticationFailed + case pairingFailed(String) + case deviceConnectedToOtherApp } // MARK: - BLEError LocalizedError Conformance extension BLEError: LocalizedError { - public var errorDescription: String? { - switch self { - case .bluetoothUnavailable: - return "Bluetooth is not available on this device." - case .bluetoothUnauthorized: - return "Bluetooth permission is required. Please enable it in Settings." - case .bluetoothPoweredOff: - return "Bluetooth is turned off. Please enable Bluetooth to connect." - case .deviceNotFound: - return "Device not found. Please make sure it's powered on and nearby." - case .connectionFailed(let message): - return "Connection failed: \(message)" - case .connectionTimeout: - return "Connection timed out. Please try again." - case .notConnected: - return "Not connected to a device." - case .characteristicNotFound: - return "Unable to communicate with device. Please try reconnecting." - case .writeError(let message): - return "Failed to send data: \(message)" - case .invalidResponse: - return "Invalid response from device. Please try again." - case .operationTimeout: - return "Operation timed out. Please try again." - case .authenticationFailed: - return "Authentication failed. Please check your device's PIN." - case .pairingFailed(let reason): - return "Bluetooth pairing failed: \(reason)" - case .deviceConnectedToOtherApp: - return "This device is connected to another app. Only one app can use a mesh radio at a time to prevent communication issues." - } + public var errorDescription: String? { + switch self { + case .bluetoothUnavailable: + "Bluetooth is not available on this device." + case .bluetoothUnauthorized: + "Bluetooth permission is required. Please enable it in Settings." + case .bluetoothPoweredOff: + "Bluetooth is turned off. Please enable Bluetooth to connect." + case .deviceNotFound: + "Device not found. Please make sure it's powered on and nearby." + case let .connectionFailed(message): + "Connection failed: \(message)" + case .connectionTimeout: + "Connection timed out. Please try again." + case .notConnected: + "Not connected to a device." + case .characteristicNotFound: + "Unable to communicate with device. Please try reconnecting." + case let .writeError(message): + "Failed to send data: \(message)" + case .invalidResponse: + "Invalid response from device. Please try again." + case .operationTimeout: + "Operation timed out. Please try again." + case .authenticationFailed: + "Authentication failed. Please check your device's PIN." + case let .pairingFailed(reason): + "Bluetooth pairing failed: \(reason)" + case .deviceConnectedToOtherApp: + "This device is connected to another app. Only one app can use a mesh radio at a time to prevent communication issues." } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/ConnectionError.swift b/MC1Services/Sources/MC1Services/Errors/ConnectionError.swift index 75c4b359..4bdd1fc0 100644 --- a/MC1Services/Sources/MC1Services/Errors/ConnectionError.swift +++ b/MC1Services/Sources/MC1Services/Errors/ConnectionError.swift @@ -2,21 +2,21 @@ import Foundation /// Errors that can occur during connection operations public enum ConnectionError: LocalizedError { - case connectionFailed(String) - case deviceNotFound - case notConnected - case initializationFailed(String) + case connectionFailed(String) + case deviceNotFound + case notConnected + case initializationFailed(String) - public var errorDescription: String? { - switch self { - case .connectionFailed(let reason): - return "Connection failed: \(reason)" - case .deviceNotFound: - return "Device not found" - case .notConnected: - return "Not connected to device" - case .initializationFailed(let reason): - return "Device initialization failed: \(reason)" - } + public var errorDescription: String? { + switch self { + case let .connectionFailed(reason): + "Connection failed: \(reason)" + case .deviceNotFound: + "Device not found" + case .notConnected: + "Not connected to device" + case let .initializationFailed(reason): + "Device initialization failed: \(reason)" } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/MeshCoreError+LocalizedError.swift b/MC1Services/Sources/MC1Services/Errors/MeshCoreError+LocalizedError.swift index 8e61d5e1..90fae061 100644 --- a/MC1Services/Sources/MC1Services/Errors/MeshCoreError+LocalizedError.swift +++ b/MC1Services/Sources/MC1Services/Errors/MeshCoreError+LocalizedError.swift @@ -4,58 +4,58 @@ import MeshCore // MARK: - MeshCoreError LocalizedError Conformance extension MeshCoreError: @retroactive LocalizedError { - public var errorDescription: String? { - switch self { - case .timeout: - "The operation timed out. Please try again." - case .deviceError(let code): - Self.deviceErrorDescription(code: code) - case .parseError(let detail): - "Failed to parse device response: \(detail)" - case .notConnected: - "Not connected to device." - case .commandFailed(_, let reason): - "Command failed: \(reason)" - case .invalidResponse(let expected, let got): - "Unexpected response from device (expected \(expected), got \(got))." - case .contactNotFound: - "Contact not found on device." - case .dataTooLarge(let maxSize, let actualSize): - "Data too large (\(actualSize) bytes, maximum is \(maxSize))." - case .signingFailed(let reason): - "Signing failed: \(reason)" - case .invalidInput(let detail): - "Invalid input: \(detail)" - case .unknown(let detail): - "An unknown error occurred: \(detail)" - case .bluetoothUnavailable: - "Bluetooth is not available on this device." - case .bluetoothUnauthorized: - "Bluetooth permission is required. Please enable it in Settings." - case .bluetoothPoweredOff: - "Bluetooth is turned off. Please enable Bluetooth to connect." - case .connectionLost(let underlying): - if let underlying { - "Connection to device was lost: \(underlying.localizedDescription)" - } else { - "Connection to device was lost." - } - case .sessionNotStarted: - "Session has not been started." - case .featureDisabled: - "This feature is disabled on the device." - } + public var errorDescription: String? { + switch self { + case .timeout: + "The operation timed out. Please try again." + case let .deviceError(code): + Self.deviceErrorDescription(code: code) + case let .parseError(detail): + "Failed to parse device response: \(detail)" + case .notConnected: + "Not connected to device." + case let .commandFailed(_, reason): + "Command failed: \(reason)" + case let .invalidResponse(expected, got): + "Unexpected response from device (expected \(expected), got \(got))." + case .contactNotFound: + "Contact not found on device." + case let .dataTooLarge(maxSize, actualSize): + "Data too large (\(actualSize) bytes, maximum is \(maxSize))." + case let .signingFailed(reason): + "Signing failed: \(reason)" + case let .invalidInput(detail): + "Invalid input: \(detail)" + case let .unknown(detail): + "An unknown error occurred: \(detail)" + case .bluetoothUnavailable: + "Bluetooth is not available on this device." + case .bluetoothUnauthorized: + "Bluetooth permission is required. Please enable it in Settings." + case .bluetoothPoweredOff: + "Bluetooth is turned off. Please enable Bluetooth to connect." + case let .connectionLost(underlying): + if let underlying { + "Connection to device was lost: \(underlying.localizedDescription)" + } else { + "Connection to device was lost." + } + case .sessionNotStarted: + "Session has not been started." + case .featureDisabled: + "This feature is disabled on the device." } + } - private static func deviceErrorDescription(code: UInt8) -> String { - switch code { - case 0x01: "Command not supported by device firmware." - case 0x02: "Item not found on device." - case 0x03: "Device storage is full." - case 0x04: "Device is in an invalid state for this operation." - case 0x05: "Device file system error." - case 0x06: "Invalid parameter sent to device." - default: "Device error (code \(code))." - } + private static func deviceErrorDescription(code: UInt8) -> String { + switch code { + case 0x01: "Command not supported by device firmware." + case 0x02: "Item not found on device." + case 0x03: "Device storage is full." + case 0x04: "Device is in an invalid state for this operation." + case 0x05: "Device file system error." + case 0x06: "Invalid parameter sent to device." + default: "Device error (code \(code))." } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/PairingError.swift b/MC1Services/Sources/MC1Services/Errors/PairingError.swift index 99e0d969..8ea25ddb 100644 --- a/MC1Services/Sources/MC1Services/Errors/PairingError.swift +++ b/MC1Services/Sources/MC1Services/Errors/PairingError.swift @@ -2,37 +2,37 @@ import Foundation /// Errors that can occur during device pairing public enum PairingError: LocalizedError { - /// ASK pairing succeeded but BLE connection failed (e.g., wrong PIN) - case connectionFailed(deviceID: UUID, underlying: Error) - /// ASK pairing succeeded but device is connected to another app - case deviceConnectedToOtherApp(deviceID: UUID) + /// ASK pairing succeeded but BLE connection failed (e.g., wrong PIN) + case connectionFailed(deviceID: UUID, underlying: Error) + /// ASK pairing succeeded but device is connected to another app + case deviceConnectedToOtherApp(deviceID: UUID) - public var errorDescription: String? { - switch self { - case .connectionFailed(_, let underlying): - return "Connection failed: \(underlying.localizedDescription)" - case .deviceConnectedToOtherApp: - return "Device is connected to another app." - } + public var errorDescription: String? { + switch self { + case let .connectionFailed(_, underlying): + "Connection failed: \(underlying.localizedDescription)" + case .deviceConnectedToOtherApp: + "Device is connected to another app." } + } - /// The device ID that failed to connect (for recovery UI) - public var deviceID: UUID? { - switch self { - case .connectionFailed(let deviceID, _): - return deviceID - case .deviceConnectedToOtherApp(let deviceID): - return deviceID - } + /// The device ID that failed to connect (for recovery UI) + public var deviceID: UUID? { + switch self { + case let .connectionFailed(deviceID, _): + deviceID + case let .deviceConnectedToOtherApp(deviceID): + deviceID } + } - /// True when the underlying BLE failure is an auth/encryption error. - /// Detection is locale-independent: BLEStateMachine maps CBATTError auth - /// codes (5/8/12/15) and CBError.encryptionTimedOut to BLEError.authenticationFailed - /// at the throw site. - public var isAuthenticationFailure: Bool { - guard case .connectionFailed(_, let underlying) = self else { return false } - if case BLEError.authenticationFailed = underlying { return true } - return false - } + /// True when the underlying BLE failure is an auth/encryption error. + /// Detection is locale-independent: `BLEStateMachine.makeConnectionError` is + /// the single source of truth for which CoreBluetooth codes map to + /// `BLEError.authenticationFailed` at the throw site. + public var isAuthenticationFailure: Bool { + guard case let .connectionFailed(_, underlying) = self else { return false } + if case BLEError.authenticationFailed = underlying { return true } + return false + } } diff --git a/MC1Services/Sources/MC1Services/Errors/ProtocolError.swift b/MC1Services/Sources/MC1Services/Errors/ProtocolError.swift index b8fdf32f..4562bb47 100644 --- a/MC1Services/Sources/MC1Services/Errors/ProtocolError.swift +++ b/MC1Services/Sources/MC1Services/Errors/ProtocolError.swift @@ -2,23 +2,23 @@ import Foundation /// Error codes returned by the device public enum ProtocolError: UInt8, Sendable, Error { - case unsupportedCommand = 0x01 - case notFound = 0x02 - case tableFull = 0x03 - case badState = 0x04 - case fileIOError = 0x05 - case illegalArgument = 0x06 + case unsupportedCommand = 0x01 + case notFound = 0x02 + case tableFull = 0x03 + case badState = 0x04 + case fileIOError = 0x05 + case illegalArgument = 0x06 } extension ProtocolError: LocalizedError { - public var errorDescription: String? { - switch self { - case .unsupportedCommand: "Command not supported by device firmware." - case .notFound: "Item not found on device." - case .tableFull: "Device storage is full." - case .badState: "Device is in an invalid state for this operation." - case .fileIOError: "Device file system error." - case .illegalArgument: "Invalid parameter sent to device." - } + public var errorDescription: String? { + switch self { + case .unsupportedCommand: "Command not supported by device firmware." + case .notFound: "Item not found on device." + case .tableFull: "Device storage is full." + case .badState: "Device is in an invalid state for this operation." + case .fileIOError: "Device file system error." + case .illegalArgument: "Invalid parameter sent to device." } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift b/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift index 4b6f8cd2..ace4cc16 100644 --- a/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift +++ b/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift @@ -2,57 +2,60 @@ import Foundation import MeshCore public enum RemoteNodeError: Error, LocalizedError, Sendable { - case notConnected - case loginFailed(String) - case sendFailed(String) - case invalidResponse - case permissionDenied - case timeout - case sessionNotFound - case passwordNotFound - case floodRouted // Keep-alive requires direct path - case pathDiscoveryFailed - case contactNotFound - case cancelled // Login cancelled due to duplicate attempt or shutdown - case sessionError(MeshCoreError) + case notConnected + case loginFailed(String) + case sendFailed(String) + case invalidResponse + case permissionDenied + case timeout + case sessionNotFound + case passwordNotFound + case floodRouted // Keep-alive requires direct path + case pathDiscoveryFailed + case contactNotFound + case radioContactsFull // Radio's contact table is full; cannot auto-add a missing node + case cancelled // Login cancelled due to duplicate attempt or shutdown + case sessionError(MeshCoreError) - public var errorDescription: String? { - switch self { - case .notConnected: - return "Not connected to mesh device" - case .loginFailed(let reason): - return "Login failed: \(reason)" - case .sendFailed(let reason): - return "Failed to send: \(reason)" - case .invalidResponse: - return "Invalid response from remote node" - case .permissionDenied: - return "Permission denied" - case .timeout: - return "Request timed out" - case .sessionNotFound: - return "Remote node session not found" - case .passwordNotFound: - return "Password not found in keychain" - case .floodRouted: - return "Keep-alive requires direct routing path" - case .pathDiscoveryFailed: - return "Failed to establish direct path" - case .contactNotFound: - return "Contact not found in database" - case .cancelled: - return "Login cancelled" - case .sessionError(let error): - return error.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: + "Not connected to mesh device" + case let .loginFailed(reason): + "Login failed: \(reason)" + case let .sendFailed(reason): + "Failed to send: \(reason)" + case .invalidResponse: + "Invalid response from remote node" + case .permissionDenied: + "Permission denied" + case .timeout: + "Request timed out" + case .sessionNotFound: + "Remote node session not found" + case .passwordNotFound: + "Password not found in keychain" + case .floodRouted: + "Keep-alive requires direct routing path" + case .pathDiscoveryFailed: + "Failed to establish direct path" + case .contactNotFound: + "Contact not found in database" + case .radioContactsFull: + "Radio contact list is full" + case .cancelled: + "Login cancelled" + case let .sessionError(error): + error.localizedDescription } + } - public var isRetryable: Bool { - switch self { - case .timeout, .notConnected, .floodRouted: - return true - default: - return false - } + public var isRetryable: Bool { + switch self { + case .timeout, .notConnected, .floodRouted: + true + default: + false } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/SettingsServiceError.swift b/MC1Services/Sources/MC1Services/Errors/SettingsServiceError.swift index b7c8c7d8..279a48fd 100644 --- a/MC1Services/Sources/MC1Services/Errors/SettingsServiceError.swift +++ b/MC1Services/Sources/MC1Services/Errors/SettingsServiceError.swift @@ -2,38 +2,38 @@ import Foundation import MeshCore public enum SettingsServiceError: Error, LocalizedError, Sendable { - case notConnected - case sendFailed - case invalidResponse - case sessionError(MeshCoreError) - case verificationFailed(expected: String, actual: String) - case deviceGPSVerificationFailed(expectedEnabled: Bool, actualEnabled: Bool) + case notConnected + case sendFailed + case invalidResponse + case sessionError(MeshCoreError) + case verificationFailed(expected: String, actual: String) + case deviceGPSVerificationFailed(expectedEnabled: Bool, actualEnabled: Bool) - public var errorDescription: String? { - switch self { - case .notConnected: return "Device not connected" - case .sendFailed: return "Failed to send command" - case .invalidResponse: return "Invalid response from device" - case .sessionError(let error): return error.localizedDescription - case .verificationFailed(let expected, let actual): - return "Setting was not saved. Expected '\(expected)' but device reports '\(actual)'." - case .deviceGPSVerificationFailed(let expectedEnabled, let actualEnabled): - let expected = expectedEnabled ? "On" : "Off" - let actual = actualEnabled ? "On" : "Off" - return "Device GPS setting was not saved. Expected '\(expected)' but device reports '\(actual)'." - } + public var errorDescription: String? { + switch self { + case .notConnected: return "Device not connected" + case .sendFailed: return "Failed to send command" + case .invalidResponse: return "Invalid response from device" + case let .sessionError(error): return error.localizedDescription + case let .verificationFailed(expected, actual): + return "Setting was not saved. Expected '\(expected)' but device reports '\(actual)'." + case let .deviceGPSVerificationFailed(expectedEnabled, actualEnabled): + let expected = expectedEnabled ? "On" : "Off" + let actual = actualEnabled ? "On" : "Off" + return "Device GPS setting was not saved. Expected '\(expected)' but device reports '\(actual)'." } + } - /// Whether this error suggests a connection issue that might be resolved by retrying - public var isRetryable: Bool { - switch self { - case .sendFailed, .notConnected: - return true - case .sessionError(let error): - if case .timeout = error { return true } - return false - default: - return false - } + /// Whether this error suggests a connection issue that might be resolved by retrying + public var isRetryable: Bool { + switch self { + case .sendFailed, .notConnected: + return true + case let .sessionError(error): + if case .timeout = error { return true } + return false + default: + return false } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/StoreServiceError.swift b/MC1Services/Sources/MC1Services/Errors/StoreServiceError.swift index 51877a70..433707ed 100644 --- a/MC1Services/Sources/MC1Services/Errors/StoreServiceError.swift +++ b/MC1Services/Sources/MC1Services/Errors/StoreServiceError.swift @@ -5,44 +5,44 @@ import StoreKit /// MC1Services error type (L10n is not visible from this package); the MC1 view-model /// layer maps these to localized copy. public enum StoreServiceError: LocalizedError, Sendable, Equatable { - case productsNotLoaded - case productNotFound(productID: String) - case purchaseFailed(reason: String) - case verificationFailed - case notEntitled - case networkUnavailable - case storefrontUnavailable - case unsupported + case productsNotLoaded + case productNotFound(productID: String) + case purchaseFailed(reason: String) + case verificationFailed + case notEntitled + case networkUnavailable + case storefrontUnavailable + case unsupported - public var errorDescription: String? { - switch self { - case .productsNotLoaded: "Products could not be loaded." - case .productNotFound: "Product not found." - case .purchaseFailed(let r): "Purchase failed: \(r)" - case .verificationFailed: "Could not verify purchase." - case .notEntitled: "Purchases are restricted on this device." - case .networkUnavailable: "Network unavailable." - case .storefrontUnavailable: "This product is not available in your region." - case .unsupported: "In-app purchases are not supported on this device." - } + public var errorDescription: String? { + switch self { + case .productsNotLoaded: "Products could not be loaded." + case .productNotFound: "Product not found." + case let .purchaseFailed(r): "Purchase failed: \(r)" + case .verificationFailed: "Could not verify purchase." + case .notEntitled: "Purchases are restricted on this device." + case .networkUnavailable: "Network unavailable." + case .storefrontUnavailable: "This product is not available in your region." + case .unsupported: "In-app purchases are not supported on this device." } + } - /// Maps a `StoreKitError` to a `StoreServiceError`, or `nil` when the error represents - /// user cancellation — which callers surface as `StorePurchaseOutcome.userCancelled`, - /// not as an alert. - public static func from(_ error: StoreKitError) -> StoreServiceError? { - if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *), - case .unsupported = error { - return .unsupported - } - switch error { - case .userCancelled: return nil - case .networkError: return .networkUnavailable - case .notAvailableInStorefront: return .storefrontUnavailable - case .notEntitled: return .notEntitled - case .systemError(let underlying): return .purchaseFailed(reason: String(describing: underlying)) - case .unknown: return .purchaseFailed(reason: "Unknown StoreKit error") - default: return .purchaseFailed(reason: "Unhandled StoreKit error") - } + /// Maps a `StoreKitError` to a `StoreServiceError`, or `nil` when the error represents + /// user cancellation — which callers surface as `StorePurchaseOutcome.userCancelled`, + /// not as an alert. + public static func from(_ error: StoreKitError) -> StoreServiceError? { + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *), + case .unsupported = error { + return .unsupported } + switch error { + case .userCancelled: return nil + case .networkError: return .networkUnavailable + case .notAvailableInStorefront: return .storefrontUnavailable + case .notEntitled: return .notEntitled + case let .systemError(underlying): return .purchaseFailed(reason: String(describing: underlying)) + case .unknown: return .purchaseFailed(reason: "Unknown StoreKit error") + default: return .purchaseFailed(reason: "Unhandled StoreKit error") + } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift b/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift index 12716965..1d001be6 100644 --- a/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift +++ b/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift @@ -4,24 +4,24 @@ import MeshCore // MARK: - WiFiTransportError LocalizedError Conformance extension WiFiTransportError: @retroactive LocalizedError { - public var errorDescription: String? { - switch self { - case .connectionFailed(let reason): - "Connection failed: \(reason)" - case .connectionTimeout: - "Connection timed out. Check the IP address and ensure the device is on the same network." - case .notConnected: - "Not connected to device." - case .sendFailed(let reason): - "Failed to send data: \(reason)" - case .sendTimeout: - "Send operation timed out." - case .invalidHost: - "Invalid IP address." - case .invalidPort: - "Invalid port number." - case .notConfigured: - "Connection not configured." - } + public var errorDescription: String? { + switch self { + case let .connectionFailed(reason): + "Connection failed: \(reason)" + case .connectionTimeout: + "Connection timed out. Check the IP address and ensure the device is on the same network." + case .notConnected: + "Not connected to device." + case let .sendFailed(reason): + "Failed to send data: \(reason)" + case .sendTimeout: + "Send operation timed out." + case .invalidHost: + "Invalid IP address." + case .invalidPort: + "Invalid port number." + case .notConfigured: + "Connection not configured." } + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/Bundle+App.swift b/MC1Services/Sources/MC1Services/Extensions/Bundle+App.swift index 2696231a..149654cb 100644 --- a/MC1Services/Sources/MC1Services/Extensions/Bundle+App.swift +++ b/MC1Services/Sources/MC1Services/Extensions/Bundle+App.swift @@ -1,13 +1,13 @@ import Foundation public extension Bundle { - /// `CFBundleShortVersionString` from the bundle's Info.plist, or `"unknown"` if absent. - var appVersion: String { - infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" - } + /// `CFBundleShortVersionString` from the bundle's Info.plist, or `"unknown"` if absent. + var appVersion: String { + infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + } - /// `CFBundleVersion` from the bundle's Info.plist, or `"unknown"` if absent. - var appBuild: String { - infoDictionary?["CFBundleVersion"] as? String ?? "unknown" - } + /// `CFBundleVersion` from the bundle's Info.plist, or `"unknown"` if absent. + var appBuild: String { + infoDictionary?["CFBundleVersion"] as? String ?? "unknown" + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/Character+EmojiDetection.swift b/MC1Services/Sources/MC1Services/Extensions/Character+EmojiDetection.swift index 6e5ac931..d5cd7147 100644 --- a/MC1Services/Sources/MC1Services/Extensions/Character+EmojiDetection.swift +++ b/MC1Services/Sources/MC1Services/Extensions/Character+EmojiDetection.swift @@ -1,8 +1,8 @@ import Foundation public extension Character { - var isEmoji: Bool { - guard let scalar = unicodeScalars.first else { return false } - return scalar.properties.isEmoji && (scalar.value > 0x238C || unicodeScalars.count > 1) - } + var isEmoji: Bool { + guard let scalar = unicodeScalars.first else { return false } + return scalar.properties.isEmoji && (scalar.value > 0x238C || unicodeScalars.count > 1) + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/Data+Extensions.swift b/MC1Services/Sources/MC1Services/Extensions/Data+Extensions.swift index 7cdae511..8a3e9897 100644 --- a/MC1Services/Sources/MC1Services/Extensions/Data+Extensions.swift +++ b/MC1Services/Sources/MC1Services/Extensions/Data+Extensions.swift @@ -7,85 +7,85 @@ import Foundation private let zlibDecompressReadChunkSize = 64 * 1024 public extension Data { - /// Converts data to an uppercase hex string with an optional separator between bytes. - /// Display-only formatting; identity comparisons and serialization use the - /// lowercase `hexString` property from MeshCore. - /// - Parameter separator: String to insert between each byte (default: none) - /// - Returns: Uppercase hex string representation - func uppercaseHexString(separator: String = "") -> String { - map { String(format: "%02X", $0) }.joined(separator: separator) - } + /// Converts data to an uppercase hex string with an optional separator between bytes. + /// Display-only formatting; identity comparisons and serialization use the + /// lowercase `hexString` property from MeshCore. + /// - Parameter separator: String to insert between each byte (default: none) + /// - Returns: Uppercase hex string representation + func uppercaseHexString(separator: String = "") -> String { + map { String(format: "%02X", $0) }.joined(separator: separator) + } - /// Splits a path's hop bytes into per-hop `(rawBytes, uppercaseHex)` pairs, chunking by - /// `hashSize`; callers pass only the valid slice. A non-positive `hashSize` returns no hops, - /// avoiding a `stride(by:)` trap, and each chunk is re-based to its own `Data` so a slice - /// with a non-zero `startIndex` indexes correctly. - internal func pathHops(hashSize: Int) -> [(data: Data, hex: String)] { - guard hashSize > 0 else { return [] } - return stride(from: 0, to: count, by: hashSize).map { offset in - let start = index(startIndex, offsetBy: offset) - let end = index(start, offsetBy: Swift.min(hashSize, count - offset)) - let chunk = Data(self[start.. [(data: Data, hex: String)] { + guard hashSize > 0 else { return [] } + return stride(from: 0, to: count, by: hashSize).map { offset in + let start = index(startIndex, offsetBy: offset) + let end = index(start, offsetBy: Swift.min(hashSize, count - offset)) + let chunk = Data(self[start..= 4 else { return 0 } - return prefix(4).withUnsafeBytes { - $0.load(as: UInt32.self).littleEndian - } - } + self = data + } - /// zlib-compress this data. Wraps Foundation's NSData bridge. - internal func zlibCompressed() throws -> Data { - try (self as NSData).compressed(using: .zlib) as Data + /// Convert first 4 bytes to UInt32 ACK code (little-endian) + /// Returns 0 if data has fewer than 4 bytes + internal var ackCodeUInt32: UInt32 { + guard count >= 4 else { return 0 } + return prefix(4).withUnsafeBytes { + $0.load(as: UInt32.self).littleEndian } + } - /// Stream-decompress a zlib payload, aborting once the output crosses - /// `maxUncompressedBytes`. Throws `AppBackupError.decompressedTooLarge` - /// on cap overflow so callers can surface a specific user-facing reason - /// instead of a generic invalid-file error. - internal func zlibDecompressed(maxUncompressedBytes: Int) throws -> Data { - var offset = 0 - let source = self - let inputFilter = try InputFilter(.decompress, using: .zlib) { length -> Data? in - let remaining = source.count - offset - guard remaining > 0 else { return nil } - let take = Swift.min(length, remaining) - let chunk = source.subdata(in: offset..<(offset + take)) - offset += take - return chunk - } + /// zlib-compress this data. Wraps Foundation's NSData bridge. + internal func zlibCompressed() throws -> Data { + try (self as NSData).compressed(using: .zlib) as Data + } + + /// Stream-decompress a zlib payload, aborting once the output crosses + /// `maxUncompressedBytes`. Throws `AppBackupError.decompressedTooLarge` + /// on cap overflow so callers can surface a specific user-facing reason + /// instead of a generic invalid-file error. + internal func zlibDecompressed(maxUncompressedBytes: Int) throws -> Data { + var offset = 0 + let source = self + let inputFilter = try InputFilter(.decompress, using: .zlib) { length -> Data? in + let remaining = source.count - offset + guard remaining > 0 else { return nil } + let take = Swift.min(length, remaining) + let chunk = source.subdata(in: offset..<(offset + take)) + offset += take + return chunk + } - var output = Data() - while let chunk = try inputFilter.readData(ofLength: zlibDecompressReadChunkSize), !chunk.isEmpty { - if output.count + chunk.count > maxUncompressedBytes { - throw AppBackupError.decompressedTooLarge(maxBytes: maxUncompressedBytes) - } - output.append(chunk) - } - return output + var output = Data() + while let chunk = try inputFilter.readData(ofLength: zlibDecompressReadChunkSize), !chunk.isEmpty { + if output.count + chunk.count > maxUncompressedBytes { + throw AppBackupError.decompressedTooLarge(maxBytes: maxUncompressedBytes) + } + output.append(chunk) } + return output + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/LPPDataPoint+Display.swift b/MC1Services/Sources/MC1Services/Extensions/LPPDataPoint+Display.swift index 4969b343..cb11a5ce 100644 --- a/MC1Services/Sources/MC1Services/Extensions/LPPDataPoint+Display.swift +++ b/MC1Services/Sources/MC1Services/Extensions/LPPDataPoint+Display.swift @@ -1,60 +1,60 @@ import Foundation import MeshCore -extension LPPDataPoint { - /// Human-readable type name for the sensor channel - public var typeName: String { - type.name - } +public extension LPPDataPoint { + /// Human-readable type name for the sensor channel + var typeName: String { + type.name + } - /// Formatted value with appropriate unit suffix - public var formattedValue: String { - switch (type, value) { - case (.voltage, .float(let v)): return "\(v.formatted(.number.precision(.fractionLength(3)))) V" - case (.temperature, .float(let t)): - let v = type.convertedValue(t) - return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" - case (.humidity, .float(let h)): return "\(h.formatted(.number.precision(.fractionLength(1))))%" - case (.barometer, .float(let p)): - let v = type.convertedValue(p) - return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" - case (.illuminance, .integer(let i)): return "\(i) lux" - case (.percentage, .integer(let p)): return "\(p)%" - case (.current, .float(let c)): return "\(c.formatted(.number.precision(.fractionLength(3)))) A" - case (.power, .float(let p)): return "\(p.formatted(.number.precision(.fractionLength(1)))) W" - case (.frequency, .float(let f)): return "\(f.formatted(.number.precision(.fractionLength(1)))) Hz" - case (.altitude, .float(let a)): - let v = type.convertedValue(a) - return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" - case (.distance, .float(let d)): - let v = type.convertedValue(d) - return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" - case (.energy, .float(let e)): return "\(e.formatted(.number.precision(.fractionLength(3)))) kWh" - case (.direction, .float(let d)): return "\(d.formatted(.number.precision(.fractionLength(0))))\u{00B0}" - case (_, .digital(let b)): return b ? "On" : "Off" - case (_, .integer(let i)): return "\(i)" - case (_, .float(let f)): return f.formatted(.number.precision(.fractionLength(3))) - case (_, .vector3(let x, let y, let z)): - return "(\(x.formatted(.number.precision(.fractionLength(2)))), \(y.formatted(.number.precision(.fractionLength(2)))), \(z.formatted(.number.precision(.fractionLength(2)))))" - case (_, .gps(let lat, let lon, let alt)): - return "\(lat.formatted(.number.precision(.fractionLength(5)))), \(lon.formatted(.number.precision(.fractionLength(5)))) @ \(alt.formatted(.number.precision(.fractionLength(1))))m" - case (_, .rgb(let r, let g, let b)): - return "RGB(\(r), \(g), \(b))" - case (_, .timestamp(let date)): - return date.formatted(date: .abbreviated, time: .shortened) - } + /// Formatted value with appropriate unit suffix + var formattedValue: String { + switch (type, value) { + case let (.voltage, .float(v)): return "\(v.formatted(.number.precision(.fractionLength(3)))) V" + case let (.temperature, .float(t)): + let v = type.convertedValue(t) + return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" + case let (.humidity, .float(h)): return "\(h.formatted(.number.precision(.fractionLength(1))))%" + case let (.barometer, .float(p)): + let v = type.convertedValue(p) + return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" + case let (.illuminance, .integer(i)): return "\(i) lux" + case let (.percentage, .integer(p)): return "\(p)%" + case let (.current, .float(c)): return "\(c.formatted(.number.precision(.fractionLength(3)))) A" + case let (.power, .float(p)): return "\(p.formatted(.number.precision(.fractionLength(1)))) W" + case let (.frequency, .float(f)): return "\(f.formatted(.number.precision(.fractionLength(1)))) Hz" + case let (.altitude, .float(a)): + let v = type.convertedValue(a) + return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" + case let (.distance, .float(d)): + let v = type.convertedValue(d) + return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" + case let (.energy, .float(e)): return "\(e.formatted(.number.precision(.fractionLength(3)))) kWh" + case let (.direction, .float(d)): return "\(d.formatted(.number.precision(.fractionLength(0))))\u{00B0}" + case let (_, .digital(b)): return b ? "On" : "Off" + case let (_, .integer(i)): return "\(i)" + case let (_, .float(f)): return f.formatted(.number.precision(.fractionLength(3))) + case let (_, .vector3(x, y, z)): + return "(\(x.formatted(.number.precision(.fractionLength(2)))), \(y.formatted(.number.precision(.fractionLength(2)))), \(z.formatted(.number.precision(.fractionLength(2)))))" + case let (_, .gps(lat, lon, alt)): + return "\(lat.formatted(.number.precision(.fractionLength(5)))), \(lon.formatted(.number.precision(.fractionLength(5)))) @ \(alt.formatted(.number.precision(.fractionLength(1))))m" + case let (_, .rgb(r, g, b)): + return "RGB(\(r), \(g), \(b))" + case let (_, .timestamp(date)): + return date.formatted(date: .abbreviated, time: .shortened) } + } - /// Estimated battery percentage based on voltage (3.0V=0%, 4.2V=100%) - /// Returns nil for non-voltage types or non-float values - public var batteryPercentage: Int? { - guard type == .voltage, case .float(let voltage) = value else { - return nil - } - - let minVoltage: Double = 3.0 - let maxVoltage: Double = 4.2 - let percentage = (voltage - minVoltage) / (maxVoltage - minVoltage) * 100 - return max(0, min(100, Int(percentage.rounded()))) + /// Estimated battery percentage based on voltage (3.0V=0%, 4.2V=100%) + /// Returns nil for non-voltage types or non-float values + var batteryPercentage: Int? { + guard type == .voltage, case let .float(voltage) = value else { + return nil } + + let minVoltage = 3.0 + let maxVoltage = 4.2 + let percentage = (voltage - minVoltage) / (maxVoltage - minVoltage) * 100 + return max(0, min(100, Int(percentage.rounded()))) + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/LPPSensorType+LocaleUnits.swift b/MC1Services/Sources/MC1Services/Extensions/LPPSensorType+LocaleUnits.swift index c7650a04..002a071f 100644 --- a/MC1Services/Sources/MC1Services/Extensions/LPPSensorType+LocaleUnits.swift +++ b/MC1Services/Sources/MC1Services/Extensions/LPPSensorType+LocaleUnits.swift @@ -1,66 +1,67 @@ import Foundation import MeshCore -extension LPPSensorType { +public extension LPPSensorType { + /// Converts an SI telemetry value to the user's locale-preferred unit. + /// Non-locale-sensitive types return the input unchanged. + func convertedValue(_ siValue: Double) -> Double { + guard let target = preferredUnit, let si = siDimension else { return siValue } + return Measurement(value: siValue, unit: si).converted(to: target).value + } - /// Converts an SI telemetry value to the user's locale-preferred unit. - /// Non-locale-sensitive types return the input unchanged. - public func convertedValue(_ siValue: Double) -> Double { - guard let target = preferredUnit, let si = siDimension else { return siValue } - return Measurement(value: siValue, unit: si).converted(to: target).value - } - - /// The locale-appropriate unit symbol (e.g. "°F" on US, "°C" on metric). - /// Falls back to the SI `unit` property for non-locale-sensitive types. - public var localizedUnitSymbol: String { - guard let target = preferredUnit else { return unit } - return target.symbol - } + /// The locale-appropriate unit symbol (e.g. "°F" on US, "°C" on metric). + /// Falls back to the SI `unit` property for non-locale-sensitive types. + var localizedUnitSymbol: String { + guard let target = preferredUnit else { return unit } + return target.symbol + } - /// Whether this type's value will be converted for the current locale. - public var isConverted: Bool { preferredUnit != nil } + /// Whether this type's value will be converted for the current locale. + var isConverted: Bool { + preferredUnit != nil + } - /// The fraction length appropriate for the converted value. - public var convertedFractionLength: Int { - guard isConverted else { - switch self { - case .temperature, .barometer, .altitude: return 1 - case .distance: return 3 - default: return 1 - } - } - switch self { - case .temperature: return 1 - case .barometer: return 2 // 29.92 inHg - case .altitude: return 0 // whole feet - case .distance: return 1 - default: return 1 - } + /// The fraction length appropriate for the converted value. + var convertedFractionLength: Int { + guard isConverted else { + switch self { + case .temperature, .barometer, .altitude: return 1 + case .distance: return 3 + default: return 1 + } + } + switch self { + case .temperature: return 1 + case .barometer: return 2 // 29.92 inHg + case .altitude: return 0 // whole feet + case .distance: return 1 + default: return 1 } + } - // MARK: - Private + // MARK: - Private - /// The locale-preferred Dimension for this sensor type, or nil if metric (no conversion needed). - private var preferredUnit: Dimension? { - guard siDimension != nil, - Locale.current.measurementSystem != .metric else { return nil } - switch self { - case .temperature: return UnitTemperature.fahrenheit - case .barometer: return UnitPressure.inchesOfMercury - case .altitude: return UnitLength.feet - case .distance: return UnitLength.feet - default: return nil - } + /// The locale-preferred Dimension for this sensor type, or nil if metric (no conversion needed). + private var preferredUnit: Dimension? { + guard siDimension != nil, + Locale.current.measurementSystem != .metric else { return nil } + switch self { + case .temperature: return UnitTemperature.fahrenheit + case .barometer: return UnitPressure.inchesOfMercury + case .altitude: return UnitLength.feet + case .distance: return UnitLength.feet + default: return nil } + } - /// Maps locale-sensitive sensor types to their Foundation SI Dimension. - private var siDimension: Dimension? { - switch self { - case .temperature: UnitTemperature.celsius - case .barometer: UnitPressure.hectopascals - case .altitude: UnitLength.meters - case .distance: UnitLength.meters - default: nil - } + /// Maps locale-sensitive sensor types to their Foundation SI Dimension. + private var siDimension: Dimension? { + switch self { + case .temperature: UnitTemperature.celsius + case .barometer: UnitPressure.hectopascals + case .altitude: UnitLength.meters + case .distance: UnitLength.meters + default: nil } + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/Locale+POSIX.swift b/MC1Services/Sources/MC1Services/Extensions/Locale+POSIX.swift index 9481b46e..d7eff896 100644 --- a/MC1Services/Sources/MC1Services/Extensions/Locale+POSIX.swift +++ b/MC1Services/Sources/MC1Services/Extensions/Locale+POSIX.swift @@ -1,7 +1,7 @@ import Foundation -extension Locale { - /// POSIX locale — always uses period as decimal separator. - /// Use for technical values like radio frequency and bandwidth. - public static let posix = Locale(identifier: "en_US_POSIX") +public extension Locale { + /// POSIX locale — always uses period as decimal separator. + /// Use for technical values like radio frequency and bandwidth. + static let posix = Locale(identifier: "en_US_POSIX") } diff --git a/MC1Services/Sources/MC1Services/Extensions/StatusResponse+Compatibility.swift b/MC1Services/Sources/MC1Services/Extensions/StatusResponse+Compatibility.swift index bd144780..4781c0de 100644 --- a/MC1Services/Sources/MC1Services/Extensions/StatusResponse+Compatibility.swift +++ b/MC1Services/Sources/MC1Services/Extensions/StatusResponse+Compatibility.swift @@ -1,18 +1,28 @@ import MeshCore -extension StatusResponse { - /// Uptime in seconds (compatibility alias) - public var uptimeSeconds: UInt32 { uptime } +public extension StatusResponse { + /// Uptime in seconds (compatibility alias) + var uptimeSeconds: UInt32 { + uptime + } - /// Battery level in millivolts (compatibility conversion) - public var batteryMillivolts: UInt16 { UInt16(clamping: battery) } + /// Battery level in millivolts (compatibility conversion) + var batteryMillivolts: UInt16 { + UInt16(clamping: battery) + } - /// Last RSSI value (compatibility alias) - public var lastRssi: Int16 { Int16(clamping: lastRSSI) } + /// Last RSSI value (compatibility alias) + var lastRssi: Int16 { + Int16(clamping: lastRSSI) + } - /// Last SNR value (compatibility conversion) - public var lastSnr: Float { Float(lastSNR) } + /// Last SNR value (compatibility conversion) + var lastSnr: Float { + Float(lastSNR) + } - /// Repeater RX airtime in seconds (compatibility alias) - public var repeaterRxAirtimeSeconds: UInt32 { rxAirtime } + /// Repeater RX airtime in seconds (compatibility alias) + var repeaterRxAirtimeSeconds: UInt32 { + rxAirtime + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/String+StableUUID.swift b/MC1Services/Sources/MC1Services/Extensions/String+StableUUID.swift index a9d6b7fe..286748ca 100644 --- a/MC1Services/Sources/MC1Services/Extensions/String+StableUUID.swift +++ b/MC1Services/Sources/MC1Services/Extensions/String+StableUUID.swift @@ -1,39 +1,39 @@ import Foundation public extension String { - /// Generates a deterministic UUID from this string using djb2 hash (non-cryptographic). - /// - /// Uses two independent hash streams (even/odd bytes) to generate 16 bytes for UUID construction. - /// The same input string always produces the same UUID output. - var stableUUID: UUID { - var hash1: UInt64 = 5381 - var hash2: UInt64 = 5381 + /// Generates a deterministic UUID from this string using djb2 hash (non-cryptographic). + /// + /// Uses two independent hash streams (even/odd bytes) to generate 16 bytes for UUID construction. + /// The same input string always produces the same UUID output. + var stableUUID: UUID { + var hash1: UInt64 = 5381 + var hash2: UInt64 = 5381 - for (index, byte) in self.utf8.enumerated() { - if index.isMultiple(of: 2) { - hash1 = ((hash1 << 5) &+ hash1) &+ UInt64(byte) - } else { - hash2 = ((hash2 << 5) &+ hash2) &+ UInt64(byte) - } - } - - return UUID(uuid: ( - UInt8(truncatingIfNeeded: hash1), - UInt8(truncatingIfNeeded: hash1 >> 8), - UInt8(truncatingIfNeeded: hash1 >> 16), - UInt8(truncatingIfNeeded: hash1 >> 24), - UInt8(truncatingIfNeeded: hash1 >> 32), - UInt8(truncatingIfNeeded: hash1 >> 40), - UInt8(truncatingIfNeeded: hash1 >> 48), - UInt8(truncatingIfNeeded: hash1 >> 56), - UInt8(truncatingIfNeeded: hash2), - UInt8(truncatingIfNeeded: hash2 >> 8), - UInt8(truncatingIfNeeded: hash2 >> 16), - UInt8(truncatingIfNeeded: hash2 >> 24), - UInt8(truncatingIfNeeded: hash2 >> 32), - UInt8(truncatingIfNeeded: hash2 >> 40), - UInt8(truncatingIfNeeded: hash2 >> 48), - UInt8(truncatingIfNeeded: hash2 >> 56) - )) + for (index, byte) in utf8.enumerated() { + if index.isMultiple(of: 2) { + hash1 = ((hash1 << 5) &+ hash1) &+ UInt64(byte) + } else { + hash2 = ((hash2 << 5) &+ hash2) &+ UInt64(byte) + } } + + return UUID(uuid: ( + UInt8(truncatingIfNeeded: hash1), + UInt8(truncatingIfNeeded: hash1 >> 8), + UInt8(truncatingIfNeeded: hash1 >> 16), + UInt8(truncatingIfNeeded: hash1 >> 24), + UInt8(truncatingIfNeeded: hash1 >> 32), + UInt8(truncatingIfNeeded: hash1 >> 40), + UInt8(truncatingIfNeeded: hash1 >> 48), + UInt8(truncatingIfNeeded: hash1 >> 56), + UInt8(truncatingIfNeeded: hash2), + UInt8(truncatingIfNeeded: hash2 >> 8), + UInt8(truncatingIfNeeded: hash2 >> 16), + UInt8(truncatingIfNeeded: hash2 >> 24), + UInt8(truncatingIfNeeded: hash2 >> 32), + UInt8(truncatingIfNeeded: hash2 >> 40), + UInt8(truncatingIfNeeded: hash2 >> 48), + UInt8(truncatingIfNeeded: hash2 >> 56) + )) + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/TelemetryResponse+DataPoints.swift b/MC1Services/Sources/MC1Services/Extensions/TelemetryResponse+DataPoints.swift index 3626e73d..cd3191cb 100644 --- a/MC1Services/Sources/MC1Services/Extensions/TelemetryResponse+DataPoints.swift +++ b/MC1Services/Sources/MC1Services/Extensions/TelemetryResponse+DataPoints.swift @@ -1,9 +1,9 @@ import MeshCore -extension TelemetryResponse { - /// Decoded LPP data points from the raw telemetry data. - /// Uses MeshCore's LPPDecoder to parse the raw bytes into structured sensor values. - public var dataPoints: [LPPDataPoint] { - LPPDecoder.decode(rawData) - } +public extension TelemetryResponse { + /// Decoded LPP data points from the raw telemetry data. + /// Uses MeshCore's LPPDecoder to parse the raw bytes into structured sensor values. + var dataPoints: [LPPDataPoint] { + LPPDecoder.decode(rawData) + } } diff --git a/MC1Services/Sources/MC1Services/MC1Services.swift b/MC1Services/Sources/MC1Services/MC1Services.swift index a8ada12d..c9651f21 100644 --- a/MC1Services/Sources/MC1Services/MC1Services.swift +++ b/MC1Services/Sources/MC1Services/MC1Services.swift @@ -10,7 +10,7 @@ /// - Notification, Keychain, and AccessorySetupKit services /// - High-level service layer (ContactService, MessageService, ChannelService) enum MC1ServicesVersion { - static let version = "0.1.0" + static let version = "0.1.0" } // MARK: - Type Aliases diff --git a/MC1Services/Sources/MC1Services/Models/AutoAddMode.swift b/MC1Services/Sources/MC1Services/Models/AutoAddMode.swift index a40f6493..d10b7b63 100644 --- a/MC1Services/Sources/MC1Services/Models/AutoAddMode.swift +++ b/MC1Services/Sources/MC1Services/Models/AutoAddMode.swift @@ -3,37 +3,37 @@ import MeshCore /// Represents the auto-add behavior for discovered nodes. public enum AutoAddMode: String, Codable, Sendable, CaseIterable { - /// Review all nodes in Discover before adding. - case manual - /// Auto-add only the types enabled in settings. - case selectedTypes - /// Auto-add every discovered node. - case all + /// Review all nodes in Discover before adding. + case manual + /// Auto-add only the types enabled in settings. + case selectedTypes + /// Auto-add every discovered node. + case all - /// Bitmask covering all auto-add type bits. - private static let typeBitsMask: UInt8 = AutoAddConfig.contactsBit - | AutoAddConfig.repeatersBit - | AutoAddConfig.roomServersBit - | AutoAddConfig.sensorsBit + /// Bitmask covering all auto-add type bits. + private static let typeBitsMask: UInt8 = AutoAddConfig.contactsBit + | AutoAddConfig.repeatersBit + | AutoAddConfig.roomServersBit + | AutoAddConfig.sensorsBit - /// Computes the auto-add mode from device settings. - /// - /// Protocol mapping: - /// - `manualAddContacts=false` → `.all` (firmware auto-adds everything) - /// - `manualAddContacts=true` + no type bits → `.manual` (user reviews all in Discover) - /// - `manualAddContacts=true` + type bits set → `.selectedTypes` (firmware auto-adds selected types) - /// - /// - Parameters: - /// - manualAddContacts: Whether manual add mode is enabled on the device - /// - autoAddConfig: The bitmask of enabled auto-add types - /// - Returns: The computed auto-add mode - public static func mode(manualAddContacts: Bool, autoAddConfig: UInt8) -> AutoAddMode { - if !manualAddContacts { - return .all // Firmware auto-adds everything - } else if autoAddConfig & typeBitsMask == 0 { - return .manual // No type bits = review all in Discover - } else { - return .selectedTypes // Type bits set = auto-add those types - } + /// Computes the auto-add mode from device settings. + /// + /// Protocol mapping: + /// - `manualAddContacts=false` → `.all` (firmware auto-adds everything) + /// - `manualAddContacts=true` + no type bits → `.manual` (user reviews all in Discover) + /// - `manualAddContacts=true` + type bits set → `.selectedTypes` (firmware auto-adds selected types) + /// + /// - Parameters: + /// - manualAddContacts: Whether manual add mode is enabled on the device + /// - autoAddConfig: The bitmask of enabled auto-add types + /// - Returns: The computed auto-add mode + public static func mode(manualAddContacts: Bool, autoAddConfig: UInt8) -> AutoAddMode { + if !manualAddContacts { + .all // Firmware auto-adds everything + } else if autoAddConfig & typeBitsMask == 0 { + .manual // No type bits = review all in Discover + } else { + .selectedTypes // Type bits set = auto-add those types } + } } diff --git a/MC1Services/Sources/MC1Services/Models/BlockedChannelSender.swift b/MC1Services/Sources/MC1Services/Models/BlockedChannelSender.swift index b1465d66..cbc20e94 100644 --- a/MC1Services/Sources/MC1Services/Models/BlockedChannelSender.swift +++ b/MC1Services/Sources/MC1Services/Models/BlockedChannelSender.swift @@ -5,64 +5,64 @@ import SwiftData /// Channel messages don't include sender keys, so blocking is name-based only. @Model public final class BlockedChannelSender { - #Index([\.radioID, \.name]) + #Index([\.radioID, \.name]) - @Attribute(.unique) - public var id: UUID + @Attribute(.unique) + public var id: UUID - /// The sender name to block (matched exactly as stored) - public var name: String + /// The sender name to block (matched exactly as stored) + public var name: String - /// Which device this block applies to - @Attribute(originalName: "deviceID") - public var radioID: UUID + /// Which device this block applies to + @Attribute(originalName: "deviceID") + public var radioID: UUID - /// When the user blocked this name - public var dateBlocked: Date + /// When the user blocked this name + public var dateBlocked: Date - public init( - id: UUID = UUID(), - name: String, - radioID: UUID, - dateBlocked: Date = .now - ) { - self.id = id - self.name = name - self.radioID = radioID - self.dateBlocked = dateBlocked - } + public init( + id: UUID = UUID(), + name: String, + radioID: UUID, + dateBlocked: Date = .now + ) { + self.id = id + self.name = name + self.radioID = radioID + self.dateBlocked = dateBlocked + } - /// Builds a model instance directly from a DTO. - public convenience init(dto: BlockedChannelSenderDTO) { - self.init(id: dto.id, name: dto.name, radioID: dto.radioID, dateBlocked: dto.dateBlocked) - } + /// Builds a model instance directly from a DTO. + public convenience init(dto: BlockedChannelSenderDTO) { + self.init(id: dto.id, name: dto.name, radioID: dto.radioID, dateBlocked: dto.dateBlocked) + } } // MARK: - Sendable DTO /// A sendable snapshot of BlockedChannelSender for cross-actor transfers. public struct BlockedChannelSenderDTO: Sendable, Equatable, Identifiable, Codable { - public let id: UUID - public let name: String - public var radioID: UUID - public let dateBlocked: Date + public let id: UUID + public let name: String + public var radioID: UUID + public let dateBlocked: Date - public init( - id: UUID = UUID(), - name: String, - radioID: UUID, - dateBlocked: Date = .now - ) { - self.id = id - self.name = name - self.radioID = radioID - self.dateBlocked = dateBlocked - } + public init( + id: UUID = UUID(), + name: String, + radioID: UUID, + dateBlocked: Date = .now + ) { + self.id = id + self.name = name + self.radioID = radioID + self.dateBlocked = dateBlocked + } - public init(from model: BlockedChannelSender) { - self.id = model.id - self.name = model.name - self.radioID = model.radioID - self.dateBlocked = model.dateBlocked - } + public init(from model: BlockedChannelSender) { + id = model.id + name = model.name + radioID = model.radioID + dateBlocked = model.dateBlocked + } } diff --git a/MC1Services/Sources/MC1Services/Models/Channel.swift b/MC1Services/Sources/MC1Services/Models/Channel.swift index 747c4134..3cca331e 100644 --- a/MC1Services/Sources/MC1Services/Models/Channel.swift +++ b/MC1Services/Sources/MC1Services/Models/Channel.swift @@ -5,481 +5,483 @@ import SwiftData /// Max number of channels depends on the device, with slot 0 being the public channel. @Model public final class Channel { - #Index( - [\.radioID], - [\.radioID, \.index] - ) - - /// Unique identifier - @Attribute(.unique) - public var id: UUID - - /// The device this channel belongs to - @Attribute(originalName: "deviceID") - public var radioID: UUID - - /// Channel slot index - public var index: UInt8 - - /// Channel name - public var name: String - - /// Channel secret (16 bytes, SHA-256 hashed from passphrase) - public var secret: Data - - /// Whether this channel is enabled/active - public var isEnabled: Bool - - /// Last message timestamp for this channel - public var lastMessageDate: Date? - - /// Unread message count - public var unreadCount: Int - - /// Unread mention count (mentions of current user not yet seen) - public var unreadMentionCount: Int = 0 - - /// Notification level for this channel (stored as raw value for SwiftData). - /// Default is -1 (unmigrated) to enable migration from legacy isMuted property. - public var notificationLevelRawValue: Int = -1 - - /// Legacy isMuted property from V1 schema (maps to old "isMuted" column). - /// Used for one-time migration to notificationLevelRawValue. - @Attribute(originalName: "isMuted") - public var legacyIsMuted: Bool? - - /// Notification level computed property with automatic migration from legacy isMuted - public var notificationLevel: NotificationLevel { - get { - // Check if migration is needed - if notificationLevelRawValue == -1 { - // Migrate from legacy isMuted - let migratedLevel: NotificationLevel = (legacyIsMuted == true) ? .muted : .all - notificationLevelRawValue = migratedLevel.rawValue - return migratedLevel - } - return NotificationLevel(rawValue: notificationLevelRawValue) ?? .all - } - set { notificationLevelRawValue = newValue.rawValue } - } - - /// Whether this channel is marked as favorite - public var isFavorite: Bool = false - - /// The named region override for this channel, when `floodScopeModeRawValue == .specific`. - /// Meaningful only in `.specific` mode; otherwise should be nil. - public var regionScope: String? - - /// Raw flood-scope-mode value ("inherit" / "allRegions" / "specific"). - /// Default "inherit" means: apply the device-level default flood scope at send time. - /// Access through ``floodScope`` for type-safe reads/writes. - public var floodScopeModeRawValue: String = ChannelFloodScopeStorage.Mode.inherit.rawValue - - private init( - id: UUID, - radioID: UUID, - index: UInt8, - name: String, - secret: Data, - isEnabled: Bool, - lastMessageDate: Date?, - unreadCount: Int, - unreadMentionCount: Int, - notificationLevelRawValue: Int, - isFavorite: Bool, - floodScopeModeRawValue: String, - regionScope: String? - ) { - self.id = id - self.radioID = radioID - self.index = index - self.name = name - self.secret = secret - self.isEnabled = isEnabled - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.notificationLevelRawValue = notificationLevelRawValue - self.isFavorite = isFavorite - self.floodScopeModeRawValue = floodScopeModeRawValue - self.regionScope = regionScope - } - - public convenience init( - id: UUID = UUID(), - radioID: UUID, - index: UInt8, - name: String, - secret: Data = Data(repeating: 0, count: 16), - isEnabled: Bool = true, - lastMessageDate: Date? = nil, - unreadCount: Int = 0, - unreadMentionCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - floodScope: ChannelFloodScope = .inherit - ) { - let storage = ChannelFloodScopeStorage.decompose(floodScope) - self.init( - id: id, - radioID: radioID, - index: index, - name: name, - secret: secret, - isEnabled: isEnabled, - lastMessageDate: lastMessageDate, - unreadCount: unreadCount, - unreadMentionCount: unreadMentionCount, - notificationLevelRawValue: notificationLevel.rawValue, - isFavorite: isFavorite, - floodScopeModeRawValue: storage.mode.rawValue, - regionScope: storage.regionName - ) - } - - /// Builds a model instance directly from a DTO. Shared by `saveChannel` and - /// backup batch-insert paths so they can't drift on field coverage. - /// Forwards raw flood-scope storage fields verbatim so pre-migration rows - /// survive without passing through the normalizing ``ChannelFloodScope`` accessor. - public convenience init(dto: ChannelDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - index: dto.index, - name: dto.name, - secret: dto.secret, - isEnabled: dto.isEnabled, - lastMessageDate: dto.lastMessageDate, - unreadCount: dto.unreadCount, - unreadMentionCount: dto.unreadMentionCount, - notificationLevelRawValue: dto.notificationLevel.rawValue, - isFavorite: dto.isFavorite, - floodScopeModeRawValue: dto.floodScopeModeRawValue, - regionScope: dto.regionScope - ) - } - - /// Applies all mutable fields from a DTO to this model instance. - /// Copies raw flood-scope storage fields verbatim; see ``init(dto:)``. - /// `radioID` and `index` are identity and stay frozen: this runs in the - /// id-matched `saveChannel(_:)` upsert, which owns app-side metadata. Slot - /// changes come from the radio keyed by `(radioID, index)`, and backup - /// relocation inserts a fresh row via ``init(dto:)``. - func apply(_ dto: ChannelDTO) { - name = dto.name - secret = dto.secret - isEnabled = dto.isEnabled - lastMessageDate = dto.lastMessageDate - unreadCount = dto.unreadCount - unreadMentionCount = dto.unreadMentionCount - notificationLevel = dto.notificationLevel - isFavorite = dto.isFavorite - floodScopeModeRawValue = dto.floodScopeModeRawValue - regionScope = dto.regionScope - } - - /// Creates a Channel from a protocol ChannelInfo - public convenience init(radioID: UUID, from info: ChannelInfo) { - self.init( - radioID: radioID, - index: info.index, - name: info.name, - secret: info.secret - ) + #Index( + [\.radioID], + [\.radioID, \.index] + ) + + /// Unique identifier + @Attribute(.unique) + public var id: UUID + + /// The device this channel belongs to + @Attribute(originalName: "deviceID") + public var radioID: UUID + + /// Channel slot index + public var index: UInt8 + + /// Channel name + public var name: String + + /// Channel secret (16 bytes, SHA-256 hashed from passphrase) + public var secret: Data + + /// Whether this channel is enabled/active + public var isEnabled: Bool + + /// Last message timestamp for this channel + public var lastMessageDate: Date? + + /// Unread message count + public var unreadCount: Int + + /// Unread mention count (mentions of current user not yet seen) + public var unreadMentionCount: Int = 0 + + /// Notification level for this channel (stored as raw value for SwiftData). + /// Default is -1 (unmigrated) to enable migration from legacy isMuted property. + public var notificationLevelRawValue: Int = -1 + + /// Legacy isMuted property from V1 schema (maps to old "isMuted" column). + /// Used for one-time migration to notificationLevelRawValue. + @Attribute(originalName: "isMuted") + public var legacyIsMuted: Bool? + + /// Notification level computed property with automatic migration from legacy isMuted + public var notificationLevel: NotificationLevel { + get { + // Check if migration is needed + if notificationLevelRawValue == -1 { + // Migrate from legacy isMuted + let migratedLevel: NotificationLevel = (legacyIsMuted == true) ? .muted : .all + notificationLevelRawValue = migratedLevel.rawValue + return migratedLevel + } + return NotificationLevel(rawValue: notificationLevelRawValue) ?? .all } + set { notificationLevelRawValue = newValue.rawValue } + } + + /// Whether this channel is marked as favorite + public var isFavorite: Bool = false + + /// The named region override for this channel, when `floodScopeModeRawValue == .specific`. + /// Meaningful only in `.specific` mode; otherwise should be nil. + public var regionScope: String? + + /// Raw flood-scope-mode value ("inherit" / "allRegions" / "specific"). + /// Default "inherit" means: apply the device-level default flood scope at send time. + /// Access through ``floodScope`` for type-safe reads/writes. + public var floodScopeModeRawValue: String = ChannelFloodScopeStorage.Mode.inherit.rawValue + + private init( + id: UUID, + radioID: UUID, + index: UInt8, + name: String, + secret: Data, + isEnabled: Bool, + lastMessageDate: Date?, + unreadCount: Int, + unreadMentionCount: Int, + notificationLevelRawValue: Int, + isFavorite: Bool, + floodScopeModeRawValue: String, + regionScope: String? + ) { + self.id = id + self.radioID = radioID + self.index = index + self.name = name + self.secret = secret + self.isEnabled = isEnabled + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.notificationLevelRawValue = notificationLevelRawValue + self.isFavorite = isFavorite + self.floodScopeModeRawValue = floodScopeModeRawValue + self.regionScope = regionScope + } + + public convenience init( + id: UUID = UUID(), + radioID: UUID, + index: UInt8, + name: String, + secret: Data = Data(repeating: 0, count: 16), + isEnabled: Bool = true, + lastMessageDate: Date? = nil, + unreadCount: Int = 0, + unreadMentionCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + floodScope: ChannelFloodScope = .inherit + ) { + let storage = ChannelFloodScopeStorage.decompose(floodScope) + self.init( + id: id, + radioID: radioID, + index: index, + name: name, + secret: secret, + isEnabled: isEnabled, + lastMessageDate: lastMessageDate, + unreadCount: unreadCount, + unreadMentionCount: unreadMentionCount, + notificationLevelRawValue: notificationLevel.rawValue, + isFavorite: isFavorite, + floodScopeModeRawValue: storage.mode.rawValue, + regionScope: storage.regionName + ) + } + + /// Builds a model instance directly from a DTO. Shared by `saveChannel` and + /// backup batch-insert paths so they can't drift on field coverage. + /// Forwards raw flood-scope storage fields verbatim so pre-migration rows + /// survive without passing through the normalizing ``ChannelFloodScope`` accessor. + public convenience init(dto: ChannelDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + index: dto.index, + name: dto.name, + secret: dto.secret, + isEnabled: dto.isEnabled, + lastMessageDate: dto.lastMessageDate, + unreadCount: dto.unreadCount, + unreadMentionCount: dto.unreadMentionCount, + notificationLevelRawValue: dto.notificationLevel.rawValue, + isFavorite: dto.isFavorite, + floodScopeModeRawValue: dto.floodScopeModeRawValue, + regionScope: dto.regionScope + ) + } + + /// Applies all mutable fields from a DTO to this model instance. + /// Copies raw flood-scope storage fields verbatim; see ``init(dto:)``. + /// `radioID` and `index` are identity and stay frozen: this runs in the + /// id-matched `saveChannel(_:)` upsert, which owns app-side metadata. Slot + /// changes come from the radio keyed by `(radioID, index)`, and backup + /// relocation inserts a fresh row via ``init(dto:)``. + func apply(_ dto: ChannelDTO) { + name = dto.name + secret = dto.secret + isEnabled = dto.isEnabled + lastMessageDate = dto.lastMessageDate + unreadCount = dto.unreadCount + unreadMentionCount = dto.unreadMentionCount + notificationLevel = dto.notificationLevel + isFavorite = dto.isFavorite + floodScopeModeRawValue = dto.floodScopeModeRawValue + regionScope = dto.regionScope + } + + /// Creates a Channel from a protocol ChannelInfo + public convenience init(radioID: UUID, from info: ChannelInfo) { + self.init( + radioID: radioID, + index: info.index, + name: info.name, + secret: info.secret + ) + } } // MARK: - Computed Properties public extension Channel { - /// Type-safe accessor for the per-channel flood scope preference. - var floodScope: ChannelFloodScope { - get { ChannelFloodScopeStorage.recompose(modeRawValue: floodScopeModeRawValue, regionName: regionScope) } - set { - let storage = ChannelFloodScopeStorage.decompose(newValue) - floodScopeModeRawValue = storage.mode.rawValue - regionScope = storage.regionName - } - } - - /// Whether this is the public channel (slot 0) - var isPublicChannel: Bool { - index == 0 - } - - /// Whether this channel has a non-empty secret - var hasSecret: Bool { - !secret.allSatisfy { $0 == 0 } - } - - /// Whether this channel uses meaningful encryption (private channels only). - /// Public channels (index 0) and hashtag channels use publicly-derivable keys. - var isEncryptedChannel: Bool { - !isPublicChannel && !name.hasPrefix("#") - } - - /// Updates from a protocol ChannelInfo - func update(from info: ChannelInfo) { - self.name = info.name - self.secret = info.secret - } - - /// Converts to a protocol ChannelInfo - func toChannelInfo() -> ChannelInfo { - ChannelInfo(index: index, name: name, secret: secret) + /// Type-safe accessor for the per-channel flood scope preference. + var floodScope: ChannelFloodScope { + get { ChannelFloodScopeStorage.recompose(modeRawValue: floodScopeModeRawValue, regionName: regionScope) } + set { + let storage = ChannelFloodScopeStorage.decompose(newValue) + floodScopeModeRawValue = storage.mode.rawValue + regionScope = storage.regionName } + } + + /// Whether this is the public channel (slot 0) + var isPublicChannel: Bool { + index == 0 + } + + /// Whether this channel has a non-empty secret + var hasSecret: Bool { + !secret.allSatisfy { $0 == 0 } + } + + /// Whether this channel uses meaningful encryption (private channels only). + /// Public channels (index 0) and hashtag channels use publicly-derivable keys. + var isEncryptedChannel: Bool { + !isPublicChannel && !name.hasPrefix("#") + } + + /// Updates from a protocol ChannelInfo + func update(from info: ChannelInfo) { + name = info.name + secret = info.secret + } + + /// Converts to a protocol ChannelInfo + func toChannelInfo() -> ChannelInfo { + ChannelInfo(index: index, name: name, secret: secret) + } } // MARK: - Sendable DTO /// A sendable snapshot of Channel for cross-actor transfers public struct ChannelDTO: Sendable, Equatable, Identifiable, Hashable, Codable { - public let id: UUID - public var radioID: UUID - public let index: UInt8 - public let name: String - public let secret: Data - public let isEnabled: Bool - public let lastMessageDate: Date? - public let unreadCount: Int - public let unreadMentionCount: Int - public let notificationLevel: NotificationLevel - public let isFavorite: Bool - public let floodScopeModeRawValue: String - public let regionScope: String? - - /// Convenience property for checking if muted - public var isMuted: Bool { notificationLevel == .muted } - - /// Type-safe accessor for the per-channel flood scope preference. - public var floodScope: ChannelFloodScope { - ChannelFloodScopeStorage.recompose(modeRawValue: floodScopeModeRawValue, regionName: regionScope) - } - - // Explicit Codable so backups predating ``floodScopeModeRawValue`` decode cleanly. - // Legacy envelopes: missing key + non-nil `regionScope` → `.specific`; missing key + - // nil `regionScope` → `.inherit` (matches the corrective post-migration semantics). - private enum CodingKeys: String, CodingKey { - case id, radioID, index, name, secret, isEnabled, lastMessageDate, - unreadCount, unreadMentionCount, notificationLevel, isFavorite, - floodScopeModeRawValue, regionScope - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(UUID.self, forKey: .id) - self.radioID = try container.decode(UUID.self, forKey: .radioID) - self.index = try container.decode(UInt8.self, forKey: .index) - self.name = try container.decode(String.self, forKey: .name) - self.secret = try container.decode(Data.self, forKey: .secret) - self.isEnabled = try container.decode(Bool.self, forKey: .isEnabled) - self.lastMessageDate = try container.decodeIfPresent(Date.self, forKey: .lastMessageDate) - self.unreadCount = try container.decode(Int.self, forKey: .unreadCount) - self.unreadMentionCount = try container.decodeIfPresent(Int.self, forKey: .unreadMentionCount) ?? 0 - self.notificationLevel = try container.decodeIfPresent(NotificationLevel.self, forKey: .notificationLevel) ?? .all - self.isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false - let region = try container.decodeIfPresent(String.self, forKey: .regionScope) - self.regionScope = region - if let raw = try container.decodeIfPresent(String.self, forKey: .floodScopeModeRawValue) { - self.floodScopeModeRawValue = raw - } else { - let mode: ChannelFloodScopeStorage.Mode = (region != nil) ? .specific : .inherit - self.floodScopeModeRawValue = mode.rawValue - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(radioID, forKey: .radioID) - try container.encode(index, forKey: .index) - try container.encode(name, forKey: .name) - try container.encode(secret, forKey: .secret) - try container.encode(isEnabled, forKey: .isEnabled) - try container.encodeIfPresent(lastMessageDate, forKey: .lastMessageDate) - try container.encode(unreadCount, forKey: .unreadCount) - try container.encode(unreadMentionCount, forKey: .unreadMentionCount) - try container.encode(notificationLevel, forKey: .notificationLevel) - try container.encode(isFavorite, forKey: .isFavorite) - try container.encode(floodScopeModeRawValue, forKey: .floodScopeModeRawValue) - try container.encodeIfPresent(regionScope, forKey: .regionScope) - } - - public init(from channel: Channel) { - self.id = channel.id - self.radioID = channel.radioID - self.index = channel.index - self.name = channel.name - self.secret = channel.secret - self.isEnabled = channel.isEnabled - self.lastMessageDate = channel.lastMessageDate - self.unreadCount = channel.unreadCount - self.unreadMentionCount = channel.unreadMentionCount - // Decode the level without invoking the migrating getter's in-memory write-back, keeping - // export a pure read. An unmigrated -1 sentinel maps to its migrated value (muted if the - // legacy isMuted flag was set, else all) exactly as the getter would — but without - // dirtying the live row. The -1 sentinel itself is never put on the wire (the DTO carries - // a NotificationLevel, not the raw column), which is intended. - self.notificationLevel = NotificationLevel(rawValue: channel.notificationLevelRawValue) - ?? ((channel.legacyIsMuted == true) ? .muted : .all) - self.isFavorite = channel.isFavorite - self.floodScopeModeRawValue = channel.floodScopeModeRawValue - self.regionScope = channel.regionScope - } - - /// Memberwise initializer for creating DTOs directly - public init( - id: UUID, - radioID: UUID, - index: UInt8, - name: String, - secret: Data, - isEnabled: Bool, - lastMessageDate: Date?, - unreadCount: Int, - unreadMentionCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - floodScope: ChannelFloodScope = .inherit - ) { - self.id = id - self.radioID = radioID - self.index = index - self.name = name - self.secret = secret - self.isEnabled = isEnabled - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.notificationLevel = notificationLevel - self.isFavorite = isFavorite - let storage = ChannelFloodScopeStorage.decompose(floodScope) - self.floodScopeModeRawValue = storage.mode.rawValue - self.regionScope = storage.regionName - } - - /// Returns a copy with only `notificationLevel` changed. - public func with(notificationLevel: NotificationLevel) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScope: floodScope - ) - } - - /// Returns a copy with only `isFavorite` changed. - public func with(isFavorite: Bool) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScope: floodScope - ) - } - - /// Returns a copy with only the flood-scope preference changed. - public func with(floodScope: ChannelFloodScope) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScope: floodScope - ) - } - - /// Returns a copy placed at a different slot `index`, forwarding every other raw - /// storage field verbatim. Used by backup import to relocate a channel whose secret - /// has no local match onto a free slot without routing through the normalizing - /// ``ChannelFloodScope`` accessor (preserving pre-migration flood-scope rows). - func with(index newIndex: UInt8) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: newIndex, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScopeModeRawValue: floodScopeModeRawValue, - regionScope: regionScope - ) - } - - /// Returns a copy with a fresh surrogate `id`, forwarding every other raw storage - /// field verbatim. Used by backup import so a relocated/non-matching channel can never - /// upsert a live local channel that happens to share the backup's `@Attribute(.unique)` - /// id. Channel has no inbound foreign key, so re-issuing the id breaks no linkage; it - /// uses the raw initializer to preserve pre-migration flood-scope rows (see ``init(dto:)``). - func with(id newID: UUID) -> ChannelDTO { - ChannelDTO( - id: newID, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScopeModeRawValue: floodScopeModeRawValue, - regionScope: regionScope - ) - } - - /// Returns a copy with only the raw `regionScope` column changed. This is the - /// low-level bypass used for tests that must simulate malformed on-disk state; - /// production code should go through ``with(floodScope:)``. - func with(regionScope: String?) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScopeModeRawValue: floodScopeModeRawValue, - regionScope: regionScope - ) - } - - /// Low-level init that sets both raw storage fields directly. Kept `internal` - /// to prevent callers from constructing invalid combinations; used by - /// ``with(regionScope:)`` and migration tests. - init( - id: UUID, - radioID: UUID, - index: UInt8, - name: String, - secret: Data, - isEnabled: Bool, - lastMessageDate: Date?, - unreadCount: Int, - unreadMentionCount: Int, - notificationLevel: NotificationLevel, - isFavorite: Bool, - floodScopeModeRawValue: String, - regionScope: String? - ) { - self.id = id - self.radioID = radioID - self.index = index - self.name = name - self.secret = secret - self.isEnabled = isEnabled - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.notificationLevel = notificationLevel - self.isFavorite = isFavorite - self.floodScopeModeRawValue = floodScopeModeRawValue - self.regionScope = regionScope - } - - public var isPublicChannel: Bool { - index == 0 - } - - public var hasSecret: Bool { - !secret.allSatisfy { $0 == 0 } - } - - /// Whether this channel uses meaningful encryption (private channels only). - /// Public channels (index 0) and hashtag channels use publicly-derivable keys. - public var isEncryptedChannel: Bool { - !isPublicChannel && !name.hasPrefix("#") + public let id: UUID + public var radioID: UUID + public let index: UInt8 + public let name: String + public let secret: Data + public let isEnabled: Bool + public let lastMessageDate: Date? + public let unreadCount: Int + public let unreadMentionCount: Int + public let notificationLevel: NotificationLevel + public let isFavorite: Bool + public let floodScopeModeRawValue: String + public let regionScope: String? + + /// Convenience property for checking if muted + public var isMuted: Bool { + notificationLevel == .muted + } + + /// Type-safe accessor for the per-channel flood scope preference. + public var floodScope: ChannelFloodScope { + ChannelFloodScopeStorage.recompose(modeRawValue: floodScopeModeRawValue, regionName: regionScope) + } + + /// Explicit Codable so backups predating ``floodScopeModeRawValue`` decode cleanly. + /// Legacy envelopes: missing key + non-nil `regionScope` → `.specific`; missing key + + /// nil `regionScope` → `.inherit` (matches the corrective post-migration semantics). + private enum CodingKeys: String, CodingKey { + case id, radioID, index, name, secret, isEnabled, lastMessageDate, + unreadCount, unreadMentionCount, notificationLevel, isFavorite, + floodScopeModeRawValue, regionScope + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + radioID = try container.decode(UUID.self, forKey: .radioID) + index = try container.decode(UInt8.self, forKey: .index) + name = try container.decode(String.self, forKey: .name) + secret = try container.decode(Data.self, forKey: .secret) + isEnabled = try container.decode(Bool.self, forKey: .isEnabled) + lastMessageDate = try container.decodeIfPresent(Date.self, forKey: .lastMessageDate) + unreadCount = try container.decode(Int.self, forKey: .unreadCount) + unreadMentionCount = try container.decodeIfPresent(Int.self, forKey: .unreadMentionCount) ?? 0 + notificationLevel = try container.decodeIfPresent(NotificationLevel.self, forKey: .notificationLevel) ?? .all + isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false + let region = try container.decodeIfPresent(String.self, forKey: .regionScope) + regionScope = region + if let raw = try container.decodeIfPresent(String.self, forKey: .floodScopeModeRawValue) { + floodScopeModeRawValue = raw + } else { + let mode: ChannelFloodScopeStorage.Mode = (region != nil) ? .specific : .inherit + floodScopeModeRawValue = mode.rawValue } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(radioID, forKey: .radioID) + try container.encode(index, forKey: .index) + try container.encode(name, forKey: .name) + try container.encode(secret, forKey: .secret) + try container.encode(isEnabled, forKey: .isEnabled) + try container.encodeIfPresent(lastMessageDate, forKey: .lastMessageDate) + try container.encode(unreadCount, forKey: .unreadCount) + try container.encode(unreadMentionCount, forKey: .unreadMentionCount) + try container.encode(notificationLevel, forKey: .notificationLevel) + try container.encode(isFavorite, forKey: .isFavorite) + try container.encode(floodScopeModeRawValue, forKey: .floodScopeModeRawValue) + try container.encodeIfPresent(regionScope, forKey: .regionScope) + } + + public init(from channel: Channel) { + id = channel.id + radioID = channel.radioID + index = channel.index + name = channel.name + secret = channel.secret + isEnabled = channel.isEnabled + lastMessageDate = channel.lastMessageDate + unreadCount = channel.unreadCount + unreadMentionCount = channel.unreadMentionCount + // Decode the level without invoking the migrating getter's in-memory write-back, keeping + // export a pure read. An unmigrated -1 sentinel maps to its migrated value (muted if the + // legacy isMuted flag was set, else all) exactly as the getter would — but without + // dirtying the live row. The -1 sentinel itself is never put on the wire (the DTO carries + // a NotificationLevel, not the raw column), which is intended. + notificationLevel = NotificationLevel(rawValue: channel.notificationLevelRawValue) + ?? ((channel.legacyIsMuted == true) ? .muted : .all) + isFavorite = channel.isFavorite + floodScopeModeRawValue = channel.floodScopeModeRawValue + regionScope = channel.regionScope + } + + /// Memberwise initializer for creating DTOs directly + public init( + id: UUID, + radioID: UUID, + index: UInt8, + name: String, + secret: Data, + isEnabled: Bool, + lastMessageDate: Date?, + unreadCount: Int, + unreadMentionCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + floodScope: ChannelFloodScope = .inherit + ) { + self.id = id + self.radioID = radioID + self.index = index + self.name = name + self.secret = secret + self.isEnabled = isEnabled + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.notificationLevel = notificationLevel + self.isFavorite = isFavorite + let storage = ChannelFloodScopeStorage.decompose(floodScope) + floodScopeModeRawValue = storage.mode.rawValue + regionScope = storage.regionName + } + + /// Returns a copy with only `notificationLevel` changed. + public func with(notificationLevel: NotificationLevel) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScope: floodScope + ) + } + + /// Returns a copy with only `isFavorite` changed. + public func with(isFavorite: Bool) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScope: floodScope + ) + } + + /// Returns a copy with only the flood-scope preference changed. + public func with(floodScope: ChannelFloodScope) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScope: floodScope + ) + } + + /// Returns a copy placed at a different slot `index`, forwarding every other raw + /// storage field verbatim. Used by backup import to relocate a channel whose secret + /// has no local match onto a free slot without routing through the normalizing + /// ``ChannelFloodScope`` accessor (preserving pre-migration flood-scope rows). + func with(index newIndex: UInt8) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: newIndex, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScopeModeRawValue: floodScopeModeRawValue, + regionScope: regionScope + ) + } + + /// Returns a copy with a fresh surrogate `id`, forwarding every other raw storage + /// field verbatim. Used by backup import so a relocated/non-matching channel can never + /// upsert a live local channel that happens to share the backup's `@Attribute(.unique)` + /// id. Channel has no inbound foreign key, so re-issuing the id breaks no linkage; it + /// uses the raw initializer to preserve pre-migration flood-scope rows (see ``init(dto:)``). + func with(id newID: UUID) -> ChannelDTO { + ChannelDTO( + id: newID, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScopeModeRawValue: floodScopeModeRawValue, + regionScope: regionScope + ) + } + + /// Returns a copy with only the raw `regionScope` column changed. This is the + /// low-level bypass used for tests that must simulate malformed on-disk state; + /// production code should go through ``with(floodScope:)``. + func with(regionScope: String?) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScopeModeRawValue: floodScopeModeRawValue, + regionScope: regionScope + ) + } + + /// Low-level init that sets both raw storage fields directly. Kept `internal` + /// to prevent callers from constructing invalid combinations; used by + /// ``with(regionScope:)`` and migration tests. + init( + id: UUID, + radioID: UUID, + index: UInt8, + name: String, + secret: Data, + isEnabled: Bool, + lastMessageDate: Date?, + unreadCount: Int, + unreadMentionCount: Int, + notificationLevel: NotificationLevel, + isFavorite: Bool, + floodScopeModeRawValue: String, + regionScope: String? + ) { + self.id = id + self.radioID = radioID + self.index = index + self.name = name + self.secret = secret + self.isEnabled = isEnabled + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.notificationLevel = notificationLevel + self.isFavorite = isFavorite + self.floodScopeModeRawValue = floodScopeModeRawValue + self.regionScope = regionScope + } + + public var isPublicChannel: Bool { + index == 0 + } + + public var hasSecret: Bool { + !secret.allSatisfy { $0 == 0 } + } + + /// Whether this channel uses meaningful encryption (private channels only). + /// Public channels (index 0) and hashtag channels use publicly-derivable keys. + public var isEncryptedChannel: Bool { + !isPublicChannel && !name.hasPrefix("#") + } } diff --git a/MC1Services/Sources/MC1Services/Models/ChannelFloodScope.swift b/MC1Services/Sources/MC1Services/Models/ChannelFloodScope.swift index 6e32a271..a4818018 100644 --- a/MC1Services/Sources/MC1Services/Models/ChannelFloodScope.swift +++ b/MC1Services/Sources/MC1Services/Models/ChannelFloodScope.swift @@ -5,9 +5,9 @@ import Foundation /// `allRegions` means "override the device default and broadcast to all regions"; /// `region(name)` is a per-channel override to a specific named region. public enum ChannelFloodScope: Sendable, Equatable, Hashable { - case inherit - case allRegions - case region(String) + case inherit + case allRegions + case region(String) } /// Bridges the two-field on-disk representation (`floodScopeModeRawValue: String` + @@ -15,31 +15,29 @@ public enum ChannelFloodScope: Sendable, Equatable, Hashable { /// combinations are not representable externally: if `.specific` mode is set but /// `regionName` is nil, recompose yields `.inherit` defensively. enum ChannelFloodScopeStorage { - enum Mode: String { - // Raw values are pinned so a case rename can't silently change what's - // persisted to SwiftData, written into backups, or matched by the migration predicate. - // swiftlint:disable redundant_string_enum_value - case inherit = "inherit" - case allRegions = "allRegions" - case specific = "specific" - // swiftlint:enable redundant_string_enum_value - } + enum Mode: String { + // Raw values are pinned so a case rename can't silently change what's + // persisted to SwiftData, written into backups, or matched by the migration predicate. + case inherit + case allRegions + case specific + } - static func decompose(_ scope: ChannelFloodScope) -> (mode: Mode, regionName: String?) { - switch scope { - case .inherit: return (.inherit, nil) - case .allRegions: return (.allRegions, nil) - case .region(let name): return (.specific, name) - } + static func decompose(_ scope: ChannelFloodScope) -> (mode: Mode, regionName: String?) { + switch scope { + case .inherit: (.inherit, nil) + case .allRegions: (.allRegions, nil) + case let .region(name): (.specific, name) } + } - static func recompose(modeRawValue: String, regionName: String?) -> ChannelFloodScope { - switch Mode(rawValue: modeRawValue) ?? .inherit { - case .inherit: return .inherit - case .allRegions: return .allRegions - case .specific: - guard let name = regionName, !name.isEmpty else { return .inherit } - return .region(name) - } + static func recompose(modeRawValue: String, regionName: String?) -> ChannelFloodScope { + switch Mode(rawValue: modeRawValue) ?? .inherit { + case .inherit: return .inherit + case .allRegions: return .allRegions + case .specific: + guard let name = regionName, !name.isEmpty else { return .inherit } + return .region(name) } + } } diff --git a/MC1Services/Sources/MC1Services/Models/ChannelMessageEnvelope.swift b/MC1Services/Sources/MC1Services/Models/ChannelMessageEnvelope.swift index 1ee16827..11838a76 100644 --- a/MC1Services/Sources/MC1Services/Models/ChannelMessageEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/ChannelMessageEnvelope.swift @@ -16,26 +16,26 @@ import Foundation /// window risks tagging the message with stale identity, or indexing /// against the wrong message entirely. public struct ChannelMessageEnvelope: Sendable { - public let messageID: UUID - public let channelIndex: UInt8 - public let isResend: Bool - public let messageText: String - public let messageTimestamp: UInt32 - public let localNodeName: String? + public let messageID: UUID + public let channelIndex: UInt8 + public let isResend: Bool + public let messageText: String + public let messageTimestamp: UInt32 + public let localNodeName: String? - public init( - messageID: UUID, - channelIndex: UInt8, - isResend: Bool, - messageText: String, - messageTimestamp: UInt32, - localNodeName: String? - ) { - self.messageID = messageID - self.channelIndex = channelIndex - self.isResend = isResend - self.messageText = messageText - self.messageTimestamp = messageTimestamp - self.localNodeName = localNodeName - } + public init( + messageID: UUID, + channelIndex: UInt8, + isResend: Bool, + messageText: String, + messageTimestamp: UInt32, + localNodeName: String? + ) { + self.messageID = messageID + self.channelIndex = channelIndex + self.isResend = isResend + self.messageText = messageText + self.messageTimestamp = messageTimestamp + self.localNodeName = localNodeName + } } diff --git a/MC1Services/Sources/MC1Services/Models/ChatConversationID.swift b/MC1Services/Sources/MC1Services/Models/ChatConversationID.swift index 343b58ef..3145e83a 100644 --- a/MC1Services/Sources/MC1Services/Models/ChatConversationID.swift +++ b/MC1Services/Sources/MC1Services/Models/ChatConversationID.swift @@ -6,45 +6,45 @@ import Foundation /// transitions. The `ChatCoordinatorRegistry` keys its dictionary on /// this value. public struct ChatConversationID: Hashable, Sendable { - public let radioID: UUID - let conversation: ConversationKey + public let radioID: UUID + let conversation: ConversationKey - enum ConversationKey: Hashable, Sendable { - case dm(contactID: UUID) - case channel(channelIndex: UInt8) - } + enum ConversationKey: Hashable { + case dm(contactID: UUID) + case channel(channelIndex: UInt8) + } } public extension ChatConversationID { - static func dm(radioID: UUID, contactID: UUID) -> ChatConversationID { - ChatConversationID(radioID: radioID, conversation: .dm(contactID: contactID)) - } + static func dm(radioID: UUID, contactID: UUID) -> ChatConversationID { + ChatConversationID(radioID: radioID, conversation: .dm(contactID: contactID)) + } - static func channel(radioID: UUID, channelIndex: UInt8) -> ChatConversationID { - ChatConversationID(radioID: radioID, conversation: .channel(channelIndex: channelIndex)) - } + static func channel(radioID: UUID, channelIndex: UInt8) -> ChatConversationID { + ChatConversationID(radioID: radioID, conversation: .channel(channelIndex: channelIndex)) + } } public extension ChatConversationID { - /// Separator between the segments of a draft storage key. - private static let keySeparator = "|" - /// Segment marking a direct-message draft key. - private static let dmKeySegment = "dm" - /// Segment marking a channel draft key. - private static let channelKeySegment = "ch" + /// Separator between the segments of a draft storage key. + private static let keySeparator = "|" + /// Segment marking a direct-message draft key. + private static let dmKeySegment = "dm" + /// Segment marking a channel draft key. + private static let channelKeySegment = "ch" - /// Stable, pinned string encoding used as the `DraftStore` dictionary key. - /// Always namespaced by `radioID` so drafts never leak across radios; DMs key - /// on the contact UUID, channels on the slot index. The format is frozen by a - /// unit test — changing it silently orphans every previously persisted draft. - var draftStorageKey: String { - switch conversation { - case .dm(let contactID): - return [radioID.uuidString, Self.dmKeySegment, contactID.uuidString] - .joined(separator: Self.keySeparator) - case .channel(let channelIndex): - return [radioID.uuidString, Self.channelKeySegment, String(channelIndex)] - .joined(separator: Self.keySeparator) - } + /// Stable, pinned string encoding used as the `DraftStore` dictionary key. + /// Always namespaced by `radioID` so drafts never leak across radios; DMs key + /// on the contact UUID, channels on the slot index. The format is frozen by a + /// unit test — changing it silently orphans every previously persisted draft. + var draftStorageKey: String { + switch conversation { + case let .dm(contactID): + [radioID.uuidString, Self.dmKeySegment, contactID.uuidString] + .joined(separator: Self.keySeparator) + case let .channel(channelIndex): + [radioID.uuidString, Self.channelKeySegment, String(channelIndex)] + .joined(separator: Self.keySeparator) } + } } diff --git a/MC1Services/Sources/MC1Services/Models/ConnectionMethod.swift b/MC1Services/Sources/MC1Services/Models/ConnectionMethod.swift index ee5ce8e0..5f6983c5 100644 --- a/MC1Services/Sources/MC1Services/Models/ConnectionMethod.swift +++ b/MC1Services/Sources/MC1Services/Models/ConnectionMethod.swift @@ -2,56 +2,55 @@ import Foundation /// Represents a method for connecting to a MeshCore device. public enum ConnectionMethod: Codable, Sendable, Identifiable, Hashable { + /// Bluetooth Low Energy connection + case bluetooth(peripheralUUID: UUID, displayName: String?) - /// Bluetooth Low Energy connection - case bluetooth(peripheralUUID: UUID, displayName: String?) + /// WiFi TCP connection + case wifi(host: String, port: UInt16, displayName: String?) - /// WiFi TCP connection - case wifi(host: String, port: UInt16, displayName: String?) + // MARK: - Identifiable - // MARK: - Identifiable - - public var id: String { - switch self { - case .bluetooth(let uuid, _): - return "ble:\(uuid.uuidString)" - case .wifi(let host, let port, _): - return "wifi:\(host):\(port)" - } - } - - // MARK: - Convenience - - /// User-assigned display name, if any. - public var displayName: String? { - switch self { - case .bluetooth(_, let name), .wifi(_, _, let name): - return name - } + public var id: String { + switch self { + case let .bluetooth(uuid, _): + "ble:\(uuid.uuidString)" + case let .wifi(host, port, _): + "wifi:\(host):\(port)" } + } - /// Whether this is a Bluetooth connection. - public var isBluetooth: Bool { - if case .bluetooth = self { return true } - return false - } + // MARK: - Convenience - /// Whether this is a WiFi connection. - public var isWiFi: Bool { - if case .wifi = self { return true } - return false + /// User-assigned display name, if any. + public var displayName: String? { + switch self { + case let .bluetooth(_, name), let .wifi(_, _, name): + name } - - /// Short description for display in lists. - public var shortDescription: String { - switch self { - case .bluetooth: - return "Bluetooth" - case .wifi(let host, let port, _): - if port == 5000 { - return host - } - return "\(host):\(port)" - } + } + + /// Whether this is a Bluetooth connection. + public var isBluetooth: Bool { + if case .bluetooth = self { return true } + return false + } + + /// Whether this is a WiFi connection. + public var isWiFi: Bool { + if case .wifi = self { return true } + return false + } + + /// Short description for display in lists. + public var shortDescription: String { + switch self { + case .bluetooth: + return "Bluetooth" + case let .wifi(host, port, _): + if port == 5000 { + return host + } + return "\(host):\(port)" } + } } diff --git a/MC1Services/Sources/MC1Services/Models/Contact.swift b/MC1Services/Sources/MC1Services/Models/Contact.swift index 5611ffae..35f43559 100644 --- a/MC1Services/Sources/MC1Services/Models/Contact.swift +++ b/MC1Services/Sources/MC1Services/Models/Contact.swift @@ -7,502 +7,523 @@ import SwiftData /// Contacts are stored per-device and synced from the device's contact table. @Model public final class Contact { - #Index( - [\.radioID], - [\.radioID, \.publicKey] + #Index( + [\.radioID], + [\.radioID, \.publicKey] + ) + + /// Unique identifier (derived from public key hash) + @Attribute(.unique) + public var id: UUID + + /// The device this contact belongs to + @Attribute(originalName: "deviceID") + public var radioID: UUID + + /// The 32-byte public key of the contact + public var publicKey: Data + + /// Human-readable name + public var name: String + + /// Contact type (chat, repeater, room) + public var typeRawValue: UInt8 + + /// Permission flags + public var flags: UInt8 + + /// Encoded outbound path length (0xFF = flood; upper 2 bits = hash mode, lower 6 bits = hop count) + public var outPathLength: UInt8 + + /// Outgoing routing path (up to 64 bytes) + public var outPath: Data + + /// Last advertisement timestamp (device time) + public var lastAdvertTimestamp: UInt32 + + /// Contact latitude + public var latitude: Double + + /// Contact longitude + public var longitude: Double + + /// Last modification timestamp (for sync watermarking) + public var lastModified: UInt32 + + /// Local nickname override (optional) + public var nickname: String? + + /// Whether this contact is blocked + public var isBlocked: Bool + + /// Whether this contact's notifications are muted + public var isMuted: Bool = false + + /// Whether this contact is a favorite/pinned + public var isFavorite: Bool + + /// Last message timestamp (for sorting conversations) + public var lastMessageDate: Date? + + /// Unread message count + public var unreadCount: Int + + /// Unread mention count (mentions of current user not yet seen) + public var unreadMentionCount: Int = 0 + + /// Selected OCV preset name (nil = liIon default) + public var ocvPreset: String? + + /// Custom OCV array as comma-separated string (e.g., "4240,4112,4029,...") + public var customOCVArrayString: String? + + public init( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data, + name: String, + typeRawValue: UInt8 = 0, + flags: UInt8 = 0, + outPathLength: UInt8 = PacketBuilder.floodPathSentinel, + outPath: Data = Data(), + lastAdvertTimestamp: UInt32 = 0, + latitude: Double = 0, + longitude: Double = 0, + lastModified: UInt32 = 0, + nickname: String? = nil, + isBlocked: Bool = false, + isMuted: Bool = false, + isFavorite: Bool = false, + lastMessageDate: Date? = nil, + unreadCount: Int = 0, + unreadMentionCount: Int = 0, + ocvPreset: String? = nil, + customOCVArrayString: String? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.typeRawValue = typeRawValue + self.flags = flags + self.outPathLength = outPathLength + self.outPath = outPath + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.lastModified = lastModified + self.nickname = nickname + self.isBlocked = isBlocked + self.isMuted = isMuted + self.isFavorite = isFavorite + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.ocvPreset = ocvPreset + self.customOCVArrayString = customOCVArrayString + } + + /// Builds a model instance directly from a DTO. Shared by `saveContact` and + /// backup batch-insert paths so they can't drift on field coverage. + public convenience init(dto: ContactDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + publicKey: dto.publicKey, + name: dto.name, + typeRawValue: dto.typeRawValue, + flags: dto.flags, + outPathLength: dto.outPathLength, + outPath: dto.outPath, + lastAdvertTimestamp: dto.lastAdvertTimestamp, + latitude: dto.latitude, + longitude: dto.longitude, + lastModified: dto.lastModified, + nickname: dto.nickname, + isBlocked: dto.isBlocked, + isMuted: dto.isMuted, + isFavorite: dto.isFavorite, + lastMessageDate: dto.lastMessageDate, + unreadCount: dto.unreadCount, + unreadMentionCount: dto.unreadMentionCount, + ocvPreset: dto.ocvPreset, + customOCVArrayString: dto.customOCVArrayString ) - - /// Unique identifier (derived from public key hash) - @Attribute(.unique) - public var id: UUID - - /// The device this contact belongs to - @Attribute(originalName: "deviceID") - public var radioID: UUID - - /// The 32-byte public key of the contact - public var publicKey: Data - - /// Human-readable name - public var name: String - - /// Contact type (chat, repeater, room) - public var typeRawValue: UInt8 - - /// Permission flags - public var flags: UInt8 - - /// Encoded outbound path length (0xFF = flood; upper 2 bits = hash mode, lower 6 bits = hop count) - public var outPathLength: UInt8 - - /// Outgoing routing path (up to 64 bytes) - public var outPath: Data - - /// Last advertisement timestamp (device time) - public var lastAdvertTimestamp: UInt32 - - /// Contact latitude - public var latitude: Double - - /// Contact longitude - public var longitude: Double - - /// Last modification timestamp (for sync watermarking) - public var lastModified: UInt32 - - /// Local nickname override (optional) - public var nickname: String? - - /// Whether this contact is blocked - public var isBlocked: Bool - - /// Whether this contact's notifications are muted - public var isMuted: Bool = false - - /// Whether this contact is a favorite/pinned - public var isFavorite: Bool - - /// Last message timestamp (for sorting conversations) - public var lastMessageDate: Date? - - /// Unread message count - public var unreadCount: Int - - /// Unread mention count (mentions of current user not yet seen) - public var unreadMentionCount: Int = 0 - - /// Selected OCV preset name (nil = liIon default) - public var ocvPreset: String? - - /// Custom OCV array as comma-separated string (e.g., "4240,4112,4029,...") - public var customOCVArrayString: String? - - public init( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data, - name: String, - typeRawValue: UInt8 = 0, - flags: UInt8 = 0, - outPathLength: UInt8 = PacketBuilder.floodPathSentinel, - outPath: Data = Data(), - lastAdvertTimestamp: UInt32 = 0, - latitude: Double = 0, - longitude: Double = 0, - lastModified: UInt32 = 0, - nickname: String? = nil, - isBlocked: Bool = false, - isMuted: Bool = false, - isFavorite: Bool = false, - lastMessageDate: Date? = nil, - unreadCount: Int = 0, - unreadMentionCount: Int = 0, - ocvPreset: String? = nil, - customOCVArrayString: String? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.typeRawValue = typeRawValue - self.flags = flags - self.outPathLength = outPathLength - self.outPath = outPath - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.lastModified = lastModified - self.nickname = nickname - self.isBlocked = isBlocked - self.isMuted = isMuted - self.isFavorite = isFavorite - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.ocvPreset = ocvPreset - self.customOCVArrayString = customOCVArrayString - } - - /// Builds a model instance directly from a DTO. Shared by `saveContact` and - /// backup batch-insert paths so they can't drift on field coverage. - public convenience init(dto: ContactDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - publicKey: dto.publicKey, - name: dto.name, - typeRawValue: dto.typeRawValue, - flags: dto.flags, - outPathLength: dto.outPathLength, - outPath: dto.outPath, - lastAdvertTimestamp: dto.lastAdvertTimestamp, - latitude: dto.latitude, - longitude: dto.longitude, - lastModified: dto.lastModified, - nickname: dto.nickname, - isBlocked: dto.isBlocked, - isMuted: dto.isMuted, - isFavorite: dto.isFavorite, - lastMessageDate: dto.lastMessageDate, - unreadCount: dto.unreadCount, - unreadMentionCount: dto.unreadMentionCount, - ocvPreset: dto.ocvPreset, - customOCVArrayString: dto.customOCVArrayString - ) - } - - /// Applies all mutable fields from a DTO to this model instance. - /// `radioID` and `publicKey` are identity and stay frozen: this runs in the - /// id-matched `saveContact(_:)` upsert, which owns app-side metadata. Key - /// changes arrive from the radio as `ContactFrame`s keyed by - /// `(radioID, publicKey)`, where a new key is a new contact row. - func apply(_ dto: ContactDTO) { - name = dto.name - typeRawValue = dto.typeRawValue - flags = dto.flags - outPathLength = dto.outPathLength - outPath = dto.outPath - lastAdvertTimestamp = dto.lastAdvertTimestamp - latitude = dto.latitude - longitude = dto.longitude - lastModified = dto.lastModified - nickname = dto.nickname - isBlocked = dto.isBlocked - isMuted = dto.isMuted - isFavorite = dto.isFavorite - lastMessageDate = dto.lastMessageDate - unreadCount = dto.unreadCount - unreadMentionCount = dto.unreadMentionCount - ocvPreset = dto.ocvPreset - customOCVArrayString = dto.customOCVArrayString - } - - /// Creates a Contact from a protocol ContactFrame - public convenience init(radioID: UUID, from frame: ContactFrame) { - self.init( - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.typeRawValue, - flags: frame.flags, - outPathLength: frame.outPathLength, - outPath: frame.outPath, - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - lastModified: frame.lastModified, - isFavorite: (frame.flags & 0x01) != 0 - ) - } + } + + /// Applies all mutable fields from a DTO to this model instance. + /// `radioID` and `publicKey` are identity and stay frozen: this runs in the + /// id-matched `saveContact(_:)` upsert, which owns app-side metadata. Key + /// changes arrive from the radio as `ContactFrame`s keyed by + /// `(radioID, publicKey)`, where a new key is a new contact row. + func apply(_ dto: ContactDTO) { + name = dto.name + typeRawValue = dto.typeRawValue + flags = dto.flags + outPathLength = dto.outPathLength + outPath = dto.outPath + lastAdvertTimestamp = dto.lastAdvertTimestamp + latitude = dto.latitude + longitude = dto.longitude + lastModified = dto.lastModified + nickname = dto.nickname + isBlocked = dto.isBlocked + isMuted = dto.isMuted + isFavorite = dto.isFavorite + lastMessageDate = dto.lastMessageDate + unreadCount = dto.unreadCount + unreadMentionCount = dto.unreadMentionCount + ocvPreset = dto.ocvPreset + customOCVArrayString = dto.customOCVArrayString + } + + /// Creates a Contact from a protocol ContactFrame + public convenience init(radioID: UUID, from frame: ContactFrame) { + self.init( + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.typeRawValue, + flags: frame.flags, + outPathLength: frame.outPathLength, + outPath: frame.outPath, + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + lastModified: frame.lastModified, + isFavorite: (frame.flags & 0x01) != 0 + ) + } } // MARK: - Computed Properties public extension Contact { - /// The contact type enum - var type: ContactType { - ContactType(rawValue: typeRawValue) ?? .chat - } - - /// Display name (nickname if set, otherwise name) - var displayName: String { - nickname ?? name - } - - /// The 6-byte public key prefix for message addressing - var publicKeyPrefix: Data { - publicKey.prefix(6) - } - - /// Whether this contact uses flood routing - var isFloodRouted: Bool { - outPathLength == PacketBuilder.floodPathSentinel - } - - /// Whether this contact has a known, valid location - var hasLocation: Bool { - let hasNonZero = latitude != 0 || longitude != 0 - guard hasNonZero else { return false } - return CLLocationCoordinate2DIsValid( - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - ) - } - - /// Whether this contact is a repeater - var isRepeater: Bool { - type == .repeater - } - - /// Whether this contact is a room - var isRoom: Bool { - type == .room - } - - /// Updates from a protocol ContactFrame - func update(from frame: ContactFrame) { - self.name = frame.name - self.typeRawValue = frame.typeRawValue - // Preserve bit 0 (favorite) from existing flags, take bits 1-7 from frame - self.flags = (self.flags & 0x01) | (frame.flags & ~0x01) - self.outPathLength = frame.outPathLength - self.outPath = frame.outPath - self.lastAdvertTimestamp = frame.lastAdvertTimestamp - self.latitude = frame.latitude - self.longitude = frame.longitude - self.lastModified = frame.lastModified - } - - /// Converts to a protocol ContactFrame for sending to device - func toContactFrame() -> ContactFrame { - ContactFrame( - publicKey: publicKey, - type: type, - typeRawValue: typeRawValue, - flags: flags, - outPathLength: outPathLength, - outPath: outPath, - name: name, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: lastModified - ) - } + /// The contact type enum + var type: ContactType { + ContactType(rawValue: typeRawValue) ?? .chat + } + + /// Display name (nickname if set, otherwise name) + var displayName: String { + nickname ?? name + } + + /// The 6-byte public key prefix for message addressing + var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + /// Whether this contact uses flood routing + var isFloodRouted: Bool { + outPathLength == PacketBuilder.floodPathSentinel + } + + /// Whether this contact has a known, valid location + var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + + /// Whether this contact is a repeater + var isRepeater: Bool { + type == .repeater + } + + /// Whether this contact is a room + var isRoom: Bool { + type == .room + } + + /// Updates from a protocol ContactFrame + func update(from frame: ContactFrame) { + name = frame.name + typeRawValue = frame.typeRawValue + // Preserve bit 0 (favorite) from existing flags, take bits 1-7 from frame + flags = (flags & 0x01) | (frame.flags & ~0x01) + outPathLength = frame.outPathLength + outPath = frame.outPath + lastAdvertTimestamp = frame.lastAdvertTimestamp + latitude = frame.latitude + longitude = frame.longitude + lastModified = frame.lastModified + } + + /// Converts to a protocol ContactFrame for sending to device + func toContactFrame() -> ContactFrame { + ContactFrame( + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: flags, + outPathLength: outPathLength, + outPath: outPath, + name: name, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified + ) + } } // MARK: - Sendable DTO /// A sendable snapshot of Contact for cross-actor transfers public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, RepeaterResolvable { - public let id: UUID - public var radioID: UUID - public let publicKey: Data - public let name: String - public let typeRawValue: UInt8 - public let flags: UInt8 - public let outPathLength: UInt8 - public let outPath: Data - public let lastAdvertTimestamp: UInt32 - public let latitude: Double - public let longitude: Double - public let lastModified: UInt32 - public let nickname: String? - public let isBlocked: Bool - public let isMuted: Bool - public let isFavorite: Bool - public let lastMessageDate: Date? - public let unreadCount: Int - public let unreadMentionCount: Int - public let ocvPreset: String? - public let customOCVArrayString: String? - - public init(from contact: Contact) { - self.id = contact.id - self.radioID = contact.radioID - self.publicKey = contact.publicKey - self.name = contact.name - self.typeRawValue = contact.typeRawValue - self.flags = contact.flags - self.outPathLength = contact.outPathLength - self.outPath = contact.outPath - self.lastAdvertTimestamp = contact.lastAdvertTimestamp - self.latitude = contact.latitude - self.longitude = contact.longitude - self.lastModified = contact.lastModified - self.nickname = contact.nickname - self.isBlocked = contact.isBlocked - self.isMuted = contact.isMuted - self.isFavorite = contact.isFavorite - self.lastMessageDate = contact.lastMessageDate - self.unreadCount = contact.unreadCount - self.unreadMentionCount = contact.unreadMentionCount - self.ocvPreset = contact.ocvPreset - self.customOCVArrayString = contact.customOCVArrayString - } - - /// Memberwise initializer for creating DTOs directly - public init( - id: UUID, - radioID: UUID, - publicKey: Data, - name: String, - typeRawValue: UInt8, - flags: UInt8, - outPathLength: UInt8, - outPath: Data, - lastAdvertTimestamp: UInt32, - latitude: Double, - longitude: Double, - lastModified: UInt32, - nickname: String?, - isBlocked: Bool, - isMuted: Bool, - isFavorite: Bool, - lastMessageDate: Date?, - unreadCount: Int, - unreadMentionCount: Int = 0, - ocvPreset: String? = nil, - customOCVArrayString: String? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.typeRawValue = typeRawValue - self.flags = flags - self.outPathLength = outPathLength - self.outPath = outPath - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.lastModified = lastModified - self.nickname = nickname - self.isBlocked = isBlocked - self.isMuted = isMuted - self.isFavorite = isFavorite - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.ocvPreset = ocvPreset - self.customOCVArrayString = customOCVArrayString - } - - public var type: ContactType { - ContactType(rawValue: typeRawValue) ?? .chat - } - - public var displayName: String { - nickname ?? name - } - - public var publicKeyPrefix: Data { - publicKey.prefix(6) - } - - public var isFloodRouted: Bool { - outPathLength == PacketBuilder.floodPathSentinel - } - - /// The hash size per hop in bytes (1, 2, or 3), derived from the upper 2 bits of ``outPathLength``. - public var pathHashSize: Int { - decodePathLen(outPathLength)?.hashSize ?? 1 - } - - /// The number of hops in the path, derived from the lower 6 bits of ``outPathLength``. - public var pathHopCount: Int { - decodePathLen(outPathLength)?.hopCount ?? 0 - } - - /// The hop count to surface in the UI: the deliberately-set out-path hops when a route exists, - /// otherwise the passively-heard inbound advert hops, which a contact does not store and the - /// caller looks up live from the discovered-node list. nil when flood-routed and none is known. - public func displayedHopCount(inboundHopCount: Int?) -> Int? { - isFloodRouted ? inboundHopCount : pathHopCount - } - - /// The total byte length of the path data (`pathHopCount * pathHashSize`). - public var pathByteLength: Int { - decodePathLen(outPathLength)?.byteLength ?? 0 - } - - /// Each hop as its raw hash bytes plus uppercase hex, e.g. `[(0xA3, "A3"), (0x7F, "7F")]`. - /// The raw bytes are needed to match a hop against a repeater's public-key prefix. - public var pathHops: [(data: Data, hex: String)] { - outPath.prefix(pathByteLength).pathHops(hashSize: pathHashSize) - } - - /// Each hop's hash as a hex string, e.g. `["A3", "7F", "42"]`. - public var pathNodesHex: [String] { - pathHops.map(\.hex) - } - - /// Human-readable path string with arrow separators, e.g. `"A3 → 7F → 42"`. - public var pathString: String { - pathNodesHex.joined(separator: " \u{2192} ") - } - - public var hasLocation: Bool { - let hasNonZero = latitude != 0 || longitude != 0 - guard hasNonZero else { return false } - return CLLocationCoordinate2DIsValid( - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - ) - } - - public var publicKeyHex: String { - publicKey.uppercaseHexString() - } - - /// Returns a copy with only `isMuted` changed. - public func with(isMuted: Bool) -> ContactDTO { - ContactDTO( - id: id, radioID: radioID, publicKey: publicKey, name: name, - typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, - outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, longitude: longitude, lastModified: lastModified, - nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, - isFavorite: isFavorite, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - ocvPreset: ocvPreset, customOCVArrayString: customOCVArrayString - ) - } - - /// Returns a copy with only `isFavorite` changed. - public func with(isFavorite: Bool) -> ContactDTO { - ContactDTO( - id: id, radioID: radioID, publicKey: publicKey, name: name, - typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, - outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, longitude: longitude, lastModified: lastModified, - nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, - isFavorite: isFavorite, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - ocvPreset: ocvPreset, customOCVArrayString: customOCVArrayString - ) - } - - /// The active OCV array for this contact (preset or custom) - public var activeOCVArray: [Int] { - // If custom preset with valid custom string, parse it - if ocvPreset == OCVPreset.custom.rawValue, let customString = customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - return parsed - } - } - - // Use preset if set - if let presetName = ocvPreset, let preset = OCVPreset(rawValue: presetName) { - return preset.ocvArray - } - - // Default to Li-Ion - return OCVPreset.liIon.ocvArray - } - - // MARK: - RepeaterResolvable - - public var recencyDate: Date { - Date(timeIntervalSince1970: Double(lastModified)) - } - - public var resolvableName: String { displayName } - - /// Converts to a protocol ContactFrame for sending to device - public func toContactFrame() -> ContactFrame { - ContactFrame( - publicKey: publicKey, - type: type, - typeRawValue: typeRawValue, - flags: flags, - outPathLength: outPathLength, - outPath: outPath, - name: name, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: lastModified - ) - } + public let id: UUID + public var radioID: UUID + public let publicKey: Data + public let name: String + public let typeRawValue: UInt8 + public let flags: UInt8 + public let outPathLength: UInt8 + public let outPath: Data + public let lastAdvertTimestamp: UInt32 + public let latitude: Double + public let longitude: Double + public let lastModified: UInt32 + public let nickname: String? + public let isBlocked: Bool + public let isMuted: Bool + public let isFavorite: Bool + public let lastMessageDate: Date? + public let unreadCount: Int + public let unreadMentionCount: Int + public let ocvPreset: String? + public let customOCVArrayString: String? + + public init(from contact: Contact) { + id = contact.id + radioID = contact.radioID + publicKey = contact.publicKey + name = contact.name + typeRawValue = contact.typeRawValue + flags = contact.flags + outPathLength = contact.outPathLength + outPath = contact.outPath + lastAdvertTimestamp = contact.lastAdvertTimestamp + latitude = contact.latitude + longitude = contact.longitude + lastModified = contact.lastModified + nickname = contact.nickname + isBlocked = contact.isBlocked + isMuted = contact.isMuted + isFavorite = contact.isFavorite + lastMessageDate = contact.lastMessageDate + unreadCount = contact.unreadCount + unreadMentionCount = contact.unreadMentionCount + ocvPreset = contact.ocvPreset + customOCVArrayString = contact.customOCVArrayString + } + + /// Memberwise initializer for creating DTOs directly + public init( + id: UUID, + radioID: UUID, + publicKey: Data, + name: String, + typeRawValue: UInt8, + flags: UInt8, + outPathLength: UInt8, + outPath: Data, + lastAdvertTimestamp: UInt32, + latitude: Double, + longitude: Double, + lastModified: UInt32, + nickname: String?, + isBlocked: Bool, + isMuted: Bool, + isFavorite: Bool, + lastMessageDate: Date?, + unreadCount: Int, + unreadMentionCount: Int = 0, + ocvPreset: String? = nil, + customOCVArrayString: String? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.typeRawValue = typeRawValue + self.flags = flags + self.outPathLength = outPathLength + self.outPath = outPath + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.lastModified = lastModified + self.nickname = nickname + self.isBlocked = isBlocked + self.isMuted = isMuted + self.isFavorite = isFavorite + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.ocvPreset = ocvPreset + self.customOCVArrayString = customOCVArrayString + } + + public var type: ContactType { + ContactType(rawValue: typeRawValue) ?? .chat + } + + public var displayName: String { + nickname ?? name + } + + public var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + public var isFloodRouted: Bool { + outPathLength == PacketBuilder.floodPathSentinel + } + + /// The hash size per hop in bytes (1, 2, or 3), derived from the upper 2 bits of ``outPathLength``. + public var pathHashSize: Int { + decodePathLen(outPathLength)?.hashSize ?? 1 + } + + /// The number of hops in the path, derived from the lower 6 bits of ``outPathLength``. + public var pathHopCount: Int { + decodePathLen(outPathLength)?.hopCount ?? 0 + } + + /// The hop count to surface in the UI: the deliberately-set out-path hops when a route exists, + /// otherwise the passively-heard inbound advert hops, which a contact does not store and the + /// caller looks up live from the discovered-node list. nil when flood-routed and none is known. + public func displayedHopCount(inboundHopCount: Int?) -> Int? { + isFloodRouted ? inboundHopCount : pathHopCount + } + + /// The total byte length of the path data (`pathHopCount * pathHashSize`). + public var pathByteLength: Int { + decodePathLen(outPathLength)?.byteLength ?? 0 + } + + /// Each hop as its raw hash bytes plus uppercase hex, e.g. `[(0xA3, "A3"), (0x7F, "7F")]`. + /// The raw bytes are needed to match a hop against a repeater's public-key prefix. + public var pathHops: [(data: Data, hex: String)] { + outPath.prefix(pathByteLength).pathHops(hashSize: pathHashSize) + } + + /// Each hop's hash as a hex string, e.g. `["A3", "7F", "42"]`. + public var pathNodesHex: [String] { + pathHops.map(\.hex) + } + + /// Human-readable path string with arrow separators, e.g. `"A3 → 7F → 42"`. + public var pathString: String { + pathNodesHex.joined(separator: " \u{2192} ") + } + + public var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + + public var publicKeyHex: String { + publicKey.uppercaseHexString() + } + + /// Returns a copy with only `isMuted` changed. + public func with(isMuted: Bool) -> ContactDTO { + ContactDTO( + id: id, radioID: radioID, publicKey: publicKey, name: name, + typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, + outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, longitude: longitude, lastModified: lastModified, + nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, + isFavorite: isFavorite, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + ocvPreset: ocvPreset, customOCVArrayString: customOCVArrayString + ) + } + + /// Returns a copy with only `isFavorite` changed. + public func with(isFavorite: Bool) -> ContactDTO { + ContactDTO( + id: id, radioID: radioID, publicKey: publicKey, name: name, + typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, + outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, longitude: longitude, lastModified: lastModified, + nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, + isFavorite: isFavorite, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + ocvPreset: ocvPreset, customOCVArrayString: customOCVArrayString + ) + } + + /// The active OCV array for this contact (preset or custom) + public var activeOCVArray: [Int] { + // If custom preset with valid custom string, parse it + if ocvPreset == OCVPreset.custom.rawValue, let customString = customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + return parsed + } + } + + // Use preset if set + if let presetName = ocvPreset, let preset = OCVPreset(rawValue: presetName) { + return preset.ocvArray + } + + // Default to Li-Ion + return OCVPreset.liIon.ocvArray + } + + // MARK: - RepeaterResolvable + + public var recencyDate: Date { + Date(timeIntervalSince1970: Double(lastModified)) + } + + public var resolvableName: String { + displayName + } + + /// Converts to a protocol ContactFrame for sending to device + public func toContactFrame() -> ContactFrame { + ContactFrame( + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: flags, + outPathLength: outPathLength, + outPath: outPath, + name: name, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified + ) + } + + /// A frame for this contact with the path reset to flood routing. A contact the radio doesn't + /// know has no valid stored path, so flooding is the only route that can reach it; the firmware + /// rediscovers the direct path from the first response. + public func floodedContactFrame(asOf now: UInt32) -> ContactFrame { + ContactFrame( + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: flags, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: name, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: now + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/ContactFrame.swift b/MC1Services/Sources/MC1Services/Models/ContactFrame.swift index 2fbdf10c..8b28b682 100644 --- a/MC1Services/Sources/MC1Services/Models/ContactFrame.swift +++ b/MC1Services/Sources/MC1Services/Models/ContactFrame.swift @@ -3,44 +3,44 @@ import MeshCore /// Contact information frame from device public struct ContactFrame: Sendable, Equatable { - public let publicKey: Data - public let type: ContactType - /// The raw 1-byte type value as it appears on the wire. Normally equal to `type.rawValue`; - /// preserved separately so a contact carrying a type byte not modeled by ``ContactType`` - /// round-trips to the device verbatim instead of being coerced. - public let typeRawValue: UInt8 - public let flags: UInt8 - public let outPathLength: UInt8 - public let outPath: Data - public let name: String - public let lastAdvertTimestamp: UInt32 - public let latitude: Double - public let longitude: Double - public let lastModified: UInt32 + public let publicKey: Data + public let type: ContactType + /// The raw 1-byte type value as it appears on the wire. Normally equal to `type.rawValue`; + /// preserved separately so a contact carrying a type byte not modeled by ``ContactType`` + /// round-trips to the device verbatim instead of being coerced. + public let typeRawValue: UInt8 + public let flags: UInt8 + public let outPathLength: UInt8 + public let outPath: Data + public let name: String + public let lastAdvertTimestamp: UInt32 + public let latitude: Double + public let longitude: Double + public let lastModified: UInt32 - public init( - publicKey: Data, - type: ContactType, - typeRawValue: UInt8? = nil, - flags: UInt8, - outPathLength: UInt8, - outPath: Data, - name: String, - lastAdvertTimestamp: UInt32, - latitude: Double, - longitude: Double, - lastModified: UInt32 - ) { - self.publicKey = publicKey - self.type = type - self.typeRawValue = typeRawValue ?? type.rawValue - self.flags = flags - self.outPathLength = outPathLength - self.outPath = outPath - self.name = name - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.lastModified = lastModified - } + public init( + publicKey: Data, + type: ContactType, + typeRawValue: UInt8? = nil, + flags: UInt8, + outPathLength: UInt8, + outPath: Data, + name: String, + lastAdvertTimestamp: UInt32, + latitude: Double, + longitude: Double, + lastModified: UInt32 + ) { + self.publicKey = publicKey + self.type = type + self.typeRawValue = typeRawValue ?? type.rawValue + self.flags = flags + self.outPathLength = outPathLength + self.outPath = outPath + self.name = name + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.lastModified = lastModified + } } diff --git a/MC1Services/Sources/MC1Services/Models/DebugLogEntry.swift b/MC1Services/Sources/MC1Services/Models/DebugLogEntry.swift index 761a4803..f7fbae82 100644 --- a/MC1Services/Sources/MC1Services/Models/DebugLogEntry.swift +++ b/MC1Services/Sources/MC1Services/Models/DebugLogEntry.swift @@ -4,67 +4,67 @@ import SwiftData /// SwiftData model for persisted debug log entries. @Model final class DebugLogEntry { - #Index([\.timestamp]) + #Index([\.timestamp]) - @Attribute(.unique) - var id: UUID + @Attribute(.unique) + var id: UUID - var timestamp: Date - var level: Int - var subsystem: String - var category: String - var message: String + var timestamp: Date + var level: Int + var subsystem: String + var category: String + var message: String - init( - id: UUID = UUID(), - timestamp: Date = Date(), - level: Int, - subsystem: String, - category: String, - message: String - ) { - self.id = id - self.timestamp = timestamp - self.level = level - self.subsystem = subsystem - self.category = category - self.message = message - } + init( + id: UUID = UUID(), + timestamp: Date = Date(), + level: Int, + subsystem: String, + category: String, + message: String + ) { + self.id = id + self.timestamp = timestamp + self.level = level + self.subsystem = subsystem + self.category = category + self.message = message + } } /// Sendable DTO for cross-actor transfer of DebugLogEntry data. public struct DebugLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { - public let id: UUID - public let timestamp: Date - public let level: DebugLogLevel - public let subsystem: String - public let category: String - public let message: String + public let id: UUID + public let timestamp: Date + public let level: DebugLogLevel + public let subsystem: String + public let category: String + public let message: String - public init( - id: UUID = UUID(), - timestamp: Date = Date(), - level: DebugLogLevel, - subsystem: String, - category: String, - message: String - ) { - self.id = id - self.timestamp = timestamp - self.level = level - self.subsystem = subsystem - self.category = category - // Truncate message to prevent memory issues - self.message = String(message.prefix(4000)) - } + public init( + id: UUID = UUID(), + timestamp: Date = Date(), + level: DebugLogLevel, + subsystem: String, + category: String, + message: String + ) { + self.id = id + self.timestamp = timestamp + self.level = level + self.subsystem = subsystem + self.category = category + // Truncate message to prevent memory issues + self.message = String(message.prefix(4000)) + } - /// Initialize from SwiftData model. - init(from model: DebugLogEntry) { - self.id = model.id - self.timestamp = model.timestamp - self.level = DebugLogLevel(rawValue: model.level) ?? .info - self.subsystem = model.subsystem - self.category = model.category - self.message = model.message - } + /// Initialize from SwiftData model. + init(from model: DebugLogEntry) { + id = model.id + timestamp = model.timestamp + level = DebugLogLevel(rawValue: model.level) ?? .info + subsystem = model.subsystem + category = model.category + message = model.message + } } diff --git a/MC1Services/Sources/MC1Services/Models/DebugLogLevel.swift b/MC1Services/Sources/MC1Services/Models/DebugLogLevel.swift index 2930628d..2920389c 100644 --- a/MC1Services/Sources/MC1Services/Models/DebugLogLevel.swift +++ b/MC1Services/Sources/MC1Services/Models/DebugLogLevel.swift @@ -1,21 +1,21 @@ import Foundation public enum DebugLogLevel: Int, Sendable, CaseIterable { - case debug = 0 - case info = 1 - case notice = 2 - case warning = 3 - case error = 4 - case fault = 5 + case debug = 0 + case info = 1 + case notice = 2 + case warning = 3 + case error = 4 + case fault = 5 - public var label: String { - switch self { - case .debug: return "DEBUG" - case .info: return "INFO" - case .notice: return "NOTICE" - case .warning: return "WARNING" - case .error: return "ERROR" - case .fault: return "FAULT" - } + public var label: String { + switch self { + case .debug: "DEBUG" + case .info: "INFO" + case .notice: "NOTICE" + case .warning: "WARNING" + case .error: "ERROR" + case .fault: "FAULT" } + } } diff --git a/MC1Services/Sources/MC1Services/Models/DecryptStatus.swift b/MC1Services/Sources/MC1Services/Models/DecryptStatus.swift index 12565ab9..edb98ac8 100644 --- a/MC1Services/Sources/MC1Services/Models/DecryptStatus.swift +++ b/MC1Services/Sources/MC1Services/Models/DecryptStatus.swift @@ -3,37 +3,37 @@ import Foundation /// Decryption outcome for channel and direct messages. public enum DecryptStatus: Int, Codable, Sendable, CaseIterable { - case notApplicable = 0 // Not a channel message (e.g., direct, advert) - case noMatchingKey = 1 // Channel: no stored channel matches - case hmacFailed = 2 // Key found but HMAC validation failed - case decryptFailed = 3 // HMAC passed but AES decrypt failed - case success = 4 // Decrypted successfully - case pending = 5 // Key found but decryption not yet implemented - case dmNoMatchingKey = 6 // DM: missing private key or contact public key + case notApplicable = 0 // Not a channel message (e.g., direct, advert) + case noMatchingKey = 1 // Channel: no stored channel matches + case hmacFailed = 2 // Key found but HMAC validation failed + case decryptFailed = 3 // HMAC passed but AES decrypt failed + case success = 4 // Decrypted successfully + case pending = 5 // Key found but decryption not yet implemented + case dmNoMatchingKey = 6 // DM: missing private key or contact public key - /// Developer-facing English description for logs; UI uses the app target's localized `DecryptStatus.localizedName`. - public var displayName: String { - switch self { - case .notApplicable: return "N/A" - case .noMatchingKey: return "No Key" - case .hmacFailed: return "HMAC Failed" - case .decryptFailed: return "Decrypt Failed" - case .success: return "Decrypted" - case .pending: return "Has Key" - case .dmNoMatchingKey: return "No DM Key" - } + /// Developer-facing English description for logs; UI uses the app target's localized `DecryptStatus.localizedName`. + public var displayName: String { + switch self { + case .notApplicable: "N/A" + case .noMatchingKey: "No Key" + case .hmacFailed: "HMAC Failed" + case .decryptFailed: "Decrypt Failed" + case .success: "Decrypted" + case .pending: "Has Key" + case .dmNoMatchingKey: "No DM Key" } + } - /// SF Symbol name for status indicator. - public var symbolName: String { - switch self { - case .notApplicable: return "minus.circle" - case .noMatchingKey: return "key.slash" - case .hmacFailed: return "exclamationmark.shield" - case .decryptFailed: return "lock.slash" - case .success: return "checkmark.seal" - case .pending: return "key" - case .dmNoMatchingKey: return "key.slash" - } + /// SF Symbol name for status indicator. + public var symbolName: String { + switch self { + case .notApplicable: "minus.circle" + case .noMatchingKey: "key.slash" + case .hmacFailed: "exclamationmark.shield" + case .decryptFailed: "lock.slash" + case .success: "checkmark.seal" + case .pending: "key" + case .dmNoMatchingKey: "key.slash" } + } } diff --git a/MC1Services/Sources/MC1Services/Models/DeliveryContext.swift b/MC1Services/Sources/MC1Services/Models/DeliveryContext.swift index 96fa471e..186aa29a 100644 --- a/MC1Services/Sources/MC1Services/Models/DeliveryContext.swift +++ b/MC1Services/Sources/MC1Services/Models/DeliveryContext.swift @@ -9,11 +9,11 @@ import Foundation /// - `initialSync` positions a drained backlog batch as one contiguous block at the drain /// time carried in `anchor`, so reconnect history lands together near the bottom rather /// than scattered through scrollback by send time. -enum DeliveryContext: Sendable { - /// Drained from the device's stored backlog during initial connect or resync. - /// `anchor` is captured once per drain so every message in the batch shares a sort - /// date and forms a contiguous block positioned at delivery time, send-ordered within. - case initialSync(anchor: Date) - /// Pushed in real time while connected. - case live +enum DeliveryContext { + /// Drained from the device's stored backlog during initial connect or resync. + /// `anchor` is captured once per drain so every message in the batch shares a sort + /// date and forms a contiguous block positioned at delivery time, send-ordered within. + case initialSync(anchor: Date) + /// Pushed in real time while connected. + case live } diff --git a/MC1Services/Sources/MC1Services/Models/Device.swift b/MC1Services/Sources/MC1Services/Models/Device.swift index 939abd2e..e1624ee3 100644 --- a/MC1Services/Sources/MC1Services/Models/Device.swift +++ b/MC1Services/Sources/MC1Services/Models/Device.swift @@ -7,779 +7,793 @@ import SwiftData /// Each device has its own isolated data store for contacts, messages, and channels. @Model public final class Device { - /// Unique identifier for the device (derived from BLE peripheral identifier) - @Attribute(.unique) - public var id: UUID + /// Unique identifier for the device (derived from BLE peripheral identifier) + @Attribute(.unique) + public var id: UUID - /// New column (not renamed from deviceID), so no @Attribute(originalName:) unlike child models. - public var radioID: UUID = UUID() + /// New column (not renamed from deviceID), so no @Attribute(originalName:) unlike child models. + public var radioID: UUID = UUID() - /// The 32-byte public key of the device - public var publicKey: Data + /// The 32-byte public key of the device + public var publicKey: Data - /// Human-readable name of the node - public var nodeName: String + /// Human-readable name of the node + public var nodeName: String - /// Firmware version code (e.g., 8) - public var firmwareVersion: UInt8 + /// Firmware version code (e.g., 8) + public var firmwareVersion: UInt8 - /// Firmware version string (e.g., "v1.11.0") - public var firmwareVersionString: String + /// Firmware version string (e.g., "v1.11.0") + public var firmwareVersionString: String - /// Manufacturer name - public var manufacturerName: String + /// Manufacturer name + public var manufacturerName: String - /// Build date string - public var buildDate: String + /// Build date string + public var buildDate: String - /// Maximum number of contacts supported - public var maxContacts: UInt16 + /// Maximum number of contacts supported + public var maxContacts: UInt16 - /// Maximum number of channels supported - public var maxChannels: UInt8 + /// Maximum number of channels supported + public var maxChannels: UInt8 - /// Radio frequency in kHz - public var frequency: UInt32 + /// Radio frequency in kHz + public var frequency: UInt32 - /// Radio bandwidth in kHz - public var bandwidth: UInt32 + /// Radio bandwidth in kHz + public var bandwidth: UInt32 - /// LoRa spreading factor (5-12) - public var spreadingFactor: UInt8 + /// LoRa spreading factor (5-12) + public var spreadingFactor: UInt8 - /// LoRa coding rate (5-8) - public var codingRate: UInt8 + /// LoRa coding rate (5-8) + public var codingRate: UInt8 - /// Transmit power in dBm (may be negative) - public var txPower: Int8 + /// Transmit power in dBm (may be negative) + public var txPower: Int8 - /// Maximum transmit power in dBm - public var maxTxPower: Int8 + /// Maximum transmit power in dBm + public var maxTxPower: Int8 - /// Node latitude (scaled by 1e6) - public var latitude: Double + /// Node latitude (scaled by 1e6) + public var latitude: Double - /// Node longitude (scaled by 1e6) - public var longitude: Double + /// Node longitude (scaled by 1e6) + public var longitude: Double - /// BLE PIN (0 = disabled, 100000-999999 = enabled) - public var blePin: UInt32 - - /// Whether client repeat mode is enabled (v9+ firmware) - public var clientRepeat: Bool = false - - /// Path hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). Firmware v10+. - public var pathHashMode: UInt8 = 0 - - /// Name of the device's persisted default flood scope, or `nil` when cleared. Firmware v11+. - /// The scope key is derived from this name on-demand via ``MeshCore/FloodScope/region(_:)``; - /// only the name is cached here because the user-facing flow selects regions by name. - public var defaultFloodScopeName: String? - - /// Cached radio settings from before repeat mode was enabled, for restoration on disable. - /// All 4 fields are set together when enabling repeat mode, and cleared together when disabling. - public var preRepeatFrequency: UInt32? - public var preRepeatBandwidth: UInt32? - public var preRepeatSpreadingFactor: UInt8? - public var preRepeatCodingRate: UInt8? - - /// Manual add contacts mode - public var manualAddContacts: Bool - - /// Auto-add configuration bitmask from device - public var autoAddConfig: UInt8 = 0 - - /// Maximum hops for auto-add filtering. 0 = no limit, 1 = direct only, N = up to N-1 hops. - public var autoAddMaxHops: UInt8 = 0 - - /// Number of acknowledgments to send for direct messages (0=disabled, 1-2 typical) - public var multiAcks: UInt8 - - /// Telemetry mode for base data - public var telemetryModeBase: UInt8 - - /// Telemetry mode for location data - public var telemetryModeLoc: UInt8 - - /// Telemetry mode for environment data - public var telemetryModeEnv: UInt8 - - /// Advertisement location policy - public var advertLocationPolicy: UInt8 - - /// Last time the device was connected - public var lastConnected: Date - - /// Last sync timestamp for contacts (watermark for incremental sync) - public var lastContactSync: UInt32 - - /// Whether this is the currently active device - public var isActive: Bool - - /// Selected OCV preset name (nil = liIon default) - public var ocvPreset: String? - - /// Custom OCV array as comma-separated string (e.g., "4240,4112,4029,...") - public var customOCVArrayString: String? - - /// Connection methods available for this device (BLE, WiFi, etc.) - public var connectionMethods: [ConnectionMethod] = [] - - /// Region codes known to this device - public var knownRegions: [String] = [] - - public init( - id: UUID = UUID(), - radioID: UUID = UUID(), - publicKey: Data, - nodeName: String, - firmwareVersion: UInt8 = 0, - firmwareVersionString: String = "", - manufacturerName: String = "", - buildDate: String = "", - maxContacts: UInt16 = 100, - maxChannels: UInt8 = 8, - frequency: UInt32 = Device.Defaults.frequency, - bandwidth: UInt32 = Device.Defaults.bandwidth, - spreadingFactor: UInt8 = Device.Defaults.spreadingFactor, - codingRate: UInt8 = Device.Defaults.codingRate, - txPower: Int8 = Device.Defaults.txPower, - maxTxPower: Int8 = Device.Defaults.maxTxPower, - latitude: Double = Device.Defaults.latitude, - longitude: Double = Device.Defaults.longitude, - blePin: UInt32 = Device.Defaults.blePin, - clientRepeat: Bool = Device.Defaults.clientRepeat, - pathHashMode: UInt8 = Device.Defaults.pathHashMode, - defaultFloodScopeName: String? = Device.Defaults.defaultFloodScopeName, - preRepeatFrequency: UInt32? = Device.Defaults.preRepeatFrequency, - preRepeatBandwidth: UInt32? = Device.Defaults.preRepeatBandwidth, - preRepeatSpreadingFactor: UInt8? = Device.Defaults.preRepeatSpreadingFactor, - preRepeatCodingRate: UInt8? = Device.Defaults.preRepeatCodingRate, - manualAddContacts: Bool = Device.Defaults.manualAddContacts, - autoAddConfig: UInt8 = Device.Defaults.autoAddConfig, - autoAddMaxHops: UInt8 = Device.Defaults.autoAddMaxHops, - multiAcks: UInt8 = Device.Defaults.multiAcks, - telemetryModeBase: UInt8 = Device.Defaults.telemetryModeBase, - telemetryModeLoc: UInt8 = Device.Defaults.telemetryModeLoc, - telemetryModeEnv: UInt8 = Device.Defaults.telemetryModeEnv, - advertLocationPolicy: UInt8 = Device.Defaults.advertLocationPolicy, - lastConnected: Date = Date(), - lastContactSync: UInt32 = 0, - isActive: Bool = false, - ocvPreset: String? = nil, - customOCVArrayString: String? = nil, - connectionMethods: [ConnectionMethod] = [], - knownRegions: [String] = [] - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.nodeName = nodeName - self.firmwareVersion = firmwareVersion - self.firmwareVersionString = firmwareVersionString - self.manufacturerName = manufacturerName - self.buildDate = buildDate - self.maxContacts = maxContacts - self.maxChannels = maxChannels - self.frequency = frequency - self.bandwidth = bandwidth - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.txPower = txPower - self.maxTxPower = maxTxPower - self.latitude = latitude - self.longitude = longitude - self.blePin = blePin - self.clientRepeat = clientRepeat - self.pathHashMode = pathHashMode - self.defaultFloodScopeName = defaultFloodScopeName - self.preRepeatFrequency = preRepeatFrequency - self.preRepeatBandwidth = preRepeatBandwidth - self.preRepeatSpreadingFactor = preRepeatSpreadingFactor - self.preRepeatCodingRate = preRepeatCodingRate - self.manualAddContacts = manualAddContacts - self.autoAddConfig = autoAddConfig - self.autoAddMaxHops = autoAddMaxHops - self.multiAcks = multiAcks - self.telemetryModeBase = telemetryModeBase - self.telemetryModeLoc = telemetryModeLoc - self.telemetryModeEnv = telemetryModeEnv - self.advertLocationPolicy = advertLocationPolicy - self.lastConnected = lastConnected - self.lastContactSync = lastContactSync - self.isActive = isActive - self.ocvPreset = ocvPreset - self.customOCVArrayString = customOCVArrayString - self.connectionMethods = connectionMethods - self.knownRegions = knownRegions - } - - /// Builds a model instance directly from a DTO. Shared by sync and backup - /// batch-insert paths so they can't drift on field coverage. - public convenience init(dto: DeviceDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - publicKey: dto.publicKey, - nodeName: dto.nodeName, - firmwareVersion: dto.firmwareVersion, - firmwareVersionString: dto.firmwareVersionString, - manufacturerName: dto.manufacturerName, - buildDate: dto.buildDate, - maxContacts: dto.maxContacts, - maxChannels: dto.maxChannels, - frequency: dto.frequency, - bandwidth: dto.bandwidth, - spreadingFactor: dto.spreadingFactor, - codingRate: dto.codingRate, - txPower: dto.txPower, - maxTxPower: dto.maxTxPower, - latitude: dto.latitude, - longitude: dto.longitude, - blePin: dto.blePin, - clientRepeat: dto.clientRepeat, - pathHashMode: dto.pathHashMode, - defaultFloodScopeName: dto.defaultFloodScopeName, - preRepeatFrequency: dto.preRepeatFrequency, - preRepeatBandwidth: dto.preRepeatBandwidth, - preRepeatSpreadingFactor: dto.preRepeatSpreadingFactor, - preRepeatCodingRate: dto.preRepeatCodingRate, - manualAddContacts: dto.manualAddContacts, - autoAddConfig: dto.autoAddConfig, - autoAddMaxHops: dto.autoAddMaxHops, - multiAcks: dto.multiAcks, - telemetryModeBase: dto.telemetryModeBase, - telemetryModeLoc: dto.telemetryModeLoc, - telemetryModeEnv: dto.telemetryModeEnv, - advertLocationPolicy: dto.advertLocationPolicy, - lastConnected: dto.lastConnected, - lastContactSync: dto.lastContactSync, - isActive: dto.isActive, - ocvPreset: dto.ocvPreset, - customOCVArrayString: dto.customOCVArrayString, - connectionMethods: dto.connectionMethods, - knownRegions: dto.knownRegions - ) - } - - /// Applies all mutable fields from a DTO to this model instance. - /// Unlike `Channel.apply` and `Contact.apply`, the identity fields - /// `publicKey` and `radioID` are deliberately rewritten: rows are matched - /// by the stable `id`, runtime key rotation must persist the new key, and - /// ghost reconciliation after a partial backup import depends on the - /// overwrite (see `ConnectionManager.reconcileIdentity`). - func apply(_ dto: DeviceDTO) { - radioID = dto.radioID - publicKey = dto.publicKey - nodeName = dto.nodeName - firmwareVersion = dto.firmwareVersion - firmwareVersionString = dto.firmwareVersionString - manufacturerName = dto.manufacturerName - buildDate = dto.buildDate - maxContacts = dto.maxContacts - maxChannels = dto.maxChannels - frequency = dto.frequency - bandwidth = dto.bandwidth - spreadingFactor = dto.spreadingFactor - codingRate = dto.codingRate - txPower = dto.txPower - maxTxPower = dto.maxTxPower - latitude = dto.latitude - longitude = dto.longitude - blePin = dto.blePin - clientRepeat = dto.clientRepeat - pathHashMode = dto.pathHashMode - defaultFloodScopeName = dto.defaultFloodScopeName - preRepeatFrequency = dto.preRepeatFrequency - preRepeatBandwidth = dto.preRepeatBandwidth - preRepeatSpreadingFactor = dto.preRepeatSpreadingFactor - preRepeatCodingRate = dto.preRepeatCodingRate - manualAddContacts = dto.manualAddContacts - autoAddConfig = dto.autoAddConfig - autoAddMaxHops = dto.autoAddMaxHops - multiAcks = dto.multiAcks - telemetryModeBase = dto.telemetryModeBase - telemetryModeLoc = dto.telemetryModeLoc - telemetryModeEnv = dto.telemetryModeEnv - advertLocationPolicy = dto.advertLocationPolicy - lastConnected = dto.lastConnected - lastContactSync = dto.lastContactSync - isActive = dto.isActive - ocvPreset = dto.ocvPreset - customOCVArrayString = dto.customOCVArrayString - connectionMethods = dto.connectionMethods - // knownRegions is app-only state owned solely by addDeviceKnownRegion/ - // removeDeviceKnownRegion. It is excluded from this full overwrite so a - // stale or empty DTO from a read-snapshot-then-save caller cannot erase - // regions the targeted path committed. First write is Device(dto:) at insert. - } + /// BLE PIN (0 = disabled, 100000-999999 = enabled) + public var blePin: UInt32 + + /// Whether client repeat mode is enabled (v9+ firmware) + public var clientRepeat: Bool = false + + /// Path hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). Firmware v10+. + public var pathHashMode: UInt8 = 0 + + /// Name of the device's persisted default flood scope, or `nil` when cleared. Firmware v11+. + /// The scope key is derived from this name on-demand via ``MeshCore/FloodScope/region(_:)``; + /// only the name is cached here because the user-facing flow selects regions by name. + public var defaultFloodScopeName: String? + + /// Cached radio settings from before repeat mode was enabled, for restoration on disable. + /// All 4 fields are set together when enabling repeat mode, and cleared together when disabling. + public var preRepeatFrequency: UInt32? + public var preRepeatBandwidth: UInt32? + public var preRepeatSpreadingFactor: UInt8? + public var preRepeatCodingRate: UInt8? + + /// Manual add contacts mode + public var manualAddContacts: Bool + + /// Auto-add configuration bitmask from device + public var autoAddConfig: UInt8 = 0 + + /// Maximum hops for auto-add filtering. 0 = no limit, 1 = direct only, N = up to N-1 hops. + public var autoAddMaxHops: UInt8 = 0 + + /// Number of acknowledgments to send for direct messages (0=disabled, 1-2 typical) + public var multiAcks: UInt8 + + /// Telemetry mode for base data + public var telemetryModeBase: UInt8 + + /// Telemetry mode for location data + public var telemetryModeLoc: UInt8 + + /// Telemetry mode for environment data + public var telemetryModeEnv: UInt8 + + /// Advertisement location policy + public var advertLocationPolicy: UInt8 + + /// Last time the device was connected + public var lastConnected: Date + + /// Last sync timestamp for contacts (watermark for incremental sync) + public var lastContactSync: UInt32 + + /// Whether this is the currently active device + public var isActive: Bool + + /// Selected OCV preset name (nil = liIon default) + public var ocvPreset: String? + + /// Custom OCV array as comma-separated string (e.g., "4240,4112,4029,...") + public var customOCVArrayString: String? + + /// Connection methods available for this device (BLE, WiFi, etc.) + public var connectionMethods: [ConnectionMethod] = [] + + /// Region codes known to this device + public var knownRegions: [String] = [] + + public init( + id: UUID = UUID(), + radioID: UUID = UUID(), + publicKey: Data, + nodeName: String, + firmwareVersion: UInt8 = 0, + firmwareVersionString: String = "", + manufacturerName: String = "", + buildDate: String = "", + maxContacts: UInt16 = 100, + maxChannels: UInt8 = 8, + frequency: UInt32 = Device.Defaults.frequency, + bandwidth: UInt32 = Device.Defaults.bandwidth, + spreadingFactor: UInt8 = Device.Defaults.spreadingFactor, + codingRate: UInt8 = Device.Defaults.codingRate, + txPower: Int8 = Device.Defaults.txPower, + maxTxPower: Int8 = Device.Defaults.maxTxPower, + latitude: Double = Device.Defaults.latitude, + longitude: Double = Device.Defaults.longitude, + blePin: UInt32 = Device.Defaults.blePin, + clientRepeat: Bool = Device.Defaults.clientRepeat, + pathHashMode: UInt8 = Device.Defaults.pathHashMode, + defaultFloodScopeName: String? = Device.Defaults.defaultFloodScopeName, + preRepeatFrequency: UInt32? = Device.Defaults.preRepeatFrequency, + preRepeatBandwidth: UInt32? = Device.Defaults.preRepeatBandwidth, + preRepeatSpreadingFactor: UInt8? = Device.Defaults.preRepeatSpreadingFactor, + preRepeatCodingRate: UInt8? = Device.Defaults.preRepeatCodingRate, + manualAddContacts: Bool = Device.Defaults.manualAddContacts, + autoAddConfig: UInt8 = Device.Defaults.autoAddConfig, + autoAddMaxHops: UInt8 = Device.Defaults.autoAddMaxHops, + multiAcks: UInt8 = Device.Defaults.multiAcks, + telemetryModeBase: UInt8 = Device.Defaults.telemetryModeBase, + telemetryModeLoc: UInt8 = Device.Defaults.telemetryModeLoc, + telemetryModeEnv: UInt8 = Device.Defaults.telemetryModeEnv, + advertLocationPolicy: UInt8 = Device.Defaults.advertLocationPolicy, + lastConnected: Date = Date(), + lastContactSync: UInt32 = 0, + isActive: Bool = false, + ocvPreset: String? = nil, + customOCVArrayString: String? = nil, + connectionMethods: [ConnectionMethod] = [], + knownRegions: [String] = [] + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.nodeName = nodeName + self.firmwareVersion = firmwareVersion + self.firmwareVersionString = firmwareVersionString + self.manufacturerName = manufacturerName + self.buildDate = buildDate + self.maxContacts = maxContacts + self.maxChannels = maxChannels + self.frequency = frequency + self.bandwidth = bandwidth + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.txPower = txPower + self.maxTxPower = maxTxPower + self.latitude = latitude + self.longitude = longitude + self.blePin = blePin + self.clientRepeat = clientRepeat + self.pathHashMode = pathHashMode + self.defaultFloodScopeName = defaultFloodScopeName + self.preRepeatFrequency = preRepeatFrequency + self.preRepeatBandwidth = preRepeatBandwidth + self.preRepeatSpreadingFactor = preRepeatSpreadingFactor + self.preRepeatCodingRate = preRepeatCodingRate + self.manualAddContacts = manualAddContacts + self.autoAddConfig = autoAddConfig + self.autoAddMaxHops = autoAddMaxHops + self.multiAcks = multiAcks + self.telemetryModeBase = telemetryModeBase + self.telemetryModeLoc = telemetryModeLoc + self.telemetryModeEnv = telemetryModeEnv + self.advertLocationPolicy = advertLocationPolicy + self.lastConnected = lastConnected + self.lastContactSync = lastContactSync + self.isActive = isActive + self.ocvPreset = ocvPreset + self.customOCVArrayString = customOCVArrayString + self.connectionMethods = connectionMethods + self.knownRegions = knownRegions + } + + /// Builds a model instance directly from a DTO. Shared by sync and backup + /// batch-insert paths so they can't drift on field coverage. + public convenience init(dto: DeviceDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + publicKey: dto.publicKey, + nodeName: dto.nodeName, + firmwareVersion: dto.firmwareVersion, + firmwareVersionString: dto.firmwareVersionString, + manufacturerName: dto.manufacturerName, + buildDate: dto.buildDate, + maxContacts: dto.maxContacts, + maxChannels: dto.maxChannels, + frequency: dto.frequency, + bandwidth: dto.bandwidth, + spreadingFactor: dto.spreadingFactor, + codingRate: dto.codingRate, + txPower: dto.txPower, + maxTxPower: dto.maxTxPower, + latitude: dto.latitude, + longitude: dto.longitude, + blePin: dto.blePin, + clientRepeat: dto.clientRepeat, + pathHashMode: dto.pathHashMode, + defaultFloodScopeName: dto.defaultFloodScopeName, + preRepeatFrequency: dto.preRepeatFrequency, + preRepeatBandwidth: dto.preRepeatBandwidth, + preRepeatSpreadingFactor: dto.preRepeatSpreadingFactor, + preRepeatCodingRate: dto.preRepeatCodingRate, + manualAddContacts: dto.manualAddContacts, + autoAddConfig: dto.autoAddConfig, + autoAddMaxHops: dto.autoAddMaxHops, + multiAcks: dto.multiAcks, + telemetryModeBase: dto.telemetryModeBase, + telemetryModeLoc: dto.telemetryModeLoc, + telemetryModeEnv: dto.telemetryModeEnv, + advertLocationPolicy: dto.advertLocationPolicy, + lastConnected: dto.lastConnected, + lastContactSync: dto.lastContactSync, + isActive: dto.isActive, + ocvPreset: dto.ocvPreset, + customOCVArrayString: dto.customOCVArrayString, + connectionMethods: dto.connectionMethods, + knownRegions: dto.knownRegions + ) + } + + /// Applies all mutable fields from a DTO to this model instance. + /// Unlike `Channel.apply` and `Contact.apply`, the identity fields + /// `publicKey` and `radioID` are deliberately rewritten: rows are matched + /// by the stable `id`, runtime key rotation must persist the new key, and + /// ghost reconciliation after a partial backup import depends on the + /// overwrite (see `ConnectionManager.reconcileIdentity`). + func apply(_ dto: DeviceDTO) { + radioID = dto.radioID + publicKey = dto.publicKey + nodeName = dto.nodeName + firmwareVersion = dto.firmwareVersion + firmwareVersionString = dto.firmwareVersionString + manufacturerName = dto.manufacturerName + buildDate = dto.buildDate + maxContacts = dto.maxContacts + maxChannels = dto.maxChannels + frequency = dto.frequency + bandwidth = dto.bandwidth + spreadingFactor = dto.spreadingFactor + codingRate = dto.codingRate + txPower = dto.txPower + maxTxPower = dto.maxTxPower + latitude = dto.latitude + longitude = dto.longitude + blePin = dto.blePin + clientRepeat = dto.clientRepeat + pathHashMode = dto.pathHashMode + defaultFloodScopeName = dto.defaultFloodScopeName + preRepeatFrequency = dto.preRepeatFrequency + preRepeatBandwidth = dto.preRepeatBandwidth + preRepeatSpreadingFactor = dto.preRepeatSpreadingFactor + preRepeatCodingRate = dto.preRepeatCodingRate + manualAddContacts = dto.manualAddContacts + autoAddConfig = dto.autoAddConfig + autoAddMaxHops = dto.autoAddMaxHops + multiAcks = dto.multiAcks + telemetryModeBase = dto.telemetryModeBase + telemetryModeLoc = dto.telemetryModeLoc + telemetryModeEnv = dto.telemetryModeEnv + advertLocationPolicy = dto.advertLocationPolicy + lastConnected = dto.lastConnected + lastContactSync = dto.lastContactSync + isActive = dto.isActive + ocvPreset = dto.ocvPreset + customOCVArrayString = dto.customOCVArrayString + connectionMethods = dto.connectionMethods + // knownRegions is app-only state owned solely by addDeviceKnownRegion/ + // removeDeviceKnownRegion. It is excluded from this full overwrite so a + // stale or empty DTO from a read-snapshot-then-save caller cannot erase + // regions the targeted path committed. First write is Device(dto:) at insert. + } } // MARK: - Sendable DTO /// A sendable snapshot of Device for cross-actor transfers public struct DeviceDTO: Sendable, Equatable, Identifiable, Codable { - public var id: UUID - public var radioID: UUID - public var publicKey: Data - public var nodeName: String - public var firmwareVersion: UInt8 - public var firmwareVersionString: String - public var manufacturerName: String - public var buildDate: String - public var maxContacts: UInt16 - public var maxChannels: UInt8 - public var frequency: UInt32 - public var bandwidth: UInt32 - public var spreadingFactor: UInt8 - public var codingRate: UInt8 - public var txPower: Int8 - public var maxTxPower: Int8 - public var latitude: Double - public var longitude: Double - public var blePin: UInt32 - public var clientRepeat: Bool - public var pathHashMode: UInt8 - - /// Name of the device's persisted default flood scope, or `nil` when cleared. Firmware v11+. - public var defaultFloodScopeName: String? - - /// The hash size per hop in bytes (1, 2, or 3), derived from ``pathHashMode``. - public var hashSize: Int { Int(pathHashMode) + 1 } - - /// Hash size per hop in trace packets (1, 2, or 4 bytes), derived from ``pathHashMode``. - /// Trace protocol uses power-of-2 encoding: `1 << pathHashMode`. - public var traceHashSize: Int { 1 << Int(pathHashMode) } - - public var hasLocation: Bool { - let hasNonZero = latitude != 0 || longitude != 0 - guard hasNonZero else { return false } - return CLLocationCoordinate2DIsValid( - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - ) - } - - public var preRepeatFrequency: UInt32? - public var preRepeatBandwidth: UInt32? - public var preRepeatSpreadingFactor: UInt8? - public var preRepeatCodingRate: UInt8? - public var manualAddContacts: Bool - public var autoAddConfig: UInt8 - public var autoAddMaxHops: UInt8 - public var multiAcks: UInt8 - public var telemetryModeBase: UInt8 - public var telemetryModeLoc: UInt8 - public var telemetryModeEnv: UInt8 - public var advertLocationPolicy: UInt8 - public var lastConnected: Date - public var lastContactSync: UInt32 - public var isActive: Bool - public var ocvPreset: String? - public var customOCVArrayString: String? - public var connectionMethods: [ConnectionMethod] - public var knownRegions: [String] - - /// Computed auto-add mode based on manualAddContacts and autoAddConfig - public var autoAddMode: AutoAddMode { - AutoAddMode.mode(manualAddContacts: manualAddContacts, autoAddConfig: autoAddConfig) - } - - /// Whether to auto-add Contact type nodes - public var autoAddContacts: Bool { - autoAddConfig & AutoAddConfig.contactsBit != 0 - } - - /// Whether to auto-add Repeater type nodes - public var autoAddRepeaters: Bool { - autoAddConfig & AutoAddConfig.repeatersBit != 0 - } - - /// Whether to auto-add Room Server type nodes - public var autoAddRoomServers: Bool { - autoAddConfig & AutoAddConfig.roomServersBit != 0 - } - - /// Whether to overwrite oldest non-favorite when storage is full - public var overwriteOldest: Bool { - autoAddConfig & AutoAddConfig.overwriteOldestBit != 0 + public var id: UUID + public var radioID: UUID + public var publicKey: Data + public var nodeName: String + public var firmwareVersion: UInt8 + public var firmwareVersionString: String + public var manufacturerName: String + public var buildDate: String + public var maxContacts: UInt16 + public var maxChannels: UInt8 + public var frequency: UInt32 + public var bandwidth: UInt32 + public var spreadingFactor: UInt8 + public var codingRate: UInt8 + public var txPower: Int8 + public var maxTxPower: Int8 + public var latitude: Double + public var longitude: Double + public var blePin: UInt32 + public var clientRepeat: Bool + public var pathHashMode: UInt8 + + /// Name of the device's persisted default flood scope, or `nil` when cleared. Firmware v11+. + public var defaultFloodScopeName: String? + + /// The hash size per hop in bytes (1, 2, or 3), derived from ``pathHashMode``. + public var hashSize: Int { + Int(pathHashMode) + 1 + } + + /// Hash size per hop in trace packets (1, 2, or 4 bytes), derived from ``pathHashMode``. + /// Trace protocol uses power-of-2 encoding: `1 << pathHashMode`. + public var traceHashSize: Int { + 1 << Int(pathHashMode) + } + + public var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + + public var preRepeatFrequency: UInt32? + public var preRepeatBandwidth: UInt32? + public var preRepeatSpreadingFactor: UInt8? + public var preRepeatCodingRate: UInt8? + public var manualAddContacts: Bool + public var autoAddConfig: UInt8 + public var autoAddMaxHops: UInt8 + public var multiAcks: UInt8 + public var telemetryModeBase: UInt8 + public var telemetryModeLoc: UInt8 + public var telemetryModeEnv: UInt8 + public var advertLocationPolicy: UInt8 + public var lastConnected: Date + public var lastContactSync: UInt32 + public var isActive: Bool + public var ocvPreset: String? + public var customOCVArrayString: String? + public var connectionMethods: [ConnectionMethod] + public var knownRegions: [String] + + /// Computed auto-add mode based on manualAddContacts and autoAddConfig + public var autoAddMode: AutoAddMode { + AutoAddMode.mode(manualAddContacts: manualAddContacts, autoAddConfig: autoAddConfig) + } + + /// Whether to auto-add Contact type nodes + public var autoAddContacts: Bool { + autoAddConfig & AutoAddConfig.contactsBit != 0 + } + + /// Whether to auto-add Repeater type nodes + public var autoAddRepeaters: Bool { + autoAddConfig & AutoAddConfig.repeatersBit != 0 + } + + /// Whether to auto-add Room Server type nodes + public var autoAddRoomServers: Bool { + autoAddConfig & AutoAddConfig.roomServersBit != 0 + } + + /// Whether to overwrite oldest non-favorite when storage is full + public var overwriteOldest: Bool { + autoAddConfig & AutoAddConfig.overwriteOldestBit != 0 + } + + /// Whether the device supports auto-add configuration (v1.12+) + /// Devices with older firmware only support manualAddContacts toggle + public var supportsAutoAddConfig: Bool { + firmwareVersionString.isAtLeast(major: 1, minor: 12) + } + + /// Whether the device supports auto-add max hops (v1.14+) + public var supportsAutoAddMaxHops: Bool { + firmwareVersionString.isAtLeast(major: 1, minor: 14) + } + + /// Whether the trace command honors a per-trace hash size in its flags byte, + /// letting a trace use a hash size different from `pathHashMode`. Added in + /// firmware v1.11.0; FIRMWARE_VER_CODE stayed 8 from v1.10.0 through v1.12.0, + /// so the version string disambiguates the v1.11/v1.12 window while + /// firmwareVersion >= 9 (v1.13+) still holds if a fork rewrites the string. + public var supportsTraceHashSizeOverride: Bool { + firmwareVersion >= 9 || firmwareVersionString.isAtLeast(major: 1, minor: 11) + } + + /// Whether this device supports client repeat mode (firmware v9+) + public var supportsClientRepeat: Bool { + firmwareVersion >= 9 + } + + /// Whether this device supports path hash mode configuration (firmware v10+) + public var supportsPathHashMode: Bool { + firmwareVersion >= 10 + } + + /// Whether this device supports the persisted default flood scope (firmware v11+). + public var supportsDefaultFloodScope: Bool { + firmwareVersion >= 11 + } + + /// Whether this device supports forcing un-scoped flood broadcasts that override + /// the persisted default flood scope (firmware v12+). + public var supportsUnscopedFloodSend: Bool { + firmwareVersion >= 12 + } + + /// Whether the radio honors an anonymous request to a non-contact public key, + /// auto-adding a temporary zero-hop entry so the app can query a nearby repeater + /// (e.g. for region discovery) without first adding it. Added with + /// FIRMWARE_VER_CODE 13; the version string covers a fork that leaves it unbumped. + public var supportsAdHocRepeaterRequest: Bool { + firmwareVersion >= 13 || firmwareVersionString.isAtLeast(major: 1, minor: 16) + } + + /// Advertisement location policy interpreted from raw value. + public var advertLocationPolicyMode: AdvertLocationPolicy { + AdvertLocationPolicy(rawValue: advertLocationPolicy) ?? .none + } + + /// Telemetry modes constructed from raw base/location/environment values. + public var telemetryModes: TelemetryModes { + TelemetryModes(base: telemetryModeBase, location: telemetryModeLoc, environment: telemetryModeEnv) + } + + /// Whether location is shared publicly in advertisements. + public var sharesLocationPublicly: Bool { + advertLocationPolicy > 0 + } + + /// Whether pre-repeat radio settings are saved for restoration. + public var hasPreRepeatSettings: Bool { + preRepeatFrequency != nil && preRepeatBandwidth != nil && + preRepeatSpreadingFactor != nil && preRepeatCodingRate != nil + } + + public init( + id: UUID, + radioID: UUID = UUID(), + publicKey: Data, + nodeName: String, + firmwareVersion: UInt8, + firmwareVersionString: String, + manufacturerName: String, + buildDate: String, + maxContacts: UInt16, + maxChannels: UInt8, + frequency: UInt32, + bandwidth: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8, + txPower: Int8, + maxTxPower: Int8, + latitude: Double, + longitude: Double, + blePin: UInt32, + clientRepeat: Bool = false, + pathHashMode: UInt8 = 0, + defaultFloodScopeName: String? = nil, + preRepeatFrequency: UInt32? = nil, + preRepeatBandwidth: UInt32? = nil, + preRepeatSpreadingFactor: UInt8? = nil, + preRepeatCodingRate: UInt8? = nil, + manualAddContacts: Bool, + autoAddConfig: UInt8 = 0, + autoAddMaxHops: UInt8 = 0, + multiAcks: UInt8, + telemetryModeBase: UInt8, + telemetryModeLoc: UInt8, + telemetryModeEnv: UInt8, + advertLocationPolicy: UInt8, + lastConnected: Date, + lastContactSync: UInt32, + isActive: Bool, + ocvPreset: String?, + customOCVArrayString: String?, + connectionMethods: [ConnectionMethod] = [], + knownRegions: [String] = [] + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.nodeName = nodeName + self.firmwareVersion = firmwareVersion + self.firmwareVersionString = firmwareVersionString + self.manufacturerName = manufacturerName + self.buildDate = buildDate + self.maxContacts = maxContacts + self.maxChannels = maxChannels + self.frequency = frequency + self.bandwidth = bandwidth + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.txPower = txPower + self.maxTxPower = maxTxPower + self.latitude = latitude + self.longitude = longitude + self.blePin = blePin + self.clientRepeat = clientRepeat + self.pathHashMode = pathHashMode + self.defaultFloodScopeName = defaultFloodScopeName + self.preRepeatFrequency = preRepeatFrequency + self.preRepeatBandwidth = preRepeatBandwidth + self.preRepeatSpreadingFactor = preRepeatSpreadingFactor + self.preRepeatCodingRate = preRepeatCodingRate + self.manualAddContacts = manualAddContacts + self.autoAddConfig = autoAddConfig + self.autoAddMaxHops = autoAddMaxHops + self.multiAcks = multiAcks + self.telemetryModeBase = telemetryModeBase + self.telemetryModeLoc = telemetryModeLoc + self.telemetryModeEnv = telemetryModeEnv + self.advertLocationPolicy = advertLocationPolicy + self.lastConnected = lastConnected + self.lastContactSync = lastContactSync + self.isActive = isActive + self.ocvPreset = ocvPreset + self.customOCVArrayString = customOCVArrayString + self.connectionMethods = connectionMethods + self.knownRegions = knownRegions + } + + public init(from device: Device) { + id = device.id + radioID = device.radioID + publicKey = device.publicKey + nodeName = device.nodeName + firmwareVersion = device.firmwareVersion + firmwareVersionString = device.firmwareVersionString + manufacturerName = device.manufacturerName + buildDate = device.buildDate + maxContacts = device.maxContacts + maxChannels = device.maxChannels + frequency = device.frequency + bandwidth = device.bandwidth + spreadingFactor = device.spreadingFactor + codingRate = device.codingRate + txPower = device.txPower + maxTxPower = device.maxTxPower + latitude = device.latitude + longitude = device.longitude + blePin = device.blePin + clientRepeat = device.clientRepeat + pathHashMode = device.pathHashMode + defaultFloodScopeName = device.defaultFloodScopeName + preRepeatFrequency = device.preRepeatFrequency + preRepeatBandwidth = device.preRepeatBandwidth + preRepeatSpreadingFactor = device.preRepeatSpreadingFactor + preRepeatCodingRate = device.preRepeatCodingRate + manualAddContacts = device.manualAddContacts + autoAddConfig = device.autoAddConfig + autoAddMaxHops = device.autoAddMaxHops + multiAcks = device.multiAcks + telemetryModeBase = device.telemetryModeBase + telemetryModeLoc = device.telemetryModeLoc + telemetryModeEnv = device.telemetryModeEnv + advertLocationPolicy = device.advertLocationPolicy + lastConnected = device.lastConnected + lastContactSync = device.lastContactSync + isActive = device.isActive + ocvPreset = device.ocvPreset + customOCVArrayString = device.customOCVArrayString + connectionMethods = device.connectionMethods + knownRegions = device.knownRegions + } + + /// The 6-byte public key prefix used for identifying messages + public var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + /// Returns a new DeviceDTO with the given mutations applied. + public func copy(_ mutations: (inout DeviceDTO) -> Void) -> DeviceDTO { + var copy = self + mutations(©) + return copy + } + + /// The active OCV array for this device (preset or custom) + public var activeOCVArray: [Int] { + // If custom preset with valid custom string, parse it + if ocvPreset == OCVPreset.custom.rawValue, let customString = customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + return parsed + } } - /// Whether the device supports auto-add configuration (v1.12+) - /// Devices with older firmware only support manualAddContacts toggle - public var supportsAutoAddConfig: Bool { - firmwareVersionString.isAtLeast(major: 1, minor: 12) + // Use preset if set + if let presetName = ocvPreset, let preset = OCVPreset(rawValue: presetName) { + return preset.ocvArray } - /// Whether the device supports auto-add max hops (v1.14+) - public var supportsAutoAddMaxHops: Bool { - firmwareVersionString.isAtLeast(major: 1, minor: 14) + // Default to Li-Ion + return OCVPreset.liIon.ocvArray + } + + /// Returns a new DeviceDTO with settings updated from SelfInfo. + /// Used after device settings are changed via SettingsService. + public func updating(from selfInfo: MeshCore.SelfInfo) -> DeviceDTO { + copy { + $0.publicKey = selfInfo.publicKey + $0.nodeName = selfInfo.name + $0.frequency = UInt32(selfInfo.radioFrequency * 1000) + $0.bandwidth = UInt32(selfInfo.radioBandwidth * 1000) + $0.spreadingFactor = selfInfo.radioSpreadingFactor + $0.codingRate = selfInfo.radioCodingRate + $0.txPower = selfInfo.txPower + $0.latitude = selfInfo.latitude + $0.longitude = selfInfo.longitude + $0.manualAddContacts = selfInfo.manualAddContacts + $0.multiAcks = selfInfo.multiAcks + $0.telemetryModeBase = selfInfo.telemetryModeBase + $0.telemetryModeLoc = selfInfo.telemetryModeLocation + $0.telemetryModeEnv = selfInfo.telemetryModeEnvironment + $0.advertLocationPolicy = selfInfo.advertisementLocationPolicy } - - /// Whether the trace command honors a per-trace hash size in its flags byte, - /// letting a trace use a hash size different from `pathHashMode`. Added in - /// firmware v1.11.0; FIRMWARE_VER_CODE stayed 8 from v1.10.0 through v1.12.0, - /// so the version string disambiguates the v1.11/v1.12 window while - /// firmwareVersion >= 9 (v1.13+) still holds if a fork rewrites the string. - public var supportsTraceHashSizeOverride: Bool { - firmwareVersion >= 9 || firmwareVersionString.isAtLeast(major: 1, minor: 11) + } + + /// Returns a new DeviceDTO with current radio settings saved as pre-repeat settings. + public func savingPreRepeatSettings() -> DeviceDTO { + copy { + $0.preRepeatFrequency = frequency + $0.preRepeatBandwidth = bandwidth + $0.preRepeatSpreadingFactor = spreadingFactor + $0.preRepeatCodingRate = codingRate } - - /// Whether this device supports client repeat mode (firmware v9+) - public var supportsClientRepeat: Bool { firmwareVersion >= 9 } - - /// Whether this device supports path hash mode configuration (firmware v10+) - public var supportsPathHashMode: Bool { firmwareVersion >= 10 } - - /// Whether this device supports the persisted default flood scope (firmware v11+). - public var supportsDefaultFloodScope: Bool { firmwareVersion >= 11 } - - /// Whether this device supports forcing un-scoped flood broadcasts that override - /// the persisted default flood scope (firmware v12+). - public var supportsUnscopedFloodSend: Bool { firmwareVersion >= 12 } - - /// Whether the radio honors an anonymous request to a non-contact public key, - /// auto-adding a temporary zero-hop entry so the app can query a nearby repeater - /// (e.g. for region discovery) without first adding it. Added with - /// FIRMWARE_VER_CODE 13; the version string covers a fork that leaves it unbumped. - public var supportsAdHocRepeaterRequest: Bool { - firmwareVersion >= 13 || firmwareVersionString.isAtLeast(major: 1, minor: 16) + } + + /// Returns a new DeviceDTO with pre-repeat settings cleared. + public func clearingPreRepeatSettings() -> DeviceDTO { + copy { + $0.preRepeatFrequency = nil + $0.preRepeatBandwidth = nil + $0.preRepeatSpreadingFactor = nil + $0.preRepeatCodingRate = nil } - - /// Advertisement location policy interpreted from raw value. - public var advertLocationPolicyMode: AdvertLocationPolicy { - AdvertLocationPolicy(rawValue: advertLocationPolicy) ?? .none + } + + /// Returns a copy with `isActive` cleared and any Bluetooth connection + /// methods stripped. Used on backup import so a restored device isn't + /// mistaken for the currently-connected radio, and so legacy backups + /// that shipped stale `CBPeripheral.identifier` values don't produce a + /// permanently-disabled row in `DeviceSelectionSheet`. + public func cleanedForImport() -> DeviceDTO { + copy { + $0.isActive = false + $0.connectionMethods = connectionMethods.filter { !$0.isBluetooth } } - - /// Telemetry modes constructed from raw base/location/environment values. - public var telemetryModes: TelemetryModes { - TelemetryModes(base: telemetryModeBase, location: telemetryModeLoc, environment: telemetryModeEnv) - } - - /// Whether location is shared publicly in advertisements. - public var sharesLocationPublicly: Bool { advertLocationPolicy > 0 } - - /// Whether pre-repeat radio settings are saved for restoration. - public var hasPreRepeatSettings: Bool { - preRepeatFrequency != nil && preRepeatBandwidth != nil && - preRepeatSpreadingFactor != nil && preRepeatCodingRate != nil - } - - public init( - id: UUID, - radioID: UUID = UUID(), - publicKey: Data, - nodeName: String, - firmwareVersion: UInt8, - firmwareVersionString: String, - manufacturerName: String, - buildDate: String, - maxContacts: UInt16, - maxChannels: UInt8, - frequency: UInt32, - bandwidth: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8, - txPower: Int8, - maxTxPower: Int8, - latitude: Double, - longitude: Double, - blePin: UInt32, - clientRepeat: Bool = false, - pathHashMode: UInt8 = 0, - defaultFloodScopeName: String? = nil, - preRepeatFrequency: UInt32? = nil, - preRepeatBandwidth: UInt32? = nil, - preRepeatSpreadingFactor: UInt8? = nil, - preRepeatCodingRate: UInt8? = nil, - manualAddContacts: Bool, - autoAddConfig: UInt8 = 0, - autoAddMaxHops: UInt8 = 0, - multiAcks: UInt8, - telemetryModeBase: UInt8, - telemetryModeLoc: UInt8, - telemetryModeEnv: UInt8, - advertLocationPolicy: UInt8, - lastConnected: Date, - lastContactSync: UInt32, - isActive: Bool, - ocvPreset: String?, - customOCVArrayString: String?, - connectionMethods: [ConnectionMethod] = [], - knownRegions: [String] = [] - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.nodeName = nodeName - self.firmwareVersion = firmwareVersion - self.firmwareVersionString = firmwareVersionString - self.manufacturerName = manufacturerName - self.buildDate = buildDate - self.maxContacts = maxContacts - self.maxChannels = maxChannels - self.frequency = frequency - self.bandwidth = bandwidth - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.txPower = txPower - self.maxTxPower = maxTxPower - self.latitude = latitude - self.longitude = longitude - self.blePin = blePin - self.clientRepeat = clientRepeat - self.pathHashMode = pathHashMode - self.defaultFloodScopeName = defaultFloodScopeName - self.preRepeatFrequency = preRepeatFrequency - self.preRepeatBandwidth = preRepeatBandwidth - self.preRepeatSpreadingFactor = preRepeatSpreadingFactor - self.preRepeatCodingRate = preRepeatCodingRate - self.manualAddContacts = manualAddContacts - self.autoAddConfig = autoAddConfig - self.autoAddMaxHops = autoAddMaxHops - self.multiAcks = multiAcks - self.telemetryModeBase = telemetryModeBase - self.telemetryModeLoc = telemetryModeLoc - self.telemetryModeEnv = telemetryModeEnv - self.advertLocationPolicy = advertLocationPolicy - self.lastConnected = lastConnected - self.lastContactSync = lastContactSync - self.isActive = isActive - self.ocvPreset = ocvPreset - self.customOCVArrayString = customOCVArrayString - self.connectionMethods = connectionMethods - self.knownRegions = knownRegions - } - - public init(from device: Device) { - self.id = device.id - self.radioID = device.radioID - self.publicKey = device.publicKey - self.nodeName = device.nodeName - self.firmwareVersion = device.firmwareVersion - self.firmwareVersionString = device.firmwareVersionString - self.manufacturerName = device.manufacturerName - self.buildDate = device.buildDate - self.maxContacts = device.maxContacts - self.maxChannels = device.maxChannels - self.frequency = device.frequency - self.bandwidth = device.bandwidth - self.spreadingFactor = device.spreadingFactor - self.codingRate = device.codingRate - self.txPower = device.txPower - self.maxTxPower = device.maxTxPower - self.latitude = device.latitude - self.longitude = device.longitude - self.blePin = device.blePin - self.clientRepeat = device.clientRepeat - self.pathHashMode = device.pathHashMode - self.defaultFloodScopeName = device.defaultFloodScopeName - self.preRepeatFrequency = device.preRepeatFrequency - self.preRepeatBandwidth = device.preRepeatBandwidth - self.preRepeatSpreadingFactor = device.preRepeatSpreadingFactor - self.preRepeatCodingRate = device.preRepeatCodingRate - self.manualAddContacts = device.manualAddContacts - self.autoAddConfig = device.autoAddConfig - self.autoAddMaxHops = device.autoAddMaxHops - self.multiAcks = device.multiAcks - self.telemetryModeBase = device.telemetryModeBase - self.telemetryModeLoc = device.telemetryModeLoc - self.telemetryModeEnv = device.telemetryModeEnv - self.advertLocationPolicy = device.advertLocationPolicy - self.lastConnected = device.lastConnected - self.lastContactSync = device.lastContactSync - self.isActive = device.isActive - self.ocvPreset = device.ocvPreset - self.customOCVArrayString = device.customOCVArrayString - self.connectionMethods = device.connectionMethods - self.knownRegions = device.knownRegions - } - - /// The 6-byte public key prefix used for identifying messages - public var publicKeyPrefix: Data { - publicKey.prefix(6) - } - - /// Returns a new DeviceDTO with the given mutations applied. - public func copy(_ mutations: (inout DeviceDTO) -> Void) -> DeviceDTO { - var copy = self - mutations(©) - return copy - } - - /// The active OCV array for this device (preset or custom) - public var activeOCVArray: [Int] { - // If custom preset with valid custom string, parse it - if ocvPreset == OCVPreset.custom.rawValue, let customString = customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - return parsed - } - } - - // Use preset if set - if let presetName = ocvPreset, let preset = OCVPreset(rawValue: presetName) { - return preset.ocvArray - } - - // Default to Li-Ion - return OCVPreset.liIon.ocvArray - } - - /// Returns a new DeviceDTO with settings updated from SelfInfo. - /// Used after device settings are changed via SettingsService. - public func updating(from selfInfo: MeshCore.SelfInfo) -> DeviceDTO { - copy { - $0.publicKey = selfInfo.publicKey - $0.nodeName = selfInfo.name - $0.frequency = UInt32(selfInfo.radioFrequency * 1000) - $0.bandwidth = UInt32(selfInfo.radioBandwidth * 1000) - $0.spreadingFactor = selfInfo.radioSpreadingFactor - $0.codingRate = selfInfo.radioCodingRate - $0.txPower = selfInfo.txPower - $0.latitude = selfInfo.latitude - $0.longitude = selfInfo.longitude - $0.manualAddContacts = selfInfo.manualAddContacts - $0.multiAcks = selfInfo.multiAcks - $0.telemetryModeBase = selfInfo.telemetryModeBase - $0.telemetryModeLoc = selfInfo.telemetryModeLocation - $0.telemetryModeEnv = selfInfo.telemetryModeEnvironment - $0.advertLocationPolicy = selfInfo.advertisementLocationPolicy - } - } - - /// Returns a new DeviceDTO with current radio settings saved as pre-repeat settings. - public func savingPreRepeatSettings() -> DeviceDTO { - copy { - $0.preRepeatFrequency = frequency - $0.preRepeatBandwidth = bandwidth - $0.preRepeatSpreadingFactor = spreadingFactor - $0.preRepeatCodingRate = codingRate - } - } - - /// Returns a new DeviceDTO with pre-repeat settings cleared. - public func clearingPreRepeatSettings() -> DeviceDTO { - copy { - $0.preRepeatFrequency = nil - $0.preRepeatBandwidth = nil - $0.preRepeatSpreadingFactor = nil - $0.preRepeatCodingRate = nil - } - } - - /// Returns a copy with `isActive` cleared and any Bluetooth connection - /// methods stripped. Used on backup import so a restored device isn't - /// mistaken for the currently-connected radio, and so legacy backups - /// that shipped stale `CBPeripheral.identifier` values don't produce a - /// permanently-disabled row in `DeviceSelectionSheet`. - public func cleanedForImport() -> DeviceDTO { - copy { - $0.isActive = false - $0.connectionMethods = connectionMethods.filter { !$0.isBluetooth } - } - } - - /// Returns a copy with radio configuration fields reset to safe defaults, - /// the surrogate `id` reset to a fresh UUID, and any Bluetooth connection - /// methods removed. Used during backup export to avoid exposing BLE PIN, - /// frequency, or the source phone's `CBPeripheral.identifier` (the BLE - /// `Device.id`) in the .mc1backup file — peripheral UUIDs are only - /// meaningful on the phone that paired the radio and are not portable - /// across installs. WiFi connection methods are intentionally retained - /// (restore-then-reconnect reads them) and are disclosed in the pre-export - /// Security Notice. `publicKey`/`radioID` are preserved as the - /// reconciliation/partition keys; import re-mints `id` again on insert. - public func redactedForBackup() -> DeviceDTO { - copy { - // Reset the surrogate id so the source phone's CBPeripheral UUID (Device.id for BLE - // radios) never travels in a shareable backup. publicKey/radioID are preserved as the - // reconciliation/partition keys; import re-mints id again on insert. For WiFi radios - // Device.id is DeviceIdentity.deriveUUID(from: publicKey), re-derivable from the - // retained publicKey, so the reset is doc-honesty plus BLE-identifier removal. - $0.id = UUID() - $0.frequency = Device.Defaults.frequency - $0.bandwidth = Device.Defaults.bandwidth - $0.spreadingFactor = Device.Defaults.spreadingFactor - $0.codingRate = Device.Defaults.codingRate - $0.txPower = Device.Defaults.txPower - $0.maxTxPower = Device.Defaults.maxTxPower - $0.latitude = Device.Defaults.latitude - $0.longitude = Device.Defaults.longitude - $0.blePin = Device.Defaults.blePin - $0.clientRepeat = Device.Defaults.clientRepeat - $0.pathHashMode = Device.Defaults.pathHashMode - $0.preRepeatFrequency = Device.Defaults.preRepeatFrequency - $0.preRepeatBandwidth = Device.Defaults.preRepeatBandwidth - $0.preRepeatSpreadingFactor = Device.Defaults.preRepeatSpreadingFactor - $0.preRepeatCodingRate = Device.Defaults.preRepeatCodingRate - $0.manualAddContacts = Device.Defaults.manualAddContacts - $0.autoAddConfig = Device.Defaults.autoAddConfig - $0.autoAddMaxHops = Device.Defaults.autoAddMaxHops - $0.multiAcks = Device.Defaults.multiAcks - $0.telemetryModeBase = Device.Defaults.telemetryModeBase - $0.telemetryModeLoc = Device.Defaults.telemetryModeLoc - $0.telemetryModeEnv = Device.Defaults.telemetryModeEnv - $0.advertLocationPolicy = Device.Defaults.advertLocationPolicy - $0.connectionMethods = connectionMethods.filter { !$0.isBluetooth } - } + } + + /// Returns a copy with radio configuration fields reset to safe defaults, + /// the surrogate `id` reset to a fresh UUID, and any Bluetooth connection + /// methods removed. Used during backup export to avoid exposing BLE PIN, + /// frequency, or the source phone's `CBPeripheral.identifier` (the BLE + /// `Device.id`) in the .mc1backup file — peripheral UUIDs are only + /// meaningful on the phone that paired the radio and are not portable + /// across installs. WiFi connection methods are intentionally retained + /// (restore-then-reconnect reads them) and are disclosed in the pre-export + /// Security Notice. `publicKey`/`radioID` are preserved as the + /// reconciliation/partition keys; import re-mints `id` again on insert. + public func redactedForBackup() -> DeviceDTO { + copy { + // Reset the surrogate id so the source phone's CBPeripheral UUID (Device.id for BLE + // radios) never travels in a shareable backup. publicKey/radioID are preserved as the + // reconciliation/partition keys; import re-mints id again on insert. For WiFi radios + // Device.id is DeviceIdentity.deriveUUID(from: publicKey), re-derivable from the + // retained publicKey, so the reset is doc-honesty plus BLE-identifier removal. + $0.id = UUID() + $0.frequency = Device.Defaults.frequency + $0.bandwidth = Device.Defaults.bandwidth + $0.spreadingFactor = Device.Defaults.spreadingFactor + $0.codingRate = Device.Defaults.codingRate + $0.txPower = Device.Defaults.txPower + $0.maxTxPower = Device.Defaults.maxTxPower + $0.latitude = Device.Defaults.latitude + $0.longitude = Device.Defaults.longitude + $0.blePin = Device.Defaults.blePin + $0.clientRepeat = Device.Defaults.clientRepeat + $0.pathHashMode = Device.Defaults.pathHashMode + $0.preRepeatFrequency = Device.Defaults.preRepeatFrequency + $0.preRepeatBandwidth = Device.Defaults.preRepeatBandwidth + $0.preRepeatSpreadingFactor = Device.Defaults.preRepeatSpreadingFactor + $0.preRepeatCodingRate = Device.Defaults.preRepeatCodingRate + $0.manualAddContacts = Device.Defaults.manualAddContacts + $0.autoAddConfig = Device.Defaults.autoAddConfig + $0.autoAddMaxHops = Device.Defaults.autoAddMaxHops + $0.multiAcks = Device.Defaults.multiAcks + $0.telemetryModeBase = Device.Defaults.telemetryModeBase + $0.telemetryModeLoc = Device.Defaults.telemetryModeLoc + $0.telemetryModeEnv = Device.Defaults.telemetryModeEnv + $0.advertLocationPolicy = Device.Defaults.advertLocationPolicy + $0.connectionMethods = connectionMethods.filter { !$0.isBluetooth } } + } } // MARK: - Shared Defaults -extension Device { - /// Radio and location defaults used by `Device.init` and by - /// `DeviceDTO.redactedForBackup()`. One source of truth keeps the two - /// paths from drifting — a future default change will flow through - /// exported backups automatically. - public enum Defaults { - public static let frequency: UInt32 = 915_000 - public static let bandwidth: UInt32 = 250_000 - public static let spreadingFactor: UInt8 = 10 - public static let codingRate: UInt8 = 5 - public static let txPower: Int8 = 20 - public static let maxTxPower: Int8 = 20 - public static let latitude: Double = 0 - public static let longitude: Double = 0 - public static let blePin: UInt32 = 0 - public static let clientRepeat: Bool = false - public static let pathHashMode: UInt8 = 0 - public static let defaultFloodScopeName: String? = nil - public static let preRepeatFrequency: UInt32? = nil - public static let preRepeatBandwidth: UInt32? = nil - public static let preRepeatSpreadingFactor: UInt8? = nil - public static let preRepeatCodingRate: UInt8? = nil - public static let manualAddContacts: Bool = false - public static let autoAddConfig: UInt8 = 0 - public static let autoAddMaxHops: UInt8 = 0 - public static let multiAcks: UInt8 = 2 - public static let telemetryModeBase: UInt8 = 2 - public static let telemetryModeLoc: UInt8 = 0 - public static let telemetryModeEnv: UInt8 = 0 - public static let advertLocationPolicy: UInt8 = 0 - } +public extension Device { + /// Radio and location defaults used by `Device.init` and by + /// `DeviceDTO.redactedForBackup()`. One source of truth keeps the two + /// paths from drifting — a future default change will flow through + /// exported backups automatically. + enum Defaults { + public static let frequency: UInt32 = 915_000 + public static let bandwidth: UInt32 = 250_000 + public static let spreadingFactor: UInt8 = 10 + public static let codingRate: UInt8 = 5 + public static let txPower: Int8 = 20 + public static let maxTxPower: Int8 = 20 + public static let latitude: Double = 0 + public static let longitude: Double = 0 + public static let blePin: UInt32 = 0 + public static let clientRepeat: Bool = false + public static let pathHashMode: UInt8 = 0 + public static let defaultFloodScopeName: String? = nil + public static let preRepeatFrequency: UInt32? = nil + public static let preRepeatBandwidth: UInt32? = nil + public static let preRepeatSpreadingFactor: UInt8? = nil + public static let preRepeatCodingRate: UInt8? = nil + public static let manualAddContacts: Bool = false + public static let autoAddConfig: UInt8 = 0 + public static let autoAddMaxHops: UInt8 = 0 + public static let multiAcks: UInt8 = 2 + public static let telemetryModeBase: UInt8 = 2 + public static let telemetryModeLoc: UInt8 = 0 + public static let telemetryModeEnv: UInt8 = 0 + public static let advertLocationPolicy: UInt8 = 0 + } } // MARK: - Version String Comparison extension String { - /// Checks if this version string is at least the specified version. - /// Handles formats like "v1.12.0", "1.12", "v1.12" - /// - Parameters: - /// - major: Required major version - /// - minor: Required minor version - /// - Returns: true if this version >= major.minor - func isAtLeast(major requiredMajor: Int, minor requiredMinor: Int) -> Bool { - let cleaned = trimmingCharacters(in: CharacterSet(charactersIn: "v")) - let components = cleaned.split(separator: ".") - guard components.count >= 2, - let major = Int(components[0]), - let minor = Int(components[1]) else { - return false - } - if major > requiredMajor { return true } - if major < requiredMajor { return false } - return minor >= requiredMinor + /// Checks if this version string is at least the specified version. + /// Handles formats like "v1.12.0", "1.12", "v1.12" + /// - Parameters: + /// - major: Required major version + /// - minor: Required minor version + /// - Returns: true if this version >= major.minor + func isAtLeast(major requiredMajor: Int, minor requiredMinor: Int) -> Bool { + let cleaned = trimmingCharacters(in: CharacterSet(charactersIn: "v")) + let components = cleaned.split(separator: ".") + guard components.count >= 2, + let major = Int(components[0]), + let minor = Int(components[1]) else { + return false } + if major > requiredMajor { return true } + if major < requiredMajor { return false } + return minor >= requiredMinor + } } diff --git a/MC1Services/Sources/MC1Services/Models/DirectMessageEnvelope.swift b/MC1Services/Sources/MC1Services/Models/DirectMessageEnvelope.swift index 695a1e12..9f71b9ff 100644 --- a/MC1Services/Sources/MC1Services/Models/DirectMessageEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/DirectMessageEnvelope.swift @@ -11,13 +11,13 @@ import Foundation /// `sendCount` bump) and `resendDirectMessage` (bumps `sendCount`, /// broadcasts `MessageStatusEvent.resent`). public struct DirectMessageEnvelope: Sendable { - public let messageID: UUID - public let contactID: UUID - public let isResend: Bool + public let messageID: UUID + public let contactID: UUID + public let isResend: Bool - public init(messageID: UUID, contactID: UUID, isResend: Bool = false) { - self.messageID = messageID - self.contactID = contactID - self.isResend = isResend - } + public init(messageID: UUID, contactID: UUID, isResend: Bool = false) { + self.messageID = messageID + self.contactID = contactID + self.isResend = isResend + } } diff --git a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift index 80833612..024820fe 100644 --- a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift +++ b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift @@ -6,185 +6,190 @@ import SwiftData /// Separate from Contact - this is ephemeral, app-only, capped at 1000 per device. @Model final class DiscoveredNode { - #Index( - [\.radioID, \.publicKey], - [\.radioID, \.lastHeard] - ) - - @Attribute(.unique) - var id: UUID - - /// Parent device ID - @Attribute(originalName: "deviceID") - var radioID: UUID - - /// 32-byte public key identifier - var publicKey: Data - - /// Advertised node name - var name: String - - /// Node type (1=chat, 2=repeater, 3=room) - var typeRawValue: UInt8 - - /// When we last received an advertisement from this node - var lastHeard: Date - - /// Firmware advertisement timestamp - var lastAdvertTimestamp: UInt32 - - /// Node latitude - var latitude: Double - - /// Node longitude - var longitude: Double - - /// Encoded routing path length (0xFF = flood) - var outPathLength: UInt8 - - /// Routing path data (up to 64 bytes) - var outPath: Data - - /// Hops the advert traversed to reach this phone (inbound), decoded from the heard - /// RX-log packet's path length. nil = never heard via an advert RX-log entry; 0 = heard - /// directly. Distinct from outPath/outPathLength, the route used to send to this node. - var inboundHopCount: Int? - - /// The firmware advert timestamp that was current when inboundHopCount was last written. - /// Paired with inboundHopCount to implement latest-advert semantics: a newer timestamp - /// always replaces the stored count; equal timestamps keep the closest copy of that broadcast. - var inboundHopAdvertTimestamp: UInt32? - - init( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data, - name: String, - typeRawValue: UInt8, - lastHeard: Date = Date(), - lastAdvertTimestamp: UInt32, - latitude: Double = 0, - longitude: Double = 0, - outPathLength: UInt8 = PacketBuilder.floodPathSentinel, - outPath: Data = Data(), - inboundHopCount: Int? = nil, - inboundHopAdvertTimestamp: UInt32? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.typeRawValue = typeRawValue - self.lastHeard = lastHeard - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.outPathLength = outPathLength - self.outPath = outPath - self.inboundHopCount = inboundHopCount - self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp - } + #Index( + [\.radioID, \.publicKey], + [\.radioID, \.lastHeard] + ) + + @Attribute(.unique) + var id: UUID + + /// Parent device ID + @Attribute(originalName: "deviceID") + var radioID: UUID + + /// 32-byte public key identifier + var publicKey: Data + + /// Advertised node name + var name: String + + /// Node type (1=chat, 2=repeater, 3=room) + var typeRawValue: UInt8 + + /// When we last received an advertisement from this node + var lastHeard: Date + + /// Firmware advertisement timestamp + var lastAdvertTimestamp: UInt32 + + /// Node latitude + var latitude: Double + + /// Node longitude + var longitude: Double + + /// Encoded routing path length (0xFF = flood) + var outPathLength: UInt8 + + /// Routing path data (up to 64 bytes) + var outPath: Data + + /// Hops the advert traversed to reach this phone (inbound), decoded from the heard + /// RX-log packet's path length. nil = never heard via an advert RX-log entry; 0 = heard + /// directly. Distinct from outPath/outPathLength, the route used to send to this node. + var inboundHopCount: Int? + + /// The firmware advert timestamp that was current when inboundHopCount was last written. + /// Paired with inboundHopCount to implement latest-advert semantics: a newer timestamp + /// always replaces the stored count; equal timestamps keep the closest copy of that broadcast. + var inboundHopAdvertTimestamp: UInt32? + + init( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data, + name: String, + typeRawValue: UInt8, + lastHeard: Date = Date(), + lastAdvertTimestamp: UInt32, + latitude: Double = 0, + longitude: Double = 0, + outPathLength: UInt8 = PacketBuilder.floodPathSentinel, + outPath: Data = Data(), + inboundHopCount: Int? = nil, + inboundHopAdvertTimestamp: UInt32? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.typeRawValue = typeRawValue + self.lastHeard = lastHeard + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.outPathLength = outPathLength + self.outPath = outPath + self.inboundHopCount = inboundHopCount + self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp + } } // MARK: - Sendable DTO /// A sendable snapshot of DiscoveredNode for cross-actor transfers public struct DiscoveredNodeDTO: Sendable, Equatable, Identifiable, RepeaterResolvable { - public let id: UUID - public let radioID: UUID - public let publicKey: Data - public let name: String - public let typeRawValue: UInt8 - public let lastHeard: Date - public let lastAdvertTimestamp: UInt32 - public let latitude: Double - public let longitude: Double - public let outPathLength: UInt8 - public let outPath: Data - public let inboundHopCount: Int? - public let inboundHopAdvertTimestamp: UInt32? - - public var nodeType: ContactType { - ContactType(rawValue: typeRawValue) ?? .chat - } - - public var hasLocation: Bool { - latitude != 0 || longitude != 0 - } - - public var isFloodRouted: Bool { - outPathLength == PacketBuilder.floodPathSentinel - } - - public var pathHashSize: Int { - decodePathLen(outPathLength)?.hashSize ?? 1 - } - - public var pathHopCount: Int { - decodePathLen(outPathLength)?.hopCount ?? 0 - } - - /// The hop count to surface in the UI: the deliberately-set out-path hops when a route exists, - /// otherwise the passively-heard inbound advert hops stored on this row. nil when flood-routed - /// and no advert hop count is known. - public var displayedHopCount: Int? { - isFloodRouted ? inboundHopCount : pathHopCount - } - - public var pathByteLength: Int { - decodePathLen(outPathLength)?.byteLength ?? 0 - } - - public var pathNodesHex: [String] { - outPath.prefix(pathByteLength).pathHops(hashSize: pathHashSize).map(\.hex) - } - - public var recencyDate: Date { lastHeard } - public var resolvableName: String { name } - - public init( - id: UUID, - radioID: UUID, - publicKey: Data, - name: String, - typeRawValue: UInt8, - lastHeard: Date, - lastAdvertTimestamp: UInt32, - latitude: Double, - longitude: Double, - outPathLength: UInt8, - outPath: Data, - inboundHopCount: Int?, - inboundHopAdvertTimestamp: UInt32? - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.typeRawValue = typeRawValue - self.lastHeard = lastHeard - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.outPathLength = outPathLength - self.outPath = outPath - self.inboundHopCount = inboundHopCount - self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp - } - - init(from node: DiscoveredNode) { - self.id = node.id - self.radioID = node.radioID - self.publicKey = node.publicKey - self.name = node.name - self.typeRawValue = node.typeRawValue - self.lastHeard = node.lastHeard - self.lastAdvertTimestamp = node.lastAdvertTimestamp - self.latitude = node.latitude - self.longitude = node.longitude - self.outPathLength = node.outPathLength - self.outPath = node.outPath - self.inboundHopCount = node.inboundHopCount - self.inboundHopAdvertTimestamp = node.inboundHopAdvertTimestamp - } + public let id: UUID + public let radioID: UUID + public let publicKey: Data + public let name: String + public let typeRawValue: UInt8 + public let lastHeard: Date + public let lastAdvertTimestamp: UInt32 + public let latitude: Double + public let longitude: Double + public let outPathLength: UInt8 + public let outPath: Data + public let inboundHopCount: Int? + public let inboundHopAdvertTimestamp: UInt32? + + public var nodeType: ContactType { + ContactType(rawValue: typeRawValue) ?? .chat + } + + public var hasLocation: Bool { + latitude != 0 || longitude != 0 + } + + public var isFloodRouted: Bool { + outPathLength == PacketBuilder.floodPathSentinel + } + + public var pathHashSize: Int { + decodePathLen(outPathLength)?.hashSize ?? 1 + } + + public var pathHopCount: Int { + decodePathLen(outPathLength)?.hopCount ?? 0 + } + + /// The hop count to surface in the UI: the deliberately-set out-path hops when a route exists, + /// otherwise the passively-heard inbound advert hops stored on this row. nil when flood-routed + /// and no advert hop count is known. + public var displayedHopCount: Int? { + isFloodRouted ? inboundHopCount : pathHopCount + } + + public var pathByteLength: Int { + decodePathLen(outPathLength)?.byteLength ?? 0 + } + + public var pathNodesHex: [String] { + outPath.prefix(pathByteLength).pathHops(hashSize: pathHashSize).map(\.hex) + } + + public var recencyDate: Date { + lastHeard + } + + public var resolvableName: String { + name + } + + public init( + id: UUID, + radioID: UUID, + publicKey: Data, + name: String, + typeRawValue: UInt8, + lastHeard: Date, + lastAdvertTimestamp: UInt32, + latitude: Double, + longitude: Double, + outPathLength: UInt8, + outPath: Data, + inboundHopCount: Int?, + inboundHopAdvertTimestamp: UInt32? + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.typeRawValue = typeRawValue + self.lastHeard = lastHeard + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.outPathLength = outPathLength + self.outPath = outPath + self.inboundHopCount = inboundHopCount + self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp + } + + init(from node: DiscoveredNode) { + id = node.id + radioID = node.radioID + publicKey = node.publicKey + name = node.name + typeRawValue = node.typeRawValue + lastHeard = node.lastHeard + lastAdvertTimestamp = node.lastAdvertTimestamp + latitude = node.latitude + longitude = node.longitude + outPathLength = node.outPathLength + outPath = node.outPath + inboundHopCount = node.inboundHopCount + inboundHopAdvertTimestamp = node.inboundHopAdvertTimestamp + } } diff --git a/MC1Services/Sources/MC1Services/Models/LinkPreviewData.swift b/MC1Services/Sources/MC1Services/Models/LinkPreviewData.swift index e6a7a934..eec8bcbf 100644 --- a/MC1Services/Sources/MC1Services/Models/LinkPreviewData.swift +++ b/MC1Services/Sources/MC1Services/Models/LinkPreviewData.swift @@ -5,45 +5,45 @@ import SwiftData /// Multiple messages with the same URL reference a single LinkPreviewData row. @Model final class LinkPreviewData { - /// The URL this preview is for (unique key) - @Attribute(.unique) - var url: String - - /// Title from link metadata - var title: String? - - /// Preview image data (hero image) - @Attribute(.externalStorage) - var imageData: Data? - - /// Icon/favicon data - @Attribute(.externalStorage) - var iconData: Data? - - /// Hero image pixel width, recorded at fetch time - var imageWidth: Int? - - /// Hero image pixel height, recorded at fetch time - var imageHeight: Int? - - /// When this preview was fetched - var fetchedAt: Date - - init( - url: String, - title: String? = nil, - imageData: Data? = nil, - iconData: Data? = nil, - imageWidth: Int? = nil, - imageHeight: Int? = nil, - fetchedAt: Date = Date() - ) { - self.url = url - self.title = title - self.imageData = imageData - self.iconData = iconData - self.imageWidth = imageWidth - self.imageHeight = imageHeight - self.fetchedAt = fetchedAt - } + /// The URL this preview is for (unique key) + @Attribute(.unique) + var url: String + + /// Title from link metadata + var title: String? + + /// Preview image data (hero image) + @Attribute(.externalStorage) + var imageData: Data? + + /// Icon/favicon data + @Attribute(.externalStorage) + var iconData: Data? + + /// Hero image pixel width, recorded at fetch time + var imageWidth: Int? + + /// Hero image pixel height, recorded at fetch time + var imageHeight: Int? + + /// When this preview was fetched + var fetchedAt: Date + + init( + url: String, + title: String? = nil, + imageData: Data? = nil, + iconData: Data? = nil, + imageWidth: Int? = nil, + imageHeight: Int? = nil, + fetchedAt: Date = Date() + ) { + self.url = url + self.title = title + self.imageData = imageData + self.iconData = iconData + self.imageWidth = imageWidth + self.imageHeight = imageHeight + self.fetchedAt = fetchedAt + } } diff --git a/MC1Services/Sources/MC1Services/Models/Message.swift b/MC1Services/Sources/MC1Services/Models/Message.swift index 56adf2a1..135a85ac 100644 --- a/MC1Services/Sources/MC1Services/Models/Message.swift +++ b/MC1Services/Sources/MC1Services/Models/Message.swift @@ -4,711 +4,710 @@ import SwiftData /// Message delivery status public enum MessageStatus: Int, Sendable, Codable { - case pending = 0 - case sending = 1 - case sent = 2 - case delivered = 3 - case failed = 4 - case retrying = 5 + case pending = 0 + case sending = 1 + case sent = 2 + case delivered = 3 + case failed = 4 + case retrying = 5 } /// Message direction public enum MessageDirection: Int, Sendable, Codable { - case incoming = 0 - case outgoing = 1 + case incoming = 0 + case outgoing = 1 } /// Represents a message in a conversation. /// Messages are stored per-device and associated with a contact or channel. @Model public final class Message { - #Index( - [\.radioID, \.channelIndex, \.createdAt], - [\.radioID, \.channelIndex, \.sortDate], - [\.radioID, \.channelIndex, \.timestamp], - [\.contactID, \.createdAt], - [\.contactID, \.sortDate], - [\.contactID, \.containsSelfMention, \.mentionSeen], - [\.radioID, \.channelIndex, \.containsSelfMention, \.mentionSeen], - [\.deduplicationKey] - ) - - /// Unique message identifier - @Attribute(.unique) - public var id: UUID - - /// The device this message belongs to - @Attribute(originalName: "deviceID") - public var radioID: UUID + #Index( + [\.radioID, \.channelIndex, \.createdAt], + [\.radioID, \.channelIndex, \.sortDate], + [\.radioID, \.channelIndex, \.timestamp], + [\.contactID, \.createdAt], + [\.contactID, \.sortDate], + [\.contactID, \.containsSelfMention, \.mentionSeen], + [\.radioID, \.channelIndex, \.containsSelfMention, \.mentionSeen], + [\.deduplicationKey] + ) - /// Contact ID for direct messages (nil for channel messages) - public var contactID: UUID? + /// Unique message identifier + @Attribute(.unique) + public var id: UUID - /// Channel index for channel messages (nil for direct messages) - public var channelIndex: UInt8? + /// The device this message belongs to + @Attribute(originalName: "deviceID") + public var radioID: UUID - /// Message text content - public var text: String + /// Contact ID for direct messages (nil for channel messages) + public var contactID: UUID? - /// Message timestamp (device time) - public var timestamp: UInt32 + /// Channel index for channel messages (nil for direct messages) + public var channelIndex: UInt8? - /// Local creation date - public var createdAt: Date + /// Message text content + public var text: String - /// Date used for send-time ordering of synced backlog messages. - /// Defaults to `createdAt` for newly created rows. - public var sortDate: Date = Date.distantPast + /// Message timestamp (device time) + public var timestamp: UInt32 - /// Direction (incoming/outgoing) - public var directionRawValue: Int + /// Local creation date + public var createdAt: Date - /// Delivery status - public var statusRawValue: Int + /// Date used for send-time ordering of synced backlog messages. + /// Defaults to `createdAt` for newly created rows. + public var sortDate: Date = Date.distantPast - /// Text type (plain, signed, etc.) - public var textTypeRawValue: UInt8 + /// Direction (incoming/outgoing) + public var directionRawValue: Int - /// ACK code for tracking delivery (outgoing only) - public var ackCode: UInt32? + /// Delivery status + public var statusRawValue: Int - /// Path length when received - public var pathLength: UInt8 + /// Text type (plain, signed, etc.) + public var textTypeRawValue: UInt8 - /// Signal-to-noise ratio in dB - public var snr: Double? + /// ACK code for tracking delivery (outgoing only) + public var ackCode: UInt32? - /// Path nodes for incoming messages (1 byte per hop, from RxLogEntry correlation) - public var pathNodes: Data? + /// Path length when received + public var pathLength: UInt8 - /// Sender public key prefix (6 bytes, for incoming messages) - public var senderKeyPrefix: Data? + /// Signal-to-noise ratio in dB + public var snr: Double? - /// Sender node name (for channel messages, parsed from "NodeName: MessageText" format) - public var senderNodeName: String? + /// Path nodes for incoming messages (1 byte per hop, from RxLogEntry correlation) + public var pathNodes: Data? - /// Whether this message has been read locally - public var isRead: Bool - - /// Reply-to message ID (for threaded replies) - public var replyToID: UUID? - - /// Round-trip time in ms (when ACK received) - public var roundTripTime: UInt32? - - /// Count of mesh repeats heard for this message (outgoing only) - public var heardRepeats: Int = 0 - - /// Number of times this message has been sent (1 = original, 2+ = sent again) - public var sendCount: Int = 1 - - /// Current retry attempt (0 = first attempt, 1 = first retry, etc.) - public var retryAttempt: Int = 0 - - /// Maximum retry attempts configured for this message - public var maxRetryAttempts: Int = 0 - - /// Deduplication key for preventing duplicate incoming messages - public var deduplicationKey: String? - - /// Link preview URL that was detected (nil if no URL in message) - public var linkPreviewURL: String? - - /// Title from link metadata - public var linkPreviewTitle: String? - - /// Preview image data (hero image) - @Attribute(.externalStorage) - public var linkPreviewImageData: Data? - - /// Icon/favicon data - @Attribute(.externalStorage) - public var linkPreviewIconData: Data? - - /// Whether fetch has been attempted (true = done, false = not yet tried) - public var linkPreviewFetched: Bool = false - - /// Whether this incoming message contains a mention of the current user - public var containsSelfMention: Bool = false - - /// Whether the user has scrolled to see this mention (for tracking unread mentions) - public var mentionSeen: Bool = false - - /// Whether the timestamp was corrected due to sender clock being invalid - public var timestampCorrected: Bool = false - - /// Original sender timestamp from the wire (for incoming messages when corrected). - /// Used for reaction hash computation to ensure sender and receiver match. - /// Nil when timestamp was not corrected or for outgoing messages. - public var senderTimestamp: UInt32? - - /// Cached reaction summary for scroll performance - /// Format: "👍:3,❤️:2,😂:1" (emoji:count pairs, ordered by count desc) - public var reactionSummary: String? - - /// Route type from RxLog correlation (-1 = unknown/uncorrelated) - public var routeTypeRawValue: Int = -1 - - /// Resolved flood region the sender transmitted under, derived from - /// `transport_codes[0]` at receive time via the RxLog correlation. Incoming - /// messages only; nil when the sender's region was not in the local - /// known-regions list at receive time (back-filled by `updateKnownRegions`). - public var regionScope: String? - - /// Heard repeats for this message (cascade delete) - @Relationship(deleteRule: .cascade, inverse: \MessageRepeat.message) - var repeats: [MessageRepeat]? - - public init( - id: UUID = UUID(), - radioID: UUID, - contactID: UUID? = nil, - channelIndex: UInt8? = nil, - text: String, - timestamp: UInt32 = 0, - createdAt: Date = Date(), - sortDate: Date? = nil, - directionRawValue: Int = MessageDirection.outgoing.rawValue, - statusRawValue: Int = MessageStatus.pending.rawValue, - textTypeRawValue: UInt8 = TextType.plain.rawValue, - ackCode: UInt32? = nil, - pathLength: UInt8 = 0, - snr: Double? = nil, - pathNodes: Data? = nil, - senderKeyPrefix: Data? = nil, - senderNodeName: String? = nil, - isRead: Bool = false, - replyToID: UUID? = nil, - roundTripTime: UInt32? = nil, - heardRepeats: Int = 0, - sendCount: Int = 1, - retryAttempt: Int = 0, - maxRetryAttempts: Int = 0, - deduplicationKey: String? = nil, - linkPreviewURL: String? = nil, - linkPreviewTitle: String? = nil, - linkPreviewImageData: Data? = nil, - linkPreviewIconData: Data? = nil, - linkPreviewFetched: Bool = false, - containsSelfMention: Bool = false, - mentionSeen: Bool = false, - timestampCorrected: Bool = false, - senderTimestamp: UInt32? = nil, - reactionSummary: String? = nil, - routeTypeRawValue: Int = -1, - regionScope: String? = nil - ) { - self.id = id - self.radioID = radioID - self.contactID = contactID - self.channelIndex = channelIndex - self.text = text - self.timestamp = timestamp > 0 ? timestamp : UInt32(createdAt.timeIntervalSince1970) - self.createdAt = createdAt - self.sortDate = sortDate ?? createdAt - self.directionRawValue = directionRawValue - self.statusRawValue = statusRawValue - self.textTypeRawValue = textTypeRawValue - self.ackCode = ackCode - self.pathLength = pathLength - self.snr = snr - self.pathNodes = pathNodes - self.senderKeyPrefix = senderKeyPrefix - self.senderNodeName = senderNodeName - self.isRead = isRead - self.replyToID = replyToID - self.roundTripTime = roundTripTime - self.heardRepeats = heardRepeats - self.sendCount = sendCount - self.retryAttempt = retryAttempt - self.maxRetryAttempts = maxRetryAttempts - self.deduplicationKey = deduplicationKey - self.linkPreviewURL = linkPreviewURL - self.linkPreviewTitle = linkPreviewTitle - self.linkPreviewImageData = linkPreviewImageData - self.linkPreviewIconData = linkPreviewIconData - self.linkPreviewFetched = linkPreviewFetched - self.containsSelfMention = containsSelfMention - self.mentionSeen = mentionSeen - self.timestampCorrected = timestampCorrected - self.senderTimestamp = senderTimestamp - self.reactionSummary = reactionSummary - self.routeTypeRawValue = routeTypeRawValue - self.regionScope = regionScope - } - - /// Builds a model instance directly from a DTO. Shared by backup batch-insert - /// paths so schema changes don't require touching a 30-argument call site. - public convenience init(dto: MessageDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - contactID: dto.contactID, - channelIndex: dto.channelIndex, - text: dto.text, - timestamp: dto.timestamp, - createdAt: dto.createdAt, - sortDate: dto.sortDate, - directionRawValue: dto.direction.rawValue, - statusRawValue: dto.status.rawValue, - textTypeRawValue: dto.textType.rawValue, - ackCode: dto.ackCode, - pathLength: dto.pathLength, - snr: dto.snr, - pathNodes: dto.pathNodes, - senderKeyPrefix: dto.senderKeyPrefix, - senderNodeName: dto.senderNodeName, - isRead: dto.isRead, - replyToID: dto.replyToID, - roundTripTime: dto.roundTripTime, - heardRepeats: dto.heardRepeats, - sendCount: dto.sendCount, - retryAttempt: dto.retryAttempt, - maxRetryAttempts: dto.maxRetryAttempts, - deduplicationKey: dto.deduplicationKey, - linkPreviewURL: dto.linkPreviewURL, - linkPreviewTitle: dto.linkPreviewTitle, - // External-storage blobs stay nil and fetched stays false so the new - // row re-fetches its preview; updateMessageLinkPreview writes them. - linkPreviewImageData: nil, - linkPreviewIconData: nil, - linkPreviewFetched: false, - containsSelfMention: dto.containsSelfMention, - mentionSeen: dto.mentionSeen, - timestampCorrected: dto.timestampCorrected, - senderTimestamp: dto.senderTimestamp, - reactionSummary: dto.reactionSummary, - routeTypeRawValue: dto.routeType.map { Int($0.rawValue) } ?? -1, - regionScope: dto.regionScope - ) - } + /// Sender public key prefix (6 bytes, for incoming messages) + public var senderKeyPrefix: Data? + + /// Sender node name (for channel messages, parsed from "NodeName: MessageText" format) + public var senderNodeName: String? + + /// Whether this message has been read locally + public var isRead: Bool + + /// Reply-to message ID (for threaded replies) + public var replyToID: UUID? + + /// Round-trip time in ms (when ACK received) + public var roundTripTime: UInt32? + + /// Count of mesh repeats heard for this message (outgoing only) + public var heardRepeats: Int = 0 + + /// Number of times this message has been sent (1 = original, 2+ = sent again) + public var sendCount: Int = 1 + + /// Current retry attempt (0 = first attempt, 1 = first retry, etc.) + public var retryAttempt: Int = 0 + + /// Maximum retry attempts configured for this message + public var maxRetryAttempts: Int = 0 + + /// Deduplication key for preventing duplicate incoming messages + public var deduplicationKey: String? + + /// Link preview URL that was detected (nil if no URL in message) + public var linkPreviewURL: String? + + /// Title from link metadata + public var linkPreviewTitle: String? + + /// Preview image data (hero image) + @Attribute(.externalStorage) + public var linkPreviewImageData: Data? + + /// Icon/favicon data + @Attribute(.externalStorage) + public var linkPreviewIconData: Data? + + /// Whether fetch has been attempted (true = done, false = not yet tried) + public var linkPreviewFetched: Bool = false + + /// Whether this incoming message contains a mention of the current user + public var containsSelfMention: Bool = false + + /// Whether the user has scrolled to see this mention (for tracking unread mentions) + public var mentionSeen: Bool = false + + /// Whether the timestamp was corrected due to sender clock being invalid + public var timestampCorrected: Bool = false + + /// Original sender timestamp from the wire (for incoming messages when corrected). + /// Used for reaction hash computation to ensure sender and receiver match. + /// Nil when timestamp was not corrected or for outgoing messages. + public var senderTimestamp: UInt32? + + /// Cached reaction summary for scroll performance + /// Format: "👍:3,❤️:2,😂:1" (emoji:count pairs, ordered by count desc) + public var reactionSummary: String? + + /// Route type from RxLog correlation (-1 = unknown/uncorrelated) + public var routeTypeRawValue: Int = -1 + + /// Resolved flood region the sender transmitted under, derived from + /// `transport_codes[0]` at receive time via the RxLog correlation. Incoming + /// messages only; nil when the sender's region was not in the local + /// known-regions list at receive time (back-filled by `updateKnownRegions`). + public var regionScope: String? + + /// Heard repeats for this message (cascade delete) + @Relationship(deleteRule: .cascade, inverse: \MessageRepeat.message) + var repeats: [MessageRepeat]? + + public init( + id: UUID = UUID(), + radioID: UUID, + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + text: String, + timestamp: UInt32 = 0, + createdAt: Date = Date(), + sortDate: Date? = nil, + directionRawValue: Int = MessageDirection.outgoing.rawValue, + statusRawValue: Int = MessageStatus.pending.rawValue, + textTypeRawValue: UInt8 = TextType.plain.rawValue, + ackCode: UInt32? = nil, + pathLength: UInt8 = 0, + snr: Double? = nil, + pathNodes: Data? = nil, + senderKeyPrefix: Data? = nil, + senderNodeName: String? = nil, + isRead: Bool = false, + replyToID: UUID? = nil, + roundTripTime: UInt32? = nil, + heardRepeats: Int = 0, + sendCount: Int = 1, + retryAttempt: Int = 0, + maxRetryAttempts: Int = 0, + deduplicationKey: String? = nil, + linkPreviewURL: String? = nil, + linkPreviewTitle: String? = nil, + linkPreviewImageData: Data? = nil, + linkPreviewIconData: Data? = nil, + linkPreviewFetched: Bool = false, + containsSelfMention: Bool = false, + mentionSeen: Bool = false, + timestampCorrected: Bool = false, + senderTimestamp: UInt32? = nil, + reactionSummary: String? = nil, + routeTypeRawValue: Int = -1, + regionScope: String? = nil + ) { + self.id = id + self.radioID = radioID + self.contactID = contactID + self.channelIndex = channelIndex + self.text = text + self.timestamp = timestamp > 0 ? timestamp : UInt32(createdAt.timeIntervalSince1970) + self.createdAt = createdAt + self.sortDate = sortDate ?? createdAt + self.directionRawValue = directionRawValue + self.statusRawValue = statusRawValue + self.textTypeRawValue = textTypeRawValue + self.ackCode = ackCode + self.pathLength = pathLength + self.snr = snr + self.pathNodes = pathNodes + self.senderKeyPrefix = senderKeyPrefix + self.senderNodeName = senderNodeName + self.isRead = isRead + self.replyToID = replyToID + self.roundTripTime = roundTripTime + self.heardRepeats = heardRepeats + self.sendCount = sendCount + self.retryAttempt = retryAttempt + self.maxRetryAttempts = maxRetryAttempts + self.deduplicationKey = deduplicationKey + self.linkPreviewURL = linkPreviewURL + self.linkPreviewTitle = linkPreviewTitle + self.linkPreviewImageData = linkPreviewImageData + self.linkPreviewIconData = linkPreviewIconData + self.linkPreviewFetched = linkPreviewFetched + self.containsSelfMention = containsSelfMention + self.mentionSeen = mentionSeen + self.timestampCorrected = timestampCorrected + self.senderTimestamp = senderTimestamp + self.reactionSummary = reactionSummary + self.routeTypeRawValue = routeTypeRawValue + self.regionScope = regionScope + } + + /// Builds a model instance directly from a DTO. Shared by backup batch-insert + /// paths so schema changes don't require touching a 30-argument call site. + public convenience init(dto: MessageDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + contactID: dto.contactID, + channelIndex: dto.channelIndex, + text: dto.text, + timestamp: dto.timestamp, + createdAt: dto.createdAt, + sortDate: dto.sortDate, + directionRawValue: dto.direction.rawValue, + statusRawValue: dto.status.rawValue, + textTypeRawValue: dto.textType.rawValue, + ackCode: dto.ackCode, + pathLength: dto.pathLength, + snr: dto.snr, + pathNodes: dto.pathNodes, + senderKeyPrefix: dto.senderKeyPrefix, + senderNodeName: dto.senderNodeName, + isRead: dto.isRead, + replyToID: dto.replyToID, + roundTripTime: dto.roundTripTime, + heardRepeats: dto.heardRepeats, + sendCount: dto.sendCount, + retryAttempt: dto.retryAttempt, + maxRetryAttempts: dto.maxRetryAttempts, + deduplicationKey: dto.deduplicationKey, + linkPreviewURL: dto.linkPreviewURL, + linkPreviewTitle: dto.linkPreviewTitle, + // External-storage blobs stay nil and fetched stays false so the new + // row re-fetches its preview; updateMessageLinkPreview writes them. + linkPreviewImageData: nil, + linkPreviewIconData: nil, + linkPreviewFetched: false, + containsSelfMention: dto.containsSelfMention, + mentionSeen: dto.mentionSeen, + timestampCorrected: dto.timestampCorrected, + senderTimestamp: dto.senderTimestamp, + reactionSummary: dto.reactionSummary, + routeTypeRawValue: dto.routeType.map { Int($0.rawValue) } ?? -1, + regionScope: dto.regionScope + ) + } } // MARK: - Computed Properties public extension Message { - /// Direction enum - var direction: MessageDirection { - MessageDirection(rawValue: directionRawValue) ?? .outgoing - } - - /// Status enum - var status: MessageStatus { - get { MessageStatus(rawValue: statusRawValue) ?? .pending } - set { statusRawValue = newValue.rawValue } - } - - /// Text type enum - var textType: TextType { - TextType(rawValue: textTypeRawValue) ?? .plain - } - - /// Whether this is an outgoing message - var isOutgoing: Bool { - direction == .outgoing - } - - /// Whether this is a channel message - var isChannelMessage: Bool { - channelIndex != nil - } - - /// Whether the message is still pending delivery - var isPending: Bool { - status == .pending || status == .sending - } - - /// Whether the message failed to send - var hasFailed: Bool { - status == .failed - } - + /// Direction enum + var direction: MessageDirection { + MessageDirection(rawValue: directionRawValue) ?? .outgoing + } + + /// Status enum + var status: MessageStatus { + get { MessageStatus(rawValue: statusRawValue) ?? .pending } + set { statusRawValue = newValue.rawValue } + } + + /// Text type enum + var textType: TextType { + TextType(rawValue: textTypeRawValue) ?? .plain + } + + /// Whether this is an outgoing message + var isOutgoing: Bool { + direction == .outgoing + } + + /// Whether this is a channel message + var isChannelMessage: Bool { + channelIndex != nil + } + + /// Whether the message is still pending delivery + var isPending: Bool { + status == .pending || status == .sending + } + + /// Whether the message failed to send + var hasFailed: Bool { + status == .failed + } } // MARK: - Sendable DTO /// A sendable snapshot of Message for cross-actor transfers public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { - public var id: UUID - public var radioID: UUID - public var contactID: UUID? - public var channelIndex: UInt8? - public var text: String - public var timestamp: UInt32 - public var createdAt: Date - public var sortDate: Date - public var direction: MessageDirection - public var status: MessageStatus - public var textType: TextType - public var ackCode: UInt32? - public var pathLength: UInt8 - public var snr: Double? - public var pathNodes: Data? - public var senderKeyPrefix: Data? - public var senderNodeName: String? - public var isRead: Bool - public var replyToID: UUID? - public var roundTripTime: UInt32? - public var heardRepeats: Int - public var sendCount: Int - public var retryAttempt: Int - public var maxRetryAttempts: Int - public var deduplicationKey: String? - public var linkPreviewURL: String? - public var linkPreviewTitle: String? - public var linkPreviewImageData: Data? - public var linkPreviewIconData: Data? - public var linkPreviewFetched: Bool - public var containsSelfMention: Bool - public var mentionSeen: Bool - public var timestampCorrected: Bool - public var senderTimestamp: UInt32? - public var reactionSummary: String? - public var routeType: RouteType? - public var regionScope: String? - - // Explicit Codable so backups predating ``sortDate`` decode cleanly. - // Legacy envelopes have no `sortDate` key; it falls back to `createdAt`, - // which must be decoded first. Every other field decodes exactly as the - // synthesized Codable did, preserving the existing wire format. - private enum CodingKeys: String, CodingKey { - case id, radioID, contactID, channelIndex, text, timestamp, createdAt, - sortDate, direction, status, textType, ackCode, pathLength, snr, - pathNodes, senderKeyPrefix, senderNodeName, isRead, replyToID, - roundTripTime, heardRepeats, sendCount, retryAttempt, maxRetryAttempts, - deduplicationKey, linkPreviewURL, linkPreviewTitle, linkPreviewImageData, - linkPreviewIconData, linkPreviewFetched, containsSelfMention, mentionSeen, - timestampCorrected, senderTimestamp, reactionSummary, routeType, regionScope - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(UUID.self, forKey: .id) - self.radioID = try container.decode(UUID.self, forKey: .radioID) - self.contactID = try container.decodeIfPresent(UUID.self, forKey: .contactID) - self.channelIndex = try container.decodeIfPresent(UInt8.self, forKey: .channelIndex) - self.text = try container.decode(String.self, forKey: .text) - self.timestamp = try container.decode(UInt32.self, forKey: .timestamp) - let createdAt = try container.decode(Date.self, forKey: .createdAt) - self.createdAt = createdAt - self.sortDate = try container.decodeIfPresent(Date.self, forKey: .sortDate) ?? createdAt - self.direction = try container.decode(MessageDirection.self, forKey: .direction) - self.status = try container.decode(MessageStatus.self, forKey: .status) - self.textType = try container.decode(TextType.self, forKey: .textType) - self.ackCode = try container.decodeIfPresent(UInt32.self, forKey: .ackCode) - self.pathLength = try container.decode(UInt8.self, forKey: .pathLength) - self.snr = try container.decodeIfPresent(Double.self, forKey: .snr) - self.pathNodes = try container.decodeIfPresent(Data.self, forKey: .pathNodes) - self.senderKeyPrefix = try container.decodeIfPresent(Data.self, forKey: .senderKeyPrefix) - self.senderNodeName = try container.decodeIfPresent(String.self, forKey: .senderNodeName) - self.isRead = try container.decode(Bool.self, forKey: .isRead) - self.replyToID = try container.decodeIfPresent(UUID.self, forKey: .replyToID) - self.roundTripTime = try container.decodeIfPresent(UInt32.self, forKey: .roundTripTime) - self.heardRepeats = try container.decode(Int.self, forKey: .heardRepeats) - self.sendCount = try container.decode(Int.self, forKey: .sendCount) - self.retryAttempt = try container.decode(Int.self, forKey: .retryAttempt) - self.maxRetryAttempts = try container.decode(Int.self, forKey: .maxRetryAttempts) - self.deduplicationKey = try container.decodeIfPresent(String.self, forKey: .deduplicationKey) - self.linkPreviewURL = try container.decodeIfPresent(String.self, forKey: .linkPreviewURL) - self.linkPreviewTitle = try container.decodeIfPresent(String.self, forKey: .linkPreviewTitle) - self.linkPreviewImageData = try container.decodeIfPresent(Data.self, forKey: .linkPreviewImageData) - self.linkPreviewIconData = try container.decodeIfPresent(Data.self, forKey: .linkPreviewIconData) - self.linkPreviewFetched = try container.decode(Bool.self, forKey: .linkPreviewFetched) - self.containsSelfMention = try container.decode(Bool.self, forKey: .containsSelfMention) - self.mentionSeen = try container.decode(Bool.self, forKey: .mentionSeen) - self.timestampCorrected = try container.decode(Bool.self, forKey: .timestampCorrected) - self.senderTimestamp = try container.decodeIfPresent(UInt32.self, forKey: .senderTimestamp) - self.reactionSummary = try container.decodeIfPresent(String.self, forKey: .reactionSummary) - self.routeType = try container.decodeIfPresent(RouteType.self, forKey: .routeType) - self.regionScope = try container.decodeIfPresent(String.self, forKey: .regionScope) - } - - public init(from message: Message, includeLinkPreviewBlobs: Bool = true) { - self.id = message.id - self.radioID = message.radioID - self.contactID = message.contactID - self.channelIndex = message.channelIndex - self.text = message.text - self.timestamp = message.timestamp - self.createdAt = message.createdAt - self.sortDate = message.sortDate - self.direction = message.direction - self.status = message.status - self.textType = message.textType - self.ackCode = message.ackCode - self.pathLength = message.pathLength - self.snr = message.snr - self.pathNodes = message.pathNodes - self.senderKeyPrefix = message.senderKeyPrefix - self.senderNodeName = message.senderNodeName - self.isRead = message.isRead - self.replyToID = message.replyToID - self.roundTripTime = message.roundTripTime - self.heardRepeats = message.heardRepeats - self.sendCount = message.sendCount - self.retryAttempt = message.retryAttempt - self.maxRetryAttempts = message.maxRetryAttempts - self.deduplicationKey = message.deduplicationKey - self.linkPreviewURL = message.linkPreviewURL - self.linkPreviewTitle = message.linkPreviewTitle - // External-storage blobs (linkPreviewImageData/IconData) are only faulted when - // accessed. Backup export skips them to avoid loading every preview image into - // memory just to discard it. - if includeLinkPreviewBlobs { - self.linkPreviewImageData = message.linkPreviewImageData - self.linkPreviewIconData = message.linkPreviewIconData - self.linkPreviewFetched = message.linkPreviewFetched - } else { - self.linkPreviewImageData = nil - self.linkPreviewIconData = nil - self.linkPreviewFetched = false + public var id: UUID + public var radioID: UUID + public var contactID: UUID? + public var channelIndex: UInt8? + public var text: String + public var timestamp: UInt32 + public var createdAt: Date + public var sortDate: Date + public var direction: MessageDirection + public var status: MessageStatus + public var textType: TextType + public var ackCode: UInt32? + public var pathLength: UInt8 + public var snr: Double? + public var pathNodes: Data? + public var senderKeyPrefix: Data? + public var senderNodeName: String? + public var isRead: Bool + public var replyToID: UUID? + public var roundTripTime: UInt32? + public var heardRepeats: Int + public var sendCount: Int + public var retryAttempt: Int + public var maxRetryAttempts: Int + public var deduplicationKey: String? + public var linkPreviewURL: String? + public var linkPreviewTitle: String? + public var linkPreviewImageData: Data? + public var linkPreviewIconData: Data? + public var linkPreviewFetched: Bool + public var containsSelfMention: Bool + public var mentionSeen: Bool + public var timestampCorrected: Bool + public var senderTimestamp: UInt32? + public var reactionSummary: String? + public var routeType: RouteType? + public var regionScope: String? + + /// Explicit Codable so backups predating ``sortDate`` decode cleanly. + /// Legacy envelopes have no `sortDate` key; it falls back to `createdAt`, + /// which must be decoded first. Every other field decodes exactly as the + /// synthesized Codable did, preserving the existing wire format. + private enum CodingKeys: String, CodingKey { + case id, radioID, contactID, channelIndex, text, timestamp, createdAt, + sortDate, direction, status, textType, ackCode, pathLength, snr, + pathNodes, senderKeyPrefix, senderNodeName, isRead, replyToID, + roundTripTime, heardRepeats, sendCount, retryAttempt, maxRetryAttempts, + deduplicationKey, linkPreviewURL, linkPreviewTitle, linkPreviewImageData, + linkPreviewIconData, linkPreviewFetched, containsSelfMention, mentionSeen, + timestampCorrected, senderTimestamp, reactionSummary, routeType, regionScope + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + radioID = try container.decode(UUID.self, forKey: .radioID) + contactID = try container.decodeIfPresent(UUID.self, forKey: .contactID) + channelIndex = try container.decodeIfPresent(UInt8.self, forKey: .channelIndex) + text = try container.decode(String.self, forKey: .text) + timestamp = try container.decode(UInt32.self, forKey: .timestamp) + let createdAt = try container.decode(Date.self, forKey: .createdAt) + self.createdAt = createdAt + sortDate = try container.decodeIfPresent(Date.self, forKey: .sortDate) ?? createdAt + direction = try container.decode(MessageDirection.self, forKey: .direction) + status = try container.decode(MessageStatus.self, forKey: .status) + textType = try container.decode(TextType.self, forKey: .textType) + ackCode = try container.decodeIfPresent(UInt32.self, forKey: .ackCode) + pathLength = try container.decode(UInt8.self, forKey: .pathLength) + snr = try container.decodeIfPresent(Double.self, forKey: .snr) + pathNodes = try container.decodeIfPresent(Data.self, forKey: .pathNodes) + senderKeyPrefix = try container.decodeIfPresent(Data.self, forKey: .senderKeyPrefix) + senderNodeName = try container.decodeIfPresent(String.self, forKey: .senderNodeName) + isRead = try container.decode(Bool.self, forKey: .isRead) + replyToID = try container.decodeIfPresent(UUID.self, forKey: .replyToID) + roundTripTime = try container.decodeIfPresent(UInt32.self, forKey: .roundTripTime) + heardRepeats = try container.decode(Int.self, forKey: .heardRepeats) + sendCount = try container.decode(Int.self, forKey: .sendCount) + retryAttempt = try container.decode(Int.self, forKey: .retryAttempt) + maxRetryAttempts = try container.decode(Int.self, forKey: .maxRetryAttempts) + deduplicationKey = try container.decodeIfPresent(String.self, forKey: .deduplicationKey) + linkPreviewURL = try container.decodeIfPresent(String.self, forKey: .linkPreviewURL) + linkPreviewTitle = try container.decodeIfPresent(String.self, forKey: .linkPreviewTitle) + linkPreviewImageData = try container.decodeIfPresent(Data.self, forKey: .linkPreviewImageData) + linkPreviewIconData = try container.decodeIfPresent(Data.self, forKey: .linkPreviewIconData) + linkPreviewFetched = try container.decode(Bool.self, forKey: .linkPreviewFetched) + containsSelfMention = try container.decode(Bool.self, forKey: .containsSelfMention) + mentionSeen = try container.decode(Bool.self, forKey: .mentionSeen) + timestampCorrected = try container.decode(Bool.self, forKey: .timestampCorrected) + senderTimestamp = try container.decodeIfPresent(UInt32.self, forKey: .senderTimestamp) + reactionSummary = try container.decodeIfPresent(String.self, forKey: .reactionSummary) + routeType = try container.decodeIfPresent(RouteType.self, forKey: .routeType) + regionScope = try container.decodeIfPresent(String.self, forKey: .regionScope) + } + + public init(from message: Message, includeLinkPreviewBlobs: Bool = true) { + id = message.id + radioID = message.radioID + contactID = message.contactID + channelIndex = message.channelIndex + text = message.text + timestamp = message.timestamp + createdAt = message.createdAt + sortDate = message.sortDate + direction = message.direction + status = message.status + textType = message.textType + ackCode = message.ackCode + pathLength = message.pathLength + snr = message.snr + pathNodes = message.pathNodes + senderKeyPrefix = message.senderKeyPrefix + senderNodeName = message.senderNodeName + isRead = message.isRead + replyToID = message.replyToID + roundTripTime = message.roundTripTime + heardRepeats = message.heardRepeats + sendCount = message.sendCount + retryAttempt = message.retryAttempt + maxRetryAttempts = message.maxRetryAttempts + deduplicationKey = message.deduplicationKey + linkPreviewURL = message.linkPreviewURL + linkPreviewTitle = message.linkPreviewTitle + // External-storage blobs (linkPreviewImageData/IconData) are only faulted when + // accessed. Backup export skips them to avoid loading every preview image into + // memory just to discard it. + if includeLinkPreviewBlobs { + linkPreviewImageData = message.linkPreviewImageData + linkPreviewIconData = message.linkPreviewIconData + linkPreviewFetched = message.linkPreviewFetched + } else { + linkPreviewImageData = nil + linkPreviewIconData = nil + linkPreviewFetched = false + } + containsSelfMention = message.containsSelfMention + mentionSeen = message.mentionSeen + timestampCorrected = message.timestampCorrected + senderTimestamp = message.senderTimestamp + reactionSummary = message.reactionSummary + routeType = UInt8(exactly: message.routeTypeRawValue) + .flatMap(RouteType.init(rawValue:)) + regionScope = message.regionScope + } + + /// Memberwise initializer for creating DTOs directly + public init( + id: UUID, + radioID: UUID, + contactID: UUID?, + channelIndex: UInt8?, + text: String, + timestamp: UInt32, + createdAt: Date, + sortDate: Date? = nil, + direction: MessageDirection, + status: MessageStatus, + textType: TextType, + ackCode: UInt32?, + pathLength: UInt8, + snr: Double?, + pathNodes: Data? = nil, + senderKeyPrefix: Data?, + senderNodeName: String?, + isRead: Bool, + replyToID: UUID?, + roundTripTime: UInt32?, + heardRepeats: Int, + sendCount: Int = 1, + retryAttempt: Int, + maxRetryAttempts: Int, + deduplicationKey: String? = nil, + linkPreviewURL: String? = nil, + linkPreviewTitle: String? = nil, + linkPreviewImageData: Data? = nil, + linkPreviewIconData: Data? = nil, + linkPreviewFetched: Bool = false, + containsSelfMention: Bool = false, + mentionSeen: Bool = false, + timestampCorrected: Bool = false, + senderTimestamp: UInt32? = nil, + reactionSummary: String? = nil, + routeType: RouteType? = nil, + regionScope: String? = nil + ) { + self.id = id + self.radioID = radioID + self.contactID = contactID + self.channelIndex = channelIndex + self.text = text + self.timestamp = timestamp + self.createdAt = createdAt + self.sortDate = sortDate ?? createdAt + self.direction = direction + self.status = status + self.textType = textType + self.ackCode = ackCode + self.pathLength = pathLength + self.snr = snr + self.pathNodes = pathNodes + self.senderKeyPrefix = senderKeyPrefix + self.senderNodeName = senderNodeName + self.isRead = isRead + self.replyToID = replyToID + self.roundTripTime = roundTripTime + self.heardRepeats = heardRepeats + self.sendCount = sendCount + self.retryAttempt = retryAttempt + self.maxRetryAttempts = maxRetryAttempts + self.deduplicationKey = deduplicationKey + self.linkPreviewURL = linkPreviewURL + self.linkPreviewTitle = linkPreviewTitle + self.linkPreviewImageData = linkPreviewImageData + self.linkPreviewIconData = linkPreviewIconData + self.linkPreviewFetched = linkPreviewFetched + self.containsSelfMention = containsSelfMention + self.mentionSeen = mentionSeen + self.timestampCorrected = timestampCorrected + self.senderTimestamp = senderTimestamp + self.reactionSummary = reactionSummary + self.routeType = routeType + self.regionScope = regionScope + } + + public var isOutgoing: Bool { + direction == .outgoing + } + + public var isChannelMessage: Bool { + channelIndex != nil + } + + /// Timestamp to use for reaction hash computation. + /// Uses original sender timestamp if available (for incoming messages with corrected timestamps), + /// otherwise uses the stored timestamp. + public var reactionTimestamp: UInt32 { + senderTimestamp ?? timestamp + } + + public var isPending: Bool { + status == .pending || status == .sending + } + + public var hasFailed: Bool { + status == .failed + } + + /// Returns a new MessageDTO with the given mutations applied. + public func copy(_ mutations: (inout MessageDTO) -> Void) -> MessageDTO { + var copy = self + mutations(©) + return copy + } + + /// Date used for display and sorting (local receive time) + public var date: Date { + createdAt + } + + /// Date derived from the sender's device clock (may differ from `date` if the sender's clock is skewed) + public var senderDate: Date { + Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + /// Raw, uncorrected send time the sender stamped on the wire. + /// Unlike `senderDate`, this is not clock-corrected, so a skewed + /// sender clock surfaces its literal value. + public var wireSentDate: Date { + Date(timeIntervalSince1970: TimeInterval(senderTimestamp ?? timestamp)) + } + + /// Hop count decoded from pathLength (lower 6 bits) + public var hopCount: Int { + decodePathLen(pathLength)?.hopCount ?? Int(pathLength & 63) + } + + /// Whether this message was flood-routed (broadcast). + /// Priority: channelIndex (channels are always flood) → routeType from RxLog → pathLength inference. + public var isFloodRouted: Bool { + if channelIndex != nil { return true } + if let routeType { return routeType == .flood || routeType == .tcFlood } + return pathLength != PacketBuilder.floodPathSentinel + } + + /// Whether this message was direct-routed (pre-built path, hops consumed in transit). + public var isDirectRouted: Bool { + !isFloodRouted + } + + /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits + public var pathHashSize: Int { + decodePathLen(pathLength)?.hashSize ?? 1 + } + + /// Hash size per hop in bytes (1, 2, or 3) when the path length byte encodes a + /// valid hash mode; nil for reserved modes or the no-path marker (0xFF). + public var pathHashSizeIfKnown: Int? { + decodePathLen(pathLength)?.hashSize + } + + /// Each hop as its raw hash bytes plus uppercase hex, e.g. `[(0xA3, "A3"), (0x7F, "7F")]`. + /// The raw bytes are needed to match a hop against a repeater's public-key prefix. + public var pathHops: [(data: Data, hex: String)] { + guard let pathNodes else { return [] } + return pathNodes.pathHops(hashSize: pathHashSize) + } + + /// Path nodes as hex strings for display, chunked by hash size + public var pathNodesHex: [String] { + pathHops.map(\.hex) + } + + /// Path as arrow-separated string (e.g., "A3 → 7F → 42") + public var pathString: String { + pathNodesHex.joined(separator: " → ") + } + + /// Path as comma-separated string for clipboard (e.g., "A3,7F,42") + public var pathStringForClipboard: String { + pathNodesHex.joined(separator: ",") + } + + // MARK: - Same-Sender Reordering + + /// Maximum time window (in seconds) within which consecutive messages from the same sender + /// are re-sorted by sender timestamp to preserve intended send order. + private static let sameSenderReorderWindow: TimeInterval = 5 + + /// Reorders messages within narrow same-sender clusters by sender timestamp. + /// + /// Expects input already sorted by `sortDate` (the display sort key). When multiple + /// messages from the same sender fall within a short window, mesh relay may deliver them + /// out of order. This function detects those clusters and re-sorts them by the sender's + /// claimed timestamp to restore the intended conversation order. The cluster window is + /// measured on `sortDate` — the same key the input is sorted by — so it stays non-negative + /// and cannot pull together rows that are far apart on the display axis. + public static func reorderSameSenderClusters(_ messages: [MessageDTO]) -> [MessageDTO] { + guard messages.count > 1 else { return messages } + + var result = messages + var clusterStart = 0 + + while clusterStart < result.count { + var clusterEnd = clusterStart + 1 + + // Extend the cluster while consecutive messages match the same sender/direction + // and fall within the reorder window + while clusterEnd < result.count { + let gap = result[clusterEnd].sortDate.timeIntervalSince(result[clusterEnd - 1].sortDate) + guard isSameSender(result[clusterEnd], result[clusterEnd - 1]), + gap <= sameSenderReorderWindow else { break } + clusterEnd += 1 + } + + // Sort the cluster by sender timestamp if it contains more than one message + if clusterEnd - clusterStart > 1 { + let sorted = result[clusterStart.. Bool { + guard a.direction == b.direction else { return false } + guard a.isChannelMessage == b.isChannelMessage else { return false } - public var hasFailed: Bool { - status == .failed + // For channel messages, compare sender node name (nil = unknown, treat as different). + // senderNodeName isn't unique — two users with the same name may be falsely clustered. + if a.isChannelMessage { + guard let nameA = a.senderNodeName, let nameB = b.senderNodeName else { return false } + return nameA == nameB } - /// Returns a new MessageDTO with the given mutations applied. - public func copy(_ mutations: (inout MessageDTO) -> Void) -> MessageDTO { - var copy = self - mutations(©) - return copy - } - - /// Date used for display and sorting (local receive time) - public var date: Date { - createdAt - } - - /// Date derived from the sender's device clock (may differ from `date` if the sender's clock is skewed) - public var senderDate: Date { - Date(timeIntervalSince1970: TimeInterval(timestamp)) - } - - /// Raw, uncorrected send time the sender stamped on the wire. - /// Unlike `senderDate`, this is not clock-corrected, so a skewed - /// sender clock surfaces its literal value. - public var wireSentDate: Date { - Date(timeIntervalSince1970: TimeInterval(senderTimestamp ?? timestamp)) - } - - /// Hop count decoded from pathLength (lower 6 bits) - public var hopCount: Int { - decodePathLen(pathLength)?.hopCount ?? Int(pathLength & 63) - } - - /// Whether this message was flood-routed (broadcast). - /// Priority: channelIndex (channels are always flood) → routeType from RxLog → pathLength inference. - public var isFloodRouted: Bool { - if channelIndex != nil { return true } - if let routeType { return routeType == .flood || routeType == .tcFlood } - return pathLength != PacketBuilder.floodPathSentinel - } - - /// Whether this message was direct-routed (pre-built path, hops consumed in transit). - public var isDirectRouted: Bool { - !isFloodRouted - } - - /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits - public var pathHashSize: Int { - decodePathLen(pathLength)?.hashSize ?? 1 - } - - /// Hash size per hop in bytes (1, 2, or 3) when the path length byte encodes a - /// valid hash mode; nil for reserved modes or the no-path marker (0xFF). - public var pathHashSizeIfKnown: Int? { - decodePathLen(pathLength)?.hashSize - } - - /// Each hop as its raw hash bytes plus uppercase hex, e.g. `[(0xA3, "A3"), (0x7F, "7F")]`. - /// The raw bytes are needed to match a hop against a repeater's public-key prefix. - public var pathHops: [(data: Data, hex: String)] { - guard let pathNodes else { return [] } - return pathNodes.pathHops(hashSize: pathHashSize) - } - - /// Path nodes as hex strings for display, chunked by hash size - public var pathNodesHex: [String] { - pathHops.map(\.hex) - } - - /// Path as arrow-separated string (e.g., "A3 → 7F → 42") - public var pathString: String { - pathNodesHex.joined(separator: " → ") - } - - /// Path as comma-separated string for clipboard (e.g., "A3,7F,42") - public var pathStringForClipboard: String { - pathNodesHex.joined(separator: ",") - } - - // MARK: - Same-Sender Reordering - - /// Maximum time window (in seconds) within which consecutive messages from the same sender - /// are re-sorted by sender timestamp to preserve intended send order. - private static let sameSenderReorderWindow: TimeInterval = 5 - - /// Reorders messages within narrow same-sender clusters by sender timestamp. - /// - /// Expects input already sorted by `sortDate` (the display sort key). When multiple - /// messages from the same sender fall within a short window, mesh relay may deliver them - /// out of order. This function detects those clusters and re-sorts them by the sender's - /// claimed timestamp to restore the intended conversation order. The cluster window is - /// measured on `sortDate` — the same key the input is sorted by — so it stays non-negative - /// and cannot pull together rows that are far apart on the display axis. - public static func reorderSameSenderClusters(_ messages: [MessageDTO]) -> [MessageDTO] { - guard messages.count > 1 else { return messages } - - var result = messages - var clusterStart = 0 - - while clusterStart < result.count { - var clusterEnd = clusterStart + 1 - - // Extend the cluster while consecutive messages match the same sender/direction - // and fall within the reorder window - while clusterEnd < result.count { - let gap = result[clusterEnd].sortDate.timeIntervalSince(result[clusterEnd - 1].sortDate) - guard isSameSender(result[clusterEnd], result[clusterEnd - 1]), - gap <= sameSenderReorderWindow else { break } - clusterEnd += 1 - } - - // Sort the cluster by sender timestamp if it contains more than one message - if clusterEnd - clusterStart > 1 { - let sorted = result[clusterStart.. Bool { - guard a.direction == b.direction else { return false } - guard a.isChannelMessage == b.isChannelMessage else { return false } - - // For channel messages, compare sender node name (nil = unknown, treat as different). - // senderNodeName isn't unique — two users with the same name may be falsely clustered. - if a.isChannelMessage { - guard let nameA = a.senderNodeName, let nameB = b.senderNodeName else { return false } - return nameA == nameB - } - - // For DMs, same-direction messages share the same sender - return true - } + // For DMs, same-direction messages share the same sender + return true + } } diff --git a/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift b/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift index c74f7141..00671ccb 100644 --- a/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift @@ -6,51 +6,51 @@ import Foundation /// `hasFailed` is true only for `MessageStatus.failed`. Transitional statuses /// (`.pending`, `.sending`, `.retrying`) do not set this flag. public struct MessageEnvelope: Sendable, Hashable { - public let messageID: UUID - public let isOutgoing: Bool - public let senderName: String - public let senderResolution: NodeNameResolution - public let status: MessageStatus - public let date: Date - public let hasFailed: Bool - public let containsSelfMention: Bool - public let mentionSeen: Bool + public let messageID: UUID + public let isOutgoing: Bool + public let senderName: String + public let senderResolution: NodeNameResolution + public let status: MessageStatus + public let date: Date + public let hasFailed: Bool + public let containsSelfMention: Bool + public let mentionSeen: Bool - public init( - messageID: UUID, - isOutgoing: Bool, - senderName: String, - senderResolution: NodeNameResolution, - status: MessageStatus, - date: Date, - hasFailed: Bool, - containsSelfMention: Bool, - mentionSeen: Bool - ) { - self.messageID = messageID - self.isOutgoing = isOutgoing - self.senderName = senderName - self.senderResolution = senderResolution - self.status = status - self.date = date - self.hasFailed = hasFailed - self.containsSelfMention = containsSelfMention - self.mentionSeen = mentionSeen - } + public init( + messageID: UUID, + isOutgoing: Bool, + senderName: String, + senderResolution: NodeNameResolution, + status: MessageStatus, + date: Date, + hasFailed: Bool, + containsSelfMention: Bool, + mentionSeen: Bool + ) { + self.messageID = messageID + self.isOutgoing = isOutgoing + self.senderName = senderName + self.senderResolution = senderResolution + self.status = status + self.date = date + self.hasFailed = hasFailed + self.containsSelfMention = containsSelfMention + self.mentionSeen = mentionSeen + } - /// Returns a new envelope with `status` (and the derived `hasFailed`) - /// overridden. Eliminates the 9-field rebuild at status-flip sites. - public func with(status: MessageStatus) -> MessageEnvelope { - MessageEnvelope( - messageID: messageID, - isOutgoing: isOutgoing, - senderName: senderName, - senderResolution: senderResolution, - status: status, - date: date, - hasFailed: status == .failed, - containsSelfMention: containsSelfMention, - mentionSeen: mentionSeen - ) - } + /// Returns a new envelope with `status` (and the derived `hasFailed`) + /// overridden. Eliminates the 9-field rebuild at status-flip sites. + public func with(status: MessageStatus) -> MessageEnvelope { + MessageEnvelope( + messageID: messageID, + isOutgoing: isOutgoing, + senderName: senderName, + senderResolution: senderResolution, + status: status, + date: date, + hasFailed: status == .failed, + containsSelfMention: containsSelfMention, + mentionSeen: mentionSeen + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/MessageRepeat.swift b/MC1Services/Sources/MC1Services/Models/MessageRepeat.swift index 666b6235..55cb87e4 100644 --- a/MC1Services/Sources/MC1Services/Models/MessageRepeat.swift +++ b/MC1Services/Sources/MC1Services/Models/MessageRepeat.swift @@ -6,170 +6,174 @@ import SwiftData /// Each repeat is an observation of the message being re-broadcast by a repeater. @Model final class MessageRepeat { - #Index( - [\.messageID, \.receivedAt], - [\.rxLogEntryID] + #Index( + [\.messageID, \.receivedAt], + [\.rxLogEntryID] + ) + + @Attribute(.unique) + var id: UUID + + /// The parent message (cascade delete when message is deleted) + var message: Message? + + /// The message ID (kept for queries, matches message.id) + var messageID: UUID + + /// When this repeat was received by the companion radio + var receivedAt: Date + + /// Repeater public key prefixes (1–3 bytes per hop depending on hash mode) + var pathNodes: Data + + /// Encoded path length byte (upper 2 bits = hash mode, lower 6 bits = hop count) + var pathLength: UInt8 = 0 + + /// Signal-to-noise ratio in dB + var snr: Double? + + /// Received signal strength indicator in dBm + var rssi: Int? + + /// Link to RxLogEntry for raw packet details + var rxLogEntryID: UUID? + + init( + id: UUID = UUID(), + message: Message? = nil, + messageID: UUID, + receivedAt: Date = Date(), + pathNodes: Data, + pathLength: UInt8 = 0, + snr: Double? = nil, + rssi: Int? = nil, + rxLogEntryID: UUID? = nil + ) { + self.id = id + self.message = message + self.messageID = messageID + self.receivedAt = receivedAt + self.pathNodes = pathNodes + self.pathLength = pathLength + self.snr = snr + self.rssi = rssi + self.rxLogEntryID = rxLogEntryID + } + + /// Builds a model instance directly from a DTO. The parent `message` + /// relationship is passed separately because the caller looks it up in + /// bulk before iterating. + convenience init(dto: MessageRepeatDTO, message: Message?) { + self.init( + id: dto.id, + message: message, + messageID: dto.messageID, + receivedAt: dto.receivedAt, + pathNodes: dto.pathNodes, + pathLength: dto.pathLength, + snr: dto.snr, + rssi: dto.rssi, + rxLogEntryID: dto.rxLogEntryID ) - - @Attribute(.unique) - var id: UUID - - /// The parent message (cascade delete when message is deleted) - var message: Message? - - /// The message ID (kept for queries, matches message.id) - var messageID: UUID - - /// When this repeat was received by the companion radio - var receivedAt: Date - - /// Repeater public key prefixes (1–3 bytes per hop depending on hash mode) - var pathNodes: Data - - /// Encoded path length byte (upper 2 bits = hash mode, lower 6 bits = hop count) - var pathLength: UInt8 = 0 - - /// Signal-to-noise ratio in dB - var snr: Double? - - /// Received signal strength indicator in dBm - var rssi: Int? - - /// Link to RxLogEntry for raw packet details - var rxLogEntryID: UUID? - - init( - id: UUID = UUID(), - message: Message? = nil, - messageID: UUID, - receivedAt: Date = Date(), - pathNodes: Data, - pathLength: UInt8 = 0, - snr: Double? = nil, - rssi: Int? = nil, - rxLogEntryID: UUID? = nil - ) { - self.id = id - self.message = message - self.messageID = messageID - self.receivedAt = receivedAt - self.pathNodes = pathNodes - self.pathLength = pathLength - self.snr = snr - self.rssi = rssi - self.rxLogEntryID = rxLogEntryID - } - - /// Builds a model instance directly from a DTO. The parent `message` - /// relationship is passed separately because the caller looks it up in - /// bulk before iterating. - convenience init(dto: MessageRepeatDTO, message: Message?) { - self.init( - id: dto.id, - message: message, - messageID: dto.messageID, - receivedAt: dto.receivedAt, - pathNodes: dto.pathNodes, - pathLength: dto.pathLength, - snr: dto.snr, - rssi: dto.rssi, - rxLogEntryID: dto.rxLogEntryID - ) - } + } } // MARK: - DTO /// Sendable DTO for cross-actor transfer of MessageRepeat data. public struct MessageRepeatDTO: Sendable, Identifiable, Equatable, Hashable, Codable { - public let id: UUID - public var messageID: UUID - public let receivedAt: Date - public let pathNodes: Data - public let pathLength: UInt8 - public let snr: Double? - public let rssi: Int? - public let rxLogEntryID: UUID? - - init(from model: MessageRepeat) { - self.id = model.id - self.messageID = model.messageID - self.receivedAt = model.receivedAt - self.pathNodes = model.pathNodes - self.pathLength = model.pathLength - self.snr = model.snr - self.rssi = model.rssi - self.rxLogEntryID = model.rxLogEntryID - } - - public init( - id: UUID = UUID(), - messageID: UUID, - receivedAt: Date, - pathNodes: Data, - pathLength: UInt8 = 0, - snr: Double?, - rssi: Int?, - rxLogEntryID: UUID? - ) { - self.id = id - self.messageID = messageID - self.receivedAt = receivedAt - self.pathNodes = pathNodes - self.pathLength = pathLength - self.snr = snr - self.rssi = rssi - self.rxLogEntryID = rxLogEntryID - } - - // MARK: - Computed Properties - - /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits - public var hashSize: Int { - decodePathLen(pathLength)?.hashSize ?? 1 - } - - /// Last repeater's public key prefix bytes (the node we heard from), or nil if direct - public var repeaterHash: Data? { - guard !pathNodes.isEmpty else { return nil } - let size = hashSize - guard pathNodes.count >= size else { return pathNodes } - return pathNodes.suffix(size) - } - - /// Number of hops in the path (1 = direct from repeater, 2+ = multi-hop) - public var hopCount: Int { - let size = hashSize - guard size > 0 else { return pathNodes.count } - return pathNodes.count / size - } - - /// Repeater hash formatted as hex (e.g., "31" for 1-byte, "31A7" for 2-byte) - public var repeaterHashFormatted: String { - guard let hash = repeaterHash else { return "00" } - return hash.uppercaseHexString() - } - - /// Path nodes as hex strings for display, chunked by hash size - public var pathNodesHex: [String] { - pathNodes.pathHops(hashSize: hashSize).map(\.hex) - } - - /// Classified signal quality based on SNR thresholds. - public var snrQuality: SNRQuality { SNRQuality(snr: snr) } - - /// SNR mapped to 0-1 for signal bars variableValue. - public var snrLevel: Double { snrQuality.barLevel } - - /// RSSI formatted for display - public var rssiFormatted: String { - guard let rssi = rssi else { return "—" } - return "\(rssi) dBm" - } - - /// SNR formatted for display - public var snrFormatted: String { - guard let snr = snr else { return "—" } - return snr.formatted(.number.precision(.fractionLength(1))) + " dB" - } + public let id: UUID + public var messageID: UUID + public let receivedAt: Date + public let pathNodes: Data + public let pathLength: UInt8 + public let snr: Double? + public let rssi: Int? + public let rxLogEntryID: UUID? + + init(from model: MessageRepeat) { + id = model.id + messageID = model.messageID + receivedAt = model.receivedAt + pathNodes = model.pathNodes + pathLength = model.pathLength + snr = model.snr + rssi = model.rssi + rxLogEntryID = model.rxLogEntryID + } + + public init( + id: UUID = UUID(), + messageID: UUID, + receivedAt: Date, + pathNodes: Data, + pathLength: UInt8 = 0, + snr: Double?, + rssi: Int?, + rxLogEntryID: UUID? + ) { + self.id = id + self.messageID = messageID + self.receivedAt = receivedAt + self.pathNodes = pathNodes + self.pathLength = pathLength + self.snr = snr + self.rssi = rssi + self.rxLogEntryID = rxLogEntryID + } + + // MARK: - Computed Properties + + /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits + public var hashSize: Int { + decodePathLen(pathLength)?.hashSize ?? 1 + } + + /// Last repeater's public key prefix bytes (the node we heard from), or nil if direct + public var repeaterHash: Data? { + guard !pathNodes.isEmpty else { return nil } + let size = hashSize + guard pathNodes.count >= size else { return pathNodes } + return pathNodes.suffix(size) + } + + /// Number of hops in the path (1 = direct from repeater, 2+ = multi-hop) + public var hopCount: Int { + let size = hashSize + guard size > 0 else { return pathNodes.count } + return pathNodes.count / size + } + + /// Repeater hash formatted as hex (e.g., "31" for 1-byte, "31A7" for 2-byte) + public var repeaterHashFormatted: String { + guard let hash = repeaterHash else { return "00" } + return hash.uppercaseHexString() + } + + /// Path nodes as hex strings for display, chunked by hash size + public var pathNodesHex: [String] { + pathNodes.pathHops(hashSize: hashSize).map(\.hex) + } + + /// Classified signal quality based on SNR thresholds. + public var snrQuality: SNRQuality { + SNRQuality(snr: snr) + } + + /// SNR mapped to 0-1 for signal bars variableValue. + public var snrLevel: Double { + snrQuality.barLevel + } + + /// RSSI formatted for display + public var rssiFormatted: String { + guard let rssi else { return "—" } + return "\(rssi) dBm" + } + + /// SNR formatted for display + public var snrFormatted: String { + guard let snr else { return "—" } + return snr.formatted(.number.precision(.fractionLength(1))) + " dB" + } } diff --git a/MC1Services/Sources/MC1Services/Models/NodeConfig.swift b/MC1Services/Sources/MC1Services/Models/NodeConfig.swift index 489d9635..033cc412 100644 --- a/MC1Services/Sources/MC1Services/Models/NodeConfig.swift +++ b/MC1Services/Sources/MC1Services/Models/NodeConfig.swift @@ -2,294 +2,294 @@ import Foundation /// JSON-serializable node configuration, compatible with MeshCore companion app format. public struct MeshCoreNodeConfig: Codable, Sendable, Equatable { - public var name: String? - public var publicKey: String? - public var privateKey: String? - public var radioSettings: RadioSettings? - public var positionSettings: PositionSettings? - public var otherSettings: OtherSettings? - public var channels: [ChannelConfig]? - public var contacts: [ContactConfig]? + public var name: String? + public var publicKey: String? + public var privateKey: String? + public var radioSettings: RadioSettings? + public var positionSettings: PositionSettings? + public var otherSettings: OtherSettings? + public var channels: [ChannelConfig]? + public var contacts: [ContactConfig]? - public init( - name: String? = nil, - publicKey: String? = nil, - privateKey: String? = nil, - radioSettings: RadioSettings? = nil, - positionSettings: PositionSettings? = nil, - otherSettings: OtherSettings? = nil, - channels: [ChannelConfig]? = nil, - contacts: [ContactConfig]? = nil - ) { - self.name = name - self.publicKey = publicKey - self.privateKey = privateKey - self.radioSettings = radioSettings - self.positionSettings = positionSettings - self.otherSettings = otherSettings - self.channels = channels - self.contacts = contacts - } + public init( + name: String? = nil, + publicKey: String? = nil, + privateKey: String? = nil, + radioSettings: RadioSettings? = nil, + positionSettings: PositionSettings? = nil, + otherSettings: OtherSettings? = nil, + channels: [ChannelConfig]? = nil, + contacts: [ContactConfig]? = nil + ) { + self.name = name + self.publicKey = publicKey + self.privateKey = privateKey + self.radioSettings = radioSettings + self.positionSettings = positionSettings + self.otherSettings = otherSettings + self.channels = channels + self.contacts = contacts + } - enum CodingKeys: String, CodingKey { - case name - case publicKey = "public_key" - case privateKey = "private_key" - case radioSettings = "radio_settings" - case positionSettings = "position_settings" - case otherSettings = "other_settings" - case channels - case contacts - } + enum CodingKeys: String, CodingKey { + case name + case publicKey = "public_key" + case privateKey = "private_key" + case radioSettings = "radio_settings" + case positionSettings = "position_settings" + case otherSettings = "other_settings" + case channels + case contacts + } } // MARK: - Radio Settings -extension MeshCoreNodeConfig { - public struct RadioSettings: Codable, Sendable, Equatable { - /// Frequency in kHz (e.g. 910525 = 910.525 MHz) - public var frequency: UInt32 - /// Bandwidth in Hz (e.g. 62500 = 62.5 kHz) - public var bandwidth: UInt32 - public var spreadingFactor: UInt8 - public var codingRate: UInt8 - /// Transmit power in dBm (may be negative) - public var txPower: Int8 +public extension MeshCoreNodeConfig { + struct RadioSettings: Codable, Sendable, Equatable { + /// Frequency in kHz (e.g. 910525 = 910.525 MHz) + public var frequency: UInt32 + /// Bandwidth in Hz (e.g. 62500 = 62.5 kHz) + public var bandwidth: UInt32 + public var spreadingFactor: UInt8 + public var codingRate: UInt8 + /// Transmit power in dBm (may be negative) + public var txPower: Int8 - public init( - frequency: UInt32, - bandwidth: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8, - txPower: Int8 - ) { - self.frequency = frequency - self.bandwidth = bandwidth - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.txPower = txPower - } + public init( + frequency: UInt32, + bandwidth: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8, + txPower: Int8 + ) { + self.frequency = frequency + self.bandwidth = bandwidth + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.txPower = txPower + } - // swiftlint:disable:next nesting - enum CodingKeys: String, CodingKey { - case frequency, bandwidth - case spreadingFactor = "spreading_factor" - case codingRate = "coding_rate" - case txPower = "tx_power" - } + // swiftlint:disable:next nesting + enum CodingKeys: String, CodingKey { + case frequency, bandwidth + case spreadingFactor = "spreading_factor" + case codingRate = "coding_rate" + case txPower = "tx_power" } + } } // MARK: - Position Settings -extension MeshCoreNodeConfig { - public struct PositionSettings: Codable, Sendable, Equatable { - public var latitude: String - public var longitude: String +public extension MeshCoreNodeConfig { + struct PositionSettings: Codable, Sendable, Equatable { + public var latitude: String + public var longitude: String - public init(latitude: String, longitude: String) { - self.latitude = latitude - self.longitude = longitude - } + public init(latitude: String, longitude: String) { + self.latitude = latitude + self.longitude = longitude + } - /// Both lat and lon are zero (likely unset). - public var isZero: Bool { - (Double(latitude) ?? 0) == 0 && (Double(longitude) ?? 0) == 0 - } + /// Both lat and lon are zero (likely unset). + public var isZero: Bool { + (Double(latitude) ?? 0) == 0 && (Double(longitude) ?? 0) == 0 } + } } // MARK: - Other Settings -extension MeshCoreNodeConfig { - /// Other device parameters. Only `manual_add_contacts` and `advert_location_policy` - /// are exported, matching the official companion app format. All 7 fields are decoded - /// on import for forward compatibility. - public struct OtherSettings: Codable, Sendable, Equatable { - public var manualAddContacts: UInt8? - public var advertLocationPolicy: UInt8? - public var telemetryModeBase: UInt8? - public var telemetryModeLocation: UInt8? - public var telemetryModeEnvironment: UInt8? - public var multiAcks: UInt8? - public var advertisementType: UInt8? +public extension MeshCoreNodeConfig { + /// Other device parameters. Only `manual_add_contacts` and `advert_location_policy` + /// are exported, matching the official companion app format. All 7 fields are decoded + /// on import for forward compatibility. + struct OtherSettings: Codable, Sendable, Equatable { + public var manualAddContacts: UInt8? + public var advertLocationPolicy: UInt8? + public var telemetryModeBase: UInt8? + public var telemetryModeLocation: UInt8? + public var telemetryModeEnvironment: UInt8? + public var multiAcks: UInt8? + public var advertisementType: UInt8? - public init( - manualAddContacts: UInt8? = nil, - advertLocationPolicy: UInt8? = nil, - telemetryModeBase: UInt8? = nil, - telemetryModeLocation: UInt8? = nil, - telemetryModeEnvironment: UInt8? = nil, - multiAcks: UInt8? = nil, - advertisementType: UInt8? = nil - ) { - self.manualAddContacts = manualAddContacts - self.advertLocationPolicy = advertLocationPolicy - self.telemetryModeBase = telemetryModeBase - self.telemetryModeLocation = telemetryModeLocation - self.telemetryModeEnvironment = telemetryModeEnvironment - self.multiAcks = multiAcks - self.advertisementType = advertisementType - } + public init( + manualAddContacts: UInt8? = nil, + advertLocationPolicy: UInt8? = nil, + telemetryModeBase: UInt8? = nil, + telemetryModeLocation: UInt8? = nil, + telemetryModeEnvironment: UInt8? = nil, + multiAcks: UInt8? = nil, + advertisementType: UInt8? = nil + ) { + self.manualAddContacts = manualAddContacts + self.advertLocationPolicy = advertLocationPolicy + self.telemetryModeBase = telemetryModeBase + self.telemetryModeLocation = telemetryModeLocation + self.telemetryModeEnvironment = telemetryModeEnvironment + self.multiAcks = multiAcks + self.advertisementType = advertisementType + } - // swiftlint:disable:next nesting - enum CodingKeys: String, CodingKey { - case manualAddContacts = "manual_add_contacts" - case advertLocationPolicy = "advert_location_policy" - case telemetryModeBase = "telemetry_mode_base" - case telemetryModeLocation = "telemetry_mode_location" - case telemetryModeEnvironment = "telemetry_mode_environment" - case multiAcks = "multi_acks" - case advertisementType = "advertisement_type" - } + // swiftlint:disable:next nesting + enum CodingKeys: String, CodingKey { + case manualAddContacts = "manual_add_contacts" + case advertLocationPolicy = "advert_location_policy" + case telemetryModeBase = "telemetry_mode_base" + case telemetryModeLocation = "telemetry_mode_location" + case telemetryModeEnvironment = "telemetry_mode_environment" + case multiAcks = "multi_acks" + case advertisementType = "advertisement_type" } + } } // MARK: - Channel Config -extension MeshCoreNodeConfig { - public struct ChannelConfig: Codable, Sendable, Equatable { - public var name: String - /// Hex-encoded 16-byte secret (32 hex characters). - public var secret: String +public extension MeshCoreNodeConfig { + struct ChannelConfig: Codable, Sendable, Equatable { + public var name: String + /// Hex-encoded 16-byte secret (32 hex characters). + public var secret: String - public init(name: String, secret: String) { - self.name = name - self.secret = secret - } + public init(name: String, secret: String) { + self.name = name + self.secret = secret } + } } // MARK: - Contact Config -extension MeshCoreNodeConfig { - public struct ContactConfig: Codable, Sendable, Equatable { - public var type: UInt8 - public var name: String - /// App-local nickname (not part of wire protocol). - /// Decoded from JSON for companion app compatibility but not imported to firmware. - public var customName: String? - /// Hex-encoded 32-byte public key (64 hex characters) - public var publicKey: String - public var flags: UInt8 - public var latitude: String - public var longitude: String - public var lastAdvert: UInt32 - /// Read-only; firmware sets this value - public var lastModified: UInt32 - /// Hex-encoded path data, or nil for no path - public var outPath: String? - /// Path hash mode (0=1-byte, 1=2-byte, 2=3-byte). Nil in configs from older versions. - public var pathHashMode: UInt8? +public extension MeshCoreNodeConfig { + struct ContactConfig: Codable, Sendable, Equatable { + public var type: UInt8 + public var name: String + /// App-local nickname (not part of wire protocol). + /// Decoded from JSON for companion app compatibility but not imported to firmware. + public var customName: String? + /// Hex-encoded 32-byte public key (64 hex characters) + public var publicKey: String + public var flags: UInt8 + public var latitude: String + public var longitude: String + public var lastAdvert: UInt32 + /// Read-only; firmware sets this value + public var lastModified: UInt32 + /// Hex-encoded path data, or nil for no path + public var outPath: String? + /// Path hash mode (0=1-byte, 1=2-byte, 2=3-byte). Nil in configs from older versions. + public var pathHashMode: UInt8? - public init( - type: UInt8, - name: String, - customName: String? = nil, - publicKey: String, - flags: UInt8, - latitude: String, - longitude: String, - lastAdvert: UInt32, - lastModified: UInt32, - outPath: String? = nil, - pathHashMode: UInt8? = nil - ) { - self.type = type - self.name = name - self.customName = customName - self.publicKey = publicKey - self.flags = flags - self.latitude = latitude - self.longitude = longitude - self.lastAdvert = lastAdvert - self.lastModified = lastModified - self.outPath = outPath - self.pathHashMode = pathHashMode - } + public init( + type: UInt8, + name: String, + customName: String? = nil, + publicKey: String, + flags: UInt8, + latitude: String, + longitude: String, + lastAdvert: UInt32, + lastModified: UInt32, + outPath: String? = nil, + pathHashMode: UInt8? = nil + ) { + self.type = type + self.name = name + self.customName = customName + self.publicKey = publicKey + self.flags = flags + self.latitude = latitude + self.longitude = longitude + self.lastAdvert = lastAdvert + self.lastModified = lastModified + self.outPath = outPath + self.pathHashMode = pathHashMode + } - // swiftlint:disable:next nesting - enum CodingKeys: String, CodingKey { - case type, name, flags, latitude, longitude - case customName = "custom_name" - case publicKey = "public_key" - case lastAdvert = "last_advert" - case lastModified = "last_modified" - case outPath = "out_path" - case pathHashMode = "path_hash_mode" - } + // swiftlint:disable:next nesting + enum CodingKeys: String, CodingKey { + case type, name, flags, latitude, longitude + case customName = "custom_name" + case publicKey = "public_key" + case lastAdvert = "last_advert" + case lastModified = "last_modified" + case outPath = "out_path" + case pathHashMode = "path_hash_mode" + } - /// Encodes with explicit `null` for nil `customName` and `outPath`, - /// matching the official companion app JSON format. - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(type, forKey: .type) - try container.encode(name, forKey: .name) - try container.encode(customName, forKey: .customName) - try container.encode(publicKey, forKey: .publicKey) - try container.encode(flags, forKey: .flags) - try container.encode(latitude, forKey: .latitude) - try container.encode(longitude, forKey: .longitude) - try container.encode(lastAdvert, forKey: .lastAdvert) - try container.encode(lastModified, forKey: .lastModified) - try container.encode(outPath, forKey: .outPath) - try container.encodeIfPresent(pathHashMode, forKey: .pathHashMode) - } + /// Encodes with explicit `null` for nil `customName` and `outPath`, + /// matching the official companion app JSON format. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(name, forKey: .name) + try container.encode(customName, forKey: .customName) + try container.encode(publicKey, forKey: .publicKey) + try container.encode(flags, forKey: .flags) + try container.encode(latitude, forKey: .latitude) + try container.encode(longitude, forKey: .longitude) + try container.encode(lastAdvert, forKey: .lastAdvert) + try container.encode(lastModified, forKey: .lastModified) + try container.encode(outPath, forKey: .outPath) + try container.encodeIfPresent(pathHashMode, forKey: .pathHashMode) } + } } // MARK: - Section Selection /// Controls which config sections to include in export/import. public struct ConfigSections: Sendable, Equatable { - public var nodeIdentity: Bool - public var radioSettings: Bool - public var positionSettings: Bool - public var otherSettings: Bool - public var channels: Bool - public var contacts: Bool + public var nodeIdentity: Bool + public var radioSettings: Bool + public var positionSettings: Bool + public var otherSettings: Bool + public var channels: Bool + public var contacts: Bool - public init( - nodeIdentity: Bool = false, - radioSettings: Bool = false, - positionSettings: Bool = false, - otherSettings: Bool = false, - channels: Bool = false, - contacts: Bool = false - ) { - self.nodeIdentity = nodeIdentity - self.radioSettings = radioSettings - self.positionSettings = positionSettings - self.otherSettings = otherSettings - self.channels = channels - self.contacts = contacts - } + public init( + nodeIdentity: Bool = false, + radioSettings: Bool = false, + positionSettings: Bool = false, + otherSettings: Bool = false, + channels: Bool = false, + contacts: Bool = false + ) { + self.nodeIdentity = nodeIdentity + self.radioSettings = radioSettings + self.positionSettings = positionSettings + self.otherSettings = otherSettings + self.channels = channels + self.contacts = contacts + } - /// True when all sections are selected. - public var allSelected: Bool { - nodeIdentity && radioSettings && positionSettings && otherSettings && channels && contacts - } + /// True when all sections are selected. + public var allSelected: Bool { + nodeIdentity && radioSettings && positionSettings && otherSettings && channels && contacts + } - /// True when at least one section is selected. - public var anySectionSelected: Bool { - nodeIdentity || radioSettings || positionSettings || otherSettings || channels || contacts - } + /// True when at least one section is selected. + public var anySectionSelected: Bool { + nodeIdentity || radioSettings || positionSettings || otherSettings || channels || contacts + } - public mutating func selectAll() { - nodeIdentity = true - radioSettings = true - positionSettings = true - otherSettings = true - channels = true - contacts = true - } + public mutating func selectAll() { + nodeIdentity = true + radioSettings = true + positionSettings = true + otherSettings = true + channels = true + contacts = true + } - public mutating func deselectAll() { - nodeIdentity = false - radioSettings = false - positionSettings = false - otherSettings = false - channels = false - contacts = false - } + public mutating func deselectAll() { + nodeIdentity = false + radioSettings = false + positionSettings = false + otherSettings = false + channels = false + contacts = false + } } diff --git a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift index 484a57bf..f2951c99 100644 --- a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift +++ b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift @@ -4,295 +4,381 @@ import SwiftData /// Capture-rate policy for node status snapshots. enum NodeSnapshotPolicy { - /// Minimum interval between persisted snapshots for the same node. - /// A status, telemetry, or neighbor capture arriving within this window of - /// the latest snapshot enriches that row rather than inserting a new one. - static let minimumInterval: TimeInterval = 15 * 60 + /// Minimum interval between persisted snapshots for the same node. + /// A status, telemetry, or neighbor capture arriving within this window of + /// the latest snapshot enriches that row rather than inserting a new one. + static let minimumInterval: TimeInterval = 15 * 60 } /// The radio/room status fields captured in a snapshot, clamped to their /// storage types. Bundling them into one value keeps the save and backfill /// paths in lockstep and performs the lossy `Int16` conversions exactly once. public struct NodeStatusMetrics: Sendable, Equatable { - public let batteryMillivolts: UInt16? - public let lastSNR: Double? - public let lastRSSI: Int16? - public let noiseFloor: Int16? - public let uptimeSeconds: UInt32? - public let rxAirtimeSeconds: UInt32? - public let packetsSent: UInt32? - public let packetsReceived: UInt32? - public let receiveErrors: UInt32? - public let postedCount: UInt16? - public let postPushCount: UInt16? - - public init( - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) { - self.batteryMillivolts = batteryMillivolts - self.lastSNR = lastSNR - self.lastRSSI = lastRSSI - self.noiseFloor = noiseFloor - self.uptimeSeconds = uptimeSeconds - self.rxAirtimeSeconds = rxAirtimeSeconds - self.packetsSent = packetsSent - self.packetsReceived = packetsReceived - self.receiveErrors = receiveErrors - self.postedCount = postedCount - self.postPushCount = postPushCount - } - - /// Build metrics from a status response. `lastRSSI` and `noiseFloor` saturate - /// into `Int16` so an out-of-range firmware value clamps to the axis bound. - /// Role-specific fields are supplied by the caller: repeaters pass - /// `rxAirtimeSeconds`/`receiveErrors`, rooms pass `postedCount`/`postPushCount`. - public init( - status: RemoteNodeStatus, - rxAirtimeSeconds: UInt32? = nil, - receiveErrors: UInt32? = nil, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) { - self.init( - batteryMillivolts: status.batteryMillivolts, - lastSNR: status.lastSNR, - lastRSSI: Int16(clamping: status.lastRSSI), - noiseFloor: Int16(clamping: status.noiseFloor), - uptimeSeconds: status.uptimeSeconds, - rxAirtimeSeconds: rxAirtimeSeconds, - packetsSent: status.packetsSent, - packetsReceived: status.packetsReceived, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - } + public let batteryMillivolts: UInt16? + public let lastSNR: Double? + public let lastRSSI: Int16? + public let noiseFloor: Int16? + public let uptimeSeconds: UInt32? + public let rxAirtimeSeconds: UInt32? + public let packetsSent: UInt32? + public let packetsReceived: UInt32? + public let receiveErrors: UInt32? + + /// Per-type packet breakdown: direct vs flood packets sent/received and duplicates. + public let sentDirect: UInt32? + public let sentFlood: UInt32? + public let receivedDirect: UInt32? + public let receivedFlood: UInt32? + public let directDuplicates: UInt32? + public let floodDuplicates: UInt32? + + public let postedCount: UInt16? + public let postPushCount: UInt16? + + public init( + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + sentDirect: UInt32?, + sentFlood: UInt32?, + receivedDirect: UInt32?, + receivedFlood: UInt32?, + directDuplicates: UInt32?, + floodDuplicates: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) { + self.batteryMillivolts = batteryMillivolts + self.lastSNR = lastSNR + self.lastRSSI = lastRSSI + self.noiseFloor = noiseFloor + self.uptimeSeconds = uptimeSeconds + self.rxAirtimeSeconds = rxAirtimeSeconds + self.packetsSent = packetsSent + self.packetsReceived = packetsReceived + self.receiveErrors = receiveErrors + self.sentDirect = sentDirect + self.sentFlood = sentFlood + self.receivedDirect = receivedDirect + self.receivedFlood = receivedFlood + self.directDuplicates = directDuplicates + self.floodDuplicates = floodDuplicates + self.postedCount = postedCount + self.postPushCount = postPushCount + } + + /// Build metrics from a status response. `lastRSSI` and `noiseFloor` saturate + /// into `Int16` so an out-of-range firmware value clamps to the axis bound. + /// Role-specific fields are supplied by the caller: repeaters pass + /// `rxAirtimeSeconds`/`receiveErrors`, rooms pass `postedCount`/`postPushCount`. + public init( + status: RemoteNodeStatus, + rxAirtimeSeconds: UInt32? = nil, + receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) { + self.init( + batteryMillivolts: status.batteryMillivolts, + lastSNR: status.lastSNR, + lastRSSI: Int16(clamping: status.lastRSSI), + noiseFloor: Int16(clamping: status.noiseFloor), + uptimeSeconds: status.uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: status.packetsSent, + packetsReceived: status.packetsReceived, + receiveErrors: receiveErrors, + sentDirect: status.sentDirect, + sentFlood: status.sentFlood, + receivedDirect: status.receivedDirect, + receivedFlood: status.receivedFlood, + directDuplicates: UInt32(clamping: status.directDuplicates), + floodDuplicates: UInt32(clamping: status.floodDuplicates), + postedCount: postedCount, + postPushCount: postPushCount + ) + } } /// Codable entry representing a single neighbor's state at snapshot time. public struct NeighborSnapshotEntry: Codable, Sendable, Equatable { - public let publicKeyPrefix: Data - public let snr: Double - public let secondsAgo: Int - - public init(publicKeyPrefix: Data, snr: Double, secondsAgo: Int) { - self.publicKeyPrefix = publicKeyPrefix - self.snr = snr - self.secondsAgo = secondsAgo - } + public let publicKeyPrefix: Data + public let snr: Double + public let secondsAgo: Int + + public init(publicKeyPrefix: Data, snr: Double, secondsAgo: Int) { + self.publicKeyPrefix = publicKeyPrefix + self.snr = snr + self.secondsAgo = secondsAgo + } } /// Codable entry representing a single telemetry reading at snapshot time. public struct TelemetrySnapshotEntry: Codable, Sendable, Equatable { - public let channel: Int - public let type: String - public let value: Double - - public init(channel: Int, type: String, value: Double) { - self.channel = channel - self.type = type - self.value = value - } + public let channel: Int + public let type: String + public let value: Double + + public init(channel: Int, type: String, value: Double) { + self.channel = channel + self.type = type + self.value = value + } } /// Point-in-time snapshot of a remote node's status, captured when the user views it. @Model final class NodeStatusSnapshot { - #Index([\.nodePublicKey, \.timestamp]) - - @Attribute(.unique) - var id: UUID - - /// When this snapshot was captured - var timestamp: Date - - /// The node's full public key (32 bytes) -- links to RemoteNodeSession - var nodePublicKey: Data - - // MARK: - Radio metrics - // Intentionally excluded: txQueueLength, airtime, sentFlood, sentDirect, - // receivedFlood, receivedDirect, fullEvents, directDuplicates, floodDuplicates - - var batteryMillivolts: UInt16? - var lastSNR: Double? - var lastRSSI: Int16? - var noiseFloor: Int16? - var uptimeSeconds: UInt32? - var rxAirtimeSeconds: UInt32? - var packetsSent: UInt32? - var packetsReceived: UInt32? - var receiveErrors: UInt32? - - // MARK: - Room server metrics - - var postedCount: UInt16? - var postPushCount: UInt16? - - // MARK: - Optional neighbor/telemetry data - - /// Neighbor data, only populated if the user expanded the neighbors section. - var neighborSnapshots: [NeighborSnapshotEntry]? - - /// Telemetry data, only populated if the user expanded the telemetry section. - var telemetryEntries: [TelemetrySnapshotEntry]? - - init( - id: UUID = UUID(), - timestamp: Date = .now, - nodePublicKey: Data, - batteryMillivolts: UInt16? = nil, - lastSNR: Double? = nil, - lastRSSI: Int16? = nil, - noiseFloor: Int16? = nil, - uptimeSeconds: UInt32? = nil, - rxAirtimeSeconds: UInt32? = nil, - packetsSent: UInt32? = nil, - packetsReceived: UInt32? = nil, - receiveErrors: UInt32? = nil, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil, - neighborSnapshots: [NeighborSnapshotEntry]? = nil, - telemetryEntries: [TelemetrySnapshotEntry]? = nil - ) { - self.id = id - self.timestamp = timestamp - self.nodePublicKey = nodePublicKey - self.batteryMillivolts = batteryMillivolts - self.lastSNR = lastSNR - self.lastRSSI = lastRSSI - self.noiseFloor = noiseFloor - self.uptimeSeconds = uptimeSeconds - self.rxAirtimeSeconds = rxAirtimeSeconds - self.packetsSent = packetsSent - self.packetsReceived = packetsReceived - self.receiveErrors = receiveErrors - self.postedCount = postedCount - self.postPushCount = postPushCount - self.neighborSnapshots = neighborSnapshots - self.telemetryEntries = telemetryEntries - } - - /// Apply captured status metrics onto this snapshot, leaving neighbor and - /// telemetry enrichment untouched. - func apply(_ metrics: NodeStatusMetrics) { - batteryMillivolts = metrics.batteryMillivolts - lastSNR = metrics.lastSNR - lastRSSI = metrics.lastRSSI - noiseFloor = metrics.noiseFloor - uptimeSeconds = metrics.uptimeSeconds - rxAirtimeSeconds = metrics.rxAirtimeSeconds - packetsSent = metrics.packetsSent - packetsReceived = metrics.packetsReceived - receiveErrors = metrics.receiveErrors - postedCount = metrics.postedCount - postPushCount = metrics.postPushCount - } - - /// Builds a model instance directly from a DTO. - convenience init(dto: NodeStatusSnapshotDTO) { - self.init( - id: dto.id, - timestamp: dto.timestamp, - nodePublicKey: dto.nodePublicKey, - batteryMillivolts: dto.batteryMillivolts, - lastSNR: dto.lastSNR, - lastRSSI: dto.lastRSSI, - noiseFloor: dto.noiseFloor, - uptimeSeconds: dto.uptimeSeconds, - rxAirtimeSeconds: dto.rxAirtimeSeconds, - packetsSent: dto.packetsSent, - packetsReceived: dto.packetsReceived, - receiveErrors: dto.receiveErrors, - postedCount: dto.postedCount, - postPushCount: dto.postPushCount, - neighborSnapshots: dto.neighborSnapshots, - telemetryEntries: dto.telemetryEntries - ) - } + #Index([\.nodePublicKey, \.timestamp]) + + @Attribute(.unique) + var id: UUID + + /// When this snapshot was captured + var timestamp: Date + + /// The node's full public key (32 bytes) -- links to RemoteNodeSession + var nodePublicKey: Data + + // MARK: - Radio metrics + + // Intentionally excluded: txQueueLength, airtime, fullEvents + + var batteryMillivolts: UInt16? + var lastSNR: Double? + var lastRSSI: Int16? + var noiseFloor: Int16? + var uptimeSeconds: UInt32? + var rxAirtimeSeconds: UInt32? + var packetsSent: UInt32? + var packetsReceived: UInt32? + var receiveErrors: UInt32? + + /// Per-type packet breakdown: direct vs flood packets sent/received and duplicates. + var sentDirect: UInt32? + var sentFlood: UInt32? + var receivedDirect: UInt32? + var receivedFlood: UInt32? + var directDuplicates: UInt32? + var floodDuplicates: UInt32? + + // MARK: - Room server metrics + + var postedCount: UInt16? + var postPushCount: UInt16? + + // MARK: - Optional neighbor/telemetry data + + /// Neighbor data, only populated if the user expanded the neighbors section. + var neighborSnapshots: [NeighborSnapshotEntry]? + + /// Telemetry data, only populated if the user expanded the telemetry section. + var telemetryEntries: [TelemetrySnapshotEntry]? + + init( + id: UUID = UUID(), + timestamp: Date = .now, + nodePublicKey: Data, + batteryMillivolts: UInt16? = nil, + lastSNR: Double? = nil, + lastRSSI: Int16? = nil, + noiseFloor: Int16? = nil, + uptimeSeconds: UInt32? = nil, + rxAirtimeSeconds: UInt32? = nil, + packetsSent: UInt32? = nil, + packetsReceived: UInt32? = nil, + receiveErrors: UInt32? = nil, + sentDirect: UInt32? = nil, + sentFlood: UInt32? = nil, + receivedDirect: UInt32? = nil, + receivedFlood: UInt32? = nil, + directDuplicates: UInt32? = nil, + floodDuplicates: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil, + neighborSnapshots: [NeighborSnapshotEntry]? = nil, + telemetryEntries: [TelemetrySnapshotEntry]? = nil + ) { + self.id = id + self.timestamp = timestamp + self.nodePublicKey = nodePublicKey + self.batteryMillivolts = batteryMillivolts + self.lastSNR = lastSNR + self.lastRSSI = lastRSSI + self.noiseFloor = noiseFloor + self.uptimeSeconds = uptimeSeconds + self.rxAirtimeSeconds = rxAirtimeSeconds + self.packetsSent = packetsSent + self.packetsReceived = packetsReceived + self.receiveErrors = receiveErrors + self.sentDirect = sentDirect + self.sentFlood = sentFlood + self.receivedDirect = receivedDirect + self.receivedFlood = receivedFlood + self.directDuplicates = directDuplicates + self.floodDuplicates = floodDuplicates + self.postedCount = postedCount + self.postPushCount = postPushCount + self.neighborSnapshots = neighborSnapshots + self.telemetryEntries = telemetryEntries + } + + /// Apply captured status metrics onto this snapshot, leaving neighbor and + /// telemetry enrichment untouched. + func apply(_ metrics: NodeStatusMetrics) { + batteryMillivolts = metrics.batteryMillivolts + lastSNR = metrics.lastSNR + lastRSSI = metrics.lastRSSI + noiseFloor = metrics.noiseFloor + uptimeSeconds = metrics.uptimeSeconds + rxAirtimeSeconds = metrics.rxAirtimeSeconds + packetsSent = metrics.packetsSent + packetsReceived = metrics.packetsReceived + receiveErrors = metrics.receiveErrors + sentDirect = metrics.sentDirect + sentFlood = metrics.sentFlood + receivedDirect = metrics.receivedDirect + receivedFlood = metrics.receivedFlood + directDuplicates = metrics.directDuplicates + floodDuplicates = metrics.floodDuplicates + postedCount = metrics.postedCount + postPushCount = metrics.postPushCount + } + + /// Builds a model instance directly from a DTO. + convenience init(dto: NodeStatusSnapshotDTO) { + self.init( + id: dto.id, + timestamp: dto.timestamp, + nodePublicKey: dto.nodePublicKey, + batteryMillivolts: dto.batteryMillivolts, + lastSNR: dto.lastSNR, + lastRSSI: dto.lastRSSI, + noiseFloor: dto.noiseFloor, + uptimeSeconds: dto.uptimeSeconds, + rxAirtimeSeconds: dto.rxAirtimeSeconds, + packetsSent: dto.packetsSent, + packetsReceived: dto.packetsReceived, + receiveErrors: dto.receiveErrors, + sentDirect: dto.sentDirect, + sentFlood: dto.sentFlood, + receivedDirect: dto.receivedDirect, + receivedFlood: dto.receivedFlood, + directDuplicates: dto.directDuplicates, + floodDuplicates: dto.floodDuplicates, + postedCount: dto.postedCount, + postPushCount: dto.postPushCount, + neighborSnapshots: dto.neighborSnapshots, + telemetryEntries: dto.telemetryEntries + ) + } } // MARK: - Sendable DTO public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable, Codable { - public let id: UUID - public let timestamp: Date - public let nodePublicKey: Data - public let batteryMillivolts: UInt16? - public let lastSNR: Double? - public let lastRSSI: Int16? - public let noiseFloor: Int16? - public let uptimeSeconds: UInt32? - public let rxAirtimeSeconds: UInt32? - public let packetsSent: UInt32? - public let packetsReceived: UInt32? - public let receiveErrors: UInt32? - public let postedCount: UInt16? - public let postPushCount: UInt16? - public let neighborSnapshots: [NeighborSnapshotEntry]? - public let telemetryEntries: [TelemetrySnapshotEntry]? - - init(from model: NodeStatusSnapshot) { - self.id = model.id - self.timestamp = model.timestamp - self.nodePublicKey = model.nodePublicKey - self.batteryMillivolts = model.batteryMillivolts - self.lastSNR = model.lastSNR - self.lastRSSI = model.lastRSSI - self.noiseFloor = model.noiseFloor - self.uptimeSeconds = model.uptimeSeconds - self.rxAirtimeSeconds = model.rxAirtimeSeconds - self.packetsSent = model.packetsSent - self.packetsReceived = model.packetsReceived - self.receiveErrors = model.receiveErrors - self.postedCount = model.postedCount - self.postPushCount = model.postPushCount - self.neighborSnapshots = model.neighborSnapshots - self.telemetryEntries = model.telemetryEntries - } - - public init( - id: UUID = UUID(), - timestamp: Date = .now, - nodePublicKey: Data, - batteryMillivolts: UInt16? = nil, - lastSNR: Double? = nil, - lastRSSI: Int16? = nil, - noiseFloor: Int16? = nil, - uptimeSeconds: UInt32? = nil, - rxAirtimeSeconds: UInt32? = nil, - packetsSent: UInt32? = nil, - packetsReceived: UInt32? = nil, - receiveErrors: UInt32? = nil, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil, - neighborSnapshots: [NeighborSnapshotEntry]? = nil, - telemetryEntries: [TelemetrySnapshotEntry]? = nil - ) { - self.id = id - self.timestamp = timestamp - self.nodePublicKey = nodePublicKey - self.batteryMillivolts = batteryMillivolts - self.lastSNR = lastSNR - self.lastRSSI = lastRSSI - self.noiseFloor = noiseFloor - self.uptimeSeconds = uptimeSeconds - self.rxAirtimeSeconds = rxAirtimeSeconds - self.packetsSent = packetsSent - self.packetsReceived = packetsReceived - self.receiveErrors = receiveErrors - self.postedCount = postedCount - self.postPushCount = postPushCount - self.neighborSnapshots = neighborSnapshots - self.telemetryEntries = telemetryEntries - } + public let id: UUID + public let timestamp: Date + public let nodePublicKey: Data + public let batteryMillivolts: UInt16? + public let lastSNR: Double? + public let lastRSSI: Int16? + public let noiseFloor: Int16? + public let uptimeSeconds: UInt32? + public let rxAirtimeSeconds: UInt32? + public let packetsSent: UInt32? + public let packetsReceived: UInt32? + public let receiveErrors: UInt32? + + /// Per-type packet breakdown: direct vs flood packets sent/received and duplicates. + public let sentDirect: UInt32? + public let sentFlood: UInt32? + public let receivedDirect: UInt32? + public let receivedFlood: UInt32? + public let directDuplicates: UInt32? + public let floodDuplicates: UInt32? + + public let postedCount: UInt16? + public let postPushCount: UInt16? + public let neighborSnapshots: [NeighborSnapshotEntry]? + public let telemetryEntries: [TelemetrySnapshotEntry]? + + init(from model: NodeStatusSnapshot) { + id = model.id + timestamp = model.timestamp + nodePublicKey = model.nodePublicKey + batteryMillivolts = model.batteryMillivolts + lastSNR = model.lastSNR + lastRSSI = model.lastRSSI + noiseFloor = model.noiseFloor + uptimeSeconds = model.uptimeSeconds + rxAirtimeSeconds = model.rxAirtimeSeconds + packetsSent = model.packetsSent + packetsReceived = model.packetsReceived + receiveErrors = model.receiveErrors + sentDirect = model.sentDirect + sentFlood = model.sentFlood + receivedDirect = model.receivedDirect + receivedFlood = model.receivedFlood + directDuplicates = model.directDuplicates + floodDuplicates = model.floodDuplicates + postedCount = model.postedCount + postPushCount = model.postPushCount + neighborSnapshots = model.neighborSnapshots + telemetryEntries = model.telemetryEntries + } + + public init( + id: UUID = UUID(), + timestamp: Date = .now, + nodePublicKey: Data, + batteryMillivolts: UInt16? = nil, + lastSNR: Double? = nil, + lastRSSI: Int16? = nil, + noiseFloor: Int16? = nil, + uptimeSeconds: UInt32? = nil, + rxAirtimeSeconds: UInt32? = nil, + packetsSent: UInt32? = nil, + packetsReceived: UInt32? = nil, + receiveErrors: UInt32? = nil, + sentDirect: UInt32? = nil, + sentFlood: UInt32? = nil, + receivedDirect: UInt32? = nil, + receivedFlood: UInt32? = nil, + directDuplicates: UInt32? = nil, + floodDuplicates: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil, + neighborSnapshots: [NeighborSnapshotEntry]? = nil, + telemetryEntries: [TelemetrySnapshotEntry]? = nil + ) { + self.id = id + self.timestamp = timestamp + self.nodePublicKey = nodePublicKey + self.batteryMillivolts = batteryMillivolts + self.lastSNR = lastSNR + self.lastRSSI = lastRSSI + self.noiseFloor = noiseFloor + self.uptimeSeconds = uptimeSeconds + self.rxAirtimeSeconds = rxAirtimeSeconds + self.packetsSent = packetsSent + self.packetsReceived = packetsReceived + self.receiveErrors = receiveErrors + self.sentDirect = sentDirect + self.sentFlood = sentFlood + self.receivedDirect = receivedDirect + self.receivedFlood = receivedFlood + self.directDuplicates = directDuplicates + self.floodDuplicates = floodDuplicates + self.postedCount = postedCount + self.postPushCount = postPushCount + self.neighborSnapshots = neighborSnapshots + self.telemetryEntries = telemetryEntries + } } diff --git a/MC1Services/Sources/MC1Services/Models/NotificationLevel.swift b/MC1Services/Sources/MC1Services/Models/NotificationLevel.swift index 5f892cca..9e55e90c 100644 --- a/MC1Services/Sources/MC1Services/Models/NotificationLevel.swift +++ b/MC1Services/Sources/MC1Services/Models/NotificationLevel.swift @@ -2,40 +2,40 @@ import Foundation /// Notification level for conversations (channels, rooms, contacts) public enum NotificationLevel: Int, Sendable, Codable, CaseIterable { - case muted = 0 - case mentionsOnly = 1 - case all = 2 + case muted = 0 + case mentionsOnly = 1 + case all = 2 - /// SF Symbol name for this notification level - public var iconName: String { - switch self { - case .muted: "bell.slash" - case .mentionsOnly: "at" - case .all: "bell.fill" - } + /// SF Symbol name for this notification level + public var iconName: String { + switch self { + case .muted: "bell.slash" + case .mentionsOnly: "at" + case .all: "bell.fill" } + } - /// Developer-facing English label for logs; UI uses the app target's localized `NotificationLevel.localizedName`. - public var displayName: String { - switch self { - case .muted: "Muted" - case .mentionsOnly: "Mentions" - case .all: "All" - } + /// Developer-facing English label for logs; UI uses the app target's localized `NotificationLevel.localizedName`. + public var displayName: String { + switch self { + case .muted: "Muted" + case .mentionsOnly: "Mentions" + case .all: "All" } + } - /// Developer-facing English description; UI uses the app target's localized `NotificationLevel.localizedAccessibilityDescription`. - public var accessibilityDescription: String { - switch self { - case .muted: "Muted, no notifications" - case .mentionsOnly: "Mentions only" - case .all: "All notifications" - } + /// Developer-facing English description; UI uses the app target's localized `NotificationLevel.localizedAccessibilityDescription`. + public var accessibilityDescription: String { + switch self { + case .muted: "Muted, no notifications" + case .mentionsOnly: "Mentions only" + case .all: "All notifications" } + } - /// Levels available for channels (which support mention tracking) - public static let channelLevels: [NotificationLevel] = [.muted, .mentionsOnly, .all] + /// Levels available for channels (which support mention tracking) + public static let channelLevels: [NotificationLevel] = [.muted, .mentionsOnly, .all] - /// Levels available for rooms (no mention tracking infrastructure) - public static let roomLevels: [NotificationLevel] = [.muted, .all] + /// Levels available for rooms (no mention tracking infrastructure) + public static let roomLevels: [NotificationLevel] = [.muted, .all] } diff --git a/MC1Services/Sources/MC1Services/Models/NotificationPreferences.swift b/MC1Services/Sources/MC1Services/Models/NotificationPreferences.swift index 114c8508..fd8625ba 100644 --- a/MC1Services/Sources/MC1Services/Models/NotificationPreferences.swift +++ b/MC1Services/Sources/MC1Services/Models/NotificationPreferences.swift @@ -1,144 +1,144 @@ import Foundation /// Thread-safe notification preferences (read-only snapshot from UserDefaults) -struct NotificationPreferences: Sendable { - let contactMessagesEnabled: Bool - let channelMessagesEnabled: Bool - let roomMessagesEnabled: Bool - let newContactDiscoveredEnabled: Bool - let discoveryContactEnabled: Bool - let discoveryRepeaterEnabled: Bool - let discoveryRoomEnabled: Bool - let reactionNotificationsEnabled: Bool - let soundEnabled: Bool - let badgeEnabled: Bool - let lowBatteryEnabled: Bool - - init() { - let defaults = UserDefaults.standard - func enabled(_ key: AppStorageKey) -> Bool { - defaults.object(forKey: key.rawValue) as? Bool ?? AppStorageKey.defaultNotificationEnabled - } - self.contactMessagesEnabled = enabled(.notifyContactMessages) - self.channelMessagesEnabled = enabled(.notifyChannelMessages) - self.roomMessagesEnabled = enabled(.notifyRoomMessages) - self.newContactDiscoveredEnabled = enabled(.notifyNewContacts) - self.discoveryContactEnabled = enabled(.notifyNewContactsContact) - self.discoveryRepeaterEnabled = enabled(.notifyNewContactsRepeater) - self.discoveryRoomEnabled = enabled(.notifyNewContactsRoom) - self.reactionNotificationsEnabled = enabled(.notifyReactions) - self.soundEnabled = enabled(.notificationSoundEnabled) - self.badgeEnabled = enabled(.notificationBadgeEnabled) - self.lowBatteryEnabled = enabled(.notifyLowBattery) +struct NotificationPreferences { + let contactMessagesEnabled: Bool + let channelMessagesEnabled: Bool + let roomMessagesEnabled: Bool + let newContactDiscoveredEnabled: Bool + let discoveryContactEnabled: Bool + let discoveryRepeaterEnabled: Bool + let discoveryRoomEnabled: Bool + let reactionNotificationsEnabled: Bool + let soundEnabled: Bool + let badgeEnabled: Bool + let lowBatteryEnabled: Bool + + init() { + let defaults = UserDefaults.standard + func enabled(_ key: AppStorageKey) -> Bool { + defaults.object(forKey: key.rawValue) as? Bool ?? AppStorageKey.defaultNotificationEnabled } + contactMessagesEnabled = enabled(.notifyContactMessages) + channelMessagesEnabled = enabled(.notifyChannelMessages) + roomMessagesEnabled = enabled(.notifyRoomMessages) + newContactDiscoveredEnabled = enabled(.notifyNewContacts) + discoveryContactEnabled = enabled(.notifyNewContactsContact) + discoveryRepeaterEnabled = enabled(.notifyNewContactsRepeater) + discoveryRoomEnabled = enabled(.notifyNewContactsRoom) + reactionNotificationsEnabled = enabled(.notifyReactions) + soundEnabled = enabled(.notificationSoundEnabled) + badgeEnabled = enabled(.notificationBadgeEnabled) + lowBatteryEnabled = enabled(.notifyLowBattery) + } } /// Observable store for notification preferences (used by Settings UI for two-way binding) @Observable @MainActor public final class NotificationPreferencesStore { - private let defaults = UserDefaults.standard - - /// Observation anchor for the UserDefaults-backed computed properties: - /// `@Observable` only instruments stored properties, so every getter reads - /// this and every setter bumps it, making writes visible to SwiftUI. - private var revision = 0 - - private func isEnabled(_ key: AppStorageKey) -> Bool { - _ = revision - return defaults.object(forKey: key.rawValue) as? Bool ?? AppStorageKey.defaultNotificationEnabled - } - - private func setEnabled(_ newValue: Bool, for key: AppStorageKey) { - defaults.set(newValue, forKey: key.rawValue) - revision += 1 - } - - // MARK: - Message Notifications - - /// Enable notifications for contact (direct) messages - public var contactMessagesEnabled: Bool { - get { isEnabled(.notifyContactMessages) } - set { setEnabled(newValue, for: .notifyContactMessages) } - } - - /// Enable notifications for channel messages - public var channelMessagesEnabled: Bool { - get { isEnabled(.notifyChannelMessages) } - set { setEnabled(newValue, for: .notifyChannelMessages) } - } - - /// Enable notifications for room messages - public var roomMessagesEnabled: Bool { - get { isEnabled(.notifyRoomMessages) } - set { setEnabled(newValue, for: .notifyRoomMessages) } - } - - /// Enable notifications when new contacts are discovered - public var newContactDiscoveredEnabled: Bool { - get { isEnabled(.notifyNewContacts) } - set { - let wasEnabled = isEnabled(.notifyNewContacts) - setEnabled(newValue, for: .notifyNewContacts) - // Only auto-enable children on first activation (keys never written before) - if newValue && !wasEnabled { - let hasExistingChoices = defaults.object(forKey: AppStorageKey.notifyNewContactsContact.rawValue) != nil - || defaults.object(forKey: AppStorageKey.notifyNewContactsRepeater.rawValue) != nil - || defaults.object(forKey: AppStorageKey.notifyNewContactsRoom.rawValue) != nil - if !hasExistingChoices { - discoveryContactEnabled = true - discoveryRepeaterEnabled = true - discoveryRoomEnabled = true - } - } + private let defaults = UserDefaults.standard + + /// Observation anchor for the UserDefaults-backed computed properties: + /// `@Observable` only instruments stored properties, so every getter reads + /// this and every setter bumps it, making writes visible to SwiftUI. + private var revision = 0 + + private func isEnabled(_ key: AppStorageKey) -> Bool { + _ = revision + return defaults.object(forKey: key.rawValue) as? Bool ?? AppStorageKey.defaultNotificationEnabled + } + + private func setEnabled(_ newValue: Bool, for key: AppStorageKey) { + defaults.set(newValue, forKey: key.rawValue) + revision += 1 + } + + // MARK: - Message Notifications + + /// Enable notifications for contact (direct) messages + public var contactMessagesEnabled: Bool { + get { isEnabled(.notifyContactMessages) } + set { setEnabled(newValue, for: .notifyContactMessages) } + } + + /// Enable notifications for channel messages + public var channelMessagesEnabled: Bool { + get { isEnabled(.notifyChannelMessages) } + set { setEnabled(newValue, for: .notifyChannelMessages) } + } + + /// Enable notifications for room messages + public var roomMessagesEnabled: Bool { + get { isEnabled(.notifyRoomMessages) } + set { setEnabled(newValue, for: .notifyRoomMessages) } + } + + /// Enable notifications when new contacts are discovered + public var newContactDiscoveredEnabled: Bool { + get { isEnabled(.notifyNewContacts) } + set { + let wasEnabled = isEnabled(.notifyNewContacts) + setEnabled(newValue, for: .notifyNewContacts) + // Only auto-enable children on first activation (keys never written before) + if newValue, !wasEnabled { + let hasExistingChoices = defaults.object(forKey: AppStorageKey.notifyNewContactsContact.rawValue) != nil + || defaults.object(forKey: AppStorageKey.notifyNewContactsRepeater.rawValue) != nil + || defaults.object(forKey: AppStorageKey.notifyNewContactsRoom.rawValue) != nil + if !hasExistingChoices { + discoveryContactEnabled = true + discoveryRepeaterEnabled = true + discoveryRoomEnabled = true } + } } - - /// Enable discovery notifications for companion (chat) nodes - public var discoveryContactEnabled: Bool { - get { isEnabled(.notifyNewContactsContact) } - set { setEnabled(newValue, for: .notifyNewContactsContact) } - } - - /// Enable discovery notifications for repeater nodes - public var discoveryRepeaterEnabled: Bool { - get { isEnabled(.notifyNewContactsRepeater) } - set { setEnabled(newValue, for: .notifyNewContactsRepeater) } - } - - /// Enable discovery notifications for room nodes - public var discoveryRoomEnabled: Bool { - get { isEnabled(.notifyNewContactsRoom) } - set { setEnabled(newValue, for: .notifyNewContactsRoom) } - } - - /// Enable notifications when someone reacts to your messages - public var reactionNotificationsEnabled: Bool { - get { isEnabled(.notifyReactions) } - set { setEnabled(newValue, for: .notifyReactions) } - } - - // MARK: - Sound & Badge - - /// Enable notification sounds - public var soundEnabled: Bool { - get { isEnabled(.notificationSoundEnabled) } - set { setEnabled(newValue, for: .notificationSoundEnabled) } - } - - /// Enable badge count on app icon - public var badgeEnabled: Bool { - get { isEnabled(.notificationBadgeEnabled) } - set { setEnabled(newValue, for: .notificationBadgeEnabled) } - } - - // MARK: - Low Battery - - /// Enable low battery warning notifications - public var lowBatteryEnabled: Bool { - get { isEnabled(.notifyLowBattery) } - set { setEnabled(newValue, for: .notifyLowBattery) } - } - - public init() {} + } + + /// Enable discovery notifications for companion (chat) nodes + public var discoveryContactEnabled: Bool { + get { isEnabled(.notifyNewContactsContact) } + set { setEnabled(newValue, for: .notifyNewContactsContact) } + } + + /// Enable discovery notifications for repeater nodes + public var discoveryRepeaterEnabled: Bool { + get { isEnabled(.notifyNewContactsRepeater) } + set { setEnabled(newValue, for: .notifyNewContactsRepeater) } + } + + /// Enable discovery notifications for room nodes + public var discoveryRoomEnabled: Bool { + get { isEnabled(.notifyNewContactsRoom) } + set { setEnabled(newValue, for: .notifyNewContactsRoom) } + } + + /// Enable notifications when someone reacts to your messages + public var reactionNotificationsEnabled: Bool { + get { isEnabled(.notifyReactions) } + set { setEnabled(newValue, for: .notifyReactions) } + } + + // MARK: - Sound & Badge + + /// Enable notification sounds + public var soundEnabled: Bool { + get { isEnabled(.notificationSoundEnabled) } + set { setEnabled(newValue, for: .notificationSoundEnabled) } + } + + /// Enable badge count on app icon + public var badgeEnabled: Bool { + get { isEnabled(.notificationBadgeEnabled) } + set { setEnabled(newValue, for: .notificationBadgeEnabled) } + } + + // MARK: - Low Battery + + /// Enable low battery warning notifications + public var lowBatteryEnabled: Bool { + get { isEnabled(.notifyLowBattery) } + set { setEnabled(newValue, for: .notifyLowBattery) } + } + + public init() {} } diff --git a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift index c17278c9..6a8d1566 100644 --- a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift +++ b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift @@ -1,11 +1,11 @@ import Foundation /// Categories for OCV presets -enum OCVPresetCategory: Sendable { - /// Generic battery chemistry (Li-Ion, LiFePO4, etc.) - case batteryChemistry - /// Specific commercial device - case deviceSpecific +enum OCVPresetCategory { + /// Generic battery chemistry (Li-Ion, LiFePO4, etc.) + case batteryChemistry + /// Specific commercial device + case deviceSpecific } /// Battery OCV (Open Circuit Voltage) presets for accurate percentage calculation. @@ -13,137 +13,137 @@ enum OCVPresetCategory: Sendable { /// /// Reference: https://github.com/meshtastic/firmware public enum OCVPreset: String, CaseIterable, Codable, Sendable { - case liIon - case liFePO4 - case leadAcid - case alkaline - case niMH - case lto - case trackerT1000E - case heltecPocket5000 - case heltecPocket10000 - case seeedWioTracker - case seeedSolarNode - case r1Neo - case wisMeshTag - case lilyGoTBeam1W - case thinkNodeM6 - case custom + case liIon + case liFePO4 + case leadAcid + case alkaline + case niMH + case lto + case trackerT1000E + case heltecPocket5000 + case heltecPocket10000 + case seeedWioTracker + case seeedSolarNode + case r1Neo + case wisMeshTag + case lilyGoTBeam1W + case thinkNodeM6 + case custom - /// Valid range for user-entered OCV voltage values (millivolts). - /// Upper bound accommodates multi-cell series packs - public static let validMillivoltRange: ClosedRange = 1000...99_999 + /// Valid range for user-entered OCV voltage values (millivolts). + /// Upper bound accommodates multi-cell series packs + public static let validMillivoltRange: ClosedRange = 1000...99999 - /// The 11-point OCV array in millivolts (100% to 0% in 10% steps) - public var ocvArray: [Int] { - switch self { - case .liIon: - [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] - case .liFePO4: - [3400, 3350, 3320, 3290, 3270, 3260, 3250, 3230, 3200, 3120, 3000] - case .leadAcid: - [2120, 2090, 2070, 2050, 2030, 2010, 1990, 1980, 1970, 1960, 1950] - case .alkaline: - [1580, 1400, 1350, 1300, 1280, 1250, 1230, 1190, 1150, 1100, 1000] - case .niMH: - [1400, 1300, 1280, 1270, 1260, 1250, 1240, 1230, 1210, 1150, 1000] - case .lto: - [2770, 2650, 2540, 2420, 2300, 2180, 2060, 1940, 1800, 1680, 1550] - case .trackerT1000E: - [4190, 4042, 3957, 3885, 3820, 3776, 3746, 3725, 3696, 3644, 3100] - case .heltecPocket5000: - [4300, 4240, 4120, 4000, 3888, 3800, 3740, 3698, 3655, 3580, 3400] - case .heltecPocket10000: - [4100, 4060, 3960, 3840, 3729, 3625, 3550, 3500, 3420, 3345, 3100] - case .seeedWioTracker: - [4200, 3876, 3826, 3763, 3713, 3660, 3573, 3485, 3422, 3359, 3300] - case .seeedSolarNode: - [4200, 3986, 3922, 3812, 3734, 3645, 3527, 3420, 3281, 3087, 2786] - case .r1Neo: - [4120, 4020, 4000, 3940, 3870, 3820, 3750, 3630, 3550, 3450, 3100] - case .wisMeshTag: - [4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990] - case .lilyGoTBeam1W: - [7950, 7850, 7750, 7580, 7440, 7310, 7150, 7005, 6860, 6685, 6000] - case .thinkNodeM6: - [4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450] - case .custom: - OCVPreset.liIon.ocvArray // Fallback, actual custom values stored separately - } + /// The 11-point OCV array in millivolts (100% to 0% in 10% steps) + public var ocvArray: [Int] { + switch self { + case .liIon: + [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] + case .liFePO4: + [3400, 3350, 3320, 3290, 3270, 3260, 3250, 3230, 3200, 3120, 3000] + case .leadAcid: + [2120, 2090, 2070, 2050, 2030, 2010, 1990, 1980, 1970, 1960, 1950] + case .alkaline: + [1580, 1400, 1350, 1300, 1280, 1250, 1230, 1190, 1150, 1100, 1000] + case .niMH: + [1400, 1300, 1280, 1270, 1260, 1250, 1240, 1230, 1210, 1150, 1000] + case .lto: + [2770, 2650, 2540, 2420, 2300, 2180, 2060, 1940, 1800, 1680, 1550] + case .trackerT1000E: + [4190, 4042, 3957, 3885, 3820, 3776, 3746, 3725, 3696, 3644, 3100] + case .heltecPocket5000: + [4300, 4240, 4120, 4000, 3888, 3800, 3740, 3698, 3655, 3580, 3400] + case .heltecPocket10000: + [4100, 4060, 3960, 3840, 3729, 3625, 3550, 3500, 3420, 3345, 3100] + case .seeedWioTracker: + [4200, 3876, 3826, 3763, 3713, 3660, 3573, 3485, 3422, 3359, 3300] + case .seeedSolarNode: + [4200, 3986, 3922, 3812, 3734, 3645, 3527, 3420, 3281, 3087, 2786] + case .r1Neo: + [4120, 4020, 4000, 3940, 3870, 3820, 3750, 3630, 3550, 3450, 3100] + case .wisMeshTag: + [4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990] + case .lilyGoTBeam1W: + [7950, 7850, 7750, 7580, 7440, 7310, 7150, 7005, 6860, 6685, 6000] + case .thinkNodeM6: + [4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450] + case .custom: + OCVPreset.liIon.ocvArray // Fallback, actual custom values stored separately } + } - /// Human-readable display name - public var displayName: String { - switch self { - case .liIon: "Li-Ion (Default)" - case .liFePO4: "LiFePO4" - case .leadAcid: "Lead Acid" - case .alkaline: "Alkaline" - case .niMH: "NiMH" - case .lto: "LTO" - case .trackerT1000E: "Tracker T1000-E" - case .heltecPocket5000: "Heltec Pocket 5000" - case .heltecPocket10000: "Heltec Pocket 10000" - case .seeedWioTracker: "Seeed WIO Tracker" - case .seeedSolarNode: "Seeed Solar Node" - case .r1Neo: "R1 Neo" - case .wisMeshTag: "WisMesh Tag" - case .lilyGoTBeam1W: "LilyGo T-Beam 1W" - case .thinkNodeM6: "ThinkNode M6" - case .custom: "Custom" - } + /// Human-readable display name + public var displayName: String { + switch self { + case .liIon: "Li-Ion (Default)" + case .liFePO4: "LiFePO4" + case .leadAcid: "Lead Acid" + case .alkaline: "Alkaline" + case .niMH: "NiMH" + case .lto: "LTO" + case .trackerT1000E: "Tracker T1000-E" + case .heltecPocket5000: "Heltec Pocket 5000" + case .heltecPocket10000: "Heltec Pocket 10000" + case .seeedWioTracker: "Seeed WIO Tracker" + case .seeedSolarNode: "Seeed Solar Node" + case .r1Neo: "R1 Neo" + case .wisMeshTag: "WisMesh Tag" + case .lilyGoTBeam1W: "LilyGo T-Beam 1W" + case .thinkNodeM6: "ThinkNode M6" + case .custom: "Custom" } + } - /// The category of this preset - var category: OCVPresetCategory { - switch self { - case .liIon, .liFePO4, .leadAcid, .alkaline, .niMH, .lto: - .batteryChemistry - case .trackerT1000E, .heltecPocket5000, .heltecPocket10000, - .seeedWioTracker, .seeedSolarNode, .r1Neo, .wisMeshTag, - .lilyGoTBeam1W, .thinkNodeM6, .custom: - .deviceSpecific - } + /// The category of this preset + var category: OCVPresetCategory { + switch self { + case .liIon, .liFePO4, .leadAcid, .alkaline, .niMH, .lto: + .batteryChemistry + case .trackerT1000E, .heltecPocket5000, .heltecPocket10000, + .seeedWioTracker, .seeedSolarNode, .r1Neo, .wisMeshTag, + .lilyGoTBeam1W, .thinkNodeM6, .custom: + .deviceSpecific } + } - /// All presets except custom (for picker display) - public static var selectablePresets: [OCVPreset] { - allCases.filter { $0 != .custom } - } + /// All presets except custom (for picker display) + public static var selectablePresets: [OCVPreset] { + allCases.filter { $0 != .custom } + } - /// Battery chemistry presets only (excludes device-specific and custom) - public static var batteryChemistryPresets: [OCVPreset] { - allCases.filter { $0.category == .batteryChemistry } - } + /// Battery chemistry presets only (excludes device-specific and custom) + public static var batteryChemistryPresets: [OCVPreset] { + allCases.filter { $0.category == .batteryChemistry } + } - /// Presets available for remote node configuration. - /// Includes battery chemistry types plus select device-specific presets. - public static var nodePresets: [OCVPreset] { - var presets = batteryChemistryPresets - presets.append(.seeedSolarNode) - return presets - } + /// Presets available for remote node configuration. + /// Includes battery chemistry types plus select device-specific presets. + public static var nodePresets: [OCVPreset] { + var presets = batteryChemistryPresets + presets.append(.seeedSolarNode) + return presets + } - private static let logger = PersistentLogger(subsystem: "com.mc1", category: "OCVPreset") + private static let logger = PersistentLogger(subsystem: "com.mc1", category: "OCVPreset") - /// Returns the OCV preset for a known manufacturer name, or nil if no match. - /// - /// Manufacturer names must exactly match the strings returned by `getManufacturerName()` - /// in the MeshCore firmware's device variant headers (`{device_variant}.h`). - /// See: https://github.com/meshcore-dev/MeshCore - public static func preset(forManufacturer name: String) -> OCVPreset? { - let preset: OCVPreset? = switch name { - case "Seeed Tracker T1000-e", "Seeed Tracker T1000-E": .trackerT1000E - case "Seeed Wio Tracker L1": .seeedWioTracker - case "Seeed SenseCap Solar": .seeedSolarNode - case "RAK WisMesh Tag": .wisMeshTag - case "LilyGo T-Beam 1W": .lilyGoTBeam1W - case "Elecrow ThinkNode M6": .thinkNodeM6 - default: nil - } - if preset == nil && !name.isEmpty { - logger.debug("No OCV preset for manufacturer: \(name)") - } - return preset + /// Returns the OCV preset for a known manufacturer name, or nil if no match. + /// + /// Manufacturer names must exactly match the strings returned by `getManufacturerName()` + /// in the MeshCore firmware's device variant headers (`{device_variant}.h`). + /// See: https://github.com/meshcore-dev/MeshCore + public static func preset(forManufacturer name: String) -> OCVPreset? { + let preset: OCVPreset? = switch name { + case "Seeed Tracker T1000-e", "Seeed Tracker T1000-E": .trackerT1000E + case "Seeed Wio Tracker L1": .seeedWioTracker + case "Seeed SenseCap Solar": .seeedSolarNode + case "RAK WisMesh Tag": .wisMeshTag + case "LilyGo T-Beam 1W": .lilyGoTBeam1W + case "Elecrow ThinkNode M6": .thinkNodeM6 + default: nil + } + if preset == nil, !name.isEmpty { + logger.debug("No OCV preset for manufacturer: \(name)") } + return preset + } } diff --git a/MC1Services/Sources/MC1Services/Models/PendingSend.swift b/MC1Services/Sources/MC1Services/Models/PendingSend.swift index f7bc3b03..26836068 100644 --- a/MC1Services/Sources/MC1Services/Models/PendingSend.swift +++ b/MC1Services/Sources/MC1Services/Models/PendingSend.swift @@ -25,97 +25,97 @@ import SwiftData /// future maintainers don't re-evaluate it from scratch. @Model public final class PendingSend { - /// Compound indexes targeting the two hot fetch paths: - /// - `(radioID, sequence)` powers `fetchPendingSends(radioID:)`'s - /// ordered scan during hydrate-on-configure. - /// - `messageID` alone powers `deletePendingSendsForMessage(...)`, - /// which runs on every successful send and every non-cancellation - /// error — i.e., the highest-frequency read path on this table. - /// Radio is intentionally not part of this index because the - /// drain-time delete must succeed regardless of which radio is - /// currently in scope. - /// The uniqueness on `id` already produces an implicit index for the - /// `deletePendingSend(id:)` path, so no third compound is needed. - #Index([\.radioID, \.sequence], [\.messageID]) + // Compound indexes targeting the two hot fetch paths: + // - `(radioID, sequence)` powers `fetchPendingSends(radioID:)`'s + // ordered scan during hydrate-on-configure. + // - `messageID` alone powers `deletePendingSendsForMessage(...)`, + // which runs on every successful send and every non-cancellation + // error — i.e., the highest-frequency read path on this table. + // Radio is intentionally not part of this index because the + // drain-time delete must succeed regardless of which radio is + // currently in scope. + // The uniqueness on `id` already produces an implicit index for the + // `deletePendingSend(id:)` path, so no third compound is needed. + #Index([\.radioID, \.sequence], [\.messageID]) - @Attribute(.unique) - public var id: UUID + @Attribute(.unique) + public var id: UUID - public var radioID: UUID - public var messageID: UUID + public var radioID: UUID + public var messageID: UUID - /// Discriminator: 0 = DM, 1 = channel. See `PendingSendKind`. - public var kindRawValue: Int + /// Discriminator: 0 = DM, 1 = channel. See `PendingSendKind`. + public var kindRawValue: Int - public var contactID: UUID? - public var channelIndex: UInt8? - public var isResend: Bool + public var contactID: UUID? + public var channelIndex: UInt8? + public var isResend: Bool - public var messageText: String - public var messageTimestamp: UInt32 - public var localNodeName: String? + public var messageText: String + public var messageTimestamp: UInt32 + public var localNodeName: String? - public var sequence: Int - public var enqueuedAt: Date + public var sequence: Int + public var enqueuedAt: Date - /// Number of drain attempts the send queue has progressed past the - /// `hasPendingSend` gate for this row. Bumped at the top of each drain - /// attempt, before any wire-affecting work. Three distinguishable states: - /// - /// - `nil` — pre-migration row, lightweight-migrated from a schema - /// version without this column. Drain history is ambiguous, - /// so `purgeLegacyAttemptCountRows` in - /// `PersistenceStore.warmUp()` deletes these on connect. - /// - `0` — row that has been persisted but has not yet progressed - /// past the top-of-drain bump (either fresh enqueue in - /// flight, or process death between persist and bump). - /// The recipient cannot have seen this packet, so the - /// next drain stamps a fresh wire timestamp - /// (`preserveTimestamp = false`). - /// - positive — at least one drain attempt has run. A wire send may - /// already have happened, so auto-retries must preserve the - /// original wire timestamp via `postBumpCount > 1`. - /// - /// PendingSendDTO is `Sendable, Hashable, Identifiable` only — intentionally - /// not Codable, intentionally excluded from AppBackupEnvelope — so there - /// is no on-disk wire format to defend against. The only "legacy" data is - /// live SwiftData rows persisted by a build that predates this field, - /// which lightweight migration maps to `nil` and warmUp purges on connect. - public var attemptCount: Int? + /// Number of drain attempts the send queue has progressed past the + /// `hasPendingSend` gate for this row. Bumped at the top of each drain + /// attempt, before any wire-affecting work. Three distinguishable states: + /// + /// - `nil` — pre-migration row, lightweight-migrated from a schema + /// version without this column. Drain history is ambiguous, + /// so `purgeLegacyAttemptCountRows` in + /// `PersistenceStore.warmUp()` deletes these on connect. + /// - `0` — row that has been persisted but has not yet progressed + /// past the top-of-drain bump (either fresh enqueue in + /// flight, or process death between persist and bump). + /// The recipient cannot have seen this packet, so the + /// next drain stamps a fresh wire timestamp + /// (`preserveTimestamp = false`). + /// - positive — at least one drain attempt has run. A wire send may + /// already have happened, so auto-retries must preserve the + /// original wire timestamp via `postBumpCount > 1`. + /// + /// PendingSendDTO is `Sendable, Hashable, Identifiable` only — intentionally + /// not Codable, intentionally excluded from AppBackupEnvelope — so there + /// is no on-disk wire format to defend against. The only "legacy" data is + /// live SwiftData rows persisted by a build that predates this field, + /// which lightweight migration maps to `nil` and warmUp purges on connect. + public var attemptCount: Int? - public init( - id: UUID, - radioID: UUID, - messageID: UUID, - kindRawValue: Int, - contactID: UUID?, - channelIndex: UInt8?, - isResend: Bool, - messageText: String, - messageTimestamp: UInt32, - localNodeName: String?, - sequence: Int, - enqueuedAt: Date, - attemptCount: Int? = nil - ) { - self.id = id - self.radioID = radioID - self.messageID = messageID - self.kindRawValue = kindRawValue - self.contactID = contactID - self.channelIndex = channelIndex - self.isResend = isResend - self.messageText = messageText - self.messageTimestamp = messageTimestamp - self.localNodeName = localNodeName - self.sequence = sequence - self.enqueuedAt = enqueuedAt - self.attemptCount = attemptCount - } + public init( + id: UUID, + radioID: UUID, + messageID: UUID, + kindRawValue: Int, + contactID: UUID?, + channelIndex: UInt8?, + isResend: Bool, + messageText: String, + messageTimestamp: UInt32, + localNodeName: String?, + sequence: Int, + enqueuedAt: Date, + attemptCount: Int? = nil + ) { + self.id = id + self.radioID = radioID + self.messageID = messageID + self.kindRawValue = kindRawValue + self.contactID = contactID + self.channelIndex = channelIndex + self.isResend = isResend + self.messageText = messageText + self.messageTimestamp = messageTimestamp + self.localNodeName = localNodeName + self.sequence = sequence + self.enqueuedAt = enqueuedAt + self.attemptCount = attemptCount + } - var kind: PendingSendKind { - PendingSendKind(rawValue: kindRawValue) ?? .dm - } + var kind: PendingSendKind { + PendingSendKind(rawValue: kindRawValue) ?? .dm + } } /// Discriminator for which `SendQueue` a row drains into. @@ -124,9 +124,9 @@ public final class PendingSend { /// even though `PendingSendDTO` is excluded from `AppBackupEnvelope`, the /// discriminator IS persisted via `kindRawValue` and a future case rename /// must not silently change the on-disk format. -enum PendingSendKind: Int, Sendable { - case dm = 0 - case channel = 1 +enum PendingSendKind: Int { + case dm = 0 + case channel = 1 } /// Sendable DTO mirror of `PendingSend`. Raw `@Model` instances never leave @@ -138,89 +138,89 @@ enum PendingSendKind: Int, Sendable { /// delivered on the radio, so the DTO does not flow through backup- /// envelope encoding paths. public struct PendingSendDTO: Sendable, Hashable, Identifiable { - public let id: UUID - public let radioID: UUID - public let messageID: UUID - let kind: PendingSendKind - public let contactID: UUID? - public let channelIndex: UInt8? - public let isResend: Bool - public let messageText: String - public let messageTimestamp: UInt32 - public let localNodeName: String? - public let sequence: Int - public let enqueuedAt: Date - /// See `PendingSend.attemptCount` for the three-state semantics. The DTO - /// memberwise init defaults this to `0` (current-build sentinel) so - /// new envelopes enter disk distinguishable from pre-migration rows that - /// lightweight-migrate to `nil`. - public let attemptCount: Int? + public let id: UUID + public let radioID: UUID + public let messageID: UUID + let kind: PendingSendKind + public let contactID: UUID? + public let channelIndex: UInt8? + public let isResend: Bool + public let messageText: String + public let messageTimestamp: UInt32 + public let localNodeName: String? + public let sequence: Int + public let enqueuedAt: Date + /// See `PendingSend.attemptCount` for the three-state semantics. The DTO + /// memberwise init defaults this to `0` (current-build sentinel) so + /// new envelopes enter disk distinguishable from pre-migration rows that + /// lightweight-migrate to `nil`. + public let attemptCount: Int? - init( - id: UUID, - radioID: UUID, - messageID: UUID, - kind: PendingSendKind, - contactID: UUID?, - channelIndex: UInt8?, - isResend: Bool, - messageText: String, - messageTimestamp: UInt32, - localNodeName: String?, - sequence: Int, - enqueuedAt: Date, - attemptCount: Int? = 0 - ) { - self.id = id - self.radioID = radioID - self.messageID = messageID - self.kind = kind - self.contactID = contactID - self.channelIndex = channelIndex - self.isResend = isResend - self.messageText = messageText - self.messageTimestamp = messageTimestamp - self.localNodeName = localNodeName - self.sequence = sequence - self.enqueuedAt = enqueuedAt - self.attemptCount = attemptCount - } + init( + id: UUID, + radioID: UUID, + messageID: UUID, + kind: PendingSendKind, + contactID: UUID?, + channelIndex: UInt8?, + isResend: Bool, + messageText: String, + messageTimestamp: UInt32, + localNodeName: String?, + sequence: Int, + enqueuedAt: Date, + attemptCount: Int? = 0 + ) { + self.id = id + self.radioID = radioID + self.messageID = messageID + self.kind = kind + self.contactID = contactID + self.channelIndex = channelIndex + self.isResend = isResend + self.messageText = messageText + self.messageTimestamp = messageTimestamp + self.localNodeName = localNodeName + self.sequence = sequence + self.enqueuedAt = enqueuedAt + self.attemptCount = attemptCount + } } public extension PendingSend { - convenience init(dto: PendingSendDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - messageID: dto.messageID, - kindRawValue: dto.kind.rawValue, - contactID: dto.contactID, - channelIndex: dto.channelIndex, - isResend: dto.isResend, - messageText: dto.messageText, - messageTimestamp: dto.messageTimestamp, - localNodeName: dto.localNodeName, - sequence: dto.sequence, - enqueuedAt: dto.enqueuedAt, - attemptCount: dto.attemptCount - ) - } + convenience init(dto: PendingSendDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + messageID: dto.messageID, + kindRawValue: dto.kind.rawValue, + contactID: dto.contactID, + channelIndex: dto.channelIndex, + isResend: dto.isResend, + messageText: dto.messageText, + messageTimestamp: dto.messageTimestamp, + localNodeName: dto.localNodeName, + sequence: dto.sequence, + enqueuedAt: dto.enqueuedAt, + attemptCount: dto.attemptCount + ) + } - func toDTO() -> PendingSendDTO { - PendingSendDTO( - id: id, - radioID: radioID, - messageID: messageID, - kind: kind, - contactID: contactID, - channelIndex: channelIndex, - isResend: isResend, - messageText: messageText, - messageTimestamp: messageTimestamp, - localNodeName: localNodeName, - sequence: sequence, - enqueuedAt: enqueuedAt, - attemptCount: attemptCount - ) - } + func toDTO() -> PendingSendDTO { + PendingSendDTO( + id: id, + radioID: radioID, + messageID: messageID, + kind: kind, + contactID: contactID, + channelIndex: channelIndex, + isResend: isResend, + messageText: messageText, + messageTimestamp: messageTimestamp, + localNodeName: localNodeName, + sequence: sequence, + enqueuedAt: enqueuedAt, + attemptCount: attemptCount + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/PendingSendEnvelope.swift b/MC1Services/Sources/MC1Services/Models/PendingSendEnvelope.swift index b9378d8c..c9035cee 100644 --- a/MC1Services/Sources/MC1Services/Models/PendingSendEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/PendingSendEnvelope.swift @@ -5,23 +5,22 @@ import Foundation /// not match the requested envelope type — guards against mis-routing a /// channel row into the DM queue or vice versa. public extension PendingSendDTO { + func directMessageEnvelope() -> DirectMessageEnvelope? { + guard kind == .dm, let contactID else { return nil } + return DirectMessageEnvelope(messageID: messageID, contactID: contactID, isResend: isResend) + } - func directMessageEnvelope() -> DirectMessageEnvelope? { - guard kind == .dm, let contactID else { return nil } - return DirectMessageEnvelope(messageID: messageID, contactID: contactID, isResend: isResend) - } - - func channelMessageEnvelope() -> ChannelMessageEnvelope? { - guard kind == .channel, let channelIndex else { return nil } - return ChannelMessageEnvelope( - messageID: messageID, - channelIndex: channelIndex, - isResend: isResend, - messageText: messageText, - messageTimestamp: messageTimestamp, - localNodeName: localNodeName - ) - } + func channelMessageEnvelope() -> ChannelMessageEnvelope? { + guard kind == .channel, let channelIndex else { return nil } + return ChannelMessageEnvelope( + messageID: messageID, + channelIndex: channelIndex, + isResend: isResend, + messageText: messageText, + messageTimestamp: messageTimestamp, + localNodeName: localNodeName + ) + } } /// Construct a `PendingSendDTO` from a runtime envelope. Used by the @@ -34,65 +33,64 @@ public extension PendingSendDTO { /// to pin sequence values should call the full positional /// `PendingSendDTO.init(...)` directly. public extension PendingSendDTO { + init( + id: UUID = UUID(), + envelope: DirectMessageEnvelope, + radioID: UUID, + enqueuedAt: Date = Date() + ) { + // DM rows persist `isResend` so a process restart between enqueue + // and drain routes the row to the same send method it would have + // hit pre-restart (sendPendingDirectMessage vs. resendDirectMessage). + // `messageTimestamp` stays sentinel: the DM drain keys preserve- + // timestamp behaviour off `PendingSend.attemptCount` via the + // top-of-drain bump (`postBumpCount > 1` returns true on the 2nd+ + // drain attempt), so the wire timestamp does not round-trip + // through the DTO. Channel rows persist both explicitly because + // reaction indexing hashes off the post-send wire timestamp. + self.init( + id: id, + radioID: radioID, + messageID: envelope.messageID, + kind: .dm, + contactID: envelope.contactID, + channelIndex: nil, + isResend: envelope.isResend, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: 0, + enqueuedAt: enqueuedAt + ) + } - init( - id: UUID = UUID(), - envelope: DirectMessageEnvelope, - radioID: UUID, - enqueuedAt: Date = Date() - ) { - // DM rows persist `isResend` so a process restart between enqueue - // and drain routes the row to the same send method it would have - // hit pre-restart (sendPendingDirectMessage vs. resendDirectMessage). - // `messageTimestamp` stays sentinel: the DM drain keys preserve- - // timestamp behaviour off `PendingSend.attemptCount` via the - // top-of-drain bump (`postBumpCount > 1` returns true on the 2nd+ - // drain attempt), so the wire timestamp does not round-trip - // through the DTO. Channel rows persist both explicitly because - // reaction indexing hashes off the post-send wire timestamp. - self.init( - id: id, - radioID: radioID, - messageID: envelope.messageID, - kind: .dm, - contactID: envelope.contactID, - channelIndex: nil, - isResend: envelope.isResend, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: 0, - enqueuedAt: enqueuedAt - ) - } - - init( - id: UUID = UUID(), - envelope: ChannelMessageEnvelope, - radioID: UUID, - enqueuedAt: Date = Date() - ) { - // ChannelMessageEnvelope DTO factory captures `envelope.messageTimestamp` - // verbatim. For isResend=true rows the stored timestamp is overwritten - // before use — `resendChannelMessage(preserveTimestamp: postBumpCount > 1)` - // stamps a fresh wire timestamp when this is the first drain attempt - // (`postBumpCount == 1`), and reaction indexing keys off the - // post-resend value, not the persisted one. Kept non-zero on resend - // rows for symmetry with original-send rows; readers must not depend - // on the value for retry-path semantics. - self.init( - id: id, - radioID: radioID, - messageID: envelope.messageID, - kind: .channel, - contactID: nil, - channelIndex: envelope.channelIndex, - isResend: envelope.isResend, - messageText: envelope.messageText, - messageTimestamp: envelope.messageTimestamp, - localNodeName: envelope.localNodeName, - sequence: 0, - enqueuedAt: enqueuedAt - ) - } + init( + id: UUID = UUID(), + envelope: ChannelMessageEnvelope, + radioID: UUID, + enqueuedAt: Date = Date() + ) { + // ChannelMessageEnvelope DTO factory captures `envelope.messageTimestamp` + // verbatim. For isResend=true rows the stored timestamp is overwritten + // before use — `resendChannelMessage(preserveTimestamp: postBumpCount > 1)` + // stamps a fresh wire timestamp when this is the first drain attempt + // (`postBumpCount == 1`), and reaction indexing keys off the + // post-resend value, not the persisted one. Kept non-zero on resend + // rows for symmetry with original-send rows; readers must not depend + // on the value for retry-path semantics. + self.init( + id: id, + radioID: radioID, + messageID: envelope.messageID, + kind: .channel, + contactID: nil, + channelIndex: envelope.channelIndex, + isResend: envelope.isResend, + messageText: envelope.messageText, + messageTimestamp: envelope.messageTimestamp, + localNodeName: envelope.localNodeName, + sequence: 0, + enqueuedAt: enqueuedAt + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/ProtocolLimits.swift b/MC1Services/Sources/MC1Services/Models/ProtocolLimits.swift index 46164c90..a1855769 100644 --- a/MC1Services/Sources/MC1Services/Models/ProtocolLimits.swift +++ b/MC1Services/Sources/MC1Services/Models/ProtocolLimits.swift @@ -2,41 +2,41 @@ import Foundation /// Protocol size limits and constants public enum ProtocolLimits { - public static let publicKeySize = 32 - public static let advertTimestampSize = 4 - public static let privateKeySize = 64 - public static let maxPathSize = 64 - public static let maxFrameSize = 172 - public static let signatureSize = 64 - public static let maxPacketPayload = 184 - public static let cipherMacSize = 2 - public static let maxHashSize = 8 - public static let cipherKeySize = 16 - public static let maxContacts = 100 - public static let offlineQueueSize = 16 - public static let maxNameLength = 32 - public static let channelSecretSize = 16 - public static let maxMessageLength = 160 + public static let publicKeySize = 32 + public static let advertTimestampSize = 4 + public static let privateKeySize = 64 + public static let maxPathSize = 64 + public static let maxFrameSize = 172 + public static let signatureSize = 64 + public static let maxPacketPayload = 184 + public static let cipherMacSize = 2 + public static let maxHashSize = 8 + public static let cipherKeySize = 16 + public static let maxContacts = 100 + public static let offlineQueueSize = 16 + public static let maxNameLength = 32 + public static let channelSecretSize = 16 + public static let maxMessageLength = 160 - /// Maximum usable bytes for names (firmware char[32] minus null terminator) - public static let maxUsableNameBytes = 31 + /// Maximum usable bytes for names (firmware char[32] minus null terminator) + public static let maxUsableNameBytes = 31 - /// Maximum UTF-8 bytes for the default flood scope name field. The firmware field is 31 - /// bytes with zero padding and accepts `0 < strlen(name) < 31`, so the effective cap is - /// 30. Longer inputs must be truncated before send; otherwise the stored display and the - /// full-name-derived scope key would disagree. - public static let maxDefaultFloodScopeNameBytes = 30 + /// Maximum UTF-8 bytes for the default flood scope name field. The firmware field is 31 + /// bytes with zero padding and accepts `0 < strlen(name) < 31`, so the effective cap is + /// 30. Longer inputs must be truncated before send; otherwise the stored display and the + /// full-name-derived scope key would disagree. + public static let maxDefaultFloodScopeNameBytes = 30 - /// Maximum UTF-8 bytes for direct message text. App-enforced at 150, which - /// sits under the firmware's binding `MAX_TEXT_LEN` of 160 so the message - /// always fits the wire buffer. - public static let maxDirectMessageLength = 150 + /// Maximum UTF-8 bytes for direct message text. App-enforced at 150, which + /// sits under the firmware's binding `MAX_TEXT_LEN` of 160 so the message + /// always fits the wire buffer. + public static let maxDirectMessageLength = 150 - /// Total limit for channel messages including "NodeName: " prefix - public static let maxChannelMessageTotalLength = 147 + /// Total limit for channel messages including "NodeName: " prefix + public static let maxChannelMessageTotalLength = 147 - /// Max user text bytes for channel messages, accounting for node name prefix - public static func maxChannelMessageLength(nodeNameByteCount: Int) -> Int { - max(0, maxChannelMessageTotalLength - nodeNameByteCount - 2) - } + /// Max user text bytes for channel messages, accounting for node name prefix + public static func maxChannelMessageLength(nodeNameByteCount: Int) -> Int { + max(0, maxChannelMessageTotalLength - nodeNameByteCount - 2) + } } diff --git a/MC1Services/Sources/MC1Services/Models/ProtocolTypes.swift b/MC1Services/Sources/MC1Services/Models/ProtocolTypes.swift index 75d42489..085e2957 100644 --- a/MC1Services/Sources/MC1Services/Models/ProtocolTypes.swift +++ b/MC1Services/Sources/MC1Services/Models/ProtocolTypes.swift @@ -1,10 +1,10 @@ -/// Semantic protocol enums that add iOS-specific value over MeshCore -/// (TextType, RemoteNodeRole, RoomPermissionLevel) plus ContactType re-exports. -/// -/// Types that are direct duplicates of MeshCore types have been removed. -/// Use MeshCore types directly: SelfInfo, DeviceCapabilities, ChannelInfo, -/// ContactMessage, ChannelMessage, MessageSentInfo, BatteryInfo, -/// ContactType, ContactFlags. +// Semantic protocol enums that add iOS-specific value over MeshCore +// (TextType, RemoteNodeRole, RoomPermissionLevel) plus ContactType re-exports. +// +// Types that are direct duplicates of MeshCore types have been removed. +// Use MeshCore types directly: SelfInfo, DeviceCapabilities, ChannelInfo, +// ContactMessage, ChannelMessage, MessageSentInfo, BatteryInfo, +// ContactType, ContactFlags. import Foundation import MeshCore @@ -18,62 +18,67 @@ typealias ContactFlags = MeshCore.ContactFlags // MARK: - Contact Type UI Extensions -extension ContactType { - /// Developer-facing English name for logs; UI uses the app target's localized `ContactType.localizedName`. - public var displayName: String { - switch self { - case .chat: return "Contact" - case .repeater: return "Repeater" - case .room: return "Room" - } +public extension ContactType { + /// Developer-facing English name for logs; UI uses the app target's localized `ContactType.localizedName`. + var displayName: String { + switch self { + case .chat: "Contact" + case .repeater: "Repeater" + case .room: "Room" } + } } // MARK: - Text Types /// Message text type encoding public enum TextType: UInt8, Sendable, Codable { - case plain = 0x00 - case cliData = 0x01 - case signedPlain = 0x02 + case plain = 0x00 + case cliData = 0x01 + case signedPlain = 0x02 } // MARK: - Remote Node Types /// Discriminates between remote node types for role-specific handling public enum RemoteNodeRole: UInt8, Sendable, Codable { - case repeater = 0x02 - case roomServer = 0x03 + case repeater = 0x02 + case roomServer = 0x03 - /// Initialize from ContactType - public init?(contactType: ContactType) { - switch contactType { - case .repeater: self = .repeater - case .room: self = .roomServer - case .chat: return nil - } + /// Initialize from ContactType + public init?(contactType: ContactType) { + switch contactType { + case .repeater: self = .repeater + case .room: self = .roomServer + case .chat: return nil } + } } /// Permission levels for room server access public enum RoomPermissionLevel: UInt8, Sendable, Comparable, Codable { - case guest = 0x00 - case readWrite = 0x01 - case admin = 0x02 + case guest = 0x00 + case readWrite = 0x01 + case admin = 0x02 - public var canPost: Bool { self >= .readWrite } - public var isAdmin: Bool { self == .admin } + public var canPost: Bool { + self >= .readWrite + } - /// Developer-facing English name for logs; UI uses the app target's localized `RoomPermissionLevel.localizedName`. - public var displayName: String { - switch self { - case .guest: return "Guest" - case .readWrite: return "Member" - case .admin: return "Admin" - } - } + public var isAdmin: Bool { + self == .admin + } - public static func < (lhs: RoomPermissionLevel, rhs: RoomPermissionLevel) -> Bool { - lhs.rawValue < rhs.rawValue + /// Developer-facing English name for logs; UI uses the app target's localized `RoomPermissionLevel.localizedName`. + public var displayName: String { + switch self { + case .guest: "Guest" + case .readWrite: "Member" + case .admin: "Admin" } + } + + public static func < (lhs: RoomPermissionLevel, rhs: RoomPermissionLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } } diff --git a/MC1Services/Sources/MC1Services/Models/Reaction.swift b/MC1Services/Sources/MC1Services/Models/Reaction.swift index c1000307..fed90262 100644 --- a/MC1Services/Sources/MC1Services/Models/Reaction.swift +++ b/MC1Services/Sources/MC1Services/Models/Reaction.swift @@ -4,135 +4,134 @@ import SwiftData /// Represents an emoji reaction to a channel or DM message. @Model public final class Reaction { - #Index( - [\.messageID], - [\.radioID, \.contactID, \.messageID], - [\.messageID, \.senderName, \.emoji] + #Index( + [\.messageID], + [\.radioID, \.contactID, \.messageID], + [\.messageID, \.senderName, \.emoji] + ) + + @Attribute(.unique) + public var id: UUID + + /// Target message UUID + public var messageID: UUID + + /// The emoji used + public var emoji: String + + /// Sender's node name + public var senderName: String + + /// Message hash from wire format (8 hex chars) + public var messageHash: String + + /// Original raw text for fallback display + public var rawText: String + + /// When we received this reaction + public var receivedAt: Date + + /// Channel index where received (nil for DM reactions) + public var channelIndex: UInt8? + + /// Contact ID for DM reactions (nil for channel reactions) + public var contactID: UUID? + + /// Device ID this belongs to + @Attribute(originalName: "deviceID") + public var radioID: UUID + + public init( + id: UUID = UUID(), + messageID: UUID, + emoji: String, + senderName: String, + messageHash: String, + rawText: String, + receivedAt: Date = Date(), + channelIndex: UInt8? = nil, + contactID: UUID? = nil, + radioID: UUID + ) { + self.id = id + self.messageID = messageID + self.emoji = emoji + self.senderName = senderName + self.messageHash = messageHash + self.rawText = rawText + self.receivedAt = receivedAt + self.channelIndex = channelIndex + self.contactID = contactID + self.radioID = radioID + } + + /// Builds a model instance directly from a DTO. + public convenience init(dto: ReactionDTO) { + self.init( + id: dto.id, + messageID: dto.messageID, + emoji: dto.emoji, + senderName: dto.senderName, + messageHash: dto.messageHash, + rawText: dto.rawText, + receivedAt: dto.receivedAt, + channelIndex: dto.channelIndex, + contactID: dto.contactID, + radioID: dto.radioID ) - - @Attribute(.unique) - public var id: UUID - - /// Target message UUID - public var messageID: UUID - - /// The emoji used - public var emoji: String - - /// Sender's node name - public var senderName: String - - /// Message hash from wire format (8 hex chars) - public var messageHash: String - - /// Original raw text for fallback display - public var rawText: String - - /// When we received this reaction - public var receivedAt: Date - - /// Channel index where received (nil for DM reactions) - public var channelIndex: UInt8? - - /// Contact ID for DM reactions (nil for channel reactions) - public var contactID: UUID? - - /// Device ID this belongs to - @Attribute(originalName: "deviceID") - public var radioID: UUID - - public init( - id: UUID = UUID(), - messageID: UUID, - emoji: String, - senderName: String, - messageHash: String, - rawText: String, - receivedAt: Date = Date(), - channelIndex: UInt8? = nil, - contactID: UUID? = nil, - radioID: UUID - ) { - self.id = id - self.messageID = messageID - self.emoji = emoji - self.senderName = senderName - self.messageHash = messageHash - self.rawText = rawText - self.receivedAt = receivedAt - self.channelIndex = channelIndex - self.contactID = contactID - self.radioID = radioID - } - - /// Builds a model instance directly from a DTO. - public convenience init(dto: ReactionDTO) { - self.init( - id: dto.id, - messageID: dto.messageID, - emoji: dto.emoji, - senderName: dto.senderName, - messageHash: dto.messageHash, - rawText: dto.rawText, - receivedAt: dto.receivedAt, - channelIndex: dto.channelIndex, - contactID: dto.contactID, - radioID: dto.radioID - ) - } + } } // MARK: - Sendable DTO public struct ReactionDTO: Sendable, Equatable, Hashable, Identifiable, Codable { - public let id: UUID - public var messageID: UUID - public let emoji: String - public let senderName: String - public let messageHash: String - public let rawText: String - public let receivedAt: Date - /// Mutable so backup import can rewrite it in lockstep with the parent channel - /// message when a channel relocates to a different local slot. - public var channelIndex: UInt8? - public var contactID: UUID? - public var radioID: UUID - - public init(from reaction: Reaction) { - self.id = reaction.id - self.messageID = reaction.messageID - self.emoji = reaction.emoji - self.senderName = reaction.senderName - self.messageHash = reaction.messageHash - self.rawText = reaction.rawText - self.receivedAt = reaction.receivedAt - self.channelIndex = reaction.channelIndex - self.contactID = reaction.contactID - self.radioID = reaction.radioID - } - - public init( - id: UUID = UUID(), - messageID: UUID, - emoji: String, - senderName: String, - messageHash: String, - rawText: String, - receivedAt: Date = Date(), - channelIndex: UInt8? = nil, - contactID: UUID? = nil, - radioID: UUID - ) { - self.id = id - self.messageID = messageID - self.emoji = emoji - self.senderName = senderName - self.messageHash = messageHash - self.rawText = rawText - self.receivedAt = receivedAt - self.channelIndex = channelIndex - self.contactID = contactID - self.radioID = radioID - } - + public let id: UUID + public var messageID: UUID + public let emoji: String + public let senderName: String + public let messageHash: String + public let rawText: String + public let receivedAt: Date + /// Mutable so backup import can rewrite it in lockstep with the parent channel + /// message when a channel relocates to a different local slot. + public var channelIndex: UInt8? + public var contactID: UUID? + public var radioID: UUID + + public init(from reaction: Reaction) { + id = reaction.id + messageID = reaction.messageID + emoji = reaction.emoji + senderName = reaction.senderName + messageHash = reaction.messageHash + rawText = reaction.rawText + receivedAt = reaction.receivedAt + channelIndex = reaction.channelIndex + contactID = reaction.contactID + radioID = reaction.radioID + } + + public init( + id: UUID = UUID(), + messageID: UUID, + emoji: String, + senderName: String, + messageHash: String, + rawText: String, + receivedAt: Date = Date(), + channelIndex: UInt8? = nil, + contactID: UUID? = nil, + radioID: UUID + ) { + self.id = id + self.messageID = messageID + self.emoji = emoji + self.senderName = senderName + self.messageHash = messageHash + self.rawText = rawText + self.receivedAt = receivedAt + self.channelIndex = channelIndex + self.contactID = contactID + self.radioID = radioID + } } diff --git a/MC1Services/Sources/MC1Services/Models/RegionSelection.swift b/MC1Services/Sources/MC1Services/Models/RegionSelection.swift index d8942a2e..84ec21f9 100644 --- a/MC1Services/Sources/MC1Services/Models/RegionSelection.swift +++ b/MC1Services/Sources/MC1Services/Models/RegionSelection.swift @@ -3,40 +3,38 @@ import Foundation /// User's geographic region, used to recommend community-curated radio presets. /// Distinct from the firmware-mesh-region concept in `RegionDiscoveryService`. public struct RegionSelection: Codable, Sendable, Equatable { - public let countryCode: String // ISO-3166 α-2 (e.g. "US") - public let administrativeAreaCode: String? // ISO 3166-2 (e.g. "US-CA") - public let countyKey: String? // normalized US county key (e.g. "los angeles") - public let source: Source + public let countryCode: String // ISO-3166 α-2 (e.g. "US") + public let administrativeAreaCode: String? // ISO 3166-2 (e.g. "US-CA") + public let countyKey: String? // normalized US county key (e.g. "los angeles") + public let source: Source - public enum Source: String, Codable, Sendable { - // swiftlint:disable redundant_string_enum_value - // Raw values pinned per backup contract: a future case rename must not silently change the on-disk format. - case location = "location" - case manual = "manual" - // swiftlint:enable redundant_string_enum_value - } + public enum Source: String, Codable, Sendable { + // Raw values pinned per backup contract: a future case rename must not silently change the on-disk format. + case location + case manual + } - public init( - countryCode: String, - administrativeAreaCode: String? = nil, - countyKey: String? = nil, - source: Source - ) { - self.countryCode = countryCode - self.administrativeAreaCode = administrativeAreaCode - self.countyKey = countyKey - self.source = source - } + public init( + countryCode: String, + administrativeAreaCode: String? = nil, + countyKey: String? = nil, + source: Source + ) { + self.countryCode = countryCode + self.administrativeAreaCode = administrativeAreaCode + self.countyKey = countyKey + self.source = source + } - private enum CodingKeys: String, CodingKey { - case countryCode, administrativeAreaCode, countyKey, source - } + private enum CodingKeys: String, CodingKey { + case countryCode, administrativeAreaCode, countyKey, source + } - public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - self.countryCode = try c.decode(String.self, forKey: .countryCode) - self.administrativeAreaCode = try c.decodeIfPresent(String.self, forKey: .administrativeAreaCode) - self.countyKey = try c.decodeIfPresent(String.self, forKey: .countyKey) - self.source = try c.decode(Source.self, forKey: .source) - } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + countryCode = try c.decode(String.self, forKey: .countryCode) + administrativeAreaCode = try c.decodeIfPresent(String.self, forKey: .administrativeAreaCode) + countyKey = try c.decodeIfPresent(String.self, forKey: .countyKey) + source = try c.decode(Source.self, forKey: .source) + } } diff --git a/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift b/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift index d845160b..eb906796 100644 --- a/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift +++ b/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift @@ -1,3 +1,4 @@ +import CoreLocation import Foundation import SwiftData @@ -5,373 +6,411 @@ import SwiftData /// Used for both room servers and repeater admin connections. @Model public final class RemoteNodeSession { - #Index([\.radioID]) + #Index([\.radioID]) - /// Unique session identifier - @Attribute(.unique) - public var id: UUID + /// Unique session identifier + @Attribute(.unique) + public var id: UUID - /// The companion radio used to access this node - @Attribute(originalName: "deviceID") - public var radioID: UUID + /// The companion radio used to access this node + @Attribute(originalName: "deviceID") + public var radioID: UUID - /// 32-byte remote node's public key - public var publicKey: Data + /// 32-byte remote node's public key + public var publicKey: Data - /// Human-readable node name - public var name: String + /// Human-readable node name + public var name: String - /// Raw value of RemoteNodeRole - public var roleRawValue: UInt8 + /// Raw value of RemoteNodeRole + public var roleRawValue: UInt8 - /// Node latitude (non-optional, consistent with Device) - public var latitude: Double + /// Node latitude (non-optional, consistent with Device) + public var latitude: Double - /// Node longitude - public var longitude: Double + /// Node longitude + public var longitude: Double - /// Whether currently connected/authenticated - public var isConnected: Bool + /// Whether currently connected/authenticated + public var isConnected: Bool - /// Permission level raw value (RoomPermissionLevel) - public var permissionLevelRawValue: UInt8 + /// Permission level raw value (RoomPermissionLevel) + public var permissionLevelRawValue: UInt8 - /// Last successful connection date - public var lastConnectedDate: Date? + /// Last successful connection date + public var lastConnectedDate: Date? - /// Cached battery level from last status - public var lastBatteryMillivolts: UInt16? + /// Cached battery level from last status + public var lastBatteryMillivolts: UInt16? - /// Cached uptime from last status - public var lastUptimeSeconds: UInt32? + /// Cached uptime from last status + public var lastUptimeSeconds: UInt32? - /// Cached noise floor from last status - public var lastNoiseFloor: Int16? + /// Cached noise floor from last status + public var lastNoiseFloor: Int16? - /// Unread message count (room-specific) - public var unreadCount: Int + /// Unread message count (room-specific) + public var unreadCount: Int - /// Notification level for this room (stored as raw value for SwiftData). - /// Default is -1 (unmigrated) to enable migration from legacy isMuted property. - public var notificationLevelRawValue: Int = -1 + /// Notification level for this room (stored as raw value for SwiftData). + /// Default is -1 (unmigrated) to enable migration from legacy isMuted property. + public var notificationLevelRawValue: Int = -1 - /// Legacy isMuted property from V1 schema (maps to old "isMuted" column). - /// Used for one-time migration to notificationLevelRawValue. - @Attribute(originalName: "isMuted") - public var legacyIsMuted: Bool? + /// Legacy isMuted property from V1 schema (maps to old "isMuted" column). + /// Used for one-time migration to notificationLevelRawValue. + @Attribute(originalName: "isMuted") + public var legacyIsMuted: Bool? - /// Notification level computed property with automatic migration from legacy isMuted - public var notificationLevel: NotificationLevel { - get { - // Check if migration is needed - if notificationLevelRawValue == -1 { - // Migrate from legacy isMuted - let migratedLevel: NotificationLevel = (legacyIsMuted == true) ? .muted : .all - notificationLevelRawValue = migratedLevel.rawValue - return migratedLevel - } - return NotificationLevel(rawValue: notificationLevelRawValue) ?? .all - } - set { notificationLevelRawValue = newValue.rawValue } - } - - /// Whether this session/node is marked as favorite - public var isFavorite: Bool = false - - /// Last RX airtime in seconds (repeater-specific) - public var lastRxAirtimeSeconds: UInt32? - - /// Number of neighbors (repeater-specific) - public var neighborCount: Int - - /// Timestamp of the last message received from this room. - /// Used to request only newer messages on reconnect. - /// Value of 0 means no messages synced yet (request all). - public var lastSyncTimestamp: UInt32 - - /// Device-local date of last message activity (send or receive). - /// Used for sorting in the chat list. Separate from lastSyncTimestamp - /// which tracks the sender's clock for sync purposes. - public var lastMessageDate: Date? - - public init( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data, - name: String, - role: RemoteNodeRole, - latitude: Double = 0, - longitude: Double = 0, - isConnected: Bool = false, - permissionLevel: RoomPermissionLevel = .guest, - lastConnectedDate: Date? = nil, - lastBatteryMillivolts: UInt16? = nil, - lastUptimeSeconds: UInt32? = nil, - lastNoiseFloor: Int16? = nil, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - lastRxAirtimeSeconds: UInt32? = nil, - neighborCount: Int = 0, - lastSyncTimestamp: UInt32 = 0, - lastMessageDate: Date? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.roleRawValue = role.rawValue - self.latitude = latitude - self.longitude = longitude - self.isConnected = isConnected - self.permissionLevelRawValue = permissionLevel.rawValue - self.lastConnectedDate = lastConnectedDate - self.lastBatteryMillivolts = lastBatteryMillivolts - self.lastUptimeSeconds = lastUptimeSeconds - self.lastNoiseFloor = lastNoiseFloor - self.unreadCount = unreadCount - self.notificationLevelRawValue = notificationLevel.rawValue - self.isFavorite = isFavorite - self.lastRxAirtimeSeconds = lastRxAirtimeSeconds - self.neighborCount = neighborCount - self.lastSyncTimestamp = lastSyncTimestamp - self.lastMessageDate = lastMessageDate - } - - /// Builds a model instance directly from a DTO. Shared by backup batch-insert - /// paths so model and DTO can't drift on field coverage. - public convenience init(dto: RemoteNodeSessionDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - publicKey: dto.publicKey, - name: dto.name, - role: dto.role, - latitude: dto.latitude, - longitude: dto.longitude, - isConnected: dto.isConnected, - permissionLevel: dto.permissionLevel, - lastConnectedDate: dto.lastConnectedDate, - lastBatteryMillivolts: dto.lastBatteryMillivolts, - lastUptimeSeconds: dto.lastUptimeSeconds, - lastNoiseFloor: dto.lastNoiseFloor, - unreadCount: dto.unreadCount, - notificationLevel: dto.notificationLevel, - isFavorite: dto.isFavorite, - lastRxAirtimeSeconds: dto.lastRxAirtimeSeconds, - neighborCount: dto.neighborCount, - lastSyncTimestamp: dto.lastSyncTimestamp, - lastMessageDate: dto.lastMessageDate - ) - } - - /// Applies all mutable fields from a DTO to this model instance. - func apply(_ dto: RemoteNodeSessionDTO) { - radioID = dto.radioID - publicKey = dto.publicKey - name = dto.name - roleRawValue = dto.role.rawValue - latitude = dto.latitude - longitude = dto.longitude - isConnected = dto.isConnected - permissionLevelRawValue = dto.permissionLevel.rawValue - lastConnectedDate = dto.lastConnectedDate - lastBatteryMillivolts = dto.lastBatteryMillivolts - lastUptimeSeconds = dto.lastUptimeSeconds - lastNoiseFloor = dto.lastNoiseFloor - unreadCount = dto.unreadCount - notificationLevel = dto.notificationLevel - isFavorite = dto.isFavorite - lastRxAirtimeSeconds = dto.lastRxAirtimeSeconds - neighborCount = dto.neighborCount - lastSyncTimestamp = dto.lastSyncTimestamp - lastMessageDate = dto.lastMessageDate + /// Notification level computed property with automatic migration from legacy isMuted + public var notificationLevel: NotificationLevel { + get { + // Check if migration is needed + if notificationLevelRawValue == -1 { + // Migrate from legacy isMuted + let migratedLevel: NotificationLevel = (legacyIsMuted == true) ? .muted : .all + notificationLevelRawValue = migratedLevel.rawValue + return migratedLevel + } + return NotificationLevel(rawValue: notificationLevelRawValue) ?? .all } + set { notificationLevelRawValue = newValue.rawValue } + } + + /// Whether this session/node is marked as favorite + public var isFavorite: Bool = false + + /// Last RX airtime in seconds (repeater-specific) + public var lastRxAirtimeSeconds: UInt32? + + /// Number of neighbors (repeater-specific) + public var neighborCount: Int + + /// Timestamp of the last message received from this room. + /// Used to request only newer messages on reconnect. + /// Value of 0 means no messages synced yet (request all). + public var lastSyncTimestamp: UInt32 + + /// Device-local date of last message activity (send or receive). + /// Used for sorting in the chat list. Separate from lastSyncTimestamp + /// which tracks the sender's clock for sync purposes. + public var lastMessageDate: Date? + + public init( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data, + name: String, + role: RemoteNodeRole, + latitude: Double = 0, + longitude: Double = 0, + isConnected: Bool = false, + permissionLevel: RoomPermissionLevel = .guest, + lastConnectedDate: Date? = nil, + lastBatteryMillivolts: UInt16? = nil, + lastUptimeSeconds: UInt32? = nil, + lastNoiseFloor: Int16? = nil, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + lastRxAirtimeSeconds: UInt32? = nil, + neighborCount: Int = 0, + lastSyncTimestamp: UInt32 = 0, + lastMessageDate: Date? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + roleRawValue = role.rawValue + self.latitude = latitude + self.longitude = longitude + self.isConnected = isConnected + permissionLevelRawValue = permissionLevel.rawValue + self.lastConnectedDate = lastConnectedDate + self.lastBatteryMillivolts = lastBatteryMillivolts + self.lastUptimeSeconds = lastUptimeSeconds + self.lastNoiseFloor = lastNoiseFloor + self.unreadCount = unreadCount + notificationLevelRawValue = notificationLevel.rawValue + self.isFavorite = isFavorite + self.lastRxAirtimeSeconds = lastRxAirtimeSeconds + self.neighborCount = neighborCount + self.lastSyncTimestamp = lastSyncTimestamp + self.lastMessageDate = lastMessageDate + } + + /// Builds a model instance directly from a DTO. Shared by backup batch-insert + /// paths so model and DTO can't drift on field coverage. + public convenience init(dto: RemoteNodeSessionDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + publicKey: dto.publicKey, + name: dto.name, + role: dto.role, + latitude: dto.latitude, + longitude: dto.longitude, + isConnected: dto.isConnected, + permissionLevel: dto.permissionLevel, + lastConnectedDate: dto.lastConnectedDate, + lastBatteryMillivolts: dto.lastBatteryMillivolts, + lastUptimeSeconds: dto.lastUptimeSeconds, + lastNoiseFloor: dto.lastNoiseFloor, + unreadCount: dto.unreadCount, + notificationLevel: dto.notificationLevel, + isFavorite: dto.isFavorite, + lastRxAirtimeSeconds: dto.lastRxAirtimeSeconds, + neighborCount: dto.neighborCount, + lastSyncTimestamp: dto.lastSyncTimestamp, + lastMessageDate: dto.lastMessageDate + ) + } + + /// Applies all mutable fields from a DTO to this model instance. + func apply(_ dto: RemoteNodeSessionDTO) { + radioID = dto.radioID + publicKey = dto.publicKey + name = dto.name + roleRawValue = dto.role.rawValue + latitude = dto.latitude + longitude = dto.longitude + isConnected = dto.isConnected + permissionLevelRawValue = dto.permissionLevel.rawValue + lastConnectedDate = dto.lastConnectedDate + lastBatteryMillivolts = dto.lastBatteryMillivolts + lastUptimeSeconds = dto.lastUptimeSeconds + lastNoiseFloor = dto.lastNoiseFloor + unreadCount = dto.unreadCount + notificationLevel = dto.notificationLevel + isFavorite = dto.isFavorite + lastRxAirtimeSeconds = dto.lastRxAirtimeSeconds + neighborCount = dto.neighborCount + lastSyncTimestamp = dto.lastSyncTimestamp + lastMessageDate = dto.lastMessageDate + } } // MARK: - Computed Properties public extension RemoteNodeSession { - /// The node role enum - var role: RemoteNodeRole { - RemoteNodeRole(rawValue: roleRawValue) ?? .repeater - } - - /// The permission level enum - var permissionLevel: RoomPermissionLevel { - get { RoomPermissionLevel(rawValue: permissionLevelRawValue) ?? .guest } - set { permissionLevelRawValue = newValue.rawValue } - } - - /// Whether this is a room server session - var isRoom: Bool { role == .roomServer } - - /// Whether this is a repeater session - var isRepeater: Bool { role == .repeater } - - /// 6-byte public key prefix for addressing - var publicKeyPrefix: Data { publicKey.prefix(6) } - - /// Hex string representation of full public key - var publicKeyHex: String { - publicKey.map { String(format: "%02X", $0) }.joined() - } - - /// Whether user can post messages (room-specific) - var canPost: Bool { isRoom && permissionLevel.canPost } - - /// Whether user has admin access - var isAdmin: Bool { permissionLevel.isAdmin } + /// The node role enum + var role: RemoteNodeRole { + RemoteNodeRole(rawValue: roleRawValue) ?? .repeater + } + + /// The permission level enum + var permissionLevel: RoomPermissionLevel { + get { RoomPermissionLevel(rawValue: permissionLevelRawValue) ?? .guest } + set { permissionLevelRawValue = newValue.rawValue } + } + + /// Whether this is a room server session + var isRoom: Bool { + role == .roomServer + } + + /// Whether this is a repeater session + var isRepeater: Bool { + role == .repeater + } + + /// 6-byte public key prefix for addressing + var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + /// Hex string representation of full public key + var publicKeyHex: String { + publicKey.map { String(format: "%02X", $0) }.joined() + } + + /// Whether user can post messages (room-specific) + var canPost: Bool { + isRoom && permissionLevel.canPost + } + + /// Whether user has admin access + var isAdmin: Bool { + permissionLevel.isAdmin + } } // MARK: - Sendable DTO /// A sendable snapshot of RemoteNodeSession for cross-actor transfers public struct RemoteNodeSessionDTO: Sendable, Equatable, Identifiable, Hashable, Codable { - public let id: UUID - public var radioID: UUID - public let publicKey: Data - public let name: String - public let role: RemoteNodeRole - public let latitude: Double - public let longitude: Double - public let isConnected: Bool - public let permissionLevel: RoomPermissionLevel - public let lastConnectedDate: Date? - public let lastBatteryMillivolts: UInt16? - public let lastUptimeSeconds: UInt32? - public let lastNoiseFloor: Int16? - public let unreadCount: Int - public let notificationLevel: NotificationLevel - public let isFavorite: Bool - - /// Convenience property for checking if muted - public var isMuted: Bool { notificationLevel == .muted } - public let lastRxAirtimeSeconds: UInt32? - public let neighborCount: Int - public let lastSyncTimestamp: UInt32 - public let lastMessageDate: Date? - - public init(from model: RemoteNodeSession) { - self.id = model.id - self.radioID = model.radioID - self.publicKey = model.publicKey - self.name = model.name - self.role = model.role - self.latitude = model.latitude - self.longitude = model.longitude - self.isConnected = model.isConnected - self.permissionLevel = model.permissionLevel - self.lastConnectedDate = model.lastConnectedDate - self.lastBatteryMillivolts = model.lastBatteryMillivolts - self.lastUptimeSeconds = model.lastUptimeSeconds - self.lastNoiseFloor = model.lastNoiseFloor - self.unreadCount = model.unreadCount - // Decode the level without invoking the migrating getter's in-memory write-back, keeping - // export a pure read (mirrors `ChannelDTO.init(from:)`). An unmigrated -1 sentinel maps - // to its migrated value (muted if the legacy isMuted flag was set, else all) exactly as - // the getter would, but without dirtying the live row. - self.notificationLevel = NotificationLevel(rawValue: model.notificationLevelRawValue) - ?? ((model.legacyIsMuted == true) ? .muted : .all) - self.isFavorite = model.isFavorite - self.lastRxAirtimeSeconds = model.lastRxAirtimeSeconds - self.neighborCount = model.neighborCount - self.lastSyncTimestamp = model.lastSyncTimestamp - // Backward compat: fall back to sync timestamp for pre-migration data - self.lastMessageDate = model.lastMessageDate - ?? (model.lastSyncTimestamp > 0 - ? Date(timeIntervalSince1970: TimeInterval(model.lastSyncTimestamp)) - : nil) - } - - /// Memberwise initializer for creating DTOs directly - public init( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data, - name: String, - role: RemoteNodeRole, - latitude: Double = 0, - longitude: Double = 0, - isConnected: Bool = false, - permissionLevel: RoomPermissionLevel = .guest, - lastConnectedDate: Date? = nil, - lastBatteryMillivolts: UInt16? = nil, - lastUptimeSeconds: UInt32? = nil, - lastNoiseFloor: Int16? = nil, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - lastRxAirtimeSeconds: UInt32? = nil, - neighborCount: Int = 0, - lastSyncTimestamp: UInt32 = 0, - lastMessageDate: Date? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.role = role - self.latitude = latitude - self.longitude = longitude - self.isConnected = isConnected - self.permissionLevel = permissionLevel - self.lastConnectedDate = lastConnectedDate - self.lastBatteryMillivolts = lastBatteryMillivolts - self.lastUptimeSeconds = lastUptimeSeconds - self.lastNoiseFloor = lastNoiseFloor - self.unreadCount = unreadCount - self.notificationLevel = notificationLevel - self.isFavorite = isFavorite - self.lastRxAirtimeSeconds = lastRxAirtimeSeconds - self.neighborCount = neighborCount - self.lastSyncTimestamp = lastSyncTimestamp - self.lastMessageDate = lastMessageDate - } - - /// Returns a copy with only `notificationLevel` changed. - public func with(notificationLevel: NotificationLevel) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: id, radioID: radioID, publicKey: publicKey, name: name, - role: role, latitude: latitude, longitude: longitude, - isConnected: isConnected, permissionLevel: permissionLevel, - lastConnectedDate: lastConnectedDate, - lastBatteryMillivolts: lastBatteryMillivolts, - lastUptimeSeconds: lastUptimeSeconds, lastNoiseFloor: lastNoiseFloor, - unreadCount: unreadCount, notificationLevel: notificationLevel, - isFavorite: isFavorite, lastRxAirtimeSeconds: lastRxAirtimeSeconds, - neighborCount: neighborCount, lastSyncTimestamp: lastSyncTimestamp, - lastMessageDate: lastMessageDate - ) - } - - /// Returns a copy with only `isFavorite` changed. - public func with(isFavorite: Bool) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: id, radioID: radioID, publicKey: publicKey, name: name, - role: role, latitude: latitude, longitude: longitude, - isConnected: isConnected, permissionLevel: permissionLevel, - lastConnectedDate: lastConnectedDate, - lastBatteryMillivolts: lastBatteryMillivolts, - lastUptimeSeconds: lastUptimeSeconds, lastNoiseFloor: lastNoiseFloor, - unreadCount: unreadCount, notificationLevel: notificationLevel, - isFavorite: isFavorite, lastRxAirtimeSeconds: lastRxAirtimeSeconds, - neighborCount: neighborCount, lastSyncTimestamp: lastSyncTimestamp, - lastMessageDate: lastMessageDate - ) - } - - public var publicKeyPrefix: Data { publicKey.prefix(6) } - - public var publicKeyHex: String { - publicKey.map { String(format: "%02X", $0) }.joined() - } - - public var isRoom: Bool { role == .roomServer } - - public var isRepeater: Bool { role == .repeater } - - public var canPost: Bool { isRoom && permissionLevel.canPost } - - public var isAdmin: Bool { permissionLevel.isAdmin } + public let id: UUID + public var radioID: UUID + public let publicKey: Data + public let name: String + public let role: RemoteNodeRole + public let latitude: Double + public let longitude: Double + public let isConnected: Bool + public let permissionLevel: RoomPermissionLevel + public let lastConnectedDate: Date? + public let lastBatteryMillivolts: UInt16? + public let lastUptimeSeconds: UInt32? + public let lastNoiseFloor: Int16? + public let unreadCount: Int + public let notificationLevel: NotificationLevel + public let isFavorite: Bool + + /// Convenience property for checking if muted + public var isMuted: Bool { + notificationLevel == .muted + } + + public let lastRxAirtimeSeconds: UInt32? + public let neighborCount: Int + public let lastSyncTimestamp: UInt32 + public let lastMessageDate: Date? + + public init(from model: RemoteNodeSession) { + id = model.id + radioID = model.radioID + publicKey = model.publicKey + name = model.name + role = model.role + latitude = model.latitude + longitude = model.longitude + isConnected = model.isConnected + permissionLevel = model.permissionLevel + lastConnectedDate = model.lastConnectedDate + lastBatteryMillivolts = model.lastBatteryMillivolts + lastUptimeSeconds = model.lastUptimeSeconds + lastNoiseFloor = model.lastNoiseFloor + unreadCount = model.unreadCount + // Decode the level without invoking the migrating getter's in-memory write-back, keeping + // export a pure read (mirrors `ChannelDTO.init(from:)`). An unmigrated -1 sentinel maps + // to its migrated value (muted if the legacy isMuted flag was set, else all) exactly as + // the getter would, but without dirtying the live row. + notificationLevel = NotificationLevel(rawValue: model.notificationLevelRawValue) + ?? ((model.legacyIsMuted == true) ? .muted : .all) + isFavorite = model.isFavorite + lastRxAirtimeSeconds = model.lastRxAirtimeSeconds + neighborCount = model.neighborCount + lastSyncTimestamp = model.lastSyncTimestamp + // Backward compat: fall back to sync timestamp for pre-migration data + lastMessageDate = model.lastMessageDate + ?? (model.lastSyncTimestamp > 0 + ? Date(timeIntervalSince1970: TimeInterval(model.lastSyncTimestamp)) + : nil) + } + + /// Memberwise initializer for creating DTOs directly + public init( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data, + name: String, + role: RemoteNodeRole, + latitude: Double = 0, + longitude: Double = 0, + isConnected: Bool = false, + permissionLevel: RoomPermissionLevel = .guest, + lastConnectedDate: Date? = nil, + lastBatteryMillivolts: UInt16? = nil, + lastUptimeSeconds: UInt32? = nil, + lastNoiseFloor: Int16? = nil, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + lastRxAirtimeSeconds: UInt32? = nil, + neighborCount: Int = 0, + lastSyncTimestamp: UInt32 = 0, + lastMessageDate: Date? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.role = role + self.latitude = latitude + self.longitude = longitude + self.isConnected = isConnected + self.permissionLevel = permissionLevel + self.lastConnectedDate = lastConnectedDate + self.lastBatteryMillivolts = lastBatteryMillivolts + self.lastUptimeSeconds = lastUptimeSeconds + self.lastNoiseFloor = lastNoiseFloor + self.unreadCount = unreadCount + self.notificationLevel = notificationLevel + self.isFavorite = isFavorite + self.lastRxAirtimeSeconds = lastRxAirtimeSeconds + self.neighborCount = neighborCount + self.lastSyncTimestamp = lastSyncTimestamp + self.lastMessageDate = lastMessageDate + } + + /// Returns a copy with only `notificationLevel` changed. + public func with(notificationLevel: NotificationLevel) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, radioID: radioID, publicKey: publicKey, name: name, + role: role, latitude: latitude, longitude: longitude, + isConnected: isConnected, permissionLevel: permissionLevel, + lastConnectedDate: lastConnectedDate, + lastBatteryMillivolts: lastBatteryMillivolts, + lastUptimeSeconds: lastUptimeSeconds, lastNoiseFloor: lastNoiseFloor, + unreadCount: unreadCount, notificationLevel: notificationLevel, + isFavorite: isFavorite, lastRxAirtimeSeconds: lastRxAirtimeSeconds, + neighborCount: neighborCount, lastSyncTimestamp: lastSyncTimestamp, + lastMessageDate: lastMessageDate + ) + } + + /// Returns a copy with only `isFavorite` changed. + public func with(isFavorite: Bool) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, radioID: radioID, publicKey: publicKey, name: name, + role: role, latitude: latitude, longitude: longitude, + isConnected: isConnected, permissionLevel: permissionLevel, + lastConnectedDate: lastConnectedDate, + lastBatteryMillivolts: lastBatteryMillivolts, + lastUptimeSeconds: lastUptimeSeconds, lastNoiseFloor: lastNoiseFloor, + unreadCount: unreadCount, notificationLevel: notificationLevel, + isFavorite: isFavorite, lastRxAirtimeSeconds: lastRxAirtimeSeconds, + neighborCount: neighborCount, lastSyncTimestamp: lastSyncTimestamp, + lastMessageDate: lastMessageDate + ) + } + + public var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + public var publicKeyHex: String { + publicKey.map { String(format: "%02X", $0) }.joined() + } + + public var isRoom: Bool { + role == .roomServer + } + + public var isRepeater: Bool { + role == .repeater + } + + public var canPost: Bool { + isRoom && permissionLevel.canPost + } + + public var isAdmin: Bool { + permissionLevel.isAdmin + } + + /// Whether the node reported a usable location. Latitude/longitude default to (0,0) when GPS was + /// never shared, so the sentinel and validity check are guarded here, mirroring `ContactDTO`. + public var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + + public var coordinate: CLLocationCoordinate2D? { + guard hasLocation else { return nil } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/BaseColorSlot.swift b/MC1Services/Sources/MC1Services/Models/Rendering/BaseColorSlot.swift index fedb5e34..4e81595c 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/BaseColorSlot.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/BaseColorSlot.swift @@ -7,6 +7,6 @@ import Foundation /// rest of the chat content model to live in MC1Services without a /// SwiftUI dependency. public enum BaseColorSlot: Sendable, Hashable { - case outgoing - case incoming + case outgoing + case incoming } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift index 021797e9..f76facc2 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift @@ -5,105 +5,104 @@ import Foundation /// `ChatViewModel` accessors that forward to the coordinator's current /// `renderState`. public struct ChatRenderState: Sendable, Equatable { + /// Tri-state load phase that distinguishes "we haven't loaded yet" from + /// "we loaded and the result was empty." Per-conversation views gate the + /// empty-state placeholder on `phase == .loaded && items.isEmpty`, so a + /// freshly-bound coordinator whose first fetch is still in-flight does + /// not flash "No messages" before the awaited fetch lands. + public enum LoadPhase: Sendable { + case uninitialized + case loading + case loaded + } - /// Tri-state load phase that distinguishes "we haven't loaded yet" from - /// "we loaded and the result was empty." Per-conversation views gate the - /// empty-state placeholder on `phase == .loaded && items.isEmpty`, so a - /// freshly-bound coordinator whose first fetch is still in-flight does - /// not flash "No messages" before the awaited fetch lands. - public enum LoadPhase: Sendable { - case uninitialized - case loading - case loaded - } + public let items: [MessageItem] + public let itemIndexByID: [UUID: Int] + public let hasMoreMessages: Bool + public let isLoadingOlder: Bool + public let totalFetchedCount: Int + public let phase: LoadPhase - public let items: [MessageItem] - public let itemIndexByID: [UUID: Int] - public let hasMoreMessages: Bool - public let isLoadingOlder: Bool - public let totalFetchedCount: Int - public let phase: LoadPhase + public init( + items: [MessageItem], + itemIndexByID: [UUID: Int], + hasMoreMessages: Bool, + isLoadingOlder: Bool, + totalFetchedCount: Int, + phase: LoadPhase = .uninitialized + ) { + self.items = items + self.itemIndexByID = itemIndexByID + self.hasMoreMessages = hasMoreMessages + self.isLoadingOlder = isLoadingOlder + self.totalFetchedCount = totalFetchedCount + self.phase = phase + } - public init( - items: [MessageItem], - itemIndexByID: [UUID: Int], - hasMoreMessages: Bool, - isLoadingOlder: Bool, - totalFetchedCount: Int, - phase: LoadPhase = .uninitialized - ) { - self.items = items - self.itemIndexByID = itemIndexByID - self.hasMoreMessages = hasMoreMessages - self.isLoadingOlder = isLoadingOlder - self.totalFetchedCount = totalFetchedCount - self.phase = phase - } + public static let empty = ChatRenderState( + items: [], + itemIndexByID: [:], + hasMoreMessages: true, + isLoadingOlder: false, + totalFetchedCount: 0, + phase: .uninitialized + ) - public static let empty = ChatRenderState( - items: [], - itemIndexByID: [:], - hasMoreMessages: true, - isLoadingOlder: false, - totalFetchedCount: 0, - phase: .uninitialized + /// Returns a new render state with the supplied fields overridden. + /// Use at every mutation site to enforce "rebuild, never mutate in place." + public func with( + items: [MessageItem]? = nil, + itemIndexByID: [UUID: Int]? = nil, + hasMoreMessages: Bool? = nil, + isLoadingOlder: Bool? = nil, + totalFetchedCount: Int? = nil, + phase: LoadPhase? = nil + ) -> ChatRenderState { + ChatRenderState( + items: items ?? self.items, + itemIndexByID: itemIndexByID ?? self.itemIndexByID, + hasMoreMessages: hasMoreMessages ?? self.hasMoreMessages, + isLoadingOlder: isLoadingOlder ?? self.isLoadingOlder, + totalFetchedCount: totalFetchedCount ?? self.totalFetchedCount, + phase: phase ?? self.phase ) + } - /// Returns a new render state with the supplied fields overridden. - /// Use at every mutation site to enforce "rebuild, never mutate in place." - public func with( - items: [MessageItem]? = nil, - itemIndexByID: [UUID: Int]? = nil, - hasMoreMessages: Bool? = nil, - isLoadingOlder: Bool? = nil, - totalFetchedCount: Int? = nil, - phase: LoadPhase? = nil - ) -> ChatRenderState { - ChatRenderState( - items: items ?? self.items, - itemIndexByID: itemIndexByID ?? self.itemIndexByID, - hasMoreMessages: hasMoreMessages ?? self.hasMoreMessages, - isLoadingOlder: isLoadingOlder ?? self.isLoadingOlder, - totalFetchedCount: totalFetchedCount ?? self.totalFetchedCount, - phase: phase ?? self.phase - ) - } + /// Replace a single item by message ID. No-op if the ID is not present + /// or if `transform` returns an equal item — returning `self` in those + /// cases preserves the underlying `items` buffer so the caller's + /// `newState != renderState` guard short-circuits on Array buffer + /// identity instead of walking every item. + public func updatingItem(id: UUID, _ transform: (MessageItem) -> MessageItem) -> ChatRenderState { + guard let index = itemIndexByID[id] else { return self } + let updated = transform(items[index]) + guard updated != items[index] else { return self } + var newItems = items + newItems[index] = updated + return with(items: newItems) + } - /// Replace a single item by message ID. No-op if the ID is not present - /// or if `transform` returns an equal item — returning `self` in those - /// cases preserves the underlying `items` buffer so the caller's - /// `newState != renderState` guard short-circuits on Array buffer - /// identity instead of walking every item. - public func updatingItem(id: UUID, _ transform: (MessageItem) -> MessageItem) -> ChatRenderState { - guard let index = itemIndexByID[id] else { return self } - let updated = transform(items[index]) - guard updated != items[index] else { return self } - var newItems = items - newItems[index] = updated - return self.with(items: newItems) - } + /// Remove a single item by message ID. Rebuilds `itemIndexByID` since + /// indices shift after removal. No-op if the ID is not present. + public func removingItem(id: UUID) -> ChatRenderState { + guard itemIndexByID[id] != nil else { return self } + var newItems = items + newItems.removeAll { $0.id == id } + return with(items: newItems, itemIndexByID: newItems.indexByID()) + } - /// Remove a single item by message ID. Rebuilds `itemIndexByID` since - /// indices shift after removal. No-op if the ID is not present. - public func removingItem(id: UUID) -> ChatRenderState { - guard itemIndexByID[id] != nil else { return self } - var newItems = items - newItems.removeAll { $0.id == id } - return self.with(items: newItems, itemIndexByID: newItems.indexByID()) - } - - /// Append a single item and update `itemIndexByID`. Caller is responsible - /// for ensuring the ID is not already present (typically via an upstream - /// `itemIndexByID[id] == nil` guard at the append site). - public func appendingItem(_ item: MessageItem) -> ChatRenderState { - var newItems = items - newItems.append(item) - var newIndex = itemIndexByID - newIndex[item.id] = newItems.count - 1 - return self.with( - items: newItems, - itemIndexByID: newIndex, - totalFetchedCount: totalFetchedCount + 1 - ) - } + /// Append a single item and update `itemIndexByID`. Caller is responsible + /// for ensuring the ID is not already present (typically via an upstream + /// `itemIndexByID[id] == nil` guard at the append site). + public func appendingItem(_ item: MessageItem) -> ChatRenderState { + var newItems = items + newItems.append(item) + var newIndex = itemIndexByID + newIndex[item.id] = newItems.count - 1 + return with( + items: newItems, + itemIndexByID: newIndex, + totalFetchedCount: totalFetchedCount + 1 + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift index 7c773e0a..ed5f816e 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift @@ -6,98 +6,94 @@ import Foundation /// and pushes it to `ChatViewModel.applyEnvInputs(_:)`; the view model /// rebuilds `MessageItem`s when the value changes. public struct EnvInputs: Sendable, Hashable { - public let showInlineImages: Bool - public let autoPlayGIFs: Bool - public let showIncomingPath: Bool - public let showIncomingHopCount: Bool - public let showIncomingRegion: Bool - public let showIncomingSendTime: Bool - public let previewsEnabled: Bool - public let isHighContrast: Bool - /// Light/dark appearance, sourced from `@Environment(\.colorScheme)` in - /// `ChatConversationView`. Threaded into `MapPreviewFragmentState` so the map - /// thumbnail renders against the matching style. Like `isHighContrast`, a - /// change forces a full `buildItems()` rebuild (rare, OS-driven). - public let isDark: Bool - /// User-controlled privacy gate. When false, `MessageFragmentBuilder` skips - /// the map-preview fragment entirely so `MapPreviewFragmentView.onAppear` - /// never fires the third-party tile request — the coordinate text in the - /// message body remains tappable. - public let showMapPreviews: Bool - /// True when `OfflineMapService.isNetworkAvailable` is false. Sourced in - /// `ChatConversationView` and threaded through `MapSnapshotRequest` so the - /// snapshotter routes to the offline-pack style URL and the cache key for - /// online and offline renders does not collide. - public let isOffline: Bool - public let currentUserName: String - /// Active theme identifier (`Theme.id`). A `Sendable, Hashable` token — never a SwiftUI - /// `Color`, which would pull SwiftUI into MC1Services and break `Hashable`. The MC1 side - /// resolves it back to a `Theme` to bake outgoing-text/hashtag colors into `MessageTextPayload`. - public let themeID: String + public let autoPlayGIFs: Bool + public let showIncomingPath: Bool + public let showIncomingHopCount: Bool + public let showIncomingRegion: Bool + public let showIncomingSendTime: Bool + public let previewsEnabled: Bool + public let isHighContrast: Bool + /// Light/dark appearance, sourced from `@Environment(\.colorScheme)` in + /// `ChatConversationView`. Threaded into `MapPreviewFragmentState` so the map + /// thumbnail renders against the matching style. Like `isHighContrast`, a + /// change forces a full `buildItems()` rebuild (rare, OS-driven). + public let isDark: Bool + /// User-controlled privacy gate. When false, `MessageFragmentBuilder` skips + /// the map-preview fragment entirely so `MapPreviewFragmentView.onAppear` + /// never fires the third-party tile request — the coordinate text in the + /// message body remains tappable. + public let showMapPreviews: Bool + /// True when `OfflineMapService.isNetworkAvailable` is false. Sourced in + /// `ChatConversationView` and threaded through `MapSnapshotRequest` so the + /// snapshotter routes to the offline-pack style URL and the cache key for + /// online and offline renders does not collide. + public let isOffline: Bool + public let currentUserName: String + /// Active theme identifier (`Theme.id`). A `Sendable, Hashable` token — never a SwiftUI + /// `Color`, which would pull SwiftUI into MC1Services and break `Hashable`. The MC1 side + /// resolves it back to a `Theme` to bake outgoing-text/hashtag colors into `MessageTextPayload`. + public let themeID: String - /// Dynamic Type size fingerprint. A `Sendable, Hashable` token (a `DynamicTypeSize` case - /// name string supplied by the MC1 side, never the SwiftUI type itself) so a Dynamic Type - /// change bumps the `EnvInputs` equality fingerprint like `themeID` and `isHighContrast` do, - /// forcing a full `buildItems()` rebuild. It does not key the bubble text view's size cache: - /// that cache is keyed on the `dynamicTypeSize` the renderer reads directly from its own - /// `@Environment`. Reflow of already-visible cells is driven by the appearance reconfigure path. - public let contentSizeCategory: String + /// Dynamic Type size fingerprint. A `Sendable, Hashable` token (a `DynamicTypeSize` case + /// name string supplied by the MC1 side, never the SwiftUI type itself) so a Dynamic Type + /// change bumps the `EnvInputs` equality fingerprint like `themeID` and `isHighContrast` do, + /// forcing a full `buildItems()` rebuild. It does not key the bubble text view's size cache: + /// that cache is keyed on the `dynamicTypeSize` the renderer reads directly from its own + /// `@Environment`. Reflow of already-visible cells is driven by the appearance reconfigure path. + public let contentSizeCategory: String - public init( - showInlineImages: Bool, - autoPlayGIFs: Bool, - showIncomingPath: Bool, - showIncomingHopCount: Bool, - showIncomingRegion: Bool, - showIncomingSendTime: Bool, - previewsEnabled: Bool, - isHighContrast: Bool, - isDark: Bool, - showMapPreviews: Bool, - isOffline: Bool, - currentUserName: String, - themeID: String, - contentSizeCategory: String - ) { - self.showInlineImages = showInlineImages - self.autoPlayGIFs = autoPlayGIFs - self.showIncomingPath = showIncomingPath - self.showIncomingHopCount = showIncomingHopCount - self.showIncomingRegion = showIncomingRegion - self.showIncomingSendTime = showIncomingSendTime - self.previewsEnabled = previewsEnabled - self.isHighContrast = isHighContrast - self.isDark = isDark - self.showMapPreviews = showMapPreviews - self.isOffline = isOffline - self.currentUserName = currentUserName - self.themeID = themeID - self.contentSizeCategory = contentSizeCategory - } + public init( + autoPlayGIFs: Bool, + showIncomingPath: Bool, + showIncomingHopCount: Bool, + showIncomingRegion: Bool, + showIncomingSendTime: Bool, + previewsEnabled: Bool, + isHighContrast: Bool, + isDark: Bool, + showMapPreviews: Bool, + isOffline: Bool, + currentUserName: String, + themeID: String, + contentSizeCategory: String + ) { + self.autoPlayGIFs = autoPlayGIFs + self.showIncomingPath = showIncomingPath + self.showIncomingHopCount = showIncomingHopCount + self.showIncomingRegion = showIncomingRegion + self.showIncomingSendTime = showIncomingSendTime + self.previewsEnabled = previewsEnabled + self.isHighContrast = isHighContrast + self.isDark = isDark + self.showMapPreviews = showMapPreviews + self.isOffline = isOffline + self.currentUserName = currentUserName + self.themeID = themeID + self.contentSizeCategory = contentSizeCategory + } - /// Identifier of the built-in default theme. Shared so `EnvInputs.default` and `Theme.default.id` - /// (defined in the MC1 layer, which cannot see `Theme` from here) cannot drift apart. - public static let defaultThemeID = "default" + /// Identifier of the built-in default theme. Shared so `EnvInputs.default` and `Theme.default.id` + /// (defined in the MC1 layer, which cannot see `Theme` from here) cannot drift apart. + public static let defaultThemeID = "default" - /// The system's unscaled Dynamic Type baseline (`DynamicTypeSize.large`). Shared so - /// `EnvInputs.default` and the MC1-side token mapper agree on the baseline string and - /// cannot drift apart. - public static let defaultContentSizeCategory = "large" + /// The system's unscaled Dynamic Type baseline (`DynamicTypeSize.large`). Shared so + /// `EnvInputs.default` and the MC1-side token mapper agree on the baseline string and + /// cannot drift apart. + public static let defaultContentSizeCategory = "large" - public static let `default` = EnvInputs( - showInlineImages: AppStorageKey.defaultShowInlineImages, - autoPlayGIFs: AppStorageKey.defaultAutoPlayGIFs, - showIncomingPath: AppStorageKey.defaultShowIncomingPath, - showIncomingHopCount: AppStorageKey.defaultShowIncomingHopCount, - showIncomingRegion: AppStorageKey.defaultShowIncomingRegion, - showIncomingSendTime: AppStorageKey.defaultShowIncomingSendTime, - previewsEnabled: AppStorageKey.defaultLinkPreviewsEnabled, - isHighContrast: false, - isDark: false, - showMapPreviews: AppStorageKey.defaultShowMapPreviewThumbnails, - isOffline: false, - currentUserName: "", - themeID: defaultThemeID, - contentSizeCategory: defaultContentSizeCategory - ) + public static let `default` = EnvInputs( + autoPlayGIFs: AppStorageKey.defaultAutoPlayGIFs, + showIncomingPath: AppStorageKey.defaultShowIncomingPath, + showIncomingHopCount: AppStorageKey.defaultShowIncomingHopCount, + showIncomingRegion: AppStorageKey.defaultShowIncomingRegion, + showIncomingSendTime: AppStorageKey.defaultShowIncomingSendTime, + previewsEnabled: AppStorageKey.defaultLinkPreviewsEnabled, + isHighContrast: false, + isDark: false, + showMapPreviews: AppStorageKey.defaultShowMapPreviewThumbnails, + isOffline: false, + currentUserName: "", + themeID: defaultThemeID, + contentSizeCategory: defaultContentSizeCategory + ) } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/GroupingFlags.swift b/MC1Services/Sources/MC1Services/Models/Rendering/GroupingFlags.swift index 74c4d950..0c614908 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/GroupingFlags.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/GroupingFlags.swift @@ -2,24 +2,24 @@ import Foundation /// Grouping signal — first-in-cluster, show timestamp, show divider. public struct GroupingFlags: Sendable, Hashable { - public let showTimestamp: Bool - public let showDirectionGap: Bool - public let showSenderName: Bool - public let showNewMessagesDivider: Bool - /// True for the first message of a new calendar day; drives the day separator. - public let showDayDivider: Bool + public let showTimestamp: Bool + public let showDirectionGap: Bool + public let showSenderName: Bool + public let showNewMessagesDivider: Bool + /// True for the first message of a new calendar day; drives the day separator. + public let showDayDivider: Bool - public init( - showTimestamp: Bool, - showDirectionGap: Bool, - showSenderName: Bool, - showNewMessagesDivider: Bool, - showDayDivider: Bool = false - ) { - self.showTimestamp = showTimestamp - self.showDirectionGap = showDirectionGap - self.showSenderName = showSenderName - self.showNewMessagesDivider = showNewMessagesDivider - self.showDayDivider = showDayDivider - } + public init( + showTimestamp: Bool, + showDirectionGap: Bool, + showSenderName: Bool, + showNewMessagesDivider: Bool, + showDayDivider: Bool = false + ) { + self.showTimestamp = showTimestamp + self.showDirectionGap = showDirectionGap + self.showSenderName = showSenderName + self.showNewMessagesDivider = showNewMessagesDivider + self.showDayDivider = showDayDivider + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/ImageReference.swift b/MC1Services/Sources/MC1Services/Models/Rendering/ImageReference.swift index 2b7ddc6c..67538a15 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/ImageReference.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/ImageReference.swift @@ -5,14 +5,14 @@ import Foundation /// it only carries a Hashable key. The bubble resolves the actual UIImage via /// an `imageResolver: (ImageReference) -> UIImage?` callback at render time. public struct ImageReference: Sendable, Hashable { - public let cacheKey: UUID - public let role: Role - public enum Role: Sendable, Hashable { - case inline, linkPreviewImage, linkPreviewIcon - } + public let cacheKey: UUID + public let role: Role + public enum Role: Sendable, Hashable { + case inline, linkPreviewImage, linkPreviewIcon + } - public init(cacheKey: UUID, role: Role) { - self.cacheKey = cacheKey - self.role = role - } + public init(cacheKey: UUID, role: Role) { + self.cacheKey = cacheKey + self.role = role + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/InlineImage.swift b/MC1Services/Sources/MC1Services/Models/Rendering/InlineImage.swift index 474efc39..cf65f87a 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/InlineImage.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/InlineImage.swift @@ -1,22 +1,27 @@ import Foundation public struct InlineImage: Sendable, Hashable { - public enum LoadState: Sendable, Hashable { - case idle(URL) - case loading(URL) - case loaded(ImageReference, isGIF: Bool) - case failed(URL) - } - public let state: LoadState - public let autoPlayGIFs: Bool - /// Width-over-height ratio resolved from `InlineImageDimensionsStore` at - /// build time. `nil` means dimensions are unknown and the view layer - /// must fall back to its 16:9 reservation skeleton. - public let cachedAspect: Double? + public enum LoadState: Sendable, Hashable { + case idle(URL) + case loading(URL) + case loaded(ImageReference, isGIF: Bool) + case failed(URL) + /// Scope-off tap-to-load placeholder: the master toggle is on but + /// auto-resolve is disabled for this conversation type, so the image is + /// not fetched until the user taps. Mirrors `LinkPreviewFragmentState.Mode.disabled`. + case disabled(URL) + } - public init(state: LoadState, autoPlayGIFs: Bool, cachedAspect: Double? = nil) { - self.state = state - self.autoPlayGIFs = autoPlayGIFs - self.cachedAspect = cachedAspect - } + public let state: LoadState + public let autoPlayGIFs: Bool + /// Width-over-height ratio resolved from `InlineImageDimensionsStore` at + /// build time. `nil` means dimensions are unknown and the view layer + /// must fall back to its 16:9 reservation skeleton. + public let cachedAspect: Double? + + public init(state: LoadState, autoPlayGIFs: Bool, cachedAspect: Double? = nil) { + self.state = state + self.autoPlayGIFs = autoPlayGIFs + self.cachedAspect = cachedAspect + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift index 3d8094ed..2ce0e2f1 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift @@ -1,33 +1,34 @@ import Foundation public struct LinkPreviewFragmentState: Sendable, Hashable { - public enum Mode: Sendable, Hashable { - case idle - case loading(URL) - case loaded(LinkPreviewDataDTO, image: ImageReference?, icon: ImageReference?) - case noPreview - case disabled(URL) - case legacy(url: URL, title: String?, image: ImageReference?, icon: ImageReference?) - } - public let mode: Mode + public enum Mode: Sendable, Hashable { + case idle + case loading(URL) + case loaded(LinkPreviewDataDTO, image: ImageReference?, icon: ImageReference?) + case noPreview + case disabled(URL) + case legacy(url: URL, title: String?, image: ImageReference?, icon: ImageReference?) + } - public init(mode: Mode) { - self.mode = mode - } + public let mode: Mode + + public init(mode: Mode) { + self.mode = mode + } - /// The single openable URL the preview resolves to: the destination of a - /// loaded or legacy card. `nil` for modes that show no openable card - /// (`idle`, `loading`, `noPreview`, `disabled`). The preview card's tap path - /// and the bubble's accessibility "open link" action both read this so the - /// URL is parsed in one place rather than re-derived per call site. - public var primaryURL: URL? { - switch mode { - case .loaded(let preview, _, _): - return URL(string: preview.url) - case .legacy(let url, _, _, _): - return url - case .idle, .loading, .noPreview, .disabled: - return nil - } + /// The single openable URL the preview resolves to: the destination of a + /// loaded or legacy card. `nil` for modes that show no openable card + /// (`idle`, `loading`, `noPreview`, `disabled`). The preview card's tap path + /// and the bubble's accessibility "open link" action both read this so the + /// URL is parsed in one place rather than re-derived per call site. + public var primaryURL: URL? { + switch mode { + case let .loaded(preview, _, _): + URL(string: preview.url) + case let .legacy(url, _, _, _): + url + case .idle, .loading, .noPreview, .disabled: + nil } + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MapPreviewFragmentState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MapPreviewFragmentState.swift index 81d42d1b..d2b3e871 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MapPreviewFragmentState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MapPreviewFragmentState.swift @@ -13,21 +13,21 @@ import Foundation /// `(rounded lat/lon, isDark, isOffline)` is resolved (cached or failed), which /// changes this `Hashable` value and reloads the row. public struct MapPreviewFragmentState: Sendable, Hashable { - public let latitude: Double - public let longitude: Double - public let isDark: Bool - public let isOffline: Bool - public let isReady: Bool + public let latitude: Double + public let longitude: Double + public let isDark: Bool + public let isOffline: Bool + public let isReady: Bool - public init(latitude: Double, longitude: Double, isDark: Bool, isOffline: Bool, isReady: Bool) { - self.latitude = latitude - self.longitude = longitude - self.isDark = isDark - self.isOffline = isOffline - self.isReady = isReady - } + public init(latitude: Double, longitude: Double, isDark: Bool, isOffline: Bool, isReady: Bool) { + self.latitude = latitude + self.longitude = longitude + self.isDark = isDark + self.isOffline = isOffline + self.isReady = isReady + } - public var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - } + public var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift index b0131e8b..3be9fc11 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift @@ -7,81 +7,88 @@ import Foundation /// `imageResolver` callback (UIImage is not Sendable). Env-derived flags /// live on `EnvInputs`, the builder's second parcel. public struct MessageBuildInputs: Sendable, Hashable { - public let messageID: UUID - public let previewState: PreviewLoadState - public let loadedPreview: LinkPreviewDataDTO? - public let cachedURL: URL? - public let hasInlineImageRef: Bool - public let hasPreviewImageRef: Bool - public let hasPreviewIconRef: Bool - public let imageIsGIF: Bool - /// Cached width-over-height ratio for `cachedURL` when it points to an - /// inline image. Resolved at build time so the bubble can reserve the - /// correct frame on first paint. `nil` for non-image URLs or when the - /// dimensions store has not yet seen this URL. - public let inlineImageAspect: Double? - /// Latitude of the first linkified coordinate in the message text, or nil. - /// Drives the `.mapPreview` fragment. Stored as `Double` for `Hashable`. - public let mapPreviewLatitude: Double? - public let mapPreviewLongitude: Double? - /// True once the snapshot for this coordinate's `(rounded lat/lon, isDark)` - /// request has resolved (cached or failed). Build-time value: a - /// `UIHostingConfiguration` cell only re-evaluates when its item changes. - public let isMapPreviewReady: Bool - public let formattedText: AttributedString? - public let baseColor: BaseColorSlot - public let formattedPath: String? - public let senderResolution: NodeNameResolution - public let showTimestamp: Bool - public let showDirectionGap: Bool - public let showSenderName: Bool - public let showNewMessagesDivider: Bool - /// True for the first message of a new calendar day; drives the day separator. - public let showDayDivider: Bool + public let messageID: UUID + public let previewState: PreviewLoadState + public let loadedPreview: LinkPreviewDataDTO? + public let cachedURL: URL? + /// Whether `cachedURL` should route to the inline-image fragment. The + /// extension-based classification minus any URL the view model has since + /// discovered serves an HTML page (and so must reroute to link preview). + /// Precomputed at build time so the pure builder consumes one decision. + public let isInlineImageURL: Bool + public let hasInlineImageRef: Bool + public let hasPreviewImageRef: Bool + public let hasPreviewIconRef: Bool + public let imageIsGIF: Bool + /// Cached width-over-height ratio for `cachedURL` when it points to an + /// inline image. Resolved at build time so the bubble can reserve the + /// correct frame on first paint. `nil` for non-image URLs or when the + /// dimensions store has not yet seen this URL. + public let inlineImageAspect: Double? + /// Latitude of the first linkified coordinate in the message text, or nil. + /// Drives the `.mapPreview` fragment. Stored as `Double` for `Hashable`. + public let mapPreviewLatitude: Double? + public let mapPreviewLongitude: Double? + /// True once the snapshot for this coordinate's `(rounded lat/lon, isDark)` + /// request has resolved (cached or failed). Build-time value: a + /// `UIHostingConfiguration` cell only re-evaluates when its item changes. + public let isMapPreviewReady: Bool + public let formattedText: AttributedString? + public let baseColor: BaseColorSlot + public let formattedPath: String? + public let senderResolution: NodeNameResolution + public let showTimestamp: Bool + public let showDirectionGap: Bool + public let showSenderName: Bool + public let showNewMessagesDivider: Bool + /// True for the first message of a new calendar day; drives the day separator. + public let showDayDivider: Bool - public init( - messageID: UUID, - previewState: PreviewLoadState, - loadedPreview: LinkPreviewDataDTO?, - cachedURL: URL?, - hasInlineImageRef: Bool, - hasPreviewImageRef: Bool, - hasPreviewIconRef: Bool, - imageIsGIF: Bool, - inlineImageAspect: Double? = nil, - mapPreviewLatitude: Double? = nil, - mapPreviewLongitude: Double? = nil, - isMapPreviewReady: Bool = false, - formattedText: AttributedString?, - baseColor: BaseColorSlot, - formattedPath: String?, - senderResolution: NodeNameResolution, - showTimestamp: Bool, - showDirectionGap: Bool, - showSenderName: Bool, - showNewMessagesDivider: Bool, - showDayDivider: Bool = false - ) { - self.messageID = messageID - self.previewState = previewState - self.loadedPreview = loadedPreview - self.cachedURL = cachedURL - self.hasInlineImageRef = hasInlineImageRef - self.hasPreviewImageRef = hasPreviewImageRef - self.hasPreviewIconRef = hasPreviewIconRef - self.imageIsGIF = imageIsGIF - self.inlineImageAspect = inlineImageAspect - self.mapPreviewLatitude = mapPreviewLatitude - self.mapPreviewLongitude = mapPreviewLongitude - self.isMapPreviewReady = isMapPreviewReady - self.formattedText = formattedText - self.baseColor = baseColor - self.formattedPath = formattedPath - self.senderResolution = senderResolution - self.showTimestamp = showTimestamp - self.showDirectionGap = showDirectionGap - self.showSenderName = showSenderName - self.showNewMessagesDivider = showNewMessagesDivider - self.showDayDivider = showDayDivider - } + public init( + messageID: UUID, + previewState: PreviewLoadState, + loadedPreview: LinkPreviewDataDTO?, + cachedURL: URL?, + isInlineImageURL: Bool, + hasInlineImageRef: Bool, + hasPreviewImageRef: Bool, + hasPreviewIconRef: Bool, + imageIsGIF: Bool, + inlineImageAspect: Double? = nil, + mapPreviewLatitude: Double? = nil, + mapPreviewLongitude: Double? = nil, + isMapPreviewReady: Bool = false, + formattedText: AttributedString?, + baseColor: BaseColorSlot, + formattedPath: String?, + senderResolution: NodeNameResolution, + showTimestamp: Bool, + showDirectionGap: Bool, + showSenderName: Bool, + showNewMessagesDivider: Bool, + showDayDivider: Bool = false + ) { + self.messageID = messageID + self.previewState = previewState + self.loadedPreview = loadedPreview + self.cachedURL = cachedURL + self.isInlineImageURL = isInlineImageURL + self.hasInlineImageRef = hasInlineImageRef + self.hasPreviewImageRef = hasPreviewImageRef + self.hasPreviewIconRef = hasPreviewIconRef + self.imageIsGIF = imageIsGIF + self.inlineImageAspect = inlineImageAspect + self.mapPreviewLatitude = mapPreviewLatitude + self.mapPreviewLongitude = mapPreviewLongitude + self.isMapPreviewReady = isMapPreviewReady + self.formattedText = formattedText + self.baseColor = baseColor + self.formattedPath = formattedPath + self.senderResolution = senderResolution + self.showTimestamp = showTimestamp + self.showDirectionGap = showDirectionGap + self.showSenderName = showSenderName + self.showNewMessagesDivider = showNewMessagesDivider + self.showDayDivider = showDayDivider + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift index f92f17af..0cb47c37 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift @@ -11,80 +11,80 @@ import Foundation /// leave bubbles displaying stale strings until a rebuild. The view body /// resolves the localized text at render time. public struct MessageFooter: Sendable, Hashable { - public let showHop: Bool - public let hopCount: Int - public let formattedPath: String? - public let regionToShow: String? - /// Send time to display inside the bubble; nil means do not show it. Holds the - /// clock-corrected `senderDate`, so a skewed sender clock never surfaces a - /// misleading time here — `sendTimeWasCorrected` flags the substitution and the - /// raw wire value stays available in the message info sheet. Stored as a `Date`, - /// not a formatted string, so a mid-session 12h/24h or language change reformats - /// at render time without rebaking items — the same rule that keeps `status` a - /// raw enum here. - public let sendTimeToShow: Date? - /// Whether the app substituted a corrected `timestamp` because the sender's - /// wire clock was invalid. Only meaningful when `sendTimeToShow != nil`; - /// drives the warning badge next to the time. - public let sendTimeWasCorrected: Bool - public let showStatusRow: Bool - public let status: MessageStatus - /// Distinguishes a channel broadcast from a DM at render time. `BubbleStatusRow` - /// uses it so a DM's transient `.sent` reads as "Sending..." while a channel's - /// `.sent` stays terminal success. Render-only, never persisted. - public let isChannelMessage: Bool - public let heardRepeats: Int - public let retryAttempt: Int - public let maxRetryAttempts: Int - public let sendCount: Int + public let showHop: Bool + public let hopCount: Int + public let formattedPath: String? + public let regionToShow: String? + /// Send time to display inside the bubble; nil means do not show it. Holds the + /// clock-corrected `senderDate`, so a skewed sender clock never surfaces a + /// misleading time here — `sendTimeWasCorrected` flags the substitution and the + /// raw wire value stays available in the message info sheet. Stored as a `Date`, + /// not a formatted string, so a mid-session 12h/24h or language change reformats + /// at render time without rebaking items — the same rule that keeps `status` a + /// raw enum here. + public let sendTimeToShow: Date? + /// Whether the app substituted a corrected `timestamp` because the sender's + /// wire clock was invalid. Only meaningful when `sendTimeToShow != nil`; + /// drives the warning badge next to the time. + public let sendTimeWasCorrected: Bool + public let showStatusRow: Bool + public let status: MessageStatus + /// Distinguishes a channel broadcast from a DM at render time. `BubbleStatusRow` + /// uses it so a DM's transient `.sent` reads as "Sending..." while a channel's + /// `.sent` stays terminal success. Render-only, never persisted. + public let isChannelMessage: Bool + public let heardRepeats: Int + public let retryAttempt: Int + public let maxRetryAttempts: Int + public let sendCount: Int - public init( - showHop: Bool, - hopCount: Int, - formattedPath: String?, - regionToShow: String?, - sendTimeToShow: Date?, - sendTimeWasCorrected: Bool, - showStatusRow: Bool, - status: MessageStatus, - isChannelMessage: Bool, - heardRepeats: Int, - retryAttempt: Int, - maxRetryAttempts: Int, - sendCount: Int - ) { - self.showHop = showHop - self.hopCount = hopCount - self.formattedPath = formattedPath - self.regionToShow = regionToShow - self.sendTimeToShow = sendTimeToShow - self.sendTimeWasCorrected = sendTimeWasCorrected - self.showStatusRow = showStatusRow - self.status = status - self.isChannelMessage = isChannelMessage - self.heardRepeats = heardRepeats - self.retryAttempt = retryAttempt - self.maxRetryAttempts = maxRetryAttempts - self.sendCount = sendCount - } + public init( + showHop: Bool, + hopCount: Int, + formattedPath: String?, + regionToShow: String?, + sendTimeToShow: Date?, + sendTimeWasCorrected: Bool, + showStatusRow: Bool, + status: MessageStatus, + isChannelMessage: Bool, + heardRepeats: Int, + retryAttempt: Int, + maxRetryAttempts: Int, + sendCount: Int + ) { + self.showHop = showHop + self.hopCount = hopCount + self.formattedPath = formattedPath + self.regionToShow = regionToShow + self.sendTimeToShow = sendTimeToShow + self.sendTimeWasCorrected = sendTimeWasCorrected + self.showStatusRow = showStatusRow + self.status = status + self.isChannelMessage = isChannelMessage + self.heardRepeats = heardRepeats + self.retryAttempt = retryAttempt + self.maxRetryAttempts = maxRetryAttempts + self.sendCount = sendCount + } - /// Returns a new footer with `status` overridden. Eliminates the 10-field - /// rebuild at status-flip sites. - public func with(status: MessageStatus) -> MessageFooter { - MessageFooter( - showHop: showHop, - hopCount: hopCount, - formattedPath: formattedPath, - regionToShow: regionToShow, - sendTimeToShow: sendTimeToShow, - sendTimeWasCorrected: sendTimeWasCorrected, - showStatusRow: showStatusRow, - status: status, - isChannelMessage: isChannelMessage, - heardRepeats: heardRepeats, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts, - sendCount: sendCount - ) - } + /// Returns a new footer with `status` overridden. Eliminates the 10-field + /// rebuild at status-flip sites. + public func with(status: MessageStatus) -> MessageFooter { + MessageFooter( + showHop: showHop, + hopCount: hopCount, + formattedPath: formattedPath, + regionToShow: regionToShow, + sendTimeToShow: sendTimeToShow, + sendTimeWasCorrected: sendTimeWasCorrected, + showStatusRow: showStatusRow, + status: status, + isChannelMessage: isChannelMessage, + heardRepeats: heardRepeats, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts, + sendCount: sendCount + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFragment.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFragment.swift index af7c8f66..41bd4665 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFragment.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFragment.swift @@ -7,15 +7,15 @@ import Foundation /// `MessageText` because module `MC1` already exports a SwiftUI view named /// `MessageText`. public enum MessageFragment: Sendable, Hashable { - case text(MessageTextPayload) - case inlineImage(InlineImage) - case linkPreview(LinkPreviewFragmentState) - case mapPreview(MapPreviewFragmentState) - case malwareWarning(URL) - /// Carries the raw summary string (`"👍:3,❤️:2,😂:1"` format produced by - /// `ReactionParser`). The DTO field `reactionSummary` is `String?`; the - /// fragment is only emitted when the value is non-nil and non-empty, so - /// the payload is a non-optional `String`. Parsing back into per-emoji - /// counts happens at render time. - case reactionSummary(String) + case text(MessageTextPayload) + case inlineImage(InlineImage) + case linkPreview(LinkPreviewFragmentState) + case mapPreview(MapPreviewFragmentState) + case malwareWarning(URL) + /// Carries the raw summary string (`"👍:3,❤️:2,😂:1"` format produced by + /// `ReactionParser`). The DTO field `reactionSummary` is `String?`; the + /// fragment is only emitted when the value is non-nil and non-empty, so + /// the payload is a non-optional `String`. Parsing back into per-emoji + /// counts happens at render time. + case reactionSummary(String) } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift index f3c62e17..da261f3c 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift @@ -17,52 +17,52 @@ import Foundation /// return stale renders. Verify the rebuild path still writes the full set of /// inputs before changing this invariant. public struct MessageItem: Identifiable, Sendable, Hashable { - public let id: UUID - public let envelope: MessageEnvelope - public let content: [MessageFragment] - public let footer: MessageFooter - public let grouping: GroupingFlags - public let shouldRequestPreviewFetch: Bool + public let id: UUID + public let envelope: MessageEnvelope + public let content: [MessageFragment] + public let footer: MessageFooter + public let grouping: GroupingFlags + public let shouldRequestPreviewFetch: Bool - public init( - id: UUID, - envelope: MessageEnvelope, - content: [MessageFragment], - footer: MessageFooter, - grouping: GroupingFlags, - shouldRequestPreviewFetch: Bool - ) { - self.id = id - self.envelope = envelope - self.content = content - self.footer = footer - self.grouping = grouping - self.shouldRequestPreviewFetch = shouldRequestPreviewFetch - } + public init( + id: UUID, + envelope: MessageEnvelope, + content: [MessageFragment], + footer: MessageFooter, + grouping: GroupingFlags, + shouldRequestPreviewFetch: Bool + ) { + self.id = id + self.envelope = envelope + self.content = content + self.footer = footer + self.grouping = grouping + self.shouldRequestPreviewFetch = shouldRequestPreviewFetch + } - /// Message-scoped identity for the bubble's preview-fetch `.task(id:)`. Holds - /// the message id while a fetch is wanted, `nil` otherwise. The task keys on - /// this rather than the bare `shouldRequestPreviewFetch` flag because a reused - /// `UIHostingConfiguration` cell preserves the task's last id: two successive - /// fetch-wanting messages both keyed on `true` produce no id edge, so the - /// second message's fetch would never start. A per-message id forces the edge. - public var previewFetchTaskID: UUID? { - shouldRequestPreviewFetch ? id : nil - } + /// Message-scoped identity for the bubble's preview-fetch `.task(id:)`. Holds + /// the message id while a fetch is wanted, `nil` otherwise. The task keys on + /// this rather than the bare `shouldRequestPreviewFetch` flag because a reused + /// `UIHostingConfiguration` cell preserves the task's last id: two successive + /// fetch-wanting messages both keyed on `true` produce no id edge, so the + /// second message's fetch would never start. A per-message id forces the edge. + public var previewFetchTaskID: UUID? { + shouldRequestPreviewFetch ? id : nil + } - /// Returns a new item with the supplied envelope and/or footer overridden. - /// Eliminates the 6-field rebuild at single-row mutation sites. - public func with( - envelope: MessageEnvelope? = nil, - footer: MessageFooter? = nil - ) -> MessageItem { - MessageItem( - id: id, - envelope: envelope ?? self.envelope, - content: content, - footer: footer ?? self.footer, - grouping: grouping, - shouldRequestPreviewFetch: shouldRequestPreviewFetch - ) - } + /// Returns a new item with the supplied envelope and/or footer overridden. + /// Eliminates the 6-field rebuild at single-row mutation sites. + public func with( + envelope: MessageEnvelope? = nil, + footer: MessageFooter? = nil + ) -> MessageItem { + MessageItem( + id: id, + envelope: envelope ?? self.envelope, + content: content, + footer: footer ?? self.footer, + grouping: grouping, + shouldRequestPreviewFetch: shouldRequestPreviewFetch + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift index 561a4c1c..b938b8f7 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift @@ -1,23 +1,23 @@ import Foundation public struct MessageTextPayload: Sendable, Hashable { - public let raw: String - public let formatted: AttributedString? - public let baseColor: BaseColorSlot - public let isOutgoing: Bool - public let currentUserName: String + public let raw: String + public let formatted: AttributedString? + public let baseColor: BaseColorSlot + public let isOutgoing: Bool + public let currentUserName: String - public init( - raw: String, - formatted: AttributedString?, - baseColor: BaseColorSlot, - isOutgoing: Bool, - currentUserName: String - ) { - self.raw = raw - self.formatted = formatted - self.baseColor = baseColor - self.isOutgoing = isOutgoing - self.currentUserName = currentUserName - } + public init( + raw: String, + formatted: AttributedString?, + baseColor: BaseColorSlot, + isOutgoing: Bool, + currentUserName: String + ) { + self.raw = raw + self.formatted = formatted + self.baseColor = baseColor + self.isOutgoing = isOutgoing + self.currentUserName = currentUserName + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/NodeNameResolution.swift b/MC1Services/Sources/MC1Services/Models/Rendering/NodeNameResolution.swift index cb82491d..29e49a46 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/NodeNameResolution.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/NodeNameResolution.swift @@ -3,24 +3,33 @@ import Foundation /// Whether a name resolution corresponds to an exact public-key match, a /// proximity fallback within a hash-prefix collision set, or no match at all. public enum NodeNameMatchKind: Sendable, Equatable, Hashable { - case exact - case fallback - case unresolved + case exact + case fallback + case unresolved } /// Resolution result for a sender node name. Pairs the resolved display name /// with the confidence level (`matchKind`) so the caller can disambiguate /// exact identity from proximity-based guesses. public struct NodeNameResolution: Sendable, Equatable, Hashable { - public let displayName: String - public let matchKind: NodeNameMatchKind + public let displayName: String + public let matchKind: NodeNameMatchKind + /// Nickname for a channel sender matched by name only, so identity is + /// unverified. Non-nil solely on the channel-sender resolution path; always + /// nil for repeater and mesh-path resolutions. + public let unverifiedNickname: String? - public var isFallback: Bool { - matchKind == .fallback - } + public var isFallback: Bool { + matchKind == .fallback + } - public init(displayName: String, matchKind: NodeNameMatchKind) { - self.displayName = displayName - self.matchKind = matchKind - } + public init( + displayName: String, + matchKind: NodeNameMatchKind, + unverifiedNickname: String? = nil + ) { + self.displayName = displayName + self.matchKind = matchKind + self.unverifiedNickname = unverifiedNickname + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/PreviewLoadState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/PreviewLoadState.swift index 46314596..8145f631 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/PreviewLoadState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/PreviewLoadState.swift @@ -2,10 +2,10 @@ import Foundation /// State of link preview loading for a message. public enum PreviewLoadState: Sendable, Hashable { - case idle - case loading - case loaded - case noPreview - case disabled - case malwareWarning + case idle + case loading + case loaded + case noPreview + case disabled + case malwareWarning } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/SNRQuality.swift b/MC1Services/Sources/MC1Services/Models/Rendering/SNRQuality.swift index be1fd9d9..7bfd48a1 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/SNRQuality.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/SNRQuality.swift @@ -2,39 +2,39 @@ /// /// Standard 4-tier scale for signal quality indicators across the app. public enum SNRQuality: Sendable, Equatable { - case excellent // SNR > +6 dB - case good // SNR > +0 dB - case fair // SNR > -6 dB - case poor // SNR <= -6 dB - case unknown // nil SNR + case excellent // SNR > +6 dB + case good // SNR > +0 dB + case fair // SNR > -6 dB + case poor // SNR <= -6 dB + case unknown // nil SNR - public init(snr: Double?) { - guard let snr else { - self = .unknown - return - } - if snr > 6 { self = .excellent } else if snr > 0 { self = .good } else if snr > -6 { self = .fair } else { self = .poor } + public init(snr: Double?) { + guard let snr else { + self = .unknown + return } + if snr > 6 { self = .excellent } else if snr > 0 { self = .good } else if snr > -6 { self = .fair } else { self = .poor } + } - /// Bar level for SF Symbol `cellularbars` variableValue (0–1). - public var barLevel: Double { - switch self { - case .excellent: 1.0 - case .good: 0.75 - case .fair: 0.5 - case .poor: 0.25 - case .unknown: 0 - } + /// Bar level for SF Symbol `cellularbars` variableValue (0–1). + public var barLevel: Double { + switch self { + case .excellent: 1.0 + case .good: 0.75 + case .fair: 0.5 + case .poor: 0.25 + case .unknown: 0 } + } - /// Developer-facing English label for logs; UI uses the app target's localized `SNRQuality.localizedLabel`. - public var qualityLabel: String { - switch self { - case .excellent: "Excellent" - case .good: "Good" - case .fair: "Fair" - case .poor: "Weak" - case .unknown: "Unknown" - } + /// Developer-facing English label for logs; UI uses the app target's localized `SNRQuality.localizedLabel`. + public var qualityLabel: String { + switch self { + case .excellent: "Excellent" + case .good: "Good" + case .fair: "Fair" + case .poor: "Weak" + case .unknown: "Unknown" } + } } diff --git a/MC1Services/Sources/MC1Services/Models/RoomMessage.swift b/MC1Services/Sources/MC1Services/Models/RoomMessage.swift index e9b42183..a385a11a 100644 --- a/MC1Services/Sources/MC1Services/Models/RoomMessage.swift +++ b/MC1Services/Sources/MC1Services/Models/RoomMessage.swift @@ -5,219 +5,219 @@ import SwiftData /// Represents a message in a room server conversation. @Model public final class RoomMessage { - #Index( - [\.sessionID, \.timestamp], - [\.sessionID, \.deduplicationKey] + #Index( + [\.sessionID, \.timestamp], + [\.sessionID, \.deduplicationKey] + ) + + /// Unique message identifier + @Attribute(.unique) + public var id: UUID + + /// References RemoteNodeSession.id + public var sessionID: UUID + + /// 4-byte original author's public key prefix from server push + public var authorKeyPrefix: Data + + /// Resolved author name (from contacts or nil) + public var authorName: String? + + /// Message text content + public var text: String + + /// Message timestamp (server time) + public var timestamp: UInt32 + + /// Local creation date + public var createdAt: Date + + /// Whether this message was posted by the current user + public var isFromSelf: Bool + + /// Deduplication key combining timestamp, author, and content hash + /// Format: "\(timestamp)-\(authorPrefixHex)-\(contentHashPrefix)" + public var deduplicationKey: String + + /// Message delivery status (uses MessageStatus enum) + public var statusRawValue: Int = MessageStatus.delivered.rawValue + + /// ACK code returned by MeshCore when message was sent + public var ackCode: UInt32? + + /// Round-trip time in milliseconds when ACK received + public var roundTripTime: UInt32? + + /// Current retry attempt number + public var retryAttempt: Int = 0 + + /// Maximum retry attempts configured + public var maxRetryAttempts: Int = 0 + + public init( + id: UUID = UUID(), + sessionID: UUID, + authorKeyPrefix: Data, + authorName: String? = nil, + text: String, + timestamp: UInt32, + isFromSelf: Bool = false, + status: MessageStatus = .delivered + ) { + self.id = id + self.sessionID = sessionID + self.authorKeyPrefix = authorKeyPrefix + self.authorName = authorName + self.text = text + self.timestamp = timestamp + createdAt = Date() + self.isFromSelf = isFromSelf + statusRawValue = status.rawValue + deduplicationKey = Self.generateDeduplicationKey( + timestamp: timestamp, + authorKeyPrefix: authorKeyPrefix, + text: text ) - - /// Unique message identifier - @Attribute(.unique) - public var id: UUID - - /// References RemoteNodeSession.id - public var sessionID: UUID - - /// 4-byte original author's public key prefix from server push - public var authorKeyPrefix: Data - - /// Resolved author name (from contacts or nil) - public var authorName: String? - - /// Message text content - public var text: String - - /// Message timestamp (server time) - public var timestamp: UInt32 - - /// Local creation date - public var createdAt: Date - - /// Whether this message was posted by the current user - public var isFromSelf: Bool - - /// Deduplication key combining timestamp, author, and content hash - /// Format: "\(timestamp)-\(authorPrefixHex)-\(contentHashPrefix)" - public var deduplicationKey: String - - /// Message delivery status (uses MessageStatus enum) - public var statusRawValue: Int = MessageStatus.delivered.rawValue - - /// ACK code returned by MeshCore when message was sent - public var ackCode: UInt32? - - /// Round-trip time in milliseconds when ACK received - public var roundTripTime: UInt32? - - /// Current retry attempt number - public var retryAttempt: Int = 0 - - /// Maximum retry attempts configured - public var maxRetryAttempts: Int = 0 - - public init( - id: UUID = UUID(), - sessionID: UUID, - authorKeyPrefix: Data, - authorName: String? = nil, - text: String, - timestamp: UInt32, - isFromSelf: Bool = false, - status: MessageStatus = .delivered - ) { - self.id = id - self.sessionID = sessionID - self.authorKeyPrefix = authorKeyPrefix - self.authorName = authorName - self.text = text - self.timestamp = timestamp - self.createdAt = Date() - self.isFromSelf = isFromSelf - self.statusRawValue = status.rawValue - self.deduplicationKey = Self.generateDeduplicationKey( - timestamp: timestamp, - authorKeyPrefix: authorKeyPrefix, - text: text - ) - } - - /// Builds a model instance directly from a DTO, preserving the exact - /// createdAt, deduplicationKey, and delivery metadata from the source. - public convenience init(dto: RoomMessageDTO) { - self.init( - id: dto.id, - sessionID: dto.sessionID, - authorKeyPrefix: dto.authorKeyPrefix, - authorName: dto.authorName, - text: dto.text, - timestamp: dto.timestamp, - isFromSelf: dto.isFromSelf, - status: dto.status - ) - createdAt = dto.createdAt - deduplicationKey = dto.deduplicationKey - ackCode = dto.ackCode - roundTripTime = dto.roundTripTime - retryAttempt = dto.retryAttempt - maxRetryAttempts = dto.maxRetryAttempts - } + } + + /// Builds a model instance directly from a DTO, preserving the exact + /// createdAt, deduplicationKey, and delivery metadata from the source. + public convenience init(dto: RoomMessageDTO) { + self.init( + id: dto.id, + sessionID: dto.sessionID, + authorKeyPrefix: dto.authorKeyPrefix, + authorName: dto.authorName, + text: dto.text, + timestamp: dto.timestamp, + isFromSelf: dto.isFromSelf, + status: dto.status + ) + createdAt = dto.createdAt + deduplicationKey = dto.deduplicationKey + ackCode = dto.ackCode + roundTripTime = dto.roundTripTime + retryAttempt = dto.retryAttempt + maxRetryAttempts = dto.maxRetryAttempts + } } // MARK: - Computed Properties public extension RoomMessage { - /// Display name for author (resolved name or hex prefix) - var authorDisplayName: String { - authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() - } - - /// Date representation of timestamp - var date: Date { - Date(timeIntervalSince1970: TimeInterval(timestamp)) - } - - /// Delivery status - var status: MessageStatus { - MessageStatus(rawValue: statusRawValue) ?? .delivered - } + /// Display name for author (resolved name or hex prefix) + var authorDisplayName: String { + authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() + } + + /// Date representation of timestamp + var date: Date { + Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + /// Delivery status + var status: MessageStatus { + MessageStatus(rawValue: statusRawValue) ?? .delivered + } } // MARK: - Deduplication public extension RoomMessage { - /// Generate a deduplication key for message uniqueness. - /// Uses timestamp + author prefix + first 8 chars of content hash. - static func generateDeduplicationKey( - timestamp: UInt32, - authorKeyPrefix: Data, - text: String - ) -> String { - let authorHex = authorKeyPrefix.map { String(format: "%02X", $0) }.joined() - let contentHash = SHA256.hash(data: Data(text.utf8)) - let hashPrefix = contentHash.prefix(4).map { String(format: "%02X", $0) }.joined() - return "\(timestamp)-\(authorHex)-\(hashPrefix)" - } + /// Generate a deduplication key for message uniqueness. + /// Uses timestamp + author prefix + first 8 chars of content hash. + static func generateDeduplicationKey( + timestamp: UInt32, + authorKeyPrefix: Data, + text: String + ) -> String { + let authorHex = authorKeyPrefix.map { String(format: "%02X", $0) }.joined() + let contentHash = SHA256.hash(data: Data(text.utf8)) + let hashPrefix = contentHash.prefix(4).map { String(format: "%02X", $0) }.joined() + return "\(timestamp)-\(authorHex)-\(hashPrefix)" + } } // MARK: - Sendable DTO /// A sendable snapshot of RoomMessage for cross-actor transfers public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codable { - public let id: UUID - public var sessionID: UUID - public let authorKeyPrefix: Data - public let authorName: String? - public let text: String - public let timestamp: UInt32 - public let createdAt: Date - public let isFromSelf: Bool - public let deduplicationKey: String - public let statusRawValue: Int - public let ackCode: UInt32? - public let roundTripTime: UInt32? - public let retryAttempt: Int - public let maxRetryAttempts: Int - - public init(from model: RoomMessage) { - self.id = model.id - self.sessionID = model.sessionID - self.authorKeyPrefix = model.authorKeyPrefix - self.authorName = model.authorName - self.text = model.text - self.timestamp = model.timestamp - self.createdAt = model.createdAt - self.isFromSelf = model.isFromSelf - self.deduplicationKey = model.deduplicationKey - self.statusRawValue = model.statusRawValue - self.ackCode = model.ackCode - self.roundTripTime = model.roundTripTime - self.retryAttempt = model.retryAttempt - self.maxRetryAttempts = model.maxRetryAttempts - } - - public init( - id: UUID = UUID(), - sessionID: UUID, - authorKeyPrefix: Data, - authorName: String? = nil, - text: String, - timestamp: UInt32, - createdAt: Date = Date(), - isFromSelf: Bool = false, - status: MessageStatus = .delivered, - ackCode: UInt32? = nil, - roundTripTime: UInt32? = nil, - retryAttempt: Int = 0, - maxRetryAttempts: Int = 0 - ) { - self.id = id - self.sessionID = sessionID - self.authorKeyPrefix = authorKeyPrefix - self.authorName = authorName - self.text = text - self.timestamp = timestamp - self.createdAt = createdAt - self.isFromSelf = isFromSelf - self.statusRawValue = status.rawValue - self.ackCode = ackCode - self.roundTripTime = roundTripTime - self.retryAttempt = retryAttempt - self.maxRetryAttempts = maxRetryAttempts - self.deduplicationKey = RoomMessage.generateDeduplicationKey( - timestamp: timestamp, - authorKeyPrefix: authorKeyPrefix, - text: text - ) - } - - public var authorDisplayName: String { - authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() - } - - public var date: Date { - Date(timeIntervalSince1970: TimeInterval(timestamp)) - } - - public var status: MessageStatus { - MessageStatus(rawValue: statusRawValue) ?? .delivered - } + public let id: UUID + public var sessionID: UUID + public let authorKeyPrefix: Data + public let authorName: String? + public let text: String + public let timestamp: UInt32 + public let createdAt: Date + public let isFromSelf: Bool + public let deduplicationKey: String + public let statusRawValue: Int + public let ackCode: UInt32? + public let roundTripTime: UInt32? + public let retryAttempt: Int + public let maxRetryAttempts: Int + + public init(from model: RoomMessage) { + id = model.id + sessionID = model.sessionID + authorKeyPrefix = model.authorKeyPrefix + authorName = model.authorName + text = model.text + timestamp = model.timestamp + createdAt = model.createdAt + isFromSelf = model.isFromSelf + deduplicationKey = model.deduplicationKey + statusRawValue = model.statusRawValue + ackCode = model.ackCode + roundTripTime = model.roundTripTime + retryAttempt = model.retryAttempt + maxRetryAttempts = model.maxRetryAttempts + } + + public init( + id: UUID = UUID(), + sessionID: UUID, + authorKeyPrefix: Data, + authorName: String? = nil, + text: String, + timestamp: UInt32, + createdAt: Date = Date(), + isFromSelf: Bool = false, + status: MessageStatus = .delivered, + ackCode: UInt32? = nil, + roundTripTime: UInt32? = nil, + retryAttempt: Int = 0, + maxRetryAttempts: Int = 0 + ) { + self.id = id + self.sessionID = sessionID + self.authorKeyPrefix = authorKeyPrefix + self.authorName = authorName + self.text = text + self.timestamp = timestamp + self.createdAt = createdAt + self.isFromSelf = isFromSelf + statusRawValue = status.rawValue + self.ackCode = ackCode + self.roundTripTime = roundTripTime + self.retryAttempt = retryAttempt + self.maxRetryAttempts = maxRetryAttempts + deduplicationKey = RoomMessage.generateDeduplicationKey( + timestamp: timestamp, + authorKeyPrefix: authorKeyPrefix, + text: text + ) + } + + public var authorDisplayName: String { + authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() + } + + public var date: Date { + Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + public var status: MessageStatus { + MessageStatus(rawValue: statusRawValue) ?? .delivered + } } diff --git a/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift b/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift index c8c9d902..f65e2226 100644 --- a/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift +++ b/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift @@ -6,290 +6,296 @@ import SwiftData /// SwiftData model for persisted RX log packets. @Model final class RxLogEntry { - #Index( - [\.channelIndex, \.senderTimestamp], - [\.radioID, \.receivedAt] - ) - - @Attribute(.unique) - var id: UUID - - @Attribute(originalName: "deviceID") - var radioID: UUID - - var receivedAt: Date - - // From MeshCore ParsedRxLogData - var snr: Double? - var rssi: Int? - var routeType: Int - var payloadType: Int - var payloadVersion: Int - var transportCode: Data? - var pathLength: Int - var pathNodes: Data // Raw bytes, 1 byte per hop - var packetPayload: Data - var rawPayload: Data - - // Correlation key for "heard repeats" - var packetHash: String - - // App-level decoding - @Attribute(originalName: "channelHash") - var channelIndex: Int? - var channelName: String? - var decryptStatus: Int - var fromContactName: String? - var toContactName: String? - - /// Sender's timestamp from decrypted payload (Unix epoch seconds). - /// Only available for successfully decrypted channel messages. - var senderTimestamp: Int? - - /// Resolved flood region the sender transmitted under, derived from - /// `transport_codes[0]` at receive time. Nil when no known region matches. - /// Local-only: not part of any backup envelope. - var regionScope: String? - - /// Raw 4-bit payload-type nibble from the wire header. Persisted so the - /// region resolver can replay the exact firmware HMAC input on back-fill, - /// even for header values that map to `PayloadType.unknown`. - var payloadTypeBits: Int = 0 - - // Privacy: Never persisted — decrypted on demand - @Transient - var decodedText: String? - - init( - id: UUID = UUID(), - radioID: UUID, - receivedAt: Date = Date(), - snr: Double? = nil, - rssi: Int? = nil, - routeType: Int, - payloadType: Int, - payloadVersion: Int, - transportCode: Data? = nil, - pathLength: Int, - pathNodes: Data, - packetPayload: Data, - rawPayload: Data, - packetHash: String, - channelIndex: Int? = nil, - channelName: String? = nil, - decryptStatus: Int = DecryptStatus.notApplicable.rawValue, - fromContactName: String? = nil, - toContactName: String? = nil, - senderTimestamp: Int? = nil, - regionScope: String? = nil, - payloadTypeBits: Int = 0 - ) { - self.id = id - self.radioID = radioID - self.receivedAt = receivedAt - self.snr = snr - self.rssi = rssi - self.routeType = routeType - self.payloadType = payloadType - self.payloadVersion = payloadVersion - self.transportCode = transportCode - self.pathLength = pathLength - self.pathNodes = pathNodes - self.packetPayload = packetPayload - self.rawPayload = rawPayload - self.packetHash = packetHash - self.channelIndex = channelIndex - self.channelName = channelName - self.decryptStatus = decryptStatus - self.fromContactName = fromContactName - self.toContactName = toContactName - self.senderTimestamp = senderTimestamp - self.regionScope = regionScope - self.payloadTypeBits = payloadTypeBits - } + #Index( + [\.channelIndex, \.senderTimestamp], + [\.radioID, \.receivedAt] + ) + + @Attribute(.unique) + var id: UUID + + @Attribute(originalName: "deviceID") + var radioID: UUID + + var receivedAt: Date + + // From MeshCore ParsedRxLogData + var snr: Double? + var rssi: Int? + var routeType: Int + var payloadType: Int + var payloadVersion: Int + var transportCode: Data? + var pathLength: Int + var pathNodes: Data // Raw bytes, 1 byte per hop + var packetPayload: Data + var rawPayload: Data + + /// Correlation key for "heard repeats" + var packetHash: String + + // App-level decoding + @Attribute(originalName: "channelHash") + var channelIndex: Int? + var channelName: String? + var decryptStatus: Int + var fromContactName: String? + var toContactName: String? + + /// Sender's timestamp from decrypted payload (Unix epoch seconds). + /// Only available for successfully decrypted channel messages. + var senderTimestamp: Int? + + /// Resolved flood region the sender transmitted under, derived from + /// `transport_codes[0]` at receive time. Nil when no known region matches. + /// Local-only: not part of any backup envelope. + var regionScope: String? + + /// Raw 4-bit payload-type nibble from the wire header. Persisted so the + /// region resolver can replay the exact firmware HMAC input on back-fill, + /// even for header values that map to `PayloadType.unknown`. + var payloadTypeBits: Int = 0 + + // Privacy: Never persisted — decrypted on demand + @Transient + var decodedText: String? + + init( + id: UUID = UUID(), + radioID: UUID, + receivedAt: Date = Date(), + snr: Double? = nil, + rssi: Int? = nil, + routeType: Int, + payloadType: Int, + payloadVersion: Int, + transportCode: Data? = nil, + pathLength: Int, + pathNodes: Data, + packetPayload: Data, + rawPayload: Data, + packetHash: String, + channelIndex: Int? = nil, + channelName: String? = nil, + decryptStatus: Int = DecryptStatus.notApplicable.rawValue, + fromContactName: String? = nil, + toContactName: String? = nil, + senderTimestamp: Int? = nil, + regionScope: String? = nil, + payloadTypeBits: Int = 0 + ) { + self.id = id + self.radioID = radioID + self.receivedAt = receivedAt + self.snr = snr + self.rssi = rssi + self.routeType = routeType + self.payloadType = payloadType + self.payloadVersion = payloadVersion + self.transportCode = transportCode + self.pathLength = pathLength + self.pathNodes = pathNodes + self.packetPayload = packetPayload + self.rawPayload = rawPayload + self.packetHash = packetHash + self.channelIndex = channelIndex + self.channelName = channelName + self.decryptStatus = decryptStatus + self.fromContactName = fromContactName + self.toContactName = toContactName + self.senderTimestamp = senderTimestamp + self.regionScope = regionScope + self.payloadTypeBits = payloadTypeBits + } } /// Sendable DTO for cross-actor transfer of RxLogEntry data. public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { - public let id: UUID - public var radioID: UUID - public let receivedAt: Date - public let snr: Double? - public let rssi: Int? - public let routeType: RouteType - public let payloadType: PayloadType - public let payloadVersion: UInt8 - public let transportCode: Data? - public let pathLength: UInt8 - public let pathNodes: Data - public let packetPayload: Data - public let rawPayload: Data - public let packetHash: String - - /// Channel attribution. Mutable so re-decryption of older entries can - /// record which channel's secret matched. - public var channelIndex: UInt8? - public var channelName: String? - - public let decryptStatus: DecryptStatus - public let fromContactName: String? - public let toContactName: String? - - /// Sender's timestamp from decrypted payload (Unix epoch seconds). - /// Only available for successfully decrypted channel messages. - /// Mutable to allow updating during re-decryption of older entries. - public var senderTimestamp: UInt32? - - /// Resolved flood region the sender transmitted under. Local-only. - public let regionScope: String? - - /// Raw 4-bit payload-type nibble from the wire header (matches firmware - /// HMAC input). Persisted alongside `regionScope` for back-fill replay. - public let payloadTypeBits: UInt8 - - // Transient - set by UI layer after decryption - public var decodedText: String? - - /// Initialize from SwiftData model. - init(from model: RxLogEntry) { - self.id = model.id - self.radioID = model.radioID - self.receivedAt = model.receivedAt - self.snr = model.snr - self.rssi = model.rssi - // Narrow each stored Int through the failable initializer so a corrupt or - // out-of-range row falls back instead of trapping inside the ModelActor. - self.routeType = UInt8(exactly: model.routeType).flatMap(RouteType.init(rawValue:)) ?? .flood - self.payloadType = UInt8(exactly: model.payloadType).flatMap(PayloadType.init(rawValue:)) ?? .unknown - self.payloadVersion = UInt8(exactly: model.payloadVersion) ?? 0 - self.transportCode = model.transportCode - self.pathLength = UInt8(exactly: model.pathLength) ?? 0 - self.pathNodes = model.pathNodes - self.packetPayload = model.packetPayload - self.rawPayload = model.rawPayload - self.packetHash = model.packetHash - self.channelIndex = model.channelIndex.flatMap { UInt8(exactly: $0) } - self.channelName = model.channelName - self.decryptStatus = DecryptStatus(rawValue: model.decryptStatus) ?? .notApplicable - self.fromContactName = model.fromContactName - self.toContactName = model.toContactName - self.senderTimestamp = model.senderTimestamp.flatMap { UInt32(exactly: $0) } - self.regionScope = model.regionScope - self.payloadTypeBits = UInt8(model.payloadTypeBits & 0x0F) - self.decodedText = model.decodedText - } - - /// Initialize from ParsedRxLogData (for new entries). - public init( - id: UUID = UUID(), - radioID: UUID, - receivedAt: Date = Date(), - from parsed: ParsedRxLogData, - channelIndex: UInt8? = nil, - channelName: String? = nil, - decryptStatus: DecryptStatus = .notApplicable, - fromContactName: String? = nil, - toContactName: String? = nil, - senderTimestamp: UInt32? = nil, - regionScope: String? = nil, - decodedText: String? = nil - ) { - self.id = id - self.radioID = radioID - self.receivedAt = receivedAt - self.snr = parsed.snr - self.rssi = parsed.rssi - self.routeType = parsed.routeType - self.payloadType = parsed.payloadType - self.payloadVersion = parsed.payloadVersion - self.transportCode = parsed.transportCode - self.pathLength = parsed.pathLength - self.pathNodes = Data(parsed.pathNodes) - self.packetPayload = parsed.packetPayload - self.rawPayload = parsed.rawPayload - self.packetHash = parsed.packetHash - self.channelIndex = channelIndex - self.channelName = channelName - self.decryptStatus = decryptStatus - self.fromContactName = fromContactName - self.toContactName = toContactName - self.senderTimestamp = senderTimestamp - self.regionScope = regionScope - self.payloadTypeBits = parsed.payloadTypeBits - self.decodedText = decodedText - } - - // MARK: - Computed Properties - - /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits. - public var pathHashSize: Int { - return decodePathLen(pathLength)?.hashSize ?? 1 - } - - /// Hop count decoded from pathLength. - public var hopCount: Int { - return decodePathLen(pathLength)?.hopCount ?? 0 - } - - /// Target node hashes extracted from TRACE payload. - /// Layout: [tag:4][auth:4][flags:1][hashes...], hash size from flags lower 2 bits. - public var traceTargetHashes: [Data]? { - guard payloadType == .trace, packetPayload.count > 9 else { return nil } - let pathSz = Int(packetPayload[8] & 0x03) - let hashSize = 1 << pathSz - let hashBytes = packetPayload.dropFirst(9) - guard !hashBytes.isEmpty, hashBytes.count % hashSize == 0 else { return nil } - return stride(from: hashBytes.startIndex, to: hashBytes.endIndex, by: hashSize).map { start in - Data(hashBytes[start..= dmPrefixSize * 2 else { return nil } - return Data(packetPayload[dmPrefixSize..= dmPrefixSize * 2 else { return nil } - return Data(packetPayload[0.. 9 else { return nil } + let pathSz = Int(packetPayload[8] & 0x03) + let hashSize = 1 << pathSz + let hashBytes = packetPayload.dropFirst(9) + guard !hashBytes.isEmpty, hashBytes.count % hashSize == 0 else { return nil } + return stride(from: hashBytes.startIndex, to: hashBytes.endIndex, by: hashSize).map { start in + Data(hashBytes[start..= dmPrefixSize * 2 else { return nil } + return Data(packetPayload[dmPrefixSize..= dmPrefixSize * 2 else { return nil } + return Data(packetPayload[0.. $1.date } - .prefix(10) - .reversed() - .map { $0.roundTripMs } - } - - /// The path as array of hash bytes - public var pathHashBytes: [UInt8] { - Array(pathBytes) - } + public let id: UUID + public var radioID: UUID + public let name: String + public let pathBytes: Data + public let hashSize: Int + public let createdDate: Date + public let runs: [TracePathRunDTO] + + init(from model: SavedTracePath) { + id = model.id + radioID = model.radioID + name = model.name + pathBytes = model.pathBytes + hashSize = model.hashSize + createdDate = model.createdDate + runs = model.runs.map { TracePathRunDTO(from: $0) } + } + + public init( + id: UUID, + radioID: UUID, + name: String, + pathBytes: Data, + hashSize: Int = 1, + createdDate: Date, + runs: [TracePathRunDTO] + ) { + self.id = id + self.radioID = radioID + self.name = name + self.pathBytes = pathBytes + self.hashSize = hashSize + self.createdDate = createdDate + self.runs = runs + } + + public var runCount: Int { + runs.count + } + + public var lastRunDate: Date? { + runs.max(by: { $0.date < $1.date })?.date + } + + public var averageRoundTripMs: Int? { + let successful = runs.filter(\.success) + guard !successful.isEmpty else { return nil } + let total = successful.reduce(0) { $0 + $1.roundTripMs } + return total / successful.count + } + + public var successRate: Int { + guard !runs.isEmpty else { return 100 } + let successful = runs.count(where: { $0.success }) + return (successful * 100) / runs.count + } + + /// Recent RTT values for sparkline (most recent 10) + public var recentRTTs: [Int] { + runs.filter(\.success) + .sorted { $0.date > $1.date } + .prefix(10) + .reversed() + .map(\.roundTripMs) + } + + /// The path as array of hash bytes + public var pathHashBytes: [UInt8] { + Array(pathBytes) + } } /// Sendable snapshot of TracePathRun public struct TracePathRunDTO: Sendable, Identifiable, Equatable, Hashable, Codable { - public let id: UUID - public let date: Date - public let success: Bool - public let roundTripMs: Int - public let hopsSNR: [Double] - - init(from model: TracePathRun) { - self.id = model.id - self.date = model.date - self.success = model.success - self.roundTripMs = model.roundTripMs - self.hopsSNR = model.hopsSNR - } - - public init(id: UUID, date: Date, success: Bool, roundTripMs: Int, hopsSNR: [Double]) { - self.id = id - self.date = date - self.success = success - self.roundTripMs = roundTripMs - self.hopsSNR = hopsSNR - } + public let id: UUID + public let date: Date + public let success: Bool + public let roundTripMs: Int + public let hopsSNR: [Double] + + init(from model: TracePathRun) { + id = model.id + date = model.date + success = model.success + roundTripMs = model.roundTripMs + hopsSNR = model.hopsSNR + } + + public init(id: UUID, date: Date, success: Bool, roundTripMs: Int, hopsSNR: [Double]) { + self.id = id + self.date = date + self.success = success + self.roundTripMs = roundTripMs + self.hopsSNR = hopsSNR + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/AppStateProvider.swift b/MC1Services/Sources/MC1Services/Protocols/AppStateProvider.swift index 2dd07b61..d1eabe9c 100644 --- a/MC1Services/Sources/MC1Services/Protocols/AppStateProvider.swift +++ b/MC1Services/Sources/MC1Services/Protocols/AppStateProvider.swift @@ -5,7 +5,7 @@ import Foundation /// Injected into services to avoid UIKit dependency in the service layer. /// Property is async to support cross-actor access from non-MainActor contexts. public protocol AppStateProvider: Sendable { - /// Returns true if the app is currently in the foreground (active state). - /// Async to allow MainActor-isolated implementations to be called from other actors. - var isInForeground: Bool { get async } + /// Returns true if the app is currently in the foreground (active state). + /// Async to allow MainActor-isolated implementations to be called from other actors. + var isInForeground: Bool { get async } } diff --git a/MC1Services/Sources/MC1Services/Protocols/ChannelServiceProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/ChannelServiceProtocol.swift index f486ce01..e9aed9ca 100644 --- a/MC1Services/Sources/MC1Services/Protocols/ChannelServiceProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/ChannelServiceProtocol.swift @@ -19,31 +19,30 @@ import MeshCore /// } /// ``` protocol ChannelServiceProtocol: Actor { + // MARK: - Channel Sync - // MARK: - Channel Sync + /// Fetches all channels for a device from the remote device. + /// - Parameters: + /// - radioID: The device UUID + /// - maxChannels: Maximum number of channels to fetch (from device capacity) + /// - usePipelinedRead: When `true`, reads channels via the bounded-window pipeline + /// (nRF52 over BLE, ESP32 over WiFi); when `false`, uses the serial acknowledged path. + /// - Returns: Sync result with number of channels synced + func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult - /// Fetches all channels for a device from the remote device. - /// - Parameters: - /// - radioID: The device UUID - /// - maxChannels: Maximum number of channels to fetch (from device capacity) - /// - usePipelinedRead: When `true`, reads channels via the bounded-window pipeline - /// (nRF52 over BLE, ESP32 over WiFi); when `false`, uses the serial acknowledged path. - /// - Returns: Sync result with number of channels synced - func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult - - /// Retries syncing only the channels that previously failed. - /// - Parameters: - /// - radioID: The device UUID - /// - indices: Channel indices to retry - /// - Returns: Sync result for the retried channels - func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult + /// Retries syncing only the channels that previously failed. + /// - Parameters: + /// - radioID: The device UUID + /// - indices: Channel indices to retry + /// - Returns: Sync result for the retried channels + func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult } extension ChannelServiceProtocol { - /// Convenience that defaults to the serial acknowledged read path. A default argument on - /// the protocol requirement itself is illegal and ignored by witness matching, so the - /// 2-arg form lives here while conformers implement only the 3-arg requirement. - func syncChannels(radioID: UUID, maxChannels: UInt8) async throws -> ChannelSyncResult { - try await syncChannels(radioID: radioID, maxChannels: maxChannels, usePipelinedRead: false) - } + /// Convenience that defaults to the serial acknowledged read path. A default argument on + /// the protocol requirement itself is illegal and ignored by witness matching, so the + /// 2-arg form lives here while conformers implement only the 3-arg requirement. + func syncChannels(radioID: UUID, maxChannels: UInt8) async throws -> ChannelSyncResult { + try await syncChannels(radioID: radioID, maxChannels: maxChannels, usePipelinedRead: false) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/ContactServiceProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/ContactServiceProtocol.swift index 190a740a..d8b9a81d 100644 --- a/MC1Services/Sources/MC1Services/Protocols/ContactServiceProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/ContactServiceProtocol.swift @@ -19,13 +19,12 @@ import MeshCore /// } /// ``` protocol ContactServiceProtocol: Actor { + // MARK: - Contact Sync - // MARK: - Contact Sync - - /// Sync all contacts from device - /// - Parameters: - /// - radioID: The device to sync from - /// - since: Optional date for incremental sync (only contacts modified after this time) - /// - Returns: Sync result with count and timestamp - func syncContacts(radioID: UUID, since: Date?) async throws -> ContactSyncResult + /// Sync all contacts from device + /// - Parameters: + /// - radioID: The device to sync from + /// - since: Optional date for incremental sync (only contacts modified after this time) + /// - Returns: Sync result with count and timestamp + func syncContacts(radioID: UUID, since: Date?) async throws -> ContactSyncResult } diff --git a/MC1Services/Sources/MC1Services/Protocols/MessagePollingServiceProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/MessagePollingServiceProtocol.swift index 869faad2..9e67ee4e 100644 --- a/MC1Services/Sources/MC1Services/Protocols/MessagePollingServiceProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/MessagePollingServiceProtocol.swift @@ -19,39 +19,38 @@ import MeshCore /// } /// ``` protocol MessagePollingServiceProtocol: Actor { + // MARK: - Message Polling - // MARK: - Message Polling + /// Poll all waiting messages from the device. + /// - Returns: Count of messages retrieved + func pollAllMessages() async throws -> Int - /// Poll all waiting messages from the device. - /// - Returns: Count of messages retrieved - func pollAllMessages() async throws -> Int + /// Wait for all pending message handlers to complete. + /// Call this after pollAllMessages() to ensure all messages are fully processed. + func waitForPendingHandlers(timeout: Duration) async -> Bool - /// Wait for all pending message handlers to complete. - /// Call this after pollAllMessages() to ensure all messages are fully processed. - func waitForPendingHandlers(timeout: Duration) async -> Bool + // MARK: - Auto-Fetch Lifecycle - // MARK: - Auto-Fetch Lifecycle + /// Start periodic message auto-fetch for the connected radio. + func startAutoFetch(radioID: UUID) async - /// Start periodic message auto-fetch for the connected radio. - func startAutoFetch(radioID: UUID) async + /// Pause auto-fetch (e.g. while a sync owns the transport). + func pauseAutoFetch() async - /// Pause auto-fetch (e.g. while a sync owns the transport). - func pauseAutoFetch() async + /// Resume auto-fetch after a pause. + func resumeAutoFetch() async - /// Resume auto-fetch after a pause. - func resumeAutoFetch() async + // MARK: - Ingestion Handlers - // MARK: - Ingestion Handlers + /// Install the handler invoked for each incoming direct message. + func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) - /// Install the handler invoked for each incoming direct message. - func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) + /// Install the handler invoked for each incoming channel message. + func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) - /// Install the handler invoked for each incoming channel message. - func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) + /// Install the handler invoked for each incoming signed room message. + func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) - /// Install the handler invoked for each incoming signed room message. - func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) - - /// Install the handler invoked for each incoming CLI response. - func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) + /// Install the handler invoked for each incoming CLI response. + func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) } diff --git a/MC1Services/Sources/MC1Services/Protocols/NotificationStringProvider.swift b/MC1Services/Sources/MC1Services/Protocols/NotificationStringProvider.swift index 8abf0ae1..80d98cca 100644 --- a/MC1Services/Sources/MC1Services/Protocols/NotificationStringProvider.swift +++ b/MC1Services/Sources/MC1Services/Protocols/NotificationStringProvider.swift @@ -5,51 +5,51 @@ import Foundation /// This protocol allows the app layer to inject localized strings into /// MC1Services without the service layer depending on L10n directly. public protocol NotificationStringProvider: Sendable { - /// Returns the notification title for a discovered contact of the given type. - /// - Parameter type: The type of contact discovered - /// - Returns: Localized notification title (e.g., "New Repeater Discovered") - func discoveryNotificationTitle(for type: ContactType) -> String + /// Returns the notification title for a discovered contact of the given type. + /// - Parameter type: The type of contact discovered + /// - Returns: Localized notification title (e.g., "New Repeater Discovered") + func discoveryNotificationTitle(for type: ContactType) -> String - /// Returns the localized title for the "Reply" notification action. - var replyActionTitle: String { get } + /// Returns the localized title for the "Reply" notification action. + var replyActionTitle: String { get } - /// Returns the localized title for the "Send" button in notification quick reply. - var sendButtonTitle: String { get } + /// Returns the localized title for the "Send" button in notification quick reply. + var sendButtonTitle: String { get } - /// Returns the localized placeholder for the notification quick reply text input. - var messagePlaceholder: String { get } + /// Returns the localized placeholder for the notification quick reply text input. + var messagePlaceholder: String { get } - /// Returns the localized title for the "Mark as Read" notification action. - var markAsReadActionTitle: String { get } + /// Returns the localized title for the "Mark as Read" notification action. + var markAsReadActionTitle: String { get } - /// Returns the localized title for a low battery warning notification. - var lowBatteryTitle: String { get } + /// Returns the localized title for a low battery warning notification. + var lowBatteryTitle: String { get } - /// Returns the localized body for a low battery warning notification. - /// - Parameters: - /// - deviceName: The name of the device with low battery - /// - percentage: The current battery percentage - /// - Returns: Localized notification body - func lowBatteryBody(deviceName: String, percentage: Int) -> String + /// Returns the localized body for a low battery warning notification. + /// - Parameters: + /// - deviceName: The name of the device with low battery + /// - percentage: The current battery percentage + /// - Returns: Localized notification body + func lowBatteryBody(deviceName: String, percentage: Int) -> String - /// Returns the localized title for a failed quick-reply notification. - var quickReplyFailedTitle: String { get } + /// Returns the localized title for a failed quick-reply notification. + var quickReplyFailedTitle: String { get } - /// Returns the localized body for a failed quick-reply notification. - /// - Parameter conversationName: The contact or channel display name - /// - Returns: Localized notification body - func quickReplyFailedBody(conversationName: String) -> String + /// Returns the localized body for a failed quick-reply notification. + /// - Parameter conversationName: The contact or channel display name + /// - Returns: Localized notification body + func quickReplyFailedBody(conversationName: String) -> String - /// Returns the localized fallback display name for a discovered contact with no advertised name. - var unknownContactName: String { get } + /// Returns the localized fallback display name for a discovered contact with no advertised name. + var unknownContactName: String { get } - /// Returns the localized fallback display name for a channel with no stored name. - /// - Parameter index: The channel's slot index - func defaultChannelName(index: Int) -> String + /// Returns the localized fallback display name for a channel with no stored name. + /// - Parameter index: The channel's slot index + func defaultChannelName(index: Int) -> String - /// Returns the localized body for a reaction notification. - /// - Parameters: - /// - emoji: The reaction emoji - /// - messagePreview: Truncated preview of the reacted-to message - func reactionNotificationBody(emoji: String, messagePreview: String) -> String + /// Returns the localized body for a reaction notification. + /// - Parameters: + /// - emoji: The reaction emoji + /// - messagePreview: Truncated preview of the reacted-to message + func reactionNotificationBody(emoji: String, messagePreview: String) -> String } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/ChannelPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/ChannelPersisting.swift index 088ea70a..b36337be 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/ChannelPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/ChannelPersisting.swift @@ -3,103 +3,102 @@ import MeshCore /// Store operations for channel rows, channel unread state, and channel mention tracking. public protocol ChannelPersisting: Actor { + // MARK: - Channel Operations - // MARK: - Channel Operations + /// Fetch all channels for a device + func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] - /// Fetch all channels for a device - func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] + /// Fetch a channel by index + func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? - /// Fetch a channel by index - func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? + /// Fetch a channel by ID + func fetchChannel(id: UUID) async throws -> ChannelDTO? - /// Fetch a channel by ID - func fetchChannel(id: UUID) async throws -> ChannelDTO? + /// Save or update a channel from ChannelInfo + @discardableResult + func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID - /// Save or update a channel from ChannelInfo - @discardableResult - func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID + /// Save or update a channel from DTO + func saveChannel(_ dto: ChannelDTO) async throws - /// Save or update a channel from DTO - func saveChannel(_ dto: ChannelDTO) async throws + /// Persists a full channel-sync pass in a single transaction: upserts each configured + /// `ChannelInfo` (matched by `(radioID, index)`), deletes stale local rows at + /// `unconfiguredIndices`, and (when `pruneBeyond` is non-nil) deletes orphaned rows + /// whose index is `>= pruneBeyond`. Rows at indices that are neither configured nor + /// unconfigured (e.g. skipped by the circuit breaker) are left untouched. Returns all + /// channels for the radio after the write, sorted by index. + func batchSaveChannels( + radioID: UUID, + configured: [ChannelInfo], + unconfiguredIndices: [UInt8], + pruneBeyond maxChannels: UInt8? + ) async throws -> [ChannelDTO] - /// Persists a full channel-sync pass in a single transaction: upserts each configured - /// `ChannelInfo` (matched by `(radioID, index)`), deletes stale local rows at - /// `unconfiguredIndices`, and (when `pruneBeyond` is non-nil) deletes orphaned rows - /// whose index is `>= pruneBeyond`. Rows at indices that are neither configured nor - /// unconfigured (e.g. skipped by the circuit breaker) are left untouched. Returns all - /// channels for the radio after the write, sorted by index. - func batchSaveChannels( - radioID: UUID, - configured: [ChannelInfo], - unconfiguredIndices: [UInt8], - pruneBeyond maxChannels: UInt8? - ) async throws -> [ChannelDTO] + /// Delete a channel + func deleteChannel(id: UUID) async throws - /// Delete a channel - func deleteChannel(id: UUID) async throws + /// Delete all messages for a channel + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws - /// Delete all messages for a channel - func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws + /// Update channel's last message info (nil clears the date) + func updateChannelLastMessage(channelID: UUID, date: Date?) async throws - /// Update channel's last message info (nil clears the date) - func updateChannelLastMessage(channelID: UUID, date: Date?) async throws + /// Increment unread count for a channel + func incrementChannelUnreadCount(channelID: UUID) async throws - /// Increment unread count for a channel - func incrementChannelUnreadCount(channelID: UUID) async throws + /// Clear unread count for a channel + func clearChannelUnreadCount(channelID: UUID) async throws - /// Clear unread count for a channel - func clearChannelUnreadCount(channelID: UUID) async throws + /// Clear unread count for a channel by radioID and index + /// More efficient than fetching the full channel DTO when only clearing unread + func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws - /// Clear unread count for a channel by radioID and index - /// More efficient than fetching the full channel DTO when only clearing unread - func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws + /// Sets the notification level for a channel + func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws - /// Sets the notification level for a channel - func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws + /// Sets the notification level for a remote node session + func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws - /// Sets the notification level for a remote node session - func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws + // MARK: - Channel Mention Tracking - // MARK: - Channel Mention Tracking + /// Increment unread mention count for a channel + func incrementChannelUnreadMentionCount(channelID: UUID) async throws - /// Increment unread mention count for a channel - func incrementChannelUnreadMentionCount(channelID: UUID) async throws + /// Decrement unread mention count for a channel + func decrementChannelUnreadMentionCount(channelID: UUID) async throws - /// Decrement unread mention count for a channel - func decrementChannelUnreadMentionCount(channelID: UUID) async throws + /// Clear unread mention count for a channel + func clearChannelUnreadMentionCount(channelID: UUID) async throws - /// Clear unread mention count for a channel - func clearChannelUnreadMentionCount(channelID: UUID) async throws - - /// Fetch unseen mention message IDs for a channel, ordered oldest-first - func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] + /// Fetch unseen mention message IDs for a channel, ordered oldest-first + func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] } // MARK: - Default Parameter Values extension ChannelPersisting { - /// Default channel-sync persistence built from the per-item operations. The concrete - /// `PersistenceStore` overrides this with a single-transaction implementation; this - /// fallback keeps lightweight test stubs conforming without their own batch logic. - func batchSaveChannels( - radioID: UUID, - configured: [ChannelInfo], - unconfiguredIndices: [UInt8], - pruneBeyond maxChannels: UInt8? - ) async throws -> [ChannelDTO] { - for info in configured { - _ = try await saveChannel(radioID: radioID, from: info) - } - for index in unconfiguredIndices { - if let stale = try await fetchChannel(radioID: radioID, index: index) { - try await deleteChannel(id: stale.id) - } - } - if let maxChannels { - for channel in try await fetchChannels(radioID: radioID) where channel.index >= maxChannels { - try await deleteChannel(id: channel.id) - } - } - return try await fetchChannels(radioID: radioID) + /// Default channel-sync persistence built from the per-item operations. The concrete + /// `PersistenceStore` overrides this with a single-transaction implementation; this + /// fallback keeps lightweight test stubs conforming without their own batch logic. + func batchSaveChannels( + radioID: UUID, + configured: [ChannelInfo], + unconfiguredIndices: [UInt8], + pruneBeyond maxChannels: UInt8? + ) async throws -> [ChannelDTO] { + for info in configured { + _ = try await saveChannel(radioID: radioID, from: info) + } + for index in unconfiguredIndices { + if let stale = try await fetchChannel(radioID: radioID, index: index) { + try await deleteChannel(id: stale.id) + } + } + if let maxChannels { + for channel in try await fetchChannels(radioID: radioID) where channel.index >= maxChannels { + try await deleteChannel(id: channel.id) + } } + return try await fetchChannels(radioID: radioID) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift index a32e479c..edad7ccc 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift @@ -2,111 +2,110 @@ import Foundation /// Store operations for contact rows, contact mention tracking, and blocked senders. public protocol ContactPersisting: Actor { + // MARK: - Contact Operations - // MARK: - Contact Operations + /// Fetch all confirmed contacts for a device + func fetchContacts(radioID: UUID) async throws -> [ContactDTO] - /// Fetch all confirmed contacts for a device - func fetchContacts(radioID: UUID) async throws -> [ContactDTO] + /// Fetch contacts with recent messages + func fetchConversations(radioID: UUID) async throws -> [ContactDTO] - /// Fetch contacts with recent messages - func fetchConversations(radioID: UUID) async throws -> [ContactDTO] + /// Fetch a contact by ID + func fetchContact(id: UUID) async throws -> ContactDTO? - /// Fetch a contact by ID - func fetchContact(id: UUID) async throws -> ContactDTO? + /// Fetch a contact by public key + func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? - /// Fetch a contact by public key - func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? + /// Fetch a contact by public key prefix + func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? - /// Fetch a contact by public key prefix - func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? + /// Fetch all contacts with their public keys for crypto operations. + /// Returns dictionary mapping 1-byte public key prefix to array of full 32-byte public keys. + /// Multiple contacts may share the same prefix byte, so we store all of them. + func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] - /// Fetch all contacts with their public keys for crypto operations. - /// Returns dictionary mapping 1-byte public key prefix to array of full 32-byte public keys. - /// Multiple contacts may share the same prefix byte, so we store all of them. - func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] + /// Find contact display name by 4-byte or 6-byte public key prefix. + /// Searches across all devices, because room message authors may only be + /// known from a previously-connected radio's contact list. + func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? - /// Find contact display name by 4-byte or 6-byte public key prefix. - /// Searches across all devices, because room message authors may only be - /// known from a previously-connected radio's contact list. - func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? + /// Find contact by 32-byte public key. Searches across all devices, + /// for routing hints where the contact may exist under another device's ID. + func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? - /// Find contact by 32-byte public key. Searches across all devices, - /// for routing hints where the contact may exist under another device's ID. - func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? + /// Save or update a contact from a ContactFrame + @discardableResult + func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID - /// Save or update a contact from a ContactFrame - @discardableResult - func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID + /// Save or update a contact from DTO + func saveContact(_ dto: ContactDTO) async throws - /// Save or update a contact from DTO - func saveContact(_ dto: ContactDTO) async throws + /// Upsert contacts from frames in a single transaction, matching local rows by + /// `(radioID, publicKey)`. Returns the number of frames persisted. + @discardableResult + func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) async throws -> Int - /// Upsert contacts from frames in a single transaction, matching local rows by - /// `(radioID, publicKey)`. Returns the number of frames persisted. - @discardableResult - func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) async throws -> Int + /// Delete a contact + func deleteContact(id: UUID) async throws - /// Delete a contact - func deleteContact(id: UUID) async throws + /// Update contact's last message info (nil clears the date, removing from conversations list) + func updateContactLastMessage(contactID: UUID, date: Date?) async throws - /// Update contact's last message info (nil clears the date, removing from conversations list) - func updateContactLastMessage(contactID: UUID, date: Date?) async throws + /// Increment unread count for a contact + func incrementUnreadCount(contactID: UUID) async throws - /// Increment unread count for a contact - func incrementUnreadCount(contactID: UUID) async throws + /// Clear unread count for a contact + func clearUnreadCount(contactID: UUID) async throws - /// Clear unread count for a contact - func clearUnreadCount(contactID: UUID) async throws + // MARK: - Mention Tracking - // MARK: - Mention Tracking + /// Mark a mention as seen + func markMentionSeen(messageID: UUID) async throws - /// Mark a mention as seen - func markMentionSeen(messageID: UUID) async throws + /// Increment unread mention count for a contact + func incrementUnreadMentionCount(contactID: UUID) async throws - /// Increment unread mention count for a contact - func incrementUnreadMentionCount(contactID: UUID) async throws + /// Decrement unread mention count for a contact + func decrementUnreadMentionCount(contactID: UUID) async throws - /// Decrement unread mention count for a contact - func decrementUnreadMentionCount(contactID: UUID) async throws + /// Clear unread mention count for a contact + func clearUnreadMentionCount(contactID: UUID) async throws - /// Clear unread mention count for a contact - func clearUnreadMentionCount(contactID: UUID) async throws + /// Fetch unseen mention message IDs for a contact, ordered oldest-first + func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] - /// Fetch unseen mention message IDs for a contact, ordered oldest-first - func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] + /// Delete all messages for a contact + func deleteMessagesForContact(contactID: UUID) async throws - /// Delete all messages for a contact - func deleteMessagesForContact(contactID: UUID) async throws + /// Delete all channel messages from a specific sender for a device + func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws - /// Delete all channel messages from a specific sender for a device - func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws + /// Fetch blocked contacts for a device + func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] - /// Fetch blocked contacts for a device - func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] + // MARK: - Blocked Channel Senders - // MARK: - Blocked Channel Senders + /// Save a blocked channel sender name (upserts to prevent duplicates) + func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws - /// Save a blocked channel sender name (upserts to prevent duplicates) - func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws + /// Delete a blocked channel sender by device and name + func deleteBlockedChannelSender(radioID: UUID, name: String) async throws - /// Delete a blocked channel sender by device and name - func deleteBlockedChannelSender(radioID: UUID, name: String) async throws - - /// Fetch all blocked channel senders for a device - func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] + /// Fetch all blocked channel senders for a device + func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] } // MARK: - Default Parameter Values extension ContactPersisting { - /// Default batch upsert built from the per-item `saveContact` path. The concrete - /// `PersistenceStore` overrides this with a single-transaction implementation; this - /// fallback keeps lightweight test stubs conforming without their own batch logic. - @discardableResult - func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) async throws -> Int { - for frame in frames { - _ = try await saveContact(radioID: radioID, from: frame) - } - return frames.count + /// Default batch upsert built from the per-item `saveContact` path. The concrete + /// `PersistenceStore` overrides this with a single-transaction implementation; this + /// fallback keeps lightweight test stubs conforming without their own batch logic. + @discardableResult + func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) async throws -> Int { + for frame in frames { + _ = try await saveContact(radioID: radioID, from: frame) } + return frames.count + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/DebugLogPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/DebugLogPersisting.swift index 9d7a0096..d55b61d8 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/DebugLogPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/DebugLogPersisting.swift @@ -2,19 +2,18 @@ import Foundation /// Store operations for debug log entries. public protocol DebugLogPersisting: Actor { + /// Save a batch of debug log entries + func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws - /// Save a batch of debug log entries - func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws + /// Fetch debug log entries since a given date + func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] - /// Fetch debug log entries since a given date - func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] + /// Count all debug log entries + func countDebugLogEntries() async throws -> Int - /// Count all debug log entries - func countDebugLogEntries() async throws -> Int + /// Prune debug log entries, keeping only the most recent + func pruneDebugLogEntries(keepCount: Int) async throws - /// Prune debug log entries, keeping only the most recent - func pruneDebugLogEntries(keepCount: Int) async throws - - /// Clear all debug log entries - func clearDebugLogEntries() async throws + /// Clear all debug log entries + func clearDebugLogEntries() async throws } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/DevicePersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/DevicePersisting.swift index 29e13708..27aa1191 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/DevicePersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/DevicePersisting.swift @@ -2,17 +2,16 @@ import Foundation /// Store operations for device rows. public protocol DevicePersisting: Actor { + /// Fetch a device by ID + func fetchDevice(id: UUID) async throws -> DeviceDTO? - /// Fetch a device by ID - func fetchDevice(id: UUID) async throws -> DeviceDTO? + /// Fetch a device by radio ID + func fetchDevice(radioID: UUID) async throws -> DeviceDTO? - /// Fetch a device by radio ID - func fetchDevice(radioID: UUID) async throws -> DeviceDTO? + /// Update the lastContactSync timestamp for a device, used to track + /// incremental contact sync progress + func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws - /// Update the lastContactSync timestamp for a device, used to track - /// incremental contact sync progress - func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws - - /// Save or update a device - func saveDevice(_ dto: DeviceDTO) async throws + /// Save or update a device + func saveDevice(_ dto: DeviceDTO) async throws } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/DiscoveredNodePersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/DiscoveredNodePersisting.swift index 5f9f6e34..ed8cabed 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/DiscoveredNodePersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/DiscoveredNodePersisting.swift @@ -2,28 +2,27 @@ import Foundation /// Store operations for discovered (not yet added) mesh nodes. public protocol DiscoveredNodePersisting: Actor { + /// Insert or update a discovered node from an advertisement frame. + /// Updates lastHeard timestamp if node already exists. + /// - Returns: Tuple of (DiscoveredNodeDTO, isNew) where isNew is true only if node was newly created + func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) - /// Insert or update a discovered node from an advertisement frame. - /// Updates lastHeard timestamp if node already exists. - /// - Returns: Tuple of (DiscoveredNodeDTO, isNew) where isNew is true only if node was newly created - func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) + /// Stamp the inbound advert hop count onto an existing discovered node, keyed by public key. + /// Tracks the latest advert: a newer advertTimestamp always replaces the stored count; + /// equal timestamps keep the closest copy of that broadcast. No-op if no matching row exists; + /// the next advert upsert creates it. + func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws - /// Stamp the inbound advert hop count onto an existing discovered node, keyed by public key. - /// Tracks the latest advert: a newer advertTimestamp always replaces the stored count; - /// equal timestamps keep the closest copy of that broadcast. No-op if no matching row exists; - /// the next advert upsert creates it. - func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws + /// Fetch all discovered nodes for a device. + func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] - /// Fetch all discovered nodes for a device. - func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] + /// Delete a discovered node by ID. + func deleteDiscoveredNode(id: UUID) async throws - /// Delete a discovered node by ID. - func deleteDiscoveredNode(id: UUID) async throws + /// Clear all discovered nodes for a device. + func clearDiscoveredNodes(radioID: UUID) async throws - /// Clear all discovered nodes for a device. - func clearDiscoveredNodes(radioID: UUID) async throws - - /// Batch fetch all contact public keys for efficient "added" state lookup. - /// Returns public keys of confirmed (non-discovered) contacts only. - func fetchContactPublicKeys(radioID: UUID) async throws -> Set + /// Batch fetch all contact public keys for efficient "added" state lookup. + /// Returns public keys of confirmed (non-discovered) contacts only. + func fetchContactPublicKeys(radioID: UUID) async throws -> Set } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/HeardRepeatPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/HeardRepeatPersisting.swift index 996e61e7..c888763a 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/HeardRepeatPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/HeardRepeatPersisting.swift @@ -2,25 +2,24 @@ import Foundation /// Store operations for correlating sent channel messages with heard repeats. public protocol HeardRepeatPersisting: Actor { + /// Find a sent channel message by exact channel, sender timestamp, and text on the sending radio + func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String) async throws -> MessageDTO? - /// Find a sent channel message matching criteria within a time window - func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? + /// Save a message repeat entry + func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws - /// Save a message repeat entry - func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws + /// Fetch all repeats for a message + func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] - /// Fetch all repeats for a message - func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] + /// Delete all repeats for a message + func deleteMessageRepeats(messageID: UUID) async throws - /// Delete all repeats for a message - func deleteMessageRepeats(messageID: UUID) async throws + /// Check if a repeat exists for the given RX log entry + func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool - /// Check if a repeat exists for the given RX log entry - func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool + /// Increment heard repeats count and return new count + func incrementMessageHeardRepeats(id: UUID) async throws -> Int - /// Increment heard repeats count and return new count - func incrementMessageHeardRepeats(id: UUID) async throws -> Int - - /// Increment send count and return new count - func incrementMessageSendCount(id: UUID) async throws -> Int + /// Increment send count and return new count + func incrementMessageSendCount(id: UUID) async throws -> Int } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/LinkPreviewPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/LinkPreviewPersisting.swift index d32c3b99..9ea1f9b4 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/LinkPreviewPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/LinkPreviewPersisting.swift @@ -2,10 +2,9 @@ import Foundation /// Store operations for cached link preview data. public protocol LinkPreviewPersisting: Actor { + /// Fetch link preview data by URL + func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? - /// Fetch link preview data by URL - func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? - - /// Save or update link preview data - func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws + /// Save or update link preview data + func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift index e591ea4b..a1d7e1f2 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift @@ -2,163 +2,162 @@ import Foundation /// Store operations for message rows and their pending-send queue entries. public protocol MessagePersisting: Actor { - - // MARK: - Message Operations - - /// Check if a message with this deduplication key already exists for the given radio. - /// - /// Dedup is scoped per-radio because the content-based key is radio-agnostic, and two - /// companion radios in the same area can receive the same over-the-air packet. Without - /// the `radioID` filter the second radio's sync would be suppressed, leaving nothing to - /// display when the user switches devices. - func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool - - /// Save a new message - func saveMessage(_ dto: MessageDTO) async throws - - /// Fetch a message by ID - func fetchMessage(id: UUID) async throws -> MessageDTO? - - /// Fetch messages for a contact - func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] - - /// Fetch messages for a channel - func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] - - /// Batch fetch last messages for multiple contacts in a single actor call. - /// Avoids N actor hops when loading message previews for the conversation list. - func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] - - /// Batch fetch last messages for multiple channels in a single actor call. - /// Each tuple contains (radioID, channelIndex, id) where id is used as the dictionary key. - func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] - - /// Finds a channel message matching a parsed reaction within a timestamp window - func findChannelMessageForReaction( - radioID: UUID, - channelIndex: UInt8, - parsedReaction: ParsedReaction, - localNodeName: String?, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> MessageDTO? - - /// Fetches channel message candidates for meshcore-open reaction matching - func fetchChannelMessageCandidates( - radioID: UUID, - channelIndex: UInt8, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> [MessageDTO] - - /// Fetches DM message candidates for meshcore-open reaction matching - func fetchDMMessageCandidates( - radioID: UUID, - contactID: UUID, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> [MessageDTO] - - /// Finds a DM message matching a reaction by hash within a timestamp window - func findDMMessageForReaction( - radioID: UUID, - contactID: UUID, - messageHash: String, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> MessageDTO? - - /// Update message status - func updateMessageStatus(id: UUID, status: MessageStatus) async throws - - /// Update message status unless delivery has already won the race. - /// - /// - Returns: `true` if the row's status was changed, `false` if no row was - /// updated (either the row is already `.delivered`, or no row exists for - /// the given `id`). Callers must gate failure side effects (e.g., the - /// `MessageStatusEvent.failed` broadcast, UI toasts) on the return value - /// so they do not surface a `.failed` event for a delivered or absent row. - func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool - - /// Clear a spent retry-loop status back to `.sent`, leaving terminal rows - /// untouched. - /// - /// Writes `.sent` only when the row is neither `.delivered` nor `.failed`, - /// so a late ACK can still upgrade the surviving `.sent` row to `.delivered` - /// while the expiry checker remains the single `.failed` writer. - /// - /// - Returns: `true` if the row moved to `.sent`, `false` if it was already - /// terminal (`.delivered`/`.failed`) or no row exists. Callers must gate - /// the `.sent` status broadcast on the return value. - func clearRetryingToSent(id: UUID) async throws -> Bool - - /// Read-only diagnostic: whether an outgoing direct-message row persists - /// `.sent` with this `ackCode`. Used to flag a stuck-`.sent` orphan (a DM - /// that lost its `pendingAcks` entry to a teardown) when a late ACK arrives - /// with no live pending entry. Observability only; never gates behavior. - func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool - - /// Update message ACK info - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws - - /// Update message retry status - func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws - - /// Update message timestamp (for resending) - func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws - - /// Update heard repeats count - func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws - - /// Mark a message as read - func markMessageAsRead(id: UUID) async throws - - /// Update link preview data for a message - func updateMessageLinkPreview( - id: UUID, - url: String?, - title: String?, - imageData: Data?, - iconData: Data?, - fetched: Bool - ) throws - - // MARK: - Pending Sends - - /// Insert (or update if `dto.id` already exists) a pending send row using the sequence value from the DTO. - func upsertPendingSend(_ dto: PendingSendDTO) async throws - - /// Insert a new pending send row, atomically assigning the next sequence number for the row's radio. - /// Returns the assigned sequence number. - func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int - - /// Fetch all pending sends for a given radio, ordered by sequence ascending. - func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] - - /// Delete a pending send by row id. No-op if the id is not present. - func deletePendingSend(id: UUID) async throws - - /// Delete every pending send row whose `messageID` matches. No-op if no rows match. - /// `messageID` is globally unique across all radios, so scoping by radio would be - /// redundant and could miss stale rows from prior pairings. - func deletePendingSendsForMessage(messageID: UUID) async throws - - /// `messageID` is globally unique. - func hasPendingSend(messageID: UUID) async throws -> Bool - - /// Increments the `attemptCount` for the `PendingSend` row matching - /// `messageID`. Returns the new count, or `nil` if no row matched. - /// Throws on SwiftData read/write failure; callers must treat a thrown - /// error as transient (park + retry) and `nil` as terminal (deleted row). - @discardableResult - func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? + // MARK: - Message Operations + + /// Check if a message with this deduplication key already exists for the given radio. + /// + /// Dedup is scoped per-radio because the content-based key is radio-agnostic, and two + /// companion radios in the same area can receive the same over-the-air packet. Without + /// the `radioID` filter the second radio's sync would be suppressed, leaving nothing to + /// display when the user switches devices. + func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool + + /// Save a new message + func saveMessage(_ dto: MessageDTO) async throws + + /// Fetch a message by ID + func fetchMessage(id: UUID) async throws -> MessageDTO? + + /// Fetch messages for a contact + func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] + + /// Fetch messages for a channel + func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] + + /// Batch fetch last messages for multiple contacts in a single actor call. + /// Avoids N actor hops when loading message previews for the conversation list. + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] + + /// Batch fetch last messages for multiple channels in a single actor call. + /// Each tuple contains (radioID, channelIndex, id) where id is used as the dictionary key. + func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] + + /// Finds a channel message matching a parsed reaction within a timestamp window + func findChannelMessageForReaction( + radioID: UUID, + channelIndex: UInt8, + parsedReaction: ParsedReaction, + localNodeName: String?, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> MessageDTO? + + /// Fetches channel message candidates for meshcore-open reaction matching + func fetchChannelMessageCandidates( + radioID: UUID, + channelIndex: UInt8, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] + + /// Fetches DM message candidates for meshcore-open reaction matching + func fetchDMMessageCandidates( + radioID: UUID, + contactID: UUID, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] + + /// Finds a DM message matching a reaction by hash within a timestamp window + func findDMMessageForReaction( + radioID: UUID, + contactID: UUID, + messageHash: String, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> MessageDTO? + + /// Update message status + func updateMessageStatus(id: UUID, status: MessageStatus) async throws + + /// Update message status unless delivery has already won the race. + /// + /// - Returns: `true` if the row's status was changed, `false` if no row was + /// updated (either the row is already `.delivered`, or no row exists for + /// the given `id`). Callers must gate failure side effects (e.g., the + /// `MessageStatusEvent.failed` broadcast, UI toasts) on the return value + /// so they do not surface a `.failed` event for a delivered or absent row. + func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool + + /// Clear a spent retry-loop status back to `.sent`, leaving terminal rows + /// untouched. + /// + /// Writes `.sent` only when the row is neither `.delivered` nor `.failed`, + /// so a late ACK can still upgrade the surviving `.sent` row to `.delivered` + /// while the expiry checker remains the single `.failed` writer. + /// + /// - Returns: `true` if the row moved to `.sent`, `false` if it was already + /// terminal (`.delivered`/`.failed`) or no row exists. Callers must gate + /// the `.sent` status broadcast on the return value. + func clearRetryingToSent(id: UUID) async throws -> Bool + + /// Read-only diagnostic: whether an outgoing direct-message row persists + /// `.sent` with this `ackCode`. Used to flag a stuck-`.sent` orphan (a DM + /// that lost its `pendingAcks` entry to a teardown) when a late ACK arrives + /// with no live pending entry. Observability only; never gates behavior. + func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool + + /// Update message ACK info + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws + + /// Update message retry status + func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws + + /// Update message timestamp (for resending) + func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws + + /// Update heard repeats count + func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws + + /// Mark a message as read + func markMessageAsRead(id: UUID) async throws + + /// Update link preview data for a message + func updateMessageLinkPreview( + id: UUID, + url: String?, + title: String?, + imageData: Data?, + iconData: Data?, + fetched: Bool + ) throws + + // MARK: - Pending Sends + + /// Insert (or update if `dto.id` already exists) a pending send row using the sequence value from the DTO. + func upsertPendingSend(_ dto: PendingSendDTO) async throws + + /// Insert a new pending send row, atomically assigning the next sequence number for the row's radio. + /// Returns the assigned sequence number. + func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int + + /// Fetch all pending sends for a given radio, ordered by sequence ascending. + func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] + + /// Delete a pending send by row id. No-op if the id is not present. + func deletePendingSend(id: UUID) async throws + + /// Delete every pending send row whose `messageID` matches. No-op if no rows match. + /// `messageID` is globally unique across all radios, so scoping by radio would be + /// redundant and could miss stale rows from prior pairings. + func deletePendingSendsForMessage(messageID: UUID) async throws + + /// `messageID` is globally unique. + func hasPendingSend(messageID: UUID) async throws -> Bool + + /// Increments the `attemptCount` for the `PendingSend` row matching + /// `messageID`. Returns the new count, or `nil` if no row matched. + /// Throws on SwiftData read/write failure; callers must treat a thrown + /// error as transient (park + retry) and `nil` as terminal (deleted row). + @discardableResult + func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? } // MARK: - Default Parameter Values extension MessagePersisting { - /// Update message ACK info with no round-trip time - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus) async throws { - try await updateMessageAck(id: id, ackCode: ackCode, status: status, roundTripTime: nil) - } + /// Update message ACK info with no round-trip time + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus) async throws { + try await updateMessageAck(id: id, ackCode: ackCode, status: status, roundTripTime: nil) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/NodeSnapshotPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/NodeSnapshotPersisting.swift index 4041062b..46a70044 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/NodeSnapshotPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/NodeSnapshotPersisting.swift @@ -2,86 +2,86 @@ import Foundation /// Store operations for node status snapshots. public protocol NodeSnapshotPersisting: Actor { + // swiftlint:disable function_parameter_count + /// Save a node status snapshot from primitive parameters. Returns the snapshot ID. + func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? + ) async throws -> UUID + // swiftlint:enable function_parameter_count - // swiftlint:disable function_parameter_count - /// Save a node status snapshot from primitive parameters. Returns the snapshot ID. - func saveNodeStatusSnapshot( - nodePublicKey: Data, - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16?, - postPushCount: UInt16? - ) async throws -> UUID - // swiftlint:enable function_parameter_count + /// Fetch the most recent snapshot for a node + func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? - /// Fetch the most recent snapshot for a node - func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? + /// Fetch snapshots for a node within a date range, sorted by timestamp ascending + func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] - /// Fetch snapshots for a node within a date range, sorted by timestamp ascending - func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] + /// Update neighbor data on an existing snapshot + func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws - /// Update neighbor data on an existing snapshot - func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws + /// Update telemetry data on an existing snapshot + func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws - /// Update telemetry data on an existing snapshot - func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws + /// Save a telemetry-only snapshot (no radio metrics). Returns the snapshot ID. + func saveTelemetryOnlySnapshot( + nodePublicKey: Data, + telemetryEntries: [TelemetrySnapshotEntry] + ) async throws -> UUID - /// Save a telemetry-only snapshot (no radio metrics). Returns the snapshot ID. - func saveTelemetryOnlySnapshot( - nodePublicKey: Data, - telemetryEntries: [TelemetrySnapshotEntry] - ) async throws -> UUID + /// Atomically capture a status, telemetry, and/or neighbor snapshot for a node, + /// enriching the latest in-window snapshot or inserting a new one. Returns the + /// snapshot ID. The concrete `PersistenceStore` performs the read-modify-write + /// in a single `@ModelActor` turn so concurrent captures cannot duplicate a row. + func recordNodeStatusSnapshot( + nodePublicKey: Data, + status: NodeStatusMetrics?, + telemetry: [TelemetrySnapshotEntry]?, + neighbors: [NeighborSnapshotEntry]? + ) async throws -> UUID - /// Atomically capture a status, telemetry, and/or neighbor snapshot for a node, - /// enriching the latest in-window snapshot or inserting a new one. Returns the - /// snapshot ID. The concrete `PersistenceStore` performs the read-modify-write - /// in a single `@ModelActor` turn so concurrent captures cannot duplicate a row. - func recordNodeStatusSnapshot( - nodePublicKey: Data, - status: NodeStatusMetrics?, - telemetry: [TelemetrySnapshotEntry]?, - neighbors: [NeighborSnapshotEntry]? - ) async throws -> UUID - - /// Delete snapshots older than the given date - func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws + /// Delete snapshots older than the given date + func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws } public extension NodeSnapshotPersisting { - - /// The most recent snapshot that actually carries neighbor data, for neighbor - /// delta display. Neighbor arrays are sparse (a snapshot holds them only when the - /// user expanded the neighbors section), so the generic previous snapshot - /// frequently has none; this skips status- or telemetry-only rows. The current - /// in-window capture is excluded so the reading being viewed is never diffed - /// against itself. - func fetchPreviousNeighborSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { - let all = try await fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) - let cutoff: Date - if let latest = all.last, - latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { - cutoff = latest.timestamp - } else { - cutoff = .now - } - return all.last { $0.timestamp < cutoff && $0.neighborSnapshots != nil } + /// The neighbor baseline: the previous neighbor-bearing snapshot (for the SNR + /// delta) plus every neighbor prefix seen across history (for the "New" badge). + /// Both derive from one fetch and one in-window cutoff, which excludes the + /// reading being viewed so it is never diffed or matched against itself. Neighbor + /// arrays are sparse (a snapshot holds them only when the user expanded the + /// neighbors section), so status- or telemetry-only rows are skipped. + func fetchNeighborBaseline(nodePublicKey: Data) async throws + -> (previous: NodeStatusSnapshotDTO?, seenPrefixes: Set) { + let all = try await fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) + let cutoff: Date = if let latest = all.last, + latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { + latest.timestamp + } else { + .now } + let history = all.filter { $0.timestamp < cutoff && $0.neighborSnapshots != nil } + let seenPrefixes = Set(history.flatMap { $0.neighborSnapshots ?? [] }.map(\.publicKeyPrefix)) + return (previous: history.last, seenPrefixes: seenPrefixes) + } - /// The most recent snapshot carrying status fields before the given date, for the - /// status delta. A neighbor- or telemetry-only capture inserts a row with no - /// status; skipping those (the `uptimeSeconds` marker the in-window throttle uses) - /// keeps such a row from blanking the delta. The in-window capture is kept, unlike - /// the neighbor baseline: status is throttled and never overwrites itself, so an - /// early reading in the current window is still a valid baseline. - func fetchPreviousStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { - let all = try await fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) - return all.last { $0.timestamp < before && $0.uptimeSeconds != nil } - } + /// The most recent snapshot carrying status fields before the given date, for the + /// status delta. A neighbor- or telemetry-only capture inserts a row with no + /// status; skipping those (the `uptimeSeconds` marker the in-window throttle uses) + /// keeps such a row from blanking the delta. The in-window capture is kept, unlike + /// the neighbor baseline: status is throttled and never overwrites itself, so an + /// early reading in the current window is still a valid baseline. + func fetchPreviousStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { + let all = try await fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) + return all.last { $0.timestamp < before && $0.uptimeSeconds != nil } + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/ReactionPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/ReactionPersisting.swift index 74d5b6f1..f339705f 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/ReactionPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/ReactionPersisting.swift @@ -2,28 +2,27 @@ import Foundation /// Store operations for message reactions and their summary cache. public protocol ReactionPersisting: Actor { + /// Fetch reactions for a message, ordered by most recent first + func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] - /// Fetch reactions for a message, ordered by most recent first - func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] + /// Save a new reaction + func saveReaction(_ dto: ReactionDTO) async throws - /// Save a new reaction - func saveReaction(_ dto: ReactionDTO) async throws + /// Check if a reaction already exists (deduplication) + func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool - /// Check if a reaction already exists (deduplication) - func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool + /// Update a message's reaction summary cache + func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws - /// Update a message's reaction summary cache - func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws - - /// Delete all reactions for a message - func deleteReactionsForMessage(messageID: UUID) async throws + /// Delete all reactions for a message + func deleteReactionsForMessage(messageID: UUID) async throws } // MARK: - Default Parameter Values extension ReactionPersisting { - /// Fetch reactions with default limit of 100 - func fetchReactions(for messageID: UUID) async throws -> [ReactionDTO] { - try await fetchReactions(for: messageID, limit: 100) - } + /// Fetch reactions with default limit of 100 + func fetchReactions(for messageID: UUID) async throws -> [ReactionDTO] { + try await fetchReactions(for: messageID, limit: 100) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/RoomPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/RoomPersisting.swift index bf1ca92c..cacb3edb 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/RoomPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/RoomPersisting.swift @@ -2,90 +2,89 @@ import Foundation /// Store operations for remote node sessions and room messages. public protocol RoomPersisting: Actor { + // MARK: - Room Session State - // MARK: - Room Session State + /// Fetch a remote node session by ID + func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? - /// Fetch a remote node session by ID - func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? + /// Fetch a remote node session by 32-byte public key + func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? - /// Fetch a remote node session by 32-byte public key - func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? + /// Fetch a remote node session by 6-byte public key prefix + func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? - /// Fetch a remote node session by 6-byte public key prefix - func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? + /// Fetch all remote node sessions for a device + func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] - /// Fetch all remote node sessions for a device - func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] + /// Fetch all connected sessions for re-authentication after BLE reconnection + func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] - /// Fetch all connected sessions for re-authentication after BLE reconnection - func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] + /// Save or update a remote node session + func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws - /// Save or update a remote node session - func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws + /// Update session connection state + func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws - /// Update session connection state - func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws + /// Clean up duplicate remote node sessions with the same public key. + /// Keeps the session with the specified ID and deletes any others. + func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws - /// Clean up duplicate remote node sessions with the same public key. - /// Keeps the session with the specified ID and deletes any others. - func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws + /// Delete remote node session and all associated room messages + func deleteRemoteNodeSession(id: UUID) async throws - /// Delete remote node session and all associated room messages - func deleteRemoteNodeSession(id: UUID) async throws + /// Mark a session as disconnected without changing permission level. + /// Use for transient disconnections (BLE drop, keep-alive failure, re-auth failure). + func markSessionDisconnected(_ sessionID: UUID) async throws - /// Mark a session as disconnected without changing permission level. - /// Use for transient disconnections (BLE drop, keep-alive failure, re-auth failure). - func markSessionDisconnected(_ sessionID: UUID) async throws + /// Mark a room session as connected. Returns true if the state actually changed. + @discardableResult + func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool - /// Mark a room session as connected. Returns true if the state actually changed. - @discardableResult - func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool + /// Update room activity timestamps (sort date and optional sync bookmark). + func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws - /// Update room activity timestamps (sort date and optional sync bookmark). - func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws + // MARK: - Room Message Operations - // MARK: - Room Message Operations + /// Save a new room message + func saveRoomMessage(_ dto: RoomMessageDTO) async throws - /// Save a new room message - func saveRoomMessage(_ dto: RoomMessageDTO) async throws + /// Fetch a room message by ID + func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? - /// Fetch a room message by ID - func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? + /// Fetch room messages for a session + func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] - /// Fetch room messages for a session - func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] + /// Check for duplicate room message + func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool - /// Check for duplicate room message - func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool + /// Update room message status after send attempt + func updateRoomMessageStatus( + id: UUID, + status: MessageStatus, + ackCode: UInt32?, + roundTripTime: UInt32? + ) async throws - /// Update room message status after send attempt - func updateRoomMessageStatus( - id: UUID, - status: MessageStatus, - ackCode: UInt32?, - roundTripTime: UInt32? - ) async throws + /// Update room message retry status + func updateRoomMessageRetryStatus( + id: UUID, + status: MessageStatus, + retryAttempt: Int, + maxRetryAttempts: Int + ) async throws - /// Update room message retry status - func updateRoomMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) async throws + /// Increment unread message count for a room session + func incrementRoomUnreadCount(_ sessionID: UUID) async throws - /// Increment unread message count for a room session - func incrementRoomUnreadCount(_ sessionID: UUID) async throws - - /// Reset unread count to zero (called when user views conversation) - func resetRoomUnreadCount(_ sessionID: UUID) async throws + /// Reset unread count to zero (called when user views conversation) + func resetRoomUnreadCount(_ sessionID: UUID) async throws } // MARK: - Default Parameter Values extension RoomPersisting { - /// Update room activity with nil sync timestamp (sort date only) - func updateRoomActivity(_ sessionID: UUID) async throws { - try await updateRoomActivity(sessionID, syncTimestamp: nil) - } + /// Update room activity with nil sync timestamp (sort date only) + func updateRoomActivity(_ sessionID: UUID) async throws { + try await updateRoomActivity(sessionID, syncTimestamp: nil) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift index aadd9628..94dbc3b3 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift @@ -2,82 +2,81 @@ import Foundation /// Store operations for RX log entries: persistence, lookup, and batch enrichment. public protocol RxLogPersisting: Actor { - - // MARK: - RxLogEntry Lookup - - /// Find RxLogEntry matching an incoming message for path correlation. - /// - /// For channel messages: Correlates by channel index and sender timestamp. - /// For direct messages: Correlates by sender timestamp and payload type. - func findRxLogEntry( - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32 - ) async throws -> RxLogEntryDTO? - - /// Find a DM RxLogEntry by matching the sender prefix byte in the packet payload. - /// Fallback for when the primary timestamp-based lookup fails. - func findRxLogEntryBySenderPrefix( - radioID: UUID, - senderPrefixByte: UInt8, - receivedSince: Date - ) async throws -> RxLogEntryDTO? - - // MARK: - RX Log - - /// Save a new RX log entry - func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws - - /// Fetch RX log entries for a device, most recent first - func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] - - /// Clear all RX log entries for a device - func clearRxLogEntries(radioID: UUID) async throws - - /// Delete oldest entries once the log materially exceeds the retention cap - func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws - - /// Fetch RX log entries that have a transport code but no resolved - /// region yet, the back-fill candidate set - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] - - /// Fetch recent RX log entries with a given decrypt status - func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] - - /// Batch update `regionScope` on RX log entries by id - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws - - /// Batch update RX log entries after successful decryption. - /// Note: decodedText is transient and not persisted. - func batchUpdateRxLogDecryption( - _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] - ) async throws - - /// Batch update `regionScope` on incoming channel `Message` rows - /// correlated by `(channelIndex, senderTimestamp)` - func batchUpdateChannelMessageRegion( - radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws - - /// Batch update `regionScope` on incoming DM `Message` rows - /// correlated by `(senderPrefixByte, senderTimestamp)` - func batchUpdateDMMessageRegion( - radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws + // MARK: - RxLogEntry Lookup + + /// Find RxLogEntry matching an incoming message for path correlation. + /// + /// For channel messages: Correlates by channel index and sender timestamp. + /// For direct messages: Correlates by sender timestamp and payload type. + func findRxLogEntry( + radioID: UUID, + channelIndex: UInt8?, + senderTimestamp: UInt32 + ) async throws -> RxLogEntryDTO? + + /// Find a DM RxLogEntry by matching the sender prefix byte in the packet payload. + /// Fallback for when the primary timestamp-based lookup fails. + func findRxLogEntryBySenderPrefix( + radioID: UUID, + senderPrefixByte: UInt8, + receivedSince: Date + ) async throws -> RxLogEntryDTO? + + // MARK: - RX Log + + /// Save a new RX log entry + func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws + + /// Fetch RX log entries for a device, most recent first + func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] + + /// Clear all RX log entries for a device + func clearRxLogEntries(radioID: UUID) async throws + + /// Delete oldest entries once the log materially exceeds the retention cap + func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws + + /// Fetch RX log entries that have a transport code but no resolved + /// region yet, the back-fill candidate set + func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] + + /// Fetch recent RX log entries with a given decrypt status + func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] + + /// Batch update `regionScope` on RX log entries by id + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws + + /// Batch update RX log entries after successful decryption. + /// Note: decodedText is transient and not persisted. + func batchUpdateRxLogDecryption( + _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] + ) async throws + + /// Batch update `regionScope` on incoming channel `Message` rows + /// correlated by `(channelIndex, senderTimestamp)` + func batchUpdateChannelMessageRegion( + radioID: UUID, + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) async throws + + /// Batch update `regionScope` on incoming DM `Message` rows + /// correlated by `(senderPrefixByte, senderTimestamp)` + func batchUpdateDMMessageRegion( + radioID: UUID, + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) async throws } // MARK: - Default Parameter Values extension RxLogPersisting { - /// Fetch RX log entries with the default limit of 500 - func fetchRxLogEntries(radioID: UUID) async throws -> [RxLogEntryDTO] { - try await fetchRxLogEntries(radioID: radioID, limit: 500) - } - - /// Prune RX log entries with the default retention cap of 1000 plus a 100-entry threshold - func pruneRxLogEntries(radioID: UUID) async throws { - try await pruneRxLogEntries(radioID: radioID, keepCount: 1000, pruneThreshold: 100) - } + /// Fetch RX log entries with the default limit of 500 + func fetchRxLogEntries(radioID: UUID) async throws -> [RxLogEntryDTO] { + try await fetchRxLogEntries(radioID: radioID, limit: 500) + } + + /// Prune RX log entries with the default retention cap of 1000 plus a 100-entry threshold + func pruneRxLogEntries(radioID: UUID) async throws { + try await pruneRxLogEntries(radioID: radioID, keepCount: 1000, pruneThreshold: 100) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/TracePathPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/TracePathPersisting.swift index 0b6d90e5..218b854c 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/TracePathPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/TracePathPersisting.swift @@ -2,22 +2,21 @@ import Foundation /// Store operations for saved trace paths and their runs. public protocol TracePathPersisting: Actor { + /// Fetch all saved trace paths for a device + func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] - /// Fetch all saved trace paths for a device - func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] + /// Fetch a single saved trace path by ID + func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? - /// Fetch a single saved trace path by ID - func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? + /// Create a new saved trace path + func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO - /// Create a new saved trace path - func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO + /// Update a saved trace path's name + func updateSavedTracePathName(id: UUID, name: String) async throws - /// Update a saved trace path's name - func updateSavedTracePathName(id: UUID, name: String) async throws + /// Delete a saved trace path + func deleteSavedTracePath(id: UUID) async throws - /// Delete a saved trace path - func deleteSavedTracePath(id: UUID) async throws - - /// Append a run to a saved trace path - func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws + /// Append a run to a saved trace path + func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws } diff --git a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift index 99ca4a0d..e95476ab 100644 --- a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift @@ -21,16 +21,16 @@ /// } /// ``` public protocol PersistenceStoreProtocol: - MessagePersisting, - DevicePersisting, - ContactPersisting, - ChannelPersisting, - TracePathPersisting, - HeardRepeatPersisting, - DebugLogPersisting, - LinkPreviewPersisting, - RxLogPersisting, - RoomPersisting, - DiscoveredNodePersisting, - ReactionPersisting, - NodeSnapshotPersisting {} + MessagePersisting, + DevicePersisting, + ContactPersisting, + ChannelPersisting, + TracePathPersisting, + HeardRepeatPersisting, + DebugLogPersisting, + LinkPreviewPersisting, + RxLogPersisting, + RoomPersisting, + DiscoveredNodePersisting, + ReactionPersisting, + NodeSnapshotPersisting {} diff --git a/MC1Services/Sources/MC1Services/Protocols/RepeaterResolvable.swift b/MC1Services/Sources/MC1Services/Protocols/RepeaterResolvable.swift index 691b329c..d0089ae5 100644 --- a/MC1Services/Sources/MC1Services/Protocols/RepeaterResolvable.swift +++ b/MC1Services/Sources/MC1Services/Protocols/RepeaterResolvable.swift @@ -3,13 +3,13 @@ import Foundation /// Shared interface for types that can be matched by `RepeaterResolver`. /// Both `ContactDTO` and `DiscoveredNodeDTO` conform. public protocol RepeaterResolvable: Sendable { - var publicKey: Data { get } - var latitude: Double { get } - var longitude: Double { get } - var hasLocation: Bool { get } - var lastAdvertTimestamp: UInt32 { get } - /// Secondary recency tiebreaker (ContactDTO → lastModified, DiscoveredNodeDTO → lastHeard). - var recencyDate: Date { get } - /// Display name used for path hops and resolver tiebreaking. - var resolvableName: String { get } + var publicKey: Data { get } + var latitude: Double { get } + var longitude: Double { get } + var hasLocation: Bool { get } + var lastAdvertTimestamp: UInt32 { get } + /// Secondary recency tiebreaker (ContactDTO → lastModified, DiscoveredNodeDTO → lastHeard). + var recencyDate: Date { get } + /// Display name used for path hops and resolver tiebreaking. + var resolvableName: String { get } } diff --git a/MC1Services/Sources/MC1Services/RF/ClearanceStatus.swift b/MC1Services/Sources/MC1Services/RF/ClearanceStatus.swift index c1983806..27597575 100644 --- a/MC1Services/Sources/MC1Services/RF/ClearanceStatus.swift +++ b/MC1Services/Sources/MC1Services/RF/ClearanceStatus.swift @@ -2,8 +2,8 @@ import Foundation /// Clearance status at worst point along path public enum ClearanceStatus: String, Sendable { - case clear = "Clear" - case marginal = "Marginal" - case partialObstruction = "Partial obstruction" - case blocked = "Blocked" + case clear = "Clear" + case marginal = "Marginal" + case partialObstruction = "Partial obstruction" + case blocked = "Blocked" } diff --git a/MC1Services/Sources/MC1Services/RF/ElevationSample.swift b/MC1Services/Sources/MC1Services/RF/ElevationSample.swift index 7ba7f1ea..24ec3112 100644 --- a/MC1Services/Sources/MC1Services/RF/ElevationSample.swift +++ b/MC1Services/Sources/MC1Services/RF/ElevationSample.swift @@ -3,14 +3,14 @@ import Foundation /// Elevation sample along the path public struct ElevationSample: Identifiable, Sendable { - public let id = UUID() - public let coordinate: CLLocationCoordinate2D - public let elevation: Double // meters above sea level - public let distanceFromAMeters: Double + public let id = UUID() + public let coordinate: CLLocationCoordinate2D + public let elevation: Double // meters above sea level + public let distanceFromAMeters: Double - public init(coordinate: CLLocationCoordinate2D, elevation: Double, distanceFromAMeters: Double) { - self.coordinate = coordinate - self.elevation = elevation - self.distanceFromAMeters = distanceFromAMeters - } + public init(coordinate: CLLocationCoordinate2D, elevation: Double, distanceFromAMeters: Double) { + self.coordinate = coordinate + self.elevation = elevation + self.distanceFromAMeters = distanceFromAMeters + } } diff --git a/MC1Services/Sources/MC1Services/RF/ObstructionPoint.swift b/MC1Services/Sources/MC1Services/RF/ObstructionPoint.swift index b606ac02..203ee4cd 100644 --- a/MC1Services/Sources/MC1Services/RF/ObstructionPoint.swift +++ b/MC1Services/Sources/MC1Services/RF/ObstructionPoint.swift @@ -2,24 +2,24 @@ import Foundation /// Point where obstruction affects the path public struct ObstructionPoint: Identifiable, Equatable, Sendable { - public let id = UUID() - public let distanceFromAMeters: Double - public let obstructionHeightMeters: Double - public let fresnelClearancePercent: Double + public let id = UUID() + public let distanceFromAMeters: Double + public let obstructionHeightMeters: Double + public let fresnelClearancePercent: Double - public init( - distanceFromAMeters: Double, - obstructionHeightMeters: Double, - fresnelClearancePercent: Double - ) { - self.distanceFromAMeters = distanceFromAMeters - self.obstructionHeightMeters = obstructionHeightMeters - self.fresnelClearancePercent = fresnelClearancePercent - } + public init( + distanceFromAMeters: Double, + obstructionHeightMeters: Double, + fresnelClearancePercent: Double + ) { + self.distanceFromAMeters = distanceFromAMeters + self.obstructionHeightMeters = obstructionHeightMeters + self.fresnelClearancePercent = fresnelClearancePercent + } - public static func == (lhs: ObstructionPoint, rhs: ObstructionPoint) -> Bool { - lhs.distanceFromAMeters == rhs.distanceFromAMeters - && lhs.obstructionHeightMeters == rhs.obstructionHeightMeters - && lhs.fresnelClearancePercent == rhs.fresnelClearancePercent - } + public static func == (lhs: ObstructionPoint, rhs: ObstructionPoint) -> Bool { + lhs.distanceFromAMeters == rhs.distanceFromAMeters + && lhs.obstructionHeightMeters == rhs.obstructionHeightMeters + && lhs.fresnelClearancePercent == rhs.fresnelClearancePercent + } } diff --git a/MC1Services/Sources/MC1Services/RF/PathAnalysisResult.swift b/MC1Services/Sources/MC1Services/RF/PathAnalysisResult.swift index 97122625..1908ed08 100644 --- a/MC1Services/Sources/MC1Services/RF/PathAnalysisResult.swift +++ b/MC1Services/Sources/MC1Services/RF/PathAnalysisResult.swift @@ -2,78 +2,80 @@ import Foundation /// Complete analysis result for a path public struct PathAnalysisResult: Equatable, Sendable { - public let distanceMeters: Double - public let freeSpacePathLoss: Double - /// Peak diffraction loss from the single worst knife-edge obstruction (not cumulative) - public let peakDiffractionLoss: Double - public let totalPathLoss: Double - public let clearanceStatus: ClearanceStatus - public let worstClearancePercent: Double - public let obstructionPoints: [ObstructionPoint] - public let frequencyMHz: Double - public let refractionK: Double + public let distanceMeters: Double + public let freeSpacePathLoss: Double + /// Peak diffraction loss from the single worst knife-edge obstruction (not cumulative) + public let peakDiffractionLoss: Double + public let totalPathLoss: Double + public let clearanceStatus: ClearanceStatus + public let worstClearancePercent: Double + public let obstructionPoints: [ObstructionPoint] + public let frequencyMHz: Double + public let refractionK: Double - public init( - distanceMeters: Double, - freeSpacePathLoss: Double, - peakDiffractionLoss: Double, - totalPathLoss: Double, - clearanceStatus: ClearanceStatus, - worstClearancePercent: Double, - obstructionPoints: [ObstructionPoint], - frequencyMHz: Double, - refractionK: Double - ) { - self.distanceMeters = distanceMeters - self.freeSpacePathLoss = freeSpacePathLoss - self.peakDiffractionLoss = peakDiffractionLoss - self.totalPathLoss = totalPathLoss - self.clearanceStatus = clearanceStatus - self.worstClearancePercent = worstClearancePercent - self.obstructionPoints = obstructionPoints - self.frequencyMHz = frequencyMHz - self.refractionK = refractionK - } + public init( + distanceMeters: Double, + freeSpacePathLoss: Double, + peakDiffractionLoss: Double, + totalPathLoss: Double, + clearanceStatus: ClearanceStatus, + worstClearancePercent: Double, + obstructionPoints: [ObstructionPoint], + frequencyMHz: Double, + refractionK: Double + ) { + self.distanceMeters = distanceMeters + self.freeSpacePathLoss = freeSpacePathLoss + self.peakDiffractionLoss = peakDiffractionLoss + self.totalPathLoss = totalPathLoss + self.clearanceStatus = clearanceStatus + self.worstClearancePercent = worstClearancePercent + self.obstructionPoints = obstructionPoints + self.frequencyMHz = frequencyMHz + self.refractionK = refractionK + } - public var distanceKm: Double { distanceMeters / 1000 } + public var distanceKm: Double { + distanceMeters / 1000 + } - public var worstObstructionPoint: ObstructionPoint? { - obstructionPoints.min(by: { $0.fresnelClearancePercent < $1.fresnelClearancePercent }) - } + public var worstObstructionPoint: ObstructionPoint? { + obstructionPoints.min(by: { $0.fresnelClearancePercent < $1.fresnelClearancePercent }) + } - /// Returns the worst obstruction point per contiguous obstructed region. - /// Groups adjacent obstruction points by sample spacing, then picks the - /// lowest clearance point from each group, one per red bar in the terrain profile. - public var peakObstructionPerRegion: [ObstructionPoint] { - guard obstructionPoints.count >= 2 else { return obstructionPoints } + /// Returns the worst obstruction point per contiguous obstructed region. + /// Groups adjacent obstruction points by sample spacing, then picks the + /// lowest clearance point from each group, one per red bar in the terrain profile. + public var peakObstructionPerRegion: [ObstructionPoint] { + guard obstructionPoints.count >= 2 else { return obstructionPoints } - // Find the smallest gap between consecutive points (= one sample step) - var minGap = Double.infinity - for i in 1.. 0 && gap < minGap { minGap = gap } - } - guard minGap.isFinite else { return [obstructionPoints[0]] } + // Find the smallest gap between consecutive points (= one sample step) + var minGap = Double.infinity + for i in 1.. 0, gap < minGap { minGap = gap } + } + guard minGap.isFinite else { return [obstructionPoints[0]] } - // A gap > 2x the sample step means a non-obstructed sample separates two regions - let gapThreshold = minGap * 2.5 + // A gap > 2x the sample step means a non-obstructed sample separates two regions + let gapThreshold = minGap * 2.5 - var regions: [ObstructionPoint] = [] - var regionWorst = obstructionPoints[0] + var regions: [ObstructionPoint] = [] + var regionWorst = obstructionPoints[0] - for i in 1.. gapThreshold { - regions.append(regionWorst) - regionWorst = point - } else if point.fresnelClearancePercent < regionWorst.fresnelClearancePercent { - regionWorst = point - } - } + if gap > gapThreshold { regions.append(regionWorst) - - return regions + regionWorst = point + } else if point.fresnelClearancePercent < regionWorst.fresnelClearancePercent { + regionWorst = point + } } + regions.append(regionWorst) + + return regions + } } diff --git a/MC1Services/Sources/MC1Services/RF/RFCalculator.swift b/MC1Services/Sources/MC1Services/RF/RFCalculator.swift index fb62f7fa..4e902c8e 100644 --- a/MC1Services/Sources/MC1Services/RF/RFCalculator.swift +++ b/MC1Services/Sources/MC1Services/RF/RFCalculator.swift @@ -6,413 +6,411 @@ import Foundation /// Provides functions for calculating wavelength, Fresnel zones, earth bulge, /// path loss, and diffraction loss for radio frequency propagation analysis. public enum RFCalculator { - - // MARK: - Constants - - /// Speed of light in meters per second - public static let speedOfLight: Double = 299_792_458 - - /// Earth's radius in kilometers - public static let earthRadiusKm: Double = 6371 - - /// Minimum Fresnel zone clearance percentage for a "clear" path - public static let clearClearanceThreshold: Double = 80 - - /// Minimum Fresnel zone clearance percentage for a "marginal" path - public static let marginalClearanceThreshold: Double = 60 - - // MARK: - Wavelength - - /// Calculates the wavelength in meters for a given frequency. - /// - Parameter frequencyMHz: The frequency in megahertz. - /// - Returns: The wavelength in meters. - public static func wavelength(frequencyMHz: Double) -> Double { - guard frequencyMHz > 0 else { return 0 } - let frequencyHz = frequencyMHz * 1_000_000 - return speedOfLight / frequencyHz + // MARK: - Constants + + /// Speed of light in meters per second + public static let speedOfLight: Double = 299_792_458 + + /// Earth's radius in kilometers + public static let earthRadiusKm: Double = 6371 + + /// Minimum Fresnel zone clearance percentage for a "clear" path + public static let clearClearanceThreshold: Double = 80 + + /// Minimum Fresnel zone clearance percentage for a "marginal" path + public static let marginalClearanceThreshold: Double = 60 + + // MARK: - Wavelength + + /// Calculates the wavelength in meters for a given frequency. + /// - Parameter frequencyMHz: The frequency in megahertz. + /// - Returns: The wavelength in meters. + public static func wavelength(frequencyMHz: Double) -> Double { + guard frequencyMHz > 0 else { return 0 } + let frequencyHz = frequencyMHz * 1_000_000 + return speedOfLight / frequencyHz + } + + // MARK: - Fresnel Zone + + /// Calculates the first Fresnel zone radius at a point along the path. + /// + /// The Fresnel zone represents the ellipsoidal region around the direct + /// line-of-sight path where radio waves propagate. For best reception, + /// at least 60% of the first Fresnel zone should be clear of obstructions. + /// + /// - Parameters: + /// - frequencyMHz: The frequency in megahertz. + /// - distanceToAMeters: Distance from point A to the calculation point in meters. + /// - distanceToBMeters: Distance from the calculation point to point B in meters. + /// - Returns: The first Fresnel zone radius in meters. + public static func fresnelRadius( + frequencyMHz: Double, + distanceToAMeters: Double, + distanceToBMeters: Double + ) -> Double { + guard frequencyMHz > 0, distanceToAMeters > 0, distanceToBMeters > 0 else { return 0 } + + let lambda = wavelength(frequencyMHz: frequencyMHz) + let totalDistance = distanceToAMeters + distanceToBMeters + + // First Fresnel zone radius: r = sqrt((lambda * d1 * d2) / (d1 + d2)) + return sqrt((lambda * distanceToAMeters * distanceToBMeters) / totalDistance) + } + + // MARK: - Earth Bulge + + /// Calculates the earth bulge (curvature correction) at a point along the path. + /// + /// Earth bulge represents how much the curved surface of the Earth rises + /// above a straight line between two points. This is critical for long-distance + /// radio links where the curvature can obstruct the signal path. + /// + /// - Parameters: + /// - distanceToAMeters: Distance from point A to the calculation point in meters. + /// - distanceToBMeters: Distance from the calculation point to point B in meters. + /// - refractionK: The effective earth radius factor. Use 1.0 for no adjustment, + /// 1.33 (4/3) for standard atmosphere, or 4.0 for ducting conditions. + /// - Returns: The earth bulge in meters. + public static func earthBulge( + distanceToAMeters: Double, + distanceToBMeters: Double, + refractionK: Double + ) -> Double { + guard distanceToAMeters > 0, distanceToBMeters > 0, refractionK > 0 else { return 0 } + + let earthRadiusMeters = earthRadiusKm * 1000 + let effectiveEarthRadius = refractionK * earthRadiusMeters + + // Earth bulge: h = (d1 * d2) / (2 * Re_effective) + return (distanceToAMeters * distanceToBMeters) / (2 * effectiveEarthRadius) + } + + // MARK: - Path Loss + + /// Calculates the free-space path loss in decibels. + /// + /// Free-space path loss represents the attenuation of radio signal + /// as it travels through free space (vacuum). Real-world losses are + /// typically higher due to atmospheric absorption and other factors. + /// + /// - Parameters: + /// - distanceMeters: The distance in meters. + /// - frequencyMHz: The frequency in megahertz. + /// - Returns: The free-space path loss in dB. + public static func pathLoss(distanceMeters: Double, frequencyMHz: Double) -> Double { + guard distanceMeters > 0, frequencyMHz > 0 else { return 0 } + + // FSPL (dB) = 20*log10(d) + 20*log10(f) + 20*log10(4*pi/c) + // Simplified: FSPL = 20*log10(d_m) + 20*log10(f_MHz) + 20*log10(4*pi*1e6/c) + // The constant = 20*log10(4*pi*1e6/299792458) ≈ -27.55 + let distanceComponent = 20 * log10(distanceMeters) + let frequencyComponent = 20 * log10(frequencyMHz) + let constant = -27.55 + + return distanceComponent + frequencyComponent + constant + } + + // MARK: - Diffraction Loss + + /// Calculates the knife-edge diffraction loss for an obstruction. + /// + /// Uses the Fresnel-Kirchhoff diffraction parameter (v) to estimate + /// the loss caused by a single knife-edge obstruction in the path. + /// + /// - Parameters: + /// - obstructionHeightMeters: The height of the obstruction above the line-of-sight + /// (positive = blocked, negative = clearance). + /// - distanceToAMeters: Distance from point A to the obstruction in meters. + /// - distanceToBMeters: Distance from the obstruction to point B in meters. + /// - frequencyMHz: The frequency in megahertz. + /// - Returns: The diffraction loss in dB (positive value represents loss). + public static func diffractionLoss( + obstructionHeightMeters: Double, + distanceToAMeters: Double, + distanceToBMeters: Double, + frequencyMHz: Double + ) -> Double { + guard distanceToAMeters > 0, distanceToBMeters > 0, frequencyMHz > 0 else { return 0 } + + let lambda = wavelength(frequencyMHz: frequencyMHz) + let totalDistance = distanceToAMeters + distanceToBMeters + + // Fresnel-Kirchhoff diffraction parameter: + // v = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)) + let vParam = obstructionHeightMeters * sqrt( + 2 * totalDistance / (lambda * distanceToAMeters * distanceToBMeters) + ) + + // Approximate diffraction loss based on v parameter + // Using ITU-R P.526 approximation + return diffractionLossFromV(vParam) + } + + /// Fresnel-Kirchhoff parameter at or below which the path has full clearance and no knife-edge loss. + private static let diffractionClearThresholdV: Double = -0.78 + + /// Calculates diffraction loss from the Fresnel-Kirchhoff v parameter. + /// + /// Uses the ITU-R P.526 single-equation knife-edge diffraction model. A single + /// expression keeps the loss continuous and monotonic across the whole obstruction + /// range, avoiding the branch-join drift of the piecewise polynomial approximations. + /// + /// - Parameter vParam: The Fresnel-Kirchhoff diffraction parameter. + /// - Returns: The diffraction loss in dB. + private static func diffractionLossFromV(_ vParam: Double) -> Double { + guard vParam > diffractionClearThresholdV else { return 0 } + let shifted = vParam - 0.1 + return 6.9 + 20 * log10(sqrt(shifted * shifted + 1) + shifted) + } + + // MARK: - Distance Calculation + + /// Calculates the great-circle distance between two coordinates using the Haversine formula. + /// + /// - Parameters: + /// - from: The starting coordinate. + /// - destination: The ending coordinate. + /// - Returns: The distance in meters. + public static func distance(from: CLLocationCoordinate2D, to destination: CLLocationCoordinate2D) -> Double { + let earthRadiusMeters = earthRadiusKm * 1000 + + let lat1 = from.latitude * .pi / 180 + let lat2 = destination.latitude * .pi / 180 + let deltaLat = (destination.latitude - from.latitude) * .pi / 180 + let deltaLon = (destination.longitude - from.longitude) * .pi / 180 + + // Haversine formula + let haversineA = sin(deltaLat / 2) * sin(deltaLat / 2) + + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) + let angularDistance = 2 * atan2(sqrt(haversineA), sqrt(1 - haversineA)) + + return earthRadiusMeters * angularDistance + } + + // MARK: - Path Analysis + + /// Analyze full path for clearance and signal propagation. + /// + /// This function evaluates an elevation profile between two points to determine: + /// - Free-space path loss (FSPL) + /// - Additional loss from diffraction over obstructions + /// - Fresnel zone clearance at each point + /// - Overall clearance status of the path + /// + /// - Parameters: + /// - elevationProfile: Array of elevation samples along the path from A to B. + /// - pointAHeightMeters: Antenna height at point A in meters above ground. + /// - pointBHeightMeters: Antenna height at point B in meters above ground. + /// - frequencyMHz: The operating frequency in megahertz. + /// - refractionK: The effective earth radius factor. Use 1.0 for no adjustment, + /// 1.33 (4/3) for standard atmosphere, or 4.0 for ducting conditions. + /// - Returns: A PathAnalysisResult containing loss calculations and clearance status. + public static func analyzePath( + elevationProfile: [ElevationSample], + pointAHeightMeters: Double, + pointBHeightMeters: Double, + frequencyMHz: Double, + refractionK: Double + ) -> PathAnalysisResult { + // Full-path distances are measured from A itself, so the origin is 0 + // even if the first sample carries a non-zero distance. + analyze( + elevationProfile: elevationProfile[...], + startHeightMeters: pointAHeightMeters, + endHeightMeters: pointBHeightMeters, + frequencyMHz: frequencyMHz, + refractionK: refractionK, + distanceOriginMeters: 0 + ) + } + + // MARK: - Segment Analysis + + /// Analyze a segment of the path for clearance and signal propagation. + /// Uses ArraySlice to avoid copying - critical for 60fps drag performance. + /// + /// - Parameters: + /// - elevationProfile: Slice of elevation samples for this segment. + /// - startHeightMeters: Antenna height at segment start in meters above ground. + /// - endHeightMeters: Antenna height at segment end in meters above ground. + /// - frequencyMHz: The operating frequency in megahertz. + /// - refractionK: The effective earth radius factor. + /// - Returns: A PathAnalysisResult for this segment. + public static func analyzePathSegment( + elevationProfile: ArraySlice, + startHeightMeters: Double, + endHeightMeters: Double, + frequencyMHz: Double, + refractionK: Double + ) -> PathAnalysisResult { + analyze( + elevationProfile: elevationProfile, + startHeightMeters: startHeightMeters, + endHeightMeters: endHeightMeters, + frequencyMHz: frequencyMHz, + refractionK: refractionK, + distanceOriginMeters: elevationProfile.first?.distanceFromAMeters ?? 0 + ) + } + + // MARK: - Shared Analysis Core + + /// Distance margin (meters) within which a sample counts as an endpoint and is skipped. + private static let endpointSkipMarginMeters: Double = 1 + + /// Shared core for `analyzePath` and `analyzePathSegment`. + /// + /// `distanceOriginMeters` anchors the local distance axis: 0 for a full + /// path, the first sample's `distanceFromAMeters` for a segment. Recorded + /// obstruction points always keep the sample's original `distanceFromAMeters` + /// so they stay in full-path coordinates for chart rendering. + private static func analyze( + elevationProfile: ArraySlice, + startHeightMeters: Double, + endHeightMeters: Double, + frequencyMHz: Double, + refractionK: Double, + distanceOriginMeters: Double + ) -> PathAnalysisResult { + guard elevationProfile.count >= 2, + let firstSample = elevationProfile.first, + let lastSample = elevationProfile.last else { + return emptyResult(frequencyMHz: frequencyMHz, refractionK: refractionK) } - // MARK: - Fresnel Zone - - /// Calculates the first Fresnel zone radius at a point along the path. - /// - /// The Fresnel zone represents the ellipsoidal region around the direct - /// line-of-sight path where radio waves propagate. For best reception, - /// at least 60% of the first Fresnel zone should be clear of obstructions. - /// - /// - Parameters: - /// - frequencyMHz: The frequency in megahertz. - /// - distanceToAMeters: Distance from point A to the calculation point in meters. - /// - distanceToBMeters: Distance from the calculation point to point B in meters. - /// - Returns: The first Fresnel zone radius in meters. - public static func fresnelRadius( - frequencyMHz: Double, - distanceToAMeters: Double, - distanceToBMeters: Double - ) -> Double { - guard frequencyMHz > 0, distanceToAMeters > 0, distanceToBMeters > 0 else { return 0 } - - let lambda = wavelength(frequencyMHz: frequencyMHz) - let totalDistance = distanceToAMeters + distanceToBMeters - - // First Fresnel zone radius: r = sqrt((lambda * d1 * d2) / (d1 + d2)) - return sqrt((lambda * distanceToAMeters * distanceToBMeters) / totalDistance) - } - - // MARK: - Earth Bulge - - /// Calculates the earth bulge (curvature correction) at a point along the path. - /// - /// Earth bulge represents how much the curved surface of the Earth rises - /// above a straight line between two points. This is critical for long-distance - /// radio links where the curvature can obstruct the signal path. - /// - /// - Parameters: - /// - distanceToAMeters: Distance from point A to the calculation point in meters. - /// - distanceToBMeters: Distance from the calculation point to point B in meters. - /// - refractionK: The effective earth radius factor. Use 1.0 for no adjustment, - /// 1.33 (4/3) for standard atmosphere, or 4.0 for ducting conditions. - /// - Returns: The earth bulge in meters. - public static func earthBulge( - distanceToAMeters: Double, - distanceToBMeters: Double, - refractionK: Double - ) -> Double { - guard distanceToAMeters > 0, distanceToBMeters > 0, refractionK > 0 else { return 0 } - - let earthRadiusMeters = earthRadiusKm * 1000 - let effectiveEarthRadius = refractionK * earthRadiusMeters - - // Earth bulge: h = (d1 * d2) / (2 * Re_effective) - return (distanceToAMeters * distanceToBMeters) / (2 * effectiveEarthRadius) - } + let lengthMeters = lastSample.distanceFromAMeters - distanceOriginMeters - // MARK: - Path Loss - - /// Calculates the free-space path loss in decibels. - /// - /// Free-space path loss represents the attenuation of radio signal - /// as it travels through free space (vacuum). Real-world losses are - /// typically higher due to atmospheric absorption and other factors. - /// - /// - Parameters: - /// - distanceMeters: The distance in meters. - /// - frequencyMHz: The frequency in megahertz. - /// - Returns: The free-space path loss in dB. - public static func pathLoss(distanceMeters: Double, frequencyMHz: Double) -> Double { - guard distanceMeters > 0, frequencyMHz > 0 else { return 0 } - - // FSPL (dB) = 20*log10(d) + 20*log10(f) + 20*log10(4*pi/c) - // Simplified: FSPL = 20*log10(d_m) + 20*log10(f_MHz) + 20*log10(4*pi*1e6/c) - // The constant = 20*log10(4*pi*1e6/299792458) ≈ -27.55 - let distanceComponent = 20 * log10(distanceMeters) - let frequencyComponent = 20 * log10(frequencyMHz) - let constant = -27.55 - - return distanceComponent + frequencyComponent + constant + guard lengthMeters > 0 else { + return emptyResult(frequencyMHz: frequencyMHz, refractionK: refractionK) } - // MARK: - Diffraction Loss - - /// Calculates the knife-edge diffraction loss for an obstruction. - /// - /// Uses the Fresnel-Kirchhoff diffraction parameter (v) to estimate - /// the loss caused by a single knife-edge obstruction in the path. - /// - /// - Parameters: - /// - obstructionHeightMeters: The height of the obstruction above the line-of-sight - /// (positive = blocked, negative = clearance). - /// - distanceToAMeters: Distance from point A to the obstruction in meters. - /// - distanceToBMeters: Distance from the obstruction to point B in meters. - /// - frequencyMHz: The frequency in megahertz. - /// - Returns: The diffraction loss in dB (positive value represents loss). - public static func diffractionLoss( - obstructionHeightMeters: Double, - distanceToAMeters: Double, - distanceToBMeters: Double, - frequencyMHz: Double - ) -> Double { - guard distanceToAMeters > 0, distanceToBMeters > 0, frequencyMHz > 0 else { return 0 } - - let lambda = wavelength(frequencyMHz: frequencyMHz) - let totalDistance = distanceToAMeters + distanceToBMeters - - // Fresnel-Kirchhoff diffraction parameter: - // v = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)) - let vParam = obstructionHeightMeters * sqrt( - 2 * totalDistance / (lambda * distanceToAMeters * distanceToBMeters) + // Antenna heights above sea level + let antennaStartHeight = firstSample.elevation + startHeightMeters + let antennaEndHeight = lastSample.elevation + endHeightMeters + + // Calculate free-space path loss + let fspl = pathLoss(distanceMeters: lengthMeters, frequencyMHz: frequencyMHz) + + var worstClearancePercent = Double.infinity + var peakDiffractionLoss = 0.0 + var obstructionPoints: [ObstructionPoint] = [] + + // Analyze each intermediate sample point (skip endpoints) + for sample in elevationProfile { + let distanceFromStart = sample.distanceFromAMeters - distanceOriginMeters + let distanceToEnd = lengthMeters - distanceFromStart + + // Skip points at or very near the endpoints + guard distanceFromStart > endpointSkipMarginMeters, + distanceToEnd > endpointSkipMarginMeters else { continue } + + // Line of sight height at this point (linear interpolation) + let fraction = distanceFromStart / lengthMeters + let losHeight = antennaStartHeight + fraction * (antennaEndHeight - antennaStartHeight) + + // Effective terrain height including earth bulge + let bulge = earthBulge( + distanceToAMeters: distanceFromStart, + distanceToBMeters: distanceToEnd, + refractionK: refractionK + ) + let effectiveTerrainHeight = sample.elevation + bulge + + // Calculate Fresnel zone radius at this point + let fresnelZoneRadius = fresnelRadius( + frequencyMHz: frequencyMHz, + distanceToAMeters: distanceFromStart, + distanceToBMeters: distanceToEnd + ) + + // Clearance: distance from terrain to line of sight + let clearance = losHeight - effectiveTerrainHeight + + // Fresnel clearance percentage + // 100% = terrain clears full first Fresnel zone + // 0% = terrain touches line of sight + // <0% = terrain blocks line of sight + let clearancePercent: Double = if fresnelZoneRadius > 0 { + (clearance / fresnelZoneRadius) * 100 + } else { + clearance > 0 ? 100 : 0 + } + + // Track worst clearance + if clearancePercent < worstClearancePercent { + worstClearancePercent = clearancePercent + } + + // Calculate diffraction loss if there's an obstruction + // Obstruction height is negative clearance (positive = blocked) + let obstructionHeight = effectiveTerrainHeight - losHeight + if obstructionHeight > -fresnelZoneRadius { + let diffLoss = diffractionLoss( + obstructionHeightMeters: obstructionHeight, + distanceToAMeters: distanceFromStart, + distanceToBMeters: distanceToEnd, + frequencyMHz: frequencyMHz ) - - // Approximate diffraction loss based on v parameter - // Using ITU-R P.526 approximation - return diffractionLossFromV(vParam) - } - - /// Fresnel-Kirchhoff parameter at or below which the path has full clearance and no knife-edge loss. - private static let diffractionClearThresholdV: Double = -0.78 - - /// Calculates diffraction loss from the Fresnel-Kirchhoff v parameter. - /// - /// Uses the ITU-R P.526 single-equation knife-edge diffraction model. A single - /// expression keeps the loss continuous and monotonic across the whole obstruction - /// range, avoiding the branch-join drift of the piecewise polynomial approximations. - /// - /// - Parameter vParam: The Fresnel-Kirchhoff diffraction parameter. - /// - Returns: The diffraction loss in dB. - private static func diffractionLossFromV(_ vParam: Double) -> Double { - guard vParam > diffractionClearThresholdV else { return 0 } - let shifted = vParam - 0.1 - return 6.9 + 20 * log10(sqrt(shifted * shifted + 1) + shifted) - } - - // MARK: - Distance Calculation - - /// Calculates the great-circle distance between two coordinates using the Haversine formula. - /// - /// - Parameters: - /// - from: The starting coordinate. - /// - destination: The ending coordinate. - /// - Returns: The distance in meters. - public static func distance(from: CLLocationCoordinate2D, to destination: CLLocationCoordinate2D) -> Double { - let earthRadiusMeters = earthRadiusKm * 1000 - - let lat1 = from.latitude * .pi / 180 - let lat2 = destination.latitude * .pi / 180 - let deltaLat = (destination.latitude - from.latitude) * .pi / 180 - let deltaLon = (destination.longitude - from.longitude) * .pi / 180 - - // Haversine formula - let haversineA = sin(deltaLat / 2) * sin(deltaLat / 2) + - cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) - let angularDistance = 2 * atan2(sqrt(haversineA), sqrt(1 - haversineA)) - - return earthRadiusMeters * angularDistance - } - - // MARK: - Path Analysis - - /// Analyze full path for clearance and signal propagation. - /// - /// This function evaluates an elevation profile between two points to determine: - /// - Free-space path loss (FSPL) - /// - Additional loss from diffraction over obstructions - /// - Fresnel zone clearance at each point - /// - Overall clearance status of the path - /// - /// - Parameters: - /// - elevationProfile: Array of elevation samples along the path from A to B. - /// - pointAHeightMeters: Antenna height at point A in meters above ground. - /// - pointBHeightMeters: Antenna height at point B in meters above ground. - /// - frequencyMHz: The operating frequency in megahertz. - /// - refractionK: The effective earth radius factor. Use 1.0 for no adjustment, - /// 1.33 (4/3) for standard atmosphere, or 4.0 for ducting conditions. - /// - Returns: A PathAnalysisResult containing loss calculations and clearance status. - public static func analyzePath( - elevationProfile: [ElevationSample], - pointAHeightMeters: Double, - pointBHeightMeters: Double, - frequencyMHz: Double, - refractionK: Double - ) -> PathAnalysisResult { - // Full-path distances are measured from A itself, so the origin is 0 - // even if the first sample carries a non-zero distance. - analyze( - elevationProfile: elevationProfile[...], - startHeightMeters: pointAHeightMeters, - endHeightMeters: pointBHeightMeters, - frequencyMHz: frequencyMHz, - refractionK: refractionK, - distanceOriginMeters: 0 - ) - } - - // MARK: - Segment Analysis - - /// Analyze a segment of the path for clearance and signal propagation. - /// Uses ArraySlice to avoid copying - critical for 60fps drag performance. - /// - /// - Parameters: - /// - elevationProfile: Slice of elevation samples for this segment. - /// - startHeightMeters: Antenna height at segment start in meters above ground. - /// - endHeightMeters: Antenna height at segment end in meters above ground. - /// - frequencyMHz: The operating frequency in megahertz. - /// - refractionK: The effective earth radius factor. - /// - Returns: A PathAnalysisResult for this segment. - public static func analyzePathSegment( - elevationProfile: ArraySlice, - startHeightMeters: Double, - endHeightMeters: Double, - frequencyMHz: Double, - refractionK: Double - ) -> PathAnalysisResult { - analyze( - elevationProfile: elevationProfile, - startHeightMeters: startHeightMeters, - endHeightMeters: endHeightMeters, - frequencyMHz: frequencyMHz, - refractionK: refractionK, - distanceOriginMeters: elevationProfile.first?.distanceFromAMeters ?? 0 - ) - } - - // MARK: - Shared Analysis Core - - /// Distance margin (meters) within which a sample counts as an endpoint and is skipped. - private static let endpointSkipMarginMeters: Double = 1 - - /// Shared core for `analyzePath` and `analyzePathSegment`. - /// - /// `distanceOriginMeters` anchors the local distance axis: 0 for a full - /// path, the first sample's `distanceFromAMeters` for a segment. Recorded - /// obstruction points always keep the sample's original `distanceFromAMeters` - /// so they stay in full-path coordinates for chart rendering. - private static func analyze( - elevationProfile: ArraySlice, - startHeightMeters: Double, - endHeightMeters: Double, - frequencyMHz: Double, - refractionK: Double, - distanceOriginMeters: Double - ) -> PathAnalysisResult { - guard elevationProfile.count >= 2, - let firstSample = elevationProfile.first, - let lastSample = elevationProfile.last else { - return emptyResult(frequencyMHz: frequencyMHz, refractionK: refractionK) - } - - let lengthMeters = lastSample.distanceFromAMeters - distanceOriginMeters - - guard lengthMeters > 0 else { - return emptyResult(frequencyMHz: frequencyMHz, refractionK: refractionK) + if diffLoss > peakDiffractionLoss { + peakDiffractionLoss = diffLoss } - - // Antenna heights above sea level - let antennaStartHeight = firstSample.elevation + startHeightMeters - let antennaEndHeight = lastSample.elevation + endHeightMeters - - // Calculate free-space path loss - let fspl = pathLoss(distanceMeters: lengthMeters, frequencyMHz: frequencyMHz) - - var worstClearancePercent = Double.infinity - var peakDiffractionLoss = 0.0 - var obstructionPoints: [ObstructionPoint] = [] - - // Analyze each intermediate sample point (skip endpoints) - for sample in elevationProfile { - let distanceFromStart = sample.distanceFromAMeters - distanceOriginMeters - let distanceToEnd = lengthMeters - distanceFromStart - - // Skip points at or very near the endpoints - guard distanceFromStart > endpointSkipMarginMeters, - distanceToEnd > endpointSkipMarginMeters else { continue } - - // Line of sight height at this point (linear interpolation) - let fraction = distanceFromStart / lengthMeters - let losHeight = antennaStartHeight + fraction * (antennaEndHeight - antennaStartHeight) - - // Effective terrain height including earth bulge - let bulge = earthBulge( - distanceToAMeters: distanceFromStart, - distanceToBMeters: distanceToEnd, - refractionK: refractionK - ) - let effectiveTerrainHeight = sample.elevation + bulge - - // Calculate Fresnel zone radius at this point - let fresnelZoneRadius = fresnelRadius( - frequencyMHz: frequencyMHz, - distanceToAMeters: distanceFromStart, - distanceToBMeters: distanceToEnd - ) - - // Clearance: distance from terrain to line of sight - let clearance = losHeight - effectiveTerrainHeight - - // Fresnel clearance percentage - // 100% = terrain clears full first Fresnel zone - // 0% = terrain touches line of sight - // <0% = terrain blocks line of sight - let clearancePercent: Double - if fresnelZoneRadius > 0 { - clearancePercent = (clearance / fresnelZoneRadius) * 100 - } else { - clearancePercent = clearance > 0 ? 100 : 0 - } - - // Track worst clearance - if clearancePercent < worstClearancePercent { - worstClearancePercent = clearancePercent - } - - // Calculate diffraction loss if there's an obstruction - // Obstruction height is negative clearance (positive = blocked) - let obstructionHeight = effectiveTerrainHeight - losHeight - if obstructionHeight > -fresnelZoneRadius { - let diffLoss = diffractionLoss( - obstructionHeightMeters: obstructionHeight, - distanceToAMeters: distanceFromStart, - distanceToBMeters: distanceToEnd, - frequencyMHz: frequencyMHz - ) - if diffLoss > peakDiffractionLoss { - peakDiffractionLoss = diffLoss - } - } - - // Record obstruction points where clearance < marginal threshold - if clearancePercent < marginalClearanceThreshold { - let obstruction = ObstructionPoint( - distanceFromAMeters: sample.distanceFromAMeters, - obstructionHeightMeters: obstructionHeight, - fresnelClearancePercent: clearancePercent - ) - obstructionPoints.append(obstruction) - } - } - - // If no samples were analyzed, set default clearance - if worstClearancePercent == .infinity { - worstClearancePercent = 100 - } - - return PathAnalysisResult( - distanceMeters: lengthMeters, - freeSpacePathLoss: fspl, - peakDiffractionLoss: peakDiffractionLoss, - totalPathLoss: fspl + peakDiffractionLoss, - clearanceStatus: clearanceStatus(forWorstClearancePercent: worstClearancePercent), - worstClearancePercent: worstClearancePercent, - obstructionPoints: obstructionPoints, - frequencyMHz: frequencyMHz, - refractionK: refractionK + } + + // Record obstruction points where clearance < marginal threshold + if clearancePercent < marginalClearanceThreshold { + let obstruction = ObstructionPoint( + distanceFromAMeters: sample.distanceFromAMeters, + obstructionHeightMeters: obstructionHeight, + fresnelClearancePercent: clearancePercent ) + obstructionPoints.append(obstruction) + } } - /// The zeroed blocked result returned for degenerate profiles. - private static func emptyResult(frequencyMHz: Double, refractionK: Double) -> PathAnalysisResult { - PathAnalysisResult( - distanceMeters: 0, - freeSpacePathLoss: 0, - peakDiffractionLoss: 0, - totalPathLoss: 0, - clearanceStatus: .blocked, - worstClearancePercent: 0, - obstructionPoints: [], - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) + // If no samples were analyzed, set default clearance + if worstClearancePercent == .infinity { + worstClearancePercent = 100 } - /// Maps a worst-case Fresnel clearance percentage to a clearance status. - private static func clearanceStatus(forWorstClearancePercent percent: Double) -> ClearanceStatus { - if percent >= clearClearanceThreshold { - return .clear - } else if percent >= marginalClearanceThreshold { - return .marginal - } else if percent >= 0 { - return .partialObstruction - } else { - return .blocked - } + return PathAnalysisResult( + distanceMeters: lengthMeters, + freeSpacePathLoss: fspl, + peakDiffractionLoss: peakDiffractionLoss, + totalPathLoss: fspl + peakDiffractionLoss, + clearanceStatus: clearanceStatus(forWorstClearancePercent: worstClearancePercent), + worstClearancePercent: worstClearancePercent, + obstructionPoints: obstructionPoints, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + } + + /// The zeroed blocked result returned for degenerate profiles. + private static func emptyResult(frequencyMHz: Double, refractionK: Double) -> PathAnalysisResult { + PathAnalysisResult( + distanceMeters: 0, + freeSpacePathLoss: 0, + peakDiffractionLoss: 0, + totalPathLoss: 0, + clearanceStatus: .blocked, + worstClearancePercent: 0, + obstructionPoints: [], + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + } + + /// Maps a worst-case Fresnel clearance percentage to a clearance status. + private static func clearanceStatus(forWorstClearancePercent percent: Double) -> ClearanceStatus { + if percent >= clearClearanceThreshold { + .clear + } else if percent >= marginalClearanceThreshold { + .marginal + } else if percent >= 0 { + .partialObstruction + } else { + .blocked } + } } diff --git a/MC1Services/Sources/MC1Services/RF/RelayPathAnalysisResult.swift b/MC1Services/Sources/MC1Services/RF/RelayPathAnalysisResult.swift index 61310858..6d3466b8 100644 --- a/MC1Services/Sources/MC1Services/RF/RelayPathAnalysisResult.swift +++ b/MC1Services/Sources/MC1Services/RF/RelayPathAnalysisResult.swift @@ -2,25 +2,27 @@ import Foundation /// Combined result when analyzing a path via repeater public struct RelayPathAnalysisResult: Equatable, Sendable { - public let segmentAR: SegmentAnalysisResult - public let segmentRB: SegmentAnalysisResult + public let segmentAR: SegmentAnalysisResult + public let segmentRB: SegmentAnalysisResult - public init(segmentAR: SegmentAnalysisResult, segmentRB: SegmentAnalysisResult) { - self.segmentAR = segmentAR - self.segmentRB = segmentRB - } + public init(segmentAR: SegmentAnalysisResult, segmentRB: SegmentAnalysisResult) { + self.segmentAR = segmentAR + self.segmentRB = segmentRB + } - public var totalDistanceMeters: Double { - segmentAR.distanceMeters + segmentRB.distanceMeters - } + public var totalDistanceMeters: Double { + segmentAR.distanceMeters + segmentRB.distanceMeters + } - public var totalDistanceKm: Double { totalDistanceMeters / 1000 } + public var totalDistanceKm: Double { + totalDistanceMeters / 1000 + } - /// Overall status is the worst of the two segments - public var overallStatus: ClearanceStatus { - let statusOrder: [ClearanceStatus] = [.clear, .marginal, .partialObstruction, .blocked] - let arIndex = statusOrder.firstIndex(of: segmentAR.clearanceStatus) ?? 0 - let rbIndex = statusOrder.firstIndex(of: segmentRB.clearanceStatus) ?? 0 - return statusOrder[max(arIndex, rbIndex)] - } + /// Overall status is the worst of the two segments + public var overallStatus: ClearanceStatus { + let statusOrder: [ClearanceStatus] = [.clear, .marginal, .partialObstruction, .blocked] + let arIndex = statusOrder.firstIndex(of: segmentAR.clearanceStatus) ?? 0 + let rbIndex = statusOrder.firstIndex(of: segmentRB.clearanceStatus) ?? 0 + return statusOrder[max(arIndex, rbIndex)] + } } diff --git a/MC1Services/Sources/MC1Services/RF/SegmentAnalysisResult.swift b/MC1Services/Sources/MC1Services/RF/SegmentAnalysisResult.swift index 9680d889..9b57549a 100644 --- a/MC1Services/Sources/MC1Services/RF/SegmentAnalysisResult.swift +++ b/MC1Services/Sources/MC1Services/RF/SegmentAnalysisResult.swift @@ -2,25 +2,27 @@ import Foundation /// Result for a single path segment (A to R, or R to B) public struct SegmentAnalysisResult: Equatable, Sendable { - public let startLabel: String - public let endLabel: String - public let clearanceStatus: ClearanceStatus - public let distanceMeters: Double - public let worstClearancePercent: Double + public let startLabel: String + public let endLabel: String + public let clearanceStatus: ClearanceStatus + public let distanceMeters: Double + public let worstClearancePercent: Double - public init( - startLabel: String, - endLabel: String, - clearanceStatus: ClearanceStatus, - distanceMeters: Double, - worstClearancePercent: Double - ) { - self.startLabel = startLabel - self.endLabel = endLabel - self.clearanceStatus = clearanceStatus - self.distanceMeters = distanceMeters - self.worstClearancePercent = worstClearancePercent - } + public init( + startLabel: String, + endLabel: String, + clearanceStatus: ClearanceStatus, + distanceMeters: Double, + worstClearancePercent: Double + ) { + self.startLabel = startLabel + self.endLabel = endLabel + self.clearanceStatus = clearanceStatus + self.distanceMeters = distanceMeters + self.worstClearancePercent = worstClearancePercent + } - public var distanceKm: Double { distanceMeters / 1000 } + public var distanceKm: Double { + distanceMeters / 1000 + } } diff --git a/MC1Services/Sources/MC1Services/ServiceContainer.swift b/MC1Services/Sources/MC1Services/ServiceContainer.swift index 95ddd964..1d2e752a 100644 --- a/MC1Services/Sources/MC1Services/ServiceContainer.swift +++ b/MC1Services/Sources/MC1Services/ServiceContainer.swift @@ -1,7 +1,7 @@ import Foundation +import MeshCore import OSLog import SwiftData -import MeshCore /// Dependency injection container for MC1Services. /// @@ -66,437 +66,435 @@ import MeshCore @Observable @MainActor public final class ServiceContainer { + // MARK: - Core Infrastructure - // MARK: - Core Infrastructure - - /// The MeshCore session for device communication - public let session: MeshCoreSession - - /// The persistence store for SwiftData operations - public let dataStore: PersistenceStore - - /// Persists inline-image aspect ratios for chat link previews. Built early - /// because downstream caches and the prefetcher depend on it. - public let inlineImageDimensionsStore: InlineImageDimensionsStore - - // MARK: - Independent Services - - /// Keychain service for secure credential storage - let keychainService: KeychainService - - /// Notification service for local notifications - public let notificationService: NotificationService - - // MARK: - Core Services - - /// Service for managing contacts - public let contactService: ContactService + /// The MeshCore session for device communication + public let session: MeshCoreSession - /// Service for sending messages, retry logic, and ACK/delivery tracking. - /// It does not receive: inbound messages arrive through `MessagePollingService` - /// and the handlers `SyncCoordinator.wireMessageHandlers` installs there. - public let messageService: MessageService + /// The persistence store for SwiftData operations + public let dataStore: PersistenceStore - /// Service for managing channels (groups) - public let channelService: ChannelService + /// Persists inline-image aspect ratios for chat link previews. Built early + /// because downstream caches and the prefetcher depend on it. + public let inlineImageDimensionsStore: InlineImageDimensionsStore - /// Service for device settings management - public let settingsService: SettingsService + // MARK: - Independent Services - /// Service for device data persistence - public let deviceService: DeviceService + /// Keychain service for secure credential storage + let keychainService: KeychainService - /// Service for advertisements and path discovery - public let advertisementService: AdvertisementService + /// Notification service for local notifications + public let notificationService: NotificationService - /// Service for polling and routing messages - let messagePollingService: MessagePollingService + // MARK: - Core Services - /// Service for binary protocol operations (telemetry, status, etc.) - public let binaryProtocolService: BinaryProtocolService + /// Service for managing contacts + public let contactService: ContactService - /// Service for RX log packet capture - public let rxLogService: RxLogService + /// Service for sending messages, retry logic, and ACK/delivery tracking. + /// It does not receive: inbound messages arrive through `MessagePollingService` + /// and the handlers `SyncCoordinator.wireMessageHandlers` installs there. + public let messageService: MessageService - /// Service for tracking heard repeats of sent messages - public let heardRepeatsService: HeardRepeatsService + /// Service for managing channels (groups) + public let channelService: ChannelService - /// Buffer for batching debug log entries to persistence - public let debugLogBuffer: DebugLogBuffer + /// Service for device settings management + public let settingsService: SettingsService - /// Service for handling emoji reactions on channel messages - public let reactionService: ReactionService + /// Service for device data persistence + public let deviceService: DeviceService - /// Service for exporting/importing node configuration - public let nodeConfigService: NodeConfigService + /// Service for advertisements and path discovery + public let advertisementService: AdvertisementService - /// Service for node status history snapshots - public let nodeSnapshotService: NodeSnapshotService + /// Service for polling and routing messages + let messagePollingService: MessagePollingService - // MARK: - Remote Node Services + /// Service for binary protocol operations (telemetry, status, etc.) + public let binaryProtocolService: BinaryProtocolService - /// Service for remote node session management - public let remoteNodeService: RemoteNodeService + /// Service for RX log packet capture + public let rxLogService: RxLogService - /// Service for repeater administration - public let repeaterAdminService: RepeaterAdminService + /// Service for tracking heard repeats of sent messages + public let heardRepeatsService: HeardRepeatsService - /// Service for room server administration (telemetry, settings) - public let roomAdminService: RoomAdminService + /// Buffer for batching debug log entries to persistence + public let debugLogBuffer: DebugLogBuffer - /// Service for room server operations - public let roomServerService: RoomServerService + /// Service for handling emoji reactions on channel messages + public let reactionService: ReactionService - // MARK: - Sync Coordination + /// Service for exporting/importing node configuration + public let nodeConfigService: NodeConfigService - /// Sync coordinator for managing sync lifecycle - public let syncCoordinator: SyncCoordinator + /// Service for node status history snapshots + public let nodeSnapshotService: NodeSnapshotService - // MARK: - Chat Send Queue + // MARK: - Remote Node Services + + /// Service for remote node session management + public let remoteNodeService: RemoteNodeService - /// Service-layer outbound chat queue. Replaces the per-view-model - /// `dmSendQueue` / `channelSendQueue` instances. Hydrates from - /// `PendingSend` on construction; drain gated on `ConnectionManager` - /// transport state via `BLETransportOpenedSignal`. - /// - /// Constructed eagerly in `ServiceContainer.init` because `radioID` - /// is known at container-build time (`buildServicesAndSaveDevice` - /// resolves the device record before instantiating the container). - /// Eager construction closes the visibility window where the - /// container exists but the service is `nil`. - public let chatSendQueueService: ChatSendQueueService - - // MARK: - Notification Actions - - /// Executes the multi-service transactions behind notification actions - /// (quick reply, mark-as-read, reactions). `AppState` installs - /// `NotificationService` forwarders that delegate here and injects the - /// app-layer inputs via `configure(isConnectionReady:localNodeName:)`. - public let notificationActionHandler: NotificationActionHandler - - // MARK: - App State - - /// Provider for checking app foreground/background state - /// Used to determine sync behavior (full vs incremental) - let appStateProvider: AppStateProvider? - - // MARK: - State - - /// Event-monitoring lifecycle. Tri-state (not a Bool) so start and stop can - /// claim the transition synchronously before their first await; two callers - /// interleaving at those suspension points must not double-start or - /// double-stop every per-service monitor. - private enum EventMonitoringState { - case stopped - case starting - case active - case stopping + /// Service for repeater administration + public let repeaterAdminService: RepeaterAdminService + + /// Service for room server administration (telemetry, settings) + public let roomAdminService: RoomAdminService + + /// Service for room server operations + public let roomServerService: RoomServerService + + // MARK: - Sync Coordination + + /// Sync coordinator for managing sync lifecycle + public let syncCoordinator: SyncCoordinator + + // MARK: - Chat Send Queue + + /// Service-layer outbound chat queue. Replaces the per-view-model + /// `dmSendQueue` / `channelSendQueue` instances. Hydrates from + /// `PendingSend` on construction; drain gated on `ConnectionManager` + /// transport state via `BLETransportOpenedSignal`. + /// + /// Constructed eagerly in `ServiceContainer.init` because `radioID` + /// is known at container-build time (`buildServicesAndSaveDevice` + /// resolves the device record before instantiating the container). + /// Eager construction closes the visibility window where the + /// container exists but the service is `nil`. + public let chatSendQueueService: ChatSendQueueService + + // MARK: - Notification Actions + + /// Executes the multi-service transactions behind notification actions + /// (quick reply, mark-as-read, reactions). `AppState` installs + /// `NotificationService` forwarders that delegate here and injects the + /// app-layer inputs via `configure(isConnectionReady:localNodeName:)`. + public let notificationActionHandler: NotificationActionHandler + + // MARK: - App State + + /// Provider for checking app foreground/background state + /// Used to determine sync behavior (full vs incremental) + let appStateProvider: AppStateProvider? + + // MARK: - State + + /// Event-monitoring lifecycle. Tri-state (not a Bool) so start and stop can + /// claim the transition synchronously before their first await; two callers + /// interleaving at those suspension points must not double-start or + /// double-stop every per-service monitor. + private enum EventMonitoringState { + case stopped + case starting + case active + case stopping + } + + private var eventMonitoringState: EventMonitoringState = .stopped + + /// Whether service event listeners are active or currently starting. + var isEventMonitoringActive: Bool { + eventMonitoringState == .starting || eventMonitoringState == .active + } + + // MARK: - Initialization + + /// Creates a new service container. + /// + /// - Parameters: + /// - session: The MeshCoreSession for device communication + /// - modelContainer: The SwiftData model container for persistence + /// - radioID: The connected device's radio ID. Used to scope the + /// chat send queue's pending-send rows so two radios cannot share + /// drain state across reconnects. + /// - appStateProvider: Optional provider for app foreground/background state + /// - connectionStateEvents: Optional broadcaster of connection-state + /// changes. When provided, the chat send queue observes it to wake + /// parked drains on each disconnected-to-connected edge. + /// - initialConnectionState: The connection state at container build + /// time. Connect paths reach `.connected` before constructing the + /// container, so the queue's edge detection treats an + /// already-connected initial value as a fired edge. + init( + session: MeshCoreSession, + modelContainer: ModelContainer, + radioID: UUID, + appStateProvider: AppStateProvider? = nil, + connectionStateEvents: EventBroadcaster? = nil, + initialConnectionState: DeviceConnectionState = .disconnected + ) { + self.session = session + self.appStateProvider = appStateProvider + dataStore = PersistenceStore(modelContainer: modelContainer) + inlineImageDimensionsStore = InlineImageDimensionsStore() + + // Independent services (no dependencies) + keychainService = KeychainService() + notificationService = NotificationService() + syncCoordinator = SyncCoordinator() + + // Core services, constructed so every dependency exists before its consumer + heardRepeatsService = HeardRepeatsService(dataStore: dataStore) + rxLogService = RxLogService( + session: session, + dataStore: dataStore, + heardRepeatsService: heardRepeatsService + ) + remoteNodeService = RemoteNodeService( + session: session, + dataStore: dataStore, + keychainService: keychainService + ) + let cleanupCoordinator = ContactCleanupCoordinator( + dataStore: dataStore, + syncCoordinator: syncCoordinator, + notificationService: notificationService, + remoteNodeService: remoteNodeService + ) + contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: syncCoordinator, + cleanupCoordinator: cleanupCoordinator + ) + messageService = MessageService( + session: session, + dataStore: dataStore, + contactService: contactService + ) + channelService = ChannelService( + session: session, + dataStore: dataStore, + rxLogService: rxLogService + ) + settingsService = SettingsService(session: session) + deviceService = DeviceService(dataStore: dataStore) + advertisementService = AdvertisementService(session: session, dataStore: dataStore) + messagePollingService = MessagePollingService(session: session, dataStore: dataStore) + binaryProtocolService = BinaryProtocolService(session: session, dataStore: dataStore) + debugLogBuffer = DebugLogBuffer(dataStore: dataStore) + DebugLogBuffer.shared = debugLogBuffer + reactionService = ReactionService() + nodeConfigService = NodeConfigService( + session: session, + settingsService: settingsService, + channelService: channelService, + dataStore: dataStore, + syncCoordinator: syncCoordinator + ) + nodeSnapshotService = NodeSnapshotService(dataStore: dataStore) + + // Higher-level services (depend on other services) + repeaterAdminService = RepeaterAdminService( + session: session, + remoteNodeService: remoteNodeService, + dataStore: dataStore + ) + roomAdminService = RoomAdminService( + remoteNodeService: remoteNodeService, + dataStore: dataStore + ) + roomServerService = RoomServerService( + session: session, + remoteNodeService: remoteNodeService, + dataStore: dataStore + ) + + chatSendQueueService = ChatSendQueueService( + radioID: radioID, + dataStore: dataStore, + messageService: messageService, + channelService: channelService, + reactionService: reactionService + ) + notificationActionHandler = NotificationActionHandler( + dataStore: dataStore, + messageService: messageService, + notificationService: notificationService, + roomServerService: roomServerService, + syncCoordinator: syncCoordinator + ) + if let connectionStateEvents { + chatSendQueueService.observeConnectionState( + initial: initialConnectionState, + events: connectionStateEvents.subscribe() + ) } - - private var eventMonitoringState: EventMonitoringState = .stopped - - /// Whether service event listeners are active or currently starting. - var isEventMonitoringActive: Bool { - eventMonitoringState == .starting || eventMonitoringState == .active - } - - // MARK: - Initialization - - /// Creates a new service container. - /// - /// - Parameters: - /// - session: The MeshCoreSession for device communication - /// - modelContainer: The SwiftData model container for persistence - /// - radioID: The connected device's radio ID. Used to scope the - /// chat send queue's pending-send rows so two radios cannot share - /// drain state across reconnects. - /// - appStateProvider: Optional provider for app foreground/background state - /// - connectionStateEvents: Optional broadcaster of connection-state - /// changes. When provided, the chat send queue observes it to wake - /// parked drains on each disconnected-to-connected edge. - /// - initialConnectionState: The connection state at container build - /// time. Connect paths reach `.connected` before constructing the - /// container, so the queue's edge detection treats an - /// already-connected initial value as a fired edge. - init( - session: MeshCoreSession, - modelContainer: ModelContainer, - radioID: UUID, - appStateProvider: AppStateProvider? = nil, - connectionStateEvents: EventBroadcaster? = nil, - initialConnectionState: DeviceConnectionState = .disconnected - ) { - self.session = session - self.appStateProvider = appStateProvider - self.dataStore = PersistenceStore(modelContainer: modelContainer) - self.inlineImageDimensionsStore = InlineImageDimensionsStore() - - // Independent services (no dependencies) - self.keychainService = KeychainService() - self.notificationService = NotificationService() - self.syncCoordinator = SyncCoordinator() - - // Core services, constructed so every dependency exists before its consumer - self.heardRepeatsService = HeardRepeatsService(dataStore: dataStore) - self.rxLogService = RxLogService( - session: session, - dataStore: dataStore, - heardRepeatsService: heardRepeatsService + } + + // MARK: - Event Monitoring + + /// Starts event monitoring for all services. + /// + /// Call this after a device is connected to begin processing events + /// from the MeshCoreSession. + /// + /// - Parameters: + /// - radioID: The connected device's radio ID for data scoping + /// - enableAutoFetch: Whether to start message auto-fetch immediately (default true) + /// - enableAdvertisementMonitoring: Whether to start advertisement monitoring immediately (default true) + func startEventMonitoring( + radioID: UUID, + enableAutoFetch: Bool = true, + enableAdvertisementMonitoring: Bool = true + ) async { + // Claim synchronously before the awaits below so an overlapping caller + // (SyncCoordinator.onConnectionEstablished racing the foreground health + // check) cannot pass the guard and double-start every monitor. + guard eventMonitoringState == .stopped else { return } + eventMonitoringState = .starting + + let logger = Logger(subsystem: "com.mc1", category: "ServiceContainer") + + // Configure HeardRepeatsService with device info + do { + if let device = try await dataStore.fetchDevice(radioID: radioID) { + await heardRepeatsService.configure( + radioID: radioID, + localNodeName: device.nodeName ) - self.remoteNodeService = RemoteNodeService( - session: session, - dataStore: dataStore, - keychainService: keychainService - ) - let cleanupCoordinator = ContactCleanupCoordinator( - dataStore: dataStore, - syncCoordinator: syncCoordinator, - notificationService: notificationService, - remoteNodeService: remoteNodeService - ) - self.contactService = ContactService( - session: session, - dataStore: dataStore, - syncCoordinator: syncCoordinator, - cleanupCoordinator: cleanupCoordinator - ) - self.messageService = MessageService( - session: session, - dataStore: dataStore, - contactService: contactService - ) - self.channelService = ChannelService( - session: session, - dataStore: dataStore, - rxLogService: rxLogService - ) - self.settingsService = SettingsService(session: session) - self.deviceService = DeviceService(dataStore: dataStore) - self.advertisementService = AdvertisementService(session: session, dataStore: dataStore) - self.messagePollingService = MessagePollingService(session: session, dataStore: dataStore) - self.binaryProtocolService = BinaryProtocolService(session: session, dataStore: dataStore) - self.debugLogBuffer = DebugLogBuffer(dataStore: dataStore) - DebugLogBuffer.shared = debugLogBuffer - self.reactionService = ReactionService() - self.nodeConfigService = NodeConfigService( - session: session, - settingsService: settingsService, - channelService: channelService, - dataStore: dataStore, - syncCoordinator: syncCoordinator - ) - self.nodeSnapshotService = NodeSnapshotService(dataStore: dataStore) - - // Higher-level services (depend on other services) - self.repeaterAdminService = RepeaterAdminService( - session: session, - remoteNodeService: remoteNodeService, - dataStore: dataStore - ) - self.roomAdminService = RoomAdminService( - remoteNodeService: remoteNodeService, - dataStore: dataStore - ) - self.roomServerService = RoomServerService( - session: session, - remoteNodeService: remoteNodeService, - dataStore: dataStore - ) - - self.chatSendQueueService = ChatSendQueueService( - radioID: radioID, - dataStore: dataStore, - messageService: messageService, - channelService: channelService, - reactionService: reactionService - ) - self.notificationActionHandler = NotificationActionHandler( - dataStore: dataStore, - messageService: messageService, - notificationService: notificationService, - roomServerService: roomServerService, - syncCoordinator: syncCoordinator - ) - if let connectionStateEvents { - chatSendQueueService.observeConnectionState( - initial: initialConnectionState, - events: connectionStateEvents.subscribe() - ) - } + } else { + logger.warning("Device not found for HeardRepeatsService configuration") + } + } catch { + logger.warning("Failed to fetch device for HeardRepeatsService: \(error)") } - // MARK: - Event Monitoring - - /// Starts event monitoring for all services. - /// - /// Call this after a device is connected to begin processing events - /// from the MeshCoreSession. - /// - /// - Parameters: - /// - radioID: The connected device's radio ID for data scoping - /// - enableAutoFetch: Whether to start message auto-fetch immediately (default true) - /// - enableAdvertisementMonitoring: Whether to start advertisement monitoring immediately (default true) - func startEventMonitoring( - radioID: UUID, - enableAutoFetch: Bool = true, - enableAdvertisementMonitoring: Bool = true - ) async { - // Claim synchronously before the awaits below so an overlapping caller - // (SyncCoordinator.onConnectionEstablished racing the foreground health - // check) cannot pass the guard and double-start every monitor. - guard eventMonitoringState == .stopped else { return } - eventMonitoringState = .starting - - let logger = Logger(subsystem: "com.mc1", category: "ServiceContainer") - - // Configure HeardRepeatsService with device info - do { - if let device = try await dataStore.fetchDevice(radioID: radioID) { - await heardRepeatsService.configure( - radioID: radioID, - localNodeName: device.nodeName - ) - } else { - logger.warning("Device not found for HeardRepeatsService configuration") - } - } catch { - logger.warning("Failed to fetch device for HeardRepeatsService: \(error)") - } - - // Start event monitoring for services that need it - if enableAdvertisementMonitoring { - await advertisementService.startEventMonitoring(radioID: radioID) - } - await rxLogService.startEventMonitoring(radioID: radioID) - await messageService.startEventMonitoring() - await messageService.startAckExpiryChecking() - await remoteNodeService.startEventMonitoring() - - // Always start message event monitoring so handlers are ready for polled messages - await messagePollingService.startMessageEventMonitoring(radioID: radioID) - if enableAutoFetch { - await messagePollingService.startAutoFetch(radioID: radioID) - } - - // Prune debug logs on connection - Task { - try? await dataStore.pruneDebugLogEntries(keepCount: 1000) - } - - // Prune node status snapshots older than 1 year - Task { - let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: .now)! - await nodeSnapshotService.pruneOldSnapshots(olderThan: oneYearAgo) - } - - eventMonitoringState = .active + // Start event monitoring for services that need it + if enableAdvertisementMonitoring { + await advertisementService.startEventMonitoring(radioID: radioID) } - - /// Stops event monitoring for all services. - /// - /// Call this when disconnecting from a device. - func stopEventMonitoring() async { - // Claimed synchronously, mirroring startEventMonitoring, so two teardown - // paths interleaving at the awaits below cannot double-stop. - guard eventMonitoringState == .active else { return } - eventMonitoringState = .stopping - - await advertisementService.stopEventMonitoring() - await rxLogService.stopEventMonitoring() - await messageService.stopEventMonitoring() - // Do not fail in-flight DMs on disconnect. The firmware retains the - // expected ACK and re-emits the delivery confirmation whenever it - // returns, so a routine BLE cycle must not mark a delivered message - // `.failed`. Stop only the expiry checker; pending entries resolve on - // reconnect within the same session or expire via `ackGiveUpWindow`. - await messageService.stopAckExpiryChecking() - await messagePollingService.stopMessageEventMonitoring() - // RemoteNodeService event monitoring is per-session, handled internally - - // Flush debug log buffer - await debugLogBuffer.shutdown() - - eventMonitoringState = .stopped + await rxLogService.startEventMonitoring(radioID: radioID) + await messageService.startEventMonitoring() + await messageService.startAckExpiryChecking() + await remoteNodeService.startEventMonitoring() + + // Always start message event monitoring so handlers are ready for polled messages + await messagePollingService.startMessageEventMonitoring(radioID: radioID) + if enableAutoFetch { + await messagePollingService.startAutoFetch(radioID: radioID) } - /// Full container teardown. Must be awaited before nulling the container - /// so chat send queue drains and chat coordinator off-main builds release - /// the strong references they hold on `MessageService` and `dataStore`. - /// `stopEventMonitoring()` alone does not cover those. - func tearDown() async { - await stopEventMonitoring() - - // Break the retain cycles the wired handlers form. The message - // closures and the discovery event task capture this container's - // services strongly (via SyncDependencies), so without this the whole - // service graph leaks on every reconnect. Cleared after - // `stopEventMonitoring()` so the event tasks reading them are cancelled. - await messagePollingService.clearMessageHandlers() - await syncCoordinator.cancelDiscoveryEventMonitoring() - - // Finishing the event streams ends every consumer's for-await loop, - // releasing the strong service references those loops hold. - syncCoordinator.finishDataEvents() - advertisementService.finishEvents() - messageService.finishStatusEvents() - heardRepeatsService.finishEvents() - remoteNodeService.finishEvents() - roomServerService.finishEvents() - contactService.finishEvents() - rxLogService.finishEntryStream() - - // The action forwarders AppState installs capture notificationActionHandler - // strongly, and the handler strong-holds notificationService back, forming a - // cycle that outlives the container. Clear them so the per-connection service - // graph is released on teardown. - notificationService.onQuickReply = nil - notificationService.onChannelQuickReply = nil - notificationService.onMarkAsRead = nil - notificationService.onChannelMarkAsRead = nil - notificationService.onRoomMarkAsRead = nil - - await chatSendQueueService.shutdown() + // Prune debug logs on connection + Task { + try? await dataStore.pruneDebugLogEntries(keepCount: 1000) } - // MARK: - Convenience Methods - - /// Performs initial database warm-up. - /// - /// Call this early during app launch to avoid lazy initialization delays. - func warmUp() async throws { - try await dataStore.warmUp() + // Prune node status snapshots older than 1 year + Task { + let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: .now)! + await nodeSnapshotService.pruneOldSnapshots(olderThan: oneYearAgo) } - /// Resets all remote node session connections. - /// - /// Call this on app launch since connections don't persist across app restarts. - func resetRemoteNodeConnections() async throws { - try await dataStore.resetAllRemoteNodeSessionConnections() - } + eventMonitoringState = .active + } + + /// Stops event monitoring for all services. + /// + /// Call this when disconnecting from a device. + func stopEventMonitoring() async { + // Claimed synchronously, mirroring startEventMonitoring, so two teardown + // paths interleaving at the awaits below cannot double-stop. + guard eventMonitoringState == .active else { return } + eventMonitoringState = .stopping + + await advertisementService.stopEventMonitoring() + await rxLogService.stopEventMonitoring() + await messageService.stopEventMonitoring() + // Do not fail in-flight DMs on disconnect. The firmware retains the + // expected ACK and re-emits the delivery confirmation whenever it + // returns, so a routine BLE cycle must not mark a delivered message + // `.failed`. Stop only the expiry checker; pending entries resolve on + // reconnect within the same session or expire via `ackGiveUpWindow`. + await messageService.stopAckExpiryChecking() + await messagePollingService.stopMessageEventMonitoring() + // RemoteNodeService event monitoring is per-session, handled internally + + // Flush debug log buffer + await debugLogBuffer.shutdown() + + eventMonitoringState = .stopped + } + + /// Full container teardown. Must be awaited before nulling the container + /// so chat send queue drains and chat coordinator off-main builds release + /// the strong references they hold on `MessageService` and `dataStore`. + /// `stopEventMonitoring()` alone does not cover those. + func tearDown() async { + await stopEventMonitoring() + + // Break the retain cycles the wired handlers form. The message + // closures and the discovery event task capture this container's + // services strongly (via SyncDependencies), so without this the whole + // service graph leaks on every reconnect. Cleared after + // `stopEventMonitoring()` so the event tasks reading them are cancelled. + await messagePollingService.clearMessageHandlers() + await syncCoordinator.cancelDiscoveryEventMonitoring() + + // Finishing the event streams ends every consumer's for-await loop, + // releasing the strong service references those loops hold. + syncCoordinator.finishDataEvents() + advertisementService.finishEvents() + messageService.finishStatusEvents() + heardRepeatsService.finishEvents() + remoteNodeService.finishEvents() + roomServerService.finishEvents() + contactService.finishEvents() + rxLogService.finishEntryStream() + + // The action forwarders AppState installs capture notificationActionHandler + // strongly, and the handler strong-holds notificationService back, forming a + // cycle that outlives the container. Clear them so the per-connection service + // graph is released on teardown. + notificationService.onQuickReply = nil + notificationService.onChannelQuickReply = nil + notificationService.onMarkAsRead = nil + notificationService.onChannelMarkAsRead = nil + notificationService.onRoomMarkAsRead = nil + + await chatSendQueueService.shutdown() + } + + // MARK: - Convenience Methods + + /// Performs initial database warm-up. + /// + /// Call this early during app launch to avoid lazy initialization delays. + func warmUp() async throws { + try await dataStore.warmUp() + } + + /// Resets all remote node session connections. + /// + /// Call this on app launch since connections don't persist across app restarts. + func resetRemoteNodeConnections() async throws { + try await dataStore.resetAllRemoteNodeSessionConnections() + } } // MARK: - Factory Methods extension ServiceContainer { - - /// Creates a service container with a new in-memory model container. - /// - /// Useful for testing and previews. The container is fully wired by `init`, - /// matching production behavior. - /// - /// - Parameters: - /// - session: The MeshCoreSession for device communication - /// - radioID: Radio ID to scope the chat send queue (default: synthesized `UUID()`) - /// - Returns: A configured ServiceContainer with in-memory storage - static func forTesting( - session: MeshCoreSession, - radioID: UUID = UUID() - ) async throws -> ServiceContainer { - let container = try PersistenceStore.createContainer(inMemory: true) - return ServiceContainer( - session: session, - modelContainer: container, - radioID: radioID - ) - } + /// Creates a service container with a new in-memory model container. + /// + /// Useful for testing and previews. The container is fully wired by `init`, + /// matching production behavior. + /// + /// - Parameters: + /// - session: The MeshCoreSession for device communication + /// - radioID: Radio ID to scope the chat send queue (default: synthesized `UUID()`) + /// - Returns: A configured ServiceContainer with in-memory storage + static func forTesting( + session: MeshCoreSession, + radioID: UUID = UUID() + ) async throws -> ServiceContainer { + let container = try PersistenceStore.createContainer(inMemory: true) + return ServiceContainer( + session: session, + modelContainer: container, + radioID: radioID + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitDiscoveryCriteria.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitDiscoveryCriteria.swift index 38445cf2..79a15016 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitDiscoveryCriteria.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitDiscoveryCriteria.swift @@ -1,41 +1,41 @@ import Foundation -struct AccessorySetupKitDiscoveryCriterion: Equatable, Sendable { - let bluetoothServiceUUID: String - let bluetoothNameSubstring: String +struct AccessorySetupKitDiscoveryCriterion: Equatable { + let bluetoothServiceUUID: String + let bluetoothNameSubstring: String } enum AccessorySetupKitDiscoveryCriteria { - /// Opt into AccessorySetupKit filtered discovery (iOS 26.1+) so the picker can relabel - /// each match with its advertised BLE name; older systems use the static picker label. - static let usesFilteredDiscovery = true + /// Opt into AccessorySetupKit filtered discovery (iOS 26.1+) so the picker can relabel + /// each match with its advertised BLE name; older systems use the static picker label. + static let usesFilteredDiscovery = true - static let bluetoothNameSubstrings = [ - "MeshCore-", - "Whisper-", - "WisCore", - "XIAO", - "elecrow", - "HT-n5262", - "Seeed", - "BQ", - "ProMicro", - "Keepteen", - "Meshtiny", - "T1000-E-BOOT", - "me25ls01-BOOT", - "NRF52 DK", - "T-Impulse", - ] + static let bluetoothNameSubstrings = [ + "MeshCore-", + "Whisper-", + "WisCore", + "XIAO", + "elecrow", + "HT-n5262", + "Seeed", + "BQ", + "ProMicro", + "Keepteen", + "Meshtiny", + "T1000-E-BOOT", + "me25ls01-BOOT", + "NRF52 DK", + "T-Impulse", + ] - static let supportedBluetoothCriteria = bluetoothNameSubstrings.map { - AccessorySetupKitDiscoveryCriterion( - bluetoothServiceUUID: BLEServiceUUID.nordicUART, - bluetoothNameSubstring: $0 - ) - } + static let supportedBluetoothCriteria = bluetoothNameSubstrings.map { + AccessorySetupKitDiscoveryCriterion( + bluetoothServiceUUID: BLEServiceUUID.nordicUART, + bluetoothNameSubstring: $0 + ) + } - static let diagnosticsSummary = supportedBluetoothCriteria - .map { "\($0.bluetoothNameSubstring)->\($0.bluetoothServiceUUID)" } - .joined(separator: ", ") + static let diagnosticsSummary = supportedBluetoothCriteria + .map { "\($0.bluetoothNameSubstring)->\($0.bluetoothServiceUUID)" } + .joined(separator: ", ") } diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitLogFormatter.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitLogFormatter.swift index b36b1214..8f59de79 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitLogFormatter.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitLogFormatter.swift @@ -1,40 +1,40 @@ import Foundation enum AccessorySetupKitLogFormatter { - private static let criteriaPreviewCount = 4 + private static let criteriaPreviewCount = 4 - static func criteriaSummary(_ criteria: [AccessorySetupKitDiscoveryCriterion]) -> String { - let preview = criteria.prefix(criteriaPreviewCount).map(\.bluetoothNameSubstring) - let remainderCount = criteria.count - preview.count - let previewText = preview.joined(separator: ", ") + static func criteriaSummary(_ criteria: [AccessorySetupKitDiscoveryCriterion]) -> String { + let preview = criteria.prefix(criteriaPreviewCount).map(\.bluetoothNameSubstring) + let remainderCount = criteria.count - preview.count + let previewText = preview.joined(separator: ", ") - if remainderCount > 0 { - return "\(criteria.count) prefixes [\(previewText), +\(remainderCount) more]" - } - - return "\(criteria.count) prefixes [\(previewText)]" + if remainderCount > 0 { + return "\(criteria.count) prefixes [\(previewText), +\(remainderCount) more]" } - static func selectionMessage( - accessoryName: String, - bluetoothID: UUID?, - elapsed: TimeInterval? - ) -> String { - let identifier = bluetoothID?.uuidString ?? "none" - return "[ASK] Selected accessory '\(accessoryName)' (id: \(identifier)) after \(durationSummary(elapsed))" - } + return "\(criteria.count) prefixes [\(previewText)]" + } - static func dismissalMessage( - outcome: String, - pairedCount: Int, - elapsed: TimeInterval?, - filteredDiscovery: Bool - ) -> String { - "[ASK] Picker dismissed after \(durationSummary(elapsed)) (outcome: \(outcome), filteredDiscovery: \(filteredDiscovery), pairedCount: \(pairedCount))" - } + static func selectionMessage( + accessoryName: String, + bluetoothID: UUID?, + elapsed: TimeInterval? + ) -> String { + let identifier = bluetoothID?.uuidString ?? "none" + return "[ASK] Selected accessory '\(accessoryName)' (id: \(identifier)) after \(durationSummary(elapsed))" + } - private static func durationSummary(_ elapsed: TimeInterval?) -> String { - guard let elapsed else { return "unknown" } - return "\(elapsed.formatted(.number.precision(.fractionLength(1))))s" - } + static func dismissalMessage( + outcome: String, + pairedCount: Int, + elapsed: TimeInterval?, + filteredDiscovery: Bool + ) -> String { + "[ASK] Picker dismissed after \(durationSummary(elapsed)) (outcome: \(outcome), filteredDiscovery: \(filteredDiscovery), pairedCount: \(pairedCount))" + } + + private static func durationSummary(_ elapsed: TimeInterval?) -> String { + guard let elapsed else { return "unknown" } + return "\(elapsed.formatted(.number.precision(.fractionLength(1))))s" + } } diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitService.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitService.swift index fe613371..17048e89 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitService.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitService.swift @@ -1,36 +1,36 @@ #if canImport(UIKit) -import AccessorySetupKit -import CoreBluetooth -import UIKit -import os - -/// Delegate protocol for accessory state changes -/// Must be @MainActor since implementations access main-actor-isolated state -@MainActor -protocol AccessorySetupKitServiceDelegate: AnyObject { + import AccessorySetupKit + import CoreBluetooth + import os + import UIKit + + /// Delegate protocol for accessory state changes + /// Must be @MainActor since implementations access main-actor-isolated state + @MainActor + protocol AccessorySetupKitServiceDelegate: AnyObject { /// Called when an accessory is removed from Settings > Accessories func accessorySetupKitService(_ service: AccessorySetupKitService, didRemoveAccessoryWithID bluetoothID: UUID) /// Called when pairing fails (e.g., wrong PIN). The device should be cleaned up from local storage. func accessorySetupKitService(_ service: AccessorySetupKitService, didFailPairingForAccessoryWithID bluetoothID: UUID) -} - -/// Manages AccessorySetupKit session for device discovery and pairing. -/// -/// AccessorySetupKit is Apple's modern framework for Bluetooth accessory setup. -/// Starting with iOS 26, only apps using AccessorySetupKit will be relaunched -/// for Bluetooth state restoration events. -/// -/// ## Usage -/// -/// ```swift -/// let accessoryService = AccessorySetupKitService() -/// try await accessoryService.activateSession() -/// let deviceUUID = try await accessoryService.showPicker() -/// ``` -@Observable -@MainActor -final class AccessorySetupKitService { + } + + /// Manages AccessorySetupKit session for device discovery and pairing. + /// + /// AccessorySetupKit is Apple's modern framework for Bluetooth accessory setup. + /// Starting with iOS 26, only apps using AccessorySetupKit will be relaunched + /// for Bluetooth state restoration events. + /// + /// ## Usage + /// + /// ```swift + /// let accessoryService = AccessorySetupKitService() + /// try await accessoryService.activateSession() + /// let deviceUUID = try await accessoryService.showPicker() + /// ``` + @Observable + @MainActor + final class AccessorySetupKitService { private let logger = PersistentLogger(subsystem: "com.mc1", category: "AccessorySetupKit") private var session: ASAccessorySession? @@ -82,14 +82,14 @@ final class AccessorySetupKitService { /// Safely resume the picker continuation exactly once /// Clears continuation BEFORE resuming to prevent double-resume race conditions private func resumePickerContinuation(with result: Result) { - guard let continuation = pickerContinuation else { return } - pickerContinuation = nil // Clear BEFORE resuming - switch result { - case .success(let uuid): - continuation.resume(returning: uuid) - case .failure(let error): - continuation.resume(throwing: error) - } + guard let continuation = pickerContinuation else { return } + pickerContinuation = nil // Clear BEFORE resuming + switch result { + case let .success(uuid): + continuation.resume(returning: uuid) + case let .failure(error): + continuation.resume(throwing: error) + } } // MARK: - Session Management @@ -97,263 +97,269 @@ final class AccessorySetupKitService { /// Activate the AccessorySetupKit session /// Uses Apple's recommended closure pattern to avoid AsyncStream issues func activateSession() async throws { - guard session == nil else { return } - - let newSession = ASAccessorySession() - session = newSession - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.activationContinuation = continuation - - newSession.activate(on: .main) { [weak self] event in - guard let self else { return } - self.handleEvent(event) - - // Resume activation continuation only once - if event.eventType == .activated { - self.isSessionActive = true - self.pairedAccessories = newSession.accessories - self.logger.info("ASAccessorySession activated with \(self.pairedAccessories.count) accessories") - self.activationContinuation?.resume() - self.activationContinuation = nil - } else if event.eventType == .invalidated { - self.activationContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) - self.activationContinuation = nil - } - } + guard session == nil else { return } + + let newSession = ASAccessorySession() + session = newSession + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.activationContinuation = continuation + + newSession.activate(on: .main) { [weak self] event in + guard let self else { return } + handleEvent(event) + + // Resume activation continuation only once + if event.eventType == .activated { + isSessionActive = true + pairedAccessories = newSession.accessories + logger.info("ASAccessorySession activated with \(pairedAccessories.count) accessories") + activationContinuation?.resume() + activationContinuation = nil + } else if event.eventType == .invalidated { + activationContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) + activationContinuation = nil + } } + } } /// Handle incoming ASK events /// Called directly from session's event handler closure private func handleEvent(_ event: ASAccessoryEvent) { - switch event.eventType { - case .activated: - // Handled in activateSession continuation - break - - case .invalidated: - logger.error("ASAccessorySession invalidated") - isSessionActive = false - pairedAccessories = [] - pickerContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) - pickerContinuation = nil - case .accessoryAdded: - if let accessory = event.accessory { - if pickerWasCancelled { - pickerWasCancelled = false - // Set to "orphanedAfterCancellation" so the next pickerDidDismiss - // log doesn't read "selected" — without this, the success branch - // below would mark outcome as "selected" even though the awaiting - // Task was already cancelled. - pickerOutcome = "orphanedAfterCancellation" - logger.info("[ASK] Removing orphaned accessory after picker cancellation: \(accessory.displayName)") - Task { [weak self] in - guard let self else { return } - do { - try await self.removeAccessory(accessory) - } catch { - self.logger.warning("[ASK] Failed to remove orphaned accessory: \(error.localizedDescription)") - } - } - // Skip the post-add `pairedAccessories` write — `removeAccessory`'s - // own write is the correct final state for the cancelled branch. - return - } + switch event.eventType { + case .activated: + // Handled in activateSession continuation + break - pairedAccessories = session?.accessories ?? [] - pickerOutcome = "selected" - logger.info("Accessory added: \(accessory.displayName)") - logger.info( - AccessorySetupKitLogFormatter.selectionMessage( - accessoryName: accessory.displayName, - bluetoothID: accessory.bluetoothIdentifier, - elapsed: pickerElapsedTime - ) - ) - - if let bluetoothID = accessory.bluetoothIdentifier { - resumePickerContinuation(with: .success(bluetoothID)) - } else { - resumePickerContinuation(with: .failure(AccessorySetupKitError.noBluetoothIdentifier)) - } - } + case .invalidated: + logger.error("ASAccessorySession invalidated") + isSessionActive = false + pairedAccessories = [] + // Drop the dead session so the next activateSession() builds a fresh + // one. Keeping it would make every later activate() a silent no-op + // (its guard keys on session == nil) and leave pairing wedged until + // app relaunch. + session = nil + pickerContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) + pickerContinuation = nil - case .accessoryRemoved: - if let accessory = event.accessory, - let bluetoothID = accessory.bluetoothIdentifier { - logger.info("Accessory removed: \(accessory.displayName)") - pairedAccessories = session?.accessories ?? [] - // Notify delegate to clear stored state - delegate?.accessorySetupKitService(self, didRemoveAccessoryWithID: bluetoothID) + case .accessoryAdded: + if let accessory = event.accessory { + if pickerWasCancelled { + pickerWasCancelled = false + // Set to "orphanedAfterCancellation" so the next pickerDidDismiss + // log doesn't read "selected" — without this, the success branch + // below would mark outcome as "selected" even though the awaiting + // Task was already cancelled. + pickerOutcome = "orphanedAfterCancellation" + logger.info("[ASK] Removing orphaned accessory after picker cancellation: \(accessory.displayName)") + Task { [weak self] in + guard let self else { return } + do { + try await removeAccessory(accessory) + } catch { + logger.warning("[ASK] Failed to remove orphaned accessory: \(error.localizedDescription)") + } } + // Skip the post-add `pairedAccessories` write — `removeAccessory`'s + // own write is the correct final state for the cancelled branch. + return + } + + pairedAccessories = session?.accessories ?? [] + pickerOutcome = "selected" + logger.info("Accessory added: \(accessory.displayName)") + logger.info( + AccessorySetupKitLogFormatter.selectionMessage( + accessoryName: accessory.displayName, + bluetoothID: accessory.bluetoothIdentifier, + elapsed: pickerElapsedTime + ) + ) - case .accessoryChanged: - pairedAccessories = session?.accessories ?? [] - logger.info("Accessory changed") + if let bluetoothID = accessory.bluetoothIdentifier { + resumePickerContinuation(with: .success(bluetoothID)) + } else { + resumePickerContinuation(with: .failure(AccessorySetupKitError.noBluetoothIdentifier)) + } + } - case .accessoryDiscovered: - // Under filtered discovery (iOS 26.1+) the picker hands each match to us so we can - // relabel it with the device's advertised name before it appears for selection. - if #available(iOS 26.1, *), usedFilteredDiscovery { - handleDiscoveredAccessory(event.accessory) - } + case .accessoryRemoved: + if let accessory = event.accessory, + let bluetoothID = accessory.bluetoothIdentifier { + logger.info("Accessory removed: \(accessory.displayName)") + pairedAccessories = session?.accessories ?? [] + // Notify delegate to clear stored state + delegate?.accessorySetupKitService(self, didRemoveAccessoryWithID: bluetoothID) + } - case .pickerDidPresent: - logger.info("Picker presented") - - case .pickerDidDismiss: - logger.info( - AccessorySetupKitLogFormatter.dismissalMessage( - outcome: pickerOutcome, - pairedCount: pairedAccessories.count, - elapsed: pickerElapsedTime, - filteredDiscovery: usedFilteredDiscovery - ) - ) - pickerPresentedAt = nil - pickerOutcome = "cancelled" - // Defense-in-depth: don't rely on `showPicker` to clear the flag. - pickerWasCancelled = false - resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerDismissed)) - - case .pickerSetupBridging: - logger.info("Picker bridging...") - - case .pickerSetupPairing: - logger.info("User entering PIN...") - - case .pickerSetupFailed: - if let error = event.error { - pickerOutcome = "pairingFailed" - logger.error("Pairing failed: \(error.localizedDescription)") - - if let accessory = event.accessory, - let bluetoothID = accessory.bluetoothIdentifier { - logger.info("Cleaning up failed pairing for \(accessory.displayName)") - - delegate?.accessorySetupKitService(self, didFailPairingForAccessoryWithID: bluetoothID) - - if pairedAccessories.contains(where: { $0.bluetoothIdentifier == bluetoothID }) { - Task { - do { - try await self.removeAccessory(accessory) - self.logger.info("Removed failed accessory from ASK") - } catch { - self.logger.warning("Failed to remove accessory from ASK: \(error.localizedDescription)") - } - } - } - } + case .accessoryChanged: + pairedAccessories = session?.accessories ?? [] + logger.info("Accessory changed") - resumePickerContinuation(with: .failure(AccessorySetupKitError.pairingFailed(error.localizedDescription))) - } + case .accessoryDiscovered: + // Under filtered discovery (iOS 26.1+) the picker hands each match to us so we can + // relabel it with the device's advertised name before it appears for selection. + if #available(iOS 26.1, *), usedFilteredDiscovery { + handleDiscoveredAccessory(event.accessory) + } + + case .pickerDidPresent: + logger.info("Picker presented") + + case .pickerDidDismiss: + logger.info( + AccessorySetupKitLogFormatter.dismissalMessage( + outcome: pickerOutcome, + pairedCount: pairedAccessories.count, + elapsed: pickerElapsedTime, + filteredDiscovery: usedFilteredDiscovery + ) + ) + pickerPresentedAt = nil + pickerOutcome = "cancelled" + // Defense-in-depth: don't rely on `showPicker` to clear the flag. + pickerWasCancelled = false + resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerDismissed)) + + case .pickerSetupBridging: + logger.info("Picker bridging...") + + case .pickerSetupPairing: + logger.info("User entering PIN...") - case .pickerSetupRename: - logger.info("Picker rename step") + case .pickerSetupFailed: + if let error = event.error { + pickerOutcome = "pairingFailed" + logger.error("Pairing failed: \(error.localizedDescription)") - case .migrationComplete: - logger.info("Migration complete") + if let accessory = event.accessory, + let bluetoothID = accessory.bluetoothIdentifier { + logger.info("Cleaning up failed pairing for \(accessory.displayName)") - case .unknown: - // Explicit handling per Apple sample code - logger.info("Received unknown event type") + delegate?.accessorySetupKitService(self, didFailPairingForAccessoryWithID: bluetoothID) + + if pairedAccessories.contains(where: { $0.bluetoothIdentifier == bluetoothID }) { + Task { + do { + try await self.removeAccessory(accessory) + self.logger.info("Removed failed accessory from ASK") + } catch { + self.logger.warning("Failed to remove accessory from ASK: \(error.localizedDescription)") + } + } + } + } - @unknown default: - // Reserve for future event types - logger.warning("Received future event type: \(String(describing: event.eventType))") + resumePickerContinuation(with: .failure(AccessorySetupKitError.pairingFailed(error.localizedDescription))) } + + case .pickerSetupRename: + logger.info("Picker rename step") + + case .migrationComplete: + logger.info("Migration complete") + + case .unknown: + // Explicit handling per Apple sample code + logger.info("Received unknown event type") + + @unknown default: + // Reserve for future event types + logger.warning("Received future event type: \(String(describing: event.eventType))") + } } /// Show the accessory picker for new device pairing /// - Returns: The Bluetooth identifier (UUID) for the paired device func showPicker() async throws -> UUID { - guard let session else { - throw AccessorySetupKitError.sessionNotActive - } - - guard pickerContinuation == nil else { - throw AccessorySetupKitError.pickerAlreadyActive - } - - discoveredDisplayItems.removeAll() - usedFilteredDiscovery = false - - if #available(iOS 26.1, *), AccessorySetupKitDiscoveryCriteria.usesFilteredDiscovery { - let settings = session.pickerDisplaySettings ?? ASPickerDisplaySettings() - settings.options.insert(.filterDiscoveryResults) - session.pickerDisplaySettings = settings - usedFilteredDiscovery = true - } else if #available(iOS 26.0, *) { - if session.pickerDisplaySettings == nil { - session.pickerDisplaySettings = ASPickerDisplaySettings() - } + guard let session else { + throw AccessorySetupKitError.sessionNotActive + } + + guard pickerContinuation == nil else { + throw AccessorySetupKitError.pickerAlreadyActive + } + + discoveredDisplayItems.removeAll() + usedFilteredDiscovery = false + + if #available(iOS 26.1, *), AccessorySetupKitDiscoveryCriteria.usesFilteredDiscovery { + let settings = session.pickerDisplaySettings ?? ASPickerDisplaySettings() + settings.options.insert(.filterDiscoveryResults) + session.pickerDisplaySettings = settings + usedFilteredDiscovery = true + } else if #available(iOS 26.0, *) { + if session.pickerDisplaySettings == nil { + session.pickerDisplaySettings = ASPickerDisplaySettings() } + } + + let productImage = createGenericProductImage() + currentProductImage = productImage + let displayItems = makePickerDisplayItems(productImage: productImage) + pickerPresentedAt = Date() + pickerOutcome = "presented" + pickerWasCancelled = false + logger.info( + """ + [ASK] Presenting picker on iOS \(currentOSVersion), \ + sessionActive: \(isSessionActive), \ + pairedCount: \(pairedAccessories.count), \ + displayItems: \(displayItems.count), \ + filteredDiscovery: \(usedFilteredDiscovery), \ + criteria: \(AccessorySetupKitLogFormatter.criteriaSummary(AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria)) + """ + ) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.pickerContinuation = continuation + + session.showPicker(for: displayItems) { [weak self] error in + guard let self else { return } - let productImage = createGenericProductImage() - currentProductImage = productImage - let displayItems = makePickerDisplayItems(productImage: productImage) - pickerPresentedAt = Date() - pickerOutcome = "presented" - pickerWasCancelled = false - logger.info( - """ - [ASK] Presenting picker on iOS \(currentOSVersion), \ - sessionActive: \(isSessionActive), \ - pairedCount: \(pairedAccessories.count), \ - displayItems: \(displayItems.count), \ - filteredDiscovery: \(usedFilteredDiscovery), \ - criteria: \(AccessorySetupKitLogFormatter.criteriaSummary(AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria)) - """ - ) - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.pickerContinuation = continuation - - session.showPicker(for: displayItems) { [weak self] error in - guard let self else { return } - - Task { @MainActor in - if let error = error as? ASError { - self.logger.error("Picker error: \(error.localizedDescription)") - - switch error.code { - case .pickerRestricted: - self.pickerOutcome = "pickerRestricted" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerRestricted)) - case .pickerAlreadyActive: - self.pickerOutcome = "pickerAlreadyActive" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerAlreadyActive)) - case .userCancelled: - self.pickerOutcome = "cancelled" - // User explicitly cancelled (error code 700) - not an error condition - // Will be handled by pickerDidDismiss event - return - case .discoveryTimeout: - self.pickerOutcome = "discoveryTimeout" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.discoveryTimeout)) - case .connectionFailed: - self.pickerOutcome = "connectionFailed" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.connectionFailed)) - default: - self.logger.error("Unexpected picker error code: \(error.code.rawValue)") - self.pickerOutcome = "connectionFailed" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.connectionFailed)) - } - } else if let error { - self.logger.error("Picker error: \(error.localizedDescription)") - self.pickerOutcome = "pairingFailed" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pairingFailed(error.localizedDescription))) - } - } - } - } - } onCancel: { [weak self] in Task { @MainActor in - self?.handlePickerCancellation() + if let error = error as? ASError { + self.logger.error("Picker error: \(error.localizedDescription)") + + switch error.code { + case .pickerRestricted: + self.pickerOutcome = "pickerRestricted" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerRestricted)) + case .pickerAlreadyActive: + self.pickerOutcome = "pickerAlreadyActive" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerAlreadyActive)) + case .userCancelled: + self.pickerOutcome = "cancelled" + // User explicitly cancelled (error code 700) - not an error condition + // Will be handled by pickerDidDismiss event + return + case .discoveryTimeout: + self.pickerOutcome = "discoveryTimeout" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.discoveryTimeout)) + case .connectionFailed: + self.pickerOutcome = "connectionFailed" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.connectionFailed)) + default: + self.logger.error("Unexpected picker error code: \(error.code.rawValue)") + self.pickerOutcome = "connectionFailed" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.connectionFailed)) + } + } else if let error { + self.logger.error("Picker error: \(error.localizedDescription)") + self.pickerOutcome = "pairingFailed" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pairingFailed(error.localizedDescription))) + } } + } + } + } onCancel: { [weak self] in + Task { @MainActor in + self?.handlePickerCancellation() } + } } /// Surfaces task cancellation as a `CancellationError` on the awaiting `showPicker`. @@ -363,119 +369,119 @@ final class AccessorySetupKitService { /// observe `pickerWasCancelled` and remove the orphaned bond. @MainActor private func handlePickerCancellation() { - pickerWasCancelled = true - // In Race-B (accessoryAdded ran first, then cancellation), the success branch - // already set pickerOutcome to "selected". Refine it so the dismissal log - // reflects what actually happened: a user-completed selection that the - // awaiting Task no longer cared about. - if pickerOutcome == "selected" { - pickerOutcome = "cancelledAfterSelection" - } - resumePickerContinuation(with: .failure(CancellationError())) - logger.warning("[ASK] Picker cancelled programmatically; awaiting Task unwound") + pickerWasCancelled = true + // In Race-B (accessoryAdded ran first, then cancellation), the success branch + // already set pickerOutcome to "selected". Refine it so the dismissal log + // reflects what actually happened: a user-completed selection that the + // awaiting Task no longer cared about. + if pickerOutcome == "selected" { + pickerOutcome = "cancelledAfterSelection" + } + resumePickerContinuation(with: .failure(CancellationError())) + logger.warning("[ASK] Picker cancelled programmatically; awaiting Task unwound") } /// Remove an accessory from the system /// Note: iOS 26 shows a confirmation dialog to the user func removeAccessory(_ accessory: ASAccessory) async throws { - guard let session else { - throw AccessorySetupKitError.sessionNotActive - } + guard let session else { + throw AccessorySetupKitError.sessionNotActive + } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - session.removeAccessory(accessory) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.removeAccessory(accessory) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } } + } - pairedAccessories = session.accessories + pairedAccessories = session.accessories } /// Shows the system rename sheet for an accessory /// - Parameter accessory: The accessory to rename func renameAccessory(_ accessory: ASAccessory) async throws { - guard let session else { - throw AccessorySetupKitError.sessionNotActive - } + guard let session else { + throw AccessorySetupKitError.sessionNotActive + } - try await session.renameAccessory(accessory) - pairedAccessories = session.accessories + try await session.renameAccessory(accessory) + pairedAccessories = session.accessories } /// Find a paired accessory by its Bluetooth identifier func accessory(for bluetoothID: UUID) -> ASAccessory? { - pairedAccessories.first { $0.bluetoothIdentifier == bluetoothID } + pairedAccessories.first { $0.bluetoothIdentifier == bluetoothID } } /// Invalidate the session func invalidateSession() { - pickerContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) - pickerContinuation = nil - activationContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) - activationContinuation = nil - session?.invalidate() - session = nil - isSessionActive = false - pairedAccessories = [] + pickerContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) + pickerContinuation = nil + activationContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) + activationContinuation = nil + session?.invalidate() + session = nil + isSessionActive = false + pairedAccessories = [] } // MARK: - Private Helpers private var currentOSVersion: String { - let version = ProcessInfo.processInfo.operatingSystemVersion - return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + let version = ProcessInfo.processInfo.operatingSystemVersion + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" } private var pickerElapsedTime: TimeInterval? { - pickerPresentedAt.map { Date().timeIntervalSince($0) } + pickerPresentedAt.map { Date().timeIntervalSince($0) } } private func makePickerDisplayItems(productImage: UIImage) -> [ASPickerDisplayItem] { - AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria.map { criterion in - let descriptor = ASDiscoveryDescriptor() - descriptor.bluetoothServiceUUID = CBUUID(string: criterion.bluetoothServiceUUID) - descriptor.bluetoothNameSubstring = criterion.bluetoothNameSubstring - - return ASPickerDisplayItem( - name: Self.defaultAccessoryName, - productImage: productImage, - descriptor: descriptor - ) - } + AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria.map { criterion in + let descriptor = ASDiscoveryDescriptor() + descriptor.bluetoothServiceUUID = CBUUID(string: criterion.bluetoothServiceUUID) + descriptor.bluetoothNameSubstring = criterion.bluetoothNameSubstring + + return ASPickerDisplayItem( + name: Self.defaultAccessoryName, + productImage: productImage, + descriptor: descriptor + ) + } } /// Relabels a filtered-discovery match with its advertised BLE name, falling back to /// `defaultAccessoryName` when the advertisement carries no usable local name. @available(iOS 26.1, *) private func handleDiscoveredAccessory(_ accessory: ASAccessory?) { - guard let session, - let discovered = accessory as? ASDiscoveredAccessory else { return } - - let advertisedName = (discovered.bluetoothAdvertisementData?[CBAdvertisementDataLocalNameKey] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let name = advertisedName.flatMap { $0.isEmpty ? nil : $0 } ?? Self.defaultAccessoryName - let productImage = currentProductImage ?? createGenericProductImage() - - logger.info("[ASK] Discovered accessory '\(name)', RSSI: \(discovered.bluetoothRSSI.map(String.init) ?? "n/a")") - - discoveredDisplayItems[AnyHashable(discovered)] = ASDiscoveredDisplayItem( - name: name, - productImage: productImage, - accessory: discovered - ) - - let items = discoveredDisplayItems.values.compactMap { $0 as? ASDiscoveredDisplayItem } - logger.info("[ASK] updatePicker with \(items.count) discovered item(s)") - session.updatePicker(showing: items) { [weak self] error in - guard let error else { return } - Task { @MainActor in - self?.logger.warning("[ASK] updatePicker failed: \(error.localizedDescription)") - } + guard let session, + let discovered = accessory as? ASDiscoveredAccessory else { return } + + let advertisedName = (discovered.bluetoothAdvertisementData?[CBAdvertisementDataLocalNameKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let name = advertisedName.flatMap { $0.isEmpty ? nil : $0 } ?? Self.defaultAccessoryName + let productImage = currentProductImage ?? createGenericProductImage() + + logger.info("[ASK] Discovered accessory '\(name)', RSSI: \(discovered.bluetoothRSSI.map(String.init) ?? "n/a")") + + discoveredDisplayItems[AnyHashable(discovered)] = ASDiscoveredDisplayItem( + name: name, + productImage: productImage, + accessory: discovered + ) + + let items = discoveredDisplayItems.values.compactMap { $0 as? ASDiscoveredDisplayItem } + logger.info("[ASK] updatePicker with \(items.count) discovered item(s)") + session.updatePicker(showing: items) { [weak self] error in + guard let error else { return } + Task { @MainActor in + self?.logger.warning("[ASK] updatePicker failed: \(error.localizedDescription)") } + } } /// Creates a generic product image for the ASK picker @@ -487,33 +493,33 @@ final class AccessorySetupKitService { /// Alternative: Could use `.withRenderingMode(.alwaysTemplate)` with `.label` color /// for better automatic dark mode adaptation, but explicit blue tint is acceptable. private func createGenericProductImage() -> UIImage { - let size = CGSize(width: 180, height: 120) - let renderer = UIGraphicsImageRenderer(size: size) - - return renderer.image { context in - // Transparent background (required for light/dark mode) - UIColor.clear.setFill() - context.fill(CGRect(origin: .zero, size: size)) - - // Draw centered SF Symbol with padding for visual balance - // Smaller point size (50 vs 60) creates breathing room in carousel - let config = UIImage.SymbolConfiguration(pointSize: 50, weight: .regular) - guard let symbol = UIImage(systemName: "antenna.radiowaves.left.and.right", withConfiguration: config)? - .withTintColor(.systemBlue, renderingMode: .alwaysOriginal) else { return } - - let symbolSize = symbol.size - let origin = CGPoint( - x: (size.width - symbolSize.width) / 2, - y: (size.height - symbolSize.height) / 2 - ) - symbol.draw(at: origin) - } + let size = CGSize(width: 180, height: 120) + let renderer = UIGraphicsImageRenderer(size: size) + + return renderer.image { context in + // Transparent background (required for light/dark mode) + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // Draw centered SF Symbol with padding for visual balance + // Smaller point size (50 vs 60) creates breathing room in carousel + let config = UIImage.SymbolConfiguration(pointSize: 50, weight: .regular) + guard let symbol = UIImage(systemName: "antenna.radiowaves.left.and.right", withConfiguration: config)? + .withTintColor(.systemBlue, renderingMode: .alwaysOriginal) else { return } + + let symbolSize = symbol.size + let origin = CGPoint( + x: (size.width - symbolSize.width) / 2, + y: (size.height - symbolSize.height) / 2 + ) + symbol.draw(at: origin) + } } -} + } -// MARK: - Errors + // MARK: - Errors -public enum AccessorySetupKitError: LocalizedError, Sendable { + public enum AccessorySetupKitError: LocalizedError, Sendable { case sessionNotActive case sessionInvalidated case pickerDismissed @@ -525,51 +531,51 @@ public enum AccessorySetupKitError: LocalizedError, Sendable { case connectionFailed public var errorDescription: String? { - switch self { - case .sessionNotActive: - return "Bluetooth is not ready. Please ensure Bluetooth is enabled and try again." - case .sessionInvalidated: - return "Bluetooth session ended unexpectedly. Please restart the app." - case .pickerDismissed: - return "Device selection was cancelled." - case .pickerRestricted: - return "Cannot show device picker. Please check that Bluetooth is enabled, wait a moment, and try again." - case .pickerAlreadyActive: - return "Device picker is already showing." - case .pairingFailed(let reason): - return "Pairing failed: \(reason)" - case .noBluetoothIdentifier: - return "Selected device does not support Bluetooth connection." - case .discoveryTimeout: - return "No devices found. Make sure your device is powered on and nearby." - case .connectionFailed: - return "Could not connect to the device. Please try again." - } + switch self { + case .sessionNotActive: + "Bluetooth is not ready. Please ensure Bluetooth is enabled and try again." + case .sessionInvalidated: + "Bluetooth session ended unexpectedly. Please restart the app." + case .pickerDismissed: + "Device selection was cancelled." + case .pickerRestricted: + "Cannot show device picker. Please check that Bluetooth is enabled, wait a moment, and try again." + case .pickerAlreadyActive: + "Device picker is already showing." + case let .pairingFailed(reason): + "Pairing failed: \(reason)" + case .noBluetoothIdentifier: + "Selected device does not support Bluetooth connection." + case .discoveryTimeout: + "No devices found. Make sure your device is powered on and nearby." + case .connectionFailed: + "Could not connect to the device. Please try again." + } } -} + } #else -// macOS stubs for compilation -import Foundation + // macOS stubs for compilation + import Foundation -struct ASAccessory: Sendable { + struct ASAccessory { var bluetoothIdentifier: UUID? var displayName: String init(bluetoothIdentifier: UUID? = nil, displayName: String = "") { - self.bluetoothIdentifier = bluetoothIdentifier - self.displayName = displayName + self.bluetoothIdentifier = bluetoothIdentifier + self.displayName = displayName } -} + } -@MainActor -protocol AccessorySetupKitServiceDelegate: AnyObject { + @MainActor + protocol AccessorySetupKitServiceDelegate: AnyObject { func accessorySetupKitService(_ service: AccessorySetupKitService, didRemoveAccessoryWithID bluetoothID: UUID) func accessorySetupKitService(_ service: AccessorySetupKitService, didFailPairingForAccessoryWithID bluetoothID: UUID) -} + } -@Observable -@MainActor -final class AccessorySetupKitService { + @Observable + @MainActor + final class AccessorySetupKitService { private(set) var pairedAccessories: [ASAccessory] = [] private(set) var isSessionActive = false weak var delegate: AccessorySetupKitServiceDelegate? @@ -577,14 +583,20 @@ final class AccessorySetupKitService { init() {} func activateSession() async throws {} - func showPicker() async throws -> UUID { throw AccessorySetupKitError.sessionNotActive } + func showPicker() async throws -> UUID { + throw AccessorySetupKitError.sessionNotActive + } + func removeAccessory(_ accessory: ASAccessory) async throws {} func renameAccessory(_ accessory: ASAccessory) async throws {} - func accessory(for bluetoothID: UUID) -> ASAccessory? { nil } + func accessory(for bluetoothID: UUID) -> ASAccessory? { + nil + } + func invalidateSession() {} -} + } -public enum AccessorySetupKitError: LocalizedError, Sendable { + public enum AccessorySetupKitError: LocalizedError, Sendable { case sessionNotActive case sessionInvalidated case pickerDismissed @@ -596,26 +608,26 @@ public enum AccessorySetupKitError: LocalizedError, Sendable { case connectionFailed public var errorDescription: String? { - switch self { - case .sessionNotActive: - return "Bluetooth is not ready. Please ensure Bluetooth is enabled and try again." - case .sessionInvalidated: - return "Bluetooth session ended unexpectedly. Please restart the app." - case .pickerDismissed: - return "Device selection was cancelled." - case .pickerRestricted: - return "Cannot show device picker. Please check that Bluetooth is enabled, wait a moment, and try again." - case .pickerAlreadyActive: - return "Device picker is already showing." - case .pairingFailed(let reason): - return "Pairing failed: \(reason)" - case .noBluetoothIdentifier: - return "Selected device does not support Bluetooth connection." - case .discoveryTimeout: - return "No devices found. Make sure your device is powered on and nearby." - case .connectionFailed: - return "Could not connect to the device. Please try again." - } + switch self { + case .sessionNotActive: + "Bluetooth is not ready. Please ensure Bluetooth is enabled and try again." + case .sessionInvalidated: + "Bluetooth session ended unexpectedly. Please restart the app." + case .pickerDismissed: + "Device selection was cancelled." + case .pickerRestricted: + "Cannot show device picker. Please check that Bluetooth is enabled, wait a moment, and try again." + case .pickerAlreadyActive: + "Device picker is already showing." + case let .pairingFailed(reason): + "Pairing failed: \(reason)" + case .noBluetoothIdentifier: + "Selected device does not support Bluetooth connection." + case .discoveryTimeout: + "No devices found. Make sure your device is powered on and nearby." + case .connectionFailed: + "Could not connect to the device. Please try again." + } } -} + } #endif diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitServicing.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitServicing.swift index 5f2eb615..a9884af4 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitServicing.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitServicing.swift @@ -1,5 +1,5 @@ #if canImport(UIKit) -import AccessorySetupKit + import AccessorySetupKit #endif import Foundation @@ -12,15 +12,15 @@ import Foundation /// a protocol. @MainActor protocol AccessorySetupKitServicing: AnyObject { - var pairedAccessories: [ASAccessory] { get } - var isSessionActive: Bool { get } - var delegate: AccessorySetupKitServiceDelegate? { get set } - func activateSession() async throws - func showPicker() async throws -> UUID - func removeAccessory(_ accessory: ASAccessory) async throws - func renameAccessory(_ accessory: ASAccessory) async throws - func accessory(for bluetoothID: UUID) -> ASAccessory? - func invalidateSession() + var pairedAccessories: [ASAccessory] { get } + var isSessionActive: Bool { get } + var delegate: AccessorySetupKitServiceDelegate? { get set } + func activateSession() async throws + func showPicker() async throws -> UUID + func removeAccessory(_ accessory: ASAccessory) async throws + func renameAccessory(_ accessory: ASAccessory) async throws + func accessory(for bluetoothID: UUID) -> ASAccessory? + func invalidateSession() } extension AccessorySetupKitService: AccessorySetupKitServicing {} diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupPairingService.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupPairingService.swift index 069849d0..973d4aab 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupPairingService.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupPairingService.swift @@ -1,5 +1,5 @@ #if canImport(UIKit) -import AccessorySetupKit + import AccessorySetupKit #endif import Foundation import os @@ -12,78 +12,89 @@ import os /// this adapter is the one place that knows AccessorySetupKit exists. @MainActor final class AccessorySetupPairingService: DevicePairingService { - private let accessorySetupKit: any AccessorySetupKitServicing - private let logger = PersistentLogger(subsystem: "com.mc1", category: "AccessorySetupPairing") - weak var delegate: (any DevicePairingDelegate)? + private let accessorySetupKit: any AccessorySetupKitServicing + private let logger = PersistentLogger(subsystem: "com.mc1", category: "AccessorySetupPairing") + weak var delegate: (any DevicePairingDelegate)? - init(accessorySetupKit: any AccessorySetupKitServicing) { - self.accessorySetupKit = accessorySetupKit - self.accessorySetupKit.delegate = self - } + init(accessorySetupKit: any AccessorySetupKitServicing) { + self.accessorySetupKit = accessorySetupKit + self.accessorySetupKit.delegate = self + } - var isSessionActive: Bool { accessorySetupKit.isSessionActive } - var registeredDeviceCount: Int { accessorySetupKit.pairedAccessories.count } - var hasSystemPairingRegistry: Bool { true } - var supportsSystemRename: Bool { true } + var isSessionActive: Bool { + accessorySetupKit.isSessionActive + } - func activate() async throws { - try await accessorySetupKit.activateSession() - } + var registeredDeviceCount: Int { + accessorySetupKit.pairedAccessories.count + } - func discoverDevice() async throws -> UUID { - do { - return try await accessorySetupKit.showPicker() - } catch AccessorySetupKitError.pickerDismissed { - throw DevicePairingError.cancelled - } catch AccessorySetupKitError.pickerAlreadyActive { - throw DevicePairingError.alreadyInProgress - } - } + var hasSystemPairingRegistry: Bool { + true + } - func isDeviceConnectable(_ id: UUID) -> Bool { - accessorySetupKit.accessory(for: id) != nil - } + var supportsSystemRename: Bool { + true + } - func registeredDeviceInfos() -> [(id: UUID, name: String)] { - accessorySetupKit.pairedAccessories.compactMap { accessory in - guard let id = accessory.bluetoothIdentifier else { return nil } - return (id: id, name: accessory.displayName) - } - } + func activate() async throws { + try await accessorySetupKit.activateSession() + } - func removeDevice(_ id: UUID) async throws { - guard let accessory = accessorySetupKit.accessory(for: id) else { return } - try await accessorySetupKit.removeAccessory(accessory) + func discoverDevice() async throws -> UUID { + do { + return try await accessorySetupKit.showPicker() + } catch AccessorySetupKitError.pickerDismissed { + throw DevicePairingError.cancelled + } catch AccessorySetupKitError.pickerAlreadyActive { + throw DevicePairingError.alreadyInProgress } + } - func renameDevice(_ id: UUID) async throws { - guard let accessory = accessorySetupKit.accessory(for: id) else { return } - try await accessorySetupKit.renameAccessory(accessory) + func isDeviceConnectable(_ id: UUID) -> Bool { + accessorySetupKit.accessory(for: id) != nil + } + + func registeredDeviceInfos() -> [(id: UUID, name: String)] { + accessorySetupKit.pairedAccessories.compactMap { accessory in + guard let id = accessory.bluetoothIdentifier else { return nil } + return (id: id, name: accessory.displayName) } + } + + func removeDevice(_ id: UUID) async throws { + guard let accessory = accessorySetupKit.accessory(for: id) else { return } + try await accessorySetupKit.removeAccessory(accessory) + } + + func renameDevice(_ id: UUID) async throws { + guard let accessory = accessorySetupKit.accessory(for: id) else { return } + try await accessorySetupKit.renameAccessory(accessory) + } - func clearStaleRegistrations() async { - for accessory in accessorySetupKit.pairedAccessories { - do { - try await accessorySetupKit.removeAccessory(accessory) - } catch { - logger.warning("Failed to remove stale accessory \(accessory.displayName): \(error.localizedDescription)") - } - } + func clearStaleRegistrations() async { + for accessory in accessorySetupKit.pairedAccessories { + do { + try await accessorySetupKit.removeAccessory(accessory) + } catch { + logger.warning("Failed to remove stale accessory \(accessory.displayName): \(error.localizedDescription)") + } } + } } extension AccessorySetupPairingService: AccessorySetupKitServiceDelegate { - func accessorySetupKitService( - _ service: AccessorySetupKitService, - didRemoveAccessoryWithID bluetoothID: UUID - ) { - delegate?.devicePairing(self, didRemoveDeviceWithID: bluetoothID) - } + func accessorySetupKitService( + _ service: AccessorySetupKitService, + didRemoveAccessoryWithID bluetoothID: UUID + ) { + delegate?.devicePairing(self, didRemoveDeviceWithID: bluetoothID) + } - func accessorySetupKitService( - _ service: AccessorySetupKitService, - didFailPairingForAccessoryWithID bluetoothID: UUID - ) { - delegate?.devicePairing(self, didFailPairingForDeviceWithID: bluetoothID) - } + func accessorySetupKitService( + _ service: AccessorySetupKitService, + didFailPairingForAccessoryWithID bluetoothID: UUID + ) { + delegate?.devicePairing(self, didFailPairingForDeviceWithID: bluetoothID) + } } diff --git a/MC1Services/Sources/MC1Services/Services/AckCodeBuilder.swift b/MC1Services/Sources/MC1Services/Services/AckCodeBuilder.swift index 4007fb40..aff48310 100644 --- a/MC1Services/Sources/MC1Services/Services/AckCodeBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/AckCodeBuilder.swift @@ -37,22 +37,22 @@ private let ackCodeByteCount = 4 /// Cross-*message* collision is unaffected by the wrap and stays mitigated by /// the per-radioID `dmQueue` serialization. enum AckCodeBuilder { - static func expectedAck( - timestamp: UInt32, - attempt: UInt8, - text: String, - senderPublicKey: Data - ) -> Data { - precondition( - attempt < 5, - "MessageServiceConfig caps maxAttempts at 5 (4 direct + 1 flood); attempt \(attempt) exceeds the index range and would over-wrap the & 0x03 ACK mask" - ) - var input = Data() - var le = timestamp.littleEndian - withUnsafeBytes(of: &le) { input.append(contentsOf: $0) } - input.append(attempt & attemptMask) - input.append(contentsOf: text.utf8) - input.append(senderPublicKey) - return Data(SHA256.hash(data: input).prefix(ackCodeByteCount)) - } + static func expectedAck( + timestamp: UInt32, + attempt: UInt8, + text: String, + senderPublicKey: Data + ) -> Data { + precondition( + attempt < 5, + "MessageServiceConfig caps maxAttempts at 5 (4 direct + 1 flood); attempt \(attempt) exceeds the index range and would over-wrap the & 0x03 ACK mask" + ) + var input = Data() + var le = timestamp.littleEndian + withUnsafeBytes(of: &le) { input.append(contentsOf: $0) } + input.append(attempt & attemptMask) + input.append(contentsOf: text.utf8) + input.append(senderPublicKey) + return Data(SHA256.hash(data: input).prefix(ackCodeByteCount)) + } } diff --git a/MC1Services/Sources/MC1Services/Services/AdvertLocationPolicy.swift b/MC1Services/Sources/MC1Services/Services/AdvertLocationPolicy.swift index 040e2145..8c3c0a81 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertLocationPolicy.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertLocationPolicy.swift @@ -2,11 +2,11 @@ import Foundation /// Location inclusion policy for advertisements. public enum AdvertLocationPolicy: UInt8, Sendable, CaseIterable { - case none = 0 - case share = 1 - case prefs = 2 + case none = 0 + case share = 1 + case prefs = 2 - public var isEnabled: Bool { - self != .none - } + public var isEnabled: Bool { + self != .none + } } diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift index 180b9e84..e7907b47 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift @@ -9,25 +9,25 @@ import MeshCore /// bumps and deletion cleanup, `ConnectionUIState` for the storage-full flag, /// path-discovery views) never steal each other's events. public enum AdvertisementEvent: Sendable { - /// A contact or discovered node was created or updated; observers should - /// reload contact lists. - case contactUpdated - /// A new contact was discovered via advertisement. - case newContactDiscovered(name: String, contactID: UUID, contactType: ContactType) - /// An advert arrived for a contact unknown locally and it was fetched - /// from the device; observers should refresh contact lists. - case contactSyncRequested(radioID: UUID) - /// The device's node storage full state changed (true = full, false = has space). - case nodeStorageFullChanged(isFull: Bool) - /// The device auto-deleted a contact (overwrite oldest); observers clean - /// up its notifications and refresh the badge. - case contactDeletedCleanup(contactID: UUID, publicKey: Data) - /// A path discovery response arrived for a contact. - case pathDiscoveryResponse(PathInfo) - /// A trace response arrived; `traceInfo.tag` correlates it with the - /// trace that requested it. - case traceResponse(traceInfo: TraceInfo, radioID: UUID) - /// The RX log reported reception of a trace packet, carrying the SNR the - /// local radio measured and, when present, the far end's measured SNR. - case traceSnrObserved(tag: UInt32, localSnr: Double, remoteSnr: Double?, radioID: UUID) + /// A contact or discovered node was created or updated; observers should + /// reload contact lists. + case contactUpdated + /// A new contact was discovered via advertisement. + case newContactDiscovered(name: String, contactID: UUID, contactType: ContactType) + /// An advert arrived for a contact unknown locally and it was fetched + /// from the device; observers should refresh contact lists. + case contactSyncRequested(radioID: UUID) + /// The device's node storage full state changed (true = full, false = has space). + case nodeStorageFullChanged(isFull: Bool) + /// The device auto-deleted a contact (overwrite oldest); observers clean + /// up its notifications and refresh the badge. + case contactDeletedCleanup(contactID: UUID, publicKey: Data) + /// A path discovery response arrived for a contact. + case pathDiscoveryResponse(PathInfo) + /// A trace response arrived; `traceInfo.tag` correlates it with the + /// trace that requested it. + case traceResponse(traceInfo: TraceInfo, radioID: UUID) + /// The RX log reported reception of a trace packet, carrying the SNR the + /// local radio measured and, when present, the far end's measured SNR. + case traceSnrObserved(tag: UInt32, localSnr: Double, remoteSnr: Double?, radioID: UUID) } diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift index ebe5fdeb..15091a89 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift @@ -5,21 +5,21 @@ import os // MARK: - Advertisement Errors public enum AdvertisementError: Error, Sendable { - case notConnected - case sendFailed - case invalidResponse - case sessionError(MeshCoreError) + case notConnected + case sendFailed + case invalidResponse + case sessionError(MeshCoreError) } extension AdvertisementError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .sendFailed: "Failed to send advertisement." - case .invalidResponse: "Invalid response from device." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .sendFailed: "Failed to send advertisement." + case .invalidResponse: "Invalid response from device." + case let .sessionError(e): e.localizedDescription } + } } // MARK: - Advertisement Service @@ -27,475 +27,474 @@ extension AdvertisementError: LocalizedError { /// Service for managing device advertisements and discovery. /// Handles sending self-advertisements and processing incoming adverts via MeshCore events. public actor AdvertisementService { + // MARK: - Properties - // MARK: - Properties + private let logger = PersistentLogger(subsystem: "com.mc1", category: "Advertisement") - private let logger = PersistentLogger(subsystem: "com.mc1", category: "Advertisement") + /// Temporary end-to-end Discover trace. Filter by category "discover-trace" + /// to follow a single advert from push receipt through persistence to the + /// view reload. Remove once the "no new nodes after clear" report is closed. + private let discoverTrace = PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - /// Temporary end-to-end Discover trace. Filter by category "discover-trace" - /// to follow a single advert from push receipt through persistence to the - /// view reload. Remove once the "no new nodes after clear" report is closed. - private let discoverTrace = PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + private let session: any AdvertisingSessionOps & SessionEventStreaming + private let dataStore: any PersistenceStoreProtocol - private let session: any AdvertisingSessionOps & SessionEventStreaming - private let dataStore: any PersistenceStoreProtocol + /// Task monitoring for events + private var eventMonitorTask: Task? + private var currentRadioID: UUID? - /// Task monitoring for events - private var eventMonitorTask: Task? - private var currentRadioID: UUID? + /// Whether contact fetches should be deferred (during sync) + private var isSyncingContacts = false + private var pendingUnknownContactKeys: Set = [] - /// Whether contact fetches should be deferred (during sync) - private var isSyncingContacts = false - private var pendingUnknownContactKeys: Set = [] + /// Tracks the last overwrite-oldest deletion for correlating with the replacement contact. + /// The device sends 0x8F (deleted) then shortly after an advert for the new contact. + private var lastOverwriteDeletion: (name: String, pubKeyHex: String, time: Date)? - /// Tracks the last overwrite-oldest deletion for correlating with the replacement contact. - /// The device sends 0x8F (deleted) then shortly after an advert for the new contact. - private var lastOverwriteDeletion: (name: String, pubKeyHex: String, time: Date)? + /// Multicast broadcaster for advertisement and discovery events. + /// Producers yield synchronously; consumers subscribe via `events()`. + nonisolated let eventBroadcaster = EventBroadcaster() - /// Multicast broadcaster for advertisement and discovery events. - /// Producers yield synchronously; consumers subscribe via `events()`. - nonisolated let eventBroadcaster = EventBroadcaster() + // MARK: - Initialization - // MARK: - Initialization + public init(session: any AdvertisingSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { + self.session = session + self.dataStore = dataStore + } - public init(session: any AdvertisingSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { - self.session = session - self.dataStore = dataStore - } + deinit { + eventMonitorTask?.cancel() + } - deinit { - eventMonitorTask?.cancel() - } + // MARK: - Events - // MARK: - Events + /// Returns a fresh stream of advertisement and discovery events. + /// Registration is synchronous, so events yielded after this call are + /// never dropped. Consumers must re-subscribe per connection because the + /// owning `ServiceContainer` is rebuilt on every connection. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } - /// Returns a fresh stream of advertisement and discovery events. - /// Registration is synchronous, so events yielded after this call are - /// never dropped. Consumers must re-subscribe per connection because the - /// owning `ServiceContainer` is rebuilt on every connection. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() - } + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishEvents() { - eventBroadcaster.finish() - } + // MARK: - Event Monitoring - // MARK: - Event Monitoring - - /// Start monitoring MeshCore events for advertisement-related notifications - public func startEventMonitoring(radioID: UUID) { - eventMonitorTask?.cancel() - currentRadioID = radioID - - eventMonitorTask = Task { [weak self] in - guard let self else { return } - let filter = EventFilter { event in - switch event { - case .advertisement, .newContact, .pathUpdate, .pathResponse, - .traceData, .contactDeleted, .contactsFull: - return true - case .rxLogData(let log) where log.payloadType == .trace: - return true - default: - return false - } - } - let events = await session.events(filter: filter) + /// Start monitoring MeshCore events for advertisement-related notifications + public func startEventMonitoring(radioID: UUID) { + eventMonitorTask?.cancel() + currentRadioID = radioID - for await event in events { - guard !Task.isCancelled else { break } - await self.handleEvent(event, radioID: radioID) - } + eventMonitorTask = Task { [weak self] in + guard let self else { return } + let filter = EventFilter { event in + switch event { + case .advertisement, .newContact, .pathUpdate, .pathResponse, + .traceData, .contactDeleted, .contactsFull: + true + case let .rxLogData(log) where log.payloadType == .trace: + true + default: + false } - } + } + let events = await session.events(filter: filter) - /// Stop monitoring events - public func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil - currentRadioID = nil + for await event in events { + guard !Task.isCancelled else { break } + await handleEvent(event, radioID: radioID) + } } - - /// Toggle deferred contact fetching during sync. - public func setSyncingContacts(_ isSyncing: Bool) async { - isSyncingContacts = isSyncing - if !isSyncing { - await fetchPendingUnknownContacts() - } + } + + /// Stop monitoring events + public func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + currentRadioID = nil + } + + /// Toggle deferred contact fetching during sync. + public func setSyncingContacts(_ isSyncing: Bool) async { + isSyncingContacts = isSyncing + if !isSyncing { + await fetchPendingUnknownContacts() } + } - /// Handle incoming MeshCore event - private func handleEvent(_ event: MeshEvent, radioID: UUID) async { - switch event { - case .advertisement(let publicKey): - await handleAdvertEvent(publicKey: publicKey, radioID: radioID) - - case .newContact(let contact): - await handleNewAdvertEvent(contact: contact, radioID: radioID) - - case .pathUpdate(let publicKey): - await handlePathUpdatedEvent(publicKey: publicKey, radioID: radioID) + /// Handle incoming MeshCore event + private func handleEvent(_ event: MeshEvent, radioID: UUID) async { + switch event { + case let .advertisement(publicKey): + await handleAdvertEvent(publicKey: publicKey, radioID: radioID) - case .pathResponse(let result): - await handlePathDiscoveryResponse(result: result, radioID: radioID) + case let .newContact(contact): + await handleNewAdvertEvent(contact: contact, radioID: radioID) - case .traceData(let traceInfo): - await handleTraceData(traceInfo: traceInfo, radioID: radioID) + case let .pathUpdate(publicKey): + await handlePathUpdatedEvent(publicKey: publicKey, radioID: radioID) - case .rxLogData(let logData) where logData.payloadType == .trace: - if logData.packetPayload.count >= 4, let snr = logData.snr { - let tag = logData.packetPayload.readUInt32LE(at: 0) - let remoteSnr: Double? = logData.pathNodes.last.map { - Double(Int8(bitPattern: $0)) / 4.0 - } - eventBroadcaster.yield(.traceSnrObserved(tag: tag, localSnr: snr, remoteSnr: remoteSnr, radioID: radioID)) - } - - case .contactDeleted(let publicKey): - await handleContactDeletedEvent(publicKey: publicKey, radioID: radioID) + case let .pathResponse(result): + await handlePathDiscoveryResponse(result: result, radioID: radioID) - case .contactsFull: - await handleContactsFullEvent() + case let .traceData(traceInfo): + await handleTraceData(traceInfo: traceInfo, radioID: radioID) - default: - break + case let .rxLogData(logData) where logData.payloadType == .trace: + if logData.packetPayload.count >= 4, let snr = logData.snr { + let tag = logData.packetPayload.readUInt32LE(at: 0) + let remoteSnr: Double? = logData.pathNodes.last.map { + Double(Int8(bitPattern: $0)) / 4.0 } - } - - // MARK: - Send Advertisement + eventBroadcaster.yield(.traceSnrObserved(tag: tag, localSnr: snr, remoteSnr: remoteSnr, radioID: radioID)) + } - /// Send self advertisement to the mesh network - /// - Parameter flood: If true, sends flood advertisement (reaches all nodes). - /// If false, sends zero-hop advertisement (direct only). - public func sendSelfAdvertisement(flood: Bool) async throws { - do { - try await session.sendAdvertisement(flood: flood) - } catch let error as MeshCoreError { - throw AdvertisementError.sessionError(error) - } - } + case let .contactDeleted(publicKey): + await handleContactDeletedEvent(publicKey: publicKey, radioID: radioID) - // MARK: - Update Node Name + case .contactsFull: + await handleContactsFullEvent() - /// Set the node's advertised name - /// - Parameter name: The name to advertise (max 31 characters) - public func setAdvertName(_ name: String) async throws { - do { - try await session.setName(name) - } catch let error as MeshCoreError { - throw AdvertisementError.sessionError(error) - } + default: + break } - - // MARK: - Update Location - - /// Set the node's advertised GPS coordinates - /// - Parameters: - /// - latitude: Latitude in degrees (-90 to 90) - /// - longitude: Longitude in degrees (-180 to 180) - public func setAdvertLocation(latitude: Double, longitude: Double) async throws { - do { - try await session.setCoordinates(latitude: latitude, longitude: longitude) - } catch let error as MeshCoreError { - throw AdvertisementError.sessionError(error) - } + } + + // MARK: - Send Advertisement + + /// Send self advertisement to the mesh network + /// - Parameter flood: If true, sends flood advertisement (reaches all nodes). + /// If false, sends zero-hop advertisement (direct only). + public func sendSelfAdvertisement(flood: Bool) async throws { + do { + try await session.sendAdvertisement(flood: flood) + } catch let error as MeshCoreError { + throw AdvertisementError.sessionError(error) } + } - // MARK: - Private Event Handlers - - /// Handle advertisement event - Existing contact updated - private func handleAdvertEvent(publicKey: Data, radioID: UUID) async { - let pubKeyHex = publicKey.map { String(format: "%02X", $0) }.joined() - logger.debug("Advert event for \(pubKeyHex)") - discoverTrace.info("B1 0x80 ADVERT received key=\(pubKeyHex)") - - let timestamp = UInt32(Date().timeIntervalSince1970) + // MARK: - Update Node Name - do { - if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { - // Create a modified version with updated timestamp - let frame = ContactFrame( - publicKey: contact.publicKey, - type: contact.type, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - name: contact.name, - lastAdvertTimestamp: timestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) - ) - _ = try await dataStore.saveContact(radioID: radioID, from: frame) - - // Also track in DiscoveredNode for Discover page visibility - do { - let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) - discoverTrace.info("B2 0x80 known-contact upsert key=\(pubKeyHex) isNew=\(isNew)") - } catch { - discoverTrace.error("B2 0x80 known-contact upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") - } - - // Notify UI of contact update - eventBroadcaster.yield(.contactUpdated) - } else { - discoverTrace.info("B2 0x80 no local contact key=\(pubKeyHex) syncing=\(isSyncingContacts)") - if isSyncingContacts { - pendingUnknownContactKeys.insert(publicKey) - logger.info("ADVERT received for unknown contact during sync - deferring fetch") - } else { - // Unknown contact - device has it but we don't (auto-add mode) - // Fetch just this contact from device and notify - logger.info("ADVERT received for unknown contact - fetching from device") - do { - if let meshContact = try await session.getContact(publicKey: publicKey) { - let frame = meshContact.toContactFrame() - let contactID = try await dataStore.saveContact(radioID: radioID, from: frame) - - // Also track in DiscoveredNode for Discover page visibility - do { - let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) - discoverTrace.info("B2 0x80 getContact OK upsert key=\(pubKeyHex) isNew=\(isNew)") - } catch { - discoverTrace.error("B2 0x80 getContact-path upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") - } - - // Empty names pass through raw; NotificationService substitutes a localized fallback. - let contactName = meshContact.advertisedName - let contactType = meshContact.type - eventBroadcaster.yield(.newContactDiscovered(name: contactName, contactID: contactID, contactType: contactType)) - - // Correlate with recent overwrite-oldest deletion - logOverwriteReplacementIfRecent( - newContactName: contactName.isEmpty ? "Unknown Contact" : contactName, - newContactType: contactType - ) - } else { - discoverTrace.notice("B2 0x80 getContact returned nil key=\(pubKeyHex)") - } - } catch { - logger.error("Failed to fetch new contact: \(error.localizedDescription)") - discoverTrace.error("B2 0x80 getContact THREW key=\(pubKeyHex): \(error.localizedDescription)") - } - eventBroadcaster.yield(.contactSyncRequested(radioID: radioID)) - } - } - } catch { - logger.error("Error handling advert event: \(error.localizedDescription)") - } + /// Set the node's advertised name + /// - Parameter name: The name to advertise (max 31 characters) + public func setAdvertName(_ name: String) async throws { + do { + try await session.setName(name) + } catch let error as MeshCoreError { + throw AdvertisementError.sessionError(error) } - - private func fetchPendingUnknownContacts() async { - guard !pendingUnknownContactKeys.isEmpty else { return } - guard let radioID = currentRadioID else { - logger.warning("No device ID available to fetch pending contacts") - return - } - - let pendingKeys = pendingUnknownContactKeys - pendingUnknownContactKeys.removeAll() - - for publicKey in pendingKeys { - do { - let pubKeyHex = publicKey.map { String(format: "%02X", $0) }.joined() - if let meshContact = try await session.getContact(publicKey: publicKey) { - let frame = meshContact.toContactFrame() - let contactID = try await dataStore.saveContact(radioID: radioID, from: frame) - - // Also track in DiscoveredNode for Discover page visibility - do { - let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) - discoverTrace.info("B2 deferred-drain upsert key=\(pubKeyHex) isNew=\(isNew)") - } catch { - discoverTrace.error("B2 deferred-drain upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") - } - - // Empty names pass through raw; NotificationService substitutes a localized fallback. - let contactName = meshContact.advertisedName - let contactType = meshContact.type - eventBroadcaster.yield(.newContactDiscovered(name: contactName, contactID: contactID, contactType: contactType)) - eventBroadcaster.yield(.contactSyncRequested(radioID: radioID)) - } - } catch { - pendingUnknownContactKeys.insert(publicKey) - logger.error("Failed to fetch deferred contact: \(error.localizedDescription)") - } - } + } + + // MARK: - Update Location + + /// Set the node's advertised GPS coordinates + /// - Parameters: + /// - latitude: Latitude in degrees (-90 to 90) + /// - longitude: Longitude in degrees (-180 to 180) + public func setAdvertLocation(latitude: Double, longitude: Double) async throws { + do { + try await session.setCoordinates(latitude: latitude, longitude: longitude) + } catch let error as MeshCoreError { + throw AdvertisementError.sessionError(error) } - - /// Handle new advertisement event - New contact discovered (manual add mode) - private func handleNewAdvertEvent(contact: MeshContact, radioID: UUID) async { - let contactFrame = contact.toContactFrame() - let pubKeyHex = contactFrame.publicKey.map { String(format: "%02X", $0) }.joined() - discoverTrace.info("B1 0x8A NEW_ADVERT received key=\(pubKeyHex)") - + } + + // MARK: - Private Event Handlers + + /// Handle advertisement event - Existing contact updated + private func handleAdvertEvent(publicKey: Data, radioID: UUID) async { + let pubKeyHex = publicKey.map { String(format: "%02X", $0) }.joined() + logger.debug("Advert event for \(pubKeyHex)") + discoverTrace.info("B1 0x80 ADVERT received key=\(pubKeyHex)") + + let timestamp = UInt32(Date().timeIntervalSince1970) + + do { + if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { + // Create a modified version with updated timestamp + let frame = ContactFrame( + publicKey: contact.publicKey, + type: contact.type, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + name: contact.name, + lastAdvertTimestamp: timestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: UInt32(Date().timeIntervalSince1970) + ) + _ = try await dataStore.saveContact(radioID: radioID, from: frame) + + // Also track in DiscoveredNode for Discover page visibility do { - let (node, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: contactFrame) - discoverTrace.info("B2 0x8A upsert key=\(pubKeyHex) isNew=\(isNew)") - - // Notify UI of discovered node update - eventBroadcaster.yield(.contactUpdated) - - // Only post notification for NEW discoveries (not repeat adverts from same contact) - if isNew { - let contactName = node.name - let contactType = node.nodeType - eventBroadcaster.yield(.newContactDiscovered(name: contactName, contactID: node.id, contactType: contactType)) - - // Correlate with recent overwrite-oldest deletion - logOverwriteReplacementIfRecent(newContactName: contactName, newContactType: contactType) - } + let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) + discoverTrace.info("B2 0x80 known-contact upsert key=\(pubKeyHex) isNew=\(isNew)") } catch { - logger.error("Error handling new advert event: \(error.localizedDescription)") - discoverTrace.error("B2 0x8A upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") + discoverTrace.error("B2 0x80 known-contact upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") } - } - - /// Handle path updated event - Contact path changed - private func handlePathUpdatedEvent(publicKey: Data, radioID: UUID) async { - let pubKeyHex = publicKey.map { String(format: "%02X", $0) }.joined() - logger.debug("Path updated event for \(pubKeyHex)") - do { - // Fetch fresh contact from device (includes updated path) - guard let meshContact = try await session.getContact(publicKey: publicKey) else { - logger.warning("Contact not found on device for public key \(pubKeyHex)") - return + // Notify UI of contact update + eventBroadcaster.yield(.contactUpdated) + } else { + discoverTrace.info("B2 0x80 no local contact key=\(pubKeyHex) syncing=\(isSyncingContacts)") + if isSyncingContacts { + pendingUnknownContactKeys.insert(publicKey) + logger.info("ADVERT received for unknown contact during sync - deferring fetch") + } else { + // Unknown contact - device has it but we don't (auto-add mode) + // Fetch just this contact from device and notify + logger.info("ADVERT received for unknown contact - fetching from device") + do { + if let meshContact = try await session.getContact(publicKey: publicKey) { + let frame = meshContact.toContactFrame() + let contactID = try await dataStore.saveContact(radioID: radioID, from: frame) + + // Also track in DiscoveredNode for Discover page visibility + do { + let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) + discoverTrace.info("B2 0x80 getContact OK upsert key=\(pubKeyHex) isNew=\(isNew)") + } catch { + discoverTrace.error("B2 0x80 getContact-path upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") + } + + // Empty names pass through raw; NotificationService substitutes a localized fallback. + let contactName = meshContact.advertisedName + let contactType = meshContact.type + eventBroadcaster.yield(.newContactDiscovered(name: contactName, contactID: contactID, contactType: contactType)) + + // Correlate with recent overwrite-oldest deletion + logOverwriteReplacementIfRecent( + newContactName: contactName.isEmpty ? "Unknown Contact" : contactName, + newContactType: contactType + ) + } else { + discoverTrace.notice("B2 0x80 getContact returned nil key=\(pubKeyHex)") } - - // Persist updated routing info - let frame = meshContact.toContactFrame() - _ = try await dataStore.saveContact(radioID: radioID, from: frame) - - logger.debug("Refreshed contact path: \(meshContact.advertisedName.isEmpty ? "unnamed" : meshContact.advertisedName)") - - // Notify UI of contact update - eventBroadcaster.yield(.contactUpdated) - - } catch { - logger.error("Error refreshing contact path: \(error.localizedDescription)") + } catch { + logger.error("Failed to fetch new contact: \(error.localizedDescription)") + discoverTrace.error("B2 0x80 getContact THREW key=\(pubKeyHex): \(error.localizedDescription)") + } + eventBroadcaster.yield(.contactSyncRequested(radioID: radioID)) } + } + } catch { + logger.error("Error handling advert event: \(error.localizedDescription)") } + } - /// Handle path discovery response event - private func handlePathDiscoveryResponse(result: PathInfo, radioID: UUID) async { - // Chunk debug output using the hash size each direction declares on - // the wire so mode-skew between firmware and the cached device record - // can't smear hop boundaries in the log. - let outHashSize = decodePathLen(result.outPathLength)?.hashSize ?? 1 - let inHashSize = decodePathLen(result.inPathLength)?.hashSize ?? 1 - let outHops = stride(from: 0, to: result.outPath.count, by: outHashSize).map { start in - result.outPath[start.. Int { - switch kind { - case .messages: messageCount - case .contacts: contactCount - case .channels: channelCount - case .devices: deviceCount - case .roomMessages: roomMessageCount - case .reactions: reactionCount - case .messageRepeats: messageRepeatCount - case .savedTracePaths: savedTracePathCount - case .remoteNodeSessions: remoteNodeSessionCount - case .blockedChannelSenders: blockedChannelSenderCount - case .nodeStatusSnapshots: nodeStatusSnapshotCount - } + public var deviceCount: Int + public var contactCount: Int + public var channelCount: Int + public var messageCount: Int + public var messageRepeatCount: Int + public var reactionCount: Int + public var roomMessageCount: Int + public var remoteNodeSessionCount: Int + public var savedTracePathCount: Int + public var blockedChannelSenderCount: Int + public var nodeStatusSnapshotCount: Int + + public init( + deviceCount: Int = 0, + contactCount: Int = 0, + channelCount: Int = 0, + messageCount: Int = 0, + messageRepeatCount: Int = 0, + reactionCount: Int = 0, + roomMessageCount: Int = 0, + remoteNodeSessionCount: Int = 0, + savedTracePathCount: Int = 0, + blockedChannelSenderCount: Int = 0, + nodeStatusSnapshotCount: Int = 0 + ) { + self.deviceCount = deviceCount + self.contactCount = contactCount + self.channelCount = channelCount + self.messageCount = messageCount + self.messageRepeatCount = messageRepeatCount + self.reactionCount = reactionCount + self.roomMessageCount = roomMessageCount + self.remoteNodeSessionCount = remoteNodeSessionCount + self.savedTracePathCount = savedTracePathCount + self.blockedChannelSenderCount = blockedChannelSenderCount + self.nodeStatusSnapshotCount = nodeStatusSnapshotCount + } + + /// Build a manifest from an envelope's actual array counts. + public init(from envelope: AppBackupEnvelope) { + deviceCount = envelope.devices.count + contactCount = envelope.contacts.count + channelCount = envelope.channels.count + messageCount = envelope.messages.count + messageRepeatCount = envelope.messageRepeats.count + reactionCount = envelope.reactions.count + roomMessageCount = envelope.roomMessages.count + remoteNodeSessionCount = envelope.remoteNodeSessions.count + savedTracePathCount = envelope.savedTracePaths.count + blockedChannelSenderCount = envelope.blockedChannelSenders.count + nodeStatusSnapshotCount = envelope.nodeStatusSnapshots.count + } + + /// Returns the declared count for `kind`. Backs `BackupModelKind`-driven UI iteration. + public func count(for kind: BackupModelKind) -> Int { + switch kind { + case .messages: messageCount + case .contacts: contactCount + case .channels: channelCount + case .devices: deviceCount + case .roomMessages: roomMessageCount + case .reactions: reactionCount + case .messageRepeats: messageRepeatCount + case .savedTracePaths: savedTracePathCount + case .remoteNodeSessions: remoteNodeSessionCount + case .blockedChannelSenders: blockedChannelSenderCount + case .nodeStatusSnapshots: nodeStatusSnapshotCount } + } - /// Returns `true` if the manifest counts match the actual array counts in the envelope. - public func validate(against envelope: AppBackupEnvelope) -> Bool { - self == BackupManifest(from: envelope) - } + /// Returns `true` if the manifest counts match the actual array counts in the envelope. + public func validate(against envelope: AppBackupEnvelope) -> Bool { + self == BackupManifest(from: envelope) + } } // MARK: - ImportResult @@ -196,63 +195,76 @@ public struct BackupManifest: Codable, Sendable, Equatable { /// Storage is a `BackupModelKind`-keyed dict so totals and UI row iteration /// stay consistent when a new model type is added. public struct ImportResult: Sendable, Equatable { - public var counts: [BackupModelKind: PerTypeCounts] - public var userDefaultsRestored: Bool = false - - /// Local channel slots, keyed by radioID, whose occupancy changed during - /// import (relocated, merged at a different slot, or dropped). The main-actor - /// caller clears chat drafts for these slots so a draft typed against one - /// channel can't silently follow a slot to a different channel. - public var channelSlotsAffectedByImport: [UUID: Set] = [:] - - public init() { - self.counts = Dictionary( - uniqueKeysWithValues: BackupModelKind.allCases.map { ($0, .zero) } - ) - } - - /// Adds to the counts for `kind`. All import phases funnel through this - /// single mutator so totals and per-kind buckets can't drift. - public mutating func record( - _ kind: BackupModelKind, - inserted: Int = 0, - merged: Int = 0, - skipped: Int = 0, - dropped: Int = 0 - ) { - var current = counts[kind, default: .zero] - current.inserted += inserted - current.merged += merged - current.skipped += skipped - current.dropped += dropped - counts[kind] = current - } - - public var totalInserted: Int { counts.values.reduce(0) { $0 + $1.inserted } } - public var totalMerged: Int { counts.values.reduce(0) { $0 + $1.merged } } - public var totalSkipped: Int { counts.values.reduce(0) { $0 + $1.skipped } } - public var totalDropped: Int { counts.values.reduce(0) { $0 + $1.dropped } } - - public var totalRestoredRecordCount: Int { totalInserted + totalMerged } - - public var hasRestoredChanges: Bool { - totalRestoredRecordCount > 0 || userDefaultsRestored - } + public var counts: [BackupModelKind: PerTypeCounts] + public var userDefaultsRestored: Bool = false + + /// Local channel slots, keyed by radioID, whose occupancy changed during + /// import (relocated, merged at a different slot, or dropped). The main-actor + /// caller clears chat drafts for these slots so a draft typed against one + /// channel can't silently follow a slot to a different channel. + public var channelSlotsAffectedByImport: [UUID: Set] = [:] + + public init() { + counts = Dictionary( + uniqueKeysWithValues: BackupModelKind.allCases.map { ($0, .zero) } + ) + } + + /// Adds to the counts for `kind`. All import phases funnel through this + /// single mutator so totals and per-kind buckets can't drift. + public mutating func record( + _ kind: BackupModelKind, + inserted: Int = 0, + merged: Int = 0, + skipped: Int = 0, + dropped: Int = 0 + ) { + var current = counts[kind, default: .zero] + current.inserted += inserted + current.merged += merged + current.skipped += skipped + current.dropped += dropped + counts[kind] = current + } + + public var totalInserted: Int { + counts.values.reduce(0) { $0 + $1.inserted } + } + + public var totalMerged: Int { + counts.values.reduce(0) { $0 + $1.merged } + } + + public var totalSkipped: Int { + counts.values.reduce(0) { $0 + $1.skipped } + } + + public var totalDropped: Int { + counts.values.reduce(0) { $0 + $1.dropped } + } + + public var totalRestoredRecordCount: Int { + totalInserted + totalMerged + } + + public var hasRestoredChanges: Bool { + totalRestoredRecordCount > 0 || userDefaultsRestored + } } func makeBackupJSONEncoder() -> JSONEncoder { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .secondsSince1970 - // Deterministic key order gives zlib longer match windows on the - // DTO-array-heavy payload — measurable ratio win at zero runtime cost. - encoder.outputFormatting = .sortedKeys - return encoder + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + // Deterministic key order gives zlib longer match windows on the + // DTO-array-heavy payload — measurable ratio win at zero runtime cost. + encoder.outputFormatting = .sortedKeys + return encoder } func makeBackupJSONDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .secondsSince1970 - return decoder + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return decoder } // MARK: - parseBackup @@ -277,43 +289,43 @@ public let maxBackupUncompressedBytes = 512 * 1_048_576 /// - Returns: A validated `AppBackupEnvelope`. /// - Throws: `AppBackupError` on failure. public func parseBackup( - data: Data, - maxUncompressedBytes: Int = maxBackupUncompressedBytes + data: Data, + maxUncompressedBytes: Int = maxBackupUncompressedBytes ) throws -> AppBackupEnvelope { - guard data.count <= maxBackupCompressedBytes else { - throw AppBackupError.fileTooLarge( - actualBytes: data.count, - maxBytes: maxBackupCompressedBytes - ) - } - - let decompressed: Data - do { - decompressed = try data.zlibDecompressed(maxUncompressedBytes: maxUncompressedBytes) - } catch let error as AppBackupError { - throw error - } catch { - throw AppBackupError.invalidFile - } - - let envelope: AppBackupEnvelope - do { - let decoder = makeBackupJSONDecoder() - envelope = try decoder.decode(AppBackupEnvelope.self, from: decompressed) - } catch { - throw AppBackupError.invalidFile - } - - guard envelope.version <= AppBackupEnvelope.currentVersion else { - throw AppBackupError.unsupportedVersion( - found: envelope.version, - maxSupported: AppBackupEnvelope.currentVersion - ) - } - - guard envelope.manifest.validate(against: envelope) else { - throw AppBackupError.corruptedManifest - } - - return envelope + guard data.count <= maxBackupCompressedBytes else { + throw AppBackupError.fileTooLarge( + actualBytes: data.count, + maxBytes: maxBackupCompressedBytes + ) + } + + let decompressed: Data + do { + decompressed = try data.zlibDecompressed(maxUncompressedBytes: maxUncompressedBytes) + } catch let error as AppBackupError { + throw error + } catch { + throw AppBackupError.invalidFile + } + + let envelope: AppBackupEnvelope + do { + let decoder = makeBackupJSONDecoder() + envelope = try decoder.decode(AppBackupEnvelope.self, from: decompressed) + } catch { + throw AppBackupError.invalidFile + } + + guard envelope.version <= AppBackupEnvelope.currentVersion else { + throw AppBackupError.unsupportedVersion( + found: envelope.version, + maxSupported: AppBackupEnvelope.currentVersion + ) + } + + guard envelope.manifest.validate(against: envelope) else { + throw AppBackupError.corruptedManifest + } + + return envelope } diff --git a/MC1Services/Sources/MC1Services/Services/AppBackupService.swift b/MC1Services/Sources/MC1Services/Services/AppBackupService.swift index 1cabe6bf..0ddd5423 100644 --- a/MC1Services/Sources/MC1Services/Services/AppBackupService.swift +++ b/MC1Services/Sources/MC1Services/Services/AppBackupService.swift @@ -2,116 +2,115 @@ import Foundation /// Result of a successful backup export. public struct ExportResult: Sendable { - public let data: Data - public let manifest: BackupManifest + public let data: Data + public let manifest: BackupManifest - public init(data: Data, manifest: BackupManifest) { - self.data = data - self.manifest = manifest - } + public init(data: Data, manifest: BackupManifest) { + self.data = data + self.manifest = manifest + } } /// Exports all app data to a compressed backup file. public actor AppBackupService { - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "AppBackupService") - - public init() {} - - // MARK: - Export - - /// Export all app data to a compressed backup. - /// - Parameter persistenceStore: The store to fetch records from. - /// - Returns: An `ExportResult` containing the compressed data and manifest. - /// - Throws: `AppBackupError.exportFailed` on failure. - public func export( - persistenceStore: PersistenceStore - ) async throws -> ExportResult { - do { - let snapshot = try await persistenceStore.fetchBackupExportSnapshot() - - let userDefaultsSnapshot = BackupUserDefaults.snapshot(from: .standard) - - let appVersion = Bundle.main.appVersion - let appBuild = Bundle.main.appBuild - - var envelope = AppBackupEnvelope( - exportDate: .now, - appVersion: appVersion, - appBuild: appBuild, - devices: snapshot.devices, - contacts: snapshot.contacts, - channels: snapshot.channels, - messages: snapshot.messages, - messageRepeats: snapshot.messageRepeats, - reactions: snapshot.reactions, - roomMessages: snapshot.roomMessages, - remoteNodeSessions: snapshot.remoteNodeSessions, - savedTracePaths: snapshot.savedTracePaths, - blockedChannelSenders: snapshot.blockedChannelSenders, - nodeStatusSnapshots: snapshot.nodeStatusSnapshots, - userDefaults: userDefaultsSnapshot - ) - envelope.manifest = BackupManifest(from: envelope) - - let encoder = makeBackupJSONEncoder() - let jsonData = try encoder.encode(envelope) - - let compressed = try jsonData.zlibCompressed() - logger.info("Backup exported: \(jsonData.count) bytes JSON → \(compressed.count) bytes compressed") - return ExportResult(data: compressed, manifest: envelope.manifest) - - } catch let error as AppBackupError { - throw error - } catch { - throw AppBackupError.exportFailed(underlying: error) - } + private let logger = PersistentLogger(subsystem: "com.mc1", category: "AppBackupService") + + public init() {} + + // MARK: - Export + + /// Export all app data to a compressed backup. + /// - Parameter persistenceStore: The store to fetch records from. + /// - Returns: An `ExportResult` containing the compressed data and manifest. + /// - Throws: `AppBackupError.exportFailed` on failure. + public func export( + persistenceStore: PersistenceStore + ) async throws -> ExportResult { + do { + let snapshot = try await persistenceStore.fetchBackupExportSnapshot() + + let userDefaultsSnapshot = BackupUserDefaults.snapshot(from: .standard) + + let appVersion = Bundle.main.appVersion + let appBuild = Bundle.main.appBuild + + var envelope = AppBackupEnvelope( + exportDate: .now, + appVersion: appVersion, + appBuild: appBuild, + devices: snapshot.devices, + contacts: snapshot.contacts, + channels: snapshot.channels, + messages: snapshot.messages, + messageRepeats: snapshot.messageRepeats, + reactions: snapshot.reactions, + roomMessages: snapshot.roomMessages, + remoteNodeSessions: snapshot.remoteNodeSessions, + savedTracePaths: snapshot.savedTracePaths, + blockedChannelSenders: snapshot.blockedChannelSenders, + nodeStatusSnapshots: snapshot.nodeStatusSnapshots, + userDefaults: userDefaultsSnapshot + ) + envelope.manifest = BackupManifest(from: envelope) + + let encoder = makeBackupJSONEncoder() + let jsonData = try encoder.encode(envelope) + + let compressed = try jsonData.zlibCompressed() + logger.info("Backup exported: \(jsonData.count) bytes JSON → \(compressed.count) bytes compressed") + return ExportResult(data: compressed, manifest: envelope.manifest) + + } catch let error as AppBackupError { + throw error + } catch { + throw AppBackupError.exportFailed(underlying: error) } - - // MARK: - Import - - /// Import backup data into the local store with radioID remapping. - /// - /// Devices are matched by `publicKey`. When a backup device matches a local device, - /// all child records are remapped to use the local device's `radioID`. Unmatched - /// devices are inserted as-is. Records are inserted in parent-before-child order - /// with a single `modelContext.save()` at the end. - /// - /// - Parameters: - /// - envelope: The decoded backup envelope. - /// - persistenceStore: The store to insert records into. - /// - defaults: UserDefaults instance for preference restore (defaults to `.standard`). - /// - Returns: An `ImportResult` with per-model inserted/skipped counts. - /// - Throws: `AppBackupError.importFailed` on failure. - @discardableResult - public func importBackup( - envelope: AppBackupEnvelope, - into persistenceStore: PersistenceStore, - defaults: UserDefaults = .standard - ) async throws -> ImportResult { - do { - var result = try await persistenceStore.importBackupDatabase(envelope) - - // Cancellation past this point is ignored. `importBackupDatabase` has - // already committed, and `BackupUserDefaults.restore(to:)` is a - // write-if-missing no-op on re-run, so finishing the side effect is - // safer than reporting cancellation while the DB is persisted. - if let userDefaultsSnapshot = envelope.userDefaults { - let addedKeys = userDefaultsSnapshot.restore(to: defaults) - result.userDefaultsRestored = !addedKeys.isEmpty - } - - logger.info( - "Import complete: \(result.totalInserted) inserted, \(result.totalMerged) merged, \(result.totalSkipped) skipped" - ) - return result - - } catch is CancellationError { - throw CancellationError() - } catch let error as AppBackupError { - throw error - } catch { - throw AppBackupError.importFailed(underlying: error) - } + } + + // MARK: - Import + + /// Import backup data into the local store with radioID remapping. + /// + /// Devices are matched by `publicKey`. When a backup device matches a local device, + /// all child records are remapped to use the local device's `radioID`. Unmatched + /// devices are inserted as-is. Records are inserted in parent-before-child order + /// with a single `modelContext.save()` at the end. + /// + /// - Parameters: + /// - envelope: The decoded backup envelope. + /// - persistenceStore: The store to insert records into. + /// - defaults: UserDefaults instance for preference restore (defaults to `.standard`). + /// - Returns: An `ImportResult` with per-model inserted/skipped counts. + /// - Throws: `AppBackupError.importFailed` on failure. + @discardableResult + public func importBackup( + envelope: AppBackupEnvelope, + into persistenceStore: PersistenceStore, + defaults: UserDefaults = .standard + ) async throws -> ImportResult { + do { + var result = try await persistenceStore.importBackupDatabase(envelope) + + // Cancellation past this point is ignored. `importBackupDatabase` has + // already committed, and `BackupUserDefaults.restore(to:)` is a + // write-if-missing no-op on re-run, so finishing the side effect is + // safer than reporting cancellation while the DB is persisted. + if let userDefaultsSnapshot = envelope.userDefaults { + let addedKeys = userDefaultsSnapshot.restore(to: defaults) + result.userDefaultsRestored = !addedKeys.isEmpty + } + + logger.info( + "Import complete: \(result.totalInserted) inserted, \(result.totalMerged) merged, \(result.totalSkipped) skipped" + ) + return result + + } catch is CancellationError { + throw CancellationError() + } catch let error as AppBackupError { + throw error + } catch { + throw AppBackupError.importFailed(underlying: error) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/AppStorageKey.swift b/MC1Services/Sources/MC1Services/Services/AppStorageKey.swift index fa24da6e..537cc0a8 100644 --- a/MC1Services/Sources/MC1Services/Services/AppStorageKey.swift +++ b/MC1Services/Sources/MC1Services/Services/AppStorageKey.swift @@ -13,70 +13,71 @@ import Foundation /// it in `BackupUserDefaults` (a mapping row or a special-cased branch) /// unless the value is intentionally device-local. public enum AppStorageKey: String { - // swiftlint:disable redundant_string_enum_value - case hasCompletedOnboarding = "hasCompletedOnboarding" - case liveActivityEnabled = "liveActivityEnabled" - case showIncomingPath = "showIncomingPath" - case showIncomingHopCount = "showIncomingHopCount" - case showIncomingRegion = "showIncomingRegion" - case showIncomingSendTime = "showIncomingSendTime" - case linkPreviewsEnabled = "linkPreviewsEnabled" - case linkPreviewsAutoResolveDM = "linkPreviewsAutoResolveDM" - case linkPreviewsAutoResolveChannels = "linkPreviewsAutoResolveChannels" - case showInlineImages = "showInlineImages" - case autoPlayGIFs = "autoPlayGIFs" - case replyWithQuote = "replyWithQuote" - case showMapPreviewThumbnails = "showMapPreviewThumbnails" - case nodesSortOrder = "nodesSortOrder" - case discoverySortOrder = "discoverySortOrder" - case tracePathViewMode = "tracePathViewMode" - case mapStyleSelection = "mapStyleSelection" - case mapShowLabels = "mapShowLabels" - case hasSeenRepeaterDragHint = "hasSeenRepeaterDragHint" - case autoDeleteStaleNodesDays = "autoDeleteStaleNodesDays" - case lastStaleCleanupDate = "lastStaleCleanupDate" - case frequentEmojis = "frequentEmojis" - case recentReactionEmojis = "recentReactionEmojis" - case isDemoModeUnlocked = "isDemoModeUnlocked" - case isDemoModeEnabled = "isDemoModeEnabled" - // Intentionally device-local: not registered in BackupUserDefaults so restored - // hardware still shows the current release's notes once. Registering it would - // suppress the sheet on a genuinely new device. - case lastShownWhatsNewVersion = "lastShownWhatsNewVersion" + case hasCompletedOnboarding + case liveActivityEnabled + case showIncomingPath + case showIncomingHopCount + case showIncomingRegion + case showIncomingSendTime + case linkPreviewsEnabled + case linkPreviewsAutoResolveDM + case linkPreviewsAutoResolveChannels + /// Retained for the backup wire format only; the `linkPreviewsEnabled` master + /// now gates inline images, so this value is round-tripped but never read. + case showInlineImages + case autoPlayGIFs + case replyWithQuote + case showMapPreviewThumbnails + case nodesSortOrder + case discoverySortOrder + case tracePathViewMode + case mapStyleSelection + case mapShowLabels + case mapNorthLocked + case hasSeenRepeaterDragHint + case autoDeleteStaleNodesDays + case lastStaleCleanupDate + case frequentEmojis + case recentReactionEmojis + case isDemoModeUnlocked + case isDemoModeEnabled + /// Intentionally device-local: not registered in BackupUserDefaults so restored + /// hardware still shows the current release's notes once. Registering it would + /// suppress the sheet on a genuinely new device. + case lastShownWhatsNewVersion - // Notification toggles read by NotificationPreferences and - // NotificationPreferencesStore; all share defaultNotificationEnabled. - case notifyContactMessages = "notifyContactMessages" - case notifyChannelMessages = "notifyChannelMessages" - case notifyRoomMessages = "notifyRoomMessages" - case notifyNewContacts = "notifyNewContacts" - case notifyNewContactsContact = "notifyNewContactsContact" - case notifyNewContactsRepeater = "notifyNewContactsRepeater" - case notifyNewContactsRoom = "notifyNewContactsRoom" - case notifyReactions = "notifyReactions" - case notificationSoundEnabled = "notificationSoundEnabled" - case notificationBadgeEnabled = "notificationBadgeEnabled" - case notifyLowBattery = "notifyLowBattery" - // swiftlint:enable redundant_string_enum_value + // Notification toggles read by NotificationPreferences and + // NotificationPreferencesStore; all share defaultNotificationEnabled. + case notifyContactMessages + case notifyChannelMessages + case notifyRoomMessages + case notifyNewContacts + case notifyNewContactsContact + case notifyNewContactsRepeater + case notifyNewContactsRoom + case notifyReactions + case notificationSoundEnabled + case notificationBadgeEnabled + case notifyLowBattery - public static let defaultShowIncomingPath: Bool = false - public static let defaultShowIncomingHopCount: Bool = false - public static let defaultShowIncomingRegion: Bool = false - public static let defaultShowIncomingSendTime: Bool = false - public static let defaultLinkPreviewsEnabled: Bool = false - public static let defaultLinkPreviewsAutoResolveDM: Bool = true - public static let defaultLinkPreviewsAutoResolveChannels: Bool = true - public static let defaultShowInlineImages: Bool = true - public static let defaultAutoPlayGIFs: Bool = true - public static let defaultReplyWithQuote: Bool = false - public static let defaultShowMapPreviewThumbnails: Bool = true - public static let defaultMapShowLabels: Bool = true - public static let defaultHasSeenRepeaterDragHint: Bool = false - public static let defaultLiveActivityEnabled: Bool = true - /// Days before a non-favorite node is auto-deleted; 0 disables cleanup. - public static let defaultAutoDeleteStaleNodesDays: Int = 0 - /// `timeIntervalSinceReferenceDate` of the last cleanup; 0 means never ran. - public static let defaultLastStaleCleanupDate: Double = 0 - /// Shared default for every notification toggle case. - public static let defaultNotificationEnabled: Bool = true + public static let defaultShowIncomingPath: Bool = false + public static let defaultShowIncomingHopCount: Bool = false + public static let defaultShowIncomingRegion: Bool = false + public static let defaultShowIncomingSendTime: Bool = false + public static let defaultLinkPreviewsEnabled: Bool = false + public static let defaultLinkPreviewsAutoResolveDM: Bool = true + public static let defaultLinkPreviewsAutoResolveChannels: Bool = true + public static let defaultAutoPlayGIFs: Bool = true + public static let defaultReplyWithQuote: Bool = false + public static let defaultShowMapPreviewThumbnails: Bool = true + public static let defaultMapShowLabels: Bool = true + public static let defaultMapNorthLocked: Bool = false + public static let defaultHasSeenRepeaterDragHint: Bool = false + public static let defaultLiveActivityEnabled: Bool = true + /// Days before a non-favorite node is auto-deleted; 0 disables cleanup. + public static let defaultAutoDeleteStaleNodesDays: Int = 0 + /// `timeIntervalSinceReferenceDate` of the last cleanup; 0 means never ran. + public static let defaultLastStaleCleanupDate: Double = 0 + /// Shared default for every notification toggle case. + public static let defaultNotificationEnabled: Bool = true } diff --git a/MC1Services/Sources/MC1Services/Services/BLETransportOpenedSignal.swift b/MC1Services/Sources/MC1Services/Services/BLETransportOpenedSignal.swift index 9c1622a3..1f5a8b2b 100644 --- a/MC1Services/Sources/MC1Services/Services/BLETransportOpenedSignal.swift +++ b/MC1Services/Sources/MC1Services/Services/BLETransportOpenedSignal.swift @@ -6,69 +6,68 @@ import Foundation /// cancelled before `fire()` lands; callers handle the throw with the /// same logic as a timeout (park the envelope, requeue). actor BLETransportOpenedSignal { + private struct Waiter { + let id: UInt64 + let continuation: CheckedContinuation + } - private struct Waiter { - let id: UInt64 - let continuation: CheckedContinuation - } - - private var armed: Bool = false - private var waiters: [Waiter] = [] - private var nextWaiterID: UInt64 = 0 + private var armed: Bool = false + private var waiters: [Waiter] = [] + private var nextWaiterID: UInt64 = 0 - init() {} + init() {} - /// Suspend until `fire()` lands. If the signal is already armed at - /// call time, the call returns immediately and consumes the armed - /// flag. Throws `CancellationError` if the calling task is cancelled - /// before the signal fires. - func wait() async throws { - if armed { - armed = false - return - } - let waiterID = nextWaiterID - nextWaiterID &+= 1 - try await withTaskCancellationHandler { - let _: Void = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - if Task.isCancelled { - continuation.resume(throwing: CancellationError()) - return - } - waiters.append(Waiter(id: waiterID, continuation: continuation)) - } - } onCancel: { - Task { [weak self] in - await self?.handleCancellation(id: waiterID) - } - } + /// Suspend until `fire()` lands. If the signal is already armed at + /// call time, the call returns immediately and consumes the armed + /// flag. Throws `CancellationError` if the calling task is cancelled + /// before the signal fires. + func wait() async throws { + if armed { + armed = false + return } - - /// Mark the signal as fired. Wakes every waiter; arms the flag for - /// the next `wait()` call if no waiters are currently suspended. - func fire() { - var anyResumed = false - for waiter in waiters { - waiter.continuation.resume() - anyResumed = true - } - waiters.removeAll() - if !anyResumed { - armed = true + let waiterID = nextWaiterID + nextWaiterID &+= 1 + try await withTaskCancellationHandler { + let _: Void = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return } + waiters.append(Waiter(id: waiterID, continuation: continuation)) + } + } onCancel: { + Task { [weak self] in + await self?.handleCancellation(id: waiterID) + } } + } - /// Drop any armed-pending state. Call sites: only after a successful - /// send, not before each attempt. The consume-on-wait semantic in - /// `wait()` already handles "fire landed during the previous attempt" - /// cleanly. - func clear() { - armed = false + /// Mark the signal as fired. Wakes every waiter; arms the flag for + /// the next `wait()` call if no waiters are currently suspended. + func fire() { + var anyResumed = false + for waiter in waiters { + waiter.continuation.resume() + anyResumed = true } - - private func handleCancellation(id: UInt64) { - guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } - let waiter = waiters.remove(at: index) - waiter.continuation.resume(throwing: CancellationError()) + waiters.removeAll() + if !anyResumed { + armed = true } + } + + /// Drop any armed-pending state. Call sites: only after a successful + /// send, not before each attempt. The consume-on-wait semantic in + /// `wait()` already handles "fire landed during the previous attempt" + /// cleanly. + func clear() { + armed = false + } + + private func handleCancellation(id: UInt64) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = waiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } } diff --git a/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift b/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift index 1f3b4d5e..4c5e0a3e 100644 --- a/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift +++ b/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift @@ -3,241 +3,242 @@ import Foundation /// Snapshot of user preferences stored in UserDefaults, used for backup/restore. /// Each property is optional — `nil` means the key was not set at export time. public struct BackupUserDefaults: Codable, Sendable, Equatable { - - // MARK: - App preferences - - public var hasCompletedOnboarding: Bool? - public var liveActivityEnabled: Bool? - public var mapStyleSelection: String? - public var selectedThemeID: String? - public var appColorSchemePreference: String? - public var mapShowLabels: Bool? - public var replyWithQuote: Bool? - public var showInlineImages: Bool? - public var autoPlayGIFs: Bool? - public var showIncomingPath: Bool? - public var showIncomingHopCount: Bool? - public var showIncomingRegion: Bool? - public var showIncomingSendTime: Bool? - public var autoDeleteStaleNodesDays: Int? - public var discoverySortOrder: String? - public var nodesSortOrder: String? - public var tracePathViewMode: String? - public var linkPreviewsEnabled: Bool? - public var linkPreviewsAutoResolveDM: Bool? - public var linkPreviewsAutoResolveChannels: Bool? - public var showMapPreviewThumbnails: Bool? - public var frequentEmojis: [String]? - public var recentEmojis: [String]? - public var hasSeenRepeaterDragHint: Bool? - public var regionSelection: RegionSelection? - - // MARK: - Notification preferences - - public var notifyContactMessages: Bool? - public var notifyChannelMessages: Bool? - public var notifyRoomMessages: Bool? - public var notifyNewContacts: Bool? - public var notifyNewContactsContact: Bool? - public var notifyNewContactsRepeater: Bool? - public var notifyNewContactsRoom: Bool? - public var notifyReactions: Bool? - public var notificationSoundEnabled: Bool? - public var notificationBadgeEnabled: Bool? - public var notifyLowBattery: Bool? - - public init() {} - - // MARK: - UserDefaults keys for special-cased (non-Bool/String) properties - - private static let autoDeleteStaleNodesDaysKey = AppStorageKey.autoDeleteStaleNodesDays.rawValue - private static let frequentEmojisKey = AppStorageKey.frequentEmojis.rawValue - private static let recentReactionEmojisKey = AppStorageKey.recentReactionEmojis.rawValue - /// Public so `AppState` (and tests) can persist via the same key without a duplicated literal. - public static let regionSelectionKey = "userPrefs.region" - - /// Property names handled by hand-rolled branches in `snapshot`/`restore` - /// rather than the bool/string mappings. Internal solely for testability — - /// do not consume from non-test code. - internal static let specialCasedPropertyNames: Set = [ - "autoDeleteStaleNodesDays", - "frequentEmojis", - "recentEmojis", - "regionSelection" - ] - - // MARK: - Region selection persistence - - /// Single source of truth for the encoder/decoder used by `regionSelection`. - /// Both `BackupUserDefaults.snapshot`/`restore` and `AppState`'s live persistence - /// path go through these helpers, so the on-disk format cannot drift. - public static func loadRegionSelection(from defaults: UserDefaults = .standard) -> RegionSelection? { - guard let data = defaults.data(forKey: regionSelectionKey) else { return nil } - return try? JSONDecoder().decode(RegionSelection.self, from: data) + // MARK: - App preferences + + public var hasCompletedOnboarding: Bool? + public var liveActivityEnabled: Bool? + public var mapStyleSelection: String? + public var selectedThemeID: String? + public var appColorSchemePreference: String? + public var mapShowLabels: Bool? + public var mapNorthLocked: Bool? + public var replyWithQuote: Bool? + public var showInlineImages: Bool? + public var autoPlayGIFs: Bool? + public var showIncomingPath: Bool? + public var showIncomingHopCount: Bool? + public var showIncomingRegion: Bool? + public var showIncomingSendTime: Bool? + public var autoDeleteStaleNodesDays: Int? + public var discoverySortOrder: String? + public var nodesSortOrder: String? + public var tracePathViewMode: String? + public var linkPreviewsEnabled: Bool? + public var linkPreviewsAutoResolveDM: Bool? + public var linkPreviewsAutoResolveChannels: Bool? + public var showMapPreviewThumbnails: Bool? + public var frequentEmojis: [String]? + public var recentEmojis: [String]? + public var hasSeenRepeaterDragHint: Bool? + public var regionSelection: RegionSelection? + + // MARK: - Notification preferences + + public var notifyContactMessages: Bool? + public var notifyChannelMessages: Bool? + public var notifyRoomMessages: Bool? + public var notifyNewContacts: Bool? + public var notifyNewContactsContact: Bool? + public var notifyNewContactsRepeater: Bool? + public var notifyNewContactsRoom: Bool? + public var notifyReactions: Bool? + public var notificationSoundEnabled: Bool? + public var notificationBadgeEnabled: Bool? + public var notifyLowBattery: Bool? + + public init() {} + + // MARK: - UserDefaults keys for special-cased (non-Bool/String) properties + + private static let autoDeleteStaleNodesDaysKey = AppStorageKey.autoDeleteStaleNodesDays.rawValue + private static let frequentEmojisKey = AppStorageKey.frequentEmojis.rawValue + private static let recentReactionEmojisKey = AppStorageKey.recentReactionEmojis.rawValue + /// Public so `AppState` (and tests) can persist via the same key without a duplicated literal. + public static let regionSelectionKey = "userPrefs.region" + + /// Property names handled by hand-rolled branches in `snapshot`/`restore` + /// rather than the bool/string mappings. Internal solely for testability — + /// do not consume from non-test code. + static let specialCasedPropertyNames: Set = [ + "autoDeleteStaleNodesDays", + "frequentEmojis", + "recentEmojis", + "regionSelection" + ] + + // MARK: - Region selection persistence + + /// Single source of truth for the encoder/decoder used by `regionSelection`. + /// Both `BackupUserDefaults.snapshot`/`restore` and `AppState`'s live persistence + /// path go through these helpers, so the on-disk format cannot drift. + public static func loadRegionSelection(from defaults: UserDefaults = .standard) -> RegionSelection? { + guard let data = defaults.data(forKey: regionSelectionKey) else { return nil } + return try? JSONDecoder().decode(RegionSelection.self, from: data) + } + + public static func persistRegionSelection( + _ region: RegionSelection?, + to defaults: UserDefaults = .standard + ) { + if let region, let data = try? JSONEncoder().encode(region) { + defaults.set(data, forKey: regionSelectionKey) + } else { + defaults.removeObject(forKey: regionSelectionKey) + } + } + + // MARK: - UserDefaults key mapping + + /// Mapping from struct keyPaths to their UserDefaults key strings. + /// `frequentEmojis` is stored as encoded `Data` in the app (via @AppStorage), + /// but we export/import the decoded `[String]` array directly. + /// + /// Marked `nonisolated(unsafe)` because `WritableKeyPath` is not `Sendable`. + /// Safe here: the array is a `let` initialised once at module load and only + /// read afterwards (never mutated); no cross-actor write race can occur. + private nonisolated(unsafe) static let boolMappings: [(WritableKeyPath, String)] = [ + (\.hasCompletedOnboarding, AppStorageKey.hasCompletedOnboarding.rawValue), + (\.liveActivityEnabled, AppStorageKey.liveActivityEnabled.rawValue), + (\.mapShowLabels, AppStorageKey.mapShowLabels.rawValue), + (\.mapNorthLocked, AppStorageKey.mapNorthLocked.rawValue), + (\.replyWithQuote, AppStorageKey.replyWithQuote.rawValue), + (\.showInlineImages, AppStorageKey.showInlineImages.rawValue), + (\.autoPlayGIFs, AppStorageKey.autoPlayGIFs.rawValue), + (\.showIncomingPath, AppStorageKey.showIncomingPath.rawValue), + (\.showIncomingHopCount, AppStorageKey.showIncomingHopCount.rawValue), + (\.showIncomingRegion, AppStorageKey.showIncomingRegion.rawValue), + (\.showIncomingSendTime, AppStorageKey.showIncomingSendTime.rawValue), + (\.linkPreviewsEnabled, AppStorageKey.linkPreviewsEnabled.rawValue), + (\.linkPreviewsAutoResolveDM, AppStorageKey.linkPreviewsAutoResolveDM.rawValue), + (\.linkPreviewsAutoResolveChannels, AppStorageKey.linkPreviewsAutoResolveChannels.rawValue), + (\.showMapPreviewThumbnails, AppStorageKey.showMapPreviewThumbnails.rawValue), + (\.hasSeenRepeaterDragHint, AppStorageKey.hasSeenRepeaterDragHint.rawValue), + (\.notifyContactMessages, AppStorageKey.notifyContactMessages.rawValue), + (\.notifyChannelMessages, AppStorageKey.notifyChannelMessages.rawValue), + (\.notifyRoomMessages, AppStorageKey.notifyRoomMessages.rawValue), + (\.notifyNewContacts, AppStorageKey.notifyNewContacts.rawValue), + (\.notifyNewContactsContact, AppStorageKey.notifyNewContactsContact.rawValue), + (\.notifyNewContactsRepeater, AppStorageKey.notifyNewContactsRepeater.rawValue), + (\.notifyNewContactsRoom, AppStorageKey.notifyNewContactsRoom.rawValue), + (\.notifyReactions, AppStorageKey.notifyReactions.rawValue), + (\.notificationSoundEnabled, AppStorageKey.notificationSoundEnabled.rawValue), + (\.notificationBadgeEnabled, AppStorageKey.notificationBadgeEnabled.rawValue), + (\.notifyLowBattery, AppStorageKey.notifyLowBattery.rawValue), + ] + + /// Key strings used by `boolMappings`. Internal solely for testability — + /// do not consume from non-test code. + static var boolMappingKeys: Set { + Set(boolMappings.map(\.1)) + } + + /// See `boolMappings` for the `nonisolated(unsafe)` rationale. + private nonisolated(unsafe) static let stringMappings: [(WritableKeyPath, String)] = [ + (\.mapStyleSelection, AppStorageKey.mapStyleSelection.rawValue), + (\.discoverySortOrder, AppStorageKey.discoverySortOrder.rawValue), + (\.nodesSortOrder, AppStorageKey.nodesSortOrder.rawValue), + (\.tracePathViewMode, AppStorageKey.tracePathViewMode.rawValue), + (\.selectedThemeID, PersistenceKeys.selectedThemeID), + (\.appColorSchemePreference, PersistenceKeys.appColorSchemePreference), + ] + + /// Key strings used by `stringMappings`. Internal solely for testability — + /// do not consume from non-test code. + static var stringMappingKeys: Set { + Set(stringMappings.map(\.1)) + } + + // MARK: - Read from UserDefaults + + /// Creates a snapshot by reading all known keys from UserDefaults. + /// - Parameter defaults: The UserDefaults instance to read from. + public static func snapshot(from defaults: UserDefaults = .standard) -> BackupUserDefaults { + var result = BackupUserDefaults() + + for (keyPath, key) in boolMappings { + if defaults.object(forKey: key) != nil { + result[keyPath: keyPath] = defaults.bool(forKey: key) + } } - public static func persistRegionSelection( - _ region: RegionSelection?, - to defaults: UserDefaults = .standard - ) { - if let region, let data = try? JSONEncoder().encode(region) { - defaults.set(data, forKey: regionSelectionKey) - } else { - defaults.removeObject(forKey: regionSelectionKey) - } + for (keyPath, key) in stringMappings { + if defaults.object(forKey: key) != nil { + result[keyPath: keyPath] = defaults.string(forKey: key) + } } - // MARK: - UserDefaults key mapping - - /// Mapping from struct keyPaths to their UserDefaults key strings. - /// `frequentEmojis` is stored as encoded `Data` in the app (via @AppStorage), - /// but we export/import the decoded `[String]` array directly. - /// - /// Marked `nonisolated(unsafe)` because `WritableKeyPath` is not `Sendable`. - /// Safe here: the array is a `let` initialised once at module load and only - /// read afterwards (never mutated); no cross-actor write race can occur. - nonisolated(unsafe) private static let boolMappings: [(WritableKeyPath, String)] = [ - (\.hasCompletedOnboarding, AppStorageKey.hasCompletedOnboarding.rawValue), - (\.liveActivityEnabled, AppStorageKey.liveActivityEnabled.rawValue), - (\.mapShowLabels, AppStorageKey.mapShowLabels.rawValue), - (\.replyWithQuote, AppStorageKey.replyWithQuote.rawValue), - (\.showInlineImages, AppStorageKey.showInlineImages.rawValue), - (\.autoPlayGIFs, AppStorageKey.autoPlayGIFs.rawValue), - (\.showIncomingPath, AppStorageKey.showIncomingPath.rawValue), - (\.showIncomingHopCount, AppStorageKey.showIncomingHopCount.rawValue), - (\.showIncomingRegion, AppStorageKey.showIncomingRegion.rawValue), - (\.showIncomingSendTime, AppStorageKey.showIncomingSendTime.rawValue), - (\.linkPreviewsEnabled, AppStorageKey.linkPreviewsEnabled.rawValue), - (\.linkPreviewsAutoResolveDM, AppStorageKey.linkPreviewsAutoResolveDM.rawValue), - (\.linkPreviewsAutoResolveChannels, AppStorageKey.linkPreviewsAutoResolveChannels.rawValue), - (\.showMapPreviewThumbnails, AppStorageKey.showMapPreviewThumbnails.rawValue), - (\.hasSeenRepeaterDragHint, AppStorageKey.hasSeenRepeaterDragHint.rawValue), - (\.notifyContactMessages, AppStorageKey.notifyContactMessages.rawValue), - (\.notifyChannelMessages, AppStorageKey.notifyChannelMessages.rawValue), - (\.notifyRoomMessages, AppStorageKey.notifyRoomMessages.rawValue), - (\.notifyNewContacts, AppStorageKey.notifyNewContacts.rawValue), - (\.notifyNewContactsContact, AppStorageKey.notifyNewContactsContact.rawValue), - (\.notifyNewContactsRepeater, AppStorageKey.notifyNewContactsRepeater.rawValue), - (\.notifyNewContactsRoom, AppStorageKey.notifyNewContactsRoom.rawValue), - (\.notifyReactions, AppStorageKey.notifyReactions.rawValue), - (\.notificationSoundEnabled, AppStorageKey.notificationSoundEnabled.rawValue), - (\.notificationBadgeEnabled, AppStorageKey.notificationBadgeEnabled.rawValue), - (\.notifyLowBattery, AppStorageKey.notifyLowBattery.rawValue), - ] - - /// Key strings used by `boolMappings`. Internal solely for testability — - /// do not consume from non-test code. - internal static var boolMappingKeys: Set { - Set(boolMappings.map { $0.1 }) + if defaults.object(forKey: Self.autoDeleteStaleNodesDaysKey) != nil { + result.autoDeleteStaleNodesDays = defaults.integer(forKey: Self.autoDeleteStaleNodesDaysKey) } - /// See `boolMappings` for the `nonisolated(unsafe)` rationale. - nonisolated(unsafe) private static let stringMappings: [(WritableKeyPath, String)] = [ - (\.mapStyleSelection, AppStorageKey.mapStyleSelection.rawValue), - (\.discoverySortOrder, AppStorageKey.discoverySortOrder.rawValue), - (\.nodesSortOrder, AppStorageKey.nodesSortOrder.rawValue), - (\.tracePathViewMode, AppStorageKey.tracePathViewMode.rawValue), - (\.selectedThemeID, PersistenceKeys.selectedThemeID), - (\.appColorSchemePreference, PersistenceKeys.appColorSchemePreference), - ] - - /// Key strings used by `stringMappings`. Internal solely for testability — - /// do not consume from non-test code. - internal static var stringMappingKeys: Set { - Set(stringMappings.map { $0.1 }) + // frequentEmojis is stored as JSON-encoded [String] via @AppStorage Data binding + if let data = defaults.data(forKey: Self.frequentEmojisKey), + let decoded = try? JSONDecoder().decode([String].self, from: data) { + result.frequentEmojis = decoded } - // MARK: - Read from UserDefaults + result.recentEmojis = defaults.stringArray(forKey: Self.recentReactionEmojisKey) - /// Creates a snapshot by reading all known keys from UserDefaults. - /// - Parameter defaults: The UserDefaults instance to read from. - public static func snapshot(from defaults: UserDefaults = .standard) -> BackupUserDefaults { - var result = BackupUserDefaults() + result.regionSelection = Self.loadRegionSelection(from: defaults) - for (keyPath, key) in boolMappings { - if defaults.object(forKey: key) != nil { - result[keyPath: keyPath] = defaults.bool(forKey: key) - } - } + return result + } - for (keyPath, key) in stringMappings { - if defaults.object(forKey: key) != nil { - result[keyPath: keyPath] = defaults.string(forKey: key) - } - } + // MARK: - Write to UserDefaults (write-if-missing) - if defaults.object(forKey: Self.autoDeleteStaleNodesDaysKey) != nil { - result.autoDeleteStaleNodesDays = defaults.integer(forKey: Self.autoDeleteStaleNodesDaysKey) - } + /// Restores preferences to UserDefaults, only writing keys that are not already set. + /// - Parameter defaults: The UserDefaults instance to write to. + /// - Returns: Keys that were newly set, in insertion order. Callers can undo a + /// partial restore by passing this list to `removeKeys(_:from:)`. + @discardableResult + public func restore(to defaults: UserDefaults = .standard) -> [String] { + var setKeys: [String] = [] + + for (keyPath, key) in Self.boolMappings { + if let value = self[keyPath: keyPath], defaults.object(forKey: key) == nil { + defaults.set(value, forKey: key) + setKeys.append(key) + } + } - // frequentEmojis is stored as JSON-encoded [String] via @AppStorage Data binding - if let data = defaults.data(forKey: Self.frequentEmojisKey), - let decoded = try? JSONDecoder().decode([String].self, from: data) { - result.frequentEmojis = decoded - } + for (keyPath, key) in Self.stringMappings { + if let value = self[keyPath: keyPath], defaults.object(forKey: key) == nil { + defaults.set(value, forKey: key) + setKeys.append(key) + } + } - result.recentEmojis = defaults.stringArray(forKey: Self.recentReactionEmojisKey) + if let value = autoDeleteStaleNodesDays, + defaults.object(forKey: Self.autoDeleteStaleNodesDaysKey) == nil { + defaults.set(value, forKey: Self.autoDeleteStaleNodesDaysKey) + setKeys.append(Self.autoDeleteStaleNodesDaysKey) + } - result.regionSelection = Self.loadRegionSelection(from: defaults) + if let emojis = frequentEmojis, defaults.object(forKey: Self.frequentEmojisKey) == nil { + if let data = try? JSONEncoder().encode(emojis) { + defaults.set(data, forKey: Self.frequentEmojisKey) + setKeys.append(Self.frequentEmojisKey) + } + } - return result + if let emojis = recentEmojis, defaults.object(forKey: Self.recentReactionEmojisKey) == nil { + defaults.set(emojis, forKey: Self.recentReactionEmojisKey) + setKeys.append(Self.recentReactionEmojisKey) } - // MARK: - Write to UserDefaults (write-if-missing) - - /// Restores preferences to UserDefaults, only writing keys that are not already set. - /// - Parameter defaults: The UserDefaults instance to write to. - /// - Returns: Keys that were newly set, in insertion order. Callers can undo a - /// partial restore by passing this list to `removeKeys(_:from:)`. - @discardableResult - public func restore(to defaults: UserDefaults = .standard) -> [String] { - var setKeys: [String] = [] - - for (keyPath, key) in Self.boolMappings { - if let value = self[keyPath: keyPath], defaults.object(forKey: key) == nil { - defaults.set(value, forKey: key) - setKeys.append(key) - } - } - - for (keyPath, key) in Self.stringMappings { - if let value = self[keyPath: keyPath], defaults.object(forKey: key) == nil { - defaults.set(value, forKey: key) - setKeys.append(key) - } - } - - if let value = autoDeleteStaleNodesDays, - defaults.object(forKey: Self.autoDeleteStaleNodesDaysKey) == nil { - defaults.set(value, forKey: Self.autoDeleteStaleNodesDaysKey) - setKeys.append(Self.autoDeleteStaleNodesDaysKey) - } - - if let emojis = frequentEmojis, defaults.object(forKey: Self.frequentEmojisKey) == nil { - if let data = try? JSONEncoder().encode(emojis) { - defaults.set(data, forKey: Self.frequentEmojisKey) - setKeys.append(Self.frequentEmojisKey) - } - } - - if let emojis = recentEmojis, defaults.object(forKey: Self.recentReactionEmojisKey) == nil { - defaults.set(emojis, forKey: Self.recentReactionEmojisKey) - setKeys.append(Self.recentReactionEmojisKey) - } - - if let region = regionSelection, - defaults.object(forKey: Self.regionSelectionKey) == nil { - Self.persistRegionSelection(region, to: defaults) - setKeys.append(Self.regionSelectionKey) - } - - return setKeys + if let region = regionSelection, + defaults.object(forKey: Self.regionSelectionKey) == nil { + Self.persistRegionSelection(region, to: defaults) + setKeys.append(Self.regionSelectionKey) } - /// Undoes a `restore(to:)` partial write by removing the specified keys. - public static func removeKeys(_ keys: [String], from defaults: UserDefaults) { - for key in keys { - defaults.removeObject(forKey: key) - } + return setKeys + } + + /// Undoes a `restore(to:)` partial write by removing the specified keys. + public static func removeKeys(_ keys: [String], from defaults: UserDefaults) { + for key in keys { + defaults.removeObject(forKey: key) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift b/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift index b96b516f..4bd562eb 100644 --- a/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift +++ b/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift @@ -5,23 +5,23 @@ import os // MARK: - Binary Protocol Errors public enum BinaryProtocolError: Error, Sendable { - case notConnected - case sendFailed - case timeout - case invalidResponse - case sessionError(MeshCoreError) + case notConnected + case sendFailed + case timeout + case invalidResponse + case sessionError(MeshCoreError) } extension BinaryProtocolError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .sendFailed: "Failed to send request." - case .timeout: "Request timed out." - case .invalidResponse: "Invalid response from device." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .sendFailed: "Failed to send request." + case .timeout: "Request timed out." + case .invalidResponse: "Invalid response from device." + case let .sessionError(e): e.localizedDescription } + } } // MARK: - Binary Protocol Service @@ -29,268 +29,267 @@ extension BinaryProtocolError: LocalizedError { /// Service for binary protocol operations with remote mesh nodes. /// Handles status, telemetry, neighbours, and ACL requests via MeshCore session. public actor BinaryProtocolService { + // MARK: - Properties - // MARK: - Properties + private let session: any DiagnosticsSessionOps & SessionEventStreaming + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "BinaryProtocol") - private let session: any DiagnosticsSessionOps & SessionEventStreaming - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "BinaryProtocol") + /// Handler for status responses (from push notifications) + private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? - /// Handler for status responses (from push notifications) - private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? + /// Handler for telemetry responses (from push notifications) + private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? - /// Handler for telemetry responses (from push notifications) - private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? + /// Handler for neighbours responses (from push notifications) + private var neighboursResponseHandler: (@Sendable (NeighboursResponse) async -> Void)? - /// Handler for neighbours responses (from push notifications) - private var neighboursResponseHandler: (@Sendable (NeighboursResponse) async -> Void)? + /// Event monitoring task + private var eventMonitorTask: Task? - /// Event monitoring task - private var eventMonitorTask: Task? + // MARK: - Initialization - // MARK: - Initialization + public init(session: any DiagnosticsSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { + self.session = session + self.dataStore = dataStore + } - public init(session: any DiagnosticsSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { - self.session = session - self.dataStore = dataStore - } - - deinit { - eventMonitorTask?.cancel() - } + deinit { + eventMonitorTask?.cancel() + } - // MARK: - Event Handlers + // MARK: - Event Handlers - /// Set handler for status responses - public func setStatusResponseHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { - statusResponseHandler = handler - } + /// Set handler for status responses + public func setStatusResponseHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { + statusResponseHandler = handler + } - /// Set handler for telemetry responses - public func setTelemetryResponseHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { - telemetryResponseHandler = handler - } + /// Set handler for telemetry responses + public func setTelemetryResponseHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { + telemetryResponseHandler = handler + } - /// Set handler for neighbours responses - public func setNeighboursResponseHandler(_ handler: @escaping @Sendable (NeighboursResponse) async -> Void) { - neighboursResponseHandler = handler - } + /// Set handler for neighbours responses + public func setNeighboursResponseHandler(_ handler: @escaping @Sendable (NeighboursResponse) async -> Void) { + neighboursResponseHandler = handler + } - // MARK: - Event Monitoring + // MARK: - Event Monitoring - /// Start monitoring MeshCore events for binary protocol responses - public func startEventMonitoring() { - eventMonitorTask?.cancel() + /// Start monitoring MeshCore events for binary protocol responses + public func startEventMonitoring() { + eventMonitorTask?.cancel() - eventMonitorTask = Task { [weak self] in - guard let self else { return } - let events = await session.events() + eventMonitorTask = Task { [weak self] in + guard let self else { return } + let events = await session.events() - for await event in events { - guard !Task.isCancelled else { break } - await self.handleEvent(event) - } - } + for await event in events { + guard !Task.isCancelled else { break } + await handleEvent(event) + } } + } - /// Stop monitoring events - public func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil - } + /// Stop monitoring events + public func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + } - /// Handle incoming MeshCore event - private func handleEvent(_ event: MeshEvent) async { - switch event { - case .statusResponse(let response): - await statusResponseHandler?(response) + /// Handle incoming MeshCore event + private func handleEvent(_ event: MeshEvent) async { + switch event { + case let .statusResponse(response): + await statusResponseHandler?(response) - case .telemetryResponse(let response): - await telemetryResponseHandler?(response) + case let .telemetryResponse(response): + await telemetryResponseHandler?(response) - case .neighboursResponse(let response): - await neighboursResponseHandler?(response) + case let .neighboursResponse(response): + await neighboursResponseHandler?(response) - default: - break - } + default: + break } - - // MARK: - Status Request - - /// Request status from a remote node (blocking, waits for response) - /// - Parameter publicKey: The remote node's full 32-byte public key - /// - Returns: StatusResponse with device stats - public func requestStatus(from publicKey: Data) async throws -> StatusResponse { - do { - return try await session.requestStatus(from: publicKey) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Status Request + + /// Request status from a remote node (blocking, waits for response) + /// - Parameter publicKey: The remote node's full 32-byte public key + /// - Returns: StatusResponse with device stats + public func requestStatus(from publicKey: Data) async throws -> StatusResponse { + do { + return try await session.requestStatus(from: publicKey) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - /// Request status from a remote node (blocking, waits for response) - /// - Parameters: - /// - publicKey: The remote node's full 32-byte public key - /// - type: The target node type used to select the correct firmware status layout - /// - Returns: StatusResponse with device stats - public func requestStatus( - from publicKey: Data, - type: ContactType - ) async throws -> StatusResponse { - do { - return try await session.requestStatus(from: publicKey, type: type) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + /// Request status from a remote node (blocking, waits for response) + /// - Parameters: + /// - publicKey: The remote node's full 32-byte public key + /// - type: The target node type used to select the correct firmware status layout + /// - Returns: StatusResponse with device stats + public func requestStatus( + from publicKey: Data, + type: ContactType + ) async throws -> StatusResponse { + do { + return try await session.requestStatus(from: publicKey, type: type) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - Telemetry Request - - /// Request telemetry from a remote node (blocking, waits for response) - /// - Parameter publicKey: The remote node's public key (full or prefix) - /// - Returns: TelemetryResponse with sensor data - public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { - do { - return try await session.requestTelemetry(from: publicKey) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Telemetry Request + + /// Request telemetry from a remote node (blocking, waits for response) + /// - Parameter publicKey: The remote node's public key (full or prefix) + /// - Returns: TelemetryResponse with sensor data + public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { + do { + return try await session.requestTelemetry(from: publicKey) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - Neighbours Request - - /// Default pubkey prefix length for neighbour queries. - /// Stored to ensure response parsing uses matching length. - public static let defaultPubkeyPrefixLength: UInt8 = 6 - - /// Request neighbours list from a remote node (blocking, waits for response) - /// - Parameters: - /// - publicKey: The remote node's public key - /// - count: Maximum number of neighbours to return (default 255 = all) - /// - offset: Pagination offset - /// - orderBy: Sort order for results (0 = newest first) - /// - pubkeyPrefixLength: Length of public key prefix in response - /// - Returns: NeighboursResponse with neighbour list - public func requestNeighbours( - from publicKey: Data, - count: UInt8 = 255, - offset: UInt16 = 0, - orderBy: UInt8 = 0, - pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength - ) async throws -> NeighboursResponse { - do { - return try await session.requestNeighbours( - from: publicKey, - count: count, - offset: offset, - orderBy: orderBy, - pubkeyPrefixLength: pubkeyPrefixLength - ) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Neighbours Request + + /// Default pubkey prefix length for neighbour queries. + /// Stored to ensure response parsing uses matching length. + public static let defaultPubkeyPrefixLength: UInt8 = 6 + + /// Request neighbours list from a remote node (blocking, waits for response) + /// - Parameters: + /// - publicKey: The remote node's public key + /// - count: Maximum number of neighbours to return (default 255 = all) + /// - offset: Pagination offset + /// - orderBy: Sort order for results (0 = newest first) + /// - pubkeyPrefixLength: Length of public key prefix in response + /// - Returns: NeighboursResponse with neighbour list + public func requestNeighbours( + from publicKey: Data, + count: UInt8 = 255, + offset: UInt16 = 0, + orderBy: UInt8 = 0, + pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength + ) async throws -> NeighboursResponse { + do { + return try await session.requestNeighbours( + from: publicKey, + count: count, + offset: offset, + orderBy: orderBy, + pubkeyPrefixLength: pubkeyPrefixLength + ) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - /// Fetch all neighbours from a remote node with automatic pagination - /// - Parameters: - /// - publicKey: The remote node's public key - /// - orderBy: Sort order for results - /// - pubkeyPrefixLength: Length of public key prefix in response - /// - Returns: NeighboursResponse with complete neighbour list - public func fetchAllNeighbours( - from publicKey: Data, - orderBy: UInt8 = 0, - pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength - ) async throws -> NeighboursResponse { - do { - return try await session.fetchAllNeighbours( - from: publicKey, - orderBy: orderBy, - pubkeyPrefixLength: pubkeyPrefixLength - ) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + /// Fetch all neighbours from a remote node with automatic pagination + /// - Parameters: + /// - publicKey: The remote node's public key + /// - orderBy: Sort order for results + /// - pubkeyPrefixLength: Length of public key prefix in response + /// - Returns: NeighboursResponse with complete neighbour list + public func fetchAllNeighbours( + from publicKey: Data, + orderBy: UInt8 = 0, + pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength + ) async throws -> NeighboursResponse { + do { + return try await session.fetchAllNeighbours( + from: publicKey, + orderBy: orderBy, + pubkeyPrefixLength: pubkeyPrefixLength + ) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - MMA Request - - /// Request min/max/average telemetry data from a remote node - /// - Parameters: - /// - publicKey: The remote node's public key - /// - start: Start of time range - /// - end: End of time range - /// - Returns: MMAResponse with aggregated telemetry - public func requestMMA( - from publicKey: Data, - start: Date, - end: Date - ) async throws -> MMAResponse { - do { - return try await session.requestMMA(from: publicKey, start: start, end: end) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - MMA Request + + /// Request min/max/average telemetry data from a remote node + /// - Parameters: + /// - publicKey: The remote node's public key + /// - start: Start of time range + /// - end: End of time range + /// - Returns: MMAResponse with aggregated telemetry + public func requestMMA( + from publicKey: Data, + start: Date, + end: Date + ) async throws -> MMAResponse { + do { + return try await session.requestMMA(from: publicKey, start: start, end: end) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - ACL Request - - /// Request access control list from a remote node - /// - Parameter publicKey: The remote node's public key - /// - Returns: ACLResponse with permission entries - public func requestACL(from publicKey: Data) async throws -> ACLResponse { - do { - return try await session.requestACL(from: publicKey) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - ACL Request + + /// Request access control list from a remote node + /// - Parameter publicKey: The remote node's public key + /// - Returns: ACLResponse with permission entries + public func requestACL(from publicKey: Data) async throws -> ACLResponse { + do { + return try await session.requestACL(from: publicKey) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } + } - // MARK: - Self Telemetry + // MARK: - Self Telemetry - /// Get telemetry from the local device - /// - Returns: TelemetryResponse with local sensor data - public func getSelfTelemetry() async throws -> TelemetryResponse { - do { - return try await session.getSelfTelemetry() - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + /// Get telemetry from the local device + /// - Returns: TelemetryResponse with local sensor data + public func getSelfTelemetry() async throws -> TelemetryResponse { + do { + return try await session.getSelfTelemetry() + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - Path Discovery - - /// Send path discovery request to a contact - /// - Parameter publicKey: The contact's public key - /// - Returns: MessageSentInfo with expected ACK code - public func sendPathDiscovery(to publicKey: Data) async throws -> MessageSentInfo { - do { - return try await session.sendPathDiscovery(to: publicKey) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Path Discovery + + /// Send path discovery request to a contact + /// - Parameter publicKey: The contact's public key + /// - Returns: MessageSentInfo with expected ACK code + public func sendPathDiscovery(to publicKey: Data) async throws -> MessageSentInfo { + do { + return try await session.sendPathDiscovery(to: publicKey) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - Trace Route - - /// Send a trace route request - /// - Parameters: - /// - tag: Optional trace tag (random if nil) - /// - authCode: Optional auth code (random if nil) - /// - flags: Trace flags - /// - path: Optional fixed path to trace - /// - Returns: MessageSentInfo with expected ACK code - public func sendTrace( - tag: UInt32? = nil, - authCode: UInt32? = nil, - flags: UInt8 = 0, - path: Data? = nil - ) async throws -> MessageSentInfo { - do { - return try await session.sendTrace(tag: tag, authCode: authCode, flags: flags, path: path) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Trace Route + + /// Send a trace route request + /// - Parameters: + /// - tag: Optional trace tag (random if nil) + /// - authCode: Optional auth code (random if nil) + /// - flags: Trace flags + /// - path: Optional fixed path to trace + /// - Returns: MessageSentInfo with expected ACK code + public func sendTrace( + tag: UInt32? = nil, + authCode: UInt32? = nil, + flags: UInt8 = 0, + path: Data? = nil + ) async throws -> MessageSentInfo { + do { + return try await session.sendTrace(tag: tag, authCode: authCode, flags: flags, path: path) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/BluetoothScanPairingService.swift b/MC1Services/Sources/MC1Services/Services/BluetoothScanPairingService.swift index bab225eb..01aaaf2d 100644 --- a/MC1Services/Sources/MC1Services/Services/BluetoothScanPairingService.swift +++ b/MC1Services/Sources/MC1Services/Services/BluetoothScanPairingService.swift @@ -16,91 +16,108 @@ import os @Observable @MainActor public final class BluetoothScanPairingService: DevicePairingService { - public weak var delegate: (any DevicePairingDelegate)? - - /// `true` while the in-app scan picker should be presented. Observed by the view layer. - public private(set) var isPresenting = false - - private var discoveryContinuation: CheckedContinuation? - - /// Set synchronously by the `onCancel` handler the instant the discovery task is cancelled, - /// before its `Task { @MainActor ... }` hop to `cancel()` is scheduled, and read on the - /// resolution path so a `select(_:)` that lands on the MainActor inside that hop window - /// resolves the continuation with `.cancelled` rather than a stale selection. Lives behind a - /// lock because `onCancel` may run off the MainActor. - private let cancellationRequested = OSAllocatedUnfairLock(initialState: false) - - public init() {} - - public var isSessionActive: Bool { false } - public var registeredDeviceCount: Int { 0 } - public var hasSystemPairingRegistry: Bool { false } - public var supportsSystemRename: Bool { false } - - public func activate() async throws {} - - /// Single-flight invariant: the only production caller is `ConnectionManager.pairNewDevice()`, - /// which is serialized by its `isPairingInProgress` gate, so two discoveries never overlap at - /// runtime — the prior continuation has always resolved before the next call begins. The - /// stranded-discovery resolution below, the `onCancel` handler, and the `cancellationRequested` - /// flag are defensive: they keep a single continuation consistent against cancellation, not - /// against concurrent callers. If a second concurrent caller of this method is ever added, add a - /// per-discovery token (mirroring `ConnectionManager.bleScanRequestID`) so a stale cancel cannot - /// resolve a newer continuation. - public func discoverDevice() async throws -> UUID { - // Clear any prior cancellation and resolve any stranded prior discovery before starting one. - cancellationRequested.withLock { $0 = false } - resolveDiscovery(with: .failure(DevicePairingError.cancelled)) - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - // If the task was already cancelled before the continuation is installed, - // `onCancel` has already run and found nothing to resolve. Resume here rather - // than installing a continuation that nothing will ever resume (which would - // leak it and strand `isPresenting`). - guard !Task.isCancelled else { - continuation.resume(throwing: DevicePairingError.cancelled) - return - } - self.discoveryContinuation = continuation - self.isPresenting = true - } - } onCancel: { - // Set synchronously so a `select(_:)` racing this cancellation on the MainActor can't - // resolve with a stale selection before the hop below runs `cancel()`. - self.cancellationRequested.withLock { $0 = true } - Task { @MainActor in self.cancel() } + public weak var delegate: (any DevicePairingDelegate)? + + /// `true` while the in-app scan picker should be presented. Observed by the view layer. + public private(set) var isPresenting = false + + private var discoveryContinuation: CheckedContinuation? + + /// Set synchronously by the `onCancel` handler the instant the discovery task is cancelled, + /// before its `Task { @MainActor ... }` hop to `cancel()` is scheduled, and read on the + /// resolution path so a `select(_:)` that lands on the MainActor inside that hop window + /// resolves the continuation with `.cancelled` rather than a stale selection. Lives behind a + /// lock because `onCancel` may run off the MainActor. + private let cancellationRequested = OSAllocatedUnfairLock(initialState: false) + + public init() {} + + public var isSessionActive: Bool { + false + } + + public var registeredDeviceCount: Int { + 0 + } + + public var hasSystemPairingRegistry: Bool { + false + } + + public var supportsSystemRename: Bool { + false + } + + public func activate() async throws {} + + /// Single-flight invariant: the only production caller is `ConnectionManager.pairNewDevice()`, + /// which is serialized by its `isPairingInProgress` gate, so two discoveries never overlap at + /// runtime — the prior continuation has always resolved before the next call begins. The + /// stranded-discovery resolution below, the `onCancel` handler, and the `cancellationRequested` + /// flag are defensive: they keep a single continuation consistent against cancellation, not + /// against concurrent callers. If a second concurrent caller of this method is ever added, add a + /// per-discovery token (mirroring `ConnectionManager.bleScanRequestID`) so a stale cancel cannot + /// resolve a newer continuation. + public func discoverDevice() async throws -> UUID { + // Clear any prior cancellation and resolve any stranded prior discovery before starting one. + cancellationRequested.withLock { $0 = false } + resolveDiscovery(with: .failure(DevicePairingError.cancelled)) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + // If the task was already cancelled before the continuation is installed, + // `onCancel` has already run and found nothing to resolve. Resume here rather + // than installing a continuation that nothing will ever resume (which would + // leak it and strand `isPresenting`). + guard !Task.isCancelled else { + continuation.resume(throwing: DevicePairingError.cancelled) + return } + self.discoveryContinuation = continuation + self.isPresenting = true + } + } onCancel: { + // Set synchronously so a `select(_:)` racing this cancellation on the MainActor can't + // resolve with a stale selection before the hop below runs `cancel()`. + self.cancellationRequested.withLock { $0 = true } + Task { @MainActor in self.cancel() } } + } - public func isDeviceConnectable(_ id: UUID) -> Bool { true } - public func registeredDeviceInfos() -> [(id: UUID, name: String)] { [] } - public func removeDevice(_ id: UUID) async throws {} - public func renameDevice(_ id: UUID) async throws {} - public func clearStaleRegistrations() async {} + public func isDeviceConnectable(_ id: UUID) -> Bool { + true + } - /// Called by the scan picker when the user selects a device. - public func select(_ id: UUID) { - resolveDiscovery(with: .success(id)) - } + public func registeredDeviceInfos() -> [(id: UUID, name: String)] { + [] + } - /// Called when the user cancels or dismisses the scan picker. - /// Surfaces as `DevicePairingError.cancelled` so call sites reuse one cancellation path - /// across both platforms. - public func cancel() { - resolveDiscovery(with: .failure(DevicePairingError.cancelled)) - } + public func removeDevice(_ id: UUID) async throws {} + public func renameDevice(_ id: UUID) async throws {} + public func clearStaleRegistrations() async {} - private func resolveDiscovery(with result: Result) { - isPresenting = false - guard let continuation = discoveryContinuation else { return } - discoveryContinuation = nil - // A selection that lands after cancellation has been signalled must not win the race: - // surface the cancellation instead of the stale selection. - if case .success = result, cancellationRequested.withLock({ $0 }) { - continuation.resume(throwing: DevicePairingError.cancelled) - return - } - continuation.resume(with: result) + /// Called by the scan picker when the user selects a device. + public func select(_ id: UUID) { + resolveDiscovery(with: .success(id)) + } + + /// Called when the user cancels or dismisses the scan picker. + /// Surfaces as `DevicePairingError.cancelled` so call sites reuse one cancellation path + /// across both platforms. + public func cancel() { + resolveDiscovery(with: .failure(DevicePairingError.cancelled)) + } + + private func resolveDiscovery(with result: Result) { + isPresenting = false + guard let continuation = discoveryContinuation else { return } + discoveryContinuation = nil + // A selection that lands after cancellation has been signalled must not win the race: + // surface the cancellation instead of the stale selection. + if case .success = result, cancellationRequested.withLock({ $0 }) { + continuation.resume(throwing: DevicePairingError.cancelled) + return } + continuation.resume(with: result) + } } diff --git a/MC1Services/Sources/MC1Services/Services/CLIResponse.swift b/MC1Services/Sources/MC1Services/Services/CLIResponse.swift index 644caa1b..e69206e1 100644 --- a/MC1Services/Sources/MC1Services/Services/CLIResponse.swift +++ b/MC1Services/Sources/MC1Services/Services/CLIResponse.swift @@ -2,128 +2,142 @@ import Foundation /// Parsed CLI response from repeater public enum CLIResponse: Sendable, Equatable { - case ok - case error(String) - case unknownCommand(String) // Specific case for "Error: unknown command" - case version(String) - case deviceTime(String) - case name(String) - case radio(frequency: Double, bandwidth: Double, spreadingFactor: Int, codingRate: Int) - case txPower(Int) - case repeatMode(Bool) - case advertInterval(Int) - case floodAdvertInterval(Int) // Value is in hours, not minutes - case floodMax(Int) - case latitude(Double) - case longitude(Double) - case ownerInfo(String) - case raw(String) - - /// Parse a CLI response text into a structured type - /// Note: Response correlation must be handled by the caller based on pending query tracking - public static func parse(_ text: String, forQuery query: String? = nil) -> CLIResponse { - var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - - // Strip MeshCore CLI prompt prefix if present - // Firmware prepends "> " to all CLI command responses - if trimmed.hasPrefix("> ") { - trimmed = String(trimmed.dropFirst(2)) - } else if trimmed == ">" { - trimmed = "" - } - - // Success responses: "OK" or "OK - clock set: ..." etc. - if trimmed == "OK" || trimmed.hasPrefix("OK - ") { - return .ok - } - - if trimmed.lowercased().hasPrefix("error") || trimmed.hasPrefix("ERR:") { - // Check for "unknown command" specifically for defensive handling - if trimmed.lowercased().contains("unknown command") { - return .unknownCommand(trimmed) - } - return .error(trimmed) - } - - // Firmware version: "MeshCore v1.10.0 (2025-04-18)" or "v1.11.0 (2025-04-18)" - // Some firmware builds omit "MeshCore " prefix - if trimmed.hasPrefix("MeshCore v") || (trimmed.hasPrefix("v") && trimmed.contains("(")) { - return .version(trimmed) - } - - // Use query hint to match version responses that don't have standard prefix - if query == "ver" { - return .version(trimmed) - } - - // Freeform text fields: query hint takes priority over broad content heuristics - // below (e.g. deviceTime matches any string with ":" and "/", which is common - // in repeater names and contact info like "Contact: KD7ABC / 145.230") - if query == "get name" { - return .name(trimmed) - } - - if query == "get owner.info" { - return .ownerInfo(trimmed) - } - - // Clock response: "06:40 - 18/4/2025 UTC" or contains time-like patterns - if trimmed.contains("UTC") || (trimmed.contains(":") && trimmed.contains("/")) { - return .deviceTime(trimmed) - } - - // Radio params: "915.000,250.0,10,5" (freq,bw,sf,cr) - // Use query hint to disambiguate from other comma-separated values - if query == "get radio" { - let parts = trimmed.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - if parts.count >= 4, - let freq = Double(parts[0]), - let bw = Double(parts[1]), - let sf = Int(parts[2]), - let cr = Int(parts[3]) { - return .radio(frequency: freq, bandwidth: bw, spreadingFactor: sf, codingRate: cr) - } - } - - // TX power: integer dBm value - if query == "get tx", let power = Int(trimmed) { - return .txPower(power) - } - - // Repeat mode: "on" or "off" - if query == "get repeat" { - if trimmed.lowercased() == "on" { - return .repeatMode(true) - } else if trimmed.lowercased() == "off" { - return .repeatMode(false) - } - } - - // Advert interval: integer minutes - if query == "get advert.interval", let interval = Int(trimmed) { - return .advertInterval(interval) - } - - // Flood advert interval: integer hours - if query == "get flood.advert.interval", let interval = Int(trimmed) { - return .floodAdvertInterval(interval) - } - - // Flood max: integer hops - if query == "get flood.max", let maxHops = Int(trimmed) { - return .floodMax(maxHops) - } - - // Latitude: decimal degrees - if query == "get lat", let lat = Double(trimmed) { - return .latitude(lat) - } - - // Longitude: decimal degrees - if query == "get lon", let lon = Double(trimmed) { - return .longitude(lon) - } - - return .raw(trimmed) + case ok + case error(String) + case unknownCommand(String) // Specific case for "Error: unknown command" + case version(String) + case deviceTime(String) + case name(String) + case radio(frequency: Double, bandwidth: Double, spreadingFactor: Int, codingRate: Int) + case txPower(Int) + case repeatMode(Bool) + case advertInterval(Int) + case floodAdvertInterval(Int) // Value is in hours, not minutes + case floodMax(Int) + case latitude(Double) + case longitude(Double) + case ownerInfo(String) + case raw(String) + + /// Parse a CLI response text into a structured type + /// Note: Response correlation must be handled by the caller based on pending query tracking + public static func parse(_ text: String, forQuery query: String? = nil) -> CLIResponse { + var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + + // Strip MeshCore CLI prompt prefix if present + // Firmware prepends "> " to all CLI command responses + if trimmed.hasPrefix("> ") { + trimmed = String(trimmed.dropFirst(2)) + } else if trimmed == ">" { + trimmed = "" } + + // Success responses: "OK" or "OK - clock set: ..." etc. + if trimmed == "OK" || trimmed.hasPrefix("OK - ") { + return .ok + } + + if trimmed.lowercased().hasPrefix("error") || trimmed.hasPrefix("ERR:") { + // Check for "unknown command" specifically for defensive handling + if trimmed.lowercased().contains("unknown command") { + return .unknownCommand(trimmed) + } + return .error(trimmed) + } + + // Firmware version: "MeshCore v1.10.0 (2025-04-18)" or "v1.11.0 (2025-04-18)" + // Some firmware builds omit "MeshCore " prefix + if trimmed.hasPrefix("MeshCore v") || (trimmed.hasPrefix("v") && trimmed.contains("(")) { + return .version(trimmed) + } + + // Use query hint to match version responses that don't have standard prefix + if query == "ver" { + return .version(trimmed) + } + + // Freeform text fields: query hint takes priority over broad content heuristics + // below (e.g. deviceTime matches any string with ":" and "/", which is common + // in repeater names and contact info like "Contact: KD7ABC / 145.230") + if query == "get name" { + return .name(trimmed) + } + + if query == "get owner.info" { + return .ownerInfo(trimmed) + } + + // Clock response: "06:40 - 18/4/2025 UTC" or contains time-like patterns + if trimmed.contains("UTC") || (trimmed.contains(":") && trimmed.contains("/")) { + return .deviceTime(trimmed) + } + + // Radio params: "915.000,250.0,10,5" (freq,bw,sf,cr) + // Use query hint to disambiguate from other comma-separated values + if query == "get radio" { + let parts = trimmed.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + if parts.count >= 4, + let freq = Double(parts[0]), + let bw = Double(parts[1]), + let sf = Int(parts[2]), + let cr = Int(parts[3]) { + return .radio(frequency: freq, bandwidth: bw, spreadingFactor: sf, codingRate: cr) + } + } + + // TX power in dBm, optionally annotated with ZephCore power-control state + if query == "get tx", let power = parseTXPowerDBm(trimmed) { + return .txPower(power) + } + + // Repeat mode: "on" or "off" + if query == "get repeat" { + if trimmed.lowercased() == "on" { + return .repeatMode(true) + } else if trimmed.lowercased() == "off" { + return .repeatMode(false) + } + } + + // Advert interval: integer minutes + if query == "get advert.interval", let interval = Int(trimmed) { + return .advertInterval(interval) + } + + // Flood advert interval: integer hours + if query == "get flood.advert.interval", let interval = Int(trimmed) { + return .floodAdvertInterval(interval) + } + + // Flood max: integer hops + if query == "get flood.max", let maxHops = Int(trimmed) { + return .floodMax(maxHops) + } + + // Latitude: decimal degrees + if query == "get lat", let lat = Double(trimmed) { + return .latitude(lat) + } + + // Longitude: decimal degrees + if query == "get lon", let lon = Double(trimmed) { + return .longitude(lon) + } + + return .raw(trimmed) + } + + /// Reads the configured TX power in dBm from a `get tx` reply. Stock firmware + /// sends a bare integer; ZephCore appends Adaptive Power Control state, and + /// while APC is active the leading number is the reduced live power whereas the + /// `max=` ceiling is what `set tx` writes back, so `max=` wins when present. + private static func parseTXPowerDBm(_ text: String) -> Int? { + if let match = text.firstMatch(of: /max=(-?\d+)/) { + return Int(match.output.1) + } + if let match = text.firstMatch(of: /^(-?\d+)/) { + return Int(match.output.1) + } + return nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChannelFloodScopeResolver.swift b/MC1Services/Sources/MC1Services/Services/ChannelFloodScopeResolver.swift index a0854bc8..9f89e45a 100644 --- a/MC1Services/Sources/MC1Services/Services/ChannelFloodScopeResolver.swift +++ b/MC1Services/Sources/MC1Services/Services/ChannelFloodScopeResolver.swift @@ -14,21 +14,21 @@ import MeshCore /// - `.inherit` + no device default → `.scope(.disabled)` (no scope filter) /// - `.region(name)` → `.scope(.region(name))` (explicit per-channel override) public enum ChannelFloodScopeResolver { - public static func resolve( - channelFloodScope: ChannelFloodScope, - deviceDefaultFloodScopeName: String?, - supportsUnscopedFloodSend: Bool - ) -> ResolvedFloodScope { - switch channelFloodScope { - case .inherit: - if let name = deviceDefaultFloodScopeName, !name.isEmpty { - return .scope(.region(name)) - } - return .scope(.disabled) - case .allRegions: - return supportsUnscopedFloodSend ? .unscoped : .scope(.disabled) - case .region(let name): - return .scope(.region(name)) - } + public static func resolve( + channelFloodScope: ChannelFloodScope, + deviceDefaultFloodScopeName: String?, + supportsUnscopedFloodSend: Bool + ) -> ResolvedFloodScope { + switch channelFloodScope { + case .inherit: + if let name = deviceDefaultFloodScopeName, !name.isEmpty { + return .scope(.region(name)) + } + return .scope(.disabled) + case .allRegions: + return supportsUnscopedFloodSend ? .unscoped : .scope(.disabled) + case let .region(name): + return .scope(.region(name)) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChannelService.swift b/MC1Services/Sources/MC1Services/Services/ChannelService.swift index e8c69774..05a9def9 100644 --- a/MC1Services/Sources/MC1Services/Services/ChannelService.swift +++ b/MC1Services/Sources/MC1Services/Services/ChannelService.swift @@ -1,106 +1,108 @@ -import Foundation import CryptoKit +import Foundation import MeshCore import os // MARK: - Channel Service Errors public enum ChannelServiceError: Error, Sendable { - case notConnected - case channelNotFound - case invalidChannelIndex - case secretHashingFailed - case saveFailed(String) - case sendFailed(String) - case sessionError(MeshCoreError) - case syncAlreadyInProgress - case circuitBreakerOpen(consecutiveFailures: Int) + case notConnected + case channelNotFound + case invalidChannelIndex + case secretHashingFailed + case saveFailed(String) + case sendFailed(String) + case sessionError(MeshCoreError) + case syncAlreadyInProgress + case circuitBreakerOpen(consecutiveFailures: Int) } extension ChannelServiceError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .channelNotFound: "Channel not found." - case .invalidChannelIndex: "Invalid channel index." - case .secretHashingFailed: "Failed to hash channel secret." - case .saveFailed(let msg): "Failed to save channel: \(msg)" - case .sendFailed(let msg): "Send failed: \(msg)" - case .sessionError(let e): e.localizedDescription - case .syncAlreadyInProgress: "Channel sync is already in progress." - case .circuitBreakerOpen(let n): "Channel sync suspended after \(n) consecutive failures." - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .channelNotFound: "Channel not found." + case .invalidChannelIndex: "Invalid channel index." + case .secretHashingFailed: "Failed to hash channel secret." + case let .saveFailed(msg): "Failed to save channel: \(msg)" + case let .sendFailed(msg): "Send failed: \(msg)" + case let .sessionError(e): e.localizedDescription + case .syncAlreadyInProgress: "Channel sync is already in progress." + case let .circuitBreakerOpen(n): "Channel sync suspended after \(n) consecutive failures." } + } } // MARK: - Channel Sync Error Details /// Detailed error information for a failed channel sync -struct ChannelSyncError: Sendable, Equatable { - let index: UInt8 - let errorType: ErrorType - let description: String - - enum ErrorType: Sendable, Equatable { - case timeout - case sendTimeout - case transportError - case circuitBreaker - case deviceError(code: UInt8) - case databaseError - case unknown +struct ChannelSyncError: Equatable { + let index: UInt8 + let errorType: ErrorType + let description: String + + enum ErrorType: Equatable { + case timeout + case sendTimeout + case transportError + case circuitBreaker + case deviceError(code: UInt8) + case databaseError + case unknown + } + + /// Whether this error type is potentially recoverable with retry + var isRetryable: Bool { + switch errorType { + case .timeout, .sendTimeout: + true + case .transportError, .circuitBreaker, .deviceError, .databaseError, .unknown: + false } - - /// Whether this error type is potentially recoverable with retry - var isRetryable: Bool { - switch errorType { - case .timeout, .sendTimeout: - return true - case .transportError, .circuitBreaker, .deviceError, .databaseError, .unknown: - return false - } - } - - var countsTowardCircuitBreaker: Bool { - switch errorType { - case .timeout, .sendTimeout, .transportError: - return true - case .circuitBreaker, .deviceError, .databaseError, .unknown: - return false - } + } + + var countsTowardCircuitBreaker: Bool { + switch errorType { + case .timeout, .sendTimeout, .transportError: + true + case .circuitBreaker, .deviceError, .databaseError, .unknown: + false } + } } // MARK: - Channel Sync Result -struct ChannelSyncResult: Sendable, Equatable { - let channelsSynced: Int - let errors: [ChannelSyncError] +struct ChannelSyncResult: Equatable { + let channelsSynced: Int + let errors: [ChannelSyncError] - /// Whether sync completed without errors - var isComplete: Bool { errors.isEmpty } + /// Whether sync completed without errors + var isComplete: Bool { + errors.isEmpty + } - var requestTimeoutCount: Int { - errors.filter { $0.errorType == .timeout }.count - } + var requestTimeoutCount: Int { + errors.count(where: { $0.errorType == .timeout }) + } - var sendTimeoutCount: Int { - errors.filter { $0.errorType == .sendTimeout }.count - } + var sendTimeoutCount: Int { + errors.count(where: { $0.errorType == .sendTimeout }) + } - var circuitBreakerAborted: Bool { - errors.contains { $0.errorType == .circuitBreaker } - } + var circuitBreakerAborted: Bool { + errors.contains { $0.errorType == .circuitBreaker } + } - /// Indices of channels that failed with retryable errors - var retryableIndices: [UInt8] { - errors.filter { $0.isRetryable }.map { $0.index } - } + /// Indices of channels that failed with retryable errors + var retryableIndices: [UInt8] { + errors.filter(\.isRetryable).map(\.index) + } - init(channelsSynced: Int, errors: [ChannelSyncError] = []) { - self.channelsSynced = channelsSynced - self.errors = errors - } + init(channelsSynced: Int, errors: [ChannelSyncError] = []) { + self.channelsSynced = channelsSynced + self.errors = errors + } } // MARK: - Channel Service Actor @@ -108,753 +110,753 @@ struct ChannelSyncResult: Sendable, Equatable { /// Actor-isolated service for channel (group) management. /// Handles channel CRUD operations, secret hashing, and broadcast messaging. public actor ChannelService { - - // MARK: - Properties - - private let session: any MeshCoreSessionProtocol - private let dataStore: any ChannelPersisting - private let logger = PersistentLogger(subsystem: "com.mc1", category: "ChannelService") - - /// Decryption cache consumer: channel writes and syncs forward the fresh - /// channel list so `RxLogService` can decode captured packets. - /// Injected by `ServiceContainer` at construction. - private let rxLogService: RxLogService? - - /// Callback invoked with the channel slots vacated by a delete or sync prune, - /// so the main-actor `DraftStore` can drop any draft keyed to a now-free slot - /// before that slot is reused by a different channel. - /// Installed by `AppState.wireServicesIfConnected`. - private var draftClearHandler: (@Sendable (UUID, Set) async -> Void)? - - /// Tracks whether a sync operation is in progress - private var isSyncing = false - - // MARK: - Initialization - - init( - session: any MeshCoreSessionProtocol, - dataStore: any ChannelPersisting, - rxLogService: RxLogService? - ) { - self.session = session - self.dataStore = dataStore - self.rxLogService = rxLogService + // MARK: - Properties + + private let session: any MeshCoreSessionProtocol + private let dataStore: any ChannelPersisting + private let logger = PersistentLogger(subsystem: "com.mc1", category: "ChannelService") + + /// Decryption cache consumer: channel writes and syncs forward the fresh + /// channel list so `RxLogService` can decode captured packets. + /// Injected by `ServiceContainer` at construction. + private let rxLogService: RxLogService? + + /// Callback invoked with the channel slots vacated by a delete or sync prune, + /// so the main-actor `DraftStore` can drop any draft keyed to a now-free slot + /// before that slot is reused by a different channel. + /// Installed by `AppState.wireServicesIfConnected`. + private var draftClearHandler: (@Sendable (UUID, Set) async -> Void)? + + /// Tracks whether a sync operation is in progress + private var isSyncing = false + + // MARK: - Initialization + + init( + session: any MeshCoreSessionProtocol, + dataStore: any ChannelPersisting, + rxLogService: RxLogService? + ) { + self.session = session + self.dataStore = dataStore + self.rxLogService = rxLogService + } + + /// Whether an RX log service was injected at construction. + var hasRxLogServiceWired: Bool { + rxLogService != nil + } + + // MARK: - Secret Hashing + + /// Hashes a passphrase into a 16-byte channel secret using SHA-256. + /// The firmware uses the first 16 bytes of the SHA-256 hash. + /// - Parameter passphrase: The passphrase to hash + /// - Returns: 16-byte secret data + public static func hashSecret(_ passphrase: String) -> Data { + guard !passphrase.isEmpty else { + return Data(repeating: 0, count: ProtocolLimits.channelSecretSize) } - /// Whether an RX log service was injected at construction. - var hasRxLogServiceWired: Bool { rxLogService != nil } - - // MARK: - Secret Hashing - - /// Hashes a passphrase into a 16-byte channel secret using SHA-256. - /// The firmware uses the first 16 bytes of the SHA-256 hash. - /// - Parameter passphrase: The passphrase to hash - /// - Returns: 16-byte secret data - public static func hashSecret(_ passphrase: String) -> Data { - guard !passphrase.isEmpty else { - return Data(repeating: 0, count: ProtocolLimits.channelSecretSize) - } - - let data = passphrase.data(using: .utf8) ?? Data() - let hash = SHA256.hash(data: data) - return Data(hash.prefix(ProtocolLimits.channelSecretSize)) + let data = passphrase.data(using: .utf8) ?? Data() + let hash = SHA256.hash(data: data) + return Data(hash.prefix(ProtocolLimits.channelSecretSize)) + } + + /// Validates that a secret has the correct size + public static func validateSecret(_ secret: Data) -> Bool { + secret.count == ProtocolLimits.channelSecretSize + } + + /// Determines if a channel slot should be treated as configured. + /// A slot is unconfigured only when both the name is empty and the secret is all zeros. + static func isChannelConfigured(name: String, secret: Data) -> Bool { + !name.isEmpty || !isZeroSecret(secret) + } + + private static func isZeroSecret(_ secret: Data) -> Bool { + secret.allSatisfy { $0 == 0 } + } + + // MARK: - Channel CRUD Operations + + /// Fetches all channels for a device from the remote device. + /// - Parameters: + /// - radioID: The device UUID + /// - maxChannels: Maximum number of channels to fetch (from device capacity) + /// - Returns: Sync result with number of channels synced + /// - Throws: `syncAlreadyInProgress` if another sync is running, + /// `circuitBreakerOpen` if too many consecutive timeouts + func syncChannels( + radioID: UUID, + maxChannels: UInt8, + usePipelinedRead: Bool + ) async throws -> ChannelSyncResult { + // Concurrency guard + guard !isSyncing else { + logger.warning("Channel sync already in progress, rejecting concurrent request") + throw ChannelServiceError.syncAlreadyInProgress } - /// Validates that a secret has the correct size - public static func validateSecret(_ secret: Data) -> Bool { - secret.count == ProtocolLimits.channelSecretSize - } + isSyncing = true + defer { isSyncing = false } - /// Determines if a channel slot should be treated as configured. - /// A slot is unconfigured only when both the name is empty and the secret is all zeros. - static func isChannelConfigured(name: String, secret: Data) -> Bool { - !name.isEmpty || !isZeroSecret(secret) + if usePipelinedRead { + return try await syncChannelsPipelined(radioID: radioID, maxChannels: maxChannels) } - private static func isZeroSecret(_ secret: Data) -> Bool { - secret.allSatisfy { $0 == 0 } - } - - // MARK: - Channel CRUD Operations - - /// Fetches all channels for a device from the remote device. - /// - Parameters: - /// - radioID: The device UUID - /// - maxChannels: Maximum number of channels to fetch (from device capacity) - /// - Returns: Sync result with number of channels synced - /// - Throws: `syncAlreadyInProgress` if another sync is running, - /// `circuitBreakerOpen` if too many consecutive timeouts - func syncChannels( - radioID: UUID, - maxChannels: UInt8, - usePipelinedRead: Bool - ) async throws -> ChannelSyncResult { - // Concurrency guard - guard !isSyncing else { - logger.warning("Channel sync already in progress, rejecting concurrent request") - throw ChannelServiceError.syncAlreadyInProgress - } - - isSyncing = true - defer { isSyncing = false } - - if usePipelinedRead { - return try await syncChannelsPipelined(radioID: radioID, maxChannels: maxChannels) + var syncErrors: [ChannelSyncError] = [] + var configured: [ChannelInfo] = [] + var unconfiguredIndices: [UInt8] = [] + var emptyNameWithSecretIndices: [UInt8] = [] + + // Circuit breaker state + var consecutiveTimeouts = 0 + let circuitBreakerThreshold = 3 + + for index: UInt8 in 0..= circuitBreakerThreshold { + logger.error("Circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, aborting sync") + // Mark remaining channels as failed + for remaining in index..= circuitBreakerThreshold { - logger.error("Circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, aborting sync") - // Mark remaining channels as failed - for remaining in index.. ChannelSyncResult { - var syncErrors: [ChannelSyncError] = [] - var configured: [ChannelInfo] = [] - var unconfiguredIndices: [UInt8] = [] - var emptyNameWithSecretIndices: [UInt8] = [] - - // A hard send failure (e.g. disconnect mid-send) throws here, aborting the round with - // nothing persisted; an idle stall returns a partial set to reconcile rather than throwing. - let (received, missing) = try await session.getChannels(indices: Array(0..= circuitBreakerThreshold { - logger.error("Reconcile circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, stopping reconcile") - for remaining in missing.drop(while: { $0 != index }) { - syncErrors.append(ChannelSyncError( - index: remaining, - errorType: .circuitBreaker, - description: "Skipped due to circuit breaker" - )) - } - break - } - - do { - if let channelInfo = try await fetchChannel(index: index) { - configured.append(channelInfo) - consecutiveTimeouts = 0 - if channelInfo.name.isEmpty { - emptyNameWithSecretIndices.append(index) - } - } else { - consecutiveTimeouts = 0 - unconfiguredIndices.append(index) - } - } catch { - let syncError = classifyError(error, forIndex: index) - consecutiveTimeouts = nextConsecutiveFailureCount( - after: syncError, - currentCount: consecutiveTimeouts - ) - logger.warning("Reconcile failed for channel \(index): \(syncError.description)") - syncErrors.append(syncError) - } + // Persist the whole pass in a single transaction. Indices skipped by the circuit breaker + // are left in neither list, so they are untouched. + return await finalizeChannelSync( + radioID: radioID, + maxChannels: maxChannels, + configured: configured, + unconfiguredIndices: unconfiguredIndices, + emptyNameWithSecretIndices: emptyNameWithSecretIndices, + syncErrors: syncErrors, + pipelined: false + ) + } + + /// Pipelined channel read for transports that support it (nRF52 over BLE, ESP32 over WiFi): + /// one bounded-window `getChannels` exchange in place of N serial round-trips, then + /// acknowledged reconciliation of any dropped requests. Classification and the + /// single-transaction persist match the serial path so an index that could not be read lands + /// in neither the configured nor the unconfigured list and is therefore never deleted. + private func syncChannelsPipelined(radioID: UUID, maxChannels: UInt8) async throws -> ChannelSyncResult { + var syncErrors: [ChannelSyncError] = [] + var configured: [ChannelInfo] = [] + var unconfiguredIndices: [UInt8] = [] + var emptyNameWithSecretIndices: [UInt8] = [] + + // A hard send failure (e.g. disconnect mid-send) throws here, aborting the round with + // nothing persisted; an idle stall returns a partial set to reconcile rather than throwing. + let (received, missing) = try await session.getChannels(indices: Array(0.. ChannelSyncResult { - var syncErrors = syncErrors - - // Snapshot occupied slots before the persist so vacated slots (unconfigured on the - // radio, or beyond capacity) can be diffed from the survivors and have their drafts - // dropped. A failed fetch yields an empty baseline that skips clearing, so log it. - let priorChannels = try? await dataStore.fetchChannels(radioID: radioID) - if priorChannels == nil { - logger.warning("Pre-sync channel snapshot failed for radio \(radioID.uuidString); skipping draft clear for any slots this prune vacates") + // Reconcile dropped Write Commands with acknowledged reads. "Consecutive" failures are + // meaningful again on this serial sub-loop, so the circuit breaker applies here. An index + // still unread after reconcile stays in neither list, so its row is never deleted. + var consecutiveTimeouts = 0 + let circuitBreakerThreshold = 3 + for index in missing { + if consecutiveTimeouts >= circuitBreakerThreshold { + logger.error("Reconcile circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, stopping reconcile") + for remaining in missing.drop(while: { $0 != index }) { + syncErrors.append(ChannelSyncError( + index: remaining, + errorType: .circuitBreaker, + description: "Skipped due to circuit breaker" + )) } - let occupiedBeforeSync = Set(priorChannels?.map(\.index) ?? []) - - let channels: [ChannelDTO] - do { - channels = try await dataStore.batchSaveChannels( - radioID: radioID, - configured: configured, - unconfiguredIndices: unconfiguredIndices, - pruneBeyond: maxChannels - ) - } catch { - logger.error("Channel batch persist failed: \(error.localizedDescription)") - for info in configured { - syncErrors.append(ChannelSyncError( - index: info.index, - errorType: .databaseError, - description: "Batch persist failed: \(error.localizedDescription)" - )) - } - return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) + break + } + + do { + if let channelInfo = try await fetchChannel(index: index) { + configured.append(channelInfo) + consecutiveTimeouts = 0 + if channelInfo.name.isEmpty { + emptyNameWithSecretIndices.append(index) + } + } else { + consecutiveTimeouts = 0 + unconfiguredIndices.append(index) } - - let diagnosticsLabel = pipelined ? "Channel sync diagnostics (pipelined)" : "Channel sync diagnostics" - logger.info( - "\(diagnosticsLabel): synced=\(configured.count), unconfigured=\(unconfiguredIndices.count), emptyNameWithSecret=\(emptyNameWithSecretIndices.count), errors=\(syncErrors.count)" + } catch { + let syncError = classifyError(error, forIndex: index) + consecutiveTimeouts = nextConsecutiveFailureCount( + after: syncError, + currentCount: consecutiveTimeouts ) - if !emptyNameWithSecretIndices.isEmpty { - logger.warning( - "Channel sync detected empty-name channels with non-zero secrets at indices: \(emptyNameWithSecretIndices)" - ) - } - - let vacatedSlots = occupiedBeforeSync.subtracting(channels.map(\.index)) - if !vacatedSlots.isEmpty { - await draftClearHandler?(radioID, vacatedSlots) - } - - await rxLogService?.updateChannels(from: channels) + logger.warning("Reconcile failed for channel \(index): \(syncError.description)") + syncErrors.append(syncError) + } + } - return ChannelSyncResult(channelsSynced: configured.count, errors: syncErrors) + return await finalizeChannelSync( + radioID: radioID, + maxChannels: maxChannels, + configured: configured, + unconfiguredIndices: unconfiguredIndices, + emptyNameWithSecretIndices: emptyNameWithSecretIndices, + syncErrors: syncErrors, + pipelined: true + ) + } + + /// Persists a completed channel-read pass in a single transaction and reports the result. + /// Shared by the serial and pipelined paths so their classification-to-persist tail cannot + /// drift: it upserts configured channels, deletes stale rows at unconfigured slots, prunes + /// orphans beyond capacity, and never touches an index that landed in neither list. + private func finalizeChannelSync( + radioID: UUID, + maxChannels: UInt8, + configured: [ChannelInfo], + unconfiguredIndices: [UInt8], + emptyNameWithSecretIndices: [UInt8], + syncErrors: [ChannelSyncError], + pipelined: Bool + ) async -> ChannelSyncResult { + var syncErrors = syncErrors + + // Snapshot occupied slots before the persist so vacated slots (unconfigured on the + // radio, or beyond capacity) can be diffed from the survivors and have their drafts + // dropped. A failed fetch yields an empty baseline that skips clearing, so log it. + let priorChannels = try? await dataStore.fetchChannels(radioID: radioID) + if priorChannels == nil { + logger.warning("Pre-sync channel snapshot failed for radio \(radioID.uuidString); skipping draft clear for any slots this prune vacates") + } + let occupiedBeforeSync = Set(priorChannels?.map(\.index) ?? []) + + let channels: [ChannelDTO] + do { + channels = try await dataStore.batchSaveChannels( + radioID: radioID, + configured: configured, + unconfiguredIndices: unconfiguredIndices, + pruneBeyond: maxChannels + ) + } catch { + logger.error("Channel batch persist failed: \(error.localizedDescription)") + for info in configured { + syncErrors.append(ChannelSyncError( + index: info.index, + errorType: .databaseError, + description: "Batch persist failed: \(error.localizedDescription)" + )) + } + return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) } - /// Retries syncing only the channels that previously failed. - /// - Parameters: - /// - radioID: The device UUID - /// - indices: Channel indices to retry - /// - Returns: Sync result for the retried channels - func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { - guard !isSyncing else { - throw ChannelServiceError.syncAlreadyInProgress - } + let diagnosticsLabel = pipelined ? "Channel sync diagnostics (pipelined)" : "Channel sync diagnostics" + logger.info( + "\(diagnosticsLabel): synced=\(configured.count), unconfigured=\(unconfiguredIndices.count), emptyNameWithSecret=\(emptyNameWithSecretIndices.count), errors=\(syncErrors.count)" + ) + if !emptyNameWithSecretIndices.isEmpty { + logger.warning( + "Channel sync detected empty-name channels with non-zero secrets at indices: \(emptyNameWithSecretIndices)" + ) + } - guard !indices.isEmpty else { - return ChannelSyncResult(channelsSynced: 0, errors: []) - } + let vacatedSlots = occupiedBeforeSync.subtracting(channels.map(\.index)) + if !vacatedSlots.isEmpty { + await draftClearHandler?(radioID, vacatedSlots) + } - isSyncing = true - defer { isSyncing = false } - - logger.info("Retrying \(indices.count) failed channels: \(indices)") - - // Brief delay before retry to allow transient issues to resolve - try await Task.sleep(for: .milliseconds(500)) - - var syncErrors: [ChannelSyncError] = [] - var configured: [ChannelInfo] = [] - - // Circuit breaker for retry (stricter threshold than initial sync) - var consecutiveTimeouts = 0 - let circuitBreakerThreshold = 2 - - for index in indices { - // Circuit breaker: stop retrying if connection is clearly broken - if consecutiveTimeouts >= circuitBreakerThreshold { - logger.warning("Retry circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, stopping retry") - // Mark remaining channels as failed - let remainingIndices = indices.drop(while: { $0 != index }) - for remaining in remainingIndices { - syncErrors.append(ChannelSyncError( - index: remaining, - errorType: .circuitBreaker, - description: "Skipped due to retry circuit breaker" - )) - } - break - } - - do { - if let channelInfo = try await fetchChannel(index: index) { - configured.append(channelInfo) - consecutiveTimeouts = 0 // Reset on success - logger.info("Retry succeeded for channel \(index)") - } else { - consecutiveTimeouts = 0 // Not-found is not a timeout - } - } catch { - let syncError = classifyError(error, forIndex: index) - consecutiveTimeouts = nextConsecutiveFailureCount( - after: syncError, - currentCount: consecutiveTimeouts - ) - logger.warning("Retry failed for channel \(index): \(syncError.description)") - syncErrors.append(syncError) - } - } + await rxLogService?.updateChannels(from: channels) - // Nothing recovered: skip the persist round-trip and the handler notification. - guard !configured.isEmpty else { - return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) - } + return ChannelSyncResult(channelsSynced: configured.count, errors: syncErrors) + } - // Upsert the recovered channels in one transaction. Retry only re-fetches previously - // failed slots, so it never deletes unconfigured slots or prunes by capacity. - do { - let allChannels = try await dataStore.batchSaveChannels( - radioID: radioID, - configured: configured, - unconfiguredIndices: [], - pruneBeyond: nil - ) - await rxLogService?.updateChannels(from: allChannels) - } catch { - logger.error("Retry batch persist failed: \(error.localizedDescription)") - for info in configured { - syncErrors.append(ChannelSyncError( - index: info.index, - errorType: .databaseError, - description: "Retry persist failed: \(error.localizedDescription)" - )) - } - return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) - } + /// Retries syncing only the channels that previously failed. + /// - Parameters: + /// - radioID: The device UUID + /// - indices: Channel indices to retry + /// - Returns: Sync result for the retried channels + func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { + guard !isSyncing else { + throw ChannelServiceError.syncAlreadyInProgress + } - return ChannelSyncResult(channelsSynced: configured.count, errors: syncErrors) + guard !indices.isEmpty else { + return ChannelSyncResult(channelsSynced: 0, errors: []) } - /// Fetches a single channel from the device with retry logic for transient BLE failures. - /// - Parameter index: The channel index - /// - Returns: Channel info if found, nil if not configured - public func fetchChannel(index: UInt8) async throws -> ChannelInfo? { - // BLE operations can fail transiently due to RF interference or timing. - // Retry with exponential backoff per industry best practices (BLE spec recommends 30s timeout, - // but shorter retries with backoff are more responsive). - let maxAttempts = 3 - var lastError: MeshCoreError = .timeout - - for attempt in 1...maxAttempts { - do { - let meshChannelInfo = try await session.getChannel(index: index) - - // Validate returned index matches requested - guard meshChannelInfo.index == index else { - logger.error("Channel index mismatch: requested \(index), received \(meshChannelInfo.index)") - throw ChannelServiceError.invalidChannelIndex - } - - // Treat channel as unconfigured only when both name and secret are empty. - guard Self.isChannelConfigured(name: meshChannelInfo.name, secret: meshChannelInfo.secret) else { - return nil - } - if meshChannelInfo.name.isEmpty { - logger.warning( - "Channel \(index) has empty name with non-zero secret; treating as configured" - ) - } - - // Convert MeshCore.ChannelInfo to MC1Services.ChannelInfo - return ChannelInfo( - index: meshChannelInfo.index, - name: meshChannelInfo.name, - secret: meshChannelInfo.secret - ) - } catch let error as MeshCoreError { - // Non-retryable: channel not found on device (permanent error) - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - return nil - } - - // Retryable: timeout errors are transient BLE issues - if case .timeout = error { - lastError = error - if attempt < maxAttempts { - // Exponential backoff: 500ms, 1000ms, 2000ms with jitter - let baseDelayMs = 500 * (1 << (attempt - 1)) - let jitterMs = Int.random(in: -100...100) - let delayMs = baseDelayMs + jitterMs - logger.info("Channel \(index) fetch timeout, retry \(attempt)/\(maxAttempts) in \(delayMs)ms") - try await Task.sleep(for: .milliseconds(delayMs)) - continue - } - } - - // Non-retryable: other MeshCore errors (device errors, parse errors, etc.) - throw ChannelServiceError.sessionError(error) - } + isSyncing = true + defer { isSyncing = false } + + logger.info("Retrying \(indices.count) failed channels: \(indices)") + + // Brief delay before retry to allow transient issues to resolve + try await Task.sleep(for: .milliseconds(500)) + + var syncErrors: [ChannelSyncError] = [] + var configured: [ChannelInfo] = [] + + // Circuit breaker for retry (stricter threshold than initial sync) + var consecutiveTimeouts = 0 + let circuitBreakerThreshold = 2 + + for index in indices { + // Circuit breaker: stop retrying if connection is clearly broken + if consecutiveTimeouts >= circuitBreakerThreshold { + logger.warning("Retry circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, stopping retry") + // Mark remaining channels as failed + let remainingIndices = indices.drop(while: { $0 != index }) + for remaining in remainingIndices { + syncErrors.append(ChannelSyncError( + index: remaining, + errorType: .circuitBreaker, + description: "Skipped due to retry circuit breaker" + )) + } + break + } + + do { + if let channelInfo = try await fetchChannel(index: index) { + configured.append(channelInfo) + consecutiveTimeouts = 0 // Reset on success + logger.info("Retry succeeded for channel \(index)") + } else { + consecutiveTimeouts = 0 // Not-found is not a timeout } + } catch { + let syncError = classifyError(error, forIndex: index) + consecutiveTimeouts = nextConsecutiveFailureCount( + after: syncError, + currentCount: consecutiveTimeouts + ) + logger.warning("Retry failed for channel \(index): \(syncError.description)") + syncErrors.append(syncError) + } + } - // All retries exhausted - throw ChannelServiceError.sessionError(lastError) + // Nothing recovered: skip the persist round-trip and the handler notification. + guard !configured.isEmpty else { + return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) } - /// Sets (creates or updates) a channel on the device. - /// - Parameters: - /// - radioID: The device UUID - /// - index: The channel index - /// - name: The channel name - /// - passphrase: The passphrase to hash into a secret - public func setChannel( - radioID: UUID, - index: UInt8, - name: String, - passphrase: String - ) async throws { - let secret = Self.hashSecret(passphrase) - let truncatedName = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - - do { - try await session.setChannel(index: index, name: truncatedName, secret: secret) - - // Save to local database - let channelInfo = ChannelInfo(index: index, name: truncatedName, secret: secret) - _ = try await dataStore.saveChannel(radioID: radioID, from: channelInfo) - - // Refresh the decryption cache with the updated channel list - let channels = try await dataStore.fetchChannels(radioID: radioID) - await rxLogService?.updateChannels(from: channels) - } catch let error as MeshCoreError { - throw ChannelServiceError.sessionError(error) - } + // Upsert the recovered channels in one transaction. Retry only re-fetches previously + // failed slots, so it never deletes unconfigured slots or prunes by capacity. + do { + let allChannels = try await dataStore.batchSaveChannels( + radioID: radioID, + configured: configured, + unconfiguredIndices: [], + pruneBeyond: nil + ) + await rxLogService?.updateChannels(from: allChannels) + } catch { + logger.error("Retry batch persist failed: \(error.localizedDescription)") + for info in configured { + syncErrors.append(ChannelSyncError( + index: info.index, + errorType: .databaseError, + description: "Retry persist failed: \(error.localizedDescription)" + )) + } + return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) } - /// Sets a channel with a pre-computed secret (for advanced use cases). - /// - Parameters: - /// - radioID: The device UUID - /// - index: The channel index - /// - name: The channel name - /// - secret: The 16-byte secret (must be exactly 16 bytes) - public func setChannelWithSecret( - radioID: UUID, - index: UInt8, - name: String, - secret: Data - ) async throws { - guard Self.validateSecret(secret) else { - throw ChannelServiceError.secretHashingFailed + return ChannelSyncResult(channelsSynced: configured.count, errors: syncErrors) + } + + /// Fetches a single channel from the device with retry logic for transient BLE failures. + /// - Parameter index: The channel index + /// - Returns: Channel info if found, nil if not configured + public func fetchChannel(index: UInt8) async throws -> ChannelInfo? { + // BLE operations can fail transiently due to RF interference or timing. + // Retry with exponential backoff per industry best practices (BLE spec recommends 30s timeout, + // but shorter retries with backoff are more responsive). + let maxAttempts = 3 + var lastError: MeshCoreError = .timeout + + for attempt in 1...maxAttempts { + do { + let meshChannelInfo = try await session.getChannel(index: index) + + // Validate returned index matches requested + guard meshChannelInfo.index == index else { + logger.error("Channel index mismatch: requested \(index), received \(meshChannelInfo.index)") + throw ChannelServiceError.invalidChannelIndex } - let truncatedName = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - - do { - try await session.setChannel(index: index, name: truncatedName, secret: secret) - - // Save to local database - let channelInfo = ChannelInfo(index: index, name: truncatedName, secret: secret) - _ = try await dataStore.saveChannel(radioID: radioID, from: channelInfo) - - // Refresh the decryption cache with the updated channel list - let channels = try await dataStore.fetchChannels(radioID: radioID) - await rxLogService?.updateChannels(from: channels) - } catch let error as MeshCoreError { - throw ChannelServiceError.sessionError(error) + // Treat channel as unconfigured only when both name and secret are empty. + guard Self.isChannelConfigured(name: meshChannelInfo.name, secret: meshChannelInfo.secret) else { + return nil } - } - - /// Clears a channel by setting it to empty name and zero secret. - /// - Parameters: - /// - radioID: The device UUID - /// - index: The channel index - public func clearChannel(radioID: UUID, index: UInt8) async throws { - // Get channel ID before clearing, so we can reliably delete it - // (fetching after setChannelWithSecret may not find the empty-named channel) - let channelToDelete = try await dataStore.fetchChannel(radioID: radioID, index: index) - - // Set empty name and zero secret to clear on device - do { - try await session.setChannel( - index: index, - name: "", - secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) - ) - } catch let error as MeshCoreError { - throw ChannelServiceError.sessionError(error) + if meshChannelInfo.name.isEmpty { + logger.warning( + "Channel \(index) has empty name with non-zero secret; treating as configured" + ) } - // Delete messages for this channel first - try await dataStore.deleteMessagesForChannel(radioID: radioID, channelIndex: index) - - // Delete channel from local database using the ID we captured earlier - if let channel = channelToDelete { - try await dataStore.deleteChannel(id: channel.id) + // Convert MeshCore.ChannelInfo to MC1Services.ChannelInfo + return ChannelInfo( + index: meshChannelInfo.index, + name: meshChannelInfo.name, + secret: meshChannelInfo.secret + ) + } catch let error as MeshCoreError { + // Non-retryable: channel not found on device (permanent error) + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + return nil } - // Drop any draft keyed to the freed slot so a channel later created at the - // same index can't surface this channel's stale draft. - await draftClearHandler?(radioID, [index]) - - // Refresh the decryption cache with the updated channel list - let channels = try await dataStore.fetchChannels(radioID: radioID) - await rxLogService?.updateChannels(from: channels) - } - - /// Clears all messages for a channel without deleting the channel itself. - /// Use this for a "Clear Messages" feature that keeps the channel active. - /// - Parameters: - /// - radioID: The device UUID - /// - channelIndex: The channel index (0-7) - public func clearChannelMessages(radioID: UUID, channelIndex: UInt8) async throws { - try await dataStore.deleteMessagesForChannel(radioID: radioID, channelIndex: channelIndex) - - // Clear the last message date so the channel doesn't show a preview, and zero - // both unread counters — leaving them set would inflate the badge for a channel - // the user just emptied. - if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { - try await dataStore.updateChannelLastMessage(channelID: channel.id, date: nil) - try await dataStore.clearChannelUnreadCount(channelID: channel.id) - try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) + // Retryable: timeout errors are transient BLE issues + if case .timeout = error { + lastError = error + if attempt < maxAttempts { + // Exponential backoff: 500ms, 1000ms, 2000ms with jitter + let baseDelayMs = 500 * (1 << (attempt - 1)) + let jitterMs = Int.random(in: -100...100) + let delayMs = baseDelayMs + jitterMs + logger.info("Channel \(index) fetch timeout, retry \(attempt)/\(maxAttempts) in \(delayMs)ms") + try await Task.sleep(for: .milliseconds(delayMs)) + continue + } } - } - - // MARK: - Local Database Operations - /// Gets all channels from local database for a device. - /// - Parameter radioID: The device UUID - /// - Returns: Array of channel DTOs - public func getChannels(radioID: UUID) async throws -> [ChannelDTO] { - try await dataStore.fetchChannels(radioID: radioID) + // Non-retryable: other MeshCore errors (device errors, parse errors, etc.) + throw ChannelServiceError.sessionError(error) + } } - /// Gets a specific channel from local database. - /// - Parameters: - /// - radioID: The device UUID - /// - index: The channel index - /// - Returns: Channel DTO if found - public func getChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { - try await dataStore.fetchChannel(radioID: radioID, index: index) + // All retries exhausted + throw ChannelServiceError.sessionError(lastError) + } + + /// Sets (creates or updates) a channel on the device. + /// - Parameters: + /// - radioID: The device UUID + /// - index: The channel index + /// - name: The channel name + /// - passphrase: The passphrase to hash into a secret + public func setChannel( + radioID: UUID, + index: UInt8, + name: String, + passphrase: String + ) async throws { + let secret = Self.hashSecret(passphrase) + let truncatedName = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + + do { + try await session.setChannel(index: index, name: truncatedName, secret: secret) + + // Save to local database + let channelInfo = ChannelInfo(index: index, name: truncatedName, secret: secret) + _ = try await dataStore.saveChannel(radioID: radioID, from: channelInfo) + + // Refresh the decryption cache with the updated channel list + let channels = try await dataStore.fetchChannels(radioID: radioID) + await rxLogService?.updateChannels(from: channels) + } catch let error as MeshCoreError { + throw ChannelServiceError.sessionError(error) } - - /// Gets channels that have messages (for chat list). - /// - Parameter radioID: The device UUID - /// - Returns: Array of channel DTOs with lastMessageDate set - public func getActiveChannels(radioID: UUID) async throws -> [ChannelDTO] { - let channels = try await dataStore.fetchChannels(radioID: radioID) - return channels.filter { $0.lastMessageDate != nil } + } + + /// Sets a channel with a pre-computed secret (for advanced use cases). + /// - Parameters: + /// - radioID: The device UUID + /// - index: The channel index + /// - name: The channel name + /// - secret: The 16-byte secret (must be exactly 16 bytes) + public func setChannelWithSecret( + radioID: UUID, + index: UInt8, + name: String, + secret: Data + ) async throws { + guard Self.validateSecret(secret) else { + throw ChannelServiceError.secretHashingFailed } - // MARK: - Public Channel (Slot 0) - - private static let publicChannelSecret = Data([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 - ]) - - /// Creates or resets the public channel (slot 0). - /// - Parameter radioID: The device UUID - public func setupPublicChannel(radioID: UUID) async throws { - try await setChannelWithSecret( - radioID: radioID, - index: 0, - name: "Public", - secret: Self.publicChannelSecret - ) - } + let truncatedName = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - /// Checks if the public channel exists locally. - /// - Parameter radioID: The device UUID - /// - Returns: True if public channel exists - public func hasPublicChannel(radioID: UUID) async throws -> Bool { - let channel = try await dataStore.fetchChannel(radioID: radioID, index: 0) - return channel != nil - } + do { + try await session.setChannel(index: index, name: truncatedName, secret: secret) - // MARK: - Handlers + // Save to local database + let channelInfo = ChannelInfo(index: index, name: truncatedName, secret: secret) + _ = try await dataStore.saveChannel(radioID: radioID, from: channelInfo) - /// Sets a callback that receives the channel slots vacated by `clearChannel` - /// and by the sync prune in `finalizeChannelSync`, so the `DraftStore` can - /// clear drafts keyed to a freed slot. - public func setDraftClearHandler(_ handler: @escaping @Sendable (UUID, Set) async -> Void) { - draftClearHandler = handler + // Refresh the decryption cache with the updated channel list + let channels = try await dataStore.fetchChannels(radioID: radioID) + await rxLogService?.updateChannels(from: channels) + } catch let error as MeshCoreError { + throw ChannelServiceError.sessionError(error) + } + } + + /// Clears a channel by setting it to empty name and zero secret. + /// - Parameters: + /// - radioID: The device UUID + /// - index: The channel index + public func clearChannel(radioID: UUID, index: UInt8) async throws { + // Get channel ID before clearing, so we can reliably delete it + // (fetching after setChannelWithSecret may not find the empty-named channel) + let channelToDelete = try await dataStore.fetchChannel(radioID: radioID, index: index) + + // Set empty name and zero secret to clear on device + do { + try await session.setChannel( + index: index, + name: "", + secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) + ) + } catch let error as MeshCoreError { + throw ChannelServiceError.sessionError(error) } - // MARK: - Private Helpers - - /// Classifies an error into a ChannelSyncError for the given index - private func classifyError(_ error: Error, forIndex index: UInt8) -> ChannelSyncError { - if let channelError = error as? ChannelServiceError { - switch channelError { - case .circuitBreakerOpen: - return ChannelSyncError( - index: index, - errorType: .circuitBreaker, - description: channelError.localizedDescription - ) - case .sessionError(let meshError): - switch meshError { - case .timeout: - return ChannelSyncError( - index: index, - errorType: .timeout, - description: "Request timed out" - ) - case .deviceError(let code): - return ChannelSyncError( - index: index, - errorType: .deviceError(code: code), - description: meshError.localizedDescription - ) - default: - return ChannelSyncError( - index: index, - errorType: .unknown, - description: meshError.localizedDescription - ) - } - case .saveFailed(let reason): - return ChannelSyncError( - index: index, - errorType: .databaseError, - description: "Save failed: \(reason)" - ) - default: - return ChannelSyncError( - index: index, - errorType: .unknown, - description: channelError.localizedDescription - ) - } - } - - if let transportError = error as? WiFiTransportError { - switch transportError { - case .sendTimeout: - return ChannelSyncError( - index: index, - errorType: .sendTimeout, - description: "Send timed out" - ) - case .notConnected, .connectionFailed, .connectionTimeout, .sendFailed: - return ChannelSyncError( - index: index, - errorType: .transportError, - description: transportError.localizedDescription - ) - case .invalidHost, .invalidPort, .notConfigured: - return ChannelSyncError( - index: index, - errorType: .unknown, - description: transportError.localizedDescription - ) - } - } + // Delete messages for this channel first + try await dataStore.deleteMessagesForChannel(radioID: radioID, channelIndex: index) - if let transportError = error as? MeshTransportError { - switch transportError { - case .sendFailed: - return ChannelSyncError( - index: index, - errorType: .transportError, - description: transportError.localizedDescription - ) - case .notConnected, .connectionFailed, .deviceNotFound, .serviceNotFound, .characteristicNotFound: - return ChannelSyncError( - index: index, - errorType: .transportError, - description: transportError.localizedDescription - ) - } - } + // Delete channel from local database using the ID we captured earlier + if let channel = channelToDelete { + try await dataStore.deleteChannel(id: channel.id) + } + // Drop any draft keyed to the freed slot so a channel later created at the + // same index can't surface this channel's stale draft. + await draftClearHandler?(radioID, [index]) + + // Refresh the decryption cache with the updated channel list + let channels = try await dataStore.fetchChannels(radioID: radioID) + await rxLogService?.updateChannels(from: channels) + } + + /// Clears all messages for a channel without deleting the channel itself. + /// Use this for a "Clear Messages" feature that keeps the channel active. + /// - Parameters: + /// - radioID: The device UUID + /// - channelIndex: The channel index (0-7) + public func clearChannelMessages(radioID: UUID, channelIndex: UInt8) async throws { + try await dataStore.deleteMessagesForChannel(radioID: radioID, channelIndex: channelIndex) + + // Clear the last message date so the channel doesn't show a preview, and zero + // both unread counters — leaving them set would inflate the badge for a channel + // the user just emptied. + if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { + try await dataStore.updateChannelLastMessage(channelID: channel.id, date: nil) + try await dataStore.clearChannelUnreadCount(channelID: channel.id) + try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) + } + } + + // MARK: - Local Database Operations + + /// Gets all channels from local database for a device. + /// - Parameter radioID: The device UUID + /// - Returns: Array of channel DTOs + public func getChannels(radioID: UUID) async throws -> [ChannelDTO] { + try await dataStore.fetchChannels(radioID: radioID) + } + + /// Gets a specific channel from local database. + /// - Parameters: + /// - radioID: The device UUID + /// - index: The channel index + /// - Returns: Channel DTO if found + public func getChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { + try await dataStore.fetchChannel(radioID: radioID, index: index) + } + + /// Gets channels that have messages (for chat list). + /// - Parameter radioID: The device UUID + /// - Returns: Array of channel DTOs with lastMessageDate set + public func getActiveChannels(radioID: UUID) async throws -> [ChannelDTO] { + let channels = try await dataStore.fetchChannels(radioID: radioID) + return channels.filter { $0.lastMessageDate != nil } + } + + // MARK: - Public Channel (Slot 0) + + private static let publicChannelSecret = Data([ + 0x8B, 0x33, 0x87, 0xE9, 0xC5, 0xCD, 0xEA, 0x6A, + 0xC9, 0xE5, 0xED, 0xBA, 0xA1, 0x15, 0xCD, 0x72 + ]) + + /// Creates or resets the public channel (slot 0). + /// - Parameter radioID: The device UUID + public func setupPublicChannel(radioID: UUID) async throws { + try await setChannelWithSecret( + radioID: radioID, + index: 0, + name: "Public", + secret: Self.publicChannelSecret + ) + } + + /// Checks if the public channel exists locally. + /// - Parameter radioID: The device UUID + /// - Returns: True if public channel exists + public func hasPublicChannel(radioID: UUID) async throws -> Bool { + let channel = try await dataStore.fetchChannel(radioID: radioID, index: 0) + return channel != nil + } + + // MARK: - Handlers + + /// Sets a callback that receives the channel slots vacated by `clearChannel` + /// and by the sync prune in `finalizeChannelSync`, so the `DraftStore` can + /// clear drafts keyed to a freed slot. + public func setDraftClearHandler(_ handler: @escaping @Sendable (UUID, Set) async -> Void) { + draftClearHandler = handler + } + + // MARK: - Private Helpers + + /// Classifies an error into a ChannelSyncError for the given index + private func classifyError(_ error: Error, forIndex index: UInt8) -> ChannelSyncError { + if let channelError = error as? ChannelServiceError { + switch channelError { + case .circuitBreakerOpen: return ChannelSyncError( + index: index, + errorType: .circuitBreaker, + description: channelError.localizedDescription + ) + case let .sessionError(meshError): + switch meshError { + case .timeout: + return ChannelSyncError( + index: index, + errorType: .timeout, + description: "Request timed out" + ) + case let .deviceError(code): + return ChannelSyncError( + index: index, + errorType: .deviceError(code: code), + description: meshError.localizedDescription + ) + default: + return ChannelSyncError( index: index, errorType: .unknown, - description: error.localizedDescription + description: meshError.localizedDescription + ) + } + case let .saveFailed(reason): + return ChannelSyncError( + index: index, + errorType: .databaseError, + description: "Save failed: \(reason)" + ) + default: + return ChannelSyncError( + index: index, + errorType: .unknown, + description: channelError.localizedDescription ) + } } - private func nextConsecutiveFailureCount( - after error: ChannelSyncError, - currentCount: Int - ) -> Int { - error.countsTowardCircuitBreaker ? currentCount + 1 : 0 + if let transportError = error as? WiFiTransportError { + switch transportError { + case .sendTimeout: + return ChannelSyncError( + index: index, + errorType: .sendTimeout, + description: "Send timed out" + ) + case .notConnected, .connectionFailed, .connectionTimeout, .sendFailed: + return ChannelSyncError( + index: index, + errorType: .transportError, + description: transportError.localizedDescription + ) + case .invalidHost, .invalidPort, .notConfigured: + return ChannelSyncError( + index: index, + errorType: .unknown, + description: transportError.localizedDescription + ) + } + } + + if let transportError = error as? MeshTransportError { + switch transportError { + case .sendFailed: + return ChannelSyncError( + index: index, + errorType: .transportError, + description: transportError.localizedDescription + ) + case .notConnected, .connectionFailed, .deviceNotFound, .serviceNotFound, .characteristicNotFound: + return ChannelSyncError( + index: index, + errorType: .transportError, + description: transportError.localizedDescription + ) + } } + return ChannelSyncError( + index: index, + errorType: .unknown, + description: error.localizedDescription + ) + } + + private func nextConsecutiveFailureCount( + after error: ChannelSyncError, + currentCount: Int + ) -> Int { + error.countsTowardCircuitBreaker ? currentCount + 1 : 0 + } } // MARK: - ChannelServiceProtocol Conformance extension ChannelService: ChannelServiceProtocol { - // Already implements syncChannels(radioID:maxChannels:) -> ChannelSyncResult + // Already implements syncChannels(radioID:maxChannels:) -> ChannelSyncResult } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift index 29957bf2..bcb6ac7e 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift @@ -1,188 +1,187 @@ import Foundation public extension ChatCoordinator { - - /// Replace the entire canonical messages list. Rebuilds the lookup - /// dictionary and bumps `renderStateID` so any in-flight off-main - /// build discards on apply. Settles `renderState.phase` to `.loaded`, - /// which is the load-completion seam for both initial loads and - /// `hardReset` refetches. - func replaceAll(_ newMessages: [MessageDTO]) { - messages = newMessages - messagesByID = Dictionary(newMessages.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) - if renderState.phase != .loaded { - renderState = renderState.with(phase: .loaded) - } - renderStateID &+= 1 + /// Replace the entire canonical messages list. Rebuilds the lookup + /// dictionary and bumps `renderStateID` so any in-flight off-main + /// build discards on apply. Settles `renderState.phase` to `.loaded`, + /// which is the load-completion seam for both initial loads and + /// `hardReset` refetches. + func replaceAll(_ newMessages: [MessageDTO]) { + messages = newMessages + messagesByID = Dictionary(newMessages.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) + if renderState.phase != .loaded { + renderState = renderState.with(phase: .loaded) } + renderStateID &+= 1 + } - /// Transition `renderState.phase` to `.loading` only when still - /// `.uninitialized`. Called at the entry of `loadMessages` / - /// `loadChannelMessages` so the per-conversation view's empty-state - /// gate stays closed while the awaited fetch is in flight. A no-op - /// once the coordinator has reached `.loading` or `.loaded` — a - /// subsequent refresh of an already-populated timeline must not - /// regress to a placeholder. - func beginLoading() { - guard renderState.phase == .uninitialized else { return } - renderState = renderState.with(phase: .loading) - renderStateID &+= 1 - } + /// Transition `renderState.phase` to `.loading` only when still + /// `.uninitialized`. Called at the entry of `loadMessages` / + /// `loadChannelMessages` so the per-conversation view's empty-state + /// gate stays closed while the awaited fetch is in flight. A no-op + /// once the coordinator has reached `.loading` or `.loaded` — a + /// subsequent refresh of an already-populated timeline must not + /// regress to a placeholder. + func beginLoading() { + guard renderState.phase == .uninitialized else { return } + renderState = renderState.with(phase: .loading) + renderStateID &+= 1 + } - /// Force the phase to `.loaded`. Used by the load paths to dismiss the - /// placeholder on error and on the nil-`dataStore` early-return, where - /// `replaceAll` is never reached. Idempotent when already `.loaded`. - func markLoaded() { - guard renderState.phase != .loaded else { return } - renderState = renderState.with(phase: .loaded) - renderStateID &+= 1 - } + /// Force the phase to `.loaded`. Used by the load paths to dismiss the + /// placeholder on error and on the nil-`dataStore` early-return, where + /// `replaceAll` is never reached. Idempotent when already `.loaded`. + func markLoaded() { + guard renderState.phase != .loaded else { return } + renderState = renderState.with(phase: .loaded) + renderStateID &+= 1 + } - /// Insert `older` at the head of the canonical messages list. Used by - /// pagination to prepend a fresh page above the current timeline. - /// Skips IDs already present so concurrent appends do not produce - /// duplicate-key crashes during reordering. - func prepend(_ older: [MessageDTO]) { - guard !older.isEmpty else { return } - let filtered = older.filter { messagesByID[$0.id] == nil } - guard !filtered.isEmpty else { return } - messages.insert(contentsOf: filtered, at: 0) - for dto in filtered { - messagesByID[dto.id] = dto - } - renderStateID &+= 1 + /// Insert `older` at the head of the canonical messages list. Used by + /// pagination to prepend a fresh page above the current timeline. + /// Skips IDs already present so concurrent appends do not produce + /// duplicate-key crashes during reordering. + func prepend(_ older: [MessageDTO]) { + guard !older.isEmpty else { return } + let filtered = older.filter { messagesByID[$0.id] == nil } + guard !filtered.isEmpty else { return } + messages.insert(contentsOf: filtered, at: 0) + for dto in filtered { + messagesByID[dto.id] = dto } + renderStateID &+= 1 + } - /// Append a single message if its ID is not already known. The guard - /// reads `messagesByID` (O(1) lookup) so events landing during an - /// off-main build are still deduplicated correctly. - @discardableResult - func append(_ message: MessageDTO) -> Bool { - guard messagesByID[message.id] == nil else { return false } - messages.append(message) - messagesByID[message.id] = message - renderStateID &+= 1 - return true - } + /// Append a single message if its ID is not already known. The guard + /// reads `messagesByID` (O(1) lookup) so events landing during an + /// off-main build are still deduplicated correctly. + @discardableResult + func append(_ message: MessageDTO) -> Bool { + guard messagesByID[message.id] == nil else { return false } + messages.append(message) + messagesByID[message.id] = message + renderStateID &+= 1 + return true + } - /// Apply an in-place mutation to a message identified by ID. No-op on - /// missing ID. Bumps `renderStateID` so stale off-main builds discard. - func update(messageID: UUID, _ transform: (inout MessageDTO) -> Void) { - guard let index = messages.firstIndex(where: { $0.id == messageID }) else { return } - transform(&messages[index]) - messagesByID[messageID] = messages[index] - renderStateID &+= 1 - } + /// Apply an in-place mutation to a message identified by ID. No-op on + /// missing ID. Bumps `renderStateID` so stale off-main builds discard. + func update(messageID: UUID, _ transform: (inout MessageDTO) -> Void) { + guard let index = messages.firstIndex(where: { $0.id == messageID }) else { return } + transform(&messages[index]) + messagesByID[messageID] = messages[index] + renderStateID &+= 1 + } - /// Remove a message by ID. No-op if absent. - func remove(messageID: UUID) { - guard messagesByID[messageID] != nil else { return } - messages.removeAll { $0.id == messageID } - messagesByID[messageID] = nil - renderStateID &+= 1 - } + /// Remove a message by ID. No-op if absent. + func remove(messageID: UUID) { + guard messagesByID[messageID] != nil else { return } + messages.removeAll { $0.id == messageID } + messagesByID[messageID] = nil + renderStateID &+= 1 + } - /// Reassign the entire `messages` array in place after a same-sender - /// reordering pass. Rebuilds `messagesByID` to match. Bumps - /// `renderStateID`. - func replaceMessagesPreservingByID(_ reordered: [MessageDTO]) { - messages = reordered - messagesByID = Dictionary(reordered.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) - renderStateID &+= 1 - } + /// Reassign the entire `messages` array in place after a same-sender + /// reordering pass. Rebuilds `messagesByID` to match. Bumps + /// `renderStateID`. + func replaceMessagesPreservingByID(_ reordered: [MessageDTO]) { + messages = reordered + messagesByID = Dictionary(reordered.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) + renderStateID &+= 1 + } - /// Single seam through which off-main builds apply their rendered - /// timeline. Returns `false` if the captured `capturedID` does not - /// match the current `renderStateID` — i.e., the build is stale and - /// the caller should re-run. - @discardableResult - func setRenderState(_ new: ChatRenderState, capturedID: UInt64) -> Bool { - guard capturedID == renderStateID else { return false } - renderState = new - return true - } + /// Single seam through which off-main builds apply their rendered + /// timeline. Returns `false` if the captured `capturedID` does not + /// match the current `renderStateID` — i.e., the build is stale and + /// the caller should re-run. + @discardableResult + func setRenderState(_ new: ChatRenderState, capturedID: UInt64) -> Bool { + guard capturedID == renderStateID else { return false } + renderState = new + return true + } - /// Direct render-state assignment for single-row updates that already - /// hold the canonical messages array consistent. Used by load paths - /// that reset pagination fields. Bumps `renderStateID` because the - /// new render state may displace an in-flight off-main build. - func updateRenderState(_ transform: (ChatRenderState) -> ChatRenderState) { - renderState = transform(renderState) - renderStateID &+= 1 - } + /// Direct render-state assignment for single-row updates that already + /// hold the canonical messages array consistent. Used by load paths + /// that reset pagination fields. Bumps `renderStateID` because the + /// new render state may displace an in-flight off-main build. + func updateRenderState(_ transform: (ChatRenderState) -> ChatRenderState) { + renderState = transform(renderState) + renderStateID &+= 1 + } - /// Append a fully built `MessageItem` to the render state. Used by the - /// single-row append path so the new bubble is visible immediately - /// without waiting for the next off-main build. - func appendRenderItem(_ item: MessageItem) { - renderState = renderState.appendingItem(item) - renderStateID &+= 1 - } + /// Append a fully built `MessageItem` to the render state. Used by the + /// single-row append path so the new bubble is visible immediately + /// without waiting for the next off-main build. + func appendRenderItem(_ item: MessageItem) { + renderState = renderState.appendingItem(item) + renderStateID &+= 1 + } - /// Replace a single render-state item by ID via `transform`. No-op on - /// missing ID. Bumps `renderStateID`. - func updateRenderItem(id: UUID, _ transform: (MessageItem) -> MessageItem) { - let newState = renderState.updatingItem(id: id, transform) - guard newState != renderState else { return } - renderState = newState - renderStateID &+= 1 - } + /// Replace a single render-state item by ID via `transform`. No-op on + /// missing ID. Bumps `renderStateID`. + func updateRenderItem(id: UUID, _ transform: (MessageItem) -> MessageItem) { + let newState = renderState.updatingItem(id: id, transform) + guard newState != renderState else { return } + renderState = newState + renderStateID &+= 1 + } - /// Remove a single render-state item by ID. Bumps `renderStateID`. - func removeRenderItem(id: UUID) { - let newState = renderState.removingItem(id: id) - guard newState != renderState else { return } - renderState = newState - renderStateID &+= 1 - } + /// Remove a single render-state item by ID. Bumps `renderStateID`. + func removeRenderItem(id: UUID) { + let newState = renderState.removingItem(id: id) + guard newState != renderState else { return } + renderState = newState + renderStateID &+= 1 + } - /// Apply a status-only update to a message in place. Mutates the canonical - /// `MessageDTO` (so `BubbleStatusRow` re-reads the new label) and the - /// rendered `MessageItem`'s envelope and footer (so `bubbleColor` and - /// `accessibilityMessageLabel` reflect the new status). No DB read. - /// - /// `roundTripTime` is preserved when nil so .sent transitions don't clobber - /// an existing RTT recorded by a prior .delivered event. - /// - /// Mirrors the monotonic guard in `PersistenceStore.updateMessageAck`: once - /// the DTO has reached `.delivered`, a later `.sent` write from the - /// send-return path is skipped so the authoritative delivery state is - /// preserved. Without this guard, the DM-send-return then ACK-listener race - /// produces a visible `.delivered` then `.sent` downgrade in the UI when - /// the listener wins. - /// - /// A second guard blocks `.failed` from being downgraded to `.pending` by - /// a non-user-initiated path. Legitimate user-initiated retry sites set - /// `userInitiated: true` so the flip lands; event-stream `applyStatusUpdate` - /// callers (ack / send confirmation) never carry `.pending`, so the - /// default avoids the visible `.failed` then `.pending` flicker when a - /// stale event lands after the queue marked the row terminal. - /// - /// The `.delivered` downgrade guard is also relaxed for `userInitiated` - /// transitions so a user-initiated resend can visibly flip a delivered - /// row back to `.pending` while the queue retransmits; event-stream - /// callers still cannot downgrade a delivered row. - func applyStatusUpdate( - messageID: UUID, - status: MessageStatus, - roundTripTime: UInt32? = nil, - userInitiated: Bool = false - ) { - if let current = messagesByID[messageID] { - if current.status == .delivered, status != .delivered, !userInitiated { return } - if current.status == .failed, status == .pending, !userInitiated { return } - } - update(messageID: messageID) { dto in - dto.status = status - if let roundTripTime { - dto.roundTripTime = roundTripTime - } - } - updateRenderItem(id: messageID) { item in - item.with( - envelope: item.envelope.with(status: status), - footer: item.footer.with(status: status) - ) - } + /// Apply a status-only update to a message in place. Mutates the canonical + /// `MessageDTO` (so `BubbleStatusRow` re-reads the new label) and the + /// rendered `MessageItem`'s envelope and footer (so `bubbleColor` and + /// `accessibilityMessageLabel` reflect the new status). No DB read. + /// + /// `roundTripTime` is preserved when nil so .sent transitions don't clobber + /// an existing RTT recorded by a prior .delivered event. + /// + /// Mirrors the monotonic guard in `PersistenceStore.updateMessageAck`: once + /// the DTO has reached `.delivered`, a later `.sent` write from the + /// send-return path is skipped so the authoritative delivery state is + /// preserved. Without this guard, the DM-send-return then ACK-listener race + /// produces a visible `.delivered` then `.sent` downgrade in the UI when + /// the listener wins. + /// + /// A second guard blocks `.failed` from being downgraded to `.pending` by + /// a non-user-initiated path. Legitimate user-initiated retry sites set + /// `userInitiated: true` so the flip lands; event-stream `applyStatusUpdate` + /// callers (ack / send confirmation) never carry `.pending`, so the + /// default avoids the visible `.failed` then `.pending` flicker when a + /// stale event lands after the queue marked the row terminal. + /// + /// The `.delivered` downgrade guard is also relaxed for `userInitiated` + /// transitions so a user-initiated resend can visibly flip a delivered + /// row back to `.pending` while the queue retransmits; event-stream + /// callers still cannot downgrade a delivered row. + func applyStatusUpdate( + messageID: UUID, + status: MessageStatus, + roundTripTime: UInt32? = nil, + userInitiated: Bool = false + ) { + if let current = messagesByID[messageID] { + if current.status == .delivered, status != .delivered, !userInitiated { return } + if current.status == .failed, status == .pending, !userInitiated { return } + } + update(messageID: messageID) { dto in + dto.status = status + if let roundTripTime { + dto.roundTripTime = roundTripTime + } + } + updateRenderItem(id: messageID) { item in + item.with( + envelope: item.envelope.with(status: status), + footer: item.footer.with(status: status) + ) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift index 41dad791..5ff6562f 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift @@ -1,73 +1,72 @@ import Foundation public extension ChatCoordinator { + /// Rebuild `renderState` from a snapshot of inputs already assembled on + /// the main actor. Runs the per-message builder loop off the main actor + /// inside a `Task { @concurrent }` hop, then applies the result on main + /// when the captured `renderStateID` still matches. + /// + /// The caller supplies the `(MessageDTO, MessageBuildInputs)` pairs so + /// per-message inputs that depend on `ChatViewModel` state (preview + /// states, cached URLs, image decode results) can be assembled on the + /// main actor where that state lives. `MessageFragmentBuilder.makeItem` + /// is pure over `Sendable` inputs, so the off-actor portion captures + /// only `Sendable` values. + /// + /// `postApply` runs on the main actor after a successful apply. The + /// view model uses it to kick off URL detection and legacy preview + /// decoding without needing to know about the off-main hop. + func rebuildItems( + inputs: [(MessageDTO, MessageBuildInputs)], + envInputs: EnvInputs, + postApply: (@MainActor () -> Void)? = nil + ) { + // Bump the generation before capturing it so two back-to-back + // rebuilds (link-preview load + URL detection, for example) cannot + // share the same `capturedID`. Without this, an older build that + // finished after a newer one would still pass `setRenderState`'s + // guard and clobber state. Last-scheduled-wins. + renderStateID &+= 1 + let capturedID = renderStateID + let snapshot = inputs - /// Rebuild `renderState` from a snapshot of inputs already assembled on - /// the main actor. Runs the per-message builder loop off the main actor - /// inside a `Task { @concurrent }` hop, then applies the result on main - /// when the captured `renderStateID` still matches. - /// - /// The caller supplies the `(MessageDTO, MessageBuildInputs)` pairs so - /// per-message inputs that depend on `ChatViewModel` state (preview - /// states, cached URLs, image decode results) can be assembled on the - /// main actor where that state lives. `MessageFragmentBuilder.makeItem` - /// is pure over `Sendable` inputs, so the off-actor portion captures - /// only `Sendable` values. - /// - /// `postApply` runs on the main actor after a successful apply. The - /// view model uses it to kick off URL detection and legacy preview - /// decoding without needing to know about the off-main hop. - func rebuildItems( - inputs: [(MessageDTO, MessageBuildInputs)], - envInputs: EnvInputs, - postApply: (@MainActor () -> Void)? = nil - ) { - // Bump the generation before capturing it so two back-to-back - // rebuilds (link-preview load + URL detection, for example) cannot - // share the same `capturedID`. Without this, an older build that - // finished after a newer one would still pass `setRenderState`'s - // guard and clobber state. Last-scheduled-wins. - renderStateID &+= 1 - let capturedID = renderStateID - let snapshot = inputs - - // Run off the MainActor; `MessageFragmentBuilder` is pure over - // Sendable inputs so the snapshot is safe to consume from a - // detached task. - buildItemsTask?.cancel() - buildItemsTask = Task(priority: .userInitiated) { @concurrent [weak self] in - var built: [MessageItem] = [] - built.reserveCapacity(snapshot.count) - for (message, perMessageInputs) in snapshot { - if Task.isCancelled { return } - built.append(MessageFragmentBuilder.makeItem( - for: message, - inputs: perMessageInputs, - envInputs: envInputs - )) - } - await self?.applyRebuiltItems(built, capturedID: capturedID, postApply: postApply) - } + // Run off the MainActor; `MessageFragmentBuilder` is pure over + // Sendable inputs so the snapshot is safe to consume from a + // detached task. + buildItemsTask?.cancel() + buildItemsTask = Task(priority: .userInitiated) { @concurrent [weak self] in + var built: [MessageItem] = [] + built.reserveCapacity(snapshot.count) + for (message, perMessageInputs) in snapshot { + if Task.isCancelled { return } + built.append(MessageFragmentBuilder.makeItem( + for: message, + inputs: perMessageInputs, + envInputs: envInputs + )) + } + await self?.applyRebuiltItems(built, capturedID: capturedID, postApply: postApply) } + } - /// Reached via `await self?.…` from the `@concurrent` builder task. - /// Inlining as `MainActor.run { guard let self else … }` trips - /// Swift 6 region isolation on the `weak self` transfer. - private func applyRebuiltItems( - _ built: [MessageItem], - capturedID: UInt64, - postApply: (@MainActor () -> Void)? - ) { - guard !Task.isCancelled else { return } - let new = renderState.with(items: built, itemIndexByID: built.indexByID()) - let applied = setRenderState(new, capturedID: capturedID) - if !applied { - // A fresher mutation landed mid-flight. Notify the bound - // view model so it can reassemble per-message inputs and - // call rebuildItems again. - renderStateInvalidated?() - return - } - postApply?() + /// Reached via `await self?.…` from the `@concurrent` builder task. + /// Inlining as `MainActor.run { guard let self else … }` trips + /// Swift 6 region isolation on the `weak self` transfer. + private func applyRebuiltItems( + _ built: [MessageItem], + capturedID: UInt64, + postApply: (@MainActor () -> Void)? + ) { + guard !Task.isCancelled else { return } + let new = renderState.with(items: built, itemIndexByID: built.indexByID()) + let applied = setRenderState(new, capturedID: capturedID) + if !applied { + // A fresher mutation landed mid-flight. Notify the bound + // view model so it can reassemble per-message inputs and + // call rebuildItems again. + renderStateInvalidated?() + return } + postApply?() + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift index 2f9ca3a8..3fb24be0 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift @@ -1,150 +1,148 @@ import Foundation public extension ChatCoordinator { + /// Single chokepoint for ack / retry / fail / heard-repeat / reaction + /// events. Unions IDs into `pendingReloadIDs` and schedules a coalesced + /// load if one is not already in flight. The load takes an atomic + /// snapshot of the buffer, clears it, fetches fresh DTOs from the + /// store, and applies. No event is ever dropped because no event asks + /// "is this in render state?". + func enqueueReload(updatedMessageIDs: Set) { + pendingReloadIDs.formUnion(updatedMessageIDs) + scheduleCoalescedReload() + } - /// Single chokepoint for ack / retry / fail / heard-repeat / reaction - /// events. Unions IDs into `pendingReloadIDs` and schedules a coalesced - /// load if one is not already in flight. The load takes an atomic - /// snapshot of the buffer, clears it, fetches fresh DTOs from the - /// store, and applies. No event is ever dropped because no event asks - /// "is this in render state?". - func enqueueReload(updatedMessageIDs: Set) { - pendingReloadIDs.formUnion(updatedMessageIDs) - scheduleCoalescedReload() - } + /// Convenience for the single-ID hot path. + func enqueueReload(messageID: UUID) { + enqueueReload(updatedMessageIDs: [messageID]) + } - /// Convenience for the single-ID hot path. - func enqueueReload(messageID: UUID) { - enqueueReload(updatedMessageIDs: [messageID]) + private func scheduleCoalescedReload() { + guard !reloadInFlight, !hardResetInFlight else { return } + reloadInFlight = true + coalescedReloadTask = Task { [weak self] in + await self?.coalescedReload() } + } - private func scheduleCoalescedReload() { - guard !reloadInFlight, !hardResetInFlight else { return } - reloadInFlight = true - coalescedReloadTask = Task { [weak self] in - await self?.coalescedReload() - } + /// Drain `pendingReloadIDs` until empty: snapshot, clear, fetch, apply. + /// Re-checks at the top of the loop so events landing during a fetch + /// are reconciled in the next iteration. The `hardResetInFlight` break + /// stops a mid-flight drain from stomping post-hardReset state with + /// stale per-ID updates; the scheduler guard above prevents new + /// Tasks, the break stops the running one. + private func coalescedReload() async { + while !pendingReloadIDs.isEmpty { + if hardResetInFlight { break } + let snapshot = pendingReloadIDs + pendingReloadIDs.removeAll(keepingCapacity: true) + await applyReloadedIDs(snapshot) } + reloadInFlight = false + } - /// Drain `pendingReloadIDs` until empty: snapshot, clear, fetch, apply. - /// Re-checks at the top of the loop so events landing during a fetch - /// are reconciled in the next iteration. The `hardResetInFlight` break - /// stops a mid-flight drain from stomping post-hardReset state with - /// stale per-ID updates; the scheduler guard above prevents new - /// Tasks, the break stops the running one. - private func coalescedReload() async { - while !pendingReloadIDs.isEmpty { - if hardResetInFlight { break } - let snapshot = pendingReloadIDs - pendingReloadIDs.removeAll(keepingCapacity: true) - await applyReloadedIDs(snapshot) + /// Per-ID fetch + in-place update. Routes through `dataStore` bound at + /// construction. A fetch returning nil for an ID that the coordinator + /// still holds is treated as inconsistency and triggers `hardReset`. + /// After each successful refresh, `renderItemRebuilder` rebuilds the + /// corresponding `MessageItem` so ack / retry / fail / heard-repeat / + /// reaction events refresh the rendered bubble without a full + /// `buildItems()` cycle. + private func applyReloadedIDs(_ ids: Set) async { + var inconsistencyDetected = false + var refreshedIDs: [UUID] = [] + for id in ids { + do { + if let fetched = try await dataStore.fetchMessage(id: id) { + guard messagesByID[id] != nil else { continue } + update(messageID: id) { dto in + dto = fetched + } + refreshedIDs.append(id) + } else if messagesByID[id] != nil { + inconsistencyDetected = true + logger.warning("applyReloadedIDs: fetch returned nil for known id \(id, privacy: .public)") } - reloadInFlight = false + } catch { + logger.warning("applyReloadedIDs fetch failed for \(id, privacy: .public): \(String(describing: error))") + } } - - /// Per-ID fetch + in-place update. Routes through `dataStore` bound at - /// construction. A fetch returning nil for an ID that the coordinator - /// still holds is treated as inconsistency and triggers `hardReset`. - /// After each successful refresh, `renderItemRebuilder` rebuilds the - /// corresponding `MessageItem` so ack / retry / fail / heard-repeat / - /// reaction events refresh the rendered bubble without a full - /// `buildItems()` cycle. - private func applyReloadedIDs(_ ids: Set) async { - var inconsistencyDetected = false - var refreshedIDs: [UUID] = [] - for id in ids { - do { - if let fetched = try await dataStore.fetchMessage(id: id) { - guard messagesByID[id] != nil else { continue } - update(messageID: id) { dto in - dto = fetched - } - refreshedIDs.append(id) - } else if messagesByID[id] != nil { - inconsistencyDetected = true - logger.warning("applyReloadedIDs: fetch returned nil for known id \(id, privacy: .public)") - } - } catch { - logger.warning("applyReloadedIDs fetch failed for \(id, privacy: .public): \(String(describing: error))") - } - } - if let rebuilder = renderItemRebuilder { - for id in refreshedIDs { - rebuilder(id) - } - } - if inconsistencyDetected { - hardReset(reason: "fetch returned nil for in-memory message") - } + if let rebuilder = renderItemRebuilder { + for id in refreshedIDs { + rebuilder(id) + } + } + if inconsistencyDetected { + hardReset(reason: "fetch returned nil for in-memory message") } + } - /// Fail-safe: drop all state and re-fetch from the data store. - /// Triggered when an internal invariant trips — currently only from - /// `applyReloadedIDs` when an expected fetch returns nil for a message - /// the coordinator still holds. - /// - /// Call-site invariant: `hardReset` is intended to be invoked from - /// `applyReloadedIDs`, which is itself running inside the in-flight - /// `coalescedReload` Task. The `hardResetInFlight` flag plus the - /// `coalescedReload` while-loop break ensure that the calling Task - /// exits without draining `pendingReloadIDs` after the refetch. A - /// future caller invoking `hardReset` from outside the coalescedReload - /// loop (a button, a remote-reset event, etc.) must either route - /// through the same `applyReloadedIDs` chokepoint or await the active - /// reload Task first — otherwise an in-flight `applyReloadedIDs` can - /// resume after `replaceAll(fresh)` and stomp the freshly-loaded - /// state with stale per-ID `update(messageID:)` writes. - func hardReset(reason: String) { - logger.warning("ChatCoordinator hardReset: \(reason, privacy: .public)") - hardResetInFlight = true - let id = conversationID - let dataStore = self.dataStore - hardResetTask = Task { [weak self] in - defer { - // Single cleanup site converges success and error paths. - // Buffered IDs from the hardReset window are guaranteed to - // drain. The Task body is @MainActor-isolated by - // ChatCoordinator's @MainActor attribute, so defer fires on - // the main actor — no nested Task hop needed. - self?.hardResetInFlight = false - if let self, !self.pendingReloadIDs.isEmpty { - self.scheduleCoalescedReload() - } - } - do { - let fresh: [MessageDTO] - switch id.conversation { - case .dm(let contactID): - fresh = try await dataStore.fetchMessages( - contactID: contactID, - limit: Self.pageSize, - offset: 0 - ) - case .channel(let channelIndex): - fresh = try await dataStore.fetchMessages( - radioID: id.radioID, - channelIndex: channelIndex, - limit: Self.pageSize, - offset: 0 - ) - } - guard let self else { return } - self.replaceAll(fresh) - self.renderStateInvalidated?() - } catch { - self?.logger.error("hardReset refetch failed: \(String(describing: error))") - } + /// Fail-safe: drop all state and re-fetch from the data store. + /// Triggered when an internal invariant trips — currently only from + /// `applyReloadedIDs` when an expected fetch returns nil for a message + /// the coordinator still holds. + /// + /// Call-site invariant: `hardReset` is intended to be invoked from + /// `applyReloadedIDs`, which is itself running inside the in-flight + /// `coalescedReload` Task. The `hardResetInFlight` flag plus the + /// `coalescedReload` while-loop break ensure that the calling Task + /// exits without draining `pendingReloadIDs` after the refetch. A + /// future caller invoking `hardReset` from outside the coalescedReload + /// loop (a button, a remote-reset event, etc.) must either route + /// through the same `applyReloadedIDs` chokepoint or await the active + /// reload Task first — otherwise an in-flight `applyReloadedIDs` can + /// resume after `replaceAll(fresh)` and stomp the freshly-loaded + /// state with stale per-ID `update(messageID:)` writes. + func hardReset(reason: String) { + logger.warning("ChatCoordinator hardReset: \(reason, privacy: .public)") + hardResetInFlight = true + let id = conversationID + let dataStore = dataStore + hardResetTask = Task { [weak self] in + defer { + // Single cleanup site converges success and error paths. + // Buffered IDs from the hardReset window are guaranteed to + // drain. The Task body is @MainActor-isolated by + // ChatCoordinator's @MainActor attribute, so defer fires on + // the main actor — no nested Task hop needed. + self?.hardResetInFlight = false + if let self, !self.pendingReloadIDs.isEmpty { + self.scheduleCoalescedReload() } + } + do { + let fresh: [MessageDTO] = switch id.conversation { + case let .dm(contactID): + try await dataStore.fetchMessages( + contactID: contactID, + limit: Self.pageSize, + offset: 0 + ) + case let .channel(channelIndex): + try await dataStore.fetchMessages( + radioID: id.radioID, + channelIndex: channelIndex, + limit: Self.pageSize, + offset: 0 + ) + } + guard let self else { return } + replaceAll(fresh) + renderStateInvalidated?() + } catch { + self?.logger.error("hardReset refetch failed: \(String(describing: error))") + } } + } - /// Cancel any in-flight maintenance Tasks owned by this coordinator. - /// Called from `ChatCoordinatorRegistry.tearDown` before the registry - /// drops its strong references so suspended drain loops do not keep - /// the coordinator (and its captured `dataStore`) alive past the - /// container's lifetime. - func cancelInFlight() { - buildItemsTask?.cancel() - coalescedReloadTask?.cancel() - hardResetTask?.cancel() - } + /// Cancel any in-flight maintenance Tasks owned by this coordinator. + /// Called from `ChatCoordinatorRegistry.tearDown` before the registry + /// drops its strong references so suspended drain loops do not keep + /// the coordinator (and its captured `dataStore`) alive past the + /// container's lifetime. + func cancelInFlight() { + buildItemsTask?.cancel() + coalescedReloadTask?.cancel() + hardResetTask?.cancel() + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift index 7bba7b3e..3dfe25a3 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift @@ -14,139 +14,138 @@ import OSLog @Observable @MainActor public final class ChatCoordinator { - - /// Number of messages fetched per pagination page. Used by `hardReset` - /// to refetch the most recent slice; consumed by `ChatViewModel` for - /// initial-load sizing so post-reset renders match the normal load. - public static let pageSize: Int = 50 - - public let conversationID: ChatConversationID - - @ObservationIgnored - let logger = Logger(subsystem: "com.mc1", category: "ChatCoordinator") - - /// Canonical loaded-messages list. Mutated only inside this class; - /// every reader either reads `messagesByID` (O(1) lookup) or - /// `renderState.items` (the rendered timeline). - public internal(set) var messages: [MessageDTO] = [] - - /// O(1) lookup keyed by message ID. The guard at every append / - /// update / event-handler site reads this, never `renderState`. - /// - /// Pairing invariant: every `messagesByID` mutation must trigger a - /// downstream `renderState.items` change — typically via the - /// `renderStateID` bump + off-main `rebuildItems` → `setRenderState` - /// apply chain on `ChatCoordinator`. View-body callers reach this map - /// only transitively through observation-tracked `renderState.items`; - /// cells re-render when items change. Any mutation that bypasses the - /// standard mutate-then-renderStateID-bump flow must manually - /// invalidate `renderState.items`, or drop `@ObservationIgnored` here. - @ObservationIgnored - public internal(set) var messagesByID: [UUID: MessageDTO] = [:] - - /// Immutable timeline snapshot rendered by the chat table. Rebuilt - /// from `messages` by the off-main builder; assigned on main only. - public internal(set) var renderState: ChatRenderState = .empty - - /// Monotonic counter incremented on every mutation of `messages` or - /// every assignment to `renderState`. The off-main build captures - /// this at start and discards its result if the counter has advanced - /// when it returns to main. Companion `urlDetectionGeneration` on the - /// view model gates `cachedURLs` writes from the URL-detection writer. - /// Consumed by the off-main builder and internal mutation tracking - /// only — no view body reads it. - @ObservationIgnored - public internal(set) var renderStateID: UInt64 = 0 - - /// IDs accumulated since the last load cycle. The next coalesced load - /// drains this set atomically. `enqueueReload(updatedMessageIDs:)` is - /// the single chokepoint for ack / retry / fail / heard-repeat / - /// reaction events. `@ObservationIgnored` because no view reads this - /// set directly — readers consume `renderState` after the load cycle - /// applies — and the registrar bookkeeping on every burst-event union - /// would be wasted work. - @ObservationIgnored - var pendingReloadIDs: Set = [] - - /// Whether a load cycle is currently in flight. The chase-the-counter - /// pattern in `coalescedReload` consults this. Also - /// `@ObservationIgnored` for the same reason as `pendingReloadIDs`. - @ObservationIgnored - var reloadInFlight = false - - /// Gates concurrent loads while a `hardReset` is mid-flight. The - /// scheduler guard prevents new `coalescedReload` Tasks from starting - /// after a hardReset begins; the loop-top check in `coalescedReload` - /// stops the already-running Task from draining `pendingReloadIDs` and - /// stomping the freshly-refetched post-hardReset state with stale - /// per-ID `update(messageID:)` writes. Cleared on hardReset completion - /// via a `defer`-driven cleanup that also schedules any buffered IDs. - @ObservationIgnored - var hardResetInFlight = false - - /// In-flight off-main batch build. Cancelled before each new - /// `rebuildItems` call so successive rebuilds do not pile up concurrent - /// work. Mirrors the cancel-and-reassign pattern used by URL detection. - @ObservationIgnored - public internal(set) var buildItemsTask: Task? - - /// In-flight coalesced-reload drain Task. Stored so the registry can - /// cancel it on `tearDown`, releasing the coordinator and any captured - /// services in flight. The `reloadInFlight` flag still serves a separate - /// concurrency purpose (break-the-running-loop semantics inside - /// `coalescedReload`); the Task handle is purely for teardown. - @ObservationIgnored - public internal(set) var coalescedReloadTask: Task? - - /// In-flight hardReset refetch Task. See `coalescedReloadTask` for the - /// teardown rationale. - @ObservationIgnored - public internal(set) var hardResetTask: Task? - - /// Data store used by `applyReloadedIDs` for per-ID fetches. Bound at - /// construction by the registry. `@ObservationIgnored` — never read - /// from a view body. - @ObservationIgnored - let dataStore: PersistenceStore - - /// Per-ID render-item rebuild hook invoked by `applyReloadedIDs` after a - /// successful DTO refresh. The bound `ChatViewModel` rebuilds the - /// corresponding `MessageItem` using its main-actor-only inputs - /// (preview state, cached URLs, decoded images). Stays `nil` when no - /// view model is bound — `applyReloadedIDs` then refreshes DTOs without - /// rebuilding render items, which matches headless and test usage. - /// `@ObservationIgnored` because no view body reads this closure. - @ObservationIgnored - public var renderItemRebuilder: (@MainActor (UUID) -> Void)? - - /// Fires when the coordinator's `renderState.items` no longer reflects - /// canonical `messages` and the bound view model must reassemble per-message - /// inputs (preview state, cached URLs, decoded images live on main and are - /// owned by the VM) before `rebuildItems` can be called again. Triggered - /// when a fresher mutation lands mid-flight and `setRenderState` rejects - /// the stale rebuild, and after `hardReset`'s `replaceAll`. - @ObservationIgnored - public var renderStateInvalidated: (@MainActor () -> Void)? - - init( - conversationID: ChatConversationID, - dataStore: PersistenceStore - ) { - self.conversationID = conversationID - self.dataStore = dataStore - } - - #if DEBUG + /// Number of messages fetched per pagination page. Used by `hardReset` + /// to refetch the most recent slice; consumed by `ChatViewModel` for + /// initial-load sizing so post-reset renders match the normal load. + public static let pageSize: Int = 50 + + public let conversationID: ChatConversationID + + @ObservationIgnored + let logger = Logger(subsystem: "com.mc1", category: "ChatCoordinator") + + /// Canonical loaded-messages list. Mutated only inside this class; + /// every reader either reads `messagesByID` (O(1) lookup) or + /// `renderState.items` (the rendered timeline). + public internal(set) var messages: [MessageDTO] = [] + + /// O(1) lookup keyed by message ID. The guard at every append / + /// update / event-handler site reads this, never `renderState`. + /// + /// Pairing invariant: every `messagesByID` mutation must trigger a + /// downstream `renderState.items` change — typically via the + /// `renderStateID` bump + off-main `rebuildItems` → `setRenderState` + /// apply chain on `ChatCoordinator`. View-body callers reach this map + /// only transitively through observation-tracked `renderState.items`; + /// cells re-render when items change. Any mutation that bypasses the + /// standard mutate-then-renderStateID-bump flow must manually + /// invalidate `renderState.items`, or drop `@ObservationIgnored` here. + @ObservationIgnored + public internal(set) var messagesByID: [UUID: MessageDTO] = [:] + + /// Immutable timeline snapshot rendered by the chat table. Rebuilt + /// from `messages` by the off-main builder; assigned on main only. + public internal(set) var renderState: ChatRenderState = .empty + + /// Monotonic counter incremented on every mutation of `messages` or + /// every assignment to `renderState`. The off-main build captures + /// this at start and discards its result if the counter has advanced + /// when it returns to main. Companion `urlDetectionGeneration` on the + /// view model gates `cachedURLs` writes from the URL-detection writer. + /// Consumed by the off-main builder and internal mutation tracking + /// only — no view body reads it. + @ObservationIgnored + public internal(set) var renderStateID: UInt64 = 0 + + /// IDs accumulated since the last load cycle. The next coalesced load + /// drains this set atomically. `enqueueReload(updatedMessageIDs:)` is + /// the single chokepoint for ack / retry / fail / heard-repeat / + /// reaction events. `@ObservationIgnored` because no view reads this + /// set directly — readers consume `renderState` after the load cycle + /// applies — and the registrar bookkeeping on every burst-event union + /// would be wasted work. + @ObservationIgnored + var pendingReloadIDs: Set = [] + + /// Whether a load cycle is currently in flight. The chase-the-counter + /// pattern in `coalescedReload` consults this. Also + /// `@ObservationIgnored` for the same reason as `pendingReloadIDs`. + @ObservationIgnored + var reloadInFlight = false + + /// Gates concurrent loads while a `hardReset` is mid-flight. The + /// scheduler guard prevents new `coalescedReload` Tasks from starting + /// after a hardReset begins; the loop-top check in `coalescedReload` + /// stops the already-running Task from draining `pendingReloadIDs` and + /// stomping the freshly-refetched post-hardReset state with stale + /// per-ID `update(messageID:)` writes. Cleared on hardReset completion + /// via a `defer`-driven cleanup that also schedules any buffered IDs. + @ObservationIgnored + var hardResetInFlight = false + + /// In-flight off-main batch build. Cancelled before each new + /// `rebuildItems` call so successive rebuilds do not pile up concurrent + /// work. Mirrors the cancel-and-reassign pattern used by URL detection. + @ObservationIgnored + public internal(set) var buildItemsTask: Task? + + /// In-flight coalesced-reload drain Task. Stored so the registry can + /// cancel it on `tearDown`, releasing the coordinator and any captured + /// services in flight. The `reloadInFlight` flag still serves a separate + /// concurrency purpose (break-the-running-loop semantics inside + /// `coalescedReload`); the Task handle is purely for teardown. + @ObservationIgnored + public internal(set) var coalescedReloadTask: Task? + + /// In-flight hardReset refetch Task. See `coalescedReloadTask` for the + /// teardown rationale. + @ObservationIgnored + public internal(set) var hardResetTask: Task? + + /// Data store used by `applyReloadedIDs` for per-ID fetches. Bound at + /// construction by the registry. `@ObservationIgnored` — never read + /// from a view body. + @ObservationIgnored + let dataStore: PersistenceStore + + /// Per-ID render-item rebuild hook invoked by `applyReloadedIDs` after a + /// successful DTO refresh. The bound `ChatViewModel` rebuilds the + /// corresponding `MessageItem` using its main-actor-only inputs + /// (preview state, cached URLs, decoded images). Stays `nil` when no + /// view model is bound — `applyReloadedIDs` then refreshes DTOs without + /// rebuilding render items, which matches headless and test usage. + /// `@ObservationIgnored` because no view body reads this closure. + @ObservationIgnored + public var renderItemRebuilder: (@MainActor (UUID) -> Void)? + + /// Fires when the coordinator's `renderState.items` no longer reflects + /// canonical `messages` and the bound view model must reassemble per-message + /// inputs (preview state, cached URLs, decoded images live on main and are + /// owned by the VM) before `rebuildItems` can be called again. Triggered + /// when a fresher mutation lands mid-flight and `setRenderState` rejects + /// the stale rebuild, and after `hardReset`'s `replaceAll`. + @ObservationIgnored + public var renderStateInvalidated: (@MainActor () -> Void)? + + init( + conversationID: ChatConversationID, + dataStore: PersistenceStore + ) { + self.conversationID = conversationID + self.dataStore = dataStore + } + + #if DEBUG /// Test-only factory that builds a standalone coordinator backed by /// an in-memory `PersistenceStore`. Lets unit tests exercise mutation /// behaviour without bringing up a `ServiceContainer`. public static func makeForTesting( - conversationID: ChatConversationID = .dm(radioID: UUID(), contactID: UUID()) + conversationID: ChatConversationID = .dm(radioID: UUID(), contactID: UUID()) ) -> ChatCoordinator { - // swiftlint:disable:next force_try - let container = try! PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - return ChatCoordinator(conversationID: conversationID, dataStore: store) + // swiftlint:disable:next force_try + let container = try! PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + return ChatCoordinator(conversationID: conversationID, dataStore: store) } - #endif + #endif } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift index 1c6ecfdc..b86281df 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift @@ -15,53 +15,52 @@ import Foundation /// no view should observe its internal entries. @MainActor public final class ChatCoordinatorRegistry { + public static let defaultCapacity = 16 - public static let defaultCapacity = 16 + private var entries: [(id: ChatConversationID, coordinator: ChatCoordinator)] = [] + private let capacity: Int + private(set) var dataStore: PersistenceStore - private var entries: [(id: ChatConversationID, coordinator: ChatCoordinator)] = [] - private let capacity: Int - private(set) var dataStore: PersistenceStore + public init( + dataStore: PersistenceStore, + capacity: Int = ChatCoordinatorRegistry.defaultCapacity + ) { + self.dataStore = dataStore + self.capacity = capacity + } - public init( - dataStore: PersistenceStore, - capacity: Int = ChatCoordinatorRegistry.defaultCapacity - ) { - self.dataStore = dataStore - self.capacity = capacity + /// Returns the coordinator for the given conversation, creating one on + /// first request. Repeat reads promote the entry to most-recently-used. + /// Two view models pointing at the same conversation share one coordinator. + public func coordinator(for id: ChatConversationID) -> ChatCoordinator { + if let index = entries.firstIndex(where: { $0.id == id }) { + let entry = entries.remove(at: index) + entries.append(entry) + return entry.coordinator } - - /// Returns the coordinator for the given conversation, creating one on - /// first request. Repeat reads promote the entry to most-recently-used. - /// Two view models pointing at the same conversation share one coordinator. - public func coordinator(for id: ChatConversationID) -> ChatCoordinator { - if let index = entries.firstIndex(where: { $0.id == id }) { - let entry = entries.remove(at: index) - entries.append(entry) - return entry.coordinator - } - let coordinator = ChatCoordinator( - conversationID: id, - dataStore: dataStore - ) - entries.append((id, coordinator)) - while entries.count > capacity { - let evicted = entries.removeFirst() - evicted.coordinator.cancelInFlight() - } - return coordinator + let coordinator = ChatCoordinator( + conversationID: id, + dataStore: dataStore + ) + entries.append((id, coordinator)) + while entries.count > capacity { + let evicted = entries.removeFirst() + evicted.coordinator.cancelInFlight() } + return coordinator + } - public func rebind(dataStore: PersistenceStore) { - tearDown() - self.dataStore = dataStore - } + public func rebind(dataStore: PersistenceStore) { + tearDown() + self.dataStore = dataStore + } - /// Cancel in-flight builds and drain Tasks on every coordinator and - /// drop all entries. - public func tearDown() { - for entry in entries { - entry.coordinator.cancelInFlight() - } - entries.removeAll() + /// Cancel in-flight builds and drain Tasks on every coordinator and + /// drop all entries. + public func tearDown() { + for entry in entries { + entry.coordinator.cancelInFlight() } + entries.removeAll() + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatSendQueueService.swift b/MC1Services/Sources/MC1Services/Services/ChatSendQueueService.swift index bd64b117..1fdb6cbe 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatSendQueueService.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatSendQueueService.swift @@ -4,46 +4,46 @@ import os // MARK: - Chat Send Queue Service Errors public enum ChatSendQueueServiceError: Error, Sendable, LocalizedError { - case persistFailed(underlying: Error) - case notConnected - - public var errorDescription: String? { - switch self { - case .persistFailed(let underlying): - return "Failed to queue message for sending: \(underlying.localizedDescription)" - case .notConnected: - return "Not connected to device." - } + case persistFailed(underlying: Error) + case notConnected + + public var errorDescription: String? { + switch self { + case let .persistFailed(underlying): + "Failed to queue message for sending: \(underlying.localizedDescription)" + case .notConnected: + "Not connected to device." } + } } /// Retry timing for `ChatSendQueueService`. -struct ChatSendQueueConfig: Sendable { - /// Maximum time to wait for a transport-open trigger before - /// silently re-attempting the send. - let transportWaitTimeout: TimeInterval - - /// Number of channel-drain attempts before the queue spends a BLE - /// round-trip to disambiguate transient NOT_FOUND (pool exhaustion) - /// from terminal NOT_FOUND (channel deleted on the device). - let disambiguateAfterAttempts: Int - - /// Backstop cap on consecutive `fetchChannel` throws. After this many - /// the channel drain treats NOT_FOUND as terminal so the user sees - /// `.failed` and can manually retry. - let maxConsecutiveFetchChannelFailures: Int - - init( - transportWaitTimeout: TimeInterval = 30, - disambiguateAfterAttempts: Int = 3, - maxConsecutiveFetchChannelFailures: Int = 16 - ) { - self.transportWaitTimeout = transportWaitTimeout - self.disambiguateAfterAttempts = disambiguateAfterAttempts - self.maxConsecutiveFetchChannelFailures = maxConsecutiveFetchChannelFailures - } - - static let `default` = ChatSendQueueConfig() +struct ChatSendQueueConfig { + /// Maximum time to wait for a transport-open trigger before + /// silently re-attempting the send. + let transportWaitTimeout: TimeInterval + + /// Number of channel-drain attempts before the queue spends a BLE + /// round-trip to disambiguate transient NOT_FOUND (pool exhaustion) + /// from terminal NOT_FOUND (channel deleted on the device). + let disambiguateAfterAttempts: Int + + /// Backstop cap on consecutive `fetchChannel` throws. After this many + /// the channel drain treats NOT_FOUND as terminal so the user sees + /// `.failed` and can manually retry. + let maxConsecutiveFetchChannelFailures: Int + + init( + transportWaitTimeout: TimeInterval = 30, + disambiguateAfterAttempts: Int = 3, + maxConsecutiveFetchChannelFailures: Int = 16 + ) { + self.transportWaitTimeout = transportWaitTimeout + self.disambiguateAfterAttempts = disambiguateAfterAttempts + self.maxConsecutiveFetchChannelFailures = maxConsecutiveFetchChannelFailures + } + + static let `default` = ChatSendQueueConfig() } /// Owns the DM and channel send queues for a connection. Replaces the @@ -67,658 +67,657 @@ struct ChatSendQueueConfig: Sendable { /// failed send needs it. @MainActor public final class ChatSendQueueService { - - let radioID: UUID - private let config: ChatSendQueueConfig - private let dataStore: any MessagePersisting & ContactPersisting - private let messageService: MessageService - private let channelService: ChannelService - private let reactionService: ReactionService - private let triggers: BLETransportOpenedSignal - private let channelFetchFailureCounter = FailureCounter() - private let logger = PersistentLogger(subsystem: "com.mc1", category: "ChatSendQueueService") - private let osLogger = Logger(subsystem: "com.mc1", category: "ChatSendQueueService") - - private let dmQueue: SendQueue - private let channelQueue: SendQueue - - private var hasHydrated = false - - /// Task consuming the connection-state stream installed by - /// `observeConnectionState`. Cancelled in `shutdown()`. - private var connectionStateTask: Task? - - // swiftlint:disable:next function_body_length - init( - radioID: UUID, - dataStore: any MessagePersisting & ContactPersisting, - messageService: MessageService, - channelService: ChannelService, - reactionService: ReactionService, - config: ChatSendQueueConfig = .default - ) { - self.radioID = radioID - self.config = config - self.dataStore = dataStore - self.messageService = messageService - self.channelService = channelService - self.reactionService = reactionService - self.triggers = BLETransportOpenedSignal() - - // Capture by value into closures; queues outlive references but - // are torn down with the service. - let triggers = self.triggers - let dataStoreRef = dataStore - let messageServiceRef = messageService - let channelServiceRef = channelService - let reactionServiceRef = reactionService - let loggerRef = logger - let osLoggerRef = osLogger - let failureCounter = channelFetchFailureCounter - let configRef = config - - self.dmQueue = SendQueue( - send: { envelope in - // Outer catch: the queue-routed catch sites in MessageService - // do not broadcast `.failed` themselves; the helper - // `failMessageAndRethrow` only writes the DB state and - // rethrows. Any non-`CancellationError` escape from this - // closure is a terminal failure for the envelope, so the - // queue calls `notifyMessageFailed` exactly once before - // letting `SendQueue.drain` propagate the error to `onError`. - // Park-and-requeue branches throw `CancellationError`, hit - // the inner re-throw, and bypass the broadcast so a - // transient error does not produce a `.failed`-then-`.pending` - // flicker on the UI. - do { - let contact: ContactDTO - switch await ChatSendQueueService.classifyRead({ try await dataStoreRef.fetchContact(id: envelope.contactID) }) { - case .found(let value): - contact = value - case .missing: - loggerRef.info("DM send queue: contact \(envelope.contactID) was deleted; dropping envelope") - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - try? await dataStoreRef.updateMessageStatus(id: envelope.messageID, status: .failed) - return - case .transient(let error): - loggerRef.warning("DM drain fetchContact transient error: \(String(describing: error)); parking envelope") - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "DM", - timeout: configRef.transportWaitTimeout - ) - } - - // Pre-send gate + attemptCount bump. Persisted attemptCount - // becomes the source of truth for preserveTimestamp. Bump - // completes (and modelContext.save() commits) before any - // wire-affecting work. - let preserveTimestamp: Bool - do { - guard let result = try await ChatSendQueueService.preflightAndBump( - dataStore: dataStoreRef, - messageID: envelope.messageID, - kind: "DM", - logger: loggerRef, - osLogger: osLoggerRef - ) else { return } - preserveTimestamp = result.preserveTimestamp - } catch { - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "DM", - timeout: configRef.transportWaitTimeout - ) - } - - do { - if envelope.isResend { - _ = try await messageServiceRef.resendDirectMessage( - messageID: envelope.messageID, - to: contact, - preserveTimestamp: preserveTimestamp - ) - } else { - _ = try await messageServiceRef.sendPendingDirectMessage( - messageID: envelope.messageID, - to: contact, - preserveTimestamp: preserveTimestamp - ) - } - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - osLoggerRef.debug("DM drain success messageID=\(envelope.messageID)") - // Success: clear any armed trigger now that the row - // is gone. A subsequent failed send for a different - // envelope re-arms on its own when the next - // transport-open fires. - await triggers.clear() - } catch is CancellationError { - throw CancellationError() - } catch { - guard ChatSendQueueService.isTransientDirectMessageError(error) else { - loggerRef.info("DM drain terminal messageID=\(envelope.messageID) error=\(String(describing: error))") - throw error - } - loggerRef.info("DM drain transient messageID=\(envelope.messageID) error=\(String(describing: error))") - // Remap the .failed write that failMessageAndRethrow just - // made back to .pending so the bubble doesn't show - // "Failed" while the queue is silently parked. Duplicate - // prevention on the next pass is via preserveTimestamp, - // not via any boundary check. - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "DM", - timeout: configRef.transportWaitTimeout - ) - } - } catch let cancellation as CancellationError { - throw cancellation - } catch { - await messageServiceRef.notifyMessageFailed(messageID: envelope.messageID) - throw error - } - }, - onError: { _, envelope in - // Permanent error path only — transient errors take the - // wait-and-retry branch above and never reach onError. - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - }, - onDrain: { lastError in - if let lastError { - loggerRef.error("DM queue drained with error: \(String(describing: lastError))") - } + let radioID: UUID + private let config: ChatSendQueueConfig + private let dataStore: any MessagePersisting & ContactPersisting + private let messageService: MessageService + private let channelService: ChannelService + private let reactionService: ReactionService + private let triggers: BLETransportOpenedSignal + private let channelFetchFailureCounter = FailureCounter() + private let logger = PersistentLogger(subsystem: "com.mc1", category: "ChatSendQueueService") + private let osLogger = Logger(subsystem: "com.mc1", category: "ChatSendQueueService") + + private let dmQueue: SendQueue + private let channelQueue: SendQueue + + private var hasHydrated = false + + /// Task consuming the connection-state stream installed by + /// `observeConnectionState`. Cancelled in `shutdown()`. + private var connectionStateTask: Task? + + // swiftlint:disable:next function_body_length + init( + radioID: UUID, + dataStore: any MessagePersisting & ContactPersisting, + messageService: MessageService, + channelService: ChannelService, + reactionService: ReactionService, + config: ChatSendQueueConfig = .default + ) { + self.radioID = radioID + self.config = config + self.dataStore = dataStore + self.messageService = messageService + self.channelService = channelService + self.reactionService = reactionService + self.triggers = BLETransportOpenedSignal() + + // Capture by value into closures; queues outlive references but + // are torn down with the service. + let triggers = triggers + let dataStoreRef = dataStore + let messageServiceRef = messageService + let channelServiceRef = channelService + let reactionServiceRef = reactionService + let loggerRef = logger + let osLoggerRef = osLogger + let failureCounter = channelFetchFailureCounter + let configRef = config + + dmQueue = SendQueue( + send: { envelope in + // Outer catch: the queue-routed catch sites in MessageService + // do not broadcast `.failed` themselves; the helper + // `failMessageAndRethrow` only writes the DB state and + // rethrows. Any non-`CancellationError` escape from this + // closure is a terminal failure for the envelope, so the + // queue calls `notifyMessageFailed` exactly once before + // letting `SendQueue.drain` propagate the error to `onError`. + // Park-and-requeue branches throw `CancellationError`, hit + // the inner re-throw, and bypass the broadcast so a + // transient error does not produce a `.failed`-then-`.pending` + // flicker on the UI. + do { + let contact: ContactDTO + switch await ChatSendQueueService.classifyRead({ try await dataStoreRef.fetchContact(id: envelope.contactID) }) { + case let .found(value): + contact = value + case .missing: + loggerRef.info("DM send queue: contact \(envelope.contactID) was deleted; dropping envelope") + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + try? await dataStoreRef.updateMessageStatus(id: envelope.messageID, status: .failed) + return + case let .transient(error): + loggerRef.warning("DM drain fetchContact transient error: \(String(describing: error)); parking envelope") + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "DM", + timeout: configRef.transportWaitTimeout + ) + } + + // Pre-send gate + attemptCount bump. Persisted attemptCount + // becomes the source of truth for preserveTimestamp. Bump + // completes (and modelContext.save() commits) before any + // wire-affecting work. + let preserveTimestamp: Bool + do { + guard let result = try await ChatSendQueueService.preflightAndBump( + dataStore: dataStoreRef, + messageID: envelope.messageID, + kind: "DM", + logger: loggerRef, + osLogger: osLoggerRef + ) else { return } + preserveTimestamp = result.preserveTimestamp + } catch { + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "DM", + timeout: configRef.transportWaitTimeout + ) + } + + do { + if envelope.isResend { + _ = try await messageServiceRef.resendDirectMessage( + messageID: envelope.messageID, + to: contact, + preserveTimestamp: preserveTimestamp + ) + } else { + _ = try await messageServiceRef.sendPendingDirectMessage( + messageID: envelope.messageID, + to: contact, + preserveTimestamp: preserveTimestamp + ) } - ) - - self.channelQueue = SendQueue( - send: { envelope in - // Outer catch: see DM closure for rationale. Park-and-requeue - // branches throw `CancellationError`; any other escape is a - // terminal failure for the envelope and calls - // `notifyMessageFailed` exactly once. - do { - // Pre-send gate + attemptCount bump; see DM closure for rationale. - let postBumpCount: Int - let preserveTimestamp: Bool - do { - guard let result = try await ChatSendQueueService.preflightAndBump( - dataStore: dataStoreRef, - messageID: envelope.messageID, - kind: "channel", - logger: loggerRef, - osLogger: osLoggerRef - ) else { return } - postBumpCount = result.postBumpCount - preserveTimestamp = result.preserveTimestamp - } catch { - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "channel", - timeout: configRef.transportWaitTimeout - ) - } - - do { - // resendChannelMessage stamps a fresh wire timestamp so the - // mesh dedup ring treats the retry as a new broadcast. - // Reactions hash off that exact timestamp via - // SHA256(text || timestamp.littleEndian), so the resent - // packet must be indexed under the post-resend value, not - // the pre-resend value captured at enqueue time. - let indexTimestamp: UInt32 - if envelope.isResend { - indexTimestamp = try await messageServiceRef.resendChannelMessage( - messageID: envelope.messageID, - preserveTimestamp: preserveTimestamp - ) - } else { - try await messageServiceRef.sendPendingChannelMessage(messageID: envelope.messageID) - indexTimestamp = envelope.messageTimestamp - } - if let nodeName = envelope.localNodeName { - _ = await reactionServiceRef.indexMessage( - id: envelope.messageID, - channelIndex: envelope.channelIndex, - senderName: nodeName, - text: envelope.messageText, - timestamp: indexTimestamp - ) - } - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - osLoggerRef.debug("channel drain success messageID=\(envelope.messageID)") - await failureCounter.reset(for: envelope.messageID) - await triggers.clear() - } catch is CancellationError { - throw CancellationError() - } catch { - guard ChatSendQueueService.isTransientChannelMessageError(error) else { - loggerRef.info("channel drain terminal messageID=\(envelope.messageID) error=\(String(describing: error))") - throw error - } - if ChatSendQueueService.isChannelMessageNotFound(error) { - // Disambiguate pool exhaustion (transient) from a stale - // channel index (terminal) by refreshing the radio's - // view of the channel. `ChannelService.fetchChannel(index:)` - // reads from the device, so a nil result means the radio - // agrees the channel is gone (terminal). - // - // Gate on disambiguateAfterAttempts so the common - // pool-exhaustion burst (1-2 NOT_FOUNDs in a row) parks - // without an extra BLE round-trip; only persistent - // NOT_FOUND warrants the fetchChannel cost. - if postBumpCount < configRef.disambiguateAfterAttempts { - loggerRef.info("channel drain NOT_FOUND below disambiguate threshold messageID=\(envelope.messageID) postBumpCount=\(postBumpCount); parking envelope") - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "channel", - timeout: configRef.transportWaitTimeout - ) - } - - let stillExists: Bool - do { - stillExists = try await channelServiceRef.fetchChannel(index: envelope.channelIndex) != nil - await failureCounter.reset(for: envelope.messageID) - } catch { - let failures = await failureCounter.increment(for: envelope.messageID) - if failures >= configRef.maxConsecutiveFetchChannelFailures { - loggerRef.warning("Channel drain: fetchChannel persistently failing (\(failures) consecutive) for messageID=\(envelope.messageID); marking terminal") - await failureCounter.reset(for: envelope.messageID) - throw error - } - loggerRef.info("channel drain NOT_FOUND followup fetchChannel failed: \(String(describing: error)); parking envelope (\(failures)/\(configRef.maxConsecutiveFetchChannelFailures))") - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "channel", - timeout: configRef.transportWaitTimeout - ) - } - if !stillExists { - loggerRef.warning("Channel drain: NOT_FOUND confirmed by fetchChannel for messageID=\(envelope.messageID); treating as terminal (channel deleted)") - await failureCounter.reset(for: envelope.messageID) - throw error - } - // Channel still exists per device — NOT_FOUND was pool - // exhaustion. Park on the transport-open trigger. The - // effective per-attempt cadence under sustained - // exhaustion is ~37s (3.5s withPoolBackoff in-loop + - // 3.5s fetchChannel disambiguation + 30s - // transportWaitTimeout park). Transport-open only fires - // on entering `.ready`, so under a healthy connection - // the 30s park is what bounds the retry rate. - } - loggerRef.info("channel drain transient messageID=\(envelope.messageID) error=\(String(describing: error))") - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "channel", - timeout: configRef.transportWaitTimeout - ) - } - } catch let cancellation as CancellationError { - throw cancellation - } catch { - await messageServiceRef.notifyMessageFailed(messageID: envelope.messageID) - throw error - } - }, - onError: { _, envelope in - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - }, - onDrain: { lastError in - if let lastError { - loggerRef.error("Channel queue drained with error: \(String(describing: lastError))") - } + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + osLoggerRef.debug("DM drain success messageID=\(envelope.messageID)") + // Success: clear any armed trigger now that the row + // is gone. A subsequent failed send for a different + // envelope re-arms on its own when the next + // transport-open fires. + await triggers.clear() + } catch is CancellationError { + throw CancellationError() + } catch { + guard ChatSendQueueService.isTransientDirectMessageError(error) else { + loggerRef.info("DM drain terminal messageID=\(envelope.messageID) error=\(String(describing: error))") + throw error } - ) - } - - /// Enqueue a DM envelope. Persists a `PendingSend` row first; the - /// queue's drain reads it back on the next send attempt. Throws - /// `ChatSendQueueServiceError.persistFailed` if the SwiftData write - /// fails so the caller can surface the failure instead of silently - /// dropping the queued send. - public func enqueueDM(_ envelope: DirectMessageEnvelope) async throws { - try await persist(PendingSendDTO(envelope: envelope, radioID: radioID)) - await dmQueue.enqueue(envelope) - } - - public func enqueueChannel(_ envelope: ChannelMessageEnvelope) async throws { - try await persist(PendingSendDTO(envelope: envelope, radioID: radioID)) - await channelQueue.enqueue(envelope) - } - - /// Signals the DM queue that a `PendingSend` row already exists for - /// this envelope. Used by the manual retry path where - /// `PersistenceStore.replacePendingSendForRetry` has already written - /// the row in one transaction; calling `enqueueDM` here would - /// double-persist. The drain still reads the row back via - /// `hasPendingSend` on the next send attempt, so the in-memory - /// enqueue is the only step left. - public func signalDMEnqueued(_ envelope: DirectMessageEnvelope) async { - await dmQueue.enqueue(envelope) - } - - /// Starts observing the connection-state stream and fires the - /// transport-open trigger exactly once each time the connection enters - /// `.ready`. Gating on `.ready` (not the first `isConnected` edge) keeps - /// hydrated sends parked through the initial-sync window, so they do not - /// contend with sync's reads on the radio's link. In production the container - /// is built while still `.connected`, so the `.ready` wake arrives on the - /// event stream; the initial-value check only fires when the observation is - /// (re)started against an already-`.ready` connection, waking drains parked in - /// `withCooperativeTimeout` instead of leaving them to wait out - /// `transportWaitTimeout`. Firing on "entered `.ready`" covers both the success - /// edge (`.connected → .ready`) and the failure-recovery edge - /// (`.syncing → .ready`). Calling again replaces the previous observation. - func observeConnectionState( - initial: DeviceConnectionState, - events: AsyncStream - ) { - connectionStateTask?.cancel() - if initial.canDrainSendQueue { - transportDidOpen() + loggerRef.info("DM drain transient messageID=\(envelope.messageID) error=\(String(describing: error))") + // Remap the .failed write that failMessageAndRethrow just + // made back to .pending so the bubble doesn't show + // "Failed" while the queue is silently parked. Duplicate + // prevention on the next pass is via preserveTimestamp, + // not via any boundary check. + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "DM", + timeout: configRef.transportWaitTimeout + ) + } + } catch let cancellation as CancellationError { + throw cancellation + } catch { + await messageServiceRef.notifyMessageFailed(messageID: envelope.messageID) + throw error } - connectionStateTask = Task { [weak self] in - var previous = initial - for await state in events { - guard let self else { return } - if !previous.canDrainSendQueue && state.canDrainSendQueue { - self.transportDidOpen() - } - previous = state - } + }, + onError: { _, envelope in + // Permanent error path only — transient errors take the + // wait-and-retry branch above and never reach onError. + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + }, + onDrain: { lastError in + if let lastError { + loggerRef.error("DM queue drained with error: \(String(describing: lastError))") } - } - - /// Fired by the connection-state observation started by - /// `observeConnectionState` each time the connection enters `.ready`. - /// Fires the trigger that wakes any drain attempt suspended in - /// `withCooperativeTimeout`. - /// - /// Fire-and-forget Task is intentional: `triggers.fire` is idempotent - /// (arming an already-armed bit is a no-op), so a tight reconnect cycle - /// (`.connected → .connecting → .connected` within a frame) collapses - /// to a single armed trigger on the actor. Do not extend this function - /// with per-call state — the fire-and-forget shape would lose ordering. - func transportDidOpen() { - osLogger.debug("transportDidOpen firing trigger") - Task { [triggers] in - await triggers.fire() - } - } - - /// Park the drain on the transport-open trigger up to `timeout` seconds. - /// Returns true if the signal fired, false on timeout or - /// cancelled-mid-wait (caller treats both as "requeue"). `nonisolated` - /// so the off-main send closures can call it. - nonisolated static func waitForTransportOpen( - triggers: BLETransportOpenedSignal, - logger: PersistentLogger, - messageID: UUID, - kind: String, - timeout: TimeInterval - ) async throws -> Bool { + } + ) + + channelQueue = SendQueue( + send: { envelope in + // Outer catch: see DM closure for rationale. Park-and-requeue + // branches throw `CancellationError`; any other escape is a + // terminal failure for the envelope and calls + // `notifyMessageFailed` exactly once. do { - try await withCooperativeTimeout(seconds: timeout) { - try await triggers.wait() - } - return true - } catch is CancellationError { - if Task.isCancelled { - logger.info("\(kind) drain cancelled mid-wait for \(messageID); requeueing") + // Pre-send gate + attemptCount bump; see DM closure for rationale. + let postBumpCount: Int + let preserveTimestamp: Bool + do { + guard let result = try await ChatSendQueueService.preflightAndBump( + dataStore: dataStoreRef, + messageID: envelope.messageID, + kind: "channel", + logger: loggerRef, + osLogger: osLoggerRef + ) else { return } + postBumpCount = result.postBumpCount + preserveTimestamp = result.preserveTimestamp + } catch { + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "channel", + timeout: configRef.transportWaitTimeout + ) + } + + do { + // resendChannelMessage stamps a fresh wire timestamp so the + // mesh dedup ring treats the retry as a new broadcast. + // Reactions hash off that exact timestamp via + // SHA256(text || timestamp.littleEndian), so the resent + // packet must be indexed under the post-resend value, not + // the pre-resend value captured at enqueue time. + let indexTimestamp: UInt32 + if envelope.isResend { + indexTimestamp = try await messageServiceRef.resendChannelMessage( + messageID: envelope.messageID, + preserveTimestamp: preserveTimestamp + ) } else { - logger.info("\(kind) drain timeout-without-fire after \(Int(timeout))s for \(messageID); requeueing") + try await messageServiceRef.sendPendingChannelMessage(messageID: envelope.messageID) + indexTimestamp = envelope.messageTimestamp } - return false - } - } - - /// Combines the `hasPendingSend` gate, the top-of-drain `attemptCount` bump, - /// and the `preserveTimestamp` computation that every DM and channel drain - /// runs before any wire-affecting work. Returns nil if the row is gone - /// (terminal — abandon envelope). Throws transient errors so the caller can - /// park and retry; throws nothing on the gate-missing terminal path. - nonisolated static func preflightAndBump( - dataStore: any MessagePersisting, - messageID: UUID, - kind: String, - logger: PersistentLogger, - osLogger: os.Logger - ) async throws -> (postBumpCount: Int, preserveTimestamp: Bool)? { - switch await ChatSendQueueService.classifyRead({ () async throws -> Bool? in - try await dataStore.hasPendingSend(messageID: messageID) ? true : nil - }) { - case .found: - break - case .missing: - logger.info("\(kind) drain abandoned messageID=\(messageID) reason=PendingSendGone") - return nil - case .transient(let error): - logger.warning("\(kind) drain hasPendingSend transient error: \(String(describing: error)); parking envelope") - throw error - } - - let postBumpCount: Int - do { - guard let bumped = try await dataStore.incrementPendingSendAttemptCount(messageID: messageID) else { - logger.info("\(kind) drain: PendingSend row gone for messageID=\(messageID); treating as terminal") - return nil + if let nodeName = envelope.localNodeName { + _ = await reactionServiceRef.indexMessage( + id: envelope.messageID, + channelIndex: envelope.channelIndex, + senderName: nodeName, + text: envelope.messageText, + timestamp: indexTimestamp + ) + } + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + osLoggerRef.debug("channel drain success messageID=\(envelope.messageID)") + await failureCounter.reset(for: envelope.messageID) + await triggers.clear() + } catch is CancellationError { + throw CancellationError() + } catch { + guard ChatSendQueueService.isTransientChannelMessageError(error) else { + loggerRef.info("channel drain terminal messageID=\(envelope.messageID) error=\(String(describing: error))") + throw error + } + if ChatSendQueueService.isChannelMessageNotFound(error) { + // Disambiguate pool exhaustion (transient) from a stale + // channel index (terminal) by refreshing the radio's + // view of the channel. `ChannelService.fetchChannel(index:)` + // reads from the device, so a nil result means the radio + // agrees the channel is gone (terminal). + // + // Gate on disambiguateAfterAttempts so the common + // pool-exhaustion burst (1-2 NOT_FOUNDs in a row) parks + // without an extra BLE round-trip; only persistent + // NOT_FOUND warrants the fetchChannel cost. + if postBumpCount < configRef.disambiguateAfterAttempts { + loggerRef.info("channel drain NOT_FOUND below disambiguate threshold messageID=\(envelope.messageID) postBumpCount=\(postBumpCount); parking envelope") + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "channel", + timeout: configRef.transportWaitTimeout + ) + } + + let stillExists: Bool + do { + stillExists = try await channelServiceRef.fetchChannel(index: envelope.channelIndex) != nil + await failureCounter.reset(for: envelope.messageID) + } catch { + let failures = await failureCounter.increment(for: envelope.messageID) + if failures >= configRef.maxConsecutiveFetchChannelFailures { + loggerRef.warning("Channel drain: fetchChannel persistently failing (\(failures) consecutive) for messageID=\(envelope.messageID); marking terminal") + await failureCounter.reset(for: envelope.messageID) + throw error + } + loggerRef.info("channel drain NOT_FOUND followup fetchChannel failed: \(String(describing: error)); parking envelope (\(failures)/\(configRef.maxConsecutiveFetchChannelFailures))") + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "channel", + timeout: configRef.transportWaitTimeout + ) + } + if !stillExists { + loggerRef.warning("Channel drain: NOT_FOUND confirmed by fetchChannel for messageID=\(envelope.messageID); treating as terminal (channel deleted)") + await failureCounter.reset(for: envelope.messageID) + throw error + } + // Channel still exists per device — NOT_FOUND was pool + // exhaustion. Park on the transport-open trigger. The + // effective per-attempt cadence under sustained + // exhaustion is ~37s (3.5s withPoolBackoff in-loop + + // 3.5s fetchChannel disambiguation + 30s + // transportWaitTimeout park). Transport-open only fires + // on entering `.ready`, so under a healthy connection + // the 30s park is what bounds the retry rate. } - postBumpCount = bumped + loggerRef.info("channel drain transient messageID=\(envelope.messageID) error=\(String(describing: error))") + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "channel", + timeout: configRef.transportWaitTimeout + ) + } + } catch let cancellation as CancellationError { + throw cancellation } catch { - logger.warning("incrementPendingSendAttemptCount failed: \(String(describing: error)); parking envelope for next transport-open") - throw error + await messageServiceRef.notifyMessageFailed(messageID: envelope.messageID) + throw error } - - let preserveTimestamp = postBumpCount > 1 - osLogger.debug("\(kind) drain begin messageID=\(messageID) postBumpCount=\(postBumpCount) preserveTimestamp=\(preserveTimestamp)") - return (postBumpCount: postBumpCount, preserveTimestamp: preserveTimestamp) - } - - /// Awaits a transport-open trigger up to the bounded timeout, then throws - /// `CancellationError` to drive `SendQueue.drain`'s requeue protocol. - /// Sites that need to revert message status to `.pending` before parking - /// must call `updateMessageStatusUnlessDelivered` themselves — this helper - /// does not write status. - nonisolated static func parkAndCancel( - triggers: BLETransportOpenedSignal, - logger: PersistentLogger, - messageID: UUID, - kind: String, - timeout: TimeInterval - ) async throws -> Never { - _ = try await ChatSendQueueService.waitForTransportOpen( - triggers: triggers, - logger: logger, - messageID: messageID, - kind: kind, - timeout: timeout - ) - throw CancellationError() - } - - /// Classify a send error as transient (park the envelope and wait on a - /// transport-open trigger) or terminal (drop the row). The transient - /// `deviceError` code differs between DM and channel paths — see - /// `FirmwareDeviceErrorCode`. - nonisolated static func isTransientError(_ error: Error, deviceCode: UInt8) -> Bool { - if let serviceError = error as? MessageServiceError { - switch serviceError { - case .sessionError(let underlying): - return isTransientError(underlying, deviceCode: deviceCode) - case .notConnected: - return true - case .contactNotFound, .channelNotFound, .sendFailed, - .invalidRecipient, .messageTooLong: - return false - } + }, + onError: { _, envelope in + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + }, + onDrain: { lastError in + if let lastError { + loggerRef.error("Channel queue drained with error: \(String(describing: lastError))") } - guard let meshError = error as? MeshCoreError else { return false } - switch meshError { - case .timeout, .notConnected, .connectionLost, - .bluetoothPoweredOff, .sessionNotStarted: - return true - case .deviceError(let code): - return code == deviceCode - case .parseError, .commandFailed, .invalidResponse, - .contactNotFound, .dataTooLarge, .signingFailed, .invalidInput, - .unknown, .bluetoothUnavailable, .bluetoothUnauthorized, - .featureDisabled: - return false + } + ) + } + + /// Enqueue a DM envelope. Persists a `PendingSend` row first; the + /// queue's drain reads it back on the next send attempt. Throws + /// `ChatSendQueueServiceError.persistFailed` if the SwiftData write + /// fails so the caller can surface the failure instead of silently + /// dropping the queued send. + public func enqueueDM(_ envelope: DirectMessageEnvelope) async throws { + try await persist(PendingSendDTO(envelope: envelope, radioID: radioID)) + await dmQueue.enqueue(envelope) + } + + public func enqueueChannel(_ envelope: ChannelMessageEnvelope) async throws { + try await persist(PendingSendDTO(envelope: envelope, radioID: radioID)) + await channelQueue.enqueue(envelope) + } + + /// Signals the DM queue that a `PendingSend` row already exists for + /// this envelope. Used by the manual retry path where + /// `PersistenceStore.replacePendingSendForRetry` has already written + /// the row in one transaction; calling `enqueueDM` here would + /// double-persist. The drain still reads the row back via + /// `hasPendingSend` on the next send attempt, so the in-memory + /// enqueue is the only step left. + public func signalDMEnqueued(_ envelope: DirectMessageEnvelope) async { + await dmQueue.enqueue(envelope) + } + + /// Starts observing the connection-state stream and fires the + /// transport-open trigger exactly once each time the connection enters + /// `.ready`. Gating on `.ready` (not the first `isConnected` edge) keeps + /// hydrated sends parked through the initial-sync window, so they do not + /// contend with sync's reads on the radio's link. In production the container + /// is built while still `.connected`, so the `.ready` wake arrives on the + /// event stream; the initial-value check only fires when the observation is + /// (re)started against an already-`.ready` connection, waking drains parked in + /// `withCooperativeTimeout` instead of leaving them to wait out + /// `transportWaitTimeout`. Firing on "entered `.ready`" covers both the success + /// edge (`.connected → .ready`) and the failure-recovery edge + /// (`.syncing → .ready`). Calling again replaces the previous observation. + func observeConnectionState( + initial: DeviceConnectionState, + events: AsyncStream + ) { + connectionStateTask?.cancel() + if initial.canDrainSendQueue { + transportDidOpen() + } + connectionStateTask = Task { [weak self] in + var previous = initial + for await state in events { + guard let self else { return } + if !previous.canDrainSendQueue, state.canDrainSendQueue { + transportDidOpen() } + previous = state + } } - - // MARK: - Store-read classifier - - nonisolated private enum DrainStoreReadOutcome: Sendable { - /// Read succeeded with a value. - case found(Value) - /// Read succeeded but the row is gone — terminal, drop the envelope. - case missing - /// Read threw — transient; park and retry on next transport open. - case transient(Error) + } + + /// Fired by the connection-state observation started by + /// `observeConnectionState` each time the connection enters `.ready`. + /// Fires the trigger that wakes any drain attempt suspended in + /// `withCooperativeTimeout`. + /// + /// Fire-and-forget Task is intentional: `triggers.fire` is idempotent + /// (arming an already-armed bit is a no-op), so a tight reconnect cycle + /// (`.connected → .connecting → .connected` within a frame) collapses + /// to a single armed trigger on the actor. Do not extend this function + /// with per-call state — the fire-and-forget shape would lose ordering. + func transportDidOpen() { + osLogger.debug("transportDidOpen firing trigger") + Task { [triggers] in + await triggers.fire() } - - /// Wraps a `throws -> T?` store read into a tri-state outcome so the drain - /// can distinguish "row deleted" (terminal) from "store fault" (transient). - nonisolated private static func classifyRead( - _ work: @Sendable () async throws -> Value? - ) async -> DrainStoreReadOutcome { - do { - if let value = try await work() { - return .found(value) - } - return .missing - } catch { - return .transient(error) - } + } + + /// Park the drain on the transport-open trigger up to `timeout` seconds. + /// Returns true if the signal fired, false on timeout or + /// cancelled-mid-wait (caller treats both as "requeue"). `nonisolated` + /// so the off-main send closures can call it. + nonisolated static func waitForTransportOpen( + triggers: BLETransportOpenedSignal, + logger: PersistentLogger, + messageID: UUID, + kind: String, + timeout: TimeInterval + ) async throws -> Bool { + do { + try await withCooperativeTimeout(seconds: timeout) { + try await triggers.wait() + } + return true + } catch is CancellationError { + if Task.isCancelled { + logger.info("\(kind) drain cancelled mid-wait for \(messageID); requeueing") + } else { + logger.info("\(kind) drain timeout-without-fire after \(Int(timeout))s for \(messageID); requeueing") + } + return false } - - nonisolated static func isTransientDirectMessageError(_ error: Error) -> Bool { - isTransientError(error, deviceCode: FirmwareDeviceErrorCode.directMessageTableFull) + } + + /// Combines the `hasPendingSend` gate, the top-of-drain `attemptCount` bump, + /// and the `preserveTimestamp` computation that every DM and channel drain + /// runs before any wire-affecting work. Returns nil if the row is gone + /// (terminal — abandon envelope). Throws transient errors so the caller can + /// park and retry; throws nothing on the gate-missing terminal path. + nonisolated static func preflightAndBump( + dataStore: any MessagePersisting, + messageID: UUID, + kind: String, + logger: PersistentLogger, + osLogger: os.Logger + ) async throws -> (postBumpCount: Int, preserveTimestamp: Bool)? { + switch await ChatSendQueueService.classifyRead({ () async throws -> Bool? in + try await dataStore.hasPendingSend(messageID: messageID) ? true : nil + }) { + case .found: + break + case .missing: + logger.info("\(kind) drain abandoned messageID=\(messageID) reason=PendingSendGone") + return nil + case let .transient(error): + logger.warning("\(kind) drain hasPendingSend transient error: \(String(describing: error)); parking envelope") + throw error } - nonisolated static func isTransientChannelMessageError(_ error: Error) -> Bool { - isTransientError(error, deviceCode: FirmwareDeviceErrorCode.channelMessageNotFound) + let postBumpCount: Int + do { + guard let bumped = try await dataStore.incrementPendingSendAttemptCount(messageID: messageID) else { + logger.info("\(kind) drain: PendingSend row gone for messageID=\(messageID); treating as terminal") + return nil + } + postBumpCount = bumped + } catch { + logger.warning("incrementPendingSendAttemptCount failed: \(String(describing: error)); parking envelope for next transport-open") + throw error } - /// Returns true if `error` is `MeshCoreError.deviceError(channelMessageNotFound)` - /// or `MessageServiceError.sessionError` wrapping the same. Used by the channel - /// drain catch to recognise the firmware NOT_FOUND signal regardless of which - /// MessageService entrypoint surfaced it. - /// - /// `MessageServiceError.sessionError` wraps `MeshCoreError` (a leaf — has no - /// `.sessionError` case), so the unwrap is single-level by construction. Two - /// pattern-matches cover both shapes; no recursion needed. - nonisolated static func isChannelMessageNotFound(_ error: Error) -> Bool { - if case MeshCoreError.deviceError(let code) = error { - return code == FirmwareDeviceErrorCode.channelMessageNotFound - } - if case MessageServiceError.sessionError(let underlying) = error, - case MeshCoreError.deviceError(let code) = underlying { - return code == FirmwareDeviceErrorCode.channelMessageNotFound - } + let preserveTimestamp = postBumpCount > 1 + osLogger.debug("\(kind) drain begin messageID=\(messageID) postBumpCount=\(postBumpCount) preserveTimestamp=\(preserveTimestamp)") + return (postBumpCount: postBumpCount, preserveTimestamp: preserveTimestamp) + } + + /// Awaits a transport-open trigger up to the bounded timeout, then throws + /// `CancellationError` to drive `SendQueue.drain`'s requeue protocol. + /// Sites that need to revert message status to `.pending` before parking + /// must call `updateMessageStatusUnlessDelivered` themselves — this helper + /// does not write status. + nonisolated static func parkAndCancel( + triggers: BLETransportOpenedSignal, + logger: PersistentLogger, + messageID: UUID, + kind: String, + timeout: TimeInterval + ) async throws -> Never { + _ = try await ChatSendQueueService.waitForTransportOpen( + triggers: triggers, + logger: logger, + messageID: messageID, + kind: kind, + timeout: timeout + ) + throw CancellationError() + } + + /// Classify a send error as transient (park the envelope and wait on a + /// transport-open trigger) or terminal (drop the row). The transient + /// `deviceError` code differs between DM and channel paths — see + /// `FirmwareDeviceErrorCode`. + nonisolated static func isTransientError(_ error: Error, deviceCode: UInt8) -> Bool { + if let serviceError = error as? MessageServiceError { + switch serviceError { + case let .sessionError(underlying): + return isTransientError(underlying, deviceCode: deviceCode) + case .notConnected: + return true + case .contactNotFound, .channelNotFound, .sendFailed, + .invalidRecipient, .messageTooLong: return false + } } + guard let meshError = error as? MeshCoreError else { return false } + switch meshError { + case .timeout, .notConnected, .connectionLost, + .bluetoothPoweredOff, .sessionNotStarted: + return true + case let .deviceError(code): + return code == deviceCode + case .parseError, .commandFailed, .invalidResponse, + .contactNotFound, .dataTooLarge, .signingFailed, .invalidInput, + .unknown, .bluetoothUnavailable, .bluetoothUnauthorized, + .featureDisabled: + return false + } + } + + // MARK: - Store-read classifier + + private nonisolated enum DrainStoreReadOutcome { + /// Read succeeded with a value. + case found(Value) + /// Read succeeded but the row is gone — terminal, drop the envelope. + case missing + /// Read threw — transient; park and retry on next transport open. + case transient(Error) + } + + /// Wraps a `throws -> T?` store read into a tri-state outcome so the drain + /// can distinguish "row deleted" (terminal) from "store fault" (transient). + private nonisolated static func classifyRead( + _ work: @Sendable () async throws -> Value? + ) async -> DrainStoreReadOutcome { + do { + if let value = try await work() { + return .found(value) + } + return .missing + } catch { + return .transient(error) + } + } + + nonisolated static func isTransientDirectMessageError(_ error: Error) -> Bool { + isTransientError(error, deviceCode: FirmwareDeviceErrorCode.directMessageTableFull) + } + + nonisolated static func isTransientChannelMessageError(_ error: Error) -> Bool { + isTransientError(error, deviceCode: FirmwareDeviceErrorCode.channelMessageNotFound) + } + + /// Returns true if `error` is `MeshCoreError.deviceError(channelMessageNotFound)` + /// or `MessageServiceError.sessionError` wrapping the same. Used by the channel + /// drain catch to recognise the firmware NOT_FOUND signal regardless of which + /// MessageService entrypoint surfaced it. + /// + /// `MessageServiceError.sessionError` wraps `MeshCoreError` (a leaf — has no + /// `.sessionError` case), so the unwrap is single-level by construction. Two + /// pattern-matches cover both shapes; no recursion needed. + nonisolated static func isChannelMessageNotFound(_ error: Error) -> Bool { + if case let MeshCoreError.deviceError(code) = error { + return code == FirmwareDeviceErrorCode.channelMessageNotFound + } + if case let MessageServiceError.sessionError(underlying) = error, + case let MeshCoreError.deviceError(code) = underlying { + return code == FirmwareDeviceErrorCode.channelMessageNotFound + } + return false + } - // MARK: - Persistence + // MARK: - Persistence - private func persist(_ dto: PendingSendDTO) async throws { - do { - _ = try await dataStore.insertPendingSendAssigningSequence(dto) - } catch { - logger.error("Persisting envelope failed: \(String(describing: error))") - throw ChatSendQueueServiceError.persistFailed(underlying: error) - } + private func persist(_ dto: PendingSendDTO) async throws { + do { + _ = try await dataStore.insertPendingSendAssigningSequence(dto) + } catch { + logger.error("Persisting envelope failed: \(String(describing: error))") + throw ChatSendQueueServiceError.persistFailed(underlying: error) } - - // MARK: - Hydration - - /// Called once by `ServiceContainer` after construction. Reads - /// every `PendingSend` row for this service's `radioID` and - /// enqueues each envelope. Subsequent calls are no-ops — the - /// service's hydration latch is per-instance, and one instance - /// lives per `ServiceContainer`, so two view models on the same - /// connection cannot trigger duplicate replay. - /// - /// The nullable-attemptCount scheme stores legacy-vs-current-build - /// distinction in the column itself; `PersistenceStore.warmUp()` runs - /// `purgeLegacyAttemptCountRows` before hydrate (wired in - /// `ConnectionManager.buildServicesAndSaveDevice`) so pre-migration `nil` - /// rows are deleted rather than promoted. Race-window rows (persist - /// succeeded, drain bump didn't run) sit at `attemptCount = 0`, bump to - /// `1`, and `preserveTimestamp` = false — correct, because the recipient - /// never saw the packet. - func hydrate() async { - guard !hasHydrated else { return } - hasHydrated = true - logger.info("hydrate begin radio=\(radioID)") - do { - let rows = try await dataStore.fetchPendingSends(radioID: radioID) - for dto in rows { - osLogger.debug("hydrate enqueue messageID=\(dto.messageID) kind=\(String(describing: dto.kind)) isResend=\(dto.isResend) attemptCount=\(String(describing: dto.attemptCount))") - switch dto.kind { - case .dm: - if let envelope = dto.directMessageEnvelope() { - await dmQueue.enqueue(envelope) - } - case .channel: - if let envelope = dto.channelMessageEnvelope() { - await channelQueue.enqueue(envelope) - } - } - } - logger.info("hydrate complete count=\(rows.count) radio=\(radioID)") - } catch { - logger.error("Hydration failed: \(String(describing: error))") + } + + // MARK: - Hydration + + /// Called once by `ServiceContainer` after construction. Reads + /// every `PendingSend` row for this service's `radioID` and + /// enqueues each envelope. Subsequent calls are no-ops — the + /// service's hydration latch is per-instance, and one instance + /// lives per `ServiceContainer`, so two view models on the same + /// connection cannot trigger duplicate replay. + /// + /// The nullable-attemptCount scheme stores legacy-vs-current-build + /// distinction in the column itself; `PersistenceStore.warmUp()` runs + /// `purgeLegacyAttemptCountRows` before hydrate (wired in + /// `ConnectionManager.buildServicesAndSaveDevice`) so pre-migration `nil` + /// rows are deleted rather than promoted. Race-window rows (persist + /// succeeded, drain bump didn't run) sit at `attemptCount = 0`, bump to + /// `1`, and `preserveTimestamp` = false — correct, because the recipient + /// never saw the packet. + func hydrate() async { + guard !hasHydrated else { return } + hasHydrated = true + logger.info("hydrate begin radio=\(radioID)") + do { + let rows = try await dataStore.fetchPendingSends(radioID: radioID) + for dto in rows { + osLogger.debug("hydrate enqueue messageID=\(dto.messageID) kind=\(String(describing: dto.kind)) isResend=\(dto.isResend) attemptCount=\(String(describing: dto.attemptCount))") + switch dto.kind { + case .dm: + if let envelope = dto.directMessageEnvelope() { + await dmQueue.enqueue(envelope) + } + case .channel: + if let envelope = dto.channelMessageEnvelope() { + await channelQueue.enqueue(envelope) + } } + } + logger.info("hydrate complete count=\(rows.count) radio=\(radioID)") + } catch { + logger.error("Hydration failed: \(String(describing: error))") } + } - #if DEBUG + #if DEBUG /// Test-only: drain readiness for synchronizing tests. func awaitDrainCompletion() async { - await dmQueue.awaitDrainCompletion() - await channelQueue.awaitDrainCompletion() - } - #endif - - /// Cancel both queues' drains so the underlying actors can deinit. - /// - /// Without this, send closures suspended in `withCooperativeTimeout` - /// keep the queue actor alive (and its captured `MessageService` / - /// `triggers`), respawning on every `.notConnected` requeue. `PendingSend` - /// rows survive in SwiftData, so the next container's `hydrate()` replays - /// them on reconnect. - /// - /// Cancelling the connection-state observation unregisters its - /// `EventBroadcaster` subscription so a torn-down container never - /// receives the next connection's edge. - func shutdown() async { - connectionStateTask?.cancel() - connectionStateTask = nil - await dmQueue.cancelDrain() - await channelQueue.cancelDrain() + await dmQueue.awaitDrainCompletion() + await channelQueue.awaitDrainCompletion() } + #endif + + /// Cancel both queues' drains so the underlying actors can deinit. + /// + /// Without this, send closures suspended in `withCooperativeTimeout` + /// keep the queue actor alive (and its captured `MessageService` / + /// `triggers`), respawning on every `.notConnected` requeue. `PendingSend` + /// rows survive in SwiftData, so the next container's `hydrate()` replays + /// them on reconnect. + /// + /// Cancelling the connection-state observation unregisters its + /// `EventBroadcaster` subscription so a torn-down container never + /// receives the next connection's edge. + func shutdown() async { + connectionStateTask?.cancel() + connectionStateTask = nil + await dmQueue.cancelDrain() + await channelQueue.cancelDrain() + } } /// Tracks consecutive `fetchChannel` throws inside the channel-drain @@ -729,16 +728,16 @@ public final class ChatSendQueueService { /// cleaned up on success and on terminal-fail so it does not grow /// unboundedly. actor FailureCounter { - private var counts: [UUID: Int] = [:] - - @discardableResult - func increment(for messageID: UUID) -> Int { - let next = (counts[messageID] ?? 0) + 1 - counts[messageID] = next - return next - } - - func reset(for messageID: UUID) { - counts.removeValue(forKey: messageID) - } + private var counts: [UUID: Int] = [:] + + @discardableResult + func increment(for messageID: UUID) -> Int { + let next = (counts[messageID] ?? 0) + 1 + counts[messageID] = next + return next + } + + func reset(for messageID: UUID) { + counts.removeValue(forKey: messageID) + } } diff --git a/MC1Services/Sources/MC1Services/Services/CommandAuditLogger.swift b/MC1Services/Sources/MC1Services/Services/CommandAuditLogger.swift index 212195a8..9d60331e 100644 --- a/MC1Services/Sources/MC1Services/Services/CommandAuditLogger.swift +++ b/MC1Services/Sources/MC1Services/Services/CommandAuditLogger.swift @@ -3,135 +3,134 @@ import Foundation /// Dedicated logger for command audit trails. /// Logs all repeater and room commands with consistent formatting. actor CommandAuditLogger { - - // MARK: - Types - - /// Direction of the command - enum Direction: String, Sendable { - case out = "->" - case `in` = "<-" - } - - /// Target type (repeater or room) - enum Target: String, Sendable { - case repeater = "REPEATER" - case room = "ROOM" - } - - // MARK: - Properties - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "CommandAudit") - private let prefix = "[CMD]" - - // MARK: - Initialization - - init() {} - - // MARK: - Login/Logout - - /// Log a login request being sent - func logLoginRequest(target: Target, publicKey: Data, pathLength: UInt8) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) LOGIN to=\(keyHex) pathLen=\(pathLength)") - } - - /// Log a successful login response - func logLoginSuccess(target: Target, publicKey: Data, isAdmin: Bool) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) LOGIN_OK from=\(keyHex) admin=\(isAdmin)") - } - - /// Log a failed login response - func logLoginFailed(target: Target, publicKey: Data, reason: String) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.warning("\(prefix) \(Direction.in.rawValue) \(target.rawValue) LOGIN_FAIL from=\(keyHex) reason=\(reason)") - } - - /// Log a logout request being sent - func logLogout(target: Target, publicKey: Data) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) LOGOUT to=\(keyHex)") - } - - // MARK: - Status/Telemetry - - /// Log a status request being sent - func logStatusRequest(target: Target, publicKey: Data) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) STATUS_REQ to=\(keyHex)") - } - - /// Log a status response received - func logStatusResponse(target: Target, publicKey: Data, batteryMv: UInt16?, uptimeSec: UInt32?) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - let battery = batteryMv.map { "\($0)mV" } ?? "n/a" - let uptime = uptimeSec.map { "\($0)s" } ?? "n/a" - logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) STATUS from=\(keyHex) battery=\(battery) uptime=\(uptime)") - } - - /// Log a telemetry request being sent - func logTelemetryRequest(target: Target, publicKey: Data) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) TELEM_REQ to=\(keyHex)") - } - - /// Log a telemetry response received - func logTelemetryResponse(target: Target, publicKey: Data, pointCount: Int) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) TELEM from=\(keyHex) points=\(pointCount)") - } - - // MARK: - CLI Commands (Repeater) - - /// Log a CLI command being sent (with password redaction) - func logCLICommand(publicKey: Data, command: String) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - let redactedCmd = LogRedaction.cliCommand(command) - logger.info("\(prefix) \(Direction.out.rawValue) REPEATER CLI to=\(keyHex) cmd=\"\(redactedCmd)\"") - } - - /// Log a CLI response received (full content logged) - func logCLIResponse(publicKey: Data, response: String) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - // Truncate very long responses for readability - let truncated = response.count <= 100 ? response : String(response.prefix(100)) + "..." - logger.info("\(prefix) \(Direction.in.rawValue) REPEATER CLI_RESP from=\(keyHex) resp=\"\(truncated)\"") - } - - // MARK: - Neighbors (Repeater) - - /// Log a neighbors request being sent - func logNeighborsRequest(publicKey: Data, count: UInt8, offset: UInt16) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) REPEATER NEIGHBORS_REQ to=\(keyHex) count=\(count) offset=\(offset)") - } - - /// Log a neighbors response received - func logNeighborsResponse(publicKey: Data, totalCount: Int, returnedCount: Int) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.in.rawValue) REPEATER NEIGHBORS from=\(keyHex) total=\(totalCount) returned=\(returnedCount)") - } - - // MARK: - Room Messages (metadata only) - - /// Log a room message being posted (no content, only length) - func logRoomMessagePosted(publicKey: Data, messageLength: Int) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) ROOM MSG to=\(keyHex) len=\(messageLength)") - } - - /// Log a room message received (no content, only metadata) - func logRoomMessageReceived(roomPublicKey: Data, authorPrefix: Data, messageLength: Int) { - let roomHex = LogRedaction.publicKeyHex(roomPublicKey) - let authorHex = authorPrefix.map { String(format: "%02x", $0) }.joined() - logger.info("\(prefix) \(Direction.in.rawValue) ROOM MSG from=\(roomHex) author=\(authorHex) len=\(messageLength)") - } - - // MARK: - Keep-alive - - /// Log a keep-alive request being sent - func logKeepAlive(target: Target, publicKey: Data) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) KEEPALIVE to=\(keyHex)") - } + // MARK: - Types + + /// Direction of the command + enum Direction: String { + case out = "->" + case `in` = "<-" + } + + /// Target type (repeater or room) + enum Target: String { + case repeater = "REPEATER" + case room = "ROOM" + } + + // MARK: - Properties + + private let logger = PersistentLogger(subsystem: "com.mc1", category: "CommandAudit") + private let prefix = "[CMD]" + + // MARK: - Initialization + + init() {} + + // MARK: - Login/Logout + + /// Log a login request being sent + func logLoginRequest(target: Target, publicKey: Data, pathLength: UInt8) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) LOGIN to=\(keyHex) pathLen=\(pathLength)") + } + + /// Log a successful login response + func logLoginSuccess(target: Target, publicKey: Data, isAdmin: Bool) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) LOGIN_OK from=\(keyHex) admin=\(isAdmin)") + } + + /// Log a failed login response + func logLoginFailed(target: Target, publicKey: Data, reason: String) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.warning("\(prefix) \(Direction.in.rawValue) \(target.rawValue) LOGIN_FAIL from=\(keyHex) reason=\(reason)") + } + + /// Log a logout request being sent + func logLogout(target: Target, publicKey: Data) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) LOGOUT to=\(keyHex)") + } + + // MARK: - Status/Telemetry + + /// Log a status request being sent + func logStatusRequest(target: Target, publicKey: Data) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) STATUS_REQ to=\(keyHex)") + } + + /// Log a status response received + func logStatusResponse(target: Target, publicKey: Data, batteryMv: UInt16?, uptimeSec: UInt32?) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + let battery = batteryMv.map { "\($0)mV" } ?? "n/a" + let uptime = uptimeSec.map { "\($0)s" } ?? "n/a" + logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) STATUS from=\(keyHex) battery=\(battery) uptime=\(uptime)") + } + + /// Log a telemetry request being sent + func logTelemetryRequest(target: Target, publicKey: Data) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) TELEM_REQ to=\(keyHex)") + } + + /// Log a telemetry response received + func logTelemetryResponse(target: Target, publicKey: Data, pointCount: Int) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) TELEM from=\(keyHex) points=\(pointCount)") + } + + // MARK: - CLI Commands (Repeater) + + /// Log a CLI command being sent (with password redaction) + func logCLICommand(publicKey: Data, command: String) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + let redactedCmd = LogRedaction.cliCommand(command) + logger.info("\(prefix) \(Direction.out.rawValue) REPEATER CLI to=\(keyHex) cmd=\"\(redactedCmd)\"") + } + + /// Log a CLI response received (full content logged) + func logCLIResponse(publicKey: Data, response: String) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + // Truncate very long responses for readability + let truncated = response.count <= 100 ? response : String(response.prefix(100)) + "..." + logger.info("\(prefix) \(Direction.in.rawValue) REPEATER CLI_RESP from=\(keyHex) resp=\"\(truncated)\"") + } + + // MARK: - Neighbors (Repeater) + + /// Log a neighbors request being sent + func logNeighborsRequest(publicKey: Data, count: UInt8, offset: UInt16) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) REPEATER NEIGHBORS_REQ to=\(keyHex) count=\(count) offset=\(offset)") + } + + /// Log a neighbors response received + func logNeighborsResponse(publicKey: Data, totalCount: Int, returnedCount: Int) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.in.rawValue) REPEATER NEIGHBORS from=\(keyHex) total=\(totalCount) returned=\(returnedCount)") + } + + // MARK: - Room Messages (metadata only) + + /// Log a room message being posted (no content, only length) + func logRoomMessagePosted(publicKey: Data, messageLength: Int) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) ROOM MSG to=\(keyHex) len=\(messageLength)") + } + + /// Log a room message received (no content, only metadata) + func logRoomMessageReceived(roomPublicKey: Data, authorPrefix: Data, messageLength: Int) { + let roomHex = LogRedaction.publicKeyHex(roomPublicKey) + let authorHex = authorPrefix.map { String(format: "%02x", $0) }.joined() + logger.info("\(prefix) \(Direction.in.rawValue) ROOM MSG from=\(roomHex) author=\(authorHex) len=\(messageLength)") + } + + // MARK: - Keep-alive + + /// Log a keep-alive request being sent + func logKeepAlive(target: Target, publicKey: Data) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) KEEPALIVE to=\(keyHex)") + } } diff --git a/MC1Services/Sources/MC1Services/Services/ContactCleanupCoordinator.swift b/MC1Services/Sources/MC1Services/Services/ContactCleanupCoordinator.swift index f6aa632d..f0108ba5 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactCleanupCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactCleanupCoordinator.swift @@ -8,54 +8,53 @@ import Foundation /// Holds the collaborating services directly, never the `ServiceContainer`, /// so a torn-down container cannot be kept alive through this coordinator. struct ContactCleanupCoordinator: ContactCleanupHandling { + private let dataStore: any ContactPersisting & RoomPersisting + private let syncCoordinator: SyncCoordinator + private let notificationService: NotificationService + private let remoteNodeService: RemoteNodeService - private let dataStore: any ContactPersisting & RoomPersisting - private let syncCoordinator: SyncCoordinator - private let notificationService: NotificationService - private let remoteNodeService: RemoteNodeService + init( + dataStore: any ContactPersisting & RoomPersisting, + syncCoordinator: SyncCoordinator, + notificationService: NotificationService, + remoteNodeService: RemoteNodeService + ) { + self.dataStore = dataStore + self.syncCoordinator = syncCoordinator + self.notificationService = notificationService + self.remoteNodeService = remoteNodeService + } - init( - dataStore: any ContactPersisting & RoomPersisting, - syncCoordinator: SyncCoordinator, - notificationService: NotificationService, - remoteNodeService: RemoteNodeService - ) { - self.dataStore = dataStore - self.syncCoordinator = syncCoordinator - self.notificationService = notificationService - self.remoteNodeService = remoteNodeService - } - - func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async { - // Refresh blocked names cache and delete channel messages on block - if reason == .blocked || reason == .unblocked { - if let contact = try? await dataStore.fetchContact(id: contactID) { - if reason == .blocked { - try? await dataStore.deleteChannelMessages( - fromSender: contact.name, radioID: contact.radioID - ) - } - await syncCoordinator.refreshBlockedContactsCache( - radioID: contact.radioID, dataStore: dataStore - ) - await syncCoordinator.notifyConversationsChanged() - } + func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async { + // Refresh blocked names cache and delete channel messages on block + if reason == .blocked || reason == .unblocked { + if let contact = try? await dataStore.fetchContact(id: contactID) { + if reason == .blocked { + try? await dataStore.deleteChannelMessages( + fromSender: contact.name, radioID: contact.radioID + ) } + await syncCoordinator.refreshBlockedContactsCache( + radioID: contact.radioID, dataStore: dataStore + ) + await syncCoordinator.notifyConversationsChanged() + } + } - // Remove delivered notifications for this contact (only on block/delete) - if reason == .blocked || reason == .deleted { - await notificationService.removeDeliveredNotifications(forContactID: contactID) - } + // Remove delivered notifications for this contact (only on block/delete) + if reason == .blocked || reason == .deleted { + await notificationService.removeDeliveredNotifications(forContactID: contactID) + } - // Update badge count - await notificationService.updateBadgeCount() + // Update badge count + await notificationService.updateBadgeCount() - // Clean up any associated remote node session on delete - if reason == .deleted { - if let session = try? await dataStore.fetchRemoteNodeSession(publicKey: publicKey) { - try? await remoteNodeService.removeSession(id: session.id, publicKey: publicKey) - } - await syncCoordinator.notifyConversationsChanged() - } + // Clean up any associated remote node session on delete + if reason == .deleted { + if let session = try? await dataStore.fetchRemoteNodeSession(publicKey: publicKey) { + try? await remoteNodeService.removeSession(id: session.id, publicKey: publicKey) + } + await syncCoordinator.notifyConversationsChanged() } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ContactCleanupHandling.swift b/MC1Services/Sources/MC1Services/Services/ContactCleanupHandling.swift index 32a32fcf..c00918f2 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactCleanupHandling.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactCleanupHandling.swift @@ -4,12 +4,11 @@ import Foundation /// delete). `ContactService` invokes this after its own database writes; /// `ContactCleanupCoordinator` is the production implementation. protocol ContactCleanupHandling: Sendable { - - /// Runs the cleanup chain for one contact. - /// - Parameters: - /// - contactID: The affected contact's local ID. - /// - reason: Which lifecycle change triggered the cleanup. - /// - publicKey: The contact's public key, used to locate any associated - /// remote node session. - func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async + /// Runs the cleanup chain for one contact. + /// - Parameters: + /// - contactID: The affected contact's local ID. + /// - reason: Which lifecycle change triggered the cleanup. + /// - publicKey: The contact's public key, used to locate any associated + /// remote node session. + func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async } diff --git a/MC1Services/Sources/MC1Services/Services/ContactService.swift b/MC1Services/Sources/MC1Services/Services/ContactService.swift index 6475a44e..3570f3fb 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactService.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactService.swift @@ -5,57 +5,57 @@ import os // MARK: - Contact Service Errors public enum ContactServiceError: Error, Sendable, LocalizedError { - case notConnected - case sendFailed - case invalidResponse - case syncInterrupted - case contactNotFound - case contactTableFull - case shareContactUnavailable - case sessionError(MeshCoreError) - - public var errorDescription: String? { - switch self { - case .notConnected: - return "Not connected to radio" - case .sendFailed: - return "Failed to send message" - case .invalidResponse: - return "Invalid response from device" - case .syncInterrupted: - return "Sync was interrupted" - case .contactNotFound: - return "Contact not found on device" - case .contactTableFull: - return "Device node list is full" - case .shareContactUnavailable: - return "Unable to share node. The node's advertisement may be missing or too old." - case .sessionError(let error): - return error.localizedDescription - } + case notConnected + case sendFailed + case invalidResponse + case syncInterrupted + case contactNotFound + case contactTableFull + case shareContactUnavailable + case sessionError(MeshCoreError) + + public var errorDescription: String? { + switch self { + case .notConnected: + "Not connected to radio" + case .sendFailed: + "Failed to send message" + case .invalidResponse: + "Invalid response from device" + case .syncInterrupted: + "Sync was interrupted" + case .contactNotFound: + "Contact not found on device" + case .contactTableFull: + "Device node list is full" + case .shareContactUnavailable: + "Unable to share node. The node's advertisement may be missing or too old." + case let .sessionError(error): + error.localizedDescription } + } } /// Reason for contact cleanup (deletion or blocking) -enum ContactCleanupReason: Sendable { - case deleted - case blocked - case unblocked +enum ContactCleanupReason { + case deleted + case blocked + case unblocked } // MARK: - Sync Result /// Result of a contact sync operation public struct ContactSyncResult: Sendable { - public let contactsReceived: Int - public let lastSyncTimestamp: UInt32 - public let isIncremental: Bool - - public init(contactsReceived: Int, lastSyncTimestamp: UInt32, isIncremental: Bool) { - self.contactsReceived = contactsReceived - self.lastSyncTimestamp = lastSyncTimestamp - self.isIncremental = isIncremental - } + public let contactsReceived: Int + public let lastSyncTimestamp: UInt32 + public let isIncremental: Bool + + public init(contactsReceived: Int, lastSyncTimestamp: UInt32, isIncremental: Bool) { + self.contactsReceived = contactsReceived + self.lastSyncTimestamp = lastSyncTimestamp + self.isIncremental = isIncremental + } } // MARK: - Contact Service @@ -63,724 +63,729 @@ public struct ContactSyncResult: Sendable { /// Service for managing mesh network contacts. /// Handles contact discovery, sync, add/update/remove operations. public actor ContactService { - - // MARK: - Properties - - private let session: any MeshCoreSessionProtocol - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "ContactService") - - /// Sync coordinator for UI refresh notifications. - /// Injected by `ServiceContainer` at construction. - private weak var syncCoordinator: SyncCoordinator? - - /// Runs the cross-service cleanup chain when a contact is deleted, blocked, or unblocked. - /// Injected by `ServiceContainer` at construction. - private let cleanupCoordinator: (any ContactCleanupHandling)? - - /// Multicast broadcaster for sync progress and node-deletion events. - /// Consumers subscribe via `events()`; finished by `ServiceContainer.tearDown()`. - private nonisolated let eventBroadcaster = EventBroadcaster() - - // MARK: - Initialization - - init( - session: any MeshCoreSessionProtocol, - dataStore: any PersistenceStoreProtocol, - syncCoordinator: SyncCoordinator?, - cleanupCoordinator: (any ContactCleanupHandling)? - ) { - self.session = session - self.dataStore = dataStore - self.syncCoordinator = syncCoordinator - self.cleanupCoordinator = cleanupCoordinator - } - - // MARK: - Events - - /// Returns a fresh stream of contact service events. Registration is - /// synchronous, so events yielded after this call returns are never - /// dropped. Consumers re-subscribe per connection because the owning - /// `ServiceContainer` is rebuilt. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() - } - - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release their service - /// references. - nonisolated func finishEvents() { - eventBroadcaster.finish() - } - - // MARK: - Configuration - - /// Whether a sync coordinator was injected at construction. - var hasSyncCoordinatorWired: Bool { syncCoordinator != nil } - - /// Whether a cleanup coordinator was injected at construction. - var hasCleanupCoordinatorWired: Bool { cleanupCoordinator != nil } - - // MARK: - Contact Sync - - /// Sync all contacts from device - /// - Parameters: - /// - radioID: The device to sync from - /// - since: Optional date for incremental sync (only contacts modified after this time) - /// - Returns: Sync result with count and timestamp - public func syncContacts(radioID: UUID, since: Date? = nil) async throws -> ContactSyncResult { - do { - let meshContacts = try await session.getContacts(since: since) - - eventBroadcaster.yield(.syncProgress(received: 0, total: meshContacts.count)) - - // Build set of public keys from device for cleanup - let devicePublicKeys = Set(meshContacts.map(\.publicKey)) - - // Persist every received contact in a single transaction rather than one - // commit per contact; the per-item save was redundant overhead on every sync. - let frames = meshContacts.map { $0.toContactFrame() } - let receivedCount = try await dataStore.batchSaveContacts(radioID: radioID, from: frames) - - let lastTimestamp = meshContacts - .map { UInt32($0.lastModified.timeIntervalSince1970) } - .max() ?? 0 - - eventBroadcaster.yield(.syncProgress(received: receivedCount, total: meshContacts.count)) - - // On full sync, remove local contacts that no longer exist on device - if since == nil { - let localContacts = try await dataStore.fetchContacts(radioID: radioID) - let orphans = localContacts.filter { !devicePublicKeys.contains($0.publicKey) } - if !orphans.isEmpty { - logger.notice("Full sync prune: \(orphans.count) local contact(s) not found on device (device has \(devicePublicKeys.count), local has \(localContacts.count))") - } - for localContact in orphans { - let keyPrefix = localContact.publicKey.prefix(4).map { String(format: "%02x", $0) }.joined() - logger.notice("Full sync prune: deleting '\(localContact.name)' [\(keyPrefix)…] (favorite=\(localContact.isFavorite), type=\(localContact.typeRawValue), lastModified=\(localContact.lastModified))") - try await dataStore.deleteMessagesForContact(contactID: localContact.id) - try await dataStore.deleteContact(id: localContact.id) - await cleanupCoordinator?.handleCleanup( - contactID: localContact.id, reason: .deleted, publicKey: localContact.publicKey - ) - } - } - - return ContactSyncResult( - contactsReceived: receivedCount, - lastSyncTimestamp: lastTimestamp, - isIncremental: since != nil - ) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) + // MARK: - Properties + + private let session: any MeshCoreSessionProtocol + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "ContactService") + + /// Sync coordinator for UI refresh notifications. + /// Injected by `ServiceContainer` at construction. + private weak var syncCoordinator: SyncCoordinator? + + /// Runs the cross-service cleanup chain when a contact is deleted, blocked, or unblocked. + /// Injected by `ServiceContainer` at construction. + private let cleanupCoordinator: (any ContactCleanupHandling)? + + /// Multicast broadcaster for sync progress and node-deletion events. + /// Consumers subscribe via `events()`; finished by `ServiceContainer.tearDown()`. + private nonisolated let eventBroadcaster = EventBroadcaster() + + // MARK: - Initialization + + init( + session: any MeshCoreSessionProtocol, + dataStore: any PersistenceStoreProtocol, + syncCoordinator: SyncCoordinator?, + cleanupCoordinator: (any ContactCleanupHandling)? + ) { + self.session = session + self.dataStore = dataStore + self.syncCoordinator = syncCoordinator + self.cleanupCoordinator = cleanupCoordinator + } + + // MARK: - Events + + /// Returns a fresh stream of contact service events. Registration is + /// synchronous, so events yielded after this call returns are never + /// dropped. Consumers re-subscribe per connection because the owning + /// `ServiceContainer` is rebuilt. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } + + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release their service + /// references. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } + + // MARK: - Configuration + + /// Whether a sync coordinator was injected at construction. + var hasSyncCoordinatorWired: Bool { + syncCoordinator != nil + } + + /// Whether a cleanup coordinator was injected at construction. + var hasCleanupCoordinatorWired: Bool { + cleanupCoordinator != nil + } + + // MARK: - Contact Sync + + /// Sync all contacts from device + /// - Parameters: + /// - radioID: The device to sync from + /// - since: Optional date for incremental sync (only contacts modified after this time) + /// - Returns: Sync result with count and timestamp + public func syncContacts(radioID: UUID, since: Date? = nil) async throws -> ContactSyncResult { + do { + let meshContacts = try await session.getContacts(since: since) + + eventBroadcaster.yield(.syncProgress(received: 0, total: meshContacts.count)) + + // Build set of public keys from device for cleanup + let devicePublicKeys = Set(meshContacts.map(\.publicKey)) + + // Persist every received contact in a single transaction rather than one + // commit per contact; the per-item save was redundant overhead on every sync. + let frames = meshContacts.map { $0.toContactFrame() } + let receivedCount = try await dataStore.batchSaveContacts(radioID: radioID, from: frames) + + let lastTimestamp = meshContacts + .map { UInt32($0.lastModified.timeIntervalSince1970) } + .max() ?? 0 + + eventBroadcaster.yield(.syncProgress(received: receivedCount, total: meshContacts.count)) + + // On full sync, remove local contacts that no longer exist on device + if since == nil { + let localContacts = try await dataStore.fetchContacts(radioID: radioID) + let orphans = localContacts.filter { !devicePublicKeys.contains($0.publicKey) } + if !orphans.isEmpty { + logger.notice("Full sync prune: \(orphans.count) local contact(s) not found on device (device has \(devicePublicKeys.count), local has \(localContacts.count))") } - } - - // MARK: - Get Contact - - /// Get a specific contact by public key from local database - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - /// - Returns: The contact if found - public func getContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { - try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) - } - - // MARK: - Add/Update Contact - - /// Add or update a contact on the device - /// - Parameters: - /// - radioID: The device ID - /// - contact: The contact to add/update - public func addOrUpdateContact(radioID: UUID, contact: ContactFrame) async throws { - do { - let meshContact = contact.toMeshContact() - try await session.addContact(meshContact) - - // Save to local database - _ = try await dataStore.saveContact(radioID: radioID, from: contact) - - // Notify UI to refresh contacts list - await syncCoordinator?.notifyContactsChanged() - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.tableFull.rawValue { - throw ContactServiceError.contactTableFull - } - throw ContactServiceError.sessionError(error) + for localContact in orphans { + let keyPrefix = localContact.publicKey.prefix(4).map { String(format: "%02x", $0) }.joined() + logger.notice("Full sync prune: deleting '\(localContact.name)' [\(keyPrefix)…] (favorite=\(localContact.isFavorite), type=\(localContact.typeRawValue), lastModified=\(localContact.lastModified))") + try await dataStore.deleteMessagesForContact(contactID: localContact.id) + try await dataStore.deleteContact(id: localContact.id) + await cleanupCoordinator?.handleCleanup( + contactID: localContact.id, reason: .deleted, publicKey: localContact.publicKey + ) } + } + + return ContactSyncResult( + contactsReceived: receivedCount, + lastSyncTimestamp: lastTimestamp, + isIncremental: since != nil + ) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } + } + + // MARK: - Get Contact + + /// Get a specific contact by public key from local database + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + /// - Returns: The contact if found + public func getContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { + try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) + } + + // MARK: - Add/Update Contact + + /// Add or update a contact on the device + /// - Parameters: + /// - radioID: The device ID + /// - contact: The contact to add/update + public func addOrUpdateContact(radioID: UUID, contact: ContactFrame) async throws { + do { + let meshContact = contact.toMeshContact() + try await session.addContact(meshContact) + + // Save to local database + _ = try await dataStore.saveContact(radioID: radioID, from: contact) + + // Notify UI to refresh contacts list + await syncCoordinator?.notifyContactsChanged() + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.tableFull.rawValue { + throw ContactServiceError.contactTableFull + } + throw ContactServiceError.sessionError(error) + } + } - // MARK: - Remove Contact - - /// Remove a contact from the device - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - public func removeContact(radioID: UUID, publicKey: Data) async throws { - do { - try await session.removeContact(publicKey: publicKey) - - // Remove from local database - if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { - let contactID = contact.id - - // Delete associated messages first - try await dataStore.deleteMessagesForContact(contactID: contactID) - - // Delete the contact - try await dataStore.deleteContact(id: contactID) - - // Trigger cleanup (notifications, badge, session) - await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .deleted, publicKey: publicKey) - } + // MARK: - Remove Contact - // Notify that a node was deleted (for clearing storage full flag) - eventBroadcaster.yield(.nodeDeleted) + /// Remove a contact from the device + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + public func removeContact(radioID: UUID, publicKey: Data) async throws { + do { + try await session.removeContact(publicKey: publicKey) - // Notify UI to refresh contacts list - await syncCoordinator?.notifyContactsChanged() - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - throw ContactServiceError.contactNotFound - } - throw ContactServiceError.sessionError(error) - } - } + // Remove from local database + if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { + let contactID = contact.id - /// Remove a contact's local data and run the full cleanup chain without contacting the device. - /// Use when the device reports the contact doesn't exist but local data remains. - public func removeLocalContact(contactID: UUID, publicKey: Data) async throws { + // Delete associated messages first try await dataStore.deleteMessagesForContact(contactID: contactID) + + // Delete the contact try await dataStore.deleteContact(id: contactID) + + // Trigger cleanup (notifications, badge, session) await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .deleted, publicKey: publicKey) - eventBroadcaster.yield(.nodeDeleted) - await syncCoordinator?.notifyContactsChanged() + } + + // Notify that a node was deleted (for clearing storage full flag) + eventBroadcaster.yield(.nodeDeleted) + + // Notify UI to refresh contacts list + await syncCoordinator?.notifyContactsChanged() + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + throw ContactServiceError.contactNotFound + } + throw ContactServiceError.sessionError(error) } - - /// Clears all messages for a direct conversation without deleting the contact. - /// Preserves `lastMessageDate` so the now-empty conversation stays in the chats list - /// (showing "No messages"), unlike delete-conversation which nils the date. Also clears - /// both unread counters and notifies observers so no stale badge or preview survives. - public func clearContactMessages(contactID: UUID) async throws { - try await dataStore.deleteMessagesForContact(contactID: contactID) - try await dataStore.clearUnreadCount(contactID: contactID) - try await dataStore.clearUnreadMentionCount(contactID: contactID) - await syncCoordinator?.notifyConversationsChanged() + } + + /// Remove a contact's local data and run the full cleanup chain without contacting the device. + /// Use when the device reports the contact doesn't exist but local data remains. + public func removeLocalContact(contactID: UUID, publicKey: Data) async throws { + try await dataStore.deleteMessagesForContact(contactID: contactID) + try await dataStore.deleteContact(id: contactID) + await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .deleted, publicKey: publicKey) + eventBroadcaster.yield(.nodeDeleted) + await syncCoordinator?.notifyContactsChanged() + } + + /// Clears all messages for a direct conversation without deleting the contact. + /// Preserves `lastMessageDate` so the now-empty conversation stays in the chats list + /// (showing "No messages"), unlike delete-conversation which nils the date. Also clears + /// both unread counters and notifies observers so no stale badge or preview survives. + public func clearContactMessages(contactID: UUID) async throws { + try await dataStore.deleteMessagesForContact(contactID: contactID) + try await dataStore.clearUnreadCount(contactID: contactID) + try await dataStore.clearUnreadMentionCount(contactID: contactID) + await syncCoordinator?.notifyConversationsChanged() + } + + // MARK: - Reset Path + + /// Reset the path for a contact (force rediscovery) + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + public func resetPath(radioID: UUID, publicKey: Data) async throws { + do { + try await session.resetPath(publicKey: publicKey) + + // Update local contact to show flood routing + if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { + let frame = contact.floodedContactFrame(asOf: UInt32(Date().timeIntervalSince1970)) + _ = try await dataStore.saveContact(radioID: radioID, from: frame) + } + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + throw ContactServiceError.contactNotFound + } + throw ContactServiceError.sessionError(error) } - - // MARK: - Reset Path - - /// Reset the path for a contact (force rediscovery) - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - public func resetPath(radioID: UUID, publicKey: Data) async throws { - do { - try await session.resetPath(publicKey: publicKey) - - // Update local contact to show flood routing - if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { - let frame = ContactFrame( - publicKey: contact.publicKey, - type: contact.type, - flags: contact.flags, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: contact.name, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) - ) - _ = try await dataStore.saveContact(radioID: radioID, from: frame) - } - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - throw ContactServiceError.contactNotFound - } - throw ContactServiceError.sessionError(error) - } + } + + // MARK: - Path Discovery + + /// Send a path discovery request to find optimal route to contact + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + /// - Returns: MessageSentInfo containing the estimated timeout from firmware + public func sendPathDiscovery(radioID: UUID, publicKey: Data) async throws -> MessageSentInfo { + do { + return try await session.sendPathDiscovery(to: publicKey) + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + throw ContactServiceError.contactNotFound + } + throw ContactServiceError.sessionError(error) } - - // MARK: - Path Discovery - - /// Send a path discovery request to find optimal route to contact - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - /// - Returns: MessageSentInfo containing the estimated timeout from firmware - public func sendPathDiscovery(radioID: UUID, publicKey: Data) async throws -> MessageSentInfo { - do { - return try await session.sendPathDiscovery(to: publicKey) - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - throw ContactServiceError.contactNotFound - } - throw ContactServiceError.sessionError(error) - } + } + + // MARK: - Set Path + + /// Set a specific path for a contact + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + /// - path: The path data (repeater hashes) + /// - pathLength: Encoded path length byte (0xFF for flood, 0 for direct, >0 for routed) + public func setPath(radioID: UUID, publicKey: Data, path: Data, pathLength: UInt8) async throws { + // Get current contact to preserve other fields + guard let existingContact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) else { + throw ContactServiceError.contactNotFound } - // MARK: - Set Path - - /// Set a specific path for a contact - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - /// - path: The path data (repeater hashes) - /// - pathLength: Encoded path length byte (0xFF for flood, 0 for direct, >0 for routed) - public func setPath(radioID: UUID, publicKey: Data, path: Data, pathLength: UInt8) async throws { - // Get current contact to preserve other fields - guard let existingContact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) else { - throw ContactServiceError.contactNotFound - } - - // Create updated contact frame with new path - let updatedFrame = ContactFrame( - publicKey: existingContact.publicKey, - type: existingContact.type, - flags: existingContact.flags, - outPathLength: pathLength, - outPath: path, - name: existingContact.name, - lastAdvertTimestamp: existingContact.lastAdvertTimestamp, - latitude: existingContact.latitude, - longitude: existingContact.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) - ) - - // Send update to device - try await addOrUpdateContact(radioID: radioID, contact: updatedFrame) + // Create updated contact frame with new path + let updatedFrame = ContactFrame( + publicKey: existingContact.publicKey, + type: existingContact.type, + flags: existingContact.flags, + outPathLength: pathLength, + outPath: path, + name: existingContact.name, + lastAdvertTimestamp: existingContact.lastAdvertTimestamp, + latitude: existingContact.latitude, + longitude: existingContact.longitude, + lastModified: UInt32(Date().timeIntervalSince1970) + ) + + // Send update to device + try await addOrUpdateContact(radioID: radioID, contact: updatedFrame) + } + + // MARK: - Share Contact + + /// Share a contact via zero-hop broadcast + /// - Parameter publicKey: The contact's 32-byte public key to share + public func shareContact(publicKey: Data) async throws { + do { + try await session.shareContact(publicKey: publicKey) + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.tableFull.rawValue { + throw ContactServiceError.shareContactUnavailable + } + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + throw ContactServiceError.contactNotFound + } + throw ContactServiceError.sessionError(error) } - - // MARK: - Share Contact - - /// Share a contact via zero-hop broadcast - /// - Parameter publicKey: The contact's 32-byte public key to share - public func shareContact(publicKey: Data) async throws { - do { - try await session.shareContact(publicKey: publicKey) - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.tableFull.rawValue { - throw ContactServiceError.shareContactUnavailable - } - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - throw ContactServiceError.contactNotFound - } - throw ContactServiceError.sessionError(error) - } + } + + // MARK: - Export/Import Contact + + /// Export a contact to a shareable URI (legacy firmware call) + /// - Parameter publicKey: The contact's 32-byte public key (nil for self) + /// - Returns: Contact URI string + @available(*, deprecated, message: "Use exportContactURI(name:publicKey:type:) instead") + public func exportContact(publicKey: Data? = nil) async throws -> String { + do { + return try await session.exportContact(publicKey: publicKey) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } - - // MARK: - Export/Import Contact - - /// Export a contact to a shareable URI (legacy firmware call) - /// - Parameter publicKey: The contact's 32-byte public key (nil for self) - /// - Returns: Contact URI string - @available(*, deprecated, message: "Use exportContactURI(name:publicKey:type:) instead") - public func exportContact(publicKey: Data? = nil) async throws -> String { - do { - return try await session.exportContact(publicKey: publicKey) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) - } + } + + private static let contactURIScheme = "meshcore" + private static let contactURIHost = "contact" + private static let contactURIPath = "/add" + private static let contactURINameKey = "name" + private static let contactURIPublicKeyKey = "public_key" + private static let contactURITypeKey = "type" + + /// Build a shareable contact URI from contact information + /// - Parameters: + /// - name: The contact's advertised name + /// - publicKey: The contact's 32-byte public key + /// - type: The contact type (chat, repeater, room) + /// - Returns: Contact URI string in format: meshcore://contact/add?name=...&public_key=...&type=... + public static func exportContactURI(name: String, publicKey: Data, type: ContactType) -> String { + // Build via URLComponents so reserved characters in the name (`&`, `=`, `+`, `?`) are + // percent-encoded per query item. String interpolation would let a crafted name inject + // its own public_key/type and spoof the parsed contact identity. + var components = URLComponents() + components.scheme = contactURIScheme + components.host = contactURIHost + components.path = contactURIPath + components.queryItems = [ + URLQueryItem(name: contactURINameKey, value: name), + URLQueryItem(name: contactURIPublicKeyKey, value: publicKey.uppercaseHexString()), + URLQueryItem(name: contactURITypeKey, value: String(type.rawValue)) + ] + return components.url?.absoluteString ?? "" + } + + /// Import a contact from card data + /// - Parameter cardData: The contact card data + public func importContact(cardData: Data) async throws { + do { + try await session.importContact(cardData: cardData) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } - - private static let contactURIScheme = "meshcore" - private static let contactURIHost = "contact" - private static let contactURIPath = "/add" - private static let contactURINameKey = "name" - private static let contactURIPublicKeyKey = "public_key" - private static let contactURITypeKey = "type" - - /// Build a shareable contact URI from contact information - /// - Parameters: - /// - name: The contact's advertised name - /// - publicKey: The contact's 32-byte public key - /// - type: The contact type (chat, repeater, room) - /// - Returns: Contact URI string in format: meshcore://contact/add?name=...&public_key=...&type=... - public static func exportContactURI(name: String, publicKey: Data, type: ContactType) -> String { - // Build via URLComponents so reserved characters in the name (`&`, `=`, `+`, `?`) are - // percent-encoded per query item. String interpolation would let a crafted name inject - // its own public_key/type and spoof the parsed contact identity. - var components = URLComponents() - components.scheme = contactURIScheme - components.host = contactURIHost - components.path = contactURIPath - components.queryItems = [ - URLQueryItem(name: contactURINameKey, value: name), - URLQueryItem(name: contactURIPublicKeyKey, value: publicKey.uppercaseHexString()), - URLQueryItem(name: contactURITypeKey, value: String(type.rawValue)) - ] - return components.url?.absoluteString ?? "" + } + + // MARK: - Local Database Operations + + /// Get all contacts for a device from local database + public func getContacts(radioID: UUID) async throws -> [ContactDTO] { + try await dataStore.fetchContacts(radioID: radioID) + } + + /// Get conversations (contacts with messages) from local database + public func getConversations(radioID: UUID) async throws -> [ContactDTO] { + try await dataStore.fetchConversations(radioID: radioID) + } + + /// Get a contact by ID from local database + public func getContactByID(_ id: UUID) async throws -> ContactDTO? { + try await dataStore.fetchContact(id: id) + } + + /// Update local contact preferences (nickname, blocked, favorite). + /// `nickname`: `nil` leaves the existing nickname unchanged; an empty or + /// whitespace-only string clears it. A non-empty value is trimmed and stored. + public func updateContactPreferences( + contactID: UUID, + nickname: String? = nil, + isBlocked: Bool? = nil, + isFavorite: Bool? = nil + ) async throws { + guard let existing = try await dataStore.fetchContact(id: contactID) else { + throw ContactServiceError.contactNotFound } - /// Import a contact from card data - /// - Parameter cardData: The contact card data - public func importContact(cardData: Data) async throws { - do { - try await session.importContact(cardData: cardData) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) - } + // nil => leave unchanged; empty/whitespace => clear; otherwise trim and set. + let resolvedNickname: String? + if let nickname { + let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + resolvedNickname = trimmed.isEmpty ? nil : trimmed + } else { + resolvedNickname = existing.nickname } - // MARK: - Local Database Operations - - /// Get all contacts for a device from local database - public func getContacts(radioID: UUID) async throws -> [ContactDTO] { - try await dataStore.fetchContacts(radioID: radioID) + // Check blocking state transitions + let isBeingBlocked = isBlocked == true && !existing.isBlocked + let isBeingUnblocked = isBlocked == false && existing.isBlocked + + // Create updated DTO preserving existing values + let updated = ContactDTO( + from: Contact( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + flags: existing.flags, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: existing.lastModified, + nickname: resolvedNickname, + isBlocked: isBlocked ?? existing.isBlocked, + isMuted: existing.isMuted, + isFavorite: isFavorite ?? existing.isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: isBeingBlocked ? 0 : existing.unreadCount, + unreadMentionCount: existing.unreadMentionCount, + ocvPreset: existing.ocvPreset, + customOCVArrayString: existing.customOCVArrayString + ) + ) + + try await dataStore.saveContact(updated) + + // Trigger cleanup for blocking state changes + if isBeingBlocked { + await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .blocked, publicKey: existing.publicKey) + } else if isBeingUnblocked { + await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .unblocked, publicKey: existing.publicKey) } - - /// Get conversations (contacts with messages) from local database - public func getConversations(radioID: UUID) async throws -> [ContactDTO] { - try await dataStore.fetchConversations(radioID: radioID) + } + + /// Updates OCV settings for a contact + /// - Parameters: + /// - contactID: The contact's ID + /// - preset: The OCV preset name + /// - customArray: Custom OCV array string (for custom preset) + public func updateContactOCVSettings( + contactID: UUID, + preset: String, + customArray: String? + ) async throws { + guard let existing = try await dataStore.fetchContact(id: contactID) else { + throw ContactServiceError.contactNotFound } - /// Get a contact by ID from local database - public func getContactByID(_ id: UUID) async throws -> ContactDTO? { - try await dataStore.fetchContact(id: id) + let updated = ContactDTO( + from: Contact( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + flags: existing.flags, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: existing.lastModified, + nickname: existing.nickname, + isBlocked: existing.isBlocked, + isFavorite: existing.isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + ocvPreset: preset, + customOCVArrayString: customArray + ) + ) + + try await dataStore.saveContact(updated) + } + + // MARK: - Device Favorite Sync + + /// Sets a contact's favorite status on the device and updates local storage. + /// + /// This method updates the device's contact flags (bit 0 = favorite), waits for + /// confirmation, then updates the local SwiftData contact. + /// + /// - Parameters: + /// - contactID: The contact's UUID. + /// - isFavorite: Whether to mark the contact as a favorite. + /// - Throws: `ContactServiceError` if the device update fails. + public func setContactFavorite(_ contactID: UUID, isFavorite: Bool) async throws { + guard let existing = try await dataStore.fetchContact(id: contactID) else { + throw ContactServiceError.contactNotFound } - /// Update local contact preferences (nickname, blocked, favorite) - public func updateContactPreferences( - contactID: UUID, - nickname: String? = nil, - isBlocked: Bool? = nil, - isFavorite: Bool? = nil - ) async throws { - guard let existing = try await dataStore.fetchContact(id: contactID) else { - throw ContactServiceError.contactNotFound - } - - // Check blocking state transitions - let isBeingBlocked = isBlocked == true && !existing.isBlocked - let isBeingUnblocked = isBlocked == false && existing.isBlocked - - // Create updated DTO preserving existing values - let updated = ContactDTO( - from: Contact( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - flags: existing.flags, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: existing.lastModified, - nickname: nickname ?? existing.nickname, - isBlocked: isBlocked ?? existing.isBlocked, - isFavorite: isFavorite ?? existing.isFavorite, - lastMessageDate: existing.lastMessageDate, - unreadCount: isBeingBlocked ? 0 : existing.unreadCount, - ocvPreset: existing.ocvPreset, - customOCVArrayString: existing.customOCVArrayString - ) - ) - - try await dataStore.saveContact(updated) - - // Trigger cleanup for blocking state changes - if isBeingBlocked { - await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .blocked, publicKey: existing.publicKey) - } else if isBeingUnblocked { - await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .unblocked, publicKey: existing.publicKey) - } + // Calculate new flags: set or clear bit 0 + let newFlags: UInt8 = isFavorite + ? existing.flags | 0x01 + : existing.flags & ~0x01 + + // Build MeshContact for device update + let meshContact = MeshContact( + id: existing.publicKey.uppercaseHexString(), + publicKey: existing.publicKey, + type: ContactType(rawValue: existing.typeRawValue) ?? .chat, + flags: ContactFlags(rawValue: existing.flags), + outPathLength: existing.outPathLength, + outPath: existing.outPath, + advertisedName: existing.name, + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(existing.lastAdvertTimestamp)), + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: Date(timeIntervalSince1970: TimeInterval(existing.lastModified)) + ) + + // Push to device and wait for confirmation + do { + try await session.changeContactFlags(meshContact, flags: ContactFlags(rawValue: newFlags)) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } - /// Updates OCV settings for a contact - /// - Parameters: - /// - contactID: The contact's ID - /// - preset: The OCV preset name - /// - customArray: Custom OCV array string (for custom preset) - public func updateContactOCVSettings( - contactID: UUID, - preset: String, - customArray: String? - ) async throws { - guard let existing = try await dataStore.fetchContact(id: contactID) else { - throw ContactServiceError.contactNotFound - } - - let updated = ContactDTO( - from: Contact( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - flags: existing.flags, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: existing.lastModified, - nickname: existing.nickname, - isBlocked: existing.isBlocked, - isFavorite: existing.isFavorite, - lastMessageDate: existing.lastMessageDate, - unreadCount: existing.unreadCount, - ocvPreset: preset, - customOCVArrayString: customArray - ) - ) - - try await dataStore.saveContact(updated) + // Device confirmed - update local storage + let updated = ContactDTO( + from: Contact( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + flags: newFlags, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: existing.lastModified, + nickname: existing.nickname, + isBlocked: existing.isBlocked, + isFavorite: isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + ocvPreset: existing.ocvPreset, + customOCVArrayString: existing.customOCVArrayString + ) + ) + + try await dataStore.saveContact(updated) + } + + // MARK: - Telemetry Permissions + + /// Whether a contact has telemetry permission flags set (bits 1-3). + public static func hasTelemetryPermissions(flags: UInt8) -> Bool { + (flags & 0x0E) != 0 + } + + /// Sets telemetry permission flags on a contact's device record. + /// Bits 1-3 of contact.flags control base/location/environment permissions. + /// Bit 0 (favourite) is preserved. + /// + /// - Parameters: + /// - contactID: The contact's UUID. + /// - granted: Whether to grant telemetry permissions. + /// - Throws: `ContactServiceError` if the device update fails. + public func setTelemetryPermissions(_ contactID: UUID, granted: Bool) async throws { + guard let existing = try await dataStore.fetchContact(id: contactID) else { + throw ContactServiceError.contactNotFound } - // MARK: - Device Favorite Sync - - /// Sets a contact's favorite status on the device and updates local storage. - /// - /// This method updates the device's contact flags (bit 0 = favorite), waits for - /// confirmation, then updates the local SwiftData contact. - /// - /// - Parameters: - /// - contactID: The contact's UUID. - /// - isFavorite: Whether to mark the contact as a favorite. - /// - Throws: `ContactServiceError` if the device update fails. - public func setContactFavorite(_ contactID: UUID, isFavorite: Bool) async throws { - guard let existing = try await dataStore.fetchContact(id: contactID) else { - throw ContactServiceError.contactNotFound - } - - // Calculate new flags: set or clear bit 0 - let newFlags: UInt8 = isFavorite - ? existing.flags | 0x01 - : existing.flags & ~0x01 - - // Build MeshContact for device update - let meshContact = MeshContact( - id: existing.publicKey.uppercaseHexString(), - publicKey: existing.publicKey, - type: ContactType(rawValue: existing.typeRawValue) ?? .chat, - flags: ContactFlags(rawValue: existing.flags), - outPathLength: existing.outPathLength, - outPath: existing.outPath, - advertisedName: existing.name, - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(existing.lastAdvertTimestamp)), - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: Date(timeIntervalSince1970: TimeInterval(existing.lastModified)) - ) - - // Push to device and wait for confirmation - do { - try await session.changeContactFlags(meshContact, flags: ContactFlags(rawValue: newFlags)) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) - } - - // Device confirmed - update local storage - let updated = ContactDTO( - from: Contact( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - flags: newFlags, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: existing.lastModified, - nickname: existing.nickname, - isBlocked: existing.isBlocked, - isFavorite: isFavorite, - lastMessageDate: existing.lastMessageDate, - unreadCount: existing.unreadCount, - ocvPreset: existing.ocvPreset, - customOCVArrayString: existing.customOCVArrayString - ) - ) - - try await dataStore.saveContact(updated) + // Set or clear bits 1-3, preserving bit 0 (favourite) + let newFlags: UInt8 = granted + ? existing.flags | 0x0E + : existing.flags & ~0x0E + + // Build MeshContact for device update + let meshContact = MeshContact( + id: existing.publicKey.uppercaseHexString(), + publicKey: existing.publicKey, + type: ContactType(rawValue: existing.typeRawValue) ?? .chat, + flags: ContactFlags(rawValue: existing.flags), + outPathLength: existing.outPathLength, + outPath: existing.outPath, + advertisedName: existing.name, + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(existing.lastAdvertTimestamp)), + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: Date(timeIntervalSince1970: TimeInterval(existing.lastModified)) + ) + + // Push to device and wait for confirmation + do { + try await session.changeContactFlags(meshContact, flags: ContactFlags(rawValue: newFlags)) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } - // MARK: - Telemetry Permissions - - /// Whether a contact has telemetry permission flags set (bits 1-3). - public static func hasTelemetryPermissions(flags: UInt8) -> Bool { - (flags & 0x0E) != 0 + // Device confirmed - update local storage + let updated = ContactDTO( + from: Contact( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + flags: newFlags, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: existing.lastModified, + nickname: existing.nickname, + isBlocked: existing.isBlocked, + isFavorite: existing.isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + ocvPreset: existing.ocvPreset, + customOCVArrayString: existing.customOCVArrayString + ) + ) + + try await dataStore.saveContact(updated) + } + + // MARK: - Favorites Migration + + private static let favoritesMigrationKey = "hasMigratedContactFavorites" + + /// Migrates existing app favorites to device flags (one-time operation). + /// + /// On first run after upgrade, this merges app favorites with device favorites: + /// any contact that is favorite in either location becomes favorite in both. + /// After migration, device wins for future syncs. + /// + /// - Parameter radioID: The connected device's UUID. + /// - Returns: Number of contacts migrated to device. + @discardableResult + public func migrateAppFavoritesToDevice(radioID: UUID) async throws -> Int { + // Check if already migrated + if UserDefaults.standard.bool(forKey: Self.favoritesMigrationKey) { + return 0 } - /// Sets telemetry permission flags on a contact's device record. - /// Bits 1-3 of contact.flags control base/location/environment permissions. - /// Bit 0 (favourite) is preserved. - /// - /// - Parameters: - /// - contactID: The contact's UUID. - /// - granted: Whether to grant telemetry permissions. - /// - Throws: `ContactServiceError` if the device update fails. - public func setTelemetryPermissions(_ contactID: UUID, granted: Bool) async throws { - guard let existing = try await dataStore.fetchContact(id: contactID) else { - throw ContactServiceError.contactNotFound - } + logger.info("Starting favorites migration to device") - // Set or clear bits 1-3, preserving bit 0 (favourite) - let newFlags: UInt8 = granted - ? existing.flags | 0x0E - : existing.flags & ~0x0E - - // Build MeshContact for device update - let meshContact = MeshContact( - id: existing.publicKey.uppercaseHexString(), - publicKey: existing.publicKey, - type: ContactType(rawValue: existing.typeRawValue) ?? .chat, - flags: ContactFlags(rawValue: existing.flags), - outPathLength: existing.outPathLength, - outPath: existing.outPath, - advertisedName: existing.name, - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(existing.lastAdvertTimestamp)), - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: Date(timeIntervalSince1970: TimeInterval(existing.lastModified)) - ) - - // Push to device and wait for confirmation - do { - try await session.changeContactFlags(meshContact, flags: ContactFlags(rawValue: newFlags)) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) - } - - // Device confirmed - update local storage - let updated = ContactDTO( - from: Contact( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - flags: newFlags, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: existing.lastModified, - nickname: existing.nickname, - isBlocked: existing.isBlocked, - isFavorite: existing.isFavorite, - lastMessageDate: existing.lastMessageDate, - unreadCount: existing.unreadCount, - ocvPreset: existing.ocvPreset, - customOCVArrayString: existing.customOCVArrayString - ) - ) - - try await dataStore.saveContact(updated) + // Find contacts that are favorite in app but not on device + let contacts = try await dataStore.fetchContacts(radioID: radioID) + let toMigrate = contacts.filter { contact in + contact.isFavorite && (contact.flags & 0x01) == 0 } - // MARK: - Favorites Migration - - private static let favoritesMigrationKey = "hasMigratedContactFavorites" - - /// Migrates existing app favorites to device flags (one-time operation). - /// - /// On first run after upgrade, this merges app favorites with device favorites: - /// any contact that is favorite in either location becomes favorite in both. - /// After migration, device wins for future syncs. - /// - /// - Parameter radioID: The connected device's UUID. - /// - Returns: Number of contacts migrated to device. - @discardableResult - public func migrateAppFavoritesToDevice(radioID: UUID) async throws -> Int { - // Check if already migrated - if UserDefaults.standard.bool(forKey: Self.favoritesMigrationKey) { - return 0 - } - - logger.info("Starting favorites migration to device") - - // Find contacts that are favorite in app but not on device - let contacts = try await dataStore.fetchContacts(radioID: radioID) - let toMigrate = contacts.filter { contact in - contact.isFavorite && (contact.flags & 0x01) == 0 - } - - if toMigrate.isEmpty { - logger.info("No favorites to migrate, marking complete") - UserDefaults.standard.set(true, forKey: Self.favoritesMigrationKey) - return 0 - } - - logger.info("Migrating \(toMigrate.count) favorites to device") - - var migratedCount = 0 - for contact in toMigrate { - do { - try await setContactFavorite(contact.id, isFavorite: true) - migratedCount += 1 - } catch { - logger.warning("Failed to migrate favorite for \(contact.name): \(error)") - // Continue with other contacts, will retry on next connect - } - } + if toMigrate.isEmpty { + logger.info("No favorites to migrate, marking complete") + UserDefaults.standard.set(true, forKey: Self.favoritesMigrationKey) + return 0 + } - // Only mark complete if all were migrated - if migratedCount == toMigrate.count { - UserDefaults.standard.set(true, forKey: Self.favoritesMigrationKey) - logger.info("Favorites migration complete: \(migratedCount) contacts") - } else { - logger.warning("Partial migration: \(migratedCount)/\(toMigrate.count), will retry") - } + logger.info("Migrating \(toMigrate.count) favorites to device") + + var migratedCount = 0 + for contact in toMigrate { + do { + try await setContactFavorite(contact.id, isFavorite: true) + migratedCount += 1 + } catch { + logger.warning("Failed to migrate favorite for \(contact.name): \(error)") + // Continue with other contacts, will retry on next connect + } + } - return migratedCount + // Only mark complete if all were migrated + if migratedCount == toMigrate.count { + UserDefaults.standard.set(true, forKey: Self.favoritesMigrationKey) + logger.info("Favorites migration complete: \(migratedCount) contacts") + } else { + logger.warning("Partial migration: \(migratedCount)/\(toMigrate.count), will retry") } + + return migratedCount + } } // MARK: - ContactServiceProtocol Conformance extension ContactService: ContactServiceProtocol { - // Already implements syncContacts(radioID:since:) -> ContactSyncResult + // Already implements syncContacts(radioID:since:) -> ContactSyncResult } // MARK: - MeshContact Extensions extension MeshContact { - /// Converts a MeshContact to a ContactFrame for persistence - func toContactFrame() -> ContactFrame { - ContactFrame( - publicKey: publicKey, - type: type, - typeRawValue: typeRawValue, - flags: flags.rawValue, - outPathLength: outPathLength, - outPath: outPath, - name: advertisedName, - lastAdvertTimestamp: UInt32(lastAdvertisement.timeIntervalSince1970), - latitude: latitude, - longitude: longitude, - lastModified: UInt32(lastModified.timeIntervalSince1970) - ) - } + /// Converts a MeshContact to a ContactFrame for persistence + func toContactFrame() -> ContactFrame { + ContactFrame( + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: flags.rawValue, + outPathLength: outPathLength, + outPath: outPath, + name: advertisedName, + lastAdvertTimestamp: UInt32(lastAdvertisement.timeIntervalSince1970), + latitude: latitude, + longitude: longitude, + lastModified: UInt32(lastModified.timeIntervalSince1970) + ) + } } // MARK: - ContactFrame Extensions -extension ContactFrame { - /// Converts a ContactFrame to a MeshContact for session operations - public func toMeshContact() -> MeshContact { - MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: type, - typeRawValue: typeRawValue, - flags: ContactFlags(rawValue: flags), - outPathLength: outPathLength, - outPath: outPath, - advertisedName: name, - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(lastAdvertTimestamp)), - latitude: latitude, - longitude: longitude, - lastModified: Date(timeIntervalSince1970: TimeInterval(lastModified)) - ) - } +public extension ContactFrame { + /// Converts a ContactFrame to a MeshContact for session operations + func toMeshContact() -> MeshContact { + MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: ContactFlags(rawValue: flags), + outPathLength: outPathLength, + outPath: outPath, + advertisedName: name, + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(lastAdvertTimestamp)), + latitude: latitude, + longitude: longitude, + lastModified: Date(timeIntervalSince1970: TimeInterval(lastModified)) + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/ContactServiceEvent.swift b/MC1Services/Sources/MC1Services/Services/ContactServiceEvent.swift index 1ef45f11..1fa213d1 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactServiceEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactServiceEvent.swift @@ -3,9 +3,9 @@ import Foundation /// One-to-many notifications broadcast by `ContactService` to its /// `events()` subscribers. public enum ContactServiceEvent: Sendable { - /// Progress during a contact sync: `received` of `total` contacts persisted. - case syncProgress(received: Int, total: Int) + /// Progress during a contact sync: `received` of `total` contacts persisted. + case syncProgress(received: Int, total: Int) - /// A contact was removed from the device, so node storage is no longer full. - case nodeDeleted + /// A contact was removed from the device, so node storage is no longer full. + case nodeDeleted } diff --git a/MC1Services/Sources/MC1Services/Services/DebugLogBuffer.swift b/MC1Services/Sources/MC1Services/Services/DebugLogBuffer.swift index ce154c7d..d5b5e4aa 100644 --- a/MC1Services/Sources/MC1Services/Services/DebugLogBuffer.swift +++ b/MC1Services/Sources/MC1Services/Services/DebugLogBuffer.swift @@ -4,88 +4,175 @@ import os /// Actor for buffering debug log entries and flushing to persistence. /// Provides batched saves for performance and backpressure handling. public actor DebugLogBuffer { - /// Backing storage for the shared buffer. Reassigned on every connection from - /// `ServiceContainer.init` while `PersistentLogger` reads it from arbitrary - /// actors, so the write and the reads must be synchronized. - private static let sharedStorage = OSAllocatedUnfairLock(initialState: nil) - - /// Shared buffer instance for app-wide logging. - public static var shared: DebugLogBuffer? { - get { sharedStorage.withLock { $0 } } - set { sharedStorage.withLock { $0 = newValue } } - } + /// The current shared buffer and the entries recorded while none exists, behind + /// one lock so `record` and the `shared` setter are atomic with respect to each + /// other: an entry either reaches the current buffer or waits in `pending` for + /// the next assignment to drain; it can't fall between the two. + private struct SharedState { + var buffer: DebugLogBuffer? + var pending: [DebugLogEntryDTO] = [] + } - private let dataStore: any DebugLogPersisting - private var buffer: [DebugLogEntryDTO] = [] - private var flushTask: Task? - private var isFlushScheduled = false - private let flushInterval: Duration = .seconds(5) - private let maxBufferSize = 50 + private static let sharedState = OSAllocatedUnfairLock(initialState: SharedState()) + static let maxPendingEntries = 500 - private static let logger = Logger(subsystem: "com.mc1", category: "DebugLogBuffer") + /// Shared buffer instance for app-wide logging. Reassigned on every connection + /// from `ServiceContainer.init` while `PersistentLogger` records from arbitrary + /// actors. Assigning a buffer atomically takes every entry recorded while none + /// existed (early launch, CoreBluetooth state restoration, background relaunch, + /// between connections) and delivers them to the new buffer in record order. + public static var shared: DebugLogBuffer? { + get { sharedState.withLock { $0.buffer } } + set { + let drained = sharedState.withLock { state -> [DebugLogEntryDTO] in + state.buffer = newValue + guard newValue != nil else { return [] } + defer { state.pending = [] } + return state.pending + } + guard let newValue, !drained.isEmpty else { return } + Task { + for entry in drained { + await newValue.append(entry) + } + } + } + } - public init(dataStore: any DebugLogPersisting) { - self.dataStore = dataStore + /// Single entry point for app-wide log delivery: hands `entry` to the current + /// shared buffer, or queues it (bounded, oldest dropped first) until one is + /// assigned. + static func record(_ entry: DebugLogEntryDTO) { + let buffer = sharedState.withLock { state -> DebugLogBuffer? in + if let buffer = state.buffer { return buffer } + state.pending.append(entry) + if state.pending.count > maxPendingEntries { + state.pending.removeFirst(state.pending.count - maxPendingEntries) + } + return nil } + guard let buffer else { return } + Task { await buffer.append(entry) } + } - public func append(_ entry: DebugLogEntryDTO) { - buffer.append(entry) + /// Empties the pending queue so tests can exercise the no-buffer window from a + /// known state. + static func resetPendingStateForTesting() { + sharedState.withLock { $0.pending = [] } + } - if buffer.count >= maxBufferSize { - flushNow() - } else { - scheduleFlush() - } - } + private let dataStore: any DebugLogPersisting + private var buffer: [DebugLogEntryDTO] = [] + private var flushTask: Task? + private var isFlushScheduled = false + private let flushInterval: Duration = .seconds(5) + static let maxBufferSize = 50 - public func flush() async { - flushTask?.cancel() - flushTask = nil - isFlushScheduled = false - await flushBuffer() - } + /// Entries lost since the last successful save, to a save failure or the requeue + /// cap. Reported as a synthesized log entry on the next successful save, since + /// those windows are otherwise invisible in the persisted log itself. + private var droppedEntryCount = 0 + + /// Guards `persistDropSummaryIfNeeded` against reentrancy: `flushBuffer` runs + /// concurrently with itself via `flushNow`'s unstructured task, and two flushes + /// succeeding together must not persist duplicate summaries. + private var isPersistingDropSummary = false + + private static let logSubsystem = "com.mc1" + private static let logCategory = "DebugLogBuffer" + private static let logger = Logger(subsystem: logSubsystem, category: logCategory) + + public init(dataStore: any DebugLogPersisting) { + self.dataStore = dataStore + } - public func shutdown() async { - flushTask?.cancel() - flushTask = nil - isFlushScheduled = false - await flushBuffer() + public func append(_ entry: DebugLogEntryDTO) { + buffer.append(entry) + + if buffer.count >= Self.maxBufferSize { + flushNow() + } else { + scheduleFlush() } + } - private func scheduleFlush() { - guard !isFlushScheduled else { return } - isFlushScheduled = true + public func flush() async { + flushTask?.cancel() + flushTask = nil + isFlushScheduled = false + await flushBuffer() + } - flushTask = Task { - try? await Task.sleep(for: flushInterval) - guard !Task.isCancelled else { return } - isFlushScheduled = false - await flushBuffer() - } + public func shutdown() async { + flushTask?.cancel() + flushTask = nil + isFlushScheduled = false + await flushBuffer() + } + + private func scheduleFlush() { + guard !isFlushScheduled else { return } + isFlushScheduled = true + + flushTask = Task { + try? await Task.sleep(for: flushInterval) + guard !Task.isCancelled else { return } + isFlushScheduled = false + await flushBuffer() } + } + + private func flushNow() { + flushTask?.cancel() + flushTask = nil + isFlushScheduled = false + Task { await flushBuffer() } + } - private func flushNow() { - flushTask?.cancel() - flushTask = nil - isFlushScheduled = false - Task { await flushBuffer() } + private func flushBuffer() async { + guard !buffer.isEmpty else { return } + let entries = buffer + buffer = [] + + do { + try await dataStore.saveDebugLogEntries(entries) + await persistDropSummaryIfNeeded() + } catch { + Self.logger.error("Failed to save debug logs: \(error.localizedDescription)") + + // Backpressure: only re-queue if total won't exceed limit + let entriesToRequeue = Array(entries.prefix(Self.maxBufferSize)) + if buffer.count + entriesToRequeue.count < Self.maxBufferSize * 2 { + buffer.insert(contentsOf: entriesToRequeue, at: 0) + droppedEntryCount += entries.count - entriesToRequeue.count + } else { + droppedEntryCount += entries.count + } } + } - private func flushBuffer() async { - guard !buffer.isEmpty else { return } - let entries = buffer - buffer = [] - - do { - try await dataStore.saveDebugLogEntries(entries) - } catch { - Self.logger.error("Failed to save debug logs: \(error.localizedDescription)") - - // Backpressure: only re-queue if total won't exceed limit - let entriesToRequeue = Array(entries.prefix(maxBufferSize)) - if buffer.count + entriesToRequeue.count < maxBufferSize * 2 { - buffer.insert(contentsOf: entriesToRequeue, at: 0) - } - } + /// Persists a summary of entries dropped since the last successful save. Left + /// uncounted on failure so the loss carries forward to the next attempt instead + /// of being silently reset. Snapshots the count before the save because the + /// actor is reentrant across that await: drops recorded while the save is + /// suspended must survive, so only the reported amount is subtracted on success. + private func persistDropSummaryIfNeeded() async { + guard droppedEntryCount > 0, !isPersistingDropSummary else { return } + isPersistingDropSummary = true + defer { isPersistingDropSummary = false } + + let reported = droppedEntryCount + let summary = DebugLogEntryDTO( + level: .warning, + subsystem: Self.logSubsystem, + category: Self.logCategory, + message: "Lost \(reported) log entries due to prior save failures" + ) + do { + try await dataStore.saveDebugLogEntries([summary]) + droppedEntryCount -= reported + } catch { + Self.logger.error("Failed to persist dropped-entry summary: \(error.localizedDescription)") } + } } diff --git a/MC1Services/Sources/MC1Services/Services/DeviceGPSState.swift b/MC1Services/Sources/MC1Services/Services/DeviceGPSState.swift index ae515618..a8ba195b 100644 --- a/MC1Services/Sources/MC1Services/Services/DeviceGPSState.swift +++ b/MC1Services/Sources/MC1Services/Services/DeviceGPSState.swift @@ -1,11 +1,11 @@ import Foundation public struct DeviceGPSState: Sendable, Equatable { - public let isSupported: Bool - public let isEnabled: Bool + public let isSupported: Bool + public let isEnabled: Bool - public init(isSupported: Bool, isEnabled: Bool) { - self.isSupported = isSupported - self.isEnabled = isEnabled - } + public init(isSupported: Bool, isEnabled: Bool) { + self.isSupported = isSupported + self.isEnabled = isEnabled + } } diff --git a/MC1Services/Sources/MC1Services/Services/DevicePairingDelegate.swift b/MC1Services/Sources/MC1Services/Services/DevicePairingDelegate.swift index 17d51f45..33d4dfdb 100644 --- a/MC1Services/Sources/MC1Services/Services/DevicePairingDelegate.swift +++ b/MC1Services/Sources/MC1Services/Services/DevicePairingDelegate.swift @@ -8,10 +8,10 @@ import Foundation /// without app-visible events. @MainActor public protocol DevicePairingDelegate: AnyObject { - /// The user removed a device from the system pairing registry - /// (iOS: Settings → Privacy & Security → Accessories). - func devicePairing(_ service: any DevicePairingService, didRemoveDeviceWithID id: UUID) + /// The user removed a device from the system pairing registry + /// (iOS: Settings → Privacy & Security → Accessories). + func devicePairing(_ service: any DevicePairingService, didRemoveDeviceWithID id: UUID) - /// Pairing failed for a device (iOS: wrong PIN). The local record should be cleaned up. - func devicePairing(_ service: any DevicePairingService, didFailPairingForDeviceWithID id: UUID) + /// Pairing failed for a device (iOS: wrong PIN). The local record should be cleaned up. + func devicePairing(_ service: any DevicePairingService, didFailPairingForDeviceWithID id: UUID) } diff --git a/MC1Services/Sources/MC1Services/Services/DevicePairingError.swift b/MC1Services/Sources/MC1Services/Services/DevicePairingError.swift index 37c4f859..c58b2773 100644 --- a/MC1Services/Sources/MC1Services/Services/DevicePairingError.swift +++ b/MC1Services/Sources/MC1Services/Services/DevicePairingError.swift @@ -8,19 +8,19 @@ import Foundation /// `AccessorySetupKitError` cases into these at the seam boundary; the macOS scan picker /// throws them directly. public enum DevicePairingError: LocalizedError, Sendable { - /// The user dismissed the discovery picker (the AccessorySetupKit system picker on iOS, - /// the in-app scan sheet on macOS). A benign cancellation, not a failure. - case cancelled + /// The user dismissed the discovery picker (the AccessorySetupKit system picker on iOS, + /// the in-app scan sheet on macOS). A benign cancellation, not a failure. + case cancelled - /// A pairing flow is already running; the re-entrant request was ignored. - case alreadyInProgress + /// A pairing flow is already running; the re-entrant request was ignored. + case alreadyInProgress - public var errorDescription: String? { - switch self { - case .cancelled: - return "Device selection was cancelled." - case .alreadyInProgress: - return "Device pairing is already in progress." - } + public var errorDescription: String? { + switch self { + case .cancelled: + "Device selection was cancelled." + case .alreadyInProgress: + "Device pairing is already in progress." } + } } diff --git a/MC1Services/Sources/MC1Services/Services/DevicePairingFactory.swift b/MC1Services/Sources/MC1Services/Services/DevicePairingFactory.swift index 6a46c61d..4551b53c 100644 --- a/MC1Services/Sources/MC1Services/Services/DevicePairingFactory.swift +++ b/MC1Services/Sources/MC1Services/Services/DevicePairingFactory.swift @@ -8,12 +8,12 @@ import Foundation /// where AccessorySetupKit is present but non-functional. Keeping the branch here means no /// `#if os(...)` or `isiOSAppOnMac` check leaks into `ConnectionManager` or the views. enum DevicePairingFactory { - /// Builds the pairing service appropriate for the current platform. - @MainActor - static func make() -> any DevicePairingService { - if ProcessInfo.processInfo.isiOSAppOnMac { - return BluetoothScanPairingService() - } - return AccessorySetupPairingService(accessorySetupKit: AccessorySetupKitService()) + /// Builds the pairing service appropriate for the current platform. + @MainActor + static func make() -> any DevicePairingService { + if ProcessInfo.processInfo.isiOSAppOnMac { + return BluetoothScanPairingService() } + return AccessorySetupPairingService(accessorySetupKit: AccessorySetupKitService()) + } } diff --git a/MC1Services/Sources/MC1Services/Services/DevicePairingService.swift b/MC1Services/Sources/MC1Services/Services/DevicePairingService.swift index f4f59365..37741cd7 100644 --- a/MC1Services/Sources/MC1Services/Services/DevicePairingService.swift +++ b/MC1Services/Sources/MC1Services/Services/DevicePairingService.swift @@ -16,67 +16,67 @@ import Foundation /// nothing to AccessorySetupKit's types. @MainActor public protocol DevicePairingService: AnyObject { - /// Receives system pairing events. Only fires on iOS. - var delegate: (any DevicePairingDelegate)? { get set } + /// Receives system pairing events. Only fires on iOS. + var delegate: (any DevicePairingDelegate)? { get set } - /// Whether a system pairing session is active. - /// - /// iOS: reflects the AccessorySetupKit session. macOS: always `false` — there is no - /// session, and a `false` value makes `ConnectionManager` skip its accessory-registration - /// guard so connects proceed purely over CoreBluetooth. - var isSessionActive: Bool { get } + /// Whether a system pairing session is active. + /// + /// iOS: reflects the AccessorySetupKit session. macOS: always `false` — there is no + /// session, and a `false` value makes `ConnectionManager` skip its accessory-registration + /// guard so connects proceed purely over CoreBluetooth. + var isSessionActive: Bool { get } - /// Number of devices registered with the system pairing registry. - /// - /// iOS: AccessorySetupKit accessory count. macOS: `0` — the in-app device list is - /// sourced from SwiftData, not from a system registry. - var registeredDeviceCount: Int { get } + /// Number of devices registered with the system pairing registry. + /// + /// iOS: AccessorySetupKit accessory count. macOS: `0` — the in-app device list is + /// sourced from SwiftData, not from a system registry. + var registeredDeviceCount: Int { get } - /// Whether this platform exposes an app-visible system pairing registry (AccessorySetupKit). - /// - /// A static platform capability, not a session state — distinct from `isSessionActive`. - /// iOS: `true`. macOS "Designed for iPad": `false` — there is no registry, so the device - /// picker filters on the stored connection method rather than registry membership, and a - /// user-initiated connect to an absent cached peripheral fails fast rather than exhausting - /// the multi-attempt retry budget (CoreBluetooth cannot pre-reject it without a registry). - var hasSystemPairingRegistry: Bool { get } + /// Whether this platform exposes an app-visible system pairing registry (AccessorySetupKit). + /// + /// A static platform capability, not a session state — distinct from `isSessionActive`. + /// iOS: `true`. macOS "Designed for iPad": `false` — there is no registry, so the device + /// picker filters on the stored connection method rather than registry membership, and a + /// user-initiated connect to an absent cached peripheral fails fast rather than exhausting + /// the multi-attempt retry budget (CoreBluetooth cannot pre-reject it without a registry). + var hasSystemPairingRegistry: Bool { get } - /// Whether `renameDevice(_:)` presents a real rename surface. - /// - /// iOS: `true` — AccessorySetupKit shows its system rename sheet. macOS: `false` — there is - /// no system rename UI, so `renameDevice(_:)` is a no-op. The UI must hide the rename action - /// when this is `false` rather than offer a control that silently does nothing. - var supportsSystemRename: Bool { get } + /// Whether `renameDevice(_:)` presents a real rename surface. + /// + /// iOS: `true` — AccessorySetupKit shows its system rename sheet. macOS: `false` — there is + /// no system rename UI, so `renameDevice(_:)` is a no-op. The UI must hide the rename action + /// when this is `false` rather than offer a control that silently does nothing. + var supportsSystemRename: Bool { get } - /// Activate the system pairing session. iOS: `ASAccessorySession.activate`. macOS: no-op. - func activate() async throws + /// Activate the system pairing session. iOS: `ASAccessorySession.activate`. macOS: no-op. + func activate() async throws - /// Present device-discovery UI and return the selected peripheral's CoreBluetooth UUID. - /// - /// iOS: the AccessorySetupKit system picker. macOS: an in-app scan picker driven by - /// `BluetoothScanPairingService`. Throws `DevicePairingError.cancelled` when - /// the user cancels, on both platforms, so call sites share one cancellation path. - func discoverDevice() async throws -> UUID + /// Present device-discovery UI and return the selected peripheral's CoreBluetooth UUID. + /// + /// iOS: the AccessorySetupKit system picker. macOS: an in-app scan picker driven by + /// `BluetoothScanPairingService`. Throws `DevicePairingError.cancelled` when + /// the user cancels, on both platforms, so call sites share one cancellation path. + func discoverDevice() async throws -> UUID - /// Whether a connect attempt to this device is permitted by the platform. - /// - /// iOS: an AccessorySetupKit accessory exists for this id (registry membership gates the - /// connect). macOS: always `true` — there is no registry, so CoreBluetooth decides - /// reachability at connect time. - func isDeviceConnectable(_ id: UUID) -> Bool + /// Whether a connect attempt to this device is permitted by the platform. + /// + /// iOS: an AccessorySetupKit accessory exists for this id (registry membership gates the + /// connect). macOS: always `true` — there is no registry, so CoreBluetooth decides + /// reachability at connect time. + func isDeviceConnectable(_ id: UUID) -> Bool - /// Registered devices as `(id, name)`, for device-list fallbacks when SwiftData is empty. - /// iOS: AccessorySetupKit accessories. macOS: `[]`. - func registeredDeviceInfos() -> [(id: UUID, name: String)] + /// Registered devices as `(id, name)`, for device-list fallbacks when SwiftData is empty. + /// iOS: AccessorySetupKit accessories. macOS: `[]`. + func registeredDeviceInfos() -> [(id: UUID, name: String)] - /// Remove a device from the system pairing registry. Best-effort; a no-op when the device - /// is not registered. iOS: `removeAccessory`. macOS: no-op (no app-managed bond). - func removeDevice(_ id: UUID) async throws + /// Remove a device from the system pairing registry. Best-effort; a no-op when the device + /// is not registered. iOS: `removeAccessory`. macOS: no-op (no app-managed bond). + func removeDevice(_ id: UUID) async throws - /// Present the system rename UI for a device. iOS: AccessorySetupKit rename sheet. macOS: no-op. - func renameDevice(_ id: UUID) async throws + /// Present the system rename UI for a device. iOS: AccessorySetupKit rename sheet. macOS: no-op. + func renameDevice(_ id: UUID) async throws - /// Remove every device from the system pairing registry. iOS: clears stale AccessorySetupKit - /// bonds left by a factory-reset radio. macOS: no-op. - func clearStaleRegistrations() async + /// Remove every device from the system pairing registry. iOS: clears stale AccessorySetupKit + /// bonds left by a factory-reset radio. macOS: no-op. + func clearStaleRegistrations() async } diff --git a/MC1Services/Sources/MC1Services/Services/DeviceService.swift b/MC1Services/Sources/MC1Services/Services/DeviceService.swift index a7d89da3..d176ba60 100644 --- a/MC1Services/Sources/MC1Services/Services/DeviceService.swift +++ b/MC1Services/Sources/MC1Services/Services/DeviceService.swift @@ -5,17 +5,17 @@ import OSLog // MARK: - Device Service Errors public enum DeviceServiceError: Error, LocalizedError, Sendable { - case deviceNotFound - case persistenceFailed(String) + case deviceNotFound + case persistenceFailed(String) - public var errorDescription: String? { - switch self { - case .deviceNotFound: - return "Device not found" - case .persistenceFailed(let reason): - return "Failed to save device settings: \(reason)" - } + public var errorDescription: String? { + switch self { + case .deviceNotFound: + "Device not found" + case let .persistenceFailed(reason): + "Failed to save device settings: \(reason)" } + } } // MARK: - Device Service @@ -23,63 +23,63 @@ public enum DeviceServiceError: Error, LocalizedError, Sendable { /// Service for managing device-level data and settings persistence. /// Handles local device configuration that doesn't require MeshCore communication. public actor DeviceService { - private let dataStore: any DevicePersisting - private let logger = PersistentLogger(subsystem: "com.mc1", category: "DeviceService") + private let dataStore: any DevicePersisting + private let logger = PersistentLogger(subsystem: "com.mc1", category: "DeviceService") - /// Callback invoked when device data is successfully updated. - /// Used to refresh ConnectionManager.connectedDevice for UI updates. - /// Installed by `AppState.wireDeviceUpdateCallbacks`. - private var onDeviceUpdated: (@Sendable (DeviceDTO) async -> Void)? + /// Callback invoked when device data is successfully updated. + /// Used to refresh ConnectionManager.connectedDevice for UI updates. + /// Installed by `AppState.wireDeviceUpdateCallbacks`. + private var onDeviceUpdated: (@Sendable (DeviceDTO) async -> Void)? - init(dataStore: any DevicePersisting) { - self.dataStore = dataStore - } - - /// Sets the callback for device updates. - public func setDeviceUpdateCallback( - _ callback: @escaping @Sendable (DeviceDTO) async -> Void - ) { - onDeviceUpdated = callback - } + init(dataStore: any DevicePersisting) { + self.dataStore = dataStore + } - // MARK: - OCV Settings + /// Sets the callback for device updates. + public func setDeviceUpdateCallback( + _ callback: @escaping @Sendable (DeviceDTO) async -> Void + ) { + onDeviceUpdated = callback + } - /// Update OCV settings for the connected device. - /// - /// - Parameters: - /// - deviceID: The UUID of the device to update - /// - preset: The OCV preset name (e.g., "liIon", "liPo", "custom") - /// - customArray: Custom OCV array as comma-separated string (required if preset is "custom") - /// - Throws: DeviceServiceError if device not found or persistence fails - public func updateOCVSettings( - deviceID: UUID, - preset: String, - customArray: String? - ) async throws { - logger.info("Updating OCV settings for device \(deviceID): preset=\(preset)") + // MARK: - OCV Settings - // Fetch current device - guard var device = try await dataStore.fetchDevice(id: deviceID) else { - logger.error("Device not found: \(deviceID)") - throw DeviceServiceError.deviceNotFound - } + /// Update OCV settings for the connected device. + /// + /// - Parameters: + /// - deviceID: The UUID of the device to update + /// - preset: The OCV preset name (e.g., "liIon", "liPo", "custom") + /// - customArray: Custom OCV array as comma-separated string (required if preset is "custom") + /// - Throws: DeviceServiceError if device not found or persistence fails + public func updateOCVSettings( + deviceID: UUID, + preset: String, + customArray: String? + ) async throws { + logger.info("Updating OCV settings for device \(deviceID): preset=\(preset)") - // Update OCV fields - device = device.copy { - $0.ocvPreset = preset - $0.customOCVArrayString = customArray - } + // Fetch current device + guard var device = try await dataStore.fetchDevice(id: deviceID) else { + logger.error("Device not found: \(deviceID)") + throw DeviceServiceError.deviceNotFound + } - // Save to persistence - do { - try await dataStore.saveDevice(device) - logger.info("OCV settings saved successfully") - } catch { - logger.error("Failed to save OCV settings: \(error.localizedDescription)") - throw DeviceServiceError.persistenceFailed(error.localizedDescription) - } + // Update OCV fields + device = device.copy { + $0.ocvPreset = preset + $0.customOCVArrayString = customArray + } - // Notify callback for UI refresh - await onDeviceUpdated?(device) + // Save to persistence + do { + try await dataStore.saveDevice(device) + logger.info("OCV settings saved successfully") + } catch { + logger.error("Failed to save OCV settings: \(error.localizedDescription)") + throw DeviceServiceError.persistenceFailed(error.localizedDescription) } + + // Notify callback for UI refresh + await onDeviceUpdated?(device) + } } diff --git a/MC1Services/Sources/MC1Services/Services/DraftStore.swift b/MC1Services/Sources/MC1Services/Services/DraftStore.swift index 91b0f2c6..17955654 100644 --- a/MC1Services/Sources/MC1Services/Services/DraftStore.swift +++ b/MC1Services/Sources/MC1Services/Services/DraftStore.swift @@ -22,76 +22,75 @@ import Foundation /// Do not add a `BackupUserDefaults` mapping for ``storageKey``. @MainActor public final class DraftStore { + /// Top-level `UserDefaults` key holding the `[draftStorageKey: text]` map. + public static let storageKey = "chat.drafts.v1" - /// Top-level `UserDefaults` key holding the `[draftStorageKey: text]` map. - public static let storageKey = "chat.drafts.v1" + private let defaults: UserDefaults + private var cache: [String: String] - private let defaults: UserDefaults - private var cache: [String: String] + /// - Parameter defaults: Injectable for tests; production uses `.standard`. + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + cache = (defaults.dictionary(forKey: Self.storageKey) as? [String: String]) ?? [:] + } - /// - Parameter defaults: Injectable for tests; production uses `.standard`. - public init(defaults: UserDefaults = .standard) { - self.defaults = defaults - self.cache = (defaults.dictionary(forKey: Self.storageKey) as? [String: String]) ?? [:] - } + /// The stored draft for a conversation, or `nil` if none. + public func draft(for id: ChatConversationID) -> String? { + cache[id.draftStorageKey] + } - /// The stored draft for a conversation, or `nil` if none. - public func draft(for id: ChatConversationID) -> String? { - cache[id.draftStorageKey] + /// Stores `text` as the draft for a conversation, or removes the entry when + /// `text` is empty or whitespace/newline-only. Trimming is used only for the + /// emptiness test; a non-empty draft (e.g. with a trailing newline) is stored + /// verbatim. + public func setDraft(_ text: String, for id: ChatConversationID) { + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + clearDraft(for: id) + } else { + cache[id.draftStorageKey] = text + persist() } + } - /// Stores `text` as the draft for a conversation, or removes the entry when - /// `text` is empty or whitespace/newline-only. Trimming is used only for the - /// emptiness test; a non-empty draft (e.g. with a trailing newline) is stored - /// verbatim. - public func setDraft(_ text: String, for id: ChatConversationID) { - if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - clearDraft(for: id) - } else { - cache[id.draftStorageKey] = text - persist() - } - } - - /// Removes the draft for a conversation, if present. - public func clearDraft(for id: ChatConversationID) { - guard cache.removeValue(forKey: id.draftStorageKey) != nil else { return } - persist() - } + /// Removes the draft for a conversation, if present. + public func clearDraft(for id: ChatConversationID) { + guard cache.removeValue(forKey: id.draftStorageKey) != nil else { return } + persist() + } - /// Removes the drafts for the given channel slots on `radioID`, persisting once for - /// the whole batch. Used by the slot-vacate paths (channel delete, sync prune) so a - /// channel later reusing a freed slot can't surface the prior channel's draft. - public func clearChannelDrafts(radioID: UUID, indices: Set) { - clearChannelDrafts(slotsByRadio: [radioID: indices]) - } + /// Removes the drafts for the given channel slots on `radioID`, persisting once for + /// the whole batch. Used by the slot-vacate paths (channel delete, sync prune) so a + /// channel later reusing a freed slot can't surface the prior channel's draft. + public func clearChannelDrafts(radioID: UUID, indices: Set) { + clearChannelDrafts(slotsByRadio: [radioID: indices]) + } - /// Removes the drafts for the given channel slots grouped by radio, persisting once for - /// the whole batch. Used by the backup-import path, where affected slots arrive per radio. - public func clearChannelDrafts(slotsByRadio: [UUID: Set]) { - var didChange = false - for (radioID, indices) in slotsByRadio { - for index in indices { - let key = ChatConversationID.channel(radioID: radioID, channelIndex: index).draftStorageKey - if cache.removeValue(forKey: key) != nil { - didChange = true - } - } - } - if didChange { - persist() + /// Removes the drafts for the given channel slots grouped by radio, persisting once for + /// the whole batch. Used by the backup-import path, where affected slots arrive per radio. + public func clearChannelDrafts(slotsByRadio: [UUID: Set]) { + var didChange = false + for (radioID, indices) in slotsByRadio { + for index in indices { + let key = ChatConversationID.channel(radioID: radioID, channelIndex: index).draftStorageKey + if cache.removeValue(forKey: key) != nil { + didChange = true } + } } - - /// Pure restore decision: returns the saved draft only when the composer is - /// empty, otherwise `nil`. Keeps a reconnect-driven re-restore (or a double - /// initial load) from clobbering text the user is already typing, and isolates - /// that guard so it is unit-testable independent of the view. - public func draftToApply(over currentText: String, for id: ChatConversationID) -> String? { - currentText.isEmpty ? draft(for: id) : nil + if didChange { + persist() } + } - private func persist() { - defaults.set(cache, forKey: Self.storageKey) - } + /// Pure restore decision: returns the saved draft only when the composer is + /// empty, otherwise `nil`. Keeps a reconnect-driven re-restore (or a double + /// initial load) from clobbering text the user is already typing, and isolates + /// that guard so it is unit-testable independent of the view. + public func draftToApply(over currentText: String, for id: ChatConversationID) -> String? { + currentText.isEmpty ? draft(for: id) : nil + } + + private func persist() { + defaults.set(cache, forKey: Self.storageKey) + } } diff --git a/MC1Services/Sources/MC1Services/Services/FirmwareDeviceErrorCode.swift b/MC1Services/Sources/MC1Services/Services/FirmwareDeviceErrorCode.swift index 1ca195ef..17e6e04c 100644 --- a/MC1Services/Sources/MC1Services/Services/FirmwareDeviceErrorCode.swift +++ b/MC1Services/Sources/MC1Services/Services/FirmwareDeviceErrorCode.swift @@ -7,24 +7,24 @@ import Foundation /// transient-vs-terminal taxonomy is an MC1 send-queue policy, not a /// protocol-level constant. public enum FirmwareDeviceErrorCode { - /// `TABLE_FULL` on the direct-message path. The radio's outbound DM pool - /// is briefly exhausted; the send queue parks and retries. - public static let directMessageTableFull: UInt8 = 3 + /// `TABLE_FULL` on the direct-message path. The radio's outbound DM pool + /// is briefly exhausted; the send queue parks and retries. + public static let directMessageTableFull: UInt8 = 3 - /// `NOT_FOUND` on the channel broadcast path. Firmware emits this for two - /// distinct failures that share a wire code: - /// - **Pool exhaustion** (transient): `createGroupDatagram` returned NULL because - /// the radio's packet pool is briefly full. The send queue parks and retries. - /// - **Stale channel index** (terminal): the user deleted the channel between - /// enqueue and drain. `ChatSendQueueService` disambiguates by calling - /// `ChannelService.fetchChannel(index:)`; if the device confirms the - /// channel is gone the envelope is dropped and the message lands in - /// `.failed` so the user can resend into a different channel. - public static let channelMessageNotFound: UInt8 = 2 + /// `NOT_FOUND` on the channel broadcast path. Firmware emits this for two + /// distinct failures that share a wire code: + /// - **Pool exhaustion** (transient): `createGroupDatagram` returned NULL because + /// the radio's packet pool is briefly full. The send queue parks and retries. + /// - **Stale channel index** (terminal): the user deleted the channel between + /// enqueue and drain. `ChatSendQueueService` disambiguates by calling + /// `ChannelService.fetchChannel(index:)`; if the device confirms the + /// channel is gone the envelope is dropped and the message lands in + /// `.failed` so the user can resend into a different channel. + public static let channelMessageNotFound: UInt8 = 2 - /// `RESP_CODE_NO_MORE_MESSAGES` surfaced on the remote-node section path. The - /// companion radio's offline-message queue is empty, meaning the awaited - /// repeater/room reply hasn't arrived yet; the section request backs off and - /// retries within its shared timeout budget. - public static let remoteNodeNoResponseYet: UInt8 = 10 + /// `RESP_CODE_NO_MORE_MESSAGES` surfaced on the remote-node section path. The + /// companion radio's offline-message queue is empty, meaning the awaited + /// repeater/room reply hasn't arrived yet; the section request backs off and + /// retries within its shared timeout budget. + public static let remoteNodeNoResponseYet: UInt8 = 10 } diff --git a/MC1Services/Sources/MC1Services/Services/HeardRepeatEvent.swift b/MC1Services/Sources/MC1Services/Services/HeardRepeatEvent.swift index 34410766..db5c06ce 100644 --- a/MC1Services/Sources/MC1Services/Services/HeardRepeatEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/HeardRepeatEvent.swift @@ -5,8 +5,8 @@ import Foundation /// Broadcast by `HeardRepeatsService.events()`. The stream is multicast: /// every subscriber receives every event. public struct HeardRepeatEvent: Sendable { - /// The sent message the repeat was correlated to. - public let messageID: UUID - /// The message's updated heard-repeat count. - public let count: Int + /// The sent message the repeat was correlated to. + public let messageID: UUID + /// The message's updated heard-repeat count. + public let count: Int } diff --git a/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift b/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift index 8540ccf0..e2e28a88 100644 --- a/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift +++ b/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift @@ -5,144 +5,144 @@ import OSLog /// Service for correlating RX log entries to sent channel messages /// and tracking "heard repeats" - evidence of message propagation through the mesh. public actor HeardRepeatsService { - private let dataStore: any HeardRepeatPersisting - private let logger = PersistentLogger(subsystem: "com.mc1", category: "HeardRepeatsService") - - /// Device ID for the current session - private var radioID: UUID? - - /// Local node name for matching sender in decrypted messages - private var localNodeName: String? - - /// Multicast broadcaster for heard-repeat events. - private nonisolated let eventBroadcaster = EventBroadcaster() - - init(dataStore: any HeardRepeatPersisting) { - self.dataStore = dataStore + private let dataStore: any HeardRepeatPersisting + private let logger = PersistentLogger(subsystem: "com.mc1", category: "HeardRepeatsService") + + /// Device ID for the current session + private var radioID: UUID? + + /// Local node name for matching sender in decrypted messages + private var localNodeName: String? + + /// Multicast broadcaster for heard-repeat events. + private nonisolated let eventBroadcaster = EventBroadcaster() + + init(dataStore: any HeardRepeatPersisting) { + self.dataStore = dataStore + } + + /// Returns a fresh stream of heard-repeat events. Registration is + /// synchronous, so events yielded after this call are never dropped. + /// Consumers must re-subscribe per connection because the owning + /// `ServiceContainer` is rebuilt on every connection. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } + + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } + + /// Configure the service with device context. + /// Must be called once before processing any RX log entries. + /// Thread-safe due to actor isolation. + public func configure(radioID: UUID, localNodeName: String) { + self.radioID = radioID + self.localNodeName = localNodeName + logger.info("Configured with radioID: \(radioID), nodeName: \(localNodeName)") + } + + /// Checks if a repeat has already been recorded for this RX log entry. + private func isDuplicateRepeat(_ entryID: UUID) async -> Bool { + do { + return try await dataStore.messageRepeatExists(rxLogEntryID: entryID) + } catch { + logger.error("Failed to check for existing repeat: \(error.localizedDescription)") + return true // Assume duplicate on error to prevent potential duplicates } - - /// Returns a fresh stream of heard-repeat events. Registration is - /// synchronous, so events yielded after this call are never dropped. - /// Consumers must re-subscribe per connection because the owning - /// `ServiceContainer` is rebuilt on every connection. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() + } + + /// Process an RX log entry to check if it's a repeat of a sent message. + /// + /// Called by RxLogService for each new entry. Only processes successfully + /// decrypted channel messages, matching the echo to a sent message by exact + /// channel, sender timestamp, and text. + /// + /// - Parameter entry: The RX log entry to process + /// - Returns: The updated heardRepeats count if a match was found, nil otherwise + @discardableResult + public func processForRepeats(_ entry: RxLogEntryDTO) async -> Int? { + // Only process successfully decrypted channel messages + guard entry.payloadType == .groupText else { return nil } + guard entry.decryptStatus == .success else { return nil } + guard let decodedText = entry.decodedText else { return nil } + guard let channelIndex = entry.channelIndex else { return nil } + guard let senderTimestamp = entry.senderTimestamp else { return nil } + guard let radioID else { return nil } + guard let localNodeName else { return nil } + + // Parse "NodeName: MessageText" format using shared utility + guard let (senderName, messageText) = ChannelMessageFormat.parse(decodedText) else { + logger.info("Failed to parse channel message text: \(decodedText.prefix(50))") + return nil } - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishEvents() { - eventBroadcaster.finish() - } - - /// Configure the service with device context. - /// Must be called once before processing any RX log entries. - /// Thread-safe due to actor isolation. - public func configure(radioID: UUID, localNodeName: String) { - self.radioID = radioID - self.localNodeName = localNodeName - logger.info("Configured with radioID: \(radioID), nodeName: \(localNodeName)") - } + // Only match messages from our own node + guard senderName == localNodeName else { return nil } - /// Checks if a repeat has already been recorded for this RX log entry. - private func isDuplicateRepeat(_ entryID: UUID) async -> Bool { - do { - return try await dataStore.messageRepeatExists(rxLogEntryID: entryID) - } catch { - logger.error("Failed to check for existing repeat: \(error.localizedDescription)") - return true // Assume duplicate on error to prevent potential duplicates - } + // Check for duplicate (already processed this RX entry) + if await isDuplicateRepeat(entry.id) { + logger.info("Repeat already recorded for RX entry: \(entry.id)") + return nil } - /// Process an RX log entry to check if it's a repeat of a sent message. - /// - /// Called by RxLogService for each new entry. Only processes successfully - /// decrypted channel messages within the 10-second matching window. - /// - /// - Parameter entry: The RX log entry to process - /// - Returns: The updated heardRepeats count if a match was found, nil otherwise - @discardableResult - public func processForRepeats(_ entry: RxLogEntryDTO) async -> Int? { - // Only process successfully decrypted channel messages - guard entry.payloadType == .groupText else { return nil } - guard entry.decryptStatus == .success else { return nil } - guard let decodedText = entry.decodedText else { return nil } - guard let channelIndex = entry.channelIndex else { return nil } - guard let senderTimestamp = entry.senderTimestamp else { return nil } - guard let radioID = self.radioID else { return nil } - guard let localNodeName = self.localNodeName else { return nil } - - // Parse "NodeName: MessageText" format using shared utility - guard let (senderName, messageText) = ChannelMessageFormat.parse(decodedText) else { - logger.info("Failed to parse channel message text: \(decodedText.prefix(50))") - return nil - } - - // Only match messages from our own node - guard senderName == localNodeName else { return nil } - - // Check for duplicate (already processed this RX entry) - if await isDuplicateRepeat(entry.id) { - logger.info("Repeat already recorded for RX entry: \(entry.id)") - return nil - } - - // Find matching sent message - do { - guard let message = try await dataStore.findSentChannelMessage( - radioID: radioID, - channelIndex: channelIndex, - timestamp: senderTimestamp, - text: messageText, - withinSeconds: 10 - ) else { - return nil - } - - // Create repeat entry - let repeatDTO = MessageRepeatDTO( - messageID: message.id, - receivedAt: entry.receivedAt, - pathNodes: entry.pathNodes, - pathLength: entry.pathLength, - snr: entry.snr, - rssi: entry.rssi, - rxLogEntryID: entry.id - ) - - try await dataStore.saveMessageRepeat(repeatDTO) - - // Increment and return new count - let newCount = try await dataStore.incrementMessageHeardRepeats(id: message.id) - - logger.info("Recorded repeat #\(newCount) for message \(message.id)") - - eventBroadcaster.yield(HeardRepeatEvent(messageID: message.id, count: newCount)) - - return newCount - - } catch { - logger.error("Failed to process repeat: \(error.localizedDescription)") - return nil - } + // Find matching sent message + do { + guard let message = try await dataStore.findSentChannelMessage( + radioID: radioID, + channelIndex: channelIndex, + timestamp: senderTimestamp, + text: messageText + ) else { + return nil + } + + // Create repeat entry + let repeatDTO = MessageRepeatDTO( + messageID: message.id, + receivedAt: entry.receivedAt, + pathNodes: entry.pathNodes, + pathLength: entry.pathLength, + snr: entry.snr, + rssi: entry.rssi, + rxLogEntryID: entry.id + ) + + try await dataStore.saveMessageRepeat(repeatDTO) + + // Increment and return new count + let newCount = try await dataStore.incrementMessageHeardRepeats(id: message.id) + + logger.info("Recorded repeat #\(newCount) for message \(message.id)") + + eventBroadcaster.yield(HeardRepeatEvent(messageID: message.id, count: newCount)) + + return newCount + + } catch { + logger.error("Failed to process repeat: \(error.localizedDescription)") + return nil } - - /// Refresh repeats for a specific message by querying the RX log. - /// Used when opening the Repeat Details sheet to catch any missed repeats. - /// - /// - Parameter messageID: The message to refresh repeats for - /// - Returns: Array of repeat DTOs sorted by receivedAt - public func refreshRepeats(for messageID: UUID) async -> [MessageRepeatDTO] { - // Return existing repeats from database - logger.info("refreshRepeats called for messageID: \(messageID)") - do { - let results = try await dataStore.fetchMessageRepeats(messageID: messageID) - logger.info("refreshRepeats returning \(results.count) repeats") - return results - } catch { - logger.error("Failed to fetch repeats: \(error.localizedDescription)") - return [] - } + } + + /// Refresh repeats for a specific message by querying the RX log. + /// Used when opening the Repeat Details sheet to catch any missed repeats. + /// + /// - Parameter messageID: The message to refresh repeats for + /// - Returns: Array of repeat DTOs sorted by receivedAt + public func refreshRepeats(for messageID: UUID) async -> [MessageRepeatDTO] { + // Return existing repeats from database + logger.info("refreshRepeats called for messageID: \(messageID)") + do { + let results = try await dataStore.fetchMessageRepeats(messageID: messageID) + logger.info("refreshRepeats returning \(results.count) repeats") + return results + } catch { + logger.error("Failed to fetch repeats: \(error.localizedDescription)") + return [] } + } } diff --git a/MC1Services/Sources/MC1Services/Services/InboundHopAdoption.swift b/MC1Services/Sources/MC1Services/Services/InboundHopAdoption.swift index 3fe21b3b..c3a7b103 100644 --- a/MC1Services/Sources/MC1Services/Services/InboundHopAdoption.swift +++ b/MC1Services/Sources/MC1Services/Services/InboundHopAdoption.swift @@ -20,22 +20,22 @@ import Foundation /// freezing the stored count until the row ages out of the discovered-node cap. Reset detection is /// out of scope. func adoptInboundHop( - storedHops: Int?, - storedTimestamp: UInt32?, - incomingHops: Int, - incomingTimestamp: UInt32? + storedHops: Int?, + storedTimestamp: UInt32?, + incomingHops: Int, + incomingTimestamp: UInt32? ) -> (hopCount: Int, advertTimestamp: UInt32?)? { - guard let storedTimestamp else { - return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) - } - guard let incomingTimestamp else { - return nil - } - if incomingTimestamp > storedTimestamp { - return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) - } - if incomingTimestamp == storedTimestamp, incomingHops < (storedHops ?? Int.max) { - return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) - } + guard let storedTimestamp else { + return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) + } + guard let incomingTimestamp else { return nil + } + if incomingTimestamp > storedTimestamp { + return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) + } + if incomingTimestamp == storedTimestamp, incomingHops < (storedHops ?? Int.max) { + return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) + } + return nil } diff --git a/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift b/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift index 00be3708..183b82b7 100644 --- a/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift +++ b/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift @@ -1,7 +1,7 @@ -import Foundation import CoreGraphics -import OSLog +import Foundation import os +import OSLog /// Persists inline-image aspect ratios to a flat JSON file in Application Support. /// @@ -13,111 +13,110 @@ import os /// `OSAllocatedUnfairLock` mirror so SwiftUI view bodies can resolve frame /// heights without awaiting an actor hop. public actor InlineImageDimensionsStore { - - private struct Entry: Codable, Sendable { - let aspect: Double - let fetchedAt: Date + private struct Entry: Codable { + let aspect: Double + let fetchedAt: Date + } + + private static let storeFilename = "InlineImageDimensions.json" + private static let resolutionStreamBufferDepth = 64 + + private let logger = Logger(subsystem: "com.mc1", category: "InlineImageDimensionsStore") + + private let fileURL: URL + private var entries: [String: Entry] = [:] + private let aspectMirror: OSAllocatedUnfairLock<[String: Double]> + private let streamContinuation: AsyncStream.Continuation + private let stream: AsyncStream + + /// Production initializer using the default Application Support path. + public init() { + self.init(fileURL: nil) + } + + /// Designated initializer. Pass a custom `fileURL` for tests; production callers + /// should use `init()` which resolves the Application Support path. + public init(fileURL: URL?) { + let resolvedURL = fileURL ?? Self.defaultFileURL() + self.fileURL = resolvedURL + + let (stream, continuation) = AsyncStream.makeStream( + of: URL.self, + bufferingPolicy: .bufferingOldest(Self.resolutionStreamBufferDepth) + ) + self.stream = stream + streamContinuation = continuation + + let directory = resolvedURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + logger.info("Could not create directory for inline image dimensions store: \(error.localizedDescription, privacy: .public)") } - private static let storeFilename = "InlineImageDimensions.json" - private static let resolutionStreamBufferDepth = 64 - - private let logger = Logger(subsystem: "com.mc1", category: "InlineImageDimensionsStore") - - private let fileURL: URL - private var entries: [String: Entry] = [:] - private let aspectMirror: OSAllocatedUnfairLock<[String: Double]> - private let streamContinuation: AsyncStream.Continuation - private let stream: AsyncStream - - /// Production initializer using the default Application Support path. - public init() { - self.init(fileURL: nil) + var loaded: [String: Entry] = [:] + if let data = try? Data(contentsOf: resolvedURL) { + if let decoded = try? JSONDecoder().decode([String: Entry].self, from: data) { + loaded = decoded + } else { + logger.info("Inline image dimensions file present but undecodable; starting empty") + } } - - /// Designated initializer. Pass a custom `fileURL` for tests; production callers - /// should use `init()` which resolves the Application Support path. - public init(fileURL: URL?) { - let resolvedURL = fileURL ?? Self.defaultFileURL() - self.fileURL = resolvedURL - - let (stream, continuation) = AsyncStream.makeStream( - of: URL.self, - bufferingPolicy: .bufferingOldest(Self.resolutionStreamBufferDepth) - ) - self.stream = stream - self.streamContinuation = continuation - - let directory = resolvedURL.deletingLastPathComponent() - do { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - } catch { - logger.info("Could not create directory for inline image dimensions store: \(error.localizedDescription, privacy: .public)") - } - - var loaded: [String: Entry] = [:] - if let data = try? Data(contentsOf: resolvedURL) { - if let decoded = try? JSONDecoder().decode([String: Entry].self, from: data) { - loaded = decoded - } else { - logger.info("Inline image dimensions file present but undecodable; starting empty") - } - } - self.entries = loaded - - let aspects = loaded.mapValues(\.aspect) - self.aspectMirror = OSAllocatedUnfairLock(initialState: aspects) + entries = loaded + + let aspects = loaded.mapValues(\.aspect) + aspectMirror = OSAllocatedUnfairLock(initialState: aspects) + } + + /// Upsert an aspect ratio for a URL. Saves with `width <= 0` or `height <= 0` + /// are rejected silently and do not emit on the stream. + public func save(url: URL, size: CGSize) async { + guard size.width > 0, size.height > 0 else { return } + + let aspect = Double(size.width) / Double(size.height) + let key = url.absoluteString + let entry = Entry(aspect: aspect, fetchedAt: Date()) + + entries[key] = entry + aspectMirror.withLock { $0[key] = aspect } + streamContinuation.yield(url) + + persist() + } + + /// Nonisolated wait-free aspect lookup. Safe to call from SwiftUI view bodies. + public nonisolated func aspect(for url: URL) -> Double? { + aspectMirror.withLock { $0[url.absoluteString] } + } + + /// Broadcast stream of URLs whose aspect was just (re)saved. Emits on every + /// save call, including idempotent re-saves where the aspect did not change. + /// + /// Single-consumer by design; `ChatViewModel` owns this in non-split-view + /// contexts. In iPad split view (multiple `ChatViewModel`s alive at once), + /// events are delivered to whichever subscriber happens to be iterating + /// first; affected bubbles still rebuild via other triggers (visible-cell + /// reload, retry, manual `rebuildDisplayItem`). + public nonisolated var resolutionStream: AsyncStream { + stream + } + + private func persist() { + guard let data = try? JSONEncoder().encode(entries) else { + logger.error("Failed to encode inline image dimensions snapshot") + return } - - /// Upsert an aspect ratio for a URL. Saves with `width <= 0` or `height <= 0` - /// are rejected silently and do not emit on the stream. - public func save(url: URL, size: CGSize) async { - guard size.width > 0, size.height > 0 else { return } - - let aspect = Double(size.width) / Double(size.height) - let key = url.absoluteString - let entry = Entry(aspect: aspect, fetchedAt: Date()) - - entries[key] = entry - aspectMirror.withLock { $0[key] = aspect } - streamContinuation.yield(url) - - persist() - } - - /// Nonisolated wait-free aspect lookup. Safe to call from SwiftUI view bodies. - public nonisolated func aspect(for url: URL) -> Double? { - aspectMirror.withLock { $0[url.absoluteString] } - } - - /// Broadcast stream of URLs whose aspect was just (re)saved. Emits on every - /// save call, including idempotent re-saves where the aspect did not change. - /// - /// Single-consumer by design; `ChatViewModel` owns this in non-split-view - /// contexts. In iPad split view (multiple `ChatViewModel`s alive at once), - /// events are delivered to whichever subscriber happens to be iterating - /// first; affected bubbles still rebuild via other triggers (visible-cell - /// reload, retry, manual `rebuildDisplayItem`). - public nonisolated var resolutionStream: AsyncStream { - stream - } - - private func persist() { - guard let data = try? JSONEncoder().encode(entries) else { - logger.error("Failed to encode inline image dimensions snapshot") - return - } - do { - try data.write(to: fileURL, options: .atomic) - } catch { - logger.error("Failed to write inline image dimensions store: \(error.localizedDescription, privacy: .public)") - } - } - - private static func defaultFileURL() -> URL { - let appSupport = FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first! - return appSupport.appending(path: storeFilename) + do { + try data.write(to: fileURL, options: .atomic) + } catch { + logger.error("Failed to write inline image dimensions store: \(error.localizedDescription, privacy: .public)") } + } + + private static func defaultFileURL() -> URL { + let appSupport = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first! + return appSupport.appending(path: storeFilename) + } } diff --git a/MC1Services/Sources/MC1Services/Services/KeepAliveRetryPolicy.swift b/MC1Services/Sources/MC1Services/Services/KeepAliveRetryPolicy.swift index 80a58742..c330886d 100644 --- a/MC1Services/Sources/MC1Services/Services/KeepAliveRetryPolicy.swift +++ b/MC1Services/Sources/MC1Services/Services/KeepAliveRetryPolicy.swift @@ -8,75 +8,75 @@ import MeshCore /// - **Skip** (floodRouted): not a failure, continue the loop /// - **Stop** (CancellationError, cancelled): task was cancelled, exit quietly enum KeepAliveRetryPolicy { - enum Action: Equatable { - /// Transient failure, try again next interval - case retryNextInterval - /// Consecutive transient failures exceeded threshold - case disconnect - /// Terminal local-state error, disconnect immediately - case disconnectNow - /// Flood-routed session, skip this iteration - case skip - /// Task cancelled, exit loop quietly - case stop + enum Action: Equatable { + /// Transient failure, try again next interval + case retryNextInterval + /// Consecutive transient failures exceeded threshold + case disconnect + /// Terminal local-state error, disconnect immediately + case disconnectNow + /// Flood-routed session, skip this iteration + case skip + /// Task cancelled, exit loop quietly + case stop - var shouldExitLoop: Bool { - switch self { - case .stop, .disconnect, .disconnectNow: true - case .retryNextInterval, .skip: false - } - } + var shouldExitLoop: Bool { + switch self { + case .stop, .disconnect, .disconnectNow: true + case .retryNextInterval, .skip: false + } } + } - /// The number of consecutive transient failures required before disconnecting. - static let maxConsecutiveFailures = 2 + /// The number of consecutive transient failures required before disconnecting. + static let maxConsecutiveFailures = 2 - /// Evaluates a keep-alive error and returns the appropriate action. - static func evaluate( - error: Error, - consecutiveFailures: inout Int - ) -> Action { - if error is CancellationError { - return .stop - } - - guard let nodeError = error as? RemoteNodeError else { - return .disconnectNow - } + /// Evaluates a keep-alive error and returns the appropriate action. + static func evaluate( + error: Error, + consecutiveFailures: inout Int + ) -> Action { + if error is CancellationError { + return .stop + } - switch nodeError { - case .cancelled: - return .stop - case .floodRouted: - return .skip - case .sessionNotFound, .contactNotFound: - return .disconnectNow - default: - consecutiveFailures += 1 - return consecutiveFailures >= maxConsecutiveFailures ? .disconnect : .retryNextInterval - } + guard let nodeError = error as? RemoteNodeError else { + return .disconnectNow } - /// Records a successful keep-alive by resetting the failure counter. - static func recordSuccess(consecutiveFailures: inout Int) { - consecutiveFailures = 0 + switch nodeError { + case .cancelled: + return .stop + case .floodRouted: + return .skip + case .sessionNotFound, .contactNotFound: + return .disconnectNow + default: + consecutiveFailures += 1 + return consecutiveFailures >= maxConsecutiveFailures ? .disconnect : .retryNextInterval } + } + + /// Records a successful keep-alive by resetting the failure counter. + static func recordSuccess(consecutiveFailures: inout Int) { + consecutiveFailures = 0 + } - /// Returns a human-readable reason for a keep-alive failure. - static func failureReason(for error: Error) -> String { - switch error { - case RemoteNodeError.sessionError(.timeout): - return "timeout" - case RemoteNodeError.sessionError(.deviceError(let code)): - return "device error (code: \(code))" - case RemoteNodeError.sessionError(.notConnected): - return "transport not connected" - case RemoteNodeError.sessionNotFound: - return "session not found" - case RemoteNodeError.contactNotFound: - return "contact not found" - default: - return "\(error)" - } + /// Returns a human-readable reason for a keep-alive failure. + static func failureReason(for error: Error) -> String { + switch error { + case RemoteNodeError.sessionError(.timeout): + "timeout" + case let RemoteNodeError.sessionError(.deviceError(code)): + "device error (code: \(code))" + case RemoteNodeError.sessionError(.notConnected): + "transport not connected" + case RemoteNodeError.sessionNotFound: + "session not found" + case RemoteNodeError.contactNotFound: + "contact not found" + default: + "\(error)" } + } } diff --git a/MC1Services/Sources/MC1Services/Services/KeyGenerationService.swift b/MC1Services/Sources/MC1Services/Services/KeyGenerationService.swift index 0f18c3e6..01931a5f 100644 --- a/MC1Services/Sources/MC1Services/Services/KeyGenerationService.swift +++ b/MC1Services/Sources/MC1Services/Services/KeyGenerationService.swift @@ -3,30 +3,30 @@ import Foundation /// Errors that can occur during key generation. public enum KeyGenerationError: Error, LocalizedError, Sendable { - /// Generation loop exceeded the maximum number of attempts without finding a matching key. - case maxAttemptsExceeded - - /// The requested prefix byte is reserved by firmware and cannot be used. - case reservedPrefix - - /// The system random number generator failed to produce bytes. - case randomGenerationFailed - - /// The provided key data is not a valid Ed25519 expanded private key. - case invalidKey - - public var errorDescription: String? { - switch self { - case .maxAttemptsExceeded: - "Could not generate a key with that prefix. Try a different one." - case .reservedPrefix: - "That prefix is reserved and cannot be used." - case .randomGenerationFailed: - "Secure random number generation failed. Please try again." - case .invalidKey: - "The key is not a valid Ed25519 private key." - } + /// Generation loop exceeded the maximum number of attempts without finding a matching key. + case maxAttemptsExceeded + + /// The requested prefix byte is reserved by firmware and cannot be used. + case reservedPrefix + + /// The system random number generator failed to produce bytes. + case randomGenerationFailed + + /// The provided key data is not a valid Ed25519 expanded private key. + case invalidKey + + public var errorDescription: String? { + switch self { + case .maxAttemptsExceeded: + "Could not generate a key with that prefix. Try a different one." + case .reservedPrefix: + "That prefix is reserved and cannot be used." + case .randomGenerationFailed: + "Secure random number generation failed. Please try again." + case .invalidKey: + "The key is not a valid Ed25519 private key." } + } } /// Generates Ed25519 keypairs compatible with MeshCore's 64-byte expanded format. @@ -34,99 +34,98 @@ public enum KeyGenerationError: Error, LocalizedError, Sendable { /// Key generation uses CryptoKit's `Curve25519.Signing.PrivateKey` and expands the /// 32-byte seed to MeshCore's 64-byte format using SHA-512 with RFC 8032 bit clamping. public enum KeyGenerationService: Sendable { + /// Generates a new Ed25519 identity, optionally matching a vanity hex prefix. + /// + /// - Parameter hexPrefix: Optional 1–4 uppercase hex characters the public key should start with. + /// Pass `nil` to accept any valid key. Prefixes of 2+ characters starting with `"00"` or `"FF"` + /// are rejected as reserved by firmware. + /// - Returns: A tuple of the 64-byte expanded private key and 32-byte public key. + /// - Throws: ``KeyGenerationError/reservedPrefix`` if the prefix maps to a reserved byte, + /// ``KeyGenerationError/maxAttemptsExceeded`` if no matching key is found, + /// or `CancellationError` if the task is cancelled. + public static func generateIdentity( + hexPrefix: String? + ) async throws -> (expandedPrivateKey: Data, publicKey: Data) { + if let hexPrefix, hexPrefix.count >= 2, + hexPrefix.hasPrefix("00") || hexPrefix.hasPrefix("FF") { + throw KeyGenerationError.reservedPrefix + } - /// Generates a new Ed25519 identity, optionally matching a vanity hex prefix. - /// - /// - Parameter hexPrefix: Optional 1–4 uppercase hex characters the public key should start with. - /// Pass `nil` to accept any valid key. Prefixes of 2+ characters starting with `"00"` or `"FF"` - /// are rejected as reserved by firmware. - /// - Returns: A tuple of the 64-byte expanded private key and 32-byte public key. - /// - Throws: ``KeyGenerationError/reservedPrefix`` if the prefix maps to a reserved byte, - /// ``KeyGenerationError/maxAttemptsExceeded`` if no matching key is found, - /// or `CancellationError` if the task is cancelled. - public static func generateIdentity( - hexPrefix: String? - ) async throws -> (expandedPrivateKey: Data, publicKey: Data) { - if let hexPrefix, hexPrefix.count >= 2, - hexPrefix.hasPrefix("00") || hexPrefix.hasPrefix("FF") { - throw KeyGenerationError.reservedPrefix - } - - let attempts = maxAttempts(forPrefixLength: hexPrefix?.count ?? 0) - - for _ in 0.. Int { - guard length > 0 else { return 10_000 } - let expected = 1 << (length * 4) // 16^length - return max(10_000, expected * 20) + throw KeyGenerationError.maxAttemptsExceeded + } + + /// Scales max attempts based on prefix length (~20x the expected attempts needed). + private static func maxAttempts(forPrefixLength length: Int) -> Int { + guard length > 0 else { return 10000 } + let expected = 1 << (length * 4) // 16^length + return max(10000, expected * 20) + } + + /// Validates that the given data is a properly clamped 64-byte Ed25519 expanded private key. + /// + /// Checks RFC 8032 bit clamping on the scalar half (first 32 bytes): + /// - Byte 0: lowest 3 bits must be clear + /// - Byte 31: highest bit must be clear, second-highest bit must be set + /// + /// - Parameter data: The key data to validate. + /// - Throws: ``KeyGenerationError/invalidKey`` if the data is not a valid expanded key. + public static func validateExpandedKey(_ data: Data) throws { + guard data.count == ProtocolLimits.privateKeySize else { + throw KeyGenerationError.invalidKey } - - /// Validates that the given data is a properly clamped 64-byte Ed25519 expanded private key. - /// - /// Checks RFC 8032 bit clamping on the scalar half (first 32 bytes): - /// - Byte 0: lowest 3 bits must be clear - /// - Byte 31: highest bit must be clear, second-highest bit must be set - /// - /// - Parameter data: The key data to validate. - /// - Throws: ``KeyGenerationError/invalidKey`` if the data is not a valid expanded key. - public static func validateExpandedKey(_ data: Data) throws { - guard data.count == ProtocolLimits.privateKeySize else { - throw KeyGenerationError.invalidKey - } - // Index relative to startIndex so a non-zero-based slice validates the right bytes. - let scalarFirst = data[data.startIndex] - let scalarLast = data[data.index(data.startIndex, offsetBy: 31)] - // RFC 8032 clamping: lowest 3 bits of byte 0 must be clear - guard scalarFirst & 0x07 == 0 else { - throw KeyGenerationError.invalidKey - } - // Byte 31: highest bit clear, second-highest set - guard scalarLast & 0x80 == 0, scalarLast & 0x40 == 0x40 else { - throw KeyGenerationError.invalidKey - } + // Index relative to startIndex so a non-zero-based slice validates the right bytes. + let scalarFirst = data[data.startIndex] + let scalarLast = data[data.index(data.startIndex, offsetBy: 31)] + // RFC 8032 clamping: lowest 3 bits of byte 0 must be clear + guard scalarFirst & 0x07 == 0 else { + throw KeyGenerationError.invalidKey } - - /// Expands a 32-byte seed to MeshCore's 64-byte format using SHA-512 with RFC 8032 bit clamping. - private static func expandSeed(_ seed: Data) -> Data { - var expanded = Data(SHA512.hash(data: seed)) - expanded[0] &= 0xF8 - expanded[31] &= 0x7F - expanded[31] |= 0x40 - return expanded + // Byte 31: highest bit clear, second-highest set + guard scalarLast & 0x80 == 0, scalarLast & 0x40 == 0x40 else { + throw KeyGenerationError.invalidKey } + } + + /// Expands a 32-byte seed to MeshCore's 64-byte format using SHA-512 with RFC 8032 bit clamping. + private static func expandSeed(_ seed: Data) -> Data { + var expanded = Data(SHA512.hash(data: seed)) + expanded[0] &= 0xF8 + expanded[31] &= 0x7F + expanded[31] |= 0x40 + return expanded + } } diff --git a/MC1Services/Sources/MC1Services/Services/KeychainService.swift b/MC1Services/Sources/MC1Services/Services/KeychainService.swift index 68013743..ff4d926c 100644 --- a/MC1Services/Sources/MC1Services/Services/KeychainService.swift +++ b/MC1Services/Sources/MC1Services/Services/KeychainService.swift @@ -7,139 +7,139 @@ import Security /// Secure password storage for remote node authentication. /// Passwords are stored device-only (not synced to iCloud). actor KeychainService { - static let shared = KeychainService() - - private let service = "com.pocketmesh.nodepasswords" - private let logger = PersistentLogger(subsystem: "com.mc1", category: "Keychain") - private let maxRetries = 3 - private let retryDelay: Duration = .milliseconds(100) - - init() {} - - /// Store a password for a remote node. - /// - Parameters: - /// - password: The password to store - /// - publicKey: The 32-byte public key of the remote node - func storePassword(_ password: String, forNodeKey publicKey: Data) async throws { - let account = publicKey.base64EncodedString() - guard let passwordData = password.data(using: .utf8) else { - throw KeychainError.encodingFailed - } - - // Delete existing entry first - let deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - SecItemDelete(deleteQuery as CFDictionary) - - // Add new entry - let addQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecValueData as String: passwordData, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly - ] - - var lastStatus: OSStatus = errSecSuccess - for attempt in 1...maxRetries { - lastStatus = SecItemAdd(addQuery as CFDictionary, nil) - if lastStatus == errSecSuccess { - logger.debug("Password stored for node") - return - } - if attempt < maxRetries { - try await Task.sleep(for: retryDelay) - } - } - - throw KeychainError.storageFailed(lastStatus) + static let shared = KeychainService() + + private let service = "com.pocketmesh.nodepasswords" + private let logger = PersistentLogger(subsystem: "com.mc1", category: "Keychain") + private let maxRetries = 3 + private let retryDelay: Duration = .milliseconds(100) + + init() {} + + /// Store a password for a remote node. + /// - Parameters: + /// - password: The password to store + /// - publicKey: The 32-byte public key of the remote node + func storePassword(_ password: String, forNodeKey publicKey: Data) async throws { + let account = publicKey.base64EncodedString() + guard let passwordData = password.data(using: .utf8) else { + throw KeychainError.encodingFailed } - /// Retrieve a stored password for a remote node. - /// - Parameter publicKey: The 32-byte public key of the remote node - /// - Returns: The stored password, or nil if not found - func retrievePassword(forNodeKey publicKey: Data) async throws -> String? { - let account = publicKey.base64EncodedString() - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - if status == errSecItemNotFound { - return nil - } - - guard status == errSecSuccess, - let data = result as? Data, - let password = String(data: data, encoding: .utf8) else { - throw KeychainError.retrievalFailed(status) - } - - return password + // Delete existing entry first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(deleteQuery as CFDictionary) + + // Add new entry + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: passwordData, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + var lastStatus: OSStatus = errSecSuccess + for attempt in 1...maxRetries { + lastStatus = SecItemAdd(addQuery as CFDictionary, nil) + if lastStatus == errSecSuccess { + logger.debug("Password stored for node") + return + } + if attempt < maxRetries { + try await Task.sleep(for: retryDelay) + } } - /// Delete a stored password for a remote node. - /// - Parameter publicKey: The 32-byte public key of the remote node - func deletePassword(forNodeKey publicKey: Data) async throws { - let account = publicKey.base64EncodedString() - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - - let status = SecItemDelete(query as CFDictionary) - if status != errSecSuccess && status != errSecItemNotFound { - throw KeychainError.deletionFailed(status) - } + throw KeychainError.storageFailed(lastStatus) + } + + /// Retrieve a stored password for a remote node. + /// - Parameter publicKey: The 32-byte public key of the remote node + /// - Returns: The stored password, or nil if not found + func retrievePassword(forNodeKey publicKey: Data) async throws -> String? { + let account = publicKey.base64EncodedString() + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return nil } - /// Check if a password exists for a remote node. - /// - Parameter publicKey: The 32-byte public key of the remote node - /// - Returns: True if a password is stored - func hasPassword(forNodeKey publicKey: Data) async -> Bool { - let account = publicKey.base64EncodedString() + guard status == errSecSuccess, + let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + throw KeychainError.retrievalFailed(status) + } + + return password + } + + /// Delete a stored password for a remote node. + /// - Parameter publicKey: The 32-byte public key of the remote node + func deletePassword(forNodeKey publicKey: Data) async throws { + let account = publicKey.base64EncodedString() - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: false - ] + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] - return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess, status != errSecItemNotFound { + throw KeychainError.deletionFailed(status) } + } + + /// Check if a password exists for a remote node. + /// - Parameter publicKey: The 32-byte public key of the remote node + /// - Returns: True if a password is stored + func hasPassword(forNodeKey publicKey: Data) async -> Bool { + let account = publicKey.base64EncodedString() + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: false + ] + + return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + } } // MARK: - KeychainError public enum KeychainError: Error, LocalizedError, Sendable { - case encodingFailed - case storageFailed(OSStatus) - case retrievalFailed(OSStatus) - case deletionFailed(OSStatus) - - public var errorDescription: String? { - switch self { - case .encodingFailed: - return "Failed to encode password" - case .storageFailed(let status): - return "Failed to store password (error \(status))" - case .retrievalFailed(let status): - return "Failed to retrieve password (error \(status))" - case .deletionFailed(let status): - return "Failed to delete password (error \(status))" - } + case encodingFailed + case storageFailed(OSStatus) + case retrievalFailed(OSStatus) + case deletionFailed(OSStatus) + + public var errorDescription: String? { + switch self { + case .encodingFailed: + "Failed to encode password" + case let .storageFailed(status): + "Failed to store password (error \(status))" + case let .retrievalFailed(status): + "Failed to retrieve password (error \(status))" + case let .deletionFailed(status): + "Failed to delete password (error \(status))" } + } } diff --git a/MC1Services/Sources/MC1Services/Services/LoginResult.swift b/MC1Services/Sources/MC1Services/Services/LoginResult.swift index 05f4c552..d56f27fa 100644 --- a/MC1Services/Sources/MC1Services/Services/LoginResult.swift +++ b/MC1Services/Sources/MC1Services/Services/LoginResult.swift @@ -1,19 +1,19 @@ import Foundation public struct LoginResult: Sendable { - public let success: Bool - public let isAdmin: Bool - public let aclPermissions: UInt8? - public let publicKeyPrefix: Data + public let success: Bool + public let isAdmin: Bool + public let aclPermissions: UInt8? + public let publicKeyPrefix: Data - public init(success: Bool, isAdmin: Bool, aclPermissions: UInt8?, publicKeyPrefix: Data) { - self.success = success - self.isAdmin = isAdmin - self.aclPermissions = aclPermissions - self.publicKeyPrefix = publicKeyPrefix - } + public init(success: Bool, isAdmin: Bool, aclPermissions: UInt8?, publicKeyPrefix: Data) { + self.success = success + self.isAdmin = isAdmin + self.aclPermissions = aclPermissions + self.publicKeyPrefix = publicKeyPrefix + } - public var permissionLevel: RoomPermissionLevel { - isAdmin ? .admin : (RoomPermissionLevel(rawValue: aclPermissions ?? 0) ?? .guest) - } + public var permissionLevel: RoomPermissionLevel { + isAdmin ? .admin : (RoomPermissionLevel(rawValue: aclPermissions ?? 0) ?? .guest) + } } diff --git a/MC1Services/Sources/MC1Services/Services/LoginTimeoutConfig.swift b/MC1Services/Sources/MC1Services/Services/LoginTimeoutConfig.swift index f46f2cea..c60fcf80 100644 --- a/MC1Services/Sources/MC1Services/Services/LoginTimeoutConfig.swift +++ b/MC1Services/Sources/MC1Services/Services/LoginTimeoutConfig.swift @@ -3,21 +3,21 @@ import MeshCore /// Configuration for login timeout based on path length enum LoginTimeoutConfig { - /// Base timeout for direct (0-hop) connections - static let directTimeout: Duration = .seconds(5) + /// Base timeout for direct (0-hop) connections + static let directTimeout: Duration = .seconds(5) - /// Additional timeout per hop in the path - static let perHopTimeout: Duration = .seconds(10) + /// Additional timeout per hop in the path + static let perHopTimeout: Duration = .seconds(10) - /// Maximum timeout regardless of path length - static let maximumTimeout: Duration = .seconds(60) + /// Maximum timeout regardless of path length + static let maximumTimeout: Duration = .seconds(60) - /// Calculate appropriate timeout based on path length - static func timeout(forPathLength pathLength: UInt8) -> Duration { - let base = directTimeout - let hopCount = decodePathLen(pathLength)?.hopCount ?? 0 - let additional = perHopTimeout * hopCount - let total = base + additional - return min(total, maximumTimeout) - } + /// Calculate appropriate timeout based on path length + static func timeout(forPathLength pathLength: UInt8) -> Duration { + let base = directTimeout + let hopCount = decodePathLen(pathLength)?.hopCount ?? 0 + let additional = perHopTimeout * hopCount + let total = base + additional + return min(total, maximumTimeout) + } } diff --git a/MC1Services/Sources/MC1Services/Services/MeshCoreOpenReactionParser.swift b/MC1Services/Sources/MC1Services/Services/MeshCoreOpenReactionParser.swift index 59e54bc5..9a3f3d1b 100644 --- a/MC1Services/Sources/MC1Services/Services/MeshCoreOpenReactionParser.swift +++ b/MC1Services/Sources/MC1Services/Services/MeshCoreOpenReactionParser.swift @@ -1,9 +1,9 @@ import Foundation /// Parsed meshcore-open v3 reaction data -struct ParsedMCOReaction: Sendable, Equatable { - let emoji: String - let dartHash: String // 4 lowercase hex chars +struct ParsedMCOReaction: Equatable { + let emoji: String + let dartHash: String // 4 lowercase hex chars } /// Parsed meshcore-open v1 reaction data (pre-Jan 2026 clients) @@ -11,16 +11,16 @@ struct ParsedMCOReaction: Sendable, Equatable { /// The `senderNameHash` and `textHash` are full Dart VM `String.hashCode` values /// (30-bit, decimal-encoded on the wire). For DM reactions, `senderNameHash` is /// not verified during matching since DMs have implicit sender context. -struct ParsedMCOReactionV1: Sendable, Equatable { - let emoji: String - let timestampSeconds: UInt32 - let senderNameHash: UInt32 - let textHash: UInt32 - - /// Reconstructs the original v1 messageId, used as the opaque reaction hash for dedup. - var messageIdHash: String { - "\(timestampSeconds)_\(senderNameHash)_\(textHash)" - } +struct ParsedMCOReactionV1: Equatable { + let emoji: String + let timestampSeconds: UInt32 + let senderNameHash: UInt32 + let textHash: UInt32 + + /// Reconstructs the original v1 messageId, used as the opaque reaction hash for dedup. + var messageIdHash: String { + "\(timestampSeconds)_\(senderNameHash)_\(textHash)" + } } /// Parses meshcore-open reaction wire format (receive-only). @@ -29,199 +29,198 @@ struct ParsedMCOReactionV1: Sendable, Equatable { /// The hash is computed using the Dart VM's `String.hashCode` algorithm /// masked to 16 bits. enum MeshCoreOpenReactionParser { - - // MARK: - Parsing - - /// Parses a meshcore-open reaction string. - /// - /// Format: `r:{4-char-hex-hash}:{2-char-hex-emoji-index}` - /// - Returns: Parsed emoji and dart hash, or nil if format doesn't match. - static func parse(_ text: String) -> ParsedMCOReaction? { - guard text.count == 9, - text.hasPrefix("r:"), - text[text.index(text.startIndex, offsetBy: 6)] == ":" else { - return nil - } - - let hashStart = text.index(text.startIndex, offsetBy: 2) - let hashEnd = text.index(text.startIndex, offsetBy: 6) - let indexStart = text.index(text.startIndex, offsetBy: 7) - - let hashStr = String(text[hashStart.. ParsedMCOReaction? { + guard text.count == 9, + text.hasPrefix("r:"), + text[text.index(text.startIndex, offsetBy: 6)] == ":" else { + return nil } - /// Parses a meshcore-open v1 reaction string. - /// - /// Format: `r:{millis}_{senderNameHash}_{textHash}:{emoji}` - /// Used by pre-Jan 2026 meshcore-open clients. The hashes are full Dart - /// `String.hashCode` values (30-bit, decimal-encoded). - static func parseV1(_ text: String) -> ParsedMCOReactionV1? { - guard text.hasPrefix("r:") else { return nil } - - // Split on last ":" to separate messageId from emoji - guard let lastColon = text.lastIndex(of: ":"), - lastColon > text.index(text.startIndex, offsetBy: 2) else { - return nil - } - - let messageId = String(text[text.index(text.startIndex, offsetBy: 2)..> 6 - /// finalize: - /// hash += hash << 3 - /// hash ^= hash >> 11 - /// hash += hash << 15 - /// hash &= (1 << 30) - 1 - /// if hash == 0: hash = 1 - /// ``` - /// Convenience overload that hashes a String's UTF-16 code units directly. - static func dartStringHash(_ string: String) -> UInt32 { - dartStringHash(Array(string.utf16)) + // Validate both are lowercase hex + guard isLowercaseHex(hashStr), isLowercaseHex(indexStr) else { + return nil } - static func dartStringHash(_ codeUnits: [UInt16]) -> UInt32 { - var hash: UInt32 = 0 + guard let emojiIndex = UInt8(indexStr, radix: 16), + Int(emojiIndex) < emojiTable.count else { + return nil + } - for unit in codeUnits { - hash = hash &+ UInt32(unit) - hash = hash &+ (hash &<< 10) - hash ^= (hash >> 6) - } + return ParsedMCOReaction( + emoji: emojiTable[Int(emojiIndex)], + dartHash: hashStr + ) + } + + /// Parses a meshcore-open v1 reaction string. + /// + /// Format: `r:{millis}_{senderNameHash}_{textHash}:{emoji}` + /// Used by pre-Jan 2026 meshcore-open clients. The hashes are full Dart + /// `String.hashCode` values (30-bit, decimal-encoded). + static func parseV1(_ text: String) -> ParsedMCOReactionV1? { + guard text.hasPrefix("r:") else { return nil } + + // Split on last ":" to separate messageId from emoji + guard let lastColon = text.lastIndex(of: ":"), + lastColon > text.index(text.startIndex, offsetBy: 2) else { + return nil + } - // Finalize - hash = hash &+ (hash &<< 3) - hash ^= (hash >> 11) - hash = hash &+ (hash &<< 15) + let messageId = String(text[text.index(text.startIndex, offsetBy: 2).. String { - var codeUnits: [UInt16] = [] - - // Append timestamp as string - codeUnits.append(contentsOf: String(timestamp).utf16) - - // Append sender name (channel only) - if let senderName { - codeUnits.append(contentsOf: senderName.utf16) - } - - // Append first 5 UTF-16 code units of text - codeUnits.append(contentsOf: text.utf16.prefix(5)) - - let hash = dartStringHash(codeUnits) & 0xFFFF - return String(format: "%04x", hash) + let seconds = timestampMillis / 1000 + guard seconds <= UInt64(UInt32.max) else { return nil } + + return ParsedMCOReactionV1( + emoji: emoji, + timestampSeconds: UInt32(seconds), + senderNameHash: senderNameHash, + textHash: textHash + ) + } + + // MARK: - Hash Computation + + /// Reimplements the Dart VM's `String.hashCode` algorithm. + /// + /// Operates on UTF-16 code units with 30-bit result: + /// ``` + /// hash = 0 + /// for each code_unit: + /// hash += code_unit + /// hash += hash << 10 + /// hash ^= hash >> 6 + /// finalize: + /// hash += hash << 3 + /// hash ^= hash >> 11 + /// hash += hash << 15 + /// hash &= (1 << 30) - 1 + /// if hash == 0: hash = 1 + /// ``` + /// Convenience overload that hashes a String's UTF-16 code units directly. + static func dartStringHash(_ string: String) -> UInt32 { + dartStringHash(Array(string.utf16)) + } + + static func dartStringHash(_ codeUnits: [UInt16]) -> UInt32 { + var hash: UInt32 = 0 + + for unit in codeUnits { + hash = hash &+ UInt32(unit) + hash = hash &+ (hash &<< 10) + hash ^= (hash >> 6) } - // MARK: - Emoji Table - - /// The 184-emoji lookup table from meshcore-open's emoji_picker.dart. - /// Concatenated in order: quickEmojis + smileys + gestures + hearts + objects. - static let emojiTable: [String] = [ - // quickEmojis (0x00–0x05) - "👍", "❤️", "😂", "🎉", "👏", "🔥", - // smileys (0x06–0x45) - "😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊", - "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", - "😗", "😙", "😚", "😋", "😛", "😝", "😜", "🤪", - "🤨", "🧐", "🤓", "😎", "🥸", "🤩", "🥳", "😏", - "😒", "😞", "😔", "😟", "😕", "🙁", "😣", "😖", - "😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡", - "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰", - "😥", "😓", "🤗", "🤔", "🤭", "🤫", "🤥", "😶", - // gestures (0x46–0x66) - "👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️", - "🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆", - "👇", "☝️", "👋", "🤚", "🖐️", "✋", "🖖", "👏", - "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", - "💪", - // hearts (0x67–0x86) - "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", - "🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞", "💓", "💗", - "💖", "💘", "💝", "💟", "💌", "💢", "💥", "💫", - "💦", "💨", "🕳️", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭", - // objects (0x87–0xB7) - "🎉", "🎊", "🎈", "🎁", "🎀", "🪅", "🪆", "🏆", - "🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐", - "🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", "🏒", - "🥍", "🏓", "🏸", "🥊", "🥋", "🥅", "⛳", "🔥", - "⭐", "🌟", "✨", "⚡", "💡", "🔦", "🏮", "🪔", - "📱", "💻", "⌚", "📷", "📺", "📻", "🎵", "🎶", - "🚀", - ] - - // MARK: - Private Helpers - - private static func isLowercaseHex(_ string: String) -> Bool { - string.allSatisfy { $0.isHexDigit && !$0.isUppercase } + // Finalize + hash = hash &+ (hash &<< 3) + hash ^= (hash >> 11) + hash = hash &+ (hash &<< 15) + + // Mask to 30 bits + let kHashBitMask: UInt32 = (1 << 30) - 1 + hash &= kHashBitMask + + if hash == 0 { hash = 1 } + return hash + } + + /// Computes the reaction hash used by meshcore-open. + /// + /// Builds UTF-16 code units from: `"\(timestamp)" + senderName + first5UTF16(text)` + /// Then runs `dartStringHash`, masks to 16 bits, and formats as 4 lowercase hex chars. + /// + /// - Parameters: + /// - timestamp: Message timestamp as UInt32 + /// - senderName: Sender node name (nil for DM reactions) + /// - text: Message text content + /// - Returns: 4-character lowercase hex hash string + static func computeReactionHash( + timestamp: UInt32, + senderName: String?, + text: String + ) -> String { + var codeUnits: [UInt16] = [] + + // Append timestamp as string + codeUnits.append(contentsOf: String(timestamp).utf16) + + // Append sender name (channel only) + if let senderName { + codeUnits.append(contentsOf: senderName.utf16) } + + // Append first 5 UTF-16 code units of text + codeUnits.append(contentsOf: text.utf16.prefix(5)) + + let hash = dartStringHash(codeUnits) & 0xFFFF + return String(format: "%04x", hash) + } + + // MARK: - Emoji Table + + /// The 184-emoji lookup table from meshcore-open's emoji_picker.dart. + /// Concatenated in order: quickEmojis + smileys + gestures + hearts + objects. + static let emojiTable: [String] = [ + // quickEmojis (0x00–0x05) + "👍", "❤️", "😂", "🎉", "👏", "🔥", + // smileys (0x06–0x45) + "😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊", + "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", + "😗", "😙", "😚", "😋", "😛", "😝", "😜", "🤪", + "🤨", "🧐", "🤓", "😎", "🥸", "🤩", "🥳", "😏", + "😒", "😞", "😔", "😟", "😕", "🙁", "😣", "😖", + "😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡", + "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰", + "😥", "😓", "🤗", "🤔", "🤭", "🤫", "🤥", "😶", + // gestures (0x46–0x66) + "👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️", + "🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆", + "👇", "☝️", "👋", "🤚", "🖐️", "✋", "🖖", "👏", + "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", + "💪", + // hearts (0x67–0x86) + "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", + "🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞", "💓", "💗", + "💖", "💘", "💝", "💟", "💌", "💢", "💥", "💫", + "💦", "💨", "🕳️", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭", + // objects (0x87–0xB7) + "🎉", "🎊", "🎈", "🎁", "🎀", "🪅", "🪆", "🏆", + "🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐", + "🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", "🏒", + "🥍", "🏓", "🏸", "🥊", "🥋", "🥅", "⛳", "🔥", + "⭐", "🌟", "✨", "⚡", "💡", "🔦", "🏮", "🪔", + "📱", "💻", "⌚", "📷", "📺", "📻", "🎵", "🎶", + "🚀", + ] + + // MARK: - Private Helpers + + private static func isLowercaseHex(_ string: String) -> Bool { + string.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift index d4aa4971..cfaa38f6 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift @@ -4,260 +4,260 @@ import Foundation /// `MessageBuildInputs` snapshot. Pure function — no I/O, no async, no actor /// state, no side effects. Unit-tested in isolation. public enum MessageFragmentBuilder { + /// Sentinel URL used when an inline image fragment falls into a `.failed` + /// state without a real source URL. `about:blank` is RFC-defined and parses + /// reliably, so the force-unwrap is safe; the bubble renders this as the + /// generic failure placeholder. + private static let blankURL = URL(string: "about:blank")! - /// Sentinel URL used when an inline image fragment falls into a `.failed` - /// state without a real source URL. `about:blank` is RFC-defined and parses - /// reliably, so the force-unwrap is safe; the bubble renders this as the - /// generic failure placeholder. - private static let blankURL = URL(string: "about:blank")! + public static func makeItem( + for message: MessageDTO, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> MessageItem { + MessageItem( + id: message.id, + envelope: makeEnvelope(for: message, inputs: inputs), + content: makeFragments(for: message, inputs: inputs, envInputs: envInputs), + footer: makeFooter(for: message, inputs: inputs, envInputs: envInputs), + grouping: makeGrouping(inputs: inputs), + shouldRequestPreviewFetch: shouldRequestPreviewFetch( + inputs: inputs, + message: message + ) + ) + } - public static func makeItem( - for message: MessageDTO, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> MessageItem { - MessageItem( - id: message.id, - envelope: makeEnvelope(for: message, inputs: inputs), - content: makeFragments(for: message, inputs: inputs, envInputs: envInputs), - footer: makeFooter(for: message, inputs: inputs, envInputs: envInputs), - grouping: makeGrouping(inputs: inputs), - shouldRequestPreviewFetch: shouldRequestPreviewFetch( - inputs: inputs, - message: message - ) - ) - } - - public static func makeFragments( - for message: MessageDTO, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> [MessageFragment] { - var fragments: [MessageFragment] = [] - - fragments.append(.text(makeText(message: message, inputs: inputs, envInputs: envInputs))) - - if let summary = message.reactionSummary, !summary.isEmpty { - fragments.append(.reactionSummary(summary)) - } + public static func makeFragments( + for message: MessageDTO, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> [MessageFragment] { + var fragments: [MessageFragment] = [] - let url = inputs.cachedURL - let isImageURL = url.map(ImageURLClassifier.isImageURL) ?? false + fragments.append(.text(makeText(message: message, inputs: inputs, envInputs: envInputs))) - if inputs.previewState == .malwareWarning, let url { - fragments.append(.malwareWarning(url)) - appendMapPreviewIfPresent(&fragments, inputs: inputs, envInputs: envInputs) - return fragments - } - - if isImageURL && envInputs.showInlineImages { - fragments.append(.inlineImage( - makeInlineImage(url: url, inputs: inputs, envInputs: envInputs) - )) - } else if envInputs.previewsEnabled { - fragments.append(.linkPreview( - makeLinkPreview(message: message, url: url, inputs: inputs) - )) - } - - appendMapPreviewIfPresent(&fragments, inputs: inputs, envInputs: envInputs) - return fragments + if let summary = message.reactionSummary, !summary.isEmpty { + fragments.append(.reactionSummary(summary)) } - /// Appends a `.mapPreview` for the first linkified coordinate, if any. Sibling - /// order is deterministic (array order), so it sits after a link preview. The - /// card is independent of any suspicious URL, so it is shown on malware - /// messages too (the location is not the link). - private static func appendMapPreviewIfPresent( - _ fragments: inout [MessageFragment], - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) { - // Privacy gate: when the user has disabled chat map thumbnails, skip the - // fragment entirely so `MapPreviewFragmentView.onAppear` never fires the - // third-party tile request. The coordinate text inside the message body - // remains tappable through the formatted-text link path. - guard envInputs.showMapPreviews else { return } - guard let latitude = inputs.mapPreviewLatitude, - let longitude = inputs.mapPreviewLongitude else { return } - fragments.append(.mapPreview(MapPreviewFragmentState( - latitude: latitude, - longitude: longitude, - isDark: envInputs.isDark, - isOffline: envInputs.isOffline, - isReady: inputs.isMapPreviewReady - ))) - } + let url = inputs.cachedURL + let isImageURL = inputs.isInlineImageURL - private static func makeText( - message: MessageDTO, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> MessageTextPayload { - MessageTextPayload( - raw: message.text, - formatted: inputs.formattedText, - baseColor: inputs.baseColor, - isOutgoing: message.isOutgoing, - currentUserName: envInputs.currentUserName - ) + if inputs.previewState == .malwareWarning, let url { + fragments.append(.malwareWarning(url)) + appendMapPreviewIfPresent(&fragments, inputs: inputs, envInputs: envInputs) + return fragments } - private static func makeInlineImage( - url: URL?, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> InlineImage { - let loadState: InlineImage.LoadState - switch inputs.previewState { - case .loaded: - if inputs.hasInlineImageRef { - loadState = .loaded( - ImageReference(cacheKey: inputs.messageID, role: .inline), - isGIF: inputs.imageIsGIF - ) - } else if let url { - loadState = .loading(url) - } else { - loadState = .failed(Self.blankURL) - } - case .loading: - loadState = url.map { .loading($0) } ?? .failed(Self.blankURL) - case .noPreview: - loadState = url.map { .failed($0) } ?? .failed(Self.blankURL) - case .idle, .disabled, .malwareWarning: - loadState = url.map { .idle($0) } ?? .failed(Self.blankURL) - } - return InlineImage( - state: loadState, - autoPlayGIFs: envInputs.autoPlayGIFs, - cachedAspect: inputs.inlineImageAspect - ) + if envInputs.previewsEnabled { + if isImageURL { + fragments.append(.inlineImage( + makeInlineImage(url: url, inputs: inputs, envInputs: envInputs) + )) + } else { + fragments.append(.linkPreview( + makeLinkPreview(message: message, url: url, inputs: inputs) + )) + } } - private static func makeLinkPreview( - message: MessageDTO, - url: URL?, - inputs: MessageBuildInputs - ) -> LinkPreviewFragmentState { - let imageRef = inputs.hasPreviewImageRef - ? ImageReference(cacheKey: inputs.messageID, role: .linkPreviewImage) - : nil - let iconRef = inputs.hasPreviewIconRef - ? ImageReference(cacheKey: inputs.messageID, role: .linkPreviewIcon) - : nil + appendMapPreviewIfPresent(&fragments, inputs: inputs, envInputs: envInputs) + return fragments + } - let mode: LinkPreviewFragmentState.Mode - switch inputs.previewState { - case .loaded: - if let preview = inputs.loadedPreview { - mode = .loaded(preview, image: imageRef, icon: iconRef) - } else { - mode = .noPreview - } - case .loading: - mode = url.map { .loading($0) } ?? .idle - case .noPreview: - mode = .noPreview - case .disabled: - mode = url.map { .disabled($0) } ?? .idle - case .idle: - if let urlString = message.linkPreviewURL, - let legacyURL = URL(string: urlString) { - mode = .legacy( - url: legacyURL, - title: message.linkPreviewTitle, - image: imageRef, - icon: iconRef - ) - } else if let url { - mode = .loading(url) - } else { - mode = .idle - } - case .malwareWarning: - mode = .idle - } - return LinkPreviewFragmentState(mode: mode) - } + /// Appends a `.mapPreview` for the first linkified coordinate, if any. Sibling + /// order is deterministic (array order), so it sits after a link preview. The + /// card is independent of any suspicious URL, so it is shown on malware + /// messages too (the location is not the link). + private static func appendMapPreviewIfPresent( + _ fragments: inout [MessageFragment], + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) { + // Privacy gate: when the user has disabled chat map thumbnails, skip the + // fragment entirely so `MapPreviewFragmentView.onAppear` never fires the + // third-party tile request. The coordinate text inside the message body + // remains tappable through the formatted-text link path. + guard envInputs.showMapPreviews else { return } + guard let latitude = inputs.mapPreviewLatitude, + let longitude = inputs.mapPreviewLongitude else { return } + fragments.append(.mapPreview(MapPreviewFragmentState( + latitude: latitude, + longitude: longitude, + isDark: envInputs.isDark, + isOffline: envInputs.isOffline, + isReady: inputs.isMapPreviewReady + ))) + } - private static func makeEnvelope( - for message: MessageDTO, - inputs: MessageBuildInputs - ) -> MessageEnvelope { - MessageEnvelope( - messageID: message.id, - isOutgoing: message.isOutgoing, - senderName: inputs.senderResolution.displayName, - senderResolution: inputs.senderResolution, - status: message.status, - // Send time, not drain time: the centered divider is the sole time surface, - // so a days-old drained message must not be relabeled at its delivery time. - date: message.senderDate, - hasFailed: message.hasFailed, - containsSelfMention: message.containsSelfMention, - mentionSeen: message.mentionSeen - ) - } + private static func makeText( + message: MessageDTO, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> MessageTextPayload { + MessageTextPayload( + raw: message.text, + formatted: inputs.formattedText, + baseColor: inputs.baseColor, + isOutgoing: message.isOutgoing, + currentUserName: envInputs.currentUserName + ) + } - private static func makeFooter( - for message: MessageDTO, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> MessageFooter { - let showHop = envInputs.showIncomingHopCount && message.isFloodRouted - let region: String? - if envInputs.showIncomingRegion, message.isFloodRouted { - region = message.regionScope - } else { - region = nil - } - // Send time shows on every incoming message (DM and channel) — unlike hop - // and region, it is not gated on `isFloodRouted`. Shows the clock-corrected - // `senderDate`, not the raw wire value, so a skewed sender clock doesn't put a - // misleading timestamp in the bubble; the badge flags the substitution and the - // raw value is available in the message info sheet. - let sendTimeToShow: Date? = - (envInputs.showIncomingSendTime && !message.isOutgoing) - ? message.senderDate : nil - return MessageFooter( - showHop: showHop, - hopCount: message.hopCount, - formattedPath: inputs.formattedPath, - regionToShow: region, - sendTimeToShow: sendTimeToShow, - sendTimeWasCorrected: message.timestampCorrected, - showStatusRow: message.isOutgoing, - status: message.status, - isChannelMessage: message.isChannelMessage, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts, - sendCount: message.sendCount + private static func makeInlineImage( + url: URL?, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> InlineImage { + let loadState: InlineImage.LoadState = switch inputs.previewState { + case .loaded: + if inputs.hasInlineImageRef { + .loaded( + ImageReference(cacheKey: inputs.messageID, role: .inline), + isGIF: inputs.imageIsGIF ) + } else if let url { + .loading(url) + } else { + .failed(Self.blankURL) + } + case .loading: + url.map { .loading($0) } ?? .failed(Self.blankURL) + case .noPreview: + url.map { .failed($0) } ?? .failed(Self.blankURL) + case .disabled: + url.map { .disabled($0) } ?? .failed(Self.blankURL) + case .idle, .malwareWarning: + url.map { .idle($0) } ?? .failed(Self.blankURL) } + return InlineImage( + state: loadState, + autoPlayGIFs: envInputs.autoPlayGIFs, + cachedAspect: inputs.inlineImageAspect + ) + } - private static func makeGrouping(inputs: MessageBuildInputs) -> GroupingFlags { - GroupingFlags( - showTimestamp: inputs.showTimestamp, - showDirectionGap: inputs.showDirectionGap, - showSenderName: inputs.showSenderName, - showNewMessagesDivider: inputs.showNewMessagesDivider, - showDayDivider: inputs.showDayDivider + private static func makeLinkPreview( + message: MessageDTO, + url: URL?, + inputs: MessageBuildInputs + ) -> LinkPreviewFragmentState { + let imageRef = inputs.hasPreviewImageRef + ? ImageReference(cacheKey: inputs.messageID, role: .linkPreviewImage) + : nil + let iconRef = inputs.hasPreviewIconRef + ? ImageReference(cacheKey: inputs.messageID, role: .linkPreviewIcon) + : nil + + let mode: LinkPreviewFragmentState.Mode = switch inputs.previewState { + case .loaded: + if let preview = inputs.loadedPreview { + .loaded(preview, image: imageRef, icon: iconRef) + } else { + .noPreview + } + case .loading: + url.map { .loading($0) } ?? .idle + case .noPreview: + .noPreview + case .disabled: + url.map { .disabled($0) } ?? .idle + case .idle: + if let urlString = message.linkPreviewURL, + let legacyURL = URL(string: urlString) { + .legacy( + url: legacyURL, + title: message.linkPreviewTitle, + image: imageRef, + icon: iconRef ) + } else if let url { + .loading(url) + } else { + .idle + } + case .malwareWarning: + .idle } + return LinkPreviewFragmentState(mode: mode) + } + + private static func makeEnvelope( + for message: MessageDTO, + inputs: MessageBuildInputs + ) -> MessageEnvelope { + MessageEnvelope( + messageID: message.id, + isOutgoing: message.isOutgoing, + senderName: inputs.senderResolution.displayName, + senderResolution: inputs.senderResolution, + status: message.status, + // Send time, not drain time: the centered divider is the sole time surface, + // so a days-old drained message must not be relabeled at its delivery time. + date: message.senderDate, + hasFailed: message.hasFailed, + containsSelfMention: message.containsSelfMention, + mentionSeen: message.mentionSeen + ) + } - /// Mirrors the predicate the bubble's `.onAppear` evaluated as - /// `previewState == .idle && detectedURL != nil && message.linkPreviewURL == nil`. - /// Pre-computed on the builder so the bubble body does not read view-model - /// state during render. - private static func shouldRequestPreviewFetch( - inputs: MessageBuildInputs, - message: MessageDTO - ) -> Bool { - inputs.previewState == .idle - && inputs.cachedURL != nil - && message.linkPreviewURL == nil + private static func makeFooter( + for message: MessageDTO, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> MessageFooter { + let showHop = envInputs.showIncomingHopCount && message.isFloodRouted + let region: String? = if envInputs.showIncomingRegion, message.isFloodRouted { + message.regionScope + } else { + nil } + // Send time shows on every incoming message (DM and channel) — unlike hop + // and region, it is not gated on `isFloodRouted`. Shows the clock-corrected + // `senderDate`, not the raw wire value, so a skewed sender clock doesn't put a + // misleading timestamp in the bubble; the badge flags the substitution and the + // raw value is available in the message info sheet. + let sendTimeToShow: Date? = + (envInputs.showIncomingSendTime && !message.isOutgoing) + ? message.senderDate : nil + return MessageFooter( + showHop: showHop, + hopCount: message.hopCount, + formattedPath: inputs.formattedPath, + regionToShow: region, + sendTimeToShow: sendTimeToShow, + sendTimeWasCorrected: message.timestampCorrected, + showStatusRow: message.isOutgoing, + status: message.status, + isChannelMessage: message.isChannelMessage, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts, + sendCount: message.sendCount + ) + } + + private static func makeGrouping(inputs: MessageBuildInputs) -> GroupingFlags { + GroupingFlags( + showTimestamp: inputs.showTimestamp, + showDirectionGap: inputs.showDirectionGap, + showSenderName: inputs.showSenderName, + showNewMessagesDivider: inputs.showNewMessagesDivider, + showDayDivider: inputs.showDayDivider + ) + } + + /// Mirrors the predicate the bubble's `.onAppear` evaluated as + /// `previewState == .idle && detectedURL != nil && message.linkPreviewURL == nil`. + /// Pre-computed on the builder so the bubble body does not read view-model + /// state during render. + private static func shouldRequestPreviewFetch( + inputs: MessageBuildInputs, + message: MessageDTO + ) -> Bool { + inputs.previewState == .idle + && inputs.cachedURL != nil + && message.linkPreviewURL == nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageLRUCache.swift b/MC1Services/Sources/MC1Services/Services/MessageLRUCache.swift index 8ff3e747..e1fb7bf6 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageLRUCache.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageLRUCache.swift @@ -1,136 +1,136 @@ import Foundation /// Key for message lookup in LRU cache -struct MessageCacheKey: Hashable, Sendable { - let channelIndex: UInt8 - let senderName: String - let messageHash: String +struct MessageCacheKey: Hashable { + let channelIndex: UInt8 + let senderName: String + let messageHash: String } /// Key for DM message lookup in LRU cache -struct DirectMessageCacheKey: Hashable, Sendable { - let contactID: UUID - let messageHash: String +struct DirectMessageCacheKey: Hashable { + let contactID: UUID + let messageHash: String } /// Candidate message for reaction matching -struct MessageCandidate: Sendable, Equatable { - let messageID: UUID - let text: String - let timestamp: UInt32 - let indexedAt: Date - - init(messageID: UUID, text: String, timestamp: UInt32, indexedAt: Date = Date()) { - self.messageID = messageID - self.text = text - self.timestamp = timestamp - self.indexedAt = indexedAt - } +struct MessageCandidate: Equatable { + let messageID: UUID + let text: String + let timestamp: UInt32 + let indexedAt: Date + + init(messageID: UUID, text: String, timestamp: UInt32, indexedAt: Date = Date()) { + self.messageID = messageID + self.text = text + self.timestamp = timestamp + self.indexedAt = indexedAt + } } /// LRU cache for recent channel messages to enable reaction matching with collision resolution actor MessageLRUCache { - private var cache: [MessageCacheKey: [MessageCandidate]] = [:] - private var order: [MessageCacheKey] = [] - - private var dmCache: [DirectMessageCacheKey: [MessageCandidate]] = [:] - private var dmOrder: [DirectMessageCacheKey] = [] + private var cache: [MessageCacheKey: [MessageCandidate]] = [:] + private var order: [MessageCacheKey] = [] - private let capacity: Int - private let maxCandidatesPerKey: Int + private var dmCache: [DirectMessageCacheKey: [MessageCandidate]] = [:] + private var dmOrder: [DirectMessageCacheKey] = [] - init(capacity: Int = 500, maxCandidatesPerKey: Int = 5) { - self.capacity = capacity - self.maxCandidatesPerKey = maxCandidatesPerKey - } + private let capacity: Int + private let maxCandidatesPerKey: Int - /// Indexes a message for later lookup - func index(messageID: UUID, channelIndex: UInt8, senderName: String, text: String, timestamp: UInt32) { - let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) - let key = MessageCacheKey(channelIndex: channelIndex, senderName: senderName, messageHash: hash) - let candidate = MessageCandidate(messageID: messageID, text: text, timestamp: timestamp) + init(capacity: Int = 500, maxCandidatesPerKey: Int = 5) { + self.capacity = capacity + self.maxCandidatesPerKey = maxCandidatesPerKey + } - // Update LRU order - if let existingIndex = order.firstIndex(of: key) { - order.remove(at: existingIndex) - } - order.append(key) + /// Indexes a message for later lookup + func index(messageID: UUID, channelIndex: UInt8, senderName: String, text: String, timestamp: UInt32) { + let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) + let key = MessageCacheKey(channelIndex: channelIndex, senderName: senderName, messageHash: hash) + let candidate = MessageCandidate(messageID: messageID, text: text, timestamp: timestamp) - // Evict oldest key if at capacity - if order.count > capacity, let oldest = order.first { - order.removeFirst() - cache.removeValue(forKey: oldest) - } + // Update LRU order + if let existingIndex = order.firstIndex(of: key) { + order.remove(at: existingIndex) + } + order.append(key) - // Add candidate to key's list - var candidates = cache[key] ?? [] + // Evict oldest key if at capacity + if order.count > capacity, let oldest = order.first { + order.removeFirst() + cache.removeValue(forKey: oldest) + } - // Remove existing candidate with same messageID (re-indexing) - candidates.removeAll { $0.messageID == messageID } + // Add candidate to key's list + var candidates = cache[key] ?? [] - // Append new candidate - candidates.append(candidate) + // Remove existing candidate with same messageID (re-indexing) + candidates.removeAll { $0.messageID == messageID } - // Prune to max candidates (keep most recent) - if candidates.count > maxCandidatesPerKey { - candidates = Array(candidates.suffix(maxCandidatesPerKey)) - } + // Append new candidate + candidates.append(candidate) - cache[key] = candidates + // Prune to max candidates (keep most recent) + if candidates.count > maxCandidatesPerKey { + candidates = Array(candidates.suffix(maxCandidatesPerKey)) } - /// Looks up candidates by cache key - func lookup(channelIndex: UInt8, senderName: String, messageHash: String) -> [MessageCandidate] { - let key = MessageCacheKey(channelIndex: channelIndex, senderName: senderName, messageHash: messageHash) - return cache[key] ?? [] - } + cache[key] = candidates + } - /// Indexes a DM message for later lookup - func indexDM(messageID: UUID, contactID: UUID, text: String, timestamp: UInt32) { - let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) - let key = DirectMessageCacheKey(contactID: contactID, messageHash: hash) - let candidate = MessageCandidate(messageID: messageID, text: text, timestamp: timestamp) + /// Looks up candidates by cache key + func lookup(channelIndex: UInt8, senderName: String, messageHash: String) -> [MessageCandidate] { + let key = MessageCacheKey(channelIndex: channelIndex, senderName: senderName, messageHash: messageHash) + return cache[key] ?? [] + } - // Update LRU order - if let existingIndex = dmOrder.firstIndex(of: key) { - dmOrder.remove(at: existingIndex) - } - dmOrder.append(key) + /// Indexes a DM message for later lookup + func indexDM(messageID: UUID, contactID: UUID, text: String, timestamp: UInt32) { + let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) + let key = DirectMessageCacheKey(contactID: contactID, messageHash: hash) + let candidate = MessageCandidate(messageID: messageID, text: text, timestamp: timestamp) - // Evict oldest key if at capacity - if dmOrder.count > capacity, let oldest = dmOrder.first { - dmOrder.removeFirst() - dmCache.removeValue(forKey: oldest) - } + // Update LRU order + if let existingIndex = dmOrder.firstIndex(of: key) { + dmOrder.remove(at: existingIndex) + } + dmOrder.append(key) - // Add candidate to key's list - var candidates = dmCache[key] ?? [] + // Evict oldest key if at capacity + if dmOrder.count > capacity, let oldest = dmOrder.first { + dmOrder.removeFirst() + dmCache.removeValue(forKey: oldest) + } - // Remove existing candidate with same messageID (re-indexing) - candidates.removeAll { $0.messageID == messageID } + // Add candidate to key's list + var candidates = dmCache[key] ?? [] - // Append new candidate - candidates.append(candidate) + // Remove existing candidate with same messageID (re-indexing) + candidates.removeAll { $0.messageID == messageID } - // Prune to max candidates (keep most recent) - if candidates.count > maxCandidatesPerKey { - candidates = Array(candidates.suffix(maxCandidatesPerKey)) - } + // Append new candidate + candidates.append(candidate) - dmCache[key] = candidates + // Prune to max candidates (keep most recent) + if candidates.count > maxCandidatesPerKey { + candidates = Array(candidates.suffix(maxCandidatesPerKey)) } - /// Looks up DM candidates by cache key - func lookupDM(contactID: UUID, messageHash: String) -> [MessageCandidate] { - let key = DirectMessageCacheKey(contactID: contactID, messageHash: messageHash) - return dmCache[key] ?? [] - } - - /// Clears the cache - func clear() { - cache.removeAll() - order.removeAll() - dmCache.removeAll() - dmOrder.removeAll() - } + dmCache[key] = candidates + } + + /// Looks up DM candidates by cache key + func lookupDM(contactID: UUID, messageHash: String) -> [MessageCandidate] { + let key = DirectMessageCacheKey(contactID: contactID, messageHash: messageHash) + return dmCache[key] ?? [] + } + + /// Clears the cache + func clear() { + cache.removeAll() + order.removeAll() + dmCache.removeAll() + dmOrder.removeAll() + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessagePollingService.swift b/MC1Services/Sources/MC1Services/Services/MessagePollingService.swift index bc1a86db..c8180abc 100644 --- a/MC1Services/Sources/MC1Services/Services/MessagePollingService.swift +++ b/MC1Services/Sources/MC1Services/Services/MessagePollingService.swift @@ -5,19 +5,19 @@ import os // MARK: - Message Polling Errors public enum MessagePollingError: Error, Sendable { - case notConnected - case pollingFailed - case sessionError(MeshCoreError) + case notConnected + case pollingFailed + case sessionError(MeshCoreError) } extension MessagePollingError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .pollingFailed: "Message polling failed." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .pollingFailed: "Message polling failed." + case let .sessionError(e): e.localizedDescription } + } } // MARK: - Message Polling Service @@ -25,330 +25,330 @@ extension MessagePollingError: LocalizedError { /// Service for polling messages from the mesh device. /// Handles automatic message fetching and contact message routing. actor MessagePollingService { - - // MARK: - Properties - - private let session: any MeshCoreSessionProtocol - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "MessagePolling") - - /// Handler for incoming contact messages. - /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. - private var contactMessageHandler: (@Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void)? - - /// Handler for incoming channel messages. - /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. - private var channelMessageHandler: (@Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void)? - - /// Handler for signed messages (from room servers). - /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. - private var signedMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? - - /// Handler for CLI responses (textType = 0x01). - /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. - private var cliMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? - - /// Event monitoring task - private var eventMonitorTask: Task? - - /// Whether auto-fetch is currently enabled - private var isAutoFetchEnabled = false - - /// Device ID for contact lookups - private var currentRadioID: UUID? - - /// Count of message handlers currently executing - /// Used to wait for sync-time handlers to complete before resuming notifications - private var pendingHandlerCount: Int = 0 - - /// Whether pollAllMessages() is actively polling and processing messages directly. - /// When true, the event monitor skips message events to avoid double-processing. - private var isPolling = false - - // MARK: - Initialization - - init( - session: any MeshCoreSessionProtocol, - dataStore: any PersistenceStoreProtocol - ) { - self.session = session - self.dataStore = dataStore + // MARK: - Properties + + private let session: any MeshCoreSessionProtocol + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "MessagePolling") + + /// Handler for incoming contact messages. + /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. + private var contactMessageHandler: (@Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void)? + + /// Handler for incoming channel messages. + /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. + private var channelMessageHandler: (@Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void)? + + /// Handler for signed messages (from room servers). + /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. + private var signedMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? + + /// Handler for CLI responses (textType = 0x01). + /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. + private var cliMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? + + /// Event monitoring task + private var eventMonitorTask: Task? + + /// Whether auto-fetch is currently enabled + private var isAutoFetchEnabled = false + + /// Device ID for contact lookups + private var currentRadioID: UUID? + + /// Count of message handlers currently executing + /// Used to wait for sync-time handlers to complete before resuming notifications + private var pendingHandlerCount: Int = 0 + + /// Whether pollAllMessages() is actively polling and processing messages directly. + /// When true, the event monitor skips message events to avoid double-processing. + private var isPolling = false + + // MARK: - Initialization + + init( + session: any MeshCoreSessionProtocol, + dataStore: any PersistenceStoreProtocol + ) { + self.session = session + self.dataStore = dataStore + } + + deinit { + eventMonitorTask?.cancel() + } + + // MARK: - Event Handlers + + /// Set handler for incoming contact messages + func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) { + contactMessageHandler = handler + } + + /// Set handler for incoming channel messages + func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) { + channelMessageHandler = handler + } + + /// Set handler for signed messages (from room servers) + func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { + signedMessageHandler = handler + } + + /// Set handler for CLI responses (textType = 0x01) + func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { + cliMessageHandler = handler + } + + /// Clears all message handlers. The wired handlers capture the owning + /// `ServiceContainer` strongly, so leaving them in place keeps the whole + /// service graph alive after the container is torn down on disconnect. + func clearMessageHandlers() { + contactMessageHandler = nil + channelMessageHandler = nil + signedMessageHandler = nil + cliMessageHandler = nil + } + + /// Whether any message handler is currently wired. + var hasMessageHandlersWired: Bool { + contactMessageHandler != nil || channelMessageHandler != nil + || signedMessageHandler != nil || cliMessageHandler != nil + } + + // MARK: - Event Monitoring + + /// Start event monitoring for message handlers without enabling auto-fetch. + /// Call this before sync to ensure handlers are ready for polled messages. + /// - Parameter radioID: The radio ID for contact lookups + func startMessageEventMonitoring(radioID: UUID) { + currentRadioID = radioID + startEventMonitoring() + logger.info("Message event monitoring started for radio \(radioID)") + } + + /// Stop event monitoring (also stops auto-fetch if running) + func stopMessageEventMonitoring() async { + if isAutoFetchEnabled { + await stopAutoFetch() + } else { + stopEventMonitoring() + currentRadioID = nil } + } - deinit { - eventMonitorTask?.cancel() - } + // MARK: - Auto-Fetch Control - // MARK: - Event Handlers + /// Start automatic message fetching for a device. + /// This enables the session's auto-fetch feature and monitors for incoming messages. + /// - Parameter radioID: The radio ID for contact lookups + func startAutoFetch(radioID: UUID) async { + guard !isAutoFetchEnabled else { return } - /// Set handler for incoming contact messages - func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) { - contactMessageHandler = handler - } + currentRadioID = radioID + isAutoFetchEnabled = true - /// Set handler for incoming channel messages - func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) { - channelMessageHandler = handler + // Start event monitoring if not already running + if eventMonitorTask == nil { + startEventMonitoring() } + await session.startAutoMessageFetching() - /// Set handler for signed messages (from room servers) - func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { - signedMessageHandler = handler - } + logger.info("Auto-fetch started for radio \(radioID)") + } - /// Set handler for CLI responses (textType = 0x01) - func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { - cliMessageHandler = handler - } + /// Stop automatic message fetching + func stopAutoFetch() async { + guard isAutoFetchEnabled else { return } - /// Clears all message handlers. The wired handlers capture the owning - /// `ServiceContainer` strongly, so leaving them in place keeps the whole - /// service graph alive after the container is torn down on disconnect. - func clearMessageHandlers() { - contactMessageHandler = nil - channelMessageHandler = nil - signedMessageHandler = nil - cliMessageHandler = nil - } + isAutoFetchEnabled = false - /// Whether any message handler is currently wired. - var hasMessageHandlersWired: Bool { - contactMessageHandler != nil || channelMessageHandler != nil - || signedMessageHandler != nil || cliMessageHandler != nil - } + // Stop session-level auto-fetch + await session.stopAutoMessageFetching() - // MARK: - Event Monitoring + // Stop event monitoring + stopEventMonitoring() - /// Start event monitoring for message handlers without enabling auto-fetch. - /// Call this before sync to ensure handlers are ready for polled messages. - /// - Parameter radioID: The radio ID for contact lookups - func startMessageEventMonitoring(radioID: UUID) { - currentRadioID = radioID - startEventMonitoring() - logger.info("Message event monitoring started for radio \(radioID)") - } + logger.info("Auto-fetch stopped") + } - /// Stop event monitoring (also stops auto-fetch if running) - func stopMessageEventMonitoring() async { - if isAutoFetchEnabled { - await stopAutoFetch() - } else { - stopEventMonitoring() - currentRadioID = nil - } - } - - // MARK: - Auto-Fetch Control + /// Pause session-level auto-fetching without stopping event monitoring. + /// Used during resync to prevent auto-fetch events from inflating handler counts. + func pauseAutoFetch() async { + guard isAutoFetchEnabled else { return } + await session.stopAutoMessageFetching() + } - /// Start automatic message fetching for a device. - /// This enables the session's auto-fetch feature and monitors for incoming messages. - /// - Parameter radioID: The radio ID for contact lookups - func startAutoFetch(radioID: UUID) async { - guard !isAutoFetchEnabled else { return } + /// Resume session-level auto-fetching after a pause. + func resumeAutoFetch() async { + guard isAutoFetchEnabled else { return } + await session.startAutoMessageFetching() + } - currentRadioID = radioID - isAutoFetchEnabled = true + /// Check if auto-fetch is currently enabled + var isAutoFetching: Bool { + isAutoFetchEnabled + } - // Start event monitoring if not already running - if eventMonitorTask == nil { - startEventMonitoring() - } - await session.startAutoMessageFetching() + // MARK: - Manual Polling - logger.info("Auto-fetch started for radio \(radioID)") + /// Manually poll for one message from the device. + /// - Returns: The message result (contact message, channel message, or no more messages) + func pollMessage() async throws -> MessageResult { + do { + return try await session.getMessage() + } catch let error as MeshCoreError { + throw MessagePollingError.sessionError(error) } - - /// Stop automatic message fetching - func stopAutoFetch() async { - guard isAutoFetchEnabled else { return } - - isAutoFetchEnabled = false - - // Stop session-level auto-fetch - await session.stopAutoMessageFetching() - - // Stop event monitoring - stopEventMonitoring() - - logger.info("Auto-fetch stopped") + } + + /// Poll all waiting messages from the device. + /// - Returns: Count of messages retrieved + func pollAllMessages() async throws -> Int { + isPolling = true + defer { isPolling = false } + var count = 0 + + // One anchor for the whole drain so every backlog message shares a sort date + // and forms a single contiguous block positioned at delivery time. + let blockAnchor = Date() + + while true { + try Task.checkCancellation() + let result = try await pollMessage() + switch result { + case let .contactMessage(msg): + count += 1 + await handleContactMessage(msg, context: .initialSync(anchor: blockAnchor)) + case let .channelMessage(msg): + count += 1 + await handleChannelMessage(msg, context: .initialSync(anchor: blockAnchor)) + case .channelDatagram: + // Datagrams are not user-visible messages; drain queue without counting. + // A follow-up plan will add a dedicated MC1Services listener for them. + break + case .noMoreMessages: + return count + } } - - /// Pause session-level auto-fetching without stopping event monitoring. - /// Used during resync to prevent auto-fetch events from inflating handler counts. - func pauseAutoFetch() async { - guard isAutoFetchEnabled else { return } - await session.stopAutoMessageFetching() + } + + /// Wait for all pending message handlers to complete. + /// Call this after pollAllMessages() to ensure all messages are fully processed + /// before performing actions that depend on completion (like resuming notifications). + func waitForPendingHandlers(timeout: Duration) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while pendingHandlerCount > 0 { + if clock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) } - /// Resume session-level auto-fetching after a pause. - func resumeAutoFetch() async { - guard isAutoFetchEnabled else { return } - await session.startAutoMessageFetching() - } + return true + } - /// Check if auto-fetch is currently enabled - var isAutoFetching: Bool { - isAutoFetchEnabled - } + // MARK: - Event Monitoring - // MARK: - Manual Polling + /// Start monitoring MeshCore events for messages + private func startEventMonitoring() { + eventMonitorTask?.cancel() - /// Manually poll for one message from the device. - /// - Returns: The message result (contact message, channel message, or no more messages) - func pollMessage() async throws -> MessageResult { - do { - return try await session.getMessage() - } catch let error as MeshCoreError { - throw MessagePollingError.sessionError(error) - } - } + eventMonitorTask = Task { [weak self] in + guard let self else { return } + let events = await session.events(filter: EventFilter.anyContactMessage.or(.anyChannelMessage)) - /// Poll all waiting messages from the device. - /// - Returns: Count of messages retrieved - func pollAllMessages() async throws -> Int { - isPolling = true - defer { isPolling = false } - var count = 0 - - // One anchor for the whole drain so every backlog message shares a sort date - // and forms a single contiguous block positioned at delivery time. - let blockAnchor = Date() - - while true { - let result = try await pollMessage() - switch result { - case .contactMessage(let msg): - count += 1 - await handleContactMessage(msg, context: .initialSync(anchor: blockAnchor)) - case .channelMessage(let msg): - count += 1 - await handleChannelMessage(msg, context: .initialSync(anchor: blockAnchor)) - case .channelDatagram: - // Datagrams are not user-visible messages; drain queue without counting. - // A follow-up plan will add a dedicated MC1Services listener for them. - break - case .noMoreMessages: - return count - } - } + for await event in events { + guard !Task.isCancelled else { break } + await handleEvent(event) + } } - - /// Wait for all pending message handlers to complete. - /// Call this after pollAllMessages() to ensure all messages are fully processed - /// before performing actions that depend on completion (like resuming notifications). - func waitForPendingHandlers(timeout: Duration) async -> Bool { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - - while pendingHandlerCount > 0 { - if clock.now >= deadline { - return false - } - try? await Task.sleep(for: .milliseconds(10)) - } - - return true + } + + /// Stop monitoring events + private func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + } + + /// Handle incoming MeshCore event + private func handleEvent(_ event: MeshEvent) async { + switch event { + case let .contactMessageReceived(message): + guard !isPolling else { return } + pendingHandlerCount += 1 + defer { pendingHandlerCount -= 1 } + await handleContactMessage(message, context: .live) + + case let .channelMessageReceived(message): + guard !isPolling else { return } + pendingHandlerCount += 1 + defer { pendingHandlerCount -= 1 } + await handleChannelMessage(message, context: .live) + + default: + break } + } - // MARK: - Event Monitoring - - /// Start monitoring MeshCore events for messages - private func startEventMonitoring() { - eventMonitorTask?.cancel() + // MARK: - Private Message Handlers - eventMonitorTask = Task { [weak self] in - guard let self else { return } - let events = await session.events(filter: EventFilter.anyContactMessage.or(.anyChannelMessage)) - - for await event in events { - guard !Task.isCancelled else { break } - await self.handleEvent(event) - } - } + /// Handle incoming contact message + private func handleContactMessage(_ message: ContactMessage, context: DeliveryContext) async { + guard let radioID = currentRadioID else { + logger.warning("Received message but no radio ID set") + await contactMessageHandler?(message, nil, context) + return } - /// Stop monitoring events - private func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil + // Look up the sender contact + let contact = try? await dataStore.fetchContact( + radioID: radioID, + publicKeyPrefix: message.senderPublicKeyPrefix + ) + + // Route based on text type + switch message.textType { + case MeshTextType.cliData.rawValue: + // CLI responses from repeaters (textType = 0x01) + await cliMessageHandler?(message, contact) + case MeshTextType.signedPlain.rawValue: + // Signed messages from room servers (textType = 0x02) + await signedMessageHandler?(message, contact) + default: + // Regular contact messages (textType = 0x00 or unknown) + await contactMessageHandler?(message, contact, context) } - - /// Handle incoming MeshCore event - private func handleEvent(_ event: MeshEvent) async { - switch event { - case .contactMessageReceived(let message): - guard !isPolling else { return } - pendingHandlerCount += 1 - defer { pendingHandlerCount -= 1 } - await handleContactMessage(message, context: .live) - - case .channelMessageReceived(let message): - guard !isPolling else { return } - pendingHandlerCount += 1 - defer { pendingHandlerCount -= 1 } - await handleChannelMessage(message, context: .live) - - default: - break - } + } + + /// Handle incoming channel message + private func handleChannelMessage(_ message: ChannelMessage, context: DeliveryContext) async { + guard let radioID = currentRadioID else { + logger.warning("Received channel message but no radio ID set") + await channelMessageHandler?(message, nil, context) + return } - // MARK: - Private Message Handlers - - /// Handle incoming contact message - private func handleContactMessage(_ message: ContactMessage, context: DeliveryContext) async { - guard let radioID = currentRadioID else { - logger.warning("Received message but no radio ID set") - await contactMessageHandler?(message, nil, context) - return - } - - // Look up the sender contact - let contact = try? await dataStore.fetchContact( - radioID: radioID, - publicKeyPrefix: message.senderPublicKeyPrefix - ) - - // Route based on text type - switch message.textType { - case MeshTextType.cliData.rawValue: - // CLI responses from repeaters (textType = 0x01) - await cliMessageHandler?(message, contact) - case MeshTextType.signedPlain.rawValue: - // Signed messages from room servers (textType = 0x02) - await signedMessageHandler?(message, contact) - default: - // Regular contact messages (textType = 0x00 or unknown) - await contactMessageHandler?(message, contact, context) - } - } - - /// Handle incoming channel message - private func handleChannelMessage(_ message: ChannelMessage, context: DeliveryContext) async { - guard let radioID = currentRadioID else { - logger.warning("Received channel message but no radio ID set") - await channelMessageHandler?(message, nil, context) - return - } + // Look up the channel + let channel = try? await dataStore.fetchChannel(radioID: radioID, index: message.channelIndex) - // Look up the channel - let channel = try? await dataStore.fetchChannel(radioID: radioID, index: message.channelIndex) - - await channelMessageHandler?(message, channel, context) - } + await channelMessageHandler?(message, channel, context) + } } // MARK: - MessagePollingServiceProtocol Conformance extension MessagePollingService: MessagePollingServiceProtocol { - // Already implements pollAllMessages() -> Int + // Already implements pollAllMessages() -> Int } // MARK: - Message Text Type Constants /// Text type identifiers for mesh messages enum MeshTextType: UInt8 { - case plain = 0x00 - case cliData = 0x01 - case signedPlain = 0x02 + case plain = 0x00 + case cliData = 0x01 + case signedPlain = 0x02 } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService+ACK.swift b/MC1Services/Sources/MC1Services/Services/MessageService+ACK.swift index ec17a325..a6443388 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService+ACK.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService+ACK.swift @@ -2,132 +2,131 @@ import Foundation // MARK: - Periodic ACK Checking -extension MessageService { - - /// Starts periodic checking for expired ACKs. - /// - /// Runs a background task that periodically fails a DM awaiting an ACK once - /// its per-entry give-up deadline elapses; `checkExpiredAcks` defines that - /// deadline. - /// - /// - Parameter interval: How often to check for expired ACKs (defaults to 5 seconds) - /// - /// # Lifecycle scope - /// - /// Independent from `startEventMonitoring()`. Counterparts are - /// `stopAckExpiryChecking()` (stop the checker only) and - /// `stopAndFailAllPending()` (stop the checker and fail every in-flight - /// DM — the explicit full-teardown variant). `stopEventMonitoring()` does - /// **not** stop this task. - public func startAckExpiryChecking(interval: TimeInterval = 5.0) { - self.checkInterval = interval - ackCheckTask?.cancel() - - ackCheckTask = Task { [weak self] in - guard let self else { return } - - while !Task.isCancelled { - do { - try await Task.sleep(for: .seconds(self.checkInterval)) - } catch { - break - } - - guard !Task.isCancelled else { break } - - do { - try await self.checkExpiredAcks() - } catch { - self.logger.error("ACK expiry check failed: \(error.localizedDescription)") - } - } - } - } - - /// Stops the periodic ACK expiry checking. - /// - /// Cancels `ackCheckTask` only. Does not stop the session event listener - /// (`stopEventMonitoring()`) and does not fail in-flight DMs - /// (`stopAndFailAllPending()` does both). This is the stop variant used on - /// a routine disconnect: in-flight DMs stay `.sent` so a reconnect within - /// the same session can still receive the ACK. - public func stopAckExpiryChecking() { - ackCheckTask?.cancel() - ackCheckTask = nil - } - - /// Checks for expired ACKs and advances their delivery state. - /// - /// Called automatically by the periodic checker, or manually for an - /// immediate check. The give-up deadline for each entry is - /// `max(ackGiveUpWindow, tracking.timeout)`: the window acts as a floor - /// and post-loop grace on fast presets, while on slow high-spreading-factor - /// presets the deadline follows the attempt's own ACK wait so the checker - /// never fires mid-attempt while the retry loop is still legitimately - /// parked in `waitForEvent`. - /// - /// - Throws: Database errors when updating message status - public func checkExpiredAcks() async throws { - let now = Date() - let window = config.ackGiveUpWindow - - let expiredEntries = pendingAcks.filter { _, tracking in - !tracking.isDelivered && - now.timeIntervalSince(tracking.sentAt) > max(window, tracking.timeout) +public extension MessageService { + /// Starts periodic checking for expired ACKs. + /// + /// Runs a background task that periodically fails a DM awaiting an ACK once + /// its per-entry give-up deadline elapses; `checkExpiredAcks` defines that + /// deadline. + /// + /// - Parameter interval: How often to check for expired ACKs (defaults to 5 seconds) + /// + /// # Lifecycle scope + /// + /// Independent from `startEventMonitoring()`. Counterparts are + /// `stopAckExpiryChecking()` (stop the checker only) and + /// `stopAndFailAllPending()` (stop the checker and fail every in-flight + /// DM — the explicit full-teardown variant). `stopEventMonitoring()` does + /// **not** stop this task. + func startAckExpiryChecking(interval: TimeInterval = 5.0) { + checkInterval = interval + ackCheckTask?.cancel() + + ackCheckTask = Task { [weak self] in + guard let self else { return } + + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(checkInterval)) + } catch { + break } - for (messageID, _) in expiredEntries { - let didFail = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) - guard let removed = pendingAcks.removeValue(forKey: messageID), - !removed.isDelivered, didFail else { continue } + guard !Task.isCancelled else { break } - let deadline = max(window, removed.timeout) - logger.warning("[ack-diag] give-up: failed after \(String(format: "%.1f", now.timeIntervalSince(removed.sentAt)))s window=\(window)s deadline=\(String(format: "%.1f", deadline))s livePending=\(pendingAcks.count)") - statusEventBroadcaster.yield(.failed(messageID: messageID)) + do { + try await checkExpiredAcks() + } catch { + logger.error("ACK expiry check failed: \(error.localizedDescription)") } + } } - - /// Fails all pending messages that are awaiting ACK. - /// - /// Use this when disconnecting from the device to mark all in-flight messages as failed. - /// - /// - Throws: Database errors when updating message status - public func failAllPendingMessages() async throws { - let pending = pendingAcks.filter { !$0.value.isDelivered } - pendingAcks.removeAll() - - for (messageID, _) in pending { - let didFail = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) - if didFail { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - } - } + } + + /// Stops the periodic ACK expiry checking. + /// + /// Cancels `ackCheckTask` only. Does not stop the session event listener + /// (`stopEventMonitoring()`) and does not fail in-flight DMs + /// (`stopAndFailAllPending()` does both). This is the stop variant used on + /// a routine disconnect: in-flight DMs stay `.sent` so a reconnect within + /// the same session can still receive the ACK. + func stopAckExpiryChecking() { + ackCheckTask?.cancel() + ackCheckTask = nil + } + + /// Checks for expired ACKs and advances their delivery state. + /// + /// Called automatically by the periodic checker, or manually for an + /// immediate check. The give-up deadline for each entry is + /// `max(ackGiveUpWindow, tracking.timeout)`: the window acts as a floor + /// and post-loop grace on fast presets, while on slow high-spreading-factor + /// presets the deadline follows the attempt's own ACK wait so the checker + /// never fires mid-attempt while the retry loop is still legitimately + /// parked in `waitForEvent`. + /// + /// - Throws: Database errors when updating message status + func checkExpiredAcks() async throws { + let now = Date() + let window = config.ackGiveUpWindow + + let expiredEntries = pendingAcks.filter { _, tracking in + !tracking.isDelivered && + now.timeIntervalSince(tracking.sentAt) > max(window, tracking.timeout) } - /// Stops ACK checking and fails all pending messages atomically. - /// - /// Use this only for an explicit full teardown where in-flight DMs should - /// be terminated. A routine disconnect uses `stopAckExpiryChecking()` - /// instead, which leaves in-flight DMs `.sent` so a reconnect can still - /// receive their ACKs. - /// - /// - Throws: Database errors when updating message status - public func stopAndFailAllPending() async throws { - ackCheckTask?.cancel() - ackCheckTask = nil - - try await failAllPendingMessages() - } + for (messageID, _) in expiredEntries { + let didFail = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) + guard let removed = pendingAcks.removeValue(forKey: messageID), + !removed.isDelivered, didFail else { continue } - /// The current number of pending ACKs being tracked. - /// - /// Includes undelivered messages still inside the `ackGiveUpWindow`. - public var pendingAckCount: Int { - pendingAcks.count + let deadline = max(window, removed.timeout) + logger.warning("[ack-diag] give-up: failed after \(String(format: "%.1f", now.timeIntervalSince(removed.sentAt)))s window=\(window)s deadline=\(String(format: "%.1f", deadline))s livePending=\(pendingAcks.count)") + statusEventBroadcaster.yield(.failed(messageID: messageID)) } - - /// Whether ACK expiry checking is currently active. - public var isAckExpiryCheckingActive: Bool { - ackCheckTask != nil + } + + /// Fails all pending messages that are awaiting ACK. + /// + /// Use this when disconnecting from the device to mark all in-flight messages as failed. + /// + /// - Throws: Database errors when updating message status + func failAllPendingMessages() async throws { + let pending = pendingAcks.filter { !$0.value.isDelivered } + pendingAcks.removeAll() + + for (messageID, _) in pending { + let didFail = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) + if didFail { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + } } + } + + /// Stops ACK checking and fails all pending messages atomically. + /// + /// Use this only for an explicit full teardown where in-flight DMs should + /// be terminated. A routine disconnect uses `stopAckExpiryChecking()` + /// instead, which leaves in-flight DMs `.sent` so a reconnect can still + /// receive their ACKs. + /// + /// - Throws: Database errors when updating message status + func stopAndFailAllPending() async throws { + ackCheckTask?.cancel() + ackCheckTask = nil + + try await failAllPendingMessages() + } + + /// The current number of pending ACKs being tracked. + /// + /// Includes undelivered messages still inside the `ackGiveUpWindow`. + var pendingAckCount: Int { + pendingAcks.count + } + + /// Whether ACK expiry checking is currently active. + var isAckExpiryCheckingActive: Bool { + ackCheckTask != nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService+SendChannel.swift b/MC1Services/Sources/MC1Services/Services/MessageService+SendChannel.swift index addbe481..3369c92a 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService+SendChannel.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService+SendChannel.swift @@ -1,293 +1,292 @@ import Foundation import MeshCore -extension MessageService { +public extension MessageService { + // MARK: - Send Channel Message - // MARK: - Send Channel Message + /// Sends a broadcast message to a channel. + /// + /// Channel messages are broadcast to all devices listening on the specified channel. + /// No acknowledgement is expected or tracked for channel messages. + /// + /// - Parameters: + /// - text: The message text to broadcast. Total payload, including the local + /// node-name header used by repeaters, is bounded by + /// `ProtocolLimits.maxChannelMessageTotalLength` (147 UTF-8 bytes). + /// - channelIndex: The channel index (0-7) + /// - radioID: The local device ID + /// - textType: The text encoding type (defaults to `.plain`) + /// + /// - Returns: The ID of the created message + /// + /// - Throws: + /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxChannelMessageTotalLength` + /// - `MessageServiceError.channelNotFound` if channel index is invalid + /// - `MessageServiceError.sessionError` if MeshCore send fails + /// + /// # Example + /// + /// ```swift + /// let messageID = try await messageService.sendChannelMessage( + /// text: "Hello channel!", + /// channelIndex: 0, + /// radioID: device.id + /// ) + /// ``` + func sendChannelMessage( + text: String, + channelIndex: UInt8, + radioID: UUID, + textType: TextType = .plain + ) async throws -> (id: UUID, timestamp: UInt32) { + // Validate message length (byte count matches firmware buffer limits) + guard text.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength else { + throw MessageServiceError.messageTooLong + } - /// Sends a broadcast message to a channel. - /// - /// Channel messages are broadcast to all devices listening on the specified channel. - /// No acknowledgement is expected or tracked for channel messages. - /// - /// - Parameters: - /// - text: The message text to broadcast. Total payload, including the local - /// node-name header used by repeaters, is bounded by - /// `ProtocolLimits.maxChannelMessageTotalLength` (147 UTF-8 bytes). - /// - channelIndex: The channel index (0-7) - /// - radioID: The local device ID - /// - textType: The text encoding type (defaults to `.plain`) - /// - /// - Returns: The ID of the created message - /// - /// - Throws: - /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxChannelMessageTotalLength` - /// - `MessageServiceError.channelNotFound` if channel index is invalid - /// - `MessageServiceError.sessionError` if MeshCore send fails - /// - /// # Example - /// - /// ```swift - /// let messageID = try await messageService.sendChannelMessage( - /// text: "Hello channel!", - /// channelIndex: 0, - /// radioID: device.id - /// ) - /// ``` - public func sendChannelMessage( - text: String, - channelIndex: UInt8, - radioID: UUID, - textType: TextType = .plain - ) async throws -> (id: UUID, timestamp: UInt32) { - // Validate message length (byte count matches firmware buffer limits) - guard text.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength else { - throw MessageServiceError.messageTooLong - } + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) + // Save message to store as pending first + let messageDTO = createOutgoingChannelMessage( + id: messageID, + radioID: radioID, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + textType: textType + ) + try await dataStore.saveMessage(messageDTO) - // Save message to store as pending first - let messageDTO = createOutgoingChannelMessage( - id: messageID, - radioID: radioID, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - textType: textType + do { + try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { + try await session.sendChannelMessage( + channel: channelIndex, + text: text, + timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)) ) - try await dataStore.saveMessage(messageDTO) - - do { - try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { - try await session.sendChannelMessage( - channel: channelIndex, - text: text, - timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)) - ) - } - } catch { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - try await failMessageAndRethrow(error, messageID: messageID) - } - - // Post-broadcast bookkeeping. The broadcast already left the radio, - // so a throw here must not mark `.failed` — that would lie about - // delivery. Log and continue; the message stays `.sent` (or sticks - // `.pending` if `updateMessageStatus` itself threw, which a subsequent - // ack or user retry can reconverge). No queue row exists on this - // inline path, so the "retry button hidden via missing PendingSend" - // pathology from the queue-routed path does not apply. - do { - try await dataStore.updateMessageStatus(id: messageID, status: .sent) - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) - if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { - try await dataStore.updateChannelLastMessage(channelID: channel.id, date: Date()) - } - } catch { - logger.warning("Channel post-broadcast bookkeeping failed (inline path) messageID=\(messageID) broadcast already out: \(String(describing: error))") - } + } + } catch { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + try await failMessageAndRethrow(error, messageID: messageID) + } - return (id: messageID, timestamp: timestamp) + // Post-broadcast bookkeeping. The broadcast already left the radio, + // so a throw here must not mark `.failed` — that would lie about + // delivery. Log and continue; the message stays `.sent` (or sticks + // `.pending` if `updateMessageStatus` itself threw, which a subsequent + // ack or user retry can reconverge). No queue row exists on this + // inline path, so the "retry button hidden via missing PendingSend" + // pathology from the queue-routed path does not apply. + do { + try await dataStore.updateMessageStatus(id: messageID, status: .sent) + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) + if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { + try await dataStore.updateChannelLastMessage(channelID: channel.id, date: Date()) + } + } catch { + logger.warning("Channel post-broadcast bookkeeping failed (inline path) messageID=\(messageID) broadcast already out: \(String(describing: error))") } - /// Creates a pending channel message without sending it. - /// - /// Use this for optimistic UI — the message is saved immediately and can be - /// displayed in the conversation while the actual send happens in the background - /// via ``sendPendingChannelMessage(messageID:channelIndex:radioID:)``. - /// - /// - Parameters: - /// - text: The message text - /// - channelIndex: The channel index to send on - /// - radioID: The device ID - /// - textType: The text type (defaults to `.plain`) - /// - /// - Returns: The created message DTO with pending status - public func createPendingChannelMessage( - text: String, - channelIndex: UInt8, - radioID: UUID, - textType: TextType = .plain - ) async throws -> MessageDTO { - guard text.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength else { - throw MessageServiceError.messageTooLong - } + return (id: messageID, timestamp: timestamp) + } - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) + /// Creates a pending channel message without sending it. + /// + /// Use this for optimistic UI — the message is saved immediately and can be + /// displayed in the conversation while the actual send happens in the background + /// via ``sendPendingChannelMessage(messageID:channelIndex:radioID:)``. + /// + /// - Parameters: + /// - text: The message text + /// - channelIndex: The channel index to send on + /// - radioID: The device ID + /// - textType: The text type (defaults to `.plain`) + /// + /// - Returns: The created message DTO with pending status + func createPendingChannelMessage( + text: String, + channelIndex: UInt8, + radioID: UUID, + textType: TextType = .plain + ) async throws -> MessageDTO { + guard text.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength else { + throw MessageServiceError.messageTooLong + } - let messageDTO = createOutgoingChannelMessage( - id: messageID, - radioID: radioID, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - textType: textType - ) - try await dataStore.saveMessage(messageDTO) + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) - return messageDTO - } + let messageDTO = createOutgoingChannelMessage( + id: messageID, + radioID: radioID, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + textType: textType + ) + try await dataStore.saveMessage(messageDTO) - /// Sends an already-created pending channel message. - /// - /// Use this after ``createPendingChannelMessage(text:channelIndex:radioID:textType:)`` - /// to transmit the message over the mesh. Updates the message status to `.sent` on - /// success or `.failed` on error. - /// - /// - Parameter messageID: The ID of the pending message to send - public func sendPendingChannelMessage(messageID: UUID) async throws { - // Catch 1: validation guards + send + status flip. Validation lives - // inside the do/catch so a missing message or missing channel index - // consistently writes `.failed` to the DB before propagating — paired - // with the queue's outer catch, which fires `notifyMessageFailed`. - // A failure before .sent is committed means the user should see - // .failed (retry button visible). - let radioID: UUID - let channelIndex: UInt8 - do { - guard let message = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Message not found") - } - guard let idx = message.channelIndex else { - throw MessageServiceError.sendFailed("Not a channel message") - } - radioID = message.radioID - channelIndex = idx + return messageDTO + } - try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { - try await session.sendChannelMessage( - channel: channelIndex, - text: message.text, - timestamp: Date(timeIntervalSince1970: TimeInterval(message.timestamp)) - ) - } - try await dataStore.updateMessageStatus(id: messageID, status: .sent) - } catch { - try await failMessageAndRethrow(error, messageID: messageID) - } + /// Sends an already-created pending channel message. + /// + /// Use this after ``createPendingChannelMessage(text:channelIndex:radioID:textType:)`` + /// to transmit the message over the mesh. Updates the message status to `.sent` on + /// success or `.failed` on error. + /// + /// - Parameter messageID: The ID of the pending message to send + func sendPendingChannelMessage(messageID: UUID) async throws { + // Catch 1: validation guards + send + status flip. Validation lives + // inside the do/catch so a missing message or missing channel index + // consistently writes `.failed` to the DB before propagating — paired + // with the queue's outer catch, which fires `notifyMessageFailed`. + // A failure before .sent is committed means the user should see + // .failed (retry button visible). + let radioID: UUID + let channelIndex: UInt8 + do { + guard let message = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Message not found") + } + guard let idx = message.channelIndex else { + throw MessageServiceError.sendFailed("Not a channel message") + } + radioID = message.radioID + channelIndex = idx - // Catch 2: post-status bookkeeping. Status is already .sent in DB; the - // radio broadcast happened. A failure here is logged but does not mark - // .failed — that would corrupt the user-visible delivery state. The - // bookkeeping (channel last-message timestamp, sent event) is - // best-effort metadata that next-load or next-ack will reconverge. - do { - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) - if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { - try await dataStore.updateChannelLastMessage(channelID: channel.id, date: Date()) - } - } catch { - logger.warning("Channel post-status bookkeeping failed messageID=\(messageID) status=.sent already committed: \(String(describing: error))") - } + try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { + try await session.sendChannelMessage( + channel: channelIndex, + text: message.text, + timestamp: Date(timeIntervalSince1970: TimeInterval(message.timestamp)) + ) + } + try await dataStore.updateMessageStatus(id: messageID, status: .sent) + } catch { + try await failMessageAndRethrow(error, messageID: messageID) } - /// Resend an existing channel message, incrementing its send count. - /// - /// This is used for "Send Again" - it re-transmits the same message - /// rather than creating a duplicate. Uses a new timestamp so the mesh - /// treats it as a fresh broadcast. - /// - /// - Returns: The new wire timestamp written to the message row. - /// Reaction indexing keys off this value via - /// `SHA256(text || timestamp.littleEndian)`; callers that index reactions - /// for the resent packet must pass this value rather than the pre-resend - /// timestamp from the queued envelope. - @discardableResult - public func resendChannelMessage( - messageID: UUID, - preserveTimestamp: Bool = false - ) async throws -> UInt32 { - // Broadcast .resent only after .sent is committed. The sentCommitted - // flag suppresses a spurious broadcast if catch 1 rethrows; - // failMessageAndRethrow always re-throws, so a catch-1 failure exits - // the function before the broadcast site is reached and the guard is - // belt-and-braces. The event still fires when catch 2 bookkeeping - // throws, since sentCommitted is set before catch 2 runs. Yielding - // after catch 2 and before return preserves the committed-before- - // broadcast ordering the resend tests assert. - var sentCommitted = false - - // Catch 1: validation guards + timestamp update + send + status flip. - // Validation lives inside the do/catch so a missing message or missing - // channel index consistently writes `.failed` before propagating — - // paired with the queue's outer catch. Once `.sent` is committed, the - // per-attempt bookkeeping below cannot retroactively flip it. - let wireTimestamp: UInt32 - do { - guard let message = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Message not found") - } - guard let channelIndex = message.channelIndex else { - throw MessageServiceError.sendFailed("Not a channel message") - } + // Catch 2: post-status bookkeeping. Status is already .sent in DB; the + // radio broadcast happened. A failure here is logged but does not mark + // .failed — that would corrupt the user-visible delivery state. The + // bookkeeping (channel last-message timestamp, sent event) is + // best-effort metadata that next-load or next-ack will reconverge. + do { + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) + if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { + try await dataStore.updateChannelLastMessage(channelID: channel.id, date: Date()) + } + } catch { + logger.warning("Channel post-status bookkeeping failed messageID=\(messageID) status=.sent already committed: \(String(describing: error))") + } + } - let wireDate: Date - if preserveTimestamp { - wireTimestamp = message.timestamp - wireDate = Date(timeIntervalSince1970: TimeInterval(wireTimestamp)) - } else { - let now = Date() - wireTimestamp = UInt32(now.timeIntervalSince1970) - wireDate = now - } + /// Resend an existing channel message, incrementing its send count. + /// + /// This is used for "Send Again" - it re-transmits the same message + /// rather than creating a duplicate. Uses a new timestamp so the mesh + /// treats it as a fresh broadcast. + /// + /// - Returns: The new wire timestamp written to the message row. + /// Reaction indexing keys off this value via + /// `SHA256(text || timestamp.littleEndian)`; callers that index reactions + /// for the resent packet must pass this value rather than the pre-resend + /// timestamp from the queued envelope. + @discardableResult + func resendChannelMessage( + messageID: UUID, + preserveTimestamp: Bool = false + ) async throws -> UInt32 { + // Broadcast .resent only after .sent is committed. The sentCommitted + // flag suppresses a spurious broadcast if catch 1 rethrows; + // failMessageAndRethrow always re-throws, so a catch-1 failure exits + // the function before the broadcast site is reached and the guard is + // belt-and-braces. The event still fires when catch 2 bookkeeping + // throws, since sentCommitted is set before catch 2 runs. Yielding + // after catch 2 and before return preserves the committed-before- + // broadcast ordering the resend tests assert. + var sentCommitted = false - if !preserveTimestamp { - try await dataStore.updateMessageTimestamp(id: messageID, timestamp: wireTimestamp) - } - try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { - try await session.sendChannelMessage( - channel: channelIndex, - text: message.text, - timestamp: wireDate - ) - } - try await dataStore.updateMessageStatus(id: messageID, status: .sent) - sentCommitted = true - } catch { - try await failMessageAndRethrow(error, messageID: messageID) - } + // Catch 1: validation guards + timestamp update + send + status flip. + // Validation lives inside the do/catch so a missing message or missing + // channel index consistently writes `.failed` before propagating — + // paired with the queue's outer catch. Once `.sent` is committed, the + // per-attempt bookkeeping below cannot retroactively flip it. + let wireTimestamp: UInt32 + do { + guard let message = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Message not found") + } + guard let channelIndex = message.channelIndex else { + throw MessageServiceError.sendFailed("Not a channel message") + } - // Catch 2: per-attempt bookkeeping. Status is already .sent in DB and - // the radio broadcast happened. Failures here log only — the message - // is correctly marked .sent and the missing metadata (sendCount, - // heardRepeats clear) is non-load-bearing for delivery semantics; the - // next inbound ack/repeat event will reconverge. - do { - _ = try await dataStore.incrementMessageSendCount(id: messageID) - try await dataStore.updateMessageHeardRepeats(id: messageID, heardRepeats: 0) - try await dataStore.deleteMessageRepeats(messageID: messageID) - } catch { - logger.warning("Resend post-status bookkeeping failed messageID=\(messageID) status=.sent already committed: \(String(describing: error))") - } + let wireDate: Date + if preserveTimestamp { + wireTimestamp = message.timestamp + wireDate = Date(timeIntervalSince1970: TimeInterval(wireTimestamp)) + } else { + let now = Date() + wireTimestamp = UInt32(now.timeIntervalSince1970) + wireDate = now + } - if sentCommitted { - statusEventBroadcaster.yield(.resent(messageID: messageID)) - } + if !preserveTimestamp { + try await dataStore.updateMessageTimestamp(id: messageID, timestamp: wireTimestamp) + } + try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { + try await session.sendChannelMessage( + channel: channelIndex, + text: message.text, + timestamp: wireDate + ) + } + try await dataStore.updateMessageStatus(id: messageID, status: .sent) + sentCommitted = true + } catch { + try await failMessageAndRethrow(error, messageID: messageID) + } - return wireTimestamp + // Catch 2: per-attempt bookkeeping. Status is already .sent in DB and + // the radio broadcast happened. Failures here log only — the message + // is correctly marked .sent and the missing metadata (sendCount, + // heardRepeats clear) is non-load-bearing for delivery semantics; the + // next inbound ack/repeat event will reconverge. + do { + _ = try await dataStore.incrementMessageSendCount(id: messageID) + try await dataStore.updateMessageHeardRepeats(id: messageID, heardRepeats: 0) + try await dataStore.deleteMessageRepeats(messageID: messageID) + } catch { + logger.warning("Resend post-status bookkeeping failed messageID=\(messageID) status=.sent already committed: \(String(describing: error))") } - private func createOutgoingChannelMessage( - id: UUID, - radioID: UUID, - channelIndex: UInt8, - text: String, - timestamp: UInt32, - textType: TextType - ) -> MessageDTO { - let message = Message( - id: id, - radioID: radioID, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - directionRawValue: MessageDirection.outgoing.rawValue, - statusRawValue: MessageStatus.pending.rawValue, - textTypeRawValue: textType.rawValue - ) - return MessageDTO(from: message) + if sentCommitted { + statusEventBroadcaster.yield(.resent(messageID: messageID)) } + + return wireTimestamp + } + + private func createOutgoingChannelMessage( + id: UUID, + radioID: UUID, + channelIndex: UInt8, + text: String, + timestamp: UInt32, + textType: TextType + ) -> MessageDTO { + let message = Message( + id: id, + radioID: radioID, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + directionRawValue: MessageDirection.outgoing.rawValue, + statusRawValue: MessageStatus.pending.rawValue, + textTypeRawValue: textType.rawValue + ) + return MessageDTO(from: message) + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService+SendDM.swift b/MC1Services/Sources/MC1Services/Services/MessageService+SendDM.swift index fa7b8ada..0d61fe66 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService+SendDM.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService+SendDM.swift @@ -4,734 +4,733 @@ import MeshCore // MARK: - Send Direct Message extension MessageService { - - /// Sends a direct message to a contact with a single send attempt. - /// - /// This method sends a message once without automatic retry. Use this when you want - /// to manually control retry logic or when retry is not needed. - /// - /// - Parameters: - /// - text: The message text to send. Bounded by - /// `ProtocolLimits.maxDirectMessageLength` (150 UTF-8 bytes). - /// - contact: The recipient contact - /// - textType: The text encoding type (defaults to `.plain`) - /// - replyToID: Optional ID of message being replied to - /// - /// - Returns: The created message DTO with pending/sent status - /// - /// - Throws: - /// - `MessageServiceError.invalidRecipient` if contact is a repeater - /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxDirectMessageLength` - /// - `MessageServiceError.sessionError` if MeshCore send fails - /// - /// # Example - /// - /// ```swift - /// let message = try await messageService.sendDirectMessage( - /// text: "Hello!", - /// to: contact - /// ) - /// ``` - public func sendDirectMessage( - text: String, - to contact: ContactDTO, - textType: TextType = .plain, - replyToID: UUID? = nil - ) async throws -> MessageDTO { - try validateDirectMessage(text: text, to: contact) - - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) - - let messageDTO = createOutgoingMessage( - id: messageID, - radioID: contact.radioID, - contactID: contact.id, + /// Sends a direct message to a contact with a single send attempt. + /// + /// This method sends a message once without automatic retry. Use this when you want + /// to manually control retry logic or when retry is not needed. + /// + /// - Parameters: + /// - text: The message text to send. Bounded by + /// `ProtocolLimits.maxDirectMessageLength` (150 UTF-8 bytes). + /// - contact: The recipient contact + /// - textType: The text encoding type (defaults to `.plain`) + /// - replyToID: Optional ID of message being replied to + /// + /// - Returns: The created message DTO with pending/sent status + /// + /// - Throws: + /// - `MessageServiceError.invalidRecipient` if contact is a repeater + /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxDirectMessageLength` + /// - `MessageServiceError.sessionError` if MeshCore send fails + /// + /// # Example + /// + /// ```swift + /// let message = try await messageService.sendDirectMessage( + /// text: "Hello!", + /// to: contact + /// ) + /// ``` + public func sendDirectMessage( + text: String, + to contact: ContactDTO, + textType: TextType = .plain, + replyToID: UUID? = nil + ) async throws -> MessageDTO { + try validateDirectMessage(text: text, to: contact) + + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) + + let messageDTO = createOutgoingMessage( + id: messageID, + radioID: contact.radioID, + contactID: contact.id, + text: text, + timestamp: timestamp, + textType: textType, + replyToID: replyToID + ) + try await dataStore.saveMessage(messageDTO) + + let predictedAck: Data + let sentInfo: MessageSentInfo + do { + // Precompute the expected ACK before the send so the persistent + // listener cannot race trackPendingAck on short direct links (same + // pattern used by the retry loop). Reactions and quick replies + // flow through this path and otherwise lose ACKs on fast links. + guard let senderPublicKey = await session.currentSelfInfo?.publicKey else { + throw MessageServiceError.notConnected + } + predictedAck = AckCodeBuilder.expectedAck( + timestamp: timestamp, + attempt: 0, + text: text, + senderPublicKey: senderPublicKey + ) + // Pre-send floor must outlive one checkExpiredAcks tick, otherwise + // a slow BLE round-trip could let the checker expire the + // speculative entry before the authoritative timeout lands. + trackPendingAck( + messageID: messageID, + contactID: contact.id, + ackCode: predictedAck, + timeout: checkInterval + ) + + do { + sentInfo = try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.directMessageTableFull, config: config.poolBackoff, logger: logger) { + try await session.sendMessage( + to: contact.publicKey, text: text, - timestamp: timestamp, - textType: textType, - replyToID: replyToID - ) - try await dataStore.saveMessage(messageDTO) - - let predictedAck: Data - let sentInfo: MessageSentInfo - do { - // Precompute the expected ACK before the send so the persistent - // listener cannot race trackPendingAck on short direct links (same - // pattern used by the retry loop). Reactions and quick replies - // flow through this path and otherwise lose ACKs on fast links. - guard let senderPublicKey = await session.currentSelfInfo?.publicKey else { - throw MessageServiceError.notConnected - } - predictedAck = AckCodeBuilder.expectedAck( - timestamp: timestamp, - attempt: 0, - text: text, - senderPublicKey: senderPublicKey - ) - // Pre-send floor must outlive one checkExpiredAcks tick, otherwise - // a slow BLE round-trip could let the checker expire the - // speculative entry before the authoritative timeout lands. - trackPendingAck( - messageID: messageID, - contactID: contact.id, - ackCode: predictedAck, - timeout: checkInterval - ) - - do { - sentInfo = try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.directMessageTableFull, config: config.poolBackoff, logger: logger) { - try await session.sendMessage( - to: contact.publicKey, - text: text, - timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)) - ) - } - } catch { - pendingAcks.removeValue(forKey: messageID) - throw error - } - } catch { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - try await failMessageAndRethrow(error, messageID: messageID) - } - - let ackTimeout = TimeInterval(sentInfo.suggestedTimeoutMs) / 1000.0 * 1.2 - - if sentInfo.expectedAck != predictedAck { - logger.warning( - "expectedAck mismatch for \(messageID) attempt 0: predicted \(predictedAck.uppercaseHexString()) vs firmware \(sentInfo.expectedAck.uppercaseHexString()); merging firmware code" - ) - trackPendingAck( - messageID: messageID, - contactID: contact.id, - ackCode: sentInfo.expectedAck, - timeout: ackTimeout - ) - } else { - pendingAcks[messageID]?.timeout = ackTimeout - pendingAcks[messageID]?.sentAt = Date() + timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)) + ) } - - // Post-send bookkeeping. The radio accepted the send, so a throw here - // must not mark `.failed` or drop the pendingAcks entry; the genuine - // end-to-end ACK or the expiry checker owns the terminal state now, - // mirroring the channel send path. - do { - // updateMessageAck preserves `.delivered`, so writing `.sent` here - // is a no-op when the listener already won the race. - try await dataStore.updateMessageAck( - id: messageID, - ackCode: sentInfo.expectedAck.ackCodeUInt32, - status: .sent - ) - try await dataStore.updateContactLastMessage(contactID: contact.id, date: Date()) - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) - } catch { - logger.warning("DM post-send bookkeeping failed messageID=\(messageID) send already accepted: \(String(describing: error))") - } - - guard let message = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Failed to fetch saved message") - } - return message + } catch { + pendingAcks.removeValue(forKey: messageID) + throw error + } + } catch { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + try await failMessageAndRethrow(error, messageID: messageID) } - // MARK: - Send with Automatic Retry - - /// Sends a direct message with automatic retry and flood routing fallback. - /// - /// This is the recommended method for sending messages. It automatically: - /// 1. Attempts direct routing up to `maxAttempts` times - /// 2. Switches to flood routing after `floodAfter` attempts - /// 3. Makes up to `maxFloodAttempts` using flood routing - /// 4. Returns immediately when ACK is received - /// - /// The message is saved to the database immediately and the `onMessageCreated` - /// callback is invoked, allowing the UI to update before the send completes. - /// - /// - Parameters: - /// - text: The message text to send. Bounded by - /// `ProtocolLimits.maxDirectMessageLength` (150 UTF-8 bytes). - /// - contact: The recipient contact - /// - textType: The text encoding type (defaults to `.plain`) - /// - replyToID: Optional ID of message being replied to - /// - timeout: Custom timeout in seconds (0 = use device-suggested timeout) - /// - onMessageCreated: Callback invoked after message is saved to database - /// - /// - Returns: The message DTO with final delivery status (delivered or failed) - /// - /// - Throws: - /// - `MessageServiceError.invalidRecipient` if contact is a repeater - /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxDirectMessageLength` - /// - `MessageServiceError.sessionError` if MeshCore send fails - /// - /// # Example - /// - /// ```swift - /// let message = try await messageService.sendMessageWithRetry( - /// text: "Hello!", - /// to: contact - /// ) { savedMessage in - /// // Update UI immediately with pending message - /// await updateConversation(with: savedMessage) - /// } - /// // Message is now delivered or failed - /// ``` - public func sendMessageWithRetry( - text: String, - to contact: ContactDTO, - textType: TextType = .plain, - replyToID: UUID? = nil, - timeout: TimeInterval = 0, - onMessageCreated: (@Sendable (MessageDTO) async -> Void)? = nil - ) async throws -> MessageDTO { - try validateDirectMessage(text: text, to: contact) - - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) - - // Save message to store as pending first - let messageDTO = createOutgoingMessage( - id: messageID, - radioID: contact.radioID, - contactID: contact.id, - text: text, - timestamp: timestamp, - textType: textType, - replyToID: replyToID - ) - try await dataStore.saveMessage(messageDTO) - - // Notify caller that message is saved - await onMessageCreated?(messageDTO) - - // Capture initial routing state to detect changes - let initialPathLength = contact.outPathLength - - // Run app-layer retry loop with UI notifications - do { - let sentInfo = try await sendDirectMessageWithRetryLoop( - messageID: messageID, - contactID: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - text: text, - timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)), - timestampRaw: timestamp, - timeout: timeout > 0 ? timeout : nil - ) - - return try await finalizeSend( - messageID: messageID, - contactID: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - sentInfo: sentInfo, - initialPathLength: initialPathLength - ) - } catch { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - try await failMessageAndRethrow(error, messageID: messageID) - } + let ackTimeout = TimeInterval(sentInfo.suggestedTimeoutMs) / 1000.0 * 1.2 + + if sentInfo.expectedAck != predictedAck { + logger.warning( + "expectedAck mismatch for \(messageID) attempt 0: predicted \(predictedAck.uppercaseHexString()) vs firmware \(sentInfo.expectedAck.uppercaseHexString()); merging firmware code" + ) + trackPendingAck( + messageID: messageID, + contactID: contact.id, + ackCode: sentInfo.expectedAck, + timeout: ackTimeout + ) + } else { + pendingAcks[messageID]?.timeout = ackTimeout + pendingAcks[messageID]?.sentAt = Date() } - /// Creates a pending message without sending it. - /// - /// Use this when you want to show the message in the UI immediately - /// and drain it later via ``sendPendingDirectMessage(messageID:to:preserveTimestamp:)``. - /// - /// - Parameters: - /// - text: The message text - /// - contact: The recipient contact - /// - textType: The type of text content (default: .plain) - /// - replyToID: Optional ID of message being replied to - /// - /// - Returns: The created message DTO with pending status - public func createPendingMessage( - text: String, - to contact: ContactDTO, - textType: TextType = .plain, - replyToID: UUID? = nil - ) async throws -> MessageDTO { - try validateDirectMessage(text: text, to: contact) - - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) - - let messageDTO = createOutgoingMessage( - id: messageID, - radioID: contact.radioID, - contactID: contact.id, - text: text, - timestamp: timestamp, - textType: textType, - replyToID: replyToID - ) - try await dataStore.saveMessage(messageDTO) - try await dataStore.updateContactLastMessage(contactID: contact.id, date: messageDTO.date) - - return messageDTO + // Post-send bookkeeping. The radio accepted the send, so a throw here + // must not mark `.failed` or drop the pendingAcks entry; the genuine + // end-to-end ACK or the expiry checker owns the terminal state now, + // mirroring the channel send path. + do { + // updateMessageAck preserves `.delivered`, so writing `.sent` here + // is a no-op when the listener already won the race. + try await dataStore.updateMessageAck( + id: messageID, + ackCode: sentInfo.expectedAck.ackCodeUInt32, + status: .sent + ) + try await dataStore.updateContactLastMessage(contactID: contact.id, date: Date()) + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) + } catch { + logger.warning("DM post-send bookkeeping failed messageID=\(messageID) send already accepted: \(String(describing: error))") } - /// Drains a `.pending` DM through the app-layer retry loop. Does not - /// bump `sendCount` or broadcast `.resent`; both bubble-affecting side - /// effects belong to - /// ``resendDirectMessage(messageID:to:preserveTimestamp:)``. - /// - /// - Parameter preserveTimestamp: True when a prior drain attempt may - /// already have placed the packet on the wire (queue auto-park-and- - /// retry), so the recipient's dedup ring catches the duplicate - /// rather than rendering it twice. False on the first drain, where - /// a fresh timestamp keeps mesh repeaters from filtering the packet. - public func sendPendingDirectMessage( - messageID: UUID, - to contact: ContactDTO, - preserveTimestamp: Bool = false - ) async throws -> MessageDTO { - try await performQueuedDirectMessageSend( - messageID: messageID, - to: contact, - preserveTimestamp: preserveTimestamp, - isResend: false - ) + guard let message = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Failed to fetch saved message") } - - /// Resends an already-sent DM. Re-runs the app-layer retry loop, - /// increments `sendCount`, and broadcasts `.resent` so the bubble - /// surfaces "Sent N times". - /// - /// - Parameter preserveTimestamp: True on queue auto-park-and-retry - /// of the resend; false on the first drain attempt so the wire - /// packet hashes distinctly from the original and clears repeater - /// dedup rings. - public func resendDirectMessage( - messageID: UUID, - to contact: ContactDTO, - preserveTimestamp: Bool = false - ) async throws -> MessageDTO { - try await performQueuedDirectMessageSend( - messageID: messageID, - to: contact, - preserveTimestamp: preserveTimestamp, - isResend: true - ) + return message + } + + // MARK: - Send with Automatic Retry + + /// Sends a direct message with automatic retry and flood routing fallback. + /// + /// This is the recommended method for sending messages. It automatically: + /// 1. Attempts direct routing up to `maxAttempts` times + /// 2. Switches to flood routing after `floodAfter` attempts + /// 3. Makes up to `maxFloodAttempts` using flood routing + /// 4. Returns immediately when ACK is received + /// + /// The message is saved to the database immediately and the `onMessageCreated` + /// callback is invoked, allowing the UI to update before the send completes. + /// + /// - Parameters: + /// - text: The message text to send. Bounded by + /// `ProtocolLimits.maxDirectMessageLength` (150 UTF-8 bytes). + /// - contact: The recipient contact + /// - textType: The text encoding type (defaults to `.plain`) + /// - replyToID: Optional ID of message being replied to + /// - timeout: Custom timeout in seconds (0 = use device-suggested timeout) + /// - onMessageCreated: Callback invoked after message is saved to database + /// + /// - Returns: The message DTO with final delivery status (delivered or failed) + /// + /// - Throws: + /// - `MessageServiceError.invalidRecipient` if contact is a repeater + /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxDirectMessageLength` + /// - `MessageServiceError.sessionError` if MeshCore send fails + /// + /// # Example + /// + /// ```swift + /// let message = try await messageService.sendMessageWithRetry( + /// text: "Hello!", + /// to: contact + /// ) { savedMessage in + /// // Update UI immediately with pending message + /// await updateConversation(with: savedMessage) + /// } + /// // Message is now delivered or failed + /// ``` + public func sendMessageWithRetry( + text: String, + to contact: ContactDTO, + textType: TextType = .plain, + replyToID: UUID? = nil, + timeout: TimeInterval = 0, + onMessageCreated: (@Sendable (MessageDTO) async -> Void)? = nil + ) async throws -> MessageDTO { + try validateDirectMessage(text: text, to: contact) + + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) + + // Save message to store as pending first + let messageDTO = createOutgoingMessage( + id: messageID, + radioID: contact.radioID, + contactID: contact.id, + text: text, + timestamp: timestamp, + textType: textType, + replyToID: replyToID + ) + try await dataStore.saveMessage(messageDTO) + + // Notify caller that message is saved + await onMessageCreated?(messageDTO) + + // Capture initial routing state to detect changes + let initialPathLength = contact.outPathLength + + // Run app-layer retry loop with UI notifications + do { + let sentInfo = try await sendDirectMessageWithRetryLoop( + messageID: messageID, + contactID: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + text: text, + timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)), + timestampRaw: timestamp, + timeout: timeout > 0 ? timeout : nil + ) + + return try await finalizeSend( + messageID: messageID, + contactID: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + sentInfo: sentInfo, + initialPathLength: initialPathLength + ) + } catch { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + try await failMessageAndRethrow(error, messageID: messageID) + } + } + + /// Creates a pending message without sending it. + /// + /// Use this when you want to show the message in the UI immediately + /// and drain it later via ``sendPendingDirectMessage(messageID:to:preserveTimestamp:)``. + /// + /// - Parameters: + /// - text: The message text + /// - contact: The recipient contact + /// - textType: The type of text content (default: .plain) + /// - replyToID: Optional ID of message being replied to + /// + /// - Returns: The created message DTO with pending status + public func createPendingMessage( + text: String, + to contact: ContactDTO, + textType: TextType = .plain, + replyToID: UUID? = nil + ) async throws -> MessageDTO { + try validateDirectMessage(text: text, to: contact) + + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) + + let messageDTO = createOutgoingMessage( + id: messageID, + radioID: contact.radioID, + contactID: contact.id, + text: text, + timestamp: timestamp, + textType: textType, + replyToID: replyToID + ) + try await dataStore.saveMessage(messageDTO) + try await dataStore.updateContactLastMessage(contactID: contact.id, date: messageDTO.date) + + return messageDTO + } + + /// Drains a `.pending` DM through the app-layer retry loop. Does not + /// bump `sendCount` or broadcast `.resent`; both bubble-affecting side + /// effects belong to + /// ``resendDirectMessage(messageID:to:preserveTimestamp:)``. + /// + /// - Parameter preserveTimestamp: True when a prior drain attempt may + /// already have placed the packet on the wire (queue auto-park-and- + /// retry), so the recipient's dedup ring catches the duplicate + /// rather than rendering it twice. False on the first drain, where + /// a fresh timestamp keeps mesh repeaters from filtering the packet. + public func sendPendingDirectMessage( + messageID: UUID, + to contact: ContactDTO, + preserveTimestamp: Bool = false + ) async throws -> MessageDTO { + try await performQueuedDirectMessageSend( + messageID: messageID, + to: contact, + preserveTimestamp: preserveTimestamp, + isResend: false + ) + } + + /// Resends an already-sent DM. Re-runs the app-layer retry loop, + /// increments `sendCount`, and broadcasts `.resent` so the bubble + /// surfaces "Sent N times". + /// + /// - Parameter preserveTimestamp: True on queue auto-park-and-retry + /// of the resend; false on the first drain attempt so the wire + /// packet hashes distinctly from the original and clears repeater + /// dedup rings. + public func resendDirectMessage( + messageID: UUID, + to contact: ContactDTO, + preserveTimestamp: Bool = false + ) async throws -> MessageDTO { + try await performQueuedDirectMessageSend( + messageID: messageID, + to: contact, + preserveTimestamp: preserveTimestamp, + isResend: true + ) + } + + private func performQueuedDirectMessageSend( + messageID: UUID, + to contact: ContactDTO, + preserveTimestamp: Bool, + isResend: Bool + ) async throws -> MessageDTO { + guard !inFlightRetries.contains(messageID) else { + logger.warning("Send already in progress for message: \(messageID)") + throw MessageServiceError.sendFailed("Retry already in progress") } - private func performQueuedDirectMessageSend( - messageID: UUID, - to contact: ContactDTO, - preserveTimestamp: Bool, - isResend: Bool - ) async throws -> MessageDTO { - guard !inFlightRetries.contains(messageID) else { - logger.warning("Send already in progress for message: \(messageID)") - throw MessageServiceError.sendFailed("Retry already in progress") - } - - inFlightRetries.insert(messageID) - defer { inFlightRetries.remove(messageID) } - - let initialPathLength = contact.outPathLength - - guard let existingMessage = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Message not found") - } + inFlightRetries.insert(messageID) + defer { inFlightRetries.remove(messageID) } - // Fresh timestamp on first drain — mesh repeaters deduplicate by - // packet content, so reusing the original would be dropped. Queue - // auto-retry passes preserveTimestamp: true so the duplicate landing - // on a recipient that already saw the first send gets caught by - // their dedup ring rather than rendered as a second copy. - let wireTimestamp: Date - let wireTimestampRaw: UInt32 - if preserveTimestamp { - wireTimestampRaw = existingMessage.timestamp - wireTimestamp = Date(timeIntervalSince1970: TimeInterval(wireTimestampRaw)) - } else { - wireTimestamp = Date() - wireTimestampRaw = UInt32(wireTimestamp.timeIntervalSince1970) - try await dataStore.updateMessageTimestamp(id: messageID, timestamp: wireTimestampRaw) - } + let initialPathLength = contact.outPathLength - do { - let sentInfo = try await sendDirectMessageWithRetryLoop( - messageID: messageID, - contactID: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - text: existingMessage.text, - timestamp: wireTimestamp, - timestampRaw: wireTimestampRaw, - timeout: nil - ) - - // Bump only on a confirmed successful resend so sendCount counts - // landed user-initiated sends, not queue-driven attempts; - // bookkeeping failures log because the on-air retransmission - // already happened. - if isResend, sentInfo != nil { - do { - _ = try await dataStore.incrementMessageSendCount(id: messageID) - } catch { - logger.warning("DM resend sendCount bookkeeping failed messageID=\(messageID): \(String(describing: error))") - } - } - - let message = try await finalizeSend( - messageID: messageID, - contactID: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - sentInfo: sentInfo, - initialPathLength: initialPathLength - ) - - // Broadcast after the DB write so the downstream refetch sees both - // the bumped sendCount and the committed terminal status. - if isResend, sentInfo != nil { - statusEventBroadcaster.yield(.resent(messageID: messageID)) - } - - return message - } catch { - try await failMessageAndRethrow(error, messageID: messageID) - } + guard let existingMessage = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Message not found") } - // MARK: - Direct Message Retry Loop - - /// Sends a direct message with app-layer retry logic and UI notifications. - /// - /// This function manages the retry loop at the app layer (instead of delegating to MeshCore) - /// to provide per-attempt UI feedback. On each attempt, it: - /// - Updates the message status in the database - /// - Broadcasts `.retrying` for UI retry progress - /// - Switches to flood routing after `floodAfter` failed attempts - /// - Broadcasts `.routingChanged` when routing switches - /// - /// - Parameters: - /// - messageID: The message ID for status updates - /// - contactID: The contact ID for routing change notifications - /// - radioID: The device ID for saving contact updates - /// - publicKey: The full 32-byte destination public key - /// - text: The message text - /// - timestamp: The message timestamp (must remain constant across retries) - /// - timestampRaw: The same timestamp as a `UInt32` epoch-seconds value. The - /// caller must pass the same integer it used to build `timestamp: Date` — - /// resampling inside the loop would break expected-ACK precomputation, - /// since the firmware hash is keyed off the exact `UInt32` that ends up - /// on the wire. - /// - timeout: Optional custom timeout per attempt (nil = use device suggested) - /// - /// - Returns: `MessageSentInfo` if ACK received, `nil` if all attempts exhausted - /// - Throws: `MeshCoreError` if send fails with unrecoverable error - private func sendDirectMessageWithRetryLoop( - messageID: UUID, - contactID: UUID, - radioID: UUID, - publicKey: Data, - text: String, - timestamp: Date, - timestampRaw: UInt32, - timeout: TimeInterval? - ) async throws -> MessageSentInfo? { - var attempts = 0 - var floodAttempts = 0 - var isFloodMode = false - - while attempts < config.maxAttempts && (!isFloodMode || floodAttempts < config.maxFloodAttempts) { - // Check for task cancellation - guard !Task.isCancelled else { - throw CancellationError() - } - - // Update database and notify UI of retry status (only after first attempt fails) - if attempts > 0 { - try await dataStore.updateMessageRetryStatus( - id: messageID, - status: .retrying, - retryAttempt: attempts - 1, - maxRetryAttempts: config.maxAttempts - 1 - ) - statusEventBroadcaster.yield(.retrying(messageID: messageID, attempt: attempts - 1, maxAttempts: config.maxAttempts - 1)) - } - - // Switch to flood routing after floodAfter direct attempts - if attempts == config.floodAfter && !isFloodMode { - logger.info("Resetting path to flood after \(attempts) failed attempts") - do { - try await session.resetPath(publicKey: publicKey) - isFloodMode = true - - // Notify UI of routing change and save updated contact - if let updatedContact = try await session.getContact(publicKey: publicKey) { - _ = try await dataStore.saveContact(radioID: radioID, from: updatedContact.toContactFrame()) - } - statusEventBroadcaster.yield(.routingChanged(contactID: contactID, isFlood: true)) - } catch { - logger.warning("Failed to reset path: \(error.localizedDescription), continuing...") - // Continue anyway - device might handle it - isFloodMode = true - } - } - - if attempts > 0 { - logger.info("Retry sending message: attempt \(attempts + 1)/\(config.maxAttempts)") - } - - // Precompute the expected ACK CRC before the send so the persistent - // ACK listener cannot race ahead of trackPendingAck on short direct links. - guard let senderPublicKey = await session.currentSelfInfo?.publicKey else { - throw MessageServiceError.notConnected - } - - let predictedAck = AckCodeBuilder.expectedAck( - timestamp: timestampRaw, - attempt: UInt8(attempts), - text: text, - senderPublicKey: senderPublicKey - ) - - // Pre-send floor must outlive one checkExpiredAcks tick; otherwise a - // BLE round-trip > 1s could let the checker expire the speculative - // entry before sendMessage returns and we overwrite the timeout with - // the authoritative sentInfo-derived value. - let preSendTimeout = max(timeout ?? config.minTimeout, checkInterval) - trackPendingAck( - messageID: messageID, - contactID: contactID, - ackCode: predictedAck, - timeout: preSendTimeout - ) - - let sentInfo: MessageSentInfo - do { - sentInfo = try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.directMessageTableFull, config: config.poolBackoff, logger: logger) { - try await session.sendMessage( - to: publicKey.prefix(6), - text: text, - timestamp: timestamp, - attempt: UInt8(attempts) - ) - } - } catch { - // Drop the whole speculative entry on send failure. A partial - // remove would leave an empty-codes PendingAck visible to - // checkExpiredAcks if failMessageAndRethrow is ever bypassed. - pendingAcks.removeValue(forKey: messageID) - throw error - } - - // If the persistent ACK listener consumed our predicted ACK during - // sendMessage's cross-actor suspension, the entry is already - // removed or marked delivered. Short-circuit before waitForEvent - // so the retry loop doesn't clobber .delivered via - // updateMessageRetryStatus and broadcast a duplicate DM. - guard let tracked = pendingAcks[messageID], !tracked.isDelivered else { - return sentInfo - } - - let ackTimeout = timeout ?? max( - config.minTimeout, - Double(sentInfo.suggestedTimeoutMs) / 1000.0 * 1.2 - ) - - if sentInfo.expectedAck != predictedAck { - logger.warning( - "expectedAck mismatch for \(messageID) attempt \(attempts): predicted \(predictedAck.uppercaseHexString()) vs firmware \(sentInfo.expectedAck.uppercaseHexString()); merging firmware code" - ) - trackPendingAck( - messageID: messageID, - contactID: contactID, - ackCode: sentInfo.expectedAck, - timeout: ackTimeout - ) - } else { - // Re-stamp sentAt so checkExpiredAcks measures timeout from - // send-return, not from the speculative insert. - pendingAcks[messageID]?.timeout = ackTimeout - pendingAcks[messageID]?.sentAt = Date() - } - - let ackEvent = await session.waitForEvent( - filter: .acknowledgement(code: sentInfo.expectedAck), - timeout: ackTimeout - ) - - if ackEvent != nil { - logger.info("Message acknowledged on attempt \(attempts + 1)") - return sentInfo - } - - // Listener may have consumed the ACK between the pre-send guard - // and waitForEvent's subscription becoming active; re-check before - // retrying so we don't resend a DM the firmware already delivered. - if pendingAcks[messageID]?.isDelivered != false { - return sentInfo - } - - // ACK timeout - increment counters and retry - attempts += 1 - if isFloodMode { - floodAttempts += 1 - } - } - - logger.warning("Message delivery failed after \(attempts) attempts") - return nil + // Fresh timestamp on first drain — mesh repeaters deduplicate by + // packet content, so reusing the original would be dropped. Queue + // auto-retry passes preserveTimestamp: true so the duplicate landing + // on a recipient that already saw the first send gets caught by + // their dedup ring rather than rendered as a second copy. + let wireTimestamp: Date + let wireTimestampRaw: UInt32 + if preserveTimestamp { + wireTimestampRaw = existingMessage.timestamp + wireTimestamp = Date(timeIntervalSince1970: TimeInterval(wireTimestampRaw)) + } else { + wireTimestamp = Date() + wireTimestampRaw = UInt32(wireTimestamp.timeIntervalSince1970) + try await dataStore.updateMessageTimestamp(id: messageID, timestamp: wireTimestampRaw) } - // MARK: - Routing Change Detection - - /// Checks if contact routing changed and broadcasts `.routingChanged` if so. - /// - /// Called after sendMessageWithRetry to detect if routing switched - /// between direct and flood modes during the retry process. - private func checkAndNotifyRoutingChange( - publicKey: Data, - contactID: UUID, - radioID: UUID, - initialPathLength: UInt8 - ) async { + do { + let sentInfo = try await sendDirectMessageWithRetryLoop( + messageID: messageID, + contactID: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + text: existingMessage.text, + timestamp: wireTimestamp, + timestampRaw: wireTimestampRaw, + timeout: nil + ) + + // Bump only on a confirmed successful resend so sendCount counts + // landed user-initiated sends, not queue-driven attempts; + // bookkeeping failures log because the on-air retransmission + // already happened. + if isResend, sentInfo != nil { do { - // Fetch fresh contact state from device - guard let updatedContact = try await session.getContact(publicKey: publicKey) else { - logger.info("Contact not found in device contacts after retry") - return - } - - // Check if routing changed - let newPathLength = updatedContact.outPathLength - if newPathLength != initialPathLength { - logger.info("Routing changed for contact \(contactID): \(initialPathLength) -> \(newPathLength)") - - // Save updated contact to database - _ = try await dataStore.saveContact(radioID: radioID, from: updatedContact.toContactFrame()) - - // Notify UI of routing change - let isNowFlood = newPathLength == PacketBuilder.floodPathSentinel - statusEventBroadcaster.yield(.routingChanged(contactID: contactID, isFlood: isNowFlood)) - } + _ = try await dataStore.incrementMessageSendCount(id: messageID) } catch { - logger.warning("Failed to check routing change: \(error.localizedDescription)") + logger.warning("DM resend sendCount bookkeeping failed messageID=\(messageID): \(String(describing: error))") } + } + + let message = try await finalizeSend( + messageID: messageID, + contactID: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + sentInfo: sentInfo, + initialPathLength: initialPathLength + ) + + // Broadcast after the DB write so the downstream refetch sees both + // the bumped sendCount and the committed terminal status. + if isResend, sentInfo != nil { + statusEventBroadcaster.yield(.resent(messageID: messageID)) + } + + return message + } catch { + try await failMessageAndRethrow(error, messageID: messageID) } + } + + // MARK: - Direct Message Retry Loop + + /// Sends a direct message with app-layer retry logic and UI notifications. + /// + /// This function manages the retry loop at the app layer (instead of delegating to MeshCore) + /// to provide per-attempt UI feedback. On each attempt, it: + /// - Updates the message status in the database + /// - Broadcasts `.retrying` for UI retry progress + /// - Switches to flood routing after `floodAfter` failed attempts + /// - Broadcasts `.routingChanged` when routing switches + /// + /// - Parameters: + /// - messageID: The message ID for status updates + /// - contactID: The contact ID for routing change notifications + /// - radioID: The device ID for saving contact updates + /// - publicKey: The full 32-byte destination public key + /// - text: The message text + /// - timestamp: The message timestamp (must remain constant across retries) + /// - timestampRaw: The same timestamp as a `UInt32` epoch-seconds value. The + /// caller must pass the same integer it used to build `timestamp: Date` — + /// resampling inside the loop would break expected-ACK precomputation, + /// since the firmware hash is keyed off the exact `UInt32` that ends up + /// on the wire. + /// - timeout: Optional custom timeout per attempt (nil = use device suggested) + /// + /// - Returns: `MessageSentInfo` if ACK received, `nil` if all attempts exhausted + /// - Throws: `MeshCoreError` if send fails with unrecoverable error + private func sendDirectMessageWithRetryLoop( + messageID: UUID, + contactID: UUID, + radioID: UUID, + publicKey: Data, + text: String, + timestamp: Date, + timestampRaw: UInt32, + timeout: TimeInterval? + ) async throws -> MessageSentInfo? { + var attempts = 0 + var floodAttempts = 0 + var isFloodMode = false + + while attempts < config.maxAttempts, !isFloodMode || floodAttempts < config.maxFloodAttempts { + // Check for task cancellation + guard !Task.isCancelled else { + throw CancellationError() + } + + // Update database and notify UI of retry status (only after first attempt fails) + if attempts > 0 { + try await dataStore.updateMessageRetryStatus( + id: messageID, + status: .retrying, + retryAttempt: attempts - 1, + maxRetryAttempts: config.maxAttempts - 1 + ) + statusEventBroadcaster.yield(.retrying(messageID: messageID, attempt: attempts - 1, maxAttempts: config.maxAttempts - 1)) + } - // MARK: - Private Helpers - - /// Records a new expected-ACK code for a message awaiting delivery. - /// - /// On the first call for a given `messageID` this creates the entry; on - /// subsequent retry attempts it adds the new `ackCode` to the same entry's - /// `ackCodes` set and resets both `sentAt` and the timeout window to the - /// latest attempt so `checkExpiredAcks` does not fail the in-flight retry. - func trackPendingAck(messageID: UUID, contactID: UUID, ackCode: Data, timeout: TimeInterval) { - // Diagnostic: the firmware ACK CRC is derived from (timestamp, attempt, - // text, sender key) with no recipient, so identical text sent in the - // same second to different contacts produces the same code. Counting - // collisions across distinct in-flight messages measures how often a - // delivery confirmation could be attributed to the wrong message. - if let colliding = pendingAcks.first(where: { $0.key != messageID && $0.value.ackCodes.contains(ackCode) }) { - logger.warning("[ack-diag] ackCode collision: code=\(ackCode.uppercaseHexString()) shared by messages \(colliding.key) and \(messageID)") + // Switch to flood routing after floodAfter direct attempts + if attempts == config.floodAfter, !isFloodMode { + logger.info("Resetting path to flood after \(attempts) failed attempts") + do { + try await session.resetPath(publicKey: publicKey) + isFloodMode = true + + // Notify UI of routing change and save updated contact + if let updatedContact = try await session.getContact(publicKey: publicKey) { + _ = try await dataStore.saveContact(radioID: radioID, from: updatedContact.toContactFrame()) + } + statusEventBroadcaster.yield(.routingChanged(contactID: contactID, isFlood: true)) + } catch { + logger.warning("Failed to reset path: \(error.localizedDescription), continuing...") + // Continue anyway - device might handle it + isFloodMode = true } - if var existing = pendingAcks[messageID] { - existing.ackCodes.insert(ackCode) - existing.sentAt = Date() - existing.timeout = timeout - pendingAcks[messageID] = existing - } else { - pendingAcks[messageID] = PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: timeout - ) + } + + if attempts > 0 { + logger.info("Retry sending message: attempt \(attempts + 1)/\(config.maxAttempts)") + } + + // Precompute the expected ACK CRC before the send so the persistent + // ACK listener cannot race ahead of trackPendingAck on short direct links. + guard let senderPublicKey = await session.currentSelfInfo?.publicKey else { + throw MessageServiceError.notConnected + } + + let predictedAck = AckCodeBuilder.expectedAck( + timestamp: timestampRaw, + attempt: UInt8(attempts), + text: text, + senderPublicKey: senderPublicKey + ) + + // Pre-send floor must outlive one checkExpiredAcks tick; otherwise a + // BLE round-trip > 1s could let the checker expire the speculative + // entry before sendMessage returns and we overwrite the timeout with + // the authoritative sentInfo-derived value. + let preSendTimeout = max(timeout ?? config.minTimeout, checkInterval) + trackPendingAck( + messageID: messageID, + contactID: contactID, + ackCode: predictedAck, + timeout: preSendTimeout + ) + + let sentInfo: MessageSentInfo + do { + sentInfo = try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.directMessageTableFull, config: config.poolBackoff, logger: logger) { + try await session.sendMessage( + to: publicKey.prefix(6), + text: text, + timestamp: timestamp, + attempt: UInt8(attempts) + ) } + } catch { + // Drop the whole speculative entry on send failure. A partial + // remove would leave an empty-codes PendingAck visible to + // checkExpiredAcks if failMessageAndRethrow is ever bypassed. + pendingAcks.removeValue(forKey: messageID) + throw error + } + + // If the persistent ACK listener consumed our predicted ACK during + // sendMessage's cross-actor suspension, the entry is already + // removed or marked delivered. Short-circuit before waitForEvent + // so the retry loop doesn't clobber .delivered via + // updateMessageRetryStatus and broadcast a duplicate DM. + guard let tracked = pendingAcks[messageID], !tracked.isDelivered else { + return sentInfo + } + + let ackTimeout = timeout ?? max( + config.minTimeout, + Double(sentInfo.suggestedTimeoutMs) / 1000.0 * 1.2 + ) + + if sentInfo.expectedAck != predictedAck { + logger.warning( + "expectedAck mismatch for \(messageID) attempt \(attempts): predicted \(predictedAck.uppercaseHexString()) vs firmware \(sentInfo.expectedAck.uppercaseHexString()); merging firmware code" + ) + trackPendingAck( + messageID: messageID, + contactID: contactID, + ackCode: sentInfo.expectedAck, + timeout: ackTimeout + ) + } else { + // Re-stamp sentAt so checkExpiredAcks measures timeout from + // send-return, not from the speculative insert. + pendingAcks[messageID]?.timeout = ackTimeout + pendingAcks[messageID]?.sentAt = Date() + } + + let ackEvent = await session.waitForEvent( + filter: .acknowledgement(code: sentInfo.expectedAck), + timeout: ackTimeout + ) + + if ackEvent != nil { + logger.info("Message acknowledged on attempt \(attempts + 1)") + return sentInfo + } + + // Listener may have consumed the ACK between the pre-send guard + // and waitForEvent's subscription becoming active; re-check before + // retrying so we don't resend a DM the firmware already delivered. + if pendingAcks[messageID]?.isDelivered != false { + return sentInfo + } + + // ACK timeout - increment counters and retry + attempts += 1 + if isFloodMode { + floodAttempts += 1 + } } - private func validateDirectMessage(text: String, to contact: ContactDTO) throws { - guard contact.type != .repeater else { throw MessageServiceError.invalidRecipient } - guard text.utf8.count <= ProtocolLimits.maxDirectMessageLength else { throw MessageServiceError.messageTooLong } + logger.warning("Message delivery failed after \(attempts) attempts") + return nil + } + + // MARK: - Routing Change Detection + + /// Checks if contact routing changed and broadcasts `.routingChanged` if so. + /// + /// Called after sendMessageWithRetry to detect if routing switched + /// between direct and flood modes during the retry process. + private func checkAndNotifyRoutingChange( + publicKey: Data, + contactID: UUID, + radioID: UUID, + initialPathLength: UInt8 + ) async { + do { + // Fetch fresh contact state from device + guard let updatedContact = try await session.getContact(publicKey: publicKey) else { + logger.info("Contact not found in device contacts after retry") + return + } + + // Check if routing changed + let newPathLength = updatedContact.outPathLength + if newPathLength != initialPathLength { + logger.info("Routing changed for contact \(contactID): \(initialPathLength) -> \(newPathLength)") + + // Save updated contact to database + _ = try await dataStore.saveContact(radioID: radioID, from: updatedContact.toContactFrame()) + + // Notify UI of routing change + let isNowFlood = newPathLength == PacketBuilder.floodPathSentinel + statusEventBroadcaster.yield(.routingChanged(contactID: contactID, isFlood: isNowFlood)) + } + } catch { + logger.warning("Failed to check routing change: \(error.localizedDescription)") } - - func finalizeSend( - messageID: UUID, - contactID: UUID, - radioID: UUID, - publicKey: Data, - sentInfo: MessageSentInfo?, - initialPathLength: UInt8 - ) async throws -> MessageDTO { - // Peek the pendingAcks entry without taking ownership. A missing entry - // means `handleAcknowledgement` already processed the ACK (it removes - // the entry on delivery) or `checkExpiredAcks` already failed and - // removed it; an `isDelivered == true` entry means the listener marked - // it mid-flight. In those cases the owner holds the terminal write - // (including `roundTripTime`) and we drop any residual entry and skip - // the DB update here. - let tracking = pendingAcks[messageID] - - if tracking == nil || tracking?.isDelivered == true { - pendingAcks.removeValue(forKey: messageID) - } else if let sentInfo { - // The retry loop's waitForEvent matched the ACK. Take ownership and - // record the legitimate `.sent` -> `.delivered` upgrade. - pendingAcks.removeValue(forKey: messageID) - try await dataStore.updateMessageAck( - id: messageID, - ackCode: sentInfo.expectedAck.ackCodeUInt32, - status: .delivered - ) - try await dataStore.updateContactLastMessage(contactID: contactID, date: Date()) - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .delivered, roundTripTime: nil)) - } else { - // Retry budget spent without an ACK. Neither fail the row nor remove - // the entry: `checkExpiredAcks` owns the give-up at - // max(ackGiveUpWindow, last attempt's ACK timeout), so a - // late-but-legitimate ACK can still upgrade the row. - // `clearRetryingToSent` rolls any `.retrying` status back to `.sent` - // and is terminal-safe: it no-ops if the checker already failed the - // row in the loop's await-gap, so the row stays `.failed` there. - let movedToSent = try await dataStore.clearRetryingToSent(id: messageID) - if movedToSent { - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) - } - } - await checkAndNotifyRoutingChange( - publicKey: publicKey, - contactID: contactID, - radioID: radioID, - initialPathLength: initialPathLength - ) - guard let message = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Failed to fetch message") - } - return message + } + + // MARK: - Private Helpers + + /// Records a new expected-ACK code for a message awaiting delivery. + /// + /// On the first call for a given `messageID` this creates the entry; on + /// subsequent retry attempts it adds the new `ackCode` to the same entry's + /// `ackCodes` set and resets both `sentAt` and the timeout window to the + /// latest attempt so `checkExpiredAcks` does not fail the in-flight retry. + func trackPendingAck(messageID: UUID, contactID: UUID, ackCode: Data, timeout: TimeInterval) { + // Diagnostic: the firmware ACK CRC is derived from (timestamp, attempt, + // text, sender key) with no recipient, so identical text sent in the + // same second to different contacts produces the same code. Counting + // collisions across distinct in-flight messages measures how often a + // delivery confirmation could be attributed to the wrong message. + if let colliding = pendingAcks.first(where: { $0.key != messageID && $0.value.ackCodes.contains(ackCode) }) { + logger.warning("[ack-diag] ackCode collision: code=\(ackCode.uppercaseHexString()) shared by messages \(colliding.key) and \(messageID)") } - - private func createOutgoingMessage( - id: UUID, - radioID: UUID, - contactID: UUID, - text: String, - timestamp: UInt32, - textType: TextType, - replyToID: UUID? - ) -> MessageDTO { - let message = Message( - id: id, - radioID: radioID, - contactID: contactID, - text: text, - timestamp: timestamp, - directionRawValue: MessageDirection.outgoing.rawValue, - statusRawValue: MessageStatus.pending.rawValue, - textTypeRawValue: textType.rawValue, - replyToID: replyToID - ) - return MessageDTO(from: message) + if var existing = pendingAcks[messageID] { + existing.ackCodes.insert(ackCode) + existing.sentAt = Date() + existing.timeout = timeout + pendingAcks[messageID] = existing + } else { + pendingAcks[messageID] = PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: timeout + ) + } + } + + private func validateDirectMessage(text: String, to contact: ContactDTO) throws { + guard contact.type != .repeater else { throw MessageServiceError.invalidRecipient } + guard text.utf8.count <= ProtocolLimits.maxDirectMessageLength else { throw MessageServiceError.messageTooLong } + } + + func finalizeSend( + messageID: UUID, + contactID: UUID, + radioID: UUID, + publicKey: Data, + sentInfo: MessageSentInfo?, + initialPathLength: UInt8 + ) async throws -> MessageDTO { + // Peek the pendingAcks entry without taking ownership. A missing entry + // means `handleAcknowledgement` already processed the ACK (it removes + // the entry on delivery) or `checkExpiredAcks` already failed and + // removed it; an `isDelivered == true` entry means the listener marked + // it mid-flight. In those cases the owner holds the terminal write + // (including `roundTripTime`) and we drop any residual entry and skip + // the DB update here. + let tracking = pendingAcks[messageID] + + if tracking == nil || tracking?.isDelivered == true { + pendingAcks.removeValue(forKey: messageID) + } else if let sentInfo { + // The retry loop's waitForEvent matched the ACK. Take ownership and + // record the legitimate `.sent` -> `.delivered` upgrade. + pendingAcks.removeValue(forKey: messageID) + try await dataStore.updateMessageAck( + id: messageID, + ackCode: sentInfo.expectedAck.ackCodeUInt32, + status: .delivered + ) + try await dataStore.updateContactLastMessage(contactID: contactID, date: Date()) + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .delivered, roundTripTime: nil)) + } else { + // Retry budget spent without an ACK. Neither fail the row nor remove + // the entry: `checkExpiredAcks` owns the give-up at + // max(ackGiveUpWindow, last attempt's ACK timeout), so a + // late-but-legitimate ACK can still upgrade the row. + // `clearRetryingToSent` rolls any `.retrying` status back to `.sent` + // and is terminal-safe: it no-ops if the checker already failed the + // row in the loop's await-gap, so the row stays `.failed` there. + let movedToSent = try await dataStore.clearRetryingToSent(id: messageID) + if movedToSent { + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) + } + } + await checkAndNotifyRoutingChange( + publicKey: publicKey, + contactID: contactID, + radioID: radioID, + initialPathLength: initialPathLength + ) + guard let message = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Failed to fetch message") } + return message + } + + private func createOutgoingMessage( + id: UUID, + radioID: UUID, + contactID: UUID, + text: String, + timestamp: UInt32, + textType: TextType, + replyToID: UUID? + ) -> MessageDTO { + let message = Message( + id: id, + radioID: radioID, + contactID: contactID, + text: text, + timestamp: timestamp, + directionRawValue: MessageDirection.outgoing.rawValue, + statusRawValue: MessageStatus.pending.rawValue, + textTypeRawValue: textType.rawValue, + replyToID: replyToID + ) + return MessageDTO(from: message) + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService+SendHelpers.swift b/MC1Services/Sources/MC1Services/Services/MessageService+SendHelpers.swift index 5431d1e0..6a328b7e 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService+SendHelpers.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService+SendHelpers.swift @@ -4,9 +4,9 @@ import MeshCore private let millisecondsPerSecond: Int = 1000 private func poolBackoffDelay(forInLoopAttempt attempt: Int, config: PoolBackoffConfig) -> Duration { - let base = config.baseDelay * pow(config.exponentBase, Double(attempt)) - let jittered = base * Double.random(in: config.jitterRange) - return .milliseconds(Int(jittered * Double(millisecondsPerSecond))) + let base = config.baseDelay * pow(config.exponentBase, Double(attempt)) + let jittered = base * Double.random(in: config.jitterRange) + return .milliseconds(Int(jittered * Double(millisecondsPerSecond))) } /// In-loop absorption of short pool-exhaustion bursts. The firmware briefly @@ -14,46 +14,45 @@ private func poolBackoffDelay(forInLoopAttempt attempt: Int, config: PoolBackoff /// helper sleeps according to `config` between attempts (default 500ms / 1s / /// 2s with +/- 20% jitter) and re-throws once `config.attemptCap` is reached. func withPoolBackoff( - transientCode: UInt8, - config: PoolBackoffConfig, - logger: PersistentLogger, - operation: sending () async throws -> T + transientCode: UInt8, + config: PoolBackoffConfig, + logger: PersistentLogger, + operation: sending () async throws -> T ) async throws -> sending T { - var attempt = 0 - while true { - do { - return try await operation() - } catch let error as MeshCoreError { - guard case .deviceError(let code) = error, code == transientCode else { - throw error - } - guard attempt < config.attemptCap else { - logger.warning("Pool backoff exhausted after \(attempt) attempts; re-throwing transient deviceError(\(code))") - throw error - } - try await Task.sleep(for: poolBackoffDelay(forInLoopAttempt: attempt, config: config)) - attempt += 1 - } + var attempt = 0 + while true { + do { + return try await operation() + } catch let error as MeshCoreError { + guard case let .deviceError(code) = error, code == transientCode else { + throw error + } + guard attempt < config.attemptCap else { + logger.warning("Pool backoff exhausted after \(attempt) attempts; re-throwing transient deviceError(\(code))") + throw error + } + try await Task.sleep(for: poolBackoffDelay(forInLoopAttempt: attempt, config: config)) + attempt += 1 } + } } extension MessageService { - - func failMessageAndRethrow(_ error: Error, messageID: UUID) async throws -> Never { - pendingAcks.removeValue(forKey: messageID) - // Persistence layer absorbs `.delivered`: if the listener won the race - // before the throw path runs, the row stays delivered. Same invariant as - // updateMessageRetryStatus and updateMessageAck. The Bool return is - // intentionally discarded; the caller observes the failure via the - // rethrown error. The caller is responsible for broadcasting the - // `.failed` event: inline non-queue catch sites yield it one line - // above this call; queue-routed catch sites delegate to the queue's - // outer catch, which calls `notifyMessageFailed` exactly once on any - // non-`CancellationError` escape. - _ = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) - if let meshError = error as? MeshCoreError { - throw MessageServiceError.sessionError(meshError) - } - throw error + func failMessageAndRethrow(_ error: Error, messageID: UUID) async throws -> Never { + pendingAcks.removeValue(forKey: messageID) + // Persistence layer absorbs `.delivered`: if the listener won the race + // before the throw path runs, the row stays delivered. Same invariant as + // updateMessageRetryStatus and updateMessageAck. The Bool return is + // intentionally discarded; the caller observes the failure via the + // rethrown error. The caller is responsible for broadcasting the + // `.failed` event: inline non-queue catch sites yield it one line + // above this call; queue-routed catch sites delegate to the queue's + // outer catch, which calls `notifyMessageFailed` exactly once on any + // non-`CancellationError` escape. + _ = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) + if let meshError = error as? MeshCoreError { + throw MessageServiceError.sessionError(meshError) } + throw error + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService.swift b/MC1Services/Sources/MC1Services/Services/MessageService.swift index 60fdff77..b287bd65 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService.swift @@ -34,196 +34,197 @@ import MeshCore /// /// Call `startEventMonitoring()` to begin processing ACKs from the session. public actor MessageService { - - // MARK: - Properties - - let logger = PersistentLogger(subsystem: "com.mc1", category: "MessageService") - - let session: any MeshCoreSessionProtocol - let dataStore: any PersistenceStoreProtocol - let config: MessageServiceConfig - - /// Contact service for path management (optional - retry with reset requires this). - /// Injected by `ServiceContainer` at construction. - private let contactService: ContactService? - - /// In-flight messages awaiting ACK, keyed by messageID. - /// - /// One entry per message. Each entry carries the full set of expected-ACK - /// CRCs accumulated across retry attempts, so a late ACK from any attempt - /// can still mark the message delivered. - var pendingAcks: [UUID: PendingAck] = [:] - - /// Multicast broadcaster for outbound-message lifecycle events. - /// - /// Every yield happens synchronously at its lifecycle site on this actor, - /// so yield order equals consumption order and multi-ACK bursts reach - /// subscribers in the order the actor processed them. - nonisolated let statusEventBroadcaster = EventBroadcaster() - - /// Task for periodic ACK expiry checking - var ackCheckTask: Task? - - /// Task for listening to session events - private var eventListenerTask: Task? - - /// Interval between ACK expiry checks (in seconds) - var checkInterval: TimeInterval = 5.0 - - /// Tracks message IDs currently being retried to prevent concurrent retry attempts - var inFlightRetries: Set = [] - - // MARK: - Initialization - - /// Creates a new message service. - /// - /// - Parameters: - /// - session: The MeshCore session for sending messages - /// - dataStore: The persistence store for saving messages - /// - contactService: Contact service for path management during retry - /// - config: Configuration for retry and routing behavior (defaults to `.default`) - init( - session: any MeshCoreSessionProtocol, - dataStore: any PersistenceStoreProtocol, - contactService: ContactService?, - config: MessageServiceConfig = .default - ) { - self.session = session - self.dataStore = dataStore - self.contactService = contactService - self.config = config - } - - /// Whether a contact service was injected at construction. - var hasContactServiceWired: Bool { contactService != nil } - - // MARK: - Event Listening - - /// Starts the session ACK event listener. - /// - /// Subscribes to `.anyAcknowledgement` events on the session and routes - /// each one through `handleAcknowledgement`. Call after the connection is - /// established; without this the listener never runs and pending DMs stay - /// `.sent` even when ACKs arrive. - /// - /// # Lifecycle scope - /// - /// This method's counterpart is `stopEventMonitoring()`. It does **not** - /// start the periodic ACK expiry checker — that's `startAckExpiryChecking()`, - /// which has its own `stopAckExpiryChecking()` / `stopAndFailAllPending()` - /// counterparts. `ServiceContainer` pairs both lifecycles together; direct - /// callers must do the same. - public func startEventMonitoring() { - eventListenerTask?.cancel() - - eventListenerTask = Task { [weak self] in - guard let self else { return } - - for await event in await session.events(filter: .anyAcknowledgement) { - guard !Task.isCancelled else { break } - - if case .acknowledgement(let code, let tripTime) = event { - await handleAcknowledgement(code: code, tripTime: tripTime) - } - } + // MARK: - Properties + + let logger = PersistentLogger(subsystem: "com.mc1", category: "MessageService") + + let session: any MeshCoreSessionProtocol + let dataStore: any PersistenceStoreProtocol + let config: MessageServiceConfig + + /// Contact service for path management (optional - retry with reset requires this). + /// Injected by `ServiceContainer` at construction. + private let contactService: ContactService? + + /// In-flight messages awaiting ACK, keyed by messageID. + /// + /// One entry per message. Each entry carries the full set of expected-ACK + /// CRCs accumulated across retry attempts, so a late ACK from any attempt + /// can still mark the message delivered. + var pendingAcks: [UUID: PendingAck] = [:] + + /// Multicast broadcaster for outbound-message lifecycle events. + /// + /// Every yield happens synchronously at its lifecycle site on this actor, + /// so yield order equals consumption order and multi-ACK bursts reach + /// subscribers in the order the actor processed them. + nonisolated let statusEventBroadcaster = EventBroadcaster() + + /// Task for periodic ACK expiry checking + var ackCheckTask: Task? + + /// Task for listening to session events + private var eventListenerTask: Task? + + /// Interval between ACK expiry checks (in seconds) + var checkInterval: TimeInterval = 5.0 + + /// Tracks message IDs currently being retried to prevent concurrent retry attempts + var inFlightRetries: Set = [] + + // MARK: - Initialization + + /// Creates a new message service. + /// + /// - Parameters: + /// - session: The MeshCore session for sending messages + /// - dataStore: The persistence store for saving messages + /// - contactService: Contact service for path management during retry + /// - config: Configuration for retry and routing behavior (defaults to `.default`) + init( + session: any MeshCoreSessionProtocol, + dataStore: any PersistenceStoreProtocol, + contactService: ContactService?, + config: MessageServiceConfig = .default + ) { + self.session = session + self.dataStore = dataStore + self.contactService = contactService + self.config = config + } + + /// Whether a contact service was injected at construction. + var hasContactServiceWired: Bool { + contactService != nil + } + + // MARK: - Event Listening + + /// Starts the session ACK event listener. + /// + /// Subscribes to `.anyAcknowledgement` events on the session and routes + /// each one through `handleAcknowledgement`. Call after the connection is + /// established; without this the listener never runs and pending DMs stay + /// `.sent` even when ACKs arrive. + /// + /// # Lifecycle scope + /// + /// This method's counterpart is `stopEventMonitoring()`. It does **not** + /// start the periodic ACK expiry checker — that's `startAckExpiryChecking()`, + /// which has its own `stopAckExpiryChecking()` / `stopAndFailAllPending()` + /// counterparts. `ServiceContainer` pairs both lifecycles together; direct + /// callers must do the same. + public func startEventMonitoring() { + eventListenerTask?.cancel() + + eventListenerTask = Task { [weak self] in + guard let self else { return } + + for await event in await session.events(filter: .anyAcknowledgement) { + guard !Task.isCancelled else { break } + + if case let .acknowledgement(code, tripTime) = event { + await handleAcknowledgement(code: code, tripTime: tripTime) } + } } - - /// Stops the session ACK event listener. - /// - /// Cancels `eventListenerTask` only. Does **not** cancel the periodic ACK - /// expiry checker — for that, call `stopAckExpiryChecking()` (just stop - /// the checker, used on a routine disconnect) or `stopAndFailAllPending()` - /// (stop the checker and fail every in-flight DM, for explicit full teardown). - public func stopEventMonitoring() { - eventListenerTask?.cancel() - eventListenerTask = nil - } - - // MARK: - ACK Handling - - /// Processes an acknowledgement from the session event stream. - /// - /// Finds the in-flight message whose accumulated `ackCodes` contains this - /// CRC, marks it delivered, writes the delivery status + round-trip time - /// to the database, and removes the entry. This is the sole DB writer for - /// delivered status on the late-ACK path. - func handleAcknowledgement(code: Data, tripTime: UInt32?) async { - guard let (messageID, tracking) = pendingAcks.first(where: { - $0.value.ackCodes.contains(code) && !$0.value.isDelivered - }) else { - // Diagnostic: the firmware delivered an end-to-end ACK we have no - // live entry for. This is either a genuinely late ACK that arrived - // after the message was already failed and removed, or a duplicate - // for an already-delivered message. `orphanSentRow` flags a `.sent` - // DM that lost its pending entry to a teardown and would otherwise - // show "Sent" forever; its field rate informs whether a - // reconnect-time orphan sweep is worth adding. - let orphanSentRow = (try? await dataStore.hasOutgoingSentDM(ackCode: code.ackCodeUInt32)) ?? false - logger.warning("[ack-diag] unmatched ACK code=\(code.uppercaseHexString()) livePending=\(pendingAcks.count) orphanSentRow=\(orphanSentRow) (late post-teardown or duplicate)") - return - } - - pendingAcks[messageID]?.isDelivered = true - - // Persist `tripTime` only when firmware supplied it. Date()-based - // fallbacks against `tracking.sentAt` would be wrong in either - // direction once retries reset the timestamp, so we prefer a nil - // `roundTripTime` over a fabricated value. - let ackCodeUInt32 = code.ackCodeUInt32 - - do { - try await dataStore.updateMessageAck( - id: messageID, - ackCode: ackCodeUInt32, - status: .delivered, - roundTripTime: tripTime - ) - } catch { - logger.error("Failed to write delivered status: \(error.localizedDescription)") - } - - do { - try await dataStore.updateContactLastMessage(contactID: tracking.contactID, date: Date()) - } catch { - logger.error("Failed to update contact lastMessageDate: \(error.localizedDescription)") - } - - pendingAcks.removeValue(forKey: messageID) - - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .delivered, roundTripTime: tripTime)) - - // Diagnostic: how long the ACK took relative to the last send attempt - // (sentAt is re-stamped per retry), the firmware-reported round trip, - // and how many other entries were in flight. Distinguishes late-but- - // arriving ACKs from never-arriving ones and measures table pressure. - let ackDelta = Date().timeIntervalSince(tracking.sentAt) - logger.info("[ack-diag] ACK matched deltaSinceLastSend=\(String(format: "%.2f", ackDelta))s firmwareTrip=\(tripTime.map { "\($0)ms" } ?? "nil") livePending=\(pendingAcks.count)") + } + + /// Stops the session ACK event listener. + /// + /// Cancels `eventListenerTask` only. Does **not** cancel the periodic ACK + /// expiry checker — for that, call `stopAckExpiryChecking()` (just stop + /// the checker, used on a routine disconnect) or `stopAndFailAllPending()` + /// (stop the checker and fail every in-flight DM, for explicit full teardown). + public func stopEventMonitoring() { + eventListenerTask?.cancel() + eventListenerTask = nil + } + + // MARK: - ACK Handling + + /// Processes an acknowledgement from the session event stream. + /// + /// Finds the in-flight message whose accumulated `ackCodes` contains this + /// CRC, marks it delivered, writes the delivery status + round-trip time + /// to the database, and removes the entry. This is the sole DB writer for + /// delivered status on the late-ACK path. + func handleAcknowledgement(code: Data, tripTime: UInt32?) async { + guard let (messageID, tracking) = pendingAcks.first(where: { + $0.value.ackCodes.contains(code) && !$0.value.isDelivered + }) else { + // Diagnostic: the firmware delivered an end-to-end ACK we have no + // live entry for. This is either a genuinely late ACK that arrived + // after the message was already failed and removed, or a duplicate + // for an already-delivered message. `orphanSentRow` flags a `.sent` + // DM that lost its pending entry to a teardown and would otherwise + // show "Sent" forever; its field rate informs whether a + // reconnect-time orphan sweep is worth adding. + let orphanSentRow = await (try? dataStore.hasOutgoingSentDM(ackCode: code.ackCodeUInt32)) ?? false + logger.warning("[ack-diag] unmatched ACK code=\(code.uppercaseHexString()) livePending=\(pendingAcks.count) orphanSentRow=\(orphanSentRow) (late post-teardown or duplicate)") + return } - // MARK: - Status Events - - /// Returns a fresh stream of outbound-message lifecycle events. - /// Registration is synchronous, so events yielded after this call are - /// never dropped. Consumers must re-subscribe per connection because the - /// owning `ServiceContainer` is rebuilt on every connection. - public nonisolated func statusEvents() -> AsyncStream { - statusEventBroadcaster.subscribe() + pendingAcks[messageID]?.isDelivered = true + + // Persist `tripTime` only when firmware supplied it. Date()-based + // fallbacks against `tracking.sentAt` would be wrong in either + // direction once retries reset the timestamp, so we prefer a nil + // `roundTripTime` over a fabricated value. + let ackCodeUInt32 = code.ackCodeUInt32 + + do { + try await dataStore.updateMessageAck( + id: messageID, + ackCode: ackCodeUInt32, + status: .delivered, + roundTripTime: tripTime + ) + } catch { + logger.error("Failed to write delivered status: \(error.localizedDescription)") } - /// Ends every `statusEvents()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishStatusEvents() { - statusEventBroadcaster.finish() + do { + try await dataStore.updateContactLastMessage(contactID: tracking.contactID, date: Date()) + } catch { + logger.error("Failed to update contact lastMessageDate: \(error.localizedDescription)") } - /// Broadcasts `.failed` for a message from outside the actor. Sole - /// purpose: the queue-side terminal catch in `ChatSendQueueService` runs - /// off-actor and needs to surface the failure. Do not grow other callers; - /// the inline send paths yield the event directly because they own the - /// catch. - public func notifyMessageFailed(messageID: UUID) async { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - } + pendingAcks.removeValue(forKey: messageID) + + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .delivered, roundTripTime: tripTime)) + + // Diagnostic: how long the ACK took relative to the last send attempt + // (sentAt is re-stamped per retry), the firmware-reported round trip, + // and how many other entries were in flight. Distinguishes late-but- + // arriving ACKs from never-arriving ones and measures table pressure. + let ackDelta = Date().timeIntervalSince(tracking.sentAt) + logger.info("[ack-diag] ACK matched deltaSinceLastSend=\(String(format: "%.2f", ackDelta))s firmwareTrip=\(tripTime.map { "\($0)ms" } ?? "nil") livePending=\(pendingAcks.count)") + } + + // MARK: - Status Events + + /// Returns a fresh stream of outbound-message lifecycle events. + /// Registration is synchronous, so events yielded after this call are + /// never dropped. Consumers must re-subscribe per connection because the + /// owning `ServiceContainer` is rebuilt on every connection. + public nonisolated func statusEvents() -> AsyncStream { + statusEventBroadcaster.subscribe() + } + + /// Ends every `statusEvents()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishStatusEvents() { + statusEventBroadcaster.finish() + } + + /// Broadcasts `.failed` for a message from outside the actor. Sole + /// purpose: the queue-side terminal catch in `ChatSendQueueService` runs + /// off-actor and needs to surface the failure. Do not grow other callers; + /// the inline send paths yield the event directly because they own the + /// catch. + public func notifyMessageFailed(messageID: UUID) async { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageServiceConfig.swift b/MC1Services/Sources/MC1Services/Services/MessageServiceConfig.swift index 2f62a9d0..ebfbaa00 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageServiceConfig.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageServiceConfig.swift @@ -2,92 +2,92 @@ import Foundation /// Tuning for the `withPoolBackoff` helper that absorbs short bursts of /// firmware pool-exhaustion errors before parking the envelope. -struct PoolBackoffConfig: Sendable { - /// Maximum in-loop retries before re-throwing the transient `deviceError`. - let attemptCap: Int - - /// Delay for the first in-loop retry (multiplied by `exponentBase` for - /// subsequent attempts, then by a value sampled from `jitterRange`). - let baseDelay: TimeInterval - - /// Exponential growth factor applied to `baseDelay` per attempt. - let exponentBase: Double - - /// Multiplicative jitter envelope sampled per retry. - let jitterRange: ClosedRange - - init( - attemptCap: Int = 3, - baseDelay: TimeInterval = 0.5, - exponentBase: Double = 2.0, - jitterRange: ClosedRange = 0.8...1.2 - ) { - self.attemptCap = attemptCap - self.baseDelay = baseDelay - self.exponentBase = exponentBase - self.jitterRange = jitterRange - } - - static let `default` = PoolBackoffConfig() +struct PoolBackoffConfig { + /// Maximum in-loop retries before re-throwing the transient `deviceError`. + let attemptCap: Int + + /// Delay for the first in-loop retry (multiplied by `exponentBase` for + /// subsequent attempts, then by a value sampled from `jitterRange`). + let baseDelay: TimeInterval + + /// Exponential growth factor applied to `baseDelay` per attempt. + let exponentBase: Double + + /// Multiplicative jitter envelope sampled per retry. + let jitterRange: ClosedRange + + init( + attemptCap: Int = 3, + baseDelay: TimeInterval = 0.5, + exponentBase: Double = 2.0, + jitterRange: ClosedRange = 0.8...1.2 + ) { + self.attemptCap = attemptCap + self.baseDelay = baseDelay + self.exponentBase = exponentBase + self.jitterRange = jitterRange + } + + static let `default` = PoolBackoffConfig() } /// Configuration for message retry and routing behavior. /// /// Controls how the message service handles delivery failures and routing fallback. -struct MessageServiceConfig: Sendable { - /// Whether to use flood routing when user manually retries a failed message - let floodFallbackOnRetry: Bool - - /// Maximum total send attempts for automatic retry - let maxAttempts: Int - - /// Maximum attempts to make after switching to flood routing - let maxFloodAttempts: Int - - /// Number of direct attempts before switching to flood routing - let floodAfter: Int - - /// Minimum timeout in seconds (floor for device-suggested timeout) - let minTimeout: TimeInterval - - /// Whether to trigger path discovery after successful flood delivery - let triggerPathDiscoveryAfterFlood: Bool - - /// Floor and post-loop grace for the give-up deadline on fast presets. - /// - /// The effective deadline for each pending entry is `max(ackGiveUpWindow, - /// PendingAck.timeout)`, so on slow high-spreading-factor presets the - /// deadline follows the attempt's real round-trip and the checker never - /// fires mid-attempt. On fast presets where `est_timeout` is small this - /// value is the binding deadline, set large enough for a late ACK to still - /// reconcile yet short enough to keep the silent "sending" tail brief. - let ackGiveUpWindow: TimeInterval - - /// Tuning for the in-loop pool-exhaustion backoff (`withPoolBackoff`). - let poolBackoff: PoolBackoffConfig - - init( - floodFallbackOnRetry: Bool = true, - maxAttempts: Int = 5, - maxFloodAttempts: Int = 1, - floodAfter: Int = 4, - minTimeout: TimeInterval = 0, - triggerPathDiscoveryAfterFlood: Bool = true, - ackGiveUpWindow: TimeInterval = 30, - poolBackoff: PoolBackoffConfig = .default - ) { - // 5 = 4 direct + 1 flood. AckCodeBuilder.expectedAck documents why attempt - // indices through 4 stay ACK-unambiguous; past that the cap bounds airtime. - precondition(maxAttempts <= 5, "maxAttempts must be <= 5 (4 direct + 1 flood)") - self.floodFallbackOnRetry = floodFallbackOnRetry - self.maxAttempts = maxAttempts - self.maxFloodAttempts = maxFloodAttempts - self.floodAfter = floodAfter - self.minTimeout = minTimeout - self.triggerPathDiscoveryAfterFlood = triggerPathDiscoveryAfterFlood - self.ackGiveUpWindow = ackGiveUpWindow - self.poolBackoff = poolBackoff - } - - static let `default` = MessageServiceConfig() +struct MessageServiceConfig { + /// Whether to use flood routing when user manually retries a failed message + let floodFallbackOnRetry: Bool + + /// Maximum total send attempts for automatic retry + let maxAttempts: Int + + /// Maximum attempts to make after switching to flood routing + let maxFloodAttempts: Int + + /// Number of direct attempts before switching to flood routing + let floodAfter: Int + + /// Minimum timeout in seconds (floor for device-suggested timeout) + let minTimeout: TimeInterval + + /// Whether to trigger path discovery after successful flood delivery + let triggerPathDiscoveryAfterFlood: Bool + + /// Floor and post-loop grace for the give-up deadline on fast presets. + /// + /// The effective deadline for each pending entry is `max(ackGiveUpWindow, + /// PendingAck.timeout)`, so on slow high-spreading-factor presets the + /// deadline follows the attempt's real round-trip and the checker never + /// fires mid-attempt. On fast presets where `est_timeout` is small this + /// value is the binding deadline, set large enough for a late ACK to still + /// reconcile yet short enough to keep the silent "sending" tail brief. + let ackGiveUpWindow: TimeInterval + + /// Tuning for the in-loop pool-exhaustion backoff (`withPoolBackoff`). + let poolBackoff: PoolBackoffConfig + + init( + floodFallbackOnRetry: Bool = true, + maxAttempts: Int = 5, + maxFloodAttempts: Int = 1, + floodAfter: Int = 4, + minTimeout: TimeInterval = 0, + triggerPathDiscoveryAfterFlood: Bool = true, + ackGiveUpWindow: TimeInterval = 30, + poolBackoff: PoolBackoffConfig = .default + ) { + // 5 = 4 direct + 1 flood. AckCodeBuilder.expectedAck documents why attempt + // indices through 4 stay ACK-unambiguous; past that the cap bounds airtime. + precondition(maxAttempts <= 5, "maxAttempts must be <= 5 (4 direct + 1 flood)") + self.floodFallbackOnRetry = floodFallbackOnRetry + self.maxAttempts = maxAttempts + self.maxFloodAttempts = maxFloodAttempts + self.floodAfter = floodAfter + self.minTimeout = minTimeout + self.triggerPathDiscoveryAfterFlood = triggerPathDiscoveryAfterFlood + self.ackGiveUpWindow = ackGiveUpWindow + self.poolBackoff = poolBackoff + } + + static let `default` = MessageServiceConfig() } diff --git a/MC1Services/Sources/MC1Services/Services/MessageServiceError.swift b/MC1Services/Sources/MC1Services/Services/MessageServiceError.swift index a46a1b9c..d3f63552 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageServiceError.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageServiceError.swift @@ -3,32 +3,32 @@ import MeshCore /// Errors that can occur during message operations. public enum MessageServiceError: Error, Sendable { - /// Not connected to a device - case notConnected - /// Contact not found in database - case contactNotFound - /// Channel not found in database - case channelNotFound - /// Message send operation failed - case sendFailed(String) - /// Attempted to send message to invalid recipient (e.g., repeater) - case invalidRecipient - /// Message text exceeds maximum allowed length - case messageTooLong - /// Underlying MeshCore session error - case sessionError(MeshCoreError) + /// Not connected to a device + case notConnected + /// Contact not found in database + case contactNotFound + /// Channel not found in database + case channelNotFound + /// Message send operation failed + case sendFailed(String) + /// Attempted to send message to invalid recipient (e.g., repeater) + case invalidRecipient + /// Message text exceeds maximum allowed length + case messageTooLong + /// Underlying MeshCore session error + case sessionError(MeshCoreError) } extension MessageServiceError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .contactNotFound: "Contact not found." - case .channelNotFound: "Channel not found." - case .sendFailed(let msg): "Send failed: \(msg)" - case .invalidRecipient: "Cannot send messages to this recipient." - case .messageTooLong: "Message exceeds the maximum allowed length." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .contactNotFound: "Contact not found." + case .channelNotFound: "Channel not found." + case let .sendFailed(msg): "Send failed: \(msg)" + case .invalidRecipient: "Cannot send messages to this recipient." + case .messageTooLong: "Message exceeds the maximum allowed length." + case let .sessionError(e): e.localizedDescription } + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageStatusEvent.swift b/MC1Services/Sources/MC1Services/Services/MessageStatusEvent.swift index b77ae3e0..6d899dbd 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageStatusEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageStatusEvent.swift @@ -8,21 +8,21 @@ import Foundation /// raw ackCode so consumers can gate on conversation membership without /// re-walking the service's pending-ACK table. public enum MessageStatusEvent: Sendable { - /// A message reached a resolved status: `.sent` once the radio queues a - /// DM or channel broadcast, `.delivered` once a DM's end-to-end ACK - /// arrives. Channel broadcasts have no recipient ACK, so `.sent` is their - /// terminal success state. `roundTripTime` is the firmware-reported value - /// when supplied, riding along so the UI can fold status and timing into - /// one in-place bubble update without re-reading the DTO. - case statusResolved(messageID: UUID, status: MessageStatus, roundTripTime: UInt32?) - /// A resend completed with `.sent` committed. Carries no status payload - /// because resends also mutate `heardRepeats` and `sendCount`; consumers - /// must refresh the entire DTO rather than apply a status-only update. - case resent(messageID: UUID) - /// A retry attempt is in flight; use for UI retry progress. - case retrying(messageID: UUID, attempt: Int, maxAttempts: Int) - /// A contact's routing switched between direct and flood during a send. - case routingChanged(contactID: UUID, isFlood: Bool) - /// A message failed after exhausting retries or a terminal send error. - case failed(messageID: UUID) + /// A message reached a resolved status: `.sent` once the radio queues a + /// DM or channel broadcast, `.delivered` once a DM's end-to-end ACK + /// arrives. Channel broadcasts have no recipient ACK, so `.sent` is their + /// terminal success state. `roundTripTime` is the firmware-reported value + /// when supplied, riding along so the UI can fold status and timing into + /// one in-place bubble update without re-reading the DTO. + case statusResolved(messageID: UUID, status: MessageStatus, roundTripTime: UInt32?) + /// A resend completed with `.sent` committed. Carries no status payload + /// because resends also mutate `heardRepeats` and `sendCount`; consumers + /// must refresh the entire DTO rather than apply a status-only update. + case resent(messageID: UUID) + /// A retry attempt is in flight; use for UI retry progress. + case retrying(messageID: UUID, attempt: Int, maxAttempts: Int) + /// A contact's routing switched between direct and flood during a send. + case routingChanged(contactID: UUID, isFlood: Bool) + /// A message failed after exhausting retries or a terminal send error. + case failed(messageID: UUID) } diff --git a/MC1Services/Sources/MC1Services/Services/NodeConfigImportPlanner.swift b/MC1Services/Sources/MC1Services/Services/NodeConfigImportPlanner.swift index 78be4602..d84fafcc 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeConfigImportPlanner.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeConfigImportPlanner.swift @@ -6,11 +6,11 @@ import MeshCore /// A snapshot of one channel slot read from the device during the import read phase. /// Consumed by ``planConfigImport`` /// so slot planning stays a pure, testable function. -struct DeviceChannelSlot: Sendable, Equatable { - let index: UInt8 - let name: String - let secret: Data - let isConfigured: Bool +struct DeviceChannelSlot: Equatable { + let index: UInt8 + let name: String + let secret: Data + let isConfigured: Bool } // MARK: - Config Import Plan @@ -21,35 +21,35 @@ struct DeviceChannelSlot: Sendable, Equatable { /// Building a plan throws `NodeConfigServiceError` on any structural problem, so a /// malformed or poison config is rejected up front and nothing is half-applied. The /// execute phase then performs only writes that have already been proven applyable. -struct ConfigImportPlan: Sendable, Equatable { - struct Coordinate: Sendable, Equatable { - let latitude: Double - let longitude: Double - } - - struct ChannelWrite: Sendable, Equatable { - let index: UInt8 - let name: String - let secret: Data - } +struct ConfigImportPlan: Equatable { + struct Coordinate: Equatable { + let latitude: Double + let longitude: Double + } - /// Validated 64-byte private key to push, when identity is selected and present. - var importPrivateKey: Data? - /// Node name to set, when identity is selected and present. - var nodeName: String? - /// Validated, in-range position to set. - var position: Coordinate? - /// Other-settings to merge at execute time (passed through verbatim, raw bytes preserved). - var otherSettings: MeshCoreNodeConfig.OtherSettings? - /// Validated radio parameters to write, when the radio section is selected and present. - var radioSettings: MeshCoreNodeConfig.RadioSettings? - /// Resolved channel writes (deduplicated; intra-import duplicates folded onto one slot). - var channelWrites: [ChannelWrite] - /// True when any channel write replaces an already-configured slot whose name/secret differs - /// — i.e. the channels section is not purely additive for this config. - var channelsOverwriteExisting: Bool - /// Validated, deduplicated contact records ready to write (raw type byte preserved). - var contactRecords: [MeshContact] + struct ChannelWrite: Equatable { + let index: UInt8 + let name: String + let secret: Data + } + + /// Validated 64-byte private key to push, when identity is selected and present. + var importPrivateKey: Data? + /// Node name to set, when identity is selected and present. + var nodeName: String? + /// Validated, in-range position to set. + var position: Coordinate? + /// Other-settings to merge at execute time (passed through verbatim, raw bytes preserved). + var otherSettings: MeshCoreNodeConfig.OtherSettings? + /// Validated radio parameters to write, when the radio section is selected and present. + var radioSettings: MeshCoreNodeConfig.RadioSettings? + /// Resolved channel writes (deduplicated; intra-import duplicates folded onto one slot). + var channelWrites: [ChannelWrite] + /// True when any channel write replaces an already-configured slot whose name/secret differs + /// — i.e. the channels section is not purely additive for this config. + var channelsOverwriteExisting: Bool + /// Validated, deduplicated contact records ready to write (raw type byte preserved). + var contactRecords: [MeshContact] } // MARK: - Planner @@ -60,90 +60,90 @@ struct ConfigImportPlan: Sendable, Equatable { /// Pure and synchronous so it is unit-testable without a live `MeshCoreSession` — mirrors the /// `resolveEffectiveRadioID` seam pattern. Only sections present in `sections` are planned. func planConfigImport( - config: MeshCoreNodeConfig, - sections: ConfigSections, - maxChannels: UInt8, - maxContacts: Int, - maxTxPower: Int8, - existingChannels: [DeviceChannelSlot], - existingContacts: [String: MeshContact] + config: MeshCoreNodeConfig, + sections: ConfigSections, + maxChannels: UInt8, + maxContacts: Int, + maxTxPower: Int8, + existingChannels: [DeviceChannelSlot], + existingContacts: [String: MeshContact] ) throws -> ConfigImportPlan { - var plan = ConfigImportPlan( - importPrivateKey: nil, - nodeName: nil, - position: nil, - otherSettings: nil, - radioSettings: nil, - channelWrites: [], - channelsOverwriteExisting: false, - contactRecords: [] + var plan = ConfigImportPlan( + importPrivateKey: nil, + nodeName: nil, + position: nil, + otherSettings: nil, + radioSettings: nil, + channelWrites: [], + channelsOverwriteExisting: false, + contactRecords: [] + ) + + if sections.nodeIdentity { + plan.importPrivateKey = try planPrivateKey(config: config) + plan.nodeName = config.name + } + + if sections.positionSettings, let position = config.positionSettings { + let lat = try validatedCoordinate(position.latitude, field: .positionLatitude, range: PacketBuilder.latitudeRange) + let lon = try validatedCoordinate(position.longitude, field: .positionLongitude, range: PacketBuilder.longitudeRange) + plan.position = ConfigImportPlan.Coordinate(latitude: lat, longitude: lon) + } + + if sections.otherSettings { + plan.otherSettings = config.otherSettings + } + + if sections.radioSettings, let radio = config.radioSettings { + plan.radioSettings = try planRadioSettings(radio, maxTxPower: maxTxPower) + } + + if sections.channels, let channels = config.channels { + let (writes, overwrite) = try planChannelWrites( + channels, maxChannels: maxChannels, existingChannels: existingChannels ) + plan.channelWrites = writes + plan.channelsOverwriteExisting = overwrite + } - if sections.nodeIdentity { - plan.importPrivateKey = try planPrivateKey(config: config) - plan.nodeName = config.name - } - - if sections.positionSettings, let position = config.positionSettings { - let lat = try validatedCoordinate(position.latitude, field: .positionLatitude, range: PacketBuilder.latitudeRange) - let lon = try validatedCoordinate(position.longitude, field: .positionLongitude, range: PacketBuilder.longitudeRange) - plan.position = ConfigImportPlan.Coordinate(latitude: lat, longitude: lon) - } - - if sections.otherSettings { - plan.otherSettings = config.otherSettings - } - - if sections.radioSettings, let radio = config.radioSettings { - plan.radioSettings = try planRadioSettings(radio, maxTxPower: maxTxPower) - } - - if sections.channels, let channels = config.channels { - let (writes, overwrite) = try planChannelWrites( - channels, maxChannels: maxChannels, existingChannels: existingChannels - ) - plan.channelWrites = writes - plan.channelsOverwriteExisting = overwrite - } - - if sections.contacts, let contacts = config.contacts { - plan.contactRecords = try planContactRecords( - contacts, maxContacts: maxContacts, existingContacts: existingContacts - ) - } + if sections.contacts, let contacts = config.contacts { + plan.contactRecords = try planContactRecords( + contacts, maxContacts: maxContacts, existingContacts: existingContacts + ) + } - return plan + return plan } // MARK: - Identity private func planPrivateKey(config: MeshCoreNodeConfig) throws -> Data? { - guard let privateKeyHex = config.privateKey else { return nil } - - // A present-but-unparseable key must be rejected, not silently skipped. The key is the 64-byte - // expanded Ed25519 secret (`clamp(SHA512(seed))`). The public key is derivable from this scalar - // (firmware re-derives and validates it on import), but MC1's CryptoKit API operates on the - // 32-byte seed, which the export omits, so MC1 cannot cheaply re-derive and cross-check it here - // and takes the pairing on trust. Firmware additionally rejects all-00/FF-prefix keys and the - // known test keypair; that content check is intentionally deferred to the device and is the one - // identity failure that can surface at execute time, but firmware checks it before saving the - // identity, so it still cannot leave a half-rotated identity. - guard privateKeyHex.allSatisfy(\.isHexDigit), - let privateKeyData = Data(hexString: privateKeyHex), - privateKeyData.count == ProtocolLimits.privateKeySize else { - throw NodeConfigServiceError.invalidPrivateKey(hexLength: privateKeyHex.count) - } - - return privateKeyData + guard let privateKeyHex = config.privateKey else { return nil } + + // A present-but-unparseable key must be rejected, not silently skipped. The key is the 64-byte + // expanded Ed25519 secret (`clamp(SHA512(seed))`). The public key is derivable from this scalar + // (firmware re-derives and validates it on import), but MC1's CryptoKit API operates on the + // 32-byte seed, which the export omits, so MC1 cannot cheaply re-derive and cross-check it here + // and takes the pairing on trust. Firmware additionally rejects all-00/FF-prefix keys and the + // known test keypair; that content check is intentionally deferred to the device and is the one + // identity failure that can surface at execute time, but firmware checks it before saving the + // identity, so it still cannot leave a half-rotated identity. + guard privateKeyHex.allSatisfy(\.isHexDigit), + let privateKeyData = Data(hexString: privateKeyHex), + privateKeyData.count == ProtocolLimits.privateKeySize else { + throw NodeConfigServiceError.invalidPrivateKey(hexLength: privateKeyHex.count) + } + + return privateKeyData } // MARK: - Coordinates private func validatedCoordinate(_ raw: String, field: CoordinateField, range: ClosedRange) throws -> Double { - guard let value = Double(raw), value.isFinite, range.contains(value) else { - throw NodeConfigServiceError.invalidCoordinate(field: field) - } - return value + guard let value = Double(raw), value.isFinite, range.contains(value) else { + throw NodeConfigServiceError.invalidCoordinate(field: field) + } + return value } // MARK: - Radio @@ -153,25 +153,25 @@ private func validatedCoordinate(_ raw: String, field: CoordinateField, range: C /// has already been rotated. The txPower upper bound is the device-reported `maxTxPower`, since it is /// hardware/build-specific; the other ranges are fixed firmware limits in `PacketBuilder`. private func planRadioSettings( - _ radio: MeshCoreNodeConfig.RadioSettings, - maxTxPower: Int8 + _ radio: MeshCoreNodeConfig.RadioSettings, + maxTxPower: Int8 ) throws -> MeshCoreNodeConfig.RadioSettings { - guard PacketBuilder.frequencyRangeKHz.contains(radio.frequency) else { - throw NodeConfigServiceError.invalidRadioSettings(field: .frequency) - } - guard PacketBuilder.bandwidthRangeHz.contains(radio.bandwidth) else { - throw NodeConfigServiceError.invalidRadioSettings(field: .bandwidth) - } - guard PacketBuilder.spreadingFactorRange.contains(radio.spreadingFactor) else { - throw NodeConfigServiceError.invalidRadioSettings(field: .spreadingFactor) - } - guard PacketBuilder.codingRateRange.contains(radio.codingRate) else { - throw NodeConfigServiceError.invalidRadioSettings(field: .codingRate) - } - guard radio.txPower >= PacketBuilder.txPowerFloor, radio.txPower <= maxTxPower else { - throw NodeConfigServiceError.invalidRadioSettings(field: .txPower) - } - return radio + guard PacketBuilder.frequencyRangeKHz.contains(radio.frequency) else { + throw NodeConfigServiceError.invalidRadioSettings(field: .frequency) + } + guard PacketBuilder.bandwidthRangeHz.contains(radio.bandwidth) else { + throw NodeConfigServiceError.invalidRadioSettings(field: .bandwidth) + } + guard PacketBuilder.spreadingFactorRange.contains(radio.spreadingFactor) else { + throw NodeConfigServiceError.invalidRadioSettings(field: .spreadingFactor) + } + guard PacketBuilder.codingRateRange.contains(radio.codingRate) else { + throw NodeConfigServiceError.invalidRadioSettings(field: .codingRate) + } + guard radio.txPower >= PacketBuilder.txPowerFloor, radio.txPower <= maxTxPower else { + throw NodeConfigServiceError.invalidRadioSettings(field: .txPower) + } + return radio } // MARK: - Channels @@ -180,95 +180,95 @@ private func planRadioSettings( /// hashtag name or secret — onto one slot so a config never consumes two slots for the same /// channel, and flagging overwrites of already-configured slots. private func planChannelWrites( - _ channels: [MeshCoreNodeConfig.ChannelConfig], - maxChannels: UInt8, - existingChannels: [DeviceChannelSlot] + _ channels: [MeshCoreNodeConfig.ChannelConfig], + maxChannels: UInt8, + existingChannels: [DeviceChannelSlot] ) throws -> (writes: [ConfigImportPlan.ChannelWrite], overwrite: Bool) { - var hashtagNameToIndex: [String: UInt8] = [:] - var secretToIndex: [String: UInt8] = [:] - var emptyIndices: [UInt8] = [] - var existingByIndex: [UInt8: (name: String, secret: Data)] = [:] - - for slot in existingChannels where slot.index < maxChannels { - if slot.isConfigured { - existingByIndex[slot.index] = (slot.name, slot.secret) - // Index every configured slot by secret, so a same-secret import folds onto it - // regardless of whether the existing slot is a hashtag channel. Hashtag slots are - // additionally indexed by their (already device-truncated) name. - secretToIndex[slot.secret.hexString] = slot.index - if slot.name.hasPrefix("#") { - hashtagNameToIndex[slot.name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes)] = slot.index - } - } else { - emptyIndices.append(slot.index) - } + var hashtagNameToIndex: [String: UInt8] = [:] + var secretToIndex: [String: UInt8] = [:] + var emptyIndices: [UInt8] = [] + var existingByIndex: [UInt8: (name: String, secret: Data)] = [:] + + for slot in existingChannels where slot.index < maxChannels { + if slot.isConfigured { + existingByIndex[slot.index] = (slot.name, slot.secret) + // Index every configured slot by secret, so a same-secret import folds onto it + // regardless of whether the existing slot is a hashtag channel. Hashtag slots are + // additionally indexed by their (already device-truncated) name. + secretToIndex[slot.secret.hexString] = slot.index + if slot.name.hasPrefix("#") { + hashtagNameToIndex[slot.name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes)] = slot.index + } + } else { + emptyIndices.append(slot.index) + } + } + + var writes: [ConfigImportPlan.ChannelWrite] = [] + var overwrite = false + // The value each slot will hold given the writes planned so far, seeded from the device's + // configured slots. The no-op skip compares against this, not the frozen `existingByIndex`, so + // a later duplicate that restores a slot an earlier write changed is not dropped. + var plannedByIndex = existingByIndex + + for (i, channel) in channels.enumerated() { + guard channel.secret.allSatisfy(\.isHexDigit), + let secretData = Data(hexString: channel.secret), + secretData.count == ProtocolLimits.channelSecretSize else { + throw NodeConfigServiceError.invalidChannelSecret(index: i, hexLength: channel.secret.count) + } + // Key on the re-hexed parsed bytes (canonical) so a config secret with non-canonical + // casing still dedups against the device's canonically-keyed slots. + let secretKey = secretData.hexString + // The device stores names truncated to the firmware field width, so dedup and overwrite + // comparison must use the truncated form — otherwise a long hashtag name misses its slot. + let lookupName = channel.name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + + // The secret is firmware's channel-match key (findChannelIdx memcmp), so it must stay + // single-homed. Resolve any slot the secret already occupies first; a hashtag-name match + // must defer to it, otherwise a "#name"+new-secret import would duplicate that secret onto + // the name's slot while its original slot still holds it, mis-attributing mesh traffic. + let secretSlot = secretToIndex[secretKey] + + let targetIndex: UInt8 + if let secretSlot { + targetIndex = secretSlot + } else if channel.name.hasPrefix("#"), let existing = hashtagNameToIndex[lookupName] { + targetIndex = existing + } else if let empty = emptyIndices.first { + emptyIndices.removeFirst() + targetIndex = empty + } else { + throw NodeConfigServiceError.noAvailableChannelSlot(name: channel.name) + } + + // Skip the write when the slot's effective value already matches, comparing the truncated + // name the device actually stores. Writing it would re-commit /channels2 for no change. + if let planned = plannedByIndex[targetIndex], planned.name == lookupName, planned.secret == secretData { + // Keep the lookup tables current so a later same-key import still folds onto this slot. + if channel.name.hasPrefix("#") { hashtagNameToIndex[lookupName] = targetIndex } + secretToIndex[secretKey] = targetIndex + continue + } + + // An overwrite is a change to a slot the device itself had configured, so it keys off the + // original device state rather than the in-progress planned state. + if let original = existingByIndex[targetIndex], original.name != lookupName || original.secret != secretData { + overwrite = true } - var writes: [ConfigImportPlan.ChannelWrite] = [] - var overwrite = false - // The value each slot will hold given the writes planned so far, seeded from the device's - // configured slots. The no-op skip compares against this, not the frozen `existingByIndex`, so - // a later duplicate that restores a slot an earlier write changed is not dropped. - var plannedByIndex = existingByIndex - - for (i, channel) in channels.enumerated() { - guard channel.secret.allSatisfy(\.isHexDigit), - let secretData = Data(hexString: channel.secret), - secretData.count == ProtocolLimits.channelSecretSize else { - throw NodeConfigServiceError.invalidChannelSecret(index: i, hexLength: channel.secret.count) - } - // Key on the re-hexed parsed bytes (canonical) so a config secret with non-canonical - // casing still dedups against the device's canonically-keyed slots. - let secretKey = secretData.hexString - // The device stores names truncated to the firmware field width, so dedup and overwrite - // comparison must use the truncated form — otherwise a long hashtag name misses its slot. - let lookupName = channel.name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - - // The secret is firmware's channel-match key (findChannelIdx memcmp), so it must stay - // single-homed. Resolve any slot the secret already occupies first; a hashtag-name match - // must defer to it, otherwise a "#name"+new-secret import would duplicate that secret onto - // the name's slot while its original slot still holds it, mis-attributing mesh traffic. - let secretSlot = secretToIndex[secretKey] - - let targetIndex: UInt8 - if let secretSlot { - targetIndex = secretSlot - } else if channel.name.hasPrefix("#"), let existing = hashtagNameToIndex[lookupName] { - targetIndex = existing - } else if let empty = emptyIndices.first { - emptyIndices.removeFirst() - targetIndex = empty - } else { - throw NodeConfigServiceError.noAvailableChannelSlot(name: channel.name) - } - - // Skip the write when the slot's effective value already matches, comparing the truncated - // name the device actually stores. Writing it would re-commit /channels2 for no change. - if let planned = plannedByIndex[targetIndex], planned.name == lookupName, planned.secret == secretData { - // Keep the lookup tables current so a later same-key import still folds onto this slot. - if channel.name.hasPrefix("#") { hashtagNameToIndex[lookupName] = targetIndex } - secretToIndex[secretKey] = targetIndex - continue - } - - // An overwrite is a change to a slot the device itself had configured, so it keys off the - // original device state rather than the in-progress planned state. - if let original = existingByIndex[targetIndex], original.name != lookupName || original.secret != secretData { - overwrite = true - } - - // Update lookup tables so a later same-name/same-secret import folds onto this slot - // instead of consuming a fresh one, and record the slot's new effective value. - if channel.name.hasPrefix("#") { - hashtagNameToIndex[lookupName] = targetIndex - } - secretToIndex[secretKey] = targetIndex - plannedByIndex[targetIndex] = (lookupName, secretData) - - writes.append(ConfigImportPlan.ChannelWrite(index: targetIndex, name: channel.name, secret: secretData)) + // Update lookup tables so a later same-name/same-secret import folds onto this slot + // instead of consuming a fresh one, and record the slot's new effective value. + if channel.name.hasPrefix("#") { + hashtagNameToIndex[lookupName] = targetIndex } + secretToIndex[secretKey] = targetIndex + plannedByIndex[targetIndex] = (lookupName, secretData) - return (writes, overwrite) + writes.append(ConfigImportPlan.ChannelWrite(index: targetIndex, name: channel.name, secret: secretData)) + } + + return (writes, overwrite) } // MARK: - Contacts @@ -278,53 +278,53 @@ private func planChannelWrites( /// the device already stores byte-for-byte, and rejects invalid keys, path modes, coordinates, and /// routing paths before any write. private func planContactRecords( - _ contacts: [MeshCoreNodeConfig.ContactConfig], - maxContacts: Int, - existingContacts: [String: MeshContact] + _ contacts: [MeshCoreNodeConfig.ContactConfig], + maxContacts: Int, + existingContacts: [String: MeshContact] ) throws -> [MeshContact] { - var byKey: [String: (config: MeshCoreNodeConfig.ContactConfig, publicKey: Data)] = [:] - var order: [String] = [] - - for contact in contacts { - guard contact.publicKey.allSatisfy(\.isHexDigit), - let publicKey = Data(hexString: contact.publicKey), - publicKey.count == ProtocolLimits.publicKeySize else { - throw NodeConfigServiceError.invalidContactPublicKey(name: contact.name) - } - let key = publicKey.hexString - if let existing = byKey[key] { - if contact.lastModified >= existing.config.lastModified { - byKey[key] = (contact, publicKey) - } - } else { - byKey[key] = (contact, publicKey) - order.append(key) - } - } - - // A firmware update of a key already on the device consumes no slot, so only keys not already - // present count against free capacity. Checking remaining slots (not the absolute table size) - // lets the non-destructive preview reject an overflow up front instead of failing partway - // through the contact writes with `TABLE_FULL`, after identity and channels have committed. - let newKeyCount = order.reduce(0) { count, key in - existingContacts.keys.contains(key) ? count : count + 1 + var byKey: [String: (config: MeshCoreNodeConfig.ContactConfig, publicKey: Data)] = [:] + var order: [String] = [] + + for contact in contacts { + guard contact.publicKey.allSatisfy(\.isHexDigit), + let publicKey = Data(hexString: contact.publicKey), + publicKey.count == ProtocolLimits.publicKeySize else { + throw NodeConfigServiceError.invalidContactPublicKey(name: contact.name) } - let availableSlots = maxContacts - existingContacts.count - guard newKeyCount <= availableSlots else { - throw NodeConfigServiceError.contactCapacityExceeded(needed: newKeyCount, available: availableSlots) + let key = publicKey.hexString + if let existing = byKey[key] { + if contact.lastModified >= existing.config.lastModified { + byKey[key] = (contact, publicKey) + } + } else { + byKey[key] = (contact, publicKey) + order.append(key) } - - // Drop a record the device already stores byte-for-byte: re-adding it would arm a - // /contacts3 rewrite for no change. A dropped record was an existing key, so it never - // counted against free capacity above. - return try order.compactMap { key in - let entry = byKey[key]! - let record = try buildContactRecord(entry.config, publicKey: entry.publicKey, hexKey: key) - if let existing = existingContacts[key], persistedContactFieldsMatch(existing, record) { - return nil - } - return record + } + + // A firmware update of a key already on the device consumes no slot, so only keys not already + // present count against free capacity. Checking remaining slots (not the absolute table size) + // lets the non-destructive preview reject an overflow up front instead of failing partway + // through the contact writes with `TABLE_FULL`, after identity and channels have committed. + let newKeyCount = order.reduce(0) { count, key in + existingContacts.keys.contains(key) ? count : count + 1 + } + let availableSlots = maxContacts - existingContacts.count + guard newKeyCount <= availableSlots else { + throw NodeConfigServiceError.contactCapacityExceeded(needed: newKeyCount, available: availableSlots) + } + + // Drop a record the device already stores byte-for-byte: re-adding it would arm a + // /contacts3 rewrite for no change. A dropped record was an existing key, so it never + // counted against free capacity above. + return try order.compactMap { key in + let entry = byKey[key]! + let record = try buildContactRecord(entry.config, publicKey: entry.publicKey, hexKey: key) + if let existing = existingContacts[key], persistedContactFieldsMatch(existing, record) { + return nil } + return record + } } /// True when `existing` (a device-resident contact) already stores exactly what `record` would @@ -334,79 +334,79 @@ private func planContactRecords( /// skipped. Names compare at the firmware field width, and coordinates by the integer the device /// actually stores, so a difference the device cannot represent is treated as equal. private func persistedContactFieldsMatch(_ existing: MeshContact, _ record: MeshContact) -> Bool { - guard existing.typeRawValue == record.typeRawValue, - existing.flags == record.flags, - existing.outPathLength == record.outPathLength, - existing.advertisedName.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - == record.advertisedName.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes), - existing.outPath.prefix(existing.pathByteLength) == record.outPath.prefix(record.pathByteLength), - Int(existing.lastModified.timeIntervalSince1970) == Int(record.lastModified.timeIntervalSince1970) else { - return false - } - return PacketBuilder.scaledCoordinate(existing.latitude, in: PacketBuilder.latitudeRange) - == PacketBuilder.scaledCoordinate(record.latitude, in: PacketBuilder.latitudeRange) - && PacketBuilder.scaledCoordinate(existing.longitude, in: PacketBuilder.longitudeRange) - == PacketBuilder.scaledCoordinate(record.longitude, in: PacketBuilder.longitudeRange) + guard existing.typeRawValue == record.typeRawValue, + existing.flags == record.flags, + existing.outPathLength == record.outPathLength, + existing.advertisedName.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + == record.advertisedName.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes), + existing.outPath.prefix(existing.pathByteLength) == record.outPath.prefix(record.pathByteLength), + Int(existing.lastModified.timeIntervalSince1970) == Int(record.lastModified.timeIntervalSince1970) else { + return false + } + return PacketBuilder.scaledCoordinate(existing.latitude, in: PacketBuilder.latitudeRange) + == PacketBuilder.scaledCoordinate(record.latitude, in: PacketBuilder.latitudeRange) + && PacketBuilder.scaledCoordinate(existing.longitude, in: PacketBuilder.longitudeRange) + == PacketBuilder.scaledCoordinate(record.longitude, in: PacketBuilder.longitudeRange) } /// Builds one validated contact record. `publicKey` and `hexKey` are the already-decoded key /// and its lowercase hex from ``planContactRecords``, so the key is parsed exactly once. private func buildContactRecord( - _ contact: MeshCoreNodeConfig.ContactConfig, - publicKey: Data, - hexKey: String + _ contact: MeshCoreNodeConfig.ContactConfig, + publicKey: Data, + hexKey: String ) throws -> MeshContact { - let (outPath, outPathLength) = try resolveOutPath(contact) - - let lat = try validatedCoordinate(contact.latitude, field: .contactLatitude(name: contact.name), range: PacketBuilder.latitudeRange) - let lon = try validatedCoordinate(contact.longitude, field: .contactLongitude(name: contact.name), range: PacketBuilder.longitudeRange) - - return MeshContact( - id: hexKey, - publicKey: publicKey, - type: ContactType(rawValue: contact.type) ?? .chat, - typeRawValue: contact.type, - flags: ContactFlags(rawValue: contact.flags), - outPathLength: outPathLength, - outPath: outPath, - advertisedName: contact.name, - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvert)), - latitude: lat, - longitude: lon, - lastModified: Date(timeIntervalSince1970: TimeInterval(contact.lastModified)) - ) + let (outPath, outPathLength) = try resolveOutPath(contact) + + let lat = try validatedCoordinate(contact.latitude, field: .contactLatitude(name: contact.name), range: PacketBuilder.latitudeRange) + let lon = try validatedCoordinate(contact.longitude, field: .contactLongitude(name: contact.name), range: PacketBuilder.longitudeRange) + + return MeshContact( + id: hexKey, + publicKey: publicKey, + type: ContactType(rawValue: contact.type) ?? .chat, + typeRawValue: contact.type, + flags: ContactFlags(rawValue: contact.flags), + outPathLength: outPathLength, + outPath: outPath, + advertisedName: contact.name, + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvert)), + latitude: lat, + longitude: lon, + lastModified: Date(timeIntervalSince1970: TimeInterval(contact.lastModified)) + ) } /// Resolves the encoded out-path for a contact, rejecting malformed routing data instead of /// silently downgrading a routed contact to direct. private func resolveOutPath(_ contact: MeshCoreNodeConfig.ContactConfig) throws -> (path: Data, length: UInt8) { - guard let pathHex = contact.outPath else { - // Absent path: flood routing. - return (Data(), PacketBuilder.floodPathSentinel) - } - if pathHex.isEmpty { - // Explicit empty string: direct contact. - return (Data(), 0) - } - // `Data(hexString:)` silently drops non-hex characters, so a routed path like "zzz" would - // parse to empty and masquerade as direct. Require contiguous, even-length hex up front. - guard pathHex.count.isMultiple(of: 2), - pathHex.allSatisfy(\.isHexDigit), - let pathData = Data(hexString: pathHex), !pathData.isEmpty else { - throw NodeConfigServiceError.invalidOutPath(name: contact.name) - } - let mode = contact.pathHashMode ?? 0 - guard mode <= UInt8(PathEncoding.maxPathHashMode) else { - throw NodeConfigServiceError.invalidPathHashMode(name: contact.name, mode: mode) - } - let hashSize = Int(mode) + 1 - // Reject paths that would silently truncate (non-multiple length, hop count past the 6-bit - // field) or exceed the firmware out-path buffer (firmware `isValidPathLen`). - guard pathData.count % hashSize == 0, - pathData.count / hashSize <= PathEncoding.maxHopCount, - pathData.count <= PathEncoding.maxPathBytes else { - throw NodeConfigServiceError.invalidOutPath(name: contact.name) - } - let hopCount = pathData.count / hashSize - return (pathData, encodePathLen(hashSize: hashSize, hopCount: hopCount)) + guard let pathHex = contact.outPath else { + // Absent path: flood routing. + return (Data(), PacketBuilder.floodPathSentinel) + } + if pathHex.isEmpty { + // Explicit empty string: direct contact. + return (Data(), 0) + } + // `Data(hexString:)` silently drops non-hex characters, so a routed path like "zzz" would + // parse to empty and masquerade as direct. Require contiguous, even-length hex up front. + guard pathHex.count.isMultiple(of: 2), + pathHex.allSatisfy(\.isHexDigit), + let pathData = Data(hexString: pathHex), !pathData.isEmpty else { + throw NodeConfigServiceError.invalidOutPath(name: contact.name) + } + let mode = contact.pathHashMode ?? 0 + guard mode <= UInt8(PathEncoding.maxPathHashMode) else { + throw NodeConfigServiceError.invalidPathHashMode(name: contact.name, mode: mode) + } + let hashSize = Int(mode) + 1 + // Reject paths that would silently truncate (non-multiple length, hop count past the 6-bit + // field) or exceed the firmware out-path buffer (firmware `isValidPathLen`). + guard pathData.count % hashSize == 0, + pathData.count / hashSize <= PathEncoding.maxHopCount, + pathData.count <= PathEncoding.maxPathBytes else { + throw NodeConfigServiceError.invalidOutPath(name: contact.name) + } + let hopCount = pathData.count / hashSize + return (pathData, encodePathLen(hashSize: hashSize, hopCount: hopCount)) } diff --git a/MC1Services/Sources/MC1Services/Services/NodeConfigService.swift b/MC1Services/Sources/MC1Services/Services/NodeConfigService.swift index 635ab38c..ecdc50f6 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeConfigService.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeConfigService.swift @@ -7,49 +7,49 @@ import OSLog /// Identifies which radio parameter failed range validation. Kept `L10n`-free so the service /// layer carries no localization; the app layer maps each case to a localized field label. public enum RadioField: Sendable, Equatable { - case frequency, bandwidth, spreadingFactor, codingRate, txPower + case frequency, bandwidth, spreadingFactor, codingRate, txPower } /// Identifies which coordinate failed range validation, and on which record. Kept `L10n`-free /// so the service layer carries no localization; the app layer maps each case to a localized label. public enum CoordinateField: Sendable, Equatable { - case positionLatitude, positionLongitude - case contactLatitude(name: String), contactLongitude(name: String) + case positionLatitude, positionLongitude + case contactLatitude(name: String), contactLongitude(name: String) } public enum NodeConfigServiceError: Error, LocalizedError, Sendable { - case invalidChannelSecret(index: Int, hexLength: Int) - case invalidContactPublicKey(name: String) - case invalidPathHashMode(name: String, mode: UInt8) - case invalidPrivateKey(hexLength: Int) - case invalidRadioSettings(field: RadioField) - case noAvailableChannelSlot(name: String) - case invalidCoordinate(field: CoordinateField) - case invalidOutPath(name: String) - case contactCapacityExceeded(needed: Int, available: Int) - - public var errorDescription: String? { - switch self { - case .invalidChannelSecret(let index, let hexLength): - "Channel \(index) has invalid secret (\(hexLength) hex chars, expected 32)" - case .invalidContactPublicKey(let name): - "Contact \"\(name)\" has an invalid public key" - case .invalidPathHashMode(let name, let mode): - "Contact \"\(name)\" has unsupported path hash mode \(mode) (expected 0, 1, or 2)" - case .invalidPrivateKey(let hexLength): - "Invalid private key (\(hexLength) hex chars, expected \(ProtocolLimits.privateKeySize * 2))" - case .invalidRadioSettings: - "Radio parameter is outside the supported range" - case .noAvailableChannelSlot(let name): - "No empty channel slot available for \"\(name)\"" - case .invalidCoordinate: - "Coordinate is invalid or out of range" - case .invalidOutPath(let name): - "Contact \"\(name)\" has an invalid routing path" - case .contactCapacityExceeded(let needed, let available): - "Import needs \(needed) free contact slot(s) but only \(available) remain on the device" - } + case invalidChannelSecret(index: Int, hexLength: Int) + case invalidContactPublicKey(name: String) + case invalidPathHashMode(name: String, mode: UInt8) + case invalidPrivateKey(hexLength: Int) + case invalidRadioSettings(field: RadioField) + case noAvailableChannelSlot(name: String) + case invalidCoordinate(field: CoordinateField) + case invalidOutPath(name: String) + case contactCapacityExceeded(needed: Int, available: Int) + + public var errorDescription: String? { + switch self { + case let .invalidChannelSecret(index, hexLength): + "Channel \(index) has invalid secret (\(hexLength) hex chars, expected 32)" + case let .invalidContactPublicKey(name): + "Contact \"\(name)\" has an invalid public key" + case let .invalidPathHashMode(name, mode): + "Contact \"\(name)\" has unsupported path hash mode \(mode) (expected 0, 1, or 2)" + case let .invalidPrivateKey(hexLength): + "Invalid private key (\(hexLength) hex chars, expected \(ProtocolLimits.privateKeySize * 2))" + case .invalidRadioSettings: + "Radio parameter is outside the supported range" + case let .noAvailableChannelSlot(name): + "No empty channel slot available for \"\(name)\"" + case .invalidCoordinate: + "Coordinate is invalid or out of range" + case let .invalidOutPath(name): + "Contact \"\(name)\" has an invalid routing path" + case let .contactCapacityExceeded(needed, available): + "Import needs \(needed) free contact slot(s) but only \(available) remain on the device" } + } } // MARK: - Import Progress @@ -58,17 +58,17 @@ public enum NodeConfigServiceError: Error, LocalizedError, Sendable { /// so the service layer carries no localization; the app layer maps each case to a localized /// description for display. public enum ImportStep: Sendable, Equatable { - case position, otherParameters, privateKey, nodeName - case radioParameters, txPower - case channel(name: String) - case contact(name: String) + case position, otherParameters, privateKey, nodeName + case radioParameters, txPower + case channel(name: String) + case contact(name: String) } /// Reports import progress to the UI. public struct ImportProgress: Sendable { - public let step: ImportStep - public let current: Int - public let total: Int + public let step: ImportStep + public let current: Int + public let total: Int } // MARK: - Import Preview @@ -76,9 +76,9 @@ public struct ImportProgress: Sendable { /// A non-destructive summary of what an import would do, used to drive the confirmation UI /// before any write. Computed by running the same planner the import uses. public struct ImportPreview: Sendable { - /// True when at least one channel write would replace an already-configured slot whose - /// name or secret differs — i.e. the channels section is not purely additive. - public let channelsOverwriteExisting: Bool + /// True when at least one channel write would replace an already-configured slot whose + /// name or secret differs — i.e. the channels section is not purely additive. + public let channelsOverwriteExisting: Bool } // MARK: - Node Config Service @@ -86,324 +86,325 @@ public struct ImportPreview: Sendable { /// Exports device configuration to `MeshCoreNodeConfig` and imports it back, /// handling section filtering, other-params merging, and safe import ordering. public actor NodeConfigService { - private let session: any MeshCoreSessionProtocol - private let settingsService: SettingsService - private let channelService: ChannelService - private let dataStore: any PersistenceStoreProtocol - /// Injected by `ServiceContainer` at construction. - private weak var syncCoordinator: SyncCoordinator? - private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigService") - /// Called after a config import restores a private key, so the connection layer can - /// reconcile device identity. Installed by `ConnectionManager.buildServicesAndSaveDevice`. - private var onPostIdentityImport: (@Sendable () async throws -> UUID?)? - - public init( - session: any MeshCoreSessionProtocol, - settingsService: SettingsService, - channelService: ChannelService, - dataStore: any PersistenceStoreProtocol, - syncCoordinator: SyncCoordinator? - ) { - self.session = session - self.settingsService = settingsService - self.channelService = channelService - self.dataStore = dataStore - self.syncCoordinator = syncCoordinator - } + private let session: any MeshCoreSessionProtocol + private let settingsService: SettingsService + private let channelService: ChannelService + private let dataStore: any PersistenceStoreProtocol + /// Injected by `ServiceContainer` at construction. + private weak var syncCoordinator: SyncCoordinator? + private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigService") + /// Called after a config import restores a private key, so the connection layer can + /// reconcile device identity. Installed by `ConnectionManager.buildServicesAndSaveDevice`. + private var onPostIdentityImport: (@Sendable () async throws -> UUID?)? + + public init( + session: any MeshCoreSessionProtocol, + settingsService: SettingsService, + channelService: ChannelService, + dataStore: any PersistenceStoreProtocol, + syncCoordinator: SyncCoordinator? + ) { + self.session = session + self.settingsService = settingsService + self.channelService = channelService + self.dataStore = dataStore + self.syncCoordinator = syncCoordinator + } + + /// Wires a late-bound callback that fires after `importIdentity` succeeds. + /// The callback is responsible for reconciling the radio's restored + /// `publicKey` with any ghost `Device` row left by a prior "remove from MC1". + /// Its return value, when non-nil, replaces the `radioID` used by all + /// subsequent steps in `importConfig` (channels, contacts). + public func setOnPostIdentityImport( + _ callback: (@Sendable () async throws -> UUID?)? + ) { + onPostIdentityImport = callback + } + + /// Whether a sync coordinator was injected at construction. + var hasSyncCoordinatorWired: Bool { + syncCoordinator != nil + } + + // MARK: - Export + + /// Reads the device state and builds a `MeshCoreNodeConfig`. + /// - Parameter sections: Which sections to include in the export. + /// - Returns: A populated config struct. + public func exportConfig(sections: ConfigSections) async throws -> MeshCoreNodeConfig { + let selfInfo = try await settingsService.getSelfInfo() + + var config = MeshCoreNodeConfig() - /// Wires a late-bound callback that fires after `importIdentity` succeeds. - /// The callback is responsible for reconciling the radio's restored - /// `publicKey` with any ghost `Device` row left by a prior "remove from MC1". - /// Its return value, when non-nil, replaces the `radioID` used by all - /// subsequent steps in `importConfig` (channels, contacts). - public func setOnPostIdentityImport( - _ callback: (@Sendable () async throws -> UUID?)? - ) { - self.onPostIdentityImport = callback + if sections.nodeIdentity { + config.name = selfInfo.name + config.publicKey = selfInfo.publicKey.hexString + // Hardened firmware disables private-key export (`featureDisabled`). Emit name + + // public key in that case rather than failing the whole export. Any other failure + // (transient BLE timeout, device error, cancellation) propagates, so the user sees + // the export fail and retries instead of saving an identity backup missing its key. + do { + let privateKey = try await settingsService.exportPrivateKey() + config.privateKey = privateKey.hexString + } catch SettingsServiceError.sessionError(.featureDisabled) { + logger.warning("Private key export disabled by firmware; exporting identity without it") + } } - /// Whether a sync coordinator was injected at construction. - var hasSyncCoordinatorWired: Bool { syncCoordinator != nil } - - // MARK: - Export - - /// Reads the device state and builds a `MeshCoreNodeConfig`. - /// - Parameter sections: Which sections to include in the export. - /// - Returns: A populated config struct. - public func exportConfig(sections: ConfigSections) async throws -> MeshCoreNodeConfig { - let selfInfo = try await settingsService.getSelfInfo() - - var config = MeshCoreNodeConfig() - - if sections.nodeIdentity { - config.name = selfInfo.name - config.publicKey = selfInfo.publicKey.hexString - // Hardened firmware disables private-key export (`featureDisabled`). Emit name + - // public key in that case rather than failing the whole export. Any other failure - // (transient BLE timeout, device error, cancellation) propagates, so the user sees - // the export fail and retries instead of saving an identity backup missing its key. - do { - let privateKey = try await settingsService.exportPrivateKey() - config.privateKey = privateKey.hexString - } catch SettingsServiceError.sessionError(.featureDisabled) { - logger.warning("Private key export disabled by firmware; exporting identity without it") - } - } - - if sections.radioSettings { - config.radioSettings = Self.buildRadioSettings(from: selfInfo) - } - - if sections.positionSettings { - config.positionSettings = MeshCoreNodeConfig.PositionSettings( - latitude: String(selfInfo.latitude), - longitude: String(selfInfo.longitude) - ) - } - - if sections.otherSettings { - config.otherSettings = Self.buildOtherSettings(from: selfInfo) - } - - if sections.channels { - let capabilities = try await settingsService.queryDevice() - config.channels = try await exportChannels(maxChannels: UInt8(capabilities.maxChannels)) - } - - if sections.contacts { - let meshContacts = try await session.getContacts(since: nil) - config.contacts = meshContacts.map { Self.buildContactConfig(from: $0) } - } - - return config + if sections.radioSettings { + config.radioSettings = Self.buildRadioSettings(from: selfInfo) } - // MARK: - Import - - /// Writes a `MeshCoreNodeConfig` to the device in safe order (radio last). - /// - /// Runs in three phases: a non-destructive **read** of device capabilities and current - /// channels, a pure **validate/plan** pass (``planConfigImport``) - /// that rejects a malformed config before any write, then **execute**. Because everything - /// that can be validated is validated up front, no validation error can land after the - /// identity has already been rotated. - /// - Parameters: - /// - config: The config to import. - /// - sections: Which sections to actually apply. - /// - radioID: The connected device UUID (needed for channel writes). - /// - onProgress: Optional callback for UI progress updates. - public func importConfig( - _ config: MeshCoreNodeConfig, - sections: ConfigSections, - radioID: UUID, - onProgress: (@Sendable (ImportProgress) -> Void)? = nil - ) async throws { - // Read + validate/plan phase (non-destructive): rejects a poison/malformed config before any write. - let plan = try await buildImportPlan(config, sections: sections) - - // Execute phase, driven through the testable `executeConfigImport` seam. The actor supplies - // the concrete write closures; the sequencing, progress, and cancellation live in the seam. - let coordinator = syncCoordinator - try await executeConfigImport( - plan: plan, sections: sections, radioID: radioID, - writers: makeWriters(), logger: logger, onProgress: onProgress, - notifyContactsChanged: { await coordinator?.notifyContactsChanged() } - ) + if sections.positionSettings { + config.positionSettings = MeshCoreNodeConfig.PositionSettings( + latitude: String(selfInfo.latitude), + longitude: String(selfInfo.longitude) + ) } - /// Builds the concrete write closures `executeConfigImport` calls, capturing this actor's - /// services. Each closure performs exactly one device (and, for contacts, database) write. - private func makeWriters() -> ConfigImportWriters { - let settings = settingsService - let channels = channelService - let session = self.session - let store = dataStore - let logger = self.logger - let callback = onPostIdentityImport - return ConfigImportWriters( - getSelfInfo: { try await settings.getSelfInfo() }, - importPrivateKey: { try await settings.importPrivateKey($0) }, - setNodeName: { try await settings.setNodeName($0) }, - setLocation: { try await settings.setLocation(latitude: $0, longitude: $1) }, - setOtherParams: { [self] in try await self.importOtherParams($0) }, - resolveEffectiveRadioID: { - try await resolveEffectiveRadioID(original: $0, didImportPrivateKey: $1, callback: callback) - }, - setRadioParams: { radio in - // bandwidthKHz parameter actually takes Hz (misnomer); pass directly. - try await settings.setRadioParams( - frequencyKHz: radio.frequency, bandwidthKHz: radio.bandwidth, - spreadingFactor: radio.spreadingFactor, codingRate: radio.codingRate - ) - }, - setTxPower: { try await settings.setTxPower($0) }, - setChannel: { radioID, write in - try await channels.setChannelWithSecret( - radioID: radioID, index: write.index, name: write.name, secret: write.secret - ) - }, - addContact: { radioID, contact in - try await session.addContact(contact) - // The device add is the irreversible change. The local row is an idempotent - // (radioID, publicKey) upsert that the next contacts sync reconciles, so a save - // failure here must not mask that the contact already landed on the device. - do { - let frame = contact.toContactFrame() - _ = try await store.saveContact(radioID: radioID, from: frame) - } catch { - logger.error("Contact added to device but local save failed; next sync will reconcile: \(error.localizedDescription)") - } - } - ) + if sections.otherSettings { + config.otherSettings = Self.buildOtherSettings(from: selfInfo) } - /// Total destructive steps for progress reporting, derived entirely from the resolved plan so the - /// bar reflects post-dedup channel/contact counts and validated sections rather than raw config. - /// - /// This must mirror the write gates in `importConfig`: every conditional `progress(...)` call - /// there needs a matching term here, or the progress bar will under- or over-count. Keep the - /// two in sync when adding or removing a write step. Internal (not private) so it is unit-testable. - static func stepCount(for plan: ConfigImportPlan) -> Int { - var count = 0 - if plan.importPrivateKey != nil { count += 1 } - if plan.nodeName != nil { count += 1 } - if plan.position != nil { count += 1 } - if plan.otherSettings != nil { count += 1 } - count += plan.channelWrites.count - count += plan.contactRecords.count - if plan.radioSettings != nil { count += 2 } // radio params + tx power - return count + if sections.channels { + let capabilities = try await settingsService.queryDevice() + config.channels = try await exportChannels(maxChannels: UInt8(capabilities.maxChannels)) } - /// Non-destructively summarizes what an import would do, so the confirmation UI can warn - /// about channel overwrites before any write. Runs the same planner as `importConfig`, so it - /// also surfaces validation errors early. The selection UI shows raw per-section counts; the - /// planner's post-dedup counts are not surfaced here, only the overwrite flag. - public func previewImport( - _ config: MeshCoreNodeConfig, - sections: ConfigSections - ) async throws -> ImportPreview { - let plan = try await buildImportPlan(config, sections: sections) - return ImportPreview(channelsOverwriteExisting: plan.channelsOverwriteExisting) + if sections.contacts { + let meshContacts = try await session.getContacts(since: nil) + config.contacts = meshContacts.map { Self.buildContactConfig(from: $0) } } - // MARK: - Internal Helpers - - /// Read phase shared by `importConfig` and `previewImport`: gathers device capabilities and current - /// state (channel slots, existing contact keys, max TX power) needed to validate the config, then - /// runs the pure planner. Non-destructive. `importConfig` re-runs this at execute time for TOCTOU - /// freshness, so the device reads happen twice across a preview-then-apply cycle; that is an - /// accepted cost on this cold, user-initiated path. - private func buildImportPlan( - _ config: MeshCoreNodeConfig, - sections: ConfigSections - ) async throws -> ConfigImportPlan { - let capabilities = try await settingsService.queryDevice() - let maxChannels = UInt8(capabilities.maxChannels) - let maxContacts = Int(capabilities.maxContacts) - - let existingChannels = sections.channels - ? try await readExistingChannels(maxChannels: maxChannels) - : [] - - // Existing contacts credit firmware updates (which consume no slot) against free capacity, - // so an over-capacity import is rejected up front instead of failing partway through the - // writes. The full records (not just keys) also let the planner drop contacts the device - // already stores byte-for-byte, sparing a redundant /contacts3 commit per unchanged contact. - let existingContacts: [String: MeshContact] = sections.contacts - ? Dictionary( - try await session.getContacts(since: nil).map { ($0.publicKey.hexString, $0) }, - uniquingKeysWith: { _, latest in latest } - ) - : [:] - - // The txPower upper bound is hardware/build-specific, so read the device's max rather than - // assuming a fixed maximum. Only needed when the radio section is selected. - let maxTxPower = sections.radioSettings - ? try await settingsService.getSelfInfo().maxTxPower - : 0 - - return try planConfigImport( - config: config, - sections: sections, - maxChannels: maxChannels, - maxContacts: maxContacts, - maxTxPower: maxTxPower, - existingChannels: existingChannels, - existingContacts: existingContacts + return config + } + + // MARK: - Import + + /// Writes a `MeshCoreNodeConfig` to the device in safe order (radio last). + /// + /// Runs in three phases: a non-destructive **read** of device capabilities and current + /// channels, a pure **validate/plan** pass (``planConfigImport``) + /// that rejects a malformed config before any write, then **execute**. Because everything + /// that can be validated is validated up front, no validation error can land after the + /// identity has already been rotated. + /// - Parameters: + /// - config: The config to import. + /// - sections: Which sections to actually apply. + /// - radioID: The connected device UUID (needed for channel writes). + /// - onProgress: Optional callback for UI progress updates. + public func importConfig( + _ config: MeshCoreNodeConfig, + sections: ConfigSections, + radioID: UUID, + onProgress: (@Sendable (ImportProgress) -> Void)? = nil + ) async throws { + // Read + validate/plan phase (non-destructive): rejects a poison/malformed config before any write. + let plan = try await buildImportPlan(config, sections: sections) + + // Execute phase, driven through the testable `executeConfigImport` seam. The actor supplies + // the concrete write closures; the sequencing, progress, and cancellation live in the seam. + let coordinator = syncCoordinator + try await executeConfigImport( + plan: plan, sections: sections, radioID: radioID, + writers: makeWriters(), logger: logger, onProgress: onProgress, + notifyContactsChanged: { await coordinator?.notifyContactsChanged() } + ) + } + + /// Builds the concrete write closures `executeConfigImport` calls, capturing this actor's + /// services. Each closure performs exactly one device (and, for contacts, database) write. + private func makeWriters() -> ConfigImportWriters { + let settings = settingsService + let channels = channelService + let session = session + let store = dataStore + let logger = logger + let callback = onPostIdentityImport + return ConfigImportWriters( + getSelfInfo: { try await settings.getSelfInfo() }, + importPrivateKey: { try await settings.importPrivateKey($0) }, + setNodeName: { try await settings.setNodeName($0) }, + setLocation: { try await settings.setLocation(latitude: $0, longitude: $1) }, + setOtherParams: { [self] in try await importOtherParams($0) }, + resolveEffectiveRadioID: { + try await resolveEffectiveRadioID(original: $0, didImportPrivateKey: $1, callback: callback) + }, + setRadioParams: { radio in + // bandwidthKHz parameter actually takes Hz (misnomer); pass directly. + try await settings.setRadioParams( + frequencyKHz: radio.frequency, bandwidthKHz: radio.bandwidth, + spreadingFactor: radio.spreadingFactor, codingRate: radio.codingRate ) - } - - /// Reads every channel slot from the device and classifies it as configured or empty. - private func readExistingChannels(maxChannels: UInt8) async throws -> [DeviceChannelSlot] { - var slots: [DeviceChannelSlot] = [] - for index in 0 as UInt8.. Int { + var count = 0 + if plan.importPrivateKey != nil { count += 1 } + if plan.nodeName != nil { count += 1 } + if plan.position != nil { count += 1 } + if plan.otherSettings != nil { count += 1 } + count += plan.channelWrites.count + count += plan.contactRecords.count + if plan.radioSettings != nil { count += 2 } // radio params + tx power + return count + } + + /// Non-destructively summarizes what an import would do, so the confirmation UI can warn + /// about channel overwrites before any write. Runs the same planner as `importConfig`, so it + /// also surfaces validation errors early. The selection UI shows raw per-section counts; the + /// planner's post-dedup counts are not surfaced here, only the overwrite flag. + public func previewImport( + _ config: MeshCoreNodeConfig, + sections: ConfigSections + ) async throws -> ImportPreview { + let plan = try await buildImportPlan(config, sections: sections) + return ImportPreview(channelsOverwriteExisting: plan.channelsOverwriteExisting) + } + + // MARK: - Internal Helpers + + /// Read phase shared by `importConfig` and `previewImport`: gathers device capabilities and current + /// state (channel slots, existing contact keys, max TX power) needed to validate the config, then + /// runs the pure planner. Non-destructive. `importConfig` re-runs this at execute time for TOCTOU + /// freshness, so the device reads happen twice across a preview-then-apply cycle; that is an + /// accepted cost on this cold, user-initiated path. + private func buildImportPlan( + _ config: MeshCoreNodeConfig, + sections: ConfigSections + ) async throws -> ConfigImportPlan { + let capabilities = try await settingsService.queryDevice() + let maxChannels = UInt8(capabilities.maxChannels) + let maxContacts = Int(capabilities.maxContacts) + + let existingChannels = sections.channels + ? try await readExistingChannels(maxChannels: maxChannels) + : [] + + // Existing contacts credit firmware updates (which consume no slot) against free capacity, + // so an over-capacity import is rejected up front instead of failing partway through the + // writes. The full records (not just keys) also let the planner drop contacts the device + // already stores byte-for-byte, sparing a redundant /contacts3 commit per unchanged contact. + let existingContacts: [String: MeshContact] = try await sections.contacts + ? Dictionary( + session.getContacts(since: nil).map { ($0.publicKey.hexString, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + : [:] + + // The txPower upper bound is hardware/build-specific, so read the device's max rather than + // assuming a fixed maximum. Only needed when the radio section is selected. + let maxTxPower = sections.radioSettings + ? try await settingsService.getSelfInfo().maxTxPower + : 0 + + return try planConfigImport( + config: config, + sections: sections, + maxChannels: maxChannels, + maxContacts: maxContacts, + maxTxPower: maxTxPower, + existingChannels: existingChannels, + existingContacts: existingContacts + ) + } + + /// Reads every channel slot from the device and classifies it as configured or empty. + private func readExistingChannels(maxChannels: UInt8) async throws -> [DeviceChannelSlot] { + var slots: [DeviceChannelSlot] = [] + for index in 0 as UInt8.. [MeshCoreNodeConfig.ChannelConfig] { - var channels: [MeshCoreNodeConfig.ChannelConfig] = [] - for index in 0.. [MeshCoreNodeConfig.ChannelConfig] { + var channels: [MeshCoreNodeConfig.ChannelConfig] = [] + for index in 0.. UUID?)? +func resolveEffectiveRadioID( + original: UUID, + didImportPrivateKey: Bool, + callback: (@Sendable () async throws -> UUID?)? ) async throws -> UUID { - guard didImportPrivateKey, let cb = callback else { - return original - } - if let reconciled = try await cb() { - return reconciled - } + guard didImportPrivateKey, let cb = callback else { return original + } + if let reconciled = try await cb() { + return reconciled + } + return original } // MARK: - Execute Orchestration (testable seam) @@ -433,19 +434,19 @@ internal func resolveEffectiveRadioID( /// The concrete device/database write operations `executeConfigImport` performs, one per closure. /// `NodeConfigService` builds these from its services; tests build spies, so the destructive-path /// sequencing can be exercised without a live `MeshCoreSession`. -struct ConfigImportWriters: Sendable { - /// Reads the device's current state so the pref/radio writes below can be diff-gated against - /// it; a read, not a commit, so eliding redundant pref commits dwarfs its cost. - let getSelfInfo: @Sendable () async throws -> SelfInfo - let importPrivateKey: @Sendable (Data) async throws -> Void - let setNodeName: @Sendable (String) async throws -> Void - let setLocation: @Sendable (_ latitude: Double, _ longitude: Double) async throws -> Void - let setOtherParams: @Sendable (MeshCoreNodeConfig.OtherSettings) async throws -> Void - let resolveEffectiveRadioID: @Sendable (_ original: UUID, _ didImportPrivateKey: Bool) async throws -> UUID - let setRadioParams: @Sendable (MeshCoreNodeConfig.RadioSettings) async throws -> Void - let setTxPower: @Sendable (Int8) async throws -> Void - let setChannel: @Sendable (_ radioID: UUID, _ write: ConfigImportPlan.ChannelWrite) async throws -> Void - let addContact: @Sendable (_ radioID: UUID, _ contact: MeshContact) async throws -> Void +struct ConfigImportWriters { + /// Reads the device's current state so the pref/radio writes below can be diff-gated against + /// it; a read, not a commit, so eliding redundant pref commits dwarfs its cost. + let getSelfInfo: @Sendable () async throws -> SelfInfo + let importPrivateKey: @Sendable (Data) async throws -> Void + let setNodeName: @Sendable (String) async throws -> Void + let setLocation: @Sendable (_ latitude: Double, _ longitude: Double) async throws -> Void + let setOtherParams: @Sendable (MeshCoreNodeConfig.OtherSettings) async throws -> Void + let resolveEffectiveRadioID: @Sendable (_ original: UUID, _ didImportPrivateKey: Bool) async throws -> UUID + let setRadioParams: @Sendable (MeshCoreNodeConfig.RadioSettings) async throws -> Void + let setTxPower: @Sendable (Int8) async throws -> Void + let setChannel: @Sendable (_ radioID: UUID, _ write: ConfigImportPlan.ChannelWrite) async throws -> Void + let addContact: @Sendable (_ radioID: UUID, _ contact: MeshContact) async throws -> Void } // MARK: - Pref/radio diff gates (testable seam) @@ -459,31 +460,31 @@ struct ConfigImportWriters: Sendable { /// /// Pure and synchronous so the per-setter decisions can be asserted against a `SelfInfo` fixture. func nodeNameNeedsWrite(_ name: String, current: SelfInfo) -> Bool { - // Compare the truncated name the device actually stores: setNodeName truncates to the firmware - // field width before writing, so a longer name whose stored bytes already match must not re-commit. - name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) != current.name + // Compare the truncated name the device actually stores: setNodeName truncates to the firmware + // field width before writing, so a longer name whose stored bytes already match must not re-commit. + name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) != current.name } func locationNeedsWrite(_ position: ConfigImportPlan.Coordinate, current: SelfInfo) -> Bool { - // Compare the integer the device persists, not raw doubles, so two coordinates that encode - // identically are treated as equal. - PacketBuilder.scaledCoordinate(position.latitude, in: PacketBuilder.latitudeRange) - != PacketBuilder.scaledCoordinate(current.latitude, in: PacketBuilder.latitudeRange) + // Compare the integer the device persists, not raw doubles, so two coordinates that encode + // identically are treated as equal. + PacketBuilder.scaledCoordinate(position.latitude, in: PacketBuilder.latitudeRange) + != PacketBuilder.scaledCoordinate(current.latitude, in: PacketBuilder.latitudeRange) || PacketBuilder.scaledCoordinate(position.longitude, in: PacketBuilder.longitudeRange) - != PacketBuilder.scaledCoordinate(current.longitude, in: PacketBuilder.longitudeRange) + != PacketBuilder.scaledCoordinate(current.longitude, in: PacketBuilder.longitudeRange) } func radioParamsNeedWrite(_ radio: MeshCoreNodeConfig.RadioSettings, current: SelfInfo) -> Bool { - // Compare through the same MHz/kHz scaling the export used, so a re-import of an unchanged - // radio matches. TX power is gated separately (it can legitimately differ alone), so normalize - // it out and let the struct's Equatable cover every other field as the type evolves. - var expected = NodeConfigService.buildRadioSettings(from: current) - expected.txPower = radio.txPower - return radio != expected + // Compare through the same MHz/kHz scaling the export used, so a re-import of an unchanged + // radio matches. TX power is gated separately (it can legitimately differ alone), so normalize + // it out and let the struct's Equatable cover every other field as the type evolves. + var expected = NodeConfigService.buildRadioSettings(from: current) + expected.txPower = radio.txPower + return radio != expected } func txPowerNeedsWrite(_ radio: MeshCoreNodeConfig.RadioSettings, current: SelfInfo) -> Bool { - radio.txPower != current.txPower + radio.txPower != current.txPower } /// Executes a validated `ConfigImportPlan` in safe order: identity (so a private-key import can @@ -494,172 +495,171 @@ func txPowerNeedsWrite(_ radio: MeshCoreNodeConfig.RadioSettings, current: SelfI /// /// Extracted as a free function taking write closures (mirroring `resolveEffectiveRadioID`) so the /// ordering, progress, and cancellation behavior are unit-testable without a live session. -internal func executeConfigImport( - plan: ConfigImportPlan, - sections: ConfigSections, - radioID: UUID, - writers: ConfigImportWriters, - logger: Logger, - onProgress: (@Sendable (ImportProgress) -> Void)?, - notifyContactsChanged: @Sendable () async -> Void = {} +func executeConfigImport( + plan: ConfigImportPlan, + sections: ConfigSections, + radioID: UUID, + writers: ConfigImportWriters, + logger: Logger, + onProgress: (@Sendable (ImportProgress) -> Void)?, + notifyContactsChanged: @Sendable () async -> Void = {} ) async throws { - let totalSteps = NodeConfigService.stepCount(for: plan) - var currentStep = 0 - func progress(_ step: ImportStep) { - currentStep += 1 - onProgress?(ImportProgress(step: step, current: currentStep, total: totalSteps)) - } - func checkCancellation() throws { - guard !Task.isCancelled else { throw CancellationError() } - } - - // Read current device state once so the pref/radio writes below can be diff-gated against it. - // Only fetched when a section that carries a savePrefs commit is present; otherParams gates - // itself inside `importOtherParams` (where the merge lives), so it is not a trigger here. - let needsPrefState = plan.nodeName != nil || plan.position != nil || plan.radioSettings != nil - let prefState = needsPrefState ? try await writers.getSelfInfo() : nil - - var effectiveRadioID = radioID - if let privateKey = plan.importPrivateKey { - try checkCancellation() - try await writers.importPrivateKey(privateKey) - progress(.privateKey) - logger.info("Imported private key") - } - if let name = plan.nodeName { - try checkCancellation() - if prefState.map({ nodeNameNeedsWrite(name, current: $0) }) ?? true { - try await writers.setNodeName(name) - logger.info("Set node name: \(name)") - } else { - logger.info("Skipped node name (already \(name))") - } - progress(.nodeName) - } - if sections.nodeIdentity { - effectiveRadioID = try await writers.resolveEffectiveRadioID(radioID, plan.importPrivateKey != nil) - if effectiveRadioID != radioID { - logger.info("Post-identity reconciliation reassigned radioID to \(effectiveRadioID)") - } - } - - if let position = plan.position { - try checkCancellation() - if prefState.map({ locationNeedsWrite(position, current: $0) }) ?? true { - try await writers.setLocation(position.latitude, position.longitude) - logger.info("Set position: \(position.latitude), \(position.longitude)") - } else { - logger.info("Skipped position (unchanged)") - } - progress(.position) - } - - if let other = plan.otherSettings { - try checkCancellation() - try await writers.setOtherParams(other) - progress(.otherParameters) - logger.info("Set other params") + let totalSteps = NodeConfigService.stepCount(for: plan) + var currentStep = 0 + func progress(_ step: ImportStep) { + currentStep += 1 + onProgress?(ImportProgress(step: step, current: currentStep, total: totalSteps)) + } + func checkCancellation() throws { + guard !Task.isCancelled else { throw CancellationError() } + } + + // Read current device state once so the pref/radio writes below can be diff-gated against it. + // Only fetched when a section that carries a savePrefs commit is present; otherParams gates + // itself inside `importOtherParams` (where the merge lives), so it is not a trigger here. + let needsPrefState = plan.nodeName != nil || plan.position != nil || plan.radioSettings != nil + let prefState = needsPrefState ? try await writers.getSelfInfo() : nil + + var effectiveRadioID = radioID + if let privateKey = plan.importPrivateKey { + try checkCancellation() + try await writers.importPrivateKey(privateKey) + progress(.privateKey) + logger.info("Imported private key") + } + if let name = plan.nodeName { + try checkCancellation() + if prefState.map({ nodeNameNeedsWrite(name, current: $0) }) ?? true { + try await writers.setNodeName(name) + logger.info("Set node name: \(name)") + } else { + logger.info("Skipped node name (already \(name))") } - - for write in plan.channelWrites { - try checkCancellation() - try await writers.setChannel(effectiveRadioID, write) - progress(.channel(name: write.name)) - logger.info("Set channel \(write.index): \(write.name)") + progress(.nodeName) + } + if sections.nodeIdentity { + effectiveRadioID = try await writers.resolveEffectiveRadioID(radioID, plan.importPrivateKey != nil) + if effectiveRadioID != radioID { + logger.info("Post-identity reconciliation reassigned radioID to \(effectiveRadioID)") } - - for contact in plan.contactRecords { - try checkCancellation() - try await writers.addContact(effectiveRadioID, contact) - // writers.addContact only throws if the device add failed; a local-save failure is - // logged and swallowed there, so reaching this line means the contact is on the - // device and the progress yield must fire. - progress(.contact(name: contact.advertisedName)) - logger.info("Imported contact: \(contact.advertisedName)") + } + + if let position = plan.position { + try checkCancellation() + if prefState.map({ locationNeedsWrite(position, current: $0) }) ?? true { + try await writers.setLocation(position.latitude, position.longitude) + logger.info("Set position: \(position.latitude), \(position.longitude)") + } else { + logger.info("Skipped position (unchanged)") } - if sections.contacts { - // Refresh contacts as soon as they are written, before the radio step, so a later radio - // failure does not suppress the UI update for contacts that already landed. - await notifyContactsChanged() + progress(.position) + } + + if let other = plan.otherSettings { + try checkCancellation() + try await writers.setOtherParams(other) + progress(.otherParameters) + logger.info("Set other params") + } + + for write in plan.channelWrites { + try checkCancellation() + try await writers.setChannel(effectiveRadioID, write) + progress(.channel(name: write.name)) + logger.info("Set channel \(write.index): \(write.name)") + } + + for contact in plan.contactRecords { + try checkCancellation() + try await writers.addContact(effectiveRadioID, contact) + // writers.addContact only throws if the device add failed; a local-save failure is + // logged and swallowed there, so reaching this line means the contact is on the + // device and the progress yield must fire. + progress(.contact(name: contact.advertisedName)) + logger.info("Imported contact: \(contact.advertisedName)") + } + if sections.contacts { + // Refresh contacts as soon as they are written, before the radio step, so a later radio + // failure does not suppress the UI update for contacts that already landed. + await notifyContactsChanged() + } + + // Radio goes last (minimizes mesh isolation on BLE disconnect). Params and TX power are gated + // independently: a change to only one re-commits only the pref it touched. + if let radio = plan.radioSettings { + try checkCancellation() + if prefState.map({ radioParamsNeedWrite(radio, current: $0) }) ?? true { + try await writers.setRadioParams(radio) + logger.info("Set radio params") + } else { + logger.info("Skipped radio params (unchanged)") } - - // Radio goes last (minimizes mesh isolation on BLE disconnect). Params and TX power are gated - // independently: a change to only one re-commits only the pref it touched. - if let radio = plan.radioSettings { - try checkCancellation() - if prefState.map({ radioParamsNeedWrite(radio, current: $0) }) ?? true { - try await writers.setRadioParams(radio) - logger.info("Set radio params") - } else { - logger.info("Skipped radio params (unchanged)") - } - progress(.radioParameters) - - try checkCancellation() - if prefState.map({ txPowerNeedsWrite(radio, current: $0) }) ?? true { - do { - try await writers.setTxPower(radio.txPower) - } catch { - // Params already retuned the node; flag that power did not follow so the failure - // isn't mistaken for "radio unchanged." - logger.error("Radio params applied but TX power did not: \(error.localizedDescription)") - throw error - } - logger.info("Set TX power: \(radio.txPower)") - } else { - logger.info("Skipped TX power (already \(radio.txPower))") - } - progress(.txPower) + progress(.radioParameters) + + try checkCancellation() + if prefState.map({ txPowerNeedsWrite(radio, current: $0) }) ?? true { + do { + try await writers.setTxPower(radio.txPower) + } catch { + // Params already retuned the node; flag that power did not follow so the failure + // isn't mistaken for "radio unchanged." + logger.error("Radio params applied but TX power did not: \(error.localizedDescription)") + throw error + } + logger.info("Set TX power: \(radio.txPower)") + } else { + logger.info("Skipped TX power (already \(radio.txPower))") } + progress(.txPower) + } } // MARK: - Static Builders (testable without actor) -extension NodeConfigService { - /// Builds radio settings from SelfInfo. - public static func buildRadioSettings(from info: SelfInfo) -> MeshCoreNodeConfig.RadioSettings { - MeshCoreNodeConfig.RadioSettings( - frequency: UInt32((info.radioFrequency * 1000).rounded()), - bandwidth: UInt32((info.radioBandwidth * 1000).rounded()), - spreadingFactor: info.radioSpreadingFactor, - codingRate: info.radioCodingRate, - txPower: info.txPower - ) +public extension NodeConfigService { + /// Builds radio settings from SelfInfo. + static func buildRadioSettings(from info: SelfInfo) -> MeshCoreNodeConfig.RadioSettings { + MeshCoreNodeConfig.RadioSettings( + frequency: UInt32((info.radioFrequency * 1000).rounded()), + bandwidth: UInt32((info.radioBandwidth * 1000).rounded()), + spreadingFactor: info.radioSpreadingFactor, + codingRate: info.radioCodingRate, + txPower: info.txPower + ) + } + + /// Builds other settings from SelfInfo, matching official companion app format. + static func buildOtherSettings(from info: SelfInfo) -> MeshCoreNodeConfig.OtherSettings { + MeshCoreNodeConfig.OtherSettings( + manualAddContacts: info.manualAddContacts ? 1 : 0, + advertLocationPolicy: info.advertisementLocationPolicy + ) + } + + /// Builds a contact config from a MeshContact. + internal static func buildContactConfig(from contact: MeshContact) -> MeshCoreNodeConfig.ContactConfig { + let outPath: String? = if contact.isFloodPath { + nil + } else if contact.pathByteLength > 0 && !contact.outPath.isEmpty { + contact.outPath.prefix(contact.pathByteLength).hexString + } else { + "" } - /// Builds other settings from SelfInfo, matching official companion app format. - public static func buildOtherSettings(from info: SelfInfo) -> MeshCoreNodeConfig.OtherSettings { - MeshCoreNodeConfig.OtherSettings( - manualAddContacts: info.manualAddContacts ? 1 : 0, - advertLocationPolicy: info.advertisementLocationPolicy - ) - } - - /// Builds a contact config from a MeshContact. - static func buildContactConfig(from contact: MeshContact) -> MeshCoreNodeConfig.ContactConfig { - let outPath: String? - if contact.isFloodPath { - outPath = nil - } else if contact.pathByteLength > 0 && !contact.outPath.isEmpty { - outPath = contact.outPath.prefix(contact.pathByteLength).hexString - } else { - outPath = "" - } - - // Extract hash mode from encoded outPathLength (upper 2 bits) - let pathHashMode: UInt8? = contact.isFloodPath ? nil : contact.outPathLength >> 6 - - return MeshCoreNodeConfig.ContactConfig( - type: contact.typeRawValue, - name: contact.advertisedName, - publicKey: contact.publicKey.hexString, - flags: contact.flags.rawValue, - latitude: String(contact.latitude), - longitude: String(contact.longitude), - lastAdvert: UInt32(contact.lastAdvertisement.timeIntervalSince1970), - lastModified: UInt32(contact.lastModified.timeIntervalSince1970), - outPath: outPath, - pathHashMode: pathHashMode - ) - } + // Extract hash mode from encoded outPathLength (upper 2 bits) + let pathHashMode: UInt8? = contact.isFloodPath ? nil : contact.outPathLength >> 6 + + return MeshCoreNodeConfig.ContactConfig( + type: contact.typeRawValue, + name: contact.advertisedName, + publicKey: contact.publicKey.hexString, + flags: contact.flags.rawValue, + latitude: String(contact.latitude), + longitude: String(contact.longitude), + lastAdvert: UInt32(contact.lastAdvertisement.timeIntervalSince1970), + lastModified: UInt32(contact.lastModified.timeIntervalSince1970), + outPath: outPath, + pathHashMode: pathHashMode + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift b/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift index cdffda91..3be09d52 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift @@ -5,204 +5,203 @@ import Foundation /// mapping, and success/error classification. Builds on `CLIResponse` and /// holds no state, so every function is directly unit-testable. public enum NodeSettingsResponseParser { - - // MARK: - Settings Fields - - /// A node settings field recoverable from an uncorrelated CLI response. - public enum SettingsField: Sendable { - case radio - case txPower - case firmwareVersion - case deviceTime - case latitude - case longitude - case name - case ownerInfo - - /// The CLI query whose response shape this field matches. - var query: String { - switch self { - case .radio: return "get radio" - case .txPower: return "get tx" - case .firmwareVersion: return "ver" - case .deviceTime: return "clock" - case .latitude: return "get lat" - case .longitude: return "get lon" - case .name: return "get name" - case .ownerInfo: return "get owner.info" - } - } + // MARK: - Settings Fields + + /// A node settings field recoverable from an uncorrelated CLI response. + public enum SettingsField: Sendable { + case radio + case txPower + case firmwareVersion + case deviceTime + case latitude + case longitude + case name + case ownerInfo + + /// The CLI query whose response shape this field matches. + var query: String { + switch self { + case .radio: "get radio" + case .txPower: "get tx" + case .firmwareVersion: "ver" + case .deviceTime: "clock" + case .latitude: "get lat" + case .longitude: "get lon" + case .name: "get name" + case .ownerInfo: "get owner.info" + } } - - /// A parsed value for one of the shared settings fields. - public enum SettingsValue: Equatable, Sendable { - case radio(frequency: Double, bandwidth: Double, spreadingFactor: Int, codingRate: Int) - case txPower(Int) - case firmwareVersion(String) - case deviceTime(String) - case latitude(Double) - case longitude(Double) - case name(String) - case ownerInfo(String) + } + + /// A parsed value for one of the shared settings fields. + public enum SettingsValue: Equatable, Sendable { + case radio(frequency: Double, bandwidth: Double, spreadingFactor: Int, codingRate: Int) + case txPower(Int) + case firmwareVersion(String) + case deviceTime(String) + case latitude(Double) + case longitude(Double) + case name(String) + case ownerInfo(String) + } + + /// Matches an uncorrelated (late) CLI response against the given fields in + /// order and returns the first field value it parses as. + /// + /// Order matters: `name` matches any free-form text, so callers must list + /// numeric fields (`latitude`, `longitude`) before it to avoid capturing a + /// stray number as the node name. + public static func firstSettingsValue( + in response: String, + checking fields: [SettingsField] + ) -> SettingsValue? { + for field in fields { + let parsed = CLIResponse.parse(response, forQuery: field.query) + switch (field, parsed) { + case let (.radio, .radio(frequency, bandwidth, spreadingFactor, codingRate)): + return .radio( + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate + ) + case let (.txPower, .txPower(power)): + return .txPower(power) + case let (.firmwareVersion, .version(version)): + return .firmwareVersion(version) + case let (.deviceTime, .deviceTime(time)): + return .deviceTime(time) + case let (.latitude, .latitude(latitude)): + return .latitude(latitude) + case let (.longitude, .longitude(longitude)): + return .longitude(longitude) + case let (.name, .name(name)): + return .name(name) + case let (.ownerInfo, .ownerInfo(info)): + return .ownerInfo(info) + default: + continue + } } - - /// Matches an uncorrelated (late) CLI response against the given fields in - /// order and returns the first field value it parses as. - /// - /// Order matters: `name` matches any free-form text, so callers must list - /// numeric fields (`latitude`, `longitude`) before it to avoid capturing a - /// stray number as the node name. - public static func firstSettingsValue( - in response: String, - checking fields: [SettingsField] - ) -> SettingsValue? { - for field in fields { - let parsed = CLIResponse.parse(response, forQuery: field.query) - switch (field, parsed) { - case (.radio, .radio(let frequency, let bandwidth, let spreadingFactor, let codingRate)): - return .radio( - frequency: frequency, - bandwidth: bandwidth, - spreadingFactor: spreadingFactor, - codingRate: codingRate - ) - case (.txPower, .txPower(let power)): - return .txPower(power) - case (.firmwareVersion, .version(let version)): - return .firmwareVersion(version) - case (.deviceTime, .deviceTime(let time)): - return .deviceTime(time) - case (.latitude, .latitude(let latitude)): - return .latitude(latitude) - case (.longitude, .longitude(let longitude)): - return .longitude(longitude) - case (.name, .name(let name)): - return .name(name) - case (.ownerInfo, .ownerInfo(let info)): - return .ownerInfo(info) - default: - continue - } - } - return nil + return nil + } + + // MARK: - Behavior Fields + + /// A repeater/room behavior field recovered from a late CLI response. + public enum BehaviorValue: Equatable, Sendable { + case advertInterval(Int) + case floodAdvertInterval(Int) + case floodMax(Int) + } + + /// Try to parse a late response as one of the shared behavior fields. + /// Returns `nil` if the response didn't match any field that's still missing. + public static func behaviorLateResponse( + _ response: String, + hasAdvertInterval: Bool, + hasFloodInterval: Bool, + hasFloodMaxHops: Bool + ) -> BehaviorValue? { + if !hasAdvertInterval { + if case let .advertInterval(interval) = CLIResponse.parse(response, forQuery: "get advert.interval") { + return .advertInterval(interval) + } } - - // MARK: - Behavior Fields - - /// A repeater/room behavior field recovered from a late CLI response. - public enum BehaviorValue: Equatable, Sendable { - case advertInterval(Int) - case floodAdvertInterval(Int) - case floodMax(Int) + if !hasFloodInterval { + if case let .floodAdvertInterval(interval) = CLIResponse.parse( + response, forQuery: "get flood.advert.interval" + ) { + return .floodAdvertInterval(interval) + } } - - /// Try to parse a late response as one of the shared behavior fields. - /// Returns `nil` if the response didn't match any field that's still missing. - public static func behaviorLateResponse( - _ response: String, - hasAdvertInterval: Bool, - hasFloodInterval: Bool, - hasFloodMaxHops: Bool - ) -> BehaviorValue? { - if !hasAdvertInterval { - if case .advertInterval(let interval) = CLIResponse.parse(response, forQuery: "get advert.interval") { - return .advertInterval(interval) - } - } - if !hasFloodInterval { - if case .floodAdvertInterval(let interval) = CLIResponse.parse( - response, forQuery: "get flood.advert.interval" - ) { - return .floodAdvertInterval(interval) - } - } - if !hasFloodMaxHops { - if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { - return .floodMax(hops) - } - } - return nil + if !hasFloodMaxHops { + if case let .floodMax(hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + return .floodMax(hops) + } } - - // MARK: - Device Clock - - private static let clockResponseDateFormat = "HH:mm d/M/yyyy" - - /// Parses a firmware clock response like "06:40 - 18/4/2025 UTC" into a `Date`. - /// Returns `nil` when the text doesn't carry the expected UTC clock shape. - public static func utcDate(fromClockResponse response: String) -> Date? { - // Regex isn't Sendable, so the literal lives here instead of in a static. - let clockResponseRegex = /(\d{1,2}:\d{2}) - (\d{1,2}\/\d{1,2}\/\d{4}) UTC/ - guard let match = response.firstMatch(of: clockResponseRegex) else { return nil } - - // A fresh formatter per call keeps this Sendable; clock parsing is rare. - let formatter = DateFormatter() - formatter.dateFormat = clockResponseDateFormat - formatter.timeZone = TimeZone(identifier: "UTC") - return formatter.date(from: "\(match.output.1) \(match.output.2)") - } - - // MARK: - Clock Sync - - /// Outcome of a `clock sync` command response. - public enum ClockSyncOutcome: Equatable, Sendable { - case synced - /// Firmware refused the sync because its clock is ahead of the phone's. - case clockAhead - /// Firmware reported an error; `message` is the response with the - /// "ERR: " prefix stripped and may be empty. - case failed(message: String) - case unexpected - } - - private static let clockAheadErrorFragment = "clock cannot go backwards" - private static let cliErrorPrefix = "ERR: " - - /// Classifies a `clock sync` response into a typed outcome. - public static func classifyClockSyncResponse(_ response: String) -> ClockSyncOutcome { - switch CLIResponse.parse(response) { - case .ok: - return .synced - case .error(let message): - if message.contains(clockAheadErrorFragment) { - return .clockAhead - } - return .failed(message: message.replacing(cliErrorPrefix, with: "")) - default: - return .unexpected - } + return nil + } + + // MARK: - Device Clock + + private static let clockResponseDateFormat = "HH:mm d/M/yyyy" + + /// Parses a firmware clock response like "06:40 - 18/4/2025 UTC" into a `Date`. + /// Returns `nil` when the text doesn't carry the expected UTC clock shape. + public static func utcDate(fromClockResponse response: String) -> Date? { + // Regex isn't Sendable, so the literal lives here instead of in a static. + let clockResponseRegex = /(\d{1,2}:\d{2}) - (\d{1,2}\/\d{1,2}\/\d{4}) UTC/ + guard let match = response.firstMatch(of: clockResponseRegex) else { return nil } + + // A fresh formatter per call keeps this Sendable; clock parsing is rare. + let formatter = DateFormatter() + formatter.dateFormat = clockResponseDateFormat + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.date(from: "\(match.output.1) \(match.output.2)") + } + + // MARK: - Clock Sync + + /// Outcome of a `clock sync` command response. + public enum ClockSyncOutcome: Equatable, Sendable { + case synced + /// Firmware refused the sync because its clock is ahead of the phone's. + case clockAhead + /// Firmware reported an error; `message` is the response with the + /// "ERR: " prefix stripped and may be empty. + case failed(message: String) + case unexpected + } + + private static let clockAheadErrorFragment = "clock cannot go backwards" + private static let cliErrorPrefix = "ERR: " + + /// Classifies a `clock sync` response into a typed outcome. + public static func classifyClockSyncResponse(_ response: String) -> ClockSyncOutcome { + switch CLIResponse.parse(response) { + case .ok: + return .synced + case let .error(message): + if message.contains(clockAheadErrorFragment) { + return .clockAhead + } + return .failed(message: message.replacing(cliErrorPrefix, with: "")) + default: + return .unexpected } - - // MARK: - Password - - /// Firmware echoes "password now: {pw}" on success instead of "OK". - private static let passwordChangedPrefix = "password now:" - - /// Whether a `password` command response indicates the change was accepted. - public static func isPasswordChangeSuccessful(_ response: String) -> Bool { - switch CLIResponse.parse(response) { - case .ok: - return true - case .raw(let text): - return text.hasPrefix(passwordChangedPrefix) - default: - return false - } + } + + // MARK: - Password + + /// Firmware echoes "password now: {pw}" on success instead of "OK". + private static let passwordChangedPrefix = "password now:" + + /// Whether a `password` command response indicates the change was accepted. + public static func isPasswordChangeSuccessful(_ response: String) -> Bool { + switch CLIResponse.parse(response) { + case .ok: + true + case let .raw(text): + text.hasPrefix(passwordChangedPrefix) + default: + false } + } - // MARK: - Owner Info + // MARK: - Owner Info - /// Firmware stores owner info as a single line with "|" separating rows. - private static let ownerInfoWireSeparator = "|" - private static let ownerInfoDisplaySeparator = "\n" + /// Firmware stores owner info as a single line with "|" separating rows. + private static let ownerInfoWireSeparator = "|" + private static let ownerInfoDisplaySeparator = "\n" - /// Maps the wire form ("|"-separated) to the multi-line display form. - public static func displayOwnerInfo(fromWire wire: String) -> String { - wire.replacing(ownerInfoWireSeparator, with: ownerInfoDisplaySeparator) - } + /// Maps the wire form ("|"-separated) to the multi-line display form. + public static func displayOwnerInfo(fromWire wire: String) -> String { + wire.replacing(ownerInfoWireSeparator, with: ownerInfoDisplaySeparator) + } - /// Maps the multi-line display form back to the "|"-separated wire form. - public static func wireOwnerInfo(fromDisplay display: String) -> String { - display.replacing(ownerInfoDisplaySeparator, with: ownerInfoWireSeparator) - } + /// Maps the multi-line display form back to the "|"-separated wire form. + public static func wireOwnerInfo(fromDisplay display: String) -> String { + display.replacing(ownerInfoDisplaySeparator, with: ownerInfoWireSeparator) + } } diff --git a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift index 81cb1d26..df83dda3 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift @@ -3,77 +3,79 @@ import OSLog /// Service for managing node status snapshots with throttled capture. public actor NodeSnapshotService { - private let dataStore: any NodeSnapshotPersisting - private let logger = Logger(subsystem: "com.mc1", category: "NodeSnapshotService") + private let dataStore: any NodeSnapshotPersisting + private let logger = Logger(subsystem: "com.mc1", category: "NodeSnapshotService") - init(dataStore: any NodeSnapshotPersisting) { - self.dataStore = dataStore - } + init(dataStore: any NodeSnapshotPersisting) { + self.dataStore = dataStore + } - /// Capture a status, telemetry, and/or neighbor reading for a node, enriching - /// the latest in-window snapshot or inserting a new one. Returns the snapshot - /// ID so callers can target later enrichment, or nil on persistence failure. - /// The throttle check and the write are atomic in the store, so concurrent - /// captures never duplicate an in-window row. - public func recordSnapshot( - nodePublicKey: Data, - status: NodeStatusMetrics? = nil, - telemetry: [TelemetrySnapshotEntry]? = nil, - neighbors: [NeighborSnapshotEntry]? = nil - ) async -> UUID? { - do { - return try await dataStore.recordNodeStatusSnapshot( - nodePublicKey: nodePublicKey, - status: status, - telemetry: telemetry, - neighbors: neighbors - ) - } catch { - logger.error("Failed to record snapshot: \(error)") - return nil - } + /// Capture a status, telemetry, and/or neighbor reading for a node, enriching + /// the latest in-window snapshot or inserting a new one. Returns the snapshot + /// ID so callers can target later enrichment, or nil on persistence failure. + /// The throttle check and the write are atomic in the store, so concurrent + /// captures never duplicate an in-window row. + public func recordSnapshot( + nodePublicKey: Data, + status: NodeStatusMetrics? = nil, + telemetry: [TelemetrySnapshotEntry]? = nil, + neighbors: [NeighborSnapshotEntry]? = nil + ) async -> UUID? { + do { + return try await dataStore.recordNodeStatusSnapshot( + nodePublicKey: nodePublicKey, + status: status, + telemetry: telemetry, + neighbors: neighbors + ) + } catch { + logger.error("Failed to record snapshot: \(error)") + return nil } + } - /// Fetch the most recent snapshot carrying neighbor data, for neighbor delta - /// display. Skips status- or telemetry-only rows and the current in-window capture. - public func previousNeighborSnapshot(for nodePublicKey: Data) async -> NodeStatusSnapshotDTO? { - do { - return try await dataStore.fetchPreviousNeighborSnapshot(nodePublicKey: nodePublicKey) - } catch { - logger.error("Failed to fetch previous neighbor snapshot: \(error)") - return nil - } + /// The neighbor baseline for a node: the previous neighbor-bearing snapshot (for + /// the SNR delta) plus every neighbor prefix seen across history (for the "New" + /// badge). Skips status- or telemetry-only rows and the current in-window capture. + public func neighborBaseline(for nodePublicKey: Data) + async -> (previous: NodeStatusSnapshotDTO?, seenPrefixes: Set) { + do { + return try await dataStore.fetchNeighborBaseline(nodePublicKey: nodePublicKey) + } catch { + logger.error("Failed to fetch neighbor baseline: \(error)") + return (nil, []) } + } - /// Fetch the most recent snapshot carrying status fields, for the status delta. - /// Skips neighbor- or telemetry-only rows so the delta is taken against the - /// previous actual status reading rather than blanking out. - public func previousStatusSnapshot(for nodePublicKey: Data, before date: Date) async -> NodeStatusSnapshotDTO? { - do { - return try await dataStore.fetchPreviousStatusSnapshot(nodePublicKey: nodePublicKey, before: date) - } catch { - logger.error("Failed to fetch previous status snapshot: \(error)") - return nil - } + /// Fetch the most recent snapshot carrying status fields, for the status delta. + /// Skips neighbor- or telemetry-only rows so the delta is taken against the + /// previous actual status reading rather than blanking out. + public func previousStatusSnapshot(for nodePublicKey: Data, before date: Date) async -> NodeStatusSnapshotDTO? { + do { + return try await dataStore.fetchPreviousStatusSnapshot(nodePublicKey: nodePublicKey, before: date) + } catch { + logger.error("Failed to fetch previous status snapshot: \(error)") + return nil } + } - /// Fetch all snapshots for a node, optionally filtered by date range. - public func fetchSnapshots(for nodePublicKey: Data, since: Date? = nil) async -> [NodeStatusSnapshotDTO] { - do { - return try await dataStore.fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: since) - } catch { - logger.error("Failed to fetch snapshots: \(error)") - return [] - } + /// Fetch all snapshots for a node, optionally filtered by date range. + public func fetchSnapshots(for nodePublicKey: Data, since: Date? = nil) async -> [NodeStatusSnapshotDTO] { + do { + return try await dataStore.fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: since) + } catch { + logger.error("Failed to fetch snapshots: \(error)") + return [] } + } - /// Delete snapshots older than the given date. - public func pruneOldSnapshots(olderThan date: Date) async { - do { - try await dataStore.deleteOldNodeStatusSnapshots(olderThan: date) - logger.info("Pruned snapshots older than \(date)") - } catch { - logger.error("Failed to prune old snapshots: \(error)") - } + /// Delete snapshots older than the given date. + public func pruneOldSnapshots(olderThan date: Date) async { + do { + try await dataStore.deleteOldNodeStatusSnapshots(olderThan: date) + logger.info("Pruned snapshots older than \(date)") + } catch { + logger.error("Failed to prune old snapshots: \(error)") } + } } diff --git a/MC1Services/Sources/MC1Services/Services/NotificationActionHandler.swift b/MC1Services/Sources/MC1Services/Services/NotificationActionHandler.swift index 168e26a8..576a8fe5 100644 --- a/MC1Services/Sources/MC1Services/Services/NotificationActionHandler.swift +++ b/MC1Services/Sources/MC1Services/Services/NotificationActionHandler.swift @@ -9,237 +9,238 @@ import os /// `configure(isConnectionReady:localNodeName:)`. @MainActor public final class NotificationActionHandler { - - private static let logger = Logger(subsystem: "com.mc1", category: "NotificationActionHandler") - - // MARK: - Reaction Preview Truncation - - nonisolated static let reactionPreviewMaxLength = 50 - nonisolated static let reactionPreviewKeepLength = 47 - nonisolated static let reactionPreviewEllipsis = "..." - - /// Truncates a reacted-to message for display in the notification body. - nonisolated static func reactionPreview(for text: String) -> String { - text.count > reactionPreviewMaxLength - ? String(text.prefix(reactionPreviewKeepLength)) + reactionPreviewEllipsis - : text + private static let logger = Logger(subsystem: "com.mc1", category: "NotificationActionHandler") + + // MARK: - Reaction Preview Truncation + + nonisolated static let reactionPreviewMaxLength = 50 + nonisolated static let reactionPreviewKeepLength = 47 + nonisolated static let reactionPreviewEllipsis = "..." + + /// Truncates a reacted-to message for display in the notification body. + nonisolated static func reactionPreview(for text: String) -> String { + text.count > reactionPreviewMaxLength + ? String(text.prefix(reactionPreviewKeepLength)) + reactionPreviewEllipsis + : text + } + + // MARK: - Dependencies + + private let dataStore: any PersistenceStoreProtocol + private let messageService: MessageService + private let notificationService: NotificationService + private let roomServerService: RoomServerService + private let syncCoordinator: SyncCoordinator + + /// Whether the connection is ready for sends. Injected as a closure so + /// every call reads the live app-facing connection state. + private var isConnectionReady: @MainActor () -> Bool = { false } + + /// The connected device's node name, used to suppress self-reaction + /// notifications. Nil means `configure` has not yet been called; a + /// non-nil closure that returns nil means configured but the device name + /// is not yet known. Injected as a closure because identity reconciliation + /// can refresh the connected device at runtime. + private var localNodeName: (@MainActor () -> String?)? + + public init( + dataStore: any PersistenceStoreProtocol, + messageService: MessageService, + notificationService: NotificationService, + roomServerService: RoomServerService, + syncCoordinator: SyncCoordinator + ) { + self.dataStore = dataStore + self.messageService = messageService + self.notificationService = notificationService + self.roomServerService = roomServerService + self.syncCoordinator = syncCoordinator + } + + /// Injects the app-layer inputs. Idempotent; re-run per connection when + /// notification handling is configured. + public func configure( + isConnectionReady: @escaping @MainActor () -> Bool, + localNodeName: @escaping @MainActor () -> String? + ) { + self.isConnectionReady = isConnectionReady + self.localNodeName = localNodeName + } + + /// Whether `configure` has been called. Used to distinguish the pre-wiring + /// window from the steady-state where node name may legitimately be nil. + var isConfigured: Bool { + localNodeName != nil + } + + // MARK: - Quick Reply + + public func handleQuickReply(contactID: UUID, text: String) async { + guard let contact = try? await dataStore.fetchContact(id: contactID) else { return } + + if isConnectionReady() { + do { + _ = try await messageService.sendDirectMessage(text: text, to: contact) + + // Clear unread state - user replied so they've seen the chat + try? await dataStore.clearUnreadCount(contactID: contactID) + await notificationService.removeDeliveredNotifications(forContactID: contactID) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + return + } catch { + // Fall through to draft handling + } } - // MARK: - Dependencies - - private let dataStore: any PersistenceStoreProtocol - private let messageService: MessageService - private let notificationService: NotificationService - private let roomServerService: RoomServerService - private let syncCoordinator: SyncCoordinator - - /// Whether the connection is ready for sends. Injected as a closure so - /// every call reads the live app-facing connection state. - private var isConnectionReady: @MainActor () -> Bool = { false } - - /// The connected device's node name, used to suppress self-reaction - /// notifications. Nil means `configure` has not yet been called; a - /// non-nil closure that returns nil means configured but the device name - /// is not yet known. Injected as a closure because identity reconciliation - /// can refresh the connected device at runtime. - private var localNodeName: (@MainActor () -> String?)? - - public init( - dataStore: any PersistenceStoreProtocol, - messageService: MessageService, - notificationService: NotificationService, - roomServerService: RoomServerService, - syncCoordinator: SyncCoordinator - ) { - self.dataStore = dataStore - self.messageService = messageService - self.notificationService = notificationService - self.roomServerService = roomServerService - self.syncCoordinator = syncCoordinator + notificationService.saveDraft(for: contactID, text: text) + await notificationService.postQuickReplyFailedNotification( + contactName: contact.displayName, + contactID: contactID + ) + } + + public func handleChannelQuickReply(radioID: UUID, channelIndex: UInt8, text: String) async { + // Fetch channel for display name in failure notification + let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) + let channelName = channelDisplayName(name: channel?.name, index: channelIndex) + + guard isConnectionReady() else { + await notificationService.postChannelQuickReplyFailedNotification( + channelName: channelName, + radioID: radioID, + channelIndex: channelIndex + ) + return } - /// Injects the app-layer inputs. Idempotent; re-run per connection when - /// notification handling is configured. - public func configure( - isConnectionReady: @escaping @MainActor () -> Bool, - localNodeName: @escaping @MainActor () -> String? - ) { - self.isConnectionReady = isConnectionReady - self.localNodeName = localNodeName + do { + _ = try await messageService.sendChannelMessage( + text: text, + channelIndex: channelIndex, + radioID: radioID + ) + + // Clear unread state - user replied so they've seen the channel + try? await dataStore.clearChannelUnreadCount(radioID: radioID, index: channelIndex) + await notificationService.removeDeliveredNotifications( + forChannelIndex: channelIndex, + radioID: radioID + ) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + } catch { + await notificationService.postChannelQuickReplyFailedNotification( + channelName: channelName, + radioID: radioID, + channelIndex: channelIndex + ) } - - /// Whether `configure` has been called. Used to distinguish the pre-wiring - /// window from the steady-state where node name may legitimately be nil. - var isConfigured: Bool { localNodeName != nil } - - // MARK: - Quick Reply - - public func handleQuickReply(contactID: UUID, text: String) async { - guard let contact = try? await dataStore.fetchContact(id: contactID) else { return } - - if isConnectionReady() { - do { - _ = try await messageService.sendDirectMessage(text: text, to: contact) - - // Clear unread state - user replied so they've seen the chat - try? await dataStore.clearUnreadCount(contactID: contactID) - await notificationService.removeDeliveredNotifications(forContactID: contactID) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - return - } catch { - // Fall through to draft handling - } - } - - notificationService.saveDraft(for: contactID, text: text) - await notificationService.postQuickReplyFailedNotification( - contactName: contact.displayName, - contactID: contactID - ) + } + + /// Resolves a channel's display name, preferring the stored name, then + /// the localized fallback, then a last-resort English literal. + func channelDisplayName(name: String?, index: UInt8) -> String { + name + ?? notificationService.strings?.defaultChannelName(index: Int(index)) + ?? "Channel \(index)" + } + + // MARK: - Mark as Read + + public func handleMarkAsRead(contactID: UUID, messageID: UUID) async { + do { + try await dataStore.markMessageAsRead(id: messageID) + try await dataStore.clearUnreadCount(contactID: contactID) + notificationService.removeDeliveredNotification(messageID: messageID) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + } catch { + // Silently ignore } - - public func handleChannelQuickReply(radioID: UUID, channelIndex: UInt8, text: String) async { - // Fetch channel for display name in failure notification - let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) - let channelName = channelDisplayName(name: channel?.name, index: channelIndex) - - guard isConnectionReady() else { - await notificationService.postChannelQuickReplyFailedNotification( - channelName: channelName, - radioID: radioID, - channelIndex: channelIndex - ) - return - } - - do { - _ = try await messageService.sendChannelMessage( - text: text, - channelIndex: channelIndex, - radioID: radioID - ) - - // Clear unread state - user replied so they've seen the channel - try? await dataStore.clearChannelUnreadCount(radioID: radioID, index: channelIndex) - await notificationService.removeDeliveredNotifications( - forChannelIndex: channelIndex, - radioID: radioID - ) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - } catch { - await notificationService.postChannelQuickReplyFailedNotification( - channelName: channelName, - radioID: radioID, - channelIndex: channelIndex - ) - } + } + + public func handleChannelMarkAsRead(radioID: UUID, channelIndex: UInt8, messageID: UUID) async { + do { + try await dataStore.markMessageAsRead(id: messageID) + try await dataStore.clearChannelUnreadCount(radioID: radioID, index: channelIndex) + notificationService.removeDeliveredNotification(messageID: messageID) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + } catch { + // Silently ignore } - - /// Resolves a channel's display name, preferring the stored name, then - /// the localized fallback, then a last-resort English literal. - func channelDisplayName(name: String?, index: UInt8) -> String { - name - ?? notificationService.strings?.defaultChannelName(index: Int(index)) - ?? "Channel \(index)" + } + + public func handleRoomMarkAsRead(sessionID: UUID, messageID: UUID) async { + do { + try await roomServerService.markAsRead(sessionID: sessionID) + notificationService.removeDeliveredNotification(messageID: messageID) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + } catch { + // Silently ignore + } + } + + // MARK: - Reactions + + /// Handle posting a notification when someone reacts to the user's message + public func handleReactionNotification(messageID: UUID) async { + // Suppress the notification entirely when configure() has not yet been + // called. Posting during that window risks notifying the user about their + // own reaction; missing a stranger's reaction for a moment is harmless. + guard let localNodeNameClosure = localNodeName else { + Self.logger.debug("Reaction notification suppressed: handler not yet configured") + return } - // MARK: - Mark as Read - - public func handleMarkAsRead(contactID: UUID, messageID: UUID) async { - do { - try await dataStore.markMessageAsRead(id: messageID) - try await dataStore.clearUnreadCount(contactID: contactID) - notificationService.removeDeliveredNotification(messageID: messageID) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - } catch { - // Silently ignore - } + // Fetch the message to check if it's outgoing + guard let message = try? await dataStore.fetchMessage(id: messageID), + message.direction == .outgoing else { + return } - public func handleChannelMarkAsRead(radioID: UUID, channelIndex: UInt8, messageID: UUID) async { - do { - try await dataStore.markMessageAsRead(id: messageID) - try await dataStore.clearChannelUnreadCount(radioID: radioID, index: channelIndex) - notificationService.removeDeliveredNotification(messageID: messageID) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - } catch { - // Silently ignore - } + // Fetch the latest reaction for this message + guard let reactions = try? await dataStore.fetchReactions(for: messageID, limit: 1), + let latestReaction = reactions.first else { + return } - public func handleRoomMarkAsRead(sessionID: UUID, messageID: UUID) async { - do { - try await roomServerService.markAsRead(sessionID: sessionID) - notificationService.removeDeliveredNotification(messageID: messageID) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - } catch { - // Silently ignore - } + // Check if this is a self-reaction (user reacting to their own message) + if let nodeName = localNodeNameClosure(), + latestReaction.senderName == nodeName { + return } - // MARK: - Reactions - - /// Handle posting a notification when someone reacts to the user's message - public func handleReactionNotification(messageID: UUID) async { - // Suppress the notification entirely when configure() has not yet been - // called. Posting during that window risks notifying the user about their - // own reaction; missing a stranger's reaction for a moment is harmless. - guard let localNodeNameClosure = localNodeName else { - Self.logger.debug("Reaction notification suppressed: handler not yet configured") - return - } - - // Fetch the message to check if it's outgoing - guard let message = try? await dataStore.fetchMessage(id: messageID), - message.direction == .outgoing else { - return - } - - // Fetch the latest reaction for this message - guard let reactions = try? await dataStore.fetchReactions(for: messageID, limit: 1), - let latestReaction = reactions.first else { - return - } - - // Check if this is a self-reaction (user reacting to their own message) - if let nodeName = localNodeNameClosure(), - latestReaction.senderName == nodeName { - return - } - - // Check mute status based on message type - let isMuted: Bool - if let contactID = message.contactID { - let contact = try? await dataStore.fetchContact(id: contactID) - isMuted = contact?.isMuted ?? false - } else if let channelIndex = message.channelIndex { - let channel = try? await dataStore.fetchChannel(radioID: message.radioID, index: channelIndex) - isMuted = channel?.isMuted ?? false - } else { - isMuted = false - } - - guard !isMuted else { return } - - let truncatedPreview = Self.reactionPreview(for: message.text) - let body = notificationService.strings?.reactionNotificationBody( - emoji: latestReaction.emoji, - messagePreview: truncatedPreview - ) ?? "Reacted \(latestReaction.emoji) to your message: \"\(truncatedPreview)\"" - - // Post the notification - await notificationService.postReactionNotification( - reactorName: latestReaction.senderName, - body: body, - messageID: messageID, - contactID: message.contactID, - channelIndex: message.channelIndex, - radioID: message.channelIndex != nil ? message.radioID : nil - ) + // Check mute status based on message type + let isMuted: Bool + if let contactID = message.contactID { + let contact = try? await dataStore.fetchContact(id: contactID) + isMuted = contact?.isMuted ?? false + } else if let channelIndex = message.channelIndex { + let channel = try? await dataStore.fetchChannel(radioID: message.radioID, index: channelIndex) + isMuted = channel?.isMuted ?? false + } else { + isMuted = false } + + guard !isMuted else { return } + + let truncatedPreview = Self.reactionPreview(for: message.text) + let body = notificationService.strings?.reactionNotificationBody( + emoji: latestReaction.emoji, + messagePreview: truncatedPreview + ) ?? "Reacted \(latestReaction.emoji) to your message: \"\(truncatedPreview)\"" + + // Post the notification + await notificationService.postReactionNotification( + reactorName: latestReaction.senderName, + body: body, + messageID: messageID, + contactID: message.contactID, + channelIndex: message.channelIndex, + radioID: message.channelIndex != nil ? message.radioID : nil + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/NotificationService.swift b/MC1Services/Sources/MC1Services/Services/NotificationService.swift index 34c1a97e..ea815cf7 100644 --- a/MC1Services/Sources/MC1Services/Services/NotificationService.swift +++ b/MC1Services/Sources/MC1Services/Services/NotificationService.swift @@ -1,23 +1,23 @@ import Foundation -import UserNotifications import os +import UserNotifications // MARK: - Notification Categories /// Notification category identifiers -enum NotificationCategory: String, Sendable { - case directMessage = "DIRECT_MESSAGE" - case channelMessage = "CHANNEL_MESSAGE" - case roomMessage = "ROOM_MESSAGE" - case reaction = "REACTION" - case lowBattery = "LOW_BATTERY" +enum NotificationCategory: String { + case directMessage = "DIRECT_MESSAGE" + case channelMessage = "CHANNEL_MESSAGE" + case roomMessage = "ROOM_MESSAGE" + case reaction = "REACTION" + case lowBattery = "LOW_BATTERY" } /// Notification action identifiers -enum NotificationAction: String, Sendable { - case reply = "REPLY_ACTION" - case markRead = "MARK_READ_ACTION" - case dismiss = "DISMISS_ACTION" +enum NotificationAction: String { + case reply = "REPLY_ACTION" + case markRead = "MARK_READ_ACTION" + case dismiss = "DISMISS_ACTION" } // MARK: - Notification Service @@ -27,959 +27,958 @@ enum NotificationAction: String, Sendable { @Observable @MainActor public final class NotificationService: NSObject { - - // MARK: - Properties - - private let logger = Logger(subsystem: "com.mc1", category: "Notifications") - - /// Whether notification permissions are authorized - public private(set) var isAuthorized: Bool = false - - /// Current authorization status - public private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined - - /// Callback for when a quick reply action is triggered - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor so the callback body executes on the main thread. - /// Without @MainActor, the callback runs on a background executor even when - /// called from MainActor context, causing "Call must be made on main thread" crashes. - public var onQuickReply: (@MainActor @Sendable (_ contactID: UUID, _ text: String) async -> Void)? - - /// Callback for when a notification is tapped - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onNotificationTapped: (@MainActor @Sendable (_ contactID: UUID) async -> Void)? - - /// Callback for when a channel notification is tapped - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onChannelNotificationTapped: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8) async -> Void)? - - /// Callback for when a room notification is tapped. - /// Identified by the room's stable session ID. - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor for main-thread callback execution; see onQuickReply. - public var onRoomNotificationTapped: (@MainActor @Sendable (_ sessionID: UUID) async -> Void)? - - /// Callback for when a new contact discovered notification is tapped - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onNewContactNotificationTapped: (@MainActor @Sendable (_ contactID: UUID) async -> Void)? - - /// Callback for when a reaction notification is tapped - /// Parameters: contactID (for DM) or nil, channelIndex/radioID (for channel) or nil, and messageID - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onReactionNotificationTapped: (@MainActor @Sendable ( - _ contactID: UUID?, - _ channelIndex: UInt8?, - _ radioID: UUID?, - _ messageID: UUID - ) async -> Void)? - - /// Provider for localized notification strings - private var stringProvider: NotificationStringProvider? - - /// Sets the string provider for localized notification content. - /// - Parameter provider: The provider implementation from the app layer - public func setStringProvider(_ provider: NotificationStringProvider) { - self.stringProvider = provider - } - - /// Read access for same-module collaborators (`NotificationActionHandler`) - /// that build localized display strings. - var strings: NotificationStringProvider? { stringProvider } - - /// Callback for when mark as read action is triggered - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onMarkAsRead: (@MainActor @Sendable (_ contactID: UUID, _ messageID: UUID) async -> Void)? - - /// Callback for when mark as read action is triggered on a channel message - /// Includes radioID to correctly identify the channel across multiple connected devices - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onChannelMarkAsRead: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8, _ messageID: UUID) async -> Void)? - - /// Callback for when mark as read action is triggered on a room message. - /// Identified by the room's stable session ID. - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onRoomMarkAsRead: (@MainActor @Sendable (_ sessionID: UUID, _ messageID: UUID) async -> Void)? - - /// Callback for when a quick reply action is triggered on a channel message. - /// Includes radioID to correctly identify the channel across multiple connected devices. - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onChannelQuickReply: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8, _ text: String) async -> Void)? - - /// Badge count - public private(set) var badgeCount: Int = 0 - - /// Whether message notifications are temporarily suppressed (during sync window) - public var isSuppressingNotifications: Bool = false - - // MARK: - Active Conversation Tracking - - /// Currently active contact ID (user is viewing this chat) - public var activeContactID: UUID? - - /// Currently active channel index (user is viewing this channel) - public var activeChannelIndex: UInt8? - - /// Device ID for the active channel - public var activeChannelRadioID: UUID? - - /// Currently active room session ID (user is viewing this room) - public var activeRoomSessionID: UUID? - - /// Atomically sets the active conversation, clearing every slot the caller - /// does not pass. Opening one conversation type therefore clears the other - /// types in a single assignment, so a foreground banner is never suppressed - /// for a conversation the user just left. Only one slot is ever populated - /// because exactly one conversation detail is visible at a time. - public func setActiveConversation( - contactID: UUID? = nil, - channelIndex: UInt8? = nil, - channelRadioID: UUID? = nil, - roomSessionID: UUID? = nil - ) { - activeContactID = contactID - activeChannelIndex = channelIndex - activeChannelRadioID = channelRadioID - activeRoomSessionID = roomSessionID - } - - // MARK: - Badge Management - - /// Callback to get total unread count from data layer - /// Returns (contactUnread, channelUnread, roomUnread) tuple for preference-aware calculation - /// Installed by `AppState.wireServicesIfConnected`. - public var getBadgeCount: (@Sendable () async -> (contacts: Int, channels: Int, rooms: Int))? - - /// Cached notification preferences (refreshed on each badge update) - private var cachedPreferences: NotificationPreferences? - - /// Last time preferences were refreshed - private var preferencesLastRefreshed: Date = .distantPast - - /// Pending badge update task (for debouncing rapid updates) - private var pendingBadgeUpdate: Task? - - /// Stored draft messages for contacts (keyed by contactID string). - /// Used when quick reply fails due to disconnection. - /// - /// - Important: Drafts are stored in-memory ONLY and will be LOST if: - /// - The app is force quit by the user - /// - The app is terminated by iOS due to memory pressure - /// - The device is restarted - /// - /// Drafts persist until consumed via `consumeDraft(for:)` or until the app - /// terminates. If disk persistence is needed in the future, consider SwiftData - /// storage with appropriate cleanup policies. - @MainActor private var pendingDrafts: [String: String] = [:] - - // MARK: - Initialization - - public override init() { - super.init() - } - - /// Sets up notification categories and checks current authorization status. - public func setup() async { - await registerCategories() - await checkAuthorizationStatus() - } - - // MARK: - Authorization - - /// Requests notification authorization. - @discardableResult - public func requestAuthorization() async -> Bool { - do { - let options: UNAuthorizationOptions = [.alert, .sound, .badge] - let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: options) - isAuthorized = granted - authorizationStatus = granted ? .authorized : .denied - return granted - } catch { - isAuthorized = false - authorizationStatus = .denied - return false - } + // MARK: - Properties + + private let logger = Logger(subsystem: "com.mc1", category: "Notifications") + + /// Whether notification permissions are authorized + public private(set) var isAuthorized: Bool = false + + /// Current authorization status + public private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined + + /// Callback for when a quick reply action is triggered + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor so the callback body executes on the main thread. + /// Without @MainActor, the callback runs on a background executor even when + /// called from MainActor context, causing "Call must be made on main thread" crashes. + public var onQuickReply: (@MainActor @Sendable (_ contactID: UUID, _ text: String) async -> Void)? + + /// Callback for when a notification is tapped + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onNotificationTapped: (@MainActor @Sendable (_ contactID: UUID) async -> Void)? + + /// Callback for when a channel notification is tapped + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onChannelNotificationTapped: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8) async -> Void)? + + /// Callback for when a room notification is tapped. + /// Identified by the room's stable session ID. + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor for main-thread callback execution; see onQuickReply. + public var onRoomNotificationTapped: (@MainActor @Sendable (_ sessionID: UUID) async -> Void)? + + /// Callback for when a new contact discovered notification is tapped + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onNewContactNotificationTapped: (@MainActor @Sendable (_ contactID: UUID) async -> Void)? + + /// Callback for when a reaction notification is tapped + /// Parameters: contactID (for DM) or nil, channelIndex/radioID (for channel) or nil, and messageID + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onReactionNotificationTapped: (@MainActor @Sendable ( + _ contactID: UUID?, + _ channelIndex: UInt8?, + _ radioID: UUID?, + _ messageID: UUID + ) async -> Void)? + + /// Provider for localized notification strings + private var stringProvider: NotificationStringProvider? + + /// Sets the string provider for localized notification content. + /// - Parameter provider: The provider implementation from the app layer + public func setStringProvider(_ provider: NotificationStringProvider) { + stringProvider = provider + } + + /// Read access for same-module collaborators (`NotificationActionHandler`) + /// that build localized display strings. + var strings: NotificationStringProvider? { + stringProvider + } + + /// Callback for when mark as read action is triggered + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onMarkAsRead: (@MainActor @Sendable (_ contactID: UUID, _ messageID: UUID) async -> Void)? + + /// Callback for when mark as read action is triggered on a channel message + /// Includes radioID to correctly identify the channel across multiple connected devices + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onChannelMarkAsRead: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8, _ messageID: UUID) async -> Void)? + + /// Callback for when mark as read action is triggered on a room message. + /// Identified by the room's stable session ID. + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onRoomMarkAsRead: (@MainActor @Sendable (_ sessionID: UUID, _ messageID: UUID) async -> Void)? + + /// Callback for when a quick reply action is triggered on a channel message. + /// Includes radioID to correctly identify the channel across multiple connected devices. + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onChannelQuickReply: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8, _ text: String) async -> Void)? + + /// Badge count + public private(set) var badgeCount: Int = 0 + + /// Whether message notifications are temporarily suppressed (during sync window) + public var isSuppressingNotifications: Bool = false + + // MARK: - Active Conversation Tracking + + /// Currently active contact ID (user is viewing this chat) + public var activeContactID: UUID? + + /// Currently active channel index (user is viewing this channel) + public var activeChannelIndex: UInt8? + + /// Device ID for the active channel + public var activeChannelRadioID: UUID? + + /// Currently active room session ID (user is viewing this room) + public var activeRoomSessionID: UUID? + + /// Atomically sets the active conversation, clearing every slot the caller + /// does not pass. Opening one conversation type therefore clears the other + /// types in a single assignment, so a foreground banner is never suppressed + /// for a conversation the user just left. Only one slot is ever populated + /// because exactly one conversation detail is visible at a time. + public func setActiveConversation( + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + channelRadioID: UUID? = nil, + roomSessionID: UUID? = nil + ) { + activeContactID = contactID + activeChannelIndex = channelIndex + activeChannelRadioID = channelRadioID + activeRoomSessionID = roomSessionID + } + + // MARK: - Badge Management + + /// Callback to get total unread count from data layer + /// Returns (contactUnread, channelUnread, roomUnread) tuple for preference-aware calculation + /// Installed by `AppState.wireServicesIfConnected`. + public var getBadgeCount: (@Sendable () async -> (contacts: Int, channels: Int, rooms: Int))? + + /// Cached notification preferences (refreshed on each badge update) + private var cachedPreferences: NotificationPreferences? + + /// Last time preferences were refreshed + private var preferencesLastRefreshed: Date = .distantPast + + /// Pending badge update task (for debouncing rapid updates) + private var pendingBadgeUpdate: Task? + + /// Stored draft messages for contacts (keyed by contactID string). + /// Used when quick reply fails due to disconnection. + /// + /// - Important: Drafts are stored in-memory ONLY and will be LOST if: + /// - The app is force quit by the user + /// - The app is terminated by iOS due to memory pressure + /// - The device is restarted + /// + /// Drafts persist until consumed via `consumeDraft(for:)` or until the app + /// terminates. If disk persistence is needed in the future, consider SwiftData + /// storage with appropriate cleanup policies. + @MainActor private var pendingDrafts: [String: String] = [:] + + // MARK: - Initialization + + override public init() { + super.init() + } + + /// Sets up notification categories and checks current authorization status. + public func setup() async { + await registerCategories() + await checkAuthorizationStatus() + } + + // MARK: - Authorization + + /// Requests notification authorization. + @discardableResult + public func requestAuthorization() async -> Bool { + do { + let options: UNAuthorizationOptions = [.alert, .sound, .badge] + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: options) + isAuthorized = granted + authorizationStatus = granted ? .authorized : .denied + return granted + } catch { + isAuthorized = false + authorizationStatus = .denied + return false } - - /// Checks current authorization status. - public func checkAuthorizationStatus() async { - let settings = await UNUserNotificationCenter.current().notificationSettings() - authorizationStatus = settings.authorizationStatus - isAuthorized = settings.authorizationStatus == .authorized - } - - // MARK: - Category Registration - - /// Registers notification categories with actions. - private func registerCategories() async { - // Reply action with text input - let replyAction = UNTextInputNotificationAction( - identifier: NotificationAction.reply.rawValue, - title: stringProvider?.replyActionTitle ?? "Reply", - options: [], - textInputButtonTitle: stringProvider?.sendButtonTitle ?? "Send", - textInputPlaceholder: stringProvider?.messagePlaceholder ?? "Message..." - ) - - // Mark as read action - let markReadAction = UNNotificationAction( - identifier: NotificationAction.markRead.rawValue, - title: stringProvider?.markAsReadActionTitle ?? "Mark as Read", - options: [] - ) - - // Direct message category - let directMessageCategory = UNNotificationCategory( - identifier: NotificationCategory.directMessage.rawValue, - actions: [replyAction, markReadAction], - intentIdentifiers: [], - options: [.customDismissAction] - ) - - // Channel message category (with reply action) - let channelMessageCategory = UNNotificationCategory( - identifier: NotificationCategory.channelMessage.rawValue, - actions: [replyAction, markReadAction], - intentIdentifiers: [], - options: [.customDismissAction] - ) - - // Room message category (no reply action) - let roomMessageCategory = UNNotificationCategory( - identifier: NotificationCategory.roomMessage.rawValue, - actions: [markReadAction], - intentIdentifiers: [], - options: [.customDismissAction] - ) - - // Reaction category (no actions - just tap to navigate) - let reactionCategory = UNNotificationCategory( - identifier: NotificationCategory.reaction.rawValue, - actions: [], - intentIdentifiers: [], - options: [] - ) - - // Low battery category - let lowBatteryCategory = UNNotificationCategory( - identifier: NotificationCategory.lowBattery.rawValue, - actions: [], - intentIdentifiers: [], - options: [] - ) - - let categories: Set = [ - directMessageCategory, - channelMessageCategory, - roomMessageCategory, - reactionCategory, - lowBatteryCategory - ] - - UNUserNotificationCenter.current().setNotificationCategories(categories) - } - - // MARK: - Sending Notifications - - /// Posts a notification for a direct message. - public func postDirectMessageNotification( - from contactName: String, - contactID: UUID, - messageText: String, - messageID: UUID, - isMuted: Bool = false - ) async { - guard !isMuted else { return } - guard isAuthorized else { return } - - // Check granular preference (uses cached preferences) - guard preferences.contactMessagesEnabled else { return } - - // Skip system notification if suppressed (during sync window) - // Unread counts and badges are updated separately by the caller - guard !isSuppressingNotifications else { return } - - let content = UNMutableNotificationContent() - content.title = contactName - content.body = messageText - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.directMessage.rawValue - content.userInfo = [ - "contactID": contactID.uuidString, - "messageID": messageID.uuidString, - "type": "directMessage" - ] - content.threadIdentifier = contactID.uuidString - - // Use current badge count (will be updated after posting) - if preferences.badgeEnabled { - content.badge = NSNumber(value: badgeCount + 1) - } - - let request = UNNotificationRequest( - identifier: messageID.uuidString, - content: content, - trigger: nil - ) - - // Post notification immediately - don't block on badge calculation - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } - - // Update badge from database AFTER posting (non-blocking for notification display) - if preferences.badgeEnabled { - await updateBadgeCount() - } + } + + /// Checks current authorization status. + public func checkAuthorizationStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + authorizationStatus = settings.authorizationStatus + isAuthorized = settings.authorizationStatus == .authorized + } + + // MARK: - Category Registration + + /// Registers notification categories with actions. + private func registerCategories() async { + // Reply action with text input + let replyAction = UNTextInputNotificationAction( + identifier: NotificationAction.reply.rawValue, + title: stringProvider?.replyActionTitle ?? "Reply", + options: [], + textInputButtonTitle: stringProvider?.sendButtonTitle ?? "Send", + textInputPlaceholder: stringProvider?.messagePlaceholder ?? "Message..." + ) + + // Mark as read action + let markReadAction = UNNotificationAction( + identifier: NotificationAction.markRead.rawValue, + title: stringProvider?.markAsReadActionTitle ?? "Mark as Read", + options: [] + ) + + // Direct message category + let directMessageCategory = UNNotificationCategory( + identifier: NotificationCategory.directMessage.rawValue, + actions: [replyAction, markReadAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + // Channel message category (with reply action) + let channelMessageCategory = UNNotificationCategory( + identifier: NotificationCategory.channelMessage.rawValue, + actions: [replyAction, markReadAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + // Room message category (no reply action) + let roomMessageCategory = UNNotificationCategory( + identifier: NotificationCategory.roomMessage.rawValue, + actions: [markReadAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + // Reaction category (no actions - just tap to navigate) + let reactionCategory = UNNotificationCategory( + identifier: NotificationCategory.reaction.rawValue, + actions: [], + intentIdentifiers: [], + options: [] + ) + + // Low battery category + let lowBatteryCategory = UNNotificationCategory( + identifier: NotificationCategory.lowBattery.rawValue, + actions: [], + intentIdentifiers: [], + options: [] + ) + + let categories: Set = [ + directMessageCategory, + channelMessageCategory, + roomMessageCategory, + reactionCategory, + lowBatteryCategory + ] + + UNUserNotificationCenter.current().setNotificationCategories(categories) + } + + // MARK: - Sending Notifications + + /// Posts a notification for a direct message. + public func postDirectMessageNotification( + from contactName: String, + contactID: UUID, + messageText: String, + messageID: UUID, + isMuted: Bool = false + ) async { + guard !isMuted else { return } + guard isAuthorized else { return } + + // Check granular preference (uses cached preferences) + guard preferences.contactMessagesEnabled else { return } + + // Skip system notification if suppressed (during sync window) + // Unread counts and badges are updated separately by the caller + guard !isSuppressingNotifications else { return } + + let content = UNMutableNotificationContent() + content.title = contactName + content.body = messageText + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.directMessage.rawValue + content.userInfo = [ + "contactID": contactID.uuidString, + "messageID": messageID.uuidString, + "type": "directMessage" + ] + content.threadIdentifier = contactID.uuidString + + // Use current badge count (will be updated after posting) + if preferences.badgeEnabled { + content.badge = NSNumber(value: badgeCount + 1) } - /// Posts a notification for a channel message. - public func postChannelMessageNotification( - channelName: String, - channelIndex: UInt8, - radioID: UUID, - senderName: String?, - messageText: String, - messageID: UUID, - notificationLevel: NotificationLevel, - hasSelfMention: Bool - ) async { - guard notificationLevel != .muted else { return } - guard notificationLevel != .mentionsOnly || hasSelfMention else { return } - guard isAuthorized else { return } - - // Check granular preference (uses cached preferences) - guard preferences.channelMessagesEnabled else { return } - - // Skip system notification if suppressed (during sync window) - guard !isSuppressingNotifications else { return } - - let content = UNMutableNotificationContent() - content.title = channelName - if let sender = senderName { - content.body = "\(sender): \(messageText)" - } else { - content.body = messageText - } - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.channelMessage.rawValue - content.userInfo = [ - "channelIndex": Int(channelIndex), - "radioID": radioID.uuidString, - "messageID": messageID.uuidString, - "type": "channelMessage" - ] - content.threadIdentifier = "channel-\(radioID.uuidString)-\(channelIndex)" - - // Use current badge count (will be updated after posting) - if preferences.badgeEnabled { - content.badge = NSNumber(value: badgeCount + 1) - } - - let request = UNNotificationRequest( - identifier: messageID.uuidString, - content: content, - trigger: nil - ) - - // Post notification immediately - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } - - // Update badge from database AFTER posting - if preferences.badgeEnabled { - await updateBadgeCount() - } + let request = UNNotificationRequest( + identifier: messageID.uuidString, + content: content, + trigger: nil + ) + + // Post notification immediately - don't block on badge calculation + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") } - /// Posts a notification for a room message. - public func postRoomMessageNotification( - roomName: String, - sessionID: UUID, - senderName: String?, - messageText: String, - messageID: UUID, - notificationLevel: NotificationLevel - ) async { - guard notificationLevel != .muted else { return } - guard isAuthorized else { return } - - // Check granular preference - guard preferences.roomMessagesEnabled else { return } - - // Skip system notification if suppressed (during sync window) - guard !isSuppressingNotifications else { return } - - let content = UNMutableNotificationContent() - content.title = roomName - if let sender = senderName { - content.body = "\(sender): \(messageText)" - } else { - content.body = messageText - } - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.roomMessage.rawValue - content.userInfo = [ - "roomName": roomName, - "sessionID": sessionID.uuidString, - "messageID": messageID.uuidString, - "type": "roomMessage" - ] - content.threadIdentifier = "room-\(sessionID.uuidString)" - - if preferences.badgeEnabled { - content.badge = NSNumber(value: badgeCount + 1) - } - - let request = UNNotificationRequest( - identifier: messageID.uuidString, - content: content, - trigger: nil - ) + // Update badge from database AFTER posting (non-blocking for notification display) + if preferences.badgeEnabled { + await updateBadgeCount() + } + } + + /// Posts a notification for a channel message. + public func postChannelMessageNotification( + channelName: String, + channelIndex: UInt8, + radioID: UUID, + senderName: String?, + messageText: String, + messageID: UUID, + notificationLevel: NotificationLevel, + hasSelfMention: Bool + ) async { + guard notificationLevel != .muted else { return } + guard notificationLevel != .mentionsOnly || hasSelfMention else { return } + guard isAuthorized else { return } + + // Check granular preference (uses cached preferences) + guard preferences.channelMessagesEnabled else { return } + + // Skip system notification if suppressed (during sync window) + guard !isSuppressingNotifications else { return } + + let content = UNMutableNotificationContent() + content.title = channelName + if let sender = senderName { + content.body = "\(sender): \(messageText)" + } else { + content.body = messageText + } + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.channelMessage.rawValue + content.userInfo = [ + "channelIndex": Int(channelIndex), + "radioID": radioID.uuidString, + "messageID": messageID.uuidString, + "type": "channelMessage" + ] + content.threadIdentifier = "channel-\(radioID.uuidString)-\(channelIndex)" + + // Use current badge count (will be updated after posting) + if preferences.badgeEnabled { + content.badge = NSNumber(value: badgeCount + 1) + } - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + let request = UNNotificationRequest( + identifier: messageID.uuidString, + content: content, + trigger: nil + ) + + // Post notification immediately + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") + } - if preferences.badgeEnabled { - await updateBadgeCount() - } + // Update badge from database AFTER posting + if preferences.badgeEnabled { + await updateBadgeCount() + } + } + + /// Posts a notification for a room message. + public func postRoomMessageNotification( + roomName: String, + sessionID: UUID, + senderName: String?, + messageText: String, + messageID: UUID, + notificationLevel: NotificationLevel + ) async { + guard notificationLevel != .muted else { return } + guard isAuthorized else { return } + + // Check granular preference + guard preferences.roomMessagesEnabled else { return } + + // Skip system notification if suppressed (during sync window) + guard !isSuppressingNotifications else { return } + + let content = UNMutableNotificationContent() + content.title = roomName + if let sender = senderName { + content.body = "\(sender): \(messageText)" + } else { + content.body = messageText + } + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.roomMessage.rawValue + content.userInfo = [ + "roomName": roomName, + "sessionID": sessionID.uuidString, + "messageID": messageID.uuidString, + "type": "roomMessage" + ] + content.threadIdentifier = "room-\(sessionID.uuidString)" + + if preferences.badgeEnabled { + content.badge = NSNumber(value: badgeCount + 1) } - /// Posts a notification that a new contact was discovered. - /// - Parameters: - /// - contactName: Display name of the discovered contact - /// - contactID: Unique identifier for the contact - /// - contactType: Type of node discovered (contact, repeater, or room) - public func postNewContactNotification( - contactName: String, - contactID: UUID, - contactType: ContactType - ) async { - guard isAuthorized else { return } - - // Check granular preference - guard preferences.newContactDiscoveredEnabled else { return } - - // Per-type filtering - switch contactType { - case .chat: guard preferences.discoveryContactEnabled else { return } - case .repeater: guard preferences.discoveryRepeaterEnabled else { return } - case .room: guard preferences.discoveryRoomEnabled else { return } - } + let request = UNNotificationRequest( + identifier: messageID.uuidString, + content: content, + trigger: nil + ) - // Get localized title from provider, fallback to English - let title = stringProvider?.discoveryNotificationTitle(for: contactType) - ?? defaultDiscoveryTitle(for: contactType) - - let content = UNMutableNotificationContent() - content.title = title - content.body = contactName.isEmpty - ? (stringProvider?.unknownContactName ?? "Unknown Contact") - : contactName - content.sound = preferences.soundEnabled ? .default : nil - content.threadIdentifier = "discovery" - content.userInfo = [ - "contactID": contactID.uuidString, - "type": "newContact" - ] - - let request = UNNotificationRequest( - identifier: "new-contact-\(contactID.uuidString)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") } - /// Default English titles when no string provider is set. - func defaultDiscoveryTitle(for type: ContactType) -> String { - switch type { - case .chat: "New Contact Discovered" - case .repeater: "New Repeater Discovered" - case .room: "New Room Discovered" - } + if preferences.badgeEnabled { + await updateBadgeCount() } - - /// Posts a notification when someone reacts to the user's message. - /// - Parameters: - /// - reactorName: Name of the person who reacted - /// - body: Localized notification body text - /// - messageID: ID of the message that was reacted to - /// - contactID: Contact ID if this is a direct message reaction (nil for channels) - /// - channelIndex: Channel index if this is a channel reaction (nil for DMs) - /// - radioID: Device ID for channel reactions (nil for DMs) - public func postReactionNotification( - reactorName: String, - body: String, - messageID: UUID, - contactID: UUID?, - channelIndex: UInt8?, - radioID: UUID? - ) async { - guard isAuthorized else { return } - - // Check reaction-specific preference - guard preferences.reactionNotificationsEnabled else { return } - - // Skip if suppressed (during sync window) - guard !isSuppressingNotifications else { return } - - let content = UNMutableNotificationContent() - content.title = reactorName - content.body = body - - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.reaction.rawValue - - // Build userInfo with all identifiers needed for navigation - var userInfo: [String: Any] = [ - "messageID": messageID.uuidString, - "type": "reaction" - ] - if let contactID { - userInfo["contactID"] = contactID.uuidString - content.threadIdentifier = "reaction-contact-\(contactID.uuidString)" - } - if let channelIndex, let radioID { - userInfo["channelIndex"] = Int(channelIndex) - userInfo["radioID"] = radioID.uuidString - content.threadIdentifier = "reaction-channel-\(radioID.uuidString)-\(channelIndex)" - } - content.userInfo = userInfo - - // Unique identifier per reaction to avoid replacing previous notifications - let request = UNNotificationRequest( - identifier: "reaction-\(messageID.uuidString)-\(reactorName)-\(Date().timeIntervalSince1970)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + } + + /// Posts a notification that a new contact was discovered. + /// - Parameters: + /// - contactName: Display name of the discovered contact + /// - contactID: Unique identifier for the contact + /// - contactType: Type of node discovered (contact, repeater, or room) + public func postNewContactNotification( + contactName: String, + contactID: UUID, + contactType: ContactType + ) async { + guard isAuthorized else { return } + + // Check granular preference + guard preferences.newContactDiscoveredEnabled else { return } + + // Per-type filtering + switch contactType { + case .chat: guard preferences.discoveryContactEnabled else { return } + case .repeater: guard preferences.discoveryRepeaterEnabled else { return } + case .room: guard preferences.discoveryRoomEnabled else { return } } - /// Posts a low battery warning notification. - public func postLowBatteryNotification( - deviceName: String, - batteryPercentage: Int - ) async { - guard isAuthorized else { return } - - guard preferences.lowBatteryEnabled else { return } - - let content = UNMutableNotificationContent() - content.title = stringProvider?.lowBatteryTitle ?? "Low Battery" - content.body = stringProvider?.lowBatteryBody(deviceName: deviceName, percentage: batteryPercentage) - ?? "\(deviceName) battery is at \(batteryPercentage)%" - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.lowBattery.rawValue - content.userInfo = [ - "type": "lowBattery", - "batteryPercentage": batteryPercentage - ] - - // Use device name as identifier to avoid duplicate notifications - let request = UNNotificationRequest( - identifier: "low-battery-\(deviceName)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + // Get localized title from provider, fallback to English + let title = stringProvider?.discoveryNotificationTitle(for: contactType) + ?? defaultDiscoveryTitle(for: contactType) + + let content = UNMutableNotificationContent() + content.title = title + content.body = contactName.isEmpty + ? (stringProvider?.unknownContactName ?? "Unknown Contact") + : contactName + content.sound = preferences.soundEnabled ? .default : nil + content.threadIdentifier = "discovery" + content.userInfo = [ + "contactID": contactID.uuidString, + "type": "newContact" + ] + + let request = UNNotificationRequest( + identifier: "new-contact-\(contactID.uuidString)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") } - - /// Posts a notification that a quick reply failed to send. - public func postQuickReplyFailedNotification( - contactName: String, - contactID: UUID - ) async { - guard isAuthorized else { return } - - let content = UNMutableNotificationContent() - content.title = stringProvider?.quickReplyFailedTitle ?? "Message Not Sent" - content.body = stringProvider?.quickReplyFailedBody(conversationName: contactName) - ?? "Your reply to \(contactName) couldn't be sent." - content.sound = .default - content.categoryIdentifier = NotificationCategory.directMessage.rawValue - content.userInfo = [ - "contactID": contactID.uuidString, - "type": "quickReplyFailed" - ] - - let request = UNNotificationRequest( - identifier: "quick-reply-failed-\(contactID.uuidString)-\(Date().timeIntervalSince1970)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + } + + /// Default English titles when no string provider is set. + func defaultDiscoveryTitle(for type: ContactType) -> String { + switch type { + case .chat: "New Contact Discovered" + case .repeater: "New Repeater Discovered" + case .room: "New Room Discovered" } - - /// Posts a notification that a channel quick reply failed to send. - public func postChannelQuickReplyFailedNotification( - channelName: String, - radioID: UUID, - channelIndex: UInt8 - ) async { - guard isAuthorized else { return } - - let content = UNMutableNotificationContent() - content.title = stringProvider?.quickReplyFailedTitle ?? "Message Not Sent" - content.body = stringProvider?.quickReplyFailedBody(conversationName: channelName) - ?? "Your reply to \(channelName) couldn't be sent." - content.sound = .default - content.userInfo = [ - "channelIndex": Int(channelIndex), - "radioID": radioID.uuidString, - "type": "channelQuickReplyFailed" - ] - - let request = UNNotificationRequest( - identifier: "channel-reply-failed-\(radioID.uuidString)-\(channelIndex)-\(Date().timeIntervalSince1970)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + } + + /// Posts a notification when someone reacts to the user's message. + /// - Parameters: + /// - reactorName: Name of the person who reacted + /// - body: Localized notification body text + /// - messageID: ID of the message that was reacted to + /// - contactID: Contact ID if this is a direct message reaction (nil for channels) + /// - channelIndex: Channel index if this is a channel reaction (nil for DMs) + /// - radioID: Device ID for channel reactions (nil for DMs) + public func postReactionNotification( + reactorName: String, + body: String, + messageID: UUID, + contactID: UUID?, + channelIndex: UInt8?, + radioID: UUID? + ) async { + guard isAuthorized else { return } + + // Check reaction-specific preference + guard preferences.reactionNotificationsEnabled else { return } + + // Skip if suppressed (during sync window) + guard !isSuppressingNotifications else { return } + + let content = UNMutableNotificationContent() + content.title = reactorName + content.body = body + + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.reaction.rawValue + + // Build userInfo with all identifiers needed for navigation + var userInfo: [String: Any] = [ + "messageID": messageID.uuidString, + "type": "reaction" + ] + if let contactID { + userInfo["contactID"] = contactID.uuidString + content.threadIdentifier = "reaction-contact-\(contactID.uuidString)" } - - // MARK: - Draft Message Storage - - /// Saves a draft message for a contact when quick reply fails. - /// - /// - Important: Draft is stored in-memory only and will be lost if app is force quit. - /// - /// - Parameters: - /// - contactID: The UUID of the contact - /// - text: The draft message text to save - @MainActor public func saveDraft(for contactID: UUID, text: String) { - pendingDrafts[contactID.uuidString] = text - } - - /// Retrieves and removes a draft message for a contact. - /// - /// The draft is removed from storage after retrieval (consumed). - /// Returns `nil` if no draft exists for the contact. - /// - /// - Parameter contactID: The UUID of the contact - /// - Returns: The draft text if one exists, otherwise `nil` - @MainActor public func consumeDraft(for contactID: UUID) -> String? { - let key = contactID.uuidString - guard let draft = pendingDrafts[key] else { return nil } - pendingDrafts.removeValue(forKey: key) - return draft - } - - // MARK: - Badge Management Methods - - /// Refresh preferences if stale (older than 5 seconds) - /// Note: 5s cache prevents excessive UserDefaults reads during rapid message arrival - private func refreshPreferencesIfNeeded() { - let now = Date() - if cachedPreferences == nil || now.timeIntervalSince(preferencesLastRefreshed) > 5.0 { - cachedPreferences = NotificationPreferences() - preferencesLastRefreshed = now - } + if let channelIndex, let radioID { + userInfo["channelIndex"] = Int(channelIndex) + userInfo["radioID"] = radioID.uuidString + content.threadIdentifier = "reaction-channel-\(radioID.uuidString)-\(channelIndex)" } - - /// Get current preferences (uses cache) - private var preferences: NotificationPreferences { - refreshPreferencesIfNeeded() - return cachedPreferences ?? NotificationPreferences() + content.userInfo = userInfo + + // Unique identifier per reaction to avoid replacing previous notifications + let request = UNNotificationRequest( + identifier: "reaction-\(messageID.uuidString)-\(reactorName)-\(Date().timeIntervalSince1970)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") } - - /// Updates the app badge count from the database. - /// Uses 100ms debounce to handle rapid-fire message arrivals efficiently. - public func updateBadgeCount() async { - // Cancel any pending update to debounce rapid arrivals - pendingBadgeUpdate?.cancel() - - // Create new debounced update - pendingBadgeUpdate = Task { - // Wait 100ms before actually updating (allows batching multiple arrivals) - try? await Task.sleep(for: .milliseconds(100)) - guard !Task.isCancelled else { return } - - await performBadgeUpdate() - } - - // Wait for the update to complete - await pendingBadgeUpdate?.value + } + + /// Posts a low battery warning notification. + public func postLowBatteryNotification( + deviceName: String, + batteryPercentage: Int + ) async { + guard isAuthorized else { return } + + guard preferences.lowBatteryEnabled else { return } + + let content = UNMutableNotificationContent() + content.title = stringProvider?.lowBatteryTitle ?? "Low Battery" + content.body = stringProvider?.lowBatteryBody(deviceName: deviceName, percentage: batteryPercentage) + ?? "\(deviceName) battery is at \(batteryPercentage)%" + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.lowBattery.rawValue + content.userInfo = [ + "type": "lowBattery", + "batteryPercentage": batteryPercentage + ] + + // Use device name as identifier to avoid duplicate notifications + let request = UNNotificationRequest( + identifier: "low-battery-\(deviceName)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") + } + } + + /// Posts a notification that a quick reply failed to send. + public func postQuickReplyFailedNotification( + contactName: String, + contactID: UUID + ) async { + guard isAuthorized else { return } + + let content = UNMutableNotificationContent() + content.title = stringProvider?.quickReplyFailedTitle ?? "Message Not Sent" + content.body = stringProvider?.quickReplyFailedBody(conversationName: contactName) + ?? "Your reply to \(contactName) couldn't be sent." + content.sound = .default + content.categoryIdentifier = NotificationCategory.directMessage.rawValue + content.userInfo = [ + "contactID": contactID.uuidString, + "type": "quickReplyFailed" + ] + + let request = UNNotificationRequest( + identifier: "quick-reply-failed-\(contactID.uuidString)-\(Date().timeIntervalSince1970)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") + } + } + + /// Posts a notification that a channel quick reply failed to send. + public func postChannelQuickReplyFailedNotification( + channelName: String, + radioID: UUID, + channelIndex: UInt8 + ) async { + guard isAuthorized else { return } + + let content = UNMutableNotificationContent() + content.title = stringProvider?.quickReplyFailedTitle ?? "Message Not Sent" + content.body = stringProvider?.quickReplyFailedBody(conversationName: channelName) + ?? "Your reply to \(channelName) couldn't be sent." + content.sound = .default + content.userInfo = [ + "channelIndex": Int(channelIndex), + "radioID": radioID.uuidString, + "type": "channelQuickReplyFailed" + ] + + let request = UNNotificationRequest( + identifier: "channel-reply-failed-\(radioID.uuidString)-\(channelIndex)-\(Date().timeIntervalSince1970)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") + } + } + + // MARK: - Draft Message Storage + + /// Saves a draft message for a contact when quick reply fails. + /// + /// - Important: Draft is stored in-memory only and will be lost if app is force quit. + /// + /// - Parameters: + /// - contactID: The UUID of the contact + /// - text: The draft message text to save + @MainActor public func saveDraft(for contactID: UUID, text: String) { + pendingDrafts[contactID.uuidString] = text + } + + /// Retrieves and removes a draft message for a contact. + /// + /// The draft is removed from storage after retrieval (consumed). + /// Returns `nil` if no draft exists for the contact. + /// + /// - Parameter contactID: The UUID of the contact + /// - Returns: The draft text if one exists, otherwise `nil` + @MainActor public func consumeDraft(for contactID: UUID) -> String? { + let key = contactID.uuidString + guard let draft = pendingDrafts[key] else { return nil } + pendingDrafts.removeValue(forKey: key) + return draft + } + + // MARK: - Badge Management Methods + + /// Refresh preferences if stale (older than 5 seconds) + /// Note: 5s cache prevents excessive UserDefaults reads during rapid message arrival + private func refreshPreferencesIfNeeded() { + let now = Date() + if cachedPreferences == nil || now.timeIntervalSince(preferencesLastRefreshed) > 5.0 { + cachedPreferences = NotificationPreferences() + preferencesLastRefreshed = now + } + } + + /// Get current preferences (uses cache) + private var preferences: NotificationPreferences { + refreshPreferencesIfNeeded() + return cachedPreferences ?? NotificationPreferences() + } + + /// Updates the app badge count from the database. + /// Uses 100ms debounce to handle rapid-fire message arrivals efficiently. + public func updateBadgeCount() async { + // Cancel any pending update to debounce rapid arrivals + pendingBadgeUpdate?.cancel() + + // Create new debounced update + pendingBadgeUpdate = Task { + // Wait 100ms before actually updating (allows batching multiple arrivals) + try? await Task.sleep(for: .milliseconds(100)) + guard !Task.isCancelled else { return } + + await performBadgeUpdate() } - /// Performs the actual badge update (called by debounced updateBadgeCount) - private func performBadgeUpdate() async { - guard let getBadgeCount else { - return - } - - refreshPreferencesIfNeeded() - let prefs = preferences - - // If badge is disabled, clear it and return - guard prefs.badgeEnabled else { - badgeCount = 0 - do { - try await UNUserNotificationCenter.current().setBadgeCount(0) - } catch { - // Failed to clear badge - } - return - } - - // Get counts from data layer via callback - let counts = await getBadgeCount() + // Wait for the update to complete + await pendingBadgeUpdate?.value + } - var totalUnread = 0 + /// Performs the actual badge update (called by debounced updateBadgeCount) + private func performBadgeUpdate() async { + guard let getBadgeCount else { + return + } - // Only include contact messages if preference enabled - if prefs.contactMessagesEnabled { - totalUnread += counts.contacts - } + refreshPreferencesIfNeeded() + let prefs = preferences + + // If badge is disabled, clear it and return + guard prefs.badgeEnabled else { + badgeCount = 0 + do { + try await UNUserNotificationCenter.current().setBadgeCount(0) + } catch { + // Failed to clear badge + } + return + } - // Only include channel messages if preference enabled - if prefs.channelMessagesEnabled { - totalUnread += counts.channels - } + // Get counts from data layer via callback + let counts = await getBadgeCount() - // Only include room messages if preference enabled - if prefs.roomMessagesEnabled { - totalUnread += counts.rooms - } + var totalUnread = 0 - // Update badge - badgeCount = totalUnread - do { - try await UNUserNotificationCenter.current().setBadgeCount(totalUnread) - } catch { - // Failed to set badge count - } + // Only include contact messages if preference enabled + if prefs.contactMessagesEnabled { + totalUnread += counts.contacts } - /// Remove a delivered notification by message ID - public func removeDeliveredNotification(messageID: UUID) { - UNUserNotificationCenter.current().removeDeliveredNotifications( - withIdentifiers: [messageID.uuidString] - ) + // Only include channel messages if preference enabled + if prefs.channelMessagesEnabled { + totalUnread += counts.channels } - /// Remove all delivered notifications for a contact - public func removeDeliveredNotifications(forContactID contactID: UUID) async { - let center = UNUserNotificationCenter.current() - let notifications = await center.deliveredNotifications() - let idsToRemove = notifications - .filter { $0.request.content.userInfo["contactID"] as? String == contactID.uuidString } - .map(\.request.identifier) - - if !idsToRemove.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: idsToRemove) - } + // Only include room messages if preference enabled + if prefs.roomMessagesEnabled { + totalUnread += counts.rooms } - /// Remove all delivered notifications for a channel - public func removeDeliveredNotifications(forChannelIndex channelIndex: UInt8, radioID: UUID) async { - let center = UNUserNotificationCenter.current() - let notifications = await center.deliveredNotifications() - - let identifiersToRemove = notifications.compactMap { notification -> String? in - let userInfo = notification.request.content.userInfo - guard let notifChannelIndex = userInfo["channelIndex"] as? Int, - let notifDeviceIDString = userInfo["radioID"] as? String, - UInt8(notifChannelIndex) == channelIndex, - notifDeviceIDString == radioID.uuidString else { - return nil - } - return notification.request.identifier - } - - if !identifiersToRemove.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: identifiersToRemove) - } + // Update badge + badgeCount = totalUnread + do { + try await UNUserNotificationCenter.current().setBadgeCount(totalUnread) + } catch { + // Failed to set badge count } - - /// Remove all delivered notifications for a room session - public func removeDeliveredNotifications(forRoomSessionID sessionID: UUID) async { - let center = UNUserNotificationCenter.current() - let notifications = await center.deliveredNotifications() - let idsToRemove = notifications - .filter { $0.request.content.userInfo["sessionID"] as? String == sessionID.uuidString } - .map(\.request.identifier) - - if !idsToRemove.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: idsToRemove) - } + } + + /// Remove a delivered notification by message ID + public func removeDeliveredNotification(messageID: UUID) { + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: [messageID.uuidString] + ) + } + + /// Remove all delivered notifications for a contact + public func removeDeliveredNotifications(forContactID contactID: UUID) async { + let center = UNUserNotificationCenter.current() + let notifications = await center.deliveredNotifications() + let idsToRemove = notifications + .filter { $0.request.content.userInfo["contactID"] as? String == contactID.uuidString } + .map(\.request.identifier) + + if !idsToRemove.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: idsToRemove) + } + } + + /// Remove all delivered notifications for a channel + public func removeDeliveredNotifications(forChannelIndex channelIndex: UInt8, radioID: UUID) async { + let center = UNUserNotificationCenter.current() + let notifications = await center.deliveredNotifications() + + let identifiersToRemove = notifications.compactMap { notification -> String? in + let userInfo = notification.request.content.userInfo + guard let notifChannelIndex = userInfo["channelIndex"] as? Int, + let notifDeviceIDString = userInfo["radioID"] as? String, + UInt8(notifChannelIndex) == channelIndex, + notifDeviceIDString == radioID.uuidString else { + return nil + } + return notification.request.identifier } + if !identifiersToRemove.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: identifiersToRemove) + } + } + + /// Remove all delivered notifications for a room session + public func removeDeliveredNotifications(forRoomSessionID sessionID: UUID) async { + let center = UNUserNotificationCenter.current() + let notifications = await center.deliveredNotifications() + let idsToRemove = notifications + .filter { $0.request.content.userInfo["sessionID"] as? String == sessionID.uuidString } + .map(\.request.identifier) + + if !idsToRemove.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: idsToRemove) + } + } } // MARK: - UNUserNotificationCenterDelegate extension NotificationService: @preconcurrency UNUserNotificationCenterDelegate { + /// Called when a notification is received while the app is in the foreground. + /// With @preconcurrency, this method inherits @MainActor from the class. + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + let userInfo = notification.request.content.userInfo + + // Check if this is a direct message notification for the active chat + if let contactIDString = userInfo["contactID"] as? String, + let contactID = UUID(uuidString: contactIDString), + contactID == activeContactID { + // User is viewing this chat - don't show notification + return [] + } - /// Called when a notification is received while the app is in the foreground. - /// With @preconcurrency, this method inherits @MainActor from the class. - public func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification - ) async -> UNNotificationPresentationOptions { - let userInfo = notification.request.content.userInfo - - // Check if this is a direct message notification for the active chat - if let contactIDString = userInfo["contactID"] as? String, - let contactID = UUID(uuidString: contactIDString), - contactID == activeContactID { - // User is viewing this chat - don't show notification - return [] - } - - // Check if this is a channel message notification for the active channel - // Must check BOTH channelIndex AND radioID for multi-device scenarios - if let channelIndex = userInfo["channelIndex"] as? Int, - let radioIDString = userInfo["radioID"] as? String, - let radioID = UUID(uuidString: radioIDString), - UInt8(channelIndex) == activeChannelIndex, - radioID == activeChannelRadioID { - // User is viewing this channel - don't show notification - return [] - } + // Check if this is a channel message notification for the active channel + // Must check BOTH channelIndex AND radioID for multi-device scenarios + if let channelIndex = userInfo["channelIndex"] as? Int, + let radioIDString = userInfo["radioID"] as? String, + let radioID = UUID(uuidString: radioIDString), + UInt8(channelIndex) == activeChannelIndex, + radioID == activeChannelRadioID { + // User is viewing this channel - don't show notification + return [] + } - // Check if this is a room message notification for the active room - if let sessionIDString = userInfo["sessionID"] as? String, - let sessionID = UUID(uuidString: sessionIDString), - sessionID == activeRoomSessionID { - // User is viewing this room - don't show notification - return [] - } + // Check if this is a room message notification for the active room + if let sessionIDString = userInfo["sessionID"] as? String, + let sessionID = UUID(uuidString: sessionIDString), + sessionID == activeRoomSessionID { + // User is viewing this room - don't show notification + return [] + } - // Show banner and sound for other notifications - return [.banner, .sound, .badge] - } - - /// Called when the user interacts with a notification. - /// With @preconcurrency, this method inherits @MainActor from the class, - /// so we can directly access self and all @Observable properties. - public func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse - ) async { - let userInfo = response.notification.request.content.userInfo - - switch response.actionIdentifier { - case NotificationAction.reply.rawValue: - // Handle quick reply - guard let textResponse = response as? UNTextInputNotificationResponse else { - return - } - - let replyText = textResponse.userText - - // Check if it's a direct message reply (existing) - if let contactIDString = userInfo["contactID"] as? String, - let contactID = UUID(uuidString: contactIDString) { - await onQuickReply?(contactID, replyText) - } - // Check if it's a channel reply (new) - else if let channelIndex = userInfo["channelIndex"] as? Int, - let radioIDString = userInfo["radioID"] as? String, - let radioID = UUID(uuidString: radioIDString) { - await onChannelQuickReply?(radioID, UInt8(channelIndex), replyText) - } - - case NotificationAction.markRead.rawValue: - // Handle mark as read - let messageIDString = userInfo["messageID"] as? String - let messageID = messageIDString.flatMap { UUID(uuidString: $0) } - - if let contactIDString = userInfo["contactID"] as? String, - let contactID = UUID(uuidString: contactIDString), - let messageID { - // Direct message mark as read - await onMarkAsRead?(contactID, messageID) - } else if let channelIndex = userInfo["channelIndex"] as? Int, - let radioIDString = userInfo["radioID"] as? String, - let radioID = UUID(uuidString: radioIDString), - let messageID { - // Channel message mark as read (includes radioID for multi-device) - await onChannelMarkAsRead?(radioID, UInt8(channelIndex), messageID) - } else if let sessionIDString = userInfo["sessionID"] as? String, - let sessionID = UUID(uuidString: sessionIDString), - let messageID { - // Room message mark as read (identified by stable session ID) - await onRoomMarkAsRead?(sessionID, messageID) - } - - case UNNotificationDefaultActionIdentifier: - // User tapped the notification - let notificationType = userInfo["type"] as? String - - // Handle reaction notifications (includes messageID for scroll-to) - if notificationType == "reaction", - let messageIDString = userInfo["messageID"] as? String, - let messageID = UUID(uuidString: messageIDString) { - let contactID = (userInfo["contactID"] as? String).flatMap { UUID(uuidString: $0) } - let channelIndex = (userInfo["channelIndex"] as? Int).map { UInt8($0) } - let radioID = (userInfo["radioID"] as? String).flatMap { UUID(uuidString: $0) } - await onReactionNotificationTapped?(contactID, channelIndex, radioID, messageID) - } - // Handle contact-based notifications (DM, new contact) - else if let contactIDString = userInfo["contactID"] as? String, - let contactID = UUID(uuidString: contactIDString) { - if notificationType == "newContact" { - await onNewContactNotificationTapped?(contactID) - } else { - await onNotificationTapped?(contactID) - } - } - // Handle channel notifications - else if let channelIndex = userInfo["channelIndex"] as? Int, - let radioIDString = userInfo["radioID"] as? String, - let radioID = UUID(uuidString: radioIDString) { - await onChannelNotificationTapped?(radioID, UInt8(channelIndex)) - } - // Handle room notifications (identified by stable session ID) - else if let sessionIDString = userInfo["sessionID"] as? String, - let sessionID = UUID(uuidString: sessionIDString) { - await onRoomNotificationTapped?(sessionID) - } - - default: - break - } + // Show banner and sound for other notifications + return [.banner, .sound, .badge] + } + + /// Called when the user interacts with a notification. + /// With @preconcurrency, this method inherits @MainActor from the class, + /// so we can directly access self and all @Observable properties. + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let userInfo = response.notification.request.content.userInfo + + switch response.actionIdentifier { + case NotificationAction.reply.rawValue: + // Handle quick reply + guard let textResponse = response as? UNTextInputNotificationResponse else { + return + } + + let replyText = textResponse.userText + + // Check if it's a direct message reply (existing) + if let contactIDString = userInfo["contactID"] as? String, + let contactID = UUID(uuidString: contactIDString) { + await onQuickReply?(contactID, replyText) + } + // Check if it's a channel reply (new) + else if let channelIndex = userInfo["channelIndex"] as? Int, + let radioIDString = userInfo["radioID"] as? String, + let radioID = UUID(uuidString: radioIDString) { + await onChannelQuickReply?(radioID, UInt8(channelIndex), replyText) + } + + case NotificationAction.markRead.rawValue: + // Handle mark as read + let messageIDString = userInfo["messageID"] as? String + let messageID = messageIDString.flatMap { UUID(uuidString: $0) } + + if let contactIDString = userInfo["contactID"] as? String, + let contactID = UUID(uuidString: contactIDString), + let messageID { + // Direct message mark as read + await onMarkAsRead?(contactID, messageID) + } else if let channelIndex = userInfo["channelIndex"] as? Int, + let radioIDString = userInfo["radioID"] as? String, + let radioID = UUID(uuidString: radioIDString), + let messageID { + // Channel message mark as read (includes radioID for multi-device) + await onChannelMarkAsRead?(radioID, UInt8(channelIndex), messageID) + } else if let sessionIDString = userInfo["sessionID"] as? String, + let sessionID = UUID(uuidString: sessionIDString), + let messageID { + // Room message mark as read (identified by stable session ID) + await onRoomMarkAsRead?(sessionID, messageID) + } + + case UNNotificationDefaultActionIdentifier: + // User tapped the notification + let notificationType = userInfo["type"] as? String + + // Handle reaction notifications (includes messageID for scroll-to) + if notificationType == "reaction", + let messageIDString = userInfo["messageID"] as? String, + let messageID = UUID(uuidString: messageIDString) { + let contactID = (userInfo["contactID"] as? String).flatMap { UUID(uuidString: $0) } + let channelIndex = (userInfo["channelIndex"] as? Int).map { UInt8($0) } + let radioID = (userInfo["radioID"] as? String).flatMap { UUID(uuidString: $0) } + await onReactionNotificationTapped?(contactID, channelIndex, radioID, messageID) + } + // Handle contact-based notifications (DM, new contact) + else if let contactIDString = userInfo["contactID"] as? String, + let contactID = UUID(uuidString: contactIDString) { + if notificationType == "newContact" { + await onNewContactNotificationTapped?(contactID) + } else { + await onNotificationTapped?(contactID) + } + } + // Handle channel notifications + else if let channelIndex = userInfo["channelIndex"] as? Int, + let radioIDString = userInfo["radioID"] as? String, + let radioID = UUID(uuidString: radioIDString) { + await onChannelNotificationTapped?(radioID, UInt8(channelIndex)) + } + // Handle room notifications (identified by stable session ID) + else if let sessionIDString = userInfo["sessionID"] as? String, + let sessionID = UUID(uuidString: sessionIDString) { + await onRoomNotificationTapped?(sessionID) + } + + default: + break } + } } diff --git a/MC1Services/Sources/MC1Services/Services/PendingAck.swift b/MC1Services/Sources/MC1Services/Services/PendingAck.swift index 6ab96f97..f1fa272a 100644 --- a/MC1Services/Sources/MC1Services/Services/PendingAck.swift +++ b/MC1Services/Sources/MC1Services/Services/PendingAck.swift @@ -7,14 +7,14 @@ import Foundation /// All attempts for the same message share one `PendingAck` entry. /// /// DM-only: channel/room broadcasts do not generate ACKs and are not tracked here. -struct PendingAck: Sendable { - let messageID: UUID - let contactID: UUID - var ackCodes: Set - var sentAt: Date - /// The latest send attempt's ACK-wait derived from the firmware's `est_timeout` hint. - /// `checkExpiredAcks` reads this as the per-attempt floor on the give-up deadline - /// so a slow high-spreading-factor round-trip is not failed while still in flight. - var timeout: TimeInterval - var isDelivered: Bool = false +struct PendingAck { + let messageID: UUID + let contactID: UUID + var ackCodes: Set + var sentAt: Date + /// The latest send attempt's ACK-wait derived from the firmware's `est_timeout` hint. + /// `checkExpiredAcks` reads this as the per-attempt floor on the give-up deadline + /// so a slow high-spreading-factor round-trip is not failed while still in flight. + var timeout: TimeInterval + var isDelivered: Bool = false } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift index 3e681c3b..c4c537b2 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift @@ -8,530 +8,528 @@ private let firstRelocatableChannelIndex = 1 // MARK: - Batch Insert extension PersistenceStore { - - /// Inserts DTOs into the model context, skipping any whose `key` is - /// already in `existingKeys`. Covers the "insert if absent" shape used - /// by most backup batch-insert paths. - /// - /// Dedup is by business `key`; each model's surrogate `id` is a random UUIDv4 minted once - /// at creation, so a colliding `id` with a divergent business key is statistically - /// unreachable on any normal path. We deliberately do not re-mint ids here: re-minting a - /// child's `id` would orphan its inbound foreign keys (`Reaction.messageID`/ - /// `MessageRepeat.messageID` key on `Message.id`; `replyToID` is rewritten only for - /// duplicate parents). Only Device (recurring CBPeripheral id) and Channel (children key by - /// slot/`channelIndex`, never `Channel.id`) are safe to re-mint, and both already do - /// (`batchInsertDevices`, `batchInsertChannels`). If a future change ever makes an `id` - /// content-derived or reused across rows, revisit this — a colliding insert would upsert the - /// local row. - @discardableResult - private func insertUnique( - _ dtos: [DTO], - existingKeys: Set, - key: (DTO) -> Key, - construct: (DTO) -> M - ) -> (inserted: Int, skipped: Int) { - var knownKeys = existingKeys - var inserted = 0 - var skipped = 0 - for dto in dtos { - if !knownKeys.insert(key(dto)).inserted { - skipped += 1 - continue - } - modelContext.insert(construct(dto)) - inserted += 1 - } - return (inserted, skipped) + /// Inserts DTOs into the model context, skipping any whose `key` is + /// already in `existingKeys`. Covers the "insert if absent" shape used + /// by most backup batch-insert paths. + /// + /// Dedup is by business `key`; each model's surrogate `id` is a random UUIDv4 minted once + /// at creation, so a colliding `id` with a divergent business key is statistically + /// unreachable on any normal path. We deliberately do not re-mint ids here: re-minting a + /// child's `id` would orphan its inbound foreign keys (`Reaction.messageID`/ + /// `MessageRepeat.messageID` key on `Message.id`; `replyToID` is rewritten only for + /// duplicate parents). Only Device (recurring CBPeripheral id) and Channel (children key by + /// slot/`channelIndex`, never `Channel.id`) are safe to re-mint, and both already do + /// (`batchInsertDevices`, `batchInsertChannels`). If a future change ever makes an `id` + /// content-derived or reused across rows, revisit this — a colliding insert would upsert the + /// local row. + @discardableResult + private func insertUnique( + _ dtos: [DTO], + existingKeys: Set, + key: (DTO) -> Key, + construct: (DTO) -> some PersistentModel + ) -> (inserted: Int, skipped: Int) { + var knownKeys = existingKeys + var inserted = 0 + var skipped = 0 + for dto in dtos { + if !knownKeys.insert(key(dto)).inserted { + skipped += 1 + continue + } + modelContext.insert(construct(dto)) + inserted += 1 } - - /// Inserts DTOs whose parent exists in `existingParentIDs`, skipping - /// orphans or duplicate keys. Inserted DTOs contribute their parent - /// ID to `affectedParentIDs` for downstream recompute work. - @discardableResult - private func insertUniqueWithParent( - _ dtos: [DTO], - existingKeys: Set, - existingParentIDs: Set, - parentID: (DTO) -> ParentKey, - key: (DTO) -> Key, - construct: (DTO) -> M - ) -> (inserted: Int, skipped: Int, affectedParentIDs: Set) { - var knownKeys = existingKeys - var inserted = 0 - var skipped = 0 - var affectedParentIDs = Set() - for dto in dtos { - let parent = parentID(dto) - guard existingParentIDs.contains(parent) else { - skipped += 1 - continue - } - if !knownKeys.insert(key(dto)).inserted { - skipped += 1 - continue - } - modelContext.insert(construct(dto)) - affectedParentIDs.insert(parent) - inserted += 1 + return (inserted, skipped) + } + + /// Inserts DTOs whose parent exists in `existingParentIDs`, skipping + /// orphans or duplicate keys. Inserted DTOs contribute their parent + /// ID to `affectedParentIDs` for downstream recompute work. + @discardableResult + private func insertUniqueWithParent( + _ dtos: [DTO], + existingKeys: Set, + existingParentIDs: Set, + parentID: (DTO) -> ParentKey, + key: (DTO) -> Key, + construct: (DTO) -> some PersistentModel + ) -> (inserted: Int, skipped: Int, affectedParentIDs: Set) { + var knownKeys = existingKeys + var inserted = 0 + var skipped = 0 + var affectedParentIDs = Set() + for dto in dtos { + let parent = parentID(dto) + guard existingParentIDs.contains(parent) else { + skipped += 1 + continue + } + if !knownKeys.insert(key(dto)).inserted { + skipped += 1 + continue + } + modelContext.insert(construct(dto)) + affectedParentIDs.insert(parent) + inserted += 1 + } + return (inserted, skipped, affectedParentIDs) + } + + @discardableResult + func batchInsertDevices( + _ dtos: [DeviceDTO], + existingKeys: Set + ) throws -> (inserted: Int, skipped: Int) { + // Assign a fresh Device.id on insert: the backup UUID was `CBPeripheral.identifier` + // from the source phone, and `Device.id` is `@Attribute(.unique)`. Reusing the + // backup UUID here could collide with a live local peripheral identifier and + // trigger SwiftData's upsert, silently overwriting the current Device row. + // Child records key by `radioID`, not `Device.id`, so the fresh id breaks no + // linkage; `ConnectionManager.buildServicesAndSaveDevice` cleans up the stub + // row when the user reconnects to the real radio. + insertUnique( + dtos, + existingKeys: existingKeys, + key: \.publicKey, + construct: { Device(dto: $0.cleanedForImport().copy { $0.id = UUID() }) } + ) + } + + @discardableResult + func batchInsertContacts( + _ dtos: [ContactDTO], + radioIDs: Set + ) throws -> (inserted: Int, skipped: Int, merged: Int, contactIDsByKey: [String: UUID]) { + var existingContactsByKey = try fetchExistingContactsByKey(radioIDs: radioIDs) + var contactIDsByKey: [String: UUID] = [:] + contactIDsByKey.reserveCapacity(dtos.count) + var inserted = 0 + var skipped = 0 + var merged = 0 + for dto in dtos { + let key = contactKey(radioID: dto.radioID, publicKey: dto.publicKey) + if let existing = existingContactsByKey[key] { + if mergeBackupMetadata(into: existing, from: dto) { + merged += 1 } - return (inserted, skipped, affectedParentIDs) + skipped += 1 + contactIDsByKey[key] = existing.id + continue + } + let contact = Contact(dto: dto) + modelContext.insert(contact) + existingContactsByKey[key] = contact + contactIDsByKey[key] = contact.id + inserted += 1 } - @discardableResult - func batchInsertDevices( - _ dtos: [DeviceDTO], - existingKeys: Set - ) throws -> (inserted: Int, skipped: Int) { - // Assign a fresh Device.id on insert: the backup UUID was `CBPeripheral.identifier` - // from the source phone, and `Device.id` is `@Attribute(.unique)`. Reusing the - // backup UUID here could collide with a live local peripheral identifier and - // trigger SwiftData's upsert, silently overwriting the current Device row. - // Child records key by `radioID`, not `Device.id`, so the fresh id breaks no - // linkage; `ConnectionManager.buildServicesAndSaveDevice` cleans up the stub - // row when the user reconnects to the real radio. - insertUnique( - dtos, - existingKeys: existingKeys, - key: \.publicKey, - construct: { Device(dto: $0.cleanedForImport().copy { $0.id = UUID() }) } - ) + return (inserted, skipped, merged, contactIDsByKey) + } + + /// Outcome of reconciling backup channels against local channels. + /// + /// `channelIndexRemap` maps `radioID -> [backupIndex: localIndex]` for every channel + /// whose placement differs from its backup slot, and `droppedChannelIndices` lists the + /// `(radioID, backupIndex)` slots that had no free local placement. The caller applies + /// both to channel messages before insert. + /// + /// `insertedLocalIndices` lists the local slots a foreign backup channel newly occupied + /// (every insert, whether relocated or placed at its own free index). These are the only + /// slots whose local channel identity changed, so they — together with `droppedChannelIndices` + /// — are the slots whose chat drafts the caller must clear. Slots reached via secret/slot + /// merge are excluded: their occupant is unchanged, so their draft stays valid. + struct ChannelBatchInsertResult { + let inserted: Int + let skipped: Int + let merged: Int + let dropped: Int + let channelIndexRemap: [UUID: [UInt8: UInt8]] + let droppedChannelIndices: [UUID: Set] + let insertedLocalIndices: [UUID: Set] + } + + /// Reconciles backup channels against local channels by stable cryptographic identity + /// `(radioID, secret)`, treating the slot `index` as mere placement. A backup channel + /// whose secret matches a local channel merges into it (and records a message-index + /// remap if the slot differs); a backup channel with no local secret match is placed at + /// its own slot when free, otherwise relocated to the lowest free slot within the + /// radio's channel capacity. Channels with an empty (all-zero) secret carry no stable + /// identity — the public channel and unconfigured slots — so they fall back to + /// index-based placement, matching legacy behavior and keeping distinct empty-secret + /// slots from collapsing. + /// + /// A backup channel with no free slot is dropped and reported via `skipped`; its + /// messages are dropped by the caller because no placement exists. + @discardableResult + func batchInsertChannels( + _ dtos: [ChannelDTO], + radioIDs: Set, + maxChannelsByRadioID: [UUID: UInt8] = [:] + ) throws -> ChannelBatchInsertResult { + let existingChannels = try fetchExistingChannels(radioIDs: radioIDs) + + var localChannelsBySecret: [UUID: [Data: Channel]] = [:] + var occupiedIndicesByRadioID: [UUID: Set] = [:] + var localChannelByIndex: [UUID: [UInt8: Channel]] = [:] + for channel in existingChannels { + occupiedIndicesByRadioID[channel.radioID, default: []].insert(channel.index) + localChannelByIndex[channel.radioID, default: [:]][channel.index] = channel + if channelHasStableSecret(channel.secret) { + localChannelsBySecret[channel.radioID, default: [:]][channel.secret] = channel + } } - @discardableResult - func batchInsertContacts( - _ dtos: [ContactDTO], - radioIDs: Set - ) throws -> (inserted: Int, skipped: Int, merged: Int, contactIDsByKey: [String: UUID]) { - var existingContactsByKey = try fetchExistingContactsByKey(radioIDs: radioIDs) - var contactIDsByKey: [String: UUID] = [:] - contactIDsByKey.reserveCapacity(dtos.count) - var inserted = 0 - var skipped = 0 - var merged = 0 - for dto in dtos { - let key = contactKey(radioID: dto.radioID, publicKey: dto.publicKey) - if let existing = existingContactsByKey[key] { - if mergeBackupMetadata(into: existing, from: dto) { - merged += 1 - } - skipped += 1 - contactIDsByKey[key] = existing.id - continue - } - let contact = Contact(dto: dto) - modelContext.insert(contact) - existingContactsByKey[key] = contact - contactIDsByKey[key] = contact.id - inserted += 1 - } - - return (inserted, skipped, merged, contactIDsByKey) + var inserted = 0 + var skipped = 0 + var merged = 0 + var dropped = 0 + var channelIndexRemap: [UUID: [UInt8: UInt8]] = [:] + var droppedChannelIndices: [UUID: Set] = [:] + var insertedLocalIndices: [UUID: Set] = [:] + + // Sort by (radioID, index) so free-slot assignment is deterministic regardless of + // the envelope's array order. + let orderedDTOs = dtos.sorted { + $0.radioID == $1.radioID ? $0.index < $1.index : $0.radioID.uuidString < $1.radioID.uuidString } - /// Outcome of reconciling backup channels against local channels. - /// - /// `channelIndexRemap` maps `radioID -> [backupIndex: localIndex]` for every channel - /// whose placement differs from its backup slot, and `droppedChannelIndices` lists the - /// `(radioID, backupIndex)` slots that had no free local placement. The caller applies - /// both to channel messages before insert. - /// - /// `insertedLocalIndices` lists the local slots a foreign backup channel newly occupied - /// (every insert, whether relocated or placed at its own free index). These are the only - /// slots whose local channel identity changed, so they — together with `droppedChannelIndices` - /// — are the slots whose chat drafts the caller must clear. Slots reached via secret/slot - /// merge are excluded: their occupant is unchanged, so their draft stays valid. - struct ChannelBatchInsertResult: Sendable { - let inserted: Int - let skipped: Int - let merged: Int - let dropped: Int - let channelIndexRemap: [UUID: [UInt8: UInt8]] - let droppedChannelIndices: [UUID: Set] - let insertedLocalIndices: [UUID: Set] - } + for dto in orderedDTOs { + let radioID = dto.radioID - /// Reconciles backup channels against local channels by stable cryptographic identity - /// `(radioID, secret)`, treating the slot `index` as mere placement. A backup channel - /// whose secret matches a local channel merges into it (and records a message-index - /// remap if the slot differs); a backup channel with no local secret match is placed at - /// its own slot when free, otherwise relocated to the lowest free slot within the - /// radio's channel capacity. Channels with an empty (all-zero) secret carry no stable - /// identity — the public channel and unconfigured slots — so they fall back to - /// index-based placement, matching legacy behavior and keeping distinct empty-secret - /// slots from collapsing. - /// - /// A backup channel with no free slot is dropped and reported via `skipped`; its - /// messages are dropped by the caller because no placement exists. - @discardableResult - func batchInsertChannels( - _ dtos: [ChannelDTO], - radioIDs: Set, - maxChannelsByRadioID: [UUID: UInt8] = [:] - ) throws -> ChannelBatchInsertResult { - let existingChannels = try fetchExistingChannels(radioIDs: radioIDs) - - var localChannelsBySecret: [UUID: [Data: Channel]] = [:] - var occupiedIndicesByRadioID: [UUID: Set] = [:] - var localChannelByIndex: [UUID: [UInt8: Channel]] = [:] - for channel in existingChannels { - occupiedIndicesByRadioID[channel.radioID, default: []].insert(channel.index) - localChannelByIndex[channel.radioID, default: [:]][channel.index] = channel - if channelHasStableSecret(channel.secret) { - localChannelsBySecret[channel.radioID, default: [:]][channel.secret] = channel - } + if channelHasStableSecret(dto.secret), + let existing = localChannelsBySecret[radioID]?[dto.secret] { + if mergeBackupMetadata(into: existing, from: dto) { + merged += 1 } - - var inserted = 0 - var skipped = 0 - var merged = 0 - var dropped = 0 - var channelIndexRemap: [UUID: [UInt8: UInt8]] = [:] - var droppedChannelIndices: [UUID: Set] = [:] - var insertedLocalIndices: [UUID: Set] = [:] - - // Sort by (radioID, index) so free-slot assignment is deterministic regardless of - // the envelope's array order. - let orderedDTOs = dtos.sorted { - $0.radioID == $1.radioID ? $0.index < $1.index : $0.radioID.uuidString < $1.radioID.uuidString + skipped += 1 + if existing.index != dto.index { + channelIndexRemap[radioID, default: [:]][dto.index] = existing.index } - - for dto in orderedDTOs { - let radioID = dto.radioID - - if channelHasStableSecret(dto.secret), - let existing = localChannelsBySecret[radioID]?[dto.secret] { - if mergeBackupMetadata(into: existing, from: dto) { - merged += 1 - } - skipped += 1 - if existing.index != dto.index { - channelIndexRemap[radioID, default: [:]][dto.index] = existing.index - } - continue - } - - // Empty-secret channels reconcile by slot (the public channel's slot is its - // identity); a local channel already in that slot is the merge target. - if !channelHasStableSecret(dto.secret), - let existing = localChannelByIndex[radioID]?[dto.index] { - if mergeBackupMetadata(into: existing, from: dto) { - merged += 1 - } - skipped += 1 - continue - } - - guard let placementIndex = resolveChannelPlacementIndex( - backupIndex: dto.index, - occupiedIndices: occupiedIndicesByRadioID[radioID] ?? [], - maxChannels: maxChannelsByRadioID[radioID] - ) else { - backupLogger.warning( - "Backup channel at slot \(dto.index) for radio \(radioID.uuidString) has no free local slot; dropping it and its messages." - ) - dropped += 1 - droppedChannelIndices[radioID, default: []].insert(dto.index) - continue - } - - // Re-mint the surrogate id on insert so a backup channel can never upsert a live - // local channel that shares its `@Attribute(.unique)` id (e.g. re-importing a stale - // backup after the same slot was reconfigured and its secret rotated in place). - // Channel has no inbound foreign key, so the fresh id breaks no message/reaction - // linkage (those key by channelIndex). Mirrors `batchInsertDevices`. - let relocatedDTO = placementIndex == dto.index ? dto : dto.with(index: placementIndex) - let placedDTO = relocatedDTO.with(id: UUID()) - let channel = Channel(dto: placedDTO) - modelContext.insert(channel) - occupiedIndicesByRadioID[radioID, default: []].insert(placementIndex) - localChannelByIndex[radioID, default: [:]][placementIndex] = channel - if channelHasStableSecret(channel.secret) { - localChannelsBySecret[radioID, default: [:]][channel.secret] = channel - } - insertedLocalIndices[radioID, default: []].insert(placementIndex) - if placementIndex != dto.index { - channelIndexRemap[radioID, default: [:]][dto.index] = placementIndex - } - inserted += 1 + continue + } + + // Empty-secret channels reconcile by slot (the public channel's slot is its + // identity); a local channel already in that slot is the merge target. + if !channelHasStableSecret(dto.secret), + let existing = localChannelByIndex[radioID]?[dto.index] { + if mergeBackupMetadata(into: existing, from: dto) { + merged += 1 } - return ChannelBatchInsertResult( - inserted: inserted, - skipped: skipped, - merged: merged, - dropped: dropped, - channelIndexRemap: channelIndexRemap, - droppedChannelIndices: droppedChannelIndices, - insertedLocalIndices: insertedLocalIndices + skipped += 1 + continue + } + + guard let placementIndex = resolveChannelPlacementIndex( + backupIndex: dto.index, + occupiedIndices: occupiedIndicesByRadioID[radioID] ?? [], + maxChannels: maxChannelsByRadioID[radioID] + ) else { + backupLogger.warning( + "Backup channel at slot \(dto.index) for radio \(radioID.uuidString) has no free local slot; dropping it and its messages." ) + dropped += 1 + droppedChannelIndices[radioID, default: []].insert(dto.index) + continue + } + + // Re-mint the surrogate id on insert so a backup channel can never upsert a live + // local channel that shares its `@Attribute(.unique)` id (e.g. re-importing a stale + // backup after the same slot was reconfigured and its secret rotated in place). + // Channel has no inbound foreign key, so the fresh id breaks no message/reaction + // linkage (those key by channelIndex). Mirrors `batchInsertDevices`. + let relocatedDTO = placementIndex == dto.index ? dto : dto.with(index: placementIndex) + let placedDTO = relocatedDTO.with(id: UUID()) + let channel = Channel(dto: placedDTO) + modelContext.insert(channel) + occupiedIndicesByRadioID[radioID, default: []].insert(placementIndex) + localChannelByIndex[radioID, default: [:]][placementIndex] = channel + if channelHasStableSecret(channel.secret) { + localChannelsBySecret[radioID, default: [:]][channel.secret] = channel + } + insertedLocalIndices[radioID, default: []].insert(placementIndex) + if placementIndex != dto.index { + channelIndexRemap[radioID, default: [:]][dto.index] = placementIndex + } + inserted += 1 } - - /// Lowest free slot for a relocating channel: prefer the backup's own slot when free, - /// otherwise the lowest unoccupied index within `[firstRelocatableChannelIndex, maxChannels)` - /// so slot 0 stays reserved for the public channel. Returns nil when no slot is free so the - /// caller can drop the channel rather than mis-associate it. When `maxChannels` is unavailable - /// (no matching device DTO), the search is bounded by the highest occupied slot plus one so it - /// still terminates without inventing a capacity. - private func resolveChannelPlacementIndex( - backupIndex: UInt8, - occupiedIndices: Set, - maxChannels: UInt8? - ) -> UInt8? { - if !occupiedIndices.contains(backupIndex) { - return backupIndex - } - let upperBound: Int - if let maxChannels { - upperBound = Int(maxChannels) - } else { - upperBound = Int(occupiedIndices.max() ?? 0) + 1 - } - guard upperBound > firstRelocatableChannelIndex else { return nil } - for candidate in firstRelocatableChannelIndex.., + maxChannels: UInt8? + ) -> UInt8? { + if !occupiedIndices.contains(backupIndex) { + return backupIndex } - - /// A channel secret carries stable cryptographic identity only when it is non-empty. - /// An all-zero secret marks the public channel or an unconfigured slot, which is keyed - /// by slot index instead. - private func channelHasStableSecret(_ secret: Data) -> Bool { - !secret.isEmpty && !secret.allSatisfy { $0 == 0 } + let upperBound = if let maxChannels { + Int(maxChannels) + } else { + Int(occupiedIndices.max() ?? 0) + 1 } - - @discardableResult - func batchInsertMessages( - _ dtos: [MessageDTO], - existingKeys: Set, - existingIDsByKey: [String: [UUID]] - ) throws -> (inserted: Int, skipped: Int, messageIDByBackupID: [UUID: UUID]) { - var knownKeys = existingKeys - var idsByKey = existingIDsByKey - var messageIDByBackupID: [UUID: UUID] = [:] - var toInsert: [MessageDTO] = [] - toInsert.reserveCapacity(dtos.count) - var skipped = 0 - - // Pass 1: resolve duplicates and build the backup→local ID remap. Doing this - // before any insert lets pass 2 rewrite replyToID onto winning local UUIDs, - // so reply chains survive imports where the parent already exists locally. - for dto in dtos { - let key = messageBackupKey(for: dto) - if knownKeys.contains(key) { - // Deterministic tiebreak when multiple locals share a key. - let winning = idsByKey[key]?.min(by: { $0.uuidString < $1.uuidString }) - if let winning, winning != dto.id { - messageIDByBackupID[dto.id] = winning - } - skipped += 1 - continue - } - knownKeys.insert(key) - idsByKey[key, default: []].append(dto.id) - toInsert.append(dto) - } - - // Pass 2: insert, rewriting replyToID through the remap so retained messages - // point at the winning local parent rather than a skipped backup UUID. - for var dto in toInsert { - if let replyTo = dto.replyToID, let winning = messageIDByBackupID[replyTo] { - dto.replyToID = winning - } - modelContext.insert(Message(dto: dto)) - } - - return (inserted: toInsert.count, skipped: skipped, messageIDByBackupID: messageIDByBackupID) + guard upperBound > firstRelocatableChannelIndex else { return nil } + for candidate in firstRelocatableChannelIndex.., - existingMessageIDs: Set - ) throws -> (inserted: Int, skipped: Int, affectedMessageIDs: Set) { - // Only pre-fetch parents that actually appear as a MessageRepeat.messageID; - // on a large import the repeat set is orders of magnitude smaller than - // the full envelope message set, so loading every message into the - // context just to key a relationship map is wasted work. - let referencedParentIDs = Set(dtos.map(\.messageID)).intersection(existingMessageIDs) - let parentMessages = try fetchInChunks(keys: Array(referencedParentIDs)) { chunk in - let predicate = #Predicate { chunk.contains($0.id) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return nil + } + + /// A channel secret carries stable cryptographic identity only when it is non-empty. + /// An all-zero secret marks the public channel or an unconfigured slot, which is keyed + /// by slot index instead. + private func channelHasStableSecret(_ secret: Data) -> Bool { + !secret.isEmpty && !secret.allSatisfy { $0 == 0 } + } + + @discardableResult + func batchInsertMessages( + _ dtos: [MessageDTO], + existingKeys: Set, + existingIDsByKey: [String: [UUID]] + ) throws -> (inserted: Int, skipped: Int, messageIDByBackupID: [UUID: UUID]) { + var knownKeys = existingKeys + var idsByKey = existingIDsByKey + var messageIDByBackupID: [UUID: UUID] = [:] + var toInsert: [MessageDTO] = [] + toInsert.reserveCapacity(dtos.count) + var skipped = 0 + + // Pass 1: resolve duplicates and build the backup→local ID remap. Doing this + // before any insert lets pass 2 rewrite replyToID onto winning local UUIDs, + // so reply chains survive imports where the parent already exists locally. + for dto in dtos { + let key = messageBackupKey(for: dto) + if knownKeys.contains(key) { + // Deterministic tiebreak when multiple locals share a key. + let winning = idsByKey[key]?.min(by: { $0.uuidString < $1.uuidString }) + if let winning, winning != dto.id { + messageIDByBackupID[dto.id] = winning } - let messagesByID = Dictionary(uniqueKeysWithValues: parentMessages.map { ($0.id, $0) }) - - let result = insertUniqueWithParent( - dtos, - existingKeys: existingIDs, - existingParentIDs: existingMessageIDs, - parentID: \.messageID, - key: \.id, - construct: { MessageRepeat(dto: $0, message: messagesByID[$0.messageID]) } - ) - return (result.inserted, result.skipped, result.affectedParentIDs) + skipped += 1 + continue + } + knownKeys.insert(key) + idsByKey[key, default: []].append(dto.id) + toInsert.append(dto) } - @discardableResult - func batchInsertReactions( - _ dtos: [ReactionDTO], - existingKeys: Set, - existingMessageIDs: Set - ) throws -> (inserted: Int, skipped: Int, affectedMessageIDs: Set) { - let result = insertUniqueWithParent( - dtos, - existingKeys: existingKeys, - existingParentIDs: existingMessageIDs, - parentID: \.messageID, - key: { reactionKey(messageID: $0.messageID, senderName: $0.senderName, emoji: $0.emoji) }, - construct: Reaction.init(dto:) - ) - return (result.inserted, result.skipped, result.affectedParentIDs) + // Pass 2: insert, rewriting replyToID through the remap so retained messages + // point at the winning local parent rather than a skipped backup UUID. + for var dto in toInsert { + if let replyTo = dto.replyToID, let winning = messageIDByBackupID[replyTo] { + dto.replyToID = winning + } + modelContext.insert(Message(dto: dto)) } - @discardableResult - func batchInsertRoomMessages( - _ dtos: [RoomMessageDTO], - existingKeys: Set, - existingSessionIDs: Set - ) throws -> (inserted: Int, skipped: Int, affectedSessionIDs: Set) { - let result = insertUniqueWithParent( - dtos, - existingKeys: existingKeys, - existingParentIDs: existingSessionIDs, - parentID: \.sessionID, - key: { roomMessageKey(sessionID: $0.sessionID, deduplicationKey: $0.deduplicationKey) }, - construct: RoomMessage.init(dto:) - ) - return (result.inserted, result.skipped, result.affectedParentIDs) + return (inserted: toInsert.count, skipped: skipped, messageIDByBackupID: messageIDByBackupID) + } + + @discardableResult + func batchInsertMessageRepeats( + _ dtos: [MessageRepeatDTO], + existingIDs: Set, + existingMessageIDs: Set + ) throws -> (inserted: Int, skipped: Int, affectedMessageIDs: Set) { + // Only pre-fetch parents that actually appear as a MessageRepeat.messageID; + // on a large import the repeat set is orders of magnitude smaller than + // the full envelope message set, so loading every message into the + // context just to key a relationship map is wasted work. + let referencedParentIDs = Set(dtos.map(\.messageID)).intersection(existingMessageIDs) + let parentMessages = try fetchInChunks(keys: Array(referencedParentIDs)) { chunk in + let predicate = #Predicate { chunk.contains($0.id) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } - - @discardableResult - func batchInsertRemoteNodeSessions( - _ dtos: [RemoteNodeSessionDTO], - radioIDs: Set - ) throws -> (inserted: Int, skipped: Int, merged: Int, sessionIDsByKey: [String: UUID]) { - var existingSessionsByKey = try fetchExistingRemoteNodeSessionsByKey(radioIDs: radioIDs) - var sessionIDsByKey: [String: UUID] = [:] - sessionIDsByKey.reserveCapacity(dtos.count) - var inserted = 0 - var skipped = 0 - var merged = 0 - for dto in dtos { - let key = remoteNodeSessionKey(radioID: dto.radioID, publicKey: dto.publicKey) - if let existing = existingSessionsByKey[key] { - if mergeBackupMetadata(into: existing, from: dto) { - merged += 1 - } - skipped += 1 - sessionIDsByKey[key] = existing.id - continue - } - let session = RemoteNodeSession(dto: dto) - // Restored sessions are never live BLE connections. - session.isConnected = false - modelContext.insert(session) - existingSessionsByKey[key] = session - sessionIDsByKey[key] = session.id - inserted += 1 + let messagesByID = Dictionary(uniqueKeysWithValues: parentMessages.map { ($0.id, $0) }) + + let result = insertUniqueWithParent( + dtos, + existingKeys: existingIDs, + existingParentIDs: existingMessageIDs, + parentID: \.messageID, + key: \.id, + construct: { MessageRepeat(dto: $0, message: messagesByID[$0.messageID]) } + ) + return (result.inserted, result.skipped, result.affectedParentIDs) + } + + @discardableResult + func batchInsertReactions( + _ dtos: [ReactionDTO], + existingKeys: Set, + existingMessageIDs: Set + ) throws -> (inserted: Int, skipped: Int, affectedMessageIDs: Set) { + let result = insertUniqueWithParent( + dtos, + existingKeys: existingKeys, + existingParentIDs: existingMessageIDs, + parentID: \.messageID, + key: { reactionKey(messageID: $0.messageID, senderName: $0.senderName, emoji: $0.emoji) }, + construct: Reaction.init(dto:) + ) + return (result.inserted, result.skipped, result.affectedParentIDs) + } + + @discardableResult + func batchInsertRoomMessages( + _ dtos: [RoomMessageDTO], + existingKeys: Set, + existingSessionIDs: Set + ) throws -> (inserted: Int, skipped: Int, affectedSessionIDs: Set) { + let result = insertUniqueWithParent( + dtos, + existingKeys: existingKeys, + existingParentIDs: existingSessionIDs, + parentID: \.sessionID, + key: { roomMessageKey(sessionID: $0.sessionID, deduplicationKey: $0.deduplicationKey) }, + construct: RoomMessage.init(dto:) + ) + return (result.inserted, result.skipped, result.affectedParentIDs) + } + + @discardableResult + func batchInsertRemoteNodeSessions( + _ dtos: [RemoteNodeSessionDTO], + radioIDs: Set + ) throws -> (inserted: Int, skipped: Int, merged: Int, sessionIDsByKey: [String: UUID]) { + var existingSessionsByKey = try fetchExistingRemoteNodeSessionsByKey(radioIDs: radioIDs) + var sessionIDsByKey: [String: UUID] = [:] + sessionIDsByKey.reserveCapacity(dtos.count) + var inserted = 0 + var skipped = 0 + var merged = 0 + for dto in dtos { + let key = remoteNodeSessionKey(radioID: dto.radioID, publicKey: dto.publicKey) + if let existing = existingSessionsByKey[key] { + if mergeBackupMetadata(into: existing, from: dto) { + merged += 1 } + skipped += 1 + sessionIDsByKey[key] = existing.id + continue + } + let session = RemoteNodeSession(dto: dto) + // Restored sessions are never live BLE connections. + session.isConnected = false + modelContext.insert(session) + existingSessionsByKey[key] = session + sessionIDsByKey[key] = session.id + inserted += 1 + } - return (inserted, skipped, merged, sessionIDsByKey) + return (inserted, skipped, merged, sessionIDsByKey) + } + + @discardableResult + func batchInsertSavedTracePaths( + _ dtos: [SavedTracePathDTO] + ) throws -> (inserted: Int, skipped: Int, merged: Int) { + guard !dtos.isEmpty else { return (0, 0, 0) } + + var inserted = 0 + var skipped = 0 + var merged = 0 + let radioIDArray = Array(Set(dtos.map(\.radioID))) + let existingPaths = try fetchInChunks(keys: radioIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.radioID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } + var existingPathsByKey: [String: SavedTracePath] = [:] - @discardableResult - func batchInsertSavedTracePaths( - _ dtos: [SavedTracePathDTO] - ) throws -> (inserted: Int, skipped: Int, merged: Int) { - guard !dtos.isEmpty else { return (0, 0, 0) } - - var inserted = 0 - var skipped = 0 - var merged = 0 - let radioIDArray = Array(Set(dtos.map(\.radioID))) - let existingPaths = try fetchInChunks(keys: radioIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.radioID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - var existingPathsByKey: [String: SavedTracePath] = [:] + for path in existingPaths { + let pathKey = savedTracePathKey(radioID: path.radioID, pathBytes: path.pathBytes, hashSize: path.hashSize) + existingPathsByKey[pathKey] = path + } - for path in existingPaths { - let pathKey = savedTracePathKey(radioID: path.radioID, pathBytes: path.pathBytes, hashSize: path.hashSize) - existingPathsByKey[pathKey] = path + // Run ids are globally unique (`TracePathRun.id` is `.unique`), so dedupe + // store-wide, not per path: inserting a run whose id already exists + // anywhere would upsert the existing row and silently relocate it under + // a different path. + var seenRunIDs = try Set(modelContext.fetch(FetchDescriptor()).map(\.id)) + + for dto in dtos { + let key = savedTracePathKey(radioID: dto.radioID, pathBytes: dto.pathBytes, hashSize: dto.hashSize) + if let existingPath = existingPathsByKey[key] { + skipped += 1 + var appendedRun = false + + for runDTO in dto.runs where !seenRunIDs.contains(runDTO.id) { + let run = try TracePathRun(dto: runDTO) + run.savedPath = existingPath + modelContext.insert(run) + seenRunIDs.insert(runDTO.id) + appendedRun = true } - // Run ids are globally unique (`TracePathRun.id` is `.unique`), so dedupe - // store-wide, not per path: inserting a run whose id already exists - // anywhere would upsert the existing row and silently relocate it under - // a different path. - var seenRunIDs = Set(try modelContext.fetch(FetchDescriptor()).map(\.id)) - - for dto in dtos { - let key = savedTracePathKey(radioID: dto.radioID, pathBytes: dto.pathBytes, hashSize: dto.hashSize) - if let existingPath = existingPathsByKey[key] { - skipped += 1 - var appendedRun = false - - for runDTO in dto.runs where !seenRunIDs.contains(runDTO.id) { - let run = try TracePathRun(dto: runDTO) - run.savedPath = existingPath - modelContext.insert(run) - seenRunIDs.insert(runDTO.id) - appendedRun = true - } - - if appendedRun { - merged += 1 - } - continue - } - - let path = SavedTracePath( - id: dto.id, - radioID: dto.radioID, - name: dto.name, - pathBytes: dto.pathBytes, - hashSize: dto.hashSize, - createdDate: dto.createdDate - ) - modelContext.insert(path) - - for runDTO in dto.runs { - guard !seenRunIDs.contains(runDTO.id) else { continue } - let run = try TracePathRun(dto: runDTO) - run.savedPath = path - modelContext.insert(run) - seenRunIDs.insert(runDTO.id) - } - - existingPathsByKey[key] = path - inserted += 1 + if appendedRun { + merged += 1 } - return (inserted, skipped, merged) - } - - @discardableResult - func batchInsertBlockedChannelSenders( - _ dtos: [BlockedChannelSenderDTO], - existingKeys: Set - ) throws -> (inserted: Int, skipped: Int) { - insertUnique( - dtos, - existingKeys: existingKeys, - key: { blockedChannelSenderKey(radioID: $0.radioID, name: $0.name) }, - construct: BlockedChannelSender.init(dto:) - ) - } - - @discardableResult - func batchInsertNodeStatusSnapshots( - _ dtos: [NodeStatusSnapshotDTO], - existingKeys: Set - ) throws -> (inserted: Int, skipped: Int) { - insertUnique( - dtos, - existingKeys: existingKeys, - key: { nodeStatusSnapshotKey(nodePublicKey: $0.nodePublicKey, timestamp: $0.timestamp) }, - construct: NodeStatusSnapshot.init(dto:) - ) + continue + } + + let path = SavedTracePath( + id: dto.id, + radioID: dto.radioID, + name: dto.name, + pathBytes: dto.pathBytes, + hashSize: dto.hashSize, + createdDate: dto.createdDate + ) + modelContext.insert(path) + + for runDTO in dto.runs { + guard !seenRunIDs.contains(runDTO.id) else { continue } + let run = try TracePathRun(dto: runDTO) + run.savedPath = path + modelContext.insert(run) + seenRunIDs.insert(runDTO.id) + } + + existingPathsByKey[key] = path + inserted += 1 } + return (inserted, skipped, merged) + } + + @discardableResult + func batchInsertBlockedChannelSenders( + _ dtos: [BlockedChannelSenderDTO], + existingKeys: Set + ) throws -> (inserted: Int, skipped: Int) { + insertUnique( + dtos, + existingKeys: existingKeys, + key: { blockedChannelSenderKey(radioID: $0.radioID, name: $0.name) }, + construct: BlockedChannelSender.init(dto:) + ) + } + + @discardableResult + func batchInsertNodeStatusSnapshots( + _ dtos: [NodeStatusSnapshotDTO], + existingKeys: Set + ) throws -> (inserted: Int, skipped: Int) { + insertUnique( + dtos, + existingKeys: existingKeys, + key: { nodeStatusSnapshotKey(nodePublicKey: $0.nodePublicKey, timestamp: $0.timestamp) }, + construct: NodeStatusSnapshot.init(dto:) + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupExport.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupExport.swift index 652e24b2..c7c0ecc5 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupExport.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupExport.swift @@ -1,139 +1,138 @@ import Foundation import SwiftData -struct BackupExportSnapshot: Sendable { - let devices: [DeviceDTO] - let contacts: [ContactDTO] - let channels: [ChannelDTO] - let messages: [MessageDTO] - let messageRepeats: [MessageRepeatDTO] - let reactions: [ReactionDTO] - let roomMessages: [RoomMessageDTO] - let remoteNodeSessions: [RemoteNodeSessionDTO] - let savedTracePaths: [SavedTracePathDTO] - let blockedChannelSenders: [BlockedChannelSenderDTO] - let nodeStatusSnapshots: [NodeStatusSnapshotDTO] +struct BackupExportSnapshot { + let devices: [DeviceDTO] + let contacts: [ContactDTO] + let channels: [ChannelDTO] + let messages: [MessageDTO] + let messageRepeats: [MessageRepeatDTO] + let reactions: [ReactionDTO] + let roomMessages: [RoomMessageDTO] + let remoteNodeSessions: [RemoteNodeSessionDTO] + let savedTracePaths: [SavedTracePathDTO] + let blockedChannelSenders: [BlockedChannelSenderDTO] + let nodeStatusSnapshots: [NodeStatusSnapshotDTO] } // MARK: - Export (fetchAll) -extension PersistenceStore { - - public func fetchAllDevices() throws -> [DeviceDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { DeviceDTO(from: $0) } - } - - public func fetchAllContacts() throws -> [ContactDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { ContactDTO(from: $0) } - } - - public func fetchAllChannels() throws -> [ChannelDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { ChannelDTO(from: $0) } - } - - /// Fetches all messages for backup export, skipping external-storage link-preview - /// blobs at the DTO boundary so SwiftData never faults them in. Backfills the - /// deduplication key on pre-migration incoming messages so import can match them. - /// Outgoing messages are intentionally left nil — restore keys them on UUID identity. - func fetchAllMessagesForBackup() throws -> [MessageDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { message in - var dto = MessageDTO(from: message, includeLinkPreviewBlobs: false) - if dto.direction != .outgoing, dto.deduplicationKey == nil { - dto.deduplicationKey = DeduplicationKey.contentBased( - contactID: dto.contactID, - channelIndex: dto.channelIndex, - senderNodeName: dto.senderNodeName, - timestamp: dto.timestamp, - content: dto.text - ) - } - return dto - } - } - - public func fetchAllReactions() throws -> [ReactionDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { ReactionDTO(from: $0) } - } - - public func fetchAllRemoteNodeSessions() throws -> [RemoteNodeSessionDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { RemoteNodeSessionDTO(from: $0) } - } - - public func fetchAllSavedTracePaths() throws -> [SavedTracePathDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { SavedTracePathDTO(from: $0) } - } - - public func fetchAllBlockedChannelSenders() throws -> [BlockedChannelSenderDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { BlockedChannelSenderDTO(from: $0) } - } - - /// Scoped by parent message IDs so we don't fetch orphaned repeats. - /// Chunks the `contains` predicate to stay under SQLite's bound-parameter limit - /// on histories with tens of thousands of messages. - public func fetchAllMessageRepeats(messageIDs: Set) throws -> [MessageRepeatDTO] { - let messageIDArray = Array(messageIDs) - guard !messageIDArray.isEmpty else { return [] } - let repeats = try fetchInChunks(keys: messageIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - return repeats.map { MessageRepeatDTO(from: $0) } - } - - /// Scoped by parent session IDs so we don't fetch orphaned room messages. - public func fetchAllRoomMessages(sessionIDs: Set) throws -> [RoomMessageDTO] { - let sessionIDArray = Array(sessionIDs) - guard !sessionIDArray.isEmpty else { return [] } - let messages = try fetchInChunks(keys: sessionIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.sessionID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - return messages.map { RoomMessageDTO(from: $0) } +public extension PersistenceStore { + func fetchAllDevices() throws -> [DeviceDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { DeviceDTO(from: $0) } + } + + func fetchAllContacts() throws -> [ContactDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { ContactDTO(from: $0) } + } + + func fetchAllChannels() throws -> [ChannelDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { ChannelDTO(from: $0) } + } + + /// Fetches all messages for backup export, skipping external-storage link-preview + /// blobs at the DTO boundary so SwiftData never faults them in. Backfills the + /// deduplication key on pre-migration incoming messages so import can match them. + /// Outgoing messages are intentionally left nil — restore keys them on UUID identity. + internal func fetchAllMessagesForBackup() throws -> [MessageDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { message in + var dto = MessageDTO(from: message, includeLinkPreviewBlobs: false) + if dto.direction != .outgoing, dto.deduplicationKey == nil { + dto.deduplicationKey = DeduplicationKey.contentBased( + contactID: dto.contactID, + channelIndex: dto.channelIndex, + senderNodeName: dto.senderNodeName, + timestamp: dto.timestamp, + content: dto.text + ) + } + return dto } - - public func fetchAllNodeStatusSnapshots() throws -> [NodeStatusSnapshotDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { NodeStatusSnapshotDTO(from: $0) } + } + + func fetchAllReactions() throws -> [ReactionDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { ReactionDTO(from: $0) } + } + + func fetchAllRemoteNodeSessions() throws -> [RemoteNodeSessionDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { RemoteNodeSessionDTO(from: $0) } + } + + func fetchAllSavedTracePaths() throws -> [SavedTracePathDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { SavedTracePathDTO(from: $0) } + } + + func fetchAllBlockedChannelSenders() throws -> [BlockedChannelSenderDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { BlockedChannelSenderDTO(from: $0) } + } + + /// Scoped by parent message IDs so we don't fetch orphaned repeats. + /// Chunks the `contains` predicate to stay under SQLite's bound-parameter limit + /// on histories with tens of thousands of messages. + func fetchAllMessageRepeats(messageIDs: Set) throws -> [MessageRepeatDTO] { + let messageIDArray = Array(messageIDs) + guard !messageIDArray.isEmpty else { return [] } + let repeats = try fetchInChunks(keys: messageIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } - - /// Fetches all backup-relevant model data in a single store-actor turn. - /// - /// This keeps the export parent/child relationships aligned with the same - /// view of the store, rather than allowing sync writes on the authoritative - /// store actor to interleave between separate awaited fetches. - func fetchBackupExportSnapshot() throws -> BackupExportSnapshot { - let devices = try fetchAllDevices().map { $0.redactedForBackup() } - let contacts = try fetchAllContacts() - let channels = try fetchAllChannels() - let messages = try fetchAllMessagesForBackup() - let reactions = try fetchAllReactions() - let sessions = try fetchAllRemoteNodeSessions() - let tracePaths = try fetchAllSavedTracePaths() - let blockedSenders = try fetchAllBlockedChannelSenders() - let messageRepeats = try fetchAllMessageRepeats(messageIDs: Set(messages.map(\.id))) - let roomMessages = try fetchAllRoomMessages(sessionIDs: Set(sessions.map(\.id))) - let nodeSnapshots = try fetchAllNodeStatusSnapshots() - - return BackupExportSnapshot( - devices: devices, - contacts: contacts, - channels: channels, - messages: messages, - messageRepeats: messageRepeats, - reactions: reactions, - roomMessages: roomMessages, - remoteNodeSessions: sessions, - savedTracePaths: tracePaths, - blockedChannelSenders: blockedSenders, - nodeStatusSnapshots: nodeSnapshots - ) + return repeats.map { MessageRepeatDTO(from: $0) } + } + + /// Scoped by parent session IDs so we don't fetch orphaned room messages. + func fetchAllRoomMessages(sessionIDs: Set) throws -> [RoomMessageDTO] { + let sessionIDArray = Array(sessionIDs) + guard !sessionIDArray.isEmpty else { return [] } + let messages = try fetchInChunks(keys: sessionIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.sessionID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } + return messages.map { RoomMessageDTO(from: $0) } + } + + func fetchAllNodeStatusSnapshots() throws -> [NodeStatusSnapshotDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { NodeStatusSnapshotDTO(from: $0) } + } + + /// Fetches all backup-relevant model data in a single store-actor turn. + /// + /// This keeps the export parent/child relationships aligned with the same + /// view of the store, rather than allowing sync writes on the authoritative + /// store actor to interleave between separate awaited fetches. + internal func fetchBackupExportSnapshot() throws -> BackupExportSnapshot { + let devices = try fetchAllDevices().map { $0.redactedForBackup() } + let contacts = try fetchAllContacts() + let channels = try fetchAllChannels() + let messages = try fetchAllMessagesForBackup() + let reactions = try fetchAllReactions() + let sessions = try fetchAllRemoteNodeSessions() + let tracePaths = try fetchAllSavedTracePaths() + let blockedSenders = try fetchAllBlockedChannelSenders() + let messageRepeats = try fetchAllMessageRepeats(messageIDs: Set(messages.map(\.id))) + let roomMessages = try fetchAllRoomMessages(sessionIDs: Set(sessions.map(\.id))) + let nodeSnapshots = try fetchAllNodeStatusSnapshots() + + return BackupExportSnapshot( + devices: devices, + contacts: contacts, + channels: channels, + messages: messages, + messageRepeats: messageRepeats, + reactions: reactions, + roomMessages: roomMessages, + remoteNodeSessions: sessions, + savedTracePaths: tracePaths, + blockedChannelSenders: blockedSenders, + nodeStatusSnapshots: nodeSnapshots + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift index 434d0e5c..29fb01dd 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift @@ -10,287 +10,284 @@ private let maxKeysPerFetch = 900 /// `#Predicate` that filters by `keys.contains($0.field)` when `keys.count` could /// realistically grow into the thousands. func fetchInChunks( - keys: [Key], - chunkSize: Int = maxKeysPerFetch, - fetcher: ([Key]) throws -> [Model] + keys: [Key], + chunkSize: Int = maxKeysPerFetch, + fetcher: ([Key]) throws -> [Model] ) throws -> [Model] { - guard !keys.isEmpty else { return [] } - if keys.count <= chunkSize { - return try fetcher(keys) - } - var combined: [Model] = [] - var index = 0 - while index < keys.count { - try Task.checkCancellation() - let end = Swift.min(index + chunkSize, keys.count) - combined.append(contentsOf: try fetcher(Array(keys[index.. String { + "\(radioID)-\(pathBytes.base64EncodedString())-\(hashSize)" + } - func savedTracePathKey(radioID: UUID, pathBytes: Data, hashSize: Int) -> String { - "\(radioID)-\(pathBytes.base64EncodedString())-\(hashSize)" - } + func contactKey(radioID: UUID, publicKey: Data) -> String { + "\(radioID)-\(publicKey.base64EncodedString())" + } - func contactKey(radioID: UUID, publicKey: Data) -> String { - "\(radioID)-\(publicKey.base64EncodedString())" - } + func channelKey(radioID: UUID, index: UInt8) -> String { + "\(radioID)-\(index)" + } - func channelKey(radioID: UUID, index: UInt8) -> String { - "\(radioID)-\(index)" + /// Dedup key for backup reconciliation. + /// Outgoing messages key on their UUID so two intentional sends with identical + /// text/timestamp/recipient do not collapse on restore. Incoming messages fall + /// back to their stored live-sync dedup key (or a content-based derivation when + /// the field was never populated — e.g. pre-schema rows), scoped by `radioID` + /// so two companion radios that both received the same wire packet restore as + /// two rows rather than collapsing into one under a single radio. + func messageBackupKey(for dto: MessageDTO) -> String { + if dto.direction == .outgoing { + return "\(DeduplicationKey.outgoingIdentityPrefix)\(dto.id.uuidString)" } + let base = dto.deduplicationKey ?? DeduplicationKey.contentBased( + contactID: dto.contactID, + channelIndex: dto.channelIndex, + senderNodeName: dto.senderNodeName, + timestamp: dto.timestamp, + content: dto.text + ) + return "\(dto.radioID.uuidString)-\(base)" + } - /// Dedup key for backup reconciliation. - /// Outgoing messages key on their UUID so two intentional sends with identical - /// text/timestamp/recipient do not collapse on restore. Incoming messages fall - /// back to their stored live-sync dedup key (or a content-based derivation when - /// the field was never populated — e.g. pre-schema rows), scoped by `radioID` - /// so two companion radios that both received the same wire packet restore as - /// two rows rather than collapsing into one under a single radio. - func messageBackupKey(for dto: MessageDTO) -> String { - if dto.direction == .outgoing { - return "\(DeduplicationKey.outgoingIdentityPrefix)\(dto.id.uuidString)" - } - let base = dto.deduplicationKey ?? DeduplicationKey.contentBased( - contactID: dto.contactID, - channelIndex: dto.channelIndex, - senderNodeName: dto.senderNodeName, - timestamp: dto.timestamp, - content: dto.text - ) - return "\(dto.radioID.uuidString)-\(base)" + func messageBackupKey(for message: Message) -> String { + if message.direction == .outgoing { + return "\(DeduplicationKey.outgoingIdentityPrefix)\(message.id.uuidString)" } + let base = message.deduplicationKey ?? DeduplicationKey.contentBased( + contactID: message.contactID, + channelIndex: message.channelIndex, + senderNodeName: message.senderNodeName, + timestamp: message.timestamp, + content: message.text + ) + return "\(message.radioID.uuidString)-\(base)" + } - func messageBackupKey(for message: Message) -> String { - if message.direction == .outgoing { - return "\(DeduplicationKey.outgoingIdentityPrefix)\(message.id.uuidString)" - } - let base = message.deduplicationKey ?? DeduplicationKey.contentBased( - contactID: message.contactID, - channelIndex: message.channelIndex, - senderNodeName: message.senderNodeName, - timestamp: message.timestamp, - content: message.text - ) - return "\(message.radioID.uuidString)-\(base)" - } + func reactionKey(messageID: UUID, senderName: String, emoji: String) -> String { + "\(messageID)-\(senderName)-\(emoji)" + } - func reactionKey(messageID: UUID, senderName: String, emoji: String) -> String { - "\(messageID)-\(senderName)-\(emoji)" - } - - func roomMessageKey(sessionID: UUID, deduplicationKey: String) -> String { - "\(sessionID)-\(deduplicationKey)" - } + func roomMessageKey(sessionID: UUID, deduplicationKey: String) -> String { + "\(sessionID)-\(deduplicationKey)" + } - func blockedChannelSenderKey(radioID: UUID, name: String) -> String { - "\(radioID)-\(name)" - } + func blockedChannelSenderKey(radioID: UUID, name: String) -> String { + "\(radioID)-\(name)" + } - func remoteNodeSessionKey(radioID: UUID, publicKey: Data) -> String { - "\(radioID)-\(publicKey.base64EncodedString())" - } + func remoteNodeSessionKey(radioID: UUID, publicKey: Data) -> String { + "\(radioID)-\(publicKey.base64EncodedString())" + } - func nodeStatusSnapshotKey(nodePublicKey: Data, timestamp: Date) -> String { - // Use milliseconds (Int) to stay stable across JSON `.secondsSince1970` - // encode/decode roundtrips — Double.bitPattern can drift on roundtrip - // even when the two Dates compare equal. - // Two distinct snapshots for one node within the same millisecond intentionally - // coalesce (the second is counted as skipped). This is acceptable, rare diagnostic - // coalescing; the id is deliberately not part of the key so re-import stays idempotent. - let milliseconds = Int(timestamp.timeIntervalSince1970 * 1000) - return "\(nodePublicKey.base64EncodedString())-\(milliseconds)" - } + func nodeStatusSnapshotKey(nodePublicKey: Data, timestamp: Date) -> String { + // Use milliseconds (Int) to stay stable across JSON `.secondsSince1970` + // encode/decode roundtrips — Double.bitPattern can drift on roundtrip + // even when the two Dates compare equal. + // Two distinct snapshots for one node within the same millisecond intentionally + // coalesce (the second is counted as skipped). This is acceptable, rare diagnostic + // coalescing; the id is deliberately not part of the key so re-import stays idempotent. + let milliseconds = Int(timestamp.timeIntervalSince1970 * 1000) + return "\(nodePublicKey.base64EncodedString())-\(milliseconds)" + } - /// Rewrite the contact-UUID segment of a DM dedup key. Only the `dm-{uuid}-` prefix - /// is touched, never raw substrings elsewhere in the key, so a UUID that happens to - /// appear inside the trailing hash (vanishingly unlikely, but possible) is preserved. - func rewriteDMDeduplicationKey(_ key: String, from backupID: UUID, to localID: UUID) -> String { - let oldPrefix = "\(DeduplicationKey.directMessagePrefix)\(backupID.uuidString)-" - guard key.hasPrefix(oldPrefix) else { return key } - let newPrefix = "\(DeduplicationKey.directMessagePrefix)\(localID.uuidString)-" - return newPrefix + key.dropFirst(oldPrefix.count) - } + /// Rewrite the contact-UUID segment of a DM dedup key. Only the `dm-{uuid}-` prefix + /// is touched, never raw substrings elsewhere in the key, so a UUID that happens to + /// appear inside the trailing hash (vanishingly unlikely, but possible) is preserved. + func rewriteDMDeduplicationKey(_ key: String, from backupID: UUID, to localID: UUID) -> String { + let oldPrefix = "\(DeduplicationKey.directMessagePrefix)\(backupID.uuidString)-" + guard key.hasPrefix(oldPrefix) else { return key } + let newPrefix = "\(DeduplicationKey.directMessagePrefix)\(localID.uuidString)-" + return newPrefix + key.dropFirst(oldPrefix.count) + } - /// Rewrite the channel-index segment of a content-based channel dedup key when a - /// backup channel is relocated to a different local slot. Only the leading - /// `ch-{index}-` segment is touched, mirroring ``rewriteDMDeduplicationKey``, so a - /// numeric run elsewhere in the key (timestamp, hash) cannot be misinterpreted as the - /// index. Keys that don't carry this prefix (outgoing identity keys, DM keys) pass - /// through unchanged. - func rewriteChannelDeduplicationKey(_ key: String, from backupIndex: UInt8, to localIndex: UInt8) -> String { - let oldPrefix = "\(DeduplicationKey.channelPrefix)\(backupIndex)-" - guard key.hasPrefix(oldPrefix) else { return key } - let newPrefix = "\(DeduplicationKey.channelPrefix)\(localIndex)-" - return newPrefix + key.dropFirst(oldPrefix.count) - } + /// Rewrite the channel-index segment of a content-based channel dedup key when a + /// backup channel is relocated to a different local slot. Only the leading + /// `ch-{index}-` segment is touched, mirroring ``rewriteDMDeduplicationKey``, so a + /// numeric run elsewhere in the key (timestamp, hash) cannot be misinterpreted as the + /// index. Keys that don't carry this prefix (outgoing identity keys, DM keys) pass + /// through unchanged. + func rewriteChannelDeduplicationKey(_ key: String, from backupIndex: UInt8, to localIndex: UInt8) -> String { + let oldPrefix = "\(DeduplicationKey.channelPrefix)\(backupIndex)-" + guard key.hasPrefix(oldPrefix) else { return key } + let newPrefix = "\(DeduplicationKey.channelPrefix)\(localIndex)-" + return newPrefix + key.dropFirst(oldPrefix.count) + } } // MARK: - Per-table existing-row lookups extension PersistenceStore { + func fetchExistingContactsByKey(radioIDs: Set) throws -> [String: Contact] { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [:] } - func fetchExistingContactsByKey(radioIDs: Set) throws -> [String: Contact] { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [:] } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return Dictionary(uniqueKeysWithValues: contacts.map { + (contactKey(radioID: $0.radioID, publicKey: $0.publicKey), $0) + }) + } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - return Dictionary(uniqueKeysWithValues: contacts.map { - (contactKey(radioID: $0.radioID, publicKey: $0.publicKey), $0) - }) - } + func fetchExistingChannelsByKey(radioIDs: Set) throws -> [String: Channel] { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [:] } - func fetchExistingChannelsByKey(radioIDs: Set) throws -> [String: Channel] { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [:] } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return Dictionary(uniqueKeysWithValues: channels.map { + (channelKey(radioID: $0.radioID, index: $0.index), $0) + }) + } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - return Dictionary(uniqueKeysWithValues: channels.map { - (channelKey(radioID: $0.radioID, index: $0.index), $0) - }) - } - - /// Fetches every local channel for the given radios as raw models. Channel - /// reconciliation needs to index local rows by both secret and slot in one pass, - /// which the `(radioID, index)`-keyed dictionary above can't express. - func fetchExistingChannels(radioIDs: Set) throws -> [Channel] { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [] } + /// Fetches every local channel for the given radios as raw models. Channel + /// reconciliation needs to index local rows by both secret and slot in one pass, + /// which the `(radioID, index)`-keyed dictionary above can't express. + func fetchExistingChannels(radioIDs: Set) throws -> [Channel] { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [] } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + } - func fetchExistingRemoteNodeSessionsByKey(radioIDs: Set) throws -> [String: RemoteNodeSession] { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [:] } + func fetchExistingRemoteNodeSessionsByKey(radioIDs: Set) throws -> [String: RemoteNodeSession] { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [:] } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let sessions = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - return Dictionary(uniqueKeysWithValues: sessions.map { - (remoteNodeSessionKey(radioID: $0.radioID, publicKey: $0.publicKey), $0) - }) - } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let sessions = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return Dictionary(uniqueKeysWithValues: sessions.map { + (remoteNodeSessionKey(radioID: $0.radioID, publicKey: $0.publicKey), $0) + }) + } } // MARK: - Merge pre-existing rows from backup metadata extension PersistenceStore { - - func mergeBackupMetadata(into contact: Contact, from dto: ContactDTO) -> Bool { - var changed = false - if contact.nickname == nil, let backupNickname = dto.nickname { - contact.nickname = backupNickname - changed = true - } - // Safety: never un-block, un-mute, or un-favorite via import - if dto.isBlocked && !contact.isBlocked { - contact.isBlocked = true - changed = true - } - if dto.isMuted && !contact.isMuted { - contact.isMuted = true - changed = true - } - if dto.isFavorite && !contact.isFavorite { - contact.isFavorite = true - changed = true - } - if let backupDate = dto.lastMessageDate { - if contact.lastMessageDate == nil || contact.lastMessageDate! < backupDate { - contact.lastMessageDate = backupDate - changed = true - } - } - let mergedUnread = max(contact.unreadCount, dto.unreadCount) - if contact.unreadCount != mergedUnread { - contact.unreadCount = mergedUnread - changed = true - } - let mergedMention = max(contact.unreadMentionCount, dto.unreadMentionCount) - if contact.unreadMentionCount != mergedMention { - contact.unreadMentionCount = mergedMention - changed = true - } - if contact.ocvPreset == nil, let backupPreset = dto.ocvPreset { - contact.ocvPreset = backupPreset - changed = true - } - if contact.customOCVArrayString == nil, let backupOCV = dto.customOCVArrayString { - contact.customOCVArrayString = backupOCV - changed = true - } - return changed + func mergeBackupMetadata(into contact: Contact, from dto: ContactDTO) -> Bool { + var changed = false + if contact.nickname == nil, let backupNickname = dto.nickname { + contact.nickname = backupNickname + changed = true + } + // Safety: never un-block, un-mute, or un-favorite via import + if dto.isBlocked, !contact.isBlocked { + contact.isBlocked = true + changed = true + } + if dto.isMuted, !contact.isMuted { + contact.isMuted = true + changed = true + } + if dto.isFavorite, !contact.isFavorite { + contact.isFavorite = true + changed = true + } + if let backupDate = dto.lastMessageDate { + if contact.lastMessageDate == nil || contact.lastMessageDate! < backupDate { + contact.lastMessageDate = backupDate + changed = true + } + } + let mergedUnread = max(contact.unreadCount, dto.unreadCount) + if contact.unreadCount != mergedUnread { + contact.unreadCount = mergedUnread + changed = true + } + let mergedMention = max(contact.unreadMentionCount, dto.unreadMentionCount) + if contact.unreadMentionCount != mergedMention { + contact.unreadMentionCount = mergedMention + changed = true + } + if contact.ocvPreset == nil, let backupPreset = dto.ocvPreset { + contact.ocvPreset = backupPreset + changed = true + } + if contact.customOCVArrayString == nil, let backupOCV = dto.customOCVArrayString { + contact.customOCVArrayString = backupOCV + changed = true } + return changed + } - func mergeBackupMetadata(into channel: Channel, from dto: ChannelDTO) -> Bool { - var changed = false - if let backupDate = dto.lastMessageDate { - if channel.lastMessageDate == nil || channel.lastMessageDate! < backupDate { - channel.lastMessageDate = backupDate - changed = true - } - } - let mergedUnread = max(channel.unreadCount, dto.unreadCount) - if channel.unreadCount != mergedUnread { - channel.unreadCount = mergedUnread - changed = true - } - let mergedMention = max(channel.unreadMentionCount, dto.unreadMentionCount) - if channel.unreadMentionCount != mergedMention { - channel.unreadMentionCount = mergedMention - changed = true - } - // Only adopt backup notification level if local is at default - if channel.notificationLevel == .all && dto.notificationLevel != .all { - channel.notificationLevel = dto.notificationLevel - changed = true - } - if dto.isFavorite && !channel.isFavorite { - channel.isFavorite = true - changed = true - } - if channel.floodScope == .inherit, dto.floodScope != .inherit { - channel.floodScope = dto.floodScope - changed = true - } - return changed + func mergeBackupMetadata(into channel: Channel, from dto: ChannelDTO) -> Bool { + var changed = false + if let backupDate = dto.lastMessageDate { + if channel.lastMessageDate == nil || channel.lastMessageDate! < backupDate { + channel.lastMessageDate = backupDate + changed = true + } } + let mergedUnread = max(channel.unreadCount, dto.unreadCount) + if channel.unreadCount != mergedUnread { + channel.unreadCount = mergedUnread + changed = true + } + let mergedMention = max(channel.unreadMentionCount, dto.unreadMentionCount) + if channel.unreadMentionCount != mergedMention { + channel.unreadMentionCount = mergedMention + changed = true + } + // Only adopt backup notification level if local is at default + if channel.notificationLevel == .all, dto.notificationLevel != .all { + channel.notificationLevel = dto.notificationLevel + changed = true + } + if dto.isFavorite, !channel.isFavorite { + channel.isFavorite = true + changed = true + } + if channel.floodScope == .inherit, dto.floodScope != .inherit { + channel.floodScope = dto.floodScope + changed = true + } + return changed + } - func mergeBackupMetadata(into session: RemoteNodeSession, from dto: RemoteNodeSessionDTO) -> Bool { - var changed = false - let mergedUnread = max(session.unreadCount, dto.unreadCount) - if session.unreadCount != mergedUnread { - session.unreadCount = mergedUnread - changed = true - } - // Only adopt backup notification level if local is at default - if session.notificationLevel == .all && dto.notificationLevel != .all { - session.notificationLevel = dto.notificationLevel - changed = true - } - if dto.isFavorite && !session.isFavorite { - session.isFavorite = true - changed = true - } - let mergedSyncTimestamp = max(session.lastSyncTimestamp, dto.lastSyncTimestamp) - if session.lastSyncTimestamp != mergedSyncTimestamp { - session.lastSyncTimestamp = mergedSyncTimestamp - changed = true - } - if let backupDate = dto.lastMessageDate { - if session.lastMessageDate == nil || session.lastMessageDate! < backupDate { - session.lastMessageDate = backupDate - changed = true - } - } - return changed + func mergeBackupMetadata(into session: RemoteNodeSession, from dto: RemoteNodeSessionDTO) -> Bool { + var changed = false + let mergedUnread = max(session.unreadCount, dto.unreadCount) + if session.unreadCount != mergedUnread { + session.unreadCount = mergedUnread + changed = true + } + // Only adopt backup notification level if local is at default + if session.notificationLevel == .all, dto.notificationLevel != .all { + session.notificationLevel = dto.notificationLevel + changed = true + } + if dto.isFavorite, !session.isFavorite { + session.isFavorite = true + changed = true + } + let mergedSyncTimestamp = max(session.lastSyncTimestamp, dto.lastSyncTimestamp) + if session.lastSyncTimestamp != mergedSyncTimestamp { + session.lastSyncTimestamp = mergedSyncTimestamp + changed = true + } + if let backupDate = dto.lastMessageDate { + if session.lastMessageDate == nil || session.lastMessageDate! < backupDate { + session.lastMessageDate = backupDate + changed = true + } } + return changed + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift index b04c14b5..65fac9bf 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift @@ -10,709 +10,705 @@ private let cancellationCheckStride = 500 // MARK: - Import Key Lookups (existingKeys) -extension PersistenceStore { - - /// Fetches every local Device in one pass and returns a publicKey → radioID map, - /// which both `buildRadioIDMapping` and `batchInsertDevices` consume — avoiding the - /// previous per-device `fetchDevice(publicKey:)` loop plus a second full-table scan. - public func existingDeviceRadioIDsByPublicKey() throws -> [Data: UUID] { - let descriptor = FetchDescriptor() - let devices = try modelContext.fetch(descriptor) - return Dictionary(devices.map { ($0.publicKey, $0.radioID) }, uniquingKeysWith: { first, _ in first }) - } - - /// Fetches all messages for the given radioIDs and returns both the dedup key set - /// and the dedup-key-to-IDs mapping in a single pass (avoids two full-table scans). - /// Outgoing messages key on identity; incoming messages share a content-based key - /// only when they represent the same wire packet. - public func existingMessageLookups( - radioIDs: Set - ) throws -> (keys: Set, idsByKey: [String: [UUID]]) { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return ([], [:]) } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let descriptor = FetchDescriptor(predicate: predicate) - let messages = try modelContext.fetch(descriptor) - var keys = Set() - var idsByKey: [String: [UUID]] = [:] - for message in messages { - let key = messageBackupKey(for: message) - keys.insert(key) - idsByKey[key, default: []].append(message.id) - } - return (keys, idsByKey) - } - - /// Each `MessageRepeat` row represents a distinct hearing of a message, so keying - /// on `id` (unique-by-construction) is sufficient and preserves multiple repeats - /// that traverse the same route. - public func existingMessageRepeatIDs(messageIDs: Set) throws -> Set { - let messageIDArray = Array(messageIDs) - guard !messageIDArray.isEmpty else { return [] } - let repeats = try fetchInChunks(keys: messageIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - return Set(repeats.map(\.id)) +public extension PersistenceStore { + /// Fetches every local Device in one pass and returns a publicKey → radioID map, + /// which both `buildRadioIDMapping` and `batchInsertDevices` consume — avoiding the + /// previous per-device `fetchDevice(publicKey:)` loop plus a second full-table scan. + func existingDeviceRadioIDsByPublicKey() throws -> [Data: UUID] { + let descriptor = FetchDescriptor() + let devices = try modelContext.fetch(descriptor) + return Dictionary(devices.map { ($0.publicKey, $0.radioID) }, uniquingKeysWith: { first, _ in first }) + } + + /// Fetches all messages for the given radioIDs and returns both the dedup key set + /// and the dedup-key-to-IDs mapping in a single pass (avoids two full-table scans). + /// Outgoing messages key on identity; incoming messages share a content-based key + /// only when they represent the same wire packet. + func existingMessageLookups( + radioIDs: Set + ) throws -> (keys: Set, idsByKey: [String: [UUID]]) { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return ([], [:]) } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let descriptor = FetchDescriptor(predicate: predicate) + let messages = try modelContext.fetch(descriptor) + var keys = Set() + var idsByKey: [String: [UUID]] = [:] + for message in messages { + let key = messageBackupKey(for: message) + keys.insert(key) + idsByKey[key, default: []].append(message.id) } - - public func existingReactionKeys(messageIDs: Set) throws -> Set { - let messageIDArray = Array(messageIDs) - guard !messageIDArray.isEmpty else { return [] } - let reactions = try fetchInChunks(keys: messageIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - return Set(reactions.map { reactionKey(messageID: $0.messageID, senderName: $0.senderName, emoji: $0.emoji) }) + return (keys, idsByKey) + } + + /// Each `MessageRepeat` row represents a distinct hearing of a message, so keying + /// on `id` (unique-by-construction) is sufficient and preserves multiple repeats + /// that traverse the same route. + func existingMessageRepeatIDs(messageIDs: Set) throws -> Set { + let messageIDArray = Array(messageIDs) + guard !messageIDArray.isEmpty else { return [] } + let repeats = try fetchInChunks(keys: messageIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } - - public func existingRoomMessageKeys(sessionIDs: Set) throws -> Set { - let sessionIDArray = Array(sessionIDs) - guard !sessionIDArray.isEmpty else { return [] } - let predicate = #Predicate { sessionIDArray.contains($0.sessionID) } - let descriptor = FetchDescriptor(predicate: predicate) - let messages = try modelContext.fetch(descriptor) - return Set(messages.map { roomMessageKey(sessionID: $0.sessionID, deduplicationKey: $0.deduplicationKey) }) - } - - public func existingBlockedSenderKeys(radioIDs: Set) throws -> Set { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [] } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let descriptor = FetchDescriptor(predicate: predicate) - let senders = try modelContext.fetch(descriptor) - return Set(senders.map { blockedChannelSenderKey(radioID: $0.radioID, name: $0.name) }) - } - - public func existingNodeStatusSnapshotKeys() throws -> Set { - let descriptor = FetchDescriptor() - let snapshots = try modelContext.fetch(descriptor) - return Set(snapshots.map { - nodeStatusSnapshotKey(nodePublicKey: $0.nodePublicKey, timestamp: $0.timestamp) - }) + return Set(repeats.map(\.id)) + } + + func existingReactionKeys(messageIDs: Set) throws -> Set { + let messageIDArray = Array(messageIDs) + guard !messageIDArray.isEmpty else { return [] } + let reactions = try fetchInChunks(keys: messageIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } + return Set(reactions.map { reactionKey(messageID: $0.messageID, senderName: $0.senderName, emoji: $0.emoji) }) + } + + func existingRoomMessageKeys(sessionIDs: Set) throws -> Set { + let sessionIDArray = Array(sessionIDs) + guard !sessionIDArray.isEmpty else { return [] } + let predicate = #Predicate { sessionIDArray.contains($0.sessionID) } + let descriptor = FetchDescriptor(predicate: predicate) + let messages = try modelContext.fetch(descriptor) + return Set(messages.map { roomMessageKey(sessionID: $0.sessionID, deduplicationKey: $0.deduplicationKey) }) + } + + func existingBlockedSenderKeys(radioIDs: Set) throws -> Set { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [] } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let descriptor = FetchDescriptor(predicate: predicate) + let senders = try modelContext.fetch(descriptor) + return Set(senders.map { blockedChannelSenderKey(radioID: $0.radioID, name: $0.name) }) + } + + func existingNodeStatusSnapshotKeys() throws -> Set { + let descriptor = FetchDescriptor() + let snapshots = try modelContext.fetch(descriptor) + return Set(snapshots.map { + nodeStatusSnapshotKey(nodePublicKey: $0.nodePublicKey, timestamp: $0.timestamp) + }) + } } // MARK: - Import -extension PersistenceStore { - - /// Result of matching backup devices to local devices by publicKey. - fileprivate struct RadioIDMapping { - let mapping: [UUID: UUID] - let unmatchedDevices: [DeviceDTO] - let duplicateDeviceCount: Int - } - - /// Imports the SwiftData-backed portion of a backup in one store-actor turn. - /// - /// Running the whole database import inside `PersistenceStore` prevents other - /// live-store writes from interleaving between awaited hops, and the `defer` - /// cleanup guarantees autosave state is restored even if the batch fails. - @discardableResult - func importBackupDatabase( - _ envelope: AppBackupEnvelope - ) throws -> ImportResult { - var result = ImportResult() - let originalAutosaveEnabled = modelContext.autosaveEnabled - var didCommit = false - - modelContext.autosaveEnabled = false - - defer { - if !didCommit { - modelContext.rollback() - } - modelContext.autosaveEnabled = originalAutosaveEnabled - } - - let localDeviceRadioIDsByPublicKey = try existingDeviceRadioIDsByPublicKey() - let radioMap = buildRadioIDMapping( - from: envelope, - localDeviceRadioIDsByPublicKey: localDeviceRadioIDsByPublicKey - ) - if radioMap.duplicateDeviceCount > 0 { - backupLogger.warning( - "Backup contained \(radioMap.duplicateDeviceCount) duplicate device public key(s); first occurrence wins." - ) - result.record(.devices, skipped: radioMap.duplicateDeviceCount) - } - - var contacts = envelope.contacts - var channels = envelope.channels - var messages = envelope.messages - var reactions = envelope.reactions - var sessions = envelope.remoteNodeSessions - var tracePaths = envelope.savedTracePaths - var blockedSenders = envelope.blockedChannelSenders - var messageRepeats = envelope.messageRepeats - var roomMessages = envelope.roomMessages - - // Skip the rewrite when every mapping entry is identity (same-device restore), - // avoiding seven copy-on-write copies. - if radioMap.mapping.contains(where: { $0.key != $0.value }) { - applyRadioIDMapping(radioMap.mapping, to: &contacts, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &channels, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &messages, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &reactions, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &sessions, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &tracePaths, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &blockedSenders, keyPath: \.radioID) - } - - try Task.checkCancellation() - let deviceResult = try batchInsertDevices( - radioMap.unmatchedDevices, - existingKeys: Set(localDeviceRadioIDsByPublicKey.keys) - ) - result.record(.devices, inserted: deviceResult.inserted, skipped: deviceResult.skipped) - - var allRadioIDs = Set(radioMap.mapping.values) - allRadioIDs.formUnion(contacts.map(\.radioID)) - allRadioIDs.formUnion(channels.map(\.radioID)) - allRadioIDs.formUnion(messages.map(\.radioID)) - allRadioIDs.formUnion(reactions.map(\.radioID)) - allRadioIDs.formUnion(sessions.map(\.radioID)) - allRadioIDs.formUnion(tracePaths.map(\.radioID)) - allRadioIDs.formUnion(blockedSenders.map(\.radioID)) - - let contactResult = try batchInsertContacts(contacts, radioIDs: allRadioIDs) - result.record( - .contacts, - inserted: contactResult.inserted, - merged: contactResult.merged, - skipped: contactResult.skipped - ) - - let contactIDMapping = buildContactIDMapping( - contacts: contacts, - contactIDsByKey: contactResult.contactIDsByKey - ) - applyContactIDMapping(contactIDMapping, toMessages: &messages, toReactions: &reactions) - - try Task.checkCancellation() - let maxChannelsByRadioID = maxChannelsByLocalRadioID( - devices: envelope.devices, - radioMap: radioMap.mapping - ) - let channelResult = try batchInsertChannels( - channels, - radioIDs: allRadioIDs, - maxChannelsByRadioID: maxChannelsByRadioID - ) - result.record( - .channels, - inserted: channelResult.inserted, - merged: channelResult.merged, - skipped: channelResult.skipped, - dropped: channelResult.dropped - ) - - // Slots whose channel identity changed — newly occupied inserts plus dropped slots — so - // the caller clears their drafts. Merge-by-secret slots keep their occupant and are - // excluded; see `ChannelBatchInsertResult.insertedLocalIndices` for the full rationale. - for (radioID, indices) in channelResult.insertedLocalIndices { - result.channelSlotsAffectedByImport[radioID, default: []].formUnion(indices) - } - for (radioID, dropped) in channelResult.droppedChannelIndices { - result.channelSlotsAffectedByImport[radioID, default: []].formUnion(dropped) - } - - // Channels are now final, so rewrite channel-message slots before inserting - // messages: relocated channels carry their messages to the new slot, and messages - // for a dropped (no-free-slot) channel are removed rather than mis-associated. - let droppedChildren = applyChannelIndexMapping( - channelResult.channelIndexRemap, - droppedChannelIndices: channelResult.droppedChannelIndices, - toMessages: &messages, - toReactions: &reactions - ) +public extension PersistenceStore { + /// Result of matching backup devices to local devices by publicKey. + fileprivate struct RadioIDMapping { + let mapping: [UUID: UUID] + let unmatchedDevices: [DeviceDTO] + let duplicateDeviceCount: Int + } + + /// Imports the SwiftData-backed portion of a backup in one store-actor turn. + /// + /// Running the whole database import inside `PersistenceStore` prevents other + /// live-store writes from interleaving between awaited hops, and the `defer` + /// cleanup guarantees autosave state is restored even if the batch fails. + @discardableResult + internal func importBackupDatabase( + _ envelope: AppBackupEnvelope + ) throws -> ImportResult { + var result = ImportResult() + let originalAutosaveEnabled = modelContext.autosaveEnabled + var didCommit = false + + modelContext.autosaveEnabled = false + + defer { + if !didCommit { + modelContext.rollback() + } + modelContext.autosaveEnabled = originalAutosaveEnabled + } - try Task.checkCancellation() - let sessionResult = try batchInsertRemoteNodeSessions(sessions, radioIDs: allRadioIDs) - result.record( - .remoteNodeSessions, - inserted: sessionResult.inserted, - merged: sessionResult.merged, - skipped: sessionResult.skipped - ) + let localDeviceRadioIDsByPublicKey = try existingDeviceRadioIDsByPublicKey() + let radioMap = buildRadioIDMapping( + from: envelope, + localDeviceRadioIDsByPublicKey: localDeviceRadioIDsByPublicKey + ) + if radioMap.duplicateDeviceCount > 0 { + backupLogger.warning( + "Backup contained \(radioMap.duplicateDeviceCount) duplicate device public key(s); first occurrence wins." + ) + result.record(.devices, skipped: radioMap.duplicateDeviceCount) + } - try Task.checkCancellation() - let messageLookups = try existingMessageLookups(radioIDs: allRadioIDs) - let messageResult = try batchInsertMessages( - messages, - existingKeys: messageLookups.keys, - existingIDsByKey: messageLookups.idsByKey - ) - result.record(.messages, inserted: messageResult.inserted, skipped: messageResult.skipped, dropped: droppedChildren.messages) - - let messageIDMapping = messageResult.messageIDByBackupID - let allMessageIDs = Set(messages.map { messageIDMapping[$0.id] ?? $0.id }) - applyMessageIDMapping(messageIDMapping, toRepeats: &messageRepeats, toReactions: &reactions) - - let existingRepeatIDs = try existingMessageRepeatIDs(messageIDs: allMessageIDs) - let repeatResult = try batchInsertMessageRepeats( - messageRepeats, - existingIDs: existingRepeatIDs, - existingMessageIDs: allMessageIDs - ) - result.record(.messageRepeats, inserted: repeatResult.inserted, skipped: repeatResult.skipped) - - let existingReactionKeys = try existingReactionKeys(messageIDs: allMessageIDs) - let reactionResult = try batchInsertReactions( - reactions, - existingKeys: existingReactionKeys, - existingMessageIDs: allMessageIDs - ) - result.record(.reactions, inserted: reactionResult.inserted, skipped: reactionResult.skipped, dropped: droppedChildren.reactions) - - let affectedMessageIDs = repeatResult.affectedMessageIDs.union(reactionResult.affectedMessageIDs) - try recomputeMessageCaches(messageIDs: affectedMessageIDs) - - let sessionIDMapping = buildSessionIDMapping( - sessions: sessions, - sessionIDsByKey: sessionResult.sessionIDsByKey - ) - let allSessionIDs = Set(sessions.map { sessionIDMapping[$0.id] ?? $0.id }) - applySessionIDMapping(sessionIDMapping, toRoomMessages: &roomMessages) + var contacts = envelope.contacts + var channels = envelope.channels + var messages = envelope.messages + var reactions = envelope.reactions + var sessions = envelope.remoteNodeSessions + var tracePaths = envelope.savedTracePaths + var blockedSenders = envelope.blockedChannelSenders + var messageRepeats = envelope.messageRepeats + var roomMessages = envelope.roomMessages + + // Skip the rewrite when every mapping entry is identity (same-device restore), + // avoiding seven copy-on-write copies. + if radioMap.mapping.contains(where: { $0.key != $0.value }) { + applyRadioIDMapping(radioMap.mapping, to: &contacts, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &channels, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &messages, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &reactions, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &sessions, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &tracePaths, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &blockedSenders, keyPath: \.radioID) + } - try Task.checkCancellation() - let existingRoomMsgKeys = try existingRoomMessageKeys(sessionIDs: allSessionIDs) - let roomMsgResult = try batchInsertRoomMessages( - roomMessages, - existingKeys: existingRoomMsgKeys, - existingSessionIDs: allSessionIDs - ) - result.record(.roomMessages, inserted: roomMsgResult.inserted, skipped: roomMsgResult.skipped) - - let traceResult = try batchInsertSavedTracePaths(tracePaths) - result.record( - .savedTracePaths, - inserted: traceResult.inserted, - merged: traceResult.merged, - skipped: traceResult.skipped - ) - - let existingBlockedKeys = try existingBlockedSenderKeys(radioIDs: allRadioIDs) - let blockedResult = try batchInsertBlockedChannelSenders( - blockedSenders, - existingKeys: existingBlockedKeys - ) - result.record(.blockedChannelSenders, inserted: blockedResult.inserted, skipped: blockedResult.skipped) - - let existingSnapshotKeys = try existingNodeStatusSnapshotKeys() - let snapshotResult = try batchInsertNodeStatusSnapshots( - envelope.nodeStatusSnapshots, - existingKeys: existingSnapshotKeys - ) - result.record(.nodeStatusSnapshots, inserted: snapshotResult.inserted, skipped: snapshotResult.skipped) - - try reconcileLastMessageDates( - messages: messages, - roomMessages: roomMessages, - affectedRoomSessionIDs: roomMsgResult.affectedSessionIDs - ) + try Task.checkCancellation() + let deviceResult = try batchInsertDevices( + radioMap.unmatchedDevices, + existingKeys: Set(localDeviceRadioIDsByPublicKey.keys) + ) + result.record(.devices, inserted: deviceResult.inserted, skipped: deviceResult.skipped) + + var allRadioIDs = Set(radioMap.mapping.values) + allRadioIDs.formUnion(contacts.map(\.radioID)) + allRadioIDs.formUnion(channels.map(\.radioID)) + allRadioIDs.formUnion(messages.map(\.radioID)) + allRadioIDs.formUnion(reactions.map(\.radioID)) + allRadioIDs.formUnion(sessions.map(\.radioID)) + allRadioIDs.formUnion(tracePaths.map(\.radioID)) + allRadioIDs.formUnion(blockedSenders.map(\.radioID)) + + let contactResult = try batchInsertContacts(contacts, radioIDs: allRadioIDs) + result.record( + .contacts, + inserted: contactResult.inserted, + merged: contactResult.merged, + skipped: contactResult.skipped + ) + + let contactIDMapping = buildContactIDMapping( + contacts: contacts, + contactIDsByKey: contactResult.contactIDsByKey + ) + applyContactIDMapping(contactIDMapping, toMessages: &messages, toReactions: &reactions) + + try Task.checkCancellation() + let maxChannelsByRadioID = maxChannelsByLocalRadioID( + devices: envelope.devices, + radioMap: radioMap.mapping + ) + let channelResult = try batchInsertChannels( + channels, + radioIDs: allRadioIDs, + maxChannelsByRadioID: maxChannelsByRadioID + ) + result.record( + .channels, + inserted: channelResult.inserted, + merged: channelResult.merged, + skipped: channelResult.skipped, + dropped: channelResult.dropped + ) + + // Slots whose channel identity changed — newly occupied inserts plus dropped slots — so + // the caller clears their drafts. Merge-by-secret slots keep their occupant and are + // excluded; see `ChannelBatchInsertResult.insertedLocalIndices` for the full rationale. + for (radioID, indices) in channelResult.insertedLocalIndices { + result.channelSlotsAffectedByImport[radioID, default: []].formUnion(indices) + } + for (radioID, dropped) in channelResult.droppedChannelIndices { + result.channelSlotsAffectedByImport[radioID, default: []].formUnion(dropped) + } - try Task.checkCancellation() - #if DEBUG - try backupImportFaultInjection?() - #endif - try modelContext.save() - didCommit = true + // Channels are now final, so rewrite channel-message slots before inserting + // messages: relocated channels carry their messages to the new slot, and messages + // for a dropped (no-free-slot) channel are removed rather than mis-associated. + let droppedChildren = applyChannelIndexMapping( + channelResult.channelIndexRemap, + droppedChannelIndices: channelResult.droppedChannelIndices, + toMessages: &messages, + toReactions: &reactions + ) + + try Task.checkCancellation() + let sessionResult = try batchInsertRemoteNodeSessions(sessions, radioIDs: allRadioIDs) + result.record( + .remoteNodeSessions, + inserted: sessionResult.inserted, + merged: sessionResult.merged, + skipped: sessionResult.skipped + ) + + try Task.checkCancellation() + let messageLookups = try existingMessageLookups(radioIDs: allRadioIDs) + let messageResult = try batchInsertMessages( + messages, + existingKeys: messageLookups.keys, + existingIDsByKey: messageLookups.idsByKey + ) + result.record(.messages, inserted: messageResult.inserted, skipped: messageResult.skipped, dropped: droppedChildren.messages) + + let messageIDMapping = messageResult.messageIDByBackupID + let allMessageIDs = Set(messages.map { messageIDMapping[$0.id] ?? $0.id }) + applyMessageIDMapping(messageIDMapping, toRepeats: &messageRepeats, toReactions: &reactions) + + let existingRepeatIDs = try existingMessageRepeatIDs(messageIDs: allMessageIDs) + let repeatResult = try batchInsertMessageRepeats( + messageRepeats, + existingIDs: existingRepeatIDs, + existingMessageIDs: allMessageIDs + ) + result.record(.messageRepeats, inserted: repeatResult.inserted, skipped: repeatResult.skipped) + + let existingReactionKeys = try existingReactionKeys(messageIDs: allMessageIDs) + let reactionResult = try batchInsertReactions( + reactions, + existingKeys: existingReactionKeys, + existingMessageIDs: allMessageIDs + ) + result.record(.reactions, inserted: reactionResult.inserted, skipped: reactionResult.skipped, dropped: droppedChildren.reactions) + + let affectedMessageIDs = repeatResult.affectedMessageIDs.union(reactionResult.affectedMessageIDs) + try recomputeMessageCaches(messageIDs: affectedMessageIDs) + + let sessionIDMapping = buildSessionIDMapping( + sessions: sessions, + sessionIDsByKey: sessionResult.sessionIDsByKey + ) + let allSessionIDs = Set(sessions.map { sessionIDMapping[$0.id] ?? $0.id }) + applySessionIDMapping(sessionIDMapping, toRoomMessages: &roomMessages) + + try Task.checkCancellation() + let existingRoomMsgKeys = try existingRoomMessageKeys(sessionIDs: allSessionIDs) + let roomMsgResult = try batchInsertRoomMessages( + roomMessages, + existingKeys: existingRoomMsgKeys, + existingSessionIDs: allSessionIDs + ) + result.record(.roomMessages, inserted: roomMsgResult.inserted, skipped: roomMsgResult.skipped) + + let traceResult = try batchInsertSavedTracePaths(tracePaths) + result.record( + .savedTracePaths, + inserted: traceResult.inserted, + merged: traceResult.merged, + skipped: traceResult.skipped + ) + + let existingBlockedKeys = try existingBlockedSenderKeys(radioIDs: allRadioIDs) + let blockedResult = try batchInsertBlockedChannelSenders( + blockedSenders, + existingKeys: existingBlockedKeys + ) + result.record(.blockedChannelSenders, inserted: blockedResult.inserted, skipped: blockedResult.skipped) + + let existingSnapshotKeys = try existingNodeStatusSnapshotKeys() + let snapshotResult = try batchInsertNodeStatusSnapshots( + envelope.nodeStatusSnapshots, + existingKeys: existingSnapshotKeys + ) + result.record(.nodeStatusSnapshots, inserted: snapshotResult.inserted, skipped: snapshotResult.skipped) + + try reconcileLastMessageDates( + messages: messages, + roomMessages: roomMessages, + affectedRoomSessionIDs: roomMsgResult.affectedSessionIDs + ) + + try Task.checkCancellation() + #if DEBUG + try backupImportFaultInjection?() + #endif + try modelContext.save() + didCommit = true - #if DEBUG - backupImportPostCommitHook?() - #endif + #if DEBUG + backupImportPostCommitHook?() + #endif - return result - } + return result + } - #if DEBUG - public func setBackupImportFaultInjection(_ hook: (@Sendable () throws -> Void)?) { - backupImportFaultInjection = hook + #if DEBUG + func setBackupImportFaultInjection(_ hook: (@Sendable () throws -> Void)?) { + backupImportFaultInjection = hook } - public func setBackupImportPostCommitHook(_ hook: (@Sendable () -> Void)?) { - backupImportPostCommitHook = hook + func setBackupImportPostCommitHook(_ hook: (@Sendable () -> Void)?) { + backupImportPostCommitHook = hook } - #endif + #endif } // MARK: - Import Mapping Helpers -extension PersistenceStore { - - /// Matches each backup device to a local device by publicKey, producing a - /// radioID remap for child records. Duplicate publicKeys in the envelope - /// (corruption) are counted so the caller can surface the skip. - /// - /// Keys are matched verbatim, with no empty/zero-key guard, because no MC1 export can - /// produce an empty or all-zero `publicKey`: every persist path sources it from a 32-byte - /// post-handshake `selfInfo.publicKey`, and `Device.init` requires it, so this branch only - /// ever sees real, distinct keys. The guard is intentionally omitted — the only way to reach - /// a collision (two devices carrying empty or zero `publicKey`s on distinct `radioID`s, which - /// would collapse into one partition and merge two radios' rows) is a hand-edited file, which - /// is out of scope for import validation. - fileprivate func buildRadioIDMapping( - from envelope: AppBackupEnvelope, - localDeviceRadioIDsByPublicKey: [Data: UUID] - ) -> RadioIDMapping { - var mapping: [UUID: UUID] = [:] - var unmatched: [DeviceDTO] = [] - var firstRadioIDByPublicKey: [Data: UUID] = [:] - var duplicates = 0 - for backupDevice in envelope.devices { - if let firstRadioID = firstRadioIDByPublicKey[backupDevice.publicKey] { - duplicates += 1 - // Point the duplicate's radioID at the first occurrence's local mapping so - // any child records keyed off it resolve to a Device row that actually exists. - if backupDevice.radioID != firstRadioID, mapping[backupDevice.radioID] == nil, - let winningLocal = mapping[firstRadioID] { - mapping[backupDevice.radioID] = winningLocal - } - continue - } - firstRadioIDByPublicKey[backupDevice.publicKey] = backupDevice.radioID - if let localRadioID = localDeviceRadioIDsByPublicKey[backupDevice.publicKey] { - mapping[backupDevice.radioID] = localRadioID - } else { - mapping[backupDevice.radioID] = backupDevice.radioID - unmatched.append(backupDevice) - } - } - return RadioIDMapping( - mapping: mapping, - unmatchedDevices: unmatched, - duplicateDeviceCount: duplicates - ) - } - - /// Rewrites a UUID field on every element of `dtos` through `mapping`. - /// Leaves elements whose current value isn't in the mapping untouched. - fileprivate func applyRadioIDMapping( - _ mapping: [UUID: UUID], - to dtos: inout [T], - keyPath: WritableKeyPath - ) { - for i in dtos.indices { - let current = dtos[i][keyPath: keyPath] - if let remapped = mapping[current] { - dtos[i][keyPath: keyPath] = remapped - } - } +private extension PersistenceStore { + /// Matches each backup device to a local device by publicKey, producing a + /// radioID remap for child records. Duplicate publicKeys in the envelope + /// (corruption) are counted so the caller can surface the skip. + /// + /// Keys are matched verbatim, with no empty/zero-key guard, because no MC1 export can + /// produce an empty or all-zero `publicKey`: every persist path sources it from a 32-byte + /// post-handshake `selfInfo.publicKey`, and `Device.init` requires it, so this branch only + /// ever sees real, distinct keys. The guard is intentionally omitted — the only way to reach + /// a collision (two devices carrying empty or zero `publicKey`s on distinct `radioID`s, which + /// would collapse into one partition and merge two radios' rows) is a hand-edited file, which + /// is out of scope for import validation. + func buildRadioIDMapping( + from envelope: AppBackupEnvelope, + localDeviceRadioIDsByPublicKey: [Data: UUID] + ) -> RadioIDMapping { + var mapping: [UUID: UUID] = [:] + var unmatched: [DeviceDTO] = [] + var firstRadioIDByPublicKey: [Data: UUID] = [:] + var duplicates = 0 + for backupDevice in envelope.devices { + if let firstRadioID = firstRadioIDByPublicKey[backupDevice.publicKey] { + duplicates += 1 + // Point the duplicate's radioID at the first occurrence's local mapping so + // any child records keyed off it resolve to a Device row that actually exists. + if backupDevice.radioID != firstRadioID, mapping[backupDevice.radioID] == nil, + let winningLocal = mapping[firstRadioID] { + mapping[backupDevice.radioID] = winningLocal + } + continue + } + firstRadioIDByPublicKey[backupDevice.publicKey] = backupDevice.radioID + if let localRadioID = localDeviceRadioIDsByPublicKey[backupDevice.publicKey] { + mapping[backupDevice.radioID] = localRadioID + } else { + mapping[backupDevice.radioID] = backupDevice.radioID + unmatched.append(backupDevice) + } } - - /// Returns backup-contact-ID → local-contact-ID entries for contacts that - /// merged into an existing local row (identity mappings are omitted). - fileprivate func buildContactIDMapping( - contacts: [ContactDTO], - contactIDsByKey: [String: UUID] - ) -> [UUID: UUID] { - var mapping: [UUID: UUID] = [:] - for contact in contacts { - let key = contactKey(radioID: contact.radioID, publicKey: contact.publicKey) - if let localID = contactIDsByKey[key], localID != contact.id { - mapping[contact.id] = localID - } - } - return mapping - } - - /// Rewrites `contactID` on messages (including DM dedup keys) and reactions. - fileprivate func applyContactIDMapping( - _ mapping: [UUID: UUID], - toMessages messages: inout [MessageDTO], - toReactions reactions: inout [ReactionDTO] - ) { - guard !mapping.isEmpty else { return } - for i in messages.indices { - guard let backupID = messages[i].contactID, - let localID = mapping[backupID] else { continue } - messages[i].contactID = localID - if let dedupKey = messages[i].deduplicationKey { - messages[i].deduplicationKey = rewriteDMDeduplicationKey(dedupKey, from: backupID, to: localID) - } - } - for i in reactions.indices { - guard let backupID = reactions[i].contactID, - let localID = mapping[backupID] else { continue } - reactions[i].contactID = localID - } + return RadioIDMapping( + mapping: mapping, + unmatchedDevices: unmatched, + duplicateDeviceCount: duplicates + ) + } + + /// Rewrites a UUID field on every element of `dtos` through `mapping`. + /// Leaves elements whose current value isn't in the mapping untouched. + func applyRadioIDMapping( + _ mapping: [UUID: UUID], + to dtos: inout [T], + keyPath: WritableKeyPath + ) { + for i in dtos.indices { + let current = dtos[i][keyPath: keyPath] + if let remapped = mapping[current] { + dtos[i][keyPath: keyPath] = remapped + } } - - /// Resolves each device's channel capacity to the local radioID it maps to, so the - /// channel reconciler can bound free-slot search by `maxChannels`. The envelope's - /// device radioIDs are pre-remap; `radioMap` rewrites them to the local partition key, - /// matching the radioID the channel DTOs were already remapped onto. - fileprivate func maxChannelsByLocalRadioID( - devices: [DeviceDTO], - radioMap: [UUID: UUID] - ) -> [UUID: UInt8] { - var result: [UUID: UInt8] = [:] - for device in devices { - let localRadioID = radioMap[device.radioID] ?? device.radioID - // First occurrence wins, consistent with duplicate-device handling upstream. - if result[localRadioID] == nil { - result[localRadioID] = device.maxChannels - } - } - return result - } - - /// Rewrites `channelIndex` on channel messages and reactions to the slot their channel - /// was placed at locally, rewriting the content-based channel dedup key in lockstep so - /// a second import of the same backup still deduplicates. Messages and reactions - /// belonging to a channel that had no free local slot are dropped, since no placement - /// exists to attach them to. Mirrors how ``applyContactIDMapping`` rewrites the DM - /// dedup key alongside the remapped identifier. - @discardableResult - fileprivate func applyChannelIndexMapping( - _ remap: [UUID: [UInt8: UInt8]], - droppedChannelIndices: [UUID: Set], - toMessages messages: inout [MessageDTO], - toReactions reactions: inout [ReactionDTO] - ) -> (messages: Int, reactions: Int) { - guard !remap.isEmpty || !droppedChannelIndices.isEmpty else { return (0, 0) } - - var droppedMessages = 0 - var droppedReactions = 0 - if !droppedChannelIndices.isEmpty { - let beforeMessages = messages.count - messages.removeAll { dto in - guard let index = dto.channelIndex else { return false } - return droppedChannelIndices[dto.radioID]?.contains(index) ?? false - } - droppedMessages = beforeMessages - messages.count - - let beforeReactions = reactions.count - reactions.removeAll { dto in - guard let index = dto.channelIndex else { return false } - return droppedChannelIndices[dto.radioID]?.contains(index) ?? false - } - droppedReactions = beforeReactions - reactions.count - } - - if !remap.isEmpty { - for i in messages.indices { - guard let backupIndex = messages[i].channelIndex, - let localIndex = remap[messages[i].radioID]?[backupIndex] else { continue } - messages[i].channelIndex = localIndex - if let dedupKey = messages[i].deduplicationKey { - messages[i].deduplicationKey = rewriteChannelDeduplicationKey( - dedupKey, from: backupIndex, to: localIndex - ) - } - } - for i in reactions.indices { - guard let backupIndex = reactions[i].channelIndex, - let localIndex = remap[reactions[i].radioID]?[backupIndex] else { continue } - reactions[i].channelIndex = localIndex - } - } - return (droppedMessages, droppedReactions) - } - - /// Rewrites `messageID` on both message repeats and reactions so they - /// reference the local (post-merge) parent. - fileprivate func applyMessageIDMapping( - _ mapping: [UUID: UUID], - toRepeats repeats: inout [MessageRepeatDTO], - toReactions reactions: inout [ReactionDTO] - ) { - guard !mapping.isEmpty else { return } - for i in repeats.indices { - if let localID = mapping[repeats[i].messageID] { - repeats[i].messageID = localID - } - } - for i in reactions.indices { - if let localID = mapping[reactions[i].messageID] { - reactions[i].messageID = localID - } - } + } + + /// Returns backup-contact-ID → local-contact-ID entries for contacts that + /// merged into an existing local row (identity mappings are omitted). + func buildContactIDMapping( + contacts: [ContactDTO], + contactIDsByKey: [String: UUID] + ) -> [UUID: UUID] { + var mapping: [UUID: UUID] = [:] + for contact in contacts { + let key = contactKey(radioID: contact.radioID, publicKey: contact.publicKey) + if let localID = contactIDsByKey[key], localID != contact.id { + mapping[contact.id] = localID + } } - - /// Returns backup-session-ID → local-session-ID entries for sessions - /// that merged into an existing local row (identity mappings omitted). - fileprivate func buildSessionIDMapping( - sessions: [RemoteNodeSessionDTO], - sessionIDsByKey: [String: UUID] - ) -> [UUID: UUID] { - var mapping: [UUID: UUID] = [:] - for session in sessions { - let key = remoteNodeSessionKey(radioID: session.radioID, publicKey: session.publicKey) - if let localID = sessionIDsByKey[key], localID != session.id { - mapping[session.id] = localID - } - } - return mapping - } - - /// Rewrites `sessionID` on room messages so they attach to the local - /// (post-merge) session row. - fileprivate func applySessionIDMapping( - _ mapping: [UUID: UUID], - toRoomMessages roomMessages: inout [RoomMessageDTO] - ) { - guard !mapping.isEmpty else { return } - for i in roomMessages.indices { - if let localID = mapping[roomMessages[i].sessionID] { - roomMessages[i].sessionID = localID - } - } + return mapping + } + + /// Rewrites `contactID` on messages (including DM dedup keys) and reactions. + func applyContactIDMapping( + _ mapping: [UUID: UUID], + toMessages messages: inout [MessageDTO], + toReactions reactions: inout [ReactionDTO] + ) { + guard !mapping.isEmpty else { return } + for i in messages.indices { + guard let backupID = messages[i].contactID, + let localID = mapping[backupID] else { continue } + messages[i].contactID = localID + if let dedupKey = messages[i].deduplicationKey { + messages[i].deduplicationKey = rewriteDMDeduplicationKey(dedupKey, from: backupID, to: localID) + } + } + for i in reactions.indices { + guard let backupID = reactions[i].contactID, + let localID = mapping[backupID] else { continue } + reactions[i].contactID = localID + } + } + + /// Resolves each device's channel capacity to the local radioID it maps to, so the + /// channel reconciler can bound free-slot search by `maxChannels`. The envelope's + /// device radioIDs are pre-remap; `radioMap` rewrites them to the local partition key, + /// matching the radioID the channel DTOs were already remapped onto. + func maxChannelsByLocalRadioID( + devices: [DeviceDTO], + radioMap: [UUID: UUID] + ) -> [UUID: UInt8] { + var result: [UUID: UInt8] = [:] + for device in devices { + let localRadioID = radioMap[device.radioID] ?? device.radioID + // First occurrence wins, consistent with duplicate-device handling upstream. + if result[localRadioID] == nil { + result[localRadioID] = device.maxChannels + } + } + return result + } + + /// Rewrites `channelIndex` on channel messages and reactions to the slot their channel + /// was placed at locally, rewriting the content-based channel dedup key in lockstep so + /// a second import of the same backup still deduplicates. Messages and reactions + /// belonging to a channel that had no free local slot are dropped, since no placement + /// exists to attach them to. Mirrors how ``applyContactIDMapping`` rewrites the DM + /// dedup key alongside the remapped identifier. + @discardableResult + func applyChannelIndexMapping( + _ remap: [UUID: [UInt8: UInt8]], + droppedChannelIndices: [UUID: Set], + toMessages messages: inout [MessageDTO], + toReactions reactions: inout [ReactionDTO] + ) -> (messages: Int, reactions: Int) { + guard !remap.isEmpty || !droppedChannelIndices.isEmpty else { return (0, 0) } + + var droppedMessages = 0 + var droppedReactions = 0 + if !droppedChannelIndices.isEmpty { + let beforeMessages = messages.count + messages.removeAll { dto in + guard let index = dto.channelIndex else { return false } + return droppedChannelIndices[dto.radioID]?.contains(index) ?? false + } + droppedMessages = beforeMessages - messages.count + + let beforeReactions = reactions.count + reactions.removeAll { dto in + guard let index = dto.channelIndex else { return false } + return droppedChannelIndices[dto.radioID]?.contains(index) ?? false + } + droppedReactions = beforeReactions - reactions.count } - /// Refreshes `lastMessageDate` on contacts, channels, and remote-node - /// sessions that received new rows during this import. - /// - /// The max date per target is computed from the imported DTOs in-memory, - /// so the store only fetches the contact/channel/session rows it needs - /// to mutate. Duplicates in the envelope are harmless: their timestamps - /// are already reflected in the pre-existing `lastMessageDate`, and the - /// max-vs-current check filters them out. - fileprivate func reconcileLastMessageDates( - messages: [MessageDTO], - roomMessages: [RoomMessageDTO], - affectedRoomSessionIDs: Set - ) throws { - var contactMaxDates: [UUID: Date] = [:] - var channelMaxDates: [UUID: [UInt8: Date]] = [:] - for message in messages { - if let contactID = message.contactID { - let existing = contactMaxDates[contactID] - if existing == nil || existing! < message.createdAt { - contactMaxDates[contactID] = message.createdAt - } - } - if let index = message.channelIndex { - let existing = channelMaxDates[message.radioID]?[index] - if existing == nil || existing! < message.createdAt { - channelMaxDates[message.radioID, default: [:]][index] = message.createdAt - } - } - } + if !remap.isEmpty { + for i in messages.indices { + guard let backupIndex = messages[i].channelIndex, + let localIndex = remap[messages[i].radioID]?[backupIndex] else { continue } + messages[i].channelIndex = localIndex + if let dedupKey = messages[i].deduplicationKey { + messages[i].deduplicationKey = rewriteChannelDeduplicationKey( + dedupKey, from: backupIndex, to: localIndex + ) + } + } + for i in reactions.indices { + guard let backupIndex = reactions[i].channelIndex, + let localIndex = remap[reactions[i].radioID]?[backupIndex] else { continue } + reactions[i].channelIndex = localIndex + } + } + return (droppedMessages, droppedReactions) + } + + /// Rewrites `messageID` on both message repeats and reactions so they + /// reference the local (post-merge) parent. + func applyMessageIDMapping( + _ mapping: [UUID: UUID], + toRepeats repeats: inout [MessageRepeatDTO], + toReactions reactions: inout [ReactionDTO] + ) { + guard !mapping.isEmpty else { return } + for i in repeats.indices { + if let localID = mapping[repeats[i].messageID] { + repeats[i].messageID = localID + } + } + for i in reactions.indices { + if let localID = mapping[reactions[i].messageID] { + reactions[i].messageID = localID + } + } + } + + /// Returns backup-session-ID → local-session-ID entries for sessions + /// that merged into an existing local row (identity mappings omitted). + func buildSessionIDMapping( + sessions: [RemoteNodeSessionDTO], + sessionIDsByKey: [String: UUID] + ) -> [UUID: UUID] { + var mapping: [UUID: UUID] = [:] + for session in sessions { + let key = remoteNodeSessionKey(radioID: session.radioID, publicKey: session.publicKey) + if let localID = sessionIDsByKey[key], localID != session.id { + mapping[session.id] = localID + } + } + return mapping + } + + /// Rewrites `sessionID` on room messages so they attach to the local + /// (post-merge) session row. + func applySessionIDMapping( + _ mapping: [UUID: UUID], + toRoomMessages roomMessages: inout [RoomMessageDTO] + ) { + guard !mapping.isEmpty else { return } + for i in roomMessages.indices { + if let localID = mapping[roomMessages[i].sessionID] { + roomMessages[i].sessionID = localID + } + } + } + + /// Refreshes `lastMessageDate` on contacts, channels, and remote-node + /// sessions that received new rows during this import. + /// + /// The max date per target is computed from the imported DTOs in-memory, + /// so the store only fetches the contact/channel/session rows it needs + /// to mutate. Duplicates in the envelope are harmless: their timestamps + /// are already reflected in the pre-existing `lastMessageDate`, and the + /// max-vs-current check filters them out. + func reconcileLastMessageDates( + messages: [MessageDTO], + roomMessages: [RoomMessageDTO], + affectedRoomSessionIDs: Set + ) throws { + var contactMaxDates: [UUID: Date] = [:] + var channelMaxDates: [UUID: [UInt8: Date]] = [:] + for message in messages { + if let contactID = message.contactID { + let existing = contactMaxDates[contactID] + if existing == nil || existing! < message.createdAt { + contactMaxDates[contactID] = message.createdAt + } + } + if let index = message.channelIndex { + let existing = channelMaxDates[message.radioID]?[index] + if existing == nil || existing! < message.createdAt { + channelMaxDates[message.radioID, default: [:]][index] = message.createdAt + } + } + } - if !contactMaxDates.isEmpty { - try applyLastMessageDatesToContacts(contactMaxDates) - } - if !channelMaxDates.isEmpty { - try applyLastMessageDatesToChannels(channelMaxDates) - } + if !contactMaxDates.isEmpty { + try applyLastMessageDatesToContacts(contactMaxDates) + } + if !channelMaxDates.isEmpty { + try applyLastMessageDatesToChannels(channelMaxDates) + } - guard !affectedRoomSessionIDs.isEmpty else { return } - var sessionMaxDates: [UUID: Date] = [:] - for roomMessage in roomMessages where affectedRoomSessionIDs.contains(roomMessage.sessionID) { - let existing = sessionMaxDates[roomMessage.sessionID] - if existing == nil || existing! < roomMessage.createdAt { - sessionMaxDates[roomMessage.sessionID] = roomMessage.createdAt - } - } - if !sessionMaxDates.isEmpty { - try applyLastMessageDatesToRemoteNodeSessions(sessionMaxDates) - } + guard !affectedRoomSessionIDs.isEmpty else { return } + var sessionMaxDates: [UUID: Date] = [:] + for roomMessage in roomMessages where affectedRoomSessionIDs.contains(roomMessage.sessionID) { + let existing = sessionMaxDates[roomMessage.sessionID] + if existing == nil || existing! < roomMessage.createdAt { + sessionMaxDates[roomMessage.sessionID] = roomMessage.createdAt + } } + if !sessionMaxDates.isEmpty { + try applyLastMessageDatesToRemoteNodeSessions(sessionMaxDates) + } + } } // MARK: - Import Reconciliation extension PersistenceStore { - - /// Recomputes cached heardRepeats count and reactionSummary for messages - /// that received new child rows during import. - public func recomputeMessageCaches(messageIDs: Set) throws { - guard !messageIDs.isEmpty else { return } - try Task.checkCancellation() - let idArray = Array(messageIDs) - - let messages = try fetchInChunks(keys: idArray) { chunk in - let predicate = #Predicate { chunk.contains($0.id) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - - let allRepeats = try fetchInChunks(keys: idArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - var repeatCountsByMessageID: [UUID: Int] = [:] - for r in allRepeats { - repeatCountsByMessageID[r.messageID, default: 0] += 1 - } - - try Task.checkCancellation() - let allReactions = try fetchInChunks(keys: idArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - var reactionsByMessageID: [UUID: [Reaction]] = [:] - for r in allReactions { - reactionsByMessageID[r.messageID, default: []].append(r) - } - - for (offset, message) in messages.enumerated() { - if offset % cancellationCheckStride == 0 { - try Task.checkCancellation() - } - let newHeardRepeats = repeatCountsByMessageID[message.id] ?? 0 - if message.heardRepeats != newHeardRepeats { - message.heardRepeats = newHeardRepeats - } - - let newSummary: String? - if let reactions = reactionsByMessageID[message.id], !reactions.isEmpty { - let reactionDTOs = reactions.map { ReactionDTO(from: $0) } - newSummary = ReactionParser.buildSummary(from: reactionDTOs) - } else { - newSummary = nil - } - if message.reactionSummary != newSummary { - message.reactionSummary = newSummary - } - } + /// Recomputes cached heardRepeats count and reactionSummary for messages + /// that received new child rows during import. + public func recomputeMessageCaches(messageIDs: Set) throws { + guard !messageIDs.isEmpty else { return } + try Task.checkCancellation() + let idArray = Array(messageIDs) + + let messages = try fetchInChunks(keys: idArray) { chunk in + let predicate = #Predicate { chunk.contains($0.id) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } - /// Advances `Contact.lastMessageDate` toward the per-contact max timestamp - /// from the just-imported messages. Existing values are preserved when - /// they're already newer (e.g., a DTO imported out of order). - fileprivate func applyLastMessageDatesToContacts(_ maxDates: [UUID: Date]) throws { - try Task.checkCancellation() - let idArray = Array(maxDates.keys) - let predicate = #Predicate { idArray.contains($0.id) } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - for contact in contacts { - guard let latest = maxDates[contact.id] else { continue } - if contact.lastMessageDate == nil || contact.lastMessageDate! < latest { - contact.lastMessageDate = latest - } - } + let allRepeats = try fetchInChunks(keys: idArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + } + var repeatCountsByMessageID: [UUID: Int] = [:] + for r in allRepeats { + repeatCountsByMessageID[r.messageID, default: 0] += 1 } - /// Advances `Channel.lastMessageDate` using max timestamps keyed by - /// `(radioID, channelIndex)`. - fileprivate func applyLastMessageDatesToChannels(_ maxDates: [UUID: [UInt8: Date]]) throws { - try Task.checkCancellation() - let radioIDArray = Array(maxDates.keys) - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - for channel in channels { - guard let latest = maxDates[channel.radioID]?[channel.index] else { continue } - if channel.lastMessageDate == nil || channel.lastMessageDate! < latest { - channel.lastMessageDate = latest - } - } + try Task.checkCancellation() + let allReactions = try fetchInChunks(keys: idArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + } + var reactionsByMessageID: [UUID: [Reaction]] = [:] + for r in allReactions { + reactionsByMessageID[r.messageID, default: []].append(r) } - /// Advances `RemoteNodeSession.lastMessageDate` using max timestamps - /// keyed by session ID. - fileprivate func applyLastMessageDatesToRemoteNodeSessions(_ maxDates: [UUID: Date]) throws { + for (offset, message) in messages.enumerated() { + if offset % cancellationCheckStride == 0 { try Task.checkCancellation() - let idArray = Array(maxDates.keys) - let predicate = #Predicate { idArray.contains($0.id) } - let sessions = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - for session in sessions { - guard let latest = maxDates[session.id] else { continue } - if session.lastMessageDate == nil || session.lastMessageDate! < latest { - session.lastMessageDate = latest - } - } + } + let newHeardRepeats = repeatCountsByMessageID[message.id] ?? 0 + if message.heardRepeats != newHeardRepeats { + message.heardRepeats = newHeardRepeats + } + + let newSummary: String? + if let reactions = reactionsByMessageID[message.id], !reactions.isEmpty { + let reactionDTOs = reactions.map { ReactionDTO(from: $0) } + newSummary = ReactionParser.buildSummary(from: reactionDTOs) + } else { + newSummary = nil + } + if message.reactionSummary != newSummary { + message.reactionSummary = newSummary + } + } + } + + /// Advances `Contact.lastMessageDate` toward the per-contact max timestamp + /// from the just-imported messages. Existing values are preserved when + /// they're already newer (e.g., a DTO imported out of order). + private func applyLastMessageDatesToContacts(_ maxDates: [UUID: Date]) throws { + try Task.checkCancellation() + let idArray = Array(maxDates.keys) + let predicate = #Predicate { idArray.contains($0.id) } + let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + for contact in contacts { + guard let latest = maxDates[contact.id] else { continue } + if contact.lastMessageDate == nil || contact.lastMessageDate! < latest { + contact.lastMessageDate = latest + } + } + } + + /// Advances `Channel.lastMessageDate` using max timestamps keyed by + /// `(radioID, channelIndex)`. + private func applyLastMessageDatesToChannels(_ maxDates: [UUID: [UInt8: Date]]) throws { + try Task.checkCancellation() + let radioIDArray = Array(maxDates.keys) + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + for channel in channels { + guard let latest = maxDates[channel.radioID]?[channel.index] else { continue } + if channel.lastMessageDate == nil || channel.lastMessageDate! < latest { + channel.lastMessageDate = latest + } + } + } + + /// Advances `RemoteNodeSession.lastMessageDate` using max timestamps + /// keyed by session ID. + private func applyLastMessageDatesToRemoteNodeSessions(_ maxDates: [UUID: Date]) throws { + try Task.checkCancellation() + let idArray = Array(maxDates.keys) + let predicate = #Predicate { idArray.contains($0.id) } + let sessions = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + for session in sessions { + guard let latest = maxDates[session.id] else { continue } + if session.lastMessageDate == nil || session.lastMessageDate! < latest { + session.lastMessageDate = latest + } } + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift index f738a1ab..8da98c18 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift @@ -2,418 +2,417 @@ import Foundation import MeshCore import SwiftData -extension PersistenceStore { - - // MARK: - Blocked Channel Senders - - public func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) throws { - let targetRadioID = dto.radioID - let targetName = dto.name - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && entry.name == targetName - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.dateBlocked = dto.dateBlocked - } else { - let entry = BlockedChannelSender( - id: dto.id, - name: targetName, - radioID: dto.radioID, - dateBlocked: dto.dateBlocked - ) - modelContext.insert(entry) - } - - try modelContext.save() - } - - public func deleteBlockedChannelSender(radioID: UUID, name: String) throws { - let targetRadioID = radioID - let targetName = name - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && entry.name == targetName - } - if let entry = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { - modelContext.delete(entry) - try modelContext.save() - } - } - - public func fetchBlockedChannelSenders(radioID: UUID) throws -> [BlockedChannelSenderDTO] { - let targetRadioID = radioID - let predicate = #Predicate { entry in - entry.radioID == targetRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.dateBlocked, order: .reverse)] - ) - let entries = try modelContext.fetch(descriptor) - return entries.map { BlockedChannelSenderDTO(from: $0) } - } - - // MARK: - Mention Tracking - - public func incrementChannelUnreadMentionCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { return } - channel.unreadMentionCount += 1 - try modelContext.save() - } - - public func decrementChannelUnreadMentionCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { return } - channel.unreadMentionCount = max(0, channel.unreadMentionCount - 1) - try modelContext.save() - } - - public func clearChannelUnreadMentionCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { return } - channel.unreadMentionCount = 0 - try modelContext.save() - } - - public func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) throws -> [UUID] { - let targetRadioID = radioID - let targetIndex: UInt8? = channelIndex - let predicate = #Predicate { message in - message.radioID == targetRadioID && - message.channelIndex == targetIndex && - message.containsSelfMention == true && - message.mentionSeen == false - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.sortBy = [SortDescriptor(\.timestamp, order: .forward)] - - let messages = try modelContext.fetch(descriptor) - return messages.map(\.id) - } - - // MARK: - Channel Operations - - /// Fetch all channels for a device - public func fetchChannels(radioID: UUID) throws -> [ChannelDTO] { - let targetRadioID = radioID - let predicate = #Predicate { channel in - channel.radioID == targetRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.index)] - ) - let channels = try modelContext.fetch(descriptor) - return channels.map { ChannelDTO(from: $0) } - } - - /// Fetch a channel by index - public func fetchChannel(radioID: UUID, index: UInt8) throws -> ChannelDTO? { - let targetRadioID = radioID - let targetIndex = index - let predicate = #Predicate { channel in - channel.radioID == targetRadioID && channel.index == targetIndex - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ChannelDTO(from: $0) } - } - - /// Fetch a channel by ID - public func fetchChannel(id: UUID) throws -> ChannelDTO? { - let targetID = id - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ChannelDTO(from: $0) } - } - - /// Save or update a channel from ChannelInfo - public func saveChannel(radioID: UUID, from info: ChannelInfo) throws -> UUID { - let targetRadioID = radioID - let targetIndex = info.index - let predicate = #Predicate { channel in - channel.radioID == targetRadioID && channel.index == targetIndex - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - let channel: Channel - if let existing = try modelContext.fetch(descriptor).first { - existing.update(from: info) - channel = existing - } else { - channel = Channel(radioID: radioID, from: info) - modelContext.insert(channel) - } - - try modelContext.save() - return channel.id - } - - /// Persists a full channel-sync pass in a single transaction. See - /// ``PersistenceStoreProtocol/batchSaveChannels(radioID:configured:unconfiguredIndices:pruneBeyond:)`` - /// for the contract. Collapses the per-index `saveChannel`/`deleteChannel` calls — each its - /// own commit, plus a redundant re-fetch — into one fetch, one mutation pass, and one - /// `save()`. Indices that were neither confirmed configured nor reported unconfigured are - /// left untouched, so a circuit-breaker abort never deletes channels it could not read. - public func batchSaveChannels( - radioID: UUID, - configured: [ChannelInfo], - unconfiguredIndices: [UInt8], - pruneBeyond maxChannels: UInt8? - ) throws -> [ChannelDTO] { - let targetRadioID = radioID - let predicate = #Predicate { channel in - channel.radioID == targetRadioID - } - let existing = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - var byIndex = Dictionary(existing.map { ($0.index, $0) }, uniquingKeysWith: { current, _ in current }) - - for info in configured { - if let row = byIndex[info.index] { - row.update(from: info) - } else { - let channel = Channel(radioID: radioID, from: info) - modelContext.insert(channel) - byIndex[info.index] = channel - } - } - - for index in unconfiguredIndices { - if let stale = byIndex[index] { - modelContext.delete(stale) - byIndex[index] = nil - } - } - - if let maxChannels { - for (index, row) in byIndex where index >= maxChannels { - modelContext.delete(row) - byIndex[index] = nil - } - } - - do { - try modelContext.save() - } catch { - // Discard the staged upserts and deletes so a later successful save on this shared - // context cannot flush them and delete channels this failed pass meant to keep. - modelContext.rollback() - throw error - } - return try fetchChannels(radioID: radioID) - } - - /// Save or update a channel from DTO - public func saveChannel(_ dto: ChannelDTO) throws { - let targetID = dto.id - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.apply(dto) - } else { - modelContext.insert(Channel(dto: dto)) - } - - try modelContext.save() - } - - /// Delete a channel - public func deleteChannel(id: UUID) throws { - let targetID = id - let predicate = #Predicate { channel in - channel.id == targetID - } - if let channel = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { - modelContext.delete(channel) - try modelContext.save() - } - } - - /// Delete all messages for a channel. - /// Cascades PendingSend, MessageRepeat, and Reaction rows associated with the deleted - /// messages within a single save. - public func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) throws { - let targetRadioID = radioID - let targetChannelIndex: UInt8? = channelIndex - let messagePredicate = #Predicate { message in - message.radioID == targetRadioID && message.channelIndex == targetChannelIndex - } - - let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) - - if !messageIDs.isEmpty { - try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) - // Cascade MessageRepeat alongside Reaction. Bulk `delete(model:where:)` - // bypasses the `@Relationship(deleteRule: .cascade)` declared on - // `Message → MessageRepeat`. Chunk both predicates to stay under - // SQLITE_MAX_VARIABLE_NUMBER (32766 on iOS 18+). - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let channel = try modelContext.fetch(descriptor).first { - channel.lastMessageDate = date - try modelContext.save() - } - } - - // MARK: - Channel Unread Count - - /// Increment unread count for a channel - public func incrementChannelUnreadCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let channel = try modelContext.fetch(descriptor).first { - channel.unreadCount += 1 - try modelContext.save() - } - } - - /// Clear unread count for a channel - public func clearChannelUnreadCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let channel = try modelContext.fetch(descriptor).first { - channel.unreadCount = 0 - try modelContext.save() - } - } - - /// Clear unread count for a channel by radioID and index - /// More efficient than fetching the full channel DTO when only clearing unread - public func clearChannelUnreadCount(radioID: UUID, index: UInt8) throws { - let targetRadioID = radioID - let targetIndex = index - let predicate = #Predicate { channel in - channel.radioID == targetRadioID && channel.index == targetIndex - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - if let channel = try modelContext.fetch(descriptor).first { - channel.unreadCount = 0 - try modelContext.save() - } - } - - /// Sets the muted state for a channel - public func setChannelMuted(_ channelID: UUID, isMuted: Bool) throws { - let targetID = channelID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.channelNotFound - } - - channel.notificationLevel = isMuted ? .muted : .all - try modelContext.save() - } - - /// Sets the notification level for a channel - public func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) throws { - let targetID = channelID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.channelNotFound - } - - channel.notificationLevel = level - try modelContext.save() - } - - /// Sets the favorite state for a channel - public func setChannelFavorite(_ channelID: UUID, isFavorite: Bool) throws { - let targetID = channelID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.channelNotFound - } - - channel.isFavorite = isFavorite - try modelContext.save() - } - - /// Atomically updates the per-channel flood-scope preference. Writes both backing - /// storage fields (`floodScopeModeRawValue` and `regionScope`) in one step so - /// callers cannot persist a malformed combination. - public func setChannelFloodScope(_ channelID: UUID, floodScope: ChannelFloodScope) throws { - let targetID = channelID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.channelNotFound - } - - channel.floodScope = floodScope - try modelContext.save() +public extension PersistenceStore { + // MARK: - Blocked Channel Senders + + func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) throws { + let targetRadioID = dto.radioID + let targetName = dto.name + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && entry.name == targetName } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let existing = try modelContext.fetch(descriptor).first { + existing.dateBlocked = dto.dateBlocked + } else { + let entry = BlockedChannelSender( + id: dto.id, + name: targetName, + radioID: dto.radioID, + dateBlocked: dto.dateBlocked + ) + modelContext.insert(entry) + } + + try modelContext.save() + } + + func deleteBlockedChannelSender(radioID: UUID, name: String) throws { + let targetRadioID = radioID + let targetName = name + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && entry.name == targetName + } + if let entry = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + modelContext.delete(entry) + try modelContext.save() + } + } + + func fetchBlockedChannelSenders(radioID: UUID) throws -> [BlockedChannelSenderDTO] { + let targetRadioID = radioID + let predicate = #Predicate { entry in + entry.radioID == targetRadioID + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.dateBlocked, order: .reverse)] + ) + let entries = try modelContext.fetch(descriptor) + return entries.map { BlockedChannelSenderDTO(from: $0) } + } + + // MARK: - Mention Tracking + + func incrementChannelUnreadMentionCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { return } + channel.unreadMentionCount += 1 + try modelContext.save() + } + + func decrementChannelUnreadMentionCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { return } + channel.unreadMentionCount = max(0, channel.unreadMentionCount - 1) + try modelContext.save() + } + + func clearChannelUnreadMentionCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { return } + channel.unreadMentionCount = 0 + try modelContext.save() + } + + func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) throws -> [UUID] { + let targetRadioID = radioID + let targetIndex: UInt8? = channelIndex + let predicate = #Predicate { message in + message.radioID == targetRadioID && + message.channelIndex == targetIndex && + message.containsSelfMention == true && + message.mentionSeen == false + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.sortBy = [SortDescriptor(\.timestamp, order: .forward)] + + let messages = try modelContext.fetch(descriptor) + return messages.map(\.id) + } + + // MARK: - Channel Operations + + /// Fetch all channels for a device + func fetchChannels(radioID: UUID) throws -> [ChannelDTO] { + let targetRadioID = radioID + let predicate = #Predicate { channel in + channel.radioID == targetRadioID + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.index)] + ) + let channels = try modelContext.fetch(descriptor) + return channels.map { ChannelDTO(from: $0) } + } + + /// Fetch a channel by index + func fetchChannel(radioID: UUID, index: UInt8) throws -> ChannelDTO? { + let targetRadioID = radioID + let targetIndex = index + let predicate = #Predicate { channel in + channel.radioID == targetRadioID && channel.index == targetIndex + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ChannelDTO(from: $0) } + } + + /// Fetch a channel by ID + func fetchChannel(id: UUID) throws -> ChannelDTO? { + let targetID = id + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ChannelDTO(from: $0) } + } + + /// Save or update a channel from ChannelInfo + func saveChannel(radioID: UUID, from info: ChannelInfo) throws -> UUID { + let targetRadioID = radioID + let targetIndex = info.index + let predicate = #Predicate { channel in + channel.radioID == targetRadioID && channel.index == targetIndex + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + let channel: Channel + if let existing = try modelContext.fetch(descriptor).first { + existing.update(from: info) + channel = existing + } else { + channel = Channel(radioID: radioID, from: info) + modelContext.insert(channel) + } + + try modelContext.save() + return channel.id + } + + /// Persists a full channel-sync pass in a single transaction. See + /// ``PersistenceStoreProtocol/batchSaveChannels(radioID:configured:unconfiguredIndices:pruneBeyond:)`` + /// for the contract. Collapses the per-index `saveChannel`/`deleteChannel` calls — each its + /// own commit, plus a redundant re-fetch — into one fetch, one mutation pass, and one + /// `save()`. Indices that were neither confirmed configured nor reported unconfigured are + /// left untouched, so a circuit-breaker abort never deletes channels it could not read. + func batchSaveChannels( + radioID: UUID, + configured: [ChannelInfo], + unconfiguredIndices: [UInt8], + pruneBeyond maxChannels: UInt8? + ) throws -> [ChannelDTO] { + let targetRadioID = radioID + let predicate = #Predicate { channel in + channel.radioID == targetRadioID + } + let existing = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + var byIndex = Dictionary(existing.map { ($0.index, $0) }, uniquingKeysWith: { current, _ in current }) + + for info in configured { + if let row = byIndex[info.index] { + row.update(from: info) + } else { + let channel = Channel(radioID: radioID, from: info) + modelContext.insert(channel) + byIndex[info.index] = channel + } + } + + for index in unconfiguredIndices { + if let stale = byIndex[index] { + modelContext.delete(stale) + byIndex[index] = nil + } + } + + if let maxChannels { + for (index, row) in byIndex where index >= maxChannels { + modelContext.delete(row) + byIndex[index] = nil + } + } + + do { + try modelContext.save() + } catch { + // Discard the staged upserts and deletes so a later successful save on this shared + // context cannot flush them and delete channels this failed pass meant to keep. + modelContext.rollback() + throw error + } + return try fetchChannels(radioID: radioID) + } + + /// Save or update a channel from DTO + func saveChannel(_ dto: ChannelDTO) throws { + let targetID = dto.id + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let existing = try modelContext.fetch(descriptor).first { + existing.apply(dto) + } else { + modelContext.insert(Channel(dto: dto)) + } + + try modelContext.save() + } + + /// Delete a channel + func deleteChannel(id: UUID) throws { + let targetID = id + let predicate = #Predicate { channel in + channel.id == targetID + } + if let channel = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + modelContext.delete(channel) + try modelContext.save() + } + } + + /// Delete all messages for a channel. + /// Cascades PendingSend, MessageRepeat, and Reaction rows associated with the deleted + /// messages within a single save. + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) throws { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let messagePredicate = #Predicate { message in + message.radioID == targetRadioID && message.channelIndex == targetChannelIndex + } + + let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) + + if !messageIDs.isEmpty { + try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + // Cascade MessageRepeat alongside Reaction. Bulk `delete(model:where:)` + // bypasses the `@Relationship(deleteRule: .cascade)` declared on + // `Message → MessageRepeat`. Chunk both predicates to stay under + // SQLITE_MAX_VARIABLE_NUMBER (32766 on iOS 18+). + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let channel = try modelContext.fetch(descriptor).first { + channel.lastMessageDate = date + try modelContext.save() + } + } + + // MARK: - Channel Unread Count + + /// Increment unread count for a channel + func incrementChannelUnreadCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let channel = try modelContext.fetch(descriptor).first { + channel.unreadCount += 1 + try modelContext.save() + } + } + + /// Clear unread count for a channel + func clearChannelUnreadCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let channel = try modelContext.fetch(descriptor).first { + channel.unreadCount = 0 + try modelContext.save() + } + } + + /// Clear unread count for a channel by radioID and index + /// More efficient than fetching the full channel DTO when only clearing unread + func clearChannelUnreadCount(radioID: UUID, index: UInt8) throws { + let targetRadioID = radioID + let targetIndex = index + let predicate = #Predicate { channel in + channel.radioID == targetRadioID && channel.index == targetIndex + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let channel = try modelContext.fetch(descriptor).first { + channel.unreadCount = 0 + try modelContext.save() + } + } + + /// Sets the muted state for a channel + func setChannelMuted(_ channelID: UUID, isMuted: Bool) throws { + let targetID = channelID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.channelNotFound + } + + channel.notificationLevel = isMuted ? .muted : .all + try modelContext.save() + } + + /// Sets the notification level for a channel + func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) throws { + let targetID = channelID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.channelNotFound + } + + channel.notificationLevel = level + try modelContext.save() + } + + /// Sets the favorite state for a channel + func setChannelFavorite(_ channelID: UUID, isFavorite: Bool) throws { + let targetID = channelID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.channelNotFound + } + + channel.isFavorite = isFavorite + try modelContext.save() + } + + /// Atomically updates the per-channel flood-scope preference. Writes both backing + /// storage fields (`floodScopeModeRawValue` and `regionScope`) in one step so + /// callers cannot persist a malformed combination. + func setChannelFloodScope(_ channelID: UUID, floodScope: ChannelFloodScope) throws { + let targetID = channelID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.channelNotFound + } + + channel.floodScope = floodScope + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift index 520eafc3..5c37ab49 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift @@ -1,375 +1,374 @@ import Foundation import SwiftData -extension PersistenceStore { - - // MARK: - Contact Operations - - /// Fetch all contacts for a device - public func fetchContacts(radioID: UUID) throws -> [ContactDTO] { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.name)] - ) - let contacts = try modelContext.fetch(descriptor) - return contacts.map { ContactDTO(from: $0) } +public extension PersistenceStore { + // MARK: - Contact Operations + + /// Fetch all contacts for a device + func fetchContacts(radioID: UUID) throws -> [ContactDTO] { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID } - - /// Fetch contacts with recent messages (for chat list) - public func fetchConversations(radioID: UUID) throws -> [ContactDTO] { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID && contact.lastMessageDate != nil - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\Contact.lastMessageDate, order: .reverse)] - ) - let contacts = try modelContext.fetch(descriptor) - return contacts.map { ContactDTO(from: $0) } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.name)] + ) + let contacts = try modelContext.fetch(descriptor) + return contacts.map { ContactDTO(from: $0) } + } + + /// Fetch contacts with recent messages (for chat list) + func fetchConversations(radioID: UUID) throws -> [ContactDTO] { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.lastMessageDate != nil } - - /// Fetch a contact by ID - public func fetchContact(id: UUID) throws -> ContactDTO? { - let targetID = id - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\Contact.lastMessageDate, order: .reverse)] + ) + let contacts = try modelContext.fetch(descriptor) + return contacts.map { ContactDTO(from: $0) } + } + + /// Fetch a contact by ID + func fetchContact(id: UUID) throws -> ContactDTO? { + let targetID = id + let predicate = #Predicate { contact in + contact.id == targetID } - - /// Fetch a contact by public key - public func fetchContact(radioID: UUID, publicKey: Data) throws -> ContactDTO? { - let targetRadioID = radioID - let targetKey = publicKey - let predicate = #Predicate { contact in - contact.radioID == targetRadioID && contact.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + } + + /// Fetch a contact by public key + func fetchContact(radioID: UUID, publicKey: Data) throws -> ContactDTO? { + let targetRadioID = radioID + let targetKey = publicKey + let predicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.publicKey == targetKey } - - /// Fetch a contact by public key prefix (6 bytes) - public func fetchContact(radioID: UUID, publicKeyPrefix: Data) throws -> ContactDTO? { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - return contacts.first { $0.publicKey.prefix(6) == publicKeyPrefix }.map { ContactDTO(from: $0) } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + } + + /// Fetch a contact by public key prefix (6 bytes) + func fetchContact(radioID: UUID, publicKeyPrefix: Data) throws -> ContactDTO? { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID } - - /// Fetch all contacts with their public keys grouped by 1-byte prefix. - /// Used for crypto operations when looking up contacts by public key prefix. - public func fetchContactPublicKeysByPrefix(radioID: UUID) throws -> [UInt8: [Data]] { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let descriptor = FetchDescriptor(predicate: predicate) - let contacts = try modelContext.fetch(descriptor) - - var result: [UInt8: [Data]] = [:] - for contact in contacts { - guard contact.publicKey.count >= 1 else { continue } - let prefix = contact.publicKey[0] - result[prefix, default: []].append(contact.publicKey) - } - return result - } - - /// Save or update a contact from a ContactFrame - public func saveContact(radioID: UUID, from frame: ContactFrame) throws -> UUID { - let targetRadioID = radioID - let targetKey = frame.publicKey - let predicate = #Predicate { contact in - contact.radioID == targetRadioID && contact.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - let contact: Contact - if let existing = try modelContext.fetch(descriptor).first { - existing.update(from: frame) - contact = existing - } else { - contact = Contact(radioID: radioID, from: frame) - modelContext.insert(contact) - } - - try modelContext.save() - return contact.id + let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return contacts.first { $0.publicKey.prefix(6) == publicKeyPrefix }.map { ContactDTO(from: $0) } + } + + /// Fetch all contacts with their public keys grouped by 1-byte prefix. + /// Used for crypto operations when looking up contacts by public key prefix. + func fetchContactPublicKeysByPrefix(radioID: UUID) throws -> [UInt8: [Data]] { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID } - - /// Upserts contacts from frames in a single transaction, matching local rows by - /// `(radioID, publicKey)`. Commits once for the whole batch instead of once per contact, - /// which is the dominant cost of a full contact sync over BLE. Returns the number of - /// frames persisted. - @discardableResult - public func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) throws -> Int { - guard !frames.isEmpty else { return 0 } - - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let existing = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - var byKey = Dictionary(existing.map { ($0.publicKey, $0) }, uniquingKeysWith: { current, _ in current }) - - for frame in frames { - if let row = byKey[frame.publicKey] { - row.update(from: frame) - } else { - let contact = Contact(radioID: radioID, from: frame) - modelContext.insert(contact) - byKey[frame.publicKey] = contact - } - } - - try modelContext.save() - return frames.count + let descriptor = FetchDescriptor(predicate: predicate) + let contacts = try modelContext.fetch(descriptor) + + var result: [UInt8: [Data]] = [:] + for contact in contacts { + guard contact.publicKey.count >= 1 else { continue } + let prefix = contact.publicKey[0] + result[prefix, default: []].append(contact.publicKey) } - - /// Save or update a contact from DTO - public func saveContact(_ dto: ContactDTO) throws { - let targetID = dto.id - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.apply(dto) - } else { - modelContext.insert(Contact(dto: dto)) - } - - try modelContext.save() + return result + } + + /// Save or update a contact from a ContactFrame + func saveContact(radioID: UUID, from frame: ContactFrame) throws -> UUID { + let targetRadioID = radioID + let targetKey = frame.publicKey + let predicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.publicKey == targetKey } - - /// Delete a contact - public func deleteContact(id: UUID) throws { - let targetID = id - let predicate = #Predicate { contact in - contact.id == targetID - } - if let contact = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { - modelContext.delete(contact) - try modelContext.save() - } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + let contact: Contact + if let existing = try modelContext.fetch(descriptor).first { + existing.update(from: frame) + contact = existing + } else { + contact = Contact(radioID: radioID, from: frame) + modelContext.insert(contact) } - /// Fetch all blocked contacts for a device - public func fetchBlockedContacts(radioID: UUID) throws -> [ContactDTO] { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID && contact.isBlocked == true - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.name)] - ) - let contacts = try modelContext.fetch(descriptor) - return contacts.map { ContactDTO(from: $0) } + try modelContext.save() + return contact.id + } + + /// Upserts contacts from frames in a single transaction, matching local rows by + /// `(radioID, publicKey)`. Commits once for the whole batch instead of once per contact, + /// which is the dominant cost of a full contact sync over BLE. Returns the number of + /// frames persisted. + @discardableResult + func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) throws -> Int { + guard !frames.isEmpty else { return 0 } + + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID } - - /// Update contact's last message info (nil clears the date, removing from conversations list) - public func updateContactLastMessage(contactID: UUID, date: Date?) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let contact = try modelContext.fetch(descriptor).first { - contact.lastMessageDate = date - try modelContext.save() - } + let existing = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + var byKey = Dictionary(existing.map { ($0.publicKey, $0) }, uniquingKeysWith: { current, _ in current }) + + for frame in frames { + if let row = byKey[frame.publicKey] { + row.update(from: frame) + } else { + let contact = Contact(radioID: radioID, from: frame) + modelContext.insert(contact) + byKey[frame.publicKey] = contact + } } - /// Re-derives a contact's lastMessageDate from its newest remaining message, - /// clearing it (removing the conversation from the list) when none remain. - /// Returns the resulting date so callers can react to removal. - @discardableResult - public func recomputeContactLastMessageDate(contactID: UUID) throws -> Date? { - let newest = try fetchMessages(contactID: contactID, limit: 1, offset: 0).first - try updateContactLastMessage(contactID: contactID, date: newest?.date) - return newest?.date - } + try modelContext.save() + return frames.count + } - /// Increment unread count for a contact - public func incrementUnreadCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let contact = try modelContext.fetch(descriptor).first { - contact.unreadCount += 1 - try modelContext.save() - } + /// Save or update a contact from DTO + func saveContact(_ dto: ContactDTO) throws { + let targetID = dto.id + let predicate = #Predicate { contact in + contact.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Clear unread count for a contact - public func clearUnreadCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let contact = try modelContext.fetch(descriptor).first { - contact.unreadCount = 0 - try modelContext.save() - } + if let existing = try modelContext.fetch(descriptor).first { + existing.apply(dto) + } else { + modelContext.insert(Contact(dto: dto)) } - // MARK: - Mention Tracking - - public func incrementUnreadMentionCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + try modelContext.save() + } - guard let contact = try modelContext.fetch(descriptor).first else { return } - contact.unreadMentionCount += 1 - try modelContext.save() + /// Delete a contact + func deleteContact(id: UUID) throws { + let targetID = id + let predicate = #Predicate { contact in + contact.id == targetID } - - public func decrementUnreadMentionCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let contact = try modelContext.fetch(descriptor).first else { return } - contact.unreadMentionCount = max(0, contact.unreadMentionCount - 1) - try modelContext.save() + if let contact = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + modelContext.delete(contact) + try modelContext.save() } + } - public func clearUnreadMentionCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let contact = try modelContext.fetch(descriptor).first else { return } - contact.unreadMentionCount = 0 - try modelContext.save() + /// Fetch all blocked contacts for a device + func fetchBlockedContacts(radioID: UUID) throws -> [ContactDTO] { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.isBlocked == true } - - public func fetchUnseenMentionIDs(contactID: UUID) throws -> [UUID] { - let targetID = contactID - let predicate = #Predicate { message in - message.contactID == targetID && - message.containsSelfMention == true && - message.mentionSeen == false - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.sortBy = [SortDescriptor(\.timestamp, order: .forward)] - - let messages = try modelContext.fetch(descriptor) - return messages.map(\.id) + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.name)] + ) + let contacts = try modelContext.fetch(descriptor) + return contacts.map { ContactDTO(from: $0) } + } + + /// Update contact's last message info (nil clears the date, removing from conversations list) + func updateContactLastMessage(contactID: UUID, date: Date?) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Sets the muted state for a contact - public func setContactMuted(_ contactID: UUID, isMuted: Bool) throws { - let targetID = contactID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let contact = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.contactNotFound - } + if let contact = try modelContext.fetch(descriptor).first { + contact.lastMessageDate = date + try modelContext.save() + } + } + + /// Re-derives a contact's lastMessageDate from its newest remaining message, + /// clearing it (removing the conversation from the list) when none remain. + /// Returns the resulting date so callers can react to removal. + @discardableResult + func recomputeContactLastMessageDate(contactID: UUID) throws -> Date? { + let newest = try fetchMessages(contactID: contactID, limit: 1, offset: 0).first + try updateContactLastMessage(contactID: contactID, date: newest?.date) + return newest?.date + } + + /// Increment unread count for a contact + func incrementUnreadCount(contactID: UUID) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - contact.isMuted = isMuted - try modelContext.save() + if let contact = try modelContext.fetch(descriptor).first { + contact.unreadCount += 1 + try modelContext.save() } + } - /// Delete all messages, reactions, message repeats, and pending sends for a contact - /// in a single transactional save. - public func deleteMessagesForContact(contactID: UUID) throws { - let targetContactID: UUID? = contactID - let messagePredicate = #Predicate { message in - message.contactID == targetContactID - } + /// Clear unread count for a contact + func clearUnreadCount(contactID: UUID) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) + if let contact = try modelContext.fetch(descriptor).first { + contact.unreadCount = 0 + try modelContext.save() + } + } - try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + // MARK: - Mention Tracking - // Reaction is keyed by contactID directly here (single-value predicate) — - // no SQLITE_MAX_VARIABLE_NUMBER risk, no chunking needed. - try modelContext.delete(model: Reaction.self, where: #Predicate { - $0.contactID == targetContactID - }) - // Cascade MessageRepeat — no contactID column, so key by messageIDs. - // Chunk to stay under SQLITE_MAX_VARIABLE_NUMBER (32766 on iOS 18+). - if !messageIDs.isEmpty { - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. { contact in + contact.id == targetID } - - // MARK: - Contact Helper Methods - - /// Find contact display name by 4-byte or 6-byte public key prefix. - /// Searches across all devices — room message authors may only be known - /// from a previously-connected radio's contact list. - public func findContactNameByKeyPrefix(_ prefix: Data) throws -> String? { - // Fetch all contacts and filter by prefix match - let contacts = try modelContext.fetch(FetchDescriptor()) - let prefixLength = prefix.count - return contacts.first { contact in - contact.publicKey.prefix(prefixLength) == prefix - }?.displayName + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(descriptor).first else { return } + contact.unreadMentionCount += 1 + try modelContext.save() + } + + func decrementUnreadMentionCount(contactID: UUID) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(descriptor).first else { return } + contact.unreadMentionCount = max(0, contact.unreadMentionCount - 1) + try modelContext.save() + } + + func clearUnreadMentionCount(contactID: UUID) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(descriptor).first else { return } + contact.unreadMentionCount = 0 + try modelContext.save() + } + + func fetchUnseenMentionIDs(contactID: UUID) throws -> [UUID] { + let targetID = contactID + let predicate = #Predicate { message in + message.contactID == targetID && + message.containsSelfMention == true && + message.mentionSeen == false + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.sortBy = [SortDescriptor(\.timestamp, order: .forward)] + + let messages = try modelContext.fetch(descriptor) + return messages.map(\.id) + } + + /// Sets the muted state for a contact + func setContactMuted(_ contactID: UUID, isMuted: Bool) throws { + let targetID = contactID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.contactNotFound } - /// Find contact by 32-byte public key. - /// Searches across all devices — used for routing hints where the contact - /// may exist under a different device's ID. - public func findContactByPublicKey(_ publicKey: Data) throws -> ContactDTO? { - let targetKey = publicKey - let predicate = #Predicate { contact in - contact.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + contact.isMuted = isMuted + try modelContext.save() + } + + /// Delete all messages, reactions, message repeats, and pending sends for a contact + /// in a single transactional save. + func deleteMessagesForContact(contactID: UUID) throws { + let targetContactID: UUID? = contactID + let messagePredicate = #Predicate { message in + message.contactID == targetContactID } - public func fetchContactPublicKeys(radioID: UUID) throws -> Set { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - let descriptor = FetchDescriptor(predicate: predicate) - let contacts = try modelContext.fetch(descriptor) - return Set(contacts.map { $0.publicKey }) + let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) + + try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + + // Reaction is keyed by contactID directly here (single-value predicate) — + // no SQLITE_MAX_VARIABLE_NUMBER risk, no chunking needed. + try modelContext.delete(model: Reaction.self, where: #Predicate { + $0.contactID == targetContactID + }) + // Cascade MessageRepeat — no contactID column, so key by messageIDs. + // Chunk to stay under SQLITE_MAX_VARIABLE_NUMBER (32766 on iOS 18+). + if !messageIDs.isEmpty { + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. String? { + // Fetch all contacts and filter by prefix match + let contacts = try modelContext.fetch(FetchDescriptor()) + let prefixLength = prefix.count + return contacts.first { contact in + contact.publicKey.prefix(prefixLength) == prefix + }?.displayName + } + + /// Find contact by 32-byte public key. + /// Searches across all devices — used for routing hints where the contact + /// may exist under a different device's ID. + func findContactByPublicKey(_ publicKey: Data) throws -> ContactDTO? { + let targetKey = publicKey + let predicate = #Predicate { contact in + contact.publicKey == targetKey } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + } + + func fetchContactPublicKeys(radioID: UUID) throws -> Set { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + let descriptor = FetchDescriptor(predicate: predicate) + let contacts = try modelContext.fetch(descriptor) + return Set(contacts.map(\.publicKey)) + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift index 958f84e8..99270764 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift @@ -1,417 +1,434 @@ import Foundation import SwiftData -extension PersistenceStore { - - // MARK: - Device Operations - - /// Fetch all devices - public func fetchDevices() throws -> [DeviceDTO] { - let descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\Device.lastConnected, order: .reverse)] - ) - let devices = try modelContext.fetch(descriptor) - return devices.map { DeviceDTO(from: $0) } - } - - /// Fetch a device by ID - public func fetchDevice(id: UUID) throws -> DeviceDTO? { - let targetID = id - let predicate = #Predicate { device in - device.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } - } - - /// Fetch a device by radio ID - public func fetchDevice(radioID: UUID) throws -> DeviceDTO? { - let targetRadioID = radioID - let predicate = #Predicate { device in - device.radioID == targetRadioID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } - } - - /// Fetch a device by public key - public func fetchDevice(publicKey: Data) throws -> DeviceDTO? { - let targetKey = publicKey - let predicate = #Predicate { device in - device.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } - } - - /// Fetch the active device - public func fetchActiveDevice() throws -> DeviceDTO? { - let predicate = #Predicate { device in - device.isActive == true - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } - } - - /// Save or update a device - public func saveDevice(_ dto: DeviceDTO) throws { - let targetID = dto.id - let predicate = #Predicate { device in - device.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.apply(dto) - } else { - modelContext.insert(Device(dto: dto)) - } - - try modelContext.save() - } - - /// Set a device as active (deactivates others) - public func setActiveDevice(id: UUID) throws { - // Deactivate all devices first - let allDevices = try modelContext.fetch(FetchDescriptor()) - for device in allDevices { - device.isActive = false - } - - // Activate the specified device - let targetID = id - let predicate = #Predicate { device in - device.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let device = try modelContext.fetch(descriptor).first { - device.isActive = true - device.lastConnected = Date() - } - - try modelContext.save() - } - - /// Update the lastContactSync timestamp for a device. - /// Used to track incremental sync progress. - public func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) throws { - let targetRadioID = radioID - let predicate = #Predicate { device in - device.radioID == targetRadioID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let device = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.deviceNotFound - } - device.lastContactSync = timestamp - try modelContext.save() - } - - /// Adds a known region to a device if not already present - public func addDeviceKnownRegion(radioID: UUID, region: String) throws { - let targetRadioID = radioID - let devicePredicate = #Predicate { $0.radioID == targetRadioID } - var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) - deviceDescriptor.fetchLimit = 1 - - guard let device = try modelContext.fetch(deviceDescriptor).first else { - throw PersistenceStoreError.deviceNotFound - } - - guard !device.knownRegions.contains(region) else { return } - device.knownRegions.append(region) - try modelContext.save() - } - - /// Removes a known region from a device and resets channels that had been scoped - /// to the removed region back to ``ChannelFloodScope/inherit``. - public func removeDeviceKnownRegion(radioID: UUID, region: String) throws { - let targetRadioID = radioID - let devicePredicate = #Predicate { $0.radioID == targetRadioID } - var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) - deviceDescriptor.fetchLimit = 1 - - guard let device = try modelContext.fetch(deviceDescriptor).first else { - throw PersistenceStoreError.deviceNotFound - } - - device.knownRegions.removeAll { $0 == region } - - let channelPredicate = #Predicate { $0.radioID == targetRadioID } - let channels = try modelContext.fetch(FetchDescriptor(predicate: channelPredicate)) - for channel in channels where channel.floodScope == .region(region) { - channel.floodScope = .inherit - } - - try modelContext.save() - } - - /// Delete all data associated with a device. - /// Deletes: reactions, room messages, remote node sessions, blocked channel senders, - /// RX log entries, discovered nodes, contacts, messages, channels, and saved trace paths. - /// Does NOT delete the Device record itself. - public func deleteDeviceData(id: UUID) throws { - try _deleteAllDeviceData(id: id) - try modelContext.save() - } - - /// Delete a device record only. Does NOT delete associated data. - public func deleteDevice(id: UUID) throws { - let targetID = id - let devicePredicate = #Predicate { device in - device.id == targetID - } - if let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first { - modelContext.delete(device) - } - try modelContext.save() - } - - /// Demotes a device row to a "ghost": preserves `publicKey` and `radioID` so the - /// publicKey ↔ radioID bridge survives, but assigns a fresh `id`, clears `isActive`, - /// and strips ALL connection methods so the row is hidden from - /// `DeviceSelectionSheet`. Used in place of `deleteDevice(id:)` whenever the user - /// removes a device but keeps their data — the next time the same physical radio - /// is re-paired (or its keypair is restored via config import), reconciliation can - /// rejoin the orphaned children to the new pairing. - /// - /// Distinct from `DeviceDTO.cleanedForImport()`, which strips only Bluetooth and - /// keeps WiFi (so a `.mc1backup`-restored radio stays reachable over WiFi without - /// re-pairing). Demote-on-remove is the user saying "make this go away"; preserving - /// any connection method would leave the row showing in `DeviceSelectionSheet` - /// because `DeviceSelectionFilter.isConnectable` matches any WiFi method. - public func demoteDeviceToGhost(id: UUID) throws { - let targetID = id - let predicate = #Predicate { device in - device.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let existing = try modelContext.fetch(descriptor).first else { return } - - let ghostDTO = DeviceDTO(from: existing).copy { - $0.id = UUID() - $0.isActive = false - $0.connectionMethods = [] - } - - modelContext.delete(existing) - let ghost = Device(dto: ghostDTO) - modelContext.insert(ghost) - try modelContext.save() - } - - /// Delete a device and all its associated data atomically (single save). - /// Use for factory reset and explicit "delete all data" user action. - public func deleteDeviceAndData(id: UUID) throws { - try _deleteAllDeviceData(id: id) - - let targetID = id - let devicePredicate = #Predicate { device in - device.id == targetID - } - if let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first { - modelContext.delete(device) - } - try modelContext.save() - } - - /// Looks for a ghost `Device` row whose `publicKey` matches `newPublicKey` - /// (left by a prior remove-from-MC1, or a `.mc1backup` shadow row). If found, - /// rewrites the current device's `radioID` and `publicKey` to the ghost's values - /// and deletes the ghost. All child rows (Contact, Message, Channel, etc.) keyed - /// by the ghost's `radioID` are now linked to the current device for free. - /// - /// The predicate filters by `isActive == false`, but that alone isn't a ghost - /// marker — `setActiveDevice(id:)` deactivates every other Device row, so a - /// saved-but-not-currently-active real device also has `isActive == false`. To - /// avoid ever deleting a real device row (defense against an upstream publicKey-dedup - /// violation), we additionally require the matched row to have **no Bluetooth - /// connection methods**: demoted ghosts strip all methods, `.mc1backup` shadow rows - /// strip BLE via `cleanedForImport()`, and a real saved device retains its BLE - /// `ConnectionMethod`. SwiftData `#Predicate` cannot reliably introspect array - /// elements, so the BLE check happens post-fetch in code. - /// - /// - Parameters: - /// - currentDeviceID: The BLE peripheral UUID of the live, currently-paired device. - /// - newPublicKey: The radio's `selfInfo.publicKey` after `importPrivateKey`. - /// - Returns: The new `radioID` if reconciliation occurred, otherwise `nil`. - public func reconcileGhostIdentity(currentDeviceID: UUID, newPublicKey: Data) throws -> UUID? { - let lookupKey = newPublicKey - let lookupID = currentDeviceID - let ghostPredicate = #Predicate { device in - device.publicKey == lookupKey && device.id != lookupID && device.isActive == false - } - var ghostDescriptor = FetchDescriptor(predicate: ghostPredicate) - ghostDescriptor.fetchLimit = 1 - guard let ghost = try modelContext.fetch(ghostDescriptor).first else { return nil } - - // Defensive: refuse to delete a row that still carries a Bluetooth method. - // It's a real saved-but-inactive device (per `setActiveDevice` semantics), - // not a ghost. Returning nil here is safe — no reconciliation, no data loss. - if ghost.connectionMethods.contains(where: { $0.isBluetooth }) { - return nil - } - - let currentPredicate = #Predicate { device in - device.id == lookupID - } - var currentDescriptor = FetchDescriptor(predicate: currentPredicate) - currentDescriptor.fetchLimit = 1 - guard let current = try modelContext.fetch(currentDescriptor).first else { return nil } - - let newRadioID = ghost.radioID - current.radioID = newRadioID - current.publicKey = newPublicKey - for method in ghost.connectionMethods where !method.isBluetooth { - if !current.connectionMethods.contains(where: { $0.id == method.id }) { - current.connectionMethods.append(method) - } - } - - modelContext.delete(ghost) - try modelContext.save() - return newRadioID - } - - /// Stages deletion of all device-scoped data without calling save(). - /// Used by both `deleteDeviceData` and `deleteDeviceAndData` to compose - /// operations while maintaining single-save atomicity. - private func _deleteAllDeviceData(id: UUID) throws { - // Look up the Device's radioID since child records are keyed by radioID, not BLE UUID - let targetBLEID = id - let devicePredicate = #Predicate { device in - device.id == targetBLEID - } - guard let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first else { - return - } - let targetRadioID = device.radioID - - // Delete reactions (references messages via messageID) - let reactionPredicate = #Predicate { reaction in - reaction.radioID == targetRadioID - } - let reactions = try modelContext.fetch(FetchDescriptor(predicate: reactionPredicate)) - for reaction in reactions { modelContext.delete(reaction) } - - // Delete room messages via their sessions, then clean up NodeStatusSnapshots - let sessionPredicate = #Predicate { session in - session.radioID == targetRadioID - } - let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) - - // Collect publicKeys before deleting sessions for NodeStatusSnapshot cleanup - let sessionPublicKeys = sessions.map { $0.publicKey } - - for session in sessions { - let sessionID = session.id - let roomMessagePredicate = #Predicate { message in - message.sessionID == sessionID - } - let roomMessages = try modelContext.fetch(FetchDescriptor(predicate: roomMessagePredicate)) - for roomMessage in roomMessages { modelContext.delete(roomMessage) } - modelContext.delete(session) - } - - // Delete NodeStatusSnapshots only when no other session references that node - for pubKey in sessionPublicKeys { - let remainingPredicate = #Predicate { session in - session.publicKey == pubKey - } - let remainingCount = try modelContext.fetchCount(FetchDescriptor(predicate: remainingPredicate)) - guard remainingCount == 0 else { continue } - - let snapshotPredicate = #Predicate { snapshot in - snapshot.nodePublicKey == pubKey - } - let snapshots = try modelContext.fetch(FetchDescriptor(predicate: snapshotPredicate)) - for snapshot in snapshots { modelContext.delete(snapshot) } - } - - // Delete blocked channel senders - let blockedPredicate = #Predicate { blocked in - blocked.radioID == targetRadioID - } - let blockedSenders = try modelContext.fetch(FetchDescriptor(predicate: blockedPredicate)) - for blocked in blockedSenders { modelContext.delete(blocked) } - - // Delete RX log entries; drop the count cache so the next save re-seeds - // from disk instead of pruning against a stale pre-delete count. - let rxLogPredicate = #Predicate { entry in - entry.radioID == targetRadioID - } - let rxLogEntries = try modelContext.fetch(FetchDescriptor(predicate: rxLogPredicate)) - for entry in rxLogEntries { modelContext.delete(entry) } - rxLogEntryCountsByDevice[targetRadioID] = nil - - // Delete discovered nodes - let discoveredPredicate = #Predicate { node in - node.radioID == targetRadioID - } - let discoveredNodes = try modelContext.fetch(FetchDescriptor(predicate: discoveredPredicate)) - for node in discoveredNodes { modelContext.delete(node) } - - // Delete contacts - let contactPredicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: contactPredicate)) - for contact in contacts { modelContext.delete(contact) } - - // Delete messages — cascade PendingSend and MessageRepeat first. - // Bulk `delete(model:where:)` bypasses `@Relationship(deleteRule: .cascade)`, - // and `purgeOrphanPendingSends` cannot reap radio-scoped PendingSends - // while the Device row survives (deleteDeviceData preserves it). - let messagePredicate = #Predicate { message in - message.radioID == targetRadioID - } - let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) - let messageIDs = messages.map(\.id) - try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) - if !messageIDs.isEmpty { - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. { row in - row.radioID == targetRadioID +public extension PersistenceStore { + // MARK: - Device Operations + + /// Fetch all devices + func fetchDevices() throws -> [DeviceDTO] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\Device.lastConnected, order: .reverse)] + ) + let devices = try modelContext.fetch(descriptor) + return devices.map { DeviceDTO(from: $0) } + } + + /// Fetch a device by ID + func fetchDevice(id: UUID) throws -> DeviceDTO? { + let targetID = id + let predicate = #Predicate { device in + device.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } + } + + /// Fetch a device by radio ID + func fetchDevice(radioID: UUID) throws -> DeviceDTO? { + let targetRadioID = radioID + let predicate = #Predicate { device in + device.radioID == targetRadioID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } + } + + /// Fetch a device by public key + func fetchDevice(publicKey: Data) throws -> DeviceDTO? { + let targetKey = publicKey + let predicate = #Predicate { device in + device.publicKey == targetKey + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } + } + + /// Fetch the active device + func fetchActiveDevice() throws -> DeviceDTO? { + let predicate = #Predicate { device in + device.isActive == true + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } + } + + /// Save or update a device + func saveDevice(_ dto: DeviceDTO) throws { + let targetID = dto.id + let predicate = #Predicate { device in + device.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let existing = try modelContext.fetch(descriptor).first { + existing.apply(dto) + } else { + modelContext.insert(Device(dto: dto)) + } + + try modelContext.save() + } + + /// Set a device as active (deactivates others) + func setActiveDevice(id: UUID) throws { + // Deactivate all devices first + let allDevices = try modelContext.fetch(FetchDescriptor()) + for device in allDevices { + device.isActive = false + } + + // Activate the specified device + let targetID = id + let predicate = #Predicate { device in + device.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let device = try modelContext.fetch(descriptor).first { + device.isActive = true + device.lastConnected = Date() + } + + try modelContext.save() + } + + /// Update the lastContactSync timestamp for a device. + /// Used to track incremental sync progress. + func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) throws { + let targetRadioID = radioID + let predicate = #Predicate { device in + device.radioID == targetRadioID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let device = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.deviceNotFound + } + device.lastContactSync = timestamp + try modelContext.save() + } + + /// Adds a known region to a device if not already present + func addDeviceKnownRegion(radioID: UUID, region: String) throws { + let targetRadioID = radioID + let devicePredicate = #Predicate { $0.radioID == targetRadioID } + var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) + deviceDescriptor.fetchLimit = 1 + + guard let device = try modelContext.fetch(deviceDescriptor).first else { + throw PersistenceStoreError.deviceNotFound + } + + guard !device.knownRegions.contains(region) else { return } + device.knownRegions.append(region) + try modelContext.save() + } + + /// Removes a known region from a device and resets channels that had been scoped + /// to the removed region back to ``ChannelFloodScope/inherit``. + func removeDeviceKnownRegion(radioID: UUID, region: String) throws { + let targetRadioID = radioID + let devicePredicate = #Predicate { $0.radioID == targetRadioID } + var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) + deviceDescriptor.fetchLimit = 1 + + guard let device = try modelContext.fetch(deviceDescriptor).first else { + throw PersistenceStoreError.deviceNotFound + } + + device.knownRegions.removeAll { $0 == region } + + let channelPredicate = #Predicate { $0.radioID == targetRadioID } + let channels = try modelContext.fetch(FetchDescriptor(predicate: channelPredicate)) + for channel in channels where channel.floodScope == .region(region) { + channel.floodScope = .inherit + } + + try modelContext.save() + } + + /// Delete all data associated with a device. + /// Deletes: reactions, room messages, remote node sessions, blocked channel senders, + /// RX log entries, discovered nodes, contacts, messages, channels, and saved trace paths. + /// Does NOT delete the Device record itself. + func deleteDeviceData(id: UUID) throws { + try _deleteAllDeviceData(id: id) + try modelContext.save() + } + + /// Delete a device record only. Does NOT delete associated data. + func deleteDevice(id: UUID) throws { + let targetID = id + let devicePredicate = #Predicate { device in + device.id == targetID + } + if let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first { + modelContext.delete(device) + } + try modelContext.save() + } + + /// Demotes a device row to a "ghost": preserves `publicKey` and `radioID` so the + /// publicKey ↔ radioID bridge survives, but assigns a fresh `id`, clears `isActive`, + /// and strips ALL connection methods so the row is hidden from + /// `DeviceSelectionSheet`. Used in place of `deleteDevice(id:)` whenever the user + /// removes a device but keeps their data — the next time the same physical radio + /// is re-paired (or its keypair is restored via config import), reconciliation can + /// rejoin the orphaned children to the new pairing. + /// + /// Distinct from `DeviceDTO.cleanedForImport()`, which strips only Bluetooth and + /// keeps WiFi (so a `.mc1backup`-restored radio stays reachable over WiFi without + /// re-pairing). Demote-on-remove is the user saying "make this go away"; preserving + /// any connection method would leave the row showing in `DeviceSelectionSheet` + /// because `DeviceSelectionFilter.isConnectable` matches any WiFi method. + func demoteDeviceToGhost(id: UUID) throws { + let targetID = id + let predicate = #Predicate { device in + device.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let existing = try modelContext.fetch(descriptor).first else { return } + + let ghostDTO = DeviceDTO(from: existing).copy { + $0.id = UUID() + $0.isActive = false + $0.connectionMethods = [] + } + + modelContext.delete(existing) + let ghost = Device(dto: ghostDTO) + modelContext.insert(ghost) + try modelContext.save() + } + + /// Delete a device and all its associated data atomically (single save). + /// Use for factory reset and explicit "delete all data" user action. + func deleteDeviceAndData(id: UUID) throws { + try _deleteAllDeviceData(id: id) + + let targetID = id + let devicePredicate = #Predicate { device in + device.id == targetID + } + if let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first { + modelContext.delete(device) + } + try modelContext.save() + } + + /// Looks for a ghost `Device` row whose `publicKey` matches `newPublicKey` + /// (left by a prior remove-from-MC1, or a `.mc1backup` shadow row). If found, + /// rewrites the current device's `radioID` and `publicKey` to the ghost's values + /// and deletes the ghost. All child rows (Contact, Message, Channel, etc.) keyed + /// by the ghost's `radioID` are now linked to the current device for free. + /// + /// The predicate filters by `isActive == false`, but that alone isn't a ghost + /// marker — `setActiveDevice(id:)` deactivates every other Device row, so a + /// saved-but-not-currently-active real device also has `isActive == false`. To + /// avoid ever deleting a real device row (defense against an upstream publicKey-dedup + /// violation), we additionally require the matched row to have **no Bluetooth + /// connection methods**: demoted ghosts strip all methods, `.mc1backup` shadow rows + /// strip BLE via `cleanedForImport()`, and a real saved device retains its BLE + /// `ConnectionMethod`. SwiftData `#Predicate` cannot reliably introspect array + /// elements, so the BLE check happens post-fetch in code. + /// + /// - Parameters: + /// - currentDeviceID: The BLE peripheral UUID of the live, currently-paired device. + /// - newPublicKey: The radio's `selfInfo.publicKey` after `importPrivateKey`. + /// - Returns: The new `radioID` if reconciliation occurred, otherwise `nil`. + func reconcileGhostIdentity(currentDeviceID: UUID, newPublicKey: Data) throws -> UUID? { + let lookupKey = newPublicKey + let lookupID = currentDeviceID + let ghostPredicate = #Predicate { device in + device.publicKey == lookupKey && device.id != lookupID && device.isActive == false + } + var ghostDescriptor = FetchDescriptor(predicate: ghostPredicate) + ghostDescriptor.fetchLimit = 1 + guard let ghost = try modelContext.fetch(ghostDescriptor).first else { return nil } + + // Defensive: refuse to delete a row that still carries a Bluetooth method. + // It's a real saved-but-inactive device (per `setActiveDevice` semantics), + // not a ghost. Returning nil here is safe — no reconciliation, no data loss. + if ghost.connectionMethods.contains(where: \.isBluetooth) { + return nil + } + + let currentPredicate = #Predicate { device in + device.id == lookupID + } + var currentDescriptor = FetchDescriptor(predicate: currentPredicate) + currentDescriptor.fetchLimit = 1 + guard let current = try modelContext.fetch(currentDescriptor).first else { return nil } + + let newRadioID = ghost.radioID + current.radioID = newRadioID + current.publicKey = newPublicKey + for method in ghost.connectionMethods where !method.isBluetooth { + if !current.connectionMethods.contains(where: { $0.id == method.id }) { + current.connectionMethods.append(method) + } + } + + modelContext.delete(ghost) + try modelContext.save() + return newRadioID + } + + /// Stages deletion of all device-scoped data without calling save(). + /// Used by both `deleteDeviceData` and `deleteDeviceAndData` to compose + /// operations while maintaining single-save atomicity. + private func _deleteAllDeviceData(id: UUID) throws { + // Look up the Device's radioID since child records are keyed by radioID, not BLE UUID + let targetBLEID = id + let devicePredicate = #Predicate { device in + device.id == targetBLEID + } + guard let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first else { + return + } + let targetRadioID = device.radioID + + // Delete reactions (references messages via messageID) + let reactionPredicate = #Predicate { reaction in + reaction.radioID == targetRadioID + } + let reactions = try modelContext.fetch(FetchDescriptor(predicate: reactionPredicate)) + for reaction in reactions { + modelContext.delete(reaction) + } + + // Delete room messages via their sessions, then clean up NodeStatusSnapshots + let sessionPredicate = #Predicate { session in + session.radioID == targetRadioID + } + let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) + + // Collect publicKeys before deleting sessions for NodeStatusSnapshot cleanup + let sessionPublicKeys = sessions.map(\.publicKey) + + for session in sessions { + let sessionID = session.id + let roomMessagePredicate = #Predicate { message in + message.sessionID == sessionID + } + let roomMessages = try modelContext.fetch(FetchDescriptor(predicate: roomMessagePredicate)) + for roomMessage in roomMessages { + modelContext.delete(roomMessage) + } + modelContext.delete(session) + } + + // Delete NodeStatusSnapshots only when no other session references that node + for pubKey in sessionPublicKeys { + let remainingPredicate = #Predicate { session in + session.publicKey == pubKey + } + let remainingCount = try modelContext.fetchCount(FetchDescriptor(predicate: remainingPredicate)) + guard remainingCount == 0 else { continue } + + let snapshotPredicate = #Predicate { snapshot in + snapshot.nodePublicKey == pubKey + } + let snapshots = try modelContext.fetch(FetchDescriptor(predicate: snapshotPredicate)) + for snapshot in snapshots { + modelContext.delete(snapshot) + } + } + + // Delete blocked channel senders + let blockedPredicate = #Predicate { blocked in + blocked.radioID == targetRadioID + } + let blockedSenders = try modelContext.fetch(FetchDescriptor(predicate: blockedPredicate)) + for blocked in blockedSenders { + modelContext.delete(blocked) + } + + // Delete RX log entries; drop the count cache so the next save re-seeds + // from disk instead of pruning against a stale pre-delete count. + let rxLogPredicate = #Predicate { entry in + entry.radioID == targetRadioID + } + let rxLogEntries = try modelContext.fetch(FetchDescriptor(predicate: rxLogPredicate)) + for entry in rxLogEntries { + modelContext.delete(entry) + } + rxLogEntryCountsByDevice[targetRadioID] = nil + + // Delete discovered nodes + let discoveredPredicate = #Predicate { node in + node.radioID == targetRadioID + } + let discoveredNodes = try modelContext.fetch(FetchDescriptor(predicate: discoveredPredicate)) + for node in discoveredNodes { + modelContext.delete(node) + } + + // Delete contacts + let contactPredicate = #Predicate { contact in + contact.radioID == targetRadioID + } + let contacts = try modelContext.fetch(FetchDescriptor(predicate: contactPredicate)) + for contact in contacts { + modelContext.delete(contact) + } + + // Delete messages — cascade PendingSend and MessageRepeat first. + // Bulk `delete(model:where:)` bypasses `@Relationship(deleteRule: .cascade)`, + // and `purgeOrphanPendingSends` cannot reap radio-scoped PendingSends + // while the Device row survives (deleteDeviceData preserves it). + let messagePredicate = #Predicate { message in + message.radioID == targetRadioID + } + let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) + let messageIDs = messages.map(\.id) + try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + if !messageIDs.isEmpty { + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. { channel in - channel.radioID == targetRadioID - } - let channels = try modelContext.fetch(FetchDescriptor(predicate: channelPredicate)) - for channel in channels { modelContext.delete(channel) } - - // Delete saved trace paths - let pathPredicate = #Predicate { path in - path.radioID == targetRadioID - } - let paths = try modelContext.fetch(FetchDescriptor(predicate: pathPredicate)) - for path in paths { modelContext.delete(path) } + } + } + // Defensive: reap PendingSend rows for this radio that have no matching + // Message (e.g. from a same-millisecond race between deleteMessage and + // upsertPendingSend, or historical bugs). The messageIDs cascade above + // does not see them; purgeOrphanPendingSends does not see them while + // the Device row survives. + try modelContext.delete(model: PendingSend.self, where: #Predicate { row in + row.radioID == targetRadioID + }) + // Delete Message rows with a store-level batch, not per-object: a per-object delete + // enters cascade propagation over Message.repeats and traps resolving the + // already-batch-deleted MessageRepeat rows. + try modelContext.delete(model: Message.self, where: messagePredicate) + + // Delete channels + let channelPredicate = #Predicate { channel in + channel.radioID == targetRadioID + } + let channels = try modelContext.fetch(FetchDescriptor(predicate: channelPredicate)) + for channel in channels { + modelContext.delete(channel) + } + + // Delete saved trace paths + let pathPredicate = #Predicate { path in + path.radioID == targetRadioID + } + let paths = try modelContext.fetch(FetchDescriptor(predicate: pathPredicate)) + for path in paths { + modelContext.delete(path) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift index 35739007..9363b831 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift @@ -2,669 +2,667 @@ import Foundation import MeshCore import SwiftData -extension PersistenceStore { - - // MARK: - Saved Trace Path Operations - - public func fetchSavedTracePaths(radioID: UUID) throws -> [SavedTracePathDTO] { - let targetRadioID = radioID - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID }, - sortBy: [SortDescriptor(\.createdDate, order: .reverse)] - ) - let paths = try modelContext.fetch(descriptor) - return paths.map { SavedTracePathDTO(from: $0) } - } - - public func fetchSavedTracePath(id: UUID) throws -> SavedTracePathDTO? { - let targetID = id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let path = try modelContext.fetch(descriptor).first else { return nil } - return SavedTracePathDTO(from: path) - } - - public func createSavedTracePath( - radioID: UUID, - name: String, - pathBytes: Data, - hashSize: Int = 1, - initialRun: TracePathRunDTO? - ) throws -> SavedTracePathDTO { - let path = SavedTracePath( - radioID: radioID, - name: name, - pathBytes: pathBytes, - hashSize: hashSize - ) - - if let runDTO = initialRun { - let run = try TracePathRun(dto: runDTO) - run.savedPath = path - path.runs.append(run) - modelContext.insert(run) - } - - modelContext.insert(path) - try modelContext.save() - return SavedTracePathDTO(from: path) - } - - public func updateSavedTracePathName(id: UUID, name: String) throws { - let targetID = id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let path = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.fetchFailed("SavedTracePath not found") - } - path.name = name - try modelContext.save() - } - - public func deleteSavedTracePath(id: UUID) throws { - let targetID = id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let path = try modelContext.fetch(descriptor).first else { return } - modelContext.delete(path) - try modelContext.save() - } - - public func appendTracePathRun(pathID: UUID, run runDTO: TracePathRunDTO) throws { - let targetID = pathID - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let path = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.fetchFailed("SavedTracePath not found") - } - - let run = try TracePathRun(dto: runDTO) - run.savedPath = path - path.runs.append(run) - modelContext.insert(run) - try modelContext.save() - } - - // MARK: - RxLogEntry - - /// Save a new RX log entry. - public func saveRxLogEntry(_ dto: RxLogEntryDTO) throws { - // Seed the count cache from disk before the insert; incrementing a - // missing entry from zero would undercount rows persisted by earlier - // sessions and suppress pruning indefinitely. - let countBeforeInsert = try cachedRxLogEntryCount(radioID: dto.radioID) - let entry = RxLogEntry( - id: dto.id, - radioID: dto.radioID, - receivedAt: dto.receivedAt, - snr: dto.snr, - rssi: dto.rssi, - routeType: Int(dto.routeType.rawValue), - payloadType: Int(dto.payloadType.rawValue), - payloadVersion: Int(dto.payloadVersion), - transportCode: dto.transportCode, - pathLength: Int(dto.pathLength), - pathNodes: dto.pathNodes, - packetPayload: dto.packetPayload, - rawPayload: dto.rawPayload, - packetHash: dto.packetHash, - channelIndex: dto.channelIndex.map { Int($0) }, - channelName: dto.channelName, - decryptStatus: dto.decryptStatus.rawValue, - fromContactName: dto.fromContactName, - toContactName: dto.toContactName, - senderTimestamp: dto.senderTimestamp.map { Int($0) }, - regionScope: dto.regionScope, - payloadTypeBits: Int(dto.payloadTypeBits) - ) - modelContext.insert(entry) - try modelContext.save() - rxLogEntryCountsByDevice[dto.radioID] = countBeforeInsert + 1 - } - - /// Fetch RX log entries for a device, most recent first. - public func fetchRxLogEntries(radioID: UUID, limit: Int = 500) throws -> [RxLogEntryDTO] { - let targetRadioID = radioID - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID }, - sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] - ) - descriptor.fetchLimit = limit - let entries = try modelContext.fetch(descriptor) - return entries.map { RxLogEntryDTO(from: $0) } - } - - /// Count RX log entries for a device. - public func countRxLogEntries(radioID: UUID) throws -> Int { - let targetRadioID = radioID - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID } - ) - return try modelContext.fetchCount(descriptor) - } - - /// Delete oldest entries once the log materially exceeds the retention cap. - /// - /// This avoids repeated count/fetch/delete maintenance on every RX packet while keeping - /// retention bounded to `keepCount + pruneThreshold` entries between prune passes. - public func pruneRxLogEntries( - radioID: UUID, - keepCount: Int = 1000, - pruneThreshold: Int = 100 - ) throws { - let count = try cachedRxLogEntryCount(radioID: radioID) - guard count > keepCount + pruneThreshold else { return } - - let deleteCount = count - keepCount - let targetRadioID = radioID - - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID }, - sortBy: [SortDescriptor(\.receivedAt, order: .forward)] // Oldest first - ) - descriptor.fetchLimit = deleteCount - - let toDelete = try modelContext.fetch(descriptor) - for entry in toDelete { - modelContext.delete(entry) - } - try modelContext.save() - rxLogEntryCountsByDevice[radioID] = keepCount - } - - /// Clear all RX log entries for a device. - public func clearRxLogEntries(radioID: UUID) throws { - let targetRadioID = radioID - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID } - ) - let entries = try modelContext.fetch(descriptor) - for entry in entries { - modelContext.delete(entry) - } - try modelContext.save() - rxLogEntryCountsByDevice[radioID] = 0 - } - - private func cachedRxLogEntryCount(radioID: UUID) throws -> Int { - if let cached = rxLogEntryCountsByDevice[radioID] { - return cached - } - - let count = try countRxLogEntries(radioID: radioID) - rxLogEntryCountsByDevice[radioID] = count - return count - } - - /// Find RxLogEntry matching an incoming message for path correlation. - /// - /// For channel messages: Correlates by channel index and sender timestamp. - /// For direct messages: Correlates by sender timestamp and payload type. - public func findRxLogEntry( - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32 - ) throws -> RxLogEntryDTO? { - let targetRadioID = radioID - let targetTimestamp = Int(senderTimestamp) - - if let channelIndex { - // Channel message: match on channelIndex and senderTimestamp - let channelIndexInt = Int(channelIndex) - - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && - entry.channelIndex == channelIndexInt && - entry.senderTimestamp == targetTimestamp - } - - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] - - let results = try modelContext.fetch(descriptor) - return results.first.map { RxLogEntryDTO(from: $0) } - } else { - // Direct message: match on senderTimestamp - let textMessageType = Int(PayloadType.textMessage.rawValue) - - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && - entry.senderTimestamp == targetTimestamp && - entry.channelIndex == nil && - entry.payloadType == textMessageType - } - - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] - - let results = try modelContext.fetch(descriptor) - return results.first.map { RxLogEntryDTO(from: $0) } - } - } - - /// Find a DM RxLogEntry by matching the sender prefix byte in the packet payload. - /// - /// Fallback for when the primary `findRxLogEntry(senderTimestamp:)` fails because - /// DM decryption hadn't succeeded yet (senderTimestamp was nil). Matches on the - /// unencrypted srcHash byte at `packetPayload[1]` and a receive-time window. - public func findRxLogEntryBySenderPrefix( - radioID: UUID, - senderPrefixByte: UInt8, - receivedSince: Date - ) throws -> RxLogEntryDTO? { - let targetRadioID = radioID - let textMessageType = Int(PayloadType.textMessage.rawValue) - let cutoff = receivedSince - - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && - entry.channelIndex == nil && - entry.payloadType == textMessageType && - entry.receivedAt >= cutoff - } - - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] - descriptor.fetchLimit = 20 - - let candidates = try modelContext.fetch(descriptor) - - // Filter in-memory: match sender prefix byte at packetPayload[1] - let match = candidates.first { entry in - entry.packetPayload.count >= 2 && entry.packetPayload[1] == senderPrefixByte - } - - return match.map { RxLogEntryDTO(from: $0) } - } - - /// Fetch recent RX log entries with a given decrypt status. - public func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) throws -> [RxLogEntryDTO] { - let targetRadioID = radioID - let targetStatus = status.rawValue - let cutoff = since - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.radioID == targetRadioID && - $0.decryptStatus == targetStatus && - $0.receivedAt >= cutoff - }, - sortBy: [SortDescriptor(\.receivedAt, order: .forward)] - ) - let entries = try modelContext.fetch(descriptor) - return entries.map { RxLogEntryDTO(from: $0) } - } - - /// Batch update RX log entries after successful decryption. - /// Note: decodedText is @Transient and not persisted. - public func batchUpdateRxLogDecryption( - _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] - ) throws { - for update in updates { - let targetID = update.id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let entry = try modelContext.fetch(descriptor).first else { continue } - - entry.channelIndex = update.channelIndex.map { Int($0) } - entry.channelName = update.channelName - entry.decryptStatus = DecryptStatus.success.rawValue - entry.senderTimestamp = update.senderTimestamp.map { Int($0) } - } - try modelContext.save() - } - - /// Fetch RX log entries that have a transport code but no resolved - /// region yet — the back-fill candidate set. - public func fetchEntriesWithMissingRegion(radioID: UUID) throws -> [RxLogEntryDTO] { - let targetRadioID = radioID - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.radioID == targetRadioID && - $0.transportCode != nil && - $0.regionScope == nil - }, - sortBy: [SortDescriptor(\.receivedAt, order: .forward)] - ) - let entries = try modelContext.fetch(descriptor) - return entries.map { RxLogEntryDTO(from: $0) } - } - - /// Batch update `regionScope` on RX log entries by id. - public func batchUpdateRxLogRegion( - updates: [(id: UUID, regionScope: String?)] - ) throws { - for update in updates { - let targetID = update.id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let entry = try modelContext.fetch(descriptor).first else { continue } - entry.regionScope = update.regionScope - } - try modelContext.save() - } - - /// Batch update `regionScope` on incoming **channel** `Message` rows - /// correlated by `(channelIndex, senderTimestamp)`. The wire timestamp - /// fallback is required because `Message.senderTimestamp` is only - /// populated for the rare timestamp-corrected case; the normal case puts - /// the wire timestamp on `Message.timestamp`. - public func batchUpdateChannelMessageRegion( - radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) throws { - let targetRadioID = radioID - let incoming = MessageDirection.incoming.rawValue - for update in updates { - let targetIndex: UInt8? = update.channelIndex - let targetSenderTimestamp: UInt32? = update.senderTimestamp - let targetWireTimestamp: UInt32 = update.senderTimestamp - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.radioID == targetRadioID && - $0.channelIndex == targetIndex && - $0.directionRawValue == incoming && - ($0.senderTimestamp == targetSenderTimestamp || - ($0.senderTimestamp == nil && $0.timestamp == targetWireTimestamp)) - } - ) - for message in try modelContext.fetch(descriptor) { - message.regionScope = update.regionScope - } - } - try modelContext.save() - } - - /// Batch update `regionScope` on incoming **DM** `Message` rows. DMs - /// carry the sender prefix byte at `RxLogEntry.packetPayload[1]` but - /// the Message side stores the full multi-byte `senderKeyPrefix`, so - /// the predicate fetches by timestamp + DM channel and an in-memory - /// pass disambiguates by first-byte equality. Mirrors the correlation - /// key used by `findRxLogEntryBySenderPrefix`. - public func batchUpdateDMMessageRegion( - radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) throws { - let targetRadioID = radioID - let nilChannel: UInt8? = nil - let incoming = MessageDirection.incoming.rawValue - for update in updates { - let targetSenderTimestamp: UInt32? = update.senderTimestamp - let targetWireTimestamp: UInt32 = update.senderTimestamp - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.radioID == targetRadioID && - $0.channelIndex == nilChannel && - $0.directionRawValue == incoming && - ($0.senderTimestamp == targetSenderTimestamp || - ($0.senderTimestamp == nil && $0.timestamp == targetWireTimestamp)) - } - ) - let prefixByte = update.senderPrefixByte - let candidates = try modelContext.fetch(descriptor) - for message in candidates where message.senderKeyPrefix?.first == prefixByte { - message.regionScope = update.regionScope - } - } - try modelContext.save() - } - - // MARK: - Debug Log Entries - - /// Saves a batch of debug log entries. - public func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) throws { - for dto in dtos { - let entry = DebugLogEntry( - id: dto.id, - timestamp: dto.timestamp, - level: dto.level.rawValue, - subsystem: dto.subsystem, - category: dto.category, - message: dto.message - ) - modelContext.insert(entry) +public extension PersistenceStore { + // MARK: - Saved Trace Path Operations + + func fetchSavedTracePaths(radioID: UUID) throws -> [SavedTracePathDTO] { + let targetRadioID = radioID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID }, + sortBy: [SortDescriptor(\.createdDate, order: .reverse)] + ) + let paths = try modelContext.fetch(descriptor) + return paths.map { SavedTracePathDTO(from: $0) } + } + + func fetchSavedTracePath(id: UUID) throws -> SavedTracePathDTO? { + let targetID = id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let path = try modelContext.fetch(descriptor).first else { return nil } + return SavedTracePathDTO(from: path) + } + + func createSavedTracePath( + radioID: UUID, + name: String, + pathBytes: Data, + hashSize: Int = 1, + initialRun: TracePathRunDTO? + ) throws -> SavedTracePathDTO { + let path = SavedTracePath( + radioID: radioID, + name: name, + pathBytes: pathBytes, + hashSize: hashSize + ) + + if let runDTO = initialRun { + let run = try TracePathRun(dto: runDTO) + run.savedPath = path + path.runs.append(run) + modelContext.insert(run) + } + + modelContext.insert(path) + try modelContext.save() + return SavedTracePathDTO(from: path) + } + + func updateSavedTracePathName(id: UUID, name: String) throws { + let targetID = id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let path = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.fetchFailed("SavedTracePath not found") + } + path.name = name + try modelContext.save() + } + + func deleteSavedTracePath(id: UUID) throws { + let targetID = id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let path = try modelContext.fetch(descriptor).first else { return } + modelContext.delete(path) + try modelContext.save() + } + + func appendTracePathRun(pathID: UUID, run runDTO: TracePathRunDTO) throws { + let targetID = pathID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let path = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.fetchFailed("SavedTracePath not found") + } + + let run = try TracePathRun(dto: runDTO) + run.savedPath = path + path.runs.append(run) + modelContext.insert(run) + try modelContext.save() + } + + // MARK: - RxLogEntry + + /// Save a new RX log entry. + func saveRxLogEntry(_ dto: RxLogEntryDTO) throws { + // Seed the count cache from disk before the insert; incrementing a + // missing entry from zero would undercount rows persisted by earlier + // sessions and suppress pruning indefinitely. + let countBeforeInsert = try cachedRxLogEntryCount(radioID: dto.radioID) + let entry = RxLogEntry( + id: dto.id, + radioID: dto.radioID, + receivedAt: dto.receivedAt, + snr: dto.snr, + rssi: dto.rssi, + routeType: Int(dto.routeType.rawValue), + payloadType: Int(dto.payloadType.rawValue), + payloadVersion: Int(dto.payloadVersion), + transportCode: dto.transportCode, + pathLength: Int(dto.pathLength), + pathNodes: dto.pathNodes, + packetPayload: dto.packetPayload, + rawPayload: dto.rawPayload, + packetHash: dto.packetHash, + channelIndex: dto.channelIndex.map { Int($0) }, + channelName: dto.channelName, + decryptStatus: dto.decryptStatus.rawValue, + fromContactName: dto.fromContactName, + toContactName: dto.toContactName, + senderTimestamp: dto.senderTimestamp.map { Int($0) }, + regionScope: dto.regionScope, + payloadTypeBits: Int(dto.payloadTypeBits) + ) + modelContext.insert(entry) + try modelContext.save() + rxLogEntryCountsByDevice[dto.radioID] = countBeforeInsert + 1 + } + + /// Fetch RX log entries for a device, most recent first. + func fetchRxLogEntries(radioID: UUID, limit: Int = 500) throws -> [RxLogEntryDTO] { + let targetRadioID = radioID + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID }, + sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] + ) + descriptor.fetchLimit = limit + let entries = try modelContext.fetch(descriptor) + return entries.map { RxLogEntryDTO(from: $0) } + } + + /// Count RX log entries for a device. + func countRxLogEntries(radioID: UUID) throws -> Int { + let targetRadioID = radioID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID } + ) + return try modelContext.fetchCount(descriptor) + } + + /// Delete oldest entries once the log materially exceeds the retention cap. + /// + /// This avoids repeated count/fetch/delete maintenance on every RX packet while keeping + /// retention bounded to `keepCount + pruneThreshold` entries between prune passes. + func pruneRxLogEntries( + radioID: UUID, + keepCount: Int = 1000, + pruneThreshold: Int = 100 + ) throws { + let count = try cachedRxLogEntryCount(radioID: radioID) + guard count > keepCount + pruneThreshold else { return } + + let deleteCount = count - keepCount + let targetRadioID = radioID + + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID }, + sortBy: [SortDescriptor(\.receivedAt, order: .forward)] // Oldest first + ) + descriptor.fetchLimit = deleteCount + + let toDelete = try modelContext.fetch(descriptor) + for entry in toDelete { + modelContext.delete(entry) + } + try modelContext.save() + rxLogEntryCountsByDevice[radioID] = keepCount + } + + /// Clear all RX log entries for a device. + func clearRxLogEntries(radioID: UUID) throws { + let targetRadioID = radioID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID } + ) + let entries = try modelContext.fetch(descriptor) + for entry in entries { + modelContext.delete(entry) + } + try modelContext.save() + rxLogEntryCountsByDevice[radioID] = 0 + } + + private func cachedRxLogEntryCount(radioID: UUID) throws -> Int { + if let cached = rxLogEntryCountsByDevice[radioID] { + return cached + } + + let count = try countRxLogEntries(radioID: radioID) + rxLogEntryCountsByDevice[radioID] = count + return count + } + + /// Find RxLogEntry matching an incoming message for path correlation. + /// + /// For channel messages: Correlates by channel index and sender timestamp. + /// For direct messages: Correlates by sender timestamp and payload type. + func findRxLogEntry( + radioID: UUID, + channelIndex: UInt8?, + senderTimestamp: UInt32 + ) throws -> RxLogEntryDTO? { + let targetRadioID = radioID + let targetTimestamp = Int(senderTimestamp) + + if let channelIndex { + // Channel message: match on channelIndex and senderTimestamp + let channelIndexInt = Int(channelIndex) + + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && + entry.channelIndex == channelIndexInt && + entry.senderTimestamp == targetTimestamp + } + + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] + + let results = try modelContext.fetch(descriptor) + return results.first.map { RxLogEntryDTO(from: $0) } + } else { + // Direct message: match on senderTimestamp + let textMessageType = Int(PayloadType.textMessage.rawValue) + + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && + entry.senderTimestamp == targetTimestamp && + entry.channelIndex == nil && + entry.payloadType == textMessageType + } + + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] + + let results = try modelContext.fetch(descriptor) + return results.first.map { RxLogEntryDTO(from: $0) } + } + } + + /// Find a DM RxLogEntry by matching the sender prefix byte in the packet payload. + /// + /// Fallback for when the primary `findRxLogEntry(senderTimestamp:)` fails because + /// DM decryption hadn't succeeded yet (senderTimestamp was nil). Matches on the + /// unencrypted srcHash byte at `packetPayload[1]` and a receive-time window. + func findRxLogEntryBySenderPrefix( + radioID: UUID, + senderPrefixByte: UInt8, + receivedSince: Date + ) throws -> RxLogEntryDTO? { + let targetRadioID = radioID + let textMessageType = Int(PayloadType.textMessage.rawValue) + let cutoff = receivedSince + + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && + entry.channelIndex == nil && + entry.payloadType == textMessageType && + entry.receivedAt >= cutoff + } + + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] + descriptor.fetchLimit = 20 + + let candidates = try modelContext.fetch(descriptor) + + // Filter in-memory: match sender prefix byte at packetPayload[1] + let match = candidates.first { entry in + entry.packetPayload.count >= 2 && entry.packetPayload[1] == senderPrefixByte + } + + return match.map { RxLogEntryDTO(from: $0) } + } + + /// Fetch recent RX log entries with a given decrypt status. + func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) throws -> [RxLogEntryDTO] { + let targetRadioID = radioID + let targetStatus = status.rawValue + let cutoff = since + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.radioID == targetRadioID && + $0.decryptStatus == targetStatus && + $0.receivedAt >= cutoff + }, + sortBy: [SortDescriptor(\.receivedAt, order: .forward)] + ) + let entries = try modelContext.fetch(descriptor) + return entries.map { RxLogEntryDTO(from: $0) } + } + + /// Batch update RX log entries after successful decryption. + /// Note: decodedText is @Transient and not persisted. + func batchUpdateRxLogDecryption( + _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] + ) throws { + for update in updates { + let targetID = update.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let entry = try modelContext.fetch(descriptor).first else { continue } + + entry.channelIndex = update.channelIndex.map { Int($0) } + entry.channelName = update.channelName + entry.decryptStatus = DecryptStatus.success.rawValue + entry.senderTimestamp = update.senderTimestamp.map { Int($0) } + } + try modelContext.save() + } + + /// Fetch RX log entries that have a transport code but no resolved + /// region yet — the back-fill candidate set. + func fetchEntriesWithMissingRegion(radioID: UUID) throws -> [RxLogEntryDTO] { + let targetRadioID = radioID + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.radioID == targetRadioID && + $0.transportCode != nil && + $0.regionScope == nil + }, + sortBy: [SortDescriptor(\.receivedAt, order: .forward)] + ) + let entries = try modelContext.fetch(descriptor) + return entries.map { RxLogEntryDTO(from: $0) } + } + + /// Batch update `regionScope` on RX log entries by id. + func batchUpdateRxLogRegion( + updates: [(id: UUID, regionScope: String?)] + ) throws { + for update in updates { + let targetID = update.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let entry = try modelContext.fetch(descriptor).first else { continue } + entry.regionScope = update.regionScope + } + try modelContext.save() + } + + /// Batch update `regionScope` on incoming **channel** `Message` rows + /// correlated by `(channelIndex, senderTimestamp)`. The wire timestamp + /// fallback is required because `Message.senderTimestamp` is only + /// populated for the rare timestamp-corrected case; the normal case puts + /// the wire timestamp on `Message.timestamp`. + func batchUpdateChannelMessageRegion( + radioID: UUID, + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) throws { + let targetRadioID = radioID + let incoming = MessageDirection.incoming.rawValue + for update in updates { + let targetIndex: UInt8? = update.channelIndex + let targetSenderTimestamp: UInt32? = update.senderTimestamp + let targetWireTimestamp: UInt32 = update.senderTimestamp + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.radioID == targetRadioID && + $0.channelIndex == targetIndex && + $0.directionRawValue == incoming && + ($0.senderTimestamp == targetSenderTimestamp || + ($0.senderTimestamp == nil && $0.timestamp == targetWireTimestamp)) } - try modelContext.save() - } - - /// Fetches debug log entries since a given date. - public func fetchDebugLogEntries(since date: Date, limit: Int = 1000) throws -> [DebugLogEntryDTO] { - let startDate = date - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.timestamp >= startDate }, - sortBy: [SortDescriptor(\.timestamp, order: .reverse)] - ) - descriptor.fetchLimit = limit - let entries = try modelContext.fetch(descriptor) - return entries.map { DebugLogEntryDTO(from: $0) } - } - - /// Counts all debug log entries. - public func countDebugLogEntries() throws -> Int { - let descriptor = FetchDescriptor() - return try modelContext.fetchCount(descriptor) - } - - /// Prunes debug log entries, keeping only the most recent entries. - public func pruneDebugLogEntries(keepCount: Int = 1000) throws { - let count = try countDebugLogEntries() - guard count > keepCount else { return } - - let deleteCount = count - keepCount - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.timestamp, order: .forward)] - ) - descriptor.fetchLimit = deleteCount - - let toDelete = try modelContext.fetch(descriptor) - for entry in toDelete { - modelContext.delete(entry) + ) + for message in try modelContext.fetch(descriptor) { + message.regionScope = update.regionScope + } + } + try modelContext.save() + } + + /// Batch update `regionScope` on incoming **DM** `Message` rows. DMs + /// carry the sender prefix byte at `RxLogEntry.packetPayload[1]` but + /// the Message side stores the full multi-byte `senderKeyPrefix`, so + /// the predicate fetches by timestamp + DM channel and an in-memory + /// pass disambiguates by first-byte equality. Mirrors the correlation + /// key used by `findRxLogEntryBySenderPrefix`. + func batchUpdateDMMessageRegion( + radioID: UUID, + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) throws { + let targetRadioID = radioID + let nilChannel: UInt8? = nil + let incoming = MessageDirection.incoming.rawValue + for update in updates { + let targetSenderTimestamp: UInt32? = update.senderTimestamp + let targetWireTimestamp: UInt32 = update.senderTimestamp + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.radioID == targetRadioID && + $0.channelIndex == nilChannel && + $0.directionRawValue == incoming && + ($0.senderTimestamp == targetSenderTimestamp || + ($0.senderTimestamp == nil && $0.timestamp == targetWireTimestamp)) } - try modelContext.save() - } - - /// Clears all debug log entries. - public func clearDebugLogEntries() throws { - try modelContext.delete(model: DebugLogEntry.self) - try modelContext.save() - } - - // MARK: - Node Status Snapshots - - public func saveNodeStatusSnapshot( - nodePublicKey: Data, - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) throws -> UUID { - try saveNodeStatusSnapshot( - timestamp: .now, - nodePublicKey: nodePublicKey, - batteryMillivolts: batteryMillivolts, - lastSNR: lastSNR, - lastRSSI: lastRSSI, - noiseFloor: noiseFloor, - uptimeSeconds: uptimeSeconds, - rxAirtimeSeconds: rxAirtimeSeconds, - packetsSent: packetsSent, - packetsReceived: packetsReceived, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - } - - // Overload that accepts an explicit timestamp, used by tests to avoid timing-dependent sleeps. - // swiftlint:disable:next function_parameter_count - public func saveNodeStatusSnapshot( - timestamp: Date, - nodePublicKey: Data, - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) throws -> UUID { - let snapshot = NodeStatusSnapshot( - timestamp: timestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: batteryMillivolts, - lastSNR: lastSNR, - lastRSSI: lastRSSI, - noiseFloor: noiseFloor, - uptimeSeconds: uptimeSeconds, - rxAirtimeSeconds: rxAirtimeSeconds, - packetsSent: packetsSent, - packetsReceived: packetsReceived, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - modelContext.insert(snapshot) - try modelContext.save() - return snapshot.id - } - - public func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) throws -> NodeStatusSnapshotDTO? { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, - sortBy: [SortDescriptor(\.timestamp, order: .reverse)] - ) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map(NodeStatusSnapshotDTO.init) - } - - public func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) throws -> [NodeStatusSnapshotDTO] { - let descriptor: FetchDescriptor - if let since { - descriptor = FetchDescriptor( - predicate: #Predicate { $0.nodePublicKey == nodePublicKey && $0.timestamp >= since }, - sortBy: [SortDescriptor(\.timestamp)] - ) - } else { - descriptor = FetchDescriptor( - predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, - sortBy: [SortDescriptor(\.timestamp)] - ) - } - return try modelContext.fetch(descriptor).map(NodeStatusSnapshotDTO.init) - } - - public func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) throws { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - guard let snapshot = try modelContext.fetch(descriptor).first else { return } - snapshot.neighborSnapshots = neighbors - try modelContext.save() - } - - public func saveTelemetryOnlySnapshot( - nodePublicKey: Data, - telemetryEntries: [TelemetrySnapshotEntry] - ) throws -> UUID { - let snapshot = NodeStatusSnapshot( - nodePublicKey: nodePublicKey, - telemetryEntries: telemetryEntries - ) - modelContext.insert(snapshot) - try modelContext.save() - return snapshot.id - } - - public func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) throws { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - guard let snapshot = try modelContext.fetch(descriptor).first else { return } - snapshot.telemetryEntries = telemetry - try modelContext.save() - } - - /// Atomically capture a status, telemetry, and/or neighbor snapshot for a node. - /// - /// The fetch-latest decision and the insert-or-enrich both run in this single - /// `@ModelActor` method body with no `await`, so two concurrent captures - /// serialize and the second observes the first's row — there is no window for a - /// duplicate in-window insert. Keeping the body suspension-free is the whole - /// point; an `await` here would reopen that race. Mirrors the same-isolation - /// guarantee `insertPendingSendAssigningSequence` relies on. - /// - /// Within `NodeSnapshotPolicy.minimumInterval` of the latest snapshot the - /// capture enriches that row: status fields are applied only when the row is - /// still telemetry-only (`uptimeSeconds == nil`), preserving the - /// one-status-point-per-window throttle; telemetry and neighbor arrays are - /// applied whenever supplied. Outside the window a new snapshot is inserted. - public func recordNodeStatusSnapshot( - nodePublicKey: Data, - status: NodeStatusMetrics?, - telemetry: [TelemetrySnapshotEntry]?, - neighbors: [NeighborSnapshotEntry]? - ) throws -> UUID { - var latestDescriptor = FetchDescriptor( - predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, - sortBy: [SortDescriptor(\.timestamp, order: .reverse)] - ) - latestDescriptor.fetchLimit = 1 - - if let latest = try modelContext.fetch(latestDescriptor).first, - latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { - if let status, latest.uptimeSeconds == nil { - latest.apply(status) - } - if let telemetry { - latest.telemetryEntries = telemetry - } - if let neighbors { - latest.neighborSnapshots = neighbors - } - try modelContext.save() - return latest.id - } - - let snapshot = NodeStatusSnapshot( - nodePublicKey: nodePublicKey, - telemetryEntries: telemetry - ) - if let status { - snapshot.apply(status) - } - if let neighbors { - snapshot.neighborSnapshots = neighbors - } - modelContext.insert(snapshot) - try modelContext.save() - return snapshot.id - } - - public func deleteOldNodeStatusSnapshots(olderThan date: Date) throws { - try modelContext.delete( - model: NodeStatusSnapshot.self, - where: #Predicate { $0.timestamp < date } - ) - try modelContext.save() - } + ) + let prefixByte = update.senderPrefixByte + let candidates = try modelContext.fetch(descriptor) + for message in candidates where message.senderKeyPrefix?.first == prefixByte { + message.regionScope = update.regionScope + } + } + try modelContext.save() + } + + // MARK: - Debug Log Entries + + /// Saves a batch of debug log entries. + func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) throws { + for dto in dtos { + let entry = DebugLogEntry( + id: dto.id, + timestamp: dto.timestamp, + level: dto.level.rawValue, + subsystem: dto.subsystem, + category: dto.category, + message: dto.message + ) + modelContext.insert(entry) + } + try modelContext.save() + } + + /// Fetches debug log entries since a given date. + func fetchDebugLogEntries(since date: Date, limit: Int = 1000) throws -> [DebugLogEntryDTO] { + let startDate = date + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.timestamp >= startDate }, + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + descriptor.fetchLimit = limit + let entries = try modelContext.fetch(descriptor) + return entries.map { DebugLogEntryDTO(from: $0) } + } + + /// Counts all debug log entries. + func countDebugLogEntries() throws -> Int { + let descriptor = FetchDescriptor() + return try modelContext.fetchCount(descriptor) + } + + /// Prunes debug log entries, keeping only the most recent entries. + func pruneDebugLogEntries(keepCount: Int = 1000) throws { + let count = try countDebugLogEntries() + guard count > keepCount else { return } + + let deleteCount = count - keepCount + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.timestamp, order: .forward)] + ) + descriptor.fetchLimit = deleteCount + + let toDelete = try modelContext.fetch(descriptor) + for entry in toDelete { + modelContext.delete(entry) + } + try modelContext.save() + } + + /// Clears all debug log entries. + func clearDebugLogEntries() throws { + try modelContext.delete(model: DebugLogEntry.self) + try modelContext.save() + } + + // MARK: - Node Status Snapshots + + func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) throws -> UUID { + try saveNodeStatusSnapshot( + timestamp: .now, + nodePublicKey: nodePublicKey, + batteryMillivolts: batteryMillivolts, + lastSNR: lastSNR, + lastRSSI: lastRSSI, + noiseFloor: noiseFloor, + uptimeSeconds: uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: packetsSent, + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + } + + // Overload that accepts an explicit timestamp, used by tests to avoid timing-dependent sleeps. + // swiftlint:disable:next function_parameter_count + func saveNodeStatusSnapshot( + timestamp: Date, + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) throws -> UUID { + let snapshot = NodeStatusSnapshot( + timestamp: timestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: batteryMillivolts, + lastSNR: lastSNR, + lastRSSI: lastRSSI, + noiseFloor: noiseFloor, + uptimeSeconds: uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: packetsSent, + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + modelContext.insert(snapshot) + try modelContext.save() + return snapshot.id + } + + func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) throws -> NodeStatusSnapshotDTO? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map(NodeStatusSnapshotDTO.init) + } + + func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) throws -> [NodeStatusSnapshotDTO] { + let descriptor = if let since { + FetchDescriptor( + predicate: #Predicate { $0.nodePublicKey == nodePublicKey && $0.timestamp >= since }, + sortBy: [SortDescriptor(\.timestamp)] + ) + } else { + FetchDescriptor( + predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, + sortBy: [SortDescriptor(\.timestamp)] + ) + } + return try modelContext.fetch(descriptor).map(NodeStatusSnapshotDTO.init) + } + + func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) throws { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + guard let snapshot = try modelContext.fetch(descriptor).first else { return } + snapshot.neighborSnapshots = neighbors + try modelContext.save() + } + + func saveTelemetryOnlySnapshot( + nodePublicKey: Data, + telemetryEntries: [TelemetrySnapshotEntry] + ) throws -> UUID { + let snapshot = NodeStatusSnapshot( + nodePublicKey: nodePublicKey, + telemetryEntries: telemetryEntries + ) + modelContext.insert(snapshot) + try modelContext.save() + return snapshot.id + } + + func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) throws { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + guard let snapshot = try modelContext.fetch(descriptor).first else { return } + snapshot.telemetryEntries = telemetry + try modelContext.save() + } + + /// Atomically capture a status, telemetry, and/or neighbor snapshot for a node. + /// + /// The fetch-latest decision and the insert-or-enrich both run in this single + /// `@ModelActor` method body with no `await`, so two concurrent captures + /// serialize and the second observes the first's row — there is no window for a + /// duplicate in-window insert. Keeping the body suspension-free is the whole + /// point; an `await` here would reopen that race. Mirrors the same-isolation + /// guarantee `insertPendingSendAssigningSequence` relies on. + /// + /// Within `NodeSnapshotPolicy.minimumInterval` of the latest snapshot the + /// capture enriches that row: status fields are applied only when the row is + /// still telemetry-only (`uptimeSeconds == nil`), preserving the + /// one-status-point-per-window throttle; telemetry and neighbor arrays are + /// applied whenever supplied. Outside the window a new snapshot is inserted. + func recordNodeStatusSnapshot( + nodePublicKey: Data, + status: NodeStatusMetrics?, + telemetry: [TelemetrySnapshotEntry]?, + neighbors: [NeighborSnapshotEntry]? + ) throws -> UUID { + var latestDescriptor = FetchDescriptor( + predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + latestDescriptor.fetchLimit = 1 + + if let latest = try modelContext.fetch(latestDescriptor).first, + latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { + if let status, latest.uptimeSeconds == nil { + latest.apply(status) + } + if let telemetry { + latest.telemetryEntries = telemetry + } + if let neighbors { + latest.neighborSnapshots = neighbors + } + try modelContext.save() + return latest.id + } + + let snapshot = NodeStatusSnapshot( + nodePublicKey: nodePublicKey, + telemetryEntries: telemetry + ) + if let status { + snapshot.apply(status) + } + if let neighbors { + snapshot.neighborSnapshots = neighbors + } + modelContext.insert(snapshot) + try modelContext.save() + return snapshot.id + } + + func deleteOldNodeStatusSnapshots(olderThan date: Date) throws { + try modelContext.delete( + model: NodeStatusSnapshot.self, + where: #Predicate { $0.timestamp < date } + ) + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift index 9743a127..cdcb20f8 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift @@ -2,773 +2,766 @@ import Foundation import os import SwiftData -extension PersistenceStore { - - // MARK: - Mention Tracking - - public func markMentionSeen(messageID: UUID) throws { - let targetID = messageID - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { return } - message.mentionSeen = true - try modelContext.save() - } - - // MARK: - Message Operations - - /// Batch fetch last messages for multiple contacts in a single actor-isolated call. - /// Runs N fetches with zero suspension points between them, avoiding N actor hops. - public func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { - var result: [UUID: [MessageDTO]] = [:] - result.reserveCapacity(contactIDs.count) - for contactID in contactIDs { - result[contactID] = try fetchMessages(contactID: contactID, limit: limit) - } - return result - } - - /// Batch fetch last messages for multiple channels in a single actor-isolated call. - /// Runs N fetches with zero suspension points between them, avoiding N actor hops. - public func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { - var result: [UUID: [MessageDTO]] = [:] - result.reserveCapacity(channels.count) - for channel in channels { - result[channel.id] = try fetchMessages(radioID: channel.radioID, channelIndex: channel.channelIndex, limit: limit) - } - return result - } - - /// Fetch messages for a contact - public func fetchMessages(contactID: UUID, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { - let targetContactID: UUID? = contactID - let predicate = #Predicate { message in - message.contactID == targetContactID - } - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\Message.sortDate, order: .reverse), - SortDescriptor(\Message.timestamp, order: .reverse), - SortDescriptor(\Message.createdAt, order: .reverse) - ] - ) - descriptor.fetchLimit = limit - descriptor.fetchOffset = offset - - let messages = try modelContext.fetch(descriptor) - let dtos = messages.reversed().map { MessageDTO(from: $0) } - return MessageDTO.reorderSameSenderClusters(dtos) - } - - /// Fetch messages for a channel - public func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { - let targetRadioID = radioID - let targetChannelIndex: UInt8? = channelIndex - let predicate = #Predicate { message in - message.radioID == targetRadioID && message.channelIndex == targetChannelIndex - } - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\Message.sortDate, order: .reverse), - SortDescriptor(\Message.timestamp, order: .reverse), - SortDescriptor(\Message.createdAt, order: .reverse) - ] - ) - descriptor.fetchLimit = limit - descriptor.fetchOffset = offset - - let messages = try modelContext.fetch(descriptor) - let dtos = messages.reversed().map { MessageDTO(from: $0) } - return MessageDTO.reorderSameSenderClusters(dtos) - } - - /// Finds a channel message matching a parsed reaction within a timestamp window. - public func findChannelMessageForReaction( - radioID: UUID, - channelIndex: UInt8, - parsedReaction: ParsedReaction, - localNodeName: String?, - timestampWindow: ClosedRange, - limit: Int - ) throws -> MessageDTO? { - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") - // swiftlint:disable:next line_length - logger.debug("[REACTION-MATCH] Looking for message: targetSender=\(parsedReaction.targetSender), hash=\(parsedReaction.messageHash), localNodeName=\(localNodeName ?? "nil"), window=\(timestampWindow.lowerBound)...\(timestampWindow.upperBound)") - - let candidates = try fetchChannelMessageCandidates( - radioID: radioID, - channelIndex: channelIndex, - timestampWindow: timestampWindow, - limit: limit - ) - logger.debug("[REACTION-MATCH] Found \(candidates.count) candidates in window") - guard !candidates.isEmpty else { return nil } - - for candidate in candidates { - let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" - let candidateHash = ReactionParser.generateMessageHash(text: candidate.text, timestamp: candidate.reactionTimestamp) - logger.debug("[REACTION-MATCH] Candidate: direction=\(direction), senderNodeName=\(candidate.senderNodeName ?? "nil"), hash=\(candidateHash), text=\(candidate.text.prefix(30))") - - if candidate.direction == .outgoing { - guard let localNodeName, parsedReaction.targetSender == localNodeName else { - logger.debug("[REACTION-MATCH] Skip outgoing: localNodeName=\(localNodeName ?? "nil"), targetSender=\(parsedReaction.targetSender)") - continue - } - } else { - guard candidate.senderNodeName == parsedReaction.targetSender else { - logger.debug("[REACTION-MATCH] Skip incoming: senderNodeName=\(candidate.senderNodeName ?? "nil") != targetSender=\(parsedReaction.targetSender)") - continue - } - } - - guard candidateHash == parsedReaction.messageHash else { - logger.debug("[REACTION-MATCH] Hash mismatch: \(candidateHash) != \(parsedReaction.messageHash)") - continue - } - - logger.debug("[REACTION-MATCH] Found match!") - return candidate - } - - logger.debug("[REACTION-MATCH] No match found") - return nil - } - - /// Fetches channel message candidates within a timestamp window for meshcore-open reaction matching. - /// - /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. - public func fetchChannelMessageCandidates( - radioID: UUID, - channelIndex: UInt8, - timestampWindow: ClosedRange, - limit: Int - ) throws -> [MessageDTO] { - let targetRadioID = radioID - let targetChannelIndex: UInt8? = channelIndex - let start = timestampWindow.lowerBound - let end = timestampWindow.upperBound - - let predicate = #Predicate { message in - message.radioID == targetRadioID && - message.channelIndex == targetChannelIndex && - message.timestamp >= start && - message.timestamp <= end - } - - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\Message.createdAt, order: .reverse), - SortDescriptor(\Message.timestamp, order: .reverse) - ] - ) - descriptor.fetchLimit = limit - - return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } - } - - /// Fetches DM message candidates within a timestamp window for meshcore-open reaction matching. - /// - /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. - public func fetchDMMessageCandidates( - radioID: UUID, - contactID: UUID, - timestampWindow: ClosedRange, - limit: Int - ) throws -> [MessageDTO] { - let targetRadioID = radioID - let targetContactID: UUID? = contactID - let start = timestampWindow.lowerBound - let end = timestampWindow.upperBound - - let predicate = #Predicate { message in - message.radioID == targetRadioID && - message.contactID == targetContactID && - message.timestamp >= start && - message.timestamp <= end - } - - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\Message.createdAt, order: .reverse), - SortDescriptor(\Message.timestamp, order: .reverse) - ] - ) - descriptor.fetchLimit = limit - - return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } - } - - /// Finds a DM message matching a reaction by hash within a timestamp window. - public func findDMMessageForReaction( - radioID: UUID, - contactID: UUID, - messageHash: String, - timestampWindow: ClosedRange, - limit: Int - ) throws -> MessageDTO? { - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") - logger.debug("[DM-REACTION-MATCH] Looking for DM: hash=\(messageHash), contactID=\(contactID)") - - let candidates = try fetchDMMessageCandidates( - radioID: radioID, - contactID: contactID, - timestampWindow: timestampWindow, - limit: limit - ) - logger.debug("[DM-REACTION-MATCH] Found \(candidates.count) candidates") - - for candidate in candidates { - // Skip messages that are themselves reactions - if ReactionParser.isReactionText(candidate.text, isDM: true) { - logger.debug("[DM-REACTION-MATCH] Skipping candidate (is reaction): \(candidate.text.prefix(30))") - continue - } - - let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" - let candidateHash = ReactionParser.generateMessageHash( - text: candidate.text, - timestamp: candidate.reactionTimestamp - ) - logger.debug("[DM-REACTION-MATCH] Candidate: direction=\(direction), timestamp=\(candidate.timestamp), senderTimestamp=\(candidate.senderTimestamp ?? 0), hash=\(candidateHash), text=\(candidate.text.prefix(30))") - if candidateHash == messageHash { - logger.debug("[DM-REACTION-MATCH] Found match: \(candidate.id)") - return candidate - } else { - logger.debug("[DM-REACTION-MATCH] Hash mismatch: \(candidateHash) != \(messageHash)") - } - } - - return nil - } - - /// Fetch a message by ID - public func fetchMessage(id: UUID) throws -> MessageDTO? { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { MessageDTO(from: $0) } - } - - /// Check if a message with this deduplication key already exists for the given radio. - public func isDuplicateMessage(deduplicationKey: String, radioID: UUID) throws -> Bool { - let targetKey = deduplicationKey - let targetRadioID = radioID - let predicate = #Predicate { - $0.deduplicationKey == targetKey && $0.radioID == targetRadioID - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 - } - - /// Save a new message - public func saveMessage(_ dto: MessageDTO) throws { - modelContext.insert(Message(dto: dto)) - try modelContext.save() - } - - /// Update message status - public func updateMessageStatus(id: UUID, status: MessageStatus) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.status = status - try modelContext.save() - } - } - - /// Update message status unless delivery has already won the race. - /// - /// - Returns: `true` if the row's status was changed, `false` if no row was - /// updated (either the row is already `.delivered`, or no row exists for - /// the given `id`). Callers must gate failure side effects (e.g., the - /// `MessageStatusEvent.failed` broadcast, UI toasts) on the return value - /// so they do not surface a `.failed` event for a delivered or absent row. - public func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) throws -> Bool { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first, message.status != .delivered else { - return false - } - message.status = status - try modelContext.save() - return true - } - - /// Clears a retry-loop status (`.retrying`/`.pending`) back to `.sent` when - /// the app-layer retry budget is spent but the end-to-end ACK may still - /// arrive within `ackGiveUpWindow`. - /// - /// Terminal-safe: no-ops and returns `false` on a `.delivered` or `.failed` - /// row, so it can never resurrect a row the expiry checker already failed or - /// downgrade a delivered row. Returns `true` when the row moved to `.sent`. - public func clearRetryingToSent(id: UUID) throws -> Bool { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first, - message.status != .delivered, - message.status != .failed else { - return false - } - message.status = .sent - try modelContext.save() - return true - } - - /// Update message status with retry attempt information. - /// - /// Skips the write on a terminal row (`.delivered` or `.failed`) so a stale - /// retry iteration cannot clobber a winning ACK, nor resurrect a row the - /// expiry checker already failed in the loop's `waitForEvent` await-gap. - /// This matches the terminal-safety of `clearRetryingToSent` and - /// `updateMessageAck`. - public func updateMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first, - message.status != .delivered, - message.status != .failed { - message.status = status - message.retryAttempt = retryAttempt - message.maxRetryAttempts = maxRetryAttempts - try modelContext.save() - } - } - - /// Update message timestamp (for resending) - public func updateMessageTimestamp(id: UUID, timestamp: UInt32) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.timestamp = timestamp - try modelContext.save() - } - } - - /// Update message ACK info. - /// - /// Both `.delivered` and `.failed` are terminal for this write: once the - /// listener (or `finalizeSend`) writes `.delivered` + `roundTripTime`, a - /// later `.sent` write from the send-return path is skipped so the - /// authoritative delivery state is preserved; and once the expiry checker - /// writes `.failed`, a late ACK landing in the checker's await-gap cannot - /// flip the row to `.delivered`. The only late transition this write allows - /// is the legitimate `.sent` -> `.delivered` upgrade. - public func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32? = nil) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - if (message.status == .delivered || message.status == .failed) && status != message.status { return } - message.ackCode = ackCode - message.status = status - message.roundTripTime = roundTripTime - try modelContext.save() - } - } - - /// Read-only diagnostic: whether an outgoing DM row persists `.sent` with - /// this `ackCode`. - public func hasOutgoingSentDM(ackCode: UInt32) throws -> Bool { - let target: UInt32? = ackCode - let sentRaw = MessageStatus.sent.rawValue - let outgoingRaw = MessageDirection.outgoing.rawValue - let predicate = #Predicate { message in - message.ackCode == target && - message.statusRawValue == sentRaw && - message.directionRawValue == outgoingRaw && - message.channelIndex == nil - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 - } - - /// Mark a message as read - public func markMessageAsRead(id: UUID) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.isRead = true - try modelContext.save() - } - } - - /// Updates the heard repeats count for a message - public func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.heardRepeats = heardRepeats - try modelContext.save() - } - } - - /// Update link preview data for a message - public func updateMessageLinkPreview( - id: UUID, - url: String?, - title: String?, - imageData: Data?, - iconData: Data?, - fetched: Bool - ) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.linkPreviewURL = url - message.linkPreviewTitle = title - message.linkPreviewImageData = imageData - message.linkPreviewIconData = iconData - message.linkPreviewFetched = fetched - try modelContext.save() - } - } - - /// Delete a message and its reactions - public func deleteMessage(id: UUID) throws { - let targetID = id - - // Cascade PendingSend outside the `if let message` guard. An orphan - // PendingSend (no matching Message row) can exist via several paths: - // the queue's success-path `deletePendingSends` racing a deleteMessage - // from another path; same-millisecond user delete + queue upsert; - // historical bugs; tests. Reaping the PendingSend unconditionally on - // deleteMessage is strictly stronger than gating on the Message - // existing — there is no Message left to be "consistent" with, and - // orphan PendingSends otherwise survive until the next purge cycle. - let pendingPredicate = #Predicate { row in - row.messageID == targetID - } - for row in try modelContext.fetch(FetchDescriptor(predicate: pendingPredicate)) { - modelContext.delete(row) - } - - let predicate = #Predicate { message in - message.id == targetID - } - if let message = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { - // Inlined Reaction cascade — only meaningful when the Message - // exists. Reactions are anchored to the Message row, so an - // orphan-Message reaction is its own cleanup problem. - try modelContext.delete(model: Reaction.self, where: #Predicate { reaction in - reaction.messageID == targetID - }) - modelContext.delete(message) - } - try modelContext.save() - } - - /// Delete all channel messages from a specific sender for a device. - /// Only deletes messages with a non-nil channelIndex (channel messages), preserving DMs. - /// Cascades PendingSend, MessageRepeat, and Reaction rows associated with the deleted - /// messages within a single save. - public func deleteChannelMessages(fromSender senderName: String, radioID: UUID) throws { - let targetRadioID = radioID - let targetSenderName: String? = senderName - let messagePredicate = #Predicate { message in - message.radioID == targetRadioID && - message.senderNodeName == targetSenderName && - message.channelIndex != nil - } - - let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) - - if !messageIDs.isEmpty { - try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) - // Cascade MessageRepeat alongside Reaction. Bulk `delete(model:where:)` - // bypasses the `@Relationship(deleteRule: .cascade)` declared on - // `Message → MessageRepeat`, so the cascade has to be explicit. - // Chunk both predicates to stay under SQLITE_MAX_VARIABLE_NUMBER - // (32766 on iOS 18+). - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. Int { - let targetRadioID = radioID - let pendingStatus = MessageStatus.pending.rawValue - let sendingStatus = MessageStatus.sending.rawValue - let predicate = #Predicate { message in - message.radioID == targetRadioID && - (message.statusRawValue == pendingStatus || - message.statusRawValue == sendingStatus) - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) - } - - // MARK: - Heard Repeats - - /// Finds a sent channel message matching the given criteria within a time window. - /// Used for correlating RX log entries to sent messages. - /// - /// - Parameters: - /// - radioID: The radio that sent the message - /// - channelIndex: Channel the message was sent on - /// - timestamp: Sender timestamp from the message - /// - text: Message text to match - /// - withinSeconds: Time window to search (default 10 seconds) - /// - Returns: MessageDTO if found, nil otherwise - public func findSentChannelMessage( - radioID: UUID, - channelIndex: UInt8, - timestamp: UInt32, - text: String, - withinSeconds: Int = 10 - ) throws -> MessageDTO? { - let targetRadioID = radioID - let targetChannelIndex: UInt8? = channelIndex - let targetTimestamp = timestamp - let outgoingDirection = MessageDirection.outgoing.rawValue - - // Calculate time window - let now = Date() - let windowStart = now.addingTimeInterval(-TimeInterval(withinSeconds)) - let windowStartTimestamp = UInt32(windowStart.timeIntervalSince1970) - - let predicate = #Predicate { message in - message.radioID == targetRadioID && - message.channelIndex == targetChannelIndex && - message.timestamp == targetTimestamp && - message.directionRawValue == outgoingDirection && - message.timestamp >= windowStartTimestamp - } - - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { - return nil - } - - // Verify text matches (outgoing channel messages store just the text) - guard message.text == text else { - return nil - } - - return MessageDTO(from: message) - } - - /// Saves a new MessageRepeat entry and links it to the parent message. - public func saveMessageRepeat(_ dto: MessageRepeatDTO) throws { - // Fetch the parent message for relationship - let targetMessageID = dto.messageID - let messagePredicate = #Predicate { message in - message.id == targetMessageID - } - var messageDescriptor = FetchDescriptor(predicate: messagePredicate) - messageDescriptor.fetchLimit = 1 - - guard let parentMessage = try modelContext.fetch(messageDescriptor).first else { - throw PersistenceStoreError.messageNotFound - } - - modelContext.insert(MessageRepeat(dto: dto, message: parentMessage)) - try modelContext.save() - } - - /// Fetches all repeats for a given message, sorted by receivedAt ascending. - public func fetchMessageRepeats(messageID: UUID) throws -> [MessageRepeatDTO] { - let targetMessageID = messageID - let predicate = #Predicate { repeat_ in - repeat_.messageID == targetMessageID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\MessageRepeat.receivedAt, order: .forward)] - ) - - let results = try modelContext.fetch(descriptor) - return results.map { MessageRepeatDTO(from: $0) } - } - - /// Deletes all repeats for a given message. - public func deleteMessageRepeats(messageID: UUID) throws { - let targetMessageID = messageID - let predicate = #Predicate { repeat_ in - repeat_.messageID == targetMessageID - } - let descriptor = FetchDescriptor(predicate: predicate) - - let results = try modelContext.fetch(descriptor) - for repeat_ in results { - modelContext.delete(repeat_) - } - try modelContext.save() - } - - /// Checks if a repeat already exists for the given RX log entry. - public func messageRepeatExists(rxLogEntryID: UUID) throws -> Bool { - let targetID: UUID? = rxLogEntryID - let predicate = #Predicate { repeat_ in - repeat_.rxLogEntryID == targetID - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 - } - - /// Increments the heardRepeats count for a message and returns the new count. - public func incrementMessageHeardRepeats(id: UUID) throws -> Int { - let targetID = id - let predicate = #Predicate { message in message.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { - return 0 - } - - message.heardRepeats += 1 - try modelContext.save() - return message.heardRepeats - } - - /// Increments the sendCount for a message and returns the new count. - public func incrementMessageSendCount(id: UUID) throws -> Int { - let targetID = id - let predicate = #Predicate { message in message.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { - return 0 - } - - message.sendCount += 1 - try modelContext.save() - return message.sendCount - } - - // MARK: - Reactions - - /// Saves a new reaction - public func saveReaction(_ dto: ReactionDTO) throws { - let reaction = Reaction( - id: dto.id, - messageID: dto.messageID, - emoji: dto.emoji, - senderName: dto.senderName, - messageHash: dto.messageHash, - rawText: dto.rawText, - receivedAt: dto.receivedAt, - channelIndex: dto.channelIndex, - contactID: dto.contactID, - radioID: dto.radioID - ) - modelContext.insert(reaction) - try modelContext.save() - } - - /// Fetches reactions for a message - public func fetchReactions(for messageID: UUID, limit: Int = 100) throws -> [ReactionDTO] { - let targetMessageID = messageID - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.messageID == targetMessageID }, - sortBy: [SortDescriptor(\Reaction.receivedAt, order: .reverse)] - ) - descriptor.fetchLimit = limit - return try modelContext.fetch(descriptor).map { ReactionDTO(from: $0) } - } - - /// Checks if a reaction already exists (deduplication) - public func reactionExists(messageID: UUID, senderName: String, emoji: String) throws -> Bool { - let targetMessageID = messageID - let targetSenderName = senderName - let targetEmoji = emoji - let predicate = #Predicate { - $0.messageID == targetMessageID && - $0.senderName == targetSenderName && - $0.emoji == targetEmoji - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 - } - - /// Updates a message's reaction summary cache - public func updateMessageReactionSummary(messageID: UUID, summary: String?) throws { - let targetMessageID = messageID - let predicate = #Predicate { $0.id == targetMessageID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { return } - message.reactionSummary = summary - try modelContext.save() - } - - /// Deletes all reactions for a message - public func deleteReactionsForMessage(messageID: UUID) throws { - let targetMessageID = messageID +public extension PersistenceStore { + // MARK: - Mention Tracking + + func markMentionSeen(messageID: UUID) throws { + let targetID = messageID + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { return } + message.mentionSeen = true + try modelContext.save() + } + + // MARK: - Message Operations + + /// Batch fetch last messages for multiple contacts in a single actor-isolated call. + /// Runs N fetches with zero suspension points between them, avoiding N actor hops. + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + result.reserveCapacity(contactIDs.count) + for contactID in contactIDs { + result[contactID] = try fetchMessages(contactID: contactID, limit: limit) + } + return result + } + + /// Batch fetch last messages for multiple channels in a single actor-isolated call. + /// Runs N fetches with zero suspension points between them, avoiding N actor hops. + func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + result.reserveCapacity(channels.count) + for channel in channels { + result[channel.id] = try fetchMessages(radioID: channel.radioID, channelIndex: channel.channelIndex, limit: limit) + } + return result + } + + /// Fetch messages for a contact + func fetchMessages(contactID: UUID, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { + let targetContactID: UUID? = contactID + let predicate = #Predicate { message in + message.contactID == targetContactID + } + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.sortDate, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse), + SortDescriptor(\Message.createdAt, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + descriptor.fetchOffset = offset + + let messages = try modelContext.fetch(descriptor) + let dtos = messages.reversed().map { MessageDTO(from: $0) } + return MessageDTO.reorderSameSenderClusters(dtos) + } + + /// Fetch messages for a channel + func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let predicate = #Predicate { message in + message.radioID == targetRadioID && message.channelIndex == targetChannelIndex + } + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.sortDate, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse), + SortDescriptor(\Message.createdAt, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + descriptor.fetchOffset = offset + + let messages = try modelContext.fetch(descriptor) + let dtos = messages.reversed().map { MessageDTO(from: $0) } + return MessageDTO.reorderSameSenderClusters(dtos) + } + + /// Finds a channel message matching a parsed reaction within a timestamp window. + func findChannelMessageForReaction( + radioID: UUID, + channelIndex: UInt8, + parsedReaction: ParsedReaction, + localNodeName: String?, + timestampWindow: ClosedRange, + limit: Int + ) throws -> MessageDTO? { + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") + logger.debug("[REACTION-MATCH] Looking for message: targetSender=\(parsedReaction.targetSender), hash=\(parsedReaction.messageHash), localNodeName=\(localNodeName ?? "nil"), window=\(timestampWindow.lowerBound)...\(timestampWindow.upperBound)") + + let candidates = try fetchChannelMessageCandidates( + radioID: radioID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: limit + ) + logger.debug("[REACTION-MATCH] Found \(candidates.count) candidates in window") + guard !candidates.isEmpty else { return nil } + + for candidate in candidates { + let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" + let candidateHash = ReactionParser.generateMessageHash(text: candidate.text, timestamp: candidate.reactionTimestamp) + logger.debug("[REACTION-MATCH] Candidate: direction=\(direction), senderNodeName=\(candidate.senderNodeName ?? "nil"), hash=\(candidateHash), text=\(candidate.text.prefix(30))") + + if candidate.direction == .outgoing { + guard let localNodeName, parsedReaction.targetSender == localNodeName else { + logger.debug("[REACTION-MATCH] Skip outgoing: localNodeName=\(localNodeName ?? "nil"), targetSender=\(parsedReaction.targetSender)") + continue + } + } else { + guard candidate.senderNodeName == parsedReaction.targetSender else { + logger.debug("[REACTION-MATCH] Skip incoming: senderNodeName=\(candidate.senderNodeName ?? "nil") != targetSender=\(parsedReaction.targetSender)") + continue + } + } + + guard candidateHash == parsedReaction.messageHash else { + logger.debug("[REACTION-MATCH] Hash mismatch: \(candidateHash) != \(parsedReaction.messageHash)") + continue + } + + logger.debug("[REACTION-MATCH] Found match!") + return candidate + } + + logger.debug("[REACTION-MATCH] No match found") + return nil + } + + /// Fetches channel message candidates within a timestamp window for meshcore-open reaction matching. + /// + /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. + func fetchChannelMessageCandidates( + radioID: UUID, + channelIndex: UInt8, + timestampWindow: ClosedRange, + limit: Int + ) throws -> [MessageDTO] { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let start = timestampWindow.lowerBound + let end = timestampWindow.upperBound + + let predicate = #Predicate { message in + message.radioID == targetRadioID && + message.channelIndex == targetChannelIndex && + message.timestamp >= start && + message.timestamp <= end + } + + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.createdAt, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + + return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } + } + + /// Fetches DM message candidates within a timestamp window for meshcore-open reaction matching. + /// + /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. + func fetchDMMessageCandidates( + radioID: UUID, + contactID: UUID, + timestampWindow: ClosedRange, + limit: Int + ) throws -> [MessageDTO] { + let targetRadioID = radioID + let targetContactID: UUID? = contactID + let start = timestampWindow.lowerBound + let end = timestampWindow.upperBound + + let predicate = #Predicate { message in + message.radioID == targetRadioID && + message.contactID == targetContactID && + message.timestamp >= start && + message.timestamp <= end + } + + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.createdAt, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + + return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } + } + + /// Finds a DM message matching a reaction by hash within a timestamp window. + func findDMMessageForReaction( + radioID: UUID, + contactID: UUID, + messageHash: String, + timestampWindow: ClosedRange, + limit: Int + ) throws -> MessageDTO? { + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") + logger.debug("[DM-REACTION-MATCH] Looking for DM: hash=\(messageHash), contactID=\(contactID)") + + let candidates = try fetchDMMessageCandidates( + radioID: radioID, + contactID: contactID, + timestampWindow: timestampWindow, + limit: limit + ) + logger.debug("[DM-REACTION-MATCH] Found \(candidates.count) candidates") + + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: true) { + logger.debug("[DM-REACTION-MATCH] Skipping candidate (is reaction): \(candidate.text.prefix(30))") + continue + } + + let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" + let candidateHash = ReactionParser.generateMessageHash( + text: candidate.text, + timestamp: candidate.reactionTimestamp + ) + logger.debug("[DM-REACTION-MATCH] Candidate: direction=\(direction), timestamp=\(candidate.timestamp), senderTimestamp=\(candidate.senderTimestamp ?? 0), hash=\(candidateHash), text=\(candidate.text.prefix(30))") + if candidateHash == messageHash { + logger.debug("[DM-REACTION-MATCH] Found match: \(candidate.id)") + return candidate + } else { + logger.debug("[DM-REACTION-MATCH] Hash mismatch: \(candidateHash) != \(messageHash)") + } + } + + return nil + } + + /// Fetch a message by ID + func fetchMessage(id: UUID) throws -> MessageDTO? { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { MessageDTO(from: $0) } + } + + /// Check if a message with this deduplication key already exists for the given radio. + func isDuplicateMessage(deduplicationKey: String, radioID: UUID) throws -> Bool { + let targetKey = deduplicationKey + let targetRadioID = radioID + let predicate = #Predicate { + $0.deduplicationKey == targetKey && $0.radioID == targetRadioID + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Save a new message + func saveMessage(_ dto: MessageDTO) throws { + modelContext.insert(Message(dto: dto)) + try modelContext.save() + } + + /// Update message status + func updateMessageStatus(id: UUID, status: MessageStatus) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.status = status + try modelContext.save() + } + } + + /// Update message status unless delivery has already won the race. + /// + /// - Returns: `true` if the row's status was changed, `false` if no row was + /// updated (either the row is already `.delivered`, or no row exists for + /// the given `id`). Callers must gate failure side effects (e.g., the + /// `MessageStatusEvent.failed` broadcast, UI toasts) on the return value + /// so they do not surface a `.failed` event for a delivered or absent row. + func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) throws -> Bool { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first, message.status != .delivered else { + return false + } + message.status = status + try modelContext.save() + return true + } + + /// Clears a retry-loop status (`.retrying`/`.pending`) back to `.sent` when + /// the app-layer retry budget is spent but the end-to-end ACK may still + /// arrive within `ackGiveUpWindow`. + /// + /// Terminal-safe: no-ops and returns `false` on a `.delivered` or `.failed` + /// row, so it can never resurrect a row the expiry checker already failed or + /// downgrade a delivered row. Returns `true` when the row moved to `.sent`. + func clearRetryingToSent(id: UUID) throws -> Bool { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first, + message.status != .delivered, + message.status != .failed else { + return false + } + message.status = .sent + try modelContext.save() + return true + } + + /// Update message status with retry attempt information. + /// + /// Skips the write on a terminal row (`.delivered` or `.failed`) so a stale + /// retry iteration cannot clobber a winning ACK, nor resurrect a row the + /// expiry checker already failed in the loop's `waitForEvent` await-gap. + /// This matches the terminal-safety of `clearRetryingToSent` and + /// `updateMessageAck`. + func updateMessageRetryStatus( + id: UUID, + status: MessageStatus, + retryAttempt: Int, + maxRetryAttempts: Int + ) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first, + message.status != .delivered, + message.status != .failed { + message.status = status + message.retryAttempt = retryAttempt + message.maxRetryAttempts = maxRetryAttempts + try modelContext.save() + } + } + + /// Update message timestamp (for resending) + func updateMessageTimestamp(id: UUID, timestamp: UInt32) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.timestamp = timestamp + try modelContext.save() + } + } + + /// Update message ACK info. + /// + /// Both `.delivered` and `.failed` are terminal for this write: once the + /// listener (or `finalizeSend`) writes `.delivered` + `roundTripTime`, a + /// later `.sent` write from the send-return path is skipped so the + /// authoritative delivery state is preserved; and once the expiry checker + /// writes `.failed`, a late ACK landing in the checker's await-gap cannot + /// flip the row to `.delivered`. The only late transition this write allows + /// is the legitimate `.sent` -> `.delivered` upgrade. + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32? = nil) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + if message.status == .delivered || message.status == .failed, status != message.status { return } + message.ackCode = ackCode + message.status = status + message.roundTripTime = roundTripTime + try modelContext.save() + } + } + + /// Read-only diagnostic: whether an outgoing DM row persists `.sent` with + /// this `ackCode`. + func hasOutgoingSentDM(ackCode: UInt32) throws -> Bool { + let target: UInt32? = ackCode + let sentRaw = MessageStatus.sent.rawValue + let outgoingRaw = MessageDirection.outgoing.rawValue + let predicate = #Predicate { message in + message.ackCode == target && + message.statusRawValue == sentRaw && + message.directionRawValue == outgoingRaw && + message.channelIndex == nil + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Mark a message as read + func markMessageAsRead(id: UUID) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.isRead = true + try modelContext.save() + } + } + + /// Updates the heard repeats count for a message + func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.heardRepeats = heardRepeats + try modelContext.save() + } + } + + /// Update link preview data for a message + func updateMessageLinkPreview( + id: UUID, + url: String?, + title: String?, + imageData: Data?, + iconData: Data?, + fetched: Bool + ) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.linkPreviewURL = url + message.linkPreviewTitle = title + message.linkPreviewImageData = imageData + message.linkPreviewIconData = iconData + message.linkPreviewFetched = fetched + try modelContext.save() + } + } + + /// Delete a message and its reactions + func deleteMessage(id: UUID) throws { + let targetID = id + + // Cascade PendingSend outside the `if let message` guard. An orphan + // PendingSend (no matching Message row) can exist via several paths: + // the queue's success-path `deletePendingSends` racing a deleteMessage + // from another path; same-millisecond user delete + queue upsert; + // historical bugs; tests. Reaping the PendingSend unconditionally on + // deleteMessage is strictly stronger than gating on the Message + // existing — there is no Message left to be "consistent" with, and + // orphan PendingSends otherwise survive until the next purge cycle. + let pendingPredicate = #Predicate { row in + row.messageID == targetID + } + for row in try modelContext.fetch(FetchDescriptor(predicate: pendingPredicate)) { + modelContext.delete(row) + } + + let predicate = #Predicate { message in + message.id == targetID + } + if let message = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + // Inlined Reaction cascade — only meaningful when the Message + // exists. Reactions are anchored to the Message row, so an + // orphan-Message reaction is its own cleanup problem. + try modelContext.delete(model: Reaction.self, where: #Predicate { reaction in + reaction.messageID == targetID + }) + modelContext.delete(message) + } + try modelContext.save() + } + + /// Delete all channel messages from a specific sender for a device. + /// Only deletes messages with a non-nil channelIndex (channel messages), preserving DMs. + /// Cascades PendingSend, MessageRepeat, and Reaction rows associated with the deleted + /// messages within a single save. + func deleteChannelMessages(fromSender senderName: String, radioID: UUID) throws { + let targetRadioID = radioID + let targetSenderName: String? = senderName + let messagePredicate = #Predicate { message in + message.radioID == targetRadioID && + message.senderNodeName == targetSenderName && + message.channelIndex != nil + } + + let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) + + if !messageIDs.isEmpty { + try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + // Cascade MessageRepeat alongside Reaction. Bulk `delete(model:where:)` + // bypasses the `@Relationship(deleteRule: .cascade)` declared on + // `Message → MessageRepeat`, so the cascade has to be explicit. + // Chunk both predicates to stay under SQLITE_MAX_VARIABLE_NUMBER + // (32766 on iOS 18+). + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. Int { + let targetRadioID = radioID + let pendingStatus = MessageStatus.pending.rawValue + let sendingStatus = MessageStatus.sending.rawValue + let predicate = #Predicate { message in + message.radioID == targetRadioID && + (message.statusRawValue == pendingStatus || + message.statusRawValue == sendingStatus) + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) + } + + // MARK: - Heard Repeats + + /// Finds the outgoing channel message a heard repeat echoes back. The sender + /// embeds a timestamp the channel payload authenticates and every mesh hop + /// forwards byte-identical, so exact channel, timestamp, and text on the sending + /// radio uniquely identify the original send; `HeardRepeatsService` deduplicates + /// repeated echoes by RX-log-entry id. + /// + /// - Parameters: + /// - radioID: The radio that sent the message + /// - channelIndex: Channel the message was sent on + /// - timestamp: Sender timestamp from the message + /// - text: Message text to match + /// - Returns: MessageDTO if found, nil otherwise + func findSentChannelMessage( + radioID: UUID, + channelIndex: UInt8, + timestamp: UInt32, + text: String + ) throws -> MessageDTO? { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let targetTimestamp = timestamp + let outgoingDirection = MessageDirection.outgoing.rawValue + let targetText = text + + let predicate = #Predicate { message in + message.radioID == targetRadioID && + message.channelIndex == targetChannelIndex && + message.timestamp == targetTimestamp && + message.directionRawValue == outgoingDirection && + message.text == targetText + } + + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\Message.createdAt, order: .reverse)] + ) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { + return nil + } + + return MessageDTO(from: message) + } + + /// Saves a new MessageRepeat entry and links it to the parent message. + func saveMessageRepeat(_ dto: MessageRepeatDTO) throws { + // Fetch the parent message for relationship + let targetMessageID = dto.messageID + let messagePredicate = #Predicate { message in + message.id == targetMessageID + } + var messageDescriptor = FetchDescriptor(predicate: messagePredicate) + messageDescriptor.fetchLimit = 1 + + guard let parentMessage = try modelContext.fetch(messageDescriptor).first else { + throw PersistenceStoreError.messageNotFound + } + + modelContext.insert(MessageRepeat(dto: dto, message: parentMessage)) + try modelContext.save() + } + + /// Fetches all repeats for a given message, sorted by receivedAt ascending. + func fetchMessageRepeats(messageID: UUID) throws -> [MessageRepeatDTO] { + let targetMessageID = messageID + let predicate = #Predicate { repeat_ in + repeat_.messageID == targetMessageID + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\MessageRepeat.receivedAt, order: .forward)] + ) + + let results = try modelContext.fetch(descriptor) + return results.map { MessageRepeatDTO(from: $0) } + } + + /// Deletes all repeats for a given message. + func deleteMessageRepeats(messageID: UUID) throws { + let targetMessageID = messageID + let predicate = #Predicate { repeat_ in + repeat_.messageID == targetMessageID + } + let descriptor = FetchDescriptor(predicate: predicate) + + let results = try modelContext.fetch(descriptor) + for repeat_ in results { + modelContext.delete(repeat_) + } + try modelContext.save() + } + + /// Checks if a repeat already exists for the given RX log entry. + func messageRepeatExists(rxLogEntryID: UUID) throws -> Bool { + let targetID: UUID? = rxLogEntryID + let predicate = #Predicate { repeat_ in + repeat_.rxLogEntryID == targetID + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Increments the heardRepeats count for a message and returns the new count. + func incrementMessageHeardRepeats(id: UUID) throws -> Int { + let targetID = id + let predicate = #Predicate { message in message.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { + return 0 + } + + message.heardRepeats += 1 + try modelContext.save() + return message.heardRepeats + } + + /// Increments the sendCount for a message and returns the new count. + func incrementMessageSendCount(id: UUID) throws -> Int { + let targetID = id + let predicate = #Predicate { message in message.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { + return 0 + } + + message.sendCount += 1 + try modelContext.save() + return message.sendCount + } + + // MARK: - Reactions + + /// Saves a new reaction + func saveReaction(_ dto: ReactionDTO) throws { + let reaction = Reaction( + id: dto.id, + messageID: dto.messageID, + emoji: dto.emoji, + senderName: dto.senderName, + messageHash: dto.messageHash, + rawText: dto.rawText, + receivedAt: dto.receivedAt, + channelIndex: dto.channelIndex, + contactID: dto.contactID, + radioID: dto.radioID + ) + modelContext.insert(reaction) + try modelContext.save() + } + + /// Fetches reactions for a message + func fetchReactions(for messageID: UUID, limit: Int = 100) throws -> [ReactionDTO] { + let targetMessageID = messageID + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.messageID == targetMessageID }, + sortBy: [SortDescriptor(\Reaction.receivedAt, order: .reverse)] + ) + descriptor.fetchLimit = limit + return try modelContext.fetch(descriptor).map { ReactionDTO(from: $0) } + } + + /// Checks if a reaction already exists (deduplication) + func reactionExists(messageID: UUID, senderName: String, emoji: String) throws -> Bool { + let targetMessageID = messageID + let targetSenderName = senderName + let targetEmoji = emoji + let predicate = #Predicate { + $0.messageID == targetMessageID && + $0.senderName == targetSenderName && + $0.emoji == targetEmoji + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Updates a message's reaction summary cache + func updateMessageReactionSummary(messageID: UUID, summary: String?) throws { + let targetMessageID = messageID + let predicate = #Predicate { $0.id == targetMessageID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { return } + message.reactionSummary = summary + try modelContext.save() + } + + /// Deletes all reactions for a message + func deleteReactionsForMessage(messageID: UUID) throws { + let targetMessageID = messageID + try modelContext.delete(model: Reaction.self, where: #Predicate { + $0.messageID == targetMessageID + }) + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Metadata.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Metadata.swift index 05e277be..66988a9b 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Metadata.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Metadata.swift @@ -1,132 +1,131 @@ import Foundation import SwiftData -extension PersistenceStore { +public extension PersistenceStore { + // MARK: - Badge Count Support - // MARK: - Badge Count Support + /// Efficiently calculate total unread counts for badge display + /// Returns tuple of (contactUnread, channelUnread, roomUnread) for preference-aware calculation + /// Optimization: Only fetches entities with unread > 0 to minimize memory usage + func getTotalUnreadCounts(radioID: UUID) throws -> (contacts: Int, channels: Int, rooms: Int) { + let targetRadioID = radioID - /// Efficiently calculate total unread counts for badge display - /// Returns tuple of (contactUnread, channelUnread, roomUnread) for preference-aware calculation - /// Optimization: Only fetches entities with unread > 0 to minimize memory usage - public func getTotalUnreadCounts(radioID: UUID) throws -> (contacts: Int, channels: Int, rooms: Int) { - let targetRadioID = radioID - - // Only fetch non-blocked, non-muted, non-repeater contacts with unread messages for this device. - // Repeater contacts are filtered out of the chats list (ChatViewModel), so unread on them - // is unreachable to the user and must not inflate the badge. - let repeaterContactRaw = ContactType.repeater.rawValue - let contactPredicate = #Predicate { - $0.radioID == targetRadioID && - $0.unreadCount > 0 && - !$0.isMuted && - !$0.isBlocked && - $0.typeRawValue != repeaterContactRaw - } - let contactDescriptor = FetchDescriptor(predicate: contactPredicate) - let contactsWithUnread = try modelContext.fetch(contactDescriptor) - let contactTotal = contactsWithUnread.reduce(0) { $0 + $1.unreadCount } - - // Channels: exclude muted, include if unreadCount > 0 OR unreadMentionCount > 0 - let mutedRawValue = NotificationLevel.muted.rawValue - let channelPredicate = #Predicate { - $0.radioID == targetRadioID && - $0.notificationLevelRawValue != mutedRawValue && - ($0.unreadCount > 0 || $0.unreadMentionCount > 0) - } - let channelDescriptor = FetchDescriptor(predicate: channelPredicate) - let channelsWithUnread = try modelContext.fetch(channelDescriptor) - let channelTotal = channelsWithUnread.reduce(0) { total, channel in - if channel.notificationLevel == .mentionsOnly { - return total + channel.unreadMentionCount - } - return total + channel.unreadCount - } + // Only fetch non-blocked, non-muted, non-repeater contacts with unread messages for this device. + // Repeater contacts are filtered out of the chats list (ChatViewModel), so unread on them + // is unreachable to the user and must not inflate the badge. + let repeaterContactRaw = ContactType.repeater.rawValue + let contactPredicate = #Predicate { + $0.radioID == targetRadioID && + $0.unreadCount > 0 && + !$0.isMuted && + !$0.isBlocked && + $0.typeRawValue != repeaterContactRaw + } + let contactDescriptor = FetchDescriptor(predicate: contactPredicate) + let contactsWithUnread = try modelContext.fetch(contactDescriptor) + let contactTotal = contactsWithUnread.reduce(0) { $0 + $1.unreadCount } - // Rooms: only include room-server sessions; repeater-role admin sessions are filtered out - // of the chats list and would otherwise be unreachable badge contributors. - let roomServerRoleRaw = RemoteNodeRole.roomServer.rawValue - let roomPredicate = #Predicate { - $0.radioID == targetRadioID && - $0.notificationLevelRawValue != mutedRawValue && - $0.unreadCount > 0 && - $0.roleRawValue == roomServerRoleRaw - } - let roomDescriptor = FetchDescriptor(predicate: roomPredicate) - let roomsWithUnread = try modelContext.fetch(roomDescriptor) - let roomTotal = roomsWithUnread.reduce(0) { $0 + $1.unreadCount } + // Channels: exclude muted, include if unreadCount > 0 OR unreadMentionCount > 0 + let mutedRawValue = NotificationLevel.muted.rawValue + let channelPredicate = #Predicate { + $0.radioID == targetRadioID && + $0.notificationLevelRawValue != mutedRawValue && + ($0.unreadCount > 0 || $0.unreadMentionCount > 0) + } + let channelDescriptor = FetchDescriptor(predicate: channelPredicate) + let channelsWithUnread = try modelContext.fetch(channelDescriptor) + let channelTotal = channelsWithUnread.reduce(0) { total, channel in + if channel.notificationLevel == .mentionsOnly { + return total + channel.unreadMentionCount + } + return total + channel.unreadCount + } - return (contacts: contactTotal, channels: channelTotal, rooms: roomTotal) + // Rooms: only include room-server sessions; repeater-role admin sessions are filtered out + // of the chats list and would otherwise be unreachable badge contributors. + let roomServerRoleRaw = RemoteNodeRole.roomServer.rawValue + let roomPredicate = #Predicate { + $0.radioID == targetRadioID && + $0.notificationLevelRawValue != mutedRawValue && + $0.unreadCount > 0 && + $0.roleRawValue == roomServerRoleRaw } + let roomDescriptor = FetchDescriptor(predicate: roomPredicate) + let roomsWithUnread = try modelContext.fetch(roomDescriptor) + let roomTotal = roomsWithUnread.reduce(0) { $0 + $1.unreadCount } + + return (contacts: contactTotal, channels: channelTotal, rooms: roomTotal) + } - /// Get total unread count for a contact - public func getUnreadCount(contactID: UUID) throws -> Int { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first?.unreadCount ?? 0 + /// Get total unread count for a contact + func getUnreadCount(contactID: UUID) throws -> Int { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first?.unreadCount ?? 0 + } - /// Get total unread count for a channel - public func getChannelUnreadCount(channelID: UUID) throws -> Int { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first?.unreadCount ?? 0 + /// Get total unread count for a channel + func getChannelUnreadCount(channelID: UUID) throws -> Int { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first?.unreadCount ?? 0 + } - // MARK: - Link Preview Data + // MARK: - Link Preview Data - /// Fetches link preview data by URL - public func fetchLinkPreview(url: String) throws -> LinkPreviewDataDTO? { - let urlToFind = url - let predicate = #Predicate { preview in - preview.url == urlToFind - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + /// Fetches link preview data by URL + func fetchLinkPreview(url: String) throws -> LinkPreviewDataDTO? { + let urlToFind = url + let predicate = #Predicate { preview in + preview.url == urlToFind + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - guard let preview = try modelContext.fetch(descriptor).first else { - return nil - } - return LinkPreviewDataDTO(from: preview) + guard let preview = try modelContext.fetch(descriptor).first else { + return nil } + return LinkPreviewDataDTO(from: preview) + } - /// Saves or updates link preview data - public func saveLinkPreview(_ dto: LinkPreviewDataDTO) throws { - let urlToFind = dto.url - let predicate = #Predicate { preview in - preview.url == urlToFind - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + /// Saves or updates link preview data + func saveLinkPreview(_ dto: LinkPreviewDataDTO) throws { + let urlToFind = dto.url + let predicate = #Predicate { preview in + preview.url == urlToFind + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - if let existing = try modelContext.fetch(descriptor).first { - // Update existing - existing.title = dto.title - existing.imageData = dto.imageData - existing.iconData = dto.iconData - existing.imageWidth = dto.imageWidth - existing.imageHeight = dto.imageHeight - existing.fetchedAt = dto.fetchedAt - } else { - // Insert new - let preview = LinkPreviewData( - url: dto.url, - title: dto.title, - imageData: dto.imageData, - iconData: dto.iconData, - imageWidth: dto.imageWidth, - imageHeight: dto.imageHeight, - fetchedAt: dto.fetchedAt - ) - modelContext.insert(preview) - } - try modelContext.save() + if let existing = try modelContext.fetch(descriptor).first { + // Update existing + existing.title = dto.title + existing.imageData = dto.imageData + existing.iconData = dto.iconData + existing.imageWidth = dto.imageWidth + existing.imageHeight = dto.imageHeight + existing.fetchedAt = dto.fetchedAt + } else { + // Insert new + let preview = LinkPreviewData( + url: dto.url, + title: dto.title, + imageData: dto.imageData, + iconData: dto.iconData, + imageWidth: dto.imageWidth, + imageHeight: dto.imageHeight, + fetchedAt: dto.fetchedAt + ) + modelContext.insert(preview) } + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Migration.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Migration.swift index 356d25f4..9622a279 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Migration.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Migration.swift @@ -3,260 +3,277 @@ import os import SwiftData extension PersistenceStore { + private static let migrationLogger = Logger(subsystem: "com.mc1", category: "RadioIDMigration") + private static let migrationKey = "hasPopulatedRadioIDs" + + /// One-time migration: populate radioID on all Devices and propagate to children, + /// then backfill deduplicationKey on outgoing Messages with nil keys. + public func performRadioIDMigration(defaults: UserDefaults = .standard) throws { + guard !defaults.bool(forKey: Self.migrationKey) else { return } + + // Step 1: For each Device, children's radioID column still contains the old BLE UUID + // (device.id) due to the @Attribute(originalName: "deviceID") rename. Generate a new + // radioID for each Device and propagate to all children. + let devices = try modelContext.fetch(FetchDescriptor()) + + let lastDeviceIDString = defaults.string(forKey: PersistenceKeys.lastConnectedDeviceID) + let lastDeviceID = lastDeviceIDString.flatMap(UUID.init) + var mappedRadioID: UUID? + + for device in devices { + let newRadioID = UUID() + let oldRadioID = device.id + device.radioID = newRadioID + + if oldRadioID == lastDeviceID { + mappedRadioID = newRadioID + } + + let targetOldID = oldRadioID + + let contacts = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for contact in contacts { + contact.radioID = newRadioID + } + + let channels = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for channel in channels { + channel.radioID = newRadioID + } + + let messages = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for message in messages { + message.radioID = newRadioID + } + + let reactions = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for reaction in reactions { + reaction.radioID = newRadioID + } + + let sessions = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for session in sessions { + session.radioID = newRadioID + } + + let paths = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for path in paths { + path.radioID = newRadioID + } + + let nodes = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for node in nodes { + node.radioID = newRadioID + } + + let blocked = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for sender in blocked { + sender.radioID = newRadioID + } + + let logs = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for log in logs { + log.radioID = newRadioID + } + } - private static let migrationLogger = Logger(subsystem: "com.mc1", category: "RadioIDMigration") - private static let migrationKey = "hasPopulatedRadioIDs" - - /// One-time migration: populate radioID on all Devices and propagate to children, - /// then backfill deduplicationKey on outgoing Messages with nil keys. - public func performRadioIDMigration() throws { - guard !UserDefaults.standard.bool(forKey: Self.migrationKey) else { return } - - // Step 1: For each Device, children's radioID column still contains the old BLE UUID - // (device.id) due to the @Attribute(originalName: "deviceID") rename. Generate a new - // radioID for each Device and propagate to all children. - let devices = try modelContext.fetch(FetchDescriptor()) - - let lastDeviceIDString = UserDefaults.standard.string(forKey: PersistenceKeys.lastConnectedDeviceID) - let lastDeviceID = lastDeviceIDString.flatMap(UUID.init) - var mappedRadioID: UUID? - - for device in devices { - let newRadioID = UUID() - let oldRadioID = device.id - device.radioID = newRadioID - - if oldRadioID == lastDeviceID { - mappedRadioID = newRadioID - } - - let targetOldID = oldRadioID - - let contacts = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for contact in contacts { contact.radioID = newRadioID } - - let channels = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for channel in channels { channel.radioID = newRadioID } - - let messages = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for message in messages { message.radioID = newRadioID } - - let reactions = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for reaction in reactions { reaction.radioID = newRadioID } - - let sessions = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for session in sessions { session.radioID = newRadioID } + // Step 2: Backfill deduplicationKey on outgoing messages with nil keys. + // Only outgoing (directionRawValue == 1); incoming messages get keys during re-sync. + let outgoingDirection = MessageDirection.outgoing.rawValue + let nilKeyPredicate = #Predicate { message in + message.deduplicationKey == nil && message.directionRawValue == outgoingDirection + } + let messagesNeedingKeys = try modelContext.fetch(FetchDescriptor(predicate: nilKeyPredicate)) + + for message in messagesNeedingKeys { + message.deduplicationKey = DeduplicationKey.contentBased( + contactID: message.contactID, + channelIndex: message.channelIndex, + senderNodeName: message.senderNodeName, + timestamp: message.timestamp, + content: message.text + ) + } - let paths = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for path in paths { path.radioID = newRadioID } + try modelContext.save() - let nodes = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for node in nodes { node.radioID = newRadioID } + if let mappedRadioID { + defaults.set(mappedRadioID.uuidString, forKey: PersistenceKeys.lastConnectedRadioID) + } else if lastDeviceID != nil { + Self.migrationLogger.warning("lastConnectedDeviceID did not match any stored device; lastConnectedRadioID not backfilled") + } - let blocked = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for sender in blocked { sender.radioID = newRadioID } + defaults.set(true, forKey: Self.migrationKey) - let logs = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for log in logs { log.radioID = newRadioID } - } + Self.migrationLogger.info("radioID migration complete: \(devices.count) devices, \(messagesNeedingKeys.count) dedup keys backfilled") + } - // Step 2: Backfill deduplicationKey on outgoing messages with nil keys. - // Only outgoing (directionRawValue == 1); incoming messages get keys during re-sync. - let outgoingDirection = MessageDirection.outgoing.rawValue - let nilKeyPredicate = #Predicate { message in - message.deduplicationKey == nil && message.directionRawValue == outgoingDirection - } - let messagesNeedingKeys = try modelContext.fetch(FetchDescriptor(predicate: nilKeyPredicate)) + /// Resets the migration flag (for testing only). + public func resetRadioIDMigrationFlag(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: Self.migrationKey) + } - for message in messagesNeedingKeys { - message.deduplicationKey = DeduplicationKey.contentBased( - contactID: message.contactID, - channelIndex: message.channelIndex, - senderNodeName: message.senderNodeName, - timestamp: message.timestamp, - content: message.text - ) - } + // MARK: - Channel flood scope corrective migration - try modelContext.save() + private static let floodScopeMigrationKey = "hasMigratedChannelFloodScope" + private static let floodScopeMigrationLogger = Logger( + subsystem: "com.mc1", + category: "ChannelFloodScopeMigration" + ) - if let mappedRadioID { - UserDefaults.standard.set(mappedRadioID.uuidString, forKey: PersistenceKeys.lastConnectedRadioID) - } else if lastDeviceID != nil { - Self.migrationLogger.warning("lastConnectedDeviceID did not match any stored device; lastConnectedRadioID not backfilled") - } + /// One-time migration: pre-existing rows were persisted before the flood-scope mode + /// column existed and all come up with the default `.inherit` value, even when they + /// had a per-channel `regionScope` override. Promote those to `.specific` so the + /// user's prior choice keeps working. Rows whose `regionScope` was nil keep + /// `.inherit` — corrective semantics, so the device default applies. + public func performChannelFloodScopeMigration(defaults: UserDefaults = .standard) throws { + guard !defaults.bool(forKey: Self.floodScopeMigrationKey) else { return } - UserDefaults.standard.set(true, forKey: Self.migrationKey) + let inheritRaw = ChannelFloodScopeStorage.Mode.inherit.rawValue + let specificRaw = ChannelFloodScopeStorage.Mode.specific.rawValue - Self.migrationLogger.info("radioID migration complete: \(devices.count) devices, \(messagesNeedingKeys.count) dedup keys backfilled") + let predicate = #Predicate { channel in + channel.regionScope != nil && channel.floodScopeModeRawValue == inheritRaw } - - /// Resets the migration flag (for testing only). - public func resetRadioIDMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.migrationKey) + let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + for channel in channels { + channel.floodScopeModeRawValue = specificRaw } + try modelContext.save() - // MARK: - Channel flood scope corrective migration + defaults.set(true, forKey: Self.floodScopeMigrationKey) - private static let floodScopeMigrationKey = "hasMigratedChannelFloodScope" - private static let floodScopeMigrationLogger = Logger( - subsystem: "com.mc1", - category: "ChannelFloodScopeMigration" + Self.floodScopeMigrationLogger.info( + "channel flood-scope migration complete: \(channels.count) rows promoted to .specific" ) - - /// One-time migration: pre-existing rows were persisted before the flood-scope mode - /// column existed and all come up with the default `.inherit` value, even when they - /// had a per-channel `regionScope` override. Promote those to `.specific` so the - /// user's prior choice keeps working. Rows whose `regionScope` was nil keep - /// `.inherit` — corrective semantics, so the device default applies. - public func performChannelFloodScopeMigration() throws { - guard !UserDefaults.standard.bool(forKey: Self.floodScopeMigrationKey) else { return } - - let inheritRaw = ChannelFloodScopeStorage.Mode.inherit.rawValue - let specificRaw = ChannelFloodScopeStorage.Mode.specific.rawValue - - let predicate = #Predicate { channel in - channel.regionScope != nil && channel.floodScopeModeRawValue == inheritRaw - } - let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - for channel in channels { - channel.floodScopeModeRawValue = specificRaw - } - try modelContext.save() - - UserDefaults.standard.set(true, forKey: Self.floodScopeMigrationKey) - - Self.floodScopeMigrationLogger.info( - "channel flood-scope migration complete: \(channels.count) rows promoted to .specific" - ) + } + + /// Resets the migration flag (for testing only). + public func resetChannelFloodScopeMigrationFlag(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: Self.floodScopeMigrationKey) + } + + // MARK: - Repeater unread-count corrective migration + + private static let repeaterUnreadMigrationKey = "hasMigratedRepeaterUnreadCounts" + private static let repeaterUnreadMigrationLogger = Logger( + subsystem: "com.mc1", + category: "RepeaterUnreadMigration" + ) + + /// One-time migration: prior versions counted unread on repeater-type contacts and + /// repeater-role node sessions toward the OS badge, even though those records are + /// filtered out of the chats list and so unreachable to the user. Zero out any + /// accumulated counters so the badge drops to a sane number on first launch after + /// upgrading. The predicate fix in `getTotalUnreadCounts` prevents new accumulation + /// from inflating the badge; this sweep clears the historical residue. + public func performRepeaterUnreadCountMigration(defaults: UserDefaults = .standard) throws { + guard !defaults.bool(forKey: Self.repeaterUnreadMigrationKey) else { return } + + let repeaterContactRaw = ContactType.repeater.rawValue + let contactPredicate = #Predicate { contact in + contact.typeRawValue == repeaterContactRaw && + (contact.unreadCount > 0 || contact.unreadMentionCount > 0) } - - /// Resets the migration flag (for testing only). - public func resetChannelFloodScopeMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.floodScopeMigrationKey) + let contacts = try modelContext.fetch(FetchDescriptor(predicate: contactPredicate)) + for contact in contacts { + contact.unreadCount = 0 + contact.unreadMentionCount = 0 } - // MARK: - Repeater unread-count corrective migration - - private static let repeaterUnreadMigrationKey = "hasMigratedRepeaterUnreadCounts" - private static let repeaterUnreadMigrationLogger = Logger( - subsystem: "com.mc1", - category: "RepeaterUnreadMigration" - ) - - /// One-time migration: prior versions counted unread on repeater-type contacts and - /// repeater-role node sessions toward the OS badge, even though those records are - /// filtered out of the chats list and so unreachable to the user. Zero out any - /// accumulated counters so the badge drops to a sane number on first launch after - /// upgrading. The predicate fix in `getTotalUnreadCounts` prevents new accumulation - /// from inflating the badge; this sweep clears the historical residue. - public func performRepeaterUnreadCountMigration() throws { - guard !UserDefaults.standard.bool(forKey: Self.repeaterUnreadMigrationKey) else { return } - - let repeaterContactRaw = ContactType.repeater.rawValue - let contactPredicate = #Predicate { contact in - contact.typeRawValue == repeaterContactRaw && - (contact.unreadCount > 0 || contact.unreadMentionCount > 0) - } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: contactPredicate)) - for contact in contacts { - contact.unreadCount = 0 - contact.unreadMentionCount = 0 - } - - let repeaterRoleRaw = RemoteNodeRole.repeater.rawValue - let sessionPredicate = #Predicate { session in - session.roleRawValue == repeaterRoleRaw && session.unreadCount > 0 - } - let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) - for session in sessions { - session.unreadCount = 0 - } - - try modelContext.save() - - UserDefaults.standard.set(true, forKey: Self.repeaterUnreadMigrationKey) - - Self.repeaterUnreadMigrationLogger.info( - "repeater unread migration complete: \(contacts.count) contacts, \(sessions.count) sessions cleared" - ) + let repeaterRoleRaw = RemoteNodeRole.repeater.rawValue + let sessionPredicate = #Predicate { session in + session.roleRawValue == repeaterRoleRaw && session.unreadCount > 0 } - - /// Resets the migration flag (for testing only). - public func resetRepeaterUnreadMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.repeaterUnreadMigrationKey) + let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) + for session in sessions { + session.unreadCount = 0 } - // MARK: - Message sortDate normalization - - /// Normalizes every message's `sortDate` to its `createdAt`, guarded by a one-time flag. - /// Shared by the backfill and reset migrations, which differ only in which flag gates - /// them and what they log. Returns the row count, or `nil` when the flag was already set - /// and the migration was skipped. - private func normalizeMessageSortDates(flagKey: String) throws -> Int? { - guard !UserDefaults.standard.bool(forKey: flagKey) else { return nil } - - let messages = try modelContext.fetch(FetchDescriptor()) - for message in messages { - message.sortDate = message.createdAt - } - try modelContext.save() - - UserDefaults.standard.set(true, forKey: flagKey) - return messages.count - } + try modelContext.save() - // MARK: - Message sortDate backfill migration + defaults.set(true, forKey: Self.repeaterUnreadMigrationKey) - private static let sortDateBackfillMigrationKey = "hasBackfilledMessageSortDate" - private static let sortDateBackfillMigrationLogger = Logger( - subsystem: "com.mc1", - category: "SortDateBackfillMigration" + Self.repeaterUnreadMigrationLogger.info( + "repeater unread migration complete: \(contacts.count) contacts, \(sessions.count) sessions cleared" ) + } - /// One-time backfill: pre-existing rows were persisted before the `sortDate` - /// column existed and come up with the `Date.distantPast` schema default. - /// Set `sortDate` to `createdAt` on every row so the date-header grouping - /// preserves their current display order. This runs once at launch before - /// any sync, so every row present is a pre-feature row. - public func performSortDateBackfillMigration() throws { - guard let count = try normalizeMessageSortDates(flagKey: Self.sortDateBackfillMigrationKey) else { return } - - Self.sortDateBackfillMigrationLogger.info( - "sortDate backfill complete: \(count) messages backfilled" - ) - } - - /// Resets the migration flag (for testing only). - public func resetSortDateBackfillMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.sortDateBackfillMigrationKey) - } + /// Resets the migration flag (for testing only). + public func resetRepeaterUnreadMigrationFlag(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: Self.repeaterUnreadMigrationKey) + } - // MARK: - Message sortDate reset migration + // MARK: - Message sortDate normalization - private static let sortDateResetMigrationKey = "hasResetMessageSortDate" - private static let sortDateResetMigrationLogger = Logger( - subsystem: "com.mc1", - category: "SortDateResetMigration" - ) + /// Normalizes every message's `sortDate` to its `createdAt`, guarded by a one-time flag. + /// Shared by the backfill and reset migrations, which differ only in which flag gates + /// them and what they log. Returns the row count, or `nil` when the flag was already set + /// and the migration was skipped. + private func normalizeMessageSortDates(flagKey: String, defaults: UserDefaults) throws -> Int? { + guard !defaults.bool(forKey: flagKey) else { return nil } - /// One-time reset: an interim build derived `sortDate` from the sender's send time, - /// which buried just-synced backlog deep in scrollback. The original backfill - /// (`performSortDateBackfillMigration`) already ran on those installs, so its flag is - /// set and it can no longer touch the rows. Re-normalize every row's `sortDate` to - /// `createdAt` so block-at-reconnect ordering starts from a clean receive-time baseline; - /// subsequent syncs derive a fresh drain anchor per batch. Runs once at launch before - /// any sync, so every row present is a pre-feature row. - public func performSortDateResetMigration() throws { - guard let count = try normalizeMessageSortDates(flagKey: Self.sortDateResetMigrationKey) else { return } - - Self.sortDateResetMigrationLogger.info( - "sortDate reset complete: \(count) messages re-normalized to createdAt" - ) + let messages = try modelContext.fetch(FetchDescriptor()) + for message in messages { + message.sortDate = message.createdAt } + try modelContext.save() + + defaults.set(true, forKey: flagKey) + return messages.count + } + + // MARK: - Message sortDate backfill migration + + private static let sortDateBackfillMigrationKey = "hasBackfilledMessageSortDate" + private static let sortDateBackfillMigrationLogger = Logger( + subsystem: "com.mc1", + category: "SortDateBackfillMigration" + ) + + /// One-time backfill: pre-existing rows were persisted before the `sortDate` + /// column existed and come up with the `Date.distantPast` schema default. + /// Set `sortDate` to `createdAt` on every row so the date-header grouping + /// preserves their current display order. This runs once at launch before + /// any sync, so every row present is a pre-feature row. + public func performSortDateBackfillMigration(defaults: UserDefaults = .standard) throws { + guard let count = try normalizeMessageSortDates(flagKey: Self.sortDateBackfillMigrationKey, defaults: defaults) else { return } + + Self.sortDateBackfillMigrationLogger.info( + "sortDate backfill complete: \(count) messages backfilled" + ) + } + + /// Resets the migration flag (for testing only). + public func resetSortDateBackfillMigrationFlag(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: Self.sortDateBackfillMigrationKey) + } + + // MARK: - Message sortDate reset migration + + private static let sortDateResetMigrationKey = "hasResetMessageSortDate" + private static let sortDateResetMigrationLogger = Logger( + subsystem: "com.mc1", + category: "SortDateResetMigration" + ) + + /// One-time reset: an interim build derived `sortDate` from the sender's send time, + /// which buried just-synced backlog deep in scrollback. The original backfill + /// (`performSortDateBackfillMigration`) already ran on those installs, so its flag is + /// set and it can no longer touch the rows. Re-normalize every row's `sortDate` to + /// `createdAt` so block-at-reconnect ordering starts from a clean receive-time baseline; + /// subsequent syncs derive a fresh drain anchor per batch. Runs once at launch before + /// any sync, so every row present is a pre-feature row. + public func performSortDateResetMigration(defaults: UserDefaults = .standard) throws { + guard let count = try normalizeMessageSortDates(flagKey: Self.sortDateResetMigrationKey, defaults: defaults) else { return } + + Self.sortDateResetMigrationLogger.info( + "sortDate reset complete: \(count) messages re-normalized to createdAt" + ) + } - /// Resets the migration flag (for testing only). - public func resetSortDateResetMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.sortDateResetMigrationKey) - } + /// Resets the migration flag (for testing only). + public func resetSortDateResetMigrationFlag(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: Self.sortDateResetMigrationKey) + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+PendingSends.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+PendingSends.swift index 213fd893..f8d3bb96 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+PendingSends.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+PendingSends.swift @@ -2,352 +2,353 @@ import Foundation import os import SwiftData -extension PersistenceStore { +public extension PersistenceStore { + private static let pendingSendLogger = Logger( + subsystem: "com.mc1", + category: "PersistenceStore.PendingSends" + ) - private static let pendingSendLogger = Logger( - subsystem: "com.mc1", - category: "PersistenceStore.PendingSends" - ) + // MARK: - PendingSend CRUD - // MARK: - PendingSend CRUD + /// Insert (or update if `dto.id` already exists) a pending send row. + /// The `dto.sequence` value is used as-is. Used by tests that need to + /// pin sequence values; the production enqueue path uses + /// `insertPendingSendAssigningSequence(_:)` instead so the sequence + /// is assigned atomically. + func upsertPendingSend(_ dto: PendingSendDTO) throws { + let id = dto.id + let predicate = #Predicate { row in + row.id == id + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Insert (or update if `dto.id` already exists) a pending send row. - /// The `dto.sequence` value is used as-is. Used by tests that need to - /// pin sequence values; the production enqueue path uses - /// `insertPendingSendAssigningSequence(_:)` instead so the sequence - /// is assigned atomically. - public func upsertPendingSend(_ dto: PendingSendDTO) throws { - let id = dto.id - let predicate = #Predicate { row in - row.id == id - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + if let existing = try modelContext.fetch(descriptor).first { + existing.radioID = dto.radioID + existing.messageID = dto.messageID + existing.kindRawValue = dto.kind.rawValue + existing.contactID = dto.contactID + existing.channelIndex = dto.channelIndex + existing.isResend = dto.isResend + existing.messageText = dto.messageText + existing.messageTimestamp = dto.messageTimestamp + existing.localNodeName = dto.localNodeName + existing.sequence = dto.sequence + existing.enqueuedAt = dto.enqueuedAt + existing.attemptCount = dto.attemptCount + } else { + modelContext.insert(PendingSend(dto: dto)) + } + try modelContext.save() + } - if let existing = try modelContext.fetch(descriptor).first { - existing.radioID = dto.radioID - existing.messageID = dto.messageID - existing.kindRawValue = dto.kind.rawValue - existing.contactID = dto.contactID - existing.channelIndex = dto.channelIndex - existing.isResend = dto.isResend - existing.messageText = dto.messageText - existing.messageTimestamp = dto.messageTimestamp - existing.localNodeName = dto.localNodeName - existing.sequence = dto.sequence - existing.enqueuedAt = dto.enqueuedAt - existing.attemptCount = dto.attemptCount - } else { - modelContext.insert(PendingSend(dto: dto)) - } - try modelContext.save() + /// Insert a new pending send row, atomically computing and assigning + /// the next sequence number for the row's radio. The `dto.sequence` + /// field is ignored — the assigned value is the one that lands on + /// disk, and it is also the return value. + /// + /// Atomicity: the read of `max(sequence)` and the insert both run on + /// `PersistenceStore`'s serial `@ModelActor` isolation, so two + /// concurrent enqueues from main never see the same `max+1`. This is + /// the production enqueue path; `upsertPendingSend(_:)` above is + /// reserved for tests and explicit-sequence scenarios. + func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) throws -> Int { + let scopedRadioID = dto.radioID + let radioPredicate = #Predicate { row in + row.radioID == scopedRadioID } + var seqDescriptor = FetchDescriptor( + predicate: radioPredicate, + sortBy: [SortDescriptor(\.sequence, order: .reverse)] + ) + seqDescriptor.fetchLimit = 1 + let latest = try modelContext.fetch(seqDescriptor).first + let assignedSequence = (latest?.sequence ?? 0) + 1 - /// Insert a new pending send row, atomically computing and assigning - /// the next sequence number for the row's radio. The `dto.sequence` - /// field is ignored — the assigned value is the one that lands on - /// disk, and it is also the return value. - /// - /// Atomicity: the read of `max(sequence)` and the insert both run on - /// `PersistenceStore`'s serial `@ModelActor` isolation, so two - /// concurrent enqueues from main never see the same `max+1`. This is - /// the production enqueue path; `upsertPendingSend(_:)` above is - /// reserved for tests and explicit-sequence scenarios. - public func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) throws -> Int { - let scopedRadioID = dto.radioID - let radioPredicate = #Predicate { row in - row.radioID == scopedRadioID - } - var seqDescriptor = FetchDescriptor( - predicate: radioPredicate, - sortBy: [SortDescriptor(\.sequence, order: .reverse)] - ) - seqDescriptor.fetchLimit = 1 - let latest = try modelContext.fetch(seqDescriptor).first - let assignedSequence = (latest?.sequence ?? 0) + 1 + let inserted = PendingSend(dto: dto) + inserted.sequence = assignedSequence + modelContext.insert(inserted) + try modelContext.save() + return assignedSequence + } - let inserted = PendingSend(dto: dto) - inserted.sequence = assignedSequence - modelContext.insert(inserted) - try modelContext.save() - return assignedSequence + /// Atomically replaces any existing `PendingSend` rows for the given + /// `messageID`, flips the matching `Message.status` to `.pending` + /// (unless already `.delivered`), and inserts the new `PendingSendDTO`. + /// All three operations land in a single `modelContext.save()` so a + /// crash mid-call cannot leave the row at `.pending` with no + /// `PendingSend` available for replay. The manual DM retry path uses + /// this to keep the queue authoritative for retries while the prior + /// multi-await sequence (delete + status flip + enqueue) had crash + /// windows that stranded `.pending` rows. + /// + /// `.delivered` is intentionally preserved — a late-arriving ACK that + /// landed while the user was reaching for the retry button must not be + /// clobbered. The `PendingSend` row is still inserted; the queue's + /// drain-time `hasPendingSend` gate notices the row but `MessageService` + /// short-circuits the wire send for an already-delivered message. + /// + /// Returns the assigned per-radio sequence number, matching the + /// `insertPendingSendAssigningSequence(_:)` contract. + func replacePendingSendForRetry( + messageID: UUID, + dto: PendingSendDTO + ) async throws -> Int { + let scopedMessageID = messageID + let pendingPredicate = #Predicate { row in + row.messageID == scopedMessageID + } + let existing = try modelContext.fetch(FetchDescriptor(predicate: pendingPredicate)) + for row in existing { + modelContext.delete(row) } - /// Atomically replaces any existing `PendingSend` rows for the given - /// `messageID`, flips the matching `Message.status` to `.pending` - /// (unless already `.delivered`), and inserts the new `PendingSendDTO`. - /// All three operations land in a single `modelContext.save()` so a - /// crash mid-call cannot leave the row at `.pending` with no - /// `PendingSend` available for replay. The manual DM retry path uses - /// this to keep the queue authoritative for retries while the prior - /// multi-await sequence (delete + status flip + enqueue) had crash - /// windows that stranded `.pending` rows. - /// - /// `.delivered` is intentionally preserved — a late-arriving ACK that - /// landed while the user was reaching for the retry button must not be - /// clobbered. The `PendingSend` row is still inserted; the queue's - /// drain-time `hasPendingSend` gate notices the row but `MessageService` - /// short-circuits the wire send for an already-delivered message. - /// - /// Returns the assigned per-radio sequence number, matching the - /// `insertPendingSendAssigningSequence(_:)` contract. - public func replacePendingSendForRetry( - messageID: UUID, - dto: PendingSendDTO - ) async throws -> Int { - let scopedMessageID = messageID - let pendingPredicate = #Predicate { row in - row.messageID == scopedMessageID - } - let existing = try modelContext.fetch(FetchDescriptor(predicate: pendingPredicate)) - for row in existing { - modelContext.delete(row) - } + let messagePredicate = #Predicate { message in + message.id == scopedMessageID + } + var messageDescriptor = FetchDescriptor(predicate: messagePredicate) + messageDescriptor.fetchLimit = 1 + if let message = try modelContext.fetch(messageDescriptor).first, + message.status != .delivered { + message.status = .pending + } - let messagePredicate = #Predicate { message in - message.id == scopedMessageID - } - var messageDescriptor = FetchDescriptor(predicate: messagePredicate) - messageDescriptor.fetchLimit = 1 - if let message = try modelContext.fetch(messageDescriptor).first, - message.status != .delivered { - message.status = .pending - } + let scopedRadioID = dto.radioID + let radioPredicate = #Predicate { row in + row.radioID == scopedRadioID + } + var seqDescriptor = FetchDescriptor( + predicate: radioPredicate, + sortBy: [SortDescriptor(\.sequence, order: .reverse)] + ) + seqDescriptor.fetchLimit = 1 + let latest = try modelContext.fetch(seqDescriptor).first + let assignedSequence = (latest?.sequence ?? 0) + 1 - let scopedRadioID = dto.radioID - let radioPredicate = #Predicate { row in - row.radioID == scopedRadioID - } - var seqDescriptor = FetchDescriptor( - predicate: radioPredicate, - sortBy: [SortDescriptor(\.sequence, order: .reverse)] - ) - seqDescriptor.fetchLimit = 1 - let latest = try modelContext.fetch(seqDescriptor).first - let assignedSequence = (latest?.sequence ?? 0) + 1 + let inserted = PendingSend(dto: dto) + inserted.sequence = assignedSequence + modelContext.insert(inserted) - let inserted = PendingSend(dto: dto) - inserted.sequence = assignedSequence - modelContext.insert(inserted) + try modelContext.save() + return assignedSequence + } - try modelContext.save() - return assignedSequence + /// Fetch all pending sends for a given radio, ordered by sequence + /// ascending. Rows whose `kindRawValue` does not resolve to a known + /// `PendingSendKind` case are skipped and logged at warning level — + /// such a row could only exist after a downgrade from a future build + /// that introduced a new case, and silently routing it to `.dm` would + /// strand the row on disk via the envelope-materialization nil guard. + func fetchPendingSends(radioID: UUID) throws -> [PendingSendDTO] { + let scopedRadioID = radioID + let predicate = #Predicate { row in + row.radioID == scopedRadioID } - - /// Fetch all pending sends for a given radio, ordered by sequence - /// ascending. Rows whose `kindRawValue` does not resolve to a known - /// `PendingSendKind` case are skipped and logged at warning level — - /// such a row could only exist after a downgrade from a future build - /// that introduced a new case, and silently routing it to `.dm` would - /// strand the row on disk via the envelope-materialization nil guard. - public func fetchPendingSends(radioID: UUID) throws -> [PendingSendDTO] { - let scopedRadioID = radioID - let predicate = #Predicate { row in - row.radioID == scopedRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.sequence, order: .forward)] + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.sequence, order: .forward)] + ) + return try modelContext.fetch(descriptor).compactMap { row in + guard PendingSendKind(rawValue: row.kindRawValue) != nil else { + Self.pendingSendLogger.warning( + "Skipping PendingSend \(row.id, privacy: .public) with unknown kindRawValue=\(row.kindRawValue, privacy: .public)" ) - return try modelContext.fetch(descriptor).compactMap { row in - guard PendingSendKind(rawValue: row.kindRawValue) != nil else { - Self.pendingSendLogger.warning( - "Skipping PendingSend \(row.id, privacy: .public) with unknown kindRawValue=\(row.kindRawValue, privacy: .public)" - ) - return nil - } - return row.toDTO() - } + return nil + } + return row.toDTO() } + } - /// Delete a pending send by row id. No-op if the id is not present. - public func deletePendingSend(id: UUID) throws { - let target = id - let predicate = #Predicate { row in - row.id == target - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - if let row = try modelContext.fetch(descriptor).first { - modelContext.delete(row) - try modelContext.save() - } + /// Delete a pending send by row id. No-op if the id is not present. + func deletePendingSend(id: UUID) throws { + let target = id + let predicate = #Predicate { row in + row.id == target } - - /// Delete every `PendingSend` row whose `radioID` does not match any - /// known `Device` row. Returns the number of rows deleted. An orphan row - /// can never drain (`fetchPendingSends(radioID:)` filters by radio and - /// the radio is gone), so leaving it on disk only wastes space. - /// - /// **Precondition (callers must satisfy):** every paired Device row, - /// including the one currently being constructed, must already be - /// persisted before this runs. The production wiring at - /// `ConnectionManager.buildServicesAndSaveDevice` saves the Device row - /// before invoking warmUp, so this precondition holds for the in-progress - /// radio as well as every previously-paired one. The doc-comment exists - /// to head off future refactors that might invoke warmUp ahead of the - /// Device save — in that order, an in-flight new-pair Device whose row - /// hasn't been flushed yet would have its enqueued PendingSends wrongly - /// classified as orphans. - @discardableResult - public func purgeOrphanPendingSends() throws -> Int { - let knownRadioIDs = try Set(modelContext.fetch(FetchDescriptor()).map(\.radioID)) - let allRows = try modelContext.fetch(FetchDescriptor()) - let orphans = allRows.filter { !knownRadioIDs.contains($0.radioID) } - guard !orphans.isEmpty else { return 0 } - for row in orphans { - modelContext.delete(row) - } - try modelContext.save() - Self.pendingSendLogger.info("Purged \(orphans.count, privacy: .public) orphan PendingSend rows") - return orphans.count + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let row = try modelContext.fetch(descriptor).first { + modelContext.delete(row) + try modelContext.save() } + } - /// Delete every pending send row whose `messageID` matches. No-op if no - /// rows match. Used at drain time when only the envelope's `messageID` - /// is available; the radio is intentionally not part of the predicate - /// because the user can switch conversations (and therefore the radio - /// in scope) between enqueue and drain, and the same `messageID` may - /// be enqueued more than once on the resend path. `messageID` is a - /// UUID so cross-radio collision is effectively zero. - public func deletePendingSendsForMessage(messageID: UUID) throws { - let scopedMessageID = messageID - let predicate = #Predicate { row in - row.messageID == scopedMessageID - } - let descriptor = FetchDescriptor(predicate: predicate) - let rows = try modelContext.fetch(descriptor) - guard !rows.isEmpty else { return } - for row in rows { - modelContext.delete(row) - } - try modelContext.save() + /// Delete every `PendingSend` row whose `radioID` does not match any + /// known `Device` row. Returns the number of rows deleted. An orphan row + /// can never drain (`fetchPendingSends(radioID:)` filters by radio and + /// the radio is gone), so leaving it on disk only wastes space. + /// + /// **Precondition (callers must satisfy):** every paired Device row, + /// including the one currently being constructed, must already be + /// persisted before this runs. The production wiring at + /// `ConnectionManager.buildServicesAndSaveDevice` saves the Device row + /// before invoking warmUp, so this precondition holds for the in-progress + /// radio as well as every previously-paired one. The doc-comment exists + /// to head off future refactors that might invoke warmUp ahead of the + /// Device save — in that order, an in-flight new-pair Device whose row + /// hasn't been flushed yet would have its enqueued PendingSends wrongly + /// classified as orphans. + @discardableResult + func purgeOrphanPendingSends() throws -> Int { + let knownRadioIDs = try Set(modelContext.fetch(FetchDescriptor()).map(\.radioID)) + let allRows = try modelContext.fetch(FetchDescriptor()) + let orphans = allRows.filter { !knownRadioIDs.contains($0.radioID) } + guard !orphans.isEmpty else { return 0 } + for row in orphans { + modelContext.delete(row) } + try modelContext.save() + Self.pendingSendLogger.info("Purged \(orphans.count, privacy: .public) orphan PendingSend rows") + return orphans.count + } - /// Bulk non-saving cascade helper. Caller owns the single transactional - /// save. Issues one `delete(model:where:)` per chunk so wiping thousands - /// of messages costs O(chunks) bulk ops rather than O(n) fetches. - /// - /// **Cascade atomicity:** the cascade is serial within `@ModelActor` - /// (`PersistenceStore`'s actor isolation serialises awaits), not - /// transactional in the SQL sense. A crash mid-cascade leaves a - /// partially-deleted state on disk; the next `purgeOrphanPendingSends` - /// + hydrate cycle reconciles. Callers requiring strict atomicity stage - /// every delete in this function and call `save()` exactly once at the - /// end (the convention every cascade site in this codebase follows). - /// - /// **Chunking:** SwiftData translates `contains` predicates to SQL - /// `IN (?, ?, …)`. SQLite's default `SQLITE_MAX_VARIABLE_NUMBER` on - /// iOS 18+ is 32766, so wiping a radio with >32k messages would hit - /// the ceiling unchunked. 500 keeps headroom and keeps the predicate - /// compact for SwiftData's compile-time validation. - /// - /// **`@Relationship` cascade bypass:** bulk `delete(model:where:)` - /// skips the SwiftData change-tracking pipeline, so - /// `@Relationship(deleteRule: .cascade)` declarations do not fire on - /// these paths. Any future cascade-by-relationship added to `Message` - /// must be mirrored explicitly in every cascade site that bulk-deletes - /// messages. - internal func _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: [UUID]) throws { - guard !messageIDs.isEmpty else { return } - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. { row in + row.messageID == scopedMessageID + } + let descriptor = FetchDescriptor(predicate: predicate) + let rows = try modelContext.fetch(descriptor) + guard !rows.isEmpty else { return } + for row in rows { + modelContext.delete(row) } + try modelContext.save() + } - public func hasPendingSend(messageID: UUID) throws -> Bool { - let scopedMessageID = messageID - let predicate = #Predicate { row in - row.messageID == scopedMessageID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try !modelContext.fetch(descriptor).isEmpty + /// Bulk non-saving cascade helper. Caller owns the single transactional + /// save. Issues one `delete(model:where:)` per chunk so wiping thousands + /// of messages costs O(chunks) bulk ops rather than O(n) fetches. + /// + /// **Cascade atomicity:** the cascade is serial within `@ModelActor` + /// (`PersistenceStore`'s actor isolation serialises awaits), not + /// transactional in the SQL sense. A crash mid-cascade leaves a + /// partially-deleted state on disk; the next `purgeOrphanPendingSends` + /// + hydrate cycle reconciles. Callers requiring strict atomicity stage + /// every delete in this function and call `save()` exactly once at the + /// end (the convention every cascade site in this codebase follows). + /// + /// **Chunking:** SwiftData translates `contains` predicates to SQL + /// `IN (?, ?, …)`. SQLite's default `SQLITE_MAX_VARIABLE_NUMBER` on + /// iOS 18+ is 32766, so wiping a radio with >32k messages would hit + /// the ceiling unchunked. 500 keeps headroom and keeps the predicate + /// compact for SwiftData's compile-time validation. + /// + /// **`@Relationship` cascade bypass:** bulk `delete(model:where:)` + /// skips the SwiftData change-tracking pipeline, so + /// `@Relationship(deleteRule: .cascade)` declarations do not fire on + /// these paths. Any future cascade-by-relationship added to `Message` + /// must be mirrored explicitly in every cascade site that bulk-deletes + /// messages. + internal func _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: [UUID]) throws { + guard !messageIDs.isEmpty else { return } + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. [PendingSendDTO] { - let scopedMessageID = messageID - let predicate = #Predicate { row in - row.messageID == scopedMessageID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.sequence, order: .forward)] - ) - return try modelContext.fetch(descriptor).compactMap { row in - guard PendingSendKind(rawValue: row.kindRawValue) != nil else { return nil } - return row.toDTO() - } + func hasPendingSend(messageID: UUID) throws -> Bool { + let scopedMessageID = messageID + let predicate = #Predicate { row in + row.messageID == scopedMessageID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try !modelContext.fetch(descriptor).isEmpty + } - /// Deletes any `PendingSend` row where `attemptCount` is `nil`. Such rows - /// can only exist via lightweight migration from a schema version that - /// predates `attemptCount`; their drain history is ambiguous, so the user - /// re-sends if they care. - /// - /// Idempotent: an empty fetch after the first purge across the lifetime of - /// the storage. - /// - /// Called from `PersistenceStore.warmUp()` on every connect. - @discardableResult - public func purgeLegacyAttemptCountRows() throws -> Int { - let nilPredicate = #Predicate { row in - row.attemptCount == nil - } - let rows = try modelContext.fetch(FetchDescriptor(predicate: nilPredicate)) - guard !rows.isEmpty else { return 0 } - for row in rows { modelContext.delete(row) } - try modelContext.save() - Self.pendingSendLogger.notice( - "purgeLegacyAttemptCountRows deleted \(rows.count, privacy: .public) row(s) with attemptCount=nil" - ) - return rows.count + /// Fetch every `PendingSend` row that matches a given message ID, ordered + /// by sequence ascending. Returns DTOs for cross-actor safety. Rows whose + /// `kindRawValue` does not resolve to a known `PendingSendKind` case are + /// skipped, matching the contract of `fetchPendingSends(radioID:)`. + /// + /// Symmetric with `deletePendingSendsForMessage(messageID:)` — both ignore + /// the radio because the user can switch radios between enqueue and + /// observation, and a UUID `messageID` makes cross-radio collision + /// effectively zero. + func fetchPendingSendsForMessage(messageID: UUID) throws -> [PendingSendDTO] { + let scopedMessageID = messageID + let predicate = #Predicate { row in + row.messageID == scopedMessageID + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.sequence, order: .forward)] + ) + return try modelContext.fetch(descriptor).compactMap { row in + guard PendingSendKind(rawValue: row.kindRawValue) != nil else { return nil } + return row.toDTO() } + } - /// Increments the `attemptCount` for the `PendingSend` row matching - /// `messageID`. Returns the new count, or `nil` if no row matched. - /// Throws on SwiftData read/write failure — callers must treat a thrown - /// error as transient (park + retry) and `nil` as terminal (deleted row). - /// - /// `warmUp`'s purge runs before hydrate, so a `nil` row reaching increment - /// time would indicate the purge step was skipped (programmer error). The - /// `?? 0` fallback degrades gracefully without pretending the row had - /// prior drain attempts. - @discardableResult - public func incrementPendingSendAttemptCount(messageID: UUID) throws -> Int? { - #if DEBUG - try incrementPendingSendAttemptCountFaultInjection?() - #endif - let scopedMessageID = messageID - let predicate = #Predicate { row in - row.messageID == scopedMessageID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let row = try modelContext.fetch(descriptor).first else { return nil } - let nextCount = (row.attemptCount ?? 0) + 1 - row.attemptCount = nextCount - try modelContext.save() - return nextCount + /// Deletes any `PendingSend` row where `attemptCount` is `nil`. Such rows + /// can only exist via lightweight migration from a schema version that + /// predates `attemptCount`; their drain history is ambiguous, so the user + /// re-sends if they care. + /// + /// Idempotent: an empty fetch after the first purge across the lifetime of + /// the storage. + /// + /// Called from `PersistenceStore.warmUp()` on every connect. + @discardableResult + func purgeLegacyAttemptCountRows() throws -> Int { + let nilPredicate = #Predicate { row in + row.attemptCount == nil } + let rows = try modelContext.fetch(FetchDescriptor(predicate: nilPredicate)) + guard !rows.isEmpty else { return 0 } + for row in rows { + modelContext.delete(row) + } + try modelContext.save() + Self.pendingSendLogger.notice( + "purgeLegacyAttemptCountRows deleted \(rows.count, privacy: .public) row(s) with attemptCount=nil" + ) + return rows.count + } + /// Increments the `attemptCount` for the `PendingSend` row matching + /// `messageID`. Returns the new count, or `nil` if no row matched. + /// Throws on SwiftData read/write failure — callers must treat a thrown + /// error as transient (park + retry) and `nil` as terminal (deleted row). + /// + /// `warmUp`'s purge runs before hydrate, so a `nil` row reaching increment + /// time would indicate the purge step was skipped (programmer error). The + /// `?? 0` fallback degrades gracefully without pretending the row had + /// prior drain attempts. + @discardableResult + func incrementPendingSendAttemptCount(messageID: UUID) throws -> Int? { #if DEBUG - public func setIncrementPendingSendAttemptCountFaultInjection(_ hook: (@Sendable () throws -> Void)?) { - incrementPendingSendAttemptCountFaultInjection = hook - } + try incrementPendingSendAttemptCountFaultInjection?() #endif + let scopedMessageID = messageID + let predicate = #Predicate { row in + row.messageID == scopedMessageID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let row = try modelContext.fetch(descriptor).first else { return nil } + let nextCount = (row.attemptCount ?? 0) + 1 + row.attemptCount = nextCount + try modelContext.save() + return nextCount + } + + #if DEBUG + func setIncrementPendingSendAttemptCountFaultInjection(_ hook: (@Sendable () throws -> Void)?) { + incrementPendingSendAttemptCountFaultInjection = hook + } + #endif } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift index ff654d79..58ca7c80 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift @@ -2,422 +2,421 @@ import Foundation import os import SwiftData -extension PersistenceStore { - - // MARK: - RemoteNodeSession Operations - - /// Fetch remote node session by UUID - public func fetchRemoteNodeSession(id: UUID) throws -> RemoteNodeSessionDTO? { - let targetID = id - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { RemoteNodeSessionDTO(from: $0) } +public extension PersistenceStore { + // MARK: - RemoteNodeSession Operations + + /// Fetch remote node session by UUID + func fetchRemoteNodeSession(id: UUID) throws -> RemoteNodeSessionDTO? { + let targetID = id + let predicate = #Predicate { session in + session.id == targetID } - - /// Fetch remote node session by 32-byte public key - public func fetchRemoteNodeSession(publicKey: Data) throws -> RemoteNodeSessionDTO? { - let targetKey = publicKey - let predicate = #Predicate { session in - session.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { RemoteNodeSessionDTO(from: $0) } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Fetch remote node session by 32-byte public key + func fetchRemoteNodeSession(publicKey: Data) throws -> RemoteNodeSessionDTO? { + let targetKey = publicKey + let predicate = #Predicate { session in + session.publicKey == targetKey } - - /// Fetch remote node session by 6-byte public key prefix - public func fetchRemoteNodeSessionByPrefix(_ prefix: Data) throws -> RemoteNodeSessionDTO? { - // SwiftData predicates don't support prefix matching directly - // Fetch all sessions and filter in memory - let sessions = try modelContext.fetch(FetchDescriptor()) - return sessions.first { $0.publicKey.prefix(6) == prefix }.map { RemoteNodeSessionDTO(from: $0) } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Fetch remote node session by 6-byte public key prefix + func fetchRemoteNodeSessionByPrefix(_ prefix: Data) throws -> RemoteNodeSessionDTO? { + // SwiftData predicates don't support prefix matching directly + // Fetch all sessions and filter in memory + let sessions = try modelContext.fetch(FetchDescriptor()) + return sessions.first { $0.publicKey.prefix(6) == prefix }.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Fetch all connected sessions for re-authentication after BLE reconnection + func fetchConnectedRemoteNodeSessions() throws -> [RemoteNodeSessionDTO] { + let predicate = #Predicate { session in + session.isConnected == true } - - /// Fetch all connected sessions for re-authentication after BLE reconnection - public func fetchConnectedRemoteNodeSessions() throws -> [RemoteNodeSessionDTO] { - let predicate = #Predicate { session in - session.isConnected == true - } - let descriptor = FetchDescriptor(predicate: predicate) - let sessions = try modelContext.fetch(descriptor) - return sessions.map { RemoteNodeSessionDTO(from: $0) } + let descriptor = FetchDescriptor(predicate: predicate) + let sessions = try modelContext.fetch(descriptor) + return sessions.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Fetch all remote node sessions for a device + func fetchRemoteNodeSessions(radioID: UUID) throws -> [RemoteNodeSessionDTO] { + let targetRadioID = radioID + let predicate = #Predicate { session in + session.radioID == targetRadioID } - - /// Fetch all remote node sessions for a device - public func fetchRemoteNodeSessions(radioID: UUID) throws -> [RemoteNodeSessionDTO] { - let targetRadioID = radioID - let predicate = #Predicate { session in - session.radioID == targetRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\RemoteNodeSession.name)] - ) - let sessions = try modelContext.fetch(descriptor) - return sessions.map { RemoteNodeSessionDTO(from: $0) } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\RemoteNodeSession.name)] + ) + let sessions = try modelContext.fetch(descriptor) + return sessions.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Save or update a remote node session (void version for cross-actor calls) + func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) throws { + _ = try saveRemoteNodeSession(dto) + } + + /// Save or update a remote node session + @discardableResult + private func saveRemoteNodeSession(_ dto: RemoteNodeSessionDTO) throws -> RemoteNodeSession { + let targetID = dto.id + let predicate = #Predicate { session in + session.id == targetID } - - /// Save or update a remote node session (void version for cross-actor calls) - public func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) throws { - _ = try saveRemoteNodeSession(dto) + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let existing = try modelContext.fetch(descriptor).first { + existing.apply(dto) + try modelContext.save() + return existing + } else { + let session = RemoteNodeSession(dto: dto) + modelContext.insert(session) + try modelContext.save() + return session } - - /// Save or update a remote node session - @discardableResult - private func saveRemoteNodeSession(_ dto: RemoteNodeSessionDTO) throws -> RemoteNodeSession { - let targetID = dto.id - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.apply(dto) - try modelContext.save() - return existing - } else { - let session = RemoteNodeSession(dto: dto) - modelContext.insert(session) - try modelContext.save() - return session - } + } + + /// Update session connection state + func updateRemoteNodeSessionConnection( + id: UUID, + isConnected: Bool, + permissionLevel: RoomPermissionLevel + ) throws { + let targetID = id + let predicate = #Predicate { session in + session.id == targetID } - - /// Update session connection state - public func updateRemoteNodeSessionConnection( - id: UUID, - isConnected: Bool, - permissionLevel: RoomPermissionLevel - ) throws { - let targetID = id - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let session = try modelContext.fetch(descriptor).first { - session.isConnected = isConnected - session.permissionLevelRawValue = permissionLevel.rawValue - if isConnected { - session.lastConnectedDate = Date() - } - try modelContext.save() - } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let session = try modelContext.fetch(descriptor).first { + session.isConnected = isConnected + session.permissionLevelRawValue = permissionLevel.rawValue + if isConnected { + session.lastConnectedDate = Date() + } + try modelContext.save() } - - /// Reset all remote node sessions to disconnected state. - /// Call this on app launch since connections don't persist across restarts. - public func resetAllRemoteNodeSessionConnections() throws { - let descriptor = FetchDescriptor() - let sessions = try modelContext.fetch(descriptor) - for session in sessions { - session.isConnected = false - } - try modelContext.save() + } + + /// Reset all remote node sessions to disconnected state. + /// Call this on app launch since connections don't persist across restarts. + func resetAllRemoteNodeSessionConnections() throws { + let descriptor = FetchDescriptor() + let sessions = try modelContext.fetch(descriptor) + for session in sessions { + session.isConnected = false } - - /// Clean up duplicate remote node sessions with the same public key. - /// Keeps the session with the specified ID and deletes any others. - /// This prevents stale sessions from causing connection state issues. - public func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) throws { - let targetKey = publicKey - let targetKeepID = keepID - let predicate = #Predicate { session in - session.publicKey == targetKey && session.id != targetKeepID - } - let duplicates = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - - if !duplicates.isEmpty { - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") - logger.warning("Found \(duplicates.count) duplicate session(s) for public key, cleaning up") - - for duplicate in duplicates { - // Delete associated room messages first - let duplicateID = duplicate.id - let messagePredicate = #Predicate { message in - message.sessionID == duplicateID - } - let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) - for message in messages { - modelContext.delete(message) - } - - modelContext.delete(duplicate) - } - try modelContext.save() - } + try modelContext.save() + } + + /// Clean up duplicate remote node sessions with the same public key. + /// Keeps the session with the specified ID and deletes any others. + /// This prevents stale sessions from causing connection state issues. + func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) throws { + let targetKey = publicKey + let targetKeepID = keepID + let predicate = #Predicate { session in + session.publicKey == targetKey && session.id != targetKeepID } + let duplicates = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - /// Delete remote node session and all associated room messages - public func deleteRemoteNodeSession(id: UUID) throws { - let targetID = id + if !duplicates.isEmpty { + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") + logger.warning("Found \(duplicates.count) duplicate session(s) for public key, cleaning up") - // Delete associated room messages + for duplicate in duplicates { + // Delete associated room messages first + let duplicateID = duplicate.id let messagePredicate = #Predicate { message in - message.sessionID == targetID + message.sessionID == duplicateID } let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) for message in messages { - modelContext.delete(message) + modelContext.delete(message) } - // Delete the session - let sessionPredicate = #Predicate { session in - session.id == targetID - } - if let session = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)).first { - modelContext.delete(session) - } - - try modelContext.save() + modelContext.delete(duplicate) + } + try modelContext.save() } + } - /// Mark a room session as connected. Called when an incoming message proves - /// the session is active. Only sets isConnected; does not change permissionLevel. - /// - Returns: true if the session was actually changed (was disconnected, now connected). - @discardableResult - public func markRoomSessionConnected(_ sessionID: UUID) throws -> Bool { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let session = try modelContext.fetch(descriptor).first else { return false } - guard !session.isConnected else { return false } + /// Delete remote node session and all associated room messages + func deleteRemoteNodeSession(id: UUID) throws { + let targetID = id - session.isConnected = true - try modelContext.save() - return true + // Delete associated room messages + let messagePredicate = #Predicate { message in + message.sessionID == targetID + } + let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) + for message in messages { + modelContext.delete(message) } - /// Mark a session as disconnected without changing permission level. - /// Use for transient disconnections (BLE drop, keep-alive failure, re-auth failure). - /// Only logout() should reset permissionLevel to .guest. - public func markSessionDisconnected(_ sessionID: UUID) throws { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + // Delete the session + let sessionPredicate = #Predicate { session in + session.id == targetID + } + if let session = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)).first { + modelContext.delete(session) + } - if let session = try modelContext.fetch(descriptor).first { - session.isConnected = false - try modelContext.save() - } + try modelContext.save() + } + + /// Mark a room session as connected. Called when an incoming message proves + /// the session is active. Only sets isConnected; does not change permissionLevel. + /// - Returns: true if the session was actually changed (was disconnected, now connected). + @discardableResult + func markRoomSessionConnected(_ sessionID: UUID) throws -> Bool { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let session = try modelContext.fetch(descriptor).first else { return false } + guard !session.isConnected else { return false } + + session.isConnected = true + try modelContext.save() + return true + } + + /// Mark a session as disconnected without changing permission level. + /// Use for transient disconnections (BLE drop, keep-alive failure, re-auth failure). + /// Only logout() should reset permissionLevel to .guest. + func markSessionDisconnected(_ sessionID: UUID) throws { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Update room activity timestamps. - /// - Parameters: - /// - sessionID: The room session ID - /// - syncTimestamp: Optional sender-clock timestamp for sync bookmark advancement. - /// Only provided on the receive path. Omit on the send path to avoid clock skew - /// issues where local send timestamps could advance the sync bookmark past - /// messages the server hasn't delivered yet. - public func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32? = nil) throws { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let session = try modelContext.fetch(descriptor).first { - if let syncTimestamp, syncTimestamp > session.lastSyncTimestamp { - session.lastSyncTimestamp = syncTimestamp - } - session.lastMessageDate = Date() - try modelContext.save() - } + if let session = try modelContext.fetch(descriptor).first { + session.isConnected = false + try modelContext.save() } + } + + /// Update room activity timestamps. + /// - Parameters: + /// - sessionID: The room session ID + /// - syncTimestamp: Optional sender-clock timestamp for sync bookmark advancement. + /// Only provided on the receive path. Omit on the send path to avoid clock skew + /// issues where local send timestamps could advance the sync bookmark past + /// messages the server hasn't delivered yet. + func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32? = nil) throws { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let session = try modelContext.fetch(descriptor).first { + if let syncTimestamp, syncTimestamp > session.lastSyncTimestamp { + session.lastSyncTimestamp = syncTimestamp + } + session.lastMessageDate = Date() + try modelContext.save() + } + } - // MARK: - RoomMessage Operations + // MARK: - RoomMessage Operations - /// Check for duplicate room message using deduplication key - public func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) throws -> Bool { - let targetSessionID = sessionID - let targetKey = deduplicationKey - let predicate = #Predicate { message in - message.sessionID == targetSessionID && message.deduplicationKey == targetKey - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + /// Check for duplicate room message using deduplication key + func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) throws -> Bool { + let targetSessionID = sessionID + let targetKey = deduplicationKey + let predicate = #Predicate { message in + message.sessionID == targetSessionID && message.deduplicationKey == targetKey + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Save room message (checks deduplication automatically) + func saveRoomMessage(_ dto: RoomMessageDTO) throws { + // Check for duplicate first + if try isDuplicateRoomMessage(sessionID: dto.sessionID, deduplicationKey: dto.deduplicationKey) { + return // Silently ignore duplicates } - /// Save room message (checks deduplication automatically) - public func saveRoomMessage(_ dto: RoomMessageDTO) throws { - // Check for duplicate first - if try isDuplicateRoomMessage(sessionID: dto.sessionID, deduplicationKey: dto.deduplicationKey) { - return // Silently ignore duplicates - } + modelContext.insert(RoomMessage(dto: dto)) + try modelContext.save() + } - modelContext.insert(RoomMessage(dto: dto)) - try modelContext.save() + /// Fetch a room message by ID + func fetchRoomMessage(id: UUID) throws -> RoomMessageDTO? { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { + return nil + } + return RoomMessageDTO(from: message) + } + + /// Update room message status after send attempt + func updateRoomMessageStatus( + id: UUID, + status: MessageStatus, + ackCode: UInt32? = nil, + roundTripTime: UInt32? = nil + ) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { + return + } + message.statusRawValue = status.rawValue + if let ackCode { + message.ackCode = ackCode } + if let roundTripTime { + message.roundTripTime = roundTripTime + } + try modelContext.save() + } + + /// Update room message retry status + func updateRoomMessageRetryStatus( + id: UUID, + status: MessageStatus, + retryAttempt: Int, + maxRetryAttempts: Int + ) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { + return + } + message.statusRawValue = status.rawValue + message.retryAttempt = retryAttempt + message.maxRetryAttempts = maxRetryAttempts + try modelContext.save() + } + + /// Increment unread message count for a room session + func incrementRoomUnreadCount(_ sessionID: UUID) throws { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Fetch a room message by ID - public func fetchRoomMessage(id: UUID) throws -> RoomMessageDTO? { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let message = try modelContext.fetch(descriptor).first else { - return nil - } - return RoomMessageDTO(from: message) - } - - /// Update room message status after send attempt - public func updateRoomMessageStatus( - id: UUID, - status: MessageStatus, - ackCode: UInt32? = nil, - roundTripTime: UInt32? = nil - ) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let message = try modelContext.fetch(descriptor).first else { - return - } - message.statusRawValue = status.rawValue - if let ackCode { - message.ackCode = ackCode - } - if let roundTripTime { - message.roundTripTime = roundTripTime - } - try modelContext.save() - } - - /// Update room message retry status - public func updateRoomMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let message = try modelContext.fetch(descriptor).first else { - return - } - message.statusRawValue = status.rawValue - message.retryAttempt = retryAttempt - message.maxRetryAttempts = maxRetryAttempts - try modelContext.save() + if let session = try modelContext.fetch(descriptor).first { + session.unreadCount += 1 + try modelContext.save() } + } - /// Increment unread message count for a room session - public func incrementRoomUnreadCount(_ sessionID: UUID) throws { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + /// Reset unread count to zero (called when user views conversation) + func resetRoomUnreadCount(_ sessionID: UUID) throws { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - if let session = try modelContext.fetch(descriptor).first { - session.unreadCount += 1 - try modelContext.save() - } + if let session = try modelContext.fetch(descriptor).first { + session.unreadCount = 0 + try modelContext.save() } + } - /// Reset unread count to zero (called when user views conversation) - public func resetRoomUnreadCount(_ sessionID: UUID) throws { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + /// Sets the muted state for a remote node session + func setSessionMuted(_ sessionID: UUID, isMuted: Bool) throws { + let targetID = sessionID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - if let session = try modelContext.fetch(descriptor).first { - session.unreadCount = 0 - try modelContext.save() - } + guard let session = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.remoteNodeSessionNotFound } - /// Sets the muted state for a remote node session - public func setSessionMuted(_ sessionID: UUID, isMuted: Bool) throws { - let targetID = sessionID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + session.notificationLevel = isMuted ? .muted : .all + try modelContext.save() + } - guard let session = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.remoteNodeSessionNotFound - } + /// Sets the notification level for a remote node session + func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) throws { + let targetID = sessionID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - session.notificationLevel = isMuted ? .muted : .all - try modelContext.save() + guard let session = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.remoteNodeSessionNotFound } - /// Sets the notification level for a remote node session - public func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) throws { - let targetID = sessionID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + session.notificationLevel = level + try modelContext.save() + } - guard let session = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.remoteNodeSessionNotFound - } + /// Sets the favorite state for a remote node session + func setSessionFavorite(_ sessionID: UUID, isFavorite: Bool) throws { + let targetID = sessionID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - session.notificationLevel = level - try modelContext.save() + guard let session = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.remoteNodeSessionNotFound } - /// Sets the favorite state for a remote node session - public func setSessionFavorite(_ sessionID: UUID, isFavorite: Bool) throws { - let targetID = sessionID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let session = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.remoteNodeSessionNotFound - } + session.isFavorite = isFavorite + try modelContext.save() + } - session.isFavorite = isFavorite - try modelContext.save() + /// Fetch room messages for a session, ordered by timestamp + func fetchRoomMessages(sessionID: UUID, limit: Int? = nil, offset: Int? = nil) throws -> [RoomMessageDTO] { + let targetSessionID = sessionID + let predicate = #Predicate { message in + message.sessionID == targetSessionID } - - /// Fetch room messages for a session, ordered by timestamp - public func fetchRoomMessages(sessionID: UUID, limit: Int? = nil, offset: Int? = nil) throws -> [RoomMessageDTO] { - let targetSessionID = sessionID - let predicate = #Predicate { message in - message.sessionID == targetSessionID - } - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\RoomMessage.timestamp, order: .forward), - SortDescriptor(\RoomMessage.createdAt, order: .forward) - ] - ) - if let limit { - descriptor.fetchLimit = limit - } - if let offset { - descriptor.fetchOffset = offset - } - let messages = try modelContext.fetch(descriptor) - return messages.map { RoomMessageDTO(from: $0) } + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\RoomMessage.timestamp, order: .forward), + SortDescriptor(\RoomMessage.createdAt, order: .forward) + ] + ) + if let limit { + descriptor.fetchLimit = limit + } + if let offset { + descriptor.fetchOffset = offset } + let messages = try modelContext.fetch(descriptor) + return messages.map { RoomMessageDTO(from: $0) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift index 197d1750..8ac1fb1f 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift @@ -5,29 +5,29 @@ import SwiftData // MARK: - PersistenceStore Errors public enum PersistenceStoreError: Error, Sendable { - case deviceNotFound - case contactNotFound - case messageNotFound - case channelNotFound - case remoteNodeSessionNotFound - case saveFailed(String) - case fetchFailed(String) - case invalidData + case deviceNotFound + case contactNotFound + case messageNotFound + case channelNotFound + case remoteNodeSessionNotFound + case saveFailed(String) + case fetchFailed(String) + case invalidData } extension PersistenceStoreError: LocalizedError { - public var errorDescription: String? { - switch self { - case .deviceNotFound: "Device not found." - case .contactNotFound: "Contact not found." - case .messageNotFound: "Message not found." - case .channelNotFound: "Channel not found." - case .remoteNodeSessionNotFound: "Remote node session not found." - case .saveFailed(let msg): "Failed to save: \(msg)" - case .fetchFailed(let msg): "Failed to fetch: \(msg)" - case .invalidData: "Invalid data." - } + public var errorDescription: String? { + switch self { + case .deviceNotFound: "Device not found." + case .contactNotFound: "Contact not found." + case .messageNotFound: "Message not found." + case .channelNotFound: "Channel not found." + case .remoteNodeSessionNotFound: "Remote node session not found." + case let .saveFailed(msg): "Failed to save: \(msg)" + case let .fetchFailed(msg): "Failed to fetch: \(msg)" + case .invalidData: "Invalid data." } + } } // MARK: - PersistenceStore Actor @@ -36,9 +36,9 @@ extension PersistenceStoreError: LocalizedError { /// Provides per-device data isolation and thread-safe access. @ModelActor public actor PersistenceStore: PersistenceStoreProtocol { - var rxLogEntryCountsByDevice: [UUID: Int] = [:] + var rxLogEntryCountsByDevice: [UUID: Int] = [:] - #if DEBUG + #if DEBUG /// Test-only fault-injection hook fired immediately before `modelContext.save()` /// in `importBackupDatabase`. Debug-only so the production API stays clean. /// SwiftData upserts on unique-constraint conflicts, so there is no reliable @@ -56,274 +56,274 @@ public actor PersistenceStore: PersistenceStoreProtocol { /// closures — a real SwiftData save failure here is otherwise hard to /// provoke from a well-formed row. var incrementPendingSendAttemptCountFaultInjection: (@Sendable () throws -> Void)? - #endif - - /// Shared schema for MeshCore One models - public static let schema = Schema([ - Device.self, - Contact.self, - Message.self, - MessageRepeat.self, - Reaction.self, - Channel.self, - RemoteNodeSession.self, - RoomMessage.self, - SavedTracePath.self, - TracePathRun.self, - RxLogEntry.self, - DebugLogEntry.self, - LinkPreviewData.self, - DiscoveredNode.self, - NodeStatusSnapshot.self, - BlockedChannelSender.self, - PendingSend.self - ]) - - /// Creates a ModelContainer for the app. - /// - /// Schema evolution (no VersionedSchema — handled via lightweight migration): - /// - v1→v2: Contact.outPathLength, DiscoveredNode.outPathLength changed Int8→UInt8 - /// (SQLite INTEGER is identical for both; bit pattern -1 == 0xFF). - /// Added MessageRepeat.pathLength (UInt8, default 0). - /// Added SavedTracePath.hashSize (Int, default 1). - /// - v2→v3: Added PendingSend (new table; no migration impact on existing rows). - /// - v3→v4: Added PendingSend.attemptCount (Int?, default nil). Existing rows - /// lightweight-migrate to NULL; PersistenceStore.warmUp() runs - /// purgeLegacyAttemptCountRows() on connect to delete any nil row. - /// - v4→v5: Added TracePathRun.id uniqueness (run ids are minted once and - /// immutable, and backup import dedupes them store-wide, so existing - /// stores hold no duplicates) and RxLogEntry [radioID, receivedAt] - /// index. - public static func createContainer(inMemory: Bool = false) throws -> ModelContainer { - if !inMemory { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) - } - - let configuration = ModelConfiguration( - schema: schema, - isStoredInMemoryOnly: inMemory, - allowsSave: true - ) - return try ModelContainer(for: schema, configurations: [configuration]) + #endif + + /// Shared schema for MeshCore One models + public static let schema = Schema([ + Device.self, + Contact.self, + Message.self, + MessageRepeat.self, + Reaction.self, + Channel.self, + RemoteNodeSession.self, + RoomMessage.self, + SavedTracePath.self, + TracePathRun.self, + RxLogEntry.self, + DebugLogEntry.self, + LinkPreviewData.self, + DiscoveredNode.self, + NodeStatusSnapshot.self, + BlockedChannelSender.self, + PendingSend.self + ]) + + /// Creates a ModelContainer for the app. + /// + /// Schema evolution (no VersionedSchema — handled via lightweight migration): + /// - v1→v2: Contact.outPathLength, DiscoveredNode.outPathLength changed Int8→UInt8 + /// (SQLite INTEGER is identical for both; bit pattern -1 == 0xFF). + /// Added MessageRepeat.pathLength (UInt8, default 0). + /// Added SavedTracePath.hashSize (Int, default 1). + /// - v2→v3: Added PendingSend (new table; no migration impact on existing rows). + /// - v3→v4: Added PendingSend.attemptCount (Int?, default nil). Existing rows + /// lightweight-migrate to NULL; PersistenceStore.warmUp() runs + /// purgeLegacyAttemptCountRows() on connect to delete any nil row. + /// - v4→v5: Added TracePathRun.id uniqueness (run ids are minted once and + /// immutable, and backup import dedupes them store-wide, so existing + /// stores hold no duplicates) and RxLogEntry [radioID, receivedAt] + /// index. + public static func createContainer(inMemory: Bool = false) throws -> ModelContainer { + if !inMemory { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) } - // MARK: - Database Warm-up - - /// Forces SwiftData to initialize the database. - /// Call this early in app lifecycle to avoid lazy initialization during user operations. - public func warmUp() throws { - // Perform a simple fetch to trigger modelContext initialization - _ = try modelContext.fetchCount(FetchDescriptor()) - - // Both inner operations are idempotent under their own predicates: - // purgeOrphanPendingSends filters by absence of a matching - // Device.radioID and returns an empty row set once Device rows catch - // up; purgeLegacyAttemptCountRows filters by `attemptCount == nil` - // and returns empty after the first purge across the lifetime of - // the storage. Running both on every connect costs an empty fetch and - // self-heals the "erase device then re-pair" path — a process-level - // latch would suppress that recovery. - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore.warmUp") - do { - try purgeOrphanPendingSends() - } catch { - logger.warning("purgeOrphanPendingSends failed: \(error.localizedDescription, privacy: .public)") - } - do { - try purgeLegacyAttemptCountRows() - } catch { - logger.warning("purgeLegacyAttemptCountRows failed: \(error.localizedDescription, privacy: .public)") - } + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: inMemory, + allowsSave: true + ) + return try ModelContainer(for: schema, configurations: [configuration]) + } + + // MARK: - Database Warm-up + + /// Forces SwiftData to initialize the database. + /// Call this early in app lifecycle to avoid lazy initialization during user operations. + public func warmUp() throws { + // Perform a simple fetch to trigger modelContext initialization + _ = try modelContext.fetchCount(FetchDescriptor()) + + // Both inner operations are idempotent under their own predicates: + // purgeOrphanPendingSends filters by absence of a matching + // Device.radioID and returns an empty row set once Device rows catch + // up; purgeLegacyAttemptCountRows filters by `attemptCount == nil` + // and returns empty after the first purge across the lifetime of + // the storage. Running both on every connect costs an empty fetch and + // self-heals the "erase device then re-pair" path — a process-level + // latch would suppress that recovery. + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore.warmUp") + do { + try purgeOrphanPendingSends() + } catch { + logger.warning("purgeOrphanPendingSends failed: \(error.localizedDescription, privacy: .public)") + } + do { + try purgeLegacyAttemptCountRows() + } catch { + logger.warning("purgeLegacyAttemptCountRows failed: \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: - Discovered Nodes + + private let maxDiscoveredNodes = 1000 + + /// Inbound advert hop counts heard via the RX log before the matching node row exists. + /// The firmware emits the 0x88 RX packet before the 0x80 advert push that creates the row, + /// so a keyed write would otherwise miss on first contact. Buffered here and drained by the + /// upsert when the row lands. Bounded; the partner advert normally drains an entry at once. + private var pendingInboundHops: [PendingHopKey: PendingHop] = [:] + private let maxPendingInboundHops = 256 + + private struct PendingHopKey: Hashable { + let radioID: UUID + let publicKey: Data + } + + private struct PendingHop { + let hopCount: Int + let advertTimestamp: UInt32? + } + + public func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) throws -> (node: DiscoveredNodeDTO, isNew: Bool) { + let targetRadioID = radioID + let publicKey = frame.publicKey + let predicate = #Predicate { node in + node.radioID == targetRadioID && node.publicKey == publicKey + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + let existing = try modelContext.fetch(descriptor) + + let node: DiscoveredNode + let isNew: Bool + if let existingNode = existing.first { + existingNode.name = frame.name + existingNode.typeRawValue = frame.type.rawValue + existingNode.lastHeard = Date() + existingNode.lastAdvertTimestamp = frame.lastAdvertTimestamp + existingNode.latitude = frame.latitude + existingNode.longitude = frame.longitude + existingNode.outPathLength = frame.outPathLength + existingNode.outPath = frame.outPath + node = existingNode + isNew = false + } else { + node = DiscoveredNode( + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.type.rawValue, + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + outPathLength: frame.outPathLength, + outPath: frame.outPath + ) + modelContext.insert(node) + isNew = true + + try enforceDiscoveredNodeCap(radioID: radioID) } - // MARK: - Discovered Nodes + applyPendingInboundHop(to: node) - private let maxDiscoveredNodes = 1000 + try modelContext.save() - /// Inbound advert hop counts heard via the RX log before the matching node row exists. - /// The firmware emits the 0x88 RX packet before the 0x80 advert push that creates the row, - /// so a keyed write would otherwise miss on first contact. Buffered here and drained by the - /// upsert when the row lands. Bounded; the partner advert normally drains an entry at once. - private var pendingInboundHops: [PendingHopKey: PendingHop] = [:] - private let maxPendingInboundHops = 256 + // Temporary Discover trace: confirm the row committed and report the + // post-save row count for this radio (catches silent cap eviction). + // Filter by category "discover-trace"; remove with the matching probes. + let radioRows = (try? modelContext.fetchCount( + FetchDescriptor(predicate: #Predicate { $0.radioID == targetRadioID }) + )) ?? -1 + PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + .info("B3 persisted DiscoveredNode isNew=\(isNew) radioRows=\(radioRows)/\(maxDiscoveredNodes)") - private struct PendingHopKey: Hashable { - let radioID: UUID - let publicKey: Data - } + return (node: DiscoveredNodeDTO(from: node), isNew: isNew) + } - private struct PendingHop { - let hopCount: Int - let advertTimestamp: UInt32? + public func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) throws { + let targetRadioID = radioID + let targetKey = publicKey + let predicate = #Predicate { node in + node.radioID == targetRadioID && node.publicKey == targetKey } - - public func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - let targetRadioID = radioID - let publicKey = frame.publicKey - let predicate = #Predicate { node in - node.radioID == targetRadioID && node.publicKey == publicKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - let existing = try modelContext.fetch(descriptor) - - let node: DiscoveredNode - let isNew: Bool - if let existingNode = existing.first { - existingNode.name = frame.name - existingNode.typeRawValue = frame.type.rawValue - existingNode.lastHeard = Date() - existingNode.lastAdvertTimestamp = frame.lastAdvertTimestamp - existingNode.latitude = frame.latitude - existingNode.longitude = frame.longitude - existingNode.outPathLength = frame.outPathLength - existingNode.outPath = frame.outPath - node = existingNode - isNew = false - } else { - node = DiscoveredNode( - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.type.rawValue, - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - outPathLength: frame.outPathLength, - outPath: frame.outPath - ) - modelContext.insert(node) - isNew = true - - try enforceDiscoveredNodeCap(radioID: radioID) - } - - applyPendingInboundHop(to: node) - - try modelContext.save() - - // Temporary Discover trace: confirm the row committed and report the - // post-save row count for this radio (catches silent cap eviction). - // Filter by category "discover-trace"; remove with the matching probes. - let radioRows = (try? modelContext.fetchCount( - FetchDescriptor(predicate: #Predicate { $0.radioID == targetRadioID }) - )) ?? -1 - PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - .info("B3 persisted DiscoveredNode isNew=\(isNew) radioRows=\(radioRows)/\(maxDiscoveredNodes)") - - return (node: DiscoveredNodeDTO(from: node), isNew: isNew) + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + // No row yet: buffer the pair so the advert upsert drains it on first contact. + guard let node = try modelContext.fetch(descriptor).first else { + bufferPendingInboundHop(radioID: targetRadioID, publicKey: targetKey, hopCount: hopCount, advertTimestamp: advertTimestamp) + return } - - public func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) throws { - let targetRadioID = radioID - let targetKey = publicKey - let predicate = #Predicate { node in - node.radioID == targetRadioID && node.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - // No row yet: buffer the pair so the advert upsert drains it on first contact. - guard let node = try modelContext.fetch(descriptor).first else { - bufferPendingInboundHop(radioID: targetRadioID, publicKey: targetKey, hopCount: hopCount, advertTimestamp: advertTimestamp) - return - } - guard let adopted = adoptInboundHop( - storedHops: node.inboundHopCount, - storedTimestamp: node.inboundHopAdvertTimestamp, - incomingHops: hopCount, - incomingTimestamp: advertTimestamp - ) else { - return - } - node.inboundHopCount = adopted.hopCount - node.inboundHopAdvertTimestamp = adopted.advertTimestamp - try modelContext.save() + guard let adopted = adoptInboundHop( + storedHops: node.inboundHopCount, + storedTimestamp: node.inboundHopAdvertTimestamp, + incomingHops: hopCount, + incomingTimestamp: advertTimestamp + ) else { + return } - - /// Stash an inbound hop count heard before its node row exists. Applies the same adopt rule - /// as the live-row path. Evicts an arbitrary entry past the cap so a flood of never-resolved - /// adverts can't grow this without bound. - private func bufferPendingInboundHop(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) { - let key = PendingHopKey(radioID: radioID, publicKey: publicKey) - let existing = pendingInboundHops[key] - guard let adopted = adoptInboundHop( - storedHops: existing?.hopCount, - storedTimestamp: existing?.advertTimestamp, - incomingHops: hopCount, - incomingTimestamp: advertTimestamp - ) else { return } - if existing == nil, - pendingInboundHops.count >= maxPendingInboundHops, - let evicted = pendingInboundHops.keys.first { - pendingInboundHops.removeValue(forKey: evicted) - } - pendingInboundHops[key] = PendingHop(hopCount: adopted.hopCount, advertTimestamp: adopted.advertTimestamp) + node.inboundHopCount = adopted.hopCount + node.inboundHopAdvertTimestamp = adopted.advertTimestamp + try modelContext.save() + } + + /// Stash an inbound hop count heard before its node row exists. Applies the same adopt rule + /// as the live-row path. Evicts an arbitrary entry past the cap so a flood of never-resolved + /// adverts can't grow this without bound. + private func bufferPendingInboundHop(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) { + let key = PendingHopKey(radioID: radioID, publicKey: publicKey) + let existing = pendingInboundHops[key] + guard let adopted = adoptInboundHop( + storedHops: existing?.hopCount, + storedTimestamp: existing?.advertTimestamp, + incomingHops: hopCount, + incomingTimestamp: advertTimestamp + ) else { return } + if existing == nil, + pendingInboundHops.count >= maxPendingInboundHops, + let evicted = pendingInboundHops.keys.first { + pendingInboundHops.removeValue(forKey: evicted) } - - /// Apply and clear any inbound hop count buffered before this row existed. - private func applyPendingInboundHop(to node: DiscoveredNode) { - let key = PendingHopKey(radioID: node.radioID, publicKey: node.publicKey) - guard let buffered = pendingInboundHops.removeValue(forKey: key) else { return } - if let adopted = adoptInboundHop( - storedHops: node.inboundHopCount, - storedTimestamp: node.inboundHopAdvertTimestamp, - incomingHops: buffered.hopCount, - incomingTimestamp: buffered.advertTimestamp - ) { - node.inboundHopCount = adopted.hopCount - node.inboundHopAdvertTimestamp = adopted.advertTimestamp - } - } - - private func enforceDiscoveredNodeCap(radioID: UUID) throws { - let targetRadioID = radioID - let countPredicate = #Predicate { $0.radioID == targetRadioID } - let countDescriptor = FetchDescriptor(predicate: countPredicate) - let count = try modelContext.fetchCount(countDescriptor) - - if count > maxDiscoveredNodes { - var oldestDescriptor = FetchDescriptor( - predicate: countPredicate, - sortBy: [SortDescriptor(\.lastHeard, order: .forward)] - ) - oldestDescriptor.fetchLimit = count - maxDiscoveredNodes - let toDelete = try modelContext.fetch(oldestDescriptor) - for node in toDelete { - modelContext.delete(node) - } - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") - logger.warning("DiscoveredNode cap exceeded, evicted \(toDelete.count) oldest nodes") - } + pendingInboundHops[key] = PendingHop(hopCount: adopted.hopCount, advertTimestamp: adopted.advertTimestamp) + } + + /// Apply and clear any inbound hop count buffered before this row existed. + private func applyPendingInboundHop(to node: DiscoveredNode) { + let key = PendingHopKey(radioID: node.radioID, publicKey: node.publicKey) + guard let buffered = pendingInboundHops.removeValue(forKey: key) else { return } + if let adopted = adoptInboundHop( + storedHops: node.inboundHopCount, + storedTimestamp: node.inboundHopAdvertTimestamp, + incomingHops: buffered.hopCount, + incomingTimestamp: buffered.advertTimestamp + ) { + node.inboundHopCount = adopted.hopCount + node.inboundHopAdvertTimestamp = adopted.advertTimestamp } - - public func fetchDiscoveredNodes(radioID: UUID) throws -> [DiscoveredNodeDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - let descriptor = FetchDescriptor(predicate: predicate) - let nodes = try modelContext.fetch(descriptor) - return nodes.map { DiscoveredNodeDTO(from: $0) } + } + + private func enforceDiscoveredNodeCap(radioID: UUID) throws { + let targetRadioID = radioID + let countPredicate = #Predicate { $0.radioID == targetRadioID } + let countDescriptor = FetchDescriptor(predicate: countPredicate) + let count = try modelContext.fetchCount(countDescriptor) + + if count > maxDiscoveredNodes { + var oldestDescriptor = FetchDescriptor( + predicate: countPredicate, + sortBy: [SortDescriptor(\.lastHeard, order: .forward)] + ) + oldestDescriptor.fetchLimit = count - maxDiscoveredNodes + let toDelete = try modelContext.fetch(oldestDescriptor) + for node in toDelete { + modelContext.delete(node) + } + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") + logger.warning("DiscoveredNode cap exceeded, evicted \(toDelete.count) oldest nodes") } - - public func deleteDiscoveredNode(id: UUID) throws { - let targetID = id - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - if let node = try modelContext.fetch(descriptor).first { - modelContext.delete(node) - try modelContext.save() - } + } + + public func fetchDiscoveredNodes(radioID: UUID) throws -> [DiscoveredNodeDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + let descriptor = FetchDescriptor(predicate: predicate) + let nodes = try modelContext.fetch(descriptor) + return nodes.map { DiscoveredNodeDTO(from: $0) } + } + + public func deleteDiscoveredNode(id: UUID) throws { + let targetID = id + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let node = try modelContext.fetch(descriptor).first { + modelContext.delete(node) + try modelContext.save() } - - public func clearDiscoveredNodes(radioID: UUID) throws { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - let descriptor = FetchDescriptor(predicate: predicate) - let nodes = try modelContext.fetch(descriptor) - for node in nodes { - modelContext.delete(node) - } - try modelContext.save() + } + + public func clearDiscoveredNodes(radioID: UUID) throws { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + let descriptor = FetchDescriptor(predicate: predicate) + let nodes = try modelContext.fetch(descriptor) + for node in nodes { + modelContext.delete(node) } + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistentLogger.swift b/MC1Services/Sources/MC1Services/Services/PersistentLogger.swift index 9a7cb693..62287c16 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistentLogger.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistentLogger.swift @@ -4,57 +4,52 @@ import os /// Drop-in replacement for Logger that uses the shared DebugLogBuffer. /// Writes to both OSLog (for system integration) and SwiftData (for persistence). public struct PersistentLogger: Sendable { - private let logger: Logger - private let subsystem: String - private let category: String - - public init(subsystem: String, category: String) { - self.logger = Logger(subsystem: subsystem, category: category) - self.subsystem = subsystem - self.category = category - } - - public func debug(_ message: String) { - logger.debug("\(message)") - persist(level: .debug, message: message) - } - - public func info(_ message: String) { - logger.info("\(message)") - persist(level: .info, message: message) - } - - public func notice(_ message: String) { - logger.notice("\(message)") - persist(level: .notice, message: message) - } - - public func warning(_ message: String) { - logger.warning("\(message)") - persist(level: .warning, message: message) - } - - public func error(_ message: String) { - logger.error("\(message)") - persist(level: .error, message: message) - } - - public func fault(_ message: String) { - logger.fault("\(message)") - persist(level: .fault, message: message) - } - - private func persist(level: DebugLogLevel, message: String) { - guard let buffer = DebugLogBuffer.shared else { return } - - let dto = DebugLogEntryDTO( - level: level, - subsystem: subsystem, - category: category, - message: message - ) - Task { - await buffer.append(dto) - } - } + private let logger: Logger + private let subsystem: String + private let category: String + + public init(subsystem: String, category: String) { + logger = Logger(subsystem: subsystem, category: category) + self.subsystem = subsystem + self.category = category + } + + public func debug(_ message: String) { + logger.debug("\(message)") + persist(level: .debug, message: message) + } + + public func info(_ message: String) { + logger.info("\(message)") + persist(level: .info, message: message) + } + + public func notice(_ message: String) { + logger.notice("\(message)") + persist(level: .notice, message: message) + } + + public func warning(_ message: String) { + logger.warning("\(message)") + persist(level: .warning, message: message) + } + + public func error(_ message: String) { + logger.error("\(message)") + persist(level: .error, message: message) + } + + public func fault(_ message: String) { + logger.fault("\(message)") + persist(level: .fault, message: message) + } + + private func persist(level: DebugLogLevel, message: String) { + DebugLogBuffer.record(DebugLogEntryDTO( + level: level, + subsystem: subsystem, + category: category, + message: message + )) + } } diff --git a/MC1Services/Sources/MC1Services/Services/RadioOptions.swift b/MC1Services/Sources/MC1Services/Services/RadioOptions.swift index 2980137c..16ad73dc 100644 --- a/MC1Services/Sources/MC1Services/Services/RadioOptions.swift +++ b/MC1Services/Sources/MC1Services/Services/RadioOptions.swift @@ -5,71 +5,71 @@ private let radioLogger = PersistentLogger(subsystem: "com.mc1", category: "radi /// Standard LoRa radio parameter options for configuration UI public enum RadioOptions { - /// Available bandwidth options in Hz (internal representation for picker tags) - /// Display values: 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500 kHz - /// - /// Note: These values are passed directly to the protocol layer. Despite the - /// misleading parameter name `bandwidthKHz` in FrameCodec.encodeSetRadioParams, - /// the firmware actually expects bandwidth in Hz. - public static let bandwidthsHz: [UInt32] = [ - 7_800, 10_400, 15_600, 20_800, 31_250, 41_700, 62_500, 125_000, 250_000, 500_000 - ] + /// Available bandwidth options in Hz (internal representation for picker tags) + /// Display values: 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500 kHz + /// + /// Note: These values are passed directly to the protocol layer. Despite the + /// misleading parameter name `bandwidthKHz` in FrameCodec.encodeSetRadioParams, + /// the firmware actually expects bandwidth in Hz. + public static let bandwidthsHz: [UInt32] = [ + 7800, 10400, 15600, 20800, 31250, 41700, 62500, 125_000, 250_000, 500_000 + ] - /// Bandwidth options in kHz for CLI protocol display - public static let bandwidthsKHz: [Double] = bandwidthsHz.map { Double($0) / 1000.0 } + /// Bandwidth options in kHz for CLI protocol display + public static let bandwidthsKHz: [Double] = bandwidthsHz.map { Double($0) / 1000.0 } - /// Valid spreading factor range (SF5-SF12) - public static let spreadingFactors: ClosedRange = 5...12 + /// Valid spreading factor range (SF5-SF12) + public static let spreadingFactors: ClosedRange = 5...12 - /// Valid coding rate range (5-8, representing 4/5 through 4/8) - public static let codingRates: ClosedRange = 5...8 + /// Valid coding rate range (5-8, representing 4/5 through 4/8) + public static let codingRates: ClosedRange = 5...8 - /// Format bandwidth Hz value for display (e.g., 7800 -> "7.8", 125000 -> "125") - /// Uses switch for known values to ensure deterministic, O(1) output. - public static func formatBandwidth(_ hz: UInt32) -> String { - switch hz { - case 7_800: return "7.8" - case 10_400: return "10.4" - case 15_600: return "15.6" - case 20_800: return "20.8" - case 31_250: return "31.25" - case 41_700: return "41.7" - case 62_500: return "62.5" - case 125_000: return "125" - case 250_000: return "250" - case 500_000: return "500" - default: - // Fallback for unexpected values (e.g., from nearestBandwidth edge cases) - let khz = Double(hz) / 1000.0 - if khz.truncatingRemainder(dividingBy: 1) == 0 { - return "\(Int(khz))" - } else { - let formatter = NumberFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 2 - return formatter.string(from: NSNumber(value: khz)) ?? "\(khz)" - } - } + /// Format bandwidth Hz value for display (e.g., 7800 -> "7.8", 125000 -> "125") + /// Uses switch for known values to ensure deterministic, O(1) output. + public static func formatBandwidth(_ hz: UInt32) -> String { + switch hz { + case 7800: return "7.8" + case 10400: return "10.4" + case 15600: return "15.6" + case 20800: return "20.8" + case 31250: return "31.25" + case 41700: return "41.7" + case 62500: return "62.5" + case 125_000: return "125" + case 250_000: return "250" + case 500_000: return "500" + default: + // Fallback for unexpected values (e.g., from nearestBandwidth edge cases) + let khz = Double(hz) / 1000.0 + if khz.truncatingRemainder(dividingBy: 1) == 0 { + return "\(Int(khz))" + } else { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 2 + return formatter.string(from: NSNumber(value: khz)) ?? "\(khz)" + } } + } - /// Find nearest valid bandwidth for a device value that may not be in the standard list. - /// Handles firmware float precision issues where values like 7800 Hz may be stored as - /// 7.8 kHz (float) and returned as 7799 or 7801 Hz. - /// - /// Logs a debug message when fallback occurs to help diagnose unexpected device values. - public static func nearestBandwidth(to hz: UInt32) -> UInt32 { - if bandwidthsHz.contains(hz) { - return hz - } - // Use explicit unsigned comparison to avoid Int64 type promotion - let nearest = bandwidthsHz.min { lhs, rhs in - let lhsDiff = lhs > hz ? lhs - hz : hz - lhs - let rhsDiff = rhs > hz ? rhs - hz : hz - rhs - return lhsDiff < rhsDiff - } ?? 250_000 - - radioLogger.debug("Bandwidth \(hz) Hz not in standard options, using nearest: \(nearest) Hz") - return nearest + /// Find nearest valid bandwidth for a device value that may not be in the standard list. + /// Handles firmware float precision issues where values like 7800 Hz may be stored as + /// 7.8 kHz (float) and returned as 7799 or 7801 Hz. + /// + /// Logs a debug message when fallback occurs to help diagnose unexpected device values. + public static func nearestBandwidth(to hz: UInt32) -> UInt32 { + if bandwidthsHz.contains(hz) { + return hz } + // Use explicit unsigned comparison to avoid Int64 type promotion + let nearest = bandwidthsHz.min { lhs, rhs in + let lhsDiff = lhs > hz ? lhs - hz : hz - lhs + let rhsDiff = rhs > hz ? rhs - hz : hz - rhs + return lhsDiff < rhsDiff + } ?? 250_000 + + radioLogger.debug("Bandwidth \(hz) Hz not in standard options, using nearest: \(nearest) Hz") + return nearest + } } diff --git a/MC1Services/Sources/MC1Services/Services/RadioPresets.swift b/MC1Services/Sources/MC1Services/Services/RadioPresets.swift index 786a8113..eaf14051 100644 --- a/MC1Services/Sources/MC1Services/Services/RadioPresets.swift +++ b/MC1Services/Sources/MC1Services/Services/RadioPresets.swift @@ -2,324 +2,337 @@ import Foundation /// Geographic regions for radio preset filtering public enum RadioRegion: String, CaseIterable, Sendable { - case northAmerica = "North America" - case europe = "Europe" - case oceania = "Oceania" - case asia = "Asia" + case northAmerica = "North America" + case southAmerica = "South America" + case europe = "Europe" + case oceania = "Oceania" + case asia = "Asia" - /// Regions that should be shown for a given locale - public static func regionsForLocale(_ locale: Locale = .current) -> [RadioRegion] { - guard let regionCode = locale.region?.identifier else { - return RadioRegion.allCases - } + /// Regions that should be shown for a given locale + public static func regionsForLocale(_ locale: Locale = .current) -> [RadioRegion] { + guard let regionCode = locale.region?.identifier else { + return RadioRegion.allCases + } - switch regionCode { - case "US", "CA": - return [.northAmerica, .europe, .oceania, .asia] - case "AU", "NZ": - return [.oceania, .northAmerica, .europe, .asia] - case "GB", "DE", "FR", "IT", "ES", "PT", "CH", "CZ", "IE", "NL", "BE", "AT": - return [.europe, .northAmerica, .oceania, .asia] - case "VN", "TH", "MY", "SG", "PH", "ID": - return [.asia, .oceania, .europe, .northAmerica] - default: - return RadioRegion.allCases - } + switch regionCode { + case "US", "CA": + return [.northAmerica, .europe, .oceania, .asia] + case "AU", "NZ": + return [.oceania, .northAmerica, .europe, .asia] + case "GB", "DE", "FR", "IT", "ES", "PT", "CH", "CZ", "IE", "NL", "BE", "AT": + return [.europe, .northAmerica, .oceania, .asia] + case "VN", "TH", "MY", "SG", "PH", "ID": + return [.asia, .oceania, .europe, .northAmerica] + case "CL": + return [.southAmerica, .northAmerica, .europe, .oceania, .asia] + default: + return RadioRegion.allCases } + } - /// Short code for display in compact UI elements - public var shortCode: String { - switch self { - case .northAmerica: return "NA" - case .europe: return "EU" - case .oceania: return "AU" - case .asia: return "AS" - } + /// Short code for display in compact UI elements + public var shortCode: String { + switch self { + case .northAmerica: "NA" + case .southAmerica: "SA" + case .europe: "EU" + case .oceania: "AU" + case .asia: "AS" } + } } /// Geographic availability tier for a `RadioPreset`. Used by the recommendation /// algorithm to choose the most-specific community-curated preset that matches /// the user's `RegionSelection`. -enum PresetAvailability: Sendable, Equatable { - case continent(RadioRegion) - case countries(Set) // ISO-3166 α-2 - case subRegions(country: String, areas: Set) // ISO 3166-2 - case counties(country: String, state: String, keys: Set) // normalized US county names +enum PresetAvailability: Equatable { + case continent(RadioRegion) + case countries(Set) // ISO-3166 α-2 + case subRegions(country: String, areas: Set) // ISO 3166-2 + case counties(country: String, state: String, keys: Set) // normalized US county names } /// Radio configuration preset for common regional settings public struct RadioPreset: Identifiable, Sendable, Equatable { - public let id: String - public let name: String - public let region: RadioRegion - public let frequencyMHz: Double - public let bandwidthKHz: Double - public let spreadingFactor: UInt8 - public let codingRate: UInt8 + public let id: String + public let name: String + public let region: RadioRegion + public let frequencyMHz: Double + public let bandwidthKHz: Double + public let spreadingFactor: UInt8 + public let codingRate: UInt8 - /// Section header for repeat mode presets (e.g., "EU/Asia", "US/AU/NZ") - public let repeatSectionHeader: String? - let availability: PresetAvailability - /// Higher value = preferred within a geographic tier. Standard presets use 100; community-recommended favorites use 110. - public let recommendationPriority: Int + /// Section header for repeat mode presets (e.g., "EU/Asia", "US/AU/NZ") + public let repeatSectionHeader: String? + let availability: PresetAvailability + /// Higher value = preferred within a geographic tier. Standard presets use 100; community-recommended favorites use 110. + public let recommendationPriority: Int - /// Frequency in kHz for protocol encoding - public var frequencyKHz: UInt32 { UInt32((frequencyMHz * 1000).rounded()) } + /// Frequency in kHz for protocol encoding + public var frequencyKHz: UInt32 { + UInt32((frequencyMHz * 1000).rounded()) + } - /// Bandwidth in Hz for protocol encoding - public var bandwidthHz: UInt32 { UInt32((bandwidthKHz * 1000).rounded()) } + /// Bandwidth in Hz for protocol encoding + public var bandwidthHz: UInt32 { + UInt32((bandwidthKHz * 1000).rounded()) + } - init( - id: String, - name: String, - region: RadioRegion, - frequencyMHz: Double, - bandwidthKHz: Double, - spreadingFactor: UInt8, - codingRate: UInt8, - repeatSectionHeader: String? = nil, - availability: PresetAvailability, - recommendationPriority: Int = 100 - ) { - self.id = id - self.name = name - self.region = region - self.frequencyMHz = frequencyMHz - self.bandwidthKHz = bandwidthKHz - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.repeatSectionHeader = repeatSectionHeader - self.availability = availability - self.recommendationPriority = recommendationPriority - } + init( + id: String, + name: String, + region: RadioRegion, + frequencyMHz: Double, + bandwidthKHz: Double, + spreadingFactor: UInt8, + codingRate: UInt8, + repeatSectionHeader: String? = nil, + availability: PresetAvailability, + recommendationPriority: Int = 100 + ) { + self.id = id + self.name = name + self.region = region + self.frequencyMHz = frequencyMHz + self.bandwidthKHz = bandwidthKHz + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.repeatSectionHeader = repeatSectionHeader + self.availability = availability + self.recommendationPriority = recommendationPriority + } } /// Static collection of all available radio presets public enum RadioPresets { - public static let all: [RadioPreset] = [ - // Oceania - RadioPreset(id: "au-915", name: "Australia", region: .oceania, - frequencyMHz: 915.800, bandwidthKHz: 250, spreadingFactor: 10, codingRate: 5, - availability: .countries(["AU"])), - RadioPreset(id: "au-narrow", name: "Australia (Narrow)", region: .oceania, - frequencyMHz: 916.575, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 8, - availability: .countries(["AU"])), - RadioPreset(id: "au-mid", name: "Australia (Mid)", region: .oceania, - frequencyMHz: 915.075, bandwidthKHz: 125, spreadingFactor: 9, codingRate: 5, - availability: .countries(["AU"])), - RadioPreset(id: "au-sa-wa", name: "Australia: SA, WA", region: .oceania, - frequencyMHz: 923.125, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - availability: .subRegions(country: "AU", areas: ["AU-SA", "AU-WA"])), - RadioPreset(id: "au-qld", name: "Australia: QLD", region: .oceania, - frequencyMHz: 923.125, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, - availability: .subRegions(country: "AU", areas: ["AU-QLD"])), - RadioPreset(id: "nz-lr", name: "New Zealand", region: .oceania, - frequencyMHz: 917.375, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, - availability: .countries(["NZ"])), - RadioPreset(id: "nz-narrow", name: "New Zealand (Narrow)", region: .oceania, - frequencyMHz: 917.375, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .countries(["NZ"])), + public static let all: [RadioPreset] = [ + // Oceania + RadioPreset(id: "au-915", name: "Australia", region: .oceania, + frequencyMHz: 915.800, bandwidthKHz: 250, spreadingFactor: 10, codingRate: 5, + availability: .countries(["AU"])), + RadioPreset(id: "au-narrow", name: "Australia (Narrow)", region: .oceania, + frequencyMHz: 916.575, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 8, + availability: .countries(["AU"])), + RadioPreset(id: "au-mid", name: "Australia (Mid)", region: .oceania, + frequencyMHz: 915.075, bandwidthKHz: 125, spreadingFactor: 9, codingRate: 5, + availability: .countries(["AU"])), + RadioPreset(id: "au-sa-wa", name: "Australia: SA, WA", region: .oceania, + frequencyMHz: 923.125, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + availability: .subRegions(country: "AU", areas: ["AU-SA", "AU-WA"])), + RadioPreset(id: "au-qld", name: "Australia: QLD", region: .oceania, + frequencyMHz: 923.125, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, + availability: .subRegions(country: "AU", areas: ["AU-QLD"])), + RadioPreset(id: "nz-lr", name: "New Zealand", region: .oceania, + frequencyMHz: 917.375, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, + availability: .countries(["NZ"])), + RadioPreset(id: "nz-narrow", name: "New Zealand (Narrow)", region: .oceania, + frequencyMHz: 917.375, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .countries(["NZ"])), - // Europe - RadioPreset(id: "eu-narrow", name: "EU/UK (Narrow)", region: .europe, - frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - availability: .continent(.europe), recommendationPriority: 110), - RadioPreset(id: "eu-lr", name: "EU/UK (Deprecated)", region: .europe, - frequencyMHz: 869.525, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, - availability: .continent(.europe)), - RadioPreset(id: "cz-narrow", name: "Czech Republic (Narrow)", region: .europe, - frequencyMHz: 869.432, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .countries(["CZ"])), - RadioPreset(id: "eu-433-lr", name: "EU 433MHz (Long Range)", region: .europe, - frequencyMHz: 433.650, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, - availability: .continent(.europe)), - RadioPreset(id: "eu-433-narrow", name: "EU 433MHz (Narrow)", region: .europe, - frequencyMHz: 433.650, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - availability: .continent(.europe)), - RadioPreset(id: "pt-433", name: "Portugal 433", region: .europe, - frequencyMHz: 433.375, bandwidthKHz: 62.5, spreadingFactor: 9, codingRate: 6, - availability: .countries(["PT"])), - RadioPreset(id: "pt-868", name: "Portugal 868", region: .europe, - frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 6, - availability: .countries(["PT"]), recommendationPriority: 110), - RadioPreset(id: "ch", name: "Switzerland", region: .europe, - frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - availability: .countries(["CH"])), - RadioPreset(id: "nl", name: "Netherlands", region: .europe, - frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .countries(["NL"]), recommendationPriority: 110), + // Europe + RadioPreset(id: "eu-narrow", name: "EU/UK (Narrow)", region: .europe, + frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + availability: .continent(.europe), recommendationPriority: 110), + RadioPreset(id: "eu-lr", name: "EU/UK (Deprecated)", region: .europe, + frequencyMHz: 869.525, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, + availability: .continent(.europe)), + RadioPreset(id: "cz-narrow", name: "Czech Republic (Narrow)", region: .europe, + frequencyMHz: 869.432, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .countries(["CZ"])), + RadioPreset(id: "eu-433-lr", name: "EU 433MHz (Long Range)", region: .europe, + frequencyMHz: 433.650, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, + availability: .continent(.europe)), + RadioPreset(id: "eu-433-narrow", name: "EU 433MHz (Narrow)", region: .europe, + frequencyMHz: 433.650, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + availability: .continent(.europe)), + RadioPreset(id: "pt-433", name: "Portugal 433", region: .europe, + frequencyMHz: 433.375, bandwidthKHz: 62.5, spreadingFactor: 9, codingRate: 6, + availability: .countries(["PT"])), + RadioPreset(id: "pt-868", name: "Portugal 868", region: .europe, + frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 6, + availability: .countries(["PT"]), recommendationPriority: 110), + RadioPreset(id: "ch", name: "Switzerland", region: .europe, + frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + availability: .countries(["CH"])), + RadioPreset(id: "nl", name: "Netherlands", region: .europe, + frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .countries(["NL"]), recommendationPriority: 110), - // North America - RadioPreset(id: "us-ca", name: "USA/Canada", region: .northAmerica, - frequencyMHz: 910.525, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .countries(["US", "CA"]), recommendationPriority: 110), - RadioPreset(id: "wcmesh", name: "WCMesh (SoCal)", region: .northAmerica, - frequencyMHz: 927.875, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .counties(country: "US", state: "US-CA", keys: [ - "los angeles", "orange", "san diego", "riverside", "san bernardino", - "ventura", "imperial", "kern", "santa barbara", "san luis obispo", - ])), + // North America + RadioPreset(id: "us-ca", name: "USA/Canada", region: .northAmerica, + frequencyMHz: 910.525, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .countries(["US", "CA"]), recommendationPriority: 110), + RadioPreset(id: "wcmesh", name: "WCMesh (SoCal)", region: .northAmerica, + frequencyMHz: 927.875, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .counties(country: "US", state: "US-CA", keys: [ + "los angeles", "orange", "san diego", "riverside", "san bernardino", + "ventura", "imperial", "kern", "santa barbara", "san luis obispo", + ])), - // Asia - RadioPreset(id: "vn-narrow", name: "Vietnam (Narrow)", region: .asia, - frequencyMHz: 920.250, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, - availability: .countries(["VN"]), recommendationPriority: 110), - RadioPreset(id: "vn", name: "Vietnam (Deprecated)", region: .asia, - frequencyMHz: 920.250, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, - availability: .countries(["VN"])), - ] + // South America + // Chile: community-standard settings from the MeshChile network (https://meshchile.cl). + RadioPreset(id: "cl", name: "Chile", region: .southAmerica, + frequencyMHz: 927.875, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, + availability: .countries(["CL"])), - /// Repeat mode frequency presets with regional grouping. - /// BW 62.5 kHz + CR 8 maximize range for portable repeaters. - /// SF varies by band: higher for lower-power EU bands, lower for US. - public static let repeatPresets: [RadioPreset] = [ - RadioPreset(id: "repeat-433", name: "433 MHz", region: .europe, - frequencyMHz: 433.000, bandwidthKHz: 62.5, spreadingFactor: 9, codingRate: 8, - repeatSectionHeader: "EU/Asia", - availability: .continent(.europe)), - RadioPreset(id: "repeat-869", name: "869 MHz", region: .europe, - frequencyMHz: 869.000, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - repeatSectionHeader: "EU", - availability: .continent(.europe)), - RadioPreset(id: "repeat-918", name: "918 MHz", region: .northAmerica, - frequencyMHz: 918.000, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 8, - repeatSectionHeader: "US/AU/NZ", - availability: .continent(.northAmerica)), - ] + // Asia + RadioPreset(id: "vn-narrow", name: "Vietnam (Narrow)", region: .asia, + frequencyMHz: 920.250, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, + availability: .countries(["VN"]), recommendationPriority: 110), + RadioPreset(id: "vn", name: "Vietnam (Deprecated)", region: .asia, + frequencyMHz: 920.250, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, + availability: .countries(["VN"])), + ] - /// Get presets filtered and sorted by user's locale - public static func presetsForLocale(_ locale: Locale = .current) -> [RadioPreset] { - let preferredRegions = RadioRegion.regionsForLocale(locale) + /// Repeat mode frequency presets with regional grouping. Frequencies must match the + /// firmware's allowed repeat set exactly. Enabling Repeat Mode applies only the frequency; + /// the per-entry bandwidth/SF/CR are inert (kept because `RadioPreset`'s fields are non-optional). + public static let repeatPresets: [RadioPreset] = [ + RadioPreset(id: "repeat-433", name: "433 MHz", region: .europe, + frequencyMHz: 433.000, bandwidthKHz: 62.5, spreadingFactor: 9, codingRate: 8, + repeatSectionHeader: "EU/Asia", + availability: .continent(.europe)), + RadioPreset(id: "repeat-869", name: "869 MHz", region: .europe, + frequencyMHz: 869.495, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + repeatSectionHeader: "EU", + availability: .continent(.europe)), + RadioPreset(id: "repeat-918", name: "918 MHz", region: .northAmerica, + frequencyMHz: 918.000, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 8, + repeatSectionHeader: "US/AU/NZ", + availability: .continent(.northAmerica)), + ] - return all.sorted { a, b in - let aIndex = preferredRegions.firstIndex(of: a.region) ?? preferredRegions.count - let bIndex = preferredRegions.firstIndex(of: b.region) ?? preferredRegions.count - if aIndex != bIndex { - return aIndex < bIndex - } - return a.name < b.name - } + /// Get presets filtered and sorted by user's locale + public static func presetsForLocale(_ locale: Locale = .current) -> [RadioPreset] { + let preferredRegions = RadioRegion.regionsForLocale(locale) + + return all.sorted { a, b in + let aIndex = preferredRegions.firstIndex(of: a.region) ?? preferredRegions.count + let bIndex = preferredRegions.firstIndex(of: b.region) ?? preferredRegions.count + if aIndex != bIndex { + return aIndex < bIndex + } + return a.name < b.name } + } - /// Find preset matching current device settings (approximate match) - public static func matchingPreset( - frequencyKHz: UInt32, - bandwidthKHz: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8 - ) -> RadioPreset? { - let freqMHz = Double(frequencyKHz) / 1000.0 - let bwKHz = Double(bandwidthKHz) / 1000.0 + /// Find preset matching current device settings (approximate match) + public static func matchingPreset( + frequencyKHz: UInt32, + bandwidthKHz: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8 + ) -> RadioPreset? { + let freqMHz = Double(frequencyKHz) / 1000.0 + let bwKHz = Double(bandwidthKHz) / 1000.0 - return all.first { preset in - abs(preset.frequencyMHz - freqMHz) < 0.1 && - abs(preset.bandwidthKHz - bwKHz) < 1.0 && - preset.spreadingFactor == spreadingFactor && - preset.codingRate == codingRate - } + return all.first { preset in + abs(preset.frequencyMHz - freqMHz) < 0.1 && + abs(preset.bandwidthKHz - bwKHz) < 1.0 && + preset.spreadingFactor == spreadingFactor && + preset.codingRate == codingRate } + } - /// Find repeat preset matching current device settings (exact match) - public static func matchingRepeatPreset( - frequencyKHz: UInt32, - bandwidthKHz: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8 - ) -> RadioPreset? { - repeatPresets.first { preset in - preset.frequencyKHz == frequencyKHz && - preset.bandwidthHz == bandwidthKHz && - preset.spreadingFactor == spreadingFactor && - preset.codingRate == codingRate - } - } + /// Find the repeat preset for the device's current frequency. Repeat Mode only sets frequency, + /// so bandwidth/SF/CR are not part of the match. + public static func matchingRepeatPreset(frequencyKHz: UInt32) -> RadioPreset? { + repeatPresets.first { $0.frequencyKHz == frequencyKHz } + } - /// Stable recommendation order, computed once. `recommendationPriority` is a - /// compile-time constant on each preset so the sort output never changes. - private static let recommendationOrder: [RadioPreset] = all.sorted { - $0.recommendationPriority != $1.recommendationPriority - ? $0.recommendationPriority > $1.recommendationPriority - : $0.id < $1.id + /// The repeat preset nearest to a frequency by absolute kHz distance. Enabling Repeat Mode snaps an + /// off-band frequency to this preset, since the firmware accepts only exact repeat frequencies. + public static func nearestRepeatPreset(toFrequencyKHz frequencyKHz: UInt32) -> RadioPreset? { + repeatPresets.min { + abs(Int($0.frequencyKHz) - Int(frequencyKHz)) < abs(Int($1.frequencyKHz) - Int(frequencyKHz)) } + } - /// Returns the most-specific community-curated preset for `region`. - /// Tier 0 (county) → Tier 1 (sub-region) → Tier 2 (country) → Tier 3 (continent). - /// Returns nil for regions not covered by any tier (e.g. Bermuda). - public static func recommended(for region: RegionSelection) -> RadioPreset? { - let stable = recommendationOrder + /// Stable recommendation order, computed once. `recommendationPriority` is a + /// compile-time constant on each preset so the sort output never changes. + private static let recommendationOrder: [RadioPreset] = all.sorted { + $0.recommendationPriority != $1.recommendationPriority + ? $0.recommendationPriority > $1.recommendationPriority + : $0.id < $1.id + } - // Tier 0: counties - if let adminCode = region.administrativeAreaCode, - let countyKey = region.countyKey, - let preset = stable.first(where: { - if case .counties(let c, let s, let keys) = $0.availability { - return c == region.countryCode && s == adminCode && keys.contains(countyKey) - } - return false - }) { return preset } + /// Returns the most-specific community-curated preset for `region`. + /// Tier 0 (county) → Tier 1 (sub-region) → Tier 2 (country) → Tier 3 (continent). + /// Returns nil for regions not covered by any tier (e.g. Bermuda). + public static func recommended(for region: RegionSelection) -> RadioPreset? { + let stable = recommendationOrder - // Tier 1: sub-regions - if let adminCode = region.administrativeAreaCode, - let preset = stable.first(where: { - if case .subRegions(let c, let areas) = $0.availability { - return c == region.countryCode && areas.contains(adminCode) - } - return false - }) { return preset } + // Tier 0: counties + if let adminCode = region.administrativeAreaCode, + let countyKey = region.countyKey, + let preset = stable.first(where: { + if case let .counties(c, s, keys) = $0.availability { + return c == region.countryCode && s == adminCode && keys.contains(countyKey) + } + return false + }) { return preset } - // Tier 2: countries - if let preset = stable.first(where: { - if case .countries(let codes) = $0.availability { - return codes.contains(region.countryCode) - } - return false - }) { return preset } + // Tier 1: sub-regions + if let adminCode = region.administrativeAreaCode, + let preset = stable.first(where: { + if case let .subRegions(c, areas) = $0.availability { + return c == region.countryCode && areas.contains(adminCode) + } + return false + }) { return preset } - // Tier 3: continent - if let continent = RegionalAreas.continents[region.countryCode], - let preset = stable.first(where: { - if case .continent(let r) = $0.availability { return r == continent } - return false - }) { return preset } + // Tier 2: countries + if let preset = stable.first(where: { + if case let .countries(codes) = $0.availability { + return codes.contains(region.countryCode) + } + return false + }) { return preset } - return nil - } + // Tier 3: continent + if let continent = RegionalAreas.continents[region.countryCode], + let preset = stable.first(where: { + if case let .continent(r) = $0.availability { return r == continent } + return false + }) { return preset } + + return nil + } - /// Returns the alternatives list for the region's country (or continent if no - /// country-level matches exist). The list always includes `.counties` and - /// `.subRegions` presets for the country regardless of the user's specific - /// county/state, so a Sacramento user can still pick `wcmesh` manually. - public static func presets(for region: RegionSelection) -> [RadioPreset] { - let countryAndBelow = all.filter { preset in - switch preset.availability { - case .counties(let c, _, _): return c == region.countryCode - case .subRegions(let c, _): return c == region.countryCode - case .countries(let codes): return codes.contains(region.countryCode) - case .continent: return false - } - } - if !countryAndBelow.isEmpty { return countryAndBelow } - guard let continent = RegionalAreas.continents[region.countryCode] else { return [] } - return all.filter { preset in - if case .continent(let r) = preset.availability { return r == continent } - return false - } + /// Returns the alternatives list for the region's country (or continent if no + /// country-level matches exist). The list always includes `.counties` and + /// `.subRegions` presets for the country regardless of the user's specific + /// county/state, so a Sacramento user can still pick `wcmesh` manually. + public static func presets(for region: RegionSelection) -> [RadioPreset] { + let countryAndBelow = all.filter { preset in + switch preset.availability { + case let .counties(c, _, _): c == region.countryCode + case let .subRegions(c, _): c == region.countryCode + case let .countries(codes): codes.contains(region.countryCode) + case .continent: false + } } + if !countryAndBelow.isEmpty { return countryAndBelow } + guard let continent = RegionalAreas.continents[region.countryCode] else { return [] } + return all.filter { preset in + if case let .continent(r) = preset.availability { return r == continent } + return false + } + } - /// Whether `preset` should appear in a manual picker for `region`. Only county-scoped presets are - /// gated: they appear only when `region` resolves to one of their counties (a nil region hides them). - /// Every other tier is always selectable. - public static func isSelectable(_ preset: RadioPreset, in region: RegionSelection?) -> Bool { - guard case .counties(let country, let state, let keys) = preset.availability else { - return true - } - guard let region, - region.countryCode == country, - region.administrativeAreaCode == state, - let countyKey = region.countyKey else { - return false - } - return keys.contains(countyKey) + /// Whether `preset` should appear in a manual picker for `region`. Only county-scoped presets are + /// gated: they appear only when `region` resolves to one of their counties (a nil region hides them). + /// Every other tier is always selectable. + public static func isSelectable(_ preset: RadioPreset, in region: RegionSelection?) -> Bool { + guard case let .counties(country, state, keys) = preset.availability else { + return true + } + guard let region, + region.countryCode == country, + region.administrativeAreaCode == state, + let countyKey = region.countyKey else { + return false } + return keys.contains(countyKey) + } } diff --git a/MC1Services/Sources/MC1Services/Services/ReactionParser.swift b/MC1Services/Sources/MC1Services/Services/ReactionParser.swift index e27c02fb..940c460b 100644 --- a/MC1Services/Sources/MC1Services/Services/ReactionParser.swift +++ b/MC1Services/Sources/MC1Services/Services/ReactionParser.swift @@ -1,5 +1,5 @@ -import Foundation import CryptoKit +import Foundation // MARK: - Crockford Base32 @@ -8,221 +8,220 @@ private let crockfordAlphabet = Array("0123456789abcdefghjkmnpqrstvwxyz") /// Crockford Base32 decode table (maps ASCII to 5-bit values, -1 for invalid) private let crockfordDecodeTable: [Int8] = { - var table = [Int8](repeating: -1, count: 128) - for (index, char) in crockfordAlphabet.enumerated() { - table[Int(char.asciiValue!)] = Int8(index) - if let upper = Character(char.uppercased()).asciiValue { - table[Int(upper)] = Int8(index) - } - } - // Handle common substitutions (both cases) - table[Int(Character("O").asciiValue!)] = 0 // O -> 0 - table[Int(Character("o").asciiValue!)] = 0 - table[Int(Character("I").asciiValue!)] = 1 // I -> 1 - table[Int(Character("i").asciiValue!)] = 1 - table[Int(Character("L").asciiValue!)] = 1 // L -> 1 - table[Int(Character("l").asciiValue!)] = 1 - return table + var table = [Int8](repeating: -1, count: 128) + for (index, char) in crockfordAlphabet.enumerated() { + table[Int(char.asciiValue!)] = Int8(index) + if let upper = Character(char.uppercased()).asciiValue { + table[Int(upper)] = Int8(index) + } + } + // Handle common substitutions (both cases) + table[Int(Character("O").asciiValue!)] = 0 // O -> 0 + table[Int(Character("o").asciiValue!)] = 0 + table[Int(Character("I").asciiValue!)] = 1 // I -> 1 + table[Int(Character("i").asciiValue!)] = 1 + table[Int(Character("L").asciiValue!)] = 1 // L -> 1 + table[Int(Character("l").asciiValue!)] = 1 + return table }() /// Encodes 5 bytes (40 bits) to 8 Crockford Base32 characters private func encodeCrockfordBase32(_ bytes: some Collection) -> String { - precondition(bytes.count == 5) - let byteArray = Array(bytes) - - // Pack 5 bytes into a 40-bit value - var bits: UInt64 = 0 - for byte in byteArray { - bits = (bits << 8) | UInt64(byte) - } - - // Extract 8 groups of 5 bits, MSB first - var result = "" - result.reserveCapacity(8) - for shift in stride(from: 35, through: 0, by: -5) { - let index = Int((bits >> shift) & 0x1F) - result.append(crockfordAlphabet[index]) - } - return result + precondition(bytes.count == 5) + let byteArray = Array(bytes) + + // Pack 5 bytes into a 40-bit value + var bits: UInt64 = 0 + for byte in byteArray { + bits = (bits << 8) | UInt64(byte) + } + + // Extract 8 groups of 5 bits, MSB first + var result = "" + result.reserveCapacity(8) + for shift in stride(from: 35, through: 0, by: -5) { + let index = Int((bits >> shift) & 0x1F) + result.append(crockfordAlphabet[index]) + } + return result } /// Validates a string contains only valid Crockford Base32 characters private func isValidCrockfordBase32(_ string: String) -> Bool { - for char in string { - guard let ascii = char.asciiValue, ascii < 128, crockfordDecodeTable[Int(ascii)] >= 0 else { - return false - } + for char in string { + guard let ascii = char.asciiValue, ascii < 128, crockfordDecodeTable[Int(ascii)] >= 0 else { + return false } - return true + } + return true } /// Normalizes a Crockford Base32 string to lowercase canonical form private func normalizeCrockfordBase32(_ string: String) -> String { - var result = "" - result.reserveCapacity(string.count) - for char in string { - guard let ascii = char.asciiValue, ascii < 128 else { continue } - let value = crockfordDecodeTable[Int(ascii)] - if value >= 0 { - result.append(crockfordAlphabet[Int(value)]) - } - } - return result + var result = "" + result.reserveCapacity(string.count) + for char in string { + guard let ascii = char.asciiValue, ascii < 128 else { continue } + let value = crockfordDecodeTable[Int(ascii)] + if value >= 0 { + result.append(crockfordAlphabet[Int(value)]) + } + } + return result } /// Parsed reaction data extracted from wire format public struct ParsedReaction: Sendable, Equatable { - public let emoji: String - public let targetSender: String - public let messageHash: String // 8 Crockford Base32 chars (lowercase) + public let emoji: String + public let targetSender: String + public let messageHash: String // 8 Crockford Base32 chars (lowercase) } /// Parsed DM reaction data (shorter format without sender) public struct ParsedDMReaction: Sendable, Equatable { - public let emoji: String - public let messageHash: String // 8 Crockford Base32 chars (lowercase) + public let emoji: String + public let messageHash: String // 8 Crockford Base32 chars (lowercase) } /// Parses reaction wire format using end-to-start strategy. /// Format: `{emoji}@[{sender}]\nxxxxxxxx` public enum ReactionParser { + /// Returns true if the text matches any known reaction format (PocketMesh or meshcore-open). + static func isReactionText(_ text: String, isDM: Bool) -> Bool { + if MeshCoreOpenReactionParser.parse(text) != nil { return true } + if MeshCoreOpenReactionParser.parseV1(text) != nil { return true } + return isDM ? parseDM(text) != nil : parse(text) != nil + } + + /// Parses reaction text, returns nil if format doesn't match + public static func parse(_ text: String) -> ParsedReaction? { + // Step 1: Split on last newline to get hash + guard let newlineIndex = text.lastIndex(of: "\n") else { + return nil + } + + let rawHash = String(text[text.index(after: newlineIndex)...]) + guard rawHash.count == 8, isValidCrockfordBase32(rawHash) else { + return nil + } + let messageHash = normalizeCrockfordBase32(rawHash) + + // Remove hash suffix (everything before the newline) + let withoutHash = String(text[.. ParsedDMReaction? { + // Reject channel format (contains `@[`) + if text.contains("@[") { + return nil + } + + // Split on newline to get hash + guard let newlineIndex = text.lastIndex(of: "\n") else { + return nil + } + + let rawHash = String(text[text.index(after: newlineIndex)...]) + guard rawHash.count == 8, isValidCrockfordBase32(rawHash) else { + return nil + } + let messageHash = normalizeCrockfordBase32(rawHash) + + // Extract emoji (everything before the newline) + let emoji = String(text[.. Bool { - if MeshCoreOpenReactionParser.parse(text) != nil { return true } - if MeshCoreOpenReactionParser.parseV1(text) != nil { return true } - return isDM ? parseDM(text) != nil : parse(text) != nil + // Validate emoji is not empty and starts with emoji character + guard !emoji.isEmpty, emoji.first?.isEmoji == true else { + return nil } - /// Parses reaction text, returns nil if format doesn't match - public static func parse(_ text: String) -> ParsedReaction? { - // Step 1: Split on last newline to get hash - guard let newlineIndex = text.lastIndex(of: "\n") else { - return nil - } - - let rawHash = String(text[text.index(after: newlineIndex)...]) - guard rawHash.count == 8, isValidCrockfordBase32(rawHash) else { - return nil - } - let messageHash = normalizeCrockfordBase32(rawHash) - - // Remove hash suffix (everything before the newline) - let withoutHash = String(text[.. ParsedDMReaction? { - // Reject channel format (contains `@[`) - if text.contains("@[") { - return nil - } - - // Split on newline to get hash - guard let newlineIndex = text.lastIndex(of: "\n") else { - return nil - } - - let rawHash = String(text[text.index(after: newlineIndex)...]) - guard rawHash.count == 8, isValidCrockfordBase32(rawHash) else { - return nil - } - let messageHash = normalizeCrockfordBase32(rawHash) - - // Extract emoji (everything before the newline) - let emoji = String(text[.. String { - let hash = generateMessageHash(text: targetText, timestamp: targetTimestamp) - return "\(emoji)\n\(hash)" - } - - /// Generates message identifier for reaction wire format (8-char Crockford Base32) - public static func generateMessageHash(text: String, timestamp: UInt32) -> String { - var data = Data(text.utf8) - withUnsafeBytes(of: timestamp.littleEndian) { data.append(contentsOf: $0) } - let digest = SHA256.hash(data: data) - let bytes = Array(digest.prefix(5)) - return encodeCrockfordBase32(bytes) - } - - /// Builds summary string from emoji counts, sorted by count descending - static func buildSummary(from reactions: [(emoji: String, count: Int)]) -> String { - reactions - .sorted { $0.count > $1.count } - .map { "\($0.emoji):\($0.count)" } - .joined(separator: ",") - } - - /// Builds summary string from reaction DTOs. - /// Sorts by count descending, then by earliest timestamp ascending for tie-breaker. - static func buildSummary(from reactions: [ReactionDTO]) -> String { - let grouped = Dictionary(grouping: reactions, by: \.emoji) - let sorted = grouped.map { emoji, items in - (emoji: emoji, count: items.count, earliest: items.map(\.receivedAt).min() ?? Date.distantPast) - } - .sorted { lhs, rhs in - if lhs.count != rhs.count { return lhs.count > rhs.count } - return lhs.earliest < rhs.earliest - } - return sorted.map { "\($0.emoji):\($0.count)" }.joined(separator: ",") - } - - /// Parses summary string into emoji/count pairs - public static func parseSummary(_ summary: String?) -> [(emoji: String, count: Int)] { - guard let summary, !summary.isEmpty else { return [] } - - return summary.split(separator: ",").compactMap { part in - let components = part.split(separator: ":") - guard components.count == 2, - let count = Int(components[1]) else { return nil } - return (String(components[0]), count) - } + return ParsedDMReaction(emoji: emoji, messageHash: messageHash) + } + + /// Builds DM reaction text in wire format. + /// Format: `{emoji}\n{hash}` + static func buildDMReactionText( + emoji: String, + targetText: String, + targetTimestamp: UInt32 + ) -> String { + let hash = generateMessageHash(text: targetText, timestamp: targetTimestamp) + return "\(emoji)\n\(hash)" + } + + /// Generates message identifier for reaction wire format (8-char Crockford Base32) + public static func generateMessageHash(text: String, timestamp: UInt32) -> String { + var data = Data(text.utf8) + withUnsafeBytes(of: timestamp.littleEndian) { data.append(contentsOf: $0) } + let digest = SHA256.hash(data: data) + let bytes = Array(digest.prefix(5)) + return encodeCrockfordBase32(bytes) + } + + /// Builds summary string from emoji counts, sorted by count descending + static func buildSummary(from reactions: [(emoji: String, count: Int)]) -> String { + reactions + .sorted { $0.count > $1.count } + .map { "\($0.emoji):\($0.count)" } + .joined(separator: ",") + } + + /// Builds summary string from reaction DTOs. + /// Sorts by count descending, then by earliest timestamp ascending for tie-breaker. + static func buildSummary(from reactions: [ReactionDTO]) -> String { + let grouped = Dictionary(grouping: reactions, by: \.emoji) + let sorted = grouped.map { emoji, items in + (emoji: emoji, count: items.count, earliest: items.map(\.receivedAt).min() ?? Date.distantPast) + } + .sorted { lhs, rhs in + if lhs.count != rhs.count { return lhs.count > rhs.count } + return lhs.earliest < rhs.earliest + } + return sorted.map { "\($0.emoji):\($0.count)" }.joined(separator: ",") + } + + /// Parses summary string into emoji/count pairs + public static func parseSummary(_ summary: String?) -> [(emoji: String, count: Int)] { + guard let summary, !summary.isEmpty else { return [] } + + return summary.split(separator: ",").compactMap { part in + let components = part.split(separator: ":") + guard components.count == 2, + let count = Int(components[1]) else { return nil } + return (String(components[0]), count) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ReactionService.swift b/MC1Services/Sources/MC1Services/Services/ReactionService.swift index 698470ea..eddb043c 100644 --- a/MC1Services/Sources/MC1Services/Services/ReactionService.swift +++ b/MC1Services/Sources/MC1Services/Services/ReactionService.swift @@ -1,323 +1,323 @@ -import Foundation import CryptoKit +import Foundation import OSLog /// A reaction waiting for its target message to be indexed public struct PendingReaction: Sendable { - public let parsed: ParsedReaction - public let channelIndex: UInt8 - public let senderNodeName: String - public let rawText: String - public let radioID: UUID - public let receivedAt: Date + public let parsed: ParsedReaction + public let channelIndex: UInt8 + public let senderNodeName: String + public let rawText: String + public let radioID: UUID + public let receivedAt: Date } /// A DM reaction waiting for its target message to be indexed public struct PendingDMReaction: Sendable { - public let parsed: ParsedDMReaction - public let contactID: UUID - public let senderName: String - public let rawText: String - public let radioID: UUID - public let receivedAt: Date + public let parsed: ParsedDMReaction + public let contactID: UUID + public let senderName: String + public let rawText: String + public let radioID: UUID + public let receivedAt: Date } /// Result of persisting a reaction public struct ReactionPersistResult: Sendable { - public let messageID: UUID - public let summary: String + public let messageID: UUID + public let summary: String } /// Service for handling emoji reactions on channel messages public actor ReactionService { - private let logger = Logger(subsystem: "com.mc1", category: "ReactionService") - private let messageCache: MessageLRUCache - - private static let maxPendingReactions = 100 - - // Pending reactions queue: no TTL, session lifetime. - private var pendingReactions: [PendingReactionKey: [PendingReaction]] = [:] - private var pendingOrder: [PendingReactionKey] = [] - - private struct PendingReactionKey: Hashable { - let channelIndex: UInt8 - let targetSender: String - let messageHash: String + private let logger = Logger(subsystem: "com.mc1", category: "ReactionService") + private let messageCache: MessageLRUCache + + private static let maxPendingReactions = 100 + + // Pending reactions queue: no TTL, session lifetime. + private var pendingReactions: [PendingReactionKey: [PendingReaction]] = [:] + private var pendingOrder: [PendingReactionKey] = [] + + private struct PendingReactionKey: Hashable { + let channelIndex: UInt8 + let targetSender: String + let messageHash: String + } + + private struct PendingDMReactionKey: Hashable { + let contactID: UUID + let messageHash: String + } + + private var pendingDMReactions: [PendingDMReactionKey: [PendingDMReaction]] = [:] + private var pendingDMOrder: [PendingDMReactionKey] = [] + + init(messageCache: MessageLRUCache = MessageLRUCache()) { + self.messageCache = messageCache + } + + /// Indexes a message for reaction matching and returns any pending reactions that now match + public func indexMessage( + id: UUID, + channelIndex: UInt8, + senderName: String, + text: String, + timestamp: UInt32 + ) async -> [PendingReaction] { + await messageCache.index( + messageID: id, + channelIndex: channelIndex, + senderName: senderName, + text: text, + timestamp: timestamp + ) + + // Check pending queue for matching reactions + let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) + let key = PendingReactionKey( + channelIndex: channelIndex, + targetSender: senderName, + messageHash: hash + ) + + guard let matched = pendingReactions.removeValue(forKey: key) else { + return [] } - private struct PendingDMReactionKey: Hashable { - let contactID: UUID - let messageHash: String + pendingOrder.removeAll { $0 == key } + + logger.debug("Matched \(matched.count) pending reaction(s) to message \(id)") + + return matched + } + + /// Builds reaction wire format text for sending + /// Format: `{emoji}@[{sender}]\n{hash}` + public nonisolated func buildReactionText( + emoji: String, + targetSender: String, + targetText: String, + targetTimestamp: UInt32 + ) -> String { + let hash = ReactionParser.generateMessageHash(text: targetText, timestamp: targetTimestamp) + return "\(emoji)@[\(targetSender)]\n\(hash)" + } + + /// Builds DM reaction wire format (shorter, no sender) + public nonisolated func buildDMReactionText( + emoji: String, + targetText: String, + targetTimestamp: UInt32 + ) -> String { + ReactionParser.buildDMReactionText( + emoji: emoji, + targetText: targetText, + targetTimestamp: targetTimestamp + ) + } + + /// Finds target message ID for a parsed reaction using hash-based lookup + func findTargetMessage(parsed: ParsedReaction, channelIndex: UInt8) async -> UUID? { + let candidates = await messageCache.lookup( + channelIndex: channelIndex, + senderName: parsed.targetSender, + messageHash: parsed.messageHash + ) + + // Return most recently indexed match + return candidates.max(by: { $0.indexedAt < $1.indexedAt })?.messageID + } + + /// Attempts to process incoming text as a reaction + /// Returns true if handled as reaction, false to process as regular message + nonisolated func tryProcessAsReaction(_ text: String) -> ParsedReaction? { + ReactionParser.parse(text) + } + + /// Queues a reaction that couldn't find its target message + func queuePendingReaction( + parsed: ParsedReaction, + channelIndex: UInt8, + senderNodeName: String, + rawText: String, + radioID: UUID + ) { + let key = PendingReactionKey( + channelIndex: channelIndex, + targetSender: parsed.targetSender, + messageHash: parsed.messageHash + ) + let pending = PendingReaction( + parsed: parsed, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + rawText: rawText, + radioID: radioID, + receivedAt: Date() + ) + + if pendingReactions[key] != nil { + pendingReactions[key]!.append(pending) + } else { + pendingReactions[key] = [pending] + pendingOrder.append(key) } - private var pendingDMReactions: [PendingDMReactionKey: [PendingDMReaction]] = [:] - private var pendingDMOrder: [PendingDMReactionKey] = [] - - init(messageCache: MessageLRUCache = MessageLRUCache()) { - self.messageCache = messageCache - } - - /// Indexes a message for reaction matching and returns any pending reactions that now match - public func indexMessage( - id: UUID, - channelIndex: UInt8, - senderName: String, - text: String, - timestamp: UInt32 - ) async -> [PendingReaction] { - await messageCache.index( - messageID: id, - channelIndex: channelIndex, - senderName: senderName, - text: text, - timestamp: timestamp - ) - - // Check pending queue for matching reactions - let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) - let key = PendingReactionKey( - channelIndex: channelIndex, - targetSender: senderName, - messageHash: hash - ) - - guard let matched = pendingReactions.removeValue(forKey: key) else { - return [] - } - - pendingOrder.removeAll { $0 == key } - - logger.debug("Matched \(matched.count) pending reaction(s) to message \(id)") - - return matched + evictIfNeeded() + logger.debug("Queued pending reaction \(parsed.emoji) for \(parsed.targetSender)") + } + + /// Clears all pending reactions (call on disconnect) + func clearPendingReactions() { + let count = pendingReactions.values.reduce(0) { $0 + $1.count } + let dmCount = pendingDMReactions.values.reduce(0) { $0 + $1.count } + pendingReactions.removeAll() + pendingOrder.removeAll() + pendingDMReactions.removeAll() + pendingDMOrder.removeAll() + if count + dmCount > 0 { + logger.debug("Cleared \(count + dmCount) pending reaction(s)") } - - /// Builds reaction wire format text for sending - /// Format: `{emoji}@[{sender}]\n{hash}` - public nonisolated func buildReactionText( - emoji: String, - targetSender: String, - targetText: String, - targetTimestamp: UInt32 - ) -> String { - let hash = ReactionParser.generateMessageHash(text: targetText, timestamp: targetTimestamp) - return "\(emoji)@[\(targetSender)]\n\(hash)" + } + + // MARK: - Persistence + + /// Persists a reaction and updates the message's reaction summary. + /// Logs errors instead of silently discarding them. + public func persistReactionAndUpdateSummary( + _ reaction: ReactionDTO, + using dataStore: any PersistenceStoreProtocol + ) async -> ReactionPersistResult? { + do { + try await dataStore.saveReaction(reaction) + } catch { + logger.error("Failed to save reaction for message \(reaction.messageID): \(error.localizedDescription)") + return nil } - /// Builds DM reaction wire format (shorter, no sender) - public nonisolated func buildDMReactionText( - emoji: String, - targetText: String, - targetTimestamp: UInt32 - ) -> String { - ReactionParser.buildDMReactionText( - emoji: emoji, - targetText: targetText, - targetTimestamp: targetTimestamp - ) + let reactions: [ReactionDTO] + do { + reactions = try await dataStore.fetchReactions(for: reaction.messageID) + } catch { + logger.error("Failed to fetch reactions for message \(reaction.messageID): \(error.localizedDescription)") + return nil } - /// Finds target message ID for a parsed reaction using hash-based lookup - func findTargetMessage(parsed: ParsedReaction, channelIndex: UInt8) async -> UUID? { - let candidates = await messageCache.lookup( - channelIndex: channelIndex, - senderName: parsed.targetSender, - messageHash: parsed.messageHash - ) - - // Return most recently indexed match - return candidates.max(by: { $0.indexedAt < $1.indexedAt })?.messageID + let summary = ReactionParser.buildSummary(from: reactions) + do { + try await dataStore.updateMessageReactionSummary(messageID: reaction.messageID, summary: summary) + } catch { + logger.error("Failed to update reaction summary for message \(reaction.messageID): \(error.localizedDescription)") + return nil } - /// Attempts to process incoming text as a reaction - /// Returns true if handled as reaction, false to process as regular message - nonisolated func tryProcessAsReaction(_ text: String) -> ParsedReaction? { - ReactionParser.parse(text) + return ReactionPersistResult(messageID: reaction.messageID, summary: summary) + } + + // MARK: - DM Reactions + + /// Indexes a DM message for reaction matching and returns any pending reactions that now match + public func indexDMMessage( + id: UUID, + contactID: UUID, + text: String, + timestamp: UInt32 + ) async -> [PendingDMReaction] { + await messageCache.indexDM( + messageID: id, + contactID: contactID, + text: text, + timestamp: timestamp + ) + + // Check pending queue for matching reactions + let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) + let key = PendingDMReactionKey(contactID: contactID, messageHash: hash) + + guard let matched = pendingDMReactions.removeValue(forKey: key) else { + return [] } - /// Queues a reaction that couldn't find its target message - func queuePendingReaction( - parsed: ParsedReaction, - channelIndex: UInt8, - senderNodeName: String, - rawText: String, - radioID: UUID - ) { - let key = PendingReactionKey( - channelIndex: channelIndex, - targetSender: parsed.targetSender, - messageHash: parsed.messageHash - ) - let pending = PendingReaction( - parsed: parsed, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - rawText: rawText, - radioID: radioID, - receivedAt: Date() - ) - - if pendingReactions[key] != nil { - pendingReactions[key]!.append(pending) - } else { - pendingReactions[key] = [pending] - pendingOrder.append(key) - } - - evictIfNeeded() - logger.debug("Queued pending reaction \(parsed.emoji) for \(parsed.targetSender)") + pendingDMOrder.removeAll { $0 == key } + + logger.debug("Matched \(matched.count) pending DM reaction(s) to message \(id)") + + return matched + } + + /// Finds target DM message ID by hash and contact + func findDMTargetMessage(messageHash: String, contactID: UUID) async -> UUID? { + let candidates = await messageCache.lookupDM(contactID: contactID, messageHash: messageHash) + return candidates.max(by: { $0.indexedAt < $1.indexedAt })?.messageID + } + + /// Queues a DM reaction that couldn't find its target message + func queuePendingDMReaction( + parsed: ParsedDMReaction, + contactID: UUID, + senderName: String, + rawText: String, + radioID: UUID + ) { + let key = PendingDMReactionKey(contactID: contactID, messageHash: parsed.messageHash) + let pending = PendingDMReaction( + parsed: parsed, + contactID: contactID, + senderName: senderName, + rawText: rawText, + radioID: radioID, + receivedAt: Date() + ) + + if pendingDMReactions[key] != nil { + pendingDMReactions[key]!.append(pending) + } else { + pendingDMReactions[key] = [pending] + pendingDMOrder.append(key) } - /// Clears all pending reactions (call on disconnect) - func clearPendingReactions() { - let count = pendingReactions.values.reduce(0) { $0 + $1.count } - let dmCount = pendingDMReactions.values.reduce(0) { $0 + $1.count } - pendingReactions.removeAll() - pendingOrder.removeAll() - pendingDMReactions.removeAll() - pendingDMOrder.removeAll() - if count + dmCount > 0 { - logger.debug("Cleared \(count + dmCount) pending reaction(s)") - } - } + evictDMIfNeeded() + logger.debug("Queued pending DM reaction \(parsed.emoji)") + } - // MARK: - Persistence - - /// Persists a reaction and updates the message's reaction summary. - /// Logs errors instead of silently discarding them. - public func persistReactionAndUpdateSummary( - _ reaction: ReactionDTO, - using dataStore: any PersistenceStoreProtocol - ) async -> ReactionPersistResult? { - do { - try await dataStore.saveReaction(reaction) - } catch { - logger.error("Failed to save reaction for message \(reaction.messageID): \(error.localizedDescription)") - return nil - } + private func evictIfNeeded() { + var totalCount = pendingReactions.values.reduce(0) { $0 + $1.count } - let reactions: [ReactionDTO] - do { - reactions = try await dataStore.fetchReactions(for: reaction.messageID) - } catch { - logger.error("Failed to fetch reactions for message \(reaction.messageID): \(error.localizedDescription)") - return nil - } + while totalCount > Self.maxPendingReactions, let oldestKey = pendingOrder.first { + if var entries = pendingReactions[oldestKey], !entries.isEmpty { + entries.removeFirst() + totalCount -= 1 - let summary = ReactionParser.buildSummary(from: reactions) - do { - try await dataStore.updateMessageReactionSummary(messageID: reaction.messageID, summary: summary) - } catch { - logger.error("Failed to update reaction summary for message \(reaction.messageID): \(error.localizedDescription)") - return nil + if entries.isEmpty { + pendingReactions.removeValue(forKey: oldestKey) + pendingOrder.removeFirst() + } else { + pendingReactions[oldestKey] = entries } - - return ReactionPersistResult(messageID: reaction.messageID, summary: summary) + } else { + pendingOrder.removeFirst() + } } + } - // MARK: - DM Reactions - - /// Indexes a DM message for reaction matching and returns any pending reactions that now match - public func indexDMMessage( - id: UUID, - contactID: UUID, - text: String, - timestamp: UInt32 - ) async -> [PendingDMReaction] { - await messageCache.indexDM( - messageID: id, - contactID: contactID, - text: text, - timestamp: timestamp - ) - - // Check pending queue for matching reactions - let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) - let key = PendingDMReactionKey(contactID: contactID, messageHash: hash) - - guard let matched = pendingDMReactions.removeValue(forKey: key) else { - return [] - } - - pendingDMOrder.removeAll { $0 == key } - - logger.debug("Matched \(matched.count) pending DM reaction(s) to message \(id)") - - return matched - } + private func evictDMIfNeeded() { + var totalCount = pendingDMReactions.values.reduce(0) { $0 + $1.count } - /// Finds target DM message ID by hash and contact - func findDMTargetMessage(messageHash: String, contactID: UUID) async -> UUID? { - let candidates = await messageCache.lookupDM(contactID: contactID, messageHash: messageHash) - return candidates.max(by: { $0.indexedAt < $1.indexedAt })?.messageID - } + while totalCount > Self.maxPendingReactions, let oldestKey = pendingDMOrder.first { + if var entries = pendingDMReactions[oldestKey], !entries.isEmpty { + entries.removeFirst() + totalCount -= 1 - /// Queues a DM reaction that couldn't find its target message - func queuePendingDMReaction( - parsed: ParsedDMReaction, - contactID: UUID, - senderName: String, - rawText: String, - radioID: UUID - ) { - let key = PendingDMReactionKey(contactID: contactID, messageHash: parsed.messageHash) - let pending = PendingDMReaction( - parsed: parsed, - contactID: contactID, - senderName: senderName, - rawText: rawText, - radioID: radioID, - receivedAt: Date() - ) - - if pendingDMReactions[key] != nil { - pendingDMReactions[key]!.append(pending) + if entries.isEmpty { + pendingDMReactions.removeValue(forKey: oldestKey) + pendingDMOrder.removeFirst() } else { - pendingDMReactions[key] = [pending] - pendingDMOrder.append(key) - } - - evictDMIfNeeded() - logger.debug("Queued pending DM reaction \(parsed.emoji)") - } - - private func evictIfNeeded() { - var totalCount = pendingReactions.values.reduce(0) { $0 + $1.count } - - while totalCount > Self.maxPendingReactions, let oldestKey = pendingOrder.first { - if var entries = pendingReactions[oldestKey], !entries.isEmpty { - entries.removeFirst() - totalCount -= 1 - - if entries.isEmpty { - pendingReactions.removeValue(forKey: oldestKey) - pendingOrder.removeFirst() - } else { - pendingReactions[oldestKey] = entries - } - } else { - pendingOrder.removeFirst() - } - } - } - - private func evictDMIfNeeded() { - var totalCount = pendingDMReactions.values.reduce(0) { $0 + $1.count } - - while totalCount > Self.maxPendingReactions, let oldestKey = pendingDMOrder.first { - if var entries = pendingDMReactions[oldestKey], !entries.isEmpty { - entries.removeFirst() - totalCount -= 1 - - if entries.isEmpty { - pendingDMReactions.removeValue(forKey: oldestKey) - pendingDMOrder.removeFirst() - } else { - pendingDMReactions[oldestKey] = entries - } - } else { - pendingDMOrder.removeFirst() - } + pendingDMReactions[oldestKey] = entries } + } else { + pendingDMOrder.removeFirst() + } } + } } diff --git a/MC1Services/Sources/MC1Services/Services/RegionDiscoveryService.swift b/MC1Services/Sources/MC1Services/Services/RegionDiscoveryService.swift index 429fe747..40bf219b 100644 --- a/MC1Services/Sources/MC1Services/Services/RegionDiscoveryService.swift +++ b/MC1Services/Sources/MC1Services/Services/RegionDiscoveryService.swift @@ -8,180 +8,179 @@ import MeshCore /// onboarding redesign) use "region" to mean *user geographic location*. These /// two concepts share a word but no semantic overlap. public enum RegionDiscoveryService { - - /// Filter value for DISCOVER_REQ that requests only repeaters. - /// Mirrors `NodeDiscoveryFilter.repeaters.filterValue` in the app target. - public static let repeatersFilter: UInt8 = 0x04 - - /// How long to listen for DISCOVER_RESP before closing the event stream. - /// Matches the scan duration used by NodeDiscoveryViewModel so flood-routed - /// responses from distant mesh nodes have time to arrive. - public static let listenDuration: Duration = .seconds(15) - - /// Firmware error code for "contact table full" responses (`ERR_CODE_TABLE_FULL`). - private static let tableFullErrorCode: UInt8 = 3 - - public enum Outcome: Sendable { - /// The DISCOVER_REQ itself failed to send. - case sendFailed - /// No repeaters responded to the probe, or no query targets could be built. - case noRepeatersResponded - /// Could not load the contact pool used to route region queries. - case errorLoadingRepeaters - /// Discovery completed. `newRegions` is filtered against the caller's - /// `knownRegions` and sorted. `allRepeatersTableFull` is `true` when every - /// successful query returned `ERR_CODE_TABLE_FULL` from the repeater. - case completed(newRegions: [String], allRepeatersTableFull: Bool) - } - - /// Runs the discovery probe and aggregates regions from all responders. - /// - /// - Parameters: - /// - session: Active mesh session for the connected device. - /// - contactService: Source of the caller's existing contacts; used to find - /// routing data for repeaters the user has already added. - /// - dataStore: Optional offline data store; when provided, responders that - /// aren't contacts are matched against the discovered-nodes table. - /// - radioID: Radio identifier used to scope contact and discovered-node lookups. - /// - knownRegions: Regions the caller already knows about; subtracted from the result. - /// - supportsAdHocRequest: Whether the radio can query a repeater that isn't a - /// contact (firmware v1.16+, `DeviceDTO.supportsAdHocRepeaterRequest`). When - /// `false`, only responders the user has already added as contacts are queried; - /// anonymous requests to non-contact keys would be rejected by the local radio. - public static func discover( - session: MeshCoreSession, - contactService: ContactService, - dataStore: PersistenceStore?, - radioID: UUID, - knownRegions: [String], - supportsAdHocRequest: Bool - ) async -> Outcome { - let discoveredPubkeys: Set - do { - let tag = try await session.sendNodeDiscoverRequest( - filter: repeatersFilter, - prefixOnly: false - ) - let tagData = withUnsafeBytes(of: tag.littleEndian) { Data($0) } - - let (subscriptionID, events) = await session.eventsTracked() - let listenTask = Task { () -> Set in - var keys = Set() - for await event in events { - if case .discoverResponse(let response) = event, - response.tag == tagData { - keys.insert(response.publicKey) - } - } - return keys - } - - try? await Task.sleep(for: listenDuration) - await session.finishEvents(id: subscriptionID) - discoveredPubkeys = await listenTask.value - } catch { - return .sendFailed + /// Filter value for DISCOVER_REQ that requests only repeaters. + /// Mirrors `NodeDiscoveryFilter.repeaters.filterValue` in the app target. + public static let repeatersFilter: UInt8 = 0x04 + + /// How long to listen for DISCOVER_RESP before closing the event stream. + /// Matches the scan duration used by NodeDiscoveryViewModel so flood-routed + /// responses from distant mesh nodes have time to arrive. + public static let listenDuration: Duration = .seconds(15) + + /// Firmware error code for "contact table full" responses (`ERR_CODE_TABLE_FULL`). + private static let tableFullErrorCode: UInt8 = 3 + + public enum Outcome: Sendable { + /// The DISCOVER_REQ itself failed to send. + case sendFailed + /// No repeaters responded to the probe, or no query targets could be built. + case noRepeatersResponded + /// Could not load the contact pool used to route region queries. + case errorLoadingRepeaters + /// Discovery completed. `newRegions` is filtered against the caller's + /// `knownRegions` and sorted. `allRepeatersTableFull` is `true` when every + /// successful query returned `ERR_CODE_TABLE_FULL` from the repeater. + case completed(newRegions: [String], allRepeatersTableFull: Bool) + } + + /// Runs the discovery probe and aggregates regions from all responders. + /// + /// - Parameters: + /// - session: Active mesh session for the connected device. + /// - contactService: Source of the caller's existing contacts; used to find + /// routing data for repeaters the user has already added. + /// - dataStore: Optional offline data store; when provided, responders that + /// aren't contacts are matched against the discovered-nodes table. + /// - radioID: Radio identifier used to scope contact and discovered-node lookups. + /// - knownRegions: Regions the caller already knows about; subtracted from the result. + /// - supportsAdHocRequest: Whether the radio can query a repeater that isn't a + /// contact (firmware v1.16+, `DeviceDTO.supportsAdHocRepeaterRequest`). When + /// `false`, only responders the user has already added as contacts are queried; + /// anonymous requests to non-contact keys would be rejected by the local radio. + public static func discover( + session: MeshCoreSession, + contactService: ContactService, + dataStore: PersistenceStore?, + radioID: UUID, + knownRegions: [String], + supportsAdHocRequest: Bool + ) async -> Outcome { + let discoveredPubkeys: Set + do { + let tag = try await session.sendNodeDiscoverRequest( + filter: repeatersFilter, + prefixOnly: false + ) + let tagData = withUnsafeBytes(of: tag.littleEndian) { Data($0) } + + let (subscriptionID, events) = await session.eventsTracked() + let listenTask = Task { () -> Set in + var keys = Set() + for await event in events { + if case let .discoverResponse(response) = event, + response.tag == tagData { + keys.insert(response.publicKey) + } } + return keys + } + + try? await Task.sleep(for: listenDuration) + await session.finishEvents(id: subscriptionID) + discoveredPubkeys = await listenTask.value + } catch { + return .sendFailed + } - guard !Task.isCancelled else { return .sendFailed } - - if discoveredPubkeys.isEmpty { - return .noRepeatersResponded - } + guard !Task.isCancelled else { return .sendFailed } - let queryTargets: [MeshContact] - do { - let contacts = try await contactService.getContacts(radioID: radioID) - let discoveredNodes = (try? await dataStore?.fetchDiscoveredNodes(radioID: radioID)) ?? [] - queryTargets = buildRegionQueryTargets( - responders: discoveredPubkeys, - contacts: contacts, - discoveredNodes: discoveredNodes, - supportsAdHocRequest: supportsAdHocRequest - ) - } catch { - return .errorLoadingRepeaters - } - - if queryTargets.isEmpty { - return .noRepeatersResponded - } + if discoveredPubkeys.isEmpty { + return .noRepeatersResponded + } - var allRegions = Set() - var anyTableFull = false - - await withTaskGroup(of: RegionQueryOutcome.self) { group in - for meshContact in queryTargets { - guard !Task.isCancelled else { break } - group.addTask { - do { - let regions = try await session.requestRegions(from: meshContact) - return .regions(regions) - } catch let MeshCoreError.deviceError(code) where code == tableFullErrorCode { - return .tableFull - } catch { - return .otherFailure - } - } - } - for await outcome in group { - switch outcome { - case .regions(let regions): - allRegions.formUnion(regions) - case .tableFull: - anyTableFull = true - case .otherFailure: - break - } - } - } + let queryTargets: [MeshContact] + do { + let contacts = try await contactService.getContacts(radioID: radioID) + let discoveredNodes = await (try? dataStore?.fetchDiscoveredNodes(radioID: radioID)) ?? [] + queryTargets = buildRegionQueryTargets( + responders: discoveredPubkeys, + contacts: contacts, + discoveredNodes: discoveredNodes, + supportsAdHocRequest: supportsAdHocRequest + ) + } catch { + return .errorLoadingRepeaters + } - let knownSet = Set(knownRegions) - let newRegions = allRegions.subtracting(knownSet).sorted() - return .completed(newRegions: newRegions, allRepeatersTableFull: anyTableFull) + if queryTargets.isEmpty { + return .noRepeatersResponded } - /// Builds the `MeshContact` query pool used to fetch regions from each responder. - /// Prefers contact records (they carry direct routing data when available) and fills - /// in from the discovered-nodes table for responders the user has not added as contacts. - /// - /// Non-contact responders are only included when `supportsAdHocRequest` is `true`: - /// older firmware rejects an anonymous request to a key it doesn't hold as a contact, - /// so querying them would only produce spurious failures. - static func buildRegionQueryTargets( - responders: Set, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - supportsAdHocRequest: Bool - ) -> [MeshContact] { - var byKey: [Data: MeshContact] = [:] - for contact in contacts where contact.type == .repeater && responders.contains(contact.publicKey) { - byKey[contact.publicKey] = contact.toContactFrame().toMeshContact() + var allRegions = Set() + var anyTableFull = false + + await withTaskGroup(of: RegionQueryOutcome.self) { group in + for meshContact in queryTargets { + guard !Task.isCancelled else { break } + group.addTask { + do { + let regions = try await session.requestRegions(from: meshContact) + return .regions(regions) + } catch let MeshCoreError.deviceError(code) where code == tableFullErrorCode { + return .tableFull + } catch { + return .otherFailure + } } - guard supportsAdHocRequest else { return Array(byKey.values) } - for node in discoveredNodes where node.nodeType == .repeater - && responders.contains(node.publicKey) - && byKey[node.publicKey] == nil { - let frame = ContactFrame( - publicKey: node.publicKey, - type: node.nodeType, - flags: 0, - outPathLength: node.outPathLength, - outPath: node.outPath, - name: node.name, - lastAdvertTimestamp: node.lastAdvertTimestamp, - latitude: node.latitude, - longitude: node.longitude, - lastModified: 0 - ) - byKey[node.publicKey] = frame.toMeshContact() + } + for await outcome in group { + switch outcome { + case let .regions(regions): + allRegions.formUnion(regions) + case .tableFull: + anyTableFull = true + case .otherFailure: + break } - return Array(byKey.values) + } } - private enum RegionQueryOutcome: Sendable { - case regions([String]) - case tableFull - case otherFailure + let knownSet = Set(knownRegions) + let newRegions = allRegions.subtracting(knownSet).sorted() + return .completed(newRegions: newRegions, allRepeatersTableFull: anyTableFull) + } + + /// Builds the `MeshContact` query pool used to fetch regions from each responder. + /// Prefers contact records (they carry direct routing data when available) and fills + /// in from the discovered-nodes table for responders the user has not added as contacts. + /// + /// Non-contact responders are only included when `supportsAdHocRequest` is `true`: + /// older firmware rejects an anonymous request to a key it doesn't hold as a contact, + /// so querying them would only produce spurious failures. + static func buildRegionQueryTargets( + responders: Set, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + supportsAdHocRequest: Bool + ) -> [MeshContact] { + var byKey: [Data: MeshContact] = [:] + for contact in contacts where contact.type == .repeater && responders.contains(contact.publicKey) { + byKey[contact.publicKey] = contact.toContactFrame().toMeshContact() + } + guard supportsAdHocRequest else { return Array(byKey.values) } + for node in discoveredNodes where node.nodeType == .repeater + && responders.contains(node.publicKey) + && byKey[node.publicKey] == nil { + let frame = ContactFrame( + publicKey: node.publicKey, + type: node.nodeType, + flags: 0, + outPathLength: node.outPathLength, + outPath: node.outPath, + name: node.name, + lastAdvertTimestamp: node.lastAdvertTimestamp, + latitude: node.latitude, + longitude: node.longitude, + lastModified: 0 + ) + byKey[node.publicKey] = frame.toMeshContact() } + return Array(byKey.values) + } + + private enum RegionQueryOutcome { + case regions([String]) + case tableFull + case otherFailure + } } diff --git a/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift b/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift index b947eca0..0c11f9fd 100644 --- a/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift +++ b/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift @@ -7,175 +7,177 @@ import Foundation /// mean a *firmware mesh region* (named flood-routing scope on a repeater). /// This file's "region" vocabulary refers to *user geographic location*. public enum RegionalAreas { + public struct Country: Sendable, Identifiable { + public let id: String // ISO α-2 + public let subdivisions: [Subdivision]? - public struct Country: Sendable, Identifiable { - public let id: String // ISO α-2 - public let subdivisions: [Subdivision]? - - public var localizedName: String { - Locale.current.localizedString(forRegionCode: id) ?? id - } + public var localizedName: String { + Locale.current.localizedString(forRegionCode: id) ?? id } + } - public struct Subdivision: Sendable, Identifiable { - public let id: String // ISO 3166-2 code - public let normalizedNames: Set // lowercased, diacritic-folded matchers from CLPlacemark - public let nameKey: String // L10n key under Settings.strings - - public init(id: String, normalizedNames: Set, nameKey: String) { - self.id = id - self.normalizedNames = normalizedNames - self.nameKey = nameKey - } - } + public struct Subdivision: Sendable, Identifiable { + public let id: String // ISO 3166-2 code + public let normalizedNames: Set // lowercased, diacritic-folded matchers from CLPlacemark + public let nameKey: String // L10n key under Settings.strings - public static let usSubdivisions: [Subdivision] = [ - Subdivision(id: "US-CA", - normalizedNames: ["california", "ca"], - nameKey: "region.subdivision.US-CA"), - ] - - public static let auSubdivisions: [Subdivision] = [ - Subdivision(id: "AU-QLD", - normalizedNames: ["queensland", "qld"], - nameKey: "region.subdivision.AU-QLD"), - Subdivision(id: "AU-SA", - normalizedNames: ["south australia", "sa"], - nameKey: "region.subdivision.AU-SA"), - Subdivision(id: "AU-WA", - normalizedNames: ["western australia", "wa"], - nameKey: "region.subdivision.AU-WA"), - ] - - /// ISO α-2 → `RadioRegion` mapping. Mexico (MX), Africa, and - /// South America are intentionally absent — these countries fall through - /// `recommended(for:)` to the empty-region fallback. - public static let continents: [String: RadioRegion] = [ - // North America - "US": .northAmerica, "CA": .northAmerica, - // Europe - "GB": .europe, "IE": .europe, "DE": .europe, "FR": .europe, - "IT": .europe, "ES": .europe, "PT": .europe, "NL": .europe, - "BE": .europe, "CH": .europe, "AT": .europe, "CZ": .europe, - "PL": .europe, "DK": .europe, "SE": .europe, "NO": .europe, - "FI": .europe, "GR": .europe, "HU": .europe, "RO": .europe, - // Oceania - "AU": .oceania, "NZ": .oceania, - // Asia - "VN": .asia, "TH": .asia, "MY": .asia, "SG": .asia, - "PH": .asia, "ID": .asia, "JP": .asia, "KR": .asia, - ] - - /// `countries` sorted by `localizedName` once at first access. The order freezes for - /// the process lifetime — acceptable because iOS app-language changes require relaunch, - /// so the user never sees a stale order in practice. - public static let countriesSortedByLocalizedName: [Country] = countries.sorted { - $0.localizedName < $1.localizedName + public init(id: String, normalizedNames: Set, nameKey: String) { + self.id = id + self.normalizedNames = normalizedNames + self.nameKey = nameKey } - - public static let countries: [Country] = [ - Country(id: "US", subdivisions: usSubdivisions), - Country(id: "CA", subdivisions: nil), - Country(id: "AU", subdivisions: auSubdivisions), - Country(id: "NZ", subdivisions: nil), - Country(id: "GB", subdivisions: nil), - Country(id: "IE", subdivisions: nil), - Country(id: "DE", subdivisions: nil), - Country(id: "FR", subdivisions: nil), - Country(id: "IT", subdivisions: nil), - Country(id: "ES", subdivisions: nil), - Country(id: "PT", subdivisions: nil), - Country(id: "NL", subdivisions: nil), - Country(id: "BE", subdivisions: nil), - Country(id: "CH", subdivisions: nil), - Country(id: "AT", subdivisions: nil), - Country(id: "CZ", subdivisions: nil), - Country(id: "PL", subdivisions: nil), - Country(id: "DK", subdivisions: nil), - Country(id: "SE", subdivisions: nil), - Country(id: "NO", subdivisions: nil), - Country(id: "FI", subdivisions: nil), - Country(id: "GR", subdivisions: nil), - Country(id: "HU", subdivisions: nil), - Country(id: "RO", subdivisions: nil), - Country(id: "VN", subdivisions: nil), - Country(id: "TH", subdivisions: nil), - Country(id: "MY", subdivisions: nil), - Country(id: "SG", subdivisions: nil), - Country(id: "PH", subdivisions: nil), - Country(id: "ID", subdivisions: nil), - Country(id: "JP", subdivisions: nil), - Country(id: "KR", subdivisions: nil), - ] - - /// Normalized US county names (lowercased, diacritic-folded, "county" suffix stripped), - /// indexed by ISO 3166-2 state code. Only states with county-scoped presets are filled. - public static let usCounties: [String: Set] = [ - "US-CA": [ - "los angeles", "orange", "san diego", "riverside", "san bernardino", - "ventura", "imperial", "kern", "santa barbara", "san luis obispo", - ], - ] - - /// Returns the subdivisions catalog for an ISO α-2 country code, or an empty array - /// when the country has no sub-region presets (or the code is unknown). - public static func subdivisions(for country: String?) -> [Subdivision] { - guard let country, - let entry = countries.first(where: { $0.id == country }) else { return [] } - return entry.subdivisions ?? [] + } + + public static let usSubdivisions: [Subdivision] = [ + Subdivision(id: "US-CA", + normalizedNames: ["california", "ca"], + nameKey: "region.subdivision.US-CA"), + ] + + public static let auSubdivisions: [Subdivision] = [ + Subdivision(id: "AU-QLD", + normalizedNames: ["queensland", "qld"], + nameKey: "region.subdivision.AU-QLD"), + Subdivision(id: "AU-SA", + normalizedNames: ["south australia", "sa"], + nameKey: "region.subdivision.AU-SA"), + Subdivision(id: "AU-WA", + normalizedNames: ["western australia", "wa"], + nameKey: "region.subdivision.AU-WA"), + ] + + /// ISO α-2 → `RadioRegion` mapping. Mexico (MX) and Africa are intentionally + /// absent — those countries fall through `recommended(for:)` to the + /// empty-region fallback. South America is currently limited to Chile (CL). + public static let continents: [String: RadioRegion] = [ + // North America + "US": .northAmerica, "CA": .northAmerica, + // South America + "CL": .southAmerica, + // Europe + "GB": .europe, "IE": .europe, "DE": .europe, "FR": .europe, + "IT": .europe, "ES": .europe, "PT": .europe, "NL": .europe, + "BE": .europe, "CH": .europe, "AT": .europe, "CZ": .europe, + "PL": .europe, "DK": .europe, "SE": .europe, "NO": .europe, + "FI": .europe, "GR": .europe, "HU": .europe, "RO": .europe, + // Oceania + "AU": .oceania, "NZ": .oceania, + // Asia + "VN": .asia, "TH": .asia, "MY": .asia, "SG": .asia, + "PH": .asia, "ID": .asia, "JP": .asia, "KR": .asia, + ] + + /// `countries` sorted by `localizedName` once at first access. The order freezes for + /// the process lifetime — acceptable because iOS app-language changes require relaunch, + /// so the user never sees a stale order in practice. + public static let countriesSortedByLocalizedName: [Country] = countries.sorted { + $0.localizedName < $1.localizedName + } + + public static let countries: [Country] = [ + Country(id: "US", subdivisions: usSubdivisions), + Country(id: "CA", subdivisions: nil), + Country(id: "CL", subdivisions: nil), + Country(id: "AU", subdivisions: auSubdivisions), + Country(id: "NZ", subdivisions: nil), + Country(id: "GB", subdivisions: nil), + Country(id: "IE", subdivisions: nil), + Country(id: "DE", subdivisions: nil), + Country(id: "FR", subdivisions: nil), + Country(id: "IT", subdivisions: nil), + Country(id: "ES", subdivisions: nil), + Country(id: "PT", subdivisions: nil), + Country(id: "NL", subdivisions: nil), + Country(id: "BE", subdivisions: nil), + Country(id: "CH", subdivisions: nil), + Country(id: "AT", subdivisions: nil), + Country(id: "CZ", subdivisions: nil), + Country(id: "PL", subdivisions: nil), + Country(id: "DK", subdivisions: nil), + Country(id: "SE", subdivisions: nil), + Country(id: "NO", subdivisions: nil), + Country(id: "FI", subdivisions: nil), + Country(id: "GR", subdivisions: nil), + Country(id: "HU", subdivisions: nil), + Country(id: "RO", subdivisions: nil), + Country(id: "VN", subdivisions: nil), + Country(id: "TH", subdivisions: nil), + Country(id: "MY", subdivisions: nil), + Country(id: "SG", subdivisions: nil), + Country(id: "PH", subdivisions: nil), + Country(id: "ID", subdivisions: nil), + Country(id: "JP", subdivisions: nil), + Country(id: "KR", subdivisions: nil), + ] + + /// Normalized US county names (lowercased, diacritic-folded, "county" suffix stripped), + /// indexed by ISO 3166-2 state code. Only states with county-scoped presets are filled. + public static let usCounties: [String: Set] = [ + "US-CA": [ + "los angeles", "orange", "san diego", "riverside", "san bernardino", + "ventura", "imperial", "kern", "santa barbara", "san luis obispo", + ], + ] + + /// Returns the subdivisions catalog for an ISO α-2 country code, or an empty array + /// when the country has no sub-region presets (or the code is unknown). + public static func subdivisions(for country: String?) -> [Subdivision] { + guard let country, + let entry = countries.first(where: { $0.id == country }) else { return [] } + return entry.subdivisions ?? [] + } + + /// Returns the ISO 3166-2 subdivision code matching a normalized administrative area name. + /// Matches against `Subdivision.normalizedNames`, which contains both the English long form + /// (e.g. "california") and short codes (e.g. "ca") so `CLPlacemark.administrativeArea` + /// returning either form resolves correctly. Returns nil on miss; recommendation falls + /// to country tier. + public static func matchSubdivision(country: String, normalized: String?) -> String? { + guard let normalized, + let entry = countries.first(where: { $0.id == country }), + let subdivisions = entry.subdivisions else { return nil } + return subdivisions.first(where: { $0.normalizedNames.contains(normalized) })?.id + } + + /// Returns the normalized county key when (country, state, name) all match the catalog. + /// US counties have no ISO identifier — the normalized name *is* the key. + public static func matchCounty(country: String, state: String?, normalized: String?) -> String? { + guard country == "US", + let state, + let normalized, + let countiesForState = usCounties[state], + countiesForState.contains(normalized) else { return nil } + return normalized + } + + /// Returns a localized display name for Settings detail and Radio footer. + /// Short form for unambiguous US states; disambiguated "State, Country" for ambiguous regions. + public static func displayName(for region: RegionSelection) -> String { + let countryName = Locale.current.localizedString(forRegionCode: region.countryCode) ?? region.countryCode + guard let admin = region.administrativeAreaCode else { return countryName } + let stateName = subdivisionDisplayName(admin) ?? admin + if region.countryCode == "US" || region.countryCode == "CA" { + return stateName } - - /// Returns the ISO 3166-2 subdivision code matching a normalized administrative area name. - /// Matches against `Subdivision.normalizedNames`, which contains both the English long form - /// (e.g. "california") and short codes (e.g. "ca") so `CLPlacemark.administrativeArea` - /// returning either form resolves correctly. Returns nil on miss; recommendation falls - /// to country tier. - public static func matchSubdivision(country: String, normalized: String?) -> String? { - guard let normalized, - let entry = countries.first(where: { $0.id == country }), - let subdivisions = entry.subdivisions else { return nil } - return subdivisions.first(where: { $0.normalizedNames.contains(normalized) })?.id - } - - /// Returns the normalized county key when (country, state, name) all match the catalog. - /// US counties have no ISO identifier — the normalized name *is* the key. - public static func matchCounty(country: String, state: String?, normalized: String?) -> String? { - guard country == "US", - let state, - let normalized, - let countiesForState = usCounties[state], - countiesForState.contains(normalized) else { return nil } - return normalized - } - - /// Returns a localized display name for Settings detail and Radio footer. - /// Short form for unambiguous US states; disambiguated "State, Country" for ambiguous regions. - public static func displayName(for region: RegionSelection) -> String { - let countryName = Locale.current.localizedString(forRegionCode: region.countryCode) ?? region.countryCode - guard let admin = region.administrativeAreaCode else { return countryName } - let stateName = subdivisionDisplayName(admin) ?? admin - if region.countryCode == "US" || region.countryCode == "CA" { - return stateName - } - return "\(stateName), \(countryName)" - } - - /// Returns the localized subdivision name for an ISO 3166-2 code (e.g. "US-CA" → "California"). - /// Looks up `region.subdivision.` in the host app bundle's `Settings.strings` and falls - /// back to the English value when the key is missing, so unit tests running outside an app - /// bundle still resolve a deterministic name. - public static func subdivisionDisplayName(_ code: String) -> String? { - guard let englishFallback = englishSubdivisionFallbacks[code] else { return nil } - let key = "region.subdivision.\(code)" - return Bundle.main.localizedString(forKey: key, value: englishFallback, table: "Settings") - } - - /// English values used as the `value:` fallback in `bundle.localizedString` and as the source - /// of truth for `Settings.strings` `region.subdivision.*` entries. - private static let englishSubdivisionFallbacks: [String: String] = [ - "US-CA": "California", - "AU-QLD": "Queensland", - "AU-SA": "South Australia", - "AU-WA": "Western Australia", - ] + return "\(stateName), \(countryName)" + } + + /// Returns the localized subdivision name for an ISO 3166-2 code (e.g. "US-CA" → "California"). + /// Looks up `region.subdivision.` in the host app bundle's `Settings.strings` and falls + /// back to the English value when the key is missing, so unit tests running outside an app + /// bundle still resolve a deterministic name. + public static func subdivisionDisplayName(_ code: String) -> String? { + guard let englishFallback = englishSubdivisionFallbacks[code] else { return nil } + let key = "region.subdivision.\(code)" + return Bundle.main.localizedString(forKey: key, value: englishFallback, table: "Settings") + } + + /// English values used as the `value:` fallback in `bundle.localizedString` and as the source + /// of truth for `Settings.strings` `region.subdivision.*` entries. + private static let englishSubdivisionFallbacks: [String: String] = [ + "US-CA": "California", + "AU-QLD": "Queensland", + "AU-SA": "South Australia", + "AU-WA": "Western Australia", + ] } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeEvent.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeEvent.swift index 1e246478..bf78f4c7 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeEvent.swift @@ -3,7 +3,7 @@ import Foundation /// Remote-node session notifications broadcast by `RemoteNodeService.events()`. /// The stream is multicast: every subscriber receives every event. public enum RemoteNodeEvent: Sendable { - /// A remote-node session's connection state changed (login, logout, - /// keep-alive failure, or BLE loss). - case sessionStateChanged(sessionID: UUID, isConnected: Bool) + /// A remote-node session's connection state changed (login, logout, + /// keep-alive failure, or BLE loss). + case sessionStateChanged(sessionID: UUID, isConnected: Bool) } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift index 4aad54ec..2368d3dc 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift @@ -2,211 +2,210 @@ import Foundation import MeshCore extension RemoteNodeService { + // MARK: - CLI Commands + + /// Send a CLI command to a remote node and wait for response (admin only). + /// - Parameters: + /// - sessionID: The remote node session ID. + /// - command: The CLI command to send. + /// - timeout: Hard maximum time to wait for response (default 10 seconds). + /// - Returns: The CLI response text from the remote node. + public func sendCLICommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - // MARK: - CLI Commands - - /// Send a CLI command to a remote node and wait for response (admin only). - /// - Parameters: - /// - sessionID: The remote node session ID. - /// - command: The CLI command to send. - /// - timeout: Hard maximum time to wait for response (default 10 seconds). - /// - Returns: The CLI response text from the remote node. - public func sendCLICommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + guard remoteSession.isAdmin else { + throw RemoteNodeError.permissionDenied + } - guard remoteSession.isAdmin else { - throw RemoteNodeError.permissionDenied - } + // Log CLI command (with password redaction) + await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) - // Log CLI command (with password redaction) - await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) - - let destinationPrefix = Data(remoteSession.publicKey.prefix(6)) - let requestTimestamp = Date() - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - let request = PendingCLIRequest( - command: command, - continuation: continuation, - timestamp: requestTimestamp - ) - - if pendingCLIRequests[destinationPrefix] == nil { - pendingCLIRequests[destinationPrefix] = [] - } - pendingCLIRequests[destinationPrefix]!.append(request) - - Task { [self] in - let sentInfo: MessageSentInfo - do { - sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) - } catch { - if var requests = pendingCLIRequests[destinationPrefix], - let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { - let failed = requests.remove(at: index) - pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests - let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) - failed.continuation.resume(throwing: RemoteNodeError.sessionError(meshError)) - } - return - } - - let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) - let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) - while ContinuousClock.now < deadline { - if let requests = pendingCLIRequests[destinationPrefix], - !requests.contains(where: { $0.timestamp == requestTimestamp }) { - return - } else if pendingCLIRequests[destinationPrefix] == nil { - return - } - - let remaining = deadline - .now - let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) - _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) - } - - if var requests = pendingCLIRequests[destinationPrefix], - let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { - let timedOut = requests.remove(at: index) - pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests - timedOut.continuation.resume(throwing: RemoteNodeError.timeout) - } - } + let destinationPrefix = Data(remoteSession.publicKey.prefix(6)) + let requestTimestamp = Date() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let request = PendingCLIRequest( + command: command, + continuation: continuation, + timestamp: requestTimestamp + ) + + if pendingCLIRequests[destinationPrefix] == nil { + pendingCLIRequests[destinationPrefix] = [] + } + pendingCLIRequests[destinationPrefix]!.append(request) + + Task { [self] in + let sentInfo: MessageSentInfo + do { + sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) + } catch { + if var requests = pendingCLIRequests[destinationPrefix], + let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { + let failed = requests.remove(at: index) + pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests + let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) + failed.continuation.resume(throwing: RemoteNodeError.sessionError(meshError)) } - } onCancel: { [weak self] in - Task { [weak self] in - await self?.cancelPendingCLIRequest(for: destinationPrefix, timestamp: requestTimestamp) + return + } + + let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) + let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) + while ContinuousClock.now < deadline { + if let requests = pendingCLIRequests[destinationPrefix], + !requests.contains(where: { $0.timestamp == requestTimestamp }) { + return + } else if pendingCLIRequests[destinationPrefix] == nil { + return } + + let remaining = deadline - .now + let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) + _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) + } + + if var requests = pendingCLIRequests[destinationPrefix], + let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { + let timedOut = requests.remove(at: index) + pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests + timedOut.continuation.resume(throwing: RemoteNodeError.timeout) + } } + } + } onCancel: { [weak self] in + Task { [weak self] in + await self?.cancelPendingCLIRequest(for: destinationPrefix, timestamp: requestTimestamp) + } + } + } + + /// Send a raw CLI command to a remote node using FIFO response matching (admin only). + /// Unlike `sendCLICommand`, this method accepts any response from the target node + /// without content-based matching. Used by CLI tool for passthrough commands. + /// - Parameters: + /// - sessionID: The remote node session ID. + /// - command: The CLI command to send. + /// - timeout: Hard maximum time to wait for response (default 10 seconds). + /// - Returns: The raw response text from the remote node. + public func sendRawCLICommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound } - /// Send a raw CLI command to a remote node using FIFO response matching (admin only). - /// Unlike `sendCLICommand`, this method accepts any response from the target node - /// without content-based matching. Used by CLI tool for passthrough commands. - /// - Parameters: - /// - sessionID: The remote node session ID. - /// - command: The CLI command to send. - /// - timeout: Hard maximum time to wait for response (default 10 seconds). - /// - Returns: The raw response text from the remote node. - public func sendRawCLICommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + guard remoteSession.isAdmin else { + throw RemoteNodeError.permissionDenied + } - guard remoteSession.isAdmin else { - throw RemoteNodeError.permissionDenied - } + // Log CLI command (with password redaction) + await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) - // Log CLI command (with password redaction) - await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) + let destinationPrefix = Data(remoteSession.publicKey.prefix(6)) - let destinationPrefix = Data(remoteSession.publicKey.prefix(6)) + // Only one raw CLI request per sender at a time (FIFO matching) + guard pendingRawCLIRequests[destinationPrefix] == nil else { + throw RemoteNodeError.sessionError(.connectionLost(underlying: nil)) + } - // Only one raw CLI request per sender at a time (FIFO matching) - guard pendingRawCLIRequests[destinationPrefix] == nil else { - throw RemoteNodeError.sessionError(.connectionLost(underlying: nil)) - } + // Register continuation BEFORE sending to avoid race condition + // Use withTaskCancellationHandler to clean up pending request if caller cancels + let response = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + pendingRawCLIRequests[destinationPrefix] = continuation + + Task { [self] in + // Send CLI command + let sentInfo: MessageSentInfo + do { + sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) + } catch { + // Send failed - remove pending request and resume with error + if let pending = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { + let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) + pending.resume(throwing: RemoteNodeError.sessionError(meshError)) + } + return + } + + let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) - // Register continuation BEFORE sending to avoid race condition - // Use withTaskCancellationHandler to clean up pending request if caller cancels - let response = try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - pendingRawCLIRequests[destinationPrefix] = continuation - - Task { [self] in - // Send CLI command - let sentInfo: MessageSentInfo - do { - sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) - } catch { - // Send failed - remove pending request and resume with error - if let pending = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { - let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) - pending.resume(throwing: RemoteNodeError.sessionError(meshError)) - } - return - } - - let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) - - // Poll for response - let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) - while ContinuousClock.now < deadline { - // Check if our request was already satisfied - guard pendingRawCLIRequests[destinationPrefix] != nil else { - return // Request was matched and removed by handleCLIResponse - } - - // Check for task cancellation - if Task.isCancelled { - if let cancelled = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { - cancelled.resume(throwing: CancellationError()) - } - return - } - - // Poll device for pending messages - let remaining = deadline - .now - let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) - _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) - } - - // Timeout - remove pending request and resume with error - if let timedOut = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { - timedOut.resume(throwing: RemoteNodeError.timeout) - } - } + // Poll for response + let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) + while ContinuousClock.now < deadline { + // Check if our request was already satisfied + guard pendingRawCLIRequests[destinationPrefix] != nil else { + return // Request was matched and removed by handleCLIResponse } - } onCancel: { [weak self] in - Task { [weak self] in - await self?.cancelPendingRawCLIRequest(for: destinationPrefix) + + // Check for task cancellation + if Task.isCancelled { + if let cancelled = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { + cancelled.resume(throwing: CancellationError()) + } + return } - } - // Clear stored password after admin password change - await handlePasswordChangeIfNeeded(command: command, sessionID: sessionID) + // Poll device for pending messages + let remaining = deadline - .now + let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) + _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) + } - return response + // Timeout - remove pending request and resume with error + if let timedOut = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { + timedOut.resume(throwing: RemoteNodeError.timeout) + } + } + } + } onCancel: { [weak self] in + Task { [weak self] in + await self?.cancelPendingRawCLIRequest(for: destinationPrefix) + } } - /// Clear stored password if command is an admin password change. - private func handlePasswordChangeIfNeeded(command: String, sessionID: UUID) async { - let lower = command.lowercased().trimmingCharacters(in: .whitespaces) + // Clear stored password after admin password change + await handlePasswordChangeIfNeeded(command: command, sessionID: sessionID) - // Only admin password changes, not guest - guard lower.hasPrefix("password ") && !lower.contains("guest.password") else { - return - } + return response + } - guard let session = try? await dataStore.fetchRemoteNodeSession(id: sessionID) else { - return - } + /// Clear stored password if command is an admin password change. + private func handlePasswordChangeIfNeeded(command: String, sessionID: UUID) async { + let lower = command.lowercased().trimmingCharacters(in: .whitespaces) - do { - try await keychainService.deletePassword(forNodeKey: session.publicKey) - logger.info("Cleared stored password after password change for session \(sessionID)") - } catch { - logger.warning("Failed to clear stored password for session \(sessionID): \(error)") - // Next login fails naturally - user re-enters password, overwrites stale credential - } + // Only admin password changes, not guest + guard lower.hasPrefix("password "), !lower.contains("guest.password") else { + return } - /// Cancel a pending raw CLI request when the calling task is cancelled. - private func cancelPendingRawCLIRequest(for prefix: Data) { - if let cancelled = pendingRawCLIRequests.removeValue(forKey: prefix) { - cancelled.resume(throwing: CancellationError()) - } + guard let session = try? await dataStore.fetchRemoteNodeSession(id: sessionID) else { + return + } + + do { + try await keychainService.deletePassword(forNodeKey: session.publicKey) + logger.info("Cleared stored password after password change for session \(sessionID)") + } catch { + logger.warning("Failed to clear stored password for session \(sessionID): \(error)") + // Next login fails naturally - user re-enters password, overwrites stale credential + } + } + + /// Cancel a pending raw CLI request when the calling task is cancelled. + private func cancelPendingRawCLIRequest(for prefix: Data) { + if let cancelled = pendingRawCLIRequests.removeValue(forKey: prefix) { + cancelled.resume(throwing: CancellationError()) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift index e42d5f06..b4885afd 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift @@ -1,349 +1,406 @@ import Foundation import MeshCore -extension RemoteNodeService { - - // MARK: - Login - - /// Login to a remote node. - /// Works for both room servers and repeaters. - /// - Parameters: - /// - sessionID: The remote session ID. - /// - password: Optional password (uses stored password if nil). - /// - pathLength: Path length hint for timeout calculation. - /// - onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. - public func login( - sessionID: UUID, - password: String? = nil, - pathLength: UInt8 = 0, - onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil - ) async throws -> LoginResult { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } +public extension RemoteNodeService { + // MARK: - Login + + /// Login to a remote node. + /// Works for both room servers and repeaters. + /// - Parameters: + /// - sessionID: The remote session ID. + /// - password: Optional password (uses stored password if nil). + /// - pathLength: Path length hint for timeout calculation. + /// - onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. + func login( + sessionID: UUID, + password: String? = nil, + pathLength: UInt8 = 0, + onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil + ) async throws -> LoginResult { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - // Get password from parameter or keychain - let pwd: String - if let password { - pwd = password - } else if let stored = try await keychainService.retrievePassword(forNodeKey: remoteSession.publicKey) { - pwd = stored - } else { - throw RemoteNodeError.passwordNotFound - } + // Get password from parameter or keychain + let pwd: String + if let password { + pwd = password + } else if let stored = try await keychainService.retrievePassword(forNodeKey: remoteSession.publicKey) { + pwd = stored + } else { + throw RemoteNodeError.passwordNotFound + } - let prefix = Data(remoteSession.publicKey.prefix(6)) + let prefix = Data(remoteSession.publicKey.prefix(6)) - // Cancel any existing pending login for this prefix - if let existing = pendingLogins.removeValue(forKey: prefix) { - let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() - logger.warning("Overwriting pending login for prefix \(prefixHex)") - pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() - existing.resume(throwing: RemoteNodeError.cancelled) - } - - // Log login request - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logLoginRequest(target: targetType, publicKey: remoteSession.publicKey, pathLength: pathLength) - - // Register continuation BEFORE sending to avoid race condition with loginSuccess event - let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() - logger.info("login: registering pending login for prefix \(prefixHex)") - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - pendingLogins[prefix] = continuation - - let timeoutTask = Task { [self] in - let sentInfo: MessageSentInfo - do { - sentInfo = try await session.sendLogin(to: remoteSession.publicKey, password: pwd) - } catch { - pendingLoginTimeoutTasks.removeValue(forKey: prefix) - if let pending = pendingLogins.removeValue(forKey: prefix) { - let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) - pending.resume(throwing: RemoteNodeError.sessionError(meshError)) - } - return - } - - let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: pathLength) - logger.info("login: send succeeded, starting \(timeout) timeout for prefix \(prefixHex)") - - if let onTimeoutKnown { - let timeoutSeconds = max(1, Int(timeInterval(for: timeout).rounded(.up))) - await onTimeoutKnown(timeoutSeconds) - } - try? await Task.sleep(for: timeout) - guard !Task.isCancelled else { return } - if let pending = pendingLogins.removeValue(forKey: prefix) { - logger.warning("Login timeout after \(timeout) for session \(sessionID), prefix \(prefixHex)") - pendingLoginTimeoutTasks.removeValue(forKey: prefix) - pending.resume(throwing: RemoteNodeError.timeout) - } else { - logger.info("login: timeout elapsed but continuation already consumed for prefix \(prefixHex)") - } - } - pendingLoginTimeoutTasks[prefix] = timeoutTask - } - } onCancel: { [weak self] in - Task { [weak self] in - await self?.cancelPendingLogin(for: prefix) - } - } + // Cancel any existing pending login for this prefix + if let existing = pendingLogins.removeValue(forKey: prefix) { + let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() + logger.warning("Overwriting pending login for prefix \(prefixHex)") + pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() + existing.resume(throwing: RemoteNodeError.cancelled) } - /// Handle login result push from device. - func handleLoginResult(_ result: LoginResult, fromPublicKeyPrefix: Data) async { - guard fromPublicKeyPrefix.count >= 6 else { - logger.warning("Login result has invalid prefix length: \(fromPublicKeyPrefix.count)") + // Log login request + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logLoginRequest(target: targetType, publicKey: remoteSession.publicKey, pathLength: pathLength) + + // Register continuation BEFORE sending to avoid race condition with loginSuccess event + let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() + logger.info("login: registering pending login for prefix \(prefixHex)") + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + pendingLogins[prefix] = continuation + + let timeoutTask = Task { [self] in + let sentInfo: MessageSentInfo + do { + sentInfo = try await sendLoginHealingIfNeeded( + publicKey: remoteSession.publicKey, + radioID: remoteSession.radioID, + password: pwd + ) + } catch { + // Cancellation means this task no longer owns the continuation: whoever + // cancelled it (an overlapping login for this prefix, or cancelPendingLogin + // on outer-task cancellation) has already resumed and removed it. + guard !Task.isCancelled else { return } + pendingLoginTimeoutTasks.removeValue(forKey: prefix) + if let pending = pendingLogins.removeValue(forKey: prefix) { + let rnError = (error as? RemoteNodeError) + ?? .sessionError(error as? MeshCoreError ?? .connectionLost(underlying: error)) + pending.resume(throwing: rnError) + } return + } + + let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: pathLength) + logger.info("login: send succeeded, starting \(timeout) timeout for prefix \(prefixHex)") + + if let onTimeoutKnown { + let timeoutSeconds = max(1, Int(timeInterval(for: timeout).rounded(.up))) + await onTimeoutKnown(timeoutSeconds) + } + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + if let pending = pendingLogins.removeValue(forKey: prefix) { + logger.warning("Login timeout after \(timeout) for session \(sessionID), prefix \(prefixHex)") + pendingLoginTimeoutTasks.removeValue(forKey: prefix) + pending.resume(throwing: RemoteNodeError.timeout) + } else { + logger.info("login: timeout elapsed but continuation already consumed for prefix \(prefixHex)") + } } + pendingLoginTimeoutTasks[prefix] = timeoutTask + } + } onCancel: { [weak self] in + Task { [weak self] in + await self?.cancelPendingLogin(for: prefix) + } + } + } + + /// Sends the login; if the radio reports the contact is missing from its table, pushes the local + /// copy (flood-routed) and retries once. A contact can be in the app database but absent on the + /// radio after a backup restore or a radio swap, which the firmware answers with notFound (0x02). + /// The parked login continuation is covered here by the session commands' own timeouts, not the + /// login timeout, which only starts after this returns. + internal func sendLoginHealingIfNeeded( + publicKey: Data, + radioID: UUID, + password: String + ) async throws -> MessageSentInfo { + do { + return try await session.sendLogin(to: publicKey, password: password) + } catch let error as MeshCoreError { + guard case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue else { + throw error + } + try Task.checkCancellation() + try await addLocalContactToRadio(publicKey: publicKey, radioID: radioID) + // Bounded to a single retry: a second notFound after a successful add is a firmware + // inconsistency, left to propagate rather than loop. + return try await session.sendLogin(to: publicKey, password: password) + } + } - let prefix = Data(fromPublicKeyPrefix.prefix(6)) - let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() - let pendingKeys = pendingLogins.keys.map { $0.map { String(format: "%02x", $0) }.joined() } - logger.info("handleLoginResult: looking for prefix \(prefixHex), pending keys: \(pendingKeys)") - guard let continuation = pendingLogins.removeValue(forKey: prefix) else { - logger.warning("Login result with no pending request. Prefix: \(prefixHex)") - return - } - pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() - logger.info("handleLoginResult: found continuation for prefix \(prefixHex)") - - if result.success { - // Update session state - do { - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(prefix) else { - logger.error("handleLoginResult: no session found for prefix \(prefixHex) - database may be corrupted") - continuation.resume(returning: result) - return - } - - // Log successful login - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logLoginSuccess(target: targetType, publicKey: prefix, isAdmin: result.isAdmin) - - let permission = result.permissionLevel - - logger.info("handleLoginResult: updating session \(remoteSession.id) isConnected=true, permission=\(permission.rawValue)") - - try await dataStore.updateRemoteNodeSessionConnection( - id: remoteSession.id, - isConnected: true, - permissionLevel: permission - ) - - // Verify the update succeeded - if let verifySession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) { - if verifySession.isConnected { - logger.info("handleLoginResult: verified session \(remoteSession.id) isConnected=true") - } else { - logger.error("handleLoginResult: session \(remoteSession.id) still shows isConnected=false after update!") - } - } - - // Notify UI of session state change - eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: true)) - - keepAliveIntervals[remoteSession.id] = Self.defaultKeepAliveInterval - } catch { - logger.error("handleLoginResult: failed to update session state: \(error)") - } - continuation.resume(returning: result) - } else { - // Log failed login - // Try to determine target type from existing session - if let remoteSession = try? await dataStore.fetchRemoteNodeSessionByPrefix(prefix) { - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logLoginFailed(target: targetType, publicKey: prefix, reason: "authentication failed") - } else { - await auditLogger.logLoginFailed(target: .repeater, publicKey: prefix, reason: "authentication failed") - } - continuation.resume(throwing: RemoteNodeError.loginFailed("authentication failed")) - } + /// Pushes the local contact to the radio's contact table with flood routing, then reconciles the + /// local row so keep-alive routing agrees with the radio. + private func addLocalContactToRadio(publicKey: Data, radioID: UUID) async throws { + guard let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) else { + throw RemoteNodeError.contactNotFound + } + let frame = contact.floodedContactFrame(asOf: UInt32(Date().timeIntervalSince1970)) + do { + try await session.addContact(frame.toMeshContact()) + } catch let error as MeshCoreError { + guard case let .deviceError(code) = error, code == ProtocolError.tableFull.rawValue else { + throw error + } + throw RemoteNodeError.radioContactsFull + } + // The radio is healed; failing to sync the local row is a bookkeeping issue that must not + // abort the login retry. Keep-alive routing self-corrects on the next contact refresh. + do { + _ = try await dataStore.saveContact(radioID: radioID, from: frame) + } catch { + logger.warning("Re-added contact to radio but failed to sync local row: \(error)") + } + logger.info("Re-added missing contact to radio during login") + } + + /// Handle login result push from device. + internal func handleLoginResult(_ result: LoginResult, fromPublicKeyPrefix: Data) async { + guard fromPublicKeyPrefix.count >= 6 else { + logger.warning("Login result has invalid prefix length: \(fromPublicKeyPrefix.count)") + return } - // MARK: - Keep-Alive (Room Servers) + let prefix = Data(fromPublicKeyPrefix.prefix(6)) + let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() + let pendingKeys = pendingLogins.keys.map { $0.map { String(format: "%02x", $0) }.joined() } + logger.info("handleLoginResult: looking for prefix \(prefixHex), pending keys: \(pendingKeys)") + guard let continuation = pendingLogins.removeValue(forKey: prefix) else { + logger.warning("Login result with no pending request. Prefix: \(prefixHex)") + return + } + pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() + logger.info("handleLoginResult: found continuation for prefix \(prefixHex)") + + if result.success { + // Update session state + do { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(prefix) else { + logger.error("handleLoginResult: no session found for prefix \(prefixHex) - database may be corrupted") + continuation.resume(returning: result) + return + } - /// Start periodic keep-alive for a room server session. - /// Sends an immediate keep-alive on start (for connectivity check + sync_since update), - /// then continues at the configured interval. Transient failures are retried up to - /// `KeepAliveRetryPolicy.maxConsecutiveFailures` times before disconnecting. - private func startKeepAlive(sessionID: UUID, publicKey: Data) { - stopKeepAlive(sessionID: sessionID) + // Log successful login + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logLoginSuccess(target: targetType, publicKey: prefix, isAdmin: result.isAdmin) - let interval = keepAliveIntervals[sessionID] ?? Self.defaultKeepAliveInterval + let permission = result.permissionLevel - // The actor retains this task via keepAliveTasks, so the loop captures - // self weakly and rebinds per tick; a strong capture would keep the - // actor (and its session/dataStore) alive for the app lifetime. - let task = Task { [weak self] in - var consecutiveFailures = 0 + logger.info("handleLoginResult: updating session \(remoteSession.id) isConnected=true, permission=\(permission.rawValue)") - while !Task.isCancelled { - guard let tick = await self?.performKeepAliveTick( - sessionID: sessionID, - publicKey: publicKey, - consecutiveFailures: consecutiveFailures - ), tick.shouldContinue else { return } - consecutiveFailures = tick.consecutiveFailures + try await dataStore.updateRemoteNodeSessionConnection( + id: remoteSession.id, + isConnected: true, + permissionLevel: permission + ) - try? await Task.sleep(for: interval) - } + // Verify the update succeeded + if let verifySession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) { + if verifySession.isConnected { + logger.info("handleLoginResult: verified session \(remoteSession.id) isConnected=true") + } else { + logger.error("handleLoginResult: session \(remoteSession.id) still shows isConnected=false after update!") + } } - keepAliveTasks[sessionID] = task + // Notify UI of session state change + eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: true)) + + keepAliveIntervals[remoteSession.id] = Self.defaultKeepAliveInterval + } catch { + logger.error("handleLoginResult: failed to update session state: \(error)") + } + continuation.resume(returning: result) + } else { + // Log failed login + // Try to determine target type from existing session + if let remoteSession = try? await dataStore.fetchRemoteNodeSessionByPrefix(prefix) { + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logLoginFailed(target: targetType, publicKey: prefix, reason: "authentication failed") + } else { + await auditLogger.logLoginFailed(target: .repeater, publicKey: prefix, reason: "authentication failed") + } + continuation.resume(throwing: RemoteNodeError.loginFailed("authentication failed")) + } + } + + // MARK: - Keep-Alive (Room Servers) + + /// Start periodic keep-alive for a room server session. + /// Sends an immediate keep-alive on start (for connectivity check + sync_since update), + /// then continues at the configured interval. Transient failures are retried up to + /// `KeepAliveRetryPolicy.maxConsecutiveFailures` times before disconnecting. + private func startKeepAlive(sessionID: UUID, publicKey: Data) { + stopKeepAlive(sessionID: sessionID) + + let interval = keepAliveIntervals[sessionID] ?? Self.defaultKeepAliveInterval + + // The actor retains this task via keepAliveTasks, so the loop captures + // self weakly and rebinds per tick; a strong capture would keep the + // actor (and its session/dataStore) alive for the app lifetime. + let task = Task { [weak self] in + var consecutiveFailures = 0 + + while !Task.isCancelled { + guard let tick = await self?.performKeepAliveTick( + sessionID: sessionID, + publicKey: publicKey, + consecutiveFailures: consecutiveFailures + ), tick.shouldContinue else { return } + consecutiveFailures = tick.consecutiveFailures + + try? await Task.sleep(for: interval) + } } - /// Runs one keep-alive attempt for the loop in `startKeepAlive`. - /// Returns whether the loop should continue and the updated failure count. - private func performKeepAliveTick( - sessionID: UUID, - publicKey: Data, - consecutiveFailures: Int - ) async -> (shouldContinue: Bool, consecutiveFailures: Int) { - var failures = consecutiveFailures + keepAliveTasks[sessionID] = task + } + + /// Runs one keep-alive attempt for the loop in `startKeepAlive`. + /// Returns whether the loop should continue and the updated failure count. + private func performKeepAliveTick( + sessionID: UUID, + publicKey: Data, + consecutiveFailures: Int + ) async -> (shouldContinue: Bool, consecutiveFailures: Int) { + var failures = consecutiveFailures + do { + try await sendKeepAliveIfDirectRouted(sessionID: sessionID, publicKey: publicKey) + KeepAliveRetryPolicy.recordSuccess(consecutiveFailures: &failures) + return (shouldContinue: true, consecutiveFailures: failures) + } catch { + let action = KeepAliveRetryPolicy.evaluate(error: error, consecutiveFailures: &failures) + switch action { + case .stop: + break + case .skip: + logger.info("Skipping keep-alive for flood-routed session \(sessionID)") + case .retryNextInterval: + let reason = KeepAliveRetryPolicy.failureReason(for: error) + logger.warning("Keep-alive \(failures)/\(KeepAliveRetryPolicy.maxConsecutiveFailures) failed for \(sessionID): \(reason)") + case .disconnect, .disconnectNow: + let reason = KeepAliveRetryPolicy.failureReason(for: error) + logger.warning("Keep-alive failed for \(sessionID): \(reason)") do { - try await sendKeepAliveIfDirectRouted(sessionID: sessionID, publicKey: publicKey) - KeepAliveRetryPolicy.recordSuccess(consecutiveFailures: &failures) - return (shouldContinue: true, consecutiveFailures: failures) + try await dataStore.markSessionDisconnected(sessionID) } catch { - let action = KeepAliveRetryPolicy.evaluate(error: error, consecutiveFailures: &failures) - switch action { - case .stop: - break - case .skip: - logger.info("Skipping keep-alive for flood-routed session \(sessionID)") - case .retryNextInterval: - let reason = KeepAliveRetryPolicy.failureReason(for: error) - logger.warning("Keep-alive \(failures)/\(KeepAliveRetryPolicy.maxConsecutiveFailures) failed for \(sessionID): \(reason)") - case .disconnect, .disconnectNow: - let reason = KeepAliveRetryPolicy.failureReason(for: error) - logger.warning("Keep-alive failed for \(sessionID): \(reason)") - do { - try await dataStore.markSessionDisconnected(sessionID) - } catch { - logger.error("Failed to persist disconnected state for session \(sessionID): \(error)") - } - eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) - } - return (shouldContinue: !action.shouldExitLoop, consecutiveFailures: failures) + logger.error("Failed to persist disconnected state for session \(sessionID): \(error)") } + eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + } + return (shouldContinue: !action.shouldExitLoop, consecutiveFailures: failures) } - - /// Stop keep-alive for a session - func stopKeepAlive(sessionID: UUID) { - keepAliveTasks[sessionID]?.cancel() - keepAliveTasks.removeValue(forKey: sessionID) + } + + /// Stop keep-alive for a session + internal func stopKeepAlive(sessionID: UUID) { + keepAliveTasks[sessionID]?.cancel() + keepAliveTasks.removeValue(forKey: sessionID) + } + + /// Send keep-alive only if the session has a direct routing path. + private func sendKeepAliveIfDirectRouted(sessionID: UUID, publicKey: Data) async throws { + // Fetch session to get radioID + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound } - /// Send keep-alive only if the session has a direct routing path. - private func sendKeepAliveIfDirectRouted(sessionID: UUID, publicKey: Data) async throws { - // Fetch session to get radioID - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } - - // Check contact's routing status - guard let contact = try await dataStore.fetchContact(radioID: remoteSession.radioID, publicKey: publicKey) else { - throw RemoteNodeError.contactNotFound - } - - // Keep-alive only works with direct routing - if contact.isFloodRouted { - throw RemoteNodeError.floodRouted - } - - // Log keep-alive - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logKeepAlive(target: targetType, publicKey: publicKey) - - // Send keep-alive with sync_since for force-resync hint - do { - _ = try await session.sendKeepAlive(to: publicKey, syncSince: remoteSession.lastSyncTimestamp) - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + // Check contact's routing status + guard let contact = try await dataStore.fetchContact(radioID: remoteSession.radioID, publicKey: publicKey) else { + throw RemoteNodeError.contactNotFound } - /// Public method to send keep-alive (for manual refresh). - public func sendKeepAlive(sessionID: UUID) async throws { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } - try await sendKeepAliveIfDirectRouted(sessionID: sessionID, publicKey: remoteSession.publicKey) + // Keep-alive only works with direct routing + if contact.isFloodRouted { + throw RemoteNodeError.floodRouted } - /// Start keep-alive for a room session (called when room view appears). - public func startSessionKeepAlive(sessionID: UUID, publicKey: Data) { - startKeepAlive(sessionID: sessionID, publicKey: publicKey) + // Log keep-alive + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logKeepAlive(target: targetType, publicKey: publicKey) + + // Send keep-alive with sync_since for force-resync hint + do { + _ = try await session.sendKeepAlive(to: publicKey, syncSince: remoteSession.lastSyncTimestamp) + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } + } - /// Stop keep-alive for a room session (called when room view disappears). - public func stopSessionKeepAlive(sessionID: UUID) { - stopKeepAlive(sessionID: sessionID) + /// Public method to send keep-alive (for manual refresh). + func sendKeepAlive(sessionID: UUID) async throws { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound } + try await sendKeepAliveIfDirectRouted(sessionID: sessionID, publicKey: remoteSession.publicKey) + } - // MARK: - History Sync + /// Start keep-alive for a room session (called when room view appears). + func startSessionKeepAlive(sessionID: UUID, publicKey: Data) { + startKeepAlive(sessionID: sessionID, publicKey: publicKey) + } - /// Request message history from a room server. - public func requestHistorySync(sessionID: UUID) async throws { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + /// Stop keep-alive for a room session (called when room view disappears). + func stopSessionKeepAlive(sessionID: UUID) { + stopKeepAlive(sessionID: sessionID) + } - guard remoteSession.isRoom else { - throw RemoteNodeError.invalidResponse - } + // MARK: - History Sync - // Check for direct route - guard let contact = try await dataStore.fetchContact(radioID: remoteSession.radioID, publicKey: remoteSession.publicKey) else { - throw RemoteNodeError.contactNotFound - } + /// Request message history from a room server. + func requestHistorySync(sessionID: UUID) async throws { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - if contact.isFloodRouted { - throw RemoteNodeError.floodRouted - } + guard remoteSession.isRoom else { + throw RemoteNodeError.invalidResponse + } - // Request status (which triggers sync) - do { - let contactType: ContactType = remoteSession.isRoom ? .room : .repeater - _ = try await session.requestStatus(from: remoteSession.publicKey, type: contactType) - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + // Check for direct route + guard let contact = try await dataStore.fetchContact(radioID: remoteSession.radioID, publicKey: remoteSession.publicKey) else { + throw RemoteNodeError.contactNotFound + } - logger.info("Requested history sync for room \(remoteSession.name)") + if contact.isFloodRouted { + throw RemoteNodeError.floodRouted } - // MARK: - Logout + // Request status (which triggers sync) + do { + let contactType: ContactType = remoteSession.isRoom ? .room : .repeater + _ = try await session.requestStatus(from: remoteSession.publicKey, type: contactType) + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) + } - /// Explicitly logout from a remote node. - public func logout(sessionID: UUID) async throws { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + logger.info("Requested history sync for room \(remoteSession.name)") + } - // Log logout - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logLogout(target: targetType, publicKey: remoteSession.publicKey) + // MARK: - Logout - stopKeepAlive(sessionID: sessionID) + /// Explicitly logout from a remote node. + func logout(sessionID: UUID) async throws { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - do { - try await session.sendLogout(to: remoteSession.publicKey) - } catch { - // Ignore errors - we're disconnecting anyway - logger.info("Logout send failed (ignoring): \(error)") - } + // Log logout + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logLogout(target: targetType, publicKey: remoteSession.publicKey) - try await dataStore.updateRemoteNodeSessionConnection( - id: sessionID, - isConnected: false, - permissionLevel: .guest - ) + stopKeepAlive(sessionID: sessionID) - // Notify UI of session state change - eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + do { + try await session.sendLogout(to: remoteSession.publicKey) + } catch { + // Ignore errors - we're disconnecting anyway + logger.info("Logout send failed (ignoring): \(error)") } + + try await dataStore.updateRemoteNodeSessionConnection( + id: sessionID, + isConnected: false, + permissionLevel: .guest + ) + + // Notify UI of session state change + eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Reconnection.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Reconnection.swift index 961b5f36..0383227c 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Reconnection.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Reconnection.swift @@ -1,98 +1,97 @@ import Foundation -extension RemoteNodeService { +public extension RemoteNodeService { + // MARK: - BLE Disconnection - // MARK: - BLE Disconnection - - /// Called when BLE connection is lost. - /// Marks all connected sessions as disconnected, stops keep-alive timers, - /// and broadcasts `RemoteNodeEvent.sessionStateChanged` for each. - /// Returns the set of session IDs that were connected, for re-auth on reconnect. - public func handleBLEDisconnection() async -> Set { - let connectedSessions: [RemoteNodeSessionDTO] - do { - connectedSessions = try await dataStore.fetchConnectedRemoteNodeSessions() - } catch { - logger.error("Failed to fetch connected sessions for BLE disconnection: \(error)") - return [] - } + /// Called when BLE connection is lost. + /// Marks all connected sessions as disconnected, stops keep-alive timers, + /// and broadcasts `RemoteNodeEvent.sessionStateChanged` for each. + /// Returns the set of session IDs that were connected, for re-auth on reconnect. + func handleBLEDisconnection() async -> Set { + let connectedSessions: [RemoteNodeSessionDTO] + do { + connectedSessions = try await dataStore.fetchConnectedRemoteNodeSessions() + } catch { + logger.error("Failed to fetch connected sessions for BLE disconnection: \(error)") + return [] + } - guard !connectedSessions.isEmpty else { return [] } + guard !connectedSessions.isEmpty else { return [] } - logger.info("BLE disconnection: marking \(connectedSessions.count) session(s) disconnected") - var sessionIDs: Set = [] + logger.info("BLE disconnection: marking \(connectedSessions.count) session(s) disconnected") + var sessionIDs: Set = [] - for session in connectedSessions { - sessionIDs.insert(session.id) - stopKeepAlive(sessionID: session.id) - do { - try await dataStore.markSessionDisconnected(session.id) - } catch { - logger.error("Failed to mark session \(session.id) disconnected: \(error)") - } - eventBroadcaster.yield(.sessionStateChanged(sessionID: session.id, isConnected: false)) - } - - return sessionIDs + for session in connectedSessions { + sessionIDs.insert(session.id) + stopKeepAlive(sessionID: session.id) + do { + try await dataStore.markSessionDisconnected(session.id) + } catch { + logger.error("Failed to mark session \(session.id) disconnected: \(error)") + } + eventBroadcaster.yield(.sessionStateChanged(sessionID: session.id, isConnected: false)) } - // MARK: - BLE Reconnection + return sessionIDs + } - /// Called when BLE connection is re-established. - /// Re-authenticates sessions that were connected before BLE loss. - /// - Parameter sessionIDs: Session IDs from `handleBLEDisconnection()`. - /// If empty (e.g., after app restart), no sessions are re-authenticated; - /// the user can manually reconnect. - public func handleBLEReconnection(sessionIDs: Set) async { - guard !isReauthenticating else { - logger.info("Skipping re-auth: already in progress") - return - } + // MARK: - BLE Reconnection - guard !sessionIDs.isEmpty else { return } + /// Called when BLE connection is re-established. + /// Re-authenticates sessions that were connected before BLE loss. + /// - Parameter sessionIDs: Session IDs from `handleBLEDisconnection()`. + /// If empty (e.g., after app restart), no sessions are re-authenticated; + /// the user can manually reconnect. + func handleBLEReconnection(sessionIDs: Set) async { + guard !isReauthenticating else { + logger.info("Skipping re-auth: already in progress") + return + } - // Fetch current session state for each ID - var sessionsToReauth: [RemoteNodeSessionDTO] = [] - for id in sessionIDs { - if let session = try? await dataStore.fetchRemoteNodeSession(id: id) { - sessionsToReauth.append(session) - } else { - logger.warning("Session \(id) not found for re-auth, skipping") - } - } + guard !sessionIDs.isEmpty else { return } + + // Fetch current session state for each ID + var sessionsToReauth: [RemoteNodeSessionDTO] = [] + for id in sessionIDs { + if let session = try? await dataStore.fetchRemoteNodeSession(id: id) { + sessionsToReauth.append(session) + } else { + logger.warning("Session \(id) not found for re-auth, skipping") + } + } - guard !sessionsToReauth.isEmpty else { return } + guard !sessionsToReauth.isEmpty else { return } - logger.info("BLE reconnection: re-authenticating \(sessionsToReauth.count) session(s)") - isReauthenticating = true - defer { isReauthenticating = false } + logger.info("BLE reconnection: re-authenticating \(sessionsToReauth.count) session(s)") + isReauthenticating = true + defer { isReauthenticating = false } - await withTaskGroup(of: Void.self) { group in - for remoteSession in sessionsToReauth { - group.addTask { [self] in - let previousPermission = remoteSession.permissionLevel - do { - let result = try await self.login(sessionID: remoteSession.id) - let newPermission = result.permissionLevel - if newPermission < previousPermission { - self.logger.warning( - "Re-auth returned degraded permission for session \(remoteSession.id): " - + "\(previousPermission) -> \(newPermission), marking disconnected" - ) - try? await self.dataStore.markSessionDisconnected(remoteSession.id) - self.eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: false)) - } - } catch { - self.logger.warning("Re-auth failed for session \(remoteSession.id): \(error)") - do { - try await self.dataStore.markSessionDisconnected(remoteSession.id) - } catch { - self.logger.error("Failed to persist disconnected state for session \(remoteSession.id): \(error)") - } - self.eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: false)) - } - } + await withTaskGroup(of: Void.self) { group in + for remoteSession in sessionsToReauth { + group.addTask { [self] in + let previousPermission = remoteSession.permissionLevel + do { + let result = try await login(sessionID: remoteSession.id) + let newPermission = result.permissionLevel + if newPermission < previousPermission { + logger.warning( + "Re-auth returned degraded permission for session \(remoteSession.id): " + + "\(previousPermission) -> \(newPermission), marking disconnected" + ) + try? await dataStore.markSessionDisconnected(remoteSession.id) + eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: false)) + } + } catch { + logger.warning("Re-auth failed for session \(remoteSession.id): \(error)") + do { + try await dataStore.markSessionDisconnected(remoteSession.id) + } catch { + logger.error("Failed to persist disconnected state for session \(remoteSession.id): \(error)") } + eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: false)) + } } + } } + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Telemetry.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Telemetry.swift index 74aa7652..c65fa666 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Telemetry.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Telemetry.swift @@ -1,74 +1,73 @@ import Foundation import MeshCore -extension RemoteNodeService { +public extension RemoteNodeService { + // MARK: - Status - // MARK: - Status - - /// Request status from a remote node. - public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + /// Request status from a remote node. + func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - // Log status request - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logStatusRequest(target: targetType, publicKey: remoteSession.publicKey) + // Log status request + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logStatusRequest(target: targetType, publicKey: remoteSession.publicKey) - do { - let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum - return try await withTimeout(effectiveTimeout, operationName: "remoteStatus") { - let contactType: ContactType = remoteSession.isRoom ? .room : .repeater - return try await self.session.requestStatus(from: remoteSession.publicKey, type: contactType) - } - } catch is TimeoutError { - throw RemoteNodeError.timeout - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + do { + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteStatus") { + let contactType: ContactType = remoteSession.isRoom ? .room : .repeater + return try await self.session.requestStatus(from: remoteSession.publicKey, type: contactType) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } + } - // MARK: - Telemetry + // MARK: - Telemetry - /// Request telemetry from a remote node - public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + /// Request telemetry from a remote node + func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - // Log telemetry request - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logTelemetryRequest(target: targetType, publicKey: remoteSession.publicKey) + // Log telemetry request + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logTelemetryRequest(target: targetType, publicKey: remoteSession.publicKey) - do { - let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum - return try await withTimeout(effectiveTimeout, operationName: "remoteTelemetry") { - try await self.session.requestTelemetry(from: remoteSession.publicKey) - } - } catch is TimeoutError { - throw RemoteNodeError.timeout - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + do { + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteTelemetry") { + try await self.session.requestTelemetry(from: remoteSession.publicKey) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } + } - // MARK: - Owner Info + // MARK: - Owner Info - /// Request owner info from a repeater using binary protocol. - public func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + /// Request owner info from a repeater using binary protocol. + func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - do { - let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum - return try await withTimeout(effectiveTimeout, operationName: "remoteOwnerInfo") { - try await self.session.requestOwnerInfo(from: remoteSession.publicKey) - } - } catch is TimeoutError { - throw RemoteNodeError.timeout - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + do { + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteOwnerInfo") { + try await self.session.requestOwnerInfo(from: remoteSession.publicKey) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift index 405a04d5..32c7352c 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift @@ -14,381 +14,381 @@ private let cliResponseTextType: UInt8 = 0x01 /// Shared service for remote node operations. /// Handles login, keep-alive, status, telemetry, and CLI for both room servers and repeaters. public actor RemoteNodeService { - - // MARK: - Properties - - let session: any RemoteAccessSessionOps & SessionEventStreaming - let dataStore: any PersistenceStoreProtocol - let keychainService: KeychainService - let logger = PersistentLogger(subsystem: "com.mc1", category: "RemoteNode") - let auditLogger = CommandAuditLogger() - - /// Pending login continuations keyed by 6-byte public key prefix. - /// Using 6-byte prefix matches MeshCore protocol format for login results. - var pendingLogins: [Data: CheckedContinuation] = [:] - - /// Timeout tasks for pending logins, keyed by 6-byte public key prefix. - /// Cancelled when login succeeds/fails before timeout. - var pendingLoginTimeoutTasks: [Data: Task] = [:] - - /// Pending CLI request with command info for content-based response matching - struct PendingCLIRequest { - let command: String - let continuation: CheckedContinuation - let timestamp: Date + // MARK: - Properties + + let session: any RemoteAccessSessionOps & SessionEventStreaming & ContactSessionOps + let dataStore: any PersistenceStoreProtocol + let keychainService: KeychainService + let logger = PersistentLogger(subsystem: "com.mc1", category: "RemoteNode") + let auditLogger = CommandAuditLogger() + + /// Pending login continuations keyed by 6-byte public key prefix. + /// Using 6-byte prefix matches MeshCore protocol format for login results. + var pendingLogins: [Data: CheckedContinuation] = [:] + + /// Timeout tasks for pending logins, keyed by 6-byte public key prefix. + /// Cancelled when login succeeds/fails before timeout. + var pendingLoginTimeoutTasks: [Data: Task] = [:] + + /// Pending CLI request with command info for content-based response matching + struct PendingCLIRequest { + let command: String + let continuation: CheckedContinuation + let timestamp: Date + } + + /// Pending CLI requests keyed by 6-byte public key prefix. + /// Multiple requests per destination stored in order for FIFO fallback. + var pendingCLIRequests: [Data: [PendingCLIRequest]] = [:] + + /// Pending raw CLI requests for passthrough (FIFO matching, single request per sender). + /// Used by CLI tool where any response should be delivered without content-based matching. + var pendingRawCLIRequests: [Data: CheckedContinuation] = [:] + + /// Keep-alive timer tasks + var keepAliveTasks: [UUID: Task] = [:] + + /// Keep-alive intervals per session (from login response, in seconds) + /// Default to 90 seconds if not specified + var keepAliveIntervals: [UUID: Duration] = [:] + static let defaultKeepAliveInterval: Duration = .seconds(90) + + /// Reentrancy guard for BLE reconnection handling + var isReauthenticating = false + + /// Event monitoring task + private var eventMonitorTask: Task? + + // MARK: - Handlers + + /// Handler for keep-alive ACK responses + /// Called when ACK with unsynced count is received. + /// Nothing assigns or reads this property today. + public var keepAliveResponseHandler: (@Sendable (UUID, Int) async -> Void)? + + // MARK: - Events + + /// Multicast broadcaster for session connection-state events. + nonisolated let eventBroadcaster = EventBroadcaster() + + /// Returns a fresh stream of remote-node session events. Registration is + /// synchronous, so events yielded after this call are never dropped. + /// Consumers must re-subscribe per connection because the owning + /// `ServiceContainer` is rebuilt on every connection. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } + + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } + + // MARK: - Initialization + + init( + session: any RemoteAccessSessionOps & SessionEventStreaming & ContactSessionOps, + dataStore: any PersistenceStoreProtocol, + keychainService: KeychainService + ) { + self.session = session + self.dataStore = dataStore + self.keychainService = keychainService + } + + deinit { + eventMonitorTask?.cancel() + for task in keepAliveTasks.values { + task.cancel() } + } - /// Pending CLI requests keyed by 6-byte public key prefix. - /// Multiple requests per destination stored in order for FIFO fallback. - var pendingCLIRequests: [Data: [PendingCLIRequest]] = [:] - - /// Pending raw CLI requests for passthrough (FIFO matching, single request per sender). - /// Used by CLI tool where any response should be delivered without content-based matching. - var pendingRawCLIRequests: [Data: CheckedContinuation] = [:] - - /// Keep-alive timer tasks - var keepAliveTasks: [UUID: Task] = [:] - - /// Keep-alive intervals per session (from login response, in seconds) - /// Default to 90 seconds if not specified - var keepAliveIntervals: [UUID: Duration] = [:] - static let defaultKeepAliveInterval: Duration = .seconds(90) + // MARK: - Event Monitoring - /// Reentrancy guard for BLE reconnection handling - var isReauthenticating = false + /// Start monitoring MeshCore events for login results + public func startEventMonitoring() { + eventMonitorTask?.cancel() - /// Event monitoring task - private var eventMonitorTask: Task? - - // MARK: - Handlers - - /// Handler for keep-alive ACK responses - /// Called when ACK with unsynced count is received. - /// Nothing assigns or reads this property today. - public var keepAliveResponseHandler: (@Sendable (UUID, Int) async -> Void)? - - // MARK: - Events - - /// Multicast broadcaster for session connection-state events. - nonisolated let eventBroadcaster = EventBroadcaster() - - /// Returns a fresh stream of remote-node session events. Registration is - /// synchronous, so events yielded after this call are never dropped. - /// Consumers must re-subscribe per connection because the owning - /// `ServiceContainer` is rebuilt on every connection. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() - } + eventMonitorTask = Task { [weak self] in + guard let self else { return } + let filter = EventFilter { event in + switch event { + case .loginSuccess, .loginFailed: + true + case let .contactMessageReceived(info) where info.textType == cliResponseTextType: + true + default: + false + } + } + let events = await session.events(filter: filter) - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishEvents() { - eventBroadcaster.finish() + for await event in events { + guard !Task.isCancelled else { break } + await handleEvent(event) + } } + } + + /// Stop monitoring events + public func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + } + + /// Handle incoming MeshCore event + private func handleEvent(_ event: MeshEvent) async { + switch event { + case let .loginSuccess(info): + let prefixHex = info.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + logger.info("loginSuccess received for prefix \(prefixHex)") + let result = LoginResult( + success: true, + isAdmin: info.isAdmin, + aclPermissions: info.permissions, + publicKeyPrefix: info.publicKeyPrefix + ) + await handleLoginResult(result, fromPublicKeyPrefix: info.publicKeyPrefix) + + case let .loginFailed(publicKeyPrefix): + if let prefix = publicKeyPrefix { + let result = LoginResult( + success: false, + isAdmin: false, + aclPermissions: nil, + publicKeyPrefix: prefix + ) + await handleLoginResult(result, fromPublicKeyPrefix: prefix) + } - // MARK: - Initialization - - init( - session: any RemoteAccessSessionOps & SessionEventStreaming, - dataStore: any PersistenceStoreProtocol, - keychainService: KeychainService - ) { - self.session = session - self.dataStore = dataStore - self.keychainService = keychainService - } + case let .contactMessageReceived(message): + if message.textType == cliResponseTextType { + handleCLIResponse(message) + } - deinit { - eventMonitorTask?.cancel() - for task in keepAliveTasks.values { - task.cancel() - } + default: + break } + } - // MARK: - Event Monitoring - - /// Start monitoring MeshCore events for login results - public func startEventMonitoring() { - eventMonitorTask?.cancel() - - eventMonitorTask = Task { [weak self] in - guard let self else { return } - let filter = EventFilter { event in - switch event { - case .loginSuccess, .loginFailed: - return true - case .contactMessageReceived(let info) where info.textType == cliResponseTextType: - return true - default: - return false - } - } - let events = await session.events(filter: filter) - - for await event in events { - guard !Task.isCancelled else { break } - await self.handleEvent(event) - } - } - } + /// Handle CLI response from a contact message. + /// Content-based matching runs first — raw (FIFO) matching only gets responses + /// that no typed query claimed. + private func handleCLIResponse(_ message: ContactMessage) { + let prefix = Data(message.senderPublicKeyPrefix.prefix(6)) - /// Stop monitoring events - public func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil - } + // Try content-based matching first for structured requests + if var requests = pendingCLIRequests[prefix], !requests.isEmpty { + let (matchIndex, matchCount) = findBestMatch(response: message.text, in: requests) - /// Handle incoming MeshCore event - private func handleEvent(_ event: MeshEvent) async { - switch event { - case .loginSuccess(let info): - let prefixHex = info.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() - logger.info("loginSuccess received for prefix \(prefixHex)") - let result = LoginResult( - success: true, - isAdmin: info.isAdmin, - aclPermissions: info.permissions, - publicKeyPrefix: info.publicKeyPrefix - ) - await handleLoginResult(result, fromPublicKeyPrefix: info.publicKeyPrefix) - - case .loginFailed(let publicKeyPrefix): - if let prefix = publicKeyPrefix { - let result = LoginResult( - success: false, - isAdmin: false, - aclPermissions: nil, - publicKeyPrefix: prefix - ) - await handleLoginResult(result, fromPublicKeyPrefix: prefix) - } - - case .contactMessageReceived(let message): - if message.textType == cliResponseTextType { - handleCLIResponse(message) - } + if let matchIndex { + let matched = requests.remove(at: matchIndex) + pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests + matched.continuation.resume(returning: message.text) + return + } - default: - break - } + if matchCount > 1 { + // Multiple matches (ambiguous like "OK") - fall back to FIFO + let oldest = requests.removeFirst() + pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests + oldest.continuation.resume(returning: message.text) + return + } } - /// Handle CLI response from a contact message. - /// Content-based matching runs first — raw (FIFO) matching only gets responses - /// that no typed query claimed. - private func handleCLIResponse(_ message: ContactMessage) { - let prefix = Data(message.senderPublicKeyPrefix.prefix(6)) - - // Try content-based matching first for structured requests - if var requests = pendingCLIRequests[prefix], !requests.isEmpty { - let (matchIndex, matchCount) = findBestMatch(response: message.text, in: requests) - - if let matchIndex { - let matched = requests.remove(at: matchIndex) - pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests - matched.continuation.resume(returning: message.text) - return - } - - if matchCount > 1 { - // Multiple matches (ambiguous like "OK") - fall back to FIFO - let oldest = requests.removeFirst() - pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests - oldest.continuation.resume(returning: message.text) - return - } - } - - // No content match — deliver to raw CLI request if one is pending - if let continuation = pendingRawCLIRequests.removeValue(forKey: prefix) { - continuation.resume(returning: message.text) - return - } - - // No pending requests matched - logger.debug("Unmatched CLI response (no pending request): \(message.text.prefix(50))") + // No content match — deliver to raw CLI request if one is pending + if let continuation = pendingRawCLIRequests.removeValue(forKey: prefix) { + continuation.resume(returning: message.text) + return } - /// Find best matching request for a response based on CLIResponse parsing - /// Returns the matching index (if exactly one) and total match count - private func findBestMatch(response: String, in requests: [PendingCLIRequest]) -> (index: Int?, matchCount: Int) { - var matchingIndices: [Int] = [] + // No pending requests matched + logger.debug("Unmatched CLI response (no pending request): \(message.text.prefix(50))") + } - for (index, request) in requests.enumerated() { - let parsed = CLIResponse.parse(response, forQuery: request.command) - - // If parsing with this query produces a specific result (not .raw), - // it's a potential match - if case .raw = parsed { - continue - } - matchingIndices.append(index) - } + /// Find best matching request for a response based on CLIResponse parsing + /// Returns the matching index (if exactly one) and total match count + private func findBestMatch(response: String, in requests: [PendingCLIRequest]) -> (index: Int?, matchCount: Int) { + var matchingIndices: [Int] = [] - // Return match only if exactly one command matches - if matchingIndices.count == 1 { - return (matchingIndices[0], 1) - } + for (index, request) in requests.enumerated() { + let parsed = CLIResponse.parse(response, forQuery: request.command) - return (nil, matchingIndices.count) + // If parsing with this query produces a specific result (not .raw), + // it's a potential match + if case .raw = parsed { + continue + } + matchingIndices.append(index) } - func timeInterval(for duration: Duration) -> TimeInterval { - let (seconds, attoseconds) = duration.components - return TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18 + // Return match only if exactly one command matches + if matchingIndices.count == 1 { + return (matchingIndices[0], 1) } - func cancelPendingLogin(for prefix: Data) { - pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() - if let continuation = pendingLogins.removeValue(forKey: prefix) { - continuation.resume(throwing: RemoteNodeError.cancelled) - } - } + return (nil, matchingIndices.count) + } - func cancelPendingCLIRequest(for prefix: Data, timestamp: Date) { - guard var requests = pendingCLIRequests[prefix], - let index = requests.firstIndex(where: { $0.timestamp == timestamp }) else { - return - } + func timeInterval(for duration: Duration) -> TimeInterval { + let (seconds, attoseconds) = duration.components + return TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18 + } - let cancelled = requests.remove(at: index) - pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests - cancelled.continuation.resume(throwing: CancellationError()) + func cancelPendingLogin(for prefix: Data) { + pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() + if let continuation = pendingLogins.removeValue(forKey: prefix) { + continuation.resume(throwing: RemoteNodeError.cancelled) } + } - // MARK: - Session Management - - /// Create a session DTO for a contact, optionally preserving data from an existing session. - private func makeSessionDTO( - radioID: UUID, - contact: ContactDTO, - role: RemoteNodeRole, - preserving existing: RemoteNodeSessionDTO? = nil - ) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: existing?.id ?? UUID(), - radioID: radioID, - publicKey: contact.publicKey, - name: contact.displayName, - role: role, - latitude: contact.latitude, - longitude: contact.longitude, - isConnected: false, - permissionLevel: existing?.permissionLevel ?? .guest, - lastConnectedDate: existing?.lastConnectedDate, - lastBatteryMillivolts: existing?.lastBatteryMillivolts, - lastUptimeSeconds: existing?.lastUptimeSeconds, - lastNoiseFloor: existing?.lastNoiseFloor, - unreadCount: existing?.unreadCount ?? 0, - notificationLevel: existing?.notificationLevel ?? .all, - lastRxAirtimeSeconds: existing?.lastRxAirtimeSeconds, - neighborCount: existing?.neighborCount ?? 0, - lastSyncTimestamp: existing?.lastSyncTimestamp ?? 0, - lastMessageDate: existing?.lastMessageDate - ) + func cancelPendingCLIRequest(for prefix: Data, timestamp: Date) { + guard var requests = pendingCLIRequests[prefix], + let index = requests.firstIndex(where: { $0.timestamp == timestamp }) else { + return } - /// Create a new session for a remote node. - public func createSession( - radioID: UUID, - contact: ContactDTO - ) async throws -> RemoteNodeSessionDTO { - guard let role = RemoteNodeRole(contactType: contact.type) else { - throw RemoteNodeError.invalidResponse - } - - guard contact.publicKey.count == ProtocolLimits.publicKeySize else { - throw RemoteNodeError.loginFailed("Invalid public key length: expected \(ProtocolLimits.publicKeySize) bytes, got \(contact.publicKey.count)") - } - - let pubKeyHex = contact.publicKey.prefix(6).map { String(format: "%02x", $0) }.joined() - - // Check for existing session - reuse to avoid duplicates - let existing = try? await dataStore.fetchRemoteNodeSession(publicKey: contact.publicKey) - - if let existing { - logger.info("createSession: reusing existing session \(existing.id) for \(pubKeyHex), isConnected=\(existing.isConnected)") - } else { - logger.info("createSession: creating new session for \(pubKeyHex)") - } - - let dto = makeSessionDTO(radioID: radioID, contact: contact, role: role, preserving: existing) + let cancelled = requests.remove(at: index) + pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests + cancelled.continuation.resume(throwing: CancellationError()) + } + + // MARK: - Session Management + + /// Create a session DTO for a contact, optionally preserving data from an existing session. + private func makeSessionDTO( + radioID: UUID, + contact: ContactDTO, + role: RemoteNodeRole, + preserving existing: RemoteNodeSessionDTO? = nil + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: existing?.id ?? UUID(), + radioID: radioID, + publicKey: contact.publicKey, + name: contact.displayName, + role: role, + latitude: contact.latitude, + longitude: contact.longitude, + isConnected: false, + permissionLevel: existing?.permissionLevel ?? .guest, + lastConnectedDate: existing?.lastConnectedDate, + lastBatteryMillivolts: existing?.lastBatteryMillivolts, + lastUptimeSeconds: existing?.lastUptimeSeconds, + lastNoiseFloor: existing?.lastNoiseFloor, + unreadCount: existing?.unreadCount ?? 0, + notificationLevel: existing?.notificationLevel ?? .all, + lastRxAirtimeSeconds: existing?.lastRxAirtimeSeconds, + neighborCount: existing?.neighborCount ?? 0, + lastSyncTimestamp: existing?.lastSyncTimestamp ?? 0, + lastMessageDate: existing?.lastMessageDate + ) + } + + /// Create a new session for a remote node. + public func createSession( + radioID: UUID, + contact: ContactDTO + ) async throws -> RemoteNodeSessionDTO { + guard let role = RemoteNodeRole(contactType: contact.type) else { + throw RemoteNodeError.invalidResponse + } - try await dataStore.saveRemoteNodeSessionDTO(dto) + guard contact.publicKey.count == ProtocolLimits.publicKeySize else { + throw RemoteNodeError.loginFailed("Invalid public key length: expected \(ProtocolLimits.publicKeySize) bytes, got \(contact.publicKey.count)") + } - // Clean up any duplicate sessions with the same public key but different IDs - try await dataStore.cleanupDuplicateRemoteNodeSessions(publicKey: contact.publicKey, keepID: dto.id) + let pubKeyHex = contact.publicKey.prefix(6).map { String(format: "%02x", $0) }.joined() - guard let saved = try await dataStore.fetchRemoteNodeSession(publicKey: contact.publicKey) else { - logger.error("createSession: failed to fetch saved session for \(pubKeyHex)") - throw RemoteNodeError.sessionNotFound - } + // Check for existing session - reuse to avoid duplicates + let existing = try? await dataStore.fetchRemoteNodeSession(publicKey: contact.publicKey) - logger.info("createSession: saved session \(saved.id) for \(pubKeyHex)") - return saved + if let existing { + logger.info("createSession: reusing existing session \(existing.id) for \(pubKeyHex), isConnected=\(existing.isConnected)") + } else { + logger.info("createSession: creating new session for \(pubKeyHex)") } - /// Remove a session and its associated data - public func removeSession(id: UUID, publicKey: Data) async throws { - stopKeepAlive(sessionID: id) - try await keychainService.deletePassword(forNodeKey: publicKey) - try await dataStore.deleteRemoteNodeSession(id: id) - } + let dto = makeSessionDTO(radioID: radioID, contact: contact, role: role, preserving: existing) - /// Check if a password is stored for a contact's public key. - public func hasPassword(forContact contact: ContactDTO) async -> Bool { - await keychainService.hasPassword(forNodeKey: contact.publicKey) - } + try await dataStore.saveRemoteNodeSessionDTO(dto) - /// Retrieve the stored password for a contact's public key. - public func retrievePassword(forContact contact: ContactDTO) async -> String? { - try? await keychainService.retrievePassword(forNodeKey: contact.publicKey) - } + // Clean up any duplicate sessions with the same public key but different IDs + try await dataStore.cleanupDuplicateRemoteNodeSessions(publicKey: contact.publicKey, keepID: dto.id) - /// Store a password for a remote node. - /// Call this after successful login to save correct passwords only. - public func storePassword(_ password: String, forNodeKey publicKey: Data) async throws { - try await keychainService.storePassword(password, forNodeKey: publicKey) + guard let saved = try await dataStore.fetchRemoteNodeSession(publicKey: contact.publicKey) else { + logger.error("createSession: failed to fetch saved session for \(pubKeyHex)") + throw RemoteNodeError.sessionNotFound } - /// Delete the stored password for a contact's public key. - public func deletePassword(forContact contact: ContactDTO) async throws { - try await keychainService.deletePassword(forNodeKey: contact.publicKey) + logger.info("createSession: saved session \(saved.id) for \(pubKeyHex)") + return saved + } + + /// Remove a session and its associated data + public func removeSession(id: UUID, publicKey: Data) async throws { + stopKeepAlive(sessionID: id) + try await keychainService.deletePassword(forNodeKey: publicKey) + try await dataStore.deleteRemoteNodeSession(id: id) + } + + /// Check if a password is stored for a contact's public key. + public func hasPassword(forContact contact: ContactDTO) async -> Bool { + await keychainService.hasPassword(forNodeKey: contact.publicKey) + } + + /// Retrieve the stored password for a contact's public key. + public func retrievePassword(forContact contact: ContactDTO) async -> String? { + try? await keychainService.retrievePassword(forNodeKey: contact.publicKey) + } + + /// Store a password for a remote node. + /// Call this after successful login to save correct passwords only. + public func storePassword(_ password: String, forNodeKey publicKey: Data) async throws { + try await keychainService.storePassword(password, forNodeKey: publicKey) + } + + /// Delete the stored password for a contact's public key. + public func deletePassword(forContact contact: ContactDTO) async throws { + try await keychainService.deletePassword(forNodeKey: contact.publicKey) + } + + // MARK: - Disconnect + + /// Mark session as disconnected without sending logout. + public func disconnect(sessionID: UUID) async { + stopKeepAlive(sessionID: sessionID) + do { + try await dataStore.markSessionDisconnected(sessionID) + } catch { + logger.error("Failed to persist disconnected state for session \(sessionID): \(error)") } - // MARK: - Disconnect + // Notify UI of session state change + eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + } - /// Mark session as disconnected without sending logout. - public func disconnect(sessionID: UUID) async { - stopKeepAlive(sessionID: sessionID) - do { - try await dataStore.markSessionDisconnected(sessionID) - } catch { - logger.error("Failed to persist disconnected state for session \(sessionID): \(error)") - } + // MARK: - Cleanup - // Notify UI of session state change - eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + /// Stop all keep-alive timers and resume any parked login continuations (call on app termination) + public func stopAllKeepAlives() { + for task in keepAliveTasks.values { + task.cancel() } + keepAliveTasks.removeAll() - // MARK: - Cleanup - - /// Stop all keep-alive timers and resume any parked login continuations (call on app termination) - public func stopAllKeepAlives() { - for task in keepAliveTasks.values { - task.cancel() - } - keepAliveTasks.removeAll() - - // Resume parked logins before dropping their timeout tasks; otherwise the - // continuation that would have resumed them is gone and the caller hangs. - for prefix in Array(pendingLogins.keys) { - cancelPendingLogin(for: prefix) - } + // Resume parked logins before dropping their timeout tasks; otherwise the + // continuation that would have resumed them is gone and the caller hangs. + for prefix in Array(pendingLogins.keys) { + cancelPendingLogin(for: prefix) } + } - /// Number of login continuations currently parked. Exposed for teardown tests. - var pendingLoginCount: Int { pendingLogins.count } - + /// Number of login continuations currently parked. Exposed for teardown tests. + var pendingLoginCount: Int { + pendingLogins.count + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteOperationTimeoutPolicy.swift b/MC1Services/Sources/MC1Services/Services/RemoteOperationTimeoutPolicy.swift index a81d9571..d69eb6cf 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteOperationTimeoutPolicy.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteOperationTimeoutPolicy.swift @@ -2,22 +2,22 @@ import Foundation import MeshCore public enum RemoteOperationTimeoutPolicy { - static let firmwareRoundTripMultiplier = 2 - static let loginMaximum: Duration = .seconds(20) - public static let binaryMaximum: Duration = .seconds(15) - static let cliMaximum: Duration = .seconds(15) - static let fireAndForgetCLI: Duration = .seconds(2) - static let pollInterval: Duration = .milliseconds(500) + static let firmwareRoundTripMultiplier = 2 + static let loginMaximum: Duration = .seconds(20) + public static let binaryMaximum: Duration = .seconds(15) + static let cliMaximum: Duration = .seconds(15) + static let fireAndForgetCLI: Duration = .seconds(2) + static let pollInterval: Duration = .milliseconds(500) - static func firmwareRoundTripTimeout(from sentInfo: MessageSentInfo) -> Duration { - .milliseconds(Int(sentInfo.suggestedTimeoutMs) * firmwareRoundTripMultiplier) - } + static func firmwareRoundTripTimeout(from sentInfo: MessageSentInfo) -> Duration { + .milliseconds(Int(sentInfo.suggestedTimeoutMs) * firmwareRoundTripMultiplier) + } - static func loginTimeout(for sentInfo: MessageSentInfo, pathLength: UInt8) -> Duration { - min(max(firmwareRoundTripTimeout(from: sentInfo), LoginTimeoutConfig.timeout(forPathLength: pathLength)), loginMaximum) - } + static func loginTimeout(for sentInfo: MessageSentInfo, pathLength: UInt8) -> Duration { + min(max(firmwareRoundTripTimeout(from: sentInfo), LoginTimeoutConfig.timeout(forPathLength: pathLength)), loginMaximum) + } - static func cliTimeout(for sentInfo: MessageSentInfo, requestedTimeout: Duration) -> Duration { - min(max(requestedTimeout, firmwareRoundTripTimeout(from: sentInfo)), cliMaximum) - } + static func cliTimeout(for sentInfo: MessageSentInfo, requestedTimeout: Duration) -> Duration { + min(max(requestedTimeout, firmwareRoundTripTimeout(from: sentInfo)), cliMaximum) + } } diff --git a/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift b/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift index 9cae9044..e2eb9c73 100644 --- a/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift +++ b/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift @@ -6,10 +6,10 @@ import os /// Sort order options for neighbor queries public enum NeighborSortOrder: UInt8, Sendable { - case newestFirst = 0 - case oldestFirst = 1 - case strongestFirst = 2 - case weakestFirst = 3 + case newestFirst = 0 + case oldestFirst = 1 + case strongestFirst = 2 + case weakestFirst = 3 } // MARK: - Repeater Admin Service @@ -17,321 +17,320 @@ public enum NeighborSortOrder: UInt8, Sendable { /// Service for repeater admin interactions. /// Handles connecting as admin, viewing status/telemetry/neighbors, and sending CLI commands. public actor RepeaterAdminService { - - // MARK: - Properties - - private let session: any RemoteAccessSessionOps - private let remoteNodeService: RemoteNodeService - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "RepeaterAdmin") - private let auditLogger = CommandAuditLogger() - - /// Handler for neighbor responses - public var neighboursResponseHandler: (@Sendable (NeighboursResponse) async -> Void)? - - /// Handler for telemetry responses - public var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? - - /// Handler for status responses - public var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? - - /// Handler for CLI text responses - public var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? - - /// Default pubkey prefix length for neighbor queries. - public static let defaultPubkeyPrefixLength: UInt8 = 6 - - /// Per-request neighbour count used while paginating. A node caps each response to one - /// radio frame regardless, so requesting the maximum minimizes the number of round-trips. - private static let neighborPageSize: UInt8 = 255 - - // MARK: - Initialization - - public init( - session: any RemoteAccessSessionOps, - remoteNodeService: RemoteNodeService, - dataStore: any PersistenceStoreProtocol - ) { - self.session = session - self.remoteNodeService = remoteNodeService - self.dataStore = dataStore - } - - // MARK: - Admin Connection - - /// Connect to a repeater as admin by creating a session and authenticating. - /// - Parameter onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. - public func connectAsAdmin( - radioID: UUID, - contact: ContactDTO, - password: String?, - rememberPassword: Bool = true, - pathLength: UInt8 = 0, - onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil - ) async throws -> RemoteNodeSessionDTO { - let remoteSession = try await remoteNodeService.createSession( - radioID: radioID, - contact: contact - ) - - // Login to the repeater with appropriate timeout - _ = try await remoteNodeService.login( - sessionID: remoteSession.id, - password: password, - pathLength: pathLength, - onTimeoutKnown: onTimeoutKnown - ) - - // Store password only after successful login - if let password, rememberPassword { - try await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) - } - - guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) else { - throw RemoteNodeError.sessionNotFound - } - return updatedSession - } - - /// Disconnect from a repeater by sending logout and removing the session. - public func disconnect(sessionID: UUID, publicKey: Data) async throws { - try await remoteNodeService.logout(sessionID: sessionID) - try await remoteNodeService.removeSession(id: sessionID, publicKey: publicKey) - } - - // MARK: - Neighbors (Repeater-Specific) - - /// Request neighbors list from repeater. - public func requestNeighbors( - sessionID: UUID, - count: UInt8 = 20, - offset: UInt16 = 0, - orderBy: NeighborSortOrder = .newestFirst, - pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength, - timeout: Duration? = nil - ) async throws -> NeighboursResponse { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), - remoteSession.isRepeater else { - throw RemoteNodeError.sessionNotFound - } - - // Log neighbors request - await auditLogger.logNeighborsRequest(publicKey: remoteSession.publicKey, count: count, offset: offset) - - do { - let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum - return try await withTimeout(effectiveTimeout, operationName: "remoteNeighbours") { - try await self.session.requestNeighbours( - from: remoteSession.publicKey, - count: count, - offset: offset, - orderBy: orderBy.rawValue, - pubkeyPrefixLength: pubkeyPrefixLength - ) - } - } catch is TimeoutError { - throw RemoteNodeError.timeout - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } - } - - /// Fetch all neighbors with automatic pagination. - public func fetchAllNeighbors( - sessionID: UUID, - orderBy: NeighborSortOrder = .newestFirst, - pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength, - timeout: Duration? = nil - ) async throws -> NeighboursResponse { - // Paginate over the per-page request so each round-trip keeps its audit log entry - // and timeout ceiling; a single node response is capped to one radio frame. - let response = try await NeighboursResponse.collectingAllPages { offset in - if offset > 0 { try await Task.sleep(for: NeighboursResponse.interPageDelay) } - return try await self.requestNeighbors( - sessionID: sessionID, - count: Self.neighborPageSize, - offset: offset, - orderBy: orderBy, - pubkeyPrefixLength: pubkeyPrefixLength, - timeout: timeout - ) - } - - if response.neighbours.count < response.totalCount { - logger.warning( - "Neighbour pagination returned \(response.neighbours.count) of \(response.totalCount) entries" - ) - } - return response - } - - // MARK: - Status - - /// Request status from a repeater. - public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { - try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) - } - - // MARK: - Telemetry - - /// Request telemetry from a repeater. - public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { - try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) - } - - // MARK: - Owner Info - - /// Request owner info from a repeater using binary protocol. - public func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { - try await remoteNodeService.requestOwnerInfo(sessionID: sessionID, timeout: timeout) - } - - // MARK: - CLI Commands - - /// Send a CLI command to a repeater and wait for response (admin only). - /// Uses content-based matching for structured CLI responses. - public func sendCommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - try await remoteNodeService.sendCLICommand( - sessionID: sessionID, - command: command, - timeout: timeout - ) + // MARK: - Properties + + private let session: any RemoteAccessSessionOps + private let remoteNodeService: RemoteNodeService + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "RepeaterAdmin") + private let auditLogger = CommandAuditLogger() + + /// Handler for neighbor responses + public var neighboursResponseHandler: (@Sendable (NeighboursResponse) async -> Void)? + + /// Handler for telemetry responses + public var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? + + /// Handler for status responses + public var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? + + /// Handler for CLI text responses + public var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? + + /// Default pubkey prefix length for neighbor queries. + public static let defaultPubkeyPrefixLength: UInt8 = 6 + + /// Per-request neighbour count used while paginating. A node caps each response to one + /// radio frame regardless, so requesting the maximum minimizes the number of round-trips. + private static let neighborPageSize: UInt8 = 255 + + // MARK: - Initialization + + public init( + session: any RemoteAccessSessionOps, + remoteNodeService: RemoteNodeService, + dataStore: any PersistenceStoreProtocol + ) { + self.session = session + self.remoteNodeService = remoteNodeService + self.dataStore = dataStore + } + + // MARK: - Admin Connection + + /// Connect to a repeater as admin by creating a session and authenticating. + /// - Parameter onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. + public func connectAsAdmin( + radioID: UUID, + contact: ContactDTO, + password: String?, + rememberPassword: Bool = true, + pathLength: UInt8 = 0, + onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil + ) async throws -> RemoteNodeSessionDTO { + let remoteSession = try await remoteNodeService.createSession( + radioID: radioID, + contact: contact + ) + + // Login to the repeater with appropriate timeout + _ = try await remoteNodeService.login( + sessionID: remoteSession.id, + password: password, + pathLength: pathLength, + onTimeoutKnown: onTimeoutKnown + ) + + // Store password only after successful login + if let password, rememberPassword { + try await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) } - /// Send a raw CLI command using FIFO response matching (admin only). - /// Used by CLI tool for passthrough where any response is accepted. - public func sendRawCommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - try await remoteNodeService.sendRawCLICommand( - sessionID: sessionID, - command: command, - timeout: timeout - ) - } - - // MARK: - Session Queries - - /// Fetch all repeater sessions for a device. - public func fetchRepeaterSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { - let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) - return sessions.filter { $0.isRepeater } + guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) else { + throw RemoteNodeError.sessionNotFound } - - /// Check if a contact is a known repeater with an active session. - public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), - remoteSession.isRepeater && remoteSession.isConnected else { - return nil - } - return remoteSession - } - - // MARK: - Handler Invocation - - /// Invoke the status response handler safely from actor context - public func invokeStatusHandler(_ status: StatusResponse) async { - // Log status response - await auditLogger.logStatusResponse( - target: .repeater, - publicKey: status.publicKeyPrefix, - batteryMv: status.batteryMillivolts, - uptimeSec: status.uptimeSeconds - ) - - guard let handler = statusResponseHandler else { - let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() - logger.debug("No status handler registered for response from \(prefixHex), ignoring") - return - } - await handler(status) + return updatedSession + } + + /// Disconnect from a repeater by sending logout and removing the session. + public func disconnect(sessionID: UUID, publicKey: Data) async throws { + try await remoteNodeService.logout(sessionID: sessionID) + try await remoteNodeService.removeSession(id: sessionID, publicKey: publicKey) + } + + // MARK: - Neighbors (Repeater-Specific) + + /// Request neighbors list from repeater. + public func requestNeighbors( + sessionID: UUID, + count: UInt8 = 20, + offset: UInt16 = 0, + orderBy: NeighborSortOrder = .newestFirst, + pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength, + timeout: Duration? = nil + ) async throws -> NeighboursResponse { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), + remoteSession.isRepeater else { + throw RemoteNodeError.sessionNotFound } - /// Invoke the neighbours response handler safely from actor context - public func invokeNeighboursHandler(_ response: NeighboursResponse) async { - // Log neighbors response - await auditLogger.logNeighborsResponse( - publicKey: response.publicKeyPrefix, - totalCount: Int(response.totalCount), - returnedCount: response.neighbours.count + // Log neighbors request + await auditLogger.logNeighborsRequest(publicKey: remoteSession.publicKey, count: count, offset: offset) + + do { + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteNeighbours") { + try await self.session.requestNeighbours( + from: remoteSession.publicKey, + count: count, + offset: offset, + orderBy: orderBy.rawValue, + pubkeyPrefixLength: pubkeyPrefixLength ) - - guard let handler = neighboursResponseHandler else { - logger.debug("No neighbours handler registered, ignoring response with \(response.neighbours.count) neighbours") - return - } - await handler(response) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } - - /// Invoke the telemetry response handler safely from actor context - public func invokeTelemetryHandler(_ response: TelemetryResponse) async { - // Log telemetry response - await auditLogger.logTelemetryResponse( - target: .repeater, - publicKey: response.publicKeyPrefix, - pointCount: response.dataPoints.count - ) - - guard let handler = telemetryResponseHandler else { - logger.debug("No telemetry handler registered, ignoring response") - return - } - await handler(response) + } + + /// Fetch all neighbors with automatic pagination. + public func fetchAllNeighbors( + sessionID: UUID, + orderBy: NeighborSortOrder = .newestFirst, + pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength, + timeout: Duration? = nil + ) async throws -> NeighboursResponse { + // Paginate over the per-page request so each round-trip keeps its audit log entry + // and timeout ceiling; a single node response is capped to one radio frame. + let response = try await NeighboursResponse.collectingAllPages { offset in + if offset > 0 { try await Task.sleep(for: NeighboursResponse.interPageDelay) } + return try await self.requestNeighbors( + sessionID: sessionID, + count: Self.neighborPageSize, + offset: offset, + orderBy: orderBy, + pubkeyPrefixLength: pubkeyPrefixLength, + timeout: timeout + ) } - /// Invoke the CLI response handler safely from actor context - public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { - // Log CLI response (full content) - await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) - - guard let handler = cliResponseHandler else { - logger.debug("No CLI handler registered, ignoring response from \(contact.displayName)") - return - } - await handler(message, contact) + if response.neighbours.count < response.totalCount { + logger.warning( + "Neighbour pagination returned \(response.neighbours.count) of \(response.totalCount) entries" + ) } - - // MARK: - Handler Setters - - /// Set handler for status responses - public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { - self.statusResponseHandler = handler + return response + } + + // MARK: - Status + + /// Request status from a repeater. + public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { + try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) + } + + // MARK: - Telemetry + + /// Request telemetry from a repeater. + public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { + try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) + } + + // MARK: - Owner Info + + /// Request owner info from a repeater using binary protocol. + public func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { + try await remoteNodeService.requestOwnerInfo(sessionID: sessionID, timeout: timeout) + } + + // MARK: - CLI Commands + + /// Send a CLI command to a repeater and wait for response (admin only). + /// Uses content-based matching for structured CLI responses. + public func sendCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + /// Send a raw CLI command using FIFO response matching (admin only). + /// Used by CLI tool for passthrough where any response is accepted. + public func sendRawCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendRawCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + // MARK: - Session Queries + + /// Fetch all repeater sessions for a device. + public func fetchRepeaterSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) + return sessions.filter(\.isRepeater) + } + + /// Check if a contact is a known repeater with an active session. + public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), + remoteSession.isRepeater, remoteSession.isConnected else { + return nil } - - /// Set handler for neighbours responses - public func setNeighboursHandler(_ handler: @escaping @Sendable (NeighboursResponse) async -> Void) { - self.neighboursResponseHandler = handler + return remoteSession + } + + // MARK: - Handler Invocation + + /// Invoke the status response handler safely from actor context + public func invokeStatusHandler(_ status: StatusResponse) async { + // Log status response + await auditLogger.logStatusResponse( + target: .repeater, + publicKey: status.publicKeyPrefix, + batteryMv: status.batteryMillivolts, + uptimeSec: status.uptimeSeconds + ) + + guard let handler = statusResponseHandler else { + let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + logger.debug("No status handler registered for response from \(prefixHex), ignoring") + return } - - /// Set handler for telemetry responses - public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { - self.telemetryResponseHandler = handler + await handler(status) + } + + /// Invoke the neighbours response handler safely from actor context + public func invokeNeighboursHandler(_ response: NeighboursResponse) async { + // Log neighbors response + await auditLogger.logNeighborsResponse( + publicKey: response.publicKeyPrefix, + totalCount: Int(response.totalCount), + returnedCount: response.neighbours.count + ) + + guard let handler = neighboursResponseHandler else { + logger.debug("No neighbours handler registered, ignoring response with \(response.neighbours.count) neighbours") + return } - - /// Set handler for CLI responses - public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { - self.cliResponseHandler = handler + await handler(response) + } + + /// Invoke the telemetry response handler safely from actor context + public func invokeTelemetryHandler(_ response: TelemetryResponse) async { + // Log telemetry response + await auditLogger.logTelemetryResponse( + target: .repeater, + publicKey: response.publicKeyPrefix, + pointCount: response.dataPoints.count + ) + + guard let handler = telemetryResponseHandler else { + logger.debug("No telemetry handler registered, ignoring response") + return } + await handler(response) + } - /// Clear all handlers (called when view disappears) - public func clearHandlers() { - self.statusResponseHandler = nil - self.neighboursResponseHandler = nil - self.telemetryResponseHandler = nil - self.cliResponseHandler = nil - } + /// Invoke the CLI response handler safely from actor context + public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { + // Log CLI response (full content) + await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) - /// Clears only the status-surface handlers so the merged admin surface can tear down its - /// status segment without dropping the settings VM's CLI handler on the shared per-connection service. - public func clearStatusHandlers() { - self.statusResponseHandler = nil - self.neighboursResponseHandler = nil - self.telemetryResponseHandler = nil + guard let handler = cliResponseHandler else { + logger.debug("No CLI handler registered, ignoring response from \(contact.displayName)") + return } + await handler(message, contact) + } + + // MARK: - Handler Setters + + /// Set handler for status responses + public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { + statusResponseHandler = handler + } + + /// Set handler for neighbours responses + public func setNeighboursHandler(_ handler: @escaping @Sendable (NeighboursResponse) async -> Void) { + neighboursResponseHandler = handler + } + + /// Set handler for telemetry responses + public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { + telemetryResponseHandler = handler + } + + /// Set handler for CLI responses + public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { + cliResponseHandler = handler + } + + /// Clear all handlers (called when view disappears) + public func clearHandlers() { + statusResponseHandler = nil + neighboursResponseHandler = nil + telemetryResponseHandler = nil + cliResponseHandler = nil + } + + /// Clears only the status-surface handlers so the merged admin surface can tear down its + /// status segment without dropping the settings VM's CLI handler on the shared per-connection service. + public func clearStatusHandlers() { + statusResponseHandler = nil + neighboursResponseHandler = nil + telemetryResponseHandler = nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/ResolvedFloodScope.swift b/MC1Services/Sources/MC1Services/Services/ResolvedFloodScope.swift index 9b36c1b6..02cd548c 100644 --- a/MC1Services/Sources/MC1Services/Services/ResolvedFloodScope.swift +++ b/MC1Services/Sources/MC1Services/Services/ResolvedFloodScope.swift @@ -8,9 +8,9 @@ import MeshCore /// resets the session scope and lets the device fall back to its persisted default, so it /// cannot stand in for an explicit "all regions" override on firmware that supports one. public enum ResolvedFloodScope: Sendable, Equatable { - /// Force un-scoped flood broadcasts, overriding the device default. Push via - /// ``MeshCoreSession/setFloodScopeUnscoped()``. Requires firmware v12+. - case unscoped - /// Push a concrete ``FloodScope`` via ``MeshCoreSession/setFloodScope(_:)``. - case scope(FloodScope) + /// Force un-scoped flood broadcasts, overriding the device default. Push via + /// ``MeshCoreSession/setFloodScopeUnscoped()``. Requires firmware v12+. + case unscoped + /// Push a concrete ``FloodScope`` via ``MeshCoreSession/setFloodScope(_:)``. + case scope(FloodScope) } diff --git a/MC1Services/Sources/MC1Services/Services/RestoreOutcome.swift b/MC1Services/Sources/MC1Services/Services/RestoreOutcome.swift index e85dc9bc..00104313 100644 --- a/MC1Services/Sources/MC1Services/Services/RestoreOutcome.swift +++ b/MC1Services/Sources/MC1Services/Services/RestoreOutcome.swift @@ -4,6 +4,6 @@ import Foundation /// iCloud password prompt or the AppStore-sync confirmation sheet is an outcome, not an error /// (the caller must avoid celebrating a cancellation as a successful restore). public enum RestoreOutcome: Sendable, Equatable { - case completed - case cancelled + case completed + case cancelled } diff --git a/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift b/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift index 961bd6a4..5e24433b 100644 --- a/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift +++ b/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift @@ -6,158 +6,157 @@ import os /// Handles viewing status/telemetry and sending CLI commands to room servers. /// Room authentication is handled by `RoomServerService.joinRoom()` via `NodeAuthenticationSheet`. public actor RoomAdminService { - - // MARK: - Properties - - private let remoteNodeService: RemoteNodeService - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomAdmin") - private let auditLogger = CommandAuditLogger() - - private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? - private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? - private var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? - - // MARK: - Initialization - - public init( - remoteNodeService: RemoteNodeService, - dataStore: any PersistenceStoreProtocol - ) { - self.remoteNodeService = remoteNodeService - self.dataStore = dataStore + // MARK: - Properties + + private let remoteNodeService: RemoteNodeService + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomAdmin") + private let auditLogger = CommandAuditLogger() + + private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? + private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? + private var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? + + // MARK: - Initialization + + public init( + remoteNodeService: RemoteNodeService, + dataStore: any PersistenceStoreProtocol + ) { + self.remoteNodeService = remoteNodeService + self.dataStore = dataStore + } + + // MARK: - Status + + /// Request status from a room server. + public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { + try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) + } + + // MARK: - Telemetry + + /// Request telemetry from a room server. + public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { + try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) + } + + // MARK: - CLI Commands + + /// Send a CLI command to a room server and wait for response (admin only). + /// Uses content-based matching for structured CLI responses. + public func sendCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + /// Send a raw CLI command using FIFO response matching (admin only). + public func sendRawCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendRawCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + // MARK: - Session Queries + + /// Fetch all room admin sessions for a device. + public func fetchRoomAdminSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) + return sessions.filter(\.isRoom) + } + + /// Check if a contact is a known room with an active session. + public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), + remoteSession.isRoom, remoteSession.isConnected else { + return nil } - - // MARK: - Status - - /// Request status from a room server. - public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { - try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) + return remoteSession + } + + // MARK: - Handler Invocation + + /// Invoke the status response handler safely from actor context + public func invokeStatusHandler(_ status: StatusResponse) async { + await auditLogger.logStatusResponse( + target: .room, + publicKey: status.publicKeyPrefix, + batteryMv: status.batteryMillivolts, + uptimeSec: status.uptimeSeconds + ) + + guard let handler = statusResponseHandler else { + let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + logger.debug("No status handler registered for room response from \(prefixHex), ignoring") + return } - - // MARK: - Telemetry - - /// Request telemetry from a room server. - public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { - try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) + await handler(status) + } + + /// Invoke the telemetry response handler safely from actor context + public func invokeTelemetryHandler(_ response: TelemetryResponse) async { + await auditLogger.logTelemetryResponse( + target: .room, + publicKey: response.publicKeyPrefix, + pointCount: response.dataPoints.count + ) + + guard let handler = telemetryResponseHandler else { + logger.debug("No telemetry handler registered for room, ignoring response") + return } + await handler(response) + } - // MARK: - CLI Commands - - /// Send a CLI command to a room server and wait for response (admin only). - /// Uses content-based matching for structured CLI responses. - public func sendCommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - try await remoteNodeService.sendCLICommand( - sessionID: sessionID, - command: command, - timeout: timeout - ) - } - - /// Send a raw CLI command using FIFO response matching (admin only). - public func sendRawCommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - try await remoteNodeService.sendRawCLICommand( - sessionID: sessionID, - command: command, - timeout: timeout - ) - } - - // MARK: - Session Queries - - /// Fetch all room admin sessions for a device. - public func fetchRoomAdminSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { - let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) - return sessions.filter { $0.isRoom } - } - - /// Check if a contact is a known room with an active session. - public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), - remoteSession.isRoom && remoteSession.isConnected else { - return nil - } - return remoteSession - } - - // MARK: - Handler Invocation - - /// Invoke the status response handler safely from actor context - public func invokeStatusHandler(_ status: StatusResponse) async { - await auditLogger.logStatusResponse( - target: .room, - publicKey: status.publicKeyPrefix, - batteryMv: status.batteryMillivolts, - uptimeSec: status.uptimeSeconds - ) - - guard let handler = statusResponseHandler else { - let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() - logger.debug("No status handler registered for room response from \(prefixHex), ignoring") - return - } - await handler(status) - } - - /// Invoke the telemetry response handler safely from actor context - public func invokeTelemetryHandler(_ response: TelemetryResponse) async { - await auditLogger.logTelemetryResponse( - target: .room, - publicKey: response.publicKeyPrefix, - pointCount: response.dataPoints.count - ) - - guard let handler = telemetryResponseHandler else { - logger.debug("No telemetry handler registered for room, ignoring response") - return - } - await handler(response) - } - - /// Invoke the CLI response handler safely from actor context - public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { - await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) - - guard let handler = cliResponseHandler else { - logger.debug("No CLI handler registered for room, ignoring response from \(contact.displayName)") - return - } - await handler(message, contact) - } - - // MARK: - Handler Setters - - public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { - self.statusResponseHandler = handler - } - - public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { - self.telemetryResponseHandler = handler - } - - public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { - self.cliResponseHandler = handler - } - - /// Clear all handlers (called when view disappears) - public func clearHandlers() { - self.statusResponseHandler = nil - self.telemetryResponseHandler = nil - self.cliResponseHandler = nil - } + /// Invoke the CLI response handler safely from actor context + public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { + await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) - /// Clears only the status-surface handlers so the merged admin surface can tear down its - /// status segment without dropping the settings VM's CLI handler on the shared per-connection service. - public func clearStatusHandlers() { - self.statusResponseHandler = nil - self.telemetryResponseHandler = nil + guard let handler = cliResponseHandler else { + logger.debug("No CLI handler registered for room, ignoring response from \(contact.displayName)") + return } + await handler(message, contact) + } + + // MARK: - Handler Setters + + public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { + statusResponseHandler = handler + } + + public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { + telemetryResponseHandler = handler + } + + public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { + cliResponseHandler = handler + } + + /// Clear all handlers (called when view disappears) + public func clearHandlers() { + statusResponseHandler = nil + telemetryResponseHandler = nil + cliResponseHandler = nil + } + + /// Clears only the status-surface handlers so the merged admin surface can tear down its + /// status segment without dropping the settings VM's CLI handler on the shared per-connection service. + public func clearStatusHandlers() { + statusResponseHandler = nil + telemetryResponseHandler = nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/RoomServerEvent.swift b/MC1Services/Sources/MC1Services/Services/RoomServerEvent.swift index 7dd1c07e..eb5ce56a 100644 --- a/MC1Services/Sources/MC1Services/Services/RoomServerEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/RoomServerEvent.swift @@ -3,8 +3,8 @@ import Foundation /// Room-server notifications broadcast by `RoomServerService.events()`. /// The stream is multicast: every subscriber receives every event. public enum RoomServerEvent: Sendable { - /// An outbound room message's delivery status changed. - case statusUpdated(messageID: UUID, status: MessageStatus) - /// An incoming message recovered a disconnected room session. - case connectionRecovered(sessionID: UUID) + /// An outbound room message's delivery status changed. + case statusUpdated(messageID: UUID, status: MessageStatus) + /// An incoming message recovered a disconnected room session. + case connectionRecovered(sessionID: UUID) } diff --git a/MC1Services/Sources/MC1Services/Services/RoomServerService.swift b/MC1Services/Sources/MC1Services/Services/RoomServerService.swift index edf144df..c2f73fba 100644 --- a/MC1Services/Sources/MC1Services/Services/RoomServerService.swift +++ b/MC1Services/Sources/MC1Services/Services/RoomServerService.swift @@ -5,25 +5,25 @@ import os // MARK: - Room Server Errors public enum RoomServerError: Error, Sendable { - case notConnected - case sessionNotFound - case sendFailed(String) - case permissionDenied - case invalidResponse - case sessionError(MeshCoreError) + case notConnected + case sessionNotFound + case sendFailed(String) + case permissionDenied + case invalidResponse + case sessionError(MeshCoreError) } extension RoomServerError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .sessionNotFound: "Room session not found." - case .sendFailed(let msg): "Send failed: \(msg)" - case .permissionDenied: "Permission denied." - case .invalidResponse: "Invalid response from device." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .sessionNotFound: "Room session not found." + case let .sendFailed(msg): "Send failed: \(msg)" + case .permissionDenied: "Permission denied." + case .invalidResponse: "Invalid response from device." + case let .sessionError(e): e.localizedDescription } + } } // MARK: - Room Server Service @@ -31,576 +31,575 @@ extension RoomServerError: LocalizedError { /// Service for room server interactions. /// Handles joining rooms, posting messages, and receiving room messages. public actor RoomServerService { - - // MARK: - Properties - - private let session: any RemoteAccessSessionOps - private let remoteNodeService: RemoteNodeService - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomServer") - private let auditLogger = CommandAuditLogger() - - /// Self public key prefix for author comparison. - /// Set from SelfInfo when device connects. - private var selfPublicKeyPrefix: Data? - - /// Configuration for retry behavior (shared with MessageService) - private let config: MessageServiceConfig - - /// Multicast broadcaster for status-update and connection-recovery events. - private nonisolated let eventBroadcaster = EventBroadcaster() - - /// Tracks message IDs currently being retried to prevent concurrent retry attempts - private var inFlightRetries: Set = [] - - // MARK: - Initialization - - init( - session: any RemoteAccessSessionOps, - remoteNodeService: RemoteNodeService, - dataStore: any PersistenceStoreProtocol, - config: MessageServiceConfig = MessageServiceConfig() - ) { - self.session = session - self.remoteNodeService = remoteNodeService - self.dataStore = dataStore - self.config = config - } - - /// Set self public key prefix from SelfInfo. - /// Call this when device info is received. - public func setSelfPublicKeyPrefix(_ prefix: Data) { - self.selfPublicKeyPrefix = prefix.prefix(4) + // MARK: - Properties + + private let session: any RemoteAccessSessionOps + private let remoteNodeService: RemoteNodeService + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomServer") + private let auditLogger = CommandAuditLogger() + + /// Self public key prefix for author comparison. + /// Set from SelfInfo when device connects. + private var selfPublicKeyPrefix: Data? + + /// Configuration for retry behavior (shared with MessageService) + private let config: MessageServiceConfig + + /// Multicast broadcaster for status-update and connection-recovery events. + private nonisolated let eventBroadcaster = EventBroadcaster() + + /// Tracks message IDs currently being retried to prevent concurrent retry attempts + private var inFlightRetries: Set = [] + + // MARK: - Initialization + + init( + session: any RemoteAccessSessionOps, + remoteNodeService: RemoteNodeService, + dataStore: any PersistenceStoreProtocol, + config: MessageServiceConfig = MessageServiceConfig() + ) { + self.session = session + self.remoteNodeService = remoteNodeService + self.dataStore = dataStore + self.config = config + } + + /// Set self public key prefix from SelfInfo. + /// Call this when device info is received. + public func setSelfPublicKeyPrefix(_ prefix: Data) { + selfPublicKeyPrefix = prefix.prefix(4) + } + + // MARK: - Events + + /// Returns a fresh stream of room-server events. Registration is + /// synchronous, so events yielded after this call are never dropped. + /// Consumers must re-subscribe per connection because the owning + /// `ServiceContainer` is rebuilt on every connection. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } + + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } + + // MARK: - Room Management + + /// Join a room server by creating a session and authenticating. + /// Automatically syncs message history based on local state. + /// - Parameters: + /// - radioID: The companion radio device ID + /// - contact: The room server contact + /// - password: Authentication password (uses keychain if not provided) + /// - rememberPassword: Whether to store password in keychain + /// - pathLength: Path length for timeout calculation (0 = direct) + /// - onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. + /// - Returns: The authenticated session + public func joinRoom( + radioID: UUID, + contact: ContactDTO, + password: String?, + rememberPassword: Bool = true, + pathLength: UInt8 = 0, + onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil + ) async throws -> RemoteNodeSessionDTO { + let remoteSession = try await remoteNodeService.createSession( + radioID: radioID, + contact: contact + ) + + // Login to the room + _ = try await remoteNodeService.login( + sessionID: remoteSession.id, + password: password, + pathLength: pathLength, + onTimeoutKnown: onTimeoutKnown + ) + + // Store password only after successful login + if let password, rememberPassword { + try await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) } - // MARK: - Events + // Attempt additional history sync if needed (non-blocking) + await syncHistoryIfPossible(sessionID: remoteSession.id) - /// Returns a fresh stream of room-server events. Registration is - /// synchronous, so events yielded after this call are never dropped. - /// Consumers must re-subscribe per connection because the owning - /// `ServiceContainer` is rebuilt on every connection. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() + guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) else { + throw RemoteNodeError.sessionNotFound } - - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishEvents() { - eventBroadcaster.finish() + return updatedSession + } + + /// Reconnect to an existing room session and sync any missed messages. + /// Use this when re-authenticating to a room after app restart or BLE reconnection. + /// - Parameters: + /// - sessionID: The existing room session ID + /// - pathLength: Optional path length hint (0 = use shortest known path) + /// - Returns: Updated session DTO + /// - Throws: RemoteNodeError if reconnection fails + public func reconnectRoom( + sessionID: UUID, + pathLength: UInt8 = 0 + ) async throws -> RemoteNodeSessionDTO { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound } - // MARK: - Room Management - - /// Join a room server by creating a session and authenticating. - /// Automatically syncs message history based on local state. - /// - Parameters: - /// - radioID: The companion radio device ID - /// - contact: The room server contact - /// - password: Authentication password (uses keychain if not provided) - /// - rememberPassword: Whether to store password in keychain - /// - pathLength: Path length for timeout calculation (0 = direct) - /// - onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. - /// - Returns: The authenticated session - public func joinRoom( - radioID: UUID, - contact: ContactDTO, - password: String?, - rememberPassword: Bool = true, - pathLength: UInt8 = 0, - onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil - ) async throws -> RemoteNodeSessionDTO { - let remoteSession = try await remoteNodeService.createSession( - radioID: radioID, - contact: contact - ) - - // Login to the room - _ = try await remoteNodeService.login( - sessionID: remoteSession.id, - password: password, - pathLength: pathLength, - onTimeoutKnown: onTimeoutKnown - ) - - // Store password only after successful login - if let password, rememberPassword { - try await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) - } - - // Attempt additional history sync if needed (non-blocking) - await syncHistoryIfPossible(sessionID: remoteSession.id) - - guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) else { - throw RemoteNodeError.sessionNotFound - } - return updatedSession + guard remoteSession.isRoom else { + throw RemoteNodeError.invalidResponse } - /// Reconnect to an existing room session and sync any missed messages. - /// Use this when re-authenticating to a room after app restart or BLE reconnection. - /// - Parameters: - /// - sessionID: The existing room session ID - /// - pathLength: Optional path length hint (0 = use shortest known path) - /// - Returns: Updated session DTO - /// - Throws: RemoteNodeError if reconnection fails - public func reconnectRoom( - sessionID: UUID, - pathLength: UInt8 = 0 - ) async throws -> RemoteNodeSessionDTO { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + // Re-authenticate to the room + _ = try await remoteNodeService.login( + sessionID: sessionID, + pathLength: pathLength + ) - guard remoteSession.isRoom else { - throw RemoteNodeError.invalidResponse - } + // Attempt additional history sync if needed (non-blocking) + await syncHistoryIfPossible(sessionID: sessionID) - // Re-authenticate to the room - _ = try await remoteNodeService.login( - sessionID: sessionID, - pathLength: pathLength - ) - - // Attempt additional history sync if needed (non-blocking) - await syncHistoryIfPossible(sessionID: sessionID) - - guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } - return updatedSession + guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } + return updatedSession + } + + /// Leave a room by sending logout and removing the session. + /// - Parameters: + /// - sessionID: The session to leave + /// - publicKey: The room's public key (for keychain cleanup) + public func leaveRoom(sessionID: UUID, publicKey: Data) async throws { + try await remoteNodeService.logout(sessionID: sessionID) + try await remoteNodeService.removeSession(id: sessionID, publicKey: publicKey) + } + + // MARK: - Message Posting + + /// Post a message to a room server. + /// + /// Posts use `TextType.plain`. The room server converts to `signedPlain` + /// when pushing to other clients. The server does not push messages back + /// to their authors, so the local message record is created immediately. + /// - Parameters: + /// - sessionID: The room session + /// - text: The message text + /// - Returns: The saved message DTO + public func postMessage(sessionID: UUID, text: String) async throws -> RoomMessageDTO { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RoomServerError.sessionNotFound } - /// Leave a room by sending logout and removing the session. - /// - Parameters: - /// - sessionID: The session to leave - /// - publicKey: The room's public key (for keychain cleanup) - public func leaveRoom(sessionID: UUID, publicKey: Data) async throws { - try await remoteNodeService.logout(sessionID: sessionID) - try await remoteNodeService.removeSession(id: sessionID, publicKey: publicKey) + guard remoteSession.canPost else { + throw RoomServerError.permissionDenied } - // MARK: - Message Posting - - /// Post a message to a room server. - /// - /// Posts use `TextType.plain`. The room server converts to `signedPlain` - /// when pushing to other clients. The server does not push messages back - /// to their authors, so the local message record is created immediately. - /// - Parameters: - /// - sessionID: The room session - /// - text: The message text - /// - Returns: The saved message DTO - public func postMessage(sessionID: UUID, text: String) async throws -> RoomMessageDTO { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RoomServerError.sessionNotFound - } + let timestamp = Date() + let messageID = UUID() + + // Create local message record immediately with pending status + let messageDTO = RoomMessageDTO( + id: messageID, + sessionID: sessionID, + authorKeyPrefix: selfPublicKeyPrefix ?? Data(repeating: 0, count: 4), + authorName: "Me", + text: text, + timestamp: UInt32(timestamp.timeIntervalSince1970), + isFromSelf: true, + status: .pending, + maxRetryAttempts: config.maxAttempts + ) + + try await dataStore.saveRoomMessage(messageDTO) + + // Log room message posted (metadata only, no content) + await auditLogger.logRoomMessagePosted(publicKey: remoteSession.publicKey, messageLength: text.count) + + // Send message in background so UI can show pending message immediately + Task { [weak self] in + guard let self else { return } + + // Send message with retry logic + // NOTE: sendMessageWithRetry requires full 32-byte public key for path reset + do { + let sentInfo = try await session.sendMessageWithRetry( + to: remoteSession.publicKey, // Full 32-byte key required + text: text, + timestamp: timestamp, + maxAttempts: config.maxAttempts, + floodAfter: config.floodAfter, + maxFloodAttempts: config.maxFloodAttempts + ) - guard remoteSession.canPost else { - throw RoomServerError.permissionDenied + if let sentInfo { + // Success - update to delivered + let ackCodeUInt32 = sentInfo.expectedAck.ackCodeUInt32 + // Handle database errors gracefully - don't lose send success state + do { + try await dataStore.updateRoomMessageStatus( + id: messageID, + status: .delivered, + ackCode: ackCodeUInt32, + roundTripTime: UInt32(sentInfo.suggestedTimeoutMs) + ) + } catch { + logger.error("Failed to update message status after successful send: \(error)") + } + eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .delivered)) + } else { + // All retries exhausted — radio transmitted but no ACK received. + // Mark as sent (not failed) since the message likely reached the room server. + do { + try await dataStore.updateRoomMessageStatus( + id: messageID, + status: .sent, + ackCode: nil, + roundTripTime: nil + ) + } catch { + logger.error("Failed to update message status to sent: \(error)") + } + eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .sent)) } - - let timestamp = Date() - let messageID = UUID() - - // Create local message record immediately with pending status - let messageDTO = RoomMessageDTO( + // Update sort date only (no sync bookmark — avoids clock skew issues) + try? await dataStore.updateRoomActivity(sessionID) + } catch { + // Send failed with error — do not update activity + do { + try await dataStore.updateRoomMessageStatus( id: messageID, - sessionID: sessionID, - authorKeyPrefix: selfPublicKeyPrefix ?? Data(repeating: 0, count: 4), - authorName: "Me", - text: text, - timestamp: UInt32(timestamp.timeIntervalSince1970), - isFromSelf: true, - status: .pending, - maxRetryAttempts: config.maxAttempts - ) - - try await dataStore.saveRoomMessage(messageDTO) - - // Log room message posted (metadata only, no content) - await auditLogger.logRoomMessagePosted(publicKey: remoteSession.publicKey, messageLength: text.count) - - // Send message in background so UI can show pending message immediately - Task { [weak self] in - guard let self else { return } - - // Send message with retry logic - // NOTE: sendMessageWithRetry requires full 32-byte public key for path reset - do { - let sentInfo = try await session.sendMessageWithRetry( - to: remoteSession.publicKey, // Full 32-byte key required - text: text, - timestamp: timestamp, - maxAttempts: config.maxAttempts, - floodAfter: config.floodAfter, - maxFloodAttempts: config.maxFloodAttempts - ) - - if let sentInfo { - // Success - update to delivered - let ackCodeUInt32 = sentInfo.expectedAck.ackCodeUInt32 - // Handle database errors gracefully - don't lose send success state - do { - try await dataStore.updateRoomMessageStatus( - id: messageID, - status: .delivered, - ackCode: ackCodeUInt32, - roundTripTime: UInt32(sentInfo.suggestedTimeoutMs) - ) - } catch { - logger.error("Failed to update message status after successful send: \(error)") - } - eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .delivered)) - } else { - // All retries exhausted — radio transmitted but no ACK received. - // Mark as sent (not failed) since the message likely reached the room server. - do { - try await dataStore.updateRoomMessageStatus( - id: messageID, - status: .sent, - ackCode: nil, - roundTripTime: nil - ) - } catch { - logger.error("Failed to update message status to sent: \(error)") - } - eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .sent)) - } - // Update sort date only (no sync bookmark — avoids clock skew issues) - try? await dataStore.updateRoomActivity(sessionID) - } catch { - // Send failed with error — do not update activity - do { - try await dataStore.updateRoomMessageStatus( - id: messageID, - status: .failed, - ackCode: nil, - roundTripTime: nil - ) - } catch let dbError { - logger.error("Failed to update message status after send error: \(dbError)") - } - eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .failed)) - logger.warning("Room message send failed: \(error)") - } + status: .failed, + ackCode: nil, + roundTripTime: nil + ) + } catch let dbError { + logger.error("Failed to update message status after send error: \(dbError)") } - - // Return pending message immediately so UI can display it - return messageDTO + eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .failed)) + logger.warning("Room message send failed: \(error)") + } } - /// Retry sending a failed room message. - /// - Parameter id: The message ID to retry - /// - Returns: Updated message DTO - public func retryMessage(id: UUID) async throws -> RoomMessageDTO { - // Guard against concurrent retries - guard !inFlightRetries.contains(id) else { - logger.warning("Retry already in progress for message: \(id)") - throw RoomServerError.sendFailed("Retry already in progress") - } - - inFlightRetries.insert(id) - defer { inFlightRetries.remove(id) } + // Return pending message immediately so UI can display it + return messageDTO + } + + /// Retry sending a failed room message. + /// - Parameter id: The message ID to retry + /// - Returns: Updated message DTO + public func retryMessage(id: UUID) async throws -> RoomMessageDTO { + // Guard against concurrent retries + guard !inFlightRetries.contains(id) else { + logger.warning("Retry already in progress for message: \(id)") + throw RoomServerError.sendFailed("Retry already in progress") + } - guard let message = try await dataStore.fetchRoomMessage(id: id) else { - throw RoomServerError.sendFailed("Message not found") - } + inFlightRetries.insert(id) + defer { inFlightRetries.remove(id) } - guard message.status == .failed else { - throw RoomServerError.sendFailed("Message is not in failed state") - } + guard let message = try await dataStore.fetchRoomMessage(id: id) else { + throw RoomServerError.sendFailed("Message not found") + } - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: message.sessionID) else { - throw RoomServerError.sessionNotFound - } + guard message.status == .failed else { + throw RoomServerError.sendFailed("Message is not in failed state") + } - // Update to pending/retrying - let newRetryAttempt = message.retryAttempt + 1 - try await dataStore.updateRoomMessageRetryStatus( - id: id, - status: .pending, - retryAttempt: newRetryAttempt, - maxRetryAttempts: config.maxAttempts - ) - eventBroadcaster.yield(.statusUpdated(messageID: id, status: .pending)) + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: message.sessionID) else { + throw RoomServerError.sessionNotFound + } - // Retry send with retry logic - // NOTE: sendMessageWithRetry requires full 32-byte public key for path reset + // Update to pending/retrying + let newRetryAttempt = message.retryAttempt + 1 + try await dataStore.updateRoomMessageRetryStatus( + id: id, + status: .pending, + retryAttempt: newRetryAttempt, + maxRetryAttempts: config.maxAttempts + ) + eventBroadcaster.yield(.statusUpdated(messageID: id, status: .pending)) + + // Retry send with retry logic + // NOTE: sendMessageWithRetry requires full 32-byte public key for path reset + do { + let sentInfo = try await session.sendMessageWithRetry( + to: remoteSession.publicKey, // Full 32-byte key required + text: message.text, + timestamp: Date(), + maxAttempts: config.maxAttempts, + floodAfter: config.floodAfter, + maxFloodAttempts: config.maxFloodAttempts + ) + + if let sentInfo { + let ackCodeUInt32 = sentInfo.expectedAck.ackCodeUInt32 do { - let sentInfo = try await session.sendMessageWithRetry( - to: remoteSession.publicKey, // Full 32-byte key required - text: message.text, - timestamp: Date(), - maxAttempts: config.maxAttempts, - floodAfter: config.floodAfter, - maxFloodAttempts: config.maxFloodAttempts - ) - - if let sentInfo { - let ackCodeUInt32 = sentInfo.expectedAck.ackCodeUInt32 - do { - try await dataStore.updateRoomMessageStatus( - id: id, - status: .delivered, - ackCode: ackCodeUInt32, - roundTripTime: UInt32(sentInfo.suggestedTimeoutMs) - ) - } catch { - logger.error("Failed to update message status after successful retry: \(error)") - } - eventBroadcaster.yield(.statusUpdated(messageID: id, status: .delivered)) - } else { - // All retries exhausted — radio transmitted but no ACK received. - // Mark as sent (not failed) since the message likely reached the room server. - do { - try await dataStore.updateRoomMessageStatus( - id: id, - status: .sent, - ackCode: nil, - roundTripTime: nil - ) - } catch { - logger.error("Failed to update message status to sent: \(error)") - } - eventBroadcaster.yield(.statusUpdated(messageID: id, status: .sent)) - } - // Update sort date so room moves to top of conversation list - try? await dataStore.updateRoomActivity(message.sessionID) + try await dataStore.updateRoomMessageStatus( + id: id, + status: .delivered, + ackCode: ackCodeUInt32, + roundTripTime: UInt32(sentInfo.suggestedTimeoutMs) + ) } catch { - do { - try await dataStore.updateRoomMessageStatus( - id: id, - status: .failed, - ackCode: nil, - roundTripTime: nil - ) - } catch let dbError { - logger.error("Failed to update message status after retry error: \(dbError)") - } - eventBroadcaster.yield(.statusUpdated(messageID: id, status: .failed)) - logger.warning("Room message retry failed: \(error)") - } - - guard let updatedMessage = try await dataStore.fetchRoomMessage(id: id) else { - throw RoomServerError.sendFailed("Failed to fetch message after retry") - } - return updatedMessage - } - - // MARK: - Incoming Messages - - /// Handle incoming room message. - /// Called by MessagePollingService when a signedPlain message arrives from a room. - /// - /// Messages arrive as `TextType.signedPlain` with the room server's key as - /// `senderPublicKeyPrefix` and the original author's 4-byte key prefix in - /// the payload (extracted to `extraData` by `decodeMessageV3`). - /// - /// Since room servers don't push messages back to their authors, incoming - /// messages should not be from self. However, we check defensively. - /// - Parameters: - /// - senderPublicKeyPrefix: The room server's 6-byte key prefix - /// - timestamp: Message timestamp from server - /// - authorPrefix: The original author's 4-byte key prefix - /// - text: The message text - /// - Returns: The saved message DTO, or nil if the message was a duplicate - @discardableResult - public func handleIncomingMessage( - senderPublicKeyPrefix: Data, - timestamp: UInt32, - authorPrefix: Data, - text: String - ) async throws -> RoomMessageDTO? { - // Find session by room server's key prefix - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(senderPublicKeyPrefix), - remoteSession.isRoom else { - return nil // Not from a known room - } - - // Receiving any message (even duplicate) proves session is active - if !remoteSession.isConnected { - let recovered = (try? await dataStore.markRoomSessionConnected(remoteSession.id)) ?? false - if recovered { - eventBroadcaster.yield(.connectionRecovered(sessionID: remoteSession.id)) - } + logger.error("Failed to update message status after successful retry: \(error)") } - - // Generate deduplication key - let dedupKey = RoomMessage.generateDeduplicationKey( - timestamp: timestamp, - authorKeyPrefix: authorPrefix, - text: text - ) - - // Check for duplicate using deduplication key - if try await dataStore.isDuplicateRoomMessage( - sessionID: remoteSession.id, - deduplicationKey: dedupKey - ) { - return nil - } - - // Log room message received (metadata only, no content) - await auditLogger.logRoomMessageReceived( - roomPublicKey: senderPublicKeyPrefix, - authorPrefix: authorPrefix, - messageLength: text.count - ) - - // Defensive check: room servers shouldn't push our own messages back - let isFromSelf = selfPublicKeyPrefix?.prefix(4) == authorPrefix.prefix(4) - if isFromSelf { - logger.info("Received self message from room server (unexpected)") + eventBroadcaster.yield(.statusUpdated(messageID: id, status: .delivered)) + } else { + // All retries exhausted — radio transmitted but no ACK received. + // Mark as sent (not failed) since the message likely reached the room server. + do { + try await dataStore.updateRoomMessageStatus( + id: id, + status: .sent, + ackCode: nil, + roundTripTime: nil + ) + } catch { + logger.error("Failed to update message status to sent: \(error)") } - - let authorName = try await resolveAuthorName(keyPrefix: authorPrefix) - - let messageDTO = RoomMessageDTO( - sessionID: remoteSession.id, - authorKeyPrefix: authorPrefix, - authorName: authorName, - text: text, - timestamp: timestamp, - isFromSelf: isFromSelf + eventBroadcaster.yield(.statusUpdated(messageID: id, status: .sent)) + } + // Update sort date so room moves to top of conversation list + try? await dataStore.updateRoomActivity(message.sessionID) + } catch { + do { + try await dataStore.updateRoomMessageStatus( + id: id, + status: .failed, + ackCode: nil, + roundTripTime: nil ) + } catch let dbError { + logger.error("Failed to update message status after retry error: \(dbError)") + } + eventBroadcaster.yield(.statusUpdated(messageID: id, status: .failed)) + logger.warning("Room message retry failed: \(error)") + } - try await dataStore.saveRoomMessage(messageDTO) - - // Update sync bookmark and sort date - try await dataStore.updateRoomActivity(remoteSession.id, syncTimestamp: timestamp) - - // Increment unread count if not from self - if !isFromSelf { - try await dataStore.incrementRoomUnreadCount(remoteSession.id) - } - - return messageDTO + guard let updatedMessage = try await dataStore.fetchRoomMessage(id: id) else { + throw RoomServerError.sendFailed("Failed to fetch message after retry") + } + return updatedMessage + } + + // MARK: - Incoming Messages + + /// Handle incoming room message. + /// Called by MessagePollingService when a signedPlain message arrives from a room. + /// + /// Messages arrive as `TextType.signedPlain` with the room server's key as + /// `senderPublicKeyPrefix` and the original author's 4-byte key prefix in + /// the payload (extracted to `extraData` by `decodeMessageV3`). + /// + /// Since room servers don't push messages back to their authors, incoming + /// messages should not be from self. However, we check defensively. + /// - Parameters: + /// - senderPublicKeyPrefix: The room server's 6-byte key prefix + /// - timestamp: Message timestamp from server + /// - authorPrefix: The original author's 4-byte key prefix + /// - text: The message text + /// - Returns: The saved message DTO, or nil if the message was a duplicate + @discardableResult + public func handleIncomingMessage( + senderPublicKeyPrefix: Data, + timestamp: UInt32, + authorPrefix: Data, + text: String + ) async throws -> RoomMessageDTO? { + // Find session by room server's key prefix + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(senderPublicKeyPrefix), + remoteSession.isRoom else { + return nil // Not from a known room } - // MARK: - Message Retrieval + // Receiving any message (even duplicate) proves session is active + if !remoteSession.isConnected { + let recovered = await (try? dataStore.markRoomSessionConnected(remoteSession.id)) ?? false + if recovered { + eventBroadcaster.yield(.connectionRecovered(sessionID: remoteSession.id)) + } + } - /// Fetch messages for a room session. - /// - Parameters: - /// - sessionID: The room session ID - /// - limit: Maximum number of messages to return - /// - offset: Offset for pagination - /// - Returns: Array of room message DTOs - public func fetchMessages(sessionID: UUID, limit: Int? = nil, offset: Int? = nil) async throws -> [RoomMessageDTO] { - try await dataStore.fetchRoomMessages(sessionID: sessionID, limit: limit, offset: offset) + // Generate deduplication key + let dedupKey = RoomMessage.generateDeduplicationKey( + timestamp: timestamp, + authorKeyPrefix: authorPrefix, + text: text + ) + + // Check for duplicate using deduplication key + if try await dataStore.isDuplicateRoomMessage( + sessionID: remoteSession.id, + deduplicationKey: dedupKey + ) { + return nil } - /// Mark room as read (reset unread count). - /// Call when user views the conversation. - /// - Parameter sessionID: The room session ID - public func markAsRead(sessionID: UUID) async throws { - try await dataStore.resetRoomUnreadCount(sessionID) + // Log room message received (metadata only, no content) + await auditLogger.logRoomMessageReceived( + roomPublicKey: senderPublicKeyPrefix, + authorPrefix: authorPrefix, + messageLength: text.count + ) + + // Defensive check: room servers shouldn't push our own messages back + let isFromSelf = selfPublicKeyPrefix?.prefix(4) == authorPrefix.prefix(4) + if isFromSelf { + logger.info("Received self message from room server (unexpected)") } - // MARK: - Session Queries + let authorName = try await resolveAuthorName(keyPrefix: authorPrefix) - /// Fetch all room sessions for a device. - /// - Parameter radioID: The companion radio device ID - /// - Returns: Array of room session DTOs - public func fetchRoomSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { - let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) - return sessions.filter { $0.isRoom } - } + let messageDTO = RoomMessageDTO( + sessionID: remoteSession.id, + authorKeyPrefix: authorPrefix, + authorName: authorName, + text: text, + timestamp: timestamp, + isFromSelf: isFromSelf + ) - /// Check if a contact is a known room server with an active session. - /// - Parameter publicKeyPrefix: The 6-byte public key prefix - /// - Returns: The session if found and connected, nil otherwise - public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), - remoteSession.isRoom && remoteSession.isConnected else { - return nil - } - return remoteSession - } + try await dataStore.saveRoomMessage(messageDTO) - // MARK: - Private Helpers + // Update sync bookmark and sort date + try await dataStore.updateRoomActivity(remoteSession.id, syncTimestamp: timestamp) - private func resolveAuthorName(keyPrefix: Data) async throws -> String? { - // Try to find contact with matching public key prefix - // Returns nil if no matching contact found - try await dataStore.findContactNameByKeyPrefix(keyPrefix) + // Increment unread count if not from self + if !isFromSelf { + try await dataStore.incrementRoomUnreadCount(remoteSession.id) } - /// Attempt to sync history, using advert path first, then falling back to path discovery. - private func syncHistoryIfPossible(sessionID: UUID) async { + return messageDTO + } + + // MARK: - Message Retrieval + + /// Fetch messages for a room session. + /// - Parameters: + /// - sessionID: The room session ID + /// - limit: Maximum number of messages to return + /// - offset: Offset for pagination + /// - Returns: Array of room message DTOs + public func fetchMessages(sessionID: UUID, limit: Int? = nil, offset: Int? = nil) async throws -> [RoomMessageDTO] { + try await dataStore.fetchRoomMessages(sessionID: sessionID, limit: limit, offset: offset) + } + + /// Mark room as read (reset unread count). + /// Call when user views the conversation. + /// - Parameter sessionID: The room session ID + public func markAsRead(sessionID: UUID) async throws { + try await dataStore.resetRoomUnreadCount(sessionID) + } + + // MARK: - Session Queries + + /// Fetch all room sessions for a device. + /// - Parameter radioID: The companion radio device ID + /// - Returns: Array of room session DTOs + public func fetchRoomSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) + return sessions.filter(\.isRoom) + } + + /// Check if a contact is a known room server with an active session. + /// - Parameter publicKeyPrefix: The 6-byte public key prefix + /// - Returns: The session if found and connected, nil otherwise + public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), + remoteSession.isRoom, remoteSession.isConnected else { + return nil + } + return remoteSession + } + + // MARK: - Private Helpers + + private func resolveAuthorName(keyPrefix: Data) async throws -> String? { + // Try to find contact with matching public key prefix + // Returns nil if no matching contact found + try await dataStore.findContactNameByKeyPrefix(keyPrefix) + } + + /// Attempt to sync history, using advert path first, then falling back to path discovery. + private func syncHistoryIfPossible(sessionID: UUID) async { + do { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), + let contact = try await dataStore.findContactByPublicKey(remoteSession.publicKey) else { + return + } + + // Strategy: + // 1. If contact has a path from advertisement (not flood-routed), try it first + // 2. If that fails or contact is flood-routed, trigger path discovery + // 3. Wait for discovery result and retry + + if !contact.isFloodRouted { + // Contact has a path from advertisement - try it directly + logger.info("Trying advert path for room \(remoteSession.name)") do { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), - let contact = try await dataStore.findContactByPublicKey(remoteSession.publicKey) else { - return - } - - // Strategy: - // 1. If contact has a path from advertisement (not flood-routed), try it first - // 2. If that fails or contact is flood-routed, trigger path discovery - // 3. Wait for discovery result and retry - - if !contact.isFloodRouted { - // Contact has a path from advertisement - try it directly - logger.info("Trying advert path for room \(remoteSession.name)") - do { - try await remoteNodeService.requestHistorySync(sessionID: sessionID) - logger.info("History sync succeeded using advert path") - return - } catch { - // Advert path didn't work - fall through to path discovery - logger.info("Advert path failed for \(remoteSession.name): \(error), trying path discovery") - } - } else { - logger.info("Room \(remoteSession.name) is flood-routed, attempting path discovery") - } - - // Path discovery fallback - let hasDirectRoute = try await discoverPathAndWait(sessionID: sessionID) - if !hasDirectRoute { - logger.info("Could not establish direct route for \(remoteSession.name), skipping history sync") - return - } - - // Retry with newly discovered path - try await remoteNodeService.requestHistorySync(sessionID: sessionID) - logger.info("History sync succeeded after path discovery") + try await remoteNodeService.requestHistorySync(sessionID: sessionID) + logger.info("History sync succeeded using advert path") + return } catch { - logger.warning("Failed to sync history for session \(sessionID): \(error)") - // Don't fail the join - messages will arrive via normal flow + // Advert path didn't work - fall through to path discovery + logger.info("Advert path failed for \(remoteSession.name): \(error), trying path discovery") } + } else { + logger.info("Room \(remoteSession.name) is flood-routed, attempting path discovery") + } + + // Path discovery fallback + let hasDirectRoute = try await discoverPathAndWait(sessionID: sessionID) + if !hasDirectRoute { + logger.info("Could not establish direct route for \(remoteSession.name), skipping history sync") + return + } + + // Retry with newly discovered path + try await remoteNodeService.requestHistorySync(sessionID: sessionID) + logger.info("History sync succeeded after path discovery") + } catch { + logger.warning("Failed to sync history for session \(sessionID): \(error)") + // Don't fail the join - messages will arrive via normal flow } + } - /// Discover path and wait for direct route. - private func discoverPathAndWait(sessionID: UUID, timeout: Duration = .seconds(10)) async throws -> Bool { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), - let contact = try await dataStore.findContactByPublicKey(remoteSession.publicKey) else { - return false - } - - // Already direct? - if !contact.isFloodRouted { - return true - } + /// Discover path and wait for direct route. + private func discoverPathAndWait(sessionID: UUID, timeout: Duration = .seconds(10)) async throws -> Bool { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), + let contact = try await dataStore.findContactByPublicKey(remoteSession.publicKey) else { + return false + } - // Trigger path discovery via MeshCore session - do { - _ = try await session.sendPathDiscovery(to: remoteSession.publicKey) - } catch { - logger.warning("Path discovery send failed: \(error)") - return false - } + // Already direct? + if !contact.isFloodRouted { + return true + } - // Wait for result - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(500)) + // Trigger path discovery via MeshCore session + do { + _ = try await session.sendPathDiscovery(to: remoteSession.publicKey) + } catch { + logger.warning("Path discovery send failed: \(error)") + return false + } - if let updated = try await dataStore.findContactByPublicKey(remoteSession.publicKey), - !updated.isFloodRouted { - return true - } - } + // Wait for result + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(500)) - return false + if let updated = try await dataStore.findContactByPublicKey(remoteSession.publicKey), + !updated.isFloodRouted { + return true + } } + + return false + } } diff --git a/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift b/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift index 6ed07952..3f4c760d 100644 --- a/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift +++ b/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift @@ -5,173 +5,171 @@ import OSLog private let logger = PersistentLogger(subsystem: "com.mc1", category: "RxLogService.Region") extension RxLogService { - - private static let missLogThrottleSeconds: TimeInterval = 60 - - /// Offset of the unencrypted sender prefix byte in a DM `packetPayload`, - /// matching `findRxLogEntryBySenderPrefix`'s correlation key. - private static let dmSenderPrefixByteOffset = 1 - - /// Build a `[(name, scopeKey)]` array from the supplied region names. - /// Skips names that `TransportCodeRegionResolver.deriveScopeKey` rejects - /// (`$`-prefixed and empty/whitespace names). - static func buildScopeKeyCache(from regions: [String]) -> [(name: String, key: Data)] { - regions.compactMap { name in - guard let key = TransportCodeRegionResolver.deriveScopeKey(regionName: name) else { - return nil - } - return (name: name, key: key) - } + private static let missLogThrottleSeconds: TimeInterval = 60 + + /// Offset of the unencrypted sender prefix byte in a DM `packetPayload`, + /// matching `findRxLogEntryBySenderPrefix`'s correlation key. + private static let dmSenderPrefixByteOffset = 1 + + /// Build a `[(name, scopeKey)]` array from the supplied region names. + /// Skips names that `TransportCodeRegionResolver.deriveScopeKey` rejects + /// (`$`-prefixed and empty/whitespace names). + static func buildScopeKeyCache(from regions: [String]) -> [(name: String, key: Data)] { + regions.compactMap { name in + guard let key = TransportCodeRegionResolver.deriveScopeKey(regionName: name) else { + return nil + } + return (name: name, key: key) } - - /// Resolve `transport_codes[0]` from a parsed RX packet to a known region - /// name. Returns nil for packets without a transport code, when the - /// scope-key cache is empty, or when no region matches. - func resolveRegionScope(for parsed: ParsedRxLogData) -> String? { - guard let transportCode = parsed.transportCode, transportCode.count >= 2 else { - return nil - } - let match = findRegionScope( - transportCode: transportCode, - payloadTypeBits: parsed.payloadTypeBits, - payload: parsed.packetPayload - ) - if match == nil { - logRegionMissThrottled() - } - return match + } + + /// Resolve `transport_codes[0]` from a parsed RX packet to a known region + /// name. Returns nil for packets without a transport code, when the + /// scope-key cache is empty, or when no region matches. + func resolveRegionScope(for parsed: ParsedRxLogData) -> String? { + guard let transportCode = parsed.transportCode, transportCode.count >= 2 else { + return nil } - - /// Update the known-regions list and rebuild the scope-key cache. Also - /// triggers back-fill of recent entries that arrived before the regions - /// were known. Wired from `ConnectionManager+Pairing` on - /// `addKnownRegion` / `removeKnownRegion`. - public func updateKnownRegions(_ regions: [String]) async { - guard knownRegions != regions else { return } - knownRegions = regions - scopeKeyCache = Self.buildScopeKeyCache(from: regions) - if !regions.isEmpty { - await reprocessNoRegionEntries() - } + let match = findRegionScope( + transportCode: transportCode, + payloadTypeBits: parsed.payloadTypeBits, + payload: parsed.packetPayload + ) + if match == nil { + logRegionMissThrottled() } - - /// Re-resolve all entries with non-nil `transportCode` and nil - /// `regionScope` against the current scope-key cache, and back-fill the - /// resolved region onto both `RxLogEntry` rows and any correlated - /// `Message` rows (keyed by `(channelIndex, senderTimestamp)`). - /// - /// This is the explicit mitigation for two races: - /// 1. Discovery-time race: regions discovered seconds after first - /// connection while messages are already arriving. - /// 2. `addKnownRegion` / `removeKnownRegion` suspension-window race: - /// packets that arrive between the synchronous `connectedDevice` - /// mutation and the dispatched `updateKnownRegions(_:)` call resolve - /// against the stale cache. - func reprocessNoRegionEntries() async { - if isReprocessingRegions { - regionReprocessDirty = true - return - } - isReprocessingRegions = true - defer { isReprocessingRegions = false } - - guard let radioID else { return } - - repeat { - regionReprocessDirty = false - await runReprocessPass(radioID: radioID) - } while regionReprocessDirty + return match + } + + /// Update the known-regions list and rebuild the scope-key cache. Also + /// triggers back-fill of recent entries that arrived before the regions + /// were known. Wired from `ConnectionManager+Pairing` on + /// `addKnownRegion` / `removeKnownRegion`. + public func updateKnownRegions(_ regions: [String]) async { + guard knownRegions != regions else { return } + knownRegions = regions + scopeKeyCache = Self.buildScopeKeyCache(from: regions) + if !regions.isEmpty { + await reprocessNoRegionEntries() } - - private func runReprocessPass(radioID: UUID) async { - do { - let entries = try await dataStore.fetchEntriesWithMissingRegion( - radioID: radioID - ) - - guard !entries.isEmpty else { return } - logger.info("Re-processing \(entries.count) entries for region back-fill") - - var rxUpdates: [(id: UUID, regionScope: String?)] = [] - var channelMessageUpdates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - var dmMessageUpdates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - - for entry in entries { - guard !Task.isCancelled else { break } - guard let resolved = resolveRegionScope(for: entry) else { continue } - - rxUpdates.append((id: entry.id, regionScope: resolved)) - - guard let senderTimestamp = entry.senderTimestamp else { continue } - if let channelIndex = entry.channelIndex { - channelMessageUpdates.append(( - channelIndex: channelIndex, - senderTimestamp: senderTimestamp, - regionScope: resolved - )) - } else if entry.packetPayload.count >= Self.dmSenderPrefixByteOffset + 1 { - let prefixByte = entry.packetPayload[Self.dmSenderPrefixByteOffset] - dmMessageUpdates.append(( - senderPrefixByte: prefixByte, - senderTimestamp: senderTimestamp, - regionScope: resolved - )) - } - } - - if !rxUpdates.isEmpty { - try await dataStore.batchUpdateRxLogRegion(updates: rxUpdates) - } - if !channelMessageUpdates.isEmpty { - try await dataStore.batchUpdateChannelMessageRegion(radioID: radioID, updates: channelMessageUpdates) - } - if !dmMessageUpdates.isEmpty { - try await dataStore.batchUpdateDMMessageRegion(radioID: radioID, updates: dmMessageUpdates) - } - - let messageCount = channelMessageUpdates.count + dmMessageUpdates.count - if !rxUpdates.isEmpty || messageCount > 0 { - logger.info("Back-filled \(rxUpdates.count) RxLog entries, \(messageCount) messages") - } - } catch { - logger.error("Failed to re-process region entries: \(error.localizedDescription)") - } + } + + /// Re-resolve all entries with non-nil `transportCode` and nil + /// `regionScope` against the current scope-key cache, and back-fill the + /// resolved region onto both `RxLogEntry` rows and any correlated + /// `Message` rows (keyed by `(channelIndex, senderTimestamp)`). + /// + /// This is the explicit mitigation for two races: + /// 1. Discovery-time race: regions discovered seconds after first + /// connection while messages are already arriving. + /// 2. `addKnownRegion` / `removeKnownRegion` suspension-window race: + /// packets that arrive between the synchronous `connectedDevice` + /// mutation and the dispatched `updateKnownRegions(_:)` call resolve + /// against the stale cache. + func reprocessNoRegionEntries() async { + if isReprocessingRegions { + regionReprocessDirty = true + return } - - private func logRegionMissThrottled() { - let now = Date() - if let last = lastRegionMissLogTime, now.timeIntervalSince(last) < Self.missLogThrottleSeconds { - return - } - lastRegionMissLogTime = now - if knownRegions.isEmpty { - logger.debug("Region resolution skipped: no known regions loaded") - } else { - logger.debug("Region resolution miss against \(self.knownRegions.count) known regions") + isReprocessingRegions = true + defer { isReprocessingRegions = false } + + guard let radioID else { return } + + repeat { + regionReprocessDirty = false + await runReprocessPass(radioID: radioID) + } while regionReprocessDirty + } + + private func runReprocessPass(radioID: UUID) async { + do { + let entries = try await dataStore.fetchEntriesWithMissingRegion( + radioID: radioID + ) + + guard !entries.isEmpty else { return } + logger.info("Re-processing \(entries.count) entries for region back-fill") + + var rxUpdates: [(id: UUID, regionScope: String?)] = [] + var channelMessageUpdates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + var dmMessageUpdates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + + for entry in entries { + guard !Task.isCancelled else { break } + guard let resolved = resolveRegionScope(for: entry) else { continue } + + rxUpdates.append((id: entry.id, regionScope: resolved)) + + guard let senderTimestamp = entry.senderTimestamp else { continue } + if let channelIndex = entry.channelIndex { + channelMessageUpdates.append(( + channelIndex: channelIndex, + senderTimestamp: senderTimestamp, + regionScope: resolved + )) + } else if entry.packetPayload.count >= Self.dmSenderPrefixByteOffset + 1 { + let prefixByte = entry.packetPayload[Self.dmSenderPrefixByteOffset] + dmMessageUpdates.append(( + senderPrefixByte: prefixByte, + senderTimestamp: senderTimestamp, + regionScope: resolved + )) } + } + + if !rxUpdates.isEmpty { + try await dataStore.batchUpdateRxLogRegion(updates: rxUpdates) + } + if !channelMessageUpdates.isEmpty { + try await dataStore.batchUpdateChannelMessageRegion(radioID: radioID, updates: channelMessageUpdates) + } + if !dmMessageUpdates.isEmpty { + try await dataStore.batchUpdateDMMessageRegion(radioID: radioID, updates: dmMessageUpdates) + } + + let messageCount = channelMessageUpdates.count + dmMessageUpdates.count + if !rxUpdates.isEmpty || messageCount > 0 { + logger.info("Back-filled \(rxUpdates.count) RxLog entries, \(messageCount) messages") + } + } catch { + logger.error("Failed to re-process region entries: \(error.localizedDescription)") } + } - /// Resolve region scope against an existing `RxLogEntryDTO` (used by the - /// back-fill path, which has the persisted entry in hand rather than a - /// fresh `ParsedRxLogData`). - private func resolveRegionScope(for entry: RxLogEntryDTO) -> String? { - findRegionScope( - transportCode: entry.transportCode, - payloadTypeBits: entry.payloadTypeBits, - payload: entry.packetPayload - ) + private func logRegionMissThrottled() { + let now = Date() + if let last = lastRegionMissLogTime, now.timeIntervalSince(last) < Self.missLogThrottleSeconds { + return } - - private func findRegionScope(transportCode: Data?, payloadTypeBits: UInt8, payload: Data) -> String? { - guard let transportCode, transportCode.count >= 2 else { return nil } - guard !scopeKeyCache.isEmpty else { return nil } - let code0 = transportCode.readUInt16LE(at: 0) - return TransportCodeRegionResolver.findMatchingRegion( - scopeKeys: scopeKeyCache, - expectedTransportCode0: code0, - payloadTypeBits: payloadTypeBits, - payload: payload - ) + lastRegionMissLogTime = now + if knownRegions.isEmpty { + logger.debug("Region resolution skipped: no known regions loaded") + } else { + logger.debug("Region resolution miss against \(knownRegions.count) known regions") } - + } + + /// Resolve region scope against an existing `RxLogEntryDTO` (used by the + /// back-fill path, which has the persisted entry in hand rather than a + /// fresh `ParsedRxLogData`). + private func resolveRegionScope(for entry: RxLogEntryDTO) -> String? { + findRegionScope( + transportCode: entry.transportCode, + payloadTypeBits: entry.payloadTypeBits, + payload: entry.packetPayload + ) + } + + private func findRegionScope(transportCode: Data?, payloadTypeBits: UInt8, payload: Data) -> String? { + guard let transportCode, transportCode.count >= 2 else { return nil } + guard !scopeKeyCache.isEmpty else { return nil } + let code0 = transportCode.readUInt16LE(at: 0) + return TransportCodeRegionResolver.findMatchingRegion( + scopeKeys: scopeKeyCache, + expectedTransportCode0: code0, + payloadTypeBits: payloadTypeBits, + payload: payload + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/RxLogService.swift b/MC1Services/Sources/MC1Services/Services/RxLogService.swift index 6547b7d5..8effd76b 100644 --- a/MC1Services/Sources/MC1Services/Services/RxLogService.swift +++ b/MC1Services/Sources/MC1Services/Services/RxLogService.swift @@ -6,560 +6,561 @@ private let logger = PersistentLogger(subsystem: "com.mc1", category: "RxLogServ /// Actor that processes RX log events, decodes channel messages, and persists to database. public actor RxLogService { - private let session: any MeshCoreSessionProtocol - let dataStore: any PersistenceStoreProtocol - var radioID: UUID? - - // Caches for fast lookup - private var channelSecrets: [UInt8: Data] = [:] // channelIndex -> secret - private var channelNames: [UInt8: String] = [:] // channelIndex -> name - private var contactNames: [Data: String] = [:] // pubkey prefix -> name - - // Crypto keys for direct message decryption - private var myPrivateKey: Data? - private var contactPublicKeysByPrefix: [UInt8: [Data]] = [:] // 1-byte prefix -> array of 32-byte public keys - - /// Multicast broadcaster for newly persisted entries. Consumers subscribe - /// via `entryStream()`; finished by `ServiceContainer.tearDown()`. - private nonisolated let entryBroadcaster = EventBroadcaster() - - // Event monitoring - private var eventMonitorTask: Task? - - // Heard repeats processing. - // Injected by `ServiceContainer` at construction. - private let heardRepeatsService: HeardRepeatsService? - - // Reentrancy guards for reprocessing (separate to avoid mutual blocking) - private var isReprocessingChannels = false - private var isReprocessingDMs = false - var isReprocessingRegions = false - - // Region resolution state - var knownRegions: [String] = [] - var scopeKeyCache: [(name: String, key: Data)] = [] - var lastRegionMissLogTime: Date? - - /// Set when a region back-fill request arrives while one is already in - /// flight. The in-flight pass re-runs after it completes so rapid - /// `addKnownRegion` / `removeKnownRegion` sequences do not lose work. - var regionReprocessDirty = false - - public init(session: any MeshCoreSessionProtocol, dataStore: any PersistenceStoreProtocol, heardRepeatsService: HeardRepeatsService?) { - self.session = session - self.dataStore = dataStore - self.heardRepeatsService = heardRepeatsService - } - - /// Whether a heard repeats service was injected at construction. - var hasHeardRepeatsServiceWired: Bool { heardRepeatsService != nil } - - deinit { - eventMonitorTask?.cancel() - } - - // MARK: - Event Monitoring - - /// Start monitoring for RX log events from MeshCore. - public func startEventMonitoring(radioID: UUID) { - self.radioID = radioID - eventMonitorTask?.cancel() - - eventMonitorTask = Task { [weak self] in - guard let self else { return } - - // Load secrets from database before entering event loop - // This eliminates the race condition where events arrive before secrets are synced - await self.loadSecretsFromDatabase(radioID: radioID) - - let events = await session.events(filter: .rxLogData) - - for await event in events { - guard !Task.isCancelled else { break } - if case .rxLogData(let parsed) = event { - await self.process(parsed) - } - } - } - } - - /// Load channel secrets and contact public keys from database to enable decryption before sync completes. - private func loadSecretsFromDatabase(radioID: UUID) async { - do { - let channels = try await dataStore.fetchChannels(radioID: radioID) - // Channels arrive sorted by slot index; a corrupt store can hold duplicate - // indices, so keep the first match for a deterministic choice. - channelSecrets = Dictionary(channels.map { ($0.index, $0.secret) }, uniquingKeysWith: { first, _ in first }) - channelNames = Dictionary(channels.map { ($0.index, $0.name) }, uniquingKeysWith: { first, _ in first }) - if !channels.isEmpty { - logger.info("Loaded \(channels.count) channel secrets from database") - } - } catch { - logger.error("Failed to load channel secrets: \(error.localizedDescription)") - } - - do { - let publicKeys = try await dataStore.fetchContactPublicKeysByPrefix(radioID: radioID) - if !publicKeys.isEmpty { - contactPublicKeysByPrefix = Self.convertPublicKeysToX25519(publicKeys) - logger.info("Loaded \(publicKeys.count) contact public key prefixes from database") - } - } catch { - logger.error("Failed to load contact public keys: \(error.localizedDescription)") - } - - let device = try? await dataStore.fetchDevice(radioID: radioID) - knownRegions = device?.knownRegions ?? [] - scopeKeyCache = Self.buildScopeKeyCache(from: knownRegions) - if !knownRegions.isEmpty { - logger.info("Loaded \(knownRegions.count) known regions from database (\(scopeKeyCache.count) resolvable)") + private let session: any MeshCoreSessionProtocol + let dataStore: any PersistenceStoreProtocol + var radioID: UUID? + + // Caches for fast lookup + private var channelSecrets: [UInt8: Data] = [:] // channelIndex -> secret + private var channelNames: [UInt8: String] = [:] // channelIndex -> name + private var contactNames: [Data: String] = [:] // pubkey prefix -> name + + // Crypto keys for direct message decryption + private var myPrivateKey: Data? + private var contactPublicKeysByPrefix: [UInt8: [Data]] = [:] // 1-byte prefix -> array of 32-byte public keys + + /// Multicast broadcaster for newly persisted entries. Consumers subscribe + /// via `entryStream()`; finished by `ServiceContainer.tearDown()`. + private nonisolated let entryBroadcaster = EventBroadcaster() + + /// Event monitoring + private var eventMonitorTask: Task? + + /// Heard repeats processing. + /// Injected by `ServiceContainer` at construction. + private let heardRepeatsService: HeardRepeatsService? + + // Reentrancy guards for reprocessing (separate to avoid mutual blocking) + private var isReprocessingChannels = false + private var isReprocessingDMs = false + var isReprocessingRegions = false + + // Region resolution state + var knownRegions: [String] = [] + var scopeKeyCache: [(name: String, key: Data)] = [] + var lastRegionMissLogTime: Date? + + /// Set when a region back-fill request arrives while one is already in + /// flight. The in-flight pass re-runs after it completes so rapid + /// `addKnownRegion` / `removeKnownRegion` sequences do not lose work. + var regionReprocessDirty = false + + public init(session: any MeshCoreSessionProtocol, dataStore: any PersistenceStoreProtocol, heardRepeatsService: HeardRepeatsService?) { + self.session = session + self.dataStore = dataStore + self.heardRepeatsService = heardRepeatsService + } + + /// Whether a heard repeats service was injected at construction. + var hasHeardRepeatsServiceWired: Bool { + heardRepeatsService != nil + } + + deinit { + eventMonitorTask?.cancel() + } + + // MARK: - Event Monitoring + + /// Start monitoring for RX log events from MeshCore. + public func startEventMonitoring(radioID: UUID) { + self.radioID = radioID + eventMonitorTask?.cancel() + + eventMonitorTask = Task { [weak self] in + guard let self else { return } + + // Load secrets from database before entering event loop + // This eliminates the race condition where events arrive before secrets are synced + await loadSecretsFromDatabase(radioID: radioID) + + let events = await session.events(filter: .rxLogData) + + for await event in events { + guard !Task.isCancelled else { break } + if case let .rxLogData(parsed) = event { + await process(parsed) } + } } - - /// Stop monitoring events. - public func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil + } + + /// Load channel secrets and contact public keys from database to enable decryption before sync completes. + private func loadSecretsFromDatabase(radioID: UUID) async { + do { + let channels = try await dataStore.fetchChannels(radioID: radioID) + // Channels arrive sorted by slot index; a corrupt store can hold duplicate + // indices, so keep the first match for a deterministic choice. + channelSecrets = Dictionary(channels.map { ($0.index, $0.secret) }, uniquingKeysWith: { first, _ in first }) + channelNames = Dictionary(channels.map { ($0.index, $0.name) }, uniquingKeysWith: { first, _ in first }) + if !channels.isEmpty { + logger.info("Loaded \(channels.count) channel secrets from database") + } + } catch { + logger.error("Failed to load channel secrets: \(error.localizedDescription)") } - /// Returns a fresh multicast stream of newly persisted entries. - /// Registration is synchronous, so entries yielded after this call returns - /// are never dropped, and coexisting subscribers each receive every entry. - public nonisolated func entryStream() -> AsyncStream { - entryBroadcaster.subscribe() + do { + let publicKeys = try await dataStore.fetchContactPublicKeysByPrefix(radioID: radioID) + if !publicKeys.isEmpty { + contactPublicKeysByPrefix = Self.convertPublicKeysToX25519(publicKeys) + logger.info("Loaded \(publicKeys.count) contact public key prefixes from database") + } + } catch { + logger.error("Failed to load contact public keys: \(error.localizedDescription)") } - /// Ends every `entryStream()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release their service - /// references. - nonisolated func finishEntryStream() { - entryBroadcaster.finish() + let device = try? await dataStore.fetchDevice(radioID: radioID) + knownRegions = device?.knownRegions ?? [] + scopeKeyCache = Self.buildScopeKeyCache(from: knownRegions) + if !knownRegions.isEmpty { + logger.info("Loaded \(knownRegions.count) known regions from database (\(scopeKeyCache.count) resolvable)") } - - /// Rebuilds the channel cache from a fresh channel list. - /// `ChannelService` forwards every channel write and sync result here so - /// captured packets can be decoded with current secrets. - public func updateChannels(from channels: [ChannelDTO]) async { - let secrets: [UInt8: Data] = Dictionary( - uniqueKeysWithValues: channels.map { ($0.index, $0.secret) } - ) - let names: [UInt8: String] = Dictionary( - uniqueKeysWithValues: channels.map { ($0.index, $0.name) } - ) - await updateChannels(secrets: secrets, names: names) + } + + /// Stop monitoring events. + public func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + } + + /// Returns a fresh multicast stream of newly persisted entries. + /// Registration is synchronous, so entries yielded after this call returns + /// are never dropped, and coexisting subscribers each receive every entry. + public nonisolated func entryStream() -> AsyncStream { + entryBroadcaster.subscribe() + } + + /// Ends every `entryStream()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release their service + /// references. + nonisolated func finishEntryStream() { + entryBroadcaster.finish() + } + + /// Rebuilds the channel cache from a fresh channel list. + /// `ChannelService` forwards every channel write and sync result here so + /// captured packets can be decoded with current secrets. + public func updateChannels(from channels: [ChannelDTO]) async { + let secrets: [UInt8: Data] = Dictionary( + uniqueKeysWithValues: channels.map { ($0.index, $0.secret) } + ) + let names: [UInt8: String] = Dictionary( + uniqueKeysWithValues: channels.map { ($0.index, $0.name) } + ) + await updateChannels(secrets: secrets, names: names) + } + + /// Update channel cache (secrets and names). + /// Re-processes any recent noMatchingKey entries when secrets are provided. + public func updateChannels(secrets: [UInt8: Data], names: [UInt8: String]) async { + channelSecrets = secrets + channelNames = names + + if !secrets.isEmpty { + await reprocessNoMatchingKeyEntries() } - - /// Update channel cache (secrets and names). - /// Re-processes any recent noMatchingKey entries when secrets are provided. - public func updateChannels(secrets: [UInt8: Data], names: [UInt8: String]) async { - channelSecrets = secrets - channelNames = names - - if !secrets.isEmpty { - await reprocessNoMatchingKeyEntries() + } + + /// Re-process recent entries that failed decryption due to missing keys. + /// Uses a reentrancy guard to prevent overlapping reprocessing. + private func reprocessNoMatchingKeyEntries() async { + guard !isReprocessingChannels else { return } + isReprocessingChannels = true + defer { isReprocessingChannels = false } + + guard let radioID else { return } + + let cutoff = Date().addingTimeInterval(-60) + + do { + let entries = try await dataStore.fetchRecentEntriesByDecryptStatus( + radioID: radioID, + status: .noMatchingKey, + since: cutoff + ) + + guard !entries.isEmpty else { return } + logger.info("Re-processing \(entries.count) noMatchingKey entries") + + // Collect successful decryptions for batch update + var updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] = [] + var decryptedEntries: [RxLogEntryDTO] = [] + + for entry in entries { + guard !Task.isCancelled else { break } + + let decrypted = decryptEntry(entry) + guard decrypted.decodedText != nil else { continue } + + updates.append(( + id: entry.id, + channelIndex: decrypted.channelIndex, + channelName: decrypted.channelName, + senderTimestamp: decrypted.senderTimestamp + )) + decryptedEntries.append(decrypted) + } + + // Batch update database (decodedText is @Transient, not persisted) + if !updates.isEmpty { + try await dataStore.batchUpdateRxLogDecryption(updates) + + // Process for heard repeats after DB update + if let heardRepeatsService { + for entry in decryptedEntries { + guard !Task.isCancelled else { break } + await heardRepeatsService.processForRepeats(entry) + } } + + logger.info("Successfully re-processed \(updates.count) entries") + } + } catch { + logger.error("Failed to re-process noMatchingKey entries: \(error.localizedDescription)") } + } - /// Re-process recent entries that failed decryption due to missing keys. - /// Uses a reentrancy guard to prevent overlapping reprocessing. - private func reprocessNoMatchingKeyEntries() async { - guard !isReprocessingChannels else { return } - isReprocessingChannels = true - defer { isReprocessingChannels = false } + /// Re-process recent DM entries that failed decryption due to missing keys. + /// Called when contact public keys or the device private key become available. + private func reprocessDMEntries() async { + guard !isReprocessingDMs else { return } + isReprocessingDMs = true + defer { isReprocessingDMs = false } - guard let radioID else { return } + guard let radioID, let myPrivateKey else { return } + guard !contactPublicKeysByPrefix.isEmpty else { return } - let cutoff = Date().addingTimeInterval(-60) + let cutoff = Date().addingTimeInterval(-60) - do { - let entries = try await dataStore.fetchRecentEntriesByDecryptStatus( - radioID: radioID, - status: .noMatchingKey, - since: cutoff - ) - - guard !entries.isEmpty else { return } - logger.info("Re-processing \(entries.count) noMatchingKey entries") - - // Collect successful decryptions for batch update - var updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] = [] - var decryptedEntries: [RxLogEntryDTO] = [] - - for entry in entries { - guard !Task.isCancelled else { break } - - let decrypted = decryptEntry(entry) - guard decrypted.decodedText != nil else { continue } - - updates.append(( - id: entry.id, - channelIndex: decrypted.channelIndex, - channelName: decrypted.channelName, - senderTimestamp: decrypted.senderTimestamp - )) - decryptedEntries.append(decrypted) - } - - // Batch update database (decodedText is @Transient, not persisted) - if !updates.isEmpty { - try await dataStore.batchUpdateRxLogDecryption(updates) - - // Process for heard repeats after DB update - if let heardRepeatsService { - for entry in decryptedEntries { - guard !Task.isCancelled else { break } - await heardRepeatsService.processForRepeats(entry) - } - } - - logger.info("Successfully re-processed \(updates.count) entries") - } - } catch { - logger.error("Failed to re-process noMatchingKey entries: \(error.localizedDescription)") - } - } + do { + let entries = try await dataStore.fetchRecentEntriesByDecryptStatus( + radioID: radioID, + status: .dmNoMatchingKey, + since: cutoff + ) - /// Re-process recent DM entries that failed decryption due to missing keys. - /// Called when contact public keys or the device private key become available. - private func reprocessDMEntries() async { - guard !isReprocessingDMs else { return } - isReprocessingDMs = true - defer { isReprocessingDMs = false } + guard !entries.isEmpty else { return } + logger.info("Re-processing \(entries.count) DM entries for timestamp extraction") - guard let radioID, let myPrivateKey else { return } - guard !contactPublicKeysByPrefix.isEmpty else { return } + var updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] = [] - let cutoff = Date().addingTimeInterval(-60) + for entry in entries { + guard !Task.isCancelled else { break } - do { - let entries = try await dataStore.fetchRecentEntriesByDecryptStatus( - radioID: radioID, - status: .dmNoMatchingKey, - since: cutoff - ) - - guard !entries.isEmpty else { return } - logger.info("Re-processing \(entries.count) DM entries for timestamp extraction") - - var updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] = [] - - for entry in entries { - guard !Task.isCancelled else { break } - - // Extract sender prefix from packetPayload: [destHash:1][srcHash:1]... - let payloadHashSize = 1 - guard entry.packetPayload.count >= payloadHashSize * 2, - entry.routeType == .direct || entry.routeType == .tcDirect else { - continue - } - let senderPrefix = entry.packetPayload[payloadHashSize] - - guard let candidateKeys = contactPublicKeysByPrefix[senderPrefix] else { - continue - } - - for senderPublicKey in candidateKeys { - if let timestamp = DirectMessageCrypto.extractTimestamp( - payload: entry.packetPayload, - myPrivateKey: myPrivateKey, - senderPublicKey: senderPublicKey - ) { - updates.append(( - id: entry.id, - channelIndex: nil, - channelName: nil, - senderTimestamp: timestamp - )) - break - } - } - } - - if !updates.isEmpty { - try await dataStore.batchUpdateRxLogDecryption(updates) - logger.info("Successfully re-processed \(updates.count) DM entries") - } - } catch { - logger.error("Failed to re-process DM entries: \(error.localizedDescription)") + // Extract sender prefix from packetPayload: [destHash:1][srcHash:1]... + let payloadHashSize = 1 + guard entry.packetPayload.count >= payloadHashSize * 2, + entry.routeType == .direct || entry.routeType == .tcDirect else { + continue } - } + let senderPrefix = entry.packetPayload[payloadHashSize] - /// Update contact names cache. - public func updateContactNames(_ names: [Data: String]) { - contactNames = names - } - - /// Update device private key for direct message decryption. - /// The exported key is 64 bytes: `[expanded_scalar:32][nonce:32]`. - /// DirectMessageCrypto needs the 32-byte Curve25519 scalar (first half). - public func updatePrivateKey(_ key: Data?) async { - myPrivateKey = key.flatMap { $0.count >= 32 ? Data($0.prefix(32)) : nil } - if myPrivateKey != nil { - await reprocessDMEntries() + guard let candidateKeys = contactPublicKeysByPrefix[senderPrefix] else { + continue } - } - /// Update contact public keys for direct message decryption. - /// Called when contacts sync completes. Re-processes any recent DM entries. - /// Input keys are Ed25519 public keys; converted to Curve25519 for ECDH. - public func updateContactPublicKeys(_ keys: [UInt8: [Data]]) async { - contactPublicKeysByPrefix = Self.convertPublicKeysToX25519(keys) - if !contactPublicKeysByPrefix.isEmpty { - await reprocessDMEntries() + for senderPublicKey in candidateKeys { + if let timestamp = DirectMessageCrypto.extractTimestamp( + payload: entry.packetPayload, + myPrivateKey: myPrivateKey, + senderPublicKey: senderPublicKey + ) { + updates.append(( + id: entry.id, + channelIndex: nil, + channelName: nil, + senderTimestamp: timestamp + )) + break + } } + } + + if !updates.isEmpty { + try await dataStore.batchUpdateRxLogDecryption(updates) + logger.info("Successfully re-processed \(updates.count) DM entries") + } + } catch { + logger.error("Failed to re-process DM entries: \(error.localizedDescription)") } - - /// Convert Ed25519 contact public keys to Curve25519 for DirectMessageCrypto. - private static func convertPublicKeysToX25519(_ keys: [UInt8: [Data]]) -> [UInt8: [Data]] { - var converted: [UInt8: [Data]] = [:] - for (prefix, publicKeys) in keys { - let x25519Keys = publicKeys.compactMap { Ed25519ToX25519.convertPublicKey($0) } - if !x25519Keys.isEmpty { - converted[prefix] = x25519Keys - } - } - return converted + } + + /// Update contact names cache. + public func updateContactNames(_ names: [Data: String]) { + contactNames = names + } + + /// Update device private key for direct message decryption. + /// The exported key is 64 bytes: `[expanded_scalar:32][nonce:32]`. + /// DirectMessageCrypto needs the 32-byte Curve25519 scalar (first half). + public func updatePrivateKey(_ key: Data?) async { + myPrivateKey = key.flatMap { $0.count >= 32 ? Data($0.prefix(32)) : nil } + if myPrivateKey != nil { + await reprocessDMEntries() } + } + + /// Update contact public keys for direct message decryption. + /// Called when contacts sync completes. Re-processes any recent DM entries. + /// Input keys are Ed25519 public keys; converted to Curve25519 for ECDH. + public func updateContactPublicKeys(_ keys: [UInt8: [Data]]) async { + contactPublicKeysByPrefix = Self.convertPublicKeysToX25519(keys) + if !contactPublicKeysByPrefix.isEmpty { + await reprocessDMEntries() + } + } + + /// Convert Ed25519 contact public keys to Curve25519 for DirectMessageCrypto. + private static func convertPublicKeysToX25519(_ keys: [UInt8: [Data]]) -> [UInt8: [Data]] { + var converted: [UInt8: [Data]] = [:] + for (prefix, publicKeys) in keys { + let x25519Keys = publicKeys.compactMap { Ed25519ToX25519.convertPublicKey($0) } + if !x25519Keys.isEmpty { + converted[prefix] = x25519Keys + } + } + return converted + } + + /// Process a parsed RX log event. + public func process(_ parsed: ParsedRxLogData) async { + guard let radioID else { return } + + // Decode channel message if applicable + var channelIndex: UInt8? + var channelName: String? + var decryptStatus = DecryptStatus.notApplicable + var decodedText: String? + var senderTimestamp: UInt32? + var fromContactName: String? + + if parsed.payloadType == .groupText || parsed.payloadType == .groupData { + // Channel payload format: [channelHash: 1B] [MAC: 2B] [ciphertext: NB] + // The first byte is a truncated channel hash (not the index), so we must + // try all known secrets to find the one where MAC validates. + let rawPayload = parsed.packetPayload + + // Need at least: 1 (channel hash) + 2 (MAC) + 16 (min ciphertext block) + if rawPayload.count >= 1 + ChannelCrypto.macSize + 16 { + let encryptedPayload = Data(rawPayload.dropFirst(1)) - /// Process a parsed RX log event. - public func process(_ parsed: ParsedRxLogData) async { - guard let radioID else { return } - - // Decode channel message if applicable - var channelIndex: UInt8? - var channelName: String? - var decryptStatus = DecryptStatus.notApplicable - var decodedText: String? - var senderTimestamp: UInt32? - var fromContactName: String? - - if parsed.payloadType == .groupText || parsed.payloadType == .groupData { - // Channel payload format: [channelHash: 1B] [MAC: 2B] [ciphertext: NB] - // The first byte is a truncated channel hash (not the index), so we must - // try all known secrets to find the one where MAC validates. - let rawPayload = parsed.packetPayload - - // Need at least: 1 (channel hash) + 2 (MAC) + 16 (min ciphertext block) - if rawPayload.count >= 1 + ChannelCrypto.macSize + 16 { - let encryptedPayload = Data(rawPayload.dropFirst(1)) - - for (index, secret) in self.channelSecrets { - let result = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) - if case .success(let timestamp, _, let text) = result { - channelIndex = index - channelName = channelNames[index] ?? "Channel \(index)" - decryptStatus = .success - senderTimestamp = timestamp - decodedText = text - break - } - } - - if decryptStatus == .notApplicable { - decryptStatus = .noMatchingKey - } - } else { - decryptStatus = .pending - } - } - - // Decrypt direct text messages to extract senderTimestamp and text - if parsed.payloadType == .textMessage, - parsed.routeType == .direct || parsed.routeType == .tcDirect { - - if let myPrivateKey = self.myPrivateKey, - let dmResult = Self.tryDecryptDM( - payload: parsed.packetPayload, - myPrivateKey: myPrivateKey, - contactPublicKeysByPrefix: contactPublicKeysByPrefix - ) { - senderTimestamp = dmResult.timestamp - decodedText = dmResult.text - decryptStatus = .success - logger.debug("Decrypted direct message senderTimestamp: \(dmResult.timestamp)") - } - - if senderTimestamp == nil { - decryptStatus = .dmNoMatchingKey - logger.debug("DM decryption failed, marking as dmNoMatchingKey for reprocessing") - } + for (index, secret) in channelSecrets { + let result = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) + if case let .success(timestamp, _, text) = result { + channelIndex = index + channelName = channelNames[index] ?? "Channel \(index)" + decryptStatus = .success + senderTimestamp = timestamp + decodedText = text + break + } } - // Resolve contact name from sender pubkey prefix (direct messages) - if let senderPrefix = parsed.senderPubkeyPrefix { - fromContactName = contactNames.first { storedPrefix, _ in - storedPrefix.starts(with: senderPrefix) || senderPrefix.starts(with: storedPrefix) - }?.value + if decryptStatus == .notApplicable { + decryptStatus = .noMatchingKey } + } else { + decryptStatus = .pending + } + } - // The advert payload carries the sender's full pubkey at offset 0, followed by the advert - // timestamp at offset 32. The inbound hop count is an exact keyed write, not a timing - // correlation. A reserved or truncated encoding skips the write rather than stamping a - // wrong value. Only flood-routed adverts accumulate a hop path; a direct-routed advert's - // path length is the remaining route, not hops traversed, so it is excluded. - if parsed.payloadType == .advert, parsed.routeType.isFlood { - let payloadBytes = parsed.packetPayload.count - if payloadBytes >= ProtocolLimits.publicKeySize, let inboundHops = decodePathLen(parsed.pathLength)?.hopCount { - let advertiserPubKey = Data(parsed.packetPayload.prefix(ProtocolLimits.publicKeySize)) - let advertTimestamp: UInt32? = payloadBytes >= ProtocolLimits.publicKeySize + ProtocolLimits.advertTimestampSize - ? parsed.packetPayload.readUInt32LE(at: ProtocolLimits.publicKeySize) - : nil - do { - try await dataStore.setInboundHopCount( - radioID: radioID, - publicKey: advertiserPubKey, - hopCount: inboundHops, - advertTimestamp: advertTimestamp - ) - } catch { - logger.error("Failed to stamp inbound hop count: \(error.localizedDescription)") - } - } - } + // Decrypt direct text messages to extract senderTimestamp and text + if parsed.payloadType == .textMessage, + parsed.routeType == .direct || parsed.routeType == .tcDirect { + if let myPrivateKey, + let dmResult = Self.tryDecryptDM( + payload: parsed.packetPayload, + myPrivateKey: myPrivateKey, + contactPublicKeysByPrefix: contactPublicKeysByPrefix + ) { + senderTimestamp = dmResult.timestamp + decodedText = dmResult.text + decryptStatus = .success + logger.debug("Decrypted direct message senderTimestamp: \(dmResult.timestamp)") + } + + if senderTimestamp == nil { + decryptStatus = .dmNoMatchingKey + logger.debug("DM decryption failed, marking as dmNoMatchingKey for reprocessing") + } + } - let regionScope = resolveRegionScope(for: parsed) + // Resolve contact name from sender pubkey prefix (direct messages) + if let senderPrefix = parsed.senderPubkeyPrefix { + fromContactName = contactNames.first { storedPrefix, _ in + storedPrefix.starts(with: senderPrefix) || senderPrefix.starts(with: storedPrefix) + }?.value + } - // Create DTO - let dto = RxLogEntryDTO( - radioID: radioID, - from: parsed, - channelIndex: channelIndex, - channelName: channelName, - decryptStatus: decryptStatus, - fromContactName: fromContactName, - senderTimestamp: senderTimestamp, - regionScope: regionScope, - decodedText: decodedText - ) - - // Persist + // The advert payload carries the sender's full pubkey at offset 0, followed by the advert + // timestamp at offset 32. The inbound hop count is an exact keyed write, not a timing + // correlation. A reserved or truncated encoding skips the write rather than stamping a + // wrong value. Only flood-routed adverts accumulate a hop path; a direct-routed advert's + // path length is the remaining route, not hops traversed, so it is excluded. + if parsed.payloadType == .advert, parsed.routeType.isFlood { + let payloadBytes = parsed.packetPayload.count + if payloadBytes >= ProtocolLimits.publicKeySize, let inboundHops = decodePathLen(parsed.pathLength)?.hopCount { + let advertiserPubKey = Data(parsed.packetPayload.prefix(ProtocolLimits.publicKeySize)) + let advertTimestamp: UInt32? = payloadBytes >= ProtocolLimits.publicKeySize + ProtocolLimits.advertTimestampSize + ? parsed.packetPayload.readUInt32LE(at: ProtocolLimits.publicKeySize) + : nil do { - try await dataStore.saveRxLogEntry(dto) - try await dataStore.pruneRxLogEntries(radioID: radioID) + try await dataStore.setInboundHopCount( + radioID: radioID, + publicKey: advertiserPubKey, + hopCount: inboundHops, + advertTimestamp: advertTimestamp + ) } catch { - logger.error("Failed to save RX log entry: \(error.localizedDescription)") - } - - // Emit to stream consumers (RX log screens, Live Activity freshness) - entryBroadcaster.yield(dto) - - // Process for heard repeats (inline await provides natural backpressure, - // preventing unbounded Task accumulation under high RX volume) - if let heardRepeatsService = self.heardRepeatsService { - await heardRepeatsService.processForRepeats(dto) + logger.error("Failed to stamp inbound hop count: \(error.localizedDescription)") } + } } - /// Load existing entries from database, re-decrypting payloads with current secrets. - public func loadExistingEntries() async -> [RxLogEntryDTO] { - guard let radioID else { return [] } - do { - let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) - return entries.map { decryptEntry($0) } - } catch { - logger.error("Failed to load RX log entries: \(error.localizedDescription)") - return [] - } + let regionScope = resolveRegionScope(for: parsed) + + // Create DTO + let dto = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: channelIndex, + channelName: channelName, + decryptStatus: decryptStatus, + fromContactName: fromContactName, + senderTimestamp: senderTimestamp, + regionScope: regionScope, + decodedText: decodedText + ) + + // Persist + do { + try await dataStore.saveRxLogEntry(dto) + try await dataStore.pruneRxLogEntries(radioID: radioID) + } catch { + logger.error("Failed to save RX log entry: \(error.localizedDescription)") } - // MARK: - Decryption - - /// Attempt to decrypt a channel message entry using current secrets. - /// Returns a copy of the entry with `decodedText` populated if decryption succeeds. - /// This is reusable for export and other features that need decrypted content. - /// - /// If the entry was previously decrypted (`decryptStatus == .success`), - /// we use the stored channel index for O(1) secret lookup instead of trying all keys. - public func decryptEntry(_ entry: RxLogEntryDTO) -> RxLogEntryDTO { - var result = entry - - // Attempt DM decryption for direct text messages - if entry.payloadType == .textMessage, - entry.routeType == .direct || entry.routeType == .tcDirect { - if let myPrivateKey = self.myPrivateKey, - let dmResult = Self.tryDecryptDM( - payload: entry.packetPayload, - myPrivateKey: myPrivateKey, - contactPublicKeysByPrefix: contactPublicKeysByPrefix - ) { - result.senderTimestamp = dmResult.timestamp - result.decodedText = dmResult.text - } - return result - } + // Emit to stream consumers (RX log screens, Live Activity freshness) + entryBroadcaster.yield(dto) - // Only attempt decryption for channel messages - guard entry.payloadType == .groupText || entry.payloadType == .groupData else { - return result - } + // Process for heard repeats (inline await provides natural backpressure, + // preventing unbounded Task accumulation under high RX volume) + if let heardRepeatsService { + await heardRepeatsService.processForRepeats(dto) + } + } + + /// Load existing entries from database, re-decrypting payloads with current secrets. + public func loadExistingEntries() async -> [RxLogEntryDTO] { + guard let radioID else { return [] } + do { + let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) + return entries.map { decryptEntry($0) } + } catch { + logger.error("Failed to load RX log entries: \(error.localizedDescription)") + return [] + } + } + + // MARK: - Decryption + + /// Attempt to decrypt a channel message entry using current secrets. + /// Returns a copy of the entry with `decodedText` populated if decryption succeeds. + /// This is reusable for export and other features that need decrypted content. + /// + /// If the entry was previously decrypted (`decryptStatus == .success`), + /// we use the stored channel index for O(1) secret lookup instead of trying all keys. + public func decryptEntry(_ entry: RxLogEntryDTO) -> RxLogEntryDTO { + var result = entry + + // Attempt DM decryption for direct text messages + if entry.payloadType == .textMessage, + entry.routeType == .direct || entry.routeType == .tcDirect { + if let myPrivateKey, + let dmResult = Self.tryDecryptDM( + payload: entry.packetPayload, + myPrivateKey: myPrivateKey, + contactPublicKeysByPrefix: contactPublicKeysByPrefix + ) { + result.senderTimestamp = dmResult.timestamp + result.decodedText = dmResult.text + } + return result + } - // Skip if payload is too small - guard entry.packetPayload.count >= 1 + ChannelCrypto.macSize + 16 else { - return result - } + // Only attempt decryption for channel messages + guard entry.payloadType == .groupText || entry.payloadType == .groupData else { + return result + } - // Channel payload format: [channelHash: 1B] [MAC: 2B] [ciphertext: NB] - let encryptedPayload = Data(entry.packetPayload.dropFirst(1)) - - // Fast path: use stored channel index if previously decrypted successfully - if entry.decryptStatus == .success, let channelIndex = entry.channelIndex, - let secret = channelSecrets[channelIndex] { - let decryptResult = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) - if case .success(let timestamp, _, let text) = decryptResult { - result.senderTimestamp = timestamp - result.decodedText = text - return result - } - } + // Skip if payload is too small + guard entry.packetPayload.count >= 1 + ChannelCrypto.macSize + 16 else { + return result + } - // Slow path: try all secrets (for .noMatchingKey entries or if fast path failed) - // and record which channel's secret matched so the entry is attributed correctly. - for (index, secret) in channelSecrets { - let decryptResult = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) - if case .success(let timestamp, _, let text) = decryptResult { - result.channelIndex = index - result.channelName = channelNames[index] ?? "Channel \(index)" - result.senderTimestamp = timestamp - result.decodedText = text - break - } - } + // Channel payload format: [channelHash: 1B] [MAC: 2B] [ciphertext: NB] + let encryptedPayload = Data(entry.packetPayload.dropFirst(1)) + // Fast path: use stored channel index if previously decrypted successfully + if entry.decryptStatus == .success, let channelIndex = entry.channelIndex, + let secret = channelSecrets[channelIndex] { + let decryptResult = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) + if case let .success(timestamp, _, text) = decryptResult { + result.senderTimestamp = timestamp + result.decodedText = text return result + } } - /// Try decrypting a DM payload by iterating candidate keys for the sender prefix byte. - /// Returns the decrypted timestamp and text, or nil if no key matched. - private static func tryDecryptDM( - payload: Data, - myPrivateKey: Data, - contactPublicKeysByPrefix: [UInt8: [Data]] - ) -> (timestamp: UInt32, text: String?)? { - guard payload.count >= DirectMessageCrypto.minPacketSize else { return nil } - // DM payload: [destHash:1][srcHash:1][MAC:2][ciphertext:N] - let payloadHashSize = 1 - let senderPrefix = payload[payloadHashSize] - guard let candidateKeys = contactPublicKeysByPrefix[senderPrefix] else { return nil } - - for senderPublicKey in candidateKeys { - if case .success(let timestamp, _, let text) = DirectMessageCrypto.decrypt( - payload: payload, - myPrivateKey: myPrivateKey, - senderPublicKey: senderPublicKey - ) { - return (timestamp, text) - } - } - return nil + // Slow path: try all secrets (for .noMatchingKey entries or if fast path failed) + // and record which channel's secret matched so the entry is attributed correctly. + for (index, secret) in channelSecrets { + let decryptResult = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) + if case let .success(timestamp, _, text) = decryptResult { + result.channelIndex = index + result.channelName = channelNames[index] ?? "Channel \(index)" + result.senderTimestamp = timestamp + result.decodedText = text + break + } } - /// Clear all entries. - public func clearEntries() async { - guard let radioID else { return } - do { - try await dataStore.clearRxLogEntries(radioID: radioID) - } catch { - logger.error("Failed to clear RX log entries: \(error.localizedDescription)") - } + return result + } + + /// Try decrypting a DM payload by iterating candidate keys for the sender prefix byte. + /// Returns the decrypted timestamp and text, or nil if no key matched. + private static func tryDecryptDM( + payload: Data, + myPrivateKey: Data, + contactPublicKeysByPrefix: [UInt8: [Data]] + ) -> (timestamp: UInt32, text: String?)? { + guard payload.count >= DirectMessageCrypto.minPacketSize else { return nil } + // DM payload: [destHash:1][srcHash:1][MAC:2][ciphertext:N] + let payloadHashSize = 1 + let senderPrefix = payload[payloadHashSize] + guard let candidateKeys = contactPublicKeysByPrefix[senderPrefix] else { return nil } + + for senderPublicKey in candidateKeys { + if case let .success(timestamp, _, text) = DirectMessageCrypto.decrypt( + payload: payload, + myPrivateKey: myPrivateKey, + senderPublicKey: senderPublicKey + ) { + return (timestamp, text) + } + } + return nil + } + + /// Clear all entries. + public func clearEntries() async { + guard let radioID else { return } + do { + try await dataStore.clearRxLogEntries(radioID: radioID) + } catch { + logger.error("Failed to clear RX log entries: \(error.localizedDescription)") } + } } diff --git a/MC1Services/Sources/MC1Services/Services/SceneStorageKey.swift b/MC1Services/Sources/MC1Services/Services/SceneStorageKey.swift index 76105a8c..d3bd97f1 100644 --- a/MC1Services/Sources/MC1Services/Services/SceneStorageKey.swift +++ b/MC1Services/Sources/MC1Services/Services/SceneStorageKey.swift @@ -10,8 +10,6 @@ import Foundation /// Raw values are pinned to their exact on-disk key so a case rename can't /// silently mint a new key and orphan restored state. Keep them pinned. public enum SceneStorageKey: String { - // swiftlint:disable redundant_string_enum_value - /// The user's last map camera region, serialized by `MapCameraStore`. - case mapCameraRegion = "mapCameraRegion" - // swiftlint:enable redundant_string_enum_value + /// The user's last map camera region, serialized by `MapCameraStore`. + case mapCameraRegion } diff --git a/MC1Services/Sources/MC1Services/Services/SendQueue.swift b/MC1Services/Sources/MC1Services/Services/SendQueue.swift index 51960515..ef9ee480 100644 --- a/MC1Services/Sources/MC1Services/Services/SendQueue.swift +++ b/MC1Services/Sources/MC1Services/Services/SendQueue.swift @@ -22,117 +22,118 @@ import Foundation /// the drain completes — a popped owner mid-drain cannot strand an /// in-flight send. Strong capture is broken naturally by Task completion. actor SendQueue { + typealias Sender = @Sendable (Envelope) async throws -> Void + typealias OnError = @Sendable (Error, Envelope) async -> Void - typealias Sender = @Sendable (Envelope) async throws -> Void - typealias OnError = @Sendable (Error, Envelope) async -> Void + /// Fires once per drain pass after the inner `while !pending.isEmpty` + /// completes. The parameter is the most recent non-cancellation + /// `Error` raised by `send(_:)` during this drain (or `nil` if no + /// envelope failed). Last-error-wins matches the original + /// `processQueue`'s `var pendingError: String?` semantics. + typealias OnDrain = @Sendable (Error?) async -> Void - /// Fires once per drain pass after the inner `while !pending.isEmpty` - /// completes. The parameter is the most recent non-cancellation - /// `Error` raised by `send(_:)` during this drain (or `nil` if no - /// envelope failed). Last-error-wins matches the original - /// `processQueue`'s `var pendingError: String?` semantics. - typealias OnDrain = @Sendable (Error?) async -> Void + private var pending: [Envelope] = [] + private var processingTask: Task? + private let send: Sender + private let onError: OnError + private let onDrain: OnDrain - private var pending: [Envelope] = [] - private var processingTask: Task? - private let send: Sender - private let onError: OnError - private let onDrain: OnDrain + init( + send: @escaping Sender, + onError: @escaping OnError, + onDrain: @escaping OnDrain + ) { + self.send = send + self.onError = onError + self.onDrain = onDrain + } - init( - send: @escaping Sender, - onError: @escaping OnError, - onDrain: @escaping OnDrain - ) { - self.send = send - self.onError = onError - self.onDrain = onDrain - } - - /// Append an envelope and ensure a drain task is running. - func enqueue(_ envelope: Envelope) { - pending.append(envelope) - ensureDraining() - } + /// Append an envelope and ensure a drain task is running. + func enqueue(_ envelope: Envelope) { + pending.append(envelope) + ensureDraining() + } - #if DEBUG + #if DEBUG /// Number of envelopes waiting. Exposed for tests; no view consumer. - var count: Int { pending.count } + var count: Int { + pending.count + } /// Await the current drain pass to completion. If no drain is in /// progress this returns immediately. Used by tests to synchronize /// on the drain task without polling — production consumers observe /// drain completion via the `onDrain` callback. func awaitDrainCompletion() async { - await processingTask?.value + await processingTask?.value } - #endif + #endif - /// Cancel the in-flight drain task and suppress the auto-respawn that - /// would otherwise follow a `CancellationError` re-insertion. The - /// current send closure's next `await` propagates `CancellationError`, - /// which the `catch is CancellationError` branch handles by - /// re-inserting the envelope; `taskCompleted` then observes - /// `Task.isCancelled` and skips its respawn so the queue goes - /// dormant. A subsequent `enqueue(_:)` schedules a fresh drain task - /// because `processingTask` is back to `nil`. - /// - /// Provided so test teardown can release a SendQueue whose send - /// closure suspends, and so a future production teardown does not - /// leak the actor through an unbounded respawn cycle. - func cancelDrain() { - processingTask?.cancel() - } + /// Cancel the in-flight drain task and suppress the auto-respawn that + /// would otherwise follow a `CancellationError` re-insertion. The + /// current send closure's next `await` propagates `CancellationError`, + /// which the `catch is CancellationError` branch handles by + /// re-inserting the envelope; `taskCompleted` then observes + /// `Task.isCancelled` and skips its respawn so the queue goes + /// dormant. A subsequent `enqueue(_:)` schedules a fresh drain task + /// because `processingTask` is back to `nil`. + /// + /// Provided so test teardown can release a SendQueue whose send + /// closure suspends, and so a future production teardown does not + /// leak the actor through an unbounded respawn cycle. + func cancelDrain() { + processingTask?.cancel() + } - private func ensureDraining() { - // Only one task in flight at a time. A draining task that requeued - // via CancellationError but hasn't yet completed still holds the - // slot; taskCompleted will respawn after it returns. - guard processingTask == nil else { return } - spawnDrainTask() - } + private func ensureDraining() { + // Only one task in flight at a time. A draining task that requeued + // via CancellationError but hasn't yet completed still holds the + // slot; taskCompleted will respawn after it returns. + guard processingTask == nil else { return } + spawnDrainTask() + } - private func spawnDrainTask() { - processingTask = Task { [self] in - await drain() - taskCompleted() - } + private func spawnDrainTask() { + processingTask = Task { [self] in + await drain() + taskCompleted() } + } - private func taskCompleted() { - processingTask = nil - // If the completing task was cancelled (via cancelDrain or an - // upstream cancellation), treat it as an intentional halt — leave - // the queue dormant. A later enqueue still schedules a fresh - // drain task because processingTask is back to nil. - if Task.isCancelled { return } - // A send-closure CancellationError may have re-inserted an envelope - // before this task returned; respawn so that envelope drains. - if !pending.isEmpty { - spawnDrainTask() - } + private func taskCompleted() { + processingTask = nil + // If the completing task was cancelled (via cancelDrain or an + // upstream cancellation), treat it as an intentional halt — leave + // the queue dormant. A later enqueue still schedules a fresh + // drain task because processingTask is back to nil. + if Task.isCancelled { return } + // A send-closure CancellationError may have re-inserted an envelope + // before this task returned; respawn so that envelope drains. + if !pending.isEmpty { + spawnDrainTask() } + } - private func drain() async { - var lastError: Error? - repeat { - while !pending.isEmpty { - let envelope = pending.removeFirst() - do { - try await send(envelope) - } catch is CancellationError { - pending.insert(envelope, at: 0) - return - } catch { - lastError = error - await onError(error, envelope) - } - } - // Outer re-check after onDrain handler suspends. The handler - // typically calls loadMessages/loadConversations, during which an - // enqueue can land. Without this outer pass, that envelope would - // sit in pending with no scheduled drain until a future enqueue. - await onDrain(lastError) - } while !pending.isEmpty - } + private func drain() async { + var lastError: Error? + repeat { + while !pending.isEmpty { + let envelope = pending.removeFirst() + do { + try await send(envelope) + } catch is CancellationError { + pending.insert(envelope, at: 0) + return + } catch { + lastError = error + await onError(error, envelope) + } + } + // Outer re-check after onDrain handler suspends. The handler + // typically calls loadMessages/loadConversations, during which an + // enqueue can land. Without this outer pass, that envelope would + // sit in pending with no scheduled drain until a future enqueue. + await onDrain(lastError) + } while !pending.isEmpty + } } diff --git a/MC1Services/Sources/MC1Services/Services/SettingsEvent.swift b/MC1Services/Sources/MC1Services/Services/SettingsEvent.swift index 2e06927f..f512fa13 100644 --- a/MC1Services/Sources/MC1Services/Services/SettingsEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/SettingsEvent.swift @@ -3,10 +3,10 @@ import MeshCore /// Events emitted by SettingsService when device settings change. public enum SettingsEvent: Sendable { - case deviceUpdated(MeshCore.SelfInfo) - case autoAddConfigUpdated(MeshCore.AutoAddConfig) - case clientRepeatUpdated(Bool) - case pathHashModeUpdated(UInt8) - case allowedRepeatFreqUpdated([MeshCore.FrequencyRange]) - case defaultFloodScopeUpdated(String?) + case deviceUpdated(MeshCore.SelfInfo) + case autoAddConfigUpdated(MeshCore.AutoAddConfig) + case clientRepeatUpdated(Bool) + case pathHashModeUpdated(UInt8) + case allowedRepeatFreqUpdated([MeshCore.FrequencyRange]) + case defaultFloodScopeUpdated(String?) } diff --git a/MC1Services/Sources/MC1Services/Services/SettingsService+Verified.swift b/MC1Services/Sources/MC1Services/Services/SettingsService+Verified.swift index 00f37513..680f454a 100644 --- a/MC1Services/Sources/MC1Services/Services/SettingsService+Verified.swift +++ b/MC1Services/Sources/MC1Services/Services/SettingsService+Verified.swift @@ -3,225 +3,227 @@ import MeshCore // MARK: - Verified Settings Methods -extension SettingsService { - - /// Set node name with verification - /// Returns the verified self info for UI update - public func setNodeNameVerified(_ name: String) async throws -> MeshCore.SelfInfo { - let truncated = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - try await setNodeName(truncated) - - let selfInfo = try await getSelfInfo() - - guard selfInfo.name == truncated else { - throw SettingsServiceError.verificationFailed( - expected: truncated, - actual: selfInfo.name - ) - } - - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo +public extension SettingsService { + /// Set node name with verification + /// Returns the verified self info for UI update + func setNodeNameVerified(_ name: String) async throws -> MeshCore.SelfInfo { + let truncated = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + try await setNodeName(truncated) + + let selfInfo = try await getSelfInfo() + + guard selfInfo.name == truncated else { + throw SettingsServiceError.verificationFailed( + expected: truncated, + actual: selfInfo.name + ) } - /// Set location with verification - public func setLocationVerified(latitude: Double, longitude: Double) async throws -> MeshCore.SelfInfo { - // Calculate the scaled values we're actually sending - let scaledLatSent = Int32(latitude * 1_000_000) - let scaledLonSent = Int32(longitude * 1_000_000) - - // log when attempting to clear location - let isClearingLocation = scaledLatSent == 0 && scaledLonSent == 0 - logger.debug("[Location] setLocationVerified called - lat: \(latitude), lon: \(longitude), isClearing: \(isClearingLocation)") + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } - try await setLocation(latitude: latitude, longitude: longitude) + /// Set location with verification + func setLocationVerified(latitude: Double, longitude: Double) async throws -> MeshCore.SelfInfo { + // Calculate the scaled values we're actually sending + let scaledLatSent = Int32(latitude * 1_000_000) + let scaledLonSent = Int32(longitude * 1_000_000) - // Read back and compare at scaled integer level for precise diagnostics - let selfInfo = try await getSelfInfo() - let scaledLatReceived = Int32(selfInfo.latitude * 1_000_000) - let scaledLonReceived = Int32(selfInfo.longitude * 1_000_000) + // log when attempting to clear location + let isClearingLocation = scaledLatSent == 0 && scaledLonSent == 0 + logger.debug("[Location] setLocationVerified called - lat: \(latitude), lon: \(longitude), isClearing: \(isClearingLocation)") - let latDiff = abs(scaledLatSent - scaledLatReceived) - let lonDiff = abs(scaledLonSent - scaledLonReceived) + try await setLocation(latitude: latitude, longitude: longitude) - // Tolerance of 2 scaled units (~0.2m) handles floating-point conversion - let tolerance: Int32 = 2 + // Read back and compare at scaled integer level for precise diagnostics + let selfInfo = try await getSelfInfo() + let scaledLatReceived = Int32(selfInfo.latitude * 1_000_000) + let scaledLonReceived = Int32(selfInfo.longitude * 1_000_000) - guard latDiff <= tolerance && lonDiff <= tolerance else { - logger.error("[Location] Verification failed - sent: (\(scaledLatSent), \(scaledLonSent)), received: (\(scaledLatReceived), \(scaledLonReceived)), diff: (lat=\(latDiff), lon=\(lonDiff))") + let latDiff = abs(scaledLatSent - scaledLatReceived) + let lonDiff = abs(scaledLonSent - scaledLonReceived) - if isClearingLocation { - logger.warning("[Location] Clear location failed - device reports non-zero coordinates. Device may have active GPS or firmware doesn't support (0,0).") - } + // Tolerance of 2 scaled units (~0.2m) handles floating-point conversion + let tolerance: Int32 = 2 - let expectedLat = Double(scaledLatSent) / 1_000_000 - let expectedLon = Double(scaledLonSent) / 1_000_000 - throw SettingsServiceError.verificationFailed( - expected: "(\(expectedLat), \(expectedLon))", - actual: "(\(selfInfo.latitude), \(selfInfo.longitude))" - ) - } + guard latDiff <= tolerance, lonDiff <= tolerance else { + logger.error("[Location] Verification failed - sent: (\(scaledLatSent), \(scaledLonSent)), received: (\(scaledLatReceived), \(scaledLonReceived)), diff: (lat=\(latDiff), lon=\(lonDiff))") - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo - } + if isClearingLocation { + logger.warning("[Location] Clear location failed - device reports non-zero coordinates. Device may have active GPS or firmware doesn't support (0,0).") + } - /// Set a manual location, turning off device GPS first when needed so the value persists. - public func setManualLocationVerified(latitude: Double, longitude: Double) async throws -> MeshCore.SelfInfo { - let gpsState = try await getDeviceGPSState() - if gpsState.isSupported, gpsState.isEnabled { - _ = try await setDeviceGPSEnabledVerified(false) - } - return try await setLocationVerified(latitude: latitude, longitude: longitude) + let expectedLat = Double(scaledLatSent) / 1_000_000 + let expectedLon = Double(scaledLonSent) / 1_000_000 + throw SettingsServiceError.verificationFailed( + expected: "(\(expectedLat), \(expectedLon))", + actual: "(\(selfInfo.latitude), \(selfInfo.longitude))" + ) } - /// Set radio parameters with verification. - /// - /// Same unit conventions as `setRadioParams(frequencyKHz:bandwidthKHz:...)` — - /// `frequencyKHz` is in kHz (869618 → 869.618 MHz) and `bandwidthKHz` is in Hz - /// (62500 → 62.5 kHz) despite the suffix. See that method for the full rationale. - public func setRadioParamsVerified( - frequencyKHz: UInt32, - bandwidthKHz: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8, - clientRepeat: Bool? = nil - ) async throws -> MeshCore.SelfInfo { - logger.info("[Radio] Sending params: freq=\(frequencyKHz)kHz, bw=\(bandwidthKHz)Hz, sf=\(spreadingFactor), cr=\(codingRate), repeat=\(String(describing: clientRepeat))") - - try await setRadioParams( - frequencyKHz: frequencyKHz, - bandwidthKHz: bandwidthKHz, - spreadingFactor: spreadingFactor, - codingRate: codingRate, - clientRepeat: clientRepeat - ) + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } - let selfInfo = try await getSelfInfo() - - let expectedFreqMHz = Double(frequencyKHz) / 1000.0 - let expectedBwMHz = Double(bandwidthKHz) / 1000.0 - - guard abs(selfInfo.radioFrequency - expectedFreqMHz) < 0.001 && - abs(selfInfo.radioBandwidth - expectedBwMHz) < 0.001 && - selfInfo.radioSpreadingFactor == spreadingFactor && - selfInfo.radioCodingRate == codingRate else { - // swiftlint:disable:next line_length - logger.warning("[Radio] Verification failed - expected: freq=\(expectedFreqMHz)MHz, bw=\(expectedBwMHz)kHz, sf=\(spreadingFactor), cr=\(codingRate); device reports: freq=\(selfInfo.radioFrequency)MHz, bw=\(selfInfo.radioBandwidth)kHz, sf=\(selfInfo.radioSpreadingFactor), cr=\(selfInfo.radioCodingRate)") - throw SettingsServiceError.verificationFailed( - expected: "freq=\(frequencyKHz), bw=\(bandwidthKHz), sf=\(spreadingFactor), cr=\(codingRate)", - actual: "freq=\(selfInfo.radioFrequency), bw=\(selfInfo.radioBandwidth), sf=\(selfInfo.radioSpreadingFactor), cr=\(selfInfo.radioCodingRate)" - ) - } - - // Verify clientRepeat via queryDevice if it was explicitly set - if let expectedRepeat = clientRepeat { - let capabilities = try await queryDevice() - guard capabilities.clientRepeat == expectedRepeat else { - logger.warning("[Radio] Client repeat verification failed - expected: \(expectedRepeat), device reports: \(capabilities.clientRepeat)") - throw SettingsServiceError.verificationFailed( - expected: "clientRepeat=\(expectedRepeat)", - actual: "clientRepeat=\(capabilities.clientRepeat)" - ) - } - logger.info("[Radio] Client repeat verified: \(expectedRepeat)") - eventContinuation?.yield(.clientRepeatUpdated(expectedRepeat)) - } - - logger.info("[Radio] Params verified successfully") - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo + /// Set a manual location, turning off device GPS first when needed so the value persists. + func setManualLocationVerified(latitude: Double, longitude: Double) async throws -> MeshCore.SelfInfo { + let gpsState = try await getDeviceGPSState() + if gpsState.isSupported, gpsState.isEnabled { + _ = try await setDeviceGPSEnabledVerified(false) } - - /// Apply radio preset with verification - public func applyRadioPresetVerified(_ preset: RadioPreset) async throws -> MeshCore.SelfInfo { - logger.info("[Radio] Applying preset: \(preset.name) (\(preset.id))") - return try await setRadioParamsVerified( - frequencyKHz: preset.frequencyKHz, - bandwidthKHz: preset.bandwidthHz, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate + return try await setLocationVerified(latitude: latitude, longitude: longitude) + } + + /// Set radio parameters with verification. + /// + /// Same unit conventions as `setRadioParams(frequencyKHz:bandwidthKHz:...)` — + /// `frequencyKHz` is in kHz (869618 → 869.618 MHz) and `bandwidthKHz` is in Hz + /// (62500 → 62.5 kHz) despite the suffix. See that method for the full rationale. + func setRadioParamsVerified( + frequencyKHz: UInt32, + bandwidthKHz: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8, + clientRepeat: Bool? = nil + ) async throws -> MeshCore.SelfInfo { + logger.info("[Radio] Sending params: freq=\(frequencyKHz)kHz, bw=\(bandwidthKHz)Hz, sf=\(spreadingFactor), cr=\(codingRate), repeat=\(String(describing: clientRepeat))") + + try await setRadioParams( + frequencyKHz: frequencyKHz, + bandwidthKHz: bandwidthKHz, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + clientRepeat: clientRepeat + ) + + let selfInfo = try await getSelfInfo() + + let expectedFreqMHz = Double(frequencyKHz) / 1000.0 + let expectedBwMHz = Double(bandwidthKHz) / 1000.0 + + guard abs(selfInfo.radioFrequency - expectedFreqMHz) < 0.001, + abs(selfInfo.radioBandwidth - expectedBwMHz) < 0.001, + selfInfo.radioSpreadingFactor == spreadingFactor, + selfInfo.radioCodingRate == codingRate else { + logger + .warning( + // swiftlint:disable:next line_length + "[Radio] Verification failed - expected: freq=\(expectedFreqMHz)MHz, bw=\(expectedBwMHz)kHz, sf=\(spreadingFactor), cr=\(codingRate); device reports: freq=\(selfInfo.radioFrequency)MHz, bw=\(selfInfo.radioBandwidth)kHz, sf=\(selfInfo.radioSpreadingFactor), cr=\(selfInfo.radioCodingRate)" ) + throw SettingsServiceError.verificationFailed( + expected: "freq=\(frequencyKHz), bw=\(bandwidthKHz), sf=\(spreadingFactor), cr=\(codingRate)", + actual: "freq=\(selfInfo.radioFrequency), bw=\(selfInfo.radioBandwidth), sf=\(selfInfo.radioSpreadingFactor), cr=\(selfInfo.radioCodingRate)" + ) } - /// Set TX power with verification - public func setTxPowerVerified(_ power: Int8) async throws -> MeshCore.SelfInfo { - logger.info("[Radio] Sending TX power: \(power)dBm") - - try await setTxPower(power) - - let selfInfo = try await getSelfInfo() - - guard selfInfo.txPower == power else { - logger.warning("[Radio] TX power verification failed - expected: \(power)dBm, device reports: \(selfInfo.txPower)dBm") - throw SettingsServiceError.verificationFailed( - expected: "\(power)", - actual: "\(selfInfo.txPower)" - ) - } - - logger.info("[Radio] TX power verified: \(power)dBm") - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo - } - - /// Set other params with verification - public func setOtherParamsVerified( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - advertLocationPolicy: AdvertLocationPolicy, - multiAcks: UInt8 - ) async throws -> MeshCore.SelfInfo { - try await setOtherParams( - autoAddContacts: autoAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicy: advertLocationPolicy, - multiAcks: multiAcks + // Verify clientRepeat via queryDevice if it was explicitly set + if let expectedRepeat = clientRepeat { + let capabilities = try await queryDevice() + guard capabilities.clientRepeat == expectedRepeat else { + logger.warning("[Radio] Client repeat verification failed - expected: \(expectedRepeat), device reports: \(capabilities.clientRepeat)") + throw SettingsServiceError.verificationFailed( + expected: "clientRepeat=\(expectedRepeat)", + actual: "clientRepeat=\(capabilities.clientRepeat)" ) - - let selfInfo = try await getSelfInfo() - - // manualAddContacts is inverted (false = auto-add enabled) - guard selfInfo.manualAddContacts != autoAddContacts else { - throw SettingsServiceError.verificationFailed( - expected: "autoAdd=\(autoAddContacts)", - actual: "autoAdd=\(!selfInfo.manualAddContacts)" - ) - } - - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo + } + logger.info("[Radio] Client repeat verified: \(expectedRepeat)") + eventContinuation?.yield(.clientRepeatUpdated(expectedRepeat)) } - /// Convenience overload: uses the device's current values as defaults, overriding only the supplied parameters. - public func setOtherParamsVerified( - from device: DeviceDTO, - autoAddContacts: Bool? = nil, - telemetryModes: TelemetryModes? = nil, - advertLocationPolicy: AdvertLocationPolicy? = nil, - multiAcks: UInt8? = nil - ) async throws -> MeshCore.SelfInfo { - try await setOtherParamsVerified( - autoAddContacts: autoAddContacts ?? !device.manualAddContacts, - telemetryModes: telemetryModes ?? device.telemetryModes, - advertLocationPolicy: advertLocationPolicy ?? device.advertLocationPolicyMode, - multiAcks: multiAcks ?? device.multiAcks - ) + logger.info("[Radio] Params verified successfully") + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } + + /// Apply radio preset with verification + func applyRadioPresetVerified(_ preset: RadioPreset) async throws -> MeshCore.SelfInfo { + logger.info("[Radio] Applying preset: \(preset.name) (\(preset.id))") + return try await setRadioParamsVerified( + frequencyKHz: preset.frequencyKHz, + bandwidthKHz: preset.bandwidthHz, + spreadingFactor: preset.spreadingFactor, + codingRate: preset.codingRate + ) + } + + /// Set TX power with verification + func setTxPowerVerified(_ power: Int8) async throws -> MeshCore.SelfInfo { + logger.info("[Radio] Sending TX power: \(power)dBm") + + try await setTxPower(power) + + let selfInfo = try await getSelfInfo() + + guard selfInfo.txPower == power else { + logger.warning("[Radio] TX power verification failed - expected: \(power)dBm, device reports: \(selfInfo.txPower)dBm") + throw SettingsServiceError.verificationFailed( + expected: "\(power)", + actual: "\(selfInfo.txPower)" + ) } - /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. - @available(*, deprecated, message: "Use advertLocationPolicy overload instead") - public func setOtherParamsVerified( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - shareLocationPublicly: Bool, - multiAcks: UInt8 - ) async throws -> MeshCore.SelfInfo { - try await setOtherParamsVerified( - autoAddContacts: autoAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicy: shareLocationPublicly ? .prefs : .none, - multiAcks: multiAcks - ) + logger.info("[Radio] TX power verified: \(power)dBm") + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } + + /// Set other params with verification + func setOtherParamsVerified( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + advertLocationPolicy: AdvertLocationPolicy, + multiAcks: UInt8 + ) async throws -> MeshCore.SelfInfo { + try await setOtherParams( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: advertLocationPolicy, + multiAcks: multiAcks + ) + + let selfInfo = try await getSelfInfo() + + // manualAddContacts is inverted (false = auto-add enabled) + guard selfInfo.manualAddContacts != autoAddContacts else { + throw SettingsServiceError.verificationFailed( + expected: "autoAdd=\(autoAddContacts)", + actual: "autoAdd=\(!selfInfo.manualAddContacts)" + ) } + + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } + + /// Convenience overload: uses the device's current values as defaults, overriding only the supplied parameters. + func setOtherParamsVerified( + from device: DeviceDTO, + autoAddContacts: Bool? = nil, + telemetryModes: TelemetryModes? = nil, + advertLocationPolicy: AdvertLocationPolicy? = nil, + multiAcks: UInt8? = nil + ) async throws -> MeshCore.SelfInfo { + try await setOtherParamsVerified( + autoAddContacts: autoAddContacts ?? !device.manualAddContacts, + telemetryModes: telemetryModes ?? device.telemetryModes, + advertLocationPolicy: advertLocationPolicy ?? device.advertLocationPolicyMode, + multiAcks: multiAcks ?? device.multiAcks + ) + } + + /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. + @available(*, deprecated, message: "Use advertLocationPolicy overload instead") + func setOtherParamsVerified( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + shareLocationPublicly: Bool, + multiAcks: UInt8 + ) async throws -> MeshCore.SelfInfo { + try await setOtherParamsVerified( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: shareLocationPublicly ? .prefs : .none, + multiAcks: multiAcks + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/SettingsService.swift b/MC1Services/Sources/MC1Services/Services/SettingsService.swift index 51104975..631251ad 100644 --- a/MC1Services/Sources/MC1Services/Services/SettingsService.swift +++ b/MC1Services/Sources/MC1Services/Services/SettingsService.swift @@ -7,506 +7,506 @@ import os /// Service for managing device settings via MeshCore session. /// Handles radio configuration, node settings, Bluetooth settings, and device info. public actor SettingsService { - private let session: any ConfigurationSessionOps - let logger = PersistentLogger(subsystem: "com.mc1", category: "SettingsService") - - // Event stream subscriber. The subscription ID lets a replaced subscriber's - // onTermination distinguish itself from the subscriber that replaced it. - var eventContinuation: AsyncStream.Continuation? - private var eventSubscriptionID: UUID? - - public init(session: any ConfigurationSessionOps) { - self.session = session - } - - /// Stream of settings change events. - /// Only one active subscriber is supported. Subsequent calls replace the previous subscriber. - public func events() -> AsyncStream { - // Register synchronously so events yielded right after this call - // returns are not dropped behind a registration Task. - let (stream, continuation) = AsyncStream.makeStream(of: SettingsEvent.self) - let subscriptionID = UUID() - setContinuation(continuation, subscriptionID: subscriptionID) - continuation.onTermination = { @Sendable _ in - Task { await self.clearContinuation(subscriptionID: subscriptionID) } - } - return stream - } - - private func setContinuation( - _ continuation: AsyncStream.Continuation, - subscriptionID: UUID - ) { - if eventContinuation != nil { - logger.warning("Replacing existing SettingsService event stream subscriber") - } - eventContinuation?.finish() - eventContinuation = continuation - eventSubscriptionID = subscriptionID - } - - /// Clears the continuation only while the terminating subscription is still - /// current, so a replaced subscriber cannot disconnect its replacement. - private func clearContinuation(subscriptionID: UUID) { - guard eventSubscriptionID == subscriptionID else { return } - eventContinuation = nil - eventSubscriptionID = nil - } - - // MARK: - Radio Settings - - /// Apply a radio preset to the device - public func applyRadioPreset(_ preset: RadioPreset) async throws { - try await setRadioParams( - frequencyKHz: preset.frequencyKHz, - bandwidthKHz: preset.bandwidthHz, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate - ) - } - - /// Set radio parameters manually. - /// - /// Both numeric parameters are integer values that get divided by 1000 before being - /// forwarded to `session.setRadio`. Pass values in the same scaled-integer form that - /// `RadioPreset.frequencyKHz` and `RadioPreset.bandwidthHz` use: - /// - `frequencyKHz`: frequency expressed in kHz (e.g. `869618` → 869.618 MHz on the wire) - /// - `bandwidthKHz`: bandwidth expressed in Hz (e.g. `62500` → 62.5 kHz on the wire) - /// - /// The `bandwidthKHz` name is preserved for source compatibility despite the value - /// actually being in Hz; do not pass `Int(62.5)` here. - public func setRadioParams( - frequencyKHz: UInt32, - bandwidthKHz: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8, - clientRepeat: Bool? = nil - ) async throws { - do { - try await session.setRadio( - frequency: Double(frequencyKHz) / 1000.0, - bandwidth: Double(bandwidthKHz) / 1000.0, - spreadingFactor: spreadingFactor, - codingRate: codingRate, - clientRepeat: clientRepeat - ) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Set transmit power - public func setTxPower(_ power: Int8) async throws { - do { - try await session.setTxPower(power) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Node Settings - - /// Set the publicly visible node name - public func setNodeName(_ name: String) async throws { - let truncated = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - do { - try await session.setName(truncated) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Set node location (latitude/longitude in degrees) - public func setLocation(latitude: Double, longitude: Double) async throws { - do { - try await session.setCoordinates(latitude: latitude, longitude: longitude) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Bluetooth Settings - - /// Set BLE PIN (0 = disabled/random, 100000-999999 = fixed PIN) - public func setBlePin(_ pin: UInt32) async throws { - do { - try await session.setDevicePin(pin) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Other Settings - - /// Set other device parameters (contacts, telemetry, location policy) - public func setOtherParams( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - advertLocationPolicy: AdvertLocationPolicy, - multiAcks: UInt8 - ) async throws { - try await setOtherParams( - autoAddContacts: autoAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicyRaw: advertLocationPolicy.rawValue, - multiAcks: multiAcks - ) - } - - /// Set other device parameters, taking the advertisement location policy as a raw byte. - /// - /// Used by config import so a policy value not modeled by ``AdvertLocationPolicy`` (e.g. from - /// newer firmware) is forwarded to the device verbatim instead of being coerced. - public func setOtherParams( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - advertLocationPolicyRaw: UInt8, - multiAcks: UInt8 - ) async throws { - do { - try await session.setOtherParams( - manualAddContacts: !autoAddContacts, - telemetryModeEnvironment: telemetryModes.environment, - telemetryModeLocation: telemetryModes.location, - telemetryModeBase: telemetryModes.base, - advertisementLocationPolicy: advertLocationPolicyRaw, - multiAcks: multiAcks - ) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. - @available(*, deprecated, message: "Use advertLocationPolicy overload instead") - public func setOtherParams( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - shareLocationPublicly: Bool, - multiAcks: UInt8 - ) async throws { - try await setOtherParams( - autoAddContacts: autoAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicy: shareLocationPublicly ? .prefs : .none, - multiAcks: multiAcks - ) - } - - // MARK: - Factory Reset - - /// Perform factory reset on device - public func factoryReset() async throws { - do { - try await session.factoryReset() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Reboot the device - public func reboot() async throws { - do { - try await session.reboot() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Device Info - - /// Fetch battery and storage information from device - /// - Returns: BatteryInfo with current values - /// - Throws: SettingsServiceError if not connected or communication fails - public func getBattery() async throws -> BatteryInfo { - do { - return try await session.getBattery() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Query device capabilities - public func queryDevice() async throws -> DeviceCapabilities { - do { - return try await session.queryDevice() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Get self info by sending appStart - public func getSelfInfo() async throws -> MeshCore.SelfInfo { - do { - return try await session.sendAppStart() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Auto-Add Config - - /// Get auto-add configuration from device - public func getAutoAddConfig() async throws -> MeshCore.AutoAddConfig { - do { - return try await session.getAutoAddConfig() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Refresh auto-add config from device (for initial load) - /// Fetches current value and triggers callback to update connected device - public func refreshAutoAddConfig() async throws { - let config = try await getAutoAddConfig() - eventContinuation?.yield(.autoAddConfigUpdated(config)) - } - - // MARK: - Repeat Frequency Ranges - - /// Get allowed repeat frequency ranges from device - private func getRepeatFreq() async throws -> [MeshCore.FrequencyRange] { - do { - return try await session.getRepeatFreq() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Refresh repeat frequency ranges from device and notify observers - public func refreshRepeatFreqRanges() async throws { - let ranges = try await getRepeatFreq() - eventContinuation?.yield(.allowedRepeatFreqUpdated(ranges)) - } - - /// Refresh device info from the device and notify observers. - /// Use this instead of `setLocationVerified` when the device already has correct coordinates (e.g. from its own GPS). - public func refreshDeviceInfo() async throws { - let selfInfo = try await getSelfInfo() - eventContinuation?.yield(.deviceUpdated(selfInfo)) - } - - /// Set auto-add configuration on device - public func setAutoAddConfig(_ config: MeshCore.AutoAddConfig) async throws { - do { - try await session.setAutoAddConfig(config) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Set auto-add configuration with verification - public func setAutoAddConfigVerified(_ config: MeshCore.AutoAddConfig) async throws -> MeshCore.AutoAddConfig { - try await setAutoAddConfig(config) - - let actualConfig = try await getAutoAddConfig() - - guard actualConfig == config else { - throw SettingsServiceError.verificationFailed( - expected: "bitmask=\(config.bitmask), maxHops=\(config.maxHops)", - actual: "bitmask=\(actualConfig.bitmask), maxHops=\(actualConfig.maxHops)" - ) - } - - eventContinuation?.yield(.autoAddConfigUpdated(actualConfig)) - return actualConfig - } - - // MARK: - Path Hash Mode - - /// Sets the path hash mode on the device. - /// - /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). - public func setPathHashMode(_ mode: UInt8) async throws { - do { - try await session.setPathHashMode(mode) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Sets the path hash mode with verification via queryDevice. - /// - /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). - /// - Returns: The verified mode value from the device. - public func setPathHashModeVerified(_ mode: UInt8) async throws -> UInt8 { - try await setPathHashMode(mode) - - let capabilities = try await queryDevice() - guard capabilities.pathHashMode == mode else { - throw SettingsServiceError.verificationFailed( - expected: "pathHashMode=\(mode)", - actual: "pathHashMode=\(capabilities.pathHashMode)" - ) - } - - eventContinuation?.yield(.pathHashModeUpdated(mode)) - return mode - } - - // MARK: - Default Flood Scope - - /// Fetches the device's persisted default flood scope. - /// - /// Requires firmware v11+; older firmware rejects the opcode and surfaces - /// ``SettingsServiceError/sessionError(_:)`` with `MeshCoreError.deviceError`. - /// - /// - Returns: The persisted scope name, or `nil` when none is configured. - public func getDefaultFloodScope() async throws -> String? { - do { - let scope = try await session.getDefaultFloodScope() - let name = scope?.name - eventContinuation?.yield(.defaultFloodScopeUpdated(name)) - return name - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Persists the device's default flood scope and verifies via a follow-up read. - /// - /// Passing `nil` for `name` clears the persisted scope. Non-nil names are sent as - /// ``MeshCore/FloodScope/region(_:)`` — firmware derives the key and stores both. - /// Names are truncated to ``ProtocolLimits/maxDefaultFloodScopeNameBytes`` UTF-8 bytes - /// before both key derivation and send, so the stored display and derived scope key - /// agree on the same byte sequence. - /// - /// - Parameter name: Region name to persist, or `nil` to clear. - /// - Returns: The verified name read back from the device. - public func setDefaultFloodScopeVerified(name: String?) async throws -> String? { - let expected: String? = (name?.isEmpty == false) - ? name?.utf8Prefix(maxBytes: ProtocolLimits.maxDefaultFloodScopeNameBytes) - : nil - do { - if let expected { - try await session.setDefaultFloodScope(name: expected, scope: .region(expected)) - } else { - try await session.setDefaultFloodScope(name: "", scope: .disabled) - } - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - - let actual = try await getDefaultFloodScope() - guard actual == expected else { - throw SettingsServiceError.verificationFailed( - expected: expected ?? "(cleared)", - actual: actual ?? "(cleared)" - ) - } - return actual - } - - // MARK: - Stats - - /// Get core statistics - public func getStatsCore() async throws -> CoreStats { - do { - return try await session.getStatsCore() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Get radio statistics - public func getStatsRadio() async throws -> RadioStats { - do { - return try await session.getStatsRadio() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Get packet statistics - public func getStatsPackets() async throws -> PacketStats { - do { - return try await session.getStatsPackets() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Custom Variables - - /// Get custom variables from device - public func getCustomVars() async throws -> [String: String] { - do { - return try await session.getCustomVars() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - public func getDeviceGPSState() async throws -> DeviceGPSState { - let vars = try await getCustomVars() - return Self.deviceGPSState(from: vars) - } - - /// Set a custom variable on device - public func setCustomVar(key: String, value: String) async throws { - do { - try await session.setCustomVar(key: key, value: value) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - public func setDeviceGPSEnabledVerified(_ enabled: Bool) async throws -> DeviceGPSState { - try await setCustomVar(key: "gps", value: enabled ? "1" : "0") - - let state = try await getDeviceGPSState() - guard state.isSupported else { - throw SettingsServiceError.deviceGPSVerificationFailed( - expectedEnabled: enabled, - actualEnabled: false - ) - } - guard state.isEnabled == enabled else { - throw SettingsServiceError.deviceGPSVerificationFailed( - expectedEnabled: enabled, - actualEnabled: state.isEnabled - ) - } - - try await refreshDeviceInfo() - return state - } - - // MARK: - Private Key Management - - /// Export private key from device - public func exportPrivateKey() async throws -> Data { - do { - return try await session.exportPrivateKey() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Import private key to device - public func importPrivateKey(_ key: Data) async throws { - do { - try await session.importPrivateKey(key) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } + private let session: any ConfigurationSessionOps + let logger = PersistentLogger(subsystem: "com.mc1", category: "SettingsService") + + // Event stream subscriber. The subscription ID lets a replaced subscriber's + // onTermination distinguish itself from the subscriber that replaced it. + var eventContinuation: AsyncStream.Continuation? + private var eventSubscriptionID: UUID? + + public init(session: any ConfigurationSessionOps) { + self.session = session + } + + /// Stream of settings change events. + /// Only one active subscriber is supported. Subsequent calls replace the previous subscriber. + public func events() -> AsyncStream { + // Register synchronously so events yielded right after this call + // returns are not dropped behind a registration Task. + let (stream, continuation) = AsyncStream.makeStream(of: SettingsEvent.self) + let subscriptionID = UUID() + setContinuation(continuation, subscriptionID: subscriptionID) + continuation.onTermination = { @Sendable _ in + Task { await self.clearContinuation(subscriptionID: subscriptionID) } + } + return stream + } + + private func setContinuation( + _ continuation: AsyncStream.Continuation, + subscriptionID: UUID + ) { + if eventContinuation != nil { + logger.warning("Replacing existing SettingsService event stream subscriber") + } + eventContinuation?.finish() + eventContinuation = continuation + eventSubscriptionID = subscriptionID + } + + /// Clears the continuation only while the terminating subscription is still + /// current, so a replaced subscriber cannot disconnect its replacement. + private func clearContinuation(subscriptionID: UUID) { + guard eventSubscriptionID == subscriptionID else { return } + eventContinuation = nil + eventSubscriptionID = nil + } + + // MARK: - Radio Settings + + /// Apply a radio preset to the device + public func applyRadioPreset(_ preset: RadioPreset) async throws { + try await setRadioParams( + frequencyKHz: preset.frequencyKHz, + bandwidthKHz: preset.bandwidthHz, + spreadingFactor: preset.spreadingFactor, + codingRate: preset.codingRate + ) + } + + /// Set radio parameters manually. + /// + /// Both numeric parameters are integer values that get divided by 1000 before being + /// forwarded to `session.setRadio`. Pass values in the same scaled-integer form that + /// `RadioPreset.frequencyKHz` and `RadioPreset.bandwidthHz` use: + /// - `frequencyKHz`: frequency expressed in kHz (e.g. `869618` → 869.618 MHz on the wire) + /// - `bandwidthKHz`: bandwidth expressed in Hz (e.g. `62500` → 62.5 kHz on the wire) + /// + /// The `bandwidthKHz` name is preserved for source compatibility despite the value + /// actually being in Hz; do not pass `Int(62.5)` here. + public func setRadioParams( + frequencyKHz: UInt32, + bandwidthKHz: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8, + clientRepeat: Bool? = nil + ) async throws { + do { + try await session.setRadio( + frequency: Double(frequencyKHz) / 1000.0, + bandwidth: Double(bandwidthKHz) / 1000.0, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + clientRepeat: clientRepeat + ) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Set transmit power + public func setTxPower(_ power: Int8) async throws { + do { + try await session.setTxPower(power) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Node Settings + + /// Set the publicly visible node name + public func setNodeName(_ name: String) async throws { + let truncated = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + do { + try await session.setName(truncated) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Set node location (latitude/longitude in degrees) + public func setLocation(latitude: Double, longitude: Double) async throws { + do { + try await session.setCoordinates(latitude: latitude, longitude: longitude) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Bluetooth Settings + + /// Set BLE PIN (0 = disabled/random, 100000-999999 = fixed PIN) + public func setBlePin(_ pin: UInt32) async throws { + do { + try await session.setDevicePin(pin) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Other Settings + + /// Set other device parameters (contacts, telemetry, location policy) + public func setOtherParams( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + advertLocationPolicy: AdvertLocationPolicy, + multiAcks: UInt8 + ) async throws { + try await setOtherParams( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicyRaw: advertLocationPolicy.rawValue, + multiAcks: multiAcks + ) + } + + /// Set other device parameters, taking the advertisement location policy as a raw byte. + /// + /// Used by config import so a policy value not modeled by ``AdvertLocationPolicy`` (e.g. from + /// newer firmware) is forwarded to the device verbatim instead of being coerced. + public func setOtherParams( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + advertLocationPolicyRaw: UInt8, + multiAcks: UInt8 + ) async throws { + do { + try await session.setOtherParams( + manualAddContacts: !autoAddContacts, + telemetryModeEnvironment: telemetryModes.environment, + telemetryModeLocation: telemetryModes.location, + telemetryModeBase: telemetryModes.base, + advertisementLocationPolicy: advertLocationPolicyRaw, + multiAcks: multiAcks + ) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. + @available(*, deprecated, message: "Use advertLocationPolicy overload instead") + public func setOtherParams( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + shareLocationPublicly: Bool, + multiAcks: UInt8 + ) async throws { + try await setOtherParams( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: shareLocationPublicly ? .prefs : .none, + multiAcks: multiAcks + ) + } + + // MARK: - Factory Reset + + /// Perform factory reset on device + public func factoryReset() async throws { + do { + try await session.factoryReset() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Reboot the device + public func reboot() async throws { + do { + try await session.reboot() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Device Info + + /// Fetch battery and storage information from device + /// - Returns: BatteryInfo with current values + /// - Throws: SettingsServiceError if not connected or communication fails + public func getBattery() async throws -> BatteryInfo { + do { + return try await session.getBattery() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Query device capabilities + public func queryDevice() async throws -> DeviceCapabilities { + do { + return try await session.queryDevice() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Get self info by sending appStart + public func getSelfInfo() async throws -> MeshCore.SelfInfo { + do { + return try await session.sendAppStart() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Auto-Add Config + + /// Get auto-add configuration from device + public func getAutoAddConfig() async throws -> MeshCore.AutoAddConfig { + do { + return try await session.getAutoAddConfig() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Refresh auto-add config from device (for initial load) + /// Fetches current value and triggers callback to update connected device + public func refreshAutoAddConfig() async throws { + let config = try await getAutoAddConfig() + eventContinuation?.yield(.autoAddConfigUpdated(config)) + } + + // MARK: - Repeat Frequency Ranges + + /// Get allowed repeat frequency ranges from device + private func getRepeatFreq() async throws -> [MeshCore.FrequencyRange] { + do { + return try await session.getRepeatFreq() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Refresh repeat frequency ranges from device and notify observers + public func refreshRepeatFreqRanges() async throws { + let ranges = try await getRepeatFreq() + eventContinuation?.yield(.allowedRepeatFreqUpdated(ranges)) + } + + /// Refresh device info from the device and notify observers. + /// Use this instead of `setLocationVerified` when the device already has correct coordinates (e.g. from its own GPS). + public func refreshDeviceInfo() async throws { + let selfInfo = try await getSelfInfo() + eventContinuation?.yield(.deviceUpdated(selfInfo)) + } + + /// Set auto-add configuration on device + public func setAutoAddConfig(_ config: MeshCore.AutoAddConfig) async throws { + do { + try await session.setAutoAddConfig(config) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Set auto-add configuration with verification + public func setAutoAddConfigVerified(_ config: MeshCore.AutoAddConfig) async throws -> MeshCore.AutoAddConfig { + try await setAutoAddConfig(config) + + let actualConfig = try await getAutoAddConfig() + + guard actualConfig == config else { + throw SettingsServiceError.verificationFailed( + expected: "bitmask=\(config.bitmask), maxHops=\(config.maxHops)", + actual: "bitmask=\(actualConfig.bitmask), maxHops=\(actualConfig.maxHops)" + ) + } + + eventContinuation?.yield(.autoAddConfigUpdated(actualConfig)) + return actualConfig + } + + // MARK: - Path Hash Mode + + /// Sets the path hash mode on the device. + /// + /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). + public func setPathHashMode(_ mode: UInt8) async throws { + do { + try await session.setPathHashMode(mode) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Sets the path hash mode with verification via queryDevice. + /// + /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). + /// - Returns: The verified mode value from the device. + public func setPathHashModeVerified(_ mode: UInt8) async throws -> UInt8 { + try await setPathHashMode(mode) + + let capabilities = try await queryDevice() + guard capabilities.pathHashMode == mode else { + throw SettingsServiceError.verificationFailed( + expected: "pathHashMode=\(mode)", + actual: "pathHashMode=\(capabilities.pathHashMode)" + ) + } + + eventContinuation?.yield(.pathHashModeUpdated(mode)) + return mode + } + + // MARK: - Default Flood Scope + + /// Fetches the device's persisted default flood scope. + /// + /// Requires firmware v11+; older firmware rejects the opcode and surfaces + /// ``SettingsServiceError/sessionError(_:)`` with `MeshCoreError.deviceError`. + /// + /// - Returns: The persisted scope name, or `nil` when none is configured. + public func getDefaultFloodScope() async throws -> String? { + do { + let scope = try await session.getDefaultFloodScope() + let name = scope?.name + eventContinuation?.yield(.defaultFloodScopeUpdated(name)) + return name + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Persists the device's default flood scope and verifies via a follow-up read. + /// + /// Passing `nil` for `name` clears the persisted scope. Non-nil names are sent as + /// ``MeshCore/FloodScope/region(_:)`` — firmware derives the key and stores both. + /// Names are truncated to ``ProtocolLimits/maxDefaultFloodScopeNameBytes`` UTF-8 bytes + /// before both key derivation and send, so the stored display and derived scope key + /// agree on the same byte sequence. + /// + /// - Parameter name: Region name to persist, or `nil` to clear. + /// - Returns: The verified name read back from the device. + public func setDefaultFloodScopeVerified(name: String?) async throws -> String? { + let expected: String? = (name?.isEmpty == false) + ? name?.utf8Prefix(maxBytes: ProtocolLimits.maxDefaultFloodScopeNameBytes) + : nil + do { + if let expected { + try await session.setDefaultFloodScope(name: expected, scope: .region(expected)) + } else { + try await session.setDefaultFloodScope(name: "", scope: .disabled) + } + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + + let actual = try await getDefaultFloodScope() + guard actual == expected else { + throw SettingsServiceError.verificationFailed( + expected: expected ?? "(cleared)", + actual: actual ?? "(cleared)" + ) + } + return actual + } + + // MARK: - Stats + + /// Get core statistics + public func getStatsCore() async throws -> CoreStats { + do { + return try await session.getStatsCore() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Get radio statistics + public func getStatsRadio() async throws -> RadioStats { + do { + return try await session.getStatsRadio() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Get packet statistics + public func getStatsPackets() async throws -> PacketStats { + do { + return try await session.getStatsPackets() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Custom Variables + + /// Get custom variables from device + public func getCustomVars() async throws -> [String: String] { + do { + return try await session.getCustomVars() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + public func getDeviceGPSState() async throws -> DeviceGPSState { + let vars = try await getCustomVars() + return Self.deviceGPSState(from: vars) + } + + /// Set a custom variable on device + public func setCustomVar(key: String, value: String) async throws { + do { + try await session.setCustomVar(key: key, value: value) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + public func setDeviceGPSEnabledVerified(_ enabled: Bool) async throws -> DeviceGPSState { + try await setCustomVar(key: "gps", value: enabled ? "1" : "0") + + let state = try await getDeviceGPSState() + guard state.isSupported else { + throw SettingsServiceError.deviceGPSVerificationFailed( + expectedEnabled: enabled, + actualEnabled: false + ) + } + guard state.isEnabled == enabled else { + throw SettingsServiceError.deviceGPSVerificationFailed( + expectedEnabled: enabled, + actualEnabled: state.isEnabled + ) + } + + try await refreshDeviceInfo() + return state + } + + // MARK: - Private Key Management + + /// Export private key from device + public func exportPrivateKey() async throws -> Data { + do { + return try await session.exportPrivateKey() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Import private key to device + public func importPrivateKey(_ key: Data) async throws { + do { + try await session.importPrivateKey(key) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } - // MARK: - Signing + // MARK: - Signing - /// Sign data using device's private key - public func sign(_ data: Data) async throws -> Data { - do { - return try await session.sign(data) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } + /// Sign data using device's private key + public func sign(_ data: Data) async throws -> Data { + do { + return try await session.sign(data) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } - private static func deviceGPSState(from vars: [String: String]) -> DeviceGPSState { - guard let value = vars["gps"] else { - return DeviceGPSState(isSupported: false, isEnabled: false) - } - return DeviceGPSState(isSupported: true, isEnabled: value == "1") + private static func deviceGPSState(from vars: [String: String]) -> DeviceGPSState { + guard let value = vars["gps"] else { + return DeviceGPSState(isSupported: false, isEnabled: false) } + return DeviceGPSState(isSupported: true, isEnabled: value == "1") + } } diff --git a/MC1Services/Sources/MC1Services/Services/StoreCatalog.swift b/MC1Services/Sources/MC1Services/Services/StoreCatalog.swift index c5b9694c..db08a506 100644 --- a/MC1Services/Sources/MC1Services/Services/StoreCatalog.swift +++ b/MC1Services/Sources/MC1Services/Services/StoreCatalog.swift @@ -3,39 +3,39 @@ import Foundation /// Single source of truth for every in-app-purchase product identifier. /// Adding a new theme, bundle, or tip means adding a constant here. public enum StoreCatalog { - public enum Theme { - public static let ember = "io.pocketmesh.app.theme.ember" - public static let fern = "io.pocketmesh.app.theme.fern" - public static let marine = "io.pocketmesh.app.theme.marine" - public static let olive = "io.pocketmesh.app.theme.olive" - public static let lavender = "io.pocketmesh.app.theme.lavender" - public static let sakura = "io.pocketmesh.app.theme.sakura" - public static let solarized = "io.pocketmesh.app.theme.solarized" - public static let nord = "io.pocketmesh.app.theme.nord" - public static let catppuccin = "io.pocketmesh.app.theme.catppuccin" - public static let bundleAll = "io.pocketmesh.app.theme.bundle.all" + public enum Theme { + public static let ember = "io.pocketmesh.app.theme.ember" + public static let fern = "io.pocketmesh.app.theme.fern" + public static let marine = "io.pocketmesh.app.theme.marine" + public static let olive = "io.pocketmesh.app.theme.olive" + public static let lavender = "io.pocketmesh.app.theme.lavender" + public static let sakura = "io.pocketmesh.app.theme.sakura" + public static let solarized = "io.pocketmesh.app.theme.solarized" + public static let nord = "io.pocketmesh.app.theme.nord" + public static let catppuccin = "io.pocketmesh.app.theme.catppuccin" + public static let bundleAll = "io.pocketmesh.app.theme.bundle.all" - /// Every theme the `bundleAll` purchase unlocks. Themes are not sold individually — the - /// bundle is the only theme purchase — so this set is purely the bundle's entitlement - /// expansion and the per-theme ownership keys used to render locked/owned state. - public static let bundledThemeIDs: Set = - [ember, fern, marine, olive, lavender, sakura, solarized, nord, catppuccin] - } + /// Every theme the `bundleAll` purchase unlocks. Themes are not sold individually — the + /// bundle is the only theme purchase — so this set is purely the bundle's entitlement + /// expansion and the per-theme ownership keys used to render locked/owned state. + public static let bundledThemeIDs: Set = + [ember, fern, marine, olive, lavender, sakura, solarized, nord, catppuccin] + } - public enum Tip { - public static let coffee = "io.pocketmesh.app.tips.coffee" - public static let lunch = "io.pocketmesh.app.tips.lunch" - public static let dinner = "io.pocketmesh.app.tips.dinner" - public static let generous = "io.pocketmesh.app.tips.generous" - public static let massive = "io.pocketmesh.app.tips.massive" - public static let epic = "io.pocketmesh.app.tips.epic" + public enum Tip { + public static let coffee = "io.pocketmesh.app.tips.coffee" + public static let lunch = "io.pocketmesh.app.tips.lunch" + public static let dinner = "io.pocketmesh.app.tips.dinner" + public static let generous = "io.pocketmesh.app.tips.generous" + public static let massive = "io.pocketmesh.app.tips.massive" + public static let epic = "io.pocketmesh.app.tips.epic" - public static let all: Set = [coffee, lunch, dinner, generous, massive, epic] - } + public static let all: Set = [coffee, lunch, dinner, generous, massive, epic] + } - /// The product IDs the app fetches from the App Store and sells: the All Themes bundle and the - /// tips. Individual themes are not sold, so they are never requested from StoreKit — the bundle - /// purchase confers them as entitlements via `Theme.bundledThemeIDs`. - public static let sellableProductIDs: Set = - Tip.all.union([Theme.bundleAll]) + /// The product IDs the app fetches from the App Store and sells: the All Themes bundle and the + /// tips. Individual themes are not sold, so they are never requested from StoreKit — the bundle + /// purchase confers them as entitlements via `Theme.bundledThemeIDs`. + public static let sellableProductIDs: Set = + Tip.all.union([Theme.bundleAll]) } diff --git a/MC1Services/Sources/MC1Services/Services/StoreLoadState.swift b/MC1Services/Sources/MC1Services/Services/StoreLoadState.swift index e6a59b8e..c362bd0f 100644 --- a/MC1Services/Sources/MC1Services/Services/StoreLoadState.swift +++ b/MC1Services/Sources/MC1Services/Services/StoreLoadState.swift @@ -2,8 +2,8 @@ import Foundation /// Lifecycle of the product catalog load. public enum StoreLoadState: Sendable, Equatable { - case idle - case loading - case loaded - case failed + case idle + case loading + case loaded + case failed } diff --git a/MC1Services/Sources/MC1Services/Services/StorePurchaseOutcome.swift b/MC1Services/Sources/MC1Services/Services/StorePurchaseOutcome.swift index bd34b2a3..e658bf20 100644 --- a/MC1Services/Sources/MC1Services/Services/StorePurchaseOutcome.swift +++ b/MC1Services/Sources/MC1Services/Services/StorePurchaseOutcome.swift @@ -2,8 +2,8 @@ import Foundation /// Non-error result of a purchase attempt. User cancellation is an outcome, not an error. public enum StorePurchaseOutcome: Sendable, Equatable { - case purchased - case userCancelled - /// Ask-to-Buy or Strong Customer Authentication: resolution arrives later via `Transaction.updates`. - case pending + case purchased + case userCancelled + /// Ask-to-Buy or Strong Customer Authentication: resolution arrives later via `Transaction.updates`. + case pending } diff --git a/MC1Services/Sources/MC1Services/Services/StoreService.swift b/MC1Services/Sources/MC1Services/Services/StoreService.swift index e550d24a..1b841898 100644 --- a/MC1Services/Sources/MC1Services/Services/StoreService.swift +++ b/MC1Services/Sources/MC1Services/Services/StoreService.swift @@ -6,263 +6,263 @@ import StoreKit @Observable @MainActor public final class StoreService { - public private(set) var products: [Product] = [] - public private(set) var ownedThemeIDs: Set = [] - public private(set) var loadState: StoreLoadState = .idle + public private(set) var products: [Product] = [] + public private(set) var ownedThemeIDs: Set = [] + public private(set) var loadState: StoreLoadState = .idle - /// Invoked on `@MainActor` after each entitlement walk (load, restore, transaction update). - /// A later plan registers `ThemeService` here to drive theme-revert reactivity. - public var onEntitlementsChanged: (@MainActor () -> Void)? + /// Invoked on `@MainActor` after each entitlement walk (load, restore, transaction update). + /// A later plan registers `ThemeService` here to drive theme-revert reactivity. + public var onEntitlementsChanged: (@MainActor () -> Void)? - /// Invoked on `@MainActor` after a consumable transaction (tip) is finished via - /// `applyTransactionUpdate`. Consumables never appear in `Transaction.currentEntitlements`, - /// so they cannot be observed via `ownedThemeIDs` — `StoreState` uses this hook to clear - /// `pendingPurchase` when an Ask-to-Buy tip is approved out-of-band. - public var onConsumablePurchased: (@MainActor (String) -> Void)? + /// Invoked on `@MainActor` after a consumable transaction (tip) is finished via + /// `applyTransactionUpdate`. Consumables never appear in `Transaction.currentEntitlements`, + /// so they cannot be observed via `ownedThemeIDs` — `StoreState` uses this hook to clear + /// `pendingPurchase` when an Ask-to-Buy tip is approved out-of-band. + public var onConsumablePurchased: (@MainActor (String) -> Void)? - /// Invoked on `@MainActor` whenever `load()` transitions to `.failed` (product fetch - /// threw or returned empty). `StoreState` uses this to surface an actionable error message - /// so the initial load failure isn't silent — the user would otherwise see disabled '—' - /// price buttons with no explanation, and the existing `.errorAlert` retry would never fire. - public var onLoadStateFailed: (@MainActor () -> Void)? + /// Invoked on `@MainActor` whenever `load()` transitions to `.failed` (product fetch + /// threw or returned empty). `StoreState` uses this to surface an actionable error message + /// so the initial load failure isn't silent — the user would otherwise see disabled '—' + /// price buttons with no explanation, and the existing `.errorAlert` retry would never fire. + public var onLoadStateFailed: (@MainActor () -> Void)? - /// Internal (not private) so `@testable` tests can assert listener teardown. - private(set) var transactionListenerTask: Task? - private var loggedUnverifiedIDs: Set = [] - /// Caps the unverified-log dedup set on an app-lifetime service. Distinct unverified - /// transaction IDs are tiny in practice; this is a backstop against unbounded growth. - private static let maxLoggedUnverifiedIDs = 256 - /// Persisted so in-app-purchase diagnostics survive into the Settings "Export Debug Logs" - /// output, for triaging purchase failures reported from TestFlight. `[IAP]`-prefixed lines - /// trace the purchase and entitlement flow. - private let logger = PersistentLogger(subsystem: "com.mc1", category: "Store") + /// Internal (not private) so `@testable` tests can assert listener teardown. + private(set) var transactionListenerTask: Task? + private var loggedUnverifiedIDs: Set = [] + /// Caps the unverified-log dedup set on an app-lifetime service. Distinct unverified + /// transaction IDs are tiny in practice; this is a backstop against unbounded growth. + private static let maxLoggedUnverifiedIDs = 256 + /// Persisted so in-app-purchase diagnostics survive into the Settings "Export Debug Logs" + /// output, for triaging purchase failures reported from TestFlight. `[IAP]`-prefixed lines + /// trace the purchase and entitlement flow. + private let logger = PersistentLogger(subsystem: "com.mc1", category: "Store") - /// Test seam: when non-nil, `restorePurchases()` invokes this closure instead of - /// `AppStore.sync()`. Exists because SKTestSession exposes no way to drive - /// `AppStore.sync()` into either of `restorePurchases()`'s two cancel paths - /// (`CancellationError` or `StoreKitError.userCancelled`), and without this the - /// `.cancelled` arm of `RestoreOutcome` is unreachable from tests. Nil in production. - internal var appStoreSyncForTesting: (@Sendable () async throws -> Void)? + /// Test seam: when non-nil, `restorePurchases()` invokes this closure instead of + /// `AppStore.sync()`. Exists because SKTestSession exposes no way to drive + /// `AppStore.sync()` into either of `restorePurchases()`'s two cancel paths + /// (`CancellationError` or `StoreKitError.userCancelled`), and without this the + /// `.cancelled` arm of `RestoreOutcome` is unreachable from tests. Nil in production. + var appStoreSyncForTesting: (@Sendable () async throws -> Void)? - public init() { - transactionListenerTask = Task { [weak self] in - await self?.observeTransactionUpdates() - } + public init() { + transactionListenerTask = Task { [weak self] in + await self?.observeTransactionUpdates() } + } - /// Cancels the listener task. There is no reliable SwiftUI app-teardown hook to call this - /// from, so in production the listener dies with the process; this exists for deterministic - /// test teardown. `deinit` is avoided because a non-isolated `deinit` cannot touch - /// `@MainActor` state under strict concurrency, and `isolated deinit` requires iOS 18.4+ - /// (the project floor is 18.0). - public func shutdown() { - transactionListenerTask?.cancel() - transactionListenerTask = nil - } + /// Cancels the listener task. There is no reliable SwiftUI app-teardown hook to call this + /// from, so in production the listener dies with the process; this exists for deterministic + /// test teardown. `deinit` is avoided because a non-isolated `deinit` cannot touch + /// `@MainActor` state under strict concurrency, and `isolated deinit` requires iOS 18.4+ + /// (the project floor is 18.0). + public func shutdown() { + transactionListenerTask?.cancel() + transactionListenerTask = nil + } - /// Loads the catalog and walks current entitlements. Non-throwing: surfaces failure via - /// `loadState`. `productIDs` defaults to the full catalog; tests override it to exercise - /// the empty-result path. - public func load(productIDs: Set = StoreCatalog.sellableProductIDs) async { - loadState = .loading - await processUnfinishedTransactions() - do { - let loaded = try await Product.products(for: productIDs) - guard !loaded.isEmpty else { - logger.error("[IAP] load failed: 0 products returned for \(productIDs.count) IDs") - products = [] - loadState = .failed - onLoadStateFailed?() - return - } - products = loaded - } catch { - logger.error("[IAP] load failed: \(String(describing: error))") - products = [] - loadState = .failed - onLoadStateFailed?() - return - } - await walkCurrentEntitlements() - loadState = .loaded - logger.notice("[IAP] load complete: \(products.count) products, owned=\(ownedThemeIDs.count)") + /// Loads the catalog and walks current entitlements. Non-throwing: surfaces failure via + /// `loadState`. `productIDs` defaults to the full catalog; tests override it to exercise + /// the empty-result path. + public func load(productIDs: Set = StoreCatalog.sellableProductIDs) async { + loadState = .loading + await processUnfinishedTransactions() + do { + let loaded = try await Product.products(for: productIDs) + guard !loaded.isEmpty else { + logger.error("[IAP] load failed: 0 products returned for \(productIDs.count) IDs") + products = [] + loadState = .failed + onLoadStateFailed?() + return + } + products = loaded + } catch { + logger.error("[IAP] load failed: \(String(describing: error))") + products = [] + loadState = .failed + onLoadStateFailed?() + return } + await walkCurrentEntitlements() + loadState = .loaded + logger.notice("[IAP] load complete: \(products.count) products, owned=\(ownedThemeIDs.count)") + } - public func product(for productID: String) -> Product? { - products.first { $0.id == productID } - } + public func product(for productID: String) -> Product? { + products.first { $0.id == productID } + } - /// Initiates a purchase via `Product.purchase()` and processes the result. Retained for the - /// `SKTestSession` suite, which cannot drive SwiftUI's `@Environment(\.purchase)`. The - /// production view path initiates through that environment action instead (see - /// `completePurchase(_:)`), so StoreKit resolves the confirmation scene from the view - /// environment rather than a bare `UIWindowScene` lookup. - public func purchase(_ product: Product) async throws -> StorePurchaseOutcome { - let result: Product.PurchaseResult - do { - result = try await product.purchase() - } catch let error as StoreKitError { - if let mapped = StoreServiceError.from(error) { throw mapped } - return .userCancelled - } catch { - throw StoreServiceError.purchaseFailed(reason: String(describing: error)) - } - return try await completePurchase(result) + /// Initiates a purchase via `Product.purchase()` and processes the result. Retained for the + /// `SKTestSession` suite, which cannot drive SwiftUI's `@Environment(\.purchase)`. The + /// production view path initiates through that environment action instead (see + /// `completePurchase(_:)`), so StoreKit resolves the confirmation scene from the view + /// environment rather than a bare `UIWindowScene` lookup. + public func purchase(_ product: Product) async throws -> StorePurchaseOutcome { + let result: Product.PurchaseResult + do { + result = try await product.purchase() + } catch let error as StoreKitError { + if let mapped = StoreServiceError.from(error) { throw mapped } + return .userCancelled + } catch { + throw StoreServiceError.purchaseFailed(reason: String(describing: error)) } + return try await completePurchase(result) + } - /// Processes a `Product.PurchaseResult` the caller already obtained: grants the entitlement - /// from the verified transaction (rather than re-reading `currentEntitlements`, which can lag), - /// finishes it, and maps to an outcome. The production view path obtains the result through - /// SwiftUI's `@Environment(\.purchase)` action so the confirmation scene is resolved from the - /// view environment; a bare `product.purchase()` relies on implicit foreground-scene lookup, - /// which returns a spurious `.userCancelled` when the active scene churns (e.g. lifecycle - /// reconciliation firing while the purchase sheet is presented). - public func completePurchase(_ result: Product.PurchaseResult) async throws -> StorePurchaseOutcome { - switch result { - case .success(let verification): - switch verification { - case .verified(let transaction): - logger.notice("[IAP] purchase verified product=\(transaction.productID)") - apply(transaction) - await transaction.finish() - return .purchased - case .unverified(let transaction, let error): - logger.error("[IAP] purchase unverified product=\(transaction.productID): \(String(describing: error))") - throw StoreServiceError.verificationFailed - } - case .userCancelled: - logger.notice("[IAP] purchase userCancelled") - return .userCancelled - case .pending: - logger.notice("[IAP] purchase pending (ask-to-buy)") - return .pending - @unknown default: - throw StoreServiceError.purchaseFailed(reason: "Unhandled purchase result") - } + /// Processes a `Product.PurchaseResult` the caller already obtained: grants the entitlement + /// from the verified transaction (rather than re-reading `currentEntitlements`, which can lag), + /// finishes it, and maps to an outcome. The production view path obtains the result through + /// SwiftUI's `@Environment(\.purchase)` action so the confirmation scene is resolved from the + /// view environment; a bare `product.purchase()` relies on implicit foreground-scene lookup, + /// which returns a spurious `.userCancelled` when the active scene churns (e.g. lifecycle + /// reconciliation firing while the purchase sheet is presented). + public func completePurchase(_ result: Product.PurchaseResult) async throws -> StorePurchaseOutcome { + switch result { + case let .success(verification): + switch verification { + case let .verified(transaction): + logger.notice("[IAP] purchase verified product=\(transaction.productID)") + apply(transaction) + await transaction.finish() + return .purchased + case let .unverified(transaction, error): + logger.error("[IAP] purchase unverified product=\(transaction.productID): \(String(describing: error))") + throw StoreServiceError.verificationFailed + } + case .userCancelled: + logger.notice("[IAP] purchase userCancelled") + return .userCancelled + case .pending: + logger.notice("[IAP] purchase pending (ask-to-buy)") + return .pending + @unknown default: + throw StoreServiceError.purchaseFailed(reason: "Unhandled purchase result") } + } - @discardableResult - public func restorePurchases() async throws -> RestoreOutcome { - do { - if let appStoreSyncForTesting { - try await appStoreSyncForTesting() - } else { - try await AppStore.sync() - } - } catch is CancellationError { - return .cancelled // view dismissal mid-sync is not an error; surface as cancel. - } catch let error as StoreKitError { - if let mapped = StoreServiceError.from(error) { - logger.error("[IAP] restore failed: \(String(describing: error))") - throw mapped - } - // .userCancelled (mapped to nil by StoreServiceError.from) lands here — it is a - // user choice, not a successful restore. Surface as cancel so the caller does not - // fire a success haptic for a dismissed iCloud password sheet. - return .cancelled - } catch { - logger.error("[IAP] restore failed: \(String(describing: error))") - throw StoreServiceError.purchaseFailed(reason: String(describing: error)) - } - await walkCurrentEntitlements() - logger.notice("[IAP] restore complete, owned=\(ownedThemeIDs.count)") - return .completed + @discardableResult + public func restorePurchases() async throws -> RestoreOutcome { + do { + if let appStoreSyncForTesting { + try await appStoreSyncForTesting() + } else { + try await AppStore.sync() + } + } catch is CancellationError { + return .cancelled // view dismissal mid-sync is not an error; surface as cancel. + } catch let error as StoreKitError { + if let mapped = StoreServiceError.from(error) { + logger.error("[IAP] restore failed: \(String(describing: error))") + throw mapped + } + // .userCancelled (mapped to nil by StoreServiceError.from) lands here — it is a + // user choice, not a successful restore. Surface as cancel so the caller does not + // fire a success haptic for a dismissed iCloud password sheet. + return .cancelled + } catch { + logger.error("[IAP] restore failed: \(String(describing: error))") + throw StoreServiceError.purchaseFailed(reason: String(describing: error)) } + await walkCurrentEntitlements() + logger.notice("[IAP] restore complete, owned=\(ownedThemeIDs.count)") + return .completed + } - nonisolated private func observeTransactionUpdates() async { - for await update in Transaction.updates { - await applyTransactionUpdate(update) - } + private nonisolated func observeTransactionUpdates() async { + for await update in Transaction.updates { + await applyTransactionUpdate(update) } + } - private func applyTransactionUpdate(_ result: VerificationResult) async { - guard case .verified(let transaction) = result else { - noteUnverified(result) - return - } - if transaction.revocationDate != nil { - // A refund or revocation is rare and the authoritative remaining set is whatever - // currentEntitlements reports, so rebuild from it rather than fold the change in. - await walkCurrentEntitlements() - } else { - apply(transaction) - } - await transaction.finish() - if transaction.productType == .consumable { - onConsumablePurchased?(transaction.productID) - } + private func applyTransactionUpdate(_ result: VerificationResult) async { + guard case let .verified(transaction) = result else { + noteUnverified(result) + return } - - /// Finishes any transactions left unfinished while the app was closed (renewals, - /// Ask-to-Buy approvals, refunds, cross-device purchases). Their entitlement state is - /// applied by the subsequent `walkCurrentEntitlements()` call in `load()`. - private func processUnfinishedTransactions() async { - for await result in Transaction.unfinished { - guard case .verified(let transaction) = result else { - noteUnverified(result) - continue - } - await transaction.finish() - } + if transaction.revocationDate != nil { + // A refund or revocation is rare and the authoritative remaining set is whatever + // currentEntitlements reports, so rebuild from it rather than fold the change in. + await walkCurrentEntitlements() + } else { + apply(transaction) } - - /// Rebuilds `ownedThemeIDs` from scratch each call (idempotent — double application is a - /// no-op). Bundle ownership expands to every purchasable theme ID. - private func walkCurrentEntitlements() async { - var owned: Set = [] - for await result in Transaction.currentEntitlements { - guard case .verified(let transaction) = result else { - noteUnverified(result) - continue - } - owned.formUnion(affectedThemeIDs(forProductID: transaction.productID)) - // Tip transactions are consumable and never appear in currentEntitlements. - } - ownedThemeIDs = owned - logger.notice("[IAP] ownedThemeIDs count=\(ownedThemeIDs.count)") - onEntitlementsChanged?() + await transaction.finish() + if transaction.productType == .consumable { + onConsumablePurchased?(transaction.productID) } + } - /// The theme product IDs an entitlement for `productID` confers: the bundle unlocks every - /// bundled theme, and anything else (tips) unlocks none. Themes are sold only as the bundle, - /// so no standalone theme entitlement is ever granted. - private func affectedThemeIDs(forProductID productID: String) -> Set { - productID == StoreCatalog.Theme.bundleAll ? StoreCatalog.Theme.bundledThemeIDs : [] + /// Finishes any transactions left unfinished while the app was closed (renewals, + /// Ask-to-Buy approvals, refunds, cross-device purchases). Their entitlement state is + /// applied by the subsequent `walkCurrentEntitlements()` call in `load()`. + private func processUnfinishedTransactions() async { + for await result in Transaction.unfinished { + guard case let .verified(transaction) = result else { + noteUnverified(result) + continue + } + await transaction.finish() } + } - /// Folds a verified transaction's entitlement into `ownedThemeIDs` directly, without re-reading - /// `Transaction.currentEntitlements` — which can lag or return empty immediately after a - /// purchase, leaving the just-bought theme locked. Grant paths (`purchase`, Ask-to-Buy - /// approval) use this; revocation does not, since a refund rebuilds from currentEntitlements. - private func apply(_ transaction: Transaction) { - applyEntitlement(productID: transaction.productID, isRevoked: transaction.revocationDate != nil) + /// Rebuilds `ownedThemeIDs` from scratch each call (idempotent — double application is a + /// no-op). Bundle ownership expands to every purchasable theme ID. + private func walkCurrentEntitlements() async { + var owned: Set = [] + for await result in Transaction.currentEntitlements { + guard case let .verified(transaction) = result else { + noteUnverified(result) + continue + } + owned.formUnion(affectedThemeIDs(forProductID: transaction.productID)) + // Tip transactions are consumable and never appear in currentEntitlements. } + ownedThemeIDs = owned + logger.notice("[IAP] ownedThemeIDs count=\(ownedThemeIDs.count)") + onEntitlementsChanged?() + } + + /// The theme product IDs an entitlement for `productID` confers: the bundle unlocks every + /// bundled theme, and anything else (tips) unlocks none. Themes are sold only as the bundle, + /// so no standalone theme entitlement is ever granted. + private func affectedThemeIDs(forProductID productID: String) -> Set { + productID == StoreCatalog.Theme.bundleAll ? StoreCatalog.Theme.bundledThemeIDs : [] + } - /// Additively grants or revokes the theme entitlements a product confers. Internal so the fold - /// logic is unit-testable without StoreKit. Fires `onEntitlementsChanged` only on a real change. - func applyEntitlement(productID: String, isRevoked: Bool) { - let affected = affectedThemeIDs(forProductID: productID) - logger.notice("[IAP] entitlement \(isRevoked ? "revoked" : "granted") product=\(productID) affected=\(affected.count)") - guard !affected.isEmpty else { return } - var owned = ownedThemeIDs - if isRevoked { - owned.subtract(affected) - } else { - owned.formUnion(affected) - } - guard owned != ownedThemeIDs else { return } - ownedThemeIDs = owned - onEntitlementsChanged?() + /// Folds a verified transaction's entitlement into `ownedThemeIDs` directly, without re-reading + /// `Transaction.currentEntitlements` — which can lag or return empty immediately after a + /// purchase, leaving the just-bought theme locked. Grant paths (`purchase`, Ask-to-Buy + /// approval) use this; revocation does not, since a refund rebuilds from currentEntitlements. + private func apply(_ transaction: Transaction) { + applyEntitlement(productID: transaction.productID, isRevoked: transaction.revocationDate != nil) + } + + /// Additively grants or revokes the theme entitlements a product confers. Internal so the fold + /// logic is unit-testable without StoreKit. Fires `onEntitlementsChanged` only on a real change. + func applyEntitlement(productID: String, isRevoked: Bool) { + let affected = affectedThemeIDs(forProductID: productID) + logger.notice("[IAP] entitlement \(isRevoked ? "revoked" : "granted") product=\(productID) affected=\(affected.count)") + guard !affected.isEmpty else { return } + var owned = ownedThemeIDs + if isRevoked { + owned.subtract(affected) + } else { + owned.formUnion(affected) } + guard owned != ownedThemeIDs else { return } + ownedThemeIDs = owned + onEntitlementsChanged?() + } - /// Logs an unverified transaction once per transaction ID, then drops it (Apple guidance). - private func noteUnverified(_ result: VerificationResult) { - guard case .unverified(let transaction, let error) = result else { return } - if loggedUnverifiedIDs.count >= Self.maxLoggedUnverifiedIDs { - loggedUnverifiedIDs.removeAll(keepingCapacity: true) - } - if loggedUnverifiedIDs.insert(transaction.id).inserted { - logger.warning("Dropping unverified transaction \(transaction.id): \(String(describing: error))") - } + /// Logs an unverified transaction once per transaction ID, then drops it (Apple guidance). + private func noteUnverified(_ result: VerificationResult) { + guard case let .unverified(transaction, error) = result else { return } + if loggedUnverifiedIDs.count >= Self.maxLoggedUnverifiedIDs { + loggedUnverifiedIDs.removeAll(keepingCapacity: true) + } + if loggedUnverifiedIDs.insert(transaction.id).inserted { + logger.warning("Dropping unverified transaction \(transaction.id): \(String(describing: error))") } + } } diff --git a/MC1Services/Sources/MC1Services/Services/TelemetryModes.swift b/MC1Services/Sources/MC1Services/Services/TelemetryModes.swift index 7f39949d..d8dff9f0 100644 --- a/MC1Services/Sources/MC1Services/Services/TelemetryModes.swift +++ b/MC1Services/Sources/MC1Services/Services/TelemetryModes.swift @@ -2,24 +2,24 @@ import Foundation /// Packed telemetry mode configuration public struct TelemetryModes: Sendable, Equatable { - public var base: UInt8 - public var location: UInt8 - public var environment: UInt8 + public var base: UInt8 + public var location: UInt8 + public var environment: UInt8 - public init(base: UInt8 = 0, location: UInt8 = 0, environment: UInt8 = 0) { - self.base = base & 0b11 - self.location = location & 0b11 - self.environment = environment & 0b11 - } + public init(base: UInt8 = 0, location: UInt8 = 0, environment: UInt8 = 0) { + self.base = base & 0b11 + self.location = location & 0b11 + self.environment = environment & 0b11 + } - /// Packed value for protocol encoding - public var packed: UInt8 { - (environment << 4) | (location << 2) | base - } + /// Packed value for protocol encoding + public var packed: UInt8 { + (environment << 4) | (location << 2) | base + } - public init(packed: UInt8) { - self.base = packed & 0b11 - self.location = (packed >> 2) & 0b11 - self.environment = (packed >> 4) & 0b11 - } + public init(packed: UInt8) { + base = packed & 0b11 + location = (packed >> 2) & 0b11 + environment = (packed >> 4) & 0b11 + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift index 41cedbbd..ba58c229 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift @@ -1,136 +1,135 @@ import Foundation extension MockDataProvider { - - /// Generate mock channel messages for a channel slot index. Channel messages - /// carry `channelIndex` (not `contactID`); incoming rows populate `senderNodeName` - /// and `senderKeyPrefix` for sender resolution. - public static func channelMessages(for index: UInt8) -> [MessageDTO] { - let now = Date() - switch index { - case publicChannelIndex: return publicChannelMessages(now: now) - case bayAreaChannelIndex: return bayAreaChannelMessages(now: now) - case trailCrewChannelIndex: return trailCrewChannelMessages(now: now) - default: return [] - } + /// Generate mock channel messages for a channel slot index. Channel messages + /// carry `channelIndex` (not `contactID`); incoming rows populate `senderNodeName` + /// and `senderKeyPrefix` for sender resolution. + public static func channelMessages(for index: UInt8) -> [MessageDTO] { + let now = Date() + switch index { + case publicChannelIndex: return publicChannelMessages(now: now) + case bayAreaChannelIndex: return bayAreaChannelMessages(now: now) + case trailCrewChannelIndex: return trailCrewChannelMessages(now: now) + default: return [] } + } - /// Public: multi-sender chatter with a same-sender cluster, plus our own reply. - private static func publicChannelMessages(now: Date) -> [MessageDTO] { - let path = encodePathLen(hashSize: 1, hopCount: 2) - return [ - channelIncoming( - "C0000000-0000-0000-0000-000000000001", - now.addingTimeInterval(-9_000), - "Anyone monitoring the north repeater today?", - sender: "Alice Chen", keySeed: 10, snr: 7.4, path: path - ), - // Same-sender cluster (consecutive messages from Alice). - channelIncoming( - "C0000000-0000-0000-0000-000000000002", - now.addingTimeInterval(-8_940), - "Signal's been solid on my end all morning.", - sender: "Alice Chen", keySeed: 10, snr: 7.6, path: path - ), - channelIncoming( - "C0000000-0000-0000-0000-000000000003", - now.addingTimeInterval(-6_000), - "Same here, clear copy from the south ridge.", - sender: "Bob Martinez", keySeed: 20, snr: 6.9, path: path - ), - MockMessageFactory.message( - id: UUID(uuidString: "C0000000-0000-0000-0000-000000000004")!, - createdAt: now.addingTimeInterval(-1_200), - text: "Good to hear. I'll run a trace this afternoon.", - direction: .outgoing, - status: .sent, - channelIndex: publicChannelIndex, - ackCode: 50_001, - pathLength: path - ) - ] - } + /// Public: multi-sender chatter with a same-sender cluster, plus our own reply. + private static func publicChannelMessages(now: Date) -> [MessageDTO] { + let path = encodePathLen(hashSize: 1, hopCount: 2) + return [ + channelIncoming( + "C0000000-0000-0000-0000-000000000001", + now.addingTimeInterval(-9000), + "Anyone monitoring the north repeater today?", + sender: "Alice Chen", keySeed: 10, snr: 7.4, path: path + ), + // Same-sender cluster (consecutive messages from Alice). + channelIncoming( + "C0000000-0000-0000-0000-000000000002", + now.addingTimeInterval(-8940), + "Signal's been solid on my end all morning.", + sender: "Alice Chen", keySeed: 10, snr: 7.6, path: path + ), + channelIncoming( + "C0000000-0000-0000-0000-000000000003", + now.addingTimeInterval(-6000), + "Same here, clear copy from the south ridge.", + sender: "Bob Martinez", keySeed: 20, snr: 6.9, path: path + ), + MockMessageFactory.message( + id: UUID(uuidString: "C0000000-0000-0000-0000-000000000004")!, + createdAt: now.addingTimeInterval(-1200), + text: "Good to hear. I'll run a trace this afternoon.", + direction: .outgoing, + status: .sent, + channelIndex: publicChannelIndex, + ackCode: 50001, + pathLength: path + ) + ] + } - /// Bay Area (favorite): a reacted message and a self-mention (unread mention badge). - private static func bayAreaChannelMessages(now: Date) -> [MessageDTO] { - let path = encodePathLen(hashSize: 1, hopCount: 1) - return [ - channelIncoming( - "C1000000-0000-0000-0000-000000000001", - now.addingTimeInterval(-7_200), - "Welcome to all the new members this week!", - sender: "Carol Diaz", keySeed: 90, snr: 9.1, path: path - ), - // Reacted message (badge applied via updateMessageReactionSummary). - channelIncoming( - "C1000000-0000-0000-0000-000000000002", - now.addingTimeInterval(-5_400), - "We just passed 50 active nodes in the area 🎉", - sender: "Carol Diaz", keySeed: 90, snr: 9.0, path: path - ), - // Self-mention drives the mention highlight and unread-mention badge; also reacted. - channelIncoming( - bayAreaMentionMessageID.uuidString, - now.addingTimeInterval(-3_600), - "@[Sim] can you cover the cleanup this Saturday?", - sender: "Carol Diaz", keySeed: 90, snr: 8.8, path: path, - isRead: false, containsSelfMention: true, mentionSeen: false - ) - ] - } + /// Bay Area (favorite): a reacted message and a self-mention (unread mention badge). + private static func bayAreaChannelMessages(now: Date) -> [MessageDTO] { + let path = encodePathLen(hashSize: 1, hopCount: 1) + return [ + channelIncoming( + "C1000000-0000-0000-0000-000000000001", + now.addingTimeInterval(-7200), + "Welcome to all the new members this week!", + sender: "Carol Diaz", keySeed: 90, snr: 9.1, path: path + ), + // Reacted message (badge applied via updateMessageReactionSummary). + channelIncoming( + "C1000000-0000-0000-0000-000000000002", + now.addingTimeInterval(-5400), + "We just passed 50 active nodes in the area 🎉", + sender: "Carol Diaz", keySeed: 90, snr: 9.0, path: path + ), + // Self-mention drives the mention highlight and unread-mention badge; also reacted. + channelIncoming( + bayAreaMentionMessageID.uuidString, + now.addingTimeInterval(-3600), + "@[Sim] can you cover the cleanup this Saturday?", + sender: "Carol Diaz", keySeed: 90, snr: 8.8, path: path, + isRead: false, containsSelfMention: true, mentionSeen: false + ) + ] + } - /// Trail Crew (muted): a short exchange to show a muted channel. - private static func trailCrewChannelMessages(now: Date) -> [MessageDTO] { - let path = encodePathLen(hashSize: 1, hopCount: 2) - return [ - channelIncoming( - "C2000000-0000-0000-0000-000000000001", - now.addingTimeInterval(-9_600), - "Bridge repair is done. Trail's open again.", - sender: "Frank Wilson", keySeed: 60, snr: 5.2, path: path - ), - MockMessageFactory.message( - id: UUID(uuidString: "C2000000-0000-0000-0000-000000000002")!, - createdAt: now.addingTimeInterval(-7_200), - text: "Nice work everyone.", - direction: .outgoing, - status: .sent, - channelIndex: trailCrewChannelIndex, - ackCode: 50_002, - pathLength: path - ) - ] - } + /// Trail Crew (muted): a short exchange to show a muted channel. + private static func trailCrewChannelMessages(now: Date) -> [MessageDTO] { + let path = encodePathLen(hashSize: 1, hopCount: 2) + return [ + channelIncoming( + "C2000000-0000-0000-0000-000000000001", + now.addingTimeInterval(-9600), + "Bridge repair is done. Trail's open again.", + sender: "Frank Wilson", keySeed: 60, snr: 5.2, path: path + ), + MockMessageFactory.message( + id: UUID(uuidString: "C2000000-0000-0000-0000-000000000002")!, + createdAt: now.addingTimeInterval(-7200), + text: "Nice work everyone.", + direction: .outgoing, + status: .sent, + channelIndex: trailCrewChannelIndex, + ackCode: 50002, + pathLength: path + ) + ] + } - /// Builds an incoming channel message, resolving the slot index from the id prefix. - private static func channelIncoming( - _ id: String, - _ createdAt: Date, - _ text: String, - sender: String, - keySeed: UInt8, - snr: Double, - path: UInt8, - isRead: Bool = true, - containsSelfMention: Bool = false, - mentionSeen: Bool = false - ) -> MessageDTO { - let index: UInt8 = id.hasPrefix("C1") ? bayAreaChannelIndex - : id.hasPrefix("C2") ? trailCrewChannelIndex - : publicChannelIndex - return MockMessageFactory.message( - id: UUID(uuidString: id)!, - createdAt: createdAt, - text: text, - direction: .incoming, - channelIndex: index, - pathLength: path, - snr: snr, - senderKeyPrefix: mockPublicKey(seed: keySeed).prefix(6), - senderNodeName: sender, - isRead: isRead, - containsSelfMention: containsSelfMention, - mentionSeen: mentionSeen - ) - } + /// Builds an incoming channel message, resolving the slot index from the id prefix. + private static func channelIncoming( + _ id: String, + _ createdAt: Date, + _ text: String, + sender: String, + keySeed: UInt8, + snr: Double, + path: UInt8, + isRead: Bool = true, + containsSelfMention: Bool = false, + mentionSeen: Bool = false + ) -> MessageDTO { + let index: UInt8 = id.hasPrefix("C1") ? bayAreaChannelIndex + : id.hasPrefix("C2") ? trailCrewChannelIndex + : publicChannelIndex + return MockMessageFactory.message( + id: UUID(uuidString: id)!, + createdAt: createdAt, + text: text, + direction: .incoming, + channelIndex: index, + pathLength: path, + snr: snr, + senderKeyPrefix: mockPublicKey(seed: keySeed).prefix(6), + senderNodeName: sender, + isRead: isRead, + containsSelfMention: containsSelfMention, + mentionSeen: mentionSeen + ) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift index 2a202839..52ec340b 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift @@ -1,55 +1,54 @@ import Foundation extension MockDataProvider { + /// Seeded channels with varied notification levels and a favorite, so the + /// channel list exercises all/favorite/muted states. Saved via the DTO-based + /// `saveChannel(_:)` (the wire `ChannelInfo` carries no notification/favorite state). + public static var channels: [ChannelDTO] { + let now = Date() + return [ + ChannelDTO( + id: publicChannelID, + radioID: simulatorDeviceID, + index: publicChannelIndex, + name: "Public", + secret: channelSecret(seed: 0xA0), + isEnabled: true, + lastMessageDate: now.addingTimeInterval(-1200), + unreadCount: 2, + notificationLevel: .all, + isFavorite: false + ), + ChannelDTO( + id: bayAreaChannelID, + radioID: simulatorDeviceID, + index: bayAreaChannelIndex, + name: "Bay Area", + secret: channelSecret(seed: 0xB0), + isEnabled: true, + lastMessageDate: now.addingTimeInterval(-3600), + unreadCount: 1, + unreadMentionCount: 1, + notificationLevel: .all, + isFavorite: true + ), + ChannelDTO( + id: trailCrewChannelID, + radioID: simulatorDeviceID, + index: trailCrewChannelIndex, + name: "Trail Crew", + secret: channelSecret(seed: 0xC0), + isEnabled: true, + lastMessageDate: now.addingTimeInterval(-7200), + unreadCount: 0, + notificationLevel: .muted, + isFavorite: false + ) + ] + } - /// Seeded channels with varied notification levels and a favorite, so the - /// channel list exercises all/favorite/muted states. Saved via the DTO-based - /// `saveChannel(_:)` (the wire `ChannelInfo` carries no notification/favorite state). - public static var channels: [ChannelDTO] { - let now = Date() - return [ - ChannelDTO( - id: publicChannelID, - radioID: simulatorDeviceID, - index: publicChannelIndex, - name: "Public", - secret: channelSecret(seed: 0xA0), - isEnabled: true, - lastMessageDate: now.addingTimeInterval(-1_200), - unreadCount: 2, - notificationLevel: .all, - isFavorite: false - ), - ChannelDTO( - id: bayAreaChannelID, - radioID: simulatorDeviceID, - index: bayAreaChannelIndex, - name: "Bay Area", - secret: channelSecret(seed: 0xB0), - isEnabled: true, - lastMessageDate: now.addingTimeInterval(-3_600), - unreadCount: 1, - unreadMentionCount: 1, - notificationLevel: .all, - isFavorite: true - ), - ChannelDTO( - id: trailCrewChannelID, - radioID: simulatorDeviceID, - index: trailCrewChannelIndex, - name: "Trail Crew", - secret: channelSecret(seed: 0xC0), - isEnabled: true, - lastMessageDate: now.addingTimeInterval(-7_200), - unreadCount: 0, - notificationLevel: .muted, - isFavorite: false - ) - ] - } - - /// Deterministic 16-byte channel PSK from a seed. - private static func channelSecret(seed: UInt8) -> Data { - Data((0..<16).map { UInt8($0) &+ seed }) - } + /// Deterministic 16-byte channel PSK from a seed. + private static func channelSecret(seed: UInt8) -> Data { + Data((0..<16).map { UInt8($0) &+ seed }) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift index 1ad52079..94c15103 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift @@ -1,186 +1,186 @@ import Foundation -extension MockDataProvider { - /// All mock contacts for simulator testing - public static var contacts: [ContactDTO] { - let now = Date() +public extension MockDataProvider { + /// All mock contacts for simulator testing + static var contacts: [ContactDTO] { + let now = Date() - return [ - // Alice Chen - chat, normal, 3 unread, 2 hops - ContactDTO( - id: aliceChenID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 10), - name: "Alice Chen", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 2, - outPath: Data([0x10, 0x20]), // 2-hop path - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 300, // 5 min ago - latitude: 37.7849, - longitude: -122.4094, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: now.addingTimeInterval(-1800), // 30 min ago - unreadCount: 3 - ), + return [ + // Alice Chen - chat, normal, 3 unread, 2 hops + ContactDTO( + id: aliceChenID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 10), + name: "Alice Chen", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 2, + outPath: Data([0x10, 0x20]), // 2-hop path + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 300, // 5 min ago + latitude: 37.7849, + longitude: -122.4094, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: now.addingTimeInterval(-1800), // 30 min ago + unreadCount: 3 + ), - // Bob Martinez - chat, 1 hop (direct) - ContactDTO( - id: bobMartinezID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 20), - name: "Bob Martinez", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 1, - outPath: Data([0x20]), // Direct - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 60, // 1 min ago - latitude: 37.7649, - longitude: -122.4294, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: now.addingTimeInterval(-900), // 15 min ago - unreadCount: 0 - ), + // Bob Martinez - chat, 1 hop (direct) + ContactDTO( + id: bobMartinezID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 20), + name: "Bob Martinez", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 1, + outPath: Data([0x20]), // Direct + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 60, // 1 min ago + latitude: 37.7649, + longitude: -122.4294, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: now.addingTimeInterval(-900), // 15 min ago + unreadCount: 0 + ), - // Charlie Node - repeater, 0 hops (self) - ContactDTO( - id: charlieNodeID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 30), - name: "Charlie Node", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 120, // 2 min ago - latitude: 37.7549, - longitude: -122.4394, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ), + // Charlie Node - repeater, 0 hops (self) + ContactDTO( + id: charlieNodeID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 30), + name: "Charlie Node", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 120, // 2 min ago + latitude: 37.7549, + longitude: -122.4394, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ), - // Diana's Room - room, 3 hops - ContactDTO( - id: dianasRoomID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 40), - name: "Diana's Room", - typeRawValue: ContactType.room.rawValue, - flags: 0, - outPathLength: 3, - outPath: Data([0x10, 0x20, 0x40]), // 3-hop path - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 600, // 10 min ago - latitude: 37.7449, - longitude: -122.4494, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ), + // Diana's Room - room, 3 hops + ContactDTO( + id: dianasRoomID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 40), + name: "Diana's Room", + typeRawValue: ContactType.room.rawValue, + flags: 0, + outPathLength: 3, + outPath: Data([0x10, 0x20, 0x40]), // 3-hop path + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 600, // 10 min ago + latitude: 37.7449, + longitude: -122.4494, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ), - // Eve Thompson - chat, blocked, 4 hops - ContactDTO( - id: eveThompsonID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 50), - name: "Eve Thompson", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 4, - outPath: Data([0x10, 0x20, 0x30, 0x50]), // 4-hop path - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 1800, // 30 min ago - latitude: 37.7349, - longitude: -122.4594, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: true, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ), + // Eve Thompson - chat, blocked, 4 hops + ContactDTO( + id: eveThompsonID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 50), + name: "Eve Thompson", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 4, + outPath: Data([0x10, 0x20, 0x30, 0x50]), // 4-hop path + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 1800, // 30 min ago + latitude: 37.7349, + longitude: -122.4594, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: true, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ), - // Frank Wilson - chat, nickname "Dad", 2 hops - ContactDTO( - id: frankWilsonID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 60), - name: "Frank Wilson", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 2, - outPath: Data([0x10, 0x60]), // 2-hop path - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 3600, // 1 hour ago - latitude: 37.7249, - longitude: -122.4694, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: "Dad", - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: now.addingTimeInterval(-7200), // 2 hours ago - unreadCount: 0 - ), + // Frank Wilson - chat, nickname "Dad", 2 hops + ContactDTO( + id: frankWilsonID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 60), + name: "Frank Wilson", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 2, + outPath: Data([0x10, 0x60]), // 2-hop path + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 3600, // 1 hour ago + latitude: 37.7249, + longitude: -122.4694, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: "Dad", + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: now.addingTimeInterval(-7200), // 2 hours ago + unreadCount: 0 + ), - // Ghost Node - repeater, no recent contact, 5 hops - ContactDTO( - id: ghostNodeID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 70), - name: "Ghost Node", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 5, - outPath: Data([0x10, 0x20, 0x30, 0x40, 0x70]), // 5-hop path (stale) - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 86400, // 24 hours ago - latitude: 0, - longitude: 0, - lastModified: UInt32(now.timeIntervalSince1970) - 86400, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ), + // Ghost Node - repeater, no recent contact, 5 hops + ContactDTO( + id: ghostNodeID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 70), + name: "Ghost Node", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 5, + outPath: Data([0x10, 0x20, 0x30, 0x40, 0x70]), // 5-hop path (stale) + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 86400, // 24 hours ago + latitude: 0, + longitude: 0, + lastModified: UInt32(now.timeIntervalSince1970) - 86400, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ), - // Hannah Lee - chat, direct, short greeting conversation - ContactDTO( - id: hannahLeeID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 80), - name: "Hannah Lee", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 1, - outPath: Data([0x80]), // Direct - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 30, // 30 seconds ago - latitude: 37.7149, - longitude: -122.4794, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: true, - lastMessageDate: now.addingTimeInterval(-600), // 10 min ago - unreadCount: 0 - ) - ] - } + // Hannah Lee - chat, direct, short greeting conversation + ContactDTO( + id: hannahLeeID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 80), + name: "Hannah Lee", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 1, + outPath: Data([0x80]), // Direct + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 30, // 30 seconds ago + latitude: 37.7149, + longitude: -122.4794, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: true, + lastMessageDate: now.addingTimeInterval(-600), // 10 min ago + unreadCount: 0 + ) + ] + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift index 95b95e20..83728a37 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift @@ -1,306 +1,305 @@ import Foundation extension MockDataProvider { + /// A seeded offline link preview. `saveMessage` drops the link-preview columns, + /// so these are applied via `updateMessageLinkPreview` after the message is saved. + struct LinkPreviewSeed { + let messageID: UUID + let url: String + let title: String + let imageData: Data + } - /// A seeded offline link preview. `saveMessage` drops the link-preview columns, - /// so these are applied via `updateMessageLinkPreview` after the message is saved. - struct LinkPreviewSeed: Sendable { - let messageID: UUID - let url: String - let title: String - let imageData: Data - } + /// Link previews to apply after their parent messages are saved. + static var linkPreviewSeeds: [LinkPreviewSeed] { + [ + LinkPreviewSeed( + messageID: aliceLinkPreviewMessageID, + url: "https://meshcoreone.com/trails/skyline", + title: "Skyline Ridge Trail Guide", + imageData: demoImageData + ) + ] + } - /// Link previews to apply after their parent messages are saved. - static var linkPreviewSeeds: [LinkPreviewSeed] { - [ - LinkPreviewSeed( - messageID: aliceLinkPreviewMessageID, - url: "https://meshcoreone.com/trails/skyline", - title: "Skyline Ridge Trail Guide", - imageData: demoImageData - ) - ] + /// Generate mock messages for a specific contact. + public static func messages(for contactID: UUID) -> [MessageDTO] { + let now = Date() + switch contactID { + case aliceChenID: return aliceMessages(now: now) + case bobMartinezID: return bobMessages(now: now) + case frankWilsonID: return frankMessages(now: now) + case hannahLeeID: return hannahMessages(now: now) + default: return [] // Charlie, Diana, Eve, Ghost remain contact-list-only fixtures } + } - /// Generate mock messages for a specific contact. - public static func messages(for contactID: UUID) -> [MessageDTO] { - let now = Date() - switch contactID { - case aliceChenID: return aliceMessages(now: now) - case bobMartinezID: return bobMessages(now: now) - case frankWilsonID: return frankMessages(now: now) - case hannahLeeID: return hannahMessages(now: now) - default: return [] // Charlie, Diana, Eve, Ghost remain contact-list-only fixtures - } - } + // MARK: - Per-contact builders - // MARK: - Per-contact builders + /// Alice (2-hop): reply, reaction badge, offline link preview, inline image, + /// clock-corrected incoming, signed-plain outgoing, varied path-hash byte size. + private static func aliceMessages(now: Date) -> [MessageDTO] { + let key = mockPublicKey(seed: 10).prefix(6) + return [ + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, + createdAt: now.addingTimeInterval(-90000), + text: "Hey Alice, are you free this weekend?", + direction: .outgoing, + status: .delivered, + contactID: aliceChenID, + ackCode: 12345, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + roundTripTime: 2500, + heardRepeats: 1 + ), + // Reacted message (badge applied via updateMessageReactionSummary). + MockMessageFactory.message( + id: aliceReactedMessageID, + createdAt: now.addingTimeInterval(-86400), + text: "Yeah! Want to go hiking? 🥾", + direction: .incoming, + contactID: aliceChenID, + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + snr: 8.5, + pathNodes: Data([0x10, 0xA3, 0x20, 0xB7]), // 2 hops x 2-byte hash + senderKeyPrefix: key + ), + // Outgoing reply referencing Alice's reacted message. + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!, + createdAt: now.addingTimeInterval(-82000), + text: "Perfect! I know a great trail.", + direction: .outgoing, + status: .sent, + contactID: aliceChenID, + ackCode: 12346, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + replyToID: aliceReactedMessageID + ), + // Signed-plain outgoing (CLI/signed styling). + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000009")!, + createdAt: now.addingTimeInterval(-78000), + text: "see you at 9am", + direction: .outgoing, + status: .delivered, + contactID: aliceChenID, + textType: .signedPlain, + ackCode: 12347, + pathLength: encodePathLen(hashSize: 1, hopCount: 2) + ), + // Clock-corrected incoming: wire send time skewed 2h behind the corrected time. + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-00000000000C")!, + createdAt: now.addingTimeInterval(-7200), + text: "Sorry, my clock was way off 😅", + direction: .incoming, + contactID: aliceChenID, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + snr: 6.5, + pathNodes: Data([0x10, 0x20]), + senderKeyPrefix: key, + isRead: false, + timestampCorrected: true, + senderTimestamp: UInt32(now.addingTimeInterval(-14400).timeIntervalSince1970) + ), + // Offline link preview (URL in body; preview applied post-save). + MockMessageFactory.message( + id: aliceLinkPreviewMessageID, + createdAt: now.addingTimeInterval(-5400), + text: "Trail details here: https://meshcoreone.com/trails/skyline", + direction: .incoming, + contactID: aliceChenID, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + snr: 7.9, + pathNodes: Data([0x10, 0x20]), + senderKeyPrefix: key, + isRead: false + ), + // Inline image (URL in body; bytes pre-seeded into the app-layer cache). + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-00000000000B")!, + createdAt: now.addingTimeInterval(-3600), + text: "Made it to the summit! \(inlineImageURL)", + direction: .incoming, + contactID: aliceChenID, + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + snr: 8.2, + pathNodes: Data([0x10, 0xA3, 0x20, 0xB7]), + senderKeyPrefix: key, + isRead: false + ) + ] + } - /// Alice (2-hop): reply, reaction badge, offline link preview, inline image, - /// clock-corrected incoming, signed-plain outgoing, varied path-hash byte size. - private static func aliceMessages(now: Date) -> [MessageDTO] { - let key = mockPublicKey(seed: 10).prefix(6) - return [ - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, - createdAt: now.addingTimeInterval(-90_000), - text: "Hey Alice, are you free this weekend?", - direction: .outgoing, - status: .delivered, - contactID: aliceChenID, - ackCode: 12_345, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - roundTripTime: 2_500, - heardRepeats: 1 - ), - // Reacted message (badge applied via updateMessageReactionSummary). - MockMessageFactory.message( - id: aliceReactedMessageID, - createdAt: now.addingTimeInterval(-86_400), - text: "Yeah! Want to go hiking? 🥾", - direction: .incoming, - contactID: aliceChenID, - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - snr: 8.5, - pathNodes: Data([0x10, 0xA3, 0x20, 0xB7]), // 2 hops x 2-byte hash - senderKeyPrefix: key - ), - // Outgoing reply referencing Alice's reacted message. - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!, - createdAt: now.addingTimeInterval(-82_000), - text: "Perfect! I know a great trail.", - direction: .outgoing, - status: .sent, - contactID: aliceChenID, - ackCode: 12_346, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - replyToID: aliceReactedMessageID - ), - // Signed-plain outgoing (CLI/signed styling). - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000009")!, - createdAt: now.addingTimeInterval(-78_000), - text: "see you at 9am", - direction: .outgoing, - status: .delivered, - contactID: aliceChenID, - textType: .signedPlain, - ackCode: 12_347, - pathLength: encodePathLen(hashSize: 1, hopCount: 2) - ), - // Clock-corrected incoming: wire send time skewed 2h behind the corrected time. - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-00000000000C")!, - createdAt: now.addingTimeInterval(-7_200), - text: "Sorry, my clock was way off 😅", - direction: .incoming, - contactID: aliceChenID, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - snr: 6.5, - pathNodes: Data([0x10, 0x20]), - senderKeyPrefix: key, - isRead: false, - timestampCorrected: true, - senderTimestamp: UInt32(now.addingTimeInterval(-14_400).timeIntervalSince1970) - ), - // Offline link preview (URL in body; preview applied post-save). - MockMessageFactory.message( - id: aliceLinkPreviewMessageID, - createdAt: now.addingTimeInterval(-5_400), - text: "Trail details here: https://meshcoreone.com/trails/skyline", - direction: .incoming, - contactID: aliceChenID, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - snr: 7.9, - pathNodes: Data([0x10, 0x20]), - senderKeyPrefix: key, - isRead: false - ), - // Inline image (URL in body; bytes pre-seeded into the app-layer cache). - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-00000000000B")!, - createdAt: now.addingTimeInterval(-3_600), - text: "Made it to the summit! \(inlineImageURL)", - direction: .incoming, - contactID: aliceChenID, - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - snr: 8.2, - pathNodes: Data([0x10, 0xA3, 0x20, 0xB7]), - senderKeyPrefix: key, - isRead: false - ) - ] - } + /// Bob (direct): full delivery-status spread (pending through failed/retrying). + private static func bobMessages(now: Date) -> [MessageDTO] { + let key = mockPublicKey(seed: 20).prefix(6) + let direct = encodePathLen(hashSize: 1, hopCount: 1) + return [ + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000001")!, + createdAt: now.addingTimeInterval(-172_800), + text: "Bob, can you check the weather?", + direction: .outgoing, + status: .delivered, + contactID: bobMartinezID, + ackCode: 23456, + pathLength: direct, + roundTripTime: 850 + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, + createdAt: now.addingTimeInterval(-7200), + text: "This message failed to send", + direction: .outgoing, + status: .failed, + contactID: bobMartinezID, + ackCode: 23457, + pathLength: direct, + retryAttempt: 3 + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000003")!, + createdAt: now.addingTimeInterval(-3600), + text: "Retrying this one...", + direction: .outgoing, + status: .retrying, + contactID: bobMartinezID, + ackCode: 23458, + pathLength: direct, + retryAttempt: 1 + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000004")!, + createdAt: now.addingTimeInterval(-900), + text: "Are you there?", + direction: .outgoing, + status: .pending, + contactID: bobMartinezID, + ackCode: 23459, + pathLength: direct + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000005")!, + createdAt: now.addingTimeInterval(-720), + text: "Testing connection...", + direction: .outgoing, + status: .sending, + contactID: bobMartinezID, + ackCode: 23460, + pathLength: direct + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000006")!, + createdAt: now.addingTimeInterval(-600), + text: "Yeah, I'm here!", + direction: .incoming, + contactID: bobMartinezID, + pathLength: direct, + snr: 12.3, // strong, direct + pathNodes: Data([0x20]), + senderKeyPrefix: key + ) + ] + } - /// Bob (direct): full delivery-status spread (pending through failed/retrying). - private static func bobMessages(now: Date) -> [MessageDTO] { - let key = mockPublicKey(seed: 20).prefix(6) - let direct = encodePathLen(hashSize: 1, hopCount: 1) - return [ - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000001")!, - createdAt: now.addingTimeInterval(-172_800), - text: "Bob, can you check the weather?", - direction: .outgoing, - status: .delivered, - contactID: bobMartinezID, - ackCode: 23_456, - pathLength: direct, - roundTripTime: 850 - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, - createdAt: now.addingTimeInterval(-7_200), - text: "This message failed to send", - direction: .outgoing, - status: .failed, - contactID: bobMartinezID, - ackCode: 23_457, - pathLength: direct, - retryAttempt: 3 - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000003")!, - createdAt: now.addingTimeInterval(-3_600), - text: "Retrying this one...", - direction: .outgoing, - status: .retrying, - contactID: bobMartinezID, - ackCode: 23_458, - pathLength: direct, - retryAttempt: 1 - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000004")!, - createdAt: now.addingTimeInterval(-900), - text: "Are you there?", - direction: .outgoing, - status: .pending, - contactID: bobMartinezID, - ackCode: 23_459, - pathLength: direct - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000005")!, - createdAt: now.addingTimeInterval(-720), - text: "Testing connection...", - direction: .outgoing, - status: .sending, - contactID: bobMartinezID, - ackCode: 23_460, - pathLength: direct - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000006")!, - createdAt: now.addingTimeInterval(-600), - text: "Yeah, I'm here!", - direction: .incoming, - contactID: bobMartinezID, - pathLength: direct, - snr: 12.3, // strong, direct - pathNodes: Data([0x20]), - senderKeyPrefix: key - ) - ] - } + /// Frank "Dad" (2-hop): weak/very-weak SNR, a flood-routed incoming with a region + /// scope, and a heard-repeat-backed outgoing (repeats seeded separately). + private static func frankMessages(now: Date) -> [MessageDTO] { + let key = mockPublicKey(seed: 60).prefix(6) + return [ + MockMessageFactory.message( + id: UUID(uuidString: "60000000-0000-0000-0000-000000000001")!, + createdAt: now.addingTimeInterval(-259_200), + text: "Hey kiddo, how are you?", + direction: .incoming, + contactID: frankWilsonID, + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + snr: 2.1, // weak + pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), + senderKeyPrefix: key + ), + // Heard-repeat-backed outgoing; heardRepeats matches the seeded repeat rows. + MockMessageFactory.message( + id: frankRepeatMessageID, + createdAt: now.addingTimeInterval(-255_600), + text: "Doing great Dad! How about you?", + direction: .outgoing, + status: .delivered, + contactID: frankWilsonID, + ackCode: 34567, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + roundTripTime: 3200, + heardRepeats: 3 + ), + MockMessageFactory.message( + id: UUID(uuidString: "60000000-0000-0000-0000-000000000003")!, + createdAt: now.addingTimeInterval(-10800), + text: "Good! Talk soon.", + direction: .incoming, + contactID: frankWilsonID, + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + snr: 0.8, // very weak + pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), + senderKeyPrefix: key + ), + // Flood-routed incoming carrying a region scope. + MockMessageFactory.message( + id: UUID(uuidString: "60000000-0000-0000-0000-000000000004")!, + createdAt: now.addingTimeInterval(-3600), + text: "Storm warning for the ridge tonight ⛈️", + direction: .incoming, + contactID: frankWilsonID, + pathLength: encodePathLen(hashSize: 1, hopCount: 3), + snr: 3.2, + pathNodes: Data([0x10, 0x44, 0x60]), + senderKeyPrefix: key, + routeType: .tcFlood, + regionScope: "US915" + ) + ] + } - /// Frank "Dad" (2-hop): weak/very-weak SNR, a flood-routed incoming with a region - /// scope, and a heard-repeat-backed outgoing (repeats seeded separately). - private static func frankMessages(now: Date) -> [MessageDTO] { - let key = mockPublicKey(seed: 60).prefix(6) - return [ - MockMessageFactory.message( - id: UUID(uuidString: "60000000-0000-0000-0000-000000000001")!, - createdAt: now.addingTimeInterval(-259_200), - text: "Hey kiddo, how are you?", - direction: .incoming, - contactID: frankWilsonID, - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - snr: 2.1, // weak - pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), - senderKeyPrefix: key - ), - // Heard-repeat-backed outgoing; heardRepeats matches the seeded repeat rows. - MockMessageFactory.message( - id: frankRepeatMessageID, - createdAt: now.addingTimeInterval(-255_600), - text: "Doing great Dad! How about you?", - direction: .outgoing, - status: .delivered, - contactID: frankWilsonID, - ackCode: 34_567, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - roundTripTime: 3_200, - heardRepeats: 3 - ), - MockMessageFactory.message( - id: UUID(uuidString: "60000000-0000-0000-0000-000000000003")!, - createdAt: now.addingTimeInterval(-10_800), - text: "Good! Talk soon.", - direction: .incoming, - contactID: frankWilsonID, - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - snr: 0.8, // very weak - pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), - senderKeyPrefix: key - ), - // Flood-routed incoming carrying a region scope. - MockMessageFactory.message( - id: UUID(uuidString: "60000000-0000-0000-0000-000000000004")!, - createdAt: now.addingTimeInterval(-3_600), - text: "Storm warning for the ridge tonight ⛈️", - direction: .incoming, - contactID: frankWilsonID, - pathLength: encodePathLen(hashSize: 1, hopCount: 3), - snr: 3.2, - pathNodes: Data([0x10, 0x44, 0x60]), - senderKeyPrefix: key, - routeType: .tcFlood, - regionScope: "US915" - ) - ] - } - - /// Hannah (direct): short greeting plus a message with a coordinate (map preview). - private static func hannahMessages(now: Date) -> [MessageDTO] { - let key = mockPublicKey(seed: 80).prefix(6) - let direct = encodePathLen(hashSize: 1, hopCount: 1) - return [ - MockMessageFactory.message( - id: UUID(uuidString: "80000000-0000-0000-0000-000000000001")!, - createdAt: now.addingTimeInterval(-1_200), - text: "Hi! This is Hannah from the trail club 👋", - direction: .incoming, - contactID: hannahLeeID, - pathLength: direct, - snr: 9.0, - pathNodes: Data([0x80]), - senderKeyPrefix: key - ), - MockMessageFactory.message( - id: UUID(uuidString: "80000000-0000-0000-0000-000000000002")!, - createdAt: now.addingTimeInterval(-900), - text: "Hey Hannah! Welcome aboard.", - direction: .outgoing, - status: .delivered, - contactID: hannahLeeID, - ackCode: 45_678, - pathLength: direct - ), - // Coordinate in body renders a map-preview fragment. - MockMessageFactory.message( - id: UUID(uuidString: "80000000-0000-0000-0000-000000000003")!, - createdAt: now.addingTimeInterval(-600), - text: "Meet me at the trailhead: 37.8651, -119.5383", - direction: .incoming, - contactID: hannahLeeID, - pathLength: direct, - snr: 8.7, - pathNodes: Data([0x80]), - senderKeyPrefix: key - ) - ] - } + /// Hannah (direct): short greeting plus a message with a coordinate (map preview). + private static func hannahMessages(now: Date) -> [MessageDTO] { + let key = mockPublicKey(seed: 80).prefix(6) + let direct = encodePathLen(hashSize: 1, hopCount: 1) + return [ + MockMessageFactory.message( + id: UUID(uuidString: "80000000-0000-0000-0000-000000000001")!, + createdAt: now.addingTimeInterval(-1200), + text: "Hi! This is Hannah from the trail club 👋", + direction: .incoming, + contactID: hannahLeeID, + pathLength: direct, + snr: 9.0, + pathNodes: Data([0x80]), + senderKeyPrefix: key + ), + MockMessageFactory.message( + id: UUID(uuidString: "80000000-0000-0000-0000-000000000002")!, + createdAt: now.addingTimeInterval(-900), + text: "Hey Hannah! Welcome aboard.", + direction: .outgoing, + status: .delivered, + contactID: hannahLeeID, + ackCode: 45678, + pathLength: direct + ), + // Coordinate in body renders a map-preview fragment. + MockMessageFactory.message( + id: UUID(uuidString: "80000000-0000-0000-0000-000000000003")!, + createdAt: now.addingTimeInterval(-600), + text: "Meet me at the trailhead: 37.8651, -119.5383", + direction: .incoming, + contactID: hannahLeeID, + pathLength: direct, + snr: 8.7, + pathNodes: Data([0x80]), + senderKeyPrefix: key + ) + ] + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DemoImage.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DemoImage.swift index 11196958..3ae0d807 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DemoImage.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DemoImage.swift @@ -1,18 +1,18 @@ import Foundation extension MockDataProvider { - /// Base64 of the embedded 120x80 gradient PNG used for the offline link-preview hero - /// and inline image. Generated, not hand-authored, and decodes to a valid - /// 1292-byte PNG. Decoded once into ``demoImageData``. - static let demoImageBase64 = """ - iVBORw0KGgoAAAANSUhEUgAAAHgAAABQCAIAAABd+SbeAAAE00lEQVR42u3Q1Y4dVBiA0f0gXHDFo0AIIYQQAiGkIaS0pVBKKW0ppUNl9Iy7u7u7u7u7u7tVaGbSyekc+78bQshO1hMsdRLwodwp8YJ4SbwiXv83qEP/j84dEcfEidgp8YJ4SbwiXsuofb+PjR0Qh8QR - cUyciJ0SL4iXtqhd309M7RH7xAFxSBwRx8SJ2KllatvnUyt2xHaJPWKfOCAOiSPi2ITa9P7Mpi1im9gR2yX2iH3igDg0ota9PhfaIDaJLWKb2BHbJfaI/TNqxfOLVWKNWCc2iE1ii9gmdsTUkseXF5aJFbFVYo1YJzaITWJLQC14fHXJIrFELBMrYqvEGrFObFil5ty/ - NmueWCAWiSVimVgRWyXWLFAzbt9YMUvMEfPEArFILBHLxMq71JTrtxLTxIzYLDFHzBMLxCKx9JaaMFyRmySmiGliRmyWmCPmiQXDFTXq8t0bY8Q4MUFMElPENDFDzMqoYefvjY0Qo2JjxDgxQUwSU8S0LWrQ6aqpIWKYGCFGxcaIcWKCmLRM9Ttes2SAGCSGiGFihBgV - GyPGzVG9jjes6yP6iQFikBgihokRYtSI6na4KdRD9Ir1Ef3EADFIDBHDZ1SH/U+dRBfRTfQQvWJ9RD8xQKi257fOtRMdRCfRRXQTPUSvWJ+Manl2+5JWsTaineggOokuopvosUo1Pb1jVjPRQrSKtRHtRAfRSXRZoBqe3LWikWgimokWolWsjWgnOkyour/v2VRPNBCN - RBPRTLQQrWJtRlSN3QO5WrE6op5oIBqJJqKZaLF7oCrtHr5RRVQTNUQtUSdWTzQQjWKq/PGjCxVEJVFFVBM1RC1RJ1YvoEr/emyqTKycqCAqiSqimqghai1TxY/sLCkhSokysXKigqgkqohqc1Thn0+sKyKKiRKilCgTKycqiMp3qfyHzyQKiEKiiCgmSohSokys/C2V - 84d9LpEnlk8UEIVEEVFMlBAq64HjuWwih8gl8sTyiQKikCiSURn3nY1lEllENpFD5BJ5YvlEgS0q7b7BVDqRQWQSWUQ2kUPkEnkWqJR7blakEmli6UQGkUlkEdlEjgmV9LuHTclECpFKpImlExlEJpFlRCXc9RJKJJKIZCKFSCXSxNKJjDMq9jfvN+KIeLEEIpFIIpKJ - FCJVTEXf8b0QQ8QScUS8WAKRSCQRyQIq8lf/S6KIaCKGiCXiiHixBCLRKhV+O9CsCCKSiCKiiRgilogj4s1RobeDrQsjwsUiiEgiiogmYojYd6ngX0IlQohQIowIF4sgIokoIvotFXgrXC6ICCZCiFAijAgXiyAib4Urv58jz/mLBRCBRBARTIQQoUSYjPL5KdqYL+FH - +IsFEIFEEBFMhNiivG7GmvImfAhfwo/wFwsgAokgy5THj3GWeBJehDfhQ/gSfoS/WIA5yu1Ggk3uYh6EJ+FFeBM+hC/hZ0QZricJuRJuhLuYB+FJeBHehM8Z5XQ9xZlwIQyEK+FGuBMeYp6EcriWdsFRzIlwJlwIA+FKuBHuAur5DxmX2BMOhKOYE+FMuBAGwtUq9fRq - llnPiOeEPeFAOIo5Ec6EiwXqvQ/e1/4FOlpH62hNR+toHa0LdLSO1nS0jtbRekFH62hNR+toHa0XdLSO1nS0jtbRmo7+//gHeCggAUg2kXEAAAAASUVORK5CYII= - """ + /// Base64 of the embedded 120x80 gradient PNG used for the offline link-preview hero + /// and inline image. Generated, not hand-authored, and decodes to a valid + /// 1292-byte PNG. Decoded once into ``demoImageData``. + static let demoImageBase64 = """ + iVBORw0KGgoAAAANSUhEUgAAAHgAAABQCAIAAABd+SbeAAAE00lEQVR42u3Q1Y4dVBiA0f0gXHDFo0AIIYQQAiGkIaS0pVBKKW0ppUNl9Iy7u7u7u7u7u7tVaGbSyekc+78bQshO1hMsdRLwodwp8YJ4SbwiXv83qEP/j84dEcfEidgp8YJ4SbwiXsuofb+PjR0Qh8QR + cUyciJ0SL4iXtqhd309M7RH7xAFxSBwRx8SJ2KllatvnUyt2xHaJPWKfOCAOiSPi2ITa9P7Mpi1im9gR2yX2iH3igDg0ota9PhfaIDaJLWKb2BHbJfaI/TNqxfOLVWKNWCc2iE1ii9gmdsTUkseXF5aJFbFVYo1YJzaITWJLQC14fHXJIrFELBMrYqvEGrFObFil5ty/ + NmueWCAWiSVimVgRWyXWLFAzbt9YMUvMEfPEArFILBHLxMq71JTrtxLTxIzYLDFHzBMLxCKx9JaaMFyRmySmiGliRmyWmCPmiQXDFTXq8t0bY8Q4MUFMElPENDFDzMqoYefvjY0Qo2JjxDgxQUwSU8S0LWrQ6aqpIWKYGCFGxcaIcWKCmLRM9Ttes2SAGCSGiGFihBgV + GyPGzVG9jjes6yP6iQFikBgihokRYtSI6na4KdRD9Ir1Ef3EADFIDBHDZ1SH/U+dRBfRTfQQvWJ9RD8xQKi257fOtRMdRCfRRXQTPUSvWJ+Manl2+5JWsTaineggOokuopvosUo1Pb1jVjPRQrSKtRHtRAfRSXRZoBqe3LWikWgimokWolWsjWgnOkyour/v2VRPNBCN + RBPRTLQQrWJtRlSN3QO5WrE6op5oIBqJJqKZaLF7oCrtHr5RRVQTNUQtUSdWTzQQjWKq/PGjCxVEJVFFVBM1RC1RJ1YvoEr/emyqTKycqCAqiSqimqghai1TxY/sLCkhSokysXKigqgkqohqc1Thn0+sKyKKiRKilCgTKycqiMp3qfyHzyQKiEKiiCgmSohSokys/C2V + 84d9LpEnlk8UEIVEEVFMlBAq64HjuWwih8gl8sTyiQKikCiSURn3nY1lEllENpFD5BJ5YvlEgS0q7b7BVDqRQWQSWUQ2kUPkEnkWqJR7blakEmli6UQGkUlkEdlEjgmV9LuHTclECpFKpImlExlEJpFlRCXc9RJKJJKIZCKFSCXSxNKJjDMq9jfvN+KIeLEEIpFIIpKJ + FCJVTEXf8b0QQ8QScUS8WAKRSCQRyQIq8lf/S6KIaCKGiCXiiHixBCLRKhV+O9CsCCKSiCKiiRgilogj4s1RobeDrQsjwsUiiEgiiogmYojYd6ngX0IlQohQIowIF4sgIokoIvotFXgrXC6ICCZCiFAijAgXiyAib4Urv58jz/mLBRCBRBARTIQQoUSYjPL5KdqYL+FH + +IsFEIFEEBFMhNiivG7GmvImfAhfwo/wFwsgAokgy5THj3GWeBJehDfhQ/gSfoS/WIA5yu1Ggk3uYh6EJ+FFeBM+hC/hZ0QZricJuRJuhLuYB+FJeBHehM8Z5XQ9xZlwIQyEK+FGuBMeYp6EcriWdsFRzIlwJlwIA+FKuBHuAur5DxmX2BMOhKOYE+FMuBAGwtUq9fRq + llnPiOeEPeFAOIo5Ec6EiwXqvQ/e1/4FOlpH62hNR+toHa0LdLSO1nS0jtbRekFH62hNR+toHa0XdLSO1nS0jtbRmo7+//gHeCggAUg2kXEAAAAASUVORK5CYII= + """ } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Reactions.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Reactions.swift index 2bf37f66..f206994d 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Reactions.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Reactions.swift @@ -1,58 +1,57 @@ import Foundation extension MockDataProvider { + /// Messages that carry seeded reactions, paired with the denormalized badge + /// summary. `saveMessage` drops `reactionSummary`, so the summary is written via + /// `updateMessageReactionSummary`; the per-reactor rows render the detail list. + /// Each summary's counts match the rows returned by `reactions(for:)`. + static let reactedMessages: [(messageID: UUID, summary: String)] = [ + (aliceReactedMessageID, "👍:2,❤️:1"), + (bayAreaReactedMessageID, "🎉:2"), + (bayAreaMentionMessageID, "👍:1") + ] - /// Messages that carry seeded reactions, paired with the denormalized badge - /// summary. `saveMessage` drops `reactionSummary`, so the summary is written via - /// `updateMessageReactionSummary`; the per-reactor rows render the detail list. - /// Each summary's counts match the rows returned by `reactions(for:)`. - static let reactedMessages: [(messageID: UUID, summary: String)] = [ - (aliceReactedMessageID, "👍:2,❤️:1"), - (bayAreaReactedMessageID, "🎉:2"), - (bayAreaMentionMessageID, "👍:1") - ] - - /// Per-reactor reaction rows for the reactor-detail list. - static func reactions(for messageID: UUID) -> [ReactionDTO] { - switch messageID { - case aliceReactedMessageID: - return [ - reaction("A0000000-0000-0000-0000-000000000001", messageID, "👍", "You", contactID: aliceChenID), - reaction("A0000000-0000-0000-0000-000000000002", messageID, "👍", "Bob Martinez", contactID: aliceChenID), - reaction("A0000000-0000-0000-0000-000000000003", messageID, "❤️", "Alice Chen", contactID: aliceChenID) - ] - case bayAreaReactedMessageID: - return [ - reaction("A1000000-0000-0000-0000-000000000001", messageID, "🎉", "Alice Chen", channelIndex: bayAreaChannelIndex), - reaction("A1000000-0000-0000-0000-000000000002", messageID, "🎉", "Bob Martinez", channelIndex: bayAreaChannelIndex) - ] - case bayAreaMentionMessageID: - return [ - reaction("A1000000-0000-0000-0000-000000000003", messageID, "👍", "Sim", channelIndex: bayAreaChannelIndex) - ] - default: - return [] - } + /// Per-reactor reaction rows for the reactor-detail list. + static func reactions(for messageID: UUID) -> [ReactionDTO] { + switch messageID { + case aliceReactedMessageID: + [ + reaction("A0000000-0000-0000-0000-000000000001", messageID, "👍", "You", contactID: aliceChenID), + reaction("A0000000-0000-0000-0000-000000000002", messageID, "👍", "Bob Martinez", contactID: aliceChenID), + reaction("A0000000-0000-0000-0000-000000000003", messageID, "❤️", "Alice Chen", contactID: aliceChenID) + ] + case bayAreaReactedMessageID: + [ + reaction("A1000000-0000-0000-0000-000000000001", messageID, "🎉", "Alice Chen", channelIndex: bayAreaChannelIndex), + reaction("A1000000-0000-0000-0000-000000000002", messageID, "🎉", "Bob Martinez", channelIndex: bayAreaChannelIndex) + ] + case bayAreaMentionMessageID: + [ + reaction("A1000000-0000-0000-0000-000000000003", messageID, "👍", "Sim", channelIndex: bayAreaChannelIndex) + ] + default: + [] } + } - private static func reaction( - _ id: String, - _ messageID: UUID, - _ emoji: String, - _ sender: String, - contactID: UUID? = nil, - channelIndex: UInt8? = nil - ) -> ReactionDTO { - ReactionDTO( - id: UUID(uuidString: id)!, - messageID: messageID, - emoji: emoji, - senderName: sender, - messageHash: String(messageID.uuidString.prefix(8)), - rawText: emoji, - channelIndex: channelIndex, - contactID: contactID, - radioID: simulatorDeviceID - ) - } + private static func reaction( + _ id: String, + _ messageID: UUID, + _ emoji: String, + _ sender: String, + contactID: UUID? = nil, + channelIndex: UInt8? = nil + ) -> ReactionDTO { + ReactionDTO( + id: UUID(uuidString: id)!, + messageID: messageID, + emoji: emoji, + senderName: sender, + messageHash: String(messageID.uuidString.prefix(8)), + rawText: emoji, + channelIndex: channelIndex, + contactID: contactID, + radioID: simulatorDeviceID + ) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Repeats.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Repeats.swift index db78a77e..84ef22d4 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Repeats.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Repeats.swift @@ -1,47 +1,46 @@ import Foundation extension MockDataProvider { + /// Message IDs that carry seeded heard-repeat rows ("Repeat Details"). + static let messagesWithRepeats: [UUID] = [frankRepeatMessageID] - /// Message IDs that carry seeded heard-repeat rows ("Repeat Details"). - static let messagesWithRepeats: [UUID] = [frankRepeatMessageID] - - /// Heard repeats for a message: distinct repeater-hash prefixes, hop counts, and - /// signal stats so "Repeat Details" lists multiple repeaters. The count matches the - /// parent message's `heardRepeats`. - static func messageRepeats(for messageID: UUID) -> [MessageRepeatDTO] { - guard messageID == frankRepeatMessageID else { return [] } - let now = Date() - return [ - MessageRepeatDTO( - id: UUID(uuidString: "B0000000-0000-0000-0000-000000000001")!, - messageID: messageID, - receivedAt: now.addingTimeInterval(-255_590), - pathNodes: Data([0x31]), // 1 hop, 1-byte hash - pathLength: encodePathLen(hashSize: 1, hopCount: 1), - snr: 6.5, - rssi: -92, - rxLogEntryID: nil - ), - MessageRepeatDTO( - id: UUID(uuidString: "B0000000-0000-0000-0000-000000000002")!, - messageID: messageID, - receivedAt: now.addingTimeInterval(-255_585), - pathNodes: Data([0x8F, 0x2C]), // 1 hop, 2-byte hash - pathLength: encodePathLen(hashSize: 2, hopCount: 1), - snr: 4.2, - rssi: -101, - rxLogEntryID: nil - ), - MessageRepeatDTO( - id: UUID(uuidString: "B0000000-0000-0000-0000-000000000003")!, - messageID: messageID, - receivedAt: now.addingTimeInterval(-255_580), - pathNodes: Data([0x44, 0x71]), // 2 hops, 1-byte hash - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - snr: 1.9, - rssi: -108, - rxLogEntryID: nil - ) - ] - } + /// Heard repeats for a message: distinct repeater-hash prefixes, hop counts, and + /// signal stats so "Repeat Details" lists multiple repeaters. The count matches the + /// parent message's `heardRepeats`. + static func messageRepeats(for messageID: UUID) -> [MessageRepeatDTO] { + guard messageID == frankRepeatMessageID else { return [] } + let now = Date() + return [ + MessageRepeatDTO( + id: UUID(uuidString: "B0000000-0000-0000-0000-000000000001")!, + messageID: messageID, + receivedAt: now.addingTimeInterval(-255_590), + pathNodes: Data([0x31]), // 1 hop, 1-byte hash + pathLength: encodePathLen(hashSize: 1, hopCount: 1), + snr: 6.5, + rssi: -92, + rxLogEntryID: nil + ), + MessageRepeatDTO( + id: UUID(uuidString: "B0000000-0000-0000-0000-000000000002")!, + messageID: messageID, + receivedAt: now.addingTimeInterval(-255_585), + pathNodes: Data([0x8F, 0x2C]), // 1 hop, 2-byte hash + pathLength: encodePathLen(hashSize: 2, hopCount: 1), + snr: 4.2, + rssi: -101, + rxLogEntryID: nil + ), + MessageRepeatDTO( + id: UUID(uuidString: "B0000000-0000-0000-0000-000000000003")!, + messageID: messageID, + receivedAt: now.addingTimeInterval(-255_580), + pathNodes: Data([0x44, 0x71]), // 2 hops, 1-byte hash + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + snr: 1.9, + rssi: -108, + rxLogEntryID: nil + ) + ] + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift index 3e61d4ea..4d42a89f 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift @@ -5,95 +5,95 @@ import Foundation /// seed data lives in `MockDataProvider+…` extensions; this file holds the shared /// identity constants, the simulated device, and the offline demo image. public enum MockDataProvider { - // MARK: - Deterministic IDs + // MARK: - Deterministic IDs - /// Simulator device UUID - public static let simulatorDeviceID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + /// Simulator device UUID + public static let simulatorDeviceID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! - /// Contact UUIDs - public static let aliceChenID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! - public static let bobMartinezID = UUID(uuidString: "00000000-0000-0000-0000-000000000020")! - public static let charlieNodeID = UUID(uuidString: "00000000-0000-0000-0000-000000000030")! - public static let dianasRoomID = UUID(uuidString: "00000000-0000-0000-0000-000000000040")! - public static let eveThompsonID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")! - public static let frankWilsonID = UUID(uuidString: "00000000-0000-0000-0000-000000000060")! - public static let ghostNodeID = UUID(uuidString: "00000000-0000-0000-0000-000000000070")! - public static let hannahLeeID = UUID(uuidString: "00000000-0000-0000-0000-000000000080")! + /// Contact UUIDs + public static let aliceChenID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! + public static let bobMartinezID = UUID(uuidString: "00000000-0000-0000-0000-000000000020")! + public static let charlieNodeID = UUID(uuidString: "00000000-0000-0000-0000-000000000030")! + public static let dianasRoomID = UUID(uuidString: "00000000-0000-0000-0000-000000000040")! + public static let eveThompsonID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")! + public static let frankWilsonID = UUID(uuidString: "00000000-0000-0000-0000-000000000060")! + public static let ghostNodeID = UUID(uuidString: "00000000-0000-0000-0000-000000000070")! + public static let hannahLeeID = UUID(uuidString: "00000000-0000-0000-0000-000000000080")! - /// Channel UUIDs - public static let publicChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000000")! - public static let bayAreaChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000001")! - public static let trailCrewChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000002")! + /// Channel UUIDs + public static let publicChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000000")! + public static let bayAreaChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000001")! + public static let trailCrewChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000002")! - /// Channel slot indices (mirror firmware slot positions) - public static let publicChannelIndex: UInt8 = 0 - public static let bayAreaChannelIndex: UInt8 = 1 - public static let trailCrewChannelIndex: UInt8 = 2 + /// Channel slot indices (mirror firmware slot positions) + public static let publicChannelIndex: UInt8 = 0 + public static let bayAreaChannelIndex: UInt8 = 1 + public static let trailCrewChannelIndex: UInt8 = 2 - /// Message IDs referenced by more than one seed helper (reaction / repeat / - /// link-preview targets) so the builders and the post-save mutators agree. - static let aliceReactedMessageID = UUID(uuidString: "10000000-0000-0000-0000-000000000002")! - static let aliceLinkPreviewMessageID = UUID(uuidString: "10000000-0000-0000-0000-00000000000A")! - static let frankRepeatMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000002")! - static let bayAreaReactedMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000002")! - static let bayAreaMentionMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000003")! + /// Message IDs referenced by more than one seed helper (reaction / repeat / + /// link-preview targets) so the builders and the post-save mutators agree. + static let aliceReactedMessageID = UUID(uuidString: "10000000-0000-0000-0000-000000000002")! + static let aliceLinkPreviewMessageID = UUID(uuidString: "10000000-0000-0000-0000-00000000000A")! + static let frankRepeatMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000002")! + static let bayAreaReactedMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000002")! + static let bayAreaMentionMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000003")! - // MARK: - Mock Public Keys + // MARK: - Mock Public Keys - /// Deterministic 32-byte public key from a seed. Internal so the per-feature - /// builders in sibling files can resolve sender key prefixes. - static func mockPublicKey(seed: UInt8) -> Data { - Data((0.. Data { + Data((0.. MessageDTO { - MessageDTO( - id: id, - radioID: MockDataProvider.simulatorDeviceID, - contactID: contactID, - channelIndex: channelIndex, - text: text, - timestamp: UInt32(createdAt.timeIntervalSince1970), - createdAt: createdAt, - direction: direction, - status: status, - textType: textType, - ackCode: ackCode, - pathLength: pathLength, - snr: snr, - pathNodes: pathNodes, - senderKeyPrefix: senderKeyPrefix, - senderNodeName: senderNodeName, - isRead: isRead, - replyToID: replyToID, - roundTripTime: roundTripTime, - heardRepeats: heardRepeats, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts, - containsSelfMention: containsSelfMention, - mentionSeen: mentionSeen, - timestampCorrected: timestampCorrected, - senderTimestamp: senderTimestamp, - routeType: routeType, - regionScope: regionScope - ) - } + static func message( + id: UUID, + createdAt: Date, + text: String, + direction: MessageDirection, + status: MessageStatus = .delivered, + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + textType: TextType = .plain, + ackCode: UInt32? = nil, + pathLength: UInt8 = 1, + snr: Double? = nil, + pathNodes: Data? = nil, + senderKeyPrefix: Data? = nil, + senderNodeName: String? = nil, + isRead: Bool = true, + replyToID: UUID? = nil, + roundTripTime: UInt32? = nil, + heardRepeats: Int = 0, + retryAttempt: Int = 0, + maxRetryAttempts: Int = 3, + containsSelfMention: Bool = false, + mentionSeen: Bool = false, + timestampCorrected: Bool = false, + senderTimestamp: UInt32? = nil, + routeType: RouteType? = nil, + regionScope: String? = nil + ) -> MessageDTO { + MessageDTO( + id: id, + radioID: MockDataProvider.simulatorDeviceID, + contactID: contactID, + channelIndex: channelIndex, + text: text, + timestamp: UInt32(createdAt.timeIntervalSince1970), + createdAt: createdAt, + direction: direction, + status: status, + textType: textType, + ackCode: ackCode, + pathLength: pathLength, + snr: snr, + pathNodes: pathNodes, + senderKeyPrefix: senderKeyPrefix, + senderNodeName: senderNodeName, + isRead: isRead, + replyToID: replyToID, + roundTripTime: roundTripTime, + heardRepeats: heardRepeats, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts, + containsSelfMention: containsSelfMention, + mentionSeen: mentionSeen, + timestampCorrected: timestampCorrected, + senderTimestamp: senderTimestamp, + routeType: routeType, + regionScope: regionScope + ) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift index 7d89be2e..0c0b1ffd 100644 --- a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift +++ b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift @@ -1,97 +1,96 @@ import Foundation -import SwiftData import OSLog +import SwiftData /// Connection mode for simulator and demo mode on device. /// Provides mock data and simulated connections without requiring real hardware. @MainActor final class SimulatorConnectionMode { - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "SimulatorConnectionMode") - - /// Whether simulator is "connected" - private(set) var isConnected = false - - /// The simulated device - var device: DeviceDTO? { - isConnected ? MockDataProvider.simulatorDevice : nil + private let logger = PersistentLogger(subsystem: "com.mc1", category: "SimulatorConnectionMode") + + /// Whether simulator is "connected" + private(set) var isConnected = false + + /// The simulated device + var device: DeviceDTO? { + isConnected ? MockDataProvider.simulatorDevice : nil + } + + init() {} + + /// Simulates connecting to the simulator device + func connect() async { + logger.info("Simulator: connecting to mock device") + try? await Task.sleep(for: .milliseconds(200)) // Brief delay + isConnected = true + logger.info("Simulator: connected") + } + + /// Simulates disconnecting + func disconnect() async { + logger.info("Simulator: disconnecting") + isConnected = false + } + + /// Seeds the data store with mock data. Each message is saved before its + /// link-preview, reaction, and repeat rows: `saveMessage` does not persist the + /// link-preview or `reactionSummary` columns, and `saveMessageRepeat` needs the + /// parent present. Re-seeding upserts on each row's unique `id`, so it is idempotent. + func seedDataStore(_ dataStore: PersistenceStore) async throws { + try await dataStore.saveDevice(MockDataProvider.simulatorDevice) + + for contact in MockDataProvider.contacts { + try await dataStore.saveContact(contact) } - init() {} - - /// Simulates connecting to the simulator device - func connect() async { - logger.info("Simulator: connecting to mock device") - try? await Task.sleep(for: .milliseconds(200)) // Brief delay - isConnected = true - logger.info("Simulator: connected") + for channel in MockDataProvider.channels { + try await dataStore.saveChannel(channel) } - /// Simulates disconnecting - func disconnect() async { - logger.info("Simulator: disconnecting") - isConnected = false + for contact in MockDataProvider.contacts { + for message in MockDataProvider.messages(for: contact.id) { + try await dataStore.saveMessage(message) + } } - /// Seeds the data store with mock data. Each message is saved before its - /// link-preview, reaction, and repeat rows: `saveMessage` does not persist the - /// link-preview or `reactionSummary` columns, and `saveMessageRepeat` needs the - /// parent present. Re-seeding upserts on each row's unique `id`, so it is idempotent. - func seedDataStore(_ dataStore: PersistenceStore) async throws { - try await dataStore.saveDevice(MockDataProvider.simulatorDevice) - - for contact in MockDataProvider.contacts { - try await dataStore.saveContact(contact) - } - - for channel in MockDataProvider.channels { - try await dataStore.saveChannel(channel) - } - - for contact in MockDataProvider.contacts { - for message in MockDataProvider.messages(for: contact.id) { - try await dataStore.saveMessage(message) - } - } - - for channel in MockDataProvider.channels { - for message in MockDataProvider.channelMessages(for: channel.index) { - try await dataStore.saveMessage(message) - } - } - - // Link previews render offline from the message-owned columns, which saveMessage drops. - for seed in MockDataProvider.linkPreviewSeeds { - try await dataStore.updateMessageLinkPreview( - id: seed.messageID, - url: seed.url, - title: seed.title, - imageData: seed.imageData, - iconData: nil, - fetched: true - ) - } + for channel in MockDataProvider.channels { + for message in MockDataProvider.channelMessages(for: channel.index) { + try await dataStore.saveMessage(message) + } + } - // Reaction rows feed the reactor-detail list; the summary drives the badge. - for reacted in MockDataProvider.reactedMessages { - for reaction in MockDataProvider.reactions(for: reacted.messageID) { - try await dataStore.saveReaction(reaction) - } - try await dataStore.updateMessageReactionSummary( - messageID: reacted.messageID, - summary: reacted.summary - ) - } + // Link previews render offline from the message-owned columns, which saveMessage drops. + for seed in MockDataProvider.linkPreviewSeeds { + try await dataStore.updateMessageLinkPreview( + id: seed.messageID, + url: seed.url, + title: seed.title, + imageData: seed.imageData, + iconData: nil, + fetched: true + ) + } - for messageID in MockDataProvider.messagesWithRepeats { - for repeatRow in MockDataProvider.messageRepeats(for: messageID) { - try await dataStore.saveMessageRepeat(repeatRow) - } - } + // Reaction rows feed the reactor-detail list; the summary drives the badge. + for reacted in MockDataProvider.reactedMessages { + for reaction in MockDataProvider.reactions(for: reacted.messageID) { + try await dataStore.saveReaction(reaction) + } + try await dataStore.updateMessageReactionSummary( + messageID: reacted.messageID, + summary: reacted.summary + ) + } - logger.info( - "Simulator: seeded \(MockDataProvider.contacts.count) contacts and " + - "\(MockDataProvider.channels.count) channels with messages" - ) + for messageID in MockDataProvider.messagesWithRepeats { + for repeatRow in MockDataProvider.messageRepeats(for: messageID) { + try await dataStore.saveMessageRepeat(repeatRow) + } } + + logger.info( + "Simulator: seeded \(MockDataProvider.contacts.count) contacts and " + + "\(MockDataProvider.channels.count) channels with messages" + ) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/SimulatorMockTransport.swift b/MC1Services/Sources/MC1Services/Simulator/SimulatorMockTransport.swift index f32e916e..385892b2 100644 --- a/MC1Services/Sources/MC1Services/Simulator/SimulatorMockTransport.swift +++ b/MC1Services/Sources/MC1Services/Simulator/SimulatorMockTransport.swift @@ -5,37 +5,37 @@ import MeshCore /// This is a minimal stub that fulfills the MeshTransport protocol /// but doesn't actually communicate with a device. actor SimulatorMockTransport: MeshTransport { - private let continuation: AsyncStream.Continuation - private var _isConnected = false + private let continuation: AsyncStream.Continuation + private var _isConnected = false - /// Stream of received data (always empty for simulator) - let receivedData: AsyncStream + /// Stream of received data (always empty for simulator) + let receivedData: AsyncStream - var isConnected: Bool { - _isConnected - } + var isConnected: Bool { + _isConnected + } - init() { - var cont: AsyncStream.Continuation! - receivedData = AsyncStream { continuation in - cont = continuation - } - self.continuation = cont + init() { + var cont: AsyncStream.Continuation! + receivedData = AsyncStream { continuation in + cont = continuation } + continuation = cont + } - func connect() async throws { - _isConnected = true - } + func connect() async throws { + _isConnected = true + } - func disconnect() async { - continuation.finish() - _isConnected = false - } + func disconnect() async { + continuation.finish() + _isConnected = false + } - func send(_ data: Data) async throws { - guard _isConnected else { - throw MeshTransportError.notConnected - } - // Simulator transport doesn't actually send data + func send(_ data: Data) async throws { + guard _isConnected else { + throw MeshTransportError.notConnected } + // Simulator transport doesn't actually send data + } } diff --git a/MC1Services/Sources/MC1Services/Sync/ConnectionManager+SyncRetry.swift b/MC1Services/Sources/MC1Services/Sync/ConnectionManager+SyncRetry.swift index 705ce6a8..49eaf042 100644 --- a/MC1Services/Sources/MC1Services/Sync/ConnectionManager+SyncRetry.swift +++ b/MC1Services/Sources/MC1Services/Sync/ConnectionManager+SyncRetry.swift @@ -5,295 +5,297 @@ import Foundation /// (`resyncTask`, `channelRetryTask`) and `resyncAttemptCount` live on /// `ConnectionManager` itself because extensions cannot add instance storage. extension ConnectionManager { + // MARK: - Retry Policy Constants + + /// Maximum resync attempts before giving up + static let maxResyncAttempts = 3 + + /// Interval between resync attempts + static let resyncInterval: Duration = .seconds(2) + + static let maxChannelRetryAttempts = 2 + static let channelRetryInitialDelay: Duration = .seconds(2) + + // MARK: - Cancellation Helpers + + /// Cancels any resync retry loop in progress. + /// The cancelled task's catch-all calls endResyncActivity(succeeded: false) asynchronously, + /// but callers that also trigger handleDisconnect don't need to wait for it; + /// handleDisconnect zeroes syncActivityCount independently, and the onEnded callback's + /// guard (syncActivityCount > 0) prevents underflow. + func cancelResyncLoop() { + resyncTask?.cancel() + resyncTask = nil + resyncAttemptCount = 0 + } + + func cancelChannelRetry() { + channelRetryTask?.cancel() + channelRetryTask = nil + } + + // MARK: - Initial Sync + + /// Performs initial sync with automatic resync loop on failure. + /// Returns `true` if sync completed successfully, `false` if it failed and a resync loop was started. + /// - Parameters: + /// - radioID: The radio ID for data scoping + /// - services: The service container + /// - transportType: The transport being used (determines whether BLE throttling applies) + /// - context: Optional context string for logging (e.g., "WiFi reconnect") + /// - forceFullSync: When true, forces complete data exchange regardless of sync state + func performInitialSync( + radioID: UUID, + services: ServiceContainer, + transportType: TransportType = .bluetooth, + context: String = "", + forceFullSync: Bool = false + ) async -> Bool { + let channelSyncConfig = currentChannelSyncConfig(for: radioID, transportType: transportType) + do { + let result = try await services.syncCoordinator.onConnectionEstablished( + radioID: radioID, + dependencies: services.syncDependencies, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: "\(detectedPlatform)" + ) + + if !result.channelRetryIndices.isEmpty { + scheduleChannelOnlyRetry( + radioID: radioID, + services: services, + indices: result.channelRetryIndices + ) + } + + if result.isConnectionUsable { + return true + } + + guard connectionIntent.wantsConnection else { return false } + let prefix = context.isEmpty ? "" : "\(context): " + logger.warning("\(prefix)Initial sync did not produce usable contacts, starting resync loop") + startResyncLoop(radioID: radioID, services: services, transportType: transportType, forceFullSync: forceFullSync) + return false + } catch { + // Don't start resync if user disconnected while sync was in progress + guard connectionIntent.wantsConnection else { return false } + let prefix = context.isEmpty ? "" : "\(context): " + logger.warning("\(prefix)Initial sync failed, starting resync loop: \(error.localizedDescription)") + startResyncLoop(radioID: radioID, services: services, transportType: transportType, forceFullSync: forceFullSync) + return false + } + } + + /// Starts a retry loop to resync after initial sync failure. + /// Retries every 2 seconds, shows "Sync Failed" pill and disconnects after 3 failures. + /// Holds a sync activity bracket so the "Syncing" pill stays visible across retries. + /// - Parameters: + /// - radioID: The radio ID for data scoping + /// - services: The ServiceContainer with all services + /// - forceFullSync: When true, forces complete data exchange regardless of sync state + func startResyncLoop( + radioID: UUID, + services: ServiceContainer, + transportType: TransportType = .bluetooth, + forceFullSync: Bool = false + ) { + resyncTask?.cancel() + resyncAttemptCount = 0 + + // Note: No [weak self] needed - Task is stored property, self is @MainActor class. + // Task inherits MainActor isolation, no retain cycle risk. + resyncTask = Task { + // Hold sync activity for the entire resync loop so the "Syncing" pill stays visible. + // Must be inside the task body: placing it before task assignment introduced a + // suspension point where resyncTask was still nil, breaking the dedup guard in + // checkSyncHealth(). + await services.syncCoordinator.beginResyncActivity() + var didEndResyncActivity = false + + while !Task.isCancelled { + try? await Task.sleep(for: Self.resyncInterval) + guard !Task.isCancelled else { break } + + // Fence on services identity so a loop orphaned by a later reconnect + // cycle neither resyncs nor disconnects against a container the manager + // has since replaced; without it the exhaustion branch below could tear + // down a healthy successor connection. + guard connectionIntent.wantsConnection, + connectionState.isOperational, + self.services === services else { break } + + resyncAttemptCount += 1 + logger.info("Resync attempt \(resyncAttemptCount)/\(Self.maxResyncAttempts)") + + let channelSyncConfig = self.currentChannelSyncConfig(for: radioID, transportType: transportType) + let success = await services.syncCoordinator.performResync( + radioID: radioID, + dependencies: services.syncDependencies, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: "\(self.detectedPlatform)" + ) - // MARK: - Retry Policy Constants - - /// Maximum resync attempts before giving up - static let maxResyncAttempts = 3 + if success { + logger.info("Resync succeeded") + resyncAttemptCount = 0 + + // Run post-sync hooks deferred when initial sync failed. + // Guard each await: disconnect(), device switch, or a new + // reconnect cycle may have torn down the connection. + guard !Task.isCancelled, + connectionIntent.wantsConnection, + connectionState.isOperational, + self.services === services else { break } + + // Promote before the post-sync hooks, matching promoteToReady's + // ordering: room re-auth is a bounded but multi-second await, and + // holding the "Syncing" pill and the send queue's .ready gate + // through it delays the user for work that isn't sync. + // Not using promoteToReady() because: (1) its guards (services identity, + // connectionIntent) are already checked above, and (2) it would re-run + // time sync and onDeviceSynced, duplicating the resync loop's own post-sync work. + await services.syncCoordinator.endResyncActivity(succeeded: true) + didEndResyncActivity = true + connectionState = .ready + + await syncDeviceTimeIfNeeded() + + guard !Task.isCancelled, + connectionIntent.wantsConnection, + connectionState.isOperational, + self.services === services else { break } + + // Re-authenticate room sessions before onDeviceSynced to avoid + // BLE contention with stale node cleanup's fire-and-forget Task. + let sessionIDs = sessionsAwaitingReauth + if !sessionIDs.isEmpty { + await services.remoteNodeService.handleBLEReconnection(sessionIDs: sessionIDs) + } + + guard !Task.isCancelled, + connectionIntent.wantsConnection, + connectionState.isOperational, + self.services === services else { break } + + // Only clear consumed IDs after confirming the loop is still valid. + // Any IDs appended during the await (via teardownSessionForReconnect) survive. + sessionsAwaitingReauth.subtract(sessionIDs) + + await onDeviceSynced?() + + break + } - /// Interval between resync attempts - static let resyncInterval: Duration = .seconds(2) + if resyncAttemptCount >= Self.maxResyncAttempts { + logger.warning("Resync failed \(Self.maxResyncAttempts) times, disconnecting") + await services.syncCoordinator.endResyncActivity(succeeded: false) + didEndResyncActivity = true + onResyncFailed?() + await disconnect(reason: .resyncFailed) + break + } + } + + // Catch-all for cancellation or guard exits + if !didEndResyncActivity { + await services.syncCoordinator.endResyncActivity(succeeded: false) + } + + // Only nil resyncTask if this task wasn't cancelled. When startResyncLoop() + // is called while a previous loop is running, it cancels the old task and + // assigns a new one. The old task's catch-all must not nil resyncTask or it + // would destroy the replacement. + if !Task.isCancelled { + resyncTask = nil + } + } + } - static let maxChannelRetryAttempts = 2 - static let channelRetryInitialDelay: Duration = .seconds(2) + /// Schedules a bounded channel-only retry after a partial channel phase. + /// This keeps contacts/messages out of the retry path when the connection is otherwise usable. + func scheduleChannelOnlyRetry( + radioID: UUID, + services: ServiceContainer, + indices: [UInt8] + ) { + let initialIndices = Array(Set(indices)).sorted() + guard !initialIndices.isEmpty else { return } - // MARK: - Cancellation Helpers + channelRetryTask?.cancel() - /// Cancels any resync retry loop in progress. - /// The cancelled task's catch-all calls endResyncActivity(succeeded: false) asynchronously, - /// but callers that also trigger handleDisconnect don't need to wait for it; - /// handleDisconnect zeroes syncActivityCount independently, and the onEnded callback's - /// guard (syncActivityCount > 0) prevents underflow. - func cancelResyncLoop() { - resyncTask?.cancel() - resyncTask = nil - resyncAttemptCount = 0 - } + channelRetryTask = Task { + var pendingIndices = initialIndices - func cancelChannelRetry() { - channelRetryTask?.cancel() - channelRetryTask = nil - } + for attempt in 1...Self.maxChannelRetryAttempts { + guard !Task.isCancelled else { break } - // MARK: - Initial Sync - - /// Performs initial sync with automatic resync loop on failure. - /// Returns `true` if sync completed successfully, `false` if it failed and a resync loop was started. - /// - Parameters: - /// - radioID: The radio ID for data scoping - /// - services: The service container - /// - transportType: The transport being used (determines whether BLE throttling applies) - /// - context: Optional context string for logging (e.g., "WiFi reconnect") - /// - forceFullSync: When true, forces complete data exchange regardless of sync state - func performInitialSync( - radioID: UUID, - services: ServiceContainer, - transportType: TransportType = .bluetooth, - context: String = "", - forceFullSync: Bool = false - ) async -> Bool { - let channelSyncConfig = currentChannelSyncConfig(for: radioID, transportType: transportType) + let delaySeconds = 2 << (attempt - 1) + let delay = max(Self.channelRetryInitialDelay, .seconds(delaySeconds)) do { - let result = try await services.syncCoordinator.onConnectionEstablished( - radioID: radioID, - dependencies: services.syncDependencies, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: "\(self.detectedPlatform)" - ) - - if !result.channelRetryIndices.isEmpty { - scheduleChannelOnlyRetry( - radioID: radioID, - services: services, - indices: result.channelRetryIndices - ) - } - - if result.isConnectionUsable { - return true - } - - guard connectionIntent.wantsConnection else { return false } - let prefix = context.isEmpty ? "" : "\(context): " - logger.warning("\(prefix)Initial sync did not produce usable contacts, starting resync loop") - startResyncLoop(radioID: radioID, services: services, transportType: transportType, forceFullSync: forceFullSync) - return false + try await Task.sleep(for: delay) } catch { - // Don't start resync if user disconnected while sync was in progress - guard connectionIntent.wantsConnection else { return false } - let prefix = context.isEmpty ? "" : "\(context): " - logger.warning("\(prefix)Initial sync failed, starting resync loop: \(error.localizedDescription)") - startResyncLoop(radioID: radioID, services: services, transportType: transportType, forceFullSync: forceFullSync) - return false + break } - } - /// Starts a retry loop to resync after initial sync failure. - /// Retries every 2 seconds, shows "Sync Failed" pill and disconnects after 3 failures. - /// Holds a sync activity bracket so the "Syncing" pill stays visible across retries. - /// - Parameters: - /// - radioID: The radio ID for data scoping - /// - services: The ServiceContainer with all services - /// - forceFullSync: When true, forces complete data exchange regardless of sync state - func startResyncLoop( - radioID: UUID, - services: ServiceContainer, - transportType: TransportType = .bluetooth, - forceFullSync: Bool = false - ) { - resyncTask?.cancel() - resyncAttemptCount = 0 - - // Note: No [weak self] needed - Task is stored property, self is @MainActor class. - // Task inherits MainActor isolation, no retain cycle risk. - resyncTask = Task { - // Hold sync activity for the entire resync loop so the "Syncing" pill stays visible. - // Must be inside the task body: placing it before task assignment introduced a - // suspension point where resyncTask was still nil, breaking the dedup guard in - // checkSyncHealth(). - await services.syncCoordinator.beginResyncActivity() - var didEndResyncActivity = false - - while !Task.isCancelled { - try? await Task.sleep(for: Self.resyncInterval) - guard !Task.isCancelled else { break } - - guard connectionIntent.wantsConnection, - connectionState.isOperational else { break } - - resyncAttemptCount += 1 - logger.info("Resync attempt \(resyncAttemptCount)/\(Self.maxResyncAttempts)") - - let channelSyncConfig = self.currentChannelSyncConfig(for: radioID, transportType: transportType) - let success = await services.syncCoordinator.performResync( - radioID: radioID, - dependencies: services.syncDependencies, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: "\(self.detectedPlatform)" - ) - - if success { - logger.info("Resync succeeded") - resyncAttemptCount = 0 - - // Run post-sync hooks deferred when initial sync failed. - // Guard each await: disconnect(), device switch, or a new - // reconnect cycle may have torn down the connection. - guard !Task.isCancelled, - connectionIntent.wantsConnection, - connectionState.isOperational, - self.services === services else { break } - - await syncDeviceTimeIfNeeded() - - guard !Task.isCancelled, - connectionIntent.wantsConnection, - connectionState.isOperational, - self.services === services else { break } - - // Re-authenticate room sessions before onDeviceSynced to avoid - // BLE contention with stale node cleanup's fire-and-forget Task. - let sessionIDs = sessionsAwaitingReauth - if !sessionIDs.isEmpty { - await services.remoteNodeService.handleBLEReconnection(sessionIDs: sessionIDs) - } - - guard !Task.isCancelled, - connectionIntent.wantsConnection, - connectionState.isOperational, - self.services === services else { break } - - // Report success only after confirming the loop is still authoritative. - // Earlier placement fired the "Ready" toast before these guards, - // relying on handleDisconnect as an accidental backstop. - await services.syncCoordinator.endResyncActivity(succeeded: true) - didEndResyncActivity = true - - // Only clear consumed IDs after confirming the loop is still valid. - // Any IDs appended during the await (via teardownSessionForReconnect) survive. - sessionsAwaitingReauth.subtract(sessionIDs) - - // Promote from .syncing to .ready now that sync completed. - // Not using promoteToReady() because: (1) its guards (services identity, - // connectionIntent) are already checked above, and (2) it would re-run - // time sync and onDeviceSynced, duplicating the resync loop's own post-sync work. - connectionState = .ready - - await onDeviceSynced?() - - break - } - - if resyncAttemptCount >= Self.maxResyncAttempts { - logger.warning("Resync failed \(Self.maxResyncAttempts) times, disconnecting") - await services.syncCoordinator.endResyncActivity(succeeded: false) - didEndResyncActivity = true - onResyncFailed?() - await disconnect(reason: .resyncFailed) - break - } - } - - // Catch-all for cancellation or guard exits - if !didEndResyncActivity { - await services.syncCoordinator.endResyncActivity(succeeded: false) - } - - // Only nil resyncTask if this task wasn't cancelled. When startResyncLoop() - // is called while a previous loop is running, it cancels the old task and - // assigns a new one. The old task's catch-all must not nil resyncTask or it - // would destroy the replacement. - if !Task.isCancelled { - resyncTask = nil - } - } - } + guard connectionIntent.wantsConnection, + connectionState.isOperational, + self.services === services else { break } - /// Schedules a bounded channel-only retry after a partial channel phase. - /// This keeps contacts/messages out of the retry path when the connection is otherwise usable. - func scheduleChannelOnlyRetry( - radioID: UUID, - services: ServiceContainer, - indices: [UInt8] - ) { - let initialIndices = Array(Set(indices)).sorted() - guard !initialIndices.isEmpty else { return } - - channelRetryTask?.cancel() - - channelRetryTask = Task { - var pendingIndices = initialIndices - - for attempt in 1...Self.maxChannelRetryAttempts { - guard !Task.isCancelled else { break } - - let delaySeconds = 2 << (attempt - 1) - let delay = max(Self.channelRetryInitialDelay, .seconds(delaySeconds)) - do { - try await Task.sleep(for: delay) - } catch { - break - } - - guard connectionIntent.wantsConnection, - connectionState.isOperational, - self.services === services else { break } - - logger.info("Channel-only retry \(attempt)/\(Self.maxChannelRetryAttempts) for \(pendingIndices.count) channel(s)") - let result = await services.syncCoordinator.retryChannels( - radioID: radioID, - channelService: services.channelService, - indices: pendingIndices - ) - - if result.isComplete { - logger.info("Channel-only retry recovered all pending channels") - pendingIndices = [] - break - } - - pendingIndices = result.retryableIndices - guard !pendingIndices.isEmpty else { - logger.warning("Channel-only retry stopped with non-retryable channel errors") - break - } - } - - if !Task.isCancelled { - if !pendingIndices.isEmpty { - logger.warning("Channel-only retry exhausted with \(pendingIndices.count) retryable channel(s) still pending") - } - channelRetryTask = nil - } + logger.info("Channel-only retry \(attempt)/\(Self.maxChannelRetryAttempts) for \(pendingIndices.count) channel(s)") + let result = await services.syncCoordinator.retryChannels( + radioID: radioID, + channelService: services.channelService, + indices: pendingIndices + ) + + if result.isComplete { + logger.info("Channel-only retry recovered all pending channels") + pendingIndices = [] + break } - } - // MARK: - Channel Sync Configuration - - /// Builds a channel sync config for the current device and transport. - /// BLE and WiFi both use platform-specific values because ESP32 radios can saturate either transport. - func currentChannelSyncConfig(for radioID: UUID, transportType: TransportType) -> ChannelSyncConfig { - // Policy gate for pipelined channel reads. nRF52 over BLE amortizes the radio's - // per-write slave-latency penalty; ESP32 over WiFi avoids the per-round-trip TCP stall - // that makes serial channel reads roughly 200ms each. ESP32 over BLE has a write-only - // characteristic (no Write Commands), so it stays serial. MeshCoreSession.getChannels - // enforces a second capability gate on the transport, so this is the policy half of a - // two-gate design and the downstream re-check is intentional, not dead. - let usePipelinedChannelRead: Bool - switch (detectedPlatform, transportType) { - case (.nrf52, .bluetooth): usePipelinedChannelRead = true - case (.esp32, .wifi): usePipelinedChannelRead = true - default: usePipelinedChannelRead = false + pendingIndices = result.retryableIndices + guard !pendingIndices.isEmpty else { + logger.warning("Channel-only retry stopped with non-retryable channel errors") + break } + } - return detectedPlatform.channelSyncConfig( - lastCleanChannelSync: lastCleanChannelSync?.radioID == radioID - ? lastCleanChannelSync?.completedAt : nil, - lastAttemptedChannelSync: lastAttemptedChannelSync?.radioID == radioID - ? lastAttemptedChannelSync?.attemptedAt : nil, - usePipelinedChannelRead: usePipelinedChannelRead - ) + if !Task.isCancelled { + if !pendingIndices.isEmpty { + logger.warning("Channel-only retry exhausted with \(pendingIndices.count) retryable channel(s) still pending") + } + channelRetryTask = nil + } } + } + + // MARK: - Channel Sync Configuration + + /// Builds a channel sync config for the current device and transport. + /// BLE and WiFi both use platform-specific values because ESP32 radios can saturate either transport. + func currentChannelSyncConfig(for radioID: UUID, transportType: TransportType) -> ChannelSyncConfig { + // Policy gate for pipelined channel reads. nRF52 over BLE amortizes the radio's + // per-write slave-latency penalty; ESP32 over WiFi avoids the per-round-trip TCP stall + // that makes serial channel reads roughly 200ms each. ESP32 over BLE has a write-only + // characteristic (no Write Commands), so it stays serial. MeshCoreSession.getChannels + // enforces a second capability gate on the transport, so this is the policy half of a + // two-gate design and the downstream re-check is intentional, not dead. + let usePipelinedChannelRead = switch (detectedPlatform, transportType) { + case (.nrf52, .bluetooth): true + case (.esp32, .wifi): true + default: false + } + + return detectedPlatform.channelSyncConfig( + lastCleanChannelSync: lastCleanChannelSync?.radioID == radioID + ? lastCleanChannelSync?.completedAt : nil, + lastAttemptedChannelSync: lastAttemptedChannelSync?.radioID == radioID + ? lastAttemptedChannelSync?.attemptedAt : nil, + usePipelinedChannelRead: usePipelinedChannelRead + ) + } } diff --git a/MC1Services/Sources/MC1Services/Sync/DevicePlatform+SyncThrottling.swift b/MC1Services/Sources/MC1Services/Sync/DevicePlatform+SyncThrottling.swift index 979020a6..8f571263 100644 --- a/MC1Services/Sources/MC1Services/Sync/DevicePlatform+SyncThrottling.swift +++ b/MC1Services/Sources/MC1Services/Sync/DevicePlatform+SyncThrottling.swift @@ -3,29 +3,28 @@ import Foundation // MARK: - Channel Sync Configuration extension DevicePlatform { - - /// If channels were synced more recently than this, skip channel re-sync on resync. - /// Only enabled for ESP32 where channel re-sync wastes scarce connection time. - /// Channel skipping is a correctness tradeoff (not just performance), so it stays - /// disabled for unknown platforms until field evidence warrants it. - var channelSyncSkipWindow: Duration { - switch self { - case .esp32: .seconds(30) - case .nrf52, .unknown: .zero - } + /// If channels were synced more recently than this, skip channel re-sync on resync. + /// Only enabled for ESP32 where channel re-sync wastes scarce connection time. + /// Channel skipping is a correctness tradeoff (not just performance), so it stays + /// disabled for unknown platforms until field evidence warrants it. + var channelSyncSkipWindow: Duration { + switch self { + case .esp32: .seconds(30) + case .nrf52, .unknown: .zero } + } - /// Builds a channel sync config for a sync operation. - func channelSyncConfig( - lastCleanChannelSync: Date?, - lastAttemptedChannelSync: Date? = nil, - usePipelinedChannelRead: Bool = false - ) -> ChannelSyncConfig { - ChannelSyncConfig( - channelSyncSkipWindow: channelSyncSkipWindow, - lastCleanChannelSync: lastCleanChannelSync, - lastAttemptedChannelSync: lastAttemptedChannelSync, - usePipelinedChannelRead: usePipelinedChannelRead - ) - } + /// Builds a channel sync config for a sync operation. + func channelSyncConfig( + lastCleanChannelSync: Date?, + lastAttemptedChannelSync: Date? = nil, + usePipelinedChannelRead: Bool = false + ) -> ChannelSyncConfig { + ChannelSyncConfig( + channelSyncSkipWindow: channelSyncSkipWindow, + lastCleanChannelSync: lastCleanChannelSync, + lastAttemptedChannelSync: lastAttemptedChannelSync, + usePipelinedChannelRead: usePipelinedChannelRead + ) + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift index f9bf159e..a788755d 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift @@ -4,180 +4,178 @@ import Foundation // MARK: - Message Handler Helpers extension SyncCoordinator { - - struct RxLogLookupResult { - let pathNodes: Data? - let pathLength: UInt8 - let packetHash: String? - let routeType: RouteType? - let regionScope: String? + struct RxLogLookupResult { + let pathNodes: Data? + let pathLength: UInt8 + let packetHash: String? + let routeType: RouteType? + let regionScope: String? + } + + /// Looks up path data from an RxLogEntry to correlate with an incoming message. + func lookupRxLogEntry( + dependencies: SyncDependencies, + radioID: UUID, + channelIndex: UInt8?, + senderTimestamp: UInt32, + senderPublicKeyPrefix: Data?, + defaultPathLength: UInt8 + ) async -> RxLogLookupResult { + if let channelIndex { + logger.debug("Looking up RxLogEntry for channel \(channelIndex) with senderTimestamp: \(senderTimestamp)") } - /// Looks up path data from an RxLogEntry to correlate with an incoming message. - func lookupRxLogEntry( - dependencies: SyncDependencies, - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32, - senderPublicKeyPrefix: Data?, - defaultPathLength: UInt8 - ) async -> RxLogLookupResult { - if let channelIndex { - logger.debug("Looking up RxLogEntry for channel \(channelIndex) with senderTimestamp: \(senderTimestamp)") + do { + if let rxEntry = try await dependencies.dataStore.findRxLogEntry( + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: senderTimestamp + ) { + let pathLength = rxEntry.pathLength + let pathNodes = rxEntry.pathNodes + if channelIndex != nil { + logger.info("Correlated channel message to RxLogEntry: pathLength=\(pathLength), pathNodes=\(pathNodes.count) bytes") + } else { + logger.debug("Correlated incoming direct message to RxLogEntry, pathLength: \(pathLength), pathNodes: \(pathNodes.count) bytes") } - - do { - if let rxEntry = try await dependencies.dataStore.findRxLogEntry( - radioID: radioID, - channelIndex: channelIndex, - senderTimestamp: senderTimestamp - ) { - let pathLength = rxEntry.pathLength - let pathNodes = rxEntry.pathNodes - if channelIndex != nil { - logger.info("Correlated channel message to RxLogEntry: pathLength=\(pathLength), pathNodes=\(pathNodes.count) bytes") - } else { - logger.debug("Correlated incoming direct message to RxLogEntry, pathLength: \(pathLength), pathNodes: \(pathNodes.count) bytes") - } - return RxLogLookupResult(pathNodes: pathNodes, pathLength: pathLength, packetHash: rxEntry.packetHash, routeType: rxEntry.routeType, regionScope: rxEntry.regionScope) - } - - // Fallback for DMs: if timestamp-based lookup failed (e.g., RxLog decryption - // hadn't extracted the timestamp yet), try matching by sender prefix byte - // in the raw packet payload within a recent time window. - if channelIndex == nil, - let prefixByte = senderPublicKeyPrefix?.first { - let lookbackWindow = Date().addingTimeInterval(-30) - if let rxEntry = try await dependencies.dataStore.findRxLogEntryBySenderPrefix( - radioID: radioID, - senderPrefixByte: prefixByte, - receivedSince: lookbackWindow - ) { - logger.debug("Correlated DM to RxLogEntry via sender prefix fallback, pathLength: \(rxEntry.pathLength)") - return RxLogLookupResult(pathNodes: rxEntry.pathNodes, pathLength: rxEntry.pathLength, packetHash: rxEntry.packetHash, routeType: rxEntry.routeType, regionScope: rxEntry.regionScope) - } - logger.debug("No RxLogEntry found for direct message (primary + fallback), senderTimestamp: \(senderTimestamp)") - } else if let channelIndex { - logger.warning("No RxLogEntry found for channel \(channelIndex), senderTimestamp: \(senderTimestamp)") - } else { - logger.debug("No RxLogEntry found for direct message, senderTimestamp: \(senderTimestamp)") - } - } catch { - if channelIndex != nil { - logger.error("Failed to lookup RxLogEntry for channel message: \(error)") - } else { - logger.error("Failed to lookup RxLogEntry for direct message: \(error)") - } + return RxLogLookupResult(pathNodes: pathNodes, pathLength: pathLength, packetHash: rxEntry.packetHash, routeType: rxEntry.routeType, regionScope: rxEntry.regionScope) + } + + // Fallback for DMs: if timestamp-based lookup failed (e.g., RxLog decryption + // hadn't extracted the timestamp yet), try matching by sender prefix byte + // in the raw packet payload within a recent time window. + if channelIndex == nil, + let prefixByte = senderPublicKeyPrefix?.first { + let lookbackWindow = Date().addingTimeInterval(-30) + if let rxEntry = try await dependencies.dataStore.findRxLogEntryBySenderPrefix( + radioID: radioID, + senderPrefixByte: prefixByte, + receivedSince: lookbackWindow + ) { + logger.debug("Correlated DM to RxLogEntry via sender prefix fallback, pathLength: \(rxEntry.pathLength)") + return RxLogLookupResult(pathNodes: rxEntry.pathNodes, pathLength: rxEntry.pathLength, packetHash: rxEntry.packetHash, routeType: rxEntry.routeType, regionScope: rxEntry.regionScope) } - - return RxLogLookupResult(pathNodes: nil, pathLength: defaultPathLength, packetHash: nil, routeType: nil, regionScope: nil) + logger.debug("No RxLogEntry found for direct message (primary + fallback), senderTimestamp: \(senderTimestamp)") + } else if let channelIndex { + logger.warning("No RxLogEntry found for channel \(channelIndex), senderTimestamp: \(senderTimestamp)") + } else { + logger.debug("No RxLogEntry found for direct message, senderTimestamp: \(senderTimestamp)") + } + } catch { + if channelIndex != nil { + logger.error("Failed to lookup RxLogEntry for channel message: \(error)") + } else { + logger.error("Failed to lookup RxLogEntry for direct message: \(error)") + } } - /// Increments unread counts and posts a notification for a direct message. - func updateDMUnreadsAndNotify( - messageDTO: MessageDTO, - contactID: UUID, - contact: ContactDTO?, - messageText: String, - hasSelfMention: Bool, - dependencies: SyncDependencies - ) async throws { - // Only increment unread if user is NOT currently viewing this contact's chat - let isViewingContact = await dependencies.notificationService.activeContactID == contactID - if !isViewingContact { - try await dependencies.dataStore.incrementUnreadCount(contactID: contactID) - - // Increment unread mention count if message contains self-mention - if hasSelfMention { - try await dependencies.dataStore.incrementUnreadMentionCount(contactID: contactID) - } - } - - await dependencies.notificationService.postDirectMessageNotification( - from: contact?.displayName ?? "Unknown", - contactID: contactID, - messageText: messageText, - messageID: messageDTO.id, - isMuted: contact?.isMuted ?? false - ) - await dependencies.notificationService.updateBadgeCount() + return RxLogLookupResult(pathNodes: nil, pathLength: defaultPathLength, packetHash: nil, routeType: nil, regionScope: nil) + } + + /// Increments unread counts and posts a notification for a direct message. + func updateDMUnreadsAndNotify( + messageDTO: MessageDTO, + contactID: UUID, + contact: ContactDTO?, + messageText: String, + hasSelfMention: Bool, + dependencies: SyncDependencies + ) async throws { + // Only increment unread if user is NOT currently viewing this contact's chat + let isViewingContact = await dependencies.notificationService.activeContactID == contactID + if !isViewingContact { + try await dependencies.dataStore.incrementUnreadCount(contactID: contactID) + + // Increment unread mention count if message contains self-mention + if hasSelfMention { + try await dependencies.dataStore.incrementUnreadMentionCount(contactID: contactID) + } } - /// Increments unread counts, posts a notification, and notifies real-time listeners for a channel message. - func updateChannelUnreadsAndNotify( - messageDTO: MessageDTO, - channel: ChannelDTO?, - channelIndex: UInt8, - senderNodeName: String?, - messageText: String, - timestamp: UInt32, - hasSelfMention: Bool, - radioID: UUID, - dependencies: SyncDependencies - ) async throws { - if let channelID = channel?.id { - // Only increment unread if user is NOT currently viewing this channel - let activeIndex = await dependencies.notificationService.activeChannelIndex - let activeRadioID = await dependencies.notificationService.activeChannelRadioID - let isViewingChannel = activeIndex == channel?.index && activeRadioID == channel?.radioID - if !isViewingChannel { - try await dependencies.dataStore.incrementChannelUnreadCount(channelID: channelID) - - // Increment unread mention count if message contains self-mention - if hasSelfMention { - try await dependencies.dataStore.incrementChannelUnreadMentionCount(channelID: channelID) - } - } + await dependencies.notificationService.postDirectMessageNotification( + from: contact?.displayName ?? "Unknown", + contactID: contactID, + messageText: messageText, + messageID: messageDTO.id, + isMuted: contact?.isMuted ?? false + ) + await dependencies.notificationService.updateBadgeCount() + } + + /// Increments unread counts, posts a notification, and notifies real-time listeners for a channel message. + func updateChannelUnreadsAndNotify( + messageDTO: MessageDTO, + channel: ChannelDTO?, + channelIndex: UInt8, + senderNodeName: String?, + messageText: String, + timestamp: UInt32, + hasSelfMention: Bool, + radioID: UUID, + dependencies: SyncDependencies + ) async throws { + if let channelID = channel?.id { + // Only increment unread if user is NOT currently viewing this channel + let activeIndex = await dependencies.notificationService.activeChannelIndex + let activeRadioID = await dependencies.notificationService.activeChannelRadioID + let isViewingChannel = activeIndex == channel?.index && activeRadioID == channel?.radioID + if !isViewingChannel { + try await dependencies.dataStore.incrementChannelUnreadCount(channelID: channelID) + + // Increment unread mention count if message contains self-mention + if hasSelfMention { + try await dependencies.dataStore.incrementChannelUnreadMentionCount(channelID: channelID) } - if Self.shouldPostChannelNotification(forResolvedChannel: channel) { - await dependencies.notificationService.postChannelMessageNotification( - channelName: channel?.name ?? "Channel \(channelIndex)", - channelIndex: channelIndex, - radioID: radioID, - senderName: senderNodeName, - messageText: messageText, - messageID: messageDTO.id, - notificationLevel: channel?.notificationLevel ?? .all, - hasSelfMention: hasSelfMention - ) - } else { - recordUnresolvedChannel( - channelIndex: channelIndex, - radioID: radioID, - senderTimestamp: timestamp - ) - } - await dependencies.notificationService.updateBadgeCount() - - // Broadcast for real-time chat updates - dataEventBroadcaster.yield(.channelMessageReceived(message: messageDTO, channelIndex: channelIndex)) + } } - - private func recordUnresolvedChannel( - channelIndex: UInt8, - radioID: UUID, - senderTimestamp: UInt32 - ) { - let isNewIndex = unresolvedChannelIndices.insert(channelIndex).inserted - logger.warning( - "Suppressing notification for unresolved channel \(channelIndex) on device \(radioID), senderTimestamp: \(senderTimestamp) — no local channel for this slot" - ) - - let now = Date() - let shouldEmitSummary: Bool - if isNewIndex { - shouldEmitSummary = true - } else if let lastSummary = lastUnresolvedChannelSummaryAt { - shouldEmitSummary = now.timeIntervalSince(lastSummary) >= unresolvedChannelSummaryIntervalSeconds - } else { - shouldEmitSummary = true - } - - guard shouldEmitSummary else { return } - let sortedIndices = unresolvedChannelIndices.sorted() - logger.warning( - "Unresolved channel summary: total=\(sortedIndices.count), indices=\(sortedIndices)" - ) - lastUnresolvedChannelSummaryAt = now + if Self.shouldPostChannelNotification(forResolvedChannel: channel) { + await dependencies.notificationService.postChannelMessageNotification( + channelName: channel?.name ?? "Channel \(channelIndex)", + channelIndex: channelIndex, + radioID: radioID, + senderName: senderNodeName, + messageText: messageText, + messageID: messageDTO.id, + notificationLevel: channel?.notificationLevel ?? .all, + hasSelfMention: hasSelfMention + ) + } else { + recordUnresolvedChannel( + channelIndex: channelIndex, + radioID: radioID, + senderTimestamp: timestamp + ) } + await dependencies.notificationService.updateBadgeCount() + + // Broadcast for real-time chat updates + dataEventBroadcaster.yield(.channelMessageReceived(message: messageDTO, channelIndex: channelIndex)) + } + + private func recordUnresolvedChannel( + channelIndex: UInt8, + radioID: UUID, + senderTimestamp: UInt32 + ) { + let isNewIndex = unresolvedChannelIndices.insert(channelIndex).inserted + logger.warning( + "Suppressing notification for unresolved channel \(channelIndex) on device \(radioID), senderTimestamp: \(senderTimestamp) — no local channel for this slot" + ) + + let now = Date() + let shouldEmitSummary: Bool = if isNewIndex { + true + } else if let lastSummary = lastUnresolvedChannelSummaryAt { + now.timeIntervalSince(lastSummary) >= unresolvedChannelSummaryIntervalSeconds + } else { + true + } + + guard shouldEmitSummary else { return } + let sortedIndices = unresolvedChannelIndices.sorted() + logger.warning( + "Unresolved channel summary: total=\(sortedIndices.count), indices=\(sortedIndices)" + ) + lastUnresolvedChannelSummaryAt = now + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift index 4d92da4a..37a68a1c 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift @@ -5,570 +5,568 @@ import Foundation // MARK: - Message & Discovery Handler Wiring extension SyncCoordinator { - - // MARK: - Message Handler Wiring - - func wireMessageHandlers(dependencies: SyncDependencies, radioID: UUID) async { - logger.info("Wiring message handlers for device \(radioID)") - - // Populate blocked contacts cache - await refreshBlockedContactsCache(radioID: radioID, dataStore: dependencies.dataStore) - - // Cache device node name for self-mention detection - let device = try? await dependencies.dataStore.fetchDevice(radioID: radioID) - let selfNodeName = device?.nodeName ?? "" - - await wireContactMessageHandler(dependencies: dependencies, radioID: radioID, selfNodeName: selfNodeName) - await wireChannelMessageHandler(dependencies: dependencies, radioID: radioID, selfNodeName: selfNodeName) - await wireSignedMessageHandler(dependencies: dependencies) - await wireCLIMessageHandler(dependencies: dependencies) - - logger.info("Message handlers wired successfully") + // MARK: - Message Handler Wiring + + func wireMessageHandlers(dependencies: SyncDependencies, radioID: UUID) async { + logger.info("Wiring message handlers for device \(radioID)") + + // Populate blocked contacts cache + await refreshBlockedContactsCache(radioID: radioID, dataStore: dependencies.dataStore) + + // Cache device node name for self-mention detection + let device = try? await dependencies.dataStore.fetchDevice(radioID: radioID) + let selfNodeName = device?.nodeName ?? "" + + await wireContactMessageHandler(dependencies: dependencies, radioID: radioID, selfNodeName: selfNodeName) + await wireChannelMessageHandler(dependencies: dependencies, radioID: radioID, selfNodeName: selfNodeName) + await wireSignedMessageHandler(dependencies: dependencies) + await wireCLIMessageHandler(dependencies: dependencies) + + logger.info("Message handlers wired successfully") + } + + // MARK: - Contact Message Handler + + private func wireContactMessageHandler(dependencies: SyncDependencies, radioID: UUID, selfNodeName: String) async { + await dependencies.messagePollingService.setContactMessageHandler { [weak self] message, contact, context in + guard let self else { return } + await handleIncomingMessage( + kind: .direct(message, contact: contact), + context: context, + dependencies: dependencies, + radioID: radioID, + selfNodeName: selfNodeName + ) } - - // MARK: - Contact Message Handler - - private func wireContactMessageHandler(dependencies: SyncDependencies, radioID: UUID, selfNodeName: String) async { - await dependencies.messagePollingService.setContactMessageHandler { [weak self] message, contact, context in - guard let self else { return } - await self.handleIncomingMessage( - kind: .direct(message, contact: contact), - context: context, - dependencies: dependencies, - radioID: radioID, - selfNodeName: selfNodeName - ) - } + } + + // MARK: - Channel Message Handler + + private func wireChannelMessageHandler(dependencies: SyncDependencies, radioID: UUID, selfNodeName: String) async { + await dependencies.messagePollingService.setChannelMessageHandler { [weak self] message, channel, context in + guard let self else { return } + await handleIncomingMessage( + kind: .channel(message, channel: channel), + context: context, + dependencies: dependencies, + radioID: radioID, + selfNodeName: selfNodeName + ) } - - // MARK: - Channel Message Handler - - private func wireChannelMessageHandler(dependencies: SyncDependencies, radioID: UUID, selfNodeName: String) async { - await dependencies.messagePollingService.setChannelMessageHandler { [weak self] message, channel, context in - guard let self else { return } - await self.handleIncomingMessage( - kind: .channel(message, channel: channel), - context: context, - dependencies: dependencies, - radioID: radioID, - selfNodeName: selfNodeName - ) - } + } + + // MARK: - Incoming Message Pipeline + + /// Discriminates the two text-message ingestion paths, carrying the wire + /// message and the resolved conversation for each. + private enum IncomingMessageKind { + case direct(ContactMessage, contact: ContactDTO?) + case channel(ChannelMessage, channel: ChannelDTO?) + + /// Log noun distinguishing the two paths in shared diagnostics. + var logLabel: String { + switch self { + case .direct: "direct" + case .channel: "channel" + } + } + } + + /// Shared ingestion pipeline for incoming direct and channel messages: + /// timestamp correction, RX-log path correlation, dedup, reaction + /// short-circuit, persistence, unread/notification updates, and UI refresh. + private func handleIncomingMessage( + kind: IncomingMessageKind, + context: DeliveryContext, + dependencies: SyncDependencies, + radioID: UUID, + selfNodeName: String + ) async { + // Per-kind wire fields. Channel messages embed the sender as a + // "NodeName: text" prefix; direct messages carry the sender key prefix. + let text: String + let senderNodeName: String? + let senderTimestampDate: Date + let textTypeRaw: UInt8 + let snr: Double? + let reportedPathLength: UInt8 + let contactID: UUID? + let channelIndex: UInt8? + let senderKeyPrefix: Data? + switch kind { + case let .direct(message, contact): + // The firmware cannot surface a self-DM here: decrypt runs against a + // contact's ECDH shared secret and the local self_id is never in + // contacts[], so an echo of the user's own DM never reaches this arm. + text = message.text + senderNodeName = nil + senderTimestampDate = message.senderTimestamp + textTypeRaw = message.textType + snr = message.snr + reportedPathLength = message.pathLength + contactID = contact?.id + channelIndex = nil + senderKeyPrefix = message.senderPublicKeyPrefix + case let .channel(message, _): + // Parse "NodeName: text" format for sender name + (senderNodeName, text) = Self.parseChannelMessage(message.text) + senderTimestampDate = message.senderTimestamp + textTypeRaw = message.textType + snr = message.snr + reportedPathLength = message.pathLength + contactID = nil + channelIndex = message.channelIndex + senderKeyPrefix = nil } - // MARK: - Incoming Message Pipeline + let timestamp = UInt32(senderTimestampDate.timeIntervalSince1970) - /// Discriminates the two text-message ingestion paths, carrying the wire - /// message and the resolved conversation for each. - private enum IncomingMessageKind { - case direct(ContactMessage, contact: ContactDTO?) - case channel(ChannelMessage, channel: ChannelDTO?) + // Correct invalid timestamps (sender clock wrong) + let receiveTime = Date() + let (finalTimestamp, timestampCorrected) = Self.correctTimestampIfNeeded(timestamp, receiveTime: receiveTime) + if timestampCorrected { + logger.debug("Corrected invalid \(kind.logLabel) message timestamp from \(Date(timeIntervalSince1970: TimeInterval(timestamp))) to \(receiveTime)") + } - /// Log noun distinguishing the two paths in shared diagnostics. - var logLabel: String { - switch self { - case .direct: "direct" - case .channel: "channel" - } - } + let sortDate = Self.sortDate(for: context, receiveTime: receiveTime) + + // Look up path data from RxLogEntry using the sender timestamp stored + // during decryption (for direct messages, channelIndex is nil) + let rxResult = await lookupRxLogEntry( + dependencies: dependencies, + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: timestamp, + senderPublicKeyPrefix: senderKeyPrefix, + defaultPathLength: reportedPathLength + ) + + // Use content-based key for dedup (stable across retry attempts). + // The RX log packetHash is per-encrypted-packet and differs between + // retries with different attempt counters, so it must not drive dedup. + let deduplicationKey = Self.fallbackDeduplicationKey( + contactID: contactID, channelIndex: channelIndex, + senderNodeName: senderNodeName, timestamp: timestamp, content: text + ) + + // Check for self-mention before creating DTO + // For channel messages, filter out messages where the user mentions themselves + let hasSelfMention: Bool = switch kind { + case .direct: + !selfNodeName.isEmpty && + MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) + case .channel: + !selfNodeName.isEmpty && + senderNodeName != selfNodeName && + MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) } - /// Shared ingestion pipeline for incoming direct and channel messages: - /// timestamp correction, RX-log path correlation, dedup, reaction - /// short-circuit, persistence, unread/notification updates, and UI refresh. - private func handleIncomingMessage( - kind: IncomingMessageKind, - context: DeliveryContext, - dependencies: SyncDependencies, - radioID: UUID, - selfNodeName: String - ) async { - // Per-kind wire fields. Channel messages embed the sender as a - // "NodeName: text" prefix; direct messages carry the sender key prefix. - let text: String - let senderNodeName: String? - let senderTimestampDate: Date - let textTypeRaw: UInt8 - let snr: Double? - let reportedPathLength: UInt8 - let contactID: UUID? - let channelIndex: UInt8? - let senderKeyPrefix: Data? - switch kind { - case .direct(let message, let contact): - // The firmware cannot surface a self-DM here: decrypt runs against a - // contact's ECDH shared secret and the local self_id is never in - // contacts[], so an echo of the user's own DM never reaches this arm. - text = message.text - senderNodeName = nil - senderTimestampDate = message.senderTimestamp - textTypeRaw = message.textType - snr = message.snr - reportedPathLength = message.pathLength - contactID = contact?.id - channelIndex = nil - senderKeyPrefix = message.senderPublicKeyPrefix - case .channel(let message, _): - // Parse "NodeName: text" format for sender name - (senderNodeName, text) = Self.parseChannelMessage(message.text) - senderTimestampDate = message.senderTimestamp - textTypeRaw = message.textType - snr = message.snr - reportedPathLength = message.pathLength - contactID = nil - channelIndex = message.channelIndex - senderKeyPrefix = nil - } + // Clamp an unknown wire textType to .plain. MessageDTO decodes textType + // non-optionally, so an out-of-range raw value would throw and fail the + // whole envelope decode on backup round-trip; guaranteeing only the + // known cases reach persistence keeps that round-trip safe. + let resolvedTextType: TextType + if let parsed = TextType(rawValue: textTypeRaw) { + resolvedTextType = parsed + } else { + logger.warning("Unknown \(kind.logLabel) message textType raw=\(textTypeRaw); clamping to .plain") + resolvedTextType = .plain + } - let timestamp = UInt32(senderTimestampDate.timeIntervalSince1970) + // regionScope is incoming-only by data-pipeline design — outgoing + // messages do not flow through 0x88 / RxLogEntry correlation. + let messageDTO = MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: channelIndex, + text: text, + timestamp: finalTimestamp, + createdAt: receiveTime, + sortDate: sortDate, + direction: .incoming, + status: .delivered, + textType: resolvedTextType, + ackCode: nil, + pathLength: rxResult.pathLength, + snr: snr, + pathNodes: rxResult.pathNodes, + senderKeyPrefix: senderKeyPrefix, + senderNodeName: senderNodeName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0, + deduplicationKey: deduplicationKey, + containsSelfMention: hasSelfMention, + mentionSeen: false, + timestampCorrected: timestampCorrected, + senderTimestamp: timestampCorrected ? timestamp : nil, + routeType: rxResult.routeType, + regionScope: rxResult.regionScope + ) + + // Check for duplicate before saving + do { + if try await dependencies.dataStore.isDuplicateMessage(deduplicationKey: deduplicationKey, radioID: radioID) { + logger.info("Skipping duplicate \(kind.logLabel) message") + return + } + } catch { + logger.warning("Dedup check failed, proceeding with save: \(error)") + } - // Correct invalid timestamps (sender clock wrong) - let receiveTime = Date() - let (finalTimestamp, timestampCorrected) = Self.correctTimestampIfNeeded(timestamp, receiveTime: receiveTime) - if timestampCorrected { - logger.debug("Corrected invalid \(kind.logLabel) message timestamp from \(Date(timeIntervalSince1970: TimeInterval(timestamp))) to \(receiveTime)") - } + switch kind { + case let .direct(_, contact): + // Check if this is a DM reaction + if let contact, + await handleDMReaction( + text: text, + contact: contact, + radioID: radioID, + dependencies: dependencies + ) { + return + } + case let .channel(message, _): + // Discard messages from blocked senders + if isBlockedSender(senderNodeName) { + return + } + + // Check if this is a reaction + if await handleChannelReaction( + text: text, + channelIndex: message.channelIndex, + senderNodeName: senderNodeName, + selfNodeName: selfNodeName, + receiveTime: receiveTime, + radioID: radioID, + dependencies: dependencies + ) { + return + } + } - let sortDate = Self.sortDate(for: context, receiveTime: receiveTime) - - // Look up path data from RxLogEntry using the sender timestamp stored - // during decryption (for direct messages, channelIndex is nil) - let rxResult = await lookupRxLogEntry( - dependencies: dependencies, - radioID: radioID, - channelIndex: channelIndex, - senderTimestamp: timestamp, - senderPublicKeyPrefix: senderKeyPrefix, - defaultPathLength: reportedPathLength + do { + try await dependencies.dataStore.saveMessage(messageDTO) + + switch kind { + case let .direct(_, contact): + try await indexAndNotifyDirectMessage( + messageDTO: messageDTO, + contact: contact, + messageText: text, + timestamp: timestamp, + hasSelfMention: hasSelfMention, + dependencies: dependencies, + radioID: radioID ) - - // Use content-based key for dedup (stable across retry attempts). - // The RX log packetHash is per-encrypted-packet and differs between - // retries with different attempt counters, so it must not drive dedup. - let deduplicationKey = Self.fallbackDeduplicationKey( - contactID: contactID, channelIndex: channelIndex, - senderNodeName: senderNodeName, timestamp: timestamp, content: text + case let .channel(message, channel): + try await indexAndNotifyChannelMessage( + messageDTO: messageDTO, + channel: channel, + channelIndex: message.channelIndex, + senderNodeName: senderNodeName, + messageText: text, + timestamp: timestamp, + hasSelfMention: hasSelfMention, + dependencies: dependencies, + radioID: radioID ) - - // Check for self-mention before creating DTO - // For channel messages, filter out messages where the user mentions themselves - let hasSelfMention: Bool - switch kind { - case .direct: - hasSelfMention = !selfNodeName.isEmpty && - MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) - case .channel: - hasSelfMention = !selfNodeName.isEmpty && - senderNodeName != selfNodeName && - MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) - } - - // Clamp an unknown wire textType to .plain. MessageDTO decodes textType - // non-optionally, so an out-of-range raw value would throw and fail the - // whole envelope decode on backup round-trip; guaranteeing only the - // known cases reach persistence keeps that round-trip safe. - let resolvedTextType: TextType - if let parsed = TextType(rawValue: textTypeRaw) { - resolvedTextType = parsed - } else { - logger.warning("Unknown \(kind.logLabel) message textType raw=\(textTypeRaw); clamping to .plain") - resolvedTextType = .plain - } - - // regionScope is incoming-only by data-pipeline design — outgoing - // messages do not flow through 0x88 / RxLogEntry correlation. - let messageDTO = MessageDTO( - id: UUID(), - radioID: radioID, - contactID: contactID, - channelIndex: channelIndex, - text: text, - timestamp: finalTimestamp, - createdAt: receiveTime, - sortDate: sortDate, - direction: .incoming, - status: .delivered, - textType: resolvedTextType, - ackCode: nil, - pathLength: rxResult.pathLength, - snr: snr, - pathNodes: rxResult.pathNodes, - senderKeyPrefix: senderKeyPrefix, - senderNodeName: senderNodeName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: deduplicationKey, - containsSelfMention: hasSelfMention, - mentionSeen: false, - timestampCorrected: timestampCorrected, - senderTimestamp: timestampCorrected ? timestamp : nil, - routeType: rxResult.routeType, - regionScope: rxResult.regionScope + } + + // Notify conversation list of changes + await notifyConversationsChanged() + + // Broadcast for real-time chat updates + if case let .direct(_, contact) = kind, let contact { + dataEventBroadcaster.yield(.directMessageReceived(message: messageDTO, contact: contact)) + } + } catch { + switch kind { + case .direct: + logger.error("Failed to save contact message: \(error)") + case .channel: + logger.error("Failed to save channel message: \(error)") + } + } + } + + /// Post-save side effects for a direct message: reaction indexing, contact + /// bookkeeping, and unread/notification updates. + private func indexAndNotifyDirectMessage( + messageDTO: MessageDTO, + contact: ContactDTO?, + messageText: String, + timestamp: UInt32, + hasSelfMention: Bool, + dependencies: SyncDependencies, + radioID: UUID + ) async throws { + // Index DM message for reaction targeting + if let contact { + let pendingMatches = await dependencies.reactionService.indexDMMessage( + id: messageDTO.id, + contactID: contact.id, + text: messageText, + timestamp: timestamp + ) + + // Process pending reactions that now have their target + for pending in pendingMatches { + let reactionDTO = ReactionDTO( + messageID: messageDTO.id, + emoji: pending.parsed.emoji, + senderName: pending.senderName, + messageHash: pending.parsed.messageHash, + rawText: pending.rawText, + contactID: contact.id, + radioID: radioID ) - - // Check for duplicate before saving - do { - if try await dependencies.dataStore.isDuplicateMessage(deduplicationKey: deduplicationKey, radioID: radioID) { - logger.info("Skipping duplicate \(kind.logLabel) message") - return - } - } catch { - logger.warning("Dedup check failed, proceeding with save: \(error)") - } - - switch kind { - case .direct(_, let contact): - // Check if this is a DM reaction - if let contact, - await handleDMReaction( - text: text, - contact: contact, - radioID: radioID, - dependencies: dependencies - ) { - return - } - case .channel(let message, _): - // Discard messages from blocked senders - if isBlockedSender(senderNodeName) { - return - } - - // Check if this is a reaction - if await handleChannelReaction( - text: text, - channelIndex: message.channelIndex, - senderNodeName: senderNodeName, - selfNodeName: selfNodeName, - receiveTime: receiveTime, - radioID: radioID, - dependencies: dependencies - ) { - return - } - } - - do { - try await dependencies.dataStore.saveMessage(messageDTO) - - switch kind { - case .direct(_, let contact): - try await indexAndNotifyDirectMessage( - messageDTO: messageDTO, - contact: contact, - messageText: text, - timestamp: timestamp, - hasSelfMention: hasSelfMention, - dependencies: dependencies, - radioID: radioID - ) - case .channel(let message, let channel): - try await indexAndNotifyChannelMessage( - messageDTO: messageDTO, - channel: channel, - channelIndex: message.channelIndex, - senderNodeName: senderNodeName, - messageText: text, - timestamp: timestamp, - hasSelfMention: hasSelfMention, - dependencies: dependencies, - radioID: radioID - ) - } - - // Notify conversation list of changes - await notifyConversationsChanged() - - // Broadcast for real-time chat updates - if case .direct(_, let contact) = kind, let contact { - dataEventBroadcaster.yield(.directMessageReceived(message: messageDTO, contact: contact)) - } - } catch { - switch kind { - case .direct: - logger.error("Failed to save contact message: \(error)") - case .channel: - logger.error("Failed to save channel message: \(error)") - } + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Processed pending DM reaction \(pending.parsed.emoji)") } + } } - /// Post-save side effects for a direct message: reaction indexing, contact - /// bookkeeping, and unread/notification updates. - private func indexAndNotifyDirectMessage( - messageDTO: MessageDTO, - contact: ContactDTO?, - messageText: String, - timestamp: UInt32, - hasSelfMention: Bool, - dependencies: SyncDependencies, - radioID: UUID - ) async throws { - // Index DM message for reaction targeting - if let contact { - let pendingMatches = await dependencies.reactionService.indexDMMessage( - id: messageDTO.id, - contactID: contact.id, - text: messageText, - timestamp: timestamp - ) - - // Process pending reactions that now have their target - for pending in pendingMatches { - let reactionDTO = ReactionDTO( - messageID: messageDTO.id, - emoji: pending.parsed.emoji, - senderName: pending.senderName, - messageHash: pending.parsed.messageHash, - rawText: pending.rawText, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Processed pending DM reaction \(pending.parsed.emoji)") - } - } - } - - // Update contact's last message date - if let contactID = contact?.id { - try await dependencies.dataStore.updateContactLastMessage(contactID: contactID, date: Date()) - } - - // Only increment unread count, post notification, and update badge for non-blocked contacts - if let contactID = contact?.id, contact?.isBlocked != true { - try await updateDMUnreadsAndNotify( - messageDTO: messageDTO, - contactID: contactID, - contact: contact, - messageText: messageText, - hasSelfMention: hasSelfMention, - dependencies: dependencies - ) - } + // Update contact's last message date + if let contactID = contact?.id { + try await dependencies.dataStore.updateContactLastMessage(contactID: contactID, date: Date()) } - /// Post-save side effects for a channel message: reaction indexing, channel - /// bookkeeping, and unread/notification updates. - private func indexAndNotifyChannelMessage( - messageDTO: MessageDTO, - channel: ChannelDTO?, - channelIndex: UInt8, - senderNodeName: String?, - messageText: String, - timestamp: UInt32, - hasSelfMention: Bool, - dependencies: SyncDependencies, - radioID: UUID - ) async throws { - // Index message for reaction matching and process any pending reactions - // Use original timestamp for indexing so pending reactions can match - if let senderName = senderNodeName { - let pendingMatches = await dependencies.reactionService.indexMessage( - id: messageDTO.id, - channelIndex: channelIndex, - senderName: senderName, - text: messageText, - timestamp: timestamp - ) - - // Process any pending reactions that now have their target - for pending in pendingMatches { - let reactionDTO = ReactionDTO( - messageID: messageDTO.id, - emoji: pending.parsed.emoji, - senderName: pending.senderNodeName, - messageHash: pending.parsed.messageHash, - rawText: pending.rawText, - channelIndex: pending.channelIndex, - radioID: pending.radioID - ) - await persistReactionIfNew(reactionDTO, dependencies: dependencies) - } - } - - // Update channel's last message date - if let channelID = channel?.id { - try await dependencies.dataStore.updateChannelLastMessage(channelID: channelID, date: Date()) - } - - // Only update unread count, badges, and notify UI for non-blocked senders - if !isBlockedSender(senderNodeName) { - try await updateChannelUnreadsAndNotify( - messageDTO: messageDTO, - channel: channel, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - messageText: messageText, - timestamp: timestamp, - hasSelfMention: hasSelfMention, - radioID: radioID, - dependencies: dependencies - ) - } + // Only increment unread count, post notification, and update badge for non-blocked contacts + if let contactID = contact?.id, contact?.isBlocked != true { + try await updateDMUnreadsAndNotify( + messageDTO: messageDTO, + contactID: contactID, + contact: contact, + messageText: messageText, + hasSelfMention: hasSelfMention, + dependencies: dependencies + ) } - - // MARK: - Signed Message Handler - - private func wireSignedMessageHandler(dependencies: SyncDependencies) async { - await dependencies.messagePollingService.setSignedMessageHandler { [weak self] message, _ in - guard let self else { return } - await self.handleIncomingSignedMessage(message, dependencies: dependencies) - } + } + + /// Post-save side effects for a channel message: reaction indexing, channel + /// bookkeeping, and unread/notification updates. + private func indexAndNotifyChannelMessage( + messageDTO: MessageDTO, + channel: ChannelDTO?, + channelIndex: UInt8, + senderNodeName: String?, + messageText: String, + timestamp: UInt32, + hasSelfMention: Bool, + dependencies: SyncDependencies, + radioID: UUID + ) async throws { + // Index message for reaction matching and process any pending reactions + // Use original timestamp for indexing so pending reactions can match + if let senderName = senderNodeName { + let pendingMatches = await dependencies.reactionService.indexMessage( + id: messageDTO.id, + channelIndex: channelIndex, + senderName: senderName, + text: messageText, + timestamp: timestamp + ) + + // Process any pending reactions that now have their target + for pending in pendingMatches { + let reactionDTO = ReactionDTO( + messageID: messageDTO.id, + emoji: pending.parsed.emoji, + senderName: pending.senderNodeName, + messageHash: pending.parsed.messageHash, + rawText: pending.rawText, + channelIndex: pending.channelIndex, + radioID: pending.radioID + ) + await persistReactionIfNew(reactionDTO, dependencies: dependencies) + } } - /// Persists a signed room message via `RoomServerService`, then posts the - /// notification and refreshes the UI when the message was new. - private func handleIncomingSignedMessage(_ message: ContactMessage, dependencies: SyncDependencies) async { - // For signed room messages, the signature contains the 4-byte author key prefix - guard let authorPrefix = message.signature?.prefix(4), authorPrefix.count == 4 else { - logger.warning("Dropping signed message: missing or invalid author prefix") - return - } + // Update channel's last message date + if let channelID = channel?.id { + try await dependencies.dataStore.updateChannelLastMessage(channelID: channelID, date: Date()) + } - let timestamp = UInt32(message.senderTimestamp.timeIntervalSince1970) - - do { - let savedMessage = try await dependencies.roomServerService.handleIncomingMessage( - senderPublicKeyPrefix: message.senderPublicKeyPrefix, - timestamp: timestamp, - authorPrefix: Data(authorPrefix), - text: message.text - ) - - // If message was saved (not a duplicate), notify UI and post notification - if let savedMessage { - // Fetch session for room name and mute status - let session = try? await dependencies.dataStore.fetchRemoteNodeSession(id: savedMessage.sessionID) - - // Post notification for room message - await dependencies.notificationService.postRoomMessageNotification( - roomName: session?.name ?? "Room", - sessionID: savedMessage.sessionID, - senderName: savedMessage.authorName, - messageText: savedMessage.text, - messageID: savedMessage.id, - notificationLevel: session?.notificationLevel ?? .all - ) - await dependencies.notificationService.updateBadgeCount() - - await notifyConversationsChanged() - dataEventBroadcaster.yield(.roomMessageReceived(savedMessage)) - } - } catch { - logger.error("Failed to handle room message: \(error)") - } + // Only update unread count, badges, and notify UI for non-blocked senders + if !isBlockedSender(senderNodeName) { + try await updateChannelUnreadsAndNotify( + messageDTO: messageDTO, + channel: channel, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + messageText: messageText, + timestamp: timestamp, + hasSelfMention: hasSelfMention, + radioID: radioID, + dependencies: dependencies + ) } + } - // MARK: - CLI Message Handler + // MARK: - Signed Message Handler - private func wireCLIMessageHandler(dependencies: SyncDependencies) async { - await dependencies.messagePollingService.setCLIMessageHandler { [weak self] message, contact in - guard let self else { return } - await self.handleIncomingCLIMessage(message, contact: contact, dependencies: dependencies) - } + private func wireSignedMessageHandler(dependencies: SyncDependencies) async { + await dependencies.messagePollingService.setSignedMessageHandler { [weak self] message, _ in + guard let self else { return } + await handleIncomingSignedMessage(message, dependencies: dependencies) } - - /// Routes a CLI response to the room or repeater admin service for the sending contact. - private func handleIncomingCLIMessage( - _ message: ContactMessage, - contact: ContactDTO?, - dependencies: SyncDependencies - ) async { - if let contact { - if contact.type == .room { - await dependencies.roomAdminService.invokeCLIHandler(message, fromContact: contact) - } else { - await dependencies.repeaterAdminService.invokeCLIHandler(message, fromContact: contact) - } - } else { - logger.warning("Dropping CLI response: no contact found for sender") - } + } + + /// Persists a signed room message via `RoomServerService`, then posts the + /// notification and refreshes the UI when the message was new. + private func handleIncomingSignedMessage(_ message: ContactMessage, dependencies: SyncDependencies) async { + // For signed room messages, the signature contains the 4-byte author key prefix + guard let authorPrefix = message.signature?.prefix(4), authorPrefix.count == 4 else { + logger.warning("Dropping signed message: missing or invalid author prefix") + return } - // MARK: - Discovery Event Monitoring - - /// Consumes the advertisement event stream to post new-contact - /// notifications and refresh contact lists. The subscription is - /// registered synchronously before this method returns; events yielded - /// earlier (during the initial sync) are deliberately not seen. - func startDiscoveryEventMonitoring(dependencies: SyncDependencies, radioID: UUID) { - logger.info("Starting discovery event monitoring for device \(radioID)") - discoveryEventsTask?.cancel() - let events = dependencies.advertisementService.events() - discoveryEventsTask = Task { [weak self] in - for await event in events { - guard let self else { return } - switch event { - case .newContactDiscovered(let name, let contactID, let contactType): - // Manual-add mode: a new contact was discovered via advertisement - PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - .info("B4 relay newContactDiscovered \(contactID) -> notifyContactsChanged") - await dependencies.notificationService.postNewContactNotification( - contactName: name, - contactID: contactID, - contactType: contactType - ) - await self.notifyContactsChanged() - case .contactSyncRequested: - // Auto-add mode: AdvertisementService already fetched and - // saved the contact, so only a UI refresh is needed - PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - .info("B4 relay contactSyncRequested -> notifyContactsChanged") - await self.notifyContactsChanged() - case .contactUpdated, .nodeStorageFullChanged, .contactDeletedCleanup, - .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: - break - } - } - } - } + let timestamp = UInt32(message.senderTimestamp.timeIntervalSince1970) + + do { + let savedMessage = try await dependencies.roomServerService.handleIncomingMessage( + senderPublicKeyPrefix: message.senderPublicKeyPrefix, + timestamp: timestamp, + authorPrefix: Data(authorPrefix), + text: message.text + ) + + // If message was saved (not a duplicate), notify UI and post notification + if let savedMessage { + // Fetch session for room name and mute status + let session = try? await dependencies.dataStore.fetchRemoteNodeSession(id: savedMessage.sessionID) + + // Post notification for room message + await dependencies.notificationService.postRoomMessageNotification( + roomName: session?.name ?? "Room", + sessionID: savedMessage.sessionID, + senderName: savedMessage.authorName, + messageText: savedMessage.text, + messageID: savedMessage.id, + notificationLevel: session?.notificationLevel ?? .all + ) + await dependencies.notificationService.updateBadgeCount() - /// Cancels the discovery event task so it releases the service references - /// it captures. Called by `ServiceContainer.tearDown()`. - func cancelDiscoveryEventMonitoring() { - discoveryEventsTask?.cancel() - discoveryEventsTask = nil + await notifyConversationsChanged() + dataEventBroadcaster.yield(.roomMessageReceived(savedMessage)) + } + } catch { + logger.error("Failed to handle room message: \(error)") } + } - // MARK: - Static Helpers + // MARK: - CLI Message Handler - nonisolated static func fallbackDeduplicationKey( - contactID: UUID?, - channelIndex: UInt8?, - senderNodeName: String?, - timestamp: UInt32, - content: String - ) -> String { - DeduplicationKey.contentBased( - contactID: contactID, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - timestamp: timestamp, - content: content - ) + private func wireCLIMessageHandler(dependencies: SyncDependencies) async { + await dependencies.messagePollingService.setCLIMessageHandler { [weak self] message, contact in + guard let self else { return } + await handleIncomingCLIMessage(message, contact: contact, dependencies: dependencies) } - - nonisolated static func parseChannelMessage(_ text: String) -> (senderNodeName: String?, messageText: String) { - let parts = text.split(separator: ":", maxSplits: 1) - if parts.count > 1 { - let senderName = String(parts[0]).trimmingCharacters(in: .whitespaces) - let messageText = String(parts[1]).trimmingCharacters(in: .whitespaces) - return (senderName, messageText) + } + + /// Routes a CLI response to the room or repeater admin service for the sending contact. + private func handleIncomingCLIMessage( + _ message: ContactMessage, + contact: ContactDTO?, + dependencies: SyncDependencies + ) async { + if let contact { + if contact.type == .room { + await dependencies.roomAdminService.invokeCLIHandler(message, fromContact: contact) + } else { + await dependencies.repeaterAdminService.invokeCLIHandler(message, fromContact: contact) + } + } else { + logger.warning("Dropping CLI response: no contact found for sender") + } + } + + // MARK: - Discovery Event Monitoring + + /// Consumes the advertisement event stream to post new-contact + /// notifications and refresh contact lists. The subscription is + /// registered synchronously before this method returns; events yielded + /// earlier (during the initial sync) are deliberately not seen. + func startDiscoveryEventMonitoring(dependencies: SyncDependencies, radioID: UUID) { + logger.info("Starting discovery event monitoring for device \(radioID)") + discoveryEventsTask?.cancel() + let events = dependencies.advertisementService.events() + discoveryEventsTask = Task { [weak self] in + for await event in events { + guard let self else { return } + switch event { + case let .newContactDiscovered(name, contactID, contactType): + // Manual-add mode: a new contact was discovered via advertisement + PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + .info("B4 relay newContactDiscovered \(contactID) -> notifyContactsChanged") + await dependencies.notificationService.postNewContactNotification( + contactName: name, + contactID: contactID, + contactType: contactType + ) + await notifyContactsChanged() + case .contactSyncRequested: + // Auto-add mode: AdvertisementService already fetched and + // saved the contact, so only a UI refresh is needed + PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + .info("B4 relay contactSyncRequested -> notifyContactsChanged") + await notifyContactsChanged() + case .contactUpdated, .nodeStorageFullChanged, .contactDeletedCleanup, + .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: + break } - return (nil, text) + } } - - /// A received channel message only warrants a user notification when it resolves to a known - /// local channel. The radio can report a message on a slot the app has no `Channel` for: the - /// firmware decrypts zero-key group traffic on an unconfigured slot and attributes it to the - /// first empty slot. Such messages have no openable chat, so they are logged as unresolved - /// but never surfaced as a notification. - nonisolated static func shouldPostChannelNotification(forResolvedChannel channel: ChannelDTO?) -> Bool { - channel != nil + } + + /// Cancels the discovery event task so it releases the service references + /// it captures. Called by `ServiceContainer.tearDown()`. + func cancelDiscoveryEventMonitoring() { + discoveryEventsTask?.cancel() + discoveryEventsTask = nil + } + + // MARK: - Static Helpers + + nonisolated static func fallbackDeduplicationKey( + contactID: UUID?, + channelIndex: UInt8?, + senderNodeName: String?, + timestamp: UInt32, + content: String + ) -> String { + DeduplicationKey.contentBased( + contactID: contactID, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + timestamp: timestamp, + content: content + ) + } + + nonisolated static func parseChannelMessage(_ text: String) -> (senderNodeName: String?, messageText: String) { + let parts = text.split(separator: ":", maxSplits: 1) + if parts.count > 1 { + let senderName = String(parts[0]).trimmingCharacters(in: .whitespaces) + let messageText = String(parts[1]).trimmingCharacters(in: .whitespaces) + return (senderName, messageText) } + return (nil, text) + } + + /// A received channel message only warrants a user notification when it resolves to a known + /// local channel. The radio can report a message on a slot the app has no `Channel` for: the + /// firmware decrypts zero-key group traffic on an unconfigured slot and attributes it to the + /// first empty slot. Such messages have no openable chat, so they are logged as unresolved + /// but never surfaced as a notification. + nonisolated static func shouldPostChannelNotification(forResolvedChannel channel: ChannelDTO?) -> Bool { + channel != nil + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+ReactionHandlers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+ReactionHandlers.swift index f8042c9c..cbf5240b 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+ReactionHandlers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+ReactionHandlers.swift @@ -4,492 +4,488 @@ import Foundation // MARK: - Reaction Handlers extension SyncCoordinator { - - /// Persists a reaction if it doesn't already exist, notifying the UI on success. - /// - /// Deduplicates the check-exists → create → persist → notify pattern used across - /// DM and channel reaction handlers. - /// - /// - Returns: `true` if the reaction was new and saved - @discardableResult - func persistReactionIfNew( - _ reactionDTO: ReactionDTO, - dependencies: SyncDependencies - ) async -> Bool { - let exists = try? await dependencies.dataStore.reactionExists( - messageID: reactionDTO.messageID, - senderName: reactionDTO.senderName, - emoji: reactionDTO.emoji - ) - - guard exists != true else { return false } - - if let result = await dependencies.reactionService.persistReactionAndUpdateSummary( - reactionDTO, - using: dependencies.dataStore - ) { - dataEventBroadcaster.yield(.reactionReceived(messageID: result.messageID, summary: result.summary)) - } - - return true + /// Persists a reaction if it doesn't already exist, notifying the UI on success. + /// + /// Deduplicates the check-exists → create → persist → notify pattern used across + /// DM and channel reaction handlers. + /// + /// - Returns: `true` if the reaction was new and saved + @discardableResult + func persistReactionIfNew( + _ reactionDTO: ReactionDTO, + dependencies: SyncDependencies + ) async -> Bool { + let exists = try? await dependencies.dataStore.reactionExists( + messageID: reactionDTO.messageID, + senderName: reactionDTO.senderName, + emoji: reactionDTO.emoji + ) + + guard exists != true else { return false } + + if let result = await dependencies.reactionService.persistReactionAndUpdateSummary( + reactionDTO, + using: dependencies.dataStore + ) { + dataEventBroadcaster.yield(.reactionReceived(messageID: result.messageID, summary: result.summary)) } - /// Handles an incoming DM reaction by looking up the target message and persisting the reaction. - /// - /// - Returns: `true` if the message was consumed as a reaction (caller should return early). - func handleDMReaction( - text: String, - contact: ContactDTO, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - // Try meshcore-open v3 format - if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { - return await handleMCODMReaction( - mcoReaction, - rawText: text, - contact: contact, - radioID: radioID, - dependencies: dependencies - ) - } + return true + } + + /// Handles an incoming DM reaction by looking up the target message and persisting the reaction. + /// + /// - Returns: `true` if the message was consumed as a reaction (caller should return early). + func handleDMReaction( + text: String, + contact: ContactDTO, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + // Try meshcore-open v3 format + if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { + return await handleMCODMReaction( + mcoReaction, + rawText: text, + contact: contact, + radioID: radioID, + dependencies: dependencies + ) + } - // Try meshcore-open v1 format - if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { - return await handleMCOV1DMReaction( - v1Reaction, - rawText: text, - contact: contact, - radioID: radioID, - dependencies: dependencies - ) - } + // Try meshcore-open v1 format + if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { + return await handleMCOV1DMReaction( + v1Reaction, + rawText: text, + contact: contact, + radioID: radioID, + dependencies: dependencies + ) + } - guard let parsed = ReactionParser.parseDM(text) else { return false } - - // Try to find target in cache first - if let targetMessageID = await dependencies.reactionService.findDMTargetMessage( - messageHash: parsed.messageHash, - contactID: contact.id - ) { - let reactionDTO = ReactionDTO( - messageID: targetMessageID, - emoji: parsed.emoji, - senderName: contact.displayName, - messageHash: parsed.messageHash, - rawText: text, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved DM reaction \(parsed.emoji) to message \(targetMessageID)") - } - - return true - } + guard let parsed = ReactionParser.parseDM(text) else { return false } + + // Try to find target in cache first + if let targetMessageID = await dependencies.reactionService.findDMTargetMessage( + messageHash: parsed.messageHash, + contactID: contact.id + ) { + let reactionDTO = ReactionDTO( + messageID: targetMessageID, + emoji: parsed.emoji, + senderName: contact.displayName, + messageHash: parsed.messageHash, + rawText: text, + contactID: contact.id, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved DM reaction \(parsed.emoji) to message \(targetMessageID)") + } + + return true + } - // Try persistence fallback - let timestampWindow = reactionTimestampWindow() - - if let targetMessage = try? await dependencies.dataStore.findDMMessageForReaction( - radioID: radioID, - contactID: contact.id, - messageHash: parsed.messageHash, - timestampWindow: timestampWindow, - limit: 200 - ) { - let reactionDTO = ReactionDTO( - messageID: targetMessage.id, - emoji: parsed.emoji, - senderName: contact.displayName, - messageHash: parsed.messageHash, - rawText: text, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved DM reaction \(parsed.emoji) to message \(targetMessage.id) (from DB)") - } - - return true - } + // Try persistence fallback + let timestampWindow = reactionTimestampWindow() + + if let targetMessage = try? await dependencies.dataStore.findDMMessageForReaction( + radioID: radioID, + contactID: contact.id, + messageHash: parsed.messageHash, + timestampWindow: timestampWindow, + limit: 200 + ) { + let reactionDTO = ReactionDTO( + messageID: targetMessage.id, + emoji: parsed.emoji, + senderName: contact.displayName, + messageHash: parsed.messageHash, + rawText: text, + contactID: contact.id, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved DM reaction \(parsed.emoji) to message \(targetMessage.id) (from DB)") + } + + return true + } - // Queue as pending if target not found - await dependencies.reactionService.queuePendingDMReaction( - parsed: parsed, - contactID: contact.id, - senderName: contact.displayName, - rawText: text, - radioID: radioID - ) - - logger.debug("Queued pending DM reaction \(parsed.emoji)") - return true + // Queue as pending if target not found + await dependencies.reactionService.queuePendingDMReaction( + parsed: parsed, + contactID: contact.id, + senderName: contact.displayName, + rawText: text, + radioID: radioID + ) + + logger.debug("Queued pending DM reaction \(parsed.emoji)") + return true + } + + /// Handles an incoming channel reaction by looking up the target message and persisting the reaction. + /// + /// - Returns: `true` if the message was consumed as a reaction. + func handleChannelReaction( + text: String, + channelIndex: UInt8, + senderNodeName: String?, + selfNodeName: String, + receiveTime: Date, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + // Try meshcore-open v3 format + if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { + return await handleMCOChannelReaction( + mcoReaction, + rawText: text, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + selfNodeName: selfNodeName, + receiveTime: receiveTime, + radioID: radioID, + dependencies: dependencies + ) } - /// Handles an incoming channel reaction by looking up the target message and persisting the reaction. - /// - /// - Returns: `true` if the message was consumed as a reaction. - func handleChannelReaction( - text: String, - channelIndex: UInt8, - senderNodeName: String?, - selfNodeName: String, - receiveTime: Date, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - // Try meshcore-open v3 format - if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { - return await handleMCOChannelReaction( - mcoReaction, - rawText: text, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - selfNodeName: selfNodeName, - receiveTime: receiveTime, - radioID: radioID, - dependencies: dependencies - ) - } + // Try meshcore-open v1 format + if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { + return await handleMCOV1ChannelReaction( + v1Reaction, + rawText: text, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + selfNodeName: selfNodeName, + radioID: radioID, + dependencies: dependencies + ) + } - // Try meshcore-open v1 format - if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { - return await handleMCOV1ChannelReaction( - v1Reaction, - rawText: text, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - selfNodeName: selfNodeName, - radioID: radioID, - dependencies: dependencies - ) - } + guard let parsed = dependencies.reactionService.tryProcessAsReaction(text) else { return false } + + let senderName = senderNodeName ?? "Unknown" + + if let targetMessageID = await dependencies.reactionService.findTargetMessage( + parsed: parsed, + channelIndex: channelIndex + ) { + let reactionDTO = ReactionDTO( + messageID: targetMessageID, + emoji: parsed.emoji, + senderName: senderName, + messageHash: parsed.messageHash, + rawText: text, + channelIndex: channelIndex, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved reaction \(parsed.emoji) to message \(targetMessageID)") + } + + return true + } - guard let parsed = dependencies.reactionService.tryProcessAsReaction(text) else { return false } - - let senderName = senderNodeName ?? "Unknown" - - if let targetMessageID = await dependencies.reactionService.findTargetMessage( - parsed: parsed, - channelIndex: channelIndex - ) { - let reactionDTO = ReactionDTO( - messageID: targetMessageID, - emoji: parsed.emoji, - senderName: senderName, - messageHash: parsed.messageHash, - rawText: text, - channelIndex: channelIndex, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved reaction \(parsed.emoji) to message \(targetMessageID)") - } - - return true + let timestampWindow = reactionTimestampWindow(at: receiveTime) + + logger.debug("DB lookup: selfNodeName='\(selfNodeName)', targetSender=\(parsed.targetSender), hash=\(parsed.messageHash)") + + if let targetMessage = try? await dependencies.dataStore.findChannelMessageForReaction( + radioID: radioID, + channelIndex: channelIndex, + parsedReaction: parsed, + localNodeName: selfNodeName.isEmpty ? nil : selfNodeName, + timestampWindow: timestampWindow, + limit: 200 + ) { + let targetMessageID = targetMessage.id + let reactionDTO = ReactionDTO( + messageID: targetMessageID, + emoji: parsed.emoji, + senderName: senderName, + messageHash: parsed.messageHash, + rawText: text, + channelIndex: channelIndex, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + let targetSenderName: String? = if targetMessage.direction == .outgoing { + selfNodeName.isEmpty ? nil : selfNodeName + } else { + targetMessage.senderNodeName } - let timestampWindow = reactionTimestampWindow(at: receiveTime) - - logger.debug("DB lookup: selfNodeName='\(selfNodeName)', targetSender=\(parsed.targetSender), hash=\(parsed.messageHash)") - - if let targetMessage = try? await dependencies.dataStore.findChannelMessageForReaction( - radioID: radioID, + if let targetSenderName { + // Index for future reactions (pending matches not needed here since + // message exists in DB, so pending reactions would also match via DB fallback) + _ = await dependencies.reactionService.indexMessage( + id: targetMessageID, channelIndex: channelIndex, - parsedReaction: parsed, - localNodeName: selfNodeName.isEmpty ? nil : selfNodeName, - timestampWindow: timestampWindow, - limit: 200 - ) { - let targetMessageID = targetMessage.id - let reactionDTO = ReactionDTO( - messageID: targetMessageID, - emoji: parsed.emoji, - senderName: senderName, - messageHash: parsed.messageHash, - rawText: text, - channelIndex: channelIndex, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - let targetSenderName: String? - if targetMessage.direction == .outgoing { - targetSenderName = selfNodeName.isEmpty ? nil : selfNodeName - } else { - targetSenderName = targetMessage.senderNodeName - } - - if let targetSenderName { - // Index for future reactions (pending matches not needed here since - // message exists in DB, so pending reactions would also match via DB fallback) - _ = await dependencies.reactionService.indexMessage( - id: targetMessageID, - channelIndex: channelIndex, - senderName: targetSenderName, - text: targetMessage.text, - timestamp: targetMessage.reactionTimestamp - ) - } - - logger.debug("Saved reaction \(parsed.emoji) to message \(targetMessageID) via DB lookup") - } - - return true + senderName: targetSenderName, + text: targetMessage.text, + timestamp: targetMessage.reactionTimestamp + ) } - // Queue reaction for later matching when target message arrives - await dependencies.reactionService.queuePendingReaction( - parsed: parsed, - channelIndex: channelIndex, - senderNodeName: senderName, - rawText: text, - radioID: radioID - ) - return true - } + logger.debug("Saved reaction \(parsed.emoji) to message \(targetMessageID) via DB lookup") + } - /// Computes a symmetric timestamp window around the given time for reaction matching. - private func reactionTimestampWindow(at time: Date = Date()) -> ClosedRange { - reactionTimestampWindow(anchor: UInt32(time.timeIntervalSince1970)) + return true } - /// Computes a symmetric timestamp window around a specific anchor timestamp. - private func reactionTimestampWindow(anchor: UInt32) -> ClosedRange { - let start = anchor > reactionTimestampWindowSeconds ? anchor - reactionTimestampWindowSeconds : 0 - return start...(anchor + reactionTimestampWindowSeconds) + // Queue reaction for later matching when target message arrives + await dependencies.reactionService.queuePendingReaction( + parsed: parsed, + channelIndex: channelIndex, + senderNodeName: senderName, + rawText: text, + radioID: radioID + ) + return true + } + + /// Computes a symmetric timestamp window around the given time for reaction matching. + private func reactionTimestampWindow(at time: Date = Date()) -> ClosedRange { + reactionTimestampWindow(anchor: UInt32(time.timeIntervalSince1970)) + } + + /// Computes a symmetric timestamp window around a specific anchor timestamp. + private func reactionTimestampWindow(anchor: UInt32) -> ClosedRange { + let start = anchor > reactionTimestampWindowSeconds ? anchor - reactionTimestampWindowSeconds : 0 + return start...(anchor + reactionTimestampWindowSeconds) + } + + // MARK: - meshcore-open Reaction Handlers + + /// Handles a meshcore-open DM reaction by computing Dart hashes against DB candidates. + /// + /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. + private func handleMCODMReaction( + _ mcoReaction: ParsedMCOReaction, + rawText: String, + contact: ContactDTO, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + let timestampWindow = reactionTimestampWindow() + + guard let candidates = try? await dependencies.dataStore.fetchDMMessageCandidates( + radioID: radioID, + contactID: contact.id, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO DM reaction \(mcoReaction.emoji): no candidates in window") + return true } - // MARK: - meshcore-open Reaction Handlers - - /// Handles a meshcore-open DM reaction by computing Dart hashes against DB candidates. - /// - /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. - private func handleMCODMReaction( - _ mcoReaction: ParsedMCOReaction, - rawText: String, - contact: ContactDTO, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - let timestampWindow = reactionTimestampWindow() - - guard let candidates = try? await dependencies.dataStore.fetchDMMessageCandidates( - radioID: radioID, - contactID: contact.id, - timestampWindow: timestampWindow, - limit: 200 - ), !candidates.isEmpty else { - logger.debug("MCO DM reaction \(mcoReaction.emoji): no candidates in window") - return true - } - - for candidate in candidates { - // Skip messages that are themselves reactions - if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } - - let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: candidate.reactionTimestamp, - senderName: nil, - text: candidate.text - ) - - guard candidateHash == mcoReaction.dartHash else { continue } - - let reactionDTO = ReactionDTO( - messageID: candidate.id, - emoji: mcoReaction.emoji, - senderName: contact.displayName, - messageHash: mcoReaction.dartHash, - rawText: rawText, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved MCO DM reaction \(mcoReaction.emoji) to message \(candidate.id)") - } - return true - } - - logger.debug("MCO DM reaction \(mcoReaction.emoji): no hash match found") - return true + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } + + let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: candidate.reactionTimestamp, + senderName: nil, + text: candidate.text + ) + + guard candidateHash == mcoReaction.dartHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: mcoReaction.emoji, + senderName: contact.displayName, + messageHash: mcoReaction.dartHash, + rawText: rawText, + contactID: contact.id, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved MCO DM reaction \(mcoReaction.emoji) to message \(candidate.id)") + } + return true } - /// Handles a meshcore-open channel reaction by computing Dart hashes against DB candidates. - /// - /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. - private func handleMCOChannelReaction( - _ mcoReaction: ParsedMCOReaction, - rawText: String, - channelIndex: UInt8, - senderNodeName: String?, - selfNodeName: String, - receiveTime: Date, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - let senderName = senderNodeName ?? "Unknown" - let timestampWindow = reactionTimestampWindow(at: receiveTime) - - guard let candidates = try? await dependencies.dataStore.fetchChannelMessageCandidates( - radioID: radioID, - channelIndex: channelIndex, - timestampWindow: timestampWindow, - limit: 200 - ), !candidates.isEmpty else { - logger.debug("MCO channel reaction \(mcoReaction.emoji): no candidates in window") - return true - } - - for candidate in candidates { - // Skip messages that are themselves reactions - if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } - - // For channel messages, the Dart hash includes the sender name - let candidateSenderName: String? - if candidate.direction == .outgoing { - candidateSenderName = selfNodeName.isEmpty ? nil : selfNodeName - } else { - candidateSenderName = candidate.senderNodeName - } - - let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: candidate.reactionTimestamp, - senderName: candidateSenderName, - text: candidate.text - ) - - guard candidateHash == mcoReaction.dartHash else { continue } - - let reactionDTO = ReactionDTO( - messageID: candidate.id, - emoji: mcoReaction.emoji, - senderName: senderName, - messageHash: mcoReaction.dartHash, - rawText: rawText, - channelIndex: channelIndex, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved MCO channel reaction \(mcoReaction.emoji) to message \(candidate.id)") - } - return true - } - - logger.debug("MCO channel reaction \(mcoReaction.emoji): no hash match found") - return true + logger.debug("MCO DM reaction \(mcoReaction.emoji): no hash match found") + return true + } + + /// Handles a meshcore-open channel reaction by computing Dart hashes against DB candidates. + /// + /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. + private func handleMCOChannelReaction( + _ mcoReaction: ParsedMCOReaction, + rawText: String, + channelIndex: UInt8, + senderNodeName: String?, + selfNodeName: String, + receiveTime: Date, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + let senderName = senderNodeName ?? "Unknown" + let timestampWindow = reactionTimestampWindow(at: receiveTime) + + guard let candidates = try? await dependencies.dataStore.fetchChannelMessageCandidates( + radioID: radioID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO channel reaction \(mcoReaction.emoji): no candidates in window") + return true } - // MARK: - meshcore-open V1 Reaction Handlers - - /// Handles a meshcore-open v1 DM reaction by matching timestamp + Dart text hash. - private func handleMCOV1DMReaction( - _ v1Reaction: ParsedMCOReactionV1, - rawText: String, - contact: ContactDTO, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - let timestampWindow = reactionTimestampWindow( - anchor: v1Reaction.timestampSeconds - ) - - guard let candidates = try? await dependencies.dataStore.fetchDMMessageCandidates( - radioID: radioID, - contactID: contact.id, - timestampWindow: timestampWindow, - limit: 200 - ), !candidates.isEmpty else { - logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no candidates in window") - return true - } - - for candidate in candidates { - if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } - - let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) - guard textHash == v1Reaction.textHash else { continue } - - let reactionDTO = ReactionDTO( - messageID: candidate.id, - emoji: v1Reaction.emoji, - senderName: contact.displayName, - messageHash: v1Reaction.messageIdHash, - rawText: rawText, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved MCO v1 DM reaction \(v1Reaction.emoji) to message \(candidate.id)") - } - return true - } + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } + + // For channel messages, the Dart hash includes the sender name + let candidateSenderName: String? = if candidate.direction == .outgoing { + selfNodeName.isEmpty ? nil : selfNodeName + } else { + candidate.senderNodeName + } + + let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: candidate.reactionTimestamp, + senderName: candidateSenderName, + text: candidate.text + ) + + guard candidateHash == mcoReaction.dartHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: mcoReaction.emoji, + senderName: senderName, + messageHash: mcoReaction.dartHash, + rawText: rawText, + channelIndex: channelIndex, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved MCO channel reaction \(mcoReaction.emoji) to message \(candidate.id)") + } + return true + } - logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no hash match found") - return true + logger.debug("MCO channel reaction \(mcoReaction.emoji): no hash match found") + return true + } + + // MARK: - meshcore-open V1 Reaction Handlers + + /// Handles a meshcore-open v1 DM reaction by matching timestamp + Dart text hash. + private func handleMCOV1DMReaction( + _ v1Reaction: ParsedMCOReactionV1, + rawText: String, + contact: ContactDTO, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + let timestampWindow = reactionTimestampWindow( + anchor: v1Reaction.timestampSeconds + ) + + guard let candidates = try? await dependencies.dataStore.fetchDMMessageCandidates( + radioID: radioID, + contactID: contact.id, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no candidates in window") + return true } - /// Handles a meshcore-open v1 channel reaction by matching timestamp + Dart sender/text hashes. - private func handleMCOV1ChannelReaction( - _ v1Reaction: ParsedMCOReactionV1, - rawText: String, - channelIndex: UInt8, - senderNodeName: String?, - selfNodeName: String, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - let senderName = senderNodeName ?? "Unknown" - let timestampWindow = reactionTimestampWindow( - anchor: v1Reaction.timestampSeconds - ) - - guard let candidates = try? await dependencies.dataStore.fetchChannelMessageCandidates( - radioID: radioID, - channelIndex: channelIndex, - timestampWindow: timestampWindow, - limit: 200 - ), !candidates.isEmpty else { - logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no candidates in window") - return true - } + for candidate in candidates { + if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } + + let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) + guard textHash == v1Reaction.textHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: v1Reaction.emoji, + senderName: contact.displayName, + messageHash: v1Reaction.messageIdHash, + rawText: rawText, + contactID: contact.id, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved MCO v1 DM reaction \(v1Reaction.emoji) to message \(candidate.id)") + } + return true + } - for candidate in candidates { - if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } - - // Verify sender name hash - let candidateSenderName: String? - if candidate.direction == .outgoing { - candidateSenderName = selfNodeName.isEmpty ? nil : selfNodeName - } else { - candidateSenderName = candidate.senderNodeName - } - - if let name = candidateSenderName { - let nameHash = MeshCoreOpenReactionParser.dartStringHash(name) - guard nameHash == v1Reaction.senderNameHash else { continue } - } - - // Verify text hash - let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) - guard textHash == v1Reaction.textHash else { continue } - - let reactionDTO = ReactionDTO( - messageID: candidate.id, - emoji: v1Reaction.emoji, - senderName: senderName, - messageHash: v1Reaction.messageIdHash, - rawText: rawText, - channelIndex: channelIndex, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved MCO v1 channel reaction \(v1Reaction.emoji) to message \(candidate.id)") - } - return true - } + logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no hash match found") + return true + } + + /// Handles a meshcore-open v1 channel reaction by matching timestamp + Dart sender/text hashes. + private func handleMCOV1ChannelReaction( + _ v1Reaction: ParsedMCOReactionV1, + rawText: String, + channelIndex: UInt8, + senderNodeName: String?, + selfNodeName: String, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + let senderName = senderNodeName ?? "Unknown" + let timestampWindow = reactionTimestampWindow( + anchor: v1Reaction.timestampSeconds + ) + + guard let candidates = try? await dependencies.dataStore.fetchChannelMessageCandidates( + radioID: radioID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no candidates in window") + return true + } - logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no hash match found") - return true + for candidate in candidates { + if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } + + // Verify sender name hash + let candidateSenderName: String? = if candidate.direction == .outgoing { + selfNodeName.isEmpty ? nil : selfNodeName + } else { + candidate.senderNodeName + } + + if let name = candidateSenderName { + let nameHash = MeshCoreOpenReactionParser.dartStringHash(name) + guard nameHash == v1Reaction.senderNameHash else { continue } + } + + // Verify text hash + let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) + guard textHash == v1Reaction.textHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: v1Reaction.emoji, + senderName: senderName, + messageHash: v1Reaction.messageIdHash, + rawText: rawText, + channelIndex: channelIndex, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved MCO v1 channel reaction \(v1Reaction.emoji) to message \(candidate.id)") + } + return true } + + logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no hash match found") + return true + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift index ac03856c..bf0d06da 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift @@ -4,734 +4,734 @@ import Foundation // MARK: - Full Sync & Connection Lifecycle extension SyncCoordinator { - private struct ContactChannelSyncResult: Sendable { - let contacts: SyncPhaseStatus - let channels: SyncPhaseStatus - let channelRetryIndices: [UInt8] + private struct ContactChannelSyncResult { + let contacts: SyncPhaseStatus + let channels: SyncPhaseStatus + let channelRetryIndices: [UInt8] + } + + // MARK: - Full Sync + + /// Performs full sync of contacts, channels, and messages from device. + /// + /// This is the core sync method that ensures all data is pulled from the device. + /// It syncs in order: contacts → channels → messages. + /// + /// - Parameters: + /// - radioID: The connected device UUID + /// - dataStore: Persistence store for data operations + /// - contactService: Service for contact sync + /// - channelService: Service for channel sync + /// - messagePollingService: Service for message polling + /// - appStateProvider: Optional provider for foreground/background state. When nil, + /// defaults to foreground mode (channels sync). When provided and app is backgrounded, + /// channel sync is skipped to reduce BLE traffic. + /// - rxLogService: Optional service for updating contact public keys after sync. + /// - forceFullSync: When true, ignores lastContactSync watermark and fetches all contacts. + @discardableResult + func performFullSync( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol, + channelService: some ChannelServiceProtocol, + messagePollingService: some MessagePollingServiceProtocol, + appStateProvider: AppStateProvider? = nil, + rxLogService: RxLogService? = nil, + notificationService: NotificationService? = nil, + forceFullSync: Bool = false, + channelSyncConfig: ChannelSyncConfig = .none, + platformName: String = "unknown" + ) async throws -> FullSyncResult { + // Prevent concurrent syncs — actor-local flag avoids the TOCTOU window + // that existed when guarding via `await state.isSyncing` + guard !isSyncInProgress else { + logger.warning("performFullSync called while already syncing, ignoring duplicate") + return .skipped } - - // MARK: - Full Sync - - /// Performs full sync of contacts, channels, and messages from device. - /// - /// This is the core sync method that ensures all data is pulled from the device. - /// It syncs in order: contacts → channels → messages. - /// - /// - Parameters: - /// - radioID: The connected device UUID - /// - dataStore: Persistence store for data operations - /// - contactService: Service for contact sync - /// - channelService: Service for channel sync - /// - messagePollingService: Service for message polling - /// - appStateProvider: Optional provider for foreground/background state. When nil, - /// defaults to foreground mode (channels sync). When provided and app is backgrounded, - /// channel sync is skipped to reduce BLE traffic. - /// - rxLogService: Optional service for updating contact public keys after sync. - /// - forceFullSync: When true, ignores lastContactSync watermark and fetches all contacts. - @discardableResult - func performFullSync( - radioID: UUID, - dataStore: any PersistenceStoreProtocol, - contactService: some ContactServiceProtocol, - channelService: some ChannelServiceProtocol, - messagePollingService: some MessagePollingServiceProtocol, - appStateProvider: AppStateProvider? = nil, - rxLogService: RxLogService? = nil, - notificationService: NotificationService? = nil, - forceFullSync: Bool = false, - channelSyncConfig: ChannelSyncConfig = .none, - platformName: String = "unknown" - ) async throws -> FullSyncResult { - // Prevent concurrent syncs — actor-local flag avoids the TOCTOU window - // that existed when guarding via `await state.isSyncing` - guard !isSyncInProgress else { - logger.warning("performFullSync called while already syncing, ignoring duplicate") - return .skipped + isSyncInProgress = true + defer { isSyncInProgress = false } + + return try await runFullSync( + radioID: radioID, + dataStore: dataStore, + contactService: contactService, + channelService: channelService, + messagePollingService: messagePollingService, + appStateProvider: appStateProvider, + rxLogService: rxLogService, + notificationService: notificationService, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: platformName + ) + } + + /// Full-sync body without the `isSyncInProgress` claim. Callers must hold + /// the claim already: `performFullSync` takes it per call, while + /// `onConnectionEstablished` claims it before its handler-wiring awaits so + /// racing connection setups cannot double-wire. + private func runFullSync( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol, + channelService: some ChannelServiceProtocol, + messagePollingService: some MessagePollingServiceProtocol, + appStateProvider: AppStateProvider? = nil, + rxLogService: RxLogService? = nil, + notificationService: NotificationService? = nil, + forceFullSync: Bool = false, + channelSyncConfig: ChannelSyncConfig = .none, + platformName: String = "unknown" + ) async throws -> FullSyncResult { + logger.info("Starting full sync for device \(radioID)") + let syncStart = ContinuousClock.now + + do { + // Set phase before triggering pill visibility + logger.info("[Sync] State → .syncing(.contacts)") + await setState(.syncing(progress: SyncProgress(phase: .contacts, current: 0, total: 0))) + hasEndedSyncActivity = false + logger.info("[Sync] Calling onSyncActivityStarted") + await onSyncActivityStarted?() + + // Perform contacts and channels sync (activity should show pill). + // Contacts remain connection-critical. Channel errors are degraded + // state: keep existing local channels and let the caller schedule a + // channel-only retry instead of replaying contacts. + let contactChannelResult = try await syncContactsAndChannels( + radioID: radioID, + dataStore: dataStore, + contactService: contactService, + channelService: channelService, + appStateProvider: appStateProvider, + rxLogService: rxLogService, + forceFullSync: forceFullSync, + channelSyncSkipWindow: channelSyncConfig.channelSyncSkipWindow, + lastCleanChannelSync: channelSyncConfig.lastCleanChannelSync, + lastAttemptedChannelSync: channelSyncConfig.lastAttemptedChannelSync, + usePipelinedRead: channelSyncConfig.usePipelinedChannelRead + ) + + // End sync activity before messages phase (pill should hide). + // During resync, the outer bracket (beginResyncActivity) holds syncActivityCount >= 1, + // so this inner succeeded=true decrement cannot reach zero and prematurely trigger + // the "Ready" toast. During initial sync there is no outer bracket, and reaching + // zero here is the correct "sync complete" signal. + await endSyncActivityOnce(succeeded: true) + + // Phase 3: Messages (no pill for this phase) + logger.info("[Sync] State → .syncing(.messages)") + await setState(.syncing(progress: SyncProgress(phase: .messages, current: 0, total: 0))) + let messageStart = ContinuousClock.now + let messageCount: Int + let messageStatus: SyncPhaseStatus + do { + messageCount = try await messagePollingService.pollAllMessages() + messageStatus = .clean + } catch let error as CancellationError { + throw error + } catch { + messageCount = 0 + messageStatus = .failed(error.localizedDescription) + logger.warning("[Sync] Message polling failed after contacts/channels: \(error.localizedDescription)") + } + let messageElapsed = ContinuousClock.now - messageStart + logger.info("[Sync] Phase end: messages - \(messageCount) polled in \(messageElapsed)") + + // Clear notification suppression immediately after catch-up poll completes. + // All catch-up messages have been processed with suppression active; + // any subsequent event-monitor messages are genuinely new and should notify. + if let notificationService { + cancelSuppressionWatchdog() + await MainActor.run { + notificationService.isSuppressingNotifications = false } - isSyncInProgress = true - defer { isSyncInProgress = false } - - return try await runFullSync( - radioID: radioID, - dataStore: dataStore, - contactService: contactService, - channelService: channelService, - messagePollingService: messagePollingService, - appStateProvider: appStateProvider, - rxLogService: rxLogService, - notificationService: notificationService, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: platformName - ) + } + + await notifyConversationsChanged() + + // Complete + logger.info("[Sync] State → .synced") + await setState(.synced) + await setLastSyncDate(Date()) + + let elapsed = ContinuousClock.now - syncStart + logger.info("[Sync] Complete: platform=\(platformName), messages=\(messageCount), duration=\(elapsed)") + return FullSyncResult( + contacts: contactChannelResult.contacts, + channels: contactChannelResult.channels, + messages: messageStatus, + channelRetryIndices: contactChannelResult.channelRetryIndices + ) + } catch let error as CancellationError { + // Defensive: ensure activity count is decremented even if cancellation + // occurs outside the contacts/channels error path. + await endSyncActivityOnce() + await setState(.idle) + throw error + } catch { + // Defensive: ensure activity count is decremented even if an error is + // thrown from a path that bypasses the inner contacts/channels catch. + await endSyncActivityOnce() + logger.warning("[Sync] State → .failed: \(error.localizedDescription)") + await setState(.failed(.syncFailed(error.localizedDescription))) + throw error } - - /// Full-sync body without the `isSyncInProgress` claim. Callers must hold - /// the claim already: `performFullSync` takes it per call, while - /// `onConnectionEstablished` claims it before its handler-wiring awaits so - /// racing connection setups cannot double-wire. - private func runFullSync( - radioID: UUID, - dataStore: any PersistenceStoreProtocol, - contactService: some ContactServiceProtocol, - channelService: some ChannelServiceProtocol, - messagePollingService: some MessagePollingServiceProtocol, - appStateProvider: AppStateProvider? = nil, - rxLogService: RxLogService? = nil, - notificationService: NotificationService? = nil, - forceFullSync: Bool = false, - channelSyncConfig: ChannelSyncConfig = .none, - platformName: String = "unknown" - ) async throws -> FullSyncResult { - logger.info("Starting full sync for device \(radioID)") - let syncStart = ContinuousClock.now - - do { - // Set phase before triggering pill visibility - logger.info("[Sync] State → .syncing(.contacts)") - await setState(.syncing(progress: SyncProgress(phase: .contacts, current: 0, total: 0))) - hasEndedSyncActivity = false - logger.info("[Sync] Calling onSyncActivityStarted") - await onSyncActivityStarted?() - - // Perform contacts and channels sync (activity should show pill). - // Contacts remain connection-critical. Channel errors are degraded - // state: keep existing local channels and let the caller schedule a - // channel-only retry instead of replaying contacts. - let contactChannelResult = try await syncContactsAndChannels( - radioID: radioID, - dataStore: dataStore, - contactService: contactService, - channelService: channelService, - appStateProvider: appStateProvider, - rxLogService: rxLogService, - forceFullSync: forceFullSync, - channelSyncSkipWindow: channelSyncConfig.channelSyncSkipWindow, - lastCleanChannelSync: channelSyncConfig.lastCleanChannelSync, - lastAttemptedChannelSync: channelSyncConfig.lastAttemptedChannelSync, - usePipelinedRead: channelSyncConfig.usePipelinedChannelRead - ) - - // End sync activity before messages phase (pill should hide). - // During resync, the outer bracket (beginResyncActivity) holds syncActivityCount >= 1, - // so this inner succeeded=true decrement cannot reach zero and prematurely trigger - // the "Ready" toast. During initial sync there is no outer bracket, and reaching - // zero here is the correct "sync complete" signal. - await endSyncActivityOnce(succeeded: true) - - // Phase 3: Messages (no pill for this phase) - logger.info("[Sync] State → .syncing(.messages)") - await setState(.syncing(progress: SyncProgress(phase: .messages, current: 0, total: 0))) - let messageStart = ContinuousClock.now - let messageCount: Int - let messageStatus: SyncPhaseStatus - do { - messageCount = try await messagePollingService.pollAllMessages() - messageStatus = .clean - } catch let error as CancellationError { - throw error - } catch { - messageCount = 0 - messageStatus = .failed(error.localizedDescription) - logger.warning("[Sync] Message polling failed after contacts/channels: \(error.localizedDescription)") - } - let messageElapsed = ContinuousClock.now - messageStart - logger.info("[Sync] Phase end: messages - \(messageCount) polled in \(messageElapsed)") - - // Clear notification suppression immediately after catch-up poll completes. - // All catch-up messages have been processed with suppression active; - // any subsequent event-monitor messages are genuinely new and should notify. - if let notificationService { - cancelSuppressionWatchdog() - await MainActor.run { - notificationService.isSuppressingNotifications = false - } - } - - await notifyConversationsChanged() - - // Complete - logger.info("[Sync] State → .synced") - await setState(.synced) - await setLastSyncDate(Date()) - - let elapsed = ContinuousClock.now - syncStart - logger.info("[Sync] Complete: platform=\(platformName), messages=\(messageCount), duration=\(elapsed)") - return FullSyncResult( - contacts: contactChannelResult.contacts, - channels: contactChannelResult.channels, - messages: messageStatus, - channelRetryIndices: contactChannelResult.channelRetryIndices - ) - } catch let error as CancellationError { - // Defensive: ensure activity count is decremented even if cancellation - // occurs outside the contacts/channels error path. - await endSyncActivityOnce() - await setState(.idle) - throw error - } catch { - // Defensive: ensure activity count is decremented even if an error is - // thrown from a path that bypasses the inner contacts/channels catch. - await endSyncActivityOnce() - logger.warning("[Sync] State → .failed: \(error.localizedDescription)") - await setState(.failed(.syncFailed(error.localizedDescription))) - throw error - } + } + + /// Attempts to resync data after a previous sync failure. + /// Unlike onConnectionEstablished, does not rewire handlers or restart event monitoring. + /// - Parameters: + /// - radioID: The connected device UUID + /// - dependencies: The sync dependency surface + /// - forceFullSync: When true, forces a full contact sync instead of incremental. + /// - channelSyncConfig: Channel sync skip configuration. + /// - platformName: Platform name for instrumentation logging. + /// - Returns: `true` if sync succeeded, `false` if it failed + func performResync( + radioID: UUID, + dependencies: SyncDependencies, + forceFullSync: Bool = false, + channelSyncConfig: ChannelSyncConfig = .none, + platformName: String = "unknown" + ) async -> Bool { + #if DEBUG + if let override = performResyncOverride { + return await override(radioID, dependencies) + } + #endif + logger.info("Attempting resync for device \(radioID)") + + await MainActor.run { + logger.info("Suppressing message notifications during resync") + dependencies.notificationService.isSuppressingNotifications = true } - - /// Attempts to resync data after a previous sync failure. - /// Unlike onConnectionEstablished, does not rewire handlers or restart event monitoring. - /// - Parameters: - /// - radioID: The connected device UUID - /// - dependencies: The sync dependency surface - /// - forceFullSync: When true, forces a full contact sync instead of incremental. - /// - channelSyncConfig: Channel sync skip configuration. - /// - platformName: Platform name for instrumentation logging. - /// - Returns: `true` if sync succeeded, `false` if it failed - func performResync( - radioID: UUID, - dependencies: SyncDependencies, - forceFullSync: Bool = false, - channelSyncConfig: ChannelSyncConfig = .none, - platformName: String = "unknown" - ) async -> Bool { - #if DEBUG - if let override = performResyncOverride { - return await override(radioID, dependencies) - } - #endif - logger.info("Attempting resync for device \(radioID)") - - await MainActor.run { - logger.info("Suppressing message notifications during resync") - dependencies.notificationService.isSuppressingNotifications = true - } - startSuppressionWatchdog(notificationService: dependencies.notificationService) - logger.info("[Sync] Pausing auto-fetch for resync") - await dependencies.messagePollingService.pauseAutoFetch() - - do { - let result = try await performFullSync( - radioID: radioID, - dataStore: dependencies.dataStore, - contactService: dependencies.contactService, - channelService: dependencies.channelService, - messagePollingService: dependencies.messagePollingService, - appStateProvider: dependencies.appStateProvider, - rxLogService: dependencies.rxLogService, - notificationService: dependencies.notificationService, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: platformName - ) - - startDiscoveryEventMonitoring(dependencies: dependencies, radioID: radioID) - - await drainHandlersAndResumeNotifications( - notificationService: dependencies.notificationService, - messagePollingService: dependencies.messagePollingService, - context: "resync complete" - ) - logger.info("[Sync] Resuming auto-fetch after resync") - await dependencies.messagePollingService.resumeAutoFetch() - - if result.isConnectionUsable { - logger.info("Resync succeeded") - return true - } - - logger.warning("Resync completed without usable contacts") - return false - } catch { - await drainHandlersAndResumeNotifications( - notificationService: dependencies.notificationService, - messagePollingService: dependencies.messagePollingService, - context: "resync failed" - ) - await dependencies.messagePollingService.resumeAutoFetch() - - logger.warning("Resync failed: \(error.localizedDescription)") - await setState(.failed(.syncFailed(error.localizedDescription))) - return false - } + startSuppressionWatchdog(notificationService: dependencies.notificationService) + logger.info("[Sync] Pausing auto-fetch for resync") + await dependencies.messagePollingService.pauseAutoFetch() + + do { + let result = try await performFullSync( + radioID: radioID, + dataStore: dependencies.dataStore, + contactService: dependencies.contactService, + channelService: dependencies.channelService, + messagePollingService: dependencies.messagePollingService, + appStateProvider: dependencies.appStateProvider, + rxLogService: dependencies.rxLogService, + notificationService: dependencies.notificationService, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: platformName + ) + + startDiscoveryEventMonitoring(dependencies: dependencies, radioID: radioID) + + await drainHandlersAndResumeNotifications( + notificationService: dependencies.notificationService, + messagePollingService: dependencies.messagePollingService, + context: "resync complete" + ) + logger.info("[Sync] Resuming auto-fetch after resync") + await dependencies.messagePollingService.resumeAutoFetch() + + if result.isConnectionUsable { + logger.info("Resync succeeded") + return true + } + + logger.warning("Resync completed without usable contacts") + return false + } catch { + await drainHandlersAndResumeNotifications( + notificationService: dependencies.notificationService, + messagePollingService: dependencies.messagePollingService, + context: "resync failed" + ) + await dependencies.messagePollingService.resumeAutoFetch() + + logger.warning("Resync failed: \(error.localizedDescription)") + await setState(.failed(.syncFailed(error.localizedDescription))) + return false } - - // MARK: - Connection Lifecycle - - /// Called by ConnectionManager when connection is established. - /// Wires handlers, starts event monitoring, and performs initial sync. - /// - /// This is the critical method that fixes the handler wiring gap: - /// 1. Wire message handlers first (before events can arrive) - /// 2. Start event monitoring (handlers are now ready) - /// 3. Perform full sync (contacts, channels, messages) - /// 4. Start discovery event monitoring (for ongoing contact discovery) - /// - /// - Parameters: - /// - radioID: The connected device UUID - /// - dependencies: The sync dependency surface - /// - forceFullSync: When true, forces a full contact sync instead of incremental. - /// - channelSyncConfig: Channel sync skip configuration. - /// - platformName: Platform name for instrumentation logging. - @discardableResult - func onConnectionEstablished( - radioID: UUID, - dependencies: SyncDependencies, - forceFullSync: Bool = false, - channelSyncConfig: ChannelSyncConfig = .none, - platformName: String = "unknown" - ) async throws -> FullSyncResult { - logger.info("Connection established for device \(radioID)") - - // Claim synchronously before the wiring awaits below. A read-only guard - // let two racing calls (rapid auto-reconnect cycles) both pass and - // double-wire handlers; the claim makes the loser skip immediately. - guard !isSyncInProgress else { - logger.warning("onConnectionEstablished called while already syncing, ignoring duplicate") - return .skipped - } - isSyncInProgress = true - defer { isSyncInProgress = false } - - // Suppress message notifications during sync to avoid flooding user on reconnect - // Unread counts and badges still update - only system notifications are suppressed - await MainActor.run { - logger.info("Suppressing message notifications during sync") - dependencies.notificationService.isSuppressingNotifications = true - } - startSuppressionWatchdog(notificationService: dependencies.notificationService) - - do { - // Defer advert-driven contact fetches during sync to avoid BLE contention - await dependencies.advertisementService.setSyncingContacts(true) - - // 1. Wire message handlers first (before events can arrive) - await wireMessageHandlers(dependencies: dependencies, radioID: radioID) - - // Clean up legacy blocked sender messages still in DB from older app versions - await deleteBlockedSenderMessages(radioID: radioID, dataStore: dependencies.dataStore) - - // 2. Now start event monitoring (handlers are ready), but delay auto-fetch and advert monitoring until after sync - logger.info("[Sync] Starting event monitoring for device \(radioID.uuidString.prefix(8))") - await dependencies.startEventMonitoring(radioID, false) - - // 3. Export device private key for direct message decryption - do { - let privateKey = try await dependencies.exportPrivateKey() - await dependencies.rxLogService.updatePrivateKey(privateKey) - logger.debug("Device private key exported for direct message decryption") - } catch { - logger.warning("Failed to export private key: \(error.localizedDescription)") - } - - // 4. Perform full sync (claim is already held; the guarded entry - // point would see its own claim and skip) - let syncResult = try await runFullSync( - radioID: radioID, - dataStore: dependencies.dataStore, - contactService: dependencies.contactService, - channelService: dependencies.channelService, - messagePollingService: dependencies.messagePollingService, - appStateProvider: dependencies.appStateProvider, - rxLogService: dependencies.rxLogService, - notificationService: dependencies.notificationService, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: platformName - ) - - // 5. Start discovery event monitoring (for ongoing contact discovery). - // Intentionally after the full sync so adverts arriving during sync - // do not spam notifications. - startDiscoveryEventMonitoring(dependencies: dependencies, radioID: radioID) - - // 6. Flush deferred advert-driven contact fetches now that discovery monitoring is live - await dependencies.advertisementService.setSyncingContacts(false) - - // 7. Drain pending message handlers and resume notifications - await drainHandlersAndResumeNotifications( - notificationService: dependencies.notificationService, - messagePollingService: dependencies.messagePollingService, - context: "sync complete" - ) - - // 8. Start auto-fetch after suppression is cleared to avoid notification spam - logger.info("[Sync] Starting auto-fetch for device \(radioID.uuidString.prefix(8))") - await dependencies.messagePollingService.startAutoFetch(radioID: radioID) - - logger.info("Connection setup complete for device \(radioID)") - return syncResult - } catch { - // Drain pending message handlers and resume notifications - await drainHandlersAndResumeNotifications( - notificationService: dependencies.notificationService, - messagePollingService: dependencies.messagePollingService, - context: "sync failed" - ) - await dependencies.advertisementService.setSyncingContacts(false) - throw error - } + } + + // MARK: - Connection Lifecycle + + /// Called by ConnectionManager when connection is established. + /// Wires handlers, starts event monitoring, and performs initial sync. + /// + /// This is the critical method that fixes the handler wiring gap: + /// 1. Wire message handlers first (before events can arrive) + /// 2. Start event monitoring (handlers are now ready) + /// 3. Perform full sync (contacts, channels, messages) + /// 4. Start discovery event monitoring (for ongoing contact discovery) + /// + /// - Parameters: + /// - radioID: The connected device UUID + /// - dependencies: The sync dependency surface + /// - forceFullSync: When true, forces a full contact sync instead of incremental. + /// - channelSyncConfig: Channel sync skip configuration. + /// - platformName: Platform name for instrumentation logging. + @discardableResult + func onConnectionEstablished( + radioID: UUID, + dependencies: SyncDependencies, + forceFullSync: Bool = false, + channelSyncConfig: ChannelSyncConfig = .none, + platformName: String = "unknown" + ) async throws -> FullSyncResult { + logger.info("Connection established for device \(radioID)") + + // Claim synchronously before the wiring awaits below. A read-only guard + // let two racing calls (rapid auto-reconnect cycles) both pass and + // double-wire handlers; the claim makes the loser skip immediately. + guard !isSyncInProgress else { + logger.warning("onConnectionEstablished called while already syncing, ignoring duplicate") + return .skipped + } + isSyncInProgress = true + defer { isSyncInProgress = false } + + // Suppress message notifications during sync to avoid flooding user on reconnect + // Unread counts and badges still update - only system notifications are suppressed + await MainActor.run { + logger.info("Suppressing message notifications during sync") + dependencies.notificationService.isSuppressingNotifications = true + } + startSuppressionWatchdog(notificationService: dependencies.notificationService) + + do { + // Defer advert-driven contact fetches during sync to avoid BLE contention + await dependencies.advertisementService.setSyncingContacts(true) + + // 1. Wire message handlers first (before events can arrive) + await wireMessageHandlers(dependencies: dependencies, radioID: radioID) + + // Clean up legacy blocked sender messages still in DB from older app versions + await deleteBlockedSenderMessages(radioID: radioID, dataStore: dependencies.dataStore) + + // 2. Now start event monitoring (handlers are ready), but delay auto-fetch and advert monitoring until after sync + logger.info("[Sync] Starting event monitoring for device \(radioID.uuidString.prefix(8))") + await dependencies.startEventMonitoring(radioID, false) + + // 3. Export device private key for direct message decryption + do { + let privateKey = try await dependencies.exportPrivateKey() + await dependencies.rxLogService.updatePrivateKey(privateKey) + logger.debug("Device private key exported for direct message decryption") + } catch { + logger.warning("Failed to export private key: \(error.localizedDescription)") + } + + // 4. Perform full sync (claim is already held; the guarded entry + // point would see its own claim and skip) + let syncResult = try await runFullSync( + radioID: radioID, + dataStore: dependencies.dataStore, + contactService: dependencies.contactService, + channelService: dependencies.channelService, + messagePollingService: dependencies.messagePollingService, + appStateProvider: dependencies.appStateProvider, + rxLogService: dependencies.rxLogService, + notificationService: dependencies.notificationService, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: platformName + ) + + // 5. Start discovery event monitoring (for ongoing contact discovery). + // Intentionally after the full sync so adverts arriving during sync + // do not spam notifications. + startDiscoveryEventMonitoring(dependencies: dependencies, radioID: radioID) + + // 6. Flush deferred advert-driven contact fetches now that discovery monitoring is live + await dependencies.advertisementService.setSyncingContacts(false) + + // 7. Drain pending message handlers and resume notifications + await drainHandlersAndResumeNotifications( + notificationService: dependencies.notificationService, + messagePollingService: dependencies.messagePollingService, + context: "sync complete" + ) + + // 8. Start auto-fetch after suppression is cleared to avoid notification spam + logger.info("[Sync] Starting auto-fetch for device \(radioID.uuidString.prefix(8))") + await dependencies.messagePollingService.startAutoFetch(radioID: radioID) + + logger.info("Connection setup complete for device \(radioID)") + return syncResult + } catch { + // Drain pending message handlers and resume notifications + await drainHandlersAndResumeNotifications( + notificationService: dependencies.notificationService, + messagePollingService: dependencies.messagePollingService, + context: "sync failed" + ) + await dependencies.advertisementService.setSyncingContacts(false) + throw error + } + } + + /// Called when disconnecting from device + /// + /// If disconnect occurs mid-sync (during contacts or channels phase), we must call + /// onSyncActivityEnded to decrement the activity count, otherwise the pill stays stuck. + func onDisconnected(notificationService: NotificationService) async { + let currentState = await state + logger.warning( + "[Sync] onDisconnected called - syncState: \(String(describing: currentState)), hasEndedSyncActivity: \(hasEndedSyncActivity)" + ) + + // Safety net: clear sync guard flag on disconnect + if isSyncInProgress { + logger.warning("isSyncInProgress still true at disconnect — clearing as safety net") } + isSyncInProgress = false - /// Called when disconnecting from device - /// - /// If disconnect occurs mid-sync (during contacts or channels phase), we must call - /// onSyncActivityEnded to decrement the activity count, otherwise the pill stays stuck. - func onDisconnected(notificationService: NotificationService) async { - let currentState = await state - logger.warning( - "[Sync] onDisconnected called - syncState: \(String(describing: currentState)), hasEndedSyncActivity: \(hasEndedSyncActivity)" - ) + // Note: pending reactions are not cleared on disconnect - they persist for the app session + // This handles temporary BLE disconnects without losing queued reactions + unresolvedChannelIndices.removeAll() + lastUnresolvedChannelSummaryAt = nil - // Safety net: clear sync guard flag on disconnect - if isSyncInProgress { - logger.warning("isSyncInProgress still true at disconnect — clearing as safety net") - } - isSyncInProgress = false + // If we're mid-sync in contacts or channels phase, end the activity to hide the pill + if case let .syncing(progress) = currentState, + progress.phase == .contacts || progress.phase == .channels { + await endSyncActivityOnce() + } - // Note: pending reactions are not cleared on disconnect - they persist for the app session - // This handles temporary BLE disconnects without losing queued reactions - unresolvedChannelIndices.removeAll() - lastUnresolvedChannelSummaryAt = nil + logger.info("[Sync] State → .idle (disconnected)") + await setState(.idle) - // If we're mid-sync in contacts or channels phase, end the activity to hide the pill - if case .syncing(let progress) = currentState, - progress.phase == .contacts || progress.phase == .channels { - await endSyncActivityOnce() - } + // Safety net: ensure suppression is cleared on disconnect + // Handles edge cases like connection dropping mid-sync or force-quit + cancelSuppressionWatchdog() + await MainActor.run { + notificationService.isSuppressingNotifications = false + } - logger.info("[Sync] State → .idle (disconnected)") - await setState(.idle) + logger.info("Disconnected, sync state reset to idle") + } + + // MARK: - Sync Helpers + + /// Syncs contacts and channels from the device (phases 1 and 2 of full sync). + private func syncContactsAndChannels( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol, + channelService: some ChannelServiceProtocol, + appStateProvider: AppStateProvider?, + rxLogService: RxLogService?, + forceFullSync: Bool, + channelSyncSkipWindow: Duration = .zero, + lastCleanChannelSync: Date? = nil, + lastAttemptedChannelSync: Date? = nil, + usePipelinedRead: Bool = false + ) async throws -> ContactChannelSyncResult { + // Fetch device once for both contacts (lastContactSync) and channels (maxChannels) + let device = try await dataStore.fetchDevice(radioID: radioID) + + // Phase 1: Contacts (incremental unless forced full) + let lastContactSync: Date? = forceFullSync ? nil : { + guard let timestamp = device?.lastContactSync, timestamp > 0 else { return nil } + return Date(timeIntervalSince1970: Double(timestamp)) + }() + if let watermark = lastContactSync { + logger.info("[Sync] Phase start: contacts (incremental, watermark=\(watermark.formatted(.iso8601)))") + } else { + let reason = forceFullSync ? "forceFullSync" : "no watermark" + logger.notice("[Sync] Phase start: contacts (FULL sync, reason=\(reason)) — local contacts not on device will be pruned") + } - // Safety net: ensure suppression is cleared on disconnect - // Handles edge cases like connection dropping mid-sync or force-quit - cancelSuppressionWatchdog() - await MainActor.run { - notificationService.isSuppressingNotifications = false - } + let contactStart = ContinuousClock.now + let contactResult = try await contactService.syncContacts(radioID: radioID, since: lastContactSync) + let contactElapsed = ContinuousClock.now - contactStart + let syncType = contactResult.isIncremental ? "incremental" : "full" + let forced = forceFullSync ? ", forced" : "" + logger.info("[Sync] Phase end: contacts - \(contactResult.contactsReceived) (\(syncType)\(forced)) in \(contactElapsed)") + await notifyContactsChanged() + + // Update lastContactSync watermark for future incremental syncs + if contactResult.lastSyncTimestamp > 0 { + try await dataStore.updateDeviceLastContactSync( + radioID: radioID, + timestamp: contactResult.lastSyncTimestamp + ) + } - logger.info("Disconnected, sync state reset to idle") + // Update RxLogService with contact public keys for direct message decryption + if let rxLogService { + do { + let publicKeys = try await dataStore.fetchContactPublicKeysByPrefix(radioID: radioID) + await rxLogService.updateContactPublicKeys(publicKeys) + logger.debug("Updated \(publicKeys.count) contact public keys for direct message decryption") + } catch { + logger.error("Failed to fetch contact public keys: \(error)") + } } - // MARK: - Sync Helpers - - /// Syncs contacts and channels from the device (phases 1 and 2 of full sync). - private func syncContactsAndChannels( - radioID: UUID, - dataStore: any PersistenceStoreProtocol, - contactService: some ContactServiceProtocol, - channelService: some ChannelServiceProtocol, - appStateProvider: AppStateProvider?, - rxLogService: RxLogService?, - forceFullSync: Bool, - channelSyncSkipWindow: Duration = .zero, - lastCleanChannelSync: Date? = nil, - lastAttemptedChannelSync: Date? = nil, - usePipelinedRead: Bool = false - ) async throws -> ContactChannelSyncResult { - // Fetch device once for both contacts (lastContactSync) and channels (maxChannels) - let device = try await dataStore.fetchDevice(radioID: radioID) - - // Phase 1: Contacts (incremental unless forced full) - let lastContactSync: Date? = forceFullSync ? nil : { - guard let timestamp = device?.lastContactSync, timestamp > 0 else { return nil } - return Date(timeIntervalSince1970: Double(timestamp)) - }() - if let watermark = lastContactSync { - logger.info("[Sync] Phase start: contacts (incremental, watermark=\(watermark.formatted(.iso8601)))") - } else { - let reason = forceFullSync ? "forceFullSync" : "no watermark" - logger.notice("[Sync] Phase start: contacts (FULL sync, reason=\(reason)) — local contacts not on device will be pruned") + // Phase 2: Channels (foreground only) + var channelStatus: SyncPhaseStatus = .skipped + var channelRetryIndices: [UInt8] = [] + logger.debug("About to check foreground state, provider exists: \(appStateProvider != nil)") + let shouldSyncChannels: Bool + if let provider = appStateProvider { + logger.debug("Calling isInForeground...") + shouldSyncChannels = await provider.isInForeground + logger.debug("isInForeground returned: \(shouldSyncChannels)") + } else { + logger.debug("No appStateProvider, defaulting to foreground mode") + shouldSyncChannels = true + } + logger.debug("Proceeding with shouldSyncChannels=\(shouldSyncChannels)") + if shouldSyncChannels { + let shouldSkipChannels: Bool = { + guard !forceFullSync, + channelSyncSkipWindow > .zero else { return false } + let now = Date() + if let lastSync = lastCleanChannelSync, + now.timeIntervalSince(lastSync) < Double(channelSyncSkipWindow.components.seconds) { + return true } - - let contactStart = ContinuousClock.now - let contactResult = try await contactService.syncContacts(radioID: radioID, since: lastContactSync) - let contactElapsed = ContinuousClock.now - contactStart - let syncType = contactResult.isIncremental ? "incremental" : "full" - let forced = forceFullSync ? ", forced" : "" - logger.info("[Sync] Phase end: contacts - \(contactResult.contactsReceived) (\(syncType)\(forced)) in \(contactElapsed)") - await notifyContactsChanged() - - // Update lastContactSync watermark for future incremental syncs - if contactResult.lastSyncTimestamp > 0 { - try await dataStore.updateDeviceLastContactSync( - radioID: radioID, - timestamp: contactResult.lastSyncTimestamp - ) + if let lastAttempt = lastAttemptedChannelSync, + now.timeIntervalSince(lastAttempt) < Double(channelSyncSkipWindow.components.seconds) { + return true } + return false + }() + + if shouldSkipChannels { + channelStatus = .skipped + logger.info("[Sync] Skipping channel sync (recent clean or attempted sync)") + } else { + logger.info("[Sync] State → .syncing(.channels)") + await setState(.syncing(progress: SyncProgress(phase: .channels, current: 0, total: 0))) + let maxChannels = device?.maxChannels ?? 0 + await onChannelSyncAttempted?(radioID) - // Update RxLogService with contact public keys for direct message decryption - if let rxLogService { + let channelStart = ContinuousClock.now + let channelResult: ChannelSyncResult + do { + channelResult = try await channelService.syncChannels( + radioID: radioID, + maxChannels: maxChannels, + usePipelinedRead: usePipelinedRead + ) + } catch let error as CancellationError { + throw error + } catch { + let channelElapsed = ContinuousClock.now - channelStart + channelStatus = .partial + logger.warning( + "[Sync] Phase end: channels partial after thrown error in \(channelElapsed): \(error.localizedDescription)" + ) + await logPostSyncChannelDiagnostics(radioID: radioID, dataStore: dataStore) + if let rxLogService { + await refreshRxLogChannels(radioID: radioID, dataStore: dataStore, rxLogService: rxLogService) + } + return ContactChannelSyncResult( + contacts: .clean, + channels: channelStatus, + channelRetryIndices: channelRetryIndices + ) + } + let channelElapsed = ContinuousClock.now - channelStart + logger.info("[Sync] Phase end: channels - \(channelResult.channelsSynced) synced (device capacity: \(maxChannels)) in \(channelElapsed)") + + var channelPhaseClean = channelResult.isComplete + let hasNonRetryableErrors = channelResult.errors.count > channelResult.retryableIndices.count + var remainingRetryableIndices = channelResult.retryableIndices + + // Retry failed channels once if there are retryable errors + if !channelResult.isComplete { + let retryableIndices = channelResult.retryableIndices + if !retryableIndices.isEmpty { + logger.info("Retrying \(retryableIndices.count) failed channels") + let retryResult: ChannelSyncResult do { - let publicKeys = try await dataStore.fetchContactPublicKeysByPrefix(radioID: radioID) - await rxLogService.updateContactPublicKeys(publicKeys) - logger.debug("Updated \(publicKeys.count) contact public keys for direct message decryption") + retryResult = try await channelService.retryFailedChannels( + radioID: radioID, + indices: retryableIndices + ) + } catch let error as CancellationError { + throw error } catch { - logger.error("Failed to fetch contact public keys: \(error)") - } - } - - // Phase 2: Channels (foreground only) - var channelStatus: SyncPhaseStatus = .skipped - var channelRetryIndices: [UInt8] = [] - logger.debug("About to check foreground state, provider exists: \(appStateProvider != nil)") - let shouldSyncChannels: Bool - if let provider = appStateProvider { - logger.debug("Calling isInForeground...") - shouldSyncChannels = await provider.isInForeground - logger.debug("isInForeground returned: \(shouldSyncChannels)") - } else { - logger.debug("No appStateProvider, defaulting to foreground mode") - shouldSyncChannels = true - } - logger.debug("Proceeding with shouldSyncChannels=\(shouldSyncChannels)") - if shouldSyncChannels { - let shouldSkipChannels: Bool = { - guard !forceFullSync, - channelSyncSkipWindow > .zero else { return false } - let now = Date() - if let lastSync = lastCleanChannelSync, - now.timeIntervalSince(lastSync) < Double(channelSyncSkipWindow.components.seconds) { - return true - } - if let lastAttempt = lastAttemptedChannelSync, - now.timeIntervalSince(lastAttempt) < Double(channelSyncSkipWindow.components.seconds) { - return true + logger.warning("Channel retry failed: \(error.localizedDescription)") + channelPhaseClean = false + remainingRetryableIndices = retryableIndices + retryResult = ChannelSyncResult( + channelsSynced: 0, + errors: retryableIndices.map { + ChannelSyncError( + index: $0, + errorType: .timeout, + description: error.localizedDescription + ) } - return false - }() + ) + } - if shouldSkipChannels { - channelStatus = .skipped - logger.info("[Sync] Skipping channel sync (recent clean or attempted sync)") + if retryResult.isComplete, !hasNonRetryableErrors { + logger.info("Retry recovered \(retryResult.channelsSynced) channels") + channelPhaseClean = true } else { - logger.info("[Sync] State → .syncing(.channels)") - await setState(.syncing(progress: SyncProgress(phase: .channels, current: 0, total: 0))) - let maxChannels = device?.maxChannels ?? 0 - await onChannelSyncAttempted?(radioID) - - let channelStart = ContinuousClock.now - let channelResult: ChannelSyncResult - do { - channelResult = try await channelService.syncChannels( - radioID: radioID, - maxChannels: maxChannels, - usePipelinedRead: usePipelinedRead - ) - } catch let error as CancellationError { - throw error - } catch { - let channelElapsed = ContinuousClock.now - channelStart - channelStatus = .partial - logger.warning( - "[Sync] Phase end: channels partial after thrown error in \(channelElapsed): \(error.localizedDescription)" - ) - await logPostSyncChannelDiagnostics(radioID: radioID, dataStore: dataStore) - if let rxLogService { - await refreshRxLogChannels(radioID: radioID, dataStore: dataStore, rxLogService: rxLogService) - } - return ContactChannelSyncResult( - contacts: .clean, - channels: channelStatus, - channelRetryIndices: channelRetryIndices - ) - } - let channelElapsed = ContinuousClock.now - channelStart - logger.info("[Sync] Phase end: channels - \(channelResult.channelsSynced) synced (device capacity: \(maxChannels)) in \(channelElapsed)") - - var channelPhaseClean = channelResult.isComplete - let hasNonRetryableErrors = channelResult.errors.count > channelResult.retryableIndices.count - var remainingRetryableIndices = channelResult.retryableIndices - - // Retry failed channels once if there are retryable errors - if !channelResult.isComplete { - let retryableIndices = channelResult.retryableIndices - if !retryableIndices.isEmpty { - logger.info("Retrying \(retryableIndices.count) failed channels") - let retryResult: ChannelSyncResult - do { - retryResult = try await channelService.retryFailedChannels( - radioID: radioID, - indices: retryableIndices - ) - } catch let error as CancellationError { - throw error - } catch { - logger.warning("Channel retry failed: \(error.localizedDescription)") - channelPhaseClean = false - remainingRetryableIndices = retryableIndices - retryResult = ChannelSyncResult( - channelsSynced: 0, - errors: retryableIndices.map { - ChannelSyncError( - index: $0, - errorType: .timeout, - description: error.localizedDescription - ) - } - ) - } - - if retryResult.isComplete && !hasNonRetryableErrors { - logger.info("Retry recovered \(retryResult.channelsSynced) channels") - channelPhaseClean = true - } else { - remainingRetryableIndices = retryResult.retryableIndices - if hasNonRetryableErrors { - logger.warning("Channels have non-retryable errors, phase not clean") - } - logger.warning("Channels still failing after retry: \(retryResult.errors.map { $0.index })") - channelPhaseClean = false - } - } - } - - if channelPhaseClean { - channelStatus = .clean - await onCleanChannelSync?(radioID) - } else { - channelStatus = .partial - channelRetryIndices = remainingRetryableIndices - } + remainingRetryableIndices = retryResult.retryableIndices + if hasNonRetryableErrors { + logger.warning("Channels have non-retryable errors, phase not clean") + } + logger.warning("Channels still failing after retry: \(retryResult.errors.map(\.index))") + channelPhaseClean = false } + } + } - await logPostSyncChannelDiagnostics(radioID: radioID, dataStore: dataStore) - if let rxLogService { - await refreshRxLogChannels(radioID: radioID, dataStore: dataStore, rxLogService: rxLogService) - } + if channelPhaseClean { + channelStatus = .clean + await onCleanChannelSync?(radioID) } else { - channelStatus = .skipped - logger.info("Skipping channel sync (app in background)") + channelStatus = .partial + channelRetryIndices = remainingRetryableIndices } - - return ContactChannelSyncResult( - contacts: .clean, - channels: channelStatus, - channelRetryIndices: channelRetryIndices - ) + } + + await logPostSyncChannelDiagnostics(radioID: radioID, dataStore: dataStore) + if let rxLogService { + await refreshRxLogChannels(radioID: radioID, dataStore: dataStore, rxLogService: rxLogService) + } + } else { + channelStatus = .skipped + logger.info("Skipping channel sync (app in background)") } - /// Retries only unresolved channel indices without replaying contacts/messages. - @discardableResult - func retryChannels( - radioID: UUID, - channelService: some ChannelServiceProtocol, - indices: [UInt8] - ) async -> ChannelSyncResult { - guard !indices.isEmpty else { - return ChannelSyncResult(channelsSynced: 0, errors: []) - } + return ContactChannelSyncResult( + contacts: .clean, + channels: channelStatus, + channelRetryIndices: channelRetryIndices + ) + } + + /// Retries only unresolved channel indices without replaying contacts/messages. + @discardableResult + func retryChannels( + radioID: UUID, + channelService: some ChannelServiceProtocol, + indices: [UInt8] + ) async -> ChannelSyncResult { + guard !indices.isEmpty else { + return ChannelSyncResult(channelsSynced: 0, errors: []) + } - guard !isSyncInProgress else { - logger.info("Channel-only retry skipped because sync is already active") - return ChannelSyncResult( - channelsSynced: 0, - errors: indices.map { - ChannelSyncError( - index: $0, - errorType: .circuitBreaker, - description: "Skipped because sync is already active" - ) - } - ) + guard !isSyncInProgress else { + logger.info("Channel-only retry skipped because sync is already active") + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError( + index: $0, + errorType: .circuitBreaker, + description: "Skipped because sync is already active" + ) } + ) + } - isSyncInProgress = true - defer { isSyncInProgress = false } - - logger.info("[Sync] State → .syncing(.channels) (channel-only retry)") - await setState(.syncing(progress: SyncProgress(phase: .channels, current: 0, total: indices.count))) - await onSyncActivityStarted?() - await onChannelSyncAttempted?(radioID) - - let result: ChannelSyncResult - do { - result = try await channelService.retryFailedChannels(radioID: radioID, indices: indices) - } catch is CancellationError { - await onSyncActivityEnded?(false) - await setState(.idle) - return ChannelSyncResult( - channelsSynced: 0, - errors: indices.map { - ChannelSyncError(index: $0, errorType: .transportError, description: "Retry cancelled") - } - ) - } catch { - logger.warning("Channel-only retry failed: \(error.localizedDescription)") - await onSyncActivityEnded?(false) - await setState(.synced) - return ChannelSyncResult( - channelsSynced: 0, - errors: indices.map { - ChannelSyncError(index: $0, errorType: .transportError, description: error.localizedDescription) - } - ) + isSyncInProgress = true + defer { isSyncInProgress = false } + + logger.info("[Sync] State → .syncing(.channels) (channel-only retry)") + await setState(.syncing(progress: SyncProgress(phase: .channels, current: 0, total: indices.count))) + await onSyncActivityStarted?() + await onChannelSyncAttempted?(radioID) + + let result: ChannelSyncResult + do { + result = try await channelService.retryFailedChannels(radioID: radioID, indices: indices) + } catch is CancellationError { + await onSyncActivityEnded?(false) + await setState(.idle) + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError(index: $0, errorType: .transportError, description: "Retry cancelled") } - - if result.isComplete { - await onCleanChannelSync?(radioID) + ) + } catch { + logger.warning("Channel-only retry failed: \(error.localizedDescription)") + await onSyncActivityEnded?(false) + await setState(.synced) + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError(index: $0, errorType: .transportError, description: error.localizedDescription) } - - await onSyncActivityEnded?(result.isComplete) - await setState(.synced) - return result + ) } - /// Cancels the suppression watchdog, resumes notifications, then waits for pending - /// message handlers to drain. Suppression is cleared first as defense-in-depth for - /// error paths where `performFullSync` throws before reaching `pollAllMessages()`. - /// Both operations are idempotent, so double-clearing from the happy path is harmless. - private func drainHandlersAndResumeNotifications( - notificationService: NotificationService, - messagePollingService: any MessagePollingServiceProtocol, - context: String - ) async { - cancelSuppressionWatchdog() - await MainActor.run { - logger.info("Resuming message notifications (\(context))") - notificationService.isSuppressingNotifications = false - } - - let pendingHandlerDrainTimeout: Duration = .seconds(30) - let didDrainPendingHandlers = await messagePollingService.waitForPendingHandlers(timeout: pendingHandlerDrainTimeout) - if !didDrainPendingHandlers { - logger.warning("Timed out waiting for pending message handlers") - } + if result.isComplete { + await onCleanChannelSync?(radioID) } - private func logPostSyncChannelDiagnostics(radioID: UUID, dataStore: any PersistenceStoreProtocol) async { - do { - let channels = try await dataStore.fetchChannels(radioID: radioID) - let emptyNameWithSecretIndices = channels - .filter { $0.name.isEmpty && $0.hasSecret } - .map(\.index) - .sorted() - logger.info( - "Post-sync channel diagnostics: total=\(channels.count), emptyNameWithSecret=\(emptyNameWithSecretIndices.count)" - ) - if !emptyNameWithSecretIndices.isEmpty { - logger.warning( - "Post-sync channels with empty names and non-zero secrets: \(emptyNameWithSecretIndices)" - ) - } - } catch { - logger.error("Failed to compute post-sync channel diagnostics: \(error)") - } + await onSyncActivityEnded?(result.isComplete) + await setState(.synced) + return result + } + + /// Cancels the suppression watchdog, resumes notifications, then waits for pending + /// message handlers to drain. Suppression is cleared first as defense-in-depth for + /// error paths where `performFullSync` throws before reaching `pollAllMessages()`. + /// Both operations are idempotent, so double-clearing from the happy path is harmless. + private func drainHandlersAndResumeNotifications( + notificationService: NotificationService, + messagePollingService: any MessagePollingServiceProtocol, + context: String + ) async { + cancelSuppressionWatchdog() + await MainActor.run { + logger.info("Resuming message notifications (\(context))") + notificationService.isSuppressingNotifications = false } - private func refreshRxLogChannels( - radioID: UUID, - dataStore: any PersistenceStoreProtocol, - rxLogService: RxLogService - ) async { - do { - let channels = try await dataStore.fetchChannels(radioID: radioID) - let secrets = Dictionary(uniqueKeysWithValues: channels.map { ($0.index, $0.secret) }) - let names = Dictionary(uniqueKeysWithValues: channels.map { ($0.index, $0.name) }) - await rxLogService.updateChannels(secrets: secrets, names: names) - logger.debug("Refreshed RxLogService channel cache with \(channels.count) channels") - } catch { - logger.error("Failed to refresh RxLogService channel cache: \(error)") - } + let pendingHandlerDrainTimeout: Duration = .seconds(30) + let didDrainPendingHandlers = await messagePollingService.waitForPendingHandlers(timeout: pendingHandlerDrainTimeout) + if !didDrainPendingHandlers { + logger.warning("Timed out waiting for pending message handlers") + } + } + + private func logPostSyncChannelDiagnostics(radioID: UUID, dataStore: any PersistenceStoreProtocol) async { + do { + let channels = try await dataStore.fetchChannels(radioID: radioID) + let emptyNameWithSecretIndices = channels + .filter { $0.name.isEmpty && $0.hasSecret } + .map(\.index) + .sorted() + logger.info( + "Post-sync channel diagnostics: total=\(channels.count), emptyNameWithSecret=\(emptyNameWithSecretIndices.count)" + ) + if !emptyNameWithSecretIndices.isEmpty { + logger.warning( + "Post-sync channels with empty names and non-zero secrets: \(emptyNameWithSecretIndices)" + ) + } + } catch { + logger.error("Failed to compute post-sync channel diagnostics: \(error)") + } + } + + private func refreshRxLogChannels( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + rxLogService: RxLogService + ) async { + do { + let channels = try await dataStore.fetchChannels(radioID: radioID) + let secrets = Dictionary(uniqueKeysWithValues: channels.map { ($0.index, $0.secret) }) + let names = Dictionary(uniqueKeysWithValues: channels.map { ($0.index, $0.name) }) + await rxLogService.updateChannels(secrets: secrets, names: names) + logger.debug("Refreshed RxLogService channel cache with \(channels.count) channels") + } catch { + logger.error("Failed to refresh RxLogService channel cache: \(error)") } + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift index c00d8ef0..63305c89 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift @@ -4,100 +4,100 @@ import Foundation // MARK: - Sync Types /// Current state of the sync coordinator -enum SyncState: Sendable, Equatable { - case idle - case syncing(progress: SyncProgress) - case synced - case failed(SyncCoordinatorError) - - /// Whether currently syncing - var isSyncing: Bool { - if case .syncing = self { return true } - return false - } - - static func == (lhs: SyncState, rhs: SyncState) -> Bool { - switch (lhs, rhs) { - case (.idle, .idle): return true - case (.synced, .synced): return true - case (.syncing(let a), .syncing(let b)): return a == b - case (.failed, .failed): return true // Simplified equality - default: return false - } +enum SyncState: Equatable { + case idle + case syncing(progress: SyncProgress) + case synced + case failed(SyncCoordinatorError) + + /// Whether currently syncing + var isSyncing: Bool { + if case .syncing = self { return true } + return false + } + + static func == (lhs: SyncState, rhs: SyncState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle): true + case (.synced, .synced): true + case let (.syncing(a), .syncing(b)): a == b + case (.failed, .failed): true // Simplified equality + default: false } + } } /// Progress information during sync -struct SyncProgress: Sendable, Equatable { - let phase: SyncPhase - let current: Int - let total: Int +struct SyncProgress: Equatable { + let phase: SyncPhase + let current: Int + let total: Int } /// Phases of the sync process public enum SyncPhase: Sendable, Equatable { - case contacts - case channels - case messages + case contacts + case channels + case messages } /// Phase-level sync outcome. -enum SyncPhaseStatus: Sendable, Equatable { - case clean - case partial - case skipped - case failed(String) - - var isClean: Bool { - self == .clean - } +enum SyncPhaseStatus: Equatable { + case clean + case partial + case skipped + case failed(String) + + var isClean: Bool { + self == .clean + } } /// Structured result for an initial or full sync. -struct FullSyncResult: Sendable, Equatable { - let contacts: SyncPhaseStatus - let channels: SyncPhaseStatus - let messages: SyncPhaseStatus - let channelRetryIndices: [UInt8] - - var isConnectionUsable: Bool { - contacts == .clean - } - - init( - contacts: SyncPhaseStatus, - channels: SyncPhaseStatus, - messages: SyncPhaseStatus, - channelRetryIndices: [UInt8] = [] - ) { - self.contacts = contacts - self.channels = channels - self.messages = messages - self.channelRetryIndices = channelRetryIndices - } - - static let skipped = FullSyncResult( - contacts: .skipped, - channels: .skipped, - messages: .skipped - ) +struct FullSyncResult: Equatable { + let contacts: SyncPhaseStatus + let channels: SyncPhaseStatus + let messages: SyncPhaseStatus + let channelRetryIndices: [UInt8] + + var isConnectionUsable: Bool { + contacts == .clean + } + + init( + contacts: SyncPhaseStatus, + channels: SyncPhaseStatus, + messages: SyncPhaseStatus, + channelRetryIndices: [UInt8] = [] + ) { + self.contacts = contacts + self.channels = channels + self.messages = messages + self.channelRetryIndices = channelRetryIndices + } + + static let skipped = FullSyncResult( + contacts: .skipped, + channels: .skipped, + messages: .skipped + ) } /// Errors from SyncCoordinator operations public enum SyncCoordinatorError: Error, Sendable { - case notConnected - case syncFailed(String) - case alreadySyncing + case notConnected + case syncFailed(String) + case alreadySyncing } extension SyncCoordinatorError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .syncFailed(let msg): "Sync failed: \(msg)" - case .alreadySyncing: "A sync is already in progress." - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case let .syncFailed(msg): "Sync failed: \(msg)" + case .alreadySyncing: "A sync is already in progress." } + } } // MARK: - SyncCoordinator Actor @@ -110,321 +110,320 @@ extension SyncCoordinatorError: LocalizedError { /// - Full sync (contacts, channels, messages) /// - UI refresh notifications public actor SyncCoordinator { + // MARK: - Logging - // MARK: - Logging + let logger = PersistentLogger(subsystem: "com.mc1", category: "SyncCoordinator") - let logger = PersistentLogger(subsystem: "com.mc1", category: "SyncCoordinator") + /// Actor-local guard against concurrent sync execution. + /// Checked and set synchronously (no `await`) to eliminate the TOCTOU window + /// that existed when guarding via the `@MainActor`-isolated `state` property. + var isSyncInProgress = false - /// Actor-local guard against concurrent sync execution. - /// Checked and set synchronously (no `await`) to eliminate the TOCTOU window - /// that existed when guarding via the `@MainActor`-isolated `state` property. - var isSyncInProgress = false + /// Cached blocked names (contacts + channel senders) for O(1) lookup in message handlers + private var blockedNames: Set = [] - /// Cached blocked names (contacts + channel senders) for O(1) lookup in message handlers - private var blockedNames: Set = [] + /// Tracks channel indices received for slots with no local channel (notifications suppressed) + /// in this connection session. + var unresolvedChannelIndices: Set = [] + var lastUnresolvedChannelSummaryAt: Date? + let unresolvedChannelSummaryIntervalSeconds: TimeInterval = 60 - /// Tracks channel indices received for slots with no local channel (notifications suppressed) - /// in this connection session. - var unresolvedChannelIndices: Set = [] - var lastUnresolvedChannelSummaryAt: Date? - let unresolvedChannelSummaryIntervalSeconds: TimeInterval = 60 + /// Timestamp window size (in seconds) for matching reactions to messages. + /// Allows for clock drift and delayed delivery within a 5-minute window. + let reactionTimestampWindowSeconds: UInt32 = 300 - /// Timestamp window size (in seconds) for matching reactions to messages. - /// Allows for clock drift and delayed delivery within a 5-minute window. - let reactionTimestampWindowSeconds: UInt32 = 300 + // MARK: - Observable State (@MainActor for SwiftUI) - // MARK: - Observable State (@MainActor for SwiftUI) + /// Current sync state + @MainActor private(set) var state: SyncState = .idle - /// Current sync state - @MainActor private(set) var state: SyncState = .idle + /// Incremented when contacts data changes + @MainActor private(set) var contactsVersion: Int = 0 - /// Incremented when contacts data changes - @MainActor private(set) var contactsVersion: Int = 0 + /// Incremented when conversations data changes + @MainActor private(set) var conversationsVersion: Int = 0 - /// Incremented when conversations data changes - @MainActor private(set) var conversationsVersion: Int = 0 + /// Last successful sync date + @MainActor private(set) var lastSyncDate: Date? - /// Last successful sync date - @MainActor private(set) var lastSyncDate: Date? + /// Called when channel sync completes with zero errors (including retries). + /// Used by ConnectionManager to track clean channel completions for smart resync. + /// Installed by `ConnectionManager.wireCleanChannelSyncCallback`. + var onCleanChannelSync: (@Sendable (_ radioID: UUID) async -> Void)? - /// Called when channel sync completes with zero errors (including retries). - /// Used by ConnectionManager to track clean channel completions for smart resync. - /// Installed by `ConnectionManager.wireCleanChannelSyncCallback`. - var onCleanChannelSync: (@Sendable (_ radioID: UUID) async -> Void)? + /// Called when a channel sync attempt starts, clean or partial. + /// Used by ConnectionManager to cool down immediate channel retry loops. + /// Installed by `ConnectionManager.wireCleanChannelSyncCallback`. + var onChannelSyncAttempted: (@Sendable (_ radioID: UUID) async -> Void)? - /// Called when a channel sync attempt starts, clean or partial. - /// Used by ConnectionManager to cool down immediate channel retry loops. - /// Installed by `ConnectionManager.wireCleanChannelSyncCallback`. - var onChannelSyncAttempted: (@Sendable (_ radioID: UUID) async -> Void)? + /// Sets the callback for clean channel sync completion. + func setCleanChannelSyncCallback(_ callback: @escaping @Sendable (_ radioID: UUID) async -> Void) { + onCleanChannelSync = callback + } - /// Sets the callback for clean channel sync completion. - func setCleanChannelSyncCallback(_ callback: @escaping @Sendable (_ radioID: UUID) async -> Void) { - onCleanChannelSync = callback - } + /// Sets the callback for any channel sync attempt. + func setChannelSyncAttemptedCallback(_ callback: @escaping @Sendable (_ radioID: UUID) async -> Void) { + onChannelSyncAttempted = callback + } - /// Sets the callback for any channel sync attempt. - func setChannelSyncAttemptedCallback(_ callback: @escaping @Sendable (_ radioID: UUID) async -> Void) { - onChannelSyncAttempted = callback - } + /// Callback when non-message sync activity starts. + /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. + var onSyncActivityStarted: (@Sendable () async -> Void)? - /// Callback when non-message sync activity starts. - /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. - var onSyncActivityStarted: (@Sendable () async -> Void)? + /// Callback when non-message sync activity ends. + /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. + var onSyncActivityEnded: (@Sendable (_ succeeded: Bool) async -> Void)? - /// Callback when non-message sync activity ends. - /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. - var onSyncActivityEnded: (@Sendable (_ succeeded: Bool) async -> Void)? + /// Tracks whether onSyncActivityEnded has been called for the current sync cycle. + /// Prevents double-callback when disconnect occurs mid-sync (both onDisconnected + /// and error path would otherwise call onSyncActivityEnded). + var hasEndedSyncActivity = true - /// Tracks whether onSyncActivityEnded has been called for the current sync cycle. - /// Prevents double-callback when disconnect occurs mid-sync (both onDisconnected - /// and error path would otherwise call onSyncActivityEnded). - var hasEndedSyncActivity = true + /// Watchdog task that force-clears notification suppression after 120s. + /// Prevents stuck suppression if sync completes abnormally without clearing it. + private var suppressionWatchdogTask: Task? - /// Watchdog task that force-clears notification suppression after 120s. - /// Prevents stuck suppression if sync completes abnormally without clearing it. - private var suppressionWatchdogTask: Task? + /// Callback when sync phase changes (for SwiftUI observation). + /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. + @MainActor private var onPhaseChanged: (@Sendable @MainActor (_ phase: SyncPhase?) -> Void)? - /// Callback when sync phase changes (for SwiftUI observation). - /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. - @MainActor private var onPhaseChanged: (@Sendable @MainActor (_ phase: SyncPhase?) -> Void)? + /// Multicast broadcaster for data-change and incoming-message events. + /// Producers yield synchronously from any isolation; consumers subscribe + /// via `dataEvents()`. + nonisolated let dataEventBroadcaster = EventBroadcaster() - /// Multicast broadcaster for data-change and incoming-message events. - /// Producers yield synchronously from any isolation; consumers subscribe - /// via `dataEvents()`. - nonisolated let dataEventBroadcaster = EventBroadcaster() + /// Task consuming `AdvertisementService.events()` for ongoing contact + /// discovery. Started by `startDiscoveryEventMonitoring` only after the + /// initial sync so adverts arriving during sync do not spam notifications; + /// cancelled by `ServiceContainer.tearDown()`. + var discoveryEventsTask: Task? - /// Task consuming `AdvertisementService.events()` for ongoing contact - /// discovery. Started by `startDiscoveryEventMonitoring` only after the - /// initial sync so adverts arriving during sync do not spam notifications; - /// cancelled by `ServiceContainer.tearDown()`. - var discoveryEventsTask: Task? + // MARK: - Test Seams - // MARK: - Test Seams - - #if DEBUG + #if DEBUG /// Test override for `performResync`. When set, bypasses the real sync path. var performResyncOverride: ((_ radioID: UUID, _ dependencies: SyncDependencies) async -> Bool)? /// Sets the test override for `performResync`. func setPerformResyncOverride(_ override: @escaping @Sendable (_ radioID: UUID, _ dependencies: SyncDependencies) async -> Bool) { - performResyncOverride = override - } - #endif - - // MARK: - Initialization - - init() {} - - // MARK: - State Setters - - @MainActor - func setState(_ newState: SyncState) { - state = newState - if case .syncing(let progress) = newState { - onPhaseChanged?(progress.phase) - } else { - onPhaseChanged?(nil) - } - } - - @MainActor - func setLastSyncDate(_ date: Date) { - lastSyncDate = date - } - - /// Sets callbacks for sync activity tracking (used by UI to show syncing pill) - /// Only called for contacts and channels phases, NOT for messages. - public func setSyncActivityCallbacks( - onStarted: @escaping @Sendable () async -> Void, - onEnded: @escaping @Sendable (_ succeeded: Bool) async -> Void, - onPhaseChanged: @escaping @Sendable @MainActor (_ phase: SyncPhase?) -> Void - ) async { - onSyncActivityStarted = onStarted - onSyncActivityEnded = onEnded - await MainActor.run { self.onPhaseChanged = onPhaseChanged } - } - - // MARK: - Data Events - - /// Returns a fresh stream of data-change and incoming-message events. - /// Registration is synchronous, so events yielded after this call are - /// never dropped. Consumers must re-subscribe per connection because the - /// owning `ServiceContainer` is rebuilt on every connection. - public nonisolated func dataEvents() -> AsyncStream { - dataEventBroadcaster.subscribe() + performResyncOverride = override } + #endif - /// Ends every `dataEvents()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishDataEvents() { - dataEventBroadcaster.finish() - } + // MARK: - Initialization - // MARK: - Sync Activity Tracking + init() {} - /// Calls onSyncActivityEnded at most once per sync cycle. - /// Guards against double-callback when disconnect occurs mid-sync. - func endSyncActivityOnce(succeeded: Bool = false) async { - guard !hasEndedSyncActivity else { return } - hasEndedSyncActivity = true - logger.info("[Sync] Calling onSyncActivityEnded (succeeded: \(succeeded))") - await onSyncActivityEnded?(succeeded) - } + // MARK: - State Setters - /// Called by ConnectionManager when a resync loop starts. - /// Increments the activity count so "Syncing" pill stays visible across retries. - func beginResyncActivity() async { - await onSyncActivityStarted?() + @MainActor + func setState(_ newState: SyncState) { + state = newState + if case let .syncing(progress) = newState { + onPhaseChanged?(progress.phase) + } else { + onPhaseChanged?(nil) } - - /// Called by ConnectionManager when a resync loop ends (success or exhausted retries). - /// Decrements the activity count. Only triggers "Ready" toast on success. - func endResyncActivity(succeeded: Bool) async { - await onSyncActivityEnded?(succeeded) + } + + @MainActor + func setLastSyncDate(_ date: Date) { + lastSyncDate = date + } + + /// Sets callbacks for sync activity tracking (used by UI to show syncing pill) + /// Only called for contacts and channels phases, NOT for messages. + public func setSyncActivityCallbacks( + onStarted: @escaping @Sendable () async -> Void, + onEnded: @escaping @Sendable (_ succeeded: Bool) async -> Void, + onPhaseChanged: @escaping @Sendable @MainActor (_ phase: SyncPhase?) -> Void + ) async { + onSyncActivityStarted = onStarted + onSyncActivityEnded = onEnded + await MainActor.run { self.onPhaseChanged = onPhaseChanged } + } + + // MARK: - Data Events + + /// Returns a fresh stream of data-change and incoming-message events. + /// Registration is synchronous, so events yielded after this call are + /// never dropped. Consumers must re-subscribe per connection because the + /// owning `ServiceContainer` is rebuilt on every connection. + public nonisolated func dataEvents() -> AsyncStream { + dataEventBroadcaster.subscribe() + } + + /// Ends every `dataEvents()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishDataEvents() { + dataEventBroadcaster.finish() + } + + // MARK: - Sync Activity Tracking + + /// Calls onSyncActivityEnded at most once per sync cycle. + /// Guards against double-callback when disconnect occurs mid-sync. + func endSyncActivityOnce(succeeded: Bool = false) async { + guard !hasEndedSyncActivity else { return } + hasEndedSyncActivity = true + logger.info("[Sync] Calling onSyncActivityEnded (succeeded: \(succeeded))") + await onSyncActivityEnded?(succeeded) + } + + /// Called by ConnectionManager when a resync loop starts. + /// Increments the activity count so "Syncing" pill stays visible across retries. + func beginResyncActivity() async { + await onSyncActivityStarted?() + } + + /// Called by ConnectionManager when a resync loop ends (success or exhausted retries). + /// Decrements the activity count. Only triggers "Ready" toast on success. + func endResyncActivity(succeeded: Bool) async { + await onSyncActivityEnded?(succeeded) + } + + // MARK: - Notification Suppression Watchdog + + func startSuppressionWatchdog(notificationService: NotificationService) { + suppressionWatchdogTask?.cancel() + suppressionWatchdogTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(120)) + guard !Task.isCancelled, let self else { return } + let isSuppressing = await notificationService.isSuppressingNotifications + guard isSuppressing else { return } + logger.warning("[Sync] Notification suppression watchdog fired after 120s - force clearing") + await MainActor.run { + notificationService.isSuppressingNotifications = false + } } - - // MARK: - Notification Suppression Watchdog - - func startSuppressionWatchdog(notificationService: NotificationService) { - suppressionWatchdogTask?.cancel() - suppressionWatchdogTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(120)) - guard !Task.isCancelled, let self else { return } - let isSuppressing = await notificationService.isSuppressingNotifications - guard isSuppressing else { return } - self.logger.warning("[Sync] Notification suppression watchdog fired after 120s - force clearing") - await MainActor.run { - notificationService.isSuppressingNotifications = false - } - } + } + + func cancelSuppressionWatchdog() { + suppressionWatchdogTask?.cancel() + suppressionWatchdogTask = nil + } + + // MARK: - Notifications + + /// Notify that contacts data changed (triggers UI refresh) + @MainActor + public func notifyContactsChanged() { + logger.info("notifyContactsChanged: version \(contactsVersion) → \(contactsVersion + 1)") + contactsVersion += 1 + dataEventBroadcaster.yield(.contactsChanged) + } + + /// Notify that conversations data changed (triggers UI refresh) + @MainActor + public func notifyConversationsChanged() { + conversationsVersion += 1 + dataEventBroadcaster.yield(.conversationsChanged) + } + + // MARK: - Blocked Contacts Cache + + /// Refresh the blocked names cache from the data store (contacts + channel senders) + public func refreshBlockedContactsCache(radioID: UUID, dataStore: any ContactPersisting) async { + do { + let blockedContacts = try await dataStore.fetchBlockedContacts(radioID: radioID) + let blockedSenders = try await dataStore.fetchBlockedChannelSenders(radioID: radioID) + blockedNames = Set(blockedContacts.map(\.name)) + .union(Set(blockedSenders.map(\.name))) + logger.debug("Refreshed blocked names cache: \(blockedNames.count) entries") + } catch { + logger.error("Failed to refresh blocked names cache: \(error)") + blockedNames = [] } - - func cancelSuppressionWatchdog() { - suppressionWatchdogTask?.cancel() - suppressionWatchdogTask = nil + } + + /// Check if a sender name is blocked (O(1) lookup) + func isBlockedSender(_ name: String?) -> Bool { + guard let name else { return false } + return blockedNames.contains(name) + } + + /// Returns a snapshot of blocked sender names for synchronous filtering + public func blockedSenderNames() -> Set { + blockedNames + } + + /// Delete any channel messages from blocked senders still in the DB. + /// Runs on every connection. After the first pass, delete queries match zero rows + /// and are effectively free (indexed predicate, no mutations). This handles legacy + /// data from app versions that filtered at read time instead of deleting at block time. + func deleteBlockedSenderMessages(radioID: UUID, dataStore: any PersistenceStoreProtocol) async { + let names = blockedNames + guard !names.isEmpty else { return } + + for name in names { + try? await dataStore.deleteChannelMessages(fromSender: name, radioID: radioID) } - - // MARK: - Notifications - - /// Notify that contacts data changed (triggers UI refresh) - @MainActor - public func notifyContactsChanged() { - logger.info("notifyContactsChanged: version \(self.contactsVersion) → \(self.contactsVersion + 1)") - contactsVersion += 1 - dataEventBroadcaster.yield(.contactsChanged) + } + + // MARK: - Timestamp Correction + + /// Maximum acceptable time in the future for a sender timestamp (5 minutes) + private static let timestampToleranceFuture: TimeInterval = 5 * 60 + + /// Maximum acceptable time in the past for a sender timestamp (6 months) + private static let timestampTolerancePast: TimeInterval = 6 * 30 * 24 * 60 * 60 + + /// Corrects invalid timestamps from senders with broken clocks. + /// + /// MeshCore protocol does not specify timestamp validation. This is a client-side + /// policy to prevent timeline corruption when devices have severely incorrect clocks + /// (a common issue per MeshCore FAQ 6.1, 6.2). Original timestamps are preserved + /// for ACK deduplication (per payloads.md:65). + /// + /// Returns the corrected timestamp and whether correction was applied. + /// Timestamps are considered invalid if: + /// - More than 5 minutes in the future (relative to receive time) + /// - More than 6 months in the past (relative to receive time) + /// + /// - Parameters: + /// - timestamp: The sender's claimed timestamp + /// - receiveTime: When the message was received (defaults to now) + /// - Returns: Tuple of (corrected timestamp, was corrected flag) + nonisolated static func correctTimestampIfNeeded( + _ timestamp: UInt32, + receiveTime: Date = Date() + ) -> (correctedTimestamp: UInt32, wasCorrected: Bool) { + let receiveSeconds = receiveTime.timeIntervalSince1970 + let timestampSeconds = TimeInterval(timestamp) + + let isTooFarInFuture = timestampSeconds > receiveSeconds + timestampToleranceFuture + let isTooFarInPast = timestampSeconds < receiveSeconds - timestampTolerancePast + + if isTooFarInFuture || isTooFarInPast { + return (UInt32(receiveSeconds), true) } - - /// Notify that conversations data changed (triggers UI refresh) - @MainActor - public func notifyConversationsChanged() { - conversationsVersion += 1 - dataEventBroadcaster.yield(.conversationsChanged) - } - - // MARK: - Blocked Contacts Cache - - /// Refresh the blocked names cache from the data store (contacts + channel senders) - public func refreshBlockedContactsCache(radioID: UUID, dataStore: any ContactPersisting) async { - do { - let blockedContacts = try await dataStore.fetchBlockedContacts(radioID: radioID) - let blockedSenders = try await dataStore.fetchBlockedChannelSenders(radioID: radioID) - blockedNames = Set(blockedContacts.map(\.name)) - .union(Set(blockedSenders.map(\.name))) - logger.debug("Refreshed blocked names cache: \(self.blockedNames.count) entries") - } catch { - logger.error("Failed to refresh blocked names cache: \(error)") - blockedNames = [] - } - } - - /// Check if a sender name is blocked (O(1) lookup) - func isBlockedSender(_ name: String?) -> Bool { - guard let name else { return false } - return blockedNames.contains(name) - } - - /// Returns a snapshot of blocked sender names for synchronous filtering - public func blockedSenderNames() -> Set { - blockedNames - } - - /// Delete any channel messages from blocked senders still in the DB. - /// Runs on every connection. After the first pass, delete queries match zero rows - /// and are effectively free (indexed predicate, no mutations). This handles legacy - /// data from app versions that filtered at read time instead of deleting at block time. - func deleteBlockedSenderMessages(radioID: UUID, dataStore: any PersistenceStoreProtocol) async { - let names = blockedNames - guard !names.isEmpty else { return } - - for name in names { - try? await dataStore.deleteChannelMessages(fromSender: name, radioID: radioID) - } - } - - // MARK: - Timestamp Correction - - /// Maximum acceptable time in the future for a sender timestamp (5 minutes) - private static let timestampToleranceFuture: TimeInterval = 5 * 60 - - /// Maximum acceptable time in the past for a sender timestamp (6 months) - private static let timestampTolerancePast: TimeInterval = 6 * 30 * 24 * 60 * 60 - - /// Corrects invalid timestamps from senders with broken clocks. - /// - /// MeshCore protocol does not specify timestamp validation. This is a client-side - /// policy to prevent timeline corruption when devices have severely incorrect clocks - /// (a common issue per MeshCore FAQ 6.1, 6.2). Original timestamps are preserved - /// for ACK deduplication (per payloads.md:65). - /// - /// Returns the corrected timestamp and whether correction was applied. - /// Timestamps are considered invalid if: - /// - More than 5 minutes in the future (relative to receive time) - /// - More than 6 months in the past (relative to receive time) - /// - /// - Parameters: - /// - timestamp: The sender's claimed timestamp - /// - receiveTime: When the message was received (defaults to now) - /// - Returns: Tuple of (corrected timestamp, was corrected flag) - nonisolated static func correctTimestampIfNeeded( - _ timestamp: UInt32, - receiveTime: Date = Date() - ) -> (correctedTimestamp: UInt32, wasCorrected: Bool) { - let receiveSeconds = receiveTime.timeIntervalSince1970 - let timestampSeconds = TimeInterval(timestamp) - - let isTooFarInFuture = timestampSeconds > receiveSeconds + timestampToleranceFuture - let isTooFarInPast = timestampSeconds < receiveSeconds - timestampTolerancePast - - if isTooFarInFuture || isTooFarInPast { - return (UInt32(receiveSeconds), true) - } - return (timestamp, false) - } - - /// Computes the persisted sort date for an incoming message based on its delivery context. - /// - /// Live messages sort by receive time so a just-arrived message stays at the bottom of the - /// transcript. Backlog messages drained during sync sort by the drain `anchor`, so a whole - /// batch lands as one contiguous block at delivery time — recent, never buried — ordered - /// within the block by the secondary `timestamp` fetch key. The sort key no longer reads - /// sender time, so a skewed sender clock cannot scatter backlog into deep scrollback; - /// `correctTimestampIfNeeded` still governs the persisted `Message.timestamp` used for - /// dedup, display, and within-block ordering. - /// - /// - Parameters: - /// - context: Whether the message arrived live or via a backlog drain (carrying the anchor). - /// - receiveTime: When a live message was received. Ignored for `initialSync`. - /// - Returns: The date to persist as the message's sort key. - nonisolated static func sortDate( - for context: DeliveryContext, - receiveTime: Date - ) -> Date { - switch context { - case .live: - return receiveTime - case .initialSync(let anchor): - return anchor - } + return (timestamp, false) + } + + /// Computes the persisted sort date for an incoming message based on its delivery context. + /// + /// Live messages sort by receive time so a just-arrived message stays at the bottom of the + /// transcript. Backlog messages drained during sync sort by the drain `anchor`, so a whole + /// batch lands as one contiguous block at delivery time — recent, never buried — ordered + /// within the block by the secondary `timestamp` fetch key. The sort key no longer reads + /// sender time, so a skewed sender clock cannot scatter backlog into deep scrollback; + /// `correctTimestampIfNeeded` still governs the persisted `Message.timestamp` used for + /// dedup, display, and within-block ordering. + /// + /// - Parameters: + /// - context: Whether the message arrived live or via a backlog drain (carrying the anchor). + /// - receiveTime: When a live message was received. Ignored for `initialSync`. + /// - Returns: The date to persist as the message's sort key. + nonisolated static func sortDate( + for context: DeliveryContext, + receiveTime: Date + ) -> Date { + switch context { + case .live: + receiveTime + case let .initialSync(anchor): + anchor } + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncDataEvent.swift b/MC1Services/Sources/MC1Services/Sync/SyncDataEvent.swift index a33b9349..b06850db 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncDataEvent.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncDataEvent.swift @@ -7,16 +7,16 @@ import Foundation /// version bumps, `MessageEventDispatcher` for chat forwarding) never steal /// each other's events. public enum SyncDataEvent: Sendable { - /// Contacts data changed; observers should reload contact lists. - case contactsChanged - /// Conversations data changed; observers should reload chat lists. - case conversationsChanged - /// An incoming direct message was persisted for a known contact. - case directMessageReceived(message: MessageDTO, contact: ContactDTO) - /// An incoming channel message was persisted. - case channelMessageReceived(message: MessageDTO, channelIndex: UInt8) - /// An incoming signed room message was persisted. - case roomMessageReceived(RoomMessageDTO) - /// A reaction was persisted and its target message's summary updated. - case reactionReceived(messageID: UUID, summary: String) + /// Contacts data changed; observers should reload contact lists. + case contactsChanged + /// Conversations data changed; observers should reload chat lists. + case conversationsChanged + /// An incoming direct message was persisted for a known contact. + case directMessageReceived(message: MessageDTO, contact: ContactDTO) + /// An incoming channel message was persisted. + case channelMessageReceived(message: MessageDTO, channelIndex: UInt8) + /// An incoming signed room message was persisted. + case roomMessageReceived(RoomMessageDTO) + /// A reaction was persisted and its target message's summary updated. + case reactionReceived(messageID: UUID, summary: String) } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncDependencies.swift b/MC1Services/Sources/MC1Services/Sync/SyncDependencies.swift index 43f7fef4..e046743d 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncDependencies.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncDependencies.swift @@ -7,112 +7,110 @@ import Foundation /// but constructible directly in tests so sync paths can run without the full /// container. Members are protocol-typed where a seam protocol exists and /// concrete elsewhere. -struct SyncDependencies: Sendable { - - /// Persistence store for device, contact, channel, message, and RX log operations. - let dataStore: any PersistenceStoreProtocol - - /// Service performing the contact sync phase. - let contactService: any ContactServiceProtocol - - /// Service performing the channel sync phase. - let channelService: any ChannelServiceProtocol - - /// Service for message polling, auto-fetch, and ingestion handler wiring. - let messagePollingService: any MessagePollingServiceProtocol - - /// Service for posting notifications and gating suppression during sync. - let notificationService: NotificationService - - /// Service handling emoji reactions on direct and channel messages. - let reactionService: ReactionService - - /// Service for advertisement events and contact discovery. - let advertisementService: AdvertisementService - - /// Service maintaining the RX log decryption caches (private key, contact - /// public keys, channel secrets). - let rxLogService: RxLogService - - /// Service persisting signed room messages. - let roomServerService: RoomServerService - - /// Service routing CLI responses from room contacts. - let roomAdminService: RoomAdminService - - /// Service routing CLI responses from repeater contacts. - let repeaterAdminService: RepeaterAdminService - - /// Optional provider for foreground/background state. When nil, sync - /// defaults to foreground behavior (channels sync). - let appStateProvider: AppStateProvider? - - /// Starts service event monitoring for the connected radio. - let startEventMonitoring: @Sendable (_ radioID: UUID, _ enableAutoFetch: Bool) async -> Void - - /// Exports the device private key for direct message decryption. - let exportPrivateKey: @Sendable () async throws -> Data - - init( - dataStore: any PersistenceStoreProtocol, - contactService: any ContactServiceProtocol, - channelService: any ChannelServiceProtocol, - messagePollingService: any MessagePollingServiceProtocol, - notificationService: NotificationService, - reactionService: ReactionService, - advertisementService: AdvertisementService, - rxLogService: RxLogService, - roomServerService: RoomServerService, - roomAdminService: RoomAdminService, - repeaterAdminService: RepeaterAdminService, - appStateProvider: AppStateProvider? = nil, - startEventMonitoring: @escaping @Sendable (_ radioID: UUID, _ enableAutoFetch: Bool) async -> Void, - exportPrivateKey: @escaping @Sendable () async throws -> Data - ) { - self.dataStore = dataStore - self.contactService = contactService - self.channelService = channelService - self.messagePollingService = messagePollingService - self.notificationService = notificationService - self.reactionService = reactionService - self.advertisementService = advertisementService - self.rxLogService = rxLogService - self.roomServerService = roomServerService - self.roomAdminService = roomAdminService - self.repeaterAdminService = repeaterAdminService - self.appStateProvider = appStateProvider - self.startEventMonitoring = startEventMonitoring - self.exportPrivateKey = exportPrivateKey - } +struct SyncDependencies { + /// Persistence store for device, contact, channel, message, and RX log operations. + let dataStore: any PersistenceStoreProtocol + + /// Service performing the contact sync phase. + let contactService: any ContactServiceProtocol + + /// Service performing the channel sync phase. + let channelService: any ChannelServiceProtocol + + /// Service for message polling, auto-fetch, and ingestion handler wiring. + let messagePollingService: any MessagePollingServiceProtocol + + /// Service for posting notifications and gating suppression during sync. + let notificationService: NotificationService + + /// Service handling emoji reactions on direct and channel messages. + let reactionService: ReactionService + + /// Service for advertisement events and contact discovery. + let advertisementService: AdvertisementService + + /// Service maintaining the RX log decryption caches (private key, contact + /// public keys, channel secrets). + let rxLogService: RxLogService + + /// Service persisting signed room messages. + let roomServerService: RoomServerService + + /// Service routing CLI responses from room contacts. + let roomAdminService: RoomAdminService + + /// Service routing CLI responses from repeater contacts. + let repeaterAdminService: RepeaterAdminService + + /// Optional provider for foreground/background state. When nil, sync + /// defaults to foreground behavior (channels sync). + let appStateProvider: AppStateProvider? + + /// Starts service event monitoring for the connected radio. + let startEventMonitoring: @Sendable (_ radioID: UUID, _ enableAutoFetch: Bool) async -> Void + + /// Exports the device private key for direct message decryption. + let exportPrivateKey: @Sendable () async throws -> Data + + init( + dataStore: any PersistenceStoreProtocol, + contactService: any ContactServiceProtocol, + channelService: any ChannelServiceProtocol, + messagePollingService: any MessagePollingServiceProtocol, + notificationService: NotificationService, + reactionService: ReactionService, + advertisementService: AdvertisementService, + rxLogService: RxLogService, + roomServerService: RoomServerService, + roomAdminService: RoomAdminService, + repeaterAdminService: RepeaterAdminService, + appStateProvider: AppStateProvider? = nil, + startEventMonitoring: @escaping @Sendable (_ radioID: UUID, _ enableAutoFetch: Bool) async -> Void, + exportPrivateKey: @escaping @Sendable () async throws -> Data + ) { + self.dataStore = dataStore + self.contactService = contactService + self.channelService = channelService + self.messagePollingService = messagePollingService + self.notificationService = notificationService + self.reactionService = reactionService + self.advertisementService = advertisementService + self.rxLogService = rxLogService + self.roomServerService = roomServerService + self.roomAdminService = roomAdminService + self.repeaterAdminService = repeaterAdminService + self.appStateProvider = appStateProvider + self.startEventMonitoring = startEventMonitoring + self.exportPrivateKey = exportPrivateKey + } } extension ServiceContainer { - - /// Builds the sync dependency surface from this container's services. - /// - /// `startEventMonitoring` captures the container weakly: the wired sync - /// closures hold a `SyncDependencies` copy, and a strong container capture - /// here would keep a torn-down service graph alive across reconnects. - var syncDependencies: SyncDependencies { - SyncDependencies( - dataStore: dataStore, - contactService: contactService, - channelService: channelService, - messagePollingService: messagePollingService, - notificationService: notificationService, - reactionService: reactionService, - advertisementService: advertisementService, - rxLogService: rxLogService, - roomServerService: roomServerService, - roomAdminService: roomAdminService, - repeaterAdminService: repeaterAdminService, - appStateProvider: appStateProvider, - startEventMonitoring: { [weak self] radioID, enableAutoFetch in - await self?.startEventMonitoring(radioID: radioID, enableAutoFetch: enableAutoFetch) - }, - exportPrivateKey: { [session] in - try await session.exportPrivateKey() - } - ) - } + /// Builds the sync dependency surface from this container's services. + /// + /// `startEventMonitoring` captures the container weakly: the wired sync + /// closures hold a `SyncDependencies` copy, and a strong container capture + /// here would keep a torn-down service graph alive across reconnects. + var syncDependencies: SyncDependencies { + SyncDependencies( + dataStore: dataStore, + contactService: contactService, + channelService: channelService, + messagePollingService: messagePollingService, + notificationService: notificationService, + reactionService: reactionService, + advertisementService: advertisementService, + rxLogService: rxLogService, + roomServerService: roomServerService, + roomAdminService: roomAdminService, + repeaterAdminService: repeaterAdminService, + appStateProvider: appStateProvider, + startEventMonitoring: { [weak self] radioID, enableAutoFetch in + await self?.startEventMonitoring(radioID: radioID, enableAutoFetch: enableAutoFetch) + }, + exportPrivateKey: { [session] in + try await session.exportPrivateKey() + } + ) + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncThrottlingConfig.swift b/MC1Services/Sources/MC1Services/Sync/SyncThrottlingConfig.swift index de979521..64138f76 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncThrottlingConfig.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncThrottlingConfig.swift @@ -2,32 +2,32 @@ import Foundation /// Controls whether channel re-sync is skipped on resync. /// Computed from `DevicePlatform` and `lastCleanChannelSync` state at sync start. -struct ChannelSyncConfig: Sendable { - /// If channels were synced more recently than this window, skip channel re-sync. - let channelSyncSkipWindow: Duration +struct ChannelSyncConfig { + /// If channels were synced more recently than this window, skip channel re-sync. + let channelSyncSkipWindow: Duration - /// Timestamp of the last fully-clean channel sync for the current device. - let lastCleanChannelSync: Date? + /// Timestamp of the last fully-clean channel sync for the current device. + let lastCleanChannelSync: Date? - /// Timestamp of the last attempted channel sync, even if it was partial. - let lastAttemptedChannelSync: Date? + /// Timestamp of the last attempted channel sync, even if it was partial. + let lastAttemptedChannelSync: Date? - /// Whether channel reads should use the windowed read pipeline — nRF52 over BLE (Write - /// Commands) or ESP32 over WiFi (TCP back-to-back sends). `false` for ESP32 over BLE. - let usePipelinedChannelRead: Bool + /// Whether channel reads should use the windowed read pipeline — nRF52 over BLE (Write + /// Commands) or ESP32 over WiFi (TCP back-to-back sends). `false` for ESP32 over BLE. + let usePipelinedChannelRead: Bool - init( - channelSyncSkipWindow: Duration = .zero, - lastCleanChannelSync: Date? = nil, - lastAttemptedChannelSync: Date? = nil, - usePipelinedChannelRead: Bool = false - ) { - self.channelSyncSkipWindow = channelSyncSkipWindow - self.lastCleanChannelSync = lastCleanChannelSync - self.lastAttemptedChannelSync = lastAttemptedChannelSync - self.usePipelinedChannelRead = usePipelinedChannelRead - } + init( + channelSyncSkipWindow: Duration = .zero, + lastCleanChannelSync: Date? = nil, + lastAttemptedChannelSync: Date? = nil, + usePipelinedChannelRead: Bool = false + ) { + self.channelSyncSkipWindow = channelSyncSkipWindow + self.lastCleanChannelSync = lastCleanChannelSync + self.lastAttemptedChannelSync = lastAttemptedChannelSync + self.usePipelinedChannelRead = usePipelinedChannelRead + } - /// No skip — used for WiFi connections. - static let none = ChannelSyncConfig() + /// No skip — used for WiFi connections. + static let none = ChannelSyncConfig() } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEPhase.swift b/MC1Services/Sources/MC1Services/Transport/BLEPhase.swift index 50d326ba..06c9d196 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEPhase.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEPhase.swift @@ -9,137 +9,135 @@ import Foundation /// CoreBluetooth types (CBPeripheral, CBService, CBCharacteristic). The /// BLEStateMachine actor ensures these are only accessed from appropriate contexts. enum BLEPhase: @unchecked Sendable { - - /// Initial state, no operations in progress - case idle - - /// Waiting for CBCentralManager to reach .poweredOn - case waitingForBluetooth( - continuation: CheckedContinuation - ) - - /// Actively connecting to a peripheral - case connecting( - peripheral: CBPeripheral, - continuation: CheckedContinuation, - timeoutTask: Task - ) - - /// Connected, discovering services - case discoveringServices( - peripheral: CBPeripheral, - continuation: CheckedContinuation - ) - - /// Services found, discovering characteristics - case discoveringCharacteristics( - peripheral: CBPeripheral, - service: CBService, - continuation: CheckedContinuation - ) - - /// Characteristics found, subscribing to notifications - case subscribingToNotifications( - peripheral: CBPeripheral, - tx: CBCharacteristic, - rx: CBCharacteristic, - continuation: CheckedContinuation - ) - - /// Discovery chain complete, continuation consumed. - /// Transitional phase between notification subscription success and - /// `connect()` creating the data stream. Holds characteristics without - /// a continuation, preventing double-resume if `cancelCurrentOperation` - /// runs before `connect()` transitions to `.connected`. - case discoveryComplete( - peripheral: CBPeripheral, - tx: CBCharacteristic, - rx: CBCharacteristic - ) - - /// Fully connected and ready for communication - case connected( - peripheral: CBPeripheral, - tx: CBCharacteristic, - rx: CBCharacteristic, - dataContinuation: AsyncStream.Continuation - ) - - /// iOS auto-reconnect in progress. - /// Progressively populated as discovery completes. - case autoReconnecting( - peripheral: CBPeripheral, - tx: CBCharacteristic?, - rx: CBCharacteristic? - ) - - /// iOS state restoration received, waiting for Bluetooth power state. - /// Transitions to .autoReconnecting when Bluetooth powers on. - case restoringState(peripheral: CBPeripheral) - - /// Intentionally disconnecting - case disconnecting( - peripheral: CBPeripheral - ) - - // MARK: - Computed Properties - - /// Human-readable name for logging - var name: String { - switch self { - case .idle: "idle" - case .waitingForBluetooth: "waitingForBluetooth" - case .connecting: "connecting" - case .discoveringServices: "discoveringServices" - case .discoveringCharacteristics: "discoveringCharacteristics" - case .subscribingToNotifications: "subscribingToNotifications" - case .discoveryComplete: "discoveryComplete" - case .connected: "connected" - case .autoReconnecting: "autoReconnecting" - case .restoringState: "restoringState" - case .disconnecting: "disconnecting" - } - } - - /// Whether this phase is part of the service/characteristic discovery chain. - /// Used by `cleanupPhaseResources` to preserve the discovery timeout when - /// transitioning within the chain. - var isDiscoveryChain: Bool { - switch self { - case .discoveringServices, .discoveringCharacteristics, .subscribingToNotifications: - return true - default: - return false - } + /// Initial state, no operations in progress + case idle + + /// Waiting for CBCentralManager to reach .poweredOn + case waitingForBluetooth( + continuation: CheckedContinuation + ) + + /// Actively connecting to a peripheral + case connecting( + peripheral: CBPeripheral, + continuation: CheckedContinuation, + timeoutTask: Task + ) + + /// Connected, discovering services + case discoveringServices( + peripheral: CBPeripheral, + continuation: CheckedContinuation + ) + + /// Services found, discovering characteristics + case discoveringCharacteristics( + peripheral: CBPeripheral, + service: CBService, + continuation: CheckedContinuation + ) + + /// Characteristics found, subscribing to notifications + case subscribingToNotifications( + peripheral: CBPeripheral, + tx: CBCharacteristic, + rx: CBCharacteristic, + continuation: CheckedContinuation + ) + + /// Discovery chain complete, continuation consumed. + /// Transitional phase between notification subscription success and + /// `connect()` creating the data stream. Holds characteristics without + /// a continuation, preventing double-resume if `cancelCurrentOperation` + /// runs before `connect()` transitions to `.connected`. + case discoveryComplete( + peripheral: CBPeripheral, + tx: CBCharacteristic, + rx: CBCharacteristic + ) + + /// Fully connected and ready for communication + case connected( + peripheral: CBPeripheral, + tx: CBCharacteristic, + rx: CBCharacteristic, + dataContinuation: AsyncStream.Continuation + ) + + /// iOS auto-reconnect in progress. + /// Progressively populated as discovery completes. + case autoReconnecting( + peripheral: CBPeripheral, + tx: CBCharacteristic?, + rx: CBCharacteristic? + ) + + /// iOS state restoration received, waiting for Bluetooth power state. + /// Transitions to .autoReconnecting when Bluetooth powers on. + case restoringState(peripheral: CBPeripheral) + + /// Intentionally disconnecting + case disconnecting( + peripheral: CBPeripheral + ) + + // MARK: - Computed Properties + + /// Human-readable name for logging + var name: String { + switch self { + case .idle: "idle" + case .waitingForBluetooth: "waitingForBluetooth" + case .connecting: "connecting" + case .discoveringServices: "discoveringServices" + case .discoveringCharacteristics: "discoveringCharacteristics" + case .subscribingToNotifications: "subscribingToNotifications" + case .discoveryComplete: "discoveryComplete" + case .connected: "connected" + case .autoReconnecting: "autoReconnecting" + case .restoringState: "restoringState" + case .disconnecting: "disconnecting" } - - /// Whether this phase represents an active operation (not idle) - var isActive: Bool { - if case .idle = self { return false } - return true + } + + /// Whether this phase is part of the service/characteristic discovery chain. + /// Used by `cleanupPhaseResources` to preserve the discovery timeout when + /// transitioning within the chain. + var isDiscoveryChain: Bool { + switch self { + case .discoveringServices, .discoveringCharacteristics, .subscribingToNotifications: + true + default: + false } - - /// The peripheral associated with this phase, if any - var peripheral: CBPeripheral? { - switch self { - case .connecting(let p, _, _), - .discoveringServices(let p, _), - .discoveringCharacteristics(let p, _, _), - .subscribingToNotifications(let p, _, _, _), - .discoveryComplete(let p, _, _), - .connected(let p, _, _, _), - .autoReconnecting(let p, _, _), - .restoringState(let p), - .disconnecting(let p): - return p - case .idle, .waitingForBluetooth: - return nil - } - } - - /// The device ID associated with this phase, if any - var deviceID: UUID? { - peripheral?.identifier + } + + /// Whether this phase represents an active operation (not idle) + var isActive: Bool { + if case .idle = self { return false } + return true + } + + /// The peripheral associated with this phase, if any + var peripheral: CBPeripheral? { + switch self { + case let .connecting(p, _, _), + let .discoveringServices(p, _), + let .discoveringCharacteristics(p, _, _), + let .subscribingToNotifications(p, _, _, _), + let .discoveryComplete(p, _, _), + let .connected(p, _, _, _), + let .autoReconnecting(p, _, _), + let .restoringState(p), + let .disconnecting(p): + p + case .idle, .waitingForBluetooth: + nil } + } + /// The device ID associated with this phase, if any + var deviceID: UUID? { + peripheral?.identifier + } } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEServiceUUID.swift b/MC1Services/Sources/MC1Services/Transport/BLEServiceUUID.swift index 439936f3..d01e389f 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEServiceUUID.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEServiceUUID.swift @@ -12,10 +12,10 @@ import Foundation /// This is inverted from the Nordic UART Service standard naming, which uses /// the peripheral's perspective. The actual UUIDs are correct. enum BLEServiceUUID { - /// Nordic UART Service UUID - static let nordicUART = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" - /// TX Characteristic - central writes to this (Nordic: RX Characteristic) - static let txCharacteristic = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" - /// RX Characteristic - central receives notifications (Nordic: TX Characteristic) - static let rxCharacteristic = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" + /// Nordic UART Service UUID + static let nordicUART = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" + /// TX Characteristic - central writes to this (Nordic: RX Characteristic) + static let txCharacteristic = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" + /// RX Characteristic - central receives notifications (Nordic: TX Characteristic) + static let rxCharacteristic = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CBDelegate.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CBDelegate.swift index dabf2ae9..89ac809a 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CBDelegate.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CBDelegate.swift @@ -23,144 +23,143 @@ import os /// the serial CBCentralManager queue, avoiding the race conditions that occur when /// multiple unstructured Tasks compete for actor access with priority-based scheduling. final class BLEDelegateHandler: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, @unchecked Sendable { - - weak var stateMachine: BLEStateMachine? - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEDelegateHandler") - - /// Lock-protected continuation for yielding received data directly. - /// Using OSAllocatedUnfairLock ensures thread-safe access from the CBCentralManager queue. - private let dataContinuationLock = OSAllocatedUnfairLock.Continuation?>(initialState: nil) - - /// FIFO of sequence numbers for `.withResponse` writes issued but not yet - /// acknowledged. CoreBluetooth delivers exactly one `didWriteValueFor` per - /// write request, in issue order, on the serial queue, so popping the head - /// tags each callback with the sequence of the write that produced it. - /// Tagging at write time (not delivery time) lets a callback that arrives - /// after its write already timed out be recognized as the old write instead - /// of being mistaken for the current one. - private let issuedWriteSequencesLock = OSAllocatedUnfairLock<[UInt64]>(initialState: []) - - /// Records a write's sequence as issued. The actor calls this immediately - /// before `writeValue` so the callback can never outrun the record. - func recordIssuedWriteSequence(_ sequence: UInt64) { - issuedWriteSequencesLock.withLock { $0.append(sequence) } - } - - /// Drops all recorded write sequences. Called when pending writes are - /// cancelled (disconnect, auto-reconnect teardown), where the outstanding - /// callbacks either never arrive or no longer have a continuation; a stale - /// entry left behind would mis-tag the next connection's first callback. - func clearIssuedWriteSequences() { - issuedWriteSequencesLock.withLock { $0.removeAll() } - } - - /// Sets the data continuation for direct yielding from delegate callbacks. - /// Call this when transitioning to connected state. - func setDataContinuation(_ continuation: AsyncStream.Continuation?) { - dataContinuationLock.withLock { $0 = continuation } - } - - // MARK: - CBCentralManagerDelegate - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - guard let sm = stateMachine else { return } - Task { await sm.handleCentralManagerDidUpdateState(central.state) } - } - - func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { - guard let sm = stateMachine else { return } - // Extract peripheral synchronously before crossing actor boundary - guard let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], - let peripheral = peripherals.first else { - return - } - Task { await sm.handleWillRestoreState(peripheral) } - } - - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], rssi RSSI: NSNumber) { - guard let sm = stateMachine else { return } - let peripheralID = peripheral.identifier - let rssiValue = RSSI.intValue - // Prefer the advertised local name; it is present before a connection is established - // and fresher than the cached `peripheral.name`. - let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name - Task { await sm.handleDidDiscoverPeripheral(peripheralID: peripheralID, name: name, rssi: rssiValue) } - } - - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidConnect(peripheral) } - } - - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidFailToConnect(peripheral, error: error) } - } - - func centralManager( - _ central: CBCentralManager, - didDisconnectPeripheral peripheral: CBPeripheral, - timestamp: CFAbsoluteTime, - isReconnecting: Bool, - error: Error? - ) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidDisconnect(peripheral, timestamp: timestamp, isReconnecting: isReconnecting, error: error) } - } - - // MARK: - CBPeripheralDelegate - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidDiscoverServices(peripheral, error: error) } - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidDiscoverCharacteristics(peripheral, service: service, error: error) } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidUpdateNotificationState(peripheral, characteristic: characteristic, error: error) } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - // Yield data directly to preserve ordering from the serial CBCentralManager queue. - // Do not spawn a Task here - that breaks ordering guarantees. - if let error { - logger.warning("[BLE] didUpdateValueFor error: \(peripheral.identifier.uuidString.prefix(8)), char: \(characteristic.uuid.uuidString.prefix(8)), error: \(error.localizedDescription)") - return - } - guard let data = characteristic.value, !data.isEmpty else { - logger.debug("[BLE] didUpdateValueFor: empty data from \(peripheral.identifier.uuidString.prefix(8)), char: \(characteristic.uuid.uuidString.prefix(8))") - return - } - _ = dataContinuationLock.withLock { $0?.yield(data) } - } - - func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidReadRSSI(RSSI: RSSI, error: error) } - } - - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - guard let sm = stateMachine else { return } - // Pop the oldest unacknowledged write's sequence (callbacks arrive in - // issue order on this serial queue) so the callback is tagged with the - // write that produced it, not whichever write is current by delivery time. - let seq = issuedWriteSequencesLock.withLock { $0.isEmpty ? nil : $0.removeFirst() } - guard let seq else { - logger.debug("[BLE] didWriteValueFor with no recorded write, ignoring") - return - } - Task { await sm.handleDidWriteValue(peripheral, characteristic: characteristic, error: error, writeSequence: seq) } - } - - func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { - guard let sm = stateMachine else { return } - Task { await sm.handlePeripheralReadyForWriteWithoutResponse() } - } + weak var stateMachine: BLEStateMachine? + + private let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEDelegateHandler") + + /// Lock-protected continuation for yielding received data directly. + /// Using OSAllocatedUnfairLock ensures thread-safe access from the CBCentralManager queue. + private let dataContinuationLock = OSAllocatedUnfairLock.Continuation?>(initialState: nil) + + /// FIFO of sequence numbers for `.withResponse` writes issued but not yet + /// acknowledged. CoreBluetooth delivers exactly one `didWriteValueFor` per + /// write request, in issue order, on the serial queue, so popping the head + /// tags each callback with the sequence of the write that produced it. + /// Tagging at write time (not delivery time) lets a callback that arrives + /// after its write already timed out be recognized as the old write instead + /// of being mistaken for the current one. + private let issuedWriteSequencesLock = OSAllocatedUnfairLock<[UInt64]>(initialState: []) + + /// Records a write's sequence as issued. The actor calls this immediately + /// before `writeValue` so the callback can never outrun the record. + func recordIssuedWriteSequence(_ sequence: UInt64) { + issuedWriteSequencesLock.withLock { $0.append(sequence) } + } + + /// Drops all recorded write sequences. Called when pending writes are + /// cancelled (disconnect, auto-reconnect teardown), where the outstanding + /// callbacks either never arrive or no longer have a continuation; a stale + /// entry left behind would mis-tag the next connection's first callback. + func clearIssuedWriteSequences() { + issuedWriteSequencesLock.withLock { $0.removeAll() } + } + + /// Sets the data continuation for direct yielding from delegate callbacks. + /// Call this when transitioning to connected state. + func setDataContinuation(_ continuation: AsyncStream.Continuation?) { + dataContinuationLock.withLock { $0 = continuation } + } + + // MARK: - CBCentralManagerDelegate + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + guard let sm = stateMachine else { return } + Task { await sm.handleCentralManagerDidUpdateState(central.state) } + } + + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + guard let sm = stateMachine else { return } + // Extract peripheral synchronously before crossing actor boundary + guard let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], + let peripheral = peripherals.first else { + return + } + Task { await sm.handleWillRestoreState(peripheral) } + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) { + guard let sm = stateMachine else { return } + let peripheralID = peripheral.identifier + let rssiValue = RSSI.intValue + // Prefer the advertised local name; it is present before a connection is established + // and fresher than the cached `peripheral.name`. + let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name + Task { await sm.handleDidDiscoverPeripheral(peripheralID: peripheralID, name: name, rssi: rssiValue) } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidConnect(peripheral) } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidFailToConnect(peripheral, error: error) } + } + + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + timestamp: CFAbsoluteTime, + isReconnecting: Bool, + error: Error? + ) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidDisconnect(peripheral, timestamp: timestamp, isReconnecting: isReconnecting, error: error) } + } + + // MARK: - CBPeripheralDelegate + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidDiscoverServices(peripheral, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidDiscoverCharacteristics(peripheral, service: service, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidUpdateNotificationState(peripheral, characteristic: characteristic, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + // Yield data directly to preserve ordering from the serial CBCentralManager queue. + // Do not spawn a Task here - that breaks ordering guarantees. + if let error { + logger.warning("[BLE] didUpdateValueFor error: \(peripheral.identifier.uuidString.prefix(8)), char: \(characteristic.uuid.uuidString.prefix(8)), error: \(error.localizedDescription)") + return + } + guard let data = characteristic.value, !data.isEmpty else { + logger.debug("[BLE] didUpdateValueFor: empty data from \(peripheral.identifier.uuidString.prefix(8)), char: \(characteristic.uuid.uuidString.prefix(8))") + return + } + _ = dataContinuationLock.withLock { $0?.yield(data) } + } + + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidReadRSSI(RSSI: RSSI, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + guard let sm = stateMachine else { return } + // Pop the oldest unacknowledged write's sequence (callbacks arrive in + // issue order on this serial queue) so the callback is tagged with the + // write that produced it, not whichever write is current by delivery time. + let seq = issuedWriteSequencesLock.withLock { $0.isEmpty ? nil : $0.removeFirst() } + guard let seq else { + logger.debug("[BLE] didWriteValueFor with no recorded write, ignoring") + return + } + Task { await sm.handleDidWriteValue(peripheral, characteristic: characteristic, error: error, writeSequence: seq) } + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + guard let sm = stateMachine else { return } + Task { await sm.handlePeripheralReadyForWriteWithoutResponse() } + } } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift index 9136c057..803fd76f 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift @@ -4,661 +4,763 @@ import Foundation // MARK: - Internal Callback Handlers extension BLEStateMachine { - - func handleCentralManagerDidUpdateState(_ state: CBManagerState) { - let stateString: String - switch state { - case .unknown: stateString = "unknown" - case .resetting: stateString = "resetting" - case .unsupported: stateString = "unsupported" - case .unauthorized: stateString = "unauthorized" - case .poweredOff: stateString = "poweredOff" - case .poweredOn: stateString = "poweredOn" - @unknown default: stateString = "unknown(\(state.rawValue))" - } - if lastCentralState != state { - lastCentralState = state - logger.info( - "[BLE] Central manager state changed: \(stateString), currentPhase: \(self.phase.name), instance: \(instanceID), \(processContext)" - ) - } - onBluetoothStateChange?(state) - - switch state { - case .poweredOn: - // Cancel any poweredOff grace period — Bluetooth is now available - bluetoothPowerOffGraceTask?.cancel() - bluetoothPowerOffGraceTask = nil - - // Resume waiting continuation if any - if case .waitingForBluetooth(let continuation) = phase { - transition(to: .idle) - continuation.resume() - } - - // Handle state restoration from phase - if case .restoringState(let peripheral) = phase { - handleRestoredPeripheral(peripheral) - } - - // Fulfill pending scan request - if pendingScanRequest { - startScanning() - } - - // Notify handler for power-on events - onBluetoothPoweredOn?() - - case .poweredOff: - let wasScanning = isCurrentlyScanning - isCurrentlyScanning = false - if wasScanning { - pendingScanRequest = true - } - - if case .waitingForBluetooth = phase { - // A freshly created CBCentralManager may briefly report poweredOff - // before settling on poweredOn. Start a grace period instead of - // failing immediately, so the initialization can complete. - if bluetoothPowerOffGraceTask == nil { - logger.info("[BLE] poweredOff during waitingForBluetooth — starting grace period") - bluetoothPowerOffGraceTask = Task { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { return } - self.handleBluetoothPowerOffGraceExpired() - } - } - } else { - // Not waiting — cancel any active operation immediately - let deviceID = phase.deviceID - cancelCurrentOperation(with: BLEError.bluetoothPoweredOff) - if let deviceID { - onDisconnection?(deviceID, nil) - } - } - - case .unauthorized: - handleBluetoothBecomingUnavailable(error: .bluetoothUnauthorized) - - case .unsupported: - handleBluetoothBecomingUnavailable(error: .bluetoothUnavailable) - - default: - break - } + func handleCentralManagerDidUpdateState(_ state: CBManagerState) { + let stateString = switch state { + case .unknown: "unknown" + case .resetting: "resetting" + case .unsupported: "unsupported" + case .unauthorized: "unauthorized" + case .poweredOff: "poweredOff" + case .poweredOn: "poweredOn" + @unknown default: "unknown(\(state.rawValue))" } + if lastCentralState != state { + lastCentralState = state + logger.info( + "[BLE] Central manager state changed: \(stateString), currentPhase: \(phase.name), instance: \(instanceID), \(processContext)" + ) + } + onBluetoothStateChange?(state) + + switch state { + case .poweredOn: + // Cancel any poweredOff grace period — Bluetooth is now available + bluetoothPowerOffGraceTask?.cancel() + bluetoothPowerOffGraceTask = nil - /// Called when the poweredOff grace period expires without poweredOn arriving. - private func handleBluetoothPowerOffGraceExpired() { - bluetoothPowerOffGraceTask = nil - guard case .waitingForBluetooth = phase else { return } - logger.info("[BLE] poweredOff grace period expired — Bluetooth is off") + // Resume waiting continuation if any + if case let .waitingForBluetooth(continuation) = phase { + transition(to: .idle) + continuation.resume() + } + + // Handle state restoration from phase + if case let .restoringState(peripheral) = phase { + handleRestoredPeripheral(peripheral) + } + + // Fulfill pending scan request + if pendingScanRequest { + startScanning() + } + + // Notify handler for power-on events + onBluetoothPoweredOn?() + + case .poweredOff: + let wasScanning = isCurrentlyScanning + isCurrentlyScanning = false + if wasScanning { + pendingScanRequest = true + } + + if case .waitingForBluetooth = phase { + // A freshly created CBCentralManager may briefly report poweredOff + // before settling on poweredOn. Start a grace period instead of + // failing immediately, so the initialization can complete. + if bluetoothPowerOffGraceTask == nil { + logger.info("[BLE] poweredOff during waitingForBluetooth — starting grace period") + bluetoothPowerOffGraceTask = Task { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + self.handleBluetoothPowerOffGraceExpired() + } + } + } else { + // Not waiting — cancel any active operation immediately let deviceID = phase.deviceID cancelCurrentOperation(with: BLEError.bluetoothPoweredOff) if let deviceID { - onDisconnection?(deviceID, nil) + onDisconnection?(deviceID, nil) } - } - - /// Handles Bluetooth becoming permanently unavailable (unauthorized or unsupported). - private func handleBluetoothBecomingUnavailable(error: BLEError) { - isCurrentlyScanning = false - pendingScanRequest = false - if case .waitingForBluetooth(let continuation) = phase { - transition(to: .idle) - continuation.resume(throwing: error) - } - if case .restoringState(let peripheral) = phase { - transition(to: .idle) - onDisconnection?(peripheral.identifier, nil) - } - } + } - func handleRestoredPeripheral(_ peripheral: CBPeripheral, source: RestoredPeripheralSource = .stateRestoration) { - let pState = peripheralStateString(peripheral.state) - logger.info("[BLE] Processing restored peripheral: \(peripheral.identifier.uuidString.prefix(8)), state: \(pState), source: \(source)") + case .unauthorized: + handleBluetoothBecomingUnavailable(error: .bluetoothUnauthorized) - peripheral.delegate = delegateHandler + case .unsupported: + handleBluetoothBecomingUnavailable(error: .bluetoothUnavailable) - // Advance connection generation for restoration-driven reconnect - advanceConnectionGeneration() - - // Start timeout for auto-reconnect discovery - armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) - - transition(to: .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil)) - - // State-restoration paths claim the reconnect cycle here so the coordinator's - // strict completion guard accepts the eventual onReconnection. The adoption - // path already claimed via startAdoptingLastSystemConnectedPeripheralIfAvailable - // and passes .adoption to skip and avoid double-claiming. - if source == .stateRestoration { - onAutoReconnecting?(peripheral.identifier, "state-restoration") - } - - if peripheral.state == .connected { - // Already connected, just need to rediscover services - peripheral.discoverServices([nordicUARTServiceUUID]) - } else if peripheral.state == .connecting { - // Connection in progress, wait for didConnect - } else { - // Not connected, try to reconnect - let options: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionEnableAutoReconnect: true - ] - centralManager.connect(peripheral, options: options) - } + default: + break } - - func handleWillRestoreState(_ peripheral: CBPeripheral) { - let pState = peripheralStateString(peripheral.state) - logger.info("[BLE] State restoration callback: \(peripheral.identifier.uuidString.prefix(8)), state: \(pState)") - - // If Bluetooth is already powered on, proceed directly to restoration. - // This handles the edge case where .poweredOn Task runs before this Task. - if centralManager.state == .poweredOn { - handleRestoredPeripheral(peripheral) - } else { - transition(to: .restoringState(peripheral: peripheral)) - } + } + + /// Called when the poweredOff grace period expires without poweredOn arriving. + private func handleBluetoothPowerOffGraceExpired() { + bluetoothPowerOffGraceTask = nil + guard case .waitingForBluetooth = phase else { return } + logger.info("[BLE] poweredOff grace period expired — Bluetooth is off") + let deviceID = phase.deviceID + cancelCurrentOperation(with: BLEError.bluetoothPoweredOff) + if let deviceID { + onDisconnection?(deviceID, nil) } + } + + /// Handles Bluetooth becoming permanently unavailable (unauthorized or unsupported). + private func handleBluetoothBecomingUnavailable(error: BLEError) { + isCurrentlyScanning = false + pendingScanRequest = false + if case let .waitingForBluetooth(continuation) = phase { + transition(to: .idle) + continuation.resume(throwing: error) + } + if case let .restoringState(peripheral) = phase { + transition(to: .idle) + onDisconnection?(peripheral.identifier, nil) + } + // Auto-reconnect waits indefinitely on the OS pending connect, which can + // never complete once Bluetooth is unavailable; tear down explicitly. + if case let .autoReconnecting(peripheral, _, _) = phase { + transition(to: .idle) + onDisconnection?(peripheral.identifier, error) + } + } - func handleDidConnect(_ peripheral: CBPeripheral) { - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.info("[BLE] Did connect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), phase: \(self.phase.name), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") - - // Handle auto-reconnect - if case .autoReconnecting(let expected, _, _) = phase, - peripheral.identifier == expected.identifier { - logger.info("[BLE] Auto-reconnect: peripheral connected, discovering services") - peripheral.delegate = delegateHandler - peripheral.discoverServices([nordicUARTServiceUUID]) - - // Cancel any existing timeout (e.g., from handleRestoredPeripheral) and restart - armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) - return - } + func handleRestoredPeripheral(_ peripheral: CBPeripheral, source: RestoredPeripheralSource = .stateRestoration) { + let pState = peripheralStateString(peripheral.state) + logger.info("[BLE] Processing restored peripheral: \(peripheral.identifier.uuidString.prefix(8)), state: \(pState), source: \(source)") - // Normal connection flow - guard case .connecting(let expected, let continuation, let timeoutTask) = phase, - expected.identifier == peripheral.identifier else { - logger.warning("Unexpected didConnect for \(peripheral.identifier)") - cancelUnexpectedPeripheral(peripheral) - return - } + peripheral.delegate = delegateHandler - timeoutTask.cancel() + // Advance connection generation for restoration-driven reconnect + advanceConnectionGeneration() - // Arm discovery timeout before starting discovery so the callback - // window between discoverServices() and timeout creation is closed. - armServiceDiscoveryTimeout(for: peripheral) + // Start timeout for auto-reconnect discovery + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) - transition(to: .discoveringServices( - peripheral: peripheral, - continuation: continuation - )) + resetAutoReconnectFailureTracking() + transition(to: .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil)) - peripheral.delegate = delegateHandler - peripheral.discoverServices([nordicUARTServiceUUID]) + // State-restoration paths claim the reconnect cycle here so the coordinator's + // strict completion guard accepts the eventual onReconnection. The adoption + // path already claimed via startAdoptingLastSystemConnectedPeripheralIfAvailable + // and passes .adoption to skip and avoid double-claiming. + if source == .stateRestoration { + onAutoReconnecting?(peripheral.identifier, "state-restoration") } - func handleDidFailToConnect(_ peripheral: CBPeripheral, error: Error?) { - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - var errorInfo = "none" - if let error = error as NSError? { - errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" - } - logger.warning( - "[BLE] Did fail to connect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), phase: \(self.phase.name), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s, error: \(errorInfo)" - ) - - // Handle failure during auto-reconnect (iOS auto-reconnect gave up) - if case .autoReconnecting(let expected, _, _) = phase, - expected.identifier == peripheral.identifier { - logger.warning("Auto-reconnect failed for \(peripheral.identifier) - transitioning to idle") - transition(to: .idle) - onDisconnection?(peripheral.identifier, error) - return - } - - guard case .connecting(let expected, let continuation, let timeoutTask) = phase, - expected.identifier == peripheral.identifier else { - logger.info("Ignoring didFailToConnect - not our peripheral or unexpected phase") - return - } - - timeoutTask.cancel() - transition(to: .idle) - continuation.resume(throwing: Self.makeConnectionError(error)) - } - - /// Maps a CoreBluetooth error to a typed BLEError. Auth/encryption codes - /// from CBATTError or CBError get the typed `.authenticationFailed` case so - /// detection survives iOS localizing the error description in any locale. - static func makeConnectionError(_ error: Error?, fallback: String = "Unknown error") -> BLEError { - if let nsError = error as NSError? { - if nsError.domain == CBATTErrorDomain { - switch nsError.code { - case CBATTError.insufficientAuthentication.rawValue, - CBATTError.insufficientAuthorization.rawValue, - CBATTError.insufficientEncryption.rawValue, - CBATTError.insufficientEncryptionKeySize.rawValue: - return .authenticationFailed - default: - break - } - } - if nsError.domain == CBErrorDomain, - nsError.code == CBError.encryptionTimedOut.rawValue { - return .authenticationFailed - } - } - return .connectionFailed(error?.localizedDescription ?? fallback) + if peripheral.state == .connected { + // Already connected, just need to rediscover services + peripheral.discoverServices([nordicUARTServiceUUID]) + } else if peripheral.state == .connecting { + // Connection in progress, wait for didConnect + } else { + // Not connected, try to reconnect + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionEnableAutoReconnect: true + ] + centralManager.connect(peripheral, options: options) + } + } + + func handleWillRestoreState(_ peripheral: CBPeripheral) { + let pState = peripheralStateString(peripheral.state) + logger.info("[BLE] State restoration callback: \(peripheral.identifier.uuidString.prefix(8)), state: \(pState)") + + // A connect attempt parked in .waitingForBluetooth must be resumed before + // restoration claims the machine: transition(to:) never resumes phase + // continuations, so clobbering the phase would hang that connect forever + // and leak its device claim, turning every later Reconnect tap into a no-op. + failPendingBluetoothWait(reason: "Superseded by state restoration") + + // If Bluetooth is already powered on, proceed directly to restoration. + // This handles the edge case where .poweredOn Task runs before this Task. + if centralManager?.state == .poweredOn { + handleRestoredPeripheral(peripheral) + } else { + transition(to: .restoringState(peripheral: peripheral)) + } + } + + /// Resumes a connect attempt parked in `.waitingForBluetooth` with an error + /// and settles the machine in `.idle`. A no-op in any other phase. + func failPendingBluetoothWait(reason: String) { + guard case let .waitingForBluetooth(continuation) = phase else { return } + logger.warning("[BLE] Failing pending Bluetooth wait: \(reason)") + transition(to: .idle) + continuation.resume(throwing: BLEError.connectionFailed(reason)) + } + + func handleDidConnect(_ peripheral: CBPeripheral) { + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + logger.info("[BLE] Did connect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), phase: \(phase.name), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + + // Handle auto-reconnect + if case let .autoReconnecting(expected, _, _) = phase, + peripheral.identifier == expected.identifier { + logger.info("[BLE] Auto-reconnect: peripheral connected, discovering services") + // The link re-established, so the transient connect-failure streak is broken. + resetAutoReconnectFailureTracking() + peripheral.delegate = delegateHandler + peripheral.discoverServices([nordicUARTServiceUUID]) + + // Cancel any existing timeout (e.g., from handleRestoredPeripheral) and restart + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) + return } - func handleDidDisconnect(_ peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: Error?) { - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - var errorInfo = "none" - if let error = error as NSError? { - errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" - } - let elapsedStr = elapsed.formatted(.number.precision(.fractionLength(2))) - logger.info( - "[BLE] Did disconnect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), isReconnecting: \(isReconnecting), phase: \(self.phase.name), elapsed: \(elapsedStr)s, error: \(errorInfo)" - ) - - // C7: Ignore stale disconnects for peripherals that don't match the active session. - // A delayed callback from an old peripheral must not cancel the current session. - if let activePeripheral = phase.peripheral, - activePeripheral.identifier != peripheral.identifier { - logger.warning("[BLE] Ignoring stale didDisconnect for \(peripheral.identifier.uuidString.prefix(8)), active: \(activePeripheral.identifier.uuidString.prefix(8))") - return - } - - // Primary stale-callback fence: reject disconnect callbacks from a previous generation. - // After app resume, iOS may deliver queued disconnects from before the suspend. - // We use CFAbsoluteTime (not a generation counter captured at callback delivery time) - // because CoreBluetooth's didDisconnectPeripheral timestamp reflects the disconnect - // event time per Apple's header ("now or a few seconds ago"), not delivery time. - // A generation captured at delivery time would be unsafe if advanceConnectionGeneration() - // runs between the event and callback delivery. CFAbsoluteTimeGetCurrent() is not - // guaranteed monotonic (NTP adjustments can cause backward jumps), so the 1.0s - // tolerance accommodates typical clock corrections. The peripheral identity check - // above provides the primary defense; this timestamp fence is a secondary guard for - // same-peripheral stale callbacks across generation boundaries. - let generationStart = connectionGenerationStartTime - if Self.isDisconnectCallbackFromPreviousGeneration( - timestamp: timestamp, - generationStart: generationStart - ) { - let callbackAge = CFAbsoluteTimeGetCurrent() - timestamp - logger.warning( - "[BLE] Ignoring stale disconnect callback: " + - "age=\(callbackAge.formatted(.number.precision(.fractionLength(1))))s, " + - "generation=\(connectionGeneration), phase=\(phase.name)" - ) - return - } - - // Secondary diagnostic: flag very old callbacks, but do not drop callbacks - // that belong to the current connection generation. - let callbackAge = CFAbsoluteTimeGetCurrent() - timestamp - if callbackAge > 120 { - logger.warning( - "[BLE] Processing aged disconnect callback: " + - "age=\(callbackAge.formatted(.number.precision(.fractionLength(1))))s, " + - "generation=\(connectionGeneration), phase=\(phase.name)" - ) - } - - let deviceID = peripheral.identifier - - if isReconnecting { - handleAutoReconnectDisconnect(peripheral: peripheral, error: error) - } else { - handleFullDisconnect(deviceID: deviceID, error: error) - } + // Normal connection flow + guard case let .connecting(expected, continuation, timeoutTask) = phase, + expected.identifier == peripheral.identifier else { + logger.warning("Unexpected didConnect for \(peripheral.identifier)") + cancelUnexpectedPeripheral(peripheral) + return } - /// Handles a disconnect where iOS is auto-reconnecting the peripheral. - /// Setup-phase continuations route through makeConnectionError so a CBATT - /// auth/encryption code arriving before the bond is established still maps - /// to the typed BLEError.authenticationFailed. - private func handleAutoReconnectDisconnect(peripheral: CBPeripheral, error: Error?) { - let deviceID = peripheral.identifier - var errorInfo = "none" - if let nsError = error as NSError? { - errorInfo = "domain=\(nsError.domain), code=\(nsError.code), desc=\(nsError.localizedDescription)" - } - logger.info("[BLE] iOS auto-reconnect started: \(deviceID.uuidString.prefix(8)), will attempt automatic reconnection") - - // Clean up pending operations before transitioning. - // This ensures any pending setup continuations and write waiters are properly - // resumed/failed, preventing orphaned continuations and waiter starvation. - cancelPendingWriteOperations() - - // Clean up current state but preserve peripheral for reconnection. - // transition() handles dataContinuation cleanup when leaving .connected. - // Note: We handle phase continuations manually below since cancelCurrentOperation - // would transition to .idle, but we need to go to .autoReconnecting. - let setupError = Self.makeConnectionError(error, fallback: "Disconnected during setup") - switch phase { - case .connecting(_, let continuation, let timeoutTask): - timeoutTask.cancel() - continuation.resume(throwing: setupError) - case .discoveringServices(_, let continuation): - continuation.resume(throwing: setupError) - case .discoveringCharacteristics(_, _, let continuation): - continuation.resume(throwing: setupError) - case .subscribingToNotifications(_, _, _, let continuation): - continuation.resume(throwing: setupError) - default: - break - } + timeoutTask.cancel() - // Advance generation for the auto-reconnect cycle - advanceConnectionGeneration() + // Arm discovery timeout before starting discovery so the callback + // window between discoverServices() and timeout creation is closed. + armServiceDiscoveryTimeout(for: peripheral) - transition(to: .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil)) + transition(to: .discoveringServices( + peripheral: peripheral, + continuation: continuation + )) - // C5: Arm the auto-reconnect discovery timeout (same as restoration path) - armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) + peripheral.delegate = delegateHandler + peripheral.discoverServices([nordicUARTServiceUUID]) + } - // Notify handler so UI can show "connecting" state - onAutoReconnecting?(deviceID, errorInfo) + func handleDidFailToConnect(_ peripheral: CBPeripheral, error: Error?) { + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + var errorInfo = "none" + if let error = error as NSError? { + errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" + } + logger.warning( + "[BLE] Did fail to connect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), phase: \(phase.name), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s, error: \(errorInfo)" + ) + + // didFailToConnect consumes the OS pending connect, and a suspended app's + // watchdog cannot retry, so a transient failure re-issues the connect and + // stays in the episode. Only a definitive bond failure tears down at once; + // exhausting the bounded budget on encryption timeouts escalates to auth + // failure so an invalidated bond still reaches guided re-pair. + if case let .autoReconnecting(expected, _, _) = phase, + expected.identifier == peripheral.identifier { + if Self.isDefinitiveAuthFailure(error) { + logger.warning("Auto-reconnect failed with definitive auth error for \(peripheral.identifier) - transitioning to idle") + resetAutoReconnectFailureTracking() + transition(to: .idle) + onDisconnection?(peripheral.identifier, BLEError.authenticationFailed) + return + } + + autoReconnectConnectFailures += 1 + if Self.isEncryptionTimedOut(error) { + encryptionTimedOutConnectFailures += 1 + } + + if autoReconnectConnectFailures < Self.maxAutoReconnectConnectFailures { + logger.info("[BLE] Transient auto-reconnect connect failure (\(autoReconnectConnectFailures)/\(Self.maxAutoReconnectConnectFailures)) for \(peripheral.identifier.uuidString.prefix(8)); re-issuing pending connect") + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionEnableAutoReconnect: true + ] + centralManager.connect(peripheral, options: options) + return + } + + let majorityEncryptionTimeouts = encryptionTimedOutConnectFailures * 2 > autoReconnectConnectFailures + let teardownError: BLEError = majorityEncryptionTimeouts ? .authenticationFailed : Self.makeConnectionError(error) + logger.warning("Auto-reconnect connect failures exhausted budget for \(peripheral.identifier) - transitioning to idle") + resetAutoReconnectFailureTracking() + transition(to: .idle) + onDisconnection?(peripheral.identifier, teardownError) + return } - /// Handles a full (non-reconnecting) disconnection. - private func handleFullDisconnect(deviceID: UUID, error: Error?) { - switch phase { - case .disconnecting: - // Expected disconnection, transition handled by disconnect() - break - - case .connected, .autoReconnecting: - // Unexpected disconnection - cancelCurrentOperation(with: BLEError.notConnected) - onDisconnection?(deviceID, error) + guard case let .connecting(expected, continuation, timeoutTask) = phase, + expected.identifier == peripheral.identifier else { + logger.info("Ignoring didFailToConnect - not our peripheral or unexpected phase") + return + } + timeoutTask.cancel() + transition(to: .idle) + continuation.resume(throwing: Self.makeConnectionError(error)) + } + + /// Maps a CoreBluetooth error to a typed BLEError. The CBATTError auth/encryption + /// family and `CBError.peerRemovedPairingInformation` are definitive bond failures + /// mapped to `.authenticationFailed`, so detection survives iOS localizing the + /// description. A lone `CBError.encryptionTimedOut` is transient and stays + /// `.connectionFailed`; `handleDidFailToConnect` escalates it only when it + /// dominates an exhausted auto-reconnect retry budget. + static func makeConnectionError(_ error: Error?, fallback: String = "Unknown error") -> BLEError { + if let nsError = error as NSError? { + if nsError.domain == CBATTErrorDomain { + switch nsError.code { + case CBATTError.insufficientAuthentication.rawValue, + CBATTError.insufficientAuthorization.rawValue, + CBATTError.insufficientEncryption.rawValue, + CBATTError.insufficientEncryptionKeySize.rawValue: + return .authenticationFailed default: - // Disconnection during connection attempt - cancelCurrentOperation(with: Self.makeConnectionError(error, fallback: "Disconnected during setup")) + break } + } + if nsError.domain == CBErrorDomain, + nsError.code == CBError.peerRemovedPairingInformation.rawValue { + return .authenticationFailed + } + } + return .connectionFailed(error?.localizedDescription ?? fallback) + } + + /// Whether an error is a definitive bond failure that must not be retried: + /// any error `makeConnectionError` classifies as `.authenticationFailed`. + static func isDefinitiveAuthFailure(_ error: Error?) -> Bool { + if case .authenticationFailed = makeConnectionError(error) { return true } + return false + } + + /// Whether an error is `CBError.encryptionTimedOut` — transient on its own, but + /// the ambiguous signature of an invalidated bond when it recurs. + static func isEncryptionTimedOut(_ error: Error?) -> Bool { + guard let nsError = error as NSError? else { return false } + return nsError.domain == CBErrorDomain && nsError.code == CBError.encryptionTimedOut.rawValue + } + + func handleDidDisconnect(_ peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: Error?) { + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + var errorInfo = "none" + if let error = error as NSError? { + errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" + } + let elapsedStr = elapsed.formatted(.number.precision(.fractionLength(2))) + logger.info( + "[BLE] Did disconnect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), isReconnecting: \(isReconnecting), phase: \(phase.name), elapsed: \(elapsedStr)s, error: \(errorInfo)" + ) + + // C7: Ignore stale disconnects for peripherals that don't match the active session. + // A delayed callback from an old peripheral must not cancel the current session. + if let activePeripheral = phase.peripheral, + activePeripheral.identifier != peripheral.identifier { + logger.warning("[BLE] Ignoring stale didDisconnect for \(peripheral.identifier.uuidString.prefix(8)), active: \(activePeripheral.identifier.uuidString.prefix(8))") + return } - func handleDidDiscoverServices(_ peripheral: CBPeripheral, error: Error?) { - let serviceCount = peripheral.services?.count ?? 0 - let hasNordicUART = peripheral.services?.contains { $0.uuid == nordicUARTServiceUUID } ?? false - logger.info("[BLE] Did discover services: \(peripheral.identifier.uuidString.prefix(8)), count: \(serviceCount), hasNordicUART: \(hasNordicUART), error: \(error?.localizedDescription ?? "none")") - - // Handle auto-reconnect - if case .autoReconnecting(let expected, _, _) = phase, - peripheral.identifier == expected.identifier { - if let error { - logger.warning("Auto-reconnect service discovery failed: \(error.localizedDescription)") - transition(to: .idle) - onDisconnection?(expected.identifier, error) - return - } - - guard let service = peripheral.services?.first(where: { $0.uuid == nordicUARTServiceUUID }) else { - logger.warning("Auto-reconnect: service not found") - transition(to: .idle) - onDisconnection?(expected.identifier, nil) - return - } - - peripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: service) - return - } - - // Normal flow - guard case .discoveringServices(let expected, let continuation) = phase, - expected.identifier == peripheral.identifier else { - logger.warning("Unexpected didDiscoverServices") - return - } + // Primary stale-callback fence: reject disconnect callbacks from a previous generation. + // After app resume, iOS may deliver queued disconnects from before the suspend. + // We use CFAbsoluteTime (not a generation counter captured at callback delivery time) + // because CoreBluetooth's didDisconnectPeripheral timestamp reflects the disconnect + // event time per Apple's header ("now or a few seconds ago"), not delivery time. + // A generation captured at delivery time would be unsafe if advanceConnectionGeneration() + // runs between the event and callback delivery. CFAbsoluteTimeGetCurrent() is not + // guaranteed monotonic (NTP adjustments can cause backward jumps), so the 1.0s + // tolerance accommodates typical clock corrections. The peripheral identity check + // above provides the primary defense; this timestamp fence is a secondary guard for + // same-peripheral stale callbacks across generation boundaries. + let generationStart = connectionGenerationStartTime + if Self.isDisconnectCallbackFromPreviousGeneration( + timestamp: timestamp, + generationStart: generationStart + ) { + let callbackAge = CFAbsoluteTimeGetCurrent() - timestamp + logger.warning( + "[BLE] Ignoring stale disconnect callback: " + + "age=\(callbackAge.formatted(.number.precision(.fractionLength(1))))s, " + + "generation=\(connectionGeneration), phase=\(phase.name)" + ) + return + } - if let error { - transition(to: .idle) - continuation.resume(throwing: Self.makeConnectionError(error)) - return - } + // Secondary diagnostic: flag very old callbacks, but do not drop callbacks + // that belong to the current connection generation. + let callbackAge = CFAbsoluteTimeGetCurrent() - timestamp + if callbackAge > 120 { + logger.warning( + "[BLE] Processing aged disconnect callback: " + + "age=\(callbackAge.formatted(.number.precision(.fractionLength(1))))s, " + + "generation=\(connectionGeneration), phase=\(phase.name)" + ) + } - guard let service = peripheral.services?.first(where: { $0.uuid == nordicUARTServiceUUID }) else { - transition(to: .idle) - continuation.resume(throwing: BLEError.characteristicNotFound) - return - } + let deviceID = peripheral.identifier + + if isReconnecting { + switch phase { + case .idle, .waitingForBluetooth: + // No operation owns a peripheral, so there is no pending connect + // behind this callback; entering .autoReconnecting would wait + // indefinitely for a reconnection this machine never started, while + // the powered-on handler and watchdog defer to it. Treat it as a + // full disconnect instead. + logger.warning("[BLE] isReconnecting disconnect in \(phase.name); treating as full disconnect") + handleFullDisconnect(deviceID: deviceID, error: error) + default: + handleAutoReconnectDisconnect(peripheral: peripheral, error: error) + } + } else { + handleFullDisconnect(deviceID: deviceID, error: error) + } + } + + /// Handles a disconnect where iOS is auto-reconnecting the peripheral. + /// Setup-phase continuations route through makeConnectionError so a CBATT + /// auth/encryption code arriving before the bond is established still maps + /// to the typed BLEError.authenticationFailed. + private func handleAutoReconnectDisconnect(peripheral: CBPeripheral, error: Error?) { + let deviceID = peripheral.identifier + var errorInfo = "none" + if let nsError = error as NSError? { + errorInfo = "domain=\(nsError.domain), code=\(nsError.code), desc=\(nsError.localizedDescription)" + } + // A disconnect that lands after discovery completed but before connect() + // adopts the link has no setup continuation to resume and no live session + // to rebuild: the pending connect belongs to a connection the caller never + // received. Route it through full-disconnect teardown so the machine + // settles in .idle, rather than forking into .autoReconnecting while + // connect() separately fails on the vanished .discoveryComplete phase. + if case .discoveryComplete = phase { + handleFullDisconnect(deviceID: deviceID, error: error) + return + } - transition(to: .discoveringCharacteristics( - peripheral: peripheral, - service: service, - continuation: continuation - )) - - peripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: service) - } - - func handleDidDiscoverCharacteristics(_ peripheral: CBPeripheral, service: CBService, error: Error?) { - let characteristics = service.characteristics ?? [] - let hasTX = characteristics.contains { $0.uuid == txCharacteristicUUID } - let hasRX = characteristics.contains { $0.uuid == rxCharacteristicUUID } - logger.info("[BLE] Did discover characteristics: \(peripheral.identifier.uuidString.prefix(8)), count: \(characteristics.count), hasTX: \(hasTX), hasRX: \(hasRX), error: \(error?.localizedDescription ?? "none")") - - // Handle auto-reconnect - if case .autoReconnecting(let expected, _, _) = phase, - peripheral.identifier == expected.identifier { - if let error { - logger.warning("Auto-reconnect characteristic discovery failed: \(error.localizedDescription)") - transition(to: .idle) - onDisconnection?(expected.identifier, error) - return - } - - guard let characteristics = service.characteristics, - let tx = characteristics.first(where: { $0.uuid == txCharacteristicUUID }), - let rx = characteristics.first(where: { $0.uuid == rxCharacteristicUUID }) else { - logger.warning("Auto-reconnect: characteristics not found") - transition(to: .idle) - onDisconnection?(expected.identifier, nil) - return - } - - captureWriteWithoutResponseCapability(from: tx) - - // Store tx/rx in phase for use when notification subscription completes - transition(to: .autoReconnecting(peripheral: peripheral, tx: tx, rx: rx)) - - // Subscribe to notifications to complete reconnection - peripheral.setNotifyValue(true, for: rx) - return - } + logger.info("[BLE] iOS auto-reconnect started: \(deviceID.uuidString.prefix(8)), will attempt automatic reconnection") + + // Clean up pending operations before transitioning. + // This ensures any pending setup continuations and write waiters are properly + // resumed/failed, preventing orphaned continuations and waiter starvation. + cancelPendingWriteOperations() + + // Clean up current state but preserve peripheral for reconnection. + // transition() handles dataContinuation cleanup when leaving .connected. + // Note: We handle phase continuations manually below since cancelCurrentOperation + // would transition to .idle, but we need to go to .autoReconnecting. + let setupError = Self.makeConnectionError(error, fallback: "Disconnected during setup") + switch phase { + case let .connecting(_, continuation, timeoutTask): + timeoutTask.cancel() + continuation.resume(throwing: setupError) + case let .discoveringServices(_, continuation): + continuation.resume(throwing: setupError) + case let .discoveringCharacteristics(_, _, continuation): + continuation.resume(throwing: setupError) + case let .subscribingToNotifications(_, _, _, continuation): + continuation.resume(throwing: setupError) + default: + break + } - // Normal flow - guard case .discoveringCharacteristics(let expected, let expectedService, let continuation) = phase, - expected.identifier == peripheral.identifier, - expectedService.uuid == service.uuid else { - logger.warning("Unexpected didDiscoverCharacteristics") - return - } + // Advance generation for the auto-reconnect cycle + advanceConnectionGeneration() + + resetAutoReconnectFailureTracking() + transition(to: .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil)) + + // C5: Arm the auto-reconnect discovery timeout (same as restoration path) + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) + + // Notify handler so UI can show "connecting" state + onAutoReconnecting?(deviceID, errorInfo) + } + + /// Handles a full (non-reconnecting) disconnection. + private func handleFullDisconnect(deviceID: UUID, error: Error?) { + switch phase { + case .disconnecting: + // Expected disconnection, transition handled by disconnect() + break + + case .connected, .autoReconnecting: + // Unexpected disconnection. Map through makeConnectionError like the + // sibling onDisconnection sites so bond invalidation arriving here still + // surfaces as the typed BLEError.authenticationFailed; nil stays nil to + // keep a clean disconnect distinguishable from a failure. + cancelCurrentOperation(with: BLEError.notConnected) + onDisconnection?(deviceID, error.map { Self.makeConnectionError($0) }) + + default: + // Disconnection during connection attempt + cancelCurrentOperation(with: Self.makeConnectionError(error, fallback: "Disconnected during setup")) + } + } + + func handleDidDiscoverServices(_ peripheral: CBPeripheral, error: Error?) { + let serviceCount = peripheral.services?.count ?? 0 + let hasNordicUART = peripheral.services?.contains { $0.uuid == nordicUARTServiceUUID } ?? false + logger.info("[BLE] Did discover services: \(peripheral.identifier.uuidString.prefix(8)), count: \(serviceCount), hasNordicUART: \(hasNordicUART), error: \(error?.localizedDescription ?? "none")") + + // Handle auto-reconnect + if case let .autoReconnecting(expected, _, _) = phase, + peripheral.identifier == expected.identifier { + if let error { + logger.warning("Auto-reconnect service discovery failed: \(error.localizedDescription)") + transition(to: .idle) + onDisconnection?(expected.identifier, Self.makeConnectionError(error)) + return + } - if let error { - transition(to: .idle) - continuation.resume(throwing: Self.makeConnectionError(error)) - return - } + guard let service = peripheral.services?.first(where: { $0.uuid == nordicUARTServiceUUID }) else { + logger.warning("Auto-reconnect: service not found") + transition(to: .idle) + onDisconnection?(expected.identifier, nil) + return + } - guard let characteristics = service.characteristics, - let tx = characteristics.first(where: { $0.uuid == txCharacteristicUUID }), - let rx = characteristics.first(where: { $0.uuid == rxCharacteristicUUID }) else { - transition(to: .idle) - continuation.resume(throwing: BLEError.characteristicNotFound) - return - } + peripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: service) + return + } - captureWriteWithoutResponseCapability(from: tx) + // Normal flow + guard case let .discoveringServices(expected, continuation) = phase, + expected.identifier == peripheral.identifier else { + logger.warning("Unexpected didDiscoverServices") + return + } - transition(to: .subscribingToNotifications( - peripheral: peripheral, - tx: tx, - rx: rx, - continuation: continuation - )) + if let error { + transition(to: .idle) + continuation.resume(throwing: Self.makeConnectionError(error)) + return + } - peripheral.setNotifyValue(true, for: rx) + guard let service = peripheral.services?.first(where: { $0.uuid == nordicUARTServiceUUID }) else { + transition(to: .idle) + continuation.resume(throwing: BLEError.characteristicNotFound) + return } - func handleDidUpdateNotificationState(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?) { - // swiftlint:disable:next line_length - logger.info("[BLE] Did update notification state: \(peripheral.identifier.uuidString.prefix(8)), isNotifying: \(characteristic.isNotifying), charUUID: \(characteristic.uuid.uuidString.prefix(8)), error: \(error?.localizedDescription ?? "none")") + transition(to: .discoveringCharacteristics( + peripheral: peripheral, + service: service, + continuation: continuation + )) + + peripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: service) + } + + func handleDidDiscoverCharacteristics(_ peripheral: CBPeripheral, service: CBService, error: Error?) { + let characteristics = service.characteristics ?? [] + let hasTX = characteristics.contains { $0.uuid == txCharacteristicUUID } + let hasRX = characteristics.contains { $0.uuid == rxCharacteristicUUID } + logger.info("[BLE] Did discover characteristics: \(peripheral.identifier.uuidString.prefix(8)), count: \(characteristics.count), hasTX: \(hasTX), hasRX: \(hasRX), error: \(error?.localizedDescription ?? "none")") + + // Handle auto-reconnect + if case let .autoReconnecting(expected, _, _) = phase, + peripheral.identifier == expected.identifier { + if let error { + logger.warning("Auto-reconnect characteristic discovery failed: \(error.localizedDescription)") + transition(to: .idle) + onDisconnection?(expected.identifier, Self.makeConnectionError(error)) + return + } + + guard let characteristics = service.characteristics, + let tx = characteristics.first(where: { $0.uuid == txCharacteristicUUID }), + let rx = characteristics.first(where: { $0.uuid == rxCharacteristicUUID }) else { + logger.warning("Auto-reconnect: characteristics not found") + transition(to: .idle) + onDisconnection?(expected.identifier, nil) + return + } - guard case .subscribingToNotifications(let expected, let tx, let rx, let continuation) = phase, - expected.identifier == peripheral.identifier, - characteristic.uuid == rxCharacteristicUUID else { - // Could be auto-reconnect scenario - handle separately - handleReconnectionNotificationState(peripheral, characteristic: characteristic, error: error) - return - } + captureWriteWithoutResponseCapability(from: tx) - if let error { - centralManager.cancelPeripheralConnection(expected) - transition(to: .idle) - continuation.resume(throwing: Self.makeConnectionError(error)) - return - } + // Store tx/rx in phase for use when notification subscription completes + transition(to: .autoReconnecting(peripheral: peripheral, tx: tx, rx: rx)) - // C9: Verify notification subscription actually succeeded - guard characteristic.isNotifying else { - logger.warning("[BLE] Notification subscription completed without isNotifying=true") - transition(to: .idle) - continuation.resume(throwing: BLEError.connectionFailed("Notification subscription failed")) - return - } + // Subscribe to notifications to complete reconnection + peripheral.setNotifyValue(true, for: rx) + return + } - // Cancel the service discovery timeout since we completed successfully - serviceDiscoveryTimeoutTask?.cancel() - serviceDiscoveryTimeoutTask = nil + // Normal flow + guard case let .discoveringCharacteristics(expected, expectedService, continuation) = phase, + expected.identifier == peripheral.identifier, + expectedService.uuid == service.uuid else { + logger.warning("Unexpected didDiscoverCharacteristics") + return + } - // Transition to discoveryComplete before resuming the continuation. - // This prevents double-resume if cancelCurrentOperation, disconnect(), - // or a timeout handler runs before connect() transitions to .connected. - transition(to: .discoveryComplete(peripheral: expected, tx: tx, rx: rx)) - continuation.resume() + if let error { + transition(to: .idle) + continuation.resume(throwing: Self.makeConnectionError(error)) + return } - private func handleReconnectionNotificationState(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?) { - // Handle auto-reconnect notification subscription completion - guard case .autoReconnecting(let expected, let tx, let rx) = phase, - peripheral.identifier == expected.identifier else { - return - } + guard let characteristics = service.characteristics, + let tx = characteristics.first(where: { $0.uuid == txCharacteristicUUID }), + let rx = characteristics.first(where: { $0.uuid == rxCharacteristicUUID }) else { + transition(to: .idle) + continuation.resume(throwing: BLEError.characteristicNotFound) + return + } - // C9: Verify characteristic UUID matches RX and notification is active - guard characteristic.uuid == rxCharacteristicUUID else { - logger.debug("[BLE] Auto-reconnect: ignoring notification state for non-RX characteristic \(characteristic.uuid.uuidString.prefix(8))") - return - } + captureWriteWithoutResponseCapability(from: tx) - if let error { - logger.warning("Auto-reconnect notification subscription failed: \(error.localizedDescription)") - transition(to: .idle) - onDisconnection?(peripheral.identifier, error) - return - } + transition(to: .subscribingToNotifications( + peripheral: peripheral, + tx: tx, + rx: rx, + continuation: continuation + )) - guard characteristic.isNotifying else { - logger.warning("[BLE] Auto-reconnect: notification subscription completed without isNotifying=true") - transition(to: .idle) - onDisconnection?(peripheral.identifier, nil) - return - } + peripheral.setNotifyValue(true, for: rx) + } - guard let tx, let rx else { - logger.error("Auto-reconnect: tx/rx characteristics missing from phase") - transition(to: .idle) - onDisconnection?(peripheral.identifier, nil) - return - } + func handleDidUpdateNotificationState(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?) { + logger.info("[BLE] Did update notification state: \(peripheral.identifier.uuidString.prefix(8)), isNotifying: \(characteristic.isNotifying), charUUID: \(characteristic.uuid.uuidString.prefix(8)), error: \(error?.localizedDescription ?? "none")") - // Cancel the auto-reconnect discovery timeout since we completed successfully - autoReconnectDiscoveryTimeoutTask?.cancel() - autoReconnectDiscoveryTimeoutTask = nil + guard case let .subscribingToNotifications(expected, tx, rx, continuation) = phase, + expected.identifier == peripheral.identifier, + characteristic.uuid == rxCharacteristicUUID else { + // Could be auto-reconnect scenario - handle separately + handleReconnectionNotificationState(peripheral, characteristic: characteristic, error: error) + return + } - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.info("[BLE] Auto-reconnect notification subscription complete, elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + if let error { + centralManager.cancelPeripheralConnection(expected) + transition(to: .idle) + continuation.resume(throwing: Self.makeConnectionError(error)) + return + } - // Create data stream and transition to connected - let (stream, continuation) = AsyncStream.makeStream( - of: Data.self, - bufferingPolicy: .bufferingOldest(512) - ) + // C9: Verify notification subscription actually succeeded + guard characteristic.isNotifying else { + logger.warning("[BLE] Notification subscription completed without isNotifying=true") + transition(to: .idle) + continuation.resume(throwing: BLEError.connectionFailed("Notification subscription failed")) + return + } - // Pass continuation to delegate handler for direct yielding (preserves ordering) - delegateHandler.setDataContinuation(continuation) + // Cancel the service discovery timeout since we completed successfully + serviceDiscoveryTimeoutTask?.cancel() + serviceDiscoveryTimeoutTask = nil + + // Transition to discoveryComplete before resuming the continuation. + // This prevents double-resume if cancelCurrentOperation, disconnect(), + // or a timeout handler runs before connect() transitions to .connected. + transition(to: .discoveryComplete(peripheral: expected, tx: tx, rx: rx)) + continuation.resume() + } + + private func handleReconnectionNotificationState(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?) { + // Handle auto-reconnect notification subscription completion + guard case let .autoReconnecting(expected, tx, rx) = phase, + peripheral.identifier == expected.identifier else { + return + } - transition(to: .connected( - peripheral: peripheral, - tx: tx, - rx: rx, - dataContinuation: continuation - )) - startRSSIKeepalive(for: peripheral) + // C9: Verify characteristic UUID matches RX and notification is active + guard characteristic.uuid == rxCharacteristicUUID else { + logger.debug("[BLE] Auto-reconnect: ignoring notification state for non-RX characteristic \(characteristic.uuid.uuidString.prefix(8))") + return + } - logger.info("[BLE] iOS auto-reconnect complete: \(peripheral.identifier.uuidString.prefix(8))") - onReconnection?(peripheral.identifier, stream) + if let error { + logger.warning("Auto-reconnect notification subscription failed: \(error.localizedDescription)") + transition(to: .idle) + onDisconnection?(peripheral.identifier, Self.makeConnectionError(error)) + return } - func handleDidWriteValue(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?, writeSequence: UInt64) { - guard let continuation = pendingWriteContinuation else { - logger.debug("[BLE] didWriteValue with no pending continuation, ignoring") - return - } + guard characteristic.isNotifying else { + logger.warning("[BLE] Auto-reconnect: notification subscription completed without isNotifying=true") + transition(to: .idle) + onDisconnection?(peripheral.identifier, nil) + return + } - // C8: Reject stale write callbacks from a previous (timed-out) write - if writeSequence != pendingWriteSequence { - logger.warning("[BLE] Stale didWriteValue: seq=\(writeSequence), expected=\(self.pendingWriteSequence), ignoring") - return - } + guard let tx, let rx else { + logger.error("Auto-reconnect: tx/rx characteristics missing from phase") + transition(to: .idle) + onDisconnection?(peripheral.identifier, nil) + return + } - // Cancel the timeout task since write completed - writeTimeoutTask?.cancel() - writeTimeoutTask = nil + // Cancel the auto-reconnect discovery timeout since we completed successfully + autoReconnectDiscoveryTimeoutTask?.cancel() + autoReconnectDiscoveryTimeoutTask = nil + + let elapsed = Date().timeIntervalSince(phaseStartTime) + logger.info("[BLE] Auto-reconnect notification subscription complete, elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + + // Create data stream and transition to connected + let (stream, continuation) = AsyncStream.makeStream( + of: Data.self, + bufferingPolicy: .bufferingOldest(512) + ) + + // Pass continuation to delegate handler for direct yielding (preserves ordering) + delegateHandler.setDataContinuation(continuation) + + transition(to: .connected( + peripheral: peripheral, + tx: tx, + rx: rx, + dataContinuation: continuation + )) + startRSSIKeepalive(for: peripheral) + + logger.info("[BLE] iOS auto-reconnect complete: \(peripheral.identifier.uuidString.prefix(8))") + onReconnection?(peripheral.identifier, stream) + } + + func handleDidWriteValue(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?, writeSequence: UInt64) { + guard let continuation = pendingWriteContinuation else { + logger.debug("[BLE] didWriteValue with no pending continuation, ignoring") + return + } - // Reset queue tracking on successful completion - consecutiveQueuedWrites = 0 + // C8: Reject stale write callbacks from a previous (timed-out) write + if writeSequence != pendingWriteSequence { + logger.warning("[BLE] Stale didWriteValue: seq=\(writeSequence), expected=\(pendingWriteSequence), ignoring") + return + } - pendingWriteContinuation = nil + // Cancel the timeout task since write completed + writeTimeoutTask?.cancel() + writeTimeoutTask = nil + + // Reset queue tracking on successful completion + consecutiveQueuedWrites = 0 + + pendingWriteContinuation = nil + + if let error { + logger.warning("[BLE] Write error: seq=\(writeSequence), error=\(error.localizedDescription)") + // Map through makeConnectionError like the sibling handlers so an ATT + // auth/encryption code on a write still routes to guided re-pair; + // everything else stays the typed write error. + let mapped = Self.makeConnectionError(error) + if case .authenticationFailed = mapped { + continuation.resume(throwing: mapped) + } else { + continuation.resume(throwing: BLEError.writeError(error.localizedDescription)) + } + } else { + logger.debug("[BLE] Write complete: seq=\(writeSequence)") + continuation.resume() + } - if let error { - logger.warning("[BLE] Write error: seq=\(writeSequence), error=\(error.localizedDescription)") - continuation.resume(throwing: BLEError.writeError(error.localizedDescription)) - } else { - logger.debug("[BLE] Write complete: seq=\(writeSequence)") - continuation.resume() - } + earliestNextWrite = ContinuousClock.now.advanced(by: .seconds(writePacingDelay)) + resumeNextWriteWaiter() + } - earliestNextWrite = ContinuousClock.now.advanced(by: .seconds(writePacingDelay)) - resumeNextWriteWaiter() - } - - func handleDidReadRSSI(RSSI: NSNumber, error: Error?) { - if let error { - consecutiveRSSIFailures += 1 - if consecutiveRSSIFailures == 3 || consecutiveRSSIFailures % 10 == 0 { - logger.warning( - "[BLE] RSSI read failed (\(self.consecutiveRSSIFailures) consecutive): \(error.localizedDescription)" - ) - } - } else { - if consecutiveRSSIFailures > 0 { - logger.info("[BLE] RSSI read recovered after \(self.consecutiveRSSIFailures) failures, RSSI: \(RSSI)") - } - consecutiveRSSIFailures = 0 - } + func handleDidReadRSSI(RSSI: NSNumber, error: Error?) { + if let error { + consecutiveRSSIFailures += 1 + if consecutiveRSSIFailures == 3 || consecutiveRSSIFailures % 10 == 0 { + logger.warning( + "[BLE] RSSI read failed (\(consecutiveRSSIFailures) consecutive): \(error.localizedDescription)" + ) + } + } else { + if consecutiveRSSIFailures > 0 { + logger.info("[BLE] RSSI read recovered after \(consecutiveRSSIFailures) failures, RSSI: \(RSSI)") + } + consecutiveRSSIFailures = 0 } + } } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift index 0c3929e7..ae3c16ef 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift @@ -9,1208 +9,1327 @@ import Foundation /// owns its resources (continuations, timeouts), ensuring proper cleanup /// on any transition. actor BLEStateMachine: BLEStateMachineProtocol { - - // MARK: - Logging - - let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEStateMachine") - let instanceID = String(UUID().uuidString.prefix(8)) - var lastCentralState: CBManagerState? - - nonisolated var processContext: String { - let processName = ProcessInfo.processInfo.processName - let bundleID = Bundle.main.bundleIdentifier ?? "unknown" - return "process: \(processName), bundle: \(bundleID)" + // MARK: - Logging + + let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEStateMachine") + let instanceID = String(UUID().uuidString.prefix(8)) + var lastCentralState: CBManagerState? + + nonisolated var processContext: String { + let processName = ProcessInfo.processInfo.processName + let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + return "process: \(processName), bundle: \(bundleID)" + } + + /// Converts CBPeripheralState to readable string for diagnostics + nonisolated func peripheralStateString(_ state: CBPeripheralState) -> String { + switch state { + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + case .disconnecting: return "disconnecting" + @unknown default: return "unknown(\(state.rawValue))" } - - /// Converts CBPeripheralState to readable string for diagnostics - nonisolated func peripheralStateString(_ state: CBPeripheralState) -> String { - switch state { - case .disconnected: return "disconnected" - case .connecting: return "connecting" - case .connected: return "connected" - case .disconnecting: return "disconnecting" - @unknown default: return "unknown(\(state.rawValue))" - } + } + + // MARK: - State + + var phase: BLEPhase = .idle + + /// Tracks when the current phase started (for timing diagnostics) + var phaseStartTime: Date = .init() + + /// Monotonically increasing generation counter. Incremented on each new + /// connection or auto-reconnect cycle. Used to reject stale disconnect + /// callbacks that arrive after a newer connection has started. + var connectionGeneration: UInt64 = 0 + + /// Monotonic boundary timestamp for the current generation. + /// Disconnect callbacks older than this belong to a previous generation. + var connectionGenerationStartTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() + + /// Number of times a discovery watchdog has deferred teardown within the + /// current generation because the peripheral was already connected. Reset + /// per generation in `advanceConnectionGeneration()`. + var discoveryTimeoutExtensions = 0 + + /// Max times a discovery watchdog defers teardown while the peripheral is + /// already connected, before forcing a reconnect. Bounds recovery so a + /// genuinely wedged-but-connected link still tears down eventually. + static let maxDiscoveryTimeoutExtensions = 2 + + /// Max consecutive `didFailToConnect` callbacks tolerated within one + /// auto-reconnect episode before the machine gives up and notifies loss. + /// Bounds re-arming so a radio that fast-rejects every connect cannot spin here. + static let maxAutoReconnectConnectFailures = 5 + + /// Consecutive `didFailToConnect` callbacks in the current auto-reconnect + /// episode. Reset when a link is re-established and when the episode ends. + var autoReconnectConnectFailures = 0 + + /// How many of `autoReconnectConnectFailures` carried `CBError.encryptionTimedOut`. + /// A majority routes an exhausted episode to guided re-pair, since repeated + /// encryption timeouts are the ambiguous in-app signature of an invalidated bond. + var encryptionTimedOutConnectFailures = 0 + + /// Expose current phase for testing + var currentPhase: BLEPhase { + phase + } + + /// Expose current connection generation for testing + var currentConnectionGeneration: UInt64 { + connectionGeneration + } + + /// Expose the per-generation discovery-extension count for testing + var currentDiscoveryTimeoutExtensions: Int { + discoveryTimeoutExtensions + } + + /// Expose the auto-reconnect connect-failure tally for testing + var currentAutoReconnectConnectFailures: Int { + autoReconnectConnectFailures + } + + // MARK: - CoreBluetooth + + /// The central manager instance. + /// + /// Marked `nonisolated(unsafe)` because: + /// 1. CBCentralManager is not Sendable + /// 2. We need nonisolated access from `bluetoothState` property + /// 3. The manager is only mutated once during initialization + /// 4. All other access is from the actor's isolated context + /// 5. The `bluetoothState` property returns `.unknown` during the brief + /// initialization window before the manager is assigned + nonisolated(unsafe) var centralManager: CBCentralManager! + let delegateHandler: BLEDelegateHandler + + // MARK: - Configuration + + private let stateRestorationID = "com.pocketmesh.ble.central" + private let connectionTimeout: TimeInterval + let serviceDiscoveryTimeout: TimeInterval + private let autoReconnectDiscoveryTimeout: TimeInterval + private let writeTimeout: TimeInterval + + /// Delay between write operations for ESP32 compatibility (0 = no pacing) + var writePacingDelay: TimeInterval = 0 + + /// Next write blocked until this instant. Checked in claimWriteSlot + /// so both queued and sequential writes are paced. + var earliestNextWrite: ContinuousClock.Instant = .now + + /// Tracks consecutive queued writes for diagnostic logging + var consecutiveQueuedWrites = 0 + private let queuePressureThreshold = 3 + + // MARK: - UUIDs + + let nordicUARTServiceUUID = CBUUID(string: BLEServiceUUID.nordicUART) + let txCharacteristicUUID = CBUUID(string: BLEServiceUUID.txCharacteristic) + let rxCharacteristicUUID = CBUUID(string: BLEServiceUUID.rxCharacteristic) + + /// Pending write continuation (only one write at a time) + var pendingWriteContinuation: CheckedContinuation? + + /// Monotonic sequence number for correlating didWriteValue callbacks to the active write. + /// Each write's sequence is recorded with the delegate handler at issue time + /// (`recordIssuedWriteSequence`), and the delegate tags each callback with the oldest + /// unacknowledged write's sequence. A callback from write N that arrives after N timed + /// out therefore carries N, mismatches `pendingWriteSequence` (N+1), and is dropped + /// instead of resuming write N+1's continuation with write N's result. + private var writeSequenceNumber: UInt64 = 0 + var pendingWriteSequence: UInt64 = 0 + + /// Queue of tasks waiting to write (serializes concurrent sends) + private var writeWaiters: [CheckedContinuation] = [] + + /// Tracks the current write timeout task so it can be cancelled when write completes + var writeTimeoutTask: Task? + + /// Whether the connected peripheral's write (tx) characteristic advertises + /// `.writeWithoutResponse`. Captured at characteristic discovery (both the normal and + /// auto-reconnect branches) and only read while `.connected`, so it cannot go stale for + /// the same radio: a fresh value is written before any send can occur. nRF52 (Adafruit + /// BLEUart) advertises it; ESP32 does not, so ESP32 stays on the acknowledged path. + private var txSupportsWriteWithoutResponse = false + + /// Continuation for a `sendWithoutResponse` caller parked on CoreBluetooth backpressure + /// (`canSendWriteWithoutResponse == false`). Resumed by the `peripheralIsReady` delegate + /// callback, by `cancelPendingWriteOperations` on teardown, or thrown by its own timeout + /// backstop, so a sender never hangs across a disconnect or a peripheral that goes silent. + /// Single slot: the pipeline issues one Write Command at a time. + private var writeWithoutResponseReadyContinuation: CheckedContinuation? + + /// Backstop timeout for `writeWithoutResponseReadyContinuation`: fails a parked sender if + /// the peripheral never re-signals readiness and no disconnect arrives, so a stuck Write + /// Command cannot defeat the session's pipeline timeout and hang the channel sync. + private var writeWithoutResponseReadyTimeoutTask: Task? + + /// Error recorded when a `.discoveryComplete` phase is torn down before + /// `connect()` adopts it, so the in-flight connect surfaces the real + /// teardown classification (a bond-loss disconnect in that window must + /// still route to guided re-pair) instead of a generic failure. + var discoveryCompleteTeardownError: BLEError? + + /// Tracks the service discovery timeout task so it can be cancelled on success + var serviceDiscoveryTimeoutTask: Task? + + /// Tracks the auto-reconnect discovery timeout task so it can be cancelled on success + var autoReconnectDiscoveryTimeoutTask: Task? + + /// Periodic RSSI read task that keeps the BLE connection alive in background. + /// Without periodic BLE activity, iOS may drop idle connections. + private var rssiKeepaliveTask: Task? + + /// Consecutive RSSI read failures. Reset on success. Logged for diagnostics. + var consecutiveRSSIFailures = 0 + + /// Tracks whether the app is in the foreground. Used to gate + /// keepalive and timeout behavior. + private var isAppActive = true + + /// Tracks whether CBCentralManager has been created + private var isActivated = false + + /// Grace period task for poweredOff during waitingForBluetooth. + /// Allows CBCentralManager initialization to settle (poweredOff → poweredOn). + var bluetoothPowerOffGraceTask: Task? + + // MARK: - Scanning (orthogonal to connection lifecycle) + + var isCurrentlyScanning = false + var pendingScanRequest = false + /// Installed by `ConnectionManager.startBLEScanning` and reset to a no-op when scanning ends. + private var onDeviceDiscovered: (@Sendable (UUID, String?, Int) -> Void)? + + // MARK: - Callbacks + + /// Installed by `ConnectionManager.init` (via `iOSBLETransport.setDisconnectionHandler`). + var onDisconnection: (@Sendable (UUID, Error?) -> Void)? + /// Installed by `ConnectionManager.init` (via `iOSBLETransport.setReconnectionHandler`, + /// which wraps it to capture the data stream before the handler runs). + var onReconnection: (@Sendable (UUID, AsyncStream) -> Void)? + /// Installed by `ConnectionManager.init`. + var onBluetoothStateChange: (@Sendable (CBManagerState) -> Void)? + /// Installed by `ConnectionManager.init`. + var onBluetoothPoweredOn: (@Sendable () -> Void)? + /// Called when entering iOS auto-reconnecting phase. + /// The device has disconnected but iOS will attempt automatic reconnection. + /// Note: The MeshCore session is invalid at this point and will be rebuilt upon successful reconnection. + /// Installed by `ConnectionManager.init`. + var onAutoReconnecting: (@Sendable (UUID, String) -> Void)? + + /// Distinguishes the call site driving `handleRestoredPeripheral` so the function + /// fires `onAutoReconnecting` only when no upstream caller has already claimed the cycle. + enum RestoredPeripheralSource { + case stateRestoration + case adoption + } + + // MARK: - Initialization + + /// Creates a new BLE state machine. + /// + /// - Parameters: + /// - connectionTimeout: Timeout for initial connection (default 10s) + /// - serviceDiscoveryTimeout: Timeout for service/characteristic discovery (default 40s for pairing dialog) + /// - autoReconnectDiscoveryTimeout: Timeout for auto-reconnect discovery (default 15s, shorter since no pairing expected) + /// - writeTimeout: Timeout for write operations (default 5s) + /// - writePacingDelay: Delay between write operations for ESP32 compatibility (default 0 = no pacing) + init( + connectionTimeout: TimeInterval = 10.0, + serviceDiscoveryTimeout: TimeInterval = 40.0, + autoReconnectDiscoveryTimeout: TimeInterval = 15.0, + writeTimeout: TimeInterval = 5.0, + writePacingDelay: TimeInterval = 0 + ) { + self.connectionTimeout = connectionTimeout + self.serviceDiscoveryTimeout = serviceDiscoveryTimeout + self.autoReconnectDiscoveryTimeout = autoReconnectDiscoveryTimeout + self.writeTimeout = writeTimeout + self.writePacingDelay = writePacingDelay + delegateHandler = BLEDelegateHandler() + } + + /// Sets the write pacing delay for ESP32 compatibility. + /// - Parameter delay: Delay in seconds between write operations (0 = no pacing) + func setWritePacingDelay(_ delay: TimeInterval) { + writePacingDelay = delay + } + + /// Activates the BLE state machine, creating the CBCentralManager. + /// Call once during app initialization. Safe to call multiple times. + func activate() { + guard !isActivated else { return } + isActivated = true + logger.info("[BLE] Activating state machine, instance: \(instanceID), \(processContext)") + initializeCentralManager() + } + + private let centralQueue = DispatchQueue(label: "com.pocketmesh.ble.central") + + private func initializeCentralManager() { + // Set stateMachine reference before creating CBCentralManager. + // iOS calls willRestoreState during or immediately after CBCentralManager.init(), + // and the delegate handler needs the stateMachine reference to process it. + delegateHandler.stateMachine = self + + logger.info("[BLE] Initializing central manager, instance: \(instanceID), \(processContext)") + let options: [String: Any] = [ + CBCentralManagerOptionRestoreIdentifierKey: stateRestorationID, + CBCentralManagerOptionShowPowerAlertKey: true + ] + centralManager = CBCentralManager( + delegate: delegateHandler, + queue: centralQueue, + options: options + ) + } + + // MARK: - Connection Generation + + /// Advances the connection generation counter and records the boundary timestamp. + /// Called when starting a new connection, auto-reconnect cycle, or restoration reconnect + /// so that stale disconnect callbacks from previous generations can be identified and rejected. + func advanceConnectionGeneration() { + connectionGeneration &+= 1 + connectionGenerationStartTime = CFAbsoluteTimeGetCurrent() + discoveryTimeoutExtensions = 0 + discoveryCompleteTeardownError = nil + } + + /// Clears the auto-reconnect connect-failure tally. Called when a link is + /// re-established and when an episode ends, so the next episode starts fresh. + func resetAutoReconnectFailureTracking() { + autoReconnectConnectFailures = 0 + encryptionTimedOutConnectFailures = 0 + } + + /// Returns true when a disconnect callback's timestamp predates the current generation boundary. + /// Uses CFAbsoluteTime from CoreBluetooth's didDisconnectPeripheral (reflects disconnect event + /// time per Apple's header: "now or a few seconds ago", not callback delivery time). + /// The tolerance accounts for non-monotonic clock adjustments (NTP sync, user clock changes). + static func isDisconnectCallbackFromPreviousGeneration( + timestamp: CFAbsoluteTime, + generationStart: CFAbsoluteTime, + tolerance: CFAbsoluteTime = 1.0 + ) -> Bool { + timestamp + tolerance < generationStart + } + + // MARK: - API + + /// Whether the state machine is currently connected to a device + var isConnected: Bool { + if case .connected = phase { return true } + return false + } + + /// Whether the state machine is currently handling iOS auto-reconnect or state restoration + var isAutoReconnecting: Bool { + switch phase { + case .autoReconnecting, .restoringState: + true + default: + false } - - // MARK: - State - - var phase: BLEPhase = .idle - - /// Tracks when the current phase started (for timing diagnostics) - var phaseStartTime: Date = Date() - - /// Monotonically increasing generation counter. Incremented on each new - /// connection or auto-reconnect cycle. Used to reject stale disconnect - /// callbacks that arrive after a newer connection has started. - var connectionGeneration: UInt64 = 0 - - /// Monotonic boundary timestamp for the current generation. - /// Disconnect callbacks older than this belong to a previous generation. - var connectionGenerationStartTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() - - /// Number of times a discovery watchdog has deferred teardown within the - /// current generation because the peripheral was already connected. Reset - /// per generation in `advanceConnectionGeneration()`. - var discoveryTimeoutExtensions = 0 - - /// Max times a discovery watchdog defers teardown while the peripheral is - /// already connected, before forcing a reconnect. Bounds recovery so a - /// genuinely wedged-but-connected link still tears down eventually. - static let maxDiscoveryTimeoutExtensions = 2 - - /// Expose current phase for testing - var currentPhase: BLEPhase { phase } - - /// Expose current connection generation for testing - var currentConnectionGeneration: UInt64 { connectionGeneration } - - /// Expose the per-generation discovery-extension count for testing - var currentDiscoveryTimeoutExtensions: Int { discoveryTimeoutExtensions } - - // MARK: - CoreBluetooth - - /// The central manager instance. - /// - /// Marked `nonisolated(unsafe)` because: - /// 1. CBCentralManager is not Sendable - /// 2. We need nonisolated access from `bluetoothState` property - /// 3. The manager is only mutated once during initialization - /// 4. All other access is from the actor's isolated context - /// 5. The `bluetoothState` property returns `.unknown` during the brief - /// initialization window before the manager is assigned - nonisolated(unsafe) var centralManager: CBCentralManager! - let delegateHandler: BLEDelegateHandler - - // MARK: - Configuration - - private let stateRestorationID = "com.pocketmesh.ble.central" - private let connectionTimeout: TimeInterval - let serviceDiscoveryTimeout: TimeInterval - private let autoReconnectDiscoveryTimeout: TimeInterval - private let writeTimeout: TimeInterval - - /// Delay between write operations for ESP32 compatibility (0 = no pacing) - var writePacingDelay: TimeInterval = 0 - - /// Next write blocked until this instant. Checked in claimWriteSlot - /// so both queued and sequential writes are paced. - var earliestNextWrite: ContinuousClock.Instant = .now - - /// Tracks consecutive queued writes for diagnostic logging - var consecutiveQueuedWrites = 0 - private let queuePressureThreshold = 3 - - // MARK: - UUIDs - - let nordicUARTServiceUUID = CBUUID(string: BLEServiceUUID.nordicUART) - let txCharacteristicUUID = CBUUID(string: BLEServiceUUID.txCharacteristic) - let rxCharacteristicUUID = CBUUID(string: BLEServiceUUID.rxCharacteristic) - - /// Pending write continuation (only one write at a time) - var pendingWriteContinuation: CheckedContinuation? - - /// Monotonic sequence number for correlating didWriteValue callbacks to the active write. - /// Each write's sequence is recorded with the delegate handler at issue time - /// (`recordIssuedWriteSequence`), and the delegate tags each callback with the oldest - /// unacknowledged write's sequence. A callback from write N that arrives after N timed - /// out therefore carries N, mismatches `pendingWriteSequence` (N+1), and is dropped - /// instead of resuming write N+1's continuation with write N's result. - private var writeSequenceNumber: UInt64 = 0 - var pendingWriteSequence: UInt64 = 0 - - /// Queue of tasks waiting to write (serializes concurrent sends) - private var writeWaiters: [CheckedContinuation] = [] - - /// Tracks the current write timeout task so it can be cancelled when write completes - var writeTimeoutTask: Task? - - /// Whether the connected peripheral's write (tx) characteristic advertises - /// `.writeWithoutResponse`. Captured at characteristic discovery (both the normal and - /// auto-reconnect branches) and only read while `.connected`, so it cannot go stale for - /// the same radio: a fresh value is written before any send can occur. nRF52 (Adafruit - /// BLEUart) advertises it; ESP32 does not, so ESP32 stays on the acknowledged path. - private var txSupportsWriteWithoutResponse = false - - /// Continuation for a `sendWithoutResponse` caller parked on CoreBluetooth backpressure - /// (`canSendWriteWithoutResponse == false`). Resumed by the `peripheralIsReady` delegate - /// callback, by `cancelPendingWriteOperations` on teardown, or thrown by its own timeout - /// backstop, so a sender never hangs across a disconnect or a peripheral that goes silent. - /// Single slot: the pipeline issues one Write Command at a time. - private var writeWithoutResponseReadyContinuation: CheckedContinuation? - - /// Backstop timeout for `writeWithoutResponseReadyContinuation`: fails a parked sender if - /// the peripheral never re-signals readiness and no disconnect arrives, so a stuck Write - /// Command cannot defeat the session's pipeline timeout and hang the channel sync. - private var writeWithoutResponseReadyTimeoutTask: Task? - - /// Tracks the service discovery timeout task so it can be cancelled on success - var serviceDiscoveryTimeoutTask: Task? - - /// Tracks the auto-reconnect discovery timeout task so it can be cancelled on success - var autoReconnectDiscoveryTimeoutTask: Task? - - /// Periodic RSSI read task that keeps the BLE connection alive in background. - /// Without periodic BLE activity, iOS may drop idle connections. - private var rssiKeepaliveTask: Task? - - /// Consecutive RSSI read failures. Reset on success. Logged for diagnostics. - var consecutiveRSSIFailures = 0 - - /// Tracks whether the app is in the foreground. Used to gate - /// keepalive and timeout behavior. - private var isAppActive = true - - /// Tracks whether CBCentralManager has been created - private var isActivated = false - - /// Grace period task for poweredOff during waitingForBluetooth. - /// Allows CBCentralManager initialization to settle (poweredOff → poweredOn). - var bluetoothPowerOffGraceTask: Task? - - // MARK: - Scanning (orthogonal to connection lifecycle) - - var isCurrentlyScanning = false - var pendingScanRequest = false - /// Installed by `ConnectionManager.startBLEScanning` and reset to a no-op when scanning ends. - private var onDeviceDiscovered: (@Sendable (UUID, String?, Int) -> Void)? - - // MARK: - Callbacks - - /// Installed by `ConnectionManager.init` (via `iOSBLETransport.setDisconnectionHandler`). - var onDisconnection: (@Sendable (UUID, Error?) -> Void)? - /// Installed by `ConnectionManager.init` (via `iOSBLETransport.setReconnectionHandler`, - /// which wraps it to capture the data stream before the handler runs). - var onReconnection: (@Sendable (UUID, AsyncStream) -> Void)? - /// Installed by `ConnectionManager.init`. - var onBluetoothStateChange: (@Sendable (CBManagerState) -> Void)? - /// Installed by `ConnectionManager.init`. - var onBluetoothPoweredOn: (@Sendable () -> Void)? - /// Called when entering iOS auto-reconnecting phase. - /// The device has disconnected but iOS will attempt automatic reconnection. - /// Note: The MeshCore session is invalid at this point and will be rebuilt upon successful reconnection. - /// Installed by `ConnectionManager.init`. - var onAutoReconnecting: (@Sendable (UUID, String) -> Void)? - - /// Distinguishes the call site driving `handleRestoredPeripheral` so the function - /// fires `onAutoReconnecting` only when no upstream caller has already claimed the cycle. - enum RestoredPeripheralSource { - case stateRestoration - case adoption + } + + /// UUID of the currently connected device, or nil if not connected + var connectedDeviceID: UUID? { + phase.deviceID + } + + /// Current Bluetooth hardware state + nonisolated var bluetoothState: CBManagerState { + centralManager?.state ?? .unknown + } + + /// Current phase name for diagnostic logging + var currentPhaseName: String { + phase.name + } + + /// Current peripheral state for diagnostic logging (nil if no peripheral) + var currentPeripheralState: String? { + guard let peripheral = phase.peripheral else { return nil } + return peripheralStateString(peripheral.state) + } + + /// Whether the Bluetooth central manager is in the powered-off state. + var isBluetoothPoweredOff: Bool { + centralManager?.state == .poweredOff + } + + /// Current CBCentralManager state name for diagnostic logging + var centralManagerStateName: String { + guard let manager = centralManager else { return "notActivated" } + switch manager.state { + case .unknown: return "unknown" + case .resetting: return "resetting" + case .unsupported: return "unsupported" + case .unauthorized: return "unauthorized" + case .poweredOff: return "poweredOff" + case .poweredOn: return "poweredOn" + @unknown default: return "unknown(\(manager.state.rawValue))" } - - // MARK: - Initialization - - /// Creates a new BLE state machine. - /// - /// - Parameters: - /// - connectionTimeout: Timeout for initial connection (default 10s) - /// - serviceDiscoveryTimeout: Timeout for service/characteristic discovery (default 40s for pairing dialog) - /// - autoReconnectDiscoveryTimeout: Timeout for auto-reconnect discovery (default 15s, shorter since no pairing expected) - /// - writeTimeout: Timeout for write operations (default 5s) - /// - writePacingDelay: Delay between write operations for ESP32 compatibility (default 0 = no pacing) - init( - connectionTimeout: TimeInterval = 10.0, - serviceDiscoveryTimeout: TimeInterval = 40.0, - autoReconnectDiscoveryTimeout: TimeInterval = 15.0, - writeTimeout: TimeInterval = 5.0, - writePacingDelay: TimeInterval = 0 - ) { - self.connectionTimeout = connectionTimeout - self.serviceDiscoveryTimeout = serviceDiscoveryTimeout - self.autoReconnectDiscoveryTimeout = autoReconnectDiscoveryTimeout - self.writeTimeout = writeTimeout - self.writePacingDelay = writePacingDelay - self.delegateHandler = BLEDelegateHandler() + } + + /// Checks if a device is connected to the system (possibly by another app). + /// Call this before attempting connection when in `.idle` phase. + /// - Parameter deviceID: The UUID of the device to check + /// - Returns: `true` if the device is connected to the system + func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool { + activate() + let connectedPeripherals = centralManager.retrieveConnectedPeripherals( + withServices: [nordicUARTServiceUUID] + ) + return connectedPeripherals.contains { $0.identifier == deviceID } + } + + func systemConnectedPeripheralIDs() -> [UUID] { + activate() + return centralManager.retrieveConnectedPeripherals( + withServices: [nordicUARTServiceUUID] + ).map(\.identifier) + } + + /// Starts a best-effort adoption of an already system-connected peripheral. + /// + /// When iOS keeps the BLE link alive but state restoration does not fire (common across app updates), + /// `retrieveConnectedPeripherals` may report the radio as system-connected while our state machine + /// remains `.idle`. In that scenario, we can adopt the existing link by running the restoration + /// discovery chain against the connected peripheral. + /// + /// - Parameter deviceID: The UUID of the device to adopt. + /// - Returns: `true` if an adoption attempt was started. + func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool { + activate() + + guard case .idle = phase else { + logger.info("[BLE] adoptSystemConnectedPeripheral skipped - phase: \(phase.name)") + return false } - /// Sets the write pacing delay for ESP32 compatibility. - /// - Parameter delay: Delay in seconds between write operations (0 = no pacing) - func setWritePacingDelay(_ delay: TimeInterval) { - writePacingDelay = delay + let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: [nordicUARTServiceUUID]) + guard let peripheral = connectedPeripherals.first(where: { $0.identifier == deviceID }) else { + return false } - /// Activates the BLE state machine, creating the CBCentralManager. - /// Call once during app initialization. Safe to call multiple times. - func activate() { - guard !isActivated else { return } - isActivated = true - logger.info("[BLE] Activating state machine, instance: \(instanceID), \(processContext)") - initializeCentralManager() + let pState = peripheralStateString(peripheral.state) + logger.info("[BLE] Adopting system-connected peripheral: \(deviceID.uuidString.prefix(8)), state: \(pState)") + handleRestoredPeripheral(peripheral, source: .adoption) + return true + } + + // MARK: - Event Handler Registration + + /// Sets a handler for disconnection events + func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) { + onDisconnection = handler + } + + /// Sets a handler for reconnection events. + /// The handler receives the device ID and the data stream for receiving data. + func setReconnectionHandler(_ handler: @escaping @Sendable (UUID, AsyncStream) -> Void) { + onReconnection = handler + } + + /// Sets a handler for Bluetooth state changes + func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) { + onBluetoothStateChange = handler + } + + /// Sets a handler called when Bluetooth powers on + func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) { + onBluetoothPoweredOn = handler + } + + /// Sets a handler for auto-reconnecting events. + /// Called when device disconnects but iOS is attempting automatic reconnection. + func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) { + onAutoReconnecting = handler + } + + // MARK: - BLE Scanning + + /// Sets a handler called when a device is discovered during scanning. + /// - Parameter handler: Callback with (deviceID, advertised name, rssi) + func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) { + onDeviceDiscovered = handler + } + + /// Starts scanning for BLE peripherals advertising the Nordic UART service. + /// Scanning is orthogonal to the connection lifecycle — it works while connected. + /// Requires `activate()` to have been called and Bluetooth to be powered on. + func startScanning() { + activate() + guard centralManager.state == .poweredOn else { + logger.info("[BLE] Cannot start scanning: Bluetooth not powered on, will start when ready") + pendingScanRequest = true + return } - - private let centralQueue = DispatchQueue(label: "com.pocketmesh.ble.central") - - private func initializeCentralManager() { - // Set stateMachine reference before creating CBCentralManager. - // iOS calls willRestoreState during or immediately after CBCentralManager.init(), - // and the delegate handler needs the stateMachine reference to process it. - delegateHandler.stateMachine = self - - logger.info("[BLE] Initializing central manager, instance: \(instanceID), \(processContext)") - let options: [String: Any] = [ - CBCentralManagerOptionRestoreIdentifierKey: stateRestorationID, - CBCentralManagerOptionShowPowerAlertKey: true - ] - self.centralManager = CBCentralManager( - delegate: delegateHandler, - queue: centralQueue, - options: options - ) + pendingScanRequest = false + guard !isCurrentlyScanning else { return } + isCurrentlyScanning = true + logger.info("[BLE] Starting BLE scan for device discovery") + centralManager.scanForPeripherals( + withServices: [nordicUARTServiceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + } + + /// Stops an active BLE scan. + func stopScanning() { + pendingScanRequest = false + guard isCurrentlyScanning else { return } + isCurrentlyScanning = false + logger.info("[BLE] Stopping BLE scan") + centralManager.stopScan() + } + + /// Handles a discovered peripheral during scanning. + func handleDidDiscoverPeripheral(peripheralID: UUID, name: String?, rssi: Int) { + guard isCurrentlyScanning else { return } + onDeviceDiscovered?(peripheralID, name, rssi) + } + + /// Waits for Bluetooth to be powered on. + /// + /// - Throws: `BLEError.bluetoothUnavailable` if Bluetooth is not supported + /// `BLEError.bluetoothUnauthorized` if access is denied + /// `BLEError.bluetoothPoweredOff` if Bluetooth is off and doesn't turn on + func waitForPoweredOn() async throws { + activate() + + // Already powered on + if centralManager.state == .poweredOn { return } + + // Terminal states won't produce further callbacks - fail immediately + switch centralManager.state { + case .unsupported: + throw BLEError.bluetoothUnavailable + case .unauthorized: + throw BLEError.bluetoothUnauthorized + default: + break } - // MARK: - Connection Generation - - /// Advances the connection generation counter and records the boundary timestamp. - /// Called when starting a new connection, auto-reconnect cycle, or restoration reconnect - /// so that stale disconnect callbacks from previous generations can be identified and rejected. - func advanceConnectionGeneration() { - connectionGeneration &+= 1 - connectionGenerationStartTime = CFAbsoluteTimeGetCurrent() - discoveryTimeoutExtensions = 0 + // Wait for state change (.unknown, .resetting, and .poweredOff reach here). + // poweredOff is included because a freshly created CBCentralManager may + // briefly report poweredOff before settling on poweredOn. + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard case .idle = phase else { + continuation.resume(throwing: BLEError.connectionFailed("Already in operation")) + return + } + transition(to: .waitingForBluetooth(continuation: continuation)) } - - /// Returns true when a disconnect callback's timestamp predates the current generation boundary. - /// Uses CFAbsoluteTime from CoreBluetooth's didDisconnectPeripheral (reflects disconnect event - /// time per Apple's header: "now or a few seconds ago", not callback delivery time). - /// The tolerance accounts for non-monotonic clock adjustments (NTP sync, user clock changes). - static func isDisconnectCallbackFromPreviousGeneration( - timestamp: CFAbsoluteTime, - generationStart: CFAbsoluteTime, - tolerance: CFAbsoluteTime = 1.0 - ) -> Bool { - timestamp + tolerance < generationStart + } + + /// Connects to a BLE device and returns a data stream. + /// + /// - Parameter deviceID: UUID of the device to connect to + /// - Returns: AsyncStream of data received from the device + /// - Throws: BLEError if connection fails + func connect(to deviceID: UUID) async throws -> AsyncStream { + logger.info("Connect requested for device: \(deviceID)") + + // Ensure we're in idle state + guard case .idle = phase else { + // Diagnostic: Log detailed state when connection is rejected + let peripheralState = phase.peripheral.map { peripheralStateString($0.state) } ?? "none" + let phaseDeviceID = phase.deviceID?.uuidString ?? "none" + logger.warning( + "Connect rejected - phase: \(phase.name), peripheralState: \(peripheralState), phaseDeviceID: \(phaseDeviceID), requestedDeviceID: \(deviceID)" + ) + throw BLEError.connectionFailed("Already in operation: \(phase.name)") } - // MARK: - API + // Wait for Bluetooth + try await waitForPoweredOn() - /// Whether the state machine is currently connected to a device - var isConnected: Bool { - if case .connected = phase { return true } - return false + // Re-validate after the suspension: state restoration or a poweredOn + // callback can claim the machine (e.g. .autoReconnecting) while waiting, + // and proceeding would clobber that phase's armed timeout and CB connect. + guard case .idle = phase else { + logger.warning("Connect rejected after waitForPoweredOn - phase: \(phase.name), requestedDeviceID: \(deviceID)") + throw BLEError.connectionFailed("Already in operation: \(phase.name)") } - /// Whether the state machine is currently handling iOS auto-reconnect or state restoration - var isAutoReconnecting: Bool { - switch phase { - case .autoReconnecting, .restoringState: - return true - default: - return false - } + // Retrieve peripheral + let peripherals = centralManager.retrievePeripherals(withIdentifiers: [deviceID]) + guard let peripheral = peripherals.first else { + logger.warning("[BLE] Device not found in peripheral cache: \(deviceID.uuidString.prefix(8))") + throw BLEError.deviceNotFound } - /// UUID of the currently connected device, or nil if not connected - var connectedDeviceID: UUID? { - phase.deviceID - } + // Advance connection generation before starting connection + advanceConnectionGeneration() + logger.info("[BLE] Connection generation advanced to \(connectionGeneration) for device: \(deviceID.uuidString.prefix(8))") - /// Current Bluetooth hardware state - nonisolated var bluetoothState: CBManagerState { - centralManager?.state ?? .unknown - } + // Connect and discover services (continuation spans entire discovery chain) + try await connectToPeripheral(peripheral) - /// Current phase name for diagnostic logging - var currentPhaseName: String { - phase.name - } + // Create data stream and transition to connected + let (stream, continuation) = AsyncStream.makeStream( + of: Data.self, + bufferingPolicy: .bufferingOldest(512) + ) - /// Current peripheral state for diagnostic logging (nil if no peripheral) - var currentPeripheralState: String? { - guard let peripheral = phase.peripheral else { return nil } - return peripheralStateString(peripheral.state) + guard case let .discoveryComplete(_, tx, rx) = phase else { + let teardownError = discoveryCompleteTeardownError + discoveryCompleteTeardownError = nil + throw teardownError ?? BLEError.connectionFailed("Unexpected state after service discovery") } - /// Whether the Bluetooth central manager is in the powered-off state. - var isBluetoothPoweredOff: Bool { - centralManager?.state == .poweredOff + // Pass continuation to delegate handler for direct yielding (preserves ordering) + delegateHandler.setDataContinuation(continuation) + + transition(to: .connected( + peripheral: peripheral, + tx: tx, + rx: rx, + dataContinuation: continuation + )) + startRSSIKeepalive(for: peripheral) + + logger.info("Connection complete for device: \(deviceID)") + return stream + } + + /// Sends data to the connected device. + /// + /// This method serializes concurrent calls - if a write is already in progress, + /// subsequent calls will wait until the previous write completes. + /// + /// - Parameter data: Data to send + /// - Throws: BLEError if not connected or write fails + func send(_ data: Data) async throws { + logger.info("[BLE] send: \(data.count) bytes") + try await claimWriteSlot(data: data) + } + + /// Whether the connected peripheral's write characteristic supports ATT Write Commands. + /// Drives the transport's `supportsWriteWithoutResponse` capability gate. + var supportsWriteWithoutResponse: Bool { + txSupportsWriteWithoutResponse + } + + /// Test observability: whether a `sendWithoutResponse` caller is currently parked + /// awaiting peripheral readiness. Reflects existing state; does not alter behavior. + var isAwaitingWriteWithoutResponseReady: Bool { + writeWithoutResponseReadyContinuation != nil + } + + /// Test observability: whether the RSSI keepalive task is currently running. + /// Reflects existing state; does not alter behavior. + var isRSSIKeepaliveActive: Bool { + rssiKeepaliveTask != nil + } + + /// Sends data as an unacknowledged ATT Write Command (no `didWriteValue` ACK), which is + /// what lets a caller pipeline back-to-back requests. + /// + /// This path is independent of the `.withResponse` machinery (`pendingWriteContinuation`, + /// `writeTimeoutTask`, `writeWaiters`): flow control is CoreBluetooth's + /// `canSendWriteWithoutResponse` backpressure plus the caller's bounded send window. It + /// deliberately skips `writePacingDelay` — that delay protects the ESP32 RX queue during + /// `.withResponse` bursts, and Write Commands are an nRF52-only path. A radio whose write + /// characteristic does not advertise the capability degrades to the acknowledged path. + func sendWithoutResponse(_ data: Data) async throws { + let myGeneration = connectionGeneration + + guard case let .connected(peripheral, _, _, _) = phase, + peripheral.state == .connected else { + throw BLEError.notConnected } - /// Current CBCentralManager state name for diagnostic logging - var centralManagerStateName: String { - guard let manager = centralManager else { return "notActivated" } - switch manager.state { - case .unknown: return "unknown" - case .resetting: return "resetting" - case .unsupported: return "unsupported" - case .unauthorized: return "unauthorized" - case .poweredOff: return "poweredOff" - case .poweredOn: return "poweredOn" - @unknown default: return "unknown(\(manager.state.rawValue))" - } + guard txSupportsWriteWithoutResponse else { + try await claimWriteSlot(data: data) + return } - /// Checks if a device is connected to the system (possibly by another app). - /// Call this before attempting connection when in `.idle` phase. - /// - Parameter deviceID: The UUID of the device to check - /// - Returns: `true` if the device is connected to the system - func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool { - activate() - let connectedPeripherals = centralManager.retrieveConnectedPeripherals( - withServices: [nordicUARTServiceUUID] - ) - return connectedPeripherals.contains { $0.identifier == deviceID } - } + try await awaitWriteWithoutResponseReadiness(alreadyReady: peripheral.canSendWriteWithoutResponse) - func systemConnectedPeripheralIDs() -> [UUID] { - activate() - return centralManager.retrieveConnectedPeripherals( - withServices: [nordicUARTServiceUUID] - ).map(\.identifier) + // Re-validate after a possible suspension: the connection may have dropped while parked. + guard case let .connected(readyPeripheral, readyTx, _, _) = phase, + readyPeripheral.state == .connected, + connectionGeneration == myGeneration else { + throw BLEError.notConnected } - /// Starts a best-effort adoption of an already system-connected peripheral. - /// - /// When iOS keeps the BLE link alive but state restoration does not fire (common across app updates), - /// `retrieveConnectedPeripherals` may report the radio as system-connected while our state machine - /// remains `.idle`. In that scenario, we can adopt the existing link by running the restoration - /// discovery chain against the connected peripheral. - /// - /// - Parameter deviceID: The UUID of the device to adopt. - /// - Returns: `true` if an adoption attempt was started. - func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool { - activate() - - guard case .idle = phase else { - logger.info("[BLE] adoptSystemConnectedPeripheral skipped - phase: \(self.phase.name)") - return false - } - - let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: [nordicUARTServiceUUID]) - guard let peripheral = connectedPeripherals.first(where: { $0.identifier == deviceID }) else { - return false + readyPeripheral.writeValue(data, for: readyTx, type: .withoutResponse) + } + + /// Serializes concurrent writes by waiting for any pending write to complete, + /// then claims the write slot and issues the BLE write. + private func claimWriteSlot(data: Data) async throws { + let myGeneration = connectionGeneration + while true { + guard connectionGeneration == myGeneration else { + throw BLEError.notConnected + } + + // Pacing: wait until earliestNextWrite. + if writePacingDelay > 0, ContinuousClock.now < earliestNextWrite { + try await Task.sleep(until: earliestNextWrite, clock: .continuous) + try Task.checkCancellation() + guard connectionGeneration == myGeneration else { + throw BLEError.notConnected } - - let pState = peripheralStateString(peripheral.state) - logger.info("[BLE] Adopting system-connected peripheral: \(deviceID.uuidString.prefix(8)), state: \(pState)") - handleRestoredPeripheral(peripheral, source: .adoption) - return true - } - - // MARK: - Event Handler Registration - - /// Sets a handler for disconnection events - func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) { - onDisconnection = handler - } - - /// Sets a handler for reconnection events. - /// The handler receives the device ID and the data stream for receiving data. - func setReconnectionHandler(_ handler: @escaping @Sendable (UUID, AsyncStream) -> Void) { - onReconnection = handler - } - - /// Sets a handler for Bluetooth state changes - func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) { - onBluetoothStateChange = handler - } - - /// Sets a handler called when Bluetooth powers on - func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) { - onBluetoothPoweredOn = handler - } - - /// Sets a handler for auto-reconnecting events. - /// Called when device disconnects but iOS is attempting automatic reconnection. - func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) { - onAutoReconnecting = handler - } - - // MARK: - BLE Scanning - - /// Sets a handler called when a device is discovered during scanning. - /// - Parameter handler: Callback with (deviceID, advertised name, rssi) - func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) { - onDeviceDiscovered = handler - } - - /// Starts scanning for BLE peripherals advertising the Nordic UART service. - /// Scanning is orthogonal to the connection lifecycle — it works while connected. - /// Requires `activate()` to have been called and Bluetooth to be powered on. - func startScanning() { - activate() - guard centralManager.state == .poweredOn else { - logger.info("[BLE] Cannot start scanning: Bluetooth not powered on, will start when ready") - pendingScanRequest = true - return - } - pendingScanRequest = false - guard !isCurrentlyScanning else { return } - isCurrentlyScanning = true - logger.info("[BLE] Starting BLE scan for device discovery") - centralManager.scanForPeripherals( - withServices: [nordicUARTServiceUUID], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] - ) - } - - /// Stops an active BLE scan. - func stopScanning() { - pendingScanRequest = false - guard isCurrentlyScanning else { return } - isCurrentlyScanning = false - logger.info("[BLE] Stopping BLE scan") - centralManager.stopScan() - } - - /// Handles a discovered peripheral during scanning. - func handleDidDiscoverPeripheral(peripheralID: UUID, name: String?, rssi: Int) { - guard isCurrentlyScanning else { return } - onDeviceDiscovered?(peripheralID, name, rssi) - } - - /// Waits for Bluetooth to be powered on. - /// - /// - Throws: `BLEError.bluetoothUnavailable` if Bluetooth is not supported - /// `BLEError.bluetoothUnauthorized` if access is denied - /// `BLEError.bluetoothPoweredOff` if Bluetooth is off and doesn't turn on - func waitForPoweredOn() async throws { - activate() - - // Already powered on - if centralManager.state == .poweredOn { return } - - // Terminal states won't produce further callbacks - fail immediately - switch centralManager.state { - case .unsupported: - throw BLEError.bluetoothUnavailable - case .unauthorized: - throw BLEError.bluetoothUnauthorized - default: - break - } - - // Wait for state change (.unknown, .resetting, and .poweredOff reach here). - // poweredOff is included because a freshly created CBCentralManager may - // briefly report poweredOff before settling on poweredOn. - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - guard case .idle = phase else { - continuation.resume(throwing: BLEError.connectionFailed("Already in operation")) - return - } - transition(to: .waitingForBluetooth(continuation: continuation)) - } - } - - /// Connects to a BLE device and returns a data stream. - /// - /// - Parameter deviceID: UUID of the device to connect to - /// - Returns: AsyncStream of data received from the device - /// - Throws: BLEError if connection fails - func connect(to deviceID: UUID) async throws -> AsyncStream { - logger.info("Connect requested for device: \(deviceID)") - - // Ensure we're in idle state - guard case .idle = phase else { - // Diagnostic: Log detailed state when connection is rejected - let peripheralState = phase.peripheral.map { peripheralStateString($0.state) } ?? "none" - let phaseDeviceID = phase.deviceID?.uuidString ?? "none" - logger.warning( - "Connect rejected - phase: \(self.phase.name), peripheralState: \(peripheralState), phaseDeviceID: \(phaseDeviceID), requestedDeviceID: \(deviceID)" - ) - throw BLEError.connectionFailed("Already in operation: \(phase.name)") - } - - // Wait for Bluetooth - try await waitForPoweredOn() - - // Re-validate after the suspension: state restoration or a poweredOn - // callback can claim the machine (e.g. .autoReconnecting) while waiting, - // and proceeding would clobber that phase's armed timeout and CB connect. - guard case .idle = phase else { - logger.warning("Connect rejected after waitForPoweredOn - phase: \(self.phase.name), requestedDeviceID: \(deviceID)") - throw BLEError.connectionFailed("Already in operation: \(phase.name)") - } - - // Retrieve peripheral - let peripherals = centralManager.retrievePeripherals(withIdentifiers: [deviceID]) - guard let peripheral = peripherals.first else { - logger.warning("[BLE] Device not found in peripheral cache: \(deviceID.uuidString.prefix(8))") - throw BLEError.deviceNotFound - } - - // Advance connection generation before starting connection - advanceConnectionGeneration() - logger.info("[BLE] Connection generation advanced to \(self.connectionGeneration) for device: \(deviceID.uuidString.prefix(8))") - - // Connect and discover services (continuation spans entire discovery chain) - try await connectToPeripheral(peripheral) - - // Create data stream and transition to connected - let (stream, continuation) = AsyncStream.makeStream( - of: Data.self, - bufferingPolicy: .bufferingOldest(512) - ) - - guard case .discoveryComplete(_, let tx, let rx) = phase else { - throw BLEError.connectionFailed("Unexpected state after service discovery") - } - - // Pass continuation to delegate handler for direct yielding (preserves ordering) - delegateHandler.setDataContinuation(continuation) - - transition(to: .connected( - peripheral: peripheral, - tx: tx, - rx: rx, - dataContinuation: continuation - )) - startRSSIKeepalive(for: peripheral) - - logger.info("Connection complete for device: \(deviceID)") - return stream - } - - /// Sends data to the connected device. - /// - /// This method serializes concurrent calls - if a write is already in progress, - /// subsequent calls will wait until the previous write completes. - /// - /// - Parameter data: Data to send - /// - Throws: BLEError if not connected or write fails - func send(_ data: Data) async throws { - logger.info("[BLE] send: \(data.count) bytes") - try await claimWriteSlot(data: data) - } - - /// Whether the connected peripheral's write characteristic supports ATT Write Commands. - /// Drives the transport's `supportsWriteWithoutResponse` capability gate. - var supportsWriteWithoutResponse: Bool { txSupportsWriteWithoutResponse } - - /// Test observability: whether a `sendWithoutResponse` caller is currently parked - /// awaiting peripheral readiness. Reflects existing state; does not alter behavior. - var isAwaitingWriteWithoutResponseReady: Bool { writeWithoutResponseReadyContinuation != nil } - - /// Sends data as an unacknowledged ATT Write Command (no `didWriteValue` ACK), which is - /// what lets a caller pipeline back-to-back requests. - /// - /// This path is independent of the `.withResponse` machinery (`pendingWriteContinuation`, - /// `writeTimeoutTask`, `writeWaiters`): flow control is CoreBluetooth's - /// `canSendWriteWithoutResponse` backpressure plus the caller's bounded send window. It - /// deliberately skips `writePacingDelay` — that delay protects the ESP32 RX queue during - /// `.withResponse` bursts, and Write Commands are an nRF52-only path. A radio whose write - /// characteristic does not advertise the capability degrades to the acknowledged path. - func sendWithoutResponse(_ data: Data) async throws { - let myGeneration = connectionGeneration - - guard case .connected(let peripheral, _, _, _) = phase, - peripheral.state == .connected else { - throw BLEError.notConnected - } - - guard txSupportsWriteWithoutResponse else { - try await claimWriteSlot(data: data) - return + } + + try Task.checkCancellation() + + guard case let .connected(peripheral, _, _, _) = phase else { + throw BLEError.notConnected + } + + guard peripheral.state == .connected else { + throw BLEError.notConnected + } + + // Wait for any pending write to complete (serializes concurrent sends). + // After waking, loop and re-check slot ownership to avoid + // continuation overwrite if multiple waiters are resumed together. + if pendingWriteContinuation != nil { + consecutiveQueuedWrites += 1 + let queueDepth = writeWaiters.count + 1 + if consecutiveQueuedWrites >= queuePressureThreshold { + logger.warning("[BLE] Write queue pressure: depth=\(queueDepth), consecutive=\(consecutiveQueuedWrites)") + } else { + logger.debug("[BLE] Write queued, depth: \(queueDepth)") } - - try await awaitWriteWithoutResponseReadiness(alreadyReady: peripheral.canSendWriteWithoutResponse) - - // Re-validate after a possible suspension: the connection may have dropped while parked. - guard case .connected(let readyPeripheral, let readyTx, _, _) = phase, - readyPeripheral.state == .connected, - connectionGeneration == myGeneration else { - throw BLEError.notConnected + await withCheckedContinuation { (waiter: CheckedContinuation) in + writeWaiters.append(waiter) } - - readyPeripheral.writeValue(data, for: readyTx, type: .withoutResponse) - } - - /// Serializes concurrent writes by waiting for any pending write to complete, - /// then claims the write slot and issues the BLE write. - private func claimWriteSlot(data: Data) async throws { - let myGeneration = connectionGeneration - while true { - guard connectionGeneration == myGeneration else { - throw BLEError.notConnected - } - - // Pacing: wait until earliestNextWrite. - if writePacingDelay > 0, ContinuousClock.now < earliestNextWrite { - try await Task.sleep(until: earliestNextWrite, clock: .continuous) - try Task.checkCancellation() - guard connectionGeneration == myGeneration else { - throw BLEError.notConnected - } - } - - try Task.checkCancellation() - - guard case .connected(let peripheral, _, _, _) = phase else { - throw BLEError.notConnected - } - - guard peripheral.state == .connected else { - throw BLEError.notConnected - } - - // Wait for any pending write to complete (serializes concurrent sends). - // After waking, loop and re-check slot ownership to avoid - // continuation overwrite if multiple waiters are resumed together. - if pendingWriteContinuation != nil { - consecutiveQueuedWrites += 1 - let queueDepth = writeWaiters.count + 1 - if consecutiveQueuedWrites >= queuePressureThreshold { - logger.warning("[BLE] Write queue pressure: depth=\(queueDepth), consecutive=\(consecutiveQueuedWrites)") - } else { - logger.debug("[BLE] Write queued, depth: \(queueDepth)") - } - await withCheckedContinuation { (waiter: CheckedContinuation) in - writeWaiters.append(waiter) - } - continue - } - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // Revalidate at claim time in case phase changed between loop iterations. - guard case .connected(let currentPeripheral, let currentTx, _, _) = self.phase, - currentPeripheral.state == .connected, - self.pendingWriteContinuation == nil else { - continuation.resume(throwing: BLEError.notConnected) - return - } - - self.writeSequenceNumber += 1 - let currentSeq = self.writeSequenceNumber - self.pendingWriteSequence = currentSeq - self.pendingWriteContinuation = continuation - // Record the sequence before issuing the write so didWriteValue - // can tag its callback with the originating write - self.delegateHandler.recordIssuedWriteSequence(currentSeq) - currentPeripheral.writeValue(data, for: currentTx, type: .withResponse) - - // Cancel any previous timeout task and create a new one - let seq = self.pendingWriteSequence - self.writeTimeoutTask?.cancel() - self.writeTimeoutTask = Task { - try? await Task.sleep(for: .seconds(self.writeTimeout)) - guard !Task.isCancelled else { return } - guard self.pendingWriteSequence == seq else { return } - if let pending = self.pendingWriteContinuation { - self.logger.warning("[BLE] Write timeout: seq=\(seq), elapsed=\(self.writeTimeout)s") - self.pendingWriteContinuation = nil - self.consecutiveQueuedWrites = 0 - pending.resume(throwing: BLEError.operationTimeout) - self.writeTimeoutTask = nil - self.earliestNextWrite = ContinuousClock.now.advanced(by: .seconds(self.writePacingDelay)) - self.resumeNextWriteWaiter() - } - } - } - return + continue + } + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // Revalidate at claim time in case phase changed between loop iterations. + guard case let .connected(currentPeripheral, currentTx, _, _) = self.phase, + currentPeripheral.state == .connected, + self.pendingWriteContinuation == nil else { + continuation.resume(throwing: BLEError.notConnected) + return } - } - - /// Resumes the next task waiting to write. - /// Pacing is enforced in claimWriteSlot via earliestNextWrite. - func resumeNextWriteWaiter() { - guard !writeWaiters.isEmpty else { return } - let waiter = writeWaiters.removeFirst() - waiter.resume() - } - /// Parks the caller until the peripheral can accept another Write Command. Returns - /// immediately when `alreadyReady`. Otherwise suspends on a single continuation that is - /// resumed by `handlePeripheralReadyForWriteWithoutResponse()`, released on teardown by - /// `cancelPendingWriteOperations`, or failed by a timeout backstop — so a peripheral that - /// stops signalling readiness cannot hang the sender past the per-write timeout. - func awaitWriteWithoutResponseReadiness(alreadyReady: Bool) async throws { - guard !alreadyReady else { return } - let timeout = writeTimeout - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // The pipeline issues one Write Command at a time, so the slot is normally free. - // Release any stale waiter rather than leak it if that invariant is ever broken. - if let stale = writeWithoutResponseReadyContinuation { - writeWithoutResponseReadyContinuation = nil - writeWithoutResponseReadyTimeoutTask?.cancel() - writeWithoutResponseReadyTimeoutTask = nil - stale.resume(throwing: BLEError.notConnected) - } - writeWithoutResponseReadyContinuation = continuation - writeWithoutResponseReadyTimeoutTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(timeout)) - guard !Task.isCancelled else { return } - await self?.timeoutWriteWithoutResponseReadiness() - } + self.writeSequenceNumber += 1 + let currentSeq = self.writeSequenceNumber + self.pendingWriteSequence = currentSeq + self.pendingWriteContinuation = continuation + // Record the sequence before issuing the write so didWriteValue + // can tag its callback with the originating write + self.delegateHandler.recordIssuedWriteSequence(currentSeq) + currentPeripheral.writeValue(data, for: currentTx, type: .withResponse) + + // Cancel any previous timeout task and create a new one + let seq = self.pendingWriteSequence + self.writeTimeoutTask?.cancel() + self.writeTimeoutTask = Task { + try? await Task.sleep(for: .seconds(self.writeTimeout)) + guard !Task.isCancelled else { return } + guard self.pendingWriteSequence == seq else { return } + if let pending = self.pendingWriteContinuation { + self.logger.warning("[BLE] Write timeout: seq=\(seq), elapsed=\(self.writeTimeout)s") + self.pendingWriteContinuation = nil + self.consecutiveQueuedWrites = 0 + pending.resume(throwing: BLEError.operationTimeout) + self.writeTimeoutTask = nil + self.earliestNextWrite = ContinuousClock.now.advanced(by: .seconds(self.writePacingDelay)) + self.resumeNextWriteWaiter() + } } + } + return } - - /// Fails a parked `sendWithoutResponse` caller when the peripheral has not re-signalled - /// readiness within the backstop interval. A no-op when no caller is parked. - private func timeoutWriteWithoutResponseReadiness() { - guard let continuation = writeWithoutResponseReadyContinuation else { return } - writeWithoutResponseReadyContinuation = nil - writeWithoutResponseReadyTimeoutTask = nil - logger.warning("[BLE] Write-without-response readiness timeout after \(self.writeTimeout)s") - continuation.resume(throwing: BLEError.operationTimeout) - } - - /// Resumes a parked `sendWithoutResponse` caller when CoreBluetooth reports the peripheral - /// can accept another Write Command. A no-op when no caller is parked. - func handlePeripheralReadyForWriteWithoutResponse() { - guard let continuation = writeWithoutResponseReadyContinuation else { return } + } + + /// Resumes the next task waiting to write. + /// Pacing is enforced in claimWriteSlot via earliestNextWrite. + func resumeNextWriteWaiter() { + guard !writeWaiters.isEmpty else { return } + let waiter = writeWaiters.removeFirst() + waiter.resume() + } + + /// Parks the caller until the peripheral can accept another Write Command. Returns + /// immediately when `alreadyReady`. Otherwise suspends on a single continuation that is + /// resumed by `handlePeripheralReadyForWriteWithoutResponse()`, released on teardown by + /// `cancelPendingWriteOperations`, or failed by a timeout backstop — so a peripheral that + /// stops signalling readiness cannot hang the sender past the per-write timeout. + func awaitWriteWithoutResponseReadiness(alreadyReady: Bool) async throws { + guard !alreadyReady else { return } + let timeout = writeTimeout + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // The pipeline issues one Write Command at a time, so the slot is normally free. + // Release any stale waiter rather than leak it if that invariant is ever broken. + if let stale = writeWithoutResponseReadyContinuation { writeWithoutResponseReadyContinuation = nil writeWithoutResponseReadyTimeoutTask?.cancel() writeWithoutResponseReadyTimeoutTask = nil - continuation.resume() + stale.resume(throwing: BLEError.notConnected) + } + writeWithoutResponseReadyContinuation = continuation + writeWithoutResponseReadyTimeoutTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(timeout)) + guard !Task.isCancelled else { return } + await self?.timeoutWriteWithoutResponseReadiness() + } } - - /// Records whether the resolved write characteristic advertises Write-Without-Response. - /// Called from characteristic discovery; the log doubles as the on-device capability check. - func captureWriteWithoutResponseCapability(from tx: CBCharacteristic) { - txSupportsWriteWithoutResponse = tx.properties.contains(.writeWithoutResponse) - logger.info("[BLE] tx writeWithoutResponse capability: \(self.txSupportsWriteWithoutResponse)") + } + + /// Fails a parked `sendWithoutResponse` caller when the peripheral has not re-signalled + /// readiness within the backstop interval. A no-op when no caller is parked. + private func timeoutWriteWithoutResponseReadiness() { + guard let continuation = writeWithoutResponseReadyContinuation else { return } + writeWithoutResponseReadyContinuation = nil + writeWithoutResponseReadyTimeoutTask = nil + logger.warning("[BLE] Write-without-response readiness timeout after \(writeTimeout)s") + continuation.resume(throwing: BLEError.operationTimeout) + } + + /// Resumes a parked `sendWithoutResponse` caller when CoreBluetooth reports the peripheral + /// can accept another Write Command. A no-op when no caller is parked. + func handlePeripheralReadyForWriteWithoutResponse() { + guard let continuation = writeWithoutResponseReadyContinuation else { return } + writeWithoutResponseReadyContinuation = nil + writeWithoutResponseReadyTimeoutTask?.cancel() + writeWithoutResponseReadyTimeoutTask = nil + continuation.resume() + } + + /// Records whether the resolved write characteristic advertises Write-Without-Response. + /// Called from characteristic discovery; the log doubles as the on-device capability check. + func captureWriteWithoutResponseCapability(from tx: CBCharacteristic) { + txSupportsWriteWithoutResponse = tx.properties.contains(.writeWithoutResponse) + logger.info("[BLE] tx writeWithoutResponse capability: \(txSupportsWriteWithoutResponse)") + } + + /// Gracefully shuts down the state machine, resuming all pending operations with cancellation. + /// Call this before dropping the last reference to the actor. + func shutdown() { + logger.info("[BLE] Shutting down state machine, instance: \(instanceID)") + + stopScanning() + + // Cancel all timeout tasks + bluetoothPowerOffGraceTask?.cancel() + bluetoothPowerOffGraceTask = nil + autoReconnectDiscoveryTimeoutTask?.cancel() + autoReconnectDiscoveryTimeoutTask = nil + serviceDiscoveryTimeoutTask?.cancel() + serviceDiscoveryTimeoutTask = nil + // The direct phase write below bypasses cleanupPhaseResources, so the + // RSSI keepalive must be cancelled here or it outlives the shutdown. + rssiKeepaliveTask?.cancel() + rssiKeepaliveTask = nil + + cancelPendingWriteOperations(error: CancellationError()) + + // Resume any phase continuation with cancellation + switch phase { + case let .waitingForBluetooth(continuation): + continuation.resume(throwing: CancellationError()) + case let .connecting(_, continuation, timeoutTask): + timeoutTask.cancel() + continuation.resume(throwing: CancellationError()) + case let .discoveringServices(_, continuation): + continuation.resume(throwing: CancellationError()) + case let .discoveringCharacteristics(_, _, continuation): + continuation.resume(throwing: CancellationError()) + case let .subscribingToNotifications(_, _, _, continuation): + continuation.resume(throwing: CancellationError()) + case let .connected(_, _, _, dataContinuation): + delegateHandler.setDataContinuation(nil) + dataContinuation.finish() + default: + break } - /// Gracefully shuts down the state machine, resuming all pending operations with cancellation. - /// Call this before dropping the last reference to the actor. - func shutdown() { - logger.info("[BLE] Shutting down state machine, instance: \(instanceID)") - - stopScanning() - - // Cancel all timeout tasks - bluetoothPowerOffGraceTask?.cancel() - bluetoothPowerOffGraceTask = nil - autoReconnectDiscoveryTimeoutTask?.cancel() - autoReconnectDiscoveryTimeoutTask = nil - serviceDiscoveryTimeoutTask?.cancel() - serviceDiscoveryTimeoutTask = nil - - cancelPendingWriteOperations(error: CancellationError()) - - // Resume any phase continuation with cancellation - switch phase { - case .waitingForBluetooth(let continuation): - continuation.resume(throwing: CancellationError()) - case .connecting(_, let continuation, let timeoutTask): - timeoutTask.cancel() - continuation.resume(throwing: CancellationError()) - case .discoveringServices(_, let continuation): - continuation.resume(throwing: CancellationError()) - case .discoveringCharacteristics(_, _, let continuation): - continuation.resume(throwing: CancellationError()) - case .subscribingToNotifications(_, _, _, let continuation): - continuation.resume(throwing: CancellationError()) - case .connected(_, _, _, let dataContinuation): - delegateHandler.setDataContinuation(nil) - dataContinuation.finish() - default: - break - } + let deviceID = phase.deviceID + phase = .idle + phaseStartTime = Date() - let deviceID = phase.deviceID - phase = .idle - phaseStartTime = Date() - - if let deviceID { - onDisconnection?(deviceID, nil) - } + if let deviceID { + onDisconnection?(deviceID, nil) } - - func appDidEnterBackground() { - isAppActive = false - autoReconnectDiscoveryTimeoutTask?.cancel() - autoReconnectDiscoveryTimeoutTask = nil - logger.info("[BLE] App entered background: cancelled auto-reconnect timeout (keepalive persists)") + } + + func appDidEnterBackground() { + isAppActive = false + autoReconnectDiscoveryTimeoutTask?.cancel() + autoReconnectDiscoveryTimeoutTask = nil + logger.info("[BLE] App entered background: cancelled auto-reconnect timeout (keepalive persists)") + } + + func appDidBecomeActive() { + isAppActive = true + logger.info("[BLE] App became active, phase: \(phase.name)") + + // Defensive restart: only if connected but keepalive task died unexpectedly + if case let .connected(peripheral, _, _, _) = phase, rssiKeepaliveTask == nil { + logger.warning("[BLE] Keepalive task died while connected - restarting defensively") + startRSSIKeepalive(for: peripheral) } - func appDidBecomeActive() { - isAppActive = true - logger.info("[BLE] App became active, phase: \(phase.name)") - - // Defensive restart: only if connected but keepalive task died unexpectedly - if case .connected(let peripheral, _, _, _) = phase, rssiKeepaliveTask == nil { - logger.warning("[BLE] Keepalive task died while connected - restarting defensively") - startRSSIKeepalive(for: peripheral) - } - - // Re-arm auto-reconnect timeout if in auto-reconnecting phase - if case .autoReconnecting(let peripheral, _, _) = phase { - phaseStartTime = Date() - armAutoReconnectDiscoveryTimeout( - for: peripheral, - generation: connectionGeneration - ) - logger.info("[BLE] Re-armed auto-reconnect timeout after foreground return") - } + // Re-arm auto-reconnect timeout if in auto-reconnecting phase + if case let .autoReconnecting(peripheral, _, _) = phase { + phaseStartTime = Date() + armAutoReconnectDiscoveryTimeout( + for: peripheral, + generation: connectionGeneration + ) + logger.info("[BLE] Re-armed auto-reconnect timeout after foreground return") } - - func armAutoReconnectDiscoveryTimeout( - for peripheral: CBPeripheral, - generation: UInt64 - ) { - autoReconnectDiscoveryTimeoutTask?.cancel() - logger.info("[BLE] Arming auto-reconnect discovery timeout: \(self.autoReconnectDiscoveryTimeout)s, generation: \(generation), device: \(peripheral.identifier.uuidString.prefix(8))") - autoReconnectDiscoveryTimeoutTask = Task { - try? await Task.sleep(for: .seconds(autoReconnectDiscoveryTimeout)) - guard !Task.isCancelled else { return } - handleAutoReconnectDiscoveryTimeout( - for: peripheral, - generation: generation - ) - } + } + + func armAutoReconnectDiscoveryTimeout( + for peripheral: CBPeripheral, + generation: UInt64 + ) { + autoReconnectDiscoveryTimeoutTask?.cancel() + logger.info("[BLE] Arming auto-reconnect discovery timeout: \(autoReconnectDiscoveryTimeout)s, generation: \(generation), device: \(peripheral.identifier.uuidString.prefix(8))") + autoReconnectDiscoveryTimeoutTask = Task { + try? await Task.sleep(for: .seconds(autoReconnectDiscoveryTimeout)) + guard !Task.isCancelled else { return } + handleAutoReconnectDiscoveryTimeout( + for: peripheral, + generation: generation + ) } + } - // Note: `isolated deinit` would be the ideal safety net here, but it requires - // a deployment target of macOS 15.4 / iOS 18.4+. Since we target iOS 18.0, - // callers must call shutdown() explicitly before dropping the actor reference. + // Note: `isolated deinit` would be the ideal safety net here, but it requires + // a deployment target of macOS 15.4 / iOS 18.4+. Since we target iOS 18.0, + // callers must call shutdown() explicitly before dropping the actor reference. - /// Disconnects from the current device. - func disconnect() async { - logger.info("Disconnect requested") + /// Disconnects from the current device. + func disconnect() async { + logger.info("Disconnect requested") - // Cancel Bluetooth power-off grace period - bluetoothPowerOffGraceTask?.cancel() - bluetoothPowerOffGraceTask = nil + // Cancel Bluetooth power-off grace period + bluetoothPowerOffGraceTask?.cancel() + bluetoothPowerOffGraceTask = nil - // Cancel write timeout task - writeTimeoutTask?.cancel() - writeTimeoutTask = nil + // Cancel write timeout task + writeTimeoutTask?.cancel() + writeTimeoutTask = nil - // Reset queue tracking - consecutiveQueuedWrites = 0 - earliestNextWrite = .now + // Reset queue tracking + consecutiveQueuedWrites = 0 + earliestNextWrite = .now - // Cancel pending write - if let pending = pendingWriteContinuation { - pendingWriteContinuation = nil - pending.resume(throwing: BLEError.notConnected) - } - - // Resume all write waiters (they'll fail on the .connected check) - while !writeWaiters.isEmpty { - writeWaiters.removeFirst().resume() - } + // Cancel pending write + if let pending = pendingWriteContinuation { + pendingWriteContinuation = nil + pending.resume(throwing: BLEError.notConnected) + } - // Get peripheral before cancelling - let peripheral = phase.peripheral + // Resume all write waiters (they'll fail on the .connected check) + while !writeWaiters.isEmpty { + writeWaiters.removeFirst().resume() + } - // Cancel current operation - cancelCurrentOperation(with: BLEError.notConnected) + // Get peripheral before cancelling + let peripheral = phase.peripheral + // During auto-reconnect iOS holds a pending connect that stays armed even + // while the peripheral reports .disconnected; an explicit disconnect must + // cancel it or iOS later re-establishes a link the user severed. + let hadPendingAutoReconnect = if case .autoReconnecting = phase { true } else { false } - // Disconnect peripheral if connected - if let peripheral, peripheral.state == .connected || peripheral.state == .connecting { - transition(to: .disconnecting(peripheral: peripheral)) - centralManager.cancelPeripheralConnection(peripheral) + // Cancel current operation + cancelCurrentOperation(with: BLEError.notConnected) - // Wait briefly for disconnection to complete - try? await Task.sleep(for: .milliseconds(100)) - } + // Disconnect peripheral if connected or holding a pending auto-reconnect + if let peripheral, hadPendingAutoReconnect || peripheral.state == .connected || peripheral.state == .connecting { + transition(to: .disconnecting(peripheral: peripheral)) + centralManager.cancelPeripheralConnection(peripheral) - transition(to: .idle) - logger.info("Disconnect complete") + // Wait briefly for disconnection to complete + try? await Task.sleep(for: .milliseconds(100)) } - /// Switches to a different device. - /// - /// Disconnects from current device (if any) and connects to the new one. - /// - /// - Parameter deviceID: UUID of the new device to connect to - /// - Returns: AsyncStream of data from the new device - /// - Throws: BLEError if connection fails - func switchDevice(to deviceID: UUID) async throws -> AsyncStream { - logger.info("Switch device requested: \(deviceID)") - - // Disconnect current device - await disconnect() - - // Connect to new device - return try await connect(to: deviceID) + transition(to: .idle) + logger.info("Disconnect complete") + } + + /// Switches to a different device. + /// + /// Disconnects from current device (if any) and connects to the new one. + /// + /// - Parameter deviceID: UUID of the new device to connect to + /// - Returns: AsyncStream of data from the new device + /// - Throws: BLEError if connection fails + func switchDevice(to deviceID: UUID) async throws -> AsyncStream { + logger.info("Switch device requested: \(deviceID)") + + // Disconnect current device + await disconnect() + + // Connect to new device + return try await connect(to: deviceID) + } + + private func connectToPeripheral(_ peripheral: CBPeripheral) async throws { + let pState = peripheralStateString(peripheral.state) + logger.info("[BLE] Connecting to peripheral: \(peripheral.identifier.uuidString.prefix(8)), currentState: \(pState), timeout: \(connectionTimeout)s, autoReconnect: enabled") + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // Create timeout task + let timeoutTask = Task { + try? await Task.sleep(for: .seconds(connectionTimeout)) + guard !Task.isCancelled else { return } + self.handleConnectionTimeout(for: peripheral) + } + + transition(to: .connecting( + peripheral: peripheral, + continuation: continuation, + timeoutTask: timeoutTask + )) + + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true, + CBConnectPeripheralOptionEnableAutoReconnect: true + ] + centralManager.connect(peripheral, options: options) } + } - private func connectToPeripheral(_ peripheral: CBPeripheral) async throws { - let pState = peripheralStateString(peripheral.state) - logger.info("[BLE] Connecting to peripheral: \(peripheral.identifier.uuidString.prefix(8)), currentState: \(pState), timeout: \(self.connectionTimeout)s, autoReconnect: enabled") - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // Create timeout task - let timeoutTask = Task { - try? await Task.sleep(for: .seconds(connectionTimeout)) - self.handleConnectionTimeout(for: peripheral) - } - - transition(to: .connecting( - peripheral: peripheral, - continuation: continuation, - timeoutTask: timeoutTask - )) - - let options: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true, - CBConnectPeripheralOptionEnableAutoReconnect: true - ] - centralManager.connect(peripheral, options: options) - } + private func handleConnectionTimeout(for peripheral: CBPeripheral) { + guard case let .connecting(expected, continuation, _) = phase, + expected.identifier == peripheral.identifier else { + return // No longer connecting to this peripheral } - private func handleConnectionTimeout(for peripheral: CBPeripheral) { - guard case .connecting(let expected, let continuation, _) = phase, - expected.identifier == peripheral.identifier else { - return // No longer connecting to this peripheral - } - - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.warning("[BLE] Connection timeout: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") - centralManager.cancelPeripheralConnection(peripheral) - transition(to: .idle) - continuation.resume(throwing: BLEError.connectionTimeout) - } + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + logger.warning("[BLE] Connection timeout: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + centralManager.cancelPeripheralConnection(peripheral) + transition(to: .idle) + continuation.resume(throwing: BLEError.connectionTimeout) + } } // MARK: - State Transitions extension BLEStateMachine { - - /// Transitions to a new phase, cleaning up the old phase's resources. - /// - /// - Parameter newPhase: The phase to transition to - /// - Returns: The previous phase (for logging/debugging) - @discardableResult - func transition(to newPhase: BLEPhase) -> BLEPhase { - let oldPhase = phase - let elapsed = Date().timeIntervalSince(phaseStartTime) - let deviceID = oldPhase.deviceID?.uuidString.prefix(8) ?? "none" - logger.info("[BLE] Transition: \(oldPhase.name) → \(newPhase.name), device: \(deviceID), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") - - // Clean up old phase resources (except continuations - caller handles those) - cleanupPhaseResources(oldPhase, newPhase: newPhase) - - phase = newPhase - phaseStartTime = Date() - return oldPhase + /// Transitions to a new phase, cleaning up the old phase's resources. + /// + /// - Parameter newPhase: The phase to transition to + /// - Returns: The previous phase (for logging/debugging) + @discardableResult + func transition(to newPhase: BLEPhase) -> BLEPhase { + let oldPhase = phase + let elapsed = Date().timeIntervalSince(phaseStartTime) + let deviceID = oldPhase.deviceID?.uuidString.prefix(8) ?? "none" + logger.info("[BLE] Transition: \(oldPhase.name) → \(newPhase.name), device: \(deviceID), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + + // Clean up old phase resources (except continuations - caller handles those) + cleanupPhaseResources(oldPhase, newPhase: newPhase) + + phase = newPhase + phaseStartTime = Date() + return oldPhase + } + + /// Starts a periodic RSSI read to keep the BLE connection alive. + /// In foreground, fires every 15s. In background, the task freezes during + /// iOS suspension; when a BLE event wakes the app, the expired sleep resumes + /// and fires an opportunistic RSSI read within the ~10s wake window. + func startRSSIKeepalive(for peripheral: CBPeripheral) { + rssiKeepaliveTask?.cancel() + consecutiveRSSIFailures = 0 + rssiKeepaliveTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(15)) + guard !Task.isCancelled else { break } + peripheral.readRSSI() + } } - - /// Starts a periodic RSSI read to keep the BLE connection alive. - /// In foreground, fires every 15s. In background, the task freezes during - /// iOS suspension; when a BLE event wakes the app, the expired sleep resumes - /// and fires an opportunistic RSSI read within the ~10s wake window. - func startRSSIKeepalive(for peripheral: CBPeripheral) { - rssiKeepaliveTask?.cancel() - consecutiveRSSIFailures = 0 - rssiKeepaliveTask = Task { - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(15)) - guard !Task.isCancelled else { break } - peripheral.readRSSI() - } - } + } + + /// Cleans up non-continuation resources owned by a phase. + /// + /// Timeout cancellation is phase-aware: + /// - Discovery timeout is preserved when transitioning within the discovery chain + /// - Auto-reconnect timeout is preserved when staying in auto-reconnect + private func cleanupPhaseResources(_ oldPhase: BLEPhase, newPhase: BLEPhase) { + // Only cancel discovery timeout when leaving the discovery chain + if !newPhase.isDiscoveryChain { + serviceDiscoveryTimeoutTask?.cancel() + serviceDiscoveryTimeoutTask = nil } - /// Cleans up non-continuation resources owned by a phase. - /// - /// Timeout cancellation is phase-aware: - /// - Discovery timeout is preserved when transitioning within the discovery chain - /// - Auto-reconnect timeout is preserved when staying in auto-reconnect - private func cleanupPhaseResources(_ oldPhase: BLEPhase, newPhase: BLEPhase) { - // Only cancel discovery timeout when leaving the discovery chain - if !newPhase.isDiscoveryChain { - serviceDiscoveryTimeoutTask?.cancel() - serviceDiscoveryTimeoutTask = nil - } - - // Only cancel auto-reconnect timeout when leaving auto-reconnect - if case .autoReconnecting = newPhase { - // preserve - } else { - autoReconnectDiscoveryTimeoutTask?.cancel() - autoReconnectDiscoveryTimeoutTask = nil - } + // Only cancel auto-reconnect timeout when leaving auto-reconnect + if case .autoReconnecting = newPhase { + // preserve + } else { + autoReconnectDiscoveryTimeoutTask?.cancel() + autoReconnectDiscoveryTimeoutTask = nil + } - switch oldPhase { - case .connecting(_, _, let timeoutTask): - timeoutTask.cancel() + switch oldPhase { + case let .connecting(_, _, timeoutTask): + timeoutTask.cancel() - case .connected(_, _, _, let dataContinuation): - rssiKeepaliveTask?.cancel() - rssiKeepaliveTask = nil - consecutiveRSSIFailures = 0 - // Clear delegate handler's continuation first to stop data flow - delegateHandler.setDataContinuation(nil) - dataContinuation.finish() + case let .connected(_, _, _, dataContinuation): + rssiKeepaliveTask?.cancel() + rssiKeepaliveTask = nil + consecutiveRSSIFailures = 0 + // Clear delegate handler's continuation first to stop data flow + delegateHandler.setDataContinuation(nil) + dataContinuation.finish() - default: - break - } + default: + break + } + } + + /// Cancels pending write operations and write waiters without touching phase state. + /// Used when transitioning to auto-reconnect where we need to clean up writes + /// but handle the phase continuation separately. + func cancelPendingWriteOperations(error: Error = BLEError.notConnected) { + writeTimeoutTask?.cancel() + writeTimeoutTask = nil + consecutiveQueuedWrites = 0 + earliestNextWrite = .now + // Outstanding write callbacks either never arrive (link dropped) or + // find no continuation; their recorded sequences must not survive to + // mis-tag the next connection's first write callback. + delegateHandler.clearIssuedWriteSequences() + + if let pending = pendingWriteContinuation { + pendingWriteContinuation = nil + pending.resume(throwing: error) } - /// Cancels pending write operations and write waiters without touching phase state. - /// Used when transitioning to auto-reconnect where we need to clean up writes - /// but handle the phase continuation separately. - func cancelPendingWriteOperations(error: Error = BLEError.notConnected) { - writeTimeoutTask?.cancel() - writeTimeoutTask = nil - consecutiveQueuedWrites = 0 - earliestNextWrite = .now - // Outstanding write callbacks either never arrive (link dropped) or - // find no continuation; their recorded sequences must not survive to - // mis-tag the next connection's first write callback. - delegateHandler.clearIssuedWriteSequences() - - if let pending = pendingWriteContinuation { - pendingWriteContinuation = nil - pending.resume(throwing: error) - } - - while !writeWaiters.isEmpty { - writeWaiters.removeFirst().resume() - } - - // Release a Write-Command sender parked on backpressure so it doesn't hang past the drop. - handlePeripheralReadyForWriteWithoutResponse() + while !writeWaiters.isEmpty { + writeWaiters.removeFirst().resume() } - /// Cancels the current operation, resuming any pending continuation with an error. - /// - /// - Parameter error: The error to resume continuations with - func cancelCurrentOperation(with error: Error) { - logger.warning("[BLE] cancelCurrentOperation: phase=\(self.phase.name), error=\(error.localizedDescription)") - cancelPendingWriteOperations(error: error) + // Release a Write-Command sender parked on backpressure so it doesn't hang past the drop. + handlePeripheralReadyForWriteWithoutResponse() + } - switch phase { - case .waitingForBluetooth(let continuation): - continuation.resume(throwing: error) + /// Cancels the current operation, resuming any pending continuation with an error. + /// + /// - Parameter error: The error to resume continuations with + func cancelCurrentOperation(with error: Error) { + logger.warning("[BLE] cancelCurrentOperation: phase=\(phase.name), error=\(error.localizedDescription)") + cancelPendingWriteOperations(error: error) - case .connecting(_, let continuation, let timeoutTask): - timeoutTask.cancel() - continuation.resume(throwing: error) + switch phase { + case let .waitingForBluetooth(continuation): + continuation.resume(throwing: error) - case .discoveringServices(_, let continuation): - continuation.resume(throwing: error) + case let .connecting(_, continuation, timeoutTask): + timeoutTask.cancel() + continuation.resume(throwing: error) - case .discoveringCharacteristics(_, _, let continuation): - continuation.resume(throwing: error) + case let .discoveringServices(_, continuation): + continuation.resume(throwing: error) - case .subscribingToNotifications(_, _, _, let continuation): - continuation.resume(throwing: error) + case let .discoveringCharacteristics(_, _, continuation): + continuation.resume(throwing: error) - case .connected(_, _, _, let dataContinuation): - dataContinuation.finish() + case let .subscribingToNotifications(_, _, _, continuation): + continuation.resume(throwing: error) - case .discoveryComplete: - // Continuation already consumed — nothing to resume - break + case let .connected(_, _, _, dataContinuation): + dataContinuation.finish() - case .idle, .autoReconnecting, .restoringState, .disconnecting: - break - } + case .discoveryComplete: + // Continuation already consumed; connect() is between discovery and + // adopting .connected. Record why the phase was torn down so connect() + // surfaces the real classification instead of a generic failure. + discoveryCompleteTeardownError = error as? BLEError ?? Self.makeConnectionError(error) - transition(to: .idle) + case .idle, .autoReconnecting, .restoringState, .disconnecting: + break } - /// Cancels connection to a peripheral if we're not expecting it. - func cancelUnexpectedPeripheral(_ peripheral: CBPeripheral) { - logger.warning("Cancelling unexpected peripheral: \(peripheral.identifier)") - centralManager.cancelPeripheralConnection(peripheral) + transition(to: .idle) + } + + /// Cancels connection to a peripheral if we're not expecting it. + func cancelUnexpectedPeripheral(_ peripheral: CBPeripheral) { + logger.warning("Cancelling unexpected peripheral: \(peripheral.identifier)") + centralManager.cancelPeripheralConnection(peripheral) + } + + /// Decides whether a discovery-phase watchdog should extend its window + /// rather than tear the link down. When the peripheral is already + /// `.connected` the BLE link is up and a `didConnect`/discovery callback is + /// in flight or merely slow; tearing it down kills a working reconnection. + /// Extend a bounded number of times so a genuinely wedged link still + /// recovers via reconnect. + static func shouldExtendDiscoveryTimeout( + peripheralState: CBPeripheralState, + extensions: Int, + maxExtensions: Int + ) -> Bool { + peripheralState == .connected && extensions < maxExtensions + } + + /// Classifies how a discovery/subscribe watchdog teardown surfaces. A + /// peripheral that reached link-layer `.connected` yet never completed + /// discovery across the full extension budget is the strongest in-app signal + /// of a silently invalidated bond: CoreBluetooth delivers no error, so + /// nothing else distinguishes it from a healthy-but-slow link. Surfacing it + /// as `.authenticationFailed` routes it into the same guided re-pair recovery + /// as a delivered bond-invalidation error, instead of the generic timeout + /// retry loop that would keep re-trying the dead bond. A link that never + /// reached `.connected` is a plain connection timeout. + static func discoveryTimeoutError( + peripheralState: CBPeripheralState, + extensions: Int, + maxExtensions: Int + ) -> BLEError { + if peripheralState == .connected, extensions >= maxExtensions { + return .authenticationFailed } - - /// Decides whether a discovery-phase watchdog should extend its window - /// rather than tear the link down. When the peripheral is already - /// `.connected` the BLE link is up and a `didConnect`/discovery callback is - /// in flight or merely slow; tearing it down kills a working reconnection. - /// Extend a bounded number of times so a genuinely wedged link still - /// recovers via reconnect. - static func shouldExtendDiscoveryTimeout( - peripheralState: CBPeripheralState, - extensions: Int, - maxExtensions: Int - ) -> Bool { - peripheralState == .connected && extensions < maxExtensions + return .connectionTimeout + } + + /// What the auto-reconnect discovery watchdog does when its window elapses. + enum AutoReconnectTimeoutAction { + case waitForPendingConnect + case extendWindow + case tearDown + } + + /// Unlike service discovery (an established link, where a stall means tear + /// down), a peripheral that is not `.connected` here is backed by an OS + /// pending connect that never expires; cancelling it abandons a reconnection + /// iOS would complete once the radio is back in range, so the watchdog + /// waits. Waiting consumes no extension budget — the bounded extensions + /// exist only for a connected-but-wedged discovery. + static func autoReconnectTimeoutAction( + peripheralState: CBPeripheralState, + extensions: Int, + maxExtensions: Int + ) -> AutoReconnectTimeoutAction { + guard peripheralState == .connected else { return .waitForPendingConnect } + return extensions < maxExtensions ? .extendWindow : .tearDown + } + + /// Counts one watchdog deferral against the current generation's budget. + func recordDiscoveryTimeoutExtension() { + discoveryTimeoutExtensions += 1 + } + + func armServiceDiscoveryTimeout(for peripheral: CBPeripheral) { + serviceDiscoveryTimeoutTask?.cancel() + serviceDiscoveryTimeoutTask = Task { + try? await Task.sleep(for: .seconds(serviceDiscoveryTimeout)) + guard !Task.isCancelled else { return } + handleServiceDiscoveryTimeout(for: peripheral) } - - /// Counts one watchdog deferral against the current generation's budget. - func recordDiscoveryTimeoutExtension() { - discoveryTimeoutExtensions += 1 + } + + func handleServiceDiscoveryTimeout(for peripheral: CBPeripheral) { + // Guard against stale timeout: if the normal path already cleared the + // task reference, this timeout fired after cancellation took effect. + guard serviceDiscoveryTimeoutTask != nil else { return } + + switch phase { + case let .discoveringServices(p, c), + let .discoveringCharacteristics(p, _, c), + let .subscribingToNotifications(p, _, _, c): + guard p.identifier == peripheral.identifier else { return } + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + logger.warning("[BLE] Service discovery timeout: \(peripheral.identifier.uuidString.prefix(8)), phase: \(phase.name), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + + if Self.shouldExtendDiscoveryTimeout( + peripheralState: peripheral.state, + extensions: discoveryTimeoutExtensions, + maxExtensions: Self.maxDiscoveryTimeoutExtensions + ) { + recordDiscoveryTimeoutExtension() + logger.warning("[BLE] Peripheral still connected; extending service discovery window (\(discoveryTimeoutExtensions)/\(Self.maxDiscoveryTimeoutExtensions)) instead of failing") + armServiceDiscoveryTimeout(for: peripheral) + return + } + + let teardownError = Self.discoveryTimeoutError( + peripheralState: peripheral.state, + extensions: discoveryTimeoutExtensions, + maxExtensions: Self.maxDiscoveryTimeoutExtensions + ) + centralManager.cancelPeripheralConnection(peripheral) + transition(to: .idle) + c.resume(throwing: teardownError) + default: + break } + } - func armServiceDiscoveryTimeout(for peripheral: CBPeripheral) { - serviceDiscoveryTimeoutTask?.cancel() - serviceDiscoveryTimeoutTask = Task { - try? await Task.sleep(for: .seconds(serviceDiscoveryTimeout)) - guard !Task.isCancelled else { return } - handleServiceDiscoveryTimeout(for: peripheral) - } - } + private func handleAutoReconnectDiscoveryTimeout(for peripheral: CBPeripheral, generation: UInt64) { + // Guard against stale timeout: if the normal path already cleared the + // task reference, this timeout fired after cancellation took effect. + guard autoReconnectDiscoveryTimeoutTask != nil else { return } - func handleServiceDiscoveryTimeout(for peripheral: CBPeripheral) { - // Guard against stale timeout: if the normal path already cleared the - // task reference, this timeout fired after cancellation took effect. - guard serviceDiscoveryTimeoutTask != nil else { return } - - switch phase { - case .discoveringServices(let p, let c), - .discoveringCharacteristics(let p, _, let c), - .subscribingToNotifications(let p, _, _, let c): - guard p.identifier == peripheral.identifier else { return } - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.warning("[BLE] Service discovery timeout: \(peripheral.identifier.uuidString.prefix(8)), phase: \(self.phase.name), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") - - if Self.shouldExtendDiscoveryTimeout( - peripheralState: peripheral.state, - extensions: discoveryTimeoutExtensions, - maxExtensions: Self.maxDiscoveryTimeoutExtensions - ) { - recordDiscoveryTimeoutExtension() - logger.warning("[BLE] Peripheral still connected; extending service discovery window (\(self.discoveryTimeoutExtensions)/\(Self.maxDiscoveryTimeoutExtensions)) instead of failing") - armServiceDiscoveryTimeout(for: peripheral) - return - } - - centralManager.cancelPeripheralConnection(peripheral) - transition(to: .idle) - c.resume(throwing: BLEError.connectionTimeout) - default: - break - } + // Skip timeout enforcement while app is inactive + guard isAppActive else { + logger.info("[BLE] Skipping auto-reconnect timeout while app inactive") + return } - private func handleAutoReconnectDiscoveryTimeout(for peripheral: CBPeripheral, generation: UInt64) { - // Guard against stale timeout: if the normal path already cleared the - // task reference, this timeout fired after cancellation took effect. - guard autoReconnectDiscoveryTimeoutTask != nil else { return } - - // Skip timeout enforcement while app is inactive - guard isAppActive else { - logger.info("[BLE] Skipping auto-reconnect timeout while app inactive") - return - } - - // Reject stale timeout from a previous generation - if generation != connectionGeneration { - logger.info("[BLE] Ignoring stale auto-reconnect timeout for generation \(generation)") - return - } + // Reject stale timeout from a previous generation + if generation != connectionGeneration { + logger.info("[BLE] Ignoring stale auto-reconnect timeout for generation \(generation)") + return + } - guard case .autoReconnecting(let expected, _, _) = phase, - expected.identifier == peripheral.identifier else { - return - } + guard case let .autoReconnecting(expected, _, _) = phase, + expected.identifier == peripheral.identifier else { + return + } - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.warning( - "[BLE] Auto-reconnect discovery timeout: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s" - ) - - // iOS may flip the peripheral to .connected before delivering didConnect. - // Tearing the link down here cancels a reconnection that actually - // succeeded, after which the late didConnect lands in .idle and is - // rejected. Give discovery another window instead of destroying it. - if Self.shouldExtendDiscoveryTimeout( - peripheralState: peripheral.state, - extensions: discoveryTimeoutExtensions, - maxExtensions: Self.maxDiscoveryTimeoutExtensions - ) { - recordDiscoveryTimeoutExtension() - logger.warning("[BLE] Peripheral still connected; extending auto-reconnect discovery window (\(self.discoveryTimeoutExtensions)/\(Self.maxDiscoveryTimeoutExtensions)) instead of tearing down") - armAutoReconnectDiscoveryTimeout(for: peripheral, generation: generation) - return - } + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) - centralManager.cancelPeripheralConnection(peripheral) - transition(to: .idle) - onDisconnection?(peripheral.identifier, BLEError.connectionTimeout) + switch Self.autoReconnectTimeoutAction( + peripheralState: peripheral.state, + extensions: discoveryTimeoutExtensions, + maxExtensions: Self.maxDiscoveryTimeoutExtensions + ) { + case .waitForPendingConnect: + logger.info( + "[BLE] Auto-reconnect window elapsed while link down: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s; waiting for pending connect" + ) + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: generation) + + case .extendWindow: + // iOS may flip the peripheral to .connected before delivering didConnect. + // Tearing the link down here cancels a reconnection that actually + // succeeded, after which the late didConnect lands in .idle and is + // rejected. Give discovery another window instead of destroying it. + recordDiscoveryTimeoutExtension() + logger.warning("[BLE] Peripheral still connected; extending auto-reconnect discovery window (\(discoveryTimeoutExtensions)/\(Self.maxDiscoveryTimeoutExtensions)) instead of tearing down") + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: generation) + + case .tearDown: + logger.warning( + "[BLE] Auto-reconnect discovery timeout: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s" + ) + let teardownError = Self.discoveryTimeoutError( + peripheralState: peripheral.state, + extensions: discoveryTimeoutExtensions, + maxExtensions: Self.maxDiscoveryTimeoutExtensions + ) + centralManager.cancelPeripheralConnection(peripheral) + transition(to: .idle) + onDisconnection?(peripheral.identifier, teardownError) } + } } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachineProtocol.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachineProtocol.swift index 373acbc8..97a132bb 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachineProtocol.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachineProtocol.swift @@ -6,91 +6,90 @@ import Foundation /// Protocol for BLE state machine operations used by ConnectionManager. /// Enables dependency injection for testing. public protocol BLEStateMachineProtocol: Actor { + // MARK: - State Properties - // MARK: - State Properties + /// Whether the state machine is currently connected to a device + var isConnected: Bool { get } - /// Whether the state machine is currently connected to a device - var isConnected: Bool { get } + /// Whether the state machine is currently handling iOS auto-reconnect or state restoration + var isAutoReconnecting: Bool { get } - /// Whether the state machine is currently handling iOS auto-reconnect or state restoration - var isAutoReconnecting: Bool { get } + /// UUID of the currently connected device, or nil if not connected + var connectedDeviceID: UUID? { get } - /// UUID of the currently connected device, or nil if not connected - var connectedDeviceID: UUID? { get } + /// Current phase name for diagnostic logging + var currentPhaseName: String { get } - /// Current phase name for diagnostic logging - var currentPhaseName: String { get } + /// Current peripheral state for diagnostic logging (nil if no peripheral) + var currentPeripheralState: String? { get } - /// Current peripheral state for diagnostic logging (nil if no peripheral) - var currentPeripheralState: String? { get } + /// Current CBCentralManager state name for diagnostic logging + var centralManagerStateName: String { get } - /// Current CBCentralManager state name for diagnostic logging - var centralManagerStateName: String { get } + /// Whether the Bluetooth central manager is in the powered-off state. + var isBluetoothPoweredOff: Bool { get } - /// Whether the Bluetooth central manager is in the powered-off state. - var isBluetoothPoweredOff: Bool { get } + // MARK: - Methods - // MARK: - Methods + /// Checks if a device is connected to the system (possibly by another app). + /// - Parameter deviceID: The UUID of the device to check + /// - Returns: `true` if the device is connected to the system + func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool - /// Checks if a device is connected to the system (possibly by another app). - /// - Parameter deviceID: The UUID of the device to check - /// - Returns: `true` if the device is connected to the system - func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool + /// Returns the UUIDs of all peripherals currently connected to the system via Nordic UART. + /// Used for diagnostics — exposes the raw `retrieveConnectedPeripherals` result. + func systemConnectedPeripheralIDs() -> [UUID] - /// Returns the UUIDs of all peripherals currently connected to the system via Nordic UART. - /// Used for diagnostics — exposes the raw `retrieveConnectedPeripherals` result. - func systemConnectedPeripheralIDs() -> [UUID] + /// Starts a best-effort adoption of an already system-connected peripheral. + /// + /// This is used to recover from cases where iOS keeps the BLE link alive across app termination + /// (e.g., app update) but state restoration did not run. The adoption uses the same discovery + /// chain as state restoration/auto-reconnect. + /// + /// - Parameter deviceID: The UUID of the device to adopt. + /// - Returns: `true` if an adoption attempt was started. + func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool - /// Starts a best-effort adoption of an already system-connected peripheral. - /// - /// This is used to recover from cases where iOS keeps the BLE link alive across app termination - /// (e.g., app update) but state restoration did not run. The adoption uses the same discovery - /// chain as state restoration/auto-reconnect. - /// - /// - Parameter deviceID: The UUID of the device to adopt. - /// - Returns: `true` if an adoption attempt was started. - func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool + /// Activates the central manager if needed. + func activate() - /// Activates the central manager if needed. - func activate() + /// Sets a handler for auto-reconnecting events. + /// Called when device disconnects but iOS is attempting automatic reconnection. + /// - Parameters: + /// - deviceID: The disconnected device ID. + /// - errorInfo: Best-effort error summary from CoreBluetooth. + func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) - /// Sets a handler for auto-reconnecting events. - /// Called when device disconnects but iOS is attempting automatic reconnection. - /// - Parameters: - /// - deviceID: The disconnected device ID. - /// - errorInfo: Best-effort error summary from CoreBluetooth. - func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) + /// Sets a handler called when Bluetooth powers on + func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) - /// Sets a handler called when Bluetooth powers on - func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) + /// Sets a handler for Bluetooth state changes + func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) - /// Sets a handler for Bluetooth state changes - func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) + /// Sets the delay between write operations for pacing. + func setWritePacingDelay(_ delay: TimeInterval) - /// Sets the delay between write operations for pacing. - func setWritePacingDelay(_ delay: TimeInterval) + /// Sets a handler called when a device is discovered during scanning. + func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) - /// Sets a handler called when a device is discovered during scanning. - func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) + /// Starts scanning for BLE peripherals. Works while connected. + func startScanning() - /// Starts scanning for BLE peripherals. Works while connected. - func startScanning() + /// Stops an active BLE scan. + func stopScanning() - /// Stops an active BLE scan. - func stopScanning() + /// Gracefully shuts down the state machine, resuming all pending operations. + /// Call before dropping the last reference to the actor. + func shutdown() - /// Gracefully shuts down the state machine, resuming all pending operations. - /// Call before dropping the last reference to the actor. - func shutdown() + /// Notifies the state machine that the app entered background. + /// Cancels foreground-only timeouts (auto-reconnect discovery) while + /// preserving the RSSI keepalive for background connection maintenance. + func appDidEnterBackground() - /// Notifies the state machine that the app entered background. - /// Cancels foreground-only timeouts (auto-reconnect discovery) while - /// preserving the RSSI keepalive for background connection maintenance. - func appDidEnterBackground() - - /// Notifies the state machine that the app became active. - /// Defensively restarts RSSI keepalive if connected, and re-arms - /// auto-reconnect discovery timeout with generation fencing if in - /// the auto-reconnecting phase. - func appDidBecomeActive() + /// Notifies the state machine that the app became active. + /// Defensively restarts RSSI keepalive if connected, and re-arms + /// auto-reconnect discovery timeout with generation fencing if in + /// the auto-reconnecting phase. + func appDidBecomeActive() } diff --git a/MC1Services/Sources/MC1Services/Transport/BluetoothAvailability.swift b/MC1Services/Sources/MC1Services/Transport/BluetoothAvailability.swift index ce194d66..48daf098 100644 --- a/MC1Services/Sources/MC1Services/Transport/BluetoothAvailability.swift +++ b/MC1Services/Sources/MC1Services/Transport/BluetoothAvailability.swift @@ -4,10 +4,10 @@ /// powered off, or the app is not authorized to use Bluetooth. Transient and unsupported states map /// to `.ready` so a picker keeps showing its scanning state rather than a remedy the user cannot act on. public enum BluetoothAvailability: Sendable { - /// Bluetooth is usable, or in a transient state expected to resolve on its own. - case ready - /// Bluetooth is powered off; the user can turn it on in system settings. - case poweredOff - /// The app lacks Bluetooth permission; the user can grant it in system settings. - case unauthorized + /// Bluetooth is usable, or in a transient state expected to resolve on its own. + case ready + /// Bluetooth is powered off; the user can turn it on in system settings. + case poweredOff + /// The app lacks Bluetooth permission; the user can grant it in system settings. + case unauthorized } diff --git a/MC1Services/Sources/MC1Services/Transport/DiscoveredDevice.swift b/MC1Services/Sources/MC1Services/Transport/DiscoveredDevice.swift index 3b3b2447..841d5699 100644 --- a/MC1Services/Sources/MC1Services/Transport/DiscoveredDevice.swift +++ b/MC1Services/Sources/MC1Services/Transport/DiscoveredDevice.swift @@ -7,16 +7,16 @@ import Foundation /// CoreBluetooth identifier used to connect, the advertised name to show the user, /// and the signal strength. public struct DiscoveredDevice: Identifiable, Sendable, Equatable { - /// The peripheral's CoreBluetooth identifier. Becomes `Device.id` on connect. - public let id: UUID - /// The advertised local name, if the peripheral published one. - public let name: String? - /// The received signal strength indicator, in dBm. - public let rssi: Int + /// The peripheral's CoreBluetooth identifier. Becomes `Device.id` on connect. + public let id: UUID + /// The advertised local name, if the peripheral published one. + public let name: String? + /// The received signal strength indicator, in dBm. + public let rssi: Int - public init(id: UUID, name: String?, rssi: Int) { - self.id = id - self.name = name - self.rssi = rssi - } + public init(id: UUID, name: String?, rssi: Int) { + self.id = id + self.name = name + self.rssi = rssi + } } diff --git a/MC1Services/Sources/MC1Services/Transport/iOSBLETransport.swift b/MC1Services/Sources/MC1Services/Transport/iOSBLETransport.swift index c76ddcb4..43fbc59e 100644 --- a/MC1Services/Sources/MC1Services/Transport/iOSBLETransport.swift +++ b/MC1Services/Sources/MC1Services/Transport/iOSBLETransport.swift @@ -26,152 +26,151 @@ import os /// try await transport.switchDevice(to: otherDeviceUUID) /// ``` actor iOSBLETransport: MeshTransport { - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "iOSBLETransport") - - private let stateMachine: BLEStateMachine - private var deviceID: UUID? - - /// Lock-protected data stream storage. - /// Using a lock allows synchronous access from BLEStateMachine callbacks without spawning Tasks. - private let dataStreamLock = OSAllocatedUnfairLock?>(initialState: nil) - - // MARK: - Initialization - - /// Creates an iOS BLE transport with an optional shared state machine. - /// - /// - Parameter stateMachine: The BLE state machine to use. If nil, creates a new one. - init(stateMachine: BLEStateMachine? = nil) { - self.stateMachine = stateMachine ?? BLEStateMachine() - } - - // MARK: - Configuration - - /// Sets the device UUID to connect to. - /// - /// - Parameter id: The UUID of the BLE device. - func setDeviceID(_ id: UUID) { - deviceID = id - } - - /// Sets a handler for disconnection events. - /// - /// - Parameter handler: Called when the device disconnects, with the device ID and optional error. - func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) async { - await stateMachine.setDisconnectionHandler(handler) - } - - /// Sets a handler for reconnection events. - /// - /// When iOS auto-reconnect completes, the transport captures the data stream - /// and then calls your handler. The `receivedData` property will be ready - /// when your handler is called. - /// - /// - Parameter handler: Called when iOS auto-reconnect completes successfully. - func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) async { - await stateMachine.setReconnectionHandler { [dataStreamLock, logger] deviceID, stream in - // Capture the stream synchronously using lock (no Task spawning). - // This ensures the stream is available before any handler code runs. - logger.info("[BLE] Auto-reconnect stream captured for device: \(deviceID.uuidString.prefix(8))") - dataStreamLock.withLock { $0 = stream } - handler(deviceID) - } - } - - // MARK: - MeshTransport Protocol - - /// Whether the transport is currently connected to a device. - var isConnected: Bool { - get async { await stateMachine.isConnected } - } - - /// Async stream of data received from the connected device. - /// - /// Returns an empty stream if not connected. - var receivedData: AsyncStream { - dataStreamLock.withLock { $0 } ?? AsyncStream { $0.finish() } - } - - /// Connects to the configured device. - /// - /// This method is idempotent: if already connected to the same device, - /// it returns without error. Use ``switchDevice(to:)`` to change devices. - /// - /// - Throws: `BLEError.deviceNotFound` if no device ID is set. - /// - Throws: `BLEError.connectionFailed` if connected to a different device. - /// - Throws: `BLEError` for connection failures. - func connect() async throws { - let connectedID = await stateMachine.connectedDeviceID - let effectiveDeviceID = self.deviceID ?? connectedID - - guard let deviceID = effectiveDeviceID else { - logger.warning("[BLE] connect() called with no device ID set") - throw BLEError.deviceNotFound - } - - // Already connected - check if it's the same device - if await stateMachine.isConnected { - if connectedID == deviceID { - logger.info("Already connected to device: \(deviceID)") - return - } else { - throw BLEError.connectionFailed("Already connected to different device: \(connectedID?.uuidString ?? "unknown"). Use switchDevice() instead.") - } - } - - logger.info("Connecting to device: \(deviceID)") - let stream = try await stateMachine.connect(to: deviceID) - logger.info("[BLE] Stream captured for device: \(deviceID.uuidString.prefix(8))") - dataStreamLock.withLock { $0 = stream } - } - - /// Disconnects from the current device. - func disconnect() async { - logger.info("Disconnecting") - await stateMachine.disconnect() - dataStreamLock.withLock { $0 = nil } + private let logger = PersistentLogger(subsystem: "com.mc1", category: "iOSBLETransport") + + private let stateMachine: BLEStateMachine + private var deviceID: UUID? + + /// Lock-protected data stream storage. + /// Using a lock allows synchronous access from BLEStateMachine callbacks without spawning Tasks. + private let dataStreamLock = OSAllocatedUnfairLock?>(initialState: nil) + + // MARK: - Initialization + + /// Creates an iOS BLE transport with an optional shared state machine. + /// + /// - Parameter stateMachine: The BLE state machine to use. If nil, creates a new one. + init(stateMachine: BLEStateMachine? = nil) { + self.stateMachine = stateMachine ?? BLEStateMachine() + } + + // MARK: - Configuration + + /// Sets the device UUID to connect to. + /// + /// - Parameter id: The UUID of the BLE device. + func setDeviceID(_ id: UUID) { + deviceID = id + } + + /// Sets a handler for disconnection events. + /// + /// - Parameter handler: Called when the device disconnects, with the device ID and optional error. + func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) async { + await stateMachine.setDisconnectionHandler(handler) + } + + /// Sets a handler for reconnection events. + /// + /// When iOS auto-reconnect completes, the transport captures the data stream + /// and then calls your handler. The `receivedData` property will be ready + /// when your handler is called. + /// + /// - Parameter handler: Called when iOS auto-reconnect completes successfully. + func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) async { + await stateMachine.setReconnectionHandler { [dataStreamLock, logger] deviceID, stream in + // Capture the stream synchronously using lock (no Task spawning). + // This ensures the stream is available before any handler code runs. + logger.info("[BLE] Auto-reconnect stream captured for device: \(deviceID.uuidString.prefix(8))") + dataStreamLock.withLock { $0 = stream } + handler(deviceID) } - - /// Sends data to the connected device. - /// - /// - Parameter data: The data to send. - /// - Throws: `BLEError.notConnected` if not connected. - /// - Throws: `BLEError.writeError` if the write fails. - func send(_ data: Data) async throws { - try await stateMachine.send(data) - } - - /// Whether the connected radio's write characteristic advertises Write-Without-Response. - var supportsWriteWithoutResponse: Bool { - get async { await stateMachine.supportsWriteWithoutResponse } + } + + // MARK: - MeshTransport Protocol + + /// Whether the transport is currently connected to a device. + var isConnected: Bool { + get async { await stateMachine.isConnected } + } + + /// Async stream of data received from the connected device. + /// + /// Returns an empty stream if not connected. + var receivedData: AsyncStream { + dataStreamLock.withLock { $0 } ?? AsyncStream { $0.finish() } + } + + /// Connects to the configured device. + /// + /// This method is idempotent: if already connected to the same device, + /// it returns without error. Use ``switchDevice(to:)`` to change devices. + /// + /// - Throws: `BLEError.deviceNotFound` if no device ID is set. + /// - Throws: `BLEError.connectionFailed` if connected to a different device. + /// - Throws: `BLEError` for connection failures. + func connect() async throws { + let connectedID = await stateMachine.connectedDeviceID + let effectiveDeviceID = deviceID ?? connectedID + + guard let deviceID = effectiveDeviceID else { + logger.warning("[BLE] connect() called with no device ID set") + throw BLEError.deviceNotFound } - /// Sends data as an unacknowledged ATT Write Command, enabling request pipelining. - /// - /// - Parameter data: The data to send. - /// - Throws: `BLEError.notConnected` if not connected. - func sendWithoutResponse(_ data: Data) async throws { - try await stateMachine.sendWithoutResponse(data) + // Already connected - check if it's the same device + if await stateMachine.isConnected { + if connectedID == deviceID { + logger.info("Already connected to device: \(deviceID)") + return + } else { + throw BLEError.connectionFailed("Already connected to different device: \(connectedID?.uuidString ?? "unknown"). Use switchDevice() instead.") + } } - // MARK: - Extended API - - /// UUID of the currently connected device, or nil if not connected. - var connectedDeviceID: UUID? { - get async { await stateMachine.connectedDeviceID } - } - - /// Switches to a different device. - /// - /// Disconnects from the current device (if any) and connects to the new one. - /// More efficient than separate disconnect/connect calls. - /// - /// - Parameter deviceID: UUID of the new device to connect to. - /// - Throws: `BLEError` if connection fails. - func switchDevice(to deviceID: UUID) async throws { - logger.info("Switching to device: \(deviceID)") - self.deviceID = deviceID - let stream = try await stateMachine.switchDevice(to: deviceID) - dataStreamLock.withLock { $0 = stream } - } + logger.info("Connecting to device: \(deviceID)") + let stream = try await stateMachine.connect(to: deviceID) + logger.info("[BLE] Stream captured for device: \(deviceID.uuidString.prefix(8))") + dataStreamLock.withLock { $0 = stream } + } + + /// Disconnects from the current device. + func disconnect() async { + logger.info("Disconnecting") + await stateMachine.disconnect() + dataStreamLock.withLock { $0 = nil } + } + + /// Sends data to the connected device. + /// + /// - Parameter data: The data to send. + /// - Throws: `BLEError.notConnected` if not connected. + /// - Throws: `BLEError.writeError` if the write fails. + func send(_ data: Data) async throws { + try await stateMachine.send(data) + } + + /// Whether the connected radio's write characteristic advertises Write-Without-Response. + var supportsWriteWithoutResponse: Bool { + get async { await stateMachine.supportsWriteWithoutResponse } + } + + /// Sends data as an unacknowledged ATT Write Command, enabling request pipelining. + /// + /// - Parameter data: The data to send. + /// - Throws: `BLEError.notConnected` if not connected. + func sendWithoutResponse(_ data: Data) async throws { + try await stateMachine.sendWithoutResponse(data) + } + + // MARK: - Extended API + + /// UUID of the currently connected device, or nil if not connected. + var connectedDeviceID: UUID? { + get async { await stateMachine.connectedDeviceID } + } + + /// Switches to a different device. + /// + /// Disconnects from the current device (if any) and connects to the new one. + /// More efficient than separate disconnect/connect calls. + /// + /// - Parameter deviceID: UUID of the new device to connect to. + /// - Throws: `BLEError` if connection fails. + func switchDevice(to deviceID: UUID) async throws { + logger.info("Switching to device: \(deviceID)") + self.deviceID = deviceID + let stream = try await stateMachine.switchDevice(to: deviceID) + dataStreamLock.withLock { $0 = stream } + } } diff --git a/MC1Services/Sources/MC1Services/Transport/iOSMeshTransport.swift b/MC1Services/Sources/MC1Services/Transport/iOSMeshTransport.swift index 40bff585..e1c16f6f 100644 --- a/MC1Services/Sources/MC1Services/Transport/iOSMeshTransport.swift +++ b/MC1Services/Sources/MC1Services/Transport/iOSMeshTransport.swift @@ -6,10 +6,10 @@ import MeshCore /// targets a place to inject a mock transport while leaving the platform-agnostic /// `MeshTransport` contract unchanged. public protocol iOSMeshTransport: MeshTransport { - func setDeviceID(_ id: UUID) async - func switchDevice(to deviceID: UUID) async throws - func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) async - func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) async + func setDeviceID(_ id: UUID) async + func switchDevice(to deviceID: UUID) async throws + func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) async + func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) async } extension iOSBLETransport: iOSMeshTransport {} diff --git a/MC1Services/Sources/MC1Services/Utilities/ChannelMessageFormat.swift b/MC1Services/Sources/MC1Services/Utilities/ChannelMessageFormat.swift index 5de7299d..d6faa063 100644 --- a/MC1Services/Sources/MC1Services/Utilities/ChannelMessageFormat.swift +++ b/MC1Services/Sources/MC1Services/Utilities/ChannelMessageFormat.swift @@ -3,23 +3,23 @@ import Foundation /// Utilities for parsing the "NodeName: MessageText" format used in channel messages. /// The firmware prepends the sender's node name before encryption. enum ChannelMessageFormat { - /// Parses "NodeName: MessageText" format from decrypted channel messages. - /// - Parameter text: The full decrypted channel message text - /// - Returns: Tuple of (senderName, messageText) or nil if format doesn't match - static func parse(_ text: String) -> (senderName: String, messageText: String)? { - guard let colonIndex = text.firstIndex(of: ":"), - colonIndex != text.startIndex else { - return nil - } - - let senderName = String(text[.. (senderName: String, messageText: String)? { + guard let colonIndex = text.firstIndex(of: ":"), + colonIndex != text.startIndex else { + return nil + } - guard afterColon < text.endIndex else { - return (senderName, "") - } + let senderName = String(text[..` is the reserved terminator and is stripped /// from the name on emit; the name may legitimately contain `:`. public enum ContactShareUtilities { - /// Marks the start of a share token. - private static let tokenOpen: Character = "<" - - /// Marks the end of a share token and is reserved out of names. - private static let tokenClose: Character = ">" - - /// Separates the public key, type, and name fields inside a token. - private static let fieldSeparator: Character = ":" - - /// Splitting the interior keeps the public key and type as their own fields while - /// letting the name retain any internal `:` characters. - private static let interiorMaxSplits = 2 - - /// Number of fields a well-formed token interior splits into. - private static let expectedFieldCount = 3 - - /// Regex pattern for a contact share token: `<64-hex:digits:name>`. - public static let shareTokenPattern = #"<[0-9a-fA-F]{64}:\d+:[^>]+>"# - - /// Pre-compiled regex for token matching (avoids recompilation per call). - public static let shareTokenRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: shareTokenPattern) - }() - - /// Formats a contact into a share token. - /// - Parameters: - /// - publicKey: The contact's 32-byte public key (rendered as uppercase hex). - /// - type: The contact type. - /// - name: The contact's advertised name; any `>` characters are stripped. - /// - Returns: A `` token. - public static func formatShare(publicKey: Data, type: ContactType, name: String) -> String { - let sanitizedName = name.filter { $0 != tokenClose } - return "\(tokenOpen)\(publicKey.uppercaseHexString())\(fieldSeparator)\(type.rawValue)\(fieldSeparator)\(sanitizedName)\(tokenClose)" + /// Marks the start of a share token. + private static let tokenOpen: Character = "<" + + /// Marks the end of a share token and is reserved out of names. + private static let tokenClose: Character = ">" + + /// Separates the public key, type, and name fields inside a token. + private static let fieldSeparator: Character = ":" + + /// Splitting the interior keeps the public key and type as their own fields while + /// letting the name retain any internal `:` characters. + private static let interiorMaxSplits = 2 + + /// Number of fields a well-formed token interior splits into. + private static let expectedFieldCount = 3 + + /// Regex pattern for a contact share token: `<64-hex:digits:name>`. + public static let shareTokenPattern = #"<[0-9a-fA-F]{64}:\d+:[^>]+>"# + + /// Pre-compiled regex for token matching (avoids recompilation per call). + public static let shareTokenRegex: NSRegularExpression? = try? NSRegularExpression(pattern: shareTokenPattern) + + /// Formats a contact into a share token. + /// - Parameters: + /// - publicKey: The contact's 32-byte public key (rendered as uppercase hex). + /// - type: The contact type. + /// - name: The contact's advertised name; any `>` characters are stripped. + /// - Returns: A `` token. + public static func formatShare(publicKey: Data, type: ContactType, name: String) -> String { + let sanitizedName = name.filter { $0 != tokenClose } + return "\(tokenOpen)\(publicKey.uppercaseHexString())\(fieldSeparator)\(type.rawValue)\(fieldSeparator)\(sanitizedName)\(tokenClose)" + } + + /// Parses the first contact share token found in the input. + /// - Parameter token: Text that may contain a share token. + /// - Returns: The recovered ``ContactResult``, or nil if no valid token is present. + public static func parseShare(_ token: String) -> ContactResult? { + guard let regex = shareTokenRegex else { return nil } + + let range = NSRange(token.startIndex..., in: token) + guard let match = regex.firstMatch(in: token, range: range), + let matchRange = Range(match.range, in: token) else { + return nil } - /// Parses the first contact share token found in the input. - /// - Parameter token: Text that may contain a share token. - /// - Returns: The recovered ``ContactResult``, or nil if no valid token is present. - public static func parseShare(_ token: String) -> ContactResult? { - guard let regex = shareTokenRegex else { return nil } + return contactResult(fromMatched: token[matchRange]) + } - let range = NSRange(token.startIndex..., in: token) - guard let match = regex.firstMatch(in: token, range: range), - let matchRange = Range(match.range, in: token) else { - return nil - } + /// Extracts every contact share token from the input text. + /// - Parameter text: Text that may contain share tokens. + /// - Returns: The recovered contacts in the order they appear. + public static func extractShares(from text: String) -> [ContactResult] { + guard let regex = shareTokenRegex else { return [] } - return contactResult(fromMatched: token[matchRange]) - } - - /// Extracts every contact share token from the input text. - /// - Parameter text: Text that may contain share tokens. - /// - Returns: The recovered contacts in the order they appear. - public static func extractShares(from text: String) -> [ContactResult] { - guard let regex = shareTokenRegex else { return [] } - - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) - return matches.compactMap { match in - guard let matchRange = Range(match.range, in: text) else { return nil } - return contactResult(fromMatched: text[matchRange]) - } + return matches.compactMap { match in + guard let matchRange = Range(match.range, in: text) else { return nil } + return contactResult(fromMatched: text[matchRange]) + } + } + + /// Validates a regex-matched token and builds a ``ContactResult``. + /// - Parameter matched: The full matched token including delimiters. + /// - Returns: The recovered contact, or nil if any field is invalid. + private static func contactResult(fromMatched matched: Substring) -> ContactResult? { + let interior = matched.dropFirst().dropLast() + let fields = interior.split( + separator: fieldSeparator, + maxSplits: interiorMaxSplits, + omittingEmptySubsequences: false + ) + guard fields.count == expectedFieldCount else { return nil } + + let publicKeyHex = String(fields[0]) + guard let publicKey = Data(hexString: publicKeyHex), + publicKey.count == ProtocolLimits.publicKeySize else { + return nil } - /// Validates a regex-matched token and builds a ``ContactResult``. - /// - Parameter matched: The full matched token including delimiters. - /// - Returns: The recovered contact, or nil if any field is invalid. - private static func contactResult(fromMatched matched: Substring) -> ContactResult? { - let interior = matched.dropFirst().dropLast() - let fields = interior.split( - separator: fieldSeparator, - maxSplits: interiorMaxSplits, - omittingEmptySubsequences: false - ) - guard fields.count == expectedFieldCount else { return nil } - - let publicKeyHex = String(fields[0]) - guard let publicKey = Data(hexString: publicKeyHex), - publicKey.count == ProtocolLimits.publicKeySize else { - return nil - } - - guard let typeValue = Int(fields[1]), - let typeByte = UInt8(exactly: typeValue), - let contactType = ContactType(rawValue: typeByte) else { - return nil - } - - let name = String(fields[2]) - guard !name.isEmpty else { return nil } - - return ContactResult(name: name, publicKey: publicKey, contactType: contactType) + guard let typeValue = Int(fields[1]), + let typeByte = UInt8(exactly: typeValue), + let contactType = ContactType(rawValue: typeByte) else { + return nil } + + let name = String(fields[2]) + guard !name.isEmpty else { return nil } + + return ContactResult(name: name, publicKey: publicKey, contactType: contactType) + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/DeduplicationKey.swift b/MC1Services/Sources/MC1Services/Utilities/DeduplicationKey.swift index 43dc71ac..71c176c1 100644 --- a/MC1Services/Sources/MC1Services/Utilities/DeduplicationKey.swift +++ b/MC1Services/Sources/MC1Services/Utilities/DeduplicationKey.swift @@ -5,24 +5,24 @@ import Foundation /// Used by SyncCoordinator (live sync), PersistenceStore+Migration (on-device backfill), /// and PersistenceStore+Backup (export/import). enum DeduplicationKey { - static let channelPrefix = "ch-" - static let directMessagePrefix = "dm-" - static let outgoingIdentityPrefix = "out-" - static let unknownContactPlaceholder = "unknown" + static let channelPrefix = "ch-" + static let directMessagePrefix = "dm-" + static let outgoingIdentityPrefix = "out-" + static let unknownContactPlaceholder = "unknown" - static func contentBased( - contactID: UUID?, - channelIndex: UInt8?, - senderNodeName: String?, - timestamp: UInt32, - content: String - ) -> String { - let contentHash = SHA256.hash(data: Data(content.utf8)) - let hashPrefix = contentHash.prefix(4).map { String(format: "%02X", $0) }.joined() - if let channelIndex { - return "\(channelPrefix)\(channelIndex)-\(timestamp)-\(senderNodeName ?? "")-\(hashPrefix)" - } - let contactSegment = contactID?.uuidString ?? unknownContactPlaceholder - return "\(directMessagePrefix)\(contactSegment)-\(timestamp)-\(hashPrefix)" + static func contentBased( + contactID: UUID?, + channelIndex: UInt8?, + senderNodeName: String?, + timestamp: UInt32, + content: String + ) -> String { + let contentHash = SHA256.hash(data: Data(content.utf8)) + let hashPrefix = contentHash.prefix(4).map { String(format: "%02X", $0) }.joined() + if let channelIndex { + return "\(channelPrefix)\(channelIndex)-\(timestamp)-\(senderNodeName ?? "")-\(hashPrefix)" } + let contactSegment = contactID?.uuidString ?? unknownContactPlaceholder + return "\(directMessagePrefix)\(contactSegment)-\(timestamp)-\(hashPrefix)" + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/DeviceIdentity.swift b/MC1Services/Sources/MC1Services/Utilities/DeviceIdentity.swift index faf91e6c..91a53270 100644 --- a/MC1Services/Sources/MC1Services/Utilities/DeviceIdentity.swift +++ b/MC1Services/Sources/MC1Services/Utilities/DeviceIdentity.swift @@ -1,20 +1,19 @@ -import Foundation import CryptoKit +import Foundation /// Utilities for deriving stable device identity from cryptographic keys. -enum DeviceIdentity: Sendable { - - /// Derives a stable UUID from a device's Ed25519 public key. - /// Uses SHA256 hash of the public key, taking first 16 bytes as UUID. - static func deriveUUID(from publicKey: Data) -> UUID { - let hash = SHA256.hash(data: publicKey) - let hashBytes = Array(hash) +enum DeviceIdentity { + /// Derives a stable UUID from a device's Ed25519 public key. + /// Uses SHA256 hash of the public key, taking first 16 bytes as UUID. + static func deriveUUID(from publicKey: Data) -> UUID { + let hash = SHA256.hash(data: publicKey) + let hashBytes = Array(hash) - return UUID(uuid: ( - hashBytes[0], hashBytes[1], hashBytes[2], hashBytes[3], - hashBytes[4], hashBytes[5], hashBytes[6], hashBytes[7], - hashBytes[8], hashBytes[9], hashBytes[10], hashBytes[11], - hashBytes[12], hashBytes[13], hashBytes[14], hashBytes[15] - )) - } + return UUID(uuid: ( + hashBytes[0], hashBytes[1], hashBytes[2], hashBytes[3], + hashBytes[4], hashBytes[5], hashBytes[6], hashBytes[7], + hashBytes[8], hashBytes[9], hashBytes[10], hashBytes[11], + hashBytes[12], hashBytes[13], hashBytes[14], hashBytes[15] + )) + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift b/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift index d909d498..25f01bef 100644 --- a/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift +++ b/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift @@ -17,71 +17,70 @@ import os /// Calling `subscribe()` after `finish()` returns a stream that is already /// finished, so its for-await loop exits immediately rather than parking. final class EventBroadcaster: Sendable { + private struct State { + var continuations: [UUID: AsyncStream.Continuation] = [:] + var isFinished: Bool = false + } - private struct State { - var continuations: [UUID: AsyncStream.Continuation] = [:] - var isFinished: Bool = false - } - - private let state = OSAllocatedUnfairLock(initialState: State()) + private let state = OSAllocatedUnfairLock(initialState: State()) - init() {} + init() {} - /// Returns a stream receiving every event yielded after this call. - /// If `finish()` has already been called, returns a stream that finishes - /// immediately. Cancelling the consuming task unregisters the subscriber. - func subscribe() -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: Event.self) - let alreadyFinished = state.withLock { locked -> Bool in - guard !locked.isFinished else { return true } - let id = UUID() - locked.continuations[id] = continuation - continuation.onTermination = { [state] _ in - state.withLock { _ = $0.continuations.removeValue(forKey: id) } - } - return false - } - if alreadyFinished { - continuation.finish() - } - return stream + /// Returns a stream receiving every event yielded after this call. + /// If `finish()` has already been called, returns a stream that finishes + /// immediately. Cancelling the consuming task unregisters the subscriber. + func subscribe() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Event.self) + let alreadyFinished = state.withLock { locked -> Bool in + guard !locked.isFinished else { return true } + let id = UUID() + locked.continuations[id] = continuation + continuation.onTermination = { [state] _ in + state.withLock { _ = $0.continuations.removeValue(forKey: id) } + } + return false } - - /// Delivers the event to every active subscriber, pruning any that - /// terminated without unregistering. - func yield(_ event: Event) { - let snapshot = state.withLock { $0.continuations } - var staleIDs: [UUID] = [] - for (id, continuation) in snapshot { - if case .terminated = continuation.yield(event) { - staleIDs.append(id) - } - } - let staleToPrune = staleIDs - guard !staleToPrune.isEmpty else { return } - state.withLock { locked in - for id in staleToPrune { - locked.continuations.removeValue(forKey: id) - } - } + if alreadyFinished { + continuation.finish() } + return stream + } - /// Ends every subscriber's stream and unregisters them all. - /// Any subsequent `subscribe()` call returns an already-finished stream. - func finish() { - let snapshot = state.withLock { locked -> [UUID: AsyncStream.Continuation] in - let current = locked.continuations - locked.continuations.removeAll() - locked.isFinished = true - return current - } - for continuation in snapshot.values { - continuation.finish() - } + /// Delivers the event to every active subscriber, pruning any that + /// terminated without unregistering. + func yield(_ event: Event) { + let snapshot = state.withLock { $0.continuations } + var staleIDs: [UUID] = [] + for (id, continuation) in snapshot { + if case .terminated = continuation.yield(event) { + staleIDs.append(id) + } } + let staleToPrune = staleIDs + guard !staleToPrune.isEmpty else { return } + state.withLock { locked in + for id in staleToPrune { + locked.continuations.removeValue(forKey: id) + } + } + } - /// Number of currently registered subscribers. - var subscriberCount: Int { - state.withLock { $0.continuations.count } + /// Ends every subscriber's stream and unregisters them all. + /// Any subsequent `subscribe()` call returns an already-finished stream. + func finish() { + let snapshot = state.withLock { locked -> [UUID: AsyncStream.Continuation] in + let current = locked.continuations + locked.continuations.removeAll() + locked.isFinished = true + return current + } + for continuation in snapshot.values { + continuation.finish() } + } + + /// Number of currently registered subscribers. + var subscriberCount: Int { + state.withLock { $0.continuations.count } + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/HashtagUtilities.swift b/MC1Services/Sources/MC1Services/Utilities/HashtagUtilities.swift index c6bb5ba3..98bf81af 100644 --- a/MC1Services/Sources/MC1Services/Utilities/HashtagUtilities.swift +++ b/MC1Services/Sources/MC1Services/Utilities/HashtagUtilities.swift @@ -2,126 +2,121 @@ import Foundation /// Utilities for detecting and processing hashtag channel references in messages public enum HashtagUtilities { + static let hashtagPattern = "#[A-Za-z0-9][A-Za-z0-9-]*" + + /// Pre-compiled regex for hashtag matching (avoids recompilation per call) + static let hashtagRegex: NSRegularExpression? = try? NSRegularExpression(pattern: hashtagPattern) + + /// Represents a detected hashtag with its location in the source text + public struct DetectedHashtag: Equatable, Sendable { + public let name: String + public let range: Range + } + + /// Extracts all valid hashtags from text, excluding those within URLs + /// - Parameter text: The message text to search + /// - Returns: Array of detected hashtags with their ranges + static func extractHashtags(from text: String) -> [DetectedHashtag] { + extractHashtags(from: text, urlRanges: findURLRanges(in: text)) + } + + /// Extracts all valid hashtags from text, using pre-computed URL ranges to skip + /// - Parameters: + /// - text: The message text to search + /// - urlRanges: Pre-computed URL ranges (avoids duplicate NSDataDetector scan) + /// - Returns: Array of detected hashtags with their ranges + public static func extractHashtags( + from text: String, + urlRanges: [Range] + ) -> [DetectedHashtag] { + guard !text.isEmpty else { return [] } + + guard let regex = hashtagRegex else { return [] } + + let nsRange = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: nsRange) + + return matches.compactMap { match -> DetectedHashtag? in + guard let range = Range(match.range, in: text) else { return nil } + + // Skip hashtags that fall within URL ranges + for urlRange in urlRanges { + if range.lowerBound >= urlRange.lowerBound, range.upperBound <= urlRange.upperBound { + return nil + } + } - static let hashtagPattern = "#[A-Za-z0-9][A-Za-z0-9-]*" - - /// Pre-compiled regex for hashtag matching (avoids recompilation per call) - static let hashtagRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: hashtagPattern) - }() - - /// Represents a detected hashtag with its location in the source text - public struct DetectedHashtag: Equatable, Sendable { - public let name: String - public let range: Range + let name = String(text[range]) + return DetectedHashtag(name: name, range: range) } - - /// Extracts all valid hashtags from text, excluding those within URLs - /// - Parameter text: The message text to search - /// - Returns: Array of detected hashtags with their ranges - static func extractHashtags(from text: String) -> [DetectedHashtag] { - extractHashtags(from: text, urlRanges: findURLRanges(in: text)) + } + + /// Validates that a channel name contains only valid characters + /// - Parameter name: Channel name without # prefix + /// - Returns: True if valid (starts with alphanumeric, then lowercase letters, numbers, hyphens only) + public static func isValidHashtagName(_ name: String) -> Bool { + guard let first = name.unicodeScalars.first else { return false } + guard isAllowedHashtagNameScalar(first, allowsHyphen: false) else { return false } + return name.unicodeScalars.allSatisfy { scalar in + isAllowedHashtagNameScalar(scalar, allowsHyphen: true) } + } - /// Extracts all valid hashtags from text, using pre-computed URL ranges to skip - /// - Parameters: - /// - text: The message text to search - /// - urlRanges: Pre-computed URL ranges (avoids duplicate NSDataDetector scan) - /// - Returns: Array of detected hashtags with their ranges - public static func extractHashtags( - from text: String, - urlRanges: [Range] - ) -> [DetectedHashtag] { - guard !text.isEmpty else { return [] } - - guard let regex = hashtagRegex else { return [] } - - let nsRange = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: nsRange) - - return matches.compactMap { match -> DetectedHashtag? in - guard let range = Range(match.range, in: text) else { return nil } - - // Skip hashtags that fall within URL ranges - for urlRange in urlRanges { - if range.lowerBound >= urlRange.lowerBound && range.upperBound <= urlRange.upperBound { - return nil - } - } - - let name = String(text[range]) - return DetectedHashtag(name: name, range: range) - } - } + public static func sanitizeHashtagNameInput(_ input: String) -> String { + var result = String() + result.reserveCapacity(input.count) - /// Validates that a channel name contains only valid characters - /// - Parameter name: Channel name without # prefix - /// - Returns: True if valid (starts with alphanumeric, then lowercase letters, numbers, hyphens only) - public static func isValidHashtagName(_ name: String) -> Bool { - guard let first = name.unicodeScalars.first else { return false } - guard isAllowedHashtagNameScalar(first, allowsHyphen: false) else { return false } - return name.unicodeScalars.allSatisfy { scalar in - isAllowedHashtagNameScalar(scalar, allowsHyphen: true) - } + for scalar in input.lowercased().unicodeScalars { + guard isAllowedHashtagNameScalar(scalar, allowsHyphen: true) else { continue } + result.unicodeScalars.append(scalar) } - public static func sanitizeHashtagNameInput(_ input: String) -> String { - var result = String() - result.reserveCapacity(input.count) - - for scalar in input.lowercased().unicodeScalars { - guard isAllowedHashtagNameScalar(scalar, allowsHyphen: true) else { continue } - result.unicodeScalars.append(scalar) - } - - while result.hasPrefix("-") { - result.removeFirst() - } - - return result + while result.hasPrefix("-") { + result.removeFirst() } - /// Normalizes a hashtag name by lowercasing and removing # prefix - /// - Parameter name: The hashtag name (with or without #) - /// - Returns: Normalized lowercase name without prefix - public static func normalizeHashtagName(_ name: String) -> String { - var normalized = name.lowercased() - if normalized.hasPrefix("#") { - normalized.removeFirst() - } - return normalized + return result + } + + /// Normalizes a hashtag name by lowercasing and removing # prefix + /// - Parameter name: The hashtag name (with or without #) + /// - Returns: Normalized lowercase name without prefix + public static func normalizeHashtagName(_ name: String) -> String { + var normalized = name.lowercased() + if normalized.hasPrefix("#") { + normalized.removeFirst() } + return normalized + } - // MARK: - Private Helpers + // MARK: - Private Helpers - private static let urlDetector: NSDataDetector? = { - try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - }() + private static let urlDetector: NSDataDetector? = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - private static func isAllowedHashtagNameScalar(_ scalar: UnicodeScalar, allowsHyphen: Bool) -> Bool { - switch scalar.value { - case 48...57, 65...90, 97...122: - return true - case 45: - return allowsHyphen - default: - return false - } + private static func isAllowedHashtagNameScalar(_ scalar: UnicodeScalar, allowsHyphen: Bool) -> Bool { + switch scalar.value { + case 48...57, 65...90, 97...122: + true + case 45: + allowsHyphen + default: + false } + } - private static func findURLRanges(in text: String) -> [Range] { - guard let detector = urlDetector else { return [] } + private static func findURLRanges(in text: String) -> [Range] { + guard let detector = urlDetector else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - let matches = detector.matches(in: text, options: [], range: nsRange) + let nsRange = NSRange(text.startIndex..., in: text) + let matches = detector.matches(in: text, options: [], range: nsRange) - return matches.compactMap { match -> Range? in - guard let url = match.url, - let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { - return nil - } - return Range(match.range, in: text) - } + return matches.compactMap { match -> Range? in + guard let url = match.url, + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + return Range(match.range, in: text) } + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/ImageURLClassifier.swift b/MC1Services/Sources/MC1Services/Utilities/ImageURLClassifier.swift index 914ea547..359385a0 100644 --- a/MC1Services/Sources/MC1Services/Utilities/ImageURLClassifier.swift +++ b/MC1Services/Sources/MC1Services/Utilities/ImageURLClassifier.swift @@ -7,65 +7,63 @@ import Foundation /// The companion `ImageURLDetector` in MC1 owns image *decoding* (depends on /// UIKit/ImageIO) and forwards URL classification to this type. public enum ImageURLClassifier { - - private static let imageExtensions: Set = [ - "jpg", "jpeg", "png", "gif", "webp", "heic" - ] - - /// Returns `true` if the URL's path extension is a known image type. - public static func isDirectImageURL(_ url: URL) -> Bool { - imageExtensions.contains(url.pathExtension.lowercased()) - } - - /// Returns the direct image URL for known hosting page URLs, or `nil` if - /// not resolvable. - public static func resolveImageURL(_ url: URL) -> URL? { - guard let host = url.host()?.lowercased() else { return nil } - - if host == "giphy.com" || host == "www.giphy.com" { - return resolveGiphyURL(url) - } - - // Already a direct Giphy media URL — no resolution needed. - if host == "media.giphy.com" || host == "i.giphy.com" { - return nil - } - - return nil + private static let imageExtensions: Set = [ + "jpg", "jpeg", "png", "gif", "webp", "heic" + ] + + /// Returns `true` if the URL's path extension is a known image type. + public static func isDirectImageURL(_ url: URL) -> Bool { + imageExtensions.contains(url.pathExtension.lowercased()) + } + + /// Returns the direct image URL for known hosting page URLs, or `nil` if + /// not resolvable. + public static func resolveImageURL(_ url: URL) -> URL? { + guard let host = url.host()?.lowercased() else { return nil } + + if host == "giphy.com" || host == "www.giphy.com" { + return resolveGiphyURL(url) } - /// Returns `true` if the URL points to a direct image or a resolvable - /// hosting page. - public static func isImageURL(_ url: URL) -> Bool { - isDirectImageURL(url) || resolveImageURL(url) != nil + // Already a direct Giphy media URL — no resolution needed. + if host == "media.giphy.com" || host == "i.giphy.com" { + return nil } - /// Returns the direct image URL: the URL itself for direct images, or - /// the resolved URL for hosting pages. - public static func directImageURL(for url: URL) -> URL { - if isDirectImageURL(url) { return url } - return resolveImageURL(url) ?? url - } + return nil + } - private static func resolveGiphyURL(_ url: URL) -> URL? { - let pathComponents = url.pathComponents + /// Returns `true` if the URL points to a direct image or a resolvable + /// hosting page. + public static func isImageURL(_ url: URL) -> Bool { + isDirectImageURL(url) || resolveImageURL(url) != nil + } - guard pathComponents.count >= 3 else { return nil } + /// Returns the direct image URL: the URL itself for direct images, or + /// the resolved URL for hosting pages. + public static func directImageURL(for url: URL) -> URL { + if isDirectImageURL(url) { return url } + return resolveImageURL(url) ?? url + } - let section = pathComponents[1].lowercased() - guard section == "gifs" || section == "embed" else { return nil } + private static func resolveGiphyURL(_ url: URL) -> URL? { + let pathComponents = url.pathComponents - let lastComponent = pathComponents[2] + guard pathComponents.count >= 3 else { return nil } - let giphyID: String - if section == "gifs" { - giphyID = lastComponent.components(separatedBy: "-").last ?? lastComponent - } else { - giphyID = lastComponent - } + let section = pathComponents[1].lowercased() + guard section == "gifs" || section == "embed" else { return nil } - guard !giphyID.isEmpty else { return nil } + let lastComponent = pathComponents[2] - return URL(string: "https://i.giphy.com/media/\(giphyID)/giphy.gif") + let giphyID: String = if section == "gifs" { + lastComponent.components(separatedBy: "-").last ?? lastComponent + } else { + lastComponent } + + guard !giphyID.isEmpty else { return nil } + + return URL(string: "https://i.giphy.com/media/\(giphyID)/giphy.gif") + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/LogRedaction.swift b/MC1Services/Sources/MC1Services/Utilities/LogRedaction.swift index f6ff2ba8..547dd846 100644 --- a/MC1Services/Sources/MC1Services/Utilities/LogRedaction.swift +++ b/MC1Services/Sources/MC1Services/Utilities/LogRedaction.swift @@ -2,41 +2,40 @@ import Foundation /// Centralized redaction utility for logging sensitive data. enum LogRedaction { + /// Formats public key prefix as hex string. + /// Public keys are NOT redacted since they're public. + /// - Parameters: + /// - key: The public key data + /// - prefixLength: Number of bytes to include (default 6) + /// - Returns: Hex string representation + static func publicKeyHex(_ key: Data, prefixLength: Int = 6) -> String { + key.prefix(prefixLength).map { String(format: "%02x", $0) }.joined() + } - /// Formats public key prefix as hex string. - /// Public keys are NOT redacted since they're public. - /// - Parameters: - /// - key: The public key data - /// - prefixLength: Number of bytes to include (default 6) - /// - Returns: Hex string representation - static func publicKeyHex(_ key: Data, prefixLength: Int = 6) -> String { - key.prefix(prefixLength).map { String(format: "%02x", $0) }.joined() - } - - /// Redacts a node/room name to first 3 chars + "***". - /// - Parameter name: The node or room name - /// - Returns: Redacted name - static func nodeName(_ name: String) -> String { - guard name.count > 3 else { return "***" } - return String(name.prefix(3)) + "***" - } + /// Redacts a node/room name to first 3 chars + "***". + /// - Parameter name: The node or room name + /// - Returns: Redacted name + static func nodeName(_ name: String) -> String { + guard name.count > 3 else { return "***" } + return String(name.prefix(3)) + "***" + } - /// Redacts sensitive parts of CLI commands (passwords). - /// - Parameter command: The CLI command string - /// - Returns: Command with password values redacted - static func cliCommand(_ command: String) -> String { - let lower = command.lowercased() - // Redact password values in CLI commands like "set password XYZ" or "password XYZ" - if lower.hasPrefix("password") || lower.contains("set password") { - let parts = command.split(separator: " ", maxSplits: 2) - if parts.count >= 2 { - return parts.dropLast().joined(separator: " ") + " [REDACTED]" - } - } - // Truncate long commands - return command.count <= 40 ? command : String(command.prefix(40)) + "..." + /// Redacts sensitive parts of CLI commands (passwords). + /// - Parameter command: The CLI command string + /// - Returns: Command with password values redacted + static func cliCommand(_ command: String) -> String { + let lower = command.lowercased() + // Redact password values in CLI commands like "set password XYZ" or "password XYZ" + if lower.hasPrefix("password") || lower.contains("set password") { + let parts = command.split(separator: " ", maxSplits: 2) + if parts.count >= 2 { + return parts.dropLast().joined(separator: " ") + " [REDACTED]" + } } + // Truncate long commands + return command.count <= 40 ? command : String(command.prefix(40)) + "..." + } - /// Placeholder for password values that should never be logged. - static let passwordPlaceholder = "[REDACTED]" + /// Placeholder for password values that should never be logged. + static let passwordPlaceholder = "[REDACTED]" } diff --git a/MC1Services/Sources/MC1Services/Utilities/MentionUtilities.swift b/MC1Services/Sources/MC1Services/Utilities/MentionUtilities.swift index e3ca6126..c27322f6 100644 --- a/MC1Services/Sources/MC1Services/Utilities/MentionUtilities.swift +++ b/MC1Services/Sources/MC1Services/Utilities/MentionUtilities.swift @@ -2,173 +2,168 @@ import Foundation /// Utilities for working with MeshCore mention format: @[nodeContactName] public enum MentionUtilities { - /// The regex pattern for matching mentions: @[name] - public static let mentionPattern = #"@\[([^\]]+)\]"# - - /// Pre-compiled regex for mention matching (avoids recompilation per call) - public static let mentionRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: mentionPattern) - }() - - /// Creates a mention string from a node contact name - /// - Parameter name: The mesh network contact name (not nickname) - /// - Returns: Formatted mention string "@[name]" - public static func createMention(for name: String) -> String { - "@[\(name)]" - } - - /// Appends a mention to the existing composer draft, preserving it. Inserts a - /// separating space only when the draft does not already end in whitespace, and - /// always leaves a trailing space. - public static func appendMention(for name: String, to draft: String) -> String { - let mention = createMention(for: name) - guard !draft.isEmpty else { return mention + " " } - let separator = draft.last?.isWhitespace == true ? "" : " " - return draft + separator + mention + " " - } - - /// Extracts all mentions from message text - /// - Parameter text: The message text to parse - /// - Returns: Array of mentioned contact names (without @[] wrapper) - public static func extractMentions(from text: String) -> [String] { - guard let regex = mentionRegex else { return [] } - - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) - - return matches.compactMap { match in - guard let captureRange = Range(match.range(at: 1), in: text) else { - return nil - } - return String(text[captureRange]) - } - } - - /// Detects an active mention query from input text. - /// Returns the search query (text after @) if user is typing a mention, nil otherwise. - /// Triggers when @ is at start or after whitespace. Returns empty string for standalone @. - public static func detectActiveMention(in text: String) -> String? { - guard !text.isEmpty else { return nil } - - // Find the last @ that could start a mention - var searchStart = text.endIndex - - while let atIndex = text[.. String { + "@[\(name)]" + } + + /// Appends a mention to the existing composer draft, preserving it. Inserts a + /// separating space only when the draft does not already end in whitespace, and + /// always leaves a trailing space. + public static func appendMention(for name: String, to draft: String) -> String { + let mention = createMention(for: name) + guard !draft.isEmpty else { return mention + " " } + let separator = draft.last?.isWhitespace == true ? "" : " " + return draft + separator + mention + " " + } + + /// Extracts all mentions from message text + /// - Parameter text: The message text to parse + /// - Returns: Array of mentioned contact names (without @[] wrapper) + public static func extractMentions(from text: String) -> [String] { + guard let regex = mentionRegex else { return [] } + + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + + return matches.compactMap { match in + guard let captureRange = Range(match.range(at: 1), in: text) else { return nil + } + return String(text[captureRange]) } - - /// Filters contacts for mention suggestions. - /// - Parameters: - /// - contacts: All available contacts - /// - query: Search query (text after @) - /// - senderOrder: Sender name → timestamp map for recency sorting (nil = alphabetical) - /// - Returns: Chat-type contacts matching query, sorted by recency then alphabetically - public static func filterContacts( - _ contacts: [ContactDTO], - query: String, - senderOrder: [String: UInt32]? = nil - ) -> [ContactDTO] { - let filtered = contacts - .filter { $0.type == .chat } - .filter { query.isEmpty || $0.displayName.localizedStandardContains(query) } - - guard let senderOrder, !senderOrder.isEmpty else { - return filtered.sorted { - $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } + } + + /// Detects an active mention query from input text. + /// Returns the search query (text after @) if user is typing a mention, nil otherwise. + /// Triggers when @ is at start or after whitespace. Returns empty string for standalone @. + public static func detectActiveMention(in text: String) -> String? { + guard !text.isEmpty else { return nil } + + // Find the last @ that could start a mention + var searchStart = text.endIndex + + while let atIndex = text[.. bT - case (_?, nil): - // Only a has a timestamp: a comes first - return true - case (nil, _?): - // Only b has a timestamp: b comes first - return false - case (nil, nil): - // Neither has a timestamp: alphabetical - return a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending - } - } + // Extract query until space or end + let query = afterAt.prefix(while: { !$0.isWhitespace }) + return String(query) } - /// Checks if text contains a mention of the specified user name - /// - Parameters: - /// - text: The message text to check - /// - selfName: The current user's node name - /// - Returns: true if text contains @[selfName] - public static func containsSelfMention(in text: String, selfName: String) -> Bool { - let mentions = extractMentions(from: text) - return mentions.contains { $0.caseInsensitiveCompare(selfName) == .orderedSame } + return nil + } + + /// Filters contacts for mention suggestions. + /// - Parameters: + /// - contacts: All available contacts + /// - query: Search query (text after @) + /// - senderOrder: Sender name → timestamp map for recency sorting (nil = alphabetical) + /// - Returns: Chat-type contacts matching query, sorted by recency then alphabetically + public static func filterContacts( + _ contacts: [ContactDTO], + query: String, + senderOrder: [String: UInt32]? = nil + ) -> [ContactDTO] { + let filtered = contacts + .filter { $0.type == .chat } + .filter { query.isEmpty || $0.displayName.localizedStandardContains(query) } + + guard let senderOrder, !senderOrder.isEmpty else { + return filtered.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } } - /// Pre-compiled regex for stripping a leading mention from reply text - private static let leadingMentionRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: #"^@\[[^\]]+\]\s*"#) - }() - - /// Builds reply text with a mention and quoted preview of the original message. - /// Strips any leading mention from the message text before generating the preview. - public static func buildReplyText(mentionName: String, messageText: String) -> String { - let previewSource: String - if let regex = leadingMentionRegex, - let match = regex.firstMatch(in: messageText, range: NSRange(messageText.startIndex..., in: messageText)), - let matchRange = Range(match.range, in: messageText) { - previewSource = String(messageText[matchRange.upperBound...]) - } else { - previewSource = messageText - } - let preview = String(previewSource.prefix(10)) - let suffix = previewSource.count > 10 ? ".." : "" - let mention = createMention(for: mentionName) - return "\(mention)\n>\(preview)\(suffix)\n" + return filtered.sorted { a, b in + let aTimestamp = senderOrder[a.name] + let bTimestamp = senderOrder[b.name] + + switch (aTimestamp, bTimestamp) { + case let (aT?, bT?): + // Both have timestamps: most recent first + return aT > bT + case (_?, nil): + // Only a has a timestamp: a comes first + return true + case (nil, _?): + // Only b has a timestamp: b comes first + return false + case (nil, nil): + // Neither has a timestamp: alphabetical + return a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending + } + } + } + + /// Checks if text contains a mention of the specified user name + /// - Parameters: + /// - text: The message text to check + /// - selfName: The current user's node name + /// - Returns: true if text contains @[selfName] + public static func containsSelfMention(in text: String, selfName: String) -> Bool { + let mentions = extractMentions(from: text) + return mentions.contains { $0.caseInsensitiveCompare(selfName) == .orderedSame } + } + + /// Pre-compiled regex for stripping a leading mention from reply text + private static let leadingMentionRegex: NSRegularExpression? = try? NSRegularExpression(pattern: #"^@\[[^\]]+\]\s*"#) + + /// Builds reply text with a mention and quoted preview of the original message. + /// Strips any leading mention from the message text before generating the preview. + public static func buildReplyText(mentionName: String, messageText: String) -> String { + let previewSource: String = if let regex = leadingMentionRegex, + let match = regex.firstMatch(in: messageText, range: NSRange(messageText.startIndex..., in: messageText)), + let matchRange = Range(match.range, in: messageText) { + String(messageText[matchRange.upperBound...]) + } else { + messageText } + let preview = String(previewSource.prefix(10)) + let suffix = previewSource.count > 10 ? ".." : "" + let mention = createMention(for: mentionName) + return "\(mention)\n>\(preview)\(suffix)\n" + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift b/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift index 8c04eb84..e021fe55 100644 --- a/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift +++ b/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift @@ -9,16 +9,16 @@ import Foundation /// plus the theme keys, which predate the typed enum. User preferences and UI /// state belong in `AppStorageKey` instead; the two namespaces never share a key. public enum PersistenceKeys { - public static let lastConnectedDeviceID = "com.pocketmesh.lastConnectedDeviceID" - public static let lastConnectedDeviceName = "com.pocketmesh.lastConnectedDeviceName" - public static let lastConnectedRadioID = "com.pocketmesh.lastConnectedRadioID" - public static let lastDisconnectDiagnostic = "com.pocketmesh.lastDisconnectDiagnostic" - public static let userExplicitlyDisconnected = "com.pocketmesh.userExplicitlyDisconnected" + public static let lastConnectedDeviceID = "com.pocketmesh.lastConnectedDeviceID" + public static let lastConnectedDeviceName = "com.pocketmesh.lastConnectedDeviceName" + public static let lastConnectedRadioID = "com.pocketmesh.lastConnectedRadioID" + public static let lastDisconnectDiagnostic = "com.pocketmesh.lastDisconnectDiagnostic" + public static let userExplicitlyDisconnected = "com.pocketmesh.userExplicitlyDisconnected" - /// Selected theme ID (bare-string key, no `com.pocketmesh.` prefix — matches the - /// `BackupUserDefaults` string-mapping convention and the value's L10n-key form). - public static let selectedThemeID = "selectedThemeID" + /// Selected theme ID (bare-string key, no `com.pocketmesh.` prefix — matches the + /// `BackupUserDefaults` string-mapping convention and the value's L10n-key form). + public static let selectedThemeID = "selectedThemeID" - /// App color-scheme preference raw value (`"system"` | `"light"` | `"dark"`). Bare string. - public static let appColorSchemePreference = "appColorSchemePreference" + /// App color-scheme preference raw value (`"system"` | `"light"` | `"dark"`). Bare string. + public static let appColorSchemePreference = "appColorSchemePreference" } diff --git a/MC1Services/Sources/MC1Services/Utilities/Sequence+IndexByID.swift b/MC1Services/Sources/MC1Services/Utilities/Sequence+IndexByID.swift index e3f78a45..de8cb4c7 100644 --- a/MC1Services/Sources/MC1Services/Utilities/Sequence+IndexByID.swift +++ b/MC1Services/Sources/MC1Services/Utilities/Sequence+IndexByID.swift @@ -1,12 +1,12 @@ import Foundation -extension Sequence where Element: Identifiable { - /// Build a `[Element.ID: Int]` mapping each element's ID to its - /// position in the sequence. On duplicate IDs the later offset wins, - /// matching the last-write-wins semantics used by `replaceAll` and - /// related chat-timeline mutations. Used by chat-timeline code to - /// maintain an O(1) lookup from message ID to row index. - public func indexByID() -> [Element.ID: Int] { - Dictionary(enumerated().map { ($0.element.id, $0.offset) }, uniquingKeysWith: { _, new in new }) - } +public extension Sequence where Element: Identifiable { + /// Build a `[Element.ID: Int]` mapping each element's ID to its + /// position in the sequence. On duplicate IDs the later offset wins, + /// matching the last-write-wins semantics used by `replaceAll` and + /// related chat-timeline mutations. Used by chat-timeline code to + /// maintain an O(1) lookup from message ID to row index. + func indexByID() -> [Element.ID: Int] { + Dictionary(enumerated().map { ($0.element.id, $0.offset) }, uniquingKeysWith: { _, new in new }) + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/TimeoutUtility.swift b/MC1Services/Sources/MC1Services/Utilities/TimeoutUtility.swift index a4b2173c..6932a4d3 100644 --- a/MC1Services/Sources/MC1Services/Utilities/TimeoutUtility.swift +++ b/MC1Services/Sources/MC1Services/Utilities/TimeoutUtility.swift @@ -4,12 +4,12 @@ import Foundation /// Error thrown when an async operation exceeds its timeout. public struct TimeoutError: Error, LocalizedError, Sendable { - public let operationName: String - public let timeout: Duration + public let operationName: String + public let timeout: Duration - public var errorDescription: String? { - "Operation '\(operationName)' timed out after \(timeout)" - } + public var errorDescription: String? { + "Operation '\(operationName)' timed out after \(timeout)" + } } // MARK: - Timeout Helpers @@ -19,15 +19,15 @@ public struct TimeoutError: Error, LocalizedError, Sendable { /// suspends the app; current callers wrap BLE operations that should not time out /// during suspension. See `raceAgainstDeadline` for the cancellation contract. public func withTimeout( - _ timeout: Duration, - operationName: String = #function, - operation: @escaping @Sendable () async throws -> T + _ timeout: Duration, + operationName: String = #function, + operation: @escaping @Sendable () async throws -> T ) async throws -> T { - try await raceAgainstDeadline( - sleepUntilDeadline: { try await Task.sleep(for: timeout, clock: .suspending) }, - makeTimeoutError: { TimeoutError(operationName: operationName, timeout: timeout) }, - operation: operation - ) + try await raceAgainstDeadline( + sleepUntilDeadline: { try await Task.sleep(for: timeout, clock: .suspending) }, + makeTimeoutError: { TimeoutError(operationName: operationName, timeout: timeout) }, + operation: operation + ) } /// Races an async operation against a deadline, throwing `CancellationError` when @@ -37,14 +37,14 @@ public func withTimeout( /// elapsing while the app is suspended. See `raceAgainstDeadline` for the /// cancellation contract. func withCooperativeTimeout( - seconds: TimeInterval, - operation: @escaping @Sendable () async throws -> T + seconds: TimeInterval, + operation: @escaping @Sendable () async throws -> T ) async throws -> T { - try await raceAgainstDeadline( - sleepUntilDeadline: { try await Task.sleep(for: .seconds(seconds)) }, - makeTimeoutError: { CancellationError() }, - operation: operation - ) + try await raceAgainstDeadline( + sleepUntilDeadline: { try await Task.sleep(for: .seconds(seconds)) }, + makeTimeoutError: { CancellationError() }, + operation: operation + ) } /// Core racer shared by `withTimeout` and `withCooperativeTimeout`. @@ -60,20 +60,20 @@ func withCooperativeTimeout( /// in-flight continuation it owned (e.g. `BLETransportOpenedSignal.wait`'s pending /// waiter slot). private func raceAgainstDeadline( - sleepUntilDeadline: @escaping @Sendable () async throws -> Void, - makeTimeoutError: @escaping @Sendable () -> any Error, - operation: @escaping @Sendable () async throws -> T + sleepUntilDeadline: @escaping @Sendable () async throws -> Void, + makeTimeoutError: @escaping @Sendable () -> any Error, + operation: @escaping @Sendable () async throws -> T ) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - defer { group.cancelAll() } - group.addTask { try await operation() } - group.addTask { - try await sleepUntilDeadline() - throw makeTimeoutError() - } - guard let result = try await group.next() else { - throw makeTimeoutError() - } - return result + try await withThrowingTaskGroup(of: T.self) { group in + defer { group.cancelAll() } + group.addTask { try await operation() } + group.addTask { + try await sleepUntilDeadline() + throw makeTimeoutError() + } + guard let result = try await group.next() else { + throw makeTimeoutError() } + return result + } } diff --git a/MC1Services/Tests/MC1ServicesTests/AppBackupEnvelopeTests.swift b/MC1Services/Tests/MC1ServicesTests/AppBackupEnvelopeTests.swift index 2363f958..6e2d572d 100644 --- a/MC1Services/Tests/MC1ServicesTests/AppBackupEnvelopeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/AppBackupEnvelopeTests.swift @@ -1,349 +1,402 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("AppBackupEnvelope") struct AppBackupEnvelopeTests { - - // MARK: - Round-trip encoding - - @Test("Envelope round-trips through JSON encode/decode") - func envelopeRoundTrip() throws { - let radioID = UUID() - let envelope = makeTestEnvelope(radioID: radioID) - - let encoder = makeBackupJSONEncoder() - let json = try encoder.encode(envelope) - - let decoder = makeBackupJSONDecoder() - let decoded = try decoder.decode(AppBackupEnvelope.self, from: json) - - #expect(decoded.version == envelope.version) - #expect(decoded.appVersion == envelope.appVersion) - #expect(decoded.appBuild == envelope.appBuild) - #expect(decoded.manifest == envelope.manifest) - #expect(decoded.devices.count == envelope.devices.count) - #expect(decoded.contacts.count == envelope.contacts.count) - #expect(decoded.channels.count == envelope.channels.count) - #expect(decoded.messages.count == envelope.messages.count) - #expect(decoded.messageRepeats.count == envelope.messageRepeats.count) - #expect(decoded.reactions.count == envelope.reactions.count) - #expect(decoded.roomMessages.count == envelope.roomMessages.count) - #expect(decoded.remoteNodeSessions.count == envelope.remoteNodeSessions.count) - #expect(decoded.savedTracePaths.count == envelope.savedTracePaths.count) - #expect(decoded.blockedChannelSenders.count == envelope.blockedChannelSenders.count) - #expect(decoded.nodeStatusSnapshots.count == envelope.nodeStatusSnapshots.count) - #expect(decoded.userDefaults == envelope.userDefaults) - } - - @Test("Backup JSON round-trips sub-second timestamps without truncation") - func backupJSONPreservesSubsecondTimestamps() throws { - let radioID = UUID() - let exportDate = Date(timeIntervalSince1970: 1_700_000_500.9876542) - let messageDate = Date(timeIntervalSince1970: 1_700_000_501.1234567) - let snapshotTimestamp = Date(timeIntervalSince1970: 1_700_000_502.7654321) - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let session = RemoteNodeSessionDTO.testSession( - radioID: radioID, - lastMessageDate: messageDate - ) - let snapshot = NodeStatusSnapshotDTO.testSnapshot( - timestamp: snapshotTimestamp, - nodePublicKey: Data(repeating: 0xAB, count: 32) - ) - let envelope = AppBackupEnvelope( - exportDate: exportDate, - appVersion: "1.0.0", - appBuild: "42", - manifest: BackupManifest( - deviceCount: 1, - remoteNodeSessionCount: 1, - nodeStatusSnapshotCount: 1 - ), - devices: [device], - remoteNodeSessions: [session], - nodeStatusSnapshots: [snapshot] - ) - - let json = try makeBackupJSONEncoder().encode(envelope) - let decoded = try makeBackupJSONDecoder().decode(AppBackupEnvelope.self, from: json) - - #expect(decoded.exportDate == exportDate) - #expect(decoded.remoteNodeSessions.first?.lastMessageDate == messageDate) - #expect(decoded.nodeStatusSnapshots.first?.timestamp == snapshotTimestamp) - } - - // MARK: - parseBackup (compress -> parse round-trip) - - @Test("parseBackup decompresses and decodes a valid backup") - func parseBackupValidFile() throws { - let envelope = makeTestEnvelope(radioID: UUID()) - - let json = try makeBackupJSONEncoder().encode(envelope) - let compressed = try json.zlibCompressed() - - let parsed = try parseBackup(data: compressed) - #expect(parsed.version == AppBackupEnvelope.currentVersion) - #expect(parsed.appVersion == "1.0.0") - #expect(parsed.devices.count == 1) - #expect(parsed.contacts.count == 1) - } - - @Test("parseBackup throws invalidFile for garbage data") - func parseBackupGarbageData() { - let garbage = Data([0x00, 0xFF, 0xAB, 0xCD]) - #expect(throws: AppBackupError.self) { - try parseBackup(data: garbage) - } - } - - @Test("parseBackup throws invalidFile for truncated zlib payloads") - func parseBackupTruncatedPayload() throws { - let envelope = makeTestEnvelope(radioID: UUID()) - let json = try makeBackupJSONEncoder().encode(envelope) - let compressed = try json.zlibCompressed() - // Valid zlib header, missing trailer + tail of deflate stream — matches - // what a truncated download or interrupted file-copy would produce. - let truncated = Data(compressed.prefix(max(compressed.count / 2, 4))) - - #expect { - try parseBackup(data: truncated) - } throws: { error in - guard let backupError = error as? AppBackupError, - case .invalidFile = backupError else { - return false - } - return true - } - } - - @Test("parseBackup rejects files larger than the size cap") - func parseBackupRejectsOversizedFile() { - let oversized = Data(count: maxBackupCompressedBytes + 1) - #expect { - try parseBackup(data: oversized) - } throws: { error in - guard let backupError = error as? AppBackupError, - case .fileTooLarge = backupError else { - return false - } - return true - } - } - - @Test("parseBackup rejects payloads whose decompressed size exceeds the cap") - func parseBackupRejectsDecompressionBomb() throws { - // 2 MB of zeros compresses to a few KB, well under the compressed cap. - // With a 1 MB uncompressed cap, decompression must abort partway through. - let testUncompressedCap = 1 * 1_048_576 - let bomb = Data(count: 2 * 1_048_576) - let compressed = try bomb.zlibCompressed() - #expect(compressed.count < maxBackupCompressedBytes) - - #expect { - try parseBackup(data: compressed, maxUncompressedBytes: testUncompressedCap) - } throws: { error in - guard let backupError = error as? AppBackupError, - case .decompressedTooLarge(let maxBytes) = backupError else { - return false - } - return maxBytes == testUncompressedCap - } + // MARK: - Round-trip encoding + + @Test + func `Envelope round-trips through JSON encode/decode`() throws { + let radioID = UUID() + let envelope = makeTestEnvelope(radioID: radioID) + + let encoder = makeBackupJSONEncoder() + let json = try encoder.encode(envelope) + + let decoder = makeBackupJSONDecoder() + let decoded = try decoder.decode(AppBackupEnvelope.self, from: json) + + #expect(decoded.version == envelope.version) + #expect(decoded.appVersion == envelope.appVersion) + #expect(decoded.appBuild == envelope.appBuild) + #expect(decoded.manifest == envelope.manifest) + #expect(decoded.devices.count == envelope.devices.count) + #expect(decoded.contacts.count == envelope.contacts.count) + #expect(decoded.channels.count == envelope.channels.count) + #expect(decoded.messages.count == envelope.messages.count) + #expect(decoded.messageRepeats.count == envelope.messageRepeats.count) + #expect(decoded.reactions.count == envelope.reactions.count) + #expect(decoded.roomMessages.count == envelope.roomMessages.count) + #expect(decoded.remoteNodeSessions.count == envelope.remoteNodeSessions.count) + #expect(decoded.savedTracePaths.count == envelope.savedTracePaths.count) + #expect(decoded.blockedChannelSenders.count == envelope.blockedChannelSenders.count) + #expect(decoded.nodeStatusSnapshots.count == envelope.nodeStatusSnapshots.count) + #expect(decoded.userDefaults == envelope.userDefaults) + } + + @Test + func `Backup JSON round-trips sub-second timestamps without truncation`() throws { + let radioID = UUID() + let exportDate = Date(timeIntervalSince1970: 1_700_000_500.9876542) + let messageDate = Date(timeIntervalSince1970: 1_700_000_501.1234567) + let snapshotTimestamp = Date(timeIntervalSince1970: 1_700_000_502.7654321) + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let session = RemoteNodeSessionDTO.testSession( + radioID: radioID, + lastMessageDate: messageDate + ) + let snapshot = NodeStatusSnapshotDTO.testSnapshot( + timestamp: snapshotTimestamp, + nodePublicKey: Data(repeating: 0xAB, count: 32) + ) + let envelope = AppBackupEnvelope( + exportDate: exportDate, + appVersion: "1.0.0", + appBuild: "42", + manifest: BackupManifest( + deviceCount: 1, + remoteNodeSessionCount: 1, + nodeStatusSnapshotCount: 1 + ), + devices: [device], + remoteNodeSessions: [session], + nodeStatusSnapshots: [snapshot] + ) + + let json = try makeBackupJSONEncoder().encode(envelope) + let decoded = try makeBackupJSONDecoder().decode(AppBackupEnvelope.self, from: json) + + #expect(decoded.exportDate == exportDate) + #expect(decoded.remoteNodeSessions.first?.lastMessageDate == messageDate) + #expect(decoded.nodeStatusSnapshots.first?.timestamp == snapshotTimestamp) + } + + // MARK: - parseBackup (compress -> parse round-trip) + + @Test + func `parseBackup decompresses and decodes a valid backup`() throws { + let envelope = makeTestEnvelope(radioID: UUID()) + + let json = try makeBackupJSONEncoder().encode(envelope) + let compressed = try json.zlibCompressed() + + let parsed = try parseBackup(data: compressed) + #expect(parsed.version == AppBackupEnvelope.currentVersion) + #expect(parsed.appVersion == "1.0.0") + #expect(parsed.devices.count == 1) + #expect(parsed.contacts.count == 1) + } + + @Test + func `parseBackup throws invalidFile for garbage data`() { + let garbage = Data([0x00, 0xFF, 0xAB, 0xCD]) + #expect(throws: AppBackupError.self) { + try parseBackup(data: garbage) } - - @Test("parseBackup throws unsupportedVersion for future versions") - func parseBackupFutureVersion() throws { - let envelope = makeTestEnvelope(radioID: UUID()) - let encoded = try makeBackupJSONEncoder().encode(envelope) - guard var json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { - Issue.record("Failed to deserialize envelope JSON") - return - } - json["version"] = 999 - let modified = try JSONSerialization.data(withJSONObject: json) - let compressed = try modified.zlibCompressed() - - #expect { - try parseBackup(data: compressed) - } throws: { error in - guard let backupError = error as? AppBackupError, - case .unsupportedVersion(let found, let max) = backupError else { - return false - } - return found == 999 && max == AppBackupEnvelope.currentVersion - } + } + + @Test + func `parseBackup throws invalidFile for truncated zlib payloads`() throws { + let envelope = makeTestEnvelope(radioID: UUID()) + let json = try makeBackupJSONEncoder().encode(envelope) + let compressed = try json.zlibCompressed() + // Valid zlib header, missing trailer + tail of deflate stream — matches + // what a truncated download or interrupted file-copy would produce. + let truncated = Data(compressed.prefix(max(compressed.count / 2, 4))) + + #expect { + try parseBackup(data: truncated) + } throws: { error in + guard let backupError = error as? AppBackupError, + case .invalidFile = backupError else { + return false + } + return true } - - @Test("parseBackup throws corruptedManifest when counts mismatch") - func parseBackupCorruptedManifest() throws { - let envelope = makeTestEnvelope(radioID: UUID()) - let wrongManifest = BackupManifest(deviceCount: 0) - let tampered = AppBackupEnvelope( - appVersion: envelope.appVersion, - appBuild: envelope.appBuild, - manifest: wrongManifest, - devices: envelope.devices, - contacts: envelope.contacts - ) - - let json = try makeBackupJSONEncoder().encode(tampered) - let compressed = try json.zlibCompressed() - - #expect(throws: AppBackupError.self) { - try parseBackup(data: compressed) - } + } + + @Test + func `parseBackup rejects files larger than the size cap`() { + let oversized = Data(count: maxBackupCompressedBytes + 1) + #expect { + try parseBackup(data: oversized) + } throws: { error in + guard let backupError = error as? AppBackupError, + case .fileTooLarge = backupError else { + return false + } + return true } - - // MARK: - BackupManifest validation - - @Test("Manifest validates correctly when counts match") - func manifestValidatesCorrectly() { - let envelope = makeTestEnvelope(radioID: UUID()) - #expect(envelope.manifest.validate(against: envelope)) + } + + @Test + func `parseBackup rejects payloads whose decompressed size exceeds the cap`() throws { + // 2 MB of zeros compresses to a few KB, well under the compressed cap. + // With a 1 MB uncompressed cap, decompression must abort partway through. + let testUncompressedCap = 1 * 1_048_576 + let bomb = Data(count: 2 * 1_048_576) + let compressed = try bomb.zlibCompressed() + #expect(compressed.count < maxBackupCompressedBytes) + + #expect { + try parseBackup(data: compressed, maxUncompressedBytes: testUncompressedCap) + } throws: { error in + guard let backupError = error as? AppBackupError, + case let .decompressedTooLarge(maxBytes) = backupError else { + return false + } + return maxBytes == testUncompressedCap } - - @Test("Manifest detects mismatch") - func manifestDetectsMismatch() { - var envelope = makeTestEnvelope(radioID: UUID()) - envelope.devices.append(DeviceDTO.testDevice()) - #expect(!envelope.manifest.validate(against: envelope)) + } + + @Test + func `parseBackup throws unsupportedVersion for future versions`() throws { + let envelope = makeTestEnvelope(radioID: UUID()) + let encoded = try makeBackupJSONEncoder().encode(envelope) + guard var json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { + Issue.record("Failed to deserialize envelope JSON") + return } - - // MARK: - ImportResult - - @Test("ImportResult computes totals correctly") - func importResultTotals() { - var result = ImportResult() - result.record(.devices, inserted: 2) - result.record(.contacts, inserted: 5, merged: 1) - result.record(.messages, skipped: 3) - result.userDefaultsRestored = true - - #expect(result.totalInserted == 7) - #expect(result.totalMerged == 1) - #expect(result.totalSkipped == 3) - #expect(result.hasRestoredChanges) + json["version"] = 999 + let modified = try JSONSerialization.data(withJSONObject: json) + let compressed = try modified.zlibCompressed() + + #expect { + try parseBackup(data: compressed) + } throws: { error in + guard let backupError = error as? AppBackupError, + case let .unsupportedVersion(found, max) = backupError else { + return false + } + return found == 999 && max == AppBackupEnvelope.currentVersion } - - // MARK: - BackupUserDefaults Codable round-trip - - @Test("BackupUserDefaults round-trips through JSON") - func userDefaultsRoundTrip() throws { - var prefs = BackupUserDefaults() - prefs.hasCompletedOnboarding = true - prefs.mapStyleSelection = "topo" - prefs.autoDeleteStaleNodesDays = 30 - prefs.frequentEmojis = ["👍", "❤️"] - prefs.recentEmojis = ["😂", "😮"] - prefs.notifyContactMessages = false - prefs.linkPreviewsEnabled = true - prefs.showIncomingRegion = true - prefs.showIncomingSendTime = true - - let data = try JSONEncoder().encode(prefs) - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - - #expect(decoded == prefs) - #expect(decoded.showIncomingRegion == true) - #expect(decoded.showIncomingSendTime == true) - } - - @Test("Legacy envelope without showIncomingSendTime decodes to nil and restore skips it") - func legacyEnvelopeMissingShowIncomingSendTime() throws { - // A backup predating the toggle has no key. decodeIfPresent must yield nil, - // and write-if-missing restore must leave any existing local value untouched. - let legacyJSON = "{\"hasCompletedOnboarding\":true}" - let data = Data(legacyJSON.utf8) - - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - #expect(decoded.showIncomingSendTime == nil) - - let suiteName = "test.showIncomingSendTime.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let key = AppStorageKey.showIncomingSendTime.rawValue - let setKeys = decoded.restore(to: defaults) - #expect(!setKeys.contains(key)) - #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `parseBackup throws corruptedManifest when counts mismatch`() throws { + let envelope = makeTestEnvelope(radioID: UUID()) + let wrongManifest = BackupManifest(deviceCount: 0) + let tampered = AppBackupEnvelope( + appVersion: envelope.appVersion, + appBuild: envelope.appBuild, + manifest: wrongManifest, + devices: envelope.devices, + contacts: envelope.contacts + ) + + let json = try makeBackupJSONEncoder().encode(tampered) + let compressed = try json.zlibCompressed() + + #expect(throws: AppBackupError.self) { + try parseBackup(data: compressed) } - - // MARK: - AppBackupError descriptions - - @Test("AppBackupError provides user-facing descriptions") - func errorDescriptions() { - let errors: [AppBackupError] = [ - .invalidFile, - .fileTooLarge(actualBytes: 100_000_000, maxBytes: 50_000_000), - .unsupportedVersion(found: 5, maxSupported: 1), - .corruptedManifest, - .exportFailed(underlying: NSError(domain: "test", code: 1)), - .importFailed(underlying: NSError(domain: "test", code: 2)), - ] - - for error in errors { - #expect(error.errorDescription != nil) - } - - // Verify importFailed does not claim "no data was changed" - let importError = AppBackupError.importFailed(underlying: NSError(domain: "", code: 0)) - let description = importError.errorDescription ?? "" - #expect(!description.lowercased().contains("no data was changed")) + } + + // MARK: - BackupManifest validation + + @Test + func `Manifest validates correctly when counts match`() { + let envelope = makeTestEnvelope(radioID: UUID()) + #expect(envelope.manifest.validate(against: envelope)) + } + + @Test + func `Manifest detects mismatch`() { + var envelope = makeTestEnvelope(radioID: UUID()) + envelope.devices.append(DeviceDTO.testDevice()) + #expect(!envelope.manifest.validate(against: envelope)) + } + + // MARK: - ImportResult + + @Test + func `ImportResult computes totals correctly`() { + var result = ImportResult() + result.record(.devices, inserted: 2) + result.record(.contacts, inserted: 5, merged: 1) + result.record(.messages, skipped: 3) + result.userDefaultsRestored = true + + #expect(result.totalInserted == 7) + #expect(result.totalMerged == 1) + #expect(result.totalSkipped == 3) + #expect(result.hasRestoredChanges) + } + + // MARK: - BackupUserDefaults Codable round-trip + + @Test + func `BackupUserDefaults round-trips through JSON`() throws { + var prefs = BackupUserDefaults() + prefs.hasCompletedOnboarding = true + prefs.mapStyleSelection = "topo" + prefs.autoDeleteStaleNodesDays = 30 + prefs.frequentEmojis = ["👍", "❤️"] + prefs.recentEmojis = ["😂", "😮"] + prefs.notifyContactMessages = false + prefs.linkPreviewsEnabled = true + prefs.showIncomingRegion = true + prefs.showIncomingSendTime = true + prefs.showInlineImages = false + + let data = try JSONEncoder().encode(prefs) + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + + #expect(decoded == prefs) + #expect(decoded.showIncomingRegion == true) + #expect(decoded.showIncomingSendTime == true) + #expect(decoded.showInlineImages == false) + } + + @Test + func `Legacy envelope without showIncomingSendTime decodes to nil and restore skips it`() throws { + // A backup predating the toggle has no key. decodeIfPresent must yield nil, + // and write-if-missing restore must leave any existing local value untouched. + let legacyJSON = "{\"hasCompletedOnboarding\":true}" + let data = Data(legacyJSON.utf8) + + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + #expect(decoded.showIncomingSendTime == nil) + + let suiteName = "test.showIncomingSendTime.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let key = AppStorageKey.showIncomingSendTime.rawValue + let setKeys = decoded.restore(to: defaults) + #expect(!setKeys.contains(key)) + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `showInlineImages survives restore and never reconstructs the link-content toggle`() throws { + // showInlineImages is retained in the wire format but no longer read as a + // gate. It must survive encode -> decode -> restore, and restoring an + // envelope that carries it must never set linkPreviewsEnabled (the toggle + // is not reconstructed from the image toggle). + let imagesKey = AppStorageKey.showInlineImages.rawValue + let linkPreviewsEnabledKey = AppStorageKey.linkPreviewsEnabled.rawValue + + var prefs = BackupUserDefaults() + prefs.showInlineImages = false + + let data = try JSONEncoder().encode(prefs) + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + #expect(decoded.showInlineImages == false) + #expect(decoded.linkPreviewsEnabled == nil) + + let suiteName = "test.showInlineImages.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let setKeys = decoded.restore(to: defaults) + #expect(setKeys.contains(imagesKey)) + #expect(defaults.bool(forKey: imagesKey) == false) + #expect(!setKeys.contains(linkPreviewsEnabledKey)) + #expect(defaults.object(forKey: linkPreviewsEnabledKey) == nil) + } + + @Test + func `Legacy envelope without showInlineImages decodes to nil and restore skips it`() throws { + // A backup predating the collapse omits the key. decodeIfPresent must yield + // nil, restore must not write it, and the link-content toggle must stay + // untouched. + let legacyJSON = "{\"hasCompletedOnboarding\":true}" + let data = Data(legacyJSON.utf8) + + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + #expect(decoded.showInlineImages == nil) + + let suiteName = "test.legacyShowInlineImages.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let imagesKey = AppStorageKey.showInlineImages.rawValue + let linkPreviewsEnabledKey = AppStorageKey.linkPreviewsEnabled.rawValue + let setKeys = decoded.restore(to: defaults) + #expect(!setKeys.contains(imagesKey)) + #expect(defaults.object(forKey: imagesKey) == nil) + #expect(!setKeys.contains(linkPreviewsEnabledKey)) + #expect(defaults.object(forKey: linkPreviewsEnabledKey) == nil) + } + + // MARK: - AppBackupError descriptions + + @Test + func `AppBackupError provides user-facing descriptions`() { + let errors: [AppBackupError] = [ + .invalidFile, + .fileTooLarge(actualBytes: 100_000_000, maxBytes: 50_000_000), + .unsupportedVersion(found: 5, maxSupported: 1), + .corruptedManifest, + .exportFailed(underlying: NSError(domain: "test", code: 1)), + .importFailed(underlying: NSError(domain: "test", code: 2)), + ] + + for error in errors { + #expect(error.errorDescription != nil) } - // MARK: - Helpers - - private func makeTestEnvelope(radioID: UUID) -> AppBackupEnvelope { - let device = DeviceDTO.testDevice() - let contact = ContactDTO.testContact(radioID: radioID) - let channel = ChannelDTO.testChannel(radioID: radioID) - let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: UUID()) - let messageRepeat = MessageRepeatDTO.testRepeat(messageID: UUID()) - let reaction = ReactionDTO.testReaction(messageID: UUID(), radioID: radioID) - let roomMessage = RoomMessageDTO.testRoomMessage(sessionID: UUID()) - let session = RemoteNodeSessionDTO.testSession(radioID: radioID) - let tracePath = SavedTracePathDTO.testPath(radioID: radioID, runs: [TracePathRunDTO.testRun()]) - let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) - let snapshot = NodeStatusSnapshotDTO.testSnapshot( - nodePublicKey: Data(repeating: 0xAA, count: 32), - neighborSnapshots: [] - ) - - var prefs = BackupUserDefaults() - prefs.hasCompletedOnboarding = true - prefs.mapStyleSelection = "standard" - - let manifest = BackupManifest( - deviceCount: 1, - contactCount: 1, - channelCount: 1, - messageCount: 1, - messageRepeatCount: 1, - reactionCount: 1, - roomMessageCount: 1, - remoteNodeSessionCount: 1, - savedTracePathCount: 1, - blockedChannelSenderCount: 1, - nodeStatusSnapshotCount: 1 - ) - - return AppBackupEnvelope( - appVersion: "1.0.0", - appBuild: "42", - manifest: manifest, - devices: [device], - contacts: [contact], - channels: [channel], - messages: [message], - messageRepeats: [messageRepeat], - reactions: [reaction], - roomMessages: [roomMessage], - remoteNodeSessions: [session], - savedTracePaths: [tracePath], - blockedChannelSenders: [blocked], - nodeStatusSnapshots: [snapshot], - userDefaults: prefs - ) - } + // Verify importFailed does not claim "no data was changed" + let importError = AppBackupError.importFailed(underlying: NSError(domain: "", code: 0)) + let description = importError.errorDescription ?? "" + #expect(!description.lowercased().contains("no data was changed")) + } + + // MARK: - Helpers + + private func makeTestEnvelope(radioID: UUID) -> AppBackupEnvelope { + let device = DeviceDTO.testDevice() + let contact = ContactDTO.testContact(radioID: radioID) + let channel = ChannelDTO.testChannel(radioID: radioID) + let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: UUID()) + let messageRepeat = MessageRepeatDTO.testRepeat(messageID: UUID()) + let reaction = ReactionDTO.testReaction(messageID: UUID(), radioID: radioID) + let roomMessage = RoomMessageDTO.testRoomMessage(sessionID: UUID()) + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + let tracePath = SavedTracePathDTO.testPath(radioID: radioID, runs: [TracePathRunDTO.testRun()]) + let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) + let snapshot = NodeStatusSnapshotDTO.testSnapshot( + nodePublicKey: Data(repeating: 0xAA, count: 32), + neighborSnapshots: [] + ) + + var prefs = BackupUserDefaults() + prefs.hasCompletedOnboarding = true + prefs.mapStyleSelection = "standard" + + let manifest = BackupManifest( + deviceCount: 1, + contactCount: 1, + channelCount: 1, + messageCount: 1, + messageRepeatCount: 1, + reactionCount: 1, + roomMessageCount: 1, + remoteNodeSessionCount: 1, + savedTracePathCount: 1, + blockedChannelSenderCount: 1, + nodeStatusSnapshotCount: 1 + ) + + return AppBackupEnvelope( + appVersion: "1.0.0", + appBuild: "42", + manifest: manifest, + devices: [device], + contacts: [contact], + channels: [channel], + messages: [message], + messageRepeats: [messageRepeat], + reactions: [reaction], + roomMessages: [roomMessage], + remoteNodeSessions: [session], + savedTracePaths: [tracePath], + blockedChannelSenders: [blocked], + nodeStatusSnapshots: [snapshot], + userDefaults: prefs + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/AppBackupServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/AppBackupServiceTests.swift index cef04d18..46ad10a5 100644 --- a/MC1Services/Tests/MC1ServicesTests/AppBackupServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/AppBackupServiceTests.swift @@ -1,628 +1,627 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("AppBackupService") struct AppBackupServiceTests { - - // MARK: - Export: happy path - - @Test("Export produces valid compressed backup") - func exportProducesValidBackup() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Seed one contact and two messages (one with link preview blobs) - let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xAA, count: 32)) - try await store.saveContact(contact) - - let plainMsg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - try await store.saveMessage(plainMsg) - - let blobMsgID = UUID() - let blobMsg = MessageDTO.testDirectMessage( - id: blobMsgID, - radioID: radioID, - contactID: contact.id, - text: "Check this out: https://example.com" - ) - try await store.saveMessage(blobMsg) - try await store.updateMessageLinkPreview( - id: blobMsgID, - url: "https://example.com", - title: "Example", - imageData: Data(repeating: 0xFF, count: 1024), - iconData: Data(repeating: 0xFE, count: 32), - fetched: true - ) - - let service = AppBackupService() - - let result = try await service.export(persistenceStore: store) - - // Must be non-empty compressed data - #expect(!result.data.isEmpty) - - // Round-trip: parse it back - let envelope = try parseBackup(data: result.data) - - // Manifest counts - #expect(envelope.devices.count == 1) - #expect(envelope.contacts.count == 1) - #expect(envelope.messages.count == 2) - #expect(envelope.manifest.validate(against: envelope)) - } - - // MARK: - Link preview blobs are stripped - - @Test("Export strips link preview blobs from messages") - func exportStripsLinkPreviewBlobs() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xBB, count: 32)) - try await store.saveContact(contact) - - let msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - try await store.saveMessage(msg) - - // Populate link preview fields (set separately after initial save) - try await store.updateMessageLinkPreview( - id: msg.id, - url: "https://example.com", - title: "Example Domain", - imageData: Data(repeating: 0x01, count: 500), - iconData: Data(repeating: 0x02, count: 64), - fetched: true - ) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - let envelope = try parseBackup(data: result.data) - let exported = try #require(envelope.messages.first) - - // URL and title are preserved - #expect(exported.linkPreviewURL == "https://example.com") - #expect(exported.linkPreviewTitle == "Example Domain") - - // Blobs and fetched flag are cleared - #expect(exported.linkPreviewImageData == nil) - #expect(exported.linkPreviewIconData == nil) - #expect(exported.linkPreviewFetched == false) - } - - // MARK: - Empty store - - @Test("Export succeeds with empty store") - func exportEmptyStore() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - let envelope = try parseBackup(data: result.data) - #expect(envelope.devices.count == 1) - #expect(envelope.contacts.count == 0) - #expect(envelope.messages.count == 0) - #expect(envelope.manifest.validate(against: envelope)) - } - - // MARK: - Manifest accuracy - - @Test("Manifest counts match exported array sizes") - func manifestCountsMatchArraySizes() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Add 3 contacts - for i in 0..<3 { - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: UInt8(i + 1), count: 32) - ) - try await store.saveContact(contact) - } - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - let envelope = try parseBackup(data: result.data) - #expect(envelope.manifest.contactCount == 3) - #expect(envelope.manifest.deviceCount == 1) - #expect(envelope.manifest.validate(against: envelope)) - } - - // MARK: - Output is compressed - - @Test("Export output is smaller than raw JSON for large payloads") - func exportOutputIsCompressed() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Add enough messages to make compression meaningful - for i in 0..<20 { - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: UInt8(i % 255 + 1), count: 32) + Data([UInt8(i / 255)]) - ) - try await store.saveContact(contact) - } - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - // Verify it decompresses successfully (proves it is valid compressed data) - let decompressed = try (result.data as NSData).decompressed(using: .zlib) as Data - #expect(decompressed.count > result.data.count) + // MARK: - Export: happy path + + @Test + func `Export produces valid compressed backup`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Seed one contact and two messages (one with link preview blobs) + let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xAA, count: 32)) + try await store.saveContact(contact) + + let plainMsg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + try await store.saveMessage(plainMsg) + + let blobMsgID = UUID() + let blobMsg = MessageDTO.testDirectMessage( + id: blobMsgID, + radioID: radioID, + contactID: contact.id, + text: "Check this out: https://example.com" + ) + try await store.saveMessage(blobMsg) + try await store.updateMessageLinkPreview( + id: blobMsgID, + url: "https://example.com", + title: "Example", + imageData: Data(repeating: 0xFF, count: 1024), + iconData: Data(repeating: 0xFE, count: 32), + fetched: true + ) + + let service = AppBackupService() + + let result = try await service.export(persistenceStore: store) + + // Must be non-empty compressed data + #expect(!result.data.isEmpty) + + // Round-trip: parse it back + let envelope = try parseBackup(data: result.data) + + // Manifest counts + #expect(envelope.devices.count == 1) + #expect(envelope.contacts.count == 1) + #expect(envelope.messages.count == 2) + #expect(envelope.manifest.validate(against: envelope)) + } + + // MARK: - Link preview blobs are stripped + + @Test + func `Export strips link preview blobs from messages`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xBB, count: 32)) + try await store.saveContact(contact) + + let msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + try await store.saveMessage(msg) + + // Populate link preview fields (set separately after initial save) + try await store.updateMessageLinkPreview( + id: msg.id, + url: "https://example.com", + title: "Example Domain", + imageData: Data(repeating: 0x01, count: 500), + iconData: Data(repeating: 0x02, count: 64), + fetched: true + ) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + let envelope = try parseBackup(data: result.data) + let exported = try #require(envelope.messages.first) + + // URL and title are preserved + #expect(exported.linkPreviewURL == "https://example.com") + #expect(exported.linkPreviewTitle == "Example Domain") + + // Blobs and fetched flag are cleared + #expect(exported.linkPreviewImageData == nil) + #expect(exported.linkPreviewIconData == nil) + #expect(exported.linkPreviewFetched == false) + } + + // MARK: - Empty store + + @Test + func `Export succeeds with empty store`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + let envelope = try parseBackup(data: result.data) + #expect(envelope.devices.count == 1) + #expect(envelope.contacts.count == 0) + #expect(envelope.messages.count == 0) + #expect(envelope.manifest.validate(against: envelope)) + } + + // MARK: - Manifest accuracy + + @Test + func `Manifest counts match exported array sizes`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Add 3 contacts + for i in 0..<3 { + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: UInt8(i + 1), count: 32) + ) + try await store.saveContact(contact) } - // MARK: - Version and source bundle - - @Test("Envelope contains expected version") - func envelopeVersionIsCorrect() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - let envelope = try parseBackup(data: result.data) - #expect(envelope.version == AppBackupEnvelope.currentVersion) + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + let envelope = try parseBackup(data: result.data) + #expect(envelope.manifest.contactCount == 3) + #expect(envelope.manifest.deviceCount == 1) + #expect(envelope.manifest.validate(against: envelope)) + } + + // MARK: - Output is compressed + + @Test + func `Export output is smaller than raw JSON for large payloads`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Add enough messages to make compression meaningful + for i in 0..<20 { + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: UInt8(i % 255 + 1), count: 32) + Data([UInt8(i / 255)]) + ) + try await store.saveContact(contact) } - // MARK: - Import: into empty store - - @Test("Import into empty store restores all records") - func importIntoEmptyStore() async throws { - let radioID = UUID() - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID, publicKey: Data(repeating: 0x01, count: 32)) - let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xAA, count: 32)) - let channel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") - let msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - let session = RemoteNodeSessionDTO.testSession(radioID: radioID) - let roomMsg = RoomMessageDTO.testRoomMessage(sessionID: session.id) - let tracePath = SavedTracePathDTO.testPath(radioID: radioID) - let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) - let snapshot = NodeStatusSnapshotDTO.testSnapshot() - let reaction = ReactionDTO.testReaction(messageID: msg.id, radioID: radioID) - let msgRepeat = MessageRepeatDTO.testRepeat(messageID: msg.id) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - channels: [channel], - messages: [msg], - messageRepeats: [msgRepeat], - reactions: [reaction], - roomMessages: [roomMsg], - remoteNodeSessions: [session], - savedTracePaths: [tracePath], - blockedChannelSenders: [blocked], - nodeStatusSnapshots: [snapshot] - ) - - // Import into a fresh empty store - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.devicesInserted == 1) - #expect(result.contactsInserted == 1) - #expect(result.channelsInserted == 1) - #expect(result.messagesInserted == 1) - #expect(result.messageRepeatsInserted == 1) - #expect(result.reactionsInserted == 1) - #expect(result.remoteNodeSessionsInserted == 1) - #expect(result.roomMessagesInserted == 1) - #expect(result.savedTracePathsInserted == 1) - #expect(result.blockedChannelSendersInserted == 1) - #expect(result.nodeStatusSnapshotsInserted == 1) - #expect(result.totalSkipped == 0) - - // Verify records persisted - let destDevices = try await destStore.fetchAllDevices() - #expect(destDevices.count == 1) - - let destContacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(destContacts.count == 1) + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + // Verify it decompresses successfully (proves it is valid compressed data) + let decompressed = try (result.data as NSData).decompressed(using: .zlib) as Data + #expect(decompressed.count > result.data.count) + } + + // MARK: - Version and source bundle + + @Test + func `Envelope contains expected version`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + let envelope = try parseBackup(data: result.data) + #expect(envelope.version == AppBackupEnvelope.currentVersion) + } + + // MARK: - Import: into empty store + + @Test + func `Import into empty store restores all records`() async throws { + let radioID = UUID() + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID, publicKey: Data(repeating: 0x01, count: 32)) + let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xAA, count: 32)) + let channel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") + let msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + let roomMsg = RoomMessageDTO.testRoomMessage(sessionID: session.id) + let tracePath = SavedTracePathDTO.testPath(radioID: radioID) + let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) + let snapshot = NodeStatusSnapshotDTO.testSnapshot() + let reaction = ReactionDTO.testReaction(messageID: msg.id, radioID: radioID) + let msgRepeat = MessageRepeatDTO.testRepeat(messageID: msg.id) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + channels: [channel], + messages: [msg], + messageRepeats: [msgRepeat], + reactions: [reaction], + roomMessages: [roomMsg], + remoteNodeSessions: [session], + savedTracePaths: [tracePath], + blockedChannelSenders: [blocked], + nodeStatusSnapshots: [snapshot] + ) + + // Import into a fresh empty store + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.devicesInserted == 1) + #expect(result.contactsInserted == 1) + #expect(result.channelsInserted == 1) + #expect(result.messagesInserted == 1) + #expect(result.messageRepeatsInserted == 1) + #expect(result.reactionsInserted == 1) + #expect(result.remoteNodeSessionsInserted == 1) + #expect(result.roomMessagesInserted == 1) + #expect(result.savedTracePathsInserted == 1) + #expect(result.blockedChannelSendersInserted == 1) + #expect(result.nodeStatusSnapshotsInserted == 1) + #expect(result.totalSkipped == 0) + + // Verify records persisted + let destDevices = try await destStore.fetchAllDevices() + #expect(destDevices.count == 1) + + let destContacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(destContacts.count == 1) + } + + // MARK: - Import: radioID remapping + + @Test + func `Import remaps radioID when local device has same publicKey`() async throws { + let sharedPublicKey = Data(repeating: 0xDD, count: 32) + let backupRadioID = UUID() + let localRadioID = UUID() + + // Build a backup envelope with the backup radioID + let backupDevice = DeviceDTO.testDevice( + id: backupRadioID, + radioID: backupRadioID, + publicKey: sharedPublicKey + ) + let backupContact = ContactDTO.testContact( + radioID: backupRadioID, + publicKey: Data(repeating: 0xEE, count: 32) + ) + let backupChannel = ChannelDTO.testChannel(radioID: backupRadioID, index: 1, name: "Remapped") + let backupMessage = MessageDTO.testDirectMessage(radioID: backupRadioID, contactID: backupContact.id) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + contacts: [backupContact], + channels: [backupChannel], + messages: [backupMessage] + ) + + // Create local store with a device using the same publicKey but different radioID + let localStore = try await PersistenceStore.createTestDataStore(radioID: localRadioID) + // Replace the test device with one that has the shared public key + let localDevice = DeviceDTO.testDevice( + id: localRadioID, + radioID: localRadioID, + publicKey: sharedPublicKey + ) + try await localStore.saveDevice(localDevice) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: localStore + ) + + // Backup device should be skipped (matched by publicKey) + #expect(result.devicesInserted == 0) + + // Child records should be inserted with the local radioID + #expect(result.contactsInserted == 1) + #expect(result.channelsInserted == 1) + #expect(result.messagesInserted == 1) + + // Verify the contact was stored with localRadioID, not backupRadioID + let localContacts = try await localStore.fetchAllContacts(radioID: localRadioID) + #expect(localContacts.count == 1) + + // The backup radioID should have no contacts + let backupContacts = try await localStore.fetchAllContacts(radioID: backupRadioID) + #expect(backupContacts.count == 0) + + // Same for channels + let localChannels = try await localStore.fetchAllChannels(radioID: localRadioID) + let remappedChannel = localChannels.first { $0.name == "Remapped" } + #expect(remappedChannel != nil) + } + + // MARK: - Export: device redaction + + @Test + func `Export redacts sensitive device fields (BLE PIN, radio config)`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Override the test device with sensitive values + let sensitiveDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { + $0.blePin = 123_456 + $0.frequency = 869_500 + $0.bandwidth = 125_000 + $0.spreadingFactor = 12 + $0.codingRate = 8 + $0.txPower = 14 + $0.maxTxPower = 22 + $0.latitude = 48.8566 + $0.longitude = 2.3522 + $0.clientRepeat = true + $0.pathHashMode = 2 + $0.preRepeatFrequency = 915_000 + $0.preRepeatBandwidth = 250_000 + $0.preRepeatSpreadingFactor = 10 + $0.preRepeatCodingRate = 5 + $0.autoAddConfig = 0x0F + $0.autoAddMaxHops = 3 + $0.telemetryModeBase = 3 + $0.telemetryModeLoc = 2 + $0.telemetryModeEnv = 1 + $0.advertLocationPolicy = 2 + $0.manualAddContacts = true + $0.multiAcks = 4 + $0.connectionMethods = [ + .bluetooth(peripheralUUID: UUID(), displayName: "BT"), + .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") + ] } - - // MARK: - Import: radioID remapping - - @Test("Import remaps radioID when local device has same publicKey") - func importRemapsRadioID() async throws { - let sharedPublicKey = Data(repeating: 0xDD, count: 32) - let backupRadioID = UUID() - let localRadioID = UUID() - - // Build a backup envelope with the backup radioID - let backupDevice = DeviceDTO.testDevice( - id: backupRadioID, - radioID: backupRadioID, - publicKey: sharedPublicKey - ) - let backupContact = ContactDTO.testContact( - radioID: backupRadioID, - publicKey: Data(repeating: 0xEE, count: 32) - ) - let backupChannel = ChannelDTO.testChannel(radioID: backupRadioID, index: 1, name: "Remapped") - let backupMessage = MessageDTO.testDirectMessage(radioID: backupRadioID, contactID: backupContact.id) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - contacts: [backupContact], - channels: [backupChannel], - messages: [backupMessage] - ) - - // Create local store with a device using the same publicKey but different radioID - let localStore = try await PersistenceStore.createTestDataStore(radioID: localRadioID) - // Replace the test device with one that has the shared public key - let localDevice = DeviceDTO.testDevice( - id: localRadioID, - radioID: localRadioID, - publicKey: sharedPublicKey - ) - try await localStore.saveDevice(localDevice) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: localStore - ) - - // Backup device should be skipped (matched by publicKey) - #expect(result.devicesInserted == 0) - - // Child records should be inserted with the local radioID - #expect(result.contactsInserted == 1) - #expect(result.channelsInserted == 1) - #expect(result.messagesInserted == 1) - - // Verify the contact was stored with localRadioID, not backupRadioID - let localContacts = try await localStore.fetchAllContacts(radioID: localRadioID) - #expect(localContacts.count == 1) - - // The backup radioID should have no contacts - let backupContacts = try await localStore.fetchAllContacts(radioID: backupRadioID) - #expect(backupContacts.count == 0) - - // Same for channels - let localChannels = try await localStore.fetchAllChannels(radioID: localRadioID) - let remappedChannel = localChannels.first { $0.name == "Remapped" } - #expect(remappedChannel != nil) + try await store.saveDevice(sensitiveDevice) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + let envelope = try parseBackup(data: result.data) + + let exported = try #require(envelope.devices.first) + + // Sensitive fields must be reset to Device.init defaults + #expect(exported.blePin == 0) + #expect(exported.frequency == 915_000) + #expect(exported.bandwidth == 250_000) + #expect(exported.spreadingFactor == 10) + #expect(exported.codingRate == 5) + #expect(exported.txPower == 20) + #expect(exported.maxTxPower == 20) + #expect(exported.latitude == 0) + #expect(exported.longitude == 0) + #expect(exported.clientRepeat == false) + #expect(exported.pathHashMode == 0) + #expect(exported.preRepeatFrequency == nil) + #expect(exported.preRepeatBandwidth == nil) + #expect(exported.preRepeatSpreadingFactor == nil) + #expect(exported.preRepeatCodingRate == nil) + #expect(exported.autoAddConfig == 0) + #expect(exported.autoAddMaxHops == 0) + #expect(exported.telemetryModeBase == 2) + #expect(exported.telemetryModeLoc == 0) + #expect(exported.telemetryModeEnv == 0) + #expect(exported.advertLocationPolicy == 0) + + // id is reset so the source phone's CBPeripheral UUID never leaves the device. + #expect(exported.id != radioID) + // WiFi is intentionally retained (needed for restore-then-reconnect) and disclosed in + // the export Security Notice; only Bluetooth is stripped. + #expect(exported.connectionMethods.contains { $0.isWiFi }) + #expect(!exported.connectionMethods.contains { $0.isBluetooth }) + // Radio-config preferences reset to defaults. + #expect(exported.manualAddContacts == Device.Defaults.manualAddContacts) + #expect(exported.multiAcks == Device.Defaults.multiAcks) + } + + @Test + func `Export preserves non-sensitive device fields`() async throws { + let radioID = UUID() + let customPublicKey = Data(repeating: 0xF7, count: 32) + let connectedDate = Date(timeIntervalSince1970: 1_700_000_000) + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let device = DeviceDTO.testDevice( + id: radioID, + radioID: radioID, + publicKey: customPublicKey, + nodeName: "FieldUnit", + firmwareVersion: 11, + firmwareVersionString: "v1.15.0", + lastConnected: connectedDate, + lastContactSync: 42 + ).copy { + $0.ocvPreset = "liIon" + $0.customOCVArrayString = "4200,4100,4000" + $0.connectionMethods = [ + .bluetooth(peripheralUUID: UUID(), displayName: "BT"), + .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") + ] } - - // MARK: - Export: device redaction - - @Test("Export redacts sensitive device fields (BLE PIN, radio config)") - func exportRedactsSensitiveDeviceFields() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Override the test device with sensitive values - let sensitiveDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { - $0.blePin = 123_456 - $0.frequency = 869_500 - $0.bandwidth = 125_000 - $0.spreadingFactor = 12 - $0.codingRate = 8 - $0.txPower = 14 - $0.maxTxPower = 22 - $0.latitude = 48.8566 - $0.longitude = 2.3522 - $0.clientRepeat = true - $0.pathHashMode = 2 - $0.preRepeatFrequency = 915_000 - $0.preRepeatBandwidth = 250_000 - $0.preRepeatSpreadingFactor = 10 - $0.preRepeatCodingRate = 5 - $0.autoAddConfig = 0x0F - $0.autoAddMaxHops = 3 - $0.telemetryModeBase = 3 - $0.telemetryModeLoc = 2 - $0.telemetryModeEnv = 1 - $0.advertLocationPolicy = 2 - $0.manualAddContacts = true - $0.multiAcks = 4 - $0.connectionMethods = [ - .bluetooth(peripheralUUID: UUID(), displayName: "BT"), - .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") - ] - } - try await store.saveDevice(sensitiveDevice) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - let envelope = try parseBackup(data: result.data) - - let exported = try #require(envelope.devices.first) - - // Sensitive fields must be reset to Device.init defaults - #expect(exported.blePin == 0) - #expect(exported.frequency == 915_000) - #expect(exported.bandwidth == 250_000) - #expect(exported.spreadingFactor == 10) - #expect(exported.codingRate == 5) - #expect(exported.txPower == 20) - #expect(exported.maxTxPower == 20) - #expect(exported.latitude == 0) - #expect(exported.longitude == 0) - #expect(exported.clientRepeat == false) - #expect(exported.pathHashMode == 0) - #expect(exported.preRepeatFrequency == nil) - #expect(exported.preRepeatBandwidth == nil) - #expect(exported.preRepeatSpreadingFactor == nil) - #expect(exported.preRepeatCodingRate == nil) - #expect(exported.autoAddConfig == 0) - #expect(exported.autoAddMaxHops == 0) - #expect(exported.telemetryModeBase == 2) - #expect(exported.telemetryModeLoc == 0) - #expect(exported.telemetryModeEnv == 0) - #expect(exported.advertLocationPolicy == 0) - - // id is reset so the source phone's CBPeripheral UUID never leaves the device. - #expect(exported.id != radioID) - // WiFi is intentionally retained (needed for restore-then-reconnect) and disclosed in - // the export Security Notice; only Bluetooth is stripped. - #expect(exported.connectionMethods.contains { $0.isWiFi }) - #expect(!exported.connectionMethods.contains { $0.isBluetooth }) - // Radio-config preferences reset to defaults. - #expect(exported.manualAddContacts == Device.Defaults.manualAddContacts) - #expect(exported.multiAcks == Device.Defaults.multiAcks) + try await store.saveDevice(device) + + // knownRegions is owned by the targeted add path, not saveDevice's overwrite. + try await store.addDeviceKnownRegion(radioID: radioID, region: "US") + try await store.addDeviceKnownRegion(radioID: radioID, region: "EU") + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + let envelope = try parseBackup(data: result.data) + + let exported = try #require(envelope.devices.first) + + #expect(exported.publicKey == customPublicKey) + #expect(exported.nodeName == "FieldUnit") + #expect(exported.firmwareVersionString == "v1.15.0") + #expect(exported.lastConnected == connectedDate) + #expect(exported.lastContactSync == 42) + #expect(exported.ocvPreset == "liIon") + #expect(exported.customOCVArrayString == "4200,4100,4000") + #expect(exported.connectionMethods.count == 1) + #expect(exported.connectionMethods.first?.isWiFi == true) + #expect(exported.knownRegions == ["US", "EU"]) + } + + @Test + func `Export strips Bluetooth connection methods`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { + $0.connectionMethods = [.bluetooth(peripheralUUID: UUID(), displayName: "BT")] } - - @Test("Export preserves non-sensitive device fields") - func exportPreservesNonSensitiveDeviceFields() async throws { - let radioID = UUID() - let customPublicKey = Data(repeating: 0xF7, count: 32) - let connectedDate = Date(timeIntervalSince1970: 1_700_000_000) - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let device = DeviceDTO.testDevice( - id: radioID, - radioID: radioID, - publicKey: customPublicKey, - nodeName: "FieldUnit", - firmwareVersion: 11, - firmwareVersionString: "v1.15.0", - lastConnected: connectedDate, - lastContactSync: 42 - ).copy { - $0.ocvPreset = "liIon" - $0.customOCVArrayString = "4200,4100,4000" - $0.connectionMethods = [ - .bluetooth(peripheralUUID: UUID(), displayName: "BT"), - .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") - ] - } - try await store.saveDevice(device) - - // knownRegions is owned by the targeted add path, not saveDevice's overwrite. - try await store.addDeviceKnownRegion(radioID: radioID, region: "US") - try await store.addDeviceKnownRegion(radioID: radioID, region: "EU") - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - let envelope = try parseBackup(data: result.data) - - let exported = try #require(envelope.devices.first) - - #expect(exported.publicKey == customPublicKey) - #expect(exported.nodeName == "FieldUnit") - #expect(exported.firmwareVersionString == "v1.15.0") - #expect(exported.lastConnected == connectedDate) - #expect(exported.lastContactSync == 42) - #expect(exported.ocvPreset == "liIon") - #expect(exported.customOCVArrayString == "4200,4100,4000") - #expect(exported.connectionMethods.count == 1) - #expect(exported.connectionMethods.first?.isWiFi == true) - #expect(exported.knownRegions == ["US", "EU"]) - } - - @Test("Export strips Bluetooth connection methods") - func exportStripsBluetoothConnectionMethods() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { - $0.connectionMethods = [.bluetooth(peripheralUUID: UUID(), displayName: "BT")] - } - try await store.saveDevice(device) - - let result = try await AppBackupService().export(persistenceStore: store) - let envelope = try parseBackup(data: result.data) - - let exported = try #require(envelope.devices.first) - #expect(exported.connectionMethods.isEmpty) - } - - @Test("Import strips Bluetooth connection methods from legacy backups") - func importStripsBluetoothConnectionMethodsFromLegacyBackups() async throws { - let radioID = UUID() - let localStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Simulate a legacy backup where the export-side filter did not yet - // strip BLE methods — the importer must still remove them so the - // restored row isn't keyed to a nonexistent local peripheral. - let legacyBackupDevice = DeviceDTO.testDevice( - id: UUID(), - publicKey: Data(repeating: 0xAB, count: 32) - ).copy { - $0.connectionMethods = [ - .bluetooth(peripheralUUID: UUID(), displayName: "BT"), - .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") - ] - } - let envelope = AppBackupEnvelope.test(devices: [legacyBackupDevice]) - - _ = try await localStore.importBackupDatabase(envelope) - - let localDevices = try await localStore.fetchAllDevices() - let restored = try #require(localDevices.first { $0.publicKey == legacyBackupDevice.publicKey }) - #expect(restored.connectionMethods.count == 1) - #expect(restored.connectionMethods.first?.isWiFi == true) + try await store.saveDevice(device) + + let result = try await AppBackupService().export(persistenceStore: store) + let envelope = try parseBackup(data: result.data) + + let exported = try #require(envelope.devices.first) + #expect(exported.connectionMethods.isEmpty) + } + + @Test + func `Import strips Bluetooth connection methods from legacy backups`() async throws { + let radioID = UUID() + let localStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Simulate a legacy backup where the export-side filter did not yet + // strip BLE methods — the importer must still remove them so the + // restored row isn't keyed to a nonexistent local peripheral. + let legacyBackupDevice = DeviceDTO.testDevice( + id: UUID(), + publicKey: Data(repeating: 0xAB, count: 32) + ).copy { + $0.connectionMethods = [ + .bluetooth(peripheralUUID: UUID(), displayName: "BT"), + .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") + ] } - - @Test("Redacted device round-trips through export and import with safe defaults") - func redactedDeviceRoundTripsWithSafeDefaults() async throws { - let radioID = UUID() - let customPublicKey = Data(repeating: 0xF8, count: 32) - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let sensitiveDevice = DeviceDTO.testDevice( - id: radioID, - radioID: radioID, - publicKey: customPublicKey - ).copy { - $0.blePin = 999_999 - $0.frequency = 433_000 - $0.txPower = 10 - } - try await sourceStore.saveDevice(sensitiveDevice) - - // Export - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - // Import into a fresh store (unmatched device — will be inserted) - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.devicesInserted == 1) - - // Verify the imported device has redacted values, not the originals - let imported = try #require(await destStore.fetchAllDevices().first) - #expect(imported.blePin == 0) - #expect(imported.frequency == 915_000) - #expect(imported.txPower == 20) - // Identity preserved - #expect(imported.publicKey == customPublicKey) - } - - // MARK: - Import: idempotent - - @Test("Importing same backup twice results in zero inserts on second pass") - func importIsIdempotent() async throws { - let radioID = UUID() - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID, publicKey: Data(repeating: 0x02, count: 32)) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xCC, count: 32) - ) - // Messages must have a deduplicationKey to be detected as duplicates - var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - msg.deduplicationKey = "test-dedup-key-1" - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [msg] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let service = AppBackupService() - - let firstResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - #expect(firstResult.devicesInserted == 1) - #expect(firstResult.contactsInserted == 1) - #expect(firstResult.messagesInserted == 1) - - // Import the same backup again - let secondResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(secondResult.devicesInserted == 0) - #expect(secondResult.contactsInserted == 0) - #expect(secondResult.messagesInserted == 0) - #expect(secondResult.totalInserted == 0) - } - - // MARK: - Export: ExportResult - - @Test("Export returns ExportResult carrying the envelope manifest") - func exportReturnsManifest() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xBB, count: 32)) - try await store.saveContact(contact) - - let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - try await store.saveMessage(message) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - #expect(result.data.isEmpty == false) - #expect(result.manifest.contactCount == 1) - #expect(result.manifest.messageCount == 1) - #expect(result.manifest.count(for: .contacts) == 1) - #expect(result.manifest.count(for: .messages) == 1) - } - - // MARK: - Import: Device.id collision does not overwrite local row - - @Test("Import assigns fresh Device.id so an id collision cannot upsert the local row") - func importAssignsFreshDeviceIDOnCollision() async throws { - // Simulates radio identity rotation: the backup's DeviceDTO carries a UUID - // that happens to equal the current local peripheral's UUID (iOS reuses the - // same CBPeripheral.identifier after a firmware pubKey change). Without the - // fix, SwiftData's upsert on `@Attribute(.unique) Device.id` would silently - // overwrite the local row's publicKey; with the fix, the backup row is - // inserted with a fresh id alongside the existing local row. - let collidingID = UUID() - let oldPublicKey = Data(repeating: 0xA0, count: 32) - let newPublicKey = Data(repeating: 0xB0, count: 32) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: collidingID) - let localDevice = DeviceDTO.testDevice( - id: collidingID, - radioID: collidingID, - publicKey: newPublicKey - ) - try await destStore.saveDevice(localDevice) - - let backupDevice = DeviceDTO.testDevice( - id: collidingID, - radioID: UUID(), - publicKey: oldPublicKey - ) - let envelope = AppBackupEnvelope.test(devices: [backupDevice]) - - let result = try await AppBackupService().importBackup( - envelope: envelope, - into: destStore - ) - #expect(result.devicesInserted == 1) - - let stored = try await destStore.fetchAllDevices() - #expect(stored.count == 2) - - // The original local device is still there, with its original pubKey intact. - let localAfter = try #require(stored.first { $0.publicKey == newPublicKey }) - #expect(localAfter.id == collidingID) - - // The backup landed as a separate row with a fresh id. - let backupAfter = try #require(stored.first { $0.publicKey == oldPublicKey }) - #expect(backupAfter.id != collidingID) + let envelope = AppBackupEnvelope.test(devices: [legacyBackupDevice]) + + _ = try await localStore.importBackupDatabase(envelope) + + let localDevices = try await localStore.fetchAllDevices() + let restored = try #require(localDevices.first { $0.publicKey == legacyBackupDevice.publicKey }) + #expect(restored.connectionMethods.count == 1) + #expect(restored.connectionMethods.first?.isWiFi == true) + } + + @Test + func `Redacted device round-trips through export and import with safe defaults`() async throws { + let radioID = UUID() + let customPublicKey = Data(repeating: 0xF8, count: 32) + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let sensitiveDevice = DeviceDTO.testDevice( + id: radioID, + radioID: radioID, + publicKey: customPublicKey + ).copy { + $0.blePin = 999_999 + $0.frequency = 433_000 + $0.txPower = 10 } + try await sourceStore.saveDevice(sensitiveDevice) + + // Export + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + // Import into a fresh store (unmatched device — will be inserted) + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.devicesInserted == 1) + + // Verify the imported device has redacted values, not the originals + let imported = try #require(await destStore.fetchAllDevices().first) + #expect(imported.blePin == 0) + #expect(imported.frequency == 915_000) + #expect(imported.txPower == 20) + // Identity preserved + #expect(imported.publicKey == customPublicKey) + } + + // MARK: - Import: idempotent + + @Test + func `Importing same backup twice results in zero inserts on second pass`() async throws { + let radioID = UUID() + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID, publicKey: Data(repeating: 0x02, count: 32)) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xCC, count: 32) + ) + // Messages must have a deduplicationKey to be detected as duplicates + var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + msg.deduplicationKey = "test-dedup-key-1" + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [msg] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let service = AppBackupService() + + let firstResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + #expect(firstResult.devicesInserted == 1) + #expect(firstResult.contactsInserted == 1) + #expect(firstResult.messagesInserted == 1) + + // Import the same backup again + let secondResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(secondResult.devicesInserted == 0) + #expect(secondResult.contactsInserted == 0) + #expect(secondResult.messagesInserted == 0) + #expect(secondResult.totalInserted == 0) + } + + // MARK: - Export: ExportResult + + @Test + func `Export returns ExportResult carrying the envelope manifest`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xBB, count: 32)) + try await store.saveContact(contact) + + let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + try await store.saveMessage(message) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + #expect(result.data.isEmpty == false) + #expect(result.manifest.contactCount == 1) + #expect(result.manifest.messageCount == 1) + #expect(result.manifest.count(for: .contacts) == 1) + #expect(result.manifest.count(for: .messages) == 1) + } + + // MARK: - Import: Device.id collision does not overwrite local row + + @Test + func `Import assigns fresh Device.id so an id collision cannot upsert the local row`() async throws { + // Simulates radio identity rotation: the backup's DeviceDTO carries a UUID + // that happens to equal the current local peripheral's UUID (iOS reuses the + // same CBPeripheral.identifier after a firmware pubKey change). Without the + // fix, SwiftData's upsert on `@Attribute(.unique) Device.id` would silently + // overwrite the local row's publicKey; with the fix, the backup row is + // inserted with a fresh id alongside the existing local row. + let collidingID = UUID() + let oldPublicKey = Data(repeating: 0xA0, count: 32) + let newPublicKey = Data(repeating: 0xB0, count: 32) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: collidingID) + let localDevice = DeviceDTO.testDevice( + id: collidingID, + radioID: collidingID, + publicKey: newPublicKey + ) + try await destStore.saveDevice(localDevice) + + let backupDevice = DeviceDTO.testDevice( + id: collidingID, + radioID: UUID(), + publicKey: oldPublicKey + ) + let envelope = AppBackupEnvelope.test(devices: [backupDevice]) + + let result = try await AppBackupService().importBackup( + envelope: envelope, + into: destStore + ) + #expect(result.devicesInserted == 1) + + let stored = try await destStore.fetchAllDevices() + #expect(stored.count == 2) + + // The original local device is still there, with its original pubKey intact. + let localAfter = try #require(stored.first { $0.publicKey == newPublicKey }) + #expect(localAfter.id == collidingID) + + // The backup landed as a separate row with a fresh id. + let backupAfter = try #require(stored.first { $0.publicKey == oldPublicKey }) + #expect(backupAfter.id != collidingID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BLEReconnectionCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/BLEReconnectionCoordinatorTests.swift index d79b694a..2254395a 100644 --- a/MC1Services/Tests/MC1ServicesTests/BLEReconnectionCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BLEReconnectionCoordinatorTests.swift @@ -1,572 +1,604 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("BLEReconnectionCoordinator Tests") @MainActor struct BLEReconnectionCoordinatorTests { + // MARK: - Test Helpers + + private func createCoordinator( + delegate: MockReconnectionDelegate? = nil, + uiTimeoutDuration: TimeInterval = 10, + maxConnectingUIWindow: TimeInterval = 60 + ) -> (BLEReconnectionCoordinator, MockReconnectionDelegate) { + let coordinator = BLEReconnectionCoordinator( + uiTimeoutDuration: uiTimeoutDuration, + maxConnectingUIWindow: maxConnectingUIWindow + ) + let mockDelegate = delegate ?? MockReconnectionDelegate() + coordinator.delegate = mockDelegate + return (coordinator, mockDelegate) + } + + // MARK: - handleEnteringAutoReconnect Tests - // MARK: - Test Helpers - - private func createCoordinator( - delegate: MockReconnectionDelegate? = nil, - uiTimeoutDuration: TimeInterval = 10, - maxConnectingUIWindow: TimeInterval = 60 - ) -> (BLEReconnectionCoordinator, MockReconnectionDelegate) { - let coordinator = BLEReconnectionCoordinator( - uiTimeoutDuration: uiTimeoutDuration, - maxConnectingUIWindow: maxConnectingUIWindow - ) - let mockDelegate = delegate ?? MockReconnectionDelegate() - coordinator.delegate = mockDelegate - return (coordinator, mockDelegate) - } + @Test + func `entering auto-reconnect sets state to .connecting when user wants connection`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - // MARK: - handleEnteringAutoReconnect Tests + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - @Test("entering auto-reconnect sets state to .connecting when user wants connection") - func enteringAutoReconnectSetsConnecting() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + #expect(delegate.connectionState == .connecting) + } - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + @Test + func `entering auto-reconnect tears down session`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() - #expect(delegate.connectionState == .connecting) - } + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + + #expect(delegate.teardownSessionCallCount == 1) + } - @Test("entering auto-reconnect tears down session") - func enteringAutoReconnectTearsDownSession() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() + @Test + func `entering auto-reconnect notifies the loss before tearing down the session`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + + #expect(delegate.notifyAutoReconnectStartedCallCount == 1) + #expect(delegate.callOrder == ["notifyAutoReconnectStarted", "teardown"]) + } + + @Test + func `entering auto-reconnect does not notify the loss when the user disconnected`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .userDisconnected + delegate.connectionState = .disconnected + + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + + #expect(delegate.notifyAutoReconnectStartedCallCount == 0) + } + + @Test + func `entering auto-reconnect is ignored when intent is .userDisconnected`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .userDisconnected + delegate.connectionState = .disconnected + + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + + #expect(delegate.connectionState == .disconnected, "State should not change when user disconnected") + #expect(delegate.teardownSessionCallCount == 0, "Session should not be torn down") + #expect(delegate.disconnectTransportCallCount == 1, "Transport should be disconnected") + } + + @Test + func `entering auto-reconnect is ignored when intent is .none`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .none + delegate.connectionState = .disconnected + + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + + #expect(delegate.connectionState == .disconnected) + #expect(delegate.disconnectTransportCallCount == 1) + } + + // MARK: - handleReconnectionComplete Tests + + // + // The coordinator only accepts a completion for a cycle it explicitly claimed + // via handleEnteringAutoReconnect. Each happy-path test below claims first to + // mirror the production wiring (setAutoReconnectingHandler always fires before + // setReconnectionHandler for the same cycle). + + @Test + func `reconnection complete keeps state .connecting when claim matches`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + + await coordinator.handleReconnectionComplete(deviceID: deviceID) + + #expect(delegate.connectionState == .connecting) + } + + @Test + func `reconnection complete calls rebuildSession when claim matches`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.teardownSessionCallCount == 1) - } + await coordinator.handleReconnectionComplete(deviceID: deviceID) + + #expect(delegate.rebuildSessionCalls.count == 1) + #expect(delegate.rebuildSessionCalls.first == deviceID) + } - @Test("entering auto-reconnect is ignored when intent is .userDisconnected") - func enteringAutoReconnectIgnoredForUserDisconnected() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .userDisconnected - delegate.connectionState = .disconnected + @Test + func `reconnection complete is ignored when no entry was claimed`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .disconnected - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + // No prior handleEnteringAutoReconnect — claim is nil. This mirrors the + // pairing race where the entry handler was suppressed but a late completion + // still arrives. + await coordinator.handleReconnectionComplete(deviceID: UUID()) - #expect(delegate.connectionState == .disconnected, "State should not change when user disconnected") - #expect(delegate.teardownSessionCallCount == 0, "Session should not be torn down") - #expect(delegate.disconnectTransportCallCount == 1, "Transport should be disconnected") - } + #expect(delegate.connectionState == .disconnected, "Should not transition without claim") + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild without claim") + } - @Test("entering auto-reconnect is ignored when intent is .none") - func enteringAutoReconnectIgnoredForNone() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .none - delegate.connectionState = .disconnected + @Test + func `reconnection complete is ignored when intent is .userDisconnected`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + // Now the user disconnects mid-reconnect. + delegate.connectionIntent = .userDisconnected - #expect(delegate.connectionState == .disconnected) - #expect(delegate.disconnectTransportCallCount == 1) - } + await coordinator.handleReconnectionComplete(deviceID: deviceID) + + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when user disconnected") + #expect(delegate.disconnectTransportCallCount == 1, "Should disconnect transport") + } - // MARK: - handleReconnectionComplete Tests - // - // The coordinator only accepts a completion for a cycle it explicitly claimed - // via handleEnteringAutoReconnect. Each happy-path test below claims first to - // mirror the production wiring (setAutoReconnectingHandler always fires before - // setReconnectionHandler for the same cycle). + @Test + func `reconnection complete is ignored when already .ready`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - @Test("reconnection complete keeps state .connecting when claim matches") - func reconnectionCompleteKeepsConnectingWhenClaimed() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + // Force state back to .ready (e.g., a parallel resync promoted the session). + delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + await coordinator.handleReconnectionComplete(deviceID: deviceID) - await coordinator.handleReconnectionComplete(deviceID: deviceID) + #expect(delegate.connectionState == .ready, "Should not change state when already ready") + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when already ready") + } - #expect(delegate.connectionState == .connecting) - } + @Test + func `reconnection complete is ignored when .syncing (session alive, resync running)`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - @Test("reconnection complete calls rebuildSession when claim matches") - func reconnectionCompleteCallsRebuild() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + // Simulate a parallel resync promoting the session to .syncing. + delegate.connectionState = .syncing + + await coordinator.handleReconnectionComplete(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + #expect(delegate.connectionState == .syncing, "Should not change state when syncing") + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when syncing") + } + + @Test + func `reconnection complete handles rebuild failure`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.rebuildSessionShouldThrow = true - await coordinator.handleReconnectionComplete(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + await coordinator.handleReconnectionComplete(deviceID: deviceID) - #expect(delegate.rebuildSessionCalls.count == 1) - #expect(delegate.rebuildSessionCalls.first == deviceID) - } + #expect(delegate.handleReconnectionFailureCallCount == 1) + } - @Test("reconnection complete is ignored when no entry was claimed") - func reconnectionCompleteIgnoredWithoutClaim() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .disconnected + @Test + func `stale device completion does not cancel active timeout`() async throws { + let activeDevice = UUID() + let staleDevice = UUID() + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - // No prior handleEnteringAutoReconnect — claim is nil. This mirrors the - // pairing race where the entry handler was suppressed but a late completion - // still arrives. - await coordinator.handleReconnectionComplete(deviceID: UUID()) + await coordinator.handleEnteringAutoReconnect(deviceID: activeDevice) + #expect(delegate.connectionState == .connecting) - #expect(delegate.connectionState == .disconnected, "Should not transition without claim") - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild without claim") - } + // Stale completion for a different device should be rejected + await coordinator.handleReconnectionComplete(deviceID: staleDevice) + + // Timeout should still fire because it was not canceled + try await waitUntil("Timeout should still fire after stale completion") { + delegate.connectionState == .disconnected + } + + #expect(delegate.connectionState == .disconnected, "Timeout should still fire after stale completion") + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild for stale device") + } - @Test("reconnection complete is ignored when intent is .userDisconnected") - func reconnectionCompleteIgnoredForUserDisconnected() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + // MARK: - UI Timeout Tests - // Now the user disconnects mid-reconnect. - delegate.connectionIntent = .userDisconnected + @Test + func `UI timeout transitions to disconnected after duration`() async throws { + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - await coordinator.handleReconnectionComplete(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + #expect(delegate.connectionState == .connecting) + + // Wait for timeout to fire and transition state + try await waitUntil("Timeout should transition to disconnected") { + delegate.connectionState == .disconnected + } + + #expect(delegate.connectionState == .disconnected) + #expect(delegate.connectedDeviceWasCleared == true) + #expect(delegate.notifyConnectionLostCallCount == 1) + } - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when user disconnected") - #expect(delegate.disconnectTransportCallCount == 1, "Should disconnect transport") - } + @Test + func `UI timeout is cancelled when reconnection completes`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - @Test("reconnection complete is ignored when already .ready") - func reconnectionCompleteIgnoredWhenReady() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + #expect(delegate.connectionState == .connecting) - // Force state back to .ready (e.g., a parallel resync promoted the session). - delegate.connectionState = .ready + // Complete reconnection before timeout (same device) + await coordinator.handleReconnectionComplete(deviceID: deviceID) - await coordinator.handleReconnectionComplete(deviceID: deviceID) + // Fixed sleep: negative assertion — confirm timeout did NOT fire + try await Task.sleep(for: .milliseconds(250)) - #expect(delegate.connectionState == .ready, "Should not change state when already ready") - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when already ready") - } + // Should be .connecting from reconnection complete, not .disconnected from timeout + #expect(delegate.connectionState == .connecting) + #expect(delegate.notifyConnectionLostCallCount == 0) + } - @Test("reconnection complete is ignored when .syncing (session alive, resync running)") - func reconnectionCompleteIgnoredWhenSyncing() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + // MARK: - Stale Retry Tests - // Simulate a parallel resync promoting the session to .syncing. - delegate.connectionState = .syncing + @Test + func `stale rebuild retry is aborted when new reconnect cycle starts during delay`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.rebuildSessionShouldThrow = true - await coordinator.handleReconnectionComplete(deviceID: deviceID) + // Claim the first cycle so the completion is accepted. + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.connectionState == .syncing, "Should not change state when syncing") - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when syncing") + // Start first reconnection — rebuild will fail, triggering 2s retry delay + let firstReconnectTask = Task { + await coordinator.handleReconnectionComplete(deviceID: deviceID) } - @Test("reconnection complete handles rebuild failure") - func reconnectionCompleteHandlesRebuildFailure() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.rebuildSessionShouldThrow = true - - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - await coordinator.handleReconnectionComplete(deviceID: deviceID) - - #expect(delegate.handleReconnectionFailureCallCount == 1) + // Wait for first rebuild to fail and enter the 2s retry delay + try await waitUntil("First rebuild should have been attempted") { + delegate.rebuildSessionCalls.count == 1 } + #expect(delegate.rebuildSessionCalls.count == 1, "First rebuild should have been attempted") - @Test("stale device completion does not cancel active timeout") - func staleDeviceDoesNotCancelTimeout() async throws { - let activeDevice = UUID() - let staleDevice = UUID() - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - - await coordinator.handleEnteringAutoReconnect(deviceID: activeDevice) - #expect(delegate.connectionState == .connecting) + // Start a new reconnect cycle during the delay — this bumps the generation counter. + // Re-claim the cycle since the prior completion cleared reconnectingDeviceID. + delegate.rebuildSessionShouldThrow = false + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + await coordinator.handleReconnectionComplete(deviceID: deviceID) - // Stale completion for a different device should be rejected - await coordinator.handleReconnectionComplete(deviceID: staleDevice) - - // Timeout should still fire because it was not canceled - try await waitUntil("Timeout should still fire after stale completion") { - delegate.connectionState == .disconnected - } - - #expect(delegate.connectionState == .disconnected, "Timeout should still fire after stale completion") - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild for stale device") - } + // Wait for the first task's stale retry to wake and be aborted + await firstReconnectTask.value - // MARK: - UI Timeout Tests + // Should have exactly 2 rebuild calls: first (failed) + new cycle (succeeded). + // The stale retry should have been aborted by the generation check. + #expect(delegate.rebuildSessionCalls.count == 2, "Stale retry should have been aborted") + #expect(delegate.handleReconnectionFailureCallCount == 0, "No failure handler since new cycle succeeded") + } - @Test("UI timeout transitions to disconnected after duration") - func uiTimeoutTransitionsToDisconnected() async throws { - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + // MARK: - Max Connecting Window Tests - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - #expect(delegate.connectionState == .connecting) + @Test + func `UI timeout disconnects when max connecting window exceeded`() async throws { + let (coordinator, delegate) = createCoordinator( + uiTimeoutDuration: 0.05, + maxConnectingUIWindow: 0.15 + ) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - // Wait for timeout to fire and transition state - try await waitUntil("Timeout should transition to disconnected") { - delegate.connectionState == .disconnected - } + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - #expect(delegate.connectionState == .disconnected) - #expect(delegate.connectedDeviceWasCleared == true) - #expect(delegate.notifyConnectionLostCallCount == 1) + // Wait for max connecting window to expire and disconnect + try await waitUntil("Max connecting window should trigger disconnect") { + delegate.connectionState == .disconnected } - @Test("UI timeout is cancelled when reconnection completes") - func uiTimeoutCancelledOnReconnection() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + #expect(delegate.connectionState == .disconnected) + #expect(delegate.notifyConnectionLostCallCount == 1) + } - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.connectionState == .connecting) + @Test + func `same-device completion is accepted after UI timeout while transport auto-reconnects`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator( + uiTimeoutDuration: 0.05, + maxConnectingUIWindow: 0.15 + ) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - // Complete reconnection before timeout (same device) - await coordinator.handleReconnectionComplete(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - // Fixed sleep: negative assertion — confirm timeout did NOT fire - try await Task.sleep(for: .milliseconds(250)) - - // Should be .connecting from reconnection complete, not .disconnected from timeout - #expect(delegate.connectionState == .connecting) - #expect(delegate.notifyConnectionLostCallCount == 0) - } - - // MARK: - Stale Retry Tests - - @Test("stale rebuild retry is aborted when new reconnect cycle starts during delay") - func staleRetryAbortedOnNewCycle() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.rebuildSessionShouldThrow = true - - // Claim the first cycle so the completion is accepted. - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - // Start first reconnection — rebuild will fail, triggering 2s retry delay - let firstReconnectTask = Task { - await coordinator.handleReconnectionComplete(deviceID: deviceID) - } - - // Wait for first rebuild to fail and enter the 2s retry delay - try await waitUntil("First rebuild should have been attempted") { - delegate.rebuildSessionCalls.count == 1 - } - #expect(delegate.rebuildSessionCalls.count == 1, "First rebuild should have been attempted") - - // Start a new reconnect cycle during the delay — this bumps the generation counter. - // Re-claim the cycle since the prior completion cleared reconnectingDeviceID. - delegate.rebuildSessionShouldThrow = false - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - await coordinator.handleReconnectionComplete(deviceID: deviceID) - - // Wait for the first task's stale retry to wake and be aborted - await firstReconnectTask.value - - // Should have exactly 2 rebuild calls: first (failed) + new cycle (succeeded). - // The stale retry should have been aborted by the generation check. - #expect(delegate.rebuildSessionCalls.count == 2, "Stale retry should have been aborted") - #expect(delegate.handleReconnectionFailureCallCount == 0, "No failure handler since new cycle succeeded") + try await waitUntil("UI timeout should transition presentation to disconnected") { + delegate.connectionState == .disconnected } - // MARK: - Max Connecting Window Tests + await coordinator.handleReconnectionComplete(deviceID: deviceID) - @Test("UI timeout disconnects when max connecting window exceeded") - func uiTimeoutDisconnectsAtMaxWindow() async throws { - let (coordinator, delegate) = createCoordinator( - uiTimeoutDuration: 0.05, - maxConnectingUIWindow: 0.15 - ) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + #expect(delegate.rebuildSessionCalls == [deviceID]) + #expect(delegate.connectionState == .connecting) + } - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + @Test + func `UI timeout clears cycle when transport stops auto-reconnecting`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = false - // Wait for max connecting window to expire and disconnect - try await waitUntil("Max connecting window should trigger disconnect") { - delegate.connectionState == .disconnected - } + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.connectionState == .disconnected) - #expect(delegate.notifyConnectionLostCallCount == 1) + try await waitUntil("UI timeout should transition to disconnected") { + delegate.connectionState == .disconnected } - @Test("same-device completion is accepted after UI timeout while transport auto-reconnects") - func sameDeviceCompletionAcceptedAfterUITimeoutWhileAutoReconnecting() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator( - uiTimeoutDuration: 0.05, - maxConnectingUIWindow: 0.15 - ) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + await coordinator.handleReconnectionComplete(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + #expect(delegate.rebuildSessionCalls.isEmpty) + } - try await waitUntil("UI timeout should transition presentation to disconnected") { - delegate.connectionState == .disconnected - } + @Test + func `different-device completion is rejected after UI timeout`() async throws { + let activeDeviceID = UUID() + let staleDeviceID = UUID() + let (coordinator, delegate) = createCoordinator( + uiTimeoutDuration: 0.05, + maxConnectingUIWindow: 0.15 + ) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - await coordinator.handleReconnectionComplete(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: activeDeviceID) - #expect(delegate.rebuildSessionCalls == [deviceID]) - #expect(delegate.connectionState == .connecting) + try await waitUntil("UI timeout should transition presentation to disconnected") { + delegate.connectionState == .disconnected } - @Test("UI timeout clears cycle when transport stops auto-reconnecting") - func uiTimeoutClearsCycleWhenTransportStopsAutoReconnecting() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = false + await coordinator.handleReconnectionComplete(deviceID: staleDeviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + #expect(delegate.rebuildSessionCalls.isEmpty) + #expect(delegate.connectionState == .disconnected) + } - try await waitUntil("UI timeout should transition to disconnected") { - delegate.connectionState == .disconnected - } + @Test + func `user-disconnected completion after UI timeout remains rejected`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator( + uiTimeoutDuration: 0.05, + maxConnectingUIWindow: 0.15 + ) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - await coordinator.handleReconnectionComplete(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.rebuildSessionCalls.isEmpty) + try await waitUntil("UI timeout should transition presentation to disconnected") { + delegate.connectionState == .disconnected } - @Test("different-device completion is rejected after UI timeout") - func differentDeviceCompletionRejectedAfterUITimeout() async throws { - let activeDeviceID = UUID() - let staleDeviceID = UUID() - let (coordinator, delegate) = createCoordinator( - uiTimeoutDuration: 0.05, - maxConnectingUIWindow: 0.15 - ) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + delegate.connectionIntent = .userDisconnected + await coordinator.handleReconnectionComplete(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: activeDeviceID) + #expect(delegate.rebuildSessionCalls.isEmpty) + #expect(delegate.disconnectTransportCallCount == 1) + } - try await waitUntil("UI timeout should transition presentation to disconnected") { - delegate.connectionState == .disconnected - } + // MARK: - cancelTimeout Tests - await coordinator.handleReconnectionComplete(deviceID: staleDeviceID) + @Test + func `UI timeout re-arms if BLE is still auto-reconnecting`() async throws { + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - #expect(delegate.rebuildSessionCalls.isEmpty) - #expect(delegate.connectionState == .disconnected) - } + let deviceID = UUID() + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - @Test("user-disconnected completion after UI timeout remains rejected") - func userDisconnectedCompletionAfterUITimeoutRejected() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator( - uiTimeoutDuration: 0.05, - maxConnectingUIWindow: 0.15 - ) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + // Fixed sleep: negative assertion — confirm re-arm keeps state as .connecting + try await Task.sleep(for: .milliseconds(200)) - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + // Should still be .connecting because BLE is auto-reconnecting + #expect(delegate.connectionState == .connecting) + #expect(delegate.notifyConnectionLostCallCount == 0) + } - try await waitUntil("UI timeout should transition presentation to disconnected") { - delegate.connectionState == .disconnected - } + @Test + func `UI timeout eventually disconnects when max window exceeded`() async throws { + // Use a very short maxConnectingUIWindow via a coordinator with short timeout + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - delegate.connectionIntent = .userDisconnected - await coordinator.handleReconnectionComplete(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - #expect(delegate.rebuildSessionCalls.isEmpty) - #expect(delegate.disconnectTransportCallCount == 1) - } + // Fixed sleep: negative assertion — within the 60s max window, re-arm + // should keep the state as .connecting. We can't wait 60s in a test, so + // verify the re-arm mechanism works within a short window. + try await Task.sleep(for: .milliseconds(200)) - // MARK: - cancelTimeout Tests + // Within the 60s window, should still be .connecting + #expect(delegate.connectionState == .connecting) + } - @Test("UI timeout re-arms if BLE is still auto-reconnecting") - func uiTimeoutRearmsWhenAutoReconnecting() async throws { - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + @Test + func `UI timeout fires normally when BLE is not auto-reconnecting`() async throws { + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = false - let deviceID = UUID() - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - // Fixed sleep: negative assertion — confirm re-arm keeps state as .connecting - try await Task.sleep(for: .milliseconds(200)) - - // Should still be .connecting because BLE is auto-reconnecting - #expect(delegate.connectionState == .connecting) - #expect(delegate.notifyConnectionLostCallCount == 0) + try await waitUntil("Timeout should fire when BLE is not auto-reconnecting") { + delegate.connectionState == .disconnected } - @Test("UI timeout eventually disconnects when max window exceeded") - func uiTimeoutEventuallyDisconnects() async throws { - // Use a very short maxConnectingUIWindow via a coordinator with short timeout - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + #expect(delegate.connectionState == .disconnected) + #expect(delegate.notifyConnectionLostCallCount == 1) + } - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + @Test + func `UI timeout aborts when reconnection completes during its transport query`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = false - // Fixed sleep: negative assertion — within the 60s max window, re-arm - // should keep the state as .connecting. We can't wait 60s in a test, so - // verify the re-arm mechanism works within a short window. - try await Task.sleep(for: .milliseconds(200)) + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - // Within the 60s window, should still be .connecting - #expect(delegate.connectionState == .connecting) + // Run handleReconnectionComplete while the timeout body is suspended on + // isTransportAutoReconnecting(), the reentrancy window where a stale + // timeout could clobber the freshly completed reconnection. + delegate.onIsTransportAutoReconnecting = { [weak coordinator, weak delegate] in + delegate?.onIsTransportAutoReconnecting = nil + await coordinator?.handleReconnectionComplete(deviceID: deviceID) } - @Test("UI timeout fires normally when BLE is not auto-reconnecting") - func uiTimeoutFiresWhenNotAutoReconnecting() async throws { - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = false - - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - - try await waitUntil("Timeout should fire when BLE is not auto-reconnecting") { - delegate.connectionState == .disconnected - } - - #expect(delegate.connectionState == .disconnected) - #expect(delegate.notifyConnectionLostCallCount == 1) + try await waitUntil("completion should run during the timeout suspension") { + delegate.rebuildSessionCalls == [deviceID] } - @Test("UI timeout aborts when reconnection completes during its transport query") - func uiTimeoutAbortsWhenCompletionInterleaves() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = false - - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - // Run handleReconnectionComplete while the timeout body is suspended on - // isTransportAutoReconnecting(), the reentrancy window where a stale - // timeout could clobber the freshly completed reconnection. - delegate.onIsTransportAutoReconnecting = { [weak coordinator, weak delegate] in - delegate?.onIsTransportAutoReconnecting = nil - await coordinator?.handleReconnectionComplete(deviceID: deviceID) - } - - try await waitUntil("completion should run during the timeout suspension") { - delegate.rebuildSessionCalls == [deviceID] - } - - // Fixed sleep: negative assertion: give the resumed timeout body a - // chance to (incorrectly) force disconnected state. - try await Task.sleep(for: .milliseconds(150)) - - #expect(delegate.connectionState == .connecting, "Stale timeout must not clobber the completed reconnection") - #expect(delegate.notifyConnectionLostCallCount == 0) - #expect(delegate.connectedDeviceWasCleared == false) - } + // Fixed sleep: negative assertion: give the resumed timeout body a + // chance to (incorrectly) force disconnected state. + try await Task.sleep(for: .milliseconds(150)) - @Test("cancelTimeout prevents timeout from firing") - func cancelTimeoutPreventsTimeout() async throws { - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + #expect(delegate.connectionState == .connecting, "Stale timeout must not clobber the completed reconnection") + #expect(delegate.notifyConnectionLostCallCount == 0) + #expect(delegate.connectedDeviceWasCleared == false) + } - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - coordinator.cancelTimeout() + @Test + func `cancelTimeout prevents timeout from firing`() async throws { + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - // Fixed sleep: negative assertion — confirm timeout was cancelled - try await Task.sleep(for: .milliseconds(250)) + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + coordinator.cancelTimeout() - // State should remain .connecting (timeout was cancelled) - #expect(delegate.connectionState == .connecting) - } + // Fixed sleep: negative assertion — confirm timeout was cancelled + try await Task.sleep(for: .milliseconds(250)) + + // State should remain .connecting (timeout was cancelled) + #expect(delegate.connectionState == .connecting) + } } // MARK: - Mock Delegate @MainActor private final class MockReconnectionDelegate: BLEReconnectionDelegate { - var connectionIntent: ConnectionIntent = .none - var connectionState: DeviceConnectionState = .disconnected - - var teardownSessionCallCount = 0 - var rebuildSessionCalls: [UUID] = [] - var rebuildSessionShouldThrow = false - var disconnectTransportCallCount = 0 - var notifyConnectionLostCallCount = 0 - var handleReconnectionFailureCallCount = 0 - var connectedDeviceWasCleared = false - var stubbedBLEPhaseIsAutoReconnecting = false - /// Runs inside `isTransportAutoReconnecting()` so tests can interleave work - /// at that suspension point before the stubbed value is returned. - var onIsTransportAutoReconnecting: (@MainActor () async -> Void)? - - func setConnectionState(_ state: DeviceConnectionState) { - connectionState = state - } - - func setConnectedDevice(_ device: DeviceDTO?) { - if device == nil { - connectedDeviceWasCleared = true - } - } - - func teardownSessionForReconnect() async { - teardownSessionCallCount += 1 - } - - func rebuildSession(deviceID: UUID) async throws { - rebuildSessionCalls.append(deviceID) - if rebuildSessionShouldThrow { - throw ReconnectionTestError.rebuildFailed - } - } - - func disconnectTransport() async { - disconnectTransportCallCount += 1 - } - - func notifyConnectionLost() async { - notifyConnectionLostCallCount += 1 - } - - func handleReconnectionFailure() async { - handleReconnectionFailureCallCount += 1 - } - - func isTransportAutoReconnecting() async -> Bool { - if let hook = onIsTransportAutoReconnecting { - await hook() - } - return stubbedBLEPhaseIsAutoReconnecting - } + var connectionIntent: ConnectionIntent = .none + var connectionState: DeviceConnectionState = .disconnected + + var teardownSessionCallCount = 0 + var rebuildSessionCalls: [UUID] = [] + var rebuildSessionShouldThrow = false + var disconnectTransportCallCount = 0 + var notifyConnectionLostCallCount = 0 + var notifyAutoReconnectStartedCallCount = 0 + var handleReconnectionFailureCallCount = 0 + /// Records delegate calls in order so tests can assert the auto-reconnect + /// notification lands before session teardown. + private(set) var callOrder: [String] = [] + var connectedDeviceWasCleared = false + var stubbedBLEPhaseIsAutoReconnecting = false + /// Runs inside `isTransportAutoReconnecting()` so tests can interleave work + /// at that suspension point before the stubbed value is returned. + var onIsTransportAutoReconnecting: (@MainActor () async -> Void)? + + func setConnectionState(_ state: DeviceConnectionState) { + connectionState = state + } + + func setConnectedDevice(_ device: DeviceDTO?) { + if device == nil { + connectedDeviceWasCleared = true + } + } + + func teardownSessionForReconnect() async { + teardownSessionCallCount += 1 + callOrder.append("teardown") + } + + func notifyAutoReconnectStarted() async { + notifyAutoReconnectStartedCallCount += 1 + callOrder.append("notifyAutoReconnectStarted") + } + + func rebuildSession(deviceID: UUID) async throws { + rebuildSessionCalls.append(deviceID) + if rebuildSessionShouldThrow { + throw ReconnectionTestError.rebuildFailed + } + } + + func disconnectTransport() async { + disconnectTransportCallCount += 1 + } + + func notifyConnectionLost() async { + notifyConnectionLostCallCount += 1 + } + + func handleReconnectionFailure() async { + handleReconnectionFailureCallCount += 1 + } + + func isTransportAutoReconnecting() async -> Bool { + if let hook = onIsTransportAutoReconnecting { + await hook() + } + return stubbedBLEPhaseIsAutoReconnecting + } } private enum ReconnectionTestError: Error { - case rebuildFailed + case rebuildFailed } diff --git a/MC1Services/Tests/MC1ServicesTests/BLEStateMachineErrorMappingTests.swift b/MC1Services/Tests/MC1ServicesTests/BLEStateMachineErrorMappingTests.swift index d02e2f88..febb8a73 100644 --- a/MC1Services/Tests/MC1ServicesTests/BLEStateMachineErrorMappingTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BLEStateMachineErrorMappingTests.swift @@ -1,77 +1,89 @@ import CoreBluetooth import Foundation -import Testing @testable import MC1Services +import Testing @Suite("BLEStateMachine.makeConnectionError") struct BLEStateMachineErrorMappingTests { + @Test + func `CBATT auth/encryption codes map to BLEError.authenticationFailed`() { + let authCodes = [ + CBATTError.insufficientAuthentication.rawValue, + CBATTError.insufficientAuthorization.rawValue, + CBATTError.insufficientEncryption.rawValue, + CBATTError.insufficientEncryptionKeySize.rawValue + ] - @Test("CBATT auth/encryption codes map to BLEError.authenticationFailed") - func mapsAuthCodes() { - let authCodes = [ - CBATTError.insufficientAuthentication.rawValue, - CBATTError.insufficientAuthorization.rawValue, - CBATTError.insufficientEncryption.rawValue, - CBATTError.insufficientEncryptionKeySize.rawValue - ] + for code in authCodes { + let nsError = NSError(domain: CBATTErrorDomain, code: code) + let result = BLEStateMachine.makeConnectionError(nsError) + guard case BLEError.authenticationFailed = result else { + Issue.record("Expected .authenticationFailed for CBATTError code \(code), got \(result)") + continue + } + } + } - for code in authCodes { - let nsError = NSError(domain: CBATTErrorDomain, code: code) - let result = BLEStateMachine.makeConnectionError(nsError) - guard case BLEError.authenticationFailed = result else { - Issue.record("Expected .authenticationFailed for CBATTError code \(code), got \(result)") - continue - } - } + @Test + func `CBError.encryptionTimedOut maps to .connectionFailed, not authenticationFailed`() { + // A single encryption timeout is transient (a backgrounded auto-reconnect + // races iOS re-establishing the bond); only a definitive auth code, or a + // majority of an exhausted retry budget, means a truly invalidated bond. + let nsError = NSError(domain: CBErrorDomain, code: CBError.encryptionTimedOut.rawValue) + let result = BLEStateMachine.makeConnectionError(nsError) + guard case BLEError.connectionFailed = result else { + Issue.record("Expected .connectionFailed, got \(result)") + return } + } - @Test("CBError.encryptionTimedOut maps to BLEError.authenticationFailed") - func mapsEncryptionTimeout() { - let nsError = NSError(domain: CBErrorDomain, code: CBError.encryptionTimedOut.rawValue) - let result = BLEStateMachine.makeConnectionError(nsError) - guard case BLEError.authenticationFailed = result else { - Issue.record("Expected .authenticationFailed, got \(result)") - return - } + @Test + func `CBError.peerRemovedPairingInformation maps to BLEError.authenticationFailed`() { + let nsError = NSError(domain: CBErrorDomain, code: CBError.peerRemovedPairingInformation.rawValue) + let result = BLEStateMachine.makeConnectionError(nsError) + guard case BLEError.authenticationFailed = result else { + Issue.record("Expected .authenticationFailed, got \(result)") + return } + } - @Test("Non-auth CBATT codes fall through to .connectionFailed") - func nonAuthCodesFallThrough() { - let nsError = NSError( - domain: CBATTErrorDomain, - code: CBATTError.requestNotSupported.rawValue, - userInfo: [NSLocalizedDescriptionKey: "Request not supported"] - ) - let result = BLEStateMachine.makeConnectionError(nsError) - guard case BLEError.connectionFailed(let msg) = result else { - Issue.record("Expected .connectionFailed, got \(result)") - return - } - #expect(msg == "Request not supported") + @Test + func `Non-auth CBATT codes fall through to .connectionFailed`() { + let nsError = NSError( + domain: CBATTErrorDomain, + code: CBATTError.requestNotSupported.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Request not supported"] + ) + let result = BLEStateMachine.makeConnectionError(nsError) + guard case let BLEError.connectionFailed(msg) = result else { + Issue.record("Expected .connectionFailed, got \(result)") + return } + #expect(msg == "Request not supported") + } - @Test("Detection survives a localized description") - func detectionSurvivesLocalizedDescription() { - // Simulate iOS localizing the auth-failure description into German. - let nsError = NSError( - domain: CBATTErrorDomain, - code: CBATTError.insufficientAuthentication.rawValue, - userInfo: [NSLocalizedDescriptionKey: "Authentifizierung ist unzureichend."] - ) - let result = BLEStateMachine.makeConnectionError(nsError) - guard case BLEError.authenticationFailed = result else { - Issue.record("Expected .authenticationFailed for localized German auth error, got \(result)") - return - } + @Test + func `Detection survives a localized description`() { + // Simulate iOS localizing the auth-failure description into German. + let nsError = NSError( + domain: CBATTErrorDomain, + code: CBATTError.insufficientAuthentication.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Authentifizierung ist unzureichend."] + ) + let result = BLEStateMachine.makeConnectionError(nsError) + guard case BLEError.authenticationFailed = result else { + Issue.record("Expected .authenticationFailed for localized German auth error, got \(result)") + return } + } - @Test("nil error uses fallback message") - func nilErrorUsesFallback() { - let result = BLEStateMachine.makeConnectionError(nil, fallback: "Disconnected during setup") - guard case BLEError.connectionFailed(let msg) = result else { - Issue.record("Expected .connectionFailed, got \(result)") - return - } - #expect(msg == "Disconnected during setup") + @Test + func `nil error uses fallback message`() { + let result = BLEStateMachine.makeConnectionError(nil, fallback: "Disconnected during setup") + guard case let BLEError.connectionFailed(msg) = result else { + Issue.record("Expected .connectionFailed, got \(result)") + return } + #expect(msg == "Disconnected during setup") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BLEStateMachineTimeoutPolicyTests.swift b/MC1Services/Tests/MC1ServicesTests/BLEStateMachineTimeoutPolicyTests.swift new file mode 100644 index 00000000..adcfb57a --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/BLEStateMachineTimeoutPolicyTests.swift @@ -0,0 +1,112 @@ +import CoreBluetooth +@testable import MC1Services +import Testing + +/// Truth table for the auto-reconnect discovery watchdog. A peripheral that is +/// not `.connected` is backed by an OS pending connect that never expires, so +/// the watchdog waits (regardless of the extension budget); only a +/// connected-but-wedged discovery consumes extensions and eventually tears down. +@Suite("BLEStateMachine Auto-Reconnect Timeout Policy Tests") +struct BLEStateMachineTimeoutPolicyTests { + private let maxExtensions = BLEStateMachine.maxDiscoveryTimeoutExtensions + + @Test + func `disconnected peripheral waits for the pending connect`() { + #expect( + BLEStateMachine.autoReconnectTimeoutAction( + peripheralState: .disconnected, extensions: 0, maxExtensions: maxExtensions + ) == .waitForPendingConnect + ) + } + + @Test + func `connecting peripheral waits for the pending connect`() { + #expect( + BLEStateMachine.autoReconnectTimeoutAction( + peripheralState: .connecting, extensions: 0, maxExtensions: maxExtensions + ) == .waitForPendingConnect + ) + } + + @Test + func `waiting never exhausts into teardown while the link is down`() { + #expect( + BLEStateMachine.autoReconnectTimeoutAction( + peripheralState: .disconnected, extensions: maxExtensions, maxExtensions: maxExtensions + ) == .waitForPendingConnect + ) + } + + @Test + func `connected peripheral with budget extends the discovery window`() { + #expect( + BLEStateMachine.autoReconnectTimeoutAction( + peripheralState: .connected, extensions: 0, maxExtensions: maxExtensions + ) == .extendWindow + ) + } + + @Test + func `connected peripheral with exhausted budget tears down`() { + #expect( + BLEStateMachine.autoReconnectTimeoutAction( + peripheralState: .connected, extensions: maxExtensions, maxExtensions: maxExtensions + ) == .tearDown + ) + } +} + +/// Truth table for classifying a discovery/subscribe watchdog teardown. Only a +/// peripheral that reached `.connected` yet exhausted its extension budget is +/// treated as a silently invalidated bond and escalated to +/// `authenticationFailed`; every other teardown state is a plain timeout. +@Suite("BLEStateMachine Discovery Timeout Classification Tests") +struct BLEStateMachineDiscoveryTimeoutClassificationTests { + private let maxExtensions = BLEStateMachine.maxDiscoveryTimeoutExtensions + + private func isAuthenticationFailed(_ error: BLEError) -> Bool { + if case .authenticationFailed = error { return true } + return false + } + + private func isConnectionTimeout(_ error: BLEError) -> Bool { + if case .connectionTimeout = error { return true } + return false + } + + @Test + func `connected peripheral with a spent budget escalates to an auth failure`() { + #expect( + isAuthenticationFailed( + BLEStateMachine.discoveryTimeoutError( + peripheralState: .connected, extensions: maxExtensions, maxExtensions: maxExtensions + ) + ) + ) + } + + @Test + func `connected peripheral within budget is a plain connection timeout`() { + #expect( + isConnectionTimeout( + BLEStateMachine.discoveryTimeoutError( + peripheralState: .connected, extensions: 0, maxExtensions: maxExtensions + ) + ) + ) + } + + @Test + func `a link that never reached connected is a plain connection timeout`() { + for state in [CBPeripheralState.disconnected, .connecting, .disconnecting] { + #expect( + isConnectionTimeout( + BLEStateMachine.discoveryTimeoutError( + peripheralState: state, extensions: maxExtensions, maxExtensions: maxExtensions + ) + ), + "state \(state.rawValue) should be a connection timeout, not an auth failure" + ) + } + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift index 90fdc1c9..64900908 100644 --- a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift @@ -1,3120 +1,3356 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services /// Integration tests covering the full export → parse → import pipeline. /// These tests use real in-memory PersistenceStores and the public AppBackupService API, /// exercising the complete data flow rather than manually constructed envelopes. @Suite("BackupIntegration") struct BackupIntegrationTests { + // MARK: - Test 1: Full round-trip + + /// Exports from a populated store and imports into a fresh store, verifying all data survives. + @Test + func `Full round-trip: export then import into fresh store restores all data`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Seed data + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xAB, count: 32), + name: "Alice" + ) + try await sourceStore.saveContact(contact) + + let channel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") + try await sourceStore.saveChannel(channel) + + var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id, text: "Hello") + msg.deduplicationKey = "integration-dedup-\(UUID())" + try await sourceStore.saveMessage(msg) + + let reaction = ReactionDTO.testReaction(messageID: msg.id, radioID: radioID) + try await sourceStore.saveReaction(reaction) + + // Export + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + + // Parse + let envelope = try parseBackup(data: exportResult.data) + #expect(envelope.manifest.validate(against: envelope)) + + // Import into a fresh store + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + // Verify insert counts + #expect(result.devicesInserted == 1) + #expect(result.contactsInserted == 1) + #expect(result.channelsInserted == 1) + #expect(result.messagesInserted == 1) + #expect(result.reactionsInserted == 1) + #expect(result.totalSkipped == 0) + + // Verify data actually persisted in the destination store + let destContacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(destContacts.count == 1) + #expect(destContacts.first?.name == "Alice") + + let destChannels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(destChannels.count == 1) + #expect(destChannels.first?.name == "General") + + let destMessages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(destMessages.count == 1) + #expect(destMessages.first?.text == "Hello") + + let messageIDs = Set(destMessages.map(\.id)) + let destReactions = try await destStore.fetchAllReactions(radioID: radioID) + #expect(destReactions.count == 1) + // Reaction messageID must match a message in the destination store + #expect(try messageIDs.contains(#require(destReactions.first?.messageID))) + } + + // MARK: - Test 1b: Unmodeled contact type byte survives backup + + /// `ContactDTO.typeRawValue` is a non-optional field that has existed since the backup + /// feature shipped, so the contract is a plain encode → decode round-trip (no + /// `decodeIfPresent` legacy path). An unmodeled type byte must survive verbatim so a + /// future refactor can't silently re-collapse it onto a modeled `ContactType`. + @Test + func `Unmodeled contact type byte (0x04) survives DTO encode → decode round-trip`() throws { + let dto = ContactDTO.testContact(typeRawValue: 0x04) + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(ContactDTO.self, from: encoded) + #expect(decoded.typeRawValue == 0x04) + #expect(decoded.type == .chat) + } + + /// Full envelope path: an unmodeled type byte must survive export → import into a fresh store, + /// not just an isolated DTO round-trip. + @Test + func `Unmodeled contact type byte (0x04) survives full backup export → import`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xAB, count: 32), + name: "FutureType", + typeRawValue: 0x04 + ) + try await sourceStore.saveContact(contact) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + _ = try await service.importBackup(envelope: envelope, into: destStore) + + let destContacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(destContacts.first?.typeRawValue == 0x04) + #expect(destContacts.first?.type == .chat) + } + + // MARK: - Test 1c: Device knownRegions survives backup + + /// `DeviceDTO.knownRegions` is a non-optional `[String]` that shipped before the + /// backup feature, so no envelope can predate it: the contract is a plain encode → + /// decode round-trip, not a `decodeIfPresent` legacy path. This locks that contract + /// at the DTO boundary so a future refactor can't drop the field from the wire format. + @Test + func `Device knownRegions survives DTO encode → decode round-trip`() throws { + let dto = DeviceDTO.testDevice().copy { $0.knownRegions = ["US915", "EU868"] } + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(DeviceDTO.self, from: encoded) + #expect(decoded.knownRegions == ["US915", "EU868"]) + } + + /// Full envelope path: regions added through the targeted, list-owning writer must + /// survive export → import into a fresh store, exercising the `Device(dto:)` insert + /// seeding the restore relies on. + @Test + func `Device knownRegions survives full backup export → import into a fresh store`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Discovery owns knownRegions through the targeted add path, not a full saveDevice. + try await sourceStore.addDeviceKnownRegion(radioID: radioID, region: "US915") + try await sourceStore.addDeviceKnownRegion(radioID: radioID, region: "EU868") + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + #expect(envelope.devices.first?.knownRegions == ["US915", "EU868"]) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let result = try await service.importBackup(envelope: envelope, into: destStore) + #expect(result.devicesInserted == 1) + + // Import re-mints Device.id; radioID is the surviving partition key. + let restored = try #require(await destStore.fetchDevice(radioID: radioID)) + #expect(restored.knownRegions == ["US915", "EU868"]) + } + + // MARK: - Test 2: Cross-bundle radioID remapping + + /// When the target store contains a device with the same publicKey as the backup but a + /// different radioID, all child records must be remapped to the local radioID. + @Test + func `Cross-bundle: child records are remapped to local radioID on publicKey match`() async throws { + let sharedPublicKey = Data(repeating: 0xCC, count: 32) + let sourceRadioID = UUID() + let targetRadioID = UUID() + + // Source store: device with sharedPublicKey, radioID = sourceRadioID + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: sourceRadioID) + let sourceDevice = DeviceDTO.testDevice( + id: sourceRadioID, + radioID: sourceRadioID, + publicKey: sharedPublicKey + ) + try await sourceStore.saveDevice(sourceDevice) + + let contact = ContactDTO.testContact( + radioID: sourceRadioID, + publicKey: Data(repeating: 0xDD, count: 32), + name: "Bob" + ) + try await sourceStore.saveContact(contact) + + // Export from source + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + // Target store: device with same publicKey but different radioID + let targetStore = try await PersistenceStore.createTestDataStore(radioID: targetRadioID) + let targetDevice = DeviceDTO.testDevice( + id: targetRadioID, + radioID: targetRadioID, + publicKey: sharedPublicKey + ) + try await targetStore.saveDevice(targetDevice) + + // Import + let result = try await service.importBackup( + envelope: envelope, + into: targetStore + ) + + // Backup device matched by publicKey → not inserted + #expect(result.devicesInserted == 0) + #expect(result.contactsInserted == 1) + + // Contact must live under targetRadioID, not sourceRadioID + let contactsUnderTarget = try await targetStore.fetchAllContacts(radioID: targetRadioID) + #expect(contactsUnderTarget.count == 1) + #expect(contactsUnderTarget.first?.name == "Bob") + + let contactsUnderSource = try await targetStore.fetchAllContacts(radioID: sourceRadioID) + #expect(contactsUnderSource.count == 0) + } + + // MARK: - Test 4: Merge import — DM thread visible for existing contact + + @Test + func `Import onto existing contact with nil lastMessageDate makes DM thread visible`() async throws { + let radioID = UUID() + let sharedPublicKey = Data(repeating: 0xAB, count: 32) + + // Destination store has a contact with no message history + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + lastMessageDate: nil + ) + try await destStore.saveContact(contact) + + // Verify contact is NOT in conversations list (lastMessageDate is nil) + let beforeConversations = try await destStore.fetchConversations(radioID: radioID) + #expect(beforeConversations.isEmpty) + + // Build backup with DMs for the same contact + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupContact = ContactDTO.testContact( + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + lastMessageDate: Date() + ) + var msg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: backupContact.id, + text: "Restored DM" + ) + msg.deduplicationKey = "merge-dm-\(UUID())" + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [backupContact], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + // Contact was skipped (already exists), message was inserted + #expect(result.contactsSkipped == 1) + #expect(result.messagesInserted == 1) + + // Contact must now appear in conversations + let afterConversations = try await destStore.fetchConversations(radioID: radioID) + #expect(afterConversations.count == 1) + #expect(afterConversations.first?.name == "Alice") + #expect(afterConversations.first?.lastMessageDate != nil) + } + + // MARK: - Test 5: Merge import — MessageRepeat relationship set (pre-existing parent) + + @Test + func `Import repeats onto existing message sets relationship and enables cascade delete`() async throws { + let radioID = UUID() + + // Destination store has an existing message + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await destStore.saveContact(contact) + + var existingMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Existing", + direction: .incoming + ) + existingMsg.deduplicationKey = "existing-msg-key" + try await destStore.saveMessage(existingMsg) + + // Build backup that contributes a repeat for the same message + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + var backupMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Existing", + direction: .incoming + ) + backupMsg.deduplicationKey = "existing-msg-key" + + let repeat1 = MessageRepeatDTO.testRepeat( + messageID: backupMsg.id, + pathNodes: Data([0x31]) + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [backupMsg], + messageRepeats: [repeat1] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.messagesSkipped == 1) + #expect(result.messageRepeatsInserted == 1) + + // Verify the repeat was linked to the existing message + let repeats = try await destStore.fetchMessageRepeats(messageID: existingMsg.id) + #expect(repeats.count == 1) + + // Delete the message — repeat must cascade-delete + try await destStore.deleteMessage(id: existingMsg.id) + let repeatsAfterDelete = try await destStore.fetchMessageRepeats(messageID: existingMsg.id) + #expect(repeatsAfterDelete.isEmpty) + } + + // MARK: - Test 6: Fresh-store import — MessageRepeat cascade delete + + @Test + func `Fresh-store import sets MessageRepeat relationship and cascade deletes work`() async throws { + let radioID = UUID() + + // Source store with a message and repeats + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xBB, count: 32), + name: "Bob" + ) + try await sourceStore.saveContact(contact) + + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + text: "Hello mesh" + ) + msg.deduplicationKey = "fresh-cascade-\(UUID())" + try await sourceStore.saveMessage(msg) + + // Save repeat using the normal (relationship-setting) path + let repeatDTO = MessageRepeatDTO.testRepeat(messageID: msg.id, pathNodes: Data([0x42])) + try await sourceStore.saveMessageRepeat(repeatDTO) + + // Export + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + // Import into fresh store + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + #expect(result.messagesInserted == 1) + #expect(result.messageRepeatsInserted == 1) + + // Verify repeats exist + let destMessages = try await destStore.fetchAllMessages(radioID: radioID) + let destMsgID = try #require(destMessages.first?.id) + let repeats = try await destStore.fetchMessageRepeats(messageID: destMsgID) + #expect(repeats.count == 1) + + // Delete the message — repeat must cascade-delete + try await destStore.deleteMessage(id: destMsgID) + let repeatsAfterDelete = try await destStore.fetchMessageRepeats(messageID: destMsgID) + #expect(repeatsAfterDelete.isEmpty) + } + + // MARK: - Test 7: Merge import — caches recomputed + + @Test + func `Import repeats/reactions onto existing message recomputes heardRepeats and reactionSummary`() async throws { + let radioID = UUID() + + // Destination store with a message (heardRepeats=0, no reactions) + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await destStore.saveContact(contact) + + var existingMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Cache test", + direction: .incoming, + heardRepeats: 0 + ) + existingMsg.deduplicationKey = "cache-test-key" + try await destStore.saveMessage(existingMsg) + + // Build backup that adds 2 repeats and 1 reaction to the same message + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + var backupMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Cache test", + direction: .incoming + ) + backupMsg.deduplicationKey = "cache-test-key" + + let repeat1 = MessageRepeatDTO.testRepeat( + messageID: backupMsg.id, + pathNodes: Data([0x31]) + ) + let repeat2 = MessageRepeatDTO.testRepeat( + messageID: backupMsg.id, + pathNodes: Data([0x42]) + ) + let reaction = ReactionDTO.testReaction( + messageID: backupMsg.id, + radioID: radioID, + emoji: "👍", + senderName: "Eve" + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [backupMsg], + messageRepeats: [repeat1, repeat2], + reactions: [reaction] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.messagesSkipped == 1) + #expect(result.messageRepeatsInserted == 2) + #expect(result.reactionsInserted == 1) + + // Verify heardRepeats was recomputed + let updatedMsg = try await destStore.fetchMessage(id: existingMsg.id) + #expect(updatedMsg?.heardRepeats == 2) + + // Verify reactionSummary was recomputed + #expect(updatedMsg?.reactionSummary == "👍:1") + } + + // MARK: - Test 8: Merge import — channel metadata refreshed + + @Test + func `Import messages onto existing channel with nil lastMessageDate refreshes metadata`() async throws { + let radioID = UUID() + + // Destination store has a channel with no messages + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let channel = ChannelDTO.testChannel( + radioID: radioID, + index: 0, + name: "General", + lastMessageDate: nil + ) + try await destStore.saveChannel(channel) + + // Verify channel has nil lastMessageDate + let beforeChannel = try await destStore.fetchChannel(radioID: radioID, index: 0) + #expect(beforeChannel?.lastMessageDate == nil) + + // Build backup with messages for the same channel + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + text: "Restored channel msg" + ) + msg.deduplicationKey = "channel-meta-\(UUID())" + + let envelope = AppBackupEnvelope.test( + devices: [device], + channels: [backupChannel], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.channelsSkipped == 1) + #expect(result.messagesInserted == 1) + + // Channel must now have lastMessageDate set + let afterChannel = try await destStore.fetchChannel(radioID: radioID, index: 0) + #expect(afterChannel?.lastMessageDate != nil) + } + + // MARK: - Test 9: Merge import — saved trace path runs preserved + + @Test + func `Import onto existing saved trace path merges runs without duplicating them`() async throws { + let radioID = UUID() + let pathBytes = Data([0x12, 0x34, 0x56, 0x78]) + let existingRun = TracePathRunDTO.testRun( + date: Date().addingTimeInterval(-120), + roundTripMs: 180 + ) + let importedRun = TracePathRunDTO.testRun( + date: Date(), + roundTripMs: 95 + ) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingPath = try await destStore.createSavedTracePath( + radioID: radioID, + name: "Shared Route", + pathBytes: pathBytes, + hashSize: 2, + initialRun: existingRun + ) + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPath = SavedTracePathDTO.testPath( + radioID: radioID, + name: "Shared Route", + pathBytes: pathBytes, + hashSize: 2, + runs: [importedRun] + ) + let envelope = AppBackupEnvelope.test( + devices: [device], + savedTracePaths: [backupPath] + ) + + let service = AppBackupService() + + let firstResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(firstResult.savedTracePathsInserted == 0) + #expect(firstResult.savedTracePathsSkipped == 1) + #expect(firstResult.savedTracePathsMerged == 1) + #expect(firstResult.hasRestoredChanges) + + let mergedPath = try #require(await destStore.fetchSavedTracePath(id: existingPath.id)) + #expect(mergedPath.runs.count == 2) + #expect(Set(mergedPath.runs.map(\.id)) == Set([existingRun.id, importedRun.id])) + + let secondResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(secondResult.savedTracePathsInserted == 0) + #expect(secondResult.savedTracePathsSkipped == 1) + #expect(secondResult.savedTracePathsMerged == 0) + #expect(!secondResult.hasRestoredChanges) + + let deduplicatedPath = try #require(await destStore.fetchSavedTracePath(id: existingPath.id)) + #expect(deduplicatedPath.runs.count == 2) + #expect(Set(deduplicatedPath.runs.map(\.id)) == Set([existingRun.id, importedRun.id])) + } + + // MARK: - Test 9b: Cross-path run id reuse never relocates a run + + @Test + func `Import reusing a local run's id under a different path drops the duplicate run rather than relocating it`() async throws { + let radioID = UUID() + let pathABytes = Data([0x12, 0x34, 0x56, 0x78]) + let pathBBytes = Data([0xAB, 0xCD, 0xEF, 0x01]) + let sharedRunID = UUID() + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Local path A owns run R. + let runR = TracePathRunDTO.testRun(id: sharedRunID, roundTripMs: 180) + let pathA = try await destStore.createSavedTracePath( + radioID: radioID, + name: "Path A", + pathBytes: pathABytes, + hashSize: 1, + initialRun: runR + ) + + // Backup path B is a distinct path whose run reuses R's id. + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPathB = SavedTracePathDTO.testPath( + radioID: radioID, + name: "Path B", + pathBytes: pathBBytes, + hashSize: 1, + runs: [TracePathRunDTO.testRun(id: sharedRunID, roundTripMs: 999)] + ) + let envelope = AppBackupEnvelope.test( + devices: [device], + savedTracePaths: [backupPathB] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + // Path B is a new path, but its duplicate-id run is dropped store-wide. + #expect(result.savedTracePathsInserted == 1) + + let allPaths = try await destStore.fetchSavedTracePaths(radioID: radioID) + #expect(allPaths.count == 2) + + // Run R stays under path A; path B gains no run. + let pathAAfter = try #require(await destStore.fetchSavedTracePath(id: pathA.id)) + #expect(pathAAfter.runs.count == 1) + #expect(pathAAfter.runs.first?.id == sharedRunID) + + let pathBAfter = try #require(allPaths.first { $0.pathBytes == pathBBytes }) + #expect(pathBAfter.runs.isEmpty) + + // The run exists exactly once in the whole store. + let totalRuns = allPaths.reduce(0) { $0 + $1.runs.count } + #expect(totalRuns == 1) + } + + // MARK: - Test 10: Orphaned radio-scoped data survives export/import + + @Test + func `Export preserves orphaned radio-scoped data after device-only delete`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xD1, count: 32), + name: "Orphaned Contact" + ) + try await sourceStore.saveContact(contact) + + let channel = ChannelDTO.testChannel( + radioID: radioID, + index: 1, + name: "Orphaned Channel", + unreadCount: 3, + notificationLevel: .mentionsOnly, + isFavorite: true + ) + try await sourceStore.saveChannel(channel) + + var message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Preserve me" + ) + message.deduplicationKey = "orphaned-message-\(UUID())" + try await sourceStore.saveMessage(message) + + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + try await sourceStore.saveRemoteNodeSessionDTO(session) + + let roomMessage = RoomMessageDTO.testRoomMessage(sessionID: session.id) + try await sourceStore.saveRoomMessage(roomMessage) + + let blockedSender = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) + try await sourceStore.saveBlockedChannelSender(blockedSender) + + let tracePath = SavedTracePathDTO.testPath(radioID: radioID) + let initialRun = tracePath.runs.first + _ = try await sourceStore.createSavedTracePath( + radioID: tracePath.radioID, + name: tracePath.name, + pathBytes: tracePath.pathBytes, + hashSize: tracePath.hashSize, + initialRun: initialRun + ) + + try await sourceStore.deleteDevice(id: radioID) + #expect(try await sourceStore.fetchDevice(id: radioID) == nil) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: result.data) + + #expect(envelope.devices.isEmpty) + #expect(envelope.contacts.count == 1) + #expect(envelope.channels.count == 1) + #expect(envelope.messages.count == 1) + #expect(envelope.remoteNodeSessions.count == 1) + #expect(envelope.roomMessages.count == 1) + #expect(envelope.savedTracePaths.count == 1) + #expect(envelope.blockedChannelSenders.count == 1) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let firstResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(firstResult.devicesInserted == 0) + #expect(firstResult.contactsInserted == 1) + #expect(firstResult.channelsInserted == 1) + #expect(firstResult.messagesInserted == 1) + #expect(firstResult.remoteNodeSessionsInserted == 1) + #expect(firstResult.roomMessagesInserted == 1) + #expect(firstResult.savedTracePathsInserted == 1) + #expect(firstResult.blockedChannelSendersInserted == 1) + + #expect(try await destStore.fetchAllContacts(radioID: radioID).count == 1) + #expect(try await destStore.fetchAllChannels(radioID: radioID).count == 1) + #expect(try await destStore.fetchAllMessages(radioID: radioID).count == 1) + #expect(try await destStore.fetchRemoteNodeSessions(radioID: radioID).count == 1) + #expect(try await destStore.fetchRoomMessages(sessionID: session.id).count == 1) + #expect(try await destStore.fetchSavedTracePaths(radioID: radioID).count == 1) + #expect(try await destStore.fetchBlockedChannelSenders(radioID: radioID).count == 1) + + let secondResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(secondResult.totalInserted == 0) + #expect(secondResult.contactsSkipped == 1) + #expect(secondResult.channelsSkipped == 1) + #expect(secondResult.messagesSkipped == 1) + #expect(secondResult.remoteNodeSessionsSkipped == 1) + #expect(secondResult.roomMessagesSkipped == 1) + #expect(secondResult.savedTracePathsSkipped == 1) + #expect(secondResult.blockedChannelSendersSkipped == 1) + } + + // MARK: - Test 11: Merge import — contact metadata restored + + @Test + func `Import onto existing contact restores backup-owned contact metadata`() async throws { + let radioID = UUID() + let sharedPublicKey = Data(repeating: 0xE2, count: 32) + let importedDate = Date(timeIntervalSince1970: 1_700_000_000) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingContact = ContactDTO.testContact( + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0 + ) + try await destStore.saveContact(existingContact) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupContact = ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + typeRawValue: existingContact.typeRawValue, + flags: existingContact.flags, + outPathLength: existingContact.outPathLength, + outPath: existingContact.outPath, + lastAdvertTimestamp: existingContact.lastAdvertTimestamp, + latitude: existingContact.latitude, + longitude: existingContact.longitude, + lastModified: existingContact.lastModified, + nickname: "Field Ops", + isBlocked: true, + isMuted: true, + isFavorite: true, + lastMessageDate: importedDate, + unreadCount: 7, + unreadMentionCount: 2, + ocvPreset: OCVPreset.custom.rawValue, + customOCVArrayString: "4200,4100,4000" + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + contacts: [backupContact] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.contactsInserted == 0) + #expect(result.contactsSkipped == 1) + + let mergedContact = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: sharedPublicKey) + ) + #expect(mergedContact.nickname == "Field Ops") + #expect(mergedContact.isBlocked == true) + #expect(mergedContact.isMuted == true) + #expect(mergedContact.isFavorite == true) + #expect(mergedContact.lastMessageDate == importedDate) + #expect(mergedContact.unreadCount == 7) + #expect(mergedContact.unreadMentionCount == 2) + #expect(mergedContact.ocvPreset == OCVPreset.custom.rawValue) + #expect(mergedContact.customOCVArrayString == "4200,4100,4000") + } + + // MARK: - Test 12: Merge import — channel metadata restored + + @Test + func `Import onto existing channel restores backup-owned channel metadata`() async throws { + let radioID = UUID() + let importedDate = Date(timeIntervalSince1970: 1_700_000_100) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 2, + name: "Ops", + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false, + floodScope: .inherit + ) + try await destStore.saveChannel(existingChannel) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: existingChannel.index, + name: existingChannel.name, + lastMessageDate: importedDate, + unreadCount: 11, + unreadMentionCount: 4, + notificationLevel: .mentionsOnly, + isFavorite: true, + floodScope: .region("US") + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupChannel] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + + let mergedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 2)) + #expect(mergedChannel.lastMessageDate == importedDate) + #expect(mergedChannel.unreadCount == 11) + #expect(mergedChannel.unreadMentionCount == 4) + #expect(mergedChannel.notificationLevel == .mentionsOnly) + #expect(mergedChannel.isFavorite == true) + #expect(mergedChannel.regionScope == "US") + } + + // MARK: - Test 12b: Different-secret slot collision relocates instead of merging + + /// Local `#kosice` (secret 0x01) occupies slot 2; the backup carries a distinct channel + /// `#praha` (secret 0x02) that also claims slot 2. Because the two secrets differ, this + /// is "different channel, relocate", never a metadata merge: `#kosice` keeps its slot, + /// name, and secret untouched, and `#praha` is inserted as a separate channel on a free + /// slot. Contrast with same-secret merges, where backup metadata is adopted in place. + @Test + func `Import onto a different-secret slot keeps the local channel and inserts the backup channel separately`() async throws { + let radioID = UUID() + let localSecret = Data(repeating: 0x01, count: 16) + let backupSecret = Data(repeating: 0x02, count: 16) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 2, + name: "#kosice", + secret: localSecret + ) + try await destStore.saveChannel(existingChannel) + + // Same (radioID, index) slot, but a different name and secret in the backup. + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: 2, + name: "#praha", + secret: backupSecret + ) + + let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupChannel]) + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + // The backup channel is a distinct identity, so it is inserted (relocated), not + // merged into #kosice's slot. + #expect(result.channelsInserted == 1) + #expect(result.channelsSkipped == 0) + + // #kosice keeps its slot, name, and secret. + let kosice = try #require(await destStore.fetchChannel(radioID: radioID, index: 2)) + #expect(kosice.name == "#kosice") + #expect(kosice.secret == localSecret) + + // #praha exists as a separate channel on a different slot, with its own name/secret. + let channels = try await destStore.fetchAllChannels(radioID: radioID) + let praha = try #require(channels.first { $0.secret == backupSecret }) + #expect(praha.name == "#praha") + #expect(praha.index != 2) + } + + // MARK: - Channel reconciliation by secret (slot relocation) — REPRO + + /// Reproduction for silent wrong-context delivery. Backup `#praha` (secretA) lives + /// at slot 3 with a channel message; locally slot 3 is `#brno` (secretB). With slot-only + /// reconciliation the imported message attaches to `#brno` and `#praha` is dropped. The + /// fix reconciles by `(radioID, secret)`: `#praha` relocates to a free slot and its + /// message follows; `#brno` is untouched. + @Test + func `Channel reconcile: backup channel collides with a different-secret local slot, relocates and carries its message`() async throws { + let radioID = UUID() + let secretBrno = Data(repeating: 0xB0, count: 16) + let secretPraha = Data(repeating: 0xA0, count: 16) + let collisionIndex: UInt8 = 3 + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let localBrno = ChannelDTO.testChannel( + radioID: radioID, + index: collisionIndex, + name: "#brno", + secret: secretBrno + ) + try await destStore.saveChannel(localBrno) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPraha = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: collisionIndex, + name: "#praha", + secret: secretPraha + ) + var prahaMessage = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: collisionIndex, + text: "Praha checkpoint", + timestamp: 1_700_000_900, + direction: .incoming, + senderNodeName: "Jan" + ) + prahaMessage.deduplicationKey = "ch-\(collisionIndex)-1700000900-Jan-DEADBEEF" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupPraha], + messages: [prahaMessage] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + // #praha is a distinct channel: inserted (relocated), not merged into #brno's slot. + #expect(result.channelsInserted == 1) + #expect(result.channelsSkipped == 0) + #expect(result.messagesInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + let brno = try #require(channels.first { $0.secret == secretBrno }) + let praha = try #require(channels.first { $0.secret == secretPraha }) + + // #brno keeps its slot and name; #praha lands on a different (free) slot. + #expect(brno.index == collisionIndex) + #expect(brno.name == "#brno") + #expect(praha.index != collisionIndex) + #expect(praha.name == "#praha") + + // The message followed #praha's relocated slot, not #brno's. + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + let restored = try #require(messages.first) + #expect(restored.channelIndex == praha.index) + #expect(restored.text == "Praha checkpoint") + + // #brno received no foreign message. + let brnoMessages = messages.filter { $0.channelIndex == collisionIndex } + #expect(brnoMessages.isEmpty) + } + + @Test + func `Re-importing a stale backup never upserts a reconfigured live channel`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let sharedID = UUID() + + // Live channel: same surrogate id as the backup, but secret rotated in place (S2), + // occupying the backup's slot. + let liveSecret = Data(repeating: 0xB2, count: 32) + let live = ChannelDTO( + id: sharedID, radioID: radioID, index: 3, name: "Live Net", + secret: liveSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + try await store.batchInsertChannels([live], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8]) + + // Backup channel: same id, slot 3, but the original secret S1 (no local match). + let backupSecret = Data(repeating: 0xA1, count: 32) + let backup = ChannelDTO( + id: sharedID, radioID: radioID, index: 3, name: "Stale Net", + secret: backupSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + let result = try await store.batchInsertChannels([backup], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8]) + + let channels = try await store.fetchChannels(radioID: radioID) + // Live channel survives untouched at slot 3 with its rotated secret. + let liveRow = try #require(channels.first { $0.secret == liveSecret }) + #expect(liveRow.index == 3) + #expect(liveRow.name == "Live Net") + // Backup channel coexists, relocated to a free slot with a fresh id. + let backupRow = try #require(channels.first { $0.secret == backupSecret }) + #expect(backupRow.id != sharedID) + #expect(backupRow.index != 3) + #expect(result.inserted == 1) + } + + @Test + func `batchInsertChannels reports newly-occupied local slots for draft clearing, excluding merges`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + + let mergeSecret = Data(repeating: 0xC1, count: 32) + let occupiedSecret = Data(repeating: 0xC4, count: 32) + let localAtSlot2 = ChannelDTO( + id: UUID(), radioID: radioID, index: 2, name: "Keep", + secret: mergeSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + let localAtSlot4 = ChannelDTO( + id: UUID(), radioID: radioID, index: 4, name: "Occupied", + secret: occupiedSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + try await store.batchInsertChannels( + [localAtSlot2, localAtSlot4], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8] + ) + + // (a) merges by secret into local slot 2 though recorded at slot 6 — occupant unchanged. + let mergeBackup = ChannelDTO( + id: UUID(), radioID: radioID, index: 6, name: "Keep", + secret: mergeSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + // (b) foreign channel placed at its own free slot 5. + let collideSecret = Data(repeating: 0xD4, count: 32) + let freshAtFreeSlot = ChannelDTO( + id: UUID(), radioID: radioID, index: 5, name: "Fresh", + secret: Data(repeating: 0xD5, count: 32), isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + // (c) foreign channel colliding with occupied slot 4, relocated to a free slot. + let collideAtSlot4 = ChannelDTO( + id: UUID(), radioID: radioID, index: 4, name: "Collide", + secret: collideSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + + let result = try await store.batchInsertChannels( + [mergeBackup, freshAtFreeSlot, collideAtSlot4], + radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8] + ) + + let inserted = result.insertedLocalIndices[radioID] ?? [] + // Fresh insert recorded at its own free slot. + #expect(inserted.contains(5)) + // Merge-by-secret destination (2) and backup-file source (6) are not local inserts. + #expect(!inserted.contains(2)) + #expect(!inserted.contains(6)) + // The colliding channel's untouched source slot (4) is not recorded; its relocation + // destination is. + #expect(!inserted.contains(4)) + let relocatedRow = try #require( + try await (store.fetchChannels(radioID: radioID)).first { $0.secret == collideSecret } + ) + #expect(relocatedRow.index != 4) + #expect(inserted.contains(relocatedRow.index)) + #expect(result.inserted == 2) + } + + @Test + func `Exporting a channel with an unmigrated notification level does not migrate the live row`() { + let channel = Channel( + radioID: UUID(), index: 1, name: "Ops", + secret: Data(repeating: 0x33, count: 32) + ) + // Simulate a pre-migration row: -1 sentinel with the legacy muted flag set. + channel.notificationLevelRawValue = -1 + channel.legacyIsMuted = true + + let dto = ChannelDTO(from: channel) + + // The read did not trigger the migrating getter's in-memory write-back. + #expect(channel.notificationLevelRawValue == -1) + // The DTO still carries the correctly decoded level (muted, from legacy isMuted). + #expect(dto.notificationLevel == .muted) + } + + @Test + func `Duplicate device public keys in one envelope skip the loser and remap its children`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let key = Data(repeating: 0x42, count: 32) + let radioA = UUID(), radioB = UUID() + let devA = DeviceDTO.testDevice().copy { $0.publicKey = key; $0.radioID = radioA } + let devB = DeviceDTO.testDevice().copy { $0.publicKey = key; $0.radioID = radioB } + // Both devices share the duplicate `key` (the scenario under test), but the contacts + // must carry distinct public keys: after both contacts' radioID is remapped to the + // single surviving local radioID they would otherwise share an identical `contactKey` + // (radioID + publicKey) and the second would be deduped, collapsing to one row. + let contactA = ContactDTO.testContact(radioID: radioA, publicKey: Data(repeating: 0x01, count: 32)) + let contactB = ContactDTO.testContact(radioID: radioB, publicKey: Data(repeating: 0x02, count: 32)) + let envelope = AppBackupEnvelope.test( + devices: [devA, devB], contacts: [contactA, contactB] + ) + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: store) + #expect(result.counts[.devices]?.skipped == 1) // loser duplicate + #expect(result.counts[.devices]?.inserted == 1) + // Both contacts land under the single surviving local radioID. + let devices = try await store.fetchAllDevices() + let localRadioID = try #require(devices.first?.radioID) + let contacts = try await store.fetchContacts(radioID: localRadioID) + #expect(contacts.count == 2) + } + + @Test + func `Channel floodScope survives a fresh-insert export/import round-trip`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + try await sourceStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General", floodScope: .inherit) + ) + try await sourceStore.saveChannel( + ChannelDTO.testChannel( + radioID: radioID, index: 1, name: "Regional", + secret: Data(repeating: 0x55, count: 32), floodScope: .region("US") + ) + ) + + let service = AppBackupService() + let envelope = try await parseBackup(data: service.export(persistenceStore: sourceStore).data) + + // Fresh dest store: both channels take the fresh-insert path (Channel(dto:)), not merge. + let destStore = try PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)) + let result = try await service.importBackup(envelope: envelope, into: destStore) + #expect(result.channelsInserted == 2) + + let restoredRegional = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) + #expect(restoredRegional.floodScope == .region("US")) + let restoredInherit = try #require(try await destStore.fetchChannel(radioID: radioID, index: 0)) + #expect(restoredInherit.floodScope == .inherit) + } + + @Test + func `Re-importing the same backup is idempotent for unread counts and last-message dates`() async throws { + let radioID = UUID() + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Local channel the backup will merge into (matched by stable secret). + let secret = Data(repeating: 0x77, count: 32) + let localDate = Date(timeIntervalSince1970: 1_700_000_000) + try await destStore.saveChannel( + ChannelDTO.testChannel( + radioID: radioID, index: 1, name: "Ops", secret: secret, + lastMessageDate: localDate, unreadCount: 5 + ) + ) + + // Backup carries the same channel with a higher unread count and a later date. + let backupDate = Date(timeIntervalSince1970: 1_700_009_000) + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + radioID: radioID, index: 1, name: "Ops", secret: secret, + lastMessageDate: backupDate, unreadCount: 9 + ) + let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupChannel]) + + let service = AppBackupService() + _ = try await service.importBackup(envelope: envelope, into: destStore) + let afterFirst = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) + + _ = try await service.importBackup(envelope: envelope, into: destStore) + let afterSecond = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) + + // The merge (max of local/backup) and date reconciliation are idempotent: a repeat + // import neither double-counts unread nor oscillates the last-message date. + #expect(afterSecond.unreadCount == afterFirst.unreadCount) + #expect(afterSecond.unreadMentionCount == afterFirst.unreadMentionCount) + #expect(afterSecond.lastMessageDate == afterFirst.lastMessageDate) + } + + /// Case A: same secret at the same slot is a pure metadata merge with no relocation. + @Test + func `Channel reconcile: same secret at same slot merges metadata, index unchanged`() async throws { + let radioID = UUID() + let secret = Data(repeating: 0x44, count: 16) + let slot: UInt8 = 2 + let importedDate = Date(timeIntervalSince1970: 1_700_000_950) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let localChannel = ChannelDTO.testChannel( + radioID: radioID, + index: slot, + name: "#ops", + secret: secret, + lastMessageDate: nil + ) + try await destStore.saveChannel(localChannel) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: slot, + name: "#ops", + secret: secret, + lastMessageDate: importedDate + ) + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: slot, + text: "Same slot", + timestamp: 1_700_000_950, + // Older than importedDate so the merged channel date is the max and survives + // the lastMessageDate reconcile pass (which keys on createdAt). + createdAt: Date(timeIntervalSince1970: 1_600_000_000), + direction: .incoming, + senderNodeName: "Eva" + ) + msg.deduplicationKey = "ch-\(slot)-1700000950-Eva-CAFE0001" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupChannel], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + #expect(result.messagesInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(channels.count == 1) + #expect(channels.first?.index == slot) + #expect(channels.first?.secret == secret) + #expect(channels.first?.lastMessageDate == importedDate) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + #expect(messages.first?.channelIndex == slot) + } + + /// Case B: the same secret moved slots between backup and restore. The message + /// remaps to the local slot; no duplicate channel is inserted. + @Test + func `Channel reconcile: same secret on a different slot remaps the message, no duplicate channel`() async throws { + let radioID = UUID() + let secret = Data(repeating: 0x55, count: 16) + let backupSlot: UInt8 = 3 + let localSlot: UInt8 = 5 + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let localChannel = ChannelDTO.testChannel( + radioID: radioID, + index: localSlot, + name: "#praha", + secret: secret + ) + try await destStore.saveChannel(localChannel) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: backupSlot, + name: "#praha", + secret: secret + ) + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: backupSlot, + text: "Moved slots", + timestamp: 1_700_001_000, + direction: .incoming, + senderNodeName: "Lena" + ) + msg.deduplicationKey = "ch-\(backupSlot)-1700001000-Lena-0BADF00D" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupChannel], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + #expect(result.messagesInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(channels.count == 1) + #expect(channels.first?.index == localSlot) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + #expect(messages.first?.channelIndex == localSlot) + } + + /// The public channel (index 0, all-zero secret) merges into the local public channel + /// without relocating, because empty-secret channels match by index. + @Test + func `Channel reconcile: public channel merges into local public channel without relocation`() async throws { + let radioID = UUID() + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let localPublic = ChannelDTO.testChannel( + radioID: radioID, + index: 0, + name: "Public", + lastMessageDate: nil + ) + try await destStore.saveChannel(localPublic) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPublic = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: 0, + name: "Public", + lastMessageDate: Date(timeIntervalSince1970: 1_700_001_100) + ) + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + text: "Public broadcast", + timestamp: 1_700_001_100, + direction: .incoming, + senderNodeName: "Mara" + ) + msg.deduplicationKey = "ch-0-1700001100-Mara-FEEDFACE" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupPublic], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + #expect(result.messagesInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(channels.count == 1) + #expect(channels.first?.index == 0) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + #expect(messages.first?.channelIndex == 0) + } + + /// Dedup-key preservation: re-importing the same relocated backup must skip the + /// channel message on the second pass, proving the dedup key was rewritten to the + /// relocated index consistently across both runs. + @Test + func `Channel reconcile: re-importing a relocated channel skips the message the second time`() async throws { + let radioID = UUID() + let secretBrno = Data(repeating: 0xB2, count: 16) + let secretPraha = Data(repeating: 0xA2, count: 16) + let collisionIndex: UInt8 = 3 + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: collisionIndex, name: "#brno", secret: secretBrno) + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPraha = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: collisionIndex, + name: "#praha", + secret: secretPraha + ) + var prahaMessage = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: collisionIndex, + text: "Praha checkpoint", + timestamp: 1_700_001_200, + direction: .incoming, + senderNodeName: "Jan" + ) + prahaMessage.deduplicationKey = "ch-\(collisionIndex)-1700001200-Jan-12345678" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupPraha], + messages: [prahaMessage] + ) + + let service = AppBackupService() + let firstResult = try await service.importBackup(envelope: envelope, into: destStore) + #expect(firstResult.messagesInserted == 1) + + let secondResult = try await service.importBackup(envelope: envelope, into: destStore) + #expect(secondResult.messagesInserted == 0) + #expect(secondResult.messagesSkipped == 1) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + } + + /// Safety case: every local slot within the radio's channel capacity is occupied by a + /// different secret, so the backup channel has nowhere to land. It must be dropped (not + /// mis-associated), and its messages must be dropped with it rather than attaching to a + /// foreign channel. + @Test + func `Channel reconcile: backup channel with no free slot is dropped along with its messages`() async throws { + let radioID = UUID() + let maxChannels: UInt8 = 2 + let secretPraha = Data(repeating: 0xA4, count: 16) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // Fill every slot in [0, maxChannels) with distinct-secret local channels. + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 0, name: "#a", secret: Data(repeating: 0x10, count: 16)) + ) + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#b", secret: Data(repeating: 0x11, count: 16)) + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = maxChannels } + let backupPraha = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: 1, + name: "#praha", + secret: secretPraha + ) + var prahaMessage = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 1, + text: "Should not survive", + timestamp: 1_700_001_300, + direction: .incoming, + senderNodeName: "Jan" + ) + prahaMessage.deduplicationKey = "ch-1-1700001300-Jan-AABBCCDD" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupPraha], + messages: [prahaMessage] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + // #praha is dropped: not inserted, counted as dropped; its message is not inserted. + #expect(result.channelsInserted == 0) + #expect(result.channelsDropped == 1) + #expect(result.messagesInserted == 0) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(channels.count == 2) + #expect(channels.contains { $0.secret == secretPraha } == false) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.isEmpty) + } + + @Test + func `Dropped channels and their messages are accounted as dropped, not already-here`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + + // Local radio is full to capacity (maxChannels = 2: slot 0 public + slot 1 used). + let existing = ChannelDTO( + id: UUID(), radioID: radioID, index: 1, name: "Local", + secret: Data(repeating: 0x11, count: 32), isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + try await store.batchInsertChannels([existing], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 2]) + + // Backup channel at slot 1 with a different secret has no free slot -> dropped. + let backupChannel = ChannelDTO( + id: UUID(), radioID: radioID, index: 1, name: "Backup", + secret: Data(repeating: 0x22, count: 32), isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + let channelResult = try await store.batchInsertChannels( + [backupChannel], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 2] + ) + #expect(channelResult.dropped == 1) + #expect(channelResult.skipped == 0) + #expect(channelResult.droppedChannelIndices[radioID]?.contains(1) == true) + } + + @Test + func `Full import accounts a no-slot channel's messages as dropped and the manifest balances`() async throws { + let radioID = UUID() + let maxChannels: UInt8 = 2 + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // Fill every slot in [0, maxChannels) with distinct-secret local channels. + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 0, name: "#a", secret: Data(repeating: 0x10, count: 16)) + ) + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#b", secret: Data(repeating: 0x11, count: 16)) + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = maxChannels } + // One backup channel that merges into an existing slot (slot 0, same empty-ish handling + // avoided by using a distinct secret at slot 0) and one that has no free slot -> dropped. + let droppedChannel = ChannelDTO.testChannel( + id: UUID(), radioID: radioID, index: 1, name: "#dropped", secret: Data(repeating: 0xA4, count: 16) + ) + var droppedMessage1 = MessageDTO.testChannelMessage( + radioID: radioID, channelIndex: 1, text: "lost one", + timestamp: 1_700_002_000, direction: .incoming, senderNodeName: "Jan" + ) + droppedMessage1.deduplicationKey = "ch-1-1700002000-Jan-AABBCCDD" + var droppedMessage2 = MessageDTO.testChannelMessage( + radioID: radioID, channelIndex: 1, text: "lost two", + timestamp: 1_700_002_100, direction: .incoming, senderNodeName: "Eva" + ) + droppedMessage2.deduplicationKey = "ch-1-1700002100-Eva-AABBCCDE" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [droppedChannel], + messages: [droppedMessage1, droppedMessage2] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.counts[.channels]?.dropped == 1) + #expect(result.counts[.messages]?.dropped == 2) + + // Message balance invariant: every declared message is inserted, merged, skipped, or dropped. + let m = try #require(result.counts[.messages]) + #expect(envelope.manifest.messageCount == m.inserted + m.merged + m.skipped + m.dropped) + } + + /// Slot 0 is reserved for the public channel, so a relocating non-public channel skips it + /// even when free. Local `#brno` occupies slot 1; slots 0 and 2 are free. The colliding + /// backup `#praha` must relocate to slot 2, never slot 0. + @Test + func `Channel reconcile: relocation reserves slot 0 for the public channel`() async throws { + let radioID = UUID() + let secretBrno = Data(repeating: 0xB6, count: 16) + let secretPraha = Data(repeating: 0xA6, count: 16) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // Local channel occupies slot 1; slot 0 is intentionally left free (no public channel). + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#brno", secret: secretBrno) + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = 3 } + let backupPraha = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: 1, + name: "#praha", + secret: secretPraha + ) + + let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupPraha]) + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + let praha = try #require(channels.first { $0.secret == secretPraha }) + // Lowest free slot at or above 1 — slot 0 is skipped even though it is free. + #expect(praha.index == 2) + } + + // MARK: - Test 13: Fresh-store import — remote sessions start disconnected + + @Test + func `Import inserts remote sessions as disconnected while preserving backup metadata`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xF3, count: 32) + let importedDate = Date(timeIntervalSince1970: 1_700_000_200) + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupSession = RemoteNodeSessionDTO.testSession( + radioID: radioID, + publicKey: publicKey, + name: "Ops Room", + role: .roomServer, + isConnected: true, + permissionLevel: .admin, + lastConnectedDate: importedDate, + unreadCount: 5, + notificationLevel: .mentionsOnly, + isFavorite: true, + neighborCount: 4, + lastSyncTimestamp: 88, + lastMessageDate: importedDate + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + remoteNodeSessions: [backupSession] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.remoteNodeSessionsInserted == 1) + #expect(result.remoteNodeSessionsSkipped == 0) + + let importedSession = try #require(await destStore.fetchRemoteNodeSession(id: backupSession.id)) + #expect(importedSession.isConnected == false) + #expect(importedSession.permissionLevel == .admin) + #expect(importedSession.lastConnectedDate == importedDate) + #expect(importedSession.unreadCount == 5) + #expect(importedSession.notificationLevel == .mentionsOnly) + #expect(importedSession.isFavorite == true) + #expect(importedSession.lastSyncTimestamp == 88) + #expect(importedSession.lastMessageDate == importedDate) + } + + // MARK: - Test 14: Room-message delivery metadata survives import + + @Test + func `Import preserves room-message delivery metadata`() async throws { + let radioID = UUID() + let createdAt = Date(timeIntervalSince1970: 1_700_000_275.25) + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + let roomMessage = RoomMessageDTO( + id: UUID(), + sessionID: session.id, + authorKeyPrefix: Data([0xCA, 0xFE, 0xBA, 0xBE]), + authorName: "Ops", + text: "Retry me", + timestamp: 1_700_000_275, + createdAt: createdAt, + isFromSelf: true, + status: .failed, + ackCode: 0xDEAD_BEEF, + roundTripTime: 1450, + retryAttempt: 3, + maxRetryAttempts: 7 + ) + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + roomMessages: [roomMessage], + remoteNodeSessions: [session] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.roomMessagesInserted == 1) + + let importedMessage = try #require(await destStore.fetchRoomMessage(id: roomMessage.id)) + #expect(importedMessage.createdAt == createdAt) + #expect(importedMessage.status == .failed) + #expect(importedMessage.ackCode == 0xDEAD_BEEF) + #expect(importedMessage.roundTripTime == 1450) + #expect(importedMessage.retryAttempt == 3) + #expect(importedMessage.maxRetryAttempts == 7) + } + + // MARK: - Test 15: Merge import — remote session metadata restored + + @Test + func `Import onto existing remote session restores backup metadata without clobbering live state`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xF4, count: 32) + let localConnectedDate = Date(timeIntervalSince1970: 1_700_000_250) + let importedDate = Date(timeIntervalSince1970: 1_700_000_300) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingSession = RemoteNodeSessionDTO.testSession( + radioID: radioID, + publicKey: publicKey, + name: "Ops Room", + role: .roomServer, + isConnected: true, + permissionLevel: .admin, + lastConnectedDate: localConnectedDate, + unreadCount: 0, + notificationLevel: .all, + isFavorite: false, + neighborCount: 2, + lastSyncTimestamp: 123, + lastMessageDate: nil + ) + try await destStore.saveRemoteNodeSessionDTO(existingSession) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupSession = RemoteNodeSessionDTO.testSession( + id: UUID(), + radioID: radioID, + publicKey: publicKey, + name: "Ops Room", + role: .roomServer, + isConnected: false, + permissionLevel: .guest, + lastConnectedDate: Date(timeIntervalSince1970: 1_700_000_225), + unreadCount: 9, + notificationLevel: .mentionsOnly, + isFavorite: true, + neighborCount: 7, + lastSyncTimestamp: 8, + lastMessageDate: importedDate + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + remoteNodeSessions: [backupSession] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.remoteNodeSessionsInserted == 0) + #expect(result.remoteNodeSessionsSkipped == 1) + #expect(result.remoteNodeSessionsMerged == 1) + #expect(result.hasRestoredChanges) + + let mergedSession = try #require(await destStore.fetchRemoteNodeSession(id: existingSession.id)) + #expect(mergedSession.isConnected == true) + #expect(mergedSession.permissionLevel == .admin) + #expect(mergedSession.lastConnectedDate == localConnectedDate) + #expect(mergedSession.unreadCount == 9) + #expect(mergedSession.notificationLevel == .mentionsOnly) + #expect(mergedSession.isFavorite == true) + #expect(mergedSession.lastSyncTimestamp == 123) + #expect(mergedSession.lastMessageDate == importedDate) + } + + // MARK: - Test 16: Merge import — same-second node snapshots preserved + + @Test + func `Import preserves distinct node snapshots recorded within the same second`() async throws { + let radioID = UUID() + let nodePublicKey = Data(repeating: 0xF5, count: 32) + let baseTimestamp = Date(timeIntervalSince1970: 1_700_000_400) + let existingTimestamp = baseTimestamp.addingTimeInterval(0.100) + let importedTimestamp = baseTimestamp.addingTimeInterval(0.900) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + _ = try await destStore.saveNodeStatusSnapshot( + timestamp: existingTimestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: 3800, + lastSNR: 8.5, + lastRSSI: -90, + noiseFloor: -112, + uptimeSeconds: 60, + rxAirtimeSeconds: nil, + packetsSent: nil, + packetsReceived: nil, + receiveErrors: nil + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let existingSnapshot = NodeStatusSnapshotDTO.testSnapshot( + timestamp: existingTimestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: 3800, + lastSNR: 8.5, + lastRSSI: -90, + noiseFloor: -112, + uptimeSeconds: 60 + ) + let importedSnapshot = NodeStatusSnapshotDTO.testSnapshot( + timestamp: importedTimestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: nil, + lastSNR: nil, + lastRSSI: nil, + noiseFloor: nil, + uptimeSeconds: nil, + telemetryEntries: [TelemetrySnapshotEntry(channel: 1, type: "temperature", value: 21.5)] + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + nodeStatusSnapshots: [existingSnapshot, importedSnapshot] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.nodeStatusSnapshotsInserted == 1) + #expect(result.nodeStatusSnapshotsSkipped == 1) + + let snapshots = try await destStore.fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) + #expect(snapshots.count == 2) + #expect(snapshots.map(\.timestamp) == [existingTimestamp, importedTimestamp]) + #expect(snapshots.last?.telemetryEntries?.count == 1) + } + + // MARK: - Node status packet-type counters + + /// The six per-type packet counters are optional DTO fields, so the contract is a + /// plain encode → decode round-trip locking them at the DTO boundary. + @Test + func `Node status packet-type counters survive DTO encode → decode round-trip`() throws { + let dto = NodeStatusSnapshotDTO.testSnapshot( + sentDirect: 100, sentFlood: 200, + receivedDirect: 300, receivedFlood: 400, + directDuplicates: 11, floodDuplicates: 22 + ) + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(NodeStatusSnapshotDTO.self, from: encoded) + #expect(decoded.sentDirect == 100) + #expect(decoded.sentFlood == 200) + #expect(decoded.receivedDirect == 300) + #expect(decoded.receivedFlood == 400) + #expect(decoded.directDuplicates == 11) + #expect(decoded.floodDuplicates == 22) + } + + /// A legacy envelope written before these fields existed omits the six keys, so they + /// must decode as `nil` rather than failing the decode (per the optional-field rule). + @Test + func `Legacy snapshot envelope without packet-type counters decodes them as nil`() throws { + let dto = NodeStatusSnapshotDTO.testSnapshot( + sentDirect: 1, sentFlood: 2, receivedDirect: 3, receivedFlood: 4, + directDuplicates: 5, floodDuplicates: 6 + ) + let encoded = try JSONEncoder().encode(dto) + let object = try JSONSerialization.jsonObject(with: encoded) + var json = try #require(object as? [String: Any]) + for key in ["sentDirect", "sentFlood", "receivedDirect", "receivedFlood", + "directDuplicates", "floodDuplicates"] { + json.removeValue(forKey: key) + } + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(NodeStatusSnapshotDTO.self, from: stripped) + #expect(decoded.sentDirect == nil) + #expect(decoded.sentFlood == nil) + #expect(decoded.receivedDirect == nil) + #expect(decoded.receivedFlood == nil) + #expect(decoded.directDuplicates == nil) + #expect(decoded.floodDuplicates == nil) + // A field present in legacy envelopes still decodes. + #expect(decoded.batteryMillivolts == 3800) + } + + /// Full envelope path: the six counters must survive export → import into a fresh store. + @Test + func `Node status packet-type counters survive full backup export → import`() async throws { + let radioID = UUID() + let nodePublicKey = Data(repeating: 0xF7, count: 32) + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let backupSnapshot = NodeStatusSnapshotDTO.testSnapshot( + nodePublicKey: nodePublicKey, + sentDirect: 100, sentFlood: 200, + receivedDirect: 300, receivedFlood: 400, + directDuplicates: 11, floodDuplicates: 22 + ) + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + nodeStatusSnapshots: [backupSnapshot] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + #expect(result.nodeStatusSnapshotsInserted == 1) + + let snapshots = try await destStore.fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) + let restored = try #require(snapshots.first) + #expect(restored.sentDirect == 100) + #expect(restored.sentFlood == 200) + #expect(restored.receivedDirect == 300) + #expect(restored.receivedFlood == 400) + #expect(restored.directDuplicates == 11) + #expect(restored.floodDuplicates == 22) + } + + // MARK: - Test 17: Failed import cleanup + + @Test + func `Successful import restores autosave on the destination store`() async throws { + let radioID = UUID() + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + await destStore.setAutosaveEnabledForTesting(true) + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.devicesInserted == 1) + #expect(await destStore.autosaveEnabledForTesting()) + #expect(await !(destStore.hasPendingChangesForTesting())) + #expect(try await destStore.fetchAllDevices().count == 1) + } + + @Test + func `Failed import rolls back reconcile-phase mutations to pre-existing rows`() async throws { + let radioID = UUID() + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + await destStore.setAutosaveEnabledForTesting(true) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xC2, count: 32), + name: "Pre-existing" + ) + try await destStore.saveContact(contact) + // Pre-existing contacts always have lastMessageDate = nil out of the gate. + let baselineContacts = try await destStore.fetchAllContacts(radioID: radioID) + let preImportLastMessageDate = baselineContacts.first?.lastMessageDate + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupMessage = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Reconcile target", + timestamp: 99999 + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [backupMessage] + ) + + await destStore.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } + + await #expect(throws: InjectedImportFailure.simulated) { + try await destStore.importBackupDatabase(envelope) + } + + let postImportContacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(postImportContacts.count == 1) + #expect(postImportContacts.first?.lastMessageDate == preImportLastMessageDate) + #expect(try await destStore.fetchAllMessages().isEmpty) + #expect(await !(destStore.hasPendingChangesForTesting())) + } + + @Test + func `Failed import clears pending data and restores autosave`() async throws { + let radioID = UUID() + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + await destStore.setAutosaveEnabledForTesting(true) + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)] + ) + + await destStore.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } + + await #expect(throws: InjectedImportFailure.simulated) { + try await destStore.importBackupDatabase(envelope) + } + + #expect(await destStore.autosaveEnabledForTesting()) + #expect(await !(destStore.hasPendingChangesForTesting())) + #expect(try await destStore.fetchAllDevices().isEmpty) + + try await destStore.saveContact( + ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xC1, count: 32), + name: "Recovered Contact" + ) + ) + + let contacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(contacts.count == 1) + #expect(contacts.first?.name == "Recovered Contact") + } + + @Test + func `Disk-backed container has zero partial state after faulted import is abandoned`() async throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("backup-crash-\(UUID().uuidString).store") + defer { + let fm = FileManager.default + try? fm.removeItem(at: storeURL) + try? fm.removeItem(at: storeURL.appendingPathExtension("shm")) + try? fm.removeItem(at: storeURL.appendingPathExtension("wal")) + } + + let radioID = UUID() + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [ + ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0x42, count: 32), + name: "Should not survive" + ) + ] + ) + + // Throw before save() to exercise the error path. With autosaveEnabled = false + // and save() never reached, nothing was flushed to SQLite — the defer's + // rollback() just clears the in-memory context. This is the error path, not a + // crash path: Swift's defer runs on throw, so a true bypassed-defer scenario + // would require a child process that exits mid-import. + do { + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let container = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let store = PersistenceStore(modelContainer: container) + await store.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } + await #expect(throws: InjectedImportFailure.simulated) { + try await store.importBackupDatabase(envelope) + } + } + + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let reopened = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let freshStore = PersistenceStore(modelContainer: reopened) + + #expect(try await freshStore.fetchAllDevices().isEmpty) + #expect(try await freshStore.fetchAllContacts(radioID: radioID).isEmpty) + } + + @Test + func `Concurrent live-store writer during import preserves both datasets`() async throws { + // Two @ModelActor instances on the same ModelContainer simulate a radio + // connecting mid-import: the backup flow resolved a standalone + // PersistenceStore at T=0, then ConnectionManager stood up a second + // PersistenceStore on the same container to service the live link. + let sharedContainer = try PersistenceStore.createContainer(inMemory: true) + let backupStore = PersistenceStore(modelContainer: sharedContainer) + let liveStore = PersistenceStore(modelContainer: sharedContainer) + + let backupRadioID = UUID() + let liveRadioID = UUID() + let backupDevicePublicKey = Data(repeating: 0xB0, count: 32) + let liveDevicePublicKey = Data(repeating: 0xC0, count: 32) + + let backupContact = ContactDTO.testContact( + radioID: backupRadioID, + publicKey: Data(repeating: 0xB1, count: 32), + name: "From backup" + ) + let envelope = AppBackupEnvelope.test( + devices: [ + DeviceDTO.testDevice( + id: backupRadioID, + radioID: backupRadioID, + publicKey: backupDevicePublicKey + ) + ], + contacts: [backupContact] + ) + + try await liveStore.saveDevice( + DeviceDTO.testDevice( + id: liveRadioID, + radioID: liveRadioID, + publicKey: liveDevicePublicKey + ) + ) + let liveContact = ContactDTO.testContact( + radioID: liveRadioID, + publicKey: Data(repeating: 0xC1, count: 32), + name: "From connect" + ) + + async let importResult: ImportResult = backupStore.importBackupDatabase(envelope) + async let liveWrite: Void = liveStore.saveContact(liveContact) + + _ = try await importResult + try await liveWrite + + // A third actor guarantees we read through the persistent store rather than + // either writer's context cache — `fetchAllContacts` on the writers can miss + // the other actor's commits until the cache invalidates. + let verifier = PersistenceStore(modelContainer: sharedContainer) + let liveContacts = try await verifier.fetchAllContacts(radioID: liveRadioID) + #expect(liveContacts.contains { $0.publicKey == liveContact.publicKey }) + let backupContacts = try await verifier.fetchAllContacts(radioID: backupRadioID) + #expect(backupContacts.contains { $0.publicKey == backupContact.publicKey }) + + let allDevices = try await verifier.fetchAllDevices() + #expect(allDevices.count == 2) + } + + // MARK: - Test 18: Export assigns content-based keys to nil-keyed messages + + @Test + func `Export assigns content-based dedup keys to incoming messages with nil deduplicationKey`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xF6, count: 32), + name: "Dan" + ) + try await sourceStore.saveContact(contact) + + // Save an incoming DM with nil deduplicationKey (simulates pre-migration message) + let dm = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Pre-migration DM", + timestamp: 12345, + direction: .incoming + ) + try await sourceStore.saveMessage(dm) + + // Save an incoming channel message with nil deduplicationKey + let chMsg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 2, + text: "Pre-migration channel msg", + timestamp: 67890, + direction: .incoming, + senderNodeName: "Node1" + ) + try await sourceStore.saveMessage(chMsg) + + // Export + let service = AppBackupService() + let result = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: result.data) + + // Verify exported messages have content-based keys, not backup- + let exportedDM = try #require(envelope.messages.first { $0.contactID == contact.id }) + #expect(exportedDM.deduplicationKey?.hasPrefix("dm-") == true) + #expect(exportedDM.deduplicationKey?.hasPrefix("backup-") != true) + + let exportedCh = try #require(envelope.messages.first { $0.channelIndex == 2 }) + #expect(exportedCh.deduplicationKey?.hasPrefix("ch-") == true) + #expect(exportedCh.deduplicationKey?.hasPrefix("backup-") != true) + } + + // MARK: - Test 19: Export preserves existing content-based keys + + @Test + func `Export preserves existing content-based dedup keys unchanged`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact(radioID: radioID) + try await sourceStore.saveContact(contact) + + let existingKey = "dm-\(contact.id.uuidString)-99999-AABBCCDD" + var msg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Already has key" + ) + msg.deduplicationKey = existingKey + try await sourceStore.saveMessage(msg) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: result.data) + + let exported = try #require(envelope.messages.first) + #expect(exported.deduplicationKey == existingKey) + } + + // MARK: - Test 23: Duplicates within a single backup don't orphan their children + + /// Two incoming messages in the same envelope that share a content key — the + /// second is skipped (same wire packet seen twice), and its repeats/reactions + /// must be remapped to the first (winning) UUID. + @Test + func `Duplicate messages within one envelope: children of the skipped duplicate link to the inserted message`() async throws { + let radioID = UUID() + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + + var firstMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Duplicate in envelope", + timestamp: 1_700_000_500, + direction: .incoming + ) + firstMsg.deduplicationKey = nil + + var secondMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Duplicate in envelope", + timestamp: 1_700_000_500, + direction: .incoming + ) + secondMsg.deduplicationKey = nil + #expect(firstMsg.id != secondMsg.id) + + // Children reference the second (to-be-skipped) message's UUID. + let repeatForSecond = MessageRepeatDTO.testRepeat( + messageID: secondMsg.id, + pathNodes: Data([0x11]) + ) + let reactionForSecond = ReactionDTO.testReaction( + messageID: secondMsg.id, + radioID: radioID, + emoji: "🌶️", + senderName: "Dup" + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [firstMsg, secondMsg], + messageRepeats: [repeatForSecond], + reactions: [reactionForSecond] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.messagesInserted == 1) + #expect(result.messagesSkipped == 1) + #expect(result.messageRepeatsInserted == 1) + #expect(result.reactionsInserted == 1) + + // Children must attach to the first (winning) UUID; the second UUID must have no rows. + let repeatsUnderFirst = try await destStore.fetchMessageRepeats(messageID: firstMsg.id) + #expect(repeatsUnderFirst.count == 1) + let orphanedRepeats = try await destStore.fetchMessageRepeats(messageID: secondMsg.id) + #expect(orphanedRepeats.isEmpty) + + let winner = try #require(await destStore.fetchMessage(id: firstMsg.id)) + #expect(winner.heardRepeats == 1) + #expect(winner.reactionSummary == "🌶️:1") + } + + // MARK: - Test 24: Cancellation after DB commit reports success, not cancelled + + /// A task cancelled between the DB commit and the rest of `importBackup` + /// must not throw CancellationError. The DB write has already landed, so + /// a throw here would report cancellation while the database actually + /// persisted. + @Test + func `Task cancellation after DB commit does not throw and returns a successful result`() async throws { + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice()] + ) + + // Post-commit hook cancels the task that's running the import (the + // child Task below), mirroring the race where the user taps Cancel + // mid-save. Running the import in a child Task keeps the outer test's + // cancellation state clean so post-import fetches can run. + await destStore.setBackupImportPostCommitHook { + withUnsafeCurrentTask { $0?.cancel() } + } + + let service = AppBackupService() + let importTask = Task { + try await service.importBackup(envelope: envelope, into: destStore) + } + + let result: ImportResult + do { + result = try await importTask.value + } catch { + Issue.record("Import after post-commit cancellation should succeed, got: \(error)") + return + } + + #expect(result.devicesInserted == 1) + #expect(try await destStore.fetchAllDevices().count == 1) + } + + // MARK: - Test 25: userDefaultsRestored reflects actual writes + + /// When every UserDefaults key carried in the backup is already set + /// locally, `restore(to:)` writes nothing. The import result must then + /// report `userDefaultsRestored == false`, otherwise a second no-op + /// import would claim `hasRestoredChanges` with nothing actually changed. + @Test + func `Import reports userDefaultsRestored=false when no new keys were written`() async throws { + let key = "hasCompletedOnboarding" + let suiteName = "test.\(UUID().uuidString)" + // UserDefaults is thread-safe but not marked Sendable, so reusing this value + // across the importBackup actor boundary needs the isolation opt-out. + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: key) + + var backupDefaults = BackupUserDefaults() + backupDefaults.hasCompletedOnboarding = true + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice()], + userDefaults: backupDefaults + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore, defaults: defaults) + + #expect(!result.userDefaultsRestored) + } + + // MARK: - Test 26: Fresh-store import — contact unread counts preserved + + @Test + func `Fresh-insert import preserves contact unread counts from backup`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xE5, count: 32) + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupContact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + unreadCount: 7, + unreadMentionCount: 2 + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [backupContact] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.contactsInserted == 1) + #expect(result.contactsSkipped == 0) + + let importedContact = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect(importedContact.unreadCount == 7) + #expect(importedContact.unreadMentionCount == 2) + } + + // MARK: - Test 27: Fresh-store import — channel unread counts preserved + + @Test + func `Fresh-insert import preserves channel unread counts from backup`() async throws { + let radioID = UUID() + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 4, + unreadCount: 3, + unreadMentionCount: 1 + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + channels: [backupChannel] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 1) + #expect(result.channelsSkipped == 0) + + let importedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 4)) + #expect(importedChannel.unreadCount == 3) + #expect(importedChannel.unreadMentionCount == 1) + } + + // MARK: - Test 28: Merge import — contact unread counts max with local + + @Test + func `Merge import keeps local contact unread counts when they exceed backup values`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xE6, count: 32) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingContact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + unreadCount: 9, + unreadMentionCount: 4 + ) + try await destStore.saveContact(existingContact) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupContact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + unreadCount: 2, + unreadMentionCount: 1 + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + contacts: [backupContact] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.contactsInserted == 0) + #expect(result.contactsSkipped == 1) + + let mergedContact = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect(mergedContact.unreadCount == 9) + #expect(mergedContact.unreadMentionCount == 4) + } + + // MARK: - Test 29: Merge import — channel unread counts max with local + + @Test + func `Merge import keeps local channel unread counts when they exceed backup values`() async throws { + let radioID = UUID() + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 5, + unreadCount: 6, + unreadMentionCount: 3 + ) + try await destStore.saveChannel(existingChannel) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 5, + unreadCount: 1, + unreadMentionCount: 0 + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupChannel] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + + let mergedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 5)) + #expect(mergedChannel.unreadCount == 6) + #expect(mergedChannel.unreadMentionCount == 3) + } + + // MARK: - Test 30: Merge import — remote session unread count max with local + + @Test + func `Merge import keeps local remote-session unread count when it exceeds backup value`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xE7, count: 32) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingSession = RemoteNodeSessionDTO.testSession( + radioID: radioID, + publicKey: publicKey, + unreadCount: 8 + ) + try await destStore.saveRemoteNodeSessionDTO(existingSession) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupSession = RemoteNodeSessionDTO.testSession( + id: UUID(), + radioID: radioID, + publicKey: publicKey, + unreadCount: 2 + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + remoteNodeSessions: [backupSession] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.remoteNodeSessionsInserted == 0) + #expect(result.remoteNodeSessionsSkipped == 1) + + let mergedSession = try #require(await destStore.fetchRemoteNodeSession(id: existingSession.id)) + #expect(mergedSession.unreadCount == 8) + } + + // MARK: - Outgoing duplicates must not collapse + + /// A user who double-taps send (or retries within the same UInt32 second) ends up + /// with two outgoing rows that share recipient + text + timestamp. They are two + /// distinct intentional actions, and backup/restore must keep both. + @Test + func `Outgoing messages with identical recipient/text/timestamp survive round-trip without deduplication`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await sourceStore.saveContact(contact) + + let sharedTimestamp: UInt32 = 1_700_001_234 + + let firstMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "ok", + timestamp: sharedTimestamp, + direction: .outgoing + ) + let secondMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "ok", + timestamp: sharedTimestamp, + direction: .outgoing + ) + #expect(firstMsg.id != secondMsg.id) + try await sourceStore.saveMessage(firstMsg) + try await sourceStore.saveMessage(secondMsg) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.messagesInserted == 2) + #expect(result.messagesSkipped == 0) + + let destMessages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(destMessages.count == 2) + #expect(Set(destMessages.map(\.id)) == Set([firstMsg.id, secondMsg.id])) + } + + // MARK: - Repeat rows through the same route must not collapse + + /// `MessageRepeat` distinguishes hearings by `id`/`rxLogEntryID`. Two observations + /// of the same message through the same path (e.g. a flood echo) are genuinely + /// distinct and must both survive round-trip so `heardRepeats` stays accurate. + @Test + func `Repeats with identical path but distinct ids survive round-trip and heardRepeats is recomputed`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await sourceStore.saveContact(contact) + + var msg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "echoed", + direction: .outgoing + ) + msg.deduplicationKey = nil + try await sourceStore.saveMessage(msg) + + let sharedPath = Data([0x42]) + let firstRepeat = MessageRepeatDTO.testRepeat( + messageID: msg.id, + pathNodes: sharedPath, + rxLogEntryID: UUID() + ) + let secondRepeat = MessageRepeatDTO.testRepeat( + messageID: msg.id, + pathNodes: sharedPath, + rxLogEntryID: UUID() + ) + #expect(firstRepeat.id != secondRepeat.id) + try await sourceStore.saveMessageRepeat(firstRepeat) + try await sourceStore.saveMessageRepeat(secondRepeat) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.messageRepeatsInserted == 2) + #expect(result.messageRepeatsSkipped == 0) + + let restoredRepeats = try await destStore.fetchMessageRepeats(messageID: msg.id) + #expect(restoredRepeats.count == 2) + #expect(Set(restoredRepeats.map(\.id)) == Set([firstRepeat.id, secondRepeat.id])) + + let restoredMsg = try #require(await destStore.fetchMessage(id: msg.id)) + #expect(restoredMsg.heardRepeats == 2) + } + + // MARK: - Reply chain remap + + /// When a backup's replied-to parent already exists locally (content-keyed merge), + /// the reply's replyToID must be rewritten onto the local parent UUID — otherwise + /// reply navigation dangles on the pre-merge backup UUID. + @Test + func `Reply remaps onto local parent when parent is merged during import`() async throws { + let radioID = UUID() + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await destStore.saveContact(contact) + + var existingParent = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Parent", + direction: .incoming + ) + existingParent.deduplicationKey = "reply-remap-parent" + try await destStore.saveMessage(existingParent) + + // Backup contains the same parent (will be skipped/merged) and a reply + // whose replyToID points at the backup-side parent UUID. + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + var backupParent = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Parent", + direction: .incoming + ) + backupParent.deduplicationKey = "reply-remap-parent" + #expect(backupParent.id != existingParent.id) + + var reply = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Reply", + direction: .incoming, + replyToID: backupParent.id + ) + reply.deduplicationKey = "reply-remap-reply" + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [backupParent, reply] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + #expect(result.messagesSkipped == 1) + #expect(result.messagesInserted == 1) + + let destMessages = try await destStore.fetchAllMessages(radioID: radioID) + let restoredReply = try #require(destMessages.first { $0.id == reply.id }) + #expect(restoredReply.replyToID == existingParent.id) + } + + // MARK: - Cross-radio channel message preservation + + /// Two companion radios often receive the same over-the-air channel packet. The stored + /// live-sync deduplicationKey is radio-agnostic by construction, so the backup-reconciliation + /// key must be scoped by radioID. Otherwise the import path collapses both rows into one + /// and the second companion's channel view renders blank after restore — the same failure + /// mode fixed in live sync (issue #288) leaking back through backup/restore. + @Test + func `Backup restores the same channel packet under each companion radio that received it`() async throws { + let radioA = UUID() + let radioB = UUID() + let deviceA = DeviceDTO.testDevice( + id: radioA, + radioID: radioA, + publicKey: Data(repeating: 0xAA, count: 32), + nodeName: "CompanionA" + ) + let deviceB = DeviceDTO.testDevice( + id: radioB, + radioID: radioB, + publicKey: Data(repeating: 0xBB, count: 32), + nodeName: "CompanionB" + ) + let channelA = ChannelDTO.testChannel(radioID: radioA, index: 0, name: "General") + let channelB = ChannelDTO.testChannel(radioID: radioB, index: 0, name: "General") + + // Same wire packet — identical content-based dedup key — seen by both companions. + let sharedDedupKey = "ch-0-1700000000-Alice-A1B2C3D4" + var msgFromA = MessageDTO.testChannelMessage( + radioID: radioA, + channelIndex: 0, + text: "aaaaa", + timestamp: 1_700_000_000, + direction: .incoming, + status: .delivered, + senderNodeName: "Alice" + ) + msgFromA.deduplicationKey = sharedDedupKey + + var msgFromB = MessageDTO.testChannelMessage( + radioID: radioB, + channelIndex: 0, + text: "aaaaa", + timestamp: 1_700_000_000, + direction: .incoming, + status: .delivered, + senderNodeName: "Alice" + ) + msgFromB.deduplicationKey = sharedDedupKey + + let envelope = AppBackupEnvelope.test( + devices: [deviceA, deviceB], + channels: [channelA, channelB], + messages: [msgFromA, msgFromB] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.messagesInserted == 2, + "Both companions' copies of the same channel packet must be restored — otherwise the second companion's channel view goes blank post-restore") + #expect(result.messagesSkipped == 0) + + let messagesForA = try await destStore.fetchAllMessages(radioID: radioA) + let messagesForB = try await destStore.fetchAllMessages(radioID: radioB) + #expect(messagesForA.count == 1) + #expect(messagesForB.count == 1) + #expect(messagesForA.first?.text == "aaaaa") + #expect(messagesForB.first?.text == "aaaaa") + } + + // MARK: - regionSelection backup contract + + @Test + func `regionSelection round-trips through encode/decode`() throws { + var prefs = BackupUserDefaults() + prefs.regionSelection = RegionSelection( + countryCode: "US", + administrativeAreaCode: "US-CA", + countyKey: "los angeles", + source: .location + ) + let data = try JSONEncoder().encode(prefs) + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + #expect(decoded.regionSelection == prefs.regionSelection) + } + + @Test + func `Legacy envelope without regionSelection decodes as nil`() throws { + let legacyJSON = """ + { + "hasCompletedOnboarding": true, + "mapStyleSelection": "topo" + } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) + #expect(decoded.regionSelection == nil) + #expect(decoded.hasCompletedOnboarding == true) + } + + @Test + func `restore writes regionSelection only when local key is missing`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let local = RegionSelection(countryCode: "DE", source: .manual) + try defaults.set(JSONEncoder().encode(local), forKey: BackupUserDefaults.regionSelectionKey) + + var prefs = BackupUserDefaults() + prefs.regionSelection = RegionSelection(countryCode: "US", source: .location) + let setKeys = prefs.restore(to: defaults) + #expect(!setKeys.contains(BackupUserDefaults.regionSelectionKey)) + + let stillThere = try JSONDecoder().decode( + RegionSelection.self, + from: #require(defaults.data(forKey: BackupUserDefaults.regionSelectionKey)) + ) + #expect(stillThere == local) + } + + @Test + func `restore writes regionSelection when local is missing (fresh install)`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + var prefs = BackupUserDefaults() + prefs.regionSelection = RegionSelection(countryCode: "US", source: .location) + let setKeys = prefs.restore(to: defaults) + + #expect(setKeys.contains(BackupUserDefaults.regionSelectionKey)) + let restored = try JSONDecoder().decode( + RegionSelection.self, + from: #require(defaults.data(forKey: BackupUserDefaults.regionSelectionKey)) + ) + #expect(restored == prefs.regionSelection) + } + + // MARK: - Message.regionScope round-trip + + @Test + func `Message.regionScope round-trips through full export/import`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xAB, count: 32), + name: "Alice" + ) + try await sourceStore.saveContact(contact) + + var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id, text: "Greetings") + msg.regionScope = "Germany" + msg.deduplicationKey = "region-scope-roundtrip-\(UUID())" + try await sourceStore.saveMessage(msg) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + _ = try await service.importBackup(envelope: envelope, into: destStore) + + let restored = try await destStore.fetchAllMessages(radioID: radioID) + #expect(restored.count == 1) + #expect(restored.first?.regionScope == "Germany") + } + + @Test + func `MessageDTO Codable: regionScope set decodes round-trip`() throws { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Test") + dto.regionScope = "Bavaria" + + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded.regionScope == "Bavaria") + } + + @Test + func `Legacy MessageDTO envelope without regionScope decodes as nil`() throws { + let baseDTO = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Legacy") + let encoded = try JSONEncoder().encode(baseDTO) + var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + json.removeValue(forKey: "regionScope") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) + #expect(decoded.regionScope == nil) + } + + @Test + func `Message(dto:) forwards regionScope verbatim through DTO to model`() { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Forward") + dto.regionScope = "USA" + let model = Message(dto: dto) + #expect(model.regionScope == "USA") + + var nilDTO = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "NilCase") + nilDTO.regionScope = nil + let nilModel = Message(dto: nilDTO) + #expect(nilModel.regionScope == nil) + } + + // MARK: - MessageDTO.sortDate round-trip + + @Test + func `MessageDTO Codable: sortDate distinct from createdAt round-trips`() throws { + let createdAt = Date(timeIntervalSince1970: 1_700_000_000) + let sortDate = Date(timeIntervalSince1970: 1_600_000_000) + var dto = MessageDTO.testDirectMessage( + radioID: UUID(), + contactID: UUID(), + text: "Test", + createdAt: createdAt + ) + dto.sortDate = sortDate + + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded.sortDate == sortDate) + #expect(decoded.sortDate != decoded.createdAt) + } + + @Test + func `Legacy MessageDTO envelope without sortDate falls back to createdAt`() throws { + let baseDTO = MessageDTO.testDirectMessage( + radioID: UUID(), + contactID: UUID(), + text: "Legacy", + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let encoded = try JSONEncoder().encode(baseDTO) + var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + json.removeValue(forKey: "sortDate") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) + #expect(decoded.sortDate == decoded.createdAt) + } + + @Test + func `Message(dto:) forwards sortDate verbatim through DTO to model`() { + let createdAt = Date(timeIntervalSince1970: 1_700_000_000) + let sortDate = Date(timeIntervalSince1970: 1_600_000_000) + var dto = MessageDTO.testDirectMessage( + radioID: UUID(), + contactID: UUID(), + text: "Forward", + createdAt: createdAt + ) + dto.sortDate = sortDate + let model = Message(dto: dto) + #expect(model.sortDate == sortDate) + #expect(model.sortDate != model.createdAt) + } + + @Test + func `Fully-populated MessageDTO survives encode/decode with every field distinct`() throws { + let dto = MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + channelIndex: 7, + text: "Every field set", + timestamp: 1_700_000_001, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + direction: .incoming, + status: .delivered, + textType: .signedPlain, + ackCode: 0xDEAD_BEEF, + pathLength: 5, + snr: 12.5, + pathNodes: Data([0x01, 0x02, 0x03]), + senderKeyPrefix: Data([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), + senderNodeName: "Bob", + isRead: true, + replyToID: UUID(), + roundTripTime: 432, + heardRepeats: 3, + sendCount: 2, + retryAttempt: 1, + maxRetryAttempts: 4, + deduplicationKey: "dedup-key", + linkPreviewURL: "https://example.com", + linkPreviewTitle: "Example", + linkPreviewImageData: Data([0x10, 0x20]), + linkPreviewIconData: Data([0x30, 0x40]), + linkPreviewFetched: true, + containsSelfMention: true, + mentionSeen: true, + timestampCorrected: true, + senderTimestamp: 1_699_999_999, + reactionSummary: "👍:3,❤️:2", + routeType: .tcDirect, + regionScope: "Germany" + ) + var populated = dto + populated.sortDate = Date(timeIntervalSince1970: 1_600_000_000) + + let encoded = try JSONEncoder().encode(populated) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded == populated) + } + + @Test + func `saveMessage persists send metadata and preview fields through export and import`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + var msg = MessageDTO.testDirectMessage(radioID: radioID, sendCount: 3) + msg.deduplicationKey = "save-message-fields-\(UUID())" + msg.linkPreviewURL = "https://example.com" + msg.linkPreviewTitle = "Example" + msg.reactionSummary = "👍:2" + try await sourceStore.saveMessage(msg) + + // saveMessage routes through Message(dto:), so the live row keeps every DTO field. + let savedRow = try #require(await sourceStore.fetchMessage(id: msg.id)) + #expect(savedRow.sendCount == 3) + #expect(savedRow.linkPreviewURL == "https://example.com") + #expect(savedRow.linkPreviewTitle == "Example") + #expect(savedRow.reactionSummary == "👍:2") + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + _ = try await service.importBackup(envelope: envelope, into: destStore) + + let imported = try #require(await destStore.fetchMessage(id: msg.id)) + #expect(imported.sendCount == 3) + #expect(imported.linkPreviewURL == "https://example.com") + #expect(imported.linkPreviewTitle == "Example") + #expect(imported.reactionSummary == "👍:2") + } + + // MARK: - selectedThemeID backup contract + + @Test + func `selectedThemeID round-trips through encode/decode`() throws { + var prefs = BackupUserDefaults() + prefs.selectedThemeID = "ember" + let data = try JSONEncoder().encode(prefs) + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + #expect(decoded.selectedThemeID == "ember") + } + + @Test + func `Legacy envelope without selectedThemeID decodes as nil`() throws { + let legacyJSON = """ + { "hasCompletedOnboarding": true, "mapStyleSelection": "topo" } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) + #expect(decoded.selectedThemeID == nil) + #expect(decoded.hasCompletedOnboarding == true) + } + + @Test + func `restore writes selectedThemeID only when local key is missing`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + defaults.set("marine", forKey: PersistenceKeys.selectedThemeID) + var prefs = BackupUserDefaults() + prefs.selectedThemeID = "ember" + let setKeys = prefs.restore(to: defaults) + #expect(!setKeys.contains(PersistenceKeys.selectedThemeID)) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == "marine") + } + + @Test + func `restore writes selectedThemeID when local is missing (fresh install)`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + var prefs = BackupUserDefaults() + prefs.selectedThemeID = "ember" + let setKeys = prefs.restore(to: defaults) + #expect(setKeys.contains(PersistenceKeys.selectedThemeID)) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == "ember") + } + + // MARK: - appColorSchemePreference backup contract + + @Test + func `Legacy envelope without appColorSchemePreference decodes as nil`() throws { + let legacyJSON = """ + { "hasCompletedOnboarding": true, "mapStyleSelection": "topo" } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) + #expect(decoded.appColorSchemePreference == nil) + } + + @Test + func `restore writes appColorSchemePreference only when local key is missing`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + defaults.set("light", forKey: PersistenceKeys.appColorSchemePreference) + var prefs = BackupUserDefaults() + prefs.appColorSchemePreference = "dark" + let setKeys = prefs.restore(to: defaults) + #expect(!setKeys.contains(PersistenceKeys.appColorSchemePreference)) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "light") + } + + @Test + func `restore writes appColorSchemePreference when local is missing (fresh install)`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + var prefs = BackupUserDefaults() + prefs.appColorSchemePreference = "dark" + let setKeys = prefs.restore(to: defaults) + #expect(setKeys.contains(PersistenceKeys.appColorSchemePreference)) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "dark") + } + + // MARK: - Export read path (snapshot) for the new appearance keys + + @Test + func `snapshot reads selectedThemeID and appColorSchemePreference from UserDefaults`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + defaults.set("marine", forKey: PersistenceKeys.selectedThemeID) + defaults.set("dark", forKey: PersistenceKeys.appColorSchemePreference) + let snapshot = BackupUserDefaults.snapshot(from: defaults) + #expect(snapshot.selectedThemeID == "marine") + #expect(snapshot.appColorSchemePreference == "dark") + } + + @Test + func `snapshot leaves appearance keys nil when unset`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let snapshot = BackupUserDefaults.snapshot(from: defaults) + #expect(snapshot.selectedThemeID == nil) + #expect(snapshot.appColorSchemePreference == nil) + } + + // MARK: - Merge import preserves non-default local metadata + + @Test + func `Import onto a muted region-scoped channel preserves the local notification and flood settings`() async throws { + let radioID = UUID() + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 2, + name: "Ops", + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .muted, + isFavorite: false, + floodScope: .region("SK") + ) + try await destStore.saveChannel(existingChannel) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: existingChannel.index, + name: existingChannel.name, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .mentionsOnly, + isFavorite: false, + floodScope: .region("US") + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupChannel] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + + let mergedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 2)) + #expect(mergedChannel.notificationLevel == .muted) + #expect(mergedChannel.regionScope == "SK") + } + + @Test + func `Import onto a muted remote session preserves the local muted notification level`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xF6, count: 32) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingSession = RemoteNodeSessionDTO.testSession( + radioID: radioID, + publicKey: publicKey, + name: "Ops Room", + role: .roomServer, + isConnected: false, + permissionLevel: .guest, + lastConnectedDate: nil, + unreadCount: 0, + notificationLevel: .muted, + isFavorite: false, + neighborCount: 0, + lastSyncTimestamp: 0, + lastMessageDate: nil + ) + try await destStore.saveRemoteNodeSessionDTO(existingSession) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupSession = RemoteNodeSessionDTO.testSession( + id: UUID(), + radioID: radioID, + publicKey: publicKey, + name: "Ops Room", + role: .roomServer, + isConnected: false, + permissionLevel: .guest, + lastConnectedDate: nil, + unreadCount: 0, + notificationLevel: .mentionsOnly, + isFavorite: false, + neighborCount: 0, + lastSyncTimestamp: 0, + lastMessageDate: nil + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + remoteNodeSessions: [backupSession] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.remoteNodeSessionsInserted == 0) + #expect(result.remoteNodeSessionsSkipped == 1) + + let mergedSession = try #require(await destStore.fetchRemoteNodeSession(id: existingSession.id)) + #expect(mergedSession.notificationLevel == .muted) + } + + @Test + func `Import onto a muted contact never un-mutes, un-blocks, or un-favorites it`() async throws { + let radioID = UUID() + let sharedPublicKey = Data(repeating: 0xE7, count: 32) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingContact = ContactDTO.testContact( + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + nickname: "Field Ops", + isBlocked: true, + isMuted: true, + isFavorite: true, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0 + ) + try await destStore.saveContact(existingContact) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupContact = ContactDTO.testContact( + id: UUID(), + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + nickname: "Elsewhere", + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0 + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + contacts: [backupContact] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.contactsInserted == 0) + #expect(result.contactsSkipped == 1) + + let mergedContact = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: sharedPublicKey) + ) + #expect(mergedContact.isMuted == true) + #expect(mergedContact.isBlocked == true) + #expect(mergedContact.isFavorite == true) + #expect(mergedContact.nickname == "Field Ops") + } +} - // MARK: - Test 1: Full round-trip - - /// Exports from a populated store and imports into a fresh store, verifying all data survives. - @Test("Full round-trip: export then import into fresh store restores all data") - func fullRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Seed data - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xAB, count: 32), - name: "Alice" - ) - try await sourceStore.saveContact(contact) - - let channel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") - try await sourceStore.saveChannel(channel) - - var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id, text: "Hello") - msg.deduplicationKey = "integration-dedup-\(UUID())" - try await sourceStore.saveMessage(msg) - - let reaction = ReactionDTO.testReaction(messageID: msg.id, radioID: radioID) - try await sourceStore.saveReaction(reaction) - - // Export - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - - // Parse - let envelope = try parseBackup(data: exportResult.data) - #expect(envelope.manifest.validate(against: envelope)) - - // Import into a fresh store - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - // Verify insert counts - #expect(result.devicesInserted == 1) - #expect(result.contactsInserted == 1) - #expect(result.channelsInserted == 1) - #expect(result.messagesInserted == 1) - #expect(result.reactionsInserted == 1) - #expect(result.totalSkipped == 0) - - // Verify data actually persisted in the destination store - let destContacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(destContacts.count == 1) - #expect(destContacts.first?.name == "Alice") - - let destChannels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(destChannels.count == 1) - #expect(destChannels.first?.name == "General") - - let destMessages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(destMessages.count == 1) - #expect(destMessages.first?.text == "Hello") - - let messageIDs = Set(destMessages.map(\.id)) - let destReactions = try await destStore.fetchAllReactions(radioID: radioID) - #expect(destReactions.count == 1) - // Reaction messageID must match a message in the destination store - #expect(messageIDs.contains(destReactions.first!.messageID)) - } - - // MARK: - Test 1b: Unmodeled contact type byte survives backup - - /// `ContactDTO.typeRawValue` is a non-optional field that has existed since the backup - /// feature shipped, so the contract is a plain encode → decode round-trip (no - /// `decodeIfPresent` legacy path). An unmodeled type byte must survive verbatim so a - /// future refactor can't silently re-collapse it onto a modeled `ContactType`. - @Test("Unmodeled contact type byte (0x04) survives DTO encode → decode round-trip") - func unmodeledContactTypeSurvivesCodableRoundTrip() throws { - let dto = ContactDTO.testContact(typeRawValue: 0x04) - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(ContactDTO.self, from: encoded) - #expect(decoded.typeRawValue == 0x04) - #expect(decoded.type == .chat) - } - - /// Full envelope path: an unmodeled type byte must survive export → import into a fresh store, - /// not just an isolated DTO round-trip. - @Test("Unmodeled contact type byte (0x04) survives full backup export → import") - func unmodeledContactTypeSurvivesBackupPipeline() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xAB, count: 32), - name: "FutureType", - typeRawValue: 0x04 - ) - try await sourceStore.saveContact(contact) - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - _ = try await service.importBackup(envelope: envelope, into: destStore) - - let destContacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(destContacts.first?.typeRawValue == 0x04) - #expect(destContacts.first?.type == .chat) - } - - // MARK: - Test 1c: Device knownRegions survives backup - - /// `DeviceDTO.knownRegions` is a non-optional `[String]` that shipped before the - /// backup feature, so no envelope can predate it: the contract is a plain encode → - /// decode round-trip, not a `decodeIfPresent` legacy path. This locks that contract - /// at the DTO boundary so a future refactor can't drop the field from the wire format. - @Test("Device knownRegions survives DTO encode → decode round-trip") - func knownRegionsSurvivesCodableRoundTrip() throws { - let dto = DeviceDTO.testDevice().copy { $0.knownRegions = ["US915", "EU868"] } - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(DeviceDTO.self, from: encoded) - #expect(decoded.knownRegions == ["US915", "EU868"]) - } - - /// Full envelope path: regions added through the targeted, list-owning writer must - /// survive export → import into a fresh store, exercising the `Device(dto:)` insert - /// seeding the restore relies on. - @Test("Device knownRegions survives full backup export → import into a fresh store") - func knownRegionsSurvivesBackupPipeline() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Discovery owns knownRegions through the targeted add path, not a full saveDevice. - try await sourceStore.addDeviceKnownRegion(radioID: radioID, region: "US915") - try await sourceStore.addDeviceKnownRegion(radioID: radioID, region: "EU868") - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - #expect(envelope.devices.first?.knownRegions == ["US915", "EU868"]) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let result = try await service.importBackup(envelope: envelope, into: destStore) - #expect(result.devicesInserted == 1) - - // Import re-mints Device.id; radioID is the surviving partition key. - let restored = try #require(await destStore.fetchDevice(radioID: radioID)) - #expect(restored.knownRegions == ["US915", "EU868"]) - } - - // MARK: - Test 2: Cross-bundle radioID remapping - - /// When the target store contains a device with the same publicKey as the backup but a - /// different radioID, all child records must be remapped to the local radioID. - @Test("Cross-bundle: child records are remapped to local radioID on publicKey match") - func crossBundleRadioIDRemapping() async throws { - let sharedPublicKey = Data(repeating: 0xCC, count: 32) - let sourceRadioID = UUID() - let targetRadioID = UUID() - - // Source store: device with sharedPublicKey, radioID = sourceRadioID - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: sourceRadioID) - let sourceDevice = DeviceDTO.testDevice( - id: sourceRadioID, - radioID: sourceRadioID, - publicKey: sharedPublicKey - ) - try await sourceStore.saveDevice(sourceDevice) - - let contact = ContactDTO.testContact( - radioID: sourceRadioID, - publicKey: Data(repeating: 0xDD, count: 32), - name: "Bob" - ) - try await sourceStore.saveContact(contact) - - // Export from source - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - // Target store: device with same publicKey but different radioID - let targetStore = try await PersistenceStore.createTestDataStore(radioID: targetRadioID) - let targetDevice = DeviceDTO.testDevice( - id: targetRadioID, - radioID: targetRadioID, - publicKey: sharedPublicKey - ) - try await targetStore.saveDevice(targetDevice) - - // Import - let result = try await service.importBackup( - envelope: envelope, - into: targetStore - ) - - // Backup device matched by publicKey → not inserted - #expect(result.devicesInserted == 0) - #expect(result.contactsInserted == 1) - - // Contact must live under targetRadioID, not sourceRadioID - let contactsUnderTarget = try await targetStore.fetchAllContacts(radioID: targetRadioID) - #expect(contactsUnderTarget.count == 1) - #expect(contactsUnderTarget.first?.name == "Bob") - - let contactsUnderSource = try await targetStore.fetchAllContacts(radioID: sourceRadioID) - #expect(contactsUnderSource.count == 0) - } - - // MARK: - Test 4: Merge import — DM thread visible for existing contact - - @Test("Import onto existing contact with nil lastMessageDate makes DM thread visible") - func importOntoExistingContact_DmThreadVisible() async throws { - let radioID = UUID() - let sharedPublicKey = Data(repeating: 0xAB, count: 32) - - // Destination store has a contact with no message history - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: sharedPublicKey, - name: "Alice", - lastMessageDate: nil - ) - try await destStore.saveContact(contact) - - // Verify contact is NOT in conversations list (lastMessageDate is nil) - let beforeConversations = try await destStore.fetchConversations(radioID: radioID) - #expect(beforeConversations.isEmpty) - - // Build backup with DMs for the same contact - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupContact = ContactDTO.testContact( - radioID: radioID, - publicKey: sharedPublicKey, - name: "Alice", - lastMessageDate: Date() - ) - var msg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: backupContact.id, - text: "Restored DM" - ) - msg.deduplicationKey = "merge-dm-\(UUID())" - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [backupContact], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - // Contact was skipped (already exists), message was inserted - #expect(result.contactsSkipped == 1) - #expect(result.messagesInserted == 1) - - // Contact must now appear in conversations - let afterConversations = try await destStore.fetchConversations(radioID: radioID) - #expect(afterConversations.count == 1) - #expect(afterConversations.first?.name == "Alice") - #expect(afterConversations.first?.lastMessageDate != nil) - } - - // MARK: - Test 5: Merge import — MessageRepeat relationship set (pre-existing parent) - - @Test("Import repeats onto existing message sets relationship and enables cascade delete") - func importOntoExistingMessage_RepeatRelationshipSet() async throws { - let radioID = UUID() - - // Destination store has an existing message - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await destStore.saveContact(contact) - - var existingMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Existing", - direction: .incoming - ) - existingMsg.deduplicationKey = "existing-msg-key" - try await destStore.saveMessage(existingMsg) - - // Build backup that contributes a repeat for the same message - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - var backupMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Existing", - direction: .incoming - ) - backupMsg.deduplicationKey = "existing-msg-key" - - let repeat1 = MessageRepeatDTO.testRepeat( - messageID: backupMsg.id, - pathNodes: Data([0x31]) - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [backupMsg], - messageRepeats: [repeat1] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.messagesSkipped == 1) - #expect(result.messageRepeatsInserted == 1) - - // Verify the repeat was linked to the existing message - let repeats = try await destStore.fetchMessageRepeats(messageID: existingMsg.id) - #expect(repeats.count == 1) - - // Delete the message — repeat must cascade-delete - try await destStore.deleteMessage(id: existingMsg.id) - let repeatsAfterDelete = try await destStore.fetchMessageRepeats(messageID: existingMsg.id) - #expect(repeatsAfterDelete.isEmpty) - } - - // MARK: - Test 6: Fresh-store import — MessageRepeat cascade delete - - @Test("Fresh-store import sets MessageRepeat relationship and cascade deletes work") - func freshStoreImport_RepeatCascadeDelete() async throws { - let radioID = UUID() - - // Source store with a message and repeats - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xBB, count: 32), - name: "Bob" - ) - try await sourceStore.saveContact(contact) - - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 0, - text: "Hello mesh" - ) - msg.deduplicationKey = "fresh-cascade-\(UUID())" - try await sourceStore.saveMessage(msg) - - // Save repeat using the normal (relationship-setting) path - let repeatDTO = MessageRepeatDTO.testRepeat(messageID: msg.id, pathNodes: Data([0x42])) - try await sourceStore.saveMessageRepeat(repeatDTO) - - // Export - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - // Import into fresh store - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - #expect(result.messagesInserted == 1) - #expect(result.messageRepeatsInserted == 1) - - // Verify repeats exist - let destMessages = try await destStore.fetchAllMessages(radioID: radioID) - let destMsgID = try #require(destMessages.first?.id) - let repeats = try await destStore.fetchMessageRepeats(messageID: destMsgID) - #expect(repeats.count == 1) - - // Delete the message — repeat must cascade-delete - try await destStore.deleteMessage(id: destMsgID) - let repeatsAfterDelete = try await destStore.fetchMessageRepeats(messageID: destMsgID) - #expect(repeatsAfterDelete.isEmpty) - } - - // MARK: - Test 7: Merge import — caches recomputed - - @Test("Import repeats/reactions onto existing message recomputes heardRepeats and reactionSummary") - func importOntoExistingMessage_CachesRecomputed() async throws { - let radioID = UUID() - - // Destination store with a message (heardRepeats=0, no reactions) - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await destStore.saveContact(contact) - - var existingMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Cache test", - direction: .incoming, - heardRepeats: 0 - ) - existingMsg.deduplicationKey = "cache-test-key" - try await destStore.saveMessage(existingMsg) - - // Build backup that adds 2 repeats and 1 reaction to the same message - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - var backupMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Cache test", - direction: .incoming - ) - backupMsg.deduplicationKey = "cache-test-key" - - let repeat1 = MessageRepeatDTO.testRepeat( - messageID: backupMsg.id, - pathNodes: Data([0x31]) - ) - let repeat2 = MessageRepeatDTO.testRepeat( - messageID: backupMsg.id, - pathNodes: Data([0x42]) - ) - let reaction = ReactionDTO.testReaction( - messageID: backupMsg.id, - radioID: radioID, - emoji: "👍", - senderName: "Eve" - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [backupMsg], - messageRepeats: [repeat1, repeat2], - reactions: [reaction] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.messagesSkipped == 1) - #expect(result.messageRepeatsInserted == 2) - #expect(result.reactionsInserted == 1) - - // Verify heardRepeats was recomputed - let updatedMsg = try await destStore.fetchMessage(id: existingMsg.id) - #expect(updatedMsg?.heardRepeats == 2) - - // Verify reactionSummary was recomputed - #expect(updatedMsg?.reactionSummary == "👍:1") - } - - // MARK: - Test 8: Merge import — channel metadata refreshed - - @Test("Import messages onto existing channel with nil lastMessageDate refreshes metadata") - func importOntoExistingChannel_ChannelMetadataRefreshed() async throws { - let radioID = UUID() - - // Destination store has a channel with no messages - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let channel = ChannelDTO.testChannel( - radioID: radioID, - index: 0, - name: "General", - lastMessageDate: nil - ) - try await destStore.saveChannel(channel) - - // Verify channel has nil lastMessageDate - let beforeChannel = try await destStore.fetchChannel(radioID: radioID, index: 0) - #expect(beforeChannel?.lastMessageDate == nil) - - // Build backup with messages for the same channel - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 0, - text: "Restored channel msg" - ) - msg.deduplicationKey = "channel-meta-\(UUID())" - - let envelope = AppBackupEnvelope.test( - devices: [device], - channels: [backupChannel], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.channelsSkipped == 1) - #expect(result.messagesInserted == 1) - - // Channel must now have lastMessageDate set - let afterChannel = try await destStore.fetchChannel(radioID: radioID, index: 0) - #expect(afterChannel?.lastMessageDate != nil) - } - - // MARK: - Test 9: Merge import — saved trace path runs preserved - - @Test("Import onto existing saved trace path merges runs without duplicating them") - func importOntoExistingSavedTracePath_MergesRuns() async throws { - let radioID = UUID() - let pathBytes = Data([0x12, 0x34, 0x56, 0x78]) - let existingRun = TracePathRunDTO.testRun( - date: Date().addingTimeInterval(-120), - roundTripMs: 180 - ) - let importedRun = TracePathRunDTO.testRun( - date: Date(), - roundTripMs: 95 - ) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingPath = try await destStore.createSavedTracePath( - radioID: radioID, - name: "Shared Route", - pathBytes: pathBytes, - hashSize: 2, - initialRun: existingRun - ) - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPath = SavedTracePathDTO.testPath( - radioID: radioID, - name: "Shared Route", - pathBytes: pathBytes, - hashSize: 2, - runs: [importedRun] - ) - let envelope = AppBackupEnvelope.test( - devices: [device], - savedTracePaths: [backupPath] - ) - - let service = AppBackupService() - - let firstResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(firstResult.savedTracePathsInserted == 0) - #expect(firstResult.savedTracePathsSkipped == 1) - #expect(firstResult.savedTracePathsMerged == 1) - #expect(firstResult.hasRestoredChanges) - - let mergedPath = try #require(await destStore.fetchSavedTracePath(id: existingPath.id)) - #expect(mergedPath.runs.count == 2) - #expect(Set(mergedPath.runs.map(\.id)) == Set([existingRun.id, importedRun.id])) - - let secondResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(secondResult.savedTracePathsInserted == 0) - #expect(secondResult.savedTracePathsSkipped == 1) - #expect(secondResult.savedTracePathsMerged == 0) - #expect(!secondResult.hasRestoredChanges) - - let deduplicatedPath = try #require(await destStore.fetchSavedTracePath(id: existingPath.id)) - #expect(deduplicatedPath.runs.count == 2) - #expect(Set(deduplicatedPath.runs.map(\.id)) == Set([existingRun.id, importedRun.id])) - } - - // MARK: - Test 9b: Cross-path run id reuse never relocates a run - - @Test("Import reusing a local run's id under a different path drops the duplicate run rather than relocating it") - func importReusingRunIDUnderDifferentPath_DropsDuplicate() async throws { - let radioID = UUID() - let pathABytes = Data([0x12, 0x34, 0x56, 0x78]) - let pathBBytes = Data([0xAB, 0xCD, 0xEF, 0x01]) - let sharedRunID = UUID() - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Local path A owns run R. - let runR = TracePathRunDTO.testRun(id: sharedRunID, roundTripMs: 180) - let pathA = try await destStore.createSavedTracePath( - radioID: radioID, - name: "Path A", - pathBytes: pathABytes, - hashSize: 1, - initialRun: runR - ) - - // Backup path B is a distinct path whose run reuses R's id. - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPathB = SavedTracePathDTO.testPath( - radioID: radioID, - name: "Path B", - pathBytes: pathBBytes, - hashSize: 1, - runs: [TracePathRunDTO.testRun(id: sharedRunID, roundTripMs: 999)] - ) - let envelope = AppBackupEnvelope.test( - devices: [device], - savedTracePaths: [backupPathB] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - // Path B is a new path, but its duplicate-id run is dropped store-wide. - #expect(result.savedTracePathsInserted == 1) - - let allPaths = try await destStore.fetchSavedTracePaths(radioID: radioID) - #expect(allPaths.count == 2) - - // Run R stays under path A; path B gains no run. - let pathAAfter = try #require(await destStore.fetchSavedTracePath(id: pathA.id)) - #expect(pathAAfter.runs.count == 1) - #expect(pathAAfter.runs.first?.id == sharedRunID) - - let pathBAfter = try #require(allPaths.first { $0.pathBytes == pathBBytes }) - #expect(pathBAfter.runs.isEmpty) - - // The run exists exactly once in the whole store. - let totalRuns = allPaths.reduce(0) { $0 + $1.runs.count } - #expect(totalRuns == 1) - } - - // MARK: - Test 10: Orphaned radio-scoped data survives export/import - - @Test("Export preserves orphaned radio-scoped data after device-only delete") - func exportPreservesOrphanedRadioScopedData() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xD1, count: 32), - name: "Orphaned Contact" - ) - try await sourceStore.saveContact(contact) - - let channel = ChannelDTO.testChannel( - radioID: radioID, - index: 1, - name: "Orphaned Channel", - unreadCount: 3, - notificationLevel: .mentionsOnly, - isFavorite: true - ) - try await sourceStore.saveChannel(channel) - - var message = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Preserve me" - ) - message.deduplicationKey = "orphaned-message-\(UUID())" - try await sourceStore.saveMessage(message) - - let session = RemoteNodeSessionDTO.testSession(radioID: radioID) - try await sourceStore.saveRemoteNodeSessionDTO(session) - - let roomMessage = RoomMessageDTO.testRoomMessage(sessionID: session.id) - try await sourceStore.saveRoomMessage(roomMessage) - - let blockedSender = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) - try await sourceStore.saveBlockedChannelSender(blockedSender) - - let tracePath = SavedTracePathDTO.testPath(radioID: radioID) - let initialRun = tracePath.runs.first - _ = try await sourceStore.createSavedTracePath( - radioID: tracePath.radioID, - name: tracePath.name, - pathBytes: tracePath.pathBytes, - hashSize: tracePath.hashSize, - initialRun: initialRun - ) - - try await sourceStore.deleteDevice(id: radioID) - #expect(try await sourceStore.fetchDevice(id: radioID) == nil) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: result.data) - - #expect(envelope.devices.isEmpty) - #expect(envelope.contacts.count == 1) - #expect(envelope.channels.count == 1) - #expect(envelope.messages.count == 1) - #expect(envelope.remoteNodeSessions.count == 1) - #expect(envelope.roomMessages.count == 1) - #expect(envelope.savedTracePaths.count == 1) - #expect(envelope.blockedChannelSenders.count == 1) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let firstResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(firstResult.devicesInserted == 0) - #expect(firstResult.contactsInserted == 1) - #expect(firstResult.channelsInserted == 1) - #expect(firstResult.messagesInserted == 1) - #expect(firstResult.remoteNodeSessionsInserted == 1) - #expect(firstResult.roomMessagesInserted == 1) - #expect(firstResult.savedTracePathsInserted == 1) - #expect(firstResult.blockedChannelSendersInserted == 1) - - #expect(try await destStore.fetchAllContacts(radioID: radioID).count == 1) - #expect(try await destStore.fetchAllChannels(radioID: radioID).count == 1) - #expect(try await destStore.fetchAllMessages(radioID: radioID).count == 1) - #expect(try await destStore.fetchRemoteNodeSessions(radioID: radioID).count == 1) - #expect(try await destStore.fetchRoomMessages(sessionID: session.id).count == 1) - #expect(try await destStore.fetchSavedTracePaths(radioID: radioID).count == 1) - #expect(try await destStore.fetchBlockedChannelSenders(radioID: radioID).count == 1) - - let secondResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(secondResult.totalInserted == 0) - #expect(secondResult.contactsSkipped == 1) - #expect(secondResult.channelsSkipped == 1) - #expect(secondResult.messagesSkipped == 1) - #expect(secondResult.remoteNodeSessionsSkipped == 1) - #expect(secondResult.roomMessagesSkipped == 1) - #expect(secondResult.savedTracePathsSkipped == 1) - #expect(secondResult.blockedChannelSendersSkipped == 1) - } - - // MARK: - Test 11: Merge import — contact metadata restored - - @Test("Import onto existing contact restores backup-owned contact metadata") - func importOntoExistingContact_RestoresBackupMetadata() async throws { - let radioID = UUID() - let sharedPublicKey = Data(repeating: 0xE2, count: 32) - let importedDate = Date(timeIntervalSince1970: 1_700_000_000) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingContact = ContactDTO.testContact( - radioID: radioID, - publicKey: sharedPublicKey, - name: "Alice", - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0 - ) - try await destStore.saveContact(existingContact) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupContact = ContactDTO( - id: UUID(), - radioID: radioID, - publicKey: sharedPublicKey, - name: "Alice", - typeRawValue: existingContact.typeRawValue, - flags: existingContact.flags, - outPathLength: existingContact.outPathLength, - outPath: existingContact.outPath, - lastAdvertTimestamp: existingContact.lastAdvertTimestamp, - latitude: existingContact.latitude, - longitude: existingContact.longitude, - lastModified: existingContact.lastModified, - nickname: "Field Ops", - isBlocked: true, - isMuted: true, - isFavorite: true, - lastMessageDate: importedDate, - unreadCount: 7, - unreadMentionCount: 2, - ocvPreset: OCVPreset.custom.rawValue, - customOCVArrayString: "4200,4100,4000" - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - contacts: [backupContact] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.contactsInserted == 0) - #expect(result.contactsSkipped == 1) - - let mergedContact = try #require( - await destStore.fetchContact(radioID: radioID, publicKey: sharedPublicKey) - ) - #expect(mergedContact.nickname == "Field Ops") - #expect(mergedContact.isBlocked == true) - #expect(mergedContact.isMuted == true) - #expect(mergedContact.isFavorite == true) - #expect(mergedContact.lastMessageDate == importedDate) - #expect(mergedContact.unreadCount == 7) - #expect(mergedContact.unreadMentionCount == 2) - #expect(mergedContact.ocvPreset == OCVPreset.custom.rawValue) - #expect(mergedContact.customOCVArrayString == "4200,4100,4000") - } - - // MARK: - Test 12: Merge import — channel metadata restored - - @Test("Import onto existing channel restores backup-owned channel metadata") - func importOntoExistingChannel_RestoresBackupMetadata() async throws { - let radioID = UUID() - let importedDate = Date(timeIntervalSince1970: 1_700_000_100) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 2, - name: "Ops", - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false, - floodScope: .inherit - ) - try await destStore.saveChannel(existingChannel) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: existingChannel.index, - name: existingChannel.name, - lastMessageDate: importedDate, - unreadCount: 11, - unreadMentionCount: 4, - notificationLevel: .mentionsOnly, - isFavorite: true, - floodScope: .region("US") - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupChannel] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - - let mergedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 2)) - #expect(mergedChannel.lastMessageDate == importedDate) - #expect(mergedChannel.unreadCount == 11) - #expect(mergedChannel.unreadMentionCount == 4) - #expect(mergedChannel.notificationLevel == .mentionsOnly) - #expect(mergedChannel.isFavorite == true) - #expect(mergedChannel.regionScope == "US") - } - - // MARK: - Test 12b: Different-secret slot collision relocates instead of merging - - /// Local `#kosice` (secret 0x01) occupies slot 2; the backup carries a distinct channel - /// `#praha` (secret 0x02) that also claims slot 2. Because the two secrets differ, this - /// is "different channel, relocate", never a metadata merge: `#kosice` keeps its slot, - /// name, and secret untouched, and `#praha` is inserted as a separate channel on a free - /// slot. Contrast with same-secret merges, where backup metadata is adopted in place. - @Test("Import onto a different-secret slot keeps the local channel and inserts the backup channel separately") - func importOntoExistingChannel_DifferentSecretRelocatesInsteadOfMerging() async throws { - let radioID = UUID() - let localSecret = Data(repeating: 0x01, count: 16) - let backupSecret = Data(repeating: 0x02, count: 16) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 2, - name: "#kosice", - secret: localSecret - ) - try await destStore.saveChannel(existingChannel) - - // Same (radioID, index) slot, but a different name and secret in the backup. - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: 2, - name: "#praha", - secret: backupSecret - ) - - let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupChannel]) - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - // The backup channel is a distinct identity, so it is inserted (relocated), not - // merged into #kosice's slot. - #expect(result.channelsInserted == 1) - #expect(result.channelsSkipped == 0) - - // #kosice keeps its slot, name, and secret. - let kosice = try #require(await destStore.fetchChannel(radioID: radioID, index: 2)) - #expect(kosice.name == "#kosice") - #expect(kosice.secret == localSecret) - - // #praha exists as a separate channel on a different slot, with its own name/secret. - let channels = try await destStore.fetchAllChannels(radioID: radioID) - let praha = try #require(channels.first { $0.secret == backupSecret }) - #expect(praha.name == "#praha") - #expect(praha.index != 2) - } - - // MARK: - Channel reconciliation by secret (slot relocation) — REPRO - - /// Reproduction for silent wrong-context delivery. Backup `#praha` (secretA) lives - /// at slot 3 with a channel message; locally slot 3 is `#brno` (secretB). With slot-only - /// reconciliation the imported message attaches to `#brno` and `#praha` is dropped. The - /// fix reconciles by `(radioID, secret)`: `#praha` relocates to a free slot and its - /// message follows; `#brno` is untouched. - @Test("Channel reconcile: backup channel collides with a different-secret local slot, relocates and carries its message") - func channelSecretReconcile_RelocatesOnDifferentSecretSlotCollision() async throws { - let radioID = UUID() - let secretBrno = Data(repeating: 0xB0, count: 16) - let secretPraha = Data(repeating: 0xA0, count: 16) - let collisionIndex: UInt8 = 3 - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let localBrno = ChannelDTO.testChannel( - radioID: radioID, - index: collisionIndex, - name: "#brno", - secret: secretBrno - ) - try await destStore.saveChannel(localBrno) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPraha = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: collisionIndex, - name: "#praha", - secret: secretPraha - ) - var prahaMessage = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: collisionIndex, - text: "Praha checkpoint", - timestamp: 1_700_000_900, - direction: .incoming, - senderNodeName: "Jan" - ) - prahaMessage.deduplicationKey = "ch-\(collisionIndex)-1700000900-Jan-DEADBEEF" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupPraha], - messages: [prahaMessage] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - // #praha is a distinct channel: inserted (relocated), not merged into #brno's slot. - #expect(result.channelsInserted == 1) - #expect(result.channelsSkipped == 0) - #expect(result.messagesInserted == 1) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - let brno = try #require(channels.first { $0.secret == secretBrno }) - let praha = try #require(channels.first { $0.secret == secretPraha }) - - // #brno keeps its slot and name; #praha lands on a different (free) slot. - #expect(brno.index == collisionIndex) - #expect(brno.name == "#brno") - #expect(praha.index != collisionIndex) - #expect(praha.name == "#praha") - - // The message followed #praha's relocated slot, not #brno's. - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - let restored = try #require(messages.first) - #expect(restored.channelIndex == praha.index) - #expect(restored.text == "Praha checkpoint") - - // #brno received no foreign message. - let brnoMessages = messages.filter { $0.channelIndex == collisionIndex } - #expect(brnoMessages.isEmpty) - } - - @Test("Re-importing a stale backup never upserts a reconfigured live channel") - func staleBackupDoesNotOverwriteReconfiguredChannel() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let radioID = UUID() - let sharedID = UUID() - - // Live channel: same surrogate id as the backup, but secret rotated in place (S2), - // occupying the backup's slot. - let liveSecret = Data(repeating: 0xB2, count: 32) - let live = ChannelDTO( - id: sharedID, radioID: radioID, index: 3, name: "Live Net", - secret: liveSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - try await store.batchInsertChannels([live], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8]) - - // Backup channel: same id, slot 3, but the original secret S1 (no local match). - let backupSecret = Data(repeating: 0xA1, count: 32) - let backup = ChannelDTO( - id: sharedID, radioID: radioID, index: 3, name: "Stale Net", - secret: backupSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - let result = try await store.batchInsertChannels([backup], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8]) - - let channels = try await store.fetchChannels(radioID: radioID) - // Live channel survives untouched at slot 3 with its rotated secret. - let liveRow = try #require(channels.first { $0.secret == liveSecret }) - #expect(liveRow.index == 3) - #expect(liveRow.name == "Live Net") - // Backup channel coexists, relocated to a free slot with a fresh id. - let backupRow = try #require(channels.first { $0.secret == backupSecret }) - #expect(backupRow.id != sharedID) - #expect(backupRow.index != 3) - #expect(result.inserted == 1) - } - - @Test("batchInsertChannels reports newly-occupied local slots for draft clearing, excluding merges") - func batchInsertReportsInsertedLocalIndicesForDraftClearing() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let radioID = UUID() - - let mergeSecret = Data(repeating: 0xC1, count: 32) - let occupiedSecret = Data(repeating: 0xC4, count: 32) - let localAtSlot2 = ChannelDTO( - id: UUID(), radioID: radioID, index: 2, name: "Keep", - secret: mergeSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - let localAtSlot4 = ChannelDTO( - id: UUID(), radioID: radioID, index: 4, name: "Occupied", - secret: occupiedSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - try await store.batchInsertChannels( - [localAtSlot2, localAtSlot4], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8] - ) - - // (a) merges by secret into local slot 2 though recorded at slot 6 — occupant unchanged. - let mergeBackup = ChannelDTO( - id: UUID(), radioID: radioID, index: 6, name: "Keep", - secret: mergeSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - // (b) foreign channel placed at its own free slot 5. - let collideSecret = Data(repeating: 0xD4, count: 32) - let freshAtFreeSlot = ChannelDTO( - id: UUID(), radioID: radioID, index: 5, name: "Fresh", - secret: Data(repeating: 0xD5, count: 32), isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - // (c) foreign channel colliding with occupied slot 4, relocated to a free slot. - let collideAtSlot4 = ChannelDTO( - id: UUID(), radioID: radioID, index: 4, name: "Collide", - secret: collideSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - - let result = try await store.batchInsertChannels( - [mergeBackup, freshAtFreeSlot, collideAtSlot4], - radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8] - ) - - let inserted = result.insertedLocalIndices[radioID] ?? [] - // Fresh insert recorded at its own free slot. - #expect(inserted.contains(5)) - // Merge-by-secret destination (2) and backup-file source (6) are not local inserts. - #expect(!inserted.contains(2)) - #expect(!inserted.contains(6)) - // The colliding channel's untouched source slot (4) is not recorded; its relocation - // destination is. - #expect(!inserted.contains(4)) - let relocatedRow = try #require( - (try await store.fetchChannels(radioID: radioID)).first { $0.secret == collideSecret } - ) - #expect(relocatedRow.index != 4) - #expect(inserted.contains(relocatedRow.index)) - #expect(result.inserted == 2) - } - - @Test("Exporting a channel with an unmigrated notification level does not migrate the live row") - func exportReadDoesNotMigrateNotificationLevel() throws { - let channel = Channel( - radioID: UUID(), index: 1, name: "Ops", - secret: Data(repeating: 0x33, count: 32) - ) - // Simulate a pre-migration row: -1 sentinel with the legacy muted flag set. - channel.notificationLevelRawValue = -1 - channel.legacyIsMuted = true - - let dto = ChannelDTO(from: channel) - - // The read did not trigger the migrating getter's in-memory write-back. - #expect(channel.notificationLevelRawValue == -1) - // The DTO still carries the correctly decoded level (muted, from legacy isMuted). - #expect(dto.notificationLevel == .muted) - } - - @Test("Duplicate device public keys in one envelope skip the loser and remap its children") - func duplicatePublicKeyRemapsChildrenToSinglePartition() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let key = Data(repeating: 0x42, count: 32) - let radioA = UUID(), radioB = UUID() - let devA = DeviceDTO.testDevice().copy { $0.publicKey = key; $0.radioID = radioA } - let devB = DeviceDTO.testDevice().copy { $0.publicKey = key; $0.radioID = radioB } - // Both devices share the duplicate `key` (the scenario under test), but the contacts - // must carry distinct public keys: after both contacts' radioID is remapped to the - // single surviving local radioID they would otherwise share an identical `contactKey` - // (radioID + publicKey) and the second would be deduped, collapsing to one row. - let contactA = ContactDTO.testContact(radioID: radioA, publicKey: Data(repeating: 0x01, count: 32)) - let contactB = ContactDTO.testContact(radioID: radioB, publicKey: Data(repeating: 0x02, count: 32)) - let envelope = AppBackupEnvelope.test( - devices: [devA, devB], contacts: [contactA, contactB] - ) - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: store) - #expect(result.counts[.devices]?.skipped == 1) // loser duplicate - #expect(result.counts[.devices]?.inserted == 1) - // Both contacts land under the single surviving local radioID. - let devices = try await store.fetchAllDevices() - let localRadioID = try #require(devices.first?.radioID) - let contacts = try await store.fetchContacts(radioID: localRadioID) - #expect(contacts.count == 2) - } - - @Test("Channel floodScope survives a fresh-insert export/import round-trip") - func channelFloodScopeRoundTripsOnFreshInsert() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - try await sourceStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General", floodScope: .inherit) - ) - try await sourceStore.saveChannel( - ChannelDTO.testChannel( - radioID: radioID, index: 1, name: "Regional", - secret: Data(repeating: 0x55, count: 32), floodScope: .region("US") - ) - ) - - let service = AppBackupService() - let envelope = try parseBackup(data: try await service.export(persistenceStore: sourceStore).data) - - // Fresh dest store: both channels take the fresh-insert path (Channel(dto:)), not merge. - let destStore = PersistenceStore(modelContainer: try PersistenceStore.createContainer(inMemory: true)) - let result = try await service.importBackup(envelope: envelope, into: destStore) - #expect(result.channelsInserted == 2) - - let restoredRegional = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) - #expect(restoredRegional.floodScope == .region("US")) - let restoredInherit = try #require(try await destStore.fetchChannel(radioID: radioID, index: 0)) - #expect(restoredInherit.floodScope == .inherit) - } - - @Test("Re-importing the same backup is idempotent for unread counts and last-message dates") - func secondImportIsIdempotentForMergeFields() async throws { - let radioID = UUID() - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Local channel the backup will merge into (matched by stable secret). - let secret = Data(repeating: 0x77, count: 32) - let localDate = Date(timeIntervalSince1970: 1_700_000_000) - try await destStore.saveChannel( - ChannelDTO.testChannel( - radioID: radioID, index: 1, name: "Ops", secret: secret, - lastMessageDate: localDate, unreadCount: 5 - ) - ) - - // Backup carries the same channel with a higher unread count and a later date. - let backupDate = Date(timeIntervalSince1970: 1_700_009_000) - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - radioID: radioID, index: 1, name: "Ops", secret: secret, - lastMessageDate: backupDate, unreadCount: 9 - ) - let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupChannel]) - - let service = AppBackupService() - _ = try await service.importBackup(envelope: envelope, into: destStore) - let afterFirst = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) - - _ = try await service.importBackup(envelope: envelope, into: destStore) - let afterSecond = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) - - // The merge (max of local/backup) and date reconciliation are idempotent: a repeat - // import neither double-counts unread nor oscillates the last-message date. - #expect(afterSecond.unreadCount == afterFirst.unreadCount) - #expect(afterSecond.unreadMentionCount == afterFirst.unreadMentionCount) - #expect(afterSecond.lastMessageDate == afterFirst.lastMessageDate) - } - - /// Case A: same secret at the same slot is a pure metadata merge with no relocation. - @Test("Channel reconcile: same secret at same slot merges metadata, index unchanged") - func channelSecretReconcile_SameSecretSameSlotIsNoOp() async throws { - let radioID = UUID() - let secret = Data(repeating: 0x44, count: 16) - let slot: UInt8 = 2 - let importedDate = Date(timeIntervalSince1970: 1_700_000_950) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let localChannel = ChannelDTO.testChannel( - radioID: radioID, - index: slot, - name: "#ops", - secret: secret, - lastMessageDate: nil - ) - try await destStore.saveChannel(localChannel) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: slot, - name: "#ops", - secret: secret, - lastMessageDate: importedDate - ) - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: slot, - text: "Same slot", - timestamp: 1_700_000_950, - // Older than importedDate so the merged channel date is the max and survives - // the lastMessageDate reconcile pass (which keys on createdAt). - createdAt: Date(timeIntervalSince1970: 1_600_000_000), - direction: .incoming, - senderNodeName: "Eva" - ) - msg.deduplicationKey = "ch-\(slot)-1700000950-Eva-CAFE0001" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupChannel], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - #expect(result.messagesInserted == 1) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(channels.count == 1) - #expect(channels.first?.index == slot) - #expect(channels.first?.secret == secret) - #expect(channels.first?.lastMessageDate == importedDate) - - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - #expect(messages.first?.channelIndex == slot) - } - - /// Case B: the same secret moved slots between backup and restore. The message - /// remaps to the local slot; no duplicate channel is inserted. - @Test("Channel reconcile: same secret on a different slot remaps the message, no duplicate channel") - func channelSecretReconcile_SameSecretDifferentSlotRemapsMessage() async throws { - let radioID = UUID() - let secret = Data(repeating: 0x55, count: 16) - let backupSlot: UInt8 = 3 - let localSlot: UInt8 = 5 - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let localChannel = ChannelDTO.testChannel( - radioID: radioID, - index: localSlot, - name: "#praha", - secret: secret - ) - try await destStore.saveChannel(localChannel) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: backupSlot, - name: "#praha", - secret: secret - ) - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: backupSlot, - text: "Moved slots", - timestamp: 1_700_001_000, - direction: .incoming, - senderNodeName: "Lena" - ) - msg.deduplicationKey = "ch-\(backupSlot)-1700001000-Lena-0BADF00D" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupChannel], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - #expect(result.messagesInserted == 1) +private enum InjectedImportFailure: Error { + case simulated +} - let channels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(channels.count == 1) - #expect(channels.first?.index == localSlot) +private extension PersistenceStore { + func setAutosaveEnabledForTesting(_ isEnabled: Bool) { + modelContext.autosaveEnabled = isEnabled + } - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - #expect(messages.first?.channelIndex == localSlot) - } + func autosaveEnabledForTesting() -> Bool { + modelContext.autosaveEnabled + } - /// The public channel (index 0, all-zero secret) merges into the local public channel - /// without relocating, because empty-secret channels match by index. - @Test("Channel reconcile: public channel merges into local public channel without relocation") - func channelSecretReconcile_PublicChannelMergesIntoLocalPublic() async throws { - let radioID = UUID() - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let localPublic = ChannelDTO.testChannel( - radioID: radioID, - index: 0, - name: "Public", - lastMessageDate: nil - ) - try await destStore.saveChannel(localPublic) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPublic = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: 0, - name: "Public", - lastMessageDate: Date(timeIntervalSince1970: 1_700_001_100) - ) - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 0, - text: "Public broadcast", - timestamp: 1_700_001_100, - direction: .incoming, - senderNodeName: "Mara" - ) - msg.deduplicationKey = "ch-0-1700001100-Mara-FEEDFACE" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupPublic], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - #expect(result.messagesInserted == 1) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(channels.count == 1) - #expect(channels.first?.index == 0) - - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - #expect(messages.first?.channelIndex == 0) - } - - /// Dedup-key preservation: re-importing the same relocated backup must skip the - /// channel message on the second pass, proving the dedup key was rewritten to the - /// relocated index consistently across both runs. - @Test("Channel reconcile: re-importing a relocated channel skips the message the second time") - func channelSecretReconcile_RemapPreservesDedupKeyAcrossReimport() async throws { - let radioID = UUID() - let secretBrno = Data(repeating: 0xB2, count: 16) - let secretPraha = Data(repeating: 0xA2, count: 16) - let collisionIndex: UInt8 = 3 - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: collisionIndex, name: "#brno", secret: secretBrno) - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPraha = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: collisionIndex, - name: "#praha", - secret: secretPraha - ) - var prahaMessage = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: collisionIndex, - text: "Praha checkpoint", - timestamp: 1_700_001_200, - direction: .incoming, - senderNodeName: "Jan" - ) - prahaMessage.deduplicationKey = "ch-\(collisionIndex)-1700001200-Jan-12345678" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupPraha], - messages: [prahaMessage] - ) - - let service = AppBackupService() - let firstResult = try await service.importBackup(envelope: envelope, into: destStore) - #expect(firstResult.messagesInserted == 1) - - let secondResult = try await service.importBackup(envelope: envelope, into: destStore) - #expect(secondResult.messagesInserted == 0) - #expect(secondResult.messagesSkipped == 1) - - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - } - - /// Safety case: every local slot within the radio's channel capacity is occupied by a - /// different secret, so the backup channel has nowhere to land. It must be dropped (not - /// mis-associated), and its messages must be dropped with it rather than attaching to a - /// foreign channel. - @Test("Channel reconcile: backup channel with no free slot is dropped along with its messages") - func channelSecretReconcile_NoFreeSlotDropsChannelAndMessages() async throws { - let radioID = UUID() - let maxChannels: UInt8 = 2 - let secretPraha = Data(repeating: 0xA4, count: 16) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - // Fill every slot in [0, maxChannels) with distinct-secret local channels. - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 0, name: "#a", secret: Data(repeating: 0x10, count: 16)) - ) - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#b", secret: Data(repeating: 0x11, count: 16)) - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = maxChannels } - let backupPraha = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: 1, - name: "#praha", - secret: secretPraha - ) - var prahaMessage = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 1, - text: "Should not survive", - timestamp: 1_700_001_300, - direction: .incoming, - senderNodeName: "Jan" - ) - prahaMessage.deduplicationKey = "ch-1-1700001300-Jan-AABBCCDD" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupPraha], - messages: [prahaMessage] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - // #praha is dropped: not inserted, counted as dropped; its message is not inserted. - #expect(result.channelsInserted == 0) - #expect(result.channelsDropped == 1) - #expect(result.messagesInserted == 0) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(channels.count == 2) - #expect(channels.contains { $0.secret == secretPraha } == false) - - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.isEmpty) - } - - @Test("Dropped channels and their messages are accounted as dropped, not already-here") - func droppedChannelHistoryIsAccountedDistinctly() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let radioID = UUID() - - // Local radio is full to capacity (maxChannels = 2: slot 0 public + slot 1 used). - let existing = ChannelDTO( - id: UUID(), radioID: radioID, index: 1, name: "Local", - secret: Data(repeating: 0x11, count: 32), isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - try await store.batchInsertChannels([existing], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 2]) - - // Backup channel at slot 1 with a different secret has no free slot -> dropped. - let backupChannel = ChannelDTO( - id: UUID(), radioID: radioID, index: 1, name: "Backup", - secret: Data(repeating: 0x22, count: 32), isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - let channelResult = try await store.batchInsertChannels( - [backupChannel], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 2] - ) - #expect(channelResult.dropped == 1) - #expect(channelResult.skipped == 0) - #expect(channelResult.droppedChannelIndices[radioID]?.contains(1) == true) - } - - @Test("Full import accounts a no-slot channel's messages as dropped and the manifest balances") - func droppedChannelFullImportBalancesManifest() async throws { - let radioID = UUID() - let maxChannels: UInt8 = 2 - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - // Fill every slot in [0, maxChannels) with distinct-secret local channels. - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 0, name: "#a", secret: Data(repeating: 0x10, count: 16)) - ) - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#b", secret: Data(repeating: 0x11, count: 16)) - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = maxChannels } - // One backup channel that merges into an existing slot (slot 0, same empty-ish handling - // avoided by using a distinct secret at slot 0) and one that has no free slot -> dropped. - let droppedChannel = ChannelDTO.testChannel( - id: UUID(), radioID: radioID, index: 1, name: "#dropped", secret: Data(repeating: 0xA4, count: 16) - ) - var droppedMessage1 = MessageDTO.testChannelMessage( - radioID: radioID, channelIndex: 1, text: "lost one", - timestamp: 1_700_002_000, direction: .incoming, senderNodeName: "Jan" - ) - droppedMessage1.deduplicationKey = "ch-1-1700002000-Jan-AABBCCDD" - var droppedMessage2 = MessageDTO.testChannelMessage( - radioID: radioID, channelIndex: 1, text: "lost two", - timestamp: 1_700_002_100, direction: .incoming, senderNodeName: "Eva" - ) - droppedMessage2.deduplicationKey = "ch-1-1700002100-Eva-AABBCCDE" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [droppedChannel], - messages: [droppedMessage1, droppedMessage2] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.counts[.channels]?.dropped == 1) - #expect(result.counts[.messages]?.dropped == 2) - - // Message balance invariant: every declared message is inserted, merged, skipped, or dropped. - let m = try #require(result.counts[.messages]) - #expect(envelope.manifest.messageCount == m.inserted + m.merged + m.skipped + m.dropped) - } - - /// Slot 0 is reserved for the public channel, so a relocating non-public channel skips it - /// even when free. Local `#brno` occupies slot 1; slots 0 and 2 are free. The colliding - /// backup `#praha` must relocate to slot 2, never slot 0. - @Test("Channel reconcile: relocation reserves slot 0 for the public channel") - func channelSecretReconcile_RelocationReservesPublicSlotZero() async throws { - let radioID = UUID() - let secretBrno = Data(repeating: 0xB6, count: 16) - let secretPraha = Data(repeating: 0xA6, count: 16) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - // Local channel occupies slot 1; slot 0 is intentionally left free (no public channel). - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#brno", secret: secretBrno) - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = 3 } - let backupPraha = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: 1, - name: "#praha", - secret: secretPraha - ) - - let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupPraha]) - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 1) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - let praha = try #require(channels.first { $0.secret == secretPraha }) - // Lowest free slot at or above 1 — slot 0 is skipped even though it is free. - #expect(praha.index == 2) - } - - // MARK: - Test 13: Fresh-store import — remote sessions start disconnected - - @Test("Import inserts remote sessions as disconnected while preserving backup metadata") - func importRemoteSession_ResetsTransientConnectionState() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xF3, count: 32) - let importedDate = Date(timeIntervalSince1970: 1_700_000_200) - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupSession = RemoteNodeSessionDTO.testSession( - radioID: radioID, - publicKey: publicKey, - name: "Ops Room", - role: .roomServer, - isConnected: true, - permissionLevel: .admin, - lastConnectedDate: importedDate, - unreadCount: 5, - notificationLevel: .mentionsOnly, - isFavorite: true, - neighborCount: 4, - lastSyncTimestamp: 88, - lastMessageDate: importedDate - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - remoteNodeSessions: [backupSession] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.remoteNodeSessionsInserted == 1) - #expect(result.remoteNodeSessionsSkipped == 0) - - let importedSession = try #require(await destStore.fetchRemoteNodeSession(id: backupSession.id)) - #expect(importedSession.isConnected == false) - #expect(importedSession.permissionLevel == .admin) - #expect(importedSession.lastConnectedDate == importedDate) - #expect(importedSession.unreadCount == 5) - #expect(importedSession.notificationLevel == .mentionsOnly) - #expect(importedSession.isFavorite == true) - #expect(importedSession.lastSyncTimestamp == 88) - #expect(importedSession.lastMessageDate == importedDate) - } - - // MARK: - Test 14: Room-message delivery metadata survives import - - @Test("Import preserves room-message delivery metadata") - func importRoomMessage_PreservesDeliveryMetadata() async throws { - let radioID = UUID() - let createdAt = Date(timeIntervalSince1970: 1_700_000_275.25) - let session = RemoteNodeSessionDTO.testSession(radioID: radioID) - let roomMessage = RoomMessageDTO( - id: UUID(), - sessionID: session.id, - authorKeyPrefix: Data([0xCA, 0xFE, 0xBA, 0xBE]), - authorName: "Ops", - text: "Retry me", - timestamp: 1_700_000_275, - createdAt: createdAt, - isFromSelf: true, - status: .failed, - ackCode: 0xDEADBEEF, - roundTripTime: 1_450, - retryAttempt: 3, - maxRetryAttempts: 7 - ) - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], - roomMessages: [roomMessage], - remoteNodeSessions: [session] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.roomMessagesInserted == 1) - - let importedMessage = try #require(await destStore.fetchRoomMessage(id: roomMessage.id)) - #expect(importedMessage.createdAt == createdAt) - #expect(importedMessage.status == .failed) - #expect(importedMessage.ackCode == 0xDEADBEEF) - #expect(importedMessage.roundTripTime == 1_450) - #expect(importedMessage.retryAttempt == 3) - #expect(importedMessage.maxRetryAttempts == 7) - } - - // MARK: - Test 15: Merge import — remote session metadata restored - - @Test("Import onto existing remote session restores backup metadata without clobbering live state") - func importOntoExistingRemoteSession_RestoresBackupMetadata() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xF4, count: 32) - let localConnectedDate = Date(timeIntervalSince1970: 1_700_000_250) - let importedDate = Date(timeIntervalSince1970: 1_700_000_300) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingSession = RemoteNodeSessionDTO.testSession( - radioID: radioID, - publicKey: publicKey, - name: "Ops Room", - role: .roomServer, - isConnected: true, - permissionLevel: .admin, - lastConnectedDate: localConnectedDate, - unreadCount: 0, - notificationLevel: .all, - isFavorite: false, - neighborCount: 2, - lastSyncTimestamp: 123, - lastMessageDate: nil - ) - try await destStore.saveRemoteNodeSessionDTO(existingSession) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupSession = RemoteNodeSessionDTO.testSession( - id: UUID(), - radioID: radioID, - publicKey: publicKey, - name: "Ops Room", - role: .roomServer, - isConnected: false, - permissionLevel: .guest, - lastConnectedDate: Date(timeIntervalSince1970: 1_700_000_225), - unreadCount: 9, - notificationLevel: .mentionsOnly, - isFavorite: true, - neighborCount: 7, - lastSyncTimestamp: 8, - lastMessageDate: importedDate - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - remoteNodeSessions: [backupSession] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.remoteNodeSessionsInserted == 0) - #expect(result.remoteNodeSessionsSkipped == 1) - #expect(result.remoteNodeSessionsMerged == 1) - #expect(result.hasRestoredChanges) - - let mergedSession = try #require(await destStore.fetchRemoteNodeSession(id: existingSession.id)) - #expect(mergedSession.isConnected == true) - #expect(mergedSession.permissionLevel == .admin) - #expect(mergedSession.lastConnectedDate == localConnectedDate) - #expect(mergedSession.unreadCount == 9) - #expect(mergedSession.notificationLevel == .mentionsOnly) - #expect(mergedSession.isFavorite == true) - #expect(mergedSession.lastSyncTimestamp == 123) - #expect(mergedSession.lastMessageDate == importedDate) - } - - // MARK: - Test 16: Merge import — same-second node snapshots preserved - - @Test("Import preserves distinct node snapshots recorded within the same second") - func importNodeSnapshots_PreservesSubsecondHistory() async throws { - let radioID = UUID() - let nodePublicKey = Data(repeating: 0xF5, count: 32) - let baseTimestamp = Date(timeIntervalSince1970: 1_700_000_400) - let existingTimestamp = baseTimestamp.addingTimeInterval(0.100) - let importedTimestamp = baseTimestamp.addingTimeInterval(0.900) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - _ = try await destStore.saveNodeStatusSnapshot( - timestamp: existingTimestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: 3800, - lastSNR: 8.5, - lastRSSI: -90, - noiseFloor: -112, - uptimeSeconds: 60, - rxAirtimeSeconds: nil, - packetsSent: nil, - packetsReceived: nil, - receiveErrors: nil - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let existingSnapshot = NodeStatusSnapshotDTO.testSnapshot( - timestamp: existingTimestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: 3800, - lastSNR: 8.5, - lastRSSI: -90, - noiseFloor: -112, - uptimeSeconds: 60 - ) - let importedSnapshot = NodeStatusSnapshotDTO.testSnapshot( - timestamp: importedTimestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: nil, - lastSNR: nil, - lastRSSI: nil, - noiseFloor: nil, - uptimeSeconds: nil, - telemetryEntries: [TelemetrySnapshotEntry(channel: 1, type: "temperature", value: 21.5)] - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - nodeStatusSnapshots: [existingSnapshot, importedSnapshot] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.nodeStatusSnapshotsInserted == 1) - #expect(result.nodeStatusSnapshotsSkipped == 1) - - let snapshots = try await destStore.fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) - #expect(snapshots.count == 2) - #expect(snapshots.map(\.timestamp) == [existingTimestamp, importedTimestamp]) - #expect(snapshots.last?.telemetryEntries?.count == 1) - } - - // MARK: - Test 17: Failed import cleanup - - @Test("Successful import restores autosave on the destination store") - func successfulImport_RestoresAutosave() async throws { - let radioID = UUID() - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - await destStore.setAutosaveEnabledForTesting(true) - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.devicesInserted == 1) - #expect(await destStore.autosaveEnabledForTesting()) - #expect(!(await destStore.hasPendingChangesForTesting())) - #expect(try await destStore.fetchAllDevices().count == 1) - } - - @Test("Failed import rolls back reconcile-phase mutations to pre-existing rows") - func failedImport_RollsBackReconcileMutationsOnExistingRows() async throws { - let radioID = UUID() - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - await destStore.setAutosaveEnabledForTesting(true) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xC2, count: 32), - name: "Pre-existing" - ) - try await destStore.saveContact(contact) - // Pre-existing contacts always have lastMessageDate = nil out of the gate. - let baselineContacts = try await destStore.fetchAllContacts(radioID: radioID) - let preImportLastMessageDate = baselineContacts.first?.lastMessageDate - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupMessage = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Reconcile target", - timestamp: 99999 - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [backupMessage] - ) - - await destStore.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } - - await #expect(throws: InjectedImportFailure.simulated) { - try await destStore.importBackupDatabase(envelope) - } - - let postImportContacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(postImportContacts.count == 1) - #expect(postImportContacts.first?.lastMessageDate == preImportLastMessageDate) - #expect(try await destStore.fetchAllMessages().isEmpty) - #expect(!(await destStore.hasPendingChangesForTesting())) - } - - @Test("Failed import clears pending data and restores autosave") - func failedImport_RollsBackPendingChangesAndRestoresAutosave() async throws { - let radioID = UUID() - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - await destStore.setAutosaveEnabledForTesting(true) - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)] - ) - - await destStore.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } - - await #expect(throws: InjectedImportFailure.simulated) { - try await destStore.importBackupDatabase(envelope) - } - - #expect(await destStore.autosaveEnabledForTesting()) - #expect(!(await destStore.hasPendingChangesForTesting())) - #expect(try await destStore.fetchAllDevices().isEmpty) - - try await destStore.saveContact( - ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xC1, count: 32), - name: "Recovered Contact" - ) - ) - - let contacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(contacts.count == 1) - #expect(contacts.first?.name == "Recovered Contact") - } - - @Test("Disk-backed container has zero partial state after faulted import is abandoned") - func faultedImport_LeavesNoPartialStateOnDiskAfterReopen() async throws { - let storeURL = FileManager.default.temporaryDirectory - .appendingPathComponent("backup-crash-\(UUID().uuidString).store") - defer { - let fm = FileManager.default - try? fm.removeItem(at: storeURL) - try? fm.removeItem(at: storeURL.appendingPathExtension("shm")) - try? fm.removeItem(at: storeURL.appendingPathExtension("wal")) - } - - let radioID = UUID() - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], - contacts: [ - ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0x42, count: 32), - name: "Should not survive" - ) - ] - ) - - // Throw before save() to exercise the error path. With autosaveEnabled = false - // and save() never reached, nothing was flushed to SQLite — the defer's - // rollback() just clears the in-memory context. This is the error path, not a - // crash path: Swift's defer runs on throw, so a true bypassed-defer scenario - // would require a child process that exits mid-import. - do { - let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) - let container = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) - let store = PersistenceStore(modelContainer: container) - await store.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } - await #expect(throws: InjectedImportFailure.simulated) { - try await store.importBackupDatabase(envelope) - } - } - - let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) - let reopened = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) - let freshStore = PersistenceStore(modelContainer: reopened) - - #expect(try await freshStore.fetchAllDevices().isEmpty) - #expect(try await freshStore.fetchAllContacts(radioID: radioID).isEmpty) - } - - @Test("Concurrent live-store writer during import preserves both datasets") - func concurrentLiveWrite_DuringImport_PreservesBoth() async throws { - // Two @ModelActor instances on the same ModelContainer simulate a radio - // connecting mid-import: the backup flow resolved a standalone - // PersistenceStore at T=0, then ConnectionManager stood up a second - // PersistenceStore on the same container to service the live link. - let sharedContainer = try PersistenceStore.createContainer(inMemory: true) - let backupStore = PersistenceStore(modelContainer: sharedContainer) - let liveStore = PersistenceStore(modelContainer: sharedContainer) - - let backupRadioID = UUID() - let liveRadioID = UUID() - let backupDevicePublicKey = Data(repeating: 0xB0, count: 32) - let liveDevicePublicKey = Data(repeating: 0xC0, count: 32) - - let backupContact = ContactDTO.testContact( - radioID: backupRadioID, - publicKey: Data(repeating: 0xB1, count: 32), - name: "From backup" - ) - let envelope = AppBackupEnvelope.test( - devices: [ - DeviceDTO.testDevice( - id: backupRadioID, - radioID: backupRadioID, - publicKey: backupDevicePublicKey - ) - ], - contacts: [backupContact] - ) - - try await liveStore.saveDevice( - DeviceDTO.testDevice( - id: liveRadioID, - radioID: liveRadioID, - publicKey: liveDevicePublicKey - ) - ) - let liveContact = ContactDTO.testContact( - radioID: liveRadioID, - publicKey: Data(repeating: 0xC1, count: 32), - name: "From connect" - ) - - async let importResult: ImportResult = backupStore.importBackupDatabase(envelope) - async let liveWrite: Void = liveStore.saveContact(liveContact) - - _ = try await importResult - try await liveWrite - - // A third actor guarantees we read through the persistent store rather than - // either writer's context cache — `fetchAllContacts` on the writers can miss - // the other actor's commits until the cache invalidates. - let verifier = PersistenceStore(modelContainer: sharedContainer) - let liveContacts = try await verifier.fetchAllContacts(radioID: liveRadioID) - #expect(liveContacts.contains { $0.publicKey == liveContact.publicKey }) - let backupContacts = try await verifier.fetchAllContacts(radioID: backupRadioID) - #expect(backupContacts.contains { $0.publicKey == backupContact.publicKey }) - - let allDevices = try await verifier.fetchAllDevices() - #expect(allDevices.count == 2) - } - - // MARK: - Test 18: Export assigns content-based keys to nil-keyed messages - - @Test("Export assigns content-based dedup keys to incoming messages with nil deduplicationKey") - func exportAssignsContentBasedKeysToNilKeyedMessages() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xF6, count: 32), - name: "Dan" - ) - try await sourceStore.saveContact(contact) - - // Save an incoming DM with nil deduplicationKey (simulates pre-migration message) - let dm = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Pre-migration DM", - timestamp: 12345, - direction: .incoming - ) - try await sourceStore.saveMessage(dm) - - // Save an incoming channel message with nil deduplicationKey - let chMsg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 2, - text: "Pre-migration channel msg", - timestamp: 67890, - direction: .incoming, - senderNodeName: "Node1" - ) - try await sourceStore.saveMessage(chMsg) - - // Export - let service = AppBackupService() - let result = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: result.data) - - // Verify exported messages have content-based keys, not backup- - let exportedDM = try #require(envelope.messages.first { $0.contactID == contact.id }) - #expect(exportedDM.deduplicationKey?.hasPrefix("dm-") == true) - #expect(exportedDM.deduplicationKey?.hasPrefix("backup-") != true) - - let exportedCh = try #require(envelope.messages.first { $0.channelIndex == 2 }) - #expect(exportedCh.deduplicationKey?.hasPrefix("ch-") == true) - #expect(exportedCh.deduplicationKey?.hasPrefix("backup-") != true) - } - - // MARK: - Test 19: Export preserves existing content-based keys - - @Test("Export preserves existing content-based dedup keys unchanged") - func exportPreservesExistingContentBasedKeys() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact(radioID: radioID) - try await sourceStore.saveContact(contact) - - let existingKey = "dm-\(contact.id.uuidString)-99999-AABBCCDD" - var msg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Already has key" - ) - msg.deduplicationKey = existingKey - try await sourceStore.saveMessage(msg) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: result.data) - - let exported = try #require(envelope.messages.first) - #expect(exported.deduplicationKey == existingKey) - } - - // MARK: - Test 23: Duplicates within a single backup don't orphan their children - - /// Two incoming messages in the same envelope that share a content key — the - /// second is skipped (same wire packet seen twice), and its repeats/reactions - /// must be remapped to the first (winning) UUID. - @Test("Duplicate messages within one envelope: children of the skipped duplicate link to the inserted message") - func importDuplicateMessagesInEnvelope_ChildrenLinkToInsertedDuplicate() async throws { - let radioID = UUID() - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - - var firstMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Duplicate in envelope", - timestamp: 1_700_000_500, - direction: .incoming - ) - firstMsg.deduplicationKey = nil - - var secondMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Duplicate in envelope", - timestamp: 1_700_000_500, - direction: .incoming - ) - secondMsg.deduplicationKey = nil - #expect(firstMsg.id != secondMsg.id) - - // Children reference the second (to-be-skipped) message's UUID. - let repeatForSecond = MessageRepeatDTO.testRepeat( - messageID: secondMsg.id, - pathNodes: Data([0x11]) - ) - let reactionForSecond = ReactionDTO.testReaction( - messageID: secondMsg.id, - radioID: radioID, - emoji: "🌶️", - senderName: "Dup" - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [firstMsg, secondMsg], - messageRepeats: [repeatForSecond], - reactions: [reactionForSecond] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.messagesInserted == 1) - #expect(result.messagesSkipped == 1) - #expect(result.messageRepeatsInserted == 1) - #expect(result.reactionsInserted == 1) - - // Children must attach to the first (winning) UUID; the second UUID must have no rows. - let repeatsUnderFirst = try await destStore.fetchMessageRepeats(messageID: firstMsg.id) - #expect(repeatsUnderFirst.count == 1) - let orphanedRepeats = try await destStore.fetchMessageRepeats(messageID: secondMsg.id) - #expect(orphanedRepeats.isEmpty) - - let winner = try #require(await destStore.fetchMessage(id: firstMsg.id)) - #expect(winner.heardRepeats == 1) - #expect(winner.reactionSummary == "🌶️:1") - } - - // MARK: - Test 24: Cancellation after DB commit reports success, not cancelled - - /// A task cancelled between the DB commit and the rest of `importBackup` - /// must not throw CancellationError. The DB write has already landed, so - /// a throw here would report cancellation while the database actually - /// persisted. - @Test("Task cancellation after DB commit does not throw and returns a successful result") - func cancellationAfterCommit_ImportReturnsSuccess() async throws { - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice()] - ) - - // Post-commit hook cancels the task that's running the import (the - // child Task below), mirroring the race where the user taps Cancel - // mid-save. Running the import in a child Task keeps the outer test's - // cancellation state clean so post-import fetches can run. - await destStore.setBackupImportPostCommitHook { - withUnsafeCurrentTask { $0?.cancel() } - } - - let service = AppBackupService() - let importTask = Task { - try await service.importBackup(envelope: envelope, into: destStore) - } - - let result: ImportResult - do { - result = try await importTask.value - } catch { - Issue.record("Import after post-commit cancellation should succeed, got: \(error)") - return - } - - #expect(result.devicesInserted == 1) - #expect(try await destStore.fetchAllDevices().count == 1) - } - - // MARK: - Test 25: userDefaultsRestored reflects actual writes - - /// When every UserDefaults key carried in the backup is already set - /// locally, `restore(to:)` writes nothing. The import result must then - /// report `userDefaultsRestored == false`, otherwise a second no-op - /// import would claim `hasRestoredChanges` with nothing actually changed. - @Test("Import reports userDefaultsRestored=false when no new keys were written") - func importWithAllDefaultsAlreadySet_ReportsRestoredFalse() async throws { - let key = "hasCompletedOnboarding" - let defaults = UserDefaults.standard - let originalValue = defaults.object(forKey: key) - defaults.set(true, forKey: key) - defer { - if let value = originalValue { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - - var backupDefaults = BackupUserDefaults() - backupDefaults.hasCompletedOnboarding = true - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice()], - userDefaults: backupDefaults - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(!result.userDefaultsRestored) - } - - // MARK: - Test 26: Fresh-store import — contact unread counts preserved - - @Test("Fresh-insert import preserves contact unread counts from backup") - func importContact_FreshInsertPreservesUnreadCount() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xE5, count: 32) - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupContact = ContactDTO.testContact( - radioID: radioID, - publicKey: publicKey, - unreadCount: 7, - unreadMentionCount: 2 - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [backupContact] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.contactsInserted == 1) - #expect(result.contactsSkipped == 0) - - let importedContact = try #require( - await destStore.fetchContact(radioID: radioID, publicKey: publicKey) - ) - #expect(importedContact.unreadCount == 7) - #expect(importedContact.unreadMentionCount == 2) - } - - // MARK: - Test 27: Fresh-store import — channel unread counts preserved - - @Test("Fresh-insert import preserves channel unread counts from backup") - func importChannel_FreshInsertPreservesUnreadCount() async throws { - let radioID = UUID() - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 4, - unreadCount: 3, - unreadMentionCount: 1 - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - channels: [backupChannel] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 1) - #expect(result.channelsSkipped == 0) - - let importedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 4)) - #expect(importedChannel.unreadCount == 3) - #expect(importedChannel.unreadMentionCount == 1) - } - - // MARK: - Test 28: Merge import — contact unread counts max with local - - @Test("Merge import keeps local contact unread counts when they exceed backup values") - func importOntoExistingContact_UnreadMergesUsingMax() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xE6, count: 32) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingContact = ContactDTO.testContact( - radioID: radioID, - publicKey: publicKey, - unreadCount: 9, - unreadMentionCount: 4 - ) - try await destStore.saveContact(existingContact) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupContact = ContactDTO.testContact( - radioID: radioID, - publicKey: publicKey, - unreadCount: 2, - unreadMentionCount: 1 - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - contacts: [backupContact] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.contactsInserted == 0) - #expect(result.contactsSkipped == 1) - - let mergedContact = try #require( - await destStore.fetchContact(radioID: radioID, publicKey: publicKey) - ) - #expect(mergedContact.unreadCount == 9) - #expect(mergedContact.unreadMentionCount == 4) - } - - // MARK: - Test 29: Merge import — channel unread counts max with local - - @Test("Merge import keeps local channel unread counts when they exceed backup values") - func importOntoExistingChannel_UnreadMergesUsingMax() async throws { - let radioID = UUID() - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 5, - unreadCount: 6, - unreadMentionCount: 3 - ) - try await destStore.saveChannel(existingChannel) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 5, - unreadCount: 1, - unreadMentionCount: 0 - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupChannel] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - - let mergedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 5)) - #expect(mergedChannel.unreadCount == 6) - #expect(mergedChannel.unreadMentionCount == 3) - } - - // MARK: - Test 30: Merge import — remote session unread count max with local - - @Test("Merge import keeps local remote-session unread count when it exceeds backup value") - func importOntoExistingRemoteSession_UnreadMergesUsingMax() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xE7, count: 32) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingSession = RemoteNodeSessionDTO.testSession( - radioID: radioID, - publicKey: publicKey, - unreadCount: 8 - ) - try await destStore.saveRemoteNodeSessionDTO(existingSession) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupSession = RemoteNodeSessionDTO.testSession( - id: UUID(), - radioID: radioID, - publicKey: publicKey, - unreadCount: 2 - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - remoteNodeSessions: [backupSession] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.remoteNodeSessionsInserted == 0) - #expect(result.remoteNodeSessionsSkipped == 1) - - let mergedSession = try #require(await destStore.fetchRemoteNodeSession(id: existingSession.id)) - #expect(mergedSession.unreadCount == 8) - } - - // MARK: - Outgoing duplicates must not collapse - - /// A user who double-taps send (or retries within the same UInt32 second) ends up - /// with two outgoing rows that share recipient + text + timestamp. They are two - /// distinct intentional actions, and backup/restore must keep both. - @Test("Outgoing messages with identical recipient/text/timestamp survive round-trip without deduplication") - func outgoingDuplicates_PreservedAcrossRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await sourceStore.saveContact(contact) - - let sharedTimestamp: UInt32 = 1_700_001_234 - - let firstMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "ok", - timestamp: sharedTimestamp, - direction: .outgoing - ) - let secondMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "ok", - timestamp: sharedTimestamp, - direction: .outgoing - ) - #expect(firstMsg.id != secondMsg.id) - try await sourceStore.saveMessage(firstMsg) - try await sourceStore.saveMessage(secondMsg) - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.messagesInserted == 2) - #expect(result.messagesSkipped == 0) - - let destMessages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(destMessages.count == 2) - #expect(Set(destMessages.map(\.id)) == Set([firstMsg.id, secondMsg.id])) - } - - // MARK: - Repeat rows through the same route must not collapse - - /// `MessageRepeat` distinguishes hearings by `id`/`rxLogEntryID`. Two observations - /// of the same message through the same path (e.g. a flood echo) are genuinely - /// distinct and must both survive round-trip so `heardRepeats` stays accurate. - @Test("Repeats with identical path but distinct ids survive round-trip and heardRepeats is recomputed") - func repeatsOnSamePath_PreservedAcrossRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await sourceStore.saveContact(contact) - - var msg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "echoed", - direction: .outgoing - ) - msg.deduplicationKey = nil - try await sourceStore.saveMessage(msg) - - let sharedPath = Data([0x42]) - let firstRepeat = MessageRepeatDTO.testRepeat( - messageID: msg.id, - pathNodes: sharedPath, - rxLogEntryID: UUID() - ) - let secondRepeat = MessageRepeatDTO.testRepeat( - messageID: msg.id, - pathNodes: sharedPath, - rxLogEntryID: UUID() - ) - #expect(firstRepeat.id != secondRepeat.id) - try await sourceStore.saveMessageRepeat(firstRepeat) - try await sourceStore.saveMessageRepeat(secondRepeat) - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.messageRepeatsInserted == 2) - #expect(result.messageRepeatsSkipped == 0) - - let restoredRepeats = try await destStore.fetchMessageRepeats(messageID: msg.id) - #expect(restoredRepeats.count == 2) - #expect(Set(restoredRepeats.map(\.id)) == Set([firstRepeat.id, secondRepeat.id])) - - let restoredMsg = try #require(await destStore.fetchMessage(id: msg.id)) - #expect(restoredMsg.heardRepeats == 2) - } - - // MARK: - Reply chain remap - - /// When a backup's replied-to parent already exists locally (content-keyed merge), - /// the reply's replyToID must be rewritten onto the local parent UUID — otherwise - /// reply navigation dangles on the pre-merge backup UUID. - @Test("Reply remaps onto local parent when parent is merged during import") - func replyRemapOntoMergedParent() async throws { - let radioID = UUID() - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await destStore.saveContact(contact) - - var existingParent = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Parent", - direction: .incoming - ) - existingParent.deduplicationKey = "reply-remap-parent" - try await destStore.saveMessage(existingParent) - - // Backup contains the same parent (will be skipped/merged) and a reply - // whose replyToID points at the backup-side parent UUID. - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - var backupParent = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Parent", - direction: .incoming - ) - backupParent.deduplicationKey = "reply-remap-parent" - #expect(backupParent.id != existingParent.id) - - var reply = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Reply", - direction: .incoming, - replyToID: backupParent.id - ) - reply.deduplicationKey = "reply-remap-reply" - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [backupParent, reply] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - #expect(result.messagesSkipped == 1) - #expect(result.messagesInserted == 1) - - let destMessages = try await destStore.fetchAllMessages(radioID: radioID) - let restoredReply = try #require(destMessages.first { $0.id == reply.id }) - #expect(restoredReply.replyToID == existingParent.id) - } - - // MARK: - Cross-radio channel message preservation - - /// Two companion radios often receive the same over-the-air channel packet. The stored - /// live-sync deduplicationKey is radio-agnostic by construction, so the backup-reconciliation - /// key must be scoped by radioID. Otherwise the import path collapses both rows into one - /// and the second companion's channel view renders blank after restore — the same failure - /// mode fixed in live sync (issue #288) leaking back through backup/restore. - @Test("Backup restores the same channel packet under each companion radio that received it") - func crossRadioChannelMessagesBothSurviveImport() async throws { - let radioA = UUID() - let radioB = UUID() - let deviceA = DeviceDTO.testDevice( - id: radioA, - radioID: radioA, - publicKey: Data(repeating: 0xAA, count: 32), - nodeName: "CompanionA" - ) - let deviceB = DeviceDTO.testDevice( - id: radioB, - radioID: radioB, - publicKey: Data(repeating: 0xBB, count: 32), - nodeName: "CompanionB" - ) - let channelA = ChannelDTO.testChannel(radioID: radioA, index: 0, name: "General") - let channelB = ChannelDTO.testChannel(radioID: radioB, index: 0, name: "General") - - // Same wire packet — identical content-based dedup key — seen by both companions. - let sharedDedupKey = "ch-0-1700000000-Alice-A1B2C3D4" - var msgFromA = MessageDTO.testChannelMessage( - radioID: radioA, - channelIndex: 0, - text: "aaaaa", - timestamp: 1_700_000_000, - direction: .incoming, - status: .delivered, - senderNodeName: "Alice" - ) - msgFromA.deduplicationKey = sharedDedupKey - - var msgFromB = MessageDTO.testChannelMessage( - radioID: radioB, - channelIndex: 0, - text: "aaaaa", - timestamp: 1_700_000_000, - direction: .incoming, - status: .delivered, - senderNodeName: "Alice" - ) - msgFromB.deduplicationKey = sharedDedupKey - - let envelope = AppBackupEnvelope.test( - devices: [deviceA, deviceB], - channels: [channelA, channelB], - messages: [msgFromA, msgFromB] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.messagesInserted == 2, - "Both companions' copies of the same channel packet must be restored — otherwise the second companion's channel view goes blank post-restore") - #expect(result.messagesSkipped == 0) - - let messagesForA = try await destStore.fetchAllMessages(radioID: radioA) - let messagesForB = try await destStore.fetchAllMessages(radioID: radioB) - #expect(messagesForA.count == 1) - #expect(messagesForB.count == 1) - #expect(messagesForA.first?.text == "aaaaa") - #expect(messagesForB.first?.text == "aaaaa") - } - - // MARK: - regionSelection backup contract - - @Test("regionSelection round-trips through encode/decode") - func regionSelectionRoundTrips() throws { - var prefs = BackupUserDefaults() - prefs.regionSelection = RegionSelection( - countryCode: "US", - administrativeAreaCode: "US-CA", - countyKey: "los angeles", - source: .location - ) - let data = try JSONEncoder().encode(prefs) - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - #expect(decoded.regionSelection == prefs.regionSelection) - } - - @Test("Legacy envelope without regionSelection decodes as nil") - func legacyEnvelopeDecodesRegionAsNil() throws { - let legacyJSON = """ - { - "hasCompletedOnboarding": true, - "mapStyleSelection": "topo" - } - """.data(using: .utf8)! - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) - #expect(decoded.regionSelection == nil) - #expect(decoded.hasCompletedOnboarding == true) - } - - @Test("restore writes regionSelection only when local key is missing") - func restoreRespectsExistingValue() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let local = RegionSelection(countryCode: "DE", source: .manual) - defaults.set(try JSONEncoder().encode(local), forKey: BackupUserDefaults.regionSelectionKey) - - var prefs = BackupUserDefaults() - prefs.regionSelection = RegionSelection(countryCode: "US", source: .location) - let setKeys = prefs.restore(to: defaults) - #expect(!setKeys.contains(BackupUserDefaults.regionSelectionKey)) - - let stillThere = try JSONDecoder().decode( - RegionSelection.self, - from: defaults.data(forKey: BackupUserDefaults.regionSelectionKey)! - ) - #expect(stillThere == local) - } - - @Test("restore writes regionSelection when local is missing (fresh install)") - func restoreWritesWhenMissing() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - var prefs = BackupUserDefaults() - prefs.regionSelection = RegionSelection(countryCode: "US", source: .location) - let setKeys = prefs.restore(to: defaults) - - #expect(setKeys.contains(BackupUserDefaults.regionSelectionKey)) - let restored = try JSONDecoder().decode( - RegionSelection.self, - from: defaults.data(forKey: BackupUserDefaults.regionSelectionKey)! - ) - #expect(restored == prefs.regionSelection) - } - - // MARK: - Message.regionScope round-trip - - @Test("Message.regionScope round-trips through full export/import") - func messageRegionScopeRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xAB, count: 32), - name: "Alice" - ) - try await sourceStore.saveContact(contact) - - var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id, text: "Greetings") - msg.regionScope = "Germany" - msg.deduplicationKey = "region-scope-roundtrip-\(UUID())" - try await sourceStore.saveMessage(msg) - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - _ = try await service.importBackup(envelope: envelope, into: destStore) - - let restored = try await destStore.fetchAllMessages(radioID: radioID) - #expect(restored.count == 1) - #expect(restored.first?.regionScope == "Germany") - } - - @Test("MessageDTO Codable: regionScope set decodes round-trip") - func messageDTORegionScopeCodableRoundTrip() throws { - var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Test") - dto.regionScope = "Bavaria" - - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) - #expect(decoded.regionScope == "Bavaria") - } - - @Test("Legacy MessageDTO envelope without regionScope decodes as nil") - func messageDTOLegacyEnvelopeMissingRegionScope() throws { - let baseDTO = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Legacy") - let encoded = try JSONEncoder().encode(baseDTO) - var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - json.removeValue(forKey: "regionScope") - - let stripped = try JSONSerialization.data(withJSONObject: json) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) - #expect(decoded.regionScope == nil) - } - - @Test("Message(dto:) forwards regionScope verbatim through DTO to model") - func messageInitFromDTOForwardsRegionScope() { - var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Forward") - dto.regionScope = "USA" - let model = Message(dto: dto) - #expect(model.regionScope == "USA") - - var nilDTO = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "NilCase") - nilDTO.regionScope = nil - let nilModel = Message(dto: nilDTO) - #expect(nilModel.regionScope == nil) - } - - // MARK: - MessageDTO.sortDate round-trip - - @Test("MessageDTO Codable: sortDate distinct from createdAt round-trips") - func messageDTOSortDateCodableRoundTrip() throws { - let createdAt = Date(timeIntervalSince1970: 1_700_000_000) - let sortDate = Date(timeIntervalSince1970: 1_600_000_000) - var dto = MessageDTO.testDirectMessage( - radioID: UUID(), - contactID: UUID(), - text: "Test", - createdAt: createdAt - ) - dto.sortDate = sortDate - - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) - #expect(decoded.sortDate == sortDate) - #expect(decoded.sortDate != decoded.createdAt) - } - - @Test("Legacy MessageDTO envelope without sortDate falls back to createdAt") - func messageDTOLegacyEnvelopeMissingSortDate() throws { - let baseDTO = MessageDTO.testDirectMessage( - radioID: UUID(), - contactID: UUID(), - text: "Legacy", - createdAt: Date(timeIntervalSince1970: 1_700_000_000) - ) - let encoded = try JSONEncoder().encode(baseDTO) - var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - json.removeValue(forKey: "sortDate") - - let stripped = try JSONSerialization.data(withJSONObject: json) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) - #expect(decoded.sortDate == decoded.createdAt) - } - - @Test("Message(dto:) forwards sortDate verbatim through DTO to model") - func messageInitFromDTOForwardsSortDate() { - let createdAt = Date(timeIntervalSince1970: 1_700_000_000) - let sortDate = Date(timeIntervalSince1970: 1_600_000_000) - var dto = MessageDTO.testDirectMessage( - radioID: UUID(), - contactID: UUID(), - text: "Forward", - createdAt: createdAt - ) - dto.sortDate = sortDate - let model = Message(dto: dto) - #expect(model.sortDate == sortDate) - #expect(model.sortDate != model.createdAt) - } - - @Test("Fully-populated MessageDTO survives encode/decode with every field distinct") - func messageDTOFullyPopulatedCodableRoundTrip() throws { - let dto = MessageDTO( - id: UUID(), - radioID: UUID(), - contactID: UUID(), - channelIndex: 7, - text: "Every field set", - timestamp: 1_700_000_001, - createdAt: Date(timeIntervalSince1970: 1_700_000_000), - direction: .incoming, - status: .delivered, - textType: .signedPlain, - ackCode: 0xDEAD_BEEF, - pathLength: 5, - snr: 12.5, - pathNodes: Data([0x01, 0x02, 0x03]), - senderKeyPrefix: Data([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), - senderNodeName: "Bob", - isRead: true, - replyToID: UUID(), - roundTripTime: 432, - heardRepeats: 3, - sendCount: 2, - retryAttempt: 1, - maxRetryAttempts: 4, - deduplicationKey: "dedup-key", - linkPreviewURL: "https://example.com", - linkPreviewTitle: "Example", - linkPreviewImageData: Data([0x10, 0x20]), - linkPreviewIconData: Data([0x30, 0x40]), - linkPreviewFetched: true, - containsSelfMention: true, - mentionSeen: true, - timestampCorrected: true, - senderTimestamp: 1_699_999_999, - reactionSummary: "👍:3,❤️:2", - routeType: .tcDirect, - regionScope: "Germany" - ) - var populated = dto - populated.sortDate = Date(timeIntervalSince1970: 1_600_000_000) - - let encoded = try JSONEncoder().encode(populated) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) - #expect(decoded == populated) - } - - @Test("saveMessage persists send metadata and preview fields through export and import") - func saveMessagePreservesMetadataThroughBackupRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - var msg = MessageDTO.testDirectMessage(radioID: radioID, sendCount: 3) - msg.deduplicationKey = "save-message-fields-\(UUID())" - msg.linkPreviewURL = "https://example.com" - msg.linkPreviewTitle = "Example" - msg.reactionSummary = "👍:2" - try await sourceStore.saveMessage(msg) - - // saveMessage routes through Message(dto:), so the live row keeps every DTO field. - let savedRow = try #require(await sourceStore.fetchMessage(id: msg.id)) - #expect(savedRow.sendCount == 3) - #expect(savedRow.linkPreviewURL == "https://example.com") - #expect(savedRow.linkPreviewTitle == "Example") - #expect(savedRow.reactionSummary == "👍:2") - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - _ = try await service.importBackup(envelope: envelope, into: destStore) - - let imported = try #require(await destStore.fetchMessage(id: msg.id)) - #expect(imported.sendCount == 3) - #expect(imported.linkPreviewURL == "https://example.com") - #expect(imported.linkPreviewTitle == "Example") - #expect(imported.reactionSummary == "👍:2") - } - - // MARK: - selectedThemeID backup contract - - @Test("selectedThemeID round-trips through encode/decode") - func selectedThemeIDRoundTrips() throws { - var prefs = BackupUserDefaults() - prefs.selectedThemeID = "ember" - let data = try JSONEncoder().encode(prefs) - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - #expect(decoded.selectedThemeID == "ember") - } - - @Test("Legacy envelope without selectedThemeID decodes as nil") - func legacyEnvelopeDecodesSelectedThemeIDAsNil() throws { - let legacyJSON = """ - { "hasCompletedOnboarding": true, "mapStyleSelection": "topo" } - """.data(using: .utf8)! - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) - #expect(decoded.selectedThemeID == nil) - #expect(decoded.hasCompletedOnboarding == true) - } - - @Test("restore writes selectedThemeID only when local key is missing") - func restoreRespectsExistingSelectedThemeID() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - defaults.set("marine", forKey: PersistenceKeys.selectedThemeID) - var prefs = BackupUserDefaults() - prefs.selectedThemeID = "ember" - let setKeys = prefs.restore(to: defaults) - #expect(!setKeys.contains(PersistenceKeys.selectedThemeID)) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == "marine") - } - - @Test("restore writes selectedThemeID when local is missing (fresh install)") - func restoreWritesSelectedThemeIDWhenMissing() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - var prefs = BackupUserDefaults() - prefs.selectedThemeID = "ember" - let setKeys = prefs.restore(to: defaults) - #expect(setKeys.contains(PersistenceKeys.selectedThemeID)) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == "ember") - } - - // MARK: - appColorSchemePreference backup contract - - @Test("appColorSchemePreference round-trips through encode/decode") - func appColorSchemePreferenceRoundTrips() throws { - var prefs = BackupUserDefaults() - prefs.appColorSchemePreference = "dark" - let data = try JSONEncoder().encode(prefs) - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - #expect(decoded.appColorSchemePreference == "dark") - } - - @Test("Legacy envelope without appColorSchemePreference decodes as nil") - func legacyEnvelopeDecodesAppColorSchemePreferenceAsNil() throws { - let legacyJSON = """ - { "hasCompletedOnboarding": true, "mapStyleSelection": "topo" } - """.data(using: .utf8)! - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) - #expect(decoded.appColorSchemePreference == nil) - } - - @Test("restore writes appColorSchemePreference only when local key is missing") - func restoreRespectsExistingAppColorSchemePreference() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - defaults.set("light", forKey: PersistenceKeys.appColorSchemePreference) - var prefs = BackupUserDefaults() - prefs.appColorSchemePreference = "dark" - let setKeys = prefs.restore(to: defaults) - #expect(!setKeys.contains(PersistenceKeys.appColorSchemePreference)) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "light") - } - - @Test("restore writes appColorSchemePreference when local is missing (fresh install)") - func restoreWritesAppColorSchemePreferenceWhenMissing() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - var prefs = BackupUserDefaults() - prefs.appColorSchemePreference = "dark" - let setKeys = prefs.restore(to: defaults) - #expect(setKeys.contains(PersistenceKeys.appColorSchemePreference)) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "dark") - } - - // MARK: - Export read path (snapshot) for the new appearance keys - - @Test("snapshot reads selectedThemeID and appColorSchemePreference from UserDefaults") - func snapshotReadsAppearanceKeys() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - defaults.set("marine", forKey: PersistenceKeys.selectedThemeID) - defaults.set("dark", forKey: PersistenceKeys.appColorSchemePreference) - let snapshot = BackupUserDefaults.snapshot(from: defaults) - #expect(snapshot.selectedThemeID == "marine") - #expect(snapshot.appColorSchemePreference == "dark") - } - - @Test("snapshot leaves appearance keys nil when unset") - func snapshotLeavesAppearanceKeysNilWhenUnset() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let snapshot = BackupUserDefaults.snapshot(from: defaults) - #expect(snapshot.selectedThemeID == nil) - #expect(snapshot.appColorSchemePreference == nil) - } -} - -private enum InjectedImportFailure: Error { - case simulated -} - -private extension PersistenceStore { - func setAutosaveEnabledForTesting(_ isEnabled: Bool) { - modelContext.autosaveEnabled = isEnabled - } - - func autosaveEnabledForTesting() -> Bool { - modelContext.autosaveEnabled - } - - func hasPendingChangesForTesting() -> Bool { - modelContext.hasChanges - } + func hasPendingChangesForTesting() -> Bool { + modelContext.hasChanges + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BackupUserDefaultsCoverageTests.swift b/MC1Services/Tests/MC1ServicesTests/BackupUserDefaultsCoverageTests.swift index 908a54b7..b4167d9d 100644 --- a/MC1Services/Tests/MC1ServicesTests/BackupUserDefaultsCoverageTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BackupUserDefaultsCoverageTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import MC1Services +import Testing /// Coverage tests for `BackupUserDefaults`. Asserts every `Bool?`/`String?` property /// has a matching mapping row, and every `Optional<*>` property is covered by a @@ -23,93 +23,92 @@ import Testing /// test must be revisited. @Suite("BackupUserDefaultsCoverage") struct BackupUserDefaultsCoverageTests { - - @Test("Every Bool? property has a boolMappings row") - func everyBoolPropertyHasMapping() { - let propertyNames = optionalPropertyNames(matching: Optional.self) - let mappingKeys = BackupUserDefaults.boolMappingKeys - - let missing = propertyNames.subtracting(mappingKeys) - let extra = mappingKeys.subtracting(propertyNames) - - #expect(missing.isEmpty, """ - Bool? properties without boolMappings rows: \(missing.sorted()). - Add (\\., "") to BackupUserDefaults.boolMappings, - or hand-roll a special-cased branch in snapshot/restore (see regionSelection). - See BackupUserDefaults.boolMappings. - """) - - #expect(extra.isEmpty, """ - boolMappings rows without matching Bool? properties: \(extra.sorted()). - Likely a stale entry after a property rename or removal. - See BackupUserDefaults.boolMappings. - """) - } - - @Test("Every String? property has a stringMappings row") - func everyStringPropertyHasMapping() { - let propertyNames = optionalPropertyNames(matching: Optional.self) - let mappingKeys = BackupUserDefaults.stringMappingKeys - - let missing = propertyNames.subtracting(mappingKeys) - let extra = mappingKeys.subtracting(propertyNames) - - #expect(missing.isEmpty, """ - String? properties without stringMappings rows: \(missing.sorted()). - Add (\\., "") to BackupUserDefaults.stringMappings, - or hand-roll a special-cased branch in snapshot/restore. - See BackupUserDefaults.stringMappings. - """) - - #expect(extra.isEmpty, """ - stringMappings rows without matching String? properties: \(extra.sorted()). - Likely a stale entry after a property rename or removal. - See BackupUserDefaults.stringMappings. - """) - } - - @Test("Every Optional property is covered by a mapping or special-cased") - func everyOptionalPropertyIsCovered() { - let allOptional = allOptionalPropertyNames() - let covered = BackupUserDefaults.boolMappingKeys - .union(BackupUserDefaults.stringMappingKeys) - .union(BackupUserDefaults.specialCasedPropertyNames) - - let uncovered = allOptional.subtracting(covered) - let staleAllowList = BackupUserDefaults.specialCasedPropertyNames.subtracting(allOptional) - - #expect(uncovered.isEmpty, """ - Optional properties not covered by any mapping or allow-list: \(uncovered.sorted()). - Either: - (a) add to boolMappings/stringMappings if Bool?/String?, or - (b) hand-roll snapshot/restore branches and add to specialCasedPropertyNames. - See BackupUserDefaults.boolMappings / stringMappings / specialCasedPropertyNames. - """) - - #expect(staleAllowList.isEmpty, """ - specialCasedPropertyNames entries without matching Optional properties: \(staleAllowList.sorted()). - Likely a stale allow-list entry after a property rename or removal. - See BackupUserDefaults.specialCasedPropertyNames. - """) - } - - // MARK: - Mirror helpers - - private func optionalPropertyNames(matching: T.Type) -> Set { - let mirror = Mirror(reflecting: BackupUserDefaults()) - return Set(mirror.children.compactMap { child -> String? in - guard let label = child.label, - type(of: child.value) == T.self else { return nil } - return label - }) - } - - private func allOptionalPropertyNames() -> Set { - let mirror = Mirror(reflecting: BackupUserDefaults()) - return Set(mirror.children.compactMap { child -> String? in - guard let label = child.label, - Mirror(reflecting: child.value).displayStyle == .optional else { return nil } - return label - }) - } + @Test + func `Every Bool? property has a boolMappings row`() { + let propertyNames = optionalPropertyNames(matching: Bool?.self) + let mappingKeys = BackupUserDefaults.boolMappingKeys + + let missing = propertyNames.subtracting(mappingKeys) + let extra = mappingKeys.subtracting(propertyNames) + + #expect(missing.isEmpty, """ + Bool? properties without boolMappings rows: \(missing.sorted()). + Add (\\., "") to BackupUserDefaults.boolMappings, + or hand-roll a special-cased branch in snapshot/restore (see regionSelection). + See BackupUserDefaults.boolMappings. + """) + + #expect(extra.isEmpty, """ + boolMappings rows without matching Bool? properties: \(extra.sorted()). + Likely a stale entry after a property rename or removal. + See BackupUserDefaults.boolMappings. + """) + } + + @Test + func `Every String? property has a stringMappings row`() { + let propertyNames = optionalPropertyNames(matching: String?.self) + let mappingKeys = BackupUserDefaults.stringMappingKeys + + let missing = propertyNames.subtracting(mappingKeys) + let extra = mappingKeys.subtracting(propertyNames) + + #expect(missing.isEmpty, """ + String? properties without stringMappings rows: \(missing.sorted()). + Add (\\., "") to BackupUserDefaults.stringMappings, + or hand-roll a special-cased branch in snapshot/restore. + See BackupUserDefaults.stringMappings. + """) + + #expect(extra.isEmpty, """ + stringMappings rows without matching String? properties: \(extra.sorted()). + Likely a stale entry after a property rename or removal. + See BackupUserDefaults.stringMappings. + """) + } + + @Test + func `Every Optional property is covered by a mapping or special-cased`() { + let allOptional = allOptionalPropertyNames() + let covered = BackupUserDefaults.boolMappingKeys + .union(BackupUserDefaults.stringMappingKeys) + .union(BackupUserDefaults.specialCasedPropertyNames) + + let uncovered = allOptional.subtracting(covered) + let staleAllowList = BackupUserDefaults.specialCasedPropertyNames.subtracting(allOptional) + + #expect(uncovered.isEmpty, """ + Optional properties not covered by any mapping or allow-list: \(uncovered.sorted()). + Either: + (a) add to boolMappings/stringMappings if Bool?/String?, or + (b) hand-roll snapshot/restore branches and add to specialCasedPropertyNames. + See BackupUserDefaults.boolMappings / stringMappings / specialCasedPropertyNames. + """) + + #expect(staleAllowList.isEmpty, """ + specialCasedPropertyNames entries without matching Optional properties: \(staleAllowList.sorted()). + Likely a stale allow-list entry after a property rename or removal. + See BackupUserDefaults.specialCasedPropertyNames. + """) + } + + // MARK: - Mirror helpers + + private func optionalPropertyNames(matching: T.Type) -> Set { + let mirror = Mirror(reflecting: BackupUserDefaults()) + return Set(mirror.children.compactMap { child -> String? in + guard let label = child.label, + type(of: child.value) == T.self else { return nil } + return label + }) + } + + private func allOptionalPropertyNames() -> Set { + let mirror = Mirror(reflecting: BackupUserDefaults()) + return Set(mirror.children.compactMap { child -> String? in + guard let label = child.label, + Mirror(reflecting: child.value).displayStyle == .optional else { return nil } + return label + }) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BlockedChannelSenderPersistenceTests.swift b/MC1Services/Tests/MC1ServicesTests/BlockedChannelSenderPersistenceTests.swift index 6e78dde8..cf50af2b 100644 --- a/MC1Services/Tests/MC1ServicesTests/BlockedChannelSenderPersistenceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BlockedChannelSenderPersistenceTests.swift @@ -1,152 +1,151 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("BlockedChannelSender Persistence Tests") struct BlockedChannelSenderPersistenceTests { + // MARK: - Test Helpers - // MARK: - Test Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private let radioID = UUID() - - // MARK: - Save & Fetch + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } - @Test("Save and fetch round-trip returns the blocked sender") - func saveAndFetchRoundTrip() async throws { - let store = try await createTestStore() - let dto = BlockedChannelSenderDTO(name: "Spammer", radioID: radioID) + private let radioID = UUID() - try await store.saveBlockedChannelSender(dto) - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + // MARK: - Save & Fetch - #expect(fetched.count == 1) - #expect(fetched.first?.name == "Spammer") - #expect(fetched.first?.radioID == radioID) - } + @Test + func `Save and fetch round-trip returns the blocked sender`() async throws { + let store = try await createTestStore() + let dto = BlockedChannelSenderDTO(name: "Spammer", radioID: radioID) + + try await store.saveBlockedChannelSender(dto) + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - // MARK: - Upsert + #expect(fetched.count == 1) + #expect(fetched.first?.name == "Spammer") + #expect(fetched.first?.radioID == radioID) + } - @Test("Re-saving same name updates dateBlocked instead of creating duplicate") - func upsertUpdateDateBlocked() async throws { - let store = try await createTestStore() - let earlier = Date.distantPast - let later = Date.now + // MARK: - Upsert - let first = BlockedChannelSenderDTO(name: "Troll", radioID: radioID, dateBlocked: earlier) - try await store.saveBlockedChannelSender(first) - - let second = BlockedChannelSenderDTO(name: "Troll", radioID: radioID, dateBlocked: later) - try await store.saveBlockedChannelSender(second) - - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(fetched.count == 1) - #expect(fetched.first?.dateBlocked == later) - } - - // MARK: - Delete - - @Test("Delete removes the blocked sender entry") - func deleteRemovesEntry() async throws { - let store = try await createTestStore() - let dto = BlockedChannelSenderDTO(name: "BadGuy", radioID: radioID) - try await store.saveBlockedChannelSender(dto) - - try await store.deleteBlockedChannelSender(radioID: radioID, name: "BadGuy") - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - - #expect(fetched.isEmpty) - } - - // MARK: - Device Scoping - - @Test("Fetch returns only senders blocked for the specified device") - func fetchScopesToDeviceID() async throws { - let store = try await createTestStore() - let otherDeviceID = UUID() - - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "Alice", radioID: radioID) - ) - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "Bob", radioID: otherDeviceID) - ) - - let device1Results = try await store.fetchBlockedChannelSenders(radioID: radioID) - let device2Results = try await store.fetchBlockedChannelSenders(radioID: otherDeviceID) - - #expect(device1Results.count == 1) - #expect(device1Results.first?.name == "Alice") - #expect(device2Results.count == 1) - #expect(device2Results.first?.name == "Bob") - } - - // MARK: - Case Insensitivity - - @Test("Name preserves original casing for display") - func namePreservesOriginalCasing() async throws { - let store = try await createTestStore() - let dto = BlockedChannelSenderDTO(name: "Alice", radioID: radioID) - try await store.saveBlockedChannelSender(dto) - - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(fetched.first?.name == "Alice") - } - - @Test("Saving same name with different case creates separate entries") - func caseSensitiveSave() async throws { - let store = try await createTestStore() - - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "Alice", radioID: radioID, dateBlocked: .distantPast) - ) - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "ALICE", radioID: radioID, dateBlocked: .now) - ) - - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(fetched.count == 2) - } - - @Test("Delete requires exact case match") - func caseSensitiveDelete() async throws { - let store = try await createTestStore() - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "Alice", radioID: radioID) - ) - - try await store.deleteBlockedChannelSender(radioID: radioID, name: "ALICE") - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - - #expect(fetched.count == 1) - #expect(fetched.first?.name == "Alice") - } - - // MARK: - Sort Order - - @Test("Fetch returns senders sorted by most recently blocked first") - func fetchSortedByDateBlockedDescending() async throws { - let store = try await createTestStore() - let oldest = Date(timeIntervalSince1970: 1_000_000) - let middle = Date(timeIntervalSince1970: 2_000_000) - let newest = Date(timeIntervalSince1970: 3_000_000) - - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "first", radioID: radioID, dateBlocked: oldest) - ) - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "second", radioID: radioID, dateBlocked: newest) - ) - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "third", radioID: radioID, dateBlocked: middle) - ) - - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(fetched.map(\.name) == ["second", "third", "first"]) - } + @Test + func `Re-saving same name updates dateBlocked instead of creating duplicate`() async throws { + let store = try await createTestStore() + let earlier = Date.distantPast + let later = Date.now + + let first = BlockedChannelSenderDTO(name: "Troll", radioID: radioID, dateBlocked: earlier) + try await store.saveBlockedChannelSender(first) + + let second = BlockedChannelSenderDTO(name: "Troll", radioID: radioID, dateBlocked: later) + try await store.saveBlockedChannelSender(second) + + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(fetched.count == 1) + #expect(fetched.first?.dateBlocked == later) + } + + // MARK: - Delete + + @Test + func `Delete removes the blocked sender entry`() async throws { + let store = try await createTestStore() + let dto = BlockedChannelSenderDTO(name: "BadGuy", radioID: radioID) + try await store.saveBlockedChannelSender(dto) + + try await store.deleteBlockedChannelSender(radioID: radioID, name: "BadGuy") + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + + #expect(fetched.isEmpty) + } + + // MARK: - Device Scoping + + @Test + func `Fetch returns only senders blocked for the specified device`() async throws { + let store = try await createTestStore() + let otherDeviceID = UUID() + + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "Alice", radioID: radioID) + ) + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "Bob", radioID: otherDeviceID) + ) + + let device1Results = try await store.fetchBlockedChannelSenders(radioID: radioID) + let device2Results = try await store.fetchBlockedChannelSenders(radioID: otherDeviceID) + + #expect(device1Results.count == 1) + #expect(device1Results.first?.name == "Alice") + #expect(device2Results.count == 1) + #expect(device2Results.first?.name == "Bob") + } + + // MARK: - Case Insensitivity + + @Test + func `Name preserves original casing for display`() async throws { + let store = try await createTestStore() + let dto = BlockedChannelSenderDTO(name: "Alice", radioID: radioID) + try await store.saveBlockedChannelSender(dto) + + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(fetched.first?.name == "Alice") + } + + @Test + func `Saving same name with different case creates separate entries`() async throws { + let store = try await createTestStore() + + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "Alice", radioID: radioID, dateBlocked: .distantPast) + ) + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "ALICE", radioID: radioID, dateBlocked: .now) + ) + + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(fetched.count == 2) + } + + @Test + func `Delete requires exact case match`() async throws { + let store = try await createTestStore() + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "Alice", radioID: radioID) + ) + + try await store.deleteBlockedChannelSender(radioID: radioID, name: "ALICE") + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + + #expect(fetched.count == 1) + #expect(fetched.first?.name == "Alice") + } + + // MARK: - Sort Order + + @Test + func `Fetch returns senders sorted by most recently blocked first`() async throws { + let store = try await createTestStore() + let oldest = Date(timeIntervalSince1970: 1_000_000) + let middle = Date(timeIntervalSince1970: 2_000_000) + let newest = Date(timeIntervalSince1970: 3_000_000) + + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "first", radioID: radioID, dateBlocked: oldest) + ) + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "second", radioID: radioID, dateBlocked: newest) + ) + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "third", radioID: radioID, dateBlocked: middle) + ) + + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(fetched.map(\.name) == ["second", "third", "first"]) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BlockedSenderMessageDeletionTests.swift b/MC1Services/Tests/MC1ServicesTests/BlockedSenderMessageDeletionTests.swift index 03f75c74..64c67615 100644 --- a/MC1Services/Tests/MC1ServicesTests/BlockedSenderMessageDeletionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BlockedSenderMessageDeletionTests.swift @@ -1,231 +1,230 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("Blocked Sender Message Deletion Tests") struct BlockedSenderMessageDeletionTests { - - // MARK: - Test Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private let radioID = UUID() - - private func makeChannelMessage( - radioID: UUID, - senderName: String, - channelIndex: UInt8 = 0, - text: String = "test" - ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: nil, - channelIndex: channelIndex, - text: text, - timestamp: UInt32(Date().timeIntervalSince1970), - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - private func makeDirectMessage( - radioID: UUID, - senderName: String, - contactID: UUID = UUID() - ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: "dm", - timestamp: UInt32(Date().timeIntervalSince1970), - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - // MARK: - Tests - - @Test("Deletes all channel messages from a named sender") - func deletesAllChannelMessagesFromSender() async throws { - let store = try await createTestStore() - - let spam1 = makeChannelMessage(radioID: radioID, senderName: "Spammer", channelIndex: 0) - let spam2 = makeChannelMessage(radioID: radioID, senderName: "Spammer", channelIndex: 1) - let legit = makeChannelMessage(radioID: radioID, senderName: "Legit", channelIndex: 0) - - try await store.saveMessage(spam1) - try await store.saveMessage(spam2) - try await store.saveMessage(legit) - - try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) - - let remainingSpam1 = try await store.fetchMessage(id: spam1.id) - let remainingSpam2 = try await store.fetchMessage(id: spam2.id) - let remainingLegit = try await store.fetchMessage(id: legit.id) - - #expect(remainingSpam1 == nil) - #expect(remainingSpam2 == nil) - #expect(remainingLegit != nil) - } - - @Test("Does not delete DMs from the same sender") - func preservesDirectMessages() async throws { - let store = try await createTestStore() - - let channelMsg = makeChannelMessage(radioID: radioID, senderName: "Spammer") - let dmMsg = makeDirectMessage(radioID: radioID, senderName: "Spammer") - - try await store.saveMessage(channelMsg) - try await store.saveMessage(dmMsg) - - try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) - - let remainingChannel = try await store.fetchMessage(id: channelMsg.id) - let remainingDM = try await store.fetchMessage(id: dmMsg.id) - - #expect(remainingChannel == nil) - #expect(remainingDM != nil) - } - - @Test("Scopes deletion to the specified device") - func scopesDeletionToDevice() async throws { - let store = try await createTestStore() - - let otherDevice = UUID() - let msg1 = makeChannelMessage(radioID: radioID, senderName: "Spammer") - let msg2 = makeChannelMessage(radioID: otherDevice, senderName: "Spammer") - - try await store.saveMessage(msg1) - try await store.saveMessage(msg2) - - try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) - - let remaining1 = try await store.fetchMessage(id: msg1.id) - let remaining2 = try await store.fetchMessage(id: msg2.id) - - #expect(remaining1 == nil) - #expect(remaining2 != nil) - } - - @Test("No-op when sender has no messages") - func noOpWhenNoMessages() async throws { - let store = try await createTestStore() - - let legit = makeChannelMessage(radioID: radioID, senderName: "Legit") - try await store.saveMessage(legit) - - try await store.deleteChannelMessages(fromSender: "Ghost", radioID: radioID) - - let remaining = try await store.fetchMessage(id: legit.id) - #expect(remaining != nil) - } - - @Test("Deletes reactions associated with deleted channel messages") - func deletesReactionsForDeletedMessages() async throws { - let store = try await createTestStore() - - let spamMsg = makeChannelMessage(radioID: radioID, senderName: "Spammer") - let legitMsg = makeChannelMessage(radioID: radioID, senderName: "Legit") - try await store.saveMessage(spamMsg) - try await store.saveMessage(legitMsg) - - // Add reactions to both messages (from other users) - let reactionOnSpam = ReactionDTO( - messageID: spamMsg.id, - emoji: "👍", - senderName: "Reactor", - messageHash: "AABB1122", - rawText: ":thumbsup:", - channelIndex: 0, - radioID: radioID - ) - let reactionOnLegit = ReactionDTO( - messageID: legitMsg.id, - emoji: "❤️", - senderName: "Reactor", - messageHash: "CCDD3344", - rawText: ":heart:", - channelIndex: 0, - radioID: radioID - ) - try await store.saveReaction(reactionOnSpam) - try await store.saveReaction(reactionOnLegit) - - try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) - - let reactionsOnSpam = try await store.fetchReactions(for: spamMsg.id) - let reactionsOnLegit = try await store.fetchReactions(for: legitMsg.id) - - #expect(reactionsOnSpam.isEmpty) - #expect(reactionsOnLegit.count == 1) - } - - @Test("Preserves messages with nil senderNodeName") - func preservesNilSenderNodeName() async throws { - let store = try await createTestStore() - - // Message with nil senderNodeName (e.g., system message) - let nilSenderMsg = MessageDTO( - id: UUID(), - radioID: radioID, - contactID: nil, - channelIndex: 0, - text: "system message", - timestamp: UInt32(Date().timeIntervalSince1970), - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - try await store.saveMessage(nilSenderMsg) - - try await store.deleteChannelMessages(fromSender: "AnyName", radioID: radioID) - - let remaining = try await store.fetchMessage(id: nilSenderMsg.id) - #expect(remaining != nil) - } + // MARK: - Test Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private let radioID = UUID() + + private func makeChannelMessage( + radioID: UUID, + senderName: String, + channelIndex: UInt8 = 0, + text: String = "test" + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: channelIndex, + text: text, + timestamp: UInt32(Date().timeIntervalSince1970), + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + private func makeDirectMessage( + radioID: UUID, + senderName: String, + contactID: UUID = UUID() + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "dm", + timestamp: UInt32(Date().timeIntervalSince1970), + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + // MARK: - Tests + + @Test + func `Deletes all channel messages from a named sender`() async throws { + let store = try await createTestStore() + + let spam1 = makeChannelMessage(radioID: radioID, senderName: "Spammer", channelIndex: 0) + let spam2 = makeChannelMessage(radioID: radioID, senderName: "Spammer", channelIndex: 1) + let legit = makeChannelMessage(radioID: radioID, senderName: "Legit", channelIndex: 0) + + try await store.saveMessage(spam1) + try await store.saveMessage(spam2) + try await store.saveMessage(legit) + + try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) + + let remainingSpam1 = try await store.fetchMessage(id: spam1.id) + let remainingSpam2 = try await store.fetchMessage(id: spam2.id) + let remainingLegit = try await store.fetchMessage(id: legit.id) + + #expect(remainingSpam1 == nil) + #expect(remainingSpam2 == nil) + #expect(remainingLegit != nil) + } + + @Test + func `Does not delete DMs from the same sender`() async throws { + let store = try await createTestStore() + + let channelMsg = makeChannelMessage(radioID: radioID, senderName: "Spammer") + let dmMsg = makeDirectMessage(radioID: radioID, senderName: "Spammer") + + try await store.saveMessage(channelMsg) + try await store.saveMessage(dmMsg) + + try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) + + let remainingChannel = try await store.fetchMessage(id: channelMsg.id) + let remainingDM = try await store.fetchMessage(id: dmMsg.id) + + #expect(remainingChannel == nil) + #expect(remainingDM != nil) + } + + @Test + func `Scopes deletion to the specified device`() async throws { + let store = try await createTestStore() + + let otherDevice = UUID() + let msg1 = makeChannelMessage(radioID: radioID, senderName: "Spammer") + let msg2 = makeChannelMessage(radioID: otherDevice, senderName: "Spammer") + + try await store.saveMessage(msg1) + try await store.saveMessage(msg2) + + try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) + + let remaining1 = try await store.fetchMessage(id: msg1.id) + let remaining2 = try await store.fetchMessage(id: msg2.id) + + #expect(remaining1 == nil) + #expect(remaining2 != nil) + } + + @Test + func `No-op when sender has no messages`() async throws { + let store = try await createTestStore() + + let legit = makeChannelMessage(radioID: radioID, senderName: "Legit") + try await store.saveMessage(legit) + + try await store.deleteChannelMessages(fromSender: "Ghost", radioID: radioID) + + let remaining = try await store.fetchMessage(id: legit.id) + #expect(remaining != nil) + } + + @Test + func `Deletes reactions associated with deleted channel messages`() async throws { + let store = try await createTestStore() + + let spamMsg = makeChannelMessage(radioID: radioID, senderName: "Spammer") + let legitMsg = makeChannelMessage(radioID: radioID, senderName: "Legit") + try await store.saveMessage(spamMsg) + try await store.saveMessage(legitMsg) + + // Add reactions to both messages (from other users) + let reactionOnSpam = ReactionDTO( + messageID: spamMsg.id, + emoji: "👍", + senderName: "Reactor", + messageHash: "AABB1122", + rawText: ":thumbsup:", + channelIndex: 0, + radioID: radioID + ) + let reactionOnLegit = ReactionDTO( + messageID: legitMsg.id, + emoji: "❤️", + senderName: "Reactor", + messageHash: "CCDD3344", + rawText: ":heart:", + channelIndex: 0, + radioID: radioID + ) + try await store.saveReaction(reactionOnSpam) + try await store.saveReaction(reactionOnLegit) + + try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) + + let reactionsOnSpam = try await store.fetchReactions(for: spamMsg.id) + let reactionsOnLegit = try await store.fetchReactions(for: legitMsg.id) + + #expect(reactionsOnSpam.isEmpty) + #expect(reactionsOnLegit.count == 1) + } + + @Test + func `Preserves messages with nil senderNodeName`() async throws { + let store = try await createTestStore() + + // Message with nil senderNodeName (e.g., system message) + let nilSenderMsg = MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: 0, + text: "system message", + timestamp: UInt32(Date().timeIntervalSince1970), + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + try await store.saveMessage(nilSenderMsg) + + try await store.deleteChannelMessages(fromSender: "AnyName", radioID: radioID) + + let remaining = try await store.fetchMessage(id: nilSenderMsg.id) + #expect(remaining != nil) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BluetoothScanPairingServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/BluetoothScanPairingServiceTests.swift index f4484fb3..a80ed28d 100644 --- a/MC1Services/Tests/MC1ServicesTests/BluetoothScanPairingServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BluetoothScanPairingServiceTests.swift @@ -1,116 +1,115 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("BluetoothScanPairingService") @MainActor struct BluetoothScanPairingServiceTests { + @Test + func `discoverDevice presents the picker and resolves with the selected id`() async throws { + let service = BluetoothScanPairingService() + let expected = UUID() - @Test("discoverDevice presents the picker and resolves with the selected id") - func selectResolvesDiscovery() async throws { - let service = BluetoothScanPairingService() - let expected = UUID() + let task = Task { try await service.discoverDevice() } + try await waitUntil("picker should request presentation") { service.isPresenting } - let task = Task { try await service.discoverDevice() } - try await waitUntil("picker should request presentation") { service.isPresenting } + service.select(expected) - service.select(expected) - - let result = try await task.value - #expect(result == expected) - #expect(service.isPresenting == false) - } + let result = try await task.value + #expect(result == expected) + #expect(service.isPresenting == false) + } - @Test("cancel surfaces DevicePairingError.cancelled and lowers presentation") - func cancelThrowsCancelled() async throws { - let service = BluetoothScanPairingService() + @Test + func `cancel surfaces DevicePairingError.cancelled and lowers presentation`() async throws { + let service = BluetoothScanPairingService() - let task = Task { try await service.discoverDevice() } - try await waitUntil("picker should request presentation") { service.isPresenting } + let task = Task { try await service.discoverDevice() } + try await waitUntil("picker should request presentation") { service.isPresenting } - service.cancel() + service.cancel() - await #expect(throws: DevicePairingError.self) { - _ = try await task.value - } - #expect(service.isPresenting == false) + await #expect(throws: DevicePairingError.self) { + _ = try await task.value } + #expect(service.isPresenting == false) + } - @Test("a new discovery resolves a stranded prior discovery") - func newDiscoveryResolvesPrior() async throws { - let service = BluetoothScanPairingService() + @Test + func `a new discovery resolves a stranded prior discovery`() async throws { + let service = BluetoothScanPairingService() - let first = Task { try await service.discoverDevice() } - try await waitUntil("first discovery should present") { service.isPresenting } + let first = Task { try await service.discoverDevice() } + try await waitUntil("first discovery should present") { service.isPresenting } - // Starting a second discovery must resolve the first's stranded continuation before - // installing its own. Awaiting `first` is the real synchronization point: it can only - // throw once the second call has run `resolveDiscovery` on the prior continuation. - let second = Task { try await service.discoverDevice() } + // Starting a second discovery must resolve the first's stranded continuation before + // installing its own. Awaiting `first` is the real synchronization point: it can only + // throw once the second call has run `resolveDiscovery` on the prior continuation. + let second = Task { try await service.discoverDevice() } - await #expect(throws: DevicePairingError.self) { - _ = try await first.value - } - - // The second continuation is now installed; selecting resolves it with the chosen id. - let expected = UUID() - service.select(expected) - #expect(try await second.value == expected) + await #expect(throws: DevicePairingError.self) { + _ = try await first.value } - @Test("cancelling the discovery task resolves it and lowers presentation") - func taskCancellationResolvesDiscovery() async throws { - let service = BluetoothScanPairingService() + // The second continuation is now installed; selecting resolves it with the chosen id. + let expected = UUID() + service.select(expected) + #expect(try await second.value == expected) + } - let task = Task { try await service.discoverDevice() } - try await waitUntil("discovery should present") { service.isPresenting } + @Test + func `cancelling the discovery task resolves it and lowers presentation`() async throws { + let service = BluetoothScanPairingService() - task.cancel() + let task = Task { try await service.discoverDevice() } + try await waitUntil("discovery should present") { service.isPresenting } - // Cancellation must surface via the same shared path as an explicit cancel(): - // onCancel -> cancel() -> resolveDiscovery(.failure(cancelled)), not a bare - // CancellationError, so call sites keep one cancellation path on both platforms. - await #expect(throws: DevicePairingError.self) { - _ = try await task.value - } - try await waitUntil("presentation should clear after cancellation") { !service.isPresenting } - } + task.cancel() - @Test("a selection racing a cancelled discovery task surfaces cancelled, not the selection") - func selectionRacingCancellationSurfacesCancelled() async throws { - let service = BluetoothScanPairingService() + // Cancellation must surface via the same shared path as an explicit cancel(): + // onCancel -> cancel() -> resolveDiscovery(.failure(cancelled)), not a bare + // CancellationError, so call sites keep one cancellation path on both platforms. + await #expect(throws: DevicePairingError.self) { + _ = try await task.value + } + try await waitUntil("presentation should clear after cancellation") { !service.isPresenting } + } - let task = Task { try await service.discoverDevice() } - try await waitUntil("discovery should present") { service.isPresenting } + @Test + func `a selection racing a cancelled discovery task surfaces cancelled, not the selection`() async throws { + let service = BluetoothScanPairingService() - // Cancelling the task runs `onCancel` synchronously, which sets the cancellation flag - // before its hop to `cancel()` is scheduled. A `select(_:)` landing on the MainActor in - // that window (here, before the hop runs) must not resolve the continuation with the stale - // selection — discovery must still surface `.cancelled`. - task.cancel() - service.select(UUID()) + let task = Task { try await service.discoverDevice() } + try await waitUntil("discovery should present") { service.isPresenting } - await #expect(throws: DevicePairingError.self) { - _ = try await task.value - } - try await waitUntil("presentation should clear after cancellation") { !service.isPresenting } - } + // Cancelling the task runs `onCancel` synchronously, which sets the cancellation flag + // before its hop to `cancel()` is scheduled. A `select(_:)` landing on the MainActor in + // that window (here, before the hop runs) must not resolve the continuation with the stale + // selection — discovery must still surface `.cancelled`. + task.cancel() + service.select(UUID()) - @Test("system-registry operations are inert on the macOS path") - func registryOperationsAreNoOps() async throws { - let service = BluetoothScanPairingService() - - #expect(service.isSessionActive == false) - #expect(service.hasSystemPairingRegistry == false) - #expect(service.registeredDeviceCount == 0) - #expect(service.supportsSystemRename == false) - #expect(service.isDeviceConnectable(UUID()) == true) - #expect(service.registeredDeviceInfos().isEmpty) - - // None of these should throw or have observable effect. - try await service.activate() - try await service.removeDevice(UUID()) - try await service.renameDevice(UUID()) - await service.clearStaleRegistrations() + await #expect(throws: DevicePairingError.self) { + _ = try await task.value } + try await waitUntil("presentation should clear after cancellation") { !service.isPresenting } + } + + @Test + func `system-registry operations are inert on the macOS path`() async throws { + let service = BluetoothScanPairingService() + + #expect(service.isSessionActive == false) + #expect(service.hasSystemPairingRegistry == false) + #expect(service.registeredDeviceCount == 0) + #expect(service.supportsSystemRename == false) + #expect(service.isDeviceConnectable(UUID()) == true) + #expect(service.registeredDeviceInfos().isEmpty) + + // None of these should throw or have observable effect. + try await service.activate() + try await service.removeDevice(UUID()) + try await service.renameDevice(UUID()) + await service.clearStaleRegistrations() + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift b/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift new file mode 100644 index 00000000..54e77b59 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift @@ -0,0 +1,148 @@ +import Foundation +@testable import MC1Services +import MeshCore +import MeshCoreTestSupport +import SwiftData +import Testing + +/// A radio that loses its bond mid-session is recovered through the guided +/// "remove and retry" flow, which calls `removeFailedPairing`. That path must +/// preserve the radio's data: demoting the `Device` row to a ghost keeps the +/// publicKey ↔ radioID bridge alive, so re-pairing the same physical radio +/// resolves the original radioID and reattaches its contacts, messages, and +/// channels. A hard delete would drop the bridge and orphan every child row. +@Suite("Bond-loss pairing recovery preserves radio data") +@MainActor +struct BondLossPairingRecoveryTests { + private static let radioPublicKey = Data(repeating: 0x7B, count: 32) + + private static let testCapabilities = DeviceCapabilities( + firmwareVersion: 9, + maxContacts: 100, + maxChannels: 8, + blePin: 0, + firmwareBuild: "01 Jan 2025", + model: "T-Deck", + version: "v1.13.0" + ) + + private static func makeSelfInfo(publicKey: Data = radioPublicKey) -> SelfInfo { + SelfInfo( + advertisementType: 0, + txPower: 20, + maxTxPower: 20, + publicKey: publicKey, + latitude: 0, + longitude: 0, + multiAcks: 2, + advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 2, + manualAddContacts: false, + radioFrequency: 915.0, + radioBandwidth: 250.0, + radioSpreadingFactor: 10, + radioCodingRate: 5, + name: "TestNode" + ) + } + + /// A never-connected session so the up-front `getAutoAddConfig` roundtrip + /// fails fast; the connect ceremony swallows it and proceeds. + private func makeOfflineSession() -> MeshCoreSession { + MeshCoreSession( + transport: SimulatorMockTransport(), + configuration: SessionConfiguration(defaultTimeout: 0.5, clientIdentifier: "BondLossRecoveryTest") + ) + } + + @Test + func `removeFailedPairing keeps the radio's data reachable when the same radio re-pairs`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + // First connect: fresh pairing mints a radioID for this publicKey. + let firstBLEID = UUID() + let firstConnect = try await manager.buildServicesAndSaveDevice( + deviceID: firstBLEID, + session: makeOfflineSession(), + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities + ) + let originalRadioID = firstConnect.radioID + let store = firstConnect.services.dataStore + + // Seed the per-radio children a synced radio accumulates. + let contactID = UUID() + let contact = ContactDTO( + id: contactID, + radioID: originalRadioID, + publicKey: Data((0.. PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - /// Inserts a Channel model with raw fields — simulates a row persisted by an older - /// app version (before `floodScopeModeRawValue` existed, so every row was "inherit" - /// regardless of the per-channel region override). - private func insertLegacyChannel( - into store: PersistenceStore, - radioID: UUID, - index: UInt8, - regionScope: String? - ) async throws { - let inheritRaw = ChannelFloodScopeStorage.Mode.inherit.rawValue - let dto = ChannelDTO( - id: UUID(), - radioID: radioID, - index: index, - name: "Chan\(index)", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false, - floodScopeModeRawValue: inheritRaw, - regionScope: regionScope - ) - try await store.saveChannel(dto) - } - - // MARK: - Migration behavior - - @Test("Non-nil regionScope with inherit mode is promoted to .specific") - func legacyRegionRowBecomesSpecific() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: "Germany") - - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.first?.floodScope == .region("Germany")) - } - - @Test("Nil regionScope with inherit mode stays at .inherit (corrective)") - func legacyNilRegionStaysInherit() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: nil) - - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.first?.floodScope == .inherit) - } - - @Test("Mixed rows migrate correctly in one pass") - func mixedRowsMigrate() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: nil) - try await insertLegacyChannel(into: store, radioID: radioID, index: 2, regionScope: "Germany") - try await insertLegacyChannel(into: store, radioID: radioID, index: 3, regionScope: "France") - - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(channels.map(\.floodScope) == [.inherit, .region("Germany"), .region("France")]) - } - - @Test("Migration is idempotent — second run is a no-op") - func migrationIsIdempotent() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: "Germany") - - try await store.performChannelFloodScopeMigration() - // Second run with the flag set must not touch anything. - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.first?.floodScope == .region("Germany")) - } - - @Test("Post-migration writes to .allRegions are not clobbered by a re-run") - func postMigrationAllRegionsPreserved() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - // A channel the user explicitly set to .allRegions after upgrading. - let dto = ChannelDTO.testChannel(radioID: radioID, index: 1, floodScope: .allRegions) - try await store.saveChannel(dto) - - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.first?.floodScope == .allRegions) - } + // MARK: - Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + /// Inserts a Channel model with raw fields — simulates a row persisted by an older + /// app version (before `floodScopeModeRawValue` existed, so every row was "inherit" + /// regardless of the per-channel region override). + private func insertLegacyChannel( + into store: PersistenceStore, + radioID: UUID, + index: UInt8, + regionScope: String? + ) async throws { + let inheritRaw = ChannelFloodScopeStorage.Mode.inherit.rawValue + let dto = ChannelDTO( + id: UUID(), + radioID: radioID, + index: index, + name: "Chan\(index)", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false, + floodScopeModeRawValue: inheritRaw, + regionScope: regionScope + ) + try await store.saveChannel(dto) + } + + // MARK: - Migration behavior + + @Test + func `Non-nil regionScope with inherit mode is promoted to .specific`() async throws { + let suiteName = "test.\(UUID().uuidString)" + // UserDefaults is thread-safe but not marked Sendable, so reusing this value + // across the performChannelFloodScopeMigration actor boundary needs the isolation opt-out. + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + let radioID = UUID() + try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: "Germany") + + try await store.performChannelFloodScopeMigration(defaults: defaults) + + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.first?.floodScope == .region("Germany")) + } + + @Test + func `Nil regionScope with inherit mode stays at .inherit (corrective)`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + let radioID = UUID() + try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: nil) + + try await store.performChannelFloodScopeMigration(defaults: defaults) + + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.first?.floodScope == .inherit) + } + + @Test + func `Mixed rows migrate correctly in one pass`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + let radioID = UUID() + try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: nil) + try await insertLegacyChannel(into: store, radioID: radioID, index: 2, regionScope: "Germany") + try await insertLegacyChannel(into: store, radioID: radioID, index: 3, regionScope: "France") + + try await store.performChannelFloodScopeMigration(defaults: defaults) + + let channels = try await store.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(channels.map(\.floodScope) == [.inherit, .region("Germany"), .region("France")]) + } + + @Test + func `Migration is idempotent — second run is a no-op`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + let radioID = UUID() + try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: "Germany") + + try await store.performChannelFloodScopeMigration(defaults: defaults) + // Second run with the flag set must not touch anything. + try await store.performChannelFloodScopeMigration(defaults: defaults) + + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.first?.floodScope == .region("Germany")) + } + + @Test + func `Post-migration writes to .allRegions are not clobbered by a re-run`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + let radioID = UUID() + // A channel the user explicitly set to .allRegions after upgrading. + let dto = ChannelDTO.testChannel(radioID: radioID, index: 1, floodScope: .allRegions) + try await store.saveChannel(dto) + + try await store.performChannelFloodScopeMigration(defaults: defaults) + + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.first?.floodScope == .allRegions) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeResolverTests.swift b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeResolverTests.swift index 6f8044e5..e890d703 100644 --- a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeResolverTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeResolverTests.swift @@ -1,86 +1,85 @@ import Foundation +@testable import MC1Services import MeshCore import Testing -@testable import MC1Services @Suite("ChannelFloodScopeResolver") struct ChannelFloodScopeResolverTests { + @Test + func `.inherit with device default set resolves to .scope(.region(default))`() { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: .inherit, + deviceDefaultFloodScopeName: "Germany", + supportsUnscopedFloodSend: true + ) + #expect(resolved == .scope(.region("Germany"))) + } - @Test(".inherit with device default set resolves to .scope(.region(default))") - func inheritWithDefaultResolvesRegion() { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: .inherit, - deviceDefaultFloodScopeName: "Germany", - supportsUnscopedFloodSend: true - ) - #expect(resolved == .scope(.region("Germany"))) - } - - @Test(".inherit with no device default resolves to .scope(.disabled)") - func inheritWithoutDefaultResolvesDisabled() { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: .inherit, - deviceDefaultFloodScopeName: nil, - supportsUnscopedFloodSend: true - ) - #expect(resolved == .scope(.disabled)) - } + @Test + func `.inherit with no device default resolves to .scope(.disabled)`() { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: .inherit, + deviceDefaultFloodScopeName: nil, + supportsUnscopedFloodSend: true + ) + #expect(resolved == .scope(.disabled)) + } - @Test(".inherit with empty-string default treats it as no default") - func inheritWithEmptyDefaultResolvesDisabled() { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: .inherit, - deviceDefaultFloodScopeName: "", - supportsUnscopedFloodSend: true - ) - #expect(resolved == .scope(.disabled)) - } + @Test + func `.inherit with empty-string default treats it as no default`() { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: .inherit, + deviceDefaultFloodScopeName: "", + supportsUnscopedFloodSend: true + ) + #expect(resolved == .scope(.disabled)) + } - @Test(".allRegions on firmware v12+ resolves to .unscoped (true override)") - func allRegionsWithCapabilityResolvesUnscoped() { - let withDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .allRegions, - deviceDefaultFloodScopeName: "Germany", - supportsUnscopedFloodSend: true - ) - let withoutDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .allRegions, - deviceDefaultFloodScopeName: nil, - supportsUnscopedFloodSend: true - ) - #expect(withDefault == .unscoped) - #expect(withoutDefault == .unscoped) - } + @Test + func `.allRegions on firmware v12+ resolves to .unscoped (true override)`() { + let withDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .allRegions, + deviceDefaultFloodScopeName: "Germany", + supportsUnscopedFloodSend: true + ) + let withoutDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .allRegions, + deviceDefaultFloodScopeName: nil, + supportsUnscopedFloodSend: true + ) + #expect(withDefault == .unscoped) + #expect(withoutDefault == .unscoped) + } - @Test(".allRegions on older firmware falls back to .scope(.disabled)") - func allRegionsWithoutCapabilityFallsBackToDisabled() { - let withDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .allRegions, - deviceDefaultFloodScopeName: "Germany", - supportsUnscopedFloodSend: false - ) - let withoutDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .allRegions, - deviceDefaultFloodScopeName: nil, - supportsUnscopedFloodSend: false - ) - #expect(withDefault == .scope(.disabled)) - #expect(withoutDefault == .scope(.disabled)) - } + @Test + func `.allRegions on older firmware falls back to .scope(.disabled)`() { + let withDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .allRegions, + deviceDefaultFloodScopeName: "Germany", + supportsUnscopedFloodSend: false + ) + let withoutDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .allRegions, + deviceDefaultFloodScopeName: nil, + supportsUnscopedFloodSend: false + ) + #expect(withDefault == .scope(.disabled)) + #expect(withoutDefault == .scope(.disabled)) + } - @Test(".region(name) resolves to that region regardless of default or capability") - func specificRegionResolvesThatRegion() { - let withDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .region("France"), - deviceDefaultFloodScopeName: "Germany", - supportsUnscopedFloodSend: true - ) - let withoutCapability = ChannelFloodScopeResolver.resolve( - channelFloodScope: .region("France"), - deviceDefaultFloodScopeName: nil, - supportsUnscopedFloodSend: false - ) - #expect(withDefault == .scope(.region("France"))) - #expect(withoutCapability == .scope(.region("France"))) - } + @Test + func `.region(name) resolves to that region regardless of default or capability`() { + let withDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .region("France"), + deviceDefaultFloodScopeName: "Germany", + supportsUnscopedFloodSend: true + ) + let withoutCapability = ChannelFloodScopeResolver.resolve( + channelFloodScope: .region("France"), + deviceDefaultFloodScopeName: nil, + supportsUnscopedFloodSend: false + ) + #expect(withDefault == .scope(.region("France"))) + #expect(withoutCapability == .scope(.region("France"))) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeTests.swift b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeTests.swift index 21a63b1b..1b6b5321 100644 --- a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeTests.swift @@ -1,172 +1,214 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ChannelFloodScope storage") struct ChannelFloodScopeTests { - - // MARK: - Enum shape - - @Test("Three distinct cases compare as equal only to themselves") - func enumCasesAreDistinct() { - #expect(ChannelFloodScope.inherit == .inherit) - #expect(ChannelFloodScope.allRegions == .allRegions) - #expect(ChannelFloodScope.region("Germany") == .region("Germany")) - #expect(ChannelFloodScope.inherit != .allRegions) - #expect(ChannelFloodScope.inherit != .region("")) - #expect(ChannelFloodScope.region("Germany") != .region("France")) - } - - // MARK: - ChannelDTO round-trip - - @Test("DTO default floodScope is .inherit") - func dtoDefaultIsInherit() { - let dto = makeDTO() - #expect(dto.floodScope == .inherit) - } - - @Test("DTO floodScope roundtrips for .inherit") - func dtoInheritRoundtrip() { - let dto = makeDTO(floodScope: .inherit) - #expect(dto.floodScope == .inherit) - #expect(dto.regionScope == nil) - } - - @Test("DTO floodScope roundtrips for .allRegions") - func dtoAllRegionsRoundtrip() { - let dto = makeDTO(floodScope: .allRegions) - #expect(dto.floodScope == .allRegions) - #expect(dto.regionScope == nil) - } - - @Test("DTO floodScope roundtrips for .region(name)") - func dtoSpecificRegionRoundtrip() { - let dto = makeDTO(floodScope: .region("Germany")) - #expect(dto.floodScope == .region("Germany")) - #expect(dto.regionScope == "Germany") - } - - @Test("DTO treats .region with empty name as .inherit defensively") - func dtoEmptyRegionNameFallsBackToInherit() { - // A storage layer pathology: floodScopeModeRawValue says "specific" but regionScope is nil. - // Public enum should never expose a malformed .region(nil); fall back to .inherit. - let dto = ChannelDTO.testChannel( - radioID: UUID(), - floodScope: .region("Germany") - ).with(regionScope: nil) - #expect(dto.floodScope == .inherit) - } - - // MARK: - Channel model round-trip (through persistence) - - @Test("Channel model default floodScope is .inherit") - func channelDefaultIsInherit() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makeDTO(radioID: radioID) - try await store.saveChannel(dto) - - let fetched = try await store.fetchChannel(id: dto.id) - #expect(fetched?.floodScope == .inherit) - } - - @Test("Channel model persists .allRegions distinctly from .inherit") - func channelPersistsAllRegionsDistinct() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makeDTO(radioID: radioID, floodScope: .allRegions) - try await store.saveChannel(dto) - - let fetched = try await store.fetchChannel(id: dto.id) - #expect(fetched?.floodScope == .allRegions) - } - - @Test("Channel model persists .region(name)") - func channelPersistsSpecificRegion() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makeDTO(radioID: radioID, floodScope: .region("Germany")) - try await store.saveChannel(dto) - - let fetched = try await store.fetchChannel(id: dto.id) - #expect(fetched?.floodScope == .region("Germany")) - } - - // MARK: - Codable envelope migration - - @Test("Codable round-trips all three cases for current envelope format") - func codableRoundTripCurrent() throws { - let radioID = UUID() - for scope in [ChannelFloodScope.inherit, .allRegions, .region("Germany")] { - let dto = makeDTO(radioID: radioID, floodScope: scope) - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(ChannelDTO.self, from: encoded) - #expect(decoded.floodScope == scope, "roundtrip lost \(scope)") - } - } - - @Test("Legacy envelope (missing mode key, nil regionScope) decodes as .inherit") - func legacyEnvelopeNilRegionDecodesInherit() throws { - let json = legacyJSON(regionScope: nil) - let decoded = try JSONDecoder().decode(ChannelDTO.self, from: Data(json.utf8)) - #expect(decoded.floodScope == .inherit) - } - - @Test("Legacy envelope (missing mode key, named regionScope) decodes as .region") - func legacyEnvelopeNamedRegionDecodesSpecific() throws { - let json = legacyJSON(regionScope: "Germany") - let decoded = try JSONDecoder().decode(ChannelDTO.self, from: Data(json.utf8)) - #expect(decoded.floodScope == .region("Germany")) - } - - @Test("setChannelFloodScope updates existing channel atomically") - func setChannelFloodScopeAtomic() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makeDTO(radioID: radioID, floodScope: .region("Germany")) - try await store.saveChannel(dto) - - try await store.setChannelFloodScope(dto.id, floodScope: .allRegions) - let after = try await store.fetchChannel(id: dto.id) - #expect(after?.floodScope == .allRegions) - #expect(after?.regionScope == nil, "switching to .allRegions must clear the region name") - - try await store.setChannelFloodScope(dto.id, floodScope: .inherit) - let afterInherit = try await store.fetchChannel(id: dto.id) - #expect(afterInherit?.floodScope == .inherit) - #expect(afterInherit?.regionScope == nil) - } - - // MARK: - Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func makeDTO( - radioID: UUID = UUID(), - floodScope: ChannelFloodScope = .inherit - ) -> ChannelDTO { - ChannelDTO.testChannel(radioID: radioID, floodScope: floodScope) - } - - private func legacyJSON(regionScope: String?) -> String { - let region = regionScope.map { "\"\($0)\"" } ?? "null" - return """ - { - "id": "\(UUID().uuidString)", - "radioID": "\(UUID().uuidString)", - "index": 1, - "name": "General", - "secret": "\(Data(repeating: 0, count: 16).base64EncodedString())", - "isEnabled": true, - "unreadCount": 0, - "unreadMentionCount": 0, - "notificationLevel": 2, - "isFavorite": false, - "regionScope": \(region) - } - """ - } + // MARK: - Enum shape + + @Test + func `Three distinct cases compare as equal only to themselves`() { + #expect(ChannelFloodScope.inherit == .inherit) + #expect(ChannelFloodScope.allRegions == .allRegions) + #expect(ChannelFloodScope.region("Germany") == .region("Germany")) + #expect(ChannelFloodScope.inherit != .allRegions) + #expect(ChannelFloodScope.inherit != .region("")) + #expect(ChannelFloodScope.region("Germany") != .region("France")) + } + + // MARK: - ChannelDTO round-trip + + @Test + func `DTO default floodScope is .inherit`() { + let dto = makeDTO() + #expect(dto.floodScope == .inherit) + } + + @Test + func `DTO floodScope roundtrips for .inherit`() { + let dto = makeDTO(floodScope: .inherit) + #expect(dto.floodScope == .inherit) + #expect(dto.regionScope == nil) + } + + @Test + func `DTO floodScope roundtrips for .allRegions`() { + let dto = makeDTO(floodScope: .allRegions) + #expect(dto.floodScope == .allRegions) + #expect(dto.regionScope == nil) + } + + @Test + func `DTO floodScope roundtrips for .region(name)`() { + let dto = makeDTO(floodScope: .region("Germany")) + #expect(dto.floodScope == .region("Germany")) + #expect(dto.regionScope == "Germany") + } + + @Test + func `DTO treats .region with empty name as .inherit defensively`() { + // A storage layer pathology: floodScopeModeRawValue says "specific" but regionScope is nil. + // Public enum should never expose a malformed .region(nil); fall back to .inherit. + let dto = ChannelDTO.testChannel( + radioID: UUID(), + floodScope: .region("Germany") + ).with(regionScope: nil) + #expect(dto.floodScope == .inherit) + } + + // MARK: - Channel model round-trip (through persistence) + + @Test + func `Channel model default floodScope is .inherit`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makeDTO(radioID: radioID) + try await store.saveChannel(dto) + + let fetched = try await store.fetchChannel(id: dto.id) + #expect(fetched?.floodScope == .inherit) + } + + @Test + func `Channel model persists .allRegions distinctly from .inherit`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makeDTO(radioID: radioID, floodScope: .allRegions) + try await store.saveChannel(dto) + + let fetched = try await store.fetchChannel(id: dto.id) + #expect(fetched?.floodScope == .allRegions) + } + + @Test + func `Channel model persists .region(name)`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makeDTO(radioID: radioID, floodScope: .region("Germany")) + try await store.saveChannel(dto) + + let fetched = try await store.fetchChannel(id: dto.id) + #expect(fetched?.floodScope == .region("Germany")) + } + + // MARK: - Codable envelope migration + + @Test + func `Codable round-trips all three cases for current envelope format`() throws { + let radioID = UUID() + for scope in [ChannelFloodScope.inherit, .allRegions, .region("Germany")] { + let dto = makeDTO(radioID: radioID, floodScope: scope) + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(ChannelDTO.self, from: encoded) + #expect(decoded.floodScope == scope, "roundtrip lost \(scope)") + } + } + + @Test + func `Legacy envelope (missing mode key, nil regionScope) decodes as .inherit`() throws { + let json = legacyJSON(regionScope: nil) + let decoded = try JSONDecoder().decode(ChannelDTO.self, from: Data(json.utf8)) + #expect(decoded.floodScope == .inherit) + } + + @Test + func `Legacy envelope (missing mode key, named regionScope) decodes as .region`() throws { + let json = legacyJSON(regionScope: "Germany") + let decoded = try JSONDecoder().decode(ChannelDTO.self, from: Data(json.utf8)) + #expect(decoded.floodScope == .region("Germany")) + } + + @Test + func `Legacy envelope omitting notificationLevel key decodes as .all`() throws { + let json = try legacyJSONOmitting("notificationLevel") + let decoded = try JSONDecoder().decode(ChannelDTO.self, from: json) + #expect(decoded.notificationLevel == .all) + } + + @Test + func `Legacy envelope omitting unreadMentionCount key decodes as 0`() throws { + let json = try legacyJSONOmitting("unreadMentionCount") + let decoded = try JSONDecoder().decode(ChannelDTO.self, from: json) + #expect(decoded.unreadMentionCount == 0) + } + + @Test + func `Legacy envelope omitting isFavorite key decodes as false`() throws { + let json = try legacyJSONOmitting("isFavorite") + let decoded = try JSONDecoder().decode(ChannelDTO.self, from: json) + #expect(decoded.isFavorite == false) + } + + @Test + func `setChannelFloodScope updates existing channel atomically`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makeDTO(radioID: radioID, floodScope: .region("Germany")) + try await store.saveChannel(dto) + + try await store.setChannelFloodScope(dto.id, floodScope: .allRegions) + let after = try await store.fetchChannel(id: dto.id) + #expect(after?.floodScope == .allRegions) + #expect(after?.regionScope == nil, "switching to .allRegions must clear the region name") + + try await store.setChannelFloodScope(dto.id, floodScope: .inherit) + let afterInherit = try await store.fetchChannel(id: dto.id) + #expect(afterInherit?.floodScope == .inherit) + #expect(afterInherit?.regionScope == nil) + } + + // MARK: - Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func makeDTO( + radioID: UUID = UUID(), + floodScope: ChannelFloodScope = .inherit + ) -> ChannelDTO { + ChannelDTO.testChannel(radioID: radioID, floodScope: floodScope) + } + + private func legacyJSON(regionScope: String?) -> String { + let region = regionScope.map { "\"\($0)\"" } ?? "null" + return """ + { + "id": "\(UUID().uuidString)", + "radioID": "\(UUID().uuidString)", + "index": 1, + "name": "General", + "secret": "\(Data(repeating: 0, count: 16).base64EncodedString())", + "isEnabled": true, + "unreadCount": 0, + "unreadMentionCount": 0, + "notificationLevel": 2, + "isFavorite": false, + "regionScope": \(region) + } + """ + } + + /// A backup predating a given field: encode a real DTO, then drop one key so the + /// JSON otherwise matches the current wire format exactly. Proves the decoder's + /// missing-key fallback survives an old envelope instead of throwing. + private func legacyJSONOmitting(_ key: String) throws -> Data { + // Non-fallback source values so a passing decode proves the fallback fired + // rather than echoing the encoded value. + let dto = ChannelDTO.testChannel( + radioID: UUID(), + unreadMentionCount: 5, + notificationLevel: .muted, + isFavorite: true + ) + let encoded = try JSONEncoder().encode(dto) + guard var object = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { + throw DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "encoded ChannelDTO was not a JSON object") + ) + } + object.removeValue(forKey: key) + return try JSONSerialization.data(withJSONObject: object) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift new file mode 100644 index 00000000..1f21dd81 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift @@ -0,0 +1,153 @@ +import Foundation +@testable import MC1Services +import MeshCore +import MeshCoreTestSupport +import SwiftData +import Testing + +/// Reconnecting the same radio over a fresh CoreBluetooth handle must not +/// re-partition its data. `buildServicesAndSaveDevice` resolves the radioID by +/// missing on `fetchDevice(id:)` (the volatile BLE UUID changed) and falling +/// back to `fetchDevice(publicKey:)`, so every per-radio row persisted under the +/// first connection stays reachable. Losing that fallback would mint a fresh +/// radioID and orphan the radio's `PendingSend` rows. +@Suite("Connect-path radioID resolution") +@MainActor +struct ConnectRadioIDResolutionTests { + private static let radioPublicKey = Data(repeating: 0xC3, count: 32) + + private static let testCapabilities = DeviceCapabilities( + firmwareVersion: 9, + maxContacts: 100, + maxChannels: 8, + blePin: 0, + firmwareBuild: "01 Jan 2025", + model: "T-Deck", + version: "v1.13.0" + ) + + private static func makeSelfInfo(publicKey: Data = radioPublicKey) -> SelfInfo { + SelfInfo( + advertisementType: 0, + txPower: 20, + maxTxPower: 20, + publicKey: publicKey, + latitude: 0, + longitude: 0, + multiAcks: 2, + advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 2, + manualAddContacts: false, + radioFrequency: 915.0, + radioBandwidth: 250.0, + radioSpreadingFactor: 10, + radioCodingRate: 5, + name: "TestNode" + ) + } + + /// A never-connected session so the up-front `getAutoAddConfig` roundtrip + /// fails fast with `.notConnected`; the ceremony swallows it and proceeds. + private func makeOfflineSession() -> MeshCoreSession { + MeshCoreSession( + transport: SimulatorMockTransport(), + configuration: SessionConfiguration(defaultTimeout: 0.5, clientIdentifier: "RadioIDResolutionTest") + ) + } + + @Test + func `Reconnect with a changed BLE id but same publicKey resolves the original radioID and keeps its PendingSends reachable`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + // First connect: fresh pairing mints a radioID for this publicKey. + let firstBLEID = UUID() + let firstConnect = try await manager.buildServicesAndSaveDevice( + deviceID: firstBLEID, + session: makeOfflineSession(), + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities + ) + let originalRadioID = firstConnect.radioID + + // The second connect hydrates the chat send queue, whose DM drain + // terminally deletes any envelope missing its Contact row ("contact + // deleted") or its Message row (sendFailed). A complete fixture — the + // same Contact + Message + PendingSend triple a real enqueue leaves on + // disk — makes the offline session's failure transient instead, so the + // drain parks and the row deterministically survives to the assertion + // below. + let contactID = UUID() + let contact = ContactDTO( + id: contactID, + radioID: originalRadioID, + publicKey: Data((0.. Void) rethrows { + let suiteName = "test.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + try body(defaults) + } - /// Runs `body` against a per-test isolated `UserDefaults` suite, - /// removing the persistent domain afterwards so no state leaks. - private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) rethrows { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - try body(defaults) - } - - @Test("persist then read round-trips deviceID, radioID, and deviceName") - func persistRoundTrip() throws { - try withIsolatedDefaults { defaults in - let store = LastConnectionStore(defaults: defaults) - let deviceID = UUID() - let radioID = UUID() + @Test + func `persist then read round-trips deviceID, radioID, and deviceName`() throws { + try withIsolatedDefaults { defaults in + let store = LastConnectionStore(defaults: defaults) + let deviceID = UUID() + let radioID = UUID() - store.persist(deviceID: deviceID, radioID: radioID, deviceName: "Test Radio") + store.persist(deviceID: deviceID, radioID: radioID, deviceName: "Test Radio") - #expect(store.deviceID == deviceID) - #expect(store.radioID == radioID) - #expect(defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) == "Test Radio") - } + #expect(store.deviceID == deviceID) + #expect(store.radioID == radioID) + #expect(defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) == "Test Radio") } + } - @Test("clear removes all three persisted values") - func clearRemovesAll() throws { - try withIsolatedDefaults { defaults in - let store = LastConnectionStore(defaults: defaults) - store.persist(deviceID: UUID(), radioID: UUID(), deviceName: "Test Radio") + @Test + func `clear removes all three persisted values`() throws { + try withIsolatedDefaults { defaults in + let store = LastConnectionStore(defaults: defaults) + store.persist(deviceID: UUID(), radioID: UUID(), deviceName: "Test Radio") - store.clear() + store.clear() - #expect(store.deviceID == nil) - #expect(store.radioID == nil) - #expect(defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) == nil) - } + #expect(store.deviceID == nil) + #expect(store.radioID == nil) + #expect(defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) == nil) } + } - @Test("empty defaults read as nil") - func emptyDefaultsReadNil() { - withIsolatedDefaults { defaults in - let store = LastConnectionStore(defaults: defaults) + @Test + func `empty defaults read as nil`() { + withIsolatedDefaults { defaults in + let store = LastConnectionStore(defaults: defaults) - #expect(store.deviceID == nil) - #expect(store.radioID == nil) - #expect(store.disconnectDiagnostic == nil) - } + #expect(store.deviceID == nil) + #expect(store.radioID == nil) + #expect(store.disconnectDiagnostic == nil) } + } - @Test("malformed UUID strings read as nil") - func malformedUUIDReadsNil() { - withIsolatedDefaults { defaults in - defaults.set("not-a-uuid", forKey: PersistenceKeys.lastConnectedDeviceID) - defaults.set("also-not-a-uuid", forKey: PersistenceKeys.lastConnectedRadioID) - let store = LastConnectionStore(defaults: defaults) + @Test + func `malformed UUID strings read as nil`() { + withIsolatedDefaults { defaults in + defaults.set("not-a-uuid", forKey: PersistenceKeys.lastConnectedDeviceID) + defaults.set("also-not-a-uuid", forKey: PersistenceKeys.lastConnectedRadioID) + let store = LastConnectionStore(defaults: defaults) - #expect(store.deviceID == nil) - #expect(store.radioID == nil) - } + #expect(store.deviceID == nil) + #expect(store.radioID == nil) } + } - @Test("persistDisconnectDiagnostic prefixes a parseable ISO8601 timestamp") - func disconnectDiagnosticTimestampPrefix() throws { - try withIsolatedDefaults { defaults in - let store = LastConnectionStore(defaults: defaults) - let summary = "source=unitTest, reason=verifyFormat" + @Test + func `persistDisconnectDiagnostic prefixes a parseable ISO8601 timestamp`() throws { + try withIsolatedDefaults { defaults in + let store = LastConnectionStore(defaults: defaults) + let summary = "source=unitTest, reason=verifyFormat" - store.persistDisconnectDiagnostic(summary) + store.persistDisconnectDiagnostic(summary) - let stored = try #require(store.disconnectDiagnostic) - let separatorIndex = try #require(stored.firstIndex(of: " ")) - let timestampPart = String(stored[.. (MeshCoreSession, ServiceContainer, DeviceDTO) { - let session = MeshCoreSession(transport: SimulatorMockTransport()) - let services = try await ServiceContainer.forTesting(session: session) - let device = DeviceDTO.testDevice(id: deviceID, radioID: deviceID) - try await services.dataStore.saveDevice(device) - if startEventMonitoring { - await services.startEventMonitoring(radioID: device.radioID) - } - return (session, services, device) + // MARK: - Test Helpers + + private func makeServices( + deviceID: UUID, + startEventMonitoring: Bool = false + ) async throws -> (MeshCoreSession, ServiceContainer, DeviceDTO) { + let session = MeshCoreSession(transport: SimulatorMockTransport()) + let services = try await ServiceContainer.forTesting(session: session) + let device = DeviceDTO.testDevice(id: deviceID, radioID: deviceID) + try await services.dataStore.saveDevice(device) + if startEventMonitoring { + await services.startEventMonitoring(radioID: device.radioID) } - - // MARK: - Early Return Tests - - @Test("returns early when transport type is WiFi") - func returnsEarlyForWiFiTransport() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - - manager.setTestState( - connectionState: .ready, - currentTransportType: .wifi, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = UUID() - - await manager.checkBLEConnectionHealth() - - // Should not change state since WiFi transport is handled elsewhere - #expect(manager.connectionState == .ready) + return (session, services, device) + } + + // MARK: - Early Return Tests + + @Test + func `returns early when transport type is WiFi`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setTestState( + connectionState: .ready, + currentTransportType: .wifi, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Should not change state since WiFi transport is handled elsewhere + #expect(manager.connectionState == .ready) + } + + @Test + func `returns early when shouldBeConnected is false`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .none + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Should not trigger cleanup since user doesn't expect to be connected + #expect(manager.connectionState == .ready) + } + + @Test + func `returns early when no lastConnectedDeviceID`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + // testLastConnectedDeviceID is nil by default + + await manager.checkBLEConnectionHealth() + + // Should not trigger cleanup without a device to reconnect to + #expect(manager.connectionState == .ready) + } + + @Test + func `returns early when BLE is connected and app stack is healthy`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let (session, services, device) = try await makeServices(deviceID: deviceID, startEventMonitoring: true) + let tracker = SessionRebuildTracker() + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + manager.rebuildSessionForHealthCheckOverride = { deviceID in + await tracker.record(deviceID) } - @Test("returns early when shouldBeConnected is false") - func returnsEarlyWhenNotExpectingConnection() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .none - ) - manager.testLastConnectedDeviceID = UUID() - - await manager.checkBLEConnectionHealth() - - // Should not trigger cleanup since user doesn't expect to be connected - #expect(manager.connectionState == .ready) + manager.setTestState( + connectionState: .ready, + services: services, + session: session, + connectedDevice: device, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(manager.connectionState == .ready) + #expect(services.isEventMonitoringActive) + #expect(await services.messagePollingService.isAutoFetching) + #expect(await tracker.recordedCalls().isEmpty) + } + + @Test + func `skips reconnect during iOS auto-reconnect`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(true) + + manager.setTestState( + connectionState: .connecting, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Should not interfere with iOS auto-reconnect + #expect(manager.connectionState == .connecting) + } + + @Test + func `skips foreground reconnect while pairing is in progress`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + isPairingInProgress: true + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Gate fires before connect(to:) — connectionState stays .disconnected + #expect(manager.connectionState == .disconnected) + } + + @Test + func `skips reconnect while session rebuild is in progress`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + sessionRebuildDeviceID: deviceID + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(manager.connectionState == .disconnected) + #expect(manager.sessionRebuildDeviceID == deviceID) + } + + // MARK: - Connected Transport App-Stack Reconciliation Tests + + @Test + func `BLE connected with missing session and services rebuilds app stack`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let tracker = SessionRebuildTracker() + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + await mock.setStubbedIsAutoReconnecting(false) + manager.rebuildSessionForHealthCheckOverride = { deviceID in + await tracker.record(deviceID) } - @Test("returns early when no lastConnectedDeviceID") - func returnsEarlyWhenNoLastDevice() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - // testLastConnectedDeviceID is nil by default - - await manager.checkBLEConnectionHealth() - - // Should not trigger cleanup without a device to reconnect to - #expect(manager.connectionState == .ready) + manager.setTestState( + connectionState: .disconnected, + services: nil, + session: nil, + connectedDevice: nil, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(await tracker.recordedCalls() == [deviceID]) + } + + @Test + func `BLE connected ready state restarts missing event monitoring`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let (session, services, device) = try await makeServices(deviceID: deviceID) + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .ready, + services: services, + session: session, + connectedDevice: device, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + #expect(!services.isEventMonitoringActive) + #expect(await !(services.messagePollingService.isAutoFetching)) + + await manager.checkBLEConnectionHealth() + + #expect(services.isEventMonitoringActive) + #expect(await services.messagePollingService.isAutoFetching) + } + + @Test + func `BLE connected with healthy listeners does not rebuild`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let (session, services, device) = try await makeServices(deviceID: deviceID, startEventMonitoring: true) + let tracker = SessionRebuildTracker() + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + await mock.setStubbedIsAutoReconnecting(false) + manager.rebuildSessionForHealthCheckOverride = { deviceID in + await tracker.record(deviceID) } - - @Test("returns early when BLE is connected and app stack is healthy") - func returnsEarlyWhenBLEConnectedAndAppStackHealthy() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let (session, services, device) = try await makeServices(deviceID: deviceID, startEventMonitoring: true) - let tracker = SessionRebuildTracker() - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - manager.rebuildSessionForHealthCheckOverride = { deviceID in - await tracker.record(deviceID) - } - - manager.setTestState( - connectionState: .ready, - services: services, - session: session, - connectedDevice: device, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(manager.connectionState == .ready) - #expect(services.isEventMonitoringActive) - #expect(await services.messagePollingService.isAutoFetching) - #expect(await tracker.recordedCalls().isEmpty) + manager.setTestState( + connectionState: .ready, + services: services, + session: session, + connectedDevice: device, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(await tracker.recordedCalls().isEmpty) + #expect(services.isEventMonitoringActive) + #expect(await services.messagePollingService.isAutoFetching) + } + + @Test + func `BLE connected skips duplicate recovery while session rebuild is active`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let tracker = SessionRebuildTracker() + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + await mock.setStubbedIsAutoReconnecting(false) + manager.rebuildSessionForHealthCheckOverride = { deviceID in + await tracker.record(deviceID) } - - @Test("skips reconnect during iOS auto-reconnect") - func skipsWhenAutoReconnecting() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(true) - - manager.setTestState( - connectionState: .connecting, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = UUID() - - await manager.checkBLEConnectionHealth() - - // Should not interfere with iOS auto-reconnect - #expect(manager.connectionState == .connecting) + manager.setTestState( + connectionState: .disconnected, + services: nil, + session: nil, + connectedDevice: nil, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + sessionRebuildDeviceID: deviceID + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(await tracker.recordedCalls().isEmpty) + #expect(manager.sessionRebuildDeviceID == deviceID) + } + + @Test + func `health check does not attempt adoption while BLE restoration is already in progress`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedCurrentPhaseName("restoringState") + await mock.setStubbedIsDeviceConnectedToSystem(true) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + let adoptionCalls = await mock.startAdoptingSystemConnectedPeripheralCalls + #expect(adoptionCalls.isEmpty) + } + + @Test + func `health check skips reconnection when Bluetooth is powered off`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + await mock.setStubbedIsBluetoothPoweredOff(true) + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Should remain disconnected without attempting reconnection when BT is off + #expect(manager.connectionState == .disconnected) + } + + // MARK: - Stale State Detection Tests (Key Fix from b2ab8f17) + + @Test + func `detects stale state when connectionState is .ready but BLE disconnected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + // BLE is actually disconnected + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + // But connectionState thinks we're ready (stale state) + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + // After detecting stale state and cleanup, connectionState should be .disconnected + #expect(manager.connectionState == .disconnected) + } + + @Test + func `detects stale state when connectionState is .connected but BLE disconnected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + // BLE is actually disconnected + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + // But connectionState thinks we're connected (stale state) + manager.setTestState( + connectionState: .connected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + // After detecting stale state and cleanup, connectionState should be .disconnected + #expect(manager.connectionState == .disconnected) + } + + @Test + func `detects stale state when connectionState is .syncing but BLE disconnected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + try await manager.setTestState( + connectionState: .syncing, + services: ServiceContainer.forTesting( + session: MeshCoreSession(transport: SimulatorMockTransport()) + ), + session: MeshCoreSession(transport: SimulatorMockTransport()), + connectedDevice: DeviceDTO.testDevice(id: deviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(manager.connectionState == .disconnected) + } + + @Test + func `does not trigger cleanup when already disconnected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + // Already in disconnected state (not stale, expected state) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + // State should remain disconnected, no double-cleanup needed + #expect(manager.connectionState == .disconnected) + } + + // MARK: - Callback Verification Test + + @Test + func `calls onConnectionLost when stale state detected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + let tracker = ConnectionLostTracker() + manager.onConnectionLost = { + await tracker.markConnectionLost() } - @Test("skips foreground reconnect while pairing is in progress") - func skipsWhenPairingInProgress() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() + await manager.checkBLEConnectionHealth() - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) + let wasCalled = await tracker.connectionLostCalled + #expect(wasCalled, "onConnectionLost should be called when stale state is detected") + } - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - isPairingInProgress: true - ) - manager.testLastConnectedDeviceID = UUID() + // MARK: - Intent Preservation Tests - await manager.checkBLEConnectionHealth() + @Test + func `preserves wantsConnection intent after resyncFailed disconnect`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() - // Gate fires before connect(to:) — connectionState stays .disconnected - #expect(manager.connectionState == .disconnected) - } + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) - @Test("skips reconnect while session rebuild is in progress") - func skipsWhenSessionRebuildInProgress() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + // Simulate state where we were connected and user wants connection + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) + // Disconnect due to resync failure (internal reason) + await manager.disconnect(reason: .resyncFailed) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - sessionRebuildDeviceID: deviceID - ) - manager.testLastConnectedDeviceID = deviceID + // Intent should be preserved — user never asked to disconnect + #expect(manager.connectionIntent.wantsConnection, + "connectionIntent should remain .wantsConnection after resyncFailed disconnect") - await manager.checkBLEConnectionHealth() + // Health check should proceed past the guard and attempt reconnection + // (it won't actually connect since there's no real BLE, but it shouldn't + // bail out at the intent check) + await manager.checkBLEConnectionHealth() - #expect(manager.connectionState == .disconnected) - #expect(manager.sessionRebuildDeviceID == deviceID) - } + // After health check, state should still reflect wanting connection + #expect(manager.connectionIntent.wantsConnection, + "connectionIntent should still be .wantsConnection after health check") + } - // MARK: - Connected Transport App-Stack Reconciliation Tests - - @Test("BLE connected with missing session and services rebuilds app stack") - func bleConnectedMissingAppStackRebuildsSession() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let tracker = SessionRebuildTracker() - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - await mock.setStubbedIsAutoReconnecting(false) - manager.rebuildSessionForHealthCheckOverride = { deviceID in - await tracker.record(deviceID) - } - - manager.setTestState( - connectionState: .disconnected, - services: nil, - session: nil, - connectedDevice: nil, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(await tracker.recordedCalls() == [deviceID]) - } + // MARK: - Lifecycle Tests - @Test("BLE connected ready state restarts missing event monitoring") - func bleConnectedReadyRestartsMissingEventMonitoring() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let (session, services, device) = try await makeServices(deviceID: deviceID) - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .ready, - services: services, - session: session, - connectedDevice: device, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - #expect(!services.isEventMonitoringActive) - #expect(!(await services.messagePollingService.isAutoFetching)) - - await manager.checkBLEConnectionHealth() - - #expect(services.isEventMonitoringActive) - #expect(await services.messagePollingService.isAutoFetching) - } - - @Test("BLE connected with healthy listeners does not rebuild") - func bleConnectedHealthyListenersDoesNotRebuild() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let (session, services, device) = try await makeServices(deviceID: deviceID, startEventMonitoring: true) - let tracker = SessionRebuildTracker() - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - await mock.setStubbedIsAutoReconnecting(false) - manager.rebuildSessionForHealthCheckOverride = { deviceID in - await tracker.record(deviceID) - } - manager.setTestState( - connectionState: .ready, - services: services, - session: session, - connectedDevice: device, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(await tracker.recordedCalls().isEmpty) - #expect(services.isEventMonitoringActive) - #expect(await services.messagePollingService.isAutoFetching) - } - - @Test("BLE connected skips duplicate recovery while session rebuild is active") - func bleConnectedSkipsDuplicateRecoveryDuringSessionRebuild() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let tracker = SessionRebuildTracker() - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - await mock.setStubbedIsAutoReconnecting(false) - manager.rebuildSessionForHealthCheckOverride = { deviceID in - await tracker.record(deviceID) - } - manager.setTestState( - connectionState: .disconnected, - services: nil, - session: nil, - connectedDevice: nil, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - sessionRebuildDeviceID: deviceID - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(await tracker.recordedCalls().isEmpty) - #expect(manager.sessionRebuildDeviceID == deviceID) - } - - @Test("health check does not attempt adoption while BLE restoration is already in progress") - func healthCheckSkipsAdoptionDuringRestoration() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedCurrentPhaseName("restoringState") - await mock.setStubbedIsDeviceConnectedToSystem(true) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - let adoptionCalls = await mock.startAdoptingSystemConnectedPeripheralCalls - #expect(adoptionCalls.isEmpty) - } + @Test + func `appDidEnterBackground forwards to state machine`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - @Test("health check skips reconnection when Bluetooth is powered off") - func healthCheckSkipsReconnectionWhenPoweredOff() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - await mock.setStubbedIsBluetoothPoweredOff(true) - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = UUID() - - await manager.checkBLEConnectionHealth() - - // Should remain disconnected without attempting reconnection when BT is off - #expect(manager.connectionState == .disconnected) - } - - // MARK: - Stale State Detection Tests (Key Fix from b2ab8f17) - - @Test("detects stale state when connectionState is .ready but BLE disconnected") - func detectsStaleReadyState() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + await manager.appDidEnterBackground() - // BLE is actually disconnected - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) + let callCount = await mock.appDidEnterBackgroundCallCount + #expect(callCount == 1) + } - // But connectionState thinks we're ready (stale state) - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID + @Test + func `appDidBecomeActive forwards to state machine and triggers health check`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - await manager.checkBLEConnectionHealth() + await manager.appDidBecomeActive() - // After detecting stale state and cleanup, connectionState should be .disconnected - #expect(manager.connectionState == .disconnected) - } + let callCount = await mock.appDidBecomeActiveCallCount + #expect(callCount == 1) + } - @Test("detects stale state when connectionState is .connected but BLE disconnected") - func detectsStaleConnectedState() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `appDidEnterBackground stops running watchdog`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) - // BLE is actually disconnected - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) + // Start watchdog via foreground + await manager.appDidBecomeActive() + #expect(manager.isReconnectionWatchdogRunning) - // But connectionState thinks we're connected (stale state) - manager.setTestState( - connectionState: .connected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID + // Background should stop it + await manager.appDidEnterBackground() + #expect(!manager.isReconnectionWatchdogRunning) + } - await manager.checkBLEConnectionHealth() + @Test + func `appDidBecomeActive re-arms watchdog when disconnected and wants connection`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - // After detecting stale state and cleanup, connectionState should be .disconnected - #expect(manager.connectionState == .disconnected) - } + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) - @Test("detects stale state when connectionState is .syncing but BLE disconnected") - func detectsStaleSyncingState() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) - - manager.setTestState( - connectionState: .syncing, - services: try await ServiceContainer.forTesting( - session: MeshCoreSession(transport: SimulatorMockTransport()) - ), - session: MeshCoreSession(transport: SimulatorMockTransport()), - connectedDevice: DeviceDTO.testDevice(id: deviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(manager.connectionState == .disconnected) - } + await manager.appDidBecomeActive() - @Test("does not trigger cleanup when already disconnected") - func noCleanupWhenAlreadyDisconnected() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) - - // Already in disconnected state (not stale, expected state) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - // State should remain disconnected, no double-cleanup needed - #expect(manager.connectionState == .disconnected) - } + #expect(manager.isReconnectionWatchdogRunning) - // MARK: - Callback Verification Test + await manager.appDidEnterBackground() + } + + @Test + func `appDidBecomeActive does not arm watchdog when user does not want connection`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .none + ) + + await manager.appDidBecomeActive() + + #expect(!manager.isReconnectionWatchdogRunning) + } + + @Test + func `appDidBecomeActive does not arm watchdog during auto-reconnect`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + + await mock.setStubbedIsAutoReconnecting(true) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) - @Test("calls onConnectionLost when stale state detected") - func callsOnConnectionLostForStaleState() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + await manager.appDidBecomeActive() - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) - - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - let tracker = ConnectionLostTracker() - manager.onConnectionLost = { - await tracker.markConnectionLost() - } - - await manager.checkBLEConnectionHealth() - - let wasCalled = await tracker.connectionLostCalled - #expect(wasCalled, "onConnectionLost should be called when stale state is detected") - } - - // MARK: - Intent Preservation Tests - - @Test("preserves wantsConnection intent after resyncFailed disconnect") - func preservesIntentAfterResyncFailed() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) - - // Simulate state where we were connected and user wants connection - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - // Disconnect due to resync failure (internal reason) - await manager.disconnect(reason: .resyncFailed) - - // Intent should be preserved — user never asked to disconnect - #expect(manager.connectionIntent.wantsConnection, - "connectionIntent should remain .wantsConnection after resyncFailed disconnect") - - // Health check should proceed past the guard and attempt reconnection - // (it won't actually connect since there's no real BLE, but it shouldn't - // bail out at the intent check) - await manager.checkBLEConnectionHealth() - - // After health check, state should still reflect wanting connection - #expect(manager.connectionIntent.wantsConnection, - "connectionIntent should still be .wantsConnection after health check") - } - - // MARK: - Lifecycle Tests - - @Test("appDidEnterBackground forwards to state machine") - func appDidEnterBackgroundForwardsToStateMachine() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await manager.appDidEnterBackground() - - let callCount = await mock.appDidEnterBackgroundCallCount - #expect(callCount == 1) - } - - @Test("appDidBecomeActive forwards to state machine and triggers health check") - func appDidBecomeActiveForwardsToStateMachine() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await manager.appDidBecomeActive() - - let callCount = await mock.appDidBecomeActiveCallCount - #expect(callCount == 1) - } - - @Test("appDidEnterBackground stops running watchdog") - func appDidEnterBackgroundStopsWatchdog() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - // Start watchdog via foreground - await manager.appDidBecomeActive() - #expect(manager.isReconnectionWatchdogRunning) - - // Background should stop it - await manager.appDidEnterBackground() - #expect(!manager.isReconnectionWatchdogRunning) - } - - @Test("appDidBecomeActive re-arms watchdog when disconnected and wants connection") - func appDidBecomeActiveRearmsWatchdogWhenDisconnected() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - await manager.appDidBecomeActive() - - #expect(manager.isReconnectionWatchdogRunning) - - await manager.appDidEnterBackground() - } - - @Test("appDidBecomeActive does not arm watchdog when user does not want connection") - func appDidBecomeActiveDoesNotArmWatchdogWhenIntentOff() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .none - ) - - await manager.appDidBecomeActive() - - #expect(!manager.isReconnectionWatchdogRunning) - } - - @Test("appDidBecomeActive does not arm watchdog during auto-reconnect") - func appDidBecomeActiveDoesNotArmWatchdogDuringAutoReconnect() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await mock.setStubbedIsAutoReconnecting(true) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - await manager.appDidBecomeActive() - - #expect(!manager.isReconnectionWatchdogRunning) - } + #expect(!manager.isReconnectionWatchdogRunning) + } } // MARK: - Test Helpers private actor ConnectionLostTracker { - var connectionLostCalled = false + var connectionLostCalled = false - func markConnectionLost() { - connectionLostCalled = true - } + func markConnectionLost() { + connectionLostCalled = true + } } private actor SessionRebuildTracker { - private var calls: [UUID] = [] + private var calls: [UUID] = [] - func record(_ deviceID: UUID) { - calls.append(deviceID) - } + func record(_ deviceID: UUID) { + calls.append(deviceID) + } - func recordedCalls() -> [UUID] { - calls - } + func recordedCalls() -> [UUID] { + calls + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerBLEScanningTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerBLEScanningTests.swift index cb593a4c..ff82021e 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerBLEScanningTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerBLEScanningTests.swift @@ -1,104 +1,103 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ConnectionManager BLE Scanning Tests") @MainActor struct ConnectionManagerBLEScanningTests { + @Test + func `startBLEScanning starts scan and forwards discoveries`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let stream = manager.startBLEScanning() - @Test("startBLEScanning starts scan and forwards discoveries") - func startBLEScanningForwardsDiscoveries() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let stream = manager.startBLEScanning() - - let expectedID = UUID() - let receiveTask = Task { await nextValue(from: stream, timeout: .seconds(10)) } - - try await waitUntil("BLE scanning should start") { - await mock.isScanning - } - await mock.simulateDiscoveredDevice(id: expectedID, name: "MeshCore-Test", rssi: -68) - - let discovery = await receiveTask.value - #expect(discovery?.id == expectedID) - #expect(discovery?.name == "MeshCore-Test") - #expect(discovery?.rssi == -68) - #expect(await mock.startScanningCallCount == 1) - #expect(await mock.isScanning) + let expectedID = UUID() + let receiveTask = Task { await nextValue(from: stream, timeout: .seconds(10)) } - await manager.stopBLEScanning() - #expect(await mock.isScanning == false) + try await waitUntil("BLE scanning should start") { + await mock.isScanning } + await mock.simulateDiscoveredDevice(id: expectedID, name: "MeshCore-Test", rssi: -68) + + let discovery = await receiveTask.value + #expect(discovery?.id == expectedID) + #expect(discovery?.name == "MeshCore-Test") + #expect(discovery?.rssi == -68) + #expect(await mock.startScanningCallCount == 1) + #expect(await mock.isScanning) + + await manager.stopBLEScanning() + #expect(await mock.isScanning == false) + } + + @Test + func `terminated scan stream does not leave scanning active`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let stream = manager.startBLEScanning() + + let consumeTask = Task { + for await _ in stream {} + } + consumeTask.cancel() + _ = await consumeTask.result - @Test("terminated scan stream does not leave scanning active") - func terminatedStreamDoesNotLeakScanning() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let stream = manager.startBLEScanning() + try await waitUntil("Stream cleanup should stop scanning") { + await mock.isScanning == false + } - let consumeTask = Task { - for await _ in stream {} - } - consumeTask.cancel() - _ = await consumeTask.result + #expect(await mock.isScanning == false) + #expect(await mock.stopScanningCallCount >= 1) + } - try await waitUntil("Stream cleanup should stop scanning") { - await mock.isScanning == false - } + @Test + func `older stream termination does not stop newer scan`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - #expect(await mock.isScanning == false) - #expect(await mock.stopScanningCallCount >= 1) + let stream1 = manager.startBLEScanning() + let consumeTask1 = Task { + for await _ in stream1 {} + } + try await waitUntil("stream1 scanning should start") { + await mock.isScanning } - @Test("older stream termination does not stop newer scan") - func oldStreamTerminationDoesNotStopNewScan() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - let stream1 = manager.startBLEScanning() - let consumeTask1 = Task { - for await _ in stream1 {} - } - try await waitUntil("stream1 scanning should start") { - await mock.isScanning - } - - let stream2 = manager.startBLEScanning() - let consumeTask2 = Task { - for await _ in stream2 {} - } - try await waitUntil("stream2 scanning should start") { - await mock.startScanningCallCount == 2 - } - - consumeTask1.cancel() - _ = await consumeTask1.result - - // Fixed sleep: negative assertion — older stream cleanup must not stop the newer scan. - // We wait briefly to confirm no erroneous stopScanning call fires. - try? await Task.sleep(for: .milliseconds(100)) - - #expect(await mock.isScanning, "Newer scan should remain active") - - consumeTask2.cancel() - _ = await consumeTask2.result + let stream2 = manager.startBLEScanning() + let consumeTask2 = Task { + for await _ in stream2 {} + } + try await waitUntil("stream2 scanning should start") { + await mock.startScanningCallCount == 2 } - private func nextValue( - from stream: AsyncStream, - timeout: Duration - ) async -> DiscoveredDevice? { - await withTaskGroup(of: DiscoveredDevice?.self) { group in - group.addTask { - var iterator = stream.makeAsyncIterator() - return await iterator.next() - } - group.addTask { - try? await Task.sleep(for: timeout) - return nil - } - - let first = await group.next() ?? nil - group.cancelAll() - return first - } + consumeTask1.cancel() + _ = await consumeTask1.result + + // Fixed sleep: negative assertion — older stream cleanup must not stop the newer scan. + // We wait briefly to confirm no erroneous stopScanning call fires. + try? await Task.sleep(for: .milliseconds(100)) + + #expect(await mock.isScanning, "Newer scan should remain active") + + consumeTask2.cancel() + _ = await consumeTask2.result + } + + private func nextValue( + from stream: AsyncStream, + timeout: Duration + ) async -> DiscoveredDevice? { + await withTaskGroup(of: DiscoveredDevice?.self) { group in + group.addTask { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + + let first = await group.next() ?? nil + group.cancelAll() + return first } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerCircuitBreakerTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerCircuitBreakerTests.swift new file mode 100644 index 00000000..9567eaf9 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerCircuitBreakerTests.swift @@ -0,0 +1,94 @@ +import Foundation +@testable import MC1Services +import Testing + +/// Covers the connection circuit breaker's full state machine: +/// closed → open on exhausted retries, 30s cooldown → half-open probe, +/// probe failure → open, success → closed, and the `force` bypass. +@Suite("ConnectionManager Circuit Breaker Tests") +@MainActor +struct ConnectionManagerCircuitBreakerTests { + /// A timestamp safely past the cooldown window. + private var pastCooldown: Date { + Date().addingTimeInterval(-60) + } + + @Test + func `closed breaker allows connections`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + + #expect(manager.shouldAllowConnection(force: false)) + } + + @Test + func `failure trips the breaker and blocks connections during cooldown`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.recordConnectionFailure() + + #expect(!manager.shouldAllowConnection(force: false)) + } + + @Test + func `force bypasses an open breaker`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.recordConnectionFailure() + + #expect(manager.shouldAllowConnection(force: true)) + } + + @Test + func `elapsed cooldown transitions the breaker to half-open and allows a probe`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setCircuitBreakerOpenForTesting(since: pastCooldown) + + #expect(manager.shouldAllowConnection(force: false)) + // Half-open keeps allowing the probe attempt until it resolves. + #expect(manager.shouldAllowConnection(force: false)) + } + + @Test + func `failed half-open probe re-opens the breaker`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setCircuitBreakerOpenForTesting(since: pastCooldown) + #expect(manager.shouldAllowConnection(force: false)) + + manager.recordConnectionFailure() + + #expect(!manager.shouldAllowConnection(force: false)) + } + + @Test + func `successful connection closes the breaker again`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.recordConnectionFailure() + manager.recordConnectionSuccess() + + #expect(manager.shouldAllowConnection(force: false)) + } + + @Test + func `repeated failures while open keep the original cooldown start`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setCircuitBreakerOpenForTesting(since: pastCooldown) + // A stray failure report while already open must not restart the cooldown. + manager.recordConnectionFailure() + + #expect(manager.shouldAllowConnection(force: false)) + } + + @Test + func `connect rejects with a typed error while the breaker is open`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.recordConnectionFailure() + + await #expect(throws: BLEError.self) { + try await manager.connect(to: UUID()) + } + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerDisconnectDiagnosticsTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerDisconnectDiagnosticsTests.swift index fbd7c630..d8ac42b3 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerDisconnectDiagnosticsTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerDisconnectDiagnosticsTests.swift @@ -1,175 +1,173 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ConnectionManager Disconnect Diagnostics Tests") @MainActor struct ConnectionManagerDisconnectDiagnosticsTests { - - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + private let defaults: UserDefaults + + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `auto-reconnect entry persists disconnect diagnostic with error info`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let deviceID = UUID() + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + // Wait for ConnectionManager init to wire auto-reconnect handler. + try await waitUntil("auto-reconnect handler should be installed") { + await mock.hasAutoReconnectingHandler } - @Test("auto-reconnect entry persists disconnect diagnostic with error info") - func autoReconnectEntryPersistsDisconnectDiagnostic() async throws { - - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - // Wait for ConnectionManager init to wire auto-reconnect handler. - try await waitUntil("auto-reconnect handler should be installed") { - await mock.hasAutoReconnectingHandler - } - - await mock.simulateAutoReconnecting( - deviceID: deviceID, - errorInfo: "domain=CBErrorDomain, code=15, desc=Failed to encrypt" - ) - - // Wait for both the .connecting state transition and the diagnostic write — - // the handler claims the cycle (sync) before running the state-machine - // queries that feed the diagnostic, so observing only the state can race - // ahead of the persistDisconnectDiagnostic call. - try await waitUntil("connectionState should transition to .connecting and diagnostic should be persisted") { - manager.connectionState == .connecting - && (manager.lastDisconnectDiagnostic ?? "") - .localizedStandardContains("source=bleStateMachine.autoReconnectingHandler") - } - - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect(diagnostic.localizedStandardContains("code=15")) - #expect(manager.connectionState == .connecting) + await mock.simulateAutoReconnecting( + deviceID: deviceID, + errorInfo: "domain=CBErrorDomain, code=15, desc=Failed to encrypt" + ) + + // Wait for both the .connecting state transition and the diagnostic write — + // the handler claims the cycle (sync) before running the state-machine + // queries that feed the diagnostic, so observing only the state can race + // ahead of the persistDisconnectDiagnostic call. + try await waitUntil("connectionState should transition to .connecting and diagnostic should be persisted") { + manager.connectionState == .connecting + && (manager.lastDisconnectDiagnostic ?? "") + .localizedStandardContains("source=bleStateMachine.autoReconnectingHandler") } - @Test("auto-reconnect entry skips the claim but tears down the stale OLD session during pairing") - func autoReconnectSuppressedDuringPairing() async throws { - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let oldDeviceID = UUID() - - manager.setTestState( - connectionState: .ready, - connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - isPairingInProgress: true - ) - - try await waitUntil("auto-reconnect handler should be installed") { - await mock.hasAutoReconnectingHandler - } - - await mock.simulateAutoReconnecting(deviceID: oldDeviceID) - - // Wait for the gate's teardown path to run. handleConnectionLoss transitions - // state to .disconnected and clears connectedDevice; observing both confirms - // teardown completed without claiming a reconnect cycle. - try await waitUntil("suppression branch should tear down OLD session") { - manager.connectionState == .disconnected && manager.connectedDevice == nil - } - - #expect(manager.activeReconnectDeviceID == nil, "Claim must remain unset so pairing's connect(to:) is not preempted") - #expect(manager.connectionState == .disconnected, "OLD session is dead — UI must not stay on .ready") - #expect(manager.connectedDevice == nil, "Stale connectedDevice must be cleared so message sends fail loudly instead of silently") - - // The suppression gate runs before the diagnostic preamble, so - // `handleConnectionLoss`'s diagnostic is the only writer for this branch - // and is the observable proof the suppression branch took the loss path. - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect(diagnostic.localizedStandardContains("source=handleConnectionLoss")) + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect(diagnostic.localizedStandardContains("code=15")) + #expect(manager.connectionState == .connecting) + } + + @Test + func `auto-reconnect entry skips the claim but tears down the stale OLD session during pairing`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let oldDeviceID = UUID() + + manager.setTestState( + connectionState: .ready, + connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + isPairingInProgress: true + ) + + try await waitUntil("auto-reconnect handler should be installed") { + await mock.hasAutoReconnectingHandler } - @Test("health check preserves intent and persists diagnostic when other app is connected") - func healthCheckPersistsDiagnosticWhenOtherAppConnected() async throws { - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(true) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID + await mock.simulateAutoReconnecting(deviceID: oldDeviceID) - await manager.checkBLEConnectionHealth() - - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect( - diagnostic.localizedStandardContains("source=checkBLEConnectionHealth.otherAppConnected") - ) - #expect(manager.connectionIntent.wantsConnection) - #expect(manager.isReconnectionWatchdogRunning) - - await manager.appDidEnterBackground() + // Wait for the gate's teardown path to run. handleConnectionLoss transitions + // state to .disconnected and clears connectedDevice; observing both confirms + // teardown completed without claiming a reconnect cycle. + try await waitUntil("suppression branch should tear down OLD session") { + manager.connectionState == .disconnected && manager.connectedDevice == nil } - @Test("health check adopts system-connected last device when adoption can start") - func healthCheckAdoptsSystemConnectedPeripheral() async throws { - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsBluetoothPoweredOff(false) - await mock.setStubbedIsDeviceConnectedToSystem(true) - await mock.setStubbedDidStartAdoptingSystemConnectedPeripheral(true) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - let calls = await mock.startAdoptingSystemConnectedPeripheralCalls - #expect(calls == [deviceID]) - - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect( - diagnostic.localizedStandardContains("source=checkBLEConnectionHealth.adoptSystemConnectedPeripheral") - ) - #expect(manager.connectionState == .connecting) - #expect(manager.connectionIntent.wantsConnection) - } - - @Test("manual connect adopts system-connected last device instead of throwing deviceConnectedToOtherApp") - func manualConnectAdoptsSystemConnectedPeripheral() async throws { - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsBluetoothPoweredOff(false) - await mock.setStubbedIsDeviceConnectedToSystem(true) - await mock.setStubbedDidStartAdoptingSystemConnectedPeripheral(true) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .none - ) - manager.testLastConnectedDeviceID = deviceID - - try await manager.connect(to: deviceID, forceFullSync: true, forceReconnect: true) - - let calls = await mock.startAdoptingSystemConnectedPeripheralCalls - #expect(calls == [deviceID]) - - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect(diagnostic.localizedStandardContains("source=connect(to:).adoptSystemConnectedPeripheral")) - #expect(manager.connectionState == .connecting) - #expect(manager.connectionIntent.wantsConnection) - } + #expect(manager.activeReconnectDeviceID == nil, "Claim must remain unset so pairing's connect(to:) is not preempted") + #expect(manager.connectionState == .disconnected, "OLD session is dead — UI must not stay on .ready") + #expect(manager.connectedDevice == nil, "Stale connectedDevice must be cleared so message sends fail loudly instead of silently") + + // The suppression gate runs before the diagnostic preamble, so + // `handleConnectionLoss`'s diagnostic is the only writer for this branch + // and is the observable proof the suppression branch took the loss path. + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect(diagnostic.localizedStandardContains("source=handleConnectionLoss")) + } + + @Test + func `health check preserves intent and persists diagnostic when other app is connected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(true) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect( + diagnostic.localizedStandardContains("source=checkBLEConnectionHealth.otherAppConnected") + ) + #expect(manager.connectionIntent.wantsConnection) + #expect(manager.isReconnectionWatchdogRunning) + + await manager.appDidEnterBackground() + } + + @Test + func `health check adopts system-connected last device when adoption can start`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsBluetoothPoweredOff(false) + await mock.setStubbedIsDeviceConnectedToSystem(true) + await mock.setStubbedDidStartAdoptingSystemConnectedPeripheral(true) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + let calls = await mock.startAdoptingSystemConnectedPeripheralCalls + #expect(calls == [deviceID]) + + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect( + diagnostic.localizedStandardContains("source=checkBLEConnectionHealth.adoptSystemConnectedPeripheral") + ) + #expect(manager.connectionState == .connecting) + #expect(manager.connectionIntent.wantsConnection) + } + + @Test + func `manual connect adopts system-connected last device instead of throwing deviceConnectedToOtherApp`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsBluetoothPoweredOff(false) + await mock.setStubbedIsDeviceConnectedToSystem(true) + await mock.setStubbedDidStartAdoptingSystemConnectedPeripheral(true) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .none + ) + manager.testLastConnectedDeviceID = deviceID + + try await manager.connect(to: deviceID, forceFullSync: true, forceReconnect: true) + + let calls = await mock.startAdoptingSystemConnectedPeripheralCalls + #expect(calls == [deviceID]) + + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect(diagnostic.localizedStandardContains("source=connect(to:).adoptSystemConnectedPeripheral")) + #expect(manager.connectionState == .connecting) + #expect(manager.connectionIntent.wantsConnection) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPairingTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPairingTests.swift index 54b6f7c6..ba578458 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPairingTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPairingTests.swift @@ -1,337 +1,338 @@ import Foundation +@testable import MC1Services import os import Testing -@testable import MC1Services /// Thread-safe counter for tracking call counts in mock handlers. private final class Counter: Sendable { - private let lock = OSAllocatedUnfairLock(initialState: 0) - - /// Increments the counter and returns the new value. - func increment() -> Int { - lock.withLock { value in - value += 1 - return value - } + private let lock = OSAllocatedUnfairLock(initialState: 0) + + /// Increments the counter and returns the new value. + func increment() -> Int { + lock.withLock { value in + value += 1 + return value } + } } @Suite("ConnectionManager Pairing Tests") @MainActor struct ConnectionManagerPairingTests { + // MARK: - State Guard Tests - // MARK: - State Guard Tests + @Test + func `unfavoritedNodeCount throws when not connected`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - @Test("unfavoritedNodeCount throws when not connected") - func unfavoritedNodeCountThrowsWhenDisconnected() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + try await #expect { + _ = try await manager.unfavoritedNodeCount() + } throws: { error in + guard let e = error as? ConnectionError, case .notConnected = e else { return false } + return true + } + } - try await #expect { - _ = try await manager.unfavoritedNodeCount() - } throws: { error in - guard let e = error as? ConnectionError, case .notConnected = e else { return false } - return true - } + @Test + func `removeUnfavoritedNodes throws when not connected`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + try await #expect { + _ = try await manager.removeUnfavoritedNodes() + } throws: { error in + guard let e = error as? ConnectionError, case .notConnected = e else { return false } + return true } + } - @Test("removeUnfavoritedNodes throws when not connected") - func removeUnfavoritedNodesThrowsWhenDisconnected() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + func `removeStaleNodes throws when not connected`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - try await #expect { - _ = try await manager.removeUnfavoritedNodes() - } throws: { error in - guard let e = error as? ConnectionError, case .notConnected = e else { return false } - return true - } + try await #expect { + _ = try await manager.removeStaleNodes(olderThanDays: 30) + } throws: { error in + guard let e = error as? ConnectionError, case .notConnected = e else { return false } + return true } + } + + @Test + func `pairNewDevice rejects re-entry without clearing the outer call's flag`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - @Test("removeStaleNodes throws when not connected") - func removeStaleNodesThrowsWhenDisconnected() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + // Simulate the outer call having already entered pairNewDevice and + // suspended in showPicker. + manager.setTestState(isPairingInProgress: true) - try await #expect { - _ = try await manager.removeStaleNodes(olderThanDays: 30) - } throws: { error in - guard let e = error as? ConnectionError, case .notConnected = e else { return false } - return true - } + try await #expect { + try await manager.pairNewDevice() + } throws: { error in + guard let e = error as? DevicePairingError, case .alreadyInProgress = e else { return false } + return true } - @Test("pairNewDevice rejects re-entry without clearing the outer call's flag") - func pairNewDeviceRejectsReEntry() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + // The inner call's defer must not unwind the outer call's state. + #expect(manager.isPairingInProgress == true) + } - // Simulate the outer call having already entered pairNewDevice and - // suspended in showPicker. - manager.setTestState(isPairingInProgress: true) + @Test + func `pairNewDevice stops BLE scanning before showing ASK picker`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } - try await #expect { - try await manager.pairNewDevice() - } throws: { error in - guard let e = error as? DevicePairingError, case .alreadyInProgress = e else { return false } - return true - } + let stream = env.manager.startBLEScanning() + let scanConsumer = Task { + for await _ in stream {} + } + defer { scanConsumer.cancel() } - // The inner call's defer must not unwind the outer call's state. - #expect(manager.isPairingInProgress == true) + try await waitUntil("BLE scanning should start") { + await env.stateMachine.isScanning } - @Test("pairNewDevice stops BLE scanning before showing ASK picker") - func pairNewDeviceStopsBLEScanningBeforeShowingPicker() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } + let pickerEntered = AsyncStream.makeStream() + let pickerGate = AsyncStream.makeStream() + env.accessorySetupKit.pickerEnteredSignal = pickerEntered.continuation + env.accessorySetupKit.pickerGate = pickerGate.stream + env.accessorySetupKit.setPickerResult(.failure(AccessorySetupKitError.pickerDismissed)) - let stream = env.manager.startBLEScanning() - let scanConsumer = Task { - for await _ in stream {} - } - defer { scanConsumer.cancel() } + let pairTask = Task { + try? await env.manager.pairNewDevice() + } - try await waitUntil("BLE scanning should start") { - await env.stateMachine.isScanning - } + for await _ in pickerEntered.stream { + break + } - let pickerEntered = AsyncStream.makeStream() - let pickerGate = AsyncStream.makeStream() - env.accessorySetupKit.pickerEnteredSignal = pickerEntered.continuation - env.accessorySetupKit.pickerGate = pickerGate.stream - env.accessorySetupKit.setPickerResult(.failure(AccessorySetupKitError.pickerDismissed)) + #expect(await env.stateMachine.stopScanningCallCount == 1) + #expect(await env.stateMachine.isScanning == false) - let pairTask = Task { - try? await env.manager.pairNewDevice() - } + pickerGate.continuation.finish() + _ = await pairTask.result + } - for await _ in pickerEntered.stream { break } + // MARK: - Device Update Tests - #expect(await env.stateMachine.stopScanningCallCount == 1) - #expect(await env.stateMachine.isScanning == false) + @Test + func `updateDevice(with:) updates connectedDevice`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice(nodeName: "NewDevice") - pickerGate.continuation.finish() - _ = await pairTask.result - } + manager.updateDevice(with: device) - // MARK: - Device Update Tests + #expect(manager.connectedDevice?.nodeName == "NewDevice") + #expect(manager.connectedDevice?.id == device.id) + } - @Test("updateDevice(with:) updates connectedDevice") - func updateDeviceWithDTO() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice(nodeName: "NewDevice") + @Test + func `updateAutoAddConfig updates config when connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.updateDevice(with: device) - manager.updateDevice(with: device) + manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) - #expect(manager.connectedDevice?.nodeName == "NewDevice") - #expect(manager.connectedDevice?.id == device.id) - } + #expect(manager.connectedDevice?.autoAddConfig == 5) + #expect(manager.connectedDevice?.autoAddMaxHops == 3) + } - @Test("updateAutoAddConfig updates config when connected") - func updateAutoAddConfigWhenConnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.updateDevice(with: device) + @Test + func `updateAutoAddConfig does nothing when not connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) + manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) - #expect(manager.connectedDevice?.autoAddConfig == 5) - #expect(manager.connectedDevice?.autoAddMaxHops == 3) - } + #expect(manager.connectedDevice == nil) + } - @Test("updateAutoAddConfig does nothing when not connected") - func updateAutoAddConfigWhenDisconnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + func `updateClientRepeat updates repeat flag when connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.updateDevice(with: device) - manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) + manager.updateClientRepeat(true) - #expect(manager.connectedDevice == nil) - } + #expect(manager.connectedDevice?.clientRepeat == true) + } - @Test("updateClientRepeat updates repeat flag when connected") - func updateClientRepeatWhenConnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.updateDevice(with: device) + @Test + func `updatePathHashMode updates hash mode when connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.updateDevice(with: device) - manager.updateClientRepeat(true) + manager.updatePathHashMode(2) - #expect(manager.connectedDevice?.clientRepeat == true) - } + #expect(manager.connectedDevice?.pathHashMode == 2) + } - @Test("updatePathHashMode updates hash mode when connected") - func updatePathHashModeWhenConnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.updateDevice(with: device) + // MARK: - Pre-Repeat Settings Tests - manager.updatePathHashMode(2) + @Test + func `savePreRepeatSettings changes connectedDevice`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice( + frequency: 915_000, + bandwidth: 250_000, + spreadingFactor: 10, + codingRate: 5, + txPower: 20 + ) + manager.updateDevice(with: device) + let original = manager.connectedDevice - #expect(manager.connectedDevice?.pathHashMode == 2) - } + manager.savePreRepeatSettings() - // MARK: - Pre-Repeat Settings Tests - - @Test("savePreRepeatSettings changes connectedDevice") - func savePreRepeatSettingsChangesDevice() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice( - frequency: 915_000, - bandwidth: 250_000, - spreadingFactor: 10, - codingRate: 5, - txPower: 20 - ) - manager.updateDevice(with: device) - let original = manager.connectedDevice - - manager.savePreRepeatSettings() - - #expect(manager.connectedDevice != original) - #expect(manager.connectedDevice != nil) - } + #expect(manager.connectedDevice != original) + #expect(manager.connectedDevice != nil) + } - @Test("clearPreRepeatSettings clears saved settings") - func clearPreRepeatSettingsClears() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.updateDevice(with: device) + @Test + func `clearPreRepeatSettings clears saved settings`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.updateDevice(with: device) - manager.savePreRepeatSettings() - let afterSave = manager.connectedDevice + manager.savePreRepeatSettings() + let afterSave = manager.connectedDevice - manager.clearPreRepeatSettings() - let afterClear = manager.connectedDevice + manager.clearPreRepeatSettings() + let afterClear = manager.connectedDevice - #expect(afterSave != afterClear) - } + #expect(afterSave != afterClear) + } - // MARK: - Other-App Reconnection Polling + // MARK: - Other-App Reconnection Polling - @Test("waitForOtherAppReconnection returns true on immediate detection") - func waitForOtherAppReconnectionImmediate() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `waitForOtherAppReconnection returns true on immediate detection`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() - await mock.setStubbedIsDeviceConnectedToSystem(true) + await mock.setStubbedIsDeviceConnectedToSystem(true) - let result = await manager.waitForOtherAppReconnection(deviceID) + let result = await manager.waitForOtherAppReconnection(deviceID) - #expect(result == true) - let callCount = await mock.isDeviceConnectedToSystemCalls.count - #expect(callCount == 1) - } + #expect(result == true) + let callCount = await mock.isDeviceConnectedToSystemCalls.count + #expect(callCount == 1) + } - @Test("waitForOtherAppReconnection returns false after all checks") - func waitForOtherAppReconnectionNoOtherApp() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `waitForOtherAppReconnection returns false after all checks`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() - await mock.setStubbedIsDeviceConnectedToSystem(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) - let result = await manager.waitForOtherAppReconnection(deviceID) + let result = await manager.waitForOtherAppReconnection(deviceID) - #expect(result == false) - let callCount = await mock.isDeviceConnectedToSystemCalls.count - #expect(callCount == 6) - } + #expect(result == false) + let callCount = await mock.isDeviceConnectedToSystemCalls.count + #expect(callCount == 6) + } - @Test("waitForOtherAppReconnection detects delayed reconnection") - func waitForOtherAppReconnectionDelayed() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `waitForOtherAppReconnection detects delayed reconnection`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() - // Return true on the 3rd call using a counter outside the actor - let callCounter = Counter() - await mock.setIsDeviceConnectedToSystemHandler { _ in - return callCounter.increment() >= 3 - } + // Return true on the 3rd call using a counter outside the actor + let callCounter = Counter() + await mock.setIsDeviceConnectedToSystemHandler { _ in + callCounter.increment() >= 3 + } - let result = await manager.waitForOtherAppReconnection(deviceID) + let result = await manager.waitForOtherAppReconnection(deviceID) - #expect(result == true) - let callCount = await mock.isDeviceConnectedToSystemCalls.count - #expect(callCount == 3) - } + #expect(result == true) + let callCount = await mock.isDeviceConnectedToSystemCalls.count + #expect(callCount == 3) + } - // MARK: - Data Operations + // MARK: - Data Operations - @Test("fetchSavedDevices returns empty array when no devices saved") - func fetchSavedDevicesEmpty() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + func `fetchSavedDevices returns empty array when no devices saved`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - let devices = try await manager.fetchSavedDevices() + let devices = try await manager.fetchSavedDevices() - #expect(devices.isEmpty) - } + #expect(devices.isEmpty) + } - @Test("deleteDevice completes without error for non-existent device") - func deleteDeviceNonExistent() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + func `deleteDevice completes without error for non-existent device`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - try await manager.deleteDevice(id: UUID()) - } + try await manager.deleteDevice(id: UUID()) + } - // MARK: - Forget/Resume Signal + // MARK: - Forget/Resume Signal - @Test("deleteDevice clears the persisted connection when removing the last-connected radio") - func deleteDeviceClearsLastConnectedSignal() async throws { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) + @Test + func `deleteDevice clears the persisted connection when removing the last-connected radio`() async throws { + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - manager.persistConnection(deviceID: deviceID, radioID: UUID(), deviceName: "Radio") - #expect(manager.lastConnectedDeviceID == deviceID) + let deviceID = UUID() + manager.persistConnection(deviceID: deviceID, radioID: UUID(), deviceName: "Radio") + #expect(manager.lastConnectedDeviceID == deviceID) - try await manager.deleteDevice(id: deviceID) + try await manager.deleteDevice(id: deviceID) - // The auto-reconnect / onboarding-resume signal must not survive deleting its own radio. - #expect(manager.lastConnectedDeviceID == nil) - } + // The auto-reconnect / onboarding-resume signal must not survive deleting its own radio. + #expect(manager.lastConnectedDeviceID == nil) + } - @Test("deleteDevice preserves the persisted connection when removing a different radio") - func deleteDevicePreservesOtherLastConnectedSignal() async throws { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) + @Test + func `deleteDevice preserves the persisted connection when removing a different radio`() async throws { + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) - let lastConnected = UUID() - manager.persistConnection(deviceID: lastConnected, radioID: UUID(), deviceName: "Radio") + let lastConnected = UUID() + manager.persistConnection(deviceID: lastConnected, radioID: UUID(), deviceName: "Radio") - try await manager.deleteDevice(id: UUID()) + try await manager.deleteDevice(id: UUID()) - #expect(manager.lastConnectedDeviceID == lastConnected) - } + #expect(manager.lastConnectedDeviceID == lastConnected) + } - @Test("forgetDevice clears the persisted connection when removing the last-connected radio") - func forgetDeviceClearsLastConnectedSignal() async throws { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) + @Test + func `forgetDevice clears the persisted connection when removing the last-connected radio`() async throws { + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - manager.persistConnection(deviceID: deviceID, radioID: UUID(), deviceName: "Radio") - #expect(manager.lastConnectedDeviceID == deviceID) + let deviceID = UUID() + manager.persistConnection(deviceID: deviceID, radioID: UUID(), deviceName: "Radio") + #expect(manager.lastConnectedDeviceID == deviceID) - await manager.forgetDevice(id: deviceID) + await manager.forgetDevice(id: deviceID) - // The auto-reconnect / onboarding-resume signal must not survive forgetting its own radio. - #expect(manager.lastConnectedDeviceID == nil) - } + // The auto-reconnect / onboarding-resume signal must not survive forgetting its own radio. + #expect(manager.lastConnectedDeviceID == nil) + } - @Test("forgetDevice preserves the persisted connection when removing a different radio") - func forgetDevicePreservesOtherLastConnectedSignal() async throws { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) + @Test + func `forgetDevice preserves the persisted connection when removing a different radio`() async throws { + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) - let lastConnected = UUID() - manager.persistConnection(deviceID: lastConnected, radioID: UUID(), deviceName: "Radio") + let lastConnected = UUID() + manager.persistConnection(deviceID: lastConnected, radioID: UUID(), deviceName: "Radio") - await manager.forgetDevice(id: UUID()) + await manager.forgetDevice(id: UUID()) - #expect(manager.lastConnectedDeviceID == lastConnected) - } + #expect(manager.lastConnectedDeviceID == lastConnected) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPromotionTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPromotionTests.swift index d6d45b5e..3fc8256d 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPromotionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPromotionTests.swift @@ -1,246 +1,245 @@ import Foundation -import Testing -import MeshCoreTestSupport @testable import MC1Services +import MeshCoreTestSupport +import Testing @Suite("ConnectionManager Promotion Tests") @MainActor struct ConnectionManagerPromotionTests { - - private func makeTestServices() async throws -> ServiceContainer { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - return try await ServiceContainer.forTesting(session: session) - } - - /// Sets up manager state for promotion tests. Sets session and connectedDevice - /// so the .ready invariant can pass when promotion succeeds. - private func setupForPromotion( - manager: ConnectionManager, - services: ServiceContainer, - connectionState: DeviceConnectionState = .connected, - connectionIntent: ConnectionIntent = .wantsConnection() - ) { - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - manager.setTestState( - connectionState: connectionState, - services: services, - session: session, - connectedDevice: DeviceDTO.testDevice(), - connectionIntent: connectionIntent - ) - } - - // MARK: - Suppression: services replaced - - @Test("promoteToReady suppressed when services replaced during sync") - func promoteSuppressedOnServicesReplacement() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let originalServices = try await makeTestServices() - let replacementServices = try await makeTestServices() - - setupForPromotion(manager: manager, services: originalServices) - - // Simulate: disconnect + new connection replaced services - manager.setTestState(services: replacementServices) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: originalServices, - transportType: .bluetooth - ) - - #expect(!promoted) - #expect(manager.connectionState == .connected) - } - - // MARK: - Suppression: services nil (disconnected) - - @Test("promoteToReady suppressed when services nil (disconnected)") - func promoteSuppressedOnDisconnect() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let originalServices = try await makeTestServices() - - manager.setTestState( - connectionState: .disconnected, - services: .some(nil), - connectionIntent: .wantsConnection() - ) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: originalServices, - transportType: .bluetooth - ) - - #expect(!promoted) - #expect(manager.connectionState == .disconnected) - } - - // MARK: - Sync failure sets .syncing (resync loop continues from there) - - @Test("promoteToReady sets .syncing when sync failed") - func promoteSetsSyncingOnFailure() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - - setupForPromotion(manager: manager, services: services) - - let promoted = await manager.promoteToReady( - syncSucceeded: false, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(promoted) - #expect(manager.connectionState == .syncing) - } - - // MARK: - Sync failure skips onDeviceSynced - - @Test("promoteToReady skips onDeviceSynced when sync failed") - func syncFailureSkipsPostSyncWork() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - var onDeviceSyncedCalled = false - - setupForPromotion(manager: manager, services: services) - manager.onDeviceSynced = { onDeviceSyncedCalled = true } - - let promoted = await manager.promoteToReady( - syncSucceeded: false, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(promoted, "Should still promote to .syncing for resync loop") - #expect(!onDeviceSyncedCalled, "onDeviceSynced should be skipped on sync failure") - } - - // MARK: - Sync success sets .ready and fires onDeviceSynced - - @Test("promoteToReady sets .ready when sync succeeded") - func promoteReadyOnSuccess() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - - setupForPromotion(manager: manager, services: services) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(promoted) - #expect(manager.connectionState == .ready) - } - - @Test("promoteToReady calls onDeviceSynced when sync succeeded") - func promoteCallsOnDeviceSyncedOnSuccess() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - var onDeviceSyncedCalled = false - - setupForPromotion(manager: manager, services: services) - manager.onDeviceSynced = { onDeviceSyncedCalled = true } - - await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(onDeviceSyncedCalled) - } - - @Test("promoteToReady does NOT call onDeviceSynced when sync failed") - func promoteSkipsOnDeviceSyncedOnFailure() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - var onDeviceSyncedCalled = false - - setupForPromotion(manager: manager, services: services) - manager.onDeviceSynced = { onDeviceSyncedCalled = true } - - await manager.promoteToReady( - syncSucceeded: false, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(!onDeviceSyncedCalled) - } - - // MARK: - Happy path - - @Test("promoteToReady sets .ready and fires onDeviceSynced on successful sync") - func promoteSucceedsOnHappyPath() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - var onDeviceSyncedCalled = false - - setupForPromotion(manager: manager, services: services) - manager.onDeviceSynced = { onDeviceSyncedCalled = true } - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(promoted) - #expect(manager.connectionState == .ready) - #expect(manager.currentTransportType == .bluetooth) - #expect(onDeviceSyncedCalled, "onDeviceSynced should fire on successful sync") - } - - // MARK: - Suppression: user disconnected - - @Test("promoteToReady suppressed when user disconnected during sync") - func promoteSuppressedOnUserDisconnect() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - - setupForPromotion(manager: manager, services: services, connectionIntent: .userDisconnected) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(!promoted) - #expect(manager.connectionState == .connected) - } - - // MARK: - Additional guard suppresses promotion - - @Test("promoteToReady suppressed when additionalGuard returns false") - func promoteSuppressedByAdditionalGuard() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - - setupForPromotion(manager: manager, services: services) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth, - additionalGuard: { false } - ) - - #expect(!promoted) - #expect(manager.connectionState == .connected) - } - - // MARK: - Post-time-sync re-validation (not unit-testable) - - // promoteToReady re-checks connectionIntent, services identity, and additionalGuard - // after syncDeviceTimeIfNeeded() returns. These guards are structurally identical to - // the pre-await guards tested above. Exercising them requires mutating state during - // the syncDeviceTimeIfNeeded() suspension point, which needs concurrency interleaving - // that unit tests cannot reliably control. Covering this path requires integration - // tests with a real (or delay-injected) session where state can change mid-await. + private func makeTestServices() async throws -> ServiceContainer { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + return try await ServiceContainer.forTesting(session: session) + } + + /// Sets up manager state for promotion tests. Sets session and connectedDevice + /// so the .ready invariant can pass when promotion succeeds. + private func setupForPromotion( + manager: ConnectionManager, + services: ServiceContainer, + connectionState: DeviceConnectionState = .connected, + connectionIntent: ConnectionIntent = .wantsConnection() + ) { + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + manager.setTestState( + connectionState: connectionState, + services: services, + session: session, + connectedDevice: DeviceDTO.testDevice(), + connectionIntent: connectionIntent + ) + } + + // MARK: - Suppression: services replaced + + @Test + func `promoteToReady suppressed when services replaced during sync`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let originalServices = try await makeTestServices() + let replacementServices = try await makeTestServices() + + setupForPromotion(manager: manager, services: originalServices) + + // Simulate: disconnect + new connection replaced services + manager.setTestState(services: replacementServices) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: originalServices, + transportType: .bluetooth + ) + + #expect(!promoted) + #expect(manager.connectionState == .connected) + } + + // MARK: - Suppression: services nil (disconnected) + + @Test + func `promoteToReady suppressed when services nil (disconnected)`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let originalServices = try await makeTestServices() + + manager.setTestState( + connectionState: .disconnected, + services: .some(nil), + connectionIntent: .wantsConnection() + ) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: originalServices, + transportType: .bluetooth + ) + + #expect(!promoted) + #expect(manager.connectionState == .disconnected) + } + + // MARK: - Sync failure sets .syncing (resync loop continues from there) + + @Test + func `promoteToReady sets .syncing when sync failed`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + + setupForPromotion(manager: manager, services: services) + + let promoted = await manager.promoteToReady( + syncSucceeded: false, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(promoted) + #expect(manager.connectionState == .syncing) + } + + // MARK: - Sync failure skips onDeviceSynced + + @Test + func `promoteToReady skips onDeviceSynced when sync failed`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + var onDeviceSyncedCalled = false + + setupForPromotion(manager: manager, services: services) + manager.onDeviceSynced = { onDeviceSyncedCalled = true } + + let promoted = await manager.promoteToReady( + syncSucceeded: false, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(promoted, "Should still promote to .syncing for resync loop") + #expect(!onDeviceSyncedCalled, "onDeviceSynced should be skipped on sync failure") + } + + // MARK: - Sync success sets .ready and fires onDeviceSynced + + @Test + func `promoteToReady sets .ready when sync succeeded`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + + setupForPromotion(manager: manager, services: services) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(promoted) + #expect(manager.connectionState == .ready) + } + + @Test + func `promoteToReady calls onDeviceSynced when sync succeeded`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + var onDeviceSyncedCalled = false + + setupForPromotion(manager: manager, services: services) + manager.onDeviceSynced = { onDeviceSyncedCalled = true } + + await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(onDeviceSyncedCalled) + } + + @Test + func `promoteToReady does NOT call onDeviceSynced when sync failed`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + var onDeviceSyncedCalled = false + + setupForPromotion(manager: manager, services: services) + manager.onDeviceSynced = { onDeviceSyncedCalled = true } + + await manager.promoteToReady( + syncSucceeded: false, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(!onDeviceSyncedCalled) + } + + // MARK: - Happy path + + @Test + func `promoteToReady sets .ready and fires onDeviceSynced on successful sync`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + var onDeviceSyncedCalled = false + + setupForPromotion(manager: manager, services: services) + manager.onDeviceSynced = { onDeviceSyncedCalled = true } + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(promoted) + #expect(manager.connectionState == .ready) + #expect(manager.currentTransportType == .bluetooth) + #expect(onDeviceSyncedCalled, "onDeviceSynced should fire on successful sync") + } + + // MARK: - Suppression: user disconnected + + @Test + func `promoteToReady suppressed when user disconnected during sync`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + + setupForPromotion(manager: manager, services: services, connectionIntent: .userDisconnected) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(!promoted) + #expect(manager.connectionState == .connected) + } + + // MARK: - Additional guard suppresses promotion + + @Test + func `promoteToReady suppressed when additionalGuard returns false`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + + setupForPromotion(manager: manager, services: services) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth, + additionalGuard: { false } + ) + + #expect(!promoted) + #expect(manager.connectionState == .connected) + } + + // MARK: - Post-time-sync re-validation (not unit-testable) + + // promoteToReady re-checks connectionIntent, services identity, and additionalGuard + // after syncDeviceTimeIfNeeded() returns. These guards are structurally identical to + // the pre-await guards tested above. Exercising them requires mutating state during + // the syncDeviceTimeIfNeeded() suspension point, which needs concurrency interleaving + // that unit tests cannot reliably control. Covering this path requires integration + // tests with a real (or delay-injected) session where state can change mid-await. } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift new file mode 100644 index 00000000..871d1fcd --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift @@ -0,0 +1,241 @@ +import Foundation +@testable import MC1Services +import Testing + +/// Tests for the reconnect-abandonment paths: while `connectionIntent.wantsConnection` +/// and the BLE state machine is auto-reconnecting, no teardown path may disconnect the +/// transport (that cancels the OS pending connect, which never expires on its own), and +/// every UI-level abandonment must leave the reconnection watchdog running. +@Suite("ConnectionManager Reconnect Abandonment Tests") +@MainActor +struct ConnectionManagerReconnectAbandonmentTests { + // MARK: - handleReconnectionFailure + + @Test + func `reconnection failure tears the transport down and restarts the watchdog`() async throws { + // Rebuild failure is a full teardown even mid-auto-reconnect: the only + // path reaching it with a live pending connect has already lost its + // reconnect-cycle claim, so a preserved link could never complete. + // Recovery is the watchdog's fresh connect instead. + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + + await env.stateMachine.setStubbedIsAutoReconnecting(true) + env.manager.setTestState( + connectionState: .connecting, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + await env.manager.handleReconnectionFailure() + + #expect(await env.transport.disconnectInvocations == 1) + #expect(env.manager.connectionState == .disconnected) + #expect(env.manager.isReconnectionWatchdogRunning) + } + + @Test + func `reconnection failure without auto-reconnect still disconnects the transport`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + + await env.stateMachine.setStubbedIsAutoReconnecting(false) + env.manager.setTestState( + connectionState: .connecting, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + await env.manager.handleReconnectionFailure() + + #expect(await env.transport.disconnectInvocations == 1) + #expect(env.manager.connectionState == .disconnected) + #expect(env.manager.isReconnectionWatchdogRunning) + } + + @Test + func `reconnection failure after user disconnect leaves the watchdog stopped`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + + await env.stateMachine.setStubbedIsAutoReconnecting(false) + env.manager.setTestState( + connectionState: .connecting, + currentTransportType: .bluetooth, + connectionIntent: .userDisconnected + ) + + await env.manager.handleReconnectionFailure() + + #expect(!env.manager.isReconnectionWatchdogRunning) + } + + // MARK: - notifyConnectionLost + + @Test + func `connection-lost notification while wanting connection arms the watchdog`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: TransportType?.none, + connectionIntent: .wantsConnection() + ) + + await manager.notifyConnectionLost() + + #expect(manager.isReconnectionWatchdogRunning) + } + + @Test + func `connection-lost notification after user disconnect does not arm the watchdog`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: TransportType?.none, + connectionIntent: .userDisconnected + ) + + await manager.notifyConnectionLost() + + #expect(!manager.isReconnectionWatchdogRunning) + } + + @Test + func `connection-lost notification on WiFi transport does not arm the BLE watchdog`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .wifi, + connectionIntent: .wantsConnection() + ) + + await manager.notifyConnectionLost() + + #expect(!manager.isReconnectionWatchdogRunning) + } + + // MARK: - connect(to:forceReconnect:) break-glass + + @Test + func `forceReconnect connect for an abandoned reconnect cycle tears down the pending connect and attempts fresh`() async throws { + // The pill already flipped to "Disconnected" (UI abandonment) but the coordinator's + // reconnect cycle survives because the OS pending connect never resolved. A forced + // tap must not re-defer into the same stuck wait. + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let deviceID = UUID() + + env.manager.reconnectionCoordinator.restartTimeout(deviceID: deviceID) + env.manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + // The mock pairing registry has no paired accessories, so the fresh attempt + // fails fast with the real "device not found" error instead of hanging - proof + // the call reached a genuine connect attempt rather than deferring. + await #expect(throws: ConnectionError.self) { + try await env.manager.connect(to: deviceID, forceReconnect: true) + } + + #expect(await env.transport.disconnectInvocations == 1) + #expect(env.manager.reconnectionCoordinator.reconnectingDeviceID == nil) + #expect(env.manager.connectionState == .disconnected) + } + + @Test + func `forceReconnect connect while still connecting keeps deferring`() async throws { + // The pill still shows "Connecting..." - the first tap case. Must keep restarting + // the timeout rather than tearing down a reconnect the UI hasn't abandoned yet. + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let deviceID = UUID() + + env.manager.reconnectionCoordinator.restartTimeout(deviceID: deviceID) + env.manager.setTestState( + connectionState: .connecting, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + try await env.manager.connect(to: deviceID, forceReconnect: true) + + #expect(await env.transport.disconnectInvocations == 0) + #expect(env.manager.reconnectionCoordinator.reconnectingDeviceID == deviceID) + #expect(env.manager.connectionState == .connecting) + } + + @Test + func `non-force connect for an abandoned reconnect cycle still defers`() async throws { + // Background/unattended reconnects must keep the full indefinite wait; only a + // user-initiated (forceReconnect) tap gets the break-glass path. + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let deviceID = UUID() + + env.manager.reconnectionCoordinator.restartTimeout(deviceID: deviceID) + env.manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + try await env.manager.connect(to: deviceID, forceReconnect: false) + + #expect(await env.transport.disconnectInvocations == 0) + #expect(env.manager.reconnectionCoordinator.reconnectingDeviceID == deviceID) + #expect(env.manager.connectionState == .disconnected) + } + + @Test + func `forceReconnect connect while a session rebuild is in flight for the device defers instead of breaking glass`() async throws { + // A live session rebuild is progress, not a stuck wait: tearing down the + // transport under it would destroy a reconnect that is about to succeed. + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let deviceID = UUID() + + env.manager.reconnectionCoordinator.restartTimeout(deviceID: deviceID) + env.manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + sessionRebuildDeviceID: deviceID + ) + + try await env.manager.connect(to: deviceID, forceReconnect: true) + + #expect(await env.transport.disconnectInvocations == 0) + #expect(env.manager.reconnectionCoordinator.reconnectingDeviceID == deviceID) + #expect(env.manager.connectionState == .disconnected) + } + + @Test + func `forceReconnect connect while transport is stuck auto-reconnecting to the same device breaks glass`() async throws { + // Covers the second deferral branch: the coordinator's cycle was already cleared + // (e.g. by UI timeout) but the state machine itself is still mid auto-reconnect + // for this same device. + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let deviceID = UUID() + + await env.stateMachine.setStubbedIsAutoReconnecting(true) + await env.stateMachine.setStubbedConnectedDeviceID(deviceID) + env.manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + await #expect(throws: ConnectionError.self) { + try await env.manager.connect(to: deviceID, forceReconnect: true) + } + + #expect(await env.transport.disconnectInvocations == 1) + #expect(env.manager.connectionState == .disconnected) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerResyncLoopTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerResyncLoopTests.swift index 2cb27862..3cdba1da 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerResyncLoopTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerResyncLoopTests.swift @@ -1,278 +1,366 @@ import Foundation -import Testing +@testable import MC1Services import MeshCore import MeshCoreTestSupport -@testable import MC1Services +import Testing @Suite("ConnectionManager Resync Loop Tests") @MainActor struct ConnectionManagerResyncLoopTests { - - /// Creates a manager + services where performResync fails immediately - /// (transport not connected → syncContacts throws .notConnected). - private func makeResyncTestHarness(deviceID: UUID) async throws -> ( - manager: ConnectionManager, - services: ServiceContainer, - session: MeshCoreSession - ) { - let (manager, _) = try ConnectionManager.createForTesting() - let transport = SimulatorMockTransport() - // Don't connect transport — commands fail immediately with .notConnected - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 0.5, clientIdentifier: "ResyncTest") - ) - let services = try await ServiceContainer.forTesting(session: session) - - // Insert a device so fetchDevice doesn't fail inside performFullSync - let device = DeviceDTO.testDevice(id: deviceID) - try await services.dataStore.saveDevice(device) - - // Set manager to .syncing with .wantsConnection — the resync loop's guard requires .isOperational - manager.setTestState( - connectionState: .syncing, - services: services, - session: session, - connectedDevice: DeviceDTO.testDevice(id: deviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - return (manager, services, session) + /// Creates a manager + services where performResync fails immediately + /// (transport not connected → syncContacts throws .notConnected). + private func makeResyncTestHarness(deviceID: UUID) async throws -> ( + manager: ConnectionManager, + services: ServiceContainer, + session: MeshCoreSession + ) { + let (manager, _) = try ConnectionManager.createForTesting() + let transport = SimulatorMockTransport() + // Don't connect transport — commands fail immediately with .notConnected + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.5, clientIdentifier: "ResyncTest") + ) + let services = try await ServiceContainer.forTesting(session: session) + + // Insert a device so fetchDevice doesn't fail inside performFullSync + let device = DeviceDTO.testDevice(id: deviceID) + try await services.dataStore.saveDevice(device) + + // Set manager to .syncing with .wantsConnection — the resync loop's guard requires .isOperational + manager.setTestState( + connectionState: .syncing, + services: services, + session: session, + connectedDevice: DeviceDTO.testDevice(id: deviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + return (manager, services, session) + } + + // MARK: - Cancellation + + @Test + func `Cancelling resync loop closes the activity bracket with succeeded=false`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + + let startedTracker = CallTracker() + let succeededValues = ValueTracker() + + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + manager.startResyncLoop(radioID: deviceID, services: services) + + // Wait for the outer bracket to open + try await waitUntil("beginResyncActivity should fire") { + startedTracker.wasCalled } - // MARK: - Cancellation + // Cancel the loop + manager.cancelResyncLoop() - @Test("Cancelling resync loop closes the activity bracket with succeeded=false") - func cancellationClosesBracket() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + // Wait for the catch-all to close the bracket + try await waitUntil("endResyncActivity should fire after cancellation") { + !succeededValues.values.isEmpty + } - let startedTracker = CallTracker() - let succeededValues = ValueTracker() + // The catch-all should report failure + let lastSucceeded = succeededValues.values.last + #expect(lastSucceeded == false, "Cancelled resync should report succeeded=false") + } - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + // MARK: - Guard Exit - manager.startResyncLoop(radioID: deviceID, services: services) + @Test + func `Resync loop exits and closes bracket when connectionIntent changes`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - // Wait for the outer bracket to open - try await waitUntil("beginResyncActivity should fire") { - startedTracker.wasCalled - } + let startedTracker = CallTracker() + let succeededValues = ValueTracker() - // Cancel the loop - manager.cancelResyncLoop() + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - // Wait for the catch-all to close the bracket - try await waitUntil("endResyncActivity should fire after cancellation") { - !succeededValues.values.isEmpty - } + manager.startResyncLoop(radioID: deviceID, services: services) - // The catch-all should report failure - let lastSucceeded = succeededValues.values.last - #expect(lastSucceeded == false, "Cancelled resync should report succeeded=false") + // Wait for the outer bracket to open + try await waitUntil("beginResyncActivity should fire") { + startedTracker.wasCalled } - // MARK: - Guard Exit + // Change intent while the loop is sleeping between retries. + // The MainActor is free during Task.sleep, so this runs immediately. + manager.setTestState(connectionIntent: .userDisconnected) + + // The guard at the top of the while loop will fail after the sleep, + // causing a break → catch-all fires endResyncActivity(succeeded: false). + try await waitUntil(timeout: .seconds(5), "endResyncActivity should fire after guard exit") { + !succeededValues.values.isEmpty + } - @Test("Resync loop exits and closes bracket when connectionIntent changes") - func guardExitClosesBracket() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + let lastSucceeded = succeededValues.values.last + #expect(lastSucceeded == false, "Guard exit should report succeeded=false") + } - let startedTracker = CallTracker() - let succeededValues = ValueTracker() + // MARK: - Max Attempts - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + @Test( + .timeLimit(.minutes(1)) + ) + func `Max resync attempts triggers disconnect and onResyncFailed`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - manager.startResyncLoop(radioID: deviceID, services: services) + let resyncFailedTracker = CallTracker() + manager.onResyncFailed = { + resyncFailedTracker.markCalled() + } - // Wait for the outer bracket to open - try await waitUntil("beginResyncActivity should fire") { - startedTracker.wasCalled - } + let succeededValues = ValueTracker() - // Change intent while the loop is sleeping between retries. - // The MainActor is free during Task.sleep, so this runs immediately. - manager.setTestState(connectionIntent: .userDisconnected) + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - // The guard at the top of the while loop will fail after the sleep, - // causing a break → catch-all fires endResyncActivity(succeeded: false). - try await waitUntil(timeout: .seconds(5), "endResyncActivity should fire after guard exit") { - !succeededValues.values.isEmpty - } + manager.startResyncLoop(radioID: deviceID, services: services) - let lastSucceeded = succeededValues.values.last - #expect(lastSucceeded == false, "Guard exit should report succeeded=false") + // Wait for max attempts to be exhausted (3 attempts × 2s intervals) + try await waitUntil(timeout: .seconds(15), "onResyncFailed should fire after max attempts") { + resyncFailedTracker.wasCalled } - // MARK: - Max Attempts + // Manager should have disconnected + #expect(manager.connectionState == .disconnected) - @Test("Max resync attempts triggers disconnect and onResyncFailed", - .timeLimit(.minutes(1))) - func maxAttemptsTriggersDisconnect() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + // The endResyncActivity(succeeded: false) should have been called + // (once for each inner performFullSync failure + once for the outer bracket) + let finalValues = succeededValues.values + #expect(finalValues.last == false, "Max-attempts bracket should close with succeeded=false") + #expect(!finalValues.contains(true), "No resync iteration should report success") + } - let resyncFailedTracker = CallTracker() - manager.onResyncFailed = { - resyncFailedTracker.markCalled() - } + // MARK: - Bracket Counting - let succeededValues = ValueTracker() + @Test( + .timeLimit(.minutes(1)) + ) + func `Each resync attempt opens and closes an inner bracket`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + let startCount = CallTracker() + let endCount = CallTracker() - manager.startResyncLoop(radioID: deviceID, services: services) + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: { startCount.markCalled() }, + onEnded: { _ in endCount.markCalled() }, + onPhaseChanged: { _ in } + ) - // Wait for max attempts to be exhausted (3 attempts × 2s intervals) - try await waitUntil(timeout: .seconds(15), "onResyncFailed should fire after max attempts") { - resyncFailedTracker.wasCalled - } + let resyncFailedTracker = CallTracker() + manager.onResyncFailed = { resyncFailedTracker.markCalled() } - // Manager should have disconnected - #expect(manager.connectionState == .disconnected) + manager.startResyncLoop(radioID: deviceID, services: services) - // The endResyncActivity(succeeded: false) should have been called - // (once for each inner performFullSync failure + once for the outer bracket) - let finalValues = succeededValues.values - #expect(finalValues.last == false, "Max-attempts bracket should close with succeeded=false") - #expect(!finalValues.contains(true), "No resync iteration should report success") + // Wait for loop to exhaust + try await waitUntil(timeout: .seconds(15), "resync loop should exhaust") { + resyncFailedTracker.wasCalled } - // MARK: - Bracket Counting + // 1 outer start + 3 inner starts = 4 + #expect(startCount.callCount == 4, "Expected 1 outer + 3 inner activity starts, got \(startCount.callCount)") + // 3 inner ends + 1 outer end = 4 + #expect(endCount.callCount == 4, "Expected 3 inner + 1 outer activity ends, got \(endCount.callCount)") + } - @Test("Each resync attempt opens and closes an inner bracket", - .timeLimit(.minutes(1))) - func innerBracketPerAttempt() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + // MARK: - Resync Success Promotion - let startCount = CallTracker() - let endCount = CallTracker() + @Test( + .timeLimit(.minutes(1)) + ) + func `Resync success promotes .syncing → .ready`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { startCount.markCalled() }, - onEnded: { _ in endCount.markCalled() }, - onPhaseChanged: { _ in } - ) + // Override performResync to succeed + await services.syncCoordinator.setPerformResyncOverride { _, _ in true } - let resyncFailedTracker = CallTracker() - manager.onResyncFailed = { resyncFailedTracker.markCalled() } + let succeededValues = ValueTracker() + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - manager.startResyncLoop(radioID: deviceID, services: services) + manager.startResyncLoop(radioID: deviceID, services: services) - // Wait for loop to exhaust - try await waitUntil(timeout: .seconds(15), "resync loop should exhaust") { - resyncFailedTracker.wasCalled - } - - // 1 outer start + 3 inner starts = 4 - #expect(startCount.callCount == 4, "Expected 1 outer + 3 inner activity starts, got \(startCount.callCount)") - // 3 inner ends + 1 outer end = 4 - #expect(endCount.callCount == 4, "Expected 3 inner + 1 outer activity ends, got \(endCount.callCount)") + try await waitUntil(timeout: .seconds(10), "endResyncActivity should fire with success") { + succeededValues.values.contains(true) } - // MARK: - Resync Success Promotion + #expect(manager.connectionState == .ready) + } - @Test("Resync success promotes .syncing → .ready", - .timeLimit(.minutes(1))) - func resyncSuccessPromotesToReady() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + @Test( + .timeLimit(.minutes(1)) + ) + func `Resync success calls onDeviceSynced`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - // Override performResync to succeed - await services.syncCoordinator.setPerformResyncOverride { _, _ in true } + // Override performResync to succeed + await services.syncCoordinator.setPerformResyncOverride { _, _ in true } - let succeededValues = ValueTracker() - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + let onDeviceSyncedTracker = CallTracker() + manager.onDeviceSynced = { onDeviceSyncedTracker.markCalled() } - manager.startResyncLoop(radioID: deviceID, services: services) + let succeededValues = ValueTracker() + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - try await waitUntil(timeout: .seconds(10), "endResyncActivity should fire with success") { - succeededValues.values.contains(true) - } + manager.startResyncLoop(radioID: deviceID, services: services) - #expect(manager.connectionState == .ready) + try await waitUntil(timeout: .seconds(10), "onDeviceSynced should fire after resync success") { + onDeviceSyncedTracker.wasCalled } - @Test("Resync success calls onDeviceSynced", - .timeLimit(.minutes(1))) - func resyncSuccessCallsOnDeviceSynced() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + #expect(onDeviceSyncedTracker.wasCalled) + } - // Override performResync to succeed - await services.syncCoordinator.setPerformResyncOverride { _, _ in true } + // MARK: - Disconnect During Syncing - let onDeviceSyncedTracker = CallTracker() - manager.onDeviceSynced = { onDeviceSyncedTracker.markCalled() } + @Test( + .timeLimit(.minutes(1)) + ) + func `Disconnect during .syncing transitions to .disconnected and closes bracket`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - let succeededValues = ValueTracker() - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + let startedTracker = CallTracker() + let succeededValues = ValueTracker() - manager.startResyncLoop(radioID: deviceID, services: services) + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - try await waitUntil(timeout: .seconds(10), "onDeviceSynced should fire after resync success") { - onDeviceSyncedTracker.wasCalled - } + manager.startResyncLoop(radioID: deviceID, services: services) - #expect(onDeviceSyncedTracker.wasCalled) + // Wait for the outer bracket to open + try await waitUntil("beginResyncActivity should fire") { + startedTracker.wasCalled } - // MARK: - Disconnect During Syncing + // Disconnect while in .syncing + await manager.disconnect(reason: .userInitiated) + + // Wait for bracket to close + try await waitUntil("endResyncActivity should fire after disconnect") { + !succeededValues.values.isEmpty + } - @Test("Disconnect during .syncing transitions to .disconnected and closes bracket", - .timeLimit(.minutes(1))) - func disconnectDuringSyncingClosesBracket() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + #expect(manager.connectionState == .disconnected) + #expect(succeededValues.values.last == false) + } + + // MARK: - Superseded Loop Isolation + + @Test( + .timeLimit(.minutes(1)) + ) + func `Superseded resync loop cannot disconnect a healthy successor connection`() async throws { + let deviceID = UUID() + let (manager, staleServices, _) = try await makeResyncTestHarness(deviceID: deviceID) + + // Count resync attempts against the cycle-N container. A loop orphaned by a + // later reconnect must never drive resync against the replaced container. + let staleResyncCalls = CallTracker() + await staleServices.syncCoordinator.setPerformResyncOverride { _, _ in + staleResyncCalls.markCalled() + return false + } - let startedTracker = CallTracker() - let succeededValues = ValueTracker() + let onResyncFailedTracker = CallTracker() + manager.onResyncFailed = { onResyncFailedTracker.markCalled() } + + // Install the resync loop bound to the cycle-N container. + manager.startResyncLoop(radioID: deviceID, services: staleServices) + + // Bring up a healthy successor container (cycle N+2) and install it as the + // live connection without cancelling the cycle-N loop, reproducing a + // reconnect that superseded the loop's container. + let successorSession = MeshCoreSession( + transport: SimulatorMockTransport(), + configuration: SessionConfiguration(defaultTimeout: 0.5, clientIdentifier: "SuccessorResyncTest") + ) + let successorServices = try await ServiceContainer.forTesting(session: successorSession) + manager.setTestState( + connectionState: .ready, + services: successorServices, + session: successorSession + ) + + // The orphaned loop wakes after a resync interval; its services-identity + // fence must break it before performResync and before the exhaustion + // branch that would disconnect. The timeout is generous enough that with + // the fence absent the loop would exhaust and tear the successor down, + // making the assertions below fail rather than hang. + try await waitUntil(timeout: .seconds(12), "orphaned resync loop should stop") { + manager.resyncTask == nil + } - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + #expect(staleResyncCalls.callCount == 0, "Superseded loop must not resync against the replaced container") + #expect(!onResyncFailedTracker.wasCalled, "Superseded loop must not trigger resync-failure teardown") + #expect(manager.connectionState == .ready, "Healthy successor connection must remain operational") + #expect(manager.services === successorServices, "Healthy successor container must stay installed") + } + + @Test( + .timeLimit(.minutes(1)) + ) + func `Cancelling the resync loop stops further resync attempts`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + + let resyncCalls = CallTracker() + await services.syncCoordinator.setPerformResyncOverride { _, _ in + resyncCalls.markCalled() + return false + } - manager.startResyncLoop(radioID: deviceID, services: services) + manager.startResyncLoop(radioID: deviceID, services: services) - // Wait for the outer bracket to open - try await waitUntil("beginResyncActivity should fire") { - startedTracker.wasCalled - } + try await waitUntil(timeout: .seconds(6), "first resync attempt should run") { + resyncCalls.wasCalled + } - // Disconnect while in .syncing - await manager.disconnect(reason: .userInitiated) + manager.cancelResyncLoop() + let countAtCancel = resyncCalls.callCount + #expect(manager.resyncTask == nil, "Cancellation must clear the stored resync task") - // Wait for bracket to close - try await waitUntil("endResyncActivity should fire after disconnect") { - !succeededValues.values.isEmpty - } + // A full interval beyond cancellation proves the loop made no further attempts. + try await Task.sleep(for: Self.postCancelObservationWindow) + #expect(resyncCalls.callCount == countAtCancel, "Cancelled loop must not perform further resync attempts") + } - #expect(manager.connectionState == .disconnected) - #expect(succeededValues.values.last == false) - } + /// Waited after cancelling a loop to confirm no further attempt fires; longer + /// than one `resyncInterval` so a still-running loop would have woken. + private static let postCancelObservationWindow: Duration = .seconds(3) } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerRetryBudgetTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerRetryBudgetTests.swift index 632210f8..dee9a30f 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerRetryBudgetTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerRetryBudgetTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import MC1Services +import Testing /// Covers the platform-aware BLE connect retry budget in `connect(to:forceReconnect:)`. /// @@ -11,127 +11,142 @@ import Testing @Suite("ConnectionManager Retry Budget") @MainActor struct ConnectionManagerRetryBudgetTests { + /// Minimal `DevicePairingService` with a configurable `hasSystemPairingRegistry` and, for the + /// registration-guard case, a configurable `isSessionActive`/`isDeviceConnectable`. The + /// defaults (`sessionActive: false`, `deviceConnectable: true`) make `connect(to:)` skip its + /// registration guard and reach the retry loop on either platform. Discovery is never + /// exercised by these tests. + @MainActor + private final class StubPairingService: DevicePairingService { + var delegate: (any DevicePairingDelegate)? + let hasSystemPairingRegistry: Bool + let sessionActive: Bool + let deviceConnectable: Bool + + init(hasSystemPairingRegistry: Bool, sessionActive: Bool = false, deviceConnectable: Bool = true) { + self.hasSystemPairingRegistry = hasSystemPairingRegistry + self.sessionActive = sessionActive + self.deviceConnectable = deviceConnectable + } + + var isSessionActive: Bool { + sessionActive + } + + var registeredDeviceCount: Int { + 0 + } + + var supportsSystemRename: Bool { + false + } + + func activate() async throws {} + func discoverDevice() async throws -> UUID { + throw CancellationError() + } - /// Minimal `DevicePairingService` with a configurable `hasSystemPairingRegistry` and, for the - /// registration-guard case, a configurable `isSessionActive`/`isDeviceConnectable`. The - /// defaults (`sessionActive: false`, `deviceConnectable: true`) make `connect(to:)` skip its - /// registration guard and reach the retry loop on either platform. Discovery is never - /// exercised by these tests. - @MainActor - private final class StubPairingService: DevicePairingService { - var delegate: (any DevicePairingDelegate)? - let hasSystemPairingRegistry: Bool - let sessionActive: Bool - let deviceConnectable: Bool - - init(hasSystemPairingRegistry: Bool, sessionActive: Bool = false, deviceConnectable: Bool = true) { - self.hasSystemPairingRegistry = hasSystemPairingRegistry - self.sessionActive = sessionActive - self.deviceConnectable = deviceConnectable - } - - var isSessionActive: Bool { sessionActive } - var registeredDeviceCount: Int { 0 } - var supportsSystemRename: Bool { false } - - func activate() async throws {} - func discoverDevice() async throws -> UUID { throw CancellationError() } - func isDeviceConnectable(_ id: UUID) -> Bool { deviceConnectable } - func registeredDeviceInfos() -> [(id: UUID, name: String)] { [] } - func removeDevice(_ id: UUID) async throws {} - func renameDevice(_ id: UUID) async throws {} - func clearStaleRegistrations() async {} + func isDeviceConnectable(_ id: UUID) -> Bool { + deviceConnectable } - /// `expectsBoundedBudget` is the hand-specified intent for each scenario (true only for a - /// macOS user-initiated tap), kept independent of the production formula so the test catches - /// a flipped boolean or a dropped qualifier rather than mirroring whatever the code computes. - struct RetryBudgetCase: Sendable, CustomTestStringConvertible { - let hasSystemPairingRegistry: Bool - let forceReconnect: Bool - let expectsBoundedBudget: Bool - - var testDescription: String { - "registry=\(hasSystemPairingRegistry) force=\(forceReconnect) → bounded=\(expectsBoundedBudget)" - } + func registeredDeviceInfos() -> [(id: UUID, name: String)] { + [] } - @Test( - "connect uses the unverified budget only for a macOS user-initiated tap, the full budget otherwise", - arguments: [ - // macOS user tap on an unreachable radio: bounded. - RetryBudgetCase(hasSystemPairingRegistry: false, forceReconnect: true, expectsBoundedBudget: true), - // macOS background reconnect: full budget, so the forceReconnect qualifier can't be dropped. - RetryBudgetCase(hasSystemPairingRegistry: false, forceReconnect: false, expectsBoundedBudget: false), - // iOS user tap: the registry validates the device, so the full budget always applies. - RetryBudgetCase(hasSystemPairingRegistry: true, forceReconnect: true, expectsBoundedBudget: false) - ] + func removeDevice(_ id: UUID) async throws {} + func renameDevice(_ id: UUID) async throws {} + func clearStaleRegistrations() async {} + } + + /// `expectsBoundedBudget` is the hand-specified intent for each scenario (true only for a + /// macOS user-initiated tap), kept independent of the production formula so the test catches + /// a flipped boolean or a dropped qualifier rather than mirroring whatever the code computes. + struct RetryBudgetCase: CustomTestStringConvertible { + let hasSystemPairingRegistry: Bool + let forceReconnect: Bool + let expectsBoundedBudget: Bool + + var testDescription: String { + "registry=\(hasSystemPairingRegistry) force=\(forceReconnect) → bounded=\(expectsBoundedBudget)" + } + } + + @Test( + arguments: [ + // macOS user tap on an unreachable radio: bounded. + RetryBudgetCase(hasSystemPairingRegistry: false, forceReconnect: true, expectsBoundedBudget: true), + // macOS background reconnect: full budget, so the forceReconnect qualifier can't be dropped. + RetryBudgetCase(hasSystemPairingRegistry: false, forceReconnect: false, expectsBoundedBudget: false), + // iOS user tap: the registry validates the device, so the full budget always applies. + RetryBudgetCase(hasSystemPairingRegistry: true, forceReconnect: true, expectsBoundedBudget: false) + ] + ) + func `connect uses the unverified budget only for a macOS user-initiated tap, the full budget otherwise`(_ testCase: RetryBudgetCase) async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let stateMachine = MockBLEStateMachine() + let transport = MockMeshTransport() + await transport.setConnectError(ConnectionError.connectionFailed("simulated unreachable radio")) + + let manager = ConnectionManager( + modelContainer: container, + defaults: defaults, + stateMachine: stateMachine, + transport: transport, + pairing: StubPairingService(hasSystemPairingRegistry: testCase.hasSystemPairingRegistry) ) - func retryBudgetMatchesPlatformAndIntent(_ testCase: RetryBudgetCase) async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - - let stateMachine = MockBLEStateMachine() - let transport = MockMeshTransport() - await transport.setConnectError(ConnectionError.connectionFailed("simulated unreachable radio")) - - let manager = ConnectionManager( - modelContainer: container, - defaults: defaults, - stateMachine: stateMachine, - transport: transport, - pairing: StubPairingService(hasSystemPairingRegistry: testCase.hasSystemPairingRegistry) - ) - - let deviceID = UUID() - await #expect(throws: Error.self) { - try await manager.connect(to: deviceID, forceReconnect: testCase.forceReconnect) - } - - let expectedAttempts = testCase.expectsBoundedBudget - ? ConnectionManager.unverifiedConnectAttempts - : ConnectionManager.defaultConnectAttempts - let attempts = await transport.connectInvocations.count - #expect(attempts == expectedAttempts) + + let deviceID = UUID() + await #expect(throws: Error.self) { + try await manager.connect(to: deviceID, forceReconnect: testCase.forceReconnect) } - /// When the pairing registry is active and reports the device as not connectable (iOS, where - /// the saved peripheral is no longer registered), `connect(to:)` must fail fast with - /// `deviceNotFound` before spending a single transport attempt — the registry, not the retry - /// budget, is the authority on reachability. - @Test("connect rejects an unconnectable registered device before any transport attempt") - func connectRejectsUnconnectableRegisteredDevice() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - - let stateMachine = MockBLEStateMachine() - let transport = MockMeshTransport() - - let manager = ConnectionManager( - modelContainer: container, - defaults: defaults, - stateMachine: stateMachine, - transport: transport, - pairing: StubPairingService( - hasSystemPairingRegistry: true, - sessionActive: true, - deviceConnectable: false - ) - ) - - let deviceID = UUID() - try await #expect { - try await manager.connect(to: deviceID) - } throws: { error in - guard let e = error as? ConnectionError, case .deviceNotFound = e else { return false } - return true - } - - let attempts = await transport.connectInvocations.count - #expect(attempts == 0) + let expectedAttempts = testCase.expectsBoundedBudget + ? ConnectionManager.unverifiedConnectAttempts + : ConnectionManager.defaultConnectAttempts + let attempts = await transport.connectInvocations.count + #expect(attempts == expectedAttempts) + } + + /// When the pairing registry is active and reports the device as not connectable (iOS, where + /// the saved peripheral is no longer registered), `connect(to:)` must fail fast with + /// `deviceNotFound` before spending a single transport attempt — the registry, not the retry + /// budget, is the authority on reachability. + @Test + func `connect rejects an unconnectable registered device before any transport attempt`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let stateMachine = MockBLEStateMachine() + let transport = MockMeshTransport() + + let manager = ConnectionManager( + modelContainer: container, + defaults: defaults, + stateMachine: stateMachine, + transport: transport, + pairing: StubPairingService( + hasSystemPairingRegistry: true, + sessionActive: true, + deviceConnectable: false + ) + ) + + let deviceID = UUID() + try await #expect { + try await manager.connect(to: deviceID) + } throws: { error in + guard let e = error as? ConnectionError, case .deviceNotFound = e else { return false } + return true } + + let attempts = await transport.connectInvocations.count + #expect(attempts == 0) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerSessionTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerSessionTests.swift index ea19f4d2..6def0b56 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerSessionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerSessionTests.swift @@ -1,196 +1,195 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ConnectionManager Session Tests") @MainActor struct ConnectionManagerSessionTests { + // MARK: - setConnectionState Tests - // MARK: - setConnectionState Tests - - @Test("setConnectionState updates to connected") - func setConnectionStateConnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(connectionIntent: .wantsConnection()) - - manager.setConnectionState(.connected) + @Test + func `setConnectionState updates to connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(connectionIntent: .wantsConnection()) - #expect(manager.connectionState == .connected) - } + manager.setConnectionState(.connected) - @Test("setConnectionState updates to disconnected") - func setConnectionStateDisconnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(connectionState: .connected, connectionIntent: .wantsConnection()) + #expect(manager.connectionState == .connected) + } - manager.setConnectionState(.disconnected) + @Test + func `setConnectionState updates to disconnected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(connectionState: .connected, connectionIntent: .wantsConnection()) - #expect(manager.connectionState == .disconnected) - } + manager.setConnectionState(.disconnected) - // MARK: - setConnectedDevice Tests + #expect(manager.connectionState == .disconnected) + } - @Test("setConnectedDevice sets device") - func setConnectedDeviceSets() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice(nodeName: "TestDevice") + // MARK: - setConnectedDevice Tests - manager.setConnectedDevice(device) + @Test + func `setConnectedDevice sets device`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice(nodeName: "TestDevice") - #expect(manager.connectedDevice?.nodeName == "TestDevice") - } + manager.setConnectedDevice(device) - @Test("setConnectedDevice sets nil") - func setConnectedDeviceSetsNil() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.setConnectedDevice(device) - #expect(manager.connectedDevice != nil) + #expect(manager.connectedDevice?.nodeName == "TestDevice") + } - manager.setConnectedDevice(nil) + @Test + func `setConnectedDevice sets nil`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.setConnectedDevice(device) + #expect(manager.connectedDevice != nil) - #expect(manager.connectedDevice == nil) - } + manager.setConnectedDevice(nil) - // MARK: - isTransportAutoReconnecting Tests + #expect(manager.connectedDevice == nil) + } - @Test("isTransportAutoReconnecting delegates to stateMachine") - func isTransportAutoReconnectingDelegates() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() + // MARK: - isTransportAutoReconnecting Tests - await mock.setStubbedIsAutoReconnecting(false) - var result = await manager.isTransportAutoReconnecting() - #expect(!result) + @Test + func `isTransportAutoReconnecting delegates to stateMachine`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - await mock.setStubbedIsAutoReconnecting(true) - result = await manager.isTransportAutoReconnecting() - #expect(result) - } + await mock.setStubbedIsAutoReconnecting(false) + var result = await manager.isTransportAutoReconnecting() + #expect(!result) - @Test("activeConnectionAttemptDeviceID prefers session rebuild device") - func activeConnectionAttemptDeviceIDPrefersSessionRebuildDevice() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let deviceID = UUID() + await mock.setStubbedIsAutoReconnecting(true) + result = await manager.isTransportAutoReconnecting() + #expect(result) + } - manager.setTestState(sessionRebuildDeviceID: deviceID) + @Test + func `activeConnectionAttemptDeviceID prefers session rebuild device`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let deviceID = UUID() - #expect(manager.activeConnectionAttemptDeviceID == deviceID) - #expect(manager.activeReconnectDeviceID == deviceID) - } + manager.setTestState(sessionRebuildDeviceID: deviceID) + + #expect(manager.activeConnectionAttemptDeviceID == deviceID) + #expect(manager.activeReconnectDeviceID == deviceID) + } - @Test("connect to same device returns early while session rebuild is in progress") - func connectToSameDeviceReturnsEarlyDuringSessionRebuild() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `connect to same device returns early while session rebuild is in progress`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let deviceID = UUID() - manager.testLastConnectedDeviceID = deviceID - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - sessionRebuildDeviceID: deviceID - ) + manager.testLastConnectedDeviceID = deviceID + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + sessionRebuildDeviceID: deviceID + ) - try await manager.connect(to: deviceID) + try await manager.connect(to: deviceID) - #expect(manager.connectionState == .disconnected) - #expect(manager.sessionRebuildDeviceID == deviceID) - #expect(manager.connectionIntent == .wantsConnection()) - #expect(manager.reconnectionCoordinator.reconnectingDeviceID == nil) - } + #expect(manager.connectionState == .disconnected) + #expect(manager.sessionRebuildDeviceID == deviceID) + #expect(manager.connectionIntent == .wantsConnection()) + #expect(manager.reconnectionCoordinator.reconnectingDeviceID == nil) + } - // MARK: - handleReconnectionFailure Tests + // MARK: - handleReconnectionFailure Tests - @Test("handleReconnectionFailure clears state and sets disconnected") - func handleReconnectionFailureClearsState() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.updateDevice(with: DeviceDTO.testDevice()) - manager.setTestState( - connectionState: .connected, - connectionIntent: ConnectionIntent.none - ) + @Test + func `handleReconnectionFailure clears state and sets disconnected`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.updateDevice(with: DeviceDTO.testDevice()) + manager.setTestState( + connectionState: .connected, + connectionIntent: ConnectionIntent.none + ) - await manager.handleReconnectionFailure() + await manager.handleReconnectionFailure() - #expect(manager.connectionState == .disconnected) - #expect(manager.connectedDevice == nil) - #expect(manager.allowedRepeatFreqRanges.isEmpty) - } + #expect(manager.connectionState == .disconnected) + #expect(manager.connectedDevice == nil) + #expect(manager.allowedRepeatFreqRanges.isEmpty) + } - // MARK: - WiFi Health Check Early Returns - - @Test("checkWiFiConnectionHealth returns early when reconnect in progress") - func wifiHealthCheckReturnsEarlyWhenReconnecting() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState( - connectionState: .ready, - currentTransportType: .wifi, - connectionIntent: .wantsConnection() - ) - - manager.wifiReconnectTask = Task { } - - await manager.checkWiFiConnectionHealth() - - #expect(manager.connectionState == .ready) - - manager.wifiReconnectTask?.cancel() - manager.wifiReconnectTask = nil - } - - @Test("checkWiFiConnectionHealth returns early when disconnected without intent") - func wifiHealthCheckReturnsEarlyWhenNoIntent() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState( - connectionState: .disconnected, - currentTransportType: nil, - connectionIntent: ConnectionIntent.none - ) - - await manager.checkWiFiConnectionHealth() - - #expect(manager.connectionState == .disconnected) - } - - @Test("checkWiFiConnectionHealth returns early when transport is bluetooth") - func wifiHealthCheckReturnsEarlyWhenBluetooth() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - await manager.checkWiFiConnectionHealth() - - #expect(manager.connectionState == .ready) - } - - @Test("handleReconnectionFailure notifies onConnectionLost so UI can react") - func handleReconnectionFailureNotifiesConnectionLost() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.updateDevice(with: DeviceDTO.testDevice()) - manager.setTestState( - connectionState: .connected, - connectionIntent: ConnectionIntent.none - ) - - let tracker = ReconnectionFailureLostTracker() - manager.onConnectionLost = { await tracker.markConnectionLost() } - - await manager.handleReconnectionFailure() - - let wasCalled = await tracker.connectionLostCalled - #expect(wasCalled, "onConnectionLost must fire so AppState can update the Live Activity to disconnected") - } + // MARK: - WiFi Health Check Early Returns + + @Test + func `checkWiFiConnectionHealth returns early when reconnect in progress`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState( + connectionState: .ready, + currentTransportType: .wifi, + connectionIntent: .wantsConnection() + ) + + manager.wifiReconnectTask = Task {} + + await manager.checkWiFiConnectionHealth() + + #expect(manager.connectionState == .ready) + + manager.wifiReconnectTask?.cancel() + manager.wifiReconnectTask = nil + } + + @Test + func `checkWiFiConnectionHealth returns early when disconnected without intent`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState( + connectionState: .disconnected, + currentTransportType: nil, + connectionIntent: ConnectionIntent.none + ) + + await manager.checkWiFiConnectionHealth() + + #expect(manager.connectionState == .disconnected) + } + + @Test + func `checkWiFiConnectionHealth returns early when transport is bluetooth`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + await manager.checkWiFiConnectionHealth() + + #expect(manager.connectionState == .ready) + } + + @Test + func `handleReconnectionFailure notifies onConnectionLost so UI can react`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.updateDevice(with: DeviceDTO.testDevice()) + manager.setTestState( + connectionState: .connected, + connectionIntent: ConnectionIntent.none + ) + + let tracker = ReconnectionFailureLostTracker() + manager.onConnectionLost = { await tracker.markConnectionLost() } + + await manager.handleReconnectionFailure() + + let wasCalled = await tracker.connectionLostCalled + #expect(wasCalled, "onConnectionLost must fire so AppState can update the Live Activity to disconnected") + } } // MARK: - Test Helpers private actor ReconnectionFailureLostTracker { - var connectionLostCalled = false + var connectionLostCalled = false - func markConnectionLost() { - connectionLostCalled = true - } + func markConnectionLost() { + connectionLostCalled = true + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ContactDTOPathTests.swift b/MC1Services/Tests/MC1ServicesTests/ContactDTOPathTests.swift index 8396b24e..f1d073c8 100644 --- a/MC1Services/Tests/MC1ServicesTests/ContactDTOPathTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ContactDTOPathTests.swift @@ -1,95 +1,94 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("ContactDTO Path Hops") struct ContactDTOPathTests { - - @Test("Single-byte hops chunk into one (data, hex) pair each") - func singleByteHops() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 1, hopCount: 3), - outPath: Data([0x1A, 0x2B, 0x3C]) - ) - - let hops = contact.pathHops - #expect(hops.map(\.data) == [Data([0x1A]), Data([0x2B]), Data([0x3C])]) - #expect(hops.map(\.hex) == ["1A", "2B", "3C"]) - #expect(contact.pathNodesHex == ["1A", "2B", "3C"]) - } - - @Test("Multi-byte hops chunk by the encoded hash size") - func multiByteHops() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 2, hopCount: 2), - outPath: Data([0x1A, 0x2B, 0x3C, 0x4D]) - ) - - let hops = contact.pathHops - #expect(hops.map(\.data) == [Data([0x1A, 0x2B]), Data([0x3C, 0x4D])]) - #expect(hops.map(\.hex) == ["1A2B", "3C4D"]) - } - - @Test("Hops ignore bytes beyond the encoded path length") - func ignoresTrailingBytes() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 1, hopCount: 2), - outPath: Data([0x1A, 0x2B, 0xFF, 0xFF]) - ) - - #expect(contact.pathHops.map(\.hex) == ["1A", "2B"]) - } - - @Test("A flood-routed contact has no hops") - func floodRoutedHasNoHops() { - let contact = ContactDTO.testContact( - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data([0x1A, 0x2B]) - ) - - #expect(contact.pathHops.isEmpty) - #expect(contact.pathNodesHex.isEmpty) - } - - @Test("Displayed hops use the out-path count when a route is set, ignoring inbound") - func displayedHopsPrefersOutPath() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 1, hopCount: 3), - outPath: Data([0x1A, 0x2B, 0x3C]) - ) - - #expect(contact.displayedHopCount(inboundHopCount: 5) == 3) - } - - @Test("A set out-path of zero hops displays zero, not the inbound fallback") - func displayedHopsOutPathZeroWins() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 1, hopCount: 0) - ) - - #expect(!contact.isFloodRouted) - #expect(contact.displayedHopCount(inboundHopCount: 5) == 0) - } - - @Test("A flood-routed contact falls back to the inbound advert hops") - func displayedHopsFloodFallsBackToInbound() { - let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) - - #expect(contact.displayedHopCount(inboundHopCount: 2) == 2) - } - - @Test("An inbound zero is a real value, not nil") - func displayedHopsInboundZeroIsPresent() { - let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) - - #expect(contact.displayedHopCount(inboundHopCount: 0) == 0) - } - - @Test("Flood-routed with no inbound reception has no displayed hops") - func displayedHopsFloodWithoutInboundIsNil() { - let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) - - #expect(contact.displayedHopCount(inboundHopCount: nil) == nil) - } + @Test + func `Single-byte hops chunk into one (data, hex) pair each`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 1, hopCount: 3), + outPath: Data([0x1A, 0x2B, 0x3C]) + ) + + let hops = contact.pathHops + #expect(hops.map(\.data) == [Data([0x1A]), Data([0x2B]), Data([0x3C])]) + #expect(hops.map(\.hex) == ["1A", "2B", "3C"]) + #expect(contact.pathNodesHex == ["1A", "2B", "3C"]) + } + + @Test + func `Multi-byte hops chunk by the encoded hash size`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 2, hopCount: 2), + outPath: Data([0x1A, 0x2B, 0x3C, 0x4D]) + ) + + let hops = contact.pathHops + #expect(hops.map(\.data) == [Data([0x1A, 0x2B]), Data([0x3C, 0x4D])]) + #expect(hops.map(\.hex) == ["1A2B", "3C4D"]) + } + + @Test + func `Hops ignore bytes beyond the encoded path length`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 1, hopCount: 2), + outPath: Data([0x1A, 0x2B, 0xFF, 0xFF]) + ) + + #expect(contact.pathHops.map(\.hex) == ["1A", "2B"]) + } + + @Test + func `A flood-routed contact has no hops`() { + let contact = ContactDTO.testContact( + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data([0x1A, 0x2B]) + ) + + #expect(contact.pathHops.isEmpty) + #expect(contact.pathNodesHex.isEmpty) + } + + @Test + func `Displayed hops use the out-path count when a route is set, ignoring inbound`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 1, hopCount: 3), + outPath: Data([0x1A, 0x2B, 0x3C]) + ) + + #expect(contact.displayedHopCount(inboundHopCount: 5) == 3) + } + + @Test + func `A set out-path of zero hops displays zero, not the inbound fallback`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 1, hopCount: 0) + ) + + #expect(!contact.isFloodRouted) + #expect(contact.displayedHopCount(inboundHopCount: 5) == 0) + } + + @Test + func `A flood-routed contact falls back to the inbound advert hops`() { + let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) + + #expect(contact.displayedHopCount(inboundHopCount: 2) == 2) + } + + @Test + func `An inbound zero is a real value, not nil`() { + let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) + + #expect(contact.displayedHopCount(inboundHopCount: 0) == 0) + } + + @Test + func `Flood-routed with no inbound reception has no displayed hops`() { + let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) + + #expect(contact.displayedHopCount(inboundHopCount: nil) == nil) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ContactShareUtilitiesTests.swift b/MC1Services/Tests/MC1ServicesTests/ContactShareUtilitiesTests.swift index 7335c2f8..42520896 100644 --- a/MC1Services/Tests/MC1ServicesTests/ContactShareUtilitiesTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ContactShareUtilitiesTests.swift @@ -1,142 +1,143 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("ContactShareUtilities Tests") struct ContactShareUtilitiesTests { - - /// A real 32-byte public key rendered as uppercase hex (64 chars). - static let validHex = "A1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" - - // MARK: - Round-trip - - @Test("formatShare then parseShare round-trips for every contact type", - arguments: [ContactType.chat, .repeater, .room]) - func testRoundTrip(type: ContactType) throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = "AVN2" - - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: type, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - - #expect(result.publicKey == publicKey) - #expect(result.contactType == type) - #expect(result.name == name) - } - - @Test("formatShare emits uppercase hex") - func testFormatEmitsUppercaseHex() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "Node") - #expect(token.contains(Self.validHex)) - #expect(token == "<\(Self.validHex):1:Node>") - } - - // MARK: - Malformed rejection - - @Test("parseShare rejects a 63-char (short) public key") - func testRejectsShortPublicKey() { - let shortHex = String(Self.validHex.dropLast()) - #expect(ContactShareUtilities.parseShare("<\(shortHex):1:Node>") == nil) - } - - @Test("parseShare rejects non-hex characters in the public key") - func testRejectsNonHexPublicKey() { - let nonHex = "G1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" - #expect(ContactShareUtilities.parseShare("<\(nonHex):1:Node>") == nil) - } - - @Test("parseShare rejects a token missing the name field") - func testRejectsMissingName() { - #expect(ContactShareUtilities.parseShare("<\(Self.validHex):1>") == nil) - } - - @Test("parseShare rejects a token missing type and name") - func testRejectsMissingTypeAndName() { - #expect(ContactShareUtilities.parseShare("<\(Self.validHex)>") == nil) - } - - @Test("parseShare rejects an empty name") - func testRejectsEmptyName() { - #expect(ContactShareUtilities.parseShare("<\(Self.validHex):1:>") == nil) - } - - // MARK: - Adversarial type bounds (regression for the trap) - - @Test("parseShare rejects out-of-range and overflowing type values without trapping", - arguments: ["256", "300", "99999999999999999999", "0", "4"]) - func testRejectsAdversarialType(typeDigits: String) { - let token = "<\(Self.validHex):\(typeDigits):x>" - #expect(ContactShareUtilities.parseShare(token) == nil) - } - - // MARK: - Name escaping - - @Test("parseShare round-trips a name containing '>' to its stripped form") - func testNameWithTerminatorStripped() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "AV>N2") - #expect(!token.dropFirst().dropLast().contains(">")) - - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == "AVN2") - } - - @Test("parseShare preserves a colon inside the name") - func testNameWithColonPreserved() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = "Base:Camp:1" - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == name) - } - - @Test("parseShare preserves a newline inside the name") - func testNameWithNewlinePreserved() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = "Line1\nLine2" - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == name) - } - - @Test("parseShare preserves an RTL-override character in the name") - func testNameWithRTLOverridePreserved() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = "Node\u{202E}flip" - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == name) - } - - @Test("parseShare round-trips a 1000-character name") - func testLongNameRoundTrips() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = String(repeating: "n", count: 1000) - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == name) - } - - // MARK: - extractShares - - @Test("extractShares finds multiple tokens amid plain text") - func testExtractMultiple() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let first = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "Alice") - let second = ContactShareUtilities.formatShare(publicKey: publicKey, type: .repeater, name: "Bob") - let text = "Add these: \(first) and also \(second) thanks" - - let results = ContactShareUtilities.extractShares(from: text) - #expect(results.count == 2) - #expect(results[0].name == "Alice") - #expect(results[0].contactType == .chat) - #expect(results[1].name == "Bob") - #expect(results[1].contactType == .repeater) - } - - @Test("extractShares returns empty when there are no tokens") - func testExtractNone() { - #expect(ContactShareUtilities.extractShares(from: "Just some plain text, no tokens here.").isEmpty) - } + /// A real 32-byte public key rendered as uppercase hex (64 chars). + static let validHex = "A1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" + + // MARK: - Round-trip + + @Test( + arguments: [ContactType.chat, .repeater, .room] + ) + func `formatShare then parseShare round-trips for every contact type`(type: ContactType) throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = "AVN2" + + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: type, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + + #expect(result.publicKey == publicKey) + #expect(result.contactType == type) + #expect(result.name == name) + } + + @Test + func `formatShare emits uppercase hex`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "Node") + #expect(token.contains(Self.validHex)) + #expect(token == "<\(Self.validHex):1:Node>") + } + + // MARK: - Malformed rejection + + @Test + func `parseShare rejects a 63-char (short) public key`() { + let shortHex = String(Self.validHex.dropLast()) + #expect(ContactShareUtilities.parseShare("<\(shortHex):1:Node>") == nil) + } + + @Test + func `parseShare rejects non-hex characters in the public key`() { + let nonHex = "G1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" + #expect(ContactShareUtilities.parseShare("<\(nonHex):1:Node>") == nil) + } + + @Test + func `parseShare rejects a token missing the name field`() { + #expect(ContactShareUtilities.parseShare("<\(Self.validHex):1>") == nil) + } + + @Test + func `parseShare rejects a token missing type and name`() { + #expect(ContactShareUtilities.parseShare("<\(Self.validHex)>") == nil) + } + + @Test + func `parseShare rejects an empty name`() { + #expect(ContactShareUtilities.parseShare("<\(Self.validHex):1:>") == nil) + } + + // MARK: - Adversarial type bounds (regression for the trap) + + @Test( + arguments: ["256", "300", "99999999999999999999", "0", "4"] + ) + func `parseShare rejects out-of-range and overflowing type values without trapping`(typeDigits: String) { + let token = "<\(Self.validHex):\(typeDigits):x>" + #expect(ContactShareUtilities.parseShare(token) == nil) + } + + // MARK: - Name escaping + + @Test + func `parseShare round-trips a name containing '>' to its stripped form`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "AV>N2") + #expect(!token.dropFirst().dropLast().contains(">")) + + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == "AVN2") + } + + @Test + func `parseShare preserves a colon inside the name`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = "Base:Camp:1" + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == name) + } + + @Test + func `parseShare preserves a newline inside the name`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = "Line1\nLine2" + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == name) + } + + @Test + func `parseShare preserves an RTL-override character in the name`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = "Node\u{202E}flip" + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == name) + } + + @Test + func `parseShare round-trips a 1000-character name`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = String(repeating: "n", count: 1000) + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == name) + } + + // MARK: - extractShares + + @Test + func `extractShares finds multiple tokens amid plain text`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let first = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "Alice") + let second = ContactShareUtilities.formatShare(publicKey: publicKey, type: .repeater, name: "Bob") + let text = "Add these: \(first) and also \(second) thanks" + + let results = ContactShareUtilities.extractShares(from: text) + #expect(results.count == 2) + #expect(results[0].name == "Alice") + #expect(results[0].contactType == .chat) + #expect(results[1].name == "Bob") + #expect(results[1].contactType == .repeater) + } + + @Test + func `extractShares returns empty when there are no tokens`() { + #expect(ContactShareUtilities.extractShares(from: "Just some plain text, no tokens here.").isEmpty) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DeduplicationKeyTests.swift b/MC1Services/Tests/MC1ServicesTests/DeduplicationKeyTests.swift index e5427160..dc9afab2 100644 --- a/MC1Services/Tests/MC1ServicesTests/DeduplicationKeyTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DeduplicationKeyTests.swift @@ -1,95 +1,93 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("DeduplicationKey") struct DeduplicationKeyTests { + // MARK: - Content-based key format - // MARK: - Content-based key format - - @Test( - "Content-based key uses the expected prefix for DM and channel scopes", - arguments: [ - (contactID: UUID?.some(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!), - channelIndex: UInt8?.none, - senderNodeName: String?.none, - timestamp: UInt32(1000), - content: "hello", - expectedPrefix: "dm-AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-1000-"), - (contactID: UUID?.none, - channelIndex: UInt8?.some(3), - senderNodeName: String?.some("Alice"), - timestamp: UInt32(2000), - content: "test", - expectedPrefix: "ch-3-2000-Alice-"), - (contactID: UUID?.none, - channelIndex: UInt8?.some(0), - senderNodeName: String?.none, - timestamp: UInt32(500), - content: "msg", - expectedPrefix: "ch-0-500--"), - (contactID: UUID?.none, - channelIndex: UInt8?.none, - senderNodeName: String?.none, - timestamp: UInt32(100), - content: "x", - expectedPrefix: "dm-unknown-100-") - ] + @Test( + arguments: [ + (contactID: UUID?.some(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!), + channelIndex: UInt8?.none, + senderNodeName: String?.none, + timestamp: UInt32(1000), + content: "hello", + expectedPrefix: "dm-AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-1000-"), + (contactID: UUID?.none, + channelIndex: UInt8?.some(3), + senderNodeName: String?.some("Alice"), + timestamp: UInt32(2000), + content: "test", + expectedPrefix: "ch-3-2000-Alice-"), + (contactID: UUID?.none, + channelIndex: UInt8?.some(0), + senderNodeName: String?.none, + timestamp: UInt32(500), + content: "msg", + expectedPrefix: "ch-0-500--"), + (contactID: UUID?.none, + channelIndex: UInt8?.none, + senderNodeName: String?.none, + timestamp: UInt32(100), + content: "x", + expectedPrefix: "dm-unknown-100-") + ] + ) + func `Content-based key uses the expected prefix for DM and channel scopes`( + contactID: UUID?, + channelIndex: UInt8?, + senderNodeName: String?, + timestamp: UInt32, + content: String, + expectedPrefix: String + ) { + let key = DeduplicationKey.contentBased( + contactID: contactID, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + timestamp: timestamp, + content: content ) - func contentBasedKeyPrefix( - contactID: UUID?, - channelIndex: UInt8?, - senderNodeName: String?, - timestamp: UInt32, - content: String, - expectedPrefix: String - ) { - let key = DeduplicationKey.contentBased( - contactID: contactID, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - timestamp: timestamp, - content: content - ) - #expect(key.hasPrefix(expectedPrefix)) - } + #expect(key.hasPrefix(expectedPrefix)) + } - @Test("Content-based key has an 8-char hex hash suffix") - func contentBasedKeyHashSuffix() { - let key = DeduplicationKey.contentBased( - contactID: UUID(), channelIndex: nil, - senderNodeName: nil, timestamp: 1000, content: "hello" - ) - let hashSuffix = String(key.split(separator: "-").last ?? "") - #expect(hashSuffix.count == 8) - } + @Test + func `Content-based key has an 8-char hex hash suffix`() { + let key = DeduplicationKey.contentBased( + contactID: UUID(), channelIndex: nil, + senderNodeName: nil, timestamp: 1000, content: "hello" + ) + let hashSuffix = String(key.split(separator: "-").last ?? "") + #expect(hashSuffix.count == 8) + } - @Test("Content-based key is deterministic and distinguishes different content") - func contentBasedKeyEquality() { - let contactID = UUID() - let keyA1 = DeduplicationKey.contentBased( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1, content: "aaa" - ) - let keyA2 = DeduplicationKey.contentBased( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1, content: "aaa" - ) - let keyB = DeduplicationKey.contentBased( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1, content: "bbb" - ) - #expect(keyA1 == keyA2) - #expect(keyA1 != keyB) - } + @Test + func `Content-based key is deterministic and distinguishes different content`() { + let contactID = UUID() + let keyA1 = DeduplicationKey.contentBased( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1, content: "aaa" + ) + let keyA2 = DeduplicationKey.contentBased( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1, content: "aaa" + ) + let keyB = DeduplicationKey.contentBased( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1, content: "bbb" + ) + #expect(keyA1 == keyA2) + #expect(keyA1 != keyB) + } - @Test("Empty content still produces a well-formed key") - func emptyContent() { - let key = DeduplicationKey.contentBased( - contactID: UUID(), channelIndex: nil, - senderNodeName: nil, timestamp: 0, content: "" - ) - #expect(key.hasPrefix("dm-")) - #expect(key.count > 10) - } + @Test + func `Empty content still produces a well-formed key`() { + let key = DeduplicationKey.contentBased( + contactID: UUID(), channelIndex: nil, + senderNodeName: nil, timestamp: 0, content: "" + ) + #expect(key.hasPrefix("dm-")) + #expect(key.count > 10) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DeviceConnectionStateTests.swift b/MC1Services/Tests/MC1ServicesTests/DeviceConnectionStateTests.swift index d9574ad3..d765b9c0 100644 --- a/MC1Services/Tests/MC1ServicesTests/DeviceConnectionStateTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DeviceConnectionStateTests.swift @@ -1,23 +1,23 @@ -import Testing @testable import MC1Services +import Testing @Suite("DeviceConnectionState") struct DeviceConnectionStateTests { - @Test("isOperational returns true only for syncing and ready") - func isOperational() { - #expect(!DeviceConnectionState.disconnected.isOperational) - #expect(!DeviceConnectionState.connecting.isOperational) - #expect(!DeviceConnectionState.connected.isOperational) - #expect(DeviceConnectionState.syncing.isOperational) - #expect(DeviceConnectionState.ready.isOperational) - } + @Test + func `isOperational returns true only for syncing and ready`() { + #expect(!DeviceConnectionState.disconnected.isOperational) + #expect(!DeviceConnectionState.connecting.isOperational) + #expect(!DeviceConnectionState.connected.isOperational) + #expect(DeviceConnectionState.syncing.isOperational) + #expect(DeviceConnectionState.ready.isOperational) + } - @Test("isConnected returns true for connected, syncing, and ready") - func isConnected() { - #expect(!DeviceConnectionState.disconnected.isConnected) - #expect(!DeviceConnectionState.connecting.isConnected) - #expect(DeviceConnectionState.connected.isConnected) - #expect(DeviceConnectionState.syncing.isConnected) - #expect(DeviceConnectionState.ready.isConnected) - } + @Test + func `isConnected returns true for connected, syncing, and ready`() { + #expect(!DeviceConnectionState.disconnected.isConnected) + #expect(!DeviceConnectionState.connecting.isConnected) + #expect(DeviceConnectionState.connected.isConnected) + #expect(DeviceConnectionState.syncing.isConnected) + #expect(DeviceConnectionState.ready.isConnected) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DevicePlatformSyncThrottlingTests.swift b/MC1Services/Tests/MC1ServicesTests/DevicePlatformSyncThrottlingTests.swift index 6c4cc453..93ccdddb 100644 --- a/MC1Services/Tests/MC1ServicesTests/DevicePlatformSyncThrottlingTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DevicePlatformSyncThrottlingTests.swift @@ -1,143 +1,142 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("DevicePlatform Channel Sync Config") struct DevicePlatformChannelSyncConfigTests { + @Test + func `ESP32 has 30s channel sync skip window`() { + let platform = DevicePlatform.esp32 + #expect(platform.channelSyncSkipWindow == .seconds(30)) + } - @Test("ESP32 has 30s channel sync skip window") - func esp32ChannelSyncSkipWindow() { - let platform = DevicePlatform.esp32 - #expect(platform.channelSyncSkipWindow == .seconds(30)) - } - - @Test("nRF52 has zero channel sync skip window") - func nrf52ChannelSyncSkipWindow() { - let platform = DevicePlatform.nrf52 - #expect(platform.channelSyncSkipWindow == .zero) - } - - @Test("Unknown has zero channel sync skip window") - func unknownChannelSyncSkipWindow() { - let platform = DevicePlatform.unknown - #expect(platform.channelSyncSkipWindow == .zero) - } + @Test + func `nRF52 has zero channel sync skip window`() { + let platform = DevicePlatform.nrf52 + #expect(platform.channelSyncSkipWindow == .zero) + } + + @Test + func `Unknown has zero channel sync skip window`() { + let platform = DevicePlatform.unknown + #expect(platform.channelSyncSkipWindow == .zero) + } - @Test("WiFi uses ESP32 channel sync cooldown") - @MainActor - func wifiUsesESP32ChannelSyncCooldown() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let radioID = UUID() - let attemptedAt = Date() + @Test + @MainActor + func `WiFi uses ESP32 channel sync cooldown`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let radioID = UUID() + let attemptedAt = Date() - manager.setTestState( - detectedPlatform: .esp32, - lastAttemptedChannelSync: (radioID: radioID, attemptedAt: attemptedAt) - ) + manager.setTestState( + detectedPlatform: .esp32, + lastAttemptedChannelSync: (radioID: radioID, attemptedAt: attemptedAt) + ) - let config = manager.currentChannelSyncConfig(for: radioID, transportType: .wifi) + let config = manager.currentChannelSyncConfig(for: radioID, transportType: .wifi) - #expect(config.channelSyncSkipWindow == .seconds(30)) - #expect(config.lastAttemptedChannelSync == attemptedAt) - } + #expect(config.channelSyncSkipWindow == .seconds(30)) + #expect(config.lastAttemptedChannelSync == attemptedAt) + } - @Test("nRF52 over BLE enables pipelined channel reads") - @MainActor - func nrf52OverBLEEnablesPipelinedRead() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(detectedPlatform: .nrf52) + @Test + @MainActor + func `nRF52 over BLE enables pipelined channel reads`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(detectedPlatform: .nrf52) - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .bluetooth) + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .bluetooth) - #expect(config.usePipelinedChannelRead) - } + #expect(config.usePipelinedChannelRead) + } - @Test("nRF52 over WiFi does not pipeline channel reads") - @MainActor - func nrf52OverWiFiDoesNotPipeline() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(detectedPlatform: .nrf52) + @Test + @MainActor + func `nRF52 over WiFi does not pipeline channel reads`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(detectedPlatform: .nrf52) - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) - #expect(!config.usePipelinedChannelRead) - } + #expect(!config.usePipelinedChannelRead) + } - @Test("ESP32 over BLE does not pipeline channel reads") - @MainActor - func esp32OverBLEDoesNotPipeline() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(detectedPlatform: .esp32) + @Test + @MainActor + func `ESP32 over BLE does not pipeline channel reads`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(detectedPlatform: .esp32) - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .bluetooth) + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .bluetooth) - #expect(!config.usePipelinedChannelRead) - } + #expect(!config.usePipelinedChannelRead) + } - @Test("ESP32 over WiFi enables pipelined channel reads") - @MainActor - func esp32OverWiFiEnablesPipelinedRead() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(detectedPlatform: .esp32) + @Test + @MainActor + func `ESP32 over WiFi enables pipelined channel reads`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(detectedPlatform: .esp32) - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) - #expect(config.usePipelinedChannelRead) - } + #expect(config.usePipelinedChannelRead) + } - @Test("WiFi connect resolves a recognized ESP32 model to ESP32") - @MainActor - func wifiRecognizedModelResolvesToESP32() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `WiFi connect resolves a recognized ESP32 model to ESP32`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.detectAndStorePlatform(model: "Heltec V3", transportType: .wifi) + manager.detectAndStorePlatform(model: "Heltec V3", transportType: .wifi) - #expect(manager.detectedPlatform == .esp32) - } + #expect(manager.detectedPlatform == .esp32) + } - @Test("WiFi connect resolves an unrecognized model to ESP32 (WiFi implies ESP32-class)") - @MainActor - func wifiUnknownModelResolvesToESP32() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `WiFi connect resolves an unrecognized model to ESP32 (WiFi implies ESP32-class)`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.detectAndStorePlatform(model: "Totally Unknown Radio 9000", transportType: .wifi) + manager.detectAndStorePlatform(model: "Totally Unknown Radio 9000", transportType: .wifi) - #expect(manager.detectedPlatform == .esp32) + #expect(manager.detectedPlatform == .esp32) - // The skip window is earned and, because the resolved platform is ESP32 over WiFi, - // the channel-read pipeline is also enabled. - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) - #expect(config.channelSyncSkipWindow == .seconds(30)) - #expect(config.usePipelinedChannelRead) - } + // The skip window is earned and, because the resolved platform is ESP32 over WiFi, + // the channel-read pipeline is also enabled. + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) + #expect(config.channelSyncSkipWindow == .seconds(30)) + #expect(config.usePipelinedChannelRead) + } - @Test("BLE connect leaves an unrecognized model as unknown (no WiFi coalesce)") - @MainActor - func bleUnknownModelStaysUnknown() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `BLE connect leaves an unrecognized model as unknown (no WiFi coalesce)`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.detectAndStorePlatform(model: "Totally Unknown Radio 9000", transportType: .bluetooth) + manager.detectAndStorePlatform(model: "Totally Unknown Radio 9000", transportType: .bluetooth) - #expect(manager.detectedPlatform == .unknown) - } + #expect(manager.detectedPlatform == .unknown) + } - @Test("BLE connect resolves an nRF52 model to nRF52") - @MainActor - func bleNRF52ModelResolvesToNRF52() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `BLE connect resolves an nRF52 model to nRF52`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.detectAndStorePlatform(model: "T1000-E", transportType: .bluetooth) + manager.detectAndStorePlatform(model: "T1000-E", transportType: .bluetooth) - #expect(manager.detectedPlatform == .nrf52) - } + #expect(manager.detectedPlatform == .nrf52) + } - @Test("Heartbeat pauses while syncing") - @MainActor - func heartbeatPausesWhileSyncing() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `Heartbeat pauses while syncing`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(connectionState: .syncing) + manager.setTestState(connectionState: .syncing) - #expect(manager.shouldPauseWiFiHeartbeatProbe) - } + #expect(manager.shouldPauseWiFiHeartbeatProbe) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DevicePublicKeyDeduplicationTests.swift b/MC1Services/Tests/MC1ServicesTests/DevicePublicKeyDeduplicationTests.swift index cc1ba566..dce60ff2 100644 --- a/MC1Services/Tests/MC1ServicesTests/DevicePublicKeyDeduplicationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DevicePublicKeyDeduplicationTests.swift @@ -1,213 +1,212 @@ import Foundation +@testable import MC1Services +import MeshCore import SwiftData import Testing -import MeshCore -@testable import MC1Services @Suite("Device publicKey deduplication") struct DevicePublicKeyDeduplicationTests { - - // MARK: - Test Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private static let testPublicKey = Data(repeating: 0xAB, count: 32) - - private static func makeSelfInfo(publicKey: Data = testPublicKey) -> SelfInfo { - SelfInfo( - advertisementType: 0, - txPower: 20, - maxTxPower: 20, - publicKey: publicKey, - latitude: 0, - longitude: 0, - multiAcks: 2, - advertisementLocationPolicy: 0, - telemetryModeEnvironment: 0, - telemetryModeLocation: 0, - telemetryModeBase: 2, - manualAddContacts: false, - radioFrequency: 915.0, - radioBandwidth: 250.0, - radioSpreadingFactor: 10, - radioCodingRate: 5, - name: "TestNode" - ) - } - - private static let testCapabilities = DeviceCapabilities( - firmwareVersion: 9, - maxContacts: 100, - maxChannels: 8, - blePin: 0, - firmwareBuild: "01 Jan 2025", - model: "T-Deck", - version: "v1.13.0" + // MARK: - Test Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private static let testPublicKey = Data(repeating: 0xAB, count: 32) + + private static func makeSelfInfo(publicKey: Data = testPublicKey) -> SelfInfo { + SelfInfo( + advertisementType: 0, + txPower: 20, + maxTxPower: 20, + publicKey: publicKey, + latitude: 0, + longitude: 0, + multiAcks: 2, + advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 2, + manualAddContacts: false, + radioFrequency: 915.0, + radioBandwidth: 250.0, + radioSpreadingFactor: 10, + radioCodingRate: 5, + name: "TestNode" + ) + } + + private static let testCapabilities = DeviceCapabilities( + firmwareVersion: 9, + maxContacts: 100, + maxChannels: 8, + blePin: 0, + firmwareBuild: "01 Jan 2025", + model: "T-Deck", + version: "v1.13.0" + ) + + // MARK: - fetchDevice(publicKey:) + + @Test + func `fetchDevice(publicKey:) returns matching device`() async throws { + let store = try await createTestStore() + let device = DeviceDTO.testDevice(publicKey: Self.testPublicKey) + try await store.saveDevice(device) + + let fetched = try await store.fetchDevice(publicKey: Self.testPublicKey) + #expect(fetched != nil) + #expect(fetched?.id == device.id) + #expect(fetched?.publicKey == Self.testPublicKey) + } + + @Test + func `fetchDevice(publicKey:) returns nil for unknown key`() async throws { + let store = try await createTestStore() + let device = DeviceDTO.testDevice(publicKey: Data(repeating: 0x01, count: 32)) + try await store.saveDevice(device) + + let unknownKey = Data(repeating: 0xFF, count: 32) + let fetched = try await store.fetchDevice(publicKey: unknownKey) + #expect(fetched == nil) + } + + // MARK: - radioID preservation via createDevice + + @Test + @MainActor + func `createDevice preserves radioID from existing device`() throws { + let existingRadioID = UUID() + let existingDevice = DeviceDTO.testDevice( + radioID: existingRadioID, + publicKey: Self.testPublicKey ) - // MARK: - fetchDevice(publicKey:) - - @Test("fetchDevice(publicKey:) returns matching device") - func fetchByPublicKeyHit() async throws { - let store = try await createTestStore() - let device = DeviceDTO.testDevice(publicKey: Self.testPublicKey) - try await store.saveDevice(device) + let (cm, _) = try ConnectionManager.createForTesting() + let newBLEUUID = UUID() - let fetched = try await store.fetchDevice(publicKey: Self.testPublicKey) - #expect(fetched != nil) - #expect(fetched?.id == device.id) - #expect(fetched?.publicKey == Self.testPublicKey) - } + let device = cm.createDevice( + deviceID: newBLEUUID, + radioID: existingRadioID, + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0), + existingDevice: existingDevice + ) - @Test("fetchDevice(publicKey:) returns nil for unknown key") - func fetchByPublicKeyMiss() async throws { - let store = try await createTestStore() - let device = DeviceDTO.testDevice(publicKey: Data(repeating: 0x01, count: 32)) - try await store.saveDevice(device) + #expect(device.id == newBLEUUID) + #expect(device.radioID == existingRadioID) + } + + @Test + @MainActor + func `createDevice uses the provided radioID for new pairings`() throws { + let (cm, _) = try ConnectionManager.createForTesting() + let bleUUID = UUID() + let freshRadioID = UUID() + + let device = cm.createDevice( + deviceID: bleUUID, + radioID: freshRadioID, + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0) + ) - let unknownKey = Data(repeating: 0xFF, count: 32) - let fetched = try await store.fetchDevice(publicKey: unknownKey) - #expect(fetched == nil) - } + #expect(device.id == bleUUID) + #expect(device.radioID == freshRadioID) + } + + // MARK: - Bluetooth connection method persistence + + /// The BLE connect ceremony passes a `.bluetooth` method so the saved row is + /// reachable on macOS, where there is no AccessorySetupKit registry to validate + /// against and `DeviceSelectionFilter` treats the method itself as the signal. + @Test + @MainActor + func `createDevice persists the Bluetooth method supplied by the BLE connect path`() throws { + let (cm, _) = try ConnectionManager.createForTesting() + let bleUUID = UUID() + + let device = cm.createDevice( + deviceID: bleUUID, + radioID: UUID(), + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0), + connectionMethods: [.bluetooth(peripheralUUID: bleUUID, displayName: nil)] + ) - // MARK: - radioID preservation via createDevice - - @Test("createDevice preserves radioID from existing device") - @MainActor - func createDevicePreservesRadioID() throws { - let existingRadioID = UUID() - let existingDevice = DeviceDTO.testDevice( - radioID: existingRadioID, - publicKey: Self.testPublicKey - ) - - let (cm, _) = try ConnectionManager.createForTesting() - let newBLEUUID = UUID() - - let device = cm.createDevice( - deviceID: newBLEUUID, - radioID: existingRadioID, - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0), - existingDevice: existingDevice - ) - - #expect(device.id == newBLEUUID) - #expect(device.radioID == existingRadioID) + #expect(device.connectionMethods.contains { $0.isBluetooth }) + } + + /// A radio reachable over both transports must keep its WiFi method when it + /// reconnects over BLE; the merge replaces by transport type rather than + /// discarding the other transport. + @Test + @MainActor + func `createDevice merges a Bluetooth method with an existing WiFi method`() throws { + let (cm, _) = try ConnectionManager.createForTesting() + let bleUUID = UUID() + let existingRadioID = UUID() + let existingDevice = DeviceDTO.testDevice( + radioID: existingRadioID, + publicKey: Self.testPublicKey + ).copy { + $0.connectionMethods = [.wifi(host: "10.0.0.2", port: 5000, displayName: nil)] } - @Test("createDevice uses the provided radioID for new pairings") - @MainActor - func createDeviceUsesProvidedRadioID() throws { - let (cm, _) = try ConnectionManager.createForTesting() - let bleUUID = UUID() - let freshRadioID = UUID() - - let device = cm.createDevice( - deviceID: bleUUID, - radioID: freshRadioID, - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0) - ) - - #expect(device.id == bleUUID) - #expect(device.radioID == freshRadioID) - } + let device = cm.createDevice( + deviceID: bleUUID, + radioID: existingRadioID, + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0), + existingDevice: existingDevice, + connectionMethods: [.bluetooth(peripheralUUID: bleUUID, displayName: nil)] + ) - // MARK: - Bluetooth connection method persistence - - /// The BLE connect ceremony passes a `.bluetooth` method so the saved row is - /// reachable on macOS, where there is no AccessorySetupKit registry to validate - /// against and `DeviceSelectionFilter` treats the method itself as the signal. - @Test("createDevice persists the Bluetooth method supplied by the BLE connect path") - @MainActor - func createDevicePersistsBluetoothMethod() throws { - let (cm, _) = try ConnectionManager.createForTesting() - let bleUUID = UUID() - - let device = cm.createDevice( - deviceID: bleUUID, - radioID: UUID(), - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0), - connectionMethods: [.bluetooth(peripheralUUID: bleUUID, displayName: nil)] - ) - - #expect(device.connectionMethods.contains { $0.isBluetooth }) + // Exactly one of each transport: the Bluetooth method is added without discarding + // the WiFi method, and neither transport is duplicated. + #expect(device.connectionMethods.filter(\.isWiFi).count == 1) + #expect(device.connectionMethods.filter(\.isBluetooth).count == 1) + } + + /// Reconnecting over BLE must replace the prior Bluetooth method, not append a second one. + /// macOS reachability and the live connect handle both key off the stored peripheralUUID, so + /// a stale UUID accumulating alongside the fresh one would strand or mis-route the device. + /// This pins the merge loop's `removeAll { $0.isBluetooth }` so dropping it would fail here. + @Test + @MainActor + func `createDevice replaces a stale Bluetooth peripheral UUID instead of accumulating`() throws { + let (cm, _) = try ConnectionManager.createForTesting() + let existingRadioID = UUID() + let staleUUID = UUID() + let existingDevice = DeviceDTO.testDevice( + radioID: existingRadioID, + publicKey: Self.testPublicKey + ).copy { + $0.connectionMethods = [.bluetooth(peripheralUUID: staleUUID, displayName: "Old")] } - /// A radio reachable over both transports must keep its WiFi method when it - /// reconnects over BLE; the merge replaces by transport type rather than - /// discarding the other transport. - @Test("createDevice merges a Bluetooth method with an existing WiFi method") - @MainActor - func createDeviceMergesBluetoothWithExistingWiFi() throws { - let (cm, _) = try ConnectionManager.createForTesting() - let bleUUID = UUID() - let existingRadioID = UUID() - let existingDevice = DeviceDTO.testDevice( - radioID: existingRadioID, - publicKey: Self.testPublicKey - ).copy { - $0.connectionMethods = [.wifi(host: "10.0.0.2", port: 5000, displayName: nil)] - } - - let device = cm.createDevice( - deviceID: bleUUID, - radioID: existingRadioID, - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0), - existingDevice: existingDevice, - connectionMethods: [.bluetooth(peripheralUUID: bleUUID, displayName: nil)] - ) - - // Exactly one of each transport: the Bluetooth method is added without discarding - // the WiFi method, and neither transport is duplicated. - #expect(device.connectionMethods.filter(\.isWiFi).count == 1) - #expect(device.connectionMethods.filter(\.isBluetooth).count == 1) - } + let freshUUID = UUID() + let device = cm.createDevice( + deviceID: freshUUID, + radioID: existingRadioID, + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0), + existingDevice: existingDevice, + connectionMethods: [.bluetooth(peripheralUUID: freshUUID, displayName: nil)] + ) - /// Reconnecting over BLE must replace the prior Bluetooth method, not append a second one. - /// macOS reachability and the live connect handle both key off the stored peripheralUUID, so - /// a stale UUID accumulating alongside the fresh one would strand or mis-route the device. - /// This pins the merge loop's `removeAll { $0.isBluetooth }` so dropping it would fail here. - @Test("createDevice replaces a stale Bluetooth peripheral UUID instead of accumulating") - @MainActor - func createDeviceReplacesStaleBluetoothMethod() throws { - let (cm, _) = try ConnectionManager.createForTesting() - let existingRadioID = UUID() - let staleUUID = UUID() - let existingDevice = DeviceDTO.testDevice( - radioID: existingRadioID, - publicKey: Self.testPublicKey - ).copy { - $0.connectionMethods = [.bluetooth(peripheralUUID: staleUUID, displayName: "Old")] - } - - let freshUUID = UUID() - let device = cm.createDevice( - deviceID: freshUUID, - radioID: existingRadioID, - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0), - existingDevice: existingDevice, - connectionMethods: [.bluetooth(peripheralUUID: freshUUID, displayName: nil)] - ) - - let bluetoothMethods = device.connectionMethods.filter(\.isBluetooth) - #expect(bluetoothMethods.count == 1) - guard case .bluetooth(let uuid, _) = bluetoothMethods.first else { - Issue.record("Expected exactly one surviving Bluetooth method") - return - } - #expect(uuid == freshUUID) + let bluetoothMethods = device.connectionMethods.filter(\.isBluetooth) + #expect(bluetoothMethods.count == 1) + guard case let .bluetooth(uuid, _) = bluetoothMethods.first else { + Issue.record("Expected exactly one surviving Bluetooth method") + return } + #expect(uuid == freshUUID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DraftStoreTests.swift b/MC1Services/Tests/MC1ServicesTests/DraftStoreTests.swift index 6b4ba438..bdea7109 100644 --- a/MC1Services/Tests/MC1ServicesTests/DraftStoreTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DraftStoreTests.swift @@ -1,231 +1,230 @@ -import Testing import Foundation @testable import MC1Services +import Testing /// Covers `DraftStore` persistence and the `draftToApply` restore guard, plus the /// pinned `ChatConversationID.draftStorageKey` encoding the store keys on. @Suite("DraftStore Tests") @MainActor struct DraftStoreTests { + /// A per-test isolated `UserDefaults` so suites never share draft state. + private func makeDefaults() -> UserDefaults { + let suiteName = "test.\(UUID().uuidString)" + return UserDefaults(suiteName: suiteName)! + } - /// A per-test isolated `UserDefaults` so suites never share draft state. - private func makeDefaults() -> UserDefaults { - let suiteName = "test.\(UUID().uuidString)" - return UserDefaults(suiteName: suiteName)! - } - - private let radioID = UUID() - private let contactID = UUID() + private let radioID = UUID() + private let contactID = UUID() - private func dm(_ radio: UUID, _ contact: UUID) -> ChatConversationID { - .dm(radioID: radio, contactID: contact) - } + private func dm(_ radio: UUID, _ contact: UUID) -> ChatConversationID { + .dm(radioID: radio, contactID: contact) + } - private func channel(_ radio: UUID, _ index: UInt8) -> ChatConversationID { - .channel(radioID: radio, channelIndex: index) - } + private func channel(_ radio: UUID, _ index: UInt8) -> ChatConversationID { + .channel(radioID: radio, channelIndex: index) + } - // MARK: - Round-trip and persistence + // MARK: - Round-trip and persistence - @Test("setDraft / draft / clearDraft round-trip") - func roundTrip() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) + @Test + func `setDraft / draft / clearDraft round-trip`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) - #expect(store.draft(for: id) == nil) + #expect(store.draft(for: id) == nil) - store.setDraft("hello", for: id) - #expect(store.draft(for: id) == "hello") + store.setDraft("hello", for: id) + #expect(store.draft(for: id) == "hello") - store.clearDraft(for: id) - #expect(store.draft(for: id) == nil) - } + store.clearDraft(for: id) + #expect(store.draft(for: id) == nil) + } - @Test("draft survives a new DraftStore instance on the same suite (restart)") - func survivesRestart() { - let defaults = makeDefaults() - let id = dm(radioID, contactID) + @Test + func `draft survives a new DraftStore instance on the same suite (restart)`() { + let defaults = makeDefaults() + let id = dm(radioID, contactID) - let first = DraftStore(defaults: defaults) - first.setDraft("persisted", for: id) + let first = DraftStore(defaults: defaults) + first.setDraft("persisted", for: id) - let second = DraftStore(defaults: defaults) - #expect(second.draft(for: id) == "persisted") - } + let second = DraftStore(defaults: defaults) + #expect(second.draft(for: id) == "persisted") + } - // MARK: - Emptiness handling + // MARK: - Emptiness handling - @Test("whitespace / newline-only input removes the entry") - func whitespaceRemovesEntry() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) + @Test + func `whitespace / newline-only input removes the entry`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) - store.setDraft("real", for: id) - #expect(store.draft(for: id) == "real") + store.setDraft("real", for: id) + #expect(store.draft(for: id) == "real") - store.setDraft(" \n\t ", for: id) - #expect(store.draft(for: id) == nil) - } + store.setDraft(" \n\t ", for: id) + #expect(store.draft(for: id) == nil) + } - @Test("non-empty draft with a trailing newline is stored verbatim") - func trailingNewlineStoredVerbatim() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) + @Test + func `non-empty draft with a trailing newline is stored verbatim`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) - store.setDraft("hi\n", for: id) - #expect(store.draft(for: id) == "hi\n") - } + store.setDraft("hi\n", for: id) + #expect(store.draft(for: id) == "hi\n") + } - // MARK: - draftToApply restore guard + // MARK: - draftToApply restore guard - @Test("draftToApply returns the saved draft when the field is empty") - func draftToApplyReturnsWhenEmpty() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) - store.setDraft("saved", for: id) + @Test + func `draftToApply returns the saved draft when the field is empty`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) + store.setDraft("saved", for: id) - #expect(store.draftToApply(over: "", for: id) == "saved") - } + #expect(store.draftToApply(over: "", for: id) == "saved") + } - @Test("draftToApply returns nil when the field already has text") - func draftToApplyGuardsNonEmpty() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) - store.setDraft("saved", for: id) + @Test + func `draftToApply returns nil when the field already has text`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) + store.setDraft("saved", for: id) - #expect(store.draftToApply(over: "typing", for: id) == nil) - } + #expect(store.draftToApply(over: "typing", for: id) == nil) + } - @Test("draftToApply returns nil when no draft exists") - func draftToApplyNilWhenAbsent() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) + @Test + func `draftToApply returns nil when no draft exists`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) - #expect(store.draftToApply(over: "", for: id) == nil) - } + #expect(store.draftToApply(over: "", for: id) == nil) + } - // MARK: - Key isolation + // MARK: - Key isolation - @Test("dm and channel drafts at matching identifiers do not collide") - func dmVsChannelIsolation() { - let store = DraftStore(defaults: makeDefaults()) - let dmID = dm(radioID, contactID) - let channelID = channel(radioID, 3) + @Test + func `dm and channel drafts at matching identifiers do not collide`() { + let store = DraftStore(defaults: makeDefaults()) + let dmID = dm(radioID, contactID) + let channelID = channel(radioID, 3) - store.setDraft("dm-text", for: dmID) - store.setDraft("channel-text", for: channelID) + store.setDraft("dm-text", for: dmID) + store.setDraft("channel-text", for: channelID) - #expect(store.draft(for: dmID) == "dm-text") - #expect(store.draft(for: channelID) == "channel-text") - } + #expect(store.draft(for: dmID) == "dm-text") + #expect(store.draft(for: channelID) == "channel-text") + } - @Test("same channel index on different radios is isolated") - func sameIndexDifferentRadioIsolation() { - let store = DraftStore(defaults: makeDefaults()) - let radioA = UUID() - let radioB = UUID() + @Test + func `same channel index on different radios is isolated`() { + let store = DraftStore(defaults: makeDefaults()) + let radioA = UUID() + let radioB = UUID() - store.setDraft("a", for: channel(radioA, 1)) - store.setDraft("b", for: channel(radioB, 1)) - - #expect(store.draft(for: channel(radioA, 1)) == "a") - #expect(store.draft(for: channel(radioB, 1)) == "b") - } + store.setDraft("a", for: channel(radioA, 1)) + store.setDraft("b", for: channel(radioB, 1)) + + #expect(store.draft(for: channel(radioA, 1)) == "a") + #expect(store.draft(for: channel(radioB, 1)) == "b") + } - // MARK: - Cleared state persistence + // MARK: - Cleared state persistence - @Test("clearDraft persists the deletion across a fresh instance (restart)") - func clearedDraftDoesNotSurviveRestart() { - let defaults = makeDefaults() - let id = dm(radioID, contactID) - - let first = DraftStore(defaults: defaults) - first.setDraft("persisted", for: id) - first.clearDraft(for: id) - - let second = DraftStore(defaults: defaults) - #expect(second.draft(for: id) == nil) - } - - @Test("whitespace-clear persists the deletion across a fresh instance (restart)") - func whitespaceClearedDraftDoesNotSurviveRestart() { - let defaults = makeDefaults() - let id = dm(radioID, contactID) - - let first = DraftStore(defaults: defaults) - first.setDraft("persisted", for: id) - first.setDraft(" \n ", for: id) - - let second = DraftStore(defaults: defaults) - #expect(second.draft(for: id) == nil) - } - - @Test("clearDraft on a never-set id is a no-op") - func clearDraftOnUnsetIdIsNoOp() { - let defaults = makeDefaults() - let id = channel(radioID, 4) - - let store = DraftStore(defaults: defaults) - store.clearDraft(for: id) - #expect(store.draft(for: id) == nil) - - let reloaded = DraftStore(defaults: defaults) - #expect(reloaded.draft(for: id) == nil) - } - - // MARK: - Batch channel-draft clearing - - @Test("clearChannelDrafts(radioID:indices:) clears the given slots and persists") - func clearChannelDraftsByRadioClearsAndPersists() { - let defaults = makeDefaults() - let store = DraftStore(defaults: defaults) - - store.setDraft("one", for: channel(radioID, 1)) - store.setDraft("two", for: channel(radioID, 2)) - store.setDraft("keep", for: channel(radioID, 3)) - - store.clearChannelDrafts(radioID: radioID, indices: [1, 2]) - - #expect(store.draft(for: channel(radioID, 1)) == nil) - #expect(store.draft(for: channel(radioID, 2)) == nil) - #expect(store.draft(for: channel(radioID, 3)) == "keep") - - let reloaded = DraftStore(defaults: defaults) - #expect(reloaded.draft(for: channel(radioID, 1)) == nil) - #expect(reloaded.draft(for: channel(radioID, 3)) == "keep") - } - - @Test("clearChannelDrafts(slotsByRadio:) clears slots across radios, leaving dm drafts intact") - func clearChannelDraftsBySlotsByRadioIsScoped() { - let store = DraftStore(defaults: makeDefaults()) - let radioA = UUID() - let radioB = UUID() - - store.setDraft("a1", for: channel(radioA, 1)) - store.setDraft("b1", for: channel(radioB, 1)) - store.setDraft("dm", for: dm(radioA, contactID)) - - store.clearChannelDrafts(slotsByRadio: [radioA: [1], radioB: [1]]) - - #expect(store.draft(for: channel(radioA, 1)) == nil) - #expect(store.draft(for: channel(radioB, 1)) == nil) - #expect(store.draft(for: dm(radioA, contactID)) == "dm") - } - - // MARK: - Pinned key encoding - - @Test("draftStorageKey encodes dm and channel keys in the pinned format") - func pinnedKeyStrings() { - let radio = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let contact = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! - - #expect( - dm(radio, contact).draftStorageKey - == "11111111-1111-1111-1111-111111111111|dm|22222222-2222-2222-2222-222222222222" - ) - #expect( - channel(radio, 5).draftStorageKey - == "11111111-1111-1111-1111-111111111111|ch|5" - ) - } + @Test + func `clearDraft persists the deletion across a fresh instance (restart)`() { + let defaults = makeDefaults() + let id = dm(radioID, contactID) + + let first = DraftStore(defaults: defaults) + first.setDraft("persisted", for: id) + first.clearDraft(for: id) + + let second = DraftStore(defaults: defaults) + #expect(second.draft(for: id) == nil) + } + + @Test + func `whitespace-clear persists the deletion across a fresh instance (restart)`() { + let defaults = makeDefaults() + let id = dm(radioID, contactID) + + let first = DraftStore(defaults: defaults) + first.setDraft("persisted", for: id) + first.setDraft(" \n ", for: id) + + let second = DraftStore(defaults: defaults) + #expect(second.draft(for: id) == nil) + } + + @Test + func `clearDraft on a never-set id is a no-op`() { + let defaults = makeDefaults() + let id = channel(radioID, 4) + + let store = DraftStore(defaults: defaults) + store.clearDraft(for: id) + #expect(store.draft(for: id) == nil) + + let reloaded = DraftStore(defaults: defaults) + #expect(reloaded.draft(for: id) == nil) + } + + // MARK: - Batch channel-draft clearing + + @Test + func `clearChannelDrafts(radioID:indices:) clears the given slots and persists`() { + let defaults = makeDefaults() + let store = DraftStore(defaults: defaults) + + store.setDraft("one", for: channel(radioID, 1)) + store.setDraft("two", for: channel(radioID, 2)) + store.setDraft("keep", for: channel(radioID, 3)) + + store.clearChannelDrafts(radioID: radioID, indices: [1, 2]) + + #expect(store.draft(for: channel(radioID, 1)) == nil) + #expect(store.draft(for: channel(radioID, 2)) == nil) + #expect(store.draft(for: channel(radioID, 3)) == "keep") + + let reloaded = DraftStore(defaults: defaults) + #expect(reloaded.draft(for: channel(radioID, 1)) == nil) + #expect(reloaded.draft(for: channel(radioID, 3)) == "keep") + } + + @Test + func `clearChannelDrafts(slotsByRadio:) clears slots across radios, leaving dm drafts intact`() { + let store = DraftStore(defaults: makeDefaults()) + let radioA = UUID() + let radioB = UUID() + + store.setDraft("a1", for: channel(radioA, 1)) + store.setDraft("b1", for: channel(radioB, 1)) + store.setDraft("dm", for: dm(radioA, contactID)) + + store.clearChannelDrafts(slotsByRadio: [radioA: [1], radioB: [1]]) + + #expect(store.draft(for: channel(radioA, 1)) == nil) + #expect(store.draft(for: channel(radioB, 1)) == nil) + #expect(store.draft(for: dm(radioA, contactID)) == "dm") + } + + // MARK: - Pinned key encoding + + @Test + func `draftStorageKey encodes dm and channel keys in the pinned format`() throws { + let radio = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let contact = try #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")) + + #expect( + dm(radio, contact).draftStorageKey + == "11111111-1111-1111-1111-111111111111|dm|22222222-2222-2222-2222-222222222222" + ) + #expect( + channel(radio, 5).draftStorageKey + == "11111111-1111-1111-1111-111111111111|ch|5" + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift b/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift index d301a07b..72904a89 100644 --- a/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift @@ -1,44 +1,34 @@ -import Testing @testable import MC1Services +import Testing @Suite("EnvInputs theme token") struct EnvInputsThemeTokenTests { + private func make(themeID: String) -> EnvInputs { + EnvInputs( + autoPlayGIFs: true, + showIncomingPath: true, + showIncomingHopCount: true, + showIncomingRegion: true, + showIncomingSendTime: true, + previewsEnabled: true, + isHighContrast: false, + isDark: false, + showMapPreviews: true, + isOffline: false, + currentUserName: "Tester", + themeID: themeID, + contentSizeCategory: EnvInputs.defaultContentSizeCategory + ) + } - private func make(themeID: String) -> EnvInputs { - EnvInputs( - showInlineImages: true, - autoPlayGIFs: true, - showIncomingPath: true, - showIncomingHopCount: true, - showIncomingRegion: true, - showIncomingSendTime: true, - previewsEnabled: true, - isHighContrast: false, - isDark: false, - showMapPreviews: true, - isOffline: false, - currentUserName: "Tester", - themeID: themeID, - contentSizeCategory: EnvInputs.defaultContentSizeCategory - ) - } - - @Test("changing only themeID makes EnvInputs unequal (drives cache invalidation)") - func themeIDChangeIsObservable() { - #expect(make(themeID: "default") != make(themeID: "ember")) - #expect(make(themeID: "default") == make(themeID: "default")) - } - - @Test("equal themeIDs produce equal hashes") - func equalThemeIDsHashEqually() { - // Hashable only guarantees equal values hash equally; distinct values may legally collide, - // so asserting unequal hashes for distinct themeIDs is non-contractual. The meaningful - // property — themeID affecting equality — is covered by `themeIDChangeIsObservable`. - #expect(make(themeID: "marine").hashValue == make(themeID: "marine").hashValue) - } + @Test + func `changing only themeID makes EnvInputs unequal (drives cache invalidation)`() { + #expect(make(themeID: "default") != make(themeID: "ember")) + #expect(make(themeID: "default") == make(themeID: "default")) + } - @Test("EnvInputs.default carries the default theme id") - func defaultTokenIsDefault() { - #expect(EnvInputs.default.themeID == "default") - } + @Test + func `EnvInputs.default carries the default theme id`() { + #expect(EnvInputs.default.themeID == "default") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/EventBroadcasterTests.swift b/MC1Services/Tests/MC1ServicesTests/EventBroadcasterTests.swift index 51b3950f..3fee8528 100644 --- a/MC1Services/Tests/MC1ServicesTests/EventBroadcasterTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/EventBroadcasterTests.swift @@ -1,113 +1,112 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("EventBroadcaster Tests") struct EventBroadcasterTests { - - @Test("two subscribers both receive every event in yield order") - func twoSubscribersReceiveAllEventsInOrder() async { - let broadcaster = EventBroadcaster() - let streamA = broadcaster.subscribe() - let streamB = broadcaster.subscribe() - let events = [1, 2, 3, 4, 5] - - for value in events { - broadcaster.yield(value) - } - broadcaster.finish() - - var receivedA: [Int] = [] - for await value in streamA { - receivedA.append(value) - } - var receivedB: [Int] = [] - for await value in streamB { - receivedB.append(value) - } - - #expect(receivedA == events) - #expect(receivedB == events) + @Test + func `two subscribers both receive every event in yield order`() async { + let broadcaster = EventBroadcaster() + let streamA = broadcaster.subscribe() + let streamB = broadcaster.subscribe() + let events = [1, 2, 3, 4, 5] + + for value in events { + broadcaster.yield(value) } + broadcaster.finish() - @Test("a cancelled subscriber is pruned and does not affect its sibling") - func cancelledSubscriberIsPrunedWithoutAffectingSibling() async { - let broadcaster = EventBroadcaster() - let doomed = broadcaster.subscribe() - let survivor = broadcaster.subscribe() - #expect(broadcaster.subscriberCount == 2) - - let consumer = Task { - for await _ in doomed {} - } - consumer.cancel() - await consumer.value - #expect(broadcaster.subscriberCount == 1) - - broadcaster.yield(7) - broadcaster.finish() - - var received: [Int] = [] - for await value in survivor { - received.append(value) - } - #expect(received == [7]) + var receivedA: [Int] = [] + for await value in streamA { + receivedA.append(value) } - - @Test("finish ends every subscriber's for-await loop") - func finishEndsAllSubscriberLoops() async { - let broadcaster = EventBroadcaster() - let streamA = broadcaster.subscribe() - let streamB = broadcaster.subscribe() - - let consumerA = Task { - for await _ in streamA {} - return true - } - let consumerB = Task { - for await _ in streamB {} - return true - } - - broadcaster.finish() - - #expect(await consumerA.value) - #expect(await consumerB.value) - #expect(broadcaster.subscriberCount == 0) + var receivedB: [Int] = [] + for await value in streamB { + receivedB.append(value) } - @Test("an event yielded immediately after subscribe is never dropped") - func subscribeThenImmediateYieldIsDelivered() async { - let broadcaster = EventBroadcaster() - let stream = broadcaster.subscribe() - broadcaster.yield("first") - broadcaster.finish() - - var received: [String] = [] - for await value in stream { - received.append(value) - } - #expect(received == ["first"]) + #expect(receivedA == events) + #expect(receivedB == events) + } + + @Test + func `a cancelled subscriber is pruned and does not affect its sibling`() async { + let broadcaster = EventBroadcaster() + let doomed = broadcaster.subscribe() + let survivor = broadcaster.subscribe() + #expect(broadcaster.subscriberCount == 2) + + let consumer = Task { + for await _ in doomed {} } + consumer.cancel() + await consumer.value + #expect(broadcaster.subscriberCount == 1) + + broadcaster.yield(7) + broadcaster.finish() - @Test("subscribing after finish returns a stream that completes immediately") - func subscribeAfterFinishCompletesImmediately() async { - let broadcaster = EventBroadcaster() - broadcaster.finish() - - let stream = broadcaster.subscribe() - var received: [Int] = [] - for await value in stream { - received.append(value) - } - #expect(received.isEmpty) + var received: [Int] = [] + for await value in survivor { + received.append(value) } + #expect(received == [7]) + } + + @Test + func `finish ends every subscriber's for-await loop`() async { + let broadcaster = EventBroadcaster() + let streamA = broadcaster.subscribe() + let streamB = broadcaster.subscribe() + + let consumerA = Task { + for await _ in streamA {} + return true + } + let consumerB = Task { + for await _ in streamB {} + return true + } + + broadcaster.finish() - @Test("yield after finish reaches no subscriber") - func yieldAfterFinishReachesNobody() async { - let broadcaster = EventBroadcaster() - broadcaster.finish() - broadcaster.yield(42) - #expect(broadcaster.subscriberCount == 0) + #expect(await consumerA.value) + #expect(await consumerB.value) + #expect(broadcaster.subscriberCount == 0) + } + + @Test + func `an event yielded immediately after subscribe is never dropped`() async { + let broadcaster = EventBroadcaster() + let stream = broadcaster.subscribe() + broadcaster.yield("first") + broadcaster.finish() + + var received: [String] = [] + for await value in stream { + received.append(value) + } + #expect(received == ["first"]) + } + + @Test + func `subscribing after finish returns a stream that completes immediately`() async { + let broadcaster = EventBroadcaster() + broadcaster.finish() + + let stream = broadcaster.subscribe() + var received: [Int] = [] + for await value in stream { + received.append(value) } + #expect(received.isEmpty) + } + + @Test + func `yield after finish reaches no subscriber`() { + let broadcaster = EventBroadcaster() + broadcaster.finish() + broadcaster.yield(42) + #expect(broadcaster.subscriberCount == 0) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/AppBackupEnvelope+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/AppBackupEnvelope+Testing.swift index 1f4e887f..6514bdd4 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/AppBackupEnvelope+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/AppBackupEnvelope+Testing.swift @@ -2,45 +2,44 @@ import Foundation @testable import MC1Services extension AppBackupEnvelope { - - /// Builds a test envelope with a manifest already derived from the passed arrays. - /// Every DTO array and userDefaults payload defaults to empty, so call sites - /// only pass what the specific test needs. - static func test( - exportDate: Date = Date(timeIntervalSince1970: 1_700_000_000), - appVersion: String = "test", - appBuild: String = "1", - devices: [DeviceDTO] = [], - contacts: [ContactDTO] = [], - channels: [ChannelDTO] = [], - messages: [MessageDTO] = [], - messageRepeats: [MessageRepeatDTO] = [], - reactions: [ReactionDTO] = [], - roomMessages: [RoomMessageDTO] = [], - remoteNodeSessions: [RemoteNodeSessionDTO] = [], - savedTracePaths: [SavedTracePathDTO] = [], - blockedChannelSenders: [BlockedChannelSenderDTO] = [], - nodeStatusSnapshots: [NodeStatusSnapshotDTO] = [], - userDefaults: BackupUserDefaults? = nil - ) -> AppBackupEnvelope { - var envelope = AppBackupEnvelope( - exportDate: exportDate, - appVersion: appVersion, - appBuild: appBuild, - devices: devices, - contacts: contacts, - channels: channels, - messages: messages, - messageRepeats: messageRepeats, - reactions: reactions, - roomMessages: roomMessages, - remoteNodeSessions: remoteNodeSessions, - savedTracePaths: savedTracePaths, - blockedChannelSenders: blockedChannelSenders, - nodeStatusSnapshots: nodeStatusSnapshots, - userDefaults: userDefaults - ) - envelope.manifest = BackupManifest(from: envelope) - return envelope - } + /// Builds a test envelope with a manifest already derived from the passed arrays. + /// Every DTO array and userDefaults payload defaults to empty, so call sites + /// only pass what the specific test needs. + static func test( + exportDate: Date = Date(timeIntervalSince1970: 1_700_000_000), + appVersion: String = "test", + appBuild: String = "1", + devices: [DeviceDTO] = [], + contacts: [ContactDTO] = [], + channels: [ChannelDTO] = [], + messages: [MessageDTO] = [], + messageRepeats: [MessageRepeatDTO] = [], + reactions: [ReactionDTO] = [], + roomMessages: [RoomMessageDTO] = [], + remoteNodeSessions: [RemoteNodeSessionDTO] = [], + savedTracePaths: [SavedTracePathDTO] = [], + blockedChannelSenders: [BlockedChannelSenderDTO] = [], + nodeStatusSnapshots: [NodeStatusSnapshotDTO] = [], + userDefaults: BackupUserDefaults? = nil + ) -> AppBackupEnvelope { + var envelope = AppBackupEnvelope( + exportDate: exportDate, + appVersion: appVersion, + appBuild: appBuild, + devices: devices, + contacts: contacts, + channels: channels, + messages: messages, + messageRepeats: messageRepeats, + reactions: reactions, + roomMessages: roomMessages, + remoteNodeSessions: remoteNodeSessions, + savedTracePaths: savedTracePaths, + blockedChannelSenders: blockedChannelSenders, + nodeStatusSnapshots: nodeStatusSnapshots, + userDefaults: userDefaults + ) + envelope.manifest = BackupManifest(from: envelope) + return envelope + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/BlockedChannelSenderDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/BlockedChannelSenderDTO+Testing.swift index c18d065b..368f4c3f 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/BlockedChannelSenderDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/BlockedChannelSenderDTO+Testing.swift @@ -2,24 +2,23 @@ import Foundation @testable import MC1Services extension BlockedChannelSenderDTO { - - /// Creates a BlockedChannelSenderDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: myRadioID) - /// ``` - static func testBlockedSender( - id: UUID = UUID(), - name: String = "SpammerNode", - radioID: UUID, - dateBlocked: Date = Date() - ) -> BlockedChannelSenderDTO { - BlockedChannelSenderDTO( - id: id, - name: name, - radioID: radioID, - dateBlocked: dateBlocked - ) - } + /// Creates a BlockedChannelSenderDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: myRadioID) + /// ``` + static func testBlockedSender( + id: UUID = UUID(), + name: String = "SpammerNode", + radioID: UUID, + dateBlocked: Date = Date() + ) -> BlockedChannelSenderDTO { + BlockedChannelSenderDTO( + id: id, + name: name, + radioID: radioID, + dateBlocked: dateBlocked + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ChannelDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ChannelDTO+Testing.swift index f79d0784..cfdc807a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ChannelDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ChannelDTO+Testing.swift @@ -2,41 +2,40 @@ import Foundation @testable import MC1Services extension ChannelDTO { - - /// Creates a ChannelDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let channel = ChannelDTO.testChannel(radioID: myRadioID) - /// let private = ChannelDTO.testChannel(radioID: myRadioID, index: 1, name: "Private") - /// ``` - static func testChannel( - id: UUID = UUID(), - radioID: UUID, - index: UInt8 = 0, - name: String = "General", - secret: Data = Data(repeating: 0, count: 16), - isEnabled: Bool = true, - lastMessageDate: Date? = nil, - unreadCount: Int = 0, - unreadMentionCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - floodScope: ChannelFloodScope = .inherit - ) -> ChannelDTO { - ChannelDTO( - id: id, - radioID: radioID, - index: index, - name: name, - secret: secret, - isEnabled: isEnabled, - lastMessageDate: lastMessageDate, - unreadCount: unreadCount, - unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, - isFavorite: isFavorite, - floodScope: floodScope - ) - } + /// Creates a ChannelDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let channel = ChannelDTO.testChannel(radioID: myRadioID) + /// let private = ChannelDTO.testChannel(radioID: myRadioID, index: 1, name: "Private") + /// ``` + static func testChannel( + id: UUID = UUID(), + radioID: UUID, + index: UInt8 = 0, + name: String = "General", + secret: Data = Data(repeating: 0, count: 16), + isEnabled: Bool = true, + lastMessageDate: Date? = nil, + unreadCount: Int = 0, + unreadMentionCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + floodScope: ChannelFloodScope = .inherit + ) -> ChannelDTO { + ChannelDTO( + id: id, + radioID: radioID, + index: index, + name: name, + secret: secret, + isEnabled: isEnabled, + lastMessageDate: lastMessageDate, + unreadCount: unreadCount, + unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, + isFavorite: isFavorite, + floodScope: floodScope + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ConnectionManager+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ConnectionManager+Testing.swift index 2e4b97fe..aac6b97d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ConnectionManager+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ConnectionManager+Testing.swift @@ -1,82 +1,81 @@ import Foundation -import SwiftData @testable import MC1Services +import SwiftData extension ConnectionManager { - static func createForTesting( - defaults: UserDefaults? = nil - ) throws -> (ConnectionManager, MockBLEStateMachine) { - let container = try PersistenceStore.createContainer(inMemory: true) - let mock = MockBLEStateMachine() - let manager: ConnectionManager - if let defaults { - manager = ConnectionManager(modelContainer: container, defaults: defaults, stateMachine: mock) - } else { - manager = ConnectionManager(modelContainer: container, stateMachine: mock) - } - return (manager, mock) + static func createForTesting( + defaults: UserDefaults? = nil + ) throws -> (ConnectionManager, MockBLEStateMachine) { + let container = try PersistenceStore.createContainer(inMemory: true) + let mock = MockBLEStateMachine() + let manager = if let defaults { + ConnectionManager(modelContainer: container, defaults: defaults, stateMachine: mock) + } else { + ConnectionManager(modelContainer: container, stateMachine: mock) } + return (manager, mock) + } - @MainActor - static func createForPairingTesting( - defaults: UserDefaults? = nil, - transport: MockMeshTransport? = nil, - accessorySetupKit: MockAccessorySetupKitService? = nil - ) throws -> PairingTestEnvironment { - let container = try PersistenceStore.createContainer(inMemory: true) - let stateMachine = MockBLEStateMachine() - let mockTransport = transport ?? MockMeshTransport() - let mockASK = accessorySetupKit ?? MockAccessorySetupKitService() - - let createdSuiteName: String? - let resolvedDefaults: UserDefaults - if let defaults { - resolvedDefaults = defaults - createdSuiteName = nil - } else { - // Per-test suite isolates UserDefaults so parallel runs don't leak - // connectionIntent into one another's init paths. - let name = "test.\(UUID().uuidString)" - resolvedDefaults = UserDefaults(suiteName: name)! - createdSuiteName = name - } + @MainActor + static func createForPairingTesting( + defaults: UserDefaults? = nil, + transport: MockMeshTransport? = nil, + accessorySetupKit: MockAccessorySetupKitService? = nil + ) throws -> PairingTestEnvironment { + let container = try PersistenceStore.createContainer(inMemory: true) + let stateMachine = MockBLEStateMachine() + let mockTransport = transport ?? MockMeshTransport() + let mockASK = accessorySetupKit ?? MockAccessorySetupKitService() - // Wrap the ASK mock in the production iOS pairing adapter so pairing flows exercise - // the real `DevicePairingService` seam while tests still assert on the mock's counts. - let manager = ConnectionManager( - modelContainer: container, - defaults: resolvedDefaults, - stateMachine: stateMachine, - transport: mockTransport, - pairing: AccessorySetupPairingService(accessorySetupKit: mockASK) - ) + let createdSuiteName: String? + let resolvedDefaults: UserDefaults + if let defaults { + resolvedDefaults = defaults + createdSuiteName = nil + } else { + // Per-test suite isolates UserDefaults so parallel runs don't leak + // connectionIntent into one another's init paths. + let name = "test.\(UUID().uuidString)" + resolvedDefaults = UserDefaults(suiteName: name)! + createdSuiteName = name + } - // Cleanup closure: tests `defer { env.cleanup() }` to remove the persistent - // suite and avoid plist accumulation in `~/Library/Preferences`. No-op - // when the caller supplied their own defaults. - let cleanup: () -> Void = { - if let name = createdSuiteName { - UserDefaults().removePersistentDomain(forName: name) - } - } + // Wrap the ASK mock in the production iOS pairing adapter so pairing flows exercise + // the real `DevicePairingService` seam while tests still assert on the mock's counts. + let manager = ConnectionManager( + modelContainer: container, + defaults: resolvedDefaults, + stateMachine: stateMachine, + transport: mockTransport, + pairing: AccessorySetupPairingService(accessorySetupKit: mockASK) + ) - return PairingTestEnvironment( - manager: manager, - stateMachine: stateMachine, - transport: mockTransport, - accessorySetupKit: mockASK, - cleanup: cleanup - ) + // Cleanup closure: tests `defer { env.cleanup() }` to remove the persistent + // suite and avoid plist accumulation in `~/Library/Preferences`. No-op + // when the caller supplied their own defaults. + let cleanup: () -> Void = { + if let name = createdSuiteName { + UserDefaults().removePersistentDomain(forName: name) + } } + + return PairingTestEnvironment( + manager: manager, + stateMachine: stateMachine, + transport: mockTransport, + accessorySetupKit: mockASK, + cleanup: cleanup + ) + } } /// Bundle of mocks and a cleanup closure produced by `createForPairingTesting`. /// Tests `defer { env.cleanup() }` to release the per-test UserDefaults suite. @MainActor struct PairingTestEnvironment { - let manager: ConnectionManager - let stateMachine: MockBLEStateMachine - let transport: MockMeshTransport - let accessorySetupKit: MockAccessorySetupKitService - let cleanup: () -> Void + let manager: ConnectionManager + let stateMachine: MockBLEStateMachine + let transport: MockMeshTransport + let accessorySetupKit: MockAccessorySetupKitService + let cleanup: () -> Void } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift index 1757da5b..fca685c4 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift @@ -1,57 +1,56 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore extension ContactDTO { - - /// Creates a ContactDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let contact = ContactDTO.testContact(radioID: myRadioID) - /// let repeater = ContactDTO.testContact(radioID: myRadioID, typeRawValue: ContactType.repeater.rawValue) - /// ``` - static func testContact( - id: UUID = UUID(), - radioID: UUID = UUID(), - publicKey: Data = Data(repeating: 0xAB, count: 32), - name: String = "TestContact", - typeRawValue: UInt8 = ContactType.chat.rawValue, - flags: UInt8 = 0, - outPathLength: UInt8 = 0xFF, - outPath: Data = Data(), - lastAdvertTimestamp: UInt32 = 0, - latitude: Double = 0, - longitude: Double = 0, - lastModified: UInt32 = 0, - nickname: String? = nil, - isBlocked: Bool = false, - isMuted: Bool = false, - isFavorite: Bool = false, - lastMessageDate: Date? = nil, - unreadCount: Int = 0, - unreadMentionCount: Int = 0 - ) -> ContactDTO { - ContactDTO( - id: id, - radioID: radioID, - publicKey: publicKey, - name: name, - typeRawValue: typeRawValue, - flags: flags, - outPathLength: outPathLength, - outPath: outPath, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: lastModified, - nickname: nickname, - isBlocked: isBlocked, - isMuted: isMuted, - isFavorite: isFavorite, - lastMessageDate: lastMessageDate, - unreadCount: unreadCount, - unreadMentionCount: unreadMentionCount - ) - } + /// Creates a ContactDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let contact = ContactDTO.testContact(radioID: myRadioID) + /// let repeater = ContactDTO.testContact(radioID: myRadioID, typeRawValue: ContactType.repeater.rawValue) + /// ``` + static func testContact( + id: UUID = UUID(), + radioID: UUID = UUID(), + publicKey: Data = Data(repeating: 0xAB, count: 32), + name: String = "TestContact", + typeRawValue: UInt8 = ContactType.chat.rawValue, + flags: UInt8 = 0, + outPathLength: UInt8 = 0xFF, + outPath: Data = Data(), + lastAdvertTimestamp: UInt32 = 0, + latitude: Double = 0, + longitude: Double = 0, + lastModified: UInt32 = 0, + nickname: String? = nil, + isBlocked: Bool = false, + isMuted: Bool = false, + isFavorite: Bool = false, + lastMessageDate: Date? = nil, + unreadCount: Int = 0, + unreadMentionCount: Int = 0 + ) -> ContactDTO { + ContactDTO( + id: id, + radioID: radioID, + publicKey: publicKey, + name: name, + typeRawValue: typeRawValue, + flags: flags, + outPathLength: outPathLength, + outPath: outPath, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified, + nickname: nickname, + isBlocked: isBlocked, + isMuted: isMuted, + isFavorite: isFavorite, + lastMessageDate: lastMessageDate, + unreadCount: unreadCount, + unreadMentionCount: unreadMentionCount + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/DeviceDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/DeviceDTO+Testing.swift index a44da7eb..ffbeca18 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/DeviceDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/DeviceDTO+Testing.swift @@ -2,67 +2,66 @@ import Foundation @testable import MC1Services extension DeviceDTO { - - /// Creates a DeviceDTO with sensible test defaults. Override specific fields with `copy {}`. - /// - /// Usage: - /// ``` - /// let device = DeviceDTO.testDevice() - /// let custom = DeviceDTO.testDevice(id: myID).copy { $0.firmwareVersion = 10 } - /// ``` - static func testDevice( - id: UUID = UUID(), - radioID: UUID? = nil, - publicKey: Data = Data(repeating: 0x01, count: 32), - nodeName: String = "TestDevice", - firmwareVersion: UInt8 = 9, - firmwareVersionString: String = "v1.13.0", - maxContacts: UInt16 = 100, - maxChannels: UInt8 = 8, - frequency: UInt32 = 915_000, - bandwidth: UInt32 = 250_000, - spreadingFactor: UInt8 = 10, - codingRate: UInt8 = 5, - txPower: Int8 = 20, - maxTxPower: Int8 = 20, - manualAddContacts: Bool = false, - multiAcks: UInt8 = 2, - lastConnected: Date = Date(), - lastContactSync: UInt32 = 0, - isActive: Bool = true - ) -> DeviceDTO { - let resolvedRadioID = radioID ?? id - return DeviceDTO( - id: id, - radioID: resolvedRadioID, - publicKey: publicKey, - nodeName: nodeName, - firmwareVersion: firmwareVersion, - firmwareVersionString: firmwareVersionString, - manufacturerName: "TestMfg", - buildDate: "01 Jan 2025", - maxContacts: maxContacts, - maxChannels: maxChannels, - frequency: frequency, - bandwidth: bandwidth, - spreadingFactor: spreadingFactor, - codingRate: codingRate, - txPower: txPower, - maxTxPower: maxTxPower, - latitude: 0, - longitude: 0, - blePin: 0, - manualAddContacts: manualAddContacts, - multiAcks: multiAcks, - telemetryModeBase: 2, - telemetryModeLoc: 0, - telemetryModeEnv: 0, - advertLocationPolicy: 0, - lastConnected: lastConnected, - lastContactSync: lastContactSync, - isActive: isActive, - ocvPreset: nil, - customOCVArrayString: nil - ) - } + /// Creates a DeviceDTO with sensible test defaults. Override specific fields with `copy {}`. + /// + /// Usage: + /// ``` + /// let device = DeviceDTO.testDevice() + /// let custom = DeviceDTO.testDevice(id: myID).copy { $0.firmwareVersion = 10 } + /// ``` + static func testDevice( + id: UUID = UUID(), + radioID: UUID? = nil, + publicKey: Data = Data(repeating: 0x01, count: 32), + nodeName: String = "TestDevice", + firmwareVersion: UInt8 = 9, + firmwareVersionString: String = "v1.13.0", + maxContacts: UInt16 = 100, + maxChannels: UInt8 = 8, + frequency: UInt32 = 915_000, + bandwidth: UInt32 = 250_000, + spreadingFactor: UInt8 = 10, + codingRate: UInt8 = 5, + txPower: Int8 = 20, + maxTxPower: Int8 = 20, + manualAddContacts: Bool = false, + multiAcks: UInt8 = 2, + lastConnected: Date = Date(), + lastContactSync: UInt32 = 0, + isActive: Bool = true + ) -> DeviceDTO { + let resolvedRadioID = radioID ?? id + return DeviceDTO( + id: id, + radioID: resolvedRadioID, + publicKey: publicKey, + nodeName: nodeName, + firmwareVersion: firmwareVersion, + firmwareVersionString: firmwareVersionString, + manufacturerName: "TestMfg", + buildDate: "01 Jan 2025", + maxContacts: maxContacts, + maxChannels: maxChannels, + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + txPower: txPower, + maxTxPower: maxTxPower, + latitude: 0, + longitude: 0, + blePin: 0, + manualAddContacts: manualAddContacts, + multiAcks: multiAcks, + telemetryModeBase: 2, + telemetryModeLoc: 0, + telemetryModeEnv: 0, + advertLocationPolicy: 0, + lastConnected: lastConnected, + lastContactSync: lastContactSync, + isActive: isActive, + ocvPreset: nil, + customOCVArrayString: nil + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ImportResult+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ImportResult+Testing.swift index 6a66194b..aff3c934 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ImportResult+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ImportResult+Testing.swift @@ -2,31 +2,111 @@ import Foundation @testable import MC1Services extension ImportResult { - var devicesInserted: Int { counts[.devices]?.inserted ?? 0 } - var devicesSkipped: Int { counts[.devices]?.skipped ?? 0 } - var contactsInserted: Int { counts[.contacts]?.inserted ?? 0 } - var contactsSkipped: Int { counts[.contacts]?.skipped ?? 0 } - var contactsMerged: Int { counts[.contacts]?.merged ?? 0 } - var channelsInserted: Int { counts[.channels]?.inserted ?? 0 } - var channelsSkipped: Int { counts[.channels]?.skipped ?? 0 } - var channelsMerged: Int { counts[.channels]?.merged ?? 0 } - var channelsDropped: Int { counts[.channels]?.dropped ?? 0 } - var messagesInserted: Int { counts[.messages]?.inserted ?? 0 } - var messagesSkipped: Int { counts[.messages]?.skipped ?? 0 } - var messageRepeatsInserted: Int { counts[.messageRepeats]?.inserted ?? 0 } - var messageRepeatsSkipped: Int { counts[.messageRepeats]?.skipped ?? 0 } - var reactionsInserted: Int { counts[.reactions]?.inserted ?? 0 } - var reactionsSkipped: Int { counts[.reactions]?.skipped ?? 0 } - var roomMessagesInserted: Int { counts[.roomMessages]?.inserted ?? 0 } - var roomMessagesSkipped: Int { counts[.roomMessages]?.skipped ?? 0 } - var remoteNodeSessionsInserted: Int { counts[.remoteNodeSessions]?.inserted ?? 0 } - var remoteNodeSessionsSkipped: Int { counts[.remoteNodeSessions]?.skipped ?? 0 } - var remoteNodeSessionsMerged: Int { counts[.remoteNodeSessions]?.merged ?? 0 } - var savedTracePathsInserted: Int { counts[.savedTracePaths]?.inserted ?? 0 } - var savedTracePathsSkipped: Int { counts[.savedTracePaths]?.skipped ?? 0 } - var savedTracePathsMerged: Int { counts[.savedTracePaths]?.merged ?? 0 } - var blockedChannelSendersInserted: Int { counts[.blockedChannelSenders]?.inserted ?? 0 } - var blockedChannelSendersSkipped: Int { counts[.blockedChannelSenders]?.skipped ?? 0 } - var nodeStatusSnapshotsInserted: Int { counts[.nodeStatusSnapshots]?.inserted ?? 0 } - var nodeStatusSnapshotsSkipped: Int { counts[.nodeStatusSnapshots]?.skipped ?? 0 } + var devicesInserted: Int { + counts[.devices]?.inserted ?? 0 + } + + var devicesSkipped: Int { + counts[.devices]?.skipped ?? 0 + } + + var contactsInserted: Int { + counts[.contacts]?.inserted ?? 0 + } + + var contactsSkipped: Int { + counts[.contacts]?.skipped ?? 0 + } + + var contactsMerged: Int { + counts[.contacts]?.merged ?? 0 + } + + var channelsInserted: Int { + counts[.channels]?.inserted ?? 0 + } + + var channelsSkipped: Int { + counts[.channels]?.skipped ?? 0 + } + + var channelsMerged: Int { + counts[.channels]?.merged ?? 0 + } + + var channelsDropped: Int { + counts[.channels]?.dropped ?? 0 + } + + var messagesInserted: Int { + counts[.messages]?.inserted ?? 0 + } + + var messagesSkipped: Int { + counts[.messages]?.skipped ?? 0 + } + + var messageRepeatsInserted: Int { + counts[.messageRepeats]?.inserted ?? 0 + } + + var messageRepeatsSkipped: Int { + counts[.messageRepeats]?.skipped ?? 0 + } + + var reactionsInserted: Int { + counts[.reactions]?.inserted ?? 0 + } + + var reactionsSkipped: Int { + counts[.reactions]?.skipped ?? 0 + } + + var roomMessagesInserted: Int { + counts[.roomMessages]?.inserted ?? 0 + } + + var roomMessagesSkipped: Int { + counts[.roomMessages]?.skipped ?? 0 + } + + var remoteNodeSessionsInserted: Int { + counts[.remoteNodeSessions]?.inserted ?? 0 + } + + var remoteNodeSessionsSkipped: Int { + counts[.remoteNodeSessions]?.skipped ?? 0 + } + + var remoteNodeSessionsMerged: Int { + counts[.remoteNodeSessions]?.merged ?? 0 + } + + var savedTracePathsInserted: Int { + counts[.savedTracePaths]?.inserted ?? 0 + } + + var savedTracePathsSkipped: Int { + counts[.savedTracePaths]?.skipped ?? 0 + } + + var savedTracePathsMerged: Int { + counts[.savedTracePaths]?.merged ?? 0 + } + + var blockedChannelSendersInserted: Int { + counts[.blockedChannelSenders]?.inserted ?? 0 + } + + var blockedChannelSendersSkipped: Int { + counts[.blockedChannelSenders]?.skipped ?? 0 + } + + var nodeStatusSnapshotsInserted: Int { + counts[.nodeStatusSnapshots]?.inserted ?? 0 + } + + var nodeStatusSnapshotsSkipped: Int { + counts[.nodeStatusSnapshots]?.skipped ?? 0 + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageDTO+Testing.swift index 32b6f007..9428f0c2 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageDTO+Testing.swift @@ -2,114 +2,113 @@ import Foundation @testable import MC1Services extension MessageDTO { + /// Creates a MessageDTO with sensible test defaults for a direct message. + /// + /// Usage: + /// ``` + /// let message = MessageDTO.testDirectMessage(radioID: myRadioID, contactID: contactID) + /// let failed = MessageDTO.testDirectMessage(radioID: id, contactID: cID, status: .failed) + /// ``` + static func testDirectMessage( + id: UUID = UUID(), + radioID: UUID = UUID(), + contactID: UUID = UUID(), + text: String = "Test message", + timestamp: UInt32 = UInt32(Date().timeIntervalSince1970), + createdAt: Date = Date(), + direction: MessageDirection = .outgoing, + status: MessageStatus = .pending, + textType: TextType = .plain, + ackCode: UInt32? = nil, + pathLength: UInt8 = 0, + snr: Double? = nil, + pathNodes: Data? = nil, + senderKeyPrefix: Data? = nil, + isRead: Bool = false, + replyToID: UUID? = nil, + roundTripTime: UInt32? = nil, + heardRepeats: Int = 0, + sendCount: Int = 1, + retryAttempt: Int = 0, + maxRetryAttempts: Int = 0, + containsSelfMention: Bool = false + ) -> MessageDTO { + MessageDTO( + id: id, + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: text, + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: status, + textType: textType, + ackCode: ackCode, + pathLength: pathLength, + snr: snr, + pathNodes: pathNodes, + senderKeyPrefix: senderKeyPrefix, + senderNodeName: nil, + isRead: isRead, + replyToID: replyToID, + roundTripTime: roundTripTime, + heardRepeats: heardRepeats, + sendCount: sendCount, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts, + containsSelfMention: containsSelfMention + ) + } - /// Creates a MessageDTO with sensible test defaults for a direct message. - /// - /// Usage: - /// ``` - /// let message = MessageDTO.testDirectMessage(radioID: myRadioID, contactID: contactID) - /// let failed = MessageDTO.testDirectMessage(radioID: id, contactID: cID, status: .failed) - /// ``` - static func testDirectMessage( - id: UUID = UUID(), - radioID: UUID = UUID(), - contactID: UUID = UUID(), - text: String = "Test message", - timestamp: UInt32 = UInt32(Date().timeIntervalSince1970), - createdAt: Date = Date(), - direction: MessageDirection = .outgoing, - status: MessageStatus = .pending, - textType: TextType = .plain, - ackCode: UInt32? = nil, - pathLength: UInt8 = 0, - snr: Double? = nil, - pathNodes: Data? = nil, - senderKeyPrefix: Data? = nil, - isRead: Bool = false, - replyToID: UUID? = nil, - roundTripTime: UInt32? = nil, - heardRepeats: Int = 0, - sendCount: Int = 1, - retryAttempt: Int = 0, - maxRetryAttempts: Int = 0, - containsSelfMention: Bool = false - ) -> MessageDTO { - MessageDTO( - id: id, - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: text, - timestamp: timestamp, - createdAt: createdAt, - direction: direction, - status: status, - textType: textType, - ackCode: ackCode, - pathLength: pathLength, - snr: snr, - pathNodes: pathNodes, - senderKeyPrefix: senderKeyPrefix, - senderNodeName: nil, - isRead: isRead, - replyToID: replyToID, - roundTripTime: roundTripTime, - heardRepeats: heardRepeats, - sendCount: sendCount, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts, - containsSelfMention: containsSelfMention - ) - } - - /// Creates a MessageDTO with sensible test defaults for a channel message. - /// - /// Usage: - /// ``` - /// let message = MessageDTO.testChannelMessage(radioID: myRadioID, channelIndex: 0) - /// ``` - static func testChannelMessage( - id: UUID = UUID(), - radioID: UUID = UUID(), - channelIndex: UInt8 = 0, - text: String = "Test channel message", - timestamp: UInt32 = UInt32(Date().timeIntervalSince1970), - createdAt: Date = Date(), - direction: MessageDirection = .outgoing, - status: MessageStatus = .sent, - textType: TextType = .plain, - pathLength: UInt8 = 0, - snr: Double? = nil, - senderNodeName: String? = nil, - isRead: Bool = false, - heardRepeats: Int = 0, - sendCount: Int = 1, - containsSelfMention: Bool = false - ) -> MessageDTO { - MessageDTO( - id: id, - radioID: radioID, - contactID: nil, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - createdAt: createdAt, - direction: direction, - status: status, - textType: textType, - ackCode: nil, - pathLength: pathLength, - snr: snr, - senderKeyPrefix: nil, - senderNodeName: senderNodeName, - isRead: isRead, - replyToID: nil, - roundTripTime: nil, - heardRepeats: heardRepeats, - sendCount: sendCount, - retryAttempt: 0, - maxRetryAttempts: 0, - containsSelfMention: containsSelfMention - ) - } + /// Creates a MessageDTO with sensible test defaults for a channel message. + /// + /// Usage: + /// ``` + /// let message = MessageDTO.testChannelMessage(radioID: myRadioID, channelIndex: 0) + /// ``` + static func testChannelMessage( + id: UUID = UUID(), + radioID: UUID = UUID(), + channelIndex: UInt8 = 0, + text: String = "Test channel message", + timestamp: UInt32 = UInt32(Date().timeIntervalSince1970), + createdAt: Date = Date(), + direction: MessageDirection = .outgoing, + status: MessageStatus = .sent, + textType: TextType = .plain, + pathLength: UInt8 = 0, + snr: Double? = nil, + senderNodeName: String? = nil, + isRead: Bool = false, + heardRepeats: Int = 0, + sendCount: Int = 1, + containsSelfMention: Bool = false + ) -> MessageDTO { + MessageDTO( + id: id, + radioID: radioID, + contactID: nil, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: status, + textType: textType, + ackCode: nil, + pathLength: pathLength, + snr: snr, + senderKeyPrefix: nil, + senderNodeName: senderNodeName, + isRead: isRead, + replyToID: nil, + roundTripTime: nil, + heardRepeats: heardRepeats, + sendCount: sendCount, + retryAttempt: 0, + maxRetryAttempts: 0, + containsSelfMention: containsSelfMention + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageRepeatDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageRepeatDTO+Testing.swift index 7641856a..3a4fa02a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageRepeatDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageRepeatDTO+Testing.swift @@ -2,32 +2,31 @@ import Foundation @testable import MC1Services extension MessageRepeatDTO { - - /// Creates a MessageRepeatDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let repeat = MessageRepeatDTO.testRepeat(messageID: myMessageID) - /// ``` - static func testRepeat( - id: UUID = UUID(), - messageID: UUID, - receivedAt: Date = Date(), - pathNodes: Data = Data([0x31]), - pathLength: UInt8 = 0, - snr: Double? = 8.5, - rssi: Int? = -90, - rxLogEntryID: UUID? = nil - ) -> MessageRepeatDTO { - MessageRepeatDTO( - id: id, - messageID: messageID, - receivedAt: receivedAt, - pathNodes: pathNodes, - pathLength: pathLength, - snr: snr, - rssi: rssi, - rxLogEntryID: rxLogEntryID - ) - } + /// Creates a MessageRepeatDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let repeat = MessageRepeatDTO.testRepeat(messageID: myMessageID) + /// ``` + static func testRepeat( + id: UUID = UUID(), + messageID: UUID, + receivedAt: Date = Date(), + pathNodes: Data = Data([0x31]), + pathLength: UInt8 = 0, + snr: Double? = 8.5, + rssi: Int? = -90, + rxLogEntryID: UUID? = nil + ) -> MessageRepeatDTO { + MessageRepeatDTO( + id: id, + messageID: messageID, + receivedAt: receivedAt, + pathNodes: pathNodes, + pathLength: pathLength, + snr: snr, + rssi: rssi, + rxLogEntryID: rxLogEntryID + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageService+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageService+Testing.swift index 8e5fa387..3de6453e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageService+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageService+Testing.swift @@ -1,150 +1,150 @@ import Foundation -import MeshCoreTestSupport -import Testing @testable import MC1Services @testable import MeshCore +import MeshCoreTestSupport +import Testing extension MessageService { - static func createForTesting( - defaultTimeout: TimeInterval = 5.0, - connectTransport: Bool = false, - config: MessageServiceConfig = .default - ) async throws -> (MessageService, PersistenceStore) { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let transport = SimulatorMockTransport() - if connectTransport { - try await transport.connect() - } - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: defaultTimeout) - ) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil, config: config) - return (service, dataStore) + static func createForTesting( + defaultTimeout: TimeInterval = 5.0, + connectTransport: Bool = false, + config: MessageServiceConfig = .default + ) async throws -> (MessageService, PersistenceStore) { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let transport = SimulatorMockTransport() + if connectTransport { + try await transport.connect() } + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: defaultTimeout) + ) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil, config: config) + return (service, dataStore) + } - func insertInFlightRetryForTest(_ messageID: UUID) { - inFlightRetries.insert(messageID) - } + func insertInFlightRetryForTest(_ messageID: UUID) { + inFlightRetries.insert(messageID) + } - func setPendingAckForTest(_ tracking: PendingAck) { - pendingAcks[tracking.messageID] = tracking - } + func setPendingAckForTest(_ tracking: PendingAck) { + pendingAcks[tracking.messageID] = tracking + } - func pendingAckForTest(_ messageID: UUID) -> PendingAck? { - pendingAcks[messageID] - } + func pendingAckForTest(_ messageID: UUID) -> PendingAck? { + pendingAcks[messageID] + } - /// Ends the status-event stream and returns everything `stream` buffered. - /// Subscribe via `statusEvents()` before triggering the behavior under - /// test: registration is synchronous and production yields happen before - /// the triggering call returns, so the drained array is complete. - nonisolated func drainStatusEvents(_ stream: AsyncStream) async -> [MessageStatusEvent] { - finishStatusEvents() - var events: [MessageStatusEvent] = [] - for await event in stream { - events.append(event) - } - return events + /// Ends the status-event stream and returns everything `stream` buffered. + /// Subscribe via `statusEvents()` before triggering the behavior under + /// test: registration is synchronous and production yields happen before + /// the triggering call returns, so the drained array is complete. + nonisolated func drainStatusEvents(_ stream: AsyncStream) async -> [MessageStatusEvent] { + finishStatusEvents() + var events: [MessageStatusEvent] = [] + for await event in stream { + events.append(event) } + return events + } - /// The concrete session injected by `createForTesting`, for test-only hooks - /// (`dispatchForTesting`, `subscriberCountForTest`) the protocol does not carry. - var sessionForTest: MeshCoreSession { - guard let concrete = session as? MeshCoreSession else { - fatalError("MessageService under test must be built with a concrete MeshCoreSession") - } - return concrete + /// The concrete session injected by `createForTesting`, for test-only hooks + /// (`dispatchForTesting`, `subscriberCountForTest`) the protocol does not carry. + var sessionForTest: MeshCoreSession { + guard let concrete = session as? MeshCoreSession else { + fatalError("MessageService under test must be built with a concrete MeshCoreSession") } + return concrete + } - func installSelfInfoForTest(publicKey: Data = Data(repeating: 0xAB, count: 32)) async { - await sessionForTest.installSelfInfoForTest(.testSelfInfo(publicKey: publicKey)) - } + func installSelfInfoForTest(publicKey: Data = Data(repeating: 0xAB, count: 32)) async { + await sessionForTest.installSelfInfoForTest(.testSelfInfo(publicKey: publicKey)) + } - /// Waits until the session's dispatcher holds exactly `expectedCount` - /// subscriptions, polling `subscriberCountForTest` without sleeping in - /// the test itself. - /// - /// Required because `startEventMonitoring()` spawns a `Task` whose subscribe - /// call races any subsequent `dispatchForTesting`. A one-hop `await` into the - /// session actor is not sufficient: the listener Task must traverse - /// session → dispatcher before the dispatch arrives. - /// - /// On restart, callers should first wait for `expectedCount: 0` to confirm - /// the previous subscription has torn down (its `onTermination` cleanup - /// runs on its own Task) before waiting for the new subscription. - func waitForSubscriberCount( - _ expectedCount: Int, - timeout: Duration = .milliseconds(500) - ) async { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if await sessionForTest.subscriberCountForTest == expectedCount { - return - } - await Task.yield() - } - Issue.record("subscriber count did not reach \(expectedCount) within \(timeout)") + /// Waits until the session's dispatcher holds exactly `expectedCount` + /// subscriptions, polling `subscriberCountForTest` without sleeping in + /// the test itself. + /// + /// Required because `startEventMonitoring()` spawns a `Task` whose subscribe + /// call races any subsequent `dispatchForTesting`. A one-hop `await` into the + /// session actor is not sufficient: the listener Task must traverse + /// session → dispatcher before the dispatch arrives. + /// + /// On restart, callers should first wait for `expectedCount: 0` to confirm + /// the previous subscription has torn down (its `onTermination` cleanup + /// runs on its own Task) before waiting for the new subscription. + func waitForSubscriberCount( + _ expectedCount: Int, + timeout: Duration = .seconds(10) + ) async { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if await sessionForTest.subscriberCountForTest == expectedCount { + return + } + try? await Task.sleep(for: .milliseconds(10)) } + Issue.record("subscriber count did not reach \(expectedCount) within \(timeout)") + } } extension SelfInfo { - static func testSelfInfo(publicKey: Data = Data(repeating: 0xAB, count: 32)) -> SelfInfo { - SelfInfo( - advertisementType: 0, - txPower: 20, - maxTxPower: 20, - publicKey: publicKey, - latitude: 0, - longitude: 0, - multiAcks: 2, - advertisementLocationPolicy: 0, - telemetryModeEnvironment: 0, - telemetryModeLocation: 0, - telemetryModeBase: 2, - manualAddContacts: false, - radioFrequency: 915.0, - radioBandwidth: 250.0, - radioSpreadingFactor: 10, - radioCodingRate: 5, - name: "TestNode" - ) - } + static func testSelfInfo(publicKey: Data = Data(repeating: 0xAB, count: 32)) -> SelfInfo { + SelfInfo( + advertisementType: 0, + txPower: 20, + maxTxPower: 20, + publicKey: publicKey, + latitude: 0, + longitude: 0, + multiAcks: 2, + advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 2, + manualAddContacts: false, + radioFrequency: 915.0, + radioBandwidth: 250.0, + radioSpreadingFactor: 10, + radioCodingRate: 5, + name: "TestNode" + ) + } } -extension Array where Element == MessageStatusEvent { - /// Message IDs carried by `.failed` events, in yield order. - var failedIDs: [UUID] { - compactMap { - if case .failed(let id) = $0 { return id } - return nil - } +extension [MessageStatusEvent] { + /// Message IDs carried by `.failed` events, in yield order. + var failedIDs: [UUID] { + compactMap { + if case let .failed(id) = $0 { return id } + return nil } + } - /// Message IDs carried by `.resent` events, in yield order. - var resentIDs: [UUID] { - compactMap { - if case .resent(let id) = $0 { return id } - return nil - } + /// Message IDs carried by `.resent` events, in yield order. + var resentIDs: [UUID] { + compactMap { + if case let .resent(id) = $0 { return id } + return nil } + } - /// Message IDs carried by `.statusResolved` events, in yield order. - var resolvedIDs: [UUID] { - compactMap { - if case .statusResolved(let id, _, _) = $0 { return id } - return nil - } + /// Message IDs carried by `.statusResolved` events, in yield order. + var resolvedIDs: [UUID] { + compactMap { + if case let .statusResolved(id, _, _) = $0 { return id } + return nil } + } - /// Payloads carried by `.retrying` events, in yield order. - var retryUpdates: [(messageID: UUID, attempt: Int, maxAttempts: Int)] { - compactMap { - if case .retrying(let id, let attempt, let maxAttempts) = $0 { - return (id, attempt, maxAttempts) - } - return nil - } + /// Payloads carried by `.retrying` events, in yield order. + var retryUpdates: [(messageID: UUID, attempt: Int, maxAttempts: Int)] { + compactMap { + if case let .retrying(id, attempt, maxAttempts) = $0 { + return (id, attempt, maxAttempts) + } + return nil } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/NodeStatusSnapshotDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/NodeStatusSnapshotDTO+Testing.swift index 116e9941..cbb8fc3a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/NodeStatusSnapshotDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/NodeStatusSnapshotDTO+Testing.swift @@ -2,36 +2,55 @@ import Foundation @testable import MC1Services extension NodeStatusSnapshotDTO { - - /// Creates a NodeStatusSnapshotDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let snapshot = NodeStatusSnapshotDTO.testSnapshot(nodePublicKey: key) - /// ``` - static func testSnapshot( - id: UUID = UUID(), - timestamp: Date = Date(), - nodePublicKey: Data = Data(repeating: 0xDD, count: 32), - batteryMillivolts: UInt16? = 3800, - lastSNR: Double? = 9.0, - lastRSSI: Int16? = -85, - noiseFloor: Int16? = -110, - uptimeSeconds: UInt32? = 3600, - neighborSnapshots: [NeighborSnapshotEntry]? = nil, - telemetryEntries: [TelemetrySnapshotEntry]? = nil - ) -> NodeStatusSnapshotDTO { - NodeStatusSnapshotDTO( - id: id, - timestamp: timestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: batteryMillivolts, - lastSNR: lastSNR, - lastRSSI: lastRSSI, - noiseFloor: noiseFloor, - uptimeSeconds: uptimeSeconds, - neighborSnapshots: neighborSnapshots, - telemetryEntries: telemetryEntries - ) - } + /// Creates a NodeStatusSnapshotDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let snapshot = NodeStatusSnapshotDTO.testSnapshot(nodePublicKey: key) + /// ``` + static func testSnapshot( + id: UUID = UUID(), + timestamp: Date = Date(), + nodePublicKey: Data = Data(repeating: 0xDD, count: 32), + batteryMillivolts: UInt16? = 3800, + lastSNR: Double? = 9.0, + lastRSSI: Int16? = -85, + noiseFloor: Int16? = -110, + uptimeSeconds: UInt32? = 3600, + rxAirtimeSeconds: UInt32? = nil, + packetsSent: UInt32? = nil, + packetsReceived: UInt32? = nil, + receiveErrors: UInt32? = nil, + sentDirect: UInt32? = nil, + sentFlood: UInt32? = nil, + receivedDirect: UInt32? = nil, + receivedFlood: UInt32? = nil, + directDuplicates: UInt32? = nil, + floodDuplicates: UInt32? = nil, + neighborSnapshots: [NeighborSnapshotEntry]? = nil, + telemetryEntries: [TelemetrySnapshotEntry]? = nil + ) -> NodeStatusSnapshotDTO { + NodeStatusSnapshotDTO( + id: id, + timestamp: timestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: batteryMillivolts, + lastSNR: lastSNR, + lastRSSI: lastRSSI, + noiseFloor: noiseFloor, + uptimeSeconds: uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: packetsSent, + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + sentDirect: sentDirect, + sentFlood: sentFlood, + receivedDirect: receivedDirect, + receivedFlood: receivedFlood, + directDuplicates: directDuplicates, + floodDuplicates: floodDuplicates, + neighborSnapshots: neighborSnapshots, + telemetryEntries: telemetryEntries + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+TestFetch.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+TestFetch.swift index 58914b29..a857cf66 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+TestFetch.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+TestFetch.swift @@ -1,74 +1,73 @@ import Foundation -import SwiftData @testable import MC1Services +import SwiftData /// Test-only radio-scoped fetchAll helpers. These exist solely so the backup /// integration tests can assert on what a specific radio imported, without /// widening the production `PersistenceStore` API. -extension PersistenceStore { - - public func fetchAllContacts(radioID: UUID) throws -> [ContactDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - .map { ContactDTO(from: $0) } - } +public extension PersistenceStore { + func fetchAllContacts(radioID: UUID) throws -> [ContactDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + .map { ContactDTO(from: $0) } + } - public func fetchAllChannels(radioID: UUID) throws -> [ChannelDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - .map { ChannelDTO(from: $0) } - } + func fetchAllChannels(radioID: UUID) throws -> [ChannelDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + .map { ChannelDTO(from: $0) } + } - public func fetchAllMessages() throws -> [MessageDTO] { - try modelContext.fetch(FetchDescriptor()).map { MessageDTO(from: $0) } - } + func fetchAllMessages() throws -> [MessageDTO] { + try modelContext.fetch(FetchDescriptor()).map { MessageDTO(from: $0) } + } - public func fetchAllMessages(radioID: UUID) throws -> [MessageDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - .map { MessageDTO(from: $0) } - } + func fetchAllMessages(radioID: UUID) throws -> [MessageDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + .map { MessageDTO(from: $0) } + } - public func fetchAllReactions(radioID: UUID) throws -> [ReactionDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - .map { ReactionDTO(from: $0) } - } + func fetchAllReactions(radioID: UUID) throws -> [ReactionDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + .map { ReactionDTO(from: $0) } + } - /// Inserts a raw Message with an explicit `sortDate` distinct from `createdAt`, - /// simulating a row persisted before the `sortDate` column existed (which comes - /// up with the `Date.distantPast` schema default). The production save path - /// always pins `sortDate` to `createdAt`, so this is test-only. - public func insertMessageWithSortDate( - id: UUID, - radioID: UUID, - text: String, - createdAt: Date, - sortDate: Date - ) throws { - let message = Message( - id: id, - radioID: radioID, - text: text, - createdAt: createdAt, - sortDate: sortDate - ) - modelContext.insert(message) - try modelContext.save() - } + /// Inserts a raw Message with an explicit `sortDate` distinct from `createdAt`, + /// simulating a row persisted before the `sortDate` column existed (which comes + /// up with the `Date.distantPast` schema default). The production save path + /// always pins `sortDate` to `createdAt`, so this is test-only. + func insertMessageWithSortDate( + id: UUID, + radioID: UUID, + text: String, + createdAt: Date, + sortDate: Date + ) throws { + let message = Message( + id: id, + radioID: radioID, + text: text, + createdAt: createdAt, + sortDate: sortDate + ) + modelContext.insert(message) + try modelContext.save() + } - /// Overwrites the `sortDate` on an existing Message row (test-only). - public func setMessageSortDate(id: UUID, sortDate: Date) throws { - let targetID = id - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let message = try modelContext.fetch(descriptor).first else { return } - message.sortDate = sortDate - try modelContext.save() - } + /// Overwrites the `sortDate` on an existing Message row (test-only). + func setMessageSortDate(id: UUID, sortDate: Date) throws { + let targetID = id + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { return } + message.sortDate = sortDate + try modelContext.save() + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift index 7eb60b92..9d807a64 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift @@ -2,25 +2,24 @@ import Foundation @testable import MC1Services extension PersistenceStore { - - /// Creates an in-memory persistence store pre-populated with a test device. - static func createTestDataStore( - radioID: UUID, - maxChannels: UInt8 = 8, - lastContactSync: UInt32 = 0 - ) async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let device = DeviceDTO.testDevice( - id: radioID, - radioID: radioID, - firmwareVersion: 8, - firmwareVersionString: "v1.0.0", - maxChannels: maxChannels, - multiAcks: 0, - lastContactSync: lastContactSync - ) - try await store.saveDevice(device) - return store - } + /// Creates an in-memory persistence store pre-populated with a test device. + static func createTestDataStore( + radioID: UUID, + maxChannels: UInt8 = 8, + lastContactSync: UInt32 = 0 + ) async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let device = DeviceDTO.testDevice( + id: radioID, + radioID: radioID, + firmwareVersion: 8, + firmwareVersionString: "v1.0.0", + maxChannels: maxChannels, + multiAcks: 0, + lastContactSync: lastContactSync + ) + try await store.saveDevice(device) + return store + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ReactionDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ReactionDTO+Testing.swift index a2412362..71ec1672 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ReactionDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ReactionDTO+Testing.swift @@ -2,36 +2,35 @@ import Foundation @testable import MC1Services extension ReactionDTO { - - /// Creates a ReactionDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let reaction = ReactionDTO.testReaction(messageID: myMessageID, radioID: myRadioID) - /// ``` - static func testReaction( - id: UUID = UUID(), - messageID: UUID, - radioID: UUID, - emoji: String = "👍", - senderName: String = "TestSender", - messageHash: String = "a1b2c3d4", - rawText: String = "+m:a1b2c3d4:👍", - receivedAt: Date = Date(), - channelIndex: UInt8? = 0, - contactID: UUID? = nil - ) -> ReactionDTO { - ReactionDTO( - id: id, - messageID: messageID, - emoji: emoji, - senderName: senderName, - messageHash: messageHash, - rawText: rawText, - receivedAt: receivedAt, - channelIndex: channelIndex, - contactID: contactID, - radioID: radioID - ) - } + /// Creates a ReactionDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let reaction = ReactionDTO.testReaction(messageID: myMessageID, radioID: myRadioID) + /// ``` + static func testReaction( + id: UUID = UUID(), + messageID: UUID, + radioID: UUID, + emoji: String = "👍", + senderName: String = "TestSender", + messageHash: String = "a1b2c3d4", + rawText: String = "+m:a1b2c3d4:👍", + receivedAt: Date = Date(), + channelIndex: UInt8? = 0, + contactID: UUID? = nil + ) -> ReactionDTO { + ReactionDTO( + id: id, + messageID: messageID, + emoji: emoji, + senderName: senderName, + messageHash: messageHash, + rawText: rawText, + receivedAt: receivedAt, + channelIndex: channelIndex, + contactID: contactID, + radioID: radioID + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/RemoteNodeSessionDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/RemoteNodeSessionDTO+Testing.swift index 58941e41..4e70a958 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/RemoteNodeSessionDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/RemoteNodeSessionDTO+Testing.swift @@ -2,49 +2,48 @@ import Foundation @testable import MC1Services extension RemoteNodeSessionDTO { - - /// Creates a RemoteNodeSessionDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let session = RemoteNodeSessionDTO.testSession(radioID: myRadioID) - /// let room = RemoteNodeSessionDTO.testSession(radioID: myRadioID, role: .roomServer) - /// ``` - static func testSession( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data = Data(repeating: 0xCC, count: 32), - name: String = "TestNode", - role: RemoteNodeRole = .roomServer, - latitude: Double = 0, - longitude: Double = 0, - isConnected: Bool = false, - permissionLevel: RoomPermissionLevel = .guest, - lastConnectedDate: Date? = nil, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - neighborCount: Int = 0, - lastSyncTimestamp: UInt32 = 0, - lastMessageDate: Date? = nil - ) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: id, - radioID: radioID, - publicKey: publicKey, - name: name, - role: role, - latitude: latitude, - longitude: longitude, - isConnected: isConnected, - permissionLevel: permissionLevel, - lastConnectedDate: lastConnectedDate, - unreadCount: unreadCount, - notificationLevel: notificationLevel, - isFavorite: isFavorite, - neighborCount: neighborCount, - lastSyncTimestamp: lastSyncTimestamp, - lastMessageDate: lastMessageDate - ) - } + /// Creates a RemoteNodeSessionDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let session = RemoteNodeSessionDTO.testSession(radioID: myRadioID) + /// let room = RemoteNodeSessionDTO.testSession(radioID: myRadioID, role: .roomServer) + /// ``` + static func testSession( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data = Data(repeating: 0xCC, count: 32), + name: String = "TestNode", + role: RemoteNodeRole = .roomServer, + latitude: Double = 0, + longitude: Double = 0, + isConnected: Bool = false, + permissionLevel: RoomPermissionLevel = .guest, + lastConnectedDate: Date? = nil, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + neighborCount: Int = 0, + lastSyncTimestamp: UInt32 = 0, + lastMessageDate: Date? = nil + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, + radioID: radioID, + publicKey: publicKey, + name: name, + role: role, + latitude: latitude, + longitude: longitude, + isConnected: isConnected, + permissionLevel: permissionLevel, + lastConnectedDate: lastConnectedDate, + unreadCount: unreadCount, + notificationLevel: notificationLevel, + isFavorite: isFavorite, + neighborCount: neighborCount, + lastSyncTimestamp: lastSyncTimestamp, + lastMessageDate: lastMessageDate + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift index f7d52904..23cdf18e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift @@ -2,34 +2,33 @@ import Foundation @testable import MC1Services extension RoomMessageDTO { - - /// Creates a RoomMessageDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let message = RoomMessageDTO.testRoomMessage(sessionID: mySessionID) - /// ``` - static func testRoomMessage( - id: UUID = UUID(), - sessionID: UUID, - authorKeyPrefix: Data = Data([0xAB, 0xCD, 0xEF, 0x01]), - authorName: String? = "TestAuthor", - text: String = "Hello from the room", - timestamp: UInt32 = 1_700_000_000, - createdAt: Date = Date(), - isFromSelf: Bool = false, - status: MessageStatus = .delivered - ) -> RoomMessageDTO { - RoomMessageDTO( - id: id, - sessionID: sessionID, - authorKeyPrefix: authorKeyPrefix, - authorName: authorName, - text: text, - timestamp: timestamp, - createdAt: createdAt, - isFromSelf: isFromSelf, - status: status - ) - } + /// Creates a RoomMessageDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let message = RoomMessageDTO.testRoomMessage(sessionID: mySessionID) + /// ``` + static func testRoomMessage( + id: UUID = UUID(), + sessionID: UUID, + authorKeyPrefix: Data = Data([0xAB, 0xCD, 0xEF, 0x01]), + authorName: String? = "TestAuthor", + text: String = "Hello from the room", + timestamp: UInt32 = 1_700_000_000, + createdAt: Date = Date(), + isFromSelf: Bool = false, + status: MessageStatus = .delivered + ) -> RoomMessageDTO { + RoomMessageDTO( + id: id, + sessionID: sessionID, + authorKeyPrefix: authorKeyPrefix, + authorName: authorName, + text: text, + timestamp: timestamp, + createdAt: createdAt, + isFromSelf: isFromSelf, + status: status + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/SavedTracePathDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/SavedTracePathDTO+Testing.swift index 0bc185de..4a272074 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/SavedTracePathDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/SavedTracePathDTO+Testing.swift @@ -2,55 +2,53 @@ import Foundation @testable import MC1Services extension SavedTracePathDTO { - - /// Creates a SavedTracePathDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let path = SavedTracePathDTO.testPath(radioID: myRadioID) - /// ``` - static func testPath( - id: UUID = UUID(), - radioID: UUID, - name: String = "Test Path", - pathBytes: Data = Data([0x31, 0xA7]), - hashSize: Int = 1, - createdDate: Date = Date(), - runs: [TracePathRunDTO] = [] - ) -> SavedTracePathDTO { - SavedTracePathDTO( - id: id, - radioID: radioID, - name: name, - pathBytes: pathBytes, - hashSize: hashSize, - createdDate: createdDate, - runs: runs - ) - } + /// Creates a SavedTracePathDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let path = SavedTracePathDTO.testPath(radioID: myRadioID) + /// ``` + static func testPath( + id: UUID = UUID(), + radioID: UUID, + name: String = "Test Path", + pathBytes: Data = Data([0x31, 0xA7]), + hashSize: Int = 1, + createdDate: Date = Date(), + runs: [TracePathRunDTO] = [] + ) -> SavedTracePathDTO { + SavedTracePathDTO( + id: id, + radioID: radioID, + name: name, + pathBytes: pathBytes, + hashSize: hashSize, + createdDate: createdDate, + runs: runs + ) + } } extension TracePathRunDTO { - - /// Creates a TracePathRunDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let run = TracePathRunDTO.testRun() - /// ``` - static func testRun( - id: UUID = UUID(), - date: Date = Date(), - success: Bool = true, - roundTripMs: Int = 250, - hopsSNR: [Double] = [8.5, 7.0] - ) -> TracePathRunDTO { - TracePathRunDTO( - id: id, - date: date, - success: success, - roundTripMs: roundTripMs, - hopsSNR: hopsSNR - ) - } + /// Creates a TracePathRunDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let run = TracePathRunDTO.testRun() + /// ``` + static func testRun( + id: UUID = UUID(), + date: Date = Date(), + success: Bool = true, + roundTripMs: Int = 250, + hopsSNR: [Double] = [8.5, 7.0] + ) -> TracePathRunDTO { + TracePathRunDTO( + id: id, + date: date, + success: success, + roundTripMs: roundTripMs, + hopsSNR: hopsSNR + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/TestPolling.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/TestPolling.swift index 2447a8da..68e37d91 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/TestPolling.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/TestPolling.swift @@ -9,19 +9,19 @@ import Foundation /// - message: Failure message if the timeout expires. /// - condition: An async closure returning `true` when the expected state is reached. func waitUntil( - timeout: Duration = .seconds(2), - pollingInterval: Duration = .milliseconds(10), - _ message: String = "waitUntil timed out", - condition: @escaping @MainActor () async -> Bool + timeout: Duration = .seconds(10), + pollingInterval: Duration = .milliseconds(10), + _ message: String = "waitUntil timed out", + condition: @escaping @MainActor () async -> Bool ) async throws { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - if await condition() { return } - try await Task.sleep(for: pollingInterval) - } + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { if await condition() { return } - struct WaitTimeoutError: Error, CustomStringConvertible { - let description: String - } - throw WaitTimeoutError(description: message) + try await Task.sleep(for: pollingInterval) + } + if await condition() { return } + struct WaitTimeoutError: Error, CustomStringConvertible { + let description: String + } + throw WaitTimeoutError(description: message) } diff --git a/MC1Services/Tests/MC1ServicesTests/IdentityReconciliationIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/IdentityReconciliationIntegrationTests.swift index b6874edd..a3974c3a 100644 --- a/MC1Services/Tests/MC1ServicesTests/IdentityReconciliationIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/IdentityReconciliationIntegrationTests.swift @@ -1,194 +1,193 @@ import Foundation +@testable import MC1Services +import MeshCore import SwiftData import Testing -import MeshCore -@testable import MC1Services @Suite("Identity reconciliation after config import") struct IdentityReconciliationIntegrationTests { - - private func createStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func randomKey() -> Data { - Data((0.. PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func randomKey() -> Data { + Data((0.. URL { - let dir = FileManager.default.temporaryDirectory - .appending(path: "InlineImageDimensionsStoreTests-\(UUID().uuidString)", directoryHint: .isDirectory) - return dir.appending(path: "InlineImageDimensions.json") - } - - @Test("save then aspect(for:) returns the expected ratio") - func saveThenAspectReturnsRatio() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/a.png")! - await store.save(url: url, size: CGSize(width: 200, height: 100)) - - #expect(store.aspect(for: url) == 2.0) + private static let streamWaitNanoseconds: UInt64 = 1_000_000_000 + + private static func makeTempFileURL() -> URL { + let dir = FileManager.default.temporaryDirectory + .appending(path: "InlineImageDimensionsStoreTests-\(UUID().uuidString)", directoryHint: .isDirectory) + return dir.appending(path: "InlineImageDimensions.json") + } + + @Test + func `save then aspect(for:) returns the expected ratio`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/a.png")) + await store.save(url: url, size: CGSize(width: 200, height: 100)) + + #expect(store.aspect(for: url) == 2.0) + } + + @Test + func `save with zero width is rejected silently`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/zero-width.png")) + await store.save(url: url, size: CGSize(width: 0, height: 100)) + + #expect(store.aspect(for: url) == nil) + } + + @Test + func `save with zero height is rejected silently`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/zero-height.png")) + await store.save(url: url, size: CGSize(width: 100, height: 0)) + + #expect(store.aspect(for: url) == nil) + } + + @Test + func `init recovers from a corrupt file by starting empty`() throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("not json".utf8).write(to: fileURL) + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let sampleURL = try #require(URL(string: "https://example.com/anything.png")) + + #expect(store.aspect(for: sampleURL) == nil) + } + + @Test + func `init on non-existent file yields empty store`() throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/unknown.png")) + + #expect(store.aspect(for: url) == nil) + } + + @Test + func `two saves are both readable via aspect(for:)`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let urlA = try #require(URL(string: "https://example.com/a.png")) + let urlB = try #require(URL(string: "https://example.com/b.png")) + await store.save(url: urlA, size: CGSize(width: 400, height: 200)) + await store.save(url: urlB, size: CGSize(width: 100, height: 400)) + + #expect(store.aspect(for: urlA) == 2.0) + #expect(store.aspect(for: urlB) == 0.25) + } + + @Test + func `resolutionStream emits the URL on save`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/stream.png")) + + let receiveTask = Task { + for await emitted in store.resolutionStream { + return emitted + } + return nil } - @Test("save with zero width is rejected silently") - func saveWithZeroWidthIsRejected() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/zero-width.png")! - await store.save(url: url, size: CGSize(width: 0, height: 100)) - - #expect(store.aspect(for: url) == nil) + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: Self.streamWaitNanoseconds) + receiveTask.cancel() + return nil } - @Test("save with zero height is rejected silently") - func saveWithZeroHeightIsRejected() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + await store.save(url: url, size: CGSize(width: 300, height: 150)) - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/zero-height.png")! - await store.save(url: url, size: CGSize(width: 100, height: 0)) + let received = await receiveTask.value + timeoutTask.cancel() - #expect(store.aspect(for: url) == nil) - } + #expect(received == url) + } - @Test("init recovers from a corrupt file by starting empty") - func initRecoversFromCorruptFile() async throws { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + @Test + func `round-trip: recreated store reads previously persisted aspect`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try Data("not json".utf8).write(to: fileURL) + let url = try #require(URL(string: "https://example.com/persisted.png")) - let store = InlineImageDimensionsStore(fileURL: fileURL) - let sampleURL = URL(string: "https://example.com/anything.png")! + let writer = InlineImageDimensionsStore(fileURL: fileURL) + await writer.save(url: url, size: CGSize(width: 600, height: 300)) - #expect(store.aspect(for: sampleURL) == nil) - } - - @Test("init on non-existent file yields empty store") - func initOnMissingFileYieldsEmptyStore() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/unknown.png")! - - #expect(store.aspect(for: url) == nil) - } - - @Test("two saves are both readable via aspect(for:)") - func twoSavesAreBothReadable() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let urlA = URL(string: "https://example.com/a.png")! - let urlB = URL(string: "https://example.com/b.png")! - await store.save(url: urlA, size: CGSize(width: 400, height: 200)) - await store.save(url: urlB, size: CGSize(width: 100, height: 400)) - - #expect(store.aspect(for: urlA) == 2.0) - #expect(store.aspect(for: urlB) == 0.25) - } - - @Test("resolutionStream emits the URL on save") - func resolutionStreamEmitsOnSave() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/stream.png")! - - let receiveTask = Task { - for await emitted in store.resolutionStream { - return emitted - } - return nil - } - - let timeoutTask = Task { - try? await Task.sleep(nanoseconds: Self.streamWaitNanoseconds) - receiveTask.cancel() - return nil - } - - await store.save(url: url, size: CGSize(width: 300, height: 150)) - - let received = await receiveTask.value - timeoutTask.cancel() - - #expect(received == url) - } - - @Test("round-trip: recreated store reads previously persisted aspect") - func roundTripPersistence() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let url = URL(string: "https://example.com/persisted.png")! - - let writer = InlineImageDimensionsStore(fileURL: fileURL) - await writer.save(url: url, size: CGSize(width: 600, height: 300)) - - let reader = InlineImageDimensionsStore(fileURL: fileURL) - #expect(reader.aspect(for: url) == 2.0) - } + let reader = InlineImageDimensionsStore(fileURL: fileURL) + #expect(reader.aspect(for: url) == 2.0) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/KnownRegionTests.swift b/MC1Services/Tests/MC1ServicesTests/KnownRegionTests.swift index 4f0c7868..77f7afb6 100644 --- a/MC1Services/Tests/MC1ServicesTests/KnownRegionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/KnownRegionTests.swift @@ -1,165 +1,164 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("Known-region persistence methods") struct KnownRegionTests { + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - @Test("addDeviceKnownRegion finds device by radioID, not by id") - func addRegionByRadioID() async throws { - let store = try await createTestStore() - - let bleUUID = UUID() - let radioID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - try await store.saveDevice(device) - - try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") - - let fetched = try await store.fetchDevice(id: bleUUID) - #expect(fetched?.knownRegions.contains("US915") == true) - } + @Test + func `addDeviceKnownRegion finds device by radioID, not by id`() async throws { + let store = try await createTestStore() - @Test("addDeviceKnownRegion does not duplicate existing region") - func addRegionSkipsDuplicate() async throws { - let store = try await createTestStore() + let bleUUID = UUID() + let radioID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + try await store.saveDevice(device) - let bleUUID = UUID() - let radioID = UUID() - var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - device.knownRegions = ["US915"] - try await store.saveDevice(device) + try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") - try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") + let fetched = try await store.fetchDevice(id: bleUUID) + #expect(fetched?.knownRegions.contains("US915") == true) + } - let fetched = try await store.fetchDevice(id: bleUUID) - #expect(fetched?.knownRegions == ["US915"]) - } + @Test + func `addDeviceKnownRegion does not duplicate existing region`() async throws { + let store = try await createTestStore() - @Test("addDeviceKnownRegion throws when device not found") - func addRegionThrowsForMissingDevice() async throws { - let store = try await createTestStore() + let bleUUID = UUID() + let radioID = UUID() + var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + device.knownRegions = ["US915"] + try await store.saveDevice(device) - await #expect(throws: PersistenceStoreError.self) { - try await store.addDeviceKnownRegion(radioID: UUID(), region: "EU868") - } - } + try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") - @Test("removeDeviceKnownRegion clears region and channel regionScope") - func removeRegionClearsRegionScope() async throws { - let store = try await createTestStore() - - let bleUUID = UUID() - let radioID = UUID() - var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - device.knownRegions = ["US915", "EU868"] - try await store.saveDevice(device) - - let channelDTO = ChannelDTO( - id: UUID(), - radioID: radioID, - index: 1, - name: "Test", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: .region("US915") - ) - try await store.saveChannel(channelDTO) - - try await store.removeDeviceKnownRegion(radioID: radioID, region: "US915") - - let fetchedDevice = try await store.fetchDevice(id: bleUUID) - #expect(fetchedDevice?.knownRegions.contains("US915") == false) - #expect(fetchedDevice?.knownRegions.contains("EU868") == true) - - let channels = try await store.fetchChannels(radioID: radioID) - let channel = channels.first - #expect(channel?.floodScope == .inherit) - } + let fetched = try await store.fetchDevice(id: bleUUID) + #expect(fetched?.knownRegions == ["US915"]) + } - @Test("removeDeviceKnownRegion leaves unrelated channel regionScope intact") - func removeRegionLeavesUnrelatedChannels() async throws { - let store = try await createTestStore() - - let bleUUID = UUID() - let radioID = UUID() - var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - device.knownRegions = ["US915", "EU868"] - try await store.saveDevice(device) - - let channel1 = ChannelDTO( - id: UUID(), - radioID: radioID, - index: 1, - name: "Chan1", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: .region("US915") - ) - let channel2 = ChannelDTO( - id: UUID(), - radioID: radioID, - index: 2, - name: "Chan2", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: .region("EU868") - ) - try await store.saveChannel(channel1) - try await store.saveChannel(channel2) - - try await store.removeDeviceKnownRegion(radioID: radioID, region: "US915") - - let channels = try await store.fetchChannels(radioID: radioID) - let scopes = channels.sorted(by: { $0.index < $1.index }).map(\.floodScope) - #expect(scopes == [.inherit, .region("EU868")]) - } - - @Test("removeDeviceKnownRegion throws when device not found") - func removeRegionThrowsForMissingDevice() async throws { - let store = try await createTestStore() + @Test + func `addDeviceKnownRegion throws when device not found`() async throws { + let store = try await createTestStore() - await #expect(throws: PersistenceStoreError.self) { - try await store.removeDeviceKnownRegion(radioID: UUID(), region: "US915") - } + await #expect(throws: PersistenceStoreError.self) { + try await store.addDeviceKnownRegion(radioID: UUID(), region: "EU868") } - - /// knownRegions is app-only state the radio never reports, owned solely by - /// the targeted add/remove methods. A full saveDevice carrying a stale or - /// empty list (an in-flight updateDevice* Task, or a connect-time rebuild - /// whose snapshot predates the add) must not overwrite it, or the user's - /// discovered regions silently vanish. - @Test("Full saveDevice does not clobber regions added via the targeted path") - func staleSaveDeviceDoesNotClobberKnownRegions() async throws { - let store = try await createTestStore() - - let bleUUID = UUID() - let radioID = UUID() - // The snapshot a long-lived caller holds, captured before any region - // was added; its knownRegions is still empty. - let staleSnapshot = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - try await store.saveDevice(staleSnapshot) - - // Discovery adds regions through the targeted, list-owning path. - try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") - try await store.addDeviceKnownRegion(radioID: radioID, region: "EU868") - - // The stale snapshot is written back as a full overwrite. The regions - // added above must survive. - try await store.saveDevice(staleSnapshot) - - let fetched = try await store.fetchDevice(id: bleUUID) - #expect(fetched?.knownRegions == ["US915", "EU868"]) + } + + @Test + func `removeDeviceKnownRegion clears region and channel regionScope`() async throws { + let store = try await createTestStore() + + let bleUUID = UUID() + let radioID = UUID() + var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + device.knownRegions = ["US915", "EU868"] + try await store.saveDevice(device) + + let channelDTO = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 1, + name: "Test", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: .region("US915") + ) + try await store.saveChannel(channelDTO) + + try await store.removeDeviceKnownRegion(radioID: radioID, region: "US915") + + let fetchedDevice = try await store.fetchDevice(id: bleUUID) + #expect(fetchedDevice?.knownRegions.contains("US915") == false) + #expect(fetchedDevice?.knownRegions.contains("EU868") == true) + + let channels = try await store.fetchChannels(radioID: radioID) + let channel = channels.first + #expect(channel?.floodScope == .inherit) + } + + @Test + func `removeDeviceKnownRegion leaves unrelated channel regionScope intact`() async throws { + let store = try await createTestStore() + + let bleUUID = UUID() + let radioID = UUID() + var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + device.knownRegions = ["US915", "EU868"] + try await store.saveDevice(device) + + let channel1 = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 1, + name: "Chan1", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: .region("US915") + ) + let channel2 = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 2, + name: "Chan2", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: .region("EU868") + ) + try await store.saveChannel(channel1) + try await store.saveChannel(channel2) + + try await store.removeDeviceKnownRegion(radioID: radioID, region: "US915") + + let channels = try await store.fetchChannels(radioID: radioID) + let scopes = channels.sorted(by: { $0.index < $1.index }).map(\.floodScope) + #expect(scopes == [.inherit, .region("EU868")]) + } + + @Test + func `removeDeviceKnownRegion throws when device not found`() async throws { + let store = try await createTestStore() + + await #expect(throws: PersistenceStoreError.self) { + try await store.removeDeviceKnownRegion(radioID: UUID(), region: "US915") } + } + + /// knownRegions is app-only state the radio never reports, owned solely by + /// the targeted add/remove methods. A full saveDevice carrying a stale or + /// empty list (an in-flight updateDevice* Task, or a connect-time rebuild + /// whose snapshot predates the add) must not overwrite it, or the user's + /// discovered regions silently vanish. + @Test + func `Full saveDevice does not clobber regions added via the targeted path`() async throws { + let store = try await createTestStore() + + let bleUUID = UUID() + let radioID = UUID() + // The snapshot a long-lived caller holds, captured before any region + // was added; its knownRegions is still empty. + let staleSnapshot = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + try await store.saveDevice(staleSnapshot) + + // Discovery adds regions through the targeted, list-owning path. + try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") + try await store.addDeviceKnownRegion(radioID: radioID, region: "EU868") + + // The stale snapshot is written back as a full overwrite. The regions + // added above must survive. + try await store.saveDevice(staleSnapshot) + + let fetched = try await store.fetchDevice(id: bleUUID) + #expect(fetched?.knownRegions == ["US915", "EU868"]) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/MC1ServicesTests.swift b/MC1Services/Tests/MC1ServicesTests/MC1ServicesTests.swift index 8c0f78c6..0370e093 100644 --- a/MC1Services/Tests/MC1ServicesTests/MC1ServicesTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/MC1ServicesTests.swift @@ -1,19 +1,18 @@ -import Testing @testable import MC1Services +import Testing @Suite("MC1Services Basic Tests") struct MC1ServicesTests { + @Test + func `Version is accessible`() { + #expect(MC1ServicesVersion.version == "0.1.0") + } - @Test("Version is accessible") - func versionAccessible() { - #expect(MC1ServicesVersion.version == "0.1.0") - } - - @Test("MeshCore types are re-exported") - func meshCoreReExported() { - // Verify MeshCore types are accessible without explicit import - let _: MeshEvent.Type = MeshEvent.self - let _: PacketBuilder.Type = PacketBuilder.self - let _: PacketParser.Type = PacketParser.self - } + @Test + func `MeshCore types are re-exported`() { + // Verify MeshCore types are accessible without explicit import + let _: MeshEvent.Type = MeshEvent.self + let _: PacketBuilder.Type = PacketBuilder.self + let _: PacketParser.Type = PacketParser.self + } } diff --git a/MC1Services/Tests/MC1ServicesTests/MeshCoreSessionStopTests.swift b/MC1Services/Tests/MC1ServicesTests/MeshCoreSessionStopTests.swift new file mode 100644 index 00000000..44853b6d --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/MeshCoreSessionStopTests.swift @@ -0,0 +1,29 @@ +import Foundation +@testable import MeshCore +import Testing + +/// Verifies `MeshCoreSession.stop(disconnectTransport:)`: a session teardown +/// over a transport the caller does not own (a reconnect cycle still riding on +/// it) must leave the link open, while the default severs it. +@Suite("MeshCoreSession Stop Transport Tests") +struct MeshCoreSessionStopTests { + @Test + func `stop with disconnectTransport false leaves the transport connected`() async { + let transport = MockMeshTransport() + let session = MeshCoreSession(transport: transport) + + await session.stop(disconnectTransport: false) + + #expect(await transport.disconnectInvocations == 0) + } + + @Test + func `stop by default disconnects the transport`() async { + let transport = MockMeshTransport() + let session = MeshCoreSession(transport: transport) + + await session.stop() + + #expect(await transport.disconnectInvocations == 1) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/MessageDTOPathTests.swift b/MC1Services/Tests/MC1ServicesTests/MessageDTOPathTests.swift index 8b95607b..8618dddf 100644 --- a/MC1Services/Tests/MC1ServicesTests/MessageDTOPathTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/MessageDTOPathTests.swift @@ -1,54 +1,53 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("MessageDTO Path Hops") struct MessageDTOPathTests { - - @Test("Single-byte hops chunk into one (data, hex) pair each") - func singleByteHops() { - let message = MessageDTO.testDirectMessage( - pathLength: encodePathLen(hashSize: 1, hopCount: 3), - pathNodes: Data([0x1A, 0x2B, 0x3C]) - ) - - let hops = message.pathHops - #expect(hops.map(\.data) == [Data([0x1A]), Data([0x2B]), Data([0x3C])]) - #expect(hops.map(\.hex) == ["1A", "2B", "3C"]) - #expect(message.pathNodesHex == ["1A", "2B", "3C"]) - } - - @Test("Multi-byte hops chunk by the encoded hash size") - func multiByteHops() { - let message = MessageDTO.testDirectMessage( - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - pathNodes: Data([0x1A, 0x2B, 0x3C, 0x4D]) - ) - - let hops = message.pathHops - #expect(hops.map(\.data) == [Data([0x1A, 0x2B]), Data([0x3C, 0x4D])]) - #expect(hops.map(\.hex) == ["1A2B", "3C4D"]) - } - - @Test("A trailing partial hop is kept as a short final chunk") - func trailingPartialHop() { - let message = MessageDTO.testDirectMessage( - pathLength: encodePathLen(hashSize: 2, hopCount: 1), - pathNodes: Data([0x1A, 0x2B, 0x3C]) - ) - - #expect(message.pathHops.map(\.hex) == ["1A2B", "3C"]) - } - - @Test("A message with no path nodes has no hops") - func noPathNodesHasNoHops() { - let message = MessageDTO.testDirectMessage( - pathLength: encodePathLen(hashSize: 1, hopCount: 0), - pathNodes: nil - ) - - #expect(message.pathHops.isEmpty) - #expect(message.pathNodesHex.isEmpty) - } + @Test + func `Single-byte hops chunk into one (data, hex) pair each`() { + let message = MessageDTO.testDirectMessage( + pathLength: encodePathLen(hashSize: 1, hopCount: 3), + pathNodes: Data([0x1A, 0x2B, 0x3C]) + ) + + let hops = message.pathHops + #expect(hops.map(\.data) == [Data([0x1A]), Data([0x2B]), Data([0x3C])]) + #expect(hops.map(\.hex) == ["1A", "2B", "3C"]) + #expect(message.pathNodesHex == ["1A", "2B", "3C"]) + } + + @Test + func `Multi-byte hops chunk by the encoded hash size`() { + let message = MessageDTO.testDirectMessage( + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + pathNodes: Data([0x1A, 0x2B, 0x3C, 0x4D]) + ) + + let hops = message.pathHops + #expect(hops.map(\.data) == [Data([0x1A, 0x2B]), Data([0x3C, 0x4D])]) + #expect(hops.map(\.hex) == ["1A2B", "3C4D"]) + } + + @Test + func `A trailing partial hop is kept as a short final chunk`() { + let message = MessageDTO.testDirectMessage( + pathLength: encodePathLen(hashSize: 2, hopCount: 1), + pathNodes: Data([0x1A, 0x2B, 0x3C]) + ) + + #expect(message.pathHops.map(\.hex) == ["1A2B", "3C"]) + } + + @Test + func `A message with no path nodes has no hops`() { + let message = MessageDTO.testDirectMessage( + pathLength: encodePathLen(hashSize: 1, hopCount: 0), + pathNodes: nil + ) + + #expect(message.pathHops.isEmpty) + #expect(message.pathNodesHex.isEmpty) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockAccessorySetupKitService.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockAccessorySetupKitService.swift index 8ef2cd79..f6c62a8a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockAccessorySetupKitService.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockAccessorySetupKitService.swift @@ -1,5 +1,5 @@ #if canImport(UIKit) -import AccessorySetupKit + import AccessorySetupKit #endif import Foundation @testable import MC1Services @@ -9,70 +9,81 @@ import Foundation /// letting tests pin pairing in specific suspension points. @MainActor public final class MockAccessorySetupKitService: AccessorySetupKitServicing { - public var pairedAccessories: [ASAccessory] = [] - public var isSessionActive: Bool = true - public weak var delegate: AccessorySetupKitServiceDelegate? + public var pairedAccessories: [ASAccessory] = [] + public var isSessionActive: Bool = true + public weak var delegate: AccessorySetupKitServiceDelegate? - public private(set) var removeAccessoryCallCount = 0 - public private(set) var lastRemovedDeviceID: UUID? - public private(set) var renameAccessoryCallCount = 0 - public private(set) var activateSessionCallCount = 0 - public private(set) var invalidateSessionCallCount = 0 + public private(set) var removeAccessoryCallCount = 0 + public private(set) var lastRemovedDeviceID: UUID? + public private(set) var renameAccessoryCallCount = 0 + public private(set) var activateSessionCallCount = 0 + public private(set) var invalidateSessionCallCount = 0 - /// Result for the next `showPicker()` call. Tests configure this before invoking - /// pairing flows. - public var pickerResult: Result = .failure(AccessorySetupKitError.sessionNotActive) + /// Result for the next `showPicker()` call. Tests configure this before invoking + /// pairing flows. + public var pickerResult: Result = .failure(AccessorySetupKitError.sessionNotActive) - /// Optional gate: when non-nil, `showPicker` awaits this stream's first element - /// before returning `pickerResult`. Used to pin the awaiting Task in suspension. - public var pickerGate: AsyncStream? + /// Optional gate: when non-nil, `showPicker` awaits this stream's first element + /// before returning `pickerResult`. Used to pin the awaiting Task in suspension. + public var pickerGate: AsyncStream? - /// Optional signal: when non-nil, `showPicker` yields to this continuation as - /// soon as it enters, before awaiting `pickerGate`. Tests await this signal to - /// know deterministically that the pair task has reached the picker await. - public var pickerEnteredSignal: AsyncStream.Continuation? + /// Optional signal: when non-nil, `showPicker` yields to this continuation as + /// soon as it enters, before awaiting `pickerGate`. Tests await this signal to + /// know deterministically that the pair task has reached the picker await. + public var pickerEnteredSignal: AsyncStream.Continuation? - public init() {} + /// When non-nil, `removeAccessory` records the attempt and then throws this, + /// modelling the user declining the system removal confirmation so the + /// accessory stays registered. + public var removeAccessoryError: Error? - public func setPickerResult(_ result: Result) { - self.pickerResult = result - } + public init() {} - public func setPairedAccessories(_ accessories: [ASAccessory]) { - self.pairedAccessories = accessories - } + public func setPickerResult(_ result: Result) { + pickerResult = result + } - public func activateSession() async throws { - activateSessionCallCount += 1 - } + public func setPairedAccessories(_ accessories: [ASAccessory]) { + pairedAccessories = accessories + } - public func showPicker() async throws -> UUID { - pickerEnteredSignal?.yield() - if let gate = pickerGate { - for await _ in gate { break } - } - switch pickerResult { - case .success(let id): - return id - case .failure(let error): - throw error - } - } + public func activateSession() async throws { + activateSessionCallCount += 1 + } - public func removeAccessory(_ accessory: ASAccessory) async throws { - removeAccessoryCallCount += 1 - lastRemovedDeviceID = accessory.bluetoothIdentifier + public func showPicker() async throws -> UUID { + pickerEnteredSignal?.yield() + if let gate = pickerGate { + for await _ in gate { + break + } } - - public func renameAccessory(_ accessory: ASAccessory) async throws { - renameAccessoryCallCount += 1 + switch pickerResult { + case let .success(id): + return id + case let .failure(error): + throw error } + } - public func accessory(for bluetoothID: UUID) -> ASAccessory? { - pairedAccessories.first { $0.bluetoothIdentifier == bluetoothID } + public func removeAccessory(_ accessory: ASAccessory) async throws { + removeAccessoryCallCount += 1 + lastRemovedDeviceID = accessory.bluetoothIdentifier + if let removeAccessoryError { + throw removeAccessoryError } + pairedAccessories.removeAll { $0.bluetoothIdentifier == accessory.bluetoothIdentifier } + } - public func invalidateSession() { - invalidateSessionCallCount += 1 - } + public func renameAccessory(_ accessory: ASAccessory) async throws { + renameAccessoryCallCount += 1 + } + + public func accessory(for bluetoothID: UUID) -> ASAccessory? { + pairedAccessories.first { $0.bluetoothIdentifier == bluetoothID } + } + + public func invalidateSession() { + invalidateSessionCallCount += 1 + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockAppStateProvider.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockAppStateProvider.swift index 5e70eaf0..47875a4d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockAppStateProvider.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockAppStateProvider.swift @@ -4,28 +4,27 @@ import Foundation /// Mock implementation of AppStateProvider for testing. /// Uses actor for thread-safe mutable state access. public actor MockAppStateProvider: AppStateProvider { + // MARK: - Stubs - // MARK: - Stubs + /// Configurable foreground state for tests + public var stubbedIsInForeground: Bool - /// Configurable foreground state for tests - public var stubbedIsInForeground: Bool + // MARK: - Protocol Properties - // MARK: - Protocol Properties + public var isInForeground: Bool { + get async { stubbedIsInForeground } + } - public var isInForeground: Bool { - get async { stubbedIsInForeground } - } + // MARK: - Initialization - // MARK: - Initialization + public init(isInForeground: Bool = true) { + stubbedIsInForeground = isInForeground + } - public init(isInForeground: Bool = true) { - self.stubbedIsInForeground = isInForeground - } + // MARK: - Test Helpers - // MARK: - Test Helpers - - /// Sets the stubbed foreground state - public func setIsInForeground(_ value: Bool) { - stubbedIsInForeground = value - } + /// Sets the stubbed foreground state + public func setIsInForeground(_ value: Bool) { + stubbedIsInForeground = value + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockBLEStateMachine.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockBLEStateMachine.swift index bb8ab745..7119d52d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockBLEStateMachine.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockBLEStateMachine.swift @@ -5,201 +5,223 @@ import Foundation /// Mock BLE state machine for testing ConnectionManager. /// Uses actor for thread-safe mutable state access. public actor MockBLEStateMachine: BLEStateMachineProtocol { + // MARK: - Stubs - // MARK: - Stubs - - public var stubbedIsConnected: Bool = false - public var stubbedIsAutoReconnecting: Bool = false - public var stubbedConnectedDeviceID: UUID? - public var stubbedCurrentPhaseName: String = "idle" - public var stubbedCurrentPeripheralState: String? - public var stubbedCentralManagerStateName: String = "poweredOn" - public var stubbedIsBluetoothPoweredOff: Bool = false - public var stubbedIsDeviceConnectedToSystem: Bool = false - public var isDeviceConnectedToSystemHandler: (@Sendable (UUID) -> Bool)? - public var stubbedDidStartAdoptingSystemConnectedPeripheral: Bool = false - - // MARK: - Protocol Properties - - public var isConnected: Bool { stubbedIsConnected } - public var isAutoReconnecting: Bool { stubbedIsAutoReconnecting } - public var connectedDeviceID: UUID? { stubbedConnectedDeviceID } - public var currentPhaseName: String { stubbedCurrentPhaseName } - public var currentPeripheralState: String? { stubbedCurrentPeripheralState } - public var centralManagerStateName: String { stubbedCentralManagerStateName } - public var isBluetoothPoweredOff: Bool { stubbedIsBluetoothPoweredOff } - public var hasAutoReconnectingHandler: Bool { autoReconnectingHandler != nil } - - // MARK: - Recorded Invocations - - public private(set) var activateCallCount = 0 - public private(set) var isDeviceConnectedToSystemCalls: [UUID] = [] - public private(set) var startAdoptingSystemConnectedPeripheralCalls: [UUID] = [] - public private(set) var startScanningCallCount = 0 - public private(set) var stopScanningCallCount = 0 - public private(set) var isScanning = false - - // MARK: - Captured Handlers - - private var autoReconnectingHandler: (@Sendable (UUID, String) -> Void)? - private var bluetoothPoweredOnHandler: (@Sendable () -> Void)? - private var bluetoothStateChangeHandler: (@Sendable (CBManagerState) -> Void)? - private var deviceDiscoveredHandler: (@Sendable (UUID, String?, Int) -> Void)? - - // MARK: - Initialization - - public init() {} - - // MARK: - Protocol Methods - - public func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool { - isDeviceConnectedToSystemCalls.append(deviceID) - if let handler = isDeviceConnectedToSystemHandler { - return handler(deviceID) - } - return stubbedIsDeviceConnectedToSystem - } + public var stubbedIsConnected: Bool = false + public var stubbedIsAutoReconnecting: Bool = false + public var stubbedConnectedDeviceID: UUID? + public var stubbedCurrentPhaseName: String = "idle" + public var stubbedCurrentPeripheralState: String? + public var stubbedCentralManagerStateName: String = "poweredOn" + public var stubbedIsBluetoothPoweredOff: Bool = false + public var stubbedIsDeviceConnectedToSystem: Bool = false + public var isDeviceConnectedToSystemHandler: (@Sendable (UUID) -> Bool)? + public var stubbedDidStartAdoptingSystemConnectedPeripheral: Bool = false - public func systemConnectedPeripheralIDs() -> [UUID] { - [] - } + // MARK: - Protocol Properties - public func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool { - startAdoptingSystemConnectedPeripheralCalls.append(deviceID) - return stubbedDidStartAdoptingSystemConnectedPeripheral - } + public var isConnected: Bool { + stubbedIsConnected + } - public func activate() { - activateCallCount += 1 - } + public var isAutoReconnecting: Bool { + stubbedIsAutoReconnecting + } - public func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) { - autoReconnectingHandler = handler - } + public var connectedDeviceID: UUID? { + stubbedConnectedDeviceID + } - public func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) { - bluetoothPoweredOnHandler = handler - } + public var currentPhaseName: String { + stubbedCurrentPhaseName + } - public func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) { - bluetoothStateChangeHandler = handler - } + public var currentPeripheralState: String? { + stubbedCurrentPeripheralState + } - public func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) { - deviceDiscoveredHandler = handler - } + public var centralManagerStateName: String { + stubbedCentralManagerStateName + } - public func startScanning() { - startScanningCallCount += 1 - isScanning = true - } + public var isBluetoothPoweredOff: Bool { + stubbedIsBluetoothPoweredOff + } - public func stopScanning() { - stopScanningCallCount += 1 - isScanning = false - } - - public func setWritePacingDelay(_ delay: TimeInterval) { - // No-op for testing - } - - public private(set) var shutdownCallCount = 0 - public private(set) var appDidEnterBackgroundCallCount = 0 - public private(set) var appDidBecomeActiveCallCount = 0 - - public func shutdown() { - shutdownCallCount += 1 - } - - public func appDidEnterBackground() { - appDidEnterBackgroundCallCount += 1 - } - - public func appDidBecomeActive() { - appDidBecomeActiveCallCount += 1 - } - - // MARK: - Test Helpers - - /// Resets all stubs and recorded invocations - public func reset() { - stubbedIsConnected = false - stubbedIsAutoReconnecting = false - stubbedConnectedDeviceID = nil - stubbedCurrentPhaseName = "idle" - stubbedCurrentPeripheralState = nil - stubbedCentralManagerStateName = "poweredOn" - stubbedIsBluetoothPoweredOff = false - stubbedIsDeviceConnectedToSystem = false - isDeviceConnectedToSystemHandler = nil - stubbedDidStartAdoptingSystemConnectedPeripheral = false - activateCallCount = 0 - isDeviceConnectedToSystemCalls = [] - startAdoptingSystemConnectedPeripheralCalls = [] - startScanningCallCount = 0 - stopScanningCallCount = 0 - isScanning = false - appDidEnterBackgroundCallCount = 0 - appDidBecomeActiveCallCount = 0 - autoReconnectingHandler = nil - bluetoothPoweredOnHandler = nil - bluetoothStateChangeHandler = nil - deviceDiscoveredHandler = nil - } - - /// Simulates auto-reconnecting event - public func simulateAutoReconnecting(deviceID: UUID, errorInfo: String = "none") { - autoReconnectingHandler?(deviceID, errorInfo) - } - - /// Simulates Bluetooth powered on event - public func simulateBluetoothPoweredOn() { - bluetoothPoweredOnHandler?() - } - - /// Simulates a Bluetooth state change event - public func simulateBluetoothStateChange(_ state: CBManagerState) { - bluetoothStateChangeHandler?(state) - } - - /// Simulates BLE discovery callback while scanning. - public func simulateDiscoveredDevice(id: UUID, name: String? = nil, rssi: Int) { - deviceDiscoveredHandler?(id, name, rssi) - } + public var hasAutoReconnectingHandler: Bool { + autoReconnectingHandler != nil + } + + // MARK: - Recorded Invocations + + public private(set) var activateCallCount = 0 + public private(set) var isDeviceConnectedToSystemCalls: [UUID] = [] + public private(set) var startAdoptingSystemConnectedPeripheralCalls: [UUID] = [] + public private(set) var startScanningCallCount = 0 + public private(set) var stopScanningCallCount = 0 + public private(set) var isScanning = false + + // MARK: - Captured Handlers + + private var autoReconnectingHandler: (@Sendable (UUID, String) -> Void)? + private var bluetoothPoweredOnHandler: (@Sendable () -> Void)? + private var bluetoothStateChangeHandler: (@Sendable (CBManagerState) -> Void)? + private var deviceDiscoveredHandler: (@Sendable (UUID, String?, Int) -> Void)? + + // MARK: - Initialization + + public init() {} + + // MARK: - Protocol Methods + + public func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool { + isDeviceConnectedToSystemCalls.append(deviceID) + if let handler = isDeviceConnectedToSystemHandler { + return handler(deviceID) + } + return stubbedIsDeviceConnectedToSystem + } + + public func systemConnectedPeripheralIDs() -> [UUID] { + [] + } + + public func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool { + startAdoptingSystemConnectedPeripheralCalls.append(deviceID) + return stubbedDidStartAdoptingSystemConnectedPeripheral + } + + public func activate() { + activateCallCount += 1 + } + + public func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) { + autoReconnectingHandler = handler + } + + public func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) { + bluetoothPoweredOnHandler = handler + } + + public func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) { + bluetoothStateChangeHandler = handler + } + + public func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) { + deviceDiscoveredHandler = handler + } + + public func startScanning() { + startScanningCallCount += 1 + isScanning = true + } + + public func stopScanning() { + stopScanningCallCount += 1 + isScanning = false + } + + public func setWritePacingDelay(_ delay: TimeInterval) { + // No-op for testing + } + + public private(set) var shutdownCallCount = 0 + public private(set) var appDidEnterBackgroundCallCount = 0 + public private(set) var appDidBecomeActiveCallCount = 0 + + public func shutdown() { + shutdownCallCount += 1 + } + + public func appDidEnterBackground() { + appDidEnterBackgroundCallCount += 1 + } + + public func appDidBecomeActive() { + appDidBecomeActiveCallCount += 1 + } + + // MARK: - Test Helpers + + /// Resets all stubs and recorded invocations + public func reset() { + stubbedIsConnected = false + stubbedIsAutoReconnecting = false + stubbedConnectedDeviceID = nil + stubbedCurrentPhaseName = "idle" + stubbedCurrentPeripheralState = nil + stubbedCentralManagerStateName = "poweredOn" + stubbedIsBluetoothPoweredOff = false + stubbedIsDeviceConnectedToSystem = false + isDeviceConnectedToSystemHandler = nil + stubbedDidStartAdoptingSystemConnectedPeripheral = false + activateCallCount = 0 + isDeviceConnectedToSystemCalls = [] + startAdoptingSystemConnectedPeripheralCalls = [] + startScanningCallCount = 0 + stopScanningCallCount = 0 + isScanning = false + appDidEnterBackgroundCallCount = 0 + appDidBecomeActiveCallCount = 0 + autoReconnectingHandler = nil + bluetoothPoweredOnHandler = nil + bluetoothStateChangeHandler = nil + deviceDiscoveredHandler = nil + } + + /// Simulates auto-reconnecting event + public func simulateAutoReconnecting(deviceID: UUID, errorInfo: String = "none") { + autoReconnectingHandler?(deviceID, errorInfo) + } + + /// Simulates Bluetooth powered on event + public func simulateBluetoothPoweredOn() { + bluetoothPoweredOnHandler?() + } + + /// Simulates a Bluetooth state change event + public func simulateBluetoothStateChange(_ state: CBManagerState) { + bluetoothStateChangeHandler?(state) + } + + /// Simulates BLE discovery callback while scanning. + public func simulateDiscoveredDevice(id: UUID, name: String? = nil, rssi: Int) { + deviceDiscoveredHandler?(id, name, rssi) + } } // MARK: - Stubbed Property Setters extension MockBLEStateMachine { - func setStubbedIsConnected(_ value: Bool) { - stubbedIsConnected = value - } + func setStubbedIsConnected(_ value: Bool) { + stubbedIsConnected = value + } - func setStubbedIsAutoReconnecting(_ value: Bool) { - stubbedIsAutoReconnecting = value - } + func setStubbedIsAutoReconnecting(_ value: Bool) { + stubbedIsAutoReconnecting = value + } - func setStubbedIsDeviceConnectedToSystem(_ value: Bool) { - stubbedIsDeviceConnectedToSystem = value - } + func setStubbedIsDeviceConnectedToSystem(_ value: Bool) { + stubbedIsDeviceConnectedToSystem = value + } - func setIsDeviceConnectedToSystemHandler(_ handler: sending (@Sendable (UUID) -> Bool)?) { - isDeviceConnectedToSystemHandler = handler - } + func setIsDeviceConnectedToSystemHandler(_ handler: sending (@Sendable (UUID) -> Bool)?) { + isDeviceConnectedToSystemHandler = handler + } - func setStubbedIsBluetoothPoweredOff(_ value: Bool) { - stubbedIsBluetoothPoweredOff = value - } + func setStubbedIsBluetoothPoweredOff(_ value: Bool) { + stubbedIsBluetoothPoweredOff = value + } - func setStubbedDidStartAdoptingSystemConnectedPeripheral(_ value: Bool) { - stubbedDidStartAdoptingSystemConnectedPeripheral = value - } + func setStubbedDidStartAdoptingSystemConnectedPeripheral(_ value: Bool) { + stubbedDidStartAdoptingSystemConnectedPeripheral = value + } - func setStubbedCurrentPhaseName(_ value: String) { - stubbedCurrentPhaseName = value - } + func setStubbedCurrentPhaseName(_ value: String) { + stubbedCurrentPhaseName = value + } - func setStubbedConnectedDeviceID(_ value: UUID?) { - stubbedConnectedDeviceID = value - } + func setStubbedConnectedDeviceID(_ value: UUID?) { + stubbedConnectedDeviceID = value + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockChannelService.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockChannelService.swift index ed932833..aeb552f5 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockChannelService.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockChannelService.swift @@ -1,74 +1,73 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore /// Mock implementation of ChannelServiceProtocol for testing. /// /// Configure the mock by setting the stub properties before calling methods. /// Track method calls by examining the recorded invocations. public actor MockChannelService: ChannelServiceProtocol { + // MARK: - Stubs - // MARK: - Stubs + /// Result to return from syncChannels + public var stubbedSyncChannelsResult: Result = .success( + ChannelSyncResult(channelsSynced: 0, errors: []) + ) - /// Result to return from syncChannels - public var stubbedSyncChannelsResult: Result = .success( - ChannelSyncResult(channelsSynced: 0, errors: []) - ) + /// Result to return from retryFailedChannels + public var stubbedRetryResult: Result = .success( + ChannelSyncResult(channelsSynced: 0, errors: []) + ) - /// Result to return from retryFailedChannels - public var stubbedRetryResult: Result = .success( - ChannelSyncResult(channelsSynced: 0, errors: []) - ) + // MARK: - Recorded Invocations - // MARK: - Recorded Invocations + public struct SyncChannelsInvocation: Sendable, Equatable { + public let radioID: UUID + public let maxChannels: UInt8 + public let usePipelinedRead: Bool + } - public struct SyncChannelsInvocation: Sendable, Equatable { - public let radioID: UUID - public let maxChannels: UInt8 - public let usePipelinedRead: Bool - } + public struct RetryInvocation: Sendable, Equatable { + public let radioID: UUID + public let indices: [UInt8] + } - public struct RetryInvocation: Sendable, Equatable { - public let radioID: UUID - public let indices: [UInt8] - } - - public private(set) var syncChannelsInvocations: [SyncChannelsInvocation] = [] - public private(set) var retryInvocations: [RetryInvocation] = [] + public private(set) var syncChannelsInvocations: [SyncChannelsInvocation] = [] + public private(set) var retryInvocations: [RetryInvocation] = [] - // MARK: - Initialization + // MARK: - Initialization - public init() {} + public init() {} - // MARK: - Protocol Methods + // MARK: - Protocol Methods - public func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult { - syncChannelsInvocations.append( - SyncChannelsInvocation(radioID: radioID, maxChannels: maxChannels, usePipelinedRead: usePipelinedRead) - ) - switch stubbedSyncChannelsResult { - case .success(let result): - return result - case .failure(let error): - throw error - } + public func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult { + syncChannelsInvocations.append( + SyncChannelsInvocation(radioID: radioID, maxChannels: maxChannels, usePipelinedRead: usePipelinedRead) + ) + switch stubbedSyncChannelsResult { + case let .success(result): + return result + case let .failure(error): + throw error } + } - public func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { - retryInvocations.append(RetryInvocation(radioID: radioID, indices: indices)) - switch stubbedRetryResult { - case .success(let result): - return result - case .failure(let error): - throw error - } + public func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { + retryInvocations.append(RetryInvocation(radioID: radioID, indices: indices)) + switch stubbedRetryResult { + case let .success(result): + return result + case let .failure(error): + throw error } + } - // MARK: - Test Helpers + // MARK: - Test Helpers - /// Resets all recorded invocations - public func reset() { - syncChannelsInvocations = [] - retryInvocations = [] - } + /// Resets all recorded invocations + public func reset() { + syncChannelsInvocations = [] + retryInvocations = [] + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockContactService.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockContactService.swift index e1fa8c17..b828cc34 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockContactService.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockContactService.swift @@ -1,54 +1,53 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore /// Mock implementation of ContactServiceProtocol for testing. /// /// Configure the mock by setting the stub properties before calling methods. /// Track method calls by examining the recorded invocations. public actor MockContactService: ContactServiceProtocol { + // MARK: - Stubs - // MARK: - Stubs - - /// Result to return from syncContacts - public var stubbedSyncContactsResult: Result = .success( - ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) - ) + /// Result to return from syncContacts + public var stubbedSyncContactsResult: Result = .success( + ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) + ) - // MARK: - Recorded Invocations + // MARK: - Recorded Invocations - public struct SyncContactsInvocation: Sendable, Equatable { - public let radioID: UUID - public let since: Date? - } + public struct SyncContactsInvocation: Sendable, Equatable { + public let radioID: UUID + public let since: Date? + } - public private(set) var syncContactsInvocations: [SyncContactsInvocation] = [] + public private(set) var syncContactsInvocations: [SyncContactsInvocation] = [] - // MARK: - Initialization + // MARK: - Initialization - public init() {} + public init() {} - // MARK: - Protocol Methods + // MARK: - Protocol Methods - public func syncContacts(radioID: UUID, since: Date? = nil) async throws -> ContactSyncResult { - syncContactsInvocations.append(SyncContactsInvocation(radioID: radioID, since: since)) - switch stubbedSyncContactsResult { - case .success(let result): - return result - case .failure(let error): - throw error - } + public func syncContacts(radioID: UUID, since: Date? = nil) async throws -> ContactSyncResult { + syncContactsInvocations.append(SyncContactsInvocation(radioID: radioID, since: since)) + switch stubbedSyncContactsResult { + case let .success(result): + return result + case let .failure(error): + throw error } + } - // MARK: - Test Helpers + // MARK: - Test Helpers - /// Resets all recorded invocations - public func reset() { - syncContactsInvocations = [] - } + /// Resets all recorded invocations + public func reset() { + syncContactsInvocations = [] + } - /// Sets the stubbed result for syncContacts - public func setStubbedSyncContactsResult(_ result: Result) { - stubbedSyncContactsResult = result - } + /// Sets the stubbed result for syncContacts + public func setStubbedSyncContactsResult(_ result: Result) { + stubbedSyncContactsResult = result + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift index b01570a7..74d51d73 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift @@ -6,338 +6,417 @@ import MeshCore /// Configure the mock by setting the stub properties before calling methods. /// Track method calls by examining the recorded invocations. public actor MockMeshCoreSession: MeshCoreSessionProtocol { + // MARK: - Connection State - // MARK: - Connection State - - public var connectionState: AsyncStream { - AsyncStream { continuation in - continuation.yield(stubbedConnectionState) - continuation.finish() - } - } - - /// Self info to return from currentSelfInfo. Configure via `setCurrentSelfInfo(_:)`. - public private(set) var currentSelfInfo: SelfInfo? - - // MARK: - Event Streaming - - private struct EventSubscription { - let filter: EventFilter? - let continuation: AsyncStream.Continuation - } - - /// Active `events()` subscriptions, keyed so a terminated stream can deregister itself. - private var eventSubscriptions: [UUID: EventSubscription] = [:] - - public func events() async -> AsyncStream { - makeEventStream(filter: nil) + public var connectionState: AsyncStream { + AsyncStream { continuation in + continuation.yield(stubbedConnectionState) + continuation.finish() } + } - public func events(filter: EventFilter) async -> AsyncStream { - makeEventStream(filter: filter) - } - - private func makeEventStream(filter: EventFilter?) -> AsyncStream { - let id = UUID() - let (stream, continuation) = AsyncStream.makeStream() - eventSubscriptions[id] = EventSubscription(filter: filter, continuation: continuation) - continuation.onTermination = { [weak self] _ in - guard let self else { return } - Task { await self.removeEventSubscription(id: id) } - } - return stream - } - - private func removeEventSubscription(id: UUID) { - eventSubscriptions[id] = nil - } - - /// Yields an event to every active `events()` subscriber whose filter matches. - public func yieldEvent(_ event: MeshEvent) { - for subscription in eventSubscriptions.values where subscription.filter?.matches(event) != false { - subscription.continuation.yield(event) - } - } - - /// Finishes every active `events()` stream, ending subscribers' for-await loops. - public func finishEventStreams() { - for subscription in eventSubscriptions.values { - subscription.continuation.finish() - } - eventSubscriptions.removeAll() - } - - // MARK: - Stubs - - /// The connection state to return from connectionState stream - public var stubbedConnectionState: ConnectionState = .disconnected - - /// Result to return from sendMessage - public var stubbedSendMessageResult: Result = .success( - MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000) - ) - - /// Result to return from sendChannelMessage - public var stubbedSendChannelMessageError: Error? - - /// Contacts to return from getContacts - public var stubbedContacts: [MeshContact] = [] - - /// Error to throw from getContacts - public var stubbedGetContactsError: Error? - - /// Contact to return from getContact (by public key) - public var stubbedContact: MeshContact? - - /// Error to throw from getContact - public var stubbedGetContactError: Error? - - /// Error to throw from addContact - public var stubbedAddContactError: Error? - - /// Error to throw from removeContact - public var stubbedRemoveContactError: Error? - - /// Error to throw from resetPath - public var stubbedResetPathError: Error? - - /// Result to return from sendPathDiscovery - public var stubbedSendPathDiscoveryResult: Result = .success( - MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000) - ) + /// Self info to return from currentSelfInfo. Configure via `setCurrentSelfInfo(_:)`. + public private(set) var currentSelfInfo: SelfInfo? - /// Channel info to return from getChannel, keyed by index - public var stubbedChannels: [UInt8: ChannelInfo] = [:] + // MARK: - Event Streaming - /// Error to throw from getChannel - public var stubbedGetChannelError: Error? + private struct EventSubscription { + let filter: EventFilter? + let continuation: AsyncStream.Continuation + } - /// Error to throw from setChannel - public var stubbedSetChannelError: Error? + /// Active `events()` subscriptions, keyed so a terminated stream can deregister itself. + private var eventSubscriptions: [UUID: EventSubscription] = [:] - /// Event to return from waitForEvent (nil simulates a timeout) - public var stubbedWaitForEventResult: MeshEvent? + public func events() async -> AsyncStream { + makeEventStream(filter: nil) + } - /// Result to return from getMessage - public var stubbedGetMessageResult: Result = .success(.noMoreMessages) + public func events(filter: EventFilter) async -> AsyncStream { + makeEventStream(filter: filter) + } - // MARK: - Recorded Invocations - - public struct SendMessageInvocation: Sendable, Equatable { - public let destination: Data - public let text: String - public let timestamp: Date - public let attempt: UInt8 + private func makeEventStream(filter: EventFilter?) -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream() + eventSubscriptions[id] = EventSubscription(filter: filter, continuation: continuation) + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { await self.removeEventSubscription(id: id) } } + return stream + } - public struct SendChannelMessageInvocation: Sendable, Equatable { - public let channel: UInt8 - public let text: String - public let timestamp: Date - } + private func removeEventSubscription(id: UUID) { + eventSubscriptions[id] = nil + } - public struct AddContactInvocation: Sendable, Equatable { - public let contact: MeshContact + /// Yields an event to every active `events()` subscriber whose filter matches. + public func yieldEvent(_ event: MeshEvent) { + for subscription in eventSubscriptions.values where subscription.filter?.matches(event) != false { + subscription.continuation.yield(event) } + } - public struct SetChannelInvocation: Sendable, Equatable { - public let index: UInt8 - public let name: String - public let secret: Data + /// Finishes every active `events()` stream, ending subscribers' for-await loops. + public func finishEventStreams() { + for subscription in eventSubscriptions.values { + subscription.continuation.finish() } + eventSubscriptions.removeAll() + } - public struct WaitForEventInvocation: Sendable { - public let filter: EventFilter - public let timeout: TimeInterval? - } + // MARK: - Stubs - public private(set) var sendMessageInvocations: [SendMessageInvocation] = [] - public private(set) var sendChannelMessageInvocations: [SendChannelMessageInvocation] = [] - public private(set) var getContactsInvocations: [Date?] = [] - public private(set) var getContactPublicKeys: [Data] = [] - public private(set) var addContactInvocations: [AddContactInvocation] = [] - public private(set) var removeContactPublicKeys: [Data] = [] - public private(set) var resetPathPublicKeys: [Data] = [] - public private(set) var sendPathDiscoveryDestinations: [Data] = [] - public private(set) var getChannelIndices: [UInt8] = [] - public private(set) var setChannelInvocations: [SetChannelInvocation] = [] - public private(set) var waitForEventInvocations: [WaitForEventInvocation] = [] - public private(set) var getMessageTimeouts: [TimeInterval?] = [] - public private(set) var startAutoMessageFetchingCallCount = 0 - public private(set) var stopAutoMessageFetchingCallCount = 0 - - // MARK: - Initialization - - public init() {} - - // MARK: - Test Configuration - - /// Sets the contacts returned by `getContacts(since:)`. Actor isolation forbids writing the - /// stub property directly from a test, so configuration goes through this isolated setter. - public func setStubbedContacts(_ contacts: [MeshContact]) { - stubbedContacts = contacts - } + /// The connection state to return from connectionState stream + public var stubbedConnectionState: ConnectionState = .disconnected - /// Sets the self info returned by `currentSelfInfo`, through an isolated setter - /// for the same actor-isolation reason as `setStubbedContacts`. - public func setCurrentSelfInfo(_ selfInfo: SelfInfo?) { - currentSelfInfo = selfInfo - } + /// Result to return from sendMessage + public var stubbedSendMessageResult: Result = .success( + MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000) + ) - // MARK: - Protocol Methods + /// Result to return from sendChannelMessage + public var stubbedSendChannelMessageError: Error? - public func sendMessage(to destination: Data, text: String, timestamp: Date, attempt: UInt8) async throws -> MessageSentInfo { - sendMessageInvocations.append(SendMessageInvocation(destination: destination, text: text, timestamp: timestamp, attempt: attempt)) - switch stubbedSendMessageResult { - case .success(let info): - return info - case .failure(let error): - throw error - } - } + /// Contacts to return from getContacts + public var stubbedContacts: [MeshContact] = [] - public func sendChannelMessage(channel: UInt8, text: String, timestamp: Date) async throws { - sendChannelMessageInvocations.append(SendChannelMessageInvocation(channel: channel, text: text, timestamp: timestamp)) - if let error = stubbedSendChannelMessageError { - throw error - } - } + /// Error to throw from getContacts + public var stubbedGetContactsError: Error? - public func getContacts(since lastModified: Date?) async throws -> [MeshContact] { - getContactsInvocations.append(lastModified) - if let error = stubbedGetContactsError { - throw error - } - return stubbedContacts - } + /// Contact to return from getContact (by public key) + public var stubbedContact: MeshContact? - public func getContact(publicKey: Data) async throws -> MeshContact? { - getContactPublicKeys.append(publicKey) - if let error = stubbedGetContactError { - throw error - } - return stubbedContact - } + /// Error to throw from getContact + public var stubbedGetContactError: Error? - public func addContact(_ contact: MeshContact) async throws { - addContactInvocations.append(AddContactInvocation(contact: contact)) - if let error = stubbedAddContactError { - throw error - } - } + /// Error to throw from addContact + public var stubbedAddContactError: Error? - public func removeContact(publicKey: Data) async throws { - removeContactPublicKeys.append(publicKey) - if let error = stubbedRemoveContactError { - throw error - } - } + /// Error to throw from removeContact + public var stubbedRemoveContactError: Error? - public func resetPath(publicKey: Data) async throws { - resetPathPublicKeys.append(publicKey) - if let error = stubbedResetPathError { - throw error - } - } + /// Error to throw from resetPath + public var stubbedResetPathError: Error? - public func sendPathDiscovery(to destination: Data) async throws -> MessageSentInfo { - sendPathDiscoveryDestinations.append(destination) - switch stubbedSendPathDiscoveryResult { - case .success(let info): - return info - case .failure(let error): - throw error - } - } + /// Result to return from sendPathDiscovery + public var stubbedSendPathDiscoveryResult: Result = .success( + MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000) + ) - public func getChannel(index: UInt8) async throws -> ChannelInfo { - getChannelIndices.append(index) - if let error = stubbedGetChannelError { - throw error - } - if let channel = stubbedChannels[index] { - return channel - } - // Return a default empty channel - return ChannelInfo(index: index, name: "", secret: Data(repeating: 0, count: 16)) - } + /// Channel info to return from getChannel, keyed by index + public var stubbedChannels: [UInt8: ChannelInfo] = [:] - public func getChannels(indices: [UInt8]) async throws -> (received: [ChannelInfo], missing: [UInt8]) { - getChannelIndices.append(contentsOf: indices) - if let error = stubbedGetChannelError { - throw error - } - // The firmware answers every requested index, so the mock returns one per request. - let received = indices.map { index in - stubbedChannels[index] ?? ChannelInfo(index: index, name: "", secret: Data(repeating: 0, count: 16)) - } - return (received: received, missing: []) - } + /// Error to throw from getChannel + public var stubbedGetChannelError: Error? + + /// Error to throw from setChannel + public var stubbedSetChannelError: Error? - public func setChannel(index: UInt8, name: String, secret: Data) async throws { - setChannelInvocations.append(SetChannelInvocation(index: index, name: name, secret: secret)) - if let error = stubbedSetChannelError { - throw error - } - } - - public func shareContact(publicKey: Data) async throws { - // Stub - not used in current tests - } - - public func exportContact(publicKey: Data?) async throws -> String { - // Stub - not used in current tests - return "" - } - - public func importContact(cardData: Data) async throws { - // Stub - not used in current tests - } + /// Event to return from waitForEvent (nil simulates a timeout) + public var stubbedWaitForEventResult: MeshEvent? + + /// Result to return from getMessage + public var stubbedGetMessageResult: Result = .success(.noMoreMessages) + + /// Results to return from successive `sendLogin` calls, consumed FIFO. + public var stubbedSendLoginResults: [Result] = [] + + // MARK: - Recorded Invocations + + public struct SendMessageInvocation: Sendable, Equatable { + public let destination: Data + public let text: String + public let timestamp: Date + public let attempt: UInt8 + } + + public struct SendChannelMessageInvocation: Sendable, Equatable { + public let channel: UInt8 + public let text: String + public let timestamp: Date + } + + public struct AddContactInvocation: Sendable, Equatable { + public let contact: MeshContact + } - public func changeContactFlags(_ contact: MeshContact, flags: ContactFlags) async throws { - // Stub - not used in current tests - } + public struct SendLoginInvocation: Sendable, Equatable { + public let destination: Data + public let password: String + } - public func waitForEvent(filter: EventFilter, timeout: TimeInterval?) async -> MeshEvent? { - waitForEventInvocations.append(WaitForEventInvocation(filter: filter, timeout: timeout)) - return stubbedWaitForEventResult - } + public struct SetChannelInvocation: Sendable, Equatable { + public let index: UInt8 + public let name: String + public let secret: Data + } - public func getMessage(timeout: TimeInterval?) async throws -> MessageResult { - getMessageTimeouts.append(timeout) - switch stubbedGetMessageResult { - case .success(let result): - return result - case .failure(let error): - throw error - } - } + public struct WaitForEventInvocation: Sendable { + public let filter: EventFilter + public let timeout: TimeInterval? + } - public func startAutoMessageFetching() async { - startAutoMessageFetchingCallCount += 1 - } + public private(set) var sendMessageInvocations: [SendMessageInvocation] = [] + public private(set) var sendChannelMessageInvocations: [SendChannelMessageInvocation] = [] + public private(set) var getContactsInvocations: [Date?] = [] + public private(set) var getContactPublicKeys: [Data] = [] + public private(set) var addContactInvocations: [AddContactInvocation] = [] + public private(set) var sendLoginInvocations: [SendLoginInvocation] = [] + public private(set) var removeContactPublicKeys: [Data] = [] + public private(set) var resetPathPublicKeys: [Data] = [] + public private(set) var sendPathDiscoveryDestinations: [Data] = [] + public private(set) var getChannelIndices: [UInt8] = [] + public private(set) var setChannelInvocations: [SetChannelInvocation] = [] + public private(set) var waitForEventInvocations: [WaitForEventInvocation] = [] + public private(set) var getMessageTimeouts: [TimeInterval?] = [] + public private(set) var startAutoMessageFetchingCallCount = 0 + public private(set) var stopAutoMessageFetchingCallCount = 0 + + // MARK: - Initialization - public func stopAutoMessageFetching() { - stopAutoMessageFetchingCallCount += 1 - } + public init() {} + + // MARK: - Test Configuration + + /// Sets the contacts returned by `getContacts(since:)`. Actor isolation forbids writing the + /// stub property directly from a test, so configuration goes through this isolated setter. + public func setStubbedContacts(_ contacts: [MeshContact]) { + stubbedContacts = contacts + } + + /// Sets the self info returned by `currentSelfInfo`, through an isolated setter + /// for the same actor-isolation reason as `setStubbedContacts`. + public func setCurrentSelfInfo(_ selfInfo: SelfInfo?) { + currentSelfInfo = selfInfo + } + + /// Sets the results returned by successive `sendLogin` calls (isolated setter). + public func setSendLoginResults(_ results: [Result]) { + stubbedSendLoginResults = results + } + + /// Sets the error thrown by `addContact` (isolated setter). + public func setAddContactError(_ error: Error?) { + stubbedAddContactError = error + } + + // MARK: - Protocol Methods + + public func sendMessage(to destination: Data, text: String, timestamp: Date, attempt: UInt8) async throws -> MessageSentInfo { + sendMessageInvocations.append(SendMessageInvocation(destination: destination, text: text, timestamp: timestamp, attempt: attempt)) + switch stubbedSendMessageResult { + case let .success(info): + return info + case let .failure(error): + throw error + } + } + + public func sendChannelMessage(channel: UInt8, text: String, timestamp: Date) async throws { + sendChannelMessageInvocations.append(SendChannelMessageInvocation(channel: channel, text: text, timestamp: timestamp)) + if let error = stubbedSendChannelMessageError { + throw error + } + } + + public func getContacts(since lastModified: Date?) async throws -> [MeshContact] { + getContactsInvocations.append(lastModified) + if let error = stubbedGetContactsError { + throw error + } + return stubbedContacts + } + + public func getContact(publicKey: Data) async throws -> MeshContact? { + getContactPublicKeys.append(publicKey) + if let error = stubbedGetContactError { + throw error + } + return stubbedContact + } + + public func addContact(_ contact: MeshContact) async throws { + addContactInvocations.append(AddContactInvocation(contact: contact)) + if let error = stubbedAddContactError { + throw error + } + } + + public func removeContact(publicKey: Data) async throws { + removeContactPublicKeys.append(publicKey) + if let error = stubbedRemoveContactError { + throw error + } + } + + public func resetPath(publicKey: Data) async throws { + resetPathPublicKeys.append(publicKey) + if let error = stubbedResetPathError { + throw error + } + } + + public func sendPathDiscovery(to destination: Data) async throws -> MessageSentInfo { + sendPathDiscoveryDestinations.append(destination) + switch stubbedSendPathDiscoveryResult { + case let .success(info): + return info + case let .failure(error): + throw error + } + } + + public func getChannel(index: UInt8) async throws -> ChannelInfo { + getChannelIndices.append(index) + if let error = stubbedGetChannelError { + throw error + } + if let channel = stubbedChannels[index] { + return channel + } + // Return a default empty channel + return ChannelInfo(index: index, name: "", secret: Data(repeating: 0, count: 16)) + } + + public func getChannels(indices: [UInt8]) async throws -> (received: [ChannelInfo], missing: [UInt8]) { + getChannelIndices.append(contentsOf: indices) + if let error = stubbedGetChannelError { + throw error + } + // The firmware answers every requested index, so the mock returns one per request. + let received = indices.map { index in + stubbedChannels[index] ?? ChannelInfo(index: index, name: "", secret: Data(repeating: 0, count: 16)) + } + return (received: received, missing: []) + } + + public func setChannel(index: UInt8, name: String, secret: Data) async throws { + setChannelInvocations.append(SetChannelInvocation(index: index, name: name, secret: secret)) + if let error = stubbedSetChannelError { + throw error + } + } + + public func shareContact(publicKey: Data) async throws { + // Stub - not used in current tests + } + + public func exportContact(publicKey: Data?) async throws -> String { + // Stub - not used in current tests + "" + } + + public func importContact(cardData: Data) async throws { + // Stub - not used in current tests + } + + public func changeContactFlags(_ contact: MeshContact, flags: ContactFlags) async throws { + // Stub - not used in current tests + } + + public func waitForEvent(filter: EventFilter, timeout: TimeInterval?) async -> MeshEvent? { + waitForEventInvocations.append(WaitForEventInvocation(filter: filter, timeout: timeout)) + return stubbedWaitForEventResult + } + + public func getMessage(timeout: TimeInterval?) async throws -> MessageResult { + getMessageTimeouts.append(timeout) + switch stubbedGetMessageResult { + case let .success(result): + return result + case let .failure(error): + throw error + } + } + + public func startAutoMessageFetching() async { + startAutoMessageFetchingCallCount += 1 + } + + public func stopAutoMessageFetching() { + stopAutoMessageFetchingCallCount += 1 + } + + // MARK: - Test Helpers + + /// Error thrown by remote-access methods that a given test has not configured. + enum NotStubbed: Error { case method(String) } + + /// Resets all recorded invocations + public func reset() { + sendMessageInvocations = [] + sendChannelMessageInvocations = [] + getContactsInvocations = [] + getContactPublicKeys = [] + addContactInvocations = [] + sendLoginInvocations = [] + removeContactPublicKeys = [] + resetPathPublicKeys = [] + sendPathDiscoveryDestinations = [] + getChannelIndices = [] + setChannelInvocations = [] + waitForEventInvocations = [] + getMessageTimeouts = [] + startAutoMessageFetchingCallCount = 0 + stopAutoMessageFetchingCallCount = 0 + } +} - // MARK: - Test Helpers - - /// Resets all recorded invocations - public func reset() { - sendMessageInvocations = [] - sendChannelMessageInvocations = [] - getContactsInvocations = [] - getContactPublicKeys = [] - addContactInvocations = [] - removeContactPublicKeys = [] - resetPathPublicKeys = [] - sendPathDiscoveryDestinations = [] - getChannelIndices = [] - setChannelInvocations = [] - waitForEventInvocations = [] - getMessageTimeouts = [] - startAutoMessageFetchingCallCount = 0 - stopAutoMessageFetchingCallCount = 0 - } +// MARK: - RemoteAccessSessionOps + +extension MockMeshCoreSession: RemoteAccessSessionOps { + public func sendLogin(to destination: Data, password: String) async throws -> MessageSentInfo { + sendLoginInvocations.append(SendLoginInvocation(destination: destination, password: password)) + guard !stubbedSendLoginResults.isEmpty else { throw NotStubbed.method("sendLogin") } + switch stubbedSendLoginResults.removeFirst() { + case let .success(info): return info + case let .failure(error): throw error + } + } + + public func sendLogout(to destination: Data) async throws {} + + public func sendCommand(to destination: Data, command: String, timestamp: Date) async throws -> MessageSentInfo { + throw NotStubbed.method("sendCommand") + } + + public func sendKeepAlive(to publicKey: Data, syncSince: UInt32) async throws -> MessageSentInfo { + throw NotStubbed.method("sendKeepAlive") + } + + public func requestOwnerInfo(from publicKey: Data) async throws -> OwnerInfoResponse { + throw NotStubbed.method("requestOwnerInfo") + } + + public func requestStatus(from publicKey: Data, type: ContactType) async throws -> StatusResponse { + throw NotStubbed.method("requestStatus") + } + + public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { + throw NotStubbed.method("requestTelemetry") + } + + public func requestNeighbours( + from publicKey: Data, + count: UInt8, + offset: UInt16, + orderBy: UInt8, + pubkeyPrefixLength: UInt8 + ) async throws -> NeighboursResponse { + throw NotStubbed.method("requestNeighbours") + } + + public func sendMessageWithRetry( + to destination: Data, + text: String, + timestamp: Date, + maxAttempts: Int, + floodAfter: Int, + maxFloodAttempts: Int, + timeout: TimeInterval? + ) async throws -> MessageSentInfo? { + throw NotStubbed.method("sendMessageWithRetry") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshTransport.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshTransport.swift index ef5285c2..fbf8e1ae 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshTransport.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshTransport.swift @@ -8,82 +8,93 @@ import Foundation /// yielding session bytes — chokepoint-dispatch tests don't need a "ready" /// session to fire their assertions. public actor MockMeshTransport: iOSMeshTransport { - public struct ConnectInvocation: Sendable, Equatable { - public let deviceID: UUID? - public let timestamp: Date - } - - public private(set) var connectInvocations: [ConnectInvocation] = [] - private var currentDeviceID: UUID? - private var disconnectionHandler: (@Sendable (UUID, Error?) -> Void)? - private var reconnectionHandler: (@Sendable (UUID) -> Void)? - private let dataStream: AsyncStream - /// Retains the stream's continuation so it stays open. Tests don't yield bytes; - /// the stalled stream models a transport that connected at the BLE layer but - /// never delivers session data, letting cancellation/teardown tests run without - /// driving a full session lifecycle. Without this property the continuation - /// would deinit and the stream would finish, ending consumer awaits prematurely. - private let dataContinuation: AsyncStream.Continuation - private var connected = false - private var connectError: Error? - - public init() { - var continuation: AsyncStream.Continuation! - self.dataStream = AsyncStream { continuation = $0 } - self.dataContinuation = continuation - } - - // MARK: - MeshTransport - - public var receivedData: AsyncStream { dataStream } - public var isConnected: Bool { connected } - - public func connect() async throws { - connectInvocations.append(ConnectInvocation(deviceID: currentDeviceID, timestamp: Date())) - if let connectError { throw connectError } - connected = true - } - - public func disconnect() async { - connected = false - } - - public func send(_ data: Data) async throws {} - - // MARK: - iOSMeshTransport - - public func setDeviceID(_ id: UUID) { self.currentDeviceID = id } - - public func switchDevice(to deviceID: UUID) async throws { - connectInvocations.append(ConnectInvocation(deviceID: deviceID, timestamp: Date())) - self.currentDeviceID = deviceID - } - - public func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) { - self.disconnectionHandler = handler - } - - public func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) { - self.reconnectionHandler = handler - } - - // MARK: - Test Helpers - - /// When set, `connect()` records the invocation then throws this error on every attempt, - /// modeling a transport that never reaches a BLE link. Lets tests drive the retry budget - /// without standing up a real session. - public func setConnectError(_ error: Error?) { - self.connectError = error - } - - /// Indicates whether `setReconnectionHandler` was wired up during init. - /// Tests poll this before invoking `simulateReconnection` to avoid races. - public var hasReconnectionHandler: Bool { reconnectionHandler != nil } - - /// Invokes the registered reconnection handler as if iOS auto-reconnect completed - /// for `deviceID`. Mirrors `MockBLEStateMachine.simulateAutoReconnecting` for the - /// completion side of the auto-reconnect lifecycle. - public func simulateReconnection(deviceID: UUID) { - reconnectionHandler?(deviceID) - } + public struct ConnectInvocation: Sendable, Equatable { + public let deviceID: UUID? + public let timestamp: Date + } + + public private(set) var connectInvocations: [ConnectInvocation] = [] + public private(set) var disconnectInvocations = 0 + private var currentDeviceID: UUID? + private var disconnectionHandler: (@Sendable (UUID, Error?) -> Void)? + private var reconnectionHandler: (@Sendable (UUID) -> Void)? + private let dataStream: AsyncStream + /// Retains the stream's continuation so it stays open. Tests don't yield bytes; + /// the stalled stream models a transport that connected at the BLE layer but + /// never delivers session data, letting cancellation/teardown tests run without + /// driving a full session lifecycle. Without this property the continuation + /// would deinit and the stream would finish, ending consumer awaits prematurely. + private let dataContinuation: AsyncStream.Continuation + private var connected = false + private var connectError: Error? + + public init() { + var continuation: AsyncStream.Continuation! + dataStream = AsyncStream { continuation = $0 } + dataContinuation = continuation + } + + // MARK: - MeshTransport + + public var receivedData: AsyncStream { + dataStream + } + + public var isConnected: Bool { + connected + } + + public func connect() async throws { + connectInvocations.append(ConnectInvocation(deviceID: currentDeviceID, timestamp: Date())) + if let connectError { throw connectError } + connected = true + } + + public func disconnect() async { + disconnectInvocations += 1 + connected = false + } + + public func send(_ data: Data) async throws {} + + // MARK: - iOSMeshTransport + + public func setDeviceID(_ id: UUID) { + currentDeviceID = id + } + + public func switchDevice(to deviceID: UUID) async throws { + connectInvocations.append(ConnectInvocation(deviceID: deviceID, timestamp: Date())) + currentDeviceID = deviceID + } + + public func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) { + disconnectionHandler = handler + } + + public func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) { + reconnectionHandler = handler + } + + // MARK: - Test Helpers + + /// When set, `connect()` records the invocation then throws this error on every attempt, + /// modeling a transport that never reaches a BLE link. Lets tests drive the retry budget + /// without standing up a real session. + public func setConnectError(_ error: Error?) { + connectError = error + } + + /// Indicates whether `setReconnectionHandler` was wired up during init. + /// Tests poll this before invoking `simulateReconnection` to avoid races. + public var hasReconnectionHandler: Bool { + reconnectionHandler != nil + } + + /// Invokes the registered reconnection handler as if iOS auto-reconnect completed + /// for `deviceID`. Mirrors `MockBLEStateMachine.simulateAutoReconnecting` for the + /// completion side of the auto-reconnect lifecycle. + public func simulateReconnection(deviceID: UUID) { + reconnectionHandler?(deviceID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMessagePollingService.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMessagePollingService.swift index 7226e786..c7de04e4 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMessagePollingService.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMessagePollingService.swift @@ -1,107 +1,106 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore /// Mock implementation of MessagePollingServiceProtocol for testing. /// /// Configure the mock by setting the stub properties before calling methods. /// Track method calls by examining the recorded invocations. public actor MockMessagePollingService: MessagePollingServiceProtocol { + // MARK: - Stubs - // MARK: - Stubs + /// Result to return from pollAllMessages + public var stubbedPollAllMessagesResult: Result = .success(0) - /// Result to return from pollAllMessages - public var stubbedPollAllMessagesResult: Result = .success(0) + // MARK: - Recorded Invocations - // MARK: - Recorded Invocations + public private(set) var pollAllMessagesCallCount: Int = 0 + public private(set) var waitForPendingHandlersInvocations: Int = 0 + public private(set) var startAutoFetchRadioIDs: [UUID] = [] + public private(set) var pauseAutoFetchCallCount: Int = 0 + public private(set) var resumeAutoFetchCallCount: Int = 0 - public private(set) var pollAllMessagesCallCount: Int = 0 - public private(set) var waitForPendingHandlersInvocations: Int = 0 - public private(set) var startAutoFetchRadioIDs: [UUID] = [] - public private(set) var pauseAutoFetchCallCount: Int = 0 - public private(set) var resumeAutoFetchCallCount: Int = 0 + // MARK: - Initialization - // MARK: - Initialization + public init() {} - public init() {} + // MARK: - Protocol Methods - // MARK: - Protocol Methods - - public func pollAllMessages() async throws -> Int { - pollAllMessagesCallCount += 1 - switch stubbedPollAllMessagesResult { - case .success(let count): - return count - case .failure(let error): - throw error - } + public func pollAllMessages() async throws -> Int { + pollAllMessagesCallCount += 1 + switch stubbedPollAllMessagesResult { + case let .success(count): + return count + case let .failure(error): + throw error } + } - public func waitForPendingHandlers(timeout: Duration) async -> Bool { - waitForPendingHandlersInvocations += 1 - return true - } + public func waitForPendingHandlers(timeout: Duration) async -> Bool { + waitForPendingHandlersInvocations += 1 + return true + } - public func startAutoFetch(radioID: UUID) async { - startAutoFetchRadioIDs.append(radioID) - } + public func startAutoFetch(radioID: UUID) async { + startAutoFetchRadioIDs.append(radioID) + } - public func pauseAutoFetch() async { - pauseAutoFetchCallCount += 1 - } + public func pauseAutoFetch() async { + pauseAutoFetchCallCount += 1 + } - public func resumeAutoFetch() async { - resumeAutoFetchCallCount += 1 - } + public func resumeAutoFetch() async { + resumeAutoFetchCallCount += 1 + } - // MARK: - Captured Handlers + // MARK: - Captured Handlers - /// Captured contact message handler (set via setContactMessageHandler) - public private(set) var capturedContactMessageHandler: (@Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void)? + /// Captured contact message handler (set via setContactMessageHandler) + public private(set) var capturedContactMessageHandler: (@Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void)? - /// Captured channel message handler (set via setChannelMessageHandler) - public private(set) var capturedChannelMessageHandler: (@Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void)? + /// Captured channel message handler (set via setChannelMessageHandler) + public private(set) var capturedChannelMessageHandler: (@Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void)? - /// Captured signed message handler (set via setSignedMessageHandler) - public private(set) var capturedSignedMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? + /// Captured signed message handler (set via setSignedMessageHandler) + public private(set) var capturedSignedMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? - /// Captured CLI message handler (set via setCLIMessageHandler) - public private(set) var capturedCLIMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? + /// Captured CLI message handler (set via setCLIMessageHandler) + public private(set) var capturedCLIMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? - // MARK: - Handler Setter Methods (matching MessagePollingService) + // MARK: - Handler Setter Methods (matching MessagePollingService) - public func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) { - capturedContactMessageHandler = handler - } + public func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) { + capturedContactMessageHandler = handler + } - public func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) { - capturedChannelMessageHandler = handler - } + public func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) { + capturedChannelMessageHandler = handler + } - public func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { - capturedSignedMessageHandler = handler - } + public func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { + capturedSignedMessageHandler = handler + } - public func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { - capturedCLIMessageHandler = handler - } + public func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { + capturedCLIMessageHandler = handler + } - // MARK: - Test Helpers - - /// Resets all recorded invocations and captured handlers - public func reset() { - pollAllMessagesCallCount = 0 - waitForPendingHandlersInvocations = 0 - startAutoFetchRadioIDs = [] - pauseAutoFetchCallCount = 0 - resumeAutoFetchCallCount = 0 - capturedContactMessageHandler = nil - capturedChannelMessageHandler = nil - capturedSignedMessageHandler = nil - capturedCLIMessageHandler = nil - } + // MARK: - Test Helpers - public func setStubbedPollAllMessagesResult(_ result: Result) { - stubbedPollAllMessagesResult = result - } + /// Resets all recorded invocations and captured handlers + public func reset() { + pollAllMessagesCallCount = 0 + waitForPendingHandlersInvocations = 0 + startAutoFetchRadioIDs = [] + pauseAutoFetchCallCount = 0 + resumeAutoFetchCallCount = 0 + capturedContactMessageHandler = nil + capturedChannelMessageHandler = nil + capturedSignedMessageHandler = nil + capturedCLIMessageHandler = nil + } + + public func setStubbedPollAllMessagesResult(_ result: Result) { + stubbedPollAllMessagesResult = result + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index 4f1c93e7..e0d414f4 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -1,1943 +1,1942 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore /// Mock implementation of PersistenceStoreProtocol for testing. /// /// Uses in-memory storage for all data. Configure by adding items to the /// storage dictionaries or setting stubbed errors. public actor MockPersistenceStore: PersistenceStoreProtocol { - - // MARK: - In-Memory Storage - - public var messages: [UUID: MessageDTO] = [:] - public var contacts: [UUID: ContactDTO] = [:] - public var channels: [UUID: ChannelDTO] = [:] - public var debugLogEntries: [DebugLogEntryDTO] = [] - - // MARK: - Stubbed Errors - - public var stubbedSaveMessageError: Error? - public var stubbedFetchMessageError: Error? - public var stubbedUpdateMessageStatusError: Error? - public var stubbedSaveContactError: Error? - public var stubbedFetchContactError: Error? - public var stubbedDeleteContactError: Error? - public var stubbedSaveChannelError: Error? - public var stubbedFetchChannelError: Error? - public var stubbedDeleteChannelError: Error? - public var stubbedDebugLogError: Error? - - // MARK: - Recorded Invocations - - public private(set) var savedMessages: [MessageDTO] = [] - public private(set) var savedContacts: [ContactDTO] = [] - public private(set) var savedChannels: [ChannelDTO] = [] - public private(set) var deletedContactIDs: [UUID] = [] - public private(set) var deletedChannelIDs: [UUID] = [] - public private(set) var deletedMessagesForContactIDs: [UUID] = [] - public private(set) var deletedMessagesForChannelCalls: [(radioID: UUID, channelIndex: UInt8)] = [] - public private(set) var deletedChannelMessagesFromSenderCalls: [(senderName: String, radioID: UUID)] = [] - public private(set) var updatedMessageStatuses: [(id: UUID, status: MessageStatus)] = [] - public private(set) var updatedMessageAcks: [(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?)] = [] - - // MARK: - Initialization - - public init() {} - - // MARK: - Message Operations - - public func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { - messages.values.contains { $0.deduplicationKey == deduplicationKey && $0.radioID == radioID } - } - - public func saveMessage(_ dto: MessageDTO) async throws { - savedMessages.append(dto) - if let error = stubbedSaveMessageError { - throw error - } - messages[dto.id] = dto - } - - public func fetchMessage(id: UUID) async throws -> MessageDTO? { - if let error = stubbedFetchMessageError { - throw error - } - return messages[id] - } - - public func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { - if let error = stubbedFetchMessageError { throw error } - var result: [UUID: [MessageDTO]] = [:] - for contactID in contactIDs { - let filtered = messages.values.filter { $0.contactID == contactID } - .sorted { $0.timestamp < $1.timestamp } - result[contactID] = Array(filtered.prefix(limit)) - } - return result - } - - public func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { - if let error = stubbedFetchMessageError { throw error } - var result: [UUID: [MessageDTO]] = [:] - for channel in channels { - let filtered = messages.values.filter { $0.radioID == channel.radioID && $0.channelIndex == channel.channelIndex } - .sorted { $0.timestamp < $1.timestamp } - result[channel.id] = Array(filtered.prefix(limit)) - } - return result - } - - public func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { - if let error = stubbedFetchMessageError { - throw error - } - let filtered = messages.values.filter { $0.contactID == contactID } - .sorted { $0.timestamp < $1.timestamp } - return Array(filtered.dropFirst(offset).prefix(limit)) - } - - public func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { - if let error = stubbedFetchMessageError { - throw error - } - let filtered = messages.values.filter { $0.radioID == radioID && $0.channelIndex == channelIndex } - .sorted { $0.timestamp < $1.timestamp } - return Array(filtered.dropFirst(offset).prefix(limit)) - } - - public func findChannelMessageForReaction( - radioID: UUID, - channelIndex: UInt8, - parsedReaction: ParsedReaction, - localNodeName: String?, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> MessageDTO? { - let candidates = try await fetchChannelMessageCandidates( - radioID: radioID, - channelIndex: channelIndex, - timestampWindow: timestampWindow, - limit: limit - ) - - for candidate in candidates { - if candidate.direction == .outgoing { - guard let localNodeName, parsedReaction.targetSender == localNodeName else { - continue - } - } else { - guard candidate.senderNodeName == parsedReaction.targetSender else { - continue - } - } - - let hash = ReactionParser.generateMessageHash( - text: candidate.text, - timestamp: candidate.reactionTimestamp - ) - guard hash == parsedReaction.messageHash else { continue } - - return candidate - } - - return nil - } - - public func fetchChannelMessageCandidates( - radioID: UUID, - channelIndex: UInt8, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> [MessageDTO] { - if let error = stubbedFetchMessageError { - throw error - } - - return messages.values.filter { - $0.radioID == radioID && - $0.channelIndex == channelIndex && - timestampWindow.contains($0.timestamp) - } - .sorted { - if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } - return $0.createdAt > $1.createdAt - } - .prefix(limit) - .map { $0 } - } - - public func fetchDMMessageCandidates( - radioID: UUID, - contactID: UUID, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> [MessageDTO] { - if let error = stubbedFetchMessageError { - throw error - } - - return messages.values.filter { - $0.radioID == radioID && - $0.contactID == contactID && - timestampWindow.contains($0.timestamp) - } - .sorted { - if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } - return $0.createdAt > $1.createdAt - } + // MARK: - In-Memory Storage + + public var messages: [UUID: MessageDTO] = [:] + public var contacts: [UUID: ContactDTO] = [:] + public var channels: [UUID: ChannelDTO] = [:] + public var debugLogEntries: [DebugLogEntryDTO] = [] + + // MARK: - Stubbed Errors + + public var stubbedSaveMessageError: Error? + public var stubbedFetchMessageError: Error? + public var stubbedUpdateMessageStatusError: Error? + public var stubbedSaveContactError: Error? + public var stubbedFetchContactError: Error? + public var stubbedDeleteContactError: Error? + public var stubbedSaveChannelError: Error? + public var stubbedFetchChannelError: Error? + public var stubbedDeleteChannelError: Error? + public var stubbedDebugLogError: Error? + + // MARK: - Recorded Invocations + + public private(set) var savedMessages: [MessageDTO] = [] + public private(set) var savedContacts: [ContactDTO] = [] + public private(set) var savedChannels: [ChannelDTO] = [] + public private(set) var deletedContactIDs: [UUID] = [] + public private(set) var deletedChannelIDs: [UUID] = [] + public private(set) var deletedMessagesForContactIDs: [UUID] = [] + public private(set) var deletedMessagesForChannelCalls: [(radioID: UUID, channelIndex: UInt8)] = [] + public private(set) var deletedChannelMessagesFromSenderCalls: [(senderName: String, radioID: UUID)] = [] + public private(set) var updatedMessageStatuses: [(id: UUID, status: MessageStatus)] = [] + public private(set) var updatedMessageAcks: [(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?)] = [] + + // MARK: - Initialization + + public init() {} + + // MARK: - Message Operations + + public func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { + messages.values.contains { $0.deduplicationKey == deduplicationKey && $0.radioID == radioID } + } + + public func saveMessage(_ dto: MessageDTO) async throws { + savedMessages.append(dto) + if let error = stubbedSaveMessageError { + throw error + } + messages[dto.id] = dto + } + + public func fetchMessage(id: UUID) async throws -> MessageDTO? { + if let error = stubbedFetchMessageError { + throw error + } + return messages[id] + } + + public func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + if let error = stubbedFetchMessageError { throw error } + var result: [UUID: [MessageDTO]] = [:] + for contactID in contactIDs { + let filtered = messages.values.filter { $0.contactID == contactID } + .sorted { $0.timestamp < $1.timestamp } + result[contactID] = Array(filtered.prefix(limit)) + } + return result + } + + public func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + if let error = stubbedFetchMessageError { throw error } + var result: [UUID: [MessageDTO]] = [:] + for channel in channels { + let filtered = messages.values.filter { $0.radioID == channel.radioID && $0.channelIndex == channel.channelIndex } + .sorted { $0.timestamp < $1.timestamp } + result[channel.id] = Array(filtered.prefix(limit)) + } + return result + } + + public func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { + if let error = stubbedFetchMessageError { + throw error + } + let filtered = messages.values.filter { $0.contactID == contactID } + .sorted { $0.timestamp < $1.timestamp } + return Array(filtered.dropFirst(offset).prefix(limit)) + } + + public func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { + if let error = stubbedFetchMessageError { + throw error + } + let filtered = messages.values.filter { $0.radioID == radioID && $0.channelIndex == channelIndex } + .sorted { $0.timestamp < $1.timestamp } + return Array(filtered.dropFirst(offset).prefix(limit)) + } + + public func findChannelMessageForReaction( + radioID: UUID, + channelIndex: UInt8, + parsedReaction: ParsedReaction, + localNodeName: String?, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> MessageDTO? { + let candidates = try await fetchChannelMessageCandidates( + radioID: radioID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: limit + ) + + for candidate in candidates { + if candidate.direction == .outgoing { + guard let localNodeName, parsedReaction.targetSender == localNodeName else { + continue + } + } else { + guard candidate.senderNodeName == parsedReaction.targetSender else { + continue + } + } + + let hash = ReactionParser.generateMessageHash( + text: candidate.text, + timestamp: candidate.reactionTimestamp + ) + guard hash == parsedReaction.messageHash else { continue } + + return candidate + } + + return nil + } + + public func fetchChannelMessageCandidates( + radioID: UUID, + channelIndex: UInt8, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] { + if let error = stubbedFetchMessageError { + throw error + } + + return messages.values.filter { + $0.radioID == radioID && + $0.channelIndex == channelIndex && + timestampWindow.contains($0.timestamp) + } + .sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } + return $0.createdAt > $1.createdAt + } + .prefix(limit) + .map(\.self) + } + + public func fetchDMMessageCandidates( + radioID: UUID, + contactID: UUID, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] { + if let error = stubbedFetchMessageError { + throw error + } + + return messages.values.filter { + $0.radioID == radioID && + $0.contactID == contactID && + timestampWindow.contains($0.timestamp) + } + .sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } + return $0.createdAt > $1.createdAt + } + .prefix(limit) + .map(\.self) + } + + public func findDMMessageForReaction( + radioID: UUID, + contactID: UUID, + messageHash: String, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> MessageDTO? { + let candidates = try await fetchDMMessageCandidates( + radioID: radioID, + contactID: contactID, + timestampWindow: timestampWindow, + limit: limit + ) + + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } + + let hash = ReactionParser.generateMessageHash( + text: candidate.text, + timestamp: candidate.reactionTimestamp + ) + if hash == messageHash { + return candidate + } + } + + return nil + } + + public func updateMessageStatus(id: UUID, status: MessageStatus) async throws { + updatedMessageStatuses.append((id: id, status: status)) + if let error = stubbedUpdateMessageStatusError { + throw error + } + if var message = messages[id] { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { + guard let message = messages[id], message.status != .delivered else { return false } + try await updateMessageStatus(id: id, status: status) + return true + } + + public func clearRetryingToSent(id: UUID) async throws -> Bool { + guard let message = messages[id], + message.status != .delivered, + message.status != .failed else { return false } + try await updateMessageStatus(id: id, status: .sent) + return true + } + + public func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { + messages.values.contains { + $0.ackCode == ackCode && $0.status == .sent && $0.direction == .outgoing && $0.channelIndex == nil + } + } + + public func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws { + updatedMessageAcks.append((id: id, ackCode: ackCode, status: status, roundTripTime: roundTripTime)) + if let error = stubbedUpdateMessageStatusError { + throw error + } + if var message = messages[id] { + // Mirror PersistenceStore.updateMessageAck: a terminal row + // (.delivered/.failed) is not flipped by a late ACK; only the + // legitimate .sent -> .delivered upgrade is allowed through. + if message.status == .delivered || message.status == .failed, status != message.status { return } + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: status, + textType: message.textType, + ackCode: ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws { + if let error = stubbedUpdateMessageStatusError { + throw error + } + // Mirror PersistenceStore: a terminal row (.delivered/.failed) is not + // overwritten by a stale retry iteration. + if let message = messages[id], message.status != .delivered, message.status != .failed { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + sendCount: message.sendCount, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts + ) + } + } + + public func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws { + if let message = messages[id] { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + sendCount: message.sendCount, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws { + if let message = messages[id] { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func markMessageAsRead(id: UUID) async throws { + if var message = messages[id] { + message.isRead = true + messages[id] = message + } + } + + public func updateMessageLinkPreview( + id: UUID, + url: String?, + title: String?, + imageData: Data?, + iconData: Data?, + fetched: Bool + ) throws { + if let message = messages[id] { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts, + linkPreviewURL: url, + linkPreviewTitle: title, + linkPreviewImageData: imageData, + linkPreviewIconData: iconData, + linkPreviewFetched: fetched + ) + } + } + + // MARK: - Contact Operations + + public func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { + if let error = stubbedFetchContactError { + throw error + } + return Array(contacts.values.filter { $0.radioID == radioID }) + } + + public func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values + .filter { $0.radioID == radioID && $0.lastMessageDate != nil } + .sorted { ($0.lastMessageDate ?? .distantPast) > ($1.lastMessageDate ?? .distantPast) } + } + + public func fetchContact(id: UUID) async throws -> ContactDTO? { + if let error = stubbedFetchContactError { + throw error + } + return contacts[id] + } + + public func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values.first { $0.radioID == radioID && $0.publicKey == publicKey } + } + + public func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values.first { $0.radioID == radioID && $0.publicKey.prefix(6) == publicKeyPrefix } + } + + public func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { + if let error = stubbedFetchContactError { + throw error + } + var result: [UInt8: [Data]] = [:] + for contact in contacts.values { + guard contact.radioID == radioID, contact.publicKey.count >= 1 else { continue } + let prefix = contact.publicKey[0] + result[prefix, default: []].append(contact.publicKey) + } + return result + } + + public func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values.first { $0.publicKey.prefix(prefix.count) == prefix }?.displayName + } + + public func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values.first { $0.publicKey == publicKey } + } + + @discardableResult + public func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { + if let error = stubbedSaveContactError { + throw error + } + // Check if contact already exists + if let existing = contacts.values.first(where: { $0.radioID == radioID && $0.publicKey == frame.publicKey }) { + return existing.id + } + let id = UUID() + let dto = ContactDTO( + id: id, + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.typeRawValue, + flags: frame.flags, + outPathLength: frame.outPathLength, + outPath: frame.outPath, + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + lastModified: frame.lastModified, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + contacts[id] = dto + savedContacts.append(dto) + return id + } + + public func saveContact(_ dto: ContactDTO) async throws { + savedContacts.append(dto) + if let error = stubbedSaveContactError { + throw error + } + contacts[dto.id] = dto + } + + public func deleteContact(id: UUID) async throws { + deletedContactIDs.append(id) + if let error = stubbedDeleteContactError { + throw error + } + contacts.removeValue(forKey: id) + } + + public func updateContactLastMessage(contactID: UUID, date: Date?) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: date, + unreadCount: contact.unreadCount, + unreadMentionCount: contact.unreadMentionCount + ) + } + } + + public func incrementUnreadCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: contact.unreadCount + 1, + unreadMentionCount: contact.unreadMentionCount + ) + } + } + + public func clearUnreadCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: 0, + unreadMentionCount: contact.unreadMentionCount + ) + } + } + + // MARK: - Mention Tracking + + public func markMentionSeen(messageID: UUID) async throws { + if let message = messages[messageID] { + messages[messageID] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts, + deduplicationKey: message.deduplicationKey, + linkPreviewURL: message.linkPreviewURL, + linkPreviewTitle: message.linkPreviewTitle, + linkPreviewImageData: message.linkPreviewImageData, + linkPreviewIconData: message.linkPreviewIconData, + linkPreviewFetched: message.linkPreviewFetched, + containsSelfMention: message.containsSelfMention, + mentionSeen: true + ) + } + } + + public func incrementUnreadMentionCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: contact.unreadCount, + unreadMentionCount: contact.unreadMentionCount + 1 + ) + } + } + + public func decrementUnreadMentionCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: contact.unreadCount, + unreadMentionCount: max(0, contact.unreadMentionCount - 1) + ) + } + } + + public func clearUnreadMentionCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: contact.unreadCount, + unreadMentionCount: 0 + ) + } + } + + public func incrementChannelUnreadMentionCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount, + unreadMentionCount: channel.unreadMentionCount + 1, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func decrementChannelUnreadMentionCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount, + unreadMentionCount: max(0, channel.unreadMentionCount - 1), + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func clearChannelUnreadMentionCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount, + unreadMentionCount: 0, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { + messages.values + .filter { $0.contactID == contactID && $0.containsSelfMention && !$0.mentionSeen } + .sorted { $0.timestamp < $1.timestamp } + .map(\.id) + } + + public func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { + messages.values + .filter { $0.radioID == radioID && $0.channelIndex == channelIndex && $0.containsSelfMention && !$0.mentionSeen } + .sorted { $0.timestamp < $1.timestamp } + .map(\.id) + } + + public func deleteMessagesForContact(contactID: UUID) async throws { + deletedMessagesForContactIDs.append(contactID) + messages = messages.filter { $0.value.contactID != contactID } + } + + public func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { + if let error = stubbedFetchContactError { + throw error + } + return Array(contacts.values.filter { $0.radioID == radioID && $0.isBlocked }) + } + + // MARK: - Blocked Channel Senders + + public var blockedChannelSenders: [String: BlockedChannelSenderDTO] = [:] + public private(set) var savedBlockedChannelSenders: [BlockedChannelSenderDTO] = [] + public private(set) var deletedBlockedChannelSenderNames: [(radioID: UUID, name: String)] = [] + + public func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws { + savedBlockedChannelSenders.append(dto) + blockedChannelSenders["\(dto.radioID)-\(dto.name)"] = dto + } + + public func deleteBlockedChannelSender(radioID: UUID, name: String) async throws { + deletedBlockedChannelSenderNames.append((radioID: radioID, name: name)) + blockedChannelSenders.removeValue(forKey: "\(radioID)-\(name)") + } + + public func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { + Array(blockedChannelSenders.values.filter { $0.radioID == radioID }) + } + + // MARK: - Channel Operations + + public func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { + if let error = stubbedFetchChannelError { + throw error + } + return channels.values.filter { $0.radioID == radioID }.sorted { $0.index < $1.index } + } + + public func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { + if let error = stubbedFetchChannelError { + throw error + } + return channels.values.first { $0.radioID == radioID && $0.index == index } + } + + public func fetchChannel(id: UUID) async throws -> ChannelDTO? { + if let error = stubbedFetchChannelError { + throw error + } + return channels[id] + } + + @discardableResult + public func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { + if let error = stubbedSaveChannelError { + throw error + } + // Check if channel already exists + if let existing = channels.values.first(where: { $0.radioID == radioID && $0.index == info.index }) { + // Update existing + channels[existing.id] = ChannelDTO( + id: existing.id, + radioID: radioID, + index: info.index, + name: info.name, + secret: info.secret, + isEnabled: !info.name.isEmpty, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + unreadMentionCount: existing.unreadMentionCount, + notificationLevel: existing.notificationLevel, + isFavorite: existing.isFavorite + ) + return existing.id + } + let id = UUID() + let dto = ChannelDTO( + id: id, + radioID: radioID, + index: info.index, + name: info.name, + secret: info.secret, + isEnabled: !info.name.isEmpty, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + channels[id] = dto + savedChannels.append(dto) + return id + } + + public func saveChannel(_ dto: ChannelDTO) async throws { + savedChannels.append(dto) + if let error = stubbedSaveChannelError { + throw error + } + channels[dto.id] = dto + } + + public func deleteChannel(id: UUID) async throws { + deletedChannelIDs.append(id) + if let error = stubbedDeleteChannelError { + throw error + } + channels.removeValue(forKey: id) + } + + public func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws { + deletedMessagesForChannelCalls.append((radioID: radioID, channelIndex: channelIndex)) + messages = messages.filter { $0.value.radioID != radioID || $0.value.channelIndex != channelIndex } + } + + public func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws { + deletedChannelMessagesFromSenderCalls.append((senderName: senderName, radioID: radioID)) + messages = messages.filter { _, msg in + !(msg.senderNodeName == senderName && msg.radioID == radioID && msg.channelIndex != nil) + } + } + + public func updateChannelLastMessage(channelID: UUID, date: Date?) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: date, + unreadCount: channel.unreadCount, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func incrementChannelUnreadCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount + 1, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws { + if let channel = channels.values.first(where: { $0.radioID == radioID && $0.index == index }) { + try await clearChannelUnreadCount(channelID: channel.id) + } + } + + public func clearChannelUnreadCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: 0, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: level, + isFavorite: channel.isFavorite + ) + } + } + + public func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws { + // Stub - sessions not tracked in mock + } + + // MARK: - RxLogEntry Lookup + + private var mockRxLogEntries: [RxLogEntryDTO] = [] + + public func setMockRxLogEntry(_ entry: RxLogEntryDTO) { + mockRxLogEntries.append(entry) + } + + public func findRxLogEntry( + radioID: UUID, + channelIndex: UInt8?, + senderTimestamp: UInt32 + ) throws -> RxLogEntryDTO? { + if let channelIndex { + mockRxLogEntries.first { entry in + entry.radioID == radioID && + entry.channelIndex == channelIndex && + entry.senderTimestamp == senderTimestamp + } + } else { + mockRxLogEntries.first { entry in + entry.radioID == radioID && + entry.senderTimestamp == senderTimestamp && + entry.channelIndex == nil + } + } + } + + public func findRxLogEntryBySenderPrefix( + radioID: UUID, + senderPrefixByte: UInt8, + receivedSince: Date + ) throws -> RxLogEntryDTO? { + mockRxLogEntries.first { entry in + entry.radioID == radioID && + entry.channelIndex == nil && + entry.payloadType == .textMessage && + entry.receivedAt >= receivedSince && + entry.packetPayload.count >= 2 && + entry.packetPayload[1] == senderPrefixByte + } + } + + // MARK: - RX Log + + public private(set) var updatedRxLogRegions: [(id: UUID, regionScope: String?)] = [] + public private(set) var updatedChannelMessageRegions: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + public private(set) var updatedDMMessageRegions: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + + public func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws { + mockRxLogEntries.append(dto) + } + + public func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { + Array( + mockRxLogEntries + .filter { $0.radioID == radioID } + .sorted { $0.receivedAt > $1.receivedAt } .prefix(limit) - .map { $0 } - } - - public func findDMMessageForReaction( - radioID: UUID, - contactID: UUID, - messageHash: String, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> MessageDTO? { - let candidates = try await fetchDMMessageCandidates( - radioID: radioID, - contactID: contactID, - timestampWindow: timestampWindow, - limit: limit - ) - - for candidate in candidates { - // Skip messages that are themselves reactions - if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } - - let hash = ReactionParser.generateMessageHash( - text: candidate.text, - timestamp: candidate.reactionTimestamp - ) - if hash == messageHash { - return candidate - } - } - - return nil - } - - public func updateMessageStatus(id: UUID, status: MessageStatus) async throws { - updatedMessageStatuses.append((id: id, status: status)) - if let error = stubbedUpdateMessageStatusError { - throw error - } - if var message = messages[id] { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { - guard let message = messages[id], message.status != .delivered else { return false } - try await updateMessageStatus(id: id, status: status) - return true - } - - public func clearRetryingToSent(id: UUID) async throws -> Bool { - guard let message = messages[id], - message.status != .delivered, - message.status != .failed else { return false } - try await updateMessageStatus(id: id, status: .sent) - return true - } - - public func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { - messages.values.contains { - $0.ackCode == ackCode && $0.status == .sent && $0.direction == .outgoing && $0.channelIndex == nil - } - } - - public func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws { - updatedMessageAcks.append((id: id, ackCode: ackCode, status: status, roundTripTime: roundTripTime)) - if let error = stubbedUpdateMessageStatusError { - throw error - } - if var message = messages[id] { - // Mirror PersistenceStore.updateMessageAck: a terminal row - // (.delivered/.failed) is not flipped by a late ACK; only the - // legitimate .sent -> .delivered upgrade is allowed through. - if (message.status == .delivered || message.status == .failed) && status != message.status { return } - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: status, - textType: message.textType, - ackCode: ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws { - if let error = stubbedUpdateMessageStatusError { - throw error - } - // Mirror PersistenceStore: a terminal row (.delivered/.failed) is not - // overwritten by a stale retry iteration. - if let message = messages[id], message.status != .delivered, message.status != .failed { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - sendCount: message.sendCount, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts - ) - } - } - - public func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws { - if let message = messages[id] { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - sendCount: message.sendCount, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws { - if let message = messages[id] { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func markMessageAsRead(id: UUID) async throws { - if var message = messages[id] { - message.isRead = true - messages[id] = message - } - } - - public func updateMessageLinkPreview( - id: UUID, - url: String?, - title: String?, - imageData: Data?, - iconData: Data?, - fetched: Bool - ) throws { - if let message = messages[id] { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts, - linkPreviewURL: url, - linkPreviewTitle: title, - linkPreviewImageData: imageData, - linkPreviewIconData: iconData, - linkPreviewFetched: fetched - ) - } - } - - // MARK: - Contact Operations - - public func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { - if let error = stubbedFetchContactError { - throw error - } - return Array(contacts.values.filter { $0.radioID == radioID }) - } - - public func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values - .filter { $0.radioID == radioID && $0.lastMessageDate != nil } - .sorted { ($0.lastMessageDate ?? .distantPast) > ($1.lastMessageDate ?? .distantPast) } - } - - public func fetchContact(id: UUID) async throws -> ContactDTO? { - if let error = stubbedFetchContactError { - throw error - } - return contacts[id] - } - - public func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values.first { $0.radioID == radioID && $0.publicKey == publicKey } - } - - public func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values.first { $0.radioID == radioID && $0.publicKey.prefix(6) == publicKeyPrefix } - } - - public func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { - if let error = stubbedFetchContactError { - throw error - } - var result: [UInt8: [Data]] = [:] - for contact in contacts.values { - guard contact.radioID == radioID, contact.publicKey.count >= 1 else { continue } - let prefix = contact.publicKey[0] - result[prefix, default: []].append(contact.publicKey) - } - return result - } - - public func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values.first { $0.publicKey.prefix(prefix.count) == prefix }?.displayName - } - - public func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values.first { $0.publicKey == publicKey } - } - - @discardableResult - public func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { - if let error = stubbedSaveContactError { - throw error - } - // Check if contact already exists - if let existing = contacts.values.first(where: { $0.radioID == radioID && $0.publicKey == frame.publicKey }) { - return existing.id - } - let id = UUID() - let dto = ContactDTO( - id: id, - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.type.rawValue, - flags: frame.flags, - outPathLength: frame.outPathLength, - outPath: frame.outPath, - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - lastModified: frame.lastModified, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - contacts[id] = dto - savedContacts.append(dto) - return id - } - - public func saveContact(_ dto: ContactDTO) async throws { - savedContacts.append(dto) - if let error = stubbedSaveContactError { - throw error - } - contacts[dto.id] = dto - } - - public func deleteContact(id: UUID) async throws { - deletedContactIDs.append(id) - if let error = stubbedDeleteContactError { - throw error - } - contacts.removeValue(forKey: id) - } - - public func updateContactLastMessage(contactID: UUID, date: Date?) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: date, - unreadCount: contact.unreadCount, - unreadMentionCount: contact.unreadMentionCount - ) - } - } - - public func incrementUnreadCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: contact.unreadCount + 1, - unreadMentionCount: contact.unreadMentionCount - ) - } - } - - public func clearUnreadCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: 0, - unreadMentionCount: contact.unreadMentionCount - ) - } - } - - // MARK: - Mention Tracking - - public func markMentionSeen(messageID: UUID) async throws { - if let message = messages[messageID] { - messages[messageID] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts, - deduplicationKey: message.deduplicationKey, - linkPreviewURL: message.linkPreviewURL, - linkPreviewTitle: message.linkPreviewTitle, - linkPreviewImageData: message.linkPreviewImageData, - linkPreviewIconData: message.linkPreviewIconData, - linkPreviewFetched: message.linkPreviewFetched, - containsSelfMention: message.containsSelfMention, - mentionSeen: true - ) - } - } - - public func incrementUnreadMentionCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: contact.unreadCount, - unreadMentionCount: contact.unreadMentionCount + 1 - ) - } - } - - public func decrementUnreadMentionCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: contact.unreadCount, - unreadMentionCount: max(0, contact.unreadMentionCount - 1) - ) - } - } - - public func clearUnreadMentionCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: contact.unreadCount, - unreadMentionCount: 0 - ) - } - } - - public func incrementChannelUnreadMentionCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount, - unreadMentionCount: channel.unreadMentionCount + 1, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func decrementChannelUnreadMentionCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount, - unreadMentionCount: max(0, channel.unreadMentionCount - 1), - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func clearChannelUnreadMentionCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount, - unreadMentionCount: 0, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { - messages.values - .filter { $0.contactID == contactID && $0.containsSelfMention && !$0.mentionSeen } - .sorted { $0.timestamp < $1.timestamp } - .map(\.id) - } - - public func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { - messages.values - .filter { $0.radioID == radioID && $0.channelIndex == channelIndex && $0.containsSelfMention && !$0.mentionSeen } - .sorted { $0.timestamp < $1.timestamp } - .map(\.id) - } - - public func deleteMessagesForContact(contactID: UUID) async throws { - deletedMessagesForContactIDs.append(contactID) - messages = messages.filter { $0.value.contactID != contactID } - } - - public func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { - if let error = stubbedFetchContactError { - throw error - } - return Array(contacts.values.filter { $0.radioID == radioID && $0.isBlocked }) - } - - // MARK: - Blocked Channel Senders - - public var blockedChannelSenders: [String: BlockedChannelSenderDTO] = [:] - public private(set) var savedBlockedChannelSenders: [BlockedChannelSenderDTO] = [] - public private(set) var deletedBlockedChannelSenderNames: [(radioID: UUID, name: String)] = [] - - public func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws { - savedBlockedChannelSenders.append(dto) - blockedChannelSenders["\(dto.radioID)-\(dto.name)"] = dto - } - - public func deleteBlockedChannelSender(radioID: UUID, name: String) async throws { - deletedBlockedChannelSenderNames.append((radioID: radioID, name: name)) - blockedChannelSenders.removeValue(forKey: "\(radioID)-\(name)") - } - - public func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { - Array(blockedChannelSenders.values.filter { $0.radioID == radioID }) - } - - // MARK: - Channel Operations - - public func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { - if let error = stubbedFetchChannelError { - throw error - } - return channels.values.filter { $0.radioID == radioID }.sorted { $0.index < $1.index } - } - - public func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { - if let error = stubbedFetchChannelError { - throw error - } - return channels.values.first { $0.radioID == radioID && $0.index == index } - } - - public func fetchChannel(id: UUID) async throws -> ChannelDTO? { - if let error = stubbedFetchChannelError { - throw error - } - return channels[id] - } - - @discardableResult - public func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { - if let error = stubbedSaveChannelError { - throw error - } - // Check if channel already exists - if let existing = channels.values.first(where: { $0.radioID == radioID && $0.index == info.index }) { - // Update existing - channels[existing.id] = ChannelDTO( - id: existing.id, - radioID: radioID, - index: info.index, - name: info.name, - secret: info.secret, - isEnabled: !info.name.isEmpty, - lastMessageDate: existing.lastMessageDate, - unreadCount: existing.unreadCount, - unreadMentionCount: existing.unreadMentionCount, - notificationLevel: existing.notificationLevel, - isFavorite: existing.isFavorite - ) - return existing.id - } - let id = UUID() - let dto = ChannelDTO( - id: id, - radioID: radioID, - index: info.index, - name: info.name, - secret: info.secret, - isEnabled: !info.name.isEmpty, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false - ) - channels[id] = dto - savedChannels.append(dto) - return id - } - - public func saveChannel(_ dto: ChannelDTO) async throws { - savedChannels.append(dto) - if let error = stubbedSaveChannelError { - throw error - } - channels[dto.id] = dto - } - - public func deleteChannel(id: UUID) async throws { - deletedChannelIDs.append(id) - if let error = stubbedDeleteChannelError { - throw error - } - channels.removeValue(forKey: id) - } - - public func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws { - deletedMessagesForChannelCalls.append((radioID: radioID, channelIndex: channelIndex)) - messages = messages.filter { $0.value.radioID != radioID || $0.value.channelIndex != channelIndex } - } - - public func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws { - deletedChannelMessagesFromSenderCalls.append((senderName: senderName, radioID: radioID)) - messages = messages.filter { _, msg in - !(msg.senderNodeName == senderName && msg.radioID == radioID && msg.channelIndex != nil) - } - } - - public func updateChannelLastMessage(channelID: UUID, date: Date?) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: date, - unreadCount: channel.unreadCount, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func incrementChannelUnreadCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount + 1, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws { - if let channel = channels.values.first(where: { $0.radioID == radioID && $0.index == index }) { - try await clearChannelUnreadCount(channelID: channel.id) - } - } - - public func clearChannelUnreadCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: 0, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: level, - isFavorite: channel.isFavorite - ) - } - } - - public func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws { - // Stub - sessions not tracked in mock - } - - // MARK: - RxLogEntry Lookup - - private var mockRxLogEntries: [RxLogEntryDTO] = [] - - public func setMockRxLogEntry(_ entry: RxLogEntryDTO) { - mockRxLogEntries.append(entry) - } - - public func findRxLogEntry( - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32 - ) throws -> RxLogEntryDTO? { - if let channelIndex { - return mockRxLogEntries.first { entry in - entry.radioID == radioID && - entry.channelIndex == channelIndex && - entry.senderTimestamp == senderTimestamp - } - } else { - return mockRxLogEntries.first { entry in - entry.radioID == radioID && - entry.senderTimestamp == senderTimestamp && - entry.channelIndex == nil - } - } - } - - public func findRxLogEntryBySenderPrefix( - radioID: UUID, - senderPrefixByte: UInt8, - receivedSince: Date - ) throws -> RxLogEntryDTO? { - mockRxLogEntries.first { entry in - entry.radioID == radioID && - entry.channelIndex == nil && - entry.payloadType == .textMessage && - entry.receivedAt >= receivedSince && - entry.packetPayload.count >= 2 && - entry.packetPayload[1] == senderPrefixByte - } - } - - // MARK: - RX Log - - public private(set) var updatedRxLogRegions: [(id: UUID, regionScope: String?)] = [] - public private(set) var updatedChannelMessageRegions: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - public private(set) var updatedDMMessageRegions: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - - public func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws { - mockRxLogEntries.append(dto) - } - - public func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { - Array( - mockRxLogEntries - .filter { $0.radioID == radioID } - .sorted { $0.receivedAt > $1.receivedAt } - .prefix(limit) - ) - } - - public func clearRxLogEntries(radioID: UUID) async throws { - mockRxLogEntries.removeAll { $0.radioID == radioID } - } - - public func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws { - let entries = mockRxLogEntries.filter { $0.radioID == radioID } - guard entries.count > keepCount + pruneThreshold else { return } - let keptIDs = Set(entries.sorted { $0.receivedAt > $1.receivedAt }.prefix(keepCount).map(\.id)) - mockRxLogEntries.removeAll { $0.radioID == radioID && !keptIDs.contains($0.id) } - } - - public func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { - mockRxLogEntries.filter { $0.radioID == radioID && $0.transportCode != nil && $0.regionScope == nil } - } - - public func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { - mockRxLogEntries.filter { $0.radioID == radioID && $0.decryptStatus == status && $0.receivedAt >= since } - } - - public func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws { - updatedRxLogRegions.append(contentsOf: updates) - } - - public func batchUpdateRxLogDecryption( - _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] - ) async throws { - for update in updates { - guard let index = mockRxLogEntries.firstIndex(where: { $0.id == update.id }) else { continue } - mockRxLogEntries[index].channelIndex = update.channelIndex - mockRxLogEntries[index].channelName = update.channelName - mockRxLogEntries[index].senderTimestamp = update.senderTimestamp - } - } - - public func batchUpdateChannelMessageRegion( - radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws { - updatedChannelMessageRegions.append(contentsOf: updates) - } - - public func batchUpdateDMMessageRegion( - radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws { - updatedDMMessageRegions.append(contentsOf: updates) - } - - // MARK: - Saved Trace Paths - - public var savedTracePaths: [UUID: SavedTracePathDTO] = [:] - - public func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { - savedTracePaths.values.filter { $0.radioID == radioID }.sorted(by: { $0.createdDate > $1.createdDate }) - } - - public func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { - savedTracePaths[id] - } - - public func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { - let id = UUID() - let runs = initialRun.map { [$0] } ?? [] - let dto = SavedTracePathDTO( - id: id, - radioID: radioID, - name: name, - pathBytes: pathBytes, - hashSize: hashSize, - createdDate: Date(), - runs: runs - ) - savedTracePaths[id] = dto - return dto - } - - public func updateSavedTracePathName(id: UUID, name: String) async throws { - if let path = savedTracePaths[id] { - savedTracePaths[id] = SavedTracePathDTO( - id: path.id, - radioID: path.radioID, - name: name, - pathBytes: path.pathBytes, - hashSize: path.hashSize, - createdDate: path.createdDate, - runs: path.runs - ) - } - } - - public func deleteSavedTracePath(id: UUID) async throws { - savedTracePaths.removeValue(forKey: id) - } - - public func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws { - if let path = savedTracePaths[pathID] { - var runs = path.runs - runs.append(run) - savedTracePaths[pathID] = SavedTracePathDTO( - id: path.id, - radioID: path.radioID, - name: path.name, - pathBytes: path.pathBytes, - hashSize: path.hashSize, - createdDate: path.createdDate, - runs: runs - ) - } - } - - // MARK: - Heard Repeats - - public func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? { - return nil // Stub - } - - public func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws { - // Stub - no-op - } - - public func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { - return [] // Stub - } - - public func deleteMessageRepeats(messageID: UUID) async throws { - // Stub - no-op - } - - public func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { - return false // Stub - } - - public func incrementMessageHeardRepeats(id: UUID) async throws -> Int { - return 0 // Stub - } - - public func incrementMessageSendCount(id: UUID) async throws -> Int { - if let message = messages[id] { - let newCount = message.sendCount + 1 - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - sendCount: newCount, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - return newCount - } - return 0 - } - - // MARK: - Debug Log Operations - - public func saveDebugLogEntries(_ entries: [DebugLogEntryDTO]) async throws { - if let error = stubbedDebugLogError { - throw error - } - debugLogEntries.append(contentsOf: entries) - } - - public func fetchDebugLogEntries(since: Date, limit: Int) async throws -> [DebugLogEntryDTO] { - if let error = stubbedDebugLogError { - throw error - } - return debugLogEntries - .filter { $0.timestamp >= since } - .sorted { $0.timestamp < $1.timestamp } - .prefix(limit) - .map { $0 } - } - - public func countDebugLogEntries() async throws -> Int { - if let error = stubbedDebugLogError { - throw error - } - return debugLogEntries.count - } - - public func pruneDebugLogEntries(keepCount: Int) async throws { - if let error = stubbedDebugLogError { - throw error - } - let sorted = debugLogEntries.sorted { $0.timestamp > $1.timestamp } - let toKeep = Set(sorted.prefix(keepCount).map { $0.id }) - debugLogEntries.removeAll { !toKeep.contains($0.id) } - } - - public func clearDebugLogEntries() async throws { - if let error = stubbedDebugLogError { - throw error - } - debugLogEntries.removeAll() - } - - // MARK: - Link Preview Cache - - public var linkPreviews: [String: LinkPreviewDataDTO] = [:] - - public func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { - linkPreviews[url] - } - - public func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws { - linkPreviews[dto.url] = dto - } - - // MARK: - Device Operations - - /// Devices keyed by radio ID - public var devices: [UUID: DeviceDTO] = [:] - public private(set) var updatedLastContactSyncs: [(radioID: UUID, timestamp: UInt32)] = [] - - public func fetchDevice(id: UUID) async throws -> DeviceDTO? { - devices.values.first { $0.id == id } - } - - public func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { - devices[radioID] - } - - public func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws { - updatedLastContactSyncs.append((radioID: radioID, timestamp: timestamp)) - if var device = devices[radioID] { - device.lastContactSync = timestamp - devices[radioID] = device - } - } - - public func saveDevice(_ dto: DeviceDTO) async throws { - devices[dto.radioID] = dto - } - - // MARK: - Room Session State - - public var remoteNodeSessions: [UUID: RemoteNodeSessionDTO] = [:] - public private(set) var markedDisconnectedSessionIDs: [UUID] = [] - public private(set) var markedConnectedSessionIDs: [UUID] = [] - public private(set) var updatedRoomActivitySessionIDs: [UUID] = [] - public private(set) var savedRemoteNodeSessions: [RemoteNodeSessionDTO] = [] - public private(set) var updatedSessionConnections: [(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel)] = [] - public private(set) var deletedRemoteNodeSessionIDs: [UUID] = [] - - public func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { - remoteNodeSessions[id] - } - - public func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { - remoteNodeSessions.values.first { $0.publicKey == publicKey } - } - - public func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { - remoteNodeSessions.values.first { $0.publicKey.prefix(6) == prefix } - } - - public func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { - remoteNodeSessions.values.filter { $0.radioID == radioID } - } - - public func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { - remoteNodeSessions.values.filter(\.isConnected) - } - - public func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws { - savedRemoteNodeSessions.append(dto) - remoteNodeSessions[dto.id] = dto - } - - public func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws { - updatedSessionConnections.append((id: id, isConnected: isConnected, permissionLevel: permissionLevel)) - if let session = remoteNodeSessions[id] { - remoteNodeSessions[id] = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - latitude: session.latitude, - longitude: session.longitude, - isConnected: isConnected, - permissionLevel: permissionLevel, - lastConnectedDate: session.lastConnectedDate, - lastBatteryMillivolts: session.lastBatteryMillivolts, - lastUptimeSeconds: session.lastUptimeSeconds, - lastNoiseFloor: session.lastNoiseFloor, - unreadCount: session.unreadCount, - notificationLevel: session.notificationLevel, - lastRxAirtimeSeconds: session.lastRxAirtimeSeconds, - neighborCount: session.neighborCount, - lastSyncTimestamp: session.lastSyncTimestamp, - lastMessageDate: session.lastMessageDate - ) - } - } - - public func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws { - let duplicateIDs = remoteNodeSessions.values - .filter { $0.publicKey == publicKey && $0.id != keepID } - .map(\.id) - for id in duplicateIDs { - remoteNodeSessions.removeValue(forKey: id) - } - } - - public func deleteRemoteNodeSession(id: UUID) async throws { - deletedRemoteNodeSessionIDs.append(id) - remoteNodeSessions.removeValue(forKey: id) - roomMessages = roomMessages.filter { $0.value.sessionID != id } - } - - public func markSessionDisconnected(_ sessionID: UUID) throws { - markedDisconnectedSessionIDs.append(sessionID) - } - - @discardableResult - public func markRoomSessionConnected(_ sessionID: UUID) throws -> Bool { - markedConnectedSessionIDs.append(sessionID) - return true - } - - public func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) throws { - updatedRoomActivitySessionIDs.append(sessionID) - } - - // MARK: - Room Message Operations - - public var roomMessages: [UUID: RoomMessageDTO] = [:] - public private(set) var savedRoomMessages: [RoomMessageDTO] = [] - public private(set) var updatedRoomMessageStatuses: [(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?)] = [] - - public func saveRoomMessage(_ dto: RoomMessageDTO) async throws { - savedRoomMessages.append(dto) - roomMessages[dto.id] = dto - } - - public func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { - roomMessages[id] - } - - public func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { - let filtered = roomMessages.values.filter { $0.sessionID == sessionID } - .sorted { $0.timestamp < $1.timestamp } - var result = Array(filtered) - if let offset { - result = Array(result.dropFirst(offset)) - } - if let limit { - result = Array(result.prefix(limit)) - } - return result - } - - public func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { - roomMessages.values.contains { $0.sessionID == sessionID && $0.deduplicationKey == deduplicationKey } - } - - public func updateRoomMessageStatus( - id: UUID, - status: MessageStatus, - ackCode: UInt32?, - roundTripTime: UInt32? - ) async throws { - updatedRoomMessageStatuses.append((id: id, status: status, ackCode: ackCode, roundTripTime: roundTripTime)) - if let message = roomMessages[id] { - roomMessages[id] = RoomMessageDTO( - id: message.id, - sessionID: message.sessionID, - authorKeyPrefix: message.authorKeyPrefix, - authorName: message.authorName, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - isFromSelf: message.isFromSelf, - status: status, - ackCode: ackCode ?? message.ackCode, - roundTripTime: roundTripTime ?? message.roundTripTime, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func updateRoomMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) async throws { - if let message = roomMessages[id] { - roomMessages[id] = RoomMessageDTO( - id: message.id, - sessionID: message.sessionID, - authorKeyPrefix: message.authorKeyPrefix, - authorName: message.authorName, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - isFromSelf: message.isFromSelf, - status: status, - ackCode: message.ackCode, - roundTripTime: message.roundTripTime, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts - ) - } - } - - public private(set) var incrementedRoomUnreadSessionIDs: [UUID] = [] - public private(set) var resetRoomUnreadSessionIDs: [UUID] = [] - - public func incrementRoomUnreadCount(_ sessionID: UUID) async throws { - incrementedRoomUnreadSessionIDs.append(sessionID) - } - - public func resetRoomUnreadCount(_ sessionID: UUID) async throws { - resetRoomUnreadSessionIDs.append(sessionID) - } - - // MARK: - Discovered Nodes - - public var discoveredNodes: [UUID: DiscoveredNodeDTO] = [:] - - public func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - if let existing = discoveredNodes.values.first(where: { $0.radioID == radioID && $0.publicKey == frame.publicKey }) { - let updated = DiscoveredNodeDTO( - id: existing.id, - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.type.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - outPathLength: frame.outPathLength, - outPath: frame.outPath, - inboundHopCount: existing.inboundHopCount, - inboundHopAdvertTimestamp: existing.inboundHopAdvertTimestamp - ) - discoveredNodes[existing.id] = updated - return (node: updated, isNew: false) - } - - let id = UUID() - let dto = DiscoveredNodeDTO( - id: id, - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.type.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - outPathLength: frame.outPathLength, - outPath: frame.outPath, - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - discoveredNodes[id] = dto - return (node: dto, isNew: true) - } - - /// Records every `setInboundHopCount` call for assertion and applies the same adopt rule as the - /// production store so tests can observe the resulting stored value via `discoveredNodes`. - public private(set) var inboundHopCountCalls: [(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?)] = [] - - public func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws { - inboundHopCountCalls.append((radioID: radioID, publicKey: publicKey, hopCount: hopCount, advertTimestamp: advertTimestamp)) - guard let existing = discoveredNodes.values.first(where: { - $0.radioID == radioID && $0.publicKey == publicKey - }) else { return } - guard let adopted = adoptInboundHop( - storedHops: existing.inboundHopCount, - storedTimestamp: existing.inboundHopAdvertTimestamp, - incomingHops: hopCount, - incomingTimestamp: advertTimestamp - ) else { return } - discoveredNodes[existing.id] = DiscoveredNodeDTO( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - lastHeard: existing.lastHeard, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - inboundHopCount: adopted.hopCount, - inboundHopAdvertTimestamp: adopted.advertTimestamp - ) - } - - public func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { - discoveredNodes.values.filter { $0.radioID == radioID } - } - - public func deleteDiscoveredNode(id: UUID) async throws { - discoveredNodes.removeValue(forKey: id) - } - - public func clearDiscoveredNodes(radioID: UUID) async throws { - let keysToRemove = discoveredNodes.values - .filter { $0.radioID == radioID } - .map(\.id) - for key in keysToRemove { - discoveredNodes.removeValue(forKey: key) - } - } - - public func fetchContactPublicKeys(radioID: UUID) async throws -> Set { - if let error = stubbedFetchContactError { - throw error - } - return Set(contacts.values.filter { $0.radioID == radioID }.map(\.publicKey)) - } - - // MARK: - Reactions - - public var reactions: [UUID: [ReactionDTO]] = [:] - public private(set) var savedReactions: [ReactionDTO] = [] - public private(set) var deletedReactionsForMessageIDs: [UUID] = [] - - public func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { - let messageReactions = reactions[messageID] ?? [] - return Array(messageReactions.sorted { $0.receivedAt > $1.receivedAt }.prefix(limit)) - } - - public func saveReaction(_ dto: ReactionDTO) async throws { - savedReactions.append(dto) - reactions[dto.messageID, default: []].append(dto) - } - - public func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { - let messageReactions = reactions[messageID] ?? [] - return messageReactions.contains { $0.senderName == senderName && $0.emoji == emoji } - } - - public func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws { - if let message = messages[messageID] { - messages[messageID] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts, - reactionSummary: summary - ) - } - } - - public func deleteReactionsForMessage(messageID: UUID) async throws { - deletedReactionsForMessageIDs.append(messageID) - reactions.removeValue(forKey: messageID) - } - - // MARK: - Node Status Snapshots - - public var nodeStatusSnapshots: [NodeStatusSnapshotDTO] = [] - - // swiftlint:disable:next function_parameter_count - public func saveNodeStatusSnapshot( - nodePublicKey: Data, - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16?, - postPushCount: UInt16? - ) async throws -> UUID { - let dto = NodeStatusSnapshotDTO( - nodePublicKey: nodePublicKey, - batteryMillivolts: batteryMillivolts, - lastSNR: lastSNR, - lastRSSI: lastRSSI, - noiseFloor: noiseFloor, - uptimeSeconds: uptimeSeconds, - rxAirtimeSeconds: rxAirtimeSeconds, - packetsSent: packetsSent, - packetsReceived: packetsReceived, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - nodeStatusSnapshots.append(dto) - return dto.id - } - - public func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { - nodeStatusSnapshots - .filter { $0.nodePublicKey == nodePublicKey } - .sorted { $0.timestamp > $1.timestamp } - .first - } - - public func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { - nodeStatusSnapshots - .filter { $0.nodePublicKey == nodePublicKey && (since == nil || $0.timestamp >= since!) } - .sorted { $0.timestamp < $1.timestamp } - } - - public func saveTelemetryOnlySnapshot( - nodePublicKey: Data, - telemetryEntries: [TelemetrySnapshotEntry] - ) async throws -> UUID { - let dto = NodeStatusSnapshotDTO( - nodePublicKey: nodePublicKey, - telemetryEntries: telemetryEntries - ) - nodeStatusSnapshots.append(dto) - return dto.id - } - - public func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws { - if let index = nodeStatusSnapshots.firstIndex(where: { $0.id == id }) { - let existing = nodeStatusSnapshots[index] - nodeStatusSnapshots[index] = NodeStatusSnapshotDTO( - id: existing.id, - timestamp: existing.timestamp, - nodePublicKey: existing.nodePublicKey, - batteryMillivolts: existing.batteryMillivolts, - lastSNR: existing.lastSNR, - lastRSSI: existing.lastRSSI, - noiseFloor: existing.noiseFloor, - uptimeSeconds: existing.uptimeSeconds, - rxAirtimeSeconds: existing.rxAirtimeSeconds, - packetsSent: existing.packetsSent, - packetsReceived: existing.packetsReceived, - receiveErrors: existing.receiveErrors, - postedCount: existing.postedCount, - postPushCount: existing.postPushCount, - neighborSnapshots: neighbors, - telemetryEntries: existing.telemetryEntries - ) - } - } - - public func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws { - if let index = nodeStatusSnapshots.firstIndex(where: { $0.id == id }) { - let existing = nodeStatusSnapshots[index] - nodeStatusSnapshots[index] = NodeStatusSnapshotDTO( - id: existing.id, - timestamp: existing.timestamp, - nodePublicKey: existing.nodePublicKey, - batteryMillivolts: existing.batteryMillivolts, - lastSNR: existing.lastSNR, - lastRSSI: existing.lastRSSI, - noiseFloor: existing.noiseFloor, - uptimeSeconds: existing.uptimeSeconds, - rxAirtimeSeconds: existing.rxAirtimeSeconds, - packetsSent: existing.packetsSent, - packetsReceived: existing.packetsReceived, - receiveErrors: existing.receiveErrors, - postedCount: existing.postedCount, - postPushCount: existing.postPushCount, - neighborSnapshots: existing.neighborSnapshots, - telemetryEntries: telemetry - ) - } - } - - /// Non-atomic in-memory stand-in for the concrete store's atomic override. - /// This double is never driven concurrently, so it enriches the latest - /// in-window row or inserts a new one without the single-turn guarantee. - public func recordNodeStatusSnapshot( - nodePublicKey: Data, - status: NodeStatusMetrics?, - telemetry: [TelemetrySnapshotEntry]?, - neighbors: [NeighborSnapshotEntry]? - ) async throws -> UUID { - if let latest = try await fetchLatestNodeStatusSnapshot(nodePublicKey: nodePublicKey), - latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { - if let telemetry { try await updateSnapshotTelemetry(id: latest.id, telemetry: telemetry) } - if let neighbors { try await updateSnapshotNeighbors(id: latest.id, neighbors: neighbors) } - return latest.id - } - - if let status { - return try await saveNodeStatusSnapshot( - nodePublicKey: nodePublicKey, - batteryMillivolts: status.batteryMillivolts, - lastSNR: status.lastSNR, - lastRSSI: status.lastRSSI, - noiseFloor: status.noiseFloor, - uptimeSeconds: status.uptimeSeconds, - rxAirtimeSeconds: status.rxAirtimeSeconds, - packetsSent: status.packetsSent, - packetsReceived: status.packetsReceived, - receiveErrors: status.receiveErrors, - postedCount: status.postedCount, - postPushCount: status.postPushCount - ) - } - - let id = try await saveTelemetryOnlySnapshot( - nodePublicKey: nodePublicKey, - telemetryEntries: telemetry ?? [] - ) - if let neighbors { try await updateSnapshotNeighbors(id: id, neighbors: neighbors) } - return id - } - - public func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws { - nodeStatusSnapshots.removeAll { $0.timestamp < date } - } - - // MARK: - Pending Sends - - public var pendingSends: [UUID: PendingSendDTO] = [:] - - public func upsertPendingSend(_ dto: PendingSendDTO) async throws { - pendingSends[dto.id] = dto - } - - public func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { - let nextSequence = (pendingSends.values.filter { $0.radioID == dto.radioID }.map(\.sequence).max() ?? 0) + 1 - let assigned = PendingSendDTO( - id: dto.id, - radioID: dto.radioID, - messageID: dto.messageID, - kind: dto.kind, - contactID: dto.contactID, - channelIndex: dto.channelIndex, - isResend: dto.isResend, - messageText: dto.messageText, - messageTimestamp: dto.messageTimestamp, - localNodeName: dto.localNodeName, - sequence: nextSequence, - enqueuedAt: dto.enqueuedAt, - attemptCount: dto.attemptCount - ) - pendingSends[dto.id] = assigned - return nextSequence - } - - public func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { - pendingSends.values.filter { $0.radioID == radioID }.sorted { $0.sequence < $1.sequence } - } - - public func deletePendingSend(id: UUID) async throws { - pendingSends.removeValue(forKey: id) - } - - public func deletePendingSendsForMessage(messageID: UUID) async throws { - let matchingIDs = pendingSends.values.filter { $0.messageID == messageID }.map(\.id) - for id in matchingIDs { - pendingSends.removeValue(forKey: id) - } - } - - public func hasPendingSend(messageID: UUID) async throws -> Bool { - pendingSends.values.contains { $0.messageID == messageID } - } - - @discardableResult - public func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { - guard let row = pendingSends.values.first(where: { $0.messageID == messageID }) else { return nil } - let nextCount = (row.attemptCount ?? 0) + 1 - pendingSends[row.id] = PendingSendDTO( - id: row.id, - radioID: row.radioID, - messageID: row.messageID, - kind: row.kind, - contactID: row.contactID, - channelIndex: row.channelIndex, - isResend: row.isResend, - messageText: row.messageText, - messageTimestamp: row.messageTimestamp, - localNodeName: row.localNodeName, - sequence: row.sequence, - enqueuedAt: row.enqueuedAt, - attemptCount: nextCount - ) - return nextCount - } - - // MARK: - Test Helpers - - /// Resets all storage and recorded invocations - public func reset() { - messages = [:] - contacts = [:] - channels = [:] - blockedChannelSenders = [:] - debugLogEntries = [] - mockRxLogEntries = [] - linkPreviews = [:] - pendingSends = [:] - roomMessages = [:] - discoveredNodes = [:] - reactions = [:] - nodeStatusSnapshots = [] - savedTracePaths = [:] - savedMessages = [] - savedContacts = [] - savedChannels = [] - savedReactions = [] - savedRoomMessages = [] - deletedContactIDs = [] - deletedChannelIDs = [] - deletedMessagesForContactIDs = [] - deletedReactionsForMessageIDs = [] - deletedMessagesForChannelCalls = [] - updatedMessageStatuses = [] - updatedMessageAcks = [] - updatedRoomMessageStatuses = [] - } + ) + } + + public func clearRxLogEntries(radioID: UUID) async throws { + mockRxLogEntries.removeAll { $0.radioID == radioID } + } + + public func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws { + let entries = mockRxLogEntries.filter { $0.radioID == radioID } + guard entries.count > keepCount + pruneThreshold else { return } + let keptIDs = Set(entries.sorted { $0.receivedAt > $1.receivedAt }.prefix(keepCount).map(\.id)) + mockRxLogEntries.removeAll { $0.radioID == radioID && !keptIDs.contains($0.id) } + } + + public func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + mockRxLogEntries.filter { $0.radioID == radioID && $0.transportCode != nil && $0.regionScope == nil } + } + + public func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { + mockRxLogEntries.filter { $0.radioID == radioID && $0.decryptStatus == status && $0.receivedAt >= since } + } + + public func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws { + updatedRxLogRegions.append(contentsOf: updates) + } + + public func batchUpdateRxLogDecryption( + _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] + ) async throws { + for update in updates { + guard let index = mockRxLogEntries.firstIndex(where: { $0.id == update.id }) else { continue } + mockRxLogEntries[index].channelIndex = update.channelIndex + mockRxLogEntries[index].channelName = update.channelName + mockRxLogEntries[index].senderTimestamp = update.senderTimestamp + } + } + + public func batchUpdateChannelMessageRegion( + radioID: UUID, + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) async throws { + updatedChannelMessageRegions.append(contentsOf: updates) + } + + public func batchUpdateDMMessageRegion( + radioID: UUID, + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) async throws { + updatedDMMessageRegions.append(contentsOf: updates) + } + + // MARK: - Saved Trace Paths + + public var savedTracePaths: [UUID: SavedTracePathDTO] = [:] + + public func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { + savedTracePaths.values.filter { $0.radioID == radioID }.sorted(by: { $0.createdDate > $1.createdDate }) + } + + public func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { + savedTracePaths[id] + } + + public func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { + let id = UUID() + let runs = initialRun.map { [$0] } ?? [] + let dto = SavedTracePathDTO( + id: id, + radioID: radioID, + name: name, + pathBytes: pathBytes, + hashSize: hashSize, + createdDate: Date(), + runs: runs + ) + savedTracePaths[id] = dto + return dto + } + + public func updateSavedTracePathName(id: UUID, name: String) async throws { + if let path = savedTracePaths[id] { + savedTracePaths[id] = SavedTracePathDTO( + id: path.id, + radioID: path.radioID, + name: name, + pathBytes: path.pathBytes, + hashSize: path.hashSize, + createdDate: path.createdDate, + runs: path.runs + ) + } + } + + public func deleteSavedTracePath(id: UUID) async throws { + savedTracePaths.removeValue(forKey: id) + } + + public func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws { + if let path = savedTracePaths[pathID] { + var runs = path.runs + runs.append(run) + savedTracePaths[pathID] = SavedTracePathDTO( + id: path.id, + radioID: path.radioID, + name: path.name, + pathBytes: path.pathBytes, + hashSize: path.hashSize, + createdDate: path.createdDate, + runs: runs + ) + } + } + + // MARK: - Heard Repeats + + public func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String) async throws -> MessageDTO? { + nil // Stub + } + + public func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws { + // Stub - no-op + } + + public func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { + [] // Stub + } + + public func deleteMessageRepeats(messageID: UUID) async throws { + // Stub - no-op + } + + public func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { + false // Stub + } + + public func incrementMessageHeardRepeats(id: UUID) async throws -> Int { + 0 // Stub + } + + public func incrementMessageSendCount(id: UUID) async throws -> Int { + if let message = messages[id] { + let newCount = message.sendCount + 1 + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + sendCount: newCount, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + return newCount + } + return 0 + } + + // MARK: - Debug Log Operations + + public func saveDebugLogEntries(_ entries: [DebugLogEntryDTO]) async throws { + if let error = stubbedDebugLogError { + throw error + } + debugLogEntries.append(contentsOf: entries) + } + + public func fetchDebugLogEntries(since: Date, limit: Int) async throws -> [DebugLogEntryDTO] { + if let error = stubbedDebugLogError { + throw error + } + return debugLogEntries + .filter { $0.timestamp >= since } + .sorted { $0.timestamp < $1.timestamp } + .prefix(limit) + .map(\.self) + } + + public func countDebugLogEntries() async throws -> Int { + if let error = stubbedDebugLogError { + throw error + } + return debugLogEntries.count + } + + public func pruneDebugLogEntries(keepCount: Int) async throws { + if let error = stubbedDebugLogError { + throw error + } + let sorted = debugLogEntries.sorted { $0.timestamp > $1.timestamp } + let toKeep = Set(sorted.prefix(keepCount).map(\.id)) + debugLogEntries.removeAll { !toKeep.contains($0.id) } + } + + public func clearDebugLogEntries() async throws { + if let error = stubbedDebugLogError { + throw error + } + debugLogEntries.removeAll() + } + + // MARK: - Link Preview Cache + + public var linkPreviews: [String: LinkPreviewDataDTO] = [:] + + public func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { + linkPreviews[url] + } + + public func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws { + linkPreviews[dto.url] = dto + } + + // MARK: - Device Operations + + /// Devices keyed by radio ID + public var devices: [UUID: DeviceDTO] = [:] + public private(set) var updatedLastContactSyncs: [(radioID: UUID, timestamp: UInt32)] = [] + + public func fetchDevice(id: UUID) async throws -> DeviceDTO? { + devices.values.first { $0.id == id } + } + + public func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { + devices[radioID] + } + + public func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws { + updatedLastContactSyncs.append((radioID: radioID, timestamp: timestamp)) + if var device = devices[radioID] { + device.lastContactSync = timestamp + devices[radioID] = device + } + } + + public func saveDevice(_ dto: DeviceDTO) async throws { + devices[dto.radioID] = dto + } + + // MARK: - Room Session State + + public var remoteNodeSessions: [UUID: RemoteNodeSessionDTO] = [:] + public private(set) var markedDisconnectedSessionIDs: [UUID] = [] + public private(set) var markedConnectedSessionIDs: [UUID] = [] + public private(set) var updatedRoomActivitySessionIDs: [UUID] = [] + public private(set) var savedRemoteNodeSessions: [RemoteNodeSessionDTO] = [] + public private(set) var updatedSessionConnections: [(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel)] = [] + public private(set) var deletedRemoteNodeSessionIDs: [UUID] = [] + + public func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { + remoteNodeSessions[id] + } + + public func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { + remoteNodeSessions.values.first { $0.publicKey == publicKey } + } + + public func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { + remoteNodeSessions.values.first { $0.publicKey.prefix(6) == prefix } + } + + public func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + remoteNodeSessions.values.filter { $0.radioID == radioID } + } + + public func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { + remoteNodeSessions.values.filter(\.isConnected) + } + + public func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws { + savedRemoteNodeSessions.append(dto) + remoteNodeSessions[dto.id] = dto + } + + public func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws { + updatedSessionConnections.append((id: id, isConnected: isConnected, permissionLevel: permissionLevel)) + if let session = remoteNodeSessions[id] { + remoteNodeSessions[id] = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + latitude: session.latitude, + longitude: session.longitude, + isConnected: isConnected, + permissionLevel: permissionLevel, + lastConnectedDate: session.lastConnectedDate, + lastBatteryMillivolts: session.lastBatteryMillivolts, + lastUptimeSeconds: session.lastUptimeSeconds, + lastNoiseFloor: session.lastNoiseFloor, + unreadCount: session.unreadCount, + notificationLevel: session.notificationLevel, + lastRxAirtimeSeconds: session.lastRxAirtimeSeconds, + neighborCount: session.neighborCount, + lastSyncTimestamp: session.lastSyncTimestamp, + lastMessageDate: session.lastMessageDate + ) + } + } + + public func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws { + let duplicateIDs = remoteNodeSessions.values + .filter { $0.publicKey == publicKey && $0.id != keepID } + .map(\.id) + for id in duplicateIDs { + remoteNodeSessions.removeValue(forKey: id) + } + } + + public func deleteRemoteNodeSession(id: UUID) async throws { + deletedRemoteNodeSessionIDs.append(id) + remoteNodeSessions.removeValue(forKey: id) + roomMessages = roomMessages.filter { $0.value.sessionID != id } + } + + public func markSessionDisconnected(_ sessionID: UUID) throws { + markedDisconnectedSessionIDs.append(sessionID) + } + + @discardableResult + public func markRoomSessionConnected(_ sessionID: UUID) throws -> Bool { + markedConnectedSessionIDs.append(sessionID) + return true + } + + public func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) throws { + updatedRoomActivitySessionIDs.append(sessionID) + } + + // MARK: - Room Message Operations + + public var roomMessages: [UUID: RoomMessageDTO] = [:] + public private(set) var savedRoomMessages: [RoomMessageDTO] = [] + public private(set) var updatedRoomMessageStatuses: [(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?)] = [] + + public func saveRoomMessage(_ dto: RoomMessageDTO) async throws { + savedRoomMessages.append(dto) + roomMessages[dto.id] = dto + } + + public func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { + roomMessages[id] + } + + public func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { + let filtered = roomMessages.values.filter { $0.sessionID == sessionID } + .sorted { $0.timestamp < $1.timestamp } + var result = Array(filtered) + if let offset { + result = Array(result.dropFirst(offset)) + } + if let limit { + result = Array(result.prefix(limit)) + } + return result + } + + public func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { + roomMessages.values.contains { $0.sessionID == sessionID && $0.deduplicationKey == deduplicationKey } + } + + public func updateRoomMessageStatus( + id: UUID, + status: MessageStatus, + ackCode: UInt32?, + roundTripTime: UInt32? + ) async throws { + updatedRoomMessageStatuses.append((id: id, status: status, ackCode: ackCode, roundTripTime: roundTripTime)) + if let message = roomMessages[id] { + roomMessages[id] = RoomMessageDTO( + id: message.id, + sessionID: message.sessionID, + authorKeyPrefix: message.authorKeyPrefix, + authorName: message.authorName, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + isFromSelf: message.isFromSelf, + status: status, + ackCode: ackCode ?? message.ackCode, + roundTripTime: roundTripTime ?? message.roundTripTime, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func updateRoomMessageRetryStatus( + id: UUID, + status: MessageStatus, + retryAttempt: Int, + maxRetryAttempts: Int + ) async throws { + if let message = roomMessages[id] { + roomMessages[id] = RoomMessageDTO( + id: message.id, + sessionID: message.sessionID, + authorKeyPrefix: message.authorKeyPrefix, + authorName: message.authorName, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + isFromSelf: message.isFromSelf, + status: status, + ackCode: message.ackCode, + roundTripTime: message.roundTripTime, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts + ) + } + } + + public private(set) var incrementedRoomUnreadSessionIDs: [UUID] = [] + public private(set) var resetRoomUnreadSessionIDs: [UUID] = [] + + public func incrementRoomUnreadCount(_ sessionID: UUID) async throws { + incrementedRoomUnreadSessionIDs.append(sessionID) + } + + public func resetRoomUnreadCount(_ sessionID: UUID) async throws { + resetRoomUnreadSessionIDs.append(sessionID) + } + + // MARK: - Discovered Nodes + + public var discoveredNodes: [UUID: DiscoveredNodeDTO] = [:] + + public func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { + if let existing = discoveredNodes.values.first(where: { $0.radioID == radioID && $0.publicKey == frame.publicKey }) { + let updated = DiscoveredNodeDTO( + id: existing.id, + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.type.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + outPathLength: frame.outPathLength, + outPath: frame.outPath, + inboundHopCount: existing.inboundHopCount, + inboundHopAdvertTimestamp: existing.inboundHopAdvertTimestamp + ) + discoveredNodes[existing.id] = updated + return (node: updated, isNew: false) + } + + let id = UUID() + let dto = DiscoveredNodeDTO( + id: id, + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.type.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + outPathLength: frame.outPathLength, + outPath: frame.outPath, + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + discoveredNodes[id] = dto + return (node: dto, isNew: true) + } + + /// Records every `setInboundHopCount` call for assertion and applies the same adopt rule as the + /// production store so tests can observe the resulting stored value via `discoveredNodes`. + public private(set) var inboundHopCountCalls: [(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?)] = [] + + public func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws { + inboundHopCountCalls.append((radioID: radioID, publicKey: publicKey, hopCount: hopCount, advertTimestamp: advertTimestamp)) + guard let existing = discoveredNodes.values.first(where: { + $0.radioID == radioID && $0.publicKey == publicKey + }) else { return } + guard let adopted = adoptInboundHop( + storedHops: existing.inboundHopCount, + storedTimestamp: existing.inboundHopAdvertTimestamp, + incomingHops: hopCount, + incomingTimestamp: advertTimestamp + ) else { return } + discoveredNodes[existing.id] = DiscoveredNodeDTO( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + lastHeard: existing.lastHeard, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + inboundHopCount: adopted.hopCount, + inboundHopAdvertTimestamp: adopted.advertTimestamp + ) + } + + public func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { + discoveredNodes.values.filter { $0.radioID == radioID } + } + + public func deleteDiscoveredNode(id: UUID) async throws { + discoveredNodes.removeValue(forKey: id) + } + + public func clearDiscoveredNodes(radioID: UUID) async throws { + let keysToRemove = discoveredNodes.values + .filter { $0.radioID == radioID } + .map(\.id) + for key in keysToRemove { + discoveredNodes.removeValue(forKey: key) + } + } + + public func fetchContactPublicKeys(radioID: UUID) async throws -> Set { + if let error = stubbedFetchContactError { + throw error + } + return Set(contacts.values.filter { $0.radioID == radioID }.map(\.publicKey)) + } + + // MARK: - Reactions + + public var reactions: [UUID: [ReactionDTO]] = [:] + public private(set) var savedReactions: [ReactionDTO] = [] + public private(set) var deletedReactionsForMessageIDs: [UUID] = [] + + public func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { + let messageReactions = reactions[messageID] ?? [] + return Array(messageReactions.sorted { $0.receivedAt > $1.receivedAt }.prefix(limit)) + } + + public func saveReaction(_ dto: ReactionDTO) async throws { + savedReactions.append(dto) + reactions[dto.messageID, default: []].append(dto) + } + + public func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { + let messageReactions = reactions[messageID] ?? [] + return messageReactions.contains { $0.senderName == senderName && $0.emoji == emoji } + } + + public func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws { + if let message = messages[messageID] { + messages[messageID] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts, + reactionSummary: summary + ) + } + } + + public func deleteReactionsForMessage(messageID: UUID) async throws { + deletedReactionsForMessageIDs.append(messageID) + reactions.removeValue(forKey: messageID) + } + + // MARK: - Node Status Snapshots + + public var nodeStatusSnapshots: [NodeStatusSnapshotDTO] = [] + + // swiftlint:disable:next function_parameter_count + public func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? + ) async throws -> UUID { + let dto = NodeStatusSnapshotDTO( + nodePublicKey: nodePublicKey, + batteryMillivolts: batteryMillivolts, + lastSNR: lastSNR, + lastRSSI: lastRSSI, + noiseFloor: noiseFloor, + uptimeSeconds: uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: packetsSent, + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + nodeStatusSnapshots.append(dto) + return dto.id + } + + public func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { + nodeStatusSnapshots + .filter { $0.nodePublicKey == nodePublicKey } + .sorted { $0.timestamp > $1.timestamp } + .first + } + + public func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { + nodeStatusSnapshots + .filter { $0.nodePublicKey == nodePublicKey && (since == nil || $0.timestamp >= since!) } + .sorted { $0.timestamp < $1.timestamp } + } + + public func saveTelemetryOnlySnapshot( + nodePublicKey: Data, + telemetryEntries: [TelemetrySnapshotEntry] + ) async throws -> UUID { + let dto = NodeStatusSnapshotDTO( + nodePublicKey: nodePublicKey, + telemetryEntries: telemetryEntries + ) + nodeStatusSnapshots.append(dto) + return dto.id + } + + public func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws { + if let index = nodeStatusSnapshots.firstIndex(where: { $0.id == id }) { + let existing = nodeStatusSnapshots[index] + nodeStatusSnapshots[index] = NodeStatusSnapshotDTO( + id: existing.id, + timestamp: existing.timestamp, + nodePublicKey: existing.nodePublicKey, + batteryMillivolts: existing.batteryMillivolts, + lastSNR: existing.lastSNR, + lastRSSI: existing.lastRSSI, + noiseFloor: existing.noiseFloor, + uptimeSeconds: existing.uptimeSeconds, + rxAirtimeSeconds: existing.rxAirtimeSeconds, + packetsSent: existing.packetsSent, + packetsReceived: existing.packetsReceived, + receiveErrors: existing.receiveErrors, + postedCount: existing.postedCount, + postPushCount: existing.postPushCount, + neighborSnapshots: neighbors, + telemetryEntries: existing.telemetryEntries + ) + } + } + + public func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws { + if let index = nodeStatusSnapshots.firstIndex(where: { $0.id == id }) { + let existing = nodeStatusSnapshots[index] + nodeStatusSnapshots[index] = NodeStatusSnapshotDTO( + id: existing.id, + timestamp: existing.timestamp, + nodePublicKey: existing.nodePublicKey, + batteryMillivolts: existing.batteryMillivolts, + lastSNR: existing.lastSNR, + lastRSSI: existing.lastRSSI, + noiseFloor: existing.noiseFloor, + uptimeSeconds: existing.uptimeSeconds, + rxAirtimeSeconds: existing.rxAirtimeSeconds, + packetsSent: existing.packetsSent, + packetsReceived: existing.packetsReceived, + receiveErrors: existing.receiveErrors, + postedCount: existing.postedCount, + postPushCount: existing.postPushCount, + neighborSnapshots: existing.neighborSnapshots, + telemetryEntries: telemetry + ) + } + } + + /// Non-atomic in-memory stand-in for the concrete store's atomic override. + /// This double is never driven concurrently, so it enriches the latest + /// in-window row or inserts a new one without the single-turn guarantee. + public func recordNodeStatusSnapshot( + nodePublicKey: Data, + status: NodeStatusMetrics?, + telemetry: [TelemetrySnapshotEntry]?, + neighbors: [NeighborSnapshotEntry]? + ) async throws -> UUID { + if let latest = try await fetchLatestNodeStatusSnapshot(nodePublicKey: nodePublicKey), + latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { + if let telemetry { try await updateSnapshotTelemetry(id: latest.id, telemetry: telemetry) } + if let neighbors { try await updateSnapshotNeighbors(id: latest.id, neighbors: neighbors) } + return latest.id + } + + if let status { + return try await saveNodeStatusSnapshot( + nodePublicKey: nodePublicKey, + batteryMillivolts: status.batteryMillivolts, + lastSNR: status.lastSNR, + lastRSSI: status.lastRSSI, + noiseFloor: status.noiseFloor, + uptimeSeconds: status.uptimeSeconds, + rxAirtimeSeconds: status.rxAirtimeSeconds, + packetsSent: status.packetsSent, + packetsReceived: status.packetsReceived, + receiveErrors: status.receiveErrors, + postedCount: status.postedCount, + postPushCount: status.postPushCount + ) + } + + let id = try await saveTelemetryOnlySnapshot( + nodePublicKey: nodePublicKey, + telemetryEntries: telemetry ?? [] + ) + if let neighbors { try await updateSnapshotNeighbors(id: id, neighbors: neighbors) } + return id + } + + public func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws { + nodeStatusSnapshots.removeAll { $0.timestamp < date } + } + + // MARK: - Pending Sends + + public var pendingSends: [UUID: PendingSendDTO] = [:] + + public func upsertPendingSend(_ dto: PendingSendDTO) async throws { + pendingSends[dto.id] = dto + } + + public func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { + let nextSequence = (pendingSends.values.filter { $0.radioID == dto.radioID }.map(\.sequence).max() ?? 0) + 1 + let assigned = PendingSendDTO( + id: dto.id, + radioID: dto.radioID, + messageID: dto.messageID, + kind: dto.kind, + contactID: dto.contactID, + channelIndex: dto.channelIndex, + isResend: dto.isResend, + messageText: dto.messageText, + messageTimestamp: dto.messageTimestamp, + localNodeName: dto.localNodeName, + sequence: nextSequence, + enqueuedAt: dto.enqueuedAt, + attemptCount: dto.attemptCount + ) + pendingSends[dto.id] = assigned + return nextSequence + } + + public func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { + pendingSends.values.filter { $0.radioID == radioID }.sorted { $0.sequence < $1.sequence } + } + + public func deletePendingSend(id: UUID) async throws { + pendingSends.removeValue(forKey: id) + } + + public func deletePendingSendsForMessage(messageID: UUID) async throws { + let matchingIDs = pendingSends.values.filter { $0.messageID == messageID }.map(\.id) + for id in matchingIDs { + pendingSends.removeValue(forKey: id) + } + } + + public func hasPendingSend(messageID: UUID) async throws -> Bool { + pendingSends.values.contains { $0.messageID == messageID } + } + + @discardableResult + public func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { + guard let row = pendingSends.values.first(where: { $0.messageID == messageID }) else { return nil } + let nextCount = (row.attemptCount ?? 0) + 1 + pendingSends[row.id] = PendingSendDTO( + id: row.id, + radioID: row.radioID, + messageID: row.messageID, + kind: row.kind, + contactID: row.contactID, + channelIndex: row.channelIndex, + isResend: row.isResend, + messageText: row.messageText, + messageTimestamp: row.messageTimestamp, + localNodeName: row.localNodeName, + sequence: row.sequence, + enqueuedAt: row.enqueuedAt, + attemptCount: nextCount + ) + return nextCount + } + + // MARK: - Test Helpers + + /// Resets all storage and recorded invocations + public func reset() { + messages = [:] + contacts = [:] + channels = [:] + blockedChannelSenders = [:] + debugLogEntries = [] + mockRxLogEntries = [] + linkPreviews = [:] + pendingSends = [:] + roomMessages = [:] + discoveredNodes = [:] + reactions = [:] + nodeStatusSnapshots = [] + savedTracePaths = [:] + savedMessages = [] + savedContacts = [] + savedChannels = [] + savedReactions = [] + savedRoomMessages = [] + deletedContactIDs = [] + deletedChannelIDs = [] + deletedMessagesForContactIDs = [] + deletedReactionsForMessageIDs = [] + deletedMessagesForChannelCalls = [] + updatedMessageStatuses = [] + updatedMessageAcks = [] + updatedRoomMessageStatuses = [] + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift index c2debefc..caed527d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift @@ -1,128 +1,127 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("ChannelDTO floodScope propagation through with(...) methods") struct ChannelRegionScopeTests { - - // MARK: - Helpers - - private func makeDTO(floodScope: ChannelFloodScope = .inherit) -> ChannelDTO { - ChannelDTO( - id: UUID(), - radioID: UUID(), - index: 1, - name: "Test", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: floodScope - ) - } - - // MARK: - Init Tests - - @Test("Default floodScope is .inherit") - func defaultFloodScopeIsInherit() { - let dto = makeDTO() - #expect(dto.floodScope == .inherit) - } - - @Test(".region(name) preserved on init") - func regionPreserved() { - let dto = makeDTO(floodScope: .region("Europe")) - #expect(dto.floodScope == .region("Europe")) - } - - // MARK: - with(notificationLevel:) - - @Test("with(notificationLevel:) preserves .region(name)") - func withNotificationLevelPreservesRegion() { - let dto = makeDTO(floodScope: .region("UK")) - let updated = dto.with(notificationLevel: .muted) - - #expect(updated.floodScope == .region("UK")) - #expect(updated.notificationLevel == .muted) - } - - @Test("with(notificationLevel:) preserves .allRegions") - func withNotificationLevelPreservesAllRegions() { - let dto = makeDTO(floodScope: .allRegions) - let updated = dto.with(notificationLevel: .mentionsOnly) - - #expect(updated.floodScope == .allRegions) - } - - @Test("with(notificationLevel:) preserves .inherit") - func withNotificationLevelPreservesInherit() { - let dto = makeDTO() - let updated = dto.with(notificationLevel: .mentionsOnly) - - #expect(updated.floodScope == .inherit) - } - - // MARK: - with(isFavorite:) - - @Test("with(isFavorite:) preserves .region(name)") - func withIsFavoritePreservesRegion() { - let dto = makeDTO(floodScope: .region("France")) - let updated = dto.with(isFavorite: true) - - #expect(updated.floodScope == .region("France")) - #expect(updated.isFavorite == true) - } - - @Test("with(isFavorite:) preserves .inherit") - func withIsFavoritePreservesInherit() { - let dto = makeDTO() - let updated = dto.with(isFavorite: true) - - #expect(updated.floodScope == .inherit) - } - - // MARK: - with(floodScope:) - - @Test("with(floodScope:) updates from .region to .region") - func withFloodScopeUpdatesBetweenRegions() { - let dto = makeDTO(floodScope: .region("Europe")) - let updated = dto.with(floodScope: .region("UK")) - - #expect(updated.floodScope == .region("UK")) - } - - @Test("with(floodScope: .inherit) clears region name in storage") - func withFloodScopeInheritClears() { - let dto = makeDTO(floodScope: .region("Europe")) - let updated = dto.with(floodScope: .inherit) - - #expect(updated.floodScope == .inherit) - #expect(updated.regionScope == nil) - } - - @Test("with(floodScope:) sets specific region from inherit") - func withFloodScopeSetsFromInherit() { - let dto = makeDTO() - let updated = dto.with(floodScope: .region("Asia")) - - #expect(updated.floodScope == .region("Asia")) - } - - @Test("with(floodScope:) preserves all other fields") - func withFloodScopePreservesOtherFields() { - let dto = makeDTO(floodScope: .region("Europe")) - let updated = dto.with(floodScope: .region("UK")) - - #expect(updated.id == dto.id) - #expect(updated.radioID == dto.radioID) - #expect(updated.index == dto.index) - #expect(updated.name == dto.name) - #expect(updated.secret == dto.secret) - #expect(updated.isEnabled == dto.isEnabled) - #expect(updated.lastMessageDate == dto.lastMessageDate) - #expect(updated.unreadCount == dto.unreadCount) - #expect(updated.unreadMentionCount == dto.unreadMentionCount) - #expect(updated.notificationLevel == dto.notificationLevel) - #expect(updated.isFavorite == dto.isFavorite) - } + // MARK: - Helpers + + private func makeDTO(floodScope: ChannelFloodScope = .inherit) -> ChannelDTO { + ChannelDTO( + id: UUID(), + radioID: UUID(), + index: 1, + name: "Test", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: floodScope + ) + } + + // MARK: - Init Tests + + @Test + func `Default floodScope is .inherit`() { + let dto = makeDTO() + #expect(dto.floodScope == .inherit) + } + + @Test + func `.region(name) preserved on init`() { + let dto = makeDTO(floodScope: .region("Europe")) + #expect(dto.floodScope == .region("Europe")) + } + + // MARK: - with(notificationLevel:) + + @Test + func `with(notificationLevel:) preserves .region(name)`() { + let dto = makeDTO(floodScope: .region("UK")) + let updated = dto.with(notificationLevel: .muted) + + #expect(updated.floodScope == .region("UK")) + #expect(updated.notificationLevel == .muted) + } + + @Test + func `with(notificationLevel:) preserves .allRegions`() { + let dto = makeDTO(floodScope: .allRegions) + let updated = dto.with(notificationLevel: .mentionsOnly) + + #expect(updated.floodScope == .allRegions) + } + + @Test + func `with(notificationLevel:) preserves .inherit`() { + let dto = makeDTO() + let updated = dto.with(notificationLevel: .mentionsOnly) + + #expect(updated.floodScope == .inherit) + } + + // MARK: - with(isFavorite:) + + @Test + func `with(isFavorite:) preserves .region(name)`() { + let dto = makeDTO(floodScope: .region("France")) + let updated = dto.with(isFavorite: true) + + #expect(updated.floodScope == .region("France")) + #expect(updated.isFavorite == true) + } + + @Test + func `with(isFavorite:) preserves .inherit`() { + let dto = makeDTO() + let updated = dto.with(isFavorite: true) + + #expect(updated.floodScope == .inherit) + } + + // MARK: - with(floodScope:) + + @Test + func `with(floodScope:) updates from .region to .region`() { + let dto = makeDTO(floodScope: .region("Europe")) + let updated = dto.with(floodScope: .region("UK")) + + #expect(updated.floodScope == .region("UK")) + } + + @Test + func `with(floodScope: .inherit) clears region name in storage`() { + let dto = makeDTO(floodScope: .region("Europe")) + let updated = dto.with(floodScope: .inherit) + + #expect(updated.floodScope == .inherit) + #expect(updated.regionScope == nil) + } + + @Test + func `with(floodScope:) sets specific region from inherit`() { + let dto = makeDTO() + let updated = dto.with(floodScope: .region("Asia")) + + #expect(updated.floodScope == .region("Asia")) + } + + @Test + func `with(floodScope:) preserves all other fields`() { + let dto = makeDTO(floodScope: .region("Europe")) + let updated = dto.with(floodScope: .region("UK")) + + #expect(updated.id == dto.id) + #expect(updated.radioID == dto.radioID) + #expect(updated.index == dto.index) + #expect(updated.name == dto.name) + #expect(updated.secret == dto.secret) + #expect(updated.isEnabled == dto.isEnabled) + #expect(updated.lastMessageDate == dto.lastMessageDate) + #expect(updated.unreadCount == dto.unreadCount) + #expect(updated.unreadMentionCount == dto.unreadMentionCount) + #expect(updated.notificationLevel == dto.notificationLevel) + #expect(updated.isFavorite == dto.isFavorite) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Models/ConnectionMethodTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/ConnectionMethodTests.swift index 688f4f42..1df140d1 100644 --- a/MC1Services/Tests/MC1ServicesTests/Models/ConnectionMethodTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Models/ConnectionMethodTests.swift @@ -1,99 +1,66 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("ConnectionMethod Tests") struct ConnectionMethodTests { - - @Test("ConnectionMethod decodes the frozen synthesized wire shape (guards against silent rename)") - func decodesFrozenLegacyWireShape() throws { - let decoder = JSONDecoder() - - let wifiJSON = #"{"wifi":{"displayName":"Field Radio","host":"192.168.1.50","port":5000}}"# - let wifi = try decoder.decode(ConnectionMethod.self, from: Data(wifiJSON.utf8)) - guard case let .wifi(host, port, displayName) = wifi else { - Issue.record("expected .wifi, got \(wifi)"); return - } - #expect(host == "192.168.1.50") - #expect(port == 5000) - #expect(displayName == "Field Radio") - - let bleJSON = #"{"bluetooth":{"displayName":null,"peripheralUUID":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F"}}"# - let ble = try decoder.decode(ConnectionMethod.self, from: Data(bleJSON.utf8)) - guard case let .bluetooth(uuid, name) = ble else { - Issue.record("expected .bluetooth, got \(ble)"); return - } - #expect(uuid == UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")) - #expect(name == nil) - } - - @Test("ConnectionMethod round-trips through the backup encoder") - func roundTripsThroughBackupEncoder() throws { - let methods: [ConnectionMethod] = [ - .wifi(host: "10.0.0.2", port: 4403, displayName: nil), - .bluetooth(peripheralUUID: UUID(), displayName: "Pocket") - ] - let data = try makeBackupJSONEncoder().encode(methods) - let decoded = try makeBackupJSONDecoder().decode([ConnectionMethod].self, from: data) - #expect(decoded == methods) - } - - @Test("ConnectionMethod ENCODES to the frozen synthesized wire shape (pins the write shape)") - func encodesToFrozenWireShape() throws { - // Decode-of-frozen-input and the symmetric round-trip do not pin the encoder's output: - // a synthesized-Codable change that altered encode and decode in lockstep would pass - // both while still bricking files written by the current app. This asserts the produced - // JSON. makeBackupJSONEncoder() uses .sortedKeys, so the literal is key-stable. - let wifi: ConnectionMethod = .wifi(host: "192.168.1.50", port: 5000, displayName: "Field Radio") - let json = String(decoding: try makeBackupJSONEncoder().encode(wifi), as: UTF8.self) - #expect(json == #"{"wifi":{"displayName":"Field Radio","host":"192.168.1.50","port":5000}}"#) - } - - @Test("Bluetooth method has correct identifier") - func bluetoothIdentifier() { - let uuid = UUID() - let method = ConnectionMethod.bluetooth(peripheralUUID: uuid, displayName: "My Device") - - #expect(method.id == "ble:\(uuid.uuidString)") - } - - @Test("WiFi method has correct identifier") - func wifiIdentifier() { - let method = ConnectionMethod.wifi(host: "192.168.1.50", port: 5000, displayName: "Home") - - #expect(method.id == "wifi:192.168.1.50:5000") + @Test + func `ConnectionMethod decodes the frozen synthesized wire shape (guards against silent rename)`() throws { + let decoder = JSONDecoder() + + let wifiJSON = #"{"wifi":{"displayName":"Field Radio","host":"192.168.1.50","port":5000}}"# + let wifi = try decoder.decode(ConnectionMethod.self, from: Data(wifiJSON.utf8)) + guard case let .wifi(host, port, displayName) = wifi else { + Issue.record("expected .wifi, got \(wifi)"); return } - - @Test("Codable round-trip for Bluetooth") - func bluetoothCodable() throws { - let uuid = UUID() - let original = ConnectionMethod.bluetooth(peripheralUUID: uuid, displayName: "Test") - - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(ConnectionMethod.self, from: encoded) - - #expect(decoded.id == original.id) - } - - @Test("Codable round-trip for WiFi") - func wifiCodable() throws { - let original = ConnectionMethod.wifi(host: "10.0.0.1", port: 8080, displayName: nil) - - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(ConnectionMethod.self, from: encoded) - - #expect(decoded.id == original.id) - } - - @Test("Display name returns custom name when set") - func displayNameCustom() { - let method = ConnectionMethod.wifi(host: "192.168.1.1", port: 5000, displayName: "Office Router") - #expect(method.displayName == "Office Router") - } - - @Test("Display name returns nil when not set") - func displayNameNil() { - let method = ConnectionMethod.wifi(host: "192.168.1.1", port: 5000, displayName: nil) - #expect(method.displayName == nil) + #expect(host == "192.168.1.50") + #expect(port == 5000) + #expect(displayName == "Field Radio") + + let bleJSON = #"{"bluetooth":{"displayName":null,"peripheralUUID":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F"}}"# + let ble = try decoder.decode(ConnectionMethod.self, from: Data(bleJSON.utf8)) + guard case let .bluetooth(uuid, name) = ble else { + Issue.record("expected .bluetooth, got \(ble)"); return } + #expect(uuid == UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")) + #expect(name == nil) + } + + @Test + func `ConnectionMethod ENCODES to the frozen synthesized wire shape (pins the write shape)`() throws { + // Decode-of-frozen-input and the symmetric round-trip do not pin the encoder's output: + // a synthesized-Codable change that altered encode and decode in lockstep would pass + // both while still bricking files written by the current app. This asserts the produced + // JSON. makeBackupJSONEncoder() uses .sortedKeys, so the literal is key-stable. + let wifi: ConnectionMethod = .wifi(host: "192.168.1.50", port: 5000, displayName: "Field Radio") + let json = try String(decoding: makeBackupJSONEncoder().encode(wifi), as: UTF8.self) + #expect(json == #"{"wifi":{"displayName":"Field Radio","host":"192.168.1.50","port":5000}}"#) + } + + @Test + func `Bluetooth method has correct identifier`() { + let uuid = UUID() + let method = ConnectionMethod.bluetooth(peripheralUUID: uuid, displayName: "My Device") + + #expect(method.id == "ble:\(uuid.uuidString)") + } + + @Test + func `WiFi method has correct identifier`() { + let method = ConnectionMethod.wifi(host: "192.168.1.50", port: 5000, displayName: "Home") + + #expect(method.id == "wifi:192.168.1.50:5000") + } + + @Test + func `Display name returns custom name when set`() { + let method = ConnectionMethod.wifi(host: "192.168.1.1", port: 5000, displayName: "Office Router") + #expect(method.displayName == "Office Router") + } + + @Test + func `Display name returns nil when not set`() { + let method = ConnectionMethod.wifi(host: "192.168.1.1", port: 5000, displayName: nil) + #expect(method.displayName == nil) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Models/DeviceDTOClientRepeatTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/DeviceDTOClientRepeatTests.swift index 284042ac..024ec2cd 100644 --- a/MC1Services/Tests/MC1ServicesTests/Models/DeviceDTOClientRepeatTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Models/DeviceDTOClientRepeatTests.swift @@ -1,261 +1,260 @@ import Foundation -import Testing -@testable import MeshCore @testable import MC1Services +@testable import MeshCore +import Testing @Suite("DeviceDTO Client Repeat Tests") struct DeviceDTOClientRepeatTests { - - // MARK: - Test Data - - private static let testSelfInfo = SelfInfo( - advertisementType: 1, - txPower: 20, - maxTxPower: 30, - publicKey: Data(repeating: 0xAB, count: 32), - latitude: 37.7749, - longitude: -122.4194, - multiAcks: 2, - advertisementLocationPolicy: 1, - telemetryModeEnvironment: 0, - telemetryModeLocation: 0, - telemetryModeBase: 2, - manualAddContacts: false, - radioFrequency: 906.875, - radioBandwidth: 250.0, - radioSpreadingFactor: 11, - radioCodingRate: 8, - name: "UpdatedNode" - ) - - private func makeDevice( - firmwareVersion: UInt8 = 9, - frequency: UInt32 = 915_000, - bandwidth: UInt32 = 250_000, - spreadingFactor: UInt8 = 8, - codingRate: UInt8 = 5, - clientRepeat: Bool = false, - preRepeatFrequency: UInt32? = nil, - preRepeatBandwidth: UInt32? = nil, - preRepeatSpreadingFactor: UInt8? = nil, - preRepeatCodingRate: UInt8? = nil - ) -> DeviceDTO { - DeviceDTO.testDevice( - firmwareVersion: firmwareVersion, - frequency: frequency, - bandwidth: bandwidth, - spreadingFactor: spreadingFactor, - codingRate: codingRate - ).copy { - $0.clientRepeat = clientRepeat - $0.preRepeatFrequency = preRepeatFrequency - $0.preRepeatBandwidth = preRepeatBandwidth - $0.preRepeatSpreadingFactor = preRepeatSpreadingFactor - $0.preRepeatCodingRate = preRepeatCodingRate - } + // MARK: - Test Data + + private static let testSelfInfo = SelfInfo( + advertisementType: 1, + txPower: 20, + maxTxPower: 30, + publicKey: Data(repeating: 0xAB, count: 32), + latitude: 37.7749, + longitude: -122.4194, + multiAcks: 2, + advertisementLocationPolicy: 1, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 2, + manualAddContacts: false, + radioFrequency: 906.875, + radioBandwidth: 250.0, + radioSpreadingFactor: 11, + radioCodingRate: 8, + name: "UpdatedNode" + ) + + private func makeDevice( + firmwareVersion: UInt8 = 9, + frequency: UInt32 = 915_000, + bandwidth: UInt32 = 250_000, + spreadingFactor: UInt8 = 8, + codingRate: UInt8 = 5, + clientRepeat: Bool = false, + preRepeatFrequency: UInt32? = nil, + preRepeatBandwidth: UInt32? = nil, + preRepeatSpreadingFactor: UInt8? = nil, + preRepeatCodingRate: UInt8? = nil + ) -> DeviceDTO { + DeviceDTO.testDevice( + firmwareVersion: firmwareVersion, + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate + ).copy { + $0.clientRepeat = clientRepeat + $0.preRepeatFrequency = preRepeatFrequency + $0.preRepeatBandwidth = preRepeatBandwidth + $0.preRepeatSpreadingFactor = preRepeatSpreadingFactor + $0.preRepeatCodingRate = preRepeatCodingRate } - - @Test("test helper defaults radioID to id") - func testHelper_defaultsRadioIDToID() { - let id = UUID() - let device = DeviceDTO.testDevice(id: id) - - #expect(device.id == id) - #expect(device.radioID == id) + } + + @Test + func `helper defaults radioID to id`() { + let id = UUID() + let device = DeviceDTO.testDevice(id: id) + + #expect(device.id == id) + #expect(device.radioID == id) + } + + // MARK: - supportsClientRepeat + + @Test + func `supportsClientRepeat returns true for firmware v9`() { + let device = makeDevice(firmwareVersion: 9) + #expect(device.supportsClientRepeat == true) + } + + @Test + func `supportsClientRepeat returns false for firmware v8`() { + let device = makeDevice(firmwareVersion: 8) + #expect(device.supportsClientRepeat == false) + } + + @Test + func `supportsClientRepeat returns true for firmware v10`() { + let device = makeDevice(firmwareVersion: 10) + #expect(device.supportsClientRepeat == true) + } + + // MARK: - advert location policy + + @Test + func `sharesLocationPublicly is false for policy none`() { + let device = makeDevice().copy { $0.advertLocationPolicy = 0 } + #expect(device.sharesLocationPublicly == false) + #expect(device.advertLocationPolicyMode == .none) + } + + @Test + func `sharesLocationPublicly is true for policy share`() { + let device = makeDevice().copy { $0.advertLocationPolicy = 1 } + #expect(device.sharesLocationPublicly == true) + #expect(device.advertLocationPolicyMode == .share) + } + + @Test + func `sharesLocationPublicly is true for policy prefs`() { + let device = makeDevice().copy { $0.advertLocationPolicy = 2 } + #expect(device.sharesLocationPublicly == true) + #expect(device.advertLocationPolicyMode == .prefs) + } + + // MARK: - copy + + @Test + func `copy mutates only specified fields`() { + let device = makeDevice(clientRepeat: false, preRepeatFrequency: 906_875) + let updated = device.copy { $0.clientRepeat = true } + + #expect(updated.clientRepeat == true) + // Other fields unchanged + #expect(updated.nodeName == device.nodeName) + #expect(updated.frequency == device.frequency) + #expect(updated.preRepeatFrequency == 906_875) + } + + @Test + func `copy can set optional fields to nil`() { + let device = makeDevice(preRepeatFrequency: 915_000) + let updated = device.copy { $0.preRepeatFrequency = nil } + + #expect(updated.preRepeatFrequency == nil) + } + + @Test + func `copy can update multiple fields at once`() { + let device = makeDevice(clientRepeat: false) + let updated = device.copy { + $0.clientRepeat = true + $0.autoAddConfig = 0x0F } - // MARK: - supportsClientRepeat - - @Test("supportsClientRepeat returns true for firmware v9") - func supportsClientRepeat_v9_returnsTrue() { - let device = makeDevice(firmwareVersion: 9) - #expect(device.supportsClientRepeat == true) - } + #expect(updated.clientRepeat == true) + #expect(updated.autoAddConfig == 0x0F) + #expect(updated.nodeName == device.nodeName) + } - @Test("supportsClientRepeat returns false for firmware v8") - func supportsClientRepeat_v8_returnsFalse() { - let device = makeDevice(firmwareVersion: 8) - #expect(device.supportsClientRepeat == false) - } - - @Test("supportsClientRepeat returns true for firmware v10") - func supportsClientRepeat_v10_returnsTrue() { - let device = makeDevice(firmwareVersion: 10) - #expect(device.supportsClientRepeat == true) - } + // MARK: - savingPreRepeatSettings - // MARK: - advert location policy - - @Test("sharesLocationPublicly is false for policy none") - func sharesLocationPublicly_none() { - let device = makeDevice().copy { $0.advertLocationPolicy = 0 } - #expect(device.sharesLocationPublicly == false) - #expect(device.advertLocationPolicyMode == .none) - } - - @Test("sharesLocationPublicly is true for policy share") - func sharesLocationPublicly_share() { - let device = makeDevice().copy { $0.advertLocationPolicy = 1 } - #expect(device.sharesLocationPublicly == true) - #expect(device.advertLocationPolicyMode == .share) - } - - @Test("sharesLocationPublicly is true for policy prefs") - func sharesLocationPublicly_prefs() { - let device = makeDevice().copy { $0.advertLocationPolicy = 2 } - #expect(device.sharesLocationPublicly == true) - #expect(device.advertLocationPolicyMode == .prefs) - } - - // MARK: - copy - - @Test("copy mutates only specified fields") - func copy_mutatesOnlySpecifiedFields() { - let device = makeDevice(clientRepeat: false, preRepeatFrequency: 906_875) - let updated = device.copy { $0.clientRepeat = true } - - #expect(updated.clientRepeat == true) - // Other fields unchanged - #expect(updated.nodeName == device.nodeName) - #expect(updated.frequency == device.frequency) - #expect(updated.preRepeatFrequency == 906_875) - } - - @Test("copy can set optional fields to nil") - func copy_canSetOptionalToNil() { - let device = makeDevice(preRepeatFrequency: 915_000) - let updated = device.copy { $0.preRepeatFrequency = nil } - - #expect(updated.preRepeatFrequency == nil) - } - - @Test("copy can update multiple fields at once") - func copy_updatesMultipleFields() { - let device = makeDevice(clientRepeat: false) - let updated = device.copy { - $0.clientRepeat = true - $0.autoAddConfig = 0x0F - } - - #expect(updated.clientRepeat == true) - #expect(updated.autoAddConfig == 0x0F) - #expect(updated.nodeName == device.nodeName) - } - - // MARK: - savingPreRepeatSettings - - @Test("savingPreRepeatSettings copies current radio params") - func savingPreRepeatSettings_copiesCurrentRadioParams() { - let device = makeDevice( - frequency: 915_000, - bandwidth: 250_000, - spreadingFactor: 8, - codingRate: 5 - ) - let saved = device.savingPreRepeatSettings() - - #expect(saved.preRepeatFrequency == 915_000) - #expect(saved.preRepeatBandwidth == 250_000) - #expect(saved.preRepeatSpreadingFactor == 8) - #expect(saved.preRepeatCodingRate == 5) - } - - @Test("savingPreRepeatSettings preserves other fields") - func savingPreRepeatSettings_preservesOtherFields() { - let device = makeDevice(clientRepeat: true) - let saved = device.savingPreRepeatSettings() - - #expect(saved.clientRepeat == true) - #expect(saved.nodeName == device.nodeName) - #expect(saved.firmwareVersion == device.firmwareVersion) - } - - // MARK: - clearingPreRepeatSettings - - @Test("clearingPreRepeatSettings sets all preRepeat fields to nil") - func clearingPreRepeatSettings_setsAllToNil() { - let device = makeDevice( - preRepeatFrequency: 915_000, - preRepeatBandwidth: 250_000, - preRepeatSpreadingFactor: 8, - preRepeatCodingRate: 5 - ) - let cleared = device.clearingPreRepeatSettings() - - #expect(cleared.preRepeatFrequency == nil) - #expect(cleared.preRepeatBandwidth == nil) - #expect(cleared.preRepeatSpreadingFactor == nil) - #expect(cleared.preRepeatCodingRate == nil) - #expect(cleared.hasPreRepeatSettings == false) - } - - // MARK: - hasPreRepeatSettings - - @Test("hasPreRepeatSettings requires all four fields") - func hasPreRepeatSettings_requiresAllFourFields() { - // Only frequency set - let partial = makeDevice(preRepeatFrequency: 915_000) - #expect(partial.hasPreRepeatSettings == false) - - // All four set - let complete = makeDevice( - preRepeatFrequency: 915_000, - preRepeatBandwidth: 250_000, - preRepeatSpreadingFactor: 8, - preRepeatCodingRate: 5 - ) - #expect(complete.hasPreRepeatSettings == true) - } - - @Test("hasPreRepeatSettings returns false when all nil") - func hasPreRepeatSettings_allNil() { - let device = makeDevice() - #expect(device.hasPreRepeatSettings == false) - } - - // MARK: - updating(from: SelfInfo) carries forward client repeat fields - - @Test("updating(from: SelfInfo) carries forward clientRepeat") - func updatingFromSelfInfo_carriesForwardClientRepeat() { - let device = makeDevice(clientRepeat: true) - let updated = device.updating(from: Self.testSelfInfo) - - #expect(updated.clientRepeat == true) - } - - @Test("updating(from: SelfInfo) carries forward preRepeat settings") - func updatingFromSelfInfo_carriesForwardPreRepeatSettings() { - let device = makeDevice( - preRepeatFrequency: 915_000, - preRepeatBandwidth: 250_000, - preRepeatSpreadingFactor: 8, - preRepeatCodingRate: 5 - ) - let updated = device.updating(from: Self.testSelfInfo) - - #expect(updated.preRepeatFrequency == 915_000) - #expect(updated.preRepeatBandwidth == 250_000) - #expect(updated.preRepeatSpreadingFactor == 8) - #expect(updated.preRepeatCodingRate == 5) - #expect(updated.hasPreRepeatSettings == true) - } - - @Test("updating(from: SelfInfo) updates radio params from SelfInfo") - func updatingFromSelfInfo_updatesRadioParams() { - let device = makeDevice( - frequency: 915_000, - bandwidth: 250_000, - spreadingFactor: 8, - codingRate: 5 - ) - let updated = device.updating(from: Self.testSelfInfo) - - // SelfInfo has radioFrequency: 906.875 MHz -> 906875 kHz - #expect(updated.frequency == 906_875) - // SelfInfo has radioBandwidth: 250.0 kHz -> 250000 kHz - #expect(updated.bandwidth == 250_000) - #expect(updated.spreadingFactor == 11) - #expect(updated.codingRate == 8) - #expect(updated.nodeName == "UpdatedNode") - } + @Test + func `savingPreRepeatSettings copies current radio params`() { + let device = makeDevice( + frequency: 915_000, + bandwidth: 250_000, + spreadingFactor: 8, + codingRate: 5 + ) + let saved = device.savingPreRepeatSettings() + + #expect(saved.preRepeatFrequency == 915_000) + #expect(saved.preRepeatBandwidth == 250_000) + #expect(saved.preRepeatSpreadingFactor == 8) + #expect(saved.preRepeatCodingRate == 5) + } + + @Test + func `savingPreRepeatSettings preserves other fields`() { + let device = makeDevice(clientRepeat: true) + let saved = device.savingPreRepeatSettings() + + #expect(saved.clientRepeat == true) + #expect(saved.nodeName == device.nodeName) + #expect(saved.firmwareVersion == device.firmwareVersion) + } + + // MARK: - clearingPreRepeatSettings + + @Test + func `clearingPreRepeatSettings sets all preRepeat fields to nil`() { + let device = makeDevice( + preRepeatFrequency: 915_000, + preRepeatBandwidth: 250_000, + preRepeatSpreadingFactor: 8, + preRepeatCodingRate: 5 + ) + let cleared = device.clearingPreRepeatSettings() + + #expect(cleared.preRepeatFrequency == nil) + #expect(cleared.preRepeatBandwidth == nil) + #expect(cleared.preRepeatSpreadingFactor == nil) + #expect(cleared.preRepeatCodingRate == nil) + #expect(cleared.hasPreRepeatSettings == false) + } + + // MARK: - hasPreRepeatSettings + + @Test + func `hasPreRepeatSettings requires all four fields`() { + // Only frequency set + let partial = makeDevice(preRepeatFrequency: 915_000) + #expect(partial.hasPreRepeatSettings == false) + + // All four set + let complete = makeDevice( + preRepeatFrequency: 915_000, + preRepeatBandwidth: 250_000, + preRepeatSpreadingFactor: 8, + preRepeatCodingRate: 5 + ) + #expect(complete.hasPreRepeatSettings == true) + } + + @Test + func `hasPreRepeatSettings returns false when all nil`() { + let device = makeDevice() + #expect(device.hasPreRepeatSettings == false) + } + + // MARK: - updating(from: SelfInfo) carries forward client repeat fields + + @Test + func `updating(from: SelfInfo) carries forward clientRepeat`() { + let device = makeDevice(clientRepeat: true) + let updated = device.updating(from: Self.testSelfInfo) + + #expect(updated.clientRepeat == true) + } + + @Test + func `updating(from: SelfInfo) carries forward preRepeat settings`() { + let device = makeDevice( + preRepeatFrequency: 915_000, + preRepeatBandwidth: 250_000, + preRepeatSpreadingFactor: 8, + preRepeatCodingRate: 5 + ) + let updated = device.updating(from: Self.testSelfInfo) + + #expect(updated.preRepeatFrequency == 915_000) + #expect(updated.preRepeatBandwidth == 250_000) + #expect(updated.preRepeatSpreadingFactor == 8) + #expect(updated.preRepeatCodingRate == 5) + #expect(updated.hasPreRepeatSettings == true) + } + + @Test + func `updating(from: SelfInfo) updates radio params from SelfInfo`() { + let device = makeDevice( + frequency: 915_000, + bandwidth: 250_000, + spreadingFactor: 8, + codingRate: 5 + ) + let updated = device.updating(from: Self.testSelfInfo) + + // SelfInfo has radioFrequency: 906.875 MHz -> 906875 kHz + #expect(updated.frequency == 906_875) + // SelfInfo has radioBandwidth: 250.0 kHz -> 250000 kHz + #expect(updated.bandwidth == 250_000) + #expect(updated.spreadingFactor == 11) + #expect(updated.codingRate == 8) + #expect(updated.nodeName == "UpdatedNode") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Models/DevicePlatformTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/DevicePlatformTests.swift index 102b33af..97fdaef3 100644 --- a/MC1Services/Tests/MC1ServicesTests/Models/DevicePlatformTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Models/DevicePlatformTests.swift @@ -1,275 +1,281 @@ -import Testing @testable import MC1Services +import Testing @Suite("DevicePlatform Detection Tests") struct DevicePlatformTests { - - // MARK: - ESP32 Devices - - @Test("Heltec V2 detected as ESP32") - func heltecV2() { - #expect(DevicePlatform.detect(from: "Heltec V2") == .esp32) - } - - @Test("Heltec V3 detected as ESP32") - func heltecV3() { - #expect(DevicePlatform.detect(from: "Heltec V3") == .esp32) - } - - @Test("Heltec V4 detected as ESP32") - func heltecV4() { - #expect(DevicePlatform.detect(from: "Heltec V4") == .esp32) - } - - @Test("Heltec Tracker detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Tracker' substring")) - func heltecTracker() { - #expect(DevicePlatform.detect(from: "Heltec Tracker") == .esp32) - } - - @Test("Heltec E290 detected as ESP32") - func heltecE290() { - #expect(DevicePlatform.detect(from: "Heltec E290") == .esp32) - } - - @Test("Heltec E213 detected as ESP32") - func heltecE213() { - #expect(DevicePlatform.detect(from: "Heltec E213") == .esp32) - } - - @Test("Heltec T190 detected as ESP32") - func heltecT190() { - #expect(DevicePlatform.detect(from: "Heltec T190") == .esp32) - } - - @Test("Heltec CT62 detected as ESP32") - func heltecCT62() { - #expect(DevicePlatform.detect(from: "Heltec CT62") == .esp32) - } - - @Test("T-Beam detected as ESP32") - func tBeam() { - #expect(DevicePlatform.detect(from: "T-Beam") == .esp32) - } - - @Test("T-Deck detected as ESP32") - func tDeck() { - #expect(DevicePlatform.detect(from: "T-Deck") == .esp32) - } - - @Test("T-LoRa detected as ESP32") - func tLora() { - #expect(DevicePlatform.detect(from: "T-LoRa") == .esp32) - } - - @Test("TLora detected as ESP32") - func tLoraAlt() { - #expect(DevicePlatform.detect(from: "TLora") == .esp32) - } - - @Test("Xiao S3 WIO detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Seeed' vendor prefix")) - func xiaoS3WIO() { - #expect(DevicePlatform.detect(from: "Xiao S3 WIO") == .esp32) - } - - @Test("Xiao C3 detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Seeed' vendor prefix")) - func xiaoC3() { - #expect(DevicePlatform.detect(from: "Xiao C3") == .esp32) - } - - @Test("Xiao C6 detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Seeed' vendor prefix")) - func xiaoC6() { - #expect(DevicePlatform.detect(from: "Xiao C6") == .esp32) - } - - @Test("RAK 3112 detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'RAK' vendor prefix")) - func rak3112() { - #expect(DevicePlatform.detect(from: "RAK 3112") == .esp32) - } - - @Test("Station G2 detected as ESP32") - func stationG2() { - #expect(DevicePlatform.detect(from: "Station G2") == .esp32) - } - - @Test("Meshadventurer detected as ESP32") - func meshadventurer() { - #expect(DevicePlatform.detect(from: "Meshadventurer") == .esp32) - } - - @Test("Generic ESP32 detected as ESP32") - func genericESP32() { - #expect(DevicePlatform.detect(from: "Generic ESP32") == .esp32) - } - - @Test("ThinkNode M2 detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Seeed' or other vendor prefix")) - func thinkNodeM2() { - #expect(DevicePlatform.detect(from: "ThinkNode M2") == .esp32) - } - - // MARK: - nRF52 Devices - - @Test("MeshPocket detected as nRF52") - func meshPocket() { - #expect(DevicePlatform.detect(from: "MeshPocket") == .nrf52) - } - - @Test("Mesh Pocket (with space) detected as nRF52") - func meshPocketSpace() { - #expect(DevicePlatform.detect(from: "Mesh Pocket") == .nrf52) - } - - @Test("T114 detected as nRF52") - func t114() { - #expect(DevicePlatform.detect(from: "T114") == .nrf52) - } - - @Test("Mesh Solar detected as nRF52") - func meshSolar() { - #expect(DevicePlatform.detect(from: "Mesh Solar") == .nrf52) - } - - @Test("Xiao-nrf52 detected as nRF52") - func xiaoNrf52Dash() { - #expect(DevicePlatform.detect(from: "Xiao-nrf52") == .nrf52) - } - - @Test("Xiao_nrf52 detected as nRF52") - func xiaoNrf52Underscore() { - #expect(DevicePlatform.detect(from: "Xiao_nrf52") == .nrf52) - } - - @Test("WM1110 detected as nRF52") - func wm1110() { - #expect(DevicePlatform.detect(from: "WM1110") == .nrf52) - } - - @Test("Wio Tracker detected as nRF52") - func wioTracker() { - #expect(DevicePlatform.detect(from: "Wio Tracker") == .nrf52) - } - - @Test("T1000-E detected as nRF52") - func t1000E() { - #expect(DevicePlatform.detect(from: "T1000-E") == .nrf52) - } - - @Test("SenseCap Solar detected as nRF52") - func senseCapSolar() { - #expect(DevicePlatform.detect(from: "SenseCap Solar") == .nrf52) - } - - @Test("WisMesh Tag detected as nRF52") - func wisMeshTag() { - #expect(DevicePlatform.detect(from: "WisMesh Tag") == .nrf52) - } - - @Test("RAK 4631 detected as nRF52") - func rak4631() { - #expect(DevicePlatform.detect(from: "RAK 4631") == .nrf52) - } - - @Test("RAK 3401 detected as nRF52") - func rak3401() { - #expect(DevicePlatform.detect(from: "RAK 3401") == .nrf52) - } - - @Test("T-Echo detected as nRF52") - func tEcho() { - #expect(DevicePlatform.detect(from: "T-Echo") == .nrf52) - } - - @Test("ThinkNode-M1 detected as nRF52") - func thinkNodeM1() { - #expect(DevicePlatform.detect(from: "ThinkNode-M1") == .nrf52) - } - - @Test("ThinkNode M3 detected as nRF52") - func thinkNodeM3() { - #expect(DevicePlatform.detect(from: "ThinkNode M3") == .nrf52) - } - - @Test("ThinkNode-M6 detected as nRF52") - func thinkNodeM6() { - #expect(DevicePlatform.detect(from: "ThinkNode-M6") == .nrf52) - } - - @Test("Ikoka detected as nRF52") - func ikoka() { - #expect(DevicePlatform.detect(from: "Ikoka") == .nrf52) - } - - @Test("ProMicro detected as nRF52") - func proMicro() { - #expect(DevicePlatform.detect(from: "ProMicro") == .nrf52) - } - - @Test("Minewsemi detected as nRF52") - func minewsemi() { - #expect(DevicePlatform.detect(from: "Minewsemi") == .nrf52) - } - - @Test("Meshtiny detected as nRF52") - func meshtiny() { - #expect(DevicePlatform.detect(from: "Meshtiny") == .nrf52) - } - - @Test("Keepteen detected as nRF52") - func keepteen() { - #expect(DevicePlatform.detect(from: "Keepteen") == .nrf52) - } - - @Test("Nano G2 Ultra detected as nRF52") - func nanoG2Ultra() { - #expect(DevicePlatform.detect(from: "Nano G2 Ultra") == .nrf52) - } - - // MARK: - Regression: Vendor prefix no longer causes wrong match - - @Test("Bare 'Heltec' vendor name is unknown (not assumed ESP32)", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Old code matched 'Heltec' prefix as ESP32, but Heltec ships nRF52 devices too")) - func bareHeltecUnknown() { - #expect(DevicePlatform.detect(from: "Heltec") == .unknown) - } - - // MARK: - Edge Cases - - @Test("Empty model string returns unknown") - func emptyString() { - #expect(DevicePlatform.detect(from: "") == .unknown) - } - - @Test("Unrecognized device returns unknown") - func unrecognizedDevice() { - #expect(DevicePlatform.detect(from: "SomeNewDevice XYZ") == .unknown) - } - - // MARK: - Pacing Values - - @Test("ESP32 pacing is 60ms") - func esp32Pacing() { - #expect(DevicePlatform.esp32.recommendedWritePacing == 0.060) - } - - @Test("nRF52 pacing is 25ms") - func nrf52Pacing() { - #expect(DevicePlatform.nrf52.recommendedWritePacing == 0.025) - } - - @Test("Unknown pacing is 60ms (conservative default)") - func unknownPacing() { - #expect(DevicePlatform.unknown.recommendedWritePacing == 0.060) - } + // MARK: - ESP32 Devices + + @Test + func `Heltec V2 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec V2") == .esp32) + } + + @Test + func `Heltec V3 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec V3") == .esp32) + } + + @Test + func `Heltec V4 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec V4") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Tracker' substring") + ) + func `Heltec Tracker detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec Tracker") == .esp32) + } + + @Test + func `Heltec E290 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec E290") == .esp32) + } + + @Test + func `Heltec E213 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec E213") == .esp32) + } + + @Test + func `Heltec T190 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec T190") == .esp32) + } + + @Test + func `Heltec CT62 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec CT62") == .esp32) + } + + @Test + func `T-Beam detected as ESP32`() { + #expect(DevicePlatform.detect(from: "T-Beam") == .esp32) + } + + @Test + func `T-Deck detected as ESP32`() { + #expect(DevicePlatform.detect(from: "T-Deck") == .esp32) + } + + @Test + func `T-LoRa detected as ESP32`() { + #expect(DevicePlatform.detect(from: "T-LoRa") == .esp32) + } + + @Test + func `TLora detected as ESP32`() { + #expect(DevicePlatform.detect(from: "TLora") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Seeed' vendor prefix") + ) + func `Xiao S3 WIO detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Xiao S3 WIO") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Seeed' vendor prefix") + ) + func `Xiao C3 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Xiao C3") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Seeed' vendor prefix") + ) + func `Xiao C6 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Xiao C6") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'RAK' vendor prefix") + ) + func `RAK 3112 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "RAK 3112") == .esp32) + } + + @Test + func `Station G2 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Station G2") == .esp32) + } + + @Test + func `Meshadventurer detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Meshadventurer") == .esp32) + } + + @Test + func `Generic ESP32 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Generic ESP32") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Seeed' or other vendor prefix") + ) + func `ThinkNode M2 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "ThinkNode M2") == .esp32) + } + + // MARK: - nRF52 Devices + + @Test + func `MeshPocket detected as nRF52`() { + #expect(DevicePlatform.detect(from: "MeshPocket") == .nrf52) + } + + @Test + func `Mesh Pocket (with space) detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Mesh Pocket") == .nrf52) + } + + @Test + func `T114 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "T114") == .nrf52) + } + + @Test + func `Mesh Solar detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Mesh Solar") == .nrf52) + } + + @Test + func `Xiao-nrf52 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Xiao-nrf52") == .nrf52) + } + + @Test + func `Xiao_nrf52 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Xiao_nrf52") == .nrf52) + } + + @Test + func `WM1110 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "WM1110") == .nrf52) + } + + @Test + func `Wio Tracker detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Wio Tracker") == .nrf52) + } + + @Test + func `T1000-E detected as nRF52`() { + #expect(DevicePlatform.detect(from: "T1000-E") == .nrf52) + } + + @Test + func `SenseCap Solar detected as nRF52`() { + #expect(DevicePlatform.detect(from: "SenseCap Solar") == .nrf52) + } + + @Test + func `WisMesh Tag detected as nRF52`() { + #expect(DevicePlatform.detect(from: "WisMesh Tag") == .nrf52) + } + + @Test + func `RAK 4631 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "RAK 4631") == .nrf52) + } + + @Test + func `RAK 3401 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "RAK 3401") == .nrf52) + } + + @Test + func `T-Echo detected as nRF52`() { + #expect(DevicePlatform.detect(from: "T-Echo") == .nrf52) + } + + @Test + func `ThinkNode-M1 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "ThinkNode-M1") == .nrf52) + } + + @Test + func `ThinkNode M3 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "ThinkNode M3") == .nrf52) + } + + @Test + func `ThinkNode-M6 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "ThinkNode-M6") == .nrf52) + } + + @Test + func `Ikoka detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Ikoka") == .nrf52) + } + + @Test + func `ProMicro detected as nRF52`() { + #expect(DevicePlatform.detect(from: "ProMicro") == .nrf52) + } + + @Test + func `Minewsemi detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Minewsemi") == .nrf52) + } + + @Test + func `Meshtiny detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Meshtiny") == .nrf52) + } + + @Test + func `Keepteen detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Keepteen") == .nrf52) + } + + @Test + func `Nano G2 Ultra detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Nano G2 Ultra") == .nrf52) + } + + // MARK: - Regression: Vendor prefix no longer causes wrong match + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Old code matched 'Heltec' prefix as ESP32, but Heltec ships nRF52 devices too") + ) + func `Bare 'Heltec' vendor name is unknown (not assumed ESP32)`() { + #expect(DevicePlatform.detect(from: "Heltec") == .unknown) + } + + // MARK: - Edge Cases + + @Test + func `Empty model string returns unknown`() { + #expect(DevicePlatform.detect(from: "") == .unknown) + } + + @Test + func `Unrecognized device returns unknown`() { + #expect(DevicePlatform.detect(from: "SomeNewDevice XYZ") == .unknown) + } + + // MARK: - Pacing Values + + @Test + func `ESP32 pacing is 60ms`() { + #expect(DevicePlatform.esp32.recommendedWritePacing == 0.060) + } + + @Test + func `nRF52 pacing is 25ms`() { + #expect(DevicePlatform.nrf52.recommendedWritePacing == 0.025) + } + + @Test + func `Unknown pacing is 60ms (conservative default)`() { + #expect(DevicePlatform.unknown.recommendedWritePacing == 0.060) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift b/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift index f32cd351..78c49de6 100644 --- a/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift @@ -1,186 +1,185 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("NodeSettingsResponseParser") struct NodeSettingsResponseParserTests { - - // MARK: - Settings Field Matching - - @Test("Radio response matches the radio field through the prompt prefix") - func radioResponse() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "> 915.000,250.0,10,5", - checking: [.radio, .txPower] - ) - #expect(value == .radio(frequency: 915.0, bandwidth: 250.0, spreadingFactor: 10, codingRate: 5)) - } - - @Test("Integer response matches TX power") - func txPowerResponse() { - let value = NodeSettingsResponseParser.firstSettingsValue(in: "22", checking: [.txPower]) - #expect(value == .txPower(22)) - } - - @Test("Version response matches firmware version with and without MeshCore prefix") - func versionResponse() { - let prefixed = NodeSettingsResponseParser.firstSettingsValue( - in: "MeshCore v1.11.0 (2025-04-18)", - checking: [.firmwareVersion] - ) - #expect(prefixed == .firmwareVersion("MeshCore v1.11.0 (2025-04-18)")) - - let bare = NodeSettingsResponseParser.firstSettingsValue( - in: "v1.10.0 (2025-03-02)", - checking: [.firmwareVersion] - ) - #expect(bare == .firmwareVersion("v1.10.0 (2025-03-02)")) - } - - @Test("Clock response matches device time") - func deviceTimeResponse() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "06:40 - 18/4/2025 UTC", - checking: [.deviceTime] - ) - #expect(value == .deviceTime("06:40 - 18/4/2025 UTC")) - } - - @Test("Numeric responses match latitude and longitude") - func coordinateResponses() { - let lat = NodeSettingsResponseParser.firstSettingsValue(in: "-36.8485", checking: [.latitude]) - #expect(lat == .latitude(-36.8485)) - - let lon = NodeSettingsResponseParser.firstSettingsValue(in: "174.7633", checking: [.longitude]) - #expect(lon == .longitude(174.7633)) - } - - @Test("Free-form text falls through numeric fields to name") - func nameOrderingAfterCoordinates() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "Alpha Repeater", - checking: [.latitude, .longitude, .name] - ) - #expect(value == .name("Alpha Repeater")) - } - - @Test("A bare number is captured by latitude before name") - func numericCaptureOrdering() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "12.5", - checking: [.latitude, .name] - ) - #expect(value == .latitude(12.5)) - } - - @Test("Owner info keeps the wire pipe separator") - func ownerInfoResponse() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "Contact: KD7ABC|ch 31", - checking: [.ownerInfo] - ) - #expect(value == .ownerInfo("Contact: KD7ABC|ch 31")) - } - - @Test("OK and error responses match no settings field") - func okAndErrorResponses() { - let fields: [NodeSettingsResponseParser.SettingsField] = [ - .radio, .txPower, .firmwareVersion, .latitude, .longitude, .name, .ownerInfo, - ] - #expect(NodeSettingsResponseParser.firstSettingsValue(in: "OK", checking: fields) == nil) - #expect(NodeSettingsResponseParser.firstSettingsValue(in: "ERR: no such setting", checking: fields) == nil) - } - - @Test("Empty field list never matches") - func emptyFieldList() { - #expect(NodeSettingsResponseParser.firstSettingsValue(in: "22", checking: []) == nil) - } - - // MARK: - Behavior Fields - - @Test("Integer fills the first missing behavior field in fixed order") - func behaviorOrdering() { - let advert = NodeSettingsResponseParser.behaviorLateResponse( - "90", hasAdvertInterval: false, hasFloodInterval: false, hasFloodMaxHops: false - ) - #expect(advert == .advertInterval(90)) - - let flood = NodeSettingsResponseParser.behaviorLateResponse( - "24", hasAdvertInterval: true, hasFloodInterval: false, hasFloodMaxHops: false - ) - #expect(flood == .floodAdvertInterval(24)) - - let hops = NodeSettingsResponseParser.behaviorLateResponse( - "8", hasAdvertInterval: true, hasFloodInterval: true, hasFloodMaxHops: false - ) - #expect(hops == .floodMax(8)) - } - - @Test("Behavior matching returns nil when all fields are present or text is non-numeric") - func behaviorNoMatch() { - #expect(NodeSettingsResponseParser.behaviorLateResponse( - "90", hasAdvertInterval: true, hasFloodInterval: true, hasFloodMaxHops: true - ) == nil) - #expect(NodeSettingsResponseParser.behaviorLateResponse( - "Alpha Repeater", hasAdvertInterval: false, hasFloodInterval: false, hasFloodMaxHops: false - ) == nil) - } - - // MARK: - Device Clock - - @Test("Firmware clock response parses to the exact UTC date") - func clockResponseParses() throws { - let date = try #require(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025 UTC")) - - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = try #require(TimeZone(identifier: "UTC")) - let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) - #expect(components.year == 2025) - #expect(components.month == 4) - #expect(components.day == 18) - #expect(components.hour == 6) - #expect(components.minute == 40) - } - - @Test("Non-clock text returns nil") - func clockResponseRejectsOtherText() { - #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "Alpha Repeater") == nil) - #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025") == nil) - } - - // MARK: - Clock Sync - - @Test("Clock sync outcomes classify OK, clock-ahead, generic error, and unexpected text") - func clockSyncClassification() { - #expect(NodeSettingsResponseParser.classifyClockSyncResponse("OK - clock set") == .synced) - #expect(NodeSettingsResponseParser.classifyClockSyncResponse( - "ERR: clock cannot go backwards" - ) == .clockAhead) - #expect(NodeSettingsResponseParser.classifyClockSyncResponse( - "ERR: invalid time" - ) == .failed(message: "invalid time")) - #expect(NodeSettingsResponseParser.classifyClockSyncResponse("hello") == .unexpected) - } - - // MARK: - Password - - @Test("Password change succeeds on OK or the firmware echo, fails otherwise") - func passwordClassification() { - #expect(NodeSettingsResponseParser.isPasswordChangeSuccessful("> password now: hunter2")) - #expect(NodeSettingsResponseParser.isPasswordChangeSuccessful("OK")) - #expect(!NodeSettingsResponseParser.isPasswordChangeSuccessful("ERR: bad password")) - #expect(!NodeSettingsResponseParser.isPasswordChangeSuccessful("Alpha Repeater")) - } - - // MARK: - Owner Info - - @Test("Owner info wire and display forms round-trip") - func ownerInfoMapping() { - #expect(NodeSettingsResponseParser.displayOwnerInfo(fromWire: "KD7ABC|ch 31") == "KD7ABC\nch 31") - #expect(NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: "KD7ABC\nch 31") == "KD7ABC|ch 31") - let display = "line one\nline two\nline three" - #expect(NodeSettingsResponseParser.displayOwnerInfo( - fromWire: NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: display) - ) == display) - } + // MARK: - Settings Field Matching + + @Test + func `Radio response matches the radio field through the prompt prefix`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "> 915.000,250.0,10,5", + checking: [.radio, .txPower] + ) + #expect(value == .radio(frequency: 915.0, bandwidth: 250.0, spreadingFactor: 10, codingRate: 5)) + } + + @Test + func `Integer response matches TX power`() { + let value = NodeSettingsResponseParser.firstSettingsValue(in: "22", checking: [.txPower]) + #expect(value == .txPower(22)) + } + + @Test + func `Version response matches firmware version with and without MeshCore prefix`() { + let prefixed = NodeSettingsResponseParser.firstSettingsValue( + in: "MeshCore v1.11.0 (2025-04-18)", + checking: [.firmwareVersion] + ) + #expect(prefixed == .firmwareVersion("MeshCore v1.11.0 (2025-04-18)")) + + let bare = NodeSettingsResponseParser.firstSettingsValue( + in: "v1.10.0 (2025-03-02)", + checking: [.firmwareVersion] + ) + #expect(bare == .firmwareVersion("v1.10.0 (2025-03-02)")) + } + + @Test + func `Clock response matches device time`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "06:40 - 18/4/2025 UTC", + checking: [.deviceTime] + ) + #expect(value == .deviceTime("06:40 - 18/4/2025 UTC")) + } + + @Test + func `Numeric responses match latitude and longitude`() { + let lat = NodeSettingsResponseParser.firstSettingsValue(in: "-36.8485", checking: [.latitude]) + #expect(lat == .latitude(-36.8485)) + + let lon = NodeSettingsResponseParser.firstSettingsValue(in: "174.7633", checking: [.longitude]) + #expect(lon == .longitude(174.7633)) + } + + @Test + func `Free-form text falls through numeric fields to name`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "Alpha Repeater", + checking: [.latitude, .longitude, .name] + ) + #expect(value == .name("Alpha Repeater")) + } + + @Test + func `A bare number is captured by latitude before name`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "12.5", + checking: [.latitude, .name] + ) + #expect(value == .latitude(12.5)) + } + + @Test + func `Owner info keeps the wire pipe separator`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "Contact: KD7ABC|ch 31", + checking: [.ownerInfo] + ) + #expect(value == .ownerInfo("Contact: KD7ABC|ch 31")) + } + + @Test + func `OK and error responses match no settings field`() { + let fields: [NodeSettingsResponseParser.SettingsField] = [ + .radio, .txPower, .firmwareVersion, .latitude, .longitude, .name, .ownerInfo, + ] + #expect(NodeSettingsResponseParser.firstSettingsValue(in: "OK", checking: fields) == nil) + #expect(NodeSettingsResponseParser.firstSettingsValue(in: "ERR: no such setting", checking: fields) == nil) + } + + @Test + func `Empty field list never matches`() { + #expect(NodeSettingsResponseParser.firstSettingsValue(in: "22", checking: []) == nil) + } + + // MARK: - Behavior Fields + + @Test + func `Integer fills the first missing behavior field in fixed order`() { + let advert = NodeSettingsResponseParser.behaviorLateResponse( + "90", hasAdvertInterval: false, hasFloodInterval: false, hasFloodMaxHops: false + ) + #expect(advert == .advertInterval(90)) + + let flood = NodeSettingsResponseParser.behaviorLateResponse( + "24", hasAdvertInterval: true, hasFloodInterval: false, hasFloodMaxHops: false + ) + #expect(flood == .floodAdvertInterval(24)) + + let hops = NodeSettingsResponseParser.behaviorLateResponse( + "8", hasAdvertInterval: true, hasFloodInterval: true, hasFloodMaxHops: false + ) + #expect(hops == .floodMax(8)) + } + + @Test + func `Behavior matching returns nil when all fields are present or text is non-numeric`() { + #expect(NodeSettingsResponseParser.behaviorLateResponse( + "90", hasAdvertInterval: true, hasFloodInterval: true, hasFloodMaxHops: true + ) == nil) + #expect(NodeSettingsResponseParser.behaviorLateResponse( + "Alpha Repeater", hasAdvertInterval: false, hasFloodInterval: false, hasFloodMaxHops: false + ) == nil) + } + + // MARK: - Device Clock + + @Test + func `Firmware clock response parses to the exact UTC date`() throws { + let date = try #require(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025 UTC")) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) + #expect(components.year == 2025) + #expect(components.month == 4) + #expect(components.day == 18) + #expect(components.hour == 6) + #expect(components.minute == 40) + } + + @Test + func `Non-clock text returns nil`() { + #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "Alpha Repeater") == nil) + #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025") == nil) + } + + // MARK: - Clock Sync + + @Test + func `Clock sync outcomes classify OK, clock-ahead, generic error, and unexpected text`() { + #expect(NodeSettingsResponseParser.classifyClockSyncResponse("OK - clock set") == .synced) + #expect(NodeSettingsResponseParser.classifyClockSyncResponse( + "ERR: clock cannot go backwards" + ) == .clockAhead) + #expect(NodeSettingsResponseParser.classifyClockSyncResponse( + "ERR: invalid time" + ) == .failed(message: "invalid time")) + #expect(NodeSettingsResponseParser.classifyClockSyncResponse("hello") == .unexpected) + } + + // MARK: - Password + + @Test + func `Password change succeeds on OK or the firmware echo, fails otherwise`() { + #expect(NodeSettingsResponseParser.isPasswordChangeSuccessful("> password now: hunter2")) + #expect(NodeSettingsResponseParser.isPasswordChangeSuccessful("OK")) + #expect(!NodeSettingsResponseParser.isPasswordChangeSuccessful("ERR: bad password")) + #expect(!NodeSettingsResponseParser.isPasswordChangeSuccessful("Alpha Repeater")) + } + + // MARK: - Owner Info + + @Test + func `Owner info wire and display forms round-trip`() { + #expect(NodeSettingsResponseParser.displayOwnerInfo(fromWire: "KD7ABC|ch 31") == "KD7ABC\nch 31") + #expect(NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: "KD7ABC\nch 31") == "KD7ABC|ch 31") + let display = "line one\nline two\nline three" + #expect(NodeSettingsResponseParser.displayOwnerInfo( + fromWire: NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: display) + ) == display) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift index 62e29da6..3de5c277 100644 --- a/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift @@ -1,562 +1,679 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("NodeSnapshotService Tests") struct NodeSnapshotServiceTests { - - private let testPublicKey = Data(repeating: 0x42, count: 32) - - private func createTestService() async throws -> (NodeSnapshotService, PersistenceStore) { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let service = NodeSnapshotService(dataStore: store) - return (service, store) - } - - private func metrics(battery: UInt16, uptime: UInt32?) -> NodeStatusMetrics { - NodeStatusMetrics( - batteryMillivolts: battery, - lastSNR: 8.5, - lastRSSI: -87, - noiseFloor: -120, - uptimeSeconds: uptime, - rxAirtimeSeconds: 100, - packetsSent: 500, - packetsReceived: 1000, - receiveErrors: nil - ) - } - - @Test("Record returns an ID on first capture") - func recordFirstSnapshot() async throws { - let (service, _) = try await createTestService() - - let id = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - - #expect(id != nil) - } - - @Test("Second status within the window enriches the same row, not a new one") - func inWindowStatusKeepsSingleRow() async throws { - let (service, _) = try await createTestService() - - let first = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - #expect(first != nil) - - let second = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3900, uptime: 7200) - ) - #expect(second == first, "An in-window status capture returns the existing row's ID") - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1, "No second snapshot is created within the window") - #expect(snapshots[0].batteryMillivolts == 3850, "A row already carrying status is not overwritten") - #expect(snapshots[0].uptimeSeconds == 3600) - } - - @Test("Different nodes get independent snapshots") - func differentNodesIndependent() async throws { - let (service, _) = try await createTestService() - let otherKey = Data(repeating: 0x99, count: 32) - - let first = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - let second = await service.recordSnapshot( - nodePublicKey: otherKey, - status: metrics(battery: 3700, uptime: 1800) - ) - - #expect(first != nil) - #expect(second != nil) - #expect(first != second, "Different nodes are not throttled against each other") - } - - @Test("Neighbors enrich the in-window snapshot") - func neighborsEnrichInWindow() async throws { - let (service, store) = try await createTestService() - - let statusID = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - let neighbors = [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 5.5, secondsAgo: 30) - ] - let neighborID = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: neighbors) - #expect(neighborID == statusID, "Neighbors land on the existing in-window row") - - let latest = try await store.fetchLatestNodeStatusSnapshot(nodePublicKey: testPublicKey) - #expect(latest?.neighborSnapshots?.count == 1) - #expect(latest?.neighborSnapshots?.first?.snr == 5.5) + private let testPublicKey = Data(repeating: 0x42, count: 32) + + private func createTestService() async throws -> (NodeSnapshotService, PersistenceStore) { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let service = NodeSnapshotService(dataStore: store) + return (service, store) + } + + private func metrics(battery: UInt16, uptime: UInt32?) -> NodeStatusMetrics { + NodeStatusMetrics( + batteryMillivolts: battery, + lastSNR: 8.5, + lastRSSI: -87, + noiseFloor: -120, + uptimeSeconds: uptime, + rxAirtimeSeconds: 100, + packetsSent: 500, + packetsReceived: 1000, + receiveErrors: nil, + sentDirect: nil, + sentFlood: nil, + receivedDirect: nil, + receivedFlood: nil, + directDuplicates: nil, + floodDuplicates: nil + ) + } + + /// Builds a neighbor prefix at the realistic on-wire length so distinct seeds + /// can't collide on a truncated prefix. + private func neighborPrefix(seed: UInt8) -> Data { + let length = Int(RepeaterAdminService.defaultPubkeyPrefixLength) + return Data((0.. enrich -> fetchAll round-trip`() async throws { + let (service, store) = try await createTestService() + + // Save two snapshots directly to the store (bypass the window) + let t1 = Date.now.addingTimeInterval(-20) + let t2 = Date.now.addingTimeInterval(-10) + let id1 = try await store.saveNodeStatusSnapshot( + timestamp: t1, + nodePublicKey: testPublicKey, + batteryMillivolts: 3600, + lastSNR: 7.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + let id2 = try await store.saveNodeStatusSnapshot( + timestamp: t2, + nodePublicKey: testPublicKey, + batteryMillivolts: 3800, + lastSNR: 8.5, lastRSSI: -85, noiseFloor: -118, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + + // Enrich both directly through the store, targeting specific rows + let telemetry1 = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 25.0)] + let telemetry2 = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 30.0)] + try await store.updateSnapshotTelemetry(id: id1, telemetry: telemetry1) + try await store.updateSnapshotTelemetry(id: id2, telemetry: telemetry2) + + let neighbors1 = [NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 5.0, secondsAgo: 60)] + let neighbors2 = [NeighborSnapshotEntry(publicKeyPrefix: Data([0x05, 0x06, 0x07, 0x08]), snr: 9.0, secondsAgo: 10)] + try await store.updateSnapshotNeighbors(id: id1, neighbors: neighbors1) + try await store.updateSnapshotNeighbors(id: id2, neighbors: neighbors2) + + // Fetch all via the method used by history views + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 2) + + #expect(snapshots[0].telemetryEntries?.count == 1, "Snapshot 1 telemetry should persist") + #expect(snapshots[0].telemetryEntries?.first?.value == 25.0) + #expect(snapshots[0].neighborSnapshots?.count == 1, "Snapshot 1 neighbors should persist") + #expect(snapshots[0].neighborSnapshots?.first?.snr == 5.0) + + #expect(snapshots[1].telemetryEntries?.count == 1, "Snapshot 2 telemetry should persist") + #expect(snapshots[1].telemetryEntries?.first?.value == 30.0) + #expect(snapshots[1].neighborSnapshots?.count == 1, "Snapshot 2 neighbors should persist") + #expect(snapshots[1].neighborSnapshots?.first?.snr == 9.0) + } + + @Test + func `Status + telemetry + neighbors in one window collapse onto a single row`() async throws { + let (service, _) = try await createTestService() + + let statusID = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3700, uptime: 3600) + ) + guard let snapshotID = statusID else { + Issue.record("First capture should return an ID") + return } - @Test("Telemetry enriches the in-window snapshot") - func telemetryEnrichInWindow() async throws { - let (service, store) = try await createTestService() - - let statusID = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - let telemetry = [ - TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 32.5) - ] - let telemetryID = await service.recordSnapshot(nodePublicKey: testPublicKey, telemetry: telemetry) - #expect(telemetryID == statusID, "Telemetry lands on the existing in-window row") - - let latest = try await store.fetchLatestNodeStatusSnapshot(nodePublicKey: testPublicKey) - #expect(latest?.telemetryEntries?.count == 1) - #expect(latest?.telemetryEntries?.first?.value == 32.5) - } - - @Test("Fetch snapshots returns ascending order") - func fetchSnapshotsOrdering() async throws { - let (service, store) = try await createTestService() - let t1 = Date.now.addingTimeInterval(-20) - let t2 = Date.now.addingTimeInterval(-10) - - _ = try await store.saveNodeStatusSnapshot( - timestamp: t1, - nodePublicKey: testPublicKey, - batteryMillivolts: 3600, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - _ = try await store.saveNodeStatusSnapshot( - timestamp: t2, - nodePublicKey: testPublicKey, - batteryMillivolts: 3800, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 2) - #expect(snapshots[0].batteryMillivolts == 3600) - #expect(snapshots[1].batteryMillivolts == 3800) - } - - @Test("Prune only deletes snapshots older than cutoff") - func pruneOldSnapshots() async throws { - let (service, store) = try await createTestService() - let oldTime = Date.now.addingTimeInterval(-60) - let cutoff = Date.now.addingTimeInterval(-30) - let recentTime = Date.now.addingTimeInterval(-10) - - // Save an "old" snapshot by writing directly to the store (bypass throttle) - _ = try await store.saveNodeStatusSnapshot( - timestamp: oldTime, - nodePublicKey: testPublicKey, - batteryMillivolts: 3600, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - // Save a "recent" snapshot - let recentID = try await store.saveNodeStatusSnapshot( - timestamp: recentTime, - nodePublicKey: testPublicKey, - batteryMillivolts: 3800, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - await service.pruneOldSnapshots(olderThan: cutoff) - - let remaining = await service.fetchSnapshots(for: testPublicKey) - #expect(remaining.count == 1, "Old snapshot should be pruned, recent should remain") - #expect(remaining.first?.id == recentID) - } - - @Test("Prune with future cutoff does not delete recent snapshots") - func prunePreservesRecentSnapshots() async throws { - let (service, store) = try await createTestService() - - _ = try await store.saveNodeStatusSnapshot( - nodePublicKey: testPublicKey, - batteryMillivolts: 3850, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - // Prune with a cutoff 1 year ago — recent data should survive - let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: .now)! - await service.pruneOldSnapshots(olderThan: oneYearAgo) - - let remaining = await service.fetchSnapshots(for: testPublicKey) - #expect(remaining.count == 1, "Recent snapshot should not be pruned") - } - - @Test("Fetch snapshots with since filter") - func fetchSnapshotsSinceDate() async throws { - let (service, store) = try await createTestService() - let t1 = Date.now.addingTimeInterval(-30) - let cutoff = Date.now.addingTimeInterval(-15) - let t2 = Date.now.addingTimeInterval(-5) - - _ = try await store.saveNodeStatusSnapshot( - timestamp: t1, - nodePublicKey: testPublicKey, - batteryMillivolts: 3600, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - _ = try await store.saveNodeStatusSnapshot( - timestamp: t2, - nodePublicKey: testPublicKey, - batteryMillivolts: 3800, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - let snapshots = await service.fetchSnapshots(for: testPublicKey, since: cutoff) - #expect(snapshots.count == 1) - #expect(snapshots[0].batteryMillivolts == 3800) - } - - // MARK: - Round-trip enrichment tests - - @Test("Enrichment data survives save -> enrich -> fetchAll round-trip") - func enrichmentRoundTrip() async throws { - let (service, store) = try await createTestService() - - // Save two snapshots directly to the store (bypass the window) - let t1 = Date.now.addingTimeInterval(-20) - let t2 = Date.now.addingTimeInterval(-10) - let id1 = try await store.saveNodeStatusSnapshot( - timestamp: t1, - nodePublicKey: testPublicKey, - batteryMillivolts: 3600, - lastSNR: 7.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - let id2 = try await store.saveNodeStatusSnapshot( - timestamp: t2, - nodePublicKey: testPublicKey, - batteryMillivolts: 3800, - lastSNR: 8.5, lastRSSI: -85, noiseFloor: -118, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - // Enrich both directly through the store, targeting specific rows - let telemetry1 = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 25.0)] - let telemetry2 = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 30.0)] - try await store.updateSnapshotTelemetry(id: id1, telemetry: telemetry1) - try await store.updateSnapshotTelemetry(id: id2, telemetry: telemetry2) - - let neighbors1 = [NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 5.0, secondsAgo: 60)] - let neighbors2 = [NeighborSnapshotEntry(publicKeyPrefix: Data([0x05, 0x06, 0x07, 0x08]), snr: 9.0, secondsAgo: 10)] - try await store.updateSnapshotNeighbors(id: id1, neighbors: neighbors1) - try await store.updateSnapshotNeighbors(id: id2, neighbors: neighbors2) - - // Fetch all via the method used by history views - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 2) - - #expect(snapshots[0].telemetryEntries?.count == 1, "Snapshot 1 telemetry should persist") - #expect(snapshots[0].telemetryEntries?.first?.value == 25.0) - #expect(snapshots[0].neighborSnapshots?.count == 1, "Snapshot 1 neighbors should persist") - #expect(snapshots[0].neighborSnapshots?.first?.snr == 5.0) - - #expect(snapshots[1].telemetryEntries?.count == 1, "Snapshot 2 telemetry should persist") - #expect(snapshots[1].telemetryEntries?.first?.value == 30.0) - #expect(snapshots[1].neighborSnapshots?.count == 1, "Snapshot 2 neighbors should persist") - #expect(snapshots[1].neighborSnapshots?.first?.snr == 9.0) - } - - @Test("Status + telemetry + neighbors in one window collapse onto a single row") - func combinedCaptureSingleRow() async throws { - let (service, _) = try await createTestService() - - let statusID = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3700, uptime: 3600) - ) - guard let snapshotID = statusID else { - Issue.record("First capture should return an ID") - return - } - - let telemetry = [ - TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 28.5), - TelemetrySnapshotEntry(channel: 1, type: "humidity", value: 65.0), - ] - let neighbors = [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0xAA, 0xBB, 0xCC, 0xDD]), snr: 6.5, secondsAgo: 45), - ] - let enrichedID = await service.recordSnapshot( - nodePublicKey: testPublicKey, - telemetry: telemetry, - neighbors: neighbors - ) - #expect(enrichedID == snapshotID) - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1) - #expect(snapshots[0].telemetryEntries?.count == 2, "Both telemetry entries should persist") - #expect(snapshots[0].neighborSnapshots?.count == 1, "Neighbor entry should persist") - } - - // MARK: - Concurrent and out-of-order capture coverage - - @Test("Telemetry-first then status-within-window backfills status onto the single snapshot") - func statusEnrichesTelemetryOnlySnapshot() async throws { - let (service, _) = try await createTestService() - - // Telemetry expanded before status: a telemetry-only snapshot is created. - let telemetry = [TelemetrySnapshotEntry(channel: 1, type: "temperature", value: 21.5)] - let telemetryID = await service.recordSnapshot(nodePublicKey: testPublicKey, telemetry: telemetry) - #expect(telemetryID != nil, "Telemetry-only capture should create a snapshot") - - // Status applied within the window backfills the telemetry-only row - // rather than being dropped or creating a duplicate. - let statusMetrics = NodeStatusMetrics( - batteryMillivolts: 3900, - lastSNR: 9.0, lastRSSI: -84, noiseFloor: -119, - uptimeSeconds: 7200, rxAirtimeSeconds: 150, - packetsSent: 600, packetsReceived: 1200, - receiveErrors: 3 - ) - let statusID = await service.recordSnapshot(nodePublicKey: testPublicKey, status: statusMetrics) - #expect(statusID == telemetryID, "Status enriches the telemetry-only row, no new snapshot") - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1, "Should remain a single snapshot carrying both data sets") - #expect(snapshots[0].telemetryEntries?.first?.value == 21.5, "Telemetry should be preserved") - #expect(snapshots[0].uptimeSeconds == 7200, "Status counters should be backfilled") - #expect(snapshots[0].batteryMillivolts == 3900) - #expect(snapshots[0].receiveErrors == 3) - } - - @Test("Neighbors captured before any status persist on a fresh snapshot") - func neighborsWithoutStatusPersist() async throws { - let (service, _) = try await createTestService() - - let neighbors = [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x0A, 0x0B, 0x0C, 0x0D]), snr: 4.0, secondsAgo: 90) - ] - let id = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: neighbors) - #expect(id != nil, "Neighbors expanded without status must still persist") - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1) - #expect(snapshots[0].neighborSnapshots?.count == 1, "Neighbor data should persist on a fresh row") - #expect(snapshots[0].uptimeSeconds == nil, "A neighbor-only row carries no status fields yet") - } - - // MARK: - Neighbor delta baseline - - @Test("Previous neighbor snapshot skips a more recent status-only row") - func previousNeighborSkipsStatusOnly() async throws { - let (service, store) = try await createTestService() - let older = Date.now.addingTimeInterval(-3600) - let recent = Date.now.addingTimeInterval(-1800) - - let neighborID = try await store.saveNodeStatusSnapshot( - timestamp: older, - nodePublicKey: testPublicKey, - batteryMillivolts: 3700, - lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: 3600, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - try await store.updateSnapshotNeighbors(id: neighborID, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) - ]) - _ = try await store.saveNodeStatusSnapshot( - timestamp: recent, - nodePublicKey: testPublicKey, - batteryMillivolts: 3850, - lastSNR: 8.0, lastRSSI: -85, noiseFloor: -118, - uptimeSeconds: 7200, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous?.neighborSnapshots?.first?.snr == 6.0, - "Returns the neighbor-bearing row, not the newer status-only row") - } - - @Test("Previous neighbor snapshot excludes the current in-window capture") - func previousNeighborExcludesCurrentWindow() async throws { - let (service, store) = try await createTestService() - let older = Date.now.addingTimeInterval(-3600) - - let priorID = try await store.saveNodeStatusSnapshot( - timestamp: older, - nodePublicKey: testPublicKey, - batteryMillivolts: 3700, - lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: 3600, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - try await store.updateSnapshotNeighbors(id: priorID, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) - ]) - // The reading being viewed now lands on a fresh in-window row. - _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) - ]) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous?.neighborSnapshots?.first?.snr == 6.0, - "The current in-window row is excluded; baseline is the prior neighbor capture") - } - - @Test("Previous neighbor snapshot is nil when no prior neighbor data exists") - func previousNeighborNilWithoutHistory() async throws { - let (service, _) = try await createTestService() - - _ = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous == nil, "Status-only history yields no neighbor baseline") - } - - @Test("Previous neighbor snapshot returns an out-of-window neighbor row as the baseline") - func previousNeighborReturnsOutOfWindowRow() async throws { - let (service, store) = try await createTestService() - let older = Date.now.addingTimeInterval(-3600) - - let priorID = try await store.saveNodeStatusSnapshot( - timestamp: older, - nodePublicKey: testPublicKey, - batteryMillivolts: 3700, - lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: 3600, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - try await store.updateSnapshotNeighbors(id: priorID, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) - ]) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous?.neighborSnapshots?.first?.snr == 6.0, - "With the latest row outside the in-window cutoff, it is the baseline") - } - - @Test("Previous neighbor snapshot is nil when the only neighbor row is the in-window capture") - func previousNeighborNilWhenOnlyInWindowCapture() async throws { - let (service, _) = try await createTestService() - - // A single fresh in-window neighbor capture is its own reading, so it has no baseline. - _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) - ]) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous == nil, "The in-window capture is excluded and there is no prior neighbor row") - } - - // MARK: - Status delta baseline - - @Test("Previous status snapshot skips a more recent neighbor-only row") - func previousStatusSkipsNeighborOnly() async throws { - let (service, store) = try await createTestService() - let older = Date.now.addingTimeInterval(-3600) - - _ = try await store.saveNodeStatusSnapshot( - timestamp: older, - nodePublicKey: testPublicKey, - batteryMillivolts: 3700, - lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: 3600, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - // A newer neighbor-only capture carries no status fields. - _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) - ]) - - let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) - #expect(previous?.uptimeSeconds == 3600, - "Returns the status-bearing row, not the newer neighbor-only row") - } - - @Test("Previous status snapshot includes an in-window status capture") - func previousStatusIncludesInWindowCapture() async throws { - let (service, _) = try await createTestService() - - // Unlike the neighbor baseline, an early in-window status reading is a valid - // baseline: status capture is throttled and never overwrites itself. - _ = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - - let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) - #expect(previous?.uptimeSeconds == 3600, "The in-window status row is a usable baseline") - } - - @Test("Previous status snapshot is nil when only neighbor data exists") - func previousStatusNilWithoutStatusHistory() async throws { - let (service, _) = try await createTestService() - - _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) - ]) - - let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) - #expect(previous == nil, "Neighbor-only history yields no status baseline") - } - - @Test("Concurrent in-window captures never duplicate a snapshot") - func concurrentCapturesSingleRow() async throws { - let (service, _) = try await createTestService() - - // Status and telemetry captured concurrently must collapse onto one - // in-window row; the atomic store serializes the read-modify-write. - let telemetry = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 19.0)] - async let statusResult = service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - async let telemetryResult = service.recordSnapshot( - nodePublicKey: testPublicKey, - telemetry: telemetry - ) - let (statusID, telemetryID) = await (statusResult, telemetryResult) - #expect(statusID != nil) - #expect(telemetryID != nil) - #expect(statusID == telemetryID, "Concurrent captures resolve to the same in-window row") - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1, "Atomic record collapses concurrent captures into one row") - } + let telemetry = [ + TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 28.5), + TelemetrySnapshotEntry(channel: 1, type: "humidity", value: 65.0), + ] + let neighbors = [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0xAA, 0xBB, 0xCC, 0xDD]), snr: 6.5, secondsAgo: 45), + ] + let enrichedID = await service.recordSnapshot( + nodePublicKey: testPublicKey, + telemetry: telemetry, + neighbors: neighbors + ) + #expect(enrichedID == snapshotID) + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 1) + #expect(snapshots[0].telemetryEntries?.count == 2, "Both telemetry entries should persist") + #expect(snapshots[0].neighborSnapshots?.count == 1, "Neighbor entry should persist") + } + + // MARK: - Concurrent and out-of-order capture coverage + + @Test + func `Telemetry-first then status-within-window backfills status onto the single snapshot`() async throws { + let (service, _) = try await createTestService() + + // Telemetry expanded before status: a telemetry-only snapshot is created. + let telemetry = [TelemetrySnapshotEntry(channel: 1, type: "temperature", value: 21.5)] + let telemetryID = await service.recordSnapshot(nodePublicKey: testPublicKey, telemetry: telemetry) + #expect(telemetryID != nil, "Telemetry-only capture should create a snapshot") + + // Status applied within the window backfills the telemetry-only row + // rather than being dropped or creating a duplicate. + let statusMetrics = NodeStatusMetrics( + batteryMillivolts: 3900, + lastSNR: 9.0, lastRSSI: -84, noiseFloor: -119, + uptimeSeconds: 7200, rxAirtimeSeconds: 150, + packetsSent: 600, packetsReceived: 1200, + receiveErrors: 3, + sentDirect: nil, + sentFlood: nil, + receivedDirect: nil, + receivedFlood: nil, + directDuplicates: nil, + floodDuplicates: nil + ) + let statusID = await service.recordSnapshot(nodePublicKey: testPublicKey, status: statusMetrics) + #expect(statusID == telemetryID, "Status enriches the telemetry-only row, no new snapshot") + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 1, "Should remain a single snapshot carrying both data sets") + #expect(snapshots[0].telemetryEntries?.first?.value == 21.5, "Telemetry should be preserved") + #expect(snapshots[0].uptimeSeconds == 7200, "Status counters should be backfilled") + #expect(snapshots[0].batteryMillivolts == 3900) + #expect(snapshots[0].receiveErrors == 3) + } + + @Test + func `Neighbors captured before any status persist on a fresh snapshot`() async throws { + let (service, _) = try await createTestService() + + let neighbors = [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x0A, 0x0B, 0x0C, 0x0D]), snr: 4.0, secondsAgo: 90) + ] + let id = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: neighbors) + #expect(id != nil, "Neighbors expanded without status must still persist") + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 1) + #expect(snapshots[0].neighborSnapshots?.count == 1, "Neighbor data should persist on a fresh row") + #expect(snapshots[0].uptimeSeconds == nil, "A neighbor-only row carries no status fields yet") + } + + // MARK: - Neighbor delta baseline + + @Test + func `Previous neighbor snapshot skips a more recent status-only row`() async throws { + let (service, store) = try await createTestService() + let older = Date.now.addingTimeInterval(-3600) + let recent = Date.now.addingTimeInterval(-1800) + + let neighborID = try await store.saveNodeStatusSnapshot( + timestamp: older, + nodePublicKey: testPublicKey, + batteryMillivolts: 3700, + lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: 3600, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + try await store.updateSnapshotNeighbors(id: neighborID, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) + ]) + _ = try await store.saveNodeStatusSnapshot( + timestamp: recent, + nodePublicKey: testPublicKey, + batteryMillivolts: 3850, + lastSNR: 8.0, lastRSSI: -85, noiseFloor: -118, + uptimeSeconds: 7200, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + + let previous = await (service.neighborBaseline(for: testPublicKey)).previous + #expect(previous?.neighborSnapshots?.first?.snr == 6.0, + "Returns the neighbor-bearing row, not the newer status-only row") + } + + @Test + func `Previous neighbor snapshot excludes the current in-window capture`() async throws { + let (service, store) = try await createTestService() + let older = Date.now.addingTimeInterval(-3600) + + let priorID = try await store.saveNodeStatusSnapshot( + timestamp: older, + nodePublicKey: testPublicKey, + batteryMillivolts: 3700, + lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: 3600, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + try await store.updateSnapshotNeighbors(id: priorID, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) + ]) + // The reading being viewed now lands on a fresh in-window row. + _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) + ]) + + let previous = await (service.neighborBaseline(for: testPublicKey)).previous + #expect(previous?.neighborSnapshots?.first?.snr == 6.0, + "The current in-window row is excluded; baseline is the prior neighbor capture") + } + + @Test + func `Previous neighbor snapshot is nil when no prior neighbor data exists`() async throws { + let (service, _) = try await createTestService() + + _ = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + + let previous = await (service.neighborBaseline(for: testPublicKey)).previous + #expect(previous == nil, "Status-only history yields no neighbor baseline") + } + + @Test + func `Previous neighbor snapshot returns an out-of-window neighbor row as the baseline`() async throws { + let (service, store) = try await createTestService() + let older = Date.now.addingTimeInterval(-3600) + + let priorID = try await store.saveNodeStatusSnapshot( + timestamp: older, + nodePublicKey: testPublicKey, + batteryMillivolts: 3700, + lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: 3600, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + try await store.updateSnapshotNeighbors(id: priorID, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) + ]) + + let previous = await (service.neighborBaseline(for: testPublicKey)).previous + #expect(previous?.neighborSnapshots?.first?.snr == 6.0, + "With the latest row outside the in-window cutoff, it is the baseline") + } + + @Test + func `Previous neighbor snapshot is nil when the only neighbor row is the in-window capture`() async throws { + let (service, _) = try await createTestService() + + // A single fresh in-window neighbor capture is its own reading, so it has no baseline. + _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) + ]) + + let previous = await (service.neighborBaseline(for: testPublicKey)).previous + #expect(previous == nil, "The in-window capture is excluded and there is no prior neighbor row") + } + + @Test + func `Seen neighbor prefixes span all history, not just the previous snapshot`() async throws { + let (service, store) = try await createTestService() + let older = Date.now.addingTimeInterval(-7200) + let recent = Date.now.addingTimeInterval(-3600) + + let prefixA = neighborPrefix(seed: 0xA0) + let prefixB = neighborPrefix(seed: 0xB0) + + // An older out-of-window snapshot heard neighbor A. + let olderID = try await store.saveNodeStatusSnapshot( + timestamp: older, + nodePublicKey: testPublicKey, + batteryMillivolts: 3700, + lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: 3600, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + try await store.updateSnapshotNeighbors(id: olderID, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: prefixA, snr: 6.0, secondsAgo: 30) + ]) + + // The immediately-previous snapshot heard B only; A went silent, so A is absent + // from the single-row baseline the old badge logic compared against. + let recentID = try await store.saveNodeStatusSnapshot( + timestamp: recent, + nodePublicKey: testPublicKey, + batteryMillivolts: 3800, + lastSNR: 7.0, lastRSSI: -88, noiseFloor: -119, + uptimeSeconds: 5400, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + try await store.updateSnapshotNeighbors(id: recentID, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: prefixB, snr: 7.0, secondsAgo: 30) + ]) + + let baseline = await service.neighborBaseline(for: testPublicKey) + + // The delta baseline is the immediately-previous snapshot, which no longer lists A. + #expect(baseline.previous?.neighborSnapshots?.first?.publicKeyPrefix == prefixB, + "The previous baseline is the most recent neighbor-bearing row") + #expect(baseline.previous?.neighborSnapshots?.contains { $0.publicKeyPrefix == prefixA } == false, + "A is absent from the immediately-previous snapshot") + + // The seen set spans all history, so A is still known despite being silent now. + #expect(baseline.seenPrefixes.contains(prefixA), "A remains seen via older history") + #expect(baseline.seenPrefixes.contains(prefixB), "B is seen in the previous snapshot") + + // The current in-window capture is excluded from the seen set. + let prefixC = neighborPrefix(seed: 0xC0) + _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: prefixC, snr: 9.0, secondsAgo: 5) + ]) + let afterCapture = await service.neighborBaseline(for: testPublicKey) + #expect(afterCapture.seenPrefixes.contains(prefixA), "Historical prefixes survive a new capture") + #expect(!afterCapture.seenPrefixes.contains(prefixC), + "The in-window capture is excluded from the seen set") + } + + // MARK: - Status delta baseline + + @Test + func `Previous status snapshot skips a more recent neighbor-only row`() async throws { + let (service, store) = try await createTestService() + let older = Date.now.addingTimeInterval(-3600) + + _ = try await store.saveNodeStatusSnapshot( + timestamp: older, + nodePublicKey: testPublicKey, + batteryMillivolts: 3700, + lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: 3600, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + // A newer neighbor-only capture carries no status fields. + _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) + ]) + + let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) + #expect(previous?.uptimeSeconds == 3600, + "Returns the status-bearing row, not the newer neighbor-only row") + } + + @Test + func `Previous status snapshot includes an in-window status capture`() async throws { + let (service, _) = try await createTestService() + + // Unlike the neighbor baseline, an early in-window status reading is a valid + // baseline: status capture is throttled and never overwrites itself. + _ = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + + let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) + #expect(previous?.uptimeSeconds == 3600, "The in-window status row is a usable baseline") + } + + @Test + func `Previous status snapshot is nil when only neighbor data exists`() async throws { + let (service, _) = try await createTestService() + + _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) + ]) + + let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) + #expect(previous == nil, "Neighbor-only history yields no status baseline") + } + + @Test + func `Concurrent in-window captures never duplicate a snapshot`() async throws { + let (service, _) = try await createTestService() + + // Status and telemetry captured concurrently must collapse onto one + // in-window row; the atomic store serializes the read-modify-write. + let telemetry = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 19.0)] + async let statusResult = service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + async let telemetryResult = service.recordSnapshot( + nodePublicKey: testPublicKey, + telemetry: telemetry + ) + let (statusID, telemetryID) = await (statusResult, telemetryResult) + #expect(statusID != nil) + #expect(telemetryID != nil) + #expect(statusID == telemetryID, "Concurrent captures resolve to the same in-window row") + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 1, "Atomic record collapses concurrent captures into one row") + } + + // MARK: - Status Metrics Mapping + + @Test + func `NodeStatusMetrics(status:) maps the six per-type packet counters`() { + let status = RemoteNodeStatus( + layout: .repeater, + publicKeyPrefix: Data(repeating: 0x42, count: 6), + battery: 3800, + txQueueLength: 1, + noiseFloor: -110, + lastRSSI: -85, + packetsReceived: 2000, + packetsSent: 1000, + airtime: 500, + uptime: 3600, + sentFlood: 200, + sentDirect: 100, + receivedFlood: 400, + receivedDirect: 300, + fullEvents: 7, + lastSNR: 9.0, + directDuplicates: 11, + floodDuplicates: 22, + rxAirtime: 100, + receiveErrors: 3 + ) + + let metrics = NodeStatusMetrics(status: status) + + #expect(metrics.sentDirect == 100) + #expect(metrics.sentFlood == 200) + #expect(metrics.receivedDirect == 300) + #expect(metrics.receivedFlood == 400) + // Duplicate counters are `Int` on the wire; the metrics conversion clamps to UInt32. + #expect(metrics.directDuplicates == 11) + #expect(metrics.floodDuplicates == 22) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift index 8ffdb11b..d7a54425 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift @@ -1,122 +1,111 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("PairingCancellation") @MainActor struct PairingCancellationTests { - - /// When pairNewDevice is cancelled before the picker resolves, ASK has not - /// added the device — there's nothing to clean up. - @Test("cancellation before showPicker completes does not call removeAccessory") - func cancellationBeforePickerDoesNotRemove() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - - let pickerEntered = AsyncStream.makeStream() - let pickerGate = AsyncStream.makeStream() - mockASK.pickerEnteredSignal = pickerEntered.continuation - mockASK.pickerGate = pickerGate.stream - mockASK.setPickerResult(.success(UUID())) - - let pairTask = Task { try? await manager.pairNewDevice() } - - // Wait for the pair task to deterministically reach showPicker. - for await _ in pickerEntered.stream { break } - - pairTask.cancel() - pickerGate.continuation.finish() - - _ = await pairTask.value - - #expect(mockASK.removeAccessoryCallCount == 0) + /// When pairNewDevice is cancelled after ASK adds the device but before + /// connect(to:) finishes, cleanupPartialPairing must remove the bond from + /// ASK so iOS doesn't retain a paired accessory with no app-level state. + @Test + func `cancellation in connect(to:) phase removes accessory from ASK`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let deviceID = UUID() + mockASK.setPickerResult(.success(deviceID)) + mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) + // Persist a matching device row so the pre-picker stranded-association sweep skips + // this accessory; the count-one assertion then isolates the cancellation cleanup as + // the sole remover. + let store = manager.createStandalonePersistenceStore() + try await store.saveDevice(DeviceDTO.testDevice(id: deviceID)) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + // Pin the wait so we can deterministically cancel after the pair task + // has entered waitForOtherAppReconnection — i.e., past showPicker. + let waitEntered = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() + manager.otherAppWaitStrategyOverride = { _ in + waitEntered.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false } - /// When pairNewDevice is cancelled after ASK adds the device but before - /// connect(to:) finishes, cleanupPartialPairing must remove the bond from - /// ASK so iOS doesn't retain a paired accessory with no app-level state. - @Test("cancellation in connect(to:) phase removes accessory from ASK") - func cancellationInConnectPhaseRemoves() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - let deviceID = UUID() - mockASK.setPickerResult(.success(deviceID)) - mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) + let pairTask = Task { try? await manager.pairNewDevice() } - // Pin the wait so we can deterministically cancel after the pair task - // has entered waitForOtherAppReconnection — i.e., past showPicker. - let waitEntered = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitEntered.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } - - let pairTask = Task { try? await manager.pairNewDevice() } - - for await _ in waitEntered.stream { break } - - pairTask.cancel() - releaseWait.continuation.finish() - _ = await pairTask.value - - #expect(mockASK.removeAccessoryCallCount == 1) - #expect(mockASK.lastRemovedDeviceID == deviceID) + for await _ in waitEntered.stream { + break } - /// Regression test for the silent-cancellation hole in connect(to:): a - /// pairNewDevice task cancelled between picker dismiss and connect(to:) - /// returning successfully must NOT drive a real BLE connect to completion. - /// Without the entry-point `Task.checkCancellation()` in connect(to:), the - /// transport.connect() invocation would still fire (the inner awaits all - /// swallowed cancellation), leaving the user paired and connected to a - /// device they tried to abandon. - @Test("cancellation before connect(to:) bails before transport.connect runs") - func cancellationBeforeConnectBailsBeforeBLE() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - let mockTransport = env.transport - let deviceID = UUID() - mockASK.setPickerResult(.success(deviceID)) - mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - let waitEntered = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitEntered.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } + pairTask.cancel() + releaseWait.continuation.finish() + _ = await pairTask.value + + #expect(mockASK.removeAccessoryCallCount == 1) + #expect(mockASK.lastRemovedDeviceID == deviceID) + } + + /// Regression test for the silent-cancellation hole in connect(to:): a + /// pairNewDevice task cancelled between picker dismiss and connect(to:) + /// returning successfully must NOT drive a real BLE connect to completion. + /// Without the entry-point `Task.checkCancellation()` in connect(to:), the + /// transport.connect() invocation would still fire (the inner awaits all + /// swallowed cancellation), leaving the user paired and connected to a + /// device they tried to abandon. + @Test + func `cancellation before connect(to:) bails before transport.connect runs`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let mockTransport = env.transport + let deviceID = UUID() + mockASK.setPickerResult(.success(deviceID)) + mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) + // Persist a matching device row so the pre-picker stranded-association sweep skips + // this accessory; the count-one assertion then isolates the cancellation cleanup as + // the sole remover. + let store = manager.createStandalonePersistenceStore() + try await store.saveDevice(DeviceDTO.testDevice(id: deviceID)) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + let waitEntered = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() + manager.otherAppWaitStrategyOverride = { _ in + waitEntered.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false + } - let pairTask = Task { try? await manager.pairNewDevice() } + let pairTask = Task { try? await manager.pairNewDevice() } - for await _ in waitEntered.stream { break } + for await _ in waitEntered.stream { + break + } - pairTask.cancel() - releaseWait.continuation.finish() - _ = await pairTask.value + pairTask.cancel() + releaseWait.continuation.finish() + _ = await pairTask.value - let invocations = await mockTransport.connectInvocations - #expect(invocations.isEmpty, "Cancelled connect(to:) must not establish a BLE link") - #expect(mockASK.removeAccessoryCallCount == 1, "ASK bond must be cleaned up after cancellation") - } + let invocations = await mockTransport.connectInvocations + #expect(invocations.isEmpty, "Cancelled connect(to:) must not establish a BLE link") + #expect(mockASK.removeAccessoryCallCount == 1, "ASK bond must be cleaned up after cancellation") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PairingRaceIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingRaceIntegrationTests.swift index d61c8217..c8e2548b 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingRaceIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingRaceIntegrationTests.swift @@ -1,71 +1,74 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("PairingRaceIntegration") @MainActor struct PairingRaceIntegrationTests { + /// While `pairNewDevice` is suspended in `waitForOtherAppReconnection`, + /// `appDidBecomeActive` (or any other foreground transition that would + /// normally trigger an opportunistic reconnect) must not race the pairing + /// flow's `connect(to:)`. The fix: `shouldDeferOpportunisticReconnect` + /// gates `attemptOpportunisticReconnect` for the duration of the pair flow. + /// + /// This test pins `pairNewDevice` in the wait via the strategy override, + /// drives `checkBLEConnectionHealth` (the foreground reconnect path) + /// concurrently, then asserts the transport never received a connect call + /// for the previously-connected device. + @Test + func `opportunistic reconnect to old device is gated while pairing is suspended in waitForOtherAppReconnection`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockTransport = env.transport + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() - /// While `pairNewDevice` is suspended in `waitForOtherAppReconnection`, - /// `appDidBecomeActive` (or any other foreground transition that would - /// normally trigger an opportunistic reconnect) must not race the pairing - /// flow's `connect(to:)`. The fix: `shouldDeferOpportunisticReconnect` - /// gates `attemptOpportunisticReconnect` for the duration of the pair flow. - /// - /// This test pins `pairNewDevice` in the wait via the strategy override, - /// drives `checkBLEConnectionHealth` (the foreground reconnect path) - /// concurrently, then asserts the transport never received a connect call - /// for the previously-connected device. - @Test("opportunistic reconnect to old device is gated while pairing is suspended in waitForOtherAppReconnection") - func opportunisticReconnectGatedDuringPairingWait() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockTransport = env.transport - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) + mockASK.setPickerResult(.success(newDeviceID)) - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) - let waitStarted = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() + let waitStarted = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitStarted.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } + manager.otherAppWaitStrategyOverride = { _ in + waitStarted.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false + } - let pairTask = Task { try? await manager.pairNewDevice() } + let pairTask = Task { try? await manager.pairNewDevice() } - // Yield to ensure pairTask has scheduled and reached the wait strategy - // before we drive the racer. Without this yield, scheduler ordering - // determines whether the racer fires before or after the gate engages. - await Task.yield() - for await _ in waitStarted.stream { break } + // Yield to ensure pairTask has scheduled and reached the wait strategy + // before we drive the racer. Without this yield, scheduler ordering + // determines whether the racer fires before or after the gate engages. + await Task.yield() + for await _ in waitStarted.stream { + break + } - await manager.checkBLEConnectionHealth() + await manager.checkBLEConnectionHealth() - let invocations = await mockTransport.connectInvocations - let invocationsForOld = invocations.filter { $0.deviceID == oldDeviceID } - #expect( - invocationsForOld.isEmpty, - "Opportunistic reconnect to old device should be gated while pairing is in progress" - ) + let invocations = await mockTransport.connectInvocations + let invocationsForOld = invocations.filter { $0.deviceID == oldDeviceID } + #expect( + invocationsForOld.isEmpty, + "Opportunistic reconnect to old device should be gated while pairing is in progress" + ) - releaseWait.continuation.finish() - // Don't await pairTask — the connect(to:) path needs a fully wired transport - // to make progress, and the mock intentionally stalls to keep the test focused - // on the gate behavior. Cancel it so it unwinds promptly. - pairTask.cancel() - _ = await pairTask.value - } + releaseWait.continuation.finish() + // Don't await pairTask — the connect(to:) path needs a fully wired transport + // to make progress, and the mock intentionally stalls to keep the test focused + // on the gate behavior. Cancel it so it unwinds promptly. + pairTask.cancel() + _ = await pairTask.value + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift new file mode 100644 index 00000000..1d61f45c --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift @@ -0,0 +1,165 @@ +import Foundation +@testable import MC1Services +import Testing + +/// Fresh pairing registers a system association before the app-level GATT handshake +/// runs. When that handshake fails (a wrong PIN surfaces as `authenticationFailed`), +/// the connect arm no longer removes the association: removal stays on explicit user +/// intent so the system confirmation dialog never appears out of context. The stranded +/// association is instead swept at the start of the next pairing attempt, when the user +/// is actively pairing, and only when it maps to no saved device. +@Suite("Pairing defers auth-failure removal and heals stranded associations") +@MainActor +struct PairingStrandedAssociationTests { + @Test + func `fresh-pair authentication failure removes no association and reports connectionFailed`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let deviceID = UUID() + + // Register the association and persist a matching device row: the pre-picker + // sweep treats it as a saved radio and leaves the accessory in place, so only a + // reintroduced auth-arm cleanup could remove it. The count-zero assertion below + // would then fail, guarding against that regression. + let store = manager.createStandalonePersistenceStore() + try await store.saveDevice(DeviceDTO.testDevice(id: deviceID)) + mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) + + mockASK.setPickerResult(.success(deviceID)) + // Reaching the transport requires the registry check to be skipped, as on the + // macOS shape; the transport then fails auth the way a wrong PIN does. + mockASK.isSessionActive = false + await env.transport.setConnectError(BLEError.authenticationFailed) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.otherAppWaitStrategyOverride = { _ in false } + + try await #expect { + try await manager.pairNewDevice() + } throws: { error in + guard let pairingError = error as? PairingError else { return false } + return pairingError.isAuthenticationFailure && pairingError.deviceID == deviceID + } + + // The auth arm rethrows only; it never summons the system removal dialog. + #expect(mockASK.removeAccessoryCallCount == 0) + } + + /// A transient connect failure (radio briefly out of range) is likewise left for the + /// explicit recovery path; the connect arm removes nothing on its own. + @Test + func `transient connect failure during pairing removes no association`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let deviceID = UUID() + + mockASK.setPickerResult(.success(deviceID)) + mockASK.isSessionActive = false + await env.transport.setConnectError(BLEError.connectionFailed("out of range")) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.otherAppWaitStrategyOverride = { _ in false } + + try await #expect { + try await manager.pairNewDevice() + } throws: { error in + guard let pairingError = error as? PairingError else { return false } + return !pairingError.isAuthenticationFailure && pairingError.deviceID == deviceID + } + + #expect(mockASK.removeAccessoryCallCount == 0) + } + + @Test + func `pairing removes a stranded association with no device record before showing the picker`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let strandedID = UUID() + + mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: strandedID, displayName: "stranded")]) + // Dismiss the picker so the assertion isolates the pre-picker sweep. + mockASK.setPickerResult(.failure(AccessorySetupKitError.pickerDismissed)) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + await #expect(throws: DevicePairingError.self) { + try await manager.pairNewDevice() + } + + #expect(mockASK.removeAccessoryCallCount == 1) + #expect(mockASK.lastRemovedDeviceID == strandedID) + } + + @Test + func `a stranded association whose removal is declined still lets pairing reach the picker`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let strandedID = UUID() + + mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: strandedID, displayName: "stranded")]) + // The user declines the system removal confirmation, so the removal throws. + mockASK.removeAccessoryError = AccessorySetupKitError.connectionFailed + mockASK.setPickerResult(.failure(AccessorySetupKitError.pickerDismissed)) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + // A declined removal must not abort pairing: the flow still reaches the picker, + // which here dismisses and surfaces cancellation as a DevicePairingError. + await #expect(throws: DevicePairingError.self) { + try await manager.pairNewDevice() + } + + #expect(mockASK.removeAccessoryCallCount == 1) + } + + @Test + func `an association matching a saved device is never removed by the heal`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let savedID = UUID() + + let store = manager.createStandalonePersistenceStore() + try await store.saveDevice(DeviceDTO.testDevice(id: savedID)) + + mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: savedID, displayName: "saved")]) + mockASK.setPickerResult(.failure(AccessorySetupKitError.pickerDismissed)) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + await #expect(throws: DevicePairingError.self) { + try await manager.pairNewDevice() + } + + #expect(mockASK.removeAccessoryCallCount == 0) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/PairingWhileConnectedTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingWhileConnectedTests.swift index f10a9796..923eea83 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingWhileConnectedTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingWhileConnectedTests.swift @@ -1,218 +1,227 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("PairingWhileConnected") @MainActor struct PairingWhileConnectedTests { + /// iOS auto-reconnect for the previously-connected radio can fire 0–~3 seconds + /// after ASK severance, landing during pairNewDevice's waitForOtherAppReconnection + /// poll. Without the auto-reconnect-handler gate, that handler would claim the + /// state machine via `reconnectionCoordinator.handleEnteringAutoReconnect` and + /// starve the pairing's subsequent `connect(to: newDeviceID)`. + /// + /// This test starts a real pairNewDevice flow, pins it in the wait override, + /// then simulates iOS auto-reconnect for the old device firing. The handler + /// must observe `shouldDeferOpportunisticReconnect`, run the old-device + /// session teardown via `handleConnectionLoss`, and return without claiming + /// the coordinator. + @Test + func `auto-reconnect during waitForOtherAppReconnection tears down old-device session without claiming coordinator`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let stateMachine = env.stateMachine + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() + + mockASK.setPickerResult(.success(newDeviceID)) + + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .ready, + connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + let waitStarted = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() + manager.otherAppWaitStrategyOverride = { _ in + waitStarted.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false + } + + try await waitUntil("auto-reconnect handler should be installed") { + await stateMachine.hasAutoReconnectingHandler + } + + let pairTask = Task { try? await manager.pairNewDevice() } + + await Task.yield() + for await _ in waitStarted.stream { + break + } + + await stateMachine.simulateAutoReconnecting(deviceID: oldDeviceID) - /// iOS auto-reconnect for the previously-connected radio can fire 0–~3 seconds - /// after ASK severance, landing during pairNewDevice's waitForOtherAppReconnection - /// poll. Without the auto-reconnect-handler gate, that handler would claim the - /// state machine via `reconnectionCoordinator.handleEnteringAutoReconnect` and - /// starve the pairing's subsequent `connect(to: newDeviceID)`. - /// - /// This test starts a real pairNewDevice flow, pins it in the wait override, - /// then simulates iOS auto-reconnect for the old device firing. The handler - /// must observe `shouldDeferOpportunisticReconnect`, run the old-device - /// session teardown via `handleConnectionLoss`, and return without claiming - /// the coordinator. - @Test("auto-reconnect during waitForOtherAppReconnection tears down old-device session without claiming coordinator") - func autoReconnectDuringWaitDoesNotClaimCoordinator() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let stateMachine = env.stateMachine - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) - - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .ready, - connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - let waitStarted = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitStarted.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } - - try await waitUntil("auto-reconnect handler should be installed") { - await stateMachine.hasAutoReconnectingHandler - } - - let pairTask = Task { try? await manager.pairNewDevice() } - - await Task.yield() - for await _ in waitStarted.stream { break } - - await stateMachine.simulateAutoReconnecting(deviceID: oldDeviceID) - - // The suppression branch routes through handleConnectionLoss to clear the - // stale old-device session. Observing the state transition and connectedDevice - // clear is the proof the handler ran without claiming the coordinator. - try await waitUntil("suppression branch should tear down old-device session") { - manager.connectionState == .disconnected && manager.connectedDevice == nil - } - - #expect(manager.activeReconnectDeviceID == nil) - #expect(manager.connectedDevice == nil) - - releaseWait.continuation.finish() - pairTask.cancel() - _ = await pairTask.value + // The suppression branch routes through handleConnectionLoss to clear the + // stale old-device session. Observing the state transition and connectedDevice + // clear is the proof the handler ran without claiming the coordinator. + try await waitUntil("suppression branch should tear down old-device session") { + manager.connectionState == .disconnected && manager.connectedDevice == nil } - /// Companion to the entry-suppression test above. The entry handler being - /// suppressed leaves `reconnectingDeviceID` as nil. A late completion for the - /// old device must be rejected by the coordinator's claim guard — otherwise - /// `rebuildSession(oldDeviceID)` would race the new pairing's `connect(to: newDeviceID)`, - /// reading the new device's traffic against the old device's identity. - @Test("auto-reconnect completion during pair-wait does not run rebuildSession") - func autoReconnectCompletionDuringWaitDoesNotRebuild() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockTransport = env.transport - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) - - // Pre-pairing: previously connected to the old device; entry was suppressed - // during ASK pairing severance, so connectionState is .disconnected and no - // claim exists in the coordinator. - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - let waitStarted = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitStarted.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } - - try await waitUntil("reconnection handler should be installed") { - await mockTransport.hasReconnectionHandler - } - - let pairTask = Task { try? await manager.pairNewDevice() } - - await Task.yield() - for await _ in waitStarted.stream { break } - - // iOS auto-reconnect for the old device completes during the wait. Without - // the claim guard this would set state to .connecting and call - // rebuildSession(oldDeviceID). - await mockTransport.simulateReconnection(deviceID: oldDeviceID) - - // Drain the dispatched @MainActor Task. With the claim guard the path returns - // synchronously after the guard check, so a few yields are sufficient. - // Without the guard the buggy path sets state to .connecting before its first - // await, which is also visible after the same yields. - for _ in 0..<5 { await Task.yield() } - - #expect(manager.connectionState == .disconnected, "Late completion must not transition state") - - releaseWait.continuation.finish() - pairTask.cancel() - _ = await pairTask.value + #expect(manager.activeReconnectDeviceID == nil) + #expect(manager.connectedDevice == nil) + + releaseWait.continuation.finish() + pairTask.cancel() + _ = await pairTask.value + } + + /// Companion to the entry-suppression test above. The entry handler being + /// suppressed leaves `reconnectingDeviceID` as nil. A late completion for the + /// old device must be rejected by the coordinator's claim guard — otherwise + /// `rebuildSession(oldDeviceID)` would race the new pairing's `connect(to: newDeviceID)`, + /// reading the new device's traffic against the old device's identity. + @Test + func `auto-reconnect completion during pair-wait does not run rebuildSession`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockTransport = env.transport + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() + + mockASK.setPickerResult(.success(newDeviceID)) + + // Pre-pairing: previously connected to the old device; entry was suppressed + // during ASK pairing severance, so connectionState is .disconnected and no + // claim exists in the coordinator. + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + let waitStarted = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() + manager.otherAppWaitStrategyOverride = { _ in + waitStarted.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false } - /// pairNewDevice routes through `connect(to:)`'s switch-device branch when an - /// old radio is still connected. Without the catch in `switchDevice`, a mid- - /// switch throw propagates without resetting `connectionState`, `connectedDevice`, - /// or `services` — leaving the UI on `.ready` pointing at the old device while - /// the failure-alert recovery actions act on stale state. - /// - /// The mock ASK starts with empty `pairedAccessories`, so `switchDevice`'s ASK - /// validation throws `ConnectionError.deviceNotFound` for the new device. That - /// throw must drive a clean teardown. - @Test("pair-while-connected with switchDevice throw leaves state .disconnected") - func switchDeviceFailureLeavesCleanState() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) - - // Skip the polling wait so the test exercises the connect/switch path directly. - manager.otherAppWaitStrategyOverride = { _ in false } - - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .ready, - connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - await #expect(throws: PairingError.self) { - try await manager.pairNewDevice() - } - - #expect(manager.connectionState == .disconnected, "switchDevice catch must reset state") - #expect(manager.connectedDevice == nil, "switchDevice catch must clear stale device") - #expect(manager.services == nil, "switchDevice catch must clear stale services") + try await waitUntil("reconnection handler should be installed") { + await mockTransport.hasReconnectionHandler } - /// `cleanupConnection` in switchDevice's catch resets state without firing - /// `onConnectionLost`, so AppState observers — and the old radio's Live Activity — - /// would stay stranded on the prior device's connected state. The catch must route - /// through `onConnectionLost` like the transport-loss and reconnect-failure paths. - @Test("failed switchDevice fires onConnectionLost so observers tear down") - func switchDeviceFailureFiresConnectionLost() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) - manager.otherAppWaitStrategyOverride = { _ in false } - - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .ready, - connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - let tracker = SwitchFailureLostTracker() - manager.onConnectionLost = { await tracker.markConnectionLost() } - - await #expect(throws: PairingError.self) { - try await manager.pairNewDevice() - } - - let wasCalled = await tracker.connectionLostCalled - #expect(wasCalled, "switchDevice catch must fire onConnectionLost so AppState retires the orphaned Live Activity") + let pairTask = Task { try? await manager.pairNewDevice() } + + await Task.yield() + for await _ in waitStarted.stream { + break + } + + // iOS auto-reconnect for the old device completes during the wait. Without + // the claim guard this would set state to .connecting and call + // rebuildSession(oldDeviceID). + await mockTransport.simulateReconnection(deviceID: oldDeviceID) + + // Drain the dispatched @MainActor Task. With the claim guard the path returns + // synchronously after the guard check, so a few yields are sufficient. + // Without the guard the buggy path sets state to .connecting before its first + // await, which is also visible after the same yields. + for _ in 0..<5 { + await Task.yield() + } + + #expect(manager.connectionState == .disconnected, "Late completion must not transition state") + + releaseWait.continuation.finish() + pairTask.cancel() + _ = await pairTask.value + } + + /// pairNewDevice routes through `connect(to:)`'s switch-device branch when an + /// old radio is still connected. Without the catch in `switchDevice`, a mid- + /// switch throw propagates without resetting `connectionState`, `connectedDevice`, + /// or `services` — leaving the UI on `.ready` pointing at the old device while + /// the failure-alert recovery actions act on stale state. + /// + /// The mock ASK starts with empty `pairedAccessories`, so `switchDevice`'s ASK + /// validation throws `ConnectionError.deviceNotFound` for the new device. That + /// throw must drive a clean teardown. + @Test + func `pair-while-connected with switchDevice throw leaves state .disconnected`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() + + mockASK.setPickerResult(.success(newDeviceID)) + + // Skip the polling wait so the test exercises the connect/switch path directly. + manager.otherAppWaitStrategyOverride = { _ in false } + + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .ready, + connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + await #expect(throws: PairingError.self) { + try await manager.pairNewDevice() } + + #expect(manager.connectionState == .disconnected, "switchDevice catch must reset state") + #expect(manager.connectedDevice == nil, "switchDevice catch must clear stale device") + #expect(manager.services == nil, "switchDevice catch must clear stale services") + } + + /// `cleanupConnection` in switchDevice's catch resets state without firing + /// `onConnectionLost`, so AppState observers — and the old radio's Live Activity — + /// would stay stranded on the prior device's connected state. The catch must route + /// through `onConnectionLost` like the transport-loss and reconnect-failure paths. + @Test + func `failed switchDevice fires onConnectionLost so observers tear down`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() + + mockASK.setPickerResult(.success(newDeviceID)) + manager.otherAppWaitStrategyOverride = { _ in false } + + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .ready, + connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + let tracker = SwitchFailureLostTracker() + manager.onConnectionLost = { await tracker.markConnectionLost() } + + await #expect(throws: PairingError.self) { + try await manager.pairNewDevice() + } + + let wasCalled = await tracker.connectionLostCalled + #expect(wasCalled, "switchDevice catch must fire onConnectionLost so AppState retires the orphaned Live Activity") + } } private actor SwitchFailureLostTracker { - var connectionLostCalled = false + var connectionLostCalled = false - func markConnectionLost() { - connectionLostCalled = true - } + func markConnectionLost() { + connectionLostCalled = true + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PendingSendPersistenceTests.swift b/MC1Services/Tests/MC1ServicesTests/PendingSendPersistenceTests.swift index 17fec9ee..32e47836 100644 --- a/MC1Services/Tests/MC1ServicesTests/PendingSendPersistenceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PendingSendPersistenceTests.swift @@ -1,415 +1,414 @@ -import Testing import Foundation -import SwiftData @testable import MC1Services +import SwiftData +import Testing @MainActor struct PendingSendPersistenceTests { - - @Test("PendingSendDTO round-trips through Model and back") - func dtoRoundTrip() async throws { - let store = try makeStore() - let radioID = UUID() - let dto = PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: UUID(), - kind: .channel, - contactID: nil, - channelIndex: 3, - isResend: true, - messageText: "test", - messageTimestamp: 1_700_000_000, - localNodeName: "Alice", - sequence: 1, - enqueuedAt: Date(timeIntervalSince1970: 1_700_000_000) - ) - - try await store.upsertPendingSend(dto) - let fetched = try await store.fetchPendingSends(radioID: radioID) - - #expect(fetched.count == 1) - #expect(fetched.first == dto) - } - - @Test("Pending sends are scoped by radioID") - func radioScoping() async throws { - let store = try makeStore() - let radioA = UUID() - let radioB = UUID() - try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 1)) - try await store.upsertPendingSend(.fixture(radioID: radioB, sequence: 1)) - - let aRows = try await store.fetchPendingSends(radioID: radioA) - let bRows = try await store.fetchPendingSends(radioID: radioB) - - #expect(aRows.count == 1) - #expect(bRows.count == 1) - #expect(aRows.first?.radioID == radioA) - #expect(bRows.first?.radioID == radioB) - } - - @Test("fetchPendingSends returns rows in sequence order") - func sequenceOrdering() async throws { - let store = try makeStore() - let radioID = UUID() - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 3)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 2)) - - let rows = try await store.fetchPendingSends(radioID: radioID) - - #expect(rows.map(\.sequence) == [1, 2, 3]) - } - - @Test("deletePendingSend removes the row") - func deletion() async throws { - let store = try makeStore() - let radioID = UUID() - let dto = PendingSendDTO.fixture(radioID: radioID, sequence: 1) - try await store.upsertPendingSend(dto) - try await store.deletePendingSend(id: dto.id) - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.isEmpty) - } - - @Test("insertPendingSendAssigningSequence returns 1 on an empty table") - func assignSequenceOnEmptyTable() async throws { - let store = try makeStore() - let radioID = UUID() - let dto = PendingSendDTO.fixture(radioID: radioID, sequence: 0) - - let assigned = try await store.insertPendingSendAssigningSequence(dto) - - #expect(assigned == 1) - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1) - #expect(rows.first?.sequence == 1) - } - - @Test("insertPendingSendAssigningSequence assigns per-radio monotonic sequences") - func assignSequenceMonotonicPerRadio() async throws { - let store = try makeStore() - let radioA = UUID() - let radioB = UUID() - - let firstA = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioA, sequence: 0)) - let secondA = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioA, sequence: 0)) - let firstB = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioB, sequence: 0)) - - #expect(firstA == 1) - #expect(secondA == 2) - #expect(firstB == 1, "Sequence numbering is per-radio") - } - - @Test("Concurrent inserts on the same radio produce distinct sequences") - func assignSequenceConcurrentInserts() async throws { - let store = try makeStore() - let radioID = UUID() - let dtoA = PendingSendDTO.fixture(radioID: radioID, sequence: 0) - let dtoB = PendingSendDTO.fixture(radioID: radioID, sequence: 0) - - async let seqA = store.insertPendingSendAssigningSequence(dtoA) - async let seqB = store.insertPendingSendAssigningSequence(dtoB) - let assigned = try await [seqA, seqB].sorted() - - #expect(assigned == [1, 2], "Serial @ModelActor isolation must produce distinct sequences") - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 2) - #expect(Set(rows.map(\.sequence)) == [1, 2]) - } - - /// `attemptCount` rides through `insertPendingSendAssigningSequence` → - /// `fetchPendingSends` (DTO → @Model → DTO) without normalization or - /// loss. The convenience initializer `PendingSend(dto:)` must forward - /// the value verbatim — per CLAUDE.md "Backup and restore", a stored - /// field that distinguishes pre-migration rows (`nil`) from current-build - /// rows (`0` or positive) must not collapse through a normalizing - /// accessor at materialization time. Round-tripping each of the three - /// distinguishable states verifies the column survives intact. - @Test("attemptCount round-trips through insertPendingSendAssigningSequence and fetchPendingSends") - func attemptCountRoundTripsThroughInsertAndFetch() async throws { - let store = try makeStore() - let radioID = UUID() - - func dto(attemptCount: Int?, messageID: UUID) -> PendingSendDTO { - PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: messageID, - kind: .dm, - contactID: UUID(), - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: 0, - enqueuedAt: Date(), - attemptCount: attemptCount - ) - } - - let legacyID = UUID() - let raceWindowID = UUID() - let drainedID = UUID() - _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: nil, messageID: legacyID)) - _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: 0, messageID: raceWindowID)) - _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: 4, messageID: drainedID)) - - let rows = try await store.fetchPendingSends(radioID: radioID) - let legacyRow = try #require(rows.first(where: { $0.messageID == legacyID })) - let raceWindowRow = try #require(rows.first(where: { $0.messageID == raceWindowID })) - let drainedRow = try #require(rows.first(where: { $0.messageID == drainedID })) - - #expect(legacyRow.attemptCount == nil, - "pre-migration row stored as nil must come back as nil — the field remains optional in storage") - #expect(raceWindowRow.attemptCount == 0, - "current-build race-window row stored as 0 must come back as 0") - #expect(drainedRow.attemptCount == 4, - "positive attemptCount must round-trip without normalization") - } - - @Test("deletePendingSendsForMessage removes every row matching the messageID") - func deletePendingSendsForMessageRemovesMatchingRows() async throws { - let store = try makeStore() - let radioA = UUID() - let radioB = UUID() - let sharedMessageID = UUID() - let otherMessageID = UUID() - - try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 1, messageID: sharedMessageID)) - try await store.upsertPendingSend(.fixture(radioID: radioB, sequence: 1, messageID: sharedMessageID)) - try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 2, messageID: otherMessageID)) - - try await store.deletePendingSendsForMessage(messageID: sharedMessageID) - - let rowsA = try await store.fetchPendingSends(radioID: radioA) - let rowsB = try await store.fetchPendingSends(radioID: radioB) - #expect(rowsA.count == 1) - #expect(rowsA.first?.messageID == otherMessageID) - #expect(rowsB.isEmpty) - } - - @Test("deletePendingSendsForMessage is a no-op when no rows match") - func deletePendingSendsForMessageNoMatch() async throws { - let store = try makeStore() - let radioID = UUID() - let messageID = UUID() - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) - - try await store.deletePendingSendsForMessage(messageID: UUID()) - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1) - #expect(rows.first?.messageID == messageID) + @Test + func `PendingSendDTO round-trips through Model and back`() async throws { + let store = try makeStore() + let radioID = UUID() + let dto = PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: UUID(), + kind: .channel, + contactID: nil, + channelIndex: 3, + isResend: true, + messageText: "test", + messageTimestamp: 1_700_000_000, + localNodeName: "Alice", + sequence: 1, + enqueuedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + + try await store.upsertPendingSend(dto) + let fetched = try await store.fetchPendingSends(radioID: radioID) + + #expect(fetched.count == 1) + #expect(fetched.first == dto) + } + + @Test + func `Pending sends are scoped by radioID`() async throws { + let store = try makeStore() + let radioA = UUID() + let radioB = UUID() + try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 1)) + try await store.upsertPendingSend(.fixture(radioID: radioB, sequence: 1)) + + let aRows = try await store.fetchPendingSends(radioID: radioA) + let bRows = try await store.fetchPendingSends(radioID: radioB) + + #expect(aRows.count == 1) + #expect(bRows.count == 1) + #expect(aRows.first?.radioID == radioA) + #expect(bRows.first?.radioID == radioB) + } + + @Test + func `fetchPendingSends returns rows in sequence order`() async throws { + let store = try makeStore() + let radioID = UUID() + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 3)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 2)) + + let rows = try await store.fetchPendingSends(radioID: radioID) + + #expect(rows.map(\.sequence) == [1, 2, 3]) + } + + @Test + func `deletePendingSend removes the row`() async throws { + let store = try makeStore() + let radioID = UUID() + let dto = PendingSendDTO.fixture(radioID: radioID, sequence: 1) + try await store.upsertPendingSend(dto) + try await store.deletePendingSend(id: dto.id) + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.isEmpty) + } + + @Test + func `insertPendingSendAssigningSequence returns 1 on an empty table`() async throws { + let store = try makeStore() + let radioID = UUID() + let dto = PendingSendDTO.fixture(radioID: radioID, sequence: 0) + + let assigned = try await store.insertPendingSendAssigningSequence(dto) + + #expect(assigned == 1) + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1) + #expect(rows.first?.sequence == 1) + } + + @Test + func `insertPendingSendAssigningSequence assigns per-radio monotonic sequences`() async throws { + let store = try makeStore() + let radioA = UUID() + let radioB = UUID() + + let firstA = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioA, sequence: 0)) + let secondA = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioA, sequence: 0)) + let firstB = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioB, sequence: 0)) + + #expect(firstA == 1) + #expect(secondA == 2) + #expect(firstB == 1, "Sequence numbering is per-radio") + } + + @Test + func `Concurrent inserts on the same radio produce distinct sequences`() async throws { + let store = try makeStore() + let radioID = UUID() + let dtoA = PendingSendDTO.fixture(radioID: radioID, sequence: 0) + let dtoB = PendingSendDTO.fixture(radioID: radioID, sequence: 0) + + async let seqA = store.insertPendingSendAssigningSequence(dtoA) + async let seqB = store.insertPendingSendAssigningSequence(dtoB) + let assigned = try await [seqA, seqB].sorted() + + #expect(assigned == [1, 2], "Serial @ModelActor isolation must produce distinct sequences") + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 2) + #expect(Set(rows.map(\.sequence)) == [1, 2]) + } + + /// `attemptCount` rides through `insertPendingSendAssigningSequence` → + /// `fetchPendingSends` (DTO → @Model → DTO) without normalization or + /// loss. The convenience initializer `PendingSend(dto:)` must forward + /// the value verbatim — per CLAUDE.md "Backup and restore", a stored + /// field that distinguishes pre-migration rows (`nil`) from current-build + /// rows (`0` or positive) must not collapse through a normalizing + /// accessor at materialization time. Round-tripping each of the three + /// distinguishable states verifies the column survives intact. + @Test + func `attemptCount round-trips through insertPendingSendAssigningSequence and fetchPendingSends`() async throws { + let store = try makeStore() + let radioID = UUID() + + func dto(attemptCount: Int?, messageID: UUID) -> PendingSendDTO { + PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: messageID, + kind: .dm, + contactID: UUID(), + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: 0, + enqueuedAt: Date(), + attemptCount: attemptCount + ) } - @Test("fetchPendingSends skips rows whose kindRawValue is unknown") - func fetchSkipsUnknownKindRows() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let radioID = UUID() - - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1)) - - let futureCaseRow = PendingSend( - id: UUID(), - radioID: radioID, - messageID: UUID(), - kindRawValue: 99, - contactID: nil, - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: 2, - enqueuedAt: Date() - ) - container.mainContext.insert(futureCaseRow) - try container.mainContext.save() - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1, "Unknown-kind row must be filtered out") - #expect(rows.first?.sequence == 1) - } - - /// `replacePendingSendForRetry` happy path: a `.failed` row is flipped - /// to `.pending`, the prior `PendingSend` for the same `messageID` is - /// gone, and a fresh `PendingSend` row exists with the supplied DTO - /// fields and a sequence assigned per-radio. - @Test("replacePendingSendForRetry flips status and inserts a new PendingSend row") - func replacePendingSendForRetry_FlipsStatusAndInsertsRow() async throws { - let store = try makeStore() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - - try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) - - let newDTO = PendingSendDTO( - envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), - radioID: radioID - ) - let assigned = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) - - let refreshed = try #require(try await store.fetchMessage(id: messageID)) - #expect(refreshed.status == .pending, "Status must flip to .pending") - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1, "Old PendingSend row must be replaced, not duplicated") - let row = try #require(rows.first) - #expect(row.id == newDTO.id, "Stored row id must match the new DTO") - #expect(row.messageID == messageID) - #expect(row.kind == .dm) - #expect(row.contactID == contactID) - #expect(row.sequence == assigned, "Assigned sequence must match the persisted row") - #expect(assigned == 1, "Replacing the only row for the radio starts fresh at 1") - } - - /// `.delivered` is the race-won state: a late ACK that landed while - /// the user was reaching for retry must not be clobbered back to - /// `.pending`. The `PendingSend` row is still inserted — the queue's - /// drain-time gate handles the no-op send. - @Test("replacePendingSendForRetry preserves .delivered status") - func replacePendingSendForRetry_PreservesDelivered() async throws { - let store = try makeStore() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - - try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .delivered)) - - let newDTO = PendingSendDTO( - envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), - radioID: radioID - ) - _ = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) - - let refreshed = try #require(try await store.fetchMessage(id: messageID)) - #expect(refreshed.status == .delivered, ".delivered must not be downgraded by retry") - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1, "PendingSend row is still inserted; drain gate handles no-op") - } - - /// All prior PendingSend rows that match the messageID are removed. - /// Older runs that may have racked up multiple PendingSend rows on - /// the same messageID across reconnects must not survive the retry. - @Test("replacePendingSendForRetry removes every existing PendingSend row for the messageID") - func replacePendingSendForRetry_RemovesExistingPendingSendRow() async throws { - let store = try makeStore() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - - try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 2, messageID: messageID)) - let unrelatedMessageID = UUID() - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 3, messageID: unrelatedMessageID)) - - let newDTO = PendingSendDTO( - envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), - radioID: radioID - ) - _ = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 2, "Unrelated row survives; both prior rows for the retried messageID are gone") - let retried = try #require(rows.first(where: { $0.messageID == messageID })) - #expect(retried.id == newDTO.id) - let unrelated = try #require(rows.first(where: { $0.messageID == unrelatedMessageID })) - #expect(unrelated.sequence == 3, "Unrelated row keeps its prior sequence") - } - - /// Sequence assignment matches `insertPendingSendAssigningSequence`: - /// the new row gets `max(sequence) + 1` for the radio. Verifies the - /// per-radio monotonic invariant survives the delete-then-insert - /// transaction. - @Test("replacePendingSendForRetry assigns the next per-radio sequence") - func replacePendingSendForRetry_AssignsNextSequence() async throws { - let store = try makeStore() - let radioID = UUID() - let otherRadioID = UUID() - let messageID = UUID() - let contactID = UUID() - - try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 7, messageID: UUID())) - try await store.upsertPendingSend(.fixture(radioID: otherRadioID, sequence: 99, messageID: UUID())) - - let newDTO = PendingSendDTO( - envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), - radioID: radioID - ) - let assigned = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) - - #expect(assigned == 8, "Sequence advances from the highest live value for this radio") - let rows = try await store.fetchPendingSends(radioID: radioID) - let inserted = try #require(rows.first(where: { $0.messageID == messageID })) - #expect(inserted.sequence == 8) - } - - private func makeOutgoingDM( - id: UUID, - radioID: UUID, - contactID: UUID, - status: MessageStatus - ) -> MessageDTO { - MessageDTO( - id: id, - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: "retry me", - timestamp: 1_700_000_000, - createdAt: Date(timeIntervalSince1970: 1_700_000_000), - direction: .outgoing, - status: status, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: true, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - private func makeStore() throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } + let legacyID = UUID() + let raceWindowID = UUID() + let drainedID = UUID() + _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: nil, messageID: legacyID)) + _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: 0, messageID: raceWindowID)) + _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: 4, messageID: drainedID)) + + let rows = try await store.fetchPendingSends(radioID: radioID) + let legacyRow = try #require(rows.first(where: { $0.messageID == legacyID })) + let raceWindowRow = try #require(rows.first(where: { $0.messageID == raceWindowID })) + let drainedRow = try #require(rows.first(where: { $0.messageID == drainedID })) + + #expect(legacyRow.attemptCount == nil, + "pre-migration row stored as nil must come back as nil — the field remains optional in storage") + #expect(raceWindowRow.attemptCount == 0, + "current-build race-window row stored as 0 must come back as 0") + #expect(drainedRow.attemptCount == 4, + "positive attemptCount must round-trip without normalization") + } + + @Test + func `deletePendingSendsForMessage removes every row matching the messageID`() async throws { + let store = try makeStore() + let radioA = UUID() + let radioB = UUID() + let sharedMessageID = UUID() + let otherMessageID = UUID() + + try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 1, messageID: sharedMessageID)) + try await store.upsertPendingSend(.fixture(radioID: radioB, sequence: 1, messageID: sharedMessageID)) + try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 2, messageID: otherMessageID)) + + try await store.deletePendingSendsForMessage(messageID: sharedMessageID) + + let rowsA = try await store.fetchPendingSends(radioID: radioA) + let rowsB = try await store.fetchPendingSends(radioID: radioB) + #expect(rowsA.count == 1) + #expect(rowsA.first?.messageID == otherMessageID) + #expect(rowsB.isEmpty) + } + + @Test + func `deletePendingSendsForMessage is a no-op when no rows match`() async throws { + let store = try makeStore() + let radioID = UUID() + let messageID = UUID() + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) + + try await store.deletePendingSendsForMessage(messageID: UUID()) + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1) + #expect(rows.first?.messageID == messageID) + } + + @Test + func `fetchPendingSends skips rows whose kindRawValue is unknown`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1)) + + let futureCaseRow = PendingSend( + id: UUID(), + radioID: radioID, + messageID: UUID(), + kindRawValue: 99, + contactID: nil, + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: 2, + enqueuedAt: Date() + ) + container.mainContext.insert(futureCaseRow) + try container.mainContext.save() + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1, "Unknown-kind row must be filtered out") + #expect(rows.first?.sequence == 1) + } + + /// `replacePendingSendForRetry` happy path: a `.failed` row is flipped + /// to `.pending`, the prior `PendingSend` for the same `messageID` is + /// gone, and a fresh `PendingSend` row exists with the supplied DTO + /// fields and a sequence assigned per-radio. + @Test + func `replacePendingSendForRetry flips status and inserts a new PendingSend row`() async throws { + let store = try makeStore() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + + try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) + + let newDTO = PendingSendDTO( + envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), + radioID: radioID + ) + let assigned = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) + + let refreshed = try #require(try await store.fetchMessage(id: messageID)) + #expect(refreshed.status == .pending, "Status must flip to .pending") + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1, "Old PendingSend row must be replaced, not duplicated") + let row = try #require(rows.first) + #expect(row.id == newDTO.id, "Stored row id must match the new DTO") + #expect(row.messageID == messageID) + #expect(row.kind == .dm) + #expect(row.contactID == contactID) + #expect(row.sequence == assigned, "Assigned sequence must match the persisted row") + #expect(assigned == 1, "Replacing the only row for the radio starts fresh at 1") + } + + /// `.delivered` is the race-won state: a late ACK that landed while + /// the user was reaching for retry must not be clobbered back to + /// `.pending`. The `PendingSend` row is still inserted — the queue's + /// drain-time gate handles the no-op send. + @Test + func `replacePendingSendForRetry preserves .delivered status`() async throws { + let store = try makeStore() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + + try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .delivered)) + + let newDTO = PendingSendDTO( + envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), + radioID: radioID + ) + _ = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) + + let refreshed = try #require(try await store.fetchMessage(id: messageID)) + #expect(refreshed.status == .delivered, ".delivered must not be downgraded by retry") + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1, "PendingSend row is still inserted; drain gate handles no-op") + } + + /// All prior PendingSend rows that match the messageID are removed. + /// Older runs that may have racked up multiple PendingSend rows on + /// the same messageID across reconnects must not survive the retry. + @Test + func `replacePendingSendForRetry removes every existing PendingSend row for the messageID`() async throws { + let store = try makeStore() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + + try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 2, messageID: messageID)) + let unrelatedMessageID = UUID() + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 3, messageID: unrelatedMessageID)) + + let newDTO = PendingSendDTO( + envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), + radioID: radioID + ) + _ = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 2, "Unrelated row survives; both prior rows for the retried messageID are gone") + let retried = try #require(rows.first(where: { $0.messageID == messageID })) + #expect(retried.id == newDTO.id) + let unrelated = try #require(rows.first(where: { $0.messageID == unrelatedMessageID })) + #expect(unrelated.sequence == 3, "Unrelated row keeps its prior sequence") + } + + /// Sequence assignment matches `insertPendingSendAssigningSequence`: + /// the new row gets `max(sequence) + 1` for the radio. Verifies the + /// per-radio monotonic invariant survives the delete-then-insert + /// transaction. + @Test + func `replacePendingSendForRetry assigns the next per-radio sequence`() async throws { + let store = try makeStore() + let radioID = UUID() + let otherRadioID = UUID() + let messageID = UUID() + let contactID = UUID() + + try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 7, messageID: UUID())) + try await store.upsertPendingSend(.fixture(radioID: otherRadioID, sequence: 99, messageID: UUID())) + + let newDTO = PendingSendDTO( + envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), + radioID: radioID + ) + let assigned = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) + + #expect(assigned == 8, "Sequence advances from the highest live value for this radio") + let rows = try await store.fetchPendingSends(radioID: radioID) + let inserted = try #require(rows.first(where: { $0.messageID == messageID })) + #expect(inserted.sequence == 8) + } + + private func makeOutgoingDM( + id: UUID, + radioID: UUID, + contactID: UUID, + status: MessageStatus + ) -> MessageDTO { + MessageDTO( + id: id, + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "retry me", + timestamp: 1_700_000_000, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + direction: .outgoing, + status: status, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: true, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + private func makeStore() throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } } private extension PendingSendDTO { - static func fixture( - radioID: UUID = UUID(), - sequence: Int, - messageID: UUID = UUID() - ) -> PendingSendDTO { - PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: messageID, - kind: .dm, - contactID: UUID(), - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: sequence, - enqueuedAt: Date() - ) - } + static func fixture( + radioID: UUID = UUID(), + sequence: Int, + messageID: UUID = UUID() + ) -> PendingSendDTO { + PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: messageID, + kind: .dm, + contactID: UUID(), + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: sequence, + enqueuedAt: Date() + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceKeysThemeTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceKeysThemeTests.swift index 4dbba308..ce1bdc66 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceKeysThemeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceKeysThemeTests.swift @@ -1,18 +1,17 @@ -import Testing @testable import MC1Services +import Testing @Suite("PersistenceKeysTheme") struct PersistenceKeysThemeTests { + @Test + func `theme keys are the exact bare-string on-disk names`() { + #expect(PersistenceKeys.selectedThemeID == "selectedThemeID") + #expect(PersistenceKeys.appColorSchemePreference == "appColorSchemePreference") + } - @Test("theme keys are the exact bare-string on-disk names") - func themeKeyLiterals() { - #expect(PersistenceKeys.selectedThemeID == "selectedThemeID") - #expect(PersistenceKeys.appColorSchemePreference == "appColorSchemePreference") - } - - @Test("theme keys carry no com.pocketmesh prefix (bare-string convention)") - func themeKeysAreBare() { - #expect(!PersistenceKeys.selectedThemeID.contains("com.pocketmesh")) - #expect(!PersistenceKeys.appColorSchemePreference.contains("com.pocketmesh")) - } + @Test + func `theme keys carry no com.pocketmesh prefix (bare-string convention)`() { + #expect(!PersistenceKeys.selectedThemeID.contains("com.pocketmesh")) + #expect(!PersistenceKeys.appColorSchemePreference.contains("com.pocketmesh")) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift index 6b2fee79..e20970d1 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift @@ -1,7 +1,7 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing /// Covers the single-transaction sync-persistence methods added for BLE sync-speed /// optimization (`batchSaveChannels` / `batchSaveContacts`). These carry the @@ -9,220 +9,219 @@ import Foundation /// capacity pruning, and leaving circuit-breaker-skipped slots untouched. @Suite("PersistenceStore Batch Sync Tests") struct PersistenceStoreBatchSyncTests { - - private func secret(_ byte: UInt8) -> Data { - Data(repeating: byte, count: ProtocolLimits.channelSecretSize) - } - - private func publicKey(_ byte: UInt8) -> Data { - Data(repeating: byte, count: ProtocolLimits.publicKeySize) - } - - private func channelInfo(_ index: UInt8, name: String, secretByte: UInt8) -> ChannelInfo { - ChannelInfo(index: index, name: name, secret: secret(secretByte)) - } - - private func contactFrame(_ keyByte: UInt8, name: String, flags: UInt8 = 0) -> ContactFrame { - ContactFrame( - publicKey: publicKey(keyByte), - type: .chat, - flags: flags, - outPathLength: 0, - outPath: Data(), - name: name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - } - - // MARK: - batchSaveChannels - - @Test("batchSaveChannels inserts new and updates existing in one pass") - func batchSaveChannelsInsertsAndUpdates() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "Old", secretByte: 0x11)) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [ - channelInfo(1, name: "New", secretByte: 0x99), - channelInfo(2, name: "Two", secretByte: 0x22) - ], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [1, 2]) - let one = try #require(try await store.fetchChannel(radioID: radioID, index: 1)) - #expect(one.name == "New") - #expect(one.secret == secret(0x99)) - let two = try #require(try await store.fetchChannel(radioID: radioID, index: 2)) - #expect(two.name == "Two") - } - - @Test("batchSaveChannels deletes stale rows at unconfigured indices") - func batchSaveChannelsDeletesUnconfigured() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "Keep", secretByte: 0x11)) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(2, name: "Gone", secretByte: 0x22)) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [channelInfo(1, name: "Keep", secretByte: 0x11)], - unconfiguredIndices: [2], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [1]) - #expect(try await store.fetchChannel(radioID: radioID, index: 2) == nil) - } - - @Test("batchSaveChannels prunes orphans beyond device capacity") - func batchSaveChannelsPrunesOrphans() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "InRange", secretByte: 0x11)) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(10, name: "Orphan", secretByte: 0xAA)) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [channelInfo(1, name: "InRange", secretByte: 0x11)], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [1]) - #expect(try await store.fetchChannel(radioID: radioID, index: 10) == nil) - } - - @Test("batchSaveChannels leaves circuit-breaker-skipped slots untouched") - func batchSaveChannelsLeavesSkippedSlotsUntouched() async throws { - // Simulates a sync that read index 1, then the circuit breaker aborted, so indices - // 2 and 3 were never queried — they are in neither the configured nor the - // unconfigured list and must survive the persist pass. - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "One", secretByte: 0x11)) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(2, name: "Two", secretByte: 0x22)) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(3, name: "Three", secretByte: 0x33)) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [channelInfo(1, name: "OneUpdated", secretByte: 0x11)], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [1, 2, 3]) - #expect(try await store.fetchChannel(radioID: radioID, index: 1)?.name == "OneUpdated") - #expect(try await store.fetchChannel(radioID: radioID, index: 2)?.name == "Two") - #expect(try await store.fetchChannel(radioID: radioID, index: 3)?.name == "Three") - } - - @Test("batchSaveChannels handles non-contiguous configured indices") - func batchSaveChannelsHandlesGaps() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [ - channelInfo(0, name: "Public", secretByte: 0x00), - channelInfo(2, name: "Two", secretByte: 0x22), - channelInfo(7, name: "Seven", secretByte: 0x77) - ], - unconfiguredIndices: [1, 3, 4, 5, 6], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [0, 2, 7]) - #expect(try await store.fetchChannel(radioID: radioID, index: 7)?.name == "Seven") - } - - @Test("batchSaveChannels on empty store with no work returns empty") - func batchSaveChannelsEmpty() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - #expect(result.isEmpty) - } - - @Test("batchSaveChannels scopes to radioID") - func batchSaveChannelsScopesToRadio() async throws { - let radioA = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioA, maxChannels: 8) - let radioB = UUID() - _ = try await store.saveChannel(radioID: radioB, from: channelInfo(1, name: "OtherRadio", secretByte: 0x55)) - - _ = try await store.batchSaveChannels( - radioID: radioA, - configured: [channelInfo(1, name: "Mine", secretByte: 0x11)], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - // Radio B's channel must be unaffected by a Radio A sync. - #expect(try await store.fetchChannel(radioID: radioB, index: 1)?.name == "OtherRadio") - } - - // MARK: - batchSaveContacts - - @Test("batchSaveContacts inserts all and returns count") - func batchSaveContactsInserts() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - let count = try await store.batchSaveContacts(radioID: radioID, from: [ - contactFrame(0x01, name: "Alice"), - contactFrame(0x02, name: "Bob"), - contactFrame(0x03, name: "Carol") - ]) - - #expect(count == 3) - let stored = try await store.fetchContacts(radioID: radioID) - #expect(stored.count == 3) - #expect(Set(stored.map(\.name)) == ["Alice", "Bob", "Carol"]) - } - - @Test("batchSaveContacts updates existing rows and preserves favorite bit") - func batchSaveContactsUpdatesAndPreservesFavorite() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - // Seed a favorited contact (flags bit 0 set). - _ = try await store.saveContact(radioID: radioID, from: contactFrame(0x01, name: "Original", flags: 0x01)) - - // Re-sync with a frame whose flags clear bit 0; update(from:) must keep the favorite. - let count = try await store.batchSaveContacts(radioID: radioID, from: [ - contactFrame(0x01, name: "Renamed", flags: 0x00) - ]) - - #expect(count == 1) - let stored = try await store.fetchContacts(radioID: radioID) - #expect(stored.count == 1) - let contact = try #require(stored.first) - #expect(contact.name == "Renamed") - #expect(contact.isFavorite) - } - - @Test("batchSaveContacts with empty frames returns zero") - func batchSaveContactsEmpty() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - let count = try await store.batchSaveContacts(radioID: radioID, from: []) - #expect(count == 0) - #expect(try await store.fetchContacts(radioID: radioID).isEmpty) - } + private func secret(_ byte: UInt8) -> Data { + Data(repeating: byte, count: ProtocolLimits.channelSecretSize) + } + + private func publicKey(_ byte: UInt8) -> Data { + Data(repeating: byte, count: ProtocolLimits.publicKeySize) + } + + private func channelInfo(_ index: UInt8, name: String, secretByte: UInt8) -> ChannelInfo { + ChannelInfo(index: index, name: name, secret: secret(secretByte)) + } + + private func contactFrame(_ keyByte: UInt8, name: String, flags: UInt8 = 0) -> ContactFrame { + ContactFrame( + publicKey: publicKey(keyByte), + type: .chat, + flags: flags, + outPathLength: 0, + outPath: Data(), + name: name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + } + + // MARK: - batchSaveChannels + + @Test + func `batchSaveChannels inserts new and updates existing in one pass`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "Old", secretByte: 0x11)) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [ + channelInfo(1, name: "New", secretByte: 0x99), + channelInfo(2, name: "Two", secretByte: 0x22) + ], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [1, 2]) + let one = try #require(try await store.fetchChannel(radioID: radioID, index: 1)) + #expect(one.name == "New") + #expect(one.secret == secret(0x99)) + let two = try #require(try await store.fetchChannel(radioID: radioID, index: 2)) + #expect(two.name == "Two") + } + + @Test + func `batchSaveChannels deletes stale rows at unconfigured indices`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "Keep", secretByte: 0x11)) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(2, name: "Gone", secretByte: 0x22)) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [channelInfo(1, name: "Keep", secretByte: 0x11)], + unconfiguredIndices: [2], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [1]) + #expect(try await store.fetchChannel(radioID: radioID, index: 2) == nil) + } + + @Test + func `batchSaveChannels prunes orphans beyond device capacity`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "InRange", secretByte: 0x11)) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(10, name: "Orphan", secretByte: 0xAA)) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [channelInfo(1, name: "InRange", secretByte: 0x11)], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [1]) + #expect(try await store.fetchChannel(radioID: radioID, index: 10) == nil) + } + + @Test + func `batchSaveChannels leaves circuit-breaker-skipped slots untouched`() async throws { + // Simulates a sync that read index 1, then the circuit breaker aborted, so indices + // 2 and 3 were never queried — they are in neither the configured nor the + // unconfigured list and must survive the persist pass. + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "One", secretByte: 0x11)) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(2, name: "Two", secretByte: 0x22)) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(3, name: "Three", secretByte: 0x33)) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [channelInfo(1, name: "OneUpdated", secretByte: 0x11)], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [1, 2, 3]) + #expect(try await store.fetchChannel(radioID: radioID, index: 1)?.name == "OneUpdated") + #expect(try await store.fetchChannel(radioID: radioID, index: 2)?.name == "Two") + #expect(try await store.fetchChannel(radioID: radioID, index: 3)?.name == "Three") + } + + @Test + func `batchSaveChannels handles non-contiguous configured indices`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [ + channelInfo(0, name: "Public", secretByte: 0x00), + channelInfo(2, name: "Two", secretByte: 0x22), + channelInfo(7, name: "Seven", secretByte: 0x77) + ], + unconfiguredIndices: [1, 3, 4, 5, 6], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [0, 2, 7]) + #expect(try await store.fetchChannel(radioID: radioID, index: 7)?.name == "Seven") + } + + @Test + func `batchSaveChannels on empty store with no work returns empty`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + #expect(result.isEmpty) + } + + @Test + func `batchSaveChannels scopes to radioID`() async throws { + let radioA = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioA, maxChannels: 8) + let radioB = UUID() + _ = try await store.saveChannel(radioID: radioB, from: channelInfo(1, name: "OtherRadio", secretByte: 0x55)) + + _ = try await store.batchSaveChannels( + radioID: radioA, + configured: [channelInfo(1, name: "Mine", secretByte: 0x11)], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + // Radio B's channel must be unaffected by a Radio A sync. + #expect(try await store.fetchChannel(radioID: radioB, index: 1)?.name == "OtherRadio") + } + + // MARK: - batchSaveContacts + + @Test + func `batchSaveContacts inserts all and returns count`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + let count = try await store.batchSaveContacts(radioID: radioID, from: [ + contactFrame(0x01, name: "Alice"), + contactFrame(0x02, name: "Bob"), + contactFrame(0x03, name: "Carol") + ]) + + #expect(count == 3) + let stored = try await store.fetchContacts(radioID: radioID) + #expect(stored.count == 3) + #expect(Set(stored.map(\.name)) == ["Alice", "Bob", "Carol"]) + } + + @Test + func `batchSaveContacts updates existing rows and preserves favorite bit`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + // Seed a favorited contact (flags bit 0 set). + _ = try await store.saveContact(radioID: radioID, from: contactFrame(0x01, name: "Original", flags: 0x01)) + + // Re-sync with a frame whose flags clear bit 0; update(from:) must keep the favorite. + let count = try await store.batchSaveContacts(radioID: radioID, from: [ + contactFrame(0x01, name: "Renamed", flags: 0x00) + ]) + + #expect(count == 1) + let stored = try await store.fetchContacts(radioID: radioID) + #expect(stored.count == 1) + let contact = try #require(stored.first) + #expect(contact.name == "Renamed") + #expect(contact.isFavorite) + } + + @Test + func `batchSaveContacts with empty frames returns zero`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + let count = try await store.batchSaveContacts(radioID: radioID, from: []) + #expect(count == 0) + #expect(try await store.fetchContacts(radioID: radioID).isEmpty) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift index b86f1317..a400429e 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift @@ -1,2947 +1,2946 @@ import Foundation +@testable import MC1Services +import MeshCore import SwiftData import Testing -import MeshCore -@testable import MC1Services @Suite("PersistenceStore Tests") struct PersistenceStoreTests { - - // MARK: - Test Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func createTestDevice(id: UUID = UUID()) -> DeviceDTO { - DeviceDTO.testDevice( - id: id, - publicKey: Data((0.. ContactFrame { - ContactFrame( - publicKey: Data((0.. ( - contactID: UUID, messageID: UUID, channelID: UUID, sessionID: UUID - ) { - let contactFrame = createTestContactFrame(name: "TestContact") - let contactID = try await store.saveContact(radioID: radioID, from: contactFrame) - - let message = MessageDTO(from: Message( - radioID: radioID, - contactID: contactID, - text: "Hello!", - timestamp: UInt32(Date().timeIntervalSince1970) - )) - try await store.saveMessage(message) - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - - let channelInfo = ChannelInfo(index: 1, name: "Private", secret: Data(repeating: 0x42, count: 16)) - let channelID = try await store.saveChannel(radioID: radioID, from: channelInfo) - - let reaction = ReactionDTO( - messageID: message.id, - emoji: "👍", - senderName: "Reactor", - messageHash: "AABBCCDD", - rawText: "👍", - radioID: radioID - ) - try await store.saveReaction(reaction) - - let session = createTestRoomSession(radioID: radioID) - try await store.saveRemoteNodeSessionDTO(session) - - let roomMessage = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), - authorName: "Author", - text: "Room message", - timestamp: UInt32(Date().timeIntervalSince1970) - ) - try await store.saveRoomMessage(roomMessage) - - let blocked = BlockedChannelSenderDTO(name: "Spammer", radioID: radioID) - try await store.saveBlockedChannelSender(blocked) - - let rxLog = createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: 12345) - try await store.saveRxLogEntry(rxLog) - - let discoveredFrame = createTestContactFrame(name: "Discovered") - _ = try await store.upsertDiscoveredNode(radioID: radioID, from: discoveredFrame) - - return (contactID, message.id, channelID, session.id) - } - - /// Asserts all entity types for a device are present. - private func assertAllDataExists( - store: PersistenceStore, radioID: UUID, sessionID: UUID, messageID: UUID - ) async throws { - let contacts = try await store.fetchContacts(radioID: radioID) - #expect(contacts.count == 1, "Expected 1 contact") - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.count == 1, "Expected 1 channel") - let reactions = try await store.fetchReactions(for: messageID) - #expect(reactions.count == 1, "Expected 1 reaction") - let sessions = try await store.fetchRemoteNodeSessions(radioID: radioID) - #expect(sessions.count == 1, "Expected 1 session") - let roomMessages = try await store.fetchRoomMessages(sessionID: sessionID) - #expect(roomMessages.count == 1, "Expected 1 room message") - let blockedSenders = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(blockedSenders.count == 1, "Expected 1 blocked sender") - let rxEntries = try await store.fetchRxLogEntries(radioID: radioID) - #expect(rxEntries.count == 1, "Expected 1 RX log entry") - let discoveredNodes = try await store.fetchDiscoveredNodes(radioID: radioID) - #expect(discoveredNodes.count == 1, "Expected 1 discovered node") - } - - /// Asserts all entity types for a device have been deleted. - private func assertAllDataDeleted( - store: PersistenceStore, radioID: UUID, sessionID: UUID, messageID: UUID - ) async throws { - let contacts = try await store.fetchContacts(radioID: radioID) - #expect(contacts.isEmpty, "Expected no contacts") - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.isEmpty, "Expected no channels") - let reactions = try await store.fetchReactions(for: messageID) - #expect(reactions.isEmpty, "Expected no reactions") - let sessions = try await store.fetchRemoteNodeSessions(radioID: radioID) - #expect(sessions.isEmpty, "Expected no sessions") - let roomMessages = try await store.fetchRoomMessages(sessionID: sessionID) - #expect(roomMessages.isEmpty, "Expected no room messages") - let blockedSenders = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(blockedSenders.isEmpty, "Expected no blocked senders") - let rxEntries = try await store.fetchRxLogEntries(radioID: radioID) - #expect(rxEntries.isEmpty, "Expected no RX log entries") - let discoveredNodes = try await store.fetchDiscoveredNodes(radioID: radioID) - #expect(discoveredNodes.isEmpty, "Expected no discovered nodes") - let repeats = try await store.fetchMessageRepeats(messageID: messageID) - #expect(repeats.isEmpty, "Expected no message repeats") - } - - @Test("deleteDevice removes only device record, preserves all associated data") - func deleteDevicePreservesData() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let ids = try await seedAllEntityTypes(store: store, radioID: device.id) - - try await store.deleteDevice(id: device.id) - - let fetchedDevice = try await store.fetchDevice(id: device.id) - #expect(fetchedDevice == nil, "Device record should be deleted") - - try await assertAllDataExists( - store: store, radioID: device.id, - sessionID: ids.sessionID, messageID: ids.messageID - ) - } - - @Test("deleteDeviceData removes all associated data but not device record") - func deleteDeviceDataPreservesDevice() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let ids = try await seedAllEntityTypes(store: store, radioID: device.id) - - try await store.deleteDeviceData(id: device.id) - - let fetchedDevice = try await store.fetchDevice(id: device.id) - #expect(fetchedDevice != nil, "Device record should be preserved") - - try await assertAllDataDeleted( - store: store, radioID: device.id, - sessionID: ids.sessionID, messageID: ids.messageID - ) - } - - @Test("deleteDeviceAndData removes device and all data atomically") - func deleteDeviceAndDataRemovesAll() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let ids = try await seedAllEntityTypes(store: store, radioID: device.id) - - try await store.deleteDeviceAndData(id: device.id) - - let fetchedDevice = try await store.fetchDevice(id: device.id) - #expect(fetchedDevice == nil, "Device record should be deleted") - - try await assertAllDataDeleted( - store: store, radioID: device.id, - sessionID: ids.sessionID, messageID: ids.messageID - ) - } - - @Test("deleteDeviceAndData does not trap when a message has heard repeats (cascade-vs-batch)") - func deleteDeviceAndDataWithMessageRepeatsDoesNotTrap() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let contactID = try await store.saveContact(radioID: device.id, from: createTestContactFrame()) - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "broadcast", - timestamp: UInt32(Date().timeIntervalSince1970), - directionRawValue: MessageDirection.outgoing.rawValue - )) - try await store.saveMessage(message) - // Two heard repeats wire up message.repeats so the delete exercises cascade - // propagation over a relationship whose child rows were batch-deleted first. - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - - try await store.deleteDeviceAndData(id: device.id) - - #expect(try await store.fetchDevice(id: device.id) == nil) - #expect(try await store.fetchMessage(id: message.id) == nil) - #expect(try await store.fetchMessageRepeats(messageID: message.id).isEmpty) - } - - @Test("deleteDeviceData reaps a message and its heard repeats while preserving the device") - func deleteDeviceDataWithMessageRepeatsPreservesDevice() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let contactID = try await store.saveContact(radioID: device.id, from: createTestContactFrame()) - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "broadcast", - timestamp: UInt32(Date().timeIntervalSince1970), - directionRawValue: MessageDirection.outgoing.rawValue - )) - try await store.saveMessage(message) - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - - try await store.deleteDeviceData(id: device.id) - - #expect(try await store.fetchDevice(id: device.id) != nil) - #expect(try await store.fetchMessage(id: message.id) == nil) - #expect(try await store.fetchMessageRepeats(messageID: message.id).isEmpty) - } - - @Test("deleteDeviceData for non-existent device does not throw") - func deleteDeviceDataNonExistent() async throws { - let store = try await createTestStore() - try await store.deleteDeviceData(id: UUID()) - } - - @Test("deleteDeviceAndData for non-existent device does not throw") - func deleteDeviceAndDataNonExistent() async throws { - let store = try await createTestStore() - try await store.deleteDeviceAndData(id: UUID()) - } - - @Test("Re-pair after device deletion re-associates orphaned data") - func rePairReassociatesOrphanedData() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let contactFrame = createTestContactFrame(name: "Survivor") - _ = try await store.saveContact(radioID: device.id, from: contactFrame) - - let channelInfo = ChannelInfo(index: 0, name: "General", secret: Data(repeating: 0, count: 16)) - _ = try await store.saveChannel(radioID: device.id, from: channelInfo) - - // Simulate ASK removal: delete device record only - try await store.deleteDevice(id: device.id) - - // Simulate re-pair: saveDevice upserts with same ID - try await store.saveDevice(device) - - let contacts = try await store.fetchContacts(radioID: device.id) - #expect(contacts.count == 1) - #expect(contacts.first?.name == "Survivor") - - let channels = try await store.fetchChannels(radioID: device.id) - #expect(channels.count == 1) - #expect(channels.first?.name == "General") - } - - @Test("Demote device to ghost preserves publicKey and radioID with fresh id") - func demoteDeviceToGhostPreservesIdentity() async throws { - let store = try await createTestStore() - let original = createTestDevice().copy { - $0.isActive = true - } - try await store.saveDevice(original) - - try await store.demoteDeviceToGhost(id: original.id) - - let originalLookup = try await store.fetchDevice(id: original.id) - #expect(originalLookup == nil, "Original BLE id should no longer resolve") - - let ghost = try await store.fetchDevice(publicKey: original.publicKey) - #expect(ghost != nil) - #expect(ghost?.id != original.id, "Ghost must have a fresh id") - #expect(ghost?.publicKey == original.publicKey) - #expect(ghost?.radioID == original.radioID) - #expect(ghost?.isActive == false) - } - - @Test("Demote device strips all connection methods so it stays hidden") - func demoteDeviceToGhostStripsAllConnectionMethods() async throws { - let store = try await createTestStore() - let wifi = ConnectionMethod.wifi(host: "10.0.0.5", port: 5000, displayName: nil) - let bluetooth = ConnectionMethod.bluetooth(peripheralUUID: UUID(), displayName: nil) - let original = createTestDevice().copy { - $0.connectionMethods = [wifi, bluetooth] - } - try await store.saveDevice(original) - - try await store.demoteDeviceToGhost(id: original.id) - - let ghost = try await store.fetchDevice(publicKey: original.publicKey) - #expect(ghost?.connectionMethods.isEmpty == true, - "Demoted ghost must have no connection methods so DeviceSelectionFilter hides it") - } - - @Test("Demote device with unknown id is a no-op") - func demoteDeviceToGhostUnknownIDNoOp() async throws { - let store = try await createTestStore() - try await store.demoteDeviceToGhost(id: UUID()) - let devices = try await store.fetchDevices() - #expect(devices.isEmpty) - } - - @Test("Removing a paired device preserves child contacts via radioID") - func demoteRetainsChildLinkageByRadioID() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let contactFrame = createTestContactFrame(name: "Alice") - _ = try await store.saveContact(radioID: device.radioID, from: contactFrame) - - try await store.demoteDeviceToGhost(id: device.id) - - let ghost = try await store.fetchDevice(publicKey: device.publicKey) - #expect(ghost?.radioID == device.radioID) - let contacts = try await store.fetchContacts(radioID: device.radioID) - #expect(contacts.count == 1) - #expect(contacts.first?.name == "Alice") - } - - // MARK: - Contact Tests - - @Test("Save and fetch contact from frame") - func saveAndFetchContactFromFrame() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame(name: "Alice") - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - let contact = try await store.fetchContact(id: contactID) - #expect(contact != nil) - #expect(contact?.name == "Alice") - #expect(contact?.type == .chat) - } - - @Test("Fetch contact by public key") - func fetchContactByPublicKey() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame(name: "Bob") - _ = try await store.saveContact(radioID: device.id, from: frame) - - let contact = try await store.fetchContact(radioID: device.id, publicKey: frame.publicKey) - #expect(contact != nil) - #expect(contact?.name == "Bob") - } - - @Test("Update contact last message and unread count") - func updateContactLastMessageAndUnread() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - let now = Date() - try await store.updateContactLastMessage(contactID: contactID, date: now) - try await store.incrementUnreadCount(contactID: contactID) - try await store.incrementUnreadCount(contactID: contactID) - - var contact = try await store.fetchContact(id: contactID) - #expect(contact?.unreadCount == 2) - #expect(contact?.lastMessageDate != nil) - - try await store.clearUnreadCount(contactID: contactID) - - contact = try await store.fetchContact(id: contactID) - #expect(contact?.unreadCount == 0) - } - - @Test("deleteMessagesForContact removes all messages for a contact") - func deleteMessagesForContactRemovesAll() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Create first contact - let frame1 = createTestContactFrame(name: "Contact1") - let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) - - // Create multiple messages for this contact - for i in 0..<5 { - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contact1ID, - text: "Message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - } - - // Create a second contact with a message (should not be deleted) - let frame2 = createTestContactFrame(name: "Contact2") - let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) - let otherMessage = MessageDTO(from: Message( - radioID: device.id, - contactID: contact2ID, - text: "Other message", - timestamp: UInt32(Date().timeIntervalSince1970) + 100 - )) - try await store.saveMessage(otherMessage) - - // Verify messages exist before deletion - var contact1Messages = try await store.fetchMessages(contactID: contact1ID) - #expect(contact1Messages.count == 5) - - var contact2Messages = try await store.fetchMessages(contactID: contact2ID) - #expect(contact2Messages.count == 1) - - // Delete messages for the first contact - try await store.deleteMessagesForContact(contactID: contact1ID) - - // Verify messages for deleted contact are gone - contact1Messages = try await store.fetchMessages(contactID: contact1ID) - #expect(contact1Messages.isEmpty) - - // Verify messages for other contact still exist - contact2Messages = try await store.fetchMessages(contactID: contact2ID) - #expect(contact2Messages.count == 1) - } - - @Test("recomputeContactLastMessageDate keeps a conversation visible while older messages remain and clears it only when empty") - func recomputeContactLastMessageDateTracksRemainingMessages() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame(name: "Conversation") - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - let base = Date(timeIntervalSince1970: 1_700_000_000) - let messages = (0..<3).map { i in - MessageDTO.testDirectMessage( - radioID: device.id, - contactID: contactID, - text: "Message \(i)", - timestamp: UInt32(base.timeIntervalSince1970) + UInt32(i), - createdAt: base.addingTimeInterval(Double(i)) - ) - } - for message in messages { - try await store.saveMessage(message) - } - - // Newest message sets the date; the conversation is visible. - var newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) - #expect(newDate == messages[2].date) - var conversations = try await store.fetchConversations(radioID: device.id) - #expect(conversations.contains { $0.id == contactID }) - - // Deleting the newest message falls back to the next remaining message, - // not nil: the conversation must stay visible while messages remain. - try await store.deleteMessage(id: messages[2].id) - newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) - #expect(newDate == messages[1].date) - conversations = try await store.fetchConversations(radioID: device.id) - #expect(conversations.contains { $0.id == contactID }) - - // Deleting the last remaining messages clears the date and removes the conversation. - try await store.deleteMessage(id: messages[1].id) - try await store.deleteMessage(id: messages[0].id) - newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) - #expect(newDate == nil) - conversations = try await store.fetchConversations(radioID: device.id) - #expect(!conversations.contains { $0.id == contactID }) - } - - @Test("deleteMessagesForChannel removes all messages for a channel") - func deleteMessagesForChannelRemovesAll() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex0: UInt8 = 0 - let channelIndex1: UInt8 = 1 - - // Create messages for channel 0 - for i in 0..<5 { - let message = MessageDTO(from: Message( - radioID: device.id, - channelIndex: channelIndex0, - text: "Channel 0 Message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - } - - // Create messages for channel 1 (should not be deleted) - for i in 0..<3 { - let message = MessageDTO(from: Message( - radioID: device.id, - channelIndex: channelIndex1, - text: "Channel 1 Message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i + 100) - )) - try await store.saveMessage(message) - } - - // Create a contact message (should not be deleted) - let frame = createTestContactFrame(name: "Contact1") - let contactID = try await store.saveContact(radioID: device.id, from: frame) - let contactMessage = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Contact message", - timestamp: UInt32(Date().timeIntervalSince1970) + 200 - )) - try await store.saveMessage(contactMessage) - - // Verify messages exist before deletion - var channel0Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex0) - #expect(channel0Messages.count == 5) - - var channel1Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex1) - #expect(channel1Messages.count == 3) - - var contactMessages = try await store.fetchMessages(contactID: contactID) - #expect(contactMessages.count == 1) - - // Delete messages for channel 0 - try await store.deleteMessagesForChannel(radioID: device.id, channelIndex: channelIndex0) - - // Verify channel 0 messages are gone - channel0Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex0) - #expect(channel0Messages.isEmpty) - - // Verify channel 1 messages still exist - channel1Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex1) - #expect(channel1Messages.count == 3) - - // Verify contact messages still exist - contactMessages = try await store.fetchMessages(contactID: contactID) - #expect(contactMessages.count == 1) - } - - // MARK: - Message Tests - - @Test("Save and fetch messages for contact") - func saveAndFetchMessagesForContact() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // Save multiple messages - for i in 0..<5 { - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - } - - let messages = try await store.fetchMessages(contactID: contactID) - #expect(messages.count == 5) - // Messages should be in chronological order (oldest first) - #expect(messages.first?.text == "Message 0") - #expect(messages.last?.text == "Message 4") - } - - @Test("Interleaved backlog from multiple senders reassembles in sortDate (send) order") - func backlogReassemblesInSortDateOrder() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // Backlog drains in a scrambled receive order: rows arrive (createdAt) - // in the order 2, 0, 3, 1 but their send times (sortDate) are 0..<4. - // Display must reassemble by send order regardless of receive order. - let drainBase = Date(timeIntervalSince1970: 2_000_000) - let sendBase = Date(timeIntervalSince1970: 1_000_000) - let scrambledSendOrder = [2, 0, 3, 1] - for (receiveOffset, sendIndex) in scrambledSendOrder.enumerated() { - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Send \(sendIndex)", - timestamp: UInt32(sendBase.timeIntervalSince1970) + UInt32(sendIndex), - createdAt: drainBase.addingTimeInterval(TimeInterval(receiveOffset)), - sortDate: sendBase.addingTimeInterval(TimeInterval(sendIndex)) - )) - try await store.saveMessage(message) - } - - let messages = try await store.fetchMessages(contactID: contactID) - #expect(messages.map(\.text) == ["Send 0", "Send 1", "Send 2", "Send 3"]) - } - - @Test("Live message stays last even when an earlier-sortDate backlog row is inserted after it") - func liveMessageStaysLastDespiteLaterBacklogInsert() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // A live row: sortDate == createdAt == now. - let now = Date(timeIntervalSince1970: 3_000_000) - let liveMessage = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Live", - timestamp: UInt32(now.timeIntervalSince1970), - createdAt: now, - sortDate: now - )) - try await store.saveMessage(liveMessage) - - // A backlog row inserted afterwards (later createdAt) but with an - // earlier send time. Its skewed sender clock must not let it sort - // above the live row. - let laterCreatedAt = now.addingTimeInterval(60) - let earlierSendTime = now.addingTimeInterval(-3600) - let backlogMessage = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Backlog", - timestamp: UInt32(earlierSendTime.timeIntervalSince1970), - createdAt: laterCreatedAt, - sortDate: earlierSendTime - )) - try await store.saveMessage(backlogMessage) - - let messages = try await store.fetchMessages(contactID: contactID) - #expect(messages.map(\.text) == ["Backlog", "Live"]) - #expect(messages.last?.text == "Live") - } - - @Test("Equal sortDate and timestamp fall back to createdAt order (tertiary key)") - func equalSortDateAndTimestampFallBackToCreatedAt() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // All three rows share an identical sortDate (primary key, e.g. un-backfilled - // rows that all defaulted to the same value) and an identical timestamp (secondary - // key), so only createdAt — the tertiary sort key — can break the tie. Varying - // timestamp too would let the secondary key drive the order and mask whether - // createdAt is honored. - let sharedSortDate = Date(timeIntervalSince1970: 4_000_000) - let sharedTimestamp = UInt32(sharedSortDate.timeIntervalSince1970) - let createdBase = Date(timeIntervalSince1970: 5_000_000) - for i in 0..<3 { - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Tie \(i)", - timestamp: sharedTimestamp, - createdAt: createdBase.addingTimeInterval(TimeInterval(i)), - sortDate: sharedSortDate - )) - try await store.saveMessage(message) - } - - let messages = try await store.fetchMessages(contactID: contactID) - #expect(messages.map(\.text) == ["Tie 0", "Tie 1", "Tie 2"]) - } - - @Test("Backlog block orders by send time within a shared drain anchor") - func backlogBlockOrdersBySendTimeWithinAnchor() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex: UInt8 = 0 - // One drain anchor shared by the whole block (block-at-reconnect). - let anchor = Date(timeIntervalSince1970: 1_700_000_000) - - // Three different senders so reorderSameSenderClusters leaves them alone, - // isolating the fetch's sort keys. createdAt (drain order) runs opposite to - // send time, proving the secondary sort key is timestamp, not createdAt. - func backlogMessage(sender: String, sendTime: UInt32, drainOffset: TimeInterval) -> MessageDTO { - MessageDTO(from: Message( - radioID: device.id, - channelIndex: channelIndex, - text: sender, - timestamp: sendTime, - createdAt: anchor.addingTimeInterval(drainOffset), - sortDate: anchor, - directionRawValue: MessageDirection.incoming.rawValue, - senderNodeName: sender - )) - } - // Drained Carol, Bob, Alice (createdAt 0,1,2) but sent Alice < Bob < Carol. - try await store.saveMessage(backlogMessage(sender: "Carol", sendTime: 300, drainOffset: 0)) - try await store.saveMessage(backlogMessage(sender: "Bob", sendTime: 200, drainOffset: 1)) - try await store.saveMessage(backlogMessage(sender: "Alice", sendTime: 100, drainOffset: 2)) - - let messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex) - - #expect(messages.map(\.text) == ["Alice", "Bob", "Carol"]) - } - - @Test("Find channel message for reaction within timestamp window") - func findChannelMessageForReactionWithinWindow() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex: UInt8 = 1 - let baseTimestamp: UInt32 = 1_700_000_000 - var targetMessage: MessageDTO? - - for i in 0..<120 { - let timestamp = baseTimestamp + UInt32(i) - let message = MessageDTO( - id: UUID(), - radioID: device.id, - contactID: nil, - channelIndex: channelIndex, - text: "Message \(i)", - timestamp: timestamp, - createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: "RemoteNode", - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - try await store.saveMessage(message) - if i == 80 { - targetMessage = message - } - } - - let message = try #require(targetMessage) - let reactionService = ReactionService() - let reactionText = reactionService.buildReactionText( - emoji: "👍", - targetSender: "RemoteNode", - targetText: message.text, - targetTimestamp: message.timestamp - ) - let parsed = try #require(ReactionParser.parse(reactionText)) - - let now = message.timestamp - let windowStart = now > 300 ? now - 300 : 0 - let windowEnd = now + 300 - - let found = try await store.findChannelMessageForReaction( - radioID: device.id, - channelIndex: channelIndex, - parsedReaction: parsed, - localNodeName: "LocalNode", - timestampWindow: windowStart...windowEnd, - limit: 200 - ) - - #expect(found?.id == message.id) - } - - @Test("Find outgoing channel message for reaction using local node name") - func findOutgoingChannelMessageForReaction() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex: UInt8 = 2 - let timestamp: UInt32 = 1_700_000_200 - - let outgoingMessage = MessageDTO( - id: UUID(), - radioID: device.id, - contactID: nil, - channelIndex: channelIndex, - text: "Local message", - timestamp: timestamp, - createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), - direction: .outgoing, - status: .sent, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - try await store.saveMessage(outgoingMessage) - - let reactionService = ReactionService() - let reactionText = reactionService.buildReactionText( - emoji: "🔥", - targetSender: "LocalNode", - targetText: outgoingMessage.text, - targetTimestamp: outgoingMessage.timestamp - ) - let parsed = try #require(ReactionParser.parse(reactionText)) - - let now = outgoingMessage.timestamp - let windowStart = now > 300 ? now - 300 : 0 - let windowEnd = now + 300 - - let found = try await store.findChannelMessageForReaction( - radioID: device.id, - channelIndex: channelIndex, - parsedReaction: parsed, - localNodeName: "LocalNode", - timestampWindow: windowStart...windowEnd, - limit: 200 - ) - - #expect(found?.id == outgoingMessage.id) - } - - @Test("Update message status") - func updateMessageStatus() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Test", - statusRawValue: MessageStatus.pending.rawValue - )) - try await store.saveMessage(message) - - // Update status to sending - try await store.updateMessageStatus(id: message.id, status: .sending) - var fetched = try await store.fetchMessage(id: message.id) - #expect(fetched?.status == .sending) - - // Update status to sent - try await store.updateMessageStatus(id: message.id, status: .sent) - fetched = try await store.fetchMessage(id: message.id) - #expect(fetched?.status == .sent) - } - - // MARK: - Channel Tests - - @Test("Save and fetch channels") - func saveAndFetchChannels() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Add public channel - let publicChannel = ChannelInfo(index: 0, name: "Public", secret: Data(repeating: 0, count: 16)) - _ = try await store.saveChannel(radioID: device.id, from: publicChannel) - - // Add private channel - let privateChannel = ChannelInfo(index: 1, name: "Private", secret: Data(repeating: 0x42, count: 16)) - _ = try await store.saveChannel(radioID: device.id, from: privateChannel) - - let channels = try await store.fetchChannels(radioID: device.id) - #expect(channels.count == 2) - #expect(channels[0].index == 0) - #expect(channels[0].name == "Public") - #expect(channels[1].index == 1) - #expect(channels[1].name == "Private") - } - - // MARK: - RemoteNodeSession Tests - - private func createTestRoomSession(radioID: UUID) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: UUID(), - radioID: radioID, - publicKey: Data((0..= firstDate!) - } - - @Test("Update room activity ignores older sync timestamps") - func updateRoomActivityIgnoresOlderSyncTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Set initial timestamp - try await store.updateRoomActivity(session.id, syncTimestamp: 5000) - - var fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.lastSyncTimestamp == 5000) - - // Try to update with older timestamp - sync timestamp should be ignored - try await store.updateRoomActivity(session.id, syncTimestamp: 3000) - - fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.lastSyncTimestamp == 5000) - // But lastMessageDate should still be updated - #expect(fetched?.lastMessageDate != nil) - } - - @Test("Update room activity without sync timestamp does not change lastSyncTimestamp") - func updateRoomActivityWithoutSyncTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Set initial sync timestamp - try await store.updateRoomActivity(session.id, syncTimestamp: 5000) - - // Call without sync timestamp (send path) - try await store.updateRoomActivity(session.id) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.lastSyncTimestamp == 5000) - #expect(fetched?.lastMessageDate != nil) - } - - @Test("Mark room session connected changes isConnected and returns true") - func markRoomSessionConnected() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Create a disconnected session with admin permission - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: false, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - let result = try await store.markRoomSessionConnected(session.id) - #expect(result == true) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == true) - // Permission level must not be changed - #expect(fetched?.permissionLevel == .admin) - } - - @Test("Mark room session connected returns false when already connected") - func markRoomSessionConnectedAlreadyConnected() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: true, - permissionLevel: .guest, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - let result = try await store.markRoomSessionConnected(session.id) - #expect(result == false) - } - - @Test("Mark session disconnected preserves permission level") - func markSessionDisconnectedPreservesPermissionLevel() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: true, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - try await store.markSessionDisconnected(session.id) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == false) - #expect(fetched?.permissionLevel == .admin) - } - - @Test("Mark session disconnected is no-op when already disconnected") - func markSessionDisconnectedAlreadyDisconnected() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: false, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - try await store.markSessionDisconnected(session.id) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == false) - #expect(fetched?.permissionLevel == .admin) - } - - @Test("Disconnect then recover preserves permission level") - func disconnectThenRecoverPreservesPermissionLevel() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: true, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - try await store.markSessionDisconnected(session.id) - _ = try await store.markRoomSessionConnected(session.id) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == true) - #expect(fetched?.permissionLevel == .admin) - } - - @Test("Update remote node session connection can reset permission to guest") - func updateRemoteNodeSessionConnectionResetsPermissionToGuest() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: true, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - try await store.updateRemoteNodeSessionConnection( - id: session.id, - isConnected: false, - permissionLevel: .guest - ) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == false) - #expect(fetched?.permissionLevel == .guest) - } - - // MARK: - RoomMessage Tests - - @Test("Save and fetch room messages") - func saveAndFetchRoomMessages() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Save room messages - for i in 0..<3 { - let message = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), - authorName: "Author\(i)", - text: "Room message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - ) - try await store.saveRoomMessage(message) - } - - let messages = try await store.fetchRoomMessages(sessionID: session.id) - #expect(messages.count == 3) - } - - @Test("Room messages tied on timestamp order deterministically by createdAt") - func roomMessagesEqualTimestampOrderByCreatedAt() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Same wire timestamp (1-second resolution) but distinct arrival times. Inserted - // out of arrival order so the fetch must impose the createdAt tie-break itself. - let sharedTimestamp = UInt32(Date().timeIntervalSince1970) - let base = Date(timeIntervalSince1970: 1_700_000_000) - let arrivals: [(text: String, createdAt: Date)] = [ - ("second", base.addingTimeInterval(1)), - ("third", base.addingTimeInterval(2)), - ("first", base) - ] - for arrival in arrivals { - let message = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), - text: arrival.text, - timestamp: sharedTimestamp, - createdAt: arrival.createdAt - ) - try await store.saveRoomMessage(message) - } - - let messages = try await store.fetchRoomMessages(sessionID: session.id) - #expect(messages.map(\.text) == ["first", "second", "third"]) - } - - @Test("Room messages order primarily by wire timestamp") - func roomMessagesOrderByTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Distinct timestamps inserted out of order; arrival order is the inverse of - // send order to prove the timestamp key wins over createdAt. - let baseTimestamp = UInt32(Date().timeIntervalSince1970) - let arrival = Date(timeIntervalSince1970: 1_700_000_000) - let entries: [(text: String, offset: UInt32, arrivalOffset: TimeInterval)] = [ - ("newest", 2, 0), - ("oldest", 0, 2), - ("middle", 1, 1) - ] - for entry in entries { - let message = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), - text: entry.text, - timestamp: baseTimestamp + entry.offset, - createdAt: arrival.addingTimeInterval(entry.arrivalOffset) - ) - try await store.saveRoomMessage(message) - } - - let messages = try await store.fetchRoomMessages(sessionID: session.id) - #expect(messages.map(\.text) == ["oldest", "middle", "newest"]) - } - - @Test("Room message deduplication") - func roomMessageDeduplication() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - let timestamp = UInt32(Date().timeIntervalSince1970) - let authorKeyPrefix = Data([0x01, 0x02, 0x03, 0x04]) - let text = "Duplicate message" - - // Save message - let message1 = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: authorKeyPrefix, - text: text, - timestamp: timestamp - ) - try await store.saveRoomMessage(message1) - - // Try to save duplicate (same timestamp, author, and content hash) - let message2 = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: authorKeyPrefix, - text: text, - timestamp: timestamp - ) - try await store.saveRoomMessage(message2) - - // Should only have one message - let messages = try await store.fetchRoomMessages(sessionID: session.id) - #expect(messages.count == 1) - } - - // MARK: - Duplicate Session Cleanup Tests - - @Test("Cleanup duplicate remote node sessions keeps target and deletes others") - func cleanupDuplicateRemoteNodeSessions() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let sharedKey = Data((0.. PendingSendDTO { - PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: messageID, - kind: .dm, - contactID: UUID(), - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: sequence, - enqueuedAt: Date(), - attemptCount: attemptCount - ) - } - - @Test("incrementPendingSendAttemptCount from 0 bumps to 1") - func testIncrementPendingSendAttemptCount_FromZero_BumpsToOne() async throws { - let store = try await createTestStore() - let messageID = UUID() - let dto = makePendingSendDTO(messageID: messageID, attemptCount: 0) - try await store.upsertPendingSend(dto) - - let result = try await store.incrementPendingSendAttemptCount(messageID: messageID) - #expect(result == 1, "first drain attempt should bump 0 → 1") - - let persisted = try await store.fetchPendingSends(radioID: dto.radioID).first - #expect(persisted?.attemptCount == 1, "persisted attemptCount should match return value") - } - - @Test("incrementPendingSendAttemptCount returns nil when no row matches") - func testIncrementPendingSendAttemptCount_NoRow_ReturnsNil() async throws { - let store = try await createTestStore() - let messageID = UUID() - - let result = try await store.incrementPendingSendAttemptCount(messageID: messageID) - #expect(result == nil, "missing-row case is terminal — return nil instead of creating a new row") - } - - @Test("purgeLegacyAttemptCountRows deletes only legacy nil rows") - func testPurgeLegacyAttemptCountRows_DeletesLegacyNilRowsOnly() async throws { - let store = try await createTestStore() - let radioID = UUID() - let legacyDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: nil, sequence: 1) - let raceDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: 0, sequence: 2) - let drainedDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: 3, sequence: 3) - try await store.upsertPendingSend(legacyDTO) - try await store.upsertPendingSend(raceDTO) - try await store.upsertPendingSend(drainedDTO) - - let deleted = try await store.purgeLegacyAttemptCountRows() - #expect(deleted == 1, "only the single nil-valued row should be deleted") - - let rows = try await store.fetchPendingSends(radioID: radioID) - let messageIDs = Set(rows.map(\.messageID)) - #expect(!messageIDs.contains(legacyDTO.messageID), "legacy nil row must be deleted") - let byMessageID = Dictionary(uniqueKeysWithValues: rows.map { ($0.messageID, $0.attemptCount) }) - #expect(byMessageID[raceDTO.messageID] == 0, "race-window 0 row stays at 0") - #expect(byMessageID[drainedDTO.messageID] == 3, "already-drained row stays untouched") - } - - @Test("purgeLegacyAttemptCountRows is idempotent") - func testPurgeLegacyAttemptCountRows_IsIdempotent() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: nil) - try await store.upsertPendingSend(dto) - - let firstDeleted = try await store.purgeLegacyAttemptCountRows() - let secondDeleted = try await store.purgeLegacyAttemptCountRows() - #expect(firstDeleted == 1, "first call deletes the legacy nil row") - #expect(secondDeleted == 0, "second call: predicate matches nothing — idempotent on an empty nil set") - } - - @Test("warmUp runs both purgeOrphanPendingSends and purgeLegacyAttemptCountRows") - func testWarmUp_RunsBothPurges() async throws { - let store = try await createTestStore() - let radioWithDevice = UUID() - let radioWithoutDevice = UUID() - - // Device for one of the two radios — the other's PendingSends are orphans. - let scopedDevice = DeviceDTO.testDevice(id: radioWithDevice, radioID: radioWithDevice) - try await store.saveDevice(scopedDevice) - - let nilCountOnDeviceRadio = makePendingSendDTO( - messageID: UUID(), radioID: radioWithDevice, attemptCount: nil, sequence: 1 - ) - let orphanOnUnknownRadio = makePendingSendDTO( - messageID: UUID(), radioID: radioWithoutDevice, attemptCount: nil, sequence: 1 - ) - try await store.upsertPendingSend(nilCountOnDeviceRadio) - try await store.upsertPendingSend(orphanOnUnknownRadio) - - try await store.warmUp() - - let survivingForDevice = try await store.fetchPendingSends(radioID: radioWithDevice) - let survivingForUnknown = try await store.fetchPendingSends(radioID: radioWithoutDevice) - #expect(survivingForDevice.isEmpty, - "warmUp must run both purges: nil-attemptCount rows deleted even when radio has a paired device") - #expect(survivingForUnknown.isEmpty, - "row attached to no-device radio must be purged by purgeOrphanPendingSends") - } - - @Test("deletePendingSendsForMessage public API saves on return") - func testDeletePendingSendsForMessage_PublicAPISavesOnReturn() async throws { - let store = try await createTestStore() - let radioID = UUID() - let messageID = UUID() - try await store.upsertPendingSend(makePendingSendDTO( - messageID: messageID, radioID: radioID, attemptCount: 1, sequence: 1 - )) - - try await store.deletePendingSendsForMessage(messageID: messageID) - - // No explicit save from the test — visibility of the deletion to a - // subsequent fetch confirms the public method saved on return, - // matching the contract expected by ChatSendQueueService callers. - let hasPending = try await store.hasPendingSend(messageID: messageID) - #expect(hasPending == false, - "public deletePendingSendsForMessage must save before returning") - } - - // MARK: - PendingSend Cascade Tests - - @Test("deleteMessage cascades the matching PendingSend in a single transaction") - func testDeleteMessage_CascadesPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let messageID = UUID() - let message = MessageDTO(from: Message( - id: messageID, - radioID: device.id, - contactID: nil, - channelIndex: 0, - text: "hello", - timestamp: UInt32(Date().timeIntervalSince1970) - )) - try await store.saveMessage(message) - - let pending = makePendingSendDTO(messageID: messageID, radioID: device.id, attemptCount: 0) - try await store.upsertPendingSend(pending) - - try await store.deleteMessage(id: messageID) - - let remainingPending = try await store.fetchPendingSends(radioID: device.id) - #expect(remainingPending.isEmpty, - "deleteMessage must cascade the PendingSend row keyed by the deleted messageID") - let remainingMessages = try await store.fetchAllMessages(radioID: device.id) - #expect(remainingMessages.isEmpty, - "deleteMessage must still remove the Message row") - } - - @Test("deleteMessage reaps an orphan PendingSend even when no Message row exists") - func testDeleteMessage_NoMatchingMessage_StillReapsOrphanPendingSend() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // An orphan PendingSend with no corresponding Message — can arise from - // a same-millisecond race between deleteMessage and upsertPendingSend. - let orphanMessageID = UUID() - let pending = makePendingSendDTO( - messageID: orphanMessageID, radioID: device.id, attemptCount: 0 - ) - try await store.upsertPendingSend(pending) - - try await store.deleteMessage(id: orphanMessageID) - - let remainingPending = try await store.fetchPendingSends(radioID: device.id) - #expect(remainingPending.isEmpty, - "deleteMessage must reap orphan PendingSends even without a matching Message row") - } - - @Test("deleteDeviceData reaps radio-scoped orphan PendingSends and preserves the Device row") - func testDeleteDeviceData_ReapsRadioOrphanPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Message + matching PendingSend (messageID cascade reaches this row). - let matchedMessageID = UUID() - let matchedMessage = MessageDTO(from: Message( - id: matchedMessageID, - radioID: device.id, - contactID: nil, - channelIndex: 0, - text: "matched", - timestamp: UInt32(Date().timeIntervalSince1970) - )) - try await store.saveMessage(matchedMessage) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: matchedMessageID, radioID: device.id, attemptCount: 0, sequence: 1 - )) - - // PendingSend whose messageID does not correspond to any saved Message. - // The messageIDs-keyed cascade cannot see it; the radioID-keyed defensive - // delete must reap it. - try await store.upsertPendingSend(makePendingSendDTO( - messageID: UUID(), radioID: device.id, attemptCount: 0, sequence: 2 - )) - - try await store.deleteDeviceData(id: device.id) - - let surviving = try await store.fetchPendingSends(radioID: device.id) - #expect(surviving.isEmpty, - "deleteDeviceData must reap both Message-matched and orphan PendingSends for the radio") - - let fetchedDevice = try await store.fetchDevice(id: device.id) - #expect(fetchedDevice != nil, - "deleteDeviceData must preserve the Device row") - } - - @Test("deleteMessagesForContact cascades PendingSends and spares unrelated contacts") - func testDeleteMessagesForContact_CascadesPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame1 = createTestContactFrame(name: "Contact1") - let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) - let frame2 = createTestContactFrame(name: "Contact2") - let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) - - var contact1MessageIDs: [UUID] = [] - for i in 0..<3 { - let messageID = UUID() - contact1MessageIDs.append(messageID) - let message = MessageDTO(from: Message( - id: messageID, - radioID: device.id, - contactID: contact1ID, - text: "C1 \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 - )) - } - - let contact2MessageID = UUID() - let contact2Message = MessageDTO(from: Message( - id: contact2MessageID, - radioID: device.id, - contactID: contact2ID, - text: "C2 keep", - timestamp: UInt32(Date().timeIntervalSince1970) + 100 - )) - try await store.saveMessage(contact2Message) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: contact2MessageID, radioID: device.id, attemptCount: 0, sequence: 99 - )) - - try await store.deleteMessagesForContact(contactID: contact1ID) - - let remaining = try await store.fetchPendingSends(radioID: device.id) - #expect(remaining.count == 1, - "only the unrelated contact's PendingSend should survive") - #expect(remaining.first?.messageID == contact2MessageID, - "surviving PendingSend must belong to the untouched contact") - - let contact2Messages = try await store.fetchMessages(contactID: contact2ID) - #expect(contact2Messages.count == 1, - "unrelated contact's Message row must be preserved") - } - - @Test("deleteMessagesForChannel cascades PendingSends and spares other channels") - func testDeleteMessagesForChannel_CascadesPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let targetChannel: UInt8 = 0 - let untouchedChannel: UInt8 = 1 - - for i in 0..<3 { - let messageID = UUID() - let message = MessageDTO(from: Message( - id: messageID, - radioID: device.id, - contactID: nil, - channelIndex: targetChannel, - text: "Ch0 \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 - )) - } - - let untouchedMessageID = UUID() - let untouchedMessage = MessageDTO(from: Message( - id: untouchedMessageID, - radioID: device.id, - contactID: nil, - channelIndex: untouchedChannel, - text: "Ch1 keep", - timestamp: UInt32(Date().timeIntervalSince1970) + 100 - )) - try await store.saveMessage(untouchedMessage) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: untouchedMessageID, radioID: device.id, attemptCount: 0, sequence: 99 - )) - - try await store.deleteMessagesForChannel(radioID: device.id, channelIndex: targetChannel) - - let remaining = try await store.fetchPendingSends(radioID: device.id) - #expect(remaining.count == 1, - "only the untouched channel's PendingSend should survive") - #expect(remaining.first?.messageID == untouchedMessageID, - "surviving PendingSend must belong to the untouched channel") - - let untouchedChannelMessages = try await store.fetchMessages( - radioID: device.id, channelIndex: untouchedChannel - ) - #expect(untouchedChannelMessages.count == 1, - "unrelated channel's Message row must be preserved") - } - - @Test("deleteChannelMessages(fromSender:) cascades PendingSends and spares other senders") - func testDeleteChannelMessagesFromSender_CascadesPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex: UInt8 = 0 - let targetSender = "Spammer" - let untouchedSender = "Friend" - - for i in 0..<3 { - let messageID = UUID() - let message = MessageDTO(from: Message( - id: messageID, - radioID: device.id, - contactID: nil, - channelIndex: channelIndex, - text: "spam \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i), - senderNodeName: targetSender - )) - try await store.saveMessage(message) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 - )) - } - - let untouchedMessageID = UUID() - let untouchedMessage = MessageDTO(from: Message( - id: untouchedMessageID, - radioID: device.id, - contactID: nil, - channelIndex: channelIndex, - text: "friend", - timestamp: UInt32(Date().timeIntervalSince1970) + 100, - senderNodeName: untouchedSender - )) - try await store.saveMessage(untouchedMessage) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: untouchedMessageID, radioID: device.id, attemptCount: 0, sequence: 99 - )) - - try await store.deleteChannelMessages(fromSender: targetSender, radioID: device.id) - - let remaining = try await store.fetchPendingSends(radioID: device.id) - #expect(remaining.count == 1, - "only the other sender's PendingSend should survive") - #expect(remaining.first?.messageID == untouchedMessageID, - "surviving PendingSend must belong to the untouched sender") - } - - // MARK: - RxLogEntry Tests - - private func createTestRxLogEntryDTO( - radioID: UUID, - senderTimestamp: UInt32? = nil, - regionScope: String? = nil, - payloadTypeBits: UInt8 = 5, - transportCode: Data? = nil, - channelIndex: UInt8? = 1, - packetPayload: Data = Data([0xAB, 0xCD, 0xEF]) - ) -> RxLogEntryDTO { - // Create minimal ParsedRxLogData for the DTO - let parsed = ParsedRxLogData( - snr: 10.5, - rssi: -65, - rawPayload: Data([0x15, 0x01, 0x02, 0x03]), - routeType: .flood, - payloadType: .groupText, - payloadVersion: 0, - payloadTypeBits: payloadTypeBits, - transportCode: transportCode, - pathLength: 1, - pathNodes: [0x42], - packetPayload: packetPayload - ) - - return RxLogEntryDTO( - radioID: radioID, - from: parsed, - channelIndex: channelIndex, - channelName: "TestChannel", - decryptStatus: .success, - senderTimestamp: senderTimestamp, - regionScope: regionScope, - decodedText: "Hello mesh!" - ) - } - - @Test("RxLogEntryDTO(from:) falls back on out-of-range stored values instead of trapping") - func rxLogEntryDTOClampsOutOfRangeStoredValues() { - let model = RxLogEntry( - radioID: UUID(), - routeType: 999, - payloadType: -1, - payloadVersion: 5_000, - pathLength: 400, - pathNodes: Data(), - packetPayload: Data(), - rawPayload: Data(), - packetHash: "deadbeef", - channelIndex: 9_999, - senderTimestamp: -10 - ) - - let dto = RxLogEntryDTO(from: model) - - #expect(dto.routeType == .flood) - #expect(dto.payloadType == .unknown) - #expect(dto.payloadVersion == 0) - #expect(dto.pathLength == 0) - #expect(dto.channelIndex == nil) - #expect(dto.senderTimestamp == nil) - } - - @Test("Save and fetch RxLogEntry preserves senderTimestamp") - func saveAndFetchRxLogEntryPreservesSenderTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let expectedTimestamp: UInt32 = 1703123456 - let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: expectedTimestamp) - - try await store.saveRxLogEntry(dto) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.count == 1) - #expect(entries.first?.senderTimestamp == expectedTimestamp) - } - - @Test("Save and fetch RxLogEntry with nil senderTimestamp") - func saveAndFetchRxLogEntryWithNilTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: nil) - - try await store.saveRxLogEntry(dto) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.count == 1) - #expect(entries.first?.senderTimestamp == nil) - } - - @Test("RxLogEntryDTO init from model preserves senderTimestamp") - func rxLogEntryDTOInitFromModelPreservesTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Save with timestamp - let expectedTimestamp: UInt32 = 1703123456 - let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: expectedTimestamp) - try await store.saveRxLogEntry(dto) - - // Fetch back (this uses RxLogEntryDTO.init(from: RxLogEntry)) - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.first?.senderTimestamp == expectedTimestamp) - - // Verify the conversion handles the Int -> UInt32 correctly - // The model stores Int, DTO uses UInt32 - #expect(entries.first?.senderTimestamp == 1703123456) - } - - @Test("RX log prune is deferred until threshold is exceeded") - func rxLogPruneDefersUntilThresholdExceeded() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - for index in 0..<1_100 { - let dto = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: UInt32(index) - ) - try await store.saveRxLogEntry(dto) - try await store.pruneRxLogEntries(radioID: device.id) - } - - let entriesBeforeThreshold = try await store.fetchRxLogEntries(radioID: device.id, limit: 1_200) - #expect(entriesBeforeThreshold.count == 1_100) - - let thresholdEntry = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: UInt32(1_100) - ) - try await store.saveRxLogEntry(thresholdEntry) - try await store.pruneRxLogEntries(radioID: device.id) - - let entriesAfterThreshold = try await store.fetchRxLogEntries(radioID: device.id, limit: 1_200) - #expect(entriesAfterThreshold.count == 1_000) - #expect(entriesAfterThreshold.first?.senderTimestamp == 1_100) - #expect(entriesAfterThreshold.last?.senderTimestamp == 101) - } - - @Test("Clearing RX log resets cached count for future pruning") - func clearRxLogResetsCachedCount() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - for index in 0..<1_101 { - let dto = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: UInt32(index) - ) - try await store.saveRxLogEntry(dto) - } - try await store.pruneRxLogEntries(radioID: device.id) - try await store.clearRxLogEntries(radioID: device.id) - - let replacement = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: 42) - try await store.saveRxLogEntry(replacement) - try await store.pruneRxLogEntries(radioID: device.id) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.count == 1) - #expect(entries.first?.senderTimestamp == 42) - } - - @Test("A store rebuilt over a populated container seeds its prune cache from disk") - func rxLogPruneSeedsCacheFromDiskOnRebuiltStore() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let radioID = UUID() - - let storeA = PersistenceStore(modelContainer: container) - try await storeA.saveDevice(createTestDevice().copy { $0.id = radioID; $0.radioID = radioID }) - - // Fill to the retention cap (keepCount + pruneThreshold) without exceeding it. - for index in 0..<1_100 { - try await storeA.saveRxLogEntry( - createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: UInt32(index)) - ) - try await storeA.pruneRxLogEntries(radioID: radioID) - } - let beforeReconnect = try await storeA.fetchRxLogEntries(radioID: radioID, limit: 1_200) - #expect(beforeReconnect.count == 1_100) - - // Reconnect: a new store over the same container starts with a cold cache. - // Writing one full prune cycle past the cap drives the count back to keepCount - // only if storeB seeded from disk; a cold-from-zero cache never trips the gate. - let storeB = PersistenceStore(modelContainer: container) - for index in 1_100..<1_202 { - try await storeB.saveRxLogEntry( - createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: UInt32(index)) - ) - try await storeB.pruneRxLogEntries(radioID: radioID) - } - - // Pruning only fires if storeB seeded its count from disk rather than from zero. - let afterReconnect = try await storeB.fetchRxLogEntries(radioID: radioID, limit: 1_300) - #expect(afterReconnect.count == 1_000) - } - - // MARK: - Region Scope Tests - - @Test("saveRxLogEntry forwards regionScope to the persisted model") - func saveRxLogEntryForwardsRegionScope() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let dto = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: 1_703_000_000, - regionScope: "Germany" - ) - try await store.saveRxLogEntry(dto) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.first?.regionScope == "Germany") - } - - @Test("saveRxLogEntry preserves payloadTypeBits including unknown nibbles") - func saveRxLogEntryPreservesPayloadTypeBits() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let dto = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: 1_703_000_001, - payloadTypeBits: 0x0C - ) - try await store.saveRxLogEntry(dto) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.first?.payloadTypeBits == 0x0C) - } - - @Test("batchUpdateChannelMessageRegion back-fills normal-case message via timestamp fallback") - func batchUpdateChannelMessageRegionBackfillsNormalCase() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let wireTimestamp: UInt32 = 1_703_111_111 - // Normal case: senderTimestamp stays nil, wire timestamp lives on `timestamp`. - let dto = MessageDTO.testChannelMessage( - radioID: device.id, - channelIndex: 0, - timestamp: wireTimestamp, - direction: .incoming, - status: .delivered - ) - try await store.saveMessage(dto) - - try await store.batchUpdateChannelMessageRegion( - radioID: device.id, - updates: [(channelIndex: 0, senderTimestamp: wireTimestamp, regionScope: "Germany")] - ) - - let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 0) - #expect(saved.first?.regionScope == "Germany") - } - - @Test("batchUpdateChannelMessageRegion back-fills timestamp-corrected message") - func batchUpdateChannelMessageRegionBackfillsCorrectedCase() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let originalWire: UInt32 = 1_703_222_222 - var dto = MessageDTO.testChannelMessage( - radioID: device.id, - channelIndex: 1, - timestamp: UInt32(Date().timeIntervalSince1970), - direction: .incoming, - status: .delivered - ) - dto.senderTimestamp = originalWire - try await store.saveMessage(dto) - - try await store.batchUpdateChannelMessageRegion( - radioID: device.id, - updates: [(channelIndex: 1, senderTimestamp: originalWire, regionScope: "USA")] - ) - - let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 1) - #expect(saved.first?.regionScope == "USA") - } - - @Test("batchUpdateChannelMessageRegion skips outgoing messages") - func batchUpdateChannelMessageRegionSkipsOutgoing() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let wireTimestamp: UInt32 = 1_703_333_333 - let dto = MessageDTO.testChannelMessage( - radioID: device.id, - channelIndex: 2, - timestamp: wireTimestamp, - direction: .outgoing, - status: .sent - ) - try await store.saveMessage(dto) - - try await store.batchUpdateChannelMessageRegion( - radioID: device.id, - updates: [(channelIndex: 2, senderTimestamp: wireTimestamp, regionScope: "France")] - ) - - let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 2) - #expect(saved.first?.regionScope == nil) - } - - @Test("batchUpdateDMMessageRegion back-fills DM by sender prefix byte") - func batchUpdateDMMessageRegionBackfillsByPrefix() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let wireTimestamp: UInt32 = 1_703_444_444 - let senderKey = Data([0xAB, 0xCD, 0xEF, 0x01, 0x02, 0x03]) - let contactID = UUID() - let dto = MessageDTO.testDirectMessage( - radioID: device.id, - contactID: contactID, - timestamp: wireTimestamp, - direction: .incoming, - status: .delivered, - senderKeyPrefix: senderKey - ) - try await store.saveMessage(dto) - - try await store.batchUpdateDMMessageRegion( - radioID: device.id, - updates: [(senderPrefixByte: 0xAB, senderTimestamp: wireTimestamp, regionScope: "Germany")] - ) - - let saved = try await store.fetchMessages(contactID: contactID) - #expect(saved.first?.regionScope == "Germany") - } - - @Test("batchUpdateDMMessageRegion ignores DMs from other senders at same timestamp") - func batchUpdateDMMessageRegionIgnoresOtherSenders() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let wireTimestamp: UInt32 = 1_703_555_555 - let aliceKey = Data([0xAA, 0x11, 0x22, 0x33, 0x44, 0x55]) - let bobKey = Data([0xBB, 0x66, 0x77, 0x88, 0x99, 0x00]) - let aliceContact = UUID() - let bobContact = UUID() - - let alice = MessageDTO.testDirectMessage( - radioID: device.id, - contactID: aliceContact, - text: "From Alice", - timestamp: wireTimestamp, - direction: .incoming, - status: .delivered, - senderKeyPrefix: aliceKey - ) - let bob = MessageDTO.testDirectMessage( - radioID: device.id, - contactID: bobContact, - text: "From Bob", - timestamp: wireTimestamp, - direction: .incoming, - status: .delivered, - senderKeyPrefix: bobKey - ) - try await store.saveMessage(alice) - try await store.saveMessage(bob) - - try await store.batchUpdateDMMessageRegion( - radioID: device.id, - updates: [(senderPrefixByte: 0xAA, senderTimestamp: wireTimestamp, regionScope: "Germany")] - ) - - let aliceSaved = try await store.fetchMessages(contactID: aliceContact) - let bobSaved = try await store.fetchMessages(contactID: bobContact) - #expect(aliceSaved.first?.regionScope == "Germany") - #expect(bobSaved.first?.regionScope == nil) - } - - // MARK: - Mute Tests - - @Test("Set contact muted") - func setContactMuted() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame(name: "Alice") - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // Initially not muted - var contact = try await store.fetchContact(id: contactID) - #expect(contact?.isMuted == false) - - // Mute - try await store.setContactMuted(contactID, isMuted: true) - contact = try await store.fetchContact(id: contactID) - #expect(contact?.isMuted == true) - - // Unmute - try await store.setContactMuted(contactID, isMuted: false) - contact = try await store.fetchContact(id: contactID) - #expect(contact?.isMuted == false) - } - - @Test("Muted contacts excluded from badge count") - func mutedContactsExcludedFromBadgeCount() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Create contact with unreads - let frame1 = createTestContactFrame(name: "Alice") - let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) - try await store.incrementUnreadCount(contactID: contact1ID) - try await store.incrementUnreadCount(contactID: contact1ID) - - // Create muted contact with unreads - let frame2 = createTestContactFrame(name: "Bob") - let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) - try await store.incrementUnreadCount(contactID: contact2ID) - try await store.setContactMuted(contact2ID, isMuted: true) - - let (contacts, _, _) = try await store.getTotalUnreadCounts(radioID: device.id) - - // Only Alice's 2 unreads should count, Bob is muted - #expect(contacts == 2) - } - - @Test("Notification levels affect badge count correctly") - func notificationLevelsAffectBadgeCount() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Create channel with unreads - let channelInfo = ChannelInfo(index: 1, name: "Test", secret: Data(repeating: 0x42, count: 16)) - let channelID = try await store.saveChannel(radioID: device.id, from: channelInfo) - try await store.incrementChannelUnreadCount(channelID: channelID) - try await store.incrementChannelUnreadCount(channelID: channelID) - - // Default (all) - should count all unreads - var counts = try await store.getTotalUnreadCounts(radioID: device.id) - #expect(counts.channels == 2) - - // Muted - should exclude from badge - try await store.setChannelNotificationLevel(channelID, level: .muted) - counts = try await store.getTotalUnreadCounts(radioID: device.id) - #expect(counts.channels == 0) - - // Mentions only with no mentions - should show 0 - try await store.setChannelNotificationLevel(channelID, level: .mentionsOnly) - counts = try await store.getTotalUnreadCounts(radioID: device.id) - #expect(counts.channels == 0) - - // Mentions only with mentions - should show mention count - try await store.incrementChannelUnreadMentionCount(channelID: channelID) - counts = try await store.getTotalUnreadCounts(radioID: device.id) - #expect(counts.channels == 1) - } - - // MARK: - Ghost Identity Reconciliation Tests - - @Test("reconcileGhostIdentity rewrites current device when ghost matches publicKey") - func reconcileGhostIdentityHappyPath() async throws { - let store = try await createTestStore() - - let oldPublicKey = Data((0.. .pending is not a constraint violation because the row can - // only ever reach .delivered by going forward through .sent again. - let remapped = try await store.updateMessageStatusUnlessDelivered(id: messageID, status: .pending) - #expect(remapped == true) - #expect(try await store.fetchMessage(id: messageID)?.status == .pending) - - // A subsequent successful redelivery still reaches .delivered. - try await store.updateMessageAck(id: messageID, ackCode: 0x12345678, status: .delivered) - #expect(try await store.fetchMessage(id: messageID)?.status == .delivered) - } - - @Test("hasOutgoingSentDM flags a stuck .sent DM by ackCode and ignores other rows") - func hasOutgoingSentDMDetectsOrphan() async throws { - let store = try await createTestStore() - let ackCode: UInt32 = 0xCAFEF00D - - let sentID = UUID() - try await store.saveMessage( - MessageDTO.testDirectMessage(id: sentID, status: .sent, ackCode: ackCode) - ) - #expect(try await store.hasOutgoingSentDM(ackCode: ackCode) == true) - - // A different ackCode must not match. - #expect(try await store.hasOutgoingSentDM(ackCode: 0x00000001) == false) - - // A delivered row with the same ackCode is not an orphan. - let deliveredID = UUID() - try await store.saveMessage( - MessageDTO.testDirectMessage(id: deliveredID, status: .delivered, ackCode: 0xBADC0DE5) - ) - #expect(try await store.hasOutgoingSentDM(ackCode: 0xBADC0DE5) == false) - } - - // MARK: - Inbound Advert Hop Count - - @Test("setInboundHopCount round-trips onto an existing discovered node") - func setInboundHopCountRoundTrips() async throws { - let store = try await createTestStore() - let radioID = UUID() - let frame = createTestContactFrame(name: "Advertiser") - let (node, _) = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) - - try await store.setInboundHopCount(radioID: radioID, publicKey: node.publicKey, hopCount: 4, advertTimestamp: 100) - - let fetched = try await store.fetchDiscoveredNodes(radioID: radioID) - #expect(fetched.count == 1) - #expect(fetched.first?.inboundHopCount == 4) - #expect(fetched.first?.inboundHopAdvertTimestamp == 100) - } - - @Test("setInboundHopCount is a no-op when no matching row exists") - func setInboundHopCountNoRowNoOp() async throws { - let store = try await createTestStore() - let radioID = UUID() - let unknownKey = Data((0.. PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func createTestDevice(id: UUID = UUID()) -> DeviceDTO { + DeviceDTO.testDevice( + id: id, + publicKey: Data((0.. ContactFrame { + ContactFrame( + publicKey: Data((0.. ( + contactID: UUID, messageID: UUID, channelID: UUID, sessionID: UUID + ) { + let contactFrame = createTestContactFrame(name: "TestContact") + let contactID = try await store.saveContact(radioID: radioID, from: contactFrame) + + let message = MessageDTO(from: Message( + radioID: radioID, + contactID: contactID, + text: "Hello!", + timestamp: UInt32(Date().timeIntervalSince1970) + )) + try await store.saveMessage(message) + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + + let channelInfo = ChannelInfo(index: 1, name: "Private", secret: Data(repeating: 0x42, count: 16)) + let channelID = try await store.saveChannel(radioID: radioID, from: channelInfo) + + let reaction = ReactionDTO( + messageID: message.id, + emoji: "👍", + senderName: "Reactor", + messageHash: "AABBCCDD", + rawText: "👍", + radioID: radioID + ) + try await store.saveReaction(reaction) + + let session = createTestRoomSession(radioID: radioID) + try await store.saveRemoteNodeSessionDTO(session) + + let roomMessage = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + authorName: "Author", + text: "Room message", + timestamp: UInt32(Date().timeIntervalSince1970) + ) + try await store.saveRoomMessage(roomMessage) + + let blocked = BlockedChannelSenderDTO(name: "Spammer", radioID: radioID) + try await store.saveBlockedChannelSender(blocked) + + let rxLog = createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: 12345) + try await store.saveRxLogEntry(rxLog) + + let discoveredFrame = createTestContactFrame(name: "Discovered") + _ = try await store.upsertDiscoveredNode(radioID: radioID, from: discoveredFrame) + + return (contactID, message.id, channelID, session.id) + } + + /// Asserts all entity types for a device are present. + private func assertAllDataExists( + store: PersistenceStore, radioID: UUID, sessionID: UUID, messageID: UUID + ) async throws { + let contacts = try await store.fetchContacts(radioID: radioID) + #expect(contacts.count == 1, "Expected 1 contact") + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.count == 1, "Expected 1 channel") + let reactions = try await store.fetchReactions(for: messageID) + #expect(reactions.count == 1, "Expected 1 reaction") + let sessions = try await store.fetchRemoteNodeSessions(radioID: radioID) + #expect(sessions.count == 1, "Expected 1 session") + let roomMessages = try await store.fetchRoomMessages(sessionID: sessionID) + #expect(roomMessages.count == 1, "Expected 1 room message") + let blockedSenders = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(blockedSenders.count == 1, "Expected 1 blocked sender") + let rxEntries = try await store.fetchRxLogEntries(radioID: radioID) + #expect(rxEntries.count == 1, "Expected 1 RX log entry") + let discoveredNodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(discoveredNodes.count == 1, "Expected 1 discovered node") + } + + /// Asserts all entity types for a device have been deleted. + private func assertAllDataDeleted( + store: PersistenceStore, radioID: UUID, sessionID: UUID, messageID: UUID + ) async throws { + let contacts = try await store.fetchContacts(radioID: radioID) + #expect(contacts.isEmpty, "Expected no contacts") + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.isEmpty, "Expected no channels") + let reactions = try await store.fetchReactions(for: messageID) + #expect(reactions.isEmpty, "Expected no reactions") + let sessions = try await store.fetchRemoteNodeSessions(radioID: radioID) + #expect(sessions.isEmpty, "Expected no sessions") + let roomMessages = try await store.fetchRoomMessages(sessionID: sessionID) + #expect(roomMessages.isEmpty, "Expected no room messages") + let blockedSenders = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(blockedSenders.isEmpty, "Expected no blocked senders") + let rxEntries = try await store.fetchRxLogEntries(radioID: radioID) + #expect(rxEntries.isEmpty, "Expected no RX log entries") + let discoveredNodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(discoveredNodes.isEmpty, "Expected no discovered nodes") + let repeats = try await store.fetchMessageRepeats(messageID: messageID) + #expect(repeats.isEmpty, "Expected no message repeats") + } + + @Test + func `deleteDevice removes only device record, preserves all associated data`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let ids = try await seedAllEntityTypes(store: store, radioID: device.id) + + try await store.deleteDevice(id: device.id) + + let fetchedDevice = try await store.fetchDevice(id: device.id) + #expect(fetchedDevice == nil, "Device record should be deleted") + + try await assertAllDataExists( + store: store, radioID: device.id, + sessionID: ids.sessionID, messageID: ids.messageID + ) + } + + @Test + func `deleteDeviceData removes all associated data but not device record`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let ids = try await seedAllEntityTypes(store: store, radioID: device.id) + + try await store.deleteDeviceData(id: device.id) + + let fetchedDevice = try await store.fetchDevice(id: device.id) + #expect(fetchedDevice != nil, "Device record should be preserved") + + try await assertAllDataDeleted( + store: store, radioID: device.id, + sessionID: ids.sessionID, messageID: ids.messageID + ) + } + + @Test + func `deleteDeviceAndData removes device and all data atomically`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let ids = try await seedAllEntityTypes(store: store, radioID: device.id) + + try await store.deleteDeviceAndData(id: device.id) + + let fetchedDevice = try await store.fetchDevice(id: device.id) + #expect(fetchedDevice == nil, "Device record should be deleted") + + try await assertAllDataDeleted( + store: store, radioID: device.id, + sessionID: ids.sessionID, messageID: ids.messageID + ) + } + + @Test + func `deleteDeviceAndData does not trap when a message has heard repeats (cascade-vs-batch)`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let contactID = try await store.saveContact(radioID: device.id, from: createTestContactFrame()) + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "broadcast", + timestamp: UInt32(Date().timeIntervalSince1970), + directionRawValue: MessageDirection.outgoing.rawValue + )) + try await store.saveMessage(message) + // Two heard repeats wire up message.repeats so the delete exercises cascade + // propagation over a relationship whose child rows were batch-deleted first. + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + + try await store.deleteDeviceAndData(id: device.id) + + #expect(try await store.fetchDevice(id: device.id) == nil) + #expect(try await store.fetchMessage(id: message.id) == nil) + #expect(try await store.fetchMessageRepeats(messageID: message.id).isEmpty) + } + + @Test + func `deleteDeviceData reaps a message and its heard repeats while preserving the device`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let contactID = try await store.saveContact(radioID: device.id, from: createTestContactFrame()) + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "broadcast", + timestamp: UInt32(Date().timeIntervalSince1970), + directionRawValue: MessageDirection.outgoing.rawValue + )) + try await store.saveMessage(message) + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + + try await store.deleteDeviceData(id: device.id) + + #expect(try await store.fetchDevice(id: device.id) != nil) + #expect(try await store.fetchMessage(id: message.id) == nil) + #expect(try await store.fetchMessageRepeats(messageID: message.id).isEmpty) + } + + @Test + func `deleteDeviceData for non-existent device does not throw`() async throws { + let store = try await createTestStore() + try await store.deleteDeviceData(id: UUID()) + } + + @Test + func `deleteDeviceAndData for non-existent device does not throw`() async throws { + let store = try await createTestStore() + try await store.deleteDeviceAndData(id: UUID()) + } + + @Test + func `Re-pair after device deletion re-associates orphaned data`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let contactFrame = createTestContactFrame(name: "Survivor") + _ = try await store.saveContact(radioID: device.id, from: contactFrame) + + let channelInfo = ChannelInfo(index: 0, name: "General", secret: Data(repeating: 0, count: 16)) + _ = try await store.saveChannel(radioID: device.id, from: channelInfo) + + // Simulate ASK removal: delete device record only + try await store.deleteDevice(id: device.id) + + // Simulate re-pair: saveDevice upserts with same ID + try await store.saveDevice(device) + + let contacts = try await store.fetchContacts(radioID: device.id) + #expect(contacts.count == 1) + #expect(contacts.first?.name == "Survivor") + + let channels = try await store.fetchChannels(radioID: device.id) + #expect(channels.count == 1) + #expect(channels.first?.name == "General") + } + + @Test + func `Demote device to ghost preserves publicKey and radioID with fresh id`() async throws { + let store = try await createTestStore() + let original = createTestDevice().copy { + $0.isActive = true + } + try await store.saveDevice(original) + + try await store.demoteDeviceToGhost(id: original.id) + + let originalLookup = try await store.fetchDevice(id: original.id) + #expect(originalLookup == nil, "Original BLE id should no longer resolve") + + let ghost = try await store.fetchDevice(publicKey: original.publicKey) + #expect(ghost != nil) + #expect(ghost?.id != original.id, "Ghost must have a fresh id") + #expect(ghost?.publicKey == original.publicKey) + #expect(ghost?.radioID == original.radioID) + #expect(ghost?.isActive == false) + } + + @Test + func `Demote device strips all connection methods so it stays hidden`() async throws { + let store = try await createTestStore() + let wifi = ConnectionMethod.wifi(host: "10.0.0.5", port: 5000, displayName: nil) + let bluetooth = ConnectionMethod.bluetooth(peripheralUUID: UUID(), displayName: nil) + let original = createTestDevice().copy { + $0.connectionMethods = [wifi, bluetooth] + } + try await store.saveDevice(original) + + try await store.demoteDeviceToGhost(id: original.id) + + let ghost = try await store.fetchDevice(publicKey: original.publicKey) + #expect(ghost?.connectionMethods.isEmpty == true, + "Demoted ghost must have no connection methods so DeviceSelectionFilter hides it") + } + + @Test + func `Demote device with unknown id is a no-op`() async throws { + let store = try await createTestStore() + try await store.demoteDeviceToGhost(id: UUID()) + let devices = try await store.fetchDevices() + #expect(devices.isEmpty) + } + + @Test + func `Removing a paired device preserves child contacts via radioID`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let contactFrame = createTestContactFrame(name: "Alice") + _ = try await store.saveContact(radioID: device.radioID, from: contactFrame) + + try await store.demoteDeviceToGhost(id: device.id) + + let ghost = try await store.fetchDevice(publicKey: device.publicKey) + #expect(ghost?.radioID == device.radioID) + let contacts = try await store.fetchContacts(radioID: device.radioID) + #expect(contacts.count == 1) + #expect(contacts.first?.name == "Alice") + } + + // MARK: - Contact Tests + + @Test + func `Save and fetch contact from frame`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "Alice") + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + let contact = try await store.fetchContact(id: contactID) + #expect(contact != nil) + #expect(contact?.name == "Alice") + #expect(contact?.type == .chat) + } + + @Test + func `Fetch contact by public key`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "Bob") + _ = try await store.saveContact(radioID: device.id, from: frame) + + let contact = try await store.fetchContact(radioID: device.id, publicKey: frame.publicKey) + #expect(contact != nil) + #expect(contact?.name == "Bob") + } + + @Test + func `Update contact last message and unread count`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + let now = Date() + try await store.updateContactLastMessage(contactID: contactID, date: now) + try await store.incrementUnreadCount(contactID: contactID) + try await store.incrementUnreadCount(contactID: contactID) + + var contact = try await store.fetchContact(id: contactID) + #expect(contact?.unreadCount == 2) + #expect(contact?.lastMessageDate != nil) + + try await store.clearUnreadCount(contactID: contactID) + + contact = try await store.fetchContact(id: contactID) + #expect(contact?.unreadCount == 0) + } + + @Test + func `deleteMessagesForContact removes all messages for a contact`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Create first contact + let frame1 = createTestContactFrame(name: "Contact1") + let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) + + // Create multiple messages for this contact + for i in 0..<5 { + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contact1ID, + text: "Message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + } + + // Create a second contact with a message (should not be deleted) + let frame2 = createTestContactFrame(name: "Contact2") + let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) + let otherMessage = MessageDTO(from: Message( + radioID: device.id, + contactID: contact2ID, + text: "Other message", + timestamp: UInt32(Date().timeIntervalSince1970) + 100 + )) + try await store.saveMessage(otherMessage) + + // Verify messages exist before deletion + var contact1Messages = try await store.fetchMessages(contactID: contact1ID) + #expect(contact1Messages.count == 5) + + var contact2Messages = try await store.fetchMessages(contactID: contact2ID) + #expect(contact2Messages.count == 1) + + // Delete messages for the first contact + try await store.deleteMessagesForContact(contactID: contact1ID) + + // Verify messages for deleted contact are gone + contact1Messages = try await store.fetchMessages(contactID: contact1ID) + #expect(contact1Messages.isEmpty) + + // Verify messages for other contact still exist + contact2Messages = try await store.fetchMessages(contactID: contact2ID) + #expect(contact2Messages.count == 1) + } + + @Test + func `recomputeContactLastMessageDate keeps a conversation visible while older messages remain and clears it only when empty`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "Conversation") + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + let base = Date(timeIntervalSince1970: 1_700_000_000) + let messages = (0..<3).map { i in + MessageDTO.testDirectMessage( + radioID: device.id, + contactID: contactID, + text: "Message \(i)", + timestamp: UInt32(base.timeIntervalSince1970) + UInt32(i), + createdAt: base.addingTimeInterval(Double(i)) + ) + } + for message in messages { + try await store.saveMessage(message) + } + + // Newest message sets the date; the conversation is visible. + var newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) + #expect(newDate == messages[2].date) + var conversations = try await store.fetchConversations(radioID: device.id) + #expect(conversations.contains { $0.id == contactID }) + + // Deleting the newest message falls back to the next remaining message, + // not nil: the conversation must stay visible while messages remain. + try await store.deleteMessage(id: messages[2].id) + newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) + #expect(newDate == messages[1].date) + conversations = try await store.fetchConversations(radioID: device.id) + #expect(conversations.contains { $0.id == contactID }) + + // Deleting the last remaining messages clears the date and removes the conversation. + try await store.deleteMessage(id: messages[1].id) + try await store.deleteMessage(id: messages[0].id) + newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) + #expect(newDate == nil) + conversations = try await store.fetchConversations(radioID: device.id) + #expect(!conversations.contains { $0.id == contactID }) + } + + @Test + func `deleteMessagesForChannel removes all messages for a channel`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex0: UInt8 = 0 + let channelIndex1: UInt8 = 1 + + // Create messages for channel 0 + for i in 0..<5 { + let message = MessageDTO(from: Message( + radioID: device.id, + channelIndex: channelIndex0, + text: "Channel 0 Message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + } + + // Create messages for channel 1 (should not be deleted) + for i in 0..<3 { + let message = MessageDTO(from: Message( + radioID: device.id, + channelIndex: channelIndex1, + text: "Channel 1 Message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i + 100) + )) + try await store.saveMessage(message) + } + + // Create a contact message (should not be deleted) + let frame = createTestContactFrame(name: "Contact1") + let contactID = try await store.saveContact(radioID: device.id, from: frame) + let contactMessage = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Contact message", + timestamp: UInt32(Date().timeIntervalSince1970) + 200 + )) + try await store.saveMessage(contactMessage) + + // Verify messages exist before deletion + var channel0Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex0) + #expect(channel0Messages.count == 5) + + var channel1Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex1) + #expect(channel1Messages.count == 3) + + var contactMessages = try await store.fetchMessages(contactID: contactID) + #expect(contactMessages.count == 1) + + // Delete messages for channel 0 + try await store.deleteMessagesForChannel(radioID: device.id, channelIndex: channelIndex0) + + // Verify channel 0 messages are gone + channel0Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex0) + #expect(channel0Messages.isEmpty) + + // Verify channel 1 messages still exist + channel1Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex1) + #expect(channel1Messages.count == 3) + + // Verify contact messages still exist + contactMessages = try await store.fetchMessages(contactID: contactID) + #expect(contactMessages.count == 1) + } + + // MARK: - Message Tests + + @Test + func `Save and fetch messages for contact`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // Save multiple messages + for i in 0..<5 { + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + } + + let messages = try await store.fetchMessages(contactID: contactID) + #expect(messages.count == 5) + // Messages should be in chronological order (oldest first) + #expect(messages.first?.text == "Message 0") + #expect(messages.last?.text == "Message 4") + } + + @Test + func `Interleaved backlog from multiple senders reassembles in sortDate (send) order`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // Backlog drains in a scrambled receive order: rows arrive (createdAt) + // in the order 2, 0, 3, 1 but their send times (sortDate) are 0..<4. + // Display must reassemble by send order regardless of receive order. + let drainBase = Date(timeIntervalSince1970: 2_000_000) + let sendBase = Date(timeIntervalSince1970: 1_000_000) + let scrambledSendOrder = [2, 0, 3, 1] + for (receiveOffset, sendIndex) in scrambledSendOrder.enumerated() { + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Send \(sendIndex)", + timestamp: UInt32(sendBase.timeIntervalSince1970) + UInt32(sendIndex), + createdAt: drainBase.addingTimeInterval(TimeInterval(receiveOffset)), + sortDate: sendBase.addingTimeInterval(TimeInterval(sendIndex)) + )) + try await store.saveMessage(message) + } + + let messages = try await store.fetchMessages(contactID: contactID) + #expect(messages.map(\.text) == ["Send 0", "Send 1", "Send 2", "Send 3"]) + } + + @Test + func `Live message stays last even when an earlier-sortDate backlog row is inserted after it`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // A live row: sortDate == createdAt == now. + let now = Date(timeIntervalSince1970: 3_000_000) + let liveMessage = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Live", + timestamp: UInt32(now.timeIntervalSince1970), + createdAt: now, + sortDate: now + )) + try await store.saveMessage(liveMessage) + + // A backlog row inserted afterwards (later createdAt) but with an + // earlier send time. Its skewed sender clock must not let it sort + // above the live row. + let laterCreatedAt = now.addingTimeInterval(60) + let earlierSendTime = now.addingTimeInterval(-3600) + let backlogMessage = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Backlog", + timestamp: UInt32(earlierSendTime.timeIntervalSince1970), + createdAt: laterCreatedAt, + sortDate: earlierSendTime + )) + try await store.saveMessage(backlogMessage) + + let messages = try await store.fetchMessages(contactID: contactID) + #expect(messages.map(\.text) == ["Backlog", "Live"]) + #expect(messages.last?.text == "Live") + } + + @Test + func `Equal sortDate and timestamp fall back to createdAt order (tertiary key)`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // All three rows share an identical sortDate (primary key, e.g. un-backfilled + // rows that all defaulted to the same value) and an identical timestamp (secondary + // key), so only createdAt — the tertiary sort key — can break the tie. Varying + // timestamp too would let the secondary key drive the order and mask whether + // createdAt is honored. + let sharedSortDate = Date(timeIntervalSince1970: 4_000_000) + let sharedTimestamp = UInt32(sharedSortDate.timeIntervalSince1970) + let createdBase = Date(timeIntervalSince1970: 5_000_000) + for i in 0..<3 { + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Tie \(i)", + timestamp: sharedTimestamp, + createdAt: createdBase.addingTimeInterval(TimeInterval(i)), + sortDate: sharedSortDate + )) + try await store.saveMessage(message) + } + + let messages = try await store.fetchMessages(contactID: contactID) + #expect(messages.map(\.text) == ["Tie 0", "Tie 1", "Tie 2"]) + } + + @Test + func `Backlog block orders by send time within a shared drain anchor`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex: UInt8 = 0 + // One drain anchor shared by the whole block (block-at-reconnect). + let anchor = Date(timeIntervalSince1970: 1_700_000_000) + + /// Three different senders so reorderSameSenderClusters leaves them alone, + /// isolating the fetch's sort keys. createdAt (drain order) runs opposite to + /// send time, proving the secondary sort key is timestamp, not createdAt. + func backlogMessage(sender: String, sendTime: UInt32, drainOffset: TimeInterval) -> MessageDTO { + MessageDTO(from: Message( + radioID: device.id, + channelIndex: channelIndex, + text: sender, + timestamp: sendTime, + createdAt: anchor.addingTimeInterval(drainOffset), + sortDate: anchor, + directionRawValue: MessageDirection.incoming.rawValue, + senderNodeName: sender + )) + } + // Drained Carol, Bob, Alice (createdAt 0,1,2) but sent Alice < Bob < Carol. + try await store.saveMessage(backlogMessage(sender: "Carol", sendTime: 300, drainOffset: 0)) + try await store.saveMessage(backlogMessage(sender: "Bob", sendTime: 200, drainOffset: 1)) + try await store.saveMessage(backlogMessage(sender: "Alice", sendTime: 100, drainOffset: 2)) + + let messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex) + + #expect(messages.map(\.text) == ["Alice", "Bob", "Carol"]) + } + + @Test + func `Find channel message for reaction within timestamp window`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex: UInt8 = 1 + let baseTimestamp: UInt32 = 1_700_000_000 + var targetMessage: MessageDTO? + + for i in 0..<120 { + let timestamp = baseTimestamp + UInt32(i) + let message = MessageDTO( + id: UUID(), + radioID: device.id, + contactID: nil, + channelIndex: channelIndex, + text: "Message \(i)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: "RemoteNode", + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + try await store.saveMessage(message) + if i == 80 { + targetMessage = message + } + } + + let message = try #require(targetMessage) + let reactionService = ReactionService() + let reactionText = reactionService.buildReactionText( + emoji: "👍", + targetSender: "RemoteNode", + targetText: message.text, + targetTimestamp: message.timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + let now = message.timestamp + let windowStart = now > 300 ? now - 300 : 0 + let windowEnd = now + 300 + + let found = try await store.findChannelMessageForReaction( + radioID: device.id, + channelIndex: channelIndex, + parsedReaction: parsed, + localNodeName: "LocalNode", + timestampWindow: windowStart...windowEnd, + limit: 200 + ) + + #expect(found?.id == message.id) + } + + @Test + func `Find outgoing channel message for reaction using local node name`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex: UInt8 = 2 + let timestamp: UInt32 = 1_700_000_200 + + let outgoingMessage = MessageDTO( + id: UUID(), + radioID: device.id, + contactID: nil, + channelIndex: channelIndex, + text: "Local message", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: .outgoing, + status: .sent, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + try await store.saveMessage(outgoingMessage) + + let reactionService = ReactionService() + let reactionText = reactionService.buildReactionText( + emoji: "🔥", + targetSender: "LocalNode", + targetText: outgoingMessage.text, + targetTimestamp: outgoingMessage.timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + let now = outgoingMessage.timestamp + let windowStart = now > 300 ? now - 300 : 0 + let windowEnd = now + 300 + + let found = try await store.findChannelMessageForReaction( + radioID: device.id, + channelIndex: channelIndex, + parsedReaction: parsed, + localNodeName: "LocalNode", + timestampWindow: windowStart...windowEnd, + limit: 200 + ) + + #expect(found?.id == outgoingMessage.id) + } + + @Test + func `Update message status`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Test", + statusRawValue: MessageStatus.pending.rawValue + )) + try await store.saveMessage(message) + + // Update status to sending + try await store.updateMessageStatus(id: message.id, status: .sending) + var fetched = try await store.fetchMessage(id: message.id) + #expect(fetched?.status == .sending) + + // Update status to sent + try await store.updateMessageStatus(id: message.id, status: .sent) + fetched = try await store.fetchMessage(id: message.id) + #expect(fetched?.status == .sent) + } + + // MARK: - Channel Tests + + @Test + func `Save and fetch channels`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Add public channel + let publicChannel = ChannelInfo(index: 0, name: "Public", secret: Data(repeating: 0, count: 16)) + _ = try await store.saveChannel(radioID: device.id, from: publicChannel) + + // Add private channel + let privateChannel = ChannelInfo(index: 1, name: "Private", secret: Data(repeating: 0x42, count: 16)) + _ = try await store.saveChannel(radioID: device.id, from: privateChannel) + + let channels = try await store.fetchChannels(radioID: device.id) + #expect(channels.count == 2) + #expect(channels[0].index == 0) + #expect(channels[0].name == "Public") + #expect(channels[1].index == 1) + #expect(channels[1].name == "Private") + } + + // MARK: - RemoteNodeSession Tests + + private func createTestRoomSession(radioID: UUID) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0..= firstDate!) + } + + @Test + func `Update room activity ignores older sync timestamps`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Set initial timestamp + try await store.updateRoomActivity(session.id, syncTimestamp: 5000) + + var fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.lastSyncTimestamp == 5000) + + // Try to update with older timestamp - sync timestamp should be ignored + try await store.updateRoomActivity(session.id, syncTimestamp: 3000) + + fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.lastSyncTimestamp == 5000) + // But lastMessageDate should still be updated + #expect(fetched?.lastMessageDate != nil) + } + + @Test + func `Update room activity without sync timestamp does not change lastSyncTimestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Set initial sync timestamp + try await store.updateRoomActivity(session.id, syncTimestamp: 5000) + + // Call without sync timestamp (send path) + try await store.updateRoomActivity(session.id) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.lastSyncTimestamp == 5000) + #expect(fetched?.lastMessageDate != nil) + } + + @Test + func `Mark room session connected changes isConnected and returns true`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Create a disconnected session with admin permission + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: false, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + let result = try await store.markRoomSessionConnected(session.id) + #expect(result == true) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == true) + // Permission level must not be changed + #expect(fetched?.permissionLevel == .admin) + } + + @Test + func `Mark room session connected returns false when already connected`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: true, + permissionLevel: .guest, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + let result = try await store.markRoomSessionConnected(session.id) + #expect(result == false) + } + + @Test + func `Mark session disconnected preserves permission level`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: true, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + try await store.markSessionDisconnected(session.id) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == false) + #expect(fetched?.permissionLevel == .admin) + } + + @Test + func `Mark session disconnected is no-op when already disconnected`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: false, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + try await store.markSessionDisconnected(session.id) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == false) + #expect(fetched?.permissionLevel == .admin) + } + + @Test + func `Disconnect then recover preserves permission level`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: true, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + try await store.markSessionDisconnected(session.id) + _ = try await store.markRoomSessionConnected(session.id) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == true) + #expect(fetched?.permissionLevel == .admin) + } + + @Test + func `Update remote node session connection can reset permission to guest`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: true, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + try await store.updateRemoteNodeSessionConnection( + id: session.id, + isConnected: false, + permissionLevel: .guest + ) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == false) + #expect(fetched?.permissionLevel == .guest) + } + + // MARK: - RoomMessage Tests + + @Test + func `Save and fetch room messages`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Save room messages + for i in 0..<3 { + let message = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + authorName: "Author\(i)", + text: "Room message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + ) + try await store.saveRoomMessage(message) + } + + let messages = try await store.fetchRoomMessages(sessionID: session.id) + #expect(messages.count == 3) + } + + @Test + func `Room messages tied on timestamp order deterministically by createdAt`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Same wire timestamp (1-second resolution) but distinct arrival times. Inserted + // out of arrival order so the fetch must impose the createdAt tie-break itself. + let sharedTimestamp = UInt32(Date().timeIntervalSince1970) + let base = Date(timeIntervalSince1970: 1_700_000_000) + let arrivals: [(text: String, createdAt: Date)] = [ + ("second", base.addingTimeInterval(1)), + ("third", base.addingTimeInterval(2)), + ("first", base) + ] + for arrival in arrivals { + let message = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + text: arrival.text, + timestamp: sharedTimestamp, + createdAt: arrival.createdAt + ) + try await store.saveRoomMessage(message) + } + + let messages = try await store.fetchRoomMessages(sessionID: session.id) + #expect(messages.map(\.text) == ["first", "second", "third"]) + } + + @Test + func `Room messages order primarily by wire timestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Distinct timestamps inserted out of order; arrival order is the inverse of + // send order to prove the timestamp key wins over createdAt. + let baseTimestamp = UInt32(Date().timeIntervalSince1970) + let arrival = Date(timeIntervalSince1970: 1_700_000_000) + let entries: [(text: String, offset: UInt32, arrivalOffset: TimeInterval)] = [ + ("newest", 2, 0), + ("oldest", 0, 2), + ("middle", 1, 1) + ] + for entry in entries { + let message = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + text: entry.text, + timestamp: baseTimestamp + entry.offset, + createdAt: arrival.addingTimeInterval(entry.arrivalOffset) + ) + try await store.saveRoomMessage(message) + } + + let messages = try await store.fetchRoomMessages(sessionID: session.id) + #expect(messages.map(\.text) == ["oldest", "middle", "newest"]) + } + + @Test + func `Room message deduplication`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + let timestamp = UInt32(Date().timeIntervalSince1970) + let authorKeyPrefix = Data([0x01, 0x02, 0x03, 0x04]) + let text = "Duplicate message" + + // Save message + let message1 = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: authorKeyPrefix, + text: text, + timestamp: timestamp + ) + try await store.saveRoomMessage(message1) + + // Try to save duplicate (same timestamp, author, and content hash) + let message2 = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: authorKeyPrefix, + text: text, + timestamp: timestamp + ) + try await store.saveRoomMessage(message2) + + // Should only have one message + let messages = try await store.fetchRoomMessages(sessionID: session.id) + #expect(messages.count == 1) + } + + // MARK: - Duplicate Session Cleanup Tests + + @Test + func `Cleanup duplicate remote node sessions keeps target and deletes others`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let sharedKey = Data((0.. PendingSendDTO { + PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: messageID, + kind: .dm, + contactID: UUID(), + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: sequence, + enqueuedAt: Date(), + attemptCount: attemptCount + ) + } + + @Test + func `incrementPendingSendAttemptCount from 0 bumps to 1`() async throws { + let store = try await createTestStore() + let messageID = UUID() + let dto = makePendingSendDTO(messageID: messageID, attemptCount: 0) + try await store.upsertPendingSend(dto) + + let result = try await store.incrementPendingSendAttemptCount(messageID: messageID) + #expect(result == 1, "first drain attempt should bump 0 → 1") + + let persisted = try await store.fetchPendingSends(radioID: dto.radioID).first + #expect(persisted?.attemptCount == 1, "persisted attemptCount should match return value") + } + + @Test + func `incrementPendingSendAttemptCount returns nil when no row matches`() async throws { + let store = try await createTestStore() + let messageID = UUID() + + let result = try await store.incrementPendingSendAttemptCount(messageID: messageID) + #expect(result == nil, "missing-row case is terminal — return nil instead of creating a new row") + } + + @Test + func `purgeLegacyAttemptCountRows deletes only legacy nil rows`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let legacyDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: nil, sequence: 1) + let raceDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: 0, sequence: 2) + let drainedDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: 3, sequence: 3) + try await store.upsertPendingSend(legacyDTO) + try await store.upsertPendingSend(raceDTO) + try await store.upsertPendingSend(drainedDTO) + + let deleted = try await store.purgeLegacyAttemptCountRows() + #expect(deleted == 1, "only the single nil-valued row should be deleted") + + let rows = try await store.fetchPendingSends(radioID: radioID) + let messageIDs = Set(rows.map(\.messageID)) + #expect(!messageIDs.contains(legacyDTO.messageID), "legacy nil row must be deleted") + let byMessageID = Dictionary(uniqueKeysWithValues: rows.map { ($0.messageID, $0.attemptCount) }) + #expect(byMessageID[raceDTO.messageID] == 0, "race-window 0 row stays at 0") + #expect(byMessageID[drainedDTO.messageID] == 3, "already-drained row stays untouched") + } + + @Test + func `purgeLegacyAttemptCountRows is idempotent`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: nil) + try await store.upsertPendingSend(dto) + + let firstDeleted = try await store.purgeLegacyAttemptCountRows() + let secondDeleted = try await store.purgeLegacyAttemptCountRows() + #expect(firstDeleted == 1, "first call deletes the legacy nil row") + #expect(secondDeleted == 0, "second call: predicate matches nothing — idempotent on an empty nil set") + } + + @Test + func `warmUp runs both purgeOrphanPendingSends and purgeLegacyAttemptCountRows`() async throws { + let store = try await createTestStore() + let radioWithDevice = UUID() + let radioWithoutDevice = UUID() + + // Device for one of the two radios — the other's PendingSends are orphans. + let scopedDevice = DeviceDTO.testDevice(id: radioWithDevice, radioID: radioWithDevice) + try await store.saveDevice(scopedDevice) + + let nilCountOnDeviceRadio = makePendingSendDTO( + messageID: UUID(), radioID: radioWithDevice, attemptCount: nil, sequence: 1 + ) + let orphanOnUnknownRadio = makePendingSendDTO( + messageID: UUID(), radioID: radioWithoutDevice, attemptCount: nil, sequence: 1 + ) + try await store.upsertPendingSend(nilCountOnDeviceRadio) + try await store.upsertPendingSend(orphanOnUnknownRadio) + + try await store.warmUp() + + let survivingForDevice = try await store.fetchPendingSends(radioID: radioWithDevice) + let survivingForUnknown = try await store.fetchPendingSends(radioID: radioWithoutDevice) + #expect(survivingForDevice.isEmpty, + "warmUp must run both purges: nil-attemptCount rows deleted even when radio has a paired device") + #expect(survivingForUnknown.isEmpty, + "row attached to no-device radio must be purged by purgeOrphanPendingSends") + } + + @Test + func `deletePendingSendsForMessage public API saves on return`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let messageID = UUID() + try await store.upsertPendingSend(makePendingSendDTO( + messageID: messageID, radioID: radioID, attemptCount: 1, sequence: 1 + )) + + try await store.deletePendingSendsForMessage(messageID: messageID) + + // No explicit save from the test — visibility of the deletion to a + // subsequent fetch confirms the public method saved on return, + // matching the contract expected by ChatSendQueueService callers. + let hasPending = try await store.hasPendingSend(messageID: messageID) + #expect(hasPending == false, + "public deletePendingSendsForMessage must save before returning") + } + + // MARK: - PendingSend Cascade Tests + + @Test + func `deleteMessage cascades the matching PendingSend in a single transaction`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let messageID = UUID() + let message = MessageDTO(from: Message( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: 0, + text: "hello", + timestamp: UInt32(Date().timeIntervalSince1970) + )) + try await store.saveMessage(message) + + let pending = makePendingSendDTO(messageID: messageID, radioID: device.id, attemptCount: 0) + try await store.upsertPendingSend(pending) + + try await store.deleteMessage(id: messageID) + + let remainingPending = try await store.fetchPendingSends(radioID: device.id) + #expect(remainingPending.isEmpty, + "deleteMessage must cascade the PendingSend row keyed by the deleted messageID") + let remainingMessages = try await store.fetchAllMessages(radioID: device.id) + #expect(remainingMessages.isEmpty, + "deleteMessage must still remove the Message row") + } + + @Test + func `deleteMessage reaps an orphan PendingSend even when no Message row exists`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // An orphan PendingSend with no corresponding Message — can arise from + // a same-millisecond race between deleteMessage and upsertPendingSend. + let orphanMessageID = UUID() + let pending = makePendingSendDTO( + messageID: orphanMessageID, radioID: device.id, attemptCount: 0 + ) + try await store.upsertPendingSend(pending) + + try await store.deleteMessage(id: orphanMessageID) + + let remainingPending = try await store.fetchPendingSends(radioID: device.id) + #expect(remainingPending.isEmpty, + "deleteMessage must reap orphan PendingSends even without a matching Message row") + } + + @Test + func `deleteDeviceData reaps radio-scoped orphan PendingSends and preserves the Device row`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Message + matching PendingSend (messageID cascade reaches this row). + let matchedMessageID = UUID() + let matchedMessage = MessageDTO(from: Message( + id: matchedMessageID, + radioID: device.id, + contactID: nil, + channelIndex: 0, + text: "matched", + timestamp: UInt32(Date().timeIntervalSince1970) + )) + try await store.saveMessage(matchedMessage) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: matchedMessageID, radioID: device.id, attemptCount: 0, sequence: 1 + )) + + // PendingSend whose messageID does not correspond to any saved Message. + // The messageIDs-keyed cascade cannot see it; the radioID-keyed defensive + // delete must reap it. + try await store.upsertPendingSend(makePendingSendDTO( + messageID: UUID(), radioID: device.id, attemptCount: 0, sequence: 2 + )) + + try await store.deleteDeviceData(id: device.id) + + let surviving = try await store.fetchPendingSends(radioID: device.id) + #expect(surviving.isEmpty, + "deleteDeviceData must reap both Message-matched and orphan PendingSends for the radio") + + let fetchedDevice = try await store.fetchDevice(id: device.id) + #expect(fetchedDevice != nil, + "deleteDeviceData must preserve the Device row") + } + + @Test + func `deleteMessagesForContact cascades PendingSends and spares unrelated contacts`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame1 = createTestContactFrame(name: "Contact1") + let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) + let frame2 = createTestContactFrame(name: "Contact2") + let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) + + var contact1MessageIDs: [UUID] = [] + for i in 0..<3 { + let messageID = UUID() + contact1MessageIDs.append(messageID) + let message = MessageDTO(from: Message( + id: messageID, + radioID: device.id, + contactID: contact1ID, + text: "C1 \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 + )) + } + + let contact2MessageID = UUID() + let contact2Message = MessageDTO(from: Message( + id: contact2MessageID, + radioID: device.id, + contactID: contact2ID, + text: "C2 keep", + timestamp: UInt32(Date().timeIntervalSince1970) + 100 + )) + try await store.saveMessage(contact2Message) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: contact2MessageID, radioID: device.id, attemptCount: 0, sequence: 99 + )) + + try await store.deleteMessagesForContact(contactID: contact1ID) + + let remaining = try await store.fetchPendingSends(radioID: device.id) + #expect(remaining.count == 1, + "only the unrelated contact's PendingSend should survive") + #expect(remaining.first?.messageID == contact2MessageID, + "surviving PendingSend must belong to the untouched contact") + + let contact2Messages = try await store.fetchMessages(contactID: contact2ID) + #expect(contact2Messages.count == 1, + "unrelated contact's Message row must be preserved") + } + + @Test + func `deleteMessagesForChannel cascades PendingSends and spares other channels`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let targetChannel: UInt8 = 0 + let untouchedChannel: UInt8 = 1 + + for i in 0..<3 { + let messageID = UUID() + let message = MessageDTO(from: Message( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: targetChannel, + text: "Ch0 \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 + )) + } + + let untouchedMessageID = UUID() + let untouchedMessage = MessageDTO(from: Message( + id: untouchedMessageID, + radioID: device.id, + contactID: nil, + channelIndex: untouchedChannel, + text: "Ch1 keep", + timestamp: UInt32(Date().timeIntervalSince1970) + 100 + )) + try await store.saveMessage(untouchedMessage) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: untouchedMessageID, radioID: device.id, attemptCount: 0, sequence: 99 + )) + + try await store.deleteMessagesForChannel(radioID: device.id, channelIndex: targetChannel) + + let remaining = try await store.fetchPendingSends(radioID: device.id) + #expect(remaining.count == 1, + "only the untouched channel's PendingSend should survive") + #expect(remaining.first?.messageID == untouchedMessageID, + "surviving PendingSend must belong to the untouched channel") + + let untouchedChannelMessages = try await store.fetchMessages( + radioID: device.id, channelIndex: untouchedChannel + ) + #expect(untouchedChannelMessages.count == 1, + "unrelated channel's Message row must be preserved") + } + + @Test + func `deleteChannelMessages(fromSender:) cascades PendingSends and spares other senders`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex: UInt8 = 0 + let targetSender = "Spammer" + let untouchedSender = "Friend" + + for i in 0..<3 { + let messageID = UUID() + let message = MessageDTO(from: Message( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: channelIndex, + text: "spam \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i), + senderNodeName: targetSender + )) + try await store.saveMessage(message) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 + )) + } + + let untouchedMessageID = UUID() + let untouchedMessage = MessageDTO(from: Message( + id: untouchedMessageID, + radioID: device.id, + contactID: nil, + channelIndex: channelIndex, + text: "friend", + timestamp: UInt32(Date().timeIntervalSince1970) + 100, + senderNodeName: untouchedSender + )) + try await store.saveMessage(untouchedMessage) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: untouchedMessageID, radioID: device.id, attemptCount: 0, sequence: 99 + )) + + try await store.deleteChannelMessages(fromSender: targetSender, radioID: device.id) + + let remaining = try await store.fetchPendingSends(radioID: device.id) + #expect(remaining.count == 1, + "only the other sender's PendingSend should survive") + #expect(remaining.first?.messageID == untouchedMessageID, + "surviving PendingSend must belong to the untouched sender") + } + + // MARK: - RxLogEntry Tests + + private func createTestRxLogEntryDTO( + radioID: UUID, + senderTimestamp: UInt32? = nil, + regionScope: String? = nil, + payloadTypeBits: UInt8 = 5, + transportCode: Data? = nil, + channelIndex: UInt8? = 1, + packetPayload: Data = Data([0xAB, 0xCD, 0xEF]) + ) -> RxLogEntryDTO { + // Create minimal ParsedRxLogData for the DTO + let parsed = ParsedRxLogData( + snr: 10.5, + rssi: -65, + rawPayload: Data([0x15, 0x01, 0x02, 0x03]), + routeType: .flood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: payloadTypeBits, + transportCode: transportCode, + pathLength: 1, + pathNodes: [0x42], + packetPayload: packetPayload + ) + + return RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: channelIndex, + channelName: "TestChannel", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: regionScope, + decodedText: "Hello mesh!" + ) + } + + @Test + func `RxLogEntryDTO(from:) falls back on out-of-range stored values instead of trapping`() { + let model = RxLogEntry( + radioID: UUID(), + routeType: 999, + payloadType: -1, + payloadVersion: 5000, + pathLength: 400, + pathNodes: Data(), + packetPayload: Data(), + rawPayload: Data(), + packetHash: "deadbeef", + channelIndex: 9999, + senderTimestamp: -10 + ) + + let dto = RxLogEntryDTO(from: model) + + #expect(dto.routeType == .flood) + #expect(dto.payloadType == .unknown) + #expect(dto.payloadVersion == 0) + #expect(dto.pathLength == 0) + #expect(dto.channelIndex == nil) + #expect(dto.senderTimestamp == nil) + } + + @Test + func `Save and fetch RxLogEntry preserves senderTimestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let expectedTimestamp: UInt32 = 1_703_123_456 + let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: expectedTimestamp) + + try await store.saveRxLogEntry(dto) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.count == 1) + #expect(entries.first?.senderTimestamp == expectedTimestamp) + } + + @Test + func `Save and fetch RxLogEntry with nil senderTimestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: nil) + + try await store.saveRxLogEntry(dto) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.count == 1) + #expect(entries.first?.senderTimestamp == nil) + } + + @Test + func `RxLogEntryDTO init from model preserves senderTimestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Save with timestamp + let expectedTimestamp: UInt32 = 1_703_123_456 + let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: expectedTimestamp) + try await store.saveRxLogEntry(dto) + + // Fetch back (this uses RxLogEntryDTO.init(from: RxLogEntry)) + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.first?.senderTimestamp == expectedTimestamp) + + // Verify the conversion handles the Int -> UInt32 correctly + // The model stores Int, DTO uses UInt32 + #expect(entries.first?.senderTimestamp == 1_703_123_456) + } + + @Test + func `RX log prune is deferred until threshold is exceeded`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + for index in 0..<1100 { + let dto = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: UInt32(index) + ) + try await store.saveRxLogEntry(dto) + try await store.pruneRxLogEntries(radioID: device.id) + } + + let entriesBeforeThreshold = try await store.fetchRxLogEntries(radioID: device.id, limit: 1200) + #expect(entriesBeforeThreshold.count == 1100) + + let thresholdEntry = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: UInt32(1100) + ) + try await store.saveRxLogEntry(thresholdEntry) + try await store.pruneRxLogEntries(radioID: device.id) + + let entriesAfterThreshold = try await store.fetchRxLogEntries(radioID: device.id, limit: 1200) + #expect(entriesAfterThreshold.count == 1000) + #expect(entriesAfterThreshold.first?.senderTimestamp == 1100) + #expect(entriesAfterThreshold.last?.senderTimestamp == 101) + } + + @Test + func `Clearing RX log resets cached count for future pruning`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + for index in 0..<1101 { + let dto = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: UInt32(index) + ) + try await store.saveRxLogEntry(dto) + } + try await store.pruneRxLogEntries(radioID: device.id) + try await store.clearRxLogEntries(radioID: device.id) + + let replacement = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: 42) + try await store.saveRxLogEntry(replacement) + try await store.pruneRxLogEntries(radioID: device.id) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.count == 1) + #expect(entries.first?.senderTimestamp == 42) + } + + @Test + func `A store rebuilt over a populated container seeds its prune cache from disk`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let radioID = UUID() + + let storeA = PersistenceStore(modelContainer: container) + try await storeA.saveDevice(createTestDevice().copy { $0.id = radioID; $0.radioID = radioID }) + + // Fill to the retention cap (keepCount + pruneThreshold) without exceeding it. + for index in 0..<1100 { + try await storeA.saveRxLogEntry( + createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: UInt32(index)) + ) + try await storeA.pruneRxLogEntries(radioID: radioID) + } + let beforeReconnect = try await storeA.fetchRxLogEntries(radioID: radioID, limit: 1200) + #expect(beforeReconnect.count == 1100) + + // Reconnect: a new store over the same container starts with a cold cache. + // Writing one full prune cycle past the cap drives the count back to keepCount + // only if storeB seeded from disk; a cold-from-zero cache never trips the gate. + let storeB = PersistenceStore(modelContainer: container) + for index in 1100..<1202 { + try await storeB.saveRxLogEntry( + createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: UInt32(index)) + ) + try await storeB.pruneRxLogEntries(radioID: radioID) + } + + // Pruning only fires if storeB seeded its count from disk rather than from zero. + let afterReconnect = try await storeB.fetchRxLogEntries(radioID: radioID, limit: 1300) + #expect(afterReconnect.count == 1000) + } + + // MARK: - Region Scope Tests + + @Test + func `saveRxLogEntry forwards regionScope to the persisted model`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let dto = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: 1_703_000_000, + regionScope: "Germany" + ) + try await store.saveRxLogEntry(dto) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.first?.regionScope == "Germany") + } + + @Test + func `saveRxLogEntry preserves payloadTypeBits including unknown nibbles`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let dto = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: 1_703_000_001, + payloadTypeBits: 0x0C + ) + try await store.saveRxLogEntry(dto) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.first?.payloadTypeBits == 0x0C) + } + + @Test + func `batchUpdateChannelMessageRegion back-fills normal-case message via timestamp fallback`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_111_111 + // Normal case: senderTimestamp stays nil, wire timestamp lives on `timestamp`. + let dto = MessageDTO.testChannelMessage( + radioID: device.id, + channelIndex: 0, + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered + ) + try await store.saveMessage(dto) + + try await store.batchUpdateChannelMessageRegion( + radioID: device.id, + updates: [(channelIndex: 0, senderTimestamp: wireTimestamp, regionScope: "Germany")] + ) + + let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 0) + #expect(saved.first?.regionScope == "Germany") + } + + @Test + func `batchUpdateChannelMessageRegion back-fills timestamp-corrected message`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let originalWire: UInt32 = 1_703_222_222 + var dto = MessageDTO.testChannelMessage( + radioID: device.id, + channelIndex: 1, + timestamp: UInt32(Date().timeIntervalSince1970), + direction: .incoming, + status: .delivered + ) + dto.senderTimestamp = originalWire + try await store.saveMessage(dto) + + try await store.batchUpdateChannelMessageRegion( + radioID: device.id, + updates: [(channelIndex: 1, senderTimestamp: originalWire, regionScope: "USA")] + ) + + let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 1) + #expect(saved.first?.regionScope == "USA") + } + + @Test + func `batchUpdateChannelMessageRegion skips outgoing messages`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_333_333 + let dto = MessageDTO.testChannelMessage( + radioID: device.id, + channelIndex: 2, + timestamp: wireTimestamp, + direction: .outgoing, + status: .sent + ) + try await store.saveMessage(dto) + + try await store.batchUpdateChannelMessageRegion( + radioID: device.id, + updates: [(channelIndex: 2, senderTimestamp: wireTimestamp, regionScope: "France")] + ) + + let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 2) + #expect(saved.first?.regionScope == nil) + } + + @Test + func `batchUpdateDMMessageRegion back-fills DM by sender prefix byte`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_444_444 + let senderKey = Data([0xAB, 0xCD, 0xEF, 0x01, 0x02, 0x03]) + let contactID = UUID() + let dto = MessageDTO.testDirectMessage( + radioID: device.id, + contactID: contactID, + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered, + senderKeyPrefix: senderKey + ) + try await store.saveMessage(dto) + + try await store.batchUpdateDMMessageRegion( + radioID: device.id, + updates: [(senderPrefixByte: 0xAB, senderTimestamp: wireTimestamp, regionScope: "Germany")] + ) + + let saved = try await store.fetchMessages(contactID: contactID) + #expect(saved.first?.regionScope == "Germany") + } + + @Test + func `batchUpdateDMMessageRegion ignores DMs from other senders at same timestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_555_555 + let aliceKey = Data([0xAA, 0x11, 0x22, 0x33, 0x44, 0x55]) + let bobKey = Data([0xBB, 0x66, 0x77, 0x88, 0x99, 0x00]) + let aliceContact = UUID() + let bobContact = UUID() + + let alice = MessageDTO.testDirectMessage( + radioID: device.id, + contactID: aliceContact, + text: "From Alice", + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered, + senderKeyPrefix: aliceKey + ) + let bob = MessageDTO.testDirectMessage( + radioID: device.id, + contactID: bobContact, + text: "From Bob", + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered, + senderKeyPrefix: bobKey + ) + try await store.saveMessage(alice) + try await store.saveMessage(bob) + + try await store.batchUpdateDMMessageRegion( + radioID: device.id, + updates: [(senderPrefixByte: 0xAA, senderTimestamp: wireTimestamp, regionScope: "Germany")] + ) + + let aliceSaved = try await store.fetchMessages(contactID: aliceContact) + let bobSaved = try await store.fetchMessages(contactID: bobContact) + #expect(aliceSaved.first?.regionScope == "Germany") + #expect(bobSaved.first?.regionScope == nil) + } + + // MARK: - Mute Tests + + @Test + func `Set contact muted`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "Alice") + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // Initially not muted + var contact = try await store.fetchContact(id: contactID) + #expect(contact?.isMuted == false) + + // Mute + try await store.setContactMuted(contactID, isMuted: true) + contact = try await store.fetchContact(id: contactID) + #expect(contact?.isMuted == true) + + // Unmute + try await store.setContactMuted(contactID, isMuted: false) + contact = try await store.fetchContact(id: contactID) + #expect(contact?.isMuted == false) + } + + @Test + func `Muted contacts excluded from badge count`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Create contact with unreads + let frame1 = createTestContactFrame(name: "Alice") + let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) + try await store.incrementUnreadCount(contactID: contact1ID) + try await store.incrementUnreadCount(contactID: contact1ID) + + // Create muted contact with unreads + let frame2 = createTestContactFrame(name: "Bob") + let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) + try await store.incrementUnreadCount(contactID: contact2ID) + try await store.setContactMuted(contact2ID, isMuted: true) + + let (contacts, _, _) = try await store.getTotalUnreadCounts(radioID: device.id) + + // Only Alice's 2 unreads should count, Bob is muted + #expect(contacts == 2) + } + + @Test + func `Notification levels affect badge count correctly`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Create channel with unreads + let channelInfo = ChannelInfo(index: 1, name: "Test", secret: Data(repeating: 0x42, count: 16)) + let channelID = try await store.saveChannel(radioID: device.id, from: channelInfo) + try await store.incrementChannelUnreadCount(channelID: channelID) + try await store.incrementChannelUnreadCount(channelID: channelID) + + // Default (all) - should count all unreads + var counts = try await store.getTotalUnreadCounts(radioID: device.id) + #expect(counts.channels == 2) + + // Muted - should exclude from badge + try await store.setChannelNotificationLevel(channelID, level: .muted) + counts = try await store.getTotalUnreadCounts(radioID: device.id) + #expect(counts.channels == 0) + + // Mentions only with no mentions - should show 0 + try await store.setChannelNotificationLevel(channelID, level: .mentionsOnly) + counts = try await store.getTotalUnreadCounts(radioID: device.id) + #expect(counts.channels == 0) + + // Mentions only with mentions - should show mention count + try await store.incrementChannelUnreadMentionCount(channelID: channelID) + counts = try await store.getTotalUnreadCounts(radioID: device.id) + #expect(counts.channels == 1) + } + + // MARK: - Ghost Identity Reconciliation Tests + + @Test + func `reconcileGhostIdentity rewrites current device when ghost matches publicKey`() async throws { + let store = try await createTestStore() + + let oldPublicKey = Data((0.. .pending is not a constraint violation because the row can + // only ever reach .delivered by going forward through .sent again. + let remapped = try await store.updateMessageStatusUnlessDelivered(id: messageID, status: .pending) + #expect(remapped == true) + #expect(try await store.fetchMessage(id: messageID)?.status == .pending) + + // A subsequent successful redelivery still reaches .delivered. + try await store.updateMessageAck(id: messageID, ackCode: 0x1234_5678, status: .delivered) + #expect(try await store.fetchMessage(id: messageID)?.status == .delivered) + } + + @Test + func `hasOutgoingSentDM flags a stuck .sent DM by ackCode and ignores other rows`() async throws { + let store = try await createTestStore() + let ackCode: UInt32 = 0xCAFE_F00D + + let sentID = UUID() + try await store.saveMessage( + MessageDTO.testDirectMessage(id: sentID, status: .sent, ackCode: ackCode) + ) + #expect(try await store.hasOutgoingSentDM(ackCode: ackCode) == true) + + // A different ackCode must not match. + #expect(try await store.hasOutgoingSentDM(ackCode: 0x0000_0001) == false) + + // A delivered row with the same ackCode is not an orphan. + let deliveredID = UUID() + try await store.saveMessage( + MessageDTO.testDirectMessage(id: deliveredID, status: .delivered, ackCode: 0xBADC_0DE5) + ) + #expect(try await store.hasOutgoingSentDM(ackCode: 0xBADC_0DE5) == false) + } + + // MARK: - Inbound Advert Hop Count + + @Test + func `setInboundHopCount round-trips onto an existing discovered node`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let frame = createTestContactFrame(name: "Advertiser") + let (node, _) = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) + + try await store.setInboundHopCount(radioID: radioID, publicKey: node.publicKey, hopCount: 4, advertTimestamp: 100) + + let fetched = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(fetched.count == 1) + #expect(fetched.first?.inboundHopCount == 4) + #expect(fetched.first?.inboundHopAdvertTimestamp == 100) + } + + @Test + func `setInboundHopCount is a no-op when no matching row exists`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let unknownKey = Data((0.. loss1km) - #expect(loss4km > loss2km) - - // Doubling distance adds ~6dB - #expect(abs((loss2km - loss1km) - 6.02) < 0.1) - #expect(abs((loss4km - loss2km) - 6.02) < 0.1) - } - - @Test("Path loss increases with frequency") - func pathLossIncreasesWithFrequency() { - let loss400MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 400) - let loss900MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 900) - let loss2400MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 2400) - - #expect(loss900MHz > loss400MHz) - #expect(loss2400MHz > loss900MHz) - } - - @Test("Path loss returns 0 for invalid inputs") - func pathLossInvalidInputs() { - #expect(RFCalculator.pathLoss(distanceMeters: 0, frequencyMHz: 910) == 0) - #expect(RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 0) == 0) - #expect(RFCalculator.pathLoss(distanceMeters: -100, frequencyMHz: 910) == 0) - #expect(RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: -910) == 0) - } - - // MARK: - Diffraction Loss Tests - - @Test("Diffraction loss is zero for clear line-of-sight (v below grazing)") - func diffractionLossClearLOS() { - // Large negative obstruction height = clear path well below LOS - let loss = RFCalculator.diffractionLoss( - obstructionHeightMeters: -50, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - #expect(loss == 0) - } - - @Test("Diffraction loss is approximately 6 dB for grazing (v near 0)") - func diffractionLossGrazing() { - // At v=0 (grazing), the obstruction is exactly on the line of sight - let loss = RFCalculator.diffractionLoss( - obstructionHeightMeters: 0, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - // At v=0: L ≈ 6.02 dB - #expect(abs(loss - 6.0) < 1.0) - } - - @Test("Diffraction loss increases for blocked path (v near 1)") - func diffractionLossBlocked() { - // Calculate the obstruction height that gives v ≈ 1 - // v = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)) - // For 6km at 910MHz: sqrt(2 * 6000 / (0.3294 * 3000 * 3000)) ≈ 0.0636 - // So h = 1 / 0.0636 ≈ 15.7m for v = 1 - let loss = RFCalculator.diffractionLoss( - obstructionHeightMeters: 15.7, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - // ITU-R P.526 J(v) at v ≈ 1 is ≈ 13.9 dB - #expect(abs(loss - 13.9) < 0.5) - } - - @Test("Diffraction loss is greater for larger obstructions") - func diffractionLossIncreasesWithObstruction() { - let lossSmall = RFCalculator.diffractionLoss( - obstructionHeightMeters: 5, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - - let lossMedium = RFCalculator.diffractionLoss( - obstructionHeightMeters: 15, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - - let lossLarge = RFCalculator.diffractionLoss( - obstructionHeightMeters: 30, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - - #expect(lossMedium > lossSmall) - #expect(lossLarge > lossMedium) - } - - @Test("Diffraction loss matches ITU-R P.526 reference value at strong obstruction (v ≈ 2.4)") - func diffractionLossMatchesITUReferenceAtV24() { - // h ≈ 37.7m gives v ≈ 2.4 for 6km at 910MHz; ITU-R P.526 J(2.4) ≈ 20.5 dB. - // A wrong-sign polynomial approximation reads ≈ 35 dB here, so this anchors the regime. - let loss = RFCalculator.diffractionLoss( - obstructionHeightMeters: 37.7, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - #expect(abs(loss - 20.5) < 0.7) - } - - @Test("Diffraction loss returns 0 for invalid inputs") - func diffractionLossInvalidInputs() { - #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 0, distanceToBMeters: 100, frequencyMHz: 910) == 0) - #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 100, distanceToBMeters: 0, frequencyMHz: 910) == 0) - #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 100, distanceToBMeters: 100, frequencyMHz: 0) == 0) - } - - // MARK: - Haversine Distance Tests - - @Test("Distance between same coordinates is zero") - func distanceSameCoordinates() { - let coord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let distance = RFCalculator.distance(from: coord, to: coord) - #expect(distance == 0) - } - - @Test("Haversine distance calculation is accurate") - func haversineDistanceAccuracy() { - // San Francisco to Los Angeles: approximately 559 km - let sanFrancisco = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let losAngeles = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) - - let distance = RFCalculator.distance(from: sanFrancisco, to: losAngeles) - - // Expected: ~559 km = 559000 meters (within 10km tolerance) - #expect(abs(distance - 559_000) < 10_000) - } - - @Test("Haversine distance is symmetric") - func haversineDistanceSymmetric() { - let coord1 = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let coord2 = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) - - let distance1 = RFCalculator.distance(from: coord1, to: coord2) - let distance2 = RFCalculator.distance(from: coord2, to: coord1) - - #expect(abs(distance1 - distance2) < 0.001) - } - - @Test("Distance across date line is correct") - func distanceAcrossDateLine() { - // Tokyo to San Francisco across the Pacific - let tokyo = CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) - let sanFrancisco = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - - let distance = RFCalculator.distance(from: tokyo, to: sanFrancisco) - - // Expected: ~8,280 km = 8,280,000 meters (within 100km tolerance) - #expect(abs(distance - 8_280_000) < 100_000) - } - - @Test("Short distance calculation is accurate") - func shortDistanceAccuracy() { - // Two points approximately 1 km apart - let point1 = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - // Moving ~0.009 degrees north is roughly 1 km - let point2 = CLLocationCoordinate2D(latitude: 37.7839, longitude: -122.4194) - - let distance = RFCalculator.distance(from: point1, to: point2) - - // Expected: ~1 km = 1000 meters (within 50m tolerance) - #expect(abs(distance - 1000) < 100) - } + // MARK: - Constants Tests + + @Test + func `Speed of light constant is correct`() { + #expect(RFCalculator.speedOfLight == 299_792_458) + } + + @Test + func `Earth radius constant is correct`() { + #expect(RFCalculator.earthRadiusKm == 6371) + } + + // MARK: - Wavelength Tests + + @Test + func `Wavelength at 910 MHz is approximately 0.3294m`() { + let wavelength = RFCalculator.wavelength(frequencyMHz: 910) + // Expected: c / f = 299792458 / 910000000 ≈ 0.3294422 + #expect(abs(wavelength - 0.3294) < 0.001) + } + + @Test + func `Wavelength at 2400 MHz is approximately 0.125m`() { + let wavelength = RFCalculator.wavelength(frequencyMHz: 2400) + // Expected: 299792458 / 2400000000 ≈ 0.1249 + #expect(abs(wavelength - 0.125) < 0.001) + } + + @Test + func `Wavelength returns 0 for zero frequency`() { + let wavelength = RFCalculator.wavelength(frequencyMHz: 0) + #expect(wavelength == 0) + } + + @Test + func `Wavelength returns 0 for negative frequency`() { + let wavelength = RFCalculator.wavelength(frequencyMHz: -100) + #expect(wavelength == 0) + } + + // MARK: - Fresnel Radius Tests + + @Test + func `Fresnel radius at midpoint is approximately 22.23m for 6km at 910MHz`() { + let radius = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: 3000, + distanceToBMeters: 3000 + ) + // r = sqrt((0.3294 * 3000 * 3000) / 6000) = sqrt(494.1) ≈ 22.23 + #expect(abs(radius - 22.23) < 0.5) + } + + @Test + func `Fresnel radius at quarter point is smaller than at midpoint`() { + let totalDistance = 6000.0 + let quarterPoint = totalDistance / 4 + + let radiusAtQuarter = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: quarterPoint, + distanceToBMeters: totalDistance - quarterPoint + ) + + let radiusAtMidpoint = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: 3000, + distanceToBMeters: 3000 + ) + + // Quarter point radius should be smaller than midpoint + // r at quarter = sqrt((0.3294 * 1500 * 4500) / 6000) ≈ 19.27 + #expect(radiusAtQuarter < radiusAtMidpoint) + #expect(abs(radiusAtQuarter - 19.27) < 0.5) + } + + @Test + func `Fresnel radius is symmetric`() { + let radius1 = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: 2000, + distanceToBMeters: 4000 + ) + + let radius2 = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: 4000, + distanceToBMeters: 2000 + ) + + #expect(abs(radius1 - radius2) < 0.001) + } + + @Test + func `Fresnel radius returns 0 for invalid inputs`() { + #expect(RFCalculator.fresnelRadius(frequencyMHz: 0, distanceToAMeters: 100, distanceToBMeters: 100) == 0) + #expect(RFCalculator.fresnelRadius(frequencyMHz: 910, distanceToAMeters: 0, distanceToBMeters: 100) == 0) + #expect(RFCalculator.fresnelRadius(frequencyMHz: 910, distanceToAMeters: 100, distanceToBMeters: 0) == 0) + #expect(RFCalculator.fresnelRadius(frequencyMHz: -100, distanceToAMeters: 100, distanceToBMeters: 100) == 0) + } + + // MARK: - Earth Bulge Tests + + @Test + func `Earth bulge at midpoint with k=0.25 is approximately 2.82m for 6km`() { + let bulge = RFCalculator.earthBulge( + distanceToAMeters: 3000, + distanceToBMeters: 3000, + refractionK: 0.25 + ) + // h = (3000 * 3000) / (2 * 0.25 * 6371000) = 9000000 / 3185500 ≈ 2.82 + #expect(abs(bulge - 2.82) < 0.05) + } + + @Test + func `Earth bulge with standard atmosphere k=1.33 is smaller`() { + let bulgeConservative = RFCalculator.earthBulge( + distanceToAMeters: 3000, + distanceToBMeters: 3000, + refractionK: 0.25 + ) + + let bulgeStandard = RFCalculator.earthBulge( + distanceToAMeters: 3000, + distanceToBMeters: 3000, + refractionK: 1.33 + ) + + // Standard atmosphere (k=1.33) gives smaller bulge due to larger effective earth radius + // h = (3000 * 3000) / (2 * 1.33 * 6371000) ≈ 0.53m + #expect(bulgeStandard < bulgeConservative) + #expect(abs(bulgeStandard - 0.53) < 0.05) + } + + @Test + func `Earth bulge is symmetric`() { + let bulge1 = RFCalculator.earthBulge( + distanceToAMeters: 2000, + distanceToBMeters: 4000, + refractionK: 1.0 + ) + + let bulge2 = RFCalculator.earthBulge( + distanceToAMeters: 4000, + distanceToBMeters: 2000, + refractionK: 1.0 + ) + + #expect(abs(bulge1 - bulge2) < 0.001) + } + + @Test + func `Earth bulge returns 0 for invalid inputs`() { + #expect(RFCalculator.earthBulge(distanceToAMeters: 0, distanceToBMeters: 100, refractionK: 1.0) == 0) + #expect(RFCalculator.earthBulge(distanceToAMeters: 100, distanceToBMeters: 0, refractionK: 1.0) == 0) + #expect(RFCalculator.earthBulge(distanceToAMeters: 100, distanceToBMeters: 100, refractionK: 0) == 0) + #expect(RFCalculator.earthBulge(distanceToAMeters: 100, distanceToBMeters: 100, refractionK: -1) == 0) + } + + // MARK: - Path Loss Tests + + @Test + func `Path loss is approximately 107.2 dB for 6km at 910MHz`() { + let loss = RFCalculator.pathLoss(distanceMeters: 6000, frequencyMHz: 910) + // FSPL = 20*log10(6000) + 20*log10(910) - 27.55 + // = 75.56 + 59.18 - 27.55 ≈ 107.19 + #expect(abs(loss - 107.2) < 0.5) + } + + @Test + func `Path loss increases with distance`() { + let loss1km = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 910) + let loss2km = RFCalculator.pathLoss(distanceMeters: 2000, frequencyMHz: 910) + let loss4km = RFCalculator.pathLoss(distanceMeters: 4000, frequencyMHz: 910) + + #expect(loss2km > loss1km) + #expect(loss4km > loss2km) + + // Doubling distance adds ~6dB + #expect(abs((loss2km - loss1km) - 6.02) < 0.1) + #expect(abs((loss4km - loss2km) - 6.02) < 0.1) + } + + @Test + func `Path loss increases with frequency`() { + let loss400MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 400) + let loss900MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 900) + let loss2400MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 2400) + + #expect(loss900MHz > loss400MHz) + #expect(loss2400MHz > loss900MHz) + } + + @Test + func `Path loss returns 0 for invalid inputs`() { + #expect(RFCalculator.pathLoss(distanceMeters: 0, frequencyMHz: 910) == 0) + #expect(RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 0) == 0) + #expect(RFCalculator.pathLoss(distanceMeters: -100, frequencyMHz: 910) == 0) + #expect(RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: -910) == 0) + } + + // MARK: - Diffraction Loss Tests + + @Test + func `Diffraction loss is zero for clear line-of-sight (v below grazing)`() { + // Large negative obstruction height = clear path well below LOS + let loss = RFCalculator.diffractionLoss( + obstructionHeightMeters: -50, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + #expect(loss == 0) + } + + @Test + func `Diffraction loss is approximately 6 dB for grazing (v near 0)`() { + // At v=0 (grazing), the obstruction is exactly on the line of sight + let loss = RFCalculator.diffractionLoss( + obstructionHeightMeters: 0, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + // At v=0: L ≈ 6.02 dB + #expect(abs(loss - 6.0) < 1.0) + } + + @Test + func `Diffraction loss increases for blocked path (v near 1)`() { + // Calculate the obstruction height that gives v ≈ 1 + // v = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)) + // For 6km at 910MHz: sqrt(2 * 6000 / (0.3294 * 3000 * 3000)) ≈ 0.0636 + // So h = 1 / 0.0636 ≈ 15.7m for v = 1 + let loss = RFCalculator.diffractionLoss( + obstructionHeightMeters: 15.7, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + // ITU-R P.526 J(v) at v ≈ 1 is ≈ 13.9 dB + #expect(abs(loss - 13.9) < 0.5) + } + + @Test + func `Diffraction loss is greater for larger obstructions`() { + let lossSmall = RFCalculator.diffractionLoss( + obstructionHeightMeters: 5, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + + let lossMedium = RFCalculator.diffractionLoss( + obstructionHeightMeters: 15, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + + let lossLarge = RFCalculator.diffractionLoss( + obstructionHeightMeters: 30, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + + #expect(lossMedium > lossSmall) + #expect(lossLarge > lossMedium) + } + + @Test + func `Diffraction loss matches ITU-R P.526 reference value at strong obstruction (v ≈ 2.4)`() { + // h ≈ 37.7m gives v ≈ 2.4 for 6km at 910MHz; ITU-R P.526 J(2.4) ≈ 20.5 dB. + // A wrong-sign polynomial approximation reads ≈ 35 dB here, so this anchors the regime. + let loss = RFCalculator.diffractionLoss( + obstructionHeightMeters: 37.7, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + #expect(abs(loss - 20.5) < 0.7) + } + + @Test + func `Diffraction loss returns 0 for invalid inputs`() { + #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 0, distanceToBMeters: 100, frequencyMHz: 910) == 0) + #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 100, distanceToBMeters: 0, frequencyMHz: 910) == 0) + #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 100, distanceToBMeters: 100, frequencyMHz: 0) == 0) + } + + // MARK: - Haversine Distance Tests + + @Test + func `Distance between same coordinates is zero`() { + let coord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let distance = RFCalculator.distance(from: coord, to: coord) + #expect(distance == 0) + } + + @Test + func `Haversine distance calculation is accurate`() { + // San Francisco to Los Angeles: approximately 559 km + let sanFrancisco = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let losAngeles = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) + + let distance = RFCalculator.distance(from: sanFrancisco, to: losAngeles) + + // Expected: ~559 km = 559000 meters (within 10km tolerance) + #expect(abs(distance - 559_000) < 10000) + } + + @Test + func `Haversine distance is symmetric`() { + let coord1 = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let coord2 = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) + + let distance1 = RFCalculator.distance(from: coord1, to: coord2) + let distance2 = RFCalculator.distance(from: coord2, to: coord1) + + #expect(abs(distance1 - distance2) < 0.001) + } + + @Test + func `Distance across date line is correct`() { + // Tokyo to San Francisco across the Pacific + let tokyo = CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) + let sanFrancisco = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + + let distance = RFCalculator.distance(from: tokyo, to: sanFrancisco) + + // Expected: ~8,280 km = 8,280,000 meters (within 100km tolerance) + #expect(abs(distance - 8_280_000) < 100_000) + } + + @Test + func `Short distance calculation is accurate`() { + // Two points approximately 1 km apart + let point1 = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + // Moving ~0.009 degrees north is roughly 1 km + let point2 = CLLocationCoordinate2D(latitude: 37.7839, longitude: -122.4194) + + let distance = RFCalculator.distance(from: point1, to: point2) + + // Expected: ~1 km = 1000 meters (within 50m tolerance) + #expect(abs(distance - 1000) < 100) + } } // MARK: - Path Analysis Tests @Suite("PathAnalysis Tests") struct PathAnalysisTests { - - // MARK: - Helper Functions - - /// Creates an elevation profile with flat terrain at specified elevation - private func createFlatProfile( - elevationMeters: Double, - totalDistanceMeters: Double, - sampleCount: Int = 11 - ) -> [ElevationSample] { - var samples: [ElevationSample] = [] - let baseCoord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - - for i in 0.. [ElevationSample] { - var samples: [ElevationSample] = [] - let baseCoord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let midpoint = sampleCount / 2 - - for i in 0..> 22m Fresnel radius - let profile = createFlatProfile( - elevationMeters: 0, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .clear) - #expect(result.worstClearancePercent >= 80) - #expect(result.obstructionPoints.isEmpty) - #expect(result.distanceMeters == 6000) - #expect(result.distanceKm == 6.0) - } - - @Test("Clear path has only FSPL, no diffraction loss") - func clearPathNoAdditionalLoss() { - let profile = createFlatProfile( - elevationMeters: 0, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - // FSPL for 6km at 910MHz is approximately 107.2 dB - #expect(abs(result.freeSpacePathLoss - 107.2) < 1.0) - #expect(result.peakDiffractionLoss == 0) - #expect(result.totalPathLoss == result.freeSpacePathLoss) - } - - // MARK: - Blocked Path Tests - - @Test("Blocked path with 100m mountain returns blocked status") - func blockedPathWithMountain() { - // 100m mountain at midpoint, antennas at 50m - // LOS at midpoint: 50m - // Mountain peakFactor: 0 + 100 + earth bulge ≈ 103m - // This is well above LOS, so path is blocked - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 100, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .blocked) - #expect(result.worstClearancePercent < 0) - #expect(!result.obstructionPoints.isEmpty) - } - - @Test("Blocked path has significant diffraction loss") - func blockedPathHasDiffractionLoss() { - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 100, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.peakDiffractionLoss > 10) - #expect(result.totalPathLoss > result.freeSpacePathLoss) - } - - // MARK: - Marginal Path Tests - - @Test("Marginal path with partial obstruction returns marginal status") - func marginalPathPartialObstruction() { - // Create a scenario where clearance is between 60-80% - // With 50m antennas and ~22m Fresnel zone at midpoint, - // we need terrain that comes within ~30% of LOS - // A small hill of ~25m should give marginal clearance - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 25, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - // With 25m hill: LOS at 50m, terrain at ~28m (25 + 3m bulge) - // Clearance: 50 - 28 = 22m, Fresnel zone ~22m - // Clearance percent: ~100%, still clear but close to marginal - #expect(result.clearanceStatus == .clear || result.clearanceStatus == .marginal) - #expect(result.worstClearancePercent >= 60) - } - - // MARK: - Partial Obstruction Tests - - @Test("Partial obstruction path returns partial obstruction status") - func partialObstructionPath() { - // Create terrain that just touches the LOS but doesn't fully block - // 45m hill with 50m antennas at 6km - should partially obstruct - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 45, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - // Hill at 45m + ~3m bulge = ~48m, LOS at 50m - // Clearance: ~2m, Fresnel zone ~22m - // Clearance percent: ~9%, which is partial obstruction - #expect(result.clearanceStatus == .partialObstruction) - #expect(result.worstClearancePercent >= 0) - #expect(result.worstClearancePercent < 60) - #expect(!result.obstructionPoints.isEmpty) - } - - // MARK: - Edge Cases - - @Test("Empty profile returns blocked status") - func emptyProfile() { - let result = RFCalculator.analyzePath( - elevationProfile: [], - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .blocked) - #expect(result.distanceMeters == 0) - } - - @Test("Single sample profile returns blocked status") - func singleSampleProfile() { - let sample = ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: [sample], - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .blocked) - } - - @Test("Profile with zero total distance returns blocked status") - func zeroDistanceProfile() { - let samples = [ - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ), - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ) - ] - - let result = RFCalculator.analyzePath( - elevationProfile: samples, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .blocked) - #expect(result.distanceMeters == 0) - } - - // MARK: - Asymmetric Antenna Heights - - @Test("Asymmetric antenna heights are handled correctly") - func asymmetricAntennaHeights() { - let profile = createFlatProfile( - elevationMeters: 0, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 100, // Higher antenna at A - pointBHeightMeters: 20, // Lower antenna at B - frequencyMHz: 910, - refractionK: 1.0 - ) - - // LOS slopes downward from A to B - // Even with asymmetry, should still be clear with these heights - #expect(result.clearanceStatus == .clear) - #expect(result.worstClearancePercent >= 80) - } - - // MARK: - Custom K-Factor Tests - - @Test("Custom k-factor affects earth bulge calculation") - func customKFactorAffectsAnalysis() { - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 40, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - // Conservative k=0.25 (larger earth bulge) - let resultConservative = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 0.25 - ) - - // Standard atmosphere k=1.33 (smaller earth bulge) - let resultStandard = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.33 - ) - - // With smaller effective earth bulge (higher k), clearance should be better - #expect(resultStandard.worstClearancePercent > resultConservative.worstClearancePercent) - } + // MARK: - Helper Functions + + /// Creates an elevation profile with flat terrain at specified elevation + private func createFlatProfile( + elevationMeters: Double, + totalDistanceMeters: Double, + sampleCount: Int = 11 + ) -> [ElevationSample] { + var samples: [ElevationSample] = [] + let baseCoord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + + for i in 0.. [ElevationSample] { + var samples: [ElevationSample] = [] + let baseCoord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let midpoint = sampleCount / 2 + + for i in 0..> 22m Fresnel radius + let profile = createFlatProfile( + elevationMeters: 0, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .clear) + #expect(result.worstClearancePercent >= 80) + #expect(result.obstructionPoints.isEmpty) + #expect(result.distanceMeters == 6000) + #expect(result.distanceKm == 6.0) + } + + @Test + func `Clear path has only FSPL, no diffraction loss`() { + let profile = createFlatProfile( + elevationMeters: 0, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + // FSPL for 6km at 910MHz is approximately 107.2 dB + #expect(abs(result.freeSpacePathLoss - 107.2) < 1.0) + #expect(result.peakDiffractionLoss == 0) + #expect(result.totalPathLoss == result.freeSpacePathLoss) + } + + // MARK: - Blocked Path Tests + + @Test + func `Blocked path with 100m mountain returns blocked status`() { + // 100m mountain at midpoint, antennas at 50m + // LOS at midpoint: 50m + // Mountain peakFactor: 0 + 100 + earth bulge ≈ 103m + // This is well above LOS, so path is blocked + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 100, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .blocked) + #expect(result.worstClearancePercent < 0) + #expect(!result.obstructionPoints.isEmpty) + } + + @Test + func `Blocked path has significant diffraction loss`() { + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 100, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.peakDiffractionLoss > 10) + #expect(result.totalPathLoss > result.freeSpacePathLoss) + } + + // MARK: - Marginal Path Tests + + @Test + func `Marginal path with partial obstruction returns marginal status`() { + // Create a scenario where clearance is between 60-80% + // With 50m antennas and ~22m Fresnel zone at midpoint, + // we need terrain that comes within ~30% of LOS + // A small hill of ~25m should give marginal clearance + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 25, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + // With 25m hill: LOS at 50m, terrain at ~28m (25 + 3m bulge) + // Clearance: 50 - 28 = 22m, Fresnel zone ~22m + // Clearance percent: ~100%, still clear but close to marginal + #expect(result.clearanceStatus == .clear || result.clearanceStatus == .marginal) + #expect(result.worstClearancePercent >= 60) + } + + // MARK: - Partial Obstruction Tests + + @Test + func `Partial obstruction path returns partial obstruction status`() { + // Create terrain that just touches the LOS but doesn't fully block + // 45m hill with 50m antennas at 6km - should partially obstruct + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 45, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + // Hill at 45m + ~3m bulge = ~48m, LOS at 50m + // Clearance: ~2m, Fresnel zone ~22m + // Clearance percent: ~9%, which is partial obstruction + #expect(result.clearanceStatus == .partialObstruction) + #expect(result.worstClearancePercent >= 0) + #expect(result.worstClearancePercent < 60) + #expect(!result.obstructionPoints.isEmpty) + } + + // MARK: - Edge Cases + + @Test + func `Empty profile returns blocked status`() { + let result = RFCalculator.analyzePath( + elevationProfile: [], + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .blocked) + #expect(result.distanceMeters == 0) + } + + @Test + func `Single sample profile returns blocked status`() { + let sample = ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: [sample], + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .blocked) + } + + @Test + func `Profile with zero total distance returns blocked status`() { + let samples = [ + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ), + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ) + ] + + let result = RFCalculator.analyzePath( + elevationProfile: samples, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .blocked) + #expect(result.distanceMeters == 0) + } + + // MARK: - Asymmetric Antenna Heights + + @Test + func `Asymmetric antenna heights are handled correctly`() { + let profile = createFlatProfile( + elevationMeters: 0, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 100, // Higher antenna at A + pointBHeightMeters: 20, // Lower antenna at B + frequencyMHz: 910, + refractionK: 1.0 + ) + + // LOS slopes downward from A to B + // Even with asymmetry, should still be clear with these heights + #expect(result.clearanceStatus == .clear) + #expect(result.worstClearancePercent >= 80) + } + + // MARK: - Custom K-Factor Tests + + @Test + func `Custom k-factor affects earth bulge calculation`() { + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 40, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + // Conservative k=0.25 (larger earth bulge) + let resultConservative = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 0.25 + ) + + // Standard atmosphere k=1.33 (smaller earth bulge) + let resultStandard = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.33 + ) + + // With smaller effective earth bulge (higher k), clearance should be better + #expect(resultStandard.worstClearancePercent > resultConservative.worstClearancePercent) + } } // MARK: - PathAnalysisResult Fields Tests @Suite("PathAnalysisResult Fields") struct PathAnalysisResultFieldsTests { - - @Test("PathAnalysisResult includes frequency used in calculation") - func resultIncludesFrequency() { - let profile = [ - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ), - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 1000 - ) - ] - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 915.0, - refractionK: 1.33 - ) - - #expect(result.frequencyMHz == 915.0) - } - - @Test("PathAnalysisResult includes k-factor used in calculation") - func resultIncludesKFactor() { - let profile = [ - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ), - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 1000 - ) - ] - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 906.0, - refractionK: 1.33 - ) - - #expect(result.refractionK == 1.33) - } + @Test + func `PathAnalysisResult includes frequency used in calculation`() { + let profile = [ + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ), + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 1000 + ) + ] + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 915.0, + refractionK: 1.33 + ) + + #expect(result.frequencyMHz == 915.0) + } + + @Test + func `PathAnalysisResult includes k-factor used in calculation`() { + let profile = [ + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ), + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 1000 + ) + ] + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 906.0, + refractionK: 1.33 + ) + + #expect(result.refractionK == 1.33) + } } // MARK: - Segment Analysis with ArraySlice Tests @Suite("Segment Analysis with ArraySlice") struct SegmentAnalysisTests { - - @Test("analyzePathSegment works with ArraySlice") - func analyzePathSegmentWithSlice() { - // Create a profile with 11 samples (0-10km, 1km intervals) - let samples = (0...10).map { i in - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.0 + Double(i) * 0.01, longitude: -122.0), - elevation: 100, // flat terrain - distanceFromAMeters: Double(i) * 1000 - ) - } - - // Analyze first half (0-5km) - let firstHalf = samples[0...5] - let result = RFCalculator.analyzePathSegment( - elevationProfile: firstHalf, - startHeightMeters: 50, // 50m antenna height for adequate Fresnel clearance - endHeightMeters: 50, - frequencyMHz: 906, - refractionK: 1.0 - ) - - #expect(result.distanceMeters == 5000) - #expect(result.clearanceStatus == .clear) // flat terrain with 50m antennas should be clear - } - - @Test("analyzePathSegment handles overlapping slice indices") - func analyzePathSegmentOverlappingSlice() { - let samples = (0...10).map { i in - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.0 + Double(i) * 0.01, longitude: -122.0), - elevation: 100, - distanceFromAMeters: Double(i) * 1000 - ) - } - - // Analyze from index 5 to 10 (second half, includes repeater at index 5) - let secondHalf = samples[5...10] - let result = RFCalculator.analyzePathSegment( - elevationProfile: secondHalf, - startHeightMeters: 10, - endHeightMeters: 10, - frequencyMHz: 906, - refractionK: 1.0 - ) - - #expect(result.distanceMeters == 5000) - } + @Test + func `analyzePathSegment works with ArraySlice`() { + // Create a profile with 11 samples (0-10km, 1km intervals) + let samples = (0...10).map { i in + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.0 + Double(i) * 0.01, longitude: -122.0), + elevation: 100, // flat terrain + distanceFromAMeters: Double(i) * 1000 + ) + } + + // Analyze first half (0-5km) + let firstHalf = samples[0...5] + let result = RFCalculator.analyzePathSegment( + elevationProfile: firstHalf, + startHeightMeters: 50, // 50m antenna height for adequate Fresnel clearance + endHeightMeters: 50, + frequencyMHz: 906, + refractionK: 1.0 + ) + + #expect(result.distanceMeters == 5000) + #expect(result.clearanceStatus == .clear) // flat terrain with 50m antennas should be clear + } + + @Test + func `analyzePathSegment handles overlapping slice indices`() { + let samples = (0...10).map { i in + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.0 + Double(i) * 0.01, longitude: -122.0), + elevation: 100, + distanceFromAMeters: Double(i) * 1000 + ) + } + + // Analyze from index 5 to 10 (second half, includes repeater at index 5) + let secondHalf = samples[5...10] + let result = RFCalculator.analyzePathSegment( + elevationProfile: secondHalf, + startHeightMeters: 10, + endHeightMeters: 10, + frequencyMHz: 906, + refractionK: 1.0 + ) + + #expect(result.distanceMeters == 5000) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/RFPathAnalysisCharacterizationTests.swift b/MC1Services/Tests/MC1ServicesTests/RFPathAnalysisCharacterizationTests.swift index e8fc7010..26ed3421 100644 --- a/MC1Services/Tests/MC1ServicesTests/RFPathAnalysisCharacterizationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RFPathAnalysisCharacterizationTests.swift @@ -1,270 +1,269 @@ import CoreLocation -import Testing @testable import MC1Services +import Testing /// Pins exact `analyzePath`/`analyzePathSegment` outputs for representative /// terrain profiles so any change to the shared analysis core is caught as a /// numeric regression, not just a status flip. @Suite("RF Path Analysis Characterization") struct RFPathAnalysisCharacterizationTests { - - // MARK: - Profile Builders - - private static func flatProfile( - elevation: Double, - totalDistance: Double, - count: Int - ) -> [ElevationSample] { - (0.. [ElevationSample] { - let midpoint = count / 2 - return (0.. [ElevationSample] { + (0.. [ElevationSample] { - (0.. [ElevationSample] { + let midpoint = count / 2 + return (0.. [ElevationSample] { + (0.. PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - // MARK: - radioID Propagation - - @Test("Migration propagates new radioID from Device to all children") - func radioIDPropagation() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - - // Create device with id = bleUUID; radioID will get UUID() default but we - // simulate the post-rename state where children have radioID == bleUUID. - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let contact = ContactDTO.testContact(id: UUID(), radioID: bleUUID, name: "Alice") - try await store.saveContact(contact) - - let channel = ChannelDTO( - id: UUID(), - radioID: bleUUID, - index: 0, - name: "General", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - notificationLevel: .all - ) - try await store.saveChannel(channel) - - let messageID = UUID() - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: bleUUID, - contactID: contact.id, - text: "Hello", - direction: .outgoing, - status: .sent - ) - try await store.saveMessage(message) - - try await store.performRadioIDMigration() - - // Device's radioID should be different from bleUUID - let fetchedDevice = try await store.fetchDevice(id: bleUUID) - #expect(fetchedDevice != nil) - let newRadioID = fetchedDevice!.radioID - #expect(newRadioID != bleUUID, "Device should have a new radioID, not the old BLE UUID") - - // All children should share the new radioID - let contacts = try await store.fetchContacts(radioID: newRadioID) - #expect(contacts.count == 1) - #expect(contacts.first?.radioID == newRadioID) - - let channels = try await store.fetchChannels(radioID: newRadioID) - #expect(channels.count == 1) - #expect(channels.first?.radioID == newRadioID) - - let fetchedMessage = try await store.fetchMessage(id: messageID) - #expect(fetchedMessage != nil) - #expect(fetchedMessage?.radioID == newRadioID) - } - - // MARK: - Dedup Key Backfill - - @Test("Outgoing DM with nil dedup key gets backfilled") - func outgoingDMGetsKey() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let contactID = UUID() - let timestamp: UInt32 = 1_704_067_200 - let text = "Hello mesh" - let messageID = UUID() - - let message = MessageDTO( - id: messageID, - radioID: bleUUID, - contactID: contactID, - channelIndex: nil, - text: text, - timestamp: timestamp, - createdAt: Date(), - direction: .outgoing, - status: .sent, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: nil - ) - try await store.saveMessage(message) - - try await store.performRadioIDMigration() - - let fetchedMsg = try await store.fetchMessage(id: messageID) - #expect(fetchedMsg != nil) - - let key = fetchedMsg?.deduplicationKey - #expect(key != nil, "Outgoing message should have a dedup key after migration") - - // Verify format matches fallbackDeduplicationKey exactly - let expectedKey = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, - channelIndex: nil, - senderNodeName: nil, - timestamp: timestamp, - content: text - ) - #expect(key == expectedKey, "Dedup key format must match fallbackDeduplicationKey: got \(key ?? "nil"), expected \(expectedKey)") - } - - @Test("Incoming message with nil dedup key stays nil") - func incomingStaysNil() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let messageID = UUID() - let message = MessageDTO( - id: messageID, - radioID: bleUUID, - contactID: UUID(), - channelIndex: nil, - text: "Incoming hello", - timestamp: 1_704_067_200, - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 1, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: nil - ) - try await store.saveMessage(message) - - try await store.performRadioIDMigration() - - let fetchedMsg = try await store.fetchMessage(id: messageID) - #expect(fetchedMsg != nil) - #expect(fetchedMsg?.deduplicationKey == nil, "Incoming message dedup key should stay nil") - } - - @Test("Existing dedup key is not overwritten") - func existingKeyUnchanged() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let existingKey = "dm-existing-key-12345678" - let messageID = UUID() - let message = MessageDTO( - id: messageID, - radioID: bleUUID, - contactID: UUID(), - channelIndex: nil, - text: "Already keyed", - timestamp: 1_704_067_200, - createdAt: Date(), - direction: .outgoing, - status: .sent, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: existingKey - ) - try await store.saveMessage(message) - - try await store.performRadioIDMigration() - - let fetchedMsg = try await store.fetchMessage(id: messageID) - #expect(fetchedMsg != nil) - #expect(fetchedMsg?.deduplicationKey == existingKey, "Pre-existing dedup key must not be modified") - } - - @Test("UserDefaults guard prevents re-run") - func migrationRunsOnceOnly() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let contact = ContactDTO.testContact(id: UUID(), radioID: bleUUID, name: "Bob") - try await store.saveContact(contact) - - // First run - try await store.performRadioIDMigration() - - let fetchedDevice = try await store.fetchDevice(id: bleUUID) - let firstRadioID = fetchedDevice!.radioID - #expect(firstRadioID != bleUUID) - - // Second run should be a no-op (guarded by UserDefaults) - try await store.performRadioIDMigration() - - let fetchedDeviceAgain = try await store.fetchDevice(id: bleUUID) - #expect(fetchedDeviceAgain!.radioID == firstRadioID, "Second migration run should not change radioID") - - let contacts = try await store.fetchContacts(radioID: firstRadioID) - #expect(contacts.count == 1, "Contact should still have the radioID from the first run") - } - - // MARK: - lastConnectedRadioID Backfill - - @Test("Migration backfills lastConnectedRadioID from lastConnectedDeviceID") - func migrationBackfillsLastConnectedRadioID() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - // Simulate pre-upgrade state: lastConnectedDeviceID exists, lastConnectedRadioID does not - UserDefaults.standard.set(bleUUID.uuidString, forKey: "com.pocketmesh.lastConnectedDeviceID") - UserDefaults.standard.removeObject(forKey: "com.pocketmesh.lastConnectedRadioID") - - try await store.performRadioIDMigration() - - // lastConnectedRadioID should now be populated - let radioIDString = UserDefaults.standard.string(forKey: "com.pocketmesh.lastConnectedRadioID") - #expect(radioIDString != nil) - - // It should match the device's new radioID - let fetchedDevice = try await store.fetchDevice(id: bleUUID) - #expect(radioIDString == fetchedDevice?.radioID.uuidString) - - // Clean up - UserDefaults.standard.removeObject(forKey: "com.pocketmesh.lastConnectedDeviceID") - UserDefaults.standard.removeObject(forKey: "com.pocketmesh.lastConnectedRadioID") - } + // MARK: - Test Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + // MARK: - radioID Propagation + + @Test + func `Migration propagates new radioID from Device to all children`() async throws { + let suiteName = "test.\(UUID().uuidString)" + // UserDefaults is thread-safe but not marked Sendable, so reusing this value + // across the performRadioIDMigration actor boundary needs the isolation opt-out. + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + + let bleUUID = UUID() + + // Create device with id = bleUUID; radioID will get UUID() default but we + // simulate the post-rename state where children have radioID == bleUUID. + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let contact = ContactDTO.testContact(id: UUID(), radioID: bleUUID, name: "Alice") + try await store.saveContact(contact) + + let channel = ChannelDTO( + id: UUID(), + radioID: bleUUID, + index: 0, + name: "General", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + notificationLevel: .all + ) + try await store.saveChannel(channel) + + let messageID = UUID() + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: bleUUID, + contactID: contact.id, + text: "Hello", + direction: .outgoing, + status: .sent + ) + try await store.saveMessage(message) + + try await store.performRadioIDMigration(defaults: defaults) + + // Device's radioID should be different from bleUUID + let fetchedDevice = try await store.fetchDevice(id: bleUUID) + #expect(fetchedDevice != nil) + let newRadioID = try #require(fetchedDevice?.radioID) + #expect(newRadioID != bleUUID, "Device should have a new radioID, not the old BLE UUID") + + // All children should share the new radioID + let contacts = try await store.fetchContacts(radioID: newRadioID) + #expect(contacts.count == 1) + #expect(contacts.first?.radioID == newRadioID) + + let channels = try await store.fetchChannels(radioID: newRadioID) + #expect(channels.count == 1) + #expect(channels.first?.radioID == newRadioID) + + let fetchedMessage = try await store.fetchMessage(id: messageID) + #expect(fetchedMessage != nil) + #expect(fetchedMessage?.radioID == newRadioID) + } + + // MARK: - Dedup Key Backfill + + @Test + func `Outgoing DM with nil dedup key gets backfilled`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let contactID = UUID() + let timestamp: UInt32 = 1_704_067_200 + let text = "Hello mesh" + let messageID = UUID() + + let message = MessageDTO( + id: messageID, + radioID: bleUUID, + contactID: contactID, + channelIndex: nil, + text: text, + timestamp: timestamp, + createdAt: Date(), + direction: .outgoing, + status: .sent, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0, + deduplicationKey: nil + ) + try await store.saveMessage(message) + + try await store.performRadioIDMigration(defaults: defaults) + + let fetchedMsg = try await store.fetchMessage(id: messageID) + #expect(fetchedMsg != nil) + + let key = fetchedMsg?.deduplicationKey + #expect(key != nil, "Outgoing message should have a dedup key after migration") + + // Verify format matches fallbackDeduplicationKey exactly + let expectedKey = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, + channelIndex: nil, + senderNodeName: nil, + timestamp: timestamp, + content: text + ) + #expect(key == expectedKey, "Dedup key format must match fallbackDeduplicationKey: got \(key ?? "nil"), expected \(expectedKey)") + } + + @Test + func `Incoming message with nil dedup key stays nil`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let messageID = UUID() + let message = MessageDTO( + id: messageID, + radioID: bleUUID, + contactID: UUID(), + channelIndex: nil, + text: "Incoming hello", + timestamp: 1_704_067_200, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 1, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0, + deduplicationKey: nil + ) + try await store.saveMessage(message) + + try await store.performRadioIDMigration(defaults: defaults) + + let fetchedMsg = try await store.fetchMessage(id: messageID) + #expect(fetchedMsg != nil) + #expect(fetchedMsg?.deduplicationKey == nil, "Incoming message dedup key should stay nil") + } + + @Test + func `Existing dedup key is not overwritten`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let existingKey = "dm-existing-key-12345678" + let messageID = UUID() + let message = MessageDTO( + id: messageID, + radioID: bleUUID, + contactID: UUID(), + channelIndex: nil, + text: "Already keyed", + timestamp: 1_704_067_200, + createdAt: Date(), + direction: .outgoing, + status: .sent, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0, + deduplicationKey: existingKey + ) + try await store.saveMessage(message) + + try await store.performRadioIDMigration(defaults: defaults) + + let fetchedMsg = try await store.fetchMessage(id: messageID) + #expect(fetchedMsg != nil) + #expect(fetchedMsg?.deduplicationKey == existingKey, "Pre-existing dedup key must not be modified") + } + + @Test + func `UserDefaults guard prevents re-run`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let contact = ContactDTO.testContact(id: UUID(), radioID: bleUUID, name: "Bob") + try await store.saveContact(contact) + + // First run + try await store.performRadioIDMigration(defaults: defaults) + + let fetchedDevice = try await store.fetchDevice(id: bleUUID) + let firstRadioID = try #require(fetchedDevice?.radioID) + #expect(firstRadioID != bleUUID) + + // Second run should be a no-op (guarded by UserDefaults) + try await store.performRadioIDMigration(defaults: defaults) + + let fetchedDeviceAgain = try await store.fetchDevice(id: bleUUID) + #expect(fetchedDeviceAgain?.radioID == firstRadioID, "Second migration run should not change radioID") + + let contacts = try await store.fetchContacts(radioID: firstRadioID) + #expect(contacts.count == 1, "Contact should still have the radioID from the first run") + } + + // MARK: - lastConnectedRadioID Backfill + + @Test + func `Migration backfills lastConnectedRadioID from lastConnectedDeviceID`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = try await createTestStore() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + // Simulate pre-upgrade state: lastConnectedDeviceID exists, lastConnectedRadioID does not + defaults.set(bleUUID.uuidString, forKey: "com.pocketmesh.lastConnectedDeviceID") + defaults.removeObject(forKey: "com.pocketmesh.lastConnectedRadioID") + + try await store.performRadioIDMigration(defaults: defaults) + + // lastConnectedRadioID should now be populated + let radioIDString = defaults.string(forKey: "com.pocketmesh.lastConnectedRadioID") + #expect(radioIDString != nil) + + // It should match the device's new radioID + let fetchedDevice = try await store.fetchDevice(id: bleUUID) + #expect(radioIDString == fetchedDevice?.radioID.uuidString) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift b/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift index 6477cf07..693a21b7 100644 --- a/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift @@ -1,218 +1,231 @@ -import Testing @testable import MC1Services +import Testing @Suite("RadioPresets.recommended(for:)") struct RadioPresetRecommendationTests { - - // MARK: - Tier 0 (county) - - @Test("LA, CA → WCMesh") - func losAngelesGetsWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "los angeles", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "wcmesh") - } - - @Test("Sacramento (no countyKey match) → us-ca") - func sacramentoFallsToCountry() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "sacramento", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "us-ca") - } - - @Test("Manual California pick (countyKey nil) → us-ca") - func manualCaliforniaIsCountryTier() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) - #expect(RadioPresets.recommended(for: region)?.id == "us-ca") - } - - // MARK: - Tier 1 (sub-region) - - @Test("Queensland → au-qld") - func queenslandGetsAUQLD() { - let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-QLD", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "au-qld") - } - - @Test("Western Australia → au-sa-wa") - func westernAustraliaGetsAUSAWA() { - let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-WA", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "au-sa-wa") - } - - // MARK: - Tier 2 (country) - - @Test("Victoria, AU (no sub-region preset) → au-915 (Tier 2)") - func victoriaAUFallsToAU915() { - let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-VIC", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "au-915") - } - - @Test("Texas → us-ca") - func texasGetsUSCA() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "us-ca") - } - - @Test("Lisbon (PT) → pt-868 (priority 110 beats pt-433)") - func lisbonGetsPT868() { - let region = RegionSelection(countryCode: "PT", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "pt-868") - } - - @Test("Vietnam → vn-narrow (priority 110 beats deprecated vn)") - func vietnamGetsVNNarrow() { - let region = RegionSelection(countryCode: "VN", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "vn-narrow") - } - - @Test("Netherlands → nl (country tier beats EU continent)") - func netherlandsGetsNL() { - let region = RegionSelection(countryCode: "NL", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "nl") - } - - // MARK: - Tier 3 (continent) - - @Test("Berlin (DE) → eu-narrow (priority 110 beats eu-lr)") - func berlinGetsEUNarrow() { - let region = RegionSelection(countryCode: "DE", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "eu-narrow") - } - - // MARK: - No match - - @Test("Bermuda → nil (no continent mapping)") - func bermudaReturnsNil() { - let region = RegionSelection(countryCode: "BM", source: .manual) - #expect(RadioPresets.recommended(for: region) == nil) - } - - // MARK: - presets(for:) - - @Test("presets(for: Sacramento) includes wcmesh in alternatives") - func sacramentoAlternativesIncludeWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "sacramento", source: .location) - let ids = RadioPresets.presets(for: region).map(\.id) - #expect(ids.contains("wcmesh")) - #expect(ids.contains("us-ca")) - } - - @Test("presets(for: DE) returns continent-tier presets") - func presetsForDEReturnsContinentPresets() { - let region = RegionSelection(countryCode: "DE", source: .location) - let ids = RadioPresets.presets(for: region).map(\.id) - #expect(ids.contains("eu-narrow")) - #expect(ids.contains("eu-lr")) - #expect(!ids.contains("us-ca")) - } - - @Test("presets(for: PT) returns country-and-below only") - func presetsForPTSkipsContinent() { - let region = RegionSelection(countryCode: "PT", source: .location) - let ids = RadioPresets.presets(for: region).map(\.id) - #expect(ids.contains("pt-868")) - #expect(ids.contains("pt-433")) - #expect(!ids.contains("eu-narrow")) - } - - @Test("presets(for: VN) includes both vn-narrow and vn") - func presetsForVNIncludesBoth() { - let region = RegionSelection(countryCode: "VN", source: .location) - let ids = RadioPresets.presets(for: region).map(\.id) - #expect(ids.contains("vn-narrow")) - #expect(ids.contains("vn")) - } + // MARK: - Tier 0 (county) + + @Test + func `LA, CA → WCMesh`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "los angeles", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "wcmesh") + } + + @Test + func `Sacramento (no countyKey match) → us-ca`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "sacramento", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "us-ca") + } + + @Test + func `Manual California pick (countyKey nil) → us-ca`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) + #expect(RadioPresets.recommended(for: region)?.id == "us-ca") + } + + // MARK: - Tier 1 (sub-region) + + @Test + func `Queensland → au-qld`() { + let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-QLD", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "au-qld") + } + + @Test + func `Western Australia → au-sa-wa`() { + let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-WA", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "au-sa-wa") + } + + // MARK: - Tier 2 (country) + + @Test + func `Victoria, AU (no sub-region preset) → au-915 (Tier 2)`() { + let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-VIC", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "au-915") + } + + @Test + func `Texas → us-ca`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "us-ca") + } + + @Test + func `Lisbon (PT) → pt-868 (priority 110 beats pt-433)`() { + let region = RegionSelection(countryCode: "PT", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "pt-868") + } + + @Test + func `Vietnam → vn-narrow (priority 110 beats deprecated vn)`() { + let region = RegionSelection(countryCode: "VN", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "vn-narrow") + } + + @Test + func `Netherlands → nl (country tier beats EU continent)`() { + let region = RegionSelection(countryCode: "NL", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "nl") + } + + @Test + func `Chile → cl`() { + let region = RegionSelection(countryCode: "CL", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "cl") + } + + @Test + func `Chile preset carries the expected radio parameters`() throws { + let preset = try #require(RadioPresets.all.first(where: { $0.id == "cl" })) + #expect(preset.frequencyMHz == 927.875) + #expect(preset.bandwidthKHz == 62.5) + #expect(preset.spreadingFactor == 8) + #expect(preset.codingRate == 5) + #expect(preset.region == .southAmerica) + } + + // MARK: - Tier 3 (continent) + + @Test + func `Berlin (DE) → eu-narrow (priority 110 beats eu-lr)`() { + let region = RegionSelection(countryCode: "DE", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "eu-narrow") + } + + // MARK: - No match + + @Test + func `Bermuda → nil (no continent mapping)`() { + let region = RegionSelection(countryCode: "BM", source: .manual) + #expect(RadioPresets.recommended(for: region) == nil) + } + + // MARK: - presets(for:) + + @Test + func `presets(for: Sacramento) includes wcmesh in alternatives`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "sacramento", source: .location) + let ids = RadioPresets.presets(for: region).map(\.id) + #expect(ids.contains("wcmesh")) + #expect(ids.contains("us-ca")) + } + + @Test + func `presets(for: DE) returns continent-tier presets`() { + let region = RegionSelection(countryCode: "DE", source: .location) + let ids = RadioPresets.presets(for: region).map(\.id) + #expect(ids.contains("eu-narrow")) + #expect(ids.contains("eu-lr")) + #expect(!ids.contains("us-ca")) + } + + @Test + func `presets(for: PT) returns country-and-below only`() { + let region = RegionSelection(countryCode: "PT", source: .location) + let ids = RadioPresets.presets(for: region).map(\.id) + #expect(ids.contains("pt-868")) + #expect(ids.contains("pt-433")) + #expect(!ids.contains("eu-narrow")) + } + + @Test + func `presets(for: VN) includes both vn-narrow and vn`() { + let region = RegionSelection(countryCode: "VN", source: .location) + let ids = RadioPresets.presets(for: region).map(\.id) + #expect(ids.contains("vn-narrow")) + #expect(ids.contains("vn")) + } } @Suite("RadioPresets.isSelectable(_:in:)") struct RadioPresetSelectabilityTests { - - private func preset(_ id: String) -> RadioPreset { - guard let preset = RadioPresets.all.first(where: { $0.id == id }) else { - fatalError("missing preset \(id)") - } - return preset - } - - // MARK: - County-restricted (WCMesh) - - @Test("SoCal county → WCMesh selectable") - func socalShowsWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "los angeles", source: .location) - #expect(RadioPresets.isSelectable(preset("wcmesh"), in: region)) - } - - @Test("NorCal county → WCMesh hidden, us-ca still selectable") - func norcalHidesWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "sacramento", source: .location) - #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) - #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) - } - - @Test("California with no county → WCMesh hidden") - func californiaWithoutCountyHidesWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) - #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) - } - - @Test("Non-CA US state → WCMesh hidden, us-ca selectable") - func texasHidesWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location) - #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) - #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) - } - - @Test("nil region → WCMesh hidden, global presets selectable") - func nilRegionHidesWCMesh() { - #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: nil)) - #expect(RadioPresets.isSelectable(preset("us-ca"), in: nil)) - #expect(RadioPresets.isSelectable(preset("eu-narrow"), in: nil)) - } - - // MARK: - Non-county presets are never gated - - @Test("Continent/country presets selectable for any region including nil") - func globalPresetsAlwaysSelectable() { - let regions: [RegionSelection?] = [ - nil, - RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location), - RegionSelection(countryCode: "DE", source: .location), - ] - for region in regions { - #expect(RadioPresets.isSelectable(preset("eu-narrow"), in: region)) - #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) - } - } + private func preset(_ id: String) -> RadioPreset { + guard let preset = RadioPresets.all.first(where: { $0.id == id }) else { + fatalError("missing preset \(id)") + } + return preset + } + + // MARK: - County-restricted (WCMesh) + + @Test + func `SoCal county → WCMesh selectable`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "los angeles", source: .location) + #expect(RadioPresets.isSelectable(preset("wcmesh"), in: region)) + } + + @Test + func `NorCal county → WCMesh hidden, us-ca still selectable`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "sacramento", source: .location) + #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) + #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) + } + + @Test + func `California with no county → WCMesh hidden`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) + #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) + } + + @Test + func `Non-CA US state → WCMesh hidden, us-ca selectable`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location) + #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) + #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) + } + + @Test + func `nil region → WCMesh hidden, global presets selectable`() { + #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: nil)) + #expect(RadioPresets.isSelectable(preset("us-ca"), in: nil)) + #expect(RadioPresets.isSelectable(preset("eu-narrow"), in: nil)) + } + + // MARK: - Non-county presets are never gated + + @Test + func `Continent/country presets selectable for any region including nil`() { + let regions: [RegionSelection?] = [ + nil, + RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location), + RegionSelection(countryCode: "DE", source: .location), + ] + for region in regions { + #expect(RadioPresets.isSelectable(preset("eu-narrow"), in: region)) + #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) + } + } } @Suite("RadioPreset protocol encoding") struct RadioPresetEncodingTests { - - private func preset(frequencyMHz: Double, bandwidthKHz: Double) -> RadioPreset { - RadioPreset( - id: "test", - name: "Test", - region: .northAmerica, - frequencyMHz: frequencyMHz, - bandwidthKHz: bandwidthKHz, - spreadingFactor: 7, - codingRate: 5, - availability: .continent(.northAmerica) - ) - } - - @Test("frequencyKHz rounds to the nearest kHz") - func frequencyRounds() { - // Truncation would yield 512001; rounding restores the representable 512002. - #expect(preset(frequencyMHz: 512.002, bandwidthKHz: 62.5).frequencyKHz == 512_002) - } - - @Test("bandwidthHz rounds to the nearest Hz") - func bandwidthRounds() { - #expect(preset(frequencyMHz: 915.0, bandwidthKHz: 62.501).bandwidthHz == 62_501) - } + private func preset(frequencyMHz: Double, bandwidthKHz: Double) -> RadioPreset { + RadioPreset( + id: "test", + name: "Test", + region: .northAmerica, + frequencyMHz: frequencyMHz, + bandwidthKHz: bandwidthKHz, + spreadingFactor: 7, + codingRate: 5, + availability: .continent(.northAmerica) + ) + } + + @Test + func `frequencyKHz rounds to the nearest kHz`() { + // Truncation would yield 512001; rounding restores the representable 512002. + #expect(preset(frequencyMHz: 512.002, bandwidthKHz: 62.5).frequencyKHz == 512_002) + } + + @Test + func `bandwidthHz rounds to the nearest Hz`() { + #expect(preset(frequencyMHz: 915.0, bandwidthKHz: 62.501).bandwidthHz == 62501) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ReconnectRebuildLifecycleTests.swift b/MC1Services/Tests/MC1ServicesTests/ReconnectRebuildLifecycleTests.swift new file mode 100644 index 00000000..0e877ba1 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/ReconnectRebuildLifecycleTests.swift @@ -0,0 +1,198 @@ +import Foundation +@testable import MC1Services +import MeshCore +import Testing + +/// Covers the iOS auto-reconnect rebuild window: the presentation state it +/// surfaces while the session is being rebuilt and its first sync runs, and the +/// guided pairing-failure recovery reaching the user when a bond turns out to be +/// invalidated mid-reconnect. +@Suite("Reconnect Rebuild Lifecycle") +@MainActor +struct ReconnectRebuildLifecycleTests { + // MARK: - Presentation state during rebuild + sync + + /// Fresh connect shows `.connected` (link up, sync running) so the syncing pill + /// is visible; the auto-reconnect rebuild must show the same rung instead of a + /// prolonged `.connecting` that no UI window bounds. The transport pins the + /// appStart handshake so the rebuild stays inside its setup+sync window while + /// the surfaced state is observed. + @Test + func `reconnect rebuild surfaces connected while the sync window is still open`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let transport = PinnedHandshakeTransport() + let manager = ConnectionManager( + modelContainer: container, + defaults: defaults, + stateMachine: MockBLEStateMachine(), + transport: transport + ) + + let deviceID = UUID() + // The coordinator sets .connecting immediately before invoking rebuildSession. + manager.setTestState( + connectionState: .connecting, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + let rebuildTask = Task { try? await manager.rebuildSession(deviceID: deviceID) } + + // The handshake is pinned in the transport, so the rebuild cannot advance to + // promotion; the state observed here is the rung the reconnect window shows. + try await waitUntil(timeout: .seconds(2), "rebuild should surface .connected during its sync window") { + manager.connectionState == .connected + } + #expect(manager.connectionState == .connected) + + // Release the pinned handshake so the rebuild unwinds and the task finishes. + await transport.releaseSend() + _ = await rebuildTask.value + } + + // MARK: - Bond loss mid-reconnect + + /// An established radio can lose its bond while iOS is auto-reconnecting: the + /// rebuild's GATT discovery/subscribe surfaces `authenticationFailed` for the + /// same device the coordinator is reconnecting. That failure must reach the + /// guided recovery (not be swallowed as a stale-device disconnect), clear the + /// reconnect claim so silent retries can't hide it, and leave the manager ready + /// for a re-pair that returns to a working paired state. + @Test + func `bond loss during an active reconnect surfaces guided recovery and re-pairs`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let deviceID = UUID() + var surfaced: [UUID] = [] + manager.onAuthenticationFailure = { surfaced.append($0) } + + manager.setTestState(connectionIntent: .wantsConnection()) + + // iOS auto-reconnect claims the cycle: state → .connecting, the coordinator + // now owns deviceID as the reconnecting device. + await manager.reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) + #expect(manager.connectionState == .connecting) + #expect(manager.reconnectionCoordinator.reconnectingDeviceID == deviceID) + + // The rebuild finds the bond invalidated: an authenticationFailed disconnect + // arrives for the same device mid-reconnect. + await manager.handleConnectionLoss(deviceID: deviceID, error: BLEError.authenticationFailed) + + #expect(surfaced == [deviceID], "auth failure mid-reconnect must reach guided recovery") + #expect(manager.connectionState == .disconnected) + #expect( + manager.reconnectionCoordinator.reconnectingDeviceID == nil, + "the stale reconnect claim must be cleared so retries don't hide the recovery" + ) + + // Guided recovery leads the user through a re-pair; the fresh connection + // promotes to .ready, returning the radio to a working paired state. + let session = MeshCoreSession(transport: SimulatorMockTransport()) + let services = try await ServiceContainer.forTesting(session: session) + manager.setTestState( + connectionState: .connected, + services: services, + session: session, + connectedDevice: DeviceDTO.testDevice(id: deviceID), + connectionIntent: .wantsConnection() + ) + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth + ) + #expect(promoted) + #expect(manager.connectionState == .ready) + + manager.stopReconnectionWatchdog() + } + + /// The mid-reconnect surfacing above must stay specific to the reconnecting + /// device: a disconnect for a different peripheral (a stale link tearing down + /// while iOS reconnects the current one) is ignored, and the active reconnect + /// for the current device survives. + @Test + func `a stale-device disconnect during reconnect is ignored and preserves the cycle`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let deviceID = UUID() + let staleDeviceID = UUID() + var surfaced: [UUID] = [] + manager.onAuthenticationFailure = { surfaced.append($0) } + + manager.setTestState(connectionIntent: .wantsConnection()) + await manager.reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) + + await manager.handleConnectionLoss(deviceID: staleDeviceID, error: BLEError.authenticationFailed) + + #expect(surfaced.isEmpty, "a disconnect for a different device must not surface recovery") + #expect(manager.connectionState == .connecting, "the active reconnect for the current device must survive") + #expect(manager.reconnectionCoordinator.reconnectingDeviceID == deviceID) + + manager.reconnectionCoordinator.cancelTimeout() + } +} + +/// Transport that lets the BLE link "connect" but pins the appStart handshake +/// send until the test releases it, holding a session rebuild inside its +/// setup+sync window so the surfaced connection state can be observed. +private actor PinnedHandshakeTransport: iOSMeshTransport { + private let dataStream: AsyncStream + private let dataContinuation: AsyncStream.Continuation + private let releaseStream: AsyncStream + private let releaseContinuation: AsyncStream.Continuation + private var connected = false + + init() { + var dataCont: AsyncStream.Continuation! + dataStream = AsyncStream { dataCont = $0 } + dataContinuation = dataCont + var releaseCont: AsyncStream.Continuation! + releaseStream = AsyncStream { releaseCont = $0 } + releaseContinuation = releaseCont + } + + var receivedData: AsyncStream { + dataStream + } + + var isConnected: Bool { + connected + } + + func connect() async throws { + connected = true + } + + func disconnect() async { + connected = false + dataContinuation.finish() + } + + func send(_ data: Data) async throws { + // Pin the appStart handshake until the test releases it; an AsyncStream gate + // (rather than a checked continuation) can't crash on an unreleased leak. + for await _ in releaseStream { + break + } + throw MeshTransportError.notConnected + } + + /// Unpins the handshake, failing the pending send so the rebuild unwinds + /// through session.start's failure path. + func releaseSend() { + releaseContinuation.yield(()) + releaseContinuation.finish() + } + + func setDeviceID(_ id: UUID) {} + func switchDevice(to deviceID: UUID) async throws {} + func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) {} + func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) {} +} diff --git a/MC1Services/Tests/MC1ServicesTests/RegionalAreasTests.swift b/MC1Services/Tests/MC1ServicesTests/RegionalAreasTests.swift index ccc0cd1c..f9dbb0e9 100644 --- a/MC1Services/Tests/MC1ServicesTests/RegionalAreasTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RegionalAreasTests.swift @@ -1,94 +1,93 @@ -import Testing @testable import MC1Services +import Testing @Suite("RegionalAreas") struct RegionalAreasTests { - - @Test("matchSubdivision finds California from normalized state name") - func matchSubdivisionCalifornia() { - #expect(RegionalAreas.matchSubdivision(country: "US", normalized: "ca") == "US-CA") - } - - @Test("matchSubdivision finds Queensland from short suffix") - func matchSubdivisionQueensland() { - #expect(RegionalAreas.matchSubdivision(country: "AU", normalized: "qld") == "AU-QLD") - } - - @Test("matchSubdivision returns nil for unknown subdivision") - func matchSubdivisionUnknown() { - #expect(RegionalAreas.matchSubdivision(country: "US", normalized: "zz") == nil) - } - - @Test("matchSubdivision returns nil for nil input") - func matchSubdivisionNilInput() { - #expect(RegionalAreas.matchSubdivision(country: "US", normalized: nil) == nil) - } - - @Test("matchCounty finds Los Angeles in US-CA") - func matchCountyLosAngeles() { - #expect(RegionalAreas.matchCounty(country: "US", state: "US-CA", normalized: "los angeles") == "los angeles") - } - - @Test("matchCounty rejects unknown county") - func matchCountyUnknown() { - #expect(RegionalAreas.matchCounty(country: "US", state: "US-CA", normalized: "sacramento") == nil) - } - - @Test("matchCounty rejects non-US country") - func matchCountyNonUS() { - #expect(RegionalAreas.matchCounty(country: "CA", state: "CA-ON", normalized: "york") == nil) - } - - @Test("matchCounty rejects nil state") - func matchCountyNilState() { - #expect(RegionalAreas.matchCounty(country: "US", state: nil, normalized: "los angeles") == nil) - } - - @Test("continents map covers known European countries") - func continentsEurope() { - #expect(RegionalAreas.continents["DE"] == .europe) - #expect(RegionalAreas.continents["GB"] == .europe) - #expect(RegionalAreas.continents["PT"] == .europe) - } - - @Test("continents map covers Oceania and Asia") - func continentsOceaniaAsia() { - #expect(RegionalAreas.continents["AU"] == .oceania) - #expect(RegionalAreas.continents["NZ"] == .oceania) - #expect(RegionalAreas.continents["VN"] == .asia) - } - - @Test("Mexico is intentionally absent from continents") - func continentsMexicoAbsent() { - #expect(RegionalAreas.continents["MX"] == nil) - } - - @Test("displayName uses short form for US states") - func displayNameUSShort() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) - #expect(RegionalAreas.displayName(for: region) == "California") - } - - @Test("displayName uses disambiguated form for AU territories") - func displayNameAUDisambiguated() { - let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-QLD", source: .manual) - let name = RegionalAreas.displayName(for: region) - #expect(name.contains("Queensland")) - #expect(name.contains("Australia")) - } - - @Test("displayName falls back to country name when admin is nil") - func displayNameCountryOnly() { - let region = RegionSelection(countryCode: "US", source: .manual) - #expect(RegionalAreas.displayName(for: region) == "United States") - } - - @Test("continents and countries cover the same set of country codes") - func continentsCountriesAlignment() { - // Adding a country to one table without the other silently breaks the picker - // (visible but no recommendation) or the recommendation (no picker entry). - let continentKeys = Set(RegionalAreas.continents.keys) - let countryIDs = Set(RegionalAreas.countries.map(\.id)) - #expect(continentKeys == countryIDs) - } + @Test + func `matchSubdivision finds California from normalized state name`() { + #expect(RegionalAreas.matchSubdivision(country: "US", normalized: "ca") == "US-CA") + } + + @Test + func `matchSubdivision finds Queensland from short suffix`() { + #expect(RegionalAreas.matchSubdivision(country: "AU", normalized: "qld") == "AU-QLD") + } + + @Test + func `matchSubdivision returns nil for unknown subdivision`() { + #expect(RegionalAreas.matchSubdivision(country: "US", normalized: "zz") == nil) + } + + @Test + func `matchSubdivision returns nil for nil input`() { + #expect(RegionalAreas.matchSubdivision(country: "US", normalized: nil) == nil) + } + + @Test + func `matchCounty finds Los Angeles in US-CA`() { + #expect(RegionalAreas.matchCounty(country: "US", state: "US-CA", normalized: "los angeles") == "los angeles") + } + + @Test + func `matchCounty rejects unknown county`() { + #expect(RegionalAreas.matchCounty(country: "US", state: "US-CA", normalized: "sacramento") == nil) + } + + @Test + func `matchCounty rejects non-US country`() { + #expect(RegionalAreas.matchCounty(country: "CA", state: "CA-ON", normalized: "york") == nil) + } + + @Test + func `matchCounty rejects nil state`() { + #expect(RegionalAreas.matchCounty(country: "US", state: nil, normalized: "los angeles") == nil) + } + + @Test + func `continents map covers known European countries`() { + #expect(RegionalAreas.continents["DE"] == .europe) + #expect(RegionalAreas.continents["GB"] == .europe) + #expect(RegionalAreas.continents["PT"] == .europe) + } + + @Test + func `continents map covers Oceania and Asia`() { + #expect(RegionalAreas.continents["AU"] == .oceania) + #expect(RegionalAreas.continents["NZ"] == .oceania) + #expect(RegionalAreas.continents["VN"] == .asia) + } + + @Test + func `Mexico is intentionally absent from continents`() { + #expect(RegionalAreas.continents["MX"] == nil) + } + + @Test + func `displayName uses short form for US states`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) + #expect(RegionalAreas.displayName(for: region) == "California") + } + + @Test + func `displayName uses disambiguated form for AU territories`() { + let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-QLD", source: .manual) + let name = RegionalAreas.displayName(for: region) + #expect(name.contains("Queensland")) + #expect(name.contains("Australia")) + } + + @Test + func `displayName falls back to country name when admin is nil`() { + let region = RegionSelection(countryCode: "US", source: .manual) + #expect(RegionalAreas.displayName(for: region) == "United States") + } + + @Test + func `continents and countries cover the same set of country codes`() { + // Adding a country to one table without the other silently breaks the picker + // (visible but no recommendation) or the recommendation (no picker entry). + let continentKeys = Set(RegionalAreas.continents.keys) + let countryIDs = Set(RegionalAreas.countries.map(\.id)) + #expect(continentKeys == countryIDs) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/RepeatPresetTests.swift b/MC1Services/Tests/MC1ServicesTests/RepeatPresetTests.swift new file mode 100644 index 00000000..343eb096 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/RepeatPresetTests.swift @@ -0,0 +1,49 @@ +@testable import MC1Services +import Testing + +@Suite("RadioPresets repeat mode") +struct RepeatPresetTests { + /// The firmware (isValidClientRepeatFreq) accepts only these exact frequencies. + private static let firmwareRepeatFreqsKHz: [String: UInt32] = [ + "repeat-433": 433_000, + "repeat-869": 869_495, + "repeat-918": 918_000, + ] + + @Test + func `repeat preset frequencies match the firmware's allowed set exactly`() { + for preset in RadioPresets.repeatPresets { + let expected = Self.firmwareRepeatFreqsKHz[preset.id] + #expect(expected != nil, "unexpected repeat preset id \(preset.id)") + #expect(preset.frequencyKHz == expected) + } + #expect(RadioPresets.repeatPresets.count == Self.firmwareRepeatFreqsKHz.count) + } + + @Test + func `matchingRepeatPreset resolves by frequency only`() { + #expect(RadioPresets.matchingRepeatPreset(frequencyKHz: 869_495)?.id == "repeat-869") + #expect(RadioPresets.matchingRepeatPreset(frequencyKHz: 433_000)?.id == "repeat-433") + #expect(RadioPresets.matchingRepeatPreset(frequencyKHz: 918_000)?.id == "repeat-918") + } + + @Test + func `matchingRepeatPreset returns nil for a non-repeat frequency`() { + #expect(RadioPresets.matchingRepeatPreset(frequencyKHz: 869_000) == nil) + #expect(RadioPresets.matchingRepeatPreset(frequencyKHz: 915_000) == nil) + } + + @Test + func `nearestRepeatPreset snaps an off-band frequency to the closest allowed one`() { + #expect(RadioPresets.nearestRepeatPreset(toFrequencyKHz: 869_000)?.id == "repeat-869") + #expect(RadioPresets.nearestRepeatPreset(toFrequencyKHz: 915_000)?.id == "repeat-918") + #expect(RadioPresets.nearestRepeatPreset(toFrequencyKHz: 868_000)?.id == "repeat-869") + #expect(RadioPresets.nearestRepeatPreset(toFrequencyKHz: 500_000)?.id == "repeat-433") + } + + @Test + func `nearestRepeatPreset returns an already-valid frequency unchanged`() { + #expect(RadioPresets.nearestRepeatPreset(toFrequencyKHz: 869_495)?.id == "repeat-869") + #expect(RadioPresets.nearestRepeatPreset(toFrequencyKHz: 918_000)?.id == "repeat-918") + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift b/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift index be9dafbd..30385109 100644 --- a/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift @@ -1,132 +1,142 @@ import Foundation +@testable import MC1Services +import MeshCore import SwiftData import Testing -import MeshCore -@testable import MC1Services @Suite("Repeater unread-count corrective migration", .serialized) struct RepeaterUnreadMigrationTests { - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func makeContactDTO( - radioID: UUID, - type: ContactType, - unreadCount: Int, - unreadMentionCount: Int = 0, - name: String = "Contact" - ) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: radioID, - publicKey: Data((0.. RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: UUID(), - radioID: radioID, - publicKey: Data((0.. PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func makeContactDTO( + radioID: UUID, + type: ContactType, + unreadCount: Int, + unreadMentionCount: Int = 0, + name: String = "Contact" + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. clear - let bothClear = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", endLabel: "R", clearanceStatus: .clear, distanceMeters: 1000, - worstClearancePercent: 90), - segmentRB: SegmentAnalysisResult( - startLabel: "R", endLabel: "B", clearanceStatus: .clear, distanceMeters: 1000, - worstClearancePercent: 85) - ) - #expect(bothClear.overallStatus == .clear) + @Test + func `RelayPathAnalysisResult overall status is worst of segments`() { + // Both clear -> clear + let bothClear = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", endLabel: "R", clearanceStatus: .clear, distanceMeters: 1000, + worstClearancePercent: 90 + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", endLabel: "B", clearanceStatus: .clear, distanceMeters: 1000, + worstClearancePercent: 85 + ) + ) + #expect(bothClear.overallStatus == .clear) - // One blocked -> blocked - let oneBlocked = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", endLabel: "R", clearanceStatus: .clear, distanceMeters: 1000, - worstClearancePercent: 90), - segmentRB: SegmentAnalysisResult( - startLabel: "R", endLabel: "B", clearanceStatus: .blocked, distanceMeters: 1000, - worstClearancePercent: -10) - ) - #expect(oneBlocked.overallStatus == .blocked) - } + // One blocked -> blocked + let oneBlocked = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", endLabel: "R", clearanceStatus: .clear, distanceMeters: 1000, + worstClearancePercent: 90 + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", endLabel: "B", clearanceStatus: .blocked, distanceMeters: 1000, + worstClearancePercent: -10 + ) + ) + #expect(oneBlocked.overallStatus == .blocked) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ServiceContainerWiringTests.swift b/MC1Services/Tests/MC1ServicesTests/ServiceContainerWiringTests.swift index 856996d6..3bb9b4ad 100644 --- a/MC1Services/Tests/MC1ServicesTests/ServiceContainerWiringTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ServiceContainerWiringTests.swift @@ -1,125 +1,124 @@ -import Testing import Foundation -import MeshCore @testable import MC1Services +import MeshCore +import Testing @Suite("ServiceContainer Wiring Tests") struct ServiceContainerWiringTests { - - /// Creates a ServiceContainer using the test factory. - @MainActor - private func makeContainer() async throws -> ServiceContainer { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - return try await ServiceContainer.forTesting(session: session) + /// Creates a ServiceContainer using the test factory. + @MainActor + private func makeContainer() async throws -> ServiceContainer { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + return try await ServiceContainer.forTesting(session: session) + } + + @Test + @MainActor + func `init establishes all 6 cross-service connections`() async throws { + let container = try await makeContainer() + + // 1. messageService → contactService + let hasContact = await container.messageService.hasContactServiceWired + #expect(hasContact, "messageService should have contactService injected") + + // 2. contactService → syncCoordinator + let hasContactSync = await container.contactService.hasSyncCoordinatorWired + #expect(hasContactSync, "contactService should have syncCoordinator injected") + + // 3. nodeConfigService → syncCoordinator + let hasNodeSync = await container.nodeConfigService.hasSyncCoordinatorWired + #expect(hasNodeSync, "nodeConfigService should have syncCoordinator injected") + + // 4. contactService → cleanup coordinator + let hasCleanup = await container.contactService.hasCleanupCoordinatorWired + #expect(hasCleanup, "contactService should have cleanupCoordinator injected") + + // 5. channelService → rxLogService + let hasRxLog = await container.channelService.hasRxLogServiceWired + #expect(hasRxLog, "channelService should have rxLogService injected") + + // 6. rxLogService → heardRepeatsService + let hasHeardRepeats = await container.rxLogService.hasHeardRepeatsServiceWired + #expect(hasHeardRepeats, "rxLogService should have heardRepeatsService injected") + } + + @Test + @MainActor + func `tearDown clears the wired message and discovery handlers`() async throws { + let container = try await makeContainer() + let radioID = UUID() + try await container.dataStore.saveDevice( + DeviceDTO.testDevice(id: radioID, radioID: radioID) + ) + + await container.syncCoordinator.wireMessageHandlers(dependencies: container.syncDependencies, radioID: radioID) + await container.syncCoordinator.startDiscoveryEventMonitoring(dependencies: container.syncDependencies, radioID: radioID) + + #expect(await container.messagePollingService.hasMessageHandlersWired) + #expect(container.advertisementService.eventBroadcaster.subscriberCount > 0, + "startDiscoveryEventMonitoring must subscribe to advertisement events") + + await container.tearDown() + + #expect(await container.messagePollingService.hasMessageHandlersWired == false, + "tearDown must clear message handlers to break the container retain cycle") + #expect(container.advertisementService.eventBroadcaster.subscriberCount == 0, + "tearDown must end every advertisement event subscription to break the container retain cycle") + } + + @Test + @MainActor + func `tearDown clears the notification action forwarders that capture the handler`() async throws { + let container = try await makeContainer() + let handler = container.notificationActionHandler + + // Mirror AppState's forwarders: each captures the handler strongly, so a + // dropped nil-out leaves the notificationService <-> handler cycle alive. + container.notificationService.onQuickReply = { contactID, text in + await handler.handleQuickReply(contactID: contactID, text: text) } - - @Test("init establishes all 6 cross-service connections") - @MainActor - func initEstablishesAllConnections() async throws { - let container = try await makeContainer() - - // 1. messageService → contactService - let hasContact = await container.messageService.hasContactServiceWired - #expect(hasContact, "messageService should have contactService injected") - - // 2. contactService → syncCoordinator - let hasContactSync = await container.contactService.hasSyncCoordinatorWired - #expect(hasContactSync, "contactService should have syncCoordinator injected") - - // 3. nodeConfigService → syncCoordinator - let hasNodeSync = await container.nodeConfigService.hasSyncCoordinatorWired - #expect(hasNodeSync, "nodeConfigService should have syncCoordinator injected") - - // 4. contactService → cleanup coordinator - let hasCleanup = await container.contactService.hasCleanupCoordinatorWired - #expect(hasCleanup, "contactService should have cleanupCoordinator injected") - - // 5. channelService → rxLogService - let hasRxLog = await container.channelService.hasRxLogServiceWired - #expect(hasRxLog, "channelService should have rxLogService injected") - - // 6. rxLogService → heardRepeatsService - let hasHeardRepeats = await container.rxLogService.hasHeardRepeatsServiceWired - #expect(hasHeardRepeats, "rxLogService should have heardRepeatsService injected") + container.notificationService.onChannelQuickReply = { radioID, channelIndex, text in + await handler.handleChannelQuickReply(radioID: radioID, channelIndex: channelIndex, text: text) } - - @Test("tearDown clears the wired message and discovery handlers") - @MainActor - func tearDownClearsWiredHandlers() async throws { - let container = try await makeContainer() - let radioID = UUID() - try await container.dataStore.saveDevice( - DeviceDTO.testDevice(id: radioID, radioID: radioID) - ) - - await container.syncCoordinator.wireMessageHandlers(dependencies: container.syncDependencies, radioID: radioID) - await container.syncCoordinator.startDiscoveryEventMonitoring(dependencies: container.syncDependencies, radioID: radioID) - - #expect(await container.messagePollingService.hasMessageHandlersWired) - #expect(container.advertisementService.eventBroadcaster.subscriberCount > 0, - "startDiscoveryEventMonitoring must subscribe to advertisement events") - - await container.tearDown() - - #expect(await container.messagePollingService.hasMessageHandlersWired == false, - "tearDown must clear message handlers to break the container retain cycle") - #expect(container.advertisementService.eventBroadcaster.subscriberCount == 0, - "tearDown must end every advertisement event subscription to break the container retain cycle") + container.notificationService.onMarkAsRead = { contactID, messageID in + await handler.handleMarkAsRead(contactID: contactID, messageID: messageID) } - - @Test("tearDown clears the notification action forwarders that capture the handler") - @MainActor - func tearDownClearsNotificationActionForwarders() async throws { - let container = try await makeContainer() - let handler = container.notificationActionHandler - - // Mirror AppState's forwarders: each captures the handler strongly, so a - // dropped nil-out leaves the notificationService <-> handler cycle alive. - container.notificationService.onQuickReply = { contactID, text in - await handler.handleQuickReply(contactID: contactID, text: text) - } - container.notificationService.onChannelQuickReply = { radioID, channelIndex, text in - await handler.handleChannelQuickReply(radioID: radioID, channelIndex: channelIndex, text: text) - } - container.notificationService.onMarkAsRead = { contactID, messageID in - await handler.handleMarkAsRead(contactID: contactID, messageID: messageID) - } - container.notificationService.onChannelMarkAsRead = { radioID, channelIndex, messageID in - await handler.handleChannelMarkAsRead(radioID: radioID, channelIndex: channelIndex, messageID: messageID) - } - container.notificationService.onRoomMarkAsRead = { sessionID, messageID in - await handler.handleRoomMarkAsRead(sessionID: sessionID, messageID: messageID) - } - - await container.tearDown() - - #expect(container.notificationService.onQuickReply == nil) - #expect(container.notificationService.onChannelQuickReply == nil) - #expect(container.notificationService.onMarkAsRead == nil) - #expect(container.notificationService.onChannelMarkAsRead == nil) - #expect(container.notificationService.onRoomMarkAsRead == nil) + container.notificationService.onChannelMarkAsRead = { radioID, channelIndex, messageID in + await handler.handleChannelMarkAsRead(radioID: radioID, channelIndex: channelIndex, messageID: messageID) } - - @Test("startEventMonitoring activates ACK expiry checker; stopEventMonitoring deactivates it") - @MainActor - func startEventMonitoringActivatesAckChecking() async throws { - let container = try await makeContainer() - let radioID = UUID() - try await container.dataStore.saveDevice( - DeviceDTO.testDevice(id: radioID, radioID: radioID) - ) - - #expect(await container.messageService.isAckExpiryCheckingActive == false) - - await container.startEventMonitoring( - radioID: radioID, - enableAutoFetch: false, - enableAdvertisementMonitoring: false - ) - #expect(await container.messageService.isAckExpiryCheckingActive == true) - - await container.stopEventMonitoring() - #expect(await container.messageService.isAckExpiryCheckingActive == false) + container.notificationService.onRoomMarkAsRead = { sessionID, messageID in + await handler.handleRoomMarkAsRead(sessionID: sessionID, messageID: messageID) } + + await container.tearDown() + + #expect(container.notificationService.onQuickReply == nil) + #expect(container.notificationService.onChannelQuickReply == nil) + #expect(container.notificationService.onMarkAsRead == nil) + #expect(container.notificationService.onChannelMarkAsRead == nil) + #expect(container.notificationService.onRoomMarkAsRead == nil) + } + + @Test + @MainActor + func `startEventMonitoring activates ACK expiry checker; stopEventMonitoring deactivates it`() async throws { + let container = try await makeContainer() + let radioID = UUID() + try await container.dataStore.saveDevice( + DeviceDTO.testDevice(id: radioID, radioID: radioID) + ) + + #expect(await container.messageService.isAckExpiryCheckingActive == false) + + await container.startEventMonitoring( + radioID: radioID, + enableAutoFetch: false, + enableAdvertisementMonitoring: false + ) + #expect(await container.messageService.isAckExpiryCheckingActive == true) + + await container.stopEventMonitoring() + #expect(await container.messageService.isAckExpiryCheckingActive == false) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/AccessorySetupKitDiscoveryCriteriaTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/AccessorySetupKitDiscoveryCriteriaTests.swift index 310a466a..ef29964f 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/AccessorySetupKitDiscoveryCriteriaTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/AccessorySetupKitDiscoveryCriteriaTests.swift @@ -1,43 +1,42 @@ -import Testing @testable import MC1Services +import Testing @Suite("AccessorySetupKit Discovery Criteria Tests") struct AccessorySetupKitDiscoveryCriteriaTests { + @Test + func `supported Bluetooth name substrings match shipped MeshCore families`() { + #expect( + AccessorySetupKitDiscoveryCriteria.bluetoothNameSubstrings == [ + "MeshCore-", + "Whisper-", + "WisCore", + "XIAO", + "elecrow", + "HT-n5262", + "Seeed", + "BQ", + "ProMicro", + "Keepteen", + "Meshtiny", + "T1000-E-BOOT", + "me25ls01-BOOT", + "NRF52 DK", + "T-Impulse", + ] + ) + } - @Test("supported Bluetooth name substrings match shipped MeshCore families") - func supportedBluetoothNameSubstrings() { - #expect( - AccessorySetupKitDiscoveryCriteria.bluetoothNameSubstrings == [ - "MeshCore-", - "Whisper-", - "WisCore", - "XIAO", - "elecrow", - "HT-n5262", - "Seeed", - "BQ", - "ProMicro", - "Keepteen", - "Meshtiny", - "T1000-E-BOOT", - "me25ls01-BOOT", - "NRF52 DK", - "T-Impulse", - ] - ) - } - - @Test("all discovery criteria use the Nordic UART service UUID") - func allDiscoveryCriteriaUseNordicUART() { - let criteria = AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria + @Test + func `all discovery criteria use the Nordic UART service UUID`() { + let criteria = AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria - #expect(criteria.count == AccessorySetupKitDiscoveryCriteria.bluetoothNameSubstrings.count) - #expect(criteria.allSatisfy { $0.bluetoothServiceUUID == BLEServiceUUID.nordicUART }) - #expect(Set(criteria.map(\.bluetoothNameSubstring)).count == criteria.count) - } + #expect(criteria.count == AccessorySetupKitDiscoveryCriteria.bluetoothNameSubstrings.count) + #expect(criteria.allSatisfy { $0.bluetoothServiceUUID == BLEServiceUUID.nordicUART }) + #expect(Set(criteria.map(\.bluetoothNameSubstring)).count == criteria.count) + } - @Test("picker opts into filtered discovery so matches can be relabeled with advertised names") - func pickerUsesFilteredDiscovery() { - #expect(AccessorySetupKitDiscoveryCriteria.usesFilteredDiscovery == true) - } + @Test + func `picker opts into filtered discovery so matches can be relabeled with advertised names`() { + #expect(AccessorySetupKitDiscoveryCriteria.usesFilteredDiscovery == true) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/AckCodeBuilderTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/AckCodeBuilderTests.swift index 87b89a19..b8d8e85c 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/AckCodeBuilderTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/AckCodeBuilderTests.swift @@ -1,65 +1,64 @@ import CryptoKit import Foundation -import Testing @testable import MC1Services +import Testing @Suite("AckCodeBuilder.expectedAck") struct AckCodeBuilderTests { + /// Golden vector reproduced in-test from the firmware formula: + /// sha256( LE32(timestamp) || byte(attempt & 0x03) || text || pubkey ) + /// and take bytes [0..3]. + @Test + func `matches firmware formula for known fixture`() { + let timestamp: UInt32 = 0x6624_AABB + let attempt: UInt8 = 2 + let text = "hello" + let pubkey = Data(repeating: 0xAA, count: 32) - // Golden vector reproduced in-test from the firmware formula: - // sha256( LE32(timestamp) || byte(attempt & 0x03) || text || pubkey ) - // and take bytes [0..3]. - @Test("matches firmware formula for known fixture") - func goldenVector() { - let timestamp: UInt32 = 0x6624_AABB - let attempt: UInt8 = 2 - let text = "hello" - let pubkey = Data(repeating: 0xAA, count: 32) - - let code = AckCodeBuilder.expectedAck( - timestamp: timestamp, - attempt: attempt, - text: text, - senderPublicKey: pubkey - ) + let code = AckCodeBuilder.expectedAck( + timestamp: timestamp, + attempt: attempt, + text: text, + senderPublicKey: pubkey + ) - var input = Data() - var ts = timestamp.littleEndian - withUnsafeBytes(of: &ts) { input.append(contentsOf: $0) } - input.append(attempt & 0x03) - input.append(contentsOf: text.utf8) - input.append(pubkey) - let expected = Data(SHA256.hash(data: input).prefix(4)) + var input = Data() + var ts = timestamp.littleEndian + withUnsafeBytes(of: &ts) { input.append(contentsOf: $0) } + input.append(attempt & 0x03) + input.append(contentsOf: text.utf8) + input.append(pubkey) + let expected = Data(SHA256.hash(data: input).prefix(4)) - #expect(code == expected) - } + #expect(code == expected) + } - @Test("different texts produce different codes") - func differentTextDifferentCode() { - let pubkey = Data(repeating: 0x01, count: 32) - let a = AckCodeBuilder.expectedAck(timestamp: 1, attempt: 0, text: "hi", senderPublicKey: pubkey) - let b = AckCodeBuilder.expectedAck(timestamp: 1, attempt: 0, text: "bye", senderPublicKey: pubkey) - #expect(a != b) - } + @Test + func `different texts produce different codes`() { + let pubkey = Data(repeating: 0x01, count: 32) + let a = AckCodeBuilder.expectedAck(timestamp: 1, attempt: 0, text: "hi", senderPublicKey: pubkey) + let b = AckCodeBuilder.expectedAck(timestamp: 1, attempt: 0, text: "bye", senderPublicKey: pubkey) + #expect(a != b) + } - @Test("attempts 0..3 produce four distinct codes") - func directAttemptsDistinct() { - let pubkey = Data(repeating: 0x02, count: 32) - let codes = (0..<4).map { - AckCodeBuilder.expectedAck(timestamp: 100, attempt: UInt8($0), text: "hi", senderPublicKey: pubkey) - } - #expect(Set(codes).count == 4) + @Test + func `attempts 0..3 produce four distinct codes`() { + let pubkey = Data(repeating: 0x02, count: 32) + let codes = (0..<4).map { + AckCodeBuilder.expectedAck(timestamp: 100, attempt: UInt8($0), text: "hi", senderPublicKey: pubkey) } + #expect(Set(codes).count == 4) + } - // The firmware masks the attempt index with & 0x03, so the flood attempt - // (index 4) intentionally reuses attempt 0's code. This is safe: a single - // message accumulates its codes in a Set, so the wrap is a no-op re-add and - // any returned ACK still matches the right message. - @Test("attempt 4 wraps to attempt 0's code") - func floodAttemptWrapsToAttemptZero() { - let pubkey = Data(repeating: 0x03, count: 32) - let attempt0 = AckCodeBuilder.expectedAck(timestamp: 100, attempt: 0, text: "hi", senderPublicKey: pubkey) - let attempt4 = AckCodeBuilder.expectedAck(timestamp: 100, attempt: 4, text: "hi", senderPublicKey: pubkey) - #expect(attempt0 == attempt4) - } + /// The firmware masks the attempt index with & 0x03, so the flood attempt + /// (index 4) intentionally reuses attempt 0's code. This is safe: a single + /// message accumulates its codes in a Set, so the wrap is a no-op re-add and + /// any returned ACK still matches the right message. + @Test + func `attempt 4 wraps to attempt 0's code`() { + let pubkey = Data(repeating: 0x03, count: 32) + let attempt0 = AckCodeBuilder.expectedAck(timestamp: 100, attempt: 0, text: "hi", senderPublicKey: pubkey) + let attempt4 = AckCodeBuilder.expectedAck(timestamp: 100, attempt: 4, text: "hi", senderPublicKey: pubkey) + #expect(attempt0 == attempt4) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/BLETransportOpenedSignalTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/BLETransportOpenedSignalTests.swift index 9483be0a..6cfc91c4 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/BLETransportOpenedSignalTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/BLETransportOpenedSignalTests.swift @@ -1,85 +1,71 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("BLETransportOpenedSignal") struct BLETransportOpenedSignalTests { + @Test + func `wait returns immediately when signal already armed`() async throws { + let triggers = BLETransportOpenedSignal() + await triggers.fire() + try await triggers.wait() + } - @Test("wait returns immediately when signal already armed") - func waitReturnsImmediately() async throws { - let triggers = BLETransportOpenedSignal() - await triggers.fire() - try await triggers.wait() + @Test + func `wait suspends until fire`() async throws { + let triggers = BLETransportOpenedSignal() + let waiting = Task { + try await triggers.wait() } + try? await Task.sleep(for: .milliseconds(50)) + await triggers.fire() + try await waiting.value + } - @Test("wait suspends until fire") - func waitSuspendsUntilFire() async throws { - let triggers = BLETransportOpenedSignal() - let waiting = Task { - try await triggers.wait() - } - try? await Task.sleep(for: .milliseconds(50)) - await triggers.fire() - try await waiting.value + /// A `wait` whose calling task is cancelled before fire lands must + /// throw `CancellationError` rather than leaking the continuation. + @Test + func `wait throws CancellationError when calling task is cancelled`() async { + let triggers = BLETransportOpenedSignal() + let task = Task { + try await triggers.wait() } + try? await Task.sleep(for: .milliseconds(50)) + task.cancel() - @Test("clear drops armed-pending signal") - func clearDropsArmed() async throws { - let triggers = BLETransportOpenedSignal() - await triggers.fire() - await triggers.clear() - let waiting = Task { - try await triggers.wait() - } - try? await Task.sleep(for: .milliseconds(20)) - await triggers.fire() - try await waiting.value + do { + try await task.value + Issue.record("wait should have thrown CancellationError") + } catch is CancellationError { + // expected + } catch { + Issue.record("wait threw unexpected error: \(error)") } + } - /// A `wait` whose calling task is cancelled before fire lands must - /// throw `CancellationError` rather than leaking the continuation. - @Test("wait throws CancellationError when calling task is cancelled") - func waitThrowsOnCancellation() async { - let triggers = BLETransportOpenedSignal() - let task = Task { - try await triggers.wait() - } - try? await Task.sleep(for: .milliseconds(50)) - task.cancel() - - do { - try await task.value - Issue.record("wait should have thrown CancellationError") - } catch is CancellationError { - // expected - } catch { - Issue.record("wait threw unexpected error: \(error)") - } + /// Regression: after a cancellation removes a waiter, a subsequent + /// `fire()` must not double-resume that continuation. The cancellation + /// path removes its own waiter before throwing, so `fire()` sees a + /// clean waiter list. The fired signal is preserved as armed for the + /// next caller (no waiter consumed it). + @Test + func `fire after cancellation does not double-resume`() async throws { + let triggers = BLETransportOpenedSignal() + let task = Task { + try await triggers.wait() } + try? await Task.sleep(for: .milliseconds(50)) + task.cancel() + _ = try? await task.value - /// Regression: after a cancellation removes a waiter, a subsequent - /// `fire()` must not double-resume that continuation. The cancellation - /// path removes its own waiter before throwing, so `fire()` sees a - /// clean waiter list. The fired signal is preserved as armed for the - /// next caller (no waiter consumed it). - @Test("fire after cancellation does not double-resume") - func fireAfterCancellationDoesNotDoubleResume() async throws { - let triggers = BLETransportOpenedSignal() - let task = Task { - try await triggers.wait() - } - try? await Task.sleep(for: .milliseconds(50)) - task.cancel() - _ = try? await task.value + // If `fire` tried to resume the already-cancelled continuation, + // `withCheckedThrowingContinuation` would trap (precondition: resume + // exactly once). Reaching the next assertion at all proves no + // double-resume happened. + await triggers.fire() - // If `fire` tried to resume the already-cancelled continuation, - // `withCheckedThrowingContinuation` would trap (precondition: resume - // exactly once). Reaching the next assertion at all proves no - // double-resume happened. - await triggers.fire() - - // Signal should be armed for the next caller since no waiter - // consumed it. - try await triggers.wait() - } + // Signal should be armed for the next caller since no waiter + // consumed it. + try await triggers.wait() + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChannelServicePipelineTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChannelServicePipelineTests.swift index 3ed70c79..5a87c5cc 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChannelServicePipelineTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChannelServicePipelineTests.swift @@ -1,338 +1,337 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing /// Service-layer validation gates for the pipelined channel sync path (real session over a /// MockTransport): #1 correctness with gaps, #2 mid-burst disconnect (throw and partial-drain /// sub-cases), #4 drop-reconcile, plus serial-path parity. @Suite("ChannelService pipelined sync") struct ChannelServicePipelineTests { + // MARK: - Gate #1: correctness with gaps + + @Test + func `pipelined sync persists non-contiguous configured channels to their own slots`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 8, usePipelinedRead: true) + } - // MARK: - Gate #1: correctness with gaps - - @Test("pipelined sync persists non-contiguous configured channels to their own slots") - func pipelinedSyncPersistsGapsToCorrectSlots() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 8, usePipelinedRead: true) - } - - try await waitUntil("all eight channel reads should be primed") { - await transport.sentData.count == 9 // appStart + 8 reads - } - - let configuredIndices: Set = [0, 2, 7] - for index in UInt8(0)..<8 { - if configuredIndices.contains(index) { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) - ) - } else { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "", secret: Data(repeating: 0, count: 16)) - ) - } - } - - let result = try await syncTask.value - #expect(result.channelsSynced == 3) - - let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(stored.map(\.index) == [0, 2, 7]) - #expect(stored.first { $0.index == 0 }?.name == "ch0") - #expect(stored.first { $0.index == 2 }?.name == "ch2") - #expect(stored.first { $0.index == 7 }?.name == "ch7") + try await waitUntil("all eight channel reads should be primed") { + await transport.sentData.count == 9 // appStart + 8 reads } - // MARK: - Gate #4: drop-reconcile - - @Test("a dropped write is reconciled with a serial read and persisted") - func droppedWriteIsReconciledAndPersisted() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) - } - - try await waitUntil("all four reads should be primed") { - await transport.sentData.count == 5 - } - - // Index 2 is never answered in the pipeline (simulated dropped Write Command). - for index: UInt8 in [0, 1, 3] { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) - ) - } - - // After the idle timeout, the service reconciles index 2 with a serial read. - try await waitUntil("reconcile should issue a serial read for the dropped index") { - await transport.sentData.count == 6 - } + let configuredIndices: Set = [0, 2, 7] + for index in UInt8(0)..<8 { + if configuredIndices.contains(index) { await transport.simulateReceive( - makeChannelInfoPacket(index: 2, name: "ch2", secret: Data(repeating: 0xAB, count: 16)) + makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) ) + } else { + await transport.simulateReceive( + makeChannelInfoPacket(index: index, name: "", secret: Data(repeating: 0, count: 16)) + ) + } + } - let result = try await syncTask.value - #expect(result.channelsSynced == 4) + let result = try await syncTask.value + #expect(result.channelsSynced == 3) + + let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(stored.map(\.index) == [0, 2, 7]) + #expect(stored.first { $0.index == 0 }?.name == "ch0") + #expect(stored.first { $0.index == 2 }?.name == "ch2") + #expect(stored.first { $0.index == 7 }?.name == "ch7") + } + + // MARK: - Gate #4: drop-reconcile + + @Test + func `a dropped write is reconciled with a serial read and persisted`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) + } + + try await waitUntil("all four reads should be primed") { + await transport.sentData.count == 5 + } - let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(stored.map(\.index) == [0, 1, 2, 3]) - #expect(stored.first { $0.index == 2 }?.name == "ch2") + // Index 2 is never answered in the pipeline (simulated dropped Write Command). + for index: UInt8 in [0, 1, 3] { + await transport.simulateReceive( + makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) + ) } - // MARK: - Gate #2a: transport throws mid-pipeline → nothing persisted + // After the idle timeout, the service reconciles index 2 with a serial read. + try await waitUntil("reconcile should issue a serial read for the dropped index") { + await transport.sentData.count == 6 + } + await transport.simulateReceive( + makeChannelInfoPacket(index: 2, name: "ch2", secret: Data(repeating: 0xAB, count: 16)) + ) + + let result = try await syncTask.value + #expect(result.channelsSynced == 4) - @Test("a transport send failure mid-pipeline throws and persists nothing") - func transportThrowMidPipelinePersistsNothing() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(stored.map(\.index) == [0, 1, 2, 3]) + #expect(stored.first { $0.index == 2 }?.name == "ch2") + } - // appStart was send #1; fail every send from #2 (the first channel read) onward. - await transport.failSends(fromSendIndex: 2) + // MARK: - Gate #2a: transport throws mid-pipeline → nothing persisted - await #expect(throws: Error.self) { - _ = try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) - } + @Test + func `a transport send failure mid-pipeline throws and persists nothing`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - let stored = try await dataStore.fetchChannels(radioID: radioID) - #expect(stored.isEmpty, "a hard send failure must not persist a partial channel set") + // appStart was send #1; fail every send from #2 (the first channel read) onward. + await transport.failSends(fromSendIndex: 2) + + await #expect(throws: Error.self) { + _ = try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) } - // MARK: - Gate #2b: partial drain → no mis-index, unread configured slot survives + let stored = try await dataStore.fetchChannels(radioID: radioID) + #expect(stored.isEmpty, "a hard send failure must not persist a partial channel set") + } - @Test("a mid-drain stall persists only read slots and never deletes an unread configured slot") - func midDrainStallKeepsUnreadConfiguredSlot() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) + // MARK: - Gate #2b: partial drain → no mis-index, unread configured slot survives - // Pre-seed a configured channel at index 2 that the upcoming sync cannot read. - _ = try await dataStore.batchSaveChannels( - radioID: radioID, - configured: [ChannelInfo(index: 2, name: "keep", secret: Data(repeating: 0xCD, count: 16))], - unconfiguredIndices: [], - pruneBeyond: nil - ) + @Test + func `a mid-drain stall persists only read slots and never deletes an unread configured slot`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) + + // Pre-seed a configured channel at index 2 that the upcoming sync cannot read. + _ = try await dataStore.batchSaveChannels( + radioID: radioID, + configured: [ChannelInfo(index: 2, name: "keep", secret: Data(repeating: 0xCD, count: 16))], + unconfiguredIndices: [], + pruneBeyond: nil + ) + + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) - } - - try await waitUntil("all four reads should be primed") { - await transport.sentData.count == 5 - } - - // The reconcile read for the dropped index (send #6, after appStart + 4 primed reads) - // fails as a transport drop, so index 2 cannot be re-read and stays in neither list. - await transport.failSends(fromSendIndex: 6) - - // Answer 0, 1, 3 but never 2. - for index: UInt8 in [0, 1, 3] { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) - ) - } - - let result = try await syncTask.value - #expect(result.channelsSynced == 3) - - let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(stored.map(\.index) == [0, 1, 2, 3], "no mis-indexed rows; the unread index 2 is not deleted") - #expect(stored.first { $0.index == 2 }?.name == "keep", "the unread configured slot is preserved verbatim") + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) } - // MARK: - Gate #4b: reconcile circuit breaker - - @Test("reconcile circuit breaker opens after consecutive failures and never deletes unread slots") - func reconcileCircuitBreakerStopsAndPreservesUnreadSlots() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - // Pre-seed configured rows at every index the pipeline will fail to read. Indices 2-4 - // each fail their reconcile read (transport drop); indices 5-7 are skipped once the - // breaker opens at threshold 3. All six land in neither the configured nor the - // unconfigured list, so the data-loss guard must leave their rows intact. - let preseeded: [ChannelInfo] = (UInt8(2)...UInt8(7)).map { - ChannelInfo(index: $0, name: "keep\($0)", secret: Data(repeating: 0xCD, count: 16)) - } - _ = try await dataStore.batchSaveChannels( - radioID: radioID, - configured: preseeded, - unconfiguredIndices: [], - pruneBeyond: nil - ) + try await waitUntil("all four reads should be primed") { + await transport.sentData.count == 5 + } + + // The reconcile read for the dropped index (send #6, after appStart + 4 primed reads) + // fails as a transport drop, so index 2 cannot be re-read and stays in neither list. + await transport.failSends(fromSendIndex: 6) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 8, usePipelinedRead: true) - } - - try await waitUntil("all eight reads should be primed") { - await transport.sentData.count == 9 // appStart + 8 primed reads - } - - // Every reconcile read (send #10 onward) fails as a transport drop. A dropped send - // surfaces as a transportError, which counts toward the breaker and is not retried, so - // indices 2-4 each register one consecutive failure and indices 5-7 are skipped. - await transport.failSends(fromSendIndex: 10) - - // Answer only 0 and 1; leave 2-7 unanswered so they become the missing set. - for index: UInt8 in [0, 1] { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) - ) - } - - let result = try await syncTask.value - - #expect(result.channelsSynced == 2) - #expect(result.circuitBreakerAborted) - - let transportErrorIndices = result.errors - .filter { $0.errorType == .transportError } - .map(\.index) - .sorted() - let circuitBreakerIndices = result.errors - .filter { $0.errorType == .circuitBreaker } - .map(\.index) - .sorted() - #expect(transportErrorIndices == [2, 3, 4], "the first three missing indices each fail their reconcile read") - #expect(circuitBreakerIndices == [5, 6, 7], "the open breaker skips the remaining missing indices") - - let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(stored.map(\.index) == [0, 1, 2, 3, 4, 5, 6, 7], "no unread slot is deleted") - for index: UInt8 in 2...7 { - #expect( - stored.first { $0.index == index }?.name == "keep\(index)", - "an unread slot (reconcile-failed or breaker-skipped) is preserved verbatim" - ) - } - #expect(stored.first { $0.index == 0 }?.name == "ch0") + // Answer 0, 1, 3 but never 2. + for index: UInt8 in [0, 1, 3] { + await transport.simulateReceive( + makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) + ) } - // MARK: - Parity: serial path unchanged + let result = try await syncTask.value + #expect(result.channelsSynced == 3) - @Test("serial path (usePipelinedRead: false) still syncs via acknowledged reads") - func serialPathStillSyncs() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 2) - let transport = MockTransport() // capability defaults to false - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(stored.map(\.index) == [0, 1, 2, 3], "no mis-indexed rows; the unread index 2 is not deleted") + #expect(stored.first { $0.index == 2 }?.name == "keep", "the unread configured slot is preserved verbatim") + } - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 2, usePipelinedRead: false) - } + // MARK: - Gate #4b: reconcile circuit breaker - try await waitUntil("first serial read should be sent") { - await transport.sentData.count == 2 - } - await transport.simulateReceive( - makeChannelInfoPacket(index: 0, name: "ch0", secret: Data(repeating: 0xAB, count: 16)) - ) - try await waitUntil("second serial read should be sent after the first responds") { - await transport.sentData.count == 3 - } - await transport.simulateReceive( - makeChannelInfoPacket(index: 1, name: "", secret: Data(repeating: 0, count: 16)) - ) + @Test + func `reconcile circuit breaker opens after consecutive failures and never deletes unread slots`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + // Pre-seed configured rows at every index the pipeline will fail to read. Indices 2-4 + // each fail their reconcile read (transport drop); indices 5-7 are skipped once the + // breaker opens at threshold 3. All six land in neither the configured nor the + // unconfigured list, so the data-loss guard must leave their rows intact. + let preseeded: [ChannelInfo] = (UInt8(2)...UInt8(7)).map { + ChannelInfo(index: $0, name: "keep\($0)", secret: Data(repeating: 0xCD, count: 16)) + } + _ = try await dataStore.batchSaveChannels( + radioID: radioID, + configured: preseeded, + unconfiguredIndices: [], + pruneBeyond: nil + ) + + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 8, usePipelinedRead: true) + } + + try await waitUntil("all eight reads should be primed") { + await transport.sentData.count == 9 // appStart + 8 primed reads + } + + // Every reconcile read (send #10 onward) fails as a transport drop. A dropped send + // surfaces as a transportError, which counts toward the breaker and is not retried, so + // indices 2-4 each register one consecutive failure and indices 5-7 are skipped. + await transport.failSends(fromSendIndex: 10) + + // Answer only 0 and 1; leave 2-7 unanswered so they become the missing set. + for index: UInt8 in [0, 1] { + await transport.simulateReceive( + makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) + ) + } - let result = try await syncTask.value - #expect(result.channelsSynced == 1) + let result = try await syncTask.value + + #expect(result.channelsSynced == 2) + #expect(result.circuitBreakerAborted) + + let transportErrorIndices = result.errors + .filter { $0.errorType == .transportError } + .map(\.index) + .sorted() + let circuitBreakerIndices = result.errors + .filter { $0.errorType == .circuitBreaker } + .map(\.index) + .sorted() + #expect(transportErrorIndices == [2, 3, 4], "the first three missing indices each fail their reconcile read") + #expect(circuitBreakerIndices == [5, 6, 7], "the open breaker skips the remaining missing indices") + + let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(stored.map(\.index) == [0, 1, 2, 3, 4, 5, 6, 7], "no unread slot is deleted") + for index: UInt8 in 2...7 { + #expect( + stored.first { $0.index == index }?.name == "keep\(index)", + "an unread slot (reconcile-failed or breaker-skipped) is preserved verbatim" + ) + } + #expect(stored.first { $0.index == 0 }?.name == "ch0") + } + + // MARK: - Parity: serial path unchanged + + @Test + func `serial path (usePipelinedRead: false) still syncs via acknowledged reads`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 2) + let transport = MockTransport() // capability defaults to false + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 2, usePipelinedRead: false) + } - let stored = try await dataStore.fetchChannels(radioID: radioID) - #expect(stored.map(\.index) == [0]) + try await waitUntil("first serial read should be sent") { + await transport.sentData.count == 2 } + await transport.simulateReceive( + makeChannelInfoPacket(index: 0, name: "ch0", secret: Data(repeating: 0xAB, count: 16)) + ) + try await waitUntil("second serial read should be sent after the first responds") { + await transport.sentData.count == 3 + } + await transport.simulateReceive( + makeChannelInfoPacket(index: 1, name: "", secret: Data(repeating: 0, count: 16)) + ) + + let result = try await syncTask.value + #expect(result.channelsSynced == 1) + + let stored = try await dataStore.fetchChannels(radioID: radioID) + #expect(stored.map(\.index) == [0]) + } } // MARK: - Helpers private func startedSession(_ transport: MockTransport) async throws -> MeshCoreSession { - // A generous defaultTimeout keeps appStart and acknowledged reads from flaking under - // heavy parallel test load; no test here relies on a getChannel actually timing out, so - // it never slows the suite. The pipeline idle timeout stays small but above the gap - // between back-to-back simulated responses so it never trips mid-stream. - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration( - defaultTimeout: 5.0, - clientIdentifier: "MCTst", - channelPipelineWindow: 8, - channelPipelineIdleTimeout: 0.5, - channelPipelineHardTimeout: 10.0, - channelPipelinePostDrainGrace: 0.05 - ) + // A generous defaultTimeout keeps appStart and acknowledged reads from flaking under + // heavy parallel test load; no test here relies on a getChannel actually timing out, so + // it never slows the suite. The pipeline idle timeout stays small but above the gap + // between back-to-back simulated responses so it never trips mid-stream. + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration( + defaultTimeout: 5.0, + clientIdentifier: "MCTst", + channelPipelineWindow: 8, + channelPipelineIdleTimeout: 0.5, + channelPipelineHardTimeout: 10.0, + channelPipelinePostDrainGrace: 0.05 ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - return session + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + return session } private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) - payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(withUnsafeBytes(of: UInt32(915_000).littleEndian) { Data($0) }) - payload.append(withUnsafeBytes(of: UInt32(125_000).littleEndian) { Data($0) }) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) + payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(withUnsafeBytes(of: UInt32(915_000).littleEndian) { Data($0) }) + payload.append(withUnsafeBytes(of: UInt32(125_000).littleEndian) { Data($0) }) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet } private func makeChannelInfoPacket(index: UInt8, name: String, secret: Data) -> Data { - var packet = Data([ResponseCode.channelInfo.rawValue, index]) - let nameBytes = Array(name.utf8.prefix(31)) - packet.append(contentsOf: nameBytes) - packet.append(0) - if nameBytes.count < 31 { - packet.append(Data(repeating: 0, count: 31 - nameBytes.count)) - } - packet.append(secret) - return packet + var packet = Data([ResponseCode.channelInfo.rawValue, index]) + let nameBytes = Array(name.utf8.prefix(31)) + packet.append(contentsOf: nameBytes) + packet.append(0) + if nameBytes.count < 31 { + packet.append(Data(repeating: 0, count: 31 - nameBytes.count)) + } + packet.append(secret) + return packet } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChannelServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChannelServiceTests.swift index fce50c82..63748294 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChannelServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChannelServiceTests.swift @@ -1,203 +1,204 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("ChannelService Tests") struct ChannelServiceTests { - - // MARK: - Secret Hashing Tests - - @Test("hashSecret produces 16-byte output") - func hashSecretProduces16Bytes() { - let secret = ChannelService.hashSecret("test passphrase") - #expect(secret.count == ProtocolLimits.channelSecretSize) - } - - @Test("hashSecret is deterministic") - func hashSecretIsDeterministic() { - let secret1 = ChannelService.hashSecret("same passphrase") - let secret2 = ChannelService.hashSecret("same passphrase") - #expect(secret1 == secret2) - } - - @Test("hashSecret differs for different inputs") - func hashSecretDiffersForDifferentInputs() { - let secret1 = ChannelService.hashSecret("passphrase one") - let secret2 = ChannelService.hashSecret("passphrase two") - #expect(secret1 != secret2) - } - - @Test("hashSecret handles empty string") - func hashSecretHandlesEmptyString() { - let secret = ChannelService.hashSecret("") - #expect(secret.count == ProtocolLimits.channelSecretSize) - #expect(secret == Data(repeating: 0, count: ProtocolLimits.channelSecretSize)) - } - - @Test("hashSecret handles unicode") - func hashSecretHandlesUnicode() { - let secret = ChannelService.hashSecret("🔐 secure 密码") - #expect(secret.count == ProtocolLimits.channelSecretSize) - } - - @Test("validateSecret accepts 16-byte secrets") - func validateSecretAccepts16Bytes() { - let validSecret = Data(repeating: 0xAB, count: ProtocolLimits.channelSecretSize) - #expect(ChannelService.validateSecret(validSecret)) - } - - @Test("validateSecret rejects wrong-sized secrets") - func validateSecretRejectsWrongSize() { - let tooShort = Data(repeating: 0xAB, count: 15) - let tooLong = Data(repeating: 0xAB, count: 17) - #expect(!ChannelService.validateSecret(tooShort)) - #expect(!ChannelService.validateSecret(tooLong)) - } - - // MARK: - ChannelSyncError Tests - - @Test("ChannelSyncError timeout is retryable") - func timeoutErrorIsRetryable() { - let error = ChannelSyncError(index: 0, errorType: .timeout, description: "Timeout") - #expect(error.isRetryable) - } - - @Test("ChannelSyncError send timeout is retryable and counted separately") - func sendTimeoutErrorIsRetryableAndCountedSeparately() { - let error = ChannelSyncError(index: 0, errorType: .sendTimeout, description: "Send timed out") - let result = ChannelSyncResult(channelsSynced: 0, errors: [error]) - - #expect(error.isRetryable) - #expect(result.requestTimeoutCount == 0) - #expect(result.sendTimeoutCount == 1) - } - - @Test("ChannelSyncError circuit breaker is not retryable") - func circuitBreakerErrorIsNotRetryable() { - let error = ChannelSyncError(index: 0, errorType: .circuitBreaker, description: "Circuit open") - #expect(!error.isRetryable) - } - - @Test("ChannelSyncError deviceError is not retryable") - func deviceErrorIsNotRetryable() { - let error = ChannelSyncError(index: 0, errorType: .deviceError(code: 0x02), description: "Not found") - #expect(!error.isRetryable) - } - - @Test("ChannelSyncError databaseError is not retryable") - func databaseErrorIsNotRetryable() { - let error = ChannelSyncError(index: 0, errorType: .databaseError, description: "Save failed") - #expect(!error.isRetryable) - } - - @Test("ChannelSyncError unknown is not retryable") - func unknownErrorIsNotRetryable() { - let error = ChannelSyncError(index: 0, errorType: .unknown, description: "Unknown error") - #expect(!error.isRetryable) - } - - // MARK: - ChannelSyncResult Tests - - @Test("ChannelSyncResult isComplete when no errors") - func syncResultIsCompleteWithNoErrors() { - let result = ChannelSyncResult(channelsSynced: 8, errors: []) - #expect(result.isComplete) - } - - @Test("ChannelSyncResult is not complete with errors") - func syncResultIsNotCompleteWithErrors() { - let error = ChannelSyncError(index: 3, errorType: .timeout, description: "Timeout") - let result = ChannelSyncResult(channelsSynced: 7, errors: [error]) - #expect(!result.isComplete) - } - - @Test("ChannelSyncResult retryableIndices filters correctly") - func syncResultRetryableIndicesFiltersCorrectly() { - let errors = [ - ChannelSyncError(index: 1, errorType: .timeout, description: "Timeout"), - ChannelSyncError(index: 2, errorType: .deviceError(code: 0x02), description: "Not found"), - ChannelSyncError(index: 5, errorType: .timeout, description: "Timeout"), - ] - let result = ChannelSyncResult(channelsSynced: 5, errors: errors) - - #expect(result.retryableIndices == [1, 5]) - } - - @Test("ChannelService aborts early when transport send timeouts cascade") - func syncChannelsAbortsEarlyForSendTimeoutCascade() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 6) - let transport = SendTimeoutTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 0.01, clientIdentifier: "MCTst") - ) - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let result = try await service.syncChannels(radioID: radioID, maxChannels: 6) - - #expect(result.sendTimeoutCount == 3) - #expect(result.circuitBreakerAborted) - #expect(await transport.sendCount == 3) - } - - @Test("ChannelSyncResult retryableIndices empty when no retryable errors") - func syncResultRetryableIndicesEmptyWhenNoRetryable() { - let errors = [ - ChannelSyncError(index: 2, errorType: .deviceError(code: 0x02), description: "Not found"), - ChannelSyncError(index: 3, errorType: .databaseError, description: "Save failed"), - ] - let result = ChannelSyncResult(channelsSynced: 6, errors: errors) - - #expect(result.retryableIndices.isEmpty) - } - - @Test("isChannelConfigured returns true for empty name with non-zero secret") - func isChannelConfiguredEmptyNameNonZeroSecret() { - let isConfigured = ChannelService.isChannelConfigured( - name: "", - secret: Data(repeating: 0x42, count: ProtocolLimits.channelSecretSize) - ) - #expect(isConfigured) - } - - @Test("isChannelConfigured returns false for empty name with zero secret") - func isChannelConfiguredEmptyNameZeroSecret() { - let isConfigured = ChannelService.isChannelConfigured( - name: "", - secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) - ) - #expect(!isConfigured) - } - - @Test("isChannelConfigured returns true for named zero-secret channel") - func isChannelConfiguredNamedZeroSecret() { - let isConfigured = ChannelService.isChannelConfigured( - name: "Public", - secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) - ) - #expect(isConfigured) - } + // MARK: - Secret Hashing Tests + + @Test + func `hashSecret produces 16-byte output`() { + let secret = ChannelService.hashSecret("test passphrase") + #expect(secret.count == ProtocolLimits.channelSecretSize) + } + + @Test + func `hashSecret is deterministic`() { + let secret1 = ChannelService.hashSecret("same passphrase") + let secret2 = ChannelService.hashSecret("same passphrase") + #expect(secret1 == secret2) + } + + @Test + func `hashSecret differs for different inputs`() { + let secret1 = ChannelService.hashSecret("passphrase one") + let secret2 = ChannelService.hashSecret("passphrase two") + #expect(secret1 != secret2) + } + + @Test + func `hashSecret handles empty string`() { + let secret = ChannelService.hashSecret("") + #expect(secret.count == ProtocolLimits.channelSecretSize) + #expect(secret == Data(repeating: 0, count: ProtocolLimits.channelSecretSize)) + } + + @Test + func `hashSecret handles unicode`() { + let secret = ChannelService.hashSecret("🔐 secure 密码") + #expect(secret.count == ProtocolLimits.channelSecretSize) + } + + @Test + func `validateSecret accepts 16-byte secrets`() { + let validSecret = Data(repeating: 0xAB, count: ProtocolLimits.channelSecretSize) + #expect(ChannelService.validateSecret(validSecret)) + } + + @Test + func `validateSecret rejects wrong-sized secrets`() { + let tooShort = Data(repeating: 0xAB, count: 15) + let tooLong = Data(repeating: 0xAB, count: 17) + #expect(!ChannelService.validateSecret(tooShort)) + #expect(!ChannelService.validateSecret(tooLong)) + } + + // MARK: - ChannelSyncError Tests + + @Test + func `ChannelSyncError timeout is retryable`() { + let error = ChannelSyncError(index: 0, errorType: .timeout, description: "Timeout") + #expect(error.isRetryable) + } + + @Test + func `ChannelSyncError send timeout is retryable and counted separately`() { + let error = ChannelSyncError(index: 0, errorType: .sendTimeout, description: "Send timed out") + let result = ChannelSyncResult(channelsSynced: 0, errors: [error]) + + #expect(error.isRetryable) + #expect(result.requestTimeoutCount == 0) + #expect(result.sendTimeoutCount == 1) + } + + @Test + func `ChannelSyncError circuit breaker is not retryable`() { + let error = ChannelSyncError(index: 0, errorType: .circuitBreaker, description: "Circuit open") + #expect(!error.isRetryable) + } + + @Test + func `ChannelSyncError deviceError is not retryable`() { + let error = ChannelSyncError(index: 0, errorType: .deviceError(code: 0x02), description: "Not found") + #expect(!error.isRetryable) + } + + @Test + func `ChannelSyncError databaseError is not retryable`() { + let error = ChannelSyncError(index: 0, errorType: .databaseError, description: "Save failed") + #expect(!error.isRetryable) + } + + @Test + func `ChannelSyncError unknown is not retryable`() { + let error = ChannelSyncError(index: 0, errorType: .unknown, description: "Unknown error") + #expect(!error.isRetryable) + } + + // MARK: - ChannelSyncResult Tests + + @Test + func `ChannelSyncResult isComplete when no errors`() { + let result = ChannelSyncResult(channelsSynced: 8, errors: []) + #expect(result.isComplete) + } + + @Test + func `ChannelSyncResult is not complete with errors`() { + let error = ChannelSyncError(index: 3, errorType: .timeout, description: "Timeout") + let result = ChannelSyncResult(channelsSynced: 7, errors: [error]) + #expect(!result.isComplete) + } + + @Test + func `ChannelSyncResult retryableIndices filters correctly`() { + let errors = [ + ChannelSyncError(index: 1, errorType: .timeout, description: "Timeout"), + ChannelSyncError(index: 2, errorType: .deviceError(code: 0x02), description: "Not found"), + ChannelSyncError(index: 5, errorType: .timeout, description: "Timeout"), + ] + let result = ChannelSyncResult(channelsSynced: 5, errors: errors) + + #expect(result.retryableIndices == [1, 5]) + } + + @Test + func `ChannelService aborts early when transport send timeouts cascade`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 6) + let transport = SendTimeoutTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.01, clientIdentifier: "MCTst") + ) + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let result = try await service.syncChannels(radioID: radioID, maxChannels: 6) + + #expect(result.sendTimeoutCount == 3) + #expect(result.circuitBreakerAborted) + #expect(await transport.sendCount == 3) + } + + @Test + func `ChannelSyncResult retryableIndices empty when no retryable errors`() { + let errors = [ + ChannelSyncError(index: 2, errorType: .deviceError(code: 0x02), description: "Not found"), + ChannelSyncError(index: 3, errorType: .databaseError, description: "Save failed"), + ] + let result = ChannelSyncResult(channelsSynced: 6, errors: errors) + + #expect(result.retryableIndices.isEmpty) + } + + @Test + func `isChannelConfigured returns true for empty name with non-zero secret`() { + let isConfigured = ChannelService.isChannelConfigured( + name: "", + secret: Data(repeating: 0x42, count: ProtocolLimits.channelSecretSize) + ) + #expect(isConfigured) + } + + @Test + func `isChannelConfigured returns false for empty name with zero secret`() { + let isConfigured = ChannelService.isChannelConfigured( + name: "", + secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) + ) + #expect(!isConfigured) + } + + @Test + func `isChannelConfigured returns true for named zero-secret channel`() { + let isConfigured = ChannelService.isChannelConfigured( + name: "Public", + secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) + ) + #expect(isConfigured) + } } private actor SendTimeoutTransport: MeshTransport { - private(set) var sendCount = 0 + private(set) var sendCount = 0 - var receivedData: AsyncStream { - AsyncStream { continuation in - continuation.finish() - } + var receivedData: AsyncStream { + AsyncStream { continuation in + continuation.finish() } + } - var isConnected: Bool { true } + var isConnected: Bool { + true + } - func connect() async throws {} + func connect() async throws {} - func disconnect() async {} + func disconnect() async {} - func send(_ data: Data) async throws { - sendCount += 1 - throw WiFiTransportError.sendTimeout - } + func send(_ data: Data) async throws { + sendCount += 1 + throw WiFiTransportError.sendTimeout + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift index 12641717..25f0ae55 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift @@ -1,56 +1,55 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ChatCoordinatorRegistry Offline") @MainActor struct ChatCoordinatorRegistryOfflineTests { - - @Test func coordinator_againstOfflineStore_returnsCoordinatorBoundToStore() async throws { - let radioID = UUID() - let contactID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(id: contactID, radioID: radioID) - try await store.saveContact(contact) - let message = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contactID, - text: "hello", - status: .delivered - ) - try await store.saveMessage(message) - - let registry = ChatCoordinatorRegistry(dataStore: store) - let coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactID)) - let messages = try await store.fetchMessages(contactID: contactID) - - #expect(messages.count == 1) - #expect(messages.first?.text == "hello") - #expect(coordinator.dataStore === store) - } - - @Test func rebind_servicesArrives_replacesCoordinatorAgainstNewStore() async throws { - let radioID = UUID() - let contactID = UUID() - let offlineStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - try await offlineStore.saveContact(ContactDTO.testContact(id: contactID, radioID: radioID)) - try await offlineStore.saveMessage(MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contactID, - text: "offline", - status: .delivered - )) - - let registry = ChatCoordinatorRegistry(dataStore: offlineStore) - let id = ChatConversationID.dm(radioID: radioID, contactID: contactID) - let beforeRebind = registry.coordinator(for: id) - - let onlineContainer = try PersistenceStore.createContainer(inMemory: true) - let onlineStore = PersistenceStore(modelContainer: onlineContainer) - registry.rebind(dataStore: onlineStore) - - let afterRebind = registry.coordinator(for: id) - #expect(beforeRebind !== afterRebind, "rebind should tear down the offline coordinator") - #expect(afterRebind.dataStore === onlineStore, "fresh coordinator binds to new store") - } + @Test func `coordinator against offline store returns coordinator bound to store`() async throws { + let radioID = UUID() + let contactID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(id: contactID, radioID: radioID) + try await store.saveContact(contact) + let message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "hello", + status: .delivered + ) + try await store.saveMessage(message) + + let registry = ChatCoordinatorRegistry(dataStore: store) + let coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactID)) + let messages = try await store.fetchMessages(contactID: contactID) + + #expect(messages.count == 1) + #expect(messages.first?.text == "hello") + #expect(coordinator.dataStore === store) + } + + @Test func `rebind services arrives replaces coordinator against new store`() async throws { + let radioID = UUID() + let contactID = UUID() + let offlineStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + try await offlineStore.saveContact(ContactDTO.testContact(id: contactID, radioID: radioID)) + try await offlineStore.saveMessage(MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "offline", + status: .delivered + )) + + let registry = ChatCoordinatorRegistry(dataStore: offlineStore) + let id = ChatConversationID.dm(radioID: radioID, contactID: contactID) + let beforeRebind = registry.coordinator(for: id) + + let onlineContainer = try PersistenceStore.createContainer(inMemory: true) + let onlineStore = PersistenceStore(modelContainer: onlineContainer) + registry.rebind(dataStore: onlineStore) + + let afterRebind = registry.coordinator(for: id) + #expect(beforeRebind !== afterRebind, "rebind should tear down the offline coordinator") + #expect(afterRebind.dataStore === onlineStore, "fresh coordinator binds to new store") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift index 2c307414..6b6f89d8 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift @@ -1,92 +1,91 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ChatCoordinatorRegistry") @MainActor struct ChatCoordinatorRegistryTests { - - private func makeRegistry() throws -> ChatCoordinatorRegistry { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - return ChatCoordinatorRegistry(dataStore: dataStore) - } - - @Test("coordinator(for:) returns the same instance on repeat calls") - func coordinatorFor_returnsSameInstance() throws { - let registry = try makeRegistry() - let id = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) - - let first = registry.coordinator(for: id) - let second = registry.coordinator(for: id) - - #expect(first === second) - } - - @Test("Distinct conversation IDs yield distinct coordinators") - func distinctIDs_yieldDistinctCoordinators() throws { - let registry = try makeRegistry() - let radioID = UUID() - let dmID = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let channelID = ChatConversationID.channel(radioID: radioID, channelIndex: 0) - - let dm = registry.coordinator(for: dmID) - let channel = registry.coordinator(for: channelID) - - #expect(dm !== channel) - } - - @MainActor - @Test func rebind_withDifferentStore_clearsExistingCoordinators() async throws { - let containerA = try PersistenceStore.createContainer(inMemory: true) - let containerB = try PersistenceStore.createContainer(inMemory: true) - let storeA = PersistenceStore(modelContainer: containerA) - let storeB = PersistenceStore(modelContainer: containerB) - let registry = ChatCoordinatorRegistry(dataStore: storeA) - let id = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) - let first = registry.coordinator(for: id) - - registry.rebind(dataStore: storeB) - let second = registry.coordinator(for: id) - - #expect(first !== second) - #expect(registry.dataStore === storeB) - } - - @Test func coordinator_exceedingCap_evictsLeastRecentlyUsed() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: store, capacity: 2) - let radioID = UUID() - - let idA = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let idB = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let idC = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - - let firstA = registry.coordinator(for: idA) - _ = registry.coordinator(for: idB) - _ = registry.coordinator(for: idC) // evicts A - - let secondA = registry.coordinator(for: idA) - #expect(firstA !== secondA, "Evicted entry should be reconstructed") - } - - @Test func coordinator_touchingEntry_promotesItToMostRecentlyUsed() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: store, capacity: 2) - let radioID = UUID() - - let idA = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let idB = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let idC = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - - let firstA = registry.coordinator(for: idA) - _ = registry.coordinator(for: idB) - _ = registry.coordinator(for: idA) // touch — promotes A - _ = registry.coordinator(for: idC) // evicts B, not A - - let secondA = registry.coordinator(for: idA) - #expect(firstA === secondA, "Touched entry should survive eviction") - } + private func makeRegistry() throws -> ChatCoordinatorRegistry { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + return ChatCoordinatorRegistry(dataStore: dataStore) + } + + @Test + func `coordinator(for:) returns the same instance on repeat calls`() throws { + let registry = try makeRegistry() + let id = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) + + let first = registry.coordinator(for: id) + let second = registry.coordinator(for: id) + + #expect(first === second) + } + + @Test + func `Distinct conversation IDs yield distinct coordinators`() throws { + let registry = try makeRegistry() + let radioID = UUID() + let dmID = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let channelID = ChatConversationID.channel(radioID: radioID, channelIndex: 0) + + let dm = registry.coordinator(for: dmID) + let channel = registry.coordinator(for: channelID) + + #expect(dm !== channel) + } + + @MainActor + @Test func `rebind with different store clears existing coordinators`() throws { + let containerA = try PersistenceStore.createContainer(inMemory: true) + let containerB = try PersistenceStore.createContainer(inMemory: true) + let storeA = PersistenceStore(modelContainer: containerA) + let storeB = PersistenceStore(modelContainer: containerB) + let registry = ChatCoordinatorRegistry(dataStore: storeA) + let id = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) + let first = registry.coordinator(for: id) + + registry.rebind(dataStore: storeB) + let second = registry.coordinator(for: id) + + #expect(first !== second) + #expect(registry.dataStore === storeB) + } + + @Test func `coordinator exceeding cap evicts least recently used`() throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: store, capacity: 2) + let radioID = UUID() + + let idA = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let idB = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let idC = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + + let firstA = registry.coordinator(for: idA) + _ = registry.coordinator(for: idB) + _ = registry.coordinator(for: idC) // evicts A + + let secondA = registry.coordinator(for: idA) + #expect(firstA !== secondA, "Evicted entry should be reconstructed") + } + + @Test func `coordinator touching entry promotes it to most recently used`() throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: store, capacity: 2) + let radioID = UUID() + + let idA = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let idB = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let idC = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + + let firstA = registry.coordinator(for: idA) + _ = registry.coordinator(for: idB) + _ = registry.coordinator(for: idA) // touch — promotes A + _ = registry.coordinator(for: idC) // evicts B, not A + + let secondA = registry.coordinator(for: idA) + #expect(firstA === secondA, "Touched entry should survive eviction") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift index 6f920978..271e0e4b 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift @@ -1,427 +1,429 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ChatCoordinator") @MainActor struct ChatCoordinatorTests { - - @Test("append adds a new message and bumps renderStateID") - func append_addsNewMessage() { - let coordinator = ChatCoordinator.makeForTesting() - let radioID = UUID() - let contactID = UUID() - let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: contactID) - - let before = coordinator.renderStateID - let inserted = coordinator.append(message) - - #expect(inserted) - #expect(coordinator.messages.count == 1) - #expect(coordinator.messagesByID[message.id] == message) - #expect(coordinator.renderStateID == before &+ 1) + @Test + func `append adds a new message and bumps renderStateID`() { + let coordinator = ChatCoordinator.makeForTesting() + let radioID = UUID() + let contactID = UUID() + let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: contactID) + + let before = coordinator.renderStateID + let inserted = coordinator.append(message) + + #expect(inserted) + #expect(coordinator.messages.count == 1) + #expect(coordinator.messagesByID[message.id] == message) + #expect(coordinator.renderStateID == before &+ 1) + } + + @Test + func `append is idempotent on duplicate id`() { + let coordinator = ChatCoordinator.makeForTesting() + let message = MessageDTO.testDirectMessage() + _ = coordinator.append(message) + + let countBefore = coordinator.messages.count + let renderIDBefore = coordinator.renderStateID + let inserted = coordinator.append(message) + + #expect(!inserted) + #expect(coordinator.messages.count == countBefore) + #expect(coordinator.renderStateID == renderIDBefore) + } + + @Test + func `update no-ops on missing id`() { + let coordinator = ChatCoordinator.makeForTesting() + let renderIDBefore = coordinator.renderStateID + + coordinator.update(messageID: UUID()) { dto in + dto = MessageDTO.testDirectMessage() } - @Test("append is idempotent on duplicate id") - func append_idempotentOnDuplicateID() { - let coordinator = ChatCoordinator.makeForTesting() - let message = MessageDTO.testDirectMessage() - _ = coordinator.append(message) + #expect(coordinator.messages.isEmpty) + #expect(coordinator.renderStateID == renderIDBefore) + } - let countBefore = coordinator.messages.count - let renderIDBefore = coordinator.renderStateID - let inserted = coordinator.append(message) + @Test + func `update mutates an existing message in place and bumps renderStateID`() { + let coordinator = ChatCoordinator.makeForTesting() + let message = MessageDTO.testDirectMessage(text: "before") + _ = coordinator.append(message) + let renderIDBefore = coordinator.renderStateID - #expect(!inserted) - #expect(coordinator.messages.count == countBefore) - #expect(coordinator.renderStateID == renderIDBefore) + coordinator.update(messageID: message.id) { dto in + dto = MessageDTO.testDirectMessage(id: message.id, text: "after") } - @Test("update no-ops on missing id") - func update_noOpsOnMissingID() { - let coordinator = ChatCoordinator.makeForTesting() - let renderIDBefore = coordinator.renderStateID - - coordinator.update(messageID: UUID()) { dto in - dto = MessageDTO.testDirectMessage() - } - - #expect(coordinator.messages.isEmpty) - #expect(coordinator.renderStateID == renderIDBefore) + #expect(coordinator.messages.first?.text == "after") + #expect(coordinator.renderStateID == renderIDBefore &+ 1) + } + + @Test + func `renderStateID increments on every mutation`() { + let coordinator = ChatCoordinator.makeForTesting() + let initial = coordinator.renderStateID + + coordinator.replaceAll([MessageDTO.testDirectMessage()]) + _ = coordinator.append(MessageDTO.testDirectMessage()) + coordinator.update(messageID: coordinator.messages[0].id) { _ in } + coordinator.remove(messageID: coordinator.messages[0].id) + + #expect(coordinator.renderStateID == initial &+ 4) + } + + /// Regression: when `rebuildItems` completes but a fresher mutation has + /// advanced `renderStateID`, `setRenderState` rejects the build and the + /// `renderStateInvalidated` callback must fire so the view model knows + /// to reassemble inputs and call `rebuildItems` again. + @Test + func `rebuildItems fires renderStateInvalidated when setRenderState rejects stale build`() async { + let coordinator = ChatCoordinator.makeForTesting() + let message = MessageDTO.testDirectMessage() + _ = coordinator.append(message) + + let invalidatedBox = MainActorBox(value: 0) + coordinator.renderStateInvalidated = { + invalidatedBox.value += 1 } - @Test("update mutates an existing message in place and bumps renderStateID") - func update_mutatesAndBumps() { - let coordinator = ChatCoordinator.makeForTesting() - let message = MessageDTO.testDirectMessage(text: "before") - _ = coordinator.append(message) - let renderIDBefore = coordinator.renderStateID - - coordinator.update(messageID: message.id) { dto in - dto = MessageDTO.testDirectMessage(id: message.id, text: "after") - } - - #expect(coordinator.messages.first?.text == "after") - #expect(coordinator.renderStateID == renderIDBefore &+ 1) + // Build minimal inputs for a single message. The off-main builder + // loop must complete (not be cancelled) so the stale-reject path runs. + let inputs: [(MessageDTO, MessageBuildInputs)] = [ + (message, MessageBuildInputs( + messageID: message.id, + previewState: .idle, + loadedPreview: nil, + cachedURL: nil, + isInlineImageURL: false, + hasInlineImageRef: false, + hasPreviewImageRef: false, + hasPreviewIconRef: false, + imageIsGIF: false, + formattedText: nil, + baseColor: .incoming, + formattedPath: nil, + senderResolution: NodeNameResolution(displayName: "", matchKind: .unresolved), + showTimestamp: false, + showDirectionGap: false, + showSenderName: false, + showNewMessagesDivider: false + )) + ] + + // Kick off rebuildItems, which captures the current renderStateID. + coordinator.rebuildItems(inputs: inputs, envInputs: .default) + + // Advance renderStateID before the off-main build lands on main, + // so setRenderState will reject the result. + _ = coordinator.append(MessageDTO.testDirectMessage()) + + // Wait for the in-flight build task to finish. The off-main loop + // and the trailing MainActor.run (which fires the invalidation + // callback on the stale-reject path) both complete before `.value` + // returns. + await coordinator.buildItemsTask?.value + + // The off-main build finished; renderState must be unchanged (reject) + // and the invalidation callback must have fired exactly once. + #expect(coordinator.renderState.items.isEmpty) + #expect(invalidatedBox.value == 1) + } + + @Test + func `setRenderState returns false on stale capturedID`() { + let coordinator = ChatCoordinator.makeForTesting() + let staleID = coordinator.renderStateID + _ = coordinator.append(MessageDTO.testDirectMessage()) + + let applied = coordinator.setRenderState( + ChatRenderState.empty.with(hasMoreMessages: false), + capturedID: staleID + ) + + #expect(!applied) + #expect(coordinator.renderState.hasMoreMessages == true) + } + + @Test + func `setRenderState applies when capturedID matches`() { + let coordinator = ChatCoordinator.makeForTesting() + let currentID = coordinator.renderStateID + + let applied = coordinator.setRenderState( + ChatRenderState.empty.with(hasMoreMessages: false), + capturedID: currentID + ) + + #expect(applied) + #expect(coordinator.renderState.hasMoreMessages == false) + } + + @Test + func `enqueueReload unions IDs into pendingReloadIDs`() { + let coordinator = ChatCoordinator.makeForTesting() + let id1 = UUID() + let id2 = UUID() + + coordinator.enqueueReload(updatedMessageIDs: [id1]) + coordinator.enqueueReload(updatedMessageIDs: [id2]) + + // The drain Task is scheduled but cannot run before this + // synchronous test returns, so `pendingReloadIDs` still holds + // both unioned IDs at this point. + #expect(coordinator.pendingReloadIDs.contains(id1)) + #expect(coordinator.pendingReloadIDs.contains(id2)) + #expect(coordinator.reloadInFlight) + } + + /// Ack / retry / fail / heard-repeat / reaction events route through + /// `enqueueReload` and then `applyReloadedIDs`, which refreshes the + /// canonical DTO in `messages`. The coordinator invokes + /// `renderItemRebuilder` for each refreshed ID so the view model + /// rebuilds the affected `MessageItem` in `renderState.items`; without + /// that callback the rendered bubble would stay visually stale until + /// an unrelated event forced a full timeline rebuild. + @Test + func `applyReloadedIDs invokes renderItemRebuilder after refreshing a DTO`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + + let radioID = UUID() + let contactID = UUID() + let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) + let coordinator = registry.coordinator(for: conversationID) + + let initial = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "before", + status: .sending + ) + try await dataStore.saveMessage(initial) + _ = coordinator.append(initial) + + let rebuiltIDs = MainActorBox<[UUID]>(value: []) + coordinator.renderItemRebuilder = { id in + rebuiltIDs.value.append(id) } - @Test("renderStateID increments on every mutation") - func renderStateID_incrementsOnEveryMutation() { - let coordinator = ChatCoordinator.makeForTesting() - let initial = coordinator.renderStateID + // Simulate an ack landing in the store after the coordinator has + // already loaded the message: status flips from `.sending` to + // `.sent` via the normal update path. + try await dataStore.updateMessageStatus(id: initial.id, status: .sent) - coordinator.replaceAll([MessageDTO.testDirectMessage()]) - _ = coordinator.append(MessageDTO.testDirectMessage()) - coordinator.update(messageID: coordinator.messages[0].id) { _ in } - coordinator.remove(messageID: coordinator.messages[0].id) + coordinator.enqueueReload(messageID: initial.id) - #expect(coordinator.renderStateID == initial &+ 4) + // Drain the coalesced reload. The implementation schedules a + // detached Task, so poll until `reloadInFlight` clears. + let deadline = ContinuousClock.now + .seconds(10) + while coordinator.reloadInFlight, ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) } - /// Regression: when `rebuildItems` completes but a fresher mutation has - /// advanced `renderStateID`, `setRenderState` rejects the build and the - /// `renderStateInvalidated` callback must fire so the view model knows - /// to reassemble inputs and call `rebuildItems` again. - @Test("rebuildItems fires renderStateInvalidated when setRenderState rejects stale build") - func rebuildItems_firesInvalidatedOnStaleBuild() async { - let coordinator = ChatCoordinator.makeForTesting() - let message = MessageDTO.testDirectMessage() - _ = coordinator.append(message) - - let invalidatedBox = MainActorBox(value: 0) - coordinator.renderStateInvalidated = { - invalidatedBox.value += 1 - } - - // Build minimal inputs for a single message. The off-main builder - // loop must complete (not be cancelled) so the stale-reject path runs. - let inputs: [(MessageDTO, MessageBuildInputs)] = [ - (message, MessageBuildInputs( - messageID: message.id, - previewState: .idle, - loadedPreview: nil, - cachedURL: nil, - hasInlineImageRef: false, - hasPreviewImageRef: false, - hasPreviewIconRef: false, - imageIsGIF: false, - formattedText: nil, - baseColor: .incoming, - formattedPath: nil, - senderResolution: NodeNameResolution(displayName: "", matchKind: .unresolved), - showTimestamp: false, - showDirectionGap: false, - showSenderName: false, - showNewMessagesDivider: false - )) - ] - - // Kick off rebuildItems, which captures the current renderStateID. - coordinator.rebuildItems(inputs: inputs, envInputs: .default) - - // Advance renderStateID before the off-main build lands on main, - // so setRenderState will reject the result. - _ = coordinator.append(MessageDTO.testDirectMessage()) - - // Wait for the in-flight build task to finish. The off-main loop - // and the trailing MainActor.run (which fires the invalidation - // callback on the stale-reject path) both complete before `.value` - // returns. - await coordinator.buildItemsTask?.value - - // The off-main build finished; renderState must be unchanged (reject) - // and the invalidation callback must have fired exactly once. - #expect(coordinator.renderState.items.isEmpty) - #expect(invalidatedBox.value == 1) + #expect(!coordinator.reloadInFlight) + #expect(rebuiltIDs.value == [initial.id]) + #expect(coordinator.messagesByID[initial.id]?.status == .sent) + } + + /// Regression: once a row has flipped to `.failed`, a stale event-stream + /// `applyStatusUpdate(.pending)` (e.g., a delayed `messageStatusResolved` + /// landing after the queue marked the row terminal) must not flicker the + /// bubble back to "Sending". Only an explicit user-initiated retry + /// (`userInitiated: true`) is allowed to downgrade `.failed → .pending`. + @Test + func `applyStatusUpdate blocks .failed -> .pending unless userInitiated`() { + let coordinator = ChatCoordinator.makeForTesting() + let message = MessageDTO.testDirectMessage(status: .pending) + _ = coordinator.append(message) + + coordinator.applyStatusUpdate(messageID: message.id, status: .failed) + #expect(coordinator.messagesByID[message.id]?.status == .failed) + + // Non-user-initiated downgrade is blocked. + coordinator.applyStatusUpdate(messageID: message.id, status: .pending) + #expect(coordinator.messagesByID[message.id]?.status == .failed) + + // User-initiated retry bypasses the guard. + coordinator.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) + #expect(coordinator.messagesByID[message.id]?.status == .pending) + } + + @Test + func `fresh coordinator starts in .uninitialized phase`() { + let coordinator = ChatCoordinator.makeForTesting() + #expect(coordinator.renderState.phase == .uninitialized) + } + + @Test + func `beginLoading transitions .uninitialized to .loading`() { + let coordinator = ChatCoordinator.makeForTesting() + let renderIDBefore = coordinator.renderStateID + + coordinator.beginLoading() + + #expect(coordinator.renderState.phase == .loading) + #expect(coordinator.renderStateID == renderIDBefore &+ 1) + } + + @Test + func `beginLoading is a no-op once .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.replaceAll([]) + let renderIDBefore = coordinator.renderStateID + + coordinator.beginLoading() + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.renderStateID == renderIDBefore) + } + + @Test + func `beginLoading is a no-op while already .loading`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.beginLoading() + let renderIDBefore = coordinator.renderStateID + + coordinator.beginLoading() + + #expect(coordinator.renderState.phase == .loading) + #expect(coordinator.renderStateID == renderIDBefore) + } + + @Test + func `markLoaded transitions .uninitialized to .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + let renderIDBefore = coordinator.renderStateID + + coordinator.markLoaded() + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.renderStateID == renderIDBefore &+ 1) + } + + @Test + func `markLoaded transitions .loading to .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.beginLoading() + let renderIDBefore = coordinator.renderStateID + + coordinator.markLoaded() + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.renderStateID == renderIDBefore &+ 1) + } + + @Test + func `markLoaded is idempotent when already .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.markLoaded() + let renderIDBefore = coordinator.renderStateID + + coordinator.markLoaded() + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.renderStateID == renderIDBefore) + } + + @Test + func `replaceAll transitions phase to .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + #expect(coordinator.renderState.phase == .uninitialized) + + coordinator.replaceAll([MessageDTO.testDirectMessage()]) + + #expect(coordinator.renderState.phase == .loaded) + } + + @Test + func `replaceAll with empty list still transitions to .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.beginLoading() + + coordinator.replaceAll([]) + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.messages.isEmpty) + } + + /// Multi-view-model scenario: two `ChatViewModel`s pointing at the same + /// conversation (iPad split view, sheet dismissal) share a single + /// coordinator instance from the registry. When one VM's load + /// completes, both VMs observe `phase == .loaded` because both read + /// through the shared `renderState`. The empty-state gate stays + /// consistent across siblings. + @Test + func `phase is observed identically by sibling view models sharing one coordinator`() throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + + let radioID = UUID() + let contactID = UUID() + let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) + let coordinatorA = registry.coordinator(for: conversationID) + let coordinatorB = registry.coordinator(for: conversationID) + + #expect(coordinatorA === coordinatorB) + #expect(coordinatorA.renderState.phase == .uninitialized) + + coordinatorA.replaceAll([]) + + #expect(coordinatorA.renderState.phase == .loaded) + #expect(coordinatorB.renderState.phase == .loaded) + } + + /// Same regression scope as `applyReloadedIDs_invokesRenderItemRebuilder`, + /// but verifies the rebuilder is not invoked for IDs that are not present + /// in the coordinator's canonical `messages` array. Paginated-out + /// messages must not fire a per-ID rebuild because there is no + /// corresponding `MessageItem` to refresh. + @Test + func `applyReloadedIDs skips renderItemRebuilder for unknown IDs`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + + let radioID = UUID() + let contactID = UUID() + let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) + let coordinator = registry.coordinator(for: conversationID) + + let pagedOut = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "paged out", + status: .sent + ) + try await dataStore.saveMessage(pagedOut) + + let rebuiltIDs = MainActorBox<[UUID]>(value: []) + coordinator.renderItemRebuilder = { id in + rebuiltIDs.value.append(id) } - @Test("setRenderState returns false on stale capturedID") - func setRenderState_returnsFalseOnStale() { - let coordinator = ChatCoordinator.makeForTesting() - let staleID = coordinator.renderStateID - _ = coordinator.append(MessageDTO.testDirectMessage()) - - let applied = coordinator.setRenderState( - ChatRenderState.empty.with(hasMoreMessages: false), - capturedID: staleID - ) + coordinator.enqueueReload(messageID: pagedOut.id) - #expect(!applied) - #expect(coordinator.renderState.hasMoreMessages == true) + let deadline = ContinuousClock.now + .seconds(10) + while coordinator.reloadInFlight, ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) } - @Test("setRenderState applies when capturedID matches") - func setRenderState_appliesWhenCurrent() { - let coordinator = ChatCoordinator.makeForTesting() - let currentID = coordinator.renderStateID - - let applied = coordinator.setRenderState( - ChatRenderState.empty.with(hasMoreMessages: false), - capturedID: currentID - ) - - #expect(applied) - #expect(coordinator.renderState.hasMoreMessages == false) - } - - @Test("enqueueReload unions IDs into pendingReloadIDs") - func enqueueReload_unionsIDs() { - let coordinator = ChatCoordinator.makeForTesting() - let id1 = UUID() - let id2 = UUID() - - coordinator.enqueueReload(updatedMessageIDs: [id1]) - coordinator.enqueueReload(updatedMessageIDs: [id2]) - - // The drain Task is scheduled but cannot run before this - // synchronous test returns, so `pendingReloadIDs` still holds - // both unioned IDs at this point. - #expect(coordinator.pendingReloadIDs.contains(id1)) - #expect(coordinator.pendingReloadIDs.contains(id2)) - #expect(coordinator.reloadInFlight) - } - - /// Ack / retry / fail / heard-repeat / reaction events route through - /// `enqueueReload` and then `applyReloadedIDs`, which refreshes the - /// canonical DTO in `messages`. The coordinator invokes - /// `renderItemRebuilder` for each refreshed ID so the view model - /// rebuilds the affected `MessageItem` in `renderState.items`; without - /// that callback the rendered bubble would stay visually stale until - /// an unrelated event forced a full timeline rebuild. - @Test("applyReloadedIDs invokes renderItemRebuilder after refreshing a DTO") - func applyReloadedIDs_invokesRenderItemRebuilder() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: dataStore) - - let radioID = UUID() - let contactID = UUID() - let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) - let coordinator = registry.coordinator(for: conversationID) - - let initial = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contactID, - text: "before", - status: .sending - ) - try await dataStore.saveMessage(initial) - _ = coordinator.append(initial) - - let rebuiltIDs = MainActorBox<[UUID]>(value: []) - coordinator.renderItemRebuilder = { id in - rebuiltIDs.value.append(id) - } - - // Simulate an ack landing in the store after the coordinator has - // already loaded the message: status flips from `.sending` to - // `.sent` via the normal update path. - try await dataStore.updateMessageStatus(id: initial.id, status: .sent) - - coordinator.enqueueReload(messageID: initial.id) - - // Drain the coalesced reload. The implementation schedules a - // detached Task, so yield until `reloadInFlight` clears. - let deadline = ContinuousClock.now + .seconds(1) - while coordinator.reloadInFlight, ContinuousClock.now < deadline { - await Task.yield() - } - - #expect(!coordinator.reloadInFlight) - #expect(rebuiltIDs.value == [initial.id]) - #expect(coordinator.messagesByID[initial.id]?.status == .sent) - } - - /// Regression: once a row has flipped to `.failed`, a stale event-stream - /// `applyStatusUpdate(.pending)` (e.g., a delayed `messageStatusResolved` - /// landing after the queue marked the row terminal) must not flicker the - /// bubble back to "Sending". Only an explicit user-initiated retry - /// (`userInitiated: true`) is allowed to downgrade `.failed → .pending`. - @Test("applyStatusUpdate blocks .failed -> .pending unless userInitiated") - func applyStatusUpdate_blocksFailedToPendingUnlessUserInitiated() { - let coordinator = ChatCoordinator.makeForTesting() - let message = MessageDTO.testDirectMessage(status: .pending) - _ = coordinator.append(message) - - coordinator.applyStatusUpdate(messageID: message.id, status: .failed) - #expect(coordinator.messagesByID[message.id]?.status == .failed) - - // Non-user-initiated downgrade is blocked. - coordinator.applyStatusUpdate(messageID: message.id, status: .pending) - #expect(coordinator.messagesByID[message.id]?.status == .failed) - - // User-initiated retry bypasses the guard. - coordinator.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) - #expect(coordinator.messagesByID[message.id]?.status == .pending) - } - - @Test("fresh coordinator starts in .uninitialized phase") - func phase_startsUninitialized() { - let coordinator = ChatCoordinator.makeForTesting() - #expect(coordinator.renderState.phase == .uninitialized) - } - - @Test("beginLoading transitions .uninitialized to .loading") - func beginLoading_transitionsFromUninitialized() { - let coordinator = ChatCoordinator.makeForTesting() - let renderIDBefore = coordinator.renderStateID - - coordinator.beginLoading() - - #expect(coordinator.renderState.phase == .loading) - #expect(coordinator.renderStateID == renderIDBefore &+ 1) - } - - @Test("beginLoading is a no-op once .loaded") - func beginLoading_noOpWhenLoaded() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.replaceAll([]) - let renderIDBefore = coordinator.renderStateID - - coordinator.beginLoading() - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.renderStateID == renderIDBefore) - } - - @Test("beginLoading is a no-op while already .loading") - func beginLoading_noOpWhenLoading() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.beginLoading() - let renderIDBefore = coordinator.renderStateID - - coordinator.beginLoading() - - #expect(coordinator.renderState.phase == .loading) - #expect(coordinator.renderStateID == renderIDBefore) - } - - @Test("markLoaded transitions .uninitialized to .loaded") - func markLoaded_transitionsFromUninitialized() { - let coordinator = ChatCoordinator.makeForTesting() - let renderIDBefore = coordinator.renderStateID - - coordinator.markLoaded() - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.renderStateID == renderIDBefore &+ 1) - } - - @Test("markLoaded transitions .loading to .loaded") - func markLoaded_transitionsFromLoading() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.beginLoading() - let renderIDBefore = coordinator.renderStateID - - coordinator.markLoaded() - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.renderStateID == renderIDBefore &+ 1) - } - - @Test("markLoaded is idempotent when already .loaded") - func markLoaded_idempotentWhenLoaded() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.markLoaded() - let renderIDBefore = coordinator.renderStateID - - coordinator.markLoaded() - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.renderStateID == renderIDBefore) - } - - @Test("replaceAll transitions phase to .loaded") - func replaceAll_transitionsToLoaded() { - let coordinator = ChatCoordinator.makeForTesting() - #expect(coordinator.renderState.phase == .uninitialized) - - coordinator.replaceAll([MessageDTO.testDirectMessage()]) - - #expect(coordinator.renderState.phase == .loaded) - } - - @Test("replaceAll with empty list still transitions to .loaded") - func replaceAll_emptyTransitionsToLoaded() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.beginLoading() - - coordinator.replaceAll([]) - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.messages.isEmpty) - } - - /// Multi-view-model scenario: two `ChatViewModel`s pointing at the same - /// conversation (iPad split view, sheet dismissal) share a single - /// coordinator instance from the registry. When one VM's load - /// completes, both VMs observe `phase == .loaded` because both read - /// through the shared `renderState`. The empty-state gate stays - /// consistent across siblings. - @Test("phase is observed identically by sibling view models sharing one coordinator") - func phase_sharedAcrossSiblings() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: dataStore) - - let radioID = UUID() - let contactID = UUID() - let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) - let coordinatorA = registry.coordinator(for: conversationID) - let coordinatorB = registry.coordinator(for: conversationID) - - #expect(coordinatorA === coordinatorB) - #expect(coordinatorA.renderState.phase == .uninitialized) - - coordinatorA.replaceAll([]) - - #expect(coordinatorA.renderState.phase == .loaded) - #expect(coordinatorB.renderState.phase == .loaded) - } - - /// Same regression scope as `applyReloadedIDs_invokesRenderItemRebuilder`, - /// but verifies the rebuilder is not invoked for IDs that are not present - /// in the coordinator's canonical `messages` array. Paginated-out - /// messages must not fire a per-ID rebuild because there is no - /// corresponding `MessageItem` to refresh. - @Test("applyReloadedIDs skips renderItemRebuilder for unknown IDs") - func applyReloadedIDs_skipsRebuilderForUnknownID() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: dataStore) - - let radioID = UUID() - let contactID = UUID() - let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) - let coordinator = registry.coordinator(for: conversationID) - - let pagedOut = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contactID, - text: "paged out", - status: .sent - ) - try await dataStore.saveMessage(pagedOut) - - let rebuiltIDs = MainActorBox<[UUID]>(value: []) - coordinator.renderItemRebuilder = { id in - rebuiltIDs.value.append(id) - } - - coordinator.enqueueReload(messageID: pagedOut.id) - - let deadline = ContinuousClock.now + .seconds(1) - while coordinator.reloadInFlight, ContinuousClock.now < deadline { - await Task.yield() - } - - #expect(rebuiltIDs.value.isEmpty) - } + #expect(rebuiltIDs.value.isEmpty) + } } /// Test-only main-actor mutable box for closure-side recording. @MainActor private final class MainActorBox { - var value: Value - init(value: Value) { self.value = value } + var value: Value + init(value: Value) { + self.value = value + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift index 1e81320e..7c2a3af7 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift @@ -1,8 +1,8 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore import MeshCoreTestSupport +import Testing /// Acceptance suite for the persistent `PendingSend.attemptCount` surface. /// Covers three cases: @@ -17,190 +17,189 @@ import MeshCoreTestSupport @Suite("ChatSendQueueService.attemptCount") @MainActor struct ChatSendQueueServiceAttemptCountTests { - - /// Helper: build a Device + Contact + Message + PendingSend with the - /// requested attemptCount, returning the service, the store it shares, - /// the messageID, the radioID, and the message's original wire timestamp. - /// The service is not hydrated — the caller decides whether to hydrate or - /// to first run `store.warmUp()`. - /// - /// The message's `timestamp` is pinned to one hour ago so a "fresh wire - /// timestamp" stamp by `updateMessageTimestamp` (preserveTimestamp=false) - /// is observably different from a "preserved" timestamp - /// (preserveTimestamp=true). UInt32 epoch-second granularity would - /// otherwise collide on a fast test run. - /// - /// `radioID` comes from `Device.radioID` (not `Device.id`) so the - /// in-progress radio survives `purgeOrphanPendingSends`, which keys on - /// `Device.radioID`. - private struct QueueHarness { - let service: ChatSendQueueService - let store: PersistenceStore - let messageID: UUID - let radioID: UUID - let originalTimestamp: UInt32 + /// Helper: build a Device + Contact + Message + PendingSend with the + /// requested attemptCount, returning the service, the store it shares, + /// the messageID, the radioID, and the message's original wire timestamp. + /// The service is not hydrated — the caller decides whether to hydrate or + /// to first run `store.warmUp()`. + /// + /// The message's `timestamp` is pinned to one hour ago so a "fresh wire + /// timestamp" stamp by `updateMessageTimestamp` (preserveTimestamp=false) + /// is observably different from a "preserved" timestamp + /// (preserveTimestamp=true). UInt32 epoch-second granularity would + /// otherwise collide on a fast test run. + /// + /// `radioID` comes from `Device.radioID` (not `Device.id`) so the + /// in-progress radio survives `purgeOrphanPendingSends`, which keys on + /// `Device.radioID`. + private struct QueueHarness { + let service: ChatSendQueueService + let store: PersistenceStore + let messageID: UUID + let radioID: UUID + let originalTimestamp: UInt32 + } + + private static func setupQueueWithRow( + attemptCount: Int? + ) async throws -> QueueHarness { + let device = Device( + publicKey: Data(repeating: 1, count: 32), + nodeName: "Test Device" + ) + let container = try PersistenceStore.createContainer(inMemory: true) + container.mainContext.insert(device) + try container.mainContext.save() + let store = PersistenceStore(modelContainer: container) + let radioID = device.radioID + + let contact = Contact( + radioID: radioID, + publicKey: Data(repeating: 2, count: 32), + name: "Test Contact" + ) + container.mainContext.insert(contact) + try container.mainContext.save() + let contactDTO = try #require(try await store.fetchContact(id: contact.id)) + + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + let messageService = MessageService(session: session, dataStore: store, contactService: nil) + let pending = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) + + let pinnedTimestamp = UInt32(Date().addingTimeInterval(-3600).timeIntervalSince1970) + try await store.updateMessageTimestamp(id: pending.id, timestamp: pinnedTimestamp) + let message = try #require(try await store.fetchMessage(id: pending.id)) + + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contactDTO.id) + let dto = PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: envelope.messageID, + kind: .dm, + contactID: envelope.contactID, + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: 1, + enqueuedAt: Date(), + attemptCount: attemptCount + ) + try await store.upsertPendingSend(dto) + + let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) + let service = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) + + return QueueHarness( + service: service, + store: store, + messageID: message.id, + radioID: radioID, + originalTimestamp: message.timestamp + ) + } + + /// Case 1 / Fresh send: a row with `attemptCount = 0` (current-build + /// race-window row that persisted but never progressed past the + /// top-of-drain bump) drains with `preserveTimestamp = false` because + /// `postBumpCount = 1 > 1` is false. The recipient never saw the packet, + /// so a fresh wire timestamp is correct. Observable side effect: + /// `Message.timestamp` changes (updateMessageTimestamp was called by + /// `sendPendingDirectMessage`). + @Test + func `fresh send: row with attemptCount=0 first drain uses fresh timestamp`() async throws { + let harness = try await Self.setupQueueWithRow(attemptCount: 0) + + await harness.service.hydrate() + // Poll for both drain effects so the assertions can't observe a + // half-applied drain on a slow runner. + try await waitUntil(timeout: .seconds(10), "first drain must bump 0 → 1 and stamp a fresh timestamp") { + guard + let row = await (try? harness.store.fetchPendingSends(radioID: harness.radioID))? + .first(where: { $0.messageID == harness.messageID }), + let timestamp = await (try? harness.store.fetchMessage(id: harness.messageID))?.timestamp + else { return false } + return row.attemptCount == 1 && timestamp != harness.originalTimestamp } - private static func setupQueueWithRow( - attemptCount: Int? - ) async throws -> QueueHarness { - let device = Device( - publicKey: Data(repeating: 1, count: 32), - nodeName: "Test Device" - ) - let container = try PersistenceStore.createContainer(inMemory: true) - container.mainContext.insert(device) - try container.mainContext.save() - let store = PersistenceStore(modelContainer: container) - let radioID = device.radioID - - let contact = Contact( - radioID: radioID, - publicKey: Data(repeating: 2, count: 32), - name: "Test Contact" - ) - container.mainContext.insert(contact) - try container.mainContext.save() - let contactDTO = try #require(try await store.fetchContact(id: contact.id)) - - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - let messageService = MessageService(session: session, dataStore: store, contactService: nil) - let pending = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) - - let pinnedTimestamp = UInt32(Date().addingTimeInterval(-3600).timeIntervalSince1970) - try await store.updateMessageTimestamp(id: pending.id, timestamp: pinnedTimestamp) - let message = try #require(try await store.fetchMessage(id: pending.id)) - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contactDTO.id) - let dto = PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: envelope.messageID, - kind: .dm, - contactID: envelope.contactID, - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: 1, - enqueuedAt: Date(), - attemptCount: attemptCount - ) - try await store.upsertPendingSend(dto) - - let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) - let service = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - return QueueHarness( - service: service, - store: store, - messageID: message.id, - radioID: radioID, - originalTimestamp: message.timestamp - ) + let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + let bumped = rows.first(where: { $0.messageID == harness.messageID })?.attemptCount + #expect(bumped == 1, "first drain attempt must bump 0 → 1") + + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.timestamp != harness.originalTimestamp, + "preserveTimestamp=false: updateMessageTimestamp must stamp a fresh wire timestamp") + + await harness.service.shutdown() + } + + /// Case 2 / Process restart: a row left at `attemptCount = 1` simulates + /// "prior process bumped before sending then died before + /// deletePendingSendsForMessage". On rehydrate the drain bumps to 2 and + /// preserves the wire timestamp so mesh dedup catches a duplicate + /// landing if the wire send completed. + @Test + func `process restart: row with attemptCount=1 rehydrates with preserveTimestamp=true`() async throws { + let harness = try await Self.setupQueueWithRow(attemptCount: 1) + + await harness.service.hydrate() + try await waitUntil(timeout: .seconds(10), "rehydrate drain must bump 1 → 2") { + let row = await (try? harness.store.fetchPendingSends(radioID: harness.radioID))? + .first(where: { $0.messageID == harness.messageID }) + return row?.attemptCount == 2 } - /// Case 1 / Fresh send: a row with `attemptCount = 0` (current-build - /// race-window row that persisted but never progressed past the - /// top-of-drain bump) drains with `preserveTimestamp = false` because - /// `postBumpCount = 1 > 1` is false. The recipient never saw the packet, - /// so a fresh wire timestamp is correct. Observable side effect: - /// `Message.timestamp` changes (updateMessageTimestamp was called by - /// `sendPendingDirectMessage`). - @Test("fresh send: row with attemptCount=0 first drain uses fresh timestamp") - func freshSendBumpsToOneAndUsesFreshTimestamp() async throws { - let harness = try await Self.setupQueueWithRow(attemptCount: 0) - - await harness.service.hydrate() - // Poll for both drain effects so the assertions can't observe a - // half-applied drain on a slow runner. - try await waitUntil(timeout: .seconds(10), "first drain must bump 0 → 1 and stamp a fresh timestamp") { - guard - let row = (try? await harness.store.fetchPendingSends(radioID: harness.radioID))? - .first(where: { $0.messageID == harness.messageID }), - let timestamp = (try? await harness.store.fetchMessage(id: harness.messageID))?.timestamp - else { return false } - return row.attemptCount == 1 && timestamp != harness.originalTimestamp - } - - let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - let bumped = rows.first(where: { $0.messageID == harness.messageID })?.attemptCount - #expect(bumped == 1, "first drain attempt must bump 0 → 1") - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.timestamp != harness.originalTimestamp, - "preserveTimestamp=false: updateMessageTimestamp must stamp a fresh wire timestamp") - - await harness.service.shutdown() + let postDrain = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(postDrain.first(where: { $0.messageID == harness.messageID })?.attemptCount == 2, + "rehydrate drain bumps 1 → 2") + + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.timestamp == harness.originalTimestamp, + "rehydrate must preserve original wire timestamp so mesh dedup catches duplicate landing") + + await harness.service.shutdown() + } + + /// Case 3 / Bump failure: when `incrementPendingSendAttemptCount` throws, + /// the drain closure must park the envelope on the transport-open trigger + /// (status reverts to `.pending`), leave the PendingSend row intact, and + /// must not call `sendPendingDirectMessage`. Without this guarantee a + /// SwiftData failure during the bump could either drop the envelope + /// silently or double-send on retry. + @Test + func `bump failure parks envelope, preserves row, does not call sendPendingDirectMessage`() async throws { + let harness = try await Self.setupQueueWithRow(attemptCount: 0) + + // Force every subsequent incrementPendingSendAttemptCount call to throw + // so the drain closure takes the bump-failure park branch. + struct FakeSaveFailure: Error {} + await harness.store.setIncrementPendingSendAttemptCountFaultInjection { + throw FakeSaveFailure() } - /// Case 2 / Process restart: a row left at `attemptCount = 1` simulates - /// "prior process bumped before sending then died before - /// deletePendingSendsForMessage". On rehydrate the drain bumps to 2 and - /// preserves the wire timestamp so mesh dedup catches a duplicate - /// landing if the wire send completed. - @Test("process restart: row with attemptCount=1 rehydrates with preserveTimestamp=true") - func processRestartPreservesTimestampOnRehydrate() async throws { - let harness = try await Self.setupQueueWithRow(attemptCount: 1) - - await harness.service.hydrate() - try await waitUntil(timeout: .seconds(10), "rehydrate drain must bump 1 → 2") { - let row = (try? await harness.store.fetchPendingSends(radioID: harness.radioID))? - .first(where: { $0.messageID == harness.messageID }) - return row?.attemptCount == 2 - } - - let postDrain = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(postDrain.first(where: { $0.messageID == harness.messageID })?.attemptCount == 2, - "rehydrate drain bumps 1 → 2") - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.timestamp == harness.originalTimestamp, - "rehydrate must preserve original wire timestamp so mesh dedup catches duplicate landing") - - await harness.service.shutdown() - } + await harness.service.hydrate() + try? await Task.sleep(for: .milliseconds(500)) - /// Case 3 / Bump failure: when `incrementPendingSendAttemptCount` throws, - /// the drain closure must park the envelope on the transport-open trigger - /// (status reverts to `.pending`), leave the PendingSend row intact, and - /// must not call `sendPendingDirectMessage`. Without this guarantee a - /// SwiftData failure during the bump could either drop the envelope - /// silently or double-send on retry. - @Test("bump failure parks envelope, preserves row, does not call sendPendingDirectMessage") - func bumpFailureParksWithoutSending() async throws { - let harness = try await Self.setupQueueWithRow(attemptCount: 0) - - // Force every subsequent incrementPendingSendAttemptCount call to throw - // so the drain closure takes the bump-failure park branch. - struct FakeSaveFailure: Error {} - await harness.store.setIncrementPendingSendAttemptCountFaultInjection { - throw FakeSaveFailure() - } - - await harness.service.hydrate() - try? await Task.sleep(for: .milliseconds(500)) - - let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(rows.count == 1, - "PendingSend row must survive the bump-failure park so the next transport-open can retry") - #expect(rows.first?.attemptCount == 0, - "attemptCount must not advance when the bump throws — the next drain bumps to the same target") - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.timestamp == harness.originalTimestamp, - "bump-failure park must run before any wire-affecting work — wire timestamp untouched") - #expect(postDrainMessage?.status == .pending, - "park branch must remap status back to .pending so the bubble does not flicker .failed") - - await harness.service.shutdown() - } + let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(rows.count == 1, + "PendingSend row must survive the bump-failure park so the next transport-open can retry") + #expect(rows.first?.attemptCount == 0, + "attemptCount must not advance when the bump throws — the next drain bumps to the same target") + + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.timestamp == harness.originalTimestamp, + "bump-failure park must run before any wire-affecting work — wire timestamp untouched") + #expect(postDrainMessage?.status == .pending, + "park branch must remap status back to .pending so the bubble does not flicker .failed") + + await harness.service.shutdown() + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift index eeee6517..e9e44337 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift @@ -1,1198 +1,1198 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore import MeshCoreTestSupport +import Testing @Suite("ChatSendQueueService") @MainActor struct ChatSendQueueServiceTests { + private static func makeStore() throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private static func makeMessageService(dataStore: PersistenceStore) async -> MessageService { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + return MessageService(session: session, dataStore: dataStore, contactService: nil) + } + + private static func makeChannelService(dataStore: PersistenceStore) async -> ChannelService { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + return ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + } + + /// PendingSend row pointing at a contact that was deleted between + /// enqueue and hydrate. The send closure's contact lookup fails, + /// which is the "drop envelope" path — the row is purged without + /// calling `sendPendingDirectMessage`. This exercises hydrate's queue + /// loading and the queue's drain → onError → row-cleanup path on + /// the simplest available transport-independent surface. + @Test + func `hydrate replays persisted PendingSend rows and drains them`() async throws { + let store = try Self.makeStore() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + let envelope = DirectMessageEnvelope(messageID: messageID, contactID: contactID) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope, radioID: radioID) + ) - private static func makeStore() throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } + let preRows = try await store.fetchPendingSends(radioID: radioID) + #expect(preRows.count == 1, "fixture should have inserted exactly one row") + + let messageService = await Self.makeMessageService(dataStore: store) + let channelService = await Self.makeChannelService(dataStore: store) + let service = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) - private static func makeMessageService(dataStore: PersistenceStore) async -> MessageService { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - return MessageService(session: session, dataStore: dataStore, contactService: nil) - } + await service.hydrate() + await service.awaitDrainCompletion() + + let rowsAfter = try await store.fetchPendingSends(radioID: radioID) + #expect(rowsAfter.isEmpty, "hydrate + drain should clear the persisted row") + } + + /// A `PendingSend` row whose send fails with a transient transport + /// error must survive the failure — the closure parks in + /// `withCooperativeTimeout` on `triggers.wait(forAnyOf:)`, and the + /// row stays on disk until either the trigger fires or the deadline + /// elapses. Without `transportDidOpen()`, the wait suspends. + @Test + func `transient send error preserves the persisted row while parked on the trigger`() async throws { + let device = Device( + publicKey: Data(repeating: 1, count: 32), + nodeName: "Test Device" + ) + let container = try PersistenceStore.createContainer(inMemory: true) + container.mainContext.insert(device) + try container.mainContext.save() + let store = PersistenceStore(modelContainer: container) + + let radioID = device.id + let contact = Contact( + radioID: radioID, + publicKey: Data(repeating: 2, count: 32), + name: "Test Contact" + ) + container.mainContext.insert(contact) + try container.mainContext.save() + let contactDTO = try #require(try await store.fetchContact(id: contact.id)) + + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + let messageService = MessageService(session: session, dataStore: store, contactService: nil) + let message = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) + + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contactDTO.id) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope, radioID: radioID) + ) - private static func makeChannelService(dataStore: PersistenceStore) async -> ChannelService { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - return ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - } + let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) + let service = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) - /// PendingSend row pointing at a contact that was deleted between - /// enqueue and hydrate. The send closure's contact lookup fails, - /// which is the "drop envelope" path — the row is purged without - /// calling `sendPendingDirectMessage`. This exercises hydrate's queue - /// loading and the queue's drain → onError → row-cleanup path on - /// the simplest available transport-independent surface. - @Test("hydrate replays persisted PendingSend rows and drains them") - func hydrateReplaysRows() async throws { - let store = try Self.makeStore() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - let envelope = DirectMessageEnvelope(messageID: messageID, contactID: contactID) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope, radioID: radioID) - ) - - let preRows = try await store.fetchPendingSends(radioID: radioID) - #expect(preRows.count == 1, "fixture should have inserted exactly one row") - - let messageService = await Self.makeMessageService(dataStore: store) - let channelService = await Self.makeChannelService(dataStore: store) - let service = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - await service.hydrate() - await service.awaitDrainCompletion() - - let rowsAfter = try await store.fetchPendingSends(radioID: radioID) - #expect(rowsAfter.isEmpty, "hydrate + drain should clear the persisted row") - } + await service.hydrate() + // Give the drain time to call sendPendingDirectMessage and throw + // a transient transport error, parking inside withCooperativeTimeout. + try? await Task.sleep(for: .milliseconds(200)) + let rowsMidFlight = try await store.fetchPendingSends(radioID: radioID) + #expect(rowsMidFlight.count == 1, + "transient error must not delete the row while suspended on the trigger") + + // Release the queue so the actor can deinit at test scope end — + // otherwise its drain task suspends for up to `transportWaitTimeout` + // before the cooperative timeout fires. + await service.shutdown() + } + + /// `hydrate` must filter by the service's `radioID`. A row inserted + /// for a different radio must not flow into this service's queues + /// (which would either send the wrong envelope or, at minimum, + /// delete the foreign row when the contact lookup fails on the + /// drop path). Regression guard against a future "optimization" + /// that drops the `radioID` predicate from `fetchPendingSends`. + @Test + func `hydrate processes only the service's own radio rows`() async throws { + let store = try Self.makeStore() + let radioA = UUID() + let radioB = UUID() + let envA1 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + let envA2 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + let envB = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envA1, radioID: radioA) + ) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envA2, radioID: radioA) + ) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envB, radioID: radioB) + ) - /// A `PendingSend` row whose send fails with a transient transport - /// error must survive the failure — the closure parks in - /// `withCooperativeTimeout` on `triggers.wait(forAnyOf:)`, and the - /// row stays on disk until either the trigger fires or the deadline - /// elapses. Without `transportDidOpen()`, the wait suspends. - @Test("transient send error preserves the persisted row while parked on the trigger") - func transientErrorPreservesRow() async throws { - let device = Device( - publicKey: Data(repeating: 1, count: 32), - nodeName: "Test Device" - ) - let container = try PersistenceStore.createContainer(inMemory: true) - container.mainContext.insert(device) - try container.mainContext.save() - let store = PersistenceStore(modelContainer: container) - - let radioID = device.id - let contact = Contact( - radioID: radioID, - publicKey: Data(repeating: 2, count: 32), - name: "Test Contact" - ) - container.mainContext.insert(contact) - try container.mainContext.save() - let contactDTO = try #require(try await store.fetchContact(id: contact.id)) - - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - let messageService = MessageService(session: session, dataStore: store, contactService: nil) - let message = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contactDTO.id) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope, radioID: radioID) - ) - - let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) - let service = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - await service.hydrate() - // Give the drain time to call sendPendingDirectMessage and throw - // a transient transport error, parking inside withCooperativeTimeout. - try? await Task.sleep(for: .milliseconds(200)) - let rowsMidFlight = try await store.fetchPendingSends(radioID: radioID) - #expect(rowsMidFlight.count == 1, - "transient error must not delete the row while suspended on the trigger") - - // Release the queue so the actor can deinit at test scope end — - // otherwise its drain task suspends for up to `transportWaitTimeout` - // before the cooperative timeout fires. - await service.shutdown() - } + let messageService = await Self.makeMessageService(dataStore: store) + let channelService = await Self.makeChannelService(dataStore: store) + let service = ChatSendQueueService( + radioID: radioA, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) - /// `hydrate` must filter by the service's `radioID`. A row inserted - /// for a different radio must not flow into this service's queues - /// (which would either send the wrong envelope or, at minimum, - /// delete the foreign row when the contact lookup fails on the - /// drop path). Regression guard against a future "optimization" - /// that drops the `radioID` predicate from `fetchPendingSends`. - @Test("hydrate processes only the service's own radio rows") - func hydrateScopesByRadioID() async throws { - let store = try Self.makeStore() - let radioA = UUID() - let radioB = UUID() - let envA1 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - let envA2 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - let envB = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envA1, radioID: radioA) - ) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envA2, radioID: radioA) - ) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envB, radioID: radioB) - ) - - let messageService = await Self.makeMessageService(dataStore: store) - let channelService = await Self.makeChannelService(dataStore: store) - let service = ChatSendQueueService( - radioID: radioA, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - await service.hydrate() - await service.awaitDrainCompletion() - - let radioARowsAfter = try await store.fetchPendingSends(radioID: radioA) - let radioBRowsAfter = try await store.fetchPendingSends(radioID: radioB) - #expect(radioARowsAfter.isEmpty, - "hydrate must drain only the service's own radio rows") - #expect(radioBRowsAfter.count == 1, - "hydrate must not touch another radio's rows") - #expect(radioBRowsAfter.first?.messageID == envB.messageID, - "the surviving row must be the foreign radio's original envelope") - } + await service.hydrate() + await service.awaitDrainCompletion() + + let radioARowsAfter = try await store.fetchPendingSends(radioID: radioA) + let radioBRowsAfter = try await store.fetchPendingSends(radioID: radioB) + #expect(radioARowsAfter.isEmpty, + "hydrate must drain only the service's own radio rows") + #expect(radioBRowsAfter.count == 1, + "hydrate must not touch another radio's rows") + #expect(radioBRowsAfter.first?.messageID == envB.messageID, + "the surviving row must be the foreign radio's original envelope") + } + + /// `fetchPendingSends` orders by sequence ASC, and `hydrate` iterates + /// that result with sequential `await dmQueue.enqueue(_:)` calls, + /// which `SendQueue` appends to a FIFO list. This verifies the + /// persistence layer preserves enqueue order across the hydrate + /// path so a future "optimize the fetch" change can't silently + /// reorder replays. We assert at the fetch boundary because the + /// downstream `SendQueue` order is exercised by its own tests. + @Test + func `hydrate fetch returns rows in sequence order`() async throws { + let store = try Self.makeStore() + let radioID = UUID() + let envA = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + let envB = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + let envC = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envA, radioID: radioID) + ) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envB, radioID: radioID) + ) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envC, radioID: radioID) + ) - /// `fetchPendingSends` orders by sequence ASC, and `hydrate` iterates - /// that result with sequential `await dmQueue.enqueue(_:)` calls, - /// which `SendQueue` appends to a FIFO list. This verifies the - /// persistence layer preserves enqueue order across the hydrate - /// path so a future "optimize the fetch" change can't silently - /// reorder replays. We assert at the fetch boundary because the - /// downstream `SendQueue` order is exercised by its own tests. - @Test("hydrate fetch returns rows in sequence order") - func hydrateFetchOrdersBySequence() async throws { - let store = try Self.makeStore() - let radioID = UUID() - let envA = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - let envB = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - let envC = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envA, radioID: radioID) - ) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envB, radioID: radioID) - ) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envC, radioID: radioID) - ) - - let rows = try await store.fetchPendingSends(radioID: radioID) - let messageIDs = rows.map(\.messageID) - #expect(messageIDs == [envA.messageID, envB.messageID, envC.messageID], - "fetchPendingSends must return rows in sequence ASC; hydrate depends on this for FIFO replay") + let rows = try await store.fetchPendingSends(radioID: radioID) + let messageIDs = rows.map(\.messageID) + #expect(messageIDs == [envA.messageID, envB.messageID, envC.messageID], + "fetchPendingSends must return rows in sequence ASC; hydrate depends on this for FIFO replay") + } + + /// Regression: the classifier must unwrap `MessageServiceError.sessionError` + /// so the wrapped form produced by `failMessageAndRethrow` matches. DM path + /// treats firmware code 3 (TABLE_FULL pool exhaustion) as transient. + @Test + func `isTransientDirectMessageError unwraps sessionError on deviceError(3)`() { + let wrapped = MessageServiceError.sessionError(.deviceError(code: 3)) + #expect(ChatSendQueueService.isTransientDirectMessageError(wrapped) == true) + } + + /// Symmetric regression for the channel path: code 2 (NOT_FOUND pool + /// exhaustion / stale channel index) is transient when wrapped in + /// MessageServiceError.sessionError. + @Test + func `isTransientChannelMessageError unwraps sessionError on deviceError(2)`() { + let wrapped = MessageServiceError.sessionError(.deviceError(code: 2)) + #expect(ChatSendQueueService.isTransientChannelMessageError(wrapped) == true) + } + + /// A DM-path classifier must not park on code 2 — only code 3 is the DM + /// pool-exhaustion signal. This locks in the asymmetry between paths. + @Test + func `isTransientDirectMessageError treats deviceError(2) as terminal`() { + let wrapped = MessageServiceError.sessionError(.deviceError(code: 2)) + #expect(ChatSendQueueService.isTransientDirectMessageError(wrapped) == false) + } + + /// Symmetric guard for the channel classifier: code 3 is the DM signal, + /// not the channel one. + @Test + func `isTransientChannelMessageError treats deviceError(3) as terminal`() { + let wrapped = MessageServiceError.sessionError(.deviceError(code: 3)) + #expect(ChatSendQueueService.isTransientChannelMessageError(wrapped) == false) + } + + /// Regression: the channel-cap helper recognises the raw + /// `MeshCoreError.deviceError(2)` shape produced by `withPoolBackoff`'s + /// re-throw before `MessageService` wraps it. + @Test + func `isChannelMessageNotFound matches raw MeshCoreError.deviceError(2)`() { + let raw: Error = MeshCoreError.deviceError(code: FirmwareDeviceErrorCode.channelMessageNotFound) + #expect(ChatSendQueueService.isChannelMessageNotFound(raw) == true) + } + + /// Regression: the helper unwraps the `MessageServiceError.sessionError` + /// shape produced by `failMessageAndRethrow` in `MessageService`. + @Test + func `isChannelMessageNotFound unwraps MessageServiceError.sessionError(deviceError(2))`() { + let wrapped: Error = MessageServiceError.sessionError(.deviceError(code: FirmwareDeviceErrorCode.channelMessageNotFound)) + #expect(ChatSendQueueService.isChannelMessageNotFound(wrapped) == true) + } + + /// Regression: only firmware code 2 maps to the cap. Other firmware codes + /// (e.g. code 3 — the DM TABLE_FULL signal) must not be classified as + /// NOT_FOUND, since the channel cap should not trigger on DM-pool exhaustion. + @Test + func `isChannelMessageNotFound rejects non-NOT_FOUND device errors`() { + let wrongCode: Error = MeshCoreError.deviceError(code: FirmwareDeviceErrorCode.directMessageTableFull) + let wrappedWrongCode: Error = MessageServiceError.sessionError(.deviceError(code: FirmwareDeviceErrorCode.directMessageTableFull)) + let timeout: Error = MeshCoreError.timeout + #expect(ChatSendQueueService.isChannelMessageNotFound(wrongCode) == false) + #expect(ChatSendQueueService.isChannelMessageNotFound(wrappedWrongCode) == false) + #expect(ChatSendQueueService.isChannelMessageNotFound(timeout) == false) + } + + /// End-to-end channel-cap behaviour on a high-attempt envelope. A channel + /// envelope with `attemptCount = 7` pre-loaded on its PendingSend row + /// reaches `postBumpCount = 8` on its next drain. When the channel send + /// throws `deviceError(channelMessageNotFound)` the cap branch fires: the + /// catch re-throws (rather than parking), the SendQueue's `onError` deletes + /// the PendingSend row, and the message stays `.failed` (the `.failed` + /// write made by `failMessageAndRethrow` is not remapped back to `.pending`). + /// + /// Drives the channel-send failure by dispatching `.error(code: 2)` events + /// in a tight loop so every `withPoolBackoff` retry sees the firmware + /// `NOT_FOUND` signal. Pool backoff exhausts after 3 in-loop attempts and + /// re-throws as `MessageServiceError.sessionError(.deviceError(2))`, which + /// reaches the queue catch. + @Test( + .disabled(""" + The previous shape exercised the maxChannelNotFoundRetries cap by driving \ + NOT_FOUND through resendChannelMessage. The new behaviour disambiguates by \ + calling ChannelService.fetchChannel(index:); that requires either a mockable \ + ChannelService or a session test harness that resolves getChannel(index:) \ + against the dispatched NOT_FOUND event. Re-enable once that harness lands. + """) + ) + func `channel drain treats deviceError(2) as terminal when fetchChannel confirms the channel is gone`() async throws { + let harness = try await Self.setUpChannelCapHarness(attemptCount: 7) + let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) + defer { dispatchTask.cancel() } + + await harness.queueService.hydrate() + await Self.waitForPendingSendDrained(messageID: harness.messageID, store: harness.store) + + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.status == .failed, + "cap branch must re-throw so .failed is not remapped to .pending") + let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(postDrainRows.isEmpty, + "cap branch terminal re-throw must route through onError, deleting the PendingSend row") + + dispatchTask.cancel() + await harness.queueService.shutdown() + } + + /// Negative case for the disambiguation gate. A channel envelope with + /// `attemptCount = 0` reaches `postBumpCount = 1` on its first drain. Even + /// with the same `deviceError(channelMessageNotFound)` failure the + /// disambiguation does not fire (1 < disambiguateAfterAttempts). The + /// transient branch instead remaps the status back to `.pending` and parks + /// the envelope. + @Test + func `channel drain parks deviceError(2) below disambiguateAfterAttempts`() async throws { + let harness = try await Self.setUpChannelCapHarness( + attemptCount: 0, + messageConfig: MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + ) + let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) + defer { dispatchTask.cancel() } + + await harness.queueService.hydrate() + // The shrunk pool-backoff lets the first drain bump attemptCount, + // exhaust backoff in tens of milliseconds, then the transient catch + // branch remaps the `.failed` write back to `.pending` and suspends in + // waitForTransportOpen with no further trigger. Poll for that parked + // steady-state — gated on attemptCount >= 1 so it cannot match the + // initial pre-drain `.pending` — rather than sleeping a fixed interval + // that races a loaded runner against the backoff schedule. + try await Self.waitForCondition(timeout: .seconds(15)) { + let message = try await harness.store.fetchMessage(id: harness.messageID) + let row = try await harness.store.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + return (row?.attemptCount ?? 0) >= 1 && message?.status == .pending } - /// Regression: the classifier must unwrap `MessageServiceError.sessionError` - /// so the wrapped form produced by `failMessageAndRethrow` matches. DM path - /// treats firmware code 3 (TABLE_FULL pool exhaustion) as transient. - @Test("isTransientDirectMessageError unwraps sessionError on deviceError(3)") - func directMessageClassifierUnwrapsSessionErrorOnCode3() { - let wrapped = MessageServiceError.sessionError(.deviceError(code: 3)) - #expect(ChatSendQueueService.isTransientDirectMessageError(wrapped) == true) - } + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.status == .pending, + "transient branch must remap .failed back to .pending so the bubble does not flicker") + let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(postDrainRows.contains(where: { $0.messageID == harness.messageID }), + "park branch must preserve the PendingSend row for the next transport-open retry") + let postRow = postDrainRows.first(where: { $0.messageID == harness.messageID }) + #expect((postRow?.attemptCount ?? 0) >= 1, + "top-of-drain bump must persist attemptCount >= 1 before the channel send fails") + + dispatchTask.cancel() + await harness.queueService.shutdown() + } + + /// Regression: the channel `fetchChannel` failure counter must be scoped + /// per envelope, not shared across the service. When envelope A drains + /// itself through the cap (consecutive `fetchChannel` throws), the + /// counter must not strand a value that causes the next envelope's + /// first disambiguate-path throw to immediately exceed the cap. + /// + /// With a service-wide `channelFetchFailureCounter` (single + /// `FailureCounter`), envelope A leaves it at the cap, then envelope B's + /// first drain enters disambiguate (postBumpCount >= 3), fetchChannel + /// throws, counter bumps past the cap, `failures >= cap` is true → B + /// terminal-fails on its first attempt. + /// + /// With a per-messageID counter, envelope B starts at 0, bumps to 1 on its + /// first throw, well below the cap → B parks. + /// + /// The harness uses a shrunk cap and tight pool-backoff to keep runtime + /// bounded; the behaviour under test is per-envelope counter scoping, + /// not the cap's absolute value. + @Test + func `Channel fetchChannel failure counter is per-envelope and does not cascade`() async throws { + let queueConfig = ChatSendQueueConfig(maxConsecutiveFetchChannelFailures: 2) + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) - /// Symmetric regression for the channel path: code 2 (NOT_FOUND pool - /// exhaustion / stale channel index) is transient when wrapped in - /// MessageServiceError.sessionError. - @Test("isTransientChannelMessageError unwraps sessionError on deviceError(2)") - func channelMessageClassifierUnwrapsSessionErrorOnCode2() { - let wrapped = MessageServiceError.sessionError(.deviceError(code: 2)) - #expect(ChatSendQueueService.isTransientChannelMessageError(wrapped) == true) - } + // attemptCount=7 → postBumpCount=8 on first drain, well above + // disambiguateAfterAttempts (3), so every drain attempt routes through + // fetchChannel and increments the counter. + let harness = try await Self.setUpChannelCapHarness( + attemptCount: 7, + queueConfig: queueConfig, + messageConfig: messageConfig + ) - /// A DM-path classifier must not park on code 2 — only code 3 is the DM - /// pool-exhaustion signal. This locks in the asymmetry between paths. - @Test("isTransientDirectMessageError treats deviceError(2) as terminal") - func directMessageClassifierTreatsCode2AsTerminal() { - let wrapped = MessageServiceError.sessionError(.deviceError(code: 2)) - #expect(ChatSendQueueService.isTransientDirectMessageError(wrapped) == false) + // attemptCount=2 → postBumpCount=3 on first drain, which is exactly + // at disambiguateAfterAttempts. Envelope B's first drain therefore + // enters the disambiguate path and exercises the counter. + let envelopeBSequence = 2 + let dtoB = PendingSendDTO( + id: UUID(), + radioID: harness.radioID, + messageID: harness.envelopeB.messageID, + kind: .channel, + contactID: nil, + channelIndex: harness.envelopeB.channelIndex, + isResend: harness.envelopeB.isResend, + messageText: harness.envelopeB.messageText, + messageTimestamp: harness.envelopeB.messageTimestamp, + localNodeName: harness.envelopeB.localNodeName, + sequence: envelopeBSequence, + enqueuedAt: Date(), + attemptCount: 2 + ) + try await harness.store.upsertPendingSend(dtoB) + + // Continuous .error(channelMessageNotFound) dispatching drives both + // envelopes' pool-backoff loops and never lets the counter reset via + // a successful fetchChannel. The cap path runs. + let dispatchTask = Self.startDeviceErrorDispatch( + service: harness.messageService, + code: FirmwareDeviceErrorCode.channelMessageNotFound + ) + defer { dispatchTask.cancel() } + + // Fire the transport-open trigger continuously so the 30s park after + // each drain attempt completes immediately. Without this, 16 drain + // attempts for envelope A would take >8 minutes; with it, each + // attempt is bounded by pool-backoff (~3.5s) + fetchChannel (~1.9s). + let triggerSpammer = Task.detached { [queue = harness.queueService] in + while !Task.isCancelled { + await queue.transportDidOpen() + try? await Task.sleep(for: .milliseconds(50)) + } } + defer { triggerSpammer.cancel() } - /// Symmetric guard for the channel classifier: code 3 is the DM signal, - /// not the channel one. - @Test("isTransientChannelMessageError treats deviceError(3) as terminal") - func channelMessageClassifierTreatsCode3AsTerminal() { - let wrapped = MessageServiceError.sessionError(.deviceError(code: 3)) - #expect(ChatSendQueueService.isTransientChannelMessageError(wrapped) == false) - } + await harness.queueService.hydrate() - /// Regression: the channel-cap helper recognises the raw - /// `MeshCoreError.deviceError(2)` shape produced by `withPoolBackoff`'s - /// re-throw before `MessageService` wraps it. - @Test("isChannelMessageNotFound matches raw MeshCoreError.deviceError(2)") - func isChannelMessageNotFoundMatchesRawDeviceError() { - let raw: Error = MeshCoreError.deviceError(code: FirmwareDeviceErrorCode.channelMessageNotFound) - #expect(ChatSendQueueService.isChannelMessageNotFound(raw) == true) + // Envelope A cycles through `cap` fetchChannel throws and terminal-fails. + // True terminal-fail is observable as "PendingSend row deleted by + // SendQueue.onError" — the per-cycle `.failed → .pending` oscillation + // inside `failMessageAndRethrow` + park remap is not a terminal signal. + try await Self.waitForCondition(timeout: .seconds(30)) { + let exists = try await harness.store.hasPendingSend(messageID: harness.envelopeA.messageID) + return exists == false } - /// Regression: the helper unwraps the `MessageServiceError.sessionError` - /// shape produced by `failMessageAndRethrow` in `MessageService`. - @Test("isChannelMessageNotFound unwraps MessageServiceError.sessionError(deviceError(2))") - func isChannelMessageNotFoundUnwrapsSessionError() { - let wrapped: Error = MessageServiceError.sessionError(.deviceError(code: FirmwareDeviceErrorCode.channelMessageNotFound)) - #expect(ChatSendQueueService.isChannelMessageNotFound(wrapped) == true) + let statusA = try await harness.store.fetchMessage(id: harness.envelopeA.messageID)?.status + #expect(statusA == .failed, + "Envelope A should be terminal-failed after persistent fetchChannel throws (cap reached)") + + // Envelope B should park (.pending) under the per-envelope counter; a + // service-wide counter would terminal-fail it on its first drain + // because the value is stranded at the cap. Wait long enough for at + // least one full B drain cycle to elapse so steady-state is observable. + try await Task.sleep(for: .seconds(3)) + + let statusB = try await harness.store.fetchMessage(id: harness.envelopeB.messageID)?.status + let existsB = try await harness.store.hasPendingSend(messageID: harness.envelopeB.messageID) + // True terminal-fail deletes the PendingSend row via SendQueue.onError + // and leaves the message at .failed. Park preserves the row and remaps + // .failed → .pending. Asserting on the row presence is the unambiguous + // signal — status oscillates within each drain cycle. + #expect(existsB == true, + "Envelope B's PendingSend row must survive (parked); the stranded counter caused it to be deleted as a terminal-fail. status=\(String(describing: statusB)) rowExists=\(existsB)") + + dispatchTask.cancel() + triggerSpammer.cancel() + await harness.queueService.shutdown() + } + + /// When the transport-open trigger never fires, the drain must time out + /// via `transportWaitTimeout` rather than waiting forever. Verifies the + /// message stays `.pending` and the PendingSend row survives across the + /// wait. Disabled by default because the production constant is 30 seconds + /// — exercise locally by uncommenting `.enabled` or by reducing the + /// timeout via a `#if DEBUG` hook. + @Test( + .disabled("Real-time test depends on the 30s transportWaitTimeout default; enable when reducing the constant via a #if DEBUG hook.") + ) + func `drain bounded wait re-attempts after transportWaitTimeout expires`() async throws { + let harness = try await Self.setUpChannelCapHarness(attemptCount: 0) + let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) + defer { dispatchTask.cancel() } + + await harness.queueService.hydrate() + try await Self.waitForCondition(timeout: .seconds(60)) { + let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + return (rows.first(where: { $0.messageID == harness.messageID })?.attemptCount ?? 0) >= 2 } - /// Regression: only firmware code 2 maps to the cap. Other firmware codes - /// (e.g. code 3 — the DM TABLE_FULL signal) must not be classified as - /// NOT_FOUND, since the channel cap should not trigger on DM-pool exhaustion. - @Test("isChannelMessageNotFound rejects non-NOT_FOUND device errors") - func isChannelMessageNotFoundRejectsOtherCodes() { - let wrongCode: Error = MeshCoreError.deviceError(code: FirmwareDeviceErrorCode.directMessageTableFull) - let wrappedWrongCode: Error = MessageServiceError.sessionError(.deviceError(code: FirmwareDeviceErrorCode.directMessageTableFull)) - let timeout: Error = MeshCoreError.timeout - #expect(ChatSendQueueService.isChannelMessageNotFound(wrongCode) == false) - #expect(ChatSendQueueService.isChannelMessageNotFound(wrappedWrongCode) == false) - #expect(ChatSendQueueService.isChannelMessageNotFound(timeout) == false) + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.status == .pending) + let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(postDrainRows.contains(where: { $0.messageID == harness.messageID })) + + dispatchTask.cancel() + await harness.queueService.shutdown() + } + + // MARK: - Channel-cap helpers + + /// In-memory test harness exercising the channel drain end-to-end. + /// + /// `queue` is the canonical accessor used by newer tests; `queueService` + /// remains a synonym so the existing channel-cap suites keep compiling + /// without churn. + /// + /// `envelopeA` is the channel envelope persisted by `setUpChannelCapHarness`. + /// `envelopeB` is a second envelope, not yet enqueued — callers that need + /// to exercise multi-envelope scenarios call `harness.queue.enqueueChannel(harness.envelopeB)` + /// themselves so each test controls its own drain ordering. + @MainActor + private final class ChannelCapHarness { + let queueService: ChatSendQueueService + var queue: ChatSendQueueService { + queueService } - /// End-to-end channel-cap behaviour on a high-attempt envelope. A channel - /// envelope with `attemptCount = 7` pre-loaded on its PendingSend row - /// reaches `postBumpCount = 8` on its next drain. When the channel send - /// throws `deviceError(channelMessageNotFound)` the cap branch fires: the - /// catch re-throws (rather than parking), the SendQueue's `onError` deletes - /// the PendingSend row, and the message stays `.failed` (the `.failed` - /// write made by `failMessageAndRethrow` is not remapped back to `.pending`). - /// - /// Drives the channel-send failure by dispatching `.error(code: 2)` events - /// in a tight loop so every `withPoolBackoff` retry sees the firmware - /// `NOT_FOUND` signal. Pool backoff exhausts after 3 in-loop attempts and - /// re-throws as `MessageServiceError.sessionError(.deviceError(2))`, which - /// reaches the queue catch. - @Test( - "channel drain treats deviceError(2) as terminal when fetchChannel confirms the channel is gone", - .disabled(""" - The previous shape exercised the maxChannelNotFoundRetries cap by driving \ - NOT_FOUND through resendChannelMessage. The new behaviour disambiguates by \ - calling ChannelService.fetchChannel(index:); that requires either a mockable \ - ChannelService or a session test harness that resolves getChannel(index:) \ - against the dispatched NOT_FOUND event. Re-enable once that harness lands. - """) - ) - func testChannelDrain_StaleChannelIndex_DropsEnvelopeWhenFetchChannelReturnsNil() async throws { - let harness = try await Self.setUpChannelCapHarness(attemptCount: 7) - let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) - defer { dispatchTask.cancel() } - - await harness.queueService.hydrate() - await Self.waitForPendingSendDrained(messageID: harness.messageID, store: harness.store) - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.status == .failed, - "cap branch must re-throw so .failed is not remapped to .pending") - let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(postDrainRows.isEmpty, - "cap branch terminal re-throw must route through onError, deleting the PendingSend row") - - dispatchTask.cancel() - await harness.queueService.shutdown() + let messageService: MessageService + let store: PersistenceStore + let messageID: UUID + let radioID: UUID + let envelopeA: ChannelMessageEnvelope + let envelopeB: ChannelMessageEnvelope + private var dispatchTask: Task? + + init( + queueService: ChatSendQueueService, + messageService: MessageService, + store: PersistenceStore, + messageID: UUID, + radioID: UUID, + envelopeA: ChannelMessageEnvelope, + envelopeB: ChannelMessageEnvelope + ) { + self.queueService = queueService + self.messageService = messageService + self.store = store + self.messageID = messageID + self.radioID = radioID + self.envelopeA = envelopeA + self.envelopeB = envelopeB } - /// Negative case for the disambiguation gate. A channel envelope with - /// `attemptCount = 0` reaches `postBumpCount = 1` on its first drain. Even - /// with the same `deviceError(channelMessageNotFound)` failure the - /// disambiguation does not fire (1 < disambiguateAfterAttempts). The - /// transient branch instead remaps the status back to `.pending` and parks - /// the envelope. - @Test("channel drain parks deviceError(2) below disambiguateAfterAttempts") - func testChannelDrain_PoolExhaustion_ChannelStillExists_ParksEnvelope() async throws { - let harness = try await Self.setUpChannelCapHarness( - attemptCount: 0, - messageConfig: MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - ) - let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) - defer { dispatchTask.cancel() } - - await harness.queueService.hydrate() - // The shrunk pool-backoff lets the first drain bump attemptCount, - // exhaust backoff in tens of milliseconds, then the transient catch - // branch remaps the `.failed` write back to `.pending` and suspends in - // waitForTransportOpen with no further trigger. Poll for that parked - // steady-state — gated on attemptCount >= 1 so it cannot match the - // initial pre-drain `.pending` — rather than sleeping a fixed interval - // that races a loaded runner against the backoff schedule. - try await Self.waitForCondition(timeout: .seconds(15)) { - let message = try await harness.store.fetchMessage(id: harness.messageID) - let row = try await harness.store.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - return (row?.attemptCount ?? 0) >= 1 && message?.status == .pending + /// Drive `fetchChannel(index:)` failures by streaming `.error(code:)` + /// events at the dispatcher's fixed cadence. The same event stream + /// also throws `MeshCoreError.deviceError(code)` on the channel send + /// path, so callers asserting the disambiguation step should set + /// `count` high enough to cover the in-loop pool-backoff retries + /// plus the trailing `fetchChannel` round-trips. + /// + /// `count == Int.max` dispatches indefinitely until `tearDown()` is + /// called. A finite count emits up to that many events and then + /// stops, allowing later attempts to surface their own outcomes + /// (timeouts or the channel-still-exists branch). + func setFetchChannelFailureCount(_ count: Int) async { + dispatchTask?.cancel() + let messageService = messageService + let target = count + dispatchTask = Task.detached { + let session = await messageService.sessionForTest + var emitted = 0 + while !Task.isCancelled, emitted < target { + await session.dispatchForTesting(.error(code: FirmwareDeviceErrorCode.channelMessageNotFound)) + if target != Int.max { + emitted += 1 + } + try? await Task.sleep(for: .milliseconds(20)) } - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.status == .pending, - "transient branch must remap .failed back to .pending so the bubble does not flicker") - let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(postDrainRows.contains(where: { $0.messageID == harness.messageID }), - "park branch must preserve the PendingSend row for the next transport-open retry") - let postRow = postDrainRows.first(where: { $0.messageID == harness.messageID }) - #expect((postRow?.attemptCount ?? 0) >= 1, - "top-of-drain bump must persist attemptCount >= 1 before the channel send fails") - - dispatchTask.cancel() - await harness.queueService.shutdown() + } } - /// Regression: the channel `fetchChannel` failure counter must be scoped - /// per envelope, not shared across the service. When envelope A drains - /// itself through the cap (consecutive `fetchChannel` throws), the - /// counter must not strand a value that causes the next envelope's - /// first disambiguate-path throw to immediately exceed the cap. + /// Run one drain cycle to completion: call `hydrate()` if it has not + /// yet run, then wait for the message to reach a terminal status + /// (`.failed` or `.sent`) or for the PendingSend row to be deleted. + /// Bounded at 8 seconds to keep test runtime predictable; the + /// pool-backoff loop exhausts in ~3.5s under steady error dispatch. /// - /// With a service-wide `channelFetchFailureCounter` (single - /// `FailureCounter`), envelope A leaves it at the cap, then envelope B's - /// first drain enters disambiguate (postBumpCount >= 3), fetchChannel - /// throws, counter bumps past the cap, `failures >= cap` is true → B - /// terminal-fails on its first attempt. - /// - /// With a per-messageID counter, envelope B starts at 0, bumps to 1 on its - /// first throw, well below the cap → B parks. - /// - /// The harness uses a shrunk cap and tight pool-backoff to keep runtime - /// bounded; the behaviour under test is per-envelope counter scoping, - /// not the cap's absolute value. - @Test("Channel fetchChannel failure counter is per-envelope and does not cascade") - func channelFetchFailureCounter_IsPerEnvelope_DoesNotCascade() async throws { - let queueConfig = ChatSendQueueConfig(maxConsecutiveFetchChannelFailures: 2) - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - - // attemptCount=7 → postBumpCount=8 on first drain, well above - // disambiguateAfterAttempts (3), so every drain attempt routes through - // fetchChannel and increments the counter. - let harness = try await Self.setUpChannelCapHarness( - attemptCount: 7, - queueConfig: queueConfig, - messageConfig: messageConfig - ) - - // attemptCount=2 → postBumpCount=3 on first drain, which is exactly - // at disambiguateAfterAttempts. Envelope B's first drain therefore - // enters the disambiguate path and exercises the counter. - let envelopeBSequence = 2 - let dtoB = PendingSendDTO( - id: UUID(), - radioID: harness.radioID, - messageID: harness.envelopeB.messageID, - kind: .channel, - contactID: nil, - channelIndex: harness.envelopeB.channelIndex, - isResend: harness.envelopeB.isResend, - messageText: harness.envelopeB.messageText, - messageTimestamp: harness.envelopeB.messageTimestamp, - localNodeName: harness.envelopeB.localNodeName, - sequence: envelopeBSequence, - enqueuedAt: Date(), - attemptCount: 2 - ) - try await harness.store.upsertPendingSend(dtoB) - - // Continuous .error(channelMessageNotFound) dispatching drives both - // envelopes' pool-backoff loops and never lets the counter reset via - // a successful fetchChannel. The cap path runs. - let dispatchTask = Self.startDeviceErrorDispatch( - service: harness.messageService, - code: FirmwareDeviceErrorCode.channelMessageNotFound - ) - defer { dispatchTask.cancel() } - - // Fire the transport-open trigger continuously so the 30s park after - // each drain attempt completes immediately. Without this, 16 drain - // attempts for envelope A would take >8 minutes; with it, each - // attempt is bounded by pool-backoff (~3.5s) + fetchChannel (~1.9s). - let triggerSpammer = Task.detached { [queue = harness.queueService] in - while !Task.isCancelled { - await queue.transportDidOpen() - try? await Task.sleep(for: .milliseconds(50)) - } + /// A parked envelope (row preserved with bumped `attemptCount`) is + /// not treated as terminal here — callers that expect park as a + /// success outcome should poll the row directly after this returns. + func drainOnce(timeout: Duration = .seconds(8)) async { + await queueService.hydrate() + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if let message = try? await store.fetchMessage(id: messageID), + message.status == .failed || message.status == .sent { + return } - defer { triggerSpammer.cancel() } - - await harness.queueService.hydrate() - - // Envelope A cycles through `cap` fetchChannel throws and terminal-fails. - // True terminal-fail is observable as "PendingSend row deleted by - // SendQueue.onError" — the per-cycle `.failed → .pending` oscillation - // inside `failMessageAndRethrow` + park remap is not a terminal signal. - try await Self.waitForCondition(timeout: .seconds(30)) { - let exists = try await harness.store.hasPendingSend(messageID: harness.envelopeA.messageID) - return exists == false + if let exists = try? await store.hasPendingSend(messageID: messageID), exists == false { + return } + try? await Task.sleep(for: .milliseconds(50)) + } + } - let statusA = try await harness.store.fetchMessage(id: harness.envelopeA.messageID)?.status - #expect(statusA == .failed, - "Envelope A should be terminal-failed after persistent fetchChannel throws (cap reached)") - - // Envelope B should park (.pending) under the per-envelope counter; a - // service-wide counter would terminal-fail it on its first drain - // because the value is stranded at the cap. Wait long enough for at - // least one full B drain cycle to elapse so steady-state is observable. - try await Task.sleep(for: .seconds(3)) - - let statusB = try await harness.store.fetchMessage(id: harness.envelopeB.messageID)?.status - let existsB = try await harness.store.hasPendingSend(messageID: harness.envelopeB.messageID) - // True terminal-fail deletes the PendingSend row via SendQueue.onError - // and leaves the message at .failed. Park preserves the row and remaps - // .failed → .pending. Asserting on the row presence is the unambiguous - // signal — status oscillates within each drain cycle. - #expect(existsB == true, - "Envelope B's PendingSend row must survive (parked); the stranded counter caused it to be deleted as a terminal-fail. status=\(String(describing: statusB)) rowExists=\(existsB)") - - dispatchTask.cancel() - triggerSpammer.cancel() - await harness.queueService.shutdown() + func tearDown() async { + dispatchTask?.cancel() + dispatchTask = nil + await queueService.shutdown() } + } + + /// Build a Device + Channel + Message + PendingSend with the requested + /// `attemptCount`. Returns the connected `ChatSendQueueService` (not yet + /// hydrated) and the supporting fixtures so callers can drive the drain. + /// + /// The PendingSend row is built with `isResend = true` so the drain + /// closure calls `MessageService.resendChannelMessage`, which routes + /// through `withPoolBackoff` and `failMessageAndRethrow` — matching the + /// retry path the cap is intended to bound. + private static func setUpChannelCapHarness( + attemptCount: Int?, + queueConfig: ChatSendQueueConfig = .default, + messageConfig: MessageServiceConfig = .default + ) async throws -> ChannelCapHarness { + let device = Device( + publicKey: Data(repeating: 0x11, count: 32), + nodeName: "Test Radio" + ) + let container = try PersistenceStore.createContainer(inMemory: true) + container.mainContext.insert(device) + try container.mainContext.save() + let store = PersistenceStore(modelContainer: container) + let radioID = device.radioID + + let channelIndex: UInt8 = 0 + let channel = Channel( + radioID: radioID, + index: channelIndex, + name: "Test Channel", + secret: Data(repeating: 0xAA, count: 16) + ) + container.mainContext.insert(channel) + try container.mainContext.save() + + // 200ms session timeout so each pool-backoff attempt rejects quickly + // if the test's dispatcher loop misses its window. The transport is + // connected so `transport.send` does not throw `notConnected` and the + // resulting error path runs through the dispatcher / matcher flow + // the cap is intended to exercise. + let transport = SimulatorMockTransport() + try await transport.connect() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2) + ) + let messageService = MessageService(session: session, dataStore: store, contactService: nil, config: messageConfig) - /// When the transport-open trigger never fires, the drain must time out - /// via `transportWaitTimeout` rather than waiting forever. Verifies the - /// message stays `.pending` and the PendingSend row survives across the - /// wait. Disabled by default because the production constant is 30 seconds - /// — exercise locally by uncommenting `.enabled` or by reducing the - /// timeout via a `#if DEBUG` hook. - @Test( - "drain bounded wait re-attempts after transportWaitTimeout expires", - .disabled("Real-time test depends on the 30s transportWaitTimeout default; enable when reducing the constant via a #if DEBUG hook.") + let pending = try await messageService.createPendingChannelMessage( + text: "Hello channel", + channelIndex: channelIndex, + radioID: radioID ) - func testDrain_TimeoutWithoutFire_LogsAndRequeues() async throws { - let harness = try await Self.setUpChannelCapHarness(attemptCount: 0) - let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) - defer { dispatchTask.cancel() } - - await harness.queueService.hydrate() - try await Self.waitForCondition(timeout: .seconds(60)) { - let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - return (rows.first(where: { $0.messageID == harness.messageID })?.attemptCount ?? 0) >= 2 - } - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.status == .pending) - let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(postDrainRows.contains(where: { $0.messageID == harness.messageID })) + let envelopeA = ChannelMessageEnvelope( + messageID: pending.id, + channelIndex: channelIndex, + isResend: true, + messageText: pending.text, + messageTimestamp: pending.timestamp, + localNodeName: "Test Radio" + ) + let dto = PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: envelopeA.messageID, + kind: .channel, + contactID: nil, + channelIndex: channelIndex, + isResend: true, + messageText: pending.text, + messageTimestamp: pending.timestamp, + localNodeName: "Test Radio", + sequence: 1, + enqueuedAt: Date(), + attemptCount: attemptCount + ) + try await store.upsertPendingSend(dto) + + // Second envelope for the same channel — never persisted at setup + // time so consuming tests own the enqueue and observe a clean + // `attemptCount = 0` row when they trigger it themselves. + let pendingB = try await messageService.createPendingChannelMessage( + text: "Second message", + channelIndex: channelIndex, + radioID: radioID + ) + let envelopeB = ChannelMessageEnvelope( + messageID: pendingB.id, + channelIndex: channelIndex, + isResend: false, + messageText: pendingB.text, + messageTimestamp: pendingB.timestamp, + localNodeName: "Test Radio" + ) - dispatchTask.cancel() - await harness.queueService.shutdown() + let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) + let queueService = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService(), + config: queueConfig + ) + + return ChannelCapHarness( + queueService: queueService, + messageService: messageService, + store: store, + messageID: pending.id, + radioID: radioID, + envelopeA: envelopeA, + envelopeB: envelopeB + ) + } + + /// Background dispatcher that broadcasts `.error(code:)` events at a + /// fixed cadence so every `withPoolBackoff` subscribe-and-wait window + /// in `session.sendChannelMessage` lands on a NOT_FOUND signal. Caller + /// cancels the returned task once the test reaches its assertion. + private static func startDeviceErrorDispatch( + service: MessageService, + code: UInt8, + interval: Duration = .milliseconds(20) + ) -> Task { + Task.detached { + let session = await service.sessionForTest + while !Task.isCancelled { + await session.dispatchForTesting(.error(code: code)) + try? await Task.sleep(for: interval) + } } + } + + /// Polls `block` until it returns true or the timeout elapses; otherwise + /// records a Swift Testing issue. Lighter than spinning a deadline loop + /// inside each test body. + private static func waitForCondition( + timeout: Duration, + pollEvery: Duration = .milliseconds(50), + _ block: () async throws -> Bool + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if try await block() { return } + try await Task.sleep(for: pollEvery) + } + Issue.record("waitForCondition timed out after \(timeout)") + } + + /// Polls until the PendingSend row for `messageID` is gone — i.e. the + /// cap branch (or any terminal `onError`) has deleted it. + private static func waitForPendingSendDrained( + messageID: UUID, + store: PersistenceStore, + timeout: Duration = .seconds(10) + ) async { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if let exists = try? await store.hasPendingSend(messageID: messageID), exists == false { + return + } + try? await Task.sleep(for: .milliseconds(50)) + } + Issue.record("PendingSend row for \(messageID) was not drained within \(timeout)") + } + + @Test + func `hydrate runs once per service instance`() async throws { + let store = try Self.makeStore() + let radioID = UUID() + let envelope = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope, radioID: radioID) + ) - // MARK: - Channel-cap helpers + let messageService = await Self.makeMessageService(dataStore: store) + let channelService = await Self.makeChannelService(dataStore: store) + let service = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) - /// In-memory test harness exercising the channel drain end-to-end. - /// - /// `queue` is the canonical accessor used by newer tests; `queueService` - /// remains a synonym so the existing channel-cap suites keep compiling - /// without churn. - /// - /// `envelopeA` is the channel envelope persisted by `setUpChannelCapHarness`. - /// `envelopeB` is a second envelope, not yet enqueued — callers that need - /// to exercise multi-envelope scenarios call `harness.queue.enqueueChannel(harness.envelopeB)` - /// themselves so each test controls its own drain ordering. - @MainActor - private final class ChannelCapHarness { - let queueService: ChatSendQueueService - var queue: ChatSendQueueService { queueService } - let messageService: MessageService - let store: PersistenceStore - let messageID: UUID - let radioID: UUID - let envelopeA: ChannelMessageEnvelope - let envelopeB: ChannelMessageEnvelope - private var dispatchTask: Task? - - init( - queueService: ChatSendQueueService, - messageService: MessageService, - store: PersistenceStore, - messageID: UUID, - radioID: UUID, - envelopeA: ChannelMessageEnvelope, - envelopeB: ChannelMessageEnvelope - ) { - self.queueService = queueService - self.messageService = messageService - self.store = store - self.messageID = messageID - self.radioID = radioID - self.envelopeA = envelopeA - self.envelopeB = envelopeB - } + await service.hydrate() + await service.awaitDrainCompletion() - /// Drive `fetchChannel(index:)` failures by streaming `.error(code:)` - /// events at the dispatcher's fixed cadence. The same event stream - /// also throws `MeshCoreError.deviceError(code)` on the channel send - /// path, so callers asserting the disambiguation step should set - /// `count` high enough to cover the in-loop pool-backoff retries - /// plus the trailing `fetchChannel` round-trips. - /// - /// `count == Int.max` dispatches indefinitely until `tearDown()` is - /// called. A finite count emits up to that many events and then - /// stops, allowing later attempts to surface their own outcomes - /// (timeouts or the channel-still-exists branch). - func setFetchChannelFailureCount(_ count: Int) async { - dispatchTask?.cancel() - let messageService = self.messageService - let target = count - dispatchTask = Task.detached { - let session = await messageService.sessionForTest - var emitted = 0 - while !Task.isCancelled && emitted < target { - await session.dispatchForTesting(.error(code: FirmwareDeviceErrorCode.channelMessageNotFound)) - if target != Int.max { - emitted += 1 - } - try? await Task.sleep(for: .milliseconds(20)) - } - } - } + // First hydrate drained the row. + let rowsAfterFirst = try await store.fetchPendingSends(radioID: radioID) + #expect(rowsAfterFirst.isEmpty) - /// Run one drain cycle to completion: call `hydrate()` if it has not - /// yet run, then wait for the message to reach a terminal status - /// (`.failed` or `.sent`) or for the PendingSend row to be deleted. - /// Bounded at 8 seconds to keep test runtime predictable; the - /// pool-backoff loop exhausts in ~3.5s under steady error dispatch. - /// - /// A parked envelope (row preserved with bumped `attemptCount`) is - /// not treated as terminal here — callers that expect park as a - /// success outcome should poll the row directly after this returns. - func drainOnce(timeout: Duration = .seconds(8)) async { - await queueService.hydrate() - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if let message = try? await store.fetchMessage(id: messageID), - message.status == .failed || message.status == .sent { - return - } - if let exists = try? await store.hasPendingSend(messageID: messageID), exists == false { - return - } - try? await Task.sleep(for: .milliseconds(50)) - } - } + // Insert a new row to detect whether a second hydrate would enqueue it. + let envelope2 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope2, radioID: radioID) + ) - func tearDown() async { - dispatchTask?.cancel() - dispatchTask = nil - await queueService.shutdown() - } - } + await service.hydrate() + await service.awaitDrainCompletion() + + // The second hydrate must be a no-op; the new row must survive. + let rowsAfterSecond = try await store.fetchPendingSends(radioID: radioID) + #expect(rowsAfterSecond.count == 1, + "second hydrate on same instance must not re-enqueue persisted rows") + } + + @Test + func `ChatSendQueueServiceError.notConnected description is non-empty`() { + let error = ChatSendQueueServiceError.notConnected + #expect(error.errorDescription?.isEmpty == false) + } + + /// Regression: when a DM send fails with a transient firmware code + /// (e.g. `directMessageTableFull`), `failMessageAndRethrow` writes + /// `.failed` and broadcasts the failure before the queue's + /// catch reclassifies the error and remaps the status back to + /// `.pending`. The broadcast propagates an in-memory `.failed` + /// snapshot to the UI even though the persisted state is `.pending`, + /// causing the bubble to flicker "Failed" while the queue is parked. + /// Transient errors must not broadcast `.failed` at all. + @Test + func `Transient DM send error must not broadcast .failed`() async throws { + // Tight pool-backoff so the failure surfaces fast. The invariant + // under test is independent of backoff duration. + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) + defer { Task { await harness.tearDown() } } - /// Build a Device + Channel + Message + PendingSend with the requested - /// `attemptCount`. Returns the connected `ChatSendQueueService` (not yet - /// hydrated) and the supporting fixtures so callers can drive the drain. - /// - /// The PendingSend row is built with `isResend = true` so the drain - /// closure calls `MessageService.resendChannelMessage`, which routes - /// through `withPoolBackoff` and `failMessageAndRethrow` — matching the - /// retry path the cap is intended to bound. - private static func setUpChannelCapHarness( - attemptCount: Int?, - queueConfig: ChatSendQueueConfig = .default, - messageConfig: MessageServiceConfig = .default - ) async throws -> ChannelCapHarness { - let device = Device( - publicKey: Data(repeating: 0x11, count: 32), - nodeName: "Test Radio" - ) - let container = try PersistenceStore.createContainer(inMemory: true) - container.mainContext.insert(device) - try container.mainContext.save() - let store = PersistenceStore(modelContainer: container) - let radioID = device.radioID - - let channelIndex: UInt8 = 0 - let channel = Channel( - radioID: radioID, - index: channelIndex, - name: "Test Channel", - secret: Data(repeating: 0xAA, count: 16) - ) - container.mainContext.insert(channel) - try container.mainContext.save() - - // 200ms session timeout so each pool-backoff attempt rejects quickly - // if the test's dispatcher loop misses its window. The transport is - // connected so `transport.send` does not throw `notConnected` and the - // resulting error path runs through the dispatcher / matcher flow - // the cap is intended to exercise. - let transport = SimulatorMockTransport() - try await transport.connect() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 0.2) - ) - let messageService = MessageService(session: session, dataStore: store, contactService: nil, config: messageConfig) - - let pending = try await messageService.createPendingChannelMessage( - text: "Hello channel", - channelIndex: channelIndex, - radioID: radioID - ) - - let envelopeA = ChannelMessageEnvelope( - messageID: pending.id, - channelIndex: channelIndex, - isResend: true, - messageText: pending.text, - messageTimestamp: pending.timestamp, - localNodeName: "Test Radio" - ) - let dto = PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: envelopeA.messageID, - kind: .channel, - contactID: nil, - channelIndex: channelIndex, - isResend: true, - messageText: pending.text, - messageTimestamp: pending.timestamp, - localNodeName: "Test Radio", - sequence: 1, - enqueuedAt: Date(), - attemptCount: attemptCount - ) - try await store.upsertPendingSend(dto) - - // Second envelope for the same channel — never persisted at setup - // time so consuming tests own the enqueue and observe a clean - // `attemptCount = 0` row when they trigger it themselves. - let pendingB = try await messageService.createPendingChannelMessage( - text: "Second message", - channelIndex: channelIndex, - radioID: radioID - ) - let envelopeB = ChannelMessageEnvelope( - messageID: pendingB.id, - channelIndex: channelIndex, - isResend: false, - messageText: pendingB.text, - messageTimestamp: pendingB.timestamp, - localNodeName: "Test Radio" - ) - - let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) - let queueService = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService(), - config: queueConfig - ) - - return ChannelCapHarness( - queueService: queueService, - messageService: messageService, - store: store, - messageID: pending.id, - radioID: radioID, - envelopeA: envelopeA, - envelopeB: envelopeB - ) - } + let statusEvents = harness.messageService.statusEvents() - /// Background dispatcher that broadcasts `.error(code:)` events at a - /// fixed cadence so every `withPoolBackoff` subscribe-and-wait window - /// in `session.sendChannelMessage` lands on a NOT_FOUND signal. Caller - /// cancels the returned task once the test reaches its assertion. - private static func startDeviceErrorDispatch( - service: MessageService, - code: UInt8, - interval: Duration = .milliseconds(20) - ) -> Task { - Task.detached { - let session = await service.sessionForTest - while !Task.isCancelled { - await session.dispatchForTesting(.error(code: code)) - try? await Task.sleep(for: interval) - } - } - } + // Configure session to throw a transient error code on send. + // The persisted PendingSend row is drained by hydrate() inside + // drainOnceAllowingPark; the drain should park the envelope on + // waitForTransportOpen rather than terminal-fail it. + await harness.setSendDirectMessageFailureMode( + .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) + ) - /// Polls `block` until it returns true or the timeout elapses; otherwise - /// records a Swift Testing issue. Lighter than spinning a deadline loop - /// inside each test body. - private static func waitForCondition( - timeout: Duration, - pollEvery: Duration = .milliseconds(50), - _ block: () async throws -> Bool - ) async throws { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if try await block() { return } - try await Task.sleep(for: pollEvery) - } - Issue.record("waitForCondition timed out after \(timeout)") - } + await harness.drainOnceAllowingPark() + // drainOnceAllowingPark returns as soon as attemptCount >= 1 + // (the top-of-drain bump). The transient failure does not fire + // until withPoolBackoff exhausts. Wait past the backoff window so + // failMessageAndRethrow has definitively run before we assert. + try await Task.sleep(for: .milliseconds(300)) + + let observed = await harness.messageService.drainStatusEvents(statusEvents).failedIDs + #expect(observed.count == 0, "Transient errors must not broadcast .failed; got \(observed.count) events") + } + + /// Complement to `transientDMError_DoesNotFireFailedHandler`. A truly + /// terminal DM send error must still surface to the UI via the `.failed` + /// broadcast, exactly once. Guards against accidentally suppressing the + /// broadcast on the queue-routed path given that `failMessageAndRethrow` + /// does not broadcast itself. + @Test + func `Terminal DM send error broadcasts .failed exactly once`() async throws { + let harness = try await Self.setUpDMHarness() + defer { Task { await harness.tearDown() } } + + let statusEvents = harness.messageService.statusEvents() + + // Non-transient firmware code so the drain's classifier treats the + // error as terminal and rethrows from the inner catch, hitting the + // outer catch in the queue closure that calls `notifyMessageFailed`. + await harness.setSendDirectMessageFailureMode(.invalidInput) + + await harness.drainOnce() + + let ids = await harness.messageService.drainStatusEvents(statusEvents).failedIDs + #expect(ids.count == 1, "Terminal errors must broadcast .failed exactly once; got \(ids.count)") + #expect(ids.first == harness.messageID, ".failed must carry the failed envelope's messageID") + } + + /// A container built after the connection already reached `.ready` receives + /// the edge through `observeConnectionState`'s initial value: an already-ready + /// initial state must fire the trigger so a drain parked in + /// `withCooperativeTimeout` wakes (observable as a second attemptCount bump) + /// instead of waiting out `transportWaitTimeout`. + @Test + func `observeConnectionState fires the trigger for an already-ready initial state`() async throws { + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) + defer { Task { await harness.tearDown() } } - /// Polls until the PendingSend row for `messageID` is gone — i.e. the - /// cap branch (or any terminal `onError`) has deleted it. - private static func waitForPendingSendDrained( - messageID: UUID, - store: PersistenceStore, - timeout: Duration = .seconds(10) - ) async { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if let exists = try? await store.hasPendingSend(messageID: messageID), exists == false { - return - } - try? await Task.sleep(for: .milliseconds(50)) - } - Issue.record("PendingSend row for \(messageID) was not drained within \(timeout)") - } + await harness.setSendDirectMessageFailureMode( + .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) + ) + await harness.drainOnceAllowingPark() - @Test("hydrate runs once per service instance") - func hydrateRunsOncePerInstance() async throws { - let store = try Self.makeStore() - let radioID = UUID() - let envelope = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope, radioID: radioID) - ) - - let messageService = await Self.makeMessageService(dataStore: store) - let channelService = await Self.makeChannelService(dataStore: store) - let service = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - await service.hydrate() - await service.awaitDrainCompletion() - - // First hydrate drained the row. - let rowsAfterFirst = try await store.fetchPendingSends(radioID: radioID) - #expect(rowsAfterFirst.isEmpty) - - // Insert a new row to detect whether a second hydrate would enqueue it. - let envelope2 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope2, radioID: radioID) - ) - - await service.hydrate() - await service.awaitDrainCompletion() - - // The second hydrate must be a no-op; the new row must survive. - let rowsAfterSecond = try await store.fetchPendingSends(radioID: radioID) - #expect(rowsAfterSecond.count == 1, - "second hydrate on same instance must not re-enqueue persisted rows") - } + harness.queue.observeConnectionState( + initial: .ready, + events: AsyncStream { $0.finish() } + ) - @Test("ChatSendQueueServiceError.notConnected description is non-empty") - func notConnectedErrorHasDescription() { - let error = ChatSendQueueServiceError.notConnected - #expect(error.errorDescription?.isEmpty == false) + try await Self.waitForCondition(timeout: .seconds(10)) { + let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + return (row?.attemptCount ?? 0) >= 2 } + } + + /// `.connected` (link up, initial sync not yet cleared) must NOT wake a + /// parked drain: hydrated sends stay parked through the sync window so they + /// do not contend with sync's reads. The drain only wakes once `.ready` + /// arrives on the stream. + @Test + func `observeConnectionState keeps a drain parked through .connected and wakes it on .ready`() async throws { + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) + defer { Task { await harness.tearDown() } } + + await harness.setSendDirectMessageFailureMode( + .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) + ) - /// Regression: when a DM send fails with a transient firmware code - /// (e.g. `directMessageTableFull`), `failMessageAndRethrow` writes - /// `.failed` and broadcasts the failure before the queue's - /// catch reclassifies the error and remaps the status back to - /// `.pending`. The broadcast propagates an in-memory `.failed` - /// snapshot to the UI even though the persisted state is `.pending`, - /// causing the bubble to flicker "Failed" while the queue is parked. - /// Transient errors must not broadcast `.failed` at all. - @Test("Transient DM send error must not broadcast .failed") - func transientDMError_DoesNotFireFailedHandler() async throws { - // Tight pool-backoff so the failure surfaces fast. The invariant - // under test is independent of backoff duration. - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) - defer { Task { await harness.tearDown() } } - - let statusEvents = harness.messageService.statusEvents() - - // Configure session to throw a transient error code on send. - // The persisted PendingSend row is drained by hydrate() inside - // drainOnceAllowingPark; the drain should park the envelope on - // waitForTransportOpen rather than terminal-fail it. - await harness.setSendDirectMessageFailureMode( - .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) - ) - - await harness.drainOnceAllowingPark() - // drainOnceAllowingPark returns as soon as attemptCount >= 1 - // (the top-of-drain bump). The transient failure does not fire - // until withPoolBackoff exhausts. Wait past the backoff window so - // failMessageAndRethrow has definitively run before we assert. - try await Task.sleep(for: .milliseconds(300)) - - let observed = await harness.messageService.drainStatusEvents(statusEvents).failedIDs - #expect(observed.count == 0, "Transient errors must not broadcast .failed; got \(observed.count) events") + let (stream, continuation) = AsyncStream.makeStream(of: DeviceConnectionState.self) + harness.queue.observeConnectionState(initial: .connected, events: stream) + + await harness.drainOnceAllowingPark() + + // `.connected` alone must not arm the trigger: the parked drain stays at one attempt. + continuation.yield(.connected) + try await Task.sleep(for: .seconds(1)) + let parkedRow = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + #expect(parkedRow?.attemptCount == 1, ".connected must not wake the drain during the sync window") + + // Entering `.ready` fires the trigger and the drain wakes for its second attempt. + continuation.yield(.ready) + continuation.finish() + try await Self.waitForCondition(timeout: .seconds(10)) { + let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + return (row?.attemptCount ?? 0) >= 2 } + } + + /// Edge-trigger contract across the connected ramp: a stream driving + /// disconnected, connected, syncing, ready crosses into `.ready` once, so + /// the trigger fires once on that entry. The signal's armed-bit consumption + /// makes a double fire observable: re-arming on a non-`.ready` rung would + /// wake the second park without a new edge, surfacing as a third + /// attemptCount bump. + @Test + func `connection-state ramp fires the trigger exactly once`() async throws { + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) + defer { Task { await harness.tearDown() } } - /// Complement to `transientDMError_DoesNotFireFailedHandler`. A truly - /// terminal DM send error must still surface to the UI via the `.failed` - /// broadcast, exactly once. Guards against accidentally suppressing the - /// broadcast on the queue-routed path given that `failMessageAndRethrow` - /// does not broadcast itself. - @Test("Terminal DM send error broadcasts .failed exactly once") - func terminalDMError_FiresFailedHandlerExactlyOnce() async throws { - let harness = try await Self.setUpDMHarness() - defer { Task { await harness.tearDown() } } + await harness.setSendDirectMessageFailureMode( + .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) + ) - let statusEvents = harness.messageService.statusEvents() + let (stream, continuation) = AsyncStream.makeStream(of: DeviceConnectionState.self) + harness.queue.observeConnectionState(initial: .disconnected, events: stream) - // Non-transient firmware code so the drain's classifier treats the - // error as terminal and rethrows from the inner catch, hitting the - // outer catch in the queue closure that calls `notifyMessageFailed`. - await harness.setSendDirectMessageFailureMode(.invalidInput) + await harness.drainOnceAllowingPark() - await harness.drainOnce() + continuation.yield(.disconnected) + continuation.yield(.connected) + continuation.yield(.syncing) + continuation.yield(.ready) + continuation.finish() - let ids = await harness.messageService.drainStatusEvents(statusEvents).failedIDs - #expect(ids.count == 1, "Terminal errors must broadcast .failed exactly once; got \(ids.count)") - #expect(ids.first == harness.messageID, ".failed must carry the failed envelope's messageID") + // The single edge wakes the parked drain once: attempt 2 runs, + // fails transiently, and parks again. + try await Self.waitForCondition(timeout: .seconds(10)) { + let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + return (row?.attemptCount ?? 0) >= 2 } - /// A container built after the connection already reached `.ready` receives - /// the edge through `observeConnectionState`'s initial value: an already-ready - /// initial state must fire the trigger so a drain parked in - /// `withCooperativeTimeout` wakes (observable as a second attemptCount bump) - /// instead of waiting out `transportWaitTimeout`. - @Test("observeConnectionState fires the trigger for an already-ready initial state") - func observeConnectionState_ReadyInitial_WakesParkedDrain() async throws { - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) - defer { Task { await harness.tearDown() } } - - await harness.setSendDirectMessageFailureMode( - .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) - ) - await harness.drainOnceAllowingPark() - - harness.queue.observeConnectionState( - initial: .ready, - events: AsyncStream { $0.finish() } - ) - - try await Self.waitForCondition(timeout: .seconds(10)) { - let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - return (row?.attemptCount ?? 0) >= 2 - } + // Steady state: no further wake without a new edge. + try await Task.sleep(for: .seconds(1)) + let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + #expect(row?.attemptCount == 2, + "the connected ramp must fire the transport-open trigger exactly once") + } + + // MARK: - DM drain helpers + + /// Distinct DM send-failure modes the harness can simulate. Each maps + /// to a different code path inside the drain closure: + /// + /// - `.normal`: no events dispatched. `session.sendMessage` waits past + /// the session's `defaultTimeout` and throws `MeshCoreError.timeout`, + /// classified transient → park branch. + /// - `.deviceError(code)`: dispatches `.error(code:)` continuously. The + /// classifier treats `FirmwareDeviceErrorCode.directMessageTableFull` + /// as transient and every other code as terminal. + /// - `.connectionLost`: tears down the transport so the next send fails + /// with `MeshCoreError.notConnected` (which `isTransientError` + /// classifies as transient, parking the envelope). One-way: the mock + /// transport's AsyncStream continuation is finished on disconnect and + /// cannot be revived, so `.connectionLost` must be the final mode set + /// before `tearDown()`. + /// - `.invalidInput`: dispatches an illegal-argument firmware code so the + /// send fails with a non-transient `deviceError`, exercising the + /// terminal-error path without touching MeshCore internals. + enum DirectSendFailureMode { + case normal + case deviceError(UInt8) + case connectionLost + case invalidInput + } + + /// DM-drain harness mirroring `ChannelCapHarness` for the + /// `ChatSendQueueService` direct-message path. Owns the transport and + /// dispatcher task so tests can express failure modes declaratively. + @MainActor + private final class DMHarness { + let queue: ChatSendQueueService + let messageService: MessageService + let dataStore: PersistenceStore + let session: MeshCoreSession + let envelope: DirectMessageEnvelope + let radioID: UUID + let messageID: UUID + private let transport: SimulatorMockTransport + private var dispatchTask: Task? + + init( + queue: ChatSendQueueService, + messageService: MessageService, + dataStore: PersistenceStore, + session: MeshCoreSession, + envelope: DirectMessageEnvelope, + radioID: UUID, + transport: SimulatorMockTransport + ) { + self.queue = queue + self.messageService = messageService + self.dataStore = dataStore + self.session = session + self.envelope = envelope + self.radioID = radioID + messageID = envelope.messageID + self.transport = transport } - /// `.connected` (link up, initial sync not yet cleared) must NOT wake a - /// parked drain: hydrated sends stay parked through the sync window so they - /// do not contend with sync's reads. The drain only wakes once `.ready` - /// arrives on the stream. - @Test("observeConnectionState keeps a drain parked through .connected and wakes it on .ready") - func observeConnectionState_ConnectedDoesNotWake_ReadyDoes() async throws { - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) - defer { Task { await harness.tearDown() } } - - await harness.setSendDirectMessageFailureMode( - .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) - ) - - let (stream, continuation) = AsyncStream.makeStream(of: DeviceConnectionState.self) - harness.queue.observeConnectionState(initial: .connected, events: stream) - - await harness.drainOnceAllowingPark() - - // `.connected` alone must not arm the trigger: the parked drain stays at one attempt. - continuation.yield(.connected) - try await Task.sleep(for: .seconds(1)) - let parkedRow = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - #expect(parkedRow?.attemptCount == 1, ".connected must not wake the drain during the sync window") - - // Entering `.ready` fires the trigger and the drain wakes for its second attempt. - continuation.yield(.ready) - continuation.finish() - try await Self.waitForCondition(timeout: .seconds(10)) { - let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - return (row?.attemptCount ?? 0) >= 2 + /// Configure the next `sendDirectMessage` to resolve in a specific + /// failure shape. Stops any previously-running dispatcher first so + /// modes do not stack. Subsequent drain attempts inherit whatever + /// mode is active until `.normal` is reapplied or the harness is + /// torn down. `.connectionLost` is one-way: once the transport is + /// disconnected, no subsequent mode (including `.normal`) can revive + /// it — callers must treat `.connectionLost` as the final mode before + /// `tearDown()`. + func setSendDirectMessageFailureMode(_ mode: DirectSendFailureMode) async { + dispatchTask?.cancel() + dispatchTask = nil + switch mode { + case .normal: + break + case let .deviceError(code): + let session = session + dispatchTask = Task.detached { + while !Task.isCancelled { + await session.dispatchForTesting(.error(code: code)) + try? await Task.sleep(for: .milliseconds(20)) + } } - } - - /// Edge-trigger contract across the connected ramp: a stream driving - /// disconnected, connected, syncing, ready crosses into `.ready` once, so - /// the trigger fires once on that entry. The signal's armed-bit consumption - /// makes a double fire observable: re-arming on a non-`.ready` rung would - /// wake the second park without a new edge, surfacing as a third - /// attemptCount bump. - @Test("connection-state ramp fires the trigger exactly once") - func observeConnectionState_ConnectedRamp_FiresExactlyOnce() async throws { - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) - defer { Task { await harness.tearDown() } } - - await harness.setSendDirectMessageFailureMode( - .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) - ) - - let (stream, continuation) = AsyncStream.makeStream(of: DeviceConnectionState.self) - harness.queue.observeConnectionState(initial: .disconnected, events: stream) - - await harness.drainOnceAllowingPark() - - continuation.yield(.disconnected) - continuation.yield(.connected) - continuation.yield(.syncing) - continuation.yield(.ready) - continuation.finish() - - // The single edge wakes the parked drain once: attempt 2 runs, - // fails transiently, and parks again. - try await Self.waitForCondition(timeout: .seconds(10)) { - let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - return (row?.attemptCount ?? 0) >= 2 + case .connectionLost: + await transport.disconnect() + case .invalidInput: + let session = session + dispatchTask = Task.detached { + while !Task.isCancelled { + await session.dispatchForTesting(.error(code: ProtocolError.illegalArgument.rawValue)) + try? await Task.sleep(for: .milliseconds(20)) + } } - - // Steady state: no further wake without a new edge. - try await Task.sleep(for: .seconds(1)) - let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - #expect(row?.attemptCount == 2, - "the connected ramp must fire the transport-open trigger exactly once") + } } - // MARK: - DM drain helpers - - /// Distinct DM send-failure modes the harness can simulate. Each maps - /// to a different code path inside the drain closure: - /// - /// - `.normal`: no events dispatched. `session.sendMessage` waits past - /// the session's `defaultTimeout` and throws `MeshCoreError.timeout`, - /// classified transient → park branch. - /// - `.deviceError(code)`: dispatches `.error(code:)` continuously. The - /// classifier treats `FirmwareDeviceErrorCode.directMessageTableFull` - /// as transient and every other code as terminal. - /// - `.connectionLost`: tears down the transport so the next send fails - /// with `MeshCoreError.notConnected` (which `isTransientError` - /// classifies as transient, parking the envelope). One-way: the mock - /// transport's AsyncStream continuation is finished on disconnect and - /// cannot be revived, so `.connectionLost` must be the final mode set - /// before `tearDown()`. - /// - `.invalidInput`: dispatches an illegal-argument firmware code so the - /// send fails with a non-transient `deviceError`, exercising the - /// terminal-error path without touching MeshCore internals. - enum DirectSendFailureMode { - case normal - case deviceError(UInt8) - case connectionLost - case invalidInput - } - - /// DM-drain harness mirroring `ChannelCapHarness` for the - /// `ChatSendQueueService` direct-message path. Owns the transport and - /// dispatcher task so tests can express failure modes declaratively. - @MainActor - private final class DMHarness { - let queue: ChatSendQueueService - let messageService: MessageService - let dataStore: PersistenceStore - let session: MeshCoreSession - let envelope: DirectMessageEnvelope - let radioID: UUID - let messageID: UUID - private let transport: SimulatorMockTransport - private var dispatchTask: Task? - - init( - queue: ChatSendQueueService, - messageService: MessageService, - dataStore: PersistenceStore, - session: MeshCoreSession, - envelope: DirectMessageEnvelope, - radioID: UUID, - transport: SimulatorMockTransport - ) { - self.queue = queue - self.messageService = messageService - self.dataStore = dataStore - self.session = session - self.envelope = envelope - self.radioID = radioID - self.messageID = envelope.messageID - self.transport = transport + /// Drive one drain cycle: `hydrate()` (or `enqueueDM`) plus a bounded + /// wait for either a terminal status (`.failed` / `.sent`) or for + /// the PendingSend row to be deleted. Records a Swift Testing issue + /// if the envelope parks instead — callers that expect park as a + /// success outcome should use `drainOnceAllowingPark()`. + func drainOnce(timeout: Duration = .seconds(8)) async { + await queue.hydrate() + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if let message = try? await dataStore.fetchMessage(id: messageID) { + if message.status == .failed || message.status == .sent { + return + } } - - /// Configure the next `sendDirectMessage` to resolve in a specific - /// failure shape. Stops any previously-running dispatcher first so - /// modes do not stack. Subsequent drain attempts inherit whatever - /// mode is active until `.normal` is reapplied or the harness is - /// torn down. `.connectionLost` is one-way: once the transport is - /// disconnected, no subsequent mode (including `.normal`) can revive - /// it — callers must treat `.connectionLost` as the final mode before - /// `tearDown()`. - func setSendDirectMessageFailureMode(_ mode: DirectSendFailureMode) async { - dispatchTask?.cancel() - dispatchTask = nil - switch mode { - case .normal: - break - case .deviceError(let code): - let session = self.session - dispatchTask = Task.detached { - while !Task.isCancelled { - await session.dispatchForTesting(.error(code: code)) - try? await Task.sleep(for: .milliseconds(20)) - } - } - case .connectionLost: - await transport.disconnect() - case .invalidInput: - let session = self.session - dispatchTask = Task.detached { - while !Task.isCancelled { - await session.dispatchForTesting(.error(code: ProtocolError.illegalArgument.rawValue)) - try? await Task.sleep(for: .milliseconds(20)) - } - } - } - } - - /// Drive one drain cycle: `hydrate()` (or `enqueueDM`) plus a bounded - /// wait for either a terminal status (`.failed` / `.sent`) or for - /// the PendingSend row to be deleted. Records a Swift Testing issue - /// if the envelope parks instead — callers that expect park as a - /// success outcome should use `drainOnceAllowingPark()`. - func drainOnce(timeout: Duration = .seconds(8)) async { - await queue.hydrate() - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if let message = try? await dataStore.fetchMessage(id: messageID) { - if message.status == .failed || message.status == .sent { - return - } - } - if let exists = try? await dataStore.hasPendingSend(messageID: messageID), exists == false { - return - } - try? await Task.sleep(for: .milliseconds(50)) - } - Issue.record("DM drain did not reach a terminal outcome within \(timeout)") + if let exists = try? await dataStore.hasPendingSend(messageID: messageID), exists == false { + return } + try? await Task.sleep(for: .milliseconds(50)) + } + Issue.record("DM drain did not reach a terminal outcome within \(timeout)") + } - /// Variant of `drainOnce` for transient-failure scenarios where a - /// parked envelope (`attemptCount > 0`, row still present, status - /// remapped to `.pending`) is the expected outcome. Returns as soon - /// as the row is observed parked or terminal. - func drainOnceAllowingPark(timeout: Duration = .seconds(8)) async { - await queue.hydrate() - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - let rows = (try? await dataStore.fetchPendingSends(radioID: radioID)) ?? [] - if let row = rows.first(where: { $0.messageID == messageID }), - (row.attemptCount ?? 0) >= 1 { - return - } - if let exists = try? await dataStore.hasPendingSend(messageID: messageID), exists == false { - return - } - try? await Task.sleep(for: .milliseconds(50)) - } + /// Variant of `drainOnce` for transient-failure scenarios where a + /// parked envelope (`attemptCount > 0`, row still present, status + /// remapped to `.pending`) is the expected outcome. Returns as soon + /// as the row is observed parked or terminal. + func drainOnceAllowingPark(timeout: Duration = .seconds(8)) async { + await queue.hydrate() + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + let rows = await (try? dataStore.fetchPendingSends(radioID: radioID)) ?? [] + if let row = rows.first(where: { $0.messageID == messageID }), + (row.attemptCount ?? 0) >= 1 { + return } - - func tearDown() async { - dispatchTask?.cancel() - dispatchTask = nil - await queue.shutdown() + if let exists = try? await dataStore.hasPendingSend(messageID: messageID), exists == false { + return } + try? await Task.sleep(for: .milliseconds(50)) + } } - /// Builds a DM-side counterpart to `setUpChannelCapHarness`. Inserts a - /// `Device` + `Contact` + pending `Message`, persists a single - /// `PendingSend` row, wires the real session/queue/services together, - /// and returns the harness with no hydration yet performed so the test - /// can configure the failure mode before triggering the drain. - /// - /// The session is configured with a 200ms `defaultTimeout` so each - /// `withPoolBackoff` cycle in `MessageService.sendDirectMessage` resolves - /// quickly when the dispatcher loop misses its window. - private static func setUpDMHarness( - queueConfig: ChatSendQueueConfig = .default, - messageConfig: MessageServiceConfig = .default - ) async throws -> DMHarness { - let device = Device( - publicKey: Data(repeating: 0x22, count: 32), - nodeName: "Test Radio" - ) - let container = try PersistenceStore.createContainer(inMemory: true) - container.mainContext.insert(device) - try container.mainContext.save() - let dataStore = PersistenceStore(modelContainer: container) - let radioID = device.radioID - - let contact = Contact( - radioID: radioID, - publicKey: Data(repeating: 0x33, count: 32), - name: "Test Contact" - ) - container.mainContext.insert(contact) - try container.mainContext.save() - let contactDTO = try #require(try await dataStore.fetchContact(id: contact.id)) - - let transport = SimulatorMockTransport() - try await transport.connect() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 0.2) - ) - let messageService = MessageService(session: session, dataStore: dataStore, contactService: nil, config: messageConfig) - await messageService.installSelfInfoForTest() - let pending = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) - - let envelope = DirectMessageEnvelope(messageID: pending.id, contactID: contactDTO.id) - _ = try await dataStore.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope, radioID: radioID) - ) - - let channelService = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - let queue = ChatSendQueueService( - radioID: radioID, - dataStore: dataStore, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService(), - config: queueConfig - ) - - return DMHarness( - queue: queue, - messageService: messageService, - dataStore: dataStore, - session: session, - envelope: envelope, - radioID: radioID, - transport: transport - ) + func tearDown() async { + dispatchTask?.cancel() + dispatchTask = nil + await queue.shutdown() } + } + + /// Builds a DM-side counterpart to `setUpChannelCapHarness`. Inserts a + /// `Device` + `Contact` + pending `Message`, persists a single + /// `PendingSend` row, wires the real session/queue/services together, + /// and returns the harness with no hydration yet performed so the test + /// can configure the failure mode before triggering the drain. + /// + /// The session is configured with a 200ms `defaultTimeout` so each + /// `withPoolBackoff` cycle in `MessageService.sendDirectMessage` resolves + /// quickly when the dispatcher loop misses its window. + private static func setUpDMHarness( + queueConfig: ChatSendQueueConfig = .default, + messageConfig: MessageServiceConfig = .default + ) async throws -> DMHarness { + let device = Device( + publicKey: Data(repeating: 0x22, count: 32), + nodeName: "Test Radio" + ) + let container = try PersistenceStore.createContainer(inMemory: true) + container.mainContext.insert(device) + try container.mainContext.save() + let dataStore = PersistenceStore(modelContainer: container) + let radioID = device.radioID + + let contact = Contact( + radioID: radioID, + publicKey: Data(repeating: 0x33, count: 32), + name: "Test Contact" + ) + container.mainContext.insert(contact) + try container.mainContext.save() + let contactDTO = try #require(try await dataStore.fetchContact(id: contact.id)) + + let transport = SimulatorMockTransport() + try await transport.connect() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2) + ) + let messageService = MessageService(session: session, dataStore: dataStore, contactService: nil, config: messageConfig) + await messageService.installSelfInfoForTest() + let pending = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) + + let envelope = DirectMessageEnvelope(messageID: pending.id, contactID: contactDTO.id) + _ = try await dataStore.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope, radioID: radioID) + ) + + let channelService = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + let queue = ChatSendQueueService( + radioID: radioID, + dataStore: dataStore, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService(), + config: queueConfig + ) + + return DMHarness( + queue: queue, + messageService: messageService, + dataStore: dataStore, + session: session, + envelope: envelope, + radioID: radioID, + transport: transport + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift index 488afe82..058ee362 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift @@ -1,94 +1,93 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing /// Service-level coverage for `ContactService.syncContacts` after it was restructured to /// batch-persist in a single transaction. Uses the real in-memory `PersistenceStore` so the /// optimized `batchSaveContacts` override (not the protocol default) is exercised end to end. @Suite("ContactService Sync Tests") struct ContactServiceSyncTests { + private func publicKey(_ byte: UInt8) -> Data { + Data(repeating: byte, count: ProtocolLimits.publicKeySize) + } - private func publicKey(_ byte: UInt8) -> Data { - Data(repeating: byte, count: ProtocolLimits.publicKeySize) - } - - private func meshContact(_ keyByte: UInt8, name: String, lastModified: Date = Date(timeIntervalSince1970: 0)) -> MeshContact { - let key = publicKey(keyByte) - return MeshContact( - id: key.uppercaseHexString(), - publicKey: key, - type: .chat, - flags: ContactFlags(rawValue: 0), - outPathLength: 0, - outPath: Data(), - advertisedName: name, - lastAdvertisement: Date(timeIntervalSince1970: 0), - latitude: 0, - longitude: 0, - lastModified: lastModified - ) - } + private func meshContact(_ keyByte: UInt8, name: String, lastModified: Date = Date(timeIntervalSince1970: 0)) -> MeshContact { + let key = publicKey(keyByte) + return MeshContact( + id: key.uppercaseHexString(), + publicKey: key, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: name, + lastAdvertisement: Date(timeIntervalSince1970: 0), + latitude: 0, + longitude: 0, + lastModified: lastModified + ) + } - private func contactFrame(_ keyByte: UInt8, name: String) -> ContactFrame { - ContactFrame( - publicKey: publicKey(keyByte), - type: .chat, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - } + private func contactFrame(_ keyByte: UInt8, name: String) -> ContactFrame { + ContactFrame( + publicKey: publicKey(keyByte), + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + } - @Test("Full sync persists all device contacts and prunes locals not on device") - func fullSyncPersistsAndPrunes() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - // A stale local contact that the device no longer reports. - _ = try await store.saveContact(radioID: radioID, from: contactFrame(0xDD, name: "Stale")) + @Test + func `Full sync persists all device contacts and prunes locals not on device`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + // A stale local contact that the device no longer reports. + _ = try await store.saveContact(radioID: radioID, from: contactFrame(0xDD, name: "Stale")) - let session = MockMeshCoreSession() - let lastModified = Date(timeIntervalSince1970: 1_700_000_000) - await session.setStubbedContacts([ - meshContact(0xAA, name: "Alice", lastModified: lastModified), - meshContact(0xBB, name: "Bob") - ]) + let session = MockMeshCoreSession() + let lastModified = Date(timeIntervalSince1970: 1_700_000_000) + await session.setStubbedContacts([ + meshContact(0xAA, name: "Alice", lastModified: lastModified), + meshContact(0xBB, name: "Bob") + ]) - let service = ContactService(session: session, dataStore: store, syncCoordinator: nil, cleanupCoordinator: nil) - let result = try await service.syncContacts(radioID: radioID, since: nil) + let service = ContactService(session: session, dataStore: store, syncCoordinator: nil, cleanupCoordinator: nil) + let result = try await service.syncContacts(radioID: radioID, since: nil) - #expect(result.contactsReceived == 2) - #expect(result.isIncremental == false) - #expect(result.lastSyncTimestamp == UInt32(lastModified.timeIntervalSince1970)) + #expect(result.contactsReceived == 2) + #expect(result.isIncremental == false) + #expect(result.lastSyncTimestamp == UInt32(lastModified.timeIntervalSince1970)) - let stored = try await store.fetchContacts(radioID: radioID) - #expect(Set(stored.map(\.name)) == ["Alice", "Bob"]) - // The stale contact was pruned on full sync. - #expect(try await store.fetchContact(radioID: radioID, publicKey: publicKey(0xDD)) == nil) - } + let stored = try await store.fetchContacts(radioID: radioID) + #expect(Set(stored.map(\.name)) == ["Alice", "Bob"]) + // The stale contact was pruned on full sync. + #expect(try await store.fetchContact(radioID: radioID, publicKey: publicKey(0xDD)) == nil) + } - @Test("Incremental sync upserts without pruning unseen locals") - func incrementalSyncDoesNotPrune() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - // A local contact not present in this incremental batch must survive. - _ = try await store.saveContact(radioID: radioID, from: contactFrame(0xDD, name: "Existing")) + @Test + func `Incremental sync upserts without pruning unseen locals`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + // A local contact not present in this incremental batch must survive. + _ = try await store.saveContact(radioID: radioID, from: contactFrame(0xDD, name: "Existing")) - let session = MockMeshCoreSession() - await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) - let service = ContactService(session: session, dataStore: store, syncCoordinator: nil, cleanupCoordinator: nil) - let result = try await service.syncContacts(radioID: radioID, since: Date(timeIntervalSince1970: 100)) + let service = ContactService(session: session, dataStore: store, syncCoordinator: nil, cleanupCoordinator: nil) + let result = try await service.syncContacts(radioID: radioID, since: Date(timeIntervalSince1970: 100)) - #expect(result.contactsReceived == 1) - #expect(result.isIncremental == true) + #expect(result.contactsReceived == 1) + #expect(result.isIncremental == true) - let stored = try await store.fetchContacts(radioID: radioID) - #expect(Set(stored.map(\.name)) == ["Alice", "Existing"]) - } + let stored = try await store.fetchContacts(radioID: radioID) + #expect(Set(stored.map(\.name)) == ["Alice", "Existing"]) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift index c0dc1114..05a8f3bc 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift @@ -1,792 +1,976 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("ContactService Tests") struct ContactServiceTests { - - // MARK: - Test Constants - - // Sync result test values - private let testContactsReceived = 5 - private let testSyncTimestamp: UInt32 = 1234567890 - private let maxContactsReceived = Int.max - private let maxSyncTimestamp = UInt32.max - - // Contact test values - private let testPublicKey = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, - 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, - 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, - 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20]) - private let testTimestamp: UInt32 = 1700000000 - private let testModifiedTimestamp: UInt32 = 1700000100 - private let testFlags: UInt8 = 0x01 - private let invalidContactType: UInt8 = 0xFF - private let testOutPath = Data([0xAA, 0xBB, 0xCC, 0xDD]) - private let floodRoutingPath = Data(repeating: 0xFF, count: 3) - - // MARK: - ContactSyncResult Tests - - @Test("ContactSyncResult initializes correctly") - func contactSyncResultInitializes() { - let result = ContactSyncResult( - contactsReceived: testContactsReceived, - lastSyncTimestamp: testSyncTimestamp, - isIncremental: true - ) - #expect(result.contactsReceived == testContactsReceived) - #expect(result.lastSyncTimestamp == testSyncTimestamp) - #expect(result.isIncremental == true) - } - - @Test("ContactSyncResult handles zero contacts") - func contactSyncResultHandlesZero() { - let result = ContactSyncResult( - contactsReceived: 0, - lastSyncTimestamp: 0, - isIncremental: false - ) - #expect(result.contactsReceived == 0) - #expect(result.lastSyncTimestamp == 0) - #expect(result.isIncremental == false) - } - - @Test("ContactSyncResult handles maximum values") - func contactSyncResultHandlesMaxValues() { - let result = ContactSyncResult( - contactsReceived: maxContactsReceived, - lastSyncTimestamp: maxSyncTimestamp, - isIncremental: true - ) - #expect(result.contactsReceived == maxContactsReceived) - #expect(result.lastSyncTimestamp == maxSyncTimestamp) - #expect(result.isIncremental == true) - } - - // MARK: - ContactServiceError Tests - - @Test("ContactServiceError cases are distinct") - func contactServiceErrorCasesDistinct() { - // Verify basic error cases - let basicErrors: [ContactServiceError] = [ - .notConnected, - .sendFailed, - .invalidResponse, - .syncInterrupted, - .contactNotFound, - .contactTableFull - ] - - // Verify all basic cases are distinct (no duplicates) - let errorDescriptions = basicErrors.map { String(describing: $0) } - let uniqueDescriptions = Set(errorDescriptions) - #expect(errorDescriptions.count == uniqueDescriptions.count) + // MARK: - Test Constants + + // Sync result test values + private let testContactsReceived = 5 + private let testSyncTimestamp: UInt32 = 1_234_567_890 + private let maxContactsReceived = Int.max + private let maxSyncTimestamp = UInt32.max + + /// Contact test values + private let testPublicKey = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20]) + private let testTimestamp: UInt32 = 1_700_000_000 + private let testModifiedTimestamp: UInt32 = 1_700_000_100 + private let testFlags: UInt8 = 0x01 + private let invalidContactType: UInt8 = 0xFF + private let testOutPath = Data([0xAA, 0xBB, 0xCC, 0xDD]) + private let floodRoutingPath = Data(repeating: 0xFF, count: 3) + + // MARK: - ContactSyncResult Tests + + @Test + func `ContactSyncResult initializes correctly`() { + let result = ContactSyncResult( + contactsReceived: testContactsReceived, + lastSyncTimestamp: testSyncTimestamp, + isIncremental: true + ) + #expect(result.contactsReceived == testContactsReceived) + #expect(result.lastSyncTimestamp == testSyncTimestamp) + #expect(result.isIncremental == true) + } + + @Test + func `ContactSyncResult handles zero contacts`() { + let result = ContactSyncResult( + contactsReceived: 0, + lastSyncTimestamp: 0, + isIncremental: false + ) + #expect(result.contactsReceived == 0) + #expect(result.lastSyncTimestamp == 0) + #expect(result.isIncremental == false) + } + + @Test + func `ContactSyncResult handles maximum values`() { + let result = ContactSyncResult( + contactsReceived: maxContactsReceived, + lastSyncTimestamp: maxSyncTimestamp, + isIncremental: true + ) + #expect(result.contactsReceived == maxContactsReceived) + #expect(result.lastSyncTimestamp == maxSyncTimestamp) + #expect(result.isIncremental == true) + } + + // MARK: - ContactServiceError Tests + + @Test + func `ContactServiceError cases are distinct`() { + // Verify basic error cases + let basicErrors: [ContactServiceError] = [ + .notConnected, + .sendFailed, + .invalidResponse, + .syncInterrupted, + .contactNotFound, + .contactTableFull + ] + + // Verify all basic cases are distinct (no duplicates) + let errorDescriptions = basicErrors.map { String(describing: $0) } + let uniqueDescriptions = Set(errorDescriptions) + #expect(errorDescriptions.count == uniqueDescriptions.count) + } + + // MARK: - MeshContact.toContactFrame() Tests + + @Test + func `MeshContact converts to ContactFrame correctly`() { + // Create a test MeshContact with all fields populated + let publicKey = testPublicKey + let outPath = testOutPath + let advertisedName = "TestNode" + let lastAdvertDate = Date(timeIntervalSince1970: TimeInterval(testTimestamp)) + let lastModifiedDate = Date(timeIntervalSince1970: TimeInterval(testModifiedTimestamp)) + let latitude = 37.7749 + let longitude = -122.4194 + + let meshContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .chat, + flags: ContactFlags(rawValue: testFlags), + outPathLength: 2, + outPath: outPath, + advertisedName: advertisedName, + lastAdvertisement: lastAdvertDate, + latitude: latitude, + longitude: longitude, + lastModified: lastModifiedDate + ) + + // Convert to ContactFrame + let contactFrame = meshContact.toContactFrame() + + // Verify all fields are correctly mapped + #expect(contactFrame.publicKey == publicKey) + #expect(contactFrame.type == .chat) + #expect(contactFrame.flags == testFlags) + #expect(contactFrame.outPathLength == 2) + #expect(contactFrame.outPath == outPath) + #expect(contactFrame.name == advertisedName) + #expect(contactFrame.lastAdvertTimestamp == UInt32(lastAdvertDate.timeIntervalSince1970)) + #expect(contactFrame.latitude == latitude) + #expect(contactFrame.longitude == longitude) + #expect(contactFrame.lastModified == UInt32(lastModifiedDate.timeIntervalSince1970)) + } + + @Test + func `MeshContact handles all ContactType conversions`() { + let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) + + // Test chat type + let chatContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Chat", + lastAdvertisement: Date(), + latitude: 0, + longitude: 0, + lastModified: Date() + ) + #expect(chatContact.toContactFrame().type == .chat) + + // Test repeater type + let repeaterContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .repeater, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Repeater", + lastAdvertisement: Date(), + latitude: 0, + longitude: 0, + lastModified: Date() + ) + #expect(repeaterContact.toContactFrame().type == .repeater) + + // Test room type + let roomContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .room, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Room", + lastAdvertisement: Date(), + latitude: 0, + longitude: 0, + lastModified: Date() + ) + #expect(roomContact.toContactFrame().type == .room) + } + + @Test + func `Parser handles invalid ContactType by defaulting to .chat`() { + // Build 147-byte contact data with invalid type byte at offset 32 + var data = Data(repeating: 0x00, count: 147) + data[32] = invalidContactType // type byte + + // Parser should default unknown types to .chat + let contact = Parsers.parseContactData(data) + #expect(contact?.type == .chat) + } + + @Test + func `MeshContact handles flood routing path`() { + let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) + + let floodContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0xFF, // Flood routing + outPath: Data(), + advertisedName: "Flood", + lastAdvertisement: Date(), + latitude: 0, + longitude: 0, + lastModified: Date() + ) + + let frame = floodContact.toContactFrame() + #expect(frame.outPathLength == 0xFF) + #expect(frame.outPath.isEmpty) + } + + // MARK: - ContactFrame.toMeshContact() Tests + + @Test + func `ContactFrame converts to MeshContact correctly`() { + // Create a test ContactFrame with all fields populated + let publicKey = testPublicKey + let outPath = testOutPath + let name = "TestNode" + let lastAdvertTimestamp = testTimestamp + let lastModified = testModifiedTimestamp + let latitude = 37.7749 + let longitude = -122.4194 + + let contactFrame = ContactFrame( + publicKey: publicKey, + type: .chat, + flags: testFlags, + outPathLength: 2, + outPath: outPath, + name: name, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified + ) + + // Convert to MeshContact + let meshContact = contactFrame.toMeshContact() + + // Verify all fields are correctly mapped + #expect(meshContact.id == publicKey.uppercaseHexString()) + #expect(meshContact.publicKey == publicKey) + #expect(meshContact.type == .chat) + #expect(meshContact.flags == ContactFlags(rawValue: testFlags)) + #expect(meshContact.outPathLength == 2) + #expect(meshContact.outPath == outPath) + #expect(meshContact.advertisedName == name) + #expect(meshContact.lastAdvertisement == Date(timeIntervalSince1970: TimeInterval(lastAdvertTimestamp))) + #expect(meshContact.latitude == latitude) + #expect(meshContact.longitude == longitude) + #expect(meshContact.lastModified == Date(timeIntervalSince1970: TimeInterval(lastModified))) + } + + @Test + func `ContactFrame ID generation from public key`() { + let publicKey = Data([0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56, 0x78, 0x90, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) + + let contactFrame = ContactFrame( + publicKey: publicKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Test", + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + + let meshContact = contactFrame.toMeshContact() + + // ID should be hex string of public key (uppercase) + let expectedID = publicKey.uppercaseHexString() + #expect(meshContact.id == expectedID) + } + + @Test + func `ContactFrame handles all ContactType conversions`() { + let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) + + // Test chat type + let chatFrame = ContactFrame( + publicKey: publicKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Chat", + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + #expect(chatFrame.toMeshContact().type == .chat) + + // Test repeater type + let repeaterFrame = ContactFrame( + publicKey: publicKey, + type: .repeater, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Repeater", + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + #expect(repeaterFrame.toMeshContact().type == .repeater) + + // Test room type + let roomFrame = ContactFrame( + publicKey: publicKey, + type: .room, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Room", + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + #expect(roomFrame.toMeshContact().type == .room) + } + + // MARK: - Round-Trip Conversion Tests + + @Test + func `Round-trip conversion MeshContact -> ContactFrame -> MeshContact`() { + let publicKey = testPublicKey + + let original = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .repeater, + flags: ContactFlags(rawValue: 0x05), + outPathLength: 3, + outPath: Data([0xAA, 0xBB, 0xCC]), + advertisedName: "OriginalNode", + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(testTimestamp)), + latitude: 40.7128, + longitude: -74.0060, + lastModified: Date(timeIntervalSince1970: 1_700_000_200) + ) + + // Convert to ContactFrame and back + let frame = original.toContactFrame() + let roundTripped = frame.toMeshContact() + + // Verify all fields survived the round trip + #expect(roundTripped.id == original.id) + #expect(roundTripped.publicKey == original.publicKey) + #expect(roundTripped.type == original.type) + #expect(roundTripped.flags == original.flags) + #expect(roundTripped.outPathLength == original.outPathLength) + #expect(roundTripped.outPath == original.outPath) + #expect(roundTripped.advertisedName == original.advertisedName) + #expect(roundTripped.lastAdvertisement == original.lastAdvertisement) + #expect(roundTripped.latitude == original.latitude) + #expect(roundTripped.longitude == original.longitude) + #expect(roundTripped.lastModified == original.lastModified) + } + + @Test + func `Round-trip conversion ContactFrame -> MeshContact -> ContactFrame`() { + let publicKey = testPublicKey + + let original = ContactFrame( + publicKey: publicKey, + type: .room, + flags: 0x03, + outPathLength: 1, + outPath: Data([0xFF]), + name: "OriginalRoom", + lastAdvertTimestamp: testTimestamp, + latitude: 51.5074, + longitude: -0.1278, + lastModified: 1_700_000_300 + ) + + // Convert to MeshContact and back + let meshContact = original.toMeshContact() + let roundTripped = meshContact.toContactFrame() + + // Verify all fields survived the round trip + #expect(roundTripped == original) + } + + // MARK: - Cleanup Coordinator Tests + + /// Actor to track cleanup invocations in a thread-safe manner + private actor CleanupTracker { + var invocations: [(contactID: UUID, reason: ContactCleanupReason, publicKey: Data)] = [] + + func record(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) { + invocations.append((contactID: contactID, reason: reason, publicKey: publicKey)) } + } - @Test("ContactServiceError sessionError carries MeshCoreError") - func contactServiceErrorSessionError() { - let meshError = MeshCoreError.notConnected - let error = ContactServiceError.sessionError(meshError) - - // Verify the associated value is accessible - if case .sessionError(let innerError) = error { - // Just verify we can access the inner error - if case .notConnected = innerError { - // Success - } else { - Issue.record("Expected .notConnected case") - } - } else { - Issue.record("Expected sessionError case") - } - } - - // MARK: - MeshContact.toContactFrame() Tests - - @Test("MeshContact converts to ContactFrame correctly") - func meshContactToContactFrame() { - // Create a test MeshContact with all fields populated - let publicKey = testPublicKey - let outPath = testOutPath - let advertisedName = "TestNode" - let lastAdvertDate = Date(timeIntervalSince1970: TimeInterval(testTimestamp)) - let lastModifiedDate = Date(timeIntervalSince1970: TimeInterval(testModifiedTimestamp)) - let latitude = 37.7749 - let longitude = -122.4194 - - let meshContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .chat, - flags: ContactFlags(rawValue: testFlags), - outPathLength: 2, - outPath: outPath, - advertisedName: advertisedName, - lastAdvertisement: lastAdvertDate, - latitude: latitude, - longitude: longitude, - lastModified: lastModifiedDate - ) - - // Convert to ContactFrame - let contactFrame = meshContact.toContactFrame() - - // Verify all fields are correctly mapped - #expect(contactFrame.publicKey == publicKey) - #expect(contactFrame.type == .chat) - #expect(contactFrame.flags == testFlags) - #expect(contactFrame.outPathLength == 2) - #expect(contactFrame.outPath == outPath) - #expect(contactFrame.name == advertisedName) - #expect(contactFrame.lastAdvertTimestamp == UInt32(lastAdvertDate.timeIntervalSince1970)) - #expect(contactFrame.latitude == latitude) - #expect(contactFrame.longitude == longitude) - #expect(contactFrame.lastModified == UInt32(lastModifiedDate.timeIntervalSince1970)) - } - - @Test("MeshContact handles all ContactType conversions") - func meshContactContactTypeConversions() { - let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) - - // Test chat type - let chatContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .chat, - flags: ContactFlags(rawValue: 0), - outPathLength: 0, - outPath: Data(), - advertisedName: "Chat", - lastAdvertisement: Date(), - latitude: 0, - longitude: 0, - lastModified: Date() - ) - #expect(chatContact.toContactFrame().type == .chat) - - // Test repeater type - let repeaterContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .repeater, - flags: ContactFlags(rawValue: 0), - outPathLength: 0, - outPath: Data(), - advertisedName: "Repeater", - lastAdvertisement: Date(), - latitude: 0, - longitude: 0, - lastModified: Date() - ) - #expect(repeaterContact.toContactFrame().type == .repeater) - - // Test room type - let roomContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .room, - flags: ContactFlags(rawValue: 0), - outPathLength: 0, - outPath: Data(), - advertisedName: "Room", - lastAdvertisement: Date(), - latitude: 0, - longitude: 0, - lastModified: Date() - ) - #expect(roomContact.toContactFrame().type == .room) - } - - @Test("Parser handles invalid ContactType by defaulting to .chat") - func parserInvalidContactType() { - // Build 147-byte contact data with invalid type byte at offset 32 - var data = Data(repeating: 0x00, count: 147) - data[32] = invalidContactType // type byte - - // Parser should default unknown types to .chat - let contact = Parsers.parseContactData(data) - #expect(contact?.type == .chat) - } - - @Test("MeshContact handles flood routing path") - func meshContactFloodRouting() { - let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) - - let floodContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .chat, - flags: ContactFlags(rawValue: 0), - outPathLength: 0xFF, // Flood routing - outPath: Data(), - advertisedName: "Flood", - lastAdvertisement: Date(), - latitude: 0, - longitude: 0, - lastModified: Date() - ) - - let frame = floodContact.toContactFrame() - #expect(frame.outPathLength == 0xFF) - #expect(frame.outPath.isEmpty) - } - - // MARK: - ContactFrame.toMeshContact() Tests - - @Test("ContactFrame converts to MeshContact correctly") - func contactFrameToMeshContact() { - // Create a test ContactFrame with all fields populated - let publicKey = testPublicKey - let outPath = testOutPath - let name = "TestNode" - let lastAdvertTimestamp = testTimestamp - let lastModified = testModifiedTimestamp - let latitude = 37.7749 - let longitude = -122.4194 - - let contactFrame = ContactFrame( - publicKey: publicKey, - type: .chat, - flags: testFlags, - outPathLength: 2, - outPath: outPath, - name: name, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: lastModified - ) - - // Convert to MeshContact - let meshContact = contactFrame.toMeshContact() - - // Verify all fields are correctly mapped - #expect(meshContact.id == publicKey.uppercaseHexString()) - #expect(meshContact.publicKey == publicKey) - #expect(meshContact.type == .chat) - #expect(meshContact.flags == ContactFlags(rawValue: testFlags)) - #expect(meshContact.outPathLength == 2) - #expect(meshContact.outPath == outPath) - #expect(meshContact.advertisedName == name) - #expect(meshContact.lastAdvertisement == Date(timeIntervalSince1970: TimeInterval(lastAdvertTimestamp))) - #expect(meshContact.latitude == latitude) - #expect(meshContact.longitude == longitude) - #expect(meshContact.lastModified == Date(timeIntervalSince1970: TimeInterval(lastModified))) - } - - @Test("ContactFrame ID generation from public key") - func contactFrameIDGeneration() { - let publicKey = Data([0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56, 0x78, 0x90, - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, - 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) - - let contactFrame = ContactFrame( - publicKey: publicKey, - type: .chat, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: "Test", - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - - let meshContact = contactFrame.toMeshContact() - - // ID should be hex string of public key (uppercase) - let expectedID = publicKey.uppercaseHexString() - #expect(meshContact.id == expectedID) - } + /// Cleanup coordinator stand-in that records each invocation on a `CleanupTracker`. + private struct RecordingCleanupCoordinator: ContactCleanupHandling { + let tracker: CleanupTracker - @Test("ContactFrame handles all ContactType conversions") - func contactFrameContactTypeConversions() { - let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) - - // Test chat type - let chatFrame = ContactFrame( - publicKey: publicKey, - type: .chat, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: "Chat", - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - #expect(chatFrame.toMeshContact().type == .chat) - - // Test repeater type - let repeaterFrame = ContactFrame( - publicKey: publicKey, - type: .repeater, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: "Repeater", - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - #expect(repeaterFrame.toMeshContact().type == .repeater) - - // Test room type - let roomFrame = ContactFrame( - publicKey: publicKey, - type: .room, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: "Room", - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - #expect(roomFrame.toMeshContact().type == .room) + func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async { + await tracker.record(contactID: contactID, reason: reason, publicKey: publicKey) } - - // MARK: - Round-Trip Conversion Tests - - @Test("Round-trip conversion MeshContact -> ContactFrame -> MeshContact") - func roundTripMeshContactConversion() { - let publicKey = testPublicKey - - let original = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .repeater, - flags: ContactFlags(rawValue: 0x05), - outPathLength: 3, - outPath: Data([0xAA, 0xBB, 0xCC]), - advertisedName: "OriginalNode", - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(testTimestamp)), - latitude: 40.7128, - longitude: -74.0060, - lastModified: Date(timeIntervalSince1970: 1700000200) - ) - - // Convert to ContactFrame and back - let frame = original.toContactFrame() - let roundTripped = frame.toMeshContact() - - // Verify all fields survived the round trip - #expect(roundTripped.id == original.id) - #expect(roundTripped.publicKey == original.publicKey) - #expect(roundTripped.type == original.type) - #expect(roundTripped.flags == original.flags) - #expect(roundTripped.outPathLength == original.outPathLength) - #expect(roundTripped.outPath == original.outPath) - #expect(roundTripped.advertisedName == original.advertisedName) - #expect(roundTripped.lastAdvertisement == original.lastAdvertisement) - #expect(roundTripped.latitude == original.latitude) - #expect(roundTripped.longitude == original.longitude) - #expect(roundTripped.lastModified == original.lastModified) - } - - @Test("Round-trip conversion ContactFrame -> MeshContact -> ContactFrame") - func roundTripContactFrameConversion() { - let publicKey = testPublicKey - - let original = ContactFrame( - publicKey: publicKey, - type: .room, - flags: 0x03, - outPathLength: 1, - outPath: Data([0xFF]), - name: "OriginalRoom", - lastAdvertTimestamp: testTimestamp, - latitude: 51.5074, - longitude: -0.1278, - lastModified: 1700000300 - ) - - // Convert to MeshContact and back - let meshContact = original.toMeshContact() - let roundTripped = meshContact.toContactFrame() - - // Verify all fields survived the round trip - #expect(roundTripped == original) - } - - // MARK: - Cleanup Coordinator Tests - - /// Actor to track cleanup invocations in a thread-safe manner - private actor CleanupTracker { - var invocations: [(contactID: UUID, reason: ContactCleanupReason, publicKey: Data)] = [] - - func record(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) { - invocations.append((contactID: contactID, reason: reason, publicKey: publicKey)) - } - } - - /// Cleanup coordinator stand-in that records each invocation on a `CleanupTracker`. - private struct RecordingCleanupCoordinator: ContactCleanupHandling { - let tracker: CleanupTracker - - func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async { - await tracker.record(contactID: contactID, reason: reason, publicKey: publicKey) - } - } - - @Test("removeContact deletes messages and triggers cleanup") - func removeContactDeletesMessagesAndTriggersCleanup() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact in the mock store - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 3 - ) - try await mockStore.saveContact(contact) - - // Track cleanup handler invocations - let tracker = CleanupTracker() - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) - ) - - // Remove the contact - try await service.removeContact(radioID: radioID, publicKey: testPublicKey) - - // Verify messages were deleted - let deletedForContacts = await mockStore.deletedMessagesForContactIDs - #expect(deletedForContacts == [contactID]) - - // Verify cleanup handler was called with reason=.deleted - let invocations = await tracker.invocations - #expect(invocations.count == 1) - #expect(invocations[0].contactID == contactID) - #expect(invocations[0].reason == .deleted) - } - - @Test("clearContactMessages deletes messages and zeroes both unread counters while preserving lastMessageDate") - func clearContactMessagesPreservesLastMessageDate() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - let lastMessageDate = Date(timeIntervalSince1970: TimeInterval(testTimestamp)) - - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: lastMessageDate, - unreadCount: 4, - unreadMentionCount: 2 - ) - try await mockStore.saveContact(contact) - - // Seed real message rows so deletion is observable, not just forwarded. - for offset in 0..<3 { - try await mockStore.saveMessage(MessageDTO( - id: UUID(), - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: "msg \(offset)", - timestamp: testTimestamp + UInt32(offset), - createdAt: lastMessageDate, - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: "TestContact", - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - )) - } - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: nil - ) - - try await service.clearContactMessages(contactID: contactID) - - // Messages are actually gone, not merely reported deleted. - let deletedForContacts = await mockStore.deletedMessagesForContactIDs - #expect(deletedForContacts == [contactID]) - let remaining = try await mockStore.fetchMessages(contactID: contactID, limit: 10, offset: 0) - #expect(remaining.isEmpty) - - // The conversation stays listed: lastMessageDate is preserved, both unread counters zeroed. - let updated = try await mockStore.fetchContact(id: contactID) - #expect(updated?.lastMessageDate == lastMessageDate) - #expect(updated?.unreadCount == 0) - #expect(updated?.unreadMentionCount == 0) - } - - @Test("updateContactPreferences clears unread when blocking") - func updateContactPreferencesClearsUnreadWhenBlocking() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact with unread count - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 5 - ) - try await mockStore.saveContact(contact) - - // Track cleanup handler invocations - let tracker = CleanupTracker() - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) - ) - - // Block the contact - try await service.updateContactPreferences(contactID: contactID, isBlocked: true) - - // Verify unread count was cleared - let updatedContact = await mockStore.contacts[contactID] - #expect(updatedContact?.unreadCount == 0) - #expect(updatedContact?.isBlocked == true) - - // Verify cleanup handler was called with reason=.blocked - let invocations = await tracker.invocations - #expect(invocations.count == 1) - #expect(invocations[0].contactID == contactID) - #expect(invocations[0].reason == .blocked) - } - - @Test("updateContactPreferences does not trigger cleanup when not blocking") - func updateContactPreferencesNoCleanupWhenNotBlocking() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 5 - ) - try await mockStore.saveContact(contact) - - // Track cleanup handler invocations - let tracker = CleanupTracker() - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) - ) - - // Update nickname (not blocking) - try await service.updateContactPreferences(contactID: contactID, nickname: "NewNickname") - - // Verify unread count was preserved - let updatedContact = await mockStore.contacts[contactID] - #expect(updatedContact?.unreadCount == 5) - #expect(updatedContact?.nickname == "NewNickname") - - // Verify cleanup handler was NOT called - let invocations = await tracker.invocations - #expect(invocations.isEmpty) - } - - @Test("updateContactPreferences preserves fields when blocking") - func updateContactPreferencesPreservesFieldsWhenBlocking() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact with special fields - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: "MyNickname", - isBlocked: false, - isMuted: false, - isFavorite: true, - lastMessageDate: Date(), - unreadCount: 5, - ocvPreset: "medium", - customOCVArrayString: "custom" - ) - try await mockStore.saveContact(contact) - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: nil - ) - - // Block the contact - try await service.updateContactPreferences(contactID: contactID, isBlocked: true) - - // Verify all fields are preserved except unreadCount - let updatedContact = await mockStore.contacts[contactID] - #expect(updatedContact?.nickname == "MyNickname") - #expect(updatedContact?.isFavorite == true) - #expect(updatedContact?.ocvPreset == "medium") - #expect(updatedContact?.customOCVArrayString == "custom") - #expect(updatedContact?.unreadCount == 0) - #expect(updatedContact?.isBlocked == true) - } - - @Test("unblocking contact triggers cleanup with unblocked reason") - func unblockingContactTriggersCleanupWithUnblockedReason() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact that is already blocked - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: true, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - try await mockStore.saveContact(contact) - - // Track cleanup handler invocations - let tracker = CleanupTracker() - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) - ) - - // Unblock the contact - try await service.updateContactPreferences(contactID: contactID, isBlocked: false) - - // Verify contact was unblocked - let updatedContact = await mockStore.contacts[contactID] - #expect(updatedContact?.isBlocked == false) - - // Verify cleanup handler was called with reason=.unblocked - let invocations = await tracker.invocations - #expect(invocations.count == 1) - #expect(invocations[0].contactID == contactID) - #expect(invocations[0].reason == .unblocked) + } + + @Test + func `removeContact deletes messages and triggers cleanup`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact in the mock store + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 3 + ) + try await mockStore.saveContact(contact) + + // Track cleanup handler invocations + let tracker = CleanupTracker() + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) + ) + + // Remove the contact + try await service.removeContact(radioID: radioID, publicKey: testPublicKey) + + // Verify messages were deleted + let deletedForContacts = await mockStore.deletedMessagesForContactIDs + #expect(deletedForContacts == [contactID]) + + // Verify cleanup handler was called with reason=.deleted + let invocations = await tracker.invocations + #expect(invocations.count == 1) + #expect(invocations[0].contactID == contactID) + #expect(invocations[0].reason == .deleted) + } + + @Test + func `clearContactMessages deletes messages and zeroes both unread counters while preserving lastMessageDate`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + let lastMessageDate = Date(timeIntervalSince1970: TimeInterval(testTimestamp)) + + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: lastMessageDate, + unreadCount: 4, + unreadMentionCount: 2 + ) + try await mockStore.saveContact(contact) + + // Seed real message rows so deletion is observable, not just forwarded. + for offset in 0..<3 { + try await mockStore.saveMessage(MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "msg \(offset)", + timestamp: testTimestamp + UInt32(offset), + createdAt: lastMessageDate, + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: "TestContact", + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + )) } + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + try await service.clearContactMessages(contactID: contactID) + + // Messages are actually gone, not merely reported deleted. + let deletedForContacts = await mockStore.deletedMessagesForContactIDs + #expect(deletedForContacts == [contactID]) + let remaining = try await mockStore.fetchMessages(contactID: contactID, limit: 10, offset: 0) + #expect(remaining.isEmpty) + + // The conversation stays listed: lastMessageDate is preserved, both unread counters zeroed. + let updated = try await mockStore.fetchContact(id: contactID) + #expect(updated?.lastMessageDate == lastMessageDate) + #expect(updated?.unreadCount == 0) + #expect(updated?.unreadMentionCount == 0) + } + + @Test + func `updateContactPreferences clears unread when blocking`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact with unread count + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 5 + ) + try await mockStore.saveContact(contact) + + // Track cleanup handler invocations + let tracker = CleanupTracker() + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) + ) + + // Block the contact + try await service.updateContactPreferences(contactID: contactID, isBlocked: true) + + // Verify unread count was cleared + let updatedContact = await mockStore.contacts[contactID] + #expect(updatedContact?.unreadCount == 0) + #expect(updatedContact?.isBlocked == true) + + // Verify cleanup handler was called with reason=.blocked + let invocations = await tracker.invocations + #expect(invocations.count == 1) + #expect(invocations[0].contactID == contactID) + #expect(invocations[0].reason == .blocked) + } + + @Test + func `updateContactPreferences does not trigger cleanup when not blocking`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 5 + ) + try await mockStore.saveContact(contact) + + // Track cleanup handler invocations + let tracker = CleanupTracker() + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) + ) + + // Update nickname (not blocking) + try await service.updateContactPreferences(contactID: contactID, nickname: "NewNickname") + + // Verify unread count was preserved + let updatedContact = await mockStore.contacts[contactID] + #expect(updatedContact?.unreadCount == 5) + #expect(updatedContact?.nickname == "NewNickname") + + // Verify cleanup handler was NOT called + let invocations = await tracker.invocations + #expect(invocations.isEmpty) + } + + @Test + func `updateContactPreferences preserves fields when blocking`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact with special fields + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: "MyNickname", + isBlocked: false, + isMuted: false, + isFavorite: true, + lastMessageDate: Date(), + unreadCount: 5, + ocvPreset: "medium", + customOCVArrayString: "custom" + ) + try await mockStore.saveContact(contact) + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + // Block the contact + try await service.updateContactPreferences(contactID: contactID, isBlocked: true) + + // Verify all fields are preserved except unreadCount + let updatedContact = await mockStore.contacts[contactID] + #expect(updatedContact?.nickname == "MyNickname") + #expect(updatedContact?.isFavorite == true) + #expect(updatedContact?.ocvPreset == "medium") + #expect(updatedContact?.customOCVArrayString == "custom") + #expect(updatedContact?.unreadCount == 0) + #expect(updatedContact?.isBlocked == true) + } + + @Test + func `unblocking contact triggers cleanup with unblocked reason`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact that is already blocked + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: true, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + try await mockStore.saveContact(contact) + + // Track cleanup handler invocations + let tracker = CleanupTracker() + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) + ) + + // Unblock the contact + try await service.updateContactPreferences(contactID: contactID, isBlocked: false) + + // Verify contact was unblocked + let updatedContact = await mockStore.contacts[contactID] + #expect(updatedContact?.isBlocked == false) + + // Verify cleanup handler was called with reason=.unblocked + let invocations = await tracker.invocations + #expect(invocations.count == 1) + #expect(invocations[0].contactID == contactID) + #expect(invocations[0].reason == .unblocked) + } + + // MARK: - Reset Path + + @Test + func `resetPath flood-routes the contact while preserving an unmodeled type byte`() async throws { + let radioID = UUID() + // The real store round-trips the raw type byte; the mock store normalizes it, so this uses + // a real PersistenceStore to observe what resetPath actually persists. + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let unmodeledType: UInt8 = 0x7F + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: testPublicKey, + typeRawValue: unmodeledType, + outPathLength: 2, + outPath: testOutPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + let service = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + try await service.resetPath(radioID: radioID, publicKey: testPublicKey) + + #expect(await session.resetPathPublicKeys == [testPublicKey]) + let reset = try await dataStore.fetchContact(radioID: radioID, publicKey: testPublicKey) + #expect(reset?.isFloodRouted == true) + #expect(reset?.typeRawValue == unmodeledType) + } + + @Test + func `updateContactPreferences clears nickname when passed empty string`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + let contactID = UUID() + + let contact = ContactDTO( + id: contactID, + radioID: UUID(), + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: "OldNickname", + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + try await mockStore.saveContact(contact) + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + try await service.updateContactPreferences(contactID: contactID, nickname: "") + + let updated = await mockStore.contacts[contactID] + #expect(updated?.nickname == nil) + } + + @Test + func `updateContactPreferences clears nickname when passed whitespace only`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + let contactID = UUID() + + let contact = ContactDTO( + id: contactID, + radioID: UUID(), + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: "OldNickname", + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + try await mockStore.saveContact(contact) + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + try await service.updateContactPreferences(contactID: contactID, nickname: " ") + + let updated = await mockStore.contacts[contactID] + #expect(updated?.nickname == nil) + } + + @Test + func `updateContactPreferences trims surrounding whitespace from nickname`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + let contactID = UUID() + + let contact = ContactDTO( + id: contactID, + radioID: UUID(), + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + try await mockStore.saveContact(contact) + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + try await service.updateContactPreferences(contactID: contactID, nickname: " Rico ") + + let updated = await mockStore.contacts[contactID] + #expect(updated?.nickname == "Rico") + } + + @Test + func `updateContactPreferences keeps nickname when nickname arg is nil`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + let contactID = UUID() + + let contact = ContactDTO( + id: contactID, + radioID: UUID(), + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: "KeepMe", + isBlocked: false, + isMuted: true, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 5 + ) + try await mockStore.saveContact(contact) + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + // Toggle favorite without touching nickname; nickname must survive. + try await service.updateContactPreferences(contactID: contactID, isFavorite: true) + + let updated = await mockStore.contacts[contactID] + #expect(updated?.nickname == "KeepMe") + #expect(updated?.isFavorite == true) + // A preferences edit must not silently reset unrelated persisted fields. + #expect(updated?.isMuted == true) + #expect(updated?.unreadMentionCount == 5) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/DebugLogBufferTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/DebugLogBufferTests.swift index f1e85481..2f50de11 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/DebugLogBufferTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/DebugLogBufferTests.swift @@ -1,52 +1,306 @@ -import Testing import Foundation @testable import MC1Services +import Testing -// Serialized because both tests reassign the process-global DebugLogBuffer.shared; -// run in parallel, one test's reassignment breaks the other's final-value assertion. +/// Serialized because both tests reassign the process-global DebugLogBuffer.shared, +/// so running them in parallel breaks each other's assertions on that global. +/// `ServiceContainer.init` also reassigns this same global, and other test suites +/// that build a `ServiceContainer` run concurrently with this suite, so a read +/// right after a write can observe a foreign buffer written in between. Assertions +/// below read back through a bounded retry to absorb that interleaving. @Suite("DebugLogBuffer Tests", .serialized) struct DebugLogBufferTests { + private static let maxReadBackAttempts = 5 + + private func makeBuffer() async throws -> DebugLogBuffer { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + return DebugLogBuffer(dataStore: store) + } + + /// Writes `buffer` to the global and reads it back, retrying because a + /// concurrently running suite may reassign the global between the write and + /// the read. Succeeds as soon as one attempt reads back the value just + /// written; only fails if every attempt observes a foreign buffer instead. + private func writeAndReadBack(_ buffer: DebugLogBuffer, attempts: Int = maxReadBackAttempts) -> Bool { + for _ in 0.. DebugLogBuffer { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - return DebugLogBuffer(dataStore: store) - } - - @Test("shared get and set round-trip through the lock") - func sharedRoundTrip() async throws { - let buffer = try await makeBuffer() - DebugLogBuffer.shared = buffer - #expect(DebugLogBuffer.shared === buffer) - DebugLogBuffer.shared = nil - #expect(DebugLogBuffer.shared == nil) - } - - /// Reassigning `shared` per connection while loggers read it from arbitrary - /// actors is the data race this guards against; the lock must make - /// concurrent reads and writes safe (verified under the thread sanitizer). - @Test("concurrent reads and writes of shared are race-free") - func concurrentAccessIsRaceFree() async throws { - let bufferA = try await makeBuffer() - let bufferB = try await makeBuffer() - DebugLogBuffer.shared = bufferA - - await withTaskGroup(of: Void.self) { group in - for index in 0..<200 { - group.addTask { - if index.isMultiple(of: 2) { - DebugLogBuffer.shared = index.isMultiple(of: 4) ? bufferA : bufferB - } else { - _ = DebugLogBuffer.shared - } - } - } + /// Reassigning `shared` per connection while loggers read it from arbitrary + /// actors is the data race this guards against; the lock must make + /// concurrent reads and writes safe (verified under the thread sanitizer). + @Test + func `concurrent reads and writes of shared are race-free`() async throws { + let bufferA = try await makeBuffer() + let bufferB = try await makeBuffer() + DebugLogBuffer.shared = bufferA + + await withTaskGroup(of: Void.self) { group in + for index in 0..<200 { + group.addTask { + if index.isMultiple(of: 2) { + DebugLogBuffer.shared = index.isMultiple(of: 4) ? bufferA : bufferB + } else { + _ = DebugLogBuffer.shared + } } + } + } + + #expect(writeAndReadBack(bufferA)) + + DebugLogBuffer.shared = nil + } + + // MARK: - Pending buffer (entries logged before `shared` is assigned) + + private enum TestStoreFailure: Error { + case saveFailed + } + + /// Repeatedly flushes `buffer` and re-checks `store` for entries in `category`, + /// absorbing the delay before a fire-and-forget drain or append `Task` runs. + private func pollForDebugLogEntries( + _ store: MockPersistenceStore, + buffer: DebugLogBuffer, + category: String, + minimumCount: Int, + attempts: Int = 30, + delay: Duration = .milliseconds(30) + ) async -> [DebugLogEntryDTO] { + for _ in 0..= minimumCount { + return matches + } + try? await Task.sleep(for: delay) + } + return await store.debugLogEntries.filter { $0.category == category } + } + + /// `PersistentLogger.persist` queues entries logged before `shared` is assigned + /// instead of dropping them; assigning `shared` drains that queue into the new + /// buffer in the order entries were logged. The whole reset-log-assign sequence + /// retries with a fresh category per attempt because a foreign suite's + /// `ServiceContainer.init` can reassign the global mid-sequence and swallow the + /// pending entries into its own buffer. + @Test + func `entries logged before shared is assigned are drained in order once a buffer is set`() async { + let store = MockPersistenceStore() + let buffer = DebugLogBuffer(dataStore: store) - // Final value must be one of the two known buffers, never a torn pointer. - let final = DebugLogBuffer.shared - #expect(final === bufferA || final === bufferB) + var entries: [DebugLogEntryDTO] = [] + for _ in 0..= 2 { break } + } + #expect(entries.map(\.message) == ["first", "second"]) + + DebugLogBuffer.shared = nil + } + + /// Beyond `maxPendingEntries`, the pending queue drops the oldest entries first, + /// so the window it protects (early launch, state restoration, background + /// relaunch) can't grow the queue without bound. + @Test + func `pending queue drops oldest entries once the bound is exceeded`() async { + let store = MockPersistenceStore() + let buffer = DebugLogBuffer(dataStore: store) + let overflow = 3 + let total = DebugLogBuffer.maxPendingEntries + overflow + + var entries: [DebugLogEntryDTO] = [] + for _ in 0..= DebugLogBuffer.maxPendingEntries { break } + } + #expect(entries.count == DebugLogBuffer.maxPendingEntries) + #expect(entries.first?.message == "\(overflow)") + #expect(entries.last?.message == "\(total - 1)") + + DebugLogBuffer.shared = nil + } + + // MARK: - Save-failure visibility + + /// A `DebugLogPersisting` fake whose saves block on a gate until released. This + /// lets a test hold two size-triggered flushes in flight at once (rather than + /// racing the buffer's fire-and-forget flush scheduling with a plain toggle), + /// so both fail together and the second observably hits the backlog cap. + private actor GatedDebugLogStore: DebugLogPersisting { + private(set) var savedEntries: [DebugLogEntryDTO] = [] + private var shouldFail = true + private var failNextSummarySave = false + private var waiters: [CheckedContinuation] = [] + + var pendingSaveCount: Int { + waiters.count + } + + /// Captures the fail/succeed decision before gating, not after resuming, so a + /// test can flip `shouldFail` back to false right after releasing without that + /// change racing the still-suspended calls it just released. + func saveDebugLogEntries(_ entries: [DebugLogEntryDTO]) async throws { + let willFail = shouldFail + if willFail { + await withCheckedContinuation { waiters.append($0) } + throw TestStoreFailure.saveFailed + } + if failNextSummarySave, entries.count == 1 { + failNextSummarySave = false + throw TestStoreFailure.saveFailed + } + savedEntries.append(contentsOf: entries) + } - DebugLogBuffer.shared = nil + func fetchDebugLogEntries(since: Date, limit: Int) async throws -> [DebugLogEntryDTO] { + [] } + + func countDebugLogEntries() async throws -> Int { + savedEntries.count + } + + func pruneDebugLogEntries(keepCount: Int) async throws {} + func clearDebugLogEntries() async throws {} + + /// Resumes every save currently blocked on the gate. + func releaseWaiters() { + let pending = waiters + waiters = [] + pending.forEach { $0.resume() } + } + + func setShouldFail(_ value: Bool) { + shouldFail = value + } + + /// Fails the next single-entry save (the drop summary is always saved alone) + /// while letting multi-entry batch saves through. + func setFailNextSummarySave(_ value: Bool) { + failNextSummarySave = value + } + } + + /// Drives two size-triggered flushes into the gated store and releases them while + /// it is still failing, leaving the buffer with `maxBufferSize` surviving entries + /// and a dropped count of `maxBufferSize` (one batch always loses to the backlog + /// cap). Returns once both saves have been held at the gate. + private func accumulateDroppedBatch(store: GatedDebugLogStore, buffer: DebugLogBuffer) async throws { + for index in 0..= 2 { break } + try await Task.sleep(for: .milliseconds(20)) + } + #expect(await store.pendingSaveCount == 2) + + await store.releaseWaiters() + await store.setShouldFail(false) + } + + /// Two size-triggered flushes of `maxBufferSize` entries each are held at the + /// save gate simultaneously, then released together while the store is still + /// failing. Whichever settles its catch block second finds the other's full + /// requeue already occupying the backlog (`buffer.count == maxBufferSize`), so + /// `buffer.count + entriesToRequeue.count` reaches the `maxBufferSize * 2` cap and + /// its entire batch is dropped — deterministic regardless of processing order, + /// since exactly one of the two batches always loses. Once the store recovers, + /// the surviving `maxBufferSize` entries save successfully and a summary entry + /// reports the other batch as lost. + @Test + func `dropped entries during save failures are reported once the store recovers`() async throws { + let store = GatedDebugLogStore() + let buffer = DebugLogBuffer(dataStore: store) + + try await accumulateDroppedBatch(store: store, buffer: buffer) + + var saved: [DebugLogEntryDTO] = [] + for _ in 0..<100 { + await buffer.flush() + saved = await store.savedEntries + if saved.contains(where: { $0.level == .warning }) { break } + try? await Task.sleep(for: .milliseconds(20)) + } + + let realEntries = saved.filter { $0.level != .warning } + #expect(realEntries.count == DebugLogBuffer.maxBufferSize) + + let summary = saved.first { $0.category == "DebugLogBuffer" && $0.level == .warning } + #expect(summary != nil) + #expect(summary?.message.contains("\(DebugLogBuffer.maxBufferSize)") == true) + } + + /// A failed summary save must not reset the dropped count: the loss carries + /// forward and the next successful save reports it in full. + @Test + func `a failed summary save carries the dropped count forward to the next successful save`() async throws { + let store = GatedDebugLogStore() + let buffer = DebugLogBuffer(dataStore: store) + + try await accumulateDroppedBatch(store: store, buffer: buffer) + await store.setFailNextSummarySave(true) + + // First recovery flush saves the surviving batch; its summary save fails once. + var saved: [DebugLogEntryDTO] = [] + for _ in 0..<100 { + await buffer.flush() + saved = await store.savedEntries + if saved.count(where: { $0.level != .warning }) >= DebugLogBuffer.maxBufferSize { break } + try? await Task.sleep(for: .milliseconds(20)) + } + #expect(saved.filter { $0.level == .warning }.isEmpty) + + // The next successful save must report the carried-forward count in full. + await buffer.append(DebugLogEntryDTO(level: .info, subsystem: "test", category: "c", message: "post-recovery")) + for _ in 0..<100 { + await buffer.flush() + saved = await store.savedEntries + if saved.contains(where: { $0.level == .warning }) { break } + try? await Task.sleep(for: .milliseconds(20)) + } + + let summary = saved.first { $0.category == "DebugLogBuffer" && $0.level == .warning } + #expect(summary != nil) + #expect(summary?.message.contains("\(DebugLogBuffer.maxBufferSize)") == true) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ErrorLocalizationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ErrorLocalizationTests.swift index 1374e17f..db57c84d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ErrorLocalizationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ErrorLocalizationTests.swift @@ -1,155 +1,154 @@ import Foundation -import Testing -@testable import MeshCore @testable import MC1Services +@testable import MeshCore +import Testing @Suite("Error Localization Tests") struct ErrorLocalizationTests { - - // MARK: - MeshCoreError Tests - - @Test("MeshCoreError.timeout produces human-readable description") - func meshCoreTimeout() { - let error: MeshCoreError = .timeout - #expect(error.localizedDescription == "The operation timed out. Please try again.") - } - - @Test("MeshCoreError.deviceError maps known firmware codes", arguments: [ - (UInt8(0x01), "Command not supported by device firmware."), - (UInt8(0x02), "Item not found on device."), - (UInt8(0x03), "Device storage is full."), - (UInt8(0x04), "Device is in an invalid state for this operation."), - (UInt8(0x05), "Device file system error."), - (UInt8(0x06), "Invalid parameter sent to device."), - ]) - func meshCoreDeviceErrorKnownCodes(code: UInt8, expected: String) { - let error: MeshCoreError = .deviceError(code: code) - #expect(error.localizedDescription == expected, "Code \(code) should produce: \(expected)") - } - - @Test("MeshCoreError.deviceError falls back for unknown codes") - func meshCoreDeviceErrorUnknownCode() { - let error: MeshCoreError = .deviceError(code: 10) - #expect(error.localizedDescription == "Device error (code 10).") - } - - @Test("MeshCoreError.deviceError handles code zero") - func meshCoreDeviceErrorCodeZero() { - let error: MeshCoreError = .deviceError(code: 0) - #expect(error.localizedDescription == "Device error (code 0).") - } - - @Test("MeshCoreError bluetooth errors produce readable descriptions") - func meshCoreBluetoothErrors() { - #expect(MeshCoreError.bluetoothUnavailable.localizedDescription == "Bluetooth is not available on this device.") - #expect(MeshCoreError.bluetoothUnauthorized.localizedDescription == "Bluetooth permission is required. Please enable it in Settings.") - #expect(MeshCoreError.bluetoothPoweredOff.localizedDescription == "Bluetooth is turned off. Please enable Bluetooth to connect.") - } - - @Test("MeshCoreError.connectionLost includes underlying error when present") - func meshCoreConnectionLostWithUnderlying() { - let underlying = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "link dropped"]) - let error: MeshCoreError = .connectionLost(underlying: underlying) - #expect(error.localizedDescription.contains("Connection to device was lost")) - #expect(error.localizedDescription.contains("link dropped")) - } - - @Test("MeshCoreError.connectionLost without underlying error") - func meshCoreConnectionLostWithoutUnderlying() { - let error: MeshCoreError = .connectionLost(underlying: nil) - #expect(error.localizedDescription == "Connection to device was lost.") - } - - @Test("MeshCoreError.featureDisabled produces readable description") - func meshCoreFeatureDisabled() { - #expect(MeshCoreError.featureDisabled.localizedDescription == "This feature is disabled on the device.") - } - - @Test("MeshCoreError.sessionNotStarted produces readable description") - func meshCoreSessionNotStarted() { - #expect(MeshCoreError.sessionNotStarted.localizedDescription == "Session has not been started.") - } - - // MARK: - ProtocolError Tests - - @Test("ProtocolError cases produce non-empty, readable descriptions", arguments: [ - ProtocolError.unsupportedCommand, .notFound, .tableFull, - .badState, .fileIOError, .illegalArgument, - ]) - func protocolErrorDescriptions(protocolError: ProtocolError) { - let description = protocolError.localizedDescription - #expect(!description.isEmpty, "ProtocolError.\(protocolError) should have a description") - #expect(!description.contains("ProtocolError"), "Should not contain raw type name") - } - - // MARK: - Service Error Session Pass-Through Tests - - @Test("MessageServiceError.sessionError passes through MeshCoreError description") - func messageServiceSessionPassThrough() { - let meshError: MeshCoreError = .deviceError(code: 0x03) - let serviceError: MessageServiceError = .sessionError(meshError) - #expect(serviceError.localizedDescription == "Device storage is full.") - } - - @Test("ChannelServiceError.sessionError passes through MeshCoreError description") - func channelServiceSessionPassThrough() { - let meshError: MeshCoreError = .timeout - let serviceError: ChannelServiceError = .sessionError(meshError) - #expect(serviceError.localizedDescription == "The operation timed out. Please try again.") - } - - @Test("SettingsServiceError.sessionError passes through without prefix") - func settingsServiceSessionPassThrough() { - let meshError: MeshCoreError = .notConnected - let serviceError: SettingsServiceError = .sessionError(meshError) - #expect(!serviceError.localizedDescription.contains("Session error:")) - #expect(serviceError.localizedDescription == "Not connected to device.") - } - - @Test("SettingsServiceError.deviceGPSVerificationFailed is human-readable") - func settingsServiceDeviceGPSVerificationFailed() { - let serviceError: SettingsServiceError = .deviceGPSVerificationFailed( - expectedEnabled: false, - actualEnabled: true - ) - #expect( - serviceError.localizedDescription == - "Device GPS setting was not saved. Expected 'Off' but device reports 'On'." - ) - } - - @Test("RemoteNodeError.sessionError passes through without prefix") - func remoteNodeSessionPassThrough() { - let meshError: MeshCoreError = .bluetoothPoweredOff - let serviceError: RemoteNodeError = .sessionError(meshError) - #expect(!serviceError.localizedDescription.contains("Session error:")) - #expect(serviceError.localizedDescription == "Bluetooth is turned off. Please enable Bluetooth to connect.") - } - - // MARK: - Service Error Spot Checks - - @Test("AdvertisementError.notConnected produces readable description") - func advertisementNotConnected() { - #expect(AdvertisementError.notConnected.localizedDescription == "Not connected to device.") - } - - @Test("RoomServerError.permissionDenied produces readable description") - func roomServerPermissionDenied() { - #expect(RoomServerError.permissionDenied.localizedDescription == "Permission denied.") - } - - @Test("BinaryProtocolError.timeout produces readable description") - func binaryProtocolTimeout() { - #expect(BinaryProtocolError.timeout.localizedDescription == "Request timed out.") - } - - @Test("SyncCoordinatorError.alreadySyncing produces readable description") - func syncCoordinatorAlreadySyncing() { - #expect(SyncCoordinatorError.alreadySyncing.localizedDescription == "A sync is already in progress.") - } - - @Test("PersistenceStoreError.contactNotFound produces readable description") - func persistenceStoreContactNotFound() { - #expect(PersistenceStoreError.contactNotFound.localizedDescription == "Contact not found.") - } + // MARK: - MeshCoreError Tests + + @Test + func `MeshCoreError.timeout produces human-readable description`() { + let error: MeshCoreError = .timeout + #expect(error.localizedDescription == "The operation timed out. Please try again.") + } + + @Test(arguments: [ + (UInt8(0x01), "Command not supported by device firmware."), + (UInt8(0x02), "Item not found on device."), + (UInt8(0x03), "Device storage is full."), + (UInt8(0x04), "Device is in an invalid state for this operation."), + (UInt8(0x05), "Device file system error."), + (UInt8(0x06), "Invalid parameter sent to device."), + ]) + func `MeshCoreError.deviceError maps known firmware codes`(code: UInt8, expected: String) { + let error: MeshCoreError = .deviceError(code: code) + #expect(error.localizedDescription == expected, "Code \(code) should produce: \(expected)") + } + + @Test + func `MeshCoreError.deviceError falls back for unknown codes`() { + let error: MeshCoreError = .deviceError(code: 10) + #expect(error.localizedDescription == "Device error (code 10).") + } + + @Test + func `MeshCoreError.deviceError handles code zero`() { + let error: MeshCoreError = .deviceError(code: 0) + #expect(error.localizedDescription == "Device error (code 0).") + } + + @Test + func `MeshCoreError bluetooth errors produce readable descriptions`() { + #expect(MeshCoreError.bluetoothUnavailable.localizedDescription == "Bluetooth is not available on this device.") + #expect(MeshCoreError.bluetoothUnauthorized.localizedDescription == "Bluetooth permission is required. Please enable it in Settings.") + #expect(MeshCoreError.bluetoothPoweredOff.localizedDescription == "Bluetooth is turned off. Please enable Bluetooth to connect.") + } + + @Test + func `MeshCoreError.connectionLost includes underlying error when present`() { + let underlying = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "link dropped"]) + let error: MeshCoreError = .connectionLost(underlying: underlying) + #expect(error.localizedDescription.contains("Connection to device was lost")) + #expect(error.localizedDescription.contains("link dropped")) + } + + @Test + func `MeshCoreError.connectionLost without underlying error`() { + let error: MeshCoreError = .connectionLost(underlying: nil) + #expect(error.localizedDescription == "Connection to device was lost.") + } + + @Test + func `MeshCoreError.featureDisabled produces readable description`() { + #expect(MeshCoreError.featureDisabled.localizedDescription == "This feature is disabled on the device.") + } + + @Test + func `MeshCoreError.sessionNotStarted produces readable description`() { + #expect(MeshCoreError.sessionNotStarted.localizedDescription == "Session has not been started.") + } + + // MARK: - ProtocolError Tests + + @Test(arguments: [ + ProtocolError.unsupportedCommand, .notFound, .tableFull, + .badState, .fileIOError, .illegalArgument, + ]) + func `ProtocolError cases produce non-empty, readable descriptions`(protocolError: ProtocolError) { + let description = protocolError.localizedDescription + #expect(!description.isEmpty, "ProtocolError.\(protocolError) should have a description") + #expect(!description.contains("ProtocolError"), "Should not contain raw type name") + } + + // MARK: - Service Error Session Pass-Through Tests + + @Test + func `MessageServiceError.sessionError passes through MeshCoreError description`() { + let meshError: MeshCoreError = .deviceError(code: 0x03) + let serviceError: MessageServiceError = .sessionError(meshError) + #expect(serviceError.localizedDescription == "Device storage is full.") + } + + @Test + func `ChannelServiceError.sessionError passes through MeshCoreError description`() { + let meshError: MeshCoreError = .timeout + let serviceError: ChannelServiceError = .sessionError(meshError) + #expect(serviceError.localizedDescription == "The operation timed out. Please try again.") + } + + @Test + func `SettingsServiceError.sessionError passes through without prefix`() { + let meshError: MeshCoreError = .notConnected + let serviceError: SettingsServiceError = .sessionError(meshError) + #expect(!serviceError.localizedDescription.contains("Session error:")) + #expect(serviceError.localizedDescription == "Not connected to device.") + } + + @Test + func `SettingsServiceError.deviceGPSVerificationFailed is human-readable`() { + let serviceError: SettingsServiceError = .deviceGPSVerificationFailed( + expectedEnabled: false, + actualEnabled: true + ) + #expect( + serviceError.localizedDescription == + "Device GPS setting was not saved. Expected 'Off' but device reports 'On'." + ) + } + + @Test + func `RemoteNodeError.sessionError passes through without prefix`() { + let meshError: MeshCoreError = .bluetoothPoweredOff + let serviceError: RemoteNodeError = .sessionError(meshError) + #expect(!serviceError.localizedDescription.contains("Session error:")) + #expect(serviceError.localizedDescription == "Bluetooth is turned off. Please enable Bluetooth to connect.") + } + + // MARK: - Service Error Spot Checks + + @Test + func `AdvertisementError.notConnected produces readable description`() { + #expect(AdvertisementError.notConnected.localizedDescription == "Not connected to device.") + } + + @Test + func `RoomServerError.permissionDenied produces readable description`() { + #expect(RoomServerError.permissionDenied.localizedDescription == "Permission denied.") + } + + @Test + func `BinaryProtocolError.timeout produces readable description`() { + #expect(BinaryProtocolError.timeout.localizedDescription == "Request timed out.") + } + + @Test + func `SyncCoordinatorError.alreadySyncing produces readable description`() { + #expect(SyncCoordinatorError.alreadySyncing.localizedDescription == "A sync is already in progress.") + } + + @Test + func `PersistenceStoreError.contactNotFound produces readable description`() { + #expect(PersistenceStoreError.contactNotFound.localizedDescription == "Contact not found.") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift index fc07431e..eed840a1 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift @@ -1,67 +1,239 @@ // MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift -import Testing import Foundation @testable import MC1Services +import MeshCore +import Testing @Suite("HeardRepeatsService Tests") struct HeardRepeatsServiceTests { - - // MARK: - ChannelMessageFormat.parse Tests - - @Test("parse with valid format returns sender and message") - func parseValidFormatReturnsSenderAndMessage() { - let result = ChannelMessageFormat.parse("NodeName: Hello world") - - #expect(result != nil) - #expect(result?.senderName == "NodeName") - #expect(result?.messageText == "Hello world") - } - - @Test("parse with no colon returns nil") - func parseNoColonReturnsNil() { - let result = ChannelMessageFormat.parse("No colon here") - - #expect(result == nil) - } - - @Test("parse with colon at start returns nil") - func parseColonAtStartReturnsNil() { - let result = ChannelMessageFormat.parse(": Message without sender") - - #expect(result == nil) - } - - @Test("parse with empty message returns empty text") - func parseEmptyMessageReturnsEmptyText() { - let result = ChannelMessageFormat.parse("Sender:") - - #expect(result != nil) - #expect(result?.senderName == "Sender") - #expect(result?.messageText == "") - } - - @Test("parse with message containing colons only splits on first") - func parseMessageWithColonsOnlySplitsOnFirst() { - let result = ChannelMessageFormat.parse("Sender: Time is 10:30:00") - - #expect(result != nil) - #expect(result?.senderName == "Sender") - #expect(result?.messageText == "Time is 10:30:00") - } - - @Test("parse trims whitespace from message") - func parseTrimsWhitespaceFromMessage() { - let result = ChannelMessageFormat.parse("Node: Padded message ") - - #expect(result != nil) - #expect(result?.messageText == "Padded message") - } - - @Test("parse preserves spaces in sender name") - func parseSenderWithSpacesPreservesSpaces() { - let result = ChannelMessageFormat.parse("Node With Spaces: Message") - - #expect(result != nil) - #expect(result?.senderName == "Node With Spaces") - } + // MARK: - ChannelMessageFormat.parse Tests + + @Test + func `parse with valid format returns sender and message`() { + let result = ChannelMessageFormat.parse("NodeName: Hello world") + + #expect(result != nil) + #expect(result?.senderName == "NodeName") + #expect(result?.messageText == "Hello world") + } + + @Test + func `parse with no colon returns nil`() { + let result = ChannelMessageFormat.parse("No colon here") + + #expect(result == nil) + } + + @Test + func `parse with colon at start returns nil`() { + let result = ChannelMessageFormat.parse(": Message without sender") + + #expect(result == nil) + } + + @Test + func `parse with empty message returns empty text`() { + let result = ChannelMessageFormat.parse("Sender:") + + #expect(result != nil) + #expect(result?.senderName == "Sender") + #expect(result?.messageText == "") + } + + @Test + func `parse with message containing colons only splits on first`() { + let result = ChannelMessageFormat.parse("Sender: Time is 10:30:00") + + #expect(result != nil) + #expect(result?.senderName == "Sender") + #expect(result?.messageText == "Time is 10:30:00") + } + + @Test + func `parse trims whitespace from message`() { + let result = ChannelMessageFormat.parse("Node: Padded message ") + + #expect(result != nil) + #expect(result?.messageText == "Padded message") + } + + @Test + func `parse preserves spaces in sender name`() { + let result = ChannelMessageFormat.parse("Node With Spaces: Message") + + #expect(result != nil) + #expect(result?.senderName == "Node With Spaces") + } + + // MARK: - processForRepeats Matching Tests + + private static let testNodeName = "TestNode" + + private func makeStoreAndService() throws -> (PersistenceStore, HeardRepeatsService) { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + return (store, HeardRepeatsService(dataStore: store)) + } + + /// Builds a decrypted channel-message echo the service can correlate: the + /// decoded text carries the `"NodeName: body"` format and a matching + /// `senderTimestamp`. + private func makeEcho( + radioID: UUID, + channelIndex: UInt8, + senderTimestamp: UInt32, + body: String, + senderName: String = testNodeName, + id: UUID = UUID() + ) -> RxLogEntryDTO { + let parsed = ParsedRxLogData( + snr: 8.0, + rssi: -70, + rawPayload: Data([0x01]), + routeType: .flood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: nil, + pathLength: 1, + pathNodes: [0x42], + packetPayload: Data([0x01, 0x02, 0x03]) + ) + return RxLogEntryDTO( + id: id, + radioID: radioID, + from: parsed, + channelIndex: channelIndex, + channelName: "Test", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + decodedText: "\(senderName): \(body)" + ) + } + + @Test + func `counts a repeat whose send is far outside the old 10s window`() async throws { + let (store, service) = try makeStoreAndService() + let radioID = UUID() + let channelIndex: UInt8 = 2 + // Sent two minutes ago: beyond the removed 10-second wall-clock gate. + let sendTimestamp = UInt32(Date().timeIntervalSince1970) &- 120 + let messageID = UUID() + try await store.saveMessage(MessageDTO.testChannelMessage( + id: messageID, + radioID: radioID, + channelIndex: channelIndex, + text: "north repeater check", + timestamp: sendTimestamp + )) + await service.configure(radioID: radioID, localNodeName: Self.testNodeName) + + let events = service.events() + let echo = makeEcho( + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: sendTimestamp, + body: "north repeater check" + ) + let count = await service.processForRepeats(echo) + + #expect(count == 1) + let repeats = try await store.fetchMessageRepeats(messageID: messageID) + #expect(repeats.count == 1) + #expect(repeats.first?.rxLogEntryID == echo.id) + + var iterator = events.makeAsyncIterator() + let event = await iterator.next() + #expect(event?.messageID == messageID) + #expect(event?.count == 1) + } + + @Test + func `same RX log entry is counted once`() async throws { + let (store, service) = try makeStoreAndService() + let radioID = UUID() + let channelIndex: UInt8 = 0 + let sendTimestamp = UInt32(Date().timeIntervalSince1970) + let messageID = UUID() + try await store.saveMessage(MessageDTO.testChannelMessage( + id: messageID, + radioID: radioID, + channelIndex: channelIndex, + text: "hello", + timestamp: sendTimestamp + )) + await service.configure(radioID: radioID, localNodeName: Self.testNodeName) + + let echo = makeEcho( + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: sendTimestamp, + body: "hello" + ) + let first = await service.processForRepeats(echo) + let second = await service.processForRepeats(echo) + + #expect(first == 1) + #expect(second == nil) + let repeats = try await store.fetchMessageRepeats(messageID: messageID) + #expect(repeats.count == 1) + } + + @Test + func `no match for unknown timestamp or foreign sender`() async throws { + let (store, service) = try makeStoreAndService() + let radioID = UUID() + let channelIndex: UInt8 = 1 + let sendTimestamp = UInt32(Date().timeIntervalSince1970) + try await store.saveMessage(MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: channelIndex, + text: "hello", + timestamp: sendTimestamp + )) + await service.configure(radioID: radioID, localNodeName: Self.testNodeName) + + let wrongTimestamp = makeEcho( + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: sendTimestamp &+ 5, + body: "hello" + ) + #expect(await service.processForRepeats(wrongTimestamp) == nil) + + let foreignSender = makeEcho( + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: sendTimestamp, + body: "hello", + senderName: "SomeoneElse" + ) + #expect(await service.processForRepeats(foreignSender) == nil) + } + + @Test + func `exact text disambiguates messages sharing channel and timestamp`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channelIndex: UInt8 = 3 + let timestamp = UInt32(Date().timeIntervalSince1970) + let aID = UUID() + let bID = UUID() + try await store.saveMessage(MessageDTO.testChannelMessage( + id: aID, radioID: radioID, channelIndex: channelIndex, text: "message A", timestamp: timestamp + )) + try await store.saveMessage(MessageDTO.testChannelMessage( + id: bID, radioID: radioID, channelIndex: channelIndex, text: "message B", timestamp: timestamp + )) + + let matchB = try await store.findSentChannelMessage( + radioID: radioID, channelIndex: channelIndex, timestamp: timestamp, text: "message B" + ) + #expect(matchB?.id == bID) + let matchA = try await store.findSentChannelMessage( + radioID: radioID, channelIndex: channelIndex, timestamp: timestamp, text: "message A" + ) + #expect(matchA?.id == aID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/KeyGenerationServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/KeyGenerationServiceTests.swift index 82575dff..590bb282 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/KeyGenerationServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/KeyGenerationServiceTests.swift @@ -1,231 +1,230 @@ import CryptoKit import Foundation -import Testing @testable import MC1Services +import Testing @Suite("KeyGenerationService Tests") struct KeyGenerationServiceTests { - - @Test("Generated expanded key is 64 bytes and public key is 32 bytes") - func keySizes() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - - #expect(result.expandedPrivateKey.count == 64) - #expect(result.publicKey.count == 32) - } - - @Test("Public key never starts with 0x00 or 0xFF") - func reservedBytesRejected() async throws { - // Generate multiple keys and verify none start with reserved bytes - for _ in 0..<20 { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let firstByte = result.publicKey[result.publicKey.startIndex] - #expect(firstByte != 0x00, "Public key should never start with 0x00") - #expect(firstByte != 0xFF, "Public key should never start with 0xFF") - } - } - - @Test("2-char vanity prefix is respected", arguments: ["AA", "42", "7F"]) - func twoCharPrefix(prefix: String) async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") - } - - @Test("1-char vanity prefix is respected", arguments: ["A", "7", "3"]) - func oneCharPrefix(prefix: String) async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") - } - - @Test("3-char vanity prefix is respected") - func threeCharPrefix() async throws { - let prefix = "A7B" - let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") - } - - @Test("4-char vanity prefix is respected") - func fourCharPrefix() async throws { - let prefix = "A7B2" - let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") - } - - @Test("Nil prefix generates any valid key") - func nilPrefix() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - #expect(result.publicKey.count == 32) - } - - @Test("Reserved prefix '00' throws reservedPrefix error") - func reservedPrefixZero() async { - await #expect(throws: KeyGenerationError.reservedPrefix) { - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "00") - } - } - - @Test("Reserved prefix 'FF' throws reservedPrefix error") - func reservedPrefixFF() async { - await #expect(throws: KeyGenerationError.reservedPrefix) { - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "FF") - } - } - - @Test("Reserved multi-char prefix '00A1' throws reservedPrefix error") - func reservedPrefixMultiChar00() async { - await #expect(throws: KeyGenerationError.reservedPrefix) { - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "00A1") - } - } - - @Test("Reserved multi-char prefix 'FFB2' throws reservedPrefix error") - func reservedPrefixMultiCharFF() async { - await #expect(throws: KeyGenerationError.reservedPrefix) { - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "FFB2") - } - } - - @Test("Single-char '0' is not reserved and succeeds") - func singleCharZeroAllowed() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: "0") - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix("0"), "Expected public key hex to start with '0', got \(publicHex)") - } - - @Test("Single-char 'F' is not reserved and succeeds") - func singleCharFAllowed() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: "F") - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix("F"), "Expected public key hex to start with 'F', got \(publicHex)") - } - - @Test("Cancellation is respected") - func cancellation() async { - let task = Task { - // Use a very unlikely 4-char prefix to make the loop run long - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "0101") - } - - // Cancel immediately - task.cancel() - - let result = await task.result - switch result { - case .success: - // If the key was found before cancellation, that's fine - break - case .failure(let error): - #expect(error is CancellationError, "Expected CancellationError, got \(error)") - } - } - - @Test("Expanded key has correct SHA-512 clamping") - func expansionClamping() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let expanded = result.expandedPrivateKey - - // RFC 8032 bit clamping checks: - // expanded[0] lowest 3 bits must be clear - #expect(expanded[0] & 0x07 == 0, "Lowest 3 bits of first byte should be cleared") - - // expanded[31] highest bit must be clear, second-highest must be set - #expect(expanded[31] & 0x80 == 0, "Highest bit of byte 31 should be cleared") - #expect(expanded[31] & 0x40 == 0x40, "Second-highest bit of byte 31 should be set") - } - - @Test("Expansion matches manual SHA-512 + clamp") - func expansionMatchesManual() async throws { - // Generate a key, then verify the expansion independently - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let expandedKey = result.expandedPrivateKey - let publicKey = result.publicKey - - // Verify the expanded key has valid structure. - #expect(expandedKey.count == 64) - #expect(publicKey.count == 32) - - // The expanded key's clamping is already verified above. - // Verify the expanded key is different from the public key (they serve different purposes) - #expect(Data(expandedKey.prefix(32)) != publicKey) - } - - @Test("randomGenerationFailed error has a description") - func randomGenerationFailedError() { - let error = KeyGenerationError.randomGenerationFailed - #expect(error.errorDescription != nil) - #expect(error.errorDescription?.isEmpty == false) - } - - @Test("Multiple generations produce different keys") - func uniqueKeys() async throws { - let result1 = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let result2 = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - - #expect(result1.publicKey != result2.publicKey, "Two generated keys should be different") - #expect(result1.expandedPrivateKey != result2.expandedPrivateKey) - } - - // MARK: - validateExpandedKey Tests - - @Test("Valid expanded key passes validation") - func validateValidKey() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - try KeyGenerationService.validateExpandedKey(result.expandedPrivateKey) - } - - @Test("Wrong length throws invalidKey", arguments: [32, 63, 65, 0]) - func validateWrongLength(length: Int) { - let data = Data(repeating: 0x40, count: length) - #expect(throws: KeyGenerationError.invalidKey) { - try KeyGenerationService.validateExpandedKey(data) - } - } - - @Test("Bad clamping byte 0 (lowest 3 bits set) throws invalidKey") - func validateBadByte0() async throws { - var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey - key[0] |= 0x07 // set lowest 3 bits - #expect(throws: KeyGenerationError.invalidKey) { - try KeyGenerationService.validateExpandedKey(key) - } - } - - @Test("Bad clamping byte 31 (highest bit set) throws invalidKey") - func validateBadByte31HighBit() async throws { - var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey - key[31] |= 0x80 // set highest bit - #expect(throws: KeyGenerationError.invalidKey) { - try KeyGenerationService.validateExpandedKey(key) - } - } - - @Test("Bad clamping byte 31 (second-highest bit clear) throws invalidKey") - func validateBadByte31SecondHighBit() async throws { - var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey - key[31] &= ~0x40 // clear second-highest bit - #expect(throws: KeyGenerationError.invalidKey) { - try KeyGenerationService.validateExpandedKey(key) - } - } - - @Test("invalidKey error has a description") - func invalidKeyError() { - let error = KeyGenerationError.invalidKey - #expect(error.errorDescription != nil) - #expect(error.errorDescription?.isEmpty == false) - } - - @Test("Validation works on a non-zero-based Data slice") - func validateNonZeroBasedSlice() async throws { - let key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey - // A slice keeps its parent's indices, so startIndex is non-zero here. - let padded = Data([0xFF, 0xFF, 0xFF, 0xFF]) + key - let slice = padded.suffix(key.count) - #expect(slice.startIndex != 0) - try KeyGenerationService.validateExpandedKey(slice) - } + @Test + func `Generated expanded key is 64 bytes and public key is 32 bytes`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + + #expect(result.expandedPrivateKey.count == 64) + #expect(result.publicKey.count == 32) + } + + @Test + func `Public key never starts with 0x00 or 0xFF`() async throws { + // Generate multiple keys and verify none start with reserved bytes + for _ in 0..<20 { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let firstByte = result.publicKey[result.publicKey.startIndex] + #expect(firstByte != 0x00, "Public key should never start with 0x00") + #expect(firstByte != 0xFF, "Public key should never start with 0xFF") + } + } + + @Test(arguments: ["AA", "42", "7F"]) + func `2-char vanity prefix is respected`(prefix: String) async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") + } + + @Test(arguments: ["A", "7", "3"]) + func `1-char vanity prefix is respected`(prefix: String) async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") + } + + @Test + func `3-char vanity prefix is respected`() async throws { + let prefix = "A7B" + let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") + } + + @Test + func `4-char vanity prefix is respected`() async throws { + let prefix = "A7B2" + let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") + } + + @Test + func `Nil prefix generates any valid key`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + #expect(result.publicKey.count == 32) + } + + @Test + func `Reserved prefix '00' throws reservedPrefix error`() async { + await #expect(throws: KeyGenerationError.reservedPrefix) { + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "00") + } + } + + @Test + func `Reserved prefix 'FF' throws reservedPrefix error`() async { + await #expect(throws: KeyGenerationError.reservedPrefix) { + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "FF") + } + } + + @Test + func `Reserved multi-char prefix '00A1' throws reservedPrefix error`() async { + await #expect(throws: KeyGenerationError.reservedPrefix) { + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "00A1") + } + } + + @Test + func `Reserved multi-char prefix 'FFB2' throws reservedPrefix error`() async { + await #expect(throws: KeyGenerationError.reservedPrefix) { + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "FFB2") + } + } + + @Test + func `Single-char '0' is not reserved and succeeds`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: "0") + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix("0"), "Expected public key hex to start with '0', got \(publicHex)") + } + + @Test + func `Single-char 'F' is not reserved and succeeds`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: "F") + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix("F"), "Expected public key hex to start with 'F', got \(publicHex)") + } + + @Test + func `Cancellation is respected`() async { + let task = Task { + // Use a very unlikely 4-char prefix to make the loop run long + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "0101") + } + + // Cancel immediately + task.cancel() + + let result = await task.result + switch result { + case .success: + // If the key was found before cancellation, that's fine + break + case let .failure(error): + #expect(error is CancellationError, "Expected CancellationError, got \(error)") + } + } + + @Test + func `Expanded key has correct SHA-512 clamping`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let expanded = result.expandedPrivateKey + + // RFC 8032 bit clamping checks: + // expanded[0] lowest 3 bits must be clear + #expect(expanded[0] & 0x07 == 0, "Lowest 3 bits of first byte should be cleared") + + // expanded[31] highest bit must be clear, second-highest must be set + #expect(expanded[31] & 0x80 == 0, "Highest bit of byte 31 should be cleared") + #expect(expanded[31] & 0x40 == 0x40, "Second-highest bit of byte 31 should be set") + } + + @Test + func `Expansion matches manual SHA-512 + clamp`() async throws { + // Generate a key, then verify the expansion independently + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let expandedKey = result.expandedPrivateKey + let publicKey = result.publicKey + + // Verify the expanded key has valid structure. + #expect(expandedKey.count == 64) + #expect(publicKey.count == 32) + + // The expanded key's clamping is already verified above. + // Verify the expanded key is different from the public key (they serve different purposes) + #expect(Data(expandedKey.prefix(32)) != publicKey) + } + + @Test + func `randomGenerationFailed error has a description`() { + let error = KeyGenerationError.randomGenerationFailed + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) + } + + @Test + func `Multiple generations produce different keys`() async throws { + let result1 = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let result2 = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + + #expect(result1.publicKey != result2.publicKey, "Two generated keys should be different") + #expect(result1.expandedPrivateKey != result2.expandedPrivateKey) + } + + // MARK: - validateExpandedKey Tests + + @Test + func `Valid expanded key passes validation`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + try KeyGenerationService.validateExpandedKey(result.expandedPrivateKey) + } + + @Test(arguments: [32, 63, 65, 0]) + func `Wrong length throws invalidKey`(length: Int) { + let data = Data(repeating: 0x40, count: length) + #expect(throws: KeyGenerationError.invalidKey) { + try KeyGenerationService.validateExpandedKey(data) + } + } + + @Test + func `Bad clamping byte 0 (lowest 3 bits set) throws invalidKey`() async throws { + var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey + key[0] |= 0x07 // set lowest 3 bits + #expect(throws: KeyGenerationError.invalidKey) { + try KeyGenerationService.validateExpandedKey(key) + } + } + + @Test + func `Bad clamping byte 31 (highest bit set) throws invalidKey`() async throws { + var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey + key[31] |= 0x80 // set highest bit + #expect(throws: KeyGenerationError.invalidKey) { + try KeyGenerationService.validateExpandedKey(key) + } + } + + @Test + func `Bad clamping byte 31 (second-highest bit clear) throws invalidKey`() async throws { + var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey + key[31] &= ~0x40 // clear second-highest bit + #expect(throws: KeyGenerationError.invalidKey) { + try KeyGenerationService.validateExpandedKey(key) + } + } + + @Test + func `invalidKey error has a description`() { + let error = KeyGenerationError.invalidKey + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) + } + + @Test + func `Validation works on a non-zero-based Data slice`() async throws { + let key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey + // A slice keeps its parent's indices, so startIndex is non-zero here. + let padded = Data([0xFF, 0xFF, 0xFF, 0xFF]) + key + let slice = padded.suffix(key.count) + #expect(slice.startIndex != 0) + try KeyGenerationService.validateExpandedKey(slice) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/LoginTimeoutConfigTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/LoginTimeoutConfigTests.swift index bae5e34e..a5d3e259 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/LoginTimeoutConfigTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/LoginTimeoutConfigTests.swift @@ -1,96 +1,95 @@ import Foundation -import Testing @testable import MC1Services @testable import MeshCore +import Testing @Suite("LoginTimeoutConfig Tests") struct LoginTimeoutConfigTests { - - private func makeSentInfo(timeoutMs: UInt32) -> MessageSentInfo { - MessageSentInfo(route: 0, expectedAck: Data([0x00]), suggestedTimeoutMs: timeoutMs) - } - - @Test("Direct path (mode 0) uses base timeout only") - func directPathMode0() { - // Mode 0, 0 hops → encoded as 0x00 - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x00) - #expect(timeout == .seconds(5)) - } - - @Test("Direct path (mode 1) uses base timeout only, not mode bits") - func directPathMode1() { - // Mode 1, 0 hops → encoded as 0x40 (64 decimal) - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x40) - #expect(timeout == .seconds(5)) - } - - @Test("Direct path (mode 2) uses base timeout only, not mode bits") - func directPathMode2() { - // Mode 2, 0 hops → encoded as 0x80 (128 decimal) - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x80) - #expect(timeout == .seconds(5)) - } - - @Test("Mode 1 with 3 hops computes timeout from hop count") - func mode1With3Hops() { - // Mode 1, 3 hops → encoded as 0x43 - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x43) - #expect(timeout == .seconds(35)) // 5 + 3*10 - } - - @Test("Mode 0 with 5 hops computes correct timeout") - func mode0With5Hops() { - let timeout = LoginTimeoutConfig.timeout(forPathLength: 5) - #expect(timeout == .seconds(55)) // 5 + 5*10 - } - - @Test("Flood routing (0xFF) falls back to base timeout") - func floodRouting() { - // 0xFF: mode 3 (reserved) → decodePathLen returns nil → 0 hops - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0xFF) - #expect(timeout == .seconds(5)) - } - - @Test("Timeout is capped at maximum") - func timeoutCapped() { - // Mode 0, 6 hops → 5 + 60 = 65, should cap at 60 - let timeout = LoginTimeoutConfig.timeout(forPathLength: 6) - #expect(timeout == .seconds(60)) - } - - @Test("Login timeout policy clamps long firmware suggestions") - func loginTimeoutPolicyClampsFirmwareSuggestion() { - let sentInfo = makeSentInfo(timeoutMs: 20_000) - - let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0) - - #expect(timeout == .seconds(20)) - } - - @Test("Login timeout policy respects path floor when firmware is shorter") - func loginTimeoutPolicyUsesPathFloor() { - let sentInfo = makeSentInfo(timeoutMs: 1_000) - - let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0x43) - - #expect(timeout == .seconds(20)) - } - - @Test("CLI timeout policy clamps long firmware suggestions") - func cliTimeoutPolicyClampsFirmwareSuggestion() { - let sentInfo = makeSentInfo(timeoutMs: 20_000) - - let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) - - #expect(timeout == .seconds(15)) - } - - @Test("CLI timeout policy keeps caller budget when firmware is shorter") - func cliTimeoutPolicyUsesCallerBudgetFloor() { - let sentInfo = makeSentInfo(timeoutMs: 1_000) - - let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) - - #expect(timeout == .seconds(10)) - } + private func makeSentInfo(timeoutMs: UInt32) -> MessageSentInfo { + MessageSentInfo(route: 0, expectedAck: Data([0x00]), suggestedTimeoutMs: timeoutMs) + } + + @Test + func `Direct path (mode 0) uses base timeout only`() { + // Mode 0, 0 hops → encoded as 0x00 + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x00) + #expect(timeout == .seconds(5)) + } + + @Test + func `Direct path (mode 1) uses base timeout only, not mode bits`() { + // Mode 1, 0 hops → encoded as 0x40 (64 decimal) + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x40) + #expect(timeout == .seconds(5)) + } + + @Test + func `Direct path (mode 2) uses base timeout only, not mode bits`() { + // Mode 2, 0 hops → encoded as 0x80 (128 decimal) + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x80) + #expect(timeout == .seconds(5)) + } + + @Test + func `Mode 1 with 3 hops computes timeout from hop count`() { + // Mode 1, 3 hops → encoded as 0x43 + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x43) + #expect(timeout == .seconds(35)) // 5 + 3*10 + } + + @Test + func `Mode 0 with 5 hops computes correct timeout`() { + let timeout = LoginTimeoutConfig.timeout(forPathLength: 5) + #expect(timeout == .seconds(55)) // 5 + 5*10 + } + + @Test + func `Flood routing (0xFF) falls back to base timeout`() { + // 0xFF: mode 3 (reserved) → decodePathLen returns nil → 0 hops + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0xFF) + #expect(timeout == .seconds(5)) + } + + @Test + func `Timeout is capped at maximum`() { + // Mode 0, 6 hops → 5 + 60 = 65, should cap at 60 + let timeout = LoginTimeoutConfig.timeout(forPathLength: 6) + #expect(timeout == .seconds(60)) + } + + @Test + func `Login timeout policy clamps long firmware suggestions`() { + let sentInfo = makeSentInfo(timeoutMs: 20000) + + let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0) + + #expect(timeout == .seconds(20)) + } + + @Test + func `Login timeout policy respects path floor when firmware is shorter`() { + let sentInfo = makeSentInfo(timeoutMs: 1000) + + let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0x43) + + #expect(timeout == .seconds(20)) + } + + @Test + func `CLI timeout policy clamps long firmware suggestions`() { + let sentInfo = makeSentInfo(timeoutMs: 20000) + + let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) + + #expect(timeout == .seconds(15)) + } + + @Test + func `CLI timeout policy keeps caller budget when firmware is shorter`() { + let sentInfo = makeSentInfo(timeoutMs: 1000) + + let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) + + #expect(timeout == .seconds(10)) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MeshCoreOpenReactionParserTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MeshCoreOpenReactionParserTests.swift index 46a07274..cdbdf304 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MeshCoreOpenReactionParserTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MeshCoreOpenReactionParserTests.swift @@ -1,494 +1,493 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("MeshCoreOpenReactionParser Tests") struct MeshCoreOpenReactionParserTests { - - // MARK: - Parse Valid Format Tests - - @Test("Parses valid reaction with thumbs up (index 00)") - func parsesThumbsUp() { - let result = MeshCoreOpenReactionParser.parse("r:a1b2:00") - - #expect(result != nil) - #expect(result?.emoji == "👍") - #expect(result?.dartHash == "a1b2") - } - - @Test("Parses valid reaction with fire (index 05)") - func parsesFire() { - let result = MeshCoreOpenReactionParser.parse("r:ff00:05") - - #expect(result != nil) - #expect(result?.emoji == "🔥") - #expect(result?.dartHash == "ff00") - } - - @Test("Parses valid reaction with heart (index 01)") - func parsesHeart() { - let result = MeshCoreOpenReactionParser.parse("r:1234:01") - - #expect(result != nil) - #expect(result?.emoji == "❤️") - #expect(result?.dartHash == "1234") - } - - @Test("Parses reaction at max valid emoji index (0xb7)") - func parsesMaxIndex() { - let result = MeshCoreOpenReactionParser.parse("r:abcd:b7") - - #expect(result != nil) - #expect(result?.emoji == "🚀") - #expect(result?.dartHash == "abcd") - } - - // MARK: - Parse Invalid Format Tests - - @Test("Rejects plain text") - func rejectsPlainText() { - #expect(MeshCoreOpenReactionParser.parse("hello world") == nil) - } - - @Test("Rejects wrong prefix") - func rejectsWrongPrefix() { - #expect(MeshCoreOpenReactionParser.parse("x:a1b2:00") == nil) - } - - @Test("Rejects uppercase hex in hash") - func rejectsUppercaseHash() { - #expect(MeshCoreOpenReactionParser.parse("r:A1B2:00") == nil) - } - - @Test("Rejects uppercase hex in index") - func rejectsUppercaseIndex() { - #expect(MeshCoreOpenReactionParser.parse("r:a1b2:0A") == nil) - } - - @Test("Rejects too short") - func rejectsTooShort() { - #expect(MeshCoreOpenReactionParser.parse("r:a1b:00") == nil) - } - - @Test("Rejects too long") - func rejectsTooLong() { - #expect(MeshCoreOpenReactionParser.parse("r:a1b2c:00") == nil) - } - - @Test("Rejects missing colons") - func rejectsMissingColons() { - #expect(MeshCoreOpenReactionParser.parse("r-a1b2-00") == nil) - } - - @Test("Rejects emoji index beyond table size") - func rejectsOutOfRangeIndex() { - // 0xb8 = 184, table has 184 entries (0x00–0xb7) - #expect(MeshCoreOpenReactionParser.parse("r:a1b2:b8") == nil) - } - - @Test("Rejects legacy channel reaction format") - func rejectsLegacyChannelFormat() { - #expect(MeshCoreOpenReactionParser.parse("👍@[AlphaNode]\n7f3a9c12") == nil) - } - - @Test("Rejects legacy DM reaction format") - func rejectsLegacyDMFormat() { - #expect(MeshCoreOpenReactionParser.parse("👍\n7f3a9c12") == nil) - } - - @Test("Rejects empty string") - func rejectsEmpty() { - #expect(MeshCoreOpenReactionParser.parse("") == nil) - } - - // MARK: - Emoji Index Mapping Tests - - @Test("Spot-check emoji indices across all categories") - func spotCheckEmojiIndices() { - // quickEmojis - #expect(MeshCoreOpenReactionParser.parse("r:0000:00")?.emoji == "👍") // 0x00 - #expect(MeshCoreOpenReactionParser.parse("r:0000:02")?.emoji == "😂") // 0x02 - #expect(MeshCoreOpenReactionParser.parse("r:0000:03")?.emoji == "🎉") // 0x03 - - // smileys start at 0x06 - #expect(MeshCoreOpenReactionParser.parse("r:0000:06")?.emoji == "😀") // first smiley - #expect(MeshCoreOpenReactionParser.parse("r:0000:45")?.emoji == "😶") // last smiley - - // gestures start at 0x46 - #expect(MeshCoreOpenReactionParser.parse("r:0000:46")?.emoji == "👍") // first gesture - #expect(MeshCoreOpenReactionParser.parse("r:0000:66")?.emoji == "💪") // last gesture - - // hearts start at 0x67 - #expect(MeshCoreOpenReactionParser.parse("r:0000:67")?.emoji == "❤️") // first heart - - // objects start at 0x87 - #expect(MeshCoreOpenReactionParser.parse("r:0000:87")?.emoji == "🎉") // first object - } - - // MARK: - Dart String Hash Tests - - @Test("Dart hash of empty input produces 1") - func dartHashEmpty() { - // Dart: "".hashCode should be 0, which becomes 1 (zero-guard) - let hash = MeshCoreOpenReactionParser.dartStringHash([]) - #expect(hash == 1) - } - - @Test("Dart hash is deterministic") - func dartHashDeterministic() { - let units: [UInt16] = Array("hello".utf16) - let hash1 = MeshCoreOpenReactionParser.dartStringHash(units) - let hash2 = MeshCoreOpenReactionParser.dartStringHash(units) - #expect(hash1 == hash2) - } - - @Test("Dart hash of single character 'a'") - func dartHashSingleChar() { - // Manually compute: code_unit = 97 (0x61) - // hash = 0 - // hash += 97 → 97 - // hash += 97 << 10 → 97 + 99328 = 99425 - // hash ^= 99425 >> 6 → 99425 ^ 1553 = 100464 - // finalize: - // hash += 100464 << 3 → 100464 + 803712 = 904176 - // hash ^= 904176 >> 11 → 904176 ^ 441 = 904617 - // hash += 904617 << 15 → 904617 + 29640630272 (wraps in UInt32) → need wrapping - // Let's just verify it's > 0 and within 30 bits - let hash = MeshCoreOpenReactionParser.dartStringHash([97]) - #expect(hash > 0) - #expect(hash < (1 << 30)) - } - - @Test("Dart hash result is within 30-bit range") - func dartHashRange() { - let units: [UInt16] = Array("test string with various chars 🎉".utf16) - let hash = MeshCoreOpenReactionParser.dartStringHash(units) - #expect(hash > 0) - #expect(hash <= (1 << 30) - 1) - } - - @Test("Different inputs produce different hashes") - func dartHashDifferentInputs() { - let hash1 = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) - let hash2 = MeshCoreOpenReactionParser.dartStringHash(Array("world".utf16)) - #expect(hash1 != hash2) - } - - // MARK: - Hash Computation Tests - - @Test("computeReactionHash returns 4-char lowercase hex") - func hashFormat() { - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "AlphaNode", - text: "Hello world" - ) - #expect(hash.count == 4) - #expect(hash.allSatisfy { $0.isHexDigit && !$0.isUppercase }) - } - - @Test("computeReactionHash is deterministic") - func hashDeterministic() { - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "AlphaNode", - text: "Hello world" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "AlphaNode", - text: "Hello world" - ) - #expect(hash1 == hash2) - } - - @Test("computeReactionHash changes with different timestamp") - func hashChangesWithTimestamp() { - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000001, - senderName: "Node", - text: "Hello" - ) - #expect(hash1 != hash2) - } - - @Test("computeReactionHash changes with different sender") - func hashChangesWithSender() { - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "AlphaNode", - text: "Hello" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "BetaNode", - text: "Hello" - ) - #expect(hash1 != hash2) - } - - @Test("computeReactionHash changes with different text") - func hashChangesWithText() { - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "World" - ) - #expect(hash1 != hash2) - } - - @Test("computeReactionHash with nil sender (DM mode)") - func hashDMMode() { - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: nil, - text: "Hello world" - ) - #expect(hash.count == 4) - - // Should differ from channel mode with same params - let channelHash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello world" - ) - #expect(hash != channelHash) - } - - @Test("computeReactionHash truncates text to 5 UTF-16 code units") - func hashTruncatesText() { - // "Hello" is 5 code units, "Hello world" has 11 - // Both should produce the same hash since only first 5 code units are used - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello world" - ) - #expect(hash1 == hash2) - } - - @Test("computeReactionHash handles short text (fewer than 5 code units)") - func hashShortText() { - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: nil, - text: "Hi" - ) - #expect(hash.count == 4) - } - - // MARK: - UTF-16 Edge Cases - - @Test("computeReactionHash handles emoji in text (multi-code-unit)") - func hashEmojiText() { - // 🎉 is 2 UTF-16 code units (surrogate pair), so "🎉abc" = 5 code units - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: nil, - text: "🎉abc" - ) - #expect(hash.count == 4) - - // "🎉abcdef" should hash the same since first 5 code units match - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: nil, - text: "🎉abcdef" - ) - #expect(hash == hash2) - } - - @Test("computeReactionHash handles empty text") - func hashEmptyText() { - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "" - ) - #expect(hash.count == 4) - } - - // MARK: - Cross-App Test Vectors - - @Test("Dart hash matches known Dart VM output for 'hello'") - func dartHashKnownVector() { - // In Dart: "hello".hashCode == 150804507 - // This is the definitive cross-app test vector - let hash = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) - #expect(hash == 150804507) - } - - @Test("computeReactionHash is internally consistent") - func hashInternalConsistency() { - // Verify computeReactionHash assembles code units correctly - // by comparing against manual dartStringHash call - let testUnits = Array("1700000000AHello".utf16) - let fullHash = MeshCoreOpenReactionParser.dartStringHash(testUnits) - let masked = fullHash & 0xFFFF - let expected = String(format: "%04x", masked) - - let computed = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "A", - text: "Hello" - ) - #expect(computed == expected) - } - - @Test("Emoji table has exactly 184 entries") - func emojiTableSize() { - #expect(MeshCoreOpenReactionParser.emojiTable.count == 184) - } - - // MARK: - V1 Parse Tests - - @Test("Parses v1 reaction from real wire capture") - func parsesV1RealCapture() { - let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_951919033_868488711:👍") - - #expect(result != nil) - #expect(result?.emoji == "👍") - #expect(result?.timestampSeconds == 1_772_600_903) - #expect(result?.senderNameHash == 951_919_033) - #expect(result?.textHash == 868_488_711) - } - - @Test("Parses v1 reaction with heart emoji") - func parsesV1Heart() { - let result = MeshCoreOpenReactionParser.parseV1("r:1700000000000_12345_67890:❤️") - - #expect(result != nil) - #expect(result?.emoji == "❤️") - #expect(result?.timestampSeconds == 1_700_000_000) - #expect(result?.senderNameHash == 12345) - #expect(result?.textHash == 67890) - } - - @Test("Parses v1 reaction with fire emoji") - func parsesV1Fire() { - let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_100_200:🔥") - - #expect(result != nil) - #expect(result?.emoji == "🔥") - } - - @Test("V1 rejects v3 format") - func v1RejectsV3() { - #expect(MeshCoreOpenReactionParser.parseV1("r:a1b2:00") == nil) - } - - @Test("V1 rejects plain text") - func v1RejectsPlainText() { - #expect(MeshCoreOpenReactionParser.parseV1("hello world") == nil) - } - - @Test("V1 rejects wrong prefix") - func v1RejectsWrongPrefix() { - #expect(MeshCoreOpenReactionParser.parseV1("x:1700000000000_100_200:👍") == nil) - } - - @Test("V1 rejects too few underscore parts") - func v1RejectsTwoParts() { - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100:👍") == nil) - } - - @Test("V1 rejects too many underscore parts") - func v1RejectsFourParts() { - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200_300:👍") == nil) - } - - @Test("V1 rejects non-numeric timestamp") - func v1RejectsNonNumericTimestamp() { - #expect(MeshCoreOpenReactionParser.parseV1("r:abc_100_200:👍") == nil) - } - - @Test("V1 rejects non-numeric hash values") - func v1RejectsNonNumericHash() { - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_abc_200:👍") == nil) - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_xyz:👍") == nil) - } - - @Test("V1 rejects empty emoji") - func v1RejectsEmptyEmoji() { - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200:") == nil) - } - - @Test("V1 rejects legacy channel format") - func v1RejectsLegacyChannel() { - #expect(MeshCoreOpenReactionParser.parseV1("👍@[AlphaNode]\n7f3a9c12") == nil) - } - - @Test("V1 rejects empty string") - func v1RejectsEmpty() { - #expect(MeshCoreOpenReactionParser.parseV1("") == nil) - } - - @Test("V1 timestamp converts millis to seconds correctly") - func v1TimestampConversion() { - // 1700000000500 ms → 1700000000 s (truncated, not rounded) - let result = MeshCoreOpenReactionParser.parseV1("r:1700000000500_100_200:👍") - #expect(result?.timestampSeconds == 1_700_000_000) - } - - // MARK: - V1 Hash Matching Tests - - @Test("dartStringHash can verify v1 sender name hash") - func v1SenderNameHashVerification() { - // Compute the Dart hash of a known sender name - let senderName = "TestNode" - let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) - - // Construct a v1 reaction with that hash - let reactionText = "r:1700000000000_\(expectedHash)_12345:👍" - let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) - - #expect(parsed != nil) - #expect(parsed?.senderNameHash == expectedHash) - } - - @Test("dartStringHash can verify v1 text hash") - func v1TextHashVerification() { - let messageText = "Hello from mesh" - let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) - - let reactionText = "r:1700000000000_12345_\(expectedHash):👍" - let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) - - #expect(parsed != nil) - #expect(parsed?.textHash == expectedHash) - } - - @Test("V1 round-trip: construct reaction and verify both hashes match") - func v1RoundTrip() { - let senderName = "AVN1" - let messageText = "Test message content" - let timestampMs: UInt64 = 1_772_600_903_000 - - let senderHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) - let textHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) - - let reactionText = "r:\(timestampMs)_\(senderHash)_\(textHash):👍" - let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) - - #expect(parsed != nil) - #expect(parsed?.timestampSeconds == UInt32(timestampMs / 1000)) - #expect(parsed?.senderNameHash == senderHash) - #expect(parsed?.textHash == textHash) - #expect(parsed?.emoji == "👍") - } + // MARK: - Parse Valid Format Tests + + @Test + func `Parses valid reaction with thumbs up (index 00)`() { + let result = MeshCoreOpenReactionParser.parse("r:a1b2:00") + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.dartHash == "a1b2") + } + + @Test + func `Parses valid reaction with fire (index 05)`() { + let result = MeshCoreOpenReactionParser.parse("r:ff00:05") + + #expect(result != nil) + #expect(result?.emoji == "🔥") + #expect(result?.dartHash == "ff00") + } + + @Test + func `Parses valid reaction with heart (index 01)`() { + let result = MeshCoreOpenReactionParser.parse("r:1234:01") + + #expect(result != nil) + #expect(result?.emoji == "❤️") + #expect(result?.dartHash == "1234") + } + + @Test + func `Parses reaction at max valid emoji index (0xb7)`() { + let result = MeshCoreOpenReactionParser.parse("r:abcd:b7") + + #expect(result != nil) + #expect(result?.emoji == "🚀") + #expect(result?.dartHash == "abcd") + } + + // MARK: - Parse Invalid Format Tests + + @Test + func `Rejects plain text`() { + #expect(MeshCoreOpenReactionParser.parse("hello world") == nil) + } + + @Test + func `Rejects wrong prefix`() { + #expect(MeshCoreOpenReactionParser.parse("x:a1b2:00") == nil) + } + + @Test + func `Rejects uppercase hex in hash`() { + #expect(MeshCoreOpenReactionParser.parse("r:A1B2:00") == nil) + } + + @Test + func `Rejects uppercase hex in index`() { + #expect(MeshCoreOpenReactionParser.parse("r:a1b2:0A") == nil) + } + + @Test + func `Rejects too short`() { + #expect(MeshCoreOpenReactionParser.parse("r:a1b:00") == nil) + } + + @Test + func `Rejects too long`() { + #expect(MeshCoreOpenReactionParser.parse("r:a1b2c:00") == nil) + } + + @Test + func `Rejects missing colons`() { + #expect(MeshCoreOpenReactionParser.parse("r-a1b2-00") == nil) + } + + @Test + func `Rejects emoji index beyond table size`() { + // 0xb8 = 184, table has 184 entries (0x00–0xb7) + #expect(MeshCoreOpenReactionParser.parse("r:a1b2:b8") == nil) + } + + @Test + func `Rejects legacy channel reaction format`() { + #expect(MeshCoreOpenReactionParser.parse("👍@[AlphaNode]\n7f3a9c12") == nil) + } + + @Test + func `Rejects legacy DM reaction format`() { + #expect(MeshCoreOpenReactionParser.parse("👍\n7f3a9c12") == nil) + } + + @Test + func `Rejects empty string`() { + #expect(MeshCoreOpenReactionParser.parse("") == nil) + } + + // MARK: - Emoji Index Mapping Tests + + @Test + func `Spot-check emoji indices across all categories`() { + // quickEmojis + #expect(MeshCoreOpenReactionParser.parse("r:0000:00")?.emoji == "👍") // 0x00 + #expect(MeshCoreOpenReactionParser.parse("r:0000:02")?.emoji == "😂") // 0x02 + #expect(MeshCoreOpenReactionParser.parse("r:0000:03")?.emoji == "🎉") // 0x03 + + // smileys start at 0x06 + #expect(MeshCoreOpenReactionParser.parse("r:0000:06")?.emoji == "😀") // first smiley + #expect(MeshCoreOpenReactionParser.parse("r:0000:45")?.emoji == "😶") // last smiley + + // gestures start at 0x46 + #expect(MeshCoreOpenReactionParser.parse("r:0000:46")?.emoji == "👍") // first gesture + #expect(MeshCoreOpenReactionParser.parse("r:0000:66")?.emoji == "💪") // last gesture + + // hearts start at 0x67 + #expect(MeshCoreOpenReactionParser.parse("r:0000:67")?.emoji == "❤️") // first heart + + // objects start at 0x87 + #expect(MeshCoreOpenReactionParser.parse("r:0000:87")?.emoji == "🎉") // first object + } + + // MARK: - Dart String Hash Tests + + @Test + func `Dart hash of empty input produces 1`() { + // Dart: "".hashCode should be 0, which becomes 1 (zero-guard) + let hash = MeshCoreOpenReactionParser.dartStringHash([]) + #expect(hash == 1) + } + + @Test + func `Dart hash is deterministic`() { + let units: [UInt16] = Array("hello".utf16) + let hash1 = MeshCoreOpenReactionParser.dartStringHash(units) + let hash2 = MeshCoreOpenReactionParser.dartStringHash(units) + #expect(hash1 == hash2) + } + + @Test + func `Dart hash of single character 'a'`() { + // Manually compute: code_unit = 97 (0x61) + // hash = 0 + // hash += 97 → 97 + // hash += 97 << 10 → 97 + 99328 = 99425 + // hash ^= 99425 >> 6 → 99425 ^ 1553 = 100464 + // finalize: + // hash += 100464 << 3 → 100464 + 803712 = 904176 + // hash ^= 904176 >> 11 → 904176 ^ 441 = 904617 + // hash += 904617 << 15 → 904617 + 29640630272 (wraps in UInt32) → need wrapping + // Let's just verify it's > 0 and within 30 bits + let hash = MeshCoreOpenReactionParser.dartStringHash([97]) + #expect(hash > 0) + #expect(hash < (1 << 30)) + } + + @Test + func `Dart hash result is within 30-bit range`() { + let units: [UInt16] = Array("test string with various chars 🎉".utf16) + let hash = MeshCoreOpenReactionParser.dartStringHash(units) + #expect(hash > 0) + #expect(hash <= (1 << 30) - 1) + } + + @Test + func `Different inputs produce different hashes`() { + let hash1 = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) + let hash2 = MeshCoreOpenReactionParser.dartStringHash(Array("world".utf16)) + #expect(hash1 != hash2) + } + + // MARK: - Hash Computation Tests + + @Test + func `computeReactionHash returns 4-char lowercase hex`() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "AlphaNode", + text: "Hello world" + ) + #expect(hash.count == 4) + #expect(hash.allSatisfy { $0.isHexDigit && !$0.isUppercase }) + } + + @Test + func `computeReactionHash is deterministic`() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "AlphaNode", + text: "Hello world" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "AlphaNode", + text: "Hello world" + ) + #expect(hash1 == hash2) + } + + @Test + func `computeReactionHash changes with different timestamp`() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_001, + senderName: "Node", + text: "Hello" + ) + #expect(hash1 != hash2) + } + + @Test + func `computeReactionHash changes with different sender`() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "AlphaNode", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "BetaNode", + text: "Hello" + ) + #expect(hash1 != hash2) + } + + @Test + func `computeReactionHash changes with different text`() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "World" + ) + #expect(hash1 != hash2) + } + + @Test + func `computeReactionHash with nil sender (DM mode)`() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: nil, + text: "Hello world" + ) + #expect(hash.count == 4) + + // Should differ from channel mode with same params + let channelHash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello world" + ) + #expect(hash != channelHash) + } + + @Test + func `computeReactionHash truncates text to 5 UTF-16 code units`() { + // "Hello" is 5 code units, "Hello world" has 11 + // Both should produce the same hash since only first 5 code units are used + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello world" + ) + #expect(hash1 == hash2) + } + + @Test + func `computeReactionHash handles short text (fewer than 5 code units)`() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: nil, + text: "Hi" + ) + #expect(hash.count == 4) + } + + // MARK: - UTF-16 Edge Cases + + @Test + func `computeReactionHash handles emoji in text (multi-code-unit)`() { + // 🎉 is 2 UTF-16 code units (surrogate pair), so "🎉abc" = 5 code units + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: nil, + text: "🎉abc" + ) + #expect(hash.count == 4) + + // "🎉abcdef" should hash the same since first 5 code units match + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: nil, + text: "🎉abcdef" + ) + #expect(hash == hash2) + } + + @Test + func `computeReactionHash handles empty text`() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "" + ) + #expect(hash.count == 4) + } + + // MARK: - Cross-App Test Vectors + + @Test + func `Dart hash matches known Dart VM output for 'hello'`() { + // In Dart: "hello".hashCode == 150804507 + // This is the definitive cross-app test vector + let hash = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) + #expect(hash == 150_804_507) + } + + @Test + func `computeReactionHash is internally consistent`() { + // Verify computeReactionHash assembles code units correctly + // by comparing against manual dartStringHash call + let testUnits = Array("1700000000AHello".utf16) + let fullHash = MeshCoreOpenReactionParser.dartStringHash(testUnits) + let masked = fullHash & 0xFFFF + let expected = String(format: "%04x", masked) + + let computed = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "A", + text: "Hello" + ) + #expect(computed == expected) + } + + @Test + func `Emoji table has exactly 184 entries`() { + #expect(MeshCoreOpenReactionParser.emojiTable.count == 184) + } + + // MARK: - V1 Parse Tests + + @Test + func `Parses v1 reaction from real wire capture`() { + let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_951919033_868488711:👍") + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.timestampSeconds == 1_772_600_903) + #expect(result?.senderNameHash == 951_919_033) + #expect(result?.textHash == 868_488_711) + } + + @Test + func `Parses v1 reaction with heart emoji`() { + let result = MeshCoreOpenReactionParser.parseV1("r:1700000000000_12345_67890:❤️") + + #expect(result != nil) + #expect(result?.emoji == "❤️") + #expect(result?.timestampSeconds == 1_700_000_000) + #expect(result?.senderNameHash == 12345) + #expect(result?.textHash == 67890) + } + + @Test + func `Parses v1 reaction with fire emoji`() { + let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_100_200:🔥") + + #expect(result != nil) + #expect(result?.emoji == "🔥") + } + + @Test + func `V1 rejects v3 format`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:a1b2:00") == nil) + } + + @Test + func `V1 rejects plain text`() { + #expect(MeshCoreOpenReactionParser.parseV1("hello world") == nil) + } + + @Test + func `V1 rejects wrong prefix`() { + #expect(MeshCoreOpenReactionParser.parseV1("x:1700000000000_100_200:👍") == nil) + } + + @Test + func `V1 rejects too few underscore parts`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100:👍") == nil) + } + + @Test + func `V1 rejects too many underscore parts`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200_300:👍") == nil) + } + + @Test + func `V1 rejects non-numeric timestamp`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:abc_100_200:👍") == nil) + } + + @Test + func `V1 rejects non-numeric hash values`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_abc_200:👍") == nil) + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_xyz:👍") == nil) + } + + @Test + func `V1 rejects empty emoji`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200:") == nil) + } + + @Test + func `V1 rejects legacy channel format`() { + #expect(MeshCoreOpenReactionParser.parseV1("👍@[AlphaNode]\n7f3a9c12") == nil) + } + + @Test + func `V1 rejects empty string`() { + #expect(MeshCoreOpenReactionParser.parseV1("") == nil) + } + + @Test + func `V1 timestamp converts millis to seconds correctly`() { + // 1700000000500 ms → 1700000000 s (truncated, not rounded) + let result = MeshCoreOpenReactionParser.parseV1("r:1700000000500_100_200:👍") + #expect(result?.timestampSeconds == 1_700_000_000) + } + + // MARK: - V1 Hash Matching Tests + + @Test + func `dartStringHash can verify v1 sender name hash`() { + // Compute the Dart hash of a known sender name + let senderName = "TestNode" + let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) + + // Construct a v1 reaction with that hash + let reactionText = "r:1700000000000_\(expectedHash)_12345:👍" + let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) + + #expect(parsed != nil) + #expect(parsed?.senderNameHash == expectedHash) + } + + @Test + func `dartStringHash can verify v1 text hash`() { + let messageText = "Hello from mesh" + let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) + + let reactionText = "r:1700000000000_12345_\(expectedHash):👍" + let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) + + #expect(parsed != nil) + #expect(parsed?.textHash == expectedHash) + } + + @Test + func `V1 round-trip: construct reaction and verify both hashes match`() { + let senderName = "AVN1" + let messageText = "Test message content" + let timestampMs: UInt64 = 1_772_600_903_000 + + let senderHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) + let textHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) + + let reactionText = "r:\(timestampMs)_\(senderHash)_\(textHash):👍" + let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) + + #expect(parsed != nil) + #expect(parsed?.timestampSeconds == UInt32(timestampMs / 1000)) + #expect(parsed?.senderNameHash == senderHash) + #expect(parsed?.textHash == textHash) + #expect(parsed?.emoji == "👍") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageDeduplicationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageDeduplicationTests.swift index a3286ec5..d2825cee 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageDeduplicationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageDeduplicationTests.swift @@ -1,186 +1,79 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("Message Deduplication Tests") struct MessageDeduplicationTests { - - // MARK: - Fallback Key Format Tests - - @Test("DM fallback key is deterministic") - func dmFallbackKeyDeterministic() { - let contactID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let key1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello world" - ) - let key2 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello world" - ) - #expect(key1 == key2) - #expect(key1.hasPrefix("dm-")) - } - - @Test("Channel fallback key is deterministic") - func channelFallbackKeyDeterministic() { - let key1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello channel" - ) - let key2 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello channel" - ) - #expect(key1 == key2) - #expect(key1.hasPrefix("ch-")) - } - - @Test("DM and channel fallback keys never collide for same content") - func dmAndChannelKeysNeverCollide() { - let contactID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let dmKey = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello" - ) - let channelKey = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello" - ) - #expect(dmKey != channelKey) - } - - @Test("Different contacts produce different DM fallback keys") - func differentContactsDifferentKeys() { - let contact1 = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let contact2 = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! - let key1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contact1, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello" - ) - let key2 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contact2, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello" - ) - #expect(key1 != key2) - } - - @Test("Different channel indices produce different channel fallback keys") - func differentChannelsDifferentKeys() { - let key1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello" - ) - let key2 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 1, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello" - ) - #expect(key1 != key2) - } - - // MARK: - Retry Dedup Stability - - @Test("DM retry attempts with same content produce identical dedup keys") - func dmRetryAttemptsProduceSameKey() { - let contactID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let timestamp: UInt32 = 1704067200 - let text = "Hello mesh" - - // Simulate two retry attempts: same contact, timestamp, and text - let keyAttempt0 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: timestamp, content: text - ) - let keyAttempt1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: timestamp, content: text - ) - #expect(keyAttempt0 == keyAttempt1, - "Retry attempts with the same content must produce identical dedup keys") - } - - @Test("Channel retry attempts with same content produce identical dedup keys") - func channelRetryAttemptsProduceSameKey() { - let timestamp: UInt32 = 1704067200 - let text = "Hello channel" - - let keyAttempt0 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 2, - senderNodeName: "Bob", timestamp: timestamp, content: text - ) - let keyAttempt1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 2, - senderNodeName: "Bob", timestamp: timestamp, content: text - ) - #expect(keyAttempt0 == keyAttempt1, - "Channel retry attempts with the same content must produce identical dedup keys") - } - - // MARK: - isDuplicateMessage via MockPersistenceStore - - @Test("isDuplicateMessage returns false when no matching key exists") - func noDuplicateWhenEmpty() async throws { - let store = MockPersistenceStore() - let result = try await store.isDuplicateMessage(deduplicationKey: "test-key", radioID: UUID()) - #expect(result == false) - } - - @Test("isDuplicateMessage returns true when matching key exists for the same radio") - func duplicateWhenKeyExists() async throws { - let store = MockPersistenceStore() - let radioID = UUID() - let dto = makeChannelMessageDTO(radioID: radioID, deduplicationKey: "test-key") - try await store.saveMessage(dto) - - let result = try await store.isDuplicateMessage(deduplicationKey: "test-key", radioID: radioID) - #expect(result) - } - - @Test("isDuplicateMessage is scoped per-radio so two companions storing the same channel packet both succeed") - func duplicateIsScopedPerRadio() async throws { - let store = MockPersistenceStore() - let radioA = UUID() - let radioB = UUID() - let sharedKey = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "aaaaa" - ) - try await store.saveMessage(makeChannelMessageDTO(radioID: radioA, deduplicationKey: sharedKey)) - - let duplicateOnA = try await store.isDuplicateMessage(deduplicationKey: sharedKey, radioID: radioA) - let duplicateOnB = try await store.isDuplicateMessage(deduplicationKey: sharedKey, radioID: radioB) - - #expect(duplicateOnA, "Same radio sees the prior save as a duplicate") - #expect(duplicateOnB == false, - "A different companion radio must not inherit radioA's dedup entry — otherwise the second radio's channel view goes blank after 'change device'") - } - - // MARK: - Helpers - - private func makeChannelMessageDTO(radioID: UUID, deduplicationKey: String) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: nil, - channelIndex: 0, - text: "Hello", - timestamp: 1704067200, - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 1, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: "Alice", - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: deduplicationKey - ) - } + // MARK: - Fallback Key Format Tests + + @Test + func `DM fallback key is deterministic`() throws { + let contactID = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let key1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello world" + ) + let key2 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello world" + ) + #expect(key1 == key2) + #expect(key1.hasPrefix("dm-")) + } + + @Test + func `Channel fallback key is deterministic`() { + let key1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 0, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello channel" + ) + let key2 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 0, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello channel" + ) + #expect(key1 == key2) + #expect(key1.hasPrefix("ch-")) + } + + @Test + func `DM and channel fallback keys never collide for same content`() throws { + let contactID = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let dmKey = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello" + ) + let channelKey = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 0, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello" + ) + #expect(dmKey != channelKey) + } + + @Test + func `Different contacts produce different DM fallback keys`() throws { + let contact1 = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let contact2 = try #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")) + let key1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contact1, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello" + ) + let key2 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contact2, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello" + ) + #expect(key1 != key2) + } + + @Test + func `Different channel indices produce different channel fallback keys`() { + let key1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 0, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello" + ) + let key2 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 1, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello" + ) + #expect(key1 != key2) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageLRUCacheTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageLRUCacheTests.swift index 3c3db945..4607286c 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageLRUCacheTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageLRUCacheTests.swift @@ -1,189 +1,188 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("MessageLRUCache Tests") struct MessageLRUCacheTests { - - @Test("Indexes and retrieves message") - func indexesAndRetrievesMessage() async { - let cache = MessageLRUCache() - let messageID = UUID() - - await cache.index( - messageID: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - - #expect(candidates.count == 1) - #expect(candidates.first?.messageID == messageID) - #expect(candidates.first?.text == "Hello") - #expect(candidates.first?.timestamp == 1704067200) - } - - @Test("Returns empty array for non-existent message") - func returnsEmptyForNonExistent() async { - let cache = MessageLRUCache() - - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: "abcd1234") - - #expect(candidates.isEmpty) - } - - @Test("Evicts oldest key at capacity") - func evictsOldestAtCapacity() async { - let cache = MessageLRUCache(capacity: 3) - - // Add 3 messages with different keys - for i in 0..<3 { - await cache.index( - messageID: UUID(), - channelIndex: 0, - senderName: "Node\(i)", - text: "Message \(i)", - timestamp: UInt32(i) - ) - } - - let hash0 = ReactionParser.generateMessageHash(text: "Message 0", timestamp: 0) - let before = await cache.lookup(channelIndex: 0, senderName: "Node0", messageHash: hash0) - #expect(!before.isEmpty) - - // Add 4th message with different key (should evict first key) - await cache.index( - messageID: UUID(), - channelIndex: 0, - senderName: "Node3", - text: "Message 3", - timestamp: 3 - ) - - let after = await cache.lookup(channelIndex: 0, senderName: "Node0", messageHash: hash0) - #expect(after.isEmpty) + @Test + func `Indexes and retrieves message`() async { + let cache = MessageLRUCache() + let messageID = UUID() + + await cache.index( + messageID: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + + #expect(candidates.count == 1) + #expect(candidates.first?.messageID == messageID) + #expect(candidates.first?.text == "Hello") + #expect(candidates.first?.timestamp == 1_704_067_200) + } + + @Test + func `Returns empty array for non-existent message`() async { + let cache = MessageLRUCache() + + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: "abcd1234") + + #expect(candidates.isEmpty) + } + + @Test + func `Evicts oldest key at capacity`() async { + let cache = MessageLRUCache(capacity: 3) + + // Add 3 messages with different keys + for i in 0..<3 { + await cache.index( + messageID: UUID(), + channelIndex: 0, + senderName: "Node\(i)", + text: "Message \(i)", + timestamp: UInt32(i) + ) } - @Test("Different channels are separate") - func differentChannelsAreSeparate() async { - let cache = MessageLRUCache() - let messageID = UUID() - - await cache.index( - messageID: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - - // Should find on channel 0 - let result0 = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - #expect(result0.first?.messageID == messageID) - - // Should not find on channel 1 - let result1 = await cache.lookup(channelIndex: 1, senderName: "Node", messageHash: hash) - #expect(result1.isEmpty) + let hash0 = ReactionParser.generateMessageHash(text: "Message 0", timestamp: 0) + let before = await cache.lookup(channelIndex: 0, senderName: "Node0", messageHash: hash0) + #expect(!before.isEmpty) + + // Add 4th message with different key (should evict first key) + await cache.index( + messageID: UUID(), + channelIndex: 0, + senderName: "Node3", + text: "Message 3", + timestamp: 3 + ) + + let after = await cache.lookup(channelIndex: 0, senderName: "Node0", messageHash: hash0) + #expect(after.isEmpty) + } + + @Test + func `Different channels are separate`() async { + let cache = MessageLRUCache() + let messageID = UUID() + + await cache.index( + messageID: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + + // Should find on channel 0 + let result0 = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + #expect(result0.first?.messageID == messageID) + + // Should not find on channel 1 + let result1 = await cache.lookup(channelIndex: 1, senderName: "Node", messageHash: hash) + #expect(result1.isEmpty) + } + + @Test + func `Stores multiple candidates per key`() async { + let cache = MessageLRUCache() + let id1 = UUID() + let id2 = UUID() + + // Two messages with same sender but different text that hash to different values + // won't test collision - instead test re-indexing same message updates it + await cache.index( + messageID: id1, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + await cache.index( + messageID: id2, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + + // Both messages have same hash, so both should be candidates + #expect(candidates.count == 2) + #expect(candidates.contains { $0.messageID == id1 }) + #expect(candidates.contains { $0.messageID == id2 }) + } + + @Test + func `Caps candidates per key`() async { + let cache = MessageLRUCache(maxCandidatesPerKey: 3) + + // Add 5 messages with same hash (same text/timestamp, different IDs) + var ids: [UUID] = [] + for _ in 0..<5 { + let id = UUID() + ids.append(id) + await cache.index( + messageID: id, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) } - @Test("Stores multiple candidates per key") - func storesMultipleCandidatesPerKey() async { - let cache = MessageLRUCache() - let id1 = UUID() - let id2 = UUID() - - // Two messages with same sender but different text that hash to different values - // won't test collision - instead test re-indexing same message updates it - await cache.index( - messageID: id1, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - await cache.index( - messageID: id2, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - - // Both messages have same hash, so both should be candidates - #expect(candidates.count == 2) - #expect(candidates.contains { $0.messageID == id1 }) - #expect(candidates.contains { $0.messageID == id2 }) - } - - @Test("Caps candidates per key") - func capsCandidatesPerKey() async { - let cache = MessageLRUCache(maxCandidatesPerKey: 3) - - // Add 5 messages with same hash (same text/timestamp, different IDs) - var ids: [UUID] = [] - for _ in 0..<5 { - let id = UUID() - ids.append(id) - await cache.index( - messageID: id, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - } - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - - // Should only keep most recent 3 - #expect(candidates.count == 3) - // First two should have been evicted - #expect(!candidates.contains { $0.messageID == ids[0] }) - #expect(!candidates.contains { $0.messageID == ids[1] }) - // Last three should be present - #expect(candidates.contains { $0.messageID == ids[2] }) - #expect(candidates.contains { $0.messageID == ids[3] }) - #expect(candidates.contains { $0.messageID == ids[4] }) - } - - @Test("Re-indexing same messageID updates instead of duplicating") - func reindexingUpdates() async { - let cache = MessageLRUCache() - let messageID = UUID() - - await cache.index( - messageID: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - // Re-index same message - await cache.index( - messageID: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - - // Should only have one entry, not two - #expect(candidates.count == 1) - #expect(candidates.first?.messageID == messageID) - } + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + + // Should only keep most recent 3 + #expect(candidates.count == 3) + // First two should have been evicted + #expect(!candidates.contains { $0.messageID == ids[0] }) + #expect(!candidates.contains { $0.messageID == ids[1] }) + // Last three should be present + #expect(candidates.contains { $0.messageID == ids[2] }) + #expect(candidates.contains { $0.messageID == ids[3] }) + #expect(candidates.contains { $0.messageID == ids[4] }) + } + + @Test + func `Re-indexing same messageID updates instead of duplicating`() async { + let cache = MessageLRUCache() + let messageID = UUID() + + await cache.index( + messageID: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + // Re-index same message + await cache.index( + messageID: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + + // Should only have one entry, not two + #expect(candidates.count == 1) + #expect(candidates.first?.messageID == messageID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceACKTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceACKTests.swift index 1f01e912..60d5e32f 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceACKTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceACKTests.swift @@ -1,1044 +1,1016 @@ -import Testing import Foundation -import MeshCoreTestSupport @testable import MC1Services +import MeshCoreTestSupport +import Testing @Suite("MessageService ACK Tests") struct MessageServiceACKTests { - - private let testDeviceID = UUID() - - private func makePending( - messageID: UUID = UUID(), - contactID: UUID = UUID(), - ackCodes: Set, - sentAt: Date = Date(), - timeout: TimeInterval = 30.0, - isDelivered: Bool = false - ) -> PendingAck { - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: ackCodes, - sentAt: sentAt, - timeout: timeout, - isDelivered: isDelivered - ) - } - - // MARK: - ACK Expiry Checking Toggle - - @Test("isAckExpiryCheckingActive toggles correctly") - func ackExpiryCheckingToggles() async throws { - let (service, _) = try await MessageService.createForTesting() - - #expect(await !service.isAckExpiryCheckingActive) - - await service.startAckExpiryChecking() - #expect(await service.isAckExpiryCheckingActive) - - await service.stopAckExpiryChecking() - #expect(await !service.isAckExpiryCheckingActive) - } - - @Test("stopAckExpiryChecking cancels the background task") - func stopCancelsTask() async throws { - let (service, _) = try await MessageService.createForTesting() - - await service.startAckExpiryChecking() - #expect(await service.isAckExpiryCheckingActive) - - await service.stopAckExpiryChecking() - #expect(await !service.isAckExpiryCheckingActive) - - await service.startAckExpiryChecking() - #expect(await service.isAckExpiryCheckingActive) - await service.stopAckExpiryChecking() - } - - // MARK: - checkExpiredAcks - - @Test("checkExpiredAcks marks expired ACK as failed") - func expiredAckMarkedFailed() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .sent - ) - try await dataStore.saveMessage(message) - - let statusEvents = service.statusEvents() - - let ackCode = Data([0x01, 0x02, 0x03, 0x04]) - await service.setPendingAckForTest( - makePending( - messageID: messageID, - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-60), - timeout: 30.0 - ) - ) - - try await service.checkExpiredAcks() - - let fetched = try await dataStore.fetchMessage(id: messageID) - #expect(fetched?.status == .failed) - - let failedIDs = await service.drainStatusEvents(statusEvents).failedIDs - #expect(failedIDs.contains(messageID)) - } - - @Test("ACK timeout keeps message .sent through the grace window so a late ACK can still reconcile") - func ackTimeoutStaysSentDuringGraceWindow() async throws { - // Pin the give-up window so the test exercises "past per-attempt timeout - // but inside the give-up window" independent of the product default. - let (service, dataStore) = try await MessageService.createForTesting( - config: MessageServiceConfig(ackGiveUpWindow: 45) - ) - let messageID = UUID() - - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .sent - ) - try await dataStore.saveMessage(message) - - let statusEvents = service.statusEvents() - - let ackCode = Data([0x11, 0x22, 0x33, 0x44]) - await service.setPendingAckForTest( - makePending( - messageID: messageID, - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-31), - timeout: 30.0 - ) - ) - - try await service.checkExpiredAcks() - - let fetched = try await dataStore.fetchMessage(id: messageID) - #expect(fetched?.status == .sent, - "Grace window must not downgrade to .retrying — nothing is actually retrying") - #expect(await service.pendingAckCount == 1) - - let events = await service.drainStatusEvents(statusEvents) - #expect(!events.failedIDs.contains(messageID)) - #expect(events.retryUpdates.isEmpty, - "Grace window is not a retry; no retrying event should fire") - } - - @Test("checkExpiredAcks preserves non-expired ACK") - func nonExpiredAckSurvives() async throws { - let (service, _) = try await MessageService.createForTesting() - - let ackCode = Data([0x05, 0x06, 0x07, 0x08]) - await service.setPendingAckForTest( - makePending(ackCodes: [ackCode], sentAt: Date(), timeout: 30.0) - ) - - try await service.checkExpiredAcks() - - #expect(await service.pendingAckCount == 1, "Non-expired ACK should survive") - } - - @Test("checkExpiredAcks skips already-delivered ACK") - func deliveredAckSkipped() async throws { - let (service, _) = try await MessageService.createForTesting() - - let ackCode = Data([0x0D, 0x0E, 0x0F, 0x10]) - await service.setPendingAckForTest( - makePending( - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-60), - timeout: 30.0, - isDelivered: true - ) - ) - - try await service.checkExpiredAcks() - - #expect(await service.pendingAckCount == 1, "Delivered ACK should not be expired") - } - - @Test("checkExpiredAcks does not broadcast failure when DB stays delivered") - func checkExpiredAcksDoesNotFireHandlerOnDeliveredRow() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let ackCode = Data([0xCE, 0xEC, 0xAC, 0xCE]) - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .delivered, - ackCode: ackCode.ackCodeUInt32 - ) - ) - let statusEvents = service.statusEvents() - await service.setPendingAckForTest( - makePending( - messageID: messageID, - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-60), - timeout: 30 - ) - ) - - try await service.checkExpiredAcks() - - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .delivered, - "DB layer must absorb .delivered against the .failed write") - let failed = await service.drainStatusEvents(statusEvents).failedIDs - #expect(!failed.contains(messageID), - ".failed must not be broadcast when the DB write is a no-op") - } - - @Test("checkExpiredAcks fails a DM after max(ackGiveUpWindow, per-attempt timeout); the window is the floor") - func checkExpiredAcksUsesAckGiveUpWindowAsFloor() async throws { - let window: TimeInterval = 20 - let shortTimeout: TimeInterval = 5 - let (service, dataStore) = try await MessageService.createForTesting( - config: MessageServiceConfig(ackGiveUpWindow: window) - ) - - let survivingID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: survivingID, radioID: testDeviceID, status: .sent) - ) - // Past the per-attempt timeout but inside the window floor: stays .sent. - await service.setPendingAckForTest( - makePending( - messageID: survivingID, - ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], - sentAt: Date().addingTimeInterval(-(window - 5)), - timeout: shortTimeout - ) - ) - - let expiredID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: expiredID, radioID: testDeviceID, status: .sent) - ) - await service.setPendingAckForTest( - makePending( - messageID: expiredID, - ackCodes: [Data([0x05, 0x06, 0x07, 0x08])], - sentAt: Date().addingTimeInterval(-(window + 5)), - timeout: shortTimeout - ) - ) - - try await service.checkExpiredAcks() - - #expect(try await dataStore.fetchMessage(id: survivingID)?.status == .sent, - "DM still inside the window floor must not be failed") - #expect(try await dataStore.fetchMessage(id: expiredID)?.status == .failed, - "DM past the window floor must be failed") - } - - @Test("checkExpiredAcks honors a per-attempt timeout longer than the give-up window (slow preset)") - func checkExpiredAcksHonorsLongPerAttemptTimeout() async throws { - let window: TimeInterval = 20 - let longTimeout: TimeInterval = 60 - let (service, dataStore) = try await MessageService.createForTesting( - config: MessageServiceConfig(ackGiveUpWindow: window) - ) - - // Past the give-up window but still inside the attempt's own ACK wait: - // the loop is legitimately waiting for a slow round-trip, so the checker - // must not fail it. - let waitingID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: waitingID, radioID: testDeviceID, status: .sent) - ) - await service.setPendingAckForTest( - makePending( - messageID: waitingID, - ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], - sentAt: Date().addingTimeInterval(-(window + 10)), - timeout: longTimeout - ) - ) - - // Past both the window and the attempt timeout: genuinely undeliverable. - let expiredID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: expiredID, radioID: testDeviceID, status: .sent) - ) - await service.setPendingAckForTest( - makePending( - messageID: expiredID, - ackCodes: [Data([0x05, 0x06, 0x07, 0x08])], - sentAt: Date().addingTimeInterval(-(longTimeout + 10)), - timeout: longTimeout - ) - ) - - try await service.checkExpiredAcks() - - #expect(try await dataStore.fetchMessage(id: waitingID)?.status == .sent, - "a DM still inside its per-attempt ACK timeout must not be failed even past the window") - #expect(try await dataStore.fetchMessage(id: expiredID)?.status == .failed, - "a DM past max(window, per-attempt timeout) must be failed") - } - - @Test("stopAckExpiryChecking leaves in-flight DMs .sent instead of failing them") - func stopAckExpiryCheckingLeavesPendingSent() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID, status: .sent) - ) - - let statusEvents = service.statusEvents() - - await service.setPendingAckForTest( - makePending(messageID: messageID, ackCodes: [Data([0x09, 0x0A, 0x0B, 0x0C])]) - ) - await service.startAckExpiryChecking() - - await service.stopAckExpiryChecking() - - #expect(await !service.isAckExpiryCheckingActive) - #expect(try await dataStore.fetchMessage(id: messageID)?.status == .sent, - "A routine disconnect must not fail in-flight DMs") - #expect(await service.pendingAckCount == 1, - "The pending entry must survive so a reconnect ACK can still reconcile") - #expect(await service.drainStatusEvents(statusEvents).failedIDs.isEmpty) - } - - // MARK: - failAllPendingMessages - - @Test("failAllPendingMessages fails all non-delivered and broadcasts .failed") - func failAllPending() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID1 = UUID() - let messageID2 = UUID() - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: messageID1, radioID: testDeviceID, status: .sent) - ) - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: messageID2, radioID: testDeviceID, status: .sent) - ) - - let statusEvents = service.statusEvents() - - await service.setPendingAckForTest( - makePending(messageID: messageID1, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) - ) - await service.setPendingAckForTest( - makePending(messageID: messageID2, ackCodes: [Data([0x05, 0x06, 0x07, 0x08])]) - ) - - try await service.failAllPendingMessages() - - let msg1 = try await dataStore.fetchMessage(id: messageID1) - let msg2 = try await dataStore.fetchMessage(id: messageID2) - #expect(msg1?.status == .failed) - #expect(msg2?.status == .failed) - - let failedIDs = await service.drainStatusEvents(statusEvents).failedIDs - #expect(failedIDs.count == 2) - #expect(failedIDs.contains(messageID1)) - #expect(failedIDs.contains(messageID2)) - } - - @Test("failAllPendingMessages skips already-delivered") - func failAllSkipsDelivered() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let deliveredID = UUID() - let pendingID = UUID() - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: pendingID, radioID: testDeviceID, status: .sent) - ) - - await service.setPendingAckForTest( - makePending( - messageID: deliveredID, - ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], - isDelivered: true - ) - ) - await service.setPendingAckForTest( - makePending(messageID: pendingID, ackCodes: [Data([0x05, 0x06, 0x07, 0x08])]) - ) - - try await service.failAllPendingMessages() - - let msg = try await dataStore.fetchMessage(id: pendingID) - #expect(msg?.status == .failed) - } - - @Test("failAllPendingMessages does not downgrade or notify on a delivered DB row") - func failAllPendingDoesNotDowngradeOrNotifyDelivered() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let ackCode = Data([0xFA, 0x11, 0x77, 0x33]) - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .delivered, - ackCode: ackCode.ackCodeUInt32 - ) - ) - let statusEvents = service.statusEvents() - await service.setPendingAckForTest( - makePending(messageID: messageID, ackCodes: [ackCode], isDelivered: false) - ) - - try await service.failAllPendingMessages() - - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .delivered, - "failAllPendingMessages must not downgrade a delivered row") - let failed = await service.drainStatusEvents(statusEvents).failedIDs - #expect(!failed.contains(messageID), - ".failed must not be broadcast when the DB write is a no-op") - } - - // MARK: - stopAndFailAllPending - - @Test("stopAndFailAllPending stops checking and fails all pending") - func stopAndFailAll() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID, status: .sent) - ) - - await service.startAckExpiryChecking() - #expect(await service.isAckExpiryCheckingActive) - - await service.setPendingAckForTest( - makePending(messageID: messageID, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) - ) - - try await service.stopAndFailAllPending() - - #expect(await !service.isAckExpiryCheckingActive) - let msg = try await dataStore.fetchMessage(id: messageID) - #expect(msg?.status == .failed) - } - - // MARK: - Trip Time Preference - - @Test("handleAcknowledgement uses firmware tripTime when provided") - func firmwareTripTimePreferred() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .sent, - ackCode: 0xDEADBEEF - ) - try await dataStore.saveMessage(message) - - let ackCode = Data([0xEF, 0xBE, 0xAD, 0xDE]) // 0xDEADBEEF LE - await service.setPendingAckForTest( - makePending( - messageID: messageID, - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-10) - ) - ) - - await service.handleAcknowledgement(code: ackCode, tripTime: 250) - - let fetched = try await dataStore.fetchMessage(id: messageID) - #expect(fetched?.status == .delivered) - #expect(fetched?.roundTripTime == 250, - "Should use firmware tripTime (250ms), not Date()-based (~10000ms)") - } - - @Test("handleAcknowledgement leaves roundTripTime nil when firmware does not supply tripTime") - func nilTripTimeLeavesRoundTripTimeNil() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .sent, - ackCode: 0xCAFEBABE - ) - try await dataStore.saveMessage(message) - - let ackCode = Data([0xBE, 0xBA, 0xFE, 0xCA]) // 0xCAFEBABE LE - await service.setPendingAckForTest( - makePending( - messageID: messageID, - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-2) - ) - ) - - await service.handleAcknowledgement(code: ackCode, tripTime: nil) - - let fetched = try await dataStore.fetchMessage(id: messageID) - #expect(fetched?.status == .delivered) - #expect(fetched?.roundTripTime == nil, - "nil tripTime must not be replaced by a fabricated Date()-based RTT") - } - - // MARK: - Multi-attempt (Issue #283) - - @Test("handleAcknowledgement matches any CRC accumulated across retry attempts") - func multiAttemptLateAckDelivers() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .pending - ) - try await dataStore.saveMessage(message) - - // Simulate three retry attempts, each producing a different CRC - // (firmware hashes attempt index into the ack code). - let attempt0 = Data([0xAA, 0xAA, 0xAA, 0xAA]) - let attempt1 = Data([0xBB, 0xBB, 0xBB, 0xBB]) - let attempt2 = Data([0xCC, 0xCC, 0xCC, 0xCC]) - await service.setPendingAckForTest( - makePending(messageID: messageID, ackCodes: [attempt0, attempt1, attempt2]) - ) - - // A late ACK for attempt 0's CRC arrives after the retry loop has moved - // on to attempt 2 — must still deliver. - await service.handleAcknowledgement(code: attempt0, tripTime: 500) - - let fetched = try await dataStore.fetchMessage(id: messageID) - #expect(fetched?.status == .delivered, - "Late ACK from an earlier attempt must still mark delivered") - #expect(fetched?.roundTripTime == 500) - #expect(await service.pendingAckCount == 0, - "Entry should be removed after delivery (no sibling accumulation)") - } - - @Test("handleAcknowledgement updates contact lastMessageDate on late ACK") - func lateAckUpdatesContactLastMessage() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let radioID = testDeviceID - try await dataStore.saveDevice(DeviceDTO.testDevice(id: radioID, radioID: radioID)) - - let frame = ContactFrame( - publicKey: Data((0..= before, - "Contact lastMessageDate should be updated to approximately now") - } - } - - @Test("trackPendingAck on retry resets sentAt so checkExpiredAcks preserves a retrying message") - func retryTrackingResetsSentAt() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let contactID = UUID() - - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .sent - ) - try await dataStore.saveMessage(message) - - // Seed attempt 0 with sentAt well past its timeout window. - let ancient = Date().addingTimeInterval(-60) - await service.setPendingAckForTest( - makePending( - messageID: messageID, - contactID: contactID, - ackCodes: [Data([0xAA, 0xAA, 0xAA, 0xAA])], - sentAt: ancient, - timeout: 30.0 - ) - ) - - // Retry attempt 1 registers a new ackCode and a fresh timeout window. - // sentAt must also advance, otherwise checkExpiredAcks will fail the - // entry immediately even though the retry is actively in flight. - await service.trackPendingAck( - messageID: messageID, - contactID: contactID, - ackCode: Data([0xBB, 0xBB, 0xBB, 0xBB]), - timeout: 30.0 - ) - - try await service.checkExpiredAcks() - - #expect(await service.pendingAckCount == 1, - "Retry attempt must preserve the entry, not expire it") - let fetched = try await dataStore.fetchMessage(id: messageID) - #expect(fetched?.status != .failed, - "Retry must not flicker to .failed while in flight") - } - - @Test("finalizeSend preserves roundTripTime after listener-won delivery") - func finalizeSendPreservesListenerRTT() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let contactID = UUID() - let radioID = testDeviceID - let publicKey = Data((0.. sent is FORBIDDEN)") - let events = await service.drainStatusEvents(statusEvents) - #expect(events.resolvedIDs.isEmpty, "no .sent yield when clearRetryingToSent returns false") - #expect(events.failedIDs.isEmpty) - } - - @Test("finalizeSend nil branch yields .statusResolved(.sent) from a non-terminal row and gates off on a terminal one") - func nilBranchYieldsStatusResolvedSent() async throws { - // Part A: a .retrying row moves to .sent and yields exactly one event. - let (serviceA, storeA) = try await MessageService.createForTesting() - let idA = UUID() - try await storeA.saveMessage( - MessageDTO.testDirectMessage(id: idA, radioID: testDeviceID, status: .retrying) - ) - await serviceA.setPendingAckForTest( - makePending(messageID: idA, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) - ) - let eventsA = serviceA.statusEvents() - _ = try await serviceA.finalizeSend( - messageID: idA, - contactID: UUID(), - radioID: testDeviceID, - publicKey: Data((0.., + sentAt: Date = Date(), + timeout: TimeInterval = 30.0, + isDelivered: Bool = false + ) -> PendingAck { + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: ackCodes, + sentAt: sentAt, + timeout: timeout, + isDelivered: isDelivered + ) + } + + // MARK: - ACK Expiry Checking Toggle + + @Test + func `isAckExpiryCheckingActive toggles correctly`() async throws { + let (service, _) = try await MessageService.createForTesting() + + #expect(await !service.isAckExpiryCheckingActive) + + await service.startAckExpiryChecking() + #expect(await service.isAckExpiryCheckingActive) + + await service.stopAckExpiryChecking() + #expect(await !service.isAckExpiryCheckingActive) + } + + @Test + func `stopAckExpiryChecking cancels the background task`() async throws { + let (service, _) = try await MessageService.createForTesting() + + await service.startAckExpiryChecking() + #expect(await service.isAckExpiryCheckingActive) + + await service.stopAckExpiryChecking() + #expect(await !service.isAckExpiryCheckingActive) + + await service.startAckExpiryChecking() + #expect(await service.isAckExpiryCheckingActive) + await service.stopAckExpiryChecking() + } + + // MARK: - checkExpiredAcks + + @Test + func `checkExpiredAcks marks expired ACK as failed`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent + ) + try await dataStore.saveMessage(message) + + let statusEvents = service.statusEvents() + + let ackCode = Data([0x01, 0x02, 0x03, 0x04]) + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-60), + timeout: 30.0 + ) + ) + + try await service.checkExpiredAcks() + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .failed) + + let failedIDs = await service.drainStatusEvents(statusEvents).failedIDs + #expect(failedIDs.contains(messageID)) + } + + @Test + func `ACK timeout keeps message .sent through the grace window so a late ACK can still reconcile`() async throws { + // Pin the give-up window so the test exercises "past per-attempt timeout + // but inside the give-up window" independent of the product default. + let (service, dataStore) = try await MessageService.createForTesting( + config: MessageServiceConfig(ackGiveUpWindow: 45) + ) + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent + ) + try await dataStore.saveMessage(message) + + let statusEvents = service.statusEvents() + + let ackCode = Data([0x11, 0x22, 0x33, 0x44]) + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-31), + timeout: 30.0 + ) + ) + + try await service.checkExpiredAcks() + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .sent, + "Grace window must not downgrade to .retrying — nothing is actually retrying") + #expect(await service.pendingAckCount == 1) + + let events = await service.drainStatusEvents(statusEvents) + #expect(!events.failedIDs.contains(messageID)) + #expect(events.retryUpdates.isEmpty, + "Grace window is not a retry; no retrying event should fire") + } + + @Test + func `checkExpiredAcks preserves non-expired ACK`() async throws { + let (service, _) = try await MessageService.createForTesting() + + let ackCode = Data([0x05, 0x06, 0x07, 0x08]) + await service.setPendingAckForTest( + makePending(ackCodes: [ackCode], sentAt: Date(), timeout: 30.0) + ) + + try await service.checkExpiredAcks() + + #expect(await service.pendingAckCount == 1, "Non-expired ACK should survive") + } + + @Test + func `checkExpiredAcks skips already-delivered ACK`() async throws { + let (service, _) = try await MessageService.createForTesting() + + let ackCode = Data([0x0D, 0x0E, 0x0F, 0x10]) + await service.setPendingAckForTest( + makePending( + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-60), + timeout: 30.0, + isDelivered: true + ) + ) + + try await service.checkExpiredAcks() + + #expect(await service.pendingAckCount == 1, "Delivered ACK should not be expired") + } + + @Test + func `checkExpiredAcks does not broadcast failure when DB stays delivered`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let ackCode = Data([0xCE, 0xEC, 0xAC, 0xCE]) + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .delivered, + ackCode: ackCode.ackCodeUInt32 + ) + ) + let statusEvents = service.statusEvents() + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-60), + timeout: 30 + ) + ) + + try await service.checkExpiredAcks() + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered, + "DB layer must absorb .delivered against the .failed write") + let failed = await service.drainStatusEvents(statusEvents).failedIDs + #expect(!failed.contains(messageID), + ".failed must not be broadcast when the DB write is a no-op") + } + + @Test + func `checkExpiredAcks fails a DM after max(ackGiveUpWindow, per-attempt timeout); the window is the floor`() async throws { + let window: TimeInterval = 20 + let shortTimeout: TimeInterval = 5 + let (service, dataStore) = try await MessageService.createForTesting( + config: MessageServiceConfig(ackGiveUpWindow: window) + ) + + let survivingID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: survivingID, radioID: testDeviceID, status: .sent) + ) + // Past the per-attempt timeout but inside the window floor: stays .sent. + await service.setPendingAckForTest( + makePending( + messageID: survivingID, + ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], + sentAt: Date().addingTimeInterval(-(window - 5)), + timeout: shortTimeout + ) + ) + + let expiredID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: expiredID, radioID: testDeviceID, status: .sent) + ) + await service.setPendingAckForTest( + makePending( + messageID: expiredID, + ackCodes: [Data([0x05, 0x06, 0x07, 0x08])], + sentAt: Date().addingTimeInterval(-(window + 5)), + timeout: shortTimeout + ) + ) + + try await service.checkExpiredAcks() + + #expect(try await dataStore.fetchMessage(id: survivingID)?.status == .sent, + "DM still inside the window floor must not be failed") + #expect(try await dataStore.fetchMessage(id: expiredID)?.status == .failed, + "DM past the window floor must be failed") + } + + @Test + func `checkExpiredAcks honors a per-attempt timeout longer than the give-up window (slow preset)`() async throws { + let window: TimeInterval = 20 + let longTimeout: TimeInterval = 60 + let (service, dataStore) = try await MessageService.createForTesting( + config: MessageServiceConfig(ackGiveUpWindow: window) + ) + + // Past the give-up window but still inside the attempt's own ACK wait: + // the loop is legitimately waiting for a slow round-trip, so the checker + // must not fail it. + let waitingID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: waitingID, radioID: testDeviceID, status: .sent) + ) + await service.setPendingAckForTest( + makePending( + messageID: waitingID, + ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], + sentAt: Date().addingTimeInterval(-(window + 10)), + timeout: longTimeout + ) + ) + + // Past both the window and the attempt timeout: genuinely undeliverable. + let expiredID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: expiredID, radioID: testDeviceID, status: .sent) + ) + await service.setPendingAckForTest( + makePending( + messageID: expiredID, + ackCodes: [Data([0x05, 0x06, 0x07, 0x08])], + sentAt: Date().addingTimeInterval(-(longTimeout + 10)), + timeout: longTimeout + ) + ) + + try await service.checkExpiredAcks() + + #expect(try await dataStore.fetchMessage(id: waitingID)?.status == .sent, + "a DM still inside its per-attempt ACK timeout must not be failed even past the window") + #expect(try await dataStore.fetchMessage(id: expiredID)?.status == .failed, + "a DM past max(window, per-attempt timeout) must be failed") + } + + @Test + func `stopAckExpiryChecking leaves in-flight DMs .sent instead of failing them`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID, status: .sent) + ) + + let statusEvents = service.statusEvents() + + await service.setPendingAckForTest( + makePending(messageID: messageID, ackCodes: [Data([0x09, 0x0A, 0x0B, 0x0C])]) + ) + await service.startAckExpiryChecking() + + await service.stopAckExpiryChecking() + + #expect(await !service.isAckExpiryCheckingActive) + #expect(try await dataStore.fetchMessage(id: messageID)?.status == .sent, + "A routine disconnect must not fail in-flight DMs") + #expect(await service.pendingAckCount == 1, + "The pending entry must survive so a reconnect ACK can still reconcile") + #expect(await service.drainStatusEvents(statusEvents).failedIDs.isEmpty) + } + + // MARK: - failAllPendingMessages + + @Test + func `failAllPendingMessages fails all non-delivered and broadcasts .failed`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID1 = UUID() + let messageID2 = UUID() + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: messageID1, radioID: testDeviceID, status: .sent) + ) + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: messageID2, radioID: testDeviceID, status: .sent) + ) + + let statusEvents = service.statusEvents() + + await service.setPendingAckForTest( + makePending(messageID: messageID1, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) + ) + await service.setPendingAckForTest( + makePending(messageID: messageID2, ackCodes: [Data([0x05, 0x06, 0x07, 0x08])]) + ) + + try await service.failAllPendingMessages() + + let msg1 = try await dataStore.fetchMessage(id: messageID1) + let msg2 = try await dataStore.fetchMessage(id: messageID2) + #expect(msg1?.status == .failed) + #expect(msg2?.status == .failed) + + let failedIDs = await service.drainStatusEvents(statusEvents).failedIDs + #expect(failedIDs.count == 2) + #expect(failedIDs.contains(messageID1)) + #expect(failedIDs.contains(messageID2)) + } + + @Test + func `failAllPendingMessages does not downgrade or notify on a delivered DB row`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let ackCode = Data([0xFA, 0x11, 0x77, 0x33]) + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .delivered, + ackCode: ackCode.ackCodeUInt32 + ) + ) + let statusEvents = service.statusEvents() + await service.setPendingAckForTest( + makePending(messageID: messageID, ackCodes: [ackCode], isDelivered: false) + ) + + try await service.failAllPendingMessages() + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered, + "failAllPendingMessages must not downgrade a delivered row") + let failed = await service.drainStatusEvents(statusEvents).failedIDs + #expect(!failed.contains(messageID), + ".failed must not be broadcast when the DB write is a no-op") + } + + // MARK: - stopAndFailAllPending + + @Test + func `stopAndFailAllPending stops checking and fails all pending`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID, status: .sent) + ) + + await service.startAckExpiryChecking() + #expect(await service.isAckExpiryCheckingActive) + + await service.setPendingAckForTest( + makePending(messageID: messageID, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) + ) + + try await service.stopAndFailAllPending() + + #expect(await !service.isAckExpiryCheckingActive) + let msg = try await dataStore.fetchMessage(id: messageID) + #expect(msg?.status == .failed) + } + + // MARK: - Trip Time Preference + + @Test + func `handleAcknowledgement uses firmware tripTime when provided`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent, + ackCode: 0xDEAD_BEEF + ) + try await dataStore.saveMessage(message) + + let ackCode = Data([0xEF, 0xBE, 0xAD, 0xDE]) // 0xDEADBEEF LE + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-10) + ) + ) + + await service.handleAcknowledgement(code: ackCode, tripTime: 250) + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .delivered) + #expect(fetched?.roundTripTime == 250, + "Should use firmware tripTime (250ms), not Date()-based (~10000ms)") + } + + @Test + func `handleAcknowledgement leaves roundTripTime nil when firmware does not supply tripTime`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent, + ackCode: 0xCAFE_BABE + ) + try await dataStore.saveMessage(message) + + let ackCode = Data([0xBE, 0xBA, 0xFE, 0xCA]) // 0xCAFEBABE LE + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-2) + ) + ) + + await service.handleAcknowledgement(code: ackCode, tripTime: nil) + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .delivered) + #expect(fetched?.roundTripTime == nil, + "nil tripTime must not be replaced by a fabricated Date()-based RTT") + } + + // MARK: - Multi-attempt (Issue #283) + + @Test + func `handleAcknowledgement matches any CRC accumulated across retry attempts`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .pending + ) + try await dataStore.saveMessage(message) + + // Simulate three retry attempts, each producing a different CRC + // (firmware hashes attempt index into the ack code). + let attempt0 = Data([0xAA, 0xAA, 0xAA, 0xAA]) + let attempt1 = Data([0xBB, 0xBB, 0xBB, 0xBB]) + let attempt2 = Data([0xCC, 0xCC, 0xCC, 0xCC]) + await service.setPendingAckForTest( + makePending(messageID: messageID, ackCodes: [attempt0, attempt1, attempt2]) + ) + + // A late ACK for attempt 0's CRC arrives after the retry loop has moved + // on to attempt 2 — must still deliver. + await service.handleAcknowledgement(code: attempt0, tripTime: 500) + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .delivered, + "Late ACK from an earlier attempt must still mark delivered") + #expect(fetched?.roundTripTime == 500) + #expect(await service.pendingAckCount == 0, + "Entry should be removed after delivery (no sibling accumulation)") + } + + @Test + func `handleAcknowledgement updates contact lastMessageDate on late ACK`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let radioID = testDeviceID + try await dataStore.saveDevice(DeviceDTO.testDevice(id: radioID, radioID: radioID)) + + let frame = ContactFrame( + publicKey: Data((0..= before, + "Contact lastMessageDate should be updated to approximately now") + } + } + + @Test + func `trackPendingAck on retry resets sentAt so checkExpiredAcks preserves a retrying message`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let contactID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent + ) + try await dataStore.saveMessage(message) + + // Seed attempt 0 with sentAt well past its timeout window. + let ancient = Date().addingTimeInterval(-60) + await service.setPendingAckForTest( + makePending( + messageID: messageID, + contactID: contactID, + ackCodes: [Data([0xAA, 0xAA, 0xAA, 0xAA])], + sentAt: ancient, + timeout: 30.0 + ) + ) + + // Retry attempt 1 registers a new ackCode and a fresh timeout window. + // sentAt must also advance, otherwise checkExpiredAcks will fail the + // entry immediately even though the retry is actively in flight. + await service.trackPendingAck( + messageID: messageID, + contactID: contactID, + ackCode: Data([0xBB, 0xBB, 0xBB, 0xBB]), + timeout: 30.0 + ) + + try await service.checkExpiredAcks() + + #expect(await service.pendingAckCount == 1, + "Retry attempt must preserve the entry, not expire it") + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status != .failed, + "Retry must not flicker to .failed while in flight") + } + + @Test + func `finalizeSend preserves roundTripTime after listener-won delivery`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let contactID = UUID() + let radioID = testDeviceID + let publicKey = Data((0.. sent is FORBIDDEN)") + let events = await service.drainStatusEvents(statusEvents) + #expect(events.resolvedIDs.isEmpty, "no .sent yield when clearRetryingToSent returns false") + #expect(events.failedIDs.isEmpty) + } + + @Test + func `finalizeSend nil branch yields .statusResolved(.sent) from a non-terminal row and gates off on a terminal one`() async throws { + // Part A: a .retrying row moves to .sent and yields exactly one event. + let (serviceA, storeA) = try await MessageService.createForTesting() + let idA = UUID() + try await storeA.saveMessage( + MessageDTO.testDirectMessage(id: idA, radioID: testDeviceID, status: .retrying) + ) + await serviceA.setPendingAckForTest( + makePending(messageID: idA, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) + ) + let eventsA = serviceA.statusEvents() + _ = try await serviceA.finalizeSend( + messageID: idA, + contactID: UUID(), + radioID: testDeviceID, + publicKey: Data((0.. 5` via a precondition. /// The firmware ACK hash masks the attempt index with `& 0x03`; a single @@ -10,16 +10,15 @@ import Testing /// inline rather than asserted at runtime; the boundary case is exercised here. @Suite("MessageServiceConfig precondition") struct MessageServiceConfigTests { + @Test + func `maxAttempts == 5 is accepted at the precondition boundary`() { + let config = MessageServiceConfig(maxAttempts: 5) + #expect(config.maxAttempts == 5) + } - @Test("maxAttempts == 5 is accepted at the precondition boundary") - func boundaryValueIsAccepted() { - let config = MessageServiceConfig(maxAttempts: 5) - #expect(config.maxAttempts == 5) - } - - @Test("Default config respects the maxAttempts ceiling") - func defaultConfigHonoursCeiling() { - let config = MessageServiceConfig() - #expect(config.maxAttempts <= 5) - } + @Test + func `Default config respects the maxAttempts ceiling`() { + let config = MessageServiceConfig() + #expect(config.maxAttempts <= 5) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceListenerIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceListenerIntegrationTests.swift index a3e4700c..e3025570 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceListenerIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceListenerIntegrationTests.swift @@ -1,108 +1,107 @@ import Foundation -import Testing @testable import MC1Services @testable import MeshCore +import Testing @Suite("MessageService listener integration") struct MessageServiceListenerIntegrationTests { + @Test + func `listener flips message to .delivered after a flood of non-matching events`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) - @Test("listener flips message to .delivered after a flood of non-matching events") - func deliveryUnderFlood() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage( - id: messageID, - radioID: radioID, - contactID: contactID, - status: .sent - ) - ) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30 - ) - ) - await service.startEventMonitoring() - await service.waitForSubscriberCount(1) + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: radioID, + contactID: contactID, + status: .sent + ) + ) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30 + ) + ) + await service.startEventMonitoring() + await service.waitForSubscriberCount(1) - let session = await service.sessionForTest - for i in 0..<500 { - await session.dispatchForTesting(.advertisement(publicKey: Data([UInt8(i % 256)]))) - } - await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 1234)) + let session = await service.sessionForTest + for i in 0..<500 { + await session.dispatchForTesting(.advertisement(publicKey: Data([UInt8(i % 256)]))) + } + await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 1234)) - try await waitForStatus(.delivered, messageID: messageID, dataStore: dataStore) + try await waitForStatus(.delivered, messageID: messageID, dataStore: dataStore) - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .delivered) - await service.stopEventMonitoring() - } + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered) + await service.stopEventMonitoring() + } - @Test("listener restart after disconnect/reconnect still observes ACKs") - func deliveryAfterListenerRestart() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - let ackCode = Data([0x11, 0x22, 0x33, 0x44]) + @Test + func `listener restart after disconnect/reconnect still observes ACKs`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + let ackCode = Data([0x11, 0x22, 0x33, 0x44]) - try await dataStore.saveMessage( - MessageDTO.testDirectMessage( - id: messageID, - radioID: radioID, - contactID: contactID, - status: .sent - ) - ) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30 - ) - ) + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: radioID, + contactID: contactID, + status: .sent + ) + ) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30 + ) + ) - await service.startEventMonitoring() - await service.waitForSubscriberCount(1) - await service.stopEventMonitoring() - await service.waitForSubscriberCount(0) - await service.startEventMonitoring() - await service.waitForSubscriberCount(1) + await service.startEventMonitoring() + await service.waitForSubscriberCount(1) + await service.stopEventMonitoring() + await service.waitForSubscriberCount(0) + await service.startEventMonitoring() + await service.waitForSubscriberCount(1) - let session = await service.sessionForTest - await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 200)) + let session = await service.sessionForTest + await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 200)) - try await waitForStatus(.delivered, messageID: messageID, dataStore: dataStore) + try await waitForStatus(.delivered, messageID: messageID, dataStore: dataStore) - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .delivered) - await service.stopEventMonitoring() - } + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered) + await service.stopEventMonitoring() + } - private func waitForStatus( - _ expected: MessageStatus, - messageID: UUID, - dataStore: PersistenceStore, - timeout: Duration = .milliseconds(500) - ) async throws { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if let m = try await dataStore.fetchMessage(id: messageID), m.status == expected { - return - } - try await Task.sleep(for: .milliseconds(10)) - } - Issue.record("Message did not reach \(expected) within \(timeout)") + private func waitForStatus( + _ expected: MessageStatus, + messageID: UUID, + dataStore: PersistenceStore, + timeout: Duration = .seconds(10) + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if let m = try await dataStore.fetchMessage(id: messageID), m.status == expected { + return + } + try await Task.sleep(for: .milliseconds(10)) } + Issue.record("Message did not reach \(expected) within \(timeout)") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendDMBookkeepingTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendDMBookkeepingTests.swift index fb011714..76656f6e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendDMBookkeepingTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendDMBookkeepingTests.swift @@ -1,92 +1,91 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("MessageService DM post-send bookkeeping") struct MessageServiceSendDMBookkeepingTests { + @Test + @MainActor + func `sendDirectMessage keeps ACK tracking and does not report failure when post-send bookkeeping fails`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } - @Test("sendDirectMessage keeps ACK tracking and does not report failure when post-send bookkeeping fails") - @MainActor - func postSendBookkeepingFailureKeepsPendingAck() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 10) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - - let statusEvents = service.statusEvents() + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - let contact = ContactDTO.testContact(radioID: UUID()) + let statusEvents = service.statusEvents() - let sendTask = Task { - try await service.sendDirectMessage(text: "hi", to: contact) - } + let contact = ContactDTO.testContact(radioID: UUID()) - try await waitUntil("send should issue CMD_SEND_TXT_MSG") { - await transport.sentData.count == 2 - } + let sendTask = Task { + try await service.sendDirectMessage(text: "hi", to: contact) + } - // Delete the saved row mid-send so post-send bookkeeping fails after - // the radio has already accepted the message. - let saved = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0) - let messageID = try #require(saved.first?.id) - try await dataStore.deleteMessage(id: messageID) + try await waitUntil("send should issue CMD_SEND_TXT_MSG") { + await transport.sentData.count == 2 + } - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(Data([0xAB, 0xCD, 0xEF, 0x12])) - msgSent.append(uint32Bytes(5_000)) - await transport.simulateReceive(msgSent) + // Delete the saved row mid-send so post-send bookkeeping fails after + // the radio has already accepted the message. + let saved = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0) + let messageID = try #require(saved.first?.id) + try await dataStore.deleteMessage(id: messageID) - await #expect(throws: Error.self) { - _ = try await sendTask.value - } + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(Data([0xAB, 0xCD, 0xEF, 0x12])) + msgSent.append(uint32Bytes(5000)) + await transport.simulateReceive(msgSent) - #expect(await service.pendingAckCount == 1, - "post-send bookkeeping failure must keep the pendingAcks entry alive for the genuine ACK") - #expect(await service.drainStatusEvents(statusEvents).failedIDs.isEmpty, - "post-send bookkeeping failure must not report the DM as failed") + await #expect(throws: Error.self) { + _ = try await sendTask.value } - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(0)) - payload.append(int32Bytes(0)) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) + #expect(await service.pendingAckCount == 1, + "post-send bookkeeping failure must keep the pendingAcks entry alive for the genuine ACK") + #expect(await service.drainStatusEvents(statusEvents).failedIDs.isEmpty, + "post-send bookkeeping failure must not report the DM as failed") + } - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet - } + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(0)) + payload.append(int32Bytes(0)) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } - } + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } - } + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } + + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendTests.swift index 6a9226fd..3d97ac56 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendTests.swift @@ -1,942 +1,1120 @@ -import Testing import Foundation -import MeshCoreTestSupport @testable import MC1Services @testable import MeshCore +import MeshCoreTestSupport +import Testing @Suite("MessageService Send Tests") struct MessageServiceSendTests { - - private let testDeviceID = UUID() - - // MARK: - sendDirectMessage - - @Test("sendDirectMessage throws invalidRecipient for repeater contacts") - func sendDirectMessageRejectsRepeater() async throws { - let (service, _) = try await MessageService.createForTesting() - let repeater = ContactDTO.testContact( - radioID: testDeviceID, - typeRawValue: ContactType.repeater.rawValue - ) - - try await #expect { - _ = try await service.sendDirectMessage(text: "Hello", to: repeater) - } throws: { error in - guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } - return true - } + private let testDeviceID = UUID() + + // MARK: - sendDirectMessage + + @Test + func `sendDirectMessage throws invalidRecipient for repeater contacts`() async throws { + let (service, _) = try await MessageService.createForTesting() + let repeater = ContactDTO.testContact( + radioID: testDeviceID, + typeRawValue: ContactType.repeater.rawValue + ) + + try await #expect { + _ = try await service.sendDirectMessage(text: "Hello", to: repeater) + } throws: { error in + guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } + return true } - - @Test("sendDirectMessage throws messageTooLong for oversized text") - func sendDirectMessageRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) - - try await #expect { - _ = try await service.sendDirectMessage(text: longText, to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } + } + + @Test + func `sendDirectMessage throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) + + try await #expect { + _ = try await service.sendDirectMessage(text: longText, to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true } - - @Test("sendDirectMessage saves message to dataStore before send attempt") - func sendDirectMessageSavesFirst() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - do { - _ = try await service.sendDirectMessage(text: "Hello", to: contact) - } catch { - // Expected — session not started - } - - let messages = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0) - #expect(!messages.isEmpty, "Message should be saved before send attempt") - #expect(messages.first?.text == "Hello") - #expect(messages.first?.direction == .outgoing) + } + + @Test + func `sendDirectMessage saves message to dataStore before send attempt`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + do { + _ = try await service.sendDirectMessage(text: "Hello", to: contact) + } catch { + // Expected — session not started } - // MARK: - sendMessageWithRetry - - @Test("sendMessageWithRetry throws invalidRecipient for repeater contacts") - func sendMessageWithRetryRejectsRepeater() async throws { - let (service, _) = try await MessageService.createForTesting() - let repeater = ContactDTO.testContact( - radioID: testDeviceID, - typeRawValue: ContactType.repeater.rawValue - ) - - try await #expect { - _ = try await service.sendMessageWithRetry(text: "Hello", to: repeater) - } throws: { error in - guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } - return true - } + let messages = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0) + #expect(!messages.isEmpty, "Message should be saved before send attempt") + #expect(messages.first?.text == "Hello") + #expect(messages.first?.direction == .outgoing) + } + + // MARK: - sendMessageWithRetry + + @Test + func `sendMessageWithRetry throws invalidRecipient for repeater contacts`() async throws { + let (service, _) = try await MessageService.createForTesting() + let repeater = ContactDTO.testContact( + radioID: testDeviceID, + typeRawValue: ContactType.repeater.rawValue + ) + + try await #expect { + _ = try await service.sendMessageWithRetry(text: "Hello", to: repeater) + } throws: { error in + guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } + return true } - - @Test("sendMessageWithRetry throws messageTooLong for oversized text") - func sendMessageWithRetryRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) - - try await #expect { - _ = try await service.sendMessageWithRetry(text: longText, to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } + } + + @Test + func `sendMessageWithRetry throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) + + try await #expect { + _ = try await service.sendMessageWithRetry(text: longText, to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true } - - // MARK: - createPendingMessage - - @Test("createPendingMessage creates message with pending status") - func createPendingMessageStatus() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - - let message = try await service.createPendingMessage(text: "Pending", to: contact) - - #expect(message.status == .pending) - #expect(message.direction == .outgoing) - #expect(message.text == "Pending") - #expect(message.contactID == contact.id) - - let fetched = try await dataStore.fetchMessage(id: message.id) - #expect(fetched != nil) - #expect(fetched?.status == .pending) + } + + // MARK: - createPendingMessage + + @Test + func `createPendingMessage creates message with pending status`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + + let message = try await service.createPendingMessage(text: "Pending", to: contact) + + #expect(message.status == .pending) + #expect(message.direction == .outgoing) + #expect(message.text == "Pending") + #expect(message.contactID == contact.id) + + let fetched = try await dataStore.fetchMessage(id: message.id) + #expect(fetched != nil) + #expect(fetched?.status == .pending) + } + + @Test + func `createPendingMessage throws invalidRecipient for repeater`() async throws { + let (service, _) = try await MessageService.createForTesting() + let repeater = ContactDTO.testContact( + radioID: testDeviceID, + typeRawValue: ContactType.repeater.rawValue + ) + + try await #expect { + _ = try await service.createPendingMessage(text: "Test", to: repeater) + } throws: { error in + guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } + return true } - - @Test("createPendingMessage throws invalidRecipient for repeater") - func createPendingMessageRejectsRepeater() async throws { - let (service, _) = try await MessageService.createForTesting() - let repeater = ContactDTO.testContact( - radioID: testDeviceID, - typeRawValue: ContactType.repeater.rawValue - ) - - try await #expect { - _ = try await service.createPendingMessage(text: "Test", to: repeater) - } throws: { error in - guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } - return true - } + } + + @Test + func `createPendingMessage throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) + + try await #expect { + _ = try await service.createPendingMessage(text: longText, to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true + } + } + + @Test + func `createPendingMessage returns DTO with correct fields`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contactID = UUID() + let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) + + let message = try await service.createPendingMessage( + text: "Hello world", + to: contact, + textType: .plain + ) + + #expect(message.text == "Hello world") + #expect(message.contactID == contactID) + #expect(message.radioID == testDeviceID) + #expect(message.direction == .outgoing) + #expect(message.textType == .plain) + #expect(message.channelIndex == nil) + } + + @Test + func `createPendingMessage stamps lastMessageDate so a first DM appears in the chat list before any send succeeds`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID, lastMessageDate: nil) + try await dataStore.saveContact(contact) + + let before = try await dataStore.fetchConversations(radioID: testDeviceID) + #expect(before.isEmpty) + + _ = try await service.createPendingMessage(text: "First DM", to: contact) + + let after = try await dataStore.fetchConversations(radioID: testDeviceID) + #expect(after.contains { $0.id == contact.id }) + #expect(after.first { $0.id == contact.id }?.lastMessageDate != nil) + } + + // MARK: - sendPendingDirectMessage / resendDirectMessage + + @Test + func `sendPendingDirectMessage rejects concurrent send for same messageID`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + let messageID = UUID() + + await service.insertInFlightRetryForTest(messageID) + + try await #expect { + _ = try await service.sendPendingDirectMessage(messageID: messageID, to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case let .sendFailed(msg) = e else { return false } + return msg.contains("already in progress") + } + } + + @Test + func `sendPendingDirectMessage throws when message not found`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + + try await #expect { + _ = try await service.sendPendingDirectMessage(messageID: UUID(), to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } + return true + } + } + + // MARK: - sendChannelMessage + + @Test + func `sendChannelMessage throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let longText = String(repeating: "a", count: ProtocolLimits.maxChannelMessageTotalLength + 1) + + try await #expect { + _ = try await service.sendChannelMessage( + text: longText, + channelIndex: 0, + radioID: testDeviceID + ) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true + } + } + + @Test + func `sendChannelMessage saves message to dataStore before send attempt`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + do { + _ = try await service.sendChannelMessage( + text: "Hello channel", + channelIndex: 0, + radioID: testDeviceID + ) + } catch { + // Expected — session not started } - @Test("createPendingMessage throws messageTooLong for oversized text") - func createPendingMessageRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) - - try await #expect { - _ = try await service.createPendingMessage(text: longText, to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } + let messages = try await dataStore.fetchMessages( + radioID: testDeviceID, channelIndex: 0, limit: 10, offset: 0 + ) + #expect(!messages.isEmpty, "Message should be saved before send attempt") + #expect(messages.first?.text == "Hello channel") + #expect(messages.first?.direction == .outgoing) + #expect(messages.first?.status == .failed, "Message should be marked failed after send error") + } + + // MARK: - createPendingChannelMessage + + @Test + func `createPendingChannelMessage saves to dataStore with pending status`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + + let message = try await service.createPendingChannelMessage( + text: "Hello channel", + channelIndex: 0, + radioID: testDeviceID + ) + + #expect(message.status == .pending) + #expect(message.direction == .outgoing) + #expect(message.text == "Hello channel") + #expect(message.channelIndex == 0) + #expect(message.radioID == testDeviceID) + #expect(message.contactID == nil) + + let stored = try await dataStore.fetchMessage(id: message.id) + #expect(stored != nil, "Message should be persisted to dataStore") + #expect(stored?.status == .pending) + } + + @Test + func `createPendingChannelMessage throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let longText = String(repeating: "a", count: ProtocolLimits.maxChannelMessageTotalLength + 1) + + try await #expect { + _ = try await service.createPendingChannelMessage( + text: longText, + channelIndex: 0, + radioID: testDeviceID + ) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true } + } - @Test("createPendingMessage returns DTO with correct fields") - func createPendingMessageFields() async throws { - let (service, _) = try await MessageService.createForTesting() - let contactID = UUID() - let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) + // MARK: - sendPendingChannelMessage - let message = try await service.createPendingMessage( - text: "Hello world", - to: contact, - textType: .plain - ) + @Test + func `sendPendingChannelMessage throws when message not found`() async throws { + let (service, _) = try await MessageService.createForTesting() - #expect(message.text == "Hello world") - #expect(message.contactID == contactID) - #expect(message.radioID == testDeviceID) - #expect(message.direction == .outgoing) - #expect(message.textType == .plain) - #expect(message.channelIndex == nil) + try await #expect { + try await service.sendPendingChannelMessage(messageID: UUID()) + } throws: { error in + guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } + return true + } + } + + @Test + func `sendPendingChannelMessage sets failed status on send error`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + + let message = try await service.createPendingChannelMessage( + text: "Hello channel", + channelIndex: 0, + radioID: testDeviceID + ) + #expect(message.status == .pending) + + do { + try await service.sendPendingChannelMessage(messageID: message.id) + } catch { + // Expected — session not started } - @Test("createPendingMessage stamps lastMessageDate so a first DM appears in the chat list before any send succeeds") - func createPendingMessageMakesConversationVisible() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID, lastMessageDate: nil) - try await dataStore.saveContact(contact) + let stored = try await dataStore.fetchMessage(id: message.id) + #expect(stored?.status == .failed, "Message should be marked failed after send error") + } - let before = try await dataStore.fetchConversations(radioID: testDeviceID) - #expect(before.isEmpty) + // MARK: - resendChannelMessage - _ = try await service.createPendingMessage(text: "First DM", to: contact) + @Test + func `resendChannelMessage throws when message not found`() async throws { + let (service, _) = try await MessageService.createForTesting() - let after = try await dataStore.fetchConversations(radioID: testDeviceID) - #expect(after.contains { $0.id == contact.id }) - #expect(after.first { $0.id == contact.id }?.lastMessageDate != nil) + try await #expect { + try await service.resendChannelMessage(messageID: UUID()) + } throws: { error in + guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } + return true } + } - // MARK: - sendPendingDirectMessage / resendDirectMessage + @Test + func `resendChannelMessage throws when message is not a channel message`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() - @Test("sendPendingDirectMessage rejects concurrent send for same messageID") - func sendPendingDirectMessageRejectsConcurrent() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - let messageID = UUID() + let dm = MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID) + try await dataStore.saveMessage(dm) - await service.insertInFlightRetryForTest(messageID) - - try await #expect { - _ = try await service.sendPendingDirectMessage(messageID: messageID, to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed(let msg) = e else { return false } - return msg.contains("already in progress") - } + try await #expect { + try await service.resendChannelMessage(messageID: messageID) + } throws: { error in + guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } + return true } - - @Test("sendPendingDirectMessage throws when message not found") - func sendPendingDirectMessageThrowsWhenNotFound() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - - try await #expect { - _ = try await service.sendPendingDirectMessage(messageID: UUID(), to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } - return true - } + } + + @Test + @MainActor + func `resendChannelMessage writes .sent before broadcasting .resent and refreshes counts`() async throws { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 } - - // MARK: - sendChannelMessage - - @Test("sendChannelMessage throws messageTooLong for oversized text") - func sendChannelMessageRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let longText = String(repeating: "a", count: ProtocolLimits.maxChannelMessageTotalLength + 1) - - try await #expect { - _ = try await service.sendChannelMessage( - text: longText, - channelIndex: 0, - radioID: testDeviceID - ) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) + + let messageID = UUID() + let failed = MessageDTO.testChannelMessage( + id: messageID, + radioID: testDeviceID, + channelIndex: 0, + status: .failed, + heardRepeats: 3, + sendCount: 1 + ) + try await dataStore.saveMessage(failed) + + let statusEvents = service.statusEvents() + + let resendTask = Task { try await service.resendChannelMessage(messageID: messageID) } + + try await waitUntil("resend should send CMD_SEND_CHANNEL_MSG") { + await transport.sentData.count == 2 } - - @Test("sendChannelMessage saves message to dataStore before send attempt") - func sendChannelMessageSavesFirst() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - do { - _ = try await service.sendChannelMessage( - text: "Hello channel", - channelIndex: 0, - radioID: testDeviceID - ) - } catch { - // Expected — session not started - } - - let messages = try await dataStore.fetchMessages( - radioID: testDeviceID, channelIndex: 0, limit: 10, offset: 0 - ) - #expect(!messages.isEmpty, "Message should be saved before send attempt") - #expect(messages.first?.text == "Hello channel") - #expect(messages.first?.direction == .outgoing) - #expect(messages.first?.status == .failed, "Message should be marked failed after send error") - } - - // MARK: - createPendingChannelMessage - - @Test("createPendingChannelMessage saves to dataStore with pending status") - func createPendingChannelMessageSavesWithPendingStatus() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - - let message = try await service.createPendingChannelMessage( - text: "Hello channel", - channelIndex: 0, - radioID: testDeviceID - ) - - #expect(message.status == .pending) - #expect(message.direction == .outgoing) - #expect(message.text == "Hello channel") - #expect(message.channelIndex == 0) - #expect(message.radioID == testDeviceID) - #expect(message.contactID == nil) - - let stored = try await dataStore.fetchMessage(id: message.id) - #expect(stored != nil, "Message should be persisted to dataStore") - #expect(stored?.status == .pending) - } - - @Test("createPendingChannelMessage throws messageTooLong for oversized text") - func createPendingChannelMessageRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let longText = String(repeating: "a", count: ProtocolLimits.maxChannelMessageTotalLength + 1) - - try await #expect { - _ = try await service.createPendingChannelMessage( - text: longText, - channelIndex: 0, - radioID: testDeviceID - ) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } + await transport.simulateOK() + + _ = try await resendTask.value + + let recorded = await service.drainStatusEvents(statusEvents).resentIDs + #expect(recorded == [messageID], ".resent must broadcast exactly once with the resent ID") + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .sent, "resend must write .sent to the DB before broadcasting .resent") + #expect(stored?.heardRepeats == 0, "resend must reset heardRepeats to 0") + #expect(stored?.sendCount == 2, "resend must increment sendCount from 1 to 2") + } + + @Test + @MainActor + func `resendDirectMessage increments sendCount and broadcasts .resent on a successful resend`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 } - - // MARK: - sendPendingChannelMessage - - @Test("sendPendingChannelMessage throws when message not found") - func sendPendingChannelMessageThrowsWhenNotFound() async throws { - let (service, _) = try await MessageService.createForTesting() - - try await #expect { - try await service.sendPendingChannelMessage(messageID: UUID()) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } - return true - } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) + + let messageID = UUID() + let contactID = UUID() + let radioID = testDeviceID + let contact = ContactDTO.testContact(id: contactID, radioID: radioID) + + let delivered = MessageDTO.testDirectMessage( + id: messageID, + radioID: radioID, + contactID: contactID, + status: .delivered, + sendCount: 1 + ) + try await dataStore.saveMessage(delivered) + + let statusEvents = service.statusEvents() + + // Pre-populate the pending-ack entry as already delivered so the + // retry loop short-circuits after sendMessage returns. + let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30, + isDelivered: true + ) + ) + + let resendTask = Task { + try await service.resendDirectMessage(messageID: messageID, to: contact) } - @Test("sendPendingChannelMessage sets failed status on send error") - func sendPendingChannelMessageSetsFailedOnError() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - - let message = try await service.createPendingChannelMessage( - text: "Hello channel", - channelIndex: 0, - radioID: testDeviceID - ) - #expect(message.status == .pending) - - do { - try await service.sendPendingChannelMessage(messageID: message.id) - } catch { - // Expected — session not started - } - - let stored = try await dataStore.fetchMessage(id: message.id) - #expect(stored?.status == .failed, "Message should be marked failed after send error") + try await waitUntil("resend should send CMD_SEND_TXT_MSG") { + await transport.sentData.count == 2 } - // MARK: - resendChannelMessage - - @Test("resendChannelMessage throws when message not found") - func resendChannelMessageThrowsWhenNotFound() async throws { - let (service, _) = try await MessageService.createForTesting() + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(5000)) + await transport.simulateReceive(msgSent) - try await #expect { - try await service.resendChannelMessage(messageID: UUID()) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } - return true - } + // finalizeSend's routing check queries the contact; answer "not found" so + // the await below doesn't sit out the session timeout on the reply. + try await waitUntil("routing check should query the contact") { + await transport.sentData.count == 3 + } + await transport.simulateReceive(Data([ResponseCode.error.rawValue])) + + _ = try await resendTask.value + + let recorded = await service.drainStatusEvents(statusEvents).resentIDs + #expect(recorded == [messageID], + ".resent must broadcast exactly once on successful DM resend") + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.sendCount == 2, + "successful resendDirectMessage must increment sendCount from 1 to 2") + } + + @Test + @MainActor + func `sendPendingDirectMessage does not bump sendCount or broadcast .resent on first send`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) + + let messageID = UUID() + let contactID = UUID() + let radioID = testDeviceID + let contact = ContactDTO.testContact(id: contactID, radioID: radioID) + + let pending = MessageDTO.testDirectMessage( + id: messageID, + radioID: radioID, + contactID: contactID, + status: .pending, + sendCount: 1 + ) + try await dataStore.saveMessage(pending) + + let statusEvents = service.statusEvents() + + // Pre-populate the pending-ack entry as already delivered so the + // retry loop short-circuits after sendMessage returns. + let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30, + isDelivered: true + ) + ) + + let sendTask = Task { + try await service.sendPendingDirectMessage(messageID: messageID, to: contact) } - @Test("resendChannelMessage throws when message is not a channel message") - func resendChannelMessageRejectsNonChannel() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - - let dm = MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID) - try await dataStore.saveMessage(dm) - - try await #expect { - try await service.resendChannelMessage(messageID: messageID) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } - return true - } + try await waitUntil("send should send CMD_SEND_TXT_MSG") { + await transport.sentData.count == 2 } - @Test("resendChannelMessage writes .sent before broadcasting .resent and refreshes counts") - @MainActor - func resendChannelMessageFiresResentHandlerAfterDBWrite() async throws { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - - let messageID = UUID() - let failed = MessageDTO.testChannelMessage( - id: messageID, - radioID: testDeviceID, - channelIndex: 0, - status: .failed, - heardRepeats: 3, - sendCount: 1 - ) - try await dataStore.saveMessage(failed) - - let statusEvents = service.statusEvents() - - let resendTask = Task { try await service.resendChannelMessage(messageID: messageID) } - - try await waitUntil("resend should send CMD_SEND_CHANNEL_MSG") { - await transport.sentData.count == 2 - } - await transport.simulateOK() - - _ = try await resendTask.value - - let recorded = await service.drainStatusEvents(statusEvents).resentIDs - #expect(recorded == [messageID], ".resent must broadcast exactly once with the resent ID") - - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .sent, "resend must write .sent to the DB before broadcasting .resent") - #expect(stored?.heardRepeats == 0, "resend must reset heardRepeats to 0") - #expect(stored?.sendCount == 2, "resend must increment sendCount from 1 to 2") - } - - @Test("resendDirectMessage increments sendCount and broadcasts .resent on a successful resend") - @MainActor - func resendDirectMessageBumpsSendCountAndFiresResentHandler() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 10) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - - let messageID = UUID() - let contactID = UUID() - let radioID = testDeviceID - let contact = ContactDTO.testContact(id: contactID, radioID: radioID) - - let delivered = MessageDTO.testDirectMessage( - id: messageID, - radioID: radioID, - contactID: contactID, - status: .delivered, - sendCount: 1 - ) - try await dataStore.saveMessage(delivered) - - let statusEvents = service.statusEvents() - - // Pre-populate the pending-ack entry as already delivered so the - // retry loop short-circuits after sendMessage returns. - let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30, - isDelivered: true - ) - ) - - let resendTask = Task { - try await service.resendDirectMessage(messageID: messageID, to: contact) - } + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(5000)) + await transport.simulateReceive(msgSent) - try await waitUntil("resend should send CMD_SEND_TXT_MSG") { - await transport.sentData.count == 2 - } + // finalizeSend's routing check queries the contact; answer "not found" so + // the await below doesn't sit out the session timeout on the reply. + try await waitUntil("routing check should query the contact") { + await transport.sentData.count == 3 + } + await transport.simulateReceive(Data([ResponseCode.error.rawValue])) + + _ = try await sendTask.value + + let recorded = await service.drainStatusEvents(statusEvents).resentIDs + #expect(recorded.isEmpty, + ".resent must not broadcast on first send") + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.sendCount == 1, + "successful sendPendingDirectMessage must leave sendCount at 1") + } + + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(0)) + payload.append(int32Bytes(0)) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } + + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } + + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + @Test + func `sendDirectMessage tracks pending ACK before session.sendMessage so the listener cannot race`() async throws { + let (service, _) = try await MessageService.createForTesting(defaultTimeout: 10, connectTransport: true) + + // Seed selfInfo so the precompute step can read currentSelfInfo.publicKey + // without simulating an APP_START round-trip. + await service.installSelfInfoForTest(publicKey: Data(repeating: 0xFE, count: 32)) + + let contact = ContactDTO.testContact() + + // The mock transport never emits a messageSent event, so sendDirectMessage + // suspends inside session.sendMessage for the full defaultTimeout, holding + // the speculative pending-ack entry that trackPendingAck adds *before* the + // send. Poll for that entry with a generous ceiling: a correct ordering + // surfaces it near-instantly, while a regression that tracked after + // session.sendMessage would be blocked behind the send's timeout and never + // surface it before the task is cancelled — so this still catches reorders. + let sendTask = Task { + try? await service.sendDirectMessage(text: "hi", to: contact) + } - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(ackCode) - msgSent.append(uint32Bytes(5_000)) - await transport.simulateReceive(msgSent) - - _ = try await resendTask.value - - let recorded = await service.drainStatusEvents(statusEvents).resentIDs - #expect(recorded == [messageID], - ".resent must broadcast exactly once on successful DM resend") - - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.sendCount == 2, - "successful resendDirectMessage must increment sendCount from 1 to 2") - } - - @Test("sendPendingDirectMessage does not bump sendCount or broadcast .resent on first send") - @MainActor - func sendPendingDirectMessageDoesNotBumpSendCount() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 10) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - - let messageID = UUID() - let contactID = UUID() - let radioID = testDeviceID - let contact = ContactDTO.testContact(id: contactID, radioID: radioID) - - let pending = MessageDTO.testDirectMessage( - id: messageID, - radioID: radioID, - contactID: contactID, - status: .pending, - sendCount: 1 - ) - try await dataStore.saveMessage(pending) - - let statusEvents = service.statusEvents() - - // Pre-populate the pending-ack entry as already delivered so the - // retry loop short-circuits after sendMessage returns. - let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30, - isDelivered: true - ) - ) - - let sendTask = Task { - try await service.sendPendingDirectMessage(messageID: messageID, to: contact) - } + try await waitUntil( + timeout: .seconds(8), + "trackPendingAck must run before session.sendMessage so a listener ACK cannot race the tracker" + ) { + await service.pendingAckCount > 0 + } - try await waitUntil("send should send CMD_SEND_TXT_MSG") { - await transport.sentData.count == 2 - } + sendTask.cancel() + _ = await sendTask.value + } - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(ackCode) - msgSent.append(uint32Bytes(5_000)) - await transport.simulateReceive(msgSent) + @Test + func `sendMessageWithRetry tracks pending ACK before session.sendMessage`() async throws { + let (service, _) = try await MessageService.createForTesting(defaultTimeout: 10, connectTransport: true) - _ = try await sendTask.value + await service.installSelfInfoForTest(publicKey: Data(repeating: 0xFE, count: 32)) - let recorded = await service.drainStatusEvents(statusEvents).resentIDs - #expect(recorded.isEmpty, - ".resent must not broadcast on first send") + let contact = ContactDTO.testContact() - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.sendCount == 1, - "successful sendPendingDirectMessage must leave sendCount at 1") + let sendTask = Task { + try? await service.sendMessageWithRetry(text: "hi", to: contact) } - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(0)) - payload.append(int32Bytes(0)) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet + try await waitUntil( + timeout: .seconds(8), + "retry-loop precompute must track before session.sendMessage on every attempt" + ) { + await service.pendingAckCount > 0 } - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + sendTask.cancel() + _ = await sendTask.value + } + + @Test + func `failMessageAndRethrow does not downgrade a delivered DB row`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let ackCode = Data([0xDD, 0x11, 0x22, 0x33]) + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .delivered, + ackCode: ackCode.ackCodeUInt32 + ) + ) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: UUID(), + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30, + isDelivered: true + ) + ) + + await #expect(throws: MessageServiceError.self) { + try await service.failMessageAndRethrow( + MeshCoreError.notConnected, + messageID: messageID + ) } - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered, + "failMessageAndRethrow must not downgrade a delivered row") + } + + @Test + func `finalizeSend exhaustion does not downgrade a delivered DB row`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let contactID = UUID() + let radioID = testDeviceID + let publicKey = Data((0.. responded { + responded = count + let newest = await transport.sentData.last + if newest?.first == CommandCode.getContactByKey.rawValue { + // Answer the routing check's contact query with "not found" so finalizeSend + // returns without waiting out the session timeout. + await transport.simulateReceive(Data([ResponseCode.error.rawValue])) + } else { + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(10)) // ~12ms ack window + await transport.simulateReceive(msgSent) + } } - - try await waitUntil( - timeout: .seconds(8), - "trackPendingAck must run before session.sendMessage so a listener ACK cannot race the tracker" - ) { - await service.pendingAckCount > 0 - } - - sendTask.cancel() - _ = await sendTask.value + await Task.yield() + } } + defer { responder.cancel() } + + let sent = try await service.sendMessageWithRetry(text: "exhaust me", to: contact) + + #expect(sent.status == .sent, "retry exhaustion must leave the row .sent, not .failed") + #expect(try await dataStore.fetchMessage(id: sent.id)?.status == .sent) + #expect(await service.pendingAckCount == 1, + "the pending entry must survive so checkExpiredAcks owns the single give-up") + + let events = await service.drainStatusEvents(statusEvents) + #expect(!events.failedIDs.contains(sent.id), "no premature .failed on retry exhaustion") + #expect(!events.retryUpdates.isEmpty, "at least one .retrying must have fired (drove >1 attempt)") + #expect(events.resolvedIDs.contains(sent.id), "the nil branch must yield .statusResolved(.sent)") + } + + @Test + @MainActor + func `a genuine send exception still fails the DM through the outer catch`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } - @Test("sendMessageWithRetry tracks pending ACK before session.sendMessage") - func sendMessageWithRetryTracksPendingAckBeforeSend() async throws { - let (service, _) = try await MessageService.createForTesting(defaultTimeout: 10, connectTransport: true) - - await service.installSelfInfoForTest(publicKey: Data(repeating: 0xFE, count: 32)) - - let contact = ContactDTO.testContact() + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - let sendTask = Task { - try? await service.sendMessageWithRetry(text: "hi", to: contact) - } + let contact = ContactDTO.testContact(id: UUID(), radioID: testDeviceID) - try await waitUntil( - timeout: .seconds(8), - "retry-loop precompute must track before session.sendMessage on every attempt" - ) { - await service.pendingAckCount > 0 - } + // Every send after app start throws, simulating a transport drop. + await transport.failSends(fromSendIndex: 2) - sendTask.cancel() - _ = await sendTask.value - } - - @Test("failMessageAndRethrow does not downgrade a delivered DB row") - func failMessageAndRethrowDoesNotDowngradeDelivered() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let ackCode = Data([0xDD, 0x11, 0x22, 0x33]) - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .delivered, - ackCode: ackCode.ackCodeUInt32 - ) - ) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: UUID(), - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30, - isDelivered: true - ) - ) - - await #expect(throws: MessageServiceError.self) { - try await service.failMessageAndRethrow( - MeshCoreError.notConnected, - messageID: messageID - ) - } + await #expect(throws: (any Error).self) { + _ = try await service.sendMessageWithRetry(text: "boom", to: contact) + } - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .delivered, - "failMessageAndRethrow must not downgrade a delivered row") - } - - @Test("finalizeSend exhaustion does not downgrade a delivered DB row") - func finalizeSendExhaustionDoesNotDowngradeDelivered() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let contactID = UUID() - let radioID = testDeviceID - let publicKey = Data((0.. responded { - responded = count - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(ackCode) - msgSent.append(uint32Bytes(10)) // ~12ms ack window - await transport.simulateReceive(msgSent) - } - await Task.yield() - } + let stored = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0).first + #expect(stored?.status == .failed, + "a genuine send failure must still reach .failed via the outer catch") + } + + @Test + @MainActor + func `checkExpiredAcks cannot fail a DM while its retry loop is still inside waitForEvent`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 20) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + // The manual checkExpiredAcks call runs immediately after the send, so + // the elapsed time since the just-re-stamped sentAt is ~0s, far under the + // default give-up window; the global checker must not fire while the loop + // holds a freshly re-stamped entry. + let service = MessageService( + session: session, + dataStore: dataStore, + contactService: nil, + config: MessageServiceConfig(maxAttempts: 2, floodAfter: 5) + ) + + let contactID = UUID() + let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) + let ackCode = Data([0x7A, 0x7B, 0x7C, 0x7D]) + + let sendTask = Task { try await service.sendMessageWithRetry(text: "in flight", to: contact) } + + // Attempt 0's send goes out; feed a messageSent whose ack window far + // exceeds any runner stall, so waitForEvent stays parked until the test + // dispatches the ack and a saturated CI machine cannot expire it into a + // second attempt that nothing here answers. + try await waitUntil("attempt 0 should send") { + await transport.sentData.count == 2 + } + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(100_000)) // ~120s window: the loop stays parked + await transport.simulateReceive(msgSent) + + // Readiness: the loop's waitForEvent subscription is active. + await service.waitForSubscriberCount(1) + + // Run the global checker mid-loop: it must not fail the in-flight DM. + try await service.checkExpiredAcks() + + let midLoop = try await dataStore.fetchMessages(contactID: contactID, limit: 1, offset: 0).first + #expect(midLoop?.status != .failed, + "checkExpiredAcks must not fail a DM whose retry loop is still in flight") + #expect(await service.pendingAckCount == 1, "the in-flight entry must survive the checker tick") + + // Unblock the loop so it completes cleanly (delivered). + await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 100)) + // finalizeSend's routing check queries the contact; answer "not found" so + // the await below doesn't sit out the session timeout on the reply. + try await waitUntil("routing check should query the contact") { + await transport.sentData.count == 3 + } + await transport.simulateReceive(Data([ResponseCode.error.rawValue])) + let delivered = try await sendTask.value + #expect(delivered.status == .delivered) + } + + @Test + @MainActor + func `checkExpiredAcks respects a slow-preset per-attempt timeout that exceeds a tiny give-up window`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 20) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + // give-up window 1s; per-attempt timeout derives from suggestedTimeoutMs 100_000 (~120s). + // After a 2s real wait the elapsed exceeds the 1s window but is inside the attempt + // timeout, so max(window, timeout) resolves to the attempt timeout and the checker + // must leave the entry alive. + let service = MessageService( + session: session, + dataStore: dataStore, + contactService: nil, + config: MessageServiceConfig(maxAttempts: 2, floodAfter: 5, ackGiveUpWindow: 1) + ) + + let contactID = UUID() + let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) + let ackCode = Data([0x7A, 0x7B, 0x7C, 0x7D]) + + let sendTask = Task { try await service.sendMessageWithRetry(text: "slow preset", to: contact) } + + try await waitUntil("attempt 0 should send") { + await transport.sentData.count == 2 + } + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(100_000)) // ~120s window: the loop stays parked + await transport.simulateReceive(msgSent) + + await service.waitForSubscriberCount(1) + + // Wait 2 real seconds so elapsed > 1s window, then run the checker. + // The per-attempt timeout dominates the window, so the entry must survive. + try await Task.sleep(for: .seconds(2)) + try await service.checkExpiredAcks() + + let midLoop = try await dataStore.fetchMessages(contactID: contactID, limit: 1, offset: 0).first + #expect(midLoop?.status != .failed, + "checkExpiredAcks must not fail a DM still inside its per-attempt ACK timeout") + #expect(await service.pendingAckCount == 1, "the in-flight entry must survive when timeout > window") + + // Unblock the loop; it returns once the listener flips isDelivered. The + // in-flight waitForEvent still parks its full per-attempt timeout rather than + // hanging, and that timeout must exceed the give-up window to stay meaningful. + await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 100)) + // finalizeSend's routing check queries the contact; answer "not found" so + // the await below doesn't sit out the session timeout on the reply. + try await waitUntil("routing check should query the contact") { + await transport.sentData.count == 3 + } + await transport.simulateReceive(Data([ResponseCode.error.rawValue])) + let delivered = try await sendTask.value + #expect(delivered.status == .delivered) + } + + // MARK: - Retry budget count and direct->flood escalation + + @Test + @MainActor + func `the retry loop sends exactly config.maxAttempts times under sustained non-ACK`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + // floodAfter above maxAttempts so no path reset fires; this case isolates the budget count. + let maxAttempts = 2 + let service = MessageService( + session: session, + dataStore: dataStore, + contactService: nil, + config: MessageServiceConfig(maxAttempts: maxAttempts, floodAfter: maxAttempts + 3, minTimeout: 0) + ) + + let contact = ContactDTO.testContact(id: UUID(), radioID: testDeviceID) + let ackCode = Data([0xA1, 0xB2, 0xC3, 0xD4]) + + // Accept every CMD_SEND_TXT_MSG with a messageSent frame so session.sendMessage + // returns, but never emit the end-to-end ACK, so each attempt's waitForEvent + // times out and the loop retries until the budget is spent. Answer the + // routing-check contact query with "not found" so finalizeSend returns fast. + let responder = Task { + var responded = 1 // app start already accounted for + while !Task.isCancelled { + let count = await transport.sentData.count + if count > responded { + responded = count + let newest = await transport.sentData.last + if newest?.first == CommandCode.getContactByKey.rawValue { + await transport.simulateReceive(Data([ResponseCode.error.rawValue])) + } else { + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(10)) // ~12ms ack window + await transport.simulateReceive(msgSent) + } } - defer { responder.cancel() } - - let sent = try await service.sendMessageWithRetry(text: "exhaust me", to: contact) - - #expect(sent.status == .sent, "retry exhaustion must leave the row .sent, not .failed") - #expect(try await dataStore.fetchMessage(id: sent.id)?.status == .sent) - #expect(await service.pendingAckCount == 1, - "the pending entry must survive so checkExpiredAcks owns the single give-up") - - let events = await service.drainStatusEvents(statusEvents) - #expect(!events.failedIDs.contains(sent.id), "no premature .failed on retry exhaustion") - #expect(!events.retryUpdates.isEmpty, "at least one .retrying must have fired (drove >1 attempt)") - #expect(events.resolvedIDs.contains(sent.id), "the nil branch must yield .statusResolved(.sent)") - } - - @Test("a genuine send exception still fails the DM through the outer catch") - @MainActor - func genuineSendFailureStillFails() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 10) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 + await Task.yield() + } + } + defer { responder.cancel() } + + let sent = try await service.sendMessageWithRetry(text: "count me", to: contact) + #expect(sent.status == .sent) + + let sends = await transport.sentData + let directSendCount = sends.count(where: { $0.first == CommandCode.sendMessage.rawValue }) + #expect(directSendCount == maxAttempts, + "the retry loop must send exactly config.maxAttempts times, not maxAttempts+1") + } + + @Test + @MainActor + func `the retry loop escalates from direct to flood via resetPath after exactly config.floodAfter direct failures`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + // floodAfter < maxAttempts so the loop must switch to flood mid-run. + let maxAttempts = 3 + let floodAfter = 2 + let service = MessageService( + session: session, + dataStore: dataStore, + contactService: nil, + config: MessageServiceConfig(maxAttempts: maxAttempts, floodAfter: floodAfter, minTimeout: 0) + ) + + let contact = ContactDTO.testContact(id: UUID(), radioID: testDeviceID) + let ackCode = Data([0xA1, 0xB2, 0xC3, 0xD4]) + + // Never emit the end-to-end ACK, so every attempt times out. Answer + // resetPath with OK and every contact query with "not found" so the flood + // switch and finalizeSend proceed without stalling on the session timeout. + let responder = Task { + var responded = 1 // app start already accounted for + while !Task.isCancelled { + let count = await transport.sentData.count + if count > responded { + responded = count + let newest = await transport.sentData.last + switch newest?.first { + case CommandCode.resetPath.rawValue: + await transport.simulateOK() + case CommandCode.getContactByKey.rawValue: + await transport.simulateReceive(Data([ResponseCode.error.rawValue])) + default: + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(10)) // ~12ms ack window + await transport.simulateReceive(msgSent) + } } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) + await Task.yield() + } + } + defer { responder.cancel() } - let contact = ContactDTO.testContact(id: UUID(), radioID: testDeviceID) + let sent = try await service.sendMessageWithRetry(text: "escalate me", to: contact) + #expect(sent.status == .sent) - // Every send after app start throws, simulating a transport drop. - await transport.failSends(fromSendIndex: 2) + let sends = await transport.sentData + let resetIndex = sends.firstIndex { $0.first == CommandCode.resetPath.rawValue } + #expect(resetIndex != nil, + "the loop must escalate to flood via resetPath after config.floodAfter direct failures") - await #expect(throws: (any Error).self) { - _ = try await service.sendMessageWithRetry(text: "boom", to: contact) - } + if let resetIndex { + let directSendsBeforeReset = sends[.. 1s window, then run the checker. - // Under max(1, 18) = 18 the entry must survive. - try await Task.sleep(for: .seconds(2)) - try await service.checkExpiredAcks() - - let midLoop = try await dataStore.fetchMessages(contactID: contactID, limit: 1, offset: 0).first - #expect(midLoop?.status != .failed, - "checkExpiredAcks must not fail a DM still inside its per-attempt ACK timeout") - #expect(await service.pendingAckCount == 1, "the in-flight entry must survive when timeout > window") - - // Unblock the loop; it returns once the listener flips isDelivered. The - // in-flight waitForEvent still parks its full per-attempt timeout rather than - // hanging, and that timeout must exceed the give-up window to stay meaningful. - await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 100)) - let delivered = try await sendTask.value - #expect(delivered.status == .delivered) + #expect(directSendsBeforeReset == floodAfter, + "resetPath must fire after exactly config.floodAfter direct sends") } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceTests.swift index 38f262ec..5020a249 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceTests.swift @@ -1,40 +1,39 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("MessageService Tests") struct MessageServiceTests { + // MARK: - MessageServiceConfig Tests - // MARK: - MessageServiceConfig Tests - - @Test("MessageServiceConfig default values") - func messageServiceConfigDefaults() { - let config = MessageServiceConfig.default - #expect(config.floodFallbackOnRetry == true) - #expect(config.maxAttempts == 5) - #expect(config.maxFloodAttempts == 1) - #expect(config.floodAfter == 4) - #expect(config.minTimeout == 0) - #expect(config.triggerPathDiscoveryAfterFlood == true) - #expect(config.ackGiveUpWindow == 30) - } + @Test + func `MessageServiceConfig default values`() { + let config = MessageServiceConfig.default + #expect(config.floodFallbackOnRetry == true) + #expect(config.maxAttempts == 5) + #expect(config.maxFloodAttempts == 1) + #expect(config.floodAfter == 4) + #expect(config.minTimeout == 0) + #expect(config.triggerPathDiscoveryAfterFlood == true) + #expect(config.ackGiveUpWindow == 30) + } - @Test("MessageServiceConfig custom values") - func messageServiceConfigCustomValues() { - let config = MessageServiceConfig( - floodFallbackOnRetry: false, - maxAttempts: 3, - maxFloodAttempts: 3, - floodAfter: 1, - minTimeout: 10.0, - triggerPathDiscoveryAfterFlood: false - ) - #expect(config.floodFallbackOnRetry == false) - #expect(config.maxAttempts == 3) - #expect(config.maxFloodAttempts == 3) - #expect(config.floodAfter == 1) - #expect(config.minTimeout == 10.0) - #expect(config.triggerPathDiscoveryAfterFlood == false) - } + @Test + func `MessageServiceConfig custom values`() { + let config = MessageServiceConfig( + floodFallbackOnRetry: false, + maxAttempts: 3, + maxFloodAttempts: 3, + floodAfter: 1, + minTimeout: 10.0, + triggerPathDiscoveryAfterFlood: false + ) + #expect(config.floodFallbackOnRetry == false) + #expect(config.maxAttempts == 3) + #expect(config.maxFloodAttempts == 3) + #expect(config.floodAfter == 1) + #expect(config.minTimeout == 10.0) + #expect(config.triggerPathDiscoveryAfterFlood == false) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigImportPlannerTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigImportPlannerTests.swift index ffa38950..e5c3f3dd 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigImportPlannerTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigImportPlannerTests.swift @@ -1,970 +1,984 @@ import Foundation -import Testing @testable import MC1Services @testable import MeshCore +import Testing /// Covers the pure validate/plan seam (`planConfigImport`) and the non-trapping coordinate/timestamp /// encoders. These exercise the import safety guarantees without a live `MeshCoreSession`. @Suite("NodeConfigImportPlanner Tests") struct NodeConfigImportPlannerTests { - - // MARK: - Fixtures - - // Raw secret bytes paired with their hex string. Building the `Data` directly avoids the - // ambiguous `Data(hexString:)` that exists in both @testable-imported modules. - private static let secretBytesA = Data([ - 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, - 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, - ]) - private static let secretBytesB = Data([ - 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, - 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00, - ]) - private static let validChannelSecretA = "00112233445566778899aabbccddeeff" - private static let validChannelSecretB = "ffeeddccbbaa99887766554433221100" - private static let pubKeyHexA = String(repeating: "ab", count: 32) - private static let pubKeyHexB = String(repeating: "cd", count: 32) - - private static func emptySlots(_ count: UInt8) -> [DeviceChannelSlot] { - (0.. ConfigSections { - ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: false, - otherSettings: false, channels: true, contacts: false - ) - } - - private static func contactSections() -> ConfigSections { - ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: false, - otherSettings: false, channels: false, contacts: true - ) - } - - private static func identitySections() -> ConfigSections { - ConfigSections( - nodeIdentity: true, radioSettings: false, positionSettings: false, - otherSettings: false, channels: false, contacts: false - ) - } - - private static func contact( - type: UInt8 = 1, - name: String, - publicKey: String, - latitude: String = "0", - longitude: String = "0", - lastModified: UInt32 = 0, - outPath: String? = nil, - pathHashMode: UInt8? = nil - ) -> MeshCoreNodeConfig.ContactConfig { - MeshCoreNodeConfig.ContactConfig( - type: type, name: name, publicKey: publicKey, flags: 0, - latitude: latitude, longitude: longitude, - lastAdvert: 0, lastModified: lastModified, - outPath: outPath, pathHashMode: pathHashMode - ) - } - - private static func plan( - channels: [MeshCoreNodeConfig.ChannelConfig]? = nil, - contacts: [MeshCoreNodeConfig.ContactConfig]? = nil, - positionSettings: MeshCoreNodeConfig.PositionSettings? = nil, - radioSettings: MeshCoreNodeConfig.RadioSettings? = nil, - privateKey: String? = nil, - publicKey: String? = nil, - name: String? = nil, - sections: ConfigSections, - maxChannels: UInt8 = 8, - maxContacts: Int = 100, - maxTxPower: Int8 = 30, - existingChannels: [DeviceChannelSlot] = emptySlots(8), - existingContacts: [String: MeshContact] = [:] - ) throws -> ConfigImportPlan { - var config = MeshCoreNodeConfig() - config.channels = channels - config.contacts = contacts - config.positionSettings = positionSettings - config.radioSettings = radioSettings - config.privateKey = privateKey - config.publicKey = publicKey - config.name = name - return try planConfigImport( - config: config, sections: sections, - maxChannels: maxChannels, maxContacts: maxContacts, maxTxPower: maxTxPower, - existingChannels: existingChannels, existingContacts: existingContacts - ) - } - - /// A present-key entry for the existing-contacts map whose fields deliberately differ from the - /// capacity tests' imports, so the key counts toward capacity without triggering an M2 skip. - /// The stored `publicKey` is irrelevant to capacity accounting (which keys on the map), so a - /// placeholder avoids the cross-module `Data(hexString:)` ambiguity. - private static func presentContact(keyedAs hexKey: String) -> (String, MeshContact) { - let contact = MeshContact( - id: hexKey, - publicKey: Data(repeating: 0, count: 32), - type: .chat, - flags: ContactFlags(rawValue: 0), - outPathLength: 0xFF, - outPath: Data(), - advertisedName: "Existing", - lastAdvertisement: Date(timeIntervalSince1970: 0), - latitude: 0, - longitude: 0, - lastModified: Date(timeIntervalSince1970: 0) - ) - return (hexKey, contact) - } - - // MARK: - Coordinate validation - - @Test("Position with NaN latitude is rejected before any write") - func positionNaNRejected() { - let sections = ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: true, - otherSettings: false, channels: false, contacts: false - ) - #expect { - _ = try Self.plan( - positionSettings: .init(latitude: "nan", longitude: "0"), - sections: sections - ) - } throws: { error in - if case NodeConfigServiceError.invalidCoordinate(.positionLatitude) = error { return true } - return false - } - } - - @Test("Position with out-of-range latitude is rejected") - func positionOutOfRangeRejected() { - let sections = ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: true, - otherSettings: false, channels: false, contacts: false - ) - #expect { - _ = try Self.plan( - positionSettings: .init(latitude: "1000000000", longitude: "0"), - sections: sections - ) - } throws: { error in - if case NodeConfigServiceError.invalidCoordinate(.positionLatitude) = error { return true } - return false - } - } - - @Test("Contact with infinite longitude is rejected") - func contactInfiniteCoordinateRejected() { - let contacts = [Self.contact(name: "Bad", publicKey: Self.pubKeyHexA, longitude: "inf")] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidCoordinate(.contactLongitude(name: "Bad")) = error { return true } - return false - } - } - - @Test("Valid position passes and is carried into the plan") - func validPositionAccepted() throws { - let sections = ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: true, - otherSettings: false, channels: false, contacts: false - ) - let plan = try Self.plan( - positionSettings: .init(latitude: "47.6", longitude: "-122.3"), - sections: sections - ) - #expect(plan.position?.latitude == 47.6) - #expect(plan.position?.longitude == -122.3) - } - - // MARK: - Channels - - @Test("Two same-name hashtag channels fold onto one slot") - func intraImportNameDedup() throws { - let channels = [ - MeshCoreNodeConfig.ChannelConfig(name: "#rescue", secret: Self.validChannelSecretA), - MeshCoreNodeConfig.ChannelConfig(name: "#rescue", secret: Self.validChannelSecretB), - ] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelWrites.count == 2) - #expect(Set(plan.channelWrites.map(\.index)).count == 1, - "Same-name channels must not consume two slots") - } - - @Test("Two same-secret channels fold onto one slot") - func intraImportSecretDedup() throws { - let channels = [ - MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA), - MeshCoreNodeConfig.ChannelConfig(name: "Beta", secret: Self.validChannelSecretA), - ] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelWrites.count == 2) - #expect(Set(plan.channelWrites.map(\.index)).count == 1, - "Same-secret channels must not consume two slots") - } - - @Test("Two distinct channels land on two separate empty slots") - func distinctChannelsTakeDistinctSlots() throws { - let channels = [ - MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA), - MeshCoreNodeConfig.ChannelConfig(name: "Beta", secret: Self.validChannelSecretB), - ] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelWrites.count == 2, - "Two genuinely distinct channels must not be merged into one write") - #expect(Set(plan.channelWrites.map(\.index)).count == 2, - "Distinct channels must occupy distinct slots") - } - - @Test("Overwriting a configured slot with a differing secret is flagged") - func overwriteDetected() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "#old", secret: Self.secretBytesA, isConfigured: true) - - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#old", secret: Self.validChannelSecretB)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelsOverwriteExisting == true) - #expect(plan.channelWrites.first?.index == 0) - } - - @Test("Adding a channel into an empty slot is not an overwrite") - func additiveChannelNotOverwrite() throws { - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#new", secret: Self.validChannelSecretA)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelsOverwriteExisting == false) - } - - @Test("Slot exhaustion is rejected before any write") - func slotExhaustionRejected() { - let slots = [DeviceChannelSlot( - index: 0, name: "Existing", - secret: Self.secretBytesA, isConfigured: true - )] - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "New", secret: Self.validChannelSecretB)] - #expect { - _ = try Self.plan( - channels: channels, sections: Self.channelSections(), - maxChannels: 1, existingChannels: slots - ) - } throws: { error in - if case NodeConfigServiceError.noAvailableChannelSlot(name: "New") = error { return true } - return false - } - } - - @Test("Existing hashtag slot folds a same-secret non-hashtag import onto its slot") - func existingHashtagSlotFoldsSameSecretImport() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "#rescue", secret: Self.secretBytesA, isConfigured: true) - - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelWrites.count == 1) - #expect(plan.channelWrites.first?.index == 0, - "A same-secret import must fold onto the existing hashtag slot, not consume a fresh one") - #expect(plan.channelsOverwriteExisting == true) - } - - @Test("Long hashtag name folds onto its existing slot despite device-side truncation") - func longHashtagNameFoldsOntoExistingSlot() throws { - let fullName = "#" + String(repeating: "a", count: 40) // 41 bytes, exceeds the 31-byte field - let deviceTruncated = "#" + String(repeating: "a", count: 30) // 31 bytes, what firmware stores - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: deviceTruncated, secret: Self.secretBytesA, isConfigured: true) - - let channels = [MeshCoreNodeConfig.ChannelConfig(name: fullName, secret: Self.validChannelSecretB)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelWrites.count == 1) - #expect(plan.channelWrites.first?.index == 0, - "A long hashtag name must match its truncated device slot, not consume a fresh one") - } - - @Test("Hashtag-name import whose secret already lives on another slot folds onto the secret's slot") - func hashtagImportFoldsToExistingSecretSlot() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "#general", secret: Self.secretBytesA, isConfigured: true) - slots[1] = DeviceChannelSlot(index: 1, name: "Other", secret: Self.secretBytesB, isConfigured: true) - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretB)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - #expect(plan.channelWrites.count == 1) - #expect(plan.channelWrites.first?.index == 1, - "A secret already on another slot must keep the write single-homed") - #expect(plan.channelWrites.allSatisfy { $0.index != 0 }, - "The secret must not be duplicated onto the hashtag-name slot") - } - - @Test("Non-canonical secret hex still dedups against the canonically-keyed existing slot") - func nonCanonicalSecretHexFolds() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "Existing", secret: Self.secretBytesA, isConfigured: true) - - // Same secret bytes as the existing slot but written in uppercase, a non-canonical casing a - // hand-edited backup might use, paired with a changed name so the fold still produces a write - // (a byte-identical import would correctly skip). It must dedup against the device's - // lowercase-canonical key rather than consume a fresh slot. - let uppercased = Self.validChannelSecretA.uppercased() - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Renamed", secret: uppercased)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelWrites.count == 1) - #expect(plan.channelWrites.first?.index == 0, - "A non-canonical secret must dedup against the canonical existing slot, not consume a fresh one") - } - - @Test("Invalid channel secret length is rejected") - func invalidChannelSecretRejected() { - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Bad", secret: "abcd")] - #expect { - _ = try Self.plan(channels: channels, sections: Self.channelSections()) - } throws: { error in - if case NodeConfigServiceError.invalidChannelSecret = error { return true } - return false - } - } - - @Test("Channel secret with trailing non-hex characters is rejected, not silently accepted") - func channelSecretTrailingNonHexRejected() { - // Valid 32-char hex with stray non-hex characters appended. The filtering parser would - // strip "zz" and still yield 16 bytes; the strict guard must reject the whole string. - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Bad", secret: Self.validChannelSecretA + "zz")] - #expect { - _ = try Self.plan(channels: channels, sections: Self.channelSections()) - } throws: { error in - if case NodeConfigServiceError.invalidChannelSecret = error { return true } - return false - } - } - - // MARK: - Private key (malformed / wrong length) - - @Test("Present-but-garbage private key is rejected, not silently skipped") - func garbagePrivateKeyRejected() { - #expect { - _ = try Self.plan(privateKey: "zzzz", sections: Self.identitySections()) - } throws: { error in - if case NodeConfigServiceError.invalidPrivateKey = error { return true } - return false - } - } - - @Test("Wrong-length private key is rejected") - func wrongLengthPrivateKeyRejected() { - #expect { - _ = try Self.plan(privateKey: "abcd", sections: Self.identitySections()) - } throws: { error in - if case NodeConfigServiceError.invalidPrivateKey = error { return true } - return false - } - } - - @Test("Valid 64-byte expanded private key without a public key is accepted") - func validPrivateKeyAccepted() async throws { - let identity = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let plan = try Self.plan( - privateKey: identity.expandedPrivateKey.hexString, - sections: Self.identitySections() - ) - #expect(plan.importPrivateKey == identity.expandedPrivateKey) - } - - /// Regression: the real export format is the 64-byte expanded key (`clamp(SHA512(seed))`) - /// alongside its public key, which export always emits. The public key is derivable from the - /// expanded scalar, but MC1's CryptoKit API works from the 32-byte seed (which the export omits), - /// so the plan accepts the pair on trust rather than re-deriving and cross-checking it. - @Test("Expanded private key with its public key round-trips and is accepted") - func matchingKeyPairAccepted() async throws { - let identity = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let plan = try Self.plan( - privateKey: identity.expandedPrivateKey.hexString, - publicKey: identity.publicKey.hexString, - sections: Self.identitySections() - ) - #expect(plan.importPrivateKey == identity.expandedPrivateKey) - } - - // MARK: - Radio validation - - private static func radioSections() -> ConfigSections { - ConfigSections( - nodeIdentity: false, radioSettings: true, positionSettings: false, - otherSettings: false, channels: false, contacts: false - ) - } - - private static func validRadio( - frequency: UInt32 = 910_525, - bandwidth: UInt32 = 62_500, - spreadingFactor: UInt8 = 7, - codingRate: UInt8 = 5, - txPower: Int8 = 20 - ) -> MeshCoreNodeConfig.RadioSettings { - .init( - frequency: frequency, bandwidth: bandwidth, - spreadingFactor: spreadingFactor, codingRate: codingRate, txPower: txPower - ) - } - - @Test("Valid radio settings within firmware ranges are carried into the plan") - func validRadioAccepted() throws { - let plan = try Self.plan(radioSettings: Self.validRadio(), sections: Self.radioSections()) - #expect(plan.radioSettings == Self.validRadio()) - } - - @Test("Out-of-range spreading factor is rejected before any write") - func radioSpreadingFactorRejected() { - #expect { - _ = try Self.plan(radioSettings: Self.validRadio(spreadingFactor: 99), sections: Self.radioSections()) - } throws: { error in - if case NodeConfigServiceError.invalidRadioSettings(.spreadingFactor) = error { return true } - return false - } - } - - @Test("Frequency below the firmware floor is rejected") - func radioFrequencyRejected() { - #expect { - _ = try Self.plan(radioSettings: Self.validRadio(frequency: 100_000), sections: Self.radioSections()) - } throws: { error in - if case NodeConfigServiceError.invalidRadioSettings(.frequency) = error { return true } - return false - } - } - - @Test("TX power above the device's reported maximum is rejected") - func radioTxPowerAboveDeviceMaxRejected() { - #expect { - _ = try Self.plan( - radioSettings: Self.validRadio(txPower: 25), - sections: Self.radioSections(), maxTxPower: 20 - ) - } throws: { error in - if case NodeConfigServiceError.invalidRadioSettings(.txPower) = error { return true } - return false - } - } - - @Test("TX power up to the device's reported maximum is accepted") - func radioTxPowerAtDeviceMaxAccepted() throws { - let plan = try Self.plan( - radioSettings: Self.validRadio(txPower: 30), - sections: Self.radioSections(), maxTxPower: 30 - ) - #expect(plan.radioSettings?.txPower == 30) - } - - // MARK: - Contacts (dedup, capacity, out_path, unknown type) - - @Test("Duplicate contacts dedup by public key, newest last_modified wins") - func contactDedupNewestWins() throws { - let contacts = [ - Self.contact(name: "Older", publicKey: Self.pubKeyHexA, lastModified: 100), - Self.contact(name: "Newer", publicKey: Self.pubKeyHexA, lastModified: 200), - ] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) - #expect(plan.contactRecords.count == 1) - #expect(plan.contactRecords.first?.advertisedName == "Newer") - } - - @Test("Dedup keeps the newer record even when it appears first (inverse comparison branch)") - func contactDedupNewestWinsReversedOrder() throws { - let contacts = [ - Self.contact(name: "Newer", publicKey: Self.pubKeyHexA, lastModified: 200), - Self.contact(name: "Older", publicKey: Self.pubKeyHexA, lastModified: 100), - ] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) - #expect(plan.contactRecords.count == 1) - #expect(plan.contactRecords.first?.advertisedName == "Newer", - "The older record must not overwrite the already-seen newer one") - } - - @Test("Dedup of equal-timestamp duplicates collapses to one record deterministically") - func contactDedupEqualTimestamps() throws { - let contacts = [ - Self.contact(name: "First", publicKey: Self.pubKeyHexA, lastModified: 100), - Self.contact(name: "Second", publicKey: Self.pubKeyHexA, lastModified: 100), - ] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) - #expect(plan.contactRecords.count == 1) - // The `>=` comparison adopts a same-timestamp later entry, so the last one read wins. - #expect(plan.contactRecords.first?.advertisedName == "Second") - } - - @Test("Contact with a valid out_path but out-of-range path hash mode is rejected") - func contactInvalidPathHashModeRejected() { - let contacts = [Self.contact( - name: "BadMode", publicKey: Self.pubKeyHexA, outPath: "aabb", pathHashMode: 3 - )] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidPathHashMode(name: "BadMode", mode: 3) = error { return true } - return false - } - } - - @Test("Exceeding device contact capacity is rejected") - func contactCapacityRejected() { - let contacts = [ - Self.contact(name: "A", publicKey: Self.pubKeyHexA), - Self.contact(name: "B", publicKey: Self.pubKeyHexB), - ] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections(), maxContacts: 1) - } throws: { error in - if case NodeConfigServiceError.contactCapacityExceeded = error { return true } - return false - } - } - - @Test("Exactly filling the remaining contact slots is accepted") - func contactCapacityBoundaryAccepted() throws { - let contacts = [ - Self.contact(name: "A", publicKey: Self.pubKeyHexA), - Self.contact(name: "B", publicKey: Self.pubKeyHexB), - ] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections(), maxContacts: 2) - #expect(plan.contactRecords.count == 2) - } - - @Test("Capacity check credits keys already on the device (updates consume no slot)") - func contactCapacityCreditsExistingKeys() throws { - // Device table is full (maxContacts 1, one existing key), but the import only updates that key. - let plan = try Self.plan( - contacts: [Self.contact(name: "Update", publicKey: Self.pubKeyHexA)], - sections: Self.contactSections(), - maxContacts: 1, - existingContacts: Dictionary(uniqueKeysWithValues: [Self.presentContact(keyedAs: Self.pubKeyHexA.lowercased())]) - ) - #expect(plan.contactRecords.count == 1) - } - - @Test("A new contact has no free slot once the device table is full") - func contactCapacityRejectsNewWhenFull() { - #expect { - _ = try Self.plan( - contacts: [Self.contact(name: "New", publicKey: Self.pubKeyHexB)], - sections: Self.contactSections(), - maxContacts: 1, - existingContacts: Dictionary(uniqueKeysWithValues: [Self.presentContact(keyedAs: Self.pubKeyHexA.lowercased())]) - ) - } throws: { error in - if case NodeConfigServiceError.contactCapacityExceeded = error { return true } - return false - } - } - - @Test("Invalid out_path hex is rejected instead of silently downgrading to direct") - func invalidOutPathRejected() { - let contacts = [Self.contact(name: "BadPath", publicKey: Self.pubKeyHexA, outPath: "zzz")] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidOutPath(name: "BadPath") = error { return true } - return false - } - } - - @Test("Out_path longer than the firmware buffer is rejected") - func outPathExceedingBufferRejected() { - // 66 bytes of 3-byte hashes = 22 hops: within the 6-bit hop field but past MAX_PATH_SIZE, - // so only the total-length guard catches it, not the hop-count guard. - let longPath = String(repeating: "ab", count: PathEncoding.maxPathBytes / 3 * 3 + 3) - let contacts = [Self.contact( - name: "TooLong", publicKey: Self.pubKeyHexA, outPath: longPath, pathHashMode: 2 - )] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidOutPath(name: "TooLong") = error { return true } - return false - } - } - - @Test("Invalid contact public key is rejected") - func invalidContactPublicKeyRejected() { - let contacts = [Self.contact(name: "BadKey", publicKey: "abcd")] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidContactPublicKey(name: "BadKey") = error { return true } - return false - } - } - - @Test("Unknown contact type byte is preserved verbatim while UI type falls back to chat") - func unknownContactTypePreserved() throws { - let contacts = [Self.contact(type: 99, name: "FutureType", publicKey: Self.pubKeyHexA)] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) - let record = try #require(plan.contactRecords.first) - #expect(record.typeRawValue == 99) - #expect(record.type == .chat) - } - - @Test("Absent out_path plans flood routing; empty string plans direct") - func outPathRoutingModes() throws { - let flood = try Self.plan( - contacts: [Self.contact(name: "Flood", publicKey: Self.pubKeyHexA, outPath: nil)], - sections: Self.contactSections() - ) - #expect(flood.contactRecords.first?.outPathLength == 0xFF) - - let direct = try Self.plan( - contacts: [Self.contact(name: "Direct", publicKey: Self.pubKeyHexB, outPath: "")], - sections: Self.contactSections() - ) - #expect(direct.contactRecords.first?.outPathLength == 0) - } - - @Test("Valid out_path resolves to its bytes and encoded length for each hash mode") - func validOutPathPerMode() throws { - // Expected encoded length is a pinned literal (mode << 6 | hopCount), not a re-derivation of - // the planner's own encodePathLen, so a bug in that encoder cannot be mirrored into the value. - let cases: [(mode: UInt8, hex: String, bytes: [UInt8], length: UInt8)] = [ - (0, "aabb", [0xaa, 0xbb], 0x02), - (1, "aabbccdd", [0xaa, 0xbb, 0xcc, 0xdd], 0x42), - (2, "aabbccddeeff", [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff], 0x82), - ] - for testCase in cases { - let plan = try Self.plan( - contacts: [Self.contact( - name: "Routed", publicKey: Self.pubKeyHexA, - outPath: testCase.hex, pathHashMode: testCase.mode - )], - sections: Self.contactSections() - ) - let record = try #require(plan.contactRecords.first) - #expect(record.outPath == Data(testCase.bytes)) - #expect(record.outPathLength == testCase.length) - } - } - - // MARK: - Empty-but-present sections / identity carry-through - - @Test("Empty-but-present channel and contact arrays plan no writes and no overwrite") - func emptyArraysPlanNothing() throws { - let sections = ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: false, - otherSettings: false, channels: true, contacts: true - ) - let plan = try Self.plan(channels: [], contacts: [], sections: sections) - #expect(plan.channelWrites.isEmpty) - #expect(plan.contactRecords.isEmpty) - #expect(plan.channelsOverwriteExisting == false) - } - - @Test("Identity plan carries a non-nil node name verbatim") - func identityCarriesNodeName() throws { - let plan = try Self.plan(name: "Rescue Base", sections: Self.identitySections()) - #expect(plan.nodeName == "Rescue Base") - } - - // MARK: - M1: byte-identical channel slot skip - - @Test("A channel byte-identical to its resolved slot plans no write") - func identicalChannelSlotSkipped() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) - - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelWrites.isEmpty, "An identical slot must not re-commit /channels2") - #expect(plan.channelsOverwriteExisting == false) - } - - @Test("A name-only diff, secret-only diff, and secret relocation each still plan one write") - func channelDiffsStillWrite() throws { - var nameDiff = Self.emptySlots(8) - nameDiff[0] = DeviceChannelSlot(index: 0, name: "Old", secret: Self.secretBytesA, isConfigured: true) - let nameDiffPlan = try Self.plan( - channels: [MeshCoreNodeConfig.ChannelConfig(name: "New", secret: Self.validChannelSecretA)], - sections: Self.channelSections(), existingChannels: nameDiff) - #expect(nameDiffPlan.channelWrites.count == 1, "A name change must still write") - - var secretDiff = Self.emptySlots(8) - secretDiff[0] = DeviceChannelSlot(index: 0, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) - let secretDiffPlan = try Self.plan( - channels: [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretB)], - sections: Self.channelSections(), existingChannels: secretDiff) - #expect(secretDiffPlan.channelWrites.count == 1, "A secret change must still write") - - // Secret A is homed at slot 2 under a different name: the import folds onto that slot and - // writes because the name differs, so the skip must not swallow it. - var relocate = Self.emptySlots(8) - relocate[2] = DeviceChannelSlot(index: 2, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) - let relocatePlan = try Self.plan( - channels: [MeshCoreNodeConfig.ChannelConfig(name: "Renamed", secret: Self.validChannelSecretA)], - sections: Self.channelSections(), existingChannels: relocate) - #expect(relocatePlan.channelWrites.count == 1, "A name change on the secret's existing slot still writes") - #expect(relocatePlan.channelWrites.first?.index == 2, "It folds onto the secret's existing slot") - } - - @Test("A brand-new channel into an empty slot still plans one write") - func newChannelStillWrites() throws { - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#new", secret: Self.validChannelSecretA)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelWrites.count == 1) - } - - @Test("A duplicate restoring a slot an earlier write changed is not swallowed by the skip") - func duplicateRestoringFoldedSlotStillWrites() throws { - // Same #hashtag homed at slot 0: the first entry changes the secret, the second restores - // the device's original secret. The skip must compare against the slot's planned value, not - // the frozen original, so the restoring write survives and wins last (slot ends at S1). - var hashtagSlots = Self.emptySlots(8) - hashtagSlots[0] = DeviceChannelSlot(index: 0, name: "#general", secret: Self.secretBytesA, isConfigured: true) - let hashtagPlan = try Self.plan( - channels: [ - MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretB), - MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretA), - ], - sections: Self.channelSections(), existingChannels: hashtagSlots) - #expect(hashtagPlan.channelWrites.count == 2, "Both folded writes are planned, last wins") - #expect(hashtagPlan.channelWrites.last?.secret == Self.secretBytesA, "The restoring write must win, not be dropped") - - // Same-secret name fold: the first entry renames the slot, the second restores the device's - // original name. The restoring write must survive so the slot ends at "Foo", not "Bar". - var secretSlots = Self.emptySlots(8) - secretSlots[0] = DeviceChannelSlot(index: 0, name: "Foo", secret: Self.secretBytesA, isConfigured: true) - let secretPlan = try Self.plan( - channels: [ - MeshCoreNodeConfig.ChannelConfig(name: "Bar", secret: Self.validChannelSecretA), - MeshCoreNodeConfig.ChannelConfig(name: "Foo", secret: Self.validChannelSecretA), - ], - sections: Self.channelSections(), existingChannels: secretSlots) - #expect(secretPlan.channelWrites.count == 2, "Both folded writes are planned, last wins") - #expect(secretPlan.channelWrites.last?.name == "Foo", "The restoring write must win, not be dropped") - } - - // MARK: - M2: byte-identical contact skip - - /// Models the device-resident form of a contact `session.getContacts` would report: the planner's - /// record encoded to the wire and decoded back through the same `parseContactData` the live read - /// uses, so the fixture reflects the device's stored bytes (name trimmed to the field width, coords - /// scaled, path sliced) rather than the planner's own `buildContactRecord` output. `lastModified` - /// models the firmware restamp the sub-148-byte add frame triggers: it defaults to the record's own - /// value (the same-device export/re-import round-trip the skip targets), or pass a different value to - /// model a foreign config the firmware re-stamped. - private func deviceStored(_ record: MeshContact, lastModified deviceLastModified: Date? = nil) -> MeshContact { - // The add frame omits last_modified (3 reserved bytes); the contact-response frame carries it at - // offset 143. Reuse the add encoder for offsets 0..<143, then append the device's last_modified. - var frame = Data(PacketBuilder.updateContact(record).dropFirst(1).prefix(143)) - frame.appendLittleEndian(UInt32((deviceLastModified ?? record.lastModified).timeIntervalSince1970)) - return Parsers.parseContactData(frame)! - } - - /// Runs the import once against an empty device to capture the record the planner builds, then - /// returns the device-resident form `getContacts` would report for it. - private func recordFor(_ contact: MeshCoreNodeConfig.ContactConfig) throws -> MeshContact { - let plan = try Self.plan(contacts: [contact], sections: Self.contactSections()) - return deviceStored(try #require(plan.contactRecords.first)) - } - - @Test("A contact equal on all persisted fields is dropped") - func identicalContactDropped() throws { - let config = Self.contact( - name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", - lastModified: 1000, outPath: "aabb", pathHashMode: 0) - let existing = try recordFor(config) - - let plan = try Self.plan( - contacts: [config], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.isEmpty, "A byte-identical contact must not re-commit /contacts3") - } - - @Test("A diff in any single persisted field still emits the contact") - func contactFieldDiffStillEmits() throws { - let base = Self.contact( - name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", - lastModified: 1000, outPath: "aabb", pathHashMode: 0) - let existing = try recordFor(base) - - let variants: [(label: String, config: MeshCoreNodeConfig.ContactConfig)] = [ - ("type", Self.contact(type: 2, name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), - ("name", Self.contact(name: "Charlie", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), - ("path", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "ccdd", pathHashMode: 0)), - ("coords", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "48.0", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), - ("lastModified", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 2000, outPath: "aabb", pathHashMode: 0)), - ] - for variant in variants { - let plan = try Self.plan( - contacts: [variant.config], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.count == 1, "A \(variant.label) diff must still write the contact") - } - } - - @Test("A name differing only past the firmware field width is treated as equal and dropped") - func contactNameDiffPastFieldWidthDropped() throws { - let shortConfig = Self.contact( - name: "#" + String(repeating: "a", count: 30), publicKey: Self.pubKeyHexA) - let existing = try recordFor(shortConfig) // 31-byte name, what the device stores - - // Import the same contact whose name only diverges past the 31-byte field width. - let longConfig = Self.contact( - name: "#" + String(repeating: "a", count: 30) + "EXTRA", publicKey: Self.pubKeyHexA) - let plan = try Self.plan( - contacts: [longConfig], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.isEmpty, "A name the device cannot represent differently must be treated as equal") - } - - @Test("A new contact is unaffected by the skip and capacity stays correct") - func newContactStillEmittedWithSkip() throws { - // One existing key matches its import (dropped); a second key is new (emitted). - let matchConfig = Self.contact(name: "Match", publicKey: Self.pubKeyHexA, lastModified: 5) - let existing = try recordFor(matchConfig) - let newConfig = Self.contact(name: "Fresh", publicKey: Self.pubKeyHexB, lastModified: 5) - - let plan = try Self.plan( - contacts: [matchConfig, newConfig], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.count == 1, "Only the unchanged contact is dropped") - #expect(plan.contactRecords.first?.advertisedName == "Fresh") - } - - @Test("A contact the firmware re-stamped is re-emitted, not dropped (skip is safe-fail)") - func restampedContactReEmitted() throws { - // The sub-148-byte contact-add frame carries no last_modified, so firmware stamps its own clock. - // For a config exported from a different device, the device-read last_modified therefore differs - // from the config's, and the skip must re-emit rather than drop: the optimization only fires for - // a same-device export/re-import round-trip, never for a foreign config the firmware re-stamped. - let config = Self.contact( - name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", - lastModified: 1000, outPath: "aabb", pathHashMode: 0) - let record = try #require( - try Self.plan(contacts: [config], sections: Self.contactSections()).contactRecords.first) - let existing = deviceStored(record, lastModified: Date(timeIntervalSince1970: 9_999)) - - let plan = try Self.plan( - contacts: [config], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.count == 1, "A re-stamped last_modified must re-emit, never silently drop") - } + // MARK: - Fixtures + + /// Raw secret bytes paired with their hex string. Building the `Data` directly avoids the + /// ambiguous `Data(hexString:)` that exists in both @testable-imported modules. + private static let secretBytesA = Data([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, + ]) + private static let secretBytesB = Data([ + 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x88, + 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00, + ]) + private static let validChannelSecretA = "00112233445566778899aabbccddeeff" + private static let validChannelSecretB = "ffeeddccbbaa99887766554433221100" + private static let pubKeyHexA = String(repeating: "ab", count: 32) + private static let pubKeyHexB = String(repeating: "cd", count: 32) + + private static func emptySlots(_ count: UInt8) -> [DeviceChannelSlot] { + (0.. ConfigSections { + ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: false, + otherSettings: false, channels: true, contacts: false + ) + } + + private static func contactSections() -> ConfigSections { + ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: false, + otherSettings: false, channels: false, contacts: true + ) + } + + private static func identitySections() -> ConfigSections { + ConfigSections( + nodeIdentity: true, radioSettings: false, positionSettings: false, + otherSettings: false, channels: false, contacts: false + ) + } + + private static func contact( + type: UInt8 = 1, + name: String, + publicKey: String, + latitude: String = "0", + longitude: String = "0", + lastModified: UInt32 = 0, + outPath: String? = nil, + pathHashMode: UInt8? = nil + ) -> MeshCoreNodeConfig.ContactConfig { + MeshCoreNodeConfig.ContactConfig( + type: type, name: name, publicKey: publicKey, flags: 0, + latitude: latitude, longitude: longitude, + lastAdvert: 0, lastModified: lastModified, + outPath: outPath, pathHashMode: pathHashMode + ) + } + + private static func plan( + channels: [MeshCoreNodeConfig.ChannelConfig]? = nil, + contacts: [MeshCoreNodeConfig.ContactConfig]? = nil, + positionSettings: MeshCoreNodeConfig.PositionSettings? = nil, + radioSettings: MeshCoreNodeConfig.RadioSettings? = nil, + privateKey: String? = nil, + publicKey: String? = nil, + name: String? = nil, + sections: ConfigSections, + maxChannels: UInt8 = 8, + maxContacts: Int = 100, + maxTxPower: Int8 = 30, + existingChannels: [DeviceChannelSlot] = emptySlots(8), + existingContacts: [String: MeshContact] = [:] + ) throws -> ConfigImportPlan { + var config = MeshCoreNodeConfig() + config.channels = channels + config.contacts = contacts + config.positionSettings = positionSettings + config.radioSettings = radioSettings + config.privateKey = privateKey + config.publicKey = publicKey + config.name = name + return try planConfigImport( + config: config, sections: sections, + maxChannels: maxChannels, maxContacts: maxContacts, maxTxPower: maxTxPower, + existingChannels: existingChannels, existingContacts: existingContacts + ) + } + + /// A present-key entry for the existing-contacts map whose fields deliberately differ from the + /// capacity tests' imports, so the key counts toward capacity without triggering an M2 skip. + /// The stored `publicKey` is irrelevant to capacity accounting (which keys on the map), so a + /// placeholder avoids the cross-module `Data(hexString:)` ambiguity. + private static func presentContact(keyedAs hexKey: String) -> (String, MeshContact) { + let contact = MeshContact( + id: hexKey, + publicKey: Data(repeating: 0, count: 32), + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0xFF, + outPath: Data(), + advertisedName: "Existing", + lastAdvertisement: Date(timeIntervalSince1970: 0), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: 0) + ) + return (hexKey, contact) + } + + // MARK: - Coordinate validation + + @Test + func `Position with NaN latitude is rejected before any write`() { + let sections = ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: true, + otherSettings: false, channels: false, contacts: false + ) + #expect { + _ = try Self.plan( + positionSettings: .init(latitude: "nan", longitude: "0"), + sections: sections + ) + } throws: { error in + if case NodeConfigServiceError.invalidCoordinate(.positionLatitude) = error { return true } + return false + } + } + + @Test + func `Position with out-of-range latitude is rejected`() { + let sections = ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: true, + otherSettings: false, channels: false, contacts: false + ) + #expect { + _ = try Self.plan( + positionSettings: .init(latitude: "1000000000", longitude: "0"), + sections: sections + ) + } throws: { error in + if case NodeConfigServiceError.invalidCoordinate(.positionLatitude) = error { return true } + return false + } + } + + @Test + func `Contact with infinite longitude is rejected`() { + let contacts = [Self.contact(name: "Bad", publicKey: Self.pubKeyHexA, longitude: "inf")] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidCoordinate(.contactLongitude(name: "Bad")) = error { return true } + return false + } + } + + @Test + func `Valid position passes and is carried into the plan`() throws { + let sections = ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: true, + otherSettings: false, channels: false, contacts: false + ) + let plan = try Self.plan( + positionSettings: .init(latitude: "47.6", longitude: "-122.3"), + sections: sections + ) + #expect(plan.position?.latitude == 47.6) + #expect(plan.position?.longitude == -122.3) + } + + // MARK: - Channels + + @Test + func `Two same-name hashtag channels fold onto one slot`() throws { + let channels = [ + MeshCoreNodeConfig.ChannelConfig(name: "#rescue", secret: Self.validChannelSecretA), + MeshCoreNodeConfig.ChannelConfig(name: "#rescue", secret: Self.validChannelSecretB), + ] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelWrites.count == 2) + #expect(Set(plan.channelWrites.map(\.index)).count == 1, + "Same-name channels must not consume two slots") + } + + @Test + func `Two same-secret channels fold onto one slot`() throws { + let channels = [ + MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA), + MeshCoreNodeConfig.ChannelConfig(name: "Beta", secret: Self.validChannelSecretA), + ] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelWrites.count == 2) + #expect(Set(plan.channelWrites.map(\.index)).count == 1, + "Same-secret channels must not consume two slots") + } + + @Test + func `Two distinct channels land on two separate empty slots`() throws { + let channels = [ + MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA), + MeshCoreNodeConfig.ChannelConfig(name: "Beta", secret: Self.validChannelSecretB), + ] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelWrites.count == 2, + "Two genuinely distinct channels must not be merged into one write") + #expect(Set(plan.channelWrites.map(\.index)).count == 2, + "Distinct channels must occupy distinct slots") + } + + @Test + func `Overwriting a configured slot with a differing secret is flagged`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "#old", secret: Self.secretBytesA, isConfigured: true) + + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#old", secret: Self.validChannelSecretB)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelsOverwriteExisting == true) + #expect(plan.channelWrites.first?.index == 0) + } + + @Test + func `Adding a channel into an empty slot is not an overwrite`() throws { + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#new", secret: Self.validChannelSecretA)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelsOverwriteExisting == false) + } + + @Test + func `Slot exhaustion is rejected before any write`() { + let slots = [DeviceChannelSlot( + index: 0, name: "Existing", + secret: Self.secretBytesA, isConfigured: true + )] + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "New", secret: Self.validChannelSecretB)] + #expect { + _ = try Self.plan( + channels: channels, sections: Self.channelSections(), + maxChannels: 1, existingChannels: slots + ) + } throws: { error in + if case NodeConfigServiceError.noAvailableChannelSlot(name: "New") = error { return true } + return false + } + } + + @Test + func `Existing hashtag slot folds a same-secret non-hashtag import onto its slot`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "#rescue", secret: Self.secretBytesA, isConfigured: true) + + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelWrites.count == 1) + #expect(plan.channelWrites.first?.index == 0, + "A same-secret import must fold onto the existing hashtag slot, not consume a fresh one") + #expect(plan.channelsOverwriteExisting == true) + } + + @Test + func `Long hashtag name folds onto its existing slot despite device-side truncation`() throws { + let fullName = "#" + String(repeating: "a", count: 40) // 41 bytes, exceeds the 31-byte field + let deviceTruncated = "#" + String(repeating: "a", count: 30) // 31 bytes, what firmware stores + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: deviceTruncated, secret: Self.secretBytesA, isConfigured: true) + + let channels = [MeshCoreNodeConfig.ChannelConfig(name: fullName, secret: Self.validChannelSecretB)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelWrites.count == 1) + #expect(plan.channelWrites.first?.index == 0, + "A long hashtag name must match its truncated device slot, not consume a fresh one") + } + + @Test + func `Hashtag-name import whose secret already lives on another slot folds onto the secret's slot`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "#general", secret: Self.secretBytesA, isConfigured: true) + slots[1] = DeviceChannelSlot(index: 1, name: "Other", secret: Self.secretBytesB, isConfigured: true) + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretB)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + #expect(plan.channelWrites.count == 1) + #expect(plan.channelWrites.first?.index == 1, + "A secret already on another slot must keep the write single-homed") + #expect(plan.channelWrites.allSatisfy { $0.index != 0 }, + "The secret must not be duplicated onto the hashtag-name slot") + } + + @Test + func `Non-canonical secret hex still dedups against the canonically-keyed existing slot`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "Existing", secret: Self.secretBytesA, isConfigured: true) + + // Same secret bytes as the existing slot but written in uppercase, a non-canonical casing a + // hand-edited backup might use, paired with a changed name so the fold still produces a write + // (a byte-identical import would correctly skip). It must dedup against the device's + // lowercase-canonical key rather than consume a fresh slot. + let uppercased = Self.validChannelSecretA.uppercased() + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Renamed", secret: uppercased)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelWrites.count == 1) + #expect(plan.channelWrites.first?.index == 0, + "A non-canonical secret must dedup against the canonical existing slot, not consume a fresh one") + } + + @Test + func `Invalid channel secret length is rejected`() { + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Bad", secret: "abcd")] + #expect { + _ = try Self.plan(channels: channels, sections: Self.channelSections()) + } throws: { error in + if case NodeConfigServiceError.invalidChannelSecret = error { return true } + return false + } + } + + @Test + func `Channel secret with trailing non-hex characters is rejected, not silently accepted`() { + // Valid 32-char hex with stray non-hex characters appended. The filtering parser would + // strip "zz" and still yield 16 bytes; the strict guard must reject the whole string. + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Bad", secret: Self.validChannelSecretA + "zz")] + #expect { + _ = try Self.plan(channels: channels, sections: Self.channelSections()) + } throws: { error in + if case NodeConfigServiceError.invalidChannelSecret = error { return true } + return false + } + } + + // MARK: - Private key (malformed / wrong length) + + @Test + func `Present-but-garbage private key is rejected, not silently skipped`() { + #expect { + _ = try Self.plan(privateKey: "zzzz", sections: Self.identitySections()) + } throws: { error in + if case NodeConfigServiceError.invalidPrivateKey = error { return true } + return false + } + } + + @Test + func `Wrong-length private key is rejected`() { + #expect { + _ = try Self.plan(privateKey: "abcd", sections: Self.identitySections()) + } throws: { error in + if case NodeConfigServiceError.invalidPrivateKey = error { return true } + return false + } + } + + @Test + func `Valid 64-byte expanded private key without a public key is accepted`() async throws { + let identity = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let plan = try Self.plan( + privateKey: identity.expandedPrivateKey.hexString, + sections: Self.identitySections() + ) + #expect(plan.importPrivateKey == identity.expandedPrivateKey) + } + + /// Regression: the real export format is the 64-byte expanded key (`clamp(SHA512(seed))`) + /// alongside its public key, which export always emits. The public key is derivable from the + /// expanded scalar, but MC1's CryptoKit API works from the 32-byte seed (which the export omits), + /// so the plan accepts the pair on trust rather than re-deriving and cross-checking it. + @Test + func `Expanded private key with its public key round-trips and is accepted`() async throws { + let identity = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let plan = try Self.plan( + privateKey: identity.expandedPrivateKey.hexString, + publicKey: identity.publicKey.hexString, + sections: Self.identitySections() + ) + #expect(plan.importPrivateKey == identity.expandedPrivateKey) + } + + // MARK: - Radio validation + + private static func radioSections() -> ConfigSections { + ConfigSections( + nodeIdentity: false, radioSettings: true, positionSettings: false, + otherSettings: false, channels: false, contacts: false + ) + } + + private static func validRadio( + frequency: UInt32 = 910_525, + bandwidth: UInt32 = 62500, + spreadingFactor: UInt8 = 7, + codingRate: UInt8 = 5, + txPower: Int8 = 20 + ) -> MeshCoreNodeConfig.RadioSettings { + .init( + frequency: frequency, bandwidth: bandwidth, + spreadingFactor: spreadingFactor, codingRate: codingRate, txPower: txPower + ) + } + + @Test + func `Valid radio settings within firmware ranges are carried into the plan`() throws { + let plan = try Self.plan(radioSettings: Self.validRadio(), sections: Self.radioSections()) + #expect(plan.radioSettings == Self.validRadio()) + } + + @Test + func `Out-of-range spreading factor is rejected before any write`() { + #expect { + _ = try Self.plan(radioSettings: Self.validRadio(spreadingFactor: 99), sections: Self.radioSections()) + } throws: { error in + if case NodeConfigServiceError.invalidRadioSettings(.spreadingFactor) = error { return true } + return false + } + } + + @Test + func `Frequency below the firmware floor is rejected`() { + #expect { + _ = try Self.plan(radioSettings: Self.validRadio(frequency: 100_000), sections: Self.radioSections()) + } throws: { error in + if case NodeConfigServiceError.invalidRadioSettings(.frequency) = error { return true } + return false + } + } + + @Test + func `TX power above the device's reported maximum is rejected`() { + #expect { + _ = try Self.plan( + radioSettings: Self.validRadio(txPower: 25), + sections: Self.radioSections(), maxTxPower: 20 + ) + } throws: { error in + if case NodeConfigServiceError.invalidRadioSettings(.txPower) = error { return true } + return false + } + } + + @Test + func `TX power up to the device's reported maximum is accepted`() throws { + let plan = try Self.plan( + radioSettings: Self.validRadio(txPower: 30), + sections: Self.radioSections(), maxTxPower: 30 + ) + #expect(plan.radioSettings?.txPower == 30) + } + + // MARK: - Contacts (dedup, capacity, out_path, unknown type) + + @Test + func `Duplicate contacts dedup by public key, newest last_modified wins`() throws { + let contacts = [ + Self.contact(name: "Older", publicKey: Self.pubKeyHexA, lastModified: 100), + Self.contact(name: "Newer", publicKey: Self.pubKeyHexA, lastModified: 200), + ] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) + #expect(plan.contactRecords.count == 1) + #expect(plan.contactRecords.first?.advertisedName == "Newer") + } + + @Test + func `Dedup keeps the newer record even when it appears first (inverse comparison branch)`() throws { + let contacts = [ + Self.contact(name: "Newer", publicKey: Self.pubKeyHexA, lastModified: 200), + Self.contact(name: "Older", publicKey: Self.pubKeyHexA, lastModified: 100), + ] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) + #expect(plan.contactRecords.count == 1) + #expect(plan.contactRecords.first?.advertisedName == "Newer", + "The older record must not overwrite the already-seen newer one") + } + + @Test + func `Dedup of equal-timestamp duplicates collapses to one record deterministically`() throws { + let contacts = [ + Self.contact(name: "First", publicKey: Self.pubKeyHexA, lastModified: 100), + Self.contact(name: "Second", publicKey: Self.pubKeyHexA, lastModified: 100), + ] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) + #expect(plan.contactRecords.count == 1) + // The `>=` comparison adopts a same-timestamp later entry, so the last one read wins. + #expect(plan.contactRecords.first?.advertisedName == "Second") + } + + @Test + func `Contact with a valid out_path but out-of-range path hash mode is rejected`() { + let contacts = [Self.contact( + name: "BadMode", publicKey: Self.pubKeyHexA, outPath: "aabb", pathHashMode: 3 + )] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidPathHashMode(name: "BadMode", mode: 3) = error { return true } + return false + } + } + + @Test + func `Exceeding device contact capacity is rejected`() { + let contacts = [ + Self.contact(name: "A", publicKey: Self.pubKeyHexA), + Self.contact(name: "B", publicKey: Self.pubKeyHexB), + ] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections(), maxContacts: 1) + } throws: { error in + if case NodeConfigServiceError.contactCapacityExceeded = error { return true } + return false + } + } + + @Test + func `Exactly filling the remaining contact slots is accepted`() throws { + let contacts = [ + Self.contact(name: "A", publicKey: Self.pubKeyHexA), + Self.contact(name: "B", publicKey: Self.pubKeyHexB), + ] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections(), maxContacts: 2) + #expect(plan.contactRecords.count == 2) + } + + @Test + func `Capacity check credits keys already on the device (updates consume no slot)`() throws { + // Device table is full (maxContacts 1, one existing key), but the import only updates that key. + let plan = try Self.plan( + contacts: [Self.contact(name: "Update", publicKey: Self.pubKeyHexA)], + sections: Self.contactSections(), + maxContacts: 1, + existingContacts: Dictionary(uniqueKeysWithValues: [Self.presentContact(keyedAs: Self.pubKeyHexA.lowercased())]) + ) + #expect(plan.contactRecords.count == 1) + } + + @Test + func `A new contact has no free slot once the device table is full`() { + #expect { + _ = try Self.plan( + contacts: [Self.contact(name: "New", publicKey: Self.pubKeyHexB)], + sections: Self.contactSections(), + maxContacts: 1, + existingContacts: Dictionary(uniqueKeysWithValues: [Self.presentContact(keyedAs: Self.pubKeyHexA.lowercased())]) + ) + } throws: { error in + if case NodeConfigServiceError.contactCapacityExceeded = error { return true } + return false + } + } + + @Test + func `Invalid out_path hex is rejected instead of silently downgrading to direct`() { + let contacts = [Self.contact(name: "BadPath", publicKey: Self.pubKeyHexA, outPath: "zzz")] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidOutPath(name: "BadPath") = error { return true } + return false + } + } + + @Test + func `Out_path longer than the firmware buffer is rejected`() { + // 66 bytes of 3-byte hashes = 22 hops: within the 6-bit hop field but past MAX_PATH_SIZE, + // so only the total-length guard catches it, not the hop-count guard. + let longPath = String(repeating: "ab", count: PathEncoding.maxPathBytes / 3 * 3 + 3) + let contacts = [Self.contact( + name: "TooLong", publicKey: Self.pubKeyHexA, outPath: longPath, pathHashMode: 2 + )] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidOutPath(name: "TooLong") = error { return true } + return false + } + } + + @Test + func `Invalid contact public key is rejected`() { + let contacts = [Self.contact(name: "BadKey", publicKey: "abcd")] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidContactPublicKey(name: "BadKey") = error { return true } + return false + } + } + + @Test + func `Unknown contact type byte is preserved verbatim while UI type falls back to chat`() throws { + let contacts = [Self.contact(type: 99, name: "FutureType", publicKey: Self.pubKeyHexA)] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) + let record = try #require(plan.contactRecords.first) + #expect(record.typeRawValue == 99) + #expect(record.type == .chat) + } + + @Test + func `Absent out_path plans flood routing; empty string plans direct`() throws { + let flood = try Self.plan( + contacts: [Self.contact(name: "Flood", publicKey: Self.pubKeyHexA, outPath: nil)], + sections: Self.contactSections() + ) + #expect(flood.contactRecords.first?.outPathLength == 0xFF) + + let direct = try Self.plan( + contacts: [Self.contact(name: "Direct", publicKey: Self.pubKeyHexB, outPath: "")], + sections: Self.contactSections() + ) + #expect(direct.contactRecords.first?.outPathLength == 0) + } + + @Test + func `Valid out_path resolves to its bytes and encoded length for each hash mode`() throws { + // Expected encoded length is a pinned literal (mode << 6 | hopCount), not a re-derivation of + // the planner's own encodePathLen, so a bug in that encoder cannot be mirrored into the value. + let cases: [(mode: UInt8, hex: String, bytes: [UInt8], length: UInt8)] = [ + (0, "aabb", [0xAA, 0xBB], 0x02), + (1, "aabbccdd", [0xAA, 0xBB, 0xCC, 0xDD], 0x42), + (2, "aabbccddeeff", [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF], 0x82), + ] + for testCase in cases { + let plan = try Self.plan( + contacts: [Self.contact( + name: "Routed", publicKey: Self.pubKeyHexA, + outPath: testCase.hex, pathHashMode: testCase.mode + )], + sections: Self.contactSections() + ) + let record = try #require(plan.contactRecords.first) + #expect(record.outPath == Data(testCase.bytes)) + #expect(record.outPathLength == testCase.length) + } + } + + // MARK: - Empty-but-present sections / identity carry-through + + @Test + func `Empty-but-present channel and contact arrays plan no writes and no overwrite`() throws { + let sections = ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: false, + otherSettings: false, channels: true, contacts: true + ) + let plan = try Self.plan(channels: [], contacts: [], sections: sections) + #expect(plan.channelWrites.isEmpty) + #expect(plan.contactRecords.isEmpty) + #expect(plan.channelsOverwriteExisting == false) + } + + @Test + func `Identity plan carries a non-nil node name verbatim`() throws { + let plan = try Self.plan(name: "Rescue Base", sections: Self.identitySections()) + #expect(plan.nodeName == "Rescue Base") + } + + // MARK: - M1: byte-identical channel slot skip + + @Test + func `A channel byte-identical to its resolved slot plans no write`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) + + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelWrites.isEmpty, "An identical slot must not re-commit /channels2") + #expect(plan.channelsOverwriteExisting == false) + } + + @Test + func `A name-only diff, secret-only diff, and secret relocation each still plan one write`() throws { + var nameDiff = Self.emptySlots(8) + nameDiff[0] = DeviceChannelSlot(index: 0, name: "Old", secret: Self.secretBytesA, isConfigured: true) + let nameDiffPlan = try Self.plan( + channels: [MeshCoreNodeConfig.ChannelConfig(name: "New", secret: Self.validChannelSecretA)], + sections: Self.channelSections(), existingChannels: nameDiff + ) + #expect(nameDiffPlan.channelWrites.count == 1, "A name change must still write") + + var secretDiff = Self.emptySlots(8) + secretDiff[0] = DeviceChannelSlot(index: 0, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) + let secretDiffPlan = try Self.plan( + channels: [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretB)], + sections: Self.channelSections(), existingChannels: secretDiff + ) + #expect(secretDiffPlan.channelWrites.count == 1, "A secret change must still write") + + // Secret A is homed at slot 2 under a different name: the import folds onto that slot and + // writes because the name differs, so the skip must not swallow it. + var relocate = Self.emptySlots(8) + relocate[2] = DeviceChannelSlot(index: 2, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) + let relocatePlan = try Self.plan( + channels: [MeshCoreNodeConfig.ChannelConfig(name: "Renamed", secret: Self.validChannelSecretA)], + sections: Self.channelSections(), existingChannels: relocate + ) + #expect(relocatePlan.channelWrites.count == 1, "A name change on the secret's existing slot still writes") + #expect(relocatePlan.channelWrites.first?.index == 2, "It folds onto the secret's existing slot") + } + + @Test + func `A brand-new channel into an empty slot still plans one write`() throws { + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#new", secret: Self.validChannelSecretA)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelWrites.count == 1) + } + + @Test + func `A duplicate restoring a slot an earlier write changed is not swallowed by the skip`() throws { + // Same #hashtag homed at slot 0: the first entry changes the secret, the second restores + // the device's original secret. The skip must compare against the slot's planned value, not + // the frozen original, so the restoring write survives and wins last (slot ends at S1). + var hashtagSlots = Self.emptySlots(8) + hashtagSlots[0] = DeviceChannelSlot(index: 0, name: "#general", secret: Self.secretBytesA, isConfigured: true) + let hashtagPlan = try Self.plan( + channels: [ + MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretB), + MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretA), + ], + sections: Self.channelSections(), existingChannels: hashtagSlots + ) + #expect(hashtagPlan.channelWrites.count == 2, "Both folded writes are planned, last wins") + #expect(hashtagPlan.channelWrites.last?.secret == Self.secretBytesA, "The restoring write must win, not be dropped") + + // Same-secret name fold: the first entry renames the slot, the second restores the device's + // original name. The restoring write must survive so the slot ends at "Foo", not "Bar". + var secretSlots = Self.emptySlots(8) + secretSlots[0] = DeviceChannelSlot(index: 0, name: "Foo", secret: Self.secretBytesA, isConfigured: true) + let secretPlan = try Self.plan( + channels: [ + MeshCoreNodeConfig.ChannelConfig(name: "Bar", secret: Self.validChannelSecretA), + MeshCoreNodeConfig.ChannelConfig(name: "Foo", secret: Self.validChannelSecretA), + ], + sections: Self.channelSections(), existingChannels: secretSlots + ) + #expect(secretPlan.channelWrites.count == 2, "Both folded writes are planned, last wins") + #expect(secretPlan.channelWrites.last?.name == "Foo", "The restoring write must win, not be dropped") + } + + // MARK: - M2: byte-identical contact skip + + /// Models the device-resident form of a contact `session.getContacts` would report: the planner's + /// record encoded to the wire and decoded back through the same `parseContactData` the live read + /// uses, so the fixture reflects the device's stored bytes (name trimmed to the field width, coords + /// scaled, path sliced) rather than the planner's own `buildContactRecord` output. `lastModified` + /// models the firmware restamp the sub-148-byte add frame triggers: it defaults to the record's own + /// value (the same-device export/re-import round-trip the skip targets), or pass a different value to + /// model a foreign config the firmware re-stamped. + private func deviceStored(_ record: MeshContact, lastModified deviceLastModified: Date? = nil) -> MeshContact { + // The add frame omits last_modified (3 reserved bytes); the contact-response frame carries it at + // offset 143. Reuse the add encoder for offsets 0..<143, then append the device's last_modified. + var frame = Data(PacketBuilder.updateContact(record).dropFirst(1).prefix(143)) + frame.appendLittleEndian(UInt32((deviceLastModified ?? record.lastModified).timeIntervalSince1970)) + return Parsers.parseContactData(frame)! + } + + /// Runs the import once against an empty device to capture the record the planner builds, then + /// returns the device-resident form `getContacts` would report for it. + private func recordFor(_ contact: MeshCoreNodeConfig.ContactConfig) throws -> MeshContact { + let plan = try Self.plan(contacts: [contact], sections: Self.contactSections()) + return try deviceStored(#require(plan.contactRecords.first)) + } + + @Test + func `A contact equal on all persisted fields is dropped`() throws { + let config = Self.contact( + name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", + lastModified: 1000, outPath: "aabb", pathHashMode: 0 + ) + let existing = try recordFor(config) + + let plan = try Self.plan( + contacts: [config], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.isEmpty, "A byte-identical contact must not re-commit /contacts3") + } + + @Test + func `A diff in any single persisted field still emits the contact`() throws { + let base = Self.contact( + name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", + lastModified: 1000, outPath: "aabb", pathHashMode: 0 + ) + let existing = try recordFor(base) + + let variants: [(label: String, config: MeshCoreNodeConfig.ContactConfig)] = [ + ("type", Self.contact(type: 2, name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), + ("name", Self.contact(name: "Charlie", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), + ("path", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "ccdd", pathHashMode: 0)), + ("coords", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "48.0", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), + ("lastModified", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 2000, outPath: "aabb", pathHashMode: 0)), + ] + for variant in variants { + let plan = try Self.plan( + contacts: [variant.config], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.count == 1, "A \(variant.label) diff must still write the contact") + } + } + + @Test + func `A name differing only past the firmware field width is treated as equal and dropped`() throws { + let shortConfig = Self.contact( + name: "#" + String(repeating: "a", count: 30), publicKey: Self.pubKeyHexA + ) + let existing = try recordFor(shortConfig) // 31-byte name, what the device stores + + // Import the same contact whose name only diverges past the 31-byte field width. + let longConfig = Self.contact( + name: "#" + String(repeating: "a", count: 30) + "EXTRA", publicKey: Self.pubKeyHexA + ) + let plan = try Self.plan( + contacts: [longConfig], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.isEmpty, "A name the device cannot represent differently must be treated as equal") + } + + @Test + func `A new contact is unaffected by the skip and capacity stays correct`() throws { + // One existing key matches its import (dropped); a second key is new (emitted). + let matchConfig = Self.contact(name: "Match", publicKey: Self.pubKeyHexA, lastModified: 5) + let existing = try recordFor(matchConfig) + let newConfig = Self.contact(name: "Fresh", publicKey: Self.pubKeyHexB, lastModified: 5) + + let plan = try Self.plan( + contacts: [matchConfig, newConfig], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.count == 1, "Only the unchanged contact is dropped") + #expect(plan.contactRecords.first?.advertisedName == "Fresh") + } + + @Test + func `A contact the firmware re-stamped is re-emitted, not dropped (skip is safe-fail)`() throws { + // The sub-148-byte contact-add frame carries no last_modified, so firmware stamps its own clock. + // For a config exported from a different device, the device-read last_modified therefore differs + // from the config's, and the skip must re-emit rather than drop: the optimization only fires for + // a same-device export/re-import round-trip, never for a foreign config the firmware re-stamped. + let config = Self.contact( + name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", + lastModified: 1000, outPath: "aabb", pathHashMode: 0 + ) + let record = try #require( + try Self.plan(contacts: [config], sections: Self.contactSections()).contactRecords.first + ) + let existing = deviceStored(record, lastModified: Date(timeIntervalSince1970: 9999)) + + let plan = try Self.plan( + contacts: [config], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.count == 1, "A re-stamped last_modified must re-emit, never silently drop") + } } // MARK: - Encoder no-trap coverage @Suite("PacketBuilder coordinate/timestamp encoders do not trap") struct PacketBuilderEncoderTests { - - private func int32LE(_ data: Data, at offset: Int) -> Int32 { - var value: UInt32 = 0 - for byte in 0..<4 { - value |= UInt32(data[data.startIndex + offset + byte]) << (8 * byte) - } - return Int32(bitPattern: value) - } - - private func uint32LE(_ data: Data, at offset: Int) -> UInt32 { - var value: UInt32 = 0 - for byte in 0..<4 { - value |= UInt32(data[data.startIndex + offset + byte]) << (8 * byte) - } - return value - } - - @Test("setCoordinates clamps NaN to zero instead of trapping") - func setCoordinatesNaN() { - let data = PacketBuilder.setCoordinates(latitude: .nan, longitude: .nan) - #expect(int32LE(data, at: 1) == 0) - #expect(int32LE(data, at: 5) == 0) - } - - @Test("setCoordinates clamps out-of-range degrees to the valid bounds") - func setCoordinatesOutOfRange() { - let data = PacketBuilder.setCoordinates(latitude: 9999, longitude: -9999) - // 90 * 1_000_000 and -180 * 1_000_000 - #expect(int32LE(data, at: 1) == 90_000_000) - #expect(int32LE(data, at: 5) == -180_000_000) - } - - @Test("setCoordinates handles infinity without trapping") - func setCoordinatesInfinity() { - let data = PacketBuilder.setCoordinates(latitude: .infinity, longitude: -.infinity) - #expect(int32LE(data, at: 1) == 0) - #expect(int32LE(data, at: 5) == 0) - } - - @Test("updateContact saturates a pre-1970 advertisement timestamp to zero") - func updateContactNegativeTimestamp() { - let contact = MeshContact( - id: "neg", publicKey: Data(repeating: 1, count: 32), - type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "Old", - lastAdvertisement: Date(timeIntervalSince1970: -1_000_000), - latitude: 0, longitude: 0, lastModified: .now - ) - let data = PacketBuilder.updateContact(contact) - #expect(uint32LE(data, at: 132) == 0) - } - - @Test("updateContact saturates a post-2106 advertisement timestamp to UInt32.max") - func updateContactOverflowTimestamp() { - let farFuture = Date(timeIntervalSince1970: TimeInterval(UInt32.max) + 1_000_000) - let contact = MeshContact( - id: "future", publicKey: Data(repeating: 1, count: 32), - type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "Future", - lastAdvertisement: farFuture, - latitude: 0, longitude: 0, lastModified: .now - ) - let data = PacketBuilder.updateContact(contact) - #expect(uint32LE(data, at: 132) == UInt32.max) - } - - @Test("updateContact clamps out-of-range coordinates without trapping") - func updateContactCoordinateClamp() { - let contact = MeshContact( - id: "coord", publicKey: Data(repeating: 1, count: 32), - type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "Coord", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: .nan, longitude: 9999, lastModified: .now - ) - let data = PacketBuilder.updateContact(contact) - #expect(int32LE(data, at: 136) == 0) - #expect(int32LE(data, at: 140) == 180_000_000) - } - - /// `addContact` now encodes via `PacketBuilder.updateContact`. Pin the frame length so a - /// silent layout drift can't change the wire size firmware validates against. - @Test("updateContact emits a 147-byte frame") - func updateContactFrameLength() { - let contact = MeshContact( - id: "len", publicKey: Data(repeating: 1, count: 32), - type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "Len", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: 0, longitude: 0, lastModified: .now - ) - #expect(PacketBuilder.updateContact(contact).count == 147) - } - - /// Modeled types must encode their raw byte at the type offset unchanged, and an unmodeled - /// byte must pass through verbatim — the type byte sits at packet offset 33 (cmd + 32-byte key). - @Test("updateContact writes the raw type byte verbatim for modeled and unmodeled types") - func updateContactPreservesRawTypeByte() { - for raw: UInt8 in [0x01, 0x02, 0x03, 0x04, 0x99] { - let contact = MeshContact( - id: "type", publicKey: Data(repeating: 1, count: 32), - type: ContactType(rawValue: raw) ?? .chat, typeRawValue: raw, - flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "T", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: 0, longitude: 0, lastModified: .now - ) - let data = PacketBuilder.updateContact(contact) - #expect(data[data.startIndex + 33] == raw) - } - } + private func int32LE(_ data: Data, at offset: Int) -> Int32 { + var value: UInt32 = 0 + for byte in 0..<4 { + value |= UInt32(data[data.startIndex + offset + byte]) << (8 * byte) + } + return Int32(bitPattern: value) + } + + private func uint32LE(_ data: Data, at offset: Int) -> UInt32 { + var value: UInt32 = 0 + for byte in 0..<4 { + value |= UInt32(data[data.startIndex + offset + byte]) << (8 * byte) + } + return value + } + + @Test + func `setCoordinates clamps NaN to zero instead of trapping`() { + let data = PacketBuilder.setCoordinates(latitude: .nan, longitude: .nan) + #expect(int32LE(data, at: 1) == 0) + #expect(int32LE(data, at: 5) == 0) + } + + @Test + func `setCoordinates clamps out-of-range degrees to the valid bounds`() { + let data = PacketBuilder.setCoordinates(latitude: 9999, longitude: -9999) + // 90 * 1_000_000 and -180 * 1_000_000 + #expect(int32LE(data, at: 1) == 90_000_000) + #expect(int32LE(data, at: 5) == -180_000_000) + } + + @Test + func `setCoordinates handles infinity without trapping`() { + let data = PacketBuilder.setCoordinates(latitude: .infinity, longitude: -.infinity) + #expect(int32LE(data, at: 1) == 0) + #expect(int32LE(data, at: 5) == 0) + } + + @Test + func `updateContact saturates a pre-1970 advertisement timestamp to zero`() { + let contact = MeshContact( + id: "neg", publicKey: Data(repeating: 1, count: 32), + type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "Old", + lastAdvertisement: Date(timeIntervalSince1970: -1_000_000), + latitude: 0, longitude: 0, lastModified: .now + ) + let data = PacketBuilder.updateContact(contact) + #expect(uint32LE(data, at: 132) == 0) + } + + @Test + func `updateContact saturates a post-2106 advertisement timestamp to UInt32.max`() { + let farFuture = Date(timeIntervalSince1970: TimeInterval(UInt32.max) + 1_000_000) + let contact = MeshContact( + id: "future", publicKey: Data(repeating: 1, count: 32), + type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "Future", + lastAdvertisement: farFuture, + latitude: 0, longitude: 0, lastModified: .now + ) + let data = PacketBuilder.updateContact(contact) + #expect(uint32LE(data, at: 132) == UInt32.max) + } + + @Test + func `updateContact clamps out-of-range coordinates without trapping`() { + let contact = MeshContact( + id: "coord", publicKey: Data(repeating: 1, count: 32), + type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "Coord", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: .nan, longitude: 9999, lastModified: .now + ) + let data = PacketBuilder.updateContact(contact) + #expect(int32LE(data, at: 136) == 0) + #expect(int32LE(data, at: 140) == 180_000_000) + } + + /// `addContact` now encodes via `PacketBuilder.updateContact`. Pin the frame length so a + /// silent layout drift can't change the wire size firmware validates against. + @Test + func `updateContact emits a 147-byte frame`() { + let contact = MeshContact( + id: "len", publicKey: Data(repeating: 1, count: 32), + type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "Len", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 0, longitude: 0, lastModified: .now + ) + #expect(PacketBuilder.updateContact(contact).count == 147) + } + + /// Modeled types must encode their raw byte at the type offset unchanged, and an unmodeled + /// byte must pass through verbatim — the type byte sits at packet offset 33 (cmd + 32-byte key). + @Test + func `updateContact writes the raw type byte verbatim for modeled and unmodeled types`() { + for raw: UInt8 in [0x01, 0x02, 0x03, 0x04, 0x99] { + let contact = MeshContact( + id: "type", publicKey: Data(repeating: 1, count: 32), + type: ContactType(rawValue: raw) ?? .chat, typeRawValue: raw, + flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "T", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 0, longitude: 0, lastModified: .now + ) + let data = PacketBuilder.updateContact(contact) + #expect(data[data.startIndex + 33] == raw) + } + } } // MARK: - Contact type-byte preservation @@ -974,68 +988,69 @@ struct PacketBuilderEncoderTests { /// coerced onto a modeled `ContactType` at any hop. @Suite("Contact type-byte round-trips through decode, cache, and export") struct ContactTypeBytePreservationTests { - - /// Builds a 147-byte contact structure (no command byte) as `parseContactData` consumes it. - private func contactBytes(typeByte: UInt8) -> Data { - var data = Data(repeating: 0, count: 147) - for i in 0..<32 { data[i] = 0xAB } // public key - data[32] = typeByte // type - data[34] = 0xFF // path length = flood - return data - } - - @Test("parseContactData preserves an unmodeled type byte while falling back to .chat") - func decodePreservesUnmodeledByte() throws { - let contact = try #require(Parsers.parseContactData(contactBytes(typeByte: 0x04))) - #expect(contact.typeRawValue == 0x04) - #expect(contact.type == .chat) - } - - @Test("parseContactData keeps modeled type bytes and their enum in sync") - func decodePreservesModeledBytes() throws { - for (raw, expected): (UInt8, ContactType) in [(0x01, .chat), (0x02, .repeater), (0x03, .room)] { - let contact = try #require(Parsers.parseContactData(contactBytes(typeByte: raw))) - #expect(contact.typeRawValue == raw) - #expect(contact.type == expected) - } - } - - @Test("ContactFrame ↔ MeshContact ↔ Contact/ContactDTO and export all keep 0x04") - func roundTripPreservesUnmodeledByte() throws { - let radioID = UUID() - let frame = ContactFrame( - publicKey: Data(repeating: 0xAB, count: 32), - type: .chat, typeRawValue: 0x04, flags: 0, - outPathLength: 0xFF, outPath: Data(), name: "FutureType", - lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0 - ) - - // Frame → MeshContact → Frame - let meshContact = frame.toMeshContact() - #expect(meshContact.typeRawValue == 0x04) - #expect(meshContact.toContactFrame().typeRawValue == 0x04) - - // Frame → Contact @Model → Frame, and through the DTO - let contact = Contact(radioID: radioID, from: frame) - #expect(contact.typeRawValue == 0x04) - #expect(contact.toContactFrame().typeRawValue == 0x04) - #expect(ContactDTO(from: contact).toContactFrame().typeRawValue == 0x04) - - // Export re-emits the raw byte - #expect(NodeConfigService.buildContactConfig(from: meshContact).type == 0x04) - } - - @Test("Export and decode stay byte-identical for modeled types") - func exportPreservesModeledBytes() { - for raw: UInt8 in [0x01, 0x02, 0x03] { - let meshContact = MeshContact( - id: "m", publicKey: Data(repeating: 0xAB, count: 32), - type: ContactType(rawValue: raw)!, typeRawValue: raw, - flags: [], outPathLength: 0xFF, outPath: Data(), advertisedName: "M", - lastAdvertisement: Date(timeIntervalSince1970: 0), - latitude: 0, longitude: 0, lastModified: Date(timeIntervalSince1970: 0) - ) - #expect(NodeConfigService.buildContactConfig(from: meshContact).type == raw) - } - } + /// Builds a 147-byte contact structure (no command byte) as `parseContactData` consumes it. + private func contactBytes(typeByte: UInt8) -> Data { + var data = Data(repeating: 0, count: 147) + for i in 0..<32 { + data[i] = 0xAB + } // public key + data[32] = typeByte // type + data[34] = 0xFF // path length = flood + return data + } + + @Test + func `parseContactData preserves an unmodeled type byte while falling back to .chat`() throws { + let contact = try #require(Parsers.parseContactData(contactBytes(typeByte: 0x04))) + #expect(contact.typeRawValue == 0x04) + #expect(contact.type == .chat) + } + + @Test + func `parseContactData keeps modeled type bytes and their enum in sync`() throws { + for (raw, expected): (UInt8, ContactType) in [(0x01, .chat), (0x02, .repeater), (0x03, .room)] { + let contact = try #require(Parsers.parseContactData(contactBytes(typeByte: raw))) + #expect(contact.typeRawValue == raw) + #expect(contact.type == expected) + } + } + + @Test + func `ContactFrame ↔ MeshContact ↔ Contact/ContactDTO and export all keep 0x04`() { + let radioID = UUID() + let frame = ContactFrame( + publicKey: Data(repeating: 0xAB, count: 32), + type: .chat, typeRawValue: 0x04, flags: 0, + outPathLength: 0xFF, outPath: Data(), name: "FutureType", + lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0 + ) + + // Frame → MeshContact → Frame + let meshContact = frame.toMeshContact() + #expect(meshContact.typeRawValue == 0x04) + #expect(meshContact.toContactFrame().typeRawValue == 0x04) + + // Frame → Contact @Model → Frame, and through the DTO + let contact = Contact(radioID: radioID, from: frame) + #expect(contact.typeRawValue == 0x04) + #expect(contact.toContactFrame().typeRawValue == 0x04) + #expect(ContactDTO(from: contact).toContactFrame().typeRawValue == 0x04) + + // Export re-emits the raw byte + #expect(NodeConfigService.buildContactConfig(from: meshContact).type == 0x04) + } + + @Test + func `Export and decode stay byte-identical for modeled types`() throws { + for raw: UInt8 in [0x01, 0x02, 0x03] { + let meshContact = try MeshContact( + id: "m", publicKey: Data(repeating: 0xAB, count: 32), + type: #require(ContactType(rawValue: raw)), typeRawValue: raw, + flags: [], outPathLength: 0xFF, outPath: Data(), advertisedName: "M", + lastAdvertisement: Date(timeIntervalSince1970: 0), + latitude: 0, longitude: 0, lastModified: Date(timeIntervalSince1970: 0) + ) + #expect(NodeConfigService.buildContactConfig(from: meshContact).type == raw) + } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigServiceTests.swift index 31fc1867..54015f8e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigServiceTests.swift @@ -1,919 +1,908 @@ import Foundation -import OSLog -import Testing @testable import MC1Services @testable import MeshCore +import OSLog +import Testing @Suite("NodeConfigService Tests") struct NodeConfigServiceTests { - - // MARK: - Test Data - - private static let testSelfInfo = SelfInfo( - advertisementType: 1, - txPower: 22, - maxTxPower: 30, - publicKey: Data(repeating: 0xAB, count: 32), - latitude: 47.6062, - longitude: -122.3321, - multiAcks: 2, - advertisementLocationPolicy: 1, - telemetryModeEnvironment: 3, - telemetryModeLocation: 2, - telemetryModeBase: 1, - manualAddContacts: false, - radioFrequency: 910.525, - radioBandwidth: 62.5, - radioSpreadingFactor: 7, - radioCodingRate: 5, - name: "TestNode" + // MARK: - Test Data + + private static let testSelfInfo = SelfInfo( + advertisementType: 1, + txPower: 22, + maxTxPower: 30, + publicKey: Data(repeating: 0xAB, count: 32), + latitude: 47.6062, + longitude: -122.3321, + multiAcks: 2, + advertisementLocationPolicy: 1, + telemetryModeEnvironment: 3, + telemetryModeLocation: 2, + telemetryModeBase: 1, + manualAddContacts: false, + radioFrequency: 910.525, + radioBandwidth: 62.5, + radioSpreadingFactor: 7, + radioCodingRate: 5, + name: "TestNode" + ) + + private static let testContact = MeshContact( + id: Data(repeating: 0x01, count: 32).hexString, + publicKey: Data(repeating: 0x01, count: 32), + type: .chat, + flags: ContactFlags(rawValue: 0x02), + outPathLength: 3, + outPath: Data([0xAA, 0xBB, 0xCC]), + advertisedName: "RemoteNode", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 47.43, + longitude: -120.36, + lastModified: Date(timeIntervalSince1970: 1_700_000_100) + ) + + private static let floodContact = MeshContact( + id: Data(repeating: 0x02, count: 32).hexString, + publicKey: Data(repeating: 0x02, count: 32), + type: .repeater, + flags: [], + outPathLength: 0xFF, + outPath: Data(), + advertisedName: "FloodNode", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_001_000), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: 1_700_001_100) + ) + + private static let zeroPathContact = MeshContact( + id: Data(repeating: 0x03, count: 32).hexString, + publicKey: Data(repeating: 0x03, count: 32), + type: .chat, + flags: [], + outPathLength: 0, + outPath: Data(), + advertisedName: "DirectNode", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_002_000), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: 1_700_002_100) + ) + + // MARK: - buildRadioSettings + + @Test + func `buildRadioSettings converts MHz frequency to kHz`() { + let radio = NodeConfigService.buildRadioSettings(from: Self.testSelfInfo) + + // 910.525 MHz → 910525 kHz + #expect(radio.frequency == 910_525) + } + + @Test + func `buildRadioSettings converts kHz bandwidth to Hz`() { + let radio = NodeConfigService.buildRadioSettings(from: Self.testSelfInfo) + + // 62.5 kHz → 62500 Hz + #expect(radio.bandwidth == 62500) + } + + @Test + func `buildRadioSettings copies spreading factor, coding rate, and tx power`() { + let radio = NodeConfigService.buildRadioSettings(from: Self.testSelfInfo) + + #expect(radio.spreadingFactor == 7) + #expect(radio.codingRate == 5) + #expect(radio.txPower == 22) + } + + @Test + func `buildRadioSettings rounds frequency to the nearest kHz`() { + let info = SelfInfo( + advertisementType: 1, + txPower: 22, + maxTxPower: 30, + publicKey: Data(repeating: 0xAB, count: 32), + latitude: 0, + longitude: 0, + multiAcks: 0, + advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 0, + manualAddContacts: false, + radioFrequency: 512.002, + radioBandwidth: 62.5, + radioSpreadingFactor: 7, + radioCodingRate: 5, + name: "TestNode" ) - - private static let testContact = MeshContact( - id: Data(repeating: 0x01, count: 32).hexString, - publicKey: Data(repeating: 0x01, count: 32), - type: .chat, - flags: ContactFlags(rawValue: 0x02), - outPathLength: 3, - outPath: Data([0xAA, 0xBB, 0xCC]), - advertisedName: "RemoteNode", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: 47.43, - longitude: -120.36, - lastModified: Date(timeIntervalSince1970: 1_700_000_100) + let radio = NodeConfigService.buildRadioSettings(from: info) + + // Truncation would yield 512001; rounding restores the representable 512002. + #expect(radio.frequency == 512_002) + } + + // MARK: - buildOtherSettings + + @Test + func `buildOtherSettings maps manualAddContacts=false to 0`() { + let other = NodeConfigService.buildOtherSettings(from: Self.testSelfInfo) + #expect(other.manualAddContacts == 0) + } + + @Test + func `buildOtherSettings maps manualAddContacts=true to 1`() { + let info = SelfInfo( + advertisementType: 0, txPower: 10, maxTxPower: 30, + publicKey: Data(repeating: 0, count: 32), + latitude: 0, longitude: 0, multiAcks: 0, + advertisementLocationPolicy: 0, telemetryModeEnvironment: 0, + telemetryModeLocation: 0, telemetryModeBase: 0, + manualAddContacts: true, + radioFrequency: 910.525, radioBandwidth: 62.5, + radioSpreadingFactor: 7, radioCodingRate: 5, name: "Test" ) - private static let floodContact = MeshContact( - id: Data(repeating: 0x02, count: 32).hexString, - publicKey: Data(repeating: 0x02, count: 32), - type: .repeater, - flags: [], - outPathLength: 0xFF, - outPath: Data(), - advertisedName: "FloodNode", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_001_000), - latitude: 0, - longitude: 0, - lastModified: Date(timeIntervalSince1970: 1_700_001_100) + let other = NodeConfigService.buildOtherSettings(from: info) + #expect(other.manualAddContacts == 1) + } + + @Test + func `buildOtherSettings exports only 2 companion-app fields`() { + let other = NodeConfigService.buildOtherSettings(from: Self.testSelfInfo) + + #expect(other.manualAddContacts == 0) + #expect(other.advertLocationPolicy == 1) + #expect(other.telemetryModeBase == nil) + #expect(other.telemetryModeLocation == nil) + #expect(other.telemetryModeEnvironment == nil) + #expect(other.multiAcks == nil) + #expect(other.advertisementType == nil) + } + + // MARK: - buildContactConfig + + @Test + func `buildContactConfig populates all fields from MeshContact`() { + let config = NodeConfigService.buildContactConfig(from: Self.testContact) + + #expect(config.type == 1) + #expect(config.name == "RemoteNode") + #expect(config.publicKey == Data(repeating: 0x01, count: 32).hexString) + #expect(config.flags == 0x02) + #expect(config.latitude == "47.43") + #expect(config.longitude == "-120.36") + #expect(config.lastAdvert == 1_700_000_000) + #expect(config.lastModified == 1_700_000_100) + } + + @Test + func `buildContactConfig includes hex outPath for routed contacts`() { + let config = NodeConfigService.buildContactConfig(from: Self.testContact) + #expect(config.outPath == "aabbcc") + } + + @Test + func `buildContactConfig uses nil outPath for flood routing`() { + let config = NodeConfigService.buildContactConfig(from: Self.floodContact) + #expect(config.outPath == nil) + } + + @Test + func `buildContactConfig uses empty string outPath for direct (zero-length) path`() { + let config = NodeConfigService.buildContactConfig(from: Self.zeroPathContact) + #expect(config.outPath == "") + } + + @Test + func `buildContactConfig truncates outPath to outPathLength bytes`() { + // Contact with outPathLength=2 but outPath has 4 bytes + let contact = MeshContact( + id: "test", + publicKey: Data(repeating: 0x04, count: 32), + type: .chat, flags: [], outPathLength: 2, + outPath: Data([0xAA, 0xBB, 0xCC, 0xDD]), + advertisedName: "Truncated", + lastAdvertisement: .now, latitude: 0, longitude: 0, + lastModified: .now ) - private static let zeroPathContact = MeshContact( - id: Data(repeating: 0x03, count: 32).hexString, - publicKey: Data(repeating: 0x03, count: 32), - type: .chat, - flags: [], - outPathLength: 0, - outPath: Data(), - advertisedName: "DirectNode", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_002_000), - latitude: 0, - longitude: 0, - lastModified: Date(timeIntervalSince1970: 1_700_002_100) + let config = NodeConfigService.buildContactConfig(from: contact) + #expect(config.outPath == "aabb") + } + + // MARK: - Step counting (mirrors the write gates, driven by the resolved plan) + + private static func emptySlots(_ count: UInt8) -> [DeviceChannelSlot] { + (0.. [DeviceChannelSlot] { - (0.. 2 as invalid") - func invalidPathHashModeRejected() throws { - let json = """ - { - "type": 1, "name": "BadMode", "public_key": "\(String(repeating: "ab", count: 32))", - "flags": 0, "latitude": "0", "longitude": "0", - "last_advert": 0, "last_modified": 0, - "out_path": "aabbcc", - "path_hash_mode": 3 - } - """ - let config = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: Data(json.utf8)) - #expect(config.pathHashMode == 3) - - // The mode validation guard should reject values > 2 - let mode = config.pathHashMode ?? 0 - #expect(mode > 2) - } - - // MARK: - ImportProgress - - @Test("ImportProgress stores step info") - func importProgressFields() { - let progress = ImportProgress(step: .contact(name: "Alice"), current: 3, total: 10) - #expect(progress.step == .contact(name: "Alice")) - #expect(progress.current == 3) - #expect(progress.total == 10) - } - - // MARK: - Post-Identity Resolution Seam - - @Test("resolveEffectiveRadioID returns callback result when private key was imported") - func resolveReturnsCallbackResult() async throws { - let original = UUID() - let reconciled = UUID() - let result = try await resolveEffectiveRadioID( - original: original, - didImportPrivateKey: true, - callback: { @Sendable in reconciled } - ) - #expect(result == reconciled) - } - - @Test("resolveEffectiveRadioID skips callback when no private key was imported") - func resolveSkipsCallbackWhenNoPrivateKey() async throws { - actor CallTracker { - var calls = 0 - func bump() { calls += 1 } - } - let tracker = CallTracker() - let original = UUID() - let result = try await resolveEffectiveRadioID( - original: original, - didImportPrivateKey: false, - callback: { @Sendable in - await tracker.bump() - return UUID() - } - ) - #expect(result == original) - #expect(await tracker.calls == 0, - "Callback must not fire when no private key was imported") - } - - @Test("resolveEffectiveRadioID returns original when callback returns nil") - func resolveReturnsOriginalWhenCallbackReturnsNil() async throws { - let original = UUID() - let result = try await resolveEffectiveRadioID( - original: original, - didImportPrivateKey: true, - callback: { @Sendable in nil } - ) - #expect(result == original) - } - - @Test("resolveEffectiveRadioID handles nil callback gracefully") - func resolveHandlesNilCallback() async throws { - let original = UUID() - let result = try await resolveEffectiveRadioID( - original: original, - didImportPrivateKey: true, - callback: nil - ) - #expect(result == original) - } - - // MARK: - Execute orchestration seam - - private static let executeSections = ConfigSections( - nodeIdentity: true, radioSettings: false, positionSettings: false, - otherSettings: false, channels: true, contacts: true + let plan = try planConfigImport( + config: config, sections: sections, + maxChannels: 8, maxContacts: 100, maxTxPower: 30, + existingChannels: Self.emptySlots(8), existingContacts: [:] ) + // Both fold onto one slot, but each is a separate write, so the bar must count two. + #expect(plan.channelWrites.count == 2) + #expect(NodeConfigService.stepCount(for: plan) == 2) + } + + @Test + func `stepCount is zero for an empty plan`() throws { + let plan = try planConfigImport( + config: MeshCoreNodeConfig(), sections: ConfigSections(), + maxChannels: 8, maxContacts: 100, maxTxPower: 30, + existingChannels: [], existingContacts: [:] + ) + #expect(NodeConfigService.stepCount(for: plan) == 0) + } - private static let executeLogger = Logger(subsystem: "test", category: "NodeConfigExecuteTests") - - /// A plan with identity, two channels, and one contact, so the seam exercises identity-first - /// ordering and per-write progress. Built through the real planner. - private static func executePlan() throws -> ConfigImportPlan { - let config = MeshCoreNodeConfig( - name: "Node", - privateKey: String(repeating: "ab", count: 64), - channels: [ - .init(name: "Ch1", secret: "00112233445566778899aabbccddeeff"), - .init(name: "Ch2", secret: "ffeeddccbbaa99887766554433221100"), - ], - contacts: [ - .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), - flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), - ] - ) - return try planConfigImport( - config: config, sections: executeSections, - maxChannels: 8, maxContacts: 100, maxTxPower: 30, - existingChannels: emptySlots(8), existingContacts: [:] - ) - } + // MARK: - OtherSettings merge logic - private static let radioExecuteSections = ConfigSections( - nodeIdentity: true, radioSettings: true, positionSettings: false, - otherSettings: false, channels: false, contacts: true + @Test + func `Partial OtherSettings fills missing fields from current device values`() { + // Imported config has only 2 of 7 fields (companion app style) + let imported = MeshCoreNodeConfig.OtherSettings( + manualAddContacts: 1, + advertLocationPolicy: 0 ) - /// A plan that selects the radio section so the seam exercises the radio-last branch - /// (setRadioParams then setTxPower) after contacts. Radio values mirror the validated - /// set in stepCountFullPlan; txPower 20 is within the maxTxPower 30 bound. - private static func radioExecutePlan() throws -> ConfigImportPlan { - let config = MeshCoreNodeConfig( - name: "Node", - privateKey: String(repeating: "ab", count: 64), - radioSettings: .init(frequency: 910_525, bandwidth: 62_500, - spreadingFactor: 7, codingRate: 5, txPower: 20), - contacts: [ - .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), - flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), - ] - ) - return try planConfigImport( - config: config, sections: radioExecuteSections, - maxChannels: 8, maxContacts: 100, maxTxPower: 30, - existingChannels: emptySlots(8), existingContacts: [:]) - } + // Simulate current device state + let current = Self.testSelfInfo + + // Merge: imported values where present, current values for nil + let manualAdd = imported.manualAddContacts ?? (current.manualAddContacts ? 1 : 0) + let advertPolicy = imported.advertLocationPolicy ?? current.advertisementLocationPolicy + let telBase = imported.telemetryModeBase ?? current.telemetryModeBase + let telLocation = imported.telemetryModeLocation ?? current.telemetryModeLocation + let telEnvironment = imported.telemetryModeEnvironment ?? current.telemetryModeEnvironment + let multiAcks = imported.multiAcks ?? current.multiAcks + + // Imported values should take precedence + #expect(manualAdd == 1) + #expect(advertPolicy == 0) + + // Missing fields should fall back to current device values + #expect(telBase == current.telemetryModeBase) + #expect(telLocation == current.telemetryModeLocation) + #expect(telEnvironment == current.telemetryModeEnvironment) + #expect(multiAcks == current.multiAcks) + } + + @Test + func `Full OtherSettings uses all imported values`() { + let imported = MeshCoreNodeConfig.OtherSettings( + manualAddContacts: 0, + advertLocationPolicy: 2, + telemetryModeBase: 3, + telemetryModeLocation: 1, + telemetryModeEnvironment: 2, + multiAcks: 5, + advertisementType: 4 + ) - private static let fullSections: ConfigSections = { - var s = ConfigSections() - s.selectAll() - return s - }() - - /// A plan exercising every step-emitting branch (identity name+key, position, other, - /// two channels, one contact, radio params + tx power) so the execute seam's progress - /// emissions can be cross-checked against stepCount(for:) across all branches. - private static func fullSectionsPlan() throws -> ConfigImportPlan { - let config = MeshCoreNodeConfig( - name: "Test", - privateKey: String(repeating: "ab", count: 64), - radioSettings: .init(frequency: 910_525, bandwidth: 62_500, - spreadingFactor: 7, codingRate: 5, txPower: 20), - positionSettings: .init(latitude: "47.0", longitude: "-122.0"), - otherSettings: .init(manualAddContacts: 0), - channels: [ - .init(name: "Ch1", secret: "00112233445566778899aabbccddeeff"), - .init(name: "Ch2", secret: "ffeeddccbbaa99887766554433221100"), - ], - contacts: [ - .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), - flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), - ]) - return try planConfigImport( - config: config, sections: fullSections, - maxChannels: 8, maxContacts: 100, maxTxPower: 30, - existingChannels: emptySlots(8), existingContacts: [:]) + let current = Self.testSelfInfo + + let manualAdd = imported.manualAddContacts ?? (current.manualAddContacts ? 1 : 0) + let advertPolicy = imported.advertLocationPolicy ?? current.advertisementLocationPolicy + let telBase = imported.telemetryModeBase ?? current.telemetryModeBase + let telLocation = imported.telemetryModeLocation ?? current.telemetryModeLocation + let telEnvironment = imported.telemetryModeEnvironment ?? current.telemetryModeEnvironment + let multiAcks = imported.multiAcks ?? current.multiAcks + + #expect(manualAdd == 0) + #expect(advertPolicy == 2) + #expect(telBase == 3) + #expect(telLocation == 1) + #expect(telEnvironment == 2) + #expect(multiAcks == 5) + } + + // MARK: - Export round-trip consistency + + @Test + func `buildRadioSettings round-trips through config format`() { + let radio = NodeConfigService.buildRadioSettings(from: Self.testSelfInfo) + + // Config stores frequency in kHz, bandwidth in Hz. + // setRadioParams's bandwidthKHz parameter actually takes Hz (matching + // RadioPreset.bandwidthHz usage), so import passes values directly + // for a lossless round-trip. + #expect(radio.frequency == 910_525) + #expect(radio.bandwidth == 62500) + } + + @Test + func `buildContactConfig and import produce consistent outPath`() { + let exported = NodeConfigService.buildContactConfig(from: Self.testContact) + #expect(exported.outPath == "aabbcc") + + // "aabbcc" = 3 bytes, matching the original outPathLength + #expect(Self.testContact.outPathLength == 3) + } + + @Test + func `buildContactConfig and import produce consistent flood path`() { + let exported = NodeConfigService.buildContactConfig(from: Self.floodContact) + // Flood routing: nil outPath, outPathLength 0xFF + #expect(exported.outPath == nil) + #expect(Self.floodContact.outPathLength == 0xFF) + } + + @Test + func `Direct contact round-trips through export and import without becoming flood`() throws { + let exported = NodeConfigService.buildContactConfig(from: Self.zeroPathContact) + #expect(exported.outPath == "") + + // Re-encode through JSON to simulate a real import + let encoded = try JSONEncoder().encode(exported) + let reimported = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: encoded) + + // Empty string is non-nil: must be treated as direct, not flood + #expect(reimported.outPath != nil) + #expect(reimported.outPath?.isEmpty == true) + + // Simulate the fixed import logic's three-way branch + let outPathLength: UInt8 = if let pathHex = reimported.outPath, !pathHex.isEmpty { + // Routed path (not reached for direct contacts) + 1 + } else if reimported.outPath != nil { + // Direct (zero-hop) — outPath was explicitly set to "" + 0 + } else { + // Flood — outPath was nil + 0xFF } - @Test("Execute applies identity before channels/contacts and reports one step per write") - func executeOrdersIdentityFirst() async throws { - let plan = try Self.executePlan() - let spy = ExecuteSpy() - try await executeConfigImport( - plan: plan, sections: Self.executeSections, radioID: UUID(), - writers: makeSpyWriters(spy), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) } - ) - let calls = spy.calls - let identityIndex = try #require(calls.firstIndex(of: "importPrivateKey")) - let nameIndex = try #require(calls.firstIndex(of: "setNodeName")) - let channelIndex = try #require(calls.firstIndex { $0.hasPrefix("setChannel") }) - let contactIndex = try #require(calls.firstIndex { $0.hasPrefix("addContact") }) - #expect(identityIndex < channelIndex) - #expect(nameIndex < channelIndex) - #expect(identityIndex < contactIndex) - // privateKey + name + 2 channels + 1 contact = 5 successful writes, each reporting once. - #expect(spy.progress.count == 5) + #expect(outPathLength == 0, "Direct contact must stay direct (0), not become flood (0xFF)") + } + + // MARK: - Multibyte path hash mode (export) + + @Test + func `buildContactConfig exports pathHashMode for mode 0 contact`() { + let config = NodeConfigService.buildContactConfig(from: Self.testContact) + // testContact has outPathLength=3 → mode 0 (upper 2 bits = 0) + #expect(config.pathHashMode == 0) + } + + @Test + func `buildContactConfig exports pathHashMode for mode 1 (2-byte) contact`() { + // outPathLength = encodePathLen(hashSize: 2, hopCount: 3) = 0b01_000011 = 0x43 + let contact = MeshContact( + id: "mode1", publicKey: Data(repeating: 0x05, count: 32), + type: .chat, flags: [], + outPathLength: encodePathLen(hashSize: 2, hopCount: 3), + outPath: Data([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), + advertisedName: "Mode1Node", + lastAdvertisement: .now, latitude: 0, longitude: 0, lastModified: .now + ) + let config = NodeConfigService.buildContactConfig(from: contact) + + #expect(config.pathHashMode == 1) + #expect(config.outPath == "aabbccddeeff") + } + + @Test + func `buildContactConfig exports nil pathHashMode for flood contacts`() { + let config = NodeConfigService.buildContactConfig(from: Self.floodContact) + #expect(config.pathHashMode == nil) + } + + // MARK: - Multibyte path hash mode (import round-trip via JSON) + + @Test + func `ContactConfig import with pathHashMode encodes outPathLength correctly`() throws { + let json = """ + { + "type": 1, "name": "Test", "public_key": "\(String(repeating: "ab", count: 32))", + "flags": 0, "latitude": "0", "longitude": "0", + "last_advert": 0, "last_modified": 0, + "out_path": "aabbccddeeff", + "path_hash_mode": 1 } - - @Test("A first-write failure reports no progress, so the import reads as clean, not partial") - func executeFirstWriteFailureEmitsNoProgress() async throws { - let plan = try Self.executePlan() - let spy = ExecuteSpy() - await #expect(throws: NodeConfigServiceError.self) { - try await executeConfigImport( - plan: plan, sections: Self.executeSections, radioID: UUID(), - writers: makeSpyWriters(spy, throwOn: { $0 == "importPrivateKey" }), - logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) } - ) - } - #expect(spy.progress.isEmpty, "No write succeeded, so no progress must be reported") + """ + let config = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: Data(json.utf8)) + + #expect(config.pathHashMode == 1) + + // Simulate what the import code does: 6 hex chars = 3 bytes + let pathByteCount = 6 // "aabbccddeeff" = 6 bytes + let hashSize = Int(config.pathHashMode ?? 0) + 1 + let hopCount = pathByteCount / hashSize + let outPathLength = encodePathLen(hashSize: hashSize, hopCount: hopCount) + + // 6 bytes / 2 bytes per hop = 3 hops, mode 1 → 0b01_000011 = 0x43 + #expect(hashSize == 2) + #expect(hopCount == 3) + #expect(outPathLength == 0x43) + } + + @Test + func `ContactConfig import without pathHashMode defaults to mode 0`() throws { + let json = """ + { + "type": 1, "name": "Test", "public_key": "\(String(repeating: "ab", count: 32))", + "flags": 0, "latitude": "0", "longitude": "0", + "last_advert": 0, "last_modified": 0, + "out_path": "aabbcc" } - - @Test("A mid-sequence failure reports progress only for the writes that already succeeded") - func executeMidSequenceFailureReportsPartialProgress() async throws { - let plan = try Self.executePlan() - let spy = ExecuteSpy() - await #expect(throws: NodeConfigServiceError.self) { - try await executeConfigImport( - plan: plan, sections: Self.executeSections, radioID: UUID(), - writers: makeSpyWriters(spy, throwOn: { $0.hasPrefix("setChannel") }), - logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) } - ) - } - // Identity succeeded and reported; the first channel write failed before its progress fired. - #expect(spy.progress == [.privateKey, .nodeName]) + """ + let config = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: Data(json.utf8)) + + #expect(config.pathHashMode == nil) + + // Simulate import: nil defaults to mode 0, raw byte count = hop count + let pathByteCount = 3 // "aabbcc" = 3 bytes + let hashSize = Int(config.pathHashMode ?? 0) + 1 + let hopCount = pathByteCount / hashSize + let outPathLength = encodePathLen(hashSize: hashSize, hopCount: hopCount) + + // 3 bytes / 1 byte per hop = 3 hops, mode 0 → 0b00_000011 = 3 + #expect(hashSize == 1) + #expect(hopCount == 3) + #expect(outPathLength == 3) + } + + @Test + func `Direct contact with pathHashMode imports as outPathLength 0, not mode-encoded`() throws { + let json = """ + { + "type": 1, "name": "DirectMode1", "public_key": "\(String(repeating: "cd", count: 32))", + "flags": 0, "latitude": "0", "longitude": "0", + "last_advert": 0, "last_modified": 0, + "out_path": "", + "path_hash_mode": 1 } - - @Test("A local-save failure after the device add still reports the contact as applied") - func executeContactDbFailureStillReportsProgress() async throws { - let plan = try Self.executePlan() // identity + 2 channels + 1 contact - let spy = ExecuteSpy() - var writers = makeSpyWriters(spy) - writers = ConfigImportWriters( - getSelfInfo: writers.getSelfInfo, - importPrivateKey: writers.importPrivateKey, - setNodeName: writers.setNodeName, - setLocation: writers.setLocation, - setOtherParams: writers.setOtherParams, - resolveEffectiveRadioID: writers.resolveEffectiveRadioID, - setRadioParams: writers.setRadioParams, - setTxPower: writers.setTxPower, - setChannel: writers.setChannel, - addContact: { _, contact in - spy.record("addContact:\(contact.advertisedName)") - // Models the post-device-add local save failing; the production closure must - // swallow this, so executeConfigImport must complete and report progress. - } - ) - try await executeConfigImport( - plan: plan, sections: Self.executeSections, radioID: UUID(), - writers: writers, logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) } - ) - #expect(spy.progress.contains(.contact(name: "C1"))) + """ + let config = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: Data(json.utf8)) + #expect(config.pathHashMode == 1) + #expect(config.outPath == "") + + // Simulate the import logic's three-way branch + let outPathLength: UInt8 = if let pathHex = config.outPath, !pathHex.isEmpty { + // Routed path (not reached for empty out_path) + 1 + } else if config.outPath != nil { + 0 + } else { + 0xFF } - @Test("Execute writes radio after contacts and tx power after radio params") - func executeWritesRadioLastAfterContacts() async throws { - let plan = try Self.radioExecutePlan() - let spy = ExecuteSpy() - try await executeConfigImport( - plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), - writers: makeSpyWriters(spy), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - let calls = spy.calls - let contactIndex = try #require(calls.lastIndex { $0.hasPrefix("addContact") }) - let radioIndex = try #require(calls.firstIndex(of: "setRadioParams")) - let txPowerIndex = try #require(calls.firstIndex(of: "setTxPower")) - #expect(contactIndex < radioIndex, "Radio must be written after contacts (radio goes last)") - #expect(radioIndex < txPowerIndex, "TX power must follow radio params") - #expect(spy.progress == [.privateKey, .nodeName, .contact(name: "C1"), - .radioParameters, .txPower]) + #expect(outPathLength == 0, "Direct contact must encode as 0, not mode-encoded 0x40") + #expect(outPathLength != 0xFF, "Direct contact must not become flood") + } + + // MARK: - Error cases + + @Test + func `NodeConfigServiceError has descriptive messages`() { + let channelError = NodeConfigServiceError.invalidChannelSecret(index: 2, hexLength: 30) + #expect(channelError.localizedDescription.contains("Channel 2")) + + let contactError = NodeConfigServiceError.invalidContactPublicKey(name: "BadContact") + #expect(contactError.localizedDescription.contains("BadContact")) + + let modeError = NodeConfigServiceError.invalidPathHashMode(name: "BadNode", mode: 5) + #expect(modeError.localizedDescription.contains("BadNode")) + #expect(modeError.localizedDescription.contains("5")) + } + + // MARK: - ImportProgress + + @Test + func `ImportProgress stores step info`() { + let progress = ImportProgress(step: .contact(name: "Alice"), current: 3, total: 10) + #expect(progress.step == .contact(name: "Alice")) + #expect(progress.current == 3) + #expect(progress.total == 10) + } + + // MARK: - Post-Identity Resolution Seam + + @Test + func `resolveEffectiveRadioID returns callback result when private key was imported`() async throws { + let original = UUID() + let reconciled = UUID() + let result = try await resolveEffectiveRadioID( + original: original, + didImportPrivateKey: true, + callback: { @Sendable in reconciled } + ) + #expect(result == reconciled) + } + + @Test + func `resolveEffectiveRadioID skips callback when no private key was imported`() async throws { + actor CallTracker { + var calls = 0 + func bump() { + calls += 1 + } } - - @Test("A tx-power failure after radio params rethrows and reports radio progress but not tx power") - func executeTxPowerFailureAfterRadioParams() async throws { - let plan = try Self.radioExecutePlan() - let spy = ExecuteSpy() - await #expect(throws: NodeConfigServiceError.self) { - try await executeConfigImport( - plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), - writers: makeSpyWriters(spy, throwOn: { $0 == "setTxPower" }), - logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - } - #expect(spy.calls.contains("setRadioParams")) - #expect(spy.calls.contains("setTxPower")) - #expect(spy.progress.contains(.radioParameters)) - #expect(!spy.progress.contains(.txPower), "TX power progress must not fire when setTxPower throws") + let tracker = CallTracker() + let original = UUID() + let result = try await resolveEffectiveRadioID( + original: original, + didImportPrivateKey: false, + callback: { @Sendable in + await tracker.bump() + return UUID() + } + ) + #expect(result == original) + #expect(await tracker.calls == 0, + "Callback must not fire when no private key was imported") + } + + @Test + func `resolveEffectiveRadioID returns original when callback returns nil`() async throws { + let original = UUID() + let result = try await resolveEffectiveRadioID( + original: original, + didImportPrivateKey: true, + callback: { @Sendable in nil } + ) + #expect(result == original) + } + + @Test + func `resolveEffectiveRadioID handles nil callback gracefully`() async throws { + let original = UUID() + let result = try await resolveEffectiveRadioID( + original: original, + didImportPrivateKey: true, + callback: nil + ) + #expect(result == original) + } + + // MARK: - Execute orchestration seam + + private static let executeSections = ConfigSections( + nodeIdentity: true, radioSettings: false, positionSettings: false, + otherSettings: false, channels: true, contacts: true + ) + + private static let executeLogger = Logger(subsystem: "test", category: "NodeConfigExecuteTests") + + /// A plan with identity, two channels, and one contact, so the seam exercises identity-first + /// ordering and per-write progress. Built through the real planner. + private static func executePlan() throws -> ConfigImportPlan { + let config = MeshCoreNodeConfig( + name: "Node", + privateKey: String(repeating: "ab", count: 64), + channels: [ + .init(name: "Ch1", secret: "00112233445566778899aabbccddeeff"), + .init(name: "Ch2", secret: "ffeeddccbbaa99887766554433221100"), + ], + contacts: [ + .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), + flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), + ] + ) + return try planConfigImport( + config: config, sections: executeSections, + maxChannels: 8, maxContacts: 100, maxTxPower: 30, + existingChannels: emptySlots(8), existingContacts: [:] + ) + } + + private static let radioExecuteSections = ConfigSections( + nodeIdentity: true, radioSettings: true, positionSettings: false, + otherSettings: false, channels: false, contacts: true + ) + + /// A plan that selects the radio section so the seam exercises the radio-last branch + /// (setRadioParams then setTxPower) after contacts. Radio values mirror the validated + /// set in stepCountFullPlan; txPower 20 is within the maxTxPower 30 bound. + private static func radioExecutePlan() throws -> ConfigImportPlan { + let config = MeshCoreNodeConfig( + name: "Node", + privateKey: String(repeating: "ab", count: 64), + radioSettings: .init(frequency: 910_525, bandwidth: 62500, + spreadingFactor: 7, codingRate: 5, txPower: 20), + contacts: [ + .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), + flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), + ] + ) + return try planConfigImport( + config: config, sections: radioExecuteSections, + maxChannels: 8, maxContacts: 100, maxTxPower: 30, + existingChannels: emptySlots(8), existingContacts: [:] + ) + } + + private static let fullSections: ConfigSections = { + var s = ConfigSections() + s.selectAll() + return s + }() + + /// A plan exercising every step-emitting branch (identity name+key, position, other, + /// two channels, one contact, radio params + tx power) so the execute seam's progress + /// emissions can be cross-checked against stepCount(for:) across all branches. + private static func fullSectionsPlan() throws -> ConfigImportPlan { + let config = MeshCoreNodeConfig( + name: "Test", + privateKey: String(repeating: "ab", count: 64), + radioSettings: .init(frequency: 910_525, bandwidth: 62500, + spreadingFactor: 7, codingRate: 5, txPower: 20), + positionSettings: .init(latitude: "47.0", longitude: "-122.0"), + otherSettings: .init(manualAddContacts: 0), + channels: [ + .init(name: "Ch1", secret: "00112233445566778899aabbccddeeff"), + .init(name: "Ch2", secret: "ffeeddccbbaa99887766554433221100"), + ], + contacts: [ + .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), + flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), + ] + ) + return try planConfigImport( + config: config, sections: fullSections, + maxChannels: 8, maxContacts: 100, maxTxPower: 30, + existingChannels: emptySlots(8), existingContacts: [:] + ) + } + + @Test + func `Execute applies identity before channels/contacts and reports one step per write`() async throws { + let plan = try Self.executePlan() + let spy = ExecuteSpy() + try await executeConfigImport( + plan: plan, sections: Self.executeSections, radioID: UUID(), + writers: makeSpyWriters(spy), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) + let calls = spy.calls + let identityIndex = try #require(calls.firstIndex(of: "importPrivateKey")) + let nameIndex = try #require(calls.firstIndex(of: "setNodeName")) + let channelIndex = try #require(calls.firstIndex { $0.hasPrefix("setChannel") }) + let contactIndex = try #require(calls.firstIndex { $0.hasPrefix("addContact") }) + #expect(identityIndex < channelIndex) + #expect(nameIndex < channelIndex) + #expect(identityIndex < contactIndex) + // privateKey + name + 2 channels + 1 contact = 5 successful writes, each reporting once. + #expect(spy.progress.count == 5) + } + + @Test + func `A first-write failure reports no progress, so the import reads as clean, not partial`() async throws { + let plan = try Self.executePlan() + let spy = ExecuteSpy() + await #expect(throws: NodeConfigServiceError.self) { + try await executeConfigImport( + plan: plan, sections: Self.executeSections, radioID: UUID(), + writers: makeSpyWriters(spy, throwOn: { $0 == "importPrivateKey" }), + logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) } - - @Test("Execute over a full plan emits exactly stepCount progress steps across all branches") - func executeFullPlanProgressMatchesStepCount() async throws { - let plan = try Self.fullSectionsPlan() - let spy = ExecuteSpy() - try await executeConfigImport( - plan: plan, sections: Self.fullSections, radioID: UUID(), - writers: makeSpyWriters(spy), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - #expect(spy.progress.count == NodeConfigService.stepCount(for: plan)) - let kinds = Set(spy.progress.map { step -> String in - switch step { - case .position: return "position" - case .otherParameters: return "other" - case .privateKey: return "privateKey" - case .nodeName: return "nodeName" - case .radioParameters: return "radio" - case .txPower: return "txPower" - case .channel: return "channel" - case .contact: return "contact" - } - }) - #expect(kinds == ["position", "other", "privateKey", "nodeName", "radio", "txPower", "channel", "contact"]) + #expect(spy.progress.isEmpty, "No write succeeded, so no progress must be reported") + } + + @Test + func `A mid-sequence failure reports progress only for the writes that already succeeded`() async throws { + let plan = try Self.executePlan() + let spy = ExecuteSpy() + await #expect(throws: NodeConfigServiceError.self) { + try await executeConfigImport( + plan: plan, sections: Self.executeSections, radioID: UUID(), + writers: makeSpyWriters(spy, throwOn: { $0.hasPrefix("setChannel") }), + logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) } - - // MARK: - M3: pref/radio diff gates - - /// A `SelfInfo` matching `fullSectionsPlan` on every diff-gated field, with the overridable - /// fields a single test needs to perturb. - private static func matchingSelfInfo( - name: String = "Test", - latitude: Double = 47.0, - longitude: Double = -122.0, - radioFrequencyMHz: Double = 910.525, - radioBandwidthKHz: Double = 62.5, - spreadingFactor: UInt8 = 7, - codingRate: UInt8 = 5, - txPower: Int8 = 20 - ) -> SelfInfo { - SelfInfo( - advertisementType: 0, txPower: txPower, maxTxPower: 30, - publicKey: Data(repeating: 0, count: 32), - latitude: latitude, longitude: longitude, - multiAcks: 0, advertisementLocationPolicy: 0, - telemetryModeEnvironment: 0, telemetryModeLocation: 0, telemetryModeBase: 0, - manualAddContacts: false, - radioFrequency: radioFrequencyMHz, radioBandwidth: radioBandwidthKHz, - radioSpreadingFactor: spreadingFactor, radioCodingRate: codingRate, - name: name - ) + // Identity succeeded and reported; the first channel write failed before its progress fired. + #expect(spy.progress == [.privateKey, .nodeName]) + } + + @Test + func `A local-save failure after the device add still reports the contact as applied`() async throws { + let plan = try Self.executePlan() // identity + 2 channels + 1 contact + let spy = ExecuteSpy() + var writers = makeSpyWriters(spy) + writers = ConfigImportWriters( + getSelfInfo: writers.getSelfInfo, + importPrivateKey: writers.importPrivateKey, + setNodeName: writers.setNodeName, + setLocation: writers.setLocation, + setOtherParams: writers.setOtherParams, + resolveEffectiveRadioID: writers.resolveEffectiveRadioID, + setRadioParams: writers.setRadioParams, + setTxPower: writers.setTxPower, + setChannel: writers.setChannel, + addContact: { _, contact in + spy.record("addContact:\(contact.advertisedName)") + // Models the post-device-add local save failing; the production closure must + // swallow this, so executeConfigImport must complete and report progress. + } + ) + try await executeConfigImport( + plan: plan, sections: Self.executeSections, radioID: UUID(), + writers: writers, logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) + #expect(spy.progress.contains(.contact(name: "C1"))) + } + + @Test + func `Execute writes radio after contacts and tx power after radio params`() async throws { + let plan = try Self.radioExecutePlan() + let spy = ExecuteSpy() + try await executeConfigImport( + plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), + writers: makeSpyWriters(spy), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) + let calls = spy.calls + let contactIndex = try #require(calls.lastIndex { $0.hasPrefix("addContact") }) + let radioIndex = try #require(calls.firstIndex(of: "setRadioParams")) + let txPowerIndex = try #require(calls.firstIndex(of: "setTxPower")) + #expect(contactIndex < radioIndex, "Radio must be written after contacts (radio goes last)") + #expect(radioIndex < txPowerIndex, "TX power must follow radio params") + #expect(spy.progress == [.privateKey, .nodeName, .contact(name: "C1"), + .radioParameters, .txPower]) + } + + @Test + func `A tx-power failure after radio params rethrows and reports radio progress but not tx power`() async throws { + let plan = try Self.radioExecutePlan() + let spy = ExecuteSpy() + await #expect(throws: NodeConfigServiceError.self) { + try await executeConfigImport( + plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), + writers: makeSpyWriters(spy, throwOn: { $0 == "setTxPower" }), + logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) } + #expect(spy.calls.contains("setRadioParams")) + #expect(spy.calls.contains("setTxPower")) + #expect(spy.progress.contains(.radioParameters)) + #expect(!spy.progress.contains(.txPower), "TX power progress must not fire when setTxPower throws") + } + + @Test + func `Execute over a full plan emits exactly stepCount progress steps across all branches`() async throws { + let plan = try Self.fullSectionsPlan() + let spy = ExecuteSpy() + try await executeConfigImport( + plan: plan, sections: Self.fullSections, radioID: UUID(), + writers: makeSpyWriters(spy), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) + #expect(spy.progress.count == NodeConfigService.stepCount(for: plan)) + let kinds = Set(spy.progress.map { step -> String in + switch step { + case .position: return "position" + case .otherParameters: return "other" + case .privateKey: return "privateKey" + case .nodeName: return "nodeName" + case .radioParameters: return "radio" + case .txPower: return "txPower" + case .channel: return "channel" + case .contact: return "contact" + } + }) + #expect(kinds == ["position", "other", "privateKey", "nodeName", "radio", "txPower", "channel", "contact"]) + } + + // MARK: - M3: pref/radio diff gates + + /// A `SelfInfo` matching `fullSectionsPlan` on every diff-gated field, with the overridable + /// fields a single test needs to perturb. + private static func matchingSelfInfo( + name: String = "Test", + latitude: Double = 47.0, + longitude: Double = -122.0, + radioFrequencyMHz: Double = 910.525, + radioBandwidthKHz: Double = 62.5, + spreadingFactor: UInt8 = 7, + codingRate: UInt8 = 5, + txPower: Int8 = 20 + ) -> SelfInfo { + SelfInfo( + advertisementType: 0, txPower: txPower, maxTxPower: 30, + publicKey: Data(repeating: 0, count: 32), + latitude: latitude, longitude: longitude, + multiAcks: 0, advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, telemetryModeLocation: 0, telemetryModeBase: 0, + manualAddContacts: false, + radioFrequency: radioFrequencyMHz, radioBandwidth: radioBandwidthKHz, + radioSpreadingFactor: spreadingFactor, radioCodingRate: codingRate, + name: name + ) + } - @Test("Pref decision gates: each setter fires only when its field differs from the device") - func prefDecisionGates() { - let info = Self.matchingSelfInfo() - let radio = MeshCoreNodeConfig.RadioSettings( - frequency: 910_525, bandwidth: 62_500, spreadingFactor: 7, codingRate: 5, txPower: 20) - - #expect(nodeNameNeedsWrite("Test", current: info) == false) - #expect(nodeNameNeedsWrite("Renamed", current: info) == true) - - // setNodeName truncates to the firmware field width, so a name differing only past that - // width stores identically on the device and must not re-commit. - let storedName = String(repeating: "a", count: ProtocolLimits.maxUsableNameBytes) - let longNameInfo = Self.matchingSelfInfo(name: storedName) - #expect(nodeNameNeedsWrite(storedName + "EXTRA", current: longNameInfo) == false) - - #expect(locationNeedsWrite(.init(latitude: 47.0, longitude: -122.0), current: info) == false) - #expect(locationNeedsWrite(.init(latitude: 47.5, longitude: -122.0), current: info) == true) - - #expect(radioParamsNeedWrite(radio, current: info) == false) - var sfChanged = radio - sfChanged = .init(frequency: 910_525, bandwidth: 62_500, spreadingFactor: 8, codingRate: 5, txPower: 20) - #expect(radioParamsNeedWrite(sfChanged, current: info) == true) + @Test + func `Pref decision gates: each setter fires only when its field differs from the device`() { + let info = Self.matchingSelfInfo() + let radio = MeshCoreNodeConfig.RadioSettings( + frequency: 910_525, bandwidth: 62500, spreadingFactor: 7, codingRate: 5, txPower: 20 + ) - #expect(txPowerNeedsWrite(radio, current: info) == false) - #expect(txPowerNeedsWrite(.init(frequency: 910_525, bandwidth: 62_500, spreadingFactor: 7, codingRate: 5, txPower: 19), current: info) == true) - } + #expect(nodeNameNeedsWrite("Test", current: info) == false) + #expect(nodeNameNeedsWrite("Renamed", current: info) == true) + + // setNodeName truncates to the firmware field width, so a name differing only past that + // width stores identically on the device and must not re-commit. + let storedName = String(repeating: "a", count: ProtocolLimits.maxUsableNameBytes) + let longNameInfo = Self.matchingSelfInfo(name: storedName) + #expect(nodeNameNeedsWrite(storedName + "EXTRA", current: longNameInfo) == false) + + #expect(locationNeedsWrite(.init(latitude: 47.0, longitude: -122.0), current: info) == false) + #expect(locationNeedsWrite(.init(latitude: 47.5, longitude: -122.0), current: info) == true) + + #expect(radioParamsNeedWrite(radio, current: info) == false) + var sfChanged = radio + sfChanged = .init(frequency: 910_525, bandwidth: 62500, spreadingFactor: 8, codingRate: 5, txPower: 20) + #expect(radioParamsNeedWrite(sfChanged, current: info) == true) + + #expect(txPowerNeedsWrite(radio, current: info) == false) + #expect(txPowerNeedsWrite(.init(frequency: 910_525, bandwidth: 62500, spreadingFactor: 7, codingRate: 5, txPower: 19), current: info) == true) + } + + @Test + func `An unchanged config skips every pref/radio device write but still reports progress`() async throws { + let plan = try Self.fullSectionsPlan() + let spy = ExecuteSpy() + let info = Self.matchingSelfInfo() + try await executeConfigImport( + plan: plan, sections: Self.fullSections, radioID: UUID(), + writers: makeSpyWriters(spy, selfInfo: { info }), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) - @Test("An unchanged config skips every pref/radio device write but still reports progress") - func executeIdenticalConfigSkipsPrefWrites() async throws { - let plan = try Self.fullSectionsPlan() - let spy = ExecuteSpy() - let info = Self.matchingSelfInfo() - try await executeConfigImport( - plan: plan, sections: Self.fullSections, radioID: UUID(), - writers: makeSpyWriters(spy, selfInfo: { info }), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - - #expect(!spy.calls.contains("setNodeName"), "Unchanged name must not commit prefs") - #expect(!spy.calls.contains("setLocation"), "Unchanged position must not commit prefs") - #expect(!spy.calls.contains("setRadioParams"), "Unchanged radio params must not commit prefs") - #expect(!spy.calls.contains("setTxPower"), "Unchanged TX power must not commit prefs") - // Progress still completes so the bar reaches 100%; the skipped commits are the only change. - #expect(spy.progress.count == NodeConfigService.stepCount(for: plan)) - #expect(spy.progress.contains(.nodeName)) - #expect(spy.progress.contains(.radioParameters)) - #expect(spy.progress.contains(.txPower)) - } + #expect(!spy.calls.contains("setNodeName"), "Unchanged name must not commit prefs") + #expect(!spy.calls.contains("setLocation"), "Unchanged position must not commit prefs") + #expect(!spy.calls.contains("setRadioParams"), "Unchanged radio params must not commit prefs") + #expect(!spy.calls.contains("setTxPower"), "Unchanged TX power must not commit prefs") + // Progress still completes so the bar reaches 100%; the skipped commits are the only change. + #expect(spy.progress.count == NodeConfigService.stepCount(for: plan)) + #expect(spy.progress.contains(.nodeName)) + #expect(spy.progress.contains(.radioParameters)) + #expect(spy.progress.contains(.txPower)) + } + + @Test + func `A TX-power-only change writes only setTxPower among the radio writes`() async throws { + let plan = try Self.radioExecutePlan() // name "Node", radio txPower 20 + let spy = ExecuteSpy() + // Match name and radio params, but a different TX power. + let info = Self.matchingSelfInfo(name: "Node", txPower: 15) + try await executeConfigImport( + plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), + writers: makeSpyWriters(spy, selfInfo: { info }), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) - @Test("A TX-power-only change writes only setTxPower among the radio writes") - func executeTxPowerOnlyChangeGatesRadioParams() async throws { - let plan = try Self.radioExecutePlan() // name "Node", radio txPower 20 - let spy = ExecuteSpy() - // Match name and radio params, but a different TX power. - let info = Self.matchingSelfInfo(name: "Node", txPower: 15) - try await executeConfigImport( - plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), - writers: makeSpyWriters(spy, selfInfo: { info }), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - - #expect(!spy.calls.contains("setNodeName"), "Matching name must be skipped") - #expect(!spy.calls.contains("setRadioParams"), "Matching radio params must be skipped") - #expect(spy.calls.contains("setTxPower"), "The differing TX power must still write") - } + #expect(!spy.calls.contains("setNodeName"), "Matching name must be skipped") + #expect(!spy.calls.contains("setRadioParams"), "Matching radio params must be skipped") + #expect(spy.calls.contains("setTxPower"), "The differing TX power must still write") + } } // MARK: - Execute-seam spy @@ -922,54 +911,65 @@ struct NodeConfigServiceTests { /// failure behavior can be asserted without a live session. Lock-guarded so the `@Sendable` closures /// can record from any executor. private final class ExecuteSpy: @unchecked Sendable { - private let lock = NSLock() - private var _calls: [String] = [] - private var _progress: [ImportStep] = [] - - func record(_ label: String) { lock.lock(); _calls.append(label); lock.unlock() } - func recordProgress(_ step: ImportStep) { lock.lock(); _progress.append(step); lock.unlock() } - var calls: [String] { lock.lock(); defer { lock.unlock() }; return _calls } - var progress: [ImportStep] { lock.lock(); defer { lock.unlock() }; return _progress } + private let lock = NSLock() + private var _calls: [String] = [] + private var _progress: [ImportStep] = [] + + func record(_ label: String) { + lock.lock(); _calls.append(label); lock.unlock() + } + + func recordProgress(_ step: ImportStep) { + lock.lock(); _progress.append(step); lock.unlock() + } + + var calls: [String] { + lock.lock(); defer { lock.unlock() }; return _calls + } + + var progress: [ImportStep] { + lock.lock(); defer { lock.unlock() }; return _progress + } } /// A `SelfInfo` whose every diff-gated field differs from the execute-seam test plans, so the M3 /// pref/radio gates never skip and the ordering/progress assertions see every write. func nonMatchingSelfInfo() -> SelfInfo { - SelfInfo( - advertisementType: 0, txPower: 0, maxTxPower: 30, - publicKey: Data(repeating: 0, count: 32), - latitude: 0, longitude: 0, - multiAcks: 0, advertisementLocationPolicy: 0, - telemetryModeEnvironment: 0, telemetryModeLocation: 0, telemetryModeBase: 0, - manualAddContacts: false, - radioFrequency: 1, radioBandwidth: 1, radioSpreadingFactor: 1, radioCodingRate: 1, - name: "\u{0}unset" - ) + SelfInfo( + advertisementType: 0, txPower: 0, maxTxPower: 30, + publicKey: Data(repeating: 0, count: 32), + latitude: 0, longitude: 0, + multiAcks: 0, advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, telemetryModeLocation: 0, telemetryModeBase: 0, + manualAddContacts: false, + radioFrequency: 1, radioBandwidth: 1, radioSpreadingFactor: 1, radioCodingRate: 1, + name: "\u{0}unset" + ) } /// Builds `ConfigImportWriters` whose closures record their label and throw when `throwOn` matches, /// so a chosen write can be made to fail at a chosen point in the sequence. private func makeSpyWriters( - _ spy: ExecuteSpy, - selfInfo: @escaping @Sendable () -> SelfInfo = { nonMatchingSelfInfo() }, - throwOn: @escaping @Sendable (String) -> Bool = { _ in false } + _ spy: ExecuteSpy, + selfInfo: @escaping @Sendable () -> SelfInfo = { nonMatchingSelfInfo() }, + throwOn: @escaping @Sendable (String) -> Bool = { _ in false } ) -> ConfigImportWriters { - @Sendable func step(_ label: String) throws { - spy.record(label) - if throwOn(label) { throw NodeConfigServiceError.invalidRadioSettings(field: .frequency) } - } - return ConfigImportWriters( - getSelfInfo: { spy.record("getSelfInfo"); return selfInfo() }, - importPrivateKey: { _ in try step("importPrivateKey") }, - setNodeName: { _ in try step("setNodeName") }, - setLocation: { _, _ in try step("setLocation") }, - setOtherParams: { _ in try step("setOtherParams") }, - resolveEffectiveRadioID: { original, _ in spy.record("resolveEffectiveRadioID"); return original }, - setRadioParams: { _ in try step("setRadioParams") }, - setTxPower: { _ in try step("setTxPower") }, - setChannel: { _, write in try step("setChannel:\(write.name)") }, - addContact: { _, contact in try step("addContact:\(contact.advertisedName)") } - ) + @Sendable func step(_ label: String) throws { + spy.record(label) + if throwOn(label) { throw NodeConfigServiceError.invalidRadioSettings(field: .frequency) } + } + return ConfigImportWriters( + getSelfInfo: { spy.record("getSelfInfo"); return selfInfo() }, + importPrivateKey: { _ in try step("importPrivateKey") }, + setNodeName: { _ in try step("setNodeName") }, + setLocation: { _, _ in try step("setLocation") }, + setOtherParams: { _ in try step("setOtherParams") }, + resolveEffectiveRadioID: { original, _ in spy.record("resolveEffectiveRadioID"); return original }, + setRadioParams: { _ in try step("setRadioParams") }, + setTxPower: { _ in try step("setTxPower") }, + setChannel: { _, write in try step("setChannel:\(write.name)") }, + addContact: { _, contact in try step("addContact:\(contact.advertisedName)") } + ) } // MARK: - importOtherParams live-session forwarding @@ -979,114 +979,116 @@ private func makeSpyWriters( /// being coerced to `.none` by a future typed re-mapping. @Suite("NodeConfigService importOtherParams live session") struct NodeConfigImportOtherParamsLiveTests { - - @Test("importOtherParams forwards an unmodeled advert_loc_policy byte to the device verbatim") - @MainActor - func unmodeledAdvertPolicyForwardedVerbatim() async throws { - let unmodeledAdvertPolicyByte: UInt8 = 99 - #expect(AdvertLocationPolicy(rawValue: unmodeledAdvertPolicyByte) == nil) // confirms 99 is unmodeled - - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - - // Complete session.start() so the session is ready to issue commands. - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - - let store = PersistenceStore(modelContainer: try PersistenceStore.createContainer(inMemory: true)) - let settings = SettingsService(session: session) - let channels = ChannelService(session: session, dataStore: store, rxLogService: nil) - let service = NodeConfigService( - session: session, settingsService: settings, - channelService: channels, dataStore: store, syncCoordinator: nil) - - let imported = MeshCoreNodeConfig.OtherSettings( - manualAddContacts: 0, advertLocationPolicy: unmodeledAdvertPolicyByte) - - let beforeImport = await transport.sentData.count // == 1 (the start handshake's appStart) - let importTask = Task { try await service.importOtherParams(imported) } - - // importOtherParams first calls getSelfInfo() -> sendAppStart() (a second appStart round-trip), - // then writes setOtherParams. Pump the self-info reply, then ack the setOtherParams OK. - try await waitUntil("importOtherParams should send getSelfInfo appStart") { - await transport.sentData.count == beforeImport + 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await waitUntil("importOtherParams should send setOtherParams") { - await transport.sentData.count == beforeImport + 2 - } - await transport.simulateOK() - try await importTask.value - - let advertLocationPolicyByteIndex = 3 // [0]=cmd,[1]=manualAdd,[2]=telemetry,[3]=policy,[4]=multiAcks - let sent = await transport.sentData - let otherParamsPacket = try #require(sent.first { $0.first == CommandCode.setOtherParams.rawValue }) - #expect(otherParamsPacket[advertLocationPolicyByteIndex] == unmodeledAdvertPolicyByte) - - await session.stop() + @Test + @MainActor + func `importOtherParams forwards an unmodeled advert_loc_policy byte to the device verbatim`() async throws { + let unmodeledAdvertPolicyByte: UInt8 = 99 + #expect(AdvertLocationPolicy(rawValue: unmodeledAdvertPolicyByte) == nil) // confirms 99 is unmodeled + + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + + // Complete session.start() so the session is ready to issue commands. + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + + let store = try PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)) + let settings = SettingsService(session: session) + let channels = ChannelService(session: session, dataStore: store, rxLogService: nil) + let service = NodeConfigService( + session: session, settingsService: settings, + channelService: channels, dataStore: store, syncCoordinator: nil + ) + + let imported = MeshCoreNodeConfig.OtherSettings( + manualAddContacts: 0, advertLocationPolicy: unmodeledAdvertPolicyByte + ) + + let beforeImport = await transport.sentData.count // == 1 (the start handshake's appStart) + let importTask = Task { try await service.importOtherParams(imported) } - @Test("importOtherParams whose merge matches the device sends no setOtherParams commit") - @MainActor - func unchangedOtherParamsSkipsCommit() async throws { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - - let store = PersistenceStore(modelContainer: try PersistenceStore.createContainer(inMemory: true)) - let settings = SettingsService(session: session) - let channels = ChannelService(session: session, dataStore: store, rxLogService: nil) - let service = NodeConfigService( - session: session, settingsService: settings, - channelService: channels, dataStore: store, syncCoordinator: nil) - - // All-nil import: every merged field falls back to the device's current value, so the - // result equals current and the commit must be elided. - let beforeImport = await transport.sentData.count - let importTask = Task { try await service.importOtherParams(MeshCoreNodeConfig.OtherSettings()) } - - try await waitUntil("importOtherParams should fetch getSelfInfo") { - await transport.sentData.count == beforeImport + 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await importTask.value - - let sent = await transport.sentData - #expect(!sent.contains { $0.first == CommandCode.setOtherParams.rawValue }, - "A no-op merge must not commit /new_prefs") - - await session.stop() + // importOtherParams first calls getSelfInfo() -> sendAppStart() (a second appStart round-trip), + // then writes setOtherParams. Pump the self-info reply, then ack the setOtherParams OK. + try await waitUntil("importOtherParams should send getSelfInfo appStart") { + await transport.sentData.count == beforeImport + 1 } + await transport.simulateReceive(makeSelfInfoPacket()) + try await waitUntil("importOtherParams should send setOtherParams") { + await transport.sentData.count == beforeImport + 2 + } + await transport.simulateOK() + try await importTask.value + + let advertLocationPolicyByteIndex = 3 // [0]=cmd,[1]=manualAdd,[2]=telemetry,[3]=policy,[4]=multiAcks + let sent = await transport.sentData + let otherParamsPacket = try #require(sent.first { $0.first == CommandCode.setOtherParams.rawValue }) + #expect(otherParamsPacket[advertLocationPolicyByteIndex] == unmodeledAdvertPolicyByte) + + await session.stop() + } + + @Test + @MainActor + func `importOtherParams whose merge matches the device sends no setOtherParams commit`() async throws { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + + let store = try PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)) + let settings = SettingsService(session: session) + let channels = ChannelService(session: session, dataStore: store, rxLogService: nil) + let service = NodeConfigService( + session: session, settingsService: settings, + channelService: channels, dataStore: store, syncCoordinator: nil + ) + + // All-nil import: every merged field falls back to the device's current value, so the + // result equals current and the commit must be elided. + let beforeImport = await transport.sentData.count + let importTask = Task { try await service.importOtherParams(MeshCoreNodeConfig.OtherSettings()) } - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) - payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(withUnsafeBytes(of: UInt32(915_000).littleEndian) { Data($0) }) - payload.append(withUnsafeBytes(of: UInt32(125_000).littleEndian) { Data($0) }) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet + try await waitUntil("importOtherParams should fetch getSelfInfo") { + await transport.sentData.count == beforeImport + 1 } + await transport.simulateReceive(makeSelfInfoPacket()) + try await importTask.value + + let sent = await transport.sentData + #expect(!sent.contains { $0.first == CommandCode.setOtherParams.rawValue }, + "A no-op merge must not commit /new_prefs") + + await session.stop() + } + + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) + payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(withUnsafeBytes(of: UInt32(915_000).littleEndian) { Data($0) }) + payload.append(withUnsafeBytes(of: UInt32(125_000).littleEndian) { Data($0) }) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigTests.swift index 0b6b3768..c1baa1ef 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigTests.swift @@ -1,519 +1,507 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("MeshCoreNodeConfig Decoding") struct NodeConfigTests { - - // MARK: - Test data - - /// Full config export fixture with synthetic test data. - private static let fullConfigJSON = Data(""" - { - "name": "TestNode-2", - "public_key": "d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9", - "private_key": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", - "radio_settings": { - "frequency": 910525, - "bandwidth": 62500, - "spreading_factor": 7, - "coding_rate": 5, - "tx_power": 22 + // MARK: - Test data + + /// Full config export fixture with synthetic test data. + private static let fullConfigJSON = Data(""" + { + "name": "TestNode-2", + "public_key": "d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9", + "private_key": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", + "radio_settings": { + "frequency": 910525, + "bandwidth": 62500, + "spreading_factor": 7, + "coding_rate": 5, + "tx_power": 22 + }, + "position_settings": { + "latitude": "0.0", + "longitude": "0.0" + }, + "other_settings": { + "manual_add_contacts": 0, + "advert_location_policy": 0 + }, + "channels": [ + { "name": "General", "secret": "aa11bb22cc33dd44ee55ff6600778899" }, + { "name": "Alpha", "secret": "11223344556677889900aabbccddeeff" }, + { "name": "#bravo", "secret": "ffeeddccbbaa99887766554433221100" }, + { "name": "Charlie", "secret": "abcdef0123456789abcdef0123456789" }, + { "name": "#delta", "secret": "0123456789abcdef0123456789abcdef" }, + { "name": "Echo", "secret": "deadbeef01234567deadbeef01234567" } + ], + "contacts": [ + { + "type": 3, + "name": "Base-W (Room)", + "custom_name": null, + "public_key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "flags": 0, + "latitude": "40.7128", + "longitude": "-74.006", + "last_advert": 1767392516, + "last_modified": 1767392535, + "out_path": null }, - "position_settings": { - "latitude": "0.0", - "longitude": "0.0" + { + "type": 2, + "name": "Base-NW (Repeater)", + "custom_name": null, + "public_key": "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", + "flags": 0, + "latitude": "34.0522", + "longitude": "-118.2437", + "last_advert": 1768515147, + "last_modified": 1768515165, + "out_path": null }, - "other_settings": { - "manual_add_contacts": 0, - "advert_location_policy": 0 + { + "type": 1, + "name": "TestNode-1", + "custom_name": null, + "public_key": "0102030405060708091011121314151617181920212223242526272829303132", + "flags": 0, + "latitude": "0.0", + "longitude": "0.0", + "last_advert": 1770439152, + "last_modified": 1770439154, + "out_path": "" + } + ] + } + """.utf8) + + /// Channels-only export fixture. + private static let channelsOnlyJSON = Data(""" + { + "channels": [ + { "name": "General", "secret": "aa11bb22cc33dd44ee55ff6600778899" }, + { "name": "Alpha", "secret": "11223344556677889900aabbccddeeff" }, + { "name": "#bravo", "secret": "ffeeddccbbaa99887766554433221100" }, + { "name": "Charlie", "secret": "abcdef0123456789abcdef0123456789" }, + { "name": "#delta", "secret": "0123456789abcdef0123456789abcdef" }, + { "name": "Echo", "secret": "deadbeef01234567deadbeef01234567" } + ] + } + """.utf8) + + /// Contacts-only export fixture. + private static let contactsOnlyJSON = Data(""" + { + "contacts": [ + { + "type": 3, + "name": "Base-W (Room)", + "custom_name": null, + "public_key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "flags": 0, + "latitude": "40.7128", + "longitude": "-74.006", + "last_advert": 1767392516, + "last_modified": 1767392535, + "out_path": null }, - "channels": [ - { "name": "General", "secret": "aa11bb22cc33dd44ee55ff6600778899" }, - { "name": "Alpha", "secret": "11223344556677889900aabbccddeeff" }, - { "name": "#bravo", "secret": "ffeeddccbbaa99887766554433221100" }, - { "name": "Charlie", "secret": "abcdef0123456789abcdef0123456789" }, - { "name": "#delta", "secret": "0123456789abcdef0123456789abcdef" }, - { "name": "Echo", "secret": "deadbeef01234567deadbeef01234567" } - ], - "contacts": [ - { - "type": 3, - "name": "Base-W (Room)", - "custom_name": null, - "public_key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - "flags": 0, - "latitude": "40.7128", - "longitude": "-74.006", - "last_advert": 1767392516, - "last_modified": 1767392535, - "out_path": null - }, - { - "type": 2, - "name": "Base-NW (Repeater)", - "custom_name": null, - "public_key": "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", - "flags": 0, - "latitude": "34.0522", - "longitude": "-118.2437", - "last_advert": 1768515147, - "last_modified": 1768515165, - "out_path": null - }, - { - "type": 1, - "name": "TestNode-1", - "custom_name": null, - "public_key": "0102030405060708091011121314151617181920212223242526272829303132", - "flags": 0, - "latitude": "0.0", - "longitude": "0.0", - "last_advert": 1770439152, - "last_modified": 1770439154, - "out_path": "" - } - ] - } - """.utf8) - - /// Channels-only export fixture. - private static let channelsOnlyJSON = Data(""" + { + "type": 1, + "name": "TestNode-1", + "custom_name": null, + "public_key": "0102030405060708091011121314151617181920212223242526272829303132", + "flags": 0, + "latitude": "0.0", + "longitude": "0.0", + "last_advert": 1770439152, + "last_modified": 1770439154, + "out_path": "" + } + ] + } + """.utf8) + + private static let decoder = JSONDecoder() + + // MARK: - Full config + + @Test + func `Full config decodes all top-level fields`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + + #expect(config.name == "TestNode-2") + #expect(config.publicKey == "d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9") + #expect(config.privateKey == "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d") + #expect(config.radioSettings != nil) + #expect(config.positionSettings != nil) + #expect(config.otherSettings != nil) + #expect(config.channels?.count == 6) + #expect(config.contacts?.count == 3) + } + + @Test + func `Full config decodes radio settings`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let radio = try #require(config.radioSettings) + + #expect(radio.frequency == 910_525) + #expect(radio.bandwidth == 62500) + #expect(radio.spreadingFactor == 7) + #expect(radio.codingRate == 5) + #expect(radio.txPower == 22) + } + + @Test + func `Full config decodes position settings with isZero`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let position = try #require(config.positionSettings) + + #expect(position.latitude == "0.0") + #expect(position.longitude == "0.0") + #expect(position.isZero) + } + + @Test + func `Full config decodes companion-app other settings (2 of 7 fields)`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let other = try #require(config.otherSettings) + + #expect(other.manualAddContacts == 0) + #expect(other.advertLocationPolicy == 0) + // Companion app omits these fields + #expect(other.telemetryModeBase == nil) + #expect(other.telemetryModeLocation == nil) + #expect(other.telemetryModeEnvironment == nil) + #expect(other.multiAcks == nil) + #expect(other.advertisementType == nil) + } + + @Test + func `Full config decodes channel names and secrets`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let channels = try #require(config.channels) + + #expect(channels[0].name == "General") + #expect(channels[0].secret == "aa11bb22cc33dd44ee55ff6600778899") + #expect(channels[2].name == "#bravo") + #expect(channels[5].name == "Echo") + } + + @Test + func `Full config decodes contacts with varying types and positions`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let contacts = try #require(config.contacts) + + // Room (type 3) with position + let room = contacts[0] + #expect(room.type == 3) + #expect(room.name == "Base-W (Room)") + #expect(room.customName == nil) + #expect(room.publicKey == "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6") + #expect(room.flags == 0) + #expect(room.latitude == "40.7128") + #expect(room.longitude == "-74.006") + #expect(room.lastAdvert == 1_767_392_516) + #expect(room.lastModified == 1_767_392_535) + #expect(room.outPath == nil) + + // Repeater (type 2) with position + let repeater = contacts[1] + #expect(repeater.type == 2) + #expect(repeater.name == "Base-NW (Repeater)") + + // Client (type 1) with empty out_path + let client = contacts[2] + #expect(client.type == 1) + #expect(client.name == "TestNode-1") + #expect(client.outPath == "") + } + + // MARK: - Channels-only config + + @Test + func `Channels-only config has nil for absent sections`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.channelsOnlyJSON) + + #expect(config.name == nil) + #expect(config.publicKey == nil) + #expect(config.privateKey == nil) + #expect(config.radioSettings == nil) + #expect(config.positionSettings == nil) + #expect(config.otherSettings == nil) + #expect(config.contacts == nil) + + let channels = try #require(config.channels) + #expect(channels.count == 6) + #expect(channels[0].name == "General") + #expect(channels[4].name == "#delta") + } + + // MARK: - Contacts-only config + + @Test + func `Contacts-only config has nil for absent sections`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.contactsOnlyJSON) + + #expect(config.name == nil) + #expect(config.publicKey == nil) + #expect(config.privateKey == nil) + #expect(config.radioSettings == nil) + #expect(config.positionSettings == nil) + #expect(config.otherSettings == nil) + #expect(config.channels == nil) + + let contacts = try #require(config.contacts) + #expect(contacts.count == 2) + #expect(contacts[0].name == "Base-W (Room)") + #expect(contacts[0].outPath == nil) + #expect(contacts[1].name == "TestNode-1") + #expect(contacts[1].outPath == "") + } + + // MARK: - Round-trip + + @Test + func `Round-trip encode/decode preserves all fields`() throws { + let original = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let encoded = try JSONEncoder().encode(original) + let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) + + #expect(decoded.name == original.name) + #expect(decoded.publicKey == original.publicKey) + #expect(decoded.privateKey == original.privateKey) + #expect(decoded.radioSettings == original.radioSettings) + #expect(decoded.positionSettings == original.positionSettings) + #expect(decoded.otherSettings == original.otherSettings) + #expect(decoded.channels == original.channels) + #expect(decoded.contacts == original.contacts) + } + + // MARK: - Edge cases + + @Test + func `Contact with null out_path decodes as nil`() throws { + let json = Data(""" { - "channels": [ - { "name": "General", "secret": "aa11bb22cc33dd44ee55ff6600778899" }, - { "name": "Alpha", "secret": "11223344556677889900aabbccddeeff" }, - { "name": "#bravo", "secret": "ffeeddccbbaa99887766554433221100" }, - { "name": "Charlie", "secret": "abcdef0123456789abcdef0123456789" }, - { "name": "#delta", "secret": "0123456789abcdef0123456789abcdef" }, - { "name": "Echo", "secret": "deadbeef01234567deadbeef01234567" } - ] + "contacts": [{ + "type": 1, "name": "Test", "custom_name": null, + "public_key": "aabb", "flags": 0, + "latitude": "0.0", "longitude": "0.0", + "last_advert": 0, "last_modified": 0, + "out_path": null + }] } """.utf8) - /// Contacts-only export fixture. - private static let contactsOnlyJSON = Data(""" + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let contact = try #require(config.contacts?.first) + #expect(contact.outPath == nil) + } + + @Test + func `Contact with empty string out_path decodes as empty string`() throws { + let json = Data(""" { - "contacts": [ - { - "type": 3, - "name": "Base-W (Room)", - "custom_name": null, - "public_key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - "flags": 0, - "latitude": "40.7128", - "longitude": "-74.006", - "last_advert": 1767392516, - "last_modified": 1767392535, - "out_path": null - }, - { - "type": 1, - "name": "TestNode-1", - "custom_name": null, - "public_key": "0102030405060708091011121314151617181920212223242526272829303132", - "flags": 0, - "latitude": "0.0", - "longitude": "0.0", - "last_advert": 1770439152, - "last_modified": 1770439154, - "out_path": "" - } - ] + "contacts": [{ + "type": 1, "name": "Test", "custom_name": null, + "public_key": "aabb", "flags": 0, + "latitude": "0.0", "longitude": "0.0", + "last_advert": 0, "last_modified": 0, + "out_path": "" + }] } """.utf8) - private static let decoder = JSONDecoder() - - // MARK: - Full config - - @Test("Full config decodes all top-level fields") - func fullConfigTopLevel() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - - #expect(config.name == "TestNode-2") - #expect(config.publicKey == "d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9") - #expect(config.privateKey == "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d") - #expect(config.radioSettings != nil) - #expect(config.positionSettings != nil) - #expect(config.otherSettings != nil) - #expect(config.channels?.count == 6) - #expect(config.contacts?.count == 3) - } - - @Test("Full config decodes radio settings") - func fullConfigRadioSettings() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let radio = try #require(config.radioSettings) - - #expect(radio.frequency == 910_525) - #expect(radio.bandwidth == 62_500) - #expect(radio.spreadingFactor == 7) - #expect(radio.codingRate == 5) - #expect(radio.txPower == 22) - } - - @Test("Full config decodes position settings with isZero") - func fullConfigPositionSettings() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let position = try #require(config.positionSettings) - - #expect(position.latitude == "0.0") - #expect(position.longitude == "0.0") - #expect(position.isZero) - } - - @Test("Full config decodes companion-app other settings (2 of 7 fields)") - func fullConfigOtherSettings() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let other = try #require(config.otherSettings) - - #expect(other.manualAddContacts == 0) - #expect(other.advertLocationPolicy == 0) - // Companion app omits these fields - #expect(other.telemetryModeBase == nil) - #expect(other.telemetryModeLocation == nil) - #expect(other.telemetryModeEnvironment == nil) - #expect(other.multiAcks == nil) - #expect(other.advertisementType == nil) - } - - @Test("Full config decodes channel names and secrets") - func fullConfigChannels() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let channels = try #require(config.channels) - - #expect(channels[0].name == "General") - #expect(channels[0].secret == "aa11bb22cc33dd44ee55ff6600778899") - #expect(channels[2].name == "#bravo") - #expect(channels[5].name == "Echo") - } - - @Test("Full config decodes contacts with varying types and positions") - func fullConfigContacts() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let contacts = try #require(config.contacts) - - // Room (type 3) with position - let room = contacts[0] - #expect(room.type == 3) - #expect(room.name == "Base-W (Room)") - #expect(room.customName == nil) - #expect(room.publicKey == "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6") - #expect(room.flags == 0) - #expect(room.latitude == "40.7128") - #expect(room.longitude == "-74.006") - #expect(room.lastAdvert == 1_767_392_516) - #expect(room.lastModified == 1_767_392_535) - #expect(room.outPath == nil) - - // Repeater (type 2) with position - let repeater = contacts[1] - #expect(repeater.type == 2) - #expect(repeater.name == "Base-NW (Repeater)") - - // Client (type 1) with empty out_path - let client = contacts[2] - #expect(client.type == 1) - #expect(client.name == "TestNode-1") - #expect(client.outPath == "") - } - - // MARK: - Channels-only config - - @Test("Channels-only config has nil for absent sections") - func channelsOnlyConfig() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.channelsOnlyJSON) - - #expect(config.name == nil) - #expect(config.publicKey == nil) - #expect(config.privateKey == nil) - #expect(config.radioSettings == nil) - #expect(config.positionSettings == nil) - #expect(config.otherSettings == nil) - #expect(config.contacts == nil) - - let channels = try #require(config.channels) - #expect(channels.count == 6) - #expect(channels[0].name == "General") - #expect(channels[4].name == "#delta") - } - - // MARK: - Contacts-only config - - @Test("Contacts-only config has nil for absent sections") - func contactsOnlyConfig() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.contactsOnlyJSON) - - #expect(config.name == nil) - #expect(config.publicKey == nil) - #expect(config.privateKey == nil) - #expect(config.radioSettings == nil) - #expect(config.positionSettings == nil) - #expect(config.otherSettings == nil) - #expect(config.channels == nil) - - let contacts = try #require(config.contacts) - #expect(contacts.count == 2) - #expect(contacts[0].name == "Base-W (Room)") - #expect(contacts[0].outPath == nil) - #expect(contacts[1].name == "TestNode-1") - #expect(contacts[1].outPath == "") - } - - // MARK: - Round-trip - - @Test("Round-trip encode/decode preserves all fields") - func roundTrip() throws { - let original = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let encoded = try JSONEncoder().encode(original) - let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) - - #expect(decoded.name == original.name) - #expect(decoded.publicKey == original.publicKey) - #expect(decoded.privateKey == original.privateKey) - #expect(decoded.radioSettings == original.radioSettings) - #expect(decoded.positionSettings == original.positionSettings) - #expect(decoded.otherSettings == original.otherSettings) - #expect(decoded.channels == original.channels) - #expect(decoded.contacts == original.contacts) - } - - @Test("Round-trip preserves channels-only config") - func roundTripChannelsOnly() throws { - let original = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.channelsOnlyJSON) - let encoded = try JSONEncoder().encode(original) - let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) - - #expect(decoded.name == nil) - #expect(decoded.channels == original.channels) - #expect(decoded.contacts == nil) - } - - // MARK: - Edge cases - - @Test("Contact with null out_path decodes as nil") - func nullOutPath() throws { - let json = Data(""" - { - "contacts": [{ - "type": 1, "name": "Test", "custom_name": null, - "public_key": "aabb", "flags": 0, - "latitude": "0.0", "longitude": "0.0", - "last_advert": 0, "last_modified": 0, - "out_path": null - }] - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let contact = try #require(config.contacts?.first) - #expect(contact.outPath == nil) - } - - @Test("Contact with empty string out_path decodes as empty string") - func emptyStringOutPath() throws { - let json = Data(""" - { - "contacts": [{ - "type": 1, "name": "Test", "custom_name": null, - "public_key": "aabb", "flags": 0, - "latitude": "0.0", "longitude": "0.0", - "last_advert": 0, "last_modified": 0, - "out_path": "" - }] - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let contact = try #require(config.contacts?.first) - #expect(contact.outPath == "") - } - - @Test("Contact with null custom_name decodes as nil") - func nullCustomName() throws { - let json = Data(""" - { - "contacts": [{ - "type": 1, "name": "Test", "custom_name": null, - "public_key": "aabb", "flags": 0, - "latitude": "0.0", "longitude": "0.0", - "last_advert": 0, "last_modified": 0, - "out_path": null - }] - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let contact = try #require(config.contacts?.first) - #expect(contact.customName == nil) - } - - @Test("Contact with non-null custom_name decodes correctly") - func nonNullCustomName() throws { - let json = Data(""" - { - "contacts": [{ - "type": 1, "name": "Test", "custom_name": "My Custom Name", - "public_key": "aabb", "flags": 0, - "latitude": "0.0", "longitude": "0.0", - "last_advert": 0, "last_modified": 0, - "out_path": null - }] - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let contact = try #require(config.contacts?.first) - #expect(contact.customName == "My Custom Name") - } - - @Test("Empty JSON object decodes with all nil fields") - func emptyObject() throws { - let json = Data("{}".utf8) - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - - #expect(config.name == nil) - #expect(config.publicKey == nil) - #expect(config.privateKey == nil) - #expect(config.radioSettings == nil) - #expect(config.positionSettings == nil) - #expect(config.otherSettings == nil) - #expect(config.channels == nil) - #expect(config.contacts == nil) - } - - @Test("ContactConfig encodes nil customName and outPath as explicit null") - func contactConfigEncodesNullFields() throws { - let contact = MeshCoreNodeConfig.ContactConfig( - type: 1, name: "Test", - publicKey: "aabb", flags: 0, - latitude: "0.0", longitude: "0.0", - lastAdvert: 0, lastModified: 0 - ) - - let data = try JSONEncoder().encode(contact) - let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - // Both keys must be present with NSNull (JSON null) - #expect(json["custom_name"] is NSNull) - #expect(json["out_path"] is NSNull) - } - - @Test("ContactConfig encodes non-nil customName and outPath as values") - func contactConfigEncodesNonNullFields() throws { - let contact = MeshCoreNodeConfig.ContactConfig( - type: 1, name: "Test", customName: "Nick", - publicKey: "aabb", flags: 0, - latitude: "0.0", longitude: "0.0", - lastAdvert: 0, lastModified: 0, - outPath: "aabbcc" - ) - - let data = try JSONEncoder().encode(contact) - let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - #expect(json["custom_name"] as? String == "Nick") - #expect(json["out_path"] as? String == "aabbcc") - } + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let contact = try #require(config.contacts?.first) + #expect(contact.outPath == "") + } - @Test("Position isZero returns false for non-zero coordinates") - func positionIsZeroFalse() { - let position = MeshCoreNodeConfig.PositionSettings( - latitude: "40.7128", - longitude: "-74.006" - ) - #expect(!position.isZero) - } - - @Test("OtherSettings with all 7 fields round-trips correctly") - func otherSettingsFullRoundTrip() throws { - let json = Data(""" - { - "other_settings": { - "manual_add_contacts": 1, - "advert_location_policy": 2, - "telemetry_mode_base": 3, - "telemetry_mode_location": 4, - "telemetry_mode_environment": 5, - "multi_acks": 6, - "advertisement_type": 7 - } - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let other = try #require(config.otherSettings) - - #expect(other.manualAddContacts == 1) - #expect(other.advertLocationPolicy == 2) - #expect(other.telemetryModeBase == 3) - #expect(other.telemetryModeLocation == 4) - #expect(other.telemetryModeEnvironment == 5) - #expect(other.multiAcks == 6) - #expect(other.advertisementType == 7) - - // Round-trip - let encoded = try JSONEncoder().encode(config) - let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) - #expect(decoded.otherSettings == other) - } - - // MARK: - ConfigSections - - @Test("ConfigSections defaults to all false") - func configSectionsDefaults() { - let sections = ConfigSections() - - #expect(!sections.nodeIdentity) - #expect(!sections.radioSettings) - #expect(!sections.positionSettings) - #expect(!sections.otherSettings) - #expect(!sections.channels) - #expect(!sections.contacts) - #expect(!sections.allSelected) - #expect(!sections.anySectionSelected) + @Test + func `Contact with null custom_name decodes as nil`() throws { + let json = Data(""" + { + "contacts": [{ + "type": 1, "name": "Test", "custom_name": null, + "public_key": "aabb", "flags": 0, + "latitude": "0.0", "longitude": "0.0", + "last_advert": 0, "last_modified": 0, + "out_path": null + }] } + """.utf8) - @Test("ConfigSections allSelected is false when any section is false") - func configSectionsPartial() { - var sections = ConfigSections() - sections.selectAll() - sections.channels = false - - #expect(!sections.allSelected) - } + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let contact = try #require(config.contacts?.first) + #expect(contact.customName == nil) + } - @Test("ConfigSections allSelected is false when only one section is true") - func configSectionsMinimal() { - let sections = ConfigSections( - nodeIdentity: false, - radioSettings: false, - positionSettings: false, - otherSettings: false, - channels: true, - contacts: false - ) - - #expect(!sections.allSelected) + @Test + func `Contact with non-null custom_name decodes correctly`() throws { + let json = Data(""" + { + "contacts": [{ + "type": 1, "name": "Test", "custom_name": "My Custom Name", + "public_key": "aabb", "flags": 0, + "latitude": "0.0", "longitude": "0.0", + "last_advert": 0, "last_modified": 0, + "out_path": null + }] } + """.utf8) - @Test("ConfigSections selectAll sets all to true") - func configSectionsSelectAll() { - var sections = ConfigSections() - sections.selectAll() - - #expect(sections.allSelected) - #expect(sections.anySectionSelected) + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let contact = try #require(config.contacts?.first) + #expect(contact.customName == "My Custom Name") + } + + @Test + func `Empty JSON object decodes with all nil fields`() throws { + let json = Data("{}".utf8) + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + + #expect(config.name == nil) + #expect(config.publicKey == nil) + #expect(config.privateKey == nil) + #expect(config.radioSettings == nil) + #expect(config.positionSettings == nil) + #expect(config.otherSettings == nil) + #expect(config.channels == nil) + #expect(config.contacts == nil) + } + + @Test + func `ContactConfig encodes nil customName and outPath as explicit null`() throws { + let contact = MeshCoreNodeConfig.ContactConfig( + type: 1, name: "Test", + publicKey: "aabb", flags: 0, + latitude: "0.0", longitude: "0.0", + lastAdvert: 0, lastModified: 0 + ) + + let data = try JSONEncoder().encode(contact) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // Both keys must be present with NSNull (JSON null) + #expect(json["custom_name"] is NSNull) + #expect(json["out_path"] is NSNull) + } + + @Test + func `ContactConfig encodes non-nil customName and outPath as values`() throws { + let contact = MeshCoreNodeConfig.ContactConfig( + type: 1, name: "Test", customName: "Nick", + publicKey: "aabb", flags: 0, + latitude: "0.0", longitude: "0.0", + lastAdvert: 0, lastModified: 0, + outPath: "aabbcc" + ) + + let data = try JSONEncoder().encode(contact) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(json["custom_name"] as? String == "Nick") + #expect(json["out_path"] as? String == "aabbcc") + } + + @Test + func `Position isZero returns false for non-zero coordinates`() { + let position = MeshCoreNodeConfig.PositionSettings( + latitude: "40.7128", + longitude: "-74.006" + ) + #expect(!position.isZero) + } + + @Test + func `OtherSettings with all 7 fields round-trips correctly`() throws { + let json = Data(""" + { + "other_settings": { + "manual_add_contacts": 1, + "advert_location_policy": 2, + "telemetry_mode_base": 3, + "telemetry_mode_location": 4, + "telemetry_mode_environment": 5, + "multi_acks": 6, + "advertisement_type": 7 + } } + """.utf8) - @Test("ConfigSections deselectAll sets all to false") - func configSectionsDeselectAll() { - var sections = ConfigSections() - sections.selectAll() - sections.deselectAll() - - #expect(!sections.allSelected) - #expect(!sections.anySectionSelected) - } + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let other = try #require(config.otherSettings) + + #expect(other.manualAddContacts == 1) + #expect(other.advertLocationPolicy == 2) + #expect(other.telemetryModeBase == 3) + #expect(other.telemetryModeLocation == 4) + #expect(other.telemetryModeEnvironment == 5) + #expect(other.multiAcks == 6) + #expect(other.advertisementType == 7) + + // Round-trip + let encoded = try JSONEncoder().encode(config) + let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) + #expect(decoded.otherSettings == other) + } + + // MARK: - ConfigSections + + @Test + func `ConfigSections defaults to all false`() { + let sections = ConfigSections() + + #expect(!sections.nodeIdentity) + #expect(!sections.radioSettings) + #expect(!sections.positionSettings) + #expect(!sections.otherSettings) + #expect(!sections.channels) + #expect(!sections.contacts) + #expect(!sections.allSelected) + #expect(!sections.anySectionSelected) + } + + @Test + func `ConfigSections allSelected is false when any section is false`() { + var sections = ConfigSections() + sections.selectAll() + sections.channels = false + + #expect(!sections.allSelected) + } + + @Test + func `ConfigSections allSelected is false when only one section is true`() { + let sections = ConfigSections( + nodeIdentity: false, + radioSettings: false, + positionSettings: false, + otherSettings: false, + channels: true, + contacts: false + ) + + #expect(!sections.allSelected) + } + + @Test + func `ConfigSections selectAll sets all to true`() { + var sections = ConfigSections() + sections.selectAll() + + #expect(sections.allSelected) + #expect(sections.anySectionSelected) + } + + @Test + func `ConfigSections deselectAll sets all to false`() { + var sections = ConfigSections() + sections.selectAll() + sections.deselectAll() + + #expect(!sections.allSelected) + #expect(!sections.anySectionSelected) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NotificationActionHandlerTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NotificationActionHandlerTests.swift index 758cbd30..653b34e7 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NotificationActionHandlerTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NotificationActionHandlerTests.swift @@ -1,172 +1,129 @@ import Foundation -import Testing -import MeshCore @testable import MC1Services - -// MARK: - Helpers - -@MainActor -private func makeServicesAndHandler() async throws -> (ServiceContainer, NotificationActionHandler) { - let session = MeshCoreSession(transport: SimulatorMockTransport()) - let services = try await ServiceContainer.forTesting(session: session) - return (services, services.notificationActionHandler) -} +import MeshCore +import Testing @Suite("NotificationActionHandler Tests") struct NotificationActionHandlerTests { - - /// Minimal provider for channel-name fallback selection tests. - private struct MockStringProvider: NotificationStringProvider { - func discoveryNotificationTitle(for type: ContactType) -> String { "Mock Title" } - var replyActionTitle: String { "Mock Reply" } - var sendButtonTitle: String { "Mock Send" } - var messagePlaceholder: String { "Mock Placeholder" } - var markAsReadActionTitle: String { "Mock Mark as Read" } - var lowBatteryTitle: String { "Mock Low Battery" } - func lowBatteryBody(deviceName: String, percentage: Int) -> String { "Mock Battery" } - var quickReplyFailedTitle: String { "Mock Not Sent" } - func quickReplyFailedBody(conversationName: String) -> String { "Mock Failed" } - var unknownContactName: String { "Mock Unknown" } - - func defaultChannelName(index: Int) -> String { - "Localized Channel \(index)" - } - - func reactionNotificationBody(emoji: String, messagePreview: String) -> String { - "Mock reacted \(emoji) to \(messagePreview)" - } + /// Minimal provider for channel-name fallback selection tests. + private struct MockStringProvider: NotificationStringProvider { + func discoveryNotificationTitle(for type: ContactType) -> String { + "Mock Title" } - @MainActor - private func makeHandler() async throws -> NotificationActionHandler { - let session = MeshCoreSession(transport: SimulatorMockTransport()) - let services = try await ServiceContainer.forTesting(session: session) - return services.notificationActionHandler + var replyActionTitle: String { + "Mock Reply" } - // MARK: - Reaction Configure Guard - - @Test("Handler is not configured before configure() is called") - @MainActor - func handlerNotConfiguredBeforeConfigure() async throws { - let handler = try await makeHandler() - #expect(handler.isConfigured == false) + var sendButtonTitle: String { + "Mock Send" } - @Test("Handler is configured after configure() is called") - @MainActor - func handlerConfiguredAfterConfigure() async throws { - let handler = try await makeHandler() - handler.configure(isConnectionReady: { true }, localNodeName: { nil }) - #expect(handler.isConfigured == true) + var messagePlaceholder: String { + "Mock Placeholder" } - @Test("Reaction notification before configure completes without posting") - @MainActor - func reactionNotificationBeforeConfigureDoesNotPost() async throws { - let (services, handler) = try await makeServicesAndHandler() - #expect(handler.isConfigured == false) - - let radioID = UUID() - let contactID = UUID() - let messageID = UUID() - let message = MessageDTO( - id: messageID, radioID: radioID, contactID: contactID, - channelIndex: nil, text: "Hello", timestamp: 1000, - createdAt: Date(), direction: .outgoing, status: .sent, - textType: .plain, ackCode: nil, pathLength: 0, snr: nil, - senderKeyPrefix: nil, senderNodeName: "Alice", - isRead: true, replyToID: nil, roundTripTime: nil, - heardRepeats: 0, retryAttempt: 0, maxRetryAttempts: 3 - ) - try await services.dataStore.saveMessage(message) - let reaction = ReactionDTO( - messageID: messageID, emoji: "👍", senderName: "Bob", - messageHash: "hash", rawText: "raw", contactID: contactID, radioID: radioID - ) - try await services.dataStore.saveReaction(reaction) - - // Should return early before reaching the notification post path - await handler.handleReactionNotification(messageID: messageID) - // Reaching here confirms no crash and no spurious self-notification + var markAsReadActionTitle: String { + "Mock Mark as Read" } - @Test("Reaction notification after configure is self-suppressed when names match") - @MainActor - func reactionNotificationSelfSuppressedAfterConfigure() async throws { - let (services, handler) = try await makeServicesAndHandler() - let selfName = "Alice" - handler.configure(isConnectionReady: { true }, localNodeName: { selfName }) - #expect(handler.isConfigured == true) - - let radioID = UUID() - let contactID = UUID() - let messageID = UUID() - let message = MessageDTO( - id: messageID, radioID: radioID, contactID: contactID, - channelIndex: nil, text: "Hello", timestamp: 1000, - createdAt: Date(), direction: .outgoing, status: .sent, - textType: .plain, ackCode: nil, pathLength: 0, snr: nil, - senderKeyPrefix: nil, senderNodeName: selfName, - isRead: true, replyToID: nil, roundTripTime: nil, - heardRepeats: 0, retryAttempt: 0, maxRetryAttempts: 3 - ) - try await services.dataStore.saveMessage(message) - let reaction = ReactionDTO( - messageID: messageID, emoji: "👍", senderName: selfName, - messageHash: "hash", rawText: "raw", contactID: contactID, radioID: radioID - ) - try await services.dataStore.saveReaction(reaction) - - // Self-reaction: should be suppressed (senderName == localNodeName) - await handler.handleReactionNotification(messageID: messageID) - // Completing without posting confirms self-suppression works after configure + var lowBatteryTitle: String { + "Mock Low Battery" } - // MARK: - Reaction Preview Truncation - - @Test("Text at 49 characters is returned unchanged") - func previewBelowLimitUnchanged() { - let text = String(repeating: "a", count: 49) - #expect(NotificationActionHandler.reactionPreview(for: text) == text) + func lowBatteryBody(deviceName: String, percentage: Int) -> String { + "Mock Battery" } - @Test("Text at exactly 50 characters is returned unchanged") - func previewAtLimitUnchanged() { - let text = String(repeating: "b", count: 50) - #expect(NotificationActionHandler.reactionPreview(for: text) == text) + var quickReplyFailedTitle: String { + "Mock Not Sent" } - @Test("Text at 51 characters is truncated to 47 plus ellipsis") - func previewOverLimitTruncated() { - let text = String(repeating: "c", count: 51) - let expected = String(repeating: "c", count: 47) + "..." - #expect(NotificationActionHandler.reactionPreview(for: text) == expected) + func quickReplyFailedBody(conversationName: String) -> String { + "Mock Failed" } - // MARK: - Channel Display Name Fallback - - @Test("Stored channel name wins over the localized fallback") - @MainActor - func channelDisplayNamePrefersStoredName() async throws { - let handler = try await makeHandler() - #expect(handler.channelDisplayName(name: "Rescue Net", index: 2) == "Rescue Net") + var unknownContactName: String { + "Mock Unknown" } - @Test("Missing name falls back to the string provider") - @MainActor - func channelDisplayNameUsesProvider() async throws { - let session = MeshCoreSession(transport: SimulatorMockTransport()) - let services = try await ServiceContainer.forTesting(session: session) - services.notificationService.setStringProvider(MockStringProvider()) - let handler = services.notificationActionHandler - #expect(handler.channelDisplayName(name: nil, index: 3) == "Localized Channel 3") + func defaultChannelName(index: Int) -> String { + "Localized Channel \(index)" } - @Test("Missing name and provider fall back to the English literal") - @MainActor - func channelDisplayNameLastResortLiteral() async throws { - let handler = try await makeHandler() - #expect(handler.channelDisplayName(name: nil, index: 7) == "Channel 7") + func reactionNotificationBody(emoji: String, messagePreview: String) -> String { + "Mock reacted \(emoji) to \(messagePreview)" } + } + + @MainActor + private func makeHandler() async throws -> NotificationActionHandler { + let session = MeshCoreSession(transport: SimulatorMockTransport()) + let services = try await ServiceContainer.forTesting(session: session) + return services.notificationActionHandler + } + + // MARK: - Reaction Configure Guard + + @Test + @MainActor + func `Handler is not configured before configure() is called`() async throws { + let handler = try await makeHandler() + #expect(handler.isConfigured == false) + } + + @Test + @MainActor + func `Handler is configured after configure() is called`() async throws { + let handler = try await makeHandler() + handler.configure(isConnectionReady: { true }, localNodeName: { nil }) + #expect(handler.isConfigured == true) + } + + // MARK: - Reaction Preview Truncation + + @Test + func `Text at 49 characters is returned unchanged`() { + let text = String(repeating: "a", count: 49) + #expect(NotificationActionHandler.reactionPreview(for: text) == text) + } + + @Test + func `Text at exactly 50 characters is returned unchanged`() { + let text = String(repeating: "b", count: 50) + #expect(NotificationActionHandler.reactionPreview(for: text) == text) + } + + @Test + func `Text at 51 characters is truncated to 47 plus ellipsis`() { + let text = String(repeating: "c", count: 51) + let expected = String(repeating: "c", count: 47) + "..." + #expect(NotificationActionHandler.reactionPreview(for: text) == expected) + } + + // MARK: - Channel Display Name Fallback + + @Test + @MainActor + func `Stored channel name wins over the localized fallback`() async throws { + let handler = try await makeHandler() + #expect(handler.channelDisplayName(name: "Rescue Net", index: 2) == "Rescue Net") + } + + @Test + @MainActor + func `Missing name falls back to the string provider`() async throws { + let session = MeshCoreSession(transport: SimulatorMockTransport()) + let services = try await ServiceContainer.forTesting(session: session) + services.notificationService.setStringProvider(MockStringProvider()) + let handler = services.notificationActionHandler + #expect(handler.channelDisplayName(name: nil, index: 3) == "Localized Channel 3") + } + + @Test + @MainActor + func `Missing name and provider fall back to the English literal`() async throws { + let handler = try await makeHandler() + #expect(handler.channelDisplayName(name: nil, index: 7) == "Channel 7") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NotificationServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NotificationServiceTests.swift index 1ad398f3..da8d3ddd 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NotificationServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NotificationServiceTests.swift @@ -1,275 +1,217 @@ // NotificationServiceTests.swift import Foundation -import Testing @testable import MC1Services +import Testing @Suite("NotificationService Tests") struct NotificationServiceTests { - - @Test("Suppression flag defaults to false") - @MainActor - func suppressionFlagDefaultsToFalse() async { - let service = NotificationService() - #expect(service.isSuppressingNotifications == false) - } - - @Test("Suppression flag can be set and cleared") - @MainActor - func suppressionFlagCanBeSetAndCleared() async { - let service = NotificationService() - - service.isSuppressingNotifications = true - #expect(service.isSuppressingNotifications == true) - - service.isSuppressingNotifications = false - #expect(service.isSuppressingNotifications == false) - } - - @Test("Suppression flag can be toggled multiple times") - @MainActor - func suppressionFlagCanBeToggledMultipleTimes() async { - let service = NotificationService() - - // Toggle several times - service.isSuppressingNotifications = true - service.isSuppressingNotifications = false - service.isSuppressingNotifications = true - service.isSuppressingNotifications = true // Setting same value - service.isSuppressingNotifications = false - - #expect(service.isSuppressingNotifications == false) - } - - @Test("postNewContactNotification uses provider for title") - @MainActor - func postNewContactNotificationUsesProviderForTitle() async { - // This test verifies the method signature accepts ContactType - // Actual notification posting requires UNUserNotificationCenter authorization - let service = NotificationService() - - // Verify method exists with correct signature (compile-time check) - // The actual notification won't post without authorization, but we can verify - // the provider is called by checking the method accepts the new parameter - await service.postNewContactNotification( - contactName: "TestNode", - contactID: UUID(), - contactType: ContactType.repeater - ) - - // If we got here without compile error, the signature is correct - #expect(true) - } - - // MARK: - Reaction Notification Tests - - @Test("postReactionNotification has correct method signature") - @MainActor - func postReactionNotificationHasCorrectSignature() async { - let service = NotificationService() - - // Verify method exists with correct signature (compile-time check) - // Actual notification won't post without authorization - await service.postReactionNotification( - reactorName: "Alice", - body: "Reacted 👍 to your message: \"Hello world\"", - messageID: UUID(), - contactID: UUID(), - channelIndex: nil, - radioID: nil - ) - - #expect(true) - } - - @Test("postReactionNotification accepts channel parameters") - @MainActor - func postReactionNotificationAcceptsChannelParameters() async { - let service = NotificationService() - - // Verify method accepts channel parameters for channel reactions - await service.postReactionNotification( - reactorName: "Bob", - body: "Reacted ❤️ to your message: \"Team update\"", - messageID: UUID(), - contactID: nil, - channelIndex: 3, - radioID: UUID() - ) - - #expect(true) - } - - @Test("onReactionNotificationTapped callback can be set") - @MainActor - func onReactionNotificationTappedCallbackCanBeSet() async { - let service = NotificationService() - var callbackInvoked = false - - service.onReactionNotificationTapped = { _, _, _, _ in - callbackInvoked = true - } - - // Verify callback is settable - #expect(service.onReactionNotificationTapped != nil) - - // Invoke callback to verify it works - await service.onReactionNotificationTapped?(UUID(), nil, nil, UUID()) - #expect(callbackInvoked) - } - - @Test("onReactionNotificationTapped receives all parameters") - @MainActor - func onReactionNotificationTappedReceivesAllParameters() async { - let service = NotificationService() - let expectedContactID = UUID() - let expectedChannelIndex: UInt8 = 5 - let expectedDeviceID = UUID() - let expectedMessageID = UUID() - - var receivedContactID: UUID? - var receivedChannelIndex: UInt8? - var receivedDeviceID: UUID? - var receivedMessageID: UUID? - - service.onReactionNotificationTapped = { contactID, channelIndex, radioID, messageID in - receivedContactID = contactID - receivedChannelIndex = channelIndex - receivedDeviceID = radioID - receivedMessageID = messageID - } - - await service.onReactionNotificationTapped?( - expectedContactID, - expectedChannelIndex, - expectedDeviceID, - expectedMessageID - ) - - #expect(receivedContactID == expectedContactID) - #expect(receivedChannelIndex == expectedChannelIndex) - #expect(receivedDeviceID == expectedDeviceID) - #expect(receivedMessageID == expectedMessageID) - } - - @Test("Room message notification is suppressed when isSuppressingNotifications is true") - @MainActor - func roomMessageNotificationSuppressedDuringSync() async { - let service = NotificationService() - service.isSuppressingNotifications = true - - // Should return without posting (no crash, no notification) - await service.postRoomMessageNotification( - roomName: "TestRoom", - sessionID: UUID(), - senderName: "Alice", - messageText: "Hello", - messageID: UUID(), - notificationLevel: .all - ) - - // Badge count should not increment when suppressed - #expect(service.badgeCount == 0) - } - - @Test("Notification category includes reaction") - func notificationCategoryIncludesReaction() { - // Verify reaction category exists in the enum - let category = NotificationCategory.reaction - #expect(category.rawValue == "REACTION") + @Test + @MainActor + func `Suppression flag defaults to false`() { + let service = NotificationService() + #expect(service.isSuppressingNotifications == false) + } + + @Test + @MainActor + func `Suppression flag can be set and cleared`() { + let service = NotificationService() + + service.isSuppressingNotifications = true + #expect(service.isSuppressingNotifications == true) + + service.isSuppressingNotifications = false + #expect(service.isSuppressingNotifications == false) + } + + @Test + @MainActor + func `Suppression flag can be toggled multiple times`() { + let service = NotificationService() + + // Toggle several times + service.isSuppressingNotifications = true + service.isSuppressingNotifications = false + service.isSuppressingNotifications = true + service.isSuppressingNotifications = true // Setting same value + service.isSuppressingNotifications = false + + #expect(service.isSuppressingNotifications == false) + } + + // MARK: - Reaction Notification Tests + + @Test + @MainActor + func `onReactionNotificationTapped callback can be set`() async { + let service = NotificationService() + var callbackInvoked = false + + service.onReactionNotificationTapped = { _, _, _, _ in + callbackInvoked = true } - // MARK: - Room Notification Tests - - @Test("Active room session tracking can be set and cleared") - @MainActor - func activeRoomSessionTrackingCanBeSetAndCleared() async { - let service = NotificationService() - let sessionID = UUID() - - #expect(service.activeRoomSessionID == nil) - - service.activeRoomSessionID = sessionID - #expect(service.activeRoomSessionID == sessionID) - - service.activeRoomSessionID = nil - #expect(service.activeRoomSessionID == nil) + // Verify callback is settable + #expect(service.onReactionNotificationTapped != nil) + + // Invoke callback to verify it works + await service.onReactionNotificationTapped?(UUID(), nil, nil, UUID()) + #expect(callbackInvoked) + } + + @Test + @MainActor + func `onReactionNotificationTapped receives all parameters`() async { + let service = NotificationService() + let expectedContactID = UUID() + let expectedChannelIndex: UInt8 = 5 + let expectedDeviceID = UUID() + let expectedMessageID = UUID() + + var receivedContactID: UUID? + var receivedChannelIndex: UInt8? + var receivedDeviceID: UUID? + var receivedMessageID: UUID? + + service.onReactionNotificationTapped = { contactID, channelIndex, radioID, messageID in + receivedContactID = contactID + receivedChannelIndex = channelIndex + receivedDeviceID = radioID + receivedMessageID = messageID } - @Test("setActiveConversation populates only the passed slot and clears the rest") - @MainActor - func setActiveConversationIsAtomicAcrossTypes() async { - let service = NotificationService() - let contactID = UUID() - let channelRadioID = UUID() - let roomSessionID = UUID() - - // Pre-populate every slot so the setter must clear the unpassed ones. - service.activeContactID = contactID - service.activeChannelIndex = 3 - service.activeChannelRadioID = channelRadioID - service.activeRoomSessionID = roomSessionID - - // Opening a DM clears channel and room slots. - service.setActiveConversation(contactID: contactID) - #expect(service.activeContactID == contactID) - #expect(service.activeChannelIndex == nil) - #expect(service.activeChannelRadioID == nil) - #expect(service.activeRoomSessionID == nil) - - // Opening a channel clears the contact slot. - service.setActiveConversation(channelIndex: 5, channelRadioID: channelRadioID) - #expect(service.activeContactID == nil) - #expect(service.activeChannelIndex == 5) - #expect(service.activeChannelRadioID == channelRadioID) - #expect(service.activeRoomSessionID == nil) - - // Opening a room clears the channel slots. - service.setActiveConversation(roomSessionID: roomSessionID) - #expect(service.activeContactID == nil) - #expect(service.activeChannelIndex == nil) - #expect(service.activeChannelRadioID == nil) - #expect(service.activeRoomSessionID == roomSessionID) + await service.onReactionNotificationTapped?( + expectedContactID, + expectedChannelIndex, + expectedDeviceID, + expectedMessageID + ) + + #expect(receivedContactID == expectedContactID) + #expect(receivedChannelIndex == expectedChannelIndex) + #expect(receivedDeviceID == expectedDeviceID) + #expect(receivedMessageID == expectedMessageID) + } + + @Test + @MainActor + func `Room message notification is suppressed when isSuppressingNotifications is true`() async { + let service = NotificationService() + service.isSuppressingNotifications = true + + // Should return without posting (no crash, no notification) + await service.postRoomMessageNotification( + roomName: "TestRoom", + sessionID: UUID(), + senderName: "Alice", + messageText: "Hello", + messageID: UUID(), + notificationLevel: .all + ) + + // Badge count should not increment when suppressed + #expect(service.badgeCount == 0) + } + + @Test + func `Notification category includes reaction`() { + // Verify reaction category exists in the enum + let category = NotificationCategory.reaction + #expect(category.rawValue == "REACTION") + } + + // MARK: - Room Notification Tests + + @Test + @MainActor + func `Active room session tracking can be set and cleared`() { + let service = NotificationService() + let sessionID = UUID() + + #expect(service.activeRoomSessionID == nil) + + service.activeRoomSessionID = sessionID + #expect(service.activeRoomSessionID == sessionID) + + service.activeRoomSessionID = nil + #expect(service.activeRoomSessionID == nil) + } + + @Test + @MainActor + func `setActiveConversation populates only the passed slot and clears the rest`() { + let service = NotificationService() + let contactID = UUID() + let channelRadioID = UUID() + let roomSessionID = UUID() + + // Pre-populate every slot so the setter must clear the unpassed ones. + service.activeContactID = contactID + service.activeChannelIndex = 3 + service.activeChannelRadioID = channelRadioID + service.activeRoomSessionID = roomSessionID + + // Opening a DM clears channel and room slots. + service.setActiveConversation(contactID: contactID) + #expect(service.activeContactID == contactID) + #expect(service.activeChannelIndex == nil) + #expect(service.activeChannelRadioID == nil) + #expect(service.activeRoomSessionID == nil) + + // Opening a channel clears the contact slot. + service.setActiveConversation(channelIndex: 5, channelRadioID: channelRadioID) + #expect(service.activeContactID == nil) + #expect(service.activeChannelIndex == 5) + #expect(service.activeChannelRadioID == channelRadioID) + #expect(service.activeRoomSessionID == nil) + + // Opening a room clears the channel slots. + service.setActiveConversation(roomSessionID: roomSessionID) + #expect(service.activeContactID == nil) + #expect(service.activeChannelIndex == nil) + #expect(service.activeChannelRadioID == nil) + #expect(service.activeRoomSessionID == roomSessionID) + } + + @Test + @MainActor + func `onRoomMarkAsRead callback can be set and receives parameters`() async { + let service = NotificationService() + let expectedSessionID = UUID() + let expectedMessageID = UUID() + + var receivedSessionID: UUID? + var receivedMessageID: UUID? + + service.onRoomMarkAsRead = { sessionID, messageID in + receivedSessionID = sessionID + receivedMessageID = messageID } - @Test("onRoomMarkAsRead callback can be set and receives parameters") - @MainActor - func onRoomMarkAsReadCallbackReceivesParameters() async { - let service = NotificationService() - let expectedSessionID = UUID() - let expectedMessageID = UUID() + #expect(service.onRoomMarkAsRead != nil) - var receivedSessionID: UUID? - var receivedMessageID: UUID? + await service.onRoomMarkAsRead?(expectedSessionID, expectedMessageID) - service.onRoomMarkAsRead = { sessionID, messageID in - receivedSessionID = sessionID - receivedMessageID = messageID - } + #expect(receivedSessionID == expectedSessionID) + #expect(receivedMessageID == expectedMessageID) + } - #expect(service.onRoomMarkAsRead != nil) + @Test + @MainActor + func `onRoomNotificationTapped callback can be set and receives the session ID`() async { + let service = NotificationService() + let expectedSessionID = UUID() - await service.onRoomMarkAsRead?(expectedSessionID, expectedMessageID) + var receivedSessionID: UUID? - #expect(receivedSessionID == expectedSessionID) - #expect(receivedMessageID == expectedMessageID) + service.onRoomNotificationTapped = { sessionID in + receivedSessionID = sessionID } - @Test("onRoomNotificationTapped callback can be set and receives the session ID") - @MainActor - func onRoomNotificationTappedCallbackReceivesSessionID() async { - let service = NotificationService() - let expectedSessionID = UUID() + #expect(service.onRoomNotificationTapped != nil) - var receivedSessionID: UUID? + await service.onRoomNotificationTapped?(expectedSessionID) - service.onRoomNotificationTapped = { sessionID in - receivedSessionID = sessionID - } - - #expect(service.onRoomNotificationTapped != nil) - - await service.onRoomNotificationTapped?(expectedSessionID) - - #expect(receivedSessionID == expectedSessionID) - } + #expect(receivedSessionID == expectedSessionID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NotificationStringProviderTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NotificationStringProviderTests.swift index 7a18a050..65a91bb1 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NotificationStringProviderTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NotificationStringProviderTests.swift @@ -1,86 +1,14 @@ import Foundation -import Testing @testable import MC1Services +import Testing struct NotificationStringProviderTests { - - /// Mock implementation for testing - struct MockStringProvider: NotificationStringProvider { - func discoveryNotificationTitle(for type: ContactType) -> String { - switch type { - case .chat: "Mock Contact Title" - case .repeater: "Mock Repeater Title" - case .room: "Mock Room Title" - } - } - - var replyActionTitle: String { "Mock Reply" } - var sendButtonTitle: String { "Mock Send" } - var messagePlaceholder: String { "Mock Placeholder" } - var markAsReadActionTitle: String { "Mock Mark as Read" } - var lowBatteryTitle: String { "Mock Low Battery" } - - func lowBatteryBody(deviceName: String, percentage: Int) -> String { - "Mock \(deviceName) at \(percentage)%" - } - - var quickReplyFailedTitle: String { "Mock Not Sent" } - - func quickReplyFailedBody(conversationName: String) -> String { - "Mock reply to \(conversationName) failed" - } - - var unknownContactName: String { "Mock Unknown Contact" } - - func defaultChannelName(index: Int) -> String { - "Mock Channel \(index)" - } - - func reactionNotificationBody(emoji: String, messagePreview: String) -> String { - "Mock reacted \(emoji) to \(messagePreview)" - } - } - - @Test("Provider returns correct title for chat type") - func providerReturnsChatTitle() { - let provider = MockStringProvider() - let title = provider.discoveryNotificationTitle(for: .chat) - #expect(title == "Mock Contact Title") - } - - @Test("Provider returns correct title for repeater type") - func providerReturnsRepeaterTitle() { - let provider = MockStringProvider() - let title = provider.discoveryNotificationTitle(for: .repeater) - #expect(title == "Mock Repeater Title") - } - - @Test("Provider returns correct title for room type") - func providerReturnsRoomTitle() { - let provider = MockStringProvider() - let title = provider.discoveryNotificationTitle(for: .room) - #expect(title == "Mock Room Title") - } - - @Test("Provider returns correct low battery title") - func providerReturnsLowBatteryTitle() { - let provider = MockStringProvider() - #expect(provider.lowBatteryTitle == "Mock Low Battery") - } - - @Test("Provider returns correct low battery body with device name and percentage") - func providerReturnsLowBatteryBody() { - let provider = MockStringProvider() - let body = provider.lowBatteryBody(deviceName: "Node-7", percentage: 15) - #expect(body == "Mock Node-7 at 15%") - } - - @Test("Default fallback titles are English") - @MainActor - func defaultFallbackTitlesAreEnglish() { - let service = NotificationService() - #expect(service.defaultDiscoveryTitle(for: .chat) == "New Contact Discovered") - #expect(service.defaultDiscoveryTitle(for: .repeater) == "New Repeater Discovered") - #expect(service.defaultDiscoveryTitle(for: .room) == "New Room Discovered") - } + @Test + @MainActor + func `Default fallback titles are English`() { + let service = NotificationService() + #expect(service.defaultDiscoveryTitle(for: .chat) == "New Contact Discovered") + #expect(service.defaultDiscoveryTitle(for: .repeater) == "New Repeater Discovered") + #expect(service.defaultDiscoveryTitle(for: .room) == "New Room Discovered") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ReactionParserTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ReactionParserTests.swift index e026a85c..43a8801a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ReactionParserTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ReactionParserTests.swift @@ -1,368 +1,367 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ReactionParser Tests") struct ReactionParserTests { - - // MARK: - Valid Format Tests - - @Test("Parses simple reaction with thumbs up") - func parsesSimpleReaction() { - let text = "👍@[AlphaNode]\n7f3a9c12" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "👍") - #expect(result?.targetSender == "AlphaNode") - #expect(result?.messageHash == "7f3a9c12") - } - - @Test("Parses reaction with heart emoji") - func parsesHeartReaction() { - let text = "❤️@[BetaNode]\ne4d8b1a0" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "❤️") - #expect(result?.targetSender == "BetaNode") - #expect(result?.messageHash == "e4d8b1a0") - } - - @Test("Parses reaction with uppercase identifier and normalizes to lowercase") - func parsesUppercaseIdentifier() { - let text = "👍@[Node]\nABCDEF12" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.messageHash == "abcdef12") - } - - @Test("Parses reaction with mixed case identifier") - func parsesMixedCaseIdentifier() { - let text = "👍@[Node]\nAbCdEf12" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.messageHash == "abcdef12") - } - - // MARK: - Crockford Base32 Identifier Tests - - @Test("Generates 8-character Crockford Base32 identifier") - func generatesEightCharBase32() { - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - #expect(hash.count == 8) - // Verify all characters are valid Crockford Base32 (lowercase) - let validChars = CharacterSet(charactersIn: "0123456789abcdefghjkmnpqrstvwxyz") - #expect(hash.unicodeScalars.allSatisfy { validChars.contains($0) }) - } - - @Test("Same input produces same identifier") - func sameInputSameHash() { - let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let hash2 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - #expect(hash1 == hash2) - } - - @Test("Different text produces different identifier") - func differentTextDifferentHash() { - let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let hash2 = ReactionParser.generateMessageHash(text: "World", timestamp: 1704067200) - #expect(hash1 != hash2) - } - - @Test("Different timestamp produces different identifier") - func differentTimestampDifferentHash() { - let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let hash2 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067201) - #expect(hash1 != hash2) - } - - @Test("Crockford O is decoded as 0") - func crockfordODecodesAsZero() { - let text = "👍@[Node]\nOOOOOOOO" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.messageHash == "00000000") - } - - @Test("Crockford I/L are decoded as 1") - func crockfordILDecodeAsOne() { - let textI = "👍@[Node]\niiiiiiii" - let resultI = ReactionParser.parse(textI) - #expect(resultI?.messageHash == "11111111") - - let textL = "👍@[Node]\nLLLLLLLL" - let resultL = ReactionParser.parse(textL) - #expect(resultL?.messageHash == "11111111") - } - - // MARK: - Edge Cases - - @Test("Parses sender name containing colon") - func parsesSenderWithColon() { - let text = "👍@[Node:Alpha]\na1b2c3d4" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.targetSender == "Node:Alpha") - } - - // MARK: - Invalid Format Tests - - @Test("Returns nil for plain text message") - func returnsNilForPlainText() { - let text = "Just a normal message" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for missing identifier") - func returnsNilForMissingHash() { - let text = "👍@[Node]" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for missing @ symbol") - func returnsNilForMissingAt() { - let text = "👍 [Node]\na1b2c3d4" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for missing brackets around sender") - func returnsNilForMissingBrackets() { - let text = "👍@Node\na1b2c3d4" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for invalid identifier length") - func returnsNilForInvalidHashLength() { - let text = "👍@[Node]\nabc" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for invalid Crockford characters (U)") - func returnsNilForInvalidCrockfordU() { - let text = "👍@[Node]\nuuuuuuuu" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for empty sender") - func returnsNilForEmptySender() { - let text = "👍@[]\na1b2c3d4" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for text not starting with emoji") - func returnsNilForNonEmojiStart() { - let text = "A@[Node]\na1b2c3d4" - #expect(ReactionParser.parse(text) == nil) - } - - // MARK: - ZWJ Emoji Tests - - @Test("Parses reaction with skin tone modifier") - func parsesEmojiWithSkinTone() { - let text = "👍🏽@[Node]\na1b2c3d4" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "👍🏽") - } - - @Test("Parses reaction with family ZWJ emoji") - func parsesFamilyEmoji() { - let text = "👨‍👩‍👧@[Node]\na1b2c3d4" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "👨‍👩‍👧") - } - - @Test("Parses reaction with flag emoji") - func parsesFlagEmoji() { - let text = "🇺🇸@[Node]\na1b2c3d4" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "🇺🇸") - } - - // MARK: - Summary Cache Tests - - @Test("Builds summary from reactions") - func buildsSummary() { - let reactions = [ - ("👍", 3), - ("❤️", 2), - ("😂", 1) - ] - let summary = ReactionParser.buildSummary(from: reactions) - #expect(summary == "👍:3,❤️:2,😂:1") - } - - @Test("Parses summary string") - func parsesSummary() { - let summary = "👍:3,❤️:2,😂:1" - let parsed = ReactionParser.parseSummary(summary) - - #expect(parsed.count == 3) - #expect(parsed[0] == ("👍", 3)) - #expect(parsed[1] == ("❤️", 2)) - #expect(parsed[2] == ("😂", 1)) - } - - @Test("Parses empty summary") - func parsesEmptySummary() { - let parsed = ReactionParser.parseSummary(nil) - #expect(parsed.isEmpty) - } - - @Test("Sorts summary by count descending") - func sortsSummaryByCount() { - let reactions = [ - ("😂", 1), - ("👍", 5), - ("❤️", 3) - ] - let summary = ReactionParser.buildSummary(from: reactions) - #expect(summary == "👍:5,❤️:3,😂:1") - } - - // MARK: - ReactionDTO DM Support Tests - - @Test("ReactionDTO can be created with contactID for DMs") - func reactionDTOWithContactID() { - let contactID = UUID() - let radioID = UUID() - let messageID = UUID() - - let dto = ReactionDTO( - messageID: messageID, - emoji: "👍", - senderName: "TestNode", - messageHash: "a1b2c3d4", - rawText: "👍@[TestNode]\na1b2c3d4", - contactID: contactID, - radioID: radioID - ) - - #expect(dto.contactID == contactID) - #expect(dto.channelIndex == nil) - } - - @Test("ReactionDTO can be created with channelIndex for channels") - func reactionDTOWithChannelIndex() { - let radioID = UUID() - let messageID = UUID() - - let dto = ReactionDTO( - messageID: messageID, - emoji: "👍", - senderName: "TestNode", - messageHash: "a1b2c3d4", - rawText: "👍@[TestNode]\na1b2c3d4", - channelIndex: 5, - radioID: radioID - ) - - #expect(dto.channelIndex == 5) - #expect(dto.contactID == nil) - } - - // MARK: - DM Reaction Format Tests - - @Test("Parses DM reaction format without sender") - func parsesDMReaction() { - let text = "👍\n7f3a9c12" - let result = ReactionParser.parseDM(text) - - #expect(result != nil) - #expect(result?.emoji == "👍") - #expect(result?.messageHash == "7f3a9c12") - } - - @Test("Parses DM reaction with heart emoji") - func parsesDMHeartReaction() { - let text = "❤️\ne4d8b1a0" - let result = ReactionParser.parseDM(text) - - #expect(result != nil) - #expect(result?.emoji == "❤️") - } - - @Test("Returns nil for DM format missing hash") - func returnsNilForDMMissingHash() { - let text = "👍" - #expect(ReactionParser.parseDM(text) == nil) - } - - @Test("DM parser rejects channel format") - func dmParserRejectsChannelFormat() { - let text = "👍@[Node]\nabcd1234" - #expect(ReactionParser.parseDM(text) == nil) - } - - @Test("Builds DM reaction text correctly") - func buildsDMReactionText() { - let text = ReactionParser.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: 1704067200 - ) - #expect(text.hasPrefix("👍\n")) - #expect(text.count == 10) // emoji (grapheme cluster) + newline + 8 char hash - #expect(!text.contains("@[")) - } - - @Test("Parses DM reaction with uppercase hash and normalizes to lowercase") - func parsesDMUppercaseHash() { - let text = "👍\nABCDEF12" - let result = ReactionParser.parseDM(text) - - #expect(result != nil) - #expect(result?.messageHash == "abcdef12") - } - - @Test("DM parser rejects invalid Crockford characters") - func dmParserRejectsInvalidCrockford() { - let text = "👍\nuuuuuuuu" - #expect(ReactionParser.parseDM(text) == nil) - } - - @Test("DM parser rejects non-emoji start") - func dmParserRejectsNonEmojiStart() { - let text = "A\na1b2c3d4" - #expect(ReactionParser.parseDM(text) == nil) - } - - @Test("DM parser handles skin tone modifier emoji") - func dmParserHandlesSkinToneEmoji() { - let text = "👍🏽\na1b2c3d4" - let result = ReactionParser.parseDM(text) - - #expect(result != nil) - #expect(result?.emoji == "👍🏽") - } - - @Test("DM round-trip: build then parse produces same emoji and hash") - func dmRoundTrip() { - let originalEmoji = "👍" - let targetText = "Hello world" - let timestamp: UInt32 = 1704067200 - - let text = ReactionParser.buildDMReactionText( - emoji: originalEmoji, - targetText: targetText, - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parseDM(text) - #expect(parsed != nil) - #expect(parsed?.emoji == originalEmoji) - - let expectedHash = ReactionParser.generateMessageHash(text: targetText, timestamp: timestamp) - #expect(parsed?.messageHash == expectedHash) - } + // MARK: - Valid Format Tests + + @Test + func `Parses simple reaction with thumbs up`() { + let text = "👍@[AlphaNode]\n7f3a9c12" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.targetSender == "AlphaNode") + #expect(result?.messageHash == "7f3a9c12") + } + + @Test + func `Parses reaction with heart emoji`() { + let text = "❤️@[BetaNode]\ne4d8b1a0" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "❤️") + #expect(result?.targetSender == "BetaNode") + #expect(result?.messageHash == "e4d8b1a0") + } + + @Test + func `Parses reaction with uppercase identifier and normalizes to lowercase`() { + let text = "👍@[Node]\nABCDEF12" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.messageHash == "abcdef12") + } + + @Test + func `Parses reaction with mixed case identifier`() { + let text = "👍@[Node]\nAbCdEf12" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.messageHash == "abcdef12") + } + + // MARK: - Crockford Base32 Identifier Tests + + @Test + func `Generates 8-character Crockford Base32 identifier`() { + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + #expect(hash.count == 8) + // Verify all characters are valid Crockford Base32 (lowercase) + let validChars = CharacterSet(charactersIn: "0123456789abcdefghjkmnpqrstvwxyz") + #expect(hash.unicodeScalars.allSatisfy { validChars.contains($0) }) + } + + @Test + func `Same input produces same identifier`() { + let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let hash2 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + #expect(hash1 == hash2) + } + + @Test + func `Different text produces different identifier`() { + let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let hash2 = ReactionParser.generateMessageHash(text: "World", timestamp: 1_704_067_200) + #expect(hash1 != hash2) + } + + @Test + func `Different timestamp produces different identifier`() { + let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let hash2 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_201) + #expect(hash1 != hash2) + } + + @Test + func `Crockford O is decoded as 0`() { + let text = "👍@[Node]\nOOOOOOOO" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.messageHash == "00000000") + } + + @Test + func `Crockford I/L are decoded as 1`() { + let textI = "👍@[Node]\niiiiiiii" + let resultI = ReactionParser.parse(textI) + #expect(resultI?.messageHash == "11111111") + + let textL = "👍@[Node]\nLLLLLLLL" + let resultL = ReactionParser.parse(textL) + #expect(resultL?.messageHash == "11111111") + } + + // MARK: - Edge Cases + + @Test + func `Parses sender name containing colon`() { + let text = "👍@[Node:Alpha]\na1b2c3d4" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.targetSender == "Node:Alpha") + } + + // MARK: - Invalid Format Tests + + @Test + func `Returns nil for plain text message`() { + let text = "Just a normal message" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for missing identifier`() { + let text = "👍@[Node]" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for missing @ symbol`() { + let text = "👍 [Node]\na1b2c3d4" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for missing brackets around sender`() { + let text = "👍@Node\na1b2c3d4" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for invalid identifier length`() { + let text = "👍@[Node]\nabc" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for invalid Crockford characters (U)`() { + let text = "👍@[Node]\nuuuuuuuu" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for empty sender`() { + let text = "👍@[]\na1b2c3d4" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for text not starting with emoji`() { + let text = "A@[Node]\na1b2c3d4" + #expect(ReactionParser.parse(text) == nil) + } + + // MARK: - ZWJ Emoji Tests + + @Test + func `Parses reaction with skin tone modifier`() { + let text = "👍🏽@[Node]\na1b2c3d4" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "👍🏽") + } + + @Test + func `Parses reaction with family ZWJ emoji`() { + let text = "👨‍👩‍👧@[Node]\na1b2c3d4" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "👨‍👩‍👧") + } + + @Test + func `Parses reaction with flag emoji`() { + let text = "🇺🇸@[Node]\na1b2c3d4" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "🇺🇸") + } + + // MARK: - Summary Cache Tests + + @Test + func `Builds summary from reactions`() { + let reactions = [ + ("👍", 3), + ("❤️", 2), + ("😂", 1) + ] + let summary = ReactionParser.buildSummary(from: reactions) + #expect(summary == "👍:3,❤️:2,😂:1") + } + + @Test + func `Parses summary string`() { + let summary = "👍:3,❤️:2,😂:1" + let parsed = ReactionParser.parseSummary(summary) + + #expect(parsed.count == 3) + #expect(parsed[0] == ("👍", 3)) + #expect(parsed[1] == ("❤️", 2)) + #expect(parsed[2] == ("😂", 1)) + } + + @Test + func `Parses empty summary`() { + let parsed = ReactionParser.parseSummary(nil) + #expect(parsed.isEmpty) + } + + @Test + func `Sorts summary by count descending`() { + let reactions = [ + ("😂", 1), + ("👍", 5), + ("❤️", 3) + ] + let summary = ReactionParser.buildSummary(from: reactions) + #expect(summary == "👍:5,❤️:3,😂:1") + } + + // MARK: - ReactionDTO DM Support Tests + + @Test + func `ReactionDTO can be created with contactID for DMs`() { + let contactID = UUID() + let radioID = UUID() + let messageID = UUID() + + let dto = ReactionDTO( + messageID: messageID, + emoji: "👍", + senderName: "TestNode", + messageHash: "a1b2c3d4", + rawText: "👍@[TestNode]\na1b2c3d4", + contactID: contactID, + radioID: radioID + ) + + #expect(dto.contactID == contactID) + #expect(dto.channelIndex == nil) + } + + @Test + func `ReactionDTO can be created with channelIndex for channels`() { + let radioID = UUID() + let messageID = UUID() + + let dto = ReactionDTO( + messageID: messageID, + emoji: "👍", + senderName: "TestNode", + messageHash: "a1b2c3d4", + rawText: "👍@[TestNode]\na1b2c3d4", + channelIndex: 5, + radioID: radioID + ) + + #expect(dto.channelIndex == 5) + #expect(dto.contactID == nil) + } + + // MARK: - DM Reaction Format Tests + + @Test + func `Parses DM reaction format without sender`() { + let text = "👍\n7f3a9c12" + let result = ReactionParser.parseDM(text) + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.messageHash == "7f3a9c12") + } + + @Test + func `Parses DM reaction with heart emoji`() { + let text = "❤️\ne4d8b1a0" + let result = ReactionParser.parseDM(text) + + #expect(result != nil) + #expect(result?.emoji == "❤️") + } + + @Test + func `Returns nil for DM format missing hash`() { + let text = "👍" + #expect(ReactionParser.parseDM(text) == nil) + } + + @Test + func `DM parser rejects channel format`() { + let text = "👍@[Node]\nabcd1234" + #expect(ReactionParser.parseDM(text) == nil) + } + + @Test + func `Builds DM reaction text correctly`() { + let text = ReactionParser.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: 1_704_067_200 + ) + #expect(text.hasPrefix("👍\n")) + #expect(text.count == 10) // emoji (grapheme cluster) + newline + 8 char hash + #expect(!text.contains("@[")) + } + + @Test + func `Parses DM reaction with uppercase hash and normalizes to lowercase`() { + let text = "👍\nABCDEF12" + let result = ReactionParser.parseDM(text) + + #expect(result != nil) + #expect(result?.messageHash == "abcdef12") + } + + @Test + func `DM parser rejects invalid Crockford characters`() { + let text = "👍\nuuuuuuuu" + #expect(ReactionParser.parseDM(text) == nil) + } + + @Test + func `DM parser rejects non-emoji start`() { + let text = "A\na1b2c3d4" + #expect(ReactionParser.parseDM(text) == nil) + } + + @Test + func `DM parser handles skin tone modifier emoji`() { + let text = "👍🏽\na1b2c3d4" + let result = ReactionParser.parseDM(text) + + #expect(result != nil) + #expect(result?.emoji == "👍🏽") + } + + @Test + func `DM round-trip: build then parse produces same emoji and hash`() { + let originalEmoji = "👍" + let targetText = "Hello world" + let timestamp: UInt32 = 1_704_067_200 + + let text = ReactionParser.buildDMReactionText( + emoji: originalEmoji, + targetText: targetText, + targetTimestamp: timestamp + ) + + let parsed = ReactionParser.parseDM(text) + #expect(parsed != nil) + #expect(parsed?.emoji == originalEmoji) + + let expectedHash = ReactionParser.generateMessageHash(text: targetText, timestamp: timestamp) + #expect(parsed?.messageHash == expectedHash) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ReactionServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ReactionServiceTests.swift index 3bbc833f..81ccd778 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ReactionServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ReactionServiceTests.swift @@ -1,583 +1,582 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ReactionService Tests") struct ReactionServiceTests { - - @Test("Builds correct wire format with Crockford Base32 identifier") - func buildsWireFormat() async { - let service = ReactionService() - let timestamp: UInt32 = 1704067200 - - let text = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "What's the situation at Main St today?", - targetTimestamp: timestamp - ) - - // Verify format: {emoji}@[{sender}]\n{hash} - #expect(text.hasPrefix("👍@[AlphaNode]\n")) - - // Verify 8-char Crockford Base32 identifier is present (lowercase) at end - let idPattern = #/\n([0-9a-hj-km-np-tv-z]{8})$/# - #expect(text.firstMatch(of: idPattern) != nil) - } - - @Test("Builds wire format with short message") - func buildsWireFormatShortMessage() async { - let service = ReactionService() - let timestamp: UInt32 = 1704067200 - - let text = service.buildReactionText( - emoji: "❤️", - targetSender: "Node", - targetText: "ok", - targetTimestamp: timestamp - ) - - #expect(text.hasPrefix("❤️@[Node]\n")) - #expect(text.hasSuffix(text.suffix(8))) // ends with 8-char hash - } - - @Test("Generated identifier is consistent") - func generatedIdentifierIsConsistent() async { - let service = ReactionService() - let timestamp: UInt32 = 1704067200 - let targetText = "Hello world" - - let text1 = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: targetText, - targetTimestamp: timestamp - ) - - let text2 = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: targetText, - targetTimestamp: timestamp - ) - - #expect(text1 == text2) - } - - @Test("Different timestamps produce different identifiers") - func differentTimestampsDifferentIdentifiers() async { - let service = ReactionService() - let targetText = "Hello world" - - let text1 = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: targetText, - targetTimestamp: 1704067200 - ) - - let text2 = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: targetText, - targetTimestamp: 1704067201 - ) - - #expect(text1 != text2) - } - - // MARK: - Disambiguation Tests - - @Test("Finds indexed message by hash and preview") - func findsIndexedMessage() async { - let service = ReactionService() - let messageID = UUID() - let timestamp: UInt32 = 1704067200 - - await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello world", - timestamp: timestamp - ) - - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: "Hello world", - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parse(reactionText)! - let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) - - #expect(foundID == messageID) - } - - @Test("Returns nil when no candidates exist") - func returnsNilWhenNoCandidates() async { - let service = ReactionService() - - let parsed = ParsedReaction( - emoji: "👍", - targetSender: "Node", - messageHash: "abcd1234" - ) - - let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) - - #expect(foundID == nil) - } - - @Test("Returns most recently indexed when multiple candidates have same hash") - func returnsMostRecentWhenMultipleCandidates() async { - let service = ReactionService() - let id1 = UUID() - let id2 = UUID() - let timestamp: UInt32 = 1704067200 - - // Index two messages with same hash (same text and timestamp) - _ = await service.indexMessage( - id: id1, - channelIndex: 0, - senderName: "Node", - text: "Same message", - timestamp: timestamp - ) - - // Small delay to ensure different indexedAt times - try? await Task.sleep(for: .milliseconds(10)) - - _ = await service.indexMessage( - id: id2, - channelIndex: 0, - senderName: "Node", - text: "Same message", - timestamp: timestamp - ) - - // Build reaction for the message - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: "Same message", - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parse(reactionText)! - let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) - - // Should find the most recently indexed (id2) - #expect(foundID == id2) - } - - // MARK: - Pending Reactions Queue Tests - - @Test("Queued reaction matches when message indexed") - func queuedReactionMatchesWhenMessageIndexed() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Build reaction text for a message that doesn't exist yet - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parse(reactionText)! - - // Queue the reaction (target message not indexed yet) - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - - // Now index the target message - should return the pending reaction - let matches = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.count == 1) - #expect(matches.first?.parsed.emoji == "👍") - #expect(matches.first?.senderNodeName == "BetaNode") - } - - @Test("Multiple reactions for same target all match") - func multipleReactionsForSameTargetAllMatch() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue multiple reactions for the same message - for emoji in ["👍", "❤️", "😂"] { - let reactionText = service.buildReactionText( - emoji: emoji, - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parse(reactionText)! - - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - } - - // Index the target message - should return all pending reactions - let matches = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.count == 3) - let emojis = Set(matches.map { $0.parsed.emoji }) - #expect(emojis == ["👍", "❤️", "😂"]) - } - - @Test("Hash mismatch prevents false match") - func hashMismatchPreventsFalseMatch() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a reaction for "Hello world" - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parse(reactionText)! - - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - - // Index a different message (different hash) - let matches = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Different text", - timestamp: timestamp - ) - - // Should NOT match because hash is different - #expect(matches.isEmpty) - } - - @Test("Clear removes all pending reactions") - func clearRemovesAllPending() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a reaction - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parse(reactionText)! - - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - - // Clear all pending - await service.clearPendingReactions() - - // Index the target message - should return nothing - let matches = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.isEmpty) - } - - @Test("Pending reactions are scoped by channel") - func pendingReactionsScopedByChannel() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a reaction for channel 0 - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parse(reactionText)! - - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - - // Index on channel 1 - should NOT match - let matchesChannel1 = await service.indexMessage( - id: messageID, - channelIndex: 1, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matchesChannel1.isEmpty) - - // Index on channel 0 - should match - let matchesChannel0 = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matchesChannel0.count == 1) - } - - // MARK: - DM Reaction Tests - - @Test("Builds DM wire format") - func buildsDMWireFormat() async { - let service = ReactionService() - let text = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: 1704067200 - ) - #expect(text.hasPrefix("👍\n")) - #expect(text.count == 10) // emoji + newline + 8 char hash - #expect(!text.contains("@[")) - } - - @Test("Indexes DM message and finds by hash") - func indexesDMMessageAndFinds() async { - let service = ReactionService() - let messageID = UUID() - let contactID = UUID() - let timestamp: UInt32 = 1704067200 - - _ = await service.indexDMMessage( - id: messageID, - contactID: contactID, - text: "Hello world", - timestamp: timestamp - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello world", timestamp: timestamp) - let foundID = await service.findDMTargetMessage( - messageHash: hash, - contactID: contactID - ) - - #expect(foundID == messageID) - } - - @Test("DM pending reactions match when message indexed") - func dmPendingReactionsMatch() async { - let service = ReactionService() - let messageID = UUID() - let contactID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - let reactionText = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parseDM(reactionText)! - - await service.queuePendingDMReaction( - parsed: parsed, - contactID: contactID, - senderName: "Alice", - rawText: reactionText, - radioID: radioID - ) - - let matches = await service.indexDMMessage( - id: messageID, - contactID: contactID, - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.count == 1) - #expect(matches.first?.parsed.emoji == "👍") - } - - @Test("DM reactions scoped by contact") - func dmReactionsScopedByContact() async { - let service = ReactionService() - let messageID = UUID() - let contactID1 = UUID() - let contactID2 = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - let reactionText = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parseDM(reactionText)! - - // Queue for contact1 - await service.queuePendingDMReaction( - parsed: parsed, - contactID: contactID1, - senderName: "Alice", - rawText: reactionText, - radioID: radioID - ) - - // Index for contact2 - should NOT match - let matchesContact2 = await service.indexDMMessage( - id: messageID, - contactID: contactID2, - text: "Hello world", - timestamp: timestamp - ) - #expect(matchesContact2.isEmpty) - - // Index for contact1 - should match - let matchesContact1 = await service.indexDMMessage( - id: messageID, - contactID: contactID1, - text: "Hello world", - timestamp: timestamp - ) - #expect(matchesContact1.count == 1) - } - - @Test("DM returns nil when no candidates in cache") - func dmReturnsNilWhenNoCandidates() async { - let service = ReactionService() - let contactID = UUID() - - let foundID = await service.findDMTargetMessage( - messageHash: "abcd1234", - contactID: contactID - ) - - #expect(foundID == nil) + @Test + func `Builds correct wire format with Crockford Base32 identifier`() { + let service = ReactionService() + let timestamp: UInt32 = 1_704_067_200 + + let text = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "What's the situation at Main St today?", + targetTimestamp: timestamp + ) + + // Verify format: {emoji}@[{sender}]\n{hash} + #expect(text.hasPrefix("👍@[AlphaNode]\n")) + + // Verify 8-char Crockford Base32 identifier is present (lowercase) at end + let idPattern = #/\n([0-9a-hj-km-np-tv-z]{8})$/# + #expect(text.firstMatch(of: idPattern) != nil) + } + + @Test + func `Builds wire format with short message`() { + let service = ReactionService() + let timestamp: UInt32 = 1_704_067_200 + + let text = service.buildReactionText( + emoji: "❤️", + targetSender: "Node", + targetText: "ok", + targetTimestamp: timestamp + ) + + #expect(text.hasPrefix("❤️@[Node]\n")) + #expect(text.hasSuffix(text.suffix(8))) // ends with 8-char hash + } + + @Test + func `Generated identifier is consistent`() { + let service = ReactionService() + let timestamp: UInt32 = 1_704_067_200 + let targetText = "Hello world" + + let text1 = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: targetText, + targetTimestamp: timestamp + ) + + let text2 = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: targetText, + targetTimestamp: timestamp + ) + + #expect(text1 == text2) + } + + @Test + func `Different timestamps produce different identifiers`() { + let service = ReactionService() + let targetText = "Hello world" + + let text1 = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: targetText, + targetTimestamp: 1_704_067_200 + ) + + let text2 = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: targetText, + targetTimestamp: 1_704_067_201 + ) + + #expect(text1 != text2) + } + + // MARK: - Disambiguation Tests + + @Test + func `Finds indexed message by hash and preview`() async throws { + let service = ReactionService() + let messageID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello world", + timestamp: timestamp + ) + + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: "Hello world", + targetTimestamp: timestamp + ) + + let parsed = try #require(ReactionParser.parse(reactionText)) + let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) + + #expect(foundID == messageID) + } + + @Test + func `Returns nil when no candidates exist`() async { + let service = ReactionService() + + let parsed = ParsedReaction( + emoji: "👍", + targetSender: "Node", + messageHash: "abcd1234" + ) + + let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) + + #expect(foundID == nil) + } + + @Test + func `Returns most recently indexed when multiple candidates have same hash`() async throws { + let service = ReactionService() + let id1 = UUID() + let id2 = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Index two messages with same hash (same text and timestamp) + _ = await service.indexMessage( + id: id1, + channelIndex: 0, + senderName: "Node", + text: "Same message", + timestamp: timestamp + ) + + // Small delay to ensure different indexedAt times + try? await Task.sleep(for: .milliseconds(10)) + + _ = await service.indexMessage( + id: id2, + channelIndex: 0, + senderName: "Node", + text: "Same message", + timestamp: timestamp + ) + + // Build reaction for the message + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: "Same message", + targetTimestamp: timestamp + ) + + let parsed = try #require(ReactionParser.parse(reactionText)) + let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) + + // Should find the most recently indexed (id2) + #expect(foundID == id2) + } + + // MARK: - Pending Reactions Queue Tests + + @Test + func `Queued reaction matches when message indexed`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Build reaction text for a message that doesn't exist yet + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + + let parsed = try #require(ReactionParser.parse(reactionText)) + + // Queue the reaction (target message not indexed yet) + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) + + // Now index the target message - should return the pending reaction + let matches = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.count == 1) + #expect(matches.first?.parsed.emoji == "👍") + #expect(matches.first?.senderNodeName == "BetaNode") + } + + @Test + func `Multiple reactions for same target all match`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue multiple reactions for the same message + for emoji in ["👍", "❤️", "😂"] { + let reactionText = service.buildReactionText( + emoji: emoji, + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) } - @Test("DM hash mismatch prevents false match") - func dmHashMismatchPreventsFalseMatch() async { - let service = ReactionService() - let messageID = UUID() - let contactID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a reaction for "Hello world" - let reactionText = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parseDM(reactionText)! - - await service.queuePendingDMReaction( - parsed: parsed, - contactID: contactID, - senderName: "Alice", - rawText: reactionText, - radioID: radioID - ) - - // Index a different message (different hash) - let matches = await service.indexDMMessage( - id: messageID, - contactID: contactID, - text: "Different text", - timestamp: timestamp - ) - - // Should NOT match because hash is different - #expect(matches.isEmpty) - } - - @Test("Clear removes DM pending reactions") - func clearRemovesDMPending() async { - let service = ReactionService() - let messageID = UUID() - let contactID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a DM reaction - let reactionText = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parseDM(reactionText)! - - await service.queuePendingDMReaction( - parsed: parsed, - contactID: contactID, - senderName: "Alice", - rawText: reactionText, - radioID: radioID - ) - - // Clear all pending - await service.clearPendingReactions() - - // Index the target message - should return nothing - let matches = await service.indexDMMessage( - id: messageID, - contactID: contactID, - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.isEmpty) - } + // Index the target message - should return all pending reactions + let matches = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.count == 3) + let emojis = Set(matches.map(\.parsed.emoji)) + #expect(emojis == ["👍", "❤️", "😂"]) + } + + @Test + func `Hash mismatch prevents false match`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a reaction for "Hello world" + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) + + // Index a different message (different hash) + let matches = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Different text", + timestamp: timestamp + ) + + // Should NOT match because hash is different + #expect(matches.isEmpty) + } + + @Test + func `Clear removes all pending reactions`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a reaction + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) + + // Clear all pending + await service.clearPendingReactions() + + // Index the target message - should return nothing + let matches = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.isEmpty) + } + + @Test + func `Pending reactions are scoped by channel`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a reaction for channel 0 + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) + + // Index on channel 1 - should NOT match + let matchesChannel1 = await service.indexMessage( + id: messageID, + channelIndex: 1, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matchesChannel1.isEmpty) + + // Index on channel 0 - should match + let matchesChannel0 = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matchesChannel0.count == 1) + } + + // MARK: - DM Reaction Tests + + @Test + func `Builds DM wire format`() { + let service = ReactionService() + let text = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: 1_704_067_200 + ) + #expect(text.hasPrefix("👍\n")) + #expect(text.count == 10) // emoji + newline + 8 char hash + #expect(!text.contains("@[")) + } + + @Test + func `Indexes DM message and finds by hash`() async { + let service = ReactionService() + let messageID = UUID() + let contactID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + _ = await service.indexDMMessage( + id: messageID, + contactID: contactID, + text: "Hello world", + timestamp: timestamp + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello world", timestamp: timestamp) + let foundID = await service.findDMTargetMessage( + messageHash: hash, + contactID: contactID + ) + + #expect(foundID == messageID) + } + + @Test + func `DM pending reactions match when message indexed`() async throws { + let service = ReactionService() + let messageID = UUID() + let contactID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + let reactionText = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: timestamp + ) + + let parsed = try #require(ReactionParser.parseDM(reactionText)) + + await service.queuePendingDMReaction( + parsed: parsed, + contactID: contactID, + senderName: "Alice", + rawText: reactionText, + radioID: radioID + ) + + let matches = await service.indexDMMessage( + id: messageID, + contactID: contactID, + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.count == 1) + #expect(matches.first?.parsed.emoji == "👍") + } + + @Test + func `DM reactions scoped by contact`() async throws { + let service = ReactionService() + let messageID = UUID() + let contactID1 = UUID() + let contactID2 = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + let reactionText = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parseDM(reactionText)) + + // Queue for contact1 + await service.queuePendingDMReaction( + parsed: parsed, + contactID: contactID1, + senderName: "Alice", + rawText: reactionText, + radioID: radioID + ) + + // Index for contact2 - should NOT match + let matchesContact2 = await service.indexDMMessage( + id: messageID, + contactID: contactID2, + text: "Hello world", + timestamp: timestamp + ) + #expect(matchesContact2.isEmpty) + + // Index for contact1 - should match + let matchesContact1 = await service.indexDMMessage( + id: messageID, + contactID: contactID1, + text: "Hello world", + timestamp: timestamp + ) + #expect(matchesContact1.count == 1) + } + + @Test + func `DM returns nil when no candidates in cache`() async { + let service = ReactionService() + let contactID = UUID() + + let foundID = await service.findDMTargetMessage( + messageHash: "abcd1234", + contactID: contactID + ) + + #expect(foundID == nil) + } + + @Test + func `DM hash mismatch prevents false match`() async throws { + let service = ReactionService() + let messageID = UUID() + let contactID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a reaction for "Hello world" + let reactionText = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parseDM(reactionText)) + + await service.queuePendingDMReaction( + parsed: parsed, + contactID: contactID, + senderName: "Alice", + rawText: reactionText, + radioID: radioID + ) + + // Index a different message (different hash) + let matches = await service.indexDMMessage( + id: messageID, + contactID: contactID, + text: "Different text", + timestamp: timestamp + ) + + // Should NOT match because hash is different + #expect(matches.isEmpty) + } + + @Test + func `Clear removes DM pending reactions`() async throws { + let service = ReactionService() + let messageID = UUID() + let contactID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a DM reaction + let reactionText = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parseDM(reactionText)) + + await service.queuePendingDMReaction( + parsed: parsed, + contactID: contactID, + senderName: "Alice", + rawText: reactionText, + radioID: radioID + ) + + // Clear all pending + await service.clearPendingReactions() + + // Index the target message - should return nothing + let matches = await service.indexDMMessage( + id: messageID, + contactID: contactID, + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.isEmpty) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift index ab510dd7..09b8a35e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift @@ -1,82 +1,81 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("RegionDiscoveryService query-target building") struct RegionDiscoveryServiceTests { + private let radioID = UUID() - private let radioID = UUID() - - private func repeaterContact(_ pub: Data) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: radioID, - publicKey: pub, - name: "Repeater", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - } + private func repeaterContact(_ pub: Data) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: pub, + name: "Repeater", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } - private func repeaterNode(_ pub: Data) -> DiscoveredNodeDTO { - DiscoveredNodeDTO( - id: UUID(), - radioID: radioID, - publicKey: pub, - name: "Discovered", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 0), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - } + private func repeaterNode(_ pub: Data) -> DiscoveredNodeDTO { + DiscoveredNodeDTO( + id: UUID(), + radioID: radioID, + publicKey: pub, + name: "Discovered", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 0), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + } - @Test("Non-contact responders are queried when ad-hoc requests are supported") - func includesNonContactNodesWhenSupported() { - let contactKey = Data(repeating: 0x11, count: 32) - let nonContactKey = Data(repeating: 0x22, count: 32) + @Test + func `Non-contact responders are queried when ad-hoc requests are supported`() { + let contactKey = Data(repeating: 0x11, count: 32) + let nonContactKey = Data(repeating: 0x22, count: 32) - let targets = RegionDiscoveryService.buildRegionQueryTargets( - responders: [contactKey, nonContactKey], - contacts: [repeaterContact(contactKey)], - discoveredNodes: [repeaterNode(nonContactKey)], - supportsAdHocRequest: true - ) + let targets = RegionDiscoveryService.buildRegionQueryTargets( + responders: [contactKey, nonContactKey], + contacts: [repeaterContact(contactKey)], + discoveredNodes: [repeaterNode(nonContactKey)], + supportsAdHocRequest: true + ) - let keys = Set(targets.map(\.publicKey)) - #expect(keys == [contactKey, nonContactKey]) - } + let keys = Set(targets.map(\.publicKey)) + #expect(keys == [contactKey, nonContactKey]) + } - @Test("Non-contact responders are skipped when ad-hoc requests are unsupported") - func excludesNonContactNodesWhenUnsupported() { - let contactKey = Data(repeating: 0x11, count: 32) - let nonContactKey = Data(repeating: 0x22, count: 32) + @Test + func `Non-contact responders are skipped when ad-hoc requests are unsupported`() { + let contactKey = Data(repeating: 0x11, count: 32) + let nonContactKey = Data(repeating: 0x22, count: 32) - let targets = RegionDiscoveryService.buildRegionQueryTargets( - responders: [contactKey, nonContactKey], - contacts: [repeaterContact(contactKey)], - discoveredNodes: [repeaterNode(nonContactKey)], - supportsAdHocRequest: false - ) + let targets = RegionDiscoveryService.buildRegionQueryTargets( + responders: [contactKey, nonContactKey], + contacts: [repeaterContact(contactKey)], + discoveredNodes: [repeaterNode(nonContactKey)], + supportsAdHocRequest: false + ) - let keys = Set(targets.map(\.publicKey)) - #expect(keys == [contactKey]) - } + let keys = Set(targets.map(\.publicKey)) + #expect(keys == [contactKey]) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeKeepAliveTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeKeepAliveTests.swift index 108a124c..b5a8ccad 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeKeepAliveTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeKeepAliveTests.swift @@ -1,176 +1,175 @@ import Foundation -import Testing -import MeshCore @testable import MC1Services +import MeshCore +import Testing @Suite("RemoteNodeService keep-alive retry logic") struct RemoteNodeKeepAliveTests { - - // MARK: - Transient failures - - @Test("single transient failure retries without disconnecting") - func singleTransientFailureRetries() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionError(.timeout), - consecutiveFailures: &failures - ) - #expect(failures == 1) - #expect(action == .retryNextInterval) - } - - @Test("two consecutive transient failures triggers disconnect") - func twoConsecutiveTransientFailuresDisconnect() { - var failures = 1 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionError(.timeout), - consecutiveFailures: &failures - ) - #expect(failures == 2) - #expect(action == .disconnect) - } - - @Test("success resets failure counter") - func successResetsCounter() { - var failures = 1 - KeepAliveRetryPolicy.recordSuccess(consecutiveFailures: &failures) - #expect(failures == 0) - } - - // MARK: - Terminal failures - - @Test("sessionNotFound disconnects immediately without incrementing counter") - func sessionNotFoundDisconnectsImmediately() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionNotFound, - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .disconnectNow) - } - - @Test("contactNotFound disconnects immediately without incrementing counter") - func contactNotFoundDisconnectsImmediately() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.contactNotFound, - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .disconnectNow) - } - - // MARK: - Transient error variants - - @Test("deviceError is treated as transient failure") - func deviceErrorIsTransient() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionError(.deviceError(code: 7)), - consecutiveFailures: &failures - ) - #expect(failures == 1) - #expect(action == .retryNextInterval) - } - - @Test("notConnected is treated as transient failure") - func notConnectedIsTransient() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionError(.notConnected), - consecutiveFailures: &failures - ) - #expect(failures == 1) - #expect(action == .retryNextInterval) - } - - // MARK: - Skip and stop - - @Test("floodRouted is not counted as a failure") - func floodRoutedNotCounted() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.floodRouted, - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .skip) - } - - @Test("CancellationError stops the loop quietly") - func cancellationErrorStops() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: CancellationError(), - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .stop) - } - - @Test("RemoteNodeError.cancelled stops the loop quietly") - func cancelledErrorStops() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.cancelled, - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .stop) - } - - @Test("unknown non-RemoteNodeError disconnects immediately") - func unknownErrorDisconnectsImmediately() { - struct PersistenceError: Error {} - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: PersistenceError(), - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .disconnectNow) - } - - // MARK: - Failure reasons - - @Test("failure reason describes timeout") - func failureReasonTimeout() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.sessionError(.timeout) - ) - #expect(reason == "timeout") - } - - @Test("failure reason describes device error with code") - func failureReasonDeviceError() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.sessionError(.deviceError(code: 42)) - ) - #expect(reason == "device error (code: 42)") - } - - @Test("failure reason describes transport not connected") - func failureReasonTransport() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.sessionError(.notConnected) - ) - #expect(reason == "transport not connected") - } - - @Test("failure reason describes session not found") - func failureReasonSessionNotFound() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.sessionNotFound - ) - #expect(reason == "session not found") - } - - @Test("failure reason describes contact not found") - func failureReasonContactNotFound() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.contactNotFound - ) - #expect(reason == "contact not found") - } + // MARK: - Transient failures + + @Test + func `single transient failure retries without disconnecting`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionError(.timeout), + consecutiveFailures: &failures + ) + #expect(failures == 1) + #expect(action == .retryNextInterval) + } + + @Test + func `two consecutive transient failures triggers disconnect`() { + var failures = 1 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionError(.timeout), + consecutiveFailures: &failures + ) + #expect(failures == 2) + #expect(action == .disconnect) + } + + @Test + func `success resets failure counter`() { + var failures = 1 + KeepAliveRetryPolicy.recordSuccess(consecutiveFailures: &failures) + #expect(failures == 0) + } + + // MARK: - Terminal failures + + @Test + func `sessionNotFound disconnects immediately without incrementing counter`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionNotFound, + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .disconnectNow) + } + + @Test + func `contactNotFound disconnects immediately without incrementing counter`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.contactNotFound, + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .disconnectNow) + } + + // MARK: - Transient error variants + + @Test + func `deviceError is treated as transient failure`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionError(.deviceError(code: 7)), + consecutiveFailures: &failures + ) + #expect(failures == 1) + #expect(action == .retryNextInterval) + } + + @Test + func `notConnected is treated as transient failure`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionError(.notConnected), + consecutiveFailures: &failures + ) + #expect(failures == 1) + #expect(action == .retryNextInterval) + } + + // MARK: - Skip and stop + + @Test + func `floodRouted is not counted as a failure`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.floodRouted, + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .skip) + } + + @Test + func `CancellationError stops the loop quietly`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: CancellationError(), + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .stop) + } + + @Test + func `RemoteNodeError.cancelled stops the loop quietly`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.cancelled, + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .stop) + } + + @Test + func `unknown non-RemoteNodeError disconnects immediately`() { + struct PersistenceError: Error {} + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: PersistenceError(), + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .disconnectNow) + } + + // MARK: - Failure reasons + + @Test + func `failure reason describes timeout`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.sessionError(.timeout) + ) + #expect(reason == "timeout") + } + + @Test + func `failure reason describes device error with code`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.sessionError(.deviceError(code: 42)) + ) + #expect(reason == "device error (code: 42)") + } + + @Test + func `failure reason describes transport not connected`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.sessionError(.notConnected) + ) + #expect(reason == "transport not connected") + } + + @Test + func `failure reason describes session not found`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.sessionNotFound + ) + #expect(reason == "session not found") + } + + @Test + func `failure reason describes contact not found`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.contactNotFound + ) + #expect(reason == "contact not found") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift new file mode 100644 index 00000000..c3500922 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift @@ -0,0 +1,254 @@ +import Foundation +@testable import MC1Services +import MeshCore +import Testing + +/// Covers the login auto-heal path: when the radio reports a contact missing from its table +/// (firmware notFound, 0x02) during `sendLogin`, the service pushes the local contact to the radio +/// and retries once. +@Suite("RemoteNodeService login heal") +struct RemoteNodeLoginHealTests { + private static let publicKey = Data(repeating: 0xCC, count: 32) + private static let directPath = Data([0x01, 0x02]) + + @Test + func `notFound on login pushes the local contact to the radio and retries`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // A direct-routed contact present locally but absent from the radio's table. + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), + .success(MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + + // The radio was sent the missing contact exactly once, and login was retried. + #expect(await session.addContactInvocations.count == 1) + #expect(await session.sendLoginInvocations.count == 2) + // The pushed contact is the right node, flood-routed, and the local row is reconciled to match. + let pushed = await session.addContactInvocations.first?.contact + #expect(pushed?.publicKey == Self.publicKey) + #expect(pushed?.outPathLength == PacketBuilder.floodPathSentinel) + let healed = try await dataStore.fetchContact(radioID: radioID, publicKey: Self.publicKey) + #expect(healed?.isFloodRouted == true) + } + + @Test + func `healing preserves a contact type byte not modeled by ContactType`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // A type byte newer firmware might use that ContactType does not model; it must reach the + // radio verbatim rather than being coerced to .chat by the typed accessor. + let unmodeledType: UInt8 = 0x7F + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + typeRawValue: unmodeledType, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), + .success(MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + + let pushed = await session.addContactInvocations.first?.contact + #expect(pushed?.typeRawValue == unmodeledType) + } + + @Test + func `a non-notFound login error skips healing and is not retried`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + // tableFull shares its code with the add-contact path; only notFound from sendLogin may heal. + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: MeshCoreError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + } + guard case let .deviceError(code) = thrown, code == ProtocolError.tableFull.rawValue else { + Issue.record("expected the original tableFull device error, got \(String(describing: thrown))") + return + } + #expect(await session.addContactInvocations.isEmpty) + #expect(await session.sendLoginInvocations.count == 1) + } + + @Test + func `a second notFound after re-adding the contact surfaces the device error unhealed`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: MeshCoreError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + } + guard case let .deviceError(code) = thrown, code == ProtocolError.notFound.rawValue else { + Issue.record("expected notFound device error, got \(String(describing: thrown))") + return + } + // The contact was re-added once, then the retry's second notFound surfaced without re-adding. + #expect(await session.addContactInvocations.count == 1) + #expect(await session.sendLoginInvocations.count == 2) + } + + @Test + func `a full radio contact table surfaces radioContactsFull`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + await session.setAddContactError(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: RemoteNodeError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + } + guard case .radioContactsFull = thrown else { + Issue.record("expected radioContactsFull, got \(String(describing: thrown))") + return + } + // The add was attempted once and the retry never fired, so login stops at the full table. + #expect(await session.addContactInvocations.count == 1) + #expect(await session.sendLoginInvocations.count == 1) + } + + @Test + func `a contact absent from the local store cannot be healed`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: RemoteNodeError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + } + guard case .contactNotFound = thrown else { + Issue.record("expected contactNotFound, got \(String(describing: thrown))") + return + } + #expect(await session.addContactInvocations.isEmpty) + } + + @Test + func `login() surfaces radioContactsFull through the continuation instead of a generic session error`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID, publicKey: Self.publicKey) + try await dataStore.saveRemoteNodeSessionDTO(remoteSession) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + // The radio is missing the contact (notFound), and re-adding it hits a full table. The error the + // public login() resolves the continuation with must stay the typed RemoteNodeError.radioContactsFull, + // not collapse into the generic .sessionError that the catch falls back to. + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + await session.setAddContactError(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: RemoteNodeError.self) { + _ = try await service.login(sessionID: remoteSession.id, password: "") + } + guard case .radioContactsFull = thrown else { + Issue.record("expected radioContactsFull to propagate unwrapped, got \(String(describing: thrown))") + return + } + #expect(await session.sendLoginInvocations.count == 1) + #expect(await session.addContactInvocations.count == 1) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeTeardownTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeTeardownTests.swift index 08dfdd6f..54bea942 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeTeardownTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeTeardownTests.swift @@ -1,105 +1,110 @@ import Foundation -import Testing -import MeshCore @testable import MC1Services +import MeshCore +import Testing /// A transport whose `send` succeeds but that never yields session bytes, so any /// command awaiting a device response stays parked. Models a radio that accepted /// the write but went silent, which is the state `stopAllKeepAlives` must unwind. private actor SilentTransport: MeshTransport { - private let dataStream: AsyncStream - private let dataContinuation: AsyncStream.Continuation - - init() { - var continuation: AsyncStream.Continuation! - self.dataStream = AsyncStream { continuation = $0 } - self.dataContinuation = continuation - } - - var receivedData: AsyncStream { dataStream } - var isConnected: Bool { true } - func connect() async throws {} - func disconnect() async {} - func send(_ data: Data) async throws {} + private let dataStream: AsyncStream + private let dataContinuation: AsyncStream.Continuation + + init() { + var continuation: AsyncStream.Continuation! + dataStream = AsyncStream { continuation = $0 } + dataContinuation = continuation + } + + var receivedData: AsyncStream { + dataStream + } + + var isConnected: Bool { + true + } + + func connect() async throws {} + func disconnect() async {} + func send(_ data: Data) async throws {} } @Suite("RemoteNodeService teardown") struct RemoteNodeTeardownTests { + @Test + func `stopAllKeepAlives resumes a parked login continuation`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Long timeout keeps `sendLogin` blocked on the silent transport, so the + // login continuation stays parked in `pendingLogins` for the duration. + let session = MeshCoreSession( + transport: SilentTransport(), + configuration: SessionConfiguration(defaultTimeout: 30.0, clientIdentifier: "MCTst") + ) + + let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID) + try await dataStore.saveRemoteNodeSessionDTO(remoteSession) + + let service = RemoteNodeService( + session: session, + dataStore: dataStore, + keychainService: KeychainService() + ) + + let loginTask = Task { + try await service.login(sessionID: remoteSession.id, password: "test-password") + } - @Test("stopAllKeepAlives resumes a parked login continuation") - func stopAllKeepAlivesResumesParkedLogin() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Long timeout keeps `sendLogin` blocked on the silent transport, so the - // login continuation stays parked in `pendingLogins` for the duration. - let session = MeshCoreSession( - transport: SilentTransport(), - configuration: SessionConfiguration(defaultTimeout: 30.0, clientIdentifier: "MCTst") - ) - - let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID) - try await dataStore.saveRemoteNodeSessionDTO(remoteSession) - - let service = RemoteNodeService( - session: session, - dataStore: dataStore, - keychainService: KeychainService() - ) - - let loginTask = Task { - try await service.login(sessionID: remoteSession.id, password: "test-password") - } - - // Wait until the continuation is registered before tearing down. - try await waitUntil("login continuation was never parked") { - await service.pendingLoginCount == 1 - } - - await service.stopAllKeepAlives() - - await #expect(throws: RemoteNodeError.self) { - _ = try await loginTask.value - } - #expect(await service.pendingLoginCount == 0) + // Wait until the continuation is registered before tearing down. + try await waitUntil("login continuation was never parked") { + await service.pendingLoginCount == 1 } - @Test("keep-alive loop does not retain the service after the last strong reference is dropped") - func keepAliveLoopDoesNotRetainService() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let session = MeshCoreSession( - transport: SilentTransport(), - configuration: SessionConfiguration(defaultTimeout: 30.0, clientIdentifier: "MCTst") - ) - - let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID) - try await dataStore.saveRemoteNodeSessionDTO(remoteSession) - - // A flood-routed contact makes each keep-alive tick a skip, so the - // loop stays parked in its inter-tick sleep without touching the session. - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: remoteSession.publicKey, - outPathLength: 0xFF - ) - try await dataStore.saveContact(contact) - - var service: RemoteNodeService? = RemoteNodeService( - session: session, - dataStore: dataStore, - keychainService: KeychainService() - ) - weak var weakService = service - - await service?.startSessionKeepAlive( - sessionID: remoteSession.id, - publicKey: remoteSession.publicKey - ) - service = nil - - try await waitUntil("keep-alive task must not keep the service alive") { - weakService == nil - } + await service.stopAllKeepAlives() + + await #expect(throws: RemoteNodeError.self) { + _ = try await loginTask.value + } + #expect(await service.pendingLoginCount == 0) + } + + @Test + func `keep-alive loop does not retain the service after the last strong reference is dropped`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let session = MeshCoreSession( + transport: SilentTransport(), + configuration: SessionConfiguration(defaultTimeout: 30.0, clientIdentifier: "MCTst") + ) + + let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID) + try await dataStore.saveRemoteNodeSessionDTO(remoteSession) + + // A flood-routed contact makes each keep-alive tick a skip, so the + // loop stays parked in its inter-tick sleep without touching the session. + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: remoteSession.publicKey, + outPathLength: 0xFF + ) + try await dataStore.saveContact(contact) + + var service: RemoteNodeService? = RemoteNodeService( + session: session, + dataStore: dataStore, + keychainService: KeychainService() + ) + weak var weakService = service + + await service?.startSessionKeepAlive( + sessionID: remoteSession.id, + publicKey: remoteSession.publicKey + ) + service = nil + + try await waitUntil("keep-alive task must not keep the service alive") { + weakService == nil } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceAdvertHopTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceAdvertHopTests.swift index 3df76d4f..76a1e66b 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceAdvertHopTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceAdvertHopTests.swift @@ -1,185 +1,184 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("RxLogService inbound advert hop count") struct RxLogServiceAdvertHopTests { - - private static let advertiserKey = Data((0.. Data { - pubKey + Data([0xAA, 0xBB, 0xCC, 0xDD]) - } - - private static func makeParsed( - payloadType: PayloadType, - pathLength: UInt8, - packetPayload: Data, - routeType: RouteType = .flood - ) -> ParsedRxLogData { - ParsedRxLogData( - snr: 8.0, - rssi: -70, - rawPayload: packetPayload, - routeType: routeType, - payloadType: payloadType, - payloadVersion: 0, - payloadTypeBits: 0, - transportCode: nil, - pathLength: pathLength, - pathNodes: [], - packetPayload: packetPayload - ) - } + private static let advertiserKey = Data((0.. Data { + pubKey + Data([0xAA, 0xBB, 0xCC, 0xDD]) + } + + private static func makeParsed( + payloadType: PayloadType, + pathLength: UInt8, + packetPayload: Data, + routeType: RouteType = .flood + ) -> ParsedRxLogData { + ParsedRxLogData( + snr: 8.0, + rssi: -70, + rawPayload: packetPayload, + routeType: routeType, + payloadType: payloadType, + payloadVersion: 0, + payloadTypeBits: 0, + transportCode: nil, + pathLength: pathLength, + pathNodes: [], + packetPayload: packetPayload + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceReprocessTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceReprocessTests.swift index a164aba1..0408ee25 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceReprocessTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceReprocessTests.swift @@ -1,189 +1,193 @@ -import Testing -import Foundation import CommonCrypto import CryptoKit +import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("RxLogService reprocessing and streaming") struct RxLogServiceReprocessTests { - - private actor EntryCollector { - var entries: [RxLogEntryDTO] = [] - func record(_ entry: RxLogEntryDTO) { entries.append(entry) } - var count: Int { entries.count } + private actor EntryCollector { + var entries: [RxLogEntryDTO] = [] + func record(_ entry: RxLogEntryDTO) { + entries.append(entry) } - @Test("late-decrypted channel entries keep the matching channel index and name") - func reprocessAttributesMatchingChannel() async throws { - let radioID = UUID() - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let session = MeshCoreSession(transport: MockTransport()) - let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) - - let channelZeroSecret = Data(repeating: 0x99, count: 16) - let channelThreeSecret = Data(repeating: 0x42, count: 16) - - // Seed the channels in the store first so the event monitor's secret - // load cannot interleave with different values than updateChannels sets. - try await dataStore.saveChannel(ChannelDTO.testChannel( - radioID: radioID, index: 0, name: "Public", secret: channelZeroSecret - )) - try await dataStore.saveChannel(ChannelDTO.testChannel( - radioID: radioID, index: 3, name: "Three", secret: channelThreeSecret - )) - - await service.startEventMonitoring(radioID: radioID) - defer { Task { await service.stopEventMonitoring() } } - - let senderTimestamp: UInt32 = 1_700_000_000 - let payload = Self.encryptedChannelPayload( - timestamp: senderTimestamp, - text: "hello", - secret: channelThreeSecret - ) - let entry = RxLogEntryDTO( - radioID: radioID, - from: Self.makeParsed(payloadType: .groupText, packetPayload: payload), - decryptStatus: .noMatchingKey - ) - try await dataStore.saveRxLogEntry(entry) - - await service.updateChannels( - secrets: [0: channelZeroSecret, 3: channelThreeSecret], - names: [0: "Public", 3: "Three"] - ) - - let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) - let updated = try #require(entries.first { $0.id == entry.id }) - #expect(updated.channelIndex == 3, - "reprocessing must record the channel whose secret matched, not nil") - #expect(updated.channelName == "Three", - "reprocessing must not fall back to channel 0's name") - #expect(updated.senderTimestamp == senderTimestamp) - #expect(updated.decryptStatus == .success) + var count: Int { + entries.count } - - @Test("coexisting entry stream subscribers each receive every entry") - func coexistingSubscribersBothReceiveEntries() async throws { - let radioID = UUID() - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let session = MeshCoreSession(transport: MockTransport()) - let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) - await service.startEventMonitoring(radioID: radioID) - defer { Task { await service.stopEventMonitoring() } } - - let streamA = service.entryStream() - let streamB = service.entryStream() - - let collectorA = EntryCollector() - let collectorB = EntryCollector() - let taskA = Task { - for await entry in streamA { - await collectorA.record(entry) - } - } - let taskB = Task { - for await entry in streamB { - await collectorB.record(entry) - } - } - - await service.process(Self.makeParsed( - payloadType: .advert, - packetPayload: Data([0x01, 0x02, 0x03, 0x04]) - )) - - try await waitUntil("the first subscriber must receive the entry") { - await collectorA.count > 0 - } - try await waitUntil("the second subscriber must also receive the entry") { - await collectorB.count > 0 - } - taskA.cancel() - taskB.cancel() + } + + @Test + func `late-decrypted channel entries keep the matching channel index and name`() async throws { + let radioID = UUID() + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let session = MeshCoreSession(transport: MockTransport()) + let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) + + let channelZeroSecret = Data(repeating: 0x99, count: 16) + let channelThreeSecret = Data(repeating: 0x42, count: 16) + + // Seed the channels in the store first so the event monitor's secret + // load cannot interleave with different values than updateChannels sets. + try await dataStore.saveChannel(ChannelDTO.testChannel( + radioID: radioID, index: 0, name: "Public", secret: channelZeroSecret + )) + try await dataStore.saveChannel(ChannelDTO.testChannel( + radioID: radioID, index: 3, name: "Three", secret: channelThreeSecret + )) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let senderTimestamp: UInt32 = 1_700_000_000 + let payload = Self.encryptedChannelPayload( + timestamp: senderTimestamp, + text: "hello", + secret: channelThreeSecret + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: Self.makeParsed(payloadType: .groupText, packetPayload: payload), + decryptStatus: .noMatchingKey + ) + try await dataStore.saveRxLogEntry(entry) + + await service.updateChannels( + secrets: [0: channelZeroSecret, 3: channelThreeSecret], + names: [0: "Public", 3: "Three"] + ) + + let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) + let updated = try #require(entries.first { $0.id == entry.id }) + #expect(updated.channelIndex == 3, + "reprocessing must record the channel whose secret matched, not nil") + #expect(updated.channelName == "Three", + "reprocessing must not fall back to channel 0's name") + #expect(updated.senderTimestamp == senderTimestamp) + #expect(updated.decryptStatus == .success) + } + + @Test + func `coexisting entry stream subscribers each receive every entry`() async throws { + let radioID = UUID() + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let session = MeshCoreSession(transport: MockTransport()) + let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let streamA = service.entryStream() + let streamB = service.entryStream() + + let collectorA = EntryCollector() + let collectorB = EntryCollector() + let taskA = Task { + for await entry in streamA { + await collectorA.record(entry) + } + } + let taskB = Task { + for await entry in streamB { + await collectorB.record(entry) + } } - @Test("finishEntryStream ends every subscriber's iteration") - func finishEndsAllSubscribers() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let session = MeshCoreSession(transport: MockTransport()) - let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) - - let streamA = service.entryStream() - let streamB = service.entryStream() - let iterationA = Task { - for await _ in streamA {} - return true - } - let iterationB = Task { - for await _ in streamB {} - return true - } - - service.finishEntryStream() + await service.process(Self.makeParsed( + payloadType: .advert, + packetPayload: Data([0x01, 0x02, 0x03, 0x04]) + )) - #expect(await iterationA.value, "finish must end the first subscriber's loop") - #expect(await iterationB.value, "finish must end the second subscriber's loop") + try await waitUntil("the first subscriber must receive the entry") { + await collectorA.count > 0 } - - // MARK: - Helpers - - private static func makeParsed(payloadType: PayloadType, packetPayload: Data) -> ParsedRxLogData { - ParsedRxLogData( - snr: 8.0, - rssi: -70, - rawPayload: packetPayload, - routeType: .flood, - payloadType: payloadType, - payloadVersion: 0, - payloadTypeBits: 0, - transportCode: nil, - pathLength: 0, - pathNodes: [], - packetPayload: packetPayload - ) + try await waitUntil("the second subscriber must also receive the entry") { + await collectorB.count > 0 + } + taskA.cancel() + taskB.cancel() + } + + @Test + func `finishEntryStream ends every subscriber's iteration`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let session = MeshCoreSession(transport: MockTransport()) + let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) + + let streamA = service.entryStream() + let streamB = service.entryStream() + let iterationA = Task { + for await _ in streamA {} + return true + } + let iterationB = Task { + for await _ in streamB {} + return true } - /// Builds a wire-format channel payload (`[channelHash:1][MAC:2][ciphertext:N]`) - /// that `ChannelCrypto.decrypt` accepts: AES-128 ECB ciphertext authenticated - /// with a truncated HMAC-SHA256, mirroring the firmware's encrypt-then-MAC. - private static func encryptedChannelPayload(timestamp: UInt32, text: String, secret: Data) -> Data { - var plaintext = withUnsafeBytes(of: timestamp.littleEndian) { Data($0) } - plaintext.append(0) - plaintext.append(Data(text.utf8)) - let blockSize = kCCBlockSizeAES128 - let paddedCount = (plaintext.count + blockSize - 1) / blockSize * blockSize - plaintext.append(Data(count: paddedCount - plaintext.count)) - - var ciphertext = Data(count: plaintext.count) - var encryptedCount: size_t = 0 - let keyBytes = secret.prefix(kCCKeySizeAES128) - let status = ciphertext.withUnsafeMutableBytes { outPtr in - plaintext.withUnsafeBytes { inPtr in - keyBytes.withUnsafeBytes { keyPtr in - CCCrypt( - CCOperation(kCCEncrypt), - CCAlgorithm(kCCAlgorithmAES), - CCOptions(kCCOptionECBMode), - keyPtr.baseAddress, kCCKeySizeAES128, - nil, - inPtr.baseAddress, plaintext.count, - outPtr.baseAddress, plaintext.count, - &encryptedCount - ) - } - } + service.finishEntryStream() + + #expect(await iterationA.value, "finish must end the first subscriber's loop") + #expect(await iterationB.value, "finish must end the second subscriber's loop") + } + + // MARK: - Helpers + + private static func makeParsed(payloadType: PayloadType, packetPayload: Data) -> ParsedRxLogData { + ParsedRxLogData( + snr: 8.0, + rssi: -70, + rawPayload: packetPayload, + routeType: .flood, + payloadType: payloadType, + payloadVersion: 0, + payloadTypeBits: 0, + transportCode: nil, + pathLength: 0, + pathNodes: [], + packetPayload: packetPayload + ) + } + + /// Builds a wire-format channel payload (`[channelHash:1][MAC:2][ciphertext:N]`) + /// that `ChannelCrypto.decrypt` accepts: AES-128 ECB ciphertext authenticated + /// with a truncated HMAC-SHA256, mirroring the firmware's encrypt-then-MAC. + private static func encryptedChannelPayload(timestamp: UInt32, text: String, secret: Data) -> Data { + var plaintext = withUnsafeBytes(of: timestamp.littleEndian) { Data($0) } + plaintext.append(0) + plaintext.append(Data(text.utf8)) + let blockSize = kCCBlockSizeAES128 + let paddedCount = (plaintext.count + blockSize - 1) / blockSize * blockSize + plaintext.append(Data(count: paddedCount - plaintext.count)) + + var ciphertext = Data(count: plaintext.count) + var encryptedCount: size_t = 0 + let keyBytes = secret.prefix(kCCKeySizeAES128) + let status = ciphertext.withUnsafeMutableBytes { outPtr in + plaintext.withUnsafeBytes { inPtr in + keyBytes.withUnsafeBytes { keyPtr in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionECBMode), + keyPtr.baseAddress, kCCKeySizeAES128, + nil, + inPtr.baseAddress, plaintext.count, + outPtr.baseAddress, plaintext.count, + &encryptedCount + ) } - precondition(status == kCCSuccess, "test fixture encryption failed") - - let mac = Data( - HMAC.authenticationCode(for: ciphertext, using: SymmetricKey(data: secret)) - .prefix(ChannelCrypto.macSize) - ) - return Data([0x00]) + mac + ciphertext.prefix(encryptedCount) + } } + precondition(status == kCCSuccess, "test fixture encryption failed") + + let mac = Data( + HMAC.authenticationCode(for: ciphertext, using: SymmetricKey(data: secret)) + .prefix(ChannelCrypto.macSize) + ) + return Data([0x00]) + mac + ciphertext.prefix(encryptedCount) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceDefaultFloodScopeTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceDefaultFloodScopeTests.swift index 3fc9adad..9bee276e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceDefaultFloodScopeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceDefaultFloodScopeTests.swift @@ -1,146 +1,147 @@ import Foundation -import Testing -@testable import MeshCore @testable import MC1Services +@testable import MeshCore +import Testing @Suite("SettingsService default flood scope") struct SettingsServiceDefaultFloodScopeTests { + @Test + @MainActor + func `setDefaultFloodScopeVerified truncates overlong names before send and verify`() async throws { + let maxBytes = ProtocolLimits.maxDefaultFloodScopeNameBytes + let overlong = String(repeating: "a", count: maxBytes + 5) + let truncated = String(repeating: "a", count: maxBytes) - @Test("setDefaultFloodScopeVerified truncates overlong names before send and verify") - @MainActor - func setDefaultFloodScopeVerified_truncatesOverlongName() async throws { - let maxBytes = ProtocolLimits.maxDefaultFloodScopeNameBytes - let overlong = String(repeating: "a", count: maxBytes + 5) - let truncated = String(repeating: "a", count: maxBytes) - - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } - - let setTask = Task { try await service.setDefaultFloodScopeVerified(name: overlong) } - - try await waitUntil("service should send setDefaultFloodScope command") { - await transport.sentData.count == 2 - } - let sentAfterWrite = await transport.sentData - let expectedPacket = PacketBuilder.setDefaultFloodScope( - name: truncated, - scope: .region(truncated) - ) - #expect(sentAfterWrite[1] == expectedPacket) - await transport.simulateOK() - - try await waitUntil("service should verify via getDefaultFloodScope") { - await transport.sentData.count == 3 - } - let sentAfterVerify = await transport.sentData - #expect(sentAfterVerify[2] == PacketBuilder.getDefaultFloodScope()) - await transport.simulateReceive(makeDefaultFloodScopePacket(name: truncated)) - - let result = try await setTask.value - #expect(result == truncated) - } - - @Test("setDefaultFloodScopeVerified forwards names at or below the cap unchanged") - @MainActor - func setDefaultFloodScopeVerified_passesShortNameThrough() async throws { - let name = "Germany" - - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } - - let setTask = Task { try await service.setDefaultFloodScopeVerified(name: name) } + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } - try await waitUntil("service should send setDefaultFloodScope command") { - await transport.sentData.count == 2 - } - let sent = await transport.sentData - #expect(sent[1] == PacketBuilder.setDefaultFloodScope(name: name, scope: .region(name))) - await transport.simulateOK() + let setTask = Task { try await service.setDefaultFloodScopeVerified(name: overlong) } - try await waitUntil("service should verify via getDefaultFloodScope") { - await transport.sentData.count == 3 - } - await transport.simulateReceive(makeDefaultFloodScopePacket(name: name)) - - let result = try await setTask.value - #expect(result == name) + try await waitUntil("service should send setDefaultFloodScope command") { + await transport.sentData.count == 2 + } + let sentAfterWrite = await transport.sentData + let expectedPacket = PacketBuilder.setDefaultFloodScope( + name: truncated, + scope: .region(truncated) + ) + #expect(sentAfterWrite[1] == expectedPacket) + await transport.simulateOK() + + try await waitUntil("service should verify via getDefaultFloodScope") { + await transport.sentData.count == 3 } + let sentAfterVerify = await transport.sentData + #expect(sentAfterVerify[2] == PacketBuilder.getDefaultFloodScope()) + await transport.simulateReceive(makeDefaultFloodScopePacket(name: truncated)) - @Test("setDefaultFloodScopeVerified clears the scope when name is nil") - @MainActor - func setDefaultFloodScopeVerified_clear() async throws { - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } + let result = try await setTask.value + #expect(result == truncated) + } - let setTask = Task { try await service.setDefaultFloodScopeVerified(name: nil) } + @Test + @MainActor + func `setDefaultFloodScopeVerified forwards names at or below the cap unchanged`() async throws { + let name = "Germany" - try await waitUntil("service should send setDefaultFloodScope clear command") { - await transport.sentData.count == 2 - } - let sent = await transport.sentData - #expect(sent[1] == PacketBuilder.setDefaultFloodScope(name: "", scope: .disabled)) - await transport.simulateOK() + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } - try await waitUntil("service should verify via getDefaultFloodScope") { - await transport.sentData.count == 3 - } - await transport.simulateReceive(Data([ResponseCode.defaultFloodScope.rawValue])) + let setTask = Task { try await service.setDefaultFloodScopeVerified(name: name) } - let result = try await setTask.value - #expect(result == nil) + try await waitUntil("service should send setDefaultFloodScope command") { + await transport.sentData.count == 2 } + let sent = await transport.sentData + #expect(sent[1] == PacketBuilder.setDefaultFloodScope(name: name, scope: .region(name))) + await transport.simulateOK() - private func makeService() async throws -> (SettingsService, MeshCoreSession, MockTransport) { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) + try await waitUntil("service should verify via getDefaultFloodScope") { + await transport.sentData.count == 3 + } + await transport.simulateReceive(makeDefaultFloodScopePacket(name: name)) - let startTask = Task { try await session.start() } + let result = try await setTask.value + #expect(result == name) + } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value + @Test + @MainActor + func `setDefaultFloodScopeVerified clears the scope when name is nil`() async throws { + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } - return (SettingsService(session: session), session, transport) - } + let setTask = Task { try await service.setDefaultFloodScopeVerified(name: nil) } - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(0)) - payload.append(int32Bytes(0)) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet + try await waitUntil("service should send setDefaultFloodScope clear command") { + await transport.sentData.count == 2 } + let sent = await transport.sentData + #expect(sent[1] == PacketBuilder.setDefaultFloodScope(name: "", scope: .disabled)) + await transport.simulateOK() - private func makeDefaultFloodScopePacket(name: String) -> Data { - var packet = Data([ResponseCode.defaultFloodScope.rawValue]) - var nameField = Data(name.utf8) - while nameField.count < 31 { nameField.append(0) } - packet.append(nameField) - packet.append(Data(repeating: 0, count: 16)) - return packet + try await waitUntil("service should verify via getDefaultFloodScope") { + await transport.sentData.count == 3 } + await transport.simulateReceive(Data([ResponseCode.defaultFloodScope.rawValue])) - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } - } + let result = try await setTask.value + #expect(result == nil) + } + + private func makeService() async throws -> (SettingsService, MeshCoreSession, MockTransport) { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } + let startTask = Task { try await session.start() } + + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + + return (SettingsService(session: session), session, transport) + } + + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(0)) + payload.append(int32Bytes(0)) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } + + private func makeDefaultFloodScopePacket(name: String) -> Data { + var packet = Data([ResponseCode.defaultFloodScope.rawValue]) + var nameField = Data(name.utf8) + while nameField.count < 31 { + nameField.append(0) } + packet.append(nameField) + packet.append(Data(repeating: 0, count: 16)) + return packet + } + + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } + + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceEventStreamTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceEventStreamTests.swift index ccf1a7b8..4fb19c4a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceEventStreamTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceEventStreamTests.swift @@ -1,86 +1,90 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("SettingsService event stream") struct SettingsServiceEventStreamTests { + private actor EventCollector { + var events: [SettingsEvent] = [] + func record(_ event: SettingsEvent) { + events.append(event) + } - private actor EventCollector { - var events: [SettingsEvent] = [] - func record(_ event: SettingsEvent) { events.append(event) } - var count: Int { events.count } + var count: Int { + events.count } + } - @Test("replacing the event subscriber keeps the replacement connected") - @MainActor - func replacedSubscriberKeepsReplacementConnected() async throws { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } + @Test + @MainActor + func `replacing the event subscriber keeps the replacement connected`() async throws { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } - let service = SettingsService(session: session) + let service = SettingsService(session: session) - let streamA = await service.events() - let streamB = await service.events() + let streamA = await service.events() + let streamB = await service.events() - // The second subscription finished A; drain it so its termination - // callback has run (it must not disconnect B). - for await _ in streamA {} + // The second subscription finished A; drain it so its termination + // callback has run (it must not disconnect B). + for await _ in streamA {} - let collector = EventCollector() - let collectTask = Task { - for await event in streamB { - await collector.record(event) - } - } + let collector = EventCollector() + let collectTask = Task { + for await event in streamB { + await collector.record(event) + } + } - let refreshTask = Task { try await service.refreshDeviceInfo() } - try await waitUntil("service should re-request self info") { - await transport.sentData.count == 2 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await refreshTask.value + let refreshTask = Task { try await service.refreshDeviceInfo() } + try await waitUntil("service should re-request self info") { + await transport.sentData.count == 2 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await refreshTask.value - try await waitUntil("the replacement subscriber must receive the deviceUpdated event") { - await collector.count > 0 - } - collectTask.cancel() + try await waitUntil("the replacement subscriber must receive the deviceUpdated event") { + await collector.count > 0 } + collectTask.cancel() + } - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(0)) - payload.append(int32Bytes(0)) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(0)) + payload.append(int32Bytes(0)) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet - } + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } - } + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } - } + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceLocationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceLocationTests.swift index 425c02b3..681a2a01 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceLocationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceLocationTests.swift @@ -1,221 +1,220 @@ import Foundation -import Testing -@testable import MeshCore @testable import MC1Services +@testable import MeshCore +import Testing @Suite("SettingsService location and device GPS") struct SettingsServiceLocationTests { + @Test + @MainActor + func `getDeviceGPSState returns unsupported when gps custom var is missing`() async throws { + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } + + let stateTask = Task { try await service.getDeviceGPSState() } + try await waitUntil("service should request custom vars") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(makeCustomVarsPacket()) + let state = try await stateTask.value + + #expect(state == DeviceGPSState(isSupported: false, isEnabled: false)) + } + + @Test + @MainActor + func `getDeviceGPSState returns enabled when gps custom var is on`() async throws { + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } + + let stateTask = Task { try await service.getDeviceGPSState() } + try await waitUntil("service should request custom vars") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(makeCustomVarsPacket("gps:1,foo:bar")) + let state = try await stateTask.value + + #expect(state == DeviceGPSState(isSupported: true, isEnabled: true)) + } + + @Test + @MainActor + func `setDeviceGPSEnabledVerified writes, verifies, and refreshes device info`() async throws { + let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) + defer { Task { await session.stop() } } + + let stateTask = Task { try await service.setDeviceGPSEnabledVerified(false) } + + try await waitUntil("service should send device GPS update") { + await transport.sentData.count == 2 + } + let sentAfterWrite = await transport.sentData + #expect(sentAfterWrite[1] == PacketBuilder.setCustomVar(key: "gps", value: "0")) + await transport.simulateOK() + + try await waitUntil("service should verify device GPS state") { + await transport.sentData.count == 3 + } + let sentAfterVerify = await transport.sentData + #expect(sentAfterVerify[2] == PacketBuilder.getCustomVars()) + await transport.simulateReceive(makeCustomVarsPacket("gps:0")) + + try await waitUntil("service should refresh self info") { + await transport.sentData.count == 4 + } + let sentAfterRefresh = await transport.sentData + #expect(sentAfterRefresh[3] == PacketBuilder.appStart(clientId: SessionConfiguration.default.clientIdentifier)) + await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) + + let state = try await stateTask.value + #expect(state == DeviceGPSState(isSupported: true, isEnabled: false)) + } + + @Test + @MainActor + func `setManualLocationVerified disables device GPS before writing location`() async throws { + let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) + defer { Task { await session.stop() } } + + let saveTask = Task { + try await service.setManualLocationVerified(latitude: 0, longitude: 0) + } + + try await waitUntil("manual save should query device GPS state") { + await transport.sentData.count == 2 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:1")) + + try await waitUntil("manual save should disable device GPS") { + await transport.sentData.count == 3 + } + let sentAfterDisable = await transport.sentData + #expect(sentAfterDisable[2] == PacketBuilder.setCustomVar(key: "gps", value: "0")) + await transport.simulateOK() - @Test("getDeviceGPSState returns unsupported when gps custom var is missing") - @MainActor - func getDeviceGPSState_unsupported() async throws { - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } - - let stateTask = Task { try await service.getDeviceGPSState() } - try await waitUntil("service should request custom vars") { - await transport.sentData.count == 2 - } - - await transport.simulateReceive(makeCustomVarsPacket()) - let state = try await stateTask.value - - #expect(state == DeviceGPSState(isSupported: false, isEnabled: false)) - } - - @Test("getDeviceGPSState returns enabled when gps custom var is on") - @MainActor - func getDeviceGPSState_enabled() async throws { - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } - - let stateTask = Task { try await service.getDeviceGPSState() } - try await waitUntil("service should request custom vars") { - await transport.sentData.count == 2 - } - - await transport.simulateReceive(makeCustomVarsPacket("gps:1,foo:bar")) - let state = try await stateTask.value - - #expect(state == DeviceGPSState(isSupported: true, isEnabled: true)) - } - - @Test("setDeviceGPSEnabledVerified writes, verifies, and refreshes device info") - @MainActor - func setDeviceGPSEnabledVerified_success() async throws { - let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) - defer { Task { await session.stop() } } - - let stateTask = Task { try await service.setDeviceGPSEnabledVerified(false) } - - try await waitUntil("service should send device GPS update") { - await transport.sentData.count == 2 - } - let sentAfterWrite = await transport.sentData - #expect(sentAfterWrite[1] == PacketBuilder.setCustomVar(key: "gps", value: "0")) - await transport.simulateOK() - - try await waitUntil("service should verify device GPS state") { - await transport.sentData.count == 3 - } - let sentAfterVerify = await transport.sentData - #expect(sentAfterVerify[2] == PacketBuilder.getCustomVars()) - await transport.simulateReceive(makeCustomVarsPacket("gps:0")) - - try await waitUntil("service should refresh self info") { - await transport.sentData.count == 4 - } - let sentAfterRefresh = await transport.sentData - #expect(sentAfterRefresh[3] == PacketBuilder.appStart(clientId: SessionConfiguration.default.clientIdentifier)) - await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) - - let state = try await stateTask.value - #expect(state == DeviceGPSState(isSupported: true, isEnabled: false)) - } - - @Test("setManualLocationVerified disables device GPS before writing location") - @MainActor - func setManualLocationVerified_turnsOffGPSFirst() async throws { - let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) - defer { Task { await session.stop() } } - - let saveTask = Task { - try await service.setManualLocationVerified(latitude: 0, longitude: 0) - } - - try await waitUntil("manual save should query device GPS state") { - await transport.sentData.count == 2 - } - await transport.simulateReceive(makeCustomVarsPacket("gps:1")) - - try await waitUntil("manual save should disable device GPS") { - await transport.sentData.count == 3 - } - let sentAfterDisable = await transport.sentData - #expect(sentAfterDisable[2] == PacketBuilder.setCustomVar(key: "gps", value: "0")) - await transport.simulateOK() - - try await waitUntil("manual save should verify device GPS off") { - await transport.sentData.count == 4 - } - await transport.simulateReceive(makeCustomVarsPacket("gps:0")) - - try await waitUntil("manual save should refresh after device GPS change") { - await transport.sentData.count == 5 - } - await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) - - try await waitUntil("manual save should send location update") { - await transport.sentData.count == 6 - } - let sentAfterLocationWrite = await transport.sentData - #expect(sentAfterLocationWrite[5] == PacketBuilder.setCoordinates(latitude: 0, longitude: 0)) - await transport.simulateOK() - - try await waitUntil("manual save should verify location through self info") { - await transport.sentData.count == 7 - } - await transport.simulateReceive(makeSelfInfoPacket(latitude: 0, longitude: 0)) - - let selfInfo = try await saveTask.value - #expect(selfInfo.latitude == 0) - #expect(selfInfo.longitude == 0) - } - - @Test("setManualLocationVerified aborts when device GPS stays on") - @MainActor - func setManualLocationVerified_abortsWhenGPSDisableDoesNotStick() async throws { - let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) - defer { Task { await session.stop() } } - - let saveTask = Task { - try await service.setManualLocationVerified(latitude: 0, longitude: 0) - } - - try await waitUntil("manual save should query device GPS state") { - await transport.sentData.count == 2 - } - await transport.simulateReceive(makeCustomVarsPacket("gps:1")) - - try await waitUntil("manual save should disable device GPS") { - await transport.sentData.count == 3 - } - await transport.simulateOK() - - try await waitUntil("manual save should verify device GPS off") { - await transport.sentData.count == 4 - } - await transport.simulateReceive(makeCustomVarsPacket("gps:1")) - - await #expect(throws: SettingsServiceError.self) { - _ = try await saveTask.value - } - - let sent = await transport.sentData - #expect(sent.count == 4) - } - - private func makeService( - initialLatitude: Double = 0, - initialLongitude: Double = 0 - ) async throws -> (SettingsService, MeshCoreSession, MockTransport) { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - - let startTask = Task { - try await session.start() - } - - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - - await transport.simulateReceive( - makeSelfInfoPacket(latitude: initialLatitude, longitude: initialLongitude) - ) - try await startTask.value - - return (SettingsService(session: session), session, transport) - } - - private func makeCustomVarsPacket(_ raw: String = "") -> Data { - var packet = Data([ResponseCode.customVars.rawValue]) - packet.append(contentsOf: raw.utf8) - return packet - } - - private func makeSelfInfoPacket( - latitude: Double, - longitude: Double, - advertisementLocationPolicy: UInt8 = 0 - ) -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(latitude * 1_000_000)) - payload.append(int32Bytes(longitude * 1_000_000)) - payload.append(0) - payload.append(advertisementLocationPolicy) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet - } - - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } - } - - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } + try await waitUntil("manual save should verify device GPS off") { + await transport.sentData.count == 4 } + await transport.simulateReceive(makeCustomVarsPacket("gps:0")) + + try await waitUntil("manual save should refresh after device GPS change") { + await transport.sentData.count == 5 + } + await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) + + try await waitUntil("manual save should send location update") { + await transport.sentData.count == 6 + } + let sentAfterLocationWrite = await transport.sentData + #expect(sentAfterLocationWrite[5] == PacketBuilder.setCoordinates(latitude: 0, longitude: 0)) + await transport.simulateOK() + + try await waitUntil("manual save should verify location through self info") { + await transport.sentData.count == 7 + } + await transport.simulateReceive(makeSelfInfoPacket(latitude: 0, longitude: 0)) + + let selfInfo = try await saveTask.value + #expect(selfInfo.latitude == 0) + #expect(selfInfo.longitude == 0) + } + + @Test + @MainActor + func `setManualLocationVerified aborts when device GPS stays on`() async throws { + let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) + defer { Task { await session.stop() } } + + let saveTask = Task { + try await service.setManualLocationVerified(latitude: 0, longitude: 0) + } + + try await waitUntil("manual save should query device GPS state") { + await transport.sentData.count == 2 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:1")) + + try await waitUntil("manual save should disable device GPS") { + await transport.sentData.count == 3 + } + await transport.simulateOK() + + try await waitUntil("manual save should verify device GPS off") { + await transport.sentData.count == 4 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:1")) + + await #expect(throws: SettingsServiceError.self) { + _ = try await saveTask.value + } + + let sent = await transport.sentData + #expect(sent.count == 4) + } + + private func makeService( + initialLatitude: Double = 0, + initialLongitude: Double = 0 + ) async throws -> (SettingsService, MeshCoreSession, MockTransport) { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + + let startTask = Task { + try await session.start() + } + + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + + await transport.simulateReceive( + makeSelfInfoPacket(latitude: initialLatitude, longitude: initialLongitude) + ) + try await startTask.value + + return (SettingsService(session: session), session, transport) + } + + private func makeCustomVarsPacket(_ raw: String = "") -> Data { + var packet = Data([ResponseCode.customVars.rawValue]) + packet.append(contentsOf: raw.utf8) + return packet + } + + private func makeSelfInfoPacket( + latitude: Double, + longitude: Double, + advertisementLocationPolicy: UInt8 = 0 + ) -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(latitude * 1_000_000)) + payload.append(int32Bytes(longitude * 1_000_000)) + payload.append(0) + payload.append(advertisementLocationPolicy) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } + + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } + + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift index 0ded0691..b7a6ba5b 100644 --- a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift @@ -1,90 +1,89 @@ import Foundation -import Testing @testable import MC1Services +import Testing /// `saveMessage` does not persist the link-preview or `reactionSummary` columns, and /// the wire `ChannelInfo` carries no notification/favorite state. These verify the /// seed's dedicated mutators (and the DTO-based `saveChannel`) write those through. @MainActor struct SimulatorSeedTests { + private func seededStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + try await SimulatorConnectionMode().seedDataStore(store) + return store + } - private func seededStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - try await SimulatorConnectionMode().seedDataStore(store) - return store - } - - private let radioID = MockDataProvider.simulatorDeviceID + private let radioID = MockDataProvider.simulatorDeviceID - @Test - func linkPreviewColumnsLand() async throws { - let store = try await seededStore() - let message = try await store.fetchMessage(id: MockDataProvider.aliceLinkPreviewMessageID) - let unwrapped = try #require(message) - #expect(unwrapped.linkPreviewTitle == "Skyline Ridge Trail Guide") - #expect(unwrapped.linkPreviewFetched == true) - let imageData = try #require(unwrapped.linkPreviewImageData) - #expect(!imageData.isEmpty) - } + @Test + func `link preview columns land`() async throws { + let store = try await seededStore() + let message = try await store.fetchMessage(id: MockDataProvider.aliceLinkPreviewMessageID) + let unwrapped = try #require(message) + #expect(unwrapped.linkPreviewTitle == "Skyline Ridge Trail Guide") + #expect(unwrapped.linkPreviewFetched == true) + let imageData = try #require(unwrapped.linkPreviewImageData) + #expect(!imageData.isEmpty) + } - @Test - func reactionSummaryAndRowsLand() async throws { - let store = try await seededStore() + @Test + func `reaction summary and rows land`() async throws { + let store = try await seededStore() - let dmMessage = try #require(try await store.fetchMessage(id: MockDataProvider.aliceReactedMessageID)) - #expect(dmMessage.reactionSummary == "👍:2,❤️:1") - let dmReactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) - #expect(dmReactions.count == 3) + let dmMessage = try #require(try await store.fetchMessage(id: MockDataProvider.aliceReactedMessageID)) + #expect(dmMessage.reactionSummary == "👍:2,❤️:1") + let dmReactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) + #expect(dmReactions.count == 3) - let channelMessage = try #require(try await store.fetchMessage(id: MockDataProvider.bayAreaReactedMessageID)) - #expect(channelMessage.reactionSummary == "🎉:2") - let channelReactions = try await store.fetchReactions(for: MockDataProvider.bayAreaReactedMessageID) - #expect(channelReactions.count == 2) - } + let channelMessage = try #require(try await store.fetchMessage(id: MockDataProvider.bayAreaReactedMessageID)) + #expect(channelMessage.reactionSummary == "🎉:2") + let channelReactions = try await store.fetchReactions(for: MockDataProvider.bayAreaReactedMessageID) + #expect(channelReactions.count == 2) + } - @Test - func channelNotificationStateLands() async throws { - let store = try await seededStore() - let channels = try await store.fetchChannels(radioID: radioID) + @Test + func `channel notification state lands`() async throws { + let store = try await seededStore() + let channels = try await store.fetchChannels(radioID: radioID) - let muted = try #require(channels.first { $0.index == MockDataProvider.trailCrewChannelIndex }) - #expect(muted.notificationLevel == .muted) + let muted = try #require(channels.first { $0.index == MockDataProvider.trailCrewChannelIndex }) + #expect(muted.notificationLevel == .muted) - let favorite = try #require(channels.first { $0.index == MockDataProvider.bayAreaChannelIndex }) - #expect(favorite.isFavorite) - } + let favorite = try #require(channels.first { $0.index == MockDataProvider.bayAreaChannelIndex }) + #expect(favorite.isFavorite) + } - @Test - func heardRepeatsLand() async throws { - let store = try await seededStore() - let repeats = try await store.fetchMessageRepeats(messageID: MockDataProvider.frankRepeatMessageID) - #expect(repeats.count == 3) - } + @Test + func `heard repeats land`() async throws { + let store = try await seededStore() + let repeats = try await store.fetchMessageRepeats(messageID: MockDataProvider.frankRepeatMessageID) + #expect(repeats.count == 3) + } - @Test - func floodRouteFieldsRoundTripThroughSaveMessage() async throws { - let store = try await seededStore() - let floodMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000004")! - let message = try #require(try await store.fetchMessage(id: floodMessageID)) - #expect(message.routeType == .tcFlood) - #expect(message.regionScope == "US915") - } + @Test + func `flood route fields round trip through save message`() async throws { + let store = try await seededStore() + let floodMessageID = try #require(UUID(uuidString: "60000000-0000-0000-0000-000000000004")) + let message = try #require(try await store.fetchMessage(id: floodMessageID)) + #expect(message.routeType == .tcFlood) + #expect(message.regionScope == "US915") + } - @Test - func reseedingIsIdempotent() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let mode = SimulatorConnectionMode() - try await mode.seedDataStore(store) - try await mode.seedDataStore(store) + @Test + func `reseeding is idempotent`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let mode = SimulatorConnectionMode() + try await mode.seedDataStore(store) + try await mode.seedDataStore(store) - // Unique-id upsert means a second pass does not duplicate rows. - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.count == 3) - let repeats = try await store.fetchMessageRepeats(messageID: MockDataProvider.frankRepeatMessageID) - #expect(repeats.count == 3) - let reactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) - #expect(reactions.count == 3) - } + // Unique-id upsert means a second pass does not duplicate rows. + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.count == 3) + let repeats = try await store.fetchMessageRepeats(messageID: MockDataProvider.frankRepeatMessageID) + #expect(repeats.count == 3) + let reactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) + #expect(reactions.count == 3) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SortDateBackfillMigrationTests.swift b/MC1Services/Tests/MC1ServicesTests/SortDateBackfillMigrationTests.swift index 91b4418d..7f179daa 100644 --- a/MC1Services/Tests/MC1ServicesTests/SortDateBackfillMigrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SortDateBackfillMigrationTests.swift @@ -1,78 +1,85 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("Message sortDate backfill migration", .serialized) struct SortDateBackfillMigrationTests { + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } + @Test + func `Pre-existing rows get sortDate backfilled to createdAt`() async throws { + let suiteName = "test.\(UUID().uuidString)" + // UserDefaults is thread-safe but not marked Sendable, so reusing this value + // across the performSortDateBackfillMigration actor boundary needs the isolation opt-out. + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } - @Test("Pre-existing rows get sortDate backfilled to createdAt") - func backfillSetsSortDateToCreatedAt() async throws { - let store = try await createTestStore() - await store.resetSortDateBackfillMigrationFlag() - let radioID = UUID() + let store = try await createTestStore() + let radioID = UUID() - let firstID = UUID() - let firstCreatedAt = Date(timeIntervalSince1970: 1_704_067_200) - try await store.insertMessageWithSortDate( - id: firstID, - radioID: radioID, - text: "Hello", - createdAt: firstCreatedAt, - sortDate: .distantPast - ) + let firstID = UUID() + let firstCreatedAt = Date(timeIntervalSince1970: 1_704_067_200) + try await store.insertMessageWithSortDate( + id: firstID, + radioID: radioID, + text: "Hello", + createdAt: firstCreatedAt, + sortDate: .distantPast + ) - let secondID = UUID() - let secondCreatedAt = Date(timeIntervalSince1970: 1_704_070_800) - try await store.insertMessageWithSortDate( - id: secondID, - radioID: radioID, - text: "World", - createdAt: secondCreatedAt, - sortDate: .distantPast - ) + let secondID = UUID() + let secondCreatedAt = Date(timeIntervalSince1970: 1_704_070_800) + try await store.insertMessageWithSortDate( + id: secondID, + radioID: radioID, + text: "World", + createdAt: secondCreatedAt, + sortDate: .distantPast + ) - try await store.performSortDateBackfillMigration() + try await store.performSortDateBackfillMigration(defaults: defaults) - let messages = try await store.fetchAllMessages() - #expect(messages.count == 2) - for message in messages { - #expect(message.sortDate == message.createdAt) - } + let messages = try await store.fetchAllMessages() + #expect(messages.count == 2) + for message in messages { + #expect(message.sortDate == message.createdAt) } + } - @Test("Migration is idempotent — second run is a no-op") - func migrationIsIdempotent() async throws { - let store = try await createTestStore() - await store.resetSortDateBackfillMigrationFlag() - let radioID = UUID() + @Test + func `Migration is idempotent — second run is a no-op`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let messageID = UUID() - let createdAt = Date(timeIntervalSince1970: 1_704_067_200) - try await store.insertMessageWithSortDate( - id: messageID, - radioID: radioID, - text: "Hello", - createdAt: createdAt, - sortDate: .distantPast - ) + let store = try await createTestStore() + let radioID = UUID() - try await store.performSortDateBackfillMigration() + let messageID = UUID() + let createdAt = Date(timeIntervalSince1970: 1_704_067_200) + try await store.insertMessageWithSortDate( + id: messageID, + radioID: radioID, + text: "Hello", + createdAt: createdAt, + sortDate: .distantPast + ) - let backfilled = try await store.fetchMessage(id: messageID) - #expect(backfilled?.sortDate == createdAt) + try await store.performSortDateBackfillMigration(defaults: defaults) - // Re-skew the row after the first run. A second call with the flag set must - // not touch it — otherwise the backfill would re-run every launch. - try await store.setMessageSortDate(id: messageID, sortDate: .distantPast) - try await store.performSortDateBackfillMigration() + let backfilled = try await store.fetchMessage(id: messageID) + #expect(backfilled?.sortDate == createdAt) - let afterSecondRun = try await store.fetchMessage(id: messageID) - #expect(afterSecondRun?.sortDate == .distantPast, "second run must be a no-op") - } + // Re-skew the row after the first run. A second call with the flag set must + // not touch it — otherwise the backfill would re-run every launch. + try await store.setMessageSortDate(id: messageID, sortDate: .distantPast) + try await store.performSortDateBackfillMigration(defaults: defaults) + + let afterSecondRun = try await store.fetchMessage(id: messageID) + #expect(afterSecondRun?.sortDate == .distantPast, "second run must be a no-op") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SortDateResetMigrationTests.swift b/MC1Services/Tests/MC1ServicesTests/SortDateResetMigrationTests.swift index 07d06c78..1ccde936 100644 --- a/MC1Services/Tests/MC1ServicesTests/SortDateResetMigrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SortDateResetMigrationTests.swift @@ -1,64 +1,71 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("Message sortDate reset migration", .serialized) struct SortDateResetMigrationTests { + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + @Test + func `Reset re-normalizes a send-time sortDate back to createdAt`() async throws { + let suiteName = "test.\(UUID().uuidString)" + // UserDefaults is thread-safe but not marked Sendable, so reusing this value + // across the performSortDateResetMigration actor boundary needs the isolation opt-out. + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } + let store = try await createTestStore() + let radioID = UUID() - @Test("Reset re-normalizes a send-time sortDate back to createdAt") - func resetUnburiesSendTimeSortDate() async throws { - let store = try await createTestStore() - await store.resetSortDateResetMigrationFlag() - let radioID = UUID() + // A row buried by the interim send-time sort: createdAt is the drain time, + // but sortDate was written far in the past from the sender's clock. The + // original backfill already ran (flag set), so only this reset can fix it. + let messageID = UUID() + let createdAt = Date(timeIntervalSince1970: 1_704_067_200) + let buriedSortDate = Date(timeIntervalSince1970: 1_700_000_000) + try await store.insertMessageWithSortDate( + id: messageID, + radioID: radioID, + text: "Buried backlog", + createdAt: createdAt, + sortDate: buriedSortDate + ) - // A row buried by the interim send-time sort: createdAt is the drain time, - // but sortDate was written far in the past from the sender's clock. The - // original backfill already ran (flag set), so only this reset can fix it. - let messageID = UUID() - let createdAt = Date(timeIntervalSince1970: 1_704_067_200) - let buriedSortDate = Date(timeIntervalSince1970: 1_700_000_000) - try await store.insertMessageWithSortDate( - id: messageID, - radioID: radioID, - text: "Buried backlog", - createdAt: createdAt, - sortDate: buriedSortDate - ) + try await store.performSortDateResetMigration(defaults: defaults) - try await store.performSortDateResetMigration() + let migrated = try await store.fetchMessage(id: messageID) + #expect(migrated?.sortDate == createdAt) + } - let migrated = try await store.fetchMessage(id: messageID) - #expect(migrated?.sortDate == createdAt) - } + @Test + func `Reset is idempotent — second run is a no-op`() async throws { + let suiteName = "test.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } - @Test("Reset is idempotent — second run is a no-op") - func resetIsIdempotent() async throws { - let store = try await createTestStore() - await store.resetSortDateResetMigrationFlag() - let radioID = UUID() + let store = try await createTestStore() + let radioID = UUID() - let messageID = UUID() - let createdAt = Date(timeIntervalSince1970: 1_704_067_200) - try await store.insertMessageWithSortDate( - id: messageID, - radioID: radioID, - text: "Hello", - createdAt: createdAt, - sortDate: Date(timeIntervalSince1970: 1_700_000_000) - ) + let messageID = UUID() + let createdAt = Date(timeIntervalSince1970: 1_704_067_200) + try await store.insertMessageWithSortDate( + id: messageID, + radioID: radioID, + text: "Hello", + createdAt: createdAt, + sortDate: Date(timeIntervalSince1970: 1_700_000_000) + ) - try await store.performSortDateResetMigration() - #expect(try await store.fetchMessage(id: messageID)?.sortDate == createdAt) + try await store.performSortDateResetMigration(defaults: defaults) + #expect(try await store.fetchMessage(id: messageID)?.sortDate == createdAt) - // Re-skew after the first run; the flag must keep a second run from touching it. - try await store.setMessageSortDate(id: messageID, sortDate: .distantPast) - try await store.performSortDateResetMigration() - #expect(try await store.fetchMessage(id: messageID)?.sortDate == .distantPast, "second run must be a no-op") - } + // Re-skew after the first run; the flag must keep a second run from touching it. + try await store.setMessageSortDate(id: messageID, sortDate: .distantPast) + try await store.performSortDateResetMigration(defaults: defaults) + #expect(try await store.fetchMessage(id: messageID)?.sortDate == .distantPast, "second run must be a no-op") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/StoreCatalogTests.swift b/MC1Services/Tests/MC1ServicesTests/StoreCatalogTests.swift index bcd0601e..fba6e701 100644 --- a/MC1Services/Tests/MC1ServicesTests/StoreCatalogTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/StoreCatalogTests.swift @@ -1,52 +1,50 @@ -import Testing @testable import MC1Services +import Testing @Suite("StoreCatalog") struct StoreCatalogTests { - - @Test("sellableProductIDs are the bundle plus the six tips — themes are not sold standalone") - func sellableProductIDsCount() { - #expect(StoreCatalog.sellableProductIDs.count == 7) - #expect(StoreCatalog.sellableProductIDs.contains(StoreCatalog.Theme.bundleAll)) - #expect(StoreCatalog.sellableProductIDs.isSuperset(of: StoreCatalog.Tip.all)) - #expect(StoreCatalog.sellableProductIDs.isDisjoint(with: StoreCatalog.Theme.bundledThemeIDs)) - } - - @Test("bundledThemeIDs are the nine themes the bundle unlocks, excluding the bundle itself") - func bundledThemeCount() { - #expect(StoreCatalog.Theme.bundledThemeIDs.count == 9) - #expect(!StoreCatalog.Theme.bundledThemeIDs.contains(StoreCatalog.Theme.bundleAll)) - } - - @Test("Tip.all has six entries, disjoint from the themes") - func tipCounts() { - #expect(StoreCatalog.Tip.all.count == 6) - #expect(StoreCatalog.Tip.all.isDisjoint(with: StoreCatalog.Theme.bundledThemeIDs)) - #expect(!StoreCatalog.Tip.all.contains(StoreCatalog.Theme.bundleAll)) - } - - @Test("every product ID uses the io.pocketmesh.app prefix") - func productIDPrefix() { - for id in StoreCatalog.sellableProductIDs.union(StoreCatalog.Theme.bundledThemeIDs) { - #expect(id.hasPrefix("io.pocketmesh.app.")) - } + @Test + func `sellableProductIDs are the bundle plus the six tips — themes are not sold standalone`() { + #expect(StoreCatalog.sellableProductIDs.count == 7) + #expect(StoreCatalog.sellableProductIDs.contains(StoreCatalog.Theme.bundleAll)) + #expect(StoreCatalog.sellableProductIDs.isSuperset(of: StoreCatalog.Tip.all)) + #expect(StoreCatalog.sellableProductIDs.isDisjoint(with: StoreCatalog.Theme.bundledThemeIDs)) + } + + @Test + func `bundledThemeIDs are the nine themes the bundle unlocks, excluding the bundle itself`() { + #expect(StoreCatalog.Theme.bundledThemeIDs.count == 9) + #expect(!StoreCatalog.Theme.bundledThemeIDs.contains(StoreCatalog.Theme.bundleAll)) + } + + @Test + func `Tip.all has six entries, disjoint from the themes`() { + #expect(StoreCatalog.Tip.all.count == 6) + #expect(StoreCatalog.Tip.all.isDisjoint(with: StoreCatalog.Theme.bundledThemeIDs)) + #expect(!StoreCatalog.Tip.all.contains(StoreCatalog.Theme.bundleAll)) + } + + @Test + func `every product ID uses the io.pocketmesh.app prefix`() { + for id in StoreCatalog.sellableProductIDs.union(StoreCatalog.Theme.bundledThemeIDs) { + #expect(id.hasPrefix("io.pocketmesh.app.")) } + } } @Suite("Store value types") struct StoreValueTypeTests { - - @Test("StoreLoadState is Equatable across its cases") - func loadStateEquatable() { - #expect(StoreLoadState.idle == .idle) - #expect(StoreLoadState.loading != .loaded) - #expect(StoreLoadState.failed != .idle) - } - - @Test("StorePurchaseOutcome distinguishes its three cases") - func purchaseOutcomeEquatable() { - #expect(StorePurchaseOutcome.purchased == .purchased) - #expect(StorePurchaseOutcome.pending != .purchased) - #expect(StorePurchaseOutcome.userCancelled != .pending) - } + @Test + func `StoreLoadState is Equatable across its cases`() { + #expect(StoreLoadState.idle == .idle) + #expect(StoreLoadState.loading != .loaded) + #expect(StoreLoadState.failed != .idle) + } + + @Test + func `StorePurchaseOutcome distinguishes its three cases`() { + #expect(StorePurchaseOutcome.purchased == .purchased) + #expect(StorePurchaseOutcome.pending != .purchased) + #expect(StorePurchaseOutcome.userCancelled != .pending) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/StoreServiceErrorTests.swift b/MC1Services/Tests/MC1ServicesTests/StoreServiceErrorTests.swift index 79656ffe..b130b8bc 100644 --- a/MC1Services/Tests/MC1ServicesTests/StoreServiceErrorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/StoreServiceErrorTests.swift @@ -1,59 +1,58 @@ -import Testing -import StoreKit import Foundation @testable import MC1Services +import StoreKit +import Testing @Suite("StoreServiceError mapping") struct StoreServiceErrorTests { - - @Test("every case has a non-empty English description") - func descriptions() { - let cases: [StoreServiceError] = [ - .productsNotLoaded, .productNotFound(productID: "x"), - .purchaseFailed(reason: "boom"), .verificationFailed, .notEntitled, - .networkUnavailable, .storefrontUnavailable, .unsupported - ] - for error in cases { - #expect(error.errorDescription?.isEmpty == false) - } - } - - @Test("purchaseFailed Equatable compares the reason string") - func purchaseFailedEquatable() { - #expect(StoreServiceError.purchaseFailed(reason: "a") == .purchaseFailed(reason: "a")) - #expect(StoreServiceError.purchaseFailed(reason: "a") != .purchaseFailed(reason: "b")) + @Test + func `every case has a non-empty English description`() { + let cases: [StoreServiceError] = [ + .productsNotLoaded, .productNotFound(productID: "x"), + .purchaseFailed(reason: "boom"), .verificationFailed, .notEntitled, + .networkUnavailable, .storefrontUnavailable, .unsupported + ] + for error in cases { + #expect(error.errorDescription?.isEmpty == false) } - - @Test("network error maps to networkUnavailable") - func mapNetwork() { - let mapped = StoreServiceError.from(.networkError(URLError(.notConnectedToInternet))) - #expect(mapped == .networkUnavailable) + } + + @Test + func `purchaseFailed Equatable compares the reason string`() { + #expect(StoreServiceError.purchaseFailed(reason: "a") == .purchaseFailed(reason: "a")) + #expect(StoreServiceError.purchaseFailed(reason: "a") != .purchaseFailed(reason: "b")) + } + + @Test + func `network error maps to networkUnavailable`() { + let mapped = StoreServiceError.from(.networkError(URLError(.notConnectedToInternet))) + #expect(mapped == .networkUnavailable) + } + + @Test + func `storefront / unsupported / notEntitled map correctly`() { + #expect(StoreServiceError.from(.notAvailableInStorefront) == .storefrontUnavailable) + if #available(iOS 18.4, macOS 15.4, *) { + #expect(StoreServiceError.from(.unsupported) == .unsupported) } - - @Test("storefront / unsupported / notEntitled map correctly") - func mapDirectCases() { - #expect(StoreServiceError.from(.notAvailableInStorefront) == .storefrontUnavailable) - if #available(iOS 18.4, macOS 15.4, *) { - #expect(StoreServiceError.from(.unsupported) == .unsupported) - } - #expect(StoreServiceError.from(.notEntitled) == .notEntitled) - } - - @Test("userCancelled maps to nil (handled as a non-error outcome)") - func mapUserCancelled() { - #expect(StoreServiceError.from(.userCancelled) == nil) - } - - @Test("unknown maps to a purchaseFailed with a fixed reason") - func mapUnknown() { - #expect(StoreServiceError.from(.unknown) == .purchaseFailed(reason: "Unknown StoreKit error")) - } - - @Test("systemError maps to purchaseFailed") - func mapSystemError() { - let mapped = StoreServiceError.from(.systemError(URLError(.unknown))) - if case .purchaseFailed = mapped { } else { - Issue.record("expected .purchaseFailed, got \(String(describing: mapped))") - } + #expect(StoreServiceError.from(.notEntitled) == .notEntitled) + } + + @Test + func `userCancelled maps to nil (handled as a non-error outcome)`() { + #expect(StoreServiceError.from(.userCancelled) == nil) + } + + @Test + func `unknown maps to a purchaseFailed with a fixed reason`() { + #expect(StoreServiceError.from(.unknown) == .purchaseFailed(reason: "Unknown StoreKit error")) + } + + @Test + func `systemError maps to purchaseFailed`() { + let mapped = StoreServiceError.from(.systemError(URLError(.unknown))) + if case .purchaseFailed = mapped { } else { + Issue.record("expected .purchaseFailed, got \(String(describing: mapped))") } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorChannelSkipTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorChannelSkipTests.swift index 83e4b6f3..feeebe4a 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorChannelSkipTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorChannelSkipTests.swift @@ -1,411 +1,409 @@ // SyncCoordinatorChannelSkipTests.swift -import Testing import Foundation +@testable import MC1Services import MeshCore import MeshCoreTestSupport -@testable import MC1Services +import Testing @Suite("SyncCoordinator Channel Skip Tests") struct SyncCoordinatorChannelSkipTests { - - private func createTestDataStore( - radioID: UUID, - maxChannels: UInt8 = 8, - lastContactSync: UInt32 = 0 - ) async throws -> PersistenceStore { - try await PersistenceStore.createTestDataStore( - radioID: radioID, - maxChannels: maxChannels, - lastContactSync: lastContactSync - ) - } - - // MARK: - Channel Skip Logic - - @Test("Channels skipped when lastCleanChannelSync is recent and skip window > 0") - @MainActor - func channelsSkippedWhenRecentCleanSync() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.isEmpty, "Channel sync should be skipped when clean sync completed recently") - } - - @Test("Channels sync when lastCleanChannelSync is nil") - @MainActor - func channelsSyncWhenNoLastCleanSync() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30)) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when lastCleanChannelSync is nil") + private func createTestDataStore( + radioID: UUID, + maxChannels: UInt8 = 8, + lastContactSync: UInt32 = 0 + ) async throws -> PersistenceStore { + try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: maxChannels, + lastContactSync: lastContactSync + ) + } + + // MARK: - Channel Skip Logic + + @Test + @MainActor + func `Channels skipped when lastCleanChannelSync is recent and skip window > 0`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.isEmpty, "Channel sync should be skipped when clean sync completed recently") + } + + @Test + @MainActor + func `Channels sync when lastCleanChannelSync is nil`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30)) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when lastCleanChannelSync is nil") + } + + @Test + @MainActor + func `Channels skipped when last attempted channel sync is recent`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig( + channelSyncSkipWindow: .seconds(30), + lastAttemptedChannelSync: Date() + ) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.isEmpty, "Channel sync should be skipped after a recent partial attempt") + } + + @Test + @MainActor + func `Channels sync when lastCleanChannelSync is expired (outside window)`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let expiredDate = Date().addingTimeInterval(-60) // 60s ago, outside 30s window + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: expiredDate) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when lastCleanChannelSync is expired") + } + + @Test + @MainActor + func `forceFullSync bypasses channel skip`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + forceFullSync: true, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when forceFullSync is true") + } + + @Test + @MainActor + func `Zero skip window disables skip`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(lastCleanChannelSync: Date()) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when skip window is zero") + } + + // MARK: - Clean Channel Callback + + @Test + @MainActor + func `Callback fires on clean channel phase (zero errors)`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Channel sync returns success (no errors) + await mockChannelService.setStubbedSyncChannelsResult(.success( + ChannelSyncResult(channelsSynced: 8, errors: []) + )) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { radioID in + #expect(radioID == testDeviceID) + callbackTracker.markCalled() } - @Test("Channels skipped when last attempted channel sync is recent") - @MainActor - func channelsSkippedWhenRecentAttemptedSync() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig( - channelSyncSkipWindow: .seconds(30), - lastAttemptedChannelSync: Date() - ) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.isEmpty, "Channel sync should be skipped after a recent partial attempt") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(callbackTracker.wasCalled, "onCleanChannelSync should fire when channel phase is clean") + } + + @Test + @MainActor + func `Callback fires when initial sync fails but retry recovers`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Initial sync has errors, but retry succeeds + let errors = [ChannelSyncError(index: 2, errorType: .timeout, description: "timeout")] + await mockChannelService.setStubbedSyncChannelsResult(.success( + ChannelSyncResult(channelsSynced: 7, errors: errors) + )) + await mockChannelService.setStubbedRetryResult(.success( + ChannelSyncResult(channelsSynced: 1, errors: []) + )) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } - @Test("Channels sync when lastCleanChannelSync is expired (outside window)") - @MainActor - func channelsSyncWhenExpired() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let expiredDate = Date().addingTimeInterval(-60) // 60s ago, outside 30s window - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: expiredDate) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when lastCleanChannelSync is expired") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(callbackTracker.wasCalled, "onCleanChannelSync should fire when retry recovers all errors") + } + + @Test + @MainActor + func `Callback does not fire when channel sync has errors after retries`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Initial sync has errors, retry also has errors + let errors = [ChannelSyncError(index: 2, errorType: .timeout, description: "timeout")] + await mockChannelService.setStubbedSyncChannelsResult(.success( + ChannelSyncResult(channelsSynced: 7, errors: errors) + )) + let retryErrors = [ChannelSyncError(index: 2, errorType: .timeout, description: "still failing")] + await mockChannelService.setStubbedRetryResult(.success( + ChannelSyncResult(channelsSynced: 0, errors: retryErrors) + )) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } - @Test("forceFullSync bypasses channel skip") - @MainActor - func forceFullSyncBypassesSkip() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - forceFullSync: true, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when forceFullSync is true") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when errors remain after retry") + } + + @Test + @MainActor + func `Callback does not fire with mixed retryable and non-retryable errors even when retry succeeds`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Initial sync: one non-retryable deviceError + one retryable timeout + let errors = [ + ChannelSyncError(index: 5, errorType: .deviceError(code: 3), description: "device error"), + ChannelSyncError(index: 10, errorType: .timeout, description: "timeout"), + ] + await mockChannelService.setStubbedSyncChannelsResult(.success( + ChannelSyncResult(channelsSynced: 6, errors: errors) + )) + // Retry succeeds for the retryable timeout (index 10), but deviceError (index 5) was never retried + await mockChannelService.setStubbedRetryResult(.success( + ChannelSyncResult(channelsSynced: 1, errors: []) + )) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } - @Test("Zero skip window disables skip") - @MainActor - func zeroSkipWindowDisablesSkip() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(lastCleanChannelSync: Date()) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when skip window is zero") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(!callbackTracker.wasCalled, "onCleanChannelSync must not fire when non-retryable errors remain unresolved") + } + + @Test + @MainActor + func `Callback does not fire when channels are skipped`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } - // MARK: - Clean Channel Callback - - @Test("Callback fires on clean channel phase (zero errors)") - @MainActor - func callbackFiresOnCleanPhase() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Channel sync returns success (no errors) - await mockChannelService.setStubbedSyncChannelsResult(.success( - ChannelSyncResult(channelsSynced: 8, errors: []) - )) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { radioID in - #expect(radioID == testDeviceID) - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(callbackTracker.wasCalled, "onCleanChannelSync should fire when channel phase is clean") - } - - @Test("Callback fires when initial sync fails but retry recovers") - @MainActor - func callbackFiresWhenRetryRecovers() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Initial sync has errors, but retry succeeds - let errors = [ChannelSyncError(index: 2, errorType: .timeout, description: "timeout")] - await mockChannelService.setStubbedSyncChannelsResult(.success( - ChannelSyncResult(channelsSynced: 7, errors: errors) - )) - await mockChannelService.setStubbedRetryResult(.success( - ChannelSyncResult(channelsSynced: 1, errors: []) - )) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(callbackTracker.wasCalled, "onCleanChannelSync should fire when retry recovers all errors") - } - - @Test("Callback does not fire when channel sync has errors after retries") - @MainActor - func callbackDoesNotFireWithErrors() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Initial sync has errors, retry also has errors - let errors = [ChannelSyncError(index: 2, errorType: .timeout, description: "timeout")] - await mockChannelService.setStubbedSyncChannelsResult(.success( - ChannelSyncResult(channelsSynced: 7, errors: errors) - )) - let retryErrors = [ChannelSyncError(index: 2, errorType: .timeout, description: "still failing")] - await mockChannelService.setStubbedRetryResult(.success( - ChannelSyncResult(channelsSynced: 0, errors: retryErrors) - )) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when errors remain after retry") - } - - @Test("Callback does not fire with mixed retryable and non-retryable errors even when retry succeeds") - @MainActor - func callbackDoesNotFireWithMixedErrors() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Initial sync: one non-retryable deviceError + one retryable timeout - let errors = [ - ChannelSyncError(index: 5, errorType: .deviceError(code: 3), description: "device error"), - ChannelSyncError(index: 10, errorType: .timeout, description: "timeout"), - ] - await mockChannelService.setStubbedSyncChannelsResult(.success( - ChannelSyncResult(channelsSynced: 6, errors: errors) - )) - // Retry succeeds for the retryable timeout (index 10), but deviceError (index 5) was never retried - await mockChannelService.setStubbedRetryResult(.success( - ChannelSyncResult(channelsSynced: 1, errors: []) - )) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(!callbackTracker.wasCalled, "onCleanChannelSync must not fire when non-retryable errors remain unresolved") - } - - @Test("Callback does not fire when channels are skipped") - @MainActor - func callbackDoesNotFireWhenSkipped() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) - ) - - #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when channels are skipped") - } - - @Test("Callback does not fire when initial sync is clean but channels skipped in background") - @MainActor - func callbackDoesNotFireInBackground() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let mockAppStateProvider = MockAppStateProvider(isInForeground: false) - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: mockAppStateProvider - ) - - #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when channels are skipped in background") - } - - // MARK: - Post-sync diagnostics still run when channels skipped - - @Test("Post-sync diagnostics still run when channels are skipped") - @MainActor - func diagnosticsRunWhenChannelsSkipped() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Skip channels (recent clean sync) -- performFullSync should still complete - // because logPostSyncChannelDiagnostics and refreshRxLogChannels read from the - // database (not the mock), so they execute regardless of whether channels were skipped. - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.isEmpty, "Channel sync should be skipped") - - // Sync should complete successfully (state == .synced) even with skipped channels - #expect(coordinator.state == .synced, "Sync should complete successfully when channels are skipped") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) + ) + + #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when channels are skipped") + } + + @Test + @MainActor + func `Callback does not fire when initial sync is clean but channels skipped in background`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let mockAppStateProvider = MockAppStateProvider(isInForeground: false) + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: mockAppStateProvider + ) + + #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when channels are skipped in background") + } + + // MARK: - Post-sync diagnostics still run when channels skipped + + @Test + @MainActor + func `Post-sync diagnostics still run when channels are skipped`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Skip channels (recent clean sync) -- performFullSync should still complete + // because logPostSyncChannelDiagnostics and refreshRxLogChannels read from the + // database (not the mock), so they execute regardless of whether channels were skipped. + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.isEmpty, "Channel sync should be skipped") + + // Sync should complete successfully (state == .synced) even with skipped channels + #expect(coordinator.state == .synced, "Sync should complete successfully when channels are skipped") + } } // MARK: - Mock Helper Extensions extension MockChannelService { - func setStubbedSyncChannelsResult(_ result: Result) { - stubbedSyncChannelsResult = result - } + func setStubbedSyncChannelsResult(_ result: Result) { + stubbedSyncChannelsResult = result + } - func setStubbedRetryResult(_ result: Result) { - stubbedRetryResult = result - } + func setStubbedRetryResult(_ result: Result) { + stubbedRetryResult = result + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorDataEventTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorDataEventTests.swift index 6928f0fb..ab9443a4 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorDataEventTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorDataEventTests.swift @@ -1,66 +1,65 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("SyncCoordinator data events") struct SyncCoordinatorDataEventTests { + @Test + @MainActor + func `two subscribers both observe contactsChanged after notifyContactsChanged`() async { + let coordinator = SyncCoordinator() + let streamA = coordinator.dataEvents() + let streamB = coordinator.dataEvents() - @Test("two subscribers both observe contactsChanged after notifyContactsChanged") - @MainActor - func twoSubscribersObserveContactsChanged() async { - let coordinator = SyncCoordinator() - let streamA = coordinator.dataEvents() - let streamB = coordinator.dataEvents() + coordinator.notifyContactsChanged() + coordinator.notifyConversationsChanged() + coordinator.finishDataEvents() - coordinator.notifyContactsChanged() - coordinator.notifyConversationsChanged() - coordinator.finishDataEvents() - - var eventsA: [SyncDataEvent] = [] - for await event in streamA { - eventsA.append(event) - } - var eventsB: [SyncDataEvent] = [] - for await event in streamB { - eventsB.append(event) - } + var eventsA: [SyncDataEvent] = [] + for await event in streamA { + eventsA.append(event) + } + var eventsB: [SyncDataEvent] = [] + for await event in streamB { + eventsB.append(event) + } - #expect(eventsA.count == 2) - #expect(eventsB.count == 2) - for events in [eventsA, eventsB] { - guard events.count == 2 else { continue } - guard case .contactsChanged = events[0] else { - Issue.record("Expected first event to be contactsChanged, got \(events[0])") - continue - } - guard case .conversationsChanged = events[1] else { - Issue.record("Expected second event to be conversationsChanged, got \(events[1])") - continue - } - } - #expect(coordinator.contactsVersion == 1) - #expect(coordinator.conversationsVersion == 1) + #expect(eventsA.count == 2) + #expect(eventsB.count == 2) + for events in [eventsA, eventsB] { + guard events.count == 2 else { continue } + guard case .contactsChanged = events[0] else { + Issue.record("Expected first event to be contactsChanged, got \(events[0])") + continue + } + guard case .conversationsChanged = events[1] else { + Issue.record("Expected second event to be conversationsChanged, got \(events[1])") + continue + } } + #expect(coordinator.contactsVersion == 1) + #expect(coordinator.conversationsVersion == 1) + } - @Test("finishDataEvents ends every subscriber's iteration") - func finishEndsSubscriberIteration() async { - let coordinator = SyncCoordinator() - let streamA = coordinator.dataEvents() - let streamB = coordinator.dataEvents() + @Test + func `finishDataEvents ends every subscriber's iteration`() async { + let coordinator = SyncCoordinator() + let streamA = coordinator.dataEvents() + let streamB = coordinator.dataEvents() - let consumerA = Task { - for await _ in streamA {} - return true - } - let consumerB = Task { - for await _ in streamB {} - return true - } + let consumerA = Task { + for await _ in streamA {} + return true + } + let consumerB = Task { + for await _ in streamB {} + return true + } - coordinator.finishDataEvents() + coordinator.finishDataEvents() - #expect(await consumerA.value) - #expect(await consumerB.value) - #expect(coordinator.dataEventBroadcaster.subscriberCount == 0) - } + #expect(await consumerA.value) + #expect(await consumerB.value) + #expect(coordinator.dataEventBroadcaster.subscriberCount == 0) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift index aa34a496..3db4db3e 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift @@ -1,239 +1,237 @@ -import Testing import Foundation -import MeshCoreTestSupport @testable import MC1Services +import MeshCoreTestSupport +import Testing @Suite("SyncCoordinator Message Handler Tests") @MainActor struct SyncCoordinatorMessageHandlerTests { - - // MARK: - Test Helpers - - private func createTestDataStore(radioID: UUID) async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let device = DeviceDTO.testDevice(id: radioID, nodeName: "TestNode") - try await store.saveDevice(device) - return store - } - - private func createTestServices() async throws -> (MeshCoreSession, ServiceContainer) { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - let services = try await ServiceContainer.forTesting(session: session) - return (session, services) - } - - // MARK: - parseChannelMessage Tests - - @Test("parseChannelMessage parses standard 'Name: text' format") - func parseStandardFormat() { - let (sender, text) = SyncCoordinator.parseChannelMessage("NodeAlpha: Hello world") - #expect(sender == "NodeAlpha") - #expect(text == "Hello world") - } - - @Test("parseChannelMessage handles multiple colons") - func parseMultipleColons() { - let (sender, text) = SyncCoordinator.parseChannelMessage("Node: time is 12:30:00") - #expect(sender == "Node") - #expect(text == "time is 12:30:00") - } - - @Test("parseChannelMessage returns nil sender for text without colon") - func parseNoColon() { - let (sender, text) = SyncCoordinator.parseChannelMessage("just plain text") - #expect(sender == nil) - #expect(text == "just plain text") - } - - @Test("parseChannelMessage returns nil sender for empty string") - func parseEmptyString() { - let (sender, text) = SyncCoordinator.parseChannelMessage("") - #expect(sender == nil) - #expect(text == "") - } - - @Test("parseChannelMessage handles colon only — split omits empty subsequences") - func parseColonOnly() { - let (sender, text) = SyncCoordinator.parseChannelMessage(":") - #expect(sender == nil) - #expect(text == ":") - } - - @Test("parseChannelMessage trims whitespace from sender and text") - func parseTrimsWhitespace() { - let (sender, text) = SyncCoordinator.parseChannelMessage(" NodeName : hello there ") - #expect(sender == "NodeName") - #expect(text == "hello there") - } - - @Test("parseChannelMessage handles colon at start — leading empty part omitted by split") - func parseColonAtStart() { - let (sender, text) = SyncCoordinator.parseChannelMessage(": some text") - #expect(sender == nil) - #expect(text == ": some text") - } - - @Test("parseChannelMessage handles emoji in name") - func parseEmojiInName() { - let (sender, text) = SyncCoordinator.parseChannelMessage("Node🔥: hello") - #expect(sender == "Node🔥") - #expect(text == "hello") - } - - @Test("parseChannelMessage handles unicode characters") - func parseUnicode() { - let (sender, text) = SyncCoordinator.parseChannelMessage("Ñoño: café time") - #expect(sender == "Ñoño") - #expect(text == "café time") - } - - @Test("parseChannelMessage handles text with only sender and colon — trailing empty part omitted") - func parseSenderColonNoText() { - let (sender, text) = SyncCoordinator.parseChannelMessage("NodeName:") - #expect(sender == nil) - #expect(text == "NodeName:") - } - - // MARK: - Blocked Sender Cache Tests - - @Test("isBlockedSender returns false for empty cache") - func blockedCacheEmptyReturnsFalse() async { - let coordinator = SyncCoordinator() - let result = await coordinator.isBlockedSender("SomeNode") - #expect(!result) - } - - @Test("refreshBlockedContactsCache loads blocked contacts by name") - func refreshBlockedCacheLoads() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let dataStore = try await createTestDataStore(radioID: radioID) - - let blockedContact = ContactDTO.testContact( - radioID: radioID, - name: "BlockedPerson", - isBlocked: true - ) - try await dataStore.saveContact(blockedContact) - - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - - let result = await coordinator.isBlockedSender("BlockedPerson") - #expect(result, "Blocked contact name should be in cache") - } - - @Test("refreshBlockedContactsCache does not cache non-blocked contacts") - func refreshBlockedCacheIgnoresUnblocked() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let dataStore = try await createTestDataStore(radioID: radioID) - - let normalContact = ContactDTO.testContact( - radioID: radioID, - name: "NormalPerson", - isBlocked: false - ) - try await dataStore.saveContact(normalContact) - - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - - let result = await coordinator.isBlockedSender("NormalPerson") - #expect(!result, "Non-blocked contact name should not be in cache") - } - - @Test("refreshBlockedContactsCache replaces previous cache") - func refreshBlockedCacheReplaces() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let dataStore = try await createTestDataStore(radioID: radioID) - - // First: add a blocked contact - let contact = ContactDTO.testContact( - id: UUID(), - radioID: radioID, - name: "WasBlocked", - isBlocked: true - ) - try await dataStore.saveContact(contact) - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - #expect(await coordinator.isBlockedSender("WasBlocked")) - - // Delete the contact and refresh — cache should be empty - try await dataStore.deleteContact(id: contact.id) - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - #expect(await !coordinator.isBlockedSender("WasBlocked")) - } - - @Test("isBlockedSender returns false for nil name") - func blockedSenderNilReturnsFalse() async { - let coordinator = SyncCoordinator() - let result = await coordinator.isBlockedSender(nil) - #expect(!result) - } - - @Test("blockedSenderNames returns snapshot of cached names") - func blockedSenderNamesReturnsSnapshot() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let dataStore = try await createTestDataStore(radioID: radioID) - - let blocked1 = ContactDTO.testContact(radioID: radioID, name: "Blocked1", isBlocked: true) - let blocked2 = ContactDTO.testContact(radioID: radioID, name: "Blocked2", isBlocked: true) - try await dataStore.saveContact(blocked1) - try await dataStore.saveContact(blocked2) - - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - - let names = await coordinator.blockedSenderNames() - #expect(names.contains("Blocked1")) - #expect(names.contains("Blocked2")) - } - - // MARK: - Handler Wiring Smoke Tests - - @Test("wireMessageHandlers completes without error") - func wireMessageHandlersSmoke() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let (_, services) = try await createTestServices() - try await services.dataStore.saveDevice(DeviceDTO.testDevice(id: radioID, nodeName: "TestNode")) - - await coordinator.wireMessageHandlers(dependencies: services.syncDependencies, radioID: radioID) - } - - @Test("startDiscoveryEventMonitoring completes without error") - func startDiscoveryEventMonitoringSmoke() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let (_, services) = try await createTestServices() - - await coordinator.startDiscoveryEventMonitoring(dependencies: services.syncDependencies, radioID: radioID) - await coordinator.cancelDiscoveryEventMonitoring() - } - - // MARK: - Unresolved Channel Notification Guard - - @Test("Channel message that resolves to no local channel must not post a notification") - func unresolvedChannelSuppressesNotification() { - #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: nil) == false) - } - - @Test("Channel message that resolves to a known local channel posts a notification") - func resolvedChannelPostsNotification() { - let channel = ChannelDTO( - id: UUID(), - radioID: UUID(), - index: 3, - name: "Test", - secret: Data(repeating: 1, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: .inherit - ) - #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: channel) == true) - } - + // MARK: - Test Helpers + + private func createTestDataStore(radioID: UUID) async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let device = DeviceDTO.testDevice(id: radioID, nodeName: "TestNode") + try await store.saveDevice(device) + return store + } + + private func createTestServices() async throws -> (MeshCoreSession, ServiceContainer) { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + let services = try await ServiceContainer.forTesting(session: session) + return (session, services) + } + + // MARK: - parseChannelMessage Tests + + @Test + func `parseChannelMessage parses standard 'Name: text' format`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("NodeAlpha: Hello world") + #expect(sender == "NodeAlpha") + #expect(text == "Hello world") + } + + @Test + func `parseChannelMessage handles multiple colons`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("Node: time is 12:30:00") + #expect(sender == "Node") + #expect(text == "time is 12:30:00") + } + + @Test + func `parseChannelMessage returns nil sender for text without colon`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("just plain text") + #expect(sender == nil) + #expect(text == "just plain text") + } + + @Test + func `parseChannelMessage returns nil sender for empty string`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("") + #expect(sender == nil) + #expect(text == "") + } + + @Test + func `parseChannelMessage handles colon only — split omits empty subsequences`() { + let (sender, text) = SyncCoordinator.parseChannelMessage(":") + #expect(sender == nil) + #expect(text == ":") + } + + @Test + func `parseChannelMessage trims whitespace from sender and text`() { + let (sender, text) = SyncCoordinator.parseChannelMessage(" NodeName : hello there ") + #expect(sender == "NodeName") + #expect(text == "hello there") + } + + @Test + func `parseChannelMessage handles colon at start — leading empty part omitted by split`() { + let (sender, text) = SyncCoordinator.parseChannelMessage(": some text") + #expect(sender == nil) + #expect(text == ": some text") + } + + @Test + func `parseChannelMessage handles emoji in name`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("Node🔥: hello") + #expect(sender == "Node🔥") + #expect(text == "hello") + } + + @Test + func `parseChannelMessage handles unicode characters`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("Ñoño: café time") + #expect(sender == "Ñoño") + #expect(text == "café time") + } + + @Test + func `parseChannelMessage handles text with only sender and colon — trailing empty part omitted`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("NodeName:") + #expect(sender == nil) + #expect(text == "NodeName:") + } + + // MARK: - Blocked Sender Cache Tests + + @Test + func `isBlockedSender returns false for empty cache`() async { + let coordinator = SyncCoordinator() + let result = await coordinator.isBlockedSender("SomeNode") + #expect(!result) + } + + @Test + func `refreshBlockedContactsCache loads blocked contacts by name`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + + let blockedContact = ContactDTO.testContact( + radioID: radioID, + name: "BlockedPerson", + isBlocked: true + ) + try await dataStore.saveContact(blockedContact) + + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + + let result = await coordinator.isBlockedSender("BlockedPerson") + #expect(result, "Blocked contact name should be in cache") + } + + @Test + func `refreshBlockedContactsCache does not cache non-blocked contacts`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + + let normalContact = ContactDTO.testContact( + radioID: radioID, + name: "NormalPerson", + isBlocked: false + ) + try await dataStore.saveContact(normalContact) + + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + + let result = await coordinator.isBlockedSender("NormalPerson") + #expect(!result, "Non-blocked contact name should not be in cache") + } + + @Test + func `refreshBlockedContactsCache replaces previous cache`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + + // First: add a blocked contact + let contact = ContactDTO.testContact( + id: UUID(), + radioID: radioID, + name: "WasBlocked", + isBlocked: true + ) + try await dataStore.saveContact(contact) + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + #expect(await coordinator.isBlockedSender("WasBlocked")) + + // Delete the contact and refresh — cache should be empty + try await dataStore.deleteContact(id: contact.id) + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + #expect(await !coordinator.isBlockedSender("WasBlocked")) + } + + @Test + func `isBlockedSender returns false for nil name`() async { + let coordinator = SyncCoordinator() + let result = await coordinator.isBlockedSender(nil) + #expect(!result) + } + + @Test + func `blockedSenderNames returns snapshot of cached names`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + + let blocked1 = ContactDTO.testContact(radioID: radioID, name: "Blocked1", isBlocked: true) + let blocked2 = ContactDTO.testContact(radioID: radioID, name: "Blocked2", isBlocked: true) + try await dataStore.saveContact(blocked1) + try await dataStore.saveContact(blocked2) + + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + + let names = await coordinator.blockedSenderNames() + #expect(names.contains("Blocked1")) + #expect(names.contains("Blocked2")) + } + + // MARK: - Handler Wiring Smoke Tests + + @Test + func `wireMessageHandlers completes without error`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let (_, services) = try await createTestServices() + try await services.dataStore.saveDevice(DeviceDTO.testDevice(id: radioID, nodeName: "TestNode")) + + await coordinator.wireMessageHandlers(dependencies: services.syncDependencies, radioID: radioID) + } + + @Test + func `startDiscoveryEventMonitoring completes without error`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let (_, services) = try await createTestServices() + + await coordinator.startDiscoveryEventMonitoring(dependencies: services.syncDependencies, radioID: radioID) + await coordinator.cancelDiscoveryEventMonitoring() + } + + // MARK: - Unresolved Channel Notification Guard + + @Test + func `Channel message that resolves to no local channel must not post a notification`() { + #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: nil) == false) + } + + @Test + func `Channel message that resolves to a known local channel posts a notification`() { + let channel = ChannelDTO( + id: UUID(), + radioID: UUID(), + index: 3, + name: "Test", + secret: Data(repeating: 1, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: .inherit + ) + #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: channel) == true) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift index 25a77d37..2c23800e 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift @@ -1,892 +1,892 @@ // SyncCoordinatorTests.swift -import Testing import Foundation +@testable import MC1Services import MeshCore import MeshCoreTestSupport -@testable import MC1Services +import Testing @Suite("SyncCoordinator Tests") struct SyncCoordinatorTests { - - private func createTestDataStore( - radioID: UUID, - maxChannels: UInt8 = 8, - lastContactSync: UInt32 = 0 - ) async throws -> PersistenceStore { - try await PersistenceStore.createTestDataStore( - radioID: radioID, - maxChannels: maxChannels, - lastContactSync: lastContactSync - ) - } - - @Test("SyncState cases are distinct") - func syncStateCasesDistinct() { - let idle = SyncState.idle - let syncing = SyncState.syncing(progress: SyncProgress(phase: .contacts, current: 0, total: 0)) - let synced = SyncState.synced - let failed = SyncState.failed(SyncCoordinatorError.notConnected) - - // Verify they're not equal - #expect(idle != syncing) - #expect(syncing != synced) - #expect(synced != failed) - } - - @Test("SyncProgress initializes correctly") - func syncProgressInitializes() { - let progress = SyncProgress(phase: .contacts, current: 5, total: 10) - #expect(progress.phase == .contacts) - #expect(progress.current == 5) - #expect(progress.total == 10) - } - - @Test("SyncPhase has all expected cases") - func syncPhaseHasAllCases() { - let phases: [SyncPhase] = [.contacts, .channels, .messages] - #expect(phases.count == 3) - } - - @Test("SyncCoordinator initializes with idle state") - @MainActor - func syncCoordinatorInitializesIdle() async { - let coordinator = SyncCoordinator() - #expect(coordinator.state == .idle) - #expect(coordinator.contactsVersion == 0) - #expect(coordinator.conversationsVersion == 0) - #expect(coordinator.lastSyncDate == nil) - } - - @Test("notifyContactsChanged increments contactsVersion") - @MainActor - func notifyContactsChangedIncrementsVersion() async { - let coordinator = SyncCoordinator() - let initialVersion = coordinator.contactsVersion - - await coordinator.notifyContactsChanged() - - #expect(coordinator.contactsVersion == initialVersion + 1) - } - - @Test("notifyConversationsChanged increments conversationsVersion") - @MainActor - func notifyConversationsChangedIncrementsVersion() async { - let coordinator = SyncCoordinator() - let initialVersion = coordinator.conversationsVersion - - await coordinator.notifyConversationsChanged() - - #expect(coordinator.conversationsVersion == initialVersion + 1) - } - - @Test("Multiple notifications increment correctly") - @MainActor - func multipleNotificationsIncrementCorrectly() async { - let coordinator = SyncCoordinator() - - await coordinator.notifyContactsChanged() - await coordinator.notifyContactsChanged() - await coordinator.notifyConversationsChanged() - - #expect(coordinator.contactsVersion == 2) - #expect(coordinator.conversationsVersion == 1) - } - - @Test("Sync activity callbacks fire during full sync") - @MainActor - func syncActivityCallbacksFire() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let startedTracker = CallTracker() - let endedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { _ in endedTracker.markCalled() }, - onPhaseChanged: { _ in } - ) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(startedTracker.wasCalled, "onSyncActivityStarted should have been called") - #expect(endedTracker.wasCalled, "onSyncActivityEnded should have been called") - } - - @Test("Channel phase failure is partial and keeps connection usable") - @MainActor - func channelFailureIsPartialAndConnectionUsable() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - await mockChannelService.setStubbedSyncChannelsResult(.failure( - ChannelServiceError.circuitBreakerOpen(consecutiveFailures: 3) - )) - - let result = try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(result.contacts == .clean) - #expect(result.channels == .partial) - #expect(result.messages == .clean) - #expect(result.isConnectionUsable) - #expect(coordinator.state == .synced) - #expect(await mockMessagePollingService.pollAllMessagesCallCount == 1) - } - - @Test("Message polling failure does not fail contacts and channels") - @MainActor - func messagePollingFailureDoesNotFailContactsAndChannels() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - await mockMessagePollingService.setStubbedPollAllMessagesResult(.failure( - SyncCoordinatorError.syncFailed("messages saturated") - )) - - let result = try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(result.contacts == .clean) - #expect(result.channels == .clean) - guard case .failed(let reason) = result.messages else { - Issue.record("Expected failed message phase, got \(result.messages)") - return - } - #expect(reason.localizedStandardContains("messages saturated")) - #expect(result.isConnectionUsable) - #expect(coordinator.state == .synced) - } - - @Test("Sync activity callbacks not double called on error") - @MainActor - func syncActivityCallbacksNotDoubleCalledOnError() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let endedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { _ in endedTracker.markCalled() }, - onPhaseChanged: { _ in } - ) - - // Configure mock to throw error during contacts sync - await mockContactService.setStubbedSyncContactsResult(.failure(SyncCoordinatorError.syncFailed("Test error"))) - - do { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - Issue.record("Should have thrown error") - } catch { - // Expected - } - - #expect(endedTracker.callCount == 1, "onSyncActivityEnded should be called exactly once on error") - } - - @Test("Sync activity ends before messages phase") - @MainActor - func syncActivityEndsBeforeMessagesPhase() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let orderTracker = OrderTrackingMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { _ in - // Record when activity ended - await orderTracker.recordActivityEnded() - }, - onPhaseChanged: { _ in } - ) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: orderTracker - ) - - // Verify that activity ended before message polling started - let activityEndedBeforeMessages = await orderTracker.activityEndedBeforeMessagePoll - #expect(activityEndedBeforeMessages, "Activity should end before message polling starts") - } - - @Test("onDisconnected clears notification suppression flag") - @MainActor - func onDisconnectedClearsSuppressionFlag() async throws { - let coordinator = SyncCoordinator() - - // Create a test ServiceContainer - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - // Manually set suppression flag to true (simulating mid-sync state) - services.notificationService.isSuppressingNotifications = true - #expect(services.notificationService.isSuppressingNotifications == true) - - // Call onDisconnected - await coordinator.onDisconnected(notificationService: services.notificationService) - - // Verify flag is cleared - #expect(services.notificationService.isSuppressingNotifications == false) - } - - @Test("onDisconnected resets sync state to idle") - @MainActor - func onDisconnectedResetsSyncState() async throws { - let coordinator = SyncCoordinator() - - // Create a test ServiceContainer - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - // Call onDisconnected - await coordinator.onDisconnected(notificationService: services.notificationService) - - // Verify state is idle - #expect(coordinator.state == .idle) - } - - @Test("onDisconnected calls onSyncActivityEnded when mid-sync in contacts phase") - @MainActor - func onDisconnectedCallsActivityEndedDuringContactsSync() async throws { - let coordinator = SyncCoordinator() - let delayingContactService = DelayingContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Create a test ServiceContainer - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - let startedTracker = CallTracker() - let endedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { _ in endedTracker.markCalled() }, - onPhaseChanged: { _ in } - ) - - // Start sync in background task - it will block during contacts phase - let syncTask = Task { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: delayingContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - } - - // Wait for sync to start (activity started callback) - try await waitUntil("Sync activity should have started") { - startedTracker.wasCalled - } - #expect(startedTracker.wasCalled, "Sync activity should have started") - - // Call onDisconnected while sync is in contacts phase - await coordinator.onDisconnected(notificationService: services.notificationService) - - // Verify onSyncActivityEnded was called by onDisconnected - #expect(endedTracker.wasCalled, "onSyncActivityEnded should be called when disconnecting mid-sync") - - // Cleanup: resume the sync so it doesn't hang - await delayingContactService.completeSync() - syncTask.cancel() - } - - @Test("Background sync skips channel sync") - @MainActor - func backgroundSyncSkipsChannels() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let mockAppStateProvider = MockAppStateProvider(isInForeground: false) - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: mockAppStateProvider - ) - - // Channel sync should be skipped in background - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.isEmpty, "Channel sync should be skipped when in background") - - // Contact sync should still happen - let contactInvocations = await mockContactService.syncContactsInvocations - #expect(contactInvocations.count == 1, "Contact sync should still run in background") - } - - @Test("Foreground sync includes channel sync") - @MainActor - func foregroundSyncIncludesChannels() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let mockAppStateProvider = MockAppStateProvider(isInForeground: true) - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: mockAppStateProvider - ) - - // Channel sync should run in foreground - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when in foreground") - - // Contact sync should also run - let contactInvocations = await mockContactService.syncContactsInvocations - #expect(contactInvocations.count == 1, "Contact sync should run in foreground") - } - - @Test("performFullSync forwards the pipelined-read flag from config into channel sync") - @MainActor - func performFullSyncForwardsPipelinedReadFlag() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: nil, - channelSyncConfig: ChannelSyncConfig(usePipelinedChannelRead: true) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1) - #expect(channelInvocations.last?.usePipelinedRead == true, "Config flag should reach channel sync") - } - - @Test("performFullSync defaults channel sync to the serial read path") - @MainActor - func performFullSyncDefaultsToSerialRead() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: nil - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1) - #expect(channelInvocations.last?.usePipelinedRead == false, "Default config should use the serial path") - } - - @Test("Nil appStateProvider defaults to foreground behavior") - @MainActor - func nilAppStateProviderDefaultsToForeground() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: nil - ) - - // Should default to foreground (run channels) - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Nil appStateProvider should default to foreground behavior") - - // Contact sync should also run - let contactInvocations = await mockContactService.syncContactsInvocations - #expect(contactInvocations.count == 1, "Contact sync should run with nil appStateProvider") - } - - @Test("performFullSync ignores duplicate calls when already syncing") - @MainActor - func performFullSyncIgnoresDuplicateWhenSyncing() async throws { - let coordinator = SyncCoordinator() - let delayingContactService = DelayingContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let startedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { _ in }, - onPhaseChanged: { _ in } - ) - - // Start first sync in background - it will block during contacts phase - let firstSyncTask = Task { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: delayingContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - } - - // Wait for first sync to start - try await waitUntil("First sync should have started") { - startedTracker.callCount >= 1 - } - - // Try to start a second sync while first is still running - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: delayingContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - // Verify onSyncActivityStarted was only called once (not twice) - #expect(startedTracker.callCount == 1, "onSyncActivityStarted should only be called once even with duplicate performFullSync calls") - - // Cleanup - await delayingContactService.completeSync() - firstSyncTask.cancel() - } - - @Test("Cancellation during channels phase ends sync activity once and resets state") - @MainActor - func cancellationDuringChannelSyncEndsActivityAndResetsState() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let delayingChannelService = DelayingChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let endedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { _ in endedTracker.markCalled() }, - onPhaseChanged: { _ in } - ) - - let syncTask = Task { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: delayingChannelService, - messagePollingService: mockMessagePollingService - ) - } - - await delayingChannelService.waitForSyncStart() - syncTask.cancel() - - do { - try await syncTask.value - Issue.record("Expected cancellation") - } catch is CancellationError { - // Expected - } catch { - Issue.record("Expected CancellationError, got \(error)") - } - - #expect(endedTracker.callCount == 1, "onSyncActivityEnded should be called exactly once on cancellation") - #expect(coordinator.state == .idle, "Sync state should reset to idle on cancellation") - } - - @Test("performFullSync clears notification suppression after poll completes") - @MainActor - func performFullSyncClearsSuppressionAfterPoll() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - // Simulate suppression being active (as it would be during a real sync) - services.notificationService.isSuppressingNotifications = true - #expect(services.notificationService.isSuppressingNotifications == true) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - notificationService: services.notificationService - ) - - // Suppression should be cleared after pollAllMessages() completes - #expect(services.notificationService.isSuppressingNotifications == false) - } - - @Test("Contact sync passes lastContactSync timestamp from device") - @MainActor - func contactSyncPassesTimestamp() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - - // Create device with a lastContactSync timestamp - let lastSyncTimestamp: UInt32 = 1704067200 // 2024-01-01 00:00:00 UTC - let dataStore = try await createTestDataStore( - radioID: testDeviceID, - lastContactSync: lastSyncTimestamp - ) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: nil - ) - - let invocations = await mockContactService.syncContactsInvocations - #expect(invocations.count == 1) - - // Verify the since parameter was passed - let since = invocations[0].since - let expectedDate = Date(timeIntervalSince1970: Double(lastSyncTimestamp)) - - // Use try #require to safely unwrap and produce a clear failure message - let actualSince = try #require(since, "Should pass lastContactSync as since parameter") - #expect(actualSince == expectedDate, "Since date should match device lastContactSync") - } - // MARK: - Succeeded Parameter Tests - - @Test("Successful sync passes succeeded: true to onEnded callback") - @MainActor - func syncActivityEndedWithSuccessPassesTrue() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let succeededValues = ValueTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(succeededValues.values == [true], "Successful sync should pass succeeded: true") - } - - @Test("Failed sync passes succeeded: false to onEnded callback") - @MainActor - func syncActivityEndedWithFailurePassesFalse() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let succeededValues = ValueTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) - - await mockContactService.setStubbedSyncContactsResult(.failure(SyncCoordinatorError.syncFailed("Test error"))) - - do { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - Issue.record("Should have thrown error") - } catch { - // Expected - } - - #expect(succeededValues.values == [false], "Failed sync should pass succeeded: false") - } - - // MARK: - Resync Activity Bracket Tests - - @Test("beginResyncActivity and endResyncActivity fire the correct callbacks") - @MainActor - func resyncActivityBracketCallsStartedAndEnded() async { - let coordinator = SyncCoordinator() - - let startedTracker = CallTracker() - let succeededValues = ValueTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) - - await coordinator.beginResyncActivity() - #expect(startedTracker.callCount == 1, "beginResyncActivity should fire onStarted") - - await coordinator.endResyncActivity(succeeded: true) - #expect(succeededValues.values == [true], "endResyncActivity(succeeded: true) should pass true") - - // Call again with false to verify the value is forwarded - await coordinator.beginResyncActivity() - await coordinator.endResyncActivity(succeeded: false) - #expect(succeededValues.values == [true, false], "endResyncActivity(succeeded: false) should pass false") - } - - @Test("Disconnect during resync does not double-end the resync bracket") - @MainActor - func disconnectDuringResyncDoesNotInterfereWithResyncBracket() async throws { - let coordinator = SyncCoordinator() - - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - let succeededValues = ValueTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) - - // Simulate resync bracket open - await coordinator.beginResyncActivity() - - // Disconnect while resync bracket is open - await coordinator.onDisconnected(notificationService: services.notificationService) - - // onDisconnected calls endSyncActivityOnce, which is for the initial sync bracket, - // not the resync bracket. Since no initial sync was started, hasEndedSyncActivity - // is already true and endSyncActivityOnce should be a no-op. - #expect(succeededValues.values.isEmpty, "onDisconnected should not end the resync bracket") - } + private func createTestDataStore( + radioID: UUID, + maxChannels: UInt8 = 8, + lastContactSync: UInt32 = 0 + ) async throws -> PersistenceStore { + try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: maxChannels, + lastContactSync: lastContactSync + ) + } + + @Test + func `SyncState cases are distinct`() { + let idle = SyncState.idle + let syncing = SyncState.syncing(progress: SyncProgress(phase: .contacts, current: 0, total: 0)) + let synced = SyncState.synced + let failed = SyncState.failed(SyncCoordinatorError.notConnected) + + // Verify they're not equal + #expect(idle != syncing) + #expect(syncing != synced) + #expect(synced != failed) + } + + @Test + func `SyncProgress initializes correctly`() { + let progress = SyncProgress(phase: .contacts, current: 5, total: 10) + #expect(progress.phase == .contacts) + #expect(progress.current == 5) + #expect(progress.total == 10) + } + + @Test + func `SyncPhase has all expected cases`() { + let phases: [SyncPhase] = [.contacts, .channels, .messages] + #expect(phases.count == 3) + } + + @Test + @MainActor + func `SyncCoordinator initializes with idle state`() { + let coordinator = SyncCoordinator() + #expect(coordinator.state == .idle) + #expect(coordinator.contactsVersion == 0) + #expect(coordinator.conversationsVersion == 0) + #expect(coordinator.lastSyncDate == nil) + } + + @Test + @MainActor + func `notifyContactsChanged increments contactsVersion`() async { + let coordinator = SyncCoordinator() + let initialVersion = coordinator.contactsVersion + + await coordinator.notifyContactsChanged() + + #expect(coordinator.contactsVersion == initialVersion + 1) + } + + @Test + @MainActor + func `notifyConversationsChanged increments conversationsVersion`() async { + let coordinator = SyncCoordinator() + let initialVersion = coordinator.conversationsVersion + + await coordinator.notifyConversationsChanged() + + #expect(coordinator.conversationsVersion == initialVersion + 1) + } + + @Test + @MainActor + func `Multiple notifications increment correctly`() async { + let coordinator = SyncCoordinator() + + await coordinator.notifyContactsChanged() + await coordinator.notifyContactsChanged() + await coordinator.notifyConversationsChanged() + + #expect(coordinator.contactsVersion == 2) + #expect(coordinator.conversationsVersion == 1) + } + + @Test + @MainActor + func `Sync activity callbacks fire during full sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let startedTracker = CallTracker() + let endedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { _ in endedTracker.markCalled() }, + onPhaseChanged: { _ in } + ) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(startedTracker.wasCalled, "onSyncActivityStarted should have been called") + #expect(endedTracker.wasCalled, "onSyncActivityEnded should have been called") + } + + @Test + @MainActor + func `Channel phase failure is partial and keeps connection usable`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + await mockChannelService.setStubbedSyncChannelsResult(.failure( + ChannelServiceError.circuitBreakerOpen(consecutiveFailures: 3) + )) + + let result = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(result.contacts == .clean) + #expect(result.channels == .partial) + #expect(result.messages == .clean) + #expect(result.isConnectionUsable) + #expect(coordinator.state == .synced) + #expect(await mockMessagePollingService.pollAllMessagesCallCount == 1) + } + + @Test + @MainActor + func `Message polling failure does not fail contacts and channels`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + await mockMessagePollingService.setStubbedPollAllMessagesResult(.failure( + SyncCoordinatorError.syncFailed("messages saturated") + )) + + let result = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(result.contacts == .clean) + #expect(result.channels == .clean) + guard case let .failed(reason) = result.messages else { + Issue.record("Expected failed message phase, got \(result.messages)") + return + } + #expect(reason.localizedStandardContains("messages saturated")) + #expect(result.isConnectionUsable) + #expect(coordinator.state == .synced) + } + + @Test + @MainActor + func `Sync activity callbacks not double called on error`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let endedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { _ in endedTracker.markCalled() }, + onPhaseChanged: { _ in } + ) + + // Configure mock to throw error during contacts sync + await mockContactService.setStubbedSyncContactsResult(.failure(SyncCoordinatorError.syncFailed("Test error"))) + + do { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + Issue.record("Should have thrown error") + } catch { + // Expected + } + + #expect(endedTracker.callCount == 1, "onSyncActivityEnded should be called exactly once on error") + } + + @Test + @MainActor + func `Sync activity ends before messages phase`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let orderTracker = OrderTrackingMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { _ in + // Record when activity ended + await orderTracker.recordActivityEnded() + }, + onPhaseChanged: { _ in } + ) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: orderTracker + ) + + // Verify that activity ended before message polling started + let activityEndedBeforeMessages = await orderTracker.activityEndedBeforeMessagePoll + #expect(activityEndedBeforeMessages, "Activity should end before message polling starts") + } + + @Test + @MainActor + func `onDisconnected clears notification suppression flag`() async throws { + let coordinator = SyncCoordinator() + + // Create a test ServiceContainer + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + // Manually set suppression flag to true (simulating mid-sync state) + services.notificationService.isSuppressingNotifications = true + #expect(services.notificationService.isSuppressingNotifications == true) + + // Call onDisconnected + await coordinator.onDisconnected(notificationService: services.notificationService) + + // Verify flag is cleared + #expect(services.notificationService.isSuppressingNotifications == false) + } + + @Test + @MainActor + func `onDisconnected resets sync state to idle`() async throws { + let coordinator = SyncCoordinator() + + // Create a test ServiceContainer + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + // Call onDisconnected + await coordinator.onDisconnected(notificationService: services.notificationService) + + // Verify state is idle + #expect(coordinator.state == .idle) + } + + @Test + @MainActor + func `onDisconnected calls onSyncActivityEnded when mid-sync in contacts phase`() async throws { + let coordinator = SyncCoordinator() + let delayingContactService = DelayingContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Create a test ServiceContainer + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + let startedTracker = CallTracker() + let endedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { _ in endedTracker.markCalled() }, + onPhaseChanged: { _ in } + ) + + // Start sync in background task - it will block during contacts phase + let syncTask = Task { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: delayingContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + } + + // Wait for sync to start (activity started callback) + try await waitUntil("Sync activity should have started") { + startedTracker.wasCalled + } + #expect(startedTracker.wasCalled, "Sync activity should have started") + + // Call onDisconnected while sync is in contacts phase + await coordinator.onDisconnected(notificationService: services.notificationService) + + // Verify onSyncActivityEnded was called by onDisconnected + #expect(endedTracker.wasCalled, "onSyncActivityEnded should be called when disconnecting mid-sync") + + // Cleanup: resume the sync so it doesn't hang + await delayingContactService.completeSync() + syncTask.cancel() + } + + @Test + @MainActor + func `Background sync skips channel sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let mockAppStateProvider = MockAppStateProvider(isInForeground: false) + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: mockAppStateProvider + ) + + // Channel sync should be skipped in background + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.isEmpty, "Channel sync should be skipped when in background") + + // Contact sync should still happen + let contactInvocations = await mockContactService.syncContactsInvocations + #expect(contactInvocations.count == 1, "Contact sync should still run in background") + } + + @Test + @MainActor + func `Foreground sync includes channel sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let mockAppStateProvider = MockAppStateProvider(isInForeground: true) + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: mockAppStateProvider + ) + + // Channel sync should run in foreground + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when in foreground") + + // Contact sync should also run + let contactInvocations = await mockContactService.syncContactsInvocations + #expect(contactInvocations.count == 1, "Contact sync should run in foreground") + } + + @Test + @MainActor + func `performFullSync forwards the pipelined-read flag from config into channel sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: nil, + channelSyncConfig: ChannelSyncConfig(usePipelinedChannelRead: true) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1) + #expect(channelInvocations.last?.usePipelinedRead == true, "Config flag should reach channel sync") + } + + @Test + @MainActor + func `performFullSync defaults channel sync to the serial read path`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: nil + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1) + #expect(channelInvocations.last?.usePipelinedRead == false, "Default config should use the serial path") + } + + @Test + @MainActor + func `Nil appStateProvider defaults to foreground behavior`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: nil + ) + + // Should default to foreground (run channels) + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Nil appStateProvider should default to foreground behavior") + + // Contact sync should also run + let contactInvocations = await mockContactService.syncContactsInvocations + #expect(contactInvocations.count == 1, "Contact sync should run with nil appStateProvider") + } + + @Test + @MainActor + func `performFullSync ignores duplicate calls when already syncing`() async throws { + let coordinator = SyncCoordinator() + let delayingContactService = DelayingContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let startedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { _ in }, + onPhaseChanged: { _ in } + ) + + // Start first sync in background - it will block during contacts phase + let firstSyncTask = Task { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: delayingContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + } + + // Wait for first sync to start + try await waitUntil("First sync should have started") { + startedTracker.callCount >= 1 + } + + // Try to start a second sync while first is still running + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: delayingContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + // Verify onSyncActivityStarted was only called once (not twice) + #expect(startedTracker.callCount == 1, "onSyncActivityStarted should only be called once even with duplicate performFullSync calls") + + // Cleanup + await delayingContactService.completeSync() + firstSyncTask.cancel() + } + + @Test + @MainActor + func `Cancellation during channels phase ends sync activity once and resets state`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let delayingChannelService = DelayingChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let endedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { _ in endedTracker.markCalled() }, + onPhaseChanged: { _ in } + ) + + let syncTask = Task { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: delayingChannelService, + messagePollingService: mockMessagePollingService + ) + } + + await delayingChannelService.waitForSyncStart() + syncTask.cancel() + + do { + try await syncTask.value + Issue.record("Expected cancellation") + } catch is CancellationError { + // Expected + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + + #expect(endedTracker.callCount == 1, "onSyncActivityEnded should be called exactly once on cancellation") + #expect(coordinator.state == .idle, "Sync state should reset to idle on cancellation") + } + + @Test + @MainActor + func `performFullSync clears notification suppression after poll completes`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + // Simulate suppression being active (as it would be during a real sync) + services.notificationService.isSuppressingNotifications = true + #expect(services.notificationService.isSuppressingNotifications == true) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + notificationService: services.notificationService + ) + + // Suppression should be cleared after pollAllMessages() completes + #expect(services.notificationService.isSuppressingNotifications == false) + } + + @Test + @MainActor + func `Contact sync passes lastContactSync timestamp from device`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + + // Create device with a lastContactSync timestamp + let lastSyncTimestamp: UInt32 = 1_704_067_200 // 2024-01-01 00:00:00 UTC + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: lastSyncTimestamp + ) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: nil + ) + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + + // Verify the since parameter was passed + let since = invocations[0].since + let expectedDate = Date(timeIntervalSince1970: Double(lastSyncTimestamp)) + + // Use try #require to safely unwrap and produce a clear failure message + let actualSince = try #require(since, "Should pass lastContactSync as since parameter") + #expect(actualSince == expectedDate, "Since date should match device lastContactSync") + } + + // MARK: - Succeeded Parameter Tests + + @Test + @MainActor + func `Successful sync passes succeeded: true to onEnded callback`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let succeededValues = ValueTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(succeededValues.values == [true], "Successful sync should pass succeeded: true") + } + + @Test + @MainActor + func `Failed sync passes succeeded: false to onEnded callback`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let succeededValues = ValueTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + await mockContactService.setStubbedSyncContactsResult(.failure(SyncCoordinatorError.syncFailed("Test error"))) + + do { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + Issue.record("Should have thrown error") + } catch { + // Expected + } + + #expect(succeededValues.values == [false], "Failed sync should pass succeeded: false") + } + + // MARK: - Resync Activity Bracket Tests + + @Test + @MainActor + func `beginResyncActivity and endResyncActivity fire the correct callbacks`() async { + let coordinator = SyncCoordinator() + + let startedTracker = CallTracker() + let succeededValues = ValueTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + await coordinator.beginResyncActivity() + #expect(startedTracker.callCount == 1, "beginResyncActivity should fire onStarted") + + await coordinator.endResyncActivity(succeeded: true) + #expect(succeededValues.values == [true], "endResyncActivity(succeeded: true) should pass true") + + // Call again with false to verify the value is forwarded + await coordinator.beginResyncActivity() + await coordinator.endResyncActivity(succeeded: false) + #expect(succeededValues.values == [true, false], "endResyncActivity(succeeded: false) should pass false") + } + + @Test + @MainActor + func `Disconnect during resync does not double-end the resync bracket`() async throws { + let coordinator = SyncCoordinator() + + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + let succeededValues = ValueTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + // Simulate resync bracket open + await coordinator.beginResyncActivity() + + // Disconnect while resync bracket is open + await coordinator.onDisconnected(notificationService: services.notificationService) + + // onDisconnected calls endSyncActivityOnce, which is for the initial sync bracket, + // not the resync bracket. Since no initial sync was started, hasEndedSyncActivity + // is already true and endSyncActivityOnce should be a no-op. + #expect(succeededValues.values.isEmpty, "onDisconnected should not end the resync bracket") + } } // MARK: - Test Helpers /// Thread-safe value recorder for verifying callback arguments in tests. final class ValueTracker: @unchecked Sendable { - private var _values: [T] = [] - private let lock = NSLock() - - var values: [T] { - lock.lock() - defer { lock.unlock() } - return _values - } - - func record(_ value: T) { - lock.lock() - defer { lock.unlock() } - _values.append(value) - } + private var _values: [T] = [] + private let lock = NSLock() + + var values: [T] { + lock.lock() + defer { lock.unlock() } + return _values + } + + func record(_ value: T) { + lock.lock() + defer { lock.unlock() } + _values.append(value) + } } /// Actor to safely track callback invocations from concurrent closures /// Mock that tracks the order of activity ended callback vs message polling actor OrderTrackingMessagePollingService: MessagePollingServiceProtocol { - private var activityEndedTime: Date? - private var messagePollTime: Date? + private var activityEndedTime: Date? + private var messagePollTime: Date? - /// Records when the activity ended callback was invoked - func recordActivityEnded() { - activityEndedTime = Date() - } + /// Records when the activity ended callback was invoked + func recordActivityEnded() { + activityEndedTime = Date() + } - /// Whether activity ended before message polling started - var activityEndedBeforeMessagePoll: Bool { - guard let ended = activityEndedTime, let poll = messagePollTime else { - return false - } - return ended < poll + /// Whether activity ended before message polling started + var activityEndedBeforeMessagePoll: Bool { + guard let ended = activityEndedTime, let poll = messagePollTime else { + return false } + return ended < poll + } - // MARK: - MessagePollingServiceProtocol + // MARK: - MessagePollingServiceProtocol - func pollAllMessages() async throws -> Int { - messagePollTime = Date() - return 0 - } + func pollAllMessages() async throws -> Int { + messagePollTime = Date() + return 0 + } - func waitForPendingHandlers(timeout: Duration) async -> Bool { - true - } + func waitForPendingHandlers(timeout: Duration) async -> Bool { + true + } - func startAutoFetch(radioID: UUID) async {} + func startAutoFetch(radioID: UUID) async {} - func pauseAutoFetch() async {} + func pauseAutoFetch() async {} - func resumeAutoFetch() async {} + func resumeAutoFetch() async {} - func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) {} + func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) {} - func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) {} + func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) {} - func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} + func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} - func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} + func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} } /// Mock contact service that delays and signals when sync has started actor DelayingContactService: ContactServiceProtocol { - private var continuation: CheckedContinuation? - - /// Wait to be signaled that contacts sync has started - func waitForSyncStart() async { - await withCheckedContinuation { continuation in - self.continuation = continuation - } - } - - /// Allow the sync to complete - func completeSync() { - continuation?.resume() - continuation = nil - } - - func syncContacts(radioID: UUID, since: Date?) async throws -> ContactSyncResult { - // Signal that sync has started, then wait to be resumed - await withCheckedContinuation { (cont: CheckedContinuation) in - Task { - // Store the continuation so completeSync can resume it - self.continuation?.resume() - self.continuation = cont - } - } - return ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) - } + private var continuation: CheckedContinuation? + + /// Wait to be signaled that contacts sync has started + func waitForSyncStart() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + /// Allow the sync to complete + func completeSync() { + continuation?.resume() + continuation = nil + } + + func syncContacts(radioID: UUID, since: Date?) async throws -> ContactSyncResult { + // Signal that sync has started, then wait to be resumed + await withCheckedContinuation { (cont: CheckedContinuation) in + Task { + // Store the continuation so completeSync can resume it + self.continuation?.resume() + self.continuation = cont + } + } + return ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) + } } /// Mock channel service that blocks in syncChannels until cancelled. actor DelayingChannelService: ChannelServiceProtocol { - private var hasStarted = false - private var startWaiters: [CheckedContinuation] = [] - - func waitForSyncStart() async { - if hasStarted { return } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) - } - } + private var hasStarted = false + private var startWaiters: [CheckedContinuation] = [] - func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult { - hasStarted = true - while !startWaiters.isEmpty { - startWaiters.removeFirst().resume() - } + func waitForSyncStart() async { + if hasStarted { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } - while true { - try Task.checkCancellation() - try await Task.sleep(for: .milliseconds(50)) - } + func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult { + hasStarted = true + while !startWaiters.isEmpty { + startWaiters.removeFirst().resume() } - func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { - ChannelSyncResult(channelsSynced: 0, errors: []) + while true { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(50)) } + } + + func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { + ChannelSyncResult(channelsSynced: 0, errors: []) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTimestampTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTimestampTests.swift index b145fa6e..24631d48 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTimestampTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTimestampTests.swift @@ -1,601 +1,598 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("SyncCoordinator Timestamp Correction") struct SyncCoordinatorTimestampTests { + // MARK: - Test Constants - // MARK: - Test Constants - - private let oneMinute: TimeInterval = 60 - private let fiveMinutes: TimeInterval = 5 * 60 - private let sixMinutes: TimeInterval = 6 * 60 - private let oneWeek: TimeInterval = 7 * 24 * 60 * 60 - private let threeMonths: TimeInterval = 3 * 30 * 24 * 60 * 60 - private let sixMonths: TimeInterval = 6 * 30 * 24 * 60 * 60 - private let sevenMonths: TimeInterval = 7 * 30 * 24 * 60 * 60 + private let oneMinute: TimeInterval = 60 + private let fiveMinutes: TimeInterval = 5 * 60 + private let sixMinutes: TimeInterval = 6 * 60 + private let oneWeek: TimeInterval = 7 * 24 * 60 * 60 + private let threeMonths: TimeInterval = 3 * 30 * 24 * 60 * 60 + private let sixMonths: TimeInterval = 6 * 30 * 24 * 60 * 60 + private let sevenMonths: TimeInterval = 7 * 30 * 24 * 60 * 60 - // MARK: - Valid Range Tests + // MARK: - Valid Range Tests - @Test("Timestamp within valid range is not corrected") - func validTimestampNotCorrected() { - let now = Date() - let timestamp = UInt32(now.timeIntervalSince1970) + @Test + func `Timestamp within valid range is not corrected`() { + let now = Date() + let timestamp = UInt32(now.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - // MARK: - Future Timestamp Tests + // MARK: - Future Timestamp Tests - @Test("Timestamp 1 minute in future is not corrected") - func oneMinuteFutureNotCorrected() { - let now = Date() - let futureDate = now.addingTimeInterval(oneMinute) - let timestamp = UInt32(futureDate.timeIntervalSince1970) + @Test + func `Timestamp 1 minute in future is not corrected`() { + let now = Date() + let futureDate = now.addingTimeInterval(oneMinute) + let timestamp = UInt32(futureDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp exactly 5 minutes in future is not corrected") - func exactlyFiveMinutesFutureNotCorrected() { - let now = Date() - let futureDate = now.addingTimeInterval(fiveMinutes) - let timestamp = UInt32(futureDate.timeIntervalSince1970) + @Test + func `Timestamp exactly 5 minutes in future is not corrected`() { + let now = Date() + let futureDate = now.addingTimeInterval(fiveMinutes) + let timestamp = UInt32(futureDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp 6 minutes in future is corrected") - func sixMinutesFutureIsCorrected() { - let now = Date() - let futureDate = now.addingTimeInterval(sixMinutes) - let timestamp = UInt32(futureDate.timeIntervalSince1970) + @Test + func `Timestamp 6 minutes in future is corrected`() { + let now = Date() + let futureDate = now.addingTimeInterval(sixMinutes) + let timestamp = UInt32(futureDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } - // MARK: - Past Timestamp Tests + // MARK: - Past Timestamp Tests - @Test("Timestamp 1 week ago is not corrected") - func oneWeekAgoNotCorrected() { - let now = Date() - let pastDate = now.addingTimeInterval(-oneWeek) - let timestamp = UInt32(pastDate.timeIntervalSince1970) + @Test + func `Timestamp 1 week ago is not corrected`() { + let now = Date() + let pastDate = now.addingTimeInterval(-oneWeek) + let timestamp = UInt32(pastDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp 3 months ago is not corrected") - func threeMonthsAgoNotCorrected() { - let now = Date() - let pastDate = now.addingTimeInterval(-threeMonths) - let timestamp = UInt32(pastDate.timeIntervalSince1970) + @Test + func `Timestamp 3 months ago is not corrected`() { + let now = Date() + let pastDate = now.addingTimeInterval(-threeMonths) + let timestamp = UInt32(pastDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp exactly 6 months in past is not corrected") - func exactlySixMonthsAgoNotCorrected() { - // Use whole-second receive time so UInt32 truncation doesn't push - // the timestamp past the boundary (fractional seconds are lost in UInt32). - let now = Date(timeIntervalSince1970: Double(Int(Date().timeIntervalSince1970))) - let pastDate = now.addingTimeInterval(-sixMonths) - let timestamp = UInt32(pastDate.timeIntervalSince1970) + @Test + func `Timestamp exactly 6 months in past is not corrected`() { + // Use whole-second receive time so UInt32 truncation doesn't push + // the timestamp past the boundary (fractional seconds are lost in UInt32). + let now = Date(timeIntervalSince1970: Double(Int(Date().timeIntervalSince1970))) + let pastDate = now.addingTimeInterval(-sixMonths) + let timestamp = UInt32(pastDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp 7 months ago is corrected") - func sevenMonthsAgoIsCorrected() { - let now = Date() - let pastDate = now.addingTimeInterval(-sevenMonths) - let timestamp = UInt32(pastDate.timeIntervalSince1970) + @Test + func `Timestamp 7 months ago is corrected`() { + let now = Date() + let pastDate = now.addingTimeInterval(-sevenMonths) + let timestamp = UInt32(pastDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } - // MARK: - Edge Case Tests + // MARK: - Edge Case Tests - @Test("Timestamp of zero (Unix epoch) is corrected") - func unixEpochIsCorrected() { - let now = Date() - let timestamp: UInt32 = 0 - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + @Test + func `Timestamp of zero (Unix epoch) is corrected`() { + let now = Date() + let timestamp: UInt32 = 0 + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } - - @Test("Timestamp from year 2020 is corrected") - func year2020IsCorrected() { - let now = Date() - let oldDate = Date(timeIntervalSince1970: 1577836800) // Jan 1, 2020 - let timestamp = UInt32(oldDate.timeIntervalSince1970) - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } - - @Test("Timestamp from year 2030 is corrected") - func year2030IsCorrected() { - let now = Date() - let futureDate = Date(timeIntervalSince1970: 1893456000) // Jan 1, 2030 - let timestamp = UInt32(futureDate.timeIntervalSince1970) - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } - - // MARK: - Original Timestamp Preservation Tests - - @Test("Original timestamp is preserved when correction is applied") - func originalTimestampPreservedForCorrelation() { - // This test documents critical behavior: the original timestamp must be preserved - // for RxLogEntry correlation (per payloads.md:65 - ACK deduplication uses original timestamp) - let now = Date() - let brokenClockTimestamp: UInt32 = 0 // Unix epoch - clearly invalid - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(brokenClockTimestamp, receiveTime: now) + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } + + @Test + func `Timestamp from year 2020 is corrected`() { + let now = Date() + let oldDate = Date(timeIntervalSince1970: 1_577_836_800) // Jan 1, 2020 + let timestamp = UInt32(oldDate.timeIntervalSince1970) + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } + + @Test + func `Timestamp from year 2030 is corrected`() { + let now = Date() + let futureDate = Date(timeIntervalSince1970: 1_893_456_000) // Jan 1, 2030 + let timestamp = UInt32(futureDate.timeIntervalSince1970) + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } + + // MARK: - Original Timestamp Preservation Tests + + @Test + func `Original timestamp is preserved when correction is applied`() { + // This test documents critical behavior: the original timestamp must be preserved + // for RxLogEntry correlation (per payloads.md:65 - ACK deduplication uses original timestamp) + let now = Date() + let brokenClockTimestamp: UInt32 = 0 // Unix epoch - clearly invalid + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(brokenClockTimestamp, receiveTime: now) - // Verify correction was applied - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - - // The original timestamp (0) is still available as the input parameter - // and should be used for RxLogEntry lookup, not the corrected value. - // This is verified by the fact that correctTimestampIfNeeded returns - // ONLY the corrected timestamp - the caller must preserve the original. - #expect(brokenClockTimestamp == 0) // Original unchanged - #expect(corrected != brokenClockTimestamp) // Different from original - } - - @Test("Corrected timestamp differs from original for invalid input") - func correctedTimestampDiffersFromOriginal() { - let now = Date() - let farFuture = now.addingTimeInterval(365 * 24 * 60 * 60) // 1 year in future - let originalTimestamp = UInt32(farFuture.timeIntervalSince1970) - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(originalTimestamp, receiveTime: now) + // Verify correction was applied + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + + // The original timestamp (0) is still available as the input parameter + // and should be used for RxLogEntry lookup, not the corrected value. + // This is verified by the fact that correctTimestampIfNeeded returns + // ONLY the corrected timestamp - the caller must preserve the original. + #expect(brokenClockTimestamp == 0) // Original unchanged + #expect(corrected != brokenClockTimestamp) // Different from original + } + + @Test + func `Corrected timestamp differs from original for invalid input`() { + let now = Date() + let farFuture = now.addingTimeInterval(365 * 24 * 60 * 60) // 1 year in future + let originalTimestamp = UInt32(farFuture.timeIntervalSince1970) + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(originalTimestamp, receiveTime: now) - #expect(wasCorrected) - // The corrected timestamp should be the receive time, not the invalid original - #expect(corrected == UInt32(now.timeIntervalSince1970)) - // Original and corrected must be different (caller uses original for RxLogEntry lookup) - #expect(corrected != originalTimestamp) - } - - // MARK: - Underflow Prevention Tests - - @Test("Receive time near Unix epoch does not crash") - func nearEpochReceiveTimeDoesNotCrash() { - // Device clock set to early 1970 - would previously cause UInt32 underflow crash - let nearEpoch = Date(timeIntervalSince1970: 1000) // ~16 minutes after Unix epoch - let timestamp: UInt32 = 500 - - // This should not crash - the fix uses TimeInterval arithmetic instead of UInt32 - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: nearEpoch) - - // Timestamp is within range (500 is less than 6 months before 1000) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } - - @Test("Receive time at Unix epoch handles timestamp validation") - func epochReceiveTimeHandlesValidation() { - let epoch = Date(timeIntervalSince1970: 0) - let timestamp: UInt32 = 1_000_000 // ~11 days after epoch + #expect(wasCorrected) + // The corrected timestamp should be the receive time, not the invalid original + #expect(corrected == UInt32(now.timeIntervalSince1970)) + // Original and corrected must be different (caller uses original for RxLogEntry lookup) + #expect(corrected != originalTimestamp) + } + + // MARK: - Underflow Prevention Tests + + @Test + func `Receive time near Unix epoch does not crash`() { + // Device clock set to early 1970 - would previously cause UInt32 underflow crash + let nearEpoch = Date(timeIntervalSince1970: 1000) // ~16 minutes after Unix epoch + let timestamp: UInt32 = 500 + + // This should not crash - the fix uses TimeInterval arithmetic instead of UInt32 + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: nearEpoch) + + // Timestamp is within range (500 is less than 6 months before 1000) + #expect(!wasCorrected) + #expect(corrected == timestamp) + } + + @Test + func `Receive time at Unix epoch handles timestamp validation`() { + let epoch = Date(timeIntervalSince1970: 0) + let timestamp: UInt32 = 1_000_000 // ~11 days after epoch - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: epoch) - - // Timestamp is too far in the future from epoch perspective (> 5 minutes) - #expect(wasCorrected) - #expect(corrected == 0) - } + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: epoch) + + // Timestamp is too far in the future from epoch perspective (> 5 minutes) + #expect(wasCorrected) + #expect(corrected == 0) + } } // MARK: - Sort Date Derivation Tests @Suite("SyncCoordinator Sort Date Derivation") struct SyncCoordinatorSortDateTests { + // MARK: - Test Constants - // MARK: - Test Constants + private let oneMinute: TimeInterval = 60 - private let oneMinute: TimeInterval = 60 + // MARK: - Live Delivery Tests - // MARK: - Live Delivery Tests + @Test + func `Live message sorts by receive time`() { + let now = Date() - @Test("Live message sorts by receive time") - func liveUsesReceiveTime() { - let now = Date() + let result = SyncCoordinator.sortDate(for: .live, receiveTime: now) - let result = SyncCoordinator.sortDate(for: .live, receiveTime: now) + #expect(result == now) + } - #expect(result == now) - } + // MARK: - Backlog Delivery Tests - // MARK: - Backlog Delivery Tests + @Test + func `Backlog message sorts by the drain anchor, not its receive time`() { + let anchor = Date().addingTimeInterval(-oneMinute) + let receiveTime = Date() - @Test("Backlog message sorts by the drain anchor, not its receive time") - func initialSyncUsesAnchor() { - let anchor = Date().addingTimeInterval(-oneMinute) - let receiveTime = Date() + let result = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: receiveTime) - let result = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: receiveTime) + // The anchor is the block's delivery-time position; the per-message + // receive time is ignored so the whole drain stays contiguous. + #expect(result == anchor) + } - // The anchor is the block's delivery-time position; the per-message - // receive time is ignored so the whole drain stays contiguous. - #expect(result == anchor) - } + @Test + func `Every message in one drain shares the anchor as its sort date`() { + let anchor = Date() - @Test("Every message in one drain shares the anchor as its sort date") - func initialSyncBlockSharesAnchor() { - let anchor = Date() + let first = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date()) + let second = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date().addingTimeInterval(oneMinute)) + let third = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date().addingTimeInterval(2 * oneMinute)) - let first = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date()) - let second = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date().addingTimeInterval(oneMinute)) - let third = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date().addingTimeInterval(2 * oneMinute)) + #expect(first == anchor) + #expect(second == anchor) + #expect(third == anchor) + } - #expect(first == anchor) - #expect(second == anchor) - #expect(third == anchor) - } + @Test + func `Distinct drains sort as distinct blocks in delivery order`() { + let earlierDrain = Date().addingTimeInterval(-oneMinute) + let laterDrain = Date() - @Test("Distinct drains sort as distinct blocks in delivery order") - func distinctDrainsSortAsDistinctBlocks() { - let earlierDrain = Date().addingTimeInterval(-oneMinute) - let laterDrain = Date() + let earlier = SyncCoordinator.sortDate(for: .initialSync(anchor: earlierDrain), receiveTime: Date()) + let later = SyncCoordinator.sortDate(for: .initialSync(anchor: laterDrain), receiveTime: Date()) - let earlier = SyncCoordinator.sortDate(for: .initialSync(anchor: earlierDrain), receiveTime: Date()) - let later = SyncCoordinator.sortDate(for: .initialSync(anchor: laterDrain), receiveTime: Date()) - - #expect(earlier == earlierDrain) - #expect(later == laterDrain) - #expect(earlier < later) - } + #expect(earlier == earlierDrain) + #expect(later == laterDrain) + #expect(earlier < later) + } } // MARK: - Same-Sender Reordering Tests @Suite("Same-Sender Reordering") struct SameSenderReorderingTests { - - private func makeDMMessage( - timestamp: UInt32, - createdAt: Date, - sortDate: Date? = nil, - direction: MessageDirection = .incoming - ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: UUID(), - contactID: UUID(), - channelIndex: nil, - text: "msg-\(timestamp)", - timestamp: timestamp, - createdAt: createdAt, - sortDate: sortDate, - direction: direction, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - private func makeChannelMessage( - timestamp: UInt32, - createdAt: Date, - senderName: String? = nil, - direction: MessageDirection = .incoming - ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: UUID(), - contactID: nil, - channelIndex: 0, - text: "msg-\(timestamp)", - timestamp: timestamp, - createdAt: createdAt, - direction: direction, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - @Test("Empty array returns empty") - func emptyArray() { - let result = MessageDTO.reorderSameSenderClusters([]) - #expect(result.isEmpty) - } - - @Test("Single message returns unchanged") - func singleMessage() { - let msg = makeDMMessage(timestamp: 100, createdAt: Date()) - let result = MessageDTO.reorderSameSenderClusters([msg]) - #expect(result.count == 1) - #expect(result[0].id == msg.id) - } - - @Test("DM messages within 5 seconds are reordered by sender timestamp") - func dmReorderWithinWindow() { - let base = Date() - // Messages arrived out of order: msg2 arrived first, then msg1 - let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) - let msg2 = makeDMMessage(timestamp: 200, createdAt: base) - - // Sorted by createdAt: [msg2(t=200), msg1(t=100)] - let input = [msg2, msg1] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Should reorder by sender timestamp: [msg1(t=100), msg2(t=200)] - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("Outgoing DM messages within 5 seconds are reordered by sender timestamp") - func outgoingDMReorderWithinWindow() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), direction: .outgoing) - let msg2 = makeDMMessage(timestamp: 200, createdAt: base, direction: .outgoing) - - let input = [msg2, msg1] - let result = MessageDTO.reorderSameSenderClusters(input) - - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("DM messages beyond 5 seconds are not reordered") - func dmNoReorderBeyondWindow() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 100, createdAt: base) - let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(6)) - - let input = [msg1, msg2] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Beyond window — stays in createdAt order - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("Channel messages from different senders are not clustered") - func channelDifferentSendersNotClustered() { - let base = Date() - // Alice sends at t=200, Bob sends at t=100, both arrive within 2 seconds - let alice = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") - let bob = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), senderName: "Bob") - - let input = [alice, bob] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Different senders — no reordering - #expect(result[0].senderNodeName == "Alice") - #expect(result[1].senderNodeName == "Bob") - } - - @Test("Channel messages from same sender within window are reordered") - func channelSameSenderReordered() { - let base = Date() - let msg1 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(3), senderName: "Alice") - let msg2 = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") - - // createdAt order: [msg2(t=200), msg1(t=100)] - let input = [msg2, msg1] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Same sender within window — reordered by timestamp - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("Mixed directions break clusters") - func mixedDirectionsBreakCluster() { - let base = Date() - let incoming = makeDMMessage(timestamp: 200, createdAt: base, direction: .incoming) - let outgoing = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(1), direction: .outgoing) - - let input = [incoming, outgoing] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Different directions — no reordering - #expect(result[0].direction == .incoming) - #expect(result[1].direction == .outgoing) - } - - @Test("Three messages in cluster are fully sorted") - func threeMessageCluster() { - let base = Date() - // Arrived in reverse order within 4 seconds - let msg1 = makeDMMessage(timestamp: 300, createdAt: base) - let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) - let msg3 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(4)) - - let input = [msg1, msg2, msg3] - let result = MessageDTO.reorderSameSenderClusters(input) - - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - #expect(result[2].timestamp == 300) - } - - @Test("Exactly 5 second gap is included in cluster") - func exactlyFiveSecondGap() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 200, createdAt: base) - let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(5)) - - let input = [msg1, msg2] - let result = MessageDTO.reorderSameSenderClusters(input) - - // 5 seconds is within window (<=5) - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("Multiple consecutive clusters are each reordered independently") - func multipleConsecutiveClusters() { - let base = Date() - // Cluster 1: two messages within 3s, out of timestamp order - let c1a = makeDMMessage(timestamp: 200, createdAt: base) - let c1b = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(3)) - - // Gap of 10 seconds separates the clusters - // Cluster 2: two messages within 2s, out of timestamp order - let c2a = makeDMMessage(timestamp: 400, createdAt: base.addingTimeInterval(13)) - let c2b = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(15)) - - let input = [c1a, c1b, c2a, c2b] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Cluster 1 reordered by timestamp - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - // Cluster 2 reordered by timestamp - #expect(result[2].timestamp == 300) - #expect(result[3].timestamp == 400) - } - - @Test("Channel messages with nil sender names are not clustered") - func nilSenderNamesNotClustered() { - let base = Date() - let msg1 = makeChannelMessage(timestamp: 200, createdAt: base) - let msg2 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) - - let result = MessageDTO.reorderSameSenderClusters([msg1, msg2]) - - // Nil senders should NOT cluster — stays in createdAt order - #expect(result[0].timestamp == 200) - #expect(result[1].timestamp == 100) - } - - @Test("Same-sender messages with identical timestamps use createdAt as tiebreaker") - func identicalTimestampsUsesCreatedAtTiebreaker() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 100, createdAt: base) - let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(0.5)) - - let input = [msg2, msg1] // reverse createdAt order - let result = MessageDTO.reorderSameSenderClusters(input) - - #expect(result[0].id == msg1.id) - #expect(result[1].id == msg2.id) - } - - @Test("Messages already in correct order are unchanged") - func alreadyCorrectOrder() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 100, createdAt: base) - let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(1)) - let msg3 = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(2)) - - let input = [msg1, msg2, msg3] - let result = MessageDTO.reorderSameSenderClusters(input) - - #expect(result[0].id == msg1.id) - #expect(result[1].id == msg2.id) - #expect(result[2].id == msg3.id) - } - - @Test("Far-apart sortDates are not clustered even when createdAt order is inverted") - func nonMonotonicCreatedAtDoesNotWidenWindow() { - let base = Date() - // Sorted by sortDate ascending, but createdAt runs backwards: a backlog row - // sent early yet drained an hour later, followed by a later-sent row received - // first. The createdAt gap is hugely negative, which the old createdAt-based - // window silently accepted (any negative gap is within the window) and - // clustered, scrambling send order. - let early = makeDMMessage( - timestamp: 200, - createdAt: base.addingTimeInterval(3600), - sortDate: base - ) - let late = makeDMMessage( - timestamp: 100, - createdAt: base, - sortDate: base.addingTimeInterval(60) - ) - - let input = [early, late] - let result = MessageDTO.reorderSameSenderClusters(input) - - // sortDate gap is 60s (> window): the two rows must stay in sortDate order, - // not be reordered by raw timestamp. - #expect(result[0].id == early.id) - #expect(result[1].id == late.id) - } - - @Test("Clustering window follows sortDate, not createdAt") - func clusterWindowMeasuredOnSortDate() { - let base = Date() - // sortDates are 2s apart (within window) but createdAt are 100s apart (beyond - // the old window). Reordering must follow the sortDate axis the array is sorted - // by, so the tight send-time cluster is reordered by sender timestamp. - let first = makeDMMessage( - timestamp: 200, - createdAt: base, - sortDate: base - ) - let second = makeDMMessage( - timestamp: 100, - createdAt: base.addingTimeInterval(100), - sortDate: base.addingTimeInterval(2) - ) - - let input = [first, second] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Within the sortDate window: reordered by sender timestamp. - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } + private func makeDMMessage( + timestamp: UInt32, + createdAt: Date, + sortDate: Date? = nil, + direction: MessageDirection = .incoming + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + channelIndex: nil, + text: "msg-\(timestamp)", + timestamp: timestamp, + createdAt: createdAt, + sortDate: sortDate, + direction: direction, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + private func makeChannelMessage( + timestamp: UInt32, + createdAt: Date, + senderName: String? = nil, + direction: MessageDirection = .incoming + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: nil, + channelIndex: 0, + text: "msg-\(timestamp)", + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + @Test + func `Empty array returns empty`() { + let result = MessageDTO.reorderSameSenderClusters([]) + #expect(result.isEmpty) + } + + @Test + func `Single message returns unchanged`() { + let msg = makeDMMessage(timestamp: 100, createdAt: Date()) + let result = MessageDTO.reorderSameSenderClusters([msg]) + #expect(result.count == 1) + #expect(result[0].id == msg.id) + } + + @Test + func `DM messages within 5 seconds are reordered by sender timestamp`() { + let base = Date() + // Messages arrived out of order: msg2 arrived first, then msg1 + let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base) + + // Sorted by createdAt: [msg2(t=200), msg1(t=100)] + let input = [msg2, msg1] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Should reorder by sender timestamp: [msg1(t=100), msg2(t=200)] + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `Outgoing DM messages within 5 seconds are reordered by sender timestamp`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), direction: .outgoing) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base, direction: .outgoing) + + let input = [msg2, msg1] + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `DM messages beyond 5 seconds are not reordered`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(6)) + + let input = [msg1, msg2] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Beyond window — stays in createdAt order + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `Channel messages from different senders are not clustered`() { + let base = Date() + // Alice sends at t=200, Bob sends at t=100, both arrive within 2 seconds + let alice = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") + let bob = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), senderName: "Bob") + + let input = [alice, bob] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Different senders — no reordering + #expect(result[0].senderNodeName == "Alice") + #expect(result[1].senderNodeName == "Bob") + } + + @Test + func `Channel messages from same sender within window are reordered`() { + let base = Date() + let msg1 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(3), senderName: "Alice") + let msg2 = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") + + // createdAt order: [msg2(t=200), msg1(t=100)] + let input = [msg2, msg1] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Same sender within window — reordered by timestamp + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `Mixed directions break clusters`() { + let base = Date() + let incoming = makeDMMessage(timestamp: 200, createdAt: base, direction: .incoming) + let outgoing = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(1), direction: .outgoing) + + let input = [incoming, outgoing] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Different directions — no reordering + #expect(result[0].direction == .incoming) + #expect(result[1].direction == .outgoing) + } + + @Test + func `Three messages in cluster are fully sorted`() { + let base = Date() + // Arrived in reverse order within 4 seconds + let msg1 = makeDMMessage(timestamp: 300, createdAt: base) + let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) + let msg3 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(4)) + + let input = [msg1, msg2, msg3] + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + #expect(result[2].timestamp == 300) + } + + @Test + func `Exactly 5 second gap is included in cluster`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 200, createdAt: base) + let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(5)) + + let input = [msg1, msg2] + let result = MessageDTO.reorderSameSenderClusters(input) + + // 5 seconds is within window (<=5) + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `Multiple consecutive clusters are each reordered independently`() { + let base = Date() + // Cluster 1: two messages within 3s, out of timestamp order + let c1a = makeDMMessage(timestamp: 200, createdAt: base) + let c1b = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(3)) + + // Gap of 10 seconds separates the clusters + // Cluster 2: two messages within 2s, out of timestamp order + let c2a = makeDMMessage(timestamp: 400, createdAt: base.addingTimeInterval(13)) + let c2b = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(15)) + + let input = [c1a, c1b, c2a, c2b] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Cluster 1 reordered by timestamp + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + // Cluster 2 reordered by timestamp + #expect(result[2].timestamp == 300) + #expect(result[3].timestamp == 400) + } + + @Test + func `Channel messages with nil sender names are not clustered`() { + let base = Date() + let msg1 = makeChannelMessage(timestamp: 200, createdAt: base) + let msg2 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) + + let result = MessageDTO.reorderSameSenderClusters([msg1, msg2]) + + // Nil senders should NOT cluster — stays in createdAt order + #expect(result[0].timestamp == 200) + #expect(result[1].timestamp == 100) + } + + @Test + func `Same-sender messages with identical timestamps use createdAt as tiebreaker`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base) + let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(0.5)) + + let input = [msg2, msg1] // reverse createdAt order + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].id == msg1.id) + #expect(result[1].id == msg2.id) + } + + @Test + func `Messages already in correct order are unchanged`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(1)) + let msg3 = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(2)) + + let input = [msg1, msg2, msg3] + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].id == msg1.id) + #expect(result[1].id == msg2.id) + #expect(result[2].id == msg3.id) + } + + @Test + func `Far-apart sortDates are not clustered even when createdAt order is inverted`() { + let base = Date() + // Sorted by sortDate ascending, but createdAt runs backwards: a backlog row + // sent early yet drained an hour later, followed by a later-sent row received + // first. The createdAt gap is hugely negative, which the old createdAt-based + // window silently accepted (any negative gap is within the window) and + // clustered, scrambling send order. + let early = makeDMMessage( + timestamp: 200, + createdAt: base.addingTimeInterval(3600), + sortDate: base + ) + let late = makeDMMessage( + timestamp: 100, + createdAt: base, + sortDate: base.addingTimeInterval(60) + ) + + let input = [early, late] + let result = MessageDTO.reorderSameSenderClusters(input) + + // sortDate gap is 60s (> window): the two rows must stay in sortDate order, + // not be reordered by raw timestamp. + #expect(result[0].id == early.id) + #expect(result[1].id == late.id) + } + + @Test + func `Clustering window follows sortDate, not createdAt`() { + let base = Date() + // sortDates are 2s apart (within window) but createdAt are 100s apart (beyond + // the old window). Reordering must follow the sortDate axis the array is sorted + // by, so the tight send-time cluster is reordered by sender timestamp. + let first = makeDMMessage( + timestamp: 200, + createdAt: base, + sortDate: base + ) + let second = makeDMMessage( + timestamp: 100, + createdAt: base.addingTimeInterval(100), + sortDate: base.addingTimeInterval(2) + ) + + let input = [first, second] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Within the sortDate window: reordered by sender timestamp. + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEPhaseTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEPhaseTests.swift index d363ec1f..611a8ada 100644 --- a/MC1Services/Tests/MC1ServicesTests/Transport/BLEPhaseTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEPhaseTests.swift @@ -1,50 +1,49 @@ -import Testing @testable import MC1Services +import Testing @Suite("BLEPhase Tests") struct BLEPhaseTests { + // MARK: - Name Tests - // MARK: - Name Tests - - @Test("idle phase has correct name") - func idlePhaseHasCorrectName() { - let phase = BLEPhase.idle - #expect(phase.name == "idle") - } + @Test + func `idle phase has correct name`() { + let phase = BLEPhase.idle + #expect(phase.name == "idle") + } - // MARK: - isDiscoveryChain Tests + // MARK: - isDiscoveryChain Tests - @Test("idle is not part of discovery chain") - func idleIsNotDiscoveryChain() { - #expect(BLEPhase.idle.isDiscoveryChain == false) - } + @Test + func `idle is not part of discovery chain`() { + #expect(BLEPhase.idle.isDiscoveryChain == false) + } - // Note: discoveringServices, discoveringCharacteristics, and subscribingToNotifications - // require CBPeripheral instances which can't be created in unit tests. - // Their isDiscoveryChain == true is verified implicitly through integration tests - // and the switch statement exhaustiveness check. + // Note: discoveringServices, discoveringCharacteristics, and subscribingToNotifications + // require CBPeripheral instances which can't be created in unit tests. + // Their isDiscoveryChain == true is verified implicitly through integration tests + // and the switch statement exhaustiveness check. - // MARK: - isActive Tests + // MARK: - isActive Tests - @Test("idle phase is not active") - func idlePhaseIsNotActive() { - let phase = BLEPhase.idle - #expect(phase.isActive == false) - } + @Test + func `idle phase is not active`() { + let phase = BLEPhase.idle + #expect(phase.isActive == false) + } - // MARK: - Peripheral Tests + // MARK: - Peripheral Tests - @Test("idle phase has no peripheral") - func idlePhaseHasNoPeripheral() { - let phase = BLEPhase.idle - #expect(phase.peripheral == nil) - } + @Test + func `idle phase has no peripheral`() { + let phase = BLEPhase.idle + #expect(phase.peripheral == nil) + } - // MARK: - DeviceID Tests + // MARK: - DeviceID Tests - @Test("idle phase has no deviceID") - func idlePhaseHasNoDeviceID() { - let phase = BLEPhase.idle - #expect(phase.deviceID == nil) - } + @Test + func `idle phase has no deviceID`() { + let phase = BLEPhase.idle + #expect(phase.deviceID == nil) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift new file mode 100644 index 00000000..11bd3f05 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift @@ -0,0 +1,168 @@ +import CoreBluetooth +import Foundation +@testable import MC1Services +import ObjectiveC +import Testing + +/// A transient `didFailToConnect` during `.autoReconnecting` (the common +/// backgrounded case: `CBError.encryptionTimedOut`) must re-issue the pending +/// connect and stay in `.autoReconnecting` rather than abandon the OS pending +/// connection. Only a definitive auth code, or an exhausted retry budget, +/// tears the episode down and notifies loss. +@Suite("BLEStateMachine auto-reconnect connect retry") +struct BLEStateMachineAutoReconnectRetryTests { + private var encryptionTimedOut: NSError { + NSError(domain: CBErrorDomain, code: CBError.encryptionTimedOut.rawValue) + } + + private var peerRemovedPairing: NSError { + NSError(domain: CBErrorDomain, code: CBError.peerRemovedPairingInformation.rawValue) + } + + private var genericTransient: NSError { + NSError(domain: CBErrorDomain, code: CBError.connectionTimeout.rawValue) + } + + private func makeRecorder(on sm: BLEStateMachine) async -> RetryDisconnectionRecorder { + let recorder = RetryDisconnectionRecorder() + await sm.setDisconnectionHandler { deviceID, error in + recorder.append(deviceID: deviceID, error: error) + } + return recorder + } + + @Test + func `transient connect failure below cap re-arms and stays auto-reconnecting`() async { + let sm = BLEStateMachine() + await sm.injectTestCentralManager() + let peripheral = makeLeakedRetryPeripheral() + let recorder = await makeRecorder(on: sm) + await sm.primeAutoReconnecting(peripheral: peripheral) + + await sm.handleDidFailToConnect(peripheral, error: encryptionTimedOut) + + #expect(await sm.currentPhase.name == "autoReconnecting") + #expect(recorder.events.isEmpty) + #expect(await sm.currentAutoReconnectConnectFailures == 1) + } + + @Test + func `exhausting the budget with encryption timeouts gives up as an auth failure`() async { + let sm = BLEStateMachine() + await sm.injectTestCentralManager() + let peripheral = makeLeakedRetryPeripheral() + let recorder = await makeRecorder(on: sm) + await sm.primeAutoReconnecting(peripheral: peripheral) + + // Every failure below the cap re-arms silently and stays in the episode. + for _ in 1.. RetryTestPeripheral { + // swiftlint:disable:next force_cast + let peripheral = class_createInstance(RetryTestPeripheral.self, 0) as! RetryTestPeripheral + RetryPeripheralStore.retained.append(peripheral) + return peripheral +} + +/// Actor-isolated seam that installs the `.autoReconnecting` phase the +/// disconnect and restoration paths would produce, then lets the real handler run. +private extension BLEStateMachine { + func primeAutoReconnecting(peripheral: CBPeripheral) { + phase = .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil) + phaseStartTime = Date() + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineBondSuspectRecoveryTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineBondSuspectRecoveryTests.swift new file mode 100644 index 00000000..380d1beb --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineBondSuspectRecoveryTests.swift @@ -0,0 +1,177 @@ +import CoreBluetooth +import Foundation +@testable import MC1Services +import ObjectiveC +import Testing + +/// End-to-end coverage for the two teardown behaviors above the error mapping: +/// a connected-but-wedged auto-reconnect discovery escalating a silent stall to +/// the guided re-pair path, and a disconnect arriving in the `.discoveryComplete` +/// window settling the machine fully instead of forking into auto-reconnect. +@Suite("BLEStateMachine bond-suspect recovery") +struct BLEStateMachineBondSuspectRecoveryTests { + /// Fires the armed auto-reconnect watchdog well within the test window. + private let watchdogFiringTimeout: TimeInterval = 0.02 + + /// Bounds the wait for the watchdog teardown so a missed escalation fails fast. + private let observationWindow: TimeInterval = 5 + + // MARK: - Wedged auto-reconnect discovery escalation + + /// A peripheral that stays `.connected` through the auto-reconnect discovery + /// window without ever completing discovery, with the extension budget spent, + /// is the strongest in-app signal of a silently invalidated bond. The watchdog + /// tears down and escalates the notified error to `authenticationFailed` so the + /// disconnection handler routes it into guided re-pair recovery, not a generic + /// timeout retry that would keep re-trying the dead bond. + @Test + func `wedged auto-reconnect discovery escalates a silent stall to an auth failure`() async { + let sm = BLEStateMachine(autoReconnectDiscoveryTimeout: watchdogFiringTimeout) + await sm.injectTestCentralManager() + let peripheral = makeLeakedPeripheral(BondSuspectConnectedPeripheral.self) + let recorder = BondSuspectDisconnectionRecorder() + await sm.setDisconnectionHandler { deviceID, error in + recorder.append(deviceID: deviceID, error: error) + } + + await sm.primeAutoReconnectTeardown(peripheral: peripheral) + + let notified = await pollUntilNotEmpty(recorder, within: observationWindow) + #expect(notified, "The wedged connected auto-reconnect watchdog must tear down and notify") + #expect(await sm.currentPhase.name == "idle") + // The extension budget stays bounded at its ceiling; teardown never pushes past it. + #expect(await sm.currentDiscoveryTimeoutExtensions == BLEStateMachine.maxDiscoveryTimeoutExtensions) + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.deviceID == peripheral.identifier) + guard case .authenticationFailed = recorder.events.first?.error as? BLEError else { + Issue.record("Expected BLEError.authenticationFailed, got \(String(describing: recorder.events.first?.error))") + return + } + } + + // MARK: - Discovery-complete window disconnect + + /// A disconnect delivered after discovery completed but before `connect()` + /// adopts the link must settle the machine in `.idle` via full-disconnect + /// teardown, not fork into `.autoReconnecting` while `connect()` separately + /// fails on the vanished phase. + @Test + func `disconnect in the discovery-complete window settles fully instead of forking`() async { + let sm = BLEStateMachine() + await sm.injectTestCentralManager() + let peripheral = makeLeakedPeripheral(BondSuspectConnectedPeripheral.self) + + let autoReconnectRecorder = BondSuspectAutoReconnectRecorder() + await sm.setAutoReconnectingHandler { deviceID, reason in + autoReconnectRecorder.append(deviceID: deviceID, reason: reason) + } + let disconnectionRecorder = BondSuspectDisconnectionRecorder() + await sm.setDisconnectionHandler { deviceID, error in + disconnectionRecorder.append(deviceID: deviceID, error: error) + } + + await sm.primeDiscoveryComplete(peripheral: peripheral) + await sm.handleDidDisconnect( + peripheral, + timestamp: CFAbsoluteTimeGetCurrent(), + isReconnecting: true, + error: nil + ) + + #expect(await sm.currentPhase.name == "idle") + #expect(autoReconnectRecorder.events.isEmpty, "Must not fork into auto-reconnect from the discovery-complete window") + #expect(disconnectionRecorder.events.isEmpty, "Full-disconnect teardown of an unadopted link notifies no session loss") + } + + // MARK: - Helpers + + private func pollUntilNotEmpty(_ recorder: BondSuspectDisconnectionRecorder, within timeout: TimeInterval) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while recorder.events.isEmpty { + if Date() > deadline { return false } + try? await Task.sleep(for: .milliseconds(5)) + } + return true + } +} + +// MARK: - Test doubles and seams + +/// Collects `(deviceID, error)` pairs delivered to `onDisconnection`. +private final class BondSuspectDisconnectionRecorder: @unchecked Sendable { + private(set) var events: [(deviceID: UUID, error: Error?)] = [] + + func append(deviceID: UUID, error: Error?) { + events.append((deviceID, error)) + } +} + +/// Collects `(deviceID, reason)` pairs delivered to `onAutoReconnecting`. +private final class BondSuspectAutoReconnectRecorder: @unchecked Sendable { + private(set) var events: [(deviceID: UUID, reason: String)] = [] + + func append(deviceID: UUID, reason: String) { + events.append((deviceID, reason)) + } +} + +/// Retains mock peripherals for the process lifetime. `CBPeripheral` has no +/// public initializer and its `-dealloc` touches internals that a runtime- +/// allocated instance never set up, so releasing one crashes; never freeing +/// them keeps the doubles usable. +private enum BondSuspectPeripheralStore { + nonisolated(unsafe) static var retained: [CBPeripheral] = [] +} + +/// A `CBPeripheral` double whose `state` is forced to `.connected`. +private final class BondSuspectConnectedPeripheral: CBPeripheral, @unchecked Sendable { + static let uuid = UUID() + override var identifier: UUID { + Self.uuid + } + + override var state: CBPeripheralState { + .connected + } +} + +/// Allocates a mock peripheral without invoking `CBPeripheral`'s unavailable +/// initializer and keeps it alive so it is never deallocated. +private func makeLeakedPeripheral(_ type: T.Type) -> T { + // swiftlint:disable:next force_cast + let peripheral = class_createInstance(type, 0) as! T + BondSuspectPeripheralStore.retained.append(peripheral) + return peripheral +} + +/// Actor-isolated seams that install the exact state the connect and +/// auto-reconnect paths would produce, then let the real handlers run. +private extension BLEStateMachine { + /// Installs a connected auto-reconnect phase with a spent extension budget and + /// arms the real auto-reconnect watchdog so its teardown branch fires. + func primeAutoReconnectTeardown(peripheral: CBPeripheral) { + phase = .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil) + phaseStartTime = Date() + discoveryTimeoutExtensions = BLEStateMachine.maxDiscoveryTimeoutExtensions + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) + } + + /// Installs the transient `.discoveryComplete` phase `connect()` occupies + /// between notification-state resume and adopting the link. + func primeDiscoveryComplete(peripheral: CBPeripheral) { + let tx = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.txCharacteristic), + properties: [.write], + value: nil, + permissions: [.writeable] + ) + let rx = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.rxCharacteristic), + properties: [.notify], + value: nil, + permissions: [.readable] + ) + phase = .discoveryComplete(peripheral: peripheral, tx: tx, rx: rx) + phaseStartTime = Date() + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineDisconnectionMappingTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineDisconnectionMappingTests.swift new file mode 100644 index 00000000..7e64eba9 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineDisconnectionMappingTests.swift @@ -0,0 +1,228 @@ +import CoreBluetooth +import Foundation +@testable import MC1Services +import ObjectiveC +import Testing + +/// Drives the callback handlers that route disconnection errors through +/// `BLEStateMachine.makeConnectionError` before invoking `onDisconnection`, +/// proving each site surfaces a bond-invalidation CoreBluetooth error as the +/// typed `BLEError.authenticationFailed` and preserves `nil` for a clean +/// disconnect in the `.connected` branch. +@Suite("BLEStateMachine disconnection error mapping") +struct BLEStateMachineDisconnectionMappingTests { + /// A CoreBluetooth error every mapped site must surface as `.authenticationFailed`. + private var bondInvalidationError: NSError { + NSError(domain: CBErrorDomain, code: CBError.peerRemovedPairingInformation.rawValue) + } + + private func makeCharacteristic(_ uuidString: String) -> CBMutableCharacteristic { + CBMutableCharacteristic( + type: CBUUID(string: uuidString), + properties: [.notify], + value: nil, + permissions: [.readable] + ) + } + + /// Installs a recording `onDisconnection` handler and returns the box collecting + /// every `(deviceID, error)` pair it receives. + private func recordDisconnections(on sm: BLEStateMachine) async -> DisconnectionRecorder { + let recorder = DisconnectionRecorder() + await sm.setDisconnectionHandler { deviceID, error in + recorder.append(deviceID: deviceID, error: error) + } + return recorder + } + + private func expectSingleAuthFailure(_ recorder: DisconnectionRecorder, deviceID: UUID) { + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.deviceID == deviceID) + guard case .authenticationFailed = recorder.events.first?.error as? BLEError else { + Issue.record("Expected BLEError.authenticationFailed, got \(String(describing: recorder.events.first?.error))") + return + } + } + + // MARK: - handleDidFailToConnect (.autoReconnecting) + + @Test + func `failed auto-reconnect maps a bond-invalidation error to authenticationFailed`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(MappingTestPeripheral.self) + let recorder = await recordDisconnections(on: sm) + await sm.primeAutoReconnecting(peripheral: peripheral) + + await sm.handleDidFailToConnect(peripheral, error: bondInvalidationError) + + expectSingleAuthFailure(recorder, deviceID: peripheral.identifier) + #expect(await sm.currentPhase.name == "idle") + } + + // MARK: - handleDidDisconnect full disconnect (.connected / .autoReconnecting) + + @Test + func `full disconnect while connected maps a bond-invalidation error to authenticationFailed`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(MappingTestPeripheral.self) + let recorder = await recordDisconnections(on: sm) + await sm.primeConnected(peripheral: peripheral) + + await sm.handleDidDisconnect( + peripheral, + timestamp: CFAbsoluteTimeGetCurrent(), + isReconnecting: false, + error: bondInvalidationError + ) + + expectSingleAuthFailure(recorder, deviceID: peripheral.identifier) + } + + @Test + func `full disconnect while connected preserves a nil error as nil`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(MappingTestPeripheral.self) + let recorder = await recordDisconnections(on: sm) + await sm.primeConnected(peripheral: peripheral) + + await sm.handleDidDisconnect( + peripheral, + timestamp: CFAbsoluteTimeGetCurrent(), + isReconnecting: false, + error: nil + ) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.deviceID == peripheral.identifier) + #expect(recorder.events.first?.error == nil) + } + + @Test + func `full disconnect while auto-reconnecting maps a bond-invalidation error to authenticationFailed`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(MappingTestPeripheral.self) + let recorder = await recordDisconnections(on: sm) + await sm.primeAutoReconnecting(peripheral: peripheral) + + await sm.handleDidDisconnect( + peripheral, + timestamp: CFAbsoluteTimeGetCurrent(), + isReconnecting: false, + error: bondInvalidationError + ) + + expectSingleAuthFailure(recorder, deviceID: peripheral.identifier) + } + + // MARK: - Auto-reconnect discovery failures + + @Test + func `auto-reconnect service discovery failure maps to authenticationFailed`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(MappingTestPeripheral.self) + let recorder = await recordDisconnections(on: sm) + await sm.primeAutoReconnecting(peripheral: peripheral) + + await sm.handleDidDiscoverServices(peripheral, error: bondInvalidationError) + + expectSingleAuthFailure(recorder, deviceID: peripheral.identifier) + #expect(await sm.currentPhase.name == "idle") + } + + @Test + func `auto-reconnect characteristic discovery failure maps to authenticationFailed`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(MappingTestPeripheral.self) + let recorder = await recordDisconnections(on: sm) + await sm.primeAutoReconnecting(peripheral: peripheral) + + let service = CBMutableService(type: CBUUID(string: BLEServiceUUID.nordicUART), primary: true) + await sm.handleDidDiscoverCharacteristics(peripheral, service: service, error: bondInvalidationError) + + expectSingleAuthFailure(recorder, deviceID: peripheral.identifier) + #expect(await sm.currentPhase.name == "idle") + } + + @Test + func `auto-reconnect notification subscription failure maps to authenticationFailed`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(MappingTestPeripheral.self) + let recorder = await recordDisconnections(on: sm) + await sm.primeAutoReconnecting(peripheral: peripheral) + + let rx = makeCharacteristic(BLEServiceUUID.rxCharacteristic) + await sm.handleDidUpdateNotificationState(peripheral, characteristic: rx, error: bondInvalidationError) + + expectSingleAuthFailure(recorder, deviceID: peripheral.identifier) + #expect(await sm.currentPhase.name == "idle") + } +} + +// MARK: - Test doubles and seams + +/// Collects `(deviceID, error)` pairs delivered to `onDisconnection`. +/// The handler is `@Sendable`; the state machine invokes it from its own +/// isolation, and each test awaits the driving call before reading `events`. +private final class DisconnectionRecorder: @unchecked Sendable { + private(set) var events: [(deviceID: UUID, error: Error?)] = [] + + func append(deviceID: UUID, error: Error?) { + events.append((deviceID, error)) + } +} + +/// Retains mock peripherals for the process lifetime. `CBPeripheral` has no +/// public initializer and its `-dealloc` touches internals that a runtime- +/// allocated instance never set up, so releasing one crashes; never freeing +/// them keeps the doubles usable. +private enum MappingPeripheralStore { + nonisolated(unsafe) static var retained: [CBPeripheral] = [] +} + +/// A `CBPeripheral` double with a stable identity and `.disconnected` state. +private final class MappingTestPeripheral: CBPeripheral, @unchecked Sendable { + static let uuid = UUID() + override var identifier: UUID { + Self.uuid + } + + override var state: CBPeripheralState { + .disconnected + } +} + +/// Allocates a mock peripheral without invoking `CBPeripheral`'s unavailable +/// initializer and keeps it alive so it is never deallocated. +private func makeLeakedPeripheral(_ type: T.Type) -> T { + // swiftlint:disable:next force_cast + let peripheral = class_createInstance(type, 0) as! T + MappingPeripheralStore.retained.append(peripheral) + return peripheral +} + +/// Actor-isolated seams that install the phases the disconnection handlers +/// switch on, then let tests drive the real handlers. +private extension BLEStateMachine { + func primeAutoReconnecting(peripheral: CBPeripheral) { + phase = .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil) + phaseStartTime = Date() + } + + func primeConnected(peripheral: CBPeripheral) { + let tx = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.txCharacteristic), + properties: [.write], + value: nil, + permissions: [.writeable] + ) + let rx = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.rxCharacteristic), + properties: [.notify], + value: nil, + permissions: [.readable] + ) + let (_, continuation) = AsyncStream.makeStream(of: Data.self) + phase = .connected(peripheral: peripheral, tx: tx, rx: rx, dataContinuation: continuation) + phaseStartTime = Date() + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineRestorationAndTeardownTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineRestorationAndTeardownTests.swift new file mode 100644 index 00000000..1f54fdbd --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineRestorationAndTeardownTests.swift @@ -0,0 +1,344 @@ +import CoreBluetooth +import Foundation +@testable import MC1Services +import ObjectiveC +import Testing + +/// Covers phase-continuation safety around state restoration and teardown: +/// a connect parked in `.waitingForBluetooth` must never be clobbered without +/// resuming, an `isReconnecting` disconnect with no owned peripheral must not +/// resurrect `.autoReconnecting`, a `.discoveryComplete` teardown must preserve +/// its error classification for the in-flight `connect()`, and `shutdown()` +/// must cancel the RSSI keepalive it bypasses `cleanupPhaseResources` for. +@Suite("BLEStateMachine restoration and teardown safety") +struct BLEStateMachineRestorationAndTeardownTests { + private var bondInvalidationError: NSError { + NSError(domain: CBErrorDomain, code: CBError.peerRemovedPairingInformation.rawValue) + } + + /// Spins up a suspended `CheckedContinuation` so a phase can own a live + /// continuation without the test blocking on it. + private func suspendedContinuation() async -> (ContinuationBox, Task) { + let box = ContinuationBox() + let driver = Task { + do { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + box.continuation = continuation + } + box.outcome = .success(()) + } catch { + box.outcome = .failure(error) + } + } + while box.continuation == nil { + await Task.yield() + } + return (box, driver) + } + + private func isConnectionFailed(_ outcome: Result?) -> Bool { + guard case let .failure(error) = outcome, + let bleError = error as? BLEError, + case .connectionFailed = bleError else { return false } + return true + } + + // MARK: - State restoration vs .waitingForBluetooth + + @Test + func `willRestoreState resumes a parked waitingForBluetooth continuation before claiming the machine`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(RestorationDisconnectedPeripheral.self) + let (box, driver) = await suspendedContinuation() + await sm.primeWaitingForBluetooth(box: box) + + await sm.handleWillRestoreState(peripheral) + await driver.value + + #expect(isConnectionFailed(box.outcome)) + #expect(await sm.currentPhase.name == "restoringState") + } + + @Test + func `failPendingBluetoothWait is a no-op outside waitingForBluetooth`() async { + let sm = BLEStateMachine() + + await sm.failPendingBluetoothWait(reason: "test") + + #expect(await sm.currentPhase.name == "idle") + } + + // MARK: - isReconnecting disconnect with no owned peripheral + + @Test + func `isReconnecting disconnect in idle does not resurrect autoReconnecting`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(RestorationDisconnectedPeripheral.self) + + await sm.handleDidDisconnect( + peripheral, + timestamp: CFAbsoluteTimeGetCurrent(), + isReconnecting: true, + error: nil + ) + + #expect(await sm.currentPhase.name == "idle") + #expect(await sm.isAutoReconnecting == false) + } + + @Test + func `isReconnecting disconnect in waitingForBluetooth resumes the parked continuation instead of leaking it`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(RestorationDisconnectedPeripheral.self) + let (box, driver) = await suspendedContinuation() + await sm.primeWaitingForBluetooth(box: box) + + await sm.handleDidDisconnect( + peripheral, + timestamp: CFAbsoluteTimeGetCurrent(), + isReconnecting: true, + error: nil + ) + await driver.value + + #expect(box.isResumed) + #expect(await sm.currentPhase.name == "idle") + #expect(await sm.isAutoReconnecting == false) + } + + // MARK: - .discoveryComplete teardown classification + + @Test + func `discoveryComplete teardown records the bond-loss classification for the in-flight connect`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(RestorationDisconnectedPeripheral.self) + await sm.primeDiscoveryComplete(peripheral: peripheral) + + await sm.handleDidDisconnect( + peripheral, + timestamp: CFAbsoluteTimeGetCurrent(), + isReconnecting: true, + error: bondInvalidationError + ) + + #expect(await sm.currentPhase.name == "idle") + let recorded = await sm.discoveryCompleteTeardownError + guard case .authenticationFailed = recorded else { + Issue.record("Expected recorded authenticationFailed, got \(String(describing: recorded))") + return + } + } + + @Test + func `advancing the connection generation clears a stale discoveryComplete teardown error`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(RestorationDisconnectedPeripheral.self) + await sm.primeDiscoveryComplete(peripheral: peripheral) + await sm.handleDidDisconnect( + peripheral, + timestamp: CFAbsoluteTimeGetCurrent(), + isReconnecting: true, + error: bondInvalidationError + ) + #expect(await sm.discoveryCompleteTeardownError != nil) + + await sm.advanceConnectionGeneration() + + #expect(await sm.discoveryCompleteTeardownError == nil) + } + + // MARK: - shutdown() keepalive + + @Test + func `shutdown cancels the RSSI keepalive that its direct phase write bypasses`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(RestorationConnectedPeripheral.self) + await sm.primeConnectedWithKeepalive(peripheral: peripheral) + #expect(await sm.isRSSIKeepaliveActive) + + await sm.shutdown() + + #expect(await sm.isRSSIKeepaliveActive == false) + #expect(await sm.currentPhase.name == "idle") + } + + // MARK: - Write-ACK sequence fencing and classification + + @Test + func `stale didWriteValue callback is dropped instead of resuming the newer write`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(RestorationConnectedPeripheral.self) + let (box, driver) = await suspendedContinuation() + await sm.primePendingWrite(box: box, sequence: 7) + + await sm.handleDidWriteValue(peripheral, characteristic: makeTxCharacteristic(), error: nil, writeSequence: 6) + + #expect(!box.isResumed) + + // The matching callback still completes the write normally. + await sm.handleDidWriteValue(peripheral, characteristic: makeTxCharacteristic(), error: nil, writeSequence: 7) + await driver.value + guard case .success = box.outcome else { + Issue.record("Expected the matching callback to resume the write successfully") + return + } + } + + @Test + func `write error carrying a bond-invalidation code surfaces as authenticationFailed`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(RestorationConnectedPeripheral.self) + let (box, driver) = await suspendedContinuation() + await sm.primePendingWrite(box: box, sequence: 1) + + await sm.handleDidWriteValue( + peripheral, + characteristic: makeTxCharacteristic(), + error: NSError(domain: CBATTErrorDomain, code: CBATTError.insufficientEncryption.rawValue), + writeSequence: 1 + ) + await driver.value + + guard case let .failure(error) = box.outcome, + case .authenticationFailed = error as? BLEError else { + Issue.record("Expected authenticationFailed, got \(String(describing: box.outcome))") + return + } + } + + @Test + func `write error without an auth code stays a writeError`() async { + let sm = BLEStateMachine() + let peripheral = makeLeakedPeripheral(RestorationConnectedPeripheral.self) + let (box, driver) = await suspendedContinuation() + await sm.primePendingWrite(box: box, sequence: 1) + + await sm.handleDidWriteValue( + peripheral, + characteristic: makeTxCharacteristic(), + error: NSError(domain: CBATTErrorDomain, code: CBATTError.writeNotPermitted.rawValue), + writeSequence: 1 + ) + await driver.value + + guard case let .failure(error) = box.outcome, + case .writeError = error as? BLEError else { + Issue.record("Expected writeError, got \(String(describing: box.outcome))") + return + } + } + + private func makeTxCharacteristic() -> CBMutableCharacteristic { + CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.txCharacteristic), + properties: [.write], + value: nil, + permissions: [.writeable] + ) + } +} + +// MARK: - Test doubles and seams + +/// Carries a phase continuation across the actor boundary and records how the +/// state machine ultimately resumed it (or that it was left suspended). +private final class ContinuationBox: @unchecked Sendable { + var continuation: CheckedContinuation? + var outcome: Result? + var isResumed: Bool { + outcome != nil + } +} + +/// Retains mock peripherals for the process lifetime. `CBPeripheral` has no +/// public initializer and its `-dealloc` touches internals that a runtime- +/// allocated instance never set up, so releasing one crashes; never freeing +/// them keeps the doubles usable. +private enum RestorationPeripheralStore { + nonisolated(unsafe) static var retained: [CBPeripheral] = [] +} + +/// A `CBPeripheral` double with a stable identity and `.disconnected` state. +private final class RestorationDisconnectedPeripheral: CBPeripheral, @unchecked Sendable { + static let uuid = UUID() + override var identifier: UUID { + Self.uuid + } + + override var state: CBPeripheralState { + .disconnected + } +} + +/// A `CBPeripheral` double with a stable identity and `.connected` state. +private final class RestorationConnectedPeripheral: CBPeripheral, @unchecked Sendable { + static let uuid = UUID() + override var identifier: UUID { + Self.uuid + } + + override var state: CBPeripheralState { + .connected + } +} + +/// Allocates a mock peripheral without invoking `CBPeripheral`'s unavailable +/// initializer and keeps it alive so it is never deallocated. +private func makeLeakedPeripheral(_ type: T.Type) -> T { + // swiftlint:disable:next force_cast + let peripheral = class_createInstance(type, 0) as! T + RestorationPeripheralStore.retained.append(peripheral) + return peripheral +} + +/// Actor-isolated seams that install the phases these handlers switch on, +/// then let tests drive the real handlers. +private extension BLEStateMachine { + func primeWaitingForBluetooth(box: ContinuationBox) { + guard let continuation = box.continuation else { return } + phase = .waitingForBluetooth(continuation: continuation) + phaseStartTime = Date() + } + + func primeDiscoveryComplete(peripheral: CBPeripheral) { + let tx = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.txCharacteristic), + properties: [.write], + value: nil, + permissions: [.writeable] + ) + let rx = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.rxCharacteristic), + properties: [.notify], + value: nil, + permissions: [.readable] + ) + phase = .discoveryComplete(peripheral: peripheral, tx: tx, rx: rx) + phaseStartTime = Date() + } + + func primeConnectedWithKeepalive(peripheral: CBPeripheral) { + let tx = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.txCharacteristic), + properties: [.write], + value: nil, + permissions: [.writeable] + ) + let rx = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.rxCharacteristic), + properties: [.notify], + value: nil, + permissions: [.readable] + ) + let (_, continuation) = AsyncStream.makeStream(of: Data.self) + phase = .connected(peripheral: peripheral, tx: tx, rx: rx, dataContinuation: continuation) + phaseStartTime = Date() + startRSSIKeepalive(for: peripheral) + } + + func primePendingWrite(box: ContinuationBox, sequence: UInt64) { + guard let continuation = box.continuation else { return } + pendingWriteContinuation = continuation + pendingWriteSequence = sequence + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineTests.swift index e262092a..0c6b90e9 100644 --- a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineTests.swift @@ -1,274 +1,464 @@ import CoreBluetooth import Foundation -import Testing @testable import MC1Services +import ObjectiveC +import Testing @Suite("BLEStateMachine Tests") struct BLEStateMachineTests { - - // MARK: - Initial State Tests - - @Test("initializes in idle phase") - func initializesInIdlePhase() async { - let sm = BLEStateMachine() - let phase = await sm.currentPhase - #expect(phase.name == "idle") - } - - @Test("isConnected returns false when idle") - func isConnectedReturnsFalseWhenIdle() async { - let sm = BLEStateMachine() - let connected = await sm.isConnected - #expect(connected == false) + // MARK: - Initial State Tests + + @Test + func `initializes in idle phase`() async { + let sm = BLEStateMachine() + let phase = await sm.currentPhase + #expect(phase.name == "idle") + } + + @Test + func `isConnected returns false when idle`() async { + let sm = BLEStateMachine() + let connected = await sm.isConnected + #expect(connected == false) + } + + @Test + func `connectedDeviceID returns nil when idle`() async { + let sm = BLEStateMachine() + let deviceID = await sm.connectedDeviceID + #expect(deviceID == nil) + } + + @Test + func `isAutoReconnecting returns false when idle`() async { + let sm = BLEStateMachine() + let reconnecting = await sm.isAutoReconnecting + #expect(reconnecting == false) + } + + @Test + func `currentPhaseName returns idle when idle`() async { + let sm = BLEStateMachine() + let name = await sm.currentPhaseName + #expect(name == "idle") + } + + // MARK: - Disconnect Tests + + @Test + func `disconnect returns immediately when idle`() async { + let sm = BLEStateMachine() + + await sm.disconnect() + + #expect(await sm.currentPhase.name == "idle") + #expect(await sm.isConnected == false) + } + + // MARK: - Connection Error Tests + + @Test + func `connect throws appropriate error for unknown UUID`() async throws { + let sm = BLEStateMachine() + let unknownID = UUID() + + await sm.activate() + + await #expect(throws: BLEError.self) { + _ = try await sm.connect(to: unknownID) } - - @Test("connectedDeviceID returns nil when idle") - func connectedDeviceIDReturnsNilWhenIdle() async { - let sm = BLEStateMachine() - let deviceID = await sm.connectedDeviceID - #expect(deviceID == nil) + } + + @Test + func `send throws notConnected when idle`() async throws { + let sm = BLEStateMachine() + let testData = Data([0x01, 0x02, 0x03]) + + do { + try await sm.send(testData) + Issue.record("Expected notConnected error") + } catch let error as BLEError { + if case .notConnected = error { + // Expected + } else { + Issue.record("Expected notConnected error, got \(error)") + } } - - @Test("isAutoReconnecting returns false when idle") - func isAutoReconnectingReturnsFalseWhenIdle() async { - let sm = BLEStateMachine() - let reconnecting = await sm.isAutoReconnecting - #expect(reconnecting == false) - } - - @Test("currentPhaseName returns idle when idle") - func currentPhaseNameReturnsIdleWhenIdle() async { - let sm = BLEStateMachine() - let name = await sm.currentPhaseName - #expect(name == "idle") - } - - // MARK: - Handler Registration Tests - - @Test("setDisconnectionHandler can be registered") - func setDisconnectionHandlerCanBeRegistered() async { - let sm = BLEStateMachine() - - await sm.setDisconnectionHandler { _, _ in } - - #expect(await sm.currentPhase.name == "idle") - } - - @Test("setReconnectionHandler can be registered") - func setReconnectionHandlerCanBeRegistered() async { - let sm = BLEStateMachine() - - await sm.setReconnectionHandler { _, _ in } - - #expect(await sm.currentPhase.name == "idle") + } + + // MARK: - Idempotency Tests + + @Test + func `disconnect is idempotent`() async { + let sm = BLEStateMachine() + + // Multiple disconnects should not crash + await sm.disconnect() + await sm.disconnect() + await sm.disconnect() + + #expect(await sm.currentPhase.name == "idle") + } + + @Test + func `activate is idempotent`() async { + let sm = BLEStateMachine() + + // Multiple activations should not crash or create duplicate managers + await sm.activate() + await sm.activate() + await sm.activate() + + #expect(await sm.currentPhase.name == "idle") + } + + // MARK: - Connection Generation Tests + + @Test + func `connection generation starts at zero`() async { + let sm = BLEStateMachine() + let generation = await sm.currentConnectionGeneration + #expect(generation == 0) + } + + @Test + func `disconnect callback from previous generation is rejected`() { + let timestamp: CFAbsoluteTime = 98 + let generationStart: CFAbsoluteTime = 101 + + let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: timestamp, + generationStart: generationStart + ) + + #expect(isStale) // 98 + 1.0 = 99 < 101 → stale + } + + @Test + func `disconnect callback at tolerance boundary is accepted`() { + let generationStart: CFAbsoluteTime = 200 + let timestamp = generationStart - 1.0 + + let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: timestamp, + generationStart: generationStart + ) + + #expect(!isStale) // 199 + 1.0 = 200, not < 200 → accepted + } + + @Test + func `disconnect callback beyond tolerance is rejected`() { + let generationStart: CFAbsoluteTime = 200 + let timestamp = generationStart - 1.5 + + let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: timestamp, + generationStart: generationStart + ) + + #expect(isStale) // 198.5 + 1.0 = 199.5 < 200 → stale + } + + // MARK: - Discovery Timeout Extension Tests + + @Test + func `extend predicate allows extension while connected and within budget`() { + let shouldExtend = BLEStateMachine.shouldExtendDiscoveryTimeout( + peripheralState: .connected, + extensions: 0, + maxExtensions: BLEStateMachine.maxDiscoveryTimeoutExtensions + ) + + #expect(shouldExtend) // link is up; a didConnect/discovery callback is in flight + } + + @Test + func `extend predicate rejects extension when peripheral is not connected`() { + for state in [CBPeripheralState.connecting, .disconnected, .disconnecting] { + let shouldExtend = BLEStateMachine.shouldExtendDiscoveryTimeout( + peripheralState: state, + extensions: 0, + maxExtensions: BLEStateMachine.maxDiscoveryTimeoutExtensions + ) + + #expect(!shouldExtend, "state \(state.rawValue) should tear down, not extend") } + } + + @Test + func `extend predicate rejects extension once the budget is spent`() { + let max = BLEStateMachine.maxDiscoveryTimeoutExtensions + + #expect(BLEStateMachine.shouldExtendDiscoveryTimeout(peripheralState: .connected, extensions: max - 1, maxExtensions: max)) + #expect(!BLEStateMachine.shouldExtendDiscoveryTimeout(peripheralState: .connected, extensions: max, maxExtensions: max)) + } + + @Test + func `advancing the connection generation resets the discovery-extension budget`() async { + let sm = BLEStateMachine() + await sm.recordDiscoveryTimeoutExtension() + await sm.recordDiscoveryTimeoutExtension() + #expect(await sm.currentDiscoveryTimeoutExtensions == BLEStateMachine.maxDiscoveryTimeoutExtensions) + + await sm.advanceConnectionGeneration() + + #expect(await sm.currentDiscoveryTimeoutExtensions == 0) + } + + @Test + func `disconnect callback is accepted when timestamp is at or after generation start`() { + let generationStart: CFAbsoluteTime = 500 + + let atStart = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: generationStart, + generationStart: generationStart + ) + let afterStart = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: generationStart + 300, + generationStart: generationStart + ) + + #expect(!atStart) + #expect(!afterStart) + } +} - @Test("setBluetoothStateChangeHandler can be registered") - func setBluetoothStateChangeHandlerCanBeRegistered() async { - let sm = BLEStateMachine() - - await sm.setBluetoothStateChangeHandler { _ in } +// MARK: - Discovery-timeout watchdog end-to-end coverage - #expect(await sm.currentPhase.name == "idle") - } +/// A large timeout so a re-armed discovery watchdog cannot fire during the test window. +private let watchdogNonFiringTimeout: TimeInterval = 3600 - @Test("setAutoReconnectingHandler can be registered") - func setAutoReconnectingHandlerCanBeRegistered() async { - let sm = BLEStateMachine() +/// A brief timeout so a re-armed discovery watchdog fires on its own during the test window. +private let watchdogReArmFiringTimeout: TimeInterval = 0.02 - await sm.setAutoReconnectingHandler { _, _ in } - - #expect(await sm.currentPhase.name == "idle") - } +/// Bounds the wait for the re-armed watchdog so a dropped re-arm fails fast instead of hanging. +private let watchdogReArmObservationWindow: TimeInterval = 5 - // MARK: - Disconnect Tests +/// Retains mock peripherals for the process lifetime. `CBPeripheral` has no +/// public initializer and its `-dealloc` touches internals that a runtime- +/// allocated instance never set up, so releasing one crashes; never freeing +/// them keeps the doubles usable. +private enum WatchdogPeripheralStore { + nonisolated(unsafe) static var retained: [CBPeripheral] = [] +} - @Test("disconnect returns immediately when idle") - func disconnectReturnsImmediatelyWhenIdle() async { - let sm = BLEStateMachine() +/// A `CBPeripheral` double whose `state` is forced to `.connected`. +private final class ConnectedTestPeripheral: CBPeripheral, @unchecked Sendable { + static let uuid = UUID() + override var identifier: UUID { + Self.uuid + } - await sm.disconnect() + override var state: CBPeripheralState { + .connected + } +} - #expect(await sm.currentPhase.name == "idle") - #expect(await sm.isConnected == false) - } +/// A `CBPeripheral` double whose `state` is forced to `.disconnected`. +private final class DisconnectedTestPeripheral: CBPeripheral, @unchecked Sendable { + static let uuid = UUID() + override var identifier: UUID { + Self.uuid + } - // MARK: - Connection Error Tests + override var state: CBPeripheralState { + .disconnected + } +} - @Test("connect throws appropriate error for unknown UUID") - func connectThrowsAppropriateErrorForUnknownUUID() async throws { - let sm = BLEStateMachine() - let unknownID = UUID() +/// Allocates a mock peripheral without invoking `CBPeripheral`'s unavailable +/// initializer and keeps it alive so it is never deallocated. +private func makeLeakedPeripheral(_ type: T.Type) -> T { + // swiftlint:disable:next force_cast + let peripheral = class_createInstance(type, 0) as! T + WatchdogPeripheralStore.retained.append(peripheral) + return peripheral +} - await sm.activate() +/// Carries the discovery-phase continuation across the actor boundary and records +/// how the state machine ultimately resumed it (or that it was left suspended). +private final class WatchdogContinuationBox: @unchecked Sendable { + var continuation: CheckedContinuation? + var outcome: Result? + var isResumed: Bool { + outcome != nil + } +} - await #expect(throws: BLEError.self) { - _ = try await sm.connect(to: unknownID) - } - } +/// Actor-isolated test seams that install the exact state +/// `armServiceDiscoveryTimeout` would produce, then drive the real handler. +extension BLEStateMachine { + func injectTestCentralManager() { + centralManager = CBCentralManager(delegate: nil, queue: nil) + } + + fileprivate func primeDiscoveringServices(peripheral: CBPeripheral, box: WatchdogContinuationBox, extensionsUsed: Int) { + guard let continuation = box.continuation else { return } + phase = .discoveringServices(peripheral: peripheral, continuation: continuation) + phaseStartTime = Date() + discoveryTimeoutExtensions = extensionsUsed + serviceDiscoveryTimeoutTask = Task {} + } + + func fireServiceDiscoveryTimeout(for peripheral: CBPeripheral) { + handleServiceDiscoveryTimeout(for: peripheral) + } + + func cancelServiceDiscoveryTimeoutForTesting() { + serviceDiscoveryTimeoutTask?.cancel() + serviceDiscoveryTimeoutTask = nil + } +} - @Test("send throws notConnected when idle") - func sendThrowsNotConnectedWhenIdle() async throws { - let sm = BLEStateMachine() - let testData = Data([0x01, 0x02, 0x03]) - - do { - try await sm.send(testData) - Issue.record("Expected notConnected error") - } catch let error as BLEError { - if case .notConnected = error { - // Expected - } else { - Issue.record("Expected notConnected error, got \(error)") - } +@Suite("BLEStateMachine service-discovery watchdog") +struct BLEStateMachineDiscoveryWatchdogTests { + /// Spins up a suspended `CheckedContinuation` and returns a box holding it plus the + /// task awaiting it, so the discovery phase can own a live continuation without the + /// test blocking on it. + private func suspendedContinuation() async -> (WatchdogContinuationBox, Task) { + let box = WatchdogContinuationBox() + let driver = Task { + do { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + box.continuation = continuation } + box.outcome = .success(()) + } catch { + box.outcome = .failure(error) + } } - - // MARK: - Idempotency Tests - - @Test("disconnect is idempotent") - func disconnectIsIdempotent() async { - let sm = BLEStateMachine() - - // Multiple disconnects should not crash - await sm.disconnect() - await sm.disconnect() - await sm.disconnect() - - #expect(await sm.currentPhase.name == "idle") - } - - @Test("activate is idempotent") - func activateIsIdempotent() async { - let sm = BLEStateMachine() - - // Multiple activations should not crash or create duplicate managers - await sm.activate() - await sm.activate() - await sm.activate() - - #expect(await sm.currentPhase.name == "idle") + while box.continuation == nil { + await Task.yield() } - - @Test("handler replacement works correctly") - func handlerReplacementWorksCorrectly() async { - let sm = BLEStateMachine() - - await sm.setDisconnectionHandler { _, _ in } - await sm.setDisconnectionHandler { _, _ in } - - // Multiple handler registrations should not crash - #expect(await sm.currentPhase.name == "idle") + return (box, driver) + } + + private func isConnectionTimeout(_ outcome: Result?) -> Bool { + guard case let .failure(error) = outcome, + let bleError = error as? BLEError, + case .connectionTimeout = bleError else { return false } + return true + } + + private func isAuthenticationFailed(_ outcome: Result?) -> Bool { + guard case let .failure(error) = outcome, + let bleError = error as? BLEError, + case .authenticationFailed = bleError else { return false } + return true + } + + /// Polls until the discovery continuation is resumed or the window elapses, so a + /// re-arm that never fired fails the assertion instead of suspending the test. + private func awaitResumed(_ box: WatchdogContinuationBox, within timeout: TimeInterval) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while !box.isResumed { + if Date() > deadline { return false } + try? await Task.sleep(for: .milliseconds(5)) } - - // MARK: - Connection Generation Tests - - @Test("connection generation starts at zero") - func connectionGenerationStartsAtZero() async { - let sm = BLEStateMachine() - let generation = await sm.currentConnectionGeneration - #expect(generation == 0) + return true + } + + @Test + func `connected peripheral mid-discovery extends the window instead of tearing down`() async { + let sm = BLEStateMachine(serviceDiscoveryTimeout: watchdogNonFiringTimeout) + await sm.injectTestCentralManager() + let peripheral = makeLeakedPeripheral(ConnectedTestPeripheral.self) + let (box, driver) = await suspendedContinuation() + + await sm.primeDiscoveringServices(peripheral: peripheral, box: box, extensionsUsed: 0) + await sm.fireServiceDiscoveryTimeout(for: peripheral) + + // The live link survives: the phase stays in discovery, the extension budget + // is consumed, and the discovery continuation is not failed. + let phaseName = await sm.currentPhase.name + #expect(phaseName == "discoveringServices") + #expect(await sm.currentDiscoveryTimeoutExtensions == 1) + #expect(!box.isResumed) + + // Release the still-suspended continuation and the re-armed watchdog. The + // phase distinguishes the branch taken, so the continuation is resumed + // exactly once: the extend branch leaves it suspended here, while a + // teardown branch already resumed it. + if phaseName == "discoveringServices" { + box.continuation?.resume() } - - @Test("disconnect callback from previous generation is rejected") - func disconnectCallbackFromPreviousGenerationIsRejected() { - let timestamp: CFAbsoluteTime = 98 - let generationStart: CFAbsoluteTime = 101 - - let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: timestamp, - generationStart: generationStart - ) - - #expect(isStale) // 98 + 1.0 = 99 < 101 → stale - } - - @Test("disconnect callback at tolerance boundary is accepted") - func disconnectCallbackAtToleranceBoundaryIsAccepted() { - let generationStart: CFAbsoluteTime = 200 - let timestamp = generationStart - 1.0 - - let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: timestamp, - generationStart: generationStart - ) - - #expect(!isStale) // 199 + 1.0 = 200, not < 200 → accepted - } - - @Test("disconnect callback beyond tolerance is rejected") - func disconnectCallbackBeyondToleranceIsRejected() { - let generationStart: CFAbsoluteTime = 200 - let timestamp = generationStart - 1.5 - - let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: timestamp, - generationStart: generationStart - ) - - #expect(isStale) // 198.5 + 1.0 = 199.5 < 200 → stale - } - - // MARK: - Discovery Timeout Extension Tests - - @Test("extend predicate allows extension while connected and within budget") - func extendPredicateAllowsExtensionWhileConnected() { - let shouldExtend = BLEStateMachine.shouldExtendDiscoveryTimeout( - peripheralState: .connected, - extensions: 0, - maxExtensions: BLEStateMachine.maxDiscoveryTimeoutExtensions - ) - - #expect(shouldExtend) // link is up; a didConnect/discovery callback is in flight - } - - @Test("extend predicate rejects extension when peripheral is not connected") - func extendPredicateRejectsExtensionWhenNotConnected() { - for state in [CBPeripheralState.connecting, .disconnected, .disconnecting] { - let shouldExtend = BLEStateMachine.shouldExtendDiscoveryTimeout( - peripheralState: state, - extensions: 0, - maxExtensions: BLEStateMachine.maxDiscoveryTimeoutExtensions - ) - - #expect(!shouldExtend, "state \(state.rawValue) should tear down, not extend") - } - } - - @Test("extend predicate rejects extension once the budget is spent") - func extendPredicateRejectsExtensionAtBudget() { - let max = BLEStateMachine.maxDiscoveryTimeoutExtensions - - #expect(BLEStateMachine.shouldExtendDiscoveryTimeout(peripheralState: .connected, extensions: max - 1, maxExtensions: max)) - #expect(!BLEStateMachine.shouldExtendDiscoveryTimeout(peripheralState: .connected, extensions: max, maxExtensions: max)) - } - - @Test("advancing the connection generation resets the discovery-extension budget") - func advanceConnectionGenerationResetsDiscoveryExtensions() async { - let sm = BLEStateMachine() - await sm.recordDiscoveryTimeoutExtension() - await sm.recordDiscoveryTimeoutExtension() - #expect(await sm.currentDiscoveryTimeoutExtensions == BLEStateMachine.maxDiscoveryTimeoutExtensions) - - await sm.advanceConnectionGeneration() - - #expect(await sm.currentDiscoveryTimeoutExtensions == 0) - } - - @Test("disconnect callback is accepted when timestamp is at or after generation start") - func disconnectCallbackAtOrAfterGenerationStartIsAccepted() { - let generationStart: CFAbsoluteTime = 500 - - let atStart = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: generationStart, - generationStart: generationStart - ) - let afterStart = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: generationStart + 300, - generationStart: generationStart - ) - - #expect(!atStart) - #expect(!afterStart) + await driver.value + await sm.cancelServiceDiscoveryTimeoutForTesting() + } + + /// A peripheral still `.connected` after the extension budget is spent never + /// delivered a discovery callback and no CoreBluetooth error arrived: the + /// strongest in-app signal of a silently invalidated bond. The teardown + /// escalates to `authenticationFailed` so it reaches guided re-pair recovery + /// rather than looping generic timeout retries against the dead bond. + @Test + func `connected peripheral with a spent budget escalates to an auth failure`() async { + let sm = BLEStateMachine(serviceDiscoveryTimeout: watchdogNonFiringTimeout) + await sm.injectTestCentralManager() + let peripheral = makeLeakedPeripheral(ConnectedTestPeripheral.self) + let (box, driver) = await suspendedContinuation() + + await sm.primeDiscoveringServices( + peripheral: peripheral, + box: box, + extensionsUsed: BLEStateMachine.maxDiscoveryTimeoutExtensions + ) + await sm.fireServiceDiscoveryTimeout(for: peripheral) + await driver.value + + #expect(await sm.currentPhase.name == "idle") + // The extension budget stays bounded: it is never pushed past its ceiling. + #expect(await sm.currentDiscoveryTimeoutExtensions == BLEStateMachine.maxDiscoveryTimeoutExtensions) + #expect(isAuthenticationFailed(box.outcome)) + } + + /// The extend branch must re-arm the watchdog, not merely consume a budget unit. + /// The manual fire spends the final extension, so the re-armed watchdog is the only + /// thing that can fire again; when it does, the spent budget forces a teardown. + /// Dropping the re-arm leaves the continuation suspended, caught here as a missed teardown. + @Test + func `extending re-arms the watchdog so a later timeout still tears the link down`() async { + let sm = BLEStateMachine(serviceDiscoveryTimeout: watchdogReArmFiringTimeout) + await sm.injectTestCentralManager() + let peripheral = makeLeakedPeripheral(ConnectedTestPeripheral.self) + let (box, driver) = await suspendedContinuation() + + await sm.primeDiscoveringServices( + peripheral: peripheral, + box: box, + extensionsUsed: BLEStateMachine.maxDiscoveryTimeoutExtensions - 1 + ) + await sm.fireServiceDiscoveryTimeout(for: peripheral) + + let tornDown = await awaitResumed(box, within: watchdogReArmObservationWindow) + #expect(tornDown, "Extending must re-arm the watchdog; without the re-arm the link keeps a spent budget and no live timeout") + + if tornDown { + #expect(await sm.currentPhase.name == "idle") + #expect(await sm.currentDiscoveryTimeoutExtensions == BLEStateMachine.maxDiscoveryTimeoutExtensions) + #expect(isAuthenticationFailed(box.outcome)) + } else { + // Release the still-suspended continuation so the driver task can finish. + box.continuation?.resume() } + await driver.value + await sm.cancelServiceDiscoveryTimeoutForTesting() + } + + @Test + func `disconnected peripheral tears down instead of extending`() async { + let sm = BLEStateMachine(serviceDiscoveryTimeout: watchdogNonFiringTimeout) + await sm.injectTestCentralManager() + let peripheral = makeLeakedPeripheral(DisconnectedTestPeripheral.self) + let (box, driver) = await suspendedContinuation() + + await sm.primeDiscoveringServices(peripheral: peripheral, box: box, extensionsUsed: 0) + await sm.fireServiceDiscoveryTimeout(for: peripheral) + await driver.value + + #expect(await sm.currentPhase.name == "idle") + #expect(await sm.currentDiscoveryTimeoutExtensions == 0) + #expect(isConnectionTimeout(box.outcome)) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineWriteWithoutResponseTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineWriteWithoutResponseTests.swift index 95d4dc7e..b32c7d82 100644 --- a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineWriteWithoutResponseTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineWriteWithoutResponseTests.swift @@ -1,7 +1,7 @@ @preconcurrency import CoreBluetooth import Foundation -import Testing @testable import MC1Services +import Testing /// Covers the `.withoutResponse` write path's backpressure continuation lifecycle and /// capability capture. The full `sendWithoutResponse` round-trip needs a real `CBPeripheral` @@ -9,130 +9,129 @@ import Testing /// critical parts that can hang or corrupt state if they regress. @Suite("BLEStateMachine write-without-response path") struct BLEStateMachineWriteWithoutResponseTests { - - // MARK: - Helpers - - /// Polls until the state machine has installed its readiness continuation, so the test - /// can deterministically signal readiness without racing the suspending task. - private func waitUntilAwaiting(_ sm: BLEStateMachine) async -> Bool { - for _ in 0..<200 { - if await sm.isAwaitingWriteWithoutResponseReady { return true } - try? await Task.sleep(for: .milliseconds(5)) - } - return false + // MARK: - Helpers + + /// Polls until the state machine has installed its readiness continuation, so the test + /// can deterministically signal readiness without racing the suspending task. + private func waitUntilAwaiting(_ sm: BLEStateMachine) async -> Bool { + for _ in 0..<200 { + if await sm.isAwaitingWriteWithoutResponseReady { return true } + try? await Task.sleep(for: .milliseconds(5)) } - - /// Returns true if `work` finishes within `duration`; false if it is still running - /// (i.e. the awaited continuation never resumed — a hang). Converts a hang into a - /// fast assertion failure instead of stalling the whole suite. - private func completesWithin(_ duration: Duration, _ work: @escaping @Sendable () async -> Void) async -> Bool { - await withTaskGroup(of: Bool.self) { group in - group.addTask { await work(); return true } - group.addTask { try? await Task.sleep(for: duration); return false } - defer { group.cancelAll() } - return await group.next() ?? false - } + return false + } + + /// Returns true if `work` finishes within `duration`; false if it is still running + /// (i.e. the awaited continuation never resumed — a hang). Converts a hang into a + /// fast assertion failure instead of stalling the whole suite. + private func completesWithin(_ duration: Duration, _ work: @escaping @Sendable () async -> Void) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { await work(); return true } + group.addTask { try? await Task.sleep(for: duration); return false } + defer { group.cancelAll() } + return await group.next() ?? false } + } - // MARK: - Readiness gating + // MARK: - Readiness gating - @Test("readiness wait returns immediately when peripheral is already ready") - func waitReturnsImmediatelyWhenReady() async { - let sm = BLEStateMachine() + @Test + func `readiness wait returns immediately when peripheral is already ready`() async { + let sm = BLEStateMachine() - let finished = await completesWithin(.seconds(1)) { - _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: true) - } - - #expect(finished) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + let finished = await completesWithin(.seconds(1)) { + _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: true) } - @Test("readiness wait resumes when the peripheral signals it can write again") - func waitResumesOnPeripheralReadyCallback() async { - let sm = BLEStateMachine() + #expect(finished) + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } - let waiter = Task { _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) } - #expect(await waitUntilAwaiting(sm)) + @Test + func `readiness wait resumes when the peripheral signals it can write again`() async { + let sm = BLEStateMachine() - await sm.handlePeripheralReadyForWriteWithoutResponse() + let waiter = Task { _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) } + #expect(await waitUntilAwaiting(sm)) - #expect(await completesWithin(.seconds(3)) { await waiter.value }) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) - } + await sm.handlePeripheralReadyForWriteWithoutResponse() - @Test("readiness wait resumes on disconnect so a sender never hangs across a drop") - func waitResumesOnDisconnect() async { - let sm = BLEStateMachine() + #expect(await completesWithin(.seconds(3)) { await waiter.value }) + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } - let waiter = Task { _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) } - #expect(await waitUntilAwaiting(sm)) + @Test + func `readiness wait resumes on disconnect so a sender never hangs across a drop`() async { + let sm = BLEStateMachine() - await sm.disconnect() + let waiter = Task { _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) } + #expect(await waitUntilAwaiting(sm)) - #expect(await completesWithin(.seconds(3)) { await waiter.value }) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) - } - - @Test("readiness wait fails via the timeout backstop when the peripheral stays silent") - func waitTimesOutWhenPeripheralStaysSilent() async { - let sm = BLEStateMachine(writeTimeout: 0.5) - - let waiter = Task { () -> Bool in - do { - try await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) - return false - } catch { - return true - } - } - #expect(await waitUntilAwaiting(sm)) - - // No ready callback and no disconnect — only the backstop timeout can release it. - let finished = await completesWithin(.seconds(3)) { _ = await waiter.value } - #expect(finished) - #expect(await waiter.value) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) - } + await sm.disconnect() - @Test("peripheral-ready callback with no pending waiter is a safe no-op") - func readyCallbackWithoutWaiterIsSafe() async { - let sm = BLEStateMachine() + #expect(await completesWithin(.seconds(3)) { await waiter.value }) + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } - await sm.handlePeripheralReadyForWriteWithoutResponse() + @Test + func `readiness wait fails via the timeout backstop when the peripheral stays silent`() async { + let sm = BLEStateMachine(writeTimeout: 0.5) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) - } - - // MARK: - Capability capture - - @Test("capability is true when the write characteristic advertises writeWithoutResponse") - func capabilityTrueForWriteWithoutResponseCharacteristic() async { - let sm = BLEStateMachine() - let characteristic = CBMutableCharacteristic( - type: CBUUID(string: BLEServiceUUID.txCharacteristic), - properties: [.write, .writeWithoutResponse], - value: nil, - permissions: [.writeable] - ) - - await sm.captureWriteWithoutResponseCapability(from: characteristic) - - #expect(await sm.supportsWriteWithoutResponse == true) - } - - @Test("capability is false when the write characteristic is write-only (ESP32)") - func capabilityFalseForWriteOnlyCharacteristic() async { - let sm = BLEStateMachine() - let characteristic = CBMutableCharacteristic( - type: CBUUID(string: BLEServiceUUID.txCharacteristic), - properties: [.write], - value: nil, - permissions: [.writeable] - ) - - await sm.captureWriteWithoutResponseCapability(from: characteristic) - - #expect(await sm.supportsWriteWithoutResponse == false) + let waiter = Task { () -> Bool in + do { + try await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) + return false + } catch { + return true + } } + #expect(await waitUntilAwaiting(sm)) + + // No ready callback and no disconnect — only the backstop timeout can release it. + let finished = await completesWithin(.seconds(3)) { _ = await waiter.value } + #expect(finished) + #expect(await waiter.value) + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } + + @Test + func `peripheral-ready callback with no pending waiter is a safe no-op`() async { + let sm = BLEStateMachine() + + await sm.handlePeripheralReadyForWriteWithoutResponse() + + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } + + // MARK: - Capability capture + + @Test + func `capability is true when the write characteristic advertises writeWithoutResponse`() async { + let sm = BLEStateMachine() + let characteristic = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.txCharacteristic), + properties: [.write, .writeWithoutResponse], + value: nil, + permissions: [.writeable] + ) + + await sm.captureWriteWithoutResponseCapability(from: characteristic) + + #expect(await sm.supportsWriteWithoutResponse == true) + } + + @Test + func `capability is false when the write characteristic is write-only (ESP32)`() async { + let sm = BLEStateMachine() + let characteristic = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.txCharacteristic), + properties: [.write], + value: nil, + permissions: [.writeable] + ) + + await sm.captureWriteWithoutResponseCapability(from: characteristic) + + #expect(await sm.supportsWriteWithoutResponse == false) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Utilities/DeviceIdentityTests.swift b/MC1Services/Tests/MC1ServicesTests/Utilities/DeviceIdentityTests.swift index 2757292f..aeec89c7 100644 --- a/MC1Services/Tests/MC1ServicesTests/Utilities/DeviceIdentityTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Utilities/DeviceIdentityTests.swift @@ -1,48 +1,47 @@ -import Testing -import Foundation import CryptoKit +import Foundation @testable import MC1Services +import Testing @Suite("DeviceIdentity Tests") struct DeviceIdentityTests { + @Test + func `Derives consistent UUID from public key`() { + let publicKey = Data(repeating: 0xAB, count: 32) - @Test("Derives consistent UUID from public key") - func derivesConsistentUUID() { - let publicKey = Data(repeating: 0xAB, count: 32) - - let uuid1 = DeviceIdentity.deriveUUID(from: publicKey) - let uuid2 = DeviceIdentity.deriveUUID(from: publicKey) + let uuid1 = DeviceIdentity.deriveUUID(from: publicKey) + let uuid2 = DeviceIdentity.deriveUUID(from: publicKey) - #expect(uuid1 == uuid2) - } + #expect(uuid1 == uuid2) + } - @Test("Different public keys produce different UUIDs") - func differentKeysProduceDifferentUUIDs() { - let key1 = Data(repeating: 0xAA, count: 32) - let key2 = Data(repeating: 0xBB, count: 32) + @Test + func `Different public keys produce different UUIDs`() { + let key1 = Data(repeating: 0xAA, count: 32) + let key2 = Data(repeating: 0xBB, count: 32) - let uuid1 = DeviceIdentity.deriveUUID(from: key1) - let uuid2 = DeviceIdentity.deriveUUID(from: key2) + let uuid1 = DeviceIdentity.deriveUUID(from: key1) + let uuid2 = DeviceIdentity.deriveUUID(from: key2) - #expect(uuid1 != uuid2) - } + #expect(uuid1 != uuid2) + } - @Test("UUID derivation uses SHA256") - func usesSecureHash() { - let publicKey = Data([0x01, 0x02, 0x03, 0x04] + Array(repeating: UInt8(0), count: 28)) + @Test + func `UUID derivation uses SHA256`() { + let publicKey = Data([0x01, 0x02, 0x03, 0x04] + Array(repeating: UInt8(0), count: 28)) - let uuid = DeviceIdentity.deriveUUID(from: publicKey) + let uuid = DeviceIdentity.deriveUUID(from: publicKey) - // Manually compute expected hash - let hash = SHA256.hash(data: publicKey) - let hashBytes = Array(hash) - let expectedUUID = UUID(uuid: ( - hashBytes[0], hashBytes[1], hashBytes[2], hashBytes[3], - hashBytes[4], hashBytes[5], hashBytes[6], hashBytes[7], - hashBytes[8], hashBytes[9], hashBytes[10], hashBytes[11], - hashBytes[12], hashBytes[13], hashBytes[14], hashBytes[15] - )) + // Manually compute expected hash + let hash = SHA256.hash(data: publicKey) + let hashBytes = Array(hash) + let expectedUUID = UUID(uuid: ( + hashBytes[0], hashBytes[1], hashBytes[2], hashBytes[3], + hashBytes[4], hashBytes[5], hashBytes[6], hashBytes[7], + hashBytes[8], hashBytes[9], hashBytes[10], hashBytes[11], + hashBytes[12], hashBytes[13], hashBytes[14], hashBytes[15] + )) - #expect(uuid == expectedUUID) - } + #expect(uuid == expectedUUID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Utilities/HashtagUtilitiesTests.swift b/MC1Services/Tests/MC1ServicesTests/Utilities/HashtagUtilitiesTests.swift index b6bfc50f..e6684646 100644 --- a/MC1Services/Tests/MC1ServicesTests/Utilities/HashtagUtilitiesTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Utilities/HashtagUtilitiesTests.swift @@ -1,116 +1,113 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("HashtagUtilities Tests") struct HashtagUtilitiesTests { - - // MARK: - Regex Pattern Tests - - @Test("hashtag pattern matches valid hashtags", arguments: ["#general", "#General", "#test-channel", "#abc123", "#a"]) - func testPatternMatchesValid(text: String) { - // swiftlint:disable:next force_try - let regex = try! NSRegularExpression(pattern: HashtagUtilities.hashtagPattern) - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) - #expect(matches.count == 1, "Expected match for: \(text)") - } - - @Test("hashtag pattern rejects invalid hashtags", arguments: ["#test_underscore", "#test.dot", "#", "#-bad", "#bad!", "#white space"]) - func testPatternRejectsInvalid(text: String) { - // Use anchored pattern for full-string validation (extraction pattern finds partial matches) - let anchoredPattern = "^" + HashtagUtilities.hashtagPattern + "$" - // swiftlint:disable:next force_try - let regex = try! NSRegularExpression(pattern: anchoredPattern) - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) - #expect(matches.isEmpty, "Expected no match for: \(text)") - } - - // MARK: - extractHashtags Tests - - @Test("extractHashtags finds single hashtag") - func testExtractSingle() { - let result = HashtagUtilities.extractHashtags(from: "Join #general today") - #expect(result.count == 1) - #expect(result.first?.name == "#general") - } - - @Test("extractHashtags accepts uppercase hashtags") - func testExtractUppercase() { - let result = HashtagUtilities.extractHashtags(from: "Join #General today") - #expect(result.count == 1) - #expect(result.first?.name == "#General") - } - - @Test("extractHashtags finds multiple hashtags") - func testExtractMultiple() { - let result = HashtagUtilities.extractHashtags(from: "Try #one and #two") - #expect(result.count == 2) - #expect(result[0].name == "#one") - #expect(result[1].name == "#two") - } - - @Test("extractHashtags returns empty for no hashtags") - func testExtractNone() { - let result = HashtagUtilities.extractHashtags(from: "No hashtags here") - #expect(result.isEmpty) - } - - @Test("extractHashtags excludes hashtags inside URLs") - func testExtractExcludesURLs() { - let result = HashtagUtilities.extractHashtags(from: "See https://example.com#section and #general") - #expect(result.count == 1) - #expect(result.first?.name == "#general") - } - - @Test("extractHashtags handles hashtag at end with punctuation") - func testExtractWithPunctuation() { - let result = HashtagUtilities.extractHashtags(from: "Join #general.") - #expect(result.count == 1) - #expect(result.first?.name == "#general") - } - - @Test("extractHashtags handles adjacent hashtags") - func testExtractAdjacent() { - let result = HashtagUtilities.extractHashtags(from: "#one#two") - #expect(result.count == 2) - } - - // MARK: - isValidHashtagName Tests - - @Test("isValidHashtagName accepts valid names") - func testIsValidAccepts() { - #expect(HashtagUtilities.isValidHashtagName("general")) - #expect(HashtagUtilities.isValidHashtagName("General")) - #expect(HashtagUtilities.isValidHashtagName("TEST")) - #expect(HashtagUtilities.isValidHashtagName("test-channel")) - #expect(HashtagUtilities.isValidHashtagName("abc123")) - #expect(HashtagUtilities.isValidHashtagName("a")) - } - - @Test("isValidHashtagName rejects invalid names") - func testIsValidRejects() { - #expect(!HashtagUtilities.isValidHashtagName("")) - #expect(!HashtagUtilities.isValidHashtagName("-bad")) - #expect(!HashtagUtilities.isValidHashtagName("test_underscore")) - #expect(!HashtagUtilities.isValidHashtagName("test.dot")) - #expect(!HashtagUtilities.isValidHashtagName("bad!")) - } - - // MARK: - normalizeHashtagName Tests - - @Test("normalizeHashtagName lowercases and strips prefix") - func testNormalize() { - #expect(HashtagUtilities.normalizeHashtagName("#General") == "general") - #expect(HashtagUtilities.normalizeHashtagName("#TEST") == "test") - #expect(HashtagUtilities.normalizeHashtagName("general") == "general") - } - - @Test("sanitizeHashtagNameInput lowercases and strips invalid characters") - func testSanitizeInput() { - #expect(HashtagUtilities.sanitizeHashtagNameInput("General") == "general") - #expect(HashtagUtilities.sanitizeHashtagNameInput("-General") == "general") - #expect(HashtagUtilities.sanitizeHashtagNameInput("gen_eral") == "general") - } + // MARK: - Regex Pattern Tests + + @Test(arguments: ["#general", "#General", "#test-channel", "#abc123", "#a"]) + func `hashtag pattern matches valid hashtags`(text: String) throws { + let regex = try NSRegularExpression(pattern: HashtagUtilities.hashtagPattern) + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + #expect(matches.count == 1, "Expected match for: \(text)") + } + + @Test(arguments: ["#test_underscore", "#test.dot", "#", "#-bad", "#bad!", "#white space"]) + func `hashtag pattern rejects invalid hashtags`(text: String) throws { + // Use anchored pattern for full-string validation (extraction pattern finds partial matches) + let anchoredPattern = "^" + HashtagUtilities.hashtagPattern + "$" + let regex = try NSRegularExpression(pattern: anchoredPattern) + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + #expect(matches.isEmpty, "Expected no match for: \(text)") + } + + // MARK: - extractHashtags Tests + + @Test + func `extractHashtags finds single hashtag`() { + let result = HashtagUtilities.extractHashtags(from: "Join #general today") + #expect(result.count == 1) + #expect(result.first?.name == "#general") + } + + @Test + func `extractHashtags accepts uppercase hashtags`() { + let result = HashtagUtilities.extractHashtags(from: "Join #General today") + #expect(result.count == 1) + #expect(result.first?.name == "#General") + } + + @Test + func `extractHashtags finds multiple hashtags`() { + let result = HashtagUtilities.extractHashtags(from: "Try #one and #two") + #expect(result.count == 2) + #expect(result[0].name == "#one") + #expect(result[1].name == "#two") + } + + @Test + func `extractHashtags returns empty for no hashtags`() { + let result = HashtagUtilities.extractHashtags(from: "No hashtags here") + #expect(result.isEmpty) + } + + @Test + func `extractHashtags excludes hashtags inside URLs`() { + let result = HashtagUtilities.extractHashtags(from: "See https://example.com#section and #general") + #expect(result.count == 1) + #expect(result.first?.name == "#general") + } + + @Test + func `extractHashtags handles hashtag at end with punctuation`() { + let result = HashtagUtilities.extractHashtags(from: "Join #general.") + #expect(result.count == 1) + #expect(result.first?.name == "#general") + } + + @Test + func `extractHashtags handles adjacent hashtags`() { + let result = HashtagUtilities.extractHashtags(from: "#one#two") + #expect(result.count == 2) + } + + // MARK: - isValidHashtagName Tests + + @Test + func `isValidHashtagName accepts valid names`() { + #expect(HashtagUtilities.isValidHashtagName("general")) + #expect(HashtagUtilities.isValidHashtagName("General")) + #expect(HashtagUtilities.isValidHashtagName("TEST")) + #expect(HashtagUtilities.isValidHashtagName("test-channel")) + #expect(HashtagUtilities.isValidHashtagName("abc123")) + #expect(HashtagUtilities.isValidHashtagName("a")) + } + + @Test + func `isValidHashtagName rejects invalid names`() { + #expect(!HashtagUtilities.isValidHashtagName("")) + #expect(!HashtagUtilities.isValidHashtagName("-bad")) + #expect(!HashtagUtilities.isValidHashtagName("test_underscore")) + #expect(!HashtagUtilities.isValidHashtagName("test.dot")) + #expect(!HashtagUtilities.isValidHashtagName("bad!")) + } + + // MARK: - normalizeHashtagName Tests + + @Test + func `normalizeHashtagName lowercases and strips prefix`() { + #expect(HashtagUtilities.normalizeHashtagName("#General") == "general") + #expect(HashtagUtilities.normalizeHashtagName("#TEST") == "test") + #expect(HashtagUtilities.normalizeHashtagName("general") == "general") + } + + @Test + func `sanitizeHashtagNameInput lowercases and strips invalid characters`() { + #expect(HashtagUtilities.sanitizeHashtagNameInput("General") == "general") + #expect(HashtagUtilities.sanitizeHashtagNameInput("-General") == "general") + #expect(HashtagUtilities.sanitizeHashtagNameInput("gen_eral") == "general") + } } diff --git a/MC1Tests/AppColorSchemePreferenceTests.swift b/MC1Tests/AppColorSchemePreferenceTests.swift index 1f545f0c..ef730c91 100644 --- a/MC1Tests/AppColorSchemePreferenceTests.swift +++ b/MC1Tests/AppColorSchemePreferenceTests.swift @@ -1,27 +1,26 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing @Suite("AppColorSchemePreference") struct AppColorSchemePreferenceTests { + @Test + func `raw values are pinned to the on-disk format`() { + #expect(AppColorSchemePreference.system.rawValue == "system") + #expect(AppColorSchemePreference.light.rawValue == "light") + #expect(AppColorSchemePreference.dark.rawValue == "dark") + } - @Test("raw values are pinned to the on-disk format") - func rawValuesPinned() { - #expect(AppColorSchemePreference.system.rawValue == "system") - #expect(AppColorSchemePreference.light.rawValue == "light") - #expect(AppColorSchemePreference.dark.rawValue == "dark") - } - - @Test("colorScheme maps system to nil and light/dark to their schemes") - func colorSchemeMapping() { - #expect(AppColorSchemePreference.system.colorScheme == nil) - #expect(AppColorSchemePreference.light.colorScheme == .light) - #expect(AppColorSchemePreference.dark.colorScheme == .dark) - } + @Test + func `colorScheme maps system to nil and light/dark to their schemes`() { + #expect(AppColorSchemePreference.system.colorScheme == nil) + #expect(AppColorSchemePreference.light.colorScheme == .light) + #expect(AppColorSchemePreference.dark.colorScheme == .dark) + } - @Test("allCases is exactly system, light, dark and id equals rawValue") - func allCasesAndID() { - #expect(AppColorSchemePreference.allCases == [.system, .light, .dark]) - #expect(AppColorSchemePreference.dark.id == "dark") - } + @Test + func `allCases is exactly system, light, dark and id equals rawValue`() { + #expect(AppColorSchemePreference.allCases == [.system, .light, .dark]) + #expect(AppColorSchemePreference.dark.id == "dark") + } } diff --git a/MC1Tests/AppState/AppStateEnvironmentDefaultTests.swift b/MC1Tests/AppState/AppStateEnvironmentDefaultTests.swift new file mode 100644 index 00000000..7daa9b2f --- /dev/null +++ b/MC1Tests/AppState/AppStateEnvironmentDefaultTests.swift @@ -0,0 +1,46 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import SwiftData +import SwiftUI +import Testing + +/// Must stay `@MainActor`: reading `EnvironmentValues().appState` takes the first-touch path +/// through `AppState.placeholder`'s `MainActor.assumeIsolated`, which traps off the main actor. +@Suite("AppState environment default", .serialized) +@MainActor +struct AppStateEnvironmentDefaultTests { + private static func inMemoryContainer() throws -> ModelContainer { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + return try ModelContainer(for: PersistenceStore.schema, configurations: [config]) + } + + @Test + func `environment default is a single shared placeholder`() { + let first = EnvironmentValues().appState + let second = EnvironmentValues().appState + #expect(first === second) + #expect(first === AppState.placeholder) + } + + @Test + func `placeholder is inert with no services or live transaction listener`() { + let placeholder = AppState.placeholder + #expect(placeholder.services == nil) + #expect(placeholder.storeState.service.transactionListenerTask == nil) + } + + @Test + func `placeholder init leaves the shared debug log buffer untouched`() throws { + let previous = DebugLogBuffer.shared + defer { DebugLogBuffer.shared = previous } + + DebugLogBuffer.shared = nil + _ = try AppState(modelContainer: Self.inMemoryContainer(), isPlaceholder: true) + #expect(DebugLogBuffer.shared == nil) + + let live = try AppState(modelContainer: Self.inMemoryContainer(), isPlaceholder: false) + #expect(DebugLogBuffer.shared != nil) + live.shutdown() + } +} diff --git a/MC1Tests/AppState/AppStateRegionTests.swift b/MC1Tests/AppState/AppStateRegionTests.swift index 99d5bb5f..5ef55787 100644 --- a/MC1Tests/AppState/AppStateRegionTests.swift +++ b/MC1Tests/AppState/AppStateRegionTests.swift @@ -1,44 +1,43 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("AppState region preference", .serialized) @MainActor struct AppStateRegionTests { + private static let regionKey = BackupUserDefaults.regionSelectionKey - private static let regionKey = BackupUserDefaults.regionSelectionKey - - @Test("regionSelection persists to UserDefaults on set") - func persistsOnSet() throws { - UserDefaults.standard.removeObject(forKey: Self.regionKey) - let appState = AppState() - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "los angeles", source: .location) - appState.regionSelection = region + @Test + func `regionSelection persists to UserDefaults on set`() throws { + UserDefaults.standard.removeObject(forKey: Self.regionKey) + let appState = AppState() + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "los angeles", source: .location) + appState.regionSelection = region - let data = try #require(UserDefaults.standard.data(forKey: Self.regionKey)) - let decoded = try JSONDecoder().decode(RegionSelection.self, from: data) - #expect(decoded == region) - } + let data = try #require(UserDefaults.standard.data(forKey: Self.regionKey)) + let decoded = try JSONDecoder().decode(RegionSelection.self, from: data) + #expect(decoded == region) + } - @Test("regionSelection clears UserDefaults on nil") - func clearsOnNil() throws { - UserDefaults.standard.removeObject(forKey: Self.regionKey) - let appState = AppState() - appState.regionSelection = RegionSelection(countryCode: "US", source: .manual) - appState.regionSelection = nil - #expect(UserDefaults.standard.data(forKey: Self.regionKey) == nil) - } + @Test + func `regionSelection clears UserDefaults on nil`() { + UserDefaults.standard.removeObject(forKey: Self.regionKey) + let appState = AppState() + appState.regionSelection = RegionSelection(countryCode: "US", source: .manual) + appState.regionSelection = nil + #expect(UserDefaults.standard.data(forKey: Self.regionKey) == nil) + } - @Test("AppState loads persisted regionSelection on init") - func loadsOnInit() throws { - defer { UserDefaults.standard.removeObject(forKey: Self.regionKey) } - let region = RegionSelection(countryCode: "PT", source: .manual) - let data = try JSONEncoder().encode(region) - UserDefaults.standard.set(data, forKey: Self.regionKey) + @Test + func `AppState loads persisted regionSelection on init`() throws { + defer { UserDefaults.standard.removeObject(forKey: Self.regionKey) } + let region = RegionSelection(countryCode: "PT", source: .manual) + let data = try JSONEncoder().encode(region) + UserDefaults.standard.set(data, forKey: Self.regionKey) - let appState = AppState() - #expect(appState.regionSelection == region) - } + let appState = AppState() + #expect(appState.regionSelection == region) + } } diff --git a/MC1Tests/AppState/AuthenticationFailureGatingTests.swift b/MC1Tests/AppState/AuthenticationFailureGatingTests.swift new file mode 100644 index 00000000..8bd2602d --- /dev/null +++ b/MC1Tests/AppState/AuthenticationFailureGatingTests.swift @@ -0,0 +1,46 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +/// The "Couldn't Pair" alert must present only while the app is active. A bond +/// failure observed on a backgrounded auto-reconnect must not latch a stale +/// alert that then appears on the next foreground even after a good reconnect. +@Suite("Authentication Failure Gating") +@MainActor +struct AuthenticationFailureGatingTests { + @Test + func `an active app presents the pairing-failure alert`() { + let appState = AppState() + + appState.handleAuthenticationFailure(deviceID: UUID(), isAppActive: true) + + #expect(appState.connectionUI.showingConnectionFailedAlert == true) + #expect(appState.connectionUI.pairingFailureKind == .authentication) + } + + @Test + func `an inactive app suppresses the pairing-failure alert`() { + let appState = AppState() + + appState.handleAuthenticationFailure(deviceID: UUID(), isAppActive: false) + + #expect(appState.connectionUI.showingConnectionFailedAlert == false) + #expect(appState.connectionUI.pairingFailureKind == nil) + } + + @Test + func `clearing pairing failure resets every alert field`() { + let sut = ConnectionUIState() + sut.presentPairingFailure(.connectionFailed(deviceID: UUID(), underlying: BLEError.authenticationFailed)) + #expect(sut.showingConnectionFailedAlert == true) + + sut.clearPairingFailure() + + #expect(sut.showingConnectionFailedAlert == false) + #expect(sut.connectionFailedTitle == nil) + #expect(sut.connectionFailedMessage == nil) + #expect(sut.pairingFailureKind == nil) + #expect(sut.failedPairingDeviceID == nil) + } +} diff --git a/MC1Tests/AppState/BatteryMonitoringTests.swift b/MC1Tests/AppState/BatteryMonitoringTests.swift index 1699377c..dbab53ea 100644 --- a/MC1Tests/AppState/BatteryMonitoringTests.swift +++ b/MC1Tests/AppState/BatteryMonitoringTests.swift @@ -1,68 +1,67 @@ -import Testing import Foundation -import MeshCore -@testable import MC1Services @testable import MC1 +@testable import MC1Services +import MeshCore +import Testing @Suite("Battery Monitoring Tests") @MainActor struct BatteryMonitoringTests { + // MARK: - Default State - // MARK: - Default State - - @Test("deviceBattery is nil by default") - func deviceBatteryDefault() { - let appState = AppState() - #expect(appState.batteryMonitor.deviceBattery == nil) - } + @Test + func `deviceBattery is nil by default`() { + let appState = AppState() + #expect(appState.batteryMonitor.deviceBattery == nil) + } - @Test("activeBatteryOCVArray returns liIon default when no device connected") - func activeBatteryOCVArrayDefault() { - let appState = AppState() - #expect(appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) == OCVPreset.liIon.ocvArray) - } + @Test + func `activeBatteryOCVArray returns liIon default when no device connected`() { + let appState = AppState() + #expect(appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) == OCVPreset.liIon.ocvArray) + } - // MARK: - fetchDeviceBattery + // MARK: - fetchDeviceBattery - @Test("fetchDeviceBattery is no-op when services is nil") - func fetchDeviceBatteryNoServices() async { - let appState = AppState() + @Test + func `fetchDeviceBattery is no-op when services is nil`() async { + let appState = AppState() - await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) - #expect(appState.batteryMonitor.deviceBattery == nil) - } + #expect(appState.batteryMonitor.deviceBattery == nil) + } - @Test("fetchDeviceBattery does not crash when called on fresh state") - func fetchDeviceBatterySafe() async { - let appState = AppState() - #expect(appState.services == nil) + @Test + func `fetchDeviceBattery does not crash when called on fresh state`() async { + let appState = AppState() + #expect(appState.services == nil) - // Should not throw or crash - await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) - #expect(appState.batteryMonitor.deviceBattery == nil) - } + // Should not throw or crash + await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + #expect(appState.batteryMonitor.deviceBattery == nil) + } - // MARK: - Battery State Observation + // MARK: - Battery State Observation - @Test("deviceBattery can be set directly for testing") - func deviceBatterySettable() { - let appState = AppState() - let battery = BatteryInfo(level: 3700) + @Test + func `deviceBattery can be set directly for testing`() { + let appState = AppState() + let battery = BatteryInfo(level: 3700) - appState.batteryMonitor.deviceBattery = battery + appState.batteryMonitor.deviceBattery = battery - #expect(appState.batteryMonitor.deviceBattery == battery) - #expect(appState.batteryMonitor.deviceBattery?.level == 3700) - } + #expect(appState.batteryMonitor.deviceBattery == battery) + #expect(appState.batteryMonitor.deviceBattery?.level == 3700) + } - @Test("deviceBattery can be cleared") - func deviceBatteryClearable() { - let appState = AppState() - appState.batteryMonitor.deviceBattery = BatteryInfo(level: 3700) + @Test + func `deviceBattery can be cleared`() { + let appState = AppState() + appState.batteryMonitor.deviceBattery = BatteryInfo(level: 3700) - appState.batteryMonitor.deviceBattery = nil + appState.batteryMonitor.deviceBattery = nil - #expect(appState.batteryMonitor.deviceBattery == nil) - } + #expect(appState.batteryMonitor.deviceBattery == nil) + } } diff --git a/MC1Tests/AppState/ConnectionUIStateTests.swift b/MC1Tests/AppState/ConnectionUIStateTests.swift index b1d31d5b..6b69ae4e 100644 --- a/MC1Tests/AppState/ConnectionUIStateTests.swift +++ b/MC1Tests/AppState/ConnectionUIStateTests.swift @@ -1,379 +1,378 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing @Suite("Connection UI State Tests") @MainActor struct ConnectionUIStateTests { + // MARK: - statusPillState Priority - // MARK: - statusPillState Priority - - @Test("statusPillState is hidden by default") - func hiddenByDefault() { - let appState = AppState() - #expect(appState.statusPillState == .hidden) - } + @Test + func `statusPillState is hidden by default`() { + let appState = AppState() + #expect(appState.statusPillState == .hidden) + } - @Test("Failed state takes priority over syncing") - func failedOverSyncing() { - let appState = AppState() - appState.connectionUI.simulateSyncStarted() - appState.connectionUI.showSyncFailedPill() + @Test + func `Failed state takes priority over syncing`() { + let appState = AppState() + appState.connectionUI.simulateSyncStarted() + appState.connectionUI.showSyncFailedPill() - #expect(appState.statusPillState == .failed(message: "Sync Failed")) - } + #expect(appState.statusPillState == .failed(message: "Sync Failed")) + } - @Test("Syncing takes priority over ready toast") - func syncingOverReady() { - let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - appState.connectionUI.simulateSyncStarted() + @Test + func `Syncing takes priority over ready toast`() { + let appState = AppState() + appState.connectionUI.showReadyToastBriefly() + appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) - } + #expect(appState.statusPillState == .syncing) + } - @Test("Ready toast takes priority over disconnected") - func readyOverDisconnected() { - let appState = AppState() - appState.connectionUI.showReadyToastBriefly() + @Test + func `Ready toast takes priority over disconnected`() { + let appState = AppState() + appState.connectionUI.showReadyToastBriefly() - // Even if disconnectedPillVisible were true, ready should win - #expect(appState.statusPillState == .ready) - } + // Even if disconnectedPillVisible were true, ready should win + #expect(appState.statusPillState == .ready) + } - @Test("Multiple sync activities keep syncing state until all end") - func multipleSyncActivities() { - let appState = AppState() + @Test + func `Multiple sync activities keep syncing state until all end`() { + let appState = AppState() - appState.connectionUI.simulateSyncStarted() - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) + appState.connectionUI.simulateSyncStarted() + appState.connectionUI.simulateSyncStarted() + #expect(appState.statusPillState == .syncing) - appState.connectionUI.simulateSyncEnded() - #expect(appState.statusPillState == .syncing) + appState.connectionUI.simulateSyncEnded() + #expect(appState.statusPillState == .syncing) - appState.connectionUI.simulateSyncEnded() - // After all sync activity ends, should not be syncing - #expect(appState.statusPillState != .syncing) - } + appState.connectionUI.simulateSyncEnded() + // After all sync activity ends, should not be syncing + #expect(appState.statusPillState != .syncing) + } - // MARK: - Ready Toast + // MARK: - Ready Toast - @Test("showReadyToastBriefly sets showReadyToast to true") - func showReadyToast() { - let appState = AppState() + @Test + func `showReadyToastBriefly sets showReadyToast to true`() { + let appState = AppState() - appState.connectionUI.showReadyToastBriefly() + appState.connectionUI.showReadyToastBriefly() - #expect(appState.connectionUI.showReadyToast == true) - #expect(appState.statusPillState == .ready) - } + #expect(appState.connectionUI.showReadyToast == true) + #expect(appState.statusPillState == .ready) + } - @Test("hideReadyToast immediately clears toast") - func hideReadyToast() { - let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - #expect(appState.connectionUI.showReadyToast == true) + @Test + func `hideReadyToast immediately clears toast`() { + let appState = AppState() + appState.connectionUI.showReadyToastBriefly() + #expect(appState.connectionUI.showReadyToast == true) - appState.connectionUI.hideReadyToast() + appState.connectionUI.hideReadyToast() - #expect(appState.connectionUI.showReadyToast == false) - #expect(appState.statusPillState == .hidden) - } + #expect(appState.connectionUI.showReadyToast == false) + #expect(appState.statusPillState == .hidden) + } - @Test("showReadyToastBriefly auto-hides after delay") - func readyToastAutoHides() async throws { - let appState = AppState() + @Test + func `showReadyToastBriefly auto-hides after delay`() async throws { + let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - #expect(appState.connectionUI.showReadyToast == true) + appState.connectionUI.showReadyToastBriefly() + #expect(appState.connectionUI.showReadyToast == true) - // Wait for the 2-second auto-hide plus margin - try await Task.sleep(for: .seconds(2.3)) + // Wait for the 2-second auto-hide plus margin + try await Task.sleep(for: .seconds(2.3)) - #expect(appState.connectionUI.showReadyToast == false) - } + #expect(appState.connectionUI.showReadyToast == false) + } - @Test("Calling showReadyToastBriefly again resets the timer") - func readyToastTimerReset() async throws { - let appState = AppState() + @Test + func `Calling showReadyToastBriefly again resets the timer`() async throws { + let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - try await Task.sleep(for: .seconds(1.5)) + appState.connectionUI.showReadyToastBriefly() + try await Task.sleep(for: .seconds(1.5)) - // Call again to reset - appState.connectionUI.showReadyToastBriefly() - #expect(appState.connectionUI.showReadyToast == true) + // Call again to reset + appState.connectionUI.showReadyToastBriefly() + #expect(appState.connectionUI.showReadyToast == true) - // Wait past original timer but within new timer - try await Task.sleep(for: .seconds(1.0)) - #expect(appState.connectionUI.showReadyToast == true) - } + // Wait past original timer but within new timer + try await Task.sleep(for: .seconds(1.0)) + #expect(appState.connectionUI.showReadyToast == true) + } - // MARK: - Sync Failed Pill + // MARK: - Sync Failed Pill - @Test("showSyncFailedPill sets visible flag") - func showSyncFailedPill() { - let appState = AppState() + @Test + func `showSyncFailedPill sets visible flag`() { + let appState = AppState() - appState.connectionUI.showSyncFailedPill() + appState.connectionUI.showSyncFailedPill() - #expect(appState.connectionUI.syncFailedPillVisible == true) - #expect(appState.statusPillState == .failed(message: "Sync Failed")) - } + #expect(appState.connectionUI.syncFailedPillVisible == true) + #expect(appState.statusPillState == .failed(message: "Sync Failed")) + } - @Test("hideSyncFailedPill immediately clears pill") - func hideSyncFailedPill() { - let appState = AppState() - appState.connectionUI.showSyncFailedPill() + @Test + func `hideSyncFailedPill immediately clears pill`() { + let appState = AppState() + appState.connectionUI.showSyncFailedPill() - appState.connectionUI.hideSyncFailedPill() + appState.connectionUI.hideSyncFailedPill() - #expect(appState.connectionUI.syncFailedPillVisible == false) - } + #expect(appState.connectionUI.syncFailedPillVisible == false) + } - @Test("showSyncFailedPill auto-hides after delay") - func syncFailedPillAutoHides() async throws { - let appState = AppState() + @Test + func `showSyncFailedPill auto-hides after delay`() async throws { + let appState = AppState() - appState.connectionUI.showSyncFailedPill() - #expect(appState.connectionUI.syncFailedPillVisible == true) + appState.connectionUI.showSyncFailedPill() + #expect(appState.connectionUI.syncFailedPillVisible == true) - // Wait for the 7-second auto-hide plus margin - try await Task.sleep(for: .seconds(7.3)) + // Wait for the 7-second auto-hide plus margin + try await Task.sleep(for: .seconds(7.3)) - #expect(appState.connectionUI.syncFailedPillVisible == false) - } + #expect(appState.connectionUI.syncFailedPillVisible == false) + } - // MARK: - Disconnected Pill + // MARK: - Disconnected Pill - @Test("disconnectedPillVisible is false by default") - func disconnectedPillDefault() { - let appState = AppState() - #expect(appState.connectionUI.disconnectedPillVisible == false) - } + @Test + func `disconnectedPillVisible is false by default`() { + let appState = AppState() + #expect(appState.connectionUI.disconnectedPillVisible == false) + } - @Test("hideDisconnectedPill clears pill immediately") - func hideDisconnectedPill() { - let appState = AppState() + @Test + func `hideDisconnectedPill clears pill immediately`() { + let appState = AppState() - appState.connectionUI.hideDisconnectedPill() + appState.connectionUI.hideDisconnectedPill() - #expect(appState.connectionUI.disconnectedPillVisible == false) - } + #expect(appState.connectionUI.disconnectedPillVisible == false) + } - @Test("updateDisconnectedPillState without paired device stays hidden") - func disconnectedPillNoPairedDevice() async throws { - let appState = AppState() + @Test + func `updateDisconnectedPillState without paired device stays hidden`() async throws { + let appState = AppState() - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) - try await Task.sleep(for: .seconds(1.3)) - #expect(appState.connectionUI.disconnectedPillVisible == false) - } + try await Task.sleep(for: .seconds(1.3)) + #expect(appState.connectionUI.disconnectedPillVisible == false) + } - // MARK: - canRunSettingsStartupReads + // MARK: - canRunSettingsStartupReads - @Test("canRunSettingsStartupReads is false when disconnected") - func cannotRunSettingsWhenDisconnected() { - let appState = AppState() - #expect(appState.canRunSettingsStartupReads == false) - } + @Test + func `canRunSettingsStartupReads is false when disconnected`() { + let appState = AppState() + #expect(appState.canRunSettingsStartupReads == false) + } - // MARK: - Sync Activity Tracking + // MARK: - Sync Activity Tracking - @Test("Sync activity shows syncing pill while active") - func syncActivityShowsSyncing() { - let appState = AppState() - - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) - - appState.connectionUI.simulateSyncEnded() - #expect(appState.statusPillState != .syncing) - } - - // MARK: - UI State Defaults - - @Test("Connection alert state defaults") - func connectionAlertDefaults() { - let appState = AppState() - #expect(appState.connectionUI.showingConnectionFailedAlert == false) - #expect(appState.connectionUI.connectionFailedMessage == nil) - #expect(appState.connectionUI.failedPairingDeviceID == nil) - #expect(appState.connectionUI.pairingFailureKind == nil) - #expect(appState.connectionUI.otherAppWarningDeviceID == nil) - #expect(appState.connectionUI.isBusy == false) - #expect(appState.connectionUI.isNodeStorageFull == false) - } - - // MARK: - presentPairingFailure / presentConnectionFailure - - @Test("presentPairingFailure(auth) sets pairingFailureKind to .authentication") - func presentPairingFailureSetsAuthKind() { - let sut = ConnectionUIState() - let deviceID = UUID() - sut.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.authenticationFailed)) - - #expect(sut.failedPairingDeviceID == deviceID) - #expect(sut.pairingFailureKind == .authentication) - #expect(sut.connectionFailedTitle != nil) - #expect(sut.showingConnectionFailedAlert == true) - } - - @Test("presentPairingFailure(transient) sets pairingFailureKind to .transient") - func presentPairingFailureSetsTransientKind() { - let sut = ConnectionUIState() - let deviceID = UUID() - sut.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.connectionFailed("timeout"))) - - #expect(sut.failedPairingDeviceID == deviceID) - #expect(sut.pairingFailureKind == .transient) - #expect(sut.connectionFailedTitle == nil) - #expect(sut.showingConnectionFailedAlert == true) - } - - @Test("presentConnectionFailure clears pairingFailureKind set by a prior pairing failure") - func presentConnectionFailureClearsPairingKind() { - let sut = ConnectionUIState() - sut.presentPairingFailure(.connectionFailed(deviceID: UUID(), underlying: BLEError.authenticationFailed)) - #expect(sut.pairingFailureKind == .authentication) - - sut.presentConnectionFailure(message: "generic") - - #expect(sut.pairingFailureKind == nil) - #expect(sut.connectionFailedTitle == nil) - } - - // MARK: - handleDisconnect - - @Test("handleDisconnect resets syncActivityCount to zero") - func handleDisconnectResetsSyncActivityCount() { - let sut = ConnectionUIState() - sut.simulateSyncStarted() - sut.simulateSyncStarted() - #expect(sut.syncActivityCount == 2) - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.syncActivityCount == 0) - } - - @Test("handleDisconnect clears currentSyncPhase") - func handleDisconnectClearsSyncPhase() { - let sut = ConnectionUIState() - sut.currentSyncPhase = .contacts - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.currentSyncPhase == nil) - } - - @Test("handleDisconnect sets isNodeStorageFull to false") - func handleDisconnectClearsNodeStorageFull() { - let sut = ConnectionUIState() - sut.isNodeStorageFull = true - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.isNodeStorageFull == false) - } - - @Test("handleDisconnect hides ready toast") - func handleDisconnectHidesReadyToast() { - let sut = ConnectionUIState() - sut.showReadyToastBriefly() - #expect(sut.showReadyToast == true) - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.showReadyToast == false) - } - - @Test("handleDisconnect shows disconnected pill when device was paired") - func handleDisconnectShowsDisconnectedPill() async throws { - let sut = ConnectionUIState() - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: false - ) - - // Disconnected pill shows after 1s delay - try await Task.sleep(for: .seconds(1.3)) - #expect(sut.disconnectedPillVisible == true) - } - - @Test("handleDisconnect does not show disconnected pill when suppressed") - func handleDisconnectSuppressesDisconnectedPill() async throws { - let sut = ConnectionUIState() - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: true - ) - - try await Task.sleep(for: .seconds(1.3)) - #expect(sut.disconnectedPillVisible == false) - } - - @Test("handleDisconnect does not show disconnected pill without paired device") - func handleDisconnectNoPairedDevice() async throws { - let sut = ConnectionUIState() - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - try await Task.sleep(for: .seconds(1.3)) - #expect(sut.disconnectedPillVisible == false) - } - - @Test("handleDisconnect resets all state in a single call") - func handleDisconnectResetsAllState() { - let sut = ConnectionUIState() - - // Set up various dirty state - sut.simulateSyncStarted() - sut.simulateSyncStarted() - sut.currentSyncPhase = .channels - sut.isNodeStorageFull = true - sut.showReadyToastBriefly() - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.syncActivityCount == 0) - #expect(sut.currentSyncPhase == nil) - #expect(sut.isNodeStorageFull == false) - #expect(sut.showReadyToast == false) - } + @Test + func `Sync activity shows syncing pill while active`() { + let appState = AppState() + + appState.connectionUI.simulateSyncStarted() + #expect(appState.statusPillState == .syncing) + + appState.connectionUI.simulateSyncEnded() + #expect(appState.statusPillState != .syncing) + } + + // MARK: - UI State Defaults + + @Test + func `Connection alert state defaults`() { + let appState = AppState() + #expect(appState.connectionUI.showingConnectionFailedAlert == false) + #expect(appState.connectionUI.connectionFailedMessage == nil) + #expect(appState.connectionUI.failedPairingDeviceID == nil) + #expect(appState.connectionUI.pairingFailureKind == nil) + #expect(appState.connectionUI.otherAppWarningDeviceID == nil) + #expect(appState.connectionUI.isBusy == false) + #expect(appState.connectionUI.isNodeStorageFull == false) + } + + // MARK: - presentPairingFailure / presentConnectionFailure + + @Test + func `presentPairingFailure(auth) sets pairingFailureKind to .authentication`() { + let sut = ConnectionUIState() + let deviceID = UUID() + sut.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.authenticationFailed)) + + #expect(sut.failedPairingDeviceID == deviceID) + #expect(sut.pairingFailureKind == .authentication) + #expect(sut.connectionFailedTitle != nil) + #expect(sut.showingConnectionFailedAlert == true) + } + + @Test + func `presentPairingFailure(transient) sets pairingFailureKind to .transient`() { + let sut = ConnectionUIState() + let deviceID = UUID() + sut.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.connectionFailed("timeout"))) + + #expect(sut.failedPairingDeviceID == deviceID) + #expect(sut.pairingFailureKind == .transient) + #expect(sut.connectionFailedTitle == nil) + #expect(sut.showingConnectionFailedAlert == true) + } + + @Test + func `presentConnectionFailure clears pairingFailureKind set by a prior pairing failure`() { + let sut = ConnectionUIState() + sut.presentPairingFailure(.connectionFailed(deviceID: UUID(), underlying: BLEError.authenticationFailed)) + #expect(sut.pairingFailureKind == .authentication) + + sut.presentConnectionFailure(message: "generic") + + #expect(sut.pairingFailureKind == nil) + #expect(sut.connectionFailedTitle == nil) + } + + // MARK: - handleDisconnect + + @Test + func `handleDisconnect resets syncActivityCount to zero`() { + let sut = ConnectionUIState() + sut.simulateSyncStarted() + sut.simulateSyncStarted() + #expect(sut.syncActivityCount == 2) + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.syncActivityCount == 0) + } + + @Test + func `handleDisconnect clears currentSyncPhase`() { + let sut = ConnectionUIState() + sut.currentSyncPhase = .contacts + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.currentSyncPhase == nil) + } + + @Test + func `handleDisconnect sets isNodeStorageFull to false`() { + let sut = ConnectionUIState() + sut.isNodeStorageFull = true + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.isNodeStorageFull == false) + } + + @Test + func `handleDisconnect hides ready toast`() { + let sut = ConnectionUIState() + sut.showReadyToastBriefly() + #expect(sut.showReadyToast == true) + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.showReadyToast == false) + } + + @Test + func `handleDisconnect shows disconnected pill when device was paired`() async throws { + let sut = ConnectionUIState() + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: false + ) + + // Disconnected pill shows after 1s delay + try await Task.sleep(for: .seconds(1.3)) + #expect(sut.disconnectedPillVisible == true) + } + + @Test + func `handleDisconnect does not show disconnected pill when suppressed`() async throws { + let sut = ConnectionUIState() + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: true + ) + + try await Task.sleep(for: .seconds(1.3)) + #expect(sut.disconnectedPillVisible == false) + } + + @Test + func `handleDisconnect does not show disconnected pill without paired device`() async throws { + let sut = ConnectionUIState() + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + try await Task.sleep(for: .seconds(1.3)) + #expect(sut.disconnectedPillVisible == false) + } + + @Test + func `handleDisconnect resets all state in a single call`() { + let sut = ConnectionUIState() + + // Set up various dirty state + sut.simulateSyncStarted() + sut.simulateSyncStarted() + sut.currentSyncPhase = .channels + sut.isNodeStorageFull = true + sut.showReadyToastBriefly() + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.syncActivityCount == 0) + #expect(sut.currentSyncPhase == nil) + #expect(sut.isNodeStorageFull == false) + #expect(sut.showReadyToast == false) + } } diff --git a/MC1Tests/AppState/DisconnectedPillTests.swift b/MC1Tests/AppState/DisconnectedPillTests.swift index 5cae077f..8e4009f7 100644 --- a/MC1Tests/AppState/DisconnectedPillTests.swift +++ b/MC1Tests/AppState/DisconnectedPillTests.swift @@ -1,125 +1,124 @@ -import Testing import Foundation -import SwiftData @testable import MC1 @testable import MC1Services +import SwiftData +import Testing @Suite("Disconnected Pill Tests") @MainActor struct DisconnectedPillTests { - - // MARK: - shouldSuppressDisconnectedPill Integration Tests - - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - private func makeTestManager() throws -> ConnectionManager { - let schema = Schema([ - Device.self, - Contact.self, - Message.self, - Channel.self, - RemoteNodeSession.self, - RoomMessage.self - ]) - let config = ModelConfiguration(isStoredInMemoryOnly: true) - let container = try ModelContainer(for: schema, configurations: [config]) - return ConnectionManager(modelContainer: container, defaults: defaults) - } - - @Test("shouldSuppressDisconnectedPill returns true when user explicitly disconnected") - func testShouldSuppressWhenUserDisconnected() throws { - ConnectionIntent.userDisconnected.persist(to: defaults) - - let manager = try makeTestManager() - #expect(manager.shouldSuppressDisconnectedPill == true) - } - - @Test("shouldSuppressDisconnectedPill returns false when user did not explicitly disconnect") - func testShouldNotSuppressWhenNoExplicitDisconnect() throws { - let manager = try makeTestManager() - #expect(manager.shouldSuppressDisconnectedPill == false) - } - - // MARK: - updateDisconnectedPillState Tests (pass values directly) - - @Test("disconnected pill not shown when user explicitly disconnected") - func testPillNotShownAfterExplicitDisconnect() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: true - ) - - try await Task.sleep(for: .seconds(1.2)) - #expect(appState.connectionUI.disconnectedPillVisible == false) - } - - @Test("disconnected pill shown after unexpected disconnect") - func testPillShownAfterUnexpectedDisconnect() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: false - ) - - try await Task.sleep(for: .seconds(1.2)) - #expect(appState.connectionUI.disconnectedPillVisible == true) - } - - @Test("disconnected pill not shown when no last connected device") - func testPillNotShownWhenNoLastDevice() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - try await Task.sleep(for: .seconds(1.2)) - #expect(appState.connectionUI.disconnectedPillVisible == false) - } - - @Test("disconnected pill hidden when connection starts") - func testPillHiddenWhenConnecting() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: false - ) - try await Task.sleep(for: .seconds(1.2)) - #expect(appState.connectionUI.disconnectedPillVisible == true) - - appState.connectionUI.hideDisconnectedPill() - #expect(appState.connectionUI.disconnectedPillVisible == false) - } - - @Test("disconnected pill delay prevents flash during brief reconnects") - func testPillDelayPreventsFlash() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: false - ) - - #expect(appState.connectionUI.disconnectedPillVisible == false) - - try await Task.sleep(for: .seconds(0.5)) - appState.connectionUI.hideDisconnectedPill() - - try await Task.sleep(for: .seconds(1.0)) - #expect(appState.connectionUI.disconnectedPillVisible == false) - } + // MARK: - shouldSuppressDisconnectedPill Integration Tests + + private let defaults: UserDefaults + + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + private func makeTestManager() throws -> ConnectionManager { + let schema = Schema([ + Device.self, + Contact.self, + Message.self, + Channel.self, + RemoteNodeSession.self, + RoomMessage.self + ]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [config]) + return ConnectionManager(modelContainer: container, defaults: defaults) + } + + @Test + func `shouldSuppressDisconnectedPill returns true when user explicitly disconnected`() throws { + ConnectionIntent.userDisconnected.persist(to: defaults) + + let manager = try makeTestManager() + #expect(manager.shouldSuppressDisconnectedPill == true) + } + + @Test + func `shouldSuppressDisconnectedPill returns false when user did not explicitly disconnect`() throws { + let manager = try makeTestManager() + #expect(manager.shouldSuppressDisconnectedPill == false) + } + + // MARK: - updateDisconnectedPillState Tests (pass values directly) + + @Test + func `disconnected pill not shown when user explicitly disconnected`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: true + ) + + try await Task.sleep(for: .seconds(1.2)) + #expect(appState.connectionUI.disconnectedPillVisible == false) + } + + @Test + func `disconnected pill shown after unexpected disconnect`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: false + ) + + try await Task.sleep(for: .seconds(1.2)) + #expect(appState.connectionUI.disconnectedPillVisible == true) + } + + @Test + func `disconnected pill not shown when no last connected device`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + try await Task.sleep(for: .seconds(1.2)) + #expect(appState.connectionUI.disconnectedPillVisible == false) + } + + @Test + func `disconnected pill hidden when connection starts`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: false + ) + try await Task.sleep(for: .seconds(1.2)) + #expect(appState.connectionUI.disconnectedPillVisible == true) + + appState.connectionUI.hideDisconnectedPill() + #expect(appState.connectionUI.disconnectedPillVisible == false) + } + + @Test + func `disconnected pill delay prevents flash during brief reconnects`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: false + ) + + #expect(appState.connectionUI.disconnectedPillVisible == false) + + try await Task.sleep(for: .seconds(0.5)) + appState.connectionUI.hideDisconnectedPill() + + try await Task.sleep(for: .seconds(1.0)) + #expect(appState.connectionUI.disconnectedPillVisible == false) + } } diff --git a/MC1Tests/AppState/FreshPairingFailureRoutingTests.swift b/MC1Tests/AppState/FreshPairingFailureRoutingTests.swift new file mode 100644 index 00000000..c84e57d5 --- /dev/null +++ b/MC1Tests/AppState/FreshPairingFailureRoutingTests.swift @@ -0,0 +1,65 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +/// Covers the routing a fresh BLE pairing attempt takes when it throws. A +/// rejected PIN during first-time pairing must surface copy naming the PIN and +/// warning about the system removal prompt, distinct from an established radio's +/// dead-bond recovery. +@Suite("Fresh Pairing Failure Routing") +@MainActor +struct FreshPairingFailureRoutingTests { + @Test + func `rejected PIN surfaces the PIN-rejected recovery`() { + let sut = ConnectionUIState() + let deviceID = UUID() + + sut.presentFreshPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.authenticationFailed)) + + #expect(sut.pairingFailureKind == .pinRejected) + #expect(sut.failedPairingDeviceID == deviceID) + #expect(sut.connectionFailedTitle == L10n.Localizable.Alert.PairingFailed.title) + #expect(sut.connectionFailedMessage == L10n.Onboarding.DeviceScan.Error.pinRejected) + #expect(sut.showingConnectionFailedAlert == true) + #expect(sut.otherAppWarningDeviceID == nil) + } + + @Test + func `saved-device authentication failure keeps the dead-bond recovery`() { + let sut = ConnectionUIState() + let deviceID = UUID() + + sut.presentSavedDeviceConnectFailure(deviceID: deviceID, error: BLEError.authenticationFailed) + + #expect(sut.pairingFailureKind == .authentication) + #expect(sut.connectionFailedMessage == L10n.Onboarding.DeviceScan.Error.authenticationFailed) + #expect(sut.connectionFailedTitle == L10n.Localizable.Alert.PairingFailed.title) + } + + @Test + func `transient fresh-pair failure keeps the non-destructive retry`() { + let sut = ConnectionUIState() + let deviceID = UUID() + + sut.presentFreshPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.connectionTimeout)) + + #expect(sut.pairingFailureKind == .transient) + #expect(sut.failedPairingDeviceID == deviceID) + #expect(sut.connectionFailedTitle == nil) + #expect(sut.connectionFailedMessage == L10n.Onboarding.DeviceScan.Error.connectionFailed) + #expect(sut.showingConnectionFailedAlert == true) + } + + @Test + func `fresh-pair other-app failure routes to the other-app warning`() { + let sut = ConnectionUIState() + let deviceID = UUID() + + sut.presentFreshPairingFailure(.deviceConnectedToOtherApp(deviceID: deviceID)) + + #expect(sut.otherAppWarningDeviceID == deviceID) + #expect(sut.pairingFailureKind == nil) + #expect(sut.showingConnectionFailedAlert == false) + } +} diff --git a/MC1Tests/AppState/LifecycleTransitionTests.swift b/MC1Tests/AppState/LifecycleTransitionTests.swift index 58cd38c6..e4881f44 100644 --- a/MC1Tests/AppState/LifecycleTransitionTests.swift +++ b/MC1Tests/AppState/LifecycleTransitionTests.swift @@ -1,89 +1,88 @@ -import Testing @testable import MC1 +import Testing @Suite("AppState Lifecycle Transition Tests") @MainActor struct LifecycleTransitionTests { + @Test + func `BLE foreground waits for queued background transition`() async { + let appState = AppState() + let recorder = TransitionRecorder() - @Test("BLE foreground waits for queued background transition") - func foregroundWaitsForBackgroundTransition() async { - let appState = AppState() - let recorder = TransitionRecorder() + appState.setBLELifecycleOverridesForTesting( + enterBackground: { + await recorder.record("background-start") + try? await Task.sleep(for: .milliseconds(150)) + await recorder.record("background-end") + }, + becomeActive: { + await recorder.record("foreground-start") + await recorder.record("foreground-end") + } + ) - appState.setBLELifecycleOverridesForTesting( - enterBackground: { - await recorder.record("background-start") - try? await Task.sleep(for: .milliseconds(150)) - await recorder.record("background-end") - }, - becomeActive: { - await recorder.record("foreground-start") - await recorder.record("foreground-end") - } - ) + appState.handleEnterBackground() + await appState.handleReturnToForeground() - appState.handleEnterBackground() - await appState.handleReturnToForeground() + let events = await recorder.events + #expect(events == [ + "background-start", + "background-end", + "foreground-start", + "foreground-end" + ]) + } - let events = await recorder.events - #expect(events == [ - "background-start", - "background-end", - "foreground-start", - "foreground-end" - ]) + @Test + func `Explicit disconnect runs the per-session teardown the loss path performs`() async { + let appState = AppState() + appState.settingsEventsTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(60)) + } } + appState.navigation.nodesShowingDiscovery = true - @Test("Explicit disconnect runs the per-session teardown the loss path performs") - func explicitDisconnectTearsDownSessionState() async { - let appState = AppState() - appState.settingsEventsTask = Task { - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(60)) - } - } - appState.navigation.nodesShowingDiscovery = true - - await appState.disconnect() + await appState.disconnect() - #expect(appState.settingsEventsTask == nil) - #expect(appState.navigation.nodesShowingDiscovery == false) - } + #expect(appState.settingsEventsTask == nil) + #expect(appState.navigation.nodesShowingDiscovery == false) + } - @Test("rapid background-active bounces keep BLE transitions ordered") - func rapidBouncesKeepTransitionsOrdered() async { - let appState = AppState() - let recorder = TransitionRecorder() + @Test + func `rapid background-active bounces keep BLE transitions ordered`() async { + let appState = AppState() + let recorder = TransitionRecorder() - appState.setBLELifecycleOverridesForTesting( - enterBackground: { - try? await Task.sleep(for: .milliseconds(20)) - await recorder.record("background") - }, - becomeActive: { - await recorder.record("foreground") - } - ) + appState.setBLELifecycleOverridesForTesting( + enterBackground: { + try? await Task.sleep(for: .milliseconds(20)) + await recorder.record("background") + }, + becomeActive: { + await recorder.record("foreground") + } + ) - for _ in 0..<5 { - appState.handleEnterBackground() - await appState.handleReturnToForeground() - } + for _ in 0..<5 { + appState.handleEnterBackground() + await appState.handleReturnToForeground() + } - let events = await recorder.events - #expect(events.count == 10) + let events = await recorder.events + #expect(events.count == 10) - for index in stride(from: 0, to: events.count, by: 2) { - #expect(events[index] == "background") - #expect(events[index + 1] == "foreground") - } + for index in stride(from: 0, to: events.count, by: 2) { + #expect(events[index] == "background") + #expect(events[index + 1] == "foreground") } + } } private actor TransitionRecorder { - private(set) var events: [String] = [] + private(set) var events: [String] = [] - func record(_ event: String) { - events.append(event) - } + func record(_ event: String) { + events.append(event) + } } diff --git a/MC1Tests/AppState/NavigationCoordinatorTests.swift b/MC1Tests/AppState/NavigationCoordinatorTests.swift index 6d7b5b65..06cc45d8 100644 --- a/MC1Tests/AppState/NavigationCoordinatorTests.swift +++ b/MC1Tests/AppState/NavigationCoordinatorTests.swift @@ -1,483 +1,480 @@ -import Testing -import Foundation import CoreLocation -@testable import MC1Services +import Foundation @testable import MC1 +@testable import MC1Services +import Testing @Suite("Navigation Coordinator Notification Handler Tests") @MainActor struct NavigationCoordinatorNotificationTests { - - // MARK: - Test Helpers - - private static func makeContact( - id: UUID = UUID(), - radioID: UUID = UUID(), - name: String = "TestContact" - ) -> ContactDTO { - ContactDTO( - id: id, - radioID: radioID, - publicKey: Data(repeating: 0xAA, count: 32), - name: name, - typeRawValue: 0x01, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - private static func makeChannel( - id: UUID = UUID(), - radioID: UUID = UUID(), - name: String = "TestChannel", - index: UInt8 = 0 - ) -> ChannelDTO { - ChannelDTO( - id: id, - radioID: radioID, - index: index, - name: name, - secret: Data(), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false - ) - } - - private static func makeDeviceDTO(manualAddContacts: Bool = false) -> DeviceDTO { - DeviceDTO( - id: UUID(), - publicKey: Data(repeating: 0xBB, count: 32), - nodeName: "TestNode", - firmwareVersion: 1, - firmwareVersionString: "1.12.0", - manufacturerName: "Test", - buildDate: "2025-01-01", - maxContacts: 100, - maxChannels: 8, - frequency: 915_000, - bandwidth: 250_000, - spreadingFactor: 10, - codingRate: 5, - txPower: 20, - maxTxPower: 20, - latitude: 0, - longitude: 0, - blePin: 0, - manualAddContacts: manualAddContacts, - multiAcks: 2, - telemetryModeBase: 2, - telemetryModeLoc: 0, - telemetryModeEnv: 0, - advertLocationPolicy: 0, - lastConnected: Date(), - lastContactSync: 0, - isActive: true, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - /// Creates an in-memory data store seeded with a contact and channel. - private static func makeSeededDataStore( - contact: ContactDTO, - channel: ChannelDTO - ) async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - try await dataStore.saveContact(contact) - try await dataStore.saveChannel(channel) - return dataStore - } - - // MARK: - DM Notification Tap - - @Test("DM notification tap navigates to chat with contact") - func dmNotificationTapNavigatesToChat() async throws { - let contact = Self.makeContact() - let dataStore = try await Self.makeSeededDataStore( - contact: contact, - channel: Self.makeChannel() - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { nil } - ) - - // Invoke the handler directly - await notificationService.onNotificationTapped?(contact.id) - - #expect(coordinator.pendingChatContact?.id == contact.id) - #expect(coordinator.chatsSelectedRoute == .direct(contact)) - #expect(coordinator.selectedTab == 0) - } - - // MARK: - New Contact Notification Tap - - @Test("New contact notification with manualAddContacts navigates to discovery") - func newContactManualAddNavigatesToDiscovery() async throws { - let contact = Self.makeContact() - let dataStore = try await Self.makeSeededDataStore( - contact: contact, - channel: Self.makeChannel() - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - let device = Self.makeDeviceDTO(manualAddContacts: true) - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { device } - ) - - await notificationService.onNewContactNotificationTapped?(contact.id) - - #expect(coordinator.pendingDiscoveryNavigation == true) - #expect(coordinator.selectedTab == 1) - } - - @Test("New contact notification without manualAddContacts navigates to contact detail") - func newContactAutoAddNavigatesToContactDetail() async throws { - let contact = Self.makeContact() - let dataStore = try await Self.makeSeededDataStore( - contact: contact, - channel: Self.makeChannel() - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - let device = Self.makeDeviceDTO(manualAddContacts: false) - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { device } - ) - - await notificationService.onNewContactNotificationTapped?(contact.id) - - #expect(coordinator.pendingContactDetail?.id == contact.id) - #expect(coordinator.selectedTab == 1) - } - - // MARK: - Channel Notification Tap - - @Test("Channel notification tap navigates to channel") - func channelNotificationTapNavigatesToChannel() async throws { - let radioID = UUID() - let channelIndex: UInt8 = 3 - let channel = Self.makeChannel(radioID: radioID, index: channelIndex) - let dataStore = try await Self.makeSeededDataStore( - contact: Self.makeContact(), - channel: channel - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { nil } - ) - - await notificationService.onChannelNotificationTapped?(radioID, channelIndex) - - #expect(coordinator.pendingChannel?.id == channel.id) - #expect(coordinator.chatsSelectedRoute == .channel(channel)) - #expect(coordinator.selectedTab == 0) - } - - // MARK: - Reaction Notification Tap - - @Test("Reaction notification on DM navigates to chat with scrollToMessageID") - func reactionOnDMNavigatesToChatWithScroll() async throws { - let contact = Self.makeContact() - let messageID = UUID() - let dataStore = try await Self.makeSeededDataStore( - contact: contact, - channel: Self.makeChannel() - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { nil } - ) - - await notificationService.onReactionNotificationTapped?(contact.id, nil, nil, messageID) - - #expect(coordinator.pendingChatContact?.id == contact.id) - #expect(coordinator.pendingScrollToMessageID == messageID) - #expect(coordinator.selectedTab == 0) - } - - @Test("Reaction notification on channel navigates to channel with scrollToMessageID") - func reactionOnChannelNavigatesToChannelWithScroll() async throws { - let radioID = UUID() - let channelIndex: UInt8 = 1 - let channel = Self.makeChannel(radioID: radioID, index: channelIndex) - let messageID = UUID() - let dataStore = try await Self.makeSeededDataStore( - contact: Self.makeContact(), - channel: channel - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { nil } - ) - - // contactID is nil → falls through to channel branch - await notificationService.onReactionNotificationTapped?(nil, channelIndex, radioID, messageID) - - #expect(coordinator.pendingChannel?.id == channel.id) - #expect(coordinator.pendingScrollToMessageID == messageID) - #expect(coordinator.selectedTab == 0) - } + // MARK: - Test Helpers + + private static func makeContact( + id: UUID = UUID(), + radioID: UUID = UUID(), + name: String = "TestContact" + ) -> ContactDTO { + ContactDTO( + id: id, + radioID: radioID, + publicKey: Data(repeating: 0xAA, count: 32), + name: name, + typeRawValue: 0x01, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + private static func makeChannel( + id: UUID = UUID(), + radioID: UUID = UUID(), + name: String = "TestChannel", + index: UInt8 = 0 + ) -> ChannelDTO { + ChannelDTO( + id: id, + radioID: radioID, + index: index, + name: name, + secret: Data(), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + } + + private static func makeDeviceDTO(manualAddContacts: Bool = false) -> DeviceDTO { + DeviceDTO( + id: UUID(), + publicKey: Data(repeating: 0xBB, count: 32), + nodeName: "TestNode", + firmwareVersion: 1, + firmwareVersionString: "1.12.0", + manufacturerName: "Test", + buildDate: "2025-01-01", + maxContacts: 100, + maxChannels: 8, + frequency: 915_000, + bandwidth: 250_000, + spreadingFactor: 10, + codingRate: 5, + txPower: 20, + maxTxPower: 20, + latitude: 0, + longitude: 0, + blePin: 0, + manualAddContacts: manualAddContacts, + multiAcks: 2, + telemetryModeBase: 2, + telemetryModeLoc: 0, + telemetryModeEnv: 0, + advertLocationPolicy: 0, + lastConnected: Date(), + lastContactSync: 0, + isActive: true, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + /// Creates an in-memory data store seeded with a contact and channel. + private static func makeSeededDataStore( + contact: ContactDTO, + channel: ChannelDTO + ) async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + try await dataStore.saveContact(contact) + try await dataStore.saveChannel(channel) + return dataStore + } + + // MARK: - DM Notification Tap + + @Test + func `DM notification tap navigates to chat with contact`() async throws { + let contact = Self.makeContact() + let dataStore = try await Self.makeSeededDataStore( + contact: contact, + channel: Self.makeChannel() + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { nil } + ) + + // Invoke the handler directly + await notificationService.onNotificationTapped?(contact.id) + + #expect(coordinator.pendingChatContact?.id == contact.id) + #expect(coordinator.chatsSelectedRoute == .direct(contact)) + #expect(coordinator.selectedTab == 0) + } + + // MARK: - New Contact Notification Tap + + @Test + func `New contact notification with manualAddContacts navigates to discovery`() async throws { + let contact = Self.makeContact() + let dataStore = try await Self.makeSeededDataStore( + contact: contact, + channel: Self.makeChannel() + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + let device = Self.makeDeviceDTO(manualAddContacts: true) + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { device } + ) + + await notificationService.onNewContactNotificationTapped?(contact.id) + + #expect(coordinator.pendingDiscoveryNavigation == true) + #expect(coordinator.selectedTab == 1) + } + + @Test + func `New contact notification without manualAddContacts navigates to contact detail`() async throws { + let contact = Self.makeContact() + let dataStore = try await Self.makeSeededDataStore( + contact: contact, + channel: Self.makeChannel() + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + let device = Self.makeDeviceDTO(manualAddContacts: false) + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { device } + ) + + await notificationService.onNewContactNotificationTapped?(contact.id) + + #expect(coordinator.pendingContactDetail?.id == contact.id) + #expect(coordinator.selectedTab == 1) + } + + // MARK: - Channel Notification Tap + + @Test + func `Channel notification tap navigates to channel`() async throws { + let radioID = UUID() + let channelIndex: UInt8 = 3 + let channel = Self.makeChannel(radioID: radioID, index: channelIndex) + let dataStore = try await Self.makeSeededDataStore( + contact: Self.makeContact(), + channel: channel + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { nil } + ) + + await notificationService.onChannelNotificationTapped?(radioID, channelIndex) + + #expect(coordinator.pendingChannel?.id == channel.id) + #expect(coordinator.chatsSelectedRoute == .channel(channel)) + #expect(coordinator.selectedTab == 0) + } + + // MARK: - Reaction Notification Tap + + @Test + func `Reaction notification on DM navigates to chat with scrollToMessageID`() async throws { + let contact = Self.makeContact() + let messageID = UUID() + let dataStore = try await Self.makeSeededDataStore( + contact: contact, + channel: Self.makeChannel() + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { nil } + ) + + await notificationService.onReactionNotificationTapped?(contact.id, nil, nil, messageID) + + #expect(coordinator.pendingChatContact?.id == contact.id) + #expect(coordinator.pendingScrollToMessageID == messageID) + #expect(coordinator.selectedTab == 0) + } + + @Test + func `Reaction notification on channel navigates to channel with scrollToMessageID`() async throws { + let radioID = UUID() + let channelIndex: UInt8 = 1 + let channel = Self.makeChannel(radioID: radioID, index: channelIndex) + let messageID = UUID() + let dataStore = try await Self.makeSeededDataStore( + contact: Self.makeContact(), + channel: channel + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { nil } + ) + + // contactID is nil → falls through to channel branch + await notificationService.onReactionNotificationTapped?(nil, channelIndex, radioID, messageID) + + #expect(coordinator.pendingChannel?.id == channel.id) + #expect(coordinator.pendingScrollToMessageID == messageID) + #expect(coordinator.selectedTab == 0) + } } @Suite("NavigationCoordinator Map Navigation Tests") @MainActor struct NavigationCoordinatorMapTests { - - @Test("navigateToMap sets pendingMapFocus and selects the map tab") - func navigateToMapSetsFocusAndTab() { - let coordinator = NavigationCoordinator() - let coordinate = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.00902) - - coordinator.navigateToMap(coordinate: coordinate) - - #expect(coordinator.pendingMapFocus?.latitude == 37.3349) - #expect(coordinator.pendingMapFocus?.longitude == -122.00902) - #expect(coordinator.pendingMapFocus?.coordinate.latitude == 37.3349) - #expect(coordinator.selectedTab == AppTab.map.rawValue) - } - - @Test("ChatViewModel.navigateToMap forwards the coordinate to the navigation sink") - func chatViewModelForwardsToNavigationSink() { - let coordinator = NavigationCoordinator() - let viewModel = ChatViewModel() - viewModel.onNavigateToMap = { coordinator.navigateToMap(coordinate: $0) } - let coordinate = CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278) - - // The thumbnail tap path ends at ChatViewModel.navigateToMap, which forwards - // to the same navigation sink ChatsView.handleMeshCoreLink uses for the text link. - viewModel.navigateToMap(coordinate) - - #expect(coordinator.pendingMapFocus?.latitude == 51.5074) - #expect(coordinator.pendingMapFocus?.longitude == -0.1278) - #expect(coordinator.selectedTab == AppTab.map.rawValue) - } - - @Test("clearPendingMapFocus resets the pending focus") - func clearPendingMapFocusResets() { - let coordinator = NavigationCoordinator() - coordinator.navigateToMap(coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2)) - - coordinator.clearPendingMapFocus() - - #expect(coordinator.pendingMapFocus == nil) - } + @Test + func `navigateToMap sets pendingMapFocus and selects the map tab`() { + let coordinator = NavigationCoordinator() + let coordinate = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.00902) + + coordinator.navigateToMap(coordinate: coordinate) + + #expect(coordinator.pendingMapFocus?.latitude == 37.3349) + #expect(coordinator.pendingMapFocus?.longitude == -122.00902) + #expect(coordinator.pendingMapFocus?.coordinate.latitude == 37.3349) + #expect(coordinator.selectedTab == AppTab.map.rawValue) + } + + @Test + func `ChatViewModel.navigateToMap forwards the coordinate to the navigation sink`() { + let coordinator = NavigationCoordinator() + let viewModel = ChatViewModel() + viewModel.onNavigateToMap = { coordinator.navigateToMap(coordinate: $0) } + let coordinate = CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278) + + // The thumbnail tap path ends at ChatViewModel.navigateToMap, which forwards + // to the same navigation sink ChatsView.handleMeshCoreLink uses for the text link. + viewModel.navigateToMap(coordinate) + + #expect(coordinator.pendingMapFocus?.latitude == 51.5074) + #expect(coordinator.pendingMapFocus?.longitude == -0.1278) + #expect(coordinator.selectedTab == AppTab.map.rawValue) + } + + @Test + func `clearPendingMapFocus resets the pending focus`() { + let coordinator = NavigationCoordinator() + coordinator.navigateToMap(coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2)) + + coordinator.clearPendingMapFocus() + + #expect(coordinator.pendingMapFocus == nil) + } } @Suite("NavigationCoordinator Pending Link Tests") @MainActor struct NavigationCoordinatorPendingLinkTests { - - @Test("pendingContactLink starts nil and clears via helper") - func pendingContactLinkClears() { - let coordinator = NavigationCoordinator() - #expect(coordinator.pendingContactLink == nil) - coordinator.pendingContactLink = MeshCoreURLParser.ContactResult( - name: "Alice", - publicKey: Data(repeating: 0xAB, count: 32), - contactType: .chat - ) - #expect(coordinator.pendingContactLink != nil) - coordinator.clearPendingContactLink() - #expect(coordinator.pendingContactLink == nil) - } - - @Test("pendingChannelLink starts nil and clears via helper") - func pendingChannelLinkClears() { - let coordinator = NavigationCoordinator() - #expect(coordinator.pendingChannelLink == nil) - coordinator.pendingChannelLink = MeshCoreURLParser.ChannelResult( - name: "general", - secret: Data(repeating: 0xCC, count: 16) - ) - #expect(coordinator.pendingChannelLink != nil) - coordinator.clearPendingChannelLink() - #expect(coordinator.pendingChannelLink == nil) - } - - @Test("pendingHashtag starts nil and clears via helper") - func pendingHashtagClears() { - let coordinator = NavigationCoordinator() - #expect(coordinator.pendingHashtag == nil) - coordinator.pendingHashtag = HashtagJoinRequest(id: "#general") - #expect(coordinator.pendingHashtag != nil) - coordinator.clearPendingHashtag() - #expect(coordinator.pendingHashtag == nil) - } - - // MARK: - clearPendingLinks (per-radio teardown) - - private static func makeContact(name: String = "TestContact") -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0xAA, count: 32), - name: name, - typeRawValue: 0x01, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - @Test("clearPendingLinks clears the hoisted Nodes selected contact") - func clearPendingLinksClearsSelectedContact() { - let coordinator = NavigationCoordinator() - coordinator.selectedContact = Self.makeContact() - #expect(coordinator.selectedContact != nil) - - coordinator.clearPendingLinks() - - #expect(coordinator.selectedContact == nil) - } - - @Test("clearPendingLinks clears the hoisted Nodes discovery flag") - func clearPendingLinksClearsNodesShowingDiscovery() { - let coordinator = NavigationCoordinator() - coordinator.nodesShowingDiscovery = true - - coordinator.clearPendingLinks() - - #expect(coordinator.nodesShowingDiscovery == false) - } - - @Test("clearPendingLinks clears every staged per-radio field at once") - func clearPendingLinksClearsAllPendingFields() { - let coordinator = NavigationCoordinator() - coordinator.pendingContactLink = MeshCoreURLParser.ContactResult( - name: "Alice", - publicKey: Data(repeating: 0xAB, count: 32), - contactType: .chat - ) - coordinator.pendingChannelLink = MeshCoreURLParser.ChannelResult( - name: "general", - secret: Data(repeating: 0xCC, count: 16) - ) - coordinator.pendingHashtag = HashtagJoinRequest(id: "#general") - coordinator.selectedContact = Self.makeContact() - coordinator.nodesShowingDiscovery = true - coordinator.chatsSelectedRoute = .direct(Self.makeContact()) - coordinator.selectedTool = .cli - - coordinator.clearPendingLinks() - - #expect(coordinator.pendingContactLink == nil) - #expect(coordinator.pendingChannelLink == nil) - #expect(coordinator.pendingHashtag == nil) - #expect(coordinator.selectedContact == nil) - #expect(coordinator.nodesShowingDiscovery == false) - #expect(coordinator.chatsSelectedRoute == nil) - #expect(coordinator.selectedTool == nil) - } - - // MARK: - clearPerRadioSelection - - @Test("clearPerRadioSelection clears the hoisted Chats route") - func clearPerRadioSelectionClearsChatsRoute() { - let coordinator = NavigationCoordinator() - coordinator.chatsSelectedRoute = .direct(Self.makeContact()) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.chatsSelectedRoute == nil) - } - - @Test("clearPerRadioSelection clears a radio-requiring tool") - func clearPerRadioSelectionClearsRadioRequiringTool() { - let coordinator = NavigationCoordinator() - coordinator.selectedTool = .cli - #expect(coordinator.selectedTool?.requiresRadio == true) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.selectedTool == nil) - } - - @Test("clearPerRadioSelection preserves the offline Line of Sight tool") - func clearPerRadioSelectionPreservesOfflineTool() { - let coordinator = NavigationCoordinator() - coordinator.selectedTool = .lineOfSight - #expect(coordinator.selectedTool?.requiresRadio == false) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.selectedTool == .lineOfSight) - } - - @Test("clearPerRadioSelection clears a per-device settings page") - func clearPerRadioSelectionClearsDeviceSetting() { - let coordinator = NavigationCoordinator() - coordinator.selectedSetting = .radio - #expect(coordinator.selectedSetting?.requiresDevice == true) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.selectedSetting == nil) - } - - @Test("clearPerRadioSelection preserves a device-independent settings page") - func clearPerRadioSelectionPreservesAppSetting() { - let coordinator = NavigationCoordinator() - coordinator.selectedSetting = .appearance - #expect(coordinator.selectedSetting?.requiresDevice == false) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.selectedSetting == .appearance) - } + @Test + func `pendingContactLink starts nil and clears via helper`() { + let coordinator = NavigationCoordinator() + #expect(coordinator.pendingContactLink == nil) + coordinator.pendingContactLink = MeshCoreURLParser.ContactResult( + name: "Alice", + publicKey: Data(repeating: 0xAB, count: 32), + contactType: .chat + ) + #expect(coordinator.pendingContactLink != nil) + coordinator.clearPendingContactLink() + #expect(coordinator.pendingContactLink == nil) + } + + @Test + func `pendingChannelLink starts nil and clears via helper`() { + let coordinator = NavigationCoordinator() + #expect(coordinator.pendingChannelLink == nil) + coordinator.pendingChannelLink = MeshCoreURLParser.ChannelResult( + name: "general", + secret: Data(repeating: 0xCC, count: 16) + ) + #expect(coordinator.pendingChannelLink != nil) + coordinator.clearPendingChannelLink() + #expect(coordinator.pendingChannelLink == nil) + } + + @Test + func `pendingHashtag starts nil and clears via helper`() { + let coordinator = NavigationCoordinator() + #expect(coordinator.pendingHashtag == nil) + coordinator.pendingHashtag = HashtagJoinRequest(id: "#general") + #expect(coordinator.pendingHashtag != nil) + coordinator.clearPendingHashtag() + #expect(coordinator.pendingHashtag == nil) + } + + // MARK: - clearPendingLinks (per-radio teardown) + + private static func makeContact(name: String = "TestContact") -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0xAA, count: 32), + name: name, + typeRawValue: 0x01, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + @Test + func `clearPendingLinks clears the hoisted Nodes selected contact`() { + let coordinator = NavigationCoordinator() + coordinator.selectedContact = Self.makeContact() + #expect(coordinator.selectedContact != nil) + + coordinator.clearPendingLinks() + + #expect(coordinator.selectedContact == nil) + } + + @Test + func `clearPendingLinks clears the hoisted Nodes discovery flag`() { + let coordinator = NavigationCoordinator() + coordinator.nodesShowingDiscovery = true + + coordinator.clearPendingLinks() + + #expect(coordinator.nodesShowingDiscovery == false) + } + + @Test + func `clearPendingLinks clears every staged per-radio field at once`() { + let coordinator = NavigationCoordinator() + coordinator.pendingContactLink = MeshCoreURLParser.ContactResult( + name: "Alice", + publicKey: Data(repeating: 0xAB, count: 32), + contactType: .chat + ) + coordinator.pendingChannelLink = MeshCoreURLParser.ChannelResult( + name: "general", + secret: Data(repeating: 0xCC, count: 16) + ) + coordinator.pendingHashtag = HashtagJoinRequest(id: "#general") + coordinator.selectedContact = Self.makeContact() + coordinator.nodesShowingDiscovery = true + coordinator.chatsSelectedRoute = .direct(Self.makeContact()) + coordinator.selectedTool = .cli + + coordinator.clearPendingLinks() + + #expect(coordinator.pendingContactLink == nil) + #expect(coordinator.pendingChannelLink == nil) + #expect(coordinator.pendingHashtag == nil) + #expect(coordinator.selectedContact == nil) + #expect(coordinator.nodesShowingDiscovery == false) + #expect(coordinator.chatsSelectedRoute == nil) + #expect(coordinator.selectedTool == nil) + } + + // MARK: - clearPerRadioSelection + + @Test + func `clearPerRadioSelection clears the hoisted Chats route`() { + let coordinator = NavigationCoordinator() + coordinator.chatsSelectedRoute = .direct(Self.makeContact()) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.chatsSelectedRoute == nil) + } + + @Test + func `clearPerRadioSelection clears a radio-requiring tool`() { + let coordinator = NavigationCoordinator() + coordinator.selectedTool = .cli + #expect(coordinator.selectedTool?.requiresRadio == true) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.selectedTool == nil) + } + + @Test + func `clearPerRadioSelection preserves the offline Line of Sight tool`() { + let coordinator = NavigationCoordinator() + coordinator.selectedTool = .lineOfSight + #expect(coordinator.selectedTool?.requiresRadio == false) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.selectedTool == .lineOfSight) + } + + @Test + func `clearPerRadioSelection clears a per-device settings page`() { + let coordinator = NavigationCoordinator() + coordinator.selectedSetting = .radio + #expect(coordinator.selectedSetting?.requiresDevice == true) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.selectedSetting == nil) + } + + @Test + func `clearPerRadioSelection preserves a device-independent settings page`() { + let coordinator = NavigationCoordinator() + coordinator.selectedSetting = .appearance + #expect(coordinator.selectedSetting?.requiresDevice == false) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.selectedSetting == .appearance) + } } diff --git a/MC1Tests/AppState/NavigationStateTests.swift b/MC1Tests/AppState/NavigationStateTests.swift index e3d4b187..8b73bcfc 100644 --- a/MC1Tests/AppState/NavigationStateTests.swift +++ b/MC1Tests/AppState/NavigationStateTests.swift @@ -1,343 +1,342 @@ -import Testing import Foundation -@testable import MC1Services @testable import MC1 +@testable import MC1Services +import Testing @Suite("Navigation State Tests") @MainActor struct NavigationStateTests { + // MARK: - Test Helpers + + private static func makeContact( + id: UUID = UUID(), + name: String = "TestContact" + ) -> ContactDTO { + ContactDTO( + id: id, + radioID: UUID(), + publicKey: Data(repeating: 0xAA, count: 32), + name: name, + typeRawValue: 0x01, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + private static func makeChannel( + id: UUID = UUID(), + name: String = "TestChannel", + index: UInt8 = 0 + ) -> ChannelDTO { + ChannelDTO( + id: id, + radioID: UUID(), + index: index, + name: name, + secret: Data(), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + } + + private static func makeRoomSession( + id: UUID = UUID(), + name: String = "TestRoom" + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, + radioID: UUID(), + publicKey: Data(repeating: 0xBB, count: 32), + name: name, + role: .roomServer, + latitude: 0, + longitude: 0, + isConnected: false, + permissionLevel: .readWrite, + lastConnectedDate: nil, + lastBatteryMillivolts: nil, + lastUptimeSeconds: nil, + lastNoiseFloor: nil, + unreadCount: 0, + notificationLevel: .all, + isFavorite: false, + lastRxAirtimeSeconds: nil, + neighborCount: 0, + lastSyncTimestamp: 0, + lastMessageDate: nil + ) + } + + // MARK: - Default State + + @Test + func `Default navigation state is tab 0 with no pending navigation`() { + let appState = AppState() + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.pendingChatContact == nil) + #expect(appState.navigation.pendingChannel == nil) + #expect(appState.navigation.pendingRoomSession == nil) + #expect(appState.navigation.pendingRoomAuthentication == nil) + #expect(appState.navigation.pendingDiscoveryNavigation == false) + #expect(appState.navigation.pendingContactDetail == nil) + #expect(appState.navigation.pendingScrollToMessageID == nil) + #expect(appState.navigation.chatsSelectedRoute == nil) + #expect(appState.navigation.tabBarVisibility == .visible) + } + + // MARK: - navigateToChat + + @Test + func `navigateToChat sets contact, route, and tab`() { + let appState = AppState() + let contact = Self.makeContact() + + appState.navigation.navigateToChat(with: contact) + + #expect(appState.navigation.pendingChatContact == contact) + #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.tabBarVisibility == .hidden) + #expect(appState.navigation.pendingScrollToMessageID == nil) + } + + @Test + func `navigateToChat with scrollToMessageID sets message ID`() { + let appState = AppState() + let contact = Self.makeContact() + let messageID = UUID() + + appState.navigation.navigateToChat(with: contact, scrollToMessageID: messageID) + + #expect(appState.navigation.pendingChatContact == contact) + #expect(appState.navigation.pendingScrollToMessageID == messageID) + #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) + #expect(appState.navigation.selectedTab == 0) + } + + @Test + func `navigateToChat switches to Chats tab from another tab`() { + let appState = AppState() + appState.navigation.selectedTab = 3 // Settings tab + let contact = Self.makeContact() + + appState.navigation.navigateToChat(with: contact) + + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.pendingChatContact == contact) + } + + // MARK: - navigateToRoom + + @Test + func `navigateToRoom sets session, route, and tab`() { + let appState = AppState() + let session = Self.makeRoomSession() + + appState.navigation.navigateToRoom(with: session) + + #expect(appState.navigation.pendingRoomSession == session) + #expect(appState.navigation.chatsSelectedRoute == .room(session)) + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.tabBarVisibility == .hidden) + } + + // MARK: - navigateToChannel + + @Test + func `navigateToChannel sets channel, route, and tab`() { + let appState = AppState() + let channel = Self.makeChannel() + + appState.navigation.navigateToChannel(with: channel) + + #expect(appState.navigation.pendingChannel == channel) + #expect(appState.navigation.chatsSelectedRoute == .channel(channel)) + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.tabBarVisibility == .hidden) + #expect(appState.navigation.pendingScrollToMessageID == nil) + } - // MARK: - Test Helpers - - private static func makeContact( - id: UUID = UUID(), - name: String = "TestContact" - ) -> ContactDTO { - ContactDTO( - id: id, - radioID: UUID(), - publicKey: Data(repeating: 0xAA, count: 32), - name: name, - typeRawValue: 0x01, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - private static func makeChannel( - id: UUID = UUID(), - name: String = "TestChannel", - index: UInt8 = 0 - ) -> ChannelDTO { - ChannelDTO( - id: id, - radioID: UUID(), - index: index, - name: name, - secret: Data(), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false - ) - } - - private static func makeRoomSession( - id: UUID = UUID(), - name: String = "TestRoom" - ) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: id, - radioID: UUID(), - publicKey: Data(repeating: 0xBB, count: 32), - name: name, - role: .roomServer, - latitude: 0, - longitude: 0, - isConnected: false, - permissionLevel: .readWrite, - lastConnectedDate: nil, - lastBatteryMillivolts: nil, - lastUptimeSeconds: nil, - lastNoiseFloor: nil, - unreadCount: 0, - notificationLevel: .all, - isFavorite: false, - lastRxAirtimeSeconds: nil, - neighborCount: 0, - lastSyncTimestamp: 0, - lastMessageDate: nil - ) - } - - // MARK: - Default State - - @Test("Default navigation state is tab 0 with no pending navigation") - func defaultState() { - let appState = AppState() - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.pendingChatContact == nil) - #expect(appState.navigation.pendingChannel == nil) - #expect(appState.navigation.pendingRoomSession == nil) - #expect(appState.navigation.pendingRoomAuthentication == nil) - #expect(appState.navigation.pendingDiscoveryNavigation == false) - #expect(appState.navigation.pendingContactDetail == nil) - #expect(appState.navigation.pendingScrollToMessageID == nil) - #expect(appState.navigation.chatsSelectedRoute == nil) - #expect(appState.navigation.tabBarVisibility == .visible) - } - - // MARK: - navigateToChat - - @Test("navigateToChat sets contact, route, and tab") - func navigateToChat() { - let appState = AppState() - let contact = Self.makeContact() - - appState.navigation.navigateToChat(with: contact) - - #expect(appState.navigation.pendingChatContact == contact) - #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.tabBarVisibility == .hidden) - #expect(appState.navigation.pendingScrollToMessageID == nil) - } - - @Test("navigateToChat with scrollToMessageID sets message ID") - func navigateToChatWithScrollTo() { - let appState = AppState() - let contact = Self.makeContact() - let messageID = UUID() - - appState.navigation.navigateToChat(with: contact, scrollToMessageID: messageID) - - #expect(appState.navigation.pendingChatContact == contact) - #expect(appState.navigation.pendingScrollToMessageID == messageID) - #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) - #expect(appState.navigation.selectedTab == 0) - } - - @Test("navigateToChat switches to Chats tab from another tab") - func navigateToChatFromOtherTab() { - let appState = AppState() - appState.navigation.selectedTab = 3 // Settings tab - let contact = Self.makeContact() - - appState.navigation.navigateToChat(with: contact) - - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.pendingChatContact == contact) - } - - // MARK: - navigateToRoom - - @Test("navigateToRoom sets session, route, and tab") - func navigateToRoom() { - let appState = AppState() - let session = Self.makeRoomSession() - - appState.navigation.navigateToRoom(with: session) - - #expect(appState.navigation.pendingRoomSession == session) - #expect(appState.navigation.chatsSelectedRoute == .room(session)) - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.tabBarVisibility == .hidden) - } - - // MARK: - navigateToChannel - - @Test("navigateToChannel sets channel, route, and tab") - func navigateToChannel() { - let appState = AppState() - let channel = Self.makeChannel() - - appState.navigation.navigateToChannel(with: channel) - - #expect(appState.navigation.pendingChannel == channel) - #expect(appState.navigation.chatsSelectedRoute == .channel(channel)) - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.tabBarVisibility == .hidden) - #expect(appState.navigation.pendingScrollToMessageID == nil) - } - - @Test("navigateToChannel with scrollToMessageID sets message ID") - func navigateToChannelWithScrollTo() { - let appState = AppState() - let channel = Self.makeChannel() - let messageID = UUID() + @Test + func `navigateToChannel with scrollToMessageID sets message ID`() { + let appState = AppState() + let channel = Self.makeChannel() + let messageID = UUID() - appState.navigation.navigateToChannel(with: channel, scrollToMessageID: messageID) + appState.navigation.navigateToChannel(with: channel, scrollToMessageID: messageID) - #expect(appState.navigation.pendingChannel == channel) - #expect(appState.navigation.pendingScrollToMessageID == messageID) - } + #expect(appState.navigation.pendingChannel == channel) + #expect(appState.navigation.pendingScrollToMessageID == messageID) + } - // MARK: - navigateToDiscovery + // MARK: - navigateToDiscovery - @Test("navigateToDiscovery sets pending flag and contacts tab") - func navigateToDiscovery() { - let appState = AppState() + @Test + func `navigateToDiscovery sets pending flag and contacts tab`() { + let appState = AppState() - appState.navigation.navigateToDiscovery() + appState.navigation.navigateToDiscovery() - #expect(appState.navigation.pendingDiscoveryNavigation == true) - #expect(appState.navigation.selectedTab == 1) - } + #expect(appState.navigation.pendingDiscoveryNavigation == true) + #expect(appState.navigation.selectedTab == 1) + } - @Test("navigateToDiscovery does not hide tab bar") - func navigateToDiscoveryTabBarVisible() { - let appState = AppState() + @Test + func `navigateToDiscovery does not hide tab bar`() { + let appState = AppState() - appState.navigation.navigateToDiscovery() + appState.navigation.navigateToDiscovery() - #expect(appState.navigation.tabBarVisibility == .visible) - } + #expect(appState.navigation.tabBarVisibility == .visible) + } - // MARK: - navigateToContacts + // MARK: - navigateToContacts - @Test("navigateToContacts switches to contacts tab") - func navigateToContacts() { - let appState = AppState() - appState.navigation.selectedTab = 3 + @Test + func `navigateToContacts switches to contacts tab`() { + let appState = AppState() + appState.navigation.selectedTab = 3 - appState.navigation.navigateToContacts() + appState.navigation.navigateToContacts() - #expect(appState.navigation.selectedTab == 1) - } + #expect(appState.navigation.selectedTab == 1) + } - // MARK: - navigateToContactDetail + // MARK: - navigateToContactDetail - @Test("navigateToContactDetail sets contact and contacts tab") - func navigateToContactDetail() { - let appState = AppState() - let contact = Self.makeContact() + @Test + func `navigateToContactDetail sets contact and contacts tab`() { + let appState = AppState() + let contact = Self.makeContact() - appState.navigation.navigateToContactDetail(contact) + appState.navigation.navigateToContactDetail(contact) - #expect(appState.navigation.pendingContactDetail == contact) - #expect(appState.navigation.selectedTab == 1) - } + #expect(appState.navigation.pendingContactDetail == contact) + #expect(appState.navigation.selectedTab == 1) + } - // MARK: - Clear Methods + // MARK: - Clear Methods - @Test("clearPendingNavigation clears chat contact") - func clearPendingNavigation() { - let appState = AppState() - appState.navigation.pendingChatContact = Self.makeContact() + @Test + func `clearPendingNavigation clears chat contact`() { + let appState = AppState() + appState.navigation.pendingChatContact = Self.makeContact() - appState.navigation.clearPendingNavigation() + appState.navigation.clearPendingNavigation() - #expect(appState.navigation.pendingChatContact == nil) - } + #expect(appState.navigation.pendingChatContact == nil) + } - @Test("clearPendingRoomNavigation clears room session") - func clearPendingRoomNavigation() { - let appState = AppState() - appState.navigation.pendingRoomSession = Self.makeRoomSession() + @Test + func `clearPendingRoomNavigation clears room session`() { + let appState = AppState() + appState.navigation.pendingRoomSession = Self.makeRoomSession() - appState.navigation.clearPendingRoomNavigation() + appState.navigation.clearPendingRoomNavigation() - #expect(appState.navigation.pendingRoomSession == nil) - } + #expect(appState.navigation.pendingRoomSession == nil) + } - @Test("clearPendingRoomAuthentication clears room auth session") - func clearPendingRoomAuthentication() { - let appState = AppState() - appState.navigation.pendingRoomAuthentication = Self.makeRoomSession() + @Test + func `clearPendingRoomAuthentication clears room auth session`() { + let appState = AppState() + appState.navigation.pendingRoomAuthentication = Self.makeRoomSession() - appState.navigation.clearPendingRoomAuthentication() + appState.navigation.clearPendingRoomAuthentication() - #expect(appState.navigation.pendingRoomAuthentication == nil) - } + #expect(appState.navigation.pendingRoomAuthentication == nil) + } - @Test("clearPendingChannelNavigation clears channel") - func clearPendingChannelNavigation() { - let appState = AppState() - appState.navigation.pendingChannel = Self.makeChannel() + @Test + func `clearPendingChannelNavigation clears channel`() { + let appState = AppState() + appState.navigation.pendingChannel = Self.makeChannel() - appState.navigation.clearPendingChannelNavigation() + appState.navigation.clearPendingChannelNavigation() - #expect(appState.navigation.pendingChannel == nil) - } + #expect(appState.navigation.pendingChannel == nil) + } - @Test("clearPendingDiscoveryNavigation clears discovery flag") - func clearPendingDiscoveryNavigation() { - let appState = AppState() - appState.navigation.pendingDiscoveryNavigation = true + @Test + func `clearPendingDiscoveryNavigation clears discovery flag`() { + let appState = AppState() + appState.navigation.pendingDiscoveryNavigation = true - appState.navigation.clearPendingDiscoveryNavigation() + appState.navigation.clearPendingDiscoveryNavigation() - #expect(appState.navigation.pendingDiscoveryNavigation == false) - } + #expect(appState.navigation.pendingDiscoveryNavigation == false) + } - @Test("clearPendingScrollToMessage clears message ID") - func clearPendingScrollToMessage() { - let appState = AppState() - appState.navigation.pendingScrollToMessageID = UUID() + @Test + func `clearPendingScrollToMessage clears message ID`() { + let appState = AppState() + appState.navigation.pendingScrollToMessageID = UUID() - appState.navigation.clearPendingScrollToMessage() + appState.navigation.clearPendingScrollToMessage() - #expect(appState.navigation.pendingScrollToMessageID == nil) - } + #expect(appState.navigation.pendingScrollToMessageID == nil) + } - @Test("clearPendingContactDetailNavigation clears contact detail") - func clearPendingContactDetailNavigation() { - let appState = AppState() - appState.navigation.pendingContactDetail = Self.makeContact() + @Test + func `clearPendingContactDetailNavigation clears contact detail`() { + let appState = AppState() + appState.navigation.pendingContactDetail = Self.makeContact() - appState.navigation.clearPendingContactDetailNavigation() + appState.navigation.clearPendingContactDetailNavigation() - #expect(appState.navigation.pendingContactDetail == nil) - } + #expect(appState.navigation.pendingContactDetail == nil) + } - // MARK: - Cross-Tab Navigation + // MARK: - Cross-Tab Navigation - @Test("navigateToChat from contacts tab hides tab bar and switches tab") - func crossTabChatNavigation() { - let appState = AppState() - appState.navigation.selectedTab = 1 // Contacts tab - let contact = Self.makeContact() + @Test + func `navigateToChat from contacts tab hides tab bar and switches tab`() { + let appState = AppState() + appState.navigation.selectedTab = 1 // Contacts tab + let contact = Self.makeContact() - appState.navigation.navigateToChat(with: contact) + appState.navigation.navigateToChat(with: contact) - #expect(appState.navigation.tabBarVisibility == .hidden) - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.pendingChatContact == contact) - #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) - } + #expect(appState.navigation.tabBarVisibility == .hidden) + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.pendingChatContact == contact) + #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) + } - @Test("Multiple navigation calls overwrite pending state") - func multipleNavigations() { - let appState = AppState() - let contact1 = Self.makeContact(name: "First") - let contact2 = Self.makeContact(name: "Second") + @Test + func `Multiple navigation calls overwrite pending state`() { + let appState = AppState() + let contact1 = Self.makeContact(name: "First") + let contact2 = Self.makeContact(name: "Second") - appState.navigation.navigateToChat(with: contact1) - appState.navigation.navigateToChat(with: contact2) + appState.navigation.navigateToChat(with: contact1) + appState.navigation.navigateToChat(with: contact2) - #expect(appState.navigation.pendingChatContact == contact2) - #expect(appState.navigation.chatsSelectedRoute == .direct(contact2)) - } + #expect(appState.navigation.pendingChatContact == contact2) + #expect(appState.navigation.chatsSelectedRoute == .direct(contact2)) + } - @Test("Device menu tip donation is pending by default when false") - func deviceMenuTipDonationDefault() { - let appState = AppState() - #expect(appState.navigation.pendingDeviceMenuTipDonation == false) - } + @Test + func `Device menu tip donation is pending by default when false`() { + let appState = AppState() + #expect(appState.navigation.pendingDeviceMenuTipDonation == false) + } } diff --git a/MC1Tests/AppState/OnboardingStateTests.swift b/MC1Tests/AppState/OnboardingStateTests.swift index 1e894e14..543c85b9 100644 --- a/MC1Tests/AppState/OnboardingStateTests.swift +++ b/MC1Tests/AppState/OnboardingStateTests.swift @@ -1,231 +1,230 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing @Suite("Onboarding State Tests") @MainActor struct OnboardingStateTests { + private let defaults: UserDefaults - private let defaults: UserDefaults + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } + // MARK: - completeOnboarding - // MARK: - completeOnboarding + @Test + func `completeOnboarding sets flag to true`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = false - @Test("completeOnboarding sets flag to true") - func completeOnboardingSetsFlag() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.hasCompletedOnboarding = false + onboarding.completeOnboarding() - onboarding.completeOnboarding() + #expect(onboarding.hasCompletedOnboarding == true) + } - #expect(onboarding.hasCompletedOnboarding == true) - } + @Test + func `completeOnboarding persists to UserDefaults`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = false - @Test("completeOnboarding persists to UserDefaults") - func completeOnboardingPersists() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.hasCompletedOnboarding = false + onboarding.completeOnboarding() - onboarding.completeOnboarding() + #expect(defaults.bool(forKey: "hasCompletedOnboarding") == true) + } - #expect(defaults.bool(forKey: "hasCompletedOnboarding") == true) - } + // MARK: - resetOnboarding - // MARK: - resetOnboarding + @Test + func `resetOnboarding clears flag`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = true - @Test("resetOnboarding clears flag") - func resetOnboardingClearsFlag() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.hasCompletedOnboarding = true + onboarding.resetOnboarding() - onboarding.resetOnboarding() + #expect(onboarding.hasCompletedOnboarding == false) + } - #expect(onboarding.hasCompletedOnboarding == false) - } + @Test + func `resetOnboarding clears onboarding path`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.onboardingPath = [.welcome, .permissions] - @Test("resetOnboarding clears onboarding path") - func resetOnboardingClearsPath() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.onboardingPath = [.welcome, .permissions] + onboarding.resetOnboarding() - onboarding.resetOnboarding() + #expect(onboarding.onboardingPath.isEmpty) + } - #expect(onboarding.onboardingPath.isEmpty) - } + @Test + func `resetOnboarding persists false to UserDefaults`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = true + onboarding.resetOnboarding() - @Test("resetOnboarding persists false to UserDefaults") - func resetOnboardingPersists() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.hasCompletedOnboarding = true - onboarding.resetOnboarding() + #expect(defaults.bool(forKey: "hasCompletedOnboarding") == false) + } - #expect(defaults.bool(forKey: "hasCompletedOnboarding") == false) - } + // MARK: - onboardingPath - // MARK: - onboardingPath + @Test + func `onboardingPath starts empty`() { + let appState = AppState() + #expect(appState.onboarding.onboardingPath.isEmpty) + } - @Test("onboardingPath starts empty") - func onboardingPathDefault() { - let appState = AppState() - #expect(appState.onboarding.onboardingPath.isEmpty) - } + @Test + func `onboardingPath can be appended to`() { + let appState = AppState() - @Test("onboardingPath can be appended to") - func onboardingPathAppend() { - let appState = AppState() + appState.onboarding.onboardingPath.append(.welcome) + appState.onboarding.onboardingPath.append(.permissions) - appState.onboarding.onboardingPath.append(.welcome) - appState.onboarding.onboardingPath.append(.permissions) + #expect(appState.onboarding.onboardingPath == [.welcome, .permissions]) + } - #expect(appState.onboarding.onboardingPath == [.welcome, .permissions]) - } + // MARK: - donateDeviceMenuTipIfOnValidTab - // MARK: - donateDeviceMenuTipIfOnValidTab + @Test + func `donateDeviceMenuTipIfOnValidTab on Chats tab clears pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 0 + appState.navigation.pendingDeviceMenuTipDonation = true - @Test("donateDeviceMenuTipIfOnValidTab on Chats tab clears pending") - func donateOnChatsTab() async { - let appState = AppState() - appState.navigation.selectedTab = 0 - appState.navigation.pendingDeviceMenuTipDonation = true + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == false) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == false) - } + @Test + func `donateDeviceMenuTipIfOnValidTab on Contacts tab clears pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 1 + appState.navigation.pendingDeviceMenuTipDonation = true - @Test("donateDeviceMenuTipIfOnValidTab on Contacts tab clears pending") - func donateOnContactsTab() async { - let appState = AppState() - appState.navigation.selectedTab = 1 - appState.navigation.pendingDeviceMenuTipDonation = true + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == false) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == false) - } + @Test + func `donateDeviceMenuTipIfOnValidTab on Map tab clears pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 2 + appState.navigation.pendingDeviceMenuTipDonation = true - @Test("donateDeviceMenuTipIfOnValidTab on Map tab clears pending") - func donateOnMapTab() async { - let appState = AppState() - appState.navigation.selectedTab = 2 - appState.navigation.pendingDeviceMenuTipDonation = true + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == false) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == false) - } + @Test + func `donateDeviceMenuTipIfOnValidTab on Settings tab sets pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 3 + appState.navigation.pendingDeviceMenuTipDonation = false - @Test("donateDeviceMenuTipIfOnValidTab on Settings tab sets pending") - func donateOnSettingsTab() async { - let appState = AppState() - appState.navigation.selectedTab = 3 - appState.navigation.pendingDeviceMenuTipDonation = false + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == true) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == true) - } + @Test + func `donateDeviceMenuTipIfOnValidTab on Tools tab sets pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 4 + appState.navigation.pendingDeviceMenuTipDonation = false - @Test("donateDeviceMenuTipIfOnValidTab on Tools tab sets pending") - func donateOnToolsTab() async { - let appState = AppState() - appState.navigation.selectedTab = 4 - appState.navigation.pendingDeviceMenuTipDonation = false + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == true) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == true) - } + // MARK: - hasCompletedOnboarding didSet + + @Test + func `hasCompletedOnboarding syncs to UserDefaults on set`() { + let onboarding = OnboardingState(defaults: defaults) - // MARK: - hasCompletedOnboarding didSet + onboarding.hasCompletedOnboarding = true + #expect(defaults.bool(forKey: "hasCompletedOnboarding") == true) - @Test("hasCompletedOnboarding syncs to UserDefaults on set") - func hasCompletedOnboardingDidSet() { - let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = false + #expect(defaults.bool(forKey: "hasCompletedOnboarding") == false) + } - onboarding.hasCompletedOnboarding = true - #expect(defaults.bool(forKey: "hasCompletedOnboarding") == true) + // MARK: - suggestedStartingPath + + @Suite("suggestedStartingPath") + @MainActor + struct SuggestedStartingPathTests { + @Test + func `Returns empty when onboarding is already complete`() async throws { + let testDefaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let onboarding = OnboardingState(defaults: testDefaults) + onboarding.hasCompletedOnboarding = true + let appState = AppState() + let path = await onboarding.suggestedStartingPath( + connectionManager: appState.connectionManager, + locationAuthorizationStatus: .notDetermined, + regionAlreadySet: false + ) + #expect(path.isEmpty) + } - onboarding.hasCompletedOnboarding = false - #expect(defaults.bool(forKey: "hasCompletedOnboarding") == false) + @Test + func `Returns empty when no paired device`() async throws { + let testDefaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let onboarding = OnboardingState(defaults: testDefaults) + + // Build the ConnectionManager with a registry-less stub so pairedAccessoriesCount == 0 + // is a guaranteed precondition; a bare AppState() would inherit whatever the host's + // pairing registry reports. The fresh defaults suite leaves lastConnectedDeviceID nil, + // so neither half of the resume guard fires and onboarding must not resume. + let container = try PersistenceStore.createContainer(inMemory: true) + let connectionManager = ConnectionManager( + modelContainer: container, + defaults: testDefaults, + pairing: StubDevicePairingService() + ) + #expect(connectionManager.pairedAccessoriesCount == 0) + #expect(connectionManager.lastConnectedDeviceID == nil) + + let path = await onboarding.suggestedStartingPath( + connectionManager: connectionManager, + locationAuthorizationStatus: .notDetermined, + regionAlreadySet: false + ) + #expect(path.isEmpty) } - // MARK: - suggestedStartingPath - - @Suite("suggestedStartingPath") - @MainActor - struct SuggestedStartingPathTests { - @Test("Returns empty when onboarding is already complete") - func emptyWhenCompleted() async { - let testDefaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let onboarding = OnboardingState(defaults: testDefaults) - onboarding.hasCompletedOnboarding = true - let appState = AppState() - let path = await onboarding.suggestedStartingPath( - connectionManager: appState.connectionManager, - locationAuthorizationStatus: .notDetermined, - regionAlreadySet: false - ) - #expect(path.isEmpty) - } - - @Test("Returns empty when no paired device") - func emptyWithNoPairing() async throws { - let testDefaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let onboarding = OnboardingState(defaults: testDefaults) - - // Build the ConnectionManager with a registry-less stub so pairedAccessoriesCount == 0 - // is a guaranteed precondition; a bare AppState() would inherit whatever the host's - // pairing registry reports. The fresh defaults suite leaves lastConnectedDeviceID nil, - // so neither half of the resume guard fires and onboarding must not resume. - let container = try PersistenceStore.createContainer(inMemory: true) - let connectionManager = ConnectionManager( - modelContainer: container, - defaults: testDefaults, - pairing: StubDevicePairingService() - ) - #expect(connectionManager.pairedAccessoriesCount == 0) - #expect(connectionManager.lastConnectedDeviceID == nil) - - let path = await onboarding.suggestedStartingPath( - connectionManager: connectionManager, - locationAuthorizationStatus: .notDetermined, - regionAlreadySet: false - ) - #expect(path.isEmpty) - } - - @Test("Resumes when a device was connected even with no system pairing registry (macOS)") - func resumesViaLastConnectedDeviceWithoutRegistry() async throws { - let testDefaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let onboarding = OnboardingState(defaults: testDefaults) - - // Build a ConnectionManager with a registry-less stub so pairedAccessoriesCount == 0 is - // a guaranteed precondition; a bare AppState() would inherit whatever the host's pairing - // registry reports. A real connect happened, so lastConnectedDeviceID is set — onboarding - // must still resume via that signal alone. - let container = try PersistenceStore.createContainer(inMemory: true) - let connectionManager = ConnectionManager( - modelContainer: container, - defaults: testDefaults, - pairing: StubDevicePairingService() - ) - connectionManager.testLastConnectedDeviceID = UUID() - #expect(connectionManager.pairedAccessoriesCount == 0) - - let path = await onboarding.suggestedStartingPath( - connectionManager: connectionManager, - locationAuthorizationStatus: .notDetermined, - regionAlreadySet: false - ) - // Passed the resume guard; halts at the permissions step (location is .notDetermined). - #expect(path == [.permissions]) - } + @Test + func `Resumes when a device was connected even with no system pairing registry (macOS)`() async throws { + let testDefaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let onboarding = OnboardingState(defaults: testDefaults) + + // Build a ConnectionManager with a registry-less stub so pairedAccessoriesCount == 0 is + // a guaranteed precondition; a bare AppState() would inherit whatever the host's pairing + // registry reports. A real connect happened, so lastConnectedDeviceID is set — onboarding + // must still resume via that signal alone. + let container = try PersistenceStore.createContainer(inMemory: true) + let connectionManager = ConnectionManager( + modelContainer: container, + defaults: testDefaults, + pairing: StubDevicePairingService() + ) + connectionManager.testLastConnectedDeviceID = UUID() + #expect(connectionManager.pairedAccessoriesCount == 0) + + let path = await onboarding.suggestedStartingPath( + connectionManager: connectionManager, + locationAuthorizationStatus: .notDetermined, + regionAlreadySet: false + ) + // Passed the resume guard; halts at the permissions step (location is .notDetermined). + #expect(path == [.permissions]) } + } } /// Registry-less `DevicePairingService` stub mirroring macOS "Designed for iPad": its @@ -233,17 +232,37 @@ struct OnboardingStateTests { /// `pairedAccessoriesCount == 0` regardless of the test host's real pairing registry. @MainActor private final class StubDevicePairingService: DevicePairingService { - var delegate: (any DevicePairingDelegate)? - var isSessionActive: Bool { false } - var registeredDeviceCount: Int { 0 } - var hasSystemPairingRegistry: Bool { false } - var supportsSystemRename: Bool { false } - - func activate() async throws {} - func discoverDevice() async throws -> UUID { throw CancellationError() } - func isDeviceConnectable(_ id: UUID) -> Bool { true } - func registeredDeviceInfos() -> [(id: UUID, name: String)] { [] } - func removeDevice(_ id: UUID) async throws {} - func renameDevice(_ id: UUID) async throws {} - func clearStaleRegistrations() async {} + var delegate: (any DevicePairingDelegate)? + var isSessionActive: Bool { + false + } + + var registeredDeviceCount: Int { + 0 + } + + var hasSystemPairingRegistry: Bool { + false + } + + var supportsSystemRename: Bool { + false + } + + func activate() async throws {} + func discoverDevice() async throws -> UUID { + throw CancellationError() + } + + func isDeviceConnectable(_ id: UUID) -> Bool { + true + } + + func registeredDeviceInfos() -> [(id: UUID, name: String)] { + [] + } + + func removeDevice(_ id: UUID) async throws {} + func renameDevice(_ id: UUID) async throws {} + func clearStaleRegistrations() async {} } diff --git a/MC1Tests/AppState/SavedDeviceConnectFailureRoutingTests.swift b/MC1Tests/AppState/SavedDeviceConnectFailureRoutingTests.swift new file mode 100644 index 00000000..f97f88d5 --- /dev/null +++ b/MC1Tests/AppState/SavedDeviceConnectFailureRoutingTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +/// Covers the routing a user-initiated connect to an already-paired radio takes +/// when the attempt throws. A dead bond surfaces as `authenticationFailed`, which +/// must reach the guided re-pair recovery, not the OK-only "check your PIN" alert. +@Suite("Saved Device Connect Failure Routing") +@MainActor +struct SavedDeviceConnectFailureRoutingTests { + @Test + func `authentication failure surfaces guided re-pair recovery`() { + let sut = ConnectionUIState() + let deviceID = UUID() + + sut.presentSavedDeviceConnectFailure(deviceID: deviceID, error: BLEError.authenticationFailed) + + #expect(sut.failedPairingDeviceID == deviceID) + #expect(sut.pairingFailureKind == .authentication) + #expect(sut.connectionFailedTitle == L10n.Localizable.Alert.PairingFailed.title) + #expect(sut.connectionFailedMessage == L10n.Onboarding.DeviceScan.Error.authenticationFailed) + #expect(sut.showingConnectionFailedAlert == true) + #expect(sut.otherAppWarningDeviceID == nil) + } + + @Test + func `other-app failure routes to the other-app warning`() { + let sut = ConnectionUIState() + let deviceID = UUID() + + sut.presentSavedDeviceConnectFailure(deviceID: deviceID, error: BLEError.deviceConnectedToOtherApp) + + #expect(sut.otherAppWarningDeviceID == deviceID) + #expect(sut.showingConnectionFailedAlert == false) + #expect(sut.failedPairingDeviceID == nil) + #expect(sut.pairingFailureKind == nil) + } + + @Test + func `non-auth failure routes to the generic connection-failed alert`() { + let sut = ConnectionUIState() + + sut.presentSavedDeviceConnectFailure(deviceID: UUID(), error: BLEError.connectionTimeout) + + #expect(sut.showingConnectionFailedAlert == true) + #expect(sut.failedPairingDeviceID == nil) + #expect(sut.pairingFailureKind == nil) + #expect(sut.connectionFailedTitle == nil) + #expect(sut.otherAppWarningDeviceID == nil) + } + + @Test + func `generic failure clears a stale failed-pairing device id`() { + let sut = ConnectionUIState() + sut.presentPairingFailure(.connectionFailed(deviceID: UUID(), underlying: BLEError.authenticationFailed)) + #expect(sut.failedPairingDeviceID != nil) + + sut.presentSavedDeviceConnectFailure(deviceID: UUID(), error: BLEError.connectionTimeout) + + #expect(sut.failedPairingDeviceID == nil) + #expect(sut.pairingFailureKind == nil) + } +} diff --git a/MC1Tests/AppState/StatusPillStateTests.swift b/MC1Tests/AppState/StatusPillStateTests.swift index c21190b5..ab0d1336 100644 --- a/MC1Tests/AppState/StatusPillStateTests.swift +++ b/MC1Tests/AppState/StatusPillStateTests.swift @@ -1,76 +1,38 @@ -import Testing -@testable import MC1Services @testable import MC1 +@testable import MC1Services +import Testing @Suite("StatusPillState Tests") struct StatusPillStateTests { - - @Test("Failed state takes highest priority") - @MainActor - func failedTakesPriority() { - let appState = AppState() - appState.connectionUI.showSyncFailedPill() - #expect(appState.statusPillState == .failed(message: "Sync Failed")) - } - - @Test("Syncing takes priority over connecting") - @MainActor - func syncingOverConnecting() { - let appState = AppState() - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) - appState.connectionUI.simulateSyncEnded() - } - - @Test("Ready state shows when toast is active") - @MainActor - func readyStateShowsWithToast() { - let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - #expect(appState.statusPillState == .ready) - } - - @Test("Hidden when no conditions met") - @MainActor - func hiddenByDefault() { - let appState = AppState() - #expect(appState.statusPillState == .hidden) - } - - @Test("Disconnected shows after delay when device was paired") - @MainActor - func disconnectedAfterDelay() async throws { - let appState = AppState() - // This test verifies the delay mechanism exists - // Full integration test would require mocking connectionManager - appState.connectionUI.updateDisconnectedPillState( - connectionState: appState.connectionState, - lastConnectedDeviceID: appState.connectionManager.lastConnectedDeviceID, - shouldSuppressDisconnectedPill: appState.connectionManager.shouldSuppressDisconnectedPill - ) - // Without a paired device, should remain hidden - #expect(appState.statusPillState == .hidden) - } - - @Test("Double onSyncActivityEnded does not drive count below zero") - @MainActor - func doubleEndedCallDoesNotGoNegative() { - let appState = AppState() - - // Simulate sync starting - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) - - // First end call (simulates onDisconnected path) - appState.connectionUI.simulateSyncEnded() - #expect(appState.statusPillState != .syncing) - - // Second end call (simulates error path) - should be no-op due to guard - appState.connectionUI.simulateSyncEnded() - #expect(appState.statusPillState == .hidden) - - // Start new sync - pill should show (proves count didn't go negative) - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) - } + @Test + @MainActor + func `Failed state takes highest priority`() { + let appState = AppState() + appState.connectionUI.showSyncFailedPill() + #expect(appState.statusPillState == .failed(message: "Sync Failed")) + } + + @Test + @MainActor + func `Syncing takes priority over connecting`() { + let appState = AppState() + appState.connectionUI.simulateSyncStarted() + #expect(appState.statusPillState == .syncing) + appState.connectionUI.simulateSyncEnded() + } + + @Test + @MainActor + func `Ready state shows when toast is active`() { + let appState = AppState() + appState.connectionUI.showReadyToastBriefly() + #expect(appState.statusPillState == .ready) + } + + @Test + @MainActor + func `Hidden when no conditions met`() { + let appState = AppState() + #expect(appState.statusPillState == .hidden) + } } diff --git a/MC1Tests/AppStateThemeWiringTests.swift b/MC1Tests/AppStateThemeWiringTests.swift index ab5398d2..7747a0d0 100644 --- a/MC1Tests/AppStateThemeWiringTests.swift +++ b/MC1Tests/AppStateThemeWiringTests.swift @@ -1,39 +1,38 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @MainActor @Suite("AppState theme wiring", .serialized) struct AppStateThemeWiringTests { + @Test + func `AppState exposes a themeService defaulting to the default theme`() { + UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) + let appState = AppState() + #expect(appState.themeService.current.id == Theme.default.id) + } - @Test("AppState exposes a themeService defaulting to the default theme") - func exposesThemeService() { - UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) - let appState = AppState() - #expect(appState.themeService.current.id == Theme.default.id) - } - - @Test("AppState exposes a storeState wrapping an idle StoreService") - func exposesStoreState() { - let appState = AppState() - #expect(appState.storeState.service.loadState == .idle) - } + @Test + func `AppState exposes a storeState wrapping an idle StoreService`() { + let appState = AppState() + #expect(appState.storeState.service.loadState == .idle) + } - @Test("notifyDataRestored does not wipe a restored paid theme while the store is unloaded") - func notifyDataRestoredDefersWhileUnloaded() { - // notifyDataRestored delegates to refreshFromUserDefaults. AppState's StoreService is .idle - // until load(), so ownership is not yet authoritative: a restored paid theme is adopted, not - // wiped, to protect an owner who restores before the entitlement walk. Enforcement once the - // store is loaded is covered by ThemeServiceOwnershipTests.refreshRevertsUnownedThemeWhenLoaded. - defer { UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) } - UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) - let appState = AppState() - #expect(appState.storeState.service.loadState == .idle) - #expect(appState.themeService.current.id == Theme.default.id) + @Test + func `notifyDataRestored does not wipe a restored paid theme while the store is unloaded`() { + // notifyDataRestored delegates to refreshFromUserDefaults. AppState's StoreService is .idle + // until load(), so ownership is not yet authoritative: a restored paid theme is adopted, not + // wiped, to protect an owner who restores before the entitlement walk. Enforcement once the + // store is loaded is covered by ThemeServiceOwnershipTests.refreshRevertsUnownedThemeWhenLoaded. + defer { UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) } + UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) + let appState = AppState() + #expect(appState.storeState.service.loadState == .idle) + #expect(appState.themeService.current.id == Theme.default.id) - UserDefaults.standard.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - appState.notifyDataRestored() - #expect(appState.themeService.current.id == Theme.ember.id) - } + UserDefaults.standard.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + appState.notifyDataRestored() + #expect(appState.themeService.current.id == Theme.ember.id) + } } diff --git a/MC1Tests/AppThemeEnvironmentTests.swift b/MC1Tests/AppThemeEnvironmentTests.swift index 2b705195..74e792eb 100644 --- a/MC1Tests/AppThemeEnvironmentTests.swift +++ b/MC1Tests/AppThemeEnvironmentTests.swift @@ -1,14 +1,13 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing @MainActor @Suite("AppTheme environment") struct AppThemeEnvironmentTests { - - @Test("the default appTheme environment value is Theme.default") - func defaultValueIsDefaultTheme() { - let values = EnvironmentValues() - #expect(values.appTheme.id == Theme.default.id) - } + @Test + func `the default appTheme environment value is Theme.default`() { + let values = EnvironmentValues() + #expect(values.appTheme.id == Theme.default.id) + } } diff --git a/MC1Tests/AppearanceSelectionTests.swift b/MC1Tests/AppearanceSelectionTests.swift index fe89b7a2..d7864df4 100644 --- a/MC1Tests/AppearanceSelectionTests.swift +++ b/MC1Tests/AppearanceSelectionTests.swift @@ -1,62 +1,62 @@ -import Testing -import SwiftUI +@testable import MC1 +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services -@testable import MC1 +import SwiftUI +import Testing @MainActor @Suite("Appearance selection logic", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class AppearanceSelectionTests { - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - session.askToBuyEnabled = false // reset: SKTestSession leaks session flags across instances in-process - session.clearTransactions() - } - - deinit { session.clearTransactions() } - - private func freshDefaults() -> UserDefaults { - UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - @Test("with no purchases, only the default theme is available and Browse-more is shown") - func emptyOwnership() async throws { - let store = StoreService() - await store.load() - let theme = ThemeService(store: store, defaults: freshDefaults()) - - #expect(theme.availableToCurrentUser().map(\.id) == [Theme.default.id]) - #expect(AppearanceView.shouldShowBrowseMore(available: theme.availableToCurrentUser())) - } - - @Test("a theme owned via the bundle becomes available; selecting it updates current") - func ownedThemeSelectable() async throws { - let store = StoreService() - await store.load() - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - let theme = ThemeService(store: store, defaults: freshDefaults()) - - let available = theme.availableToCurrentUser().map(\.id) - #expect(available.contains(Theme.marine.id)) - - try theme.setCurrent(.marine) - #expect(theme.current.id == Theme.marine.id) - } - - @Test("owning the bundle makes every theme available and hides Browse-more") - func bundleOwnershipHidesBrowseMore() async throws { - let store = StoreService() - await store.load() - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - let theme = ThemeService(store: store, defaults: freshDefaults()) - - #expect(theme.availableToCurrentUser().count == ThemeRegistry.allThemes.count) - #expect(!AppearanceView.shouldShowBrowseMore(available: theme.availableToCurrentUser())) - } + let session: SKTestSession + + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + session.askToBuyEnabled = false // reset: SKTestSession leaks session flags across instances in-process + session.clearTransactions() + } + + deinit { session.clearTransactions() } + + private func freshDefaults() -> UserDefaults { + UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `with no purchases, only the default theme is available and Browse-more is shown`() async { + let store = StoreService() + await store.load() + let theme = ThemeService(store: store, defaults: freshDefaults()) + + #expect(theme.availableToCurrentUser().map(\.id) == [Theme.default.id]) + #expect(AppearanceView.shouldShowBrowseMore(available: theme.availableToCurrentUser())) + } + + @Test + func `a theme owned via the bundle becomes available; selecting it updates current`() async throws { + let store = StoreService() + await store.load() + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + let theme = ThemeService(store: store, defaults: freshDefaults()) + + let available = theme.availableToCurrentUser().map(\.id) + #expect(available.contains(Theme.marine.id)) + + try theme.setCurrent(.marine) + #expect(theme.current.id == Theme.marine.id) + } + + @Test + func `owning the bundle makes every theme available and hides Browse-more`() async throws { + let store = StoreService() + await store.load() + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + let theme = ThemeService(store: store, defaults: freshDefaults()) + + #expect(theme.availableToCurrentUser().count == ThemeRegistry.allThemes.count) + #expect(!AppearanceView.shouldShowBrowseMore(available: theme.availableToCurrentUser())) + } } diff --git a/MC1Tests/Extensions/BatteryInfoDisplayTests.swift b/MC1Tests/Extensions/BatteryInfoDisplayTests.swift index 704922b3..dae7d139 100644 --- a/MC1Tests/Extensions/BatteryInfoDisplayTests.swift +++ b/MC1Tests/Extensions/BatteryInfoDisplayTests.swift @@ -1,106 +1,105 @@ -import Testing @testable import MC1 import MeshCore +import Testing struct BatteryInfoDisplayTests { - - // MARK: - Voltage Tests - - @Test func voltage_convertsMillivoltsCorrectly() { - let battery = BatteryInfo(level: 3700) - #expect(battery.voltage == 3.7) - } - - @Test func voltage_zeroMillivolts() { - let battery = BatteryInfo(level: 0) - #expect(battery.voltage == 0.0) - } - - // MARK: - Percentage Tests - - @Test func percentage_fullBattery() { - let battery = BatteryInfo(level: 4200) - #expect(battery.percentage == 100) - } - - @Test func percentage_emptyBattery() { - let battery = BatteryInfo(level: 3000) - #expect(battery.percentage == 0) - } - - @Test func percentage_midRange() { - let battery = BatteryInfo(level: 3600) // 50% point - #expect(battery.percentage == 50) - } - - @Test func percentage_clampsAbove100() { - let battery = BatteryInfo(level: 4500) - #expect(battery.percentage == 100) - } - - @Test func percentage_clampsBelow0() { - let battery = BatteryInfo(level: 2500) - #expect(battery.percentage == 0) - } - - // MARK: - Icon Tests - - @Test func iconName_fullBattery() { - let battery = BatteryInfo(level: 4200) - #expect(battery.iconName == "battery.100") - } - - @Test func iconName_75percent() { - let battery = BatteryInfo(level: 3900) // ~75% - #expect(battery.iconName == "battery.75") - } - - @Test func iconName_50percent() { - let battery = BatteryInfo(level: 3600) // ~50% - #expect(battery.iconName == "battery.50") - } - - @Test func iconName_25percent() { - let battery = BatteryInfo(level: 3300) // ~25% - #expect(battery.iconName == "battery.25") - } - - @Test func iconName_lowBattery() { - let battery = BatteryInfo(level: 3100) // ~8% - #expect(battery.iconName == "battery.0") - } - - // MARK: - Color Tests - - @Test func levelColor_normalLevel() { - let battery = BatteryInfo(level: 3600) // 50% - #expect(battery.levelColor == .primary) - } - - @Test func levelColor_warningLevel() { - let battery = BatteryInfo(level: 3180) // ~15% - #expect(battery.levelColor == .orange) - } - - @Test func levelColor_criticalLevel() { - let battery = BatteryInfo(level: 3060) // ~5% - #expect(battery.levelColor == .red) - } - - // MARK: - Battery Presence Tests - - @Test func isBatteryPresent_zeroMillivolts_returnsFalse() { - let battery = BatteryInfo(level: 0) - #expect(!battery.isBatteryPresent) - } - - @Test func isBatteryPresent_normalVoltage_returnsTrue() { - let battery = BatteryInfo(level: 3700) - #expect(battery.isBatteryPresent) - } - - @Test func isBatteryPresent_minimumValidVoltage_returnsTrue() { - let battery = BatteryInfo(level: 1) - #expect(battery.isBatteryPresent) - } + // MARK: - Voltage Tests + + @Test func `voltage converts millivolts correctly`() { + let battery = BatteryInfo(level: 3700) + #expect(battery.voltage == 3.7) + } + + @Test func `voltage zero millivolts`() { + let battery = BatteryInfo(level: 0) + #expect(battery.voltage == 0.0) + } + + // MARK: - Percentage Tests + + @Test func `percentage full battery`() { + let battery = BatteryInfo(level: 4200) + #expect(battery.percentage == 100) + } + + @Test func `percentage empty battery`() { + let battery = BatteryInfo(level: 3000) + #expect(battery.percentage == 0) + } + + @Test func `percentage mid range`() { + let battery = BatteryInfo(level: 3600) // 50% point + #expect(battery.percentage == 50) + } + + @Test func `percentage clamps above 100`() { + let battery = BatteryInfo(level: 4500) + #expect(battery.percentage == 100) + } + + @Test func `percentage clamps below 0`() { + let battery = BatteryInfo(level: 2500) + #expect(battery.percentage == 0) + } + + // MARK: - Icon Tests + + @Test func `icon name full battery`() { + let battery = BatteryInfo(level: 4200) + #expect(battery.iconName == "battery.100") + } + + @Test func `icon name 75 percent`() { + let battery = BatteryInfo(level: 3900) // ~75% + #expect(battery.iconName == "battery.75") + } + + @Test func `icon name 50 percent`() { + let battery = BatteryInfo(level: 3600) // ~50% + #expect(battery.iconName == "battery.50") + } + + @Test func `icon name 25 percent`() { + let battery = BatteryInfo(level: 3300) // ~25% + #expect(battery.iconName == "battery.25") + } + + @Test func `icon name low battery`() { + let battery = BatteryInfo(level: 3100) // ~8% + #expect(battery.iconName == "battery.0") + } + + // MARK: - Color Tests + + @Test func `level color normal level`() { + let battery = BatteryInfo(level: 3600) // 50% + #expect(battery.levelColor == .primary) + } + + @Test func `level color warning level`() { + let battery = BatteryInfo(level: 3180) // ~15% + #expect(battery.levelColor == .orange) + } + + @Test func `level color critical level`() { + let battery = BatteryInfo(level: 3060) // ~5% + #expect(battery.levelColor == .red) + } + + // MARK: - Battery Presence Tests + + @Test func `is battery present zero millivolts returns false`() { + let battery = BatteryInfo(level: 0) + #expect(!battery.isBatteryPresent) + } + + @Test func `is battery present normal voltage returns true`() { + let battery = BatteryInfo(level: 3700) + #expect(battery.isBatteryPresent) + } + + @Test func `is battery present minimum valid voltage returns true`() { + let battery = BatteryInfo(level: 1) + #expect(battery.isBatteryPresent) + } } diff --git a/MC1Tests/Extensions/BatteryPercentageCalculationTests.swift b/MC1Tests/Extensions/BatteryPercentageCalculationTests.swift index 9ab3d596..7c904fe2 100644 --- a/MC1Tests/Extensions/BatteryPercentageCalculationTests.swift +++ b/MC1Tests/Extensions/BatteryPercentageCalculationTests.swift @@ -1,49 +1,48 @@ -import Testing -import MeshCore @testable import MC1 +import MeshCore +import Testing @Suite("Battery Percentage Calculation Tests") struct BatteryPercentageCalculationTests { - - // Li-Ion array for testing: [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] - let liIonArray = [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] - - @Test("Voltage at 100% point returns 100") - func voltageAt100Percent() { - let battery = BatteryInfo(level: 4190) - #expect(battery.percentage(using: liIonArray) == 100) - } - - @Test("Voltage at 0% point returns 0") - func voltageAt0Percent() { - let battery = BatteryInfo(level: 3100) - #expect(battery.percentage(using: liIonArray) == 0) - } - - @Test("Voltage above max returns 100") - func voltageAboveMax() { - let battery = BatteryInfo(level: 4500) - #expect(battery.percentage(using: liIonArray) == 100) - } - - @Test("Voltage below min returns 0") - func voltageBelowMin() { - let battery = BatteryInfo(level: 2800) - #expect(battery.percentage(using: liIonArray) == 0) - } - - @Test("Voltage at 50% point returns 50") - func voltageAt50Percent() { - // 50% is at index 5 = 3720mV - let battery = BatteryInfo(level: 3720) - #expect(battery.percentage(using: liIonArray) == 50) - } - - @Test("Voltage interpolates between points") - func voltageInterpolates() { - // Midpoint between 4190 (100%) and 4050 (90%) = 4120 should be ~95% - let battery = BatteryInfo(level: 4120) - let percent = battery.percentage(using: liIonArray) - #expect(percent >= 94 && percent <= 96, "Expected ~95%, got \(percent)") - } + /// Li-Ion array for testing: [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] + let liIonArray = [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] + + @Test + func `Voltage at 100% point returns 100`() { + let battery = BatteryInfo(level: 4190) + #expect(battery.percentage(using: liIonArray) == 100) + } + + @Test + func `Voltage at 0% point returns 0`() { + let battery = BatteryInfo(level: 3100) + #expect(battery.percentage(using: liIonArray) == 0) + } + + @Test + func `Voltage above max returns 100`() { + let battery = BatteryInfo(level: 4500) + #expect(battery.percentage(using: liIonArray) == 100) + } + + @Test + func `Voltage below min returns 0`() { + let battery = BatteryInfo(level: 2800) + #expect(battery.percentage(using: liIonArray) == 0) + } + + @Test + func `Voltage at 50% point returns 50`() { + // 50% is at index 5 = 3720mV + let battery = BatteryInfo(level: 3720) + #expect(battery.percentage(using: liIonArray) == 50) + } + + @Test + func `Voltage interpolates between points`() { + // Midpoint between 4190 (100%) and 4050 (90%) = 4120 should be ~95% + let battery = BatteryInfo(level: 4120) + let percent = battery.percentage(using: liIonArray) + #expect(percent >= 94 && percent <= 96, "Expected ~95%, got \(percent)") + } } diff --git a/MC1Tests/Extensions/DataExtensionsTests.swift b/MC1Tests/Extensions/DataExtensionsTests.swift index b670e3b1..aa33db24 100644 --- a/MC1Tests/Extensions/DataExtensionsTests.swift +++ b/MC1Tests/Extensions/DataExtensionsTests.swift @@ -1,107 +1,106 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("Data Extensions Tests") struct DataExtensionsTests { - - // MARK: - uppercaseHexString() Tests - - @Test("Empty data returns empty string") - func uppercaseHexStringEmpty() { - let data = Data() - #expect(data.uppercaseHexString() == "") - } - - @Test("Hex string with no separator") - func uppercaseHexStringNoSeparator() { - let data = Data([0xAA, 0xBB, 0xCC, 0xDD]) - #expect(data.uppercaseHexString() == "AABBCCDD") - } - - @Test("Hex string with space separator") - func uppercaseHexStringWithSpaceSeparator() { - let data = Data([0xAA, 0xBB, 0xCC, 0xDD]) - #expect(data.uppercaseHexString(separator: " ") == "AA BB CC DD") - } - - @Test("Hex string with custom separator") - func uppercaseHexStringWithCustomSeparator() { - let data = Data([0xAA, 0xBB, 0xCC]) - #expect(data.uppercaseHexString(separator: ":") == "AA:BB:CC") - } - - @Test("Hex string for single byte") - func uppercaseHexStringSingleByte() { - let data = Data([0x0F]) - #expect(data.uppercaseHexString() == "0F") - } - - @Test("Hex string preserves leading zeros") - func uppercaseHexStringLeadingZero() { - let data = Data([0x00, 0x01, 0x02]) - #expect(data.uppercaseHexString() == "000102") - } - - // MARK: - init?(hexString:) Tests - - @Test("Init from valid hex string") - func initFromHexStringValid() { - let data = Data(hexString: "AABBCCDD") - #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) - } - - @Test("Init from hex string with spaces") - func initFromHexStringWithSpaces() { - let data = Data(hexString: "AA BB CC DD") - #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) - } - - @Test("Init from lowercase hex string") - func initFromHexStringLowercase() { - let data = Data(hexString: "aabbccdd") - #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) - } - - @Test("Init from mixed case hex string") - func initFromHexStringMixedCase() { - let data = Data(hexString: "AaBbCcDd") - #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) - } - - @Test("Init from empty hex string") - func initFromHexStringEmpty() { - let data = Data(hexString: "") - #expect(data == Data()) - } - - @Test("Init from odd-length hex string returns nil") - func initFromHexStringOddLength() { - let data = Data(hexString: "ABC") - #expect(data == nil) - } - - @Test("Init filters out non-hex characters") - func initFromHexStringWithNonHexCharacters() { - let data = Data(hexString: "AA-BB-CC") - #expect(data == Data([0xAA, 0xBB, 0xCC])) - } - - // MARK: - Round-trip Tests - - @Test("Round-trip preserves data") - func roundTrip() { - let original = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) - let hexString = original.uppercaseHexString() - let restored = Data(hexString: hexString) - #expect(restored == original) - } - - @Test("Round-trip with spaces preserves data") - func roundTripWithSpaces() { - let original = Data([0xDE, 0xAD, 0xBE, 0xEF]) - let hexString = original.uppercaseHexString(separator: " ") - let restored = Data(hexString: hexString) - #expect(restored == original) - } + // MARK: - uppercaseHexString() Tests + + @Test + func `Empty data returns empty string`() { + let data = Data() + #expect(data.uppercaseHexString() == "") + } + + @Test + func `Hex string with no separator`() { + let data = Data([0xAA, 0xBB, 0xCC, 0xDD]) + #expect(data.uppercaseHexString() == "AABBCCDD") + } + + @Test + func `Hex string with space separator`() { + let data = Data([0xAA, 0xBB, 0xCC, 0xDD]) + #expect(data.uppercaseHexString(separator: " ") == "AA BB CC DD") + } + + @Test + func `Hex string with custom separator`() { + let data = Data([0xAA, 0xBB, 0xCC]) + #expect(data.uppercaseHexString(separator: ":") == "AA:BB:CC") + } + + @Test + func `Hex string for single byte`() { + let data = Data([0x0F]) + #expect(data.uppercaseHexString() == "0F") + } + + @Test + func `Hex string preserves leading zeros`() { + let data = Data([0x00, 0x01, 0x02]) + #expect(data.uppercaseHexString() == "000102") + } + + // MARK: - init?(hexString:) Tests + + @Test + func `Init from valid hex string`() { + let data = Data(hexString: "AABBCCDD") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test + func `Init from hex string with spaces`() { + let data = Data(hexString: "AA BB CC DD") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test + func `Init from lowercase hex string`() { + let data = Data(hexString: "aabbccdd") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test + func `Init from mixed case hex string`() { + let data = Data(hexString: "AaBbCcDd") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test + func `Init from empty hex string`() { + let data = Data(hexString: "") + #expect(data == Data()) + } + + @Test + func `Init from odd-length hex string returns nil`() { + let data = Data(hexString: "ABC") + #expect(data == nil) + } + + @Test + func `Init filters out non-hex characters`() { + let data = Data(hexString: "AA-BB-CC") + #expect(data == Data([0xAA, 0xBB, 0xCC])) + } + + // MARK: - Round-trip Tests + + @Test + func `Round-trip preserves data`() { + let original = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) + let hexString = original.uppercaseHexString() + let restored = Data(hexString: hexString) + #expect(restored == original) + } + + @Test + func `Round-trip with spaces preserves data`() { + let original = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let hexString = original.uppercaseHexString(separator: " ") + let restored = Data(hexString: hexString) + #expect(restored == original) + } } diff --git a/MC1Tests/Extensions/ErrorDispatchCoverageTests.swift b/MC1Tests/Extensions/ErrorDispatchCoverageTests.swift index c698c029..a59866c1 100644 --- a/MC1Tests/Extensions/ErrorDispatchCoverageTests.swift +++ b/MC1Tests/Extensions/ErrorDispatchCoverageTests.swift @@ -16,130 +16,129 @@ import Testing /// if it never reaches .errorAlert. @Suite("ErrorDispatchCoverage") struct ErrorDispatchCoverageTests { - - // MARK: - Allowlist - - /// Types that implement LocalizedError but are intentionally omitted from the - /// Error+UserFacingMessage dispatcher because they never reach .errorAlert. - /// - /// Each entry includes a one-line justification. The test will fail if an - /// allowlisted name no longer appears in the source tree (stale allowlist). - private static let intentionallyUnmappedTypes: [String: String] = [ - "PairingError": "Routed through ConnectionUIState.presentPairingFailure; " - + "produces bespoke auth-failure vs generic-failure alerts, not a plain message.", - "DevicePairingError": "Control-flow signal (cancelled / alreadyInProgress); " - + "caught at every call site before the error reaches .errorAlert.", - ] - - // MARK: - Test - - @Test("Every LocalizedError enum in MC1Services and MeshCore has a dispatch arm or is allowlisted") - func everyLocalizedErrorEnumIsDispatched() throws { - let repoRoot = try resolveRepoRoot() - let discovered = try discoverLocalizedErrorEnumNames(under: repoRoot) - let dispatched = try dispatchedTypeNames(in: repoRoot) - let allowlisted = Set(Self.intentionallyUnmappedTypes.keys) - - let unmapped = discovered.subtracting(dispatched).subtracting(allowlisted) - let staleAllowList = allowlisted.subtracting(discovered) - - #expect(unmapped.isEmpty, """ - LocalizedError enums without a dispatch arm or allowlist entry: \(unmapped.sorted()). - Add a `case let error as : error.userFacingMessage` arm to - Error+UserFacingMessage.swift and a matching +UserFacingMessage file, - OR add the type to intentionallyUnmappedTypes with a justification. - """) - - #expect(staleAllowList.isEmpty, """ - intentionallyUnmappedTypes entries whose type name no longer appears in the source tree: \ - \(staleAllowList.sorted()). - Remove stale entries from intentionallyUnmappedTypes. - """) - } - - // MARK: - Source scanning helpers - - /// Resolves the repo root from this file's compile-time path. - /// This file lives at MC1Tests/Extensions/ErrorDispatchCoverageTests.swift, - /// so the root is three parent directories up. - private func resolveRepoRoot(filePath: StaticString = #filePath) throws -> URL { - let thisFile = URL(fileURLWithPath: "\(filePath)") - // MC1Tests/Extensions/ErrorDispatchCoverageTests.swift -> go up 3 levels - let root = thisFile - .deletingLastPathComponent() // Extensions/ - .deletingLastPathComponent() // MC1Tests/ - .deletingLastPathComponent() // repo root - guard FileManager.default.fileExists(atPath: root.path) else { - throw CocoaError(.fileNoSuchFile, - userInfo: [NSFilePathErrorKey: root.path]) - } - return root + // MARK: - Allowlist + + /// Types that implement LocalizedError but are intentionally omitted from the + /// Error+UserFacingMessage dispatcher because they never reach .errorAlert. + /// + /// Each entry includes a one-line justification. The test will fail if an + /// allowlisted name no longer appears in the source tree (stale allowlist). + private static let intentionallyUnmappedTypes: [String: String] = [ + "PairingError": "Routed through ConnectionUIState.presentPairingFailure; " + + "produces bespoke auth-failure vs generic-failure alerts, not a plain message.", + "DevicePairingError": "Control-flow signal (cancelled / alreadyInProgress); " + + "caught at every call site before the error reaches .errorAlert.", + ] + + // MARK: - Test + + @Test + func `Every LocalizedError enum in MC1Services and MeshCore has a dispatch arm or is allowlisted`() throws { + let repoRoot = try resolveRepoRoot() + let discovered = try discoverLocalizedErrorEnumNames(under: repoRoot) + let dispatched = try dispatchedTypeNames(in: repoRoot) + let allowlisted = Set(Self.intentionallyUnmappedTypes.keys) + + let unmapped = discovered.subtracting(dispatched).subtracting(allowlisted) + let staleAllowList = allowlisted.subtracting(discovered) + + #expect(unmapped.isEmpty, """ + LocalizedError enums without a dispatch arm or allowlist entry: \(unmapped.sorted()). + Add a `case let error as : error.userFacingMessage` arm to + Error+UserFacingMessage.swift and a matching +UserFacingMessage file, + OR add the type to intentionallyUnmappedTypes with a justification. + """) + + #expect(staleAllowList.isEmpty, """ + intentionallyUnmappedTypes entries whose type name no longer appears in the source tree: \ + \(staleAllowList.sorted()). + Remove stale entries from intentionallyUnmappedTypes. + """) + } + + // MARK: - Source scanning helpers + + /// Resolves the repo root from this file's compile-time path. + /// This file lives at MC1Tests/Extensions/ErrorDispatchCoverageTests.swift, + /// so the root is three parent directories up. + private func resolveRepoRoot(filePath: StaticString = #filePath) throws -> URL { + let thisFile = URL(fileURLWithPath: "\(filePath)") + // MC1Tests/Extensions/ErrorDispatchCoverageTests.swift -> go up 3 levels + let root = thisFile + .deletingLastPathComponent() // Extensions/ + .deletingLastPathComponent() // MC1Tests/ + .deletingLastPathComponent() // repo root + guard FileManager.default.fileExists(atPath: root.path) else { + throw CocoaError(.fileNoSuchFile, + userInfo: [NSFilePathErrorKey: root.path]) } + return root + } + + /// Scans MC1Services/Sources and MeshCore/Sources for Swift files and extracts + /// every type name that conforms to LocalizedError (directly or via extension). + /// + /// Patterns matched: + /// - `enum Foo: ..., LocalizedError, ...` + /// - `extension Foo: LocalizedError` / `extension Foo: @retroactive LocalizedError` + private func discoverLocalizedErrorEnumNames(under root: URL) throws -> Set { + let sourceDirs = [ + root.appendingPathComponent("MC1Services/Sources"), + root.appendingPathComponent("MeshCore/Sources"), + ] - /// Scans MC1Services/Sources and MeshCore/Sources for Swift files and extracts - /// every type name that conforms to LocalizedError (directly or via extension). - /// - /// Patterns matched: - /// - `enum Foo: ..., LocalizedError, ...` - /// - `extension Foo: LocalizedError` / `extension Foo: @retroactive LocalizedError` - private func discoverLocalizedErrorEnumNames(under root: URL) throws -> Set { - let sourceDirs = [ - root.appendingPathComponent("MC1Services/Sources"), - root.appendingPathComponent("MeshCore/Sources"), - ] - - // Regex: matches `enum Name` where the conformance list contains LocalizedError, - // or `extension Name: ... LocalizedError ...` (with optional @retroactive). - let enumPattern = try NSRegularExpression( - pattern: #"(?m)^\s*(?:public\s+)?enum\s+(\w+)\s*:[^{]*\bLocalizedError\b"# - ) - // Extension conformances, covering multi-protocol lists and @retroactive: - // `extension Foo: LocalizedError`, `extension Foo: Bar, LocalizedError` - let multiExtensionPattern = try NSRegularExpression( - pattern: #"(?m)^\s*extension\s+(\w+)\s*:[^{]*\bLocalizedError\b"# - ) - - var names = Set() - - for dir in sourceDirs { - guard let enumerator = FileManager.default.enumerator( - at: dir, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { continue } - - for case let fileURL as URL in enumerator { - guard fileURL.pathExtension == "swift" else { continue } - let source = try String(contentsOf: fileURL, encoding: .utf8) - let range = NSRange(source.startIndex..., in: source) - for pattern in [enumPattern, multiExtensionPattern] { - let matches = pattern.matches(in: source, range: range) - for match in matches { - if let nameRange = Range(match.range(at: 1), in: source) { - names.insert(String(source[nameRange])) - } - } - } + // Regex: matches `enum Name` where the conformance list contains LocalizedError, + // or `extension Name: ... LocalizedError ...` (with optional @retroactive). + let enumPattern = try NSRegularExpression( + pattern: #"(?m)^\s*(?:public\s+)?enum\s+(\w+)\s*:[^{]*\bLocalizedError\b"# + ) + // Extension conformances, covering multi-protocol lists and @retroactive: + // `extension Foo: LocalizedError`, `extension Foo: Bar, LocalizedError` + let multiExtensionPattern = try NSRegularExpression( + pattern: #"(?m)^\s*extension\s+(\w+)\s*:[^{]*\bLocalizedError\b"# + ) + + var names = Set() + + for dir in sourceDirs { + guard let enumerator = FileManager.default.enumerator( + at: dir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { continue } + + for case let fileURL as URL in enumerator { + guard fileURL.pathExtension == "swift" else { continue } + let source = try String(contentsOf: fileURL, encoding: .utf8) + let range = NSRange(source.startIndex..., in: source) + for pattern in [enumPattern, multiExtensionPattern] { + let matches = pattern.matches(in: source, range: range) + for match in matches { + if let nameRange = Range(match.range(at: 1), in: source) { + names.insert(String(source[nameRange])) } + } } - - return names + } } - /// Scans Error+UserFacingMessage.swift for `case let error as :` arms. - private func dispatchedTypeNames(in root: URL) throws -> Set { - let dispatchFile = root.appendingPathComponent( - "MC1/Extensions/Errors/Error+UserFacingMessage.swift" - ) - let source = try String(contentsOf: dispatchFile, encoding: .utf8) - let pattern = try NSRegularExpression( - pattern: #"case\s+let\s+\w+\s+as\s+(\w+)\s*:"# - ) - let range = NSRange(source.startIndex..., in: source) - let matches = pattern.matches(in: source, range: range) - return Set(matches.compactMap { match -> String? in - guard let nameRange = Range(match.range(at: 1), in: source) else { return nil } - return String(source[nameRange]) - }) - } + return names + } + + /// Scans Error+UserFacingMessage.swift for `case let error as :` arms. + private func dispatchedTypeNames(in root: URL) throws -> Set { + let dispatchFile = root.appendingPathComponent( + "MC1/Extensions/Errors/Error+UserFacingMessage.swift" + ) + let source = try String(contentsOf: dispatchFile, encoding: .utf8) + let pattern = try NSRegularExpression( + pattern: #"case\s+let\s+\w+\s+as\s+(\w+)\s*:"# + ) + let range = NSRange(source.startIndex..., in: source) + let matches = pattern.matches(in: source, range: range) + return Set(matches.compactMap { match -> String? in + guard let nameRange = Range(match.range(at: 1), in: source) else { return nil } + return String(source[nameRange]) + }) + } } diff --git a/MC1Tests/Extensions/ErrorUserFacingMessageTests.swift b/MC1Tests/Extensions/ErrorUserFacingMessageTests.swift index d4ccddc4..927815cc 100644 --- a/MC1Tests/Extensions/ErrorUserFacingMessageTests.swift +++ b/MC1Tests/Extensions/ErrorUserFacingMessageTests.swift @@ -1,277 +1,276 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing struct ErrorUserFacingMessageTests { - - // MARK: - Dispatch Through `any Error` - - @Test func meshCoreErrorDispatchesToConcreteMapping() { - let error: any Error = MeshCoreError.timeout - #expect(error.userFacingMessage == L10n.Localizable.Error.MeshCore.timeout) - } - - @Test func protocolErrorDispatchesToConcreteMapping() { - let error: any Error = ProtocolError.tableFull - #expect(error.userFacingMessage == L10n.Localizable.Error.Device.storageFull) - } - - @Test func timeoutErrorDispatchesToConcreteMapping() { - let error: any Error = TimeoutError(operationName: "sync", timeout: .seconds(5)) - #expect(error.userFacingMessage == L10n.Localizable.Error.Timeout.operationTimedOut) - } - - @Test func appBackupErrorDispatchesToConcreteMapping() { - let error: any Error = AppBackupError.invalidFile - #expect(error.userFacingMessage == AppBackupError.invalidFile.userFacingMessage) - } - - @Test func bleErrorDispatchesToConcreteMapping() { - let detail = "peripheral unreachable" - let error: any Error = BLEError.connectionFailed(detail) - #expect(error.userFacingMessage == L10n.Localizable.Error.Ble.connectionFailed(detail)) - } - - @Test func connectionErrorDispatchesToConcreteMapping() { - let reason = "handshake rejected" - let error: any Error = ConnectionError.initializationFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.Connection.initializationFailed(reason)) - } - - @Test func wifiTransportErrorDispatchesToConcreteMapping() { - let error: any Error = WiFiTransportError.invalidHost - #expect(error.userFacingMessage == L10n.Localizable.Error.Wifi.invalidHost) - } - - @Test func accessorySetupKitErrorDispatchesToConcreteMapping() { - let reason = "user declined" - let error: any Error = AccessorySetupKitError.pairingFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.AccessorySetup.pairingFailed(reason)) - } - - @Test func contactServiceErrorDispatchesToConcreteMapping() { - let error: any Error = ContactServiceError.contactTableFull - #expect(error.userFacingMessage == L10n.Localizable.Error.ContactService.contactTableFull) - } - - @Test func messageServiceErrorDispatchesToConcreteMapping() { - let reason = "queue rejected" - let error: any Error = MessageServiceError.sendFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.MessageService.sendFailed) - #expect(!error.userFacingMessage.contains(reason)) - } - - @Test func channelServiceErrorDispatchesToConcreteMapping() { - let failureCount = 3 - let error: any Error = ChannelServiceError.circuitBreakerOpen(consecutiveFailures: failureCount) - #expect(error.userFacingMessage == L10n.Localizable.Error.ChannelService.circuitBreakerOpen(failureCount)) - } - - @Test func chatSendQueueServiceErrorDispatchesToConcreteMapping() { - let error: any Error = ChatSendQueueServiceError.notConnected - #expect(error.userFacingMessage == L10n.Localizable.Error.ChatSendQueue.notConnected) - } - - @Test func messagePollingErrorDispatchesToConcreteMapping() { - let error: any Error = MessagePollingError.pollingFailed - #expect(error.userFacingMessage == L10n.Localizable.Error.MessagePolling.pollingFailed) - } - - @Test func advertisementErrorDispatchesToConcreteMapping() { - let error: any Error = AdvertisementError.sendFailed - #expect(error.userFacingMessage == L10n.Localizable.Error.Advertisement.sendFailed) - } - - @Test func remoteNodeErrorDispatchesToConcreteMapping() { - let reason = "authentication failed" - let error: any Error = RemoteNodeError.loginFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.RemoteNode.loginFailed) - #expect(!error.userFacingMessage.contains(reason)) - } - - @Test func roomServerErrorDispatchesToConcreteMapping() { - let error: any Error = RoomServerError.sessionNotFound - #expect(error.userFacingMessage == L10n.Localizable.Error.RoomServer.sessionNotFound) - } - - @Test func roomServerSendFailedDropsRawReason() { - let reason = "Retry already in progress" - let error: any Error = RoomServerError.sendFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.RoomServer.sendFailed) - #expect(!error.userFacingMessage.contains(reason)) - } - - @Test func binaryProtocolErrorDispatchesToConcreteMapping() { - let error: any Error = BinaryProtocolError.timeout - #expect(error.userFacingMessage == L10n.Localizable.Error.BinaryProtocol.timeout) - } - - @Test func persistenceStoreErrorDispatchesToConcreteMapping() { - let reason = "store unavailable" - let error: any Error = PersistenceStoreError.saveFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.Persistence.saveFailed(reason)) - } - - @Test func syncCoordinatorErrorDispatchesToConcreteMapping() { - let error: any Error = SyncCoordinatorError.alreadySyncing - #expect(error.userFacingMessage == L10n.Localizable.Error.SyncCoordinator.alreadySyncing) - } - - @Test func deviceServiceErrorDispatchesToConcreteMapping() { - let reason = "write rejected" - let error: any Error = DeviceServiceError.persistenceFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.DeviceService.persistenceFailed(reason)) - } - - @Test func settingsServiceErrorDispatchesToConcreteMapping() { - let expected = "915.5" - let actual = "868.0" - let error: any Error = SettingsServiceError.verificationFailed(expected: expected, actual: actual) - #expect(error.userFacingMessage == L10n.Localizable.Error.Settings.verificationFailed(expected, actual)) - } - - @Test func keychainErrorDispatchesToConcreteMapping() { - let status: OSStatus = errSecDuplicateItem - let error: any Error = KeychainError.storageFailed(status) - #expect(error.userFacingMessage == L10n.Localizable.Error.Keychain.storageFailed(Int(status))) - } - - @Test func keyGenerationErrorDispatchesToConcreteMapping() { - let error: any Error = KeyGenerationError.reservedPrefix - #expect(error.userFacingMessage == L10n.Localizable.Error.KeyGeneration.reservedPrefix) - } - - @Test func nodeConfigServiceErrorDispatchesToConcreteMapping() { - let channelIndex = 2 - let hexLength = 30 - let error: any Error = NodeConfigServiceError.invalidChannelSecret(index: channelIndex, hexLength: hexLength) - #expect( - error.userFacingMessage - == L10n.Settings.ConfigImport.Error.invalidChannelSecret(channelIndex, hexLength) - ) - } - - @Test func storeServiceErrorDispatchesToConcreteMapping() { - let reason = "storefront rejected" - let error: any Error = StoreServiceError.purchaseFailed(reason: reason) - #expect(error.userFacingMessage == L10n.Settings.Support.Error.purchaseFailed(reason)) - } - - @Test func deviceGPSVerificationFailedPicksBooleanVariantKey() { - #expect( - SettingsServiceError.deviceGPSVerificationFailed(expectedEnabled: true, actualEnabled: false) - .userFacingMessage == L10n.Localizable.Error.Settings.gpsNotSavedExpectedOn - ) - #expect( - SettingsServiceError.deviceGPSVerificationFailed(expectedEnabled: false, actualEnabled: true) - .userFacingMessage == L10n.Localizable.Error.Settings.gpsNotSavedExpectedOff - ) - } - - @Test func unmappedErrorFallsBackToLocalizedDescription() { - let description = "Something went wrong" - let error: any Error = NSError( - domain: "MC1Tests", - code: 1, - userInfo: [NSLocalizedDescriptionKey: description] - ) - #expect(error.userFacingMessage == description) - } - - // MARK: - Service Error sessionError Delegation - - @Test func sessionErrorDelegatesToCentralMeshCoreMapping() { - #expect( - (ContactServiceError.sessionError(.timeout) as any Error).userFacingMessage - == L10n.Localizable.Error.MeshCore.timeout - ) - #expect( - MessageServiceError.sessionError(.notConnected).userFacingMessage - == L10n.Localizable.Error.MeshCore.notConnected - ) - #expect( - ChannelServiceError.sessionError(.sessionNotStarted).userFacingMessage - == L10n.Localizable.Error.MeshCore.sessionNotStarted - ) - #expect( - MessagePollingError.sessionError(.bluetoothPoweredOff).userFacingMessage - == L10n.Localizable.Error.MeshCore.bluetoothPoweredOff + // MARK: - Dispatch Through `any Error` + + @Test func `mesh core error dispatches to concrete mapping`() { + let error: any Error = MeshCoreError.timeout + #expect(error.userFacingMessage == L10n.Localizable.Error.MeshCore.timeout) + } + + @Test func `protocol error dispatches to concrete mapping`() { + let error: any Error = ProtocolError.tableFull + #expect(error.userFacingMessage == L10n.Localizable.Error.Device.storageFull) + } + + @Test func `timeout error dispatches to concrete mapping`() { + let error: any Error = TimeoutError(operationName: "sync", timeout: .seconds(5)) + #expect(error.userFacingMessage == L10n.Localizable.Error.Timeout.operationTimedOut) + } + + @Test func `app backup error dispatches to concrete mapping`() { + let error: any Error = AppBackupError.invalidFile + #expect(error.userFacingMessage == AppBackupError.invalidFile.userFacingMessage) + } + + @Test func `ble error dispatches to concrete mapping`() { + let detail = "peripheral unreachable" + let error: any Error = BLEError.connectionFailed(detail) + #expect(error.userFacingMessage == L10n.Localizable.Error.Ble.connectionFailed(detail)) + } + + @Test func `connection error dispatches to concrete mapping`() { + let reason = "handshake rejected" + let error: any Error = ConnectionError.initializationFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.Connection.initializationFailed(reason)) + } + + @Test func `wifi transport error dispatches to concrete mapping`() { + let error: any Error = WiFiTransportError.invalidHost + #expect(error.userFacingMessage == L10n.Localizable.Error.Wifi.invalidHost) + } + + @Test func `accessory setup kit error dispatches to concrete mapping`() { + let reason = "user declined" + let error: any Error = AccessorySetupKitError.pairingFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.AccessorySetup.pairingFailed(reason)) + } + + @Test func `contact service error dispatches to concrete mapping`() { + let error: any Error = ContactServiceError.contactTableFull + #expect(error.userFacingMessage == L10n.Localizable.Error.ContactService.contactTableFull) + } + + @Test func `message service error dispatches to concrete mapping`() { + let reason = "queue rejected" + let error: any Error = MessageServiceError.sendFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.MessageService.sendFailed) + #expect(!error.userFacingMessage.contains(reason)) + } + + @Test func `channel service error dispatches to concrete mapping`() { + let failureCount = 3 + let error: any Error = ChannelServiceError.circuitBreakerOpen(consecutiveFailures: failureCount) + #expect(error.userFacingMessage == L10n.Localizable.Error.ChannelService.circuitBreakerOpen(failureCount)) + } + + @Test func `chat send queue service error dispatches to concrete mapping`() { + let error: any Error = ChatSendQueueServiceError.notConnected + #expect(error.userFacingMessage == L10n.Localizable.Error.ChatSendQueue.notConnected) + } + + @Test func `message polling error dispatches to concrete mapping`() { + let error: any Error = MessagePollingError.pollingFailed + #expect(error.userFacingMessage == L10n.Localizable.Error.MessagePolling.pollingFailed) + } + + @Test func `advertisement error dispatches to concrete mapping`() { + let error: any Error = AdvertisementError.sendFailed + #expect(error.userFacingMessage == L10n.Localizable.Error.Advertisement.sendFailed) + } + + @Test func `remote node error dispatches to concrete mapping`() { + let reason = "authentication failed" + let error: any Error = RemoteNodeError.loginFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.RemoteNode.loginFailed) + #expect(!error.userFacingMessage.contains(reason)) + } + + @Test func `room server error dispatches to concrete mapping`() { + let error: any Error = RoomServerError.sessionNotFound + #expect(error.userFacingMessage == L10n.Localizable.Error.RoomServer.sessionNotFound) + } + + @Test func `room server send failed drops raw reason`() { + let reason = "Retry already in progress" + let error: any Error = RoomServerError.sendFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.RoomServer.sendFailed) + #expect(!error.userFacingMessage.contains(reason)) + } + + @Test func `binary protocol error dispatches to concrete mapping`() { + let error: any Error = BinaryProtocolError.timeout + #expect(error.userFacingMessage == L10n.Localizable.Error.BinaryProtocol.timeout) + } + + @Test func `persistence store error dispatches to concrete mapping`() { + let reason = "store unavailable" + let error: any Error = PersistenceStoreError.saveFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.Persistence.saveFailed(reason)) + } + + @Test func `sync coordinator error dispatches to concrete mapping`() { + let error: any Error = SyncCoordinatorError.alreadySyncing + #expect(error.userFacingMessage == L10n.Localizable.Error.SyncCoordinator.alreadySyncing) + } + + @Test func `device service error dispatches to concrete mapping`() { + let reason = "write rejected" + let error: any Error = DeviceServiceError.persistenceFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.DeviceService.persistenceFailed(reason)) + } + + @Test func `settings service error dispatches to concrete mapping`() { + let expected = "915.5" + let actual = "868.0" + let error: any Error = SettingsServiceError.verificationFailed(expected: expected, actual: actual) + #expect(error.userFacingMessage == L10n.Localizable.Error.Settings.verificationFailed(expected, actual)) + } + + @Test func `keychain error dispatches to concrete mapping`() { + let status: OSStatus = errSecDuplicateItem + let error: any Error = KeychainError.storageFailed(status) + #expect(error.userFacingMessage == L10n.Localizable.Error.Keychain.storageFailed(Int(status))) + } + + @Test func `key generation error dispatches to concrete mapping`() { + let error: any Error = KeyGenerationError.reservedPrefix + #expect(error.userFacingMessage == L10n.Localizable.Error.KeyGeneration.reservedPrefix) + } + + @Test func `node config service error dispatches to concrete mapping`() { + let channelIndex = 2 + let hexLength = 30 + let error: any Error = NodeConfigServiceError.invalidChannelSecret(index: channelIndex, hexLength: hexLength) + #expect( + error.userFacingMessage + == L10n.Settings.ConfigImport.Error.invalidChannelSecret(channelIndex, hexLength) + ) + } + + @Test func `store service error dispatches to concrete mapping`() { + let reason = "storefront rejected" + let error: any Error = StoreServiceError.purchaseFailed(reason: reason) + #expect(error.userFacingMessage == L10n.Settings.Support.Error.purchaseFailed(reason)) + } + + @Test func `device GPS verification failed picks boolean variant key`() { + #expect( + SettingsServiceError.deviceGPSVerificationFailed(expectedEnabled: true, actualEnabled: false) + .userFacingMessage == L10n.Localizable.Error.Settings.gpsNotSavedExpectedOn + ) + #expect( + SettingsServiceError.deviceGPSVerificationFailed(expectedEnabled: false, actualEnabled: true) + .userFacingMessage == L10n.Localizable.Error.Settings.gpsNotSavedExpectedOff + ) + } + + @Test func `unmapped error falls back to localized description`() { + let description = "Something went wrong" + let error: any Error = NSError( + domain: "MC1Tests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: description] + ) + #expect(error.userFacingMessage == description) + } + + // MARK: - Service Error sessionError Delegation + + @Test func `session error delegates to central mesh core mapping`() { + #expect( + (ContactServiceError.sessionError(.timeout) as any Error).userFacingMessage + == L10n.Localizable.Error.MeshCore.timeout + ) + #expect( + MessageServiceError.sessionError(.notConnected).userFacingMessage + == L10n.Localizable.Error.MeshCore.notConnected + ) + #expect( + ChannelServiceError.sessionError(.sessionNotStarted).userFacingMessage + == L10n.Localizable.Error.MeshCore.sessionNotStarted + ) + #expect( + MessagePollingError.sessionError(.bluetoothPoweredOff).userFacingMessage + == L10n.Localizable.Error.MeshCore.bluetoothPoweredOff + ) + #expect( + AdvertisementError.sessionError(.featureDisabled).userFacingMessage + == L10n.Localizable.Error.MeshCore.featureDisabled + ) + #expect( + RemoteNodeError.sessionError(.timeout).userFacingMessage + == L10n.Localizable.Error.MeshCore.timeout + ) + #expect( + RoomServerError.sessionError(.notConnected).userFacingMessage + == L10n.Localizable.Error.MeshCore.notConnected + ) + #expect( + BinaryProtocolError.sessionError(.sessionNotStarted).userFacingMessage + == L10n.Localizable.Error.MeshCore.sessionNotStarted + ) + #expect( + SettingsServiceError.sessionError(.timeout).userFacingMessage + == L10n.Localizable.Error.MeshCore.timeout + ) + } + + @Test func `persist failed recurses into underlying error`() { + let underlying = MessageServiceError.notConnected + #expect( + ChatSendQueueServiceError.persistFailed(underlying: underlying).userFacingMessage + == L10n.Localizable.Error.ChatSendQueue.persistFailed( + L10n.Localizable.Error.MessageService.notConnected ) - #expect( - AdvertisementError.sessionError(.featureDisabled).userFacingMessage - == L10n.Localizable.Error.MeshCore.featureDisabled + ) + } + + // MARK: - MeshCoreError Mapping + + @Test func `interpolated cases carry associated values`() { + let detail = "bad frame" + #expect( + MeshCoreError.parseError(detail).userFacingMessage + == L10n.Localizable.Error.MeshCore.parseError(detail) + ) + #expect( + MeshCoreError.dataTooLarge(maxSize: 184, actualSize: 200).userFacingMessage + == L10n.Localizable.Error.MeshCore.dataTooLarge(200, 184) + ) + } + + @Test func `device error maps known codes through protocol error`() { + for protocolError in [ProtocolError.unsupportedCommand, .notFound, .tableFull, .badState, .fileIOError, .illegalArgument] { + #expect( + MeshCoreError.deviceError(code: protocolError.rawValue).userFacingMessage + == protocolError.userFacingMessage + ) + } + } + + @Test func `device error falls back for unknown code`() { + let unknownCode: UInt8 = 0x42 + #expect( + MeshCoreError.deviceError(code: unknownCode).userFacingMessage + == L10n.Localizable.Error.Device.unknown(Int(unknownCode)) + ) + } + + @Test func `connection lost recurses into underlying error`() { + let underlying = MeshCoreError.bluetoothPoweredOff + #expect( + MeshCoreError.connectionLost(underlying: underlying).userFacingMessage + == L10n.Localizable.Error.MeshCore.connectionLost( + L10n.Localizable.Error.MeshCore.bluetoothPoweredOff ) - #expect( - RemoteNodeError.sessionError(.timeout).userFacingMessage - == L10n.Localizable.Error.MeshCore.timeout - ) - #expect( - RoomServerError.sessionError(.notConnected).userFacingMessage - == L10n.Localizable.Error.MeshCore.notConnected - ) - #expect( - BinaryProtocolError.sessionError(.sessionNotStarted).userFacingMessage - == L10n.Localizable.Error.MeshCore.sessionNotStarted - ) - #expect( - SettingsServiceError.sessionError(.timeout).userFacingMessage - == L10n.Localizable.Error.MeshCore.timeout - ) - } - - @Test func persistFailedRecursesIntoUnderlyingError() { - let underlying = MessageServiceError.notConnected - #expect( - ChatSendQueueServiceError.persistFailed(underlying: underlying).userFacingMessage - == L10n.Localizable.Error.ChatSendQueue.persistFailed( - L10n.Localizable.Error.MessageService.notConnected - ) - ) - } - - // MARK: - MeshCoreError Mapping - - @Test func interpolatedCasesCarryAssociatedValues() { - let detail = "bad frame" - #expect( - MeshCoreError.parseError(detail).userFacingMessage - == L10n.Localizable.Error.MeshCore.parseError(detail) - ) - #expect( - MeshCoreError.dataTooLarge(maxSize: 184, actualSize: 200).userFacingMessage - == L10n.Localizable.Error.MeshCore.dataTooLarge(200, 184) - ) - } - - @Test func deviceErrorMapsKnownCodesThroughProtocolError() { - for protocolError in [ProtocolError.unsupportedCommand, .notFound, .tableFull, .badState, .fileIOError, .illegalArgument] { - #expect( - MeshCoreError.deviceError(code: protocolError.rawValue).userFacingMessage - == protocolError.userFacingMessage - ) - } - } - - @Test func deviceErrorFallsBackForUnknownCode() { - let unknownCode: UInt8 = 0x42 - #expect( - MeshCoreError.deviceError(code: unknownCode).userFacingMessage - == L10n.Localizable.Error.Device.unknown(Int(unknownCode)) - ) - } - - @Test func connectionLostRecursesIntoUnderlyingError() { - let underlying = MeshCoreError.bluetoothPoweredOff - #expect( - MeshCoreError.connectionLost(underlying: underlying).userFacingMessage - == L10n.Localizable.Error.MeshCore.connectionLost( - L10n.Localizable.Error.MeshCore.bluetoothPoweredOff - ) - ) - #expect( - MeshCoreError.connectionLost(underlying: nil).userFacingMessage - == L10n.Localizable.Error.MeshCore.connectionLostNoDetail - ) - } + ) + #expect( + MeshCoreError.connectionLost(underlying: nil).userFacingMessage + == L10n.Localizable.Error.MeshCore.connectionLostNoDetail + ) + } } diff --git a/MC1Tests/Formatters/MessagePathFormatterTests.swift b/MC1Tests/Formatters/MessagePathFormatterTests.swift index 524a601c..b467065d 100644 --- a/MC1Tests/Formatters/MessagePathFormatterTests.swift +++ b/MC1Tests/Formatters/MessagePathFormatterTests.swift @@ -1,160 +1,160 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("MessagePathFormatter Tests") struct MessagePathFormatterTests { - // MARK: - Direct Path Tests - - @Test("pathLength 0 with no pathNodes returns Flood (zero-hop flood)") - func floodPathLengthZero() { - let message = createMessage(pathLength: 0, pathNodes: nil) - let result = MessagePathFormatter.format(message) - #expect(result == L10n.Chats.Chats.Message.Path.flood) - } - - @Test("pathLength 0xFF returns Direct") - func directPathLengthMax() { - let message = createMessage(pathLength: 0xFF, pathNodes: nil) - let result = MessagePathFormatter.format(message) - #expect(result == L10n.Chats.Chats.Message.Path.direct) - } - - @Test("pathLength 1 with 0xFF destination marker returns Direct") - func directWithDestinationMarker() { - let message = createMessage(pathLength: 1, pathNodes: Data([0xFF])) - let result = MessagePathFormatter.format(message) - #expect(result == L10n.Chats.Chats.Message.Path.direct) - } - - // MARK: - Path Nodes Tests - - @Test("Single node path formats correctly") - func singleNode() { - let message = createMessage(pathLength: 1, pathNodes: Data([0xA3])) - let result = MessagePathFormatter.format(message) - #expect(result == "A3") - } - - @Test("Three node path formats with commas") - func threeNodes() { - let message = createMessage(pathLength: 3, pathNodes: Data([0xA3, 0x7F, 0x42])) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42") - } - - @Test("Four node path shows all nodes") - func fourNodes() { - let message = createMessage(pathLength: 4, pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2])) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42,B2") - } - - // MARK: - Truncation Tests - - @Test("Six node path shows all nodes (no truncation)") - func sixNodesNotTruncated() { - let message = createMessage( - pathLength: 6, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42,B2,C1,D4") - } - - @Test("Seven node path truncates with ellipsis") - func sevenNodesTruncated() { - let message = createMessage( - pathLength: 7, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42…C1,D4,E5") - } - - @Test("Ten node path truncates correctly") - func tenNodesTruncated() { - let message = createMessage( - pathLength: 10, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5, 0xF6, 0x11, 0x22]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42…F6,11,22") - } - - // MARK: - Boundary & Edge Case Tests - - @Test("Five node path shows all nodes (boundary before truncation)") - func fiveNodesNoTruncation() { - let message = createMessage( - pathLength: 5, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42,B2,C1") - } - - @Test("Zero-byte node formats correctly") - func zeroByteNode() { - let message = createMessage(pathLength: 2, pathNodes: Data([0x00, 0xA3])) - let result = MessagePathFormatter.format(message) - #expect(result == "00,A3") - } - - // MARK: - Fallback Tests - - @Test("Missing pathNodes on flood-routed returns Flood") - func fallbackToFlood() { - let message = createMessage(pathLength: 3, pathNodes: nil) - let result = MessagePathFormatter.format(message) - #expect(result == L10n.Chats.Chats.Message.Path.flood) - } - - // MARK: - Edge Case Tests - - @Test("pathLength doesn't match pathNodes count - shows actual nodes") - func mismatchedPathData() { - // pathLength says 5, but only 3 nodes in data - should show what we have - let message = createMessage(pathLength: 5, pathNodes: Data([0xA3, 0x7F, 0x42])) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42") - } - - // MARK: - Multibyte Hash Mode Tests - - @Test("Mode-1 path chunks bytes into 2-byte hops") - func mode1TwoByteHops() { - // 0x42 = mode 1 (2 bytes/hop), 2 hops → 4 wire bytes → ["A1B2","C3D4"]. - let message = createMessage( - pathLength: 0x42, - pathNodes: Data([0xA1, 0xB2, 0xC3, 0xD4]) - ) - #expect(MessagePathFormatter.format(message) == "A1B2,C3D4") - } - - @Test("Mode-2 path chunks bytes into 3-byte hops") - func mode2ThreeByteHops() { - // 0x82 = mode 2 (3 bytes/hop), 2 hops → 6 wire bytes → ["010203","040506"]. - let message = createMessage( - pathLength: 0x82, - pathNodes: Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]) - ) - #expect(MessagePathFormatter.format(message) == "010203,040506") - } - - // MARK: - Helper - - private func createMessage(pathLength: UInt8, pathNodes: Data?) -> MessageDTO { - let message = Message( - radioID: UUID(), - contactID: UUID(), - text: "Test", - directionRawValue: MessageDirection.incoming.rawValue, - statusRawValue: MessageStatus.delivered.rawValue, - pathLength: pathLength, - pathNodes: pathNodes - ) - return MessageDTO(from: message) - } + // MARK: - Direct Path Tests + + @Test + func `pathLength 0 with no pathNodes returns Flood (zero-hop flood)`() { + let message = createMessage(pathLength: 0, pathNodes: nil) + let result = MessagePathFormatter.format(message) + #expect(result == L10n.Chats.Chats.Message.Path.flood) + } + + @Test + func `pathLength 0xFF returns Direct`() { + let message = createMessage(pathLength: 0xFF, pathNodes: nil) + let result = MessagePathFormatter.format(message) + #expect(result == L10n.Chats.Chats.Message.Path.direct) + } + + @Test + func `pathLength 1 with 0xFF destination marker returns Direct`() { + let message = createMessage(pathLength: 1, pathNodes: Data([0xFF])) + let result = MessagePathFormatter.format(message) + #expect(result == L10n.Chats.Chats.Message.Path.direct) + } + + // MARK: - Path Nodes Tests + + @Test + func `Single node path formats correctly`() { + let message = createMessage(pathLength: 1, pathNodes: Data([0xA3])) + let result = MessagePathFormatter.format(message) + #expect(result == "A3") + } + + @Test + func `Three node path formats with commas`() { + let message = createMessage(pathLength: 3, pathNodes: Data([0xA3, 0x7F, 0x42])) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42") + } + + @Test + func `Four node path shows all nodes`() { + let message = createMessage(pathLength: 4, pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2])) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42,B2") + } + + // MARK: - Truncation Tests + + @Test + func `Six node path shows all nodes (no truncation)`() { + let message = createMessage( + pathLength: 6, + pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4]) + ) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42,B2,C1,D4") + } + + @Test + func `Seven node path truncates with ellipsis`() { + let message = createMessage( + pathLength: 7, + pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5]) + ) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42…C1,D4,E5") + } + + @Test + func `Ten node path truncates correctly`() { + let message = createMessage( + pathLength: 10, + pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5, 0xF6, 0x11, 0x22]) + ) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42…F6,11,22") + } + + // MARK: - Boundary & Edge Case Tests + + @Test + func `Five node path shows all nodes (boundary before truncation)`() { + let message = createMessage( + pathLength: 5, + pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1]) + ) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42,B2,C1") + } + + @Test + func `Zero-byte node formats correctly`() { + let message = createMessage(pathLength: 2, pathNodes: Data([0x00, 0xA3])) + let result = MessagePathFormatter.format(message) + #expect(result == "00,A3") + } + + // MARK: - Fallback Tests + + @Test + func `Missing pathNodes on flood-routed returns Flood`() { + let message = createMessage(pathLength: 3, pathNodes: nil) + let result = MessagePathFormatter.format(message) + #expect(result == L10n.Chats.Chats.Message.Path.flood) + } + + // MARK: - Edge Case Tests + + @Test + func `pathLength doesn't match pathNodes count - shows actual nodes`() { + // pathLength says 5, but only 3 nodes in data - should show what we have + let message = createMessage(pathLength: 5, pathNodes: Data([0xA3, 0x7F, 0x42])) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42") + } + + // MARK: - Multibyte Hash Mode Tests + + @Test + func `Mode-1 path chunks bytes into 2-byte hops`() { + // 0x42 = mode 1 (2 bytes/hop), 2 hops → 4 wire bytes → ["A1B2","C3D4"]. + let message = createMessage( + pathLength: 0x42, + pathNodes: Data([0xA1, 0xB2, 0xC3, 0xD4]) + ) + #expect(MessagePathFormatter.format(message) == "A1B2,C3D4") + } + + @Test + func `Mode-2 path chunks bytes into 3-byte hops`() { + // 0x82 = mode 2 (3 bytes/hop), 2 hops → 6 wire bytes → ["010203","040506"]. + let message = createMessage( + pathLength: 0x82, + pathNodes: Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]) + ) + #expect(MessagePathFormatter.format(message) == "010203,040506") + } + + // MARK: - Helper + + private func createMessage(pathLength: UInt8, pathNodes: Data?) -> MessageDTO { + let message = Message( + radioID: UUID(), + contactID: UUID(), + text: "Test", + directionRawValue: MessageDirection.incoming.rawValue, + statusRawValue: MessageStatus.delivered.rawValue, + pathLength: pathLength, + pathNodes: pathNodes + ) + return MessageDTO(from: message) + } } diff --git a/MC1Tests/Helpers/StoreKitTestPurchase.swift b/MC1Tests/Helpers/StoreKitTestPurchase.swift index ff73ef6e..1557f28f 100644 --- a/MC1Tests/Helpers/StoreKitTestPurchase.swift +++ b/MC1Tests/Helpers/StoreKitTestPurchase.swift @@ -1,5 +1,5 @@ -import StoreKit @testable import MC1Services +import StoreKit /// Retries `StoreService.purchase` past the transient `StoreKitError.unknown` that storekitd raises /// intermittently under SKTestSession churn (it surfaces as `.purchaseFailed`). A real purchase @@ -7,16 +7,16 @@ import StoreKit /// every SKTestSession suite so setup purchases don't flake under `make test-store`. @MainActor func purchaseWithRetry( - _ product: Product, - on service: StoreService, - attempts: Int = 4 + _ product: Product, + on service: StoreService, + attempts: Int = 4 ) async throws -> StorePurchaseOutcome { - for attempt in 1...attempts { - do { - return try await service.purchase(product) - } catch let error as StoreServiceError { - guard case .purchaseFailed = error, attempt < attempts else { throw error } - } + for attempt in 1...attempts { + do { + return try await service.purchase(product) + } catch let error as StoreServiceError { + guard case .purchaseFailed = error, attempt < attempts else { throw error } } - throw StoreServiceError.purchaseFailed(reason: "purchase retries exhausted") + } + throw StoreServiceError.purchaseFailed(reason: "purchase retries exhausted") } diff --git a/MC1Tests/Helpers/TestHelpers.swift b/MC1Tests/Helpers/TestHelpers.swift index 6c9b3365..84719505 100644 --- a/MC1Tests/Helpers/TestHelpers.swift +++ b/MC1Tests/Helpers/TestHelpers.swift @@ -4,9 +4,9 @@ import Foundation /// This is needed because Swift's strict concurrency does not allow capturing /// `var` in async closures that may execute concurrently. public final class MutableBox: @unchecked Sendable { - public var value: T + public var value: T - public init(_ value: T) { - self.value = value - } + public init(_ value: T) { + self.value = value + } } diff --git a/MC1Tests/Helpers/TestPolling.swift b/MC1Tests/Helpers/TestPolling.swift index e9b93bdb..cb90223d 100644 --- a/MC1Tests/Helpers/TestPolling.swift +++ b/MC1Tests/Helpers/TestPolling.swift @@ -2,22 +2,22 @@ import Foundation /// Polls a condition until it becomes true or a timeout expires. func waitUntil( - timeout: Duration = .seconds(2), - pollingInterval: Duration = .milliseconds(10), - _ message: String = "waitUntil timed out", - condition: @escaping @MainActor () async -> Bool + timeout: Duration = .seconds(2), + pollingInterval: Duration = .milliseconds(10), + _ message: String = "waitUntil timed out", + condition: @escaping @MainActor () async -> Bool ) async throws { - let deadline = ContinuousClock.now + timeout - - while ContinuousClock.now < deadline { - if await condition() { return } - try await Task.sleep(for: pollingInterval) - } + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { if await condition() { return } + try await Task.sleep(for: pollingInterval) + } + + if await condition() { return } - struct WaitTimeoutError: Error, CustomStringConvertible { - let description: String - } - throw WaitTimeoutError(description: message) + struct WaitTimeoutError: Error, CustomStringConvertible { + let description: String + } + throw WaitTimeoutError(description: message) } diff --git a/MC1Tests/Intents/EntityIdentityTests.swift b/MC1Tests/Intents/EntityIdentityTests.swift index a729b8d0..00fe9bca 100644 --- a/MC1Tests/Intents/EntityIdentityTests.swift +++ b/MC1Tests/Intents/EntityIdentityTests.swift @@ -1,8 +1,8 @@ import CryptoKit import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing /// The App Intents entity ids are persisted by the framework inside user-saved /// shortcuts, so they must be stable across backup export/import (which re-mints @@ -12,188 +12,187 @@ import Testing /// failing safe to nil rather than resolving against the wrong radio. @MainActor struct EntityIdentityTests { - - private static let radioID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - private static let otherRadioID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! - - private static func makeContact( - rowID: UUID = UUID(), - radioID: UUID = radioID, - publicKey: Data, - name: String = "Alice" - ) -> ContactDTO { - ContactDTO( - id: rowID, - radioID: radioID, - publicKey: publicKey, - name: name, - typeRawValue: 0x01, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - private static func makeChannel( - rowID: UUID = UUID(), - radioID: UUID = radioID, - index: UInt8 = 0, - name: String = "Ops", - secret: Data - ) -> ChannelDTO { - ChannelDTO( - id: rowID, - radioID: radioID, - index: index, - name: name, - secret: secret, - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false, - floodScope: .inherit - ) - } - - // MARK: - Composite id round-trip - - @Test func compositeIDRoundTrips() { - let keyHex = Data(repeating: 0xAB, count: 32).hexString - let id = formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: keyHex) - - let parsed = parseCompositeID(id) - #expect(parsed?.radioID == Self.radioID) - #expect(parsed?.kind == .contact) - #expect(parsed?.keyHex == keyHex) - } - - @Test(arguments: [ - "", // empty - "not-a-uuid/contact/abc", // bad radio scope - "11111111-1111-1111-1111-111111111111", // missing kind + key - "11111111-1111-1111-1111-111111111111/contact", // missing key - "11111111-1111-1111-1111-111111111111/contact/", // empty key - "11111111-1111-1111-1111-111111111111/bogus/abc" // unknown kind - ]) - func malformedCompositeIDFailsSafe(_ id: String) { - #expect(parseCompositeID(id) == nil) - } - - // MARK: - Contact identity - - @Test func contactIDDerivesFromPublicKeyNotRowID() { - let publicKey = Data(repeating: 0xCD, count: 32) - let first = MessageTargetEntity(dto: Self.makeContact(rowID: UUID(), publicKey: publicKey)) - let second = MessageTargetEntity(dto: Self.makeContact(rowID: UUID(), publicKey: publicKey)) - - // Same radio + public key must yield the same id even though the volatile - // row UUID differs (as it would after a backup export/import). - #expect(first.id == second.id) - #expect(first.id == formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: publicKey.hexString)) - #expect(first.kind == .contact) - } - - @Test func contactIDIsRadioScoped() { - let publicKey = Data(repeating: 0xCD, count: 32) - let here = MessageTargetEntity(dto: Self.makeContact(radioID: Self.radioID, publicKey: publicKey)) - let elsewhere = MessageTargetEntity(dto: Self.makeContact(radioID: Self.otherRadioID, publicKey: publicKey)) - - #expect(here.id != elsewhere.id) - } - - // MARK: - Channel identity - - @Test func channelIDDerivesFromSecretDigestNotRawSecretOrIndexOrRowID() { - let secret = Data(repeating: 0x5A, count: 16) - let first = MessageTargetEntity(dto: Self.makeChannel(rowID: UUID(), index: 0, secret: secret)) - let second = MessageTargetEntity(dto: Self.makeChannel(rowID: UUID(), index: 7, secret: secret)) - - // Stable across a relocated slot and a re-minted row id. - #expect(first.id == second.id) - #expect(first.kind == .channel) - - // The raw secret must never appear in the id (it is the live encryption key). - let secretHex = secret.hexString - #expect(!first.id.contains(secretHex)) - - // The key portion is the domain-separated digest, not the secret. - let parsed = parseCompositeID(first.id) - let expectedDigest = channelSecretDigestHex(radioID: Self.radioID, secret: secret) - #expect(parsed?.kind == .channel) - #expect(parsed?.keyHex == expectedDigest) - #expect(parsed?.keyHex != secretHex) - } - - // MARK: - Contact / channel ids never collide across kinds - - @Test func contactAndChannelIDsAreDistinctEvenWhenKeyMatches() { - // A contact public key and a channel digest are different lengths, but the - // kind segment guarantees the two id spaces never overlap regardless. - let contact = MessageTargetEntity(dto: Self.makeContact(publicKey: Data(repeating: 0x11, count: 32))) - let channel = MessageTargetEntity(dto: Self.makeChannel(secret: Data(repeating: 0x11, count: 16))) - #expect(contact.id != channel.id) - #expect(parseCompositeID(contact.id)?.kind == .contact) - #expect(parseCompositeID(channel.id)?.kind == .channel) - } - - @Test func channelDigestIsRadioScoped() { - let secret = Data(repeating: 0x5A, count: 16) - let here = channelSecretDigestHex(radioID: Self.radioID, secret: secret) - let elsewhere = channelSecretDigestHex(radioID: Self.otherRadioID, secret: secret) - - #expect(here != elsewhere) - } - - @Test func distinctChannelSecretsDoNotCollide() { - // Seed a radio's worth of channels with distinct secrets and assert every - // entity id is unique, so a digest never mis-routes a send to the wrong - // channel. - let channels = (0..<8).map { i in - Self.makeChannel(index: UInt8(i), secret: Data(repeating: UInt8(i), count: 16)) - } - let ids = channels.map { MessageTargetEntity(dto: $0).id } - #expect(Set(ids).count == ids.count) - } - - @Test func channelDigestLengthIsFixedWidth() { - let secret = Data(repeating: 0x5A, count: 16) - let digestHex = channelSecretDigestHex(radioID: Self.radioID, secret: secret) - // 16 bytes rendered as lowercase hex is 32 characters. - #expect(digestHex.count == 32) - } - - // MARK: - Persisted kind-segment string format - - @Test func kindRawValuesArePinnedStrings() { - // The raw value is the literal segment written into framework-persisted - // saved-shortcut ids; assert it independently of the enum symbol so a - // future case rename that changed the on-disk string is caught here. - #expect(MessageTargetKind.contact.rawValue == "contact") - #expect(MessageTargetKind.channel.rawValue == "channel") - } - - @Test func compositeIDEmbedsTheLiteralKindSegment() { - let contactID = formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: "ab") - let channelID = formatCompositeID(radioID: Self.radioID, kind: .channel, keyHex: "ab") - - #expect(contactID.contains("/contact/")) - #expect(channelID.contains("/channel/")) + private static let radioID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + private static let otherRadioID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + + private static func makeContact( + rowID: UUID = UUID(), + radioID: UUID = radioID, + publicKey: Data, + name: String = "Alice" + ) -> ContactDTO { + ContactDTO( + id: rowID, + radioID: radioID, + publicKey: publicKey, + name: name, + typeRawValue: 0x01, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + private static func makeChannel( + rowID: UUID = UUID(), + radioID: UUID = radioID, + index: UInt8 = 0, + name: String = "Ops", + secret: Data + ) -> ChannelDTO { + ChannelDTO( + id: rowID, + radioID: radioID, + index: index, + name: name, + secret: secret, + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false, + floodScope: .inherit + ) + } + + // MARK: - Composite id round-trip + + @Test func `composite ID round trips`() { + let keyHex = Data(repeating: 0xAB, count: 32).hexString + let id = formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: keyHex) + + let parsed = parseCompositeID(id) + #expect(parsed?.radioID == Self.radioID) + #expect(parsed?.kind == .contact) + #expect(parsed?.keyHex == keyHex) + } + + @Test(arguments: [ + "", // empty + "not-a-uuid/contact/abc", // bad radio scope + "11111111-1111-1111-1111-111111111111", // missing kind + key + "11111111-1111-1111-1111-111111111111/contact", // missing key + "11111111-1111-1111-1111-111111111111/contact/", // empty key + "11111111-1111-1111-1111-111111111111/bogus/abc" // unknown kind + ]) + func `malformed composite ID fails safe`(_ id: String) { + #expect(parseCompositeID(id) == nil) + } + + // MARK: - Contact identity + + @Test func `contact ID derives from public key not row ID`() { + let publicKey = Data(repeating: 0xCD, count: 32) + let first = MessageTargetEntity(dto: Self.makeContact(rowID: UUID(), publicKey: publicKey)) + let second = MessageTargetEntity(dto: Self.makeContact(rowID: UUID(), publicKey: publicKey)) + + // Same radio + public key must yield the same id even though the volatile + // row UUID differs (as it would after a backup export/import). + #expect(first.id == second.id) + #expect(first.id == formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: publicKey.hexString)) + #expect(first.kind == .contact) + } + + @Test func `contact ID is radio scoped`() { + let publicKey = Data(repeating: 0xCD, count: 32) + let here = MessageTargetEntity(dto: Self.makeContact(radioID: Self.radioID, publicKey: publicKey)) + let elsewhere = MessageTargetEntity(dto: Self.makeContact(radioID: Self.otherRadioID, publicKey: publicKey)) + + #expect(here.id != elsewhere.id) + } + + // MARK: - Channel identity + + @Test func `channel ID derives from secret digest not raw secret or index or row ID`() { + let secret = Data(repeating: 0x5A, count: 16) + let first = MessageTargetEntity(dto: Self.makeChannel(rowID: UUID(), index: 0, secret: secret)) + let second = MessageTargetEntity(dto: Self.makeChannel(rowID: UUID(), index: 7, secret: secret)) + + // Stable across a relocated slot and a re-minted row id. + #expect(first.id == second.id) + #expect(first.kind == .channel) + + // The raw secret must never appear in the id (it is the live encryption key). + let secretHex = secret.hexString + #expect(!first.id.contains(secretHex)) + + // The key portion is the domain-separated digest, not the secret. + let parsed = parseCompositeID(first.id) + let expectedDigest = channelSecretDigestHex(radioID: Self.radioID, secret: secret) + #expect(parsed?.kind == .channel) + #expect(parsed?.keyHex == expectedDigest) + #expect(parsed?.keyHex != secretHex) + } + + // MARK: - Contact / channel ids never collide across kinds + + @Test func `contact and channel I ds are distinct even when key matches`() { + // A contact public key and a channel digest are different lengths, but the + // kind segment guarantees the two id spaces never overlap regardless. + let contact = MessageTargetEntity(dto: Self.makeContact(publicKey: Data(repeating: 0x11, count: 32))) + let channel = MessageTargetEntity(dto: Self.makeChannel(secret: Data(repeating: 0x11, count: 16))) + #expect(contact.id != channel.id) + #expect(parseCompositeID(contact.id)?.kind == .contact) + #expect(parseCompositeID(channel.id)?.kind == .channel) + } + + @Test func `channel digest is radio scoped`() { + let secret = Data(repeating: 0x5A, count: 16) + let here = channelSecretDigestHex(radioID: Self.radioID, secret: secret) + let elsewhere = channelSecretDigestHex(radioID: Self.otherRadioID, secret: secret) + + #expect(here != elsewhere) + } + + @Test func `distinct channel secrets do not collide`() { + // Seed a radio's worth of channels with distinct secrets and assert every + // entity id is unique, so a digest never mis-routes a send to the wrong + // channel. + let channels = (0..<8).map { i in + Self.makeChannel(index: UInt8(i), secret: Data(repeating: UInt8(i), count: 16)) } + let ids = channels.map { MessageTargetEntity(dto: $0).id } + #expect(Set(ids).count == ids.count) + } + + @Test func `channel digest length is fixed width`() { + let secret = Data(repeating: 0x5A, count: 16) + let digestHex = channelSecretDigestHex(radioID: Self.radioID, secret: secret) + // 16 bytes rendered as lowercase hex is 32 characters. + #expect(digestHex.count == 32) + } + + // MARK: - Persisted kind-segment string format + + @Test func `kind raw values are pinned strings`() { + // The raw value is the literal segment written into framework-persisted + // saved-shortcut ids; assert it independently of the enum symbol so a + // future case rename that changed the on-disk string is caught here. + #expect(MessageTargetKind.contact.rawValue == "contact") + #expect(MessageTargetKind.channel.rawValue == "channel") + } + + @Test func `composite ID embeds the literal kind segment`() { + let contactID = formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: "ab") + let channelID = formatCompositeID(radioID: Self.radioID, kind: .channel, keyHex: "ab") + + #expect(contactID.contains("/contact/")) + #expect(channelID.contains("/channel/")) + } } /// Exercises the radio-scoped fetch and the digest disambiguation that @@ -203,171 +202,170 @@ struct EntityIdentityTests { /// scoping and fail-safe resolution without standing up a connection. @MainActor struct EntityQueryScopingTests { - - private static let radioA = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! - private static let radioB = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! - - private func makeStore() throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func makeContact(radioID: UUID, publicKey: Data, name: String, type: ContactType = .chat) -> ContactDTO { - ContactDTO( - id: UUID(), radioID: radioID, publicKey: publicKey, name: name, - typeRawValue: type.rawValue, flags: 0, outPathLength: 0, outPath: Data(), - lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0, - nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, - lastMessageDate: nil, unreadCount: 0 - ) - } - - private func makeChannel(radioID: UUID, index: UInt8, name: String, secret: Data) -> ChannelDTO { - ChannelDTO( - id: UUID(), radioID: radioID, index: index, name: name, secret: secret, - isEnabled: true, lastMessageDate: nil, unreadCount: 0 - ) - } - - @Test func fetchReturnsOnlyScopedRadioRows() async throws { - let store = try makeStore() - try await store.saveContact(makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0xA1, count: 32), name: "A-contact")) - try await store.saveContact(makeContact(radioID: Self.radioB, publicKey: Data(repeating: 0xB1, count: 32), name: "B-contact")) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "A-channel", secret: Data(repeating: 0xA2, count: 16))) - try await store.saveChannel(makeChannel(radioID: Self.radioB, index: 0, name: "B-channel", secret: Data(repeating: 0xB2, count: 16))) - - let contactsA = try await store.fetchContacts(radioID: Self.radioA) - let channelsA = try await store.fetchChannels(radioID: Self.radioA) - - #expect(contactsA.map(\.name) == ["A-contact"]) - #expect(channelsA.map(\.name) == ["A-channel"]) - #expect(contactsA.allSatisfy { $0.radioID == Self.radioA }) - #expect(channelsA.allSatisfy { $0.radioID == Self.radioA }) - } - - @Test func fetchIsEmptyForUnseededRadio() async throws { - // A `nil` current radio short-circuits to `[]` before any fetch; an - // unknown-but-non-nil radio likewise yields no rows. - let store = try makeStore() - try await store.saveContact(makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0xA1, count: 32), name: "A-contact")) - - let stranger = UUID() - #expect(try await store.fetchContacts(radioID: stranger).isEmpty) - #expect(try await store.fetchChannels(radioID: stranger).isEmpty) - } - - private func channelID(radioID: UUID, secret: Data) -> String { - formatCompositeID(radioID: radioID, kind: .channel, keyHex: channelSecretDigestHex(radioID: radioID, secret: secret)) - } - - @Test func channelResolveFailsSafeOnDuplicateDigest() async throws { - let store = try makeStore() - let secret = Data(repeating: 0x09, count: 16) - // Two distinct rows sharing a secret collide to one digest id; the resolver - // must refuse both rather than mis-route a send to an arbitrary one. - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Dup0", secret: secret)) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Dup1", secret: secret)) - - let id = channelID(radioID: Self.radioA, secret: secret) - let resolved = resolveUniqueChannels(matching: [id], in: try await store.fetchChannels(radioID: Self.radioA)) - - #expect(resolved.isEmpty) - } - - @Test func channelResolveFailsSafeOnZeroMatch() async throws { - let store = try makeStore() - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Only", secret: Data(repeating: 0x01, count: 16))) - - let unknownID = channelID(radioID: Self.radioA, secret: Data(repeating: 0xFE, count: 16)) - let resolved = resolveUniqueChannels(matching: [unknownID], in: try await store.fetchChannels(radioID: Self.radioA)) - - #expect(resolved.isEmpty) - } - - @Test func channelResolveAcceptsUniqueDigest() async throws { - let store = try makeStore() - let secret = Data(repeating: 0x33, count: 16) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Wanted", secret: secret)) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Other", secret: Data(repeating: 0x44, count: 16))) - - let id = channelID(radioID: Self.radioA, secret: secret) - let resolved = resolveUniqueChannels(matching: [id], in: try await store.fetchChannels(radioID: Self.radioA)) - - #expect(resolved.map { MessageTargetEntity(dto: $0).id } == [id]) - #expect(resolved.map(\.name) == ["Wanted"]) - } - - // MARK: - Merged resolution keeps the channel fail-safe - - @Test func mergedResolveExcludesDuplicateDigestChannel() async throws { - // The same duplicate-digest fail-safe must survive inside the merged - // contact+channel resolution, not just the channel helper, so a future - // collapse-to-one-filter refactor of the query fails loudly here. - let store = try makeStore() - let secret = Data(repeating: 0x07, count: 16) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Dup0", secret: secret)) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Dup1", secret: secret)) - - let id = channelID(radioID: Self.radioA, secret: secret) - let resolved = resolveMessageTargets( - matching: [id], - contacts: [], - channels: try await store.fetchChannels(radioID: Self.radioA) - ) - - #expect(resolved.isEmpty) - } - - // MARK: - Picker list is chat-only - - @Test func pickerListExcludesRepeaterAndRoomContacts() { - let contacts = [ - makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x01, count: 32), name: "Person", type: .chat), - makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x02, count: 32), name: "Repeater", type: .repeater), - makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x03, count: 32), name: "Room", type: .room) - ] - let channels = [makeChannel(radioID: Self.radioA, index: 0, name: "Ops", secret: Data(repeating: 0x0A, count: 16))] - - let targets = buildMessageTargets(contacts: chatContacts(contacts), channels: channels) - - let contactNames = targets.filter { $0.kind == .contact }.map(\.displayName) - #expect(contactNames == ["Person"]) - #expect(targets.contains { $0.kind == .channel && $0.displayName == "Ops" }) - } - - @Test func pickerListExcludesDuplicateDigestChannels() { - // Two slots sharing a secret collide to one unresolvable id. Listing - // either would offer a target the send path refuses with invalidRecipient, - // so the picker must drop both and keep only the uniquely resolvable one. - let sharedSecret = Data(repeating: 0x0B, count: 16) - let channels = [ - makeChannel(radioID: Self.radioA, index: 0, name: "DupA", secret: sharedSecret), - makeChannel(radioID: Self.radioA, index: 1, name: "DupB", secret: sharedSecret), - makeChannel(radioID: Self.radioA, index: 2, name: "Unique", secret: Data(repeating: 0x0C, count: 16)) - ] - - let targets = buildMessageTargets(contacts: [], channels: channels) - - #expect(targets.filter { $0.kind == .channel }.map(\.displayName) == ["Unique"]) - } - - // MARK: - Scope fail-safe (nil radio yields nil scope) - - @Test func scopeIsNilWhenBridgeHasNoAppState() { - // A pre-launch bridge that was never adopted has a nil appState, so the - // scope resolves nil and every caller maps that to an empty result. - let bridge = IntentBridge() - #expect(currentRadioScope(bridge)?.radioID == nil) - } - - @Test func scopeIsNilWhenAppStateHasNoCurrentRadio() { - // A fresh AppState with no connection has a nil currentRadioID, so even an - // adopted bridge resolves no scope rather than reading the wrong radio. - let bridge = IntentBridge() - let appState = AppState() - bridge.adopt(appState) - - #expect(appState.currentRadioID == nil) - #expect(currentRadioScope(bridge)?.radioID == nil) - } + private static let radioA = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + private static let radioB = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + + private func makeStore() throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func makeContact(radioID: UUID, publicKey: Data, name: String, type: ContactType = .chat) -> ContactDTO { + ContactDTO( + id: UUID(), radioID: radioID, publicKey: publicKey, name: name, + typeRawValue: type.rawValue, flags: 0, outPathLength: 0, outPath: Data(), + lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0, + nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, + lastMessageDate: nil, unreadCount: 0 + ) + } + + private func makeChannel(radioID: UUID, index: UInt8, name: String, secret: Data) -> ChannelDTO { + ChannelDTO( + id: UUID(), radioID: radioID, index: index, name: name, secret: secret, + isEnabled: true, lastMessageDate: nil, unreadCount: 0 + ) + } + + @Test func `fetch returns only scoped radio rows`() async throws { + let store = try makeStore() + try await store.saveContact(makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0xA1, count: 32), name: "A-contact")) + try await store.saveContact(makeContact(radioID: Self.radioB, publicKey: Data(repeating: 0xB1, count: 32), name: "B-contact")) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "A-channel", secret: Data(repeating: 0xA2, count: 16))) + try await store.saveChannel(makeChannel(radioID: Self.radioB, index: 0, name: "B-channel", secret: Data(repeating: 0xB2, count: 16))) + + let contactsA = try await store.fetchContacts(radioID: Self.radioA) + let channelsA = try await store.fetchChannels(radioID: Self.radioA) + + #expect(contactsA.map(\.name) == ["A-contact"]) + #expect(channelsA.map(\.name) == ["A-channel"]) + #expect(contactsA.allSatisfy { $0.radioID == Self.radioA }) + #expect(channelsA.allSatisfy { $0.radioID == Self.radioA }) + } + + @Test func `fetch is empty for unseeded radio`() async throws { + // A `nil` current radio short-circuits to `[]` before any fetch; an + // unknown-but-non-nil radio likewise yields no rows. + let store = try makeStore() + try await store.saveContact(makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0xA1, count: 32), name: "A-contact")) + + let stranger = UUID() + #expect(try await store.fetchContacts(radioID: stranger).isEmpty) + #expect(try await store.fetchChannels(radioID: stranger).isEmpty) + } + + private func channelID(radioID: UUID, secret: Data) -> String { + formatCompositeID(radioID: radioID, kind: .channel, keyHex: channelSecretDigestHex(radioID: radioID, secret: secret)) + } + + @Test func `channel resolve fails safe on duplicate digest`() async throws { + let store = try makeStore() + let secret = Data(repeating: 0x09, count: 16) + // Two distinct rows sharing a secret collide to one digest id; the resolver + // must refuse both rather than mis-route a send to an arbitrary one. + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Dup0", secret: secret)) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Dup1", secret: secret)) + + let id = channelID(radioID: Self.radioA, secret: secret) + let resolved = try await resolveUniqueChannels(matching: [id], in: store.fetchChannels(radioID: Self.radioA)) + + #expect(resolved.isEmpty) + } + + @Test func `channel resolve fails safe on zero match`() async throws { + let store = try makeStore() + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Only", secret: Data(repeating: 0x01, count: 16))) + + let unknownID = channelID(radioID: Self.radioA, secret: Data(repeating: 0xFE, count: 16)) + let resolved = try await resolveUniqueChannels(matching: [unknownID], in: store.fetchChannels(radioID: Self.radioA)) + + #expect(resolved.isEmpty) + } + + @Test func `channel resolve accepts unique digest`() async throws { + let store = try makeStore() + let secret = Data(repeating: 0x33, count: 16) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Wanted", secret: secret)) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Other", secret: Data(repeating: 0x44, count: 16))) + + let id = channelID(radioID: Self.radioA, secret: secret) + let resolved = try await resolveUniqueChannels(matching: [id], in: store.fetchChannels(radioID: Self.radioA)) + + #expect(resolved.map { MessageTargetEntity(dto: $0).id } == [id]) + #expect(resolved.map(\.name) == ["Wanted"]) + } + + // MARK: - Merged resolution keeps the channel fail-safe + + @Test func `merged resolve excludes duplicate digest channel`() async throws { + // The same duplicate-digest fail-safe must survive inside the merged + // contact+channel resolution, not just the channel helper, so a future + // collapse-to-one-filter refactor of the query fails loudly here. + let store = try makeStore() + let secret = Data(repeating: 0x07, count: 16) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Dup0", secret: secret)) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Dup1", secret: secret)) + + let id = channelID(radioID: Self.radioA, secret: secret) + let resolved = try await resolveMessageTargets( + matching: [id], + contacts: [], + channels: store.fetchChannels(radioID: Self.radioA) + ) + + #expect(resolved.isEmpty) + } + + // MARK: - Picker list is chat-only + + @Test func `picker list excludes repeater and room contacts`() { + let contacts = [ + makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x01, count: 32), name: "Person", type: .chat), + makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x02, count: 32), name: "Repeater", type: .repeater), + makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x03, count: 32), name: "Room", type: .room) + ] + let channels = [makeChannel(radioID: Self.radioA, index: 0, name: "Ops", secret: Data(repeating: 0x0A, count: 16))] + + let targets = buildMessageTargets(contacts: chatContacts(contacts), channels: channels) + + let contactNames = targets.filter { $0.kind == .contact }.map(\.displayName) + #expect(contactNames == ["Person"]) + #expect(targets.contains { $0.kind == .channel && $0.displayName == "Ops" }) + } + + @Test func `picker list excludes duplicate digest channels`() { + // Two slots sharing a secret collide to one unresolvable id. Listing + // either would offer a target the send path refuses with invalidRecipient, + // so the picker must drop both and keep only the uniquely resolvable one. + let sharedSecret = Data(repeating: 0x0B, count: 16) + let channels = [ + makeChannel(radioID: Self.radioA, index: 0, name: "DupA", secret: sharedSecret), + makeChannel(radioID: Self.radioA, index: 1, name: "DupB", secret: sharedSecret), + makeChannel(radioID: Self.radioA, index: 2, name: "Unique", secret: Data(repeating: 0x0C, count: 16)) + ] + + let targets = buildMessageTargets(contacts: [], channels: channels) + + #expect(targets.filter { $0.kind == .channel }.map(\.displayName) == ["Unique"]) + } + + // MARK: - Scope fail-safe (nil radio yields nil scope) + + @Test func `scope is nil when bridge has no app state`() { + // A pre-launch bridge that was never adopted has a nil appState, so the + // scope resolves nil and every caller maps that to an empty result. + let bridge = IntentBridge() + #expect(currentRadioScope(bridge)?.radioID == nil) + } + + @Test func `scope is nil when app state has no current radio`() { + // A fresh AppState with no connection has a nil currentRadioID, so even an + // adopted bridge resolves no scope rather than reading the wrong radio. + let bridge = IntentBridge() + let appState = AppState() + bridge.adopt(appState) + + #expect(appState.currentRadioID == nil) + #expect(currentRadioScope(bridge)?.radioID == nil) + } } diff --git a/MC1Tests/Intents/IntentBridgeTests.swift b/MC1Tests/Intents/IntentBridgeTests.swift index 14391cc7..d9695891 100644 --- a/MC1Tests/Intents/IntentBridgeTests.swift +++ b/MC1Tests/Intents/IntentBridgeTests.swift @@ -1,7 +1,7 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing /// `IntentBridge` is the stable holder that survives the before-first-unlock /// `AppState` swap in `MC1App`. These tests pin its adopt semantics so a future @@ -9,26 +9,25 @@ import Testing /// is caught: the bridge must always point at the most recently adopted state. @MainActor struct IntentBridgeTests { + @Test func `adopt stores the adopted app state`() { + let bridge = IntentBridge() + #expect(bridge.appState == nil) - @Test func adoptStoresTheAdoptedAppState() { - let bridge = IntentBridge() - #expect(bridge.appState == nil) - - let appState = AppState() - bridge.adopt(appState) + let appState = AppState() + bridge.adopt(appState) - #expect(bridge.appState === appState) - } + #expect(bridge.appState === appState) + } - @Test func reAdoptReplacesThePreviousAppState() { - let bridge = IntentBridge() - let first = AppState() - let second = AppState() + @Test func `re adopt replaces the previous app state`() { + let bridge = IntentBridge() + let first = AppState() + let second = AppState() - bridge.adopt(first) - bridge.adopt(second) + bridge.adopt(first) + bridge.adopt(second) - #expect(bridge.appState === second) - #expect(bridge.appState !== first) - } + #expect(bridge.appState === second) + #expect(bridge.appState !== first) + } } diff --git a/MC1Tests/Intents/IntentErrorLocalizationTests.swift b/MC1Tests/Intents/IntentErrorLocalizationTests.swift index beef2359..312b1ec6 100644 --- a/MC1Tests/Intents/IntentErrorLocalizationTests.swift +++ b/MC1Tests/Intents/IntentErrorLocalizationTests.swift @@ -1,95 +1,94 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing /// `IntentError.errorDescription` is the localization seam Siri and Shortcuts /// read, so it must route through `L10n` in every locale, never an English /// fallback. These tests pin every shipped case to its `L10n` key and confirm /// the raw key resolves to real copy in all 9 locales. struct IntentErrorLocalizationTests { + /// The localizable table backing the `error.intent.*` keys. + private static let table = "Localizable" - /// The localizable table backing the `error.intent.*` keys. - private static let table = "Localizable" - - /// Every `IntentError` case that carries its own L10n key, paired with the - /// raw `.strings` key and the generated accessor that must agree with it. - private static let casesWithKeys: [(error: IntentError, rawKey: String, generatedAccessor: String)] = [ - (.notConnected, "error.intent.notConnected", L10n.Localizable.Error.Intent.notConnected), - (.invalidRecipient, "error.intent.invalidRecipient", L10n.Localizable.Error.Intent.invalidRecipient), - (.messageTooLong, "error.intent.messageTooLong", L10n.Localizable.Error.Intent.messageTooLong), - (.sendFailed, "error.intent.sendFailed", L10n.Localizable.Error.Intent.sendFailed), - // `.advertFailed` reuses the existing advertisement-error copy rather than - // minting a duplicate intent-scoped key. - (.advertFailed, "error.advertisement.sendFailed", L10n.Localizable.Error.Advertisement.sendFailed), - ] + /// Every `IntentError` case that carries its own L10n key, paired with the + /// raw `.strings` key and the generated accessor that must agree with it. + private static let casesWithKeys: [(error: IntentError, rawKey: String, generatedAccessor: String)] = [ + (.notConnected, "error.intent.notConnected", L10n.Localizable.Error.Intent.notConnected), + (.invalidRecipient, "error.intent.invalidRecipient", L10n.Localizable.Error.Intent.invalidRecipient), + (.messageTooLong, "error.intent.messageTooLong", L10n.Localizable.Error.Intent.messageTooLong), + (.sendFailed, "error.intent.sendFailed", L10n.Localizable.Error.Intent.sendFailed), + // `.advertFailed` reuses the existing advertisement-error copy rather than + // minting a duplicate intent-scoped key. + (.advertFailed, "error.advertisement.sendFailed", L10n.Localizable.Error.Advertisement.sendFailed), + ] - /// The 9 shipped locales. A key missing from any one would fall back to the - /// raw key string at runtime, so each must resolve real copy. - private static let locales = ["de", "en", "es", "fr", "nl", "pl", "ru", "uk", "zh-Hans"] + /// The 9 shipped locales. A key missing from any one would fall back to the + /// raw key string at runtime, so each must resolve real copy. + private static let locales = ["de", "en", "es", "fr", "nl", "pl", "ru", "uk", "zh-Hans"] - // MARK: - Generated accessor agreement + // MARK: - Generated accessor agreement - @Test func errorDescriptionRoutesThroughL10nForEachCase() { - for entry in Self.casesWithKeys { - #expect(entry.error.errorDescription == entry.generatedAccessor) - } + @Test func `error description routes through L 10 n for each case`() { + for entry in Self.casesWithKeys { + #expect(entry.error.errorDescription == entry.generatedAccessor) } + } - // MARK: - sessionError recursion + // MARK: - sessionError recursion - @Test func sessionErrorRecursesIntoWrappedMeshCoreError() { - #expect( - IntentError.sessionError(.timeout).errorDescription - == L10n.Localizable.Error.MeshCore.timeout - ) - #expect( - IntentError.sessionError(.notConnected).errorDescription - == MeshCoreError.notConnected.userFacingMessage - ) - } + @Test func `session error recurses into wrapped mesh core error`() { + #expect( + IntentError.sessionError(.timeout).errorDescription + == L10n.Localizable.Error.MeshCore.timeout + ) + #expect( + IntentError.sessionError(.notConnected).errorDescription + == MeshCoreError.notConnected.userFacingMessage + ) + } - @Test func sessionErrorCarriesNoKeyOfItsOwn() { - // `.sessionError` must resolve to the wrapped error's copy, never the - // raw key, in every locale (the wrapped error owns the localization). - let resolved = IntentError.sessionError(.featureDisabled).errorDescription - #expect(resolved == MeshCoreError.featureDisabled.userFacingMessage) - #expect(resolved?.isEmpty == false) - } + @Test func `session error carries no key of its own`() { + // `.sessionError` must resolve to the wrapped error's copy, never the + // raw key, in every locale (the wrapped error owns the localization). + let resolved = IntentError.sessionError(.featureDisabled).errorDescription + #expect(resolved == MeshCoreError.featureDisabled.userFacingMessage) + #expect(resolved?.isEmpty == false) + } - // MARK: - No raw-key fallback across all 9 locales + // MARK: - No raw-key fallback across all 9 locales - @Test func everyIntentKeyResolvesInEveryLocale() throws { - for locale in Self.locales { - let bundle = try #require( - Self.localeBundle(locale), - "Missing \(locale).lproj in the app bundle" - ) - for entry in Self.casesWithKeys { - let resolved = bundle.localizedString( - forKey: entry.rawKey, - value: Self.sentinel, - table: Self.table - ) - #expect( - resolved != Self.sentinel && resolved != entry.rawKey, - "\(entry.rawKey) falls back to the raw key in \(locale).lproj" - ) - } - } + @Test func `every intent key resolves in every locale`() throws { + for locale in Self.locales { + let bundle = try #require( + Self.localeBundle(locale), + "Missing \(locale).lproj in the app bundle" + ) + for entry in Self.casesWithKeys { + let resolved = bundle.localizedString( + forKey: entry.rawKey, + value: Self.sentinel, + table: Self.table + ) + #expect( + resolved != Self.sentinel && resolved != entry.rawKey, + "\(entry.rawKey) falls back to the raw key in \(locale).lproj" + ) + } } + } - // MARK: - Helpers + // MARK: - Helpers - /// A value the bundle returns verbatim when the key is missing, so a - /// missing key is distinguishable from real (possibly key-shaped) copy. - private static let sentinel = "\u{0}__intent_key_missing__\u{0}" + /// A value the bundle returns verbatim when the key is missing, so a + /// missing key is distinguishable from real (possibly key-shaped) copy. + private static let sentinel = "\u{0}__intent_key_missing__\u{0}" - private static func localeBundle(_ locale: String) -> Bundle? { - // The test host is the app, so Bundle.main carries every .lproj. - guard let url = Bundle.main.url(forResource: locale, withExtension: "lproj") else { - return nil - } - return Bundle(url: url) + private static func localeBundle(_ locale: String) -> Bundle? { + // The test host is the app, so Bundle.main carries every .lproj. + guard let url = Bundle.main.url(forResource: locale, withExtension: "lproj") else { + return nil } + return Bundle(url: url) + } } diff --git a/MC1Tests/Intents/IntentMetadataLocalizationTests.swift b/MC1Tests/Intents/IntentMetadataLocalizationTests.swift index 1620f509..b25a5b33 100644 --- a/MC1Tests/Intents/IntentMetadataLocalizationTests.swift +++ b/MC1Tests/Intents/IntentMetadataLocalizationTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import MC1 +import Testing /// App Intents resolves every static metadata literal (`LocalizedStringResource` /// title, description, parameter title, short title, and `ParameterSummary`) @@ -9,96 +9,95 @@ import Testing /// tests pin every metadata key the intents reference and confirm each resolves /// to real copy in all 9 locales, never the raw-key fallback. struct IntentMetadataLocalizationTests { + /// The table backing every App Intents static metadata literal. + private static let table = "Tools" - /// The table backing every App Intents static metadata literal. - private static let table = "Tools" - - /// Every key passed to `LocalizedStringResource("...", table: "Tools")` by the - /// Intents sources. Each is a dotted identifier, so a value equal to the key - /// signals a missing translation. The `ParameterSummary` keys are checked - /// separately because their English value legitimately equals the key string. - private static let keys: [String] = [ - "intent.entity.target", - "intent.entity.channel", - "intent.status.title", - "intent.status.shortTitle", - "intent.status.description", - "intent.send.title", - "intent.send.shortTitle", - "intent.send.description", - "intent.send.param.target", - "intent.send.param.message", - "intent.advert.title", - "intent.advert.shortTitle", - "intent.advert.description", - "intent.advert.param.reach", - "intent.advert.reach.type", - "intent.advert.reach.zeroHop", - "intent.advert.reach.flood", - ] + /// Every key passed to `LocalizedStringResource("...", table: "Tools")` by the + /// Intents sources. Each is a dotted identifier, so a value equal to the key + /// signals a missing translation. The `ParameterSummary` keys are checked + /// separately because their English value legitimately equals the key string. + private static let keys: [String] = [ + "intent.entity.target", + "intent.entity.channel", + "intent.status.title", + "intent.status.shortTitle", + "intent.status.description", + "intent.send.title", + "intent.send.shortTitle", + "intent.send.description", + "intent.send.param.target", + "intent.send.param.message", + "intent.advert.title", + "intent.advert.shortTitle", + "intent.advert.description", + "intent.advert.param.reach", + "intent.advert.reach.type", + "intent.advert.reach.zeroHop", + "intent.advert.reach.flood", + ] - /// The 9 shipped locales. A key missing from any one falls back to the raw - /// key string at runtime, so each must resolve real copy. - private static let locales = ["de", "en", "es", "fr", "nl", "pl", "ru", "uk", "zh-Hans"] + /// The 9 shipped locales. A key missing from any one falls back to the raw + /// key string at runtime, so each must resolve real copy. + private static let locales = ["de", "en", "es", "fr", "nl", "pl", "ru", "uk", "zh-Hans"] - @Test func everyMetadataKeyResolvesInEveryLocale() throws { - for locale in Self.locales { - let bundle = try #require( - Self.localeBundle(locale), - "Missing \(locale).lproj in the app bundle" - ) - for key in Self.keys { - let resolved = bundle.localizedString( - forKey: key, - value: Self.sentinel, - table: Self.table - ) - #expect( - resolved != Self.sentinel && resolved != key, - "\(key) falls back to the raw key in \(locale).lproj" - ) - } - } + @Test func `every metadata key resolves in every locale`() throws { + for locale in Self.locales { + let bundle = try #require( + Self.localeBundle(locale), + "Missing \(locale).lproj in the app bundle" + ) + for key in Self.keys { + let resolved = bundle.localizedString( + forKey: key, + value: Self.sentinel, + table: Self.table + ) + #expect( + resolved != Self.sentinel && resolved != key, + "\(key) falls back to the raw key in \(locale).lproj" + ) + } } + } - /// The `ParameterSummary` format strings substitute `${message}` and the - /// recipient token at runtime, so every locale's translation must keep both - /// tokens or the displayed summary loses a value. This also proves the keys - /// are present in every locale: a missing key resolves to the sentinel, which - /// carries neither token, so the token assertion fails. - @Test func parameterSummaryKeysKeepTheirTokensInEveryLocale() throws { - let summaryTokens: [(key: String, tokens: [String])] = [ - ("Send ${message} to ${target}", ["${message}", "${target}"]), - ("Send a ${reach} advert", ["${reach}"]), - ] - for locale in Self.locales { - let bundle = try #require(Self.localeBundle(locale)) - for entry in summaryTokens { - let resolved = bundle.localizedString( - forKey: entry.key, - value: Self.sentinel, - table: Self.table - ) - for token in entry.tokens { - #expect( - resolved.contains(token), - "\(entry.key) drops \(token) in \(locale).lproj: \(resolved)" - ) - } - } + /// The `ParameterSummary` format strings substitute `${message}` and the + /// recipient token at runtime, so every locale's translation must keep both + /// tokens or the displayed summary loses a value. This also proves the keys + /// are present in every locale: a missing key resolves to the sentinel, which + /// carries neither token, so the token assertion fails. + @Test func `parameter summary keys keep their tokens in every locale`() throws { + let summaryTokens: [(key: String, tokens: [String])] = [ + ("Send ${message} to ${target}", ["${message}", "${target}"]), + ("Send a ${reach} advert", ["${reach}"]), + ] + for locale in Self.locales { + let bundle = try #require(Self.localeBundle(locale)) + for entry in summaryTokens { + let resolved = bundle.localizedString( + forKey: entry.key, + value: Self.sentinel, + table: Self.table + ) + for token in entry.tokens { + #expect( + resolved.contains(token), + "\(entry.key) drops \(token) in \(locale).lproj: \(resolved)" + ) } + } } + } - // MARK: - Helpers + // MARK: - Helpers - /// A value the bundle returns verbatim when the key is missing, so a missing - /// key is distinguishable from real (possibly key-shaped) copy. - private static let sentinel = "\u{0}__intent_metadata_key_missing__\u{0}" + /// A value the bundle returns verbatim when the key is missing, so a missing + /// key is distinguishable from real (possibly key-shaped) copy. + private static let sentinel = "\u{0}__intent_metadata_key_missing__\u{0}" - private static func localeBundle(_ locale: String) -> Bundle? { - guard let url = Bundle.main.url(forResource: locale, withExtension: "lproj") else { - return nil - } - return Bundle(url: url) + private static func localeBundle(_ locale: String) -> Bundle? { + guard let url = Bundle.main.url(forResource: locale, withExtension: "lproj") else { + return nil } + return Bundle(url: url) + } } diff --git a/MC1Tests/Intents/SendAdvertIntentTests.swift b/MC1Tests/Intents/SendAdvertIntentTests.swift index d5648aaf..5d0fa6d8 100644 --- a/MC1Tests/Intents/SendAdvertIntentTests.swift +++ b/MC1Tests/Intents/SendAdvertIntentTests.swift @@ -1,8 +1,8 @@ import Foundation -import MeshCore -import Testing @testable import MC1 @testable import MC1Services +import MeshCore +import Testing /// `SendAdvertIntent` broadcasts a self-advertisement. Its `perform()` is a /// `ProvidesDialog` flow that isn't unit-assertable, so coverage lands on the @@ -11,150 +11,149 @@ import Testing /// Siri/Shortcuts invocation and the connected send are verified on device. @MainActor struct SendAdvertIntentTests { - - // MARK: - Reach mapping - - @Test func reachMapsToFloodFlag() { - #expect(AdvertReach.zeroHop.sendsFlood == false) - #expect(AdvertReach.flood.sendsFlood == true) - } - - // These raw values are the persisted identifiers the Shortcuts framework - // stores in saved shortcuts; renaming one breaks every shortcut on disk. - @Test func reachRawValuesArePinnedShortcutIdentifiers() { - #expect(AdvertReach.zeroHop.rawValue == "zeroHop") - #expect(AdvertReach.flood.rawValue == "flood") + // MARK: - Reach mapping + + @Test func `reach maps to flood flag`() { + #expect(AdvertReach.zeroHop.sendsFlood == false) + #expect(AdvertReach.flood.sendsFlood == true) + } + + /// These raw values are the persisted identifiers the Shortcuts framework + /// stores in saved shortcuts; renaming one breaks every shortcut on disk. + @Test func `reach raw values are pinned shortcut identifiers`() { + #expect(AdvertReach.zeroHop.rawValue == "zeroHop") + #expect(AdvertReach.flood.rawValue == "flood") + } + + // MARK: - Spoken dialog + + @Test func `success dialog is reach specific and honest`() { + #expect(SendAdvertIntent.successDialog(for: .zeroHop) == L10n.Tools.Intent.Advert.Dialog.sentZeroHop) + #expect(SendAdvertIntent.successDialog(for: .flood) == L10n.Tools.Intent.Advert.Dialog.sentFlood) + // An advert has no delivery ACK, so the dialog must never claim delivery. + #expect(!SendAdvertIntent.successDialog(for: .zeroHop).lowercased().contains("delivered")) + #expect(!SendAdvertIntent.successDialog(for: .flood).lowercased().contains("delivered")) + } + + // MARK: - Error mapping + + @Test func `map to intent error routes every case`() { + #expect(Self.tag(SendAdvertIntent.mapToIntentError(.notConnected)) == "notConnected") + #expect(Self.tag(SendAdvertIntent.mapToIntentError(.sendFailed)) == "advertFailed") + #expect(Self.tag(SendAdvertIntent.mapToIntentError(.invalidResponse)) == "advertFailed") + + let mapped = SendAdvertIntent.mapToIntentError(.sessionError(.timeout)) + #expect(Self.tag(mapped) == "sessionError") + #expect(mapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) + } + + @Test func `general error mapping never leaks raw errors`() { + // An AdvertisementError routes through the typed, exhaustive mapper. + #expect(Self.tag(SendAdvertIntent.mapToIntentError(AdvertisementError.notConnected as Error)) == "notConnected") + // A MeshCoreError the service forwards becomes a localized session error. + #expect(Self.tag(SendAdvertIntent.mapToIntentError(MeshCoreError.timeout as Error)) == "sessionError") + // A raw transport error the service does not rewrap still maps, so Siri + // never speaks it verbatim. + #expect(Self.tag(SendAdvertIntent.mapToIntentError(Self.UnmappedError())) == "advertFailed") + // An already-localized IntentError passes through unchanged. + #expect(Self.tag(SendAdvertIntent.mapToIntentError(IntentError.messageTooLong as Error)) == "messageTooLong") + } + + private struct UnmappedError: Error {} + + // MARK: - GPS gate (privacy) + + @Test func `gps gate respects the per device preference`() throws { + let appState = AppState() + let suite = try #require(UserDefaults(suiteName: "test.advert.gate.\(UUID().uuidString)")) + let store = DevicePreferenceStore(userDefaults: suite) + let deviceID = UUID() + + // Disabled by default: no location is refreshed even with a permissive policy. + #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 1), store: store) == nil) + + // Enabled, but the device policy forbids location: still no refresh. + store.setAutoUpdateLocationEnabled(true, deviceID: deviceID) + #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 0), store: store) == nil) + + // Enabled and policy allows: the configured source is returned. + store.setGPSSource(.device, deviceID: deviceID) + #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 1), store: store) == .device) + } + + @Test func `gps gate is nil without A device`() throws { + let appState = AppState() + let store = try DevicePreferenceStore(userDefaults: #require(UserDefaults(suiteName: "test.advert.gate.\(UUID().uuidString)"))) + #expect(appState.advertGPSSource(device: nil, store: store) == nil) + } + + // MARK: - Disconnected guard + + @Test func `send self advert without services throws not connected`() async { + let appState = AppState() + do { + try await appState.sendSelfAdvert(flood: false) + Issue.record("expected sendSelfAdvert to throw while disconnected") + } catch let error as AdvertisementError { + guard case .notConnected = error else { + Issue.record("expected .notConnected, got \(error)") + return + } + } catch { + Issue.record("unexpected error: \(error)") } - - // MARK: - Spoken dialog - - @Test func successDialogIsReachSpecificAndHonest() { - #expect(SendAdvertIntent.successDialog(for: .zeroHop) == L10n.Tools.Intent.Advert.Dialog.sentZeroHop) - #expect(SendAdvertIntent.successDialog(for: .flood) == L10n.Tools.Intent.Advert.Dialog.sentFlood) - // An advert has no delivery ACK, so the dialog must never claim delivery. - #expect(!SendAdvertIntent.successDialog(for: .zeroHop).lowercased().contains("delivered")) - #expect(!SendAdvertIntent.successDialog(for: .flood).lowercased().contains("delivered")) - } - - // MARK: - Error mapping - - @Test func mapToIntentErrorRoutesEveryCase() { - #expect(Self.tag(SendAdvertIntent.mapToIntentError(.notConnected)) == "notConnected") - #expect(Self.tag(SendAdvertIntent.mapToIntentError(.sendFailed)) == "advertFailed") - #expect(Self.tag(SendAdvertIntent.mapToIntentError(.invalidResponse)) == "advertFailed") - - let mapped = SendAdvertIntent.mapToIntentError(.sessionError(.timeout)) - #expect(Self.tag(mapped) == "sessionError") - #expect(mapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) - } - - @Test func generalErrorMappingNeverLeaksRawErrors() { - // An AdvertisementError routes through the typed, exhaustive mapper. - #expect(Self.tag(SendAdvertIntent.mapToIntentError(AdvertisementError.notConnected as Error)) == "notConnected") - // A MeshCoreError the service forwards becomes a localized session error. - #expect(Self.tag(SendAdvertIntent.mapToIntentError(MeshCoreError.timeout as Error)) == "sessionError") - // A raw transport error the service does not rewrap still maps, so Siri - // never speaks it verbatim. - #expect(Self.tag(SendAdvertIntent.mapToIntentError(Self.UnmappedError())) == "advertFailed") - // An already-localized IntentError passes through unchanged. - #expect(Self.tag(SendAdvertIntent.mapToIntentError(IntentError.messageTooLong as Error)) == "messageTooLong") - } - - private struct UnmappedError: Error {} - - // MARK: - GPS gate (privacy) - - @Test func gpsGateRespectsThePerDevicePreference() { - let appState = AppState() - let suite = UserDefaults(suiteName: "test.advert.gate.\(UUID().uuidString)")! - let store = DevicePreferenceStore(userDefaults: suite) - let deviceID = UUID() - - // Disabled by default: no location is refreshed even with a permissive policy. - #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 1), store: store) == nil) - - // Enabled, but the device policy forbids location: still no refresh. - store.setAutoUpdateLocationEnabled(true, deviceID: deviceID) - #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 0), store: store) == nil) - - // Enabled and policy allows: the configured source is returned. - store.setGPSSource(.device, deviceID: deviceID) - #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 1), store: store) == .device) - } - - @Test func gpsGateIsNilWithoutADevice() { - let appState = AppState() - let store = DevicePreferenceStore(userDefaults: UserDefaults(suiteName: "test.advert.gate.\(UUID().uuidString)")!) - #expect(appState.advertGPSSource(device: nil, store: store) == nil) - } - - // MARK: - Disconnected guard - - @Test func sendSelfAdvertWithoutServicesThrowsNotConnected() async { - let appState = AppState() - do { - try await appState.sendSelfAdvert(flood: false) - Issue.record("expected sendSelfAdvert to throw while disconnected") - } catch let error as AdvertisementError { - guard case .notConnected = error else { - Issue.record("expected .notConnected, got \(error)") - return - } - } catch { - Issue.record("unexpected error: \(error)") - } - } - - // MARK: - Helpers - - private static func tag(_ error: IntentError) -> String { - switch error { - case .notConnected: "notConnected" - case .invalidRecipient: "invalidRecipient" - case .messageTooLong: "messageTooLong" - case .sendFailed: "sendFailed" - case .advertFailed: "advertFailed" - case .sessionError: "sessionError" - } - } - - private static func makeDevice(id: UUID, advertLocationPolicy: UInt8) -> DeviceDTO { - DeviceDTO( - id: id, - radioID: UUID(), - publicKey: Data(repeating: 0x01, count: 32), - nodeName: "Test", - firmwareVersion: 8, - firmwareVersionString: "1.10", - manufacturerName: "Test", - buildDate: "", - maxContacts: 100, - maxChannels: 16, - frequency: 0, - bandwidth: 0, - spreadingFactor: 0, - codingRate: 0, - txPower: 0, - maxTxPower: 0, - latitude: 0, - longitude: 0, - blePin: 0, - clientRepeat: false, - pathHashMode: 0, - manualAddContacts: false, - autoAddConfig: 0, - autoAddMaxHops: 0, - multiAcks: 0, - telemetryModeBase: 0, - telemetryModeLoc: 0, - telemetryModeEnv: 0, - advertLocationPolicy: advertLocationPolicy, - lastConnected: Date(), - lastContactSync: 0, - isActive: true, - ocvPreset: nil, - customOCVArrayString: nil, - connectionMethods: [] - ) + } + + // MARK: - Helpers + + private static func tag(_ error: IntentError) -> String { + switch error { + case .notConnected: "notConnected" + case .invalidRecipient: "invalidRecipient" + case .messageTooLong: "messageTooLong" + case .sendFailed: "sendFailed" + case .advertFailed: "advertFailed" + case .sessionError: "sessionError" } + } + + private static func makeDevice(id: UUID, advertLocationPolicy: UInt8) -> DeviceDTO { + DeviceDTO( + id: id, + radioID: UUID(), + publicKey: Data(repeating: 0x01, count: 32), + nodeName: "Test", + firmwareVersion: 8, + firmwareVersionString: "1.10", + manufacturerName: "Test", + buildDate: "", + maxContacts: 100, + maxChannels: 16, + frequency: 0, + bandwidth: 0, + spreadingFactor: 0, + codingRate: 0, + txPower: 0, + maxTxPower: 0, + latitude: 0, + longitude: 0, + blePin: 0, + clientRepeat: false, + pathHashMode: 0, + manualAddContacts: false, + autoAddConfig: 0, + autoAddMaxHops: 0, + multiAcks: 0, + telemetryModeBase: 0, + telemetryModeLoc: 0, + telemetryModeEnv: 0, + advertLocationPolicy: advertLocationPolicy, + lastConnected: Date(), + lastContactSync: 0, + isActive: true, + ocvPreset: nil, + customOCVArrayString: nil, + connectionMethods: [] + ) + } } diff --git a/MC1Tests/Intents/SendMessageIntentTests.swift b/MC1Tests/Intents/SendMessageIntentTests.swift index 99e4d282..3532d986 100644 --- a/MC1Tests/Intents/SendMessageIntentTests.swift +++ b/MC1Tests/Intents/SendMessageIntentTests.swift @@ -1,8 +1,8 @@ import Foundation -import MeshCore -import Testing @testable import MC1 @testable import MC1Services +import MeshCore +import Testing /// `SendMessageIntent` is the safety-critical send: a message reported as sent /// that wasn't is a safety lie. These tests pin the routing decision, the @@ -13,431 +13,430 @@ import Testing /// framework-driven confirmation and foreground handoff are verified on device. @MainActor struct SendMessageIntentTests { - - private static let radioID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")! - - // MARK: - Fixtures - - private static func makeContact( - radioID: UUID = radioID, - type: ContactType = .chat, - name: String = "Alice" - ) -> ContactDTO { - ContactDTO( - id: UUID(), radioID: radioID, publicKey: Data(repeating: 0xC1, count: 32), name: name, - typeRawValue: type.rawValue, flags: 0, outPathLength: 0, outPath: Data(), - lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0, - nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, - lastMessageDate: nil, unreadCount: 0 - ) - } - - private static func makeChannel(radioID: UUID = radioID, name: String = "Ops") -> ChannelDTO { - ChannelDTO( - id: UUID(), radioID: radioID, index: 0, name: name, - secret: Data(repeating: 0x5A, count: 16), isEnabled: true, - lastMessageDate: nil, unreadCount: 0 - ) - } - - private static func makeDevice(radioID: UUID = radioID, name: String = "Base Camp") -> DeviceDTO { - DeviceDTO( - id: UUID(), radioID: radioID, publicKey: Data(repeating: 0x01, count: 32), - nodeName: name, firmwareVersion: 8, firmwareVersionString: "1.10", - manufacturerName: "Test", buildDate: "", maxContacts: 100, maxChannels: 16, - frequency: 0, bandwidth: 0, spreadingFactor: 0, codingRate: 0, txPower: 0, - maxTxPower: 0, latitude: 0, longitude: 0, blePin: 0, clientRepeat: false, - pathHashMode: 0, manualAddContacts: false, autoAddConfig: 0, autoAddMaxHops: 0, - multiAcks: 0, telemetryModeBase: 0, telemetryModeLoc: 0, telemetryModeEnv: 0, - advertLocationPolicy: 0, lastConnected: Date(), lastContactSync: 0, isActive: true, - ocvPreset: nil, customOCVArrayString: nil, connectionMethods: [] - ) - } - - /// Seeds an `AppState` with a real `ServiceContainer` over an in-memory store - /// scoped to `radioID`, at the given rung. Returns the live store so a test - /// can assert the durable `PendingSend` write. - private func seedReady( - _ appState: AppState, - state: DeviceConnectionState - ) throws -> ServiceContainer { - let container = try PersistenceStore.createContainer(inMemory: true) - let services = ServiceContainer( - session: MeshCoreSession(transport: MockTransport()), - modelContainer: container, - radioID: Self.radioID - ) - appState.connectionManager.setTestState( - connectionState: state, - services: services, - connectedDevice: Self.makeDevice() - ) - return services - } - - // MARK: - Routing (pure) - - @Test func readyRoutesToHeadlessQueue() { - #expect(SendMessageIntent.route(for: .ready) == .headlessQueue) - } - - @Test func syncingRoutesToQueueAfterSync() { - #expect(SendMessageIntent.route(for: .syncing) == .queueAfterSync) - } - - @Test(arguments: [DeviceConnectionState.connected, .connecting]) - func transientRoutesToForegroundEscalation(_ state: DeviceConnectionState) { - #expect(SendMessageIntent.route(for: state) == .foregroundEscalate) - } - - @Test func disconnectedRoutesToNotConnected() { - #expect(SendMessageIntent.route(for: .disconnected) == .notConnected) - } - - @Test func restorableRadioForegroundEscalatesNeverThrows() { - // A last-connected radio is restorable, so the send hands off to the - // foregrounded app rather than failing. - #expect(SendMessageIntent.disconnectedRoute(hasRestorableRadio: true) == .foregroundEscalate) - } - - @Test func neverConnectedSurfacesNotConnected() { - // No prior radio: retrying alone cannot help, so the send throws. - #expect(SendMessageIntent.disconnectedRoute(hasRestorableRadio: false) == .notConnected) - } - - // MARK: - Validation - - @Test func repeaterDMRejected() throws { - let repeater = Self.makeContact(type: .repeater) - let thrown = #expect(throws: IntentError.self) { - try SendMessageIntent.validate(message: "hi", for: .contact(repeater), nodeNameByteCount: 0) - } - #expect(isCase(try #require(thrown), .invalidRecipient)) - } - - @Test func roomDMRejected() throws { - let room = Self.makeContact(type: .room) - let thrown = #expect(throws: IntentError.self) { - try SendMessageIntent.validate(message: "hi", for: .contact(room), nodeNameByteCount: 0) - } - #expect(isCase(try #require(thrown), .invalidRecipient)) - } - - @Test func chatDMAccepted() throws { - try SendMessageIntent.validate(message: "hi", for: .contact(Self.makeContact()), nodeNameByteCount: 0) - } - - @Test func overlongChannelMessageRejected() throws { - let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0) - let tooLong = String(repeating: "x", count: maxBytes + 1) - let thrown = #expect(throws: IntentError.self) { - try SendMessageIntent.validate(message: tooLong, for: .channel(Self.makeChannel()), nodeNameByteCount: 0) - } - #expect(isCase(try #require(thrown), .messageTooLong)) - } - - @Test func channelMessageAtLimitAccepted() throws { - let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0) - let atLimit = String(repeating: "x", count: maxBytes) - try SendMessageIntent.validate(message: atLimit, for: .channel(Self.makeChannel()), nodeNameByteCount: 0) + private static let radioID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")! + + // MARK: - Fixtures + + private static func makeContact( + radioID: UUID = radioID, + type: ContactType = .chat, + name: String = "Alice" + ) -> ContactDTO { + ContactDTO( + id: UUID(), radioID: radioID, publicKey: Data(repeating: 0xC1, count: 32), name: name, + typeRawValue: type.rawValue, flags: 0, outPathLength: 0, outPath: Data(), + lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0, + nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, + lastMessageDate: nil, unreadCount: 0 + ) + } + + private static func makeChannel(radioID: UUID = radioID, name: String = "Ops") -> ChannelDTO { + ChannelDTO( + id: UUID(), radioID: radioID, index: 0, name: name, + secret: Data(repeating: 0x5A, count: 16), isEnabled: true, + lastMessageDate: nil, unreadCount: 0 + ) + } + + private static func makeDevice(radioID: UUID = radioID, name: String = "Base Camp") -> DeviceDTO { + DeviceDTO( + id: UUID(), radioID: radioID, publicKey: Data(repeating: 0x01, count: 32), + nodeName: name, firmwareVersion: 8, firmwareVersionString: "1.10", + manufacturerName: "Test", buildDate: "", maxContacts: 100, maxChannels: 16, + frequency: 0, bandwidth: 0, spreadingFactor: 0, codingRate: 0, txPower: 0, + maxTxPower: 0, latitude: 0, longitude: 0, blePin: 0, clientRepeat: false, + pathHashMode: 0, manualAddContacts: false, autoAddConfig: 0, autoAddMaxHops: 0, + multiAcks: 0, telemetryModeBase: 0, telemetryModeLoc: 0, telemetryModeEnv: 0, + advertLocationPolicy: 0, lastConnected: Date(), lastContactSync: 0, isActive: true, + ocvPreset: nil, customOCVArrayString: nil, connectionMethods: [] + ) + } + + /// Seeds an `AppState` with a real `ServiceContainer` over an in-memory store + /// scoped to `radioID`, at the given rung. Returns the live store so a test + /// can assert the durable `PendingSend` write. + private func seedReady( + _ appState: AppState, + state: DeviceConnectionState + ) throws -> ServiceContainer { + let container = try PersistenceStore.createContainer(inMemory: true) + let services = ServiceContainer( + session: MeshCoreSession(transport: MockTransport()), + modelContainer: container, + radioID: Self.radioID + ) + appState.connectionManager.setTestState( + connectionState: state, + services: services, + connectedDevice: Self.makeDevice() + ) + return services + } + + // MARK: - Routing (pure) + + @Test func `ready routes to headless queue`() { + #expect(SendMessageIntent.route(for: .ready) == .headlessQueue) + } + + @Test func `syncing routes to queue after sync`() { + #expect(SendMessageIntent.route(for: .syncing) == .queueAfterSync) + } + + @Test(arguments: [DeviceConnectionState.connected, .connecting]) + func `transient routes to foreground escalation`(_ state: DeviceConnectionState) { + #expect(SendMessageIntent.route(for: state) == .foregroundEscalate) + } + + @Test func `disconnected routes to not connected`() { + #expect(SendMessageIntent.route(for: .disconnected) == .notConnected) + } + + @Test func `restorable radio foreground escalates never throws`() { + // A last-connected radio is restorable, so the send hands off to the + // foregrounded app rather than failing. + #expect(SendMessageIntent.disconnectedRoute(hasRestorableRadio: true) == .foregroundEscalate) + } + + @Test func `never connected surfaces not connected`() { + // No prior radio: retrying alone cannot help, so the send throws. + #expect(SendMessageIntent.disconnectedRoute(hasRestorableRadio: false) == .notConnected) + } + + // MARK: - Validation + + @Test func `repeater DM rejected`() throws { + let repeater = Self.makeContact(type: .repeater) + let thrown = #expect(throws: IntentError.self) { + try SendMessageIntent.validate(message: "hi", for: .contact(repeater), nodeNameByteCount: 0) } + #expect(try isCase(#require(thrown), .invalidRecipient)) + } - @Test func channelMessageFittingTotalButExceedingNodeNameBudgetRejected() throws { - // The firmware prepends ": " to channel broadcasts, so the - // usable text is the total length minus the node name and separator. A - // message that fits the 147-byte total but not the adjusted budget would - // be silently truncated on the air, so the intent must reject it. - let nodeName = "Base Camp" - let nodeNameByteCount = nodeName.utf8.count - let adjustedMax = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: nodeNameByteCount) - let message = String(repeating: "x", count: adjustedMax + 1) - #expect(message.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength) - - let thrown = #expect(throws: IntentError.self) { - try SendMessageIntent.validate( - message: message, for: .channel(Self.makeChannel()), nodeNameByteCount: nodeNameByteCount - ) - } - #expect(isCase(try #require(thrown), .messageTooLong)) + @Test func `room DM rejected`() throws { + let room = Self.makeContact(type: .room) + let thrown = #expect(throws: IntentError.self) { + try SendMessageIntent.validate(message: "hi", for: .contact(room), nodeNameByteCount: 0) } - - // MARK: - Error rewrap - - /// `IntentError` carries a non-Equatable `MeshCoreError`, so cases are matched - /// structurally; the surfaced `errorDescription` (what Siri speaks) pins the - /// localized mapping. - private func isCase(_ error: IntentError, _ expected: IntentError) -> Bool { - switch (error, expected) { - case (.notConnected, .notConnected), - (.invalidRecipient, .invalidRecipient), (.messageTooLong, .messageTooLong), - (.sendFailed, .sendFailed), (.advertFailed, .advertFailed), - (.sessionError, .sessionError): - return true - default: - return false - } - } - - @Test func serviceErrorsRewrapToLocalizedIntentError() { - #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.notConnected), .notConnected)) - #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.messageTooLong), .messageTooLong)) - #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.invalidRecipient), .invalidRecipient)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.channelNotFound), .invalidRecipient)) - - // A wrapped session error preserves its underlying MeshCoreError, and the - // surfaced description routes through L10n (never a raw service string). - let wrapped = SendMessageIntent.mapToIntentError(MessageServiceError.sessionError(.timeout)) - #expect(isCase(wrapped, .sessionError(.timeout))) - #expect(wrapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) - } - - /// Pins the full `ChannelServiceError` bucketing in `mapToIntentError`: a - /// bad index reads as an invalid recipient, every local-failure case reads as - /// a send failure (never a disconnect), and a wrapped session error preserves - /// its underlying `MeshCoreError`. - @Test func channelServiceErrorsBucketIntoIntentErrors() { - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.notConnected), .notConnected)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.invalidChannelIndex), .invalidRecipient)) - - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.secretHashingFailed), .sendFailed)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.sendFailed("io")), .sendFailed)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.syncAlreadyInProgress), .sendFailed)) - #expect( - isCase( - SendMessageIntent.mapToIntentError(ChannelServiceError.circuitBreakerOpen(consecutiveFailures: 3)), - .sendFailed - ) - ) - - let wrapped = SendMessageIntent.mapToIntentError(ChannelServiceError.sessionError(.timeout)) - #expect(isCase(wrapped, .sessionError(.timeout))) - #expect(wrapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) - } - - /// A failure to persist or enqueue a connected send must speak as a send - /// failure, never "not connected"; the radio is up, the local write failed. - @Test func sendWriteFailuresMapToSendFailedNotNotConnected() { - let queuePersistFailed = ChatSendQueueServiceError.persistFailed(underlying: MeshCoreError.timeout) - #expect(isCase(SendMessageIntent.mapToIntentError(queuePersistFailed), .sendFailed)) - #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.sendFailed("io")), .sendFailed)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.saveFailed("io")), .sendFailed)) - - // The spoken line routes through L10n, not a raw error string. - #expect( - SendMessageIntent.mapToIntentError(queuePersistFailed).errorDescription - == L10n.Localizable.Error.Intent.sendFailed - ) - - // A genuine not-connected from the queue still reads as not connected. - #expect(isCase(SendMessageIntent.mapToIntentError(ChatSendQueueServiceError.notConnected), .notConnected)) - - // An unrecognized error during a send is a send failure, not a disconnect. - let unknown = NSError(domain: "test", code: 1) - #expect(isCase(SendMessageIntent.mapToIntentError(unknown), .sendFailed)) - } - - // MARK: - .ready DM enqueue (durable write) - - @Test func readyDMEnqueuesPendingSend() async throws { - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let contact = Self.makeContact(name: "Alice") - try await services.dataStore.saveContact(contact) - - let outcome = try await SendMessageIntent.performSend( - message: "on my way", recipient: .contact(contact), in: appState - ) - #expect(outcome == .queued) - - // The durable PendingSend row is what survives a drop and drains at .ready. - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.count == 1) + #expect(try isCase(#require(thrown), .invalidRecipient)) + } + + @Test func `chat DM accepted`() throws { + try SendMessageIntent.validate(message: "hi", for: .contact(Self.makeContact()), nodeNameByteCount: 0) + } + + @Test func `overlong channel message rejected`() throws { + let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0) + let tooLong = String(repeating: "x", count: maxBytes + 1) + let thrown = #expect(throws: IntentError.self) { + try SendMessageIntent.validate(message: tooLong, for: .channel(Self.makeChannel()), nodeNameByteCount: 0) } - - // MARK: - Conversation list refresh - - @Test func readySendBumpsConversationsVersionSoTheListRefreshes() async throws { - // A headless send writes the message row but, unlike an in-app send, never - // crosses the chat view model. Without an explicit refresh the Chats list - // keeps its cached snapshot, so the preview stays stale until an unrelated - // reload fires. The send must announce the change itself. - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let contact = Self.makeContact(name: "Alice") - try await services.dataStore.saveContact(contact) - let before = appState.conversationsVersion - - let outcome = try await SendMessageIntent.performSend( - message: "on my way", recipient: .contact(contact), in: appState - ) - - #expect(outcome == .queued) - #expect(appState.conversationsVersion > before) + #expect(try isCase(#require(thrown), .messageTooLong)) + } + + @Test func `channel message at limit accepted`() throws { + let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0) + let atLimit = String(repeating: "x", count: maxBytes) + try SendMessageIntent.validate(message: atLimit, for: .channel(Self.makeChannel()), nodeNameByteCount: 0) + } + + @Test func `channel message fitting total but exceeding node name budget rejected`() throws { + // The firmware prepends ": " to channel broadcasts, so the + // usable text is the total length minus the node name and separator. A + // message that fits the 147-byte total but not the adjusted budget would + // be silently truncated on the air, so the intent must reject it. + let nodeName = "Base Camp" + let nodeNameByteCount = nodeName.utf8.count + let adjustedMax = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: nodeNameByteCount) + let message = String(repeating: "x", count: adjustedMax + 1) + #expect(message.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength) + + let thrown = #expect(throws: IntentError.self) { + try SendMessageIntent.validate( + message: message, for: .channel(Self.makeChannel()), nodeNameByteCount: nodeNameByteCount + ) } - - @Test func mustForegroundDoesNotBumpConversationsVersion() async throws { - // A nil-services re-read returns `.mustForeground` before any write, so - // there is nothing to announce and the list must not be reloaded. - let appState = AppState() - let before = appState.conversationsVersion - - let outcome = try await SendMessageIntent.performSend( - message: "on my way", recipient: .contact(Self.makeContact()), in: appState - ) - - #expect(outcome == .mustForeground) - #expect(appState.conversationsVersion == before) + #expect(try isCase(#require(thrown), .messageTooLong)) + } + + // MARK: - Error rewrap + + /// `IntentError` carries a non-Equatable `MeshCoreError`, so cases are matched + /// structurally; the surfaced `errorDescription` (what Siri speaks) pins the + /// localized mapping. + private func isCase(_ error: IntentError, _ expected: IntentError) -> Bool { + switch (error, expected) { + case (.notConnected, .notConnected), + (.invalidRecipient, .invalidRecipient), (.messageTooLong, .messageTooLong), + (.sendFailed, .sendFailed), (.advertFailed, .advertFailed), + (.sessionError, .sessionError): + true + default: + false } - - // MARK: - .ready channel enqueue (durable write) - - @Test func readyChannelEnqueuesPendingSend() async throws { - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let channel = Self.makeChannel(name: "Ops") - try await services.dataStore.saveChannel(channel) - - let outcome = try await SendMessageIntent.performSend( - message: "radio check", recipient: .channel(channel), in: appState - ) - #expect(outcome == .queued) - - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.count == 1) - } - - // MARK: - channel length re-validated against the live node name at send time - - @Test func channelMessageRevalidatedAgainstLiveNodeNameAtSendTime() async throws { - // A message sized to the zero-node-name budget clears the pre-confirmation - // validate, but the live radio ("Base Camp") shrinks the on-air budget by - // the prepended ": ". performSend must re-validate and reject it - // rather than enqueue a message the firmware would silently truncate. - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let channel = Self.makeChannel(name: "Ops") - try await services.dataStore.saveChannel(channel) - - let overBudget = String(repeating: "x", count: ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0)) - - let thrown = await #expect(throws: IntentError.self) { - try await SendMessageIntent.performSend( - message: overBudget, recipient: .channel(channel), in: appState - ) - } - #expect(isCase(try #require(thrown), .messageTooLong)) - - // The reject precedes the durable write, so nothing is enqueued. - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.isEmpty) - } - - // MARK: - enqueue-failure orphan recovery primitive - - /// `performSend`'s catch marks a persisted-but-unenqueued row `.failed` via - /// `updateMessageStatusUnlessDelivered` so an enqueue-write failure surfaces a - /// retry instead of hanging `.pending`. Forcing the enqueue itself to throw is - /// not reachable through any existing test seam (the queue persists through the - /// concrete in-memory `PersistenceStore`, which does not fail), so this pins - /// the recovery primitive on a real row built by production `createPendingMessage`: - /// a `.pending` row flips to `.failed`, while a `.delivered` row is left intact - /// so the recovery can never downgrade a delivered send. - @Test func updateMessageStatusUnlessDeliveredFailsPendingButSparesDelivered() async throws { - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let contact = Self.makeContact() - try await services.dataStore.saveContact(contact) - - let pending = try await services.messageService.createPendingMessage(text: "queued", to: contact) - #expect(try await services.dataStore.fetchMessage(id: pending.id)?.status == .pending) - - let flipped = try await services.dataStore.updateMessageStatusUnlessDelivered(id: pending.id, status: .failed) - #expect(flipped) - #expect(try await services.dataStore.fetchMessage(id: pending.id)?.status == .failed) - - let delivered = try await services.messageService.createPendingMessage(text: "acked", to: contact) - try await services.dataStore.updateMessageAck(id: delivered.id, ackCode: 0x1234_5678, status: .delivered) - - let spared = try await services.dataStore.updateMessageStatusUnlessDelivered(id: delivered.id, status: .failed) - #expect(!spared) - #expect(try await services.dataStore.fetchMessage(id: delivered.id)?.status == .delivered) - } - - // MARK: - .syncing takes the queue - - @Test func syncingEnqueuesPendingSend() async throws { - let appState = AppState() - let services = try seedReady(appState, state: .syncing) - let contact = Self.makeContact(name: "Bravo") - try await services.dataStore.saveContact(contact) - - // The .syncing rung classifies as .queueAfterSync, which shares the queue - // branch with .ready: the durable row is written either way. - #expect(SendMessageIntent.route(for: .syncing) == .queueAfterSync) - let outcome = try await SendMessageIntent.performSend( - message: "staging", recipient: .contact(contact), in: appState - ) - #expect(outcome == .queued) - - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.count == 1) - } - - // MARK: - services-nil during confirmation re-routes to foreground (no fabricated queued) - - @Test func servicesNilAfterClassifyRoutesToForegroundNotASilentEnqueue() async throws { - let appState = AppState() - // Classified .ready with a live service, then the radio drops during the - // confirmation await: services go nil. performSend must report - // .mustForeground, never a "queued" for a send that never enqueued. - _ = try seedReady(appState, state: .ready) - let contact = Self.makeContact() - appState.connectionManager.setTestState(services: .some(nil)) - - let outcome = try await SendMessageIntent.performSend( - message: "dropped", recipient: .contact(contact), in: appState - ) - #expect(outcome == .mustForeground) - } - - // MARK: - radio switch during confirmation re-routes to foreground (no cross-radio enqueue) - - @Test func recipientFromAnotherRadioRoutesToForegroundNotACrossRadioEnqueue() async throws { - let appState = AppState() - // Live radio is B (the seeded service and connected device). The recipient - // was resolved against radio A before the confirmation await, so enqueuing - // would scope the message row to A and the PendingSend to B, mis-routing - // the send. performSend must report .mustForeground and write nothing. - let services = try seedReady(appState, state: .ready) - let otherRadioID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")! - let contact = Self.makeContact(radioID: otherRadioID) - - let outcome = try await SendMessageIntent.performSend( - message: "switched radios", recipient: .contact(contact), in: appState - ) - #expect(outcome == .mustForeground) - - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.isEmpty) - let otherPending = try await services.dataStore.fetchPendingSends(radioID: otherRadioID) - #expect(otherPending.isEmpty) - } - - // MARK: - bare .connected with no services never silently enqueues - - @Test func connectedWithoutServicesNeverEnqueues() async throws { - let appState = AppState() - // Bare .connected: the rung is set before services are built, so services - // can be nil. The route classifies .foregroundEscalate (never the queue), - // and even if performSend were reached it returns .mustForeground. - appState.connectionManager.setTestState( - connectionState: .connected, services: .some(nil), connectedDevice: Self.makeDevice() - ) - #expect(SendMessageIntent.route(for: .connected) == .foregroundEscalate) - - let outcome = try await SendMessageIntent.performSend( - message: "nope", recipient: .contact(Self.makeContact()), in: appState - ) - #expect(outcome == .mustForeground) + } + + @Test func `service errors rewrap to localized intent error`() { + #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.notConnected), .notConnected)) + #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.messageTooLong), .messageTooLong)) + #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.invalidRecipient), .invalidRecipient)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.channelNotFound), .invalidRecipient)) + + // A wrapped session error preserves its underlying MeshCoreError, and the + // surfaced description routes through L10n (never a raw service string). + let wrapped = SendMessageIntent.mapToIntentError(MessageServiceError.sessionError(.timeout)) + #expect(isCase(wrapped, .sessionError(.timeout))) + #expect(wrapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) + } + + /// Pins the full `ChannelServiceError` bucketing in `mapToIntentError`: a + /// bad index reads as an invalid recipient, every local-failure case reads as + /// a send failure (never a disconnect), and a wrapped session error preserves + /// its underlying `MeshCoreError`. + @Test func `channel service errors bucket into intent errors`() { + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.notConnected), .notConnected)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.invalidChannelIndex), .invalidRecipient)) + + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.secretHashingFailed), .sendFailed)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.sendFailed("io")), .sendFailed)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.syncAlreadyInProgress), .sendFailed)) + #expect( + isCase( + SendMessageIntent.mapToIntentError(ChannelServiceError.circuitBreakerOpen(consecutiveFailures: 3)), + .sendFailed + ) + ) + + let wrapped = SendMessageIntent.mapToIntentError(ChannelServiceError.sessionError(.timeout)) + #expect(isCase(wrapped, .sessionError(.timeout))) + #expect(wrapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) + } + + /// A failure to persist or enqueue a connected send must speak as a send + /// failure, never "not connected"; the radio is up, the local write failed. + @Test func `send write failures map to send failed not not connected`() { + let queuePersistFailed = ChatSendQueueServiceError.persistFailed(underlying: MeshCoreError.timeout) + #expect(isCase(SendMessageIntent.mapToIntentError(queuePersistFailed), .sendFailed)) + #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.sendFailed("io")), .sendFailed)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.saveFailed("io")), .sendFailed)) + + // The spoken line routes through L10n, not a raw error string. + #expect( + SendMessageIntent.mapToIntentError(queuePersistFailed).errorDescription + == L10n.Localizable.Error.Intent.sendFailed + ) + + // A genuine not-connected from the queue still reads as not connected. + #expect(isCase(SendMessageIntent.mapToIntentError(ChatSendQueueServiceError.notConnected), .notConnected)) + + // An unrecognized error during a send is a send failure, not a disconnect. + let unknown = NSError(domain: "test", code: 1) + #expect(isCase(SendMessageIntent.mapToIntentError(unknown), .sendFailed)) + } + + // MARK: - .ready DM enqueue (durable write) + + @Test func `ready DM enqueues pending send`() async throws { + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let contact = Self.makeContact(name: "Alice") + try await services.dataStore.saveContact(contact) + + let outcome = try await SendMessageIntent.performSend( + message: "on my way", recipient: .contact(contact), in: appState + ) + #expect(outcome == .queued) + + // The durable PendingSend row is what survives a drop and drains at .ready. + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.count == 1) + } + + // MARK: - Conversation list refresh + + @Test func `ready send bumps conversations version so the list refreshes`() async throws { + // A headless send writes the message row but, unlike an in-app send, never + // crosses the chat view model. Without an explicit refresh the Chats list + // keeps its cached snapshot, so the preview stays stale until an unrelated + // reload fires. The send must announce the change itself. + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let contact = Self.makeContact(name: "Alice") + try await services.dataStore.saveContact(contact) + let before = appState.conversationsVersion + + let outcome = try await SendMessageIntent.performSend( + message: "on my way", recipient: .contact(contact), in: appState + ) + + #expect(outcome == .queued) + #expect(appState.conversationsVersion > before) + } + + @Test func `must foreground does not bump conversations version`() async throws { + // A nil-services re-read returns `.mustForeground` before any write, so + // there is nothing to announce and the list must not be reloaded. + let appState = AppState() + let before = appState.conversationsVersion + + let outcome = try await SendMessageIntent.performSend( + message: "on my way", recipient: .contact(Self.makeContact()), in: appState + ) + + #expect(outcome == .mustForeground) + #expect(appState.conversationsVersion == before) + } + + // MARK: - .ready channel enqueue (durable write) + + @Test func `ready channel enqueues pending send`() async throws { + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let channel = Self.makeChannel(name: "Ops") + try await services.dataStore.saveChannel(channel) + + let outcome = try await SendMessageIntent.performSend( + message: "radio check", recipient: .channel(channel), in: appState + ) + #expect(outcome == .queued) + + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.count == 1) + } + + // MARK: - channel length re-validated against the live node name at send time + + @Test func `channel message revalidated against live node name at send time`() async throws { + // A message sized to the zero-node-name budget clears the pre-confirmation + // validate, but the live radio ("Base Camp") shrinks the on-air budget by + // the prepended ": ". performSend must re-validate and reject it + // rather than enqueue a message the firmware would silently truncate. + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let channel = Self.makeChannel(name: "Ops") + try await services.dataStore.saveChannel(channel) + + let overBudget = String(repeating: "x", count: ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0)) + + let thrown = await #expect(throws: IntentError.self) { + try await SendMessageIntent.performSend( + message: overBudget, recipient: .channel(channel), in: appState + ) } + #expect(try isCase(#require(thrown), .messageTooLong)) + + // The reject precedes the durable write, so nothing is enqueued. + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.isEmpty) + } + + // MARK: - enqueue-failure orphan recovery primitive + + /// `performSend`'s catch marks a persisted-but-unenqueued row `.failed` via + /// `updateMessageStatusUnlessDelivered` so an enqueue-write failure surfaces a + /// retry instead of hanging `.pending`. Forcing the enqueue itself to throw is + /// not reachable through any existing test seam (the queue persists through the + /// concrete in-memory `PersistenceStore`, which does not fail), so this pins + /// the recovery primitive on a real row built by production `createPendingMessage`: + /// a `.pending` row flips to `.failed`, while a `.delivered` row is left intact + /// so the recovery can never downgrade a delivered send. + @Test func `update message status unless delivered fails pending but spares delivered`() async throws { + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let contact = Self.makeContact() + try await services.dataStore.saveContact(contact) + + let pending = try await services.messageService.createPendingMessage(text: "queued", to: contact) + #expect(try await services.dataStore.fetchMessage(id: pending.id)?.status == .pending) + + let flipped = try await services.dataStore.updateMessageStatusUnlessDelivered(id: pending.id, status: .failed) + #expect(flipped) + #expect(try await services.dataStore.fetchMessage(id: pending.id)?.status == .failed) + + let delivered = try await services.messageService.createPendingMessage(text: "acked", to: contact) + try await services.dataStore.updateMessageAck(id: delivered.id, ackCode: 0x1234_5678, status: .delivered) + + let spared = try await services.dataStore.updateMessageStatusUnlessDelivered(id: delivered.id, status: .failed) + #expect(!spared) + #expect(try await services.dataStore.fetchMessage(id: delivered.id)?.status == .delivered) + } + + // MARK: - .syncing takes the queue + + @Test func `syncing enqueues pending send`() async throws { + let appState = AppState() + let services = try seedReady(appState, state: .syncing) + let contact = Self.makeContact(name: "Bravo") + try await services.dataStore.saveContact(contact) + + // The .syncing rung classifies as .queueAfterSync, which shares the queue + // branch with .ready: the durable row is written either way. + #expect(SendMessageIntent.route(for: .syncing) == .queueAfterSync) + let outcome = try await SendMessageIntent.performSend( + message: "staging", recipient: .contact(contact), in: appState + ) + #expect(outcome == .queued) + + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.count == 1) + } + + // MARK: - services-nil during confirmation re-routes to foreground (no fabricated queued) + + @Test func `services nil after classify routes to foreground not A silent enqueue`() async throws { + let appState = AppState() + // Classified .ready with a live service, then the radio drops during the + // confirmation await: services go nil. performSend must report + // .mustForeground, never a "queued" for a send that never enqueued. + _ = try seedReady(appState, state: .ready) + let contact = Self.makeContact() + appState.connectionManager.setTestState(services: .some(nil)) + + let outcome = try await SendMessageIntent.performSend( + message: "dropped", recipient: .contact(contact), in: appState + ) + #expect(outcome == .mustForeground) + } + + // MARK: - radio switch during confirmation re-routes to foreground (no cross-radio enqueue) + + @Test func `recipient from another radio routes to foreground not A cross radio enqueue`() async throws { + let appState = AppState() + // Live radio is B (the seeded service and connected device). The recipient + // was resolved against radio A before the confirmation await, so enqueuing + // would scope the message row to A and the PendingSend to B, mis-routing + // the send. performSend must report .mustForeground and write nothing. + let services = try seedReady(appState, state: .ready) + let otherRadioID = try #require(UUID(uuidString: "55555555-5555-5555-5555-555555555555")) + let contact = Self.makeContact(radioID: otherRadioID) + + let outcome = try await SendMessageIntent.performSend( + message: "switched radios", recipient: .contact(contact), in: appState + ) + #expect(outcome == .mustForeground) + + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.isEmpty) + let otherPending = try await services.dataStore.fetchPendingSends(radioID: otherRadioID) + #expect(otherPending.isEmpty) + } + + // MARK: - bare .connected with no services never silently enqueues + + @Test func `connected without services never enqueues`() async throws { + let appState = AppState() + // Bare .connected: the rung is set before services are built, so services + // can be nil. The route classifies .foregroundEscalate (never the queue), + // and even if performSend were reached it returns .mustForeground. + appState.connectionManager.setTestState( + connectionState: .connected, services: .some(nil), connectedDevice: Self.makeDevice() + ) + #expect(SendMessageIntent.route(for: .connected) == .foregroundEscalate) + + let outcome = try await SendMessageIntent.performSend( + message: "nope", recipient: .contact(Self.makeContact()), in: appState + ) + #expect(outcome == .mustForeground) + } } diff --git a/MC1Tests/Intents/StatusQueryIntentTests.swift b/MC1Tests/Intents/StatusQueryIntentTests.swift index 132c023b..3c52221a 100644 --- a/MC1Tests/Intents/StatusQueryIntentTests.swift +++ b/MC1Tests/Intents/StatusQueryIntentTests.swift @@ -1,8 +1,8 @@ import Foundation -import MeshCore -import Testing @testable import MC1 @testable import MC1Services +import MeshCore +import Testing /// `StatusQueryIntent` is the read-only, cached-only voice glance: it answers from /// synchronous `@Observable` state with no radio round-trip, never throws on a @@ -14,172 +14,171 @@ import Testing /// is verified on device rather than here. @MainActor struct StatusQueryIntentTests { - - private static let radioID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! - - /// An OCV curve (11 points, 100%..0%) used to pin the conversion: the seeded - /// level maps to a deterministic percent through `percentage(using:)`, a value - /// that can never be confused with the raw millivolts. - private static let ocvArray = [4200, 4060, 3980, 3920, 3870, 3820, 3790, 3770, 3730, 3680, 3000] - - /// A seeded millivolt level whose digits never coincide with its resulting - /// percent, so a test catching raw-mV leakage stays meaningful. 4100mV resolves - /// to 93% against `ocvArray`. - private static let seededLevel = 4100 - private static let expectedPercent = 93 - - private static func makeDevice(name: String) -> DeviceDTO { - DeviceDTO( - id: UUID(), - radioID: radioID, - publicKey: Data(repeating: 0x01, count: 32), - nodeName: name, - firmwareVersion: 8, - firmwareVersionString: "1.10", - manufacturerName: "Test", - buildDate: "", - maxContacts: 100, - maxChannels: 16, - frequency: 0, - bandwidth: 0, - spreadingFactor: 0, - codingRate: 0, - txPower: 0, - maxTxPower: 0, - latitude: 0, - longitude: 0, - blePin: 0, - clientRepeat: false, - pathHashMode: 0, - manualAddContacts: false, - autoAddConfig: 0, - autoAddMaxHops: 0, - multiAcks: 0, - telemetryModeBase: 0, - telemetryModeLoc: 0, - telemetryModeEnv: 0, - advertLocationPolicy: 0, - lastConnected: Date(), - lastContactSync: 0, - isActive: true, - ocvPreset: nil, - customOCVArrayString: nil, - connectionMethods: [] - ) - } - - private func seedConnected( - _ appState: AppState, - state: DeviceConnectionState, - name: String, - battery: BatteryInfo? - ) { - appState.connectionManager.setTestState( - connectionState: state, - connectedDevice: Self.makeDevice(name: name), - currentTransportType: .bluetooth - ) - appState.batteryMonitor.deviceBattery = battery - } - - // MARK: - Connected rungs - - @Test(arguments: [DeviceConnectionState.ready, .syncing, .connected]) - func connectedWithBatterySpeaksNameAndPercent(_ state: DeviceConnectionState) { - let appState = AppState() - let battery = BatteryInfo(level: Self.seededLevel) - seedConnected(appState, state: state, name: "Base Camp", battery: battery) - - let percent = battery.percentage(using: appState.connectedDevice!.activeOCVArray) - let spoken = StatusQueryIntent.dialogText(for: appState) - - #expect(spoken == L10n.Tools.Intent.Status.Dialog.connectedWithBattery("Base Camp", percent)) - // Raw millivolts must never be spoken as the percentage. - #expect(!spoken.contains(String(Self.seededLevel))) - } - - @Test func absentBatteryReportsNoReadingNotZeroPercent() { - let appState = AppState() - // 0mV = no battery hardware (mains-powered); `isBatteryPresent` is false. - seedConnected(appState, state: .ready, name: "Mains Node", battery: BatteryInfo(level: 0)) - - let spoken = StatusQueryIntent.dialogText(for: appState) - #expect(spoken == L10n.Tools.Intent.Status.Dialog.connectedNoBattery("Mains Node")) - #expect(!spoken.contains("0%")) - } - - @Test func missingBatteryReadingReportsNoReading() { - let appState = AppState() - seedConnected(appState, state: .ready, name: "No Poll Yet", battery: nil) - - #expect( - StatusQueryIntent.dialogText(for: appState) - == L10n.Tools.Intent.Status.Dialog.connectedNoBattery("No Poll Yet") - ) - } - - // MARK: - Connecting and disconnected rungs (offline name, no throw) - - @Test func connectingSpeaksConnecting() { - let appState = AppState() - appState.connectionManager.persistConnection( - deviceID: UUID(), radioID: Self.radioID, deviceName: "Field Radio" - ) - appState.connectionManager.setTestState(connectionState: .connecting, connectedDevice: .some(nil)) - - #expect( - StatusQueryIntent.dialogText(for: appState) - == L10n.Tools.Intent.Status.Dialog.connecting("Field Radio") - ) - appState.connectionManager.clearPersistedConnection() - } - - @Test func disconnectedWithKnownRadioSpeaksOfflineName() { - let appState = AppState() - appState.connectionManager.persistConnection( - deviceID: UUID(), radioID: Self.radioID, deviceName: "Last Radio" - ) - appState.connectionManager.setTestState(connectionState: .disconnected, connectedDevice: .some(nil)) - - #expect( - StatusQueryIntent.dialogText(for: appState) - == L10n.Tools.Intent.Status.Dialog.disconnectedNamed("Last Radio") - ) - appState.connectionManager.clearPersistedConnection() - } - - @Test func neverConnectedReportsNoRadio() { - let appState = AppState() - appState.connectionManager.clearPersistedConnection() - appState.connectionManager.setTestState(connectionState: .disconnected, connectedDevice: .some(nil)) - - #expect( - StatusQueryIntent.dialogText(for: appState) - == L10n.Tools.Intent.Status.Dialog.disconnectedUnknown - ) - } - - // MARK: - Pre-unlock (no AppState) - - @Test func nilAppStateMeansNotReady() { - let bridge = IntentBridge() - // No `adopt`, so `bridge.appState` is nil; the pre-unlock BFU window, where - // `perform()` returns the not-ready dialog without reading any state. The - // `nil` bridge is what gates that branch. - #expect(bridge.appState == nil) - } - - // MARK: - Conversion correctness - - @Test func millivoltsConvertToPercentThroughOCVCurve() { - let battery = BatteryInfo(level: Self.seededLevel) - let percent = battery.percentage(using: Self.ocvArray) - #expect(percent == Self.expectedPercent) - #expect(percent != battery.level) - } - - @Test func absentBatteryIsNotPresent() { - #expect(BatteryInfo(level: 0).isBatteryPresent == false) - #expect(BatteryInfo(level: Self.seededLevel).isBatteryPresent == true) - } + private static let radioID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! + + /// An OCV curve (11 points, 100%..0%) used to pin the conversion: the seeded + /// level maps to a deterministic percent through `percentage(using:)`, a value + /// that can never be confused with the raw millivolts. + private static let ocvArray = [4200, 4060, 3980, 3920, 3870, 3820, 3790, 3770, 3730, 3680, 3000] + + /// A seeded millivolt level whose digits never coincide with its resulting + /// percent, so a test catching raw-mV leakage stays meaningful. 4100mV resolves + /// to 93% against `ocvArray`. + private static let seededLevel = 4100 + private static let expectedPercent = 93 + + private static func makeDevice(name: String) -> DeviceDTO { + DeviceDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0x01, count: 32), + nodeName: name, + firmwareVersion: 8, + firmwareVersionString: "1.10", + manufacturerName: "Test", + buildDate: "", + maxContacts: 100, + maxChannels: 16, + frequency: 0, + bandwidth: 0, + spreadingFactor: 0, + codingRate: 0, + txPower: 0, + maxTxPower: 0, + latitude: 0, + longitude: 0, + blePin: 0, + clientRepeat: false, + pathHashMode: 0, + manualAddContacts: false, + autoAddConfig: 0, + autoAddMaxHops: 0, + multiAcks: 0, + telemetryModeBase: 0, + telemetryModeLoc: 0, + telemetryModeEnv: 0, + advertLocationPolicy: 0, + lastConnected: Date(), + lastContactSync: 0, + isActive: true, + ocvPreset: nil, + customOCVArrayString: nil, + connectionMethods: [] + ) + } + + private func seedConnected( + _ appState: AppState, + state: DeviceConnectionState, + name: String, + battery: BatteryInfo? + ) { + appState.connectionManager.setTestState( + connectionState: state, + connectedDevice: Self.makeDevice(name: name), + currentTransportType: .bluetooth + ) + appState.batteryMonitor.deviceBattery = battery + } + + // MARK: - Connected rungs + + @Test(arguments: [DeviceConnectionState.ready, .syncing, .connected]) + func `connected with battery speaks name and percent`(_ state: DeviceConnectionState) throws { + let appState = AppState() + let battery = BatteryInfo(level: Self.seededLevel) + seedConnected(appState, state: state, name: "Base Camp", battery: battery) + + let percent = try battery.percentage(using: #require(appState.connectedDevice?.activeOCVArray)) + let spoken = StatusQueryIntent.dialogText(for: appState) + + #expect(spoken == L10n.Tools.Intent.Status.Dialog.connectedWithBattery("Base Camp", percent)) + // Raw millivolts must never be spoken as the percentage. + #expect(!spoken.contains(String(Self.seededLevel))) + } + + @Test func `absent battery reports no reading not zero percent`() { + let appState = AppState() + // 0mV = no battery hardware (mains-powered); `isBatteryPresent` is false. + seedConnected(appState, state: .ready, name: "Mains Node", battery: BatteryInfo(level: 0)) + + let spoken = StatusQueryIntent.dialogText(for: appState) + #expect(spoken == L10n.Tools.Intent.Status.Dialog.connectedNoBattery("Mains Node")) + #expect(!spoken.contains("0%")) + } + + @Test func `missing battery reading reports no reading`() { + let appState = AppState() + seedConnected(appState, state: .ready, name: "No Poll Yet", battery: nil) + + #expect( + StatusQueryIntent.dialogText(for: appState) + == L10n.Tools.Intent.Status.Dialog.connectedNoBattery("No Poll Yet") + ) + } + + // MARK: - Connecting and disconnected rungs (offline name, no throw) + + @Test func `connecting speaks connecting`() { + let appState = AppState() + appState.connectionManager.persistConnection( + deviceID: UUID(), radioID: Self.radioID, deviceName: "Field Radio" + ) + appState.connectionManager.setTestState(connectionState: .connecting, connectedDevice: .some(nil)) + + #expect( + StatusQueryIntent.dialogText(for: appState) + == L10n.Tools.Intent.Status.Dialog.connecting("Field Radio") + ) + appState.connectionManager.clearPersistedConnection() + } + + @Test func `disconnected with known radio speaks offline name`() { + let appState = AppState() + appState.connectionManager.persistConnection( + deviceID: UUID(), radioID: Self.radioID, deviceName: "Last Radio" + ) + appState.connectionManager.setTestState(connectionState: .disconnected, connectedDevice: .some(nil)) + + #expect( + StatusQueryIntent.dialogText(for: appState) + == L10n.Tools.Intent.Status.Dialog.disconnectedNamed("Last Radio") + ) + appState.connectionManager.clearPersistedConnection() + } + + @Test func `never connected reports no radio`() { + let appState = AppState() + appState.connectionManager.clearPersistedConnection() + appState.connectionManager.setTestState(connectionState: .disconnected, connectedDevice: .some(nil)) + + #expect( + StatusQueryIntent.dialogText(for: appState) + == L10n.Tools.Intent.Status.Dialog.disconnectedUnknown + ) + } + + // MARK: - Pre-unlock (no AppState) + + @Test func `nil app state means not ready`() { + let bridge = IntentBridge() + // No `adopt`, so `bridge.appState` is nil; the pre-unlock BFU window, where + // `perform()` returns the not-ready dialog without reading any state. The + // `nil` bridge is what gates that branch. + #expect(bridge.appState == nil) + } + + // MARK: - Conversion correctness + + @Test func `millivolts convert to percent through OCV curve`() { + let battery = BatteryInfo(level: Self.seededLevel) + let percent = battery.percentage(using: Self.ocvArray) + #expect(percent == Self.expectedPercent) + #expect(percent != battery.level) + } + + @Test func `absent battery is not present`() { + #expect(BatteryInfo(level: 0).isBatteryPresent == false) + #expect(BatteryInfo(level: Self.seededLevel).isBatteryPresent == true) + } } diff --git a/MC1Tests/Localization/AirtimePercentLabelTests.swift b/MC1Tests/Localization/AirtimePercentLabelTests.swift index f4ff0056..186fb44a 100644 --- a/MC1Tests/Localization/AirtimePercentLabelTests.swift +++ b/MC1Tests/Localization/AirtimePercentLabelTests.swift @@ -1,13 +1,12 @@ -import Testing @testable import MC1 +import Testing @Suite("Airtime % label") struct AirtimePercentLabelTests { - - // A bare `%` in the .strings value is consumed by String(format:) inside L10n.tr, - // dropping the percent sign. The value must be escaped as `%%` to render literally. - @Test("airtimePercent renders a literal percent sign") - func rendersLiteralPercent() { - #expect(L10n.RemoteNodes.RemoteNodes.Status.airtimePercent == "Airtime %") - } + /// A bare `%` in the .strings value is consumed by String(format:) inside L10n.tr, + /// dropping the percent sign. The value must be escaped as `%%` to render literally. + @Test + func `airtimePercent renders a literal percent sign`() { + #expect(L10n.RemoteNodes.RemoteNodes.Status.airtimePercent == "Airtime %") + } } diff --git a/MC1Tests/MessageTextThemeTests.swift b/MC1Tests/MessageTextThemeTests.swift index 76611ac5..17b89f9c 100644 --- a/MC1Tests/MessageTextThemeTests.swift +++ b/MC1Tests/MessageTextThemeTests.swift @@ -1,53 +1,52 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing @MainActor @Suite("MessageText theme colors") struct MessageTextThemeTests { + @Test + func `incoming hashtag color is baked: different hashtagColor yields different output`() { + let cyan = MessageText.buildFormattedText( + text: "see #news", isOutgoing: false, currentUserName: nil, + isHighContrast: false, outgoingTextColor: .white, hashtagColor: .cyan, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + let orange = MessageText.buildFormattedText( + text: "see #news", isOutgoing: false, currentUserName: nil, + isHighContrast: false, outgoingTextColor: .white, hashtagColor: .orange, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + #expect(cyan.text != orange.text) + } - @Test("incoming hashtag color is baked: different hashtagColor yields different output") - func incomingHashtagColorIsBaked() { - let cyan = MessageText.buildFormattedText( - text: "see #news", isOutgoing: false, currentUserName: nil, - isHighContrast: false, outgoingTextColor: .white, hashtagColor: .cyan, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - let orange = MessageText.buildFormattedText( - text: "see #news", isOutgoing: false, currentUserName: nil, - isHighContrast: false, outgoingTextColor: .white, hashtagColor: .orange, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - #expect(cyan.text != orange.text) - } - - @Test("outgoing text color is baked: different outgoingTextColor yields different output") - func outgoingTextColorIsBaked() { - let white = MessageText.buildFormattedText( - text: "hello #news", isOutgoing: true, currentUserName: nil, - isHighContrast: false, outgoingTextColor: .white, hashtagColor: .cyan, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - let pink = MessageText.buildFormattedText( - text: "hello #news", isOutgoing: true, currentUserName: nil, - isHighContrast: false, outgoingTextColor: .pink, hashtagColor: .cyan, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - #expect(white.text != pink.text) - } + @Test + func `outgoing text color is baked: different outgoingTextColor yields different output`() { + let white = MessageText.buildFormattedText( + text: "hello #news", isOutgoing: true, currentUserName: nil, + isHighContrast: false, outgoingTextColor: .white, hashtagColor: .cyan, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + let pink = MessageText.buildFormattedText( + text: "hello #news", isOutgoing: true, currentUserName: nil, + isHighContrast: false, outgoingTextColor: .pink, hashtagColor: .cyan, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + #expect(white.text != pink.text) + } - @Test("default theme's hashtag color is the one baked into the hashtag run") - func defaultThemeHashtagColorIsBaked() { - // Drive the input from the real default theme rather than a literal, so retinting the - // default theme's hashtag asset cannot pass this test by coincidence. - let themed = MessageText.buildFormattedText( - text: "tap #news", isOutgoing: false, currentUserName: nil, - isHighContrast: false, - outgoingTextColor: Theme.default.outgoingTextColor, - hashtagColor: Theme.default.hashtagColor, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - let hashtagRun = themed.text.runs.first { $0.link != nil } - #expect(hashtagRun?.foregroundColor == Theme.default.hashtagColor) - } + @Test + func `default theme's hashtag color is the one baked into the hashtag run`() { + // Drive the input from the real default theme rather than a literal, so retinting the + // default theme's hashtag asset cannot pass this test by coincidence. + let themed = MessageText.buildFormattedText( + text: "tap #news", isOutgoing: false, currentUserName: nil, + isHighContrast: false, + outgoingTextColor: Theme.default.outgoingTextColor, + hashtagColor: Theme.default.hashtagColor, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + let hashtagRun = themed.text.runs.first { $0.link != nil } + #expect(hashtagRun?.foregroundColor == Theme.default.hashtagColor) + } } diff --git a/MC1Tests/Models/ContactOCVTests.swift b/MC1Tests/Models/ContactOCVTests.swift index 7db82107..f9abc7ec 100644 --- a/MC1Tests/Models/ContactOCVTests.swift +++ b/MC1Tests/Models/ContactOCVTests.swift @@ -1,122 +1,121 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("Contact OCV Tests") struct ContactOCVTests { + @Test + func `activeOCVArray returns Li-Ion by default`() { + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0xFF, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) - @Test("activeOCVArray returns Li-Ion by default") - func activeOCVArrayReturnsLiIonByDefault() { - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0xFF, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - - #expect(contact.activeOCVArray == OCVPreset.liIon.ocvArray) - } + #expect(contact.activeOCVArray == OCVPreset.liIon.ocvArray) + } - @Test("activeOCVArray returns preset array when set") - func activeOCVArrayReturnsPresetWhenSet() { - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0xFF, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - ocvPreset: OCVPreset.liFePO4.rawValue, - customOCVArrayString: nil - ) + @Test + func `activeOCVArray returns preset array when set`() { + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0xFF, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + ocvPreset: OCVPreset.liFePO4.rawValue, + customOCVArrayString: nil + ) - #expect(contact.activeOCVArray == OCVPreset.liFePO4.ocvArray) - } + #expect(contact.activeOCVArray == OCVPreset.liFePO4.ocvArray) + } - @Test("activeOCVArray returns custom array when valid") - func activeOCVArrayReturnsCustomWhenValid() { - let customArray = [4200, 4100, 4000, 3900, 3800, 3700, 3600, 3500, 3400, 3300, 3200] - let customString = customArray.map(String.init).joined(separator: ",") + @Test + func `activeOCVArray returns custom array when valid`() { + let customArray = [4200, 4100, 4000, 3900, 3800, 3700, 3600, 3500, 3400, 3300, 3200] + let customString = customArray.map(String.init).joined(separator: ",") - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0xFF, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - ocvPreset: OCVPreset.custom.rawValue, - customOCVArrayString: customString - ) + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0xFF, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + ocvPreset: OCVPreset.custom.rawValue, + customOCVArrayString: customString + ) - #expect(contact.activeOCVArray == customArray) - } + #expect(contact.activeOCVArray == customArray) + } - @Test("activeOCVArray falls back to Li-Ion for invalid custom array") - func activeOCVArrayFallsBackForInvalidCustom() { - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0xFF, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - ocvPreset: OCVPreset.custom.rawValue, - customOCVArrayString: "invalid" - ) + @Test + func `activeOCVArray falls back to Li-Ion for invalid custom array`() { + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0xFF, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + ocvPreset: OCVPreset.custom.rawValue, + customOCVArrayString: "invalid" + ) - #expect(contact.activeOCVArray == OCVPreset.liIon.ocvArray) - } + #expect(contact.activeOCVArray == OCVPreset.liIon.ocvArray) + } } diff --git a/MC1Tests/Models/ConversationFilteringTests.swift b/MC1Tests/Models/ConversationFilteringTests.swift index e779c8ca..ce04271e 100644 --- a/MC1Tests/Models/ConversationFilteringTests.swift +++ b/MC1Tests/Models/ConversationFilteringTests.swift @@ -1,203 +1,202 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing -@Suite struct ConversationFilteringTests { - - // MARK: - Test Data - - private func makeContact( - name: String, - isFavorite: Bool = false, - unreadCount: Int = 0, - isMuted: Bool = false - ) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0, count: 32), - name: name, - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: isMuted, - isFavorite: isFavorite, - lastMessageDate: Date(), - unreadCount: unreadCount - ) - } - - private func makeChannel( - name: String, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false - ) -> ChannelDTO { - ChannelDTO( - id: UUID(), - radioID: UUID(), - index: 1, - name: name, - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: Date(), - unreadCount: unreadCount, - notificationLevel: notificationLevel, - isFavorite: isFavorite - ) - } - - private func makeRoom( - name: String, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false - ) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0, count: 32), - name: name, - role: .roomServer, - isConnected: true, - lastConnectedDate: Date(), - unreadCount: unreadCount, - notificationLevel: notificationLevel, - isFavorite: isFavorite - ) - } - - // MARK: - Filter Tests - - @Test func allFilterShowsAll() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .channel(makeChannel(name: "General")), - .room(makeRoom(name: "Room1")) - ] - - let result = conversations.filtered(by: .all, searchText: "") - - #expect(result.count == 3) - } - - @Test func filterByUnread() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice", unreadCount: 5)), - .direct(makeContact(name: "Bob", unreadCount: 0)), - .channel(makeChannel(name: "General", unreadCount: 2)) - ] - - let result = conversations.filtered(by: .unread, searchText: "") - - #expect(result.count == 2) - #expect(result.allSatisfy { $0.unreadCount > 0 }) - } - - @Test func filterByDirectMessages() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .direct(makeContact(name: "Bob")), - .channel(makeChannel(name: "General")), - .room(makeRoom(name: "Room1")) - ] - - let result = conversations.filtered(by: .directMessages, searchText: "") - - #expect(result.count == 2) - #expect(result.allSatisfy { - if case .direct = $0 { return true } - return false - }) - } - - @Test func filterByChannelsExcludesRooms() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .channel(makeChannel(name: "General")), - .room(makeRoom(name: "Room1")) - ] - - let result = conversations.filtered(by: .channels, searchText: "") - - #expect(result.count == 1) - #expect(result.allSatisfy { - if case .channel = $0 { return true } - return false - }) - } - - @Test func filterByRoomsExcludesChannels() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .channel(makeChannel(name: "General")), - .room(makeRoom(name: "Room1")), - .room(makeRoom(name: "Room2")) - ] - - let result = conversations.filtered(by: .rooms, searchText: "") - - #expect(result.count == 2) - #expect(result.allSatisfy { - if case .room = $0 { return true } - return false - }) - } - - @Test func searchWithinFilter() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice", unreadCount: 1)), - .direct(makeContact(name: "Bob", unreadCount: 1)), - .direct(makeContact(name: "Charlie", unreadCount: 0)) - ] - - let result = conversations.filtered(by: .unread, searchText: "Ali") - - #expect(result.count == 1) - #expect(result.first?.displayName == "Alice") - } - - @Test func searchOnlyWithoutFilter() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .direct(makeContact(name: "Bob")), - .channel(makeChannel(name: "Alpha")) - ] - - let result = conversations.filtered(by: .all, searchText: "Al") - - #expect(result.count == 2) - } - - @Test func emptyResultsWhenNoMatch() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .direct(makeContact(name: "Bob")) - ] - - let result = conversations.filtered(by: .all, searchText: "Zzzz") - - #expect(result.isEmpty) - } - - @Test func unreadFilterExcludesMuted() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice", unreadCount: 5, isMuted: false)), - .direct(makeContact(name: "Bob", unreadCount: 3, isMuted: true)), - .channel(makeChannel(name: "General", unreadCount: 2, notificationLevel: .all)) - ] - - let result = conversations.filtered(by: .unread, searchText: "") - - #expect(result.count == 2) - #expect(!result.contains { $0.displayName == "Bob" }) - } +struct ConversationFilteringTests { + // MARK: - Test Data + + private func makeContact( + name: String, + isFavorite: Bool = false, + unreadCount: Int = 0, + isMuted: Bool = false + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0, count: 32), + name: name, + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: isMuted, + isFavorite: isFavorite, + lastMessageDate: Date(), + unreadCount: unreadCount + ) + } + + private func makeChannel( + name: String, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false + ) -> ChannelDTO { + ChannelDTO( + id: UUID(), + radioID: UUID(), + index: 1, + name: name, + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: unreadCount, + notificationLevel: notificationLevel, + isFavorite: isFavorite + ) + } + + private func makeRoom( + name: String, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0, count: 32), + name: name, + role: .roomServer, + isConnected: true, + lastConnectedDate: Date(), + unreadCount: unreadCount, + notificationLevel: notificationLevel, + isFavorite: isFavorite + ) + } + + // MARK: - Filter Tests + + @Test func `all filter shows all`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .channel(makeChannel(name: "General")), + .room(makeRoom(name: "Room1")) + ] + + let result = conversations.filtered(by: .all, searchText: "") + + #expect(result.count == 3) + } + + @Test func `filter by unread`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice", unreadCount: 5)), + .direct(makeContact(name: "Bob", unreadCount: 0)), + .channel(makeChannel(name: "General", unreadCount: 2)) + ] + + let result = conversations.filtered(by: .unread, searchText: "") + + #expect(result.count == 2) + #expect(result.allSatisfy { $0.unreadCount > 0 }) + } + + @Test func `filter by direct messages`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .direct(makeContact(name: "Bob")), + .channel(makeChannel(name: "General")), + .room(makeRoom(name: "Room1")) + ] + + let result = conversations.filtered(by: .directMessages, searchText: "") + + #expect(result.count == 2) + #expect(result.allSatisfy { + if case .direct = $0 { return true } + return false + }) + } + + @Test func `filter by channels excludes rooms`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .channel(makeChannel(name: "General")), + .room(makeRoom(name: "Room1")) + ] + + let result = conversations.filtered(by: .channels, searchText: "") + + #expect(result.count == 1) + #expect(result.allSatisfy { + if case .channel = $0 { return true } + return false + }) + } + + @Test func `filter by rooms excludes channels`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .channel(makeChannel(name: "General")), + .room(makeRoom(name: "Room1")), + .room(makeRoom(name: "Room2")) + ] + + let result = conversations.filtered(by: .rooms, searchText: "") + + #expect(result.count == 2) + #expect(result.allSatisfy { + if case .room = $0 { return true } + return false + }) + } + + @Test func `search within filter`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice", unreadCount: 1)), + .direct(makeContact(name: "Bob", unreadCount: 1)), + .direct(makeContact(name: "Charlie", unreadCount: 0)) + ] + + let result = conversations.filtered(by: .unread, searchText: "Ali") + + #expect(result.count == 1) + #expect(result.first?.displayName == "Alice") + } + + @Test func `search only without filter`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .direct(makeContact(name: "Bob")), + .channel(makeChannel(name: "Alpha")) + ] + + let result = conversations.filtered(by: .all, searchText: "Al") + + #expect(result.count == 2) + } + + @Test func `empty results when no match`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .direct(makeContact(name: "Bob")) + ] + + let result = conversations.filtered(by: .all, searchText: "Zzzz") + + #expect(result.isEmpty) + } + + @Test func `unread filter excludes muted`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice", unreadCount: 5, isMuted: false)), + .direct(makeContact(name: "Bob", unreadCount: 3, isMuted: true)), + .channel(makeChannel(name: "General", unreadCount: 2, notificationLevel: .all)) + ] + + let result = conversations.filtered(by: .unread, searchText: "") + + #expect(result.count == 2) + #expect(!result.contains { $0.displayName == "Bob" }) + } } diff --git a/MC1Tests/Models/DevicePreferenceStoreTests.swift b/MC1Tests/Models/DevicePreferenceStoreTests.swift index 6842ba52..3efad871 100644 --- a/MC1Tests/Models/DevicePreferenceStoreTests.swift +++ b/MC1Tests/Models/DevicePreferenceStoreTests.swift @@ -1,118 +1,116 @@ import Foundation -import Testing - @testable import MC1 +import Testing @Suite("DevicePreferenceStore Tests") struct DevicePreferenceStoreTests { - - @Test("Auto-update location defaults to false") - func autoUpdateDefaultsFalse() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - #expect(store.isAutoUpdateLocationEnabled(deviceID: UUID()) == false) - } - - @Test("GPS source defaults to phone") - func gpsSourceDefaultsToPhone() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - #expect(store.gpsSource(deviceID: UUID()) == .phone) - } - - @Test("Auto-update values are scoped per device") - func autoUpdatePerDeviceIsolation() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - let deviceA = UUID() - let deviceB = UUID() - - store.setAutoUpdateLocationEnabled(true, deviceID: deviceA) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == true) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == false) - - store.setAutoUpdateLocationEnabled(true, deviceID: deviceB) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == true) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == true) - - store.setAutoUpdateLocationEnabled(false, deviceID: deviceA) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == false) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == true) - } - - @Test("GPS source values are scoped per device") - func gpsSourcePerDeviceIsolation() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - let deviceA = UUID() - let deviceB = UUID() - - store.setGPSSource(.device, deviceID: deviceA) - #expect(store.gpsSource(deviceID: deviceA) == .device) - #expect(store.gpsSource(deviceID: deviceB) == .phone) - - store.setGPSSource(.device, deviceID: deviceB) - #expect(store.gpsSource(deviceID: deviceA) == .device) - #expect(store.gpsSource(deviceID: deviceB) == .device) - - store.setGPSSource(.phone, deviceID: deviceA) - #expect(store.gpsSource(deviceID: deviceA) == .phone) - #expect(store.gpsSource(deviceID: deviceB) == .device) - } - - @Test("hasSetGPSSource returns false when no source has been set") - func hasSetGPSSourceDefaultsFalse() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - #expect(store.hasSetGPSSource(deviceID: UUID()) == false) - } - - @Test("hasSetGPSSource returns true after setting a source") - func hasSetGPSSourceAfterSet() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - let deviceID = UUID() - - #expect(store.hasSetGPSSource(deviceID: deviceID) == false) - store.setGPSSource(.phone, deviceID: deviceID) - #expect(store.hasSetGPSSource(deviceID: deviceID) == true) - } - - @Test("Setting and getting round-trips correctly") - func roundTrip() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - let deviceID = UUID() - - store.setAutoUpdateLocationEnabled(true, deviceID: deviceID) - store.setGPSSource(.device, deviceID: deviceID) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceID) == true) - #expect(store.gpsSource(deviceID: deviceID) == .device) - - store.setAutoUpdateLocationEnabled(false, deviceID: deviceID) - store.setGPSSource(.phone, deviceID: deviceID) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceID) == false) - #expect(store.gpsSource(deviceID: deviceID) == .phone) - } + @Test + func `Auto-update location defaults to false`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + #expect(store.isAutoUpdateLocationEnabled(deviceID: UUID()) == false) + } + + @Test + func `GPS source defaults to phone`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + #expect(store.gpsSource(deviceID: UUID()) == .phone) + } + + @Test + func `Auto-update values are scoped per device`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + let deviceA = UUID() + let deviceB = UUID() + + store.setAutoUpdateLocationEnabled(true, deviceID: deviceA) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == true) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == false) + + store.setAutoUpdateLocationEnabled(true, deviceID: deviceB) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == true) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == true) + + store.setAutoUpdateLocationEnabled(false, deviceID: deviceA) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == false) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == true) + } + + @Test + func `GPS source values are scoped per device`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + let deviceA = UUID() + let deviceB = UUID() + + store.setGPSSource(.device, deviceID: deviceA) + #expect(store.gpsSource(deviceID: deviceA) == .device) + #expect(store.gpsSource(deviceID: deviceB) == .phone) + + store.setGPSSource(.device, deviceID: deviceB) + #expect(store.gpsSource(deviceID: deviceA) == .device) + #expect(store.gpsSource(deviceID: deviceB) == .device) + + store.setGPSSource(.phone, deviceID: deviceA) + #expect(store.gpsSource(deviceID: deviceA) == .phone) + #expect(store.gpsSource(deviceID: deviceB) == .device) + } + + @Test + func `hasSetGPSSource returns false when no source has been set`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + #expect(store.hasSetGPSSource(deviceID: UUID()) == false) + } + + @Test + func `hasSetGPSSource returns true after setting a source`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + let deviceID = UUID() + + #expect(store.hasSetGPSSource(deviceID: deviceID) == false) + store.setGPSSource(.phone, deviceID: deviceID) + #expect(store.hasSetGPSSource(deviceID: deviceID) == true) + } + + @Test + func `Setting and getting round-trips correctly`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + let deviceID = UUID() + + store.setAutoUpdateLocationEnabled(true, deviceID: deviceID) + store.setGPSSource(.device, deviceID: deviceID) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceID) == true) + #expect(store.gpsSource(deviceID: deviceID) == .device) + + store.setAutoUpdateLocationEnabled(false, deviceID: deviceID) + store.setGPSSource(.phone, deviceID: deviceID) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceID) == false) + #expect(store.gpsSource(deviceID: deviceID) == .phone) + } } diff --git a/MC1Tests/Models/LinkPreviewPreferencesTests.swift b/MC1Tests/Models/LinkPreviewPreferencesTests.swift index 3d9d4358..f4e0a858 100644 --- a/MC1Tests/Models/LinkPreviewPreferencesTests.swift +++ b/MC1Tests/Models/LinkPreviewPreferencesTests.swift @@ -1,63 +1,62 @@ -import Testing import Foundation @testable import MC1 +import Testing @Suite("LinkPreviewPreferences Tests") struct LinkPreviewPreferencesTests { - - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - @Test("Has expected defaults: previews off, auto-resolve on") - func defaultsToEnabled() { - let prefs = LinkPreviewPreferences(defaults: defaults) - #expect(prefs.previewsEnabled == false) - #expect(prefs.autoResolveDM == true) - #expect(prefs.autoResolveChannels == true) - } - - @Test("shouldAutoResolve for DM respects settings") - func shouldAutoResolveForDM() { - var prefs = LinkPreviewPreferences(defaults: defaults) - - // Master on, auto on -> true - prefs.previewsEnabled = true - prefs.autoResolveDM = true - #expect(prefs.shouldAutoResolve(isChannelMessage: false) == true) - - // Master on, auto off -> false - prefs.autoResolveDM = false - #expect(prefs.shouldAutoResolve(isChannelMessage: false) == false) - - // Master off -> false regardless - prefs.previewsEnabled = false - prefs.autoResolveDM = true - #expect(prefs.shouldAutoResolve(isChannelMessage: false) == false) - } - - @Test("shouldAutoResolve for channel respects settings") - func shouldAutoResolveForChannel() { - var prefs = LinkPreviewPreferences(defaults: defaults) - - prefs.previewsEnabled = true - prefs.autoResolveChannels = true - #expect(prefs.shouldAutoResolve(isChannelMessage: true) == true) - - prefs.autoResolveChannels = false - #expect(prefs.shouldAutoResolve(isChannelMessage: true) == false) - } - - @Test("shouldShowPreview reflects master toggle") - func shouldShowPreview() { - var prefs = LinkPreviewPreferences(defaults: defaults) - - prefs.previewsEnabled = true - #expect(prefs.shouldShowPreview == true) - - prefs.previewsEnabled = false - #expect(prefs.shouldShowPreview == false) - } + private let defaults: UserDefaults + + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `Has expected defaults: previews off, auto-resolve on`() { + let prefs = LinkPreviewPreferences(defaults: defaults) + #expect(prefs.previewsEnabled == false) + #expect(prefs.autoResolveDM == true) + #expect(prefs.autoResolveChannels == true) + } + + @Test + func `shouldAutoResolve for DM respects settings`() { + let prefs = LinkPreviewPreferences(defaults: defaults) + + // Master on, auto on -> true + prefs.previewsEnabled = true + prefs.autoResolveDM = true + #expect(prefs.shouldAutoResolve(isChannelMessage: false) == true) + + // Master on, auto off -> false + prefs.autoResolveDM = false + #expect(prefs.shouldAutoResolve(isChannelMessage: false) == false) + + // Master off -> false regardless + prefs.previewsEnabled = false + prefs.autoResolveDM = true + #expect(prefs.shouldAutoResolve(isChannelMessage: false) == false) + } + + @Test + func `shouldAutoResolve for channel respects settings`() { + let prefs = LinkPreviewPreferences(defaults: defaults) + + prefs.previewsEnabled = true + prefs.autoResolveChannels = true + #expect(prefs.shouldAutoResolve(isChannelMessage: true) == true) + + prefs.autoResolveChannels = false + #expect(prefs.shouldAutoResolve(isChannelMessage: true) == false) + } + + @Test + func `shouldShowPreview reflects global toggle`() { + let prefs = LinkPreviewPreferences(defaults: defaults) + + prefs.previewsEnabled = true + #expect(prefs.shouldShowPreview == true) + + prefs.previewsEnabled = false + #expect(prefs.shouldShowPreview == false) + } } diff --git a/MC1Tests/Models/OCVPresetTests.swift b/MC1Tests/Models/OCVPresetTests.swift index 8bcc7a90..806d0b26 100644 --- a/MC1Tests/Models/OCVPresetTests.swift +++ b/MC1Tests/Models/OCVPresetTests.swift @@ -1,148 +1,147 @@ -import Testing @testable import MC1Services +import Testing @Suite("OCVPreset Tests") struct OCVPresetTests { - - @Test("All presets have exactly 11 values", arguments: OCVPreset.allCases.filter { $0 != .custom }) - func presetsHaveCorrectLength(preset: OCVPreset) { - #expect(preset.ocvArray.count == 11, "Preset \(preset) should have 11 values") - } - - @Test("All preset arrays are descending", arguments: OCVPreset.allCases.filter { $0 != .custom }) - func presetsAreDescending(preset: OCVPreset) { - let array = preset.ocvArray - for i in 0..<(array.count - 1) { - #expect(array[i] > array[i + 1], "Preset \(preset) should be descending at index \(i)") - } - } - - @Test("All presets fit within UI validation range", arguments: OCVPreset.allCases.filter { $0 != .custom }) - func presetsFitValidationRange(preset: OCVPreset) { - for value in preset.ocvArray { - #expect( - OCVPreset.validMillivoltRange.contains(value), - "Preset \(preset) has value \(value) outside \(OCVPreset.validMillivoltRange)" - ) - } - } - - @Test("All presets have display names", arguments: OCVPreset.allCases) - func presetsHaveDisplayNames(preset: OCVPreset) { - #expect(!preset.displayName.isEmpty, "Preset \(preset) should have a display name") - } - - @Test("Selectable presets excludes custom") - func selectablePresetsExcludesCustom() { - #expect(!OCVPreset.selectablePresets.contains(.custom)) - #expect(OCVPreset.selectablePresets.count == OCVPreset.allCases.count - 1) - } - - @Test("Li-Ion preset has expected values") - func liIonPresetValues() { - let expected = [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] - #expect(OCVPreset.liIon.ocvArray == expected) - } - - @Test("WisMesh Tag preset has expected values") - func wisMeshTagPresetValues() { - let expected = [4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990] - #expect(OCVPreset.wisMeshTag.ocvArray == expected) - } - - @Test("LilyGo T-Beam 1W preset has expected values") - func lilyGoTBeam1WPresetValues() { - let expected = [7950, 7850, 7750, 7580, 7440, 7310, 7150, 7005, 6860, 6685, 6000] - #expect(OCVPreset.lilyGoTBeam1W.ocvArray == expected) - } - - @Test("ThinkNode M6 preset has expected values") - func thinkNodeM6PresetValues() { - let expected = [4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450] - #expect(OCVPreset.thinkNodeM6.ocvArray == expected) - } - - // MARK: - Category Tests - - @Test("Battery chemistry presets include only chemistry types") - func batteryChemistryPresetsIncludeOnlyChemistryTypes() { - let presets = OCVPreset.batteryChemistryPresets - - #expect(presets.contains(.liIon)) - #expect(presets.contains(.liFePO4)) - #expect(presets.contains(.leadAcid)) - #expect(presets.contains(.alkaline)) - #expect(presets.contains(.niMH)) - #expect(presets.contains(.lto)) - #expect(presets.count == 6) - } - - @Test("Battery chemistry presets exclude device-specific presets") - func batteryChemistryPresetsExcludeDeviceSpecific() { - let presets = OCVPreset.batteryChemistryPresets - - #expect(!presets.contains(.trackerT1000E)) - #expect(!presets.contains(.heltecPocket5000)) - #expect(!presets.contains(.custom)) - } - - @Test("Li-Ion is battery chemistry category") - func liIonIsBatteryChemistry() { - #expect(OCVPreset.liIon.category == .batteryChemistry) - } - - @Test("Tracker T1000-E is device specific category") - func trackerIsDeviceSpecific() { - #expect(OCVPreset.trackerT1000E.category == .deviceSpecific) - } - - @Test("Custom is device specific category") - func customIsDeviceSpecific() { - #expect(OCVPreset.custom.category == .deviceSpecific) - } - - // MARK: - Manufacturer Matching Tests - - @Test("Seeed Tracker T1000-e maps to trackerT1000E preset") - func seeedTrackerMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "Seeed Tracker T1000-e") == .trackerT1000E) - } - - @Test("Seeed Wio Tracker L1 maps to seeedWioTracker preset") - func seeedWioTrackerMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "Seeed Wio Tracker L1") == .seeedWioTracker) - } - - @Test("Seeed SenseCap Solar maps to seeedSolarNode preset") - func seeedSenseCapMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "Seeed SenseCap Solar") == .seeedSolarNode) - } - - @Test("RAK WisMesh Tag maps to wisMeshTag preset") - func rakWisMeshTagMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "RAK WisMesh Tag") == .wisMeshTag) - } - - @Test("LilyGo T-Beam 1W maps to lilyGoTBeam1W preset") - func lilyGoTBeam1WMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "LilyGo T-Beam 1W") == .lilyGoTBeam1W) - } - - @Test("Elecrow ThinkNode M6 maps to thinkNodeM6 preset") - func elecrowThinkNodeM6MapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "Elecrow ThinkNode M6") == .thinkNodeM6) - } - - @Test("Unknown manufacturer returns nil") - func unknownManufacturerReturnsNil() { - #expect(OCVPreset.preset(forManufacturer: "Generic ESP32") == nil) - #expect(OCVPreset.preset(forManufacturer: "Heltec MeshPocket") == nil) - #expect(OCVPreset.preset(forManufacturer: "") == nil) - } - - @Test("Manufacturer matching is case-sensitive") - func manufacturerMatchingIsCaseSensitive() { - #expect(OCVPreset.preset(forManufacturer: "seeed tracker t1000-e") == nil) - #expect(OCVPreset.preset(forManufacturer: "SEEED TRACKER T1000-E") == nil) - } + @Test(arguments: OCVPreset.allCases.filter { $0 != .custom }) + func `All presets have exactly 11 values`(preset: OCVPreset) { + #expect(preset.ocvArray.count == 11, "Preset \(preset) should have 11 values") + } + + @Test(arguments: OCVPreset.allCases.filter { $0 != .custom }) + func `All preset arrays are descending`(preset: OCVPreset) { + let array = preset.ocvArray + for i in 0..<(array.count - 1) { + #expect(array[i] > array[i + 1], "Preset \(preset) should be descending at index \(i)") + } + } + + @Test(arguments: OCVPreset.allCases.filter { $0 != .custom }) + func `All presets fit within UI validation range`(preset: OCVPreset) { + for value in preset.ocvArray { + #expect( + OCVPreset.validMillivoltRange.contains(value), + "Preset \(preset) has value \(value) outside \(OCVPreset.validMillivoltRange)" + ) + } + } + + @Test(arguments: OCVPreset.allCases) + func `All presets have display names`(preset: OCVPreset) { + #expect(!preset.displayName.isEmpty, "Preset \(preset) should have a display name") + } + + @Test + func `Selectable presets excludes custom`() { + #expect(!OCVPreset.selectablePresets.contains(.custom)) + #expect(OCVPreset.selectablePresets.count == OCVPreset.allCases.count - 1) + } + + @Test + func `Li-Ion preset has expected values`() { + let expected = [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] + #expect(OCVPreset.liIon.ocvArray == expected) + } + + @Test + func `WisMesh Tag preset has expected values`() { + let expected = [4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990] + #expect(OCVPreset.wisMeshTag.ocvArray == expected) + } + + @Test + func `LilyGo T-Beam 1W preset has expected values`() { + let expected = [7950, 7850, 7750, 7580, 7440, 7310, 7150, 7005, 6860, 6685, 6000] + #expect(OCVPreset.lilyGoTBeam1W.ocvArray == expected) + } + + @Test + func `ThinkNode M6 preset has expected values`() { + let expected = [4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450] + #expect(OCVPreset.thinkNodeM6.ocvArray == expected) + } + + // MARK: - Category Tests + + @Test + func `Battery chemistry presets include only chemistry types`() { + let presets = OCVPreset.batteryChemistryPresets + + #expect(presets.contains(.liIon)) + #expect(presets.contains(.liFePO4)) + #expect(presets.contains(.leadAcid)) + #expect(presets.contains(.alkaline)) + #expect(presets.contains(.niMH)) + #expect(presets.contains(.lto)) + #expect(presets.count == 6) + } + + @Test + func `Battery chemistry presets exclude device-specific presets`() { + let presets = OCVPreset.batteryChemistryPresets + + #expect(!presets.contains(.trackerT1000E)) + #expect(!presets.contains(.heltecPocket5000)) + #expect(!presets.contains(.custom)) + } + + @Test + func `Li-Ion is battery chemistry category`() { + #expect(OCVPreset.liIon.category == .batteryChemistry) + } + + @Test + func `Tracker T1000-E is device specific category`() { + #expect(OCVPreset.trackerT1000E.category == .deviceSpecific) + } + + @Test + func `Custom is device specific category`() { + #expect(OCVPreset.custom.category == .deviceSpecific) + } + + // MARK: - Manufacturer Matching Tests + + @Test + func `Seeed Tracker T1000-e maps to trackerT1000E preset`() { + #expect(OCVPreset.preset(forManufacturer: "Seeed Tracker T1000-e") == .trackerT1000E) + } + + @Test + func `Seeed Wio Tracker L1 maps to seeedWioTracker preset`() { + #expect(OCVPreset.preset(forManufacturer: "Seeed Wio Tracker L1") == .seeedWioTracker) + } + + @Test + func `Seeed SenseCap Solar maps to seeedSolarNode preset`() { + #expect(OCVPreset.preset(forManufacturer: "Seeed SenseCap Solar") == .seeedSolarNode) + } + + @Test + func `RAK WisMesh Tag maps to wisMeshTag preset`() { + #expect(OCVPreset.preset(forManufacturer: "RAK WisMesh Tag") == .wisMeshTag) + } + + @Test + func `LilyGo T-Beam 1W maps to lilyGoTBeam1W preset`() { + #expect(OCVPreset.preset(forManufacturer: "LilyGo T-Beam 1W") == .lilyGoTBeam1W) + } + + @Test + func `Elecrow ThinkNode M6 maps to thinkNodeM6 preset`() { + #expect(OCVPreset.preset(forManufacturer: "Elecrow ThinkNode M6") == .thinkNodeM6) + } + + @Test + func `Unknown manufacturer returns nil`() { + #expect(OCVPreset.preset(forManufacturer: "Generic ESP32") == nil) + #expect(OCVPreset.preset(forManufacturer: "Heltec MeshPocket") == nil) + #expect(OCVPreset.preset(forManufacturer: "") == nil) + } + + @Test + func `Manufacturer matching is case-sensitive`() { + #expect(OCVPreset.preset(forManufacturer: "seeed tracker t1000-e") == nil) + #expect(OCVPreset.preset(forManufacturer: "SEEED TRACKER T1000-E") == nil) + } } diff --git a/MC1Tests/Models/RemoteNodeModelTests.swift b/MC1Tests/Models/RemoteNodeModelTests.swift index 8141453e..8a046cf0 100644 --- a/MC1Tests/Models/RemoteNodeModelTests.swift +++ b/MC1Tests/Models/RemoteNodeModelTests.swift @@ -1,241 +1,240 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("Remote Node Model Tests") struct RemoteNodeModelTests { - - // MARK: - RemoteNodeSession Tests - - @Test("RemoteNodeSession correctly stores role") - func remoteNodeSessionStoresRole() async throws { - let container = try DataStore.createContainer(inMemory: true) - let dataStore = DataStore(modelContainer: container) - - let deviceID = UUID() - let publicKey = Data((0.. WhatsNewVersion(major: 1, minor: 9)) - } + @Test + func `comparison is lexicographic on (major, minor)`() { + #expect(WhatsNewVersion(major: 1, minor: 0) < WhatsNewVersion(major: 1, minor: 1)) + #expect(WhatsNewVersion(major: 1, minor: 9) < WhatsNewVersion(major: 2, minor: 0)) + #expect(WhatsNewVersion(major: 1, minor: 0) < WhatsNewVersion(major: 1, minor: 2)) + #expect(WhatsNewVersion(major: 2, minor: 0) > WhatsNewVersion(major: 1, minor: 9)) + } - @Test("a patch bump is not greater", arguments: [ - ("1.0", "1.0.2"), - ("1.0.0", "1.0.9") - ]) - func patchBumpNotGreater(baseline: String, current: String) { - let lhs = WhatsNewVersion(marketingVersion: current) - let rhs = WhatsNewVersion(marketingVersion: baseline) - #expect(lhs == rhs) - #expect(!(lhs! > rhs!)) - } + @Test(arguments: [ + ("1.0", "1.0.2"), + ("1.0.0", "1.0.9") + ]) + func `a patch bump is not greater`(baseline: String, current: String) throws { + let lhs = WhatsNewVersion(marketingVersion: current) + let rhs = WhatsNewVersion(marketingVersion: baseline) + #expect(lhs == rhs) + #expect(try !(#require(lhs) > rhs!)) + } - @Test("unparseable strings yield nil", arguments: [ - "unknown", "2", "2.0-beta", "1.0 (123)", "", "x.y", "1." - ]) - func unparseableYieldsNil(input: String) { - #expect(WhatsNewVersion(marketingVersion: input) == nil) - } + @Test(arguments: [ + "unknown", "2", "2.0-beta", "1.0 (123)", "", "x.y", "1." + ]) + func `unparseable strings yield nil`(input: String) { + #expect(WhatsNewVersion(marketingVersion: input) == nil) + } } diff --git a/MC1Tests/Protocol/CLIResponseTests.swift b/MC1Tests/Protocol/CLIResponseTests.swift index b094916c..0926cd23 100644 --- a/MC1Tests/Protocol/CLIResponseTests.swift +++ b/MC1Tests/Protocol/CLIResponseTests.swift @@ -1,346 +1,358 @@ -import Testing @testable import MC1Services +import Testing struct CLIResponseTests { - - // MARK: - Prompt Prefix Stripping - - @Test func parse_promptPrefix_stripsPrefix() { - let result = CLIResponse.parse("> OK") - #expect(result == .ok) - } - - @Test func parse_noPromptPrefix_stillWorks() { - let result = CLIResponse.parse("OK") - #expect(result == .ok) - } - - @Test func parse_promptPrefix_withWhitespace() { - let result = CLIResponse.parse(" > OK ") - #expect(result == .ok) - } - - @Test func parse_okWithMessage_clockSet() { - let result = CLIResponse.parse("OK - clock set: 16:13 - 9/1/2026 UTC") - #expect(result == .ok) - } - - @Test func parse_okWithMessage_withPromptPrefix() { - let result = CLIResponse.parse("> OK - clock set: 16:13 - 9/1/2026 UTC") - #expect(result == .ok) - } - - // MARK: - Radio with Prompt - - @Test func parse_radio_withPromptPrefix() { - let result = CLIResponse.parse("> 910.5250244,62.5,7,8", forQuery: "get radio") - if case let .radio(freq, bw, sf, cr) = result { - #expect(abs(freq - 910.5250244) < 0.0001) - #expect(abs(bw - 62.5) < 0.01) - #expect(sf == 7) - #expect(cr == 8) - } else { - Issue.record("Expected .radio, got \(result)") - } - } - - @Test func parse_radio_withoutPromptPrefix() { - let result = CLIResponse.parse("915.000,250.0,10,5", forQuery: "get radio") - if case let .radio(freq, bw, sf, cr) = result { - #expect(abs(freq - 915.0) < 0.0001) - #expect(abs(bw - 250.0) < 0.01) - #expect(sf == 10) - #expect(cr == 5) - } else { - Issue.record("Expected .radio, got \(result)") - } - } - - // MARK: - Name with Prompt - - @Test func parse_name_withPromptPrefix() { - let result = CLIResponse.parse("> Sunnyslope Repeater", forQuery: "get name") - #expect(result == .name("Sunnyslope Repeater")) - } - - @Test func parse_name_withoutPromptPrefix() { - let result = CLIResponse.parse("My Node", forQuery: "get name") - #expect(result == .name("My Node")) - } - - // MARK: - Repeat Mode with Prompt - - @Test func parse_repeatMode_on_withPromptPrefix() { - let result = CLIResponse.parse("> on", forQuery: "get repeat") - #expect(result == .repeatMode(true)) - } - - @Test func parse_repeatMode_off_withPromptPrefix() { - let result = CLIResponse.parse("> off", forQuery: "get repeat") - #expect(result == .repeatMode(false)) - } - - @Test func parse_repeatMode_withoutPromptPrefix() { - let result = CLIResponse.parse("on", forQuery: "get repeat") - #expect(result == .repeatMode(true)) - } - - // MARK: - TX Power with Prompt - - @Test func parse_txPower_withPromptPrefix() { - let result = CLIResponse.parse("> 22", forQuery: "get tx") - #expect(result == .txPower(22)) - } - - @Test func parse_txPower_withoutPromptPrefix() { - let result = CLIResponse.parse("17", forQuery: "get tx") - #expect(result == .txPower(17)) - } - - // MARK: - Coordinates with Prompt - - @Test func parse_latitude_withPromptPrefix() { - let result = CLIResponse.parse("> 33.4484", forQuery: "get lat") - if case let .latitude(lat) = result { - #expect(abs(lat - 33.4484) < 0.0001) - } else { - Issue.record("Expected .latitude, got \(result)") - } - } - - @Test func parse_latitude_withoutPromptPrefix() { - let result = CLIResponse.parse("40.7128", forQuery: "get lat") - if case let .latitude(lat) = result { - #expect(abs(lat - 40.7128) < 0.0001) - } else { - Issue.record("Expected .latitude, got \(result)") - } - } - - @Test func parse_longitude_withPromptPrefix() { - let result = CLIResponse.parse("> -112.0740", forQuery: "get lon") - if case let .longitude(lon) = result { - #expect(abs(lon - (-112.0740)) < 0.0001) - } else { - Issue.record("Expected .longitude, got \(result)") - } - } - - @Test func parse_longitude_withoutPromptPrefix() { - let result = CLIResponse.parse("-74.0060", forQuery: "get lon") - if case let .longitude(lon) = result { - #expect(abs(lon - (-74.0060)) < 0.0001) - } else { - Issue.record("Expected .longitude, got \(result)") - } - } - - // MARK: - Intervals with Prompt - - @Test func parse_advertInterval_withPromptPrefix() { - let result = CLIResponse.parse("> 5", forQuery: "get advert.interval") - #expect(result == .advertInterval(5)) - } - - @Test func parse_advertInterval_withoutPromptPrefix() { - let result = CLIResponse.parse("10", forQuery: "get advert.interval") - #expect(result == .advertInterval(10)) - } - - @Test func parse_floodAdvertInterval_withPromptPrefix() { - let result = CLIResponse.parse("> 12", forQuery: "get flood.advert.interval") - #expect(result == .floodAdvertInterval(12)) - } - - @Test func parse_floodAdvertInterval_withoutPromptPrefix() { - let result = CLIResponse.parse("6", forQuery: "get flood.advert.interval") - #expect(result == .floodAdvertInterval(6)) - } - - @Test func parse_floodMax_withPromptPrefix() { - let result = CLIResponse.parse("> 3", forQuery: "get flood.max") - #expect(result == .floodMax(3)) - } - - @Test func parse_floodMax_withoutPromptPrefix() { - let result = CLIResponse.parse("4", forQuery: "get flood.max") - #expect(result == .floodMax(4)) - } - - // MARK: - Error with Prompt - - @Test func parse_error_withPromptPrefix() { - let result = CLIResponse.parse("> Error: permission denied") - if case let .error(msg) = result { - #expect(msg == "Error: permission denied") - } else { - Issue.record("Expected .error, got \(result)") - } - } - - @Test func parse_error_withoutPromptPrefix() { - let result = CLIResponse.parse("Error: invalid value") - if case let .error(msg) = result { - #expect(msg == "Error: invalid value") - } else { - Issue.record("Expected .error, got \(result)") - } - } - - @Test func parse_unknownCommand_withPromptPrefix() { - let result = CLIResponse.parse("> Error: unknown command") - #expect(result == .unknownCommand("Error: unknown command")) - } - - // MARK: - Version with Prompt - - @Test func parse_version_withPromptPrefix() { - let result = CLIResponse.parse("> MeshCore v1.10.0 (2025-04-18)") - #expect(result == .version("MeshCore v1.10.0 (2025-04-18)")) - } - - @Test func parse_version_withoutPromptPrefix() { - let result = CLIResponse.parse("MeshCore v1.11.0 (2025-05-01)") - #expect(result == .version("MeshCore v1.11.0 (2025-05-01)")) - } - - @Test func parse_version_shortFormat_withPromptPrefix() { - let result = CLIResponse.parse("> v1.12.0 (2025-06-15)") - #expect(result == .version("v1.12.0 (2025-06-15)")) - } - - @Test func parse_version_nonStandardFormat_withQueryHint() { - let result = CLIResponse.parse("1.11.0-letsmesh.net-dev-2026-01-06-09005fa (Build: 06-Jan-2026)", forQuery: "ver") - #expect(result == .version("1.11.0-letsmesh.net-dev-2026-01-06-09005fa (Build: 06-Jan-2026)")) - } - - // MARK: - Device Time with Prompt - - @Test func parse_deviceTime_withPromptPrefix() { - let result = CLIResponse.parse("> 06:40 - 18/4/2025 UTC") - #expect(result == .deviceTime("06:40 - 18/4/2025 UTC")) - } - - @Test func parse_deviceTime_withoutPromptPrefix() { - let result = CLIResponse.parse("14:30 - 25/12/2025 UTC") - #expect(result == .deviceTime("14:30 - 25/12/2025 UTC")) - } - - // MARK: - Owner Info - - @Test func parse_ownerInfo_plainText() { - let result = CLIResponse.parse("some|pipe|text", forQuery: "get owner.info") - #expect(result == .ownerInfo("some|pipe|text")) - } - - @Test func parse_ownerInfo_withPromptPrefix() { - let result = CLIResponse.parse("> 915MHz|KD7ABC|example.com", forQuery: "get owner.info") - #expect(result == .ownerInfo("915MHz|KD7ABC|example.com")) - } - - @Test func parse_ownerInfo_empty() { - let result = CLIResponse.parse("", forQuery: "get owner.info") - #expect(result == .ownerInfo("")) - } - - @Test func parse_ownerInfo_barePrompt() { - // Firmware returns just "> " when owner.info is empty - let result = CLIResponse.parse("> ", forQuery: "get owner.info") - #expect(result == .ownerInfo("")) - } - - @Test func parse_ownerInfo_singleLine() { - let result = CLIResponse.parse("just a name", forQuery: "get owner.info") - #expect(result == .ownerInfo("just a name")) - } - - @Test func parse_ownerInfo_withColonAndSlash() { - // Contact info with ":" and "/" was misclassified as deviceTime - let result = CLIResponse.parse("> Contact: KD7ABC / 145.230", forQuery: "get owner.info") - #expect(result == .ownerInfo("Contact: KD7ABC / 145.230")) - } - - @Test func parse_name_withColonAndSlash() { - // Name with ":" and "/" was misclassified as deviceTime - let result = CLIResponse.parse("> Repeater: East / West", forQuery: "get name") - #expect(result == .name("Repeater: East / West")) - } - - @Test func parse_deviceTime_stillWorks_withoutQueryHint() { - let result = CLIResponse.parse("06:40 - 18/4/2025 UTC") - #expect(result == .deviceTime("06:40 - 18/4/2025 UTC")) - } - - // MARK: - Edge Cases - - @Test func parse_greaterThanInContent_notStripped() { - // Content that starts with ">" but not "> " should not be stripped - let result = CLIResponse.parse(">nosuchcommand", forQuery: "get name") - #expect(result == .name(">nosuchcommand")) - } - - @Test func parse_multipleGreaterThan_onlyFirstStripped() { - // Only the first "> " should be stripped - let result = CLIResponse.parse("> > nested prompt", forQuery: "get name") - #expect(result == .name("> nested prompt")) - } - - @Test func parse_emptyAfterStrip() { - // When input is "> ", trimming whitespace gives ">", which is the bare - // CLI prompt character — treated as empty content - let result = CLIResponse.parse("> ") - #expect(result == .raw("")) - } - - @Test func parse_justPrompt() { - // Just the prompt character without space — treated as empty content - let result = CLIResponse.parse(">") - #expect(result == .raw("")) - } - - // MARK: - Query Hint Matching (Integration) - - /// These tests verify that query hint matching correctly identifies response types - /// even when responses have the prompt prefix. This tests the full flow that - /// RepeaterSettingsViewModel.handleCLIResponse() uses. - - @Test func queryHintMatching_longitude_withPromptPrefix() { - // Simulate the query hint matching logic from handleCLIResponse - var trimmedText = "> -120.338211".trimmingCharacters(in: .whitespacesAndNewlines) - - // Strip prompt - this is what we're fixing - if trimmedText.hasPrefix("> ") { - trimmedText = String(trimmedText.dropFirst(2)) - } - - // Now the query matching pattern should work - let isValidDouble = Double(trimmedText) != nil && !trimmedText.contains(",") - #expect(isValidDouble == true) - - // And parsing should succeed - let result = CLIResponse.parse("> -120.338211", forQuery: "get lon") - if case let .longitude(lon) = result { - #expect(abs(lon - (-120.338211)) < 0.0001) - } else { - Issue.record("Expected .longitude, got \(result)") - } - } - - @Test func queryHintMatching_repeatMode_withPromptPrefix() { - // Simulate the query hint matching logic from handleCLIResponse - var trimmedText = "> on".trimmingCharacters(in: .whitespacesAndNewlines) - - // Strip prompt - this is what we're fixing - if trimmedText.hasPrefix("> ") { - trimmedText = String(trimmedText.dropFirst(2)) - } - - // Now the query matching pattern should work - let isValidRepeatMode = trimmedText.lowercased() == "on" || trimmedText.lowercased() == "off" - #expect(isValidRepeatMode == true) - - // And parsing should succeed - let result = CLIResponse.parse("> on", forQuery: "get repeat") - #expect(result == .repeatMode(true)) - } + // MARK: - Prompt Prefix Stripping + + @Test func `parse prompt prefix strips prefix`() { + let result = CLIResponse.parse("> OK") + #expect(result == .ok) + } + + @Test func `parse no prompt prefix still works`() { + let result = CLIResponse.parse("OK") + #expect(result == .ok) + } + + @Test func `parse prompt prefix with whitespace`() { + let result = CLIResponse.parse(" > OK ") + #expect(result == .ok) + } + + @Test func `parse ok with message clock set`() { + let result = CLIResponse.parse("OK - clock set: 16:13 - 9/1/2026 UTC") + #expect(result == .ok) + } + + @Test func `parse ok with message with prompt prefix`() { + let result = CLIResponse.parse("> OK - clock set: 16:13 - 9/1/2026 UTC") + #expect(result == .ok) + } + + // MARK: - Radio with Prompt + + @Test func `parse radio with prompt prefix`() { + let result = CLIResponse.parse("> 910.5250244,62.5,7,8", forQuery: "get radio") + if case let .radio(freq, bw, sf, cr) = result { + #expect(abs(freq - 910.5250244) < 0.0001) + #expect(abs(bw - 62.5) < 0.01) + #expect(sf == 7) + #expect(cr == 8) + } else { + Issue.record("Expected .radio, got \(result)") + } + } + + @Test func `parse radio without prompt prefix`() { + let result = CLIResponse.parse("915.000,250.0,10,5", forQuery: "get radio") + if case let .radio(freq, bw, sf, cr) = result { + #expect(abs(freq - 915.0) < 0.0001) + #expect(abs(bw - 250.0) < 0.01) + #expect(sf == 10) + #expect(cr == 5) + } else { + Issue.record("Expected .radio, got \(result)") + } + } + + // MARK: - Name with Prompt + + @Test func `parse name with prompt prefix`() { + let result = CLIResponse.parse("> Sunnyslope Repeater", forQuery: "get name") + #expect(result == .name("Sunnyslope Repeater")) + } + + @Test func `parse name without prompt prefix`() { + let result = CLIResponse.parse("My Node", forQuery: "get name") + #expect(result == .name("My Node")) + } + + // MARK: - Repeat Mode with Prompt + + @Test func `parse repeat mode on with prompt prefix`() { + let result = CLIResponse.parse("> on", forQuery: "get repeat") + #expect(result == .repeatMode(true)) + } + + @Test func `parse repeat mode off with prompt prefix`() { + let result = CLIResponse.parse("> off", forQuery: "get repeat") + #expect(result == .repeatMode(false)) + } + + @Test func `parse repeat mode without prompt prefix`() { + let result = CLIResponse.parse("on", forQuery: "get repeat") + #expect(result == .repeatMode(true)) + } + + // MARK: - TX Power with Prompt + + @Test func `parse tx power with prompt prefix`() { + let result = CLIResponse.parse("> 22", forQuery: "get tx") + #expect(result == .txPower(22)) + } + + @Test func `parse tx power without prompt prefix`() { + let result = CLIResponse.parse("17", forQuery: "get tx") + #expect(result == .txPower(17)) + } + + @Test func `parse ZephCore tx power with apc off suffix`() { + let result = CLIResponse.parse("> 22dBm (apc=off)", forQuery: "get tx") + #expect(result == .txPower(22)) + } + + @Test func `parse ZephCore tx power with apc on uses configured ceiling`() { + let result = CLIResponse.parse( + "> 16dBm (apc=on max=22 reduction=6 margin=18.5 target=16)", + forQuery: "get tx" + ) + #expect(result == .txPower(22)) + } + + // MARK: - Coordinates with Prompt + + @Test func `parse latitude with prompt prefix`() { + let result = CLIResponse.parse("> 33.4484", forQuery: "get lat") + if case let .latitude(lat) = result { + #expect(abs(lat - 33.4484) < 0.0001) + } else { + Issue.record("Expected .latitude, got \(result)") + } + } + + @Test func `parse latitude without prompt prefix`() { + let result = CLIResponse.parse("40.7128", forQuery: "get lat") + if case let .latitude(lat) = result { + #expect(abs(lat - 40.7128) < 0.0001) + } else { + Issue.record("Expected .latitude, got \(result)") + } + } + + @Test func `parse longitude with prompt prefix`() { + let result = CLIResponse.parse("> -112.0740", forQuery: "get lon") + if case let .longitude(lon) = result { + #expect(abs(lon - -112.0740) < 0.0001) + } else { + Issue.record("Expected .longitude, got \(result)") + } + } + + @Test func `parse longitude without prompt prefix`() { + let result = CLIResponse.parse("-74.0060", forQuery: "get lon") + if case let .longitude(lon) = result { + #expect(abs(lon - -74.0060) < 0.0001) + } else { + Issue.record("Expected .longitude, got \(result)") + } + } + + // MARK: - Intervals with Prompt + + @Test func `parse advert interval with prompt prefix`() { + let result = CLIResponse.parse("> 5", forQuery: "get advert.interval") + #expect(result == .advertInterval(5)) + } + + @Test func `parse advert interval without prompt prefix`() { + let result = CLIResponse.parse("10", forQuery: "get advert.interval") + #expect(result == .advertInterval(10)) + } + + @Test func `parse flood advert interval with prompt prefix`() { + let result = CLIResponse.parse("> 12", forQuery: "get flood.advert.interval") + #expect(result == .floodAdvertInterval(12)) + } + + @Test func `parse flood advert interval without prompt prefix`() { + let result = CLIResponse.parse("6", forQuery: "get flood.advert.interval") + #expect(result == .floodAdvertInterval(6)) + } + + @Test func `parse flood max with prompt prefix`() { + let result = CLIResponse.parse("> 3", forQuery: "get flood.max") + #expect(result == .floodMax(3)) + } + + @Test func `parse flood max without prompt prefix`() { + let result = CLIResponse.parse("4", forQuery: "get flood.max") + #expect(result == .floodMax(4)) + } + + // MARK: - Error with Prompt + + @Test func `parse error with prompt prefix`() { + let result = CLIResponse.parse("> Error: permission denied") + if case let .error(msg) = result { + #expect(msg == "Error: permission denied") + } else { + Issue.record("Expected .error, got \(result)") + } + } + + @Test func `parse error without prompt prefix`() { + let result = CLIResponse.parse("Error: invalid value") + if case let .error(msg) = result { + #expect(msg == "Error: invalid value") + } else { + Issue.record("Expected .error, got \(result)") + } + } + + @Test func `parse unknown command with prompt prefix`() { + let result = CLIResponse.parse("> Error: unknown command") + #expect(result == .unknownCommand("Error: unknown command")) + } + + // MARK: - Version with Prompt + + @Test func `parse version with prompt prefix`() { + let result = CLIResponse.parse("> MeshCore v1.10.0 (2025-04-18)") + #expect(result == .version("MeshCore v1.10.0 (2025-04-18)")) + } + + @Test func `parse version without prompt prefix`() { + let result = CLIResponse.parse("MeshCore v1.11.0 (2025-05-01)") + #expect(result == .version("MeshCore v1.11.0 (2025-05-01)")) + } + + @Test func `parse version short format with prompt prefix`() { + let result = CLIResponse.parse("> v1.12.0 (2025-06-15)") + #expect(result == .version("v1.12.0 (2025-06-15)")) + } + + @Test func `parse version non standard format with query hint`() { + let result = CLIResponse.parse("1.11.0-letsmesh.net-dev-2026-01-06-09005fa (Build: 06-Jan-2026)", forQuery: "ver") + #expect(result == .version("1.11.0-letsmesh.net-dev-2026-01-06-09005fa (Build: 06-Jan-2026)")) + } + + // MARK: - Device Time with Prompt + + @Test func `parse device time with prompt prefix`() { + let result = CLIResponse.parse("> 06:40 - 18/4/2025 UTC") + #expect(result == .deviceTime("06:40 - 18/4/2025 UTC")) + } + + @Test func `parse device time without prompt prefix`() { + let result = CLIResponse.parse("14:30 - 25/12/2025 UTC") + #expect(result == .deviceTime("14:30 - 25/12/2025 UTC")) + } + + // MARK: - Owner Info + + @Test func `parse owner info plain text`() { + let result = CLIResponse.parse("some|pipe|text", forQuery: "get owner.info") + #expect(result == .ownerInfo("some|pipe|text")) + } + + @Test func `parse owner info with prompt prefix`() { + let result = CLIResponse.parse("> 915MHz|KD7ABC|example.com", forQuery: "get owner.info") + #expect(result == .ownerInfo("915MHz|KD7ABC|example.com")) + } + + @Test func `parse owner info empty`() { + let result = CLIResponse.parse("", forQuery: "get owner.info") + #expect(result == .ownerInfo("")) + } + + @Test func `parse owner info bare prompt`() { + // Firmware returns just "> " when owner.info is empty + let result = CLIResponse.parse("> ", forQuery: "get owner.info") + #expect(result == .ownerInfo("")) + } + + @Test func `parse owner info single line`() { + let result = CLIResponse.parse("just a name", forQuery: "get owner.info") + #expect(result == .ownerInfo("just a name")) + } + + @Test func `parse owner info with colon and slash`() { + // Contact info with ":" and "/" was misclassified as deviceTime + let result = CLIResponse.parse("> Contact: KD7ABC / 145.230", forQuery: "get owner.info") + #expect(result == .ownerInfo("Contact: KD7ABC / 145.230")) + } + + @Test func `parse name with colon and slash`() { + // Name with ":" and "/" was misclassified as deviceTime + let result = CLIResponse.parse("> Repeater: East / West", forQuery: "get name") + #expect(result == .name("Repeater: East / West")) + } + + @Test func `parse device time still works without query hint`() { + let result = CLIResponse.parse("06:40 - 18/4/2025 UTC") + #expect(result == .deviceTime("06:40 - 18/4/2025 UTC")) + } + + // MARK: - Edge Cases + + @Test func `parse greater than in content not stripped`() { + // Content that starts with ">" but not "> " should not be stripped + let result = CLIResponse.parse(">nosuchcommand", forQuery: "get name") + #expect(result == .name(">nosuchcommand")) + } + + @Test func `parse multiple greater than only first stripped`() { + // Only the first "> " should be stripped + let result = CLIResponse.parse("> > nested prompt", forQuery: "get name") + #expect(result == .name("> nested prompt")) + } + + @Test func `parse empty after strip`() { + // When input is "> ", trimming whitespace gives ">", which is the bare + // CLI prompt character — treated as empty content + let result = CLIResponse.parse("> ") + #expect(result == .raw("")) + } + + @Test func `parse just prompt`() { + // Just the prompt character without space — treated as empty content + let result = CLIResponse.parse(">") + #expect(result == .raw("")) + } + + // MARK: - Query Hint Matching (Integration) + + // These tests verify that query hint matching correctly identifies response types + // even when responses have the prompt prefix. This tests the full flow that + // RepeaterSettingsViewModel.handleCLIResponse() uses. + + @Test func `query hint matching longitude with prompt prefix`() { + // Simulate the query hint matching logic from handleCLIResponse + var trimmedText = "> -120.338211".trimmingCharacters(in: .whitespacesAndNewlines) + + // Strip prompt - this is what we're fixing + if trimmedText.hasPrefix("> ") { + trimmedText = String(trimmedText.dropFirst(2)) + } + + // Now the query matching pattern should work + let isValidDouble = Double(trimmedText) != nil && !trimmedText.contains(",") + #expect(isValidDouble == true) + + // And parsing should succeed + let result = CLIResponse.parse("> -120.338211", forQuery: "get lon") + if case let .longitude(lon) = result { + #expect(abs(lon - -120.338211) < 0.0001) + } else { + Issue.record("Expected .longitude, got \(result)") + } + } + + @Test func `query hint matching repeat mode with prompt prefix`() { + // Simulate the query hint matching logic from handleCLIResponse + var trimmedText = "> on".trimmingCharacters(in: .whitespacesAndNewlines) + + // Strip prompt - this is what we're fixing + if trimmedText.hasPrefix("> ") { + trimmedText = String(trimmedText.dropFirst(2)) + } + + // Now the query matching pattern should work + let isValidRepeatMode = trimmedText.lowercased() == "on" || trimmedText.lowercased() == "off" + #expect(isValidRepeatMode == true) + + // And parsing should succeed + let result = CLIResponse.parse("> on", forQuery: "get repeat") + #expect(result == .repeatMode(true)) + } } diff --git a/MC1Tests/Protocol/LPPDataPointDisplayTests.swift b/MC1Tests/Protocol/LPPDataPointDisplayTests.swift index df414994..09090803 100644 --- a/MC1Tests/Protocol/LPPDataPointDisplayTests.swift +++ b/MC1Tests/Protocol/LPPDataPointDisplayTests.swift @@ -1,124 +1,123 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("LPP Data Point Display Tests") struct LPPDataPointDisplayTests { + // MARK: - Battery Percentage Tests - // MARK: - Battery Percentage Tests + @Test + func `Battery percentage at minimum voltage (3.0V) returns 0%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.0)) - @Test("Battery percentage at minimum voltage (3.0V) returns 0%") - func batteryPercentage3_0VReturns0() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.0)) - - #expect(dataPoint.batteryPercentage == 0) - } + #expect(dataPoint.batteryPercentage == 0) + } - @Test("Battery percentage at maximum voltage (4.2V) returns 100%") - func batteryPercentage4_2VReturns100() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(4.2)) + @Test + func `Battery percentage at maximum voltage (4.2V) returns 100%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(4.2)) - #expect(dataPoint.batteryPercentage == 100) - } + #expect(dataPoint.batteryPercentage == 100) + } - @Test("Battery percentage at midpoint voltage (3.6V) returns approximately 50%") - func batteryPercentage3_6VReturns50() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.6)) + @Test + func `Battery percentage at midpoint voltage (3.6V) returns approximately 50%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.6)) - #expect(dataPoint.batteryPercentage == 50) - } + #expect(dataPoint.batteryPercentage == 50) + } - @Test("Battery percentage at 3.9V returns 75%") - func batteryPercentage3_9VReturns75() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.9)) + @Test + func `Battery percentage at 3.9V returns 75%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.9)) - #expect(dataPoint.batteryPercentage == 75) - } + #expect(dataPoint.batteryPercentage == 75) + } - @Test("Battery percentage below minimum voltage clamps to 0%") - func batteryPercentageBelowMinClampsTo0() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(2.5)) + @Test + func `Battery percentage below minimum voltage clamps to 0%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(2.5)) - #expect(dataPoint.batteryPercentage == 0) - } + #expect(dataPoint.batteryPercentage == 0) + } - @Test("Battery percentage above maximum voltage clamps to 100%") - func batteryPercentageAboveMaxClampsTo100() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(5.0)) + @Test + func `Battery percentage above maximum voltage clamps to 100%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(5.0)) - #expect(dataPoint.batteryPercentage == 100) - } + #expect(dataPoint.batteryPercentage == 100) + } - @Test("Battery percentage for non-voltage type returns nil") - func batteryPercentageNonVoltageReturnsNil() { - let dataPoint = LPPDataPoint(channel: 0, type: .temperature, value: .float(25.0)) + @Test + func `Battery percentage for non-voltage type returns nil`() { + let dataPoint = LPPDataPoint(channel: 0, type: .temperature, value: .float(25.0)) - #expect(dataPoint.batteryPercentage == nil) - } + #expect(dataPoint.batteryPercentage == nil) + } - @Test("Battery percentage for integer value returns nil") - func batteryPercentageIntegerValueReturnsNil() { - // Voltage should be float, but test edge case where it's not - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .integer(4)) + @Test + func `Battery percentage for integer value returns nil`() { + // Voltage should be float, but test edge case where it's not + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .integer(4)) - #expect(dataPoint.batteryPercentage == nil) - } + #expect(dataPoint.batteryPercentage == nil) + } - @Test("Battery percentage for percentage type returns nil") - func batteryPercentageForPercentageTypeReturnsNil() { - let dataPoint = LPPDataPoint(channel: 0, type: .percentage, value: .integer(75)) + @Test + func `Battery percentage for percentage type returns nil`() { + let dataPoint = LPPDataPoint(channel: 0, type: .percentage, value: .integer(75)) - #expect(dataPoint.batteryPercentage == nil) - } + #expect(dataPoint.batteryPercentage == nil) + } - // MARK: - Formatted Value Tests + // MARK: - Formatted Value Tests - @Test("Voltage formatted value includes V suffix") - func voltageFormattedValueIncludesVSuffix() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.85)) + @Test + func `Voltage formatted value includes V suffix`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.85)) - #expect(dataPoint.formattedValue == "\(3.85.formatted(.number.precision(.fractionLength(3)))) V") - } + #expect(dataPoint.formattedValue == "\(3.85.formatted(.number.precision(.fractionLength(3)))) V") + } - @Test("Temperature formatted value uses locale-appropriate unit") - func temperatureFormattedValueUsesLocaleUnit() { - let dataPoint = LPPDataPoint(channel: 0, type: .temperature, value: .float(25.5)) - let sensorType = LPPSensorType.temperature - let converted = sensorType.convertedValue(25.5) - let precision = sensorType.convertedFractionLength - let expected = "\(converted.formatted(.number.precision(.fractionLength(precision)))) \(sensorType.localizedUnitSymbol)" + @Test + func `Temperature formatted value uses locale-appropriate unit`() { + let dataPoint = LPPDataPoint(channel: 0, type: .temperature, value: .float(25.5)) + let sensorType = LPPSensorType.temperature + let converted = sensorType.convertedValue(25.5) + let precision = sensorType.convertedFractionLength + let expected = "\(converted.formatted(.number.precision(.fractionLength(precision)))) \(sensorType.localizedUnitSymbol)" - #expect(dataPoint.formattedValue == expected) - } + #expect(dataPoint.formattedValue == expected) + } - // MARK: - Locale Conversion Tests - - @Test("Temperature conversion from Celsius to Fahrenheit") - func temperatureConversion() { - let celsius = 25.0 - let converted = LPPSensorType.temperature.convertedValue(celsius) - if Locale.current.measurementSystem == .metric { - #expect(converted == celsius) - } else { - #expect(abs(converted - 77.0) < 0.01) - } - } + // MARK: - Locale Conversion Tests - @Test("Voltage is not locale-sensitive") - func voltageNotLocaleSensitive() { - let volts = 3.85 - let converted = LPPSensorType.voltage.convertedValue(volts) - #expect(converted == volts) - #expect(LPPSensorType.voltage.localizedUnitSymbol == "V") + @Test + func `Temperature conversion from Celsius to Fahrenheit`() { + let celsius = 25.0 + let converted = LPPSensorType.temperature.convertedValue(celsius) + if Locale.current.measurementSystem == .metric { + #expect(converted == celsius) + } else { + #expect(abs(converted - 77.0) < 0.01) } - - @Test("Altitude uses locale-appropriate unit symbol") - func altitudeLocalizedUnit() { - let symbol = LPPSensorType.altitude.localizedUnitSymbol - if Locale.current.measurementSystem == .metric { - #expect(symbol == "m") - } else { - #expect(symbol == "ft") - } + } + + @Test + func `Voltage is not locale-sensitive`() { + let volts = 3.85 + let converted = LPPSensorType.voltage.convertedValue(volts) + #expect(converted == volts) + #expect(LPPSensorType.voltage.localizedUnitSymbol == "V") + } + + @Test + func `Altitude uses locale-appropriate unit symbol`() { + let symbol = LPPSensorType.altitude.localizedUnitSymbol + if Locale.current.measurementSystem == .metric { + #expect(symbol == "m") + } else { + #expect(symbol == "ft") } + } } diff --git a/MC1Tests/RefundLinkSectionTests.swift b/MC1Tests/RefundLinkSectionTests.swift index a5305f45..59cd8668 100644 --- a/MC1Tests/RefundLinkSectionTests.swift +++ b/MC1Tests/RefundLinkSectionTests.swift @@ -1,9 +1,9 @@ -import Testing -import SwiftUI +@testable import MC1 +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services -@testable import MC1 +import SwiftUI +import Testing /// SKTestSession-driven coverage for `RefundLinkSection.latestRefundableTransactionID()`. /// Only the bundle is purchasable, so it is the only refundable product. `Transaction.latest(for:)` @@ -12,68 +12,68 @@ import StoreKitTest @MainActor @Suite("RefundLinkSection helpers", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class RefundLinkSectionTests { - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - // Reset Ask-to-Buy: SKTestSession does not clear this on storekitd across instances. - session.askToBuyEnabled = false - session.clearTransactions() - } + let session: SKTestSession - deinit { session.clearTransactions() } + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + // Reset Ask-to-Buy: SKTestSession does not clear this on storekitd across instances. + session.askToBuyEnabled = false + session.clearTransactions() + } - @Test("latestRefundableTransactionID excludes a refunded (revoked) bundle transaction") - func latestRefundableTransactionIDExcludesRevoked() async throws { - // Purchase the bundle via product.purchase() (synchronously consistent, unlike - // session.buyProduct() which is eventually consistent under storekitd churn). - let bundle = try #require( - try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first - ) - try await purchaseUnfinished(bundle) + deinit { session.clearTransactions() } - // Confirm the helper finds the verified, non-revoked purchase before refunding so a - // post-refund nil is provably from the revocation filter, not from a missing setup. - let section = RefundLinkSection() - let preRefundID = await section.latestRefundableTransactionID() - #expect(preRefundID != nil) + @Test + func `latestRefundableTransactionID excludes a refunded (revoked) bundle transaction`() async throws { + // Purchase the bundle via product.purchase() (synchronously consistent, unlike + // session.buyProduct() which is eventually consistent under storekitd churn). + let bundle = try #require( + try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first + ) + try await purchaseUnfinished(bundle) - let txn = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.refundTransaction(identifier: txn.identifier) + // Confirm the helper finds the verified, non-revoked purchase before refunding so a + // post-refund nil is provably from the revocation filter, not from a missing setup. + let section = RefundLinkSection() + let preRefundID = await section.latestRefundableTransactionID() + #expect(preRefundID != nil) - // Wait for refund propagation: Transaction.latest's revocationDate is the field - // RefundLinkSection.latestRefundableTransactionID guards on. - try await waitUntil(timeout: .seconds(5)) { - guard case .verified(let latest) = - await Transaction.latest(for: StoreCatalog.Theme.bundleAll) - else { return false } - return latest.revocationDate != nil - } + let txn = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.refundTransaction(identifier: txn.identifier) - let postRefundID = await section.latestRefundableTransactionID() - #expect(postRefundID == nil) + // Wait for refund propagation: Transaction.latest's revocationDate is the field + // RefundLinkSection.latestRefundableTransactionID guards on. + try await waitUntil(timeout: .seconds(5)) { + guard case let .verified(latest) = + await Transaction.latest(for: StoreCatalog.Theme.bundleAll) + else { return false } + return latest.revocationDate != nil } - /// Commits a purchase through `product.purchase()` and leaves the transaction unfinished; - /// retries the transient `StoreKitError.unknown` storekitd raises under SKTestSession churn. - /// Same shape as `StoreServiceTests.purchaseUnfinished` — duplicated locally because this - /// suite is decoupled from `StoreService`. - private func purchaseUnfinished(_ product: Product, attempts: Int = 4) async throws { - for attempt in 1...attempts { - do { - let result = try await product.purchase() - guard case .success = result else { - throw RefundLinkTestError.purchaseDidNotSucceed - } - return - } catch let error as StoreKitError { - guard case .unknown = error, attempt < attempts else { throw error } - } + let postRefundID = await section.latestRefundableTransactionID() + #expect(postRefundID == nil) + } + + /// Commits a purchase through `product.purchase()` and leaves the transaction unfinished; + /// retries the transient `StoreKitError.unknown` storekitd raises under SKTestSession churn. + /// Same shape as `StoreServiceTests.purchaseUnfinished` — duplicated locally because this + /// suite is decoupled from `StoreService`. + private func purchaseUnfinished(_ product: Product, attempts: Int = 4) async throws { + for attempt in 1...attempts { + do { + let result = try await product.purchase() + guard case .success = result else { + throw RefundLinkTestError.purchaseDidNotSucceed } + return + } catch let error as StoreKitError { + guard case .unknown = error, attempt < attempts else { throw error } + } } + } } private enum RefundLinkTestError: Error { case purchaseDidNotSucceed } diff --git a/MC1Tests/Services/DecodedPreviewCacheTests.swift b/MC1Tests/Services/DecodedPreviewCacheTests.swift index 3fd7e178..9e1d254a 100644 --- a/MC1Tests/Services/DecodedPreviewCacheTests.swift +++ b/MC1Tests/Services/DecodedPreviewCacheTests.swift @@ -1,139 +1,138 @@ import Foundation +@testable import MC1 +@testable import MC1Services import Testing import UIKit -@testable import MC1Services -@testable import MC1 @Suite("DecodedPreviewCache Tests") struct DecodedPreviewCacheTests { - - @Test("Returns nil for an unseen URL") - func decodedReturnsNilForMissingKey() { - let cache = DecodedPreviewCache() - #expect(cache.decoded(for: uniqueURL()) == nil) - } - - @Test("Round-trips a stored entry with DTO, hero, and icon") - @MainActor - func decodedRoundTrip() { - let cache = DecodedPreviewCache() - let url = uniqueURL() - let dto = Self.makeDTO(url: url.absoluteString) - let hero = Self.makeImage() - let icon = Self.makeImage() - cache.store(CachedDecodedPreview(dto: dto, hero: hero, icon: icon), for: url) - - let result = cache.decoded(for: url) - #expect(result?.dto.url == dto.url) - #expect(result?.dto.title == dto.title) - #expect(result?.hero === hero) - #expect(result?.icon === icon) - } - - @Test("Tolerates a nil hero or icon") - @MainActor - func decodedToleratesNilAssets() { - let cache = DecodedPreviewCache() - let url = uniqueURL() - let dto = Self.makeDTO(url: url.absoluteString) - cache.store(CachedDecodedPreview(dto: dto, hero: nil, icon: Self.makeImage()), for: url) - - let result = cache.decoded(for: url) - #expect(result?.hero == nil) - #expect(result?.icon != nil) - } - - @Test("Re-storing the same key replaces the entry") - @MainActor - func decodedReplaceSameKey() { - let cache = DecodedPreviewCache() - let url = uniqueURL() - let first = CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString, title: "first"), hero: Self.makeImage(), icon: nil) - let second = CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString, title: "second"), hero: Self.makeImage(), icon: nil) - - cache.store(first, for: url) - cache.store(second, for: url) - - let result = cache.decoded(for: url) - #expect(result?.hero === second.hero) - #expect(result?.dto.title == "second") - } - - @Test("Strips raw image and icon bytes from the cached DTO") - @MainActor - func stripsRawBytesFromDTO() { - let dto = LinkPreviewDataDTO( - url: "https://example.invalid/strip", - title: "Title", - imageData: Data([0x01, 0x02, 0x03]), - iconData: Data([0x04, 0x05]), - imageWidth: 100, - imageHeight: 50 - ) - let entry = CachedDecodedPreview(dto: dto, hero: Self.makeImage(), icon: Self.makeImage()) - - #expect(entry.dto.imageData == nil) - #expect(entry.dto.iconData == nil) - // Metadata the rehydration render path reads is preserved. - #expect(entry.dto.url == dto.url) - #expect(entry.dto.title == dto.title) - #expect(entry.dto.imageWidth == 100) - #expect(entry.dto.imageHeight == 50) - } - - @Test("Cost reflects decoded pixel bytes") - @MainActor - func costIsPixelBytes() { - let dto = Self.makeDTO(url: "https://example.invalid/cost") - let entry = CachedDecodedPreview(dto: dto, hero: Self.makeImage(width: 100, height: 50), icon: nil) - // 100x50 RGBA bitmap is 100 * 50 * 4 = 20_000 bytes minimum; - // bytesPerRow may be padded for alignment, so assert a lower bound. - #expect(entry.cost >= 20_000) - } - - @Test("clear() empties the cache") - @MainActor - func clearEmpties() { - let cache = DecodedPreviewCache() - let url = uniqueURL() - cache.store(CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString), hero: Self.makeImage(), icon: nil), for: url) - #expect(cache.decoded(for: url) != nil) - - cache.clear() - #expect(cache.decoded(for: url) == nil) - } - - @Test("FIFO eviction drops the oldest entry past the count cap") - @MainActor - func fifoEvictsOldest() { - let cache = DecodedPreviewCache() - // maxEntryCount is 50; store 60 tiny entries so the first 10 evict. - var urls: [URL] = [] - for _ in 0..<60 { - let url = uniqueURL() - urls.append(url) - cache.store(CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString), hero: Self.makeImage(), icon: nil), for: url) - } - #expect(cache.decoded(for: urls.first!) == nil) - #expect(cache.decoded(for: urls.last!) != nil) - } - - // MARK: - Helpers - - private func uniqueURL() -> URL { - URL(string: "https://example.invalid/\(UUID().uuidString)")! + @Test + func `Returns nil for an unseen URL`() { + let cache = DecodedPreviewCache() + #expect(cache.decoded(for: uniqueURL()) == nil) + } + + @Test + @MainActor + func `Round-trips a stored entry with DTO, hero, and icon`() { + let cache = DecodedPreviewCache() + let url = uniqueURL() + let dto = Self.makeDTO(url: url.absoluteString) + let hero = Self.makeImage() + let icon = Self.makeImage() + cache.store(CachedDecodedPreview(dto: dto, hero: hero, icon: icon), for: url) + + let result = cache.decoded(for: url) + #expect(result?.dto.url == dto.url) + #expect(result?.dto.title == dto.title) + #expect(result?.hero === hero) + #expect(result?.icon === icon) + } + + @Test + @MainActor + func `Tolerates a nil hero or icon`() { + let cache = DecodedPreviewCache() + let url = uniqueURL() + let dto = Self.makeDTO(url: url.absoluteString) + cache.store(CachedDecodedPreview(dto: dto, hero: nil, icon: Self.makeImage()), for: url) + + let result = cache.decoded(for: url) + #expect(result?.hero == nil) + #expect(result?.icon != nil) + } + + @Test + @MainActor + func `Re-storing the same key replaces the entry`() { + let cache = DecodedPreviewCache() + let url = uniqueURL() + let first = CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString, title: "first"), hero: Self.makeImage(), icon: nil) + let second = CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString, title: "second"), hero: Self.makeImage(), icon: nil) + + cache.store(first, for: url) + cache.store(second, for: url) + + let result = cache.decoded(for: url) + #expect(result?.hero === second.hero) + #expect(result?.dto.title == "second") + } + + @Test + @MainActor + func `Strips raw image and icon bytes from the cached DTO`() { + let dto = LinkPreviewDataDTO( + url: "https://example.invalid/strip", + title: "Title", + imageData: Data([0x01, 0x02, 0x03]), + iconData: Data([0x04, 0x05]), + imageWidth: 100, + imageHeight: 50 + ) + let entry = CachedDecodedPreview(dto: dto, hero: Self.makeImage(), icon: Self.makeImage()) + + #expect(entry.dto.imageData == nil) + #expect(entry.dto.iconData == nil) + // Metadata the rehydration render path reads is preserved. + #expect(entry.dto.url == dto.url) + #expect(entry.dto.title == dto.title) + #expect(entry.dto.imageWidth == 100) + #expect(entry.dto.imageHeight == 50) + } + + @Test + @MainActor + func `Cost reflects decoded pixel bytes`() { + let dto = Self.makeDTO(url: "https://example.invalid/cost") + let entry = CachedDecodedPreview(dto: dto, hero: Self.makeImage(width: 100, height: 50), icon: nil) + // 100x50 RGBA bitmap is 100 * 50 * 4 = 20_000 bytes minimum; + // bytesPerRow may be padded for alignment, so assert a lower bound. + #expect(entry.cost >= 20000) + } + + @Test + @MainActor + func `clear() empties the cache`() { + let cache = DecodedPreviewCache() + let url = uniqueURL() + cache.store(CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString), hero: Self.makeImage(), icon: nil), for: url) + #expect(cache.decoded(for: url) != nil) + + cache.clear() + #expect(cache.decoded(for: url) == nil) + } + + @Test + @MainActor + func `FIFO eviction drops the oldest entry past the count cap`() throws { + let cache = DecodedPreviewCache() + // maxEntryCount is 50; store 60 tiny entries so the first 10 evict. + var urls: [URL] = [] + for _ in 0..<60 { + let url = uniqueURL() + urls.append(url) + cache.store(CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString), hero: Self.makeImage(), icon: nil), for: url) } - - private static func makeDTO(url: String, title: String? = "Title") -> LinkPreviewDataDTO { - LinkPreviewDataDTO(url: url, title: title, imageWidth: 100, imageHeight: 50) - } - - @MainActor - private static func makeImage(width: Int = 1, height: Int = 1) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) - return renderer.image { ctx in - UIColor.black.setFill() - ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) - } + #expect(try cache.decoded(for: #require(urls.first)) == nil) + #expect(try cache.decoded(for: #require(urls.last)) != nil) + } + + // MARK: - Helpers + + private func uniqueURL() -> URL { + URL(string: "https://example.invalid/\(UUID().uuidString)")! + } + + private static func makeDTO(url: String, title: String? = "Title") -> LinkPreviewDataDTO { + LinkPreviewDataDTO(url: url, title: title, imageWidth: 100, imageHeight: 50) + } + + @MainActor + private static func makeImage(width: Int = 1, height: Int = 1) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) + return renderer.image { ctx in + UIColor.black.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) } + } } diff --git a/MC1Tests/Services/ElevationServiceTests.swift b/MC1Tests/Services/ElevationServiceTests.swift index 434c0c0e..185003ff 100644 --- a/MC1Tests/Services/ElevationServiceTests.swift +++ b/MC1Tests/Services/ElevationServiceTests.swift @@ -1,198 +1,192 @@ import CoreLocation -import Testing - @testable import MC1 @testable import MC1Services +import Testing @Suite("ElevationService Tests") struct ElevationServiceTests { + // MARK: - Sample Count Tests + + @Suite("optimalSampleCount") + struct OptimalSampleCountTests { + @Test + func `Returns 20 samples for distances under 1km`() { + #expect(ElevationService.optimalSampleCount(distanceMeters: 0) == 20) + #expect(ElevationService.optimalSampleCount(distanceMeters: 500) == 20) + #expect(ElevationService.optimalSampleCount(distanceMeters: 999) == 20) + } + + @Test + func `Returns 50 samples for distances 1-5km`() { + #expect(ElevationService.optimalSampleCount(distanceMeters: 1000) == 50) + #expect(ElevationService.optimalSampleCount(distanceMeters: 2500) == 50) + #expect(ElevationService.optimalSampleCount(distanceMeters: 4999) == 50) + } - // MARK: - Sample Count Tests - - @Suite("optimalSampleCount") - struct OptimalSampleCountTests { - - @Test("Returns 20 samples for distances under 1km") - func sampleCountUnder1km() { - #expect(ElevationService.optimalSampleCount(distanceMeters: 0) == 20) - #expect(ElevationService.optimalSampleCount(distanceMeters: 500) == 20) - #expect(ElevationService.optimalSampleCount(distanceMeters: 999) == 20) - } - - @Test("Returns 50 samples for distances 1-5km") - func sampleCount1to5km() { - #expect(ElevationService.optimalSampleCount(distanceMeters: 1000) == 50) - #expect(ElevationService.optimalSampleCount(distanceMeters: 2500) == 50) - #expect(ElevationService.optimalSampleCount(distanceMeters: 4999) == 50) - } - - @Test("Returns 80 samples for distances 5-20km") - func sampleCount5to20km() { - #expect(ElevationService.optimalSampleCount(distanceMeters: 5000) == 80) - #expect(ElevationService.optimalSampleCount(distanceMeters: 10000) == 80) - #expect(ElevationService.optimalSampleCount(distanceMeters: 19999) == 80) - } - - @Test("Returns 100 samples for distances over 20km") - func sampleCountOver20km() { - #expect(ElevationService.optimalSampleCount(distanceMeters: 20000) == 100) - #expect(ElevationService.optimalSampleCount(distanceMeters: 50000) == 100) - #expect(ElevationService.optimalSampleCount(distanceMeters: 100000) == 100) - } - - @Test("Sample count never exceeds 100") - func sampleCountNeverExceeds100() { - // Test a range of distances to ensure we never exceed 100 - let distances = [0, 100, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000, 1_000_000] - for distance in distances { - let count = ElevationService.optimalSampleCount(distanceMeters: Double(distance)) - #expect(count <= 100, "Sample count \(count) exceeds 100 for distance \(distance)m") - } - } - } - - // MARK: - Sample Coordinates Tests - - @Suite("sampleCoordinates") - struct SampleCoordinatesTests { - - private let pointA = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - private let pointB = CLLocationCoordinate2D(latitude: 37.8049, longitude: -122.3894) - - @Test("First coordinate equals pointA") - func firstCoordinateIsPointA() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 10) - - #expect(samples.first?.latitude == pointA.latitude) - #expect(samples.first?.longitude == pointA.longitude) - } - - @Test("Last coordinate equals pointB") - func lastCoordinateIsPointB() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 10) - - #expect(samples.last?.latitude == pointB.latitude) - #expect(samples.last?.longitude == pointB.longitude) - } - - @Test("Returns correct number of samples") - func correctSampleCount() { - for count in [2, 5, 10, 20, 50, 100] { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: count) - #expect(samples.count == count, "Expected \(count) samples, got \(samples.count)") - } - } - - @Test("Sample count clamped to minimum of 2") - func minimumSampleCount() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 1) - #expect(samples.count == 2) - - let zeroSamples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 0) - #expect(zeroSamples.count == 2) - } - - @Test("Sample count clamped to maximum of 100") - func maximumSampleCount() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 150) - #expect(samples.count == 100) - } - - @Test("Coordinates are evenly distributed") - func evenlyDistributed() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 5) - - // Calculate expected latitude/longitude step - let latStep = (pointB.latitude - pointA.latitude) / 4 - let lonStep = (pointB.longitude - pointA.longitude) / 4 - - // Check each point is at expected position - for i in 0..<5 { - let expectedLat = pointA.latitude + Double(i) * latStep - let expectedLon = pointA.longitude + Double(i) * lonStep - - #expect( - abs(samples[i].latitude - expectedLat) < 0.0001, - "Latitude at index \(i) differs: expected \(expectedLat), got \(samples[i].latitude)" - ) - #expect( - abs(samples[i].longitude - expectedLon) < 0.0001, - "Longitude at index \(i) differs: expected \(expectedLon), got \(samples[i].longitude)" - ) - } - } - - @Test("Identical points return same coordinate repeated") - func identicalPoints() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointA, sampleCount: 5) - - #expect(samples.count == 5) - for sample in samples { - #expect(sample.latitude == pointA.latitude) - #expect(sample.longitude == pointA.longitude) - } - } - } - - // MARK: - Error Type Tests - - @Suite("ElevationServiceError") - struct ErrorTests { - - @Test("networkError has descriptive message") - func networkErrorDescription() { - let underlyingError = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connection failed"]) - let error = ElevationServiceError.networkError(underlyingError.localizedDescription) - - #expect(error.errorDescription?.contains("Network error") == true) - #expect(error.errorDescription?.contains("Connection failed") == true) - } - - @Test("invalidResponse has descriptive message") - func invalidResponseDescription() { - let error = ElevationServiceError.invalidResponse - #expect(error.errorDescription == "Invalid response from elevation API") - } - - @Test("apiError includes message") - func apiErrorDescription() { - let error = ElevationServiceError.apiError("Rate limit exceeded") - #expect(error.errorDescription?.contains("API error") == true) - #expect(error.errorDescription?.contains("Rate limit exceeded") == true) - } - - @Test("noData has descriptive message") - func noDataDescription() { - let error = ElevationServiceError.noData - #expect(error.errorDescription == "No elevation data returned") - } - } - - // MARK: - Integration with RFCalculator Distance - - @Suite("Distance Integration") - struct DistanceIntegrationTests { - - @Test("Sample coordinates work with RFCalculator distance") - func sampleCoordinatesDistanceIntegration() { - let pointA = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let pointB = CLLocationCoordinate2D(latitude: 37.8049, longitude: -122.3894) - - let totalDistance = RFCalculator.distance(from: pointA, to: pointB) - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 5) - - // Calculate distance between each consecutive pair - var cumulativeDistance = 0.0 - for i in 1.. Data? { + let bytesPerRow = width * 4 + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + context.setFillColor(CGColor(red: 0, green: 1, blue: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { return nil } - /// Creates an in-memory PNG of the requested pixel dimensions. - private static func makePNG(width: Int, height: Int) -> Data? { - let bytesPerRow = width * 4 - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: bytesPerRow, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { return nil } - context.setFillColor(CGColor(red: 0, green: 1, blue: 0, alpha: 1)) - context.fill(CGRect(x: 0, y: 0, width: width, height: height)) - guard let image = context.makeImage() else { return nil } - - let mutable = NSMutableData() - guard let destination = CGImageDestinationCreateWithData( - mutable, - UTType.png.identifier as CFString, - 1, - nil - ) else { return nil } - CGImageDestinationAddImage(destination, image, nil) - guard CGImageDestinationFinalize(destination) else { return nil } - return mutable as Data - } + let mutable = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + mutable, + UTType.png.identifier as CFString, + 1, + nil + ) else { return nil } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { return nil } + return mutable as Data + } - @Test("decodeDimensions returns pixel dims for a valid PNG") - func decodeReturnsDimsForValidPNG() throws { - let png = try #require(Self.makePNG(width: 480, height: 270)) - let dims = ImageHeaderDecoder.decodeDimensions(from: png) - #expect(dims?.width == 480) - #expect(dims?.height == 270) - } + @Test + func `decodeDimensions returns pixel dims for a valid PNG`() throws { + let png = try #require(Self.makePNG(width: 480, height: 270)) + let dims = ImageHeaderDecoder.decodeDimensions(from: png) + #expect(dims?.width == 480) + #expect(dims?.height == 270) + } - @Test("decodeDimensions returns nil for non-image bytes") - func decodeReturnsNilForCorruptData() { - let dims = ImageHeaderDecoder.decodeDimensions(from: Data("not an image".utf8)) - #expect(dims == nil) - } + @Test + func `decodeDimensions returns nil for non-image bytes`() { + let dims = ImageHeaderDecoder.decodeDimensions(from: Data("not an image".utf8)) + #expect(dims == nil) + } - @Test("decodeDimensions returns nil for empty data") - func decodeReturnsNilForEmptyData() { - let dims = ImageHeaderDecoder.decodeDimensions(from: Data()) - #expect(dims == nil) - } + @Test + func `decodeDimensions returns nil for empty data`() { + let dims = ImageHeaderDecoder.decodeDimensions(from: Data()) + #expect(dims == nil) + } } diff --git a/MC1Tests/Services/ImageURLDetectorTests.swift b/MC1Tests/Services/ImageURLDetectorTests.swift index 24571893..14dade18 100644 --- a/MC1Tests/Services/ImageURLDetectorTests.swift +++ b/MC1Tests/Services/ImageURLDetectorTests.swift @@ -1,166 +1,165 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing struct ImageURLDetectorTests { - - // MARK: - Direct Image URL Detection - - @Test("Detects common image extensions", arguments: ["jpg", "jpeg", "png", "gif", "webp", "heic"]) - func detectsImageExtensions(ext: String) { - let url = URL(string: "https://example.com/photo.\(ext)")! - #expect(ImageURLClassifier.isDirectImageURL(url), "Should detect .\(ext)") - } - - @Test("Rejects non-image extensions", arguments: ["html", "pdf", "mp4", "txt", "js", "css"]) - func rejectsNonImageExtensions(ext: String) { - let url = URL(string: "https://example.com/file.\(ext)")! - #expect(!ImageURLClassifier.isDirectImageURL(url), "Should reject .\(ext)") - } - - @Test("Case insensitive extension detection") - func caseInsensitiveExtension() { - let url = URL(string: "https://example.com/photo.JPG")! - #expect(ImageURLClassifier.isDirectImageURL(url)) - - let urlMixed = URL(string: "https://example.com/photo.Png")! - #expect(ImageURLClassifier.isDirectImageURL(urlMixed)) - } - - @Test("Handles URLs with query parameters") - func urlWithQueryParameters() { - let url = URL(string: "https://example.com/photo.jpg?width=100&height=100")! - #expect(ImageURLClassifier.isDirectImageURL(url)) - } - - @Test("Rejects URL with no extension") - func noExtension() { - let url = URL(string: "https://example.com/photo")! - #expect(!ImageURLClassifier.isDirectImageURL(url)) - } - - @Test("Rejects empty path") - func emptyPath() { - let url = URL(string: "https://example.com/")! - #expect(!ImageURLClassifier.isDirectImageURL(url)) - } - - // MARK: - GIF Magic Byte Detection - - @Test("Detects GIF87a magic bytes") - func detectsGIF87a() { - let data = Data([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) // GIF87a - #expect(ImageURLDetector.isGIFData(data)) - } - - @Test("Detects GIF89a magic bytes") - func detectsGIF89a() { - let data = Data([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) // GIF89a - #expect(ImageURLDetector.isGIFData(data)) - } - - @Test("Rejects non-GIF data") - func rejectsNonGIFData() { - let pngData = Data([0x89, 0x50, 0x4E, 0x47]) // PNG header - #expect(!ImageURLDetector.isGIFData(pngData)) - } - - @Test("Rejects data shorter than 4 bytes") - func rejectsShortData() { - let data = Data([0x47, 0x49, 0x46]) // Only 3 bytes - #expect(!ImageURLDetector.isGIFData(data)) - } - - @Test("Rejects empty data") - func rejectsEmptyData() { - #expect(!ImageURLDetector.isGIFData(Data())) - } - - // MARK: - Giphy URL Resolution - - @Test("Resolves giphy.com/gifs/slug-text-ID") - func resolvesGiphySlugURL() { - let url = URL(string: "https://giphy.com/gifs/meme-cute-penguin-UTYwlUGi5iiRHtqEgj")! - let resolved = ImageURLClassifier.resolveImageURL(url) - #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") - } - - @Test("Resolves giphy.com/gifs/ID (no slug)") - func resolvesGiphyIDOnly() { - let url = URL(string: "https://giphy.com/gifs/UTYwlUGi5iiRHtqEgj")! - let resolved = ImageURLClassifier.resolveImageURL(url) - #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") - } - - @Test("Resolves giphy.com/embed/ID") - func resolvesGiphyEmbedURL() { - let url = URL(string: "https://giphy.com/embed/UTYwlUGi5iiRHtqEgj")! - let resolved = ImageURLClassifier.resolveImageURL(url) - #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") - } - - @Test("Recognizes media.giphy.com as direct image URL") - func recognizesMediaGiphy() { - let url = URL(string: "https://media.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif")! - #expect(ImageURLClassifier.isDirectImageURL(url), "Should be detected as direct image URL via .gif extension") - } - - @Test("Recognizes i.giphy.com as direct image URL") - func recognizesIGiphy() { - let url = URL(string: "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif")! - #expect(ImageURLClassifier.isDirectImageURL(url), "Should be detected as direct image URL via .gif extension") - } - - @Test("Returns nil for non-Giphy URLs") - func returnsNilForNonGiphy() { - let url = URL(string: "https://example.com/gifs/test-123")! - #expect(ImageURLClassifier.resolveImageURL(url) == nil) - } - - @Test("Returns nil for Giphy URLs without valid path") - func returnsNilForInvalidGiphyPath() { - let url = URL(string: "https://giphy.com/")! - #expect(ImageURLClassifier.resolveImageURL(url) == nil) - } - - @Test("Resolves www.giphy.com URLs") - func resolvesWWWGiphy() { - let url = URL(string: "https://www.giphy.com/gifs/test-ID123")! - let resolved = ImageURLClassifier.resolveImageURL(url) - #expect(resolved?.absoluteString == "https://i.giphy.com/media/ID123/giphy.gif") - } - - // MARK: - Composite Detection - - @Test("isImageURL returns true for direct image URLs") - func isImageURLDirectImage() { - let url = URL(string: "https://example.com/photo.png")! - #expect(ImageURLClassifier.isImageURL(url)) - } - - @Test("isImageURL returns true for resolvable Giphy URLs") - func isImageURLGiphy() { - let url = URL(string: "https://giphy.com/gifs/test-ABC123")! - #expect(ImageURLClassifier.isImageURL(url)) - } - - @Test("isImageURL returns false for non-image URLs") - func isImageURLNonImage() { - let url = URL(string: "https://example.com/page.html")! - #expect(!ImageURLClassifier.isImageURL(url)) - } - - @Test("directImageURL returns self for direct images") - func directImageURLSelf() { - let url = URL(string: "https://example.com/photo.jpg")! - #expect(ImageURLClassifier.directImageURL(for: url) == url) - } - - @Test("directImageURL resolves Giphy URLs") - func directImageURLResolvesGiphy() { - let url = URL(string: "https://giphy.com/gifs/funny-ABC123")! - let resolved = ImageURLClassifier.directImageURL(for: url) - #expect(resolved.absoluteString == "https://i.giphy.com/media/ABC123/giphy.gif") - } + // MARK: - Direct Image URL Detection + + @Test(arguments: ["jpg", "jpeg", "png", "gif", "webp", "heic"]) + func `Detects common image extensions`(ext: String) throws { + let url = try #require(URL(string: "https://example.com/photo.\(ext)")) + #expect(ImageURLClassifier.isDirectImageURL(url), "Should detect .\(ext)") + } + + @Test(arguments: ["html", "pdf", "mp4", "txt", "js", "css"]) + func `Rejects non-image extensions`(ext: String) throws { + let url = try #require(URL(string: "https://example.com/file.\(ext)")) + #expect(!ImageURLClassifier.isDirectImageURL(url), "Should reject .\(ext)") + } + + @Test + func `Case insensitive extension detection`() throws { + let url = try #require(URL(string: "https://example.com/photo.JPG")) + #expect(ImageURLClassifier.isDirectImageURL(url)) + + let urlMixed = try #require(URL(string: "https://example.com/photo.Png")) + #expect(ImageURLClassifier.isDirectImageURL(urlMixed)) + } + + @Test + func `Handles URLs with query parameters`() throws { + let url = try #require(URL(string: "https://example.com/photo.jpg?width=100&height=100")) + #expect(ImageURLClassifier.isDirectImageURL(url)) + } + + @Test + func `Rejects URL with no extension`() throws { + let url = try #require(URL(string: "https://example.com/photo")) + #expect(!ImageURLClassifier.isDirectImageURL(url)) + } + + @Test + func `Rejects empty path`() throws { + let url = try #require(URL(string: "https://example.com/")) + #expect(!ImageURLClassifier.isDirectImageURL(url)) + } + + // MARK: - GIF Magic Byte Detection + + @Test + func `Detects GIF87a magic bytes`() { + let data = Data([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) // GIF87a + #expect(ImageURLDetector.isGIFData(data)) + } + + @Test + func `Detects GIF89a magic bytes`() { + let data = Data([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) // GIF89a + #expect(ImageURLDetector.isGIFData(data)) + } + + @Test + func `Rejects non-GIF data`() { + let pngData = Data([0x89, 0x50, 0x4E, 0x47]) // PNG header + #expect(!ImageURLDetector.isGIFData(pngData)) + } + + @Test + func `Rejects data shorter than 4 bytes`() { + let data = Data([0x47, 0x49, 0x46]) // Only 3 bytes + #expect(!ImageURLDetector.isGIFData(data)) + } + + @Test + func `Rejects empty data`() { + #expect(!ImageURLDetector.isGIFData(Data())) + } + + // MARK: - Giphy URL Resolution + + @Test + func `Resolves giphy.com/gifs/slug-text-ID`() throws { + let url = try #require(URL(string: "https://giphy.com/gifs/meme-cute-penguin-UTYwlUGi5iiRHtqEgj")) + let resolved = ImageURLClassifier.resolveImageURL(url) + #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") + } + + @Test + func `Resolves giphy.com/gifs/ID (no slug)`() throws { + let url = try #require(URL(string: "https://giphy.com/gifs/UTYwlUGi5iiRHtqEgj")) + let resolved = ImageURLClassifier.resolveImageURL(url) + #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") + } + + @Test + func `Resolves giphy.com/embed/ID`() throws { + let url = try #require(URL(string: "https://giphy.com/embed/UTYwlUGi5iiRHtqEgj")) + let resolved = ImageURLClassifier.resolveImageURL(url) + #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") + } + + @Test + func `Recognizes media.giphy.com as direct image URL`() throws { + let url = try #require(URL(string: "https://media.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif")) + #expect(ImageURLClassifier.isDirectImageURL(url), "Should be detected as direct image URL via .gif extension") + } + + @Test + func `Recognizes i.giphy.com as direct image URL`() throws { + let url = try #require(URL(string: "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif")) + #expect(ImageURLClassifier.isDirectImageURL(url), "Should be detected as direct image URL via .gif extension") + } + + @Test + func `Returns nil for non-Giphy URLs`() throws { + let url = try #require(URL(string: "https://example.com/gifs/test-123")) + #expect(ImageURLClassifier.resolveImageURL(url) == nil) + } + + @Test + func `Returns nil for Giphy URLs without valid path`() throws { + let url = try #require(URL(string: "https://giphy.com/")) + #expect(ImageURLClassifier.resolveImageURL(url) == nil) + } + + @Test + func `Resolves www.giphy.com URLs`() throws { + let url = try #require(URL(string: "https://www.giphy.com/gifs/test-ID123")) + let resolved = ImageURLClassifier.resolveImageURL(url) + #expect(resolved?.absoluteString == "https://i.giphy.com/media/ID123/giphy.gif") + } + + // MARK: - Composite Detection + + @Test + func `isImageURL returns true for direct image URLs`() throws { + let url = try #require(URL(string: "https://example.com/photo.png")) + #expect(ImageURLClassifier.isImageURL(url)) + } + + @Test + func `isImageURL returns true for resolvable Giphy URLs`() throws { + let url = try #require(URL(string: "https://giphy.com/gifs/test-ABC123")) + #expect(ImageURLClassifier.isImageURL(url)) + } + + @Test + func `isImageURL returns false for non-image URLs`() throws { + let url = try #require(URL(string: "https://example.com/page.html")) + #expect(!ImageURLClassifier.isImageURL(url)) + } + + @Test + func `directImageURL returns self for direct images`() throws { + let url = try #require(URL(string: "https://example.com/photo.jpg")) + #expect(ImageURLClassifier.directImageURL(for: url) == url) + } + + @Test + func `directImageURL resolves Giphy URLs`() throws { + let url = try #require(URL(string: "https://giphy.com/gifs/funny-ABC123")) + let resolved = ImageURLClassifier.directImageURL(for: url) + #expect(resolved.absoluteString == "https://i.giphy.com/media/ABC123/giphy.gif") + } } diff --git a/MC1Tests/Services/InlineImageCacheTests.swift b/MC1Tests/Services/InlineImageCacheTests.swift index b0bc42c9..b1f0148c 100644 --- a/MC1Tests/Services/InlineImageCacheTests.swift +++ b/MC1Tests/Services/InlineImageCacheTests.swift @@ -1,103 +1,190 @@ import Foundation +@testable import MC1 import Testing import UIKit -@testable import MC1 @Suite("InlineImageCache Tests") struct InlineImageCacheTests { - - // MARK: - Safety gate - - @Test("Probe returns nil for a private-IP host") - func probeRejectsPrivateIP() async { - let url = URL(string: "http://127.0.0.1/test.png")! - let result = await InlineImageCache.shared.probeImageDimensions(url: url) - #expect(result == nil) - } - - @Test("Probe returns nil for a non-HTTP scheme") - func probeRejectsNonHTTPScheme() async { - let url = URL(string: "ftp://example.com/test.png")! - let result = await InlineImageCache.shared.probeImageDimensions(url: url) - #expect(result == nil) - } - - // MARK: - Decoded cache - - @Test("Decoded cache returns nil for an unseen URL") - func decodedReturnsNilForMissingKey() async { - let url = uniqueURL() - #expect(InlineImageCache.shared.decoded(for: url) == nil) - } - - @Test("Decoded cache round-trips a stored entry with raw bytes") - @MainActor - func decodedRoundTrip() async { - let url = uniqueURL() - let image = Self.makeImage() - let bytes = Data([0xFF, 0xD8, 0xFF, 0xE0]) - let entry = CachedDecodedImage(image: image, isGIF: false, data: bytes) - await InlineImageCache.shared.storeDecoded(entry, for: url) - - let result = InlineImageCache.shared.decoded(for: url) - #expect(result?.image === image) - #expect(result?.isGIF == false) - #expect(result?.data == bytes) - } - - @Test("Decoded cache preserves the GIF flag and omits bytes") - @MainActor - func decodedPreservesIsGIF() async { - let url = uniqueURL() - let image = Self.makeImage() - let entry = CachedDecodedImage(image: image, isGIF: true, data: nil) - await InlineImageCache.shared.storeDecoded(entry, for: url) - - let result = InlineImageCache.shared.decoded(for: url) - #expect(result?.isGIF == true) - #expect(result?.data == nil) - } - - @Test("Re-storing the same key replaces the entry without growing the cache") - @MainActor - func decodedReplaceSameKey() async { - let url = uniqueURL() - let first = CachedDecodedImage(image: Self.makeImage(), isGIF: false, data: Data([0x01])) - let second = CachedDecodedImage(image: Self.makeImage(), isGIF: true, data: nil) - - await InlineImageCache.shared.storeDecoded(first, for: url) - await InlineImageCache.shared.storeDecoded(second, for: url) - - let result = InlineImageCache.shared.decoded(for: url) - #expect(result?.image === second.image) - #expect(result?.isGIF == true) - #expect(result?.data == nil) - } - - @Test("Decoded cost reflects pixel size plus raw bytes") - @MainActor - func decodedCostIsPixelBytes() { - let image = Self.makeImage(width: 100, height: 50) - let bytes = Data(repeating: 0, count: 1_000) - let entry = CachedDecodedImage(image: image, isGIF: false, data: bytes) - // 100x50 RGBA bitmap is 100 * 50 * 4 = 20_000 bytes minimum, plus - // 1_000 raw bytes. CGImage.bytesPerRow may be padded for alignment, - // so we assert a sane lower bound rather than an exact value. - #expect(entry.cost >= 21_000) - } - - // MARK: - Helpers - - private func uniqueURL() -> URL { - URL(string: "https://example.invalid/\(UUID().uuidString).png")! + // MARK: - Safety gate + + @Test + func `Probe returns nil for a private-IP host`() async throws { + let url = try #require(URL(string: "http://127.0.0.1/test.png")) + let result = await InlineImageCache.shared.probeImageDimensions(url: url) + #expect(result == nil) + } + + @Test + func `Probe returns nil for a non-HTTP scheme`() async throws { + let url = try #require(URL(string: "ftp://example.com/test.png")) + let result = await InlineImageCache.shared.probeImageDimensions(url: url) + #expect(result == nil) + } + + // MARK: - HTML reroute + + @Test + func `An image URL that serves HTML returns notImage and stays retryable`() async { + let cache = InlineImageCache(session: Self.stubbedSession()) + let url = Self.htmlServingURL() + + let first = await cache.fetchImageData(for: url) + #expect(Self.isNotImage(first)) + + // A reroute must not poison the negative cache: re-fetching runs the + // network path again rather than short-circuiting to .failed, so a chat + // re-entry can still discover the page and load its og:image. + let second = await cache.fetchImageData(for: url) + #expect(Self.isNotImage(second)) + } + + @Test + func `An oversized HTML page still returns notImage instead of tripping the size guard`() async { + let cache = InlineImageCache(session: Self.stubbedSession()) + let url = Self.htmlServingURL(oversized: true) + + // The mime-type check must precede the download-size guard, so a page + // larger than the image cap reroutes rather than dead-ending as .failed. + let result = await cache.fetchImageData(for: url) + #expect(Self.isNotImage(result)) + } + + // MARK: - Decoded cache + + @Test + func `Decoded cache returns nil for an unseen URL`() { + let url = uniqueURL() + #expect(InlineImageCache.shared.decoded(for: url) == nil) + } + + @Test + @MainActor + func `Decoded cache round-trips a stored entry with raw bytes`() { + let url = uniqueURL() + let image = Self.makeImage() + let bytes = Data([0xFF, 0xD8, 0xFF, 0xE0]) + let entry = CachedDecodedImage(image: image, isGIF: false, data: bytes) + InlineImageCache.shared.storeDecoded(entry, for: url) + + let result = InlineImageCache.shared.decoded(for: url) + #expect(result?.image === image) + #expect(result?.isGIF == false) + #expect(result?.data == bytes) + } + + @Test + @MainActor + func `Decoded cache preserves the GIF flag and omits bytes`() { + let url = uniqueURL() + let image = Self.makeImage() + let entry = CachedDecodedImage(image: image, isGIF: true, data: nil) + InlineImageCache.shared.storeDecoded(entry, for: url) + + let result = InlineImageCache.shared.decoded(for: url) + #expect(result?.isGIF == true) + #expect(result?.data == nil) + } + + @Test + @MainActor + func `Re-storing the same key replaces the entry without growing the cache`() { + let url = uniqueURL() + let first = CachedDecodedImage(image: Self.makeImage(), isGIF: false, data: Data([0x01])) + let second = CachedDecodedImage(image: Self.makeImage(), isGIF: true, data: nil) + + InlineImageCache.shared.storeDecoded(first, for: url) + InlineImageCache.shared.storeDecoded(second, for: url) + + let result = InlineImageCache.shared.decoded(for: url) + #expect(result?.image === second.image) + #expect(result?.isGIF == true) + #expect(result?.data == nil) + } + + @Test + @MainActor + func `Decoded cost reflects pixel size plus raw bytes`() { + let image = Self.makeImage(width: 100, height: 50) + let bytes = Data(repeating: 0, count: 1000) + let entry = CachedDecodedImage(image: image, isGIF: false, data: bytes) + // 100x50 RGBA bitmap is 100 * 50 * 4 = 20_000 bytes minimum, plus + // 1_000 raw bytes. CGImage.bytesPerRow may be padded for alignment, + // so we assert a sane lower bound rather than an exact value. + #expect(entry.cost >= 21000) + } + + // MARK: - Helpers + + private func uniqueURL() -> URL { + URL(string: "https://example.invalid/\(UUID().uuidString).png")! + } + + /// An image-extension URL on an allow-listed CDN host, so the fetch's SSRF + /// gate passes without a real DNS lookup and the stubbed session answers. + private static func htmlServingURL(oversized: Bool = false) -> URL { + let marker = oversized ? "-\(HTMLResponseURLProtocol.oversizedMarker)" : "" + return URL(string: "https://media.giphy.com/\(UUID().uuidString)\(marker).jpg")! + } + + private static func stubbedSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [HTMLResponseURLProtocol.self] + return URLSession(configuration: config) + } + + private static func isNotImage(_ result: InlineImageResult) -> Bool { + if case .notImage = result { return true } + return false + } + + @MainActor + private static func makeImage(width: Int = 1, height: Int = 1) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) + return renderer.image { ctx in + UIColor.black.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) } + } +} - @MainActor - private static func makeImage(width: Int = 1, height: Int = 1) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) - return renderer.image { ctx in - UIColor.black.setFill() - ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) - } +/// Answers any request with a 200 `text/html` response, standing in for an +/// image-extension URL that actually serves a landing page. A URL carrying +/// `oversizedMarker` returns a body larger than the fetch's 10MB image cap, +/// exercising that the HTML reclassification precedes the size guard. +private final class HTMLResponseURLProtocol: URLProtocol { + static let oversizedMarker = "oversized" + + private static let htmlBody = Data("landing page".utf8) + private static let oversizedByteCount = 11 * 1024 * 1024 + + // swiftlint:disable:next static_over_final_class + override class func canInit(with request: URLRequest) -> Bool { + true + } + + // swiftlint:disable:next static_over_final_class + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/html; charset=utf-8"] + ) else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + let body = url.absoluteString.contains(Self.oversizedMarker) + ? Data(count: Self.oversizedByteCount) + : Self.htmlBody + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} } diff --git a/MC1Tests/Services/InlineImagePrefetcherTests.swift b/MC1Tests/Services/InlineImagePrefetcherTests.swift index a4aa37d6..7d8fd491 100644 --- a/MC1Tests/Services/InlineImagePrefetcherTests.swift +++ b/MC1Tests/Services/InlineImagePrefetcherTests.swift @@ -1,398 +1,650 @@ -import Testing -import Foundation import CoreGraphics -import MeshCore +import Foundation @testable import MC1 @testable import MC1Services +import MeshCore +import Testing @Suite("InlineImagePrefetcher Tests") @MainActor struct InlineImagePrefetcherTests { - - // MARK: - URL extraction - - @Test("Text with no URLs returns immediately without probes or previews") - func noURLsInTextNoCalls() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch(urlsIn: "hello world, no links here", isChannelMessage: false) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.isEmpty) - #expect(previewCalls.isEmpty) - } - - @Test("Direct image suffix invokes the dimension probe path") - func directImageInvokesProbe() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch( - urlsIn: "look at https://example.com/cat.png", - isChannelMessage: false - ) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.map(\.absoluteString) == ["https://example.com/cat.png"]) - #expect(previewCalls.isEmpty) - } - - // MARK: - Parallel fan-out - - @Test("Multiple URLs fan out across both classifier paths") - func multipleURLsParallel() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch( - urlsIn: "image https://example.com/cat.png and article https://example.com/article", - isChannelMessage: false - ) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.map(\.absoluteString) == ["https://example.com/cat.png"]) - #expect(previewCalls.map(\.absoluteString) == ["https://example.com/article"]) - } - - // MARK: - Skip-if-cached - - @Test("Direct image probe is skipped when dimensions are already cached") - func skipsCachedDirectImage() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let cachedURL = URL(string: "https://example.com/cat.png")! - await store.save(url: cachedURL, size: CGSize(width: 200, height: 100)) - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch( - urlsIn: "look at https://example.com/cat.png", - isChannelMessage: false - ) - - let probeCalls = await imageCache.probedURLs - #expect(probeCalls.isEmpty) - } - - // MARK: - Mixed direct image + link preview - - @Test("Mixed direct image and link preview URLs both invoke their paths") - func mixedDirectAndPreview() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch( - urlsIn: "see https://example.com/cat.jpg then read https://news.example.com/post", - isChannelMessage: true - ) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.count == 1) - #expect(probeCalls.first?.absoluteString == "https://example.com/cat.jpg") - #expect(previewCalls.count == 1) - #expect(previewCalls.first?.absoluteString == "https://news.example.com/post") - - let channelFlags = await linkCache.fetchedChannelFlags - #expect(channelFlags == [true]) - } - - // MARK: - Giphy hosting page routes to probe - - @Test("Giphy hosting URL routes to probe path with resolved direct image URL") - func giphyHostingRoutesToProbe() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - let hostingURL = URL(string: "https://giphy.com/gifs/abc123")! - let expectedProbeURL = ImageURLClassifier.directImageURL(for: hostingURL) - - await prefetcher.prefetch( - urlsIn: "look at \(hostingURL.absoluteString)", - isChannelMessage: false - ) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.map(\.absoluteString) == [expectedProbeURL.absoluteString]) - #expect(previewCalls.isEmpty) - } - - // MARK: - Helpers - - private static func makeTempDimensionsURL() -> URL { - let directory = FileManager.default.temporaryDirectory - .appending(path: "InlineImagePrefetcherTests-\(UUID().uuidString)") - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory.appending(path: "dimensions.json") - } + // MARK: - URL extraction + + @Test + func `Text with no URLs returns immediately without probes or previews`() async { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch(urlsIn: "hello world, no links here", isChannelMessage: false, allowImageProbes: true) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.isEmpty) + #expect(previewCalls.isEmpty) + } + + @Test + func `Direct image suffix invokes the dimension probe path`() async { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch( + urlsIn: "look at https://example.com/cat.png", + isChannelMessage: false, + allowImageProbes: true + ) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.map(\.absoluteString) == ["https://example.com/cat.png"]) + #expect(previewCalls.isEmpty) + } + + /// Receive-time leak regression: with `allowImageProbes` false, a direct + /// image URL fires no dimension probe (no third-party image request on + /// receive), while a card URL still reaches `LinkPreviewCache.preview`, + /// which self-gates. + @Test + func `Image probes are skipped when disallowed but card URLs still resolve`() async { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch( + urlsIn: "image https://example.com/cat.png and article https://example.com/article", + isChannelMessage: false, + allowImageProbes: false + ) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.isEmpty) + #expect(previewCalls.map(\.absoluteString) == ["https://example.com/article"]) + } + + // MARK: - Parallel fan-out + + @Test + func `Multiple URLs fan out across both classifier paths`() async { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch( + urlsIn: "image https://example.com/cat.png and article https://example.com/article", + isChannelMessage: false, + allowImageProbes: true + ) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.map(\.absoluteString) == ["https://example.com/cat.png"]) + #expect(previewCalls.map(\.absoluteString) == ["https://example.com/article"]) + } + + // MARK: - Skip-if-cached + + @Test + func `Direct image probe is skipped when dimensions are already cached`() async throws { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let cachedURL = try #require(URL(string: "https://example.com/cat.png")) + await store.save(url: cachedURL, size: CGSize(width: 200, height: 100)) + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch( + urlsIn: "look at https://example.com/cat.png", + isChannelMessage: false, + allowImageProbes: true + ) + + let probeCalls = await imageCache.probedURLs + #expect(probeCalls.isEmpty) + } + + // MARK: - Mixed direct image + link preview + + @Test + func `Mixed direct image and link preview URLs both invoke their paths`() async { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch( + urlsIn: "see https://example.com/cat.jpg then read https://news.example.com/post", + isChannelMessage: true, + allowImageProbes: true + ) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.count == 1) + #expect(probeCalls.first?.absoluteString == "https://example.com/cat.jpg") + #expect(previewCalls.count == 1) + #expect(previewCalls.first?.absoluteString == "https://news.example.com/post") + + let channelFlags = await linkCache.fetchedChannelFlags + #expect(channelFlags == [true]) + } + + // MARK: - Giphy hosting page routes to probe + + @Test + func `Giphy hosting URL routes to probe path with resolved direct image URL`() async throws { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + let hostingURL = try #require(URL(string: "https://giphy.com/gifs/abc123")) + let expectedProbeURL = ImageURLClassifier.directImageURL(for: hostingURL) + + await prefetcher.prefetch( + urlsIn: "look at \(hostingURL.absoluteString)", + isChannelMessage: false, + allowImageProbes: true + ) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.map(\.absoluteString) == [expectedProbeURL.absoluteString]) + #expect(previewCalls.isEmpty) + } + + // MARK: - Helpers + + private static func makeTempDimensionsURL() -> URL { + let directory = FileManager.default.temporaryDirectory + .appending(path: "InlineImagePrefetcherTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appending(path: "dimensions.json") + } } // MARK: - Stubs private actor StubImageProber: InlineImageDimensionProbing { - private(set) var probedURLs: [URL] = [] + private(set) var probedURLs: [URL] = [] - func probeImageDimensions(url: URL) async -> CGSize? { - probedURLs.append(url) - return nil - } + func probeImageDimensions(url: URL) async -> CGSize? { + probedURLs.append(url) + return nil + } } private actor StubLinkPreviewFetcher: LinkPreviewCaching { - private(set) var fetchedURLs: [URL] = [] - private(set) var fetchedChannelFlags: [Bool] = [] - - func preview( - for url: URL, - using dataStore: any PersistenceStoreProtocol, - isChannelMessage: Bool - ) async -> LinkPreviewResult { - fetchedURLs.append(url) - fetchedChannelFlags.append(isChannelMessage) - return .noPreviewAvailable - } - - func manualFetch( - for url: URL, - using dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - .noPreviewAvailable - } - - func isFetching(_ url: URL) async -> Bool { false } - - func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { nil } + private(set) var fetchedURLs: [URL] = [] + private(set) var fetchedChannelFlags: [Bool] = [] + + func preview( + for url: URL, + using dataStore: any PersistenceStoreProtocol, + isChannelMessage: Bool + ) async -> LinkPreviewResult { + fetchedURLs.append(url) + fetchedChannelFlags.append(isChannelMessage) + return .noPreviewAvailable + } + + func manualFetch( + for url: URL, + using dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult { + .noPreviewAvailable + } + + func isFetching(_ url: URL) async -> Bool { + false + } + + func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { + nil + } } private actor StubDataStore: PersistenceStoreProtocol { - - // MARK: - Link Preview Data - - func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { nil } - func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws {} - - // MARK: - Required Protocol Stubs - - func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { false } - func saveMessage(_ dto: MessageDTO) async throws {} - func fetchMessage(id: UUID) async throws -> MessageDTO? { nil } - func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } - func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } - func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } - func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } - func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} - func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} - func updateMessageLinkPreview(id: UUID, url: String?, title: String?, imageData: Data?, iconData: Data?, fetched: Bool) throws {} - - func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { [] } - func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { [] } - func fetchContact(id: UUID) async throws -> ContactDTO? { nil } - func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { nil } - func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { nil } - @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { UUID() } - func saveContact(_ dto: ContactDTO) async throws {} - func deleteContact(id: UUID) async throws {} - func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} - func incrementUnreadCount(contactID: UUID) async throws {} - func clearUnreadCount(contactID: UUID) async throws {} - - func markMentionSeen(messageID: UUID) async throws {} - func incrementUnreadMentionCount(contactID: UUID) async throws {} - func decrementUnreadMentionCount(contactID: UUID) async throws {} - func clearUnreadMentionCount(contactID: UUID) async throws {} - func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func clearChannelUnreadMentionCount(channelID: UUID) async throws {} - func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { [] } - func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { [] } - func deleteMessagesForContact(contactID: UUID) async throws {} - func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { [] } - - func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} - func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} - func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} - func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { [] } - - func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { [] } - func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { nil } - func fetchChannel(id: UUID) async throws -> ChannelDTO? { nil } - @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { UUID() } - func saveChannel(_ dto: ChannelDTO) async throws {} - func deleteChannel(id: UUID) async throws {} - func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} - func incrementChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} - - func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { [] } - func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { nil } - func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { - SavedTracePathDTO(id: UUID(), radioID: radioID, name: name, pathBytes: pathBytes, hashSize: hashSize, createdDate: Date(), runs: []) - } - func updateSavedTracePathName(id: UUID, name: String) async throws {} - func deleteSavedTracePath(id: UUID) async throws {} - func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} - - func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? { nil } - func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} - func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { [] } - func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { false } - func incrementMessageHeardRepeats(id: UUID) async throws -> Int { 0 } - func deleteMessageRepeats(messageID: UUID) async throws {} - func incrementMessageSendCount(id: UUID) async throws -> Int { 0 } - func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} - - func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} - func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { [] } - func countDebugLogEntries() async throws -> Int { 0 } - func pruneDebugLogEntries(keepCount: Int) async throws {} - func clearDebugLogEntries() async throws {} - - func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { [:] } - - func findRxLogEntry(radioID: UUID, channelIndex: UInt8?, senderTimestamp: UInt32) async throws -> RxLogEntryDTO? { nil } - func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { nil } - - func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} - func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { nil } - func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { [] } - func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { false } - func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} - func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} - - func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - fatalError("Not implemented") - } - func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} - func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { [] } - func deleteDiscoveredNode(id: UUID) async throws {} - func clearDiscoveredNodes(radioID: UUID) async throws {} - func fetchContactPublicKeys(radioID: UUID) async throws -> Set { Set() } - - func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { [] } - func saveReaction(_ dto: ReactionDTO) async throws {} - func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { false } - func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} - func deleteReactionsForMessage(messageID: UUID) async throws {} - func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - - func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} - func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} - func fetchDevice(id: UUID) async throws -> DeviceDTO? { nil } - func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { nil } - func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} - func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { nil } - func markSessionDisconnected(_ sessionID: UUID) async throws {} - func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { false } - func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { false } - func clearRetryingToSent(id: UUID) async throws -> Bool { false } - func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { false } - func markMessageAsRead(id: UUID) async throws {} - func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { nil } - func saveDevice(_ dto: DeviceDTO) async throws {} - func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { [] } - func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { [] } - func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} - func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} - func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} - func deleteRemoteNodeSession(id: UUID) async throws {} - func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} - func resetRoomUnreadCount(_ sessionID: UUID) async throws {} - func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { nil } - func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { nil } - func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} - func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } - func clearRxLogEntries(radioID: UUID) async throws {} - func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { [] } - func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} - func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} - func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - - func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} - - // swiftlint:disable:next line_length function_parameter_count - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } - func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } - func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } - func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} - func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} - func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { UUID() } - func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { UUID() } - func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} - - func upsertPendingSend(_ dto: PendingSendDTO) async throws {} - func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { 0 } - func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { [] } - func deletePendingSend(id: UUID) async throws {} - func deletePendingSendsForMessage(messageID: UUID) async throws {} - func hasPendingSend(messageID: UUID) async throws -> Bool { false } + // MARK: - Link Preview Data + + func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { + nil + } + + func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws {} + + // MARK: - Required Protocol Stubs + + func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { + false + } + + func saveMessage(_ dto: MessageDTO) async throws {} + func fetchMessage(id: UUID) async throws -> MessageDTO? { + nil + } + + func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { + [] + } + + func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { + [] + } + + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + [:] + } + + func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + [:] + } + + func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} + func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} + func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} + func updateMessageLinkPreview(id: UUID, url: String?, title: String?, imageData: Data?, iconData: Data?, fetched: Bool) throws {} + + func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func fetchContact(id: UUID) async throws -> ContactDTO? { + nil + } + + func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { + nil + } + + func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { + nil + } + + @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { + UUID() + } + + func saveContact(_ dto: ContactDTO) async throws {} + func deleteContact(id: UUID) async throws {} + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} + func incrementUnreadCount(contactID: UUID) async throws {} + func clearUnreadCount(contactID: UUID) async throws {} + + func markMentionSeen(messageID: UUID) async throws {} + func incrementUnreadMentionCount(contactID: UUID) async throws {} + func decrementUnreadMentionCount(contactID: UUID) async throws {} + func clearUnreadMentionCount(contactID: UUID) async throws {} + func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func clearChannelUnreadMentionCount(channelID: UUID) async throws {} + func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { + [] + } + + func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { + [] + } + + func deleteMessagesForContact(contactID: UUID) async throws {} + func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} + func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} + func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} + func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { + [] + } + + func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { + [] + } + + func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { + nil + } + + func fetchChannel(id: UUID) async throws -> ChannelDTO? { + nil + } + + @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { + UUID() + } + + func saveChannel(_ dto: ChannelDTO) async throws {} + func deleteChannel(id: UUID) async throws {} + func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} + func incrementChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} + + func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { + [] + } + + func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { + nil + } + + func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { + SavedTracePathDTO(id: UUID(), radioID: radioID, name: name, pathBytes: pathBytes, hashSize: hashSize, createdDate: Date(), runs: []) + } + + func updateSavedTracePathName(id: UUID, name: String) async throws {} + func deleteSavedTracePath(id: UUID) async throws {} + func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} + + func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String) async throws -> MessageDTO? { + nil + } + + func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} + func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { + [] + } + + func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { + false + } + + func incrementMessageHeardRepeats(id: UUID) async throws -> Int { + 0 + } + + func deleteMessageRepeats(messageID: UUID) async throws {} + func incrementMessageSendCount(id: UUID) async throws -> Int { + 0 + } + + func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} + + func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} + func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { + [] + } + + func countDebugLogEntries() async throws -> Int { + 0 + } + + func pruneDebugLogEntries(keepCount: Int) async throws {} + func clearDebugLogEntries() async throws {} + + func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { + [:] + } + + func findRxLogEntry(radioID: UUID, channelIndex: UInt8?, senderTimestamp: UInt32) async throws -> RxLogEntryDTO? { + nil + } + + func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { + nil + } + + func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} + func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { + nil + } + + func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { + [] + } + + func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { + false + } + + func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} + func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} + func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} + + func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { + fatalError("Not implemented") + } + + func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} + func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { + [] + } + + func deleteDiscoveredNode(id: UUID) async throws {} + func clearDiscoveredNodes(radioID: UUID) async throws {} + func fetchContactPublicKeys(radioID: UUID) async throws -> Set { + Set() + } + + func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { + [] + } + + func saveReaction(_ dto: ReactionDTO) async throws {} + func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { + false + } + + func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} + func deleteReactionsForMessage(messageID: UUID) async throws {} + func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} + func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} + func fetchDevice(id: UUID) async throws -> DeviceDTO? { + nil + } + + func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { + nil + } + + func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} + func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func markSessionDisconnected(_ sessionID: UUID) async throws {} + func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { + false + } + + func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { + false + } + + func clearRetryingToSent(id: UUID) async throws -> Bool { + false + } + + func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { + false + } + + func markMessageAsRead(id: UUID) async throws {} + func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { + nil + } + + func saveDevice(_ dto: DeviceDTO) async throws {} + func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + [] + } + + func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { + [] + } + + func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} + func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} + func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} + func deleteRemoteNodeSession(id: UUID) async throws {} + func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} + func resetRoomUnreadCount(_ sessionID: UUID) async throws {} + func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { + nil + } + + func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { + nil + } + + func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} + func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { + [] + } + + func clearRxLogEntries(radioID: UUID) async throws {} + func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} + func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + [] + } + + func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { + [] + } + + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} + + // swiftlint:disable:next function_parameter_count + func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? + ) async throws -> UUID { + UUID() + } + + func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { + nil + } + + func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { + [] + } + + func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} + func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} + func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { + UUID() + } + + func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { + UUID() + } + + func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} + + func upsertPendingSend(_ dto: PendingSendDTO) async throws {} + func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { + 0 + } + + func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { + [] + } + + func deletePendingSend(id: UUID) async throws {} + func deletePendingSendsForMessage(messageID: UUID) async throws {} + func hasPendingSend(messageID: UUID) async throws -> Bool { + false + } } diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index 16b778d7..59af4444 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -1,225 +1,164 @@ -import Testing import Foundation -import MeshCore @testable import MC1 @testable import MC1Services +import MeshCore +import Testing @Suite("LinkPreviewCache Tests") struct LinkPreviewCacheTests { - - // MARK: - Memory Cache Tests - - @Test("Returns cached preview from memory on subsequent requests") - func returnsCachedPreviewFromMemory() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/article")! - - // Seed the database with a preview - let dto = LinkPreviewDataDTO( - url: url.absoluteString, - title: "Test Article", - imageData: nil, - iconData: nil - ) - await dataStore.setStoredPreview(dto, for: url.absoluteString) - - // First request should hit database - let result1 = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - #expect(isLoaded(result1, withTitle: "Test Article")) - let fetchCount1 = await dataStore.fetchCallCount - #expect(fetchCount1 == 1) - - // Second request should hit memory cache (no additional fetch) - let result2 = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - #expect(isLoaded(result2, withTitle: "Test Article")) - let fetchCount2 = await dataStore.fetchCallCount - #expect(fetchCount2 == 1) // Should not increase - } - - @Test("Memory cache returns correct preview data") - func memoryCacheReturnsCorrectData() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/test")! - - let dto = LinkPreviewDataDTO( - url: url.absoluteString, - title: "Memory Cache Test", - imageData: Data([1, 2, 3]), - iconData: Data([4, 5, 6]) - ) - await dataStore.setStoredPreview(dto, for: url.absoluteString) - - // Load into memory cache - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - - // Verify cached data matches - let cached = await cache.cachedPreview(for: url) - #expect(cached?.title == "Memory Cache Test") - #expect(cached?.imageData == Data([1, 2, 3])) - #expect(cached?.iconData == Data([4, 5, 6])) - } - - // MARK: - Negative Cache Tests - - @Test("Negative cache prevents repeated network fetches for unavailable previews") - func negativeCachePreventsRepeatedFetches() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/no-preview")! - - // First request finds no preview (returns noPreviewAvailable) - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - - let initialFetchCount = await dataStore.fetchCallCount - - // Subsequent requests should hit negative cache (no database lookup) - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - - // The key assertion is that repeated requests don't exponentially increase fetches - let finalFetchCount = await dataStore.fetchCallCount - #expect(finalFetchCount <= initialFetchCount + 2) - } - - @Test("Manual fetch clears negative cache and retries") - func manualFetchClearsNegativeCache() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/retry")! - - // First auto-fetch finds nothing - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - - // Manual fetch should attempt again (clearing negative cache) - _ = await cache.manualFetch(for: url, using: dataStore) - - // Verify manual fetch was attempted (fetch count increased) - let fetchCount = await dataStore.fetchCallCount - #expect(fetchCount >= 1) - } - - // MARK: - In-Flight Deduplication Tests - - @Test("Concurrent requests for same URL don't create duplicate fetches") - func concurrentRequestsAreDeduplicated() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/concurrent")! - - // Add delay to database fetch to simulate slow operation - await dataStore.setFetchDelay(.milliseconds(100)) - - // Launch multiple concurrent requests - async let result1 = cache.preview(for: url, using: dataStore, isChannelMessage: false) - async let result2 = cache.preview(for: url, using: dataStore, isChannelMessage: false) - async let result3 = cache.preview(for: url, using: dataStore, isChannelMessage: false) - - // Wait for all to complete - let results = await [result1, result2, result3] - - // All results should be consistent - #expect(results.count == 3) + // MARK: - Memory Cache Tests + + @Test + func `Returns cached preview from memory on subsequent requests`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/article")) + + // Seed the database with a preview + let dto = LinkPreviewDataDTO( + url: url.absoluteString, + title: "Test Article", + imageData: nil, + iconData: nil + ) + await dataStore.setStoredPreview(dto, for: url.absoluteString) + + // First request should hit database + let result1 = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + #expect(isLoaded(result1, withTitle: "Test Article")) + let fetchCount1 = await dataStore.fetchCallCount + #expect(fetchCount1 == 1) + + // Second request should hit memory cache (no additional fetch) + let result2 = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + #expect(isLoaded(result2, withTitle: "Test Article")) + let fetchCount2 = await dataStore.fetchCallCount + #expect(fetchCount2 == 1) // Should not increase + } + + @Test + func `Memory cache returns correct preview data`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/test")) + + let dto = LinkPreviewDataDTO( + url: url.absoluteString, + title: "Memory Cache Test", + imageData: Data([1, 2, 3]), + iconData: Data([4, 5, 6]) + ) + await dataStore.setStoredPreview(dto, for: url.absoluteString) + + // Load into memory cache + _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + + // Verify cached data matches + let cached = await cache.cachedPreview(for: url) + #expect(cached?.title == "Memory Cache Test") + #expect(cached?.imageData == Data([1, 2, 3])) + #expect(cached?.iconData == Data([4, 5, 6])) + } + + // MARK: - In-Flight Deduplication Tests + + @Test + func `isFetching returns true while fetch is in progress`() async throws { + let cache = LinkPreviewCache() + let url = try #require(URL(string: "https://example.com/inflight")) + + // Initially not fetching + let initiallyFetching = await cache.isFetching(url) + #expect(!initiallyFetching) + } + + @Test + func `Concurrent fetches for the same URL coalesce; every caller receives the loaded result`() async throws { + let fetcher = FakeMetadataFetcher(delay: .milliseconds(200), title: "Coalesced") + let cache = LinkPreviewCache(service: fetcher) + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/coalesce")) + + // manualFetch bypasses the auto-resolve preference gate (off by default in + // tests) and routes through the same coalescing network fetch path. + // Start the first fetch and wait until it is registered in-flight, so the + // second request deterministically arrives during the network fetch window. + let first = Task { await cache.manualFetch(for: url, using: dataStore) } + var spins = 0 + while await !cache.isFetching(url), spins < 1000 { + await Task.yield() + spins += 1 } - @Test("isFetching returns true while fetch is in progress") - func isFetchingReturnsTrueDuringFetch() async { - let cache = LinkPreviewCache() - let url = URL(string: "https://example.com/inflight")! + // A follower arriving mid-flight must receive the resolved result, not a + // `.loading` placeholder that would strand its preview state forever. + let second = await cache.manualFetch(for: url, using: dataStore) + let firstResult = await first.value - // Initially not fetching - let initiallyFetching = await cache.isFetching(url) - #expect(!initiallyFetching) - } + #expect(isLoaded(firstResult, withTitle: "Coalesced")) + #expect(isLoaded(second, withTitle: "Coalesced")) - @Test("Concurrent fetches for the same URL coalesce; every caller receives the loaded result") - func concurrentNetworkFetchesCoalesceToLoaded() async { - let fetcher = FakeMetadataFetcher(delay: .milliseconds(200), title: "Coalesced") - let cache = LinkPreviewCache(service: fetcher) - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/coalesce")! - - // manualFetch bypasses the auto-resolve preference gate (off by default in - // tests) and routes through the same coalescing network fetch path. - // Start the first fetch and wait until it is registered in-flight, so the - // second request deterministically arrives during the network fetch window. - let first = Task { await cache.manualFetch(for: url, using: dataStore) } - var spins = 0 - while await !cache.isFetching(url), spins < 1000 { - await Task.yield() - spins += 1 - } - - // A follower arriving mid-flight must receive the resolved result, not a - // `.loading` placeholder that would strand its preview state forever. - let second = await cache.manualFetch(for: url, using: dataStore) - let firstResult = await first.value - - #expect(isLoaded(firstResult, withTitle: "Coalesced")) - #expect(isLoaded(second, withTitle: "Coalesced")) - - // Coalescing means the underlying network fetch ran exactly once. - let calls = await fetcher.callCount - #expect(calls == 1) - } + // Coalescing means the underlying network fetch ran exactly once. + let calls = await fetcher.callCount + #expect(calls == 1) + } - // MARK: - Database Integration Tests + // MARK: - Database Integration Tests - @Test("Preview is persisted to database after network fetch") - func previewIsPersistedToDatabase() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/persist")! + @Test + func `Preview is persisted to database after network fetch`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/persist")) - // Seed a preview that will be "fetched" - let dto = LinkPreviewDataDTO( - url: url.absoluteString, - title: "Persisted Preview", - imageData: nil, - iconData: nil - ) - await dataStore.setStoredPreview(dto, for: url.absoluteString) + // Seed a preview that will be "fetched" + let dto = LinkPreviewDataDTO( + url: url.absoluteString, + title: "Persisted Preview", + imageData: nil, + iconData: nil + ) + await dataStore.setStoredPreview(dto, for: url.absoluteString) - // Request preview - let result = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + // Request preview + let result = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - #expect(isLoaded(result, withTitle: "Persisted Preview")) - } + #expect(isLoaded(result, withTitle: "Persisted Preview")) + } - @Test("Database errors are handled gracefully") - func databaseErrorsHandledGracefully() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/error")! + @Test + func `Database errors are handled gracefully`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/error")) - // Configure dataStore to throw on fetch - await dataStore.setShouldThrowOnFetch(true) + // Configure dataStore to throw on fetch + await dataStore.setShouldThrowOnFetch(true) - // Request should not crash - let result = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + // Request should not crash + let result = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - // Should return disabled or noPreviewAvailable, not crash - #expect(isDisabledOrNoPreview(result)) - } + // Should return disabled or noPreviewAvailable, not crash + #expect(isDisabledOrNoPreview(result)) + } - // MARK: - Helper Functions + // MARK: - Helper Functions - private func isLoaded(_ result: LinkPreviewResult, withTitle title: String) -> Bool { - if case .loaded(let dto) = result { - return dto.title == title - } - return false + private func isLoaded(_ result: LinkPreviewResult, withTitle title: String) -> Bool { + if case let .loaded(dto) = result { + return dto.title == title } - - private func isDisabledOrNoPreview(_ result: LinkPreviewResult) -> Bool { - switch result { - case .disabled, .noPreviewAvailable: - return true - default: - return false - } + return false + } + + private func isDisabledOrNoPreview(_ result: LinkPreviewResult) -> Bool { + switch result { + case .disabled, .noPreviewAvailable: + true + default: + false } + } } // MARK: - Fake Metadata Fetcher @@ -227,256 +166,459 @@ struct LinkPreviewCacheTests { /// Deterministic stand-in for the LinkPresentation network fetch. Counts calls /// so a test can assert that concurrent same-URL requests coalesce onto one fetch. private actor FakeMetadataFetcher: LinkMetadataFetching { - private(set) var callCount = 0 - private let delay: Duration - private let title: String? - - init(delay: Duration, title: String?) { - self.delay = delay - self.title = title - } - - func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? { - callCount += 1 - if delay > .zero { try? await Task.sleep(for: delay) } - guard let title else { return nil } - return LinkPreviewMetadata(url: url, title: title, imageData: nil, iconData: nil) - } + private(set) var callCount = 0 + private let delay: Duration + private let title: String? + + init(delay: Duration, title: String?) { + self.delay = delay + self.title = title + } + + func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? { + callCount += 1 + if delay > .zero { try? await Task.sleep(for: delay) } + guard let title else { return nil } + return LinkPreviewMetadata(url: url, title: title, imageData: nil, iconData: nil) + } } // MARK: - Mock Data Store private actor MockPreviewDataStore: PersistenceStoreProtocol { - private var storedPreviews: [String: LinkPreviewDataDTO] = [:] - private(set) var fetchCallCount = 0 - private var saveCallCount = 0 - private var fetchDelay: Duration = .zero - private var shouldThrowOnFetch = false - private var shouldThrowOnSave = false - - // Async setters for actor-isolated properties - func setStoredPreview(_ dto: LinkPreviewDataDTO, for url: String) { - storedPreviews[url] = dto - } - - func setFetchDelay(_ delay: Duration) { - fetchDelay = delay - } - - func setShouldThrowOnFetch(_ value: Bool) { - shouldThrowOnFetch = value - } - - func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { - fetchCallCount += 1 - - if shouldThrowOnFetch { - throw MockError.fetchFailed - } - - if fetchDelay > .zero { - try? await Task.sleep(for: fetchDelay) - } - - return storedPreviews[url] + private var storedPreviews: [String: LinkPreviewDataDTO] = [:] + private(set) var fetchCallCount = 0 + private var saveCallCount = 0 + private var shouldThrowOnFetch = false + private var shouldThrowOnSave = false + + /// Async setters for actor-isolated properties + func setStoredPreview(_ dto: LinkPreviewDataDTO, for url: String) { + storedPreviews[url] = dto + } + + func setShouldThrowOnFetch(_ value: Bool) { + shouldThrowOnFetch = value + } + + func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { + fetchCallCount += 1 + + if shouldThrowOnFetch { + throw MockError.fetchFailed } - func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws { - saveCallCount += 1 - - if shouldThrowOnSave { - throw MockError.saveFailed - } + return storedPreviews[url] + } - storedPreviews[dto.url] = dto - } + func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws { + saveCallCount += 1 - private enum MockError: Error { - case fetchFailed - case saveFailed + if shouldThrowOnSave { + throw MockError.saveFailed } - // MARK: - Required Protocol Stubs - - // Message Operations - func saveMessage(_ dto: MessageDTO) async throws {} - func fetchMessage(id: UUID) async throws -> MessageDTO? { nil } - func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } - func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } - func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } - func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } - func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} - func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} - func updateMessageLinkPreview(id: UUID, url: String?, title: String?, imageData: Data?, iconData: Data?, fetched: Bool) throws {} - - // Contact Operations - func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { [] } - func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { [] } - func fetchContact(id: UUID) async throws -> ContactDTO? { nil } - func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { nil } - func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { nil } - @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { UUID() } - func saveContact(_ dto: ContactDTO) async throws {} - func deleteContact(id: UUID) async throws {} - func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} - func incrementUnreadCount(contactID: UUID) async throws {} - func clearUnreadCount(contactID: UUID) async throws {} - - // Mention Tracking - func markMentionSeen(messageID: UUID) async throws {} - func incrementUnreadMentionCount(contactID: UUID) async throws {} - func decrementUnreadMentionCount(contactID: UUID) async throws {} - func clearUnreadMentionCount(contactID: UUID) async throws {} - func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func clearChannelUnreadMentionCount(channelID: UUID) async throws {} - func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { [] } - func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { [] } - func deleteMessagesForContact(contactID: UUID) async throws {} - func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { [] } - - // Blocked Channel Senders - func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} - func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} - func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} - func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { [] } - - // Channel Operations - func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { [] } - func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { nil } - func fetchChannel(id: UUID) async throws -> ChannelDTO? { nil } - @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { UUID() } - func saveChannel(_ dto: ChannelDTO) async throws {} - func deleteChannel(id: UUID) async throws {} - func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} - func incrementChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} - - // Saved Trace Paths - func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { [] } - func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { nil } - func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { - SavedTracePathDTO(id: UUID(), radioID: radioID, name: name, pathBytes: pathBytes, hashSize: hashSize, createdDate: Date(), runs: []) - } - func updateSavedTracePathName(id: UUID, name: String) async throws {} - func deleteSavedTracePath(id: UUID) async throws {} - func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} - - // Heard Repeats - func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? { nil } - func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} - func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { [] } - func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { false } - func incrementMessageHeardRepeats(id: UUID) async throws -> Int { 0 } - func deleteMessageRepeats(messageID: UUID) async throws {} - func incrementMessageSendCount(id: UUID) async throws -> Int { 0 } - func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} - - // Debug Log Entries - func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} - func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { [] } - func countDebugLogEntries() async throws -> Int { 0 } - func pruneDebugLogEntries(keepCount: Int) async throws {} - func clearDebugLogEntries() async throws {} - - // Contact Public Keys - func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { [:] } - - // RxLogEntry Lookup - func findRxLogEntry(radioID: UUID, channelIndex: UInt8?, senderTimestamp: UInt32) async throws -> RxLogEntryDTO? { nil } - func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { nil } - - // Room Message Operations - func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} - func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { nil } - func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { [] } - func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { false } - func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { false } - func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} - func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} - - // Discovered Nodes - func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - fatalError("Not implemented") - } - func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} - func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { [] } - func deleteDiscoveredNode(id: UUID) async throws {} - func clearDiscoveredNodes(radioID: UUID) async throws {} - func fetchContactPublicKeys(radioID: UUID) async throws -> Set { Set() } - - // Reactions - func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { [] } - func saveReaction(_ dto: ReactionDTO) async throws {} - func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { false } - func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} - func deleteReactionsForMessage(messageID: UUID) async throws {} - func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - - // Notification Level - func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} - func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} - func fetchDevice(id: UUID) async throws -> DeviceDTO? { nil } - func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { nil } - func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} - func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { nil } - func markSessionDisconnected(_ sessionID: UUID) async throws {} - func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { false } - func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { false } - func clearRetryingToSent(id: UUID) async throws -> Bool { false } - func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { false } - func markMessageAsRead(id: UUID) async throws {} - func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { nil } - func saveDevice(_ dto: DeviceDTO) async throws {} - func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { [] } - func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { [] } - func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} - func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} - func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} - func deleteRemoteNodeSession(id: UUID) async throws {} - func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} - func resetRoomUnreadCount(_ sessionID: UUID) async throws {} - func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { nil } - func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { nil } - func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} - func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } - func clearRxLogEntries(radioID: UUID) async throws {} - func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { [] } - func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} - func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} - func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - - // Channel Message Deletion - func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} - - // Node Status Snapshots - // swiftlint:disable:next line_length function_parameter_count - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } - func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } - func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } - func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} - func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} - func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { UUID() } - func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { UUID() } - func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} - - // Pending Sends - func upsertPendingSend(_ dto: PendingSendDTO) async throws {} - func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { 0 } - func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { [] } - func deletePendingSend(id: UUID) async throws {} - func deletePendingSendsForMessage(messageID: UUID) async throws {} - func hasPendingSend(messageID: UUID) async throws -> Bool { false } + storedPreviews[dto.url] = dto + } + + private enum MockError: Error { + case fetchFailed + case saveFailed + } + + // MARK: - Required Protocol Stubs + + // Message Operations + func saveMessage(_ dto: MessageDTO) async throws {} + func fetchMessage(id: UUID) async throws -> MessageDTO? { + nil + } + + func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { + [] + } + + func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { + [] + } + + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + [:] + } + + func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + [:] + } + + func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} + func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} + func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} + func updateMessageLinkPreview(id: UUID, url: String?, title: String?, imageData: Data?, iconData: Data?, fetched: Bool) throws {} + + /// Contact Operations + func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func fetchContact(id: UUID) async throws -> ContactDTO? { + nil + } + + func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { + nil + } + + func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { + nil + } + + @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { + UUID() + } + + func saveContact(_ dto: ContactDTO) async throws {} + func deleteContact(id: UUID) async throws {} + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} + func incrementUnreadCount(contactID: UUID) async throws {} + func clearUnreadCount(contactID: UUID) async throws {} + + // Mention Tracking + func markMentionSeen(messageID: UUID) async throws {} + func incrementUnreadMentionCount(contactID: UUID) async throws {} + func decrementUnreadMentionCount(contactID: UUID) async throws {} + func clearUnreadMentionCount(contactID: UUID) async throws {} + func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func clearChannelUnreadMentionCount(channelID: UUID) async throws {} + func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { + [] + } + + func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { + [] + } + + func deleteMessagesForContact(contactID: UUID) async throws {} + func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + // Blocked Channel Senders + func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} + func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} + func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} + func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { + [] + } + + /// Channel Operations + func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { + [] + } + + func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { + nil + } + + func fetchChannel(id: UUID) async throws -> ChannelDTO? { + nil + } + + @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { + UUID() + } + + func saveChannel(_ dto: ChannelDTO) async throws {} + func deleteChannel(id: UUID) async throws {} + func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} + func incrementChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} + + /// Saved Trace Paths + func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { + [] + } + + func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { + nil + } + + func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { + SavedTracePathDTO(id: UUID(), radioID: radioID, name: name, pathBytes: pathBytes, hashSize: hashSize, createdDate: Date(), runs: []) + } + + func updateSavedTracePathName(id: UUID, name: String) async throws {} + func deleteSavedTracePath(id: UUID) async throws {} + func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} + + /// Heard Repeats + func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String) async throws -> MessageDTO? { + nil + } + + func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} + func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { + [] + } + + func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { + false + } + + func incrementMessageHeardRepeats(id: UUID) async throws -> Int { + 0 + } + + func deleteMessageRepeats(messageID: UUID) async throws {} + func incrementMessageSendCount(id: UUID) async throws -> Int { + 0 + } + + func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} + + // Debug Log Entries + func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} + func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { + [] + } + + func countDebugLogEntries() async throws -> Int { + 0 + } + + func pruneDebugLogEntries(keepCount: Int) async throws {} + func clearDebugLogEntries() async throws {} + + /// Contact Public Keys + func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { + [:] + } + + /// RxLogEntry Lookup + func findRxLogEntry(radioID: UUID, channelIndex: UInt8?, senderTimestamp: UInt32) async throws -> RxLogEntryDTO? { + nil + } + + func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { + nil + } + + // Room Message Operations + func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} + func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { + nil + } + + func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { + [] + } + + func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { + false + } + + func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { + false + } + + func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} + func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} + func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} + + /// Discovered Nodes + func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { + fatalError("Not implemented") + } + + func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} + func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { + [] + } + + func deleteDiscoveredNode(id: UUID) async throws {} + func clearDiscoveredNodes(radioID: UUID) async throws {} + func fetchContactPublicKeys(radioID: UUID) async throws -> Set { + Set() + } + + /// Reactions + func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { + [] + } + + func saveReaction(_ dto: ReactionDTO) async throws {} + func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { + false + } + + func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} + func deleteReactionsForMessage(messageID: UUID) async throws {} + func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + // Notification Level + func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} + func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} + func fetchDevice(id: UUID) async throws -> DeviceDTO? { + nil + } + + func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { + nil + } + + func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} + func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func markSessionDisconnected(_ sessionID: UUID) async throws {} + func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { + false + } + + func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { + false + } + + func clearRetryingToSent(id: UUID) async throws -> Bool { + false + } + + func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { + false + } + + func markMessageAsRead(id: UUID) async throws {} + func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { + nil + } + + func saveDevice(_ dto: DeviceDTO) async throws {} + func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + [] + } + + func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { + [] + } + + func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} + func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} + func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} + func deleteRemoteNodeSession(id: UUID) async throws {} + func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} + func resetRoomUnreadCount(_ sessionID: UUID) async throws {} + func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { + nil + } + + func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { + nil + } + + func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} + func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { + [] + } + + func clearRxLogEntries(radioID: UUID) async throws {} + func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} + func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + [] + } + + func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { + [] + } + + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + + /// Channel Message Deletion + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} + + // Node Status Snapshots + // swiftlint:disable:next function_parameter_count + func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? + ) async throws -> UUID { + UUID() + } + + func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { + nil + } + + func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { + [] + } + + func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} + func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} + func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { + UUID() + } + + func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { + UUID() + } + + func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} + + // Pending Sends + func upsertPendingSend(_ dto: PendingSendDTO) async throws {} + func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { + 0 + } + + func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { + [] + } + + func deletePendingSend(id: UUID) async throws {} + func deletePendingSendsForMessage(messageID: UUID) async throws {} + func hasPendingSend(messageID: UUID) async throws -> Bool { + false + } } diff --git a/MC1Tests/Services/LinkPreviewServiceTests.swift b/MC1Tests/Services/LinkPreviewServiceTests.swift index 35907887..be93fcc6 100644 --- a/MC1Tests/Services/LinkPreviewServiceTests.swift +++ b/MC1Tests/Services/LinkPreviewServiceTests.swift @@ -1,170 +1,558 @@ -import Testing - +import Foundation @testable import MC1 +import Testing +import UIKit @Suite("LinkPreviewService Tests") @MainActor struct LinkPreviewServiceTests { - @Test("Extracts HTTPS URL from text") - func extractsHTTPSURL() { - let text = "Check out https://example.com/article for more info" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://example.com/article") - } + @Test + func `Extracts HTTPS URL from text`() { + let text = "Check out https://example.com/article for more info" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://example.com/article") + } - @Test("Extracts HTTP URL from text") - func extractsHTTPURL() { - let text = "Visit http://example.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.scheme == "http") - } + @Test + func `Extracts HTTP URL from text`() { + let text = "Visit http://example.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.scheme == "http") + } - @Test("Returns nil for text without URLs") - func returnsNilForNoURL() { - let text = "Just some plain text without links" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } + @Test + func `Returns nil for text without URLs`() { + let text = "Just some plain text without links" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } - @Test("Extracts first URL when multiple URLs present") - func extractsFirstURLOnly() { - let text = "First https://first.com then https://second.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.host == "first.com") - } + @Test + func `Extracts first URL when multiple URLs present`() { + let text = "First https://first.com then https://second.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.host == "first.com") + } - @Test("Ignores non-HTTP schemes like tel: and mailto:") - func ignoresNonHTTPSchemes() { - let text = "Call me at tel:+1234567890 or mailto:test@example.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } + @Test + func `Ignores non-HTTP schemes like tel: and mailto:`() { + let text = "Call me at tel:+1234567890 or mailto:test@example.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } - @Test("Extracts URL with path and query string") - func extractsURLWithPath() { - let text = "Read https://example.com/blog/2024/article-title?ref=social" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.path == "/blog/2024/article-title") - #expect(url?.query == "ref=social") - } + @Test + func `Extracts URL with path and query string`() { + let text = "Read https://example.com/blog/2024/article-title?ref=social" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.path == "/blog/2024/article-title") + #expect(url?.query == "ref=social") + } - @Test("Extracts URL at beginning of text") - func extractsURLAtBeginning() { - let text = "https://example.com is a great site" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://example.com") - } + @Test + func `Extracts URL at beginning of text`() { + let text = "https://example.com is a great site" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://example.com") + } - @Test("Extracts URL at end of text") - func extractsURLAtEnd() { - let text = "Check this out: https://example.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://example.com") - } + @Test + func `Extracts URL at end of text`() { + let text = "Check this out: https://example.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://example.com") + } - @Test("Returns nil for empty text") - func returnsNilForEmptyText() { - let text = "" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } + @Test + func `Returns nil for empty text`() { + let text = "" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } - @Test("Handles URL with fragment") - func handlesURLWithFragment() { - let text = "See https://example.com/page#section" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.fragment == "section") - } + @Test + func `Handles URL with fragment`() { + let text = "See https://example.com/page#section" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.fragment == "section") + } - // MARK: - URL in Mention Tests + // MARK: - URL in Mention Tests - @Test("Ignores URL-like text within mention brackets") - func ignoresURLInMention() { - let text = "Hey @[Ferret PocketMesh WCMesh.com], check this out!" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil, "WCMesh.com within @[] should not be extracted as a URL") - } + @Test + func `Ignores URL-like text within mention brackets`() { + let text = "Hey @[Ferret PocketMesh WCMesh.com], check this out!" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil, "WCMesh.com within @[] should not be extracted as a URL") + } - @Test("Ignores domain-like text within mention brackets") - func ignoresDomainInMention() { - let text = "@[Server node.example.com] says hello" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil, "node.example.com within @[] should not be extracted") - } + @Test + func `Ignores domain-like text within mention brackets`() { + let text = "@[Server node.example.com] says hello" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil, "node.example.com within @[] should not be extracted") + } - @Test("Extracts real URL when mention also contains URL-like text") - func extractsRealURLNotMentionURL() { - let text = "@[Server node.example.com] says check https://docs.example.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://docs.example.com") - } + @Test + func `Extracts real URL when mention also contains URL-like text`() { + let text = "@[Server node.example.com] says check https://docs.example.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://docs.example.com") + } - @Test("Extracts URL when no mentions present") - func extractsURLWithoutMentions() { - let text = "Just a normal message with https://example.com link" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://example.com") - } + @Test + func `Extracts URL when no mentions present`() { + let text = "Just a normal message with https://example.com link" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://example.com") + } - @Test("Returns nil when only URL-like text in mention") - func returnsNilForOnlyMentionURL() { - let text = "Message from @[192.168.1.100]" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil, "IP address in mention should not be extracted") - } + @Test + func `Returns nil when only URL-like text in mention`() { + let text = "Message from @[192.168.1.100]" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil, "IP address in mention should not be extracted") + } - // MARK: - Meshcore-open GIF Format Tests + // MARK: - Meshcore-open GIF Format Tests - @Test("Extracts Giphy URL from g: prefix message") - func extractsGiphyFromGPrefix() { - let text = "g:JgWZYoIgjzsIQO8joZ" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://media.giphy.com/media/JgWZYoIgjzsIQO8joZ/giphy.gif") - } + @Test + func `Extracts Giphy URL from g: prefix message`() { + let text = "g:JgWZYoIgjzsIQO8joZ" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://media.giphy.com/media/JgWZYoIgjzsIQO8joZ/giphy.gif") + } - @Test("Extracts Giphy URL from g: with whitespace") - func extractsGiphyWithWhitespace() { - let text = " g:ABC123xyz " - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://media.giphy.com/media/ABC123xyz/giphy.gif") - } + @Test + func `Extracts Giphy URL from g: with whitespace`() { + let text = " g:ABC123xyz " + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://media.giphy.com/media/ABC123xyz/giphy.gif") + } - @Test("Handles g: with hyphens and underscores in ID") - func extractsGiphyWithHyphensUnderscores() { - let text = "g:my-gif_ID-123" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://media.giphy.com/media/my-gif_ID-123/giphy.gif") - } + @Test + func `Handles g: with hyphens and underscores in ID`() { + let text = "g:my-gif_ID-123" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://media.giphy.com/media/my-gif_ID-123/giphy.gif") + } - @Test("Returns nil for g: with no ID") - func returnsNilForEmptyGPrefix() { - let text = "g:" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } + @Test + func `Returns nil for g: with no ID`() { + let text = "g:" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } - @Test("Does not match g: embedded in longer text") - func doesNotMatchEmbeddedGPrefix() { - let text = "Check out g:ABC123 please" - let url = LinkPreviewService.extractFirstURL(from: text) - // Should not match because wholeMatch requires entire string - #expect(url == nil) - } + @Test + func `Does not match g: embedded in longer text`() { + let text = "Check out g:ABC123 please" + let url = LinkPreviewService.extractFirstURL(from: text) + // Should not match because wholeMatch requires entire string + #expect(url == nil) + } - @Test("Does not match g: with invalid characters in ID") - func doesNotMatchInvalidGPrefixChars() { - let text = "g:ABC 123" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } + @Test + func `Does not match g: with invalid characters in ID`() { + let text = "g:ABC 123" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } - @Test("extractGiphyGIFURL returns nil for plain text") - func giphyExtractReturnsNilForPlainText() { - #expect(LinkPreviewService.extractGiphyGIFURL(from: "hello world") == nil) - } + @Test + func `extractGiphyGIFURL returns nil for plain text`() { + #expect(LinkPreviewService.extractGiphyGIFURL(from: "hello world") == nil) + } + + @Test + func `extractGiphyGIFURL returns nil for regular URL`() { + #expect(LinkPreviewService.extractGiphyGIFURL(from: "https://example.com") == nil) + } + + // MARK: - parseHTMLMetadata (og:image scrape) + + @Test + func `Parses a pasteboard-style head for its og image`() throws { + let html = """ + + + + + """ + let baseURL = try #require(URL(string: "https://pasteboard.co/lZtwvIBHoO7L.jpg")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result?.imageURL?.absoluteString == "https://gcdnb.pbrd.co/images/lZtwvIBHoO7L.jpg") + } + + @Test + func `Prefers og image over twitter image when both are present`() throws { + let html = """ + + + + + + """ + let baseURL = try #require(URL(string: "https://imgur.com/gallery/abc123")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result?.imageURL?.absoluteString == "https://i.imgur.com/rmwz8FJ.jpeg?fb") + } + + @Test + func `Handles content attribute before property attribute`() throws { + let html = """ + + """ + let baseURL = try #require(URL(string: "https://example.com/page")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result?.imageURL?.absoluteString == "https://example.com/hero.jpg") + } + + @Test + func `Falls back to twitter image when no og image is present`() throws { + let html = """ + + """ + let baseURL = try #require(URL(string: "https://example.com/page")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result?.imageURL?.absoluteString == "https://example.com/twitter-hero.jpg") + } + + @Test + func `Resolves a relative og image URL against the page URL`() throws { + let html = """ + + """ + let baseURL = try #require(URL(string: "https://example.com/page")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result?.imageURL?.absoluteString == "https://example.com/images/hero.jpg") + } + + @Test + func `Unescapes HTML entities in a multi param og image URL`() throws { + let html = """ + + """ + let baseURL = try #require(URL(string: "https://example.com/page")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result?.imageURL?.absoluteString == "https://example.com/img.jpg?a=1&b=2") + } + + @Test + func `Returns nil when the page has no Open Graph or Twitter Card tags`() throws { + let html = "No OG tags" + let baseURL = try #require(URL(string: "https://example.com/page")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result == nil) + } + + @Test + func `Extracts the og title alongside the og image`() throws { + let html = """ + + + + + """ + let baseURL = try #require(URL(string: "https://example.com/page")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result?.title == "Ben & Jerry") + #expect(result?.imageURL?.absoluteString == "https://example.com/hero.jpg") + } + + @Test + func `Returns a title-only result when the page has og title but no image`() throws { + let html = """ + + """ + let baseURL = try #require(URL(string: "https://example.com/page")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result?.title == "Title only page") + #expect(result?.imageURL == nil) + } + + @Test + func `Drops a non-HTTP og image URL`() throws { + let html = """ + + """ + let baseURL = try #require(URL(string: "https://example.com/page")) + let result = LinkPreviewService.parseHTMLMetadata(html, baseURL: baseURL) + #expect(result == nil) + } + + // MARK: - scrapeHTMLMetadata (URLProtocol-stubbed) + + @Test + func `scrapeHTMLMetadata parses og tags from a stubbed HTML page`() async throws { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [HTMLPageURLProtocol.self] + let session = URLSession(configuration: config) + let service = LinkPreviewService(scrapeSession: session) + + let url = try #require(URL(string: "https://example.com/page")) + let result = await service.scrapeHTMLMetadata(for: url) + #expect(result?.title == "Stubbed page") + #expect(result?.imageURL?.absoluteString == "https://example.com/hero.jpg") + } + + @Test + func `scrapeHTMLMetadata rejects a non-HTML mime type`() async throws { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [NonImageMimeURLProtocol.self] + let session = URLSession(configuration: config) + let service = LinkPreviewService(scrapeSession: session) - @Test("extractGiphyGIFURL returns nil for regular URL") - func giphyExtractReturnsNilForRegularURL() { - #expect(LinkPreviewService.extractGiphyGIFURL(from: "https://example.com") == nil) + let url = try #require(URL(string: "https://example.com/page")) + let result = await service.scrapeHTMLMetadata(for: url) + #expect(result == nil) + } + + // MARK: - loadImageData orchestration (URLProtocol-stubbed) + + @Test + func `loadImageData rejects a redirect to a private host`() async throws { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [RedirectToPrivateHostURLProtocol.self] + let session = URLSession(configuration: config, delegate: RedirectSafetyDelegate(), delegateQueue: nil) + let service = LinkPreviewService(scrapeSession: session) + + let url = try #require(URL(string: "https://example.com/photo.jpg")) + let data = await service.loadImageData(from: url) + #expect(data == nil) + } + + @Test + func `loadImageData rejects an oversized expected content length`() async throws { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OversizedImageURLProtocol.self] + let session = URLSession(configuration: config) + let service = LinkPreviewService(scrapeSession: session) + + let url = try #require(URL(string: "https://example.com/huge.jpg")) + let data = await service.loadImageData(from: url) + #expect(data == nil) + } + + @Test + func `loadImageData rejects a non image mime type`() async throws { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [NonImageMimeURLProtocol.self] + let session = URLSession(configuration: config) + let service = LinkPreviewService(scrapeSession: session) + + let url = try #require(URL(string: "https://example.com/not-an-image.jpg")) + let data = await service.loadImageData(from: url) + #expect(data == nil) + } + + @Test + func `loadImageData returns decoded data for a valid image`() async throws { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ValidImageURLProtocol.self] + let session = URLSession(configuration: config) + let service = LinkPreviewService(scrapeSession: session) + + let url = try #require(URL(string: "https://example.com/photo.jpg")) + let data = await service.loadImageData(from: url) + let image = try #require(data.flatMap(UIImage.init(data:))) + #expect(image.size.width > 0) + } +} + +/// A real, decodable JPEG produced by the platform encoder, so tests that +/// assert a fetch was refused can't pass merely because the payload was +/// undecodable, and positive controls have genuine image bytes to decode. +private let jpegFixture: Data = { + let size = CGSize(width: 4, height: 4) + let image = UIGraphicsImageRenderer(size: size).image { context in + UIColor.red.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + return image.jpegData(compressionQuality: 1.0)! +}() + +/// Redirects the initial request to a loopback-literal target via the real +/// URL Loading System redirect flow (`wasRedirectedTo:redirectResponse:`), +/// so a test using the production `RedirectSafetyDelegate` proves the +/// redirect hop is refused rather than followed. If the redirect were +/// followed despite being unsafe, the private-host leg would answer with a +/// valid image and the test would fail. +private final class RedirectToPrivateHostURLProtocol: URLProtocol { + static let privateTarget = "http://127.0.0.1/secret.jpg" + + // swiftlint:disable:next static_over_final_class + override class func canInit(with request: URLRequest) -> Bool { + true + } + + // swiftlint:disable:next static_over_final_class + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url else { return } + + if url.host == "127.0.0.1" { + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "image/jpeg"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + // A real decodable JPEG: if the redirect guard failed and this leg + // were fetched, loadImageData would return data and the test would + // fail, rather than passing because the payload couldn't decode. + client?.urlProtocol(self, didLoad: jpegFixture) + client?.urlProtocolDidFinishLoading(self) + return } + + let redirectRequest = URLRequest(url: URL(string: Self.privateTarget)!) + let redirectResponse = HTTPURLResponse( + url: url, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: ["Location": Self.privateTarget] + )! + // Deliver the 302 through the normal response path too: if the + // delegate declines the redirect, the loading system needs an already + // "finished" load to resolve the task promptly with the redirect + // response, rather than sitting until the request timeout elapses. + client?.urlProtocol(self, wasRedirectedTo: redirectRequest, redirectResponse: redirectResponse) + client?.urlProtocol(self, didReceive: redirectResponse, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +/// Answers with a `Content-Length` above `imageByteCap` so the pre-download +/// size guard rejects the fetch before any body bytes are streamed. +private final class OversizedImageURLProtocol: URLProtocol { + private static let oversizedByteCount = 3 * 1024 * 1024 + + // swiftlint:disable:next static_over_final_class + override class func canInit(with request: URLRequest) -> Bool { + true + } + + // swiftlint:disable:next static_over_final_class + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url else { return } + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "image/jpeg", + "Content-Length": "\(Self.oversizedByteCount)" + ] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: jpegFixture) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +/// Serves a small, genuinely decodable JPEG so the happy path proves +/// `loadImageData` actually returns image data when nothing is wrong. +private final class ValidImageURLProtocol: URLProtocol { + // swiftlint:disable:next static_over_final_class + override class func canInit(with request: URLRequest) -> Bool { + true + } + + // swiftlint:disable:next static_over_final_class + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url else { return } + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "image/jpeg"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: jpegFixture) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +/// Serves a small HTML page carrying `og:title` and `og:image` tags for the +/// `scrapeHTMLMetadata` network-path tests. +private final class HTMLPageURLProtocol: URLProtocol { + static let html = """ + + + + + """ + + // swiftlint:disable:next static_over_final_class + override class func canInit(with request: URLRequest) -> Bool { + true + } + + // swiftlint:disable:next static_over_final_class + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url else { return } + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(Self.html.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +/// Answers a `.jpg`-path request with a non-image body, standing in for an +/// og:image URL that doesn't actually serve image bytes. +private final class NonImageMimeURLProtocol: URLProtocol { + // swiftlint:disable:next static_over_final_class + override class func canInit(with request: URLRequest) -> Bool { + true + } + + // swiftlint:disable:next static_over_final_class + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url else { return } + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/plain"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data("nope".utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} } diff --git a/MC1Tests/Services/MapSnapshotStoreTests.swift b/MC1Tests/Services/MapSnapshotStoreTests.swift index 307432cc..065bc4c3 100644 --- a/MC1Tests/Services/MapSnapshotStoreTests.swift +++ b/MC1Tests/Services/MapSnapshotStoreTests.swift @@ -1,220 +1,223 @@ +@testable import MC1 import Testing import UIKit -@testable import MC1 @Suite("MapSnapshotStore Tests") @MainActor struct MapSnapshotStoreTests { - - /// Fake renderer: returns a 1x1 solid image after an optional gate, counting - /// calls. No MapLibre/GL. - final class FakeRenderer: MapSnapshotRendering { - private(set) var renderCount = 0 - var gate: CheckedContinuation? - var shouldFail = false - - func render(_ request: MapSnapshotRequest) async -> UIImage? { - renderCount += 1 - if gate == nil, awaitGate { - await withCheckedContinuation { gate = $0 } - } - if shouldFail { return nil } - let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) - return renderer.image { ctx in - UIColor.red.setFill() - ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) - } - } - - var awaitGate = false - func release() { gate?.resume(); gate = nil } - } - - private func request( - _ lat: Double = 37.0, - _ lon: Double = -122.0, - dark: Bool = false, - offline: Bool = false - ) -> MapSnapshotRequest { - MapSnapshotRequest(latitude: lat, longitude: lon, isDark: dark, isOffline: offline) - } - - @Test("A cache miss returns nil synchronously; isResolved is false") - func missIsNilAndUnresolved() { - let store = MapSnapshotStore(renderer: FakeRenderer()) - let req = request() - #expect(store.image(for: req) == nil) - #expect(store.isResolved(req) == false) - } - - @Test("Signed-zero coordinates normalize so cacheKey agrees with Hashable equality") - func signedZeroNormalizes() { - // A latitude that rounds to -0.0 must key identically to +0.0: the two are - // Hashable-equal, so a divergent cacheKey string would split the cache. - let negativeZero = MapSnapshotRequest(latitude: -0.000004, longitude: 50.0, isDark: false, isOffline: false) - let positiveZero = MapSnapshotRequest(latitude: 0.0, longitude: 50.0, isDark: false, isOffline: false) - #expect(negativeZero == positiveZero) - #expect(negativeZero.hashValue == positiveZero.hashValue) - #expect(negativeZero.cacheKey == positiveZero.cacheKey) + /// Fake renderer: returns a 1x1 solid image after an optional gate, counting + /// calls. No MapLibre/GL. + final class FakeRenderer: MapSnapshotRendering { + private(set) var renderCount = 0 + var gate: CheckedContinuation? + var shouldFail = false + + func render(_ request: MapSnapshotRequest) async -> UIImage? { + renderCount += 1 + if gate == nil, awaitGate { + await withCheckedContinuation { gate = $0 } + } + if shouldFail { return nil } + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) + return renderer.image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } } - @Test("Online and offline requests for the same coordinate produce distinct cache keys") - func isOfflineDistinguishesCacheKey() { - let online = MapSnapshotRequest(latitude: 37.0, longitude: -122.0, isDark: false, isOffline: false) - let offline = MapSnapshotRequest(latitude: 37.0, longitude: -122.0, isDark: false, isOffline: true) - #expect(online != offline) - #expect(online.hashValue != offline.hashValue) - #expect(online.cacheKey != offline.cacheKey) + var awaitGate = false + func release() { + gate?.resume(); gate = nil } - - @Test("A completed render caches the image and emits on the stream") - func completedRenderCachesAndEmits() async { - let fake = FakeRenderer() - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - let emitted = await iterator.next() - - #expect(emitted == req) - #expect(store.image(for: req) != nil) - #expect(store.isResolved(req) == true) - #expect(fake.renderCount == 1) + } + + private func request( + _ lat: Double = 37.0, + _ lon: Double = -122.0, + dark: Bool = false, + offline: Bool = false + ) -> MapSnapshotRequest { + MapSnapshotRequest(latitude: lat, longitude: lon, isDark: dark, isOffline: offline) + } + + @Test + func `A cache miss returns nil synchronously; isResolved is false`() { + let store = MapSnapshotStore(renderer: FakeRenderer()) + let req = request() + #expect(store.image(for: req) == nil) + #expect(store.isResolved(req) == false) + } + + @Test + func `Signed-zero coordinates normalize so cacheKey agrees with Hashable equality`() { + // A latitude that rounds to -0.0 must key identically to +0.0: the two are + // Hashable-equal, so a divergent cacheKey string would split the cache. + let negativeZero = MapSnapshotRequest(latitude: -0.000004, longitude: 50.0, isDark: false, isOffline: false) + let positiveZero = MapSnapshotRequest(latitude: 0.0, longitude: 50.0, isDark: false, isOffline: false) + #expect(negativeZero == positiveZero) + #expect(negativeZero.hashValue == positiveZero.hashValue) + #expect(negativeZero.cacheKey == positiveZero.cacheKey) + } + + @Test + func `Online and offline requests for the same coordinate produce distinct cache keys`() { + let online = MapSnapshotRequest(latitude: 37.0, longitude: -122.0, isDark: false, isOffline: false) + let offline = MapSnapshotRequest(latitude: 37.0, longitude: -122.0, isDark: false, isOffline: true) + #expect(online != offline) + #expect(online.hashValue != offline.hashValue) + #expect(online.cacheKey != offline.cacheKey) + } + + @Test + func `A completed render caches the image and emits on the stream`() async { + let fake = FakeRenderer() + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + let emitted = await iterator.next() + + #expect(emitted == req) + #expect(store.image(for: req) != nil) + #expect(store.isResolved(req) == true) + #expect(fake.renderCount == 1) + } + + @Test + func `A failed render marks the request resolved with no image`() async { + let fake = FakeRenderer() + fake.shouldFail = true + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + _ = await iterator.next() + + #expect(store.image(for: req) == nil) + #expect(store.isResolved(req) == true) + } + + @Test + func `N concurrent requests for the same key enqueue exactly one render`() async { + let fake = FakeRenderer() + fake.awaitGate = true + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + store.request(req) + store.request(req) + // Wait until the single render reaches the gate (it bumps renderCount + // before parking on the gate); poll instead of guessing the yield count. + while fake.renderCount == 0 { + await Task.yield() } - - @Test("A failed render marks the request resolved with no image") - func failedRenderResolvesWithoutImage() async { - let fake = FakeRenderer() - fake.shouldFail = true - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - _ = await iterator.next() - - #expect(store.image(for: req) == nil) - #expect(store.isResolved(req) == true) + #expect(fake.renderCount == 1) + + fake.release() + _ = await iterator.next() + #expect(store.isResolved(req) == true) + } + + @Test + func `A cached request is not re-rendered`() async { + let fake = FakeRenderer() + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + _ = await iterator.next() + #expect(fake.renderCount == 1) + + store.request(req) + await Task.yield() + #expect(fake.renderCount == 1) + } + + @Test + func `The failed set is capped; the oldest entry is evicted when the limit is exceeded`() async { + let fake = FakeRenderer() + fake.shouldFail = true + let store = MapSnapshotStore(renderer: fake) + let limit = MapSnapshotStore.failedSetSizeLimit + + var iterator = store.resolutionStream().makeAsyncIterator() + for i in 0..<(limit + 1) { + store.request(request(Double(i), 0)) + _ = await iterator.next() } - @Test("N concurrent requests for the same key enqueue exactly one render") - func dedupesConcurrentSameKey() async { - let fake = FakeRenderer() - fake.awaitGate = true - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - store.request(req) - store.request(req) - // Wait until the single render reaches the gate (it bumps renderCount - // before parking on the gate); poll instead of guessing the yield count. - while fake.renderCount == 0 { await Task.yield() } - #expect(fake.renderCount == 1) - - fake.release() - _ = await iterator.next() - #expect(store.isResolved(req) == true) - } - - @Test("A cached request is not re-rendered") - func cachedRequestNotRerendered() async { - let fake = FakeRenderer() - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - _ = await iterator.next() - #expect(fake.renderCount == 1) - - store.request(req) - await Task.yield() - #expect(fake.renderCount == 1) - } - - @Test("The failed set is capped; the oldest entry is evicted when the limit is exceeded") - func failedSetCapEvictsOldest() async { - let fake = FakeRenderer() - fake.shouldFail = true - let store = MapSnapshotStore(renderer: fake) - let limit = MapSnapshotStore.failedSetSizeLimit - - var iterator = store.resolutionStream().makeAsyncIterator() - for i in 0..<(limit + 1) { - store.request(request(Double(i), 0)) - _ = await iterator.next() - } - - // Oldest entry was evicted; a fresh request would re-render. - #expect(store.isResolved(request(0, 0)) == false) - // Newest entry is still marked failed. - #expect(store.isResolved(request(Double(limit), 0)) == true) - } - - @Test("clear() yields resolutions for previously-resolved requests so on-screen rows reload") - func clearYieldsResolutionsForResolvedRequests() async { - let fake = FakeRenderer() - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - let firstEmit = await iterator.next() - #expect(firstEmit == req) - #expect(store.image(for: req) != nil) - - store.clear() - - let secondEmit = await iterator.next() - #expect(secondEmit == req) - #expect(store.image(for: req) == nil) - #expect(store.isResolved(req) == false) - } - - @Test("clearFailures() drops failed entries so the next request triggers a retry") - func clearFailuresAllowsRetry() async { - let fake = FakeRenderer() - fake.shouldFail = true - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - _ = await iterator.next() - #expect(store.isResolved(req) == true) - - store.clearFailures() - // Drain the invalidation yield emitted by clearFailures() so the next - // iterator.next() sees the retry's resolution, not the stale invalidation. - _ = await iterator.next() - #expect(store.isResolved(req) == false) - - fake.shouldFail = false - store.request(req) - _ = await iterator.next() - #expect(store.image(for: req) != nil) - #expect(fake.renderCount == 2) - } - - @Test("Every subscriber receives each resolution (multicast, not single-consumer split)") - func multicastDeliversToAllSubscribers() async { - let fake = FakeRenderer() - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iteratorA = store.resolutionStream().makeAsyncIterator() - var iteratorB = store.resolutionStream().makeAsyncIterator() - store.request(req) - - // A single shared AsyncStream would hand the yield to only one iterator; - // both must see it. - let emittedA = await iteratorA.next() - let emittedB = await iteratorB.next() - #expect(emittedA == req) - #expect(emittedB == req) - #expect(fake.renderCount == 1) - } + // Oldest entry was evicted; a fresh request would re-render. + #expect(store.isResolved(request(0, 0)) == false) + // Newest entry is still marked failed. + #expect(store.isResolved(request(Double(limit), 0)) == true) + } + + @Test + func `clear() yields resolutions for previously-resolved requests so on-screen rows reload`() async { + let fake = FakeRenderer() + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + let firstEmit = await iterator.next() + #expect(firstEmit == req) + #expect(store.image(for: req) != nil) + + store.clear() + + let secondEmit = await iterator.next() + #expect(secondEmit == req) + #expect(store.image(for: req) == nil) + #expect(store.isResolved(req) == false) + } + + @Test + func `clearFailures() drops failed entries so the next request triggers a retry`() async { + let fake = FakeRenderer() + fake.shouldFail = true + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + _ = await iterator.next() + #expect(store.isResolved(req) == true) + + store.clearFailures() + // Drain the invalidation yield emitted by clearFailures() so the next + // iterator.next() sees the retry's resolution, not the stale invalidation. + _ = await iterator.next() + #expect(store.isResolved(req) == false) + + fake.shouldFail = false + store.request(req) + _ = await iterator.next() + #expect(store.image(for: req) != nil) + #expect(fake.renderCount == 2) + } + + @Test + func `Every subscriber receives each resolution (multicast, not single-consumer split)`() async { + let fake = FakeRenderer() + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iteratorA = store.resolutionStream().makeAsyncIterator() + var iteratorB = store.resolutionStream().makeAsyncIterator() + store.request(req) + + // A single shared AsyncStream would hand the yield to only one iterator; + // both must see it. + let emittedA = await iteratorA.next() + let emittedB = await iteratorB.next() + #expect(emittedA == req) + #expect(emittedB == req) + #expect(fake.renderCount == 1) + } } diff --git a/MC1Tests/Services/RegionResolverTests.swift b/MC1Tests/Services/RegionResolverTests.swift index 34293360..ae1d1666 100644 --- a/MC1Tests/Services/RegionResolverTests.swift +++ b/MC1Tests/Services/RegionResolverTests.swift @@ -1,64 +1,52 @@ import CoreLocation import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("RegionResolver") @MainActor struct RegionResolverTests { + // MARK: - Test doubles - // MARK: - Test doubles - - private final class StubGeocoder: Geocoder, @unchecked Sendable { - var stub: GeocodeResult? - var error: Error? - private(set) var cancelGeocodeCallCount = 0 - - func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? { - if let error { throw error } - return stub - } - - func cancelGeocode() { - cancelGeocodeCallCount += 1 - } - } - - // MARK: - Failure paths - // - // These three tests exercise the `location.isAuthorized` guard — the resolver - // returns nil before the geocoder runs when authorization is undetermined. - // Success-path coverage requires injecting a stubbed `LocationService`, which - // is a follow-up (LocationService is not currently abstracted behind a - // protocol). - - @Test("nil isoCountryCode → nil") - func nilCountryCodeReturnsNil() async { - let location = LocationService() - let geocoder = StubGeocoder() - geocoder.stub = nil - let resolver = RegionResolver(location: location, geocoder: geocoder) - let result = await resolver.resolve() - #expect(result == nil) - } + private final class StubGeocoder: Geocoder, @unchecked Sendable { + var stub: GeocodeResult? + private(set) var cancelGeocodeCallCount = 0 - @Test("Geocoder error → nil") - func geocoderErrorReturnsNil() async { - let location = LocationService() - let geocoder = StubGeocoder() - geocoder.error = NSError(domain: "test", code: -1) - let resolver = RegionResolver(location: location, geocoder: geocoder) - let result = await resolver.resolve() - #expect(result == nil) + func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? { + stub } - @Test("Unauthorized location → nil") - func unauthorizedReturnsNil() async { - let location = LocationService() // .notDetermined by default - let geocoder = StubGeocoder() - let resolver = RegionResolver(location: location, geocoder: geocoder) - let result = await resolver.resolve() - #expect(result == nil) + func cancelGeocode() { + cancelGeocodeCallCount += 1 } + } + + // MARK: - Failure paths + + // + // These tests exercise the `location.isAuthorized` guard — the resolver + // returns nil before the geocoder runs when authorization is undetermined. + // Success-path coverage requires injecting a stubbed `LocationService`, which + // is a follow-up (LocationService is not currently abstracted behind a + // protocol). + + @Test + func `nil isoCountryCode → nil`() async { + let location = LocationService() + let geocoder = StubGeocoder() + geocoder.stub = nil + let resolver = RegionResolver(location: location, geocoder: geocoder) + let result = await resolver.resolve() + #expect(result == nil) + } + + @Test + func `Unauthorized location → nil`() async { + let location = LocationService() // .notDetermined by default + let geocoder = StubGeocoder() + let resolver = RegionResolver(location: location, geocoder: geocoder) + let result = await resolver.resolve() + #expect(result == nil) + } } diff --git a/MC1Tests/SidebarNavigationLayoutTests.swift b/MC1Tests/SidebarNavigationLayoutTests.swift index 083b5ffe..f953db4e 100644 --- a/MC1Tests/SidebarNavigationLayoutTests.swift +++ b/MC1Tests/SidebarNavigationLayoutTests.swift @@ -1,78 +1,77 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing @Suite("SidebarNavigationLayout") struct SidebarNavigationLayoutTests { + /// Point width of an 11-inch iPad in portrait. The narrowest device we promise + /// three-column tiling for (iPad mini at 744pt intentionally collapses, matching Mail). + static let iPad11InchPortraitWidth: CGFloat = 834 - /// Point width of an 11-inch iPad in portrait. The narrowest device we promise - /// three-column tiling for (iPad mini at 744pt intentionally collapses, matching Mail). - static let iPad11InchPortraitWidth: CGFloat = 834 + /// Point width of an iPad mini in portrait. Intentionally below the tiling breakpoint + /// so it collapses to the section's hidden shape rather than tiling three columns. + static let iPadMiniPortraitWidth: CGFloat = 744 - /// Point width of an iPad mini in portrait. Intentionally below the tiling breakpoint - /// so it collapses to the section's hidden shape rather than tiling three columns. - static let iPadMiniPortraitWidth: CGFloat = 744 + /// Upper bound for an icon-only sidebar width; a full text sidebar would exceed this. + static let iconSidebarUpperBound: CGFloat = 115 - /// Upper bound for an icon-only sidebar width; a full text sidebar would exceed this. - static let iconSidebarUpperBound: CGFloat = 115 + @Test + func `Section sidebar is narrow enough that 11-inch portrait tiles all three columns`() { + #expect(MainSidebarView.sidebarTileableMinWidth <= Self.iPad11InchPortraitWidth) + } - @Test("Section sidebar is narrow enough that 11-inch portrait tiles all three columns") - func sidebarTilesInPortrait() { - #expect(MainSidebarView.sidebarTileableMinWidth <= Self.iPad11InchPortraitWidth) - } + @Test + func `iPad mini portrait intentionally collapses rather than tiling`() { + #expect(MainSidebarView.sidebarTileableMinWidth > Self.iPadMiniPortraitWidth) + } - @Test("iPad mini portrait intentionally collapses rather than tiling") - func iPadMiniPortraitCollapses() { - #expect(MainSidebarView.sidebarTileableMinWidth > Self.iPadMiniPortraitWidth) - } + @Test + func `Sidebar width is in icon-only range, not a full sidebar`() { + // Guards the configured constant. That iPadOS actually renders the sidebar this narrow and + // tiles three columns is validated by manual layout testing on device, not asserted here. + #expect(MainSidebarView.sidebarColumnWidth <= Self.iconSidebarUpperBound) + } - @Test("Sidebar width is in icon-only range, not a full sidebar") - func sidebarWidthIsCompact() { - // Guards the configured constant. That iPadOS actually renders the sidebar this narrow and - // tiles three columns is validated by manual layout testing on device, not asserted here. - #expect(MainSidebarView.sidebarColumnWidth <= Self.iconSidebarUpperBound) - } + @Test + func `A sidebar-collapsing tool collapses the sidebar even when the container is wide`() { + let visibility = MainSidebarView.sidebarVisibility( + isWide: true, + toolCollapsesSidebar: true, + sectionCollapsed: .doubleColumn + ) + #expect(visibility == .doubleColumn) + } - @Test("A sidebar-collapsing tool collapses the sidebar even when the container is wide") - func sidebarCollapsingToolCollapsesSidebarWhenWide() { - let visibility = MainSidebarView.sidebarVisibility( - isWide: true, - toolCollapsesSidebar: true, - sectionCollapsed: .doubleColumn - ) - #expect(visibility == .doubleColumn) - } + @Test + func `A wide container tiles the sidebar when no sidebar-collapsing tool is open`() { + let visibility = MainSidebarView.sidebarVisibility( + isWide: true, + toolCollapsesSidebar: false, + sectionCollapsed: .doubleColumn + ) + #expect(visibility == .all) + } - @Test("A wide container tiles the sidebar when no sidebar-collapsing tool is open") - func wideContainerTilesSidebar() { - let visibility = MainSidebarView.sidebarVisibility( - isWide: true, - toolCollapsesSidebar: false, - sectionCollapsed: .doubleColumn - ) - #expect(visibility == .all) - } - - @Test("A narrow container collapses to the section's hidden shape") - func narrowContainerCollapsesToSectionShape() { - #expect( - MainSidebarView.sidebarVisibility( - isWide: false, toolCollapsesSidebar: false, sectionCollapsed: .doubleColumn - ) == .doubleColumn - ) - #expect( - MainSidebarView.sidebarVisibility( - isWide: false, toolCollapsesSidebar: false, sectionCollapsed: .detailOnly - ) == .detailOnly - ) - } + @Test + func `A narrow container collapses to the section's hidden shape`() { + #expect( + MainSidebarView.sidebarVisibility( + isWide: false, toolCollapsesSidebar: false, sectionCollapsed: .doubleColumn + ) == .doubleColumn + ) + #expect( + MainSidebarView.sidebarVisibility( + isWide: false, toolCollapsesSidebar: false, sectionCollapsed: .detailOnly + ) == .detailOnly + ) + } - @Test("Line of Sight and Trace Path collapse the sidebar; other tools keep it") - func onlyWideToolsCollapseSidebar() { - #expect(ToolSelection.lineOfSight.prefersCollapsedSidebar) - #expect(ToolSelection.tracePath.prefersCollapsedSidebar) - for tool in ToolSelection.allCases where tool != .lineOfSight && tool != .tracePath { - #expect(!tool.prefersCollapsedSidebar) - } + @Test + func `Line of Sight and Trace Path collapse the sidebar; other tools keep it`() { + #expect(ToolSelection.lineOfSight.prefersCollapsedSidebar) + #expect(ToolSelection.tracePath.prefersCollapsedSidebar) + for tool in ToolSelection.allCases where tool != .lineOfSight && tool != .tracePath { + #expect(!tool.prefersCollapsedSidebar) } + } } diff --git a/MC1Tests/State/LiveActivityManagerTests.swift b/MC1Tests/State/LiveActivityManagerTests.swift index 1b07a067..5cd3c71b 100644 --- a/MC1Tests/State/LiveActivityManagerTests.swift +++ b/MC1Tests/State/LiveActivityManagerTests.swift @@ -1,44 +1,43 @@ -import Testing import Foundation @testable import MC1 +import Testing @Suite("LiveActivityManager packet rate") @MainActor struct LiveActivityManagerTests { + private let now = Date(timeIntervalSinceReferenceDate: 1_000_000) - private let now = Date(timeIntervalSinceReferenceDate: 1_000_000) - - @Test("no packets reads as zero") - func emptyIsZero() { - #expect(LiveActivityManager.packetsPerMinute(timestamps: [], now: now) == 0) - } + @Test + func `no packets reads as zero`() { + #expect(LiveActivityManager.packetsPerMinute(timestamps: [], now: now) == 0) + } - @Test("default 60s window returns the exact count, matching the RX Log") - func sixtySecondWindowIsTrueCount() { - let timestamps = (1...7).map { now.addingTimeInterval(-Double($0) * 5) } - #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now) == 7) - } + @Test + func `default 60s window returns the exact count, matching the RX Log`() { + let timestamps = (1...7).map { now.addingTimeInterval(-Double($0) * 5) } + #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now) == 7) + } - @Test("packets older than the window are excluded") - func staleTimestampsExcluded() { - let timestamps = [ - now.addingTimeInterval(-10), - now.addingTimeInterval(-59), - now.addingTimeInterval(-61), - now.addingTimeInterval(-120) - ] - #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now) == 2) - } + @Test + func `packets older than the window are excluded`() { + let timestamps = [ + now.addingTimeInterval(-10), + now.addingTimeInterval(-59), + now.addingTimeInterval(-61), + now.addingTimeInterval(-120) + ] + #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now) == 2) + } - @Test("a timestamp exactly at the cutoff is included") - func boundaryIsInclusive() { - let atCutoff = now.addingTimeInterval(-LiveActivityManager.packetWindowSeconds) - #expect(LiveActivityManager.packetsPerMinute(timestamps: [atCutoff], now: now) == 1) - } + @Test + func `a timestamp exactly at the cutoff is included`() { + let atCutoff = now.addingTimeInterval(-LiveActivityManager.packetWindowSeconds) + #expect(LiveActivityManager.packetsPerMinute(timestamps: [atCutoff], now: now) == 1) + } - @Test("a shorter window still projects to a per-minute rate") - func shorterWindowProjects() { - let timestamps = (1...3).map { now.addingTimeInterval(-Double($0)) } - #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now, window: 15) == 12) - } + @Test + func `a shorter window still projects to a per-minute rate`() { + let timestamps = (1...3).map { now.addingTimeInterval(-Double($0)) } + #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now, window: 15) == 12) + } } diff --git a/MC1Tests/State/MessageEventStreamTests.swift b/MC1Tests/State/MessageEventStreamTests.swift index d6c89341..83131b73 100644 --- a/MC1Tests/State/MessageEventStreamTests.swift +++ b/MC1Tests/State/MessageEventStreamTests.swift @@ -1,109 +1,113 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing @Suite("MessageEventStream") @MainActor struct MessageEventStreamTests { + @Test + func `Single consumer receives an event sent via send(_:)`() async { + let stream = MessageEventStream() + let event = MessageEvent.messageStatusResolved(messageID: UUID(), status: .sent) + + let received = Task { @MainActor () -> MessageEvent? in + var iterator = stream.events().makeAsyncIterator() + return await iterator.next() + } - @Test("Single consumer receives an event sent via send(_:)") - func singleConsumerReceivesEvent() async { - let stream = MessageEventStream() - let event = MessageEvent.messageStatusResolved(messageID: UUID(), status: .sent) + await Task.yield() + stream.send(event) - let received = Task { @MainActor () -> MessageEvent? in - var iterator = stream.events().makeAsyncIterator() - return await iterator.next() - } + #expect(await received.value == event) + } - await Task.yield() - stream.send(event) + @Test + func `Multiple consumers each receive the same event`() async { + let stream = MessageEventStream() + let event = MessageEvent.heardRepeatRecorded(messageID: UUID(), count: 3) - #expect(await received.value == event) + let firstReceived = Task { @MainActor () -> MessageEvent? in + var iterator = stream.events().makeAsyncIterator() + return await iterator.next() } + let secondReceived = Task { @MainActor () -> MessageEvent? in + var iterator = stream.events().makeAsyncIterator() + return await iterator.next() + } + + await Task.yield() + await Task.yield() + #expect(stream.subscriberCount() == 2) - @Test("Multiple consumers each receive the same event") - func multipleConsumersBothReceive() async { - let stream = MessageEventStream() - let event = MessageEvent.heardRepeatRecorded(messageID: UUID(), count: 3) + stream.send(event) - let firstReceived = Task { @MainActor () -> MessageEvent? in - var iterator = stream.events().makeAsyncIterator() - return await iterator.next() - } - let secondReceived = Task { @MainActor () -> MessageEvent? in - var iterator = stream.events().makeAsyncIterator() - return await iterator.next() - } + #expect(await firstReceived.value == event) + #expect(await secondReceived.value == event) + } - await Task.yield() - await Task.yield() - #expect(stream.subscriberCount() == 2) + @Test + func `Events sent across consumer task restarts are not dropped when the consumer is held by a long-lived task`() async { + let stream = MessageEventStream() + let event1 = MessageEvent.messageStatusResolved(messageID: UUID(), status: .sent) + let event2 = MessageEvent.messageStatusResolved(messageID: UUID(), status: .delivered) - stream.send(event) + actor Collector { + var items: [MessageEvent] = [] + func append(_ value: MessageEvent) { + items.append(value) + } - #expect(await firstReceived.value == event) - #expect(await secondReceived.value == event) + func snapshot() -> [MessageEvent] { + items + } } + let received = Collector() + + let consumer = Task { @MainActor in + for await event in stream.events() { + await received.append(event) + if await received.snapshot().count == 2 { return } + } + } + + await Task.yield() + stream.send(event1) + stream.send(event2) + + _ = await consumer.value + let collected = await received.snapshot() + #expect(collected.count == 2) + #expect(collected[0] == event1) + #expect(collected[1] == event2) + } + + @Test + func `subscriberCount reflects active subscriptions`() async { + let stream = MessageEventStream() + #expect(stream.subscriberCount() == 0) - @Test("Events sent across consumer task restarts are not dropped when the consumer is held by a long-lived task") - func longLivedConsumer_DoesNotDropEventsAcrossSimulatedReconnect() async { - let stream = MessageEventStream() - let event1 = MessageEvent.messageStatusResolved(messageID: UUID(), status: .sent) - let event2 = MessageEvent.messageStatusResolved(messageID: UUID(), status: .delivered) - - actor Collector { - var items: [MessageEvent] = [] - func append(_ value: MessageEvent) { items.append(value) } - func snapshot() -> [MessageEvent] { items } - } - let received = Collector() - - let consumer = Task { @MainActor in - for await event in stream.events() { - await received.append(event) - if await received.snapshot().count == 2 { return } - } - } - - await Task.yield() - stream.send(event1) - stream.send(event2) - - _ = await consumer.value - let collected = await received.snapshot() - #expect(collected.count == 2) - #expect(collected[0] == event1) - #expect(collected[1] == event2) + let firstTask = Task { @MainActor in + for await _ in stream.events() { /* hold subscription */ } } + await Task.yield() + #expect(stream.subscriberCount() == 1) - @Test("subscriberCount reflects active subscriptions") - func subscriberCountTracksRegistration() async { - let stream = MessageEventStream() - #expect(stream.subscriberCount() == 0) - - let firstTask = Task { @MainActor in - for await _ in stream.events() { /* hold subscription */ } - } - await Task.yield() - #expect(stream.subscriberCount() == 1) - - let secondTask = Task { @MainActor in - for await _ in stream.events() { /* hold subscription */ } - } - await Task.yield() - #expect(stream.subscriberCount() == 2) - - firstTask.cancel() - secondTask.cancel() - _ = await firstTask.value - _ = await secondTask.value - - // The onTermination hops to main via a Task; pump a send through - // to force the opportunistic-prune branch to run. After the pump, - // both terminated slots should be removed. - stream.send(.messageFailed(messageID: UUID())) - #expect(stream.subscriberCount() == 0) + let secondTask = Task { @MainActor in + for await _ in stream.events() { /* hold subscription */ } } + await Task.yield() + #expect(stream.subscriberCount() == 2) + + firstTask.cancel() + secondTask.cancel() + _ = await firstTask.value + _ = await secondTask.value + + // The onTermination hops to main via a Task; pump a send through + // to force the opportunistic-prune branch to run. After the pump, + // both terminated slots should be removed. + stream.send(.messageFailed(messageID: UUID())) + #expect(stream.subscriberCount() == 0) + } } diff --git a/MC1Tests/State/PendingExternalURLTests.swift b/MC1Tests/State/PendingExternalURLTests.swift new file mode 100644 index 00000000..c62be072 --- /dev/null +++ b/MC1Tests/State/PendingExternalURLTests.swift @@ -0,0 +1,63 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("PendingExternalURL Tests") +@MainActor +struct PendingExternalURLTests { + private func makeAppState() -> AppState { + AppState() + } + + @Test + func `A URL submitted before ready is held, not routed, until markReady`() throws { + let appState = makeAppState() + let holder = PendingExternalURL() + let url = try #require(URL(string: "meshcore://map?lat=37.7749&lon=-122.4194")) + + holder.submit(url, appState: appState) + + // The guard is the whole point of the holder: a cold-launch URL delivered + // before initialization settles must not route into a not-ready AppState. + #expect(holder.isReady == false) + #expect(holder.url != nil) + #expect(appState.navigation.pendingMapFocus == nil) + + holder.markReady(appState) + + #expect(holder.isReady) + #expect(holder.url == nil) + #expect(appState.navigation.pendingMapFocus != nil) + } + + @Test + func `markReady routes a held URL exactly once`() throws { + let appState = makeAppState() + let holder = PendingExternalURL() + let url = try #require(URL(string: "meshcore://map?lat=37.7749&lon=-122.4194")) + + holder.submit(url, appState: appState) + holder.markReady(appState) + + #expect(appState.navigation.pendingMapFocus != nil) + #expect(holder.url == nil) + + // A second markReady cannot re-route: the held URL was consumed. + holder.markReady(appState) + #expect(holder.url == nil) + } + + @Test + func `A URL submitted after ready routes immediately`() throws { + let appState = makeAppState() + let holder = PendingExternalURL() + let url = try #require(URL(string: "meshcore://map?lat=37.7749&lon=-122.4194")) + holder.markReady(appState) + + holder.submit(url, appState: appState) + + #expect(appState.navigation.pendingMapFocus != nil) + #expect(holder.url == nil) + } +} diff --git a/MC1Tests/State/SendQueueTests.swift b/MC1Tests/State/SendQueueTests.swift index be635f7e..c91dfc79 100644 --- a/MC1Tests/State/SendQueueTests.swift +++ b/MC1Tests/State/SendQueueTests.swift @@ -1,391 +1,442 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing @Suite("SendQueue") struct SendQueueTests { - - /// A trivial envelope used in tests where the payload is just an - /// identifier the assertions can compare against. - private struct Marker: Sendable, Equatable { - let id: Int + /// A trivial envelope used in tests where the payload is just an + /// identifier the assertions can compare against. + private struct Marker: Equatable { + let id: Int + } + + @Test + func `Serial draining: enqueueing N envelopes triggers N sends in FIFO order`() async { + actor Collector { + var observed: [Int] = [] + func record(_ value: Int) { + observed.append(value) + } + + func snapshot() -> [Int] { + observed + } } + let collector = Collector() + let drainSignal = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + await collector.record(envelope.id) + }, + onError: { _, _ in }, + onDrain: { _ in + drainSignal.continuation.yield(()) + drainSignal.continuation.finish() + } + ) - @Test("Serial draining: enqueueing N envelopes triggers N sends in FIFO order") - func serialDrainProcessesEnvelopesInOrder() async { - actor Collector { - var observed: [Int] = [] - func record(_ value: Int) { observed.append(value) } - func snapshot() -> [Int] { observed } - } - let collector = Collector() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - await collector.record(envelope.id) - }, - onError: { _, _ in }, - onDrain: { _ in - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - for id in 1...5 { - await queue.enqueue(Marker(id: id)) - } - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let observed = await collector.snapshot() - let count = await queue.count - #expect(observed == [1, 2, 3, 4, 5]) - #expect(count == 0) + for id in 1...5 { + await queue.enqueue(Marker(id: id)) } - @Test("CancellationError requeues at the front and the drain auto-respawns") - func cancellationRequeuesAndAutoRespawns() async { - // The drain's `catch is CancellationError` branch re-inserts the - // in-flight envelope at index 0, then `taskCompleted` respawns - // because pending is non-empty. The retried send succeeds (the - // latch flips after the first throw), and the envelope drains - // without an external nudge. - actor Tracker { - var sendCalls = 0 - var succeededIDs: [Int] = [] - func recordSend() -> Bool { - sendCalls += 1 - return sendCalls == 1 - } - func recordSuccess(_ id: Int) { succeededIDs.append(id) } - func snapshot() -> (Int, [Int]) { (sendCalls, succeededIDs) } - } - let tracker = Tracker() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - let throwThisTime = await tracker.recordSend() - if throwThisTime { throw CancellationError() } - await tracker.recordSuccess(envelope.id) - }, - onError: { _, _ in }, - onDrain: { _ in - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - await queue.enqueue(Marker(id: 42)) - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let (sendCalls, succeeded) = await tracker.snapshot() - #expect(sendCalls == 2, "Envelope is attempted twice: once cancelled, once successful") - #expect(succeeded == [42], "The auto-respawn retry delivers the envelope") - - let finalCount = await queue.count - #expect(finalCount == 0) + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let observed = await collector.snapshot() + let count = await queue.count + #expect(observed == [1, 2, 3, 4, 5]) + #expect(count == 0) + } + + @Test + func `CancellationError requeues at the front and the drain auto-respawns`() async { + // The drain's `catch is CancellationError` branch re-inserts the + // in-flight envelope at index 0, then `taskCompleted` respawns + // because pending is non-empty. The retried send succeeds (the + // latch flips after the first throw), and the envelope drains + // without an external nudge. + actor Tracker { + var sendCalls = 0 + var succeededIDs: [Int] = [] + func recordSend() -> Bool { + sendCalls += 1 + return sendCalls == 1 + } + + func recordSuccess(_ id: Int) { + succeededIDs.append(id) + } + + func snapshot() -> (Int, [Int]) { + (sendCalls, succeededIDs) + } } - - @Test("Non-cancellation error fires onError and the drain continues") - func nonCancellationErrorFiresOnErrorAndContinues() async { - struct Boom: Error {} - actor Collector { - var sent: [Int] = [] - var errors: [Int] = [] - func recordSend(_ id: Int) { sent.append(id) } - func recordError(_ id: Int) { errors.append(id) } - func sentSnapshot() -> [Int] { sent } - func errorSnapshot() -> [Int] { errors } - } - let collector = Collector() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - if envelope.id == 2 { - throw Boom() - } - await collector.recordSend(envelope.id) - }, - onError: { _, envelope in - await collector.recordError(envelope.id) - }, - onDrain: { _ in - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - for id in 1...3 { - await queue.enqueue(Marker(id: id)) - } - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let sent = await collector.sentSnapshot() - let errored = await collector.errorSnapshot() - #expect(sent == [1, 3], "Drain must continue past the failing envelope") - #expect(errored == [2]) + let tracker = Tracker() + let drainSignal = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + let throwThisTime = await tracker.recordSend() + if throwThisTime { throw CancellationError() } + await tracker.recordSuccess(envelope.id) + }, + onError: { _, _ in }, + onDrain: { _ in + drainSignal.continuation.yield(()) + drainSignal.continuation.finish() + } + ) + + await queue.enqueue(Marker(id: 42)) + + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let (sendCalls, succeeded) = await tracker.snapshot() + #expect(sendCalls == 2, "Envelope is attempted twice: once cancelled, once successful") + #expect(succeeded == [42], "The auto-respawn retry delivers the envelope") + + let finalCount = await queue.count + #expect(finalCount == 0) + } + + @Test + func `Non-cancellation error fires onError and the drain continues`() async { + struct Boom: Error {} + actor Collector { + var sent: [Int] = [] + var errors: [Int] = [] + func recordSend(_ id: Int) { + sent.append(id) + } + + func recordError(_ id: Int) { + errors.append(id) + } + + func sentSnapshot() -> [Int] { + sent + } + + func errorSnapshot() -> [Int] { + errors + } } + let collector = Collector() + let drainSignal = AsyncStream.makeStream(of: Void.self) - @Test("onDrain fires exactly once per drain pass") - func onDrainFiresOncePerPass() async { - // Block the first send call on a barrier so the test can enqueue - // envelopes 2 and 3 while drain is parked. Without the barrier, - // a synchronous send closure lets T1 finish draining envelope 1 - // (and fire onDrain) before the test thread submits enqueues 2 - // and 3 to the actor — exact ordering is up to the scheduler. - actor State { - var firstSendSeen = false - var sent: [Int] = [] - func markFirstSend() -> Bool { - let isFirst = !firstSendSeen - firstSendSeen = true - return isFirst - } - func recordSent(_ id: Int) { sent.append(id) } - func snapshot() -> [Int] { sent } + let queue = SendQueue( + send: { envelope in + if envelope.id == 2 { + throw Boom() } - actor DrainCounter { - var count = 0 - func bump() { count += 1 } - func snapshot() -> Int { count } - } - let state = State() - let counter = DrainCounter() - let drainSignal = AsyncStream.makeStream(of: Void.self) - let firstSendReached = AsyncStream.makeStream(of: Void.self) - let releaseFirstSend = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - if await state.markFirstSend() { - firstSendReached.continuation.yield(()) - firstSendReached.continuation.finish() - var releaseIter = releaseFirstSend.stream.makeAsyncIterator() - _ = await releaseIter.next() - } - await state.recordSent(envelope.id) - }, - onError: { _, _ in }, - onDrain: { _ in - await counter.bump() - drainSignal.continuation.yield(()) - } - ) - - await queue.enqueue(Marker(id: 1)) - - // Park here until drain has popped envelope 1 and entered send. - var firstIter = firstSendReached.stream.makeAsyncIterator() - _ = await firstIter.next() - - await queue.enqueue(Marker(id: 2)) - await queue.enqueue(Marker(id: 3)) - - releaseFirstSend.continuation.yield(()) - releaseFirstSend.continuation.finish() - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - await queue.awaitDrainCompletion() + await collector.recordSend(envelope.id) + }, + onError: { _, envelope in + await collector.recordError(envelope.id) + }, + onDrain: { _ in + drainSignal.continuation.yield(()) drainSignal.continuation.finish() + } + ) - let sent = await state.snapshot() - let observed = await counter.snapshot() - #expect(sent == [1, 2, 3]) - #expect(observed == 1, "All three envelopes drain in one pass; onDrain fires once") + for id in 1...3 { + await queue.enqueue(Marker(id: id)) } - @Test("onDrain receives the most recent non-cancellation error from the drain pass") - func onDrainReceivesLastError() async { - struct Boom: Error, Equatable { - let id: Int - } - actor ErrorBox { - var value: Error? - func record(_ error: Error?) { value = error } - func snapshot() -> Error? { value } - } - let box = ErrorBox() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - throw Boom(id: envelope.id) - }, - onError: { _, _ in }, - onDrain: { lastError in - await box.record(lastError) - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - await queue.enqueue(Marker(id: 1)) - await queue.enqueue(Marker(id: 2)) - await queue.enqueue(Marker(id: 3)) - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let recorded = await box.snapshot() - // Last-error-wins: the third envelope's Boom should reach onDrain. - #expect((recorded as? Boom) == Boom(id: 3)) + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let sent = await collector.sentSnapshot() + let errored = await collector.errorSnapshot() + #expect(sent == [1, 3], "Drain must continue past the failing envelope") + #expect(errored == [2]) + } + + @Test + func `onDrain fires exactly once per drain pass`() async { + // Block the first send call on a barrier so the test can enqueue + // envelopes 2 and 3 while drain is parked. Without the barrier, + // a synchronous send closure lets T1 finish draining envelope 1 + // (and fire onDrain) before the test thread submits enqueues 2 + // and 3 to the actor — exact ordering is up to the scheduler. + actor State { + var firstSendSeen = false + var sent: [Int] = [] + func markFirstSend() -> Bool { + let isFirst = !firstSendSeen + firstSendSeen = true + return isFirst + } + + func recordSent(_ id: Int) { + sent.append(id) + } + + func snapshot() -> [Int] { + sent + } } - - @Test("onDrain receives nil when no envelope failed during the drain pass") - func onDrainReceivesNilOnSuccessfulPass() async { - actor ErrorBox { - var value: Error? - var didFire = false - func record(_ error: Error?) { value = error; didFire = true } - func snapshot() -> (Error?, Bool) { (value, didFire) } - } - let box = ErrorBox() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { _ in }, - onError: { _, _ in }, - onDrain: { lastError in - await box.record(lastError) - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - await queue.enqueue(Marker(id: 1)) - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let (recorded, fired) = await box.snapshot() - #expect(fired) - #expect(recorded == nil) + actor DrainCounter { + var count = 0 + func bump() { + count += 1 + } + + func snapshot() -> Int { + count + } } - - @Test("drain's outer loop processes envelopes enqueued during onDrain") - func outerLoopProcessesEnqueueDuringOnDrain() async { - // The outer `while !pending.isEmpty` re-check after onDrain is what - // catches an enqueue that lands during the onDrain await. Park - // the first onDrain on a barrier, enqueue a follow-up envelope - // from outside, release. The same drain task should pop the new - // envelope and fire onDrain a second time. - actor State { - var sent: [Int] = [] - var onDrainCalls = 0 - func recordSend(_ id: Int) { sent.append(id) } - func bump() -> Int { - onDrainCalls += 1 - return onDrainCalls - } - func snapshot() -> (sent: [Int], onDrainCalls: Int) { - (sent, onDrainCalls) - } + let state = State() + let counter = DrainCounter() + let drainSignal = AsyncStream.makeStream(of: Void.self) + let firstSendReached = AsyncStream.makeStream(of: Void.self) + let releaseFirstSend = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + if await state.markFirstSend() { + firstSendReached.continuation.yield(()) + firstSendReached.continuation.finish() + var releaseIter = releaseFirstSend.stream.makeAsyncIterator() + _ = await releaseIter.next() } - let state = State() - let firstOnDrainReached = AsyncStream.makeStream(of: Void.self) - let releaseFirstOnDrain = AsyncStream.makeStream(of: Void.self) - let secondOnDrainFired = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - await state.recordSend(envelope.id) - }, - onError: { _, _ in }, - onDrain: { _ in - let callNumber = await state.bump() - if callNumber == 1 { - firstOnDrainReached.continuation.yield(()) - firstOnDrainReached.continuation.finish() - var releaseIter = releaseFirstOnDrain.stream.makeAsyncIterator() - _ = await releaseIter.next() - } else { - secondOnDrainFired.continuation.yield(()) - secondOnDrainFired.continuation.finish() - } - } - ) - - await queue.enqueue(Marker(id: 1)) - - // Park here until the first onDrain has fired and is blocked. - var firstIter = firstOnDrainReached.stream.makeAsyncIterator() - _ = await firstIter.next() - - // Enqueue 2 while onDrain is parked. The outer loop must pick it up. - await queue.enqueue(Marker(id: 2)) - - releaseFirstOnDrain.continuation.yield(()) - releaseFirstOnDrain.continuation.finish() - - var secondIter = secondOnDrainFired.stream.makeAsyncIterator() - _ = await secondIter.next() - await queue.awaitDrainCompletion() - - let snapshot = await state.snapshot() - #expect(snapshot.sent == [1, 2]) - #expect(snapshot.onDrainCalls == 2, "Outer loop produces a second onDrain after the re-enqueued envelope drains") + await state.recordSent(envelope.id) + }, + onError: { _, _ in }, + onDrain: { _ in + await counter.bump() + drainSignal.continuation.yield(()) + } + ) + + await queue.enqueue(Marker(id: 1)) + + // Park here until drain has popped envelope 1 and entered send. + var firstIter = firstSendReached.stream.makeAsyncIterator() + _ = await firstIter.next() + + await queue.enqueue(Marker(id: 2)) + await queue.enqueue(Marker(id: 3)) + + releaseFirstSend.continuation.yield(()) + releaseFirstSend.continuation.finish() + + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + await queue.awaitDrainCompletion() + drainSignal.continuation.finish() + + let sent = await state.snapshot() + let observed = await counter.snapshot() + #expect(sent == [1, 2, 3]) + #expect(observed == 1, "All three envelopes drain in one pass; onDrain fires once") + } + + @Test + func `onDrain receives the most recent non-cancellation error from the drain pass`() async { + struct Boom: Error, Equatable { + let id: Int } - - @Test("Drain task captures actor strongly: drain completes after only external ref drops") - func drainCompletesAfterExternalRefDrops() async { - // Drop the local strong ref to the queue while the drain's send - // closure is parked. Because the drain Task captures the actor - // strongly, the actor stays alive until drain completes; the - // send closure resumes after release and observably runs to - // completion. - actor State { - var sendCompleted = false - func markCompleted() { sendCompleted = true } - func snapshot() -> Bool { sendCompleted } - } - let state = State() - let sendReached = AsyncStream.makeStream(of: Void.self) - let releaseSend = AsyncStream.makeStream(of: Void.self) - - do { - let queue = SendQueue( - send: { _ in - sendReached.continuation.yield(()) - sendReached.continuation.finish() - var releaseIter = releaseSend.stream.makeAsyncIterator() - _ = await releaseIter.next() - await state.markCompleted() - }, - onError: { _, _ in }, - onDrain: { _ in } - ) - await queue.enqueue(Marker(id: 1)) - - var iter = sendReached.stream.makeAsyncIterator() - _ = await iter.next() - // Local `queue` strong ref drops at the end of this `do` block. - } - - releaseSend.continuation.yield(()) - releaseSend.continuation.finish() - - // Poll for send completion. The drain Task is the sole remaining - // strong ref to the actor; if strong-self capture works, send - // resumes and runs to completion. - var completed = false - for _ in 0..<200 { - if await state.snapshot() { - completed = true - break - } - try? await Task.sleep(nanoseconds: 5_000_000) + actor ErrorBox { + var value: Error? + func record(_ error: Error?) { + value = error + } + + func snapshot() -> Error? { + value + } + } + let box = ErrorBox() + let drainSignal = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + throw Boom(id: envelope.id) + }, + onError: { _, _ in }, + onDrain: { lastError in + await box.record(lastError) + drainSignal.continuation.yield(()) + drainSignal.continuation.finish() + } + ) + + await queue.enqueue(Marker(id: 1)) + await queue.enqueue(Marker(id: 2)) + await queue.enqueue(Marker(id: 3)) + + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let recorded = await box.snapshot() + // Last-error-wins: the third envelope's Boom should reach onDrain. + #expect((recorded as? Boom) == Boom(id: 3)) + } + + @Test + func `onDrain receives nil when no envelope failed during the drain pass`() async { + actor ErrorBox { + var value: Error? + var didFire = false + func record(_ error: Error?) { + value = error; didFire = true + } + + func snapshot() -> (Error?, Bool) { + (value, didFire) + } + } + let box = ErrorBox() + let drainSignal = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { _ in }, + onError: { _, _ in }, + onDrain: { lastError in + await box.record(lastError) + drainSignal.continuation.yield(()) + drainSignal.continuation.finish() + } + ) + + await queue.enqueue(Marker(id: 1)) + + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let (recorded, fired) = await box.snapshot() + #expect(fired) + #expect(recorded == nil) + } + + @Test + func `drain's outer loop processes envelopes enqueued during onDrain`() async { + // The outer `while !pending.isEmpty` re-check after onDrain is what + // catches an enqueue that lands during the onDrain await. Park + // the first onDrain on a barrier, enqueue a follow-up envelope + // from outside, release. The same drain task should pop the new + // envelope and fire onDrain a second time. + actor State { + var sent: [Int] = [] + var onDrainCalls = 0 + func recordSend(_ id: Int) { + sent.append(id) + } + + func bump() -> Int { + onDrainCalls += 1 + return onDrainCalls + } + + func snapshot() -> (sent: [Int], onDrainCalls: Int) { + (sent, onDrainCalls) + } + } + let state = State() + let firstOnDrainReached = AsyncStream.makeStream(of: Void.self) + let releaseFirstOnDrain = AsyncStream.makeStream(of: Void.self) + let secondOnDrainFired = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + await state.recordSend(envelope.id) + }, + onError: { _, _ in }, + onDrain: { _ in + let callNumber = await state.bump() + if callNumber == 1 { + firstOnDrainReached.continuation.yield(()) + firstOnDrainReached.continuation.finish() + var releaseIter = releaseFirstOnDrain.stream.makeAsyncIterator() + _ = await releaseIter.next() + } else { + secondOnDrainFired.continuation.yield(()) + secondOnDrainFired.continuation.finish() } + } + ) + + await queue.enqueue(Marker(id: 1)) + + // Park here until the first onDrain has fired and is blocked. + var firstIter = firstOnDrainReached.stream.makeAsyncIterator() + _ = await firstIter.next() + + // Enqueue 2 while onDrain is parked. The outer loop must pick it up. + await queue.enqueue(Marker(id: 2)) + + releaseFirstOnDrain.continuation.yield(()) + releaseFirstOnDrain.continuation.finish() + + var secondIter = secondOnDrainFired.stream.makeAsyncIterator() + _ = await secondIter.next() + await queue.awaitDrainCompletion() + + let snapshot = await state.snapshot() + #expect(snapshot.sent == [1, 2]) + #expect(snapshot.onDrainCalls == 2, "Outer loop produces a second onDrain after the re-enqueued envelope drains") + } + + @Test + func `Drain task captures actor strongly: drain completes after only external ref drops`() async { + // Drop the local strong ref to the queue while the drain's send + // closure is parked. Because the drain Task captures the actor + // strongly, the actor stays alive until drain completes; the + // send closure resumes after release and observably runs to + // completion. + actor State { + var sendCompleted = false + func markCompleted() { + sendCompleted = true + } + + func snapshot() -> Bool { + sendCompleted + } + } + let state = State() + let sendReached = AsyncStream.makeStream(of: Void.self) + let releaseSend = AsyncStream.makeStream(of: Void.self) + + do { + let queue = SendQueue( + send: { _ in + sendReached.continuation.yield(()) + sendReached.continuation.finish() + var releaseIter = releaseSend.stream.makeAsyncIterator() + _ = await releaseIter.next() + await state.markCompleted() + }, + onError: { _, _ in }, + onDrain: { _ in } + ) + await queue.enqueue(Marker(id: 1)) + + var iter = sendReached.stream.makeAsyncIterator() + _ = await iter.next() + // Local `queue` strong ref drops at the end of this `do` block. + } - #expect(completed, "Send must complete even after the only external strong reference was released") + releaseSend.continuation.yield(()) + releaseSend.continuation.finish() + + // Poll for send completion. The drain Task is the sole remaining + // strong ref to the actor; if strong-self capture works, send + // resumes and runs to completion. + var completed = false + for _ in 0..<200 { + if await state.snapshot() { + completed = true + break + } + try? await Task.sleep(nanoseconds: 5_000_000) } + + #expect(completed, "Send must complete even after the only external strong reference was released") + } } diff --git a/MC1Tests/State/WhatsNewStateTests.swift b/MC1Tests/State/WhatsNewStateTests.swift index 9aaae66d..c073b584 100644 --- a/MC1Tests/State/WhatsNewStateTests.swift +++ b/MC1Tests/State/WhatsNewStateTests.swift @@ -1,180 +1,179 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("WhatsNewState") @MainActor final class WhatsNewStateTests { - - private let baselineKey = AppStorageKey.lastShownWhatsNewVersion.rawValue - private let suiteName = "test.\(UUID().uuidString)" - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: suiteName)! - } - - deinit { - UserDefaults().removePersistentDomain(forName: suiteName) - } - - private func version(_ string: String) -> WhatsNewVersion { - WhatsNewVersion(marketingVersion: string)! - } - - private func release(_ major: Int, _ minor: Int, items: Int = 1) -> WhatsNewRelease { - WhatsNewRelease( - version: WhatsNewVersion(major: major, minor: minor), - items: (0.. WhatsNewVersion { + WhatsNewVersion(marketingVersion: string)! + } + + private func release(_ major: Int, _ minor: Int, items: Int = 1) -> WhatsNewRelease { + WhatsNewRelease( + version: WhatsNewVersion(major: major, minor: minor), + items: (0.. StoreService { - let service = StoreService() - service.shutdown() - return service - } + /// A service with its `Transaction.updates` listener cancelled — the fold logic needs no + /// live StoreKit, and detaching avoids an idle listener Task outliving the test. + private func makeService() -> StoreService { + let service = StoreService() + service.shutdown() + return service + } - @Test("granting the bundle unlocks every bundled theme") - func grantBundle() { - let service = makeService() - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - } + @Test + func `granting the bundle unlocks every bundled theme`() { + let service = makeService() + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + } - @Test("revoking the bundle removes every theme it granted") - func revokeBundle() { - let service = makeService() - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: true) - #expect(service.ownedThemeIDs.isEmpty) - } + @Test + func `revoking the bundle removes every theme it granted`() { + let service = makeService() + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: true) + #expect(service.ownedThemeIDs.isEmpty) + } - @Test("a consumable tip product grants no theme entitlement") - func tipGrantsNothing() { - let service = makeService() - service.applyEntitlement(productID: StoreCatalog.Tip.coffee, isRevoked: false) - #expect(service.ownedThemeIDs.isEmpty) - } + @Test + func `a consumable tip product grants no theme entitlement`() { + let service = makeService() + service.applyEntitlement(productID: StoreCatalog.Tip.coffee, isRevoked: false) + #expect(service.ownedThemeIDs.isEmpty) + } - @Test("the entitlements-changed callback fires only on a real change") - func callbackFiresOnChangeOnly() { - let service = makeService() - let count = MutableBox(0) - service.onEntitlementsChanged = { count.value += 1 } + @Test + func `the entitlements-changed callback fires only on a real change`() { + let service = makeService() + let count = MutableBox(0) + service.onEntitlementsChanged = { count.value += 1 } - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) // already owned + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) // already owned - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - #expect(count.value == 1) - } + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + #expect(count.value == 1) + } } diff --git a/MC1Tests/StoreKitTestAvailability.swift b/MC1Tests/StoreKitTestAvailability.swift index f3839f03..104ec4af 100644 --- a/MC1Tests/StoreKitTestAvailability.swift +++ b/MC1Tests/StoreKitTestAvailability.swift @@ -8,10 +8,10 @@ import Foundation /// affected runtime — keeping the default iOS 26 run green — and run on iOS 18.x, which /// `make test-store` pins explicitly. enum StoreKitTestAvailability { - /// iOS 26.x is the known-broken runtime; every other version serves products normally. - static let brokenMajorVersion = 26 + /// iOS 26.x is the known-broken runtime; every other version serves products normally. + static let brokenMajorVersion = 26 - static var servesProducts: Bool { - ProcessInfo.processInfo.operatingSystemVersion.majorVersion != brokenMajorVersion - } + static var servesProducts: Bool { + ProcessInfo.processInfo.operatingSystemVersion.majorVersion != brokenMajorVersion + } } diff --git a/MC1Tests/StoreServiceTests.swift b/MC1Tests/StoreServiceTests.swift index a9b13dab..b5e1a7af 100644 --- a/MC1Tests/StoreServiceTests.swift +++ b/MC1Tests/StoreServiceTests.swift @@ -1,255 +1,257 @@ -import Testing +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services +import Testing @MainActor @Suite("StoreService", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class StoreServiceTests { - // Two spec behaviors are covered by code inspection, not automated tests, because - // SKTestSession cannot produce the required conditions: - // 1. Unverified-transaction drop + deduped warning log. SKTestSession only ever - // produces VerificationResult.verified transactions, so the `.unverified` branch - // in walkCurrentEntitlements / applyTransactionUpdate / processUnfinishedTransactions - // is unreachable from a test. The production handling (noteUnverified) is verified by - // inspecting StoreService and exercised in the sandbox pre-submission checklist. - // 2. CancellationError filtering in restorePurchases(). AppStore.sync() cannot be made to - // throw CancellationError on demand; the `catch is CancellationError { return }` guard - // is verified by inspection. - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - // Keep Ask-to-Buy off by default; askToBuyPendingThenApproved opts in, and the - // setting otherwise persists across SKTestSession instances in the same process. - session.askToBuyEnabled = false - session.clearTransactions() + /// Two spec behaviors are covered by code inspection, not automated tests, because + /// SKTestSession cannot produce the required conditions: + /// 1. Unverified-transaction drop + deduped warning log. SKTestSession only ever + /// produces VerificationResult.verified transactions, so the `.unverified` branch + /// in walkCurrentEntitlements / applyTransactionUpdate / processUnfinishedTransactions + /// is unreachable from a test. The production handling (noteUnverified) is verified by + /// inspecting StoreService and exercised in the sandbox pre-submission checklist. + /// 2. CancellationError filtering in restorePurchases(). AppStore.sync() cannot be made to + /// throw CancellationError on demand; the `catch is CancellationError { return }` guard + /// is verified by inspection. + let session: SKTestSession + + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + // Keep Ask-to-Buy off by default; askToBuyPendingThenApproved opts in, and the + // setting otherwise persists across SKTestSession instances in the same process. + session.askToBuyEnabled = false + session.clearTransactions() + } + + deinit { session.clearTransactions() } + + @Test + func `the bundled configuration exposes all seven sellable products`() async throws { + let products = try await Product.products(for: StoreCatalog.sellableProductIDs) + #expect(products.count == 7) + } + + @Test + func `load populates products and reports loaded`() async { + let service = StoreService() + await service.load() + #expect(service.loadState == .loaded) + #expect(service.products.count == 7) + #expect(service.product(for: StoreCatalog.Theme.bundleAll) != nil) + } + + @Test + func `a clean account owns no themes after load`() async { + let service = StoreService() + await service.load() + #expect(service.ownedThemeIDs.isEmpty) + } + + @Test + func `load with only unknown IDs fails`() async { + let service = StoreService() + await service.load(productIDs: ["io.pocketmesh.app.nonexistent"]) + #expect(service.loadState == .failed) + #expect(service.products.isEmpty) + } + + @Test + func `shutdown cancels the transaction listener`() { + let service = StoreService() + #expect(service.transactionListenerTask != nil) + service.shutdown() + #expect(service.transactionListenerTask == nil) + } + + @Test + func `buying the bundle grants every theme, fires the callback, and finishes the transaction`() async throws { + let service = StoreService() + await service.load() + let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) + + let callbackCount = MutableBox(0) + service.onEntitlementsChanged = { callbackCount.value += 1 } + + let outcome = try await purchaseWithRetry(bundle, on: service) + + #expect(outcome == .purchased) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + #expect(callbackCount.value >= 1) + } + + @Test + func `buying a consumable tip succeeds without granting a theme entitlement`() async throws { + let service = StoreService() + await service.load() + let coffee = try #require(service.product(for: StoreCatalog.Tip.coffee)) + + let outcome = try await purchaseWithRetry(coffee, on: service) + + #expect(outcome == .purchased) + #expect(service.ownedThemeIDs.isEmpty) + } + + @Test + func `an Ask-to-Buy-approved consumable fires onConsumablePurchased with the productID`() async throws { + // `onConsumablePurchased` exists to clear a pending banner when an Ask-to-Buy tip is + // approved out-of-band (the inline product.purchase() path never reaches it for inline + // buys; only `applyTransactionUpdate` does, and that path only runs for transactions + // delivered through `Transaction.updates`). This test exercises the production trigger. + session.askToBuyEnabled = true + let service = StoreService() + await service.load() + let coffee = try #require(service.product(for: StoreCatalog.Tip.coffee)) + + let received = MutableBox<[String]>([]) + service.onConsumablePurchased = { productID in received.value.append(productID) } + + let outcome = try await purchaseWithRetry(coffee, on: service) + #expect(outcome == .pending) + + let pendingTxn = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Tip.coffee + }) + try session.approveAskToBuyTransaction(identifier: pendingTxn.identifier) + + try await waitUntil(timeout: .seconds(5)) { + received.value.contains(StoreCatalog.Tip.coffee) } - - deinit { session.clearTransactions() } - - @Test("the bundled configuration exposes all seven sellable products") - func configExposesAllProducts() async throws { - let products = try await Product.products(for: StoreCatalog.sellableProductIDs) - #expect(products.count == 7) + #expect(received.value == [StoreCatalog.Tip.coffee]) + } + + @Test + func `refunding the bundle revokes its theme entitlements via the listener`() async throws { + let service = StoreService() + await service.load() + let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: service) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + + let txn = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.refundTransaction(identifier: txn.identifier) + + try await waitUntil(timeout: .seconds(5)) { + service.ownedThemeIDs.isEmpty } - - @Test("load populates products and reports loaded") - func loadPopulatesProducts() async throws { - let service = StoreService() - await service.load() - #expect(service.loadState == .loaded) - #expect(service.products.count == 7) - #expect(service.product(for: StoreCatalog.Theme.bundleAll) != nil) + } + + @Test + func `load drains unfinished transactions and applies their entitlement`() async throws { + // Simulate a purchase committed while no StoreService was running: buy directly and leave + // the transaction unfinished. product.purchase() is synchronously consistent (unlike the + // eventually-consistent session.buyProduct), so the transaction is committed before any + // StoreService exists and load()'s drain path sees it deterministically. + let bundle = try #require( + try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first + ) + try await purchaseUnfinished(bundle) + + // Guard against any residual lag before Transaction.unfinished reflects the committed sale. + try await waitUntil( + timeout: .seconds(5), + pollingInterval: .milliseconds(100), + "committed bundle transaction did not surface in Transaction.unfinished" + ) { + await self.hasUnfinished(StoreCatalog.Theme.bundleAll) } - @Test("a clean account owns no themes after load") - func cleanAccountOwnsNothing() async throws { - let service = StoreService() - await service.load() - #expect(service.ownedThemeIDs.isEmpty) - } + let service = StoreService() + service.shutdown() // detach the listener so only load()'s drain path can finish the transaction + await service.load() - @Test("load with only unknown IDs fails") - func loadEmptyFails() async throws { - let service = StoreService() - await service.load(productIDs: ["io.pocketmesh.app.nonexistent"]) - #expect(service.loadState == .failed) - #expect(service.products.isEmpty) - } + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - @Test("shutdown cancels the transaction listener") - func shutdownCancelsListener() async throws { - let service = StoreService() - #expect(service.transactionListenerTask != nil) - service.shutdown() - #expect(service.transactionListenerTask == nil) + var remainingUnfinished = 0 + for await _ in Transaction.unfinished { + remainingUnfinished += 1 } - - @Test("buying the bundle grants every theme, fires the callback, and finishes the transaction") - func purchaseBundleGrantsAllThemes() async throws { - let service = StoreService() - await service.load() - let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) - - let callbackCount = MutableBox(0) - service.onEntitlementsChanged = { callbackCount.value += 1 } - - let outcome = try await purchaseWithRetry(bundle, on: service) - - #expect(outcome == .purchased) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - #expect(callbackCount.value >= 1) - } - - @Test("buying a consumable tip succeeds without granting a theme entitlement") - func purchaseTipGrantsNoEntitlement() async throws { - let service = StoreService() - await service.load() - let coffee = try #require(service.product(for: StoreCatalog.Tip.coffee)) - - let outcome = try await purchaseWithRetry(coffee, on: service) - - #expect(outcome == .purchased) - #expect(service.ownedThemeIDs.isEmpty) + #expect(remainingUnfinished == 0) + } + + @Test + func `restore re-walks entitlements after AppStore.sync`() async throws { + // Establish ownership via the synchronously-consistent product.purchase(), then build a + // fresh service whose ownedThemeIDs starts empty and recover it through restore. + let bundle = try #require( + try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first + ) + try await purchaseUnfinished(bundle) + + let service = StoreService() + service.shutdown() // isolate the restore path from the listener + try await service.restorePurchases() + + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + } + + @Test + func `Ask-to-Buy yields a pending outcome, then the approval grants entitlement`() async throws { + session.askToBuyEnabled = true + let service = StoreService() + await service.load() + let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) + + let outcome = try await purchaseWithRetry(bundle, on: service) + #expect(outcome == .pending) + #expect(service.ownedThemeIDs.isEmpty) + + let pending = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.approveAskToBuyTransaction(identifier: pending.identifier) + + try await waitUntil(timeout: .seconds(5)) { + service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs } - - @Test("an Ask-to-Buy-approved consumable fires onConsumablePurchased with the productID") - func consumablePurchaseFiresOnConsumablePurchased() async throws { - // `onConsumablePurchased` exists to clear a pending banner when an Ask-to-Buy tip is - // approved out-of-band (the inline product.purchase() path never reaches it for inline - // buys; only `applyTransactionUpdate` does, and that path only runs for transactions - // delivered through `Transaction.updates`). This test exercises the production trigger. - session.askToBuyEnabled = true - let service = StoreService() - await service.load() - let coffee = try #require(service.product(for: StoreCatalog.Tip.coffee)) - - let received = MutableBox<[String]>([]) - service.onConsumablePurchased = { productID in received.value.append(productID) } - - let outcome = try await purchaseWithRetry(coffee, on: service) - #expect(outcome == .pending) - - let pendingTxn = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Tip.coffee - }) - try session.approveAskToBuyTransaction(identifier: pendingTxn.identifier) - - try await waitUntil(timeout: .seconds(5)) { - received.value.contains(StoreCatalog.Tip.coffee) + } + + @Test + func `listener and load applying the same transaction do not corrupt state`() async throws { + let service = StoreService() + await service.load() + let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) + + _ = try await purchaseWithRetry(bundle, on: service) // listener + purchase both walk + let afterPurchase = service.ownedThemeIDs + await service.load() // walk again + let afterReload = service.ownedThemeIDs + + #expect(afterPurchase == afterReload) + #expect(afterReload == StoreCatalog.Theme.bundledThemeIDs) + } + + // MARK: - SKTestSession robustness helpers + + /// Commits a purchase through the synchronously-consistent `product.purchase()` (unlike the + /// eventually-consistent `session.buyProduct`) and deliberately leaves the transaction + /// unfinished, so a later `load()` or `restorePurchases()` sees it deterministically. Retries + /// the transient `StoreKitError.unknown` that storekitd raises under SKTestSession churn. + private func purchaseUnfinished(_ product: Product, attempts: Int = 4) async throws { + for attempt in 1...attempts { + do { + let result = try await product.purchase() + guard case .success = result else { + throw StoreServiceError.purchaseFailed(reason: "setup purchase did not succeed: \(result)") } - #expect(received.value == [StoreCatalog.Tip.coffee]) + return + } catch let error as StoreKitError { + guard case .unknown = error, attempt < attempts else { throw error } + } } + } - @Test("refunding the bundle revokes its theme entitlements via the listener") - func refundRevokesThemes() async throws { - let service = StoreService() - await service.load() - let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: service) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - - let txn = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.refundTransaction(identifier: txn.identifier) - - try await waitUntil(timeout: .seconds(5)) { - service.ownedThemeIDs.isEmpty - } - } - - @Test("load drains unfinished transactions and applies their entitlement") - func loadDrainsUnfinished() async throws { - // Simulate a purchase committed while no StoreService was running: buy directly and leave - // the transaction unfinished. product.purchase() is synchronously consistent (unlike the - // eventually-consistent session.buyProduct), so the transaction is committed before any - // StoreService exists and load()'s drain path sees it deterministically. - let bundle = try #require( - try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first - ) - try await purchaseUnfinished(bundle) - - // Guard against any residual lag before Transaction.unfinished reflects the committed sale. - try await waitUntil( - timeout: .seconds(5), - pollingInterval: .milliseconds(100), - "committed bundle transaction did not surface in Transaction.unfinished" - ) { - await self.hasUnfinished(StoreCatalog.Theme.bundleAll) - } - - let service = StoreService() - service.shutdown() // detach the listener so only load()'s drain path can finish the transaction - await service.load() - - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - - var remainingUnfinished = 0 - for await _ in Transaction.unfinished { remainingUnfinished += 1 } - #expect(remainingUnfinished == 0) - } - - @Test("restore re-walks entitlements after AppStore.sync") - func restoreRecoversEntitlements() async throws { - // Establish ownership via the synchronously-consistent product.purchase(), then build a - // fresh service whose ownedThemeIDs starts empty and recover it through restore. - let bundle = try #require( - try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first - ) - try await purchaseUnfinished(bundle) - - let service = StoreService() - service.shutdown() // isolate the restore path from the listener - try await service.restorePurchases() - - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - } - - @Test("Ask-to-Buy yields a pending outcome, then the approval grants entitlement") - func askToBuyPendingThenApproved() async throws { - session.askToBuyEnabled = true - let service = StoreService() - await service.load() - let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) - - let outcome = try await purchaseWithRetry(bundle, on: service) - #expect(outcome == .pending) - #expect(service.ownedThemeIDs.isEmpty) - - let pending = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.approveAskToBuyTransaction(identifier: pending.identifier) - - try await waitUntil(timeout: .seconds(5)) { - service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs - } - } - - @Test("listener and load applying the same transaction do not corrupt state") - func listenerLoadIdempotence() async throws { - let service = StoreService() - await service.load() - let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) - - _ = try await purchaseWithRetry(bundle, on: service) // listener + purchase both walk - let afterPurchase = service.ownedThemeIDs - await service.load() // walk again - let afterReload = service.ownedThemeIDs - - #expect(afterPurchase == afterReload) - #expect(afterReload == StoreCatalog.Theme.bundledThemeIDs) - } - - // MARK: - SKTestSession robustness helpers - - /// Commits a purchase through the synchronously-consistent `product.purchase()` (unlike the - /// eventually-consistent `session.buyProduct`) and deliberately leaves the transaction - /// unfinished, so a later `load()` or `restorePurchases()` sees it deterministically. Retries - /// the transient `StoreKitError.unknown` that storekitd raises under SKTestSession churn. - private func purchaseUnfinished(_ product: Product, attempts: Int = 4) async throws { - for attempt in 1...attempts { - do { - let result = try await product.purchase() - guard case .success = result else { - throw StoreServiceError.purchaseFailed(reason: "setup purchase did not succeed: \(result)") - } - return - } catch let error as StoreKitError { - guard case .unknown = error, attempt < attempts else { throw error } - } - } - } - - /// True once `productID` has an unfinished transaction, so a single `load()` sees it on the - /// drain path. - private func hasUnfinished(_ productID: String) async -> Bool { - for await result in Transaction.unfinished { - if case .verified(let txn) = result, txn.productID == productID { return true } - } - return false + /// True once `productID` has an unfinished transaction, so a single `load()` sees it on the + /// drain path. + private func hasUnfinished(_ productID: String) async -> Bool { + for await result in Transaction.unfinished { + if case let .verified(txn) = result, txn.productID == productID { return true } } + return false + } } diff --git a/MC1Tests/StoreStateTests.swift b/MC1Tests/StoreStateTests.swift index 32d8a400..218272ac 100644 --- a/MC1Tests/StoreStateTests.swift +++ b/MC1Tests/StoreStateTests.swift @@ -1,204 +1,189 @@ -import Testing @testable import MC1 +import Testing @Suite("Store value-model types") struct StoreValueModelTests { - - @Test("RestoreState distinguishes its five cases") - func restoreStateEquatable() { - #expect(RestoreState.idle == .idle) - #expect(RestoreState.syncing != .completed) - #expect(RestoreState.failed != .idle) - #expect(RestoreState.cancelled != .completed) - #expect(RestoreState.cancelled != .failed) - } - - @Test("PendingPurchase carries productID and displayName") - func pendingPurchaseFields() { - let pending = PendingPurchase(productID: "io.pocketmesh.app.theme.marine", displayName: "Marine") - #expect(pending.productID == "io.pocketmesh.app.theme.marine") - #expect(pending.displayName == "Marine") - #expect(pending == PendingPurchase(productID: "io.pocketmesh.app.theme.marine", displayName: "Marine")) - } + @Test + func `RestoreState distinguishes its five cases`() { + #expect(RestoreState.idle == .idle) + #expect(RestoreState.syncing != .completed) + #expect(RestoreState.failed != .idle) + #expect(RestoreState.cancelled != .completed) + #expect(RestoreState.cancelled != .failed) + } + + @Test + func `PendingPurchase carries productID and displayName`() { + let pending = PendingPurchase(productID: "io.pocketmesh.app.theme.marine", displayName: "Marine") + #expect(pending.productID == "io.pocketmesh.app.theme.marine") + #expect(pending.displayName == "Marine") + #expect(pending == PendingPurchase(productID: "io.pocketmesh.app.theme.marine", displayName: "Marine")) + } } -import SwiftUI +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services +import SwiftUI @MainActor @Suite("StoreState error mapping") struct StoreStateErrorMappingTests { - - private func makeState() -> StoreState { - let service = StoreService() - service.shutdown() - return StoreState(service: service) - } - - @Test("every StoreServiceError maps to a non-empty localized message") - func everyErrorMaps() { - let state = makeState() - let cases: [StoreServiceError] = [ - .productsNotLoaded, .productNotFound(productID: "x"), - .purchaseFailed(reason: "boom"), .verificationFailed, - .networkUnavailable, .storefrontUnavailable, .unsupported - ] - for error in cases { - #expect(state.localizedMessage(for: error).isEmpty == false) - } - } - - @Test("purchaseFailed embeds the underlying reason") - func purchaseFailedEmbedsReason() { - let state = makeState() - #expect(state.localizedMessage(for: .purchaseFailed(reason: "boom")).contains("boom")) - } - - @Test("localizedMessage delegates to the shared userFacingMessage mapping") - func localizedMessageMatchesUserFacingMessage() { - let state = makeState() - let cases: [StoreServiceError] = [ - .productsNotLoaded, .productNotFound(productID: "x"), - .purchaseFailed(reason: "boom"), .verificationFailed, - .notEntitled, .networkUnavailable, .storefrontUnavailable, .unsupported - ] - for error in cases { - #expect(state.localizedMessage(for: error) == error.userFacingMessage) - } - } - - @Test("reconcilePendingPurchase is a no-op when there is no pending purchase") - func reconcileNoPending() { - let state = makeState() - state.reconcilePendingPurchase() - #expect(state.pendingPurchase == nil) + private func makeState() -> StoreState { + let service = StoreService() + service.shutdown() + return StoreState(service: service) + } + + @Test + func `every StoreServiceError maps to a non-empty localized message`() { + let state = makeState() + let cases: [StoreServiceError] = [ + .productsNotLoaded, .productNotFound(productID: "x"), + .purchaseFailed(reason: "boom"), .verificationFailed, + .networkUnavailable, .storefrontUnavailable, .unsupported + ] + for error in cases { + #expect(state.localizedMessage(for: error).isEmpty == false) } + } + + @Test + func `purchaseFailed embeds the underlying reason`() { + let state = makeState() + #expect(state.localizedMessage(for: .purchaseFailed(reason: "boom")).contains("boom")) + } + + @Test + func `reconcilePendingPurchase is a no-op when there is no pending purchase`() { + let state = makeState() + state.reconcilePendingPurchase() + #expect(state.pendingPurchase == nil) + } } @MainActor @Suite("StoreState purchase/restore", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class StoreStatePurchaseTests { - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - session.askToBuyEnabled = false // reset: SKTestSession leaks session flags across instances in-process - session.clearTransactions() - } - - deinit { session.clearTransactions() } - - @Test("buying the bundle sets no error and leaves no pending purchase") - func purchaseBundleNoPending() async throws { - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } - - #expect(state.errorMessage == nil) - #expect(state.pendingPurchase == nil) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - } - - @Test("an Ask-to-Buy purchase sets a pending banner, then reconcile clears it on approval") - func askToBuySetsAndClearsPending() async throws { - session.askToBuyEnabled = true - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } - #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) - - let pending = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.approveAskToBuyTransaction(identifier: pending.identifier) - - try await waitUntil(timeout: .seconds(5)) { - service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs - } - state.reconcilePendingPurchase() - #expect(state.pendingPurchase == nil) - } - - @Test("restore transitions syncing then completed") - func restoreCompletes() async throws { - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - await state.restorePurchases() - - #expect(state.restoreState == .completed) - #expect(state.errorMessage == nil) - } - - @Test("purchase returns true on a verified .purchased outcome") - func purchaseReturnsTrueOnPurchased() async throws { - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - let result = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } - - #expect(result == true) - #expect(state.errorMessage == nil) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - } - - @Test("purchase returns false on .userCancelled without setting an error or pending banner") - func purchaseReturnsFalseOnUserCancel() async throws { - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - // SKTestSession exposes no `.userCancelled` `Product.PurchaseResult`; the purchase closure - // stubs it so this branch is reachable from tests. - let result = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { _ in .userCancelled } - - #expect(result == false) - #expect(state.errorMessage == nil) - #expect(state.pendingPurchase == nil) - } - - @Test("an unrelated .purchased does not clear an in-flight pending purchase") - func purchaseDoesNotClearUnrelatedPending() async throws { - // Ask-to-Buy on while the bundle is bought: outcome is .pending, banner is set. - session.askToBuyEnabled = true - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - _ = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } - #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) - - // Ask-to-Buy off so the next purchase completes immediately. - session.askToBuyEnabled = false - let coffeeResult = await state.purchase(productID: StoreCatalog.Tip.coffee) { try await $0.purchase() } - - #expect(coffeeResult == true) - // Clearing the pending banner is gated on matching productID, so completing an - // unrelated tip purchase leaves the bundle's banner intact. - #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) - } - - @Test("restore that the user cancels maps to .cancelled, not .completed") - func restoreUserCancelMapsToCancelledNotCompleted() async throws { - let service = StoreService() - await service.load() - // AppStore.sync() cannot be made to throw userCancelled from SKTestSession; the seam - // throws on its behalf so the `.cancelled` arm of `RestoreOutcome` is reachable. - service.appStoreSyncForTesting = { throw StoreKitError.userCancelled } - let state = StoreState(service: service) - - await state.restorePurchases() - - #expect(state.restoreState == .cancelled) - #expect(state.errorMessage == nil) + let session: SKTestSession + + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + session.askToBuyEnabled = false // reset: SKTestSession leaks session flags across instances in-process + session.clearTransactions() + } + + deinit { session.clearTransactions() } + + @Test + func `buying the bundle sets no error and leaves no pending purchase`() async { + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } + + #expect(state.errorMessage == nil) + #expect(state.pendingPurchase == nil) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + } + + @Test + func `an Ask-to-Buy purchase sets a pending banner, then reconcile clears it on approval`() async throws { + session.askToBuyEnabled = true + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } + #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) + + let pending = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.approveAskToBuyTransaction(identifier: pending.identifier) + + try await waitUntil(timeout: .seconds(5)) { + service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs } + state.reconcilePendingPurchase() + #expect(state.pendingPurchase == nil) + } + + @Test + func `restore transitions syncing then completed`() async { + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + await state.restorePurchases() + + #expect(state.restoreState == .completed) + #expect(state.errorMessage == nil) + } + + @Test + func `purchase returns true on a verified .purchased outcome`() async { + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + let result = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } + + #expect(result == true) + #expect(state.errorMessage == nil) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + } + + @Test + func `purchase returns false on .userCancelled without setting an error or pending banner`() async { + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + // SKTestSession exposes no `.userCancelled` `Product.PurchaseResult`; the purchase closure + // stubs it so this branch is reachable from tests. + let result = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { _ in .userCancelled } + + #expect(result == false) + #expect(state.errorMessage == nil) + #expect(state.pendingPurchase == nil) + } + + @Test + func `an unrelated .purchased does not clear an in-flight pending purchase`() async { + // Ask-to-Buy on while the bundle is bought: outcome is .pending, banner is set. + session.askToBuyEnabled = true + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + _ = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } + #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) + + // Ask-to-Buy off so the next purchase completes immediately. + session.askToBuyEnabled = false + let coffeeResult = await state.purchase(productID: StoreCatalog.Tip.coffee) { try await $0.purchase() } + + #expect(coffeeResult == true) + // Clearing the pending banner is gated on matching productID, so completing an + // unrelated tip purchase leaves the bundle's banner intact. + #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) + } + + @Test + func `restore that the user cancels maps to .cancelled, not .completed`() async { + let service = StoreService() + await service.load() + // AppStore.sync() cannot be made to throw userCancelled from SKTestSession; the seam + // throws on its behalf so the `.cancelled` arm of `RestoreOutcome` is reachable. + service.appStoreSyncForTesting = { throw StoreKitError.userCancelled } + let state = StoreState(service: service) + + await state.restorePurchases() + + #expect(state.restoreState == .cancelled) + #expect(state.errorMessage == nil) + } } diff --git a/MC1Tests/Theme/IdentityGamutTests.swift b/MC1Tests/Theme/IdentityGamutTests.swift index 9ef95997..2990361c 100644 --- a/MC1Tests/Theme/IdentityGamutTests.swift +++ b/MC1Tests/Theme/IdentityGamutTests.swift @@ -1,80 +1,79 @@ -import Testing +@testable import MC1 import SwiftUI +import Testing import UIKit -@testable import MC1 /// Validates the identity-color solver in isolation: for a representative gamut, every name must /// resolve to a color that clears WCAG AA against the surfaces it renders on, in both appearances /// and both contrast settings, and the avatar glyph must clear AA against that color as a fill. @Suite("IdentityGamut contrast") struct IdentityGamutTests { + private let gamut = IdentityGamut( + hueAnchors: [20, 50, 95, 150, 195, 235, 280, 320], + saturation: 0.45...0.75 + ) - private let gamut = IdentityGamut( - hueAnchors: [20, 50, 95, 150, 195, 235, 280, 320], - saturation: 0.45...0.75 - ) + /// A broad spread of names, including anagrams and near-duplicates that stress hash collisions. + private static let names: [String] = (0..<600).map { "Sender \($0)" } + ["Bob", "obB", "Alice", "alice", "灯火", "Søren"] - /// A broad spread of names, including anagrams and near-duplicates that stress hash collisions. - private static let names: [String] = (0..<600).map { "Sender \($0)" } + ["Bob", "obB", "Alice", "alice", "灯火", "Søren"] + private static let light = UITraitCollection(userInterfaceStyle: .light) + private static let dark = UITraitCollection(userInterfaceStyle: .dark) - private static let light = UITraitCollection(userInterfaceStyle: .light) - private static let dark = UITraitCollection(userInterfaceStyle: .dark) + private func luminance(of color: Color, _ trait: UITraitCollection) -> Double { + WCAGContrast.relativeLuminance(of: UIColor(color).resolvedColor(with: trait)) + } - private func luminance(of color: Color, _ trait: UITraitCollection) -> Double { - WCAGContrast.relativeLuminance(of: UIColor(color).resolvedColor(with: trait)) - } + private func surfaceLuminances(_ trait: UITraitCollection) -> [Double] { + [ + luminance(of: Color(.systemBackground), trait), + luminance(of: Color(.secondarySystemBackground), trait), + luminance(of: Color(UIColor.systemGray5), trait) + ] + } - private func surfaceLuminances(_ trait: UITraitCollection) -> [Double] { - [ - luminance(of: Color(.systemBackground), trait), - luminance(of: Color(.secondarySystemBackground), trait), - luminance(of: Color(UIColor.systemGray5), trait) - ] - } - - @Test("every identity color clears AA against its surfaces in both appearances and contrasts") - func identityColorsClearAA() { - for trait in [Self.light, Self.dark] { - for highContrast in [false, true] { - let backgrounds = surfaceLuminances(trait) - let floor = highContrast ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor - for name in Self.names { - let color = gamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: highContrast) - let colorLuminance = luminance(of: color, trait) - for background in backgrounds { - let ratio = WCAGContrast.contrastRatio(colorLuminance, background) - #expect( - ratio >= floor, - "\(name) color luminance \(colorLuminance) vs surface \(background) is \(ratio), below \(floor)" - ) - } - } - } + @Test + func `every identity color clears AA against its surfaces in both appearances and contrasts`() { + for trait in [Self.light, Self.dark] { + for highContrast in [false, true] { + let backgrounds = surfaceLuminances(trait) + let floor = highContrast ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor + for name in Self.names { + let color = gamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: highContrast) + let colorLuminance = luminance(of: color, trait) + for background in backgrounds { + let ratio = WCAGContrast.contrastRatio(colorLuminance, background) + #expect( + ratio >= floor, + "\(name) color luminance \(colorLuminance) vs surface \(background) is \(ratio), below \(floor)" + ) + } } + } } + } - @Test("avatar glyph clears AA against the identity fill in both appearances") - func glyphClearsAA() { - for trait in [Self.light, Self.dark] { - let backgrounds = surfaceLuminances(trait) - for name in Self.names { - let fill = gamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: false) - let fillLuminance = luminance(of: fill, trait) - let glyph = IdentityGamut.glyphColor(forFillLuminance: fillLuminance) - let ratio = WCAGContrast.contrastRatio(luminance(of: glyph, trait), fillLuminance) - #expect(ratio >= WCAGContrast.aaFloor, "\(name) glyph vs fill is \(ratio)") - } - } + @Test + func `avatar glyph clears AA against the identity fill in both appearances`() { + for trait in [Self.light, Self.dark] { + let backgrounds = surfaceLuminances(trait) + for name in Self.names { + let fill = gamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: false) + let fillLuminance = luminance(of: fill, trait) + let glyph = IdentityGamut.glyphColor(forFillLuminance: fillLuminance) + let ratio = WCAGContrast.contrastRatio(luminance(of: glyph, trait), fillLuminance) + #expect(ratio >= WCAGContrast.aaFloor, "\(name) glyph vs fill is \(ratio)") + } } + } - @Test("hue is stable across appearance and contrast for a given name") - func hueStableAcrossAppearance() { - let lightBackgrounds = surfaceLuminances(Self.light) - let darkBackgrounds = surfaceLuminances(Self.dark) - for name in Self.names.prefix(50) { - let lightHue = gamut.resolve(forName: name, backgroundLuminances: lightBackgrounds, highContrast: false).hue - let darkHue = gamut.resolve(forName: name, backgroundLuminances: darkBackgrounds, highContrast: false).hue - #expect(lightHue == darkHue, "\(name) hue drifted across appearance: \(lightHue) vs \(darkHue)") - } + @Test + func `hue is stable across appearance and contrast for a given name`() { + let lightBackgrounds = surfaceLuminances(Self.light) + let darkBackgrounds = surfaceLuminances(Self.dark) + for name in Self.names.prefix(50) { + let lightHue = gamut.resolve(forName: name, backgroundLuminances: lightBackgrounds, highContrast: false).hue + let darkHue = gamut.resolve(forName: name, backgroundLuminances: darkBackgrounds, highContrast: false).hue + #expect(lightHue == darkHue, "\(name) hue drifted across appearance: \(lightHue) vs \(darkHue)") } + } } diff --git a/MC1Tests/Theme/ThemeContrastTests.swift b/MC1Tests/Theme/ThemeContrastTests.swift index ee95885d..94bf85a5 100644 --- a/MC1Tests/Theme/ThemeContrastTests.swift +++ b/MC1Tests/Theme/ThemeContrastTests.swift @@ -1,7 +1,7 @@ -import Testing +@testable import MC1 import SwiftUI +import Testing import UIKit -@testable import MC1 /// Guards the legibility of outgoing message text against its bubble fill (the theme accent) for /// every paid theme, in every appearance/contrast combination, and the legibility of identity and @@ -9,161 +9,158 @@ import UIKit /// stress and outdoor glare, so they must clear the WCAG AA 4.5:1 floor. @Suite("Theme contrast") struct ThemeContrastTests { - - /// Derived from the registry so a future paid theme is covered automatically rather than - /// shipping with no contrast validation because someone forgot to extend a hand-written list. - private static let paidThemes: [Theme] = ThemeRegistry.allThemes.filter { $0.productID != nil } - - private static let traits: [(name: String, style: UIUserInterfaceStyle, collection: UITraitCollection)] = [ - ("light", .light, UITraitCollection(userInterfaceStyle: .light)), - ("dark", .dark, UITraitCollection(userInterfaceStyle: .dark)), - ("light+highContrast", .light, UITraitCollection(traitsFrom: [ - UITraitCollection(userInterfaceStyle: .light), - UITraitCollection(accessibilityContrast: .high) - ])), - ("dark+highContrast", .dark, UITraitCollection(traitsFrom: [ - UITraitCollection(userInterfaceStyle: .dark), - UITraitCollection(accessibilityContrast: .high) - ])) - ] - - /// The appearances a theme actually renders in. A forced color scheme pins the theme to one - /// appearance, so asserting contrast in the other tests a combination users never see — e.g. - /// Ember is dark-only, and its hashtag is tuned for its dark surface, not the light bubble it - /// never shows on. - private static func traits( - for theme: Theme - ) -> [(name: String, style: UIUserInterfaceStyle, collection: UITraitCollection)] { - guard let forced = theme.preferredColorScheme else { return traits } - let style: UIUserInterfaceStyle = (forced == .dark) ? .dark : .light - return traits.filter { $0.style == style } - } - - /// A broad spread of names, including anagrams and near-duplicates that stress hash collisions. - private static let identityNames: [String] = - (0..<400).map { "Sender \($0)" } + ["Bob", "obB", "Alice", "alice", "灯火", "Søren", "#general"] - - private static func floor(forTraitNamed name: String) -> Double { - name.contains("highContrast") ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor - } - - private static func luminance(of color: Color, _ collection: UITraitCollection) -> Double { - WCAGContrast.relativeLuminance(of: UIColor(color).resolvedColor(with: collection)) - } - - /// Canvas (or system background for the surfaceless default theme) and incoming bubble — the two - /// surfaces identity colors must stay legible against. - private static func surfaceLuminances(_ theme: Theme, _ collection: UITraitCollection) -> [Double] { - [theme.surfaces?.canvas ?? Color(.systemBackground), theme.incomingBubbleColor] - .map { luminance(of: $0, collection) } + /// Derived from the registry so a future paid theme is covered automatically rather than + /// shipping with no contrast validation because someone forgot to extend a hand-written list. + private static let paidThemes: [Theme] = ThemeRegistry.allThemes.filter { $0.productID != nil } + + private static let traits: [(name: String, style: UIUserInterfaceStyle, collection: UITraitCollection)] = [ + ("light", .light, UITraitCollection(userInterfaceStyle: .light)), + ("dark", .dark, UITraitCollection(userInterfaceStyle: .dark)), + ("light+highContrast", .light, UITraitCollection(userInterfaceStyle: .light).modifyingTraits { + $0.accessibilityContrast = .high + }), + ("dark+highContrast", .dark, UITraitCollection(userInterfaceStyle: .dark).modifyingTraits { + $0.accessibilityContrast = .high + }) + ] + + /// The appearances a theme actually renders in. A forced color scheme pins the theme to one + /// appearance, so asserting contrast in the other tests a combination users never see — e.g. + /// Ember is dark-only, and its hashtag is tuned for its dark surface, not the light bubble it + /// never shows on. + private static func traits( + for theme: Theme + ) -> [(name: String, style: UIUserInterfaceStyle, collection: UITraitCollection)] { + guard let forced = theme.preferredColorScheme else { return traits } + let style: UIUserInterfaceStyle = (forced == .dark) ? .dark : .light + return traits.filter { $0.style == style } + } + + /// A broad spread of names, including anagrams and near-duplicates that stress hash collisions. + private static let identityNames: [String] = + (0..<400).map { "Sender \($0)" } + ["Bob", "obB", "Alice", "alice", "灯火", "Søren", "#general"] + + private static func floor(forTraitNamed name: String) -> Double { + name.contains("highContrast") ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor + } + + private static func luminance(of color: Color, _ collection: UITraitCollection) -> Double { + WCAGContrast.relativeLuminance(of: UIColor(color).resolvedColor(with: collection)) + } + + /// Canvas (or system background for the surfaceless default theme) and incoming bubble — the two + /// surfaces identity colors must stay legible against. + private static func surfaceLuminances(_ theme: Theme, _ collection: UITraitCollection) -> [Double] { + [theme.surfaces?.canvas ?? Color(.systemBackground), theme.incomingBubbleColor] + .map { luminance(of: $0, collection) } + } + + /// The list canvas — the only surface a category avatar renders on. + private static func canvasLuminance(_ theme: Theme, _ collection: UITraitCollection) -> Double { + luminance(of: theme.surfaces?.canvas ?? Color(.systemBackground), collection) + } + + @Test + func `outgoing text clears WCAG AA 4.5:1 against the accent in every appearance`() { + for theme in Self.paidThemes { + for trait in Self.traits(for: theme) { + let text = Self.luminance(of: theme.outgoingTextColor, trait.collection) + let fill = Self.luminance(of: theme.accentColor, trait.collection) + let ratio = WCAGContrast.contrastRatio(text, fill) + #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) outgoing text vs accent in \(trait.name) is \(ratio)") + } } - - /// The list canvas — the only surface a category avatar renders on. - private static func canvasLuminance(_ theme: Theme, _ collection: UITraitCollection) -> Double { - luminance(of: theme.surfaces?.canvas ?? Color(.systemBackground), collection) - } - - @Test("outgoing text clears WCAG AA 4.5:1 against the accent in every appearance") - func outgoingTextClearsAAFloor() { - for theme in Self.paidThemes { - for trait in Self.traits(for: theme) { - let text = Self.luminance(of: theme.outgoingTextColor, trait.collection) - let fill = Self.luminance(of: theme.accentColor, trait.collection) - let ratio = WCAGContrast.contrastRatio(text, fill) - #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) outgoing text vs accent in \(trait.name) is \(ratio)") - } - } - } - - @Test("incoming hashtag links clear WCAG AA 4.5:1 against the incoming bubble in every appearance") - func incomingHashtagClearsAAFloor() { - for theme in Self.paidThemes { - for trait in Self.traits(for: theme) { - let hashtag = Self.luminance(of: theme.hashtagColor, trait.collection) - let bubble = Self.luminance(of: theme.incomingBubbleColor, trait.collection) - let ratio = WCAGContrast.contrastRatio(hashtag, bubble) - #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) incoming hashtag vs bubble in \(trait.name) is \(ratio)") - } - } + } + + @Test + func `incoming hashtag links clear WCAG AA 4.5:1 against the incoming bubble in every appearance`() { + for theme in Self.paidThemes { + for trait in Self.traits(for: theme) { + let hashtag = Self.luminance(of: theme.hashtagColor, trait.collection) + let bubble = Self.luminance(of: theme.incomingBubbleColor, trait.collection) + let ratio = WCAGContrast.contrastRatio(hashtag, bubble) + #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) incoming hashtag vs bubble in \(trait.name) is \(ratio)") + } } - - @Test("identity colors clear AA against their surfaces for every theme and appearance") - func identityColorsClearAA() { - for theme in ThemeRegistry.allThemes { - for trait in Self.traits(for: theme) { - let floor = Self.floor(forTraitNamed: trait.name) - let highContrast = trait.name.contains("highContrast") - let backgrounds = Self.surfaceLuminances(theme, trait.collection) - for name in Self.identityNames { - let color = theme.identityGamut.color( - forName: name, - backgroundLuminances: backgrounds, - highContrast: highContrast - ) - let colorLuminance = Self.luminance(of: color, trait.collection) - for background in backgrounds { - let ratio = WCAGContrast.contrastRatio(colorLuminance, background) - #expect(ratio >= floor, "\(theme.id) identity '\(name)' vs surface \(background) in \(trait.name) is \(ratio)") - } - } - } + } + + @Test + func `identity colors clear AA against their surfaces for every theme and appearance`() { + for theme in ThemeRegistry.allThemes { + for trait in Self.traits(for: theme) { + let floor = Self.floor(forTraitNamed: trait.name) + let highContrast = trait.name.contains("highContrast") + let backgrounds = Self.surfaceLuminances(theme, trait.collection) + for name in Self.identityNames { + let color = theme.identityGamut.color( + forName: name, + backgroundLuminances: backgrounds, + highContrast: highContrast + ) + let colorLuminance = Self.luminance(of: color, trait.collection) + for background in backgrounds { + let ratio = WCAGContrast.contrastRatio(colorLuminance, background) + #expect(ratio >= floor, "\(theme.id) identity '\(name)' vs surface \(background) in \(trait.name) is \(ratio)") + } } + } } - - /// Resolves the channel / repeater / room avatar colors the way `Theme.categoryAvatarColor` does: - /// each at its curated hue, against the list canvas only, at the darkest legible brightness. - private static func categoryColors(_ theme: Theme, canvas: Double, highContrast: Bool) -> [(AvatarCategory, Color)] { - AvatarCategory.anchorPriority.map { category in - (category, theme.identityGamut.color( - forName: category.gamutSeed, - backgroundLuminances: [canvas], - highContrast: highContrast, - atHue: theme.categoryHue(for: category), - atVariety: Theme.categoryDarkestVariety - )) - } + } + + /// Resolves the channel / repeater / room avatar colors the way `Theme.categoryAvatarColor` does: + /// each at its curated hue, against the list canvas only, at the darkest legible brightness. + private static func categoryColors(_ theme: Theme, canvas: Double, highContrast: Bool) -> [(AvatarCategory, Color)] { + AvatarCategory.anchorPriority.map { category in + (category, theme.identityGamut.color( + forName: category.gamutSeed, + backgroundLuminances: [canvas], + highContrast: highContrast, + atHue: theme.categoryHue(for: category), + atVariety: Theme.categoryDarkestVariety + )) } - - @Test("gamut-derived category colors clear AA against the list canvas") - func categoryColorsClearAA() { - // The System theme pins category colors to fixed legacy values that predate this AA bar, so - // it is exempt; every gamut-derived theme must clear it against the canvas it renders on. - for theme in ThemeRegistry.allThemes where theme.categoryAvatarOverride == nil { - for trait in Self.traits(for: theme) { - let floor = Self.floor(forTraitNamed: trait.name) - let highContrast = trait.name.contains("highContrast") - let canvas = Self.canvasLuminance(theme, trait.collection) - for (category, color) in Self.categoryColors(theme, canvas: canvas, highContrast: highContrast) { - let ratio = WCAGContrast.contrastRatio(Self.luminance(of: color, trait.collection), canvas) - #expect(ratio >= floor, "\(theme.id) category \(category) vs canvas \(canvas) in \(trait.name) is \(ratio)") - } - } + } + + @Test + func `gamut-derived category colors clear AA against the list canvas`() { + // The System theme pins category colors to fixed legacy values that predate this AA bar, so + // it is exempt; every gamut-derived theme must clear it against the canvas it renders on. + for theme in ThemeRegistry.allThemes where theme.categoryAvatarOverride == nil { + for trait in Self.traits(for: theme) { + let floor = Self.floor(forTraitNamed: trait.name) + let highContrast = trait.name.contains("highContrast") + let canvas = Self.canvasLuminance(theme, trait.collection) + for (category, color) in Self.categoryColors(theme, canvas: canvas, highContrast: highContrast) { + let ratio = WCAGContrast.contrastRatio(Self.luminance(of: color, trait.collection), canvas) + #expect(ratio >= floor, "\(theme.id) category \(category) vs canvas \(canvas) in \(trait.name) is \(ratio)") } + } } - - @Test("channel, repeater, and room avatars resolve to distinct on-anchor hues for every gamut theme") - func categoryColorsAreDistinct() { - for theme in ThemeRegistry.allThemes where theme.categoryAvatarOverride == nil { - let anchors = Set(theme.identityGamut.sortedAnchors) - let hues = AvatarCategory.anchorPriority.map { theme.categoryHue(for: $0) } - #expect(Set(hues).count == hues.count, "\(theme.id) category hues collide: \(hues)") - #expect(hues.allSatisfy { anchors.contains($0) }, "\(theme.id) category hue left the palette: \(hues)") - } + } + + @Test + func `channel, repeater, and room avatars resolve to distinct on-anchor hues for every gamut theme`() { + for theme in ThemeRegistry.allThemes where theme.categoryAvatarOverride == nil { + let anchors = Set(theme.identityGamut.sortedAnchors) + let hues = AvatarCategory.anchorPriority.map { theme.categoryHue(for: $0) } + #expect(Set(hues).count == hues.count, "\(theme.id) category hues collide: \(hues)") + #expect(hues.allSatisfy { anchors.contains($0) }, "\(theme.id) category hue left the palette: \(hues)") } - - @Test("avatar glyph clears AA against the identity fill for every theme and appearance") - func glyphClearsAA() { - for theme in ThemeRegistry.allThemes { - for trait in Self.traits(for: theme) { - let backgrounds = Self.surfaceLuminances(theme, trait.collection) - for name in Self.identityNames.prefix(100) { - let fill = theme.identityGamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: false) - let fillLuminance = Self.luminance(of: fill, trait.collection) - let glyph = IdentityGamut.glyphColor(forFillLuminance: fillLuminance) - let ratio = WCAGContrast.contrastRatio(Self.luminance(of: glyph, trait.collection), fillLuminance) - #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) glyph vs fill '\(name)' in \(trait.name) is \(ratio)") - } - } + } + + @Test + func `avatar glyph clears AA against the identity fill for every theme and appearance`() { + for theme in ThemeRegistry.allThemes { + for trait in Self.traits(for: theme) { + let backgrounds = Self.surfaceLuminances(theme, trait.collection) + for name in Self.identityNames.prefix(100) { + let fill = theme.identityGamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: false) + let fillLuminance = Self.luminance(of: fill, trait.collection) + let glyph = IdentityGamut.glyphColor(forFillLuminance: fillLuminance) + let ratio = WCAGContrast.contrastRatio(Self.luminance(of: glyph, trait.collection), fillLuminance) + #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) glyph vs fill '\(name)' in \(trait.name) is \(ratio)") } + } } + } } diff --git a/MC1Tests/Theme/ThemeStructureTests.swift b/MC1Tests/Theme/ThemeStructureTests.swift index d86d58c2..cea7203e 100644 --- a/MC1Tests/Theme/ThemeStructureTests.swift +++ b/MC1Tests/Theme/ThemeStructureTests.swift @@ -1,80 +1,79 @@ -import Testing +@testable import MC1 import SwiftUI +import Testing import UIKit -@testable import MC1 @Suite("Theme structure") struct ThemeStructureTests { + @Test + func `Default theme paints no surfaces`() { + #expect(Theme.default.surfaces == nil) + } - @Test("Default theme paints no surfaces") - func defaultThemeHasNoSurfaces() { - #expect(Theme.default.surfaces == nil) - } - - @Test("Default theme imposes no chrome tint, deferring to the system") - func defaultThemeHasNoChromeTint() { - #expect(Theme.default.chromeTint == nil) - } + @Test + func `Default theme imposes no chrome tint, deferring to the system`() { + #expect(Theme.default.chromeTint == nil) + } - /// Derived from the registry so a future paid theme is covered automatically. - private static let paidThemes: [Theme] = ThemeRegistry.allThemes.filter { $0.productID != nil } + /// Derived from the registry so a future paid theme is covered automatically. + private static let paidThemes: [Theme] = ThemeRegistry.allThemes.filter { $0.productID != nil } - @Test("Paid themes impose their accent on chrome") - func paidThemesTintChromeWithAccent() { - for theme in Self.paidThemes { - #expect(theme.chromeTint == theme.accentColor, "\(theme.id) must tint chrome with its accent") - } + @Test + func `Paid themes impose their accent on chrome`() { + for theme in Self.paidThemes { + #expect(theme.chromeTint == theme.accentColor, "\(theme.id) must tint chrome with its accent") } + } - @Test("Ember paints the canvas but not the card tier") - func emberPaintsCanvasButNotCard() throws { - let surfaces = try #require(Theme.ember.surfaces) - #expect(surfaces.canvas == .black) - #expect(surfaces.card == nil) - } + @Test + func `Ember paints the canvas but not the card tier`() throws { + let surfaces = try #require(Theme.ember.surfaces) + #expect(surfaces.canvas == .black) + #expect(surfaces.card == nil) + } - @Test("Every painted theme defines both canvas and card tiers") - func allPaintedThemesHaveBothTiers() throws { - // Painted = every paid theme except the canvas-only Ember. Excluding by identity (not by - // card-presence) keeps the assertion meaningful: a future paid theme that forgets its card - // tier lands here and fails instead of being silently filtered out. - let painted = Self.paidThemes.filter { $0.id != Theme.ember.id } - for theme in painted { - let surfaces = try #require(theme.surfaces, "\(theme.id) must have surfaces") - #expect(surfaces.card != nil, "\(theme.id) must define card tier") - } + @Test + func `Every painted theme defines both canvas and card tiers`() throws { + // Painted = every paid theme except the canvas-only Ember. Excluding by identity (not by + // card-presence) keeps the assertion meaningful: a future paid theme that forgets its card + // tier lands here and fails instead of being silently filtered out. + let painted = Self.paidThemes.filter { $0.id != Theme.ember.id } + for theme in painted { + let surfaces = try #require(theme.surfaces, "\(theme.id) must have surfaces") + #expect(surfaces.card != nil, "\(theme.id) must define card tier") } + } - @Test("Every theme defines a usable identity gamut") - func everyThemeHasIdentityGamut() { - for theme in ThemeRegistry.allThemes { - let gamut = theme.identityGamut - #expect(!gamut.hueAnchors.isEmpty, "\(theme.id) must define identity hue anchors") - #expect(gamut.hueAnchors.allSatisfy { (0..<360).contains($0) }, "\(theme.id) hue anchors must be 0..<360") - #expect(gamut.saturation.lowerBound >= 0 && gamut.saturation.upperBound <= 1, - "\(theme.id) saturation must be within 0...1") - #expect(gamut.saturation.lowerBound < gamut.saturation.upperBound, - "\(theme.id) saturation must be a non-empty range") - } + @Test + func `Every theme defines a usable identity gamut`() { + for theme in ThemeRegistry.allThemes { + let gamut = theme.identityGamut + #expect(!gamut.hueAnchors.isEmpty, "\(theme.id) must define identity hue anchors") + #expect(gamut.hueAnchors.allSatisfy { (0..<360).contains($0) }, "\(theme.id) hue anchors must be 0..<360") + #expect(gamut.saturation.lowerBound >= 0 && gamut.saturation.upperBound <= 1, + "\(theme.id) saturation must be within 0...1") + #expect(gamut.saturation.lowerBound < gamut.saturation.upperBound, + "\(theme.id) saturation must be a non-empty range") } + } - @Test("Only the System theme pins fixed category avatar colors") - func onlySystemPinsCategoryColors() { - #expect(Theme.default.categoryAvatarOverride != nil, "System theme must pin category colors") - for theme in Self.paidThemes { - #expect(theme.categoryAvatarOverride == nil, "\(theme.id) must derive category colors from its gamut") - } + @Test + func `Only the System theme pins fixed category avatar colors`() { + #expect(Theme.default.categoryAvatarOverride != nil, "System theme must pin category colors") + for theme in Self.paidThemes { + #expect(theme.categoryAvatarOverride == nil, "\(theme.id) must derive category colors from its gamut") } + } - @Test("Migrated themes' outgoingTextColor resolves differently in light vs dark") - func outgoingTextFlipsAcrossAppearance() { - let migrated: [Theme] = [.fern, .olive, .lavender, .sakura] - let light = UITraitCollection(userInterfaceStyle: .light) - let dark = UITraitCollection(userInterfaceStyle: .dark) - for theme in migrated { - let lightResolved = UIColor(theme.outgoingTextColor).resolvedColor(with: light) - let darkResolved = UIColor(theme.outgoingTextColor).resolvedColor(with: dark) - #expect(lightResolved != darkResolved, "\(theme.id) outgoingText must differ across appearance") - } + @Test + func `Migrated themes' outgoingTextColor resolves differently in light vs dark`() { + let migrated: [Theme] = [.fern, .olive, .lavender, .sakura] + let light = UITraitCollection(userInterfaceStyle: .light) + let dark = UITraitCollection(userInterfaceStyle: .dark) + for theme in migrated { + let lightResolved = UIColor(theme.outgoingTextColor).resolvedColor(with: light) + let darkResolved = UIColor(theme.outgoingTextColor).resolvedColor(with: dark) + #expect(lightResolved != darkResolved, "\(theme.id) outgoingText must differ across appearance") } + } } diff --git a/MC1Tests/ThemeLocalizedNameTests.swift b/MC1Tests/ThemeLocalizedNameTests.swift index 06876519..18c5f20e 100644 --- a/MC1Tests/ThemeLocalizedNameTests.swift +++ b/MC1Tests/ThemeLocalizedNameTests.swift @@ -1,35 +1,34 @@ -import Testing @testable import MC1 +import Testing @Suite("Theme.localizedName") struct ThemeLocalizedNameTests { - - @Test("every registry theme has a non-empty localized name") - func everyThemeNamed() { - for theme in ThemeRegistry.allThemes { - #expect(theme.localizedName.isEmpty == false) - } + @Test + func `every registry theme has a non-empty localized name`() { + for theme in ThemeRegistry.allThemes { + #expect(theme.localizedName.isEmpty == false) } + } - @Test("every registry theme resolves to copy distinct from its raw display-name key") - func everyThemeResolvesToLocalizedCopy() { - // A theme added to the registry without a `localizedName` case would fall back to the raw - // dotted key. Asserting resolution differs from `displayNameKey` catches that regression - // (and the missing case also traps via assertionFailure on this debug test run). - for theme in ThemeRegistry.allThemes { - #expect(theme.localizedName != theme.displayNameKey) - } + @Test + func `every registry theme resolves to copy distinct from its raw display-name key`() { + // A theme added to the registry without a `localizedName` case would fall back to the raw + // dotted key. Asserting resolution differs from `displayNameKey` catches that regression + // (and the missing case also traps via assertionFailure on this debug test run). + for theme in ThemeRegistry.allThemes { + #expect(theme.localizedName != theme.displayNameKey) } + } - @Test("theme names are distinct across the registry") - func namesAreDistinct() { - let names = ThemeRegistry.allThemes.map(\.localizedName) - #expect(Set(names).count == names.count) - } + @Test + func `theme names are distinct across the registry`() { + let names = ThemeRegistry.allThemes.map(\.localizedName) + #expect(Set(names).count == names.count) + } - @Test("default and ember resolve to their expected English copy") - func knownNames() { - #expect(Theme.default.localizedName == L10n.Settings.Support.Theme.default) - #expect(Theme.ember.localizedName == L10n.Settings.Support.Theme.ember) - } + @Test + func `default and ember resolve to their expected English copy`() { + #expect(Theme.default.localizedName == L10n.Settings.Support.Theme.default) + #expect(Theme.ember.localizedName == L10n.Settings.Support.Theme.ember) + } } diff --git a/MC1Tests/ThemeRegistryTests.swift b/MC1Tests/ThemeRegistryTests.swift index 51494083..0911076d 100644 --- a/MC1Tests/ThemeRegistryTests.swift +++ b/MC1Tests/ThemeRegistryTests.swift @@ -1,51 +1,50 @@ -import Testing -import SwiftUI -@testable import MC1Services @testable import MC1 +@testable import MC1Services +import SwiftUI +import Testing @Suite("ThemeRegistry") struct ThemeRegistryTests { + @Test + func `allThemes holds the ten built-ins`() { + #expect(ThemeRegistry.allThemes.count == 10) + #expect(ThemeRegistry.allThemes.first?.id == "default") + } - @Test("allThemes holds the ten built-ins") - func allThemesCount() { - #expect(ThemeRegistry.allThemes.count == 10) - #expect(ThemeRegistry.allThemes.first?.id == "default") - } - - @Test("theme(forID:) resolves known IDs and returns nil for unknown") - func themeLookup() { - #expect(ThemeRegistry.theme(forID: "default")?.id == Theme.default.id) - #expect(ThemeRegistry.theme(forID: "ember")?.id == "ember") - #expect(ThemeRegistry.theme(forID: "does-not-exist") == nil) - } + @Test + func `theme(forID:) resolves known IDs and returns nil for unknown`() { + #expect(ThemeRegistry.theme(forID: "default")?.id == Theme.default.id) + #expect(ThemeRegistry.theme(forID: "ember")?.id == "ember") + #expect(ThemeRegistry.theme(forID: "does-not-exist") == nil) + } - @Test("default theme has no productID; all paid themes do") - func productIDPresence() { - #expect(Theme.default.productID == nil) - let paid = ThemeRegistry.allThemes.filter { $0.id != "default" } - #expect(paid.count == 9) - #expect(paid.allSatisfy { $0.productID != nil }) - } + @Test + func `default theme has no productID; all paid themes do`() { + #expect(Theme.default.productID == nil) + let paid = ThemeRegistry.allThemes.filter { $0.id != "default" } + #expect(paid.count == 9) + #expect(paid.allSatisfy { $0.productID != nil }) + } - @Test("paid theme productIDs match StoreCatalog's bundled theme IDs") - func productIDsMatchCatalog() { - let registryProductIDs = Set(ThemeRegistry.allThemes.compactMap { $0.productID }) - #expect(registryProductIDs == StoreCatalog.Theme.bundledThemeIDs) - #expect(Theme.ember.productID == StoreCatalog.Theme.ember) - #expect(Theme.solarized.productID == StoreCatalog.Theme.solarized) - } + @Test + func `paid theme productIDs match StoreCatalog's bundled theme IDs`() { + let registryProductIDs = Set(ThemeRegistry.allThemes.compactMap(\.productID)) + #expect(registryProductIDs == StoreCatalog.Theme.bundledThemeIDs) + #expect(Theme.ember.productID == StoreCatalog.Theme.ember) + #expect(Theme.solarized.productID == StoreCatalog.Theme.solarized) + } - @Test("only Ember forces a color scheme, and it forces dark") - func forcedColorSchemeThemes() { - #expect(Theme.ember.preferredColorScheme == .dark) - let forced: Set = ["ember"] - let others = ThemeRegistry.allThemes.filter { !forced.contains($0.id) } - #expect(others.allSatisfy { $0.preferredColorScheme == nil }) - } + @Test + func `only Ember forces a color scheme, and it forces dark`() { + #expect(Theme.ember.preferredColorScheme == .dark) + let forced: Set = ["ember"] + let others = ThemeRegistry.allThemes.filter { !forced.contains($0.id) } + #expect(others.allSatisfy { $0.preferredColorScheme == nil }) + } - @Test("theme IDs are unique") - func idsAreUnique() { - let ids = ThemeRegistry.allThemes.map(\.id) - #expect(Set(ids).count == ids.count) - } + @Test + func `theme IDs are unique`() { + let ids = ThemeRegistry.allThemes.map(\.id) + #expect(Set(ids).count == ids.count) + } } diff --git a/MC1Tests/ThemeServiceTests.swift b/MC1Tests/ThemeServiceTests.swift index f6549f37..b9704b9d 100644 --- a/MC1Tests/ThemeServiceTests.swift +++ b/MC1Tests/ThemeServiceTests.swift @@ -1,307 +1,305 @@ -import Testing -import SwiftUI +@testable import MC1 +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services -@testable import MC1 +import SwiftUI +import Testing @Suite("ThemeServiceError") struct ThemeServiceErrorTests { - - @Test("notOwned carries the productID and has a non-empty localized description") - func notOwnedDescription() { - let error = ThemeServiceError.notOwned(productID: StoreCatalog.Theme.ember) - #expect(error.errorDescription?.isEmpty == false) - } + @Test + func `notOwned carries the productID and has a non-empty localized description`() { + let error = ThemeServiceError.notOwned(productID: StoreCatalog.Theme.ember) + #expect(error.errorDescription?.isEmpty == false) + } } @MainActor @Suite("ThemeService pure") struct ThemeServicePureTests { - - /// A StoreService with no purchases and its listener detached, for ownership-independent tests. - private func emptyStore() -> StoreService { - let store = StoreService() - store.shutdown() - return store - } - - private func freshDefaults() -> UserDefaults { - UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - @Test("missing selectedThemeID uses default in memory and does NOT write back") - func missingThemeIDNoWriteBack() { - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.default.id) - #expect(defaults.object(forKey: PersistenceKeys.selectedThemeID) == nil) - } - - @Test("unknown selectedThemeID falls back to default and overwrites the stored value") - func unknownThemeIDOverwrites() { - let defaults = freshDefaults() - defaults.set("ghost-theme", forKey: PersistenceKeys.selectedThemeID) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.default.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) - } - - @Test("cold start retains a persisted paid theme even with empty ownership") - func coldStartRetainsPaidTheme() { - let defaults = freshDefaults() - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.ember.id) - // Valid registry ID — not overwritten on init. - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) - } - - @Test("with no purchases, only the default theme is available and paid themes throw on setCurrent") - func emptyOwnershipAccessRule() { - let service = ThemeService(store: emptyStore(), defaults: freshDefaults()) - #expect(service.availableToCurrentUser().map(\.id) == [Theme.default.id]) - #expect(throws: ThemeServiceError.self) { - try service.setCurrent(.ember) - } - } - - @Test("missing appColorSchemePreference uses .system without writing back") - func missingColorSchemeNoWriteBack() { - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.colorSchemePreference == .system) - #expect(defaults.object(forKey: PersistenceKeys.appColorSchemePreference) == nil) - } - - @Test("unknown appColorSchemePreference falls back to .system and overwrites") - func unknownColorSchemeOverwrites() { - let defaults = freshDefaults() - defaults.set("auto", forKey: PersistenceKeys.appColorSchemePreference) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.colorSchemePreference == .system) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - == AppColorSchemePreference.system.rawValue) - } - - @Test("setColorSchemePreference persists the raw value and updates state") - func setColorSchemePersists() { - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - service.setColorSchemePreference(.dark) - #expect(service.colorSchemePreference == .dark) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "dark") - } - - @Test("effectiveColorScheme: default theme defers to the preference") - func effectiveColorSchemeDefersForDefault() { - let service = ThemeService(store: emptyStore(), defaults: freshDefaults()) - service.setColorSchemePreference(.system) - #expect(service.effectiveColorScheme == nil) - service.setColorSchemePreference(.light) - #expect(service.effectiveColorScheme == .light) - service.setColorSchemePreference(.dark) - #expect(service.effectiveColorScheme == .dark) - } - - @Test("effectiveColorScheme: Ember forces .dark regardless of the preference") - func effectiveColorSchemeEmberForcesDark() { - let defaults = freshDefaults() - // init adopts the persisted theme without enforcing ownership. - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let service = ThemeService(store: emptyStore(), defaults: defaults) - service.setColorSchemePreference(.light) - #expect(service.effectiveColorScheme == .dark) - service.setColorSchemePreference(.system) - #expect(service.effectiveColorScheme == .dark) - } - - @Test("refreshFromUserDefaults adopts an externally written scheme and retains the theme while unloaded") - func refreshAdoptsExternalScheme() { - // Scheme adoption has no ownership gate, so it applies regardless of load state. The theme - // is retained (not reverted) because `emptyStore()` is `.idle` — ownership is not yet - // authoritative, so refresh must not destructively revert (mirrors `init`). - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - defaults.set(Theme.fern.id, forKey: PersistenceKeys.selectedThemeID) - defaults.set("dark", forKey: PersistenceKeys.appColorSchemePreference) - service.refreshFromUserDefaults() - #expect(service.current.id == Theme.fern.id) - #expect(service.colorSchemePreference == .dark) - } - - @Test("refreshFromUserDefaults does not revert a persisted theme while the store is unloaded") - func refreshRetainsThemeWhileStoreUnloaded() { - // Regression guard: an owner restoring a backup before the entitlement walk has populated - // ownedThemeIDs (store still `.idle`/`.failed`) must keep their selection. `emptyStore()` is - // `.idle`, so the ownership revert is deferred to the post-load listener — refresh neither - // changes `current` nor overwrites the persisted value. - let defaults = freshDefaults() - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.ember.id) - - service.refreshFromUserDefaults() - - #expect(service.current.id == Theme.ember.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) - } - - @Test("refreshFromUserDefaults overwrites an unknown selectedThemeID with the default") - func refreshFromUserDefaultsOverwritesUnknownThemeID() { - // Seed after init so the unknown-value path is exercised in refresh, not init. This is - // the mid-session backup-import shape: BackupUserDefaults.restore writes the foreign - // value into defaults, then notifyDataRestored calls refreshFromUserDefaults. - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - defaults.set("future-build-theme-id", forKey: PersistenceKeys.selectedThemeID) - - service.refreshFromUserDefaults() - - #expect(service.current.id == Theme.default.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) - } - - @Test("refreshFromUserDefaults overwrites an unknown color-scheme preference with .system") - func refreshFromUserDefaultsOverwritesUnknownColorScheme() { - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - defaults.set("auto", forKey: PersistenceKeys.appColorSchemePreference) - - service.refreshFromUserDefaults() - - #expect(service.colorSchemePreference == .system) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - == AppColorSchemePreference.system.rawValue) - } - - @Test("restore-origin downgrade: an unknown backed-up scheme falls back to .system on init") - func restoreUnknownSchemeDowngrades() { - let defaults = freshDefaults() - var prefs = BackupUserDefaults() - prefs.appColorSchemePreference = "auto" // a future raw value this build doesn't know - prefs.restore(to: defaults) // write-if-missing writes "auto" - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.colorSchemePreference == .system) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - == AppColorSchemePreference.system.rawValue) - } - - @Test("restore-origin downgrade: an unknown backed-up theme ID falls back to default on init") - func restoreUnknownThemeDowngrades() { - let defaults = freshDefaults() - var prefs = BackupUserDefaults() - prefs.selectedThemeID = "theme-from-a-future-build" - prefs.restore(to: defaults) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.default.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + /// A StoreService with no purchases and its listener detached, for ownership-independent tests. + private func emptyStore() -> StoreService { + let store = StoreService() + store.shutdown() + return store + } + + private func freshDefaults() -> UserDefaults { + UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `missing selectedThemeID uses default in memory and does NOT write back`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.default.id) + #expect(defaults.object(forKey: PersistenceKeys.selectedThemeID) == nil) + } + + @Test + func `unknown selectedThemeID falls back to default and overwrites the stored value`() { + let defaults = freshDefaults() + defaults.set("ghost-theme", forKey: PersistenceKeys.selectedThemeID) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.default.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } + + @Test + func `cold start retains a persisted paid theme even with empty ownership`() { + let defaults = freshDefaults() + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.ember.id) + // Valid registry ID — not overwritten on init. + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) + } + + @Test + func `with no purchases, only the default theme is available and paid themes throw on setCurrent`() { + let service = ThemeService(store: emptyStore(), defaults: freshDefaults()) + #expect(service.availableToCurrentUser().map(\.id) == [Theme.default.id]) + #expect(throws: ThemeServiceError.self) { + try service.setCurrent(.ember) } + } + + @Test + func `missing appColorSchemePreference uses .system without writing back`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.colorSchemePreference == .system) + #expect(defaults.object(forKey: PersistenceKeys.appColorSchemePreference) == nil) + } + + @Test + func `unknown appColorSchemePreference falls back to .system and overwrites`() { + let defaults = freshDefaults() + defaults.set("auto", forKey: PersistenceKeys.appColorSchemePreference) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.colorSchemePreference == .system) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + == AppColorSchemePreference.system.rawValue) + } + + @Test + func `setColorSchemePreference persists the raw value and updates state`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + service.setColorSchemePreference(.dark) + #expect(service.colorSchemePreference == .dark) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "dark") + } + + @Test + func `effectiveColorScheme: default theme defers to the preference`() { + let service = ThemeService(store: emptyStore(), defaults: freshDefaults()) + service.setColorSchemePreference(.system) + #expect(service.effectiveColorScheme == nil) + service.setColorSchemePreference(.light) + #expect(service.effectiveColorScheme == .light) + service.setColorSchemePreference(.dark) + #expect(service.effectiveColorScheme == .dark) + } + + @Test + func `effectiveColorScheme: Ember forces .dark regardless of the preference`() { + let defaults = freshDefaults() + // init adopts the persisted theme without enforcing ownership. + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let service = ThemeService(store: emptyStore(), defaults: defaults) + service.setColorSchemePreference(.light) + #expect(service.effectiveColorScheme == .dark) + service.setColorSchemePreference(.system) + #expect(service.effectiveColorScheme == .dark) + } + + @Test + func `refreshFromUserDefaults adopts an externally written scheme and retains the theme while unloaded`() { + // Scheme adoption has no ownership gate, so it applies regardless of load state. The theme + // is retained (not reverted) because `emptyStore()` is `.idle` — ownership is not yet + // authoritative, so refresh must not destructively revert (mirrors `init`). + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + defaults.set(Theme.fern.id, forKey: PersistenceKeys.selectedThemeID) + defaults.set("dark", forKey: PersistenceKeys.appColorSchemePreference) + service.refreshFromUserDefaults() + #expect(service.current.id == Theme.fern.id) + #expect(service.colorSchemePreference == .dark) + } + + @Test + func `refreshFromUserDefaults does not revert a persisted theme while the store is unloaded`() { + // Regression guard: an owner restoring a backup before the entitlement walk has populated + // ownedThemeIDs (store still `.idle`/`.failed`) must keep their selection. `emptyStore()` is + // `.idle`, so the ownership revert is deferred to the post-load listener — refresh neither + // changes `current` nor overwrites the persisted value. + let defaults = freshDefaults() + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.ember.id) + + service.refreshFromUserDefaults() + + #expect(service.current.id == Theme.ember.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) + } + + @Test + func `refreshFromUserDefaults overwrites an unknown selectedThemeID with the default`() { + // Seed after init so the unknown-value path is exercised in refresh, not init. This is + // the mid-session backup-import shape: BackupUserDefaults.restore writes the foreign + // value into defaults, then notifyDataRestored calls refreshFromUserDefaults. + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + defaults.set("future-build-theme-id", forKey: PersistenceKeys.selectedThemeID) + + service.refreshFromUserDefaults() + + #expect(service.current.id == Theme.default.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } + + @Test + func `refreshFromUserDefaults overwrites an unknown color-scheme preference with .system`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + defaults.set("auto", forKey: PersistenceKeys.appColorSchemePreference) + + service.refreshFromUserDefaults() + + #expect(service.colorSchemePreference == .system) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + == AppColorSchemePreference.system.rawValue) + } + + @Test + func `restore-origin downgrade: an unknown backed-up scheme falls back to .system on init`() { + let defaults = freshDefaults() + var prefs = BackupUserDefaults() + prefs.appColorSchemePreference = "auto" // a future raw value this build doesn't know + prefs.restore(to: defaults) // write-if-missing writes "auto" + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.colorSchemePreference == .system) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + == AppColorSchemePreference.system.rawValue) + } + + @Test + func `restore-origin downgrade: an unknown backed-up theme ID falls back to default on init`() { + let defaults = freshDefaults() + var prefs = BackupUserDefaults() + prefs.selectedThemeID = "theme-from-a-future-build" + prefs.restore(to: defaults) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.default.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } } @MainActor @Suite("ThemeService ownership", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class ThemeServiceOwnershipTests { - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - // Reset Ask-to-Buy: a fresh SKTestSession does not clear this flag on storekitd, so a - // prior suite that enabled it would otherwise leak a pending-purchase mode into these tests. - session.askToBuyEnabled = false - session.clearTransactions() - } - - deinit { session.clearTransactions() } - - private func freshDefaults() -> UserDefaults { - UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - @Test("a theme owned via the bundle is accessible and selectable") - func ownedThemeAccessible() async throws { - let store = StoreService() - await store.load() - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - store.shutdown() - - let theme = ThemeService(store: store, defaults: freshDefaults()) - #expect(theme.availableToCurrentUser().contains { $0.id == Theme.ember.id }) - try theme.setCurrent(.ember) - #expect(theme.current.id == Theme.ember.id) - } - - @Test("owning the bundle makes every theme available") - func bundleGrantsAllThemes() async throws { - let store = StoreService() - await store.load() - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - store.shutdown() - - let theme = ThemeService(store: store, defaults: freshDefaults()) - #expect(Set(theme.availableToCurrentUser().map(\.id)) - == Set(ThemeRegistry.allThemes.map(\.id))) - } - - @Test("refunding the bundle reverts the selected theme to default via the listener") - func refundRevertsSelectedTheme() async throws { - let store = StoreService() // listener stays live for the refund path - await store.load() - let defaults = freshDefaults() - let theme = ThemeService(store: store, defaults: defaults) - - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - try theme.setCurrent(.ember) - #expect(theme.current.id == Theme.ember.id) - - let txn = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.refundTransaction(identifier: txn.identifier) - - try await waitUntil(timeout: .seconds(5)) { - theme.current.id == Theme.default.id - } - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) - } - - @Test("the load() walk does not wipe a persisted theme on an empty ownership read") - func loadWalkDoesNotWipePersistedTheme() async throws { - // Guards the cold-start reversion hole: the first entitlement walk fires - // onEntitlementsChanged from inside load() while still .loading, and on a cold storekitd - // it can read empty. With the listener live and a paid theme already persisted, that empty - // read must not overwrite the selection. - let store = StoreService() - let defaults = freshDefaults() - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let theme = ThemeService(store: store, defaults: defaults) - #expect(theme.current.id == Theme.ember.id) - - await store.load() - store.shutdown() - - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) - } - - @Test("refreshFromUserDefaults reverts an unowned restored theme once the store is loaded") - func refreshRevertsUnownedThemeWhenLoaded() async throws { - // The post-load enforcement path: with a loaded store and no purchases, a restored paid - // theme is genuinely unowned, so refresh reverts it and overwrites the persisted value. - let store = StoreService() - await store.load() - store.shutdown() - let defaults = freshDefaults() - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let theme = ThemeService(store: store, defaults: defaults) - #expect(theme.current.id == Theme.ember.id) - - theme.refreshFromUserDefaults() - - #expect(theme.current.id == Theme.default.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + let session: SKTestSession + + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + // Reset Ask-to-Buy: a fresh SKTestSession does not clear this flag on storekitd, so a + // prior suite that enabled it would otherwise leak a pending-purchase mode into these tests. + session.askToBuyEnabled = false + session.clearTransactions() + } + + deinit { session.clearTransactions() } + + private func freshDefaults() -> UserDefaults { + UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `a theme owned via the bundle is accessible and selectable`() async throws { + let store = StoreService() + await store.load() + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + store.shutdown() + + let theme = ThemeService(store: store, defaults: freshDefaults()) + #expect(theme.availableToCurrentUser().contains { $0.id == Theme.ember.id }) + try theme.setCurrent(.ember) + #expect(theme.current.id == Theme.ember.id) + } + + @Test + func `owning the bundle makes every theme available`() async throws { + let store = StoreService() + await store.load() + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + store.shutdown() + + let theme = ThemeService(store: store, defaults: freshDefaults()) + #expect(Set(theme.availableToCurrentUser().map(\.id)) + == Set(ThemeRegistry.allThemes.map(\.id))) + } + + @Test + func `refunding the bundle reverts the selected theme to default via the listener`() async throws { + let store = StoreService() // listener stays live for the refund path + await store.load() + let defaults = freshDefaults() + let theme = ThemeService(store: store, defaults: defaults) + + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + try theme.setCurrent(.ember) + #expect(theme.current.id == Theme.ember.id) + + let txn = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.refundTransaction(identifier: txn.identifier) + + try await waitUntil(timeout: .seconds(5)) { + theme.current.id == Theme.default.id } + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } + + @Test + func `the load() walk does not wipe a persisted theme on an empty ownership read`() async { + // Guards the cold-start reversion hole: the first entitlement walk fires + // onEntitlementsChanged from inside load() while still .loading, and on a cold storekitd + // it can read empty. With the listener live and a paid theme already persisted, that empty + // read must not overwrite the selection. + let store = StoreService() + let defaults = freshDefaults() + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let theme = ThemeService(store: store, defaults: defaults) + #expect(theme.current.id == Theme.ember.id) + + await store.load() + store.shutdown() + + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) + } + + @Test + func `refreshFromUserDefaults reverts an unowned restored theme once the store is loaded`() async { + // The post-load enforcement path: with a loaded store and no purchases, a restored paid + // theme is genuinely unowned, so refresh reverts it and overwrites the persisted value. + let store = StoreService() + await store.load() + store.shutdown() + let defaults = freshDefaults() + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let theme = ThemeService(store: store, defaults: defaults) + #expect(theme.current.id == Theme.ember.id) + + theme.refreshFromUserDefaults() + + #expect(theme.current.id == Theme.default.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } } diff --git a/MC1Tests/ThemedSurfaceRowFillTests.swift b/MC1Tests/ThemedSurfaceRowFillTests.swift index 731c60b1..9ddc6319 100644 --- a/MC1Tests/ThemedSurfaceRowFillTests.swift +++ b/MC1Tests/ThemedSurfaceRowFillTests.swift @@ -1,6 +1,6 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing /// Guards the row fill that emits no background in the iPad Settings sidebar (`flatten: true`), so /// sidebar rows stay transparent and keep the native `.sidebar` selection highlight, while @@ -8,29 +8,28 @@ import SwiftUI /// This is logic-only coverage; the rendered result is verified visually on device/simulator. @Suite("Themed surface row fill") struct ThemedSurfaceRowFillTests { + @Test + func `card theme paints rows with the card tier in a normal (non-flattened) context`() throws { + let surfaces = try #require(Theme.marine.surfaces) + #expect(surfaces.rowFill(flatten: false) == surfaces.card) + } - @Test("card theme paints rows with the card tier in a normal (non-flattened) context") - func cardThemeUsesCardByDefault() { - let surfaces = Theme.marine.surfaces! - #expect(surfaces.rowFill(flatten: false) == surfaces.card) - } - - @Test("card theme emits no row fill when flattened, so the iPad Settings sidebar keeps native selection") - func cardThemeFlattenedHasNoFill() { - let surfaces = Theme.marine.surfaces! - #expect(surfaces.rowFill(flatten: true) == nil) - } + @Test + func `card theme emits no row fill when flattened, so the iPad Settings sidebar keeps native selection`() throws { + let surfaces = try #require(Theme.marine.surfaces) + #expect(surfaces.rowFill(flatten: true) == nil) + } - @Test("canvas-only theme (Ember) leaves rows on the system tier regardless of flattening") - func cardlessSurfaceIsAlwaysNil() { - let surfaces = Theme.ember.surfaces! - #expect(surfaces.card == nil) - #expect(surfaces.rowFill(flatten: false) == nil) - #expect(surfaces.rowFill(flatten: true) == nil) - } + @Test + func `canvas-only theme (Ember) leaves rows on the system tier regardless of flattening`() throws { + let surfaces = try #require(Theme.ember.surfaces) + #expect(surfaces.card == nil) + #expect(surfaces.rowFill(flatten: false) == nil) + #expect(surfaces.rowFill(flatten: true) == nil) + } - @Test("default theme has no surfaces, so themed row backgrounds are a no-op") - func defaultThemeHasNoSurfaces() { - #expect(Theme.default.surfaces == nil) - } + @Test + func `default theme has no surfaces, so themed row backgrounds are a no-op`() { + #expect(Theme.default.surfaces == nil) + } } diff --git a/MC1Tests/Utilities/ChatCoordinateDetectorTests.swift b/MC1Tests/Utilities/ChatCoordinateDetectorTests.swift index 86602560..1a82119e 100644 --- a/MC1Tests/Utilities/ChatCoordinateDetectorTests.swift +++ b/MC1Tests/Utilities/ChatCoordinateDetectorTests.swift @@ -1,63 +1,62 @@ -import Testing import CoreLocation @testable import MC1 +import Testing @Suite("ChatCoordinateDetector Tests") struct ChatCoordinateDetectorTests { - - @Test("A valid decimal pair is detected") - func validPairDetected() { - let coord = ChatCoordinateDetector.firstCoordinate(in: "Meet at 37.334900, -122.009020 tonight") - #expect(coord != nil) - #expect(abs((coord?.latitude ?? 0) - 37.3349) < 0.000001) - #expect(abs((coord?.longitude ?? 0) - (-122.00902)) < 0.000001) - } - - @Test("An integer pair is not detected") - func integerPairRejected() { - #expect(ChatCoordinateDetector.firstCoordinate(in: "ratio is 3, 4 today") == nil) - } - - @Test("An out-of-range pair is rejected by the clamp, not the regex") - func outOfRangeRejected() { - // `200.0, 400.0` passes the \d{1,3} regex; only the -90...90 / -180...180 - // clamp rejects it. This is the sole validity gate for the thumbnail path. - #expect(ChatCoordinateDetector.firstCoordinate(in: "bad 200.0, 400.0 coord") == nil) - } - - @Test("A three-number decimal list is treated as a list, not a coordinate") - func decimalListRejected() { - #expect(ChatCoordinateDetector.firstCoordinate(in: "values 1.0, 2.0, 3.0 here") == nil) - } - - @Test("A version-like string is not detected") - func versionLikeRejected() { - #expect(ChatCoordinateDetector.firstCoordinate(in: "v1.2, 3.4 release") == nil) - } - - @Test("firstCoordinate returns nil when there is no coordinate") - func noneReturnsNil() { - #expect(ChatCoordinateDetector.firstCoordinate(in: "no coordinates here") == nil) - } - - @Test("firstCoordinate returns the first of several coordinates") - func firstOfMany() { - let coord = ChatCoordinateDetector.firstCoordinate(in: "A 10.0, 20.0 and B 30.0, 40.0") - #expect(coord?.latitude == 10.0) - #expect(coord?.longitude == 20.0) - } - - @Test("matches returns every valid coordinate in document order") - func matchesInOrder() { - let matches = ChatCoordinateDetector.matches(in: "A 10.0, 20.0 and B 30.0, 40.0") - #expect(matches.count == 2) - #expect(matches.first?.coordinate.latitude == 10.0) - #expect(matches.last?.coordinate.latitude == 30.0) - } - - @Test("A coordinate ending a sentence is detected, period excluded") - func trailingPeriod() { - let coord = ChatCoordinateDetector.firstCoordinate(in: "Meet at 37.7749, -122.4194.") - #expect(coord?.latitude == 37.7749) - } + @Test + func `A valid decimal pair is detected`() { + let coord = ChatCoordinateDetector.firstCoordinate(in: "Meet at 37.334900, -122.009020 tonight") + #expect(coord != nil) + #expect(abs((coord?.latitude ?? 0) - 37.3349) < 0.000001) + #expect(abs((coord?.longitude ?? 0) - -122.00902) < 0.000001) + } + + @Test + func `An integer pair is not detected`() { + #expect(ChatCoordinateDetector.firstCoordinate(in: "ratio is 3, 4 today") == nil) + } + + @Test + func `An out-of-range pair is rejected by the clamp, not the regex`() { + // `200.0, 400.0` passes the \d{1,3} regex; only the -90...90 / -180...180 + // clamp rejects it. This is the sole validity gate for the thumbnail path. + #expect(ChatCoordinateDetector.firstCoordinate(in: "bad 200.0, 400.0 coord") == nil) + } + + @Test + func `A three-number decimal list is treated as a list, not a coordinate`() { + #expect(ChatCoordinateDetector.firstCoordinate(in: "values 1.0, 2.0, 3.0 here") == nil) + } + + @Test + func `A version-like string is not detected`() { + #expect(ChatCoordinateDetector.firstCoordinate(in: "v1.2, 3.4 release") == nil) + } + + @Test + func `firstCoordinate returns nil when there is no coordinate`() { + #expect(ChatCoordinateDetector.firstCoordinate(in: "no coordinates here") == nil) + } + + @Test + func `firstCoordinate returns the first of several coordinates`() { + let coord = ChatCoordinateDetector.firstCoordinate(in: "A 10.0, 20.0 and B 30.0, 40.0") + #expect(coord?.latitude == 10.0) + #expect(coord?.longitude == 20.0) + } + + @Test + func `matches returns every valid coordinate in document order`() { + let matches = ChatCoordinateDetector.matches(in: "A 10.0, 20.0 and B 30.0, 40.0") + #expect(matches.count == 2) + #expect(matches.first?.coordinate.latitude == 10.0) + #expect(matches.last?.coordinate.latitude == 30.0) + } + + @Test + func `A coordinate ending a sentence is detected, period excluded`() { + let coord = ChatCoordinateDetector.firstCoordinate(in: "Meet at 37.7749, -122.4194.") + #expect(coord?.latitude == 37.7749) + } } diff --git a/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift b/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift index 2eedf6be..9b1226ab 100644 --- a/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift +++ b/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift @@ -1,61 +1,60 @@ -import Testing import Foundation @testable import MC1 +import Testing @Suite("ChatScrollToMentionPolicy Tests") struct ChatScrollToMentionPolicyTests { - - @Test("shouldScrollToBottom returns false when mentionTargetID is nil") - func mentionTargetIDNilReturnsFalse() { - let newestID = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: nil, newestItemID: newestID) == false) - } - - @Test("shouldScrollToBottom returns false when newestItemID is nil") - func newestItemIDNilReturnsFalse() { - let mentionID = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: nil) == false) - } - - @Test("shouldScrollToBottom returns false when IDs differ") - func differentIDsReturnFalse() { - let mentionID = UUID() - let newestID = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: newestID) == false) - } - - @Test("shouldScrollToBottom returns true when IDs match") - func matchingIDsReturnTrue() { - let id = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: id, newestItemID: id) == true) - } - - @Test("nextTarget returns nil when there are no off-screen mentions") - func nextTargetEmptyReturnsNil() { - #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: []) == nil) - } - - @Test("nextTarget picks the newest (last) of an oldest-to-newest list") - func nextTargetPicksNewest() { - let oldest = UUID() - let middle = UUID() - let newest = UUID() - #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: [oldest, middle, newest]) == newest) + @Test + func `shouldScrollToBottom returns false when mentionTargetID is nil`() { + let newestID = UUID() + #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: nil, newestItemID: newestID) == false) + } + + @Test + func `shouldScrollToBottom returns false when newestItemID is nil`() { + let mentionID = UUID() + #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: nil) == false) + } + + @Test + func `shouldScrollToBottom returns false when IDs differ`() { + let mentionID = UUID() + let newestID = UUID() + #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: newestID) == false) + } + + @Test + func `shouldScrollToBottom returns true when IDs match`() { + let id = UUID() + #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: id, newestItemID: id) == true) + } + + @Test + func `nextTarget returns nil when there are no off-screen mentions`() { + #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: []) == nil) + } + + @Test + func `nextTarget picks the newest (last) of an oldest-to-newest list`() { + let oldest = UUID() + let middle = UUID() + let newest = UUID() + #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: [oldest, middle, newest]) == newest) + } + + @Test + func `nextTarget walks upward to the earliest as newer targets are consumed`() { + let oldest = UUID() + let middle = UUID() + let newest = UUID() + var remaining = [oldest, middle, newest] + + var visited: [UUID] = [] + while let target = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: remaining) { + visited.append(target) + remaining.removeAll { $0 == target } } - @Test("nextTarget walks upward to the earliest as newer targets are consumed") - func nextTargetWalksUpward() { - let oldest = UUID() - let middle = UUID() - let newest = UUID() - var remaining = [oldest, middle, newest] - - var visited: [UUID] = [] - while let target = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: remaining) { - visited.append(target) - remaining.removeAll { $0 == target } - } - - #expect(visited == [newest, middle, oldest]) - } + #expect(visited == [newest, middle, oldest]) + } } diff --git a/MC1Tests/Utilities/DemoModeManagerTests.swift b/MC1Tests/Utilities/DemoModeManagerTests.swift index a1e26746..9d246699 100644 --- a/MC1Tests/Utilities/DemoModeManagerTests.swift +++ b/MC1Tests/Utilities/DemoModeManagerTests.swift @@ -1,92 +1,91 @@ -import Testing import Foundation @testable import MC1 +import Testing -@Suite("DemoModeManager Tests") +@Suite("DemoModeManager Tests", .serialized) @MainActor struct DemoModeManagerTests { + private let defaults: UserDefaults - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } - // MARK: - Singleton Pattern Tests + // MARK: - Singleton Pattern Tests - @Test("shared returns the same instance", .serialized) - func testSingletonPattern() { - let instance1 = DemoModeManager.shared - let instance2 = DemoModeManager.shared - #expect(instance1 === instance2) - } + @Test + func `shared returns the same instance`() { + let instance1 = DemoModeManager.shared + let instance2 = DemoModeManager.shared + #expect(instance1 === instance2) + } - // MARK: - Default Values Tests + // MARK: - Default Values Tests - @Test("properties default to false for new instances") - func testDefaultValues() { - let manager = DemoModeManager(defaults: defaults) - #expect(manager.isUnlocked == false) - #expect(manager.isEnabled == false) - } + @Test + func `properties default to false for new instances`() { + let manager = DemoModeManager(defaults: defaults) + #expect(manager.isUnlocked == false) + #expect(manager.isEnabled == false) + } - // MARK: - unlock() Method Tests + // MARK: - unlock() Method Tests - @Test("unlock sets both isUnlocked and isEnabled to true") - func testUnlockSetsBothFlags() { - let manager = DemoModeManager(defaults: defaults) + @Test + func `unlock sets both isUnlocked and isEnabled to true`() { + let manager = DemoModeManager(defaults: defaults) - #expect(manager.isUnlocked == false) - #expect(manager.isEnabled == false) + #expect(manager.isUnlocked == false) + #expect(manager.isEnabled == false) - manager.unlock() + manager.unlock() - #expect(manager.isUnlocked == true) - #expect(manager.isEnabled == true) - } + #expect(manager.isUnlocked == true) + #expect(manager.isEnabled == true) + } - // MARK: - UserDefaults Persistence Tests + // MARK: - UserDefaults Persistence Tests - @Test("UserDefaults persistence works for isUnlocked") - func testUserDefaultsPersistenceForIsUnlocked() { - let manager = DemoModeManager(defaults: defaults) + @Test + func `UserDefaults persistence works for isUnlocked`() { + let manager = DemoModeManager(defaults: defaults) - manager.isUnlocked = true + manager.isUnlocked = true - let persistedValue = defaults.bool(forKey: "isDemoModeUnlocked") - #expect(persistedValue == true) - } + let persistedValue = defaults.bool(forKey: "isDemoModeUnlocked") + #expect(persistedValue == true) + } - @Test("UserDefaults persistence works for isEnabled") - func testUserDefaultsPersistenceForIsEnabled() { - let manager = DemoModeManager(defaults: defaults) + @Test + func `UserDefaults persistence works for isEnabled`() { + let manager = DemoModeManager(defaults: defaults) - manager.isEnabled = true + manager.isEnabled = true - let persistedValue = defaults.bool(forKey: "isDemoModeEnabled") - #expect(persistedValue == true) - } + let persistedValue = defaults.bool(forKey: "isDemoModeEnabled") + #expect(persistedValue == true) + } - @Test("unlock persists both values to UserDefaults") - func testUnlockPersistsToUserDefaults() { - let manager = DemoModeManager(defaults: defaults) + @Test + func `unlock persists both values to UserDefaults`() { + let manager = DemoModeManager(defaults: defaults) - manager.unlock() + manager.unlock() - let unlockedValue = defaults.bool(forKey: "isDemoModeUnlocked") - let enabledValue = defaults.bool(forKey: "isDemoModeEnabled") + let unlockedValue = defaults.bool(forKey: "isDemoModeUnlocked") + let enabledValue = defaults.bool(forKey: "isDemoModeEnabled") - #expect(unlockedValue == true) - #expect(enabledValue == true) - } + #expect(unlockedValue == true) + #expect(enabledValue == true) + } - @Test("values persist and can be read back") - func testPersistenceReadBack() { - defaults.set(true, forKey: "isDemoModeUnlocked") - defaults.set(true, forKey: "isDemoModeEnabled") + @Test + func `values persist and can be read back`() { + defaults.set(true, forKey: "isDemoModeUnlocked") + defaults.set(true, forKey: "isDemoModeEnabled") - let manager = DemoModeManager(defaults: defaults) - #expect(manager.isUnlocked == true) - #expect(manager.isEnabled == true) - } + let manager = DemoModeManager(defaults: defaults) + #expect(manager.isUnlocked == true) + #expect(manager.isEnabled == true) + } } diff --git a/MC1Tests/Utilities/FirmwareSuggestedTimeoutTests.swift b/MC1Tests/Utilities/FirmwareSuggestedTimeoutTests.swift index 1cd60eab..20c55765 100644 --- a/MC1Tests/Utilities/FirmwareSuggestedTimeoutTests.swift +++ b/MC1Tests/Utilities/FirmwareSuggestedTimeoutTests.swift @@ -1,29 +1,77 @@ -import Testing @testable import MC1 +import Testing @Suite("FirmwareSuggestedTimeout Sanitizing") struct FirmwareSuggestedTimeoutTests { - @Test("Accepts sane firmware timeout") - func acceptsSaneFirmwareTimeout() { - let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 5_000) - #expect(timeout == 6.0) - } - - @Test("Falls back on zero timeout") - func fallsBackOnZeroTimeout() { - let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 0) - #expect(timeout == 30.0) - } - - @Test("Falls back below minimum timeout") - func fallsBackBelowMinimumTimeout() { - let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 3_000) - #expect(timeout == 30.0) - } - - @Test("Falls back for absurdly large timeout") - func fallsBackForAbsurdlyLargeTimeout() { - let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 68_719_800) - #expect(timeout == 30.0) - } + private let tolerance = 0.0001 + + // MARK: - Zero-hop (direct single-neighbor ping) + + @Test + func `Zero-hop honors a small valid hint instead of inflating it`() { + // Firmware default preset (SF10/BW250) estimates ~3.2s for a zero-hop trace. + // A hint that small is valid, not implausible, so it is honored rather than + // raised to the no-hint default. + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 3200, profile: .zeroHop) + #expect(abs(timeout - 3.84) < tolerance) + #expect(timeout < 5.0) // below the no-hint default, not snapped up to it + } + + @Test + func `Zero-hop honors a fast-preset hint`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 1300, profile: .zeroHop) + #expect(abs(timeout - 1.56) < tolerance) + } + + @Test + func `Zero-hop floors a tiny hint`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 500, profile: .zeroHop) + #expect(timeout == 1.0) + } + + @Test + func `Zero-hop honors a slow max-range preset hint up to the ceiling`() { + // SF12/BW125 zero-hop round trips genuinely approach ~20s; it must not be + // clamped down, or a valid slow link reports a false no-response. + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 20000, profile: .zeroHop) + #expect(timeout == 24.0) + } + + @Test + func `Zero-hop defaults on a missing hint`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 0, profile: .zeroHop) + #expect(timeout == 5.0) + } + + @Test + func `Zero-hop caps an absurd hint`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 68_719_800, profile: .zeroHop) + #expect(timeout == 30.0) + } + + // MARK: - Flood (path discovery, user-built multi-hop traces) + + @Test + func `Flood honors a sane hint`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 5000, profile: .flood) + #expect(timeout == 6.0) + } + + @Test + func `Flood floors a small hint rather than rejecting it`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 3000, profile: .flood) + #expect(timeout == 5.0) + } + + @Test + func `Flood defaults on a missing hint`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 0, profile: .flood) + #expect(timeout == 30.0) + } + + @Test + func `Flood caps an absurd hint`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 68_719_800, profile: .flood) + #expect(timeout == 60.0) + } } diff --git a/MC1Tests/Utilities/MalwareDomainFilterTests.swift b/MC1Tests/Utilities/MalwareDomainFilterTests.swift index 51fd542e..9d9d45ff 100644 --- a/MC1Tests/Utilities/MalwareDomainFilterTests.swift +++ b/MC1Tests/Utilities/MalwareDomainFilterTests.swift @@ -1,71 +1,70 @@ -import Testing import Foundation @testable import MC1 +import Testing struct MalwareDomainFilterTests { + // MARK: - Basic Filtering - // MARK: - Basic Filtering - - @Test("Blocks domain from bundled list") - func blocksBundledDomain() async { - // Read a domain from the bundled list to test against. - // The test host is the app, so Bundle.main contains app resources. - guard let bundleURL = Bundle.main.url( - forResource: "urlhaus-filter-hosts-online", - withExtension: "txt" - ), - let text = try? String(contentsOf: bundleURL, encoding: .utf8) else { - Issue.record("Bundled malware domain list not found") - return - } - - // Find first non-comment, non-empty domain - var testDomain: String? - for line in text.split(separator: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } - let parts = trimmed.split(separator: " ", maxSplits: 1) - if parts.count == 2 { - testDomain = String(parts[1]) - break - } - } + @Test + func `Blocks domain from bundled list`() async { + // Read a domain from the bundled list to test against. + // The test host is the app, so Bundle.main contains app resources. + guard let bundleURL = Bundle.main.url( + forResource: "urlhaus-filter-hosts-online", + withExtension: "txt" + ), + let text = try? String(contentsOf: bundleURL, encoding: .utf8) else { + Issue.record("Bundled malware domain list not found") + return + } - guard let domain = testDomain else { - Issue.record("No valid domain found in bundled list") - return - } + // Find first non-comment, non-empty domain + var testDomain: String? + for line in text.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + let parts = trimmed.split(separator: " ", maxSplits: 1) + if parts.count == 2 { + testDomain = String(parts[1]) + break + } + } - let result = await MalwareDomainFilter.shared.isBlocked(domain) - #expect(result, "Domain '\(domain)' from bundled list should be blocked") + guard let domain = testDomain else { + Issue.record("No valid domain found in bundled list") + return } - @Test("Allows legitimate domains") - func allowsLegitimateDomains() async { - let safeDomains = [ - "apple.com", - "google.com", - "github.com", - "example.com", - "media.giphy.com" - ] + let result = await MalwareDomainFilter.shared.isBlocked(domain) + #expect(result, "Domain '\(domain)' from bundled list should be blocked") + } - for domain in safeDomains { - let result = await MalwareDomainFilter.shared.isBlocked(domain) - #expect(!result, "'\(domain)' should not be blocked") - } - } + @Test + func `Allows legitimate domains`() async { + let safeDomains = [ + "apple.com", + "google.com", + "github.com", + "example.com", + "media.giphy.com" + ] - @Test("Exact match only, no substring matching") - func exactMatchOnly() async { - // "evil.com" should not block "not-evil.com" - let result = await MalwareDomainFilter.shared.isBlocked("definitely-not-malware-\(UUID().uuidString).example.com") - #expect(!result, "Random domain should not be blocked") + for domain in safeDomains { + let result = await MalwareDomainFilter.shared.isBlocked(domain) + #expect(!result, "'\(domain)' should not be blocked") } + } - @Test("Empty host is not blocked") - func emptyHostNotBlocked() async { - let result = await MalwareDomainFilter.shared.isBlocked("") - #expect(!result, "Empty string should not be blocked") - } + @Test + func `Exact match only, no substring matching`() async { + // "evil.com" should not block "not-evil.com" + let result = await MalwareDomainFilter.shared.isBlocked("definitely-not-malware-\(UUID().uuidString).example.com") + #expect(!result, "Random domain should not be blocked") + } + + @Test + func `Empty host is not blocked`() async { + let result = await MalwareDomainFilter.shared.isBlocked("") + #expect(!result, "Empty string should not be blocked") + } } diff --git a/MC1Tests/Utilities/MentionInsertionTests.swift b/MC1Tests/Utilities/MentionInsertionTests.swift index b6f005e9..120ec0b9 100644 --- a/MC1Tests/Utilities/MentionInsertionTests.swift +++ b/MC1Tests/Utilities/MentionInsertionTests.swift @@ -1,66 +1,65 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("Mention Insertion Tests") struct MentionInsertionTests { + @Test + func `insertMention replaces @query with mention format`() throws { + var text = "hey @ali" + let query = try #require(MentionUtilities.detectActiveMention(in: text)) + let searchPattern = "@" + query - @Test("insertMention replaces @query with mention format") - func testInsertMention() { - var text = "hey @ali" - let query = MentionUtilities.detectActiveMention(in: text)! - let searchPattern = "@" + query - - if let range = text.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: "Alice") - text.replaceSubrange(range, with: mention + " ") - } - - #expect(text == "hey @[Alice] ") + if let range = text.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: "Alice") + text.replaceSubrange(range, with: mention + " ") } - @Test("insertMention handles query at start of text") - func testInsertMentionAtStart() { - var text = "@bob" - let query = MentionUtilities.detectActiveMention(in: text)! - let searchPattern = "@" + query + #expect(text == "hey @[Alice] ") + } - if let range = text.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: "Bob") - text.replaceSubrange(range, with: mention + " ") - } + @Test + func `insertMention handles query at start of text`() throws { + var text = "@bob" + let query = try #require(MentionUtilities.detectActiveMention(in: text)) + let searchPattern = "@" + query - #expect(text == "@[Bob] ") + if let range = text.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: "Bob") + text.replaceSubrange(range, with: mention + " ") } - @Test("insertMention preserves preceding text") - func testInsertMentionPreservesText() { - var text = "Hello @[Alice] and @jo" - let query = MentionUtilities.detectActiveMention(in: text)! - let searchPattern = "@" + query + #expect(text == "@[Bob] ") + } - if let range = text.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: "John") - text.replaceSubrange(range, with: mention + " ") - } + @Test + func `insertMention preserves preceding text`() throws { + var text = "Hello @[Alice] and @jo" + let query = try #require(MentionUtilities.detectActiveMention(in: text)) + let searchPattern = "@" + query - #expect(text == "Hello @[Alice] and @[John] ") + if let range = text.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: "John") + text.replaceSubrange(range, with: mention + " ") } - @Test("insertMention uses contact name not nickname") - func testInsertMentionUsesNodeName() { - // Simulates: user searches "Bob" (nickname), selects contact with name "Bob's Solar Node" - var text = "@bob" - let contactNodeName = "Bob's Solar Node" + #expect(text == "Hello @[Alice] and @[John] ") + } - let query = MentionUtilities.detectActiveMention(in: text)! - let searchPattern = "@" + query + @Test + func `insertMention uses contact name not nickname`() throws { + // Simulates: user searches "Bob" (nickname), selects contact with name "Bob's Solar Node" + var text = "@bob" + let contactNodeName = "Bob's Solar Node" - if let range = text.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: contactNodeName) - text.replaceSubrange(range, with: mention + " ") - } + let query = try #require(MentionUtilities.detectActiveMention(in: text)) + let searchPattern = "@" + query - #expect(text == "@[Bob's Solar Node] ") + if let range = text.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: contactNodeName) + text.replaceSubrange(range, with: mention + " ") } + + #expect(text == "@[Bob's Solar Node] ") + } } diff --git a/MC1Tests/Utilities/MentionUtilitiesTests.swift b/MC1Tests/Utilities/MentionUtilitiesTests.swift index 8acdae3a..92c64b93 100644 --- a/MC1Tests/Utilities/MentionUtilitiesTests.swift +++ b/MC1Tests/Utilities/MentionUtilitiesTests.swift @@ -1,391 +1,390 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("MentionUtilities Tests") struct MentionUtilitiesTests { - - // MARK: - createMention Tests - - @Test("createMention creates correct format") - func testCreateMention() { - let mention = MentionUtilities.createMention(for: "Alice") - #expect(mention == "@[Alice]") - } - - @Test("createMention handles names with spaces") - func testCreateMentionWithSpaces() { - let mention = MentionUtilities.createMention(for: "My Node") - #expect(mention == "@[My Node]") - } - - @Test("createMention handles special characters") - func testCreateMentionWithSpecialChars() { - let mention = MentionUtilities.createMention(for: "Node-123") - #expect(mention == "@[Node-123]") - } - - // MARK: - appendMention Tests - - @Test("appendMention into empty draft yields just the mention") - func testAppendMentionEmptyDraft() { - let result = MentionUtilities.appendMention(for: "Alice", to: "") - #expect(result == "@[Alice] ") - } - - @Test("appendMention preserves draft and adds a separating space") - func testAppendMentionPreservesDraftWithoutTrailingSpace() { - let result = MentionUtilities.appendMention(for: "Alice", to: "hello") - #expect(result == "hello @[Alice] ") - } - - @Test("appendMention does not double the space when draft ends in whitespace") - func testAppendMentionDraftWithTrailingSpace() { - let result = MentionUtilities.appendMention(for: "Alice", to: "hello ") - #expect(result == "hello @[Alice] ") - } - - @Test("createMention handles empty name") - func testCreateMentionEmpty() { - let mention = MentionUtilities.createMention(for: "") - #expect(mention == "@[]") - } - - // MARK: - extractMentions Tests - - @Test("extractMentions parses single mention") - func testExtractSingleMention() { - let mentions = MentionUtilities.extractMentions(from: "@[Alice] hello!") - #expect(mentions == ["Alice"]) - } - - @Test("extractMentions parses multiple mentions") - func testExtractMultipleMentions() { - let mentions = MentionUtilities.extractMentions(from: "@[Alice] and @[Bob] hello!") - #expect(mentions == ["Alice", "Bob"]) - } - - @Test("extractMentions returns empty for no mentions") - func testExtractNoMentions() { - let mentions = MentionUtilities.extractMentions(from: "Hello world!") - #expect(mentions.isEmpty) - } - - @Test("extractMentions handles names with spaces") - func testExtractMentionWithSpaces() { - let mentions = MentionUtilities.extractMentions(from: "@[My Node] says hi") - #expect(mentions == ["My Node"]) - } - - @Test("extractMentions handles special characters") - func testExtractMentionWithSpecialChars() { - let mentions = MentionUtilities.extractMentions(from: "@[Node-123] testing") - #expect(mentions == ["Node-123"]) - } - - @Test("extractMentions handles adjacent mentions") - func testExtractAdjacentMentions() { - let mentions = MentionUtilities.extractMentions(from: "@[Alice]@[Bob]") - #expect(mentions == ["Alice", "Bob"]) - } - - @Test("extractMentions ignores malformed patterns") - func testExtractMalformedPatterns() { - // Missing closing bracket - let mentions1 = MentionUtilities.extractMentions(from: "@[Alice hello") - #expect(mentions1.isEmpty) - - // Missing opening bracket - let mentions2 = MentionUtilities.extractMentions(from: "@Alice] hello") - #expect(mentions2.isEmpty) - - // Just @ symbol - let mentions3 = MentionUtilities.extractMentions(from: "@ hello") - #expect(mentions3.isEmpty) - } - - @Test("extractMentions handles empty message") - func testExtractFromEmptyMessage() { - let mentions = MentionUtilities.extractMentions(from: "") - #expect(mentions.isEmpty) - } - - @Test("extractMentions handles Unicode names") - func testExtractUnicodeMentions() { - let mentions = MentionUtilities.extractMentions(from: "@[日本語] hello") - #expect(mentions == ["日本語"]) - } - - // MARK: - detectActiveMention Tests - - @Test("detectActiveMention returns nil for empty text") - func testDetectActiveMentionEmpty() { - let result = MentionUtilities.detectActiveMention(in: "") - #expect(result == nil) - } - - @Test("detectActiveMention returns nil for text without @") - func testDetectActiveMentionNoAt() { - let result = MentionUtilities.detectActiveMention(in: "hello world") - #expect(result == nil) - } - - @Test("detectActiveMention returns empty string for @ alone") - func testDetectActiveMentionAtAlone() { - let result = MentionUtilities.detectActiveMention(in: "@") - #expect(result == "") - } - - @Test("detectActiveMention returns query after @") - func testDetectActiveMentionBasic() { - let result = MentionUtilities.detectActiveMention(in: "@jo") - #expect(result == "jo") - } - - @Test("detectActiveMention works at start of message") - func testDetectActiveMentionAtStart() { - let result = MentionUtilities.detectActiveMention(in: "@alice") - #expect(result == "alice") - } - - @Test("detectActiveMention works after space") - func testDetectActiveMentionAfterSpace() { - let result = MentionUtilities.detectActiveMention(in: "hey @bob") - #expect(result == "bob") - } - - @Test("detectActiveMention returns nil for @ mid-word") - func testDetectActiveMentionMidWord() { - let result = MentionUtilities.detectActiveMention(in: "email@domain") - #expect(result == nil) - } - - @Test("detectActiveMention returns nil when space follows @") - func testDetectActiveMentionSpaceAfter() { - let result = MentionUtilities.detectActiveMention(in: "@ hello") - #expect(result == nil) - } - - @Test("detectActiveMention returns last active mention") - func testDetectActiveMentionMultiple() { - let result = MentionUtilities.detectActiveMention(in: "@[Alice] hey @bo") - #expect(result == "bo") - } - - @Test("detectActiveMention returns nil for completed mention") - func testDetectActiveMentionCompleted() { - let result = MentionUtilities.detectActiveMention(in: "@[Alice] hello") - #expect(result == nil) - } - - @Test("detectActiveMention handles Unicode") - func testDetectActiveMentionUnicode() { - let result = MentionUtilities.detectActiveMention(in: "@日本") - #expect(result == "日本") - } - - @Test("detectActiveMention ignores email addresses") - func testDetectActiveMentionEmail() { - let result = MentionUtilities.detectActiveMention(in: "contact me at test@example.com") - #expect(result == nil) - } - - @Test("detectActiveMention handles double @ symbols") - func testDetectActiveMentionDoubleAt() { - let result = MentionUtilities.detectActiveMention(in: "@@alice") - #expect(result == nil) - } - - @Test("detectActiveMention returns nil for unclosed bracket") - func testDetectActiveMentionUnclosedBracket() { - let result = MentionUtilities.detectActiveMention(in: "@[Alice") - #expect(result == nil) - } - - // MARK: - filterContacts Tests - - private func makeContact( - name: String, - type: ContactType = .chat, - publicKey: Data = Data([0xAB]) - ) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: publicKey, - name: name, - typeRawValue: type.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - } - - @Test("filterContacts matches using localizedStandardContains") - func testFilterContactsMatches() { - let contacts = [ - makeContact(name: "Alice"), - makeContact(name: "Bob"), - makeContact(name: "Amanda") - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "a") - #expect(filtered.count == 2) - #expect(filtered.map(\.name).contains("Alice")) - #expect(filtered.map(\.name).contains("Amanda")) - } - - @Test("filterContacts excludes repeaters") - func testFilterContactsExcludesRepeaters() { - let contacts = [ - makeContact(name: "Alice", type: .chat), - makeContact(name: "Repeater1", type: .repeater) - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "") - #expect(filtered.count == 1) - #expect(filtered.first?.name == "Alice") - } - - @Test("filterContacts excludes rooms") - func testFilterContactsExcludesRooms() { - let contacts = [ - makeContact(name: "Alice", type: .chat), - makeContact(name: "Room1", type: .room) - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "") - #expect(filtered.count == 1) - } - - @Test("filterContacts sorts alphabetically") - func testFilterContactsSortsAlphabetically() { - let contacts = [ - makeContact(name: "Zoe"), - makeContact(name: "Alice"), - makeContact(name: "Bob") - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "") - #expect(filtered.map(\.name) == ["Alice", "Bob", "Zoe"]) - } - - @Test("filterContacts returns empty for no matches") - func testFilterContactsNoMatches() { - let contacts = [makeContact(name: "Alice")] - let filtered = MentionUtilities.filterContacts(contacts, query: "xyz") - #expect(filtered.isEmpty) - } - - @Test("filterContacts handles empty input") - func testFilterContactsEmptyInput() { - let filtered = MentionUtilities.filterContacts([], query: "a") - #expect(filtered.isEmpty) - } - - @Test("filterContacts sorts by sender order when provided") - func testFilterContactsSortsBySenderOrder() { - let contacts = [ - makeContact(name: "Alice"), - makeContact(name: "Bob"), - makeContact(name: "Charlie") - ] - let senderOrder: [String: UInt32] = [ - "Charlie": 300, - "Alice": 200, - "Bob": 100 - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "", senderOrder: senderOrder) - #expect(filtered.map(\.name) == ["Charlie", "Alice", "Bob"]) - } - - @Test("filterContacts sender order partial match: ordered first, then alphabetical") - func testFilterContactsSenderOrderPartialMatch() { - let contacts = [ - makeContact(name: "Zoe"), - makeContact(name: "Alice"), - makeContact(name: "Bob"), - makeContact(name: "Dan") - ] - // Only Bob and Alice have timestamps; Zoe and Dan don't - let senderOrder: [String: UInt32] = [ - "Bob": 500, - "Alice": 100 - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "", senderOrder: senderOrder) - // Bob (500) first, Alice (100) second, then Dan and Zoe alphabetically - #expect(filtered.map(\.name) == ["Bob", "Alice", "Dan", "Zoe"]) - } - - // MARK: - containsSelfMention Tests - - @Test("containsSelfMention returns true for exact match") - func testContainsSelfMentionExact() { - let result = MentionUtilities.containsSelfMention(in: "Hello @[Alice]!", selfName: "Alice") - #expect(result == true) - } - - @Test("containsSelfMention is case insensitive") - func testContainsSelfMentionCaseInsensitive() { - #expect(MentionUtilities.containsSelfMention(in: "@[ALICE]", selfName: "alice")) - #expect(MentionUtilities.containsSelfMention(in: "@[alice]", selfName: "ALICE")) - #expect(MentionUtilities.containsSelfMention(in: "@[Alice]", selfName: "aLiCe")) - } - - @Test("containsSelfMention returns false for different name") - func testContainsSelfMentionDifferentName() { - let result = MentionUtilities.containsSelfMention(in: "@[Bob] hello", selfName: "Alice") - #expect(result == false) - } - - @Test("containsSelfMention handles multiple mentions") - func testContainsSelfMentionMultiple() { - // Self mention is second - #expect(MentionUtilities.containsSelfMention(in: "@[Bob] @[Alice]", selfName: "Alice")) - // Self mention is first - #expect(MentionUtilities.containsSelfMention(in: "@[Alice] @[Bob]", selfName: "Alice")) - } - - @Test("containsSelfMention returns false for empty text") - func testContainsSelfMentionEmptyText() { - let result = MentionUtilities.containsSelfMention(in: "", selfName: "Alice") - #expect(result == false) - } - - @Test("containsSelfMention returns false for empty selfName") - func testContainsSelfMentionEmptySelfName() { - let result = MentionUtilities.containsSelfMention(in: "@[Alice]", selfName: "") - #expect(result == false) - } - - @Test("containsSelfMention handles names with spaces") - func testContainsSelfMentionWithSpaces() { - let result = MentionUtilities.containsSelfMention(in: "@[My Node] hello", selfName: "My Node") - #expect(result == true) - } - - @Test("containsSelfMention handles special characters") - func testContainsSelfMentionSpecialChars() { - let result = MentionUtilities.containsSelfMention(in: "@[Node-123] test", selfName: "Node-123") - #expect(result == true) - } - - @Test("containsSelfMention returns false for partial match") - func testContainsSelfMentionPartialMatch() { - // "Ali" should not match "Alice" - let result = MentionUtilities.containsSelfMention(in: "@[Ali]", selfName: "Alice") - #expect(result == false) - } - - @Test("containsSelfMention returns false for text without mentions") - func testContainsSelfMentionNoMentions() { - let result = MentionUtilities.containsSelfMention(in: "Hello world", selfName: "Alice") - #expect(result == false) - } + // MARK: - createMention Tests + + @Test + func `createMention creates correct format`() { + let mention = MentionUtilities.createMention(for: "Alice") + #expect(mention == "@[Alice]") + } + + @Test + func `createMention handles names with spaces`() { + let mention = MentionUtilities.createMention(for: "My Node") + #expect(mention == "@[My Node]") + } + + @Test + func `createMention handles special characters`() { + let mention = MentionUtilities.createMention(for: "Node-123") + #expect(mention == "@[Node-123]") + } + + // MARK: - appendMention Tests + + @Test + func `appendMention into empty draft yields just the mention`() { + let result = MentionUtilities.appendMention(for: "Alice", to: "") + #expect(result == "@[Alice] ") + } + + @Test + func `appendMention preserves draft and adds a separating space`() { + let result = MentionUtilities.appendMention(for: "Alice", to: "hello") + #expect(result == "hello @[Alice] ") + } + + @Test + func `appendMention does not double the space when draft ends in whitespace`() { + let result = MentionUtilities.appendMention(for: "Alice", to: "hello ") + #expect(result == "hello @[Alice] ") + } + + @Test + func `createMention handles empty name`() { + let mention = MentionUtilities.createMention(for: "") + #expect(mention == "@[]") + } + + // MARK: - extractMentions Tests + + @Test + func `extractMentions parses single mention`() { + let mentions = MentionUtilities.extractMentions(from: "@[Alice] hello!") + #expect(mentions == ["Alice"]) + } + + @Test + func `extractMentions parses multiple mentions`() { + let mentions = MentionUtilities.extractMentions(from: "@[Alice] and @[Bob] hello!") + #expect(mentions == ["Alice", "Bob"]) + } + + @Test + func `extractMentions returns empty for no mentions`() { + let mentions = MentionUtilities.extractMentions(from: "Hello world!") + #expect(mentions.isEmpty) + } + + @Test + func `extractMentions handles names with spaces`() { + let mentions = MentionUtilities.extractMentions(from: "@[My Node] says hi") + #expect(mentions == ["My Node"]) + } + + @Test + func `extractMentions handles special characters`() { + let mentions = MentionUtilities.extractMentions(from: "@[Node-123] testing") + #expect(mentions == ["Node-123"]) + } + + @Test + func `extractMentions handles adjacent mentions`() { + let mentions = MentionUtilities.extractMentions(from: "@[Alice]@[Bob]") + #expect(mentions == ["Alice", "Bob"]) + } + + @Test + func `extractMentions ignores malformed patterns`() { + // Missing closing bracket + let mentions1 = MentionUtilities.extractMentions(from: "@[Alice hello") + #expect(mentions1.isEmpty) + + // Missing opening bracket + let mentions2 = MentionUtilities.extractMentions(from: "@Alice] hello") + #expect(mentions2.isEmpty) + + // Just @ symbol + let mentions3 = MentionUtilities.extractMentions(from: "@ hello") + #expect(mentions3.isEmpty) + } + + @Test + func `extractMentions handles empty message`() { + let mentions = MentionUtilities.extractMentions(from: "") + #expect(mentions.isEmpty) + } + + @Test + func `extractMentions handles Unicode names`() { + let mentions = MentionUtilities.extractMentions(from: "@[日本語] hello") + #expect(mentions == ["日本語"]) + } + + // MARK: - detectActiveMention Tests + + @Test + func `detectActiveMention returns nil for empty text`() { + let result = MentionUtilities.detectActiveMention(in: "") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns nil for text without @`() { + let result = MentionUtilities.detectActiveMention(in: "hello world") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns empty string for @ alone`() { + let result = MentionUtilities.detectActiveMention(in: "@") + #expect(result == "") + } + + @Test + func `detectActiveMention returns query after @`() { + let result = MentionUtilities.detectActiveMention(in: "@jo") + #expect(result == "jo") + } + + @Test + func `detectActiveMention works at start of message`() { + let result = MentionUtilities.detectActiveMention(in: "@alice") + #expect(result == "alice") + } + + @Test + func `detectActiveMention works after space`() { + let result = MentionUtilities.detectActiveMention(in: "hey @bob") + #expect(result == "bob") + } + + @Test + func `detectActiveMention returns nil for @ mid-word`() { + let result = MentionUtilities.detectActiveMention(in: "email@domain") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns nil when space follows @`() { + let result = MentionUtilities.detectActiveMention(in: "@ hello") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns last active mention`() { + let result = MentionUtilities.detectActiveMention(in: "@[Alice] hey @bo") + #expect(result == "bo") + } + + @Test + func `detectActiveMention returns nil for completed mention`() { + let result = MentionUtilities.detectActiveMention(in: "@[Alice] hello") + #expect(result == nil) + } + + @Test + func `detectActiveMention handles Unicode`() { + let result = MentionUtilities.detectActiveMention(in: "@日本") + #expect(result == "日本") + } + + @Test + func `detectActiveMention ignores email addresses`() { + let result = MentionUtilities.detectActiveMention(in: "contact me at test@example.com") + #expect(result == nil) + } + + @Test + func `detectActiveMention handles double @ symbols`() { + let result = MentionUtilities.detectActiveMention(in: "@@alice") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns nil for unclosed bracket`() { + let result = MentionUtilities.detectActiveMention(in: "@[Alice") + #expect(result == nil) + } + + // MARK: - filterContacts Tests + + private func makeContact( + name: String, + type: ContactType = .chat, + publicKey: Data = Data([0xAB]) + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: publicKey, + name: name, + typeRawValue: type.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } + + @Test + func `filterContacts matches using localizedStandardContains`() { + let contacts = [ + makeContact(name: "Alice"), + makeContact(name: "Bob"), + makeContact(name: "Amanda") + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "a") + #expect(filtered.count == 2) + #expect(filtered.map(\.name).contains("Alice")) + #expect(filtered.map(\.name).contains("Amanda")) + } + + @Test + func `filterContacts excludes repeaters`() { + let contacts = [ + makeContact(name: "Alice", type: .chat), + makeContact(name: "Repeater1", type: .repeater) + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "") + #expect(filtered.count == 1) + #expect(filtered.first?.name == "Alice") + } + + @Test + func `filterContacts excludes rooms`() { + let contacts = [ + makeContact(name: "Alice", type: .chat), + makeContact(name: "Room1", type: .room) + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "") + #expect(filtered.count == 1) + } + + @Test + func `filterContacts sorts alphabetically`() { + let contacts = [ + makeContact(name: "Zoe"), + makeContact(name: "Alice"), + makeContact(name: "Bob") + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "") + #expect(filtered.map(\.name) == ["Alice", "Bob", "Zoe"]) + } + + @Test + func `filterContacts returns empty for no matches`() { + let contacts = [makeContact(name: "Alice")] + let filtered = MentionUtilities.filterContacts(contacts, query: "xyz") + #expect(filtered.isEmpty) + } + + @Test + func `filterContacts handles empty input`() { + let filtered = MentionUtilities.filterContacts([], query: "a") + #expect(filtered.isEmpty) + } + + @Test + func `filterContacts sorts by sender order when provided`() { + let contacts = [ + makeContact(name: "Alice"), + makeContact(name: "Bob"), + makeContact(name: "Charlie") + ] + let senderOrder: [String: UInt32] = [ + "Charlie": 300, + "Alice": 200, + "Bob": 100 + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "", senderOrder: senderOrder) + #expect(filtered.map(\.name) == ["Charlie", "Alice", "Bob"]) + } + + @Test + func `filterContacts sender order partial match: ordered first, then alphabetical`() { + let contacts = [ + makeContact(name: "Zoe"), + makeContact(name: "Alice"), + makeContact(name: "Bob"), + makeContact(name: "Dan") + ] + // Only Bob and Alice have timestamps; Zoe and Dan don't + let senderOrder: [String: UInt32] = [ + "Bob": 500, + "Alice": 100 + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "", senderOrder: senderOrder) + // Bob (500) first, Alice (100) second, then Dan and Zoe alphabetically + #expect(filtered.map(\.name) == ["Bob", "Alice", "Dan", "Zoe"]) + } + + // MARK: - containsSelfMention Tests + + @Test + func `containsSelfMention returns true for exact match`() { + let result = MentionUtilities.containsSelfMention(in: "Hello @[Alice]!", selfName: "Alice") + #expect(result == true) + } + + @Test + func `containsSelfMention is case insensitive`() { + #expect(MentionUtilities.containsSelfMention(in: "@[ALICE]", selfName: "alice")) + #expect(MentionUtilities.containsSelfMention(in: "@[alice]", selfName: "ALICE")) + #expect(MentionUtilities.containsSelfMention(in: "@[Alice]", selfName: "aLiCe")) + } + + @Test + func `containsSelfMention returns false for different name`() { + let result = MentionUtilities.containsSelfMention(in: "@[Bob] hello", selfName: "Alice") + #expect(result == false) + } + + @Test + func `containsSelfMention handles multiple mentions`() { + // Self mention is second + #expect(MentionUtilities.containsSelfMention(in: "@[Bob] @[Alice]", selfName: "Alice")) + // Self mention is first + #expect(MentionUtilities.containsSelfMention(in: "@[Alice] @[Bob]", selfName: "Alice")) + } + + @Test + func `containsSelfMention returns false for empty text`() { + let result = MentionUtilities.containsSelfMention(in: "", selfName: "Alice") + #expect(result == false) + } + + @Test + func `containsSelfMention returns false for empty selfName`() { + let result = MentionUtilities.containsSelfMention(in: "@[Alice]", selfName: "") + #expect(result == false) + } + + @Test + func `containsSelfMention handles names with spaces`() { + let result = MentionUtilities.containsSelfMention(in: "@[My Node] hello", selfName: "My Node") + #expect(result == true) + } + + @Test + func `containsSelfMention handles special characters`() { + let result = MentionUtilities.containsSelfMention(in: "@[Node-123] test", selfName: "Node-123") + #expect(result == true) + } + + @Test + func `containsSelfMention returns false for partial match`() { + // "Ali" should not match "Alice" + let result = MentionUtilities.containsSelfMention(in: "@[Ali]", selfName: "Alice") + #expect(result == false) + } + + @Test + func `containsSelfMention returns false for text without mentions`() { + let result = MentionUtilities.containsSelfMention(in: "Hello world", selfName: "Alice") + #expect(result == false) + } } diff --git a/MC1Tests/Utilities/MeshCoreURLParserTests.swift b/MC1Tests/Utilities/MeshCoreURLParserTests.swift index 39a6024b..5ea17313 100644 --- a/MC1Tests/Utilities/MeshCoreURLParserTests.swift +++ b/MC1Tests/Utilities/MeshCoreURLParserTests.swift @@ -1,119 +1,118 @@ -import Testing -import Foundation import CoreLocation -@testable import MC1Services +import Foundation @testable import MC1 +@testable import MC1Services +import Testing @Suite("MeshCoreURLParser Tests") struct MeshCoreURLParserTests { - - /// A real 32-byte public key rendered as uppercase hex (64 chars). - static let validHex = "A1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" - - /// A second, distinct 32-byte key used to prove an injected key never wins. - static let otherHex = String(repeating: "BC", count: 32) - - /// Exports a contact then parses it back, exercising the full emit -> parse round-trip. - private static func roundTrip(name: String, type: ContactType = .chat) throws -> MeshCoreURLParser.ContactResult { - let key = try #require(Data(hexString: validHex)) - let uri = ContactService.exportContactURI(name: name, publicKey: key, type: type) - return try #require(MeshCoreURLParser.parseContactURL(uri), "round-trip should parse for name: \(name)") - } - - @Test("parseContactURL falls back to .chat for an out-of-range type without trapping") - func testOutOfRangeTypeFallsBackToChat() throws { - let url = "meshcore://contact/add?name=Node&public_key=\(Self.validHex)&type=300" - let result = try #require(MeshCoreURLParser.parseContactURL(url)) - #expect(result.name == "Node") - #expect(result.contactType == .chat) - } - - @Test("parseContactURL maps type=2 to .repeater") - func testRepeaterType() throws { - let url = "meshcore://contact/add?name=Node&public_key=\(Self.validHex)&type=2" - let result = try #require(MeshCoreURLParser.parseContactURL(url)) - #expect(result.contactType == .repeater) - } - - // MARK: - Export/parse round-trip and query injection - - @Test("A name carrying an injected public_key/type query does not override the declared key or type") - func exportNeutralizesQueryInjection() throws { - let spoofName = "Alice&public_key=\(Self.otherHex)&type=3" - let declaredKey = try #require(Data(hexString: Self.validHex)) - - let uri = ContactService.exportContactURI(name: spoofName, publicKey: declaredKey, type: .chat) - let result = try #require(MeshCoreURLParser.parseContactURL(uri)) - - #expect(result.publicKey == declaredKey, "Declared key must win over the injected public_key") - #expect(result.contactType == .chat, "Declared type must win over the injected type") - #expect(result.name == spoofName, "The whole injected string is the name, preserved verbatim") - } - - @Test("Names containing query-significant characters round-trip intact") - func exportRoundTripsQuerySignificantCharacters() throws { - for name in ["a & b", "a=b", "key & value = pair", "100% sure?", "a#b"] { - let result = try Self.roundTrip(name: name) - #expect(result.name == name, "name should survive round-trip: expected \(name), got \(result.name)") - } - } - - @Test("A literal plus in a name is preserved, not turned into a space") - func exportPreservesLiteralPlus() throws { - let result = try Self.roundTrip(name: "C++ dev") - #expect(result.name == "C++ dev") - } - - @Test("Spaces, colons, and unicode round-trip intact") - func exportRoundTripsSpacesColonsAndUnicode() throws { - for name in ["Field Base", "12:30 rally point", "Café au lait", "北京"] { - let result = try Self.roundTrip(name: name) - #expect(result.name == name, "name should survive round-trip: expected \(name), got \(result.name)") - } - } - - @Test("Every contact type round-trips through export and parse") - func exportRoundTripsContactType() throws { - for type in [ContactType.chat, .repeater, .room] { - let result = try Self.roundTrip(name: "Node", type: type) - #expect(result.contactType == type) - } + /// A real 32-byte public key rendered as uppercase hex (64 chars). + static let validHex = "A1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" + + /// A second, distinct 32-byte key used to prove an injected key never wins. + static let otherHex = String(repeating: "BC", count: 32) + + /// Exports a contact then parses it back, exercising the full emit -> parse round-trip. + private static func roundTrip(name: String, type: ContactType = .chat) throws -> MeshCoreURLParser.ContactResult { + let key = try #require(Data(hexString: validHex)) + let uri = ContactService.exportContactURI(name: name, publicKey: key, type: type) + return try #require(MeshCoreURLParser.parseContactURL(uri), "round-trip should parse for name: \(name)") + } + + @Test + func `parseContactURL falls back to .chat for an out-of-range type without trapping`() throws { + let url = "meshcore://contact/add?name=Node&public_key=\(Self.validHex)&type=300" + let result = try #require(MeshCoreURLParser.parseContactURL(url)) + #expect(result.name == "Node") + #expect(result.contactType == .chat) + } + + @Test + func `parseContactURL maps type=2 to .repeater`() throws { + let url = "meshcore://contact/add?name=Node&public_key=\(Self.validHex)&type=2" + let result = try #require(MeshCoreURLParser.parseContactURL(url)) + #expect(result.contactType == .repeater) + } + + // MARK: - Export/parse round-trip and query injection + + @Test + func `A name carrying an injected public_key/type query does not override the declared key or type`() throws { + let spoofName = "Alice&public_key=\(Self.otherHex)&type=3" + let declaredKey = try #require(Data(hexString: Self.validHex)) + + let uri = ContactService.exportContactURI(name: spoofName, publicKey: declaredKey, type: .chat) + let result = try #require(MeshCoreURLParser.parseContactURL(uri)) + + #expect(result.publicKey == declaredKey, "Declared key must win over the injected public_key") + #expect(result.contactType == .chat, "Declared type must win over the injected type") + #expect(result.name == spoofName, "The whole injected string is the name, preserved verbatim") + } + + @Test + func `Names containing query-significant characters round-trip intact`() throws { + for name in ["a & b", "a=b", "key & value = pair", "100% sure?", "a#b"] { + let result = try Self.roundTrip(name: name) + #expect(result.name == name, "name should survive round-trip: expected \(name), got \(result.name)") } - - // MARK: - parseMapURL - - @Test("parseMapURL parses a valid map link into the correct coordinate") - func mapURLValidRoundTrip() throws { - let url = "meshcore://map?lat=37.334900&lon=-122.009020" - let coordinate = try #require(MeshCoreURLParser.parseMapURL(url)) - #expect(abs(coordinate.latitude - 37.3349) < 0.000001) - #expect(abs(coordinate.longitude - (-122.00902)) < 0.000001) - } - - @Test("parseMapURL returns nil when lat or lon is missing") - func mapURLMissingParams() { - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=37.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lon=-122.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map") == nil) - } - - @Test("parseMapURL rejects out-of-range coordinates") - func mapURLOutOfRange() { - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=91.0&lon=0.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0.0&lon=181.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=-90.001&lon=0.0") == nil) - } - - @Test("parseMapURL rejects a non-map host") - func mapURLWrongHost() { - #expect(MeshCoreURLParser.parseMapURL("meshcore://contact?lat=10.0&lon=10.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://channel/add?lat=10.0&lon=10.0") == nil) + } + + @Test + func `A literal plus in a name is preserved, not turned into a space`() throws { + let result = try Self.roundTrip(name: "C++ dev") + #expect(result.name == "C++ dev") + } + + @Test + func `Spaces, colons, and unicode round-trip intact`() throws { + for name in ["Field Base", "12:30 rally point", "Café au lait", "北京"] { + let result = try Self.roundTrip(name: name) + #expect(result.name == name, "name should survive round-trip: expected \(name), got \(result.name)") } + } - @Test("parseMapURL rejects non-finite and hex-float values that Double would otherwise accept") - func mapURLRejectsNonDecimalValues() { - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=nan&lon=0.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0.0&lon=inf") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0x1p4&lon=0.0") == nil) + @Test + func `Every contact type round-trips through export and parse`() throws { + for type in [ContactType.chat, .repeater, .room] { + let result = try Self.roundTrip(name: "Node", type: type) + #expect(result.contactType == type) } + } + + // MARK: - parseMapURL + + @Test + func `parseMapURL parses a valid map link into the correct coordinate`() throws { + let url = "meshcore://map?lat=37.334900&lon=-122.009020" + let coordinate = try #require(MeshCoreURLParser.parseMapURL(url)) + #expect(abs(coordinate.latitude - 37.3349) < 0.000001) + #expect(abs(coordinate.longitude - -122.00902) < 0.000001) + } + + @Test + func `parseMapURL returns nil when lat or lon is missing`() { + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=37.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lon=-122.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map") == nil) + } + + @Test + func `parseMapURL rejects out-of-range coordinates`() { + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=91.0&lon=0.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0.0&lon=181.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=-90.001&lon=0.0") == nil) + } + + @Test + func `parseMapURL rejects a non-map host`() { + #expect(MeshCoreURLParser.parseMapURL("meshcore://contact?lat=10.0&lon=10.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://channel/add?lat=10.0&lon=10.0") == nil) + } + + @Test + func `parseMapURL rejects non-finite and hex-float values that Double would otherwise accept`() { + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=nan&lon=0.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0.0&lon=inf") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0x1p4&lon=0.0") == nil) + } } diff --git a/MC1Tests/Utilities/RepeaterResolverTests.swift b/MC1Tests/Utilities/RepeaterResolverTests.swift index ac8a5877..011911b0 100644 --- a/MC1Tests/Utilities/RepeaterResolverTests.swift +++ b/MC1Tests/Utilities/RepeaterResolverTests.swift @@ -1,506 +1,505 @@ import CoreLocation import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("RepeaterResolver") struct RepeaterResolverTests { - - private func createRepeater( - prefix: UInt8, - secondByte: UInt8, - name: String, - lastAdvertTimestamp: UInt32, - latitude: Double, - longitude: Double - ) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([prefix, secondByte] + Array(repeating: UInt8(0), count: 30)), - name: name, - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - } - - @Test("prefers closest repeater when location available") - func prefersClosestWithLocation() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Near", - lastAdvertTimestamp: 10, - latitude: 37.0, - longitude: -122.0 - ) - let repeaterB = createRepeater( - prefix: 0x3F, - secondByte: 0x02, - name: "Far", - lastAdvertTimestamp: 200, - latitude: 38.0, - longitude: -123.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [repeaterA, repeaterB], userLocation: userLocation) - - #expect(match?.displayName == "Near") - } - - @Test("exact match with full public key ignores proximity/recency") - func exactMatchWithFullPublicKey() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Target", - lastAdvertTimestamp: 10, - latitude: 38.0, - longitude: -123.0 - ) - let repeaterB = createRepeater( - prefix: 0x3F, - secondByte: 0x02, - name: "Closer and Newer", - lastAdvertTimestamp: 200, - latitude: 37.0, - longitude: -122.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - // PathHop with full key of repeaterA - should match exactly despite repeaterB being closer/newer - let hop = PathHop(hashBytes: Data([0x3F]), publicKey: repeaterA.publicKey, resolvedName: "Target") - let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA, repeaterB], userLocation: userLocation) - - #expect(match?.displayName == "Target") - } - - @Test("PathHop without public key falls back to proximity/recency") - func pathHopWithoutKeyFallsBackToProximity() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Far", - lastAdvertTimestamp: 10, - latitude: 38.0, - longitude: -123.0 - ) - let repeaterB = createRepeater( - prefix: 0x3F, - secondByte: 0x02, - name: "Near", - lastAdvertTimestamp: 200, - latitude: 37.0, - longitude: -122.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - // PathHop with nil publicKey - should fall back to proximity match - let hop = PathHop(hashBytes: Data([0x3F]), resolvedName: nil) - let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA, repeaterB], userLocation: userLocation) - - #expect(match?.displayName == "Near") - } - - @Test("PathHop with deleted contact key falls back to hash byte match") - func pathHopWithDeletedContactFallsBack() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Only Match", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - - // PathHop has a key that doesn't match any current repeater (contact was deleted) - let deletedKey = Data([0x3F, 0xFF] + Array(repeating: UInt8(0), count: 30)) - let hop = PathHop(hashBytes: Data([0x3F]), publicKey: deletedKey, resolvedName: "Deleted") - let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA], userLocation: nil) - - // Falls back to hash byte match - #expect(match?.displayName == "Only Match") - } - - // MARK: - DiscoveredNodeDTO Tests - - private func createDiscoveredNode( - prefix: UInt8, - secondByte: UInt8, - name: String, - lastAdvertTimestamp: UInt32, - lastHeard: Date = Date(), - latitude: Double, - longitude: Double - ) -> DiscoveredNodeDTO { - DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([prefix, secondByte] + Array(repeating: UInt8(0), count: 30)), - name: name, - typeRawValue: ContactType.repeater.rawValue, - lastHeard: lastHeard, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - } - - @Test("prefers closest discovered node when location available") - func prefersClosestDiscoveredNodeWithLocation() { - let nodeA = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x01, - name: "Near Node", - lastAdvertTimestamp: 10, - latitude: 37.0, - longitude: -122.0 - ) - let nodeB = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x02, - name: "Far Node", - lastAdvertTimestamp: 200, - latitude: 38.0, - longitude: -123.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [nodeA, nodeB], userLocation: userLocation) - - #expect(match?.name == "Near Node") - } - - @Test("prefers most recent discovered node without location") - func prefersMostRecentDiscoveredNodeWithoutLocation() { - let nodeA = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x01, - name: "Older Node", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - let nodeB = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x02, - name: "Newer Node", - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0 - ) - - let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [nodeA, nodeB], userLocation: nil) - - #expect(match?.name == "Newer Node") - } - - @Test("exact match with full public key for discovered node PathHop variant") - func exactMatchDiscoveredNodePathHop() { - let nodeA = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x01, - name: "Target Node", - lastAdvertTimestamp: 10, - latitude: 38.0, - longitude: -123.0 - ) - let nodeB = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x02, - name: "Closer and Newer", - lastAdvertTimestamp: 200, - latitude: 37.0, - longitude: -122.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - let hop = PathHop(hashBytes: Data([0x3F]), publicKey: nodeA.publicKey, resolvedName: "Target Node") - let match = RepeaterResolver.bestMatch(for: hop, in: [nodeA, nodeB], userLocation: userLocation) - - #expect(match?.name == "Target Node") - } - - // MARK: - ContactDTO Tests - - @Test("prefers most recent when location unavailable") - func prefersMostRecentWithoutLocation() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Older", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - let repeaterB = createRepeater( - prefix: 0x3F, - secondByte: 0x02, - name: "Newer", - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0 - ) - - let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [repeaterA, repeaterB], userLocation: nil) - - #expect(match?.displayName == "Newer") - } - - @Test("neighbor name resolver uses discovered node when contact is absent") - func neighborNameResolverUsesDiscoveredNode() { - let node = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0xEF] + Array(repeating: UInt8(0), count: 29)), - name: "Ridge Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: 100, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let name = NeighborNameResolver.resolveName( - for: Data([0xAB, 0xCD]), - contacts: [], - discoveredNodes: [node], - userLocation: nil - ) - - #expect(name == "Ridge Repeater") - } - - @Test("neighbor name resolver marks unique short discovered prefix as exact") - func neighborNameResolverMarksUniqueShortDiscoveredPrefixAsExact() { - let node = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0xEF] + Array(repeating: UInt8(0), count: 29)), - name: "Ridge Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: 100, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let result = NeighborNameResolver.resolve( - for: Data([0xAB, 0xCD]), - contacts: [], - discoveredNodes: [node], - userLocation: nil - ) - - #expect(result?.displayName == "Ridge Repeater") - #expect(result?.matchKind == .exact) - } - - @Test("neighbor name resolver marks full prefix contact match as exact") - func neighborNameResolverMarksFullPrefixContactMatchAsExact() { - let contact = createRepeater( - prefix: 0xAB, - secondByte: 0xCD, - name: "Saved Repeater", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - - let result = NeighborNameResolver.resolve( - for: contact.publicKeyPrefix, - contacts: [contact], - discoveredNodes: [], - userLocation: nil - ) - - #expect(result?.displayName == "Saved Repeater") - #expect(result?.matchKind == .exact) - } - - @Test("neighbor name resolver prefers contacts over discovered nodes") - func neighborNameResolverPrefersContacts() { - let contact = createRepeater( - prefix: 0xAB, - secondByte: 0xCD, - name: "Saved Repeater", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - let node = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: contact.publicKey, - name: "Advert Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let name = NeighborNameResolver.resolveName( - for: Data([0xAB, 0xCD]), - contacts: [contact], - discoveredNodes: [node], - userLocation: nil - ) - - #expect(name == "Saved Repeater") - } - - @Test("neighbor name resolver marks cross-source short prefix ambiguity as fallback") - func neighborNameResolverMarksCrossSourceShortPrefixAmbiguityAsFallback() { - let contact = createRepeater( - prefix: 0xAB, - secondByte: 0xCD, - name: "Saved Repeater", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - let node = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xEF] + Array(repeating: UInt8(0), count: 30)), - name: "Advert Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let result = NeighborNameResolver.resolve( - for: Data([0xAB]), - contacts: [contact], - discoveredNodes: [node], - userLocation: nil - ) - - #expect(result?.displayName == "Saved Repeater") - #expect(result?.matchKind == .fallback) - } - - @Test("neighbor name resolver disambiguates short discovered prefixes by recency") - func neighborNameResolverDisambiguatesShortPrefixesByRecency() { - let older = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0x01] + Array(repeating: UInt8(0), count: 29)), - name: "Older Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 1_000), - lastAdvertTimestamp: 100, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - let newer = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0x02] + Array(repeating: UInt8(0), count: 29)), - name: "Newer Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 2_000), - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let name = NeighborNameResolver.resolveName( - for: Data([0xAB, 0xCD]), - contacts: [], - discoveredNodes: [older, newer], - userLocation: nil - ) - - #expect(name == "Newer Repeater") - } - - @Test("neighbor name resolver marks ambiguous short discovered prefix as fallback") - func neighborNameResolverMarksAmbiguousShortDiscoveredPrefixAsFallback() { - let older = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0x01] + Array(repeating: UInt8(0), count: 29)), - name: "Older Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 1_000), - lastAdvertTimestamp: 100, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - let newer = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0x02] + Array(repeating: UInt8(0), count: 29)), - name: "Newer Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 2_000), - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let result = NeighborNameResolver.resolve( - for: Data([0xAB, 0xCD]), - contacts: [], - discoveredNodes: [older, newer], - userLocation: nil - ) - - #expect(result?.displayName == "Newer Repeater") - #expect(result?.matchKind == .fallback) - } + private func createRepeater( + prefix: UInt8, + secondByte: UInt8, + name: String, + lastAdvertTimestamp: UInt32, + latitude: Double, + longitude: Double + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([prefix, secondByte] + Array(repeating: UInt8(0), count: 30)), + name: name, + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } + + @Test + func `prefers closest repeater when location available`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Near", + lastAdvertTimestamp: 10, + latitude: 37.0, + longitude: -122.0 + ) + let repeaterB = createRepeater( + prefix: 0x3F, + secondByte: 0x02, + name: "Far", + lastAdvertTimestamp: 200, + latitude: 38.0, + longitude: -123.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [repeaterA, repeaterB], userLocation: userLocation) + + #expect(match?.displayName == "Near") + } + + @Test + func `exact match with full public key ignores proximity/recency`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Target", + lastAdvertTimestamp: 10, + latitude: 38.0, + longitude: -123.0 + ) + let repeaterB = createRepeater( + prefix: 0x3F, + secondByte: 0x02, + name: "Closer and Newer", + lastAdvertTimestamp: 200, + latitude: 37.0, + longitude: -122.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + // PathHop with full key of repeaterA - should match exactly despite repeaterB being closer/newer + let hop = PathHop(hashBytes: Data([0x3F]), publicKey: repeaterA.publicKey, resolvedName: "Target") + let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA, repeaterB], userLocation: userLocation) + + #expect(match?.displayName == "Target") + } + + @Test + func `PathHop without public key falls back to proximity/recency`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Far", + lastAdvertTimestamp: 10, + latitude: 38.0, + longitude: -123.0 + ) + let repeaterB = createRepeater( + prefix: 0x3F, + secondByte: 0x02, + name: "Near", + lastAdvertTimestamp: 200, + latitude: 37.0, + longitude: -122.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + // PathHop with nil publicKey - should fall back to proximity match + let hop = PathHop(hashBytes: Data([0x3F]), resolvedName: nil) + let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA, repeaterB], userLocation: userLocation) + + #expect(match?.displayName == "Near") + } + + @Test + func `PathHop with deleted contact key falls back to hash byte match`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Only Match", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + + // PathHop has a key that doesn't match any current repeater (contact was deleted) + let deletedKey = Data([0x3F, 0xFF] + Array(repeating: UInt8(0), count: 30)) + let hop = PathHop(hashBytes: Data([0x3F]), publicKey: deletedKey, resolvedName: "Deleted") + let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA], userLocation: nil) + + // Falls back to hash byte match + #expect(match?.displayName == "Only Match") + } + + // MARK: - DiscoveredNodeDTO Tests + + private func createDiscoveredNode( + prefix: UInt8, + secondByte: UInt8, + name: String, + lastAdvertTimestamp: UInt32, + lastHeard: Date = Date(), + latitude: Double, + longitude: Double + ) -> DiscoveredNodeDTO { + DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([prefix, secondByte] + Array(repeating: UInt8(0), count: 30)), + name: name, + typeRawValue: ContactType.repeater.rawValue, + lastHeard: lastHeard, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + } + + @Test + func `prefers closest discovered node when location available`() { + let nodeA = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x01, + name: "Near Node", + lastAdvertTimestamp: 10, + latitude: 37.0, + longitude: -122.0 + ) + let nodeB = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x02, + name: "Far Node", + lastAdvertTimestamp: 200, + latitude: 38.0, + longitude: -123.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [nodeA, nodeB], userLocation: userLocation) + + #expect(match?.name == "Near Node") + } + + @Test + func `prefers most recent discovered node without location`() { + let nodeA = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x01, + name: "Older Node", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + let nodeB = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x02, + name: "Newer Node", + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0 + ) + + let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [nodeA, nodeB], userLocation: nil) + + #expect(match?.name == "Newer Node") + } + + @Test + func `exact match with full public key for discovered node PathHop variant`() { + let nodeA = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x01, + name: "Target Node", + lastAdvertTimestamp: 10, + latitude: 38.0, + longitude: -123.0 + ) + let nodeB = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x02, + name: "Closer and Newer", + lastAdvertTimestamp: 200, + latitude: 37.0, + longitude: -122.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + let hop = PathHop(hashBytes: Data([0x3F]), publicKey: nodeA.publicKey, resolvedName: "Target Node") + let match = RepeaterResolver.bestMatch(for: hop, in: [nodeA, nodeB], userLocation: userLocation) + + #expect(match?.name == "Target Node") + } + + // MARK: - ContactDTO Tests + + @Test + func `prefers most recent when location unavailable`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Older", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + let repeaterB = createRepeater( + prefix: 0x3F, + secondByte: 0x02, + name: "Newer", + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0 + ) + + let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [repeaterA, repeaterB], userLocation: nil) + + #expect(match?.displayName == "Newer") + } + + @Test + func `neighbor name resolver uses discovered node when contact is absent`() { + let node = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0xEF] + Array(repeating: UInt8(0), count: 29)), + name: "Ridge Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: 100, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let name = NeighborNameResolver.resolveName( + for: Data([0xAB, 0xCD]), + contacts: [], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(name == "Ridge Repeater") + } + + @Test + func `neighbor name resolver marks unique short discovered prefix as exact`() { + let node = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0xEF] + Array(repeating: UInt8(0), count: 29)), + name: "Ridge Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: 100, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let result = NeighborNameResolver.resolve( + for: Data([0xAB, 0xCD]), + contacts: [], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(result?.displayName == "Ridge Repeater") + #expect(result?.matchKind == .exact) + } + + @Test + func `neighbor name resolver marks full prefix contact match as exact`() { + let contact = createRepeater( + prefix: 0xAB, + secondByte: 0xCD, + name: "Saved Repeater", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + + let result = NeighborNameResolver.resolve( + for: contact.publicKeyPrefix, + contacts: [contact], + discoveredNodes: [], + userLocation: nil + ) + + #expect(result?.displayName == "Saved Repeater") + #expect(result?.matchKind == .exact) + } + + @Test + func `neighbor name resolver prefers contacts over discovered nodes`() { + let contact = createRepeater( + prefix: 0xAB, + secondByte: 0xCD, + name: "Saved Repeater", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + let node = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: contact.publicKey, + name: "Advert Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let name = NeighborNameResolver.resolveName( + for: Data([0xAB, 0xCD]), + contacts: [contact], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(name == "Saved Repeater") + } + + @Test + func `neighbor name resolver marks cross-source short prefix ambiguity as fallback`() { + let contact = createRepeater( + prefix: 0xAB, + secondByte: 0xCD, + name: "Saved Repeater", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + let node = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xEF] + Array(repeating: UInt8(0), count: 30)), + name: "Advert Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let result = NeighborNameResolver.resolve( + for: Data([0xAB]), + contacts: [contact], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(result?.displayName == "Saved Repeater") + #expect(result?.matchKind == .fallback) + } + + @Test + func `neighbor name resolver disambiguates short discovered prefixes by recency`() { + let older = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0x01] + Array(repeating: UInt8(0), count: 29)), + name: "Older Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 1000), + lastAdvertTimestamp: 100, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + let newer = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0x02] + Array(repeating: UInt8(0), count: 29)), + name: "Newer Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 2000), + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let name = NeighborNameResolver.resolveName( + for: Data([0xAB, 0xCD]), + contacts: [], + discoveredNodes: [older, newer], + userLocation: nil + ) + + #expect(name == "Newer Repeater") + } + + @Test + func `neighbor name resolver marks ambiguous short discovered prefix as fallback`() { + let older = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0x01] + Array(repeating: UInt8(0), count: 29)), + name: "Older Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 1000), + lastAdvertTimestamp: 100, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + let newer = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0x02] + Array(repeating: UInt8(0), count: 29)), + name: "Newer Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 2000), + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let result = NeighborNameResolver.resolve( + for: Data([0xAB, 0xCD]), + contacts: [], + discoveredNodes: [older, newer], + userLocation: nil + ) + + #expect(result?.displayName == "Newer Repeater") + #expect(result?.matchKind == .fallback) + } } diff --git a/MC1Tests/Utilities/URLSafetyCheckerTests.swift b/MC1Tests/Utilities/URLSafetyCheckerTests.swift index 10475c09..ee7bed49 100644 --- a/MC1Tests/Utilities/URLSafetyCheckerTests.swift +++ b/MC1Tests/Utilities/URLSafetyCheckerTests.swift @@ -1,211 +1,210 @@ -import Testing import Foundation @testable import MC1 +import Testing struct URLSafetyCheckerTests { - - // MARK: - Scheme Validation - - @Test("Allows HTTPS URLs") - func allowsHTTPS() async { - let url = URL(string: "https://example.com/page")! - let result = await URLSafetyChecker.isSafe(url) - #expect(result, "HTTPS should be allowed") - } - - @Test("Allows HTTP URLs") - func allowsHTTP() async { - let url = URL(string: "http://example.com/page")! - let result = await URLSafetyChecker.isSafe(url) - #expect(result, "HTTP should be allowed") - } - - @Test("Rejects FTP scheme") - func rejectsFTP() async { - let url = URL(string: "ftp://example.com/file")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "FTP should be rejected") - } - - @Test("Rejects file scheme") - func rejectsFile() async { - let url = URL(string: "file:///etc/passwd")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "file:// should be rejected") - } - - @Test("Rejects javascript scheme") - func rejectsJavascript() async { - let url = URL(string: "javascript:alert(1)")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "javascript: should be rejected") - } - - // MARK: - Host Validation - - @Test("Rejects URL with no host") - func rejectsNoHost() async { - let url = URL(string: "https://")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "URL with no host should be rejected") - } - - // MARK: - Allow-listed Hosts - - @Test("Allows media.giphy.com") - func allowsMediaGiphy() async { - let url = URL(string: "https://media.giphy.com/media/abc123/giphy.gif")! - let result = await URLSafetyChecker.isSafe(url) - #expect(result, "media.giphy.com should be allow-listed") - } - - @Test("Allows i.giphy.com") - func allowsIGiphy() async { - let url = URL(string: "https://i.giphy.com/media/abc123/giphy.gif")! - let result = await URLSafetyChecker.isSafe(url) - #expect(result, "i.giphy.com should be allow-listed") - } - - // MARK: - Private/Reserved IP Detection (IPv4) - - @Test("Detects loopback 127.0.0.1") - func detectsLoopback() { - #expect(URLSafetyChecker.isPrivateOrReserved("127.0.0.1")) - } - - @Test("Detects loopback 127.255.255.255") - func detectsLoopbackRange() { - #expect(URLSafetyChecker.isPrivateOrReserved("127.255.255.255")) - } - - @Test("Detects 10.0.0.0/8") - func detects10Network() { - #expect(URLSafetyChecker.isPrivateOrReserved("10.0.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("10.255.255.255")) - } - - @Test("Detects 172.16.0.0/12") - func detects172Network() { - #expect(URLSafetyChecker.isPrivateOrReserved("172.16.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("172.31.255.255")) - #expect(!URLSafetyChecker.isPrivateOrReserved("172.32.0.1"), "172.32.x.x is public") - } - - @Test("Detects 192.168.0.0/16") - func detects192Network() { - #expect(URLSafetyChecker.isPrivateOrReserved("192.168.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("192.168.255.255")) - } - - @Test("Detects link-local 169.254.0.0/16") - func detectsLinkLocal() { - #expect(URLSafetyChecker.isPrivateOrReserved("169.254.1.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("169.254.169.254"), "AWS metadata endpoint") - } - - @Test("Detects 0.0.0.0") - func detectsZeroAddress() { - #expect(URLSafetyChecker.isPrivateOrReserved("0.0.0.0")) - } - - @Test("Detects multicast 224.0.0.0/4") - func detectsMulticast() { - #expect(URLSafetyChecker.isPrivateOrReserved("224.0.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("239.255.255.255")) - } - - @Test("Detects reserved 240.0.0.0/4") - func detectsReserved() { - #expect(URLSafetyChecker.isPrivateOrReserved("240.0.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("255.255.255.254")) - } - - @Test("Allows public IPv4 addresses") - func allowsPublicIPv4() { - #expect(!URLSafetyChecker.isPrivateOrReserved("8.8.8.8"), "Google DNS is public") - #expect(!URLSafetyChecker.isPrivateOrReserved("1.1.1.1"), "Cloudflare DNS is public") - #expect(!URLSafetyChecker.isPrivateOrReserved("93.184.216.34"), "example.com is public") - } - - // MARK: - Private/Reserved IP Detection (IPv6) - - @Test("Detects IPv6 loopback ::1") - func detectsIPv6Loopback() { - #expect(URLSafetyChecker.isPrivateOrReserved("::1")) - } - - @Test("Detects IPv6 unspecified ::") - func detectsIPv6Unspecified() { - #expect(URLSafetyChecker.isPrivateOrReserved("::")) - } - - @Test("Detects IPv6 link-local fe80::") - func detectsIPv6LinkLocal() { - #expect(URLSafetyChecker.isPrivateOrReserved("fe80::1")) - #expect(URLSafetyChecker.isPrivateOrReserved("fe80::abcd:ef01:2345:6789")) - } - - @Test("Detects IPv6 unique local fc00::/7") - func detectsIPv6UniqueLocal() { - #expect(URLSafetyChecker.isPrivateOrReserved("fc00::1")) - #expect(URLSafetyChecker.isPrivateOrReserved("fd00::1")) - } - - @Test("Detects IPv4-mapped IPv6 addresses") - func detectsIPv4MappedIPv6() { - #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:127.0.0.1"), "Mapped loopback") - #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:192.168.1.1"), "Mapped private") - #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:10.0.0.1"), "Mapped 10.x private") - #expect(!URLSafetyChecker.isPrivateOrReserved("::ffff:8.8.8.8"), "Mapped public should pass") - } - - @Test("Detects IPv6 multicast ff00::/8") - func detectsIPv6Multicast() { - #expect(URLSafetyChecker.isPrivateOrReserved("ff02::1")) - #expect(URLSafetyChecker.isPrivateOrReserved("ff05::2")) - } - - @Test("Allows public IPv6 addresses") - func allowsPublicIPv6() { - #expect(!URLSafetyChecker.isPrivateOrReserved("2001:4860:4860::8888"), "Google DNS v6") - #expect(!URLSafetyChecker.isPrivateOrReserved("2606:4700:4700::1111"), "Cloudflare v6") - } - - // MARK: - Non-IP Hostnames - - @Test("Non-IP strings are not private") - func nonIPNotPrivate() { - #expect(!URLSafetyChecker.isPrivateOrReserved("example.com")) - #expect(!URLSafetyChecker.isPrivateOrReserved("localhost")) - } - - // MARK: - IP Literal URLs - - @Test("Rejects URL with private IP literal") - func rejectsPrivateIPLiteral() async { - let url = URL(string: "http://192.168.1.1/admin")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "Private IP URL should be rejected") - } - - @Test("Rejects URL with loopback IP literal") - func rejectsLoopbackLiteral() async { - let url = URL(string: "http://127.0.0.1:8080/api")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "Loopback IP URL should be rejected") - } - - @Test("Rejects metadata endpoint") - func rejectsMetadataEndpoint() async { - let url = URL(string: "http://169.254.169.254/latest/meta-data/")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "AWS metadata endpoint should be rejected") - } - - @Test("Rejects private IP with port") - func rejectsPrivateIPWithPort() async { - let url = URL(string: "http://10.0.0.1:3000/api")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "Private IP with port should be rejected") - } + // MARK: - Scheme Validation + + @Test + func `Allows HTTPS URLs`() async throws { + let url = try #require(URL(string: "https://example.com/page")) + let result = await URLSafetyChecker.isSafe(url) + #expect(result, "HTTPS should be allowed") + } + + @Test + func `Allows HTTP URLs`() async throws { + let url = try #require(URL(string: "http://example.com/page")) + let result = await URLSafetyChecker.isSafe(url) + #expect(result, "HTTP should be allowed") + } + + @Test + func `Rejects FTP scheme`() async throws { + let url = try #require(URL(string: "ftp://example.com/file")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "FTP should be rejected") + } + + @Test + func `Rejects file scheme`() async throws { + let url = try #require(URL(string: "file:///etc/passwd")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "file:// should be rejected") + } + + @Test + func `Rejects javascript scheme`() async throws { + let url = try #require(URL(string: "javascript:alert(1)")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "javascript: should be rejected") + } + + // MARK: - Host Validation + + @Test + func `Rejects URL with no host`() async throws { + let url = try #require(URL(string: "https://")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "URL with no host should be rejected") + } + + // MARK: - Allow-listed Hosts + + @Test + func `Allows media.giphy.com`() async throws { + let url = try #require(URL(string: "https://media.giphy.com/media/abc123/giphy.gif")) + let result = await URLSafetyChecker.isSafe(url) + #expect(result, "media.giphy.com should be allow-listed") + } + + @Test + func `Allows i.giphy.com`() async throws { + let url = try #require(URL(string: "https://i.giphy.com/media/abc123/giphy.gif")) + let result = await URLSafetyChecker.isSafe(url) + #expect(result, "i.giphy.com should be allow-listed") + } + + // MARK: - Private/Reserved IP Detection (IPv4) + + @Test + func `Detects loopback 127.0.0.1`() { + #expect(URLSafetyChecker.isPrivateOrReserved("127.0.0.1")) + } + + @Test + func `Detects loopback 127.255.255.255`() { + #expect(URLSafetyChecker.isPrivateOrReserved("127.255.255.255")) + } + + @Test + func `Detects 10.0.0.0/8`() { + #expect(URLSafetyChecker.isPrivateOrReserved("10.0.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("10.255.255.255")) + } + + @Test + func `Detects 172.16.0.0/12`() { + #expect(URLSafetyChecker.isPrivateOrReserved("172.16.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("172.31.255.255")) + #expect(!URLSafetyChecker.isPrivateOrReserved("172.32.0.1"), "172.32.x.x is public") + } + + @Test + func `Detects 192.168.0.0/16`() { + #expect(URLSafetyChecker.isPrivateOrReserved("192.168.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("192.168.255.255")) + } + + @Test + func `Detects link-local 169.254.0.0/16`() { + #expect(URLSafetyChecker.isPrivateOrReserved("169.254.1.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("169.254.169.254"), "AWS metadata endpoint") + } + + @Test + func `Detects 0.0.0.0`() { + #expect(URLSafetyChecker.isPrivateOrReserved("0.0.0.0")) + } + + @Test + func `Detects multicast 224.0.0.0/4`() { + #expect(URLSafetyChecker.isPrivateOrReserved("224.0.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("239.255.255.255")) + } + + @Test + func `Detects reserved 240.0.0.0/4`() { + #expect(URLSafetyChecker.isPrivateOrReserved("240.0.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("255.255.255.254")) + } + + @Test + func `Allows public IPv4 addresses`() { + #expect(!URLSafetyChecker.isPrivateOrReserved("8.8.8.8"), "Google DNS is public") + #expect(!URLSafetyChecker.isPrivateOrReserved("1.1.1.1"), "Cloudflare DNS is public") + #expect(!URLSafetyChecker.isPrivateOrReserved("93.184.216.34"), "example.com is public") + } + + // MARK: - Private/Reserved IP Detection (IPv6) + + @Test + func `Detects IPv6 loopback ::1`() { + #expect(URLSafetyChecker.isPrivateOrReserved("::1")) + } + + @Test + func `Detects IPv6 unspecified ::`() { + #expect(URLSafetyChecker.isPrivateOrReserved("::")) + } + + @Test + func `Detects IPv6 link-local fe80::`() { + #expect(URLSafetyChecker.isPrivateOrReserved("fe80::1")) + #expect(URLSafetyChecker.isPrivateOrReserved("fe80::abcd:ef01:2345:6789")) + } + + @Test + func `Detects IPv6 unique local fc00::/7`() { + #expect(URLSafetyChecker.isPrivateOrReserved("fc00::1")) + #expect(URLSafetyChecker.isPrivateOrReserved("fd00::1")) + } + + @Test + func `Detects IPv4-mapped IPv6 addresses`() { + #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:127.0.0.1"), "Mapped loopback") + #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:192.168.1.1"), "Mapped private") + #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:10.0.0.1"), "Mapped 10.x private") + #expect(!URLSafetyChecker.isPrivateOrReserved("::ffff:8.8.8.8"), "Mapped public should pass") + } + + @Test + func `Detects IPv6 multicast ff00::/8`() { + #expect(URLSafetyChecker.isPrivateOrReserved("ff02::1")) + #expect(URLSafetyChecker.isPrivateOrReserved("ff05::2")) + } + + @Test + func `Allows public IPv6 addresses`() { + #expect(!URLSafetyChecker.isPrivateOrReserved("2001:4860:4860::8888"), "Google DNS v6") + #expect(!URLSafetyChecker.isPrivateOrReserved("2606:4700:4700::1111"), "Cloudflare v6") + } + + // MARK: - Non-IP Hostnames + + @Test + func `Non-IP strings are not private`() { + #expect(!URLSafetyChecker.isPrivateOrReserved("example.com")) + #expect(!URLSafetyChecker.isPrivateOrReserved("localhost")) + } + + // MARK: - IP Literal URLs + + @Test + func `Rejects URL with private IP literal`() async throws { + let url = try #require(URL(string: "http://192.168.1.1/admin")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "Private IP URL should be rejected") + } + + @Test + func `Rejects URL with loopback IP literal`() async throws { + let url = try #require(URL(string: "http://127.0.0.1:8080/api")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "Loopback IP URL should be rejected") + } + + @Test + func `Rejects metadata endpoint`() async throws { + let url = try #require(URL(string: "http://169.254.169.254/latest/meta-data/")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "AWS metadata endpoint should be rejected") + } + + @Test + func `Rejects private IP with port`() async throws { + let url = try #require(URL(string: "http://10.0.0.1:3000/api")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "Private IP with port should be rejected") + } } diff --git a/MC1Tests/ViewModels/AppBackupViewModelTests.swift b/MC1Tests/ViewModels/AppBackupViewModelTests.swift index 8b25394d..cedc9925 100644 --- a/MC1Tests/ViewModels/AppBackupViewModelTests.swift +++ b/MC1Tests/ViewModels/AppBackupViewModelTests.swift @@ -1,207 +1,205 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services @testable import MeshCore +import Testing @Suite("AppBackupViewModel — export success handling") @MainActor struct AppBackupViewModelExportTests { - - private func makeViewModel() throws -> AppBackupViewModel { - let container = try PersistenceStore.createContainer(inMemory: true) - let manager = ConnectionManager(modelContainer: container) - return AppBackupViewModel(connectionManager: manager) - } - - private func sampleManifest(messages: Int = 3, contacts: Int = 2) -> BackupManifest { - BackupManifest(contactCount: contacts, messageCount: messages) - } - - @Test("handleExportResult(.success) promotes pending to success") - func successPromotesToSummary() throws { - let vm = try makeViewModel() - let manifest = sampleManifest() - vm.exportState = .pending(AppBackupViewModel.PendingExport( - data: Data(repeating: 0xAB, count: 128), - manifest: manifest - )) - - let saveURL = URL(fileURLWithPath: "/tmp/MC1-backup-2026-04-19.mc1backup") - vm.handleExportResult(.success(saveURL)) - - #expect(vm.pendingExport == nil) - #expect(vm.exportSummary != nil) - #expect(vm.exportSummary?.filename == "MC1-backup-2026-04-19.mc1backup") - #expect(vm.exportSummary?.byteCount == 128) - #expect(vm.exportSummary?.manifest.messageCount == 3) - #expect(vm.exportSummary?.manifest.contactCount == 2) - #expect(vm.errorMessage == nil) - } - - @Test("handleExportResult(.failure(userCancelled)) returns to idle without errorMessage") - func userCancelIsSilent() throws { - let vm = try makeViewModel() - vm.exportState = .pending(AppBackupViewModel.PendingExport( - data: Data([0x01]), - manifest: sampleManifest() - )) - vm.handleExportResult(.failure(CocoaError(.userCancelled))) - - #expect(vm.pendingExport == nil) - #expect(vm.exportSummary == nil) - #expect(vm.errorMessage == nil) - } - - @Test("handleExportResult(.failure(other)) surfaces errorMessage") - func genericFailureSurfacesErrorMessage() throws { - let vm = try makeViewModel() - vm.exportState = .pending(AppBackupViewModel.PendingExport( - data: Data([0x01]), - manifest: sampleManifest() - )) - let saveError = NSError(domain: NSPOSIXErrorDomain, code: 28) // ENOSPC - - vm.handleExportResult(.failure(saveError)) - - #expect(vm.pendingExport == nil) - #expect(vm.exportSummary == nil) - #expect(vm.errorMessage != nil) - } - - @Test("handleExportResult(.success) with no pending export is a no-op") - func successWithoutPendingIsNoOp() throws { - let vm = try makeViewModel() - #expect(vm.pendingExport == nil) - - vm.handleExportResult(.success(URL(fileURLWithPath: "/tmp/x.mc1backup"))) - - #expect(vm.exportSummary == nil) - #expect(vm.errorMessage == nil) - } - - @Test("dismissExportSuccess clears the summary") - func dismissClearsSummary() throws { - let vm = try makeViewModel() - vm.exportState = .success(AppBackupViewModel.ExportSuccessSummary( - filename: "x.mc1backup", - byteCount: 1, - manifest: sampleManifest() - )) - - vm.dismissExportSuccess() - - #expect(vm.exportSummary == nil) - } + private func makeViewModel() throws -> AppBackupViewModel { + let container = try PersistenceStore.createContainer(inMemory: true) + let manager = ConnectionManager(modelContainer: container) + return AppBackupViewModel(connectionManager: manager) + } + + private func sampleManifest(messages: Int = 3, contacts: Int = 2) -> BackupManifest { + BackupManifest(contactCount: contacts, messageCount: messages) + } + + @Test + func `handleExportResult(.success) promotes pending to success`() throws { + let vm = try makeViewModel() + let manifest = sampleManifest() + vm.exportState = .pending(AppBackupViewModel.PendingExport( + data: Data(repeating: 0xAB, count: 128), + manifest: manifest + )) + + let saveURL = URL(fileURLWithPath: "/tmp/MC1-backup-2026-04-19.mc1backup") + vm.handleExportResult(.success(saveURL)) + + #expect(vm.pendingExport == nil) + #expect(vm.exportSummary != nil) + #expect(vm.exportSummary?.filename == "MC1-backup-2026-04-19.mc1backup") + #expect(vm.exportSummary?.byteCount == 128) + #expect(vm.exportSummary?.manifest.messageCount == 3) + #expect(vm.exportSummary?.manifest.contactCount == 2) + #expect(vm.errorMessage == nil) + } + + @Test + func `handleExportResult(.failure(userCancelled)) returns to idle without errorMessage`() throws { + let vm = try makeViewModel() + vm.exportState = .pending(AppBackupViewModel.PendingExport( + data: Data([0x01]), + manifest: sampleManifest() + )) + vm.handleExportResult(.failure(CocoaError(.userCancelled))) + + #expect(vm.pendingExport == nil) + #expect(vm.exportSummary == nil) + #expect(vm.errorMessage == nil) + } + + @Test + func `handleExportResult(.failure(other)) surfaces errorMessage`() throws { + let vm = try makeViewModel() + vm.exportState = .pending(AppBackupViewModel.PendingExport( + data: Data([0x01]), + manifest: sampleManifest() + )) + let saveError = NSError(domain: NSPOSIXErrorDomain, code: 28) // ENOSPC + + vm.handleExportResult(.failure(saveError)) + + #expect(vm.pendingExport == nil) + #expect(vm.exportSummary == nil) + #expect(vm.errorMessage != nil) + } + + @Test + func `handleExportResult(.success) with no pending export is a no-op`() throws { + let vm = try makeViewModel() + #expect(vm.pendingExport == nil) + + vm.handleExportResult(.success(URL(fileURLWithPath: "/tmp/x.mc1backup"))) + + #expect(vm.exportSummary == nil) + #expect(vm.errorMessage == nil) + } + + @Test + func `dismissExportSuccess clears the summary`() throws { + let vm = try makeViewModel() + vm.exportState = .success(AppBackupViewModel.ExportSuccessSummary( + filename: "x.mc1backup", + byteCount: 1, + manifest: sampleManifest() + )) + + vm.dismissExportSuccess() + + #expect(vm.exportSummary == nil) + } } @Suite("App Backup") @MainActor struct AppBackupViewModelTests { + @Test + func `Import picker cancellation does not surface an error`() throws { + let viewModel = try makeViewModel() - @Test("Import picker cancellation does not surface an error") - func handleFileSelected_IgnoresUserCancellation() throws { - let viewModel = try makeViewModel() + viewModel.handleFileSelected(result: .failure(CocoaError(.userCancelled))) - viewModel.handleFileSelected(result: .failure(CocoaError(.userCancelled))) + #expect(viewModel.errorMessage == nil) + } - #expect(viewModel.errorMessage == nil) - } + @Test + func `Readable backup URLs load even when no security scope is granted`() async throws { + let viewModel = try makeViewModel() + let backupURL = try makeReadableBackupURL() - @Test("Readable backup URLs load even when no security scope is granted") - func loadAndParseBackup_AcceptsReadableURLWithoutSecurityScope() async throws { - let viewModel = try makeViewModel() - let backupURL = try makeReadableBackupURL() + viewModel.loadAndParseBackup(from: backupURL) - viewModel.loadAndParseBackup(from: backupURL) - - try await waitUntil("Backup preview never loaded") { - viewModel.previewEnvelope != nil || viewModel.errorMessage != nil - } - - let previewEnvelope = try #require(viewModel.previewEnvelope) - #expect(viewModel.errorMessage == nil) - #expect(previewEnvelope.manifest == BackupManifest()) - } - - @Test("Export uses the active services store when one is available") - func performExport_PrefersActiveServicesStore() async throws { - let manager = ConnectionManager(modelContainer: try PersistenceStore.createContainer(inMemory: true)) - let radioID = UUID() - let services = ServiceContainer( - session: MeshCoreSession(transport: MockTransport()), - modelContainer: try PersistenceStore.createContainer(inMemory: true), - radioID: radioID - ) - try await services.dataStore.saveContact( - ContactDTO( - id: UUID(), - radioID: radioID, - publicKey: Data(repeating: 0xAB, count: 32), - name: "Backed Up Contact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - ) - manager.setTestState(services: services) - - let viewModel = AppBackupViewModel(connectionManager: manager) - viewModel.performExport() - - try await waitUntil("Backup export never completed") { - viewModel.pendingExport != nil || viewModel.errorMessage != nil - } - - let pending = try #require(viewModel.pendingExport) - let envelope = try parseBackup(data: pending.data) - #expect(viewModel.errorMessage == nil) - #expect(envelope.contacts.count == 1) - #expect(envelope.contacts.first?.name == "Backed Up Contact") + try await waitUntil("Backup preview never loaded") { + viewModel.previewEnvelope != nil || viewModel.errorMessage != nil } - @Test("performImport aborts and dismisses when the radio is connected") - func performImportAbortsWhenConnected() async throws { - // The VM's `connectionManager` is private, so drive connection state on the manager - // before constructing the VM, using the existing test seam. - let container = try PersistenceStore.createContainer(inMemory: true) - let manager = ConnectionManager(modelContainer: container) - manager.setTestState(connectionState: .ready) - let vm = AppBackupViewModel(connectionManager: manager) - let envelope = AppBackupEnvelope(appVersion: "test", appBuild: "1", manifest: BackupManifest()) - vm.importState = .preview(envelope) - vm.performImport() - // Aborted: no import ran and the sheet was dismissed back to idle. - #expect(vm.previewEnvelope == nil) - if case .importing = vm.importState { Issue.record("import ran while connected") } + let previewEnvelope = try #require(viewModel.previewEnvelope) + #expect(viewModel.errorMessage == nil) + #expect(previewEnvelope.manifest == BackupManifest()) + } + + @Test + func `Export uses the active services store when one is available`() async throws { + let manager = try ConnectionManager(modelContainer: PersistenceStore.createContainer(inMemory: true)) + let radioID = UUID() + let services = try ServiceContainer( + session: MeshCoreSession(transport: MockTransport()), + modelContainer: PersistenceStore.createContainer(inMemory: true), + radioID: radioID + ) + try await services.dataStore.saveContact( + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0xAB, count: 32), + name: "Backed Up Contact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + ) + manager.setTestState(services: services) + + let viewModel = AppBackupViewModel(connectionManager: manager) + viewModel.performExport() + + try await waitUntil("Backup export never completed") { + viewModel.pendingExport != nil || viewModel.errorMessage != nil } - private func makeViewModel() throws -> AppBackupViewModel { - let container = try PersistenceStore.createContainer(inMemory: true) - let manager = ConnectionManager(modelContainer: container) - return AppBackupViewModel(connectionManager: manager) - } - - private func makeReadableBackupURL() throws -> URL { - let envelope = AppBackupEnvelope( - appVersion: "test", - appBuild: "1", - manifest: BackupManifest() - ) - let jsonData = try makeBackupJSONEncoder().encode(envelope) - let compressed = try jsonData.zlibCompressed() - let encodedData = compressed.base64EncodedString() - return try #require(URL(string: "data:application/octet-stream;base64,\(encodedData)")) - } + let pending = try #require(viewModel.pendingExport) + let envelope = try parseBackup(data: pending.data) + #expect(viewModel.errorMessage == nil) + #expect(envelope.contacts.count == 1) + #expect(envelope.contacts.first?.name == "Backed Up Contact") + } + + @Test + func `performImport aborts and dismisses when the radio is connected`() throws { + // The VM's `connectionManager` is private, so drive connection state on the manager + // before constructing the VM, using the existing test seam. + let container = try PersistenceStore.createContainer(inMemory: true) + let manager = ConnectionManager(modelContainer: container) + manager.setTestState(connectionState: .ready) + let vm = AppBackupViewModel(connectionManager: manager) + let envelope = AppBackupEnvelope(appVersion: "test", appBuild: "1", manifest: BackupManifest()) + vm.importState = .preview(envelope) + vm.performImport() + // Aborted: no import ran and the sheet was dismissed back to idle. + #expect(vm.previewEnvelope == nil) + if case .importing = vm.importState { Issue.record("import ran while connected") } + } + + private func makeViewModel() throws -> AppBackupViewModel { + let container = try PersistenceStore.createContainer(inMemory: true) + let manager = ConnectionManager(modelContainer: container) + return AppBackupViewModel(connectionManager: manager) + } + + private func makeReadableBackupURL() throws -> URL { + let envelope = AppBackupEnvelope( + appVersion: "test", + appBuild: "1", + manifest: BackupManifest() + ) + let jsonData = try makeBackupJSONEncoder().encode(envelope) + let compressed = try jsonData.zlibCompressed() + let encodedData = compressed.base64EncodedString() + return try #require(URL(string: "data:application/octet-stream;base64,\(encodedData)")) + } } diff --git a/MC1Tests/ViewModels/BatchTraceTests.swift b/MC1Tests/ViewModels/BatchTraceTests.swift index f48a1160..c892120d 100644 --- a/MC1Tests/ViewModels/BatchTraceTests.swift +++ b/MC1Tests/ViewModels/BatchTraceTests.swift @@ -1,333 +1,316 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services @testable import MeshCore +import Testing // MARK: - Test Helpers private func createTestHop(snr: Double, isStartNode: Bool = false, isEndNode: Bool = false) -> TraceHop { - TraceHop( - hashBytes: isStartNode || isEndNode ? nil : Data([0xAA]), - resolvedName: nil, - snr: snr, - isStartNode: isStartNode, - isEndNode: isEndNode, - latitude: nil, - longitude: nil - ) + TraceHop( + hashBytes: isStartNode || isEndNode ? nil : Data([0xAA]), + resolvedName: nil, + snr: snr, + isStartNode: isStartNode, + isEndNode: isEndNode, + latitude: nil, + longitude: nil + ) } private func createTestResult(hopSNRs: [Double], durationMs: Int, success: Bool = true) -> TraceResult { - var hops: [TraceHop] = [createTestHop(snr: 0, isStartNode: true)] - for snr in hopSNRs { - hops.append(createTestHop(snr: snr)) - } - hops.append(createTestHop(snr: hopSNRs.last ?? 0, isEndNode: true)) - - return TraceResult( - hops: hops, - durationMs: durationMs, - success: success, - errorMessage: success ? nil : "Failed", - tracedPathBytes: [0xAA], - hashSize: 1 - ) + var hops: [TraceHop] = [createTestHop(snr: 0, isStartNode: true)] + for snr in hopSNRs { + hops.append(createTestHop(snr: snr)) + } + hops.append(createTestHop(snr: hopSNRs.last ?? 0, isEndNode: true)) + + return TraceResult( + hops: hops, + durationMs: durationMs, + success: success, + errorMessage: success ? nil : "Failed", + tracedPathBytes: [0xAA], + hashSize: 1 + ) } @Suite("Batch Trace State") @MainActor struct BatchTraceStateTests { + @Test + func `batch properties have correct defaults`() { + let viewModel = TracePathViewModel() + + #expect(viewModel.batchEnabled == false) + #expect(viewModel.batchSize == 5) + #expect(viewModel.currentTraceIndex == 0) + #expect(viewModel.completedResults.isEmpty) + #expect(viewModel.isBatchInProgress == false) + #expect(viewModel.isBatchComplete == false) + } + + @Test + func `successfulResults filters to successful traces only`() { + let viewModel = TracePathViewModel() + + let successResult = TraceResult( + hops: [], + durationMs: 100, + success: true, + errorMessage: nil, + tracedPathBytes: [0xAA], + hashSize: 1 + ) + let failedResult = TraceResult( + hops: [], + durationMs: 0, + success: false, + errorMessage: "Timeout", + tracedPathBytes: [0xAA], + hashSize: 1 + ) + + viewModel.completedResults = [successResult, failedResult, successResult] + + #expect(viewModel.successfulResults.count == 2) + } - @Test("batch properties have correct defaults") - func batchPropertiesHaveCorrectDefaults() { - let viewModel = TracePathViewModel() - - #expect(viewModel.batchEnabled == false) - #expect(viewModel.batchSize == 5) - #expect(viewModel.currentTraceIndex == 0) - #expect(viewModel.completedResults.isEmpty) - #expect(viewModel.isBatchInProgress == false) - #expect(viewModel.isBatchComplete == false) - } - - @Test("successfulResults filters to successful traces only") - func successfulResultsFiltersCorrectly() { - let viewModel = TracePathViewModel() - - let successResult = TraceResult( - hops: [], - durationMs: 100, - success: true, - errorMessage: nil, - tracedPathBytes: [0xAA], - hashSize: 1 - ) - let failedResult = TraceResult( - hops: [], - durationMs: 0, - success: false, - errorMessage: "Timeout", - tracedPathBytes: [0xAA], - hashSize: 1 - ) - - viewModel.completedResults = [successResult, failedResult, successResult] - - #expect(viewModel.successfulResults.count == 2) - } - - @Test("successCount returns number of successful traces") - func successCountReturnsCorrectValue() { - let viewModel = TracePathViewModel() - - let successResult = TraceResult( - hops: [], - durationMs: 100, - success: true, - errorMessage: nil, - tracedPathBytes: [0xAA], - hashSize: 1 - ) - let failedResult = TraceResult( - hops: [], - durationMs: 0, - success: false, - errorMessage: "Timeout", - tracedPathBytes: [0xAA], - hashSize: 1 - ) - - viewModel.completedResults = [successResult, failedResult, successResult] - - #expect(viewModel.successCount == 2) - } - - @Test("batchEnabled didSet clears batch state when disabled") - func batchEnabledDidSetClearsBatchState() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.currentTraceIndex = 3 - viewModel.completedResults = [ - TraceResult(hops: [], durationMs: 100, success: true, errorMessage: nil, tracedPathBytes: [0xAA], hashSize: 1) - ] - - viewModel.batchEnabled = false - - #expect(viewModel.currentTraceIndex == 0) - #expect(viewModel.completedResults.isEmpty) - } + @Test + func `successCount returns number of successful traces`() { + let viewModel = TracePathViewModel() + + let successResult = TraceResult( + hops: [], + durationMs: 100, + success: true, + errorMessage: nil, + tracedPathBytes: [0xAA], + hashSize: 1 + ) + let failedResult = TraceResult( + hops: [], + durationMs: 0, + success: false, + errorMessage: "Timeout", + tracedPathBytes: [0xAA], + hashSize: 1 + ) + + viewModel.completedResults = [successResult, failedResult, successResult] + + #expect(viewModel.successCount == 2) + } + + @Test + func `batchEnabled didSet clears batch state when disabled`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.currentTraceIndex = 3 + viewModel.completedResults = [ + TraceResult(hops: [], durationMs: 100, success: true, errorMessage: nil, tracedPathBytes: [0xAA], hashSize: 1) + ] + + viewModel.batchEnabled = false + + #expect(viewModel.currentTraceIndex == 0) + #expect(viewModel.completedResults.isEmpty) + } } @Suite("Batch Aggregate Computation") @MainActor struct BatchAggregateTests { - - @Test("RTT aggregates compute correctly") - func rttAggregatesComputeCorrectly() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100), - createTestResult(hopSNRs: [5.0], durationMs: 200), - createTestResult(hopSNRs: [5.0], durationMs: 150) - ] - - #expect(viewModel.averageRTT == 150) - #expect(viewModel.minRTT == 100) - #expect(viewModel.maxRTT == 200) - } - - @Test("RTT aggregates exclude failed traces") - func rttAggregatesExcludeFailedTraces() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100), - createTestResult(hopSNRs: [], durationMs: 0, success: false), - createTestResult(hopSNRs: [5.0], durationMs: 200) - ] - - #expect(viewModel.averageRTT == 150) - #expect(viewModel.minRTT == 100) - #expect(viewModel.maxRTT == 200) - } - - @Test("RTT aggregates return nil when no successful traces") - func rttAggregatesReturnNilWhenNoSuccess() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [], durationMs: 0, success: false) - ] - - #expect(viewModel.averageRTT == nil) - #expect(viewModel.minRTT == nil) - #expect(viewModel.maxRTT == nil) - } - - @Test("hop stats compute correctly for intermediate hops") - func hopStatsComputeCorrectly() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - // 2 intermediate hops per result - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0, 3.0], durationMs: 100), - createTestResult(hopSNRs: [7.0, 1.0], durationMs: 100), - createTestResult(hopSNRs: [6.0, 2.0], durationMs: 100) - ] - - // Hop index 1 is first intermediate hop (index 0 is start node) - let stats1 = viewModel.hopStats(at: 1) - #expect(stats1 != nil) - #expect(stats1?.avg == 6.0) // (5+7+6)/3 - #expect(stats1?.min == 5.0) - #expect(stats1?.max == 7.0) - - // Hop index 2 is second intermediate hop - let stats2 = viewModel.hopStats(at: 2) - #expect(stats2 != nil) - #expect(stats2?.avg == 2.0) // (3+1+2)/3 - #expect(stats2?.min == 1.0) - #expect(stats2?.max == 3.0) - } - - @Test("hop stats return nil for start node") - func hopStatsReturnNilForStartNode() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100) - ] - - #expect(viewModel.hopStats(at: 0) == nil) - } - - @Test("latestHopSNR returns SNR from most recent successful result") - func latestHopSNRReturnsCorrectValue() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100), - createTestResult(hopSNRs: [7.0], durationMs: 100) - ] - - #expect(viewModel.latestHopSNR(at: 1) == 7.0) - } + @Test + func `RTT aggregates compute correctly`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100), + createTestResult(hopSNRs: [5.0], durationMs: 200), + createTestResult(hopSNRs: [5.0], durationMs: 150) + ] + + #expect(viewModel.averageRTT == 150) + #expect(viewModel.minRTT == 100) + #expect(viewModel.maxRTT == 200) + } + + @Test + func `RTT aggregates exclude failed traces`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100), + createTestResult(hopSNRs: [], durationMs: 0, success: false), + createTestResult(hopSNRs: [5.0], durationMs: 200) + ] + + #expect(viewModel.averageRTT == 150) + #expect(viewModel.minRTT == 100) + #expect(viewModel.maxRTT == 200) + } + + @Test + func `RTT aggregates return nil when no successful traces`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [], durationMs: 0, success: false) + ] + + #expect(viewModel.averageRTT == nil) + #expect(viewModel.minRTT == nil) + #expect(viewModel.maxRTT == nil) + } + + @Test + func `hop stats compute correctly for intermediate hops`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + // 2 intermediate hops per result + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0, 3.0], durationMs: 100), + createTestResult(hopSNRs: [7.0, 1.0], durationMs: 100), + createTestResult(hopSNRs: [6.0, 2.0], durationMs: 100) + ] + + // Hop index 1 is first intermediate hop (index 0 is start node) + let stats1 = viewModel.hopStats(at: 1) + #expect(stats1 != nil) + #expect(stats1?.avg == 6.0) // (5+7+6)/3 + #expect(stats1?.min == 5.0) + #expect(stats1?.max == 7.0) + + // Hop index 2 is second intermediate hop + let stats2 = viewModel.hopStats(at: 2) + #expect(stats2 != nil) + #expect(stats2?.avg == 2.0) // (3+1+2)/3 + #expect(stats2?.min == 1.0) + #expect(stats2?.max == 3.0) + } + + @Test + func `hop stats return nil for start node`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100) + ] + + #expect(viewModel.hopStats(at: 0) == nil) + } + + @Test + func `latestHopSNR returns SNR from most recent successful result`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100), + createTestResult(hopSNRs: [7.0], durationMs: 100) + ] + + #expect(viewModel.latestHopSNR(at: 1) == 7.0) + } } @Suite("Batch Execution") @MainActor struct BatchExecutionTests { - - @Test("runBatchTrace resets batch state before starting") - func runBatchTraceResetsBatchState() async { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.batchSize = 3 - - // Pre-populate with stale data - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100) - ] - viewModel.currentTraceIndex = 2 - - // Can't actually run without appState, but we can verify reset happens - await viewModel.runBatchTrace() - - // Should have reset (even though trace won't actually run without appState) - #expect(viewModel.completedResults.isEmpty) - #expect(viewModel.currentTraceIndex == 0) - } - - @Test("clearBatchState resets all batch properties") - func clearBatchStateResetsAll() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.currentTraceIndex = 3 - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100) - ] - - viewModel.clearBatchState() - - #expect(viewModel.currentTraceIndex == 0) - #expect(viewModel.completedResults.isEmpty) - } - - @Test("cancelBatchTrace clears running state") - func cancelBatchTraceClearsRunningState() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.isRunning = true - viewModel.currentTraceIndex = 2 - viewModel.setPendingTagForTesting(12345) - - viewModel.cancelBatchTrace() - - #expect(viewModel.isRunning == false) - #expect(viewModel.currentTraceIndex == 0) - } - - @Test("cancelBatchTrace resumes pending continuation") - func cancelBatchTraceResumesContinuation() async { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - // This test verifies the continuation isn't leaked - // (actual continuation behavior requires integration test) - viewModel.cancelBatchTrace() - - // Should not crash or hang - continuation was properly cleaned up - #expect(viewModel.isRunning == false) - } + @Test + func `runBatchTrace resets batch state before starting`() async { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.batchSize = 3 + + // Pre-populate with stale data + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100) + ] + viewModel.currentTraceIndex = 2 + + // Can't actually run without appState, but we can verify reset happens + await viewModel.runBatchTrace() + + // Should have reset (even though trace won't actually run without appState) + #expect(viewModel.completedResults.isEmpty) + #expect(viewModel.currentTraceIndex == 0) + } + + @Test + func `clearBatchState resets all batch properties`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.currentTraceIndex = 3 + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100) + ] + + viewModel.clearBatchState() + + #expect(viewModel.currentTraceIndex == 0) + #expect(viewModel.completedResults.isEmpty) + } + + @Test + func `cancelBatchTrace clears running state`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.isRunning = true + viewModel.currentTraceIndex = 2 + viewModel.setPendingTagForTesting(12345) + + viewModel.cancelBatchTrace() + + #expect(viewModel.isRunning == false) + #expect(viewModel.currentTraceIndex == 0) + } } @Suite("Batch Cancellation Behavior") @MainActor struct BatchCancellationTests { - - @Test("isBatchInProgress returns false after cancel") - func isBatchInProgressFalseAfterCancel() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.batchSize = 5 - viewModel.currentTraceIndex = 2 - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100) - ] - - // Verify it's in progress - #expect(viewModel.isBatchInProgress == true) - - viewModel.cancelBatchTrace() - - // Should no longer be in progress - #expect(viewModel.isBatchInProgress == false) - } - - @Test("batch state preserved on cancel for partial results access") - func batchStatePreservedOnCancel() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.batchSize = 5 - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100), - createTestResult(hopSNRs: [6.0], durationMs: 110) - ] - viewModel.currentTraceIndex = 3 - - viewModel.cancelBatchTrace() - - // Completed results should be preserved for viewing - #expect(viewModel.completedResults.count == 2) - // But execution state should be reset - #expect(viewModel.currentTraceIndex == 0) - } + @Test + func `isBatchInProgress returns false after cancel`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.batchSize = 5 + viewModel.currentTraceIndex = 2 + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100) + ] + + // Verify it's in progress + #expect(viewModel.isBatchInProgress == true) + + viewModel.cancelBatchTrace() + + // Should no longer be in progress + #expect(viewModel.isBatchInProgress == false) + } + + @Test + func `batch state preserved on cancel for partial results access`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.batchSize = 5 + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100), + createTestResult(hopSNRs: [6.0], durationMs: 110) + ] + viewModel.currentTraceIndex = 3 + + viewModel.cancelBatchTrace() + + // Completed results should be preserved for viewing + #expect(viewModel.completedResults.count == 2) + // But execution state should be reset + #expect(viewModel.currentTraceIndex == 0) + } } diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index 471937d4..f5d258ae 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -1,462 +1,122 @@ -import Testing import Foundation -import SwiftData @testable import MC1 @testable import MC1Services +import SwiftData +import Testing // MARK: - Test Helpers private func createTestContact( - id: UUID = UUID(), - radioID: UUID, - name: String = "TestContact" + id: UUID = UUID(), + radioID: UUID, + name: String = "TestContact" ) -> ContactDTO { - ContactDTO( - id: id, - radioID: radioID, - publicKey: Data((0.. ChannelDTO { - ChannelDTO( - id: id, - radioID: radioID, - index: index, - name: name, - secret: Data(), - isEnabled: true, - lastMessageDate: Date(), - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false - ) + ChannelDTO( + id: id, + radioID: radioID, + index: index, + name: name, + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) } private func createTestMessage( - contactID: UUID, - radioID: UUID, - timestamp: UInt32, - createdAt: Date = Date(), - direction: MessageDirection = .incoming, - text: String = "Test message" + contactID: UUID, + radioID: UUID, + timestamp: UInt32, + createdAt: Date = Date(), + direction: MessageDirection = .incoming, + text: String = "Test message" ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: text, - timestamp: timestamp, - createdAt: createdAt, - direction: direction, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: text, + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) } private func createChannelMessage( - radioID: UUID, - channelIndex: UInt8, - timestamp: UInt32, - senderName: String = "Sender", - text: String = "Test message" + radioID: UUID, + channelIndex: UInt8, + timestamp: UInt32, + senderName: String = "Sender", + text: String = "Test message" ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: nil, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) -} - -// MARK: - Mock DataStore for Pagination Testing - -/// A minimal mock data store for testing pagination behavior. -/// Uses in-memory storage and allows configuring responses. -actor PaginationTestDataStore: PersistenceStoreProtocol { - var messages: [UUID: MessageDTO] = [:] - var contacts: [UUID: ContactDTO] = [:] - var channels: [UUID: ChannelDTO] = [:] - var blockedContacts: [ContactDTO] = [] - - var stubbedFetchError: Error? - - init() {} - - // MARK: - Message Operations - - func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} - - func saveMessage(_ dto: MessageDTO) async throws { - messages[dto.id] = dto - } - - func fetchMessage(id: UUID) async throws -> MessageDTO? { - messages[id] - } - - func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { - var result: [UUID: [MessageDTO]] = [:] - for contactID in contactIDs { - let filtered = messages.values.filter { $0.contactID == contactID } - .sorted { $0.timestamp < $1.timestamp } - result[contactID] = Array(filtered.prefix(limit)) - } - return result - } - - func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { - var result: [UUID: [MessageDTO]] = [:] - for channel in channels { - let filtered = messages.values.filter { $0.radioID == channel.radioID && $0.channelIndex == channel.channelIndex } - .sorted { $0.timestamp < $1.timestamp } - result[channel.id] = Array(filtered.prefix(limit)) - } - return result - } - - func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { - if let error = stubbedFetchError { - throw error - } - // Match production: sort descending (newest first), apply offset/limit, then reverse to ascending - let filtered = messages.values.filter { $0.contactID == contactID } - .sorted { $0.timestamp > $1.timestamp } - return Array(filtered.dropFirst(offset).prefix(limit).reversed()) - } - - func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { - if let error = stubbedFetchError { - throw error - } - // Match production: sort descending (newest first), apply offset/limit, then reverse to ascending - let filtered = messages.values.filter { $0.radioID == radioID && $0.channelIndex == channelIndex } - .sorted { $0.timestamp > $1.timestamp } - return Array(filtered.dropFirst(offset).prefix(limit).reversed()) - } - - func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} - func updateMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) async throws {} - func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} - func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} - func updateMessageLinkPreview( - id: UUID, - url: String?, - title: String?, - imageData: Data?, - iconData: Data?, - fetched: Bool - ) throws {} - - // MARK: - Contact Operations - - func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { - contacts.values.filter { $0.radioID == radioID } - } - - func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { - contacts.values.filter { $0.radioID == radioID && $0.lastMessageDate != nil } - } - - func fetchContact(id: UUID) async throws -> ContactDTO? { - contacts[id] - } - - func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { - contacts.values.first { $0.radioID == radioID && $0.publicKey == publicKey } - } - - func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { - contacts.values.first { $0.radioID == radioID && $0.publicKey.prefix(6) == publicKeyPrefix } - } - - func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { [:] } - @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { UUID() } - func saveContact(_ dto: ContactDTO) async throws { contacts[dto.id] = dto } - func deleteContact(id: UUID) async throws { contacts.removeValue(forKey: id) } - func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} - func incrementUnreadCount(contactID: UUID) async throws {} - func clearUnreadCount(contactID: UUID) async throws {} - - // MARK: - Mention Tracking - - func markMentionSeen(messageID: UUID) async throws {} - func incrementUnreadMentionCount(contactID: UUID) async throws {} - func decrementUnreadMentionCount(contactID: UUID) async throws {} - func clearUnreadMentionCount(contactID: UUID) async throws {} - func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func clearChannelUnreadMentionCount(channelID: UUID) async throws {} - func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { [] } - func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { [] } - func deleteMessagesForContact(contactID: UUID) async throws {} - func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { - blockedContacts.filter { $0.radioID == radioID } - } - - // MARK: - Blocked Channel Senders - - func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} - func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} - func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} - func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { [] } - - // MARK: - Channel Operations - - func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { - channels.values.filter { $0.radioID == radioID }.sorted { $0.index < $1.index } - } - - func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { - channels.values.first { $0.radioID == radioID && $0.index == index } - } - - func fetchChannel(id: UUID) async throws -> ChannelDTO? { - channels[id] - } - - @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { UUID() } - func saveChannel(_ dto: ChannelDTO) async throws { channels[dto.id] = dto } - func deleteChannel(id: UUID) async throws { channels.removeValue(forKey: id) } - func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} - func incrementChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} - - // MARK: - Saved Trace Paths - - func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { [] } - func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { nil } - func createSavedTracePath( - radioID: UUID, - name: String, - pathBytes: Data, - hashSize: Int, - initialRun: TracePathRunDTO? - ) async throws -> SavedTracePathDTO { - SavedTracePathDTO( - id: UUID(), - radioID: radioID, - name: name, - pathBytes: pathBytes, - hashSize: hashSize, - createdDate: Date(), - runs: [] - ) - } - func updateSavedTracePathName(id: UUID, name: String) async throws {} - func deleteSavedTracePath(id: UUID) async throws {} - func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} - - // MARK: - Heard Repeats - - func findSentChannelMessage( - radioID: UUID, - channelIndex: UInt8, - timestamp: UInt32, - text: String, - withinSeconds: Int - ) async throws -> MessageDTO? { nil } - func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} - func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { [] } - func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { false } - func incrementMessageHeardRepeats(id: UUID) async throws -> Int { 0 } - func deleteMessageRepeats(messageID: UUID) async throws {} - func incrementMessageSendCount(id: UUID) async throws -> Int { 0 } - - // MARK: - Debug Log Operations - - func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} - func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { [] } - func countDebugLogEntries() async throws -> Int { 0 } - func pruneDebugLogEntries(keepCount: Int) async throws {} - func clearDebugLogEntries() async throws {} - - // MARK: - Link Preview Data - - func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { nil } - func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws {} - - // MARK: - RxLogEntry Lookup - - func findRxLogEntry( - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32 - ) async throws -> RxLogEntryDTO? { nil } - func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { nil } - - // MARK: - Discovered Nodes - - func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - fatalError("Not implemented") - } - func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { [] } - func deleteDiscoveredNode(id: UUID) async throws {} - func clearDiscoveredNodes(radioID: UUID) async throws {} - func fetchContactPublicKeys(radioID: UUID) async throws -> Set { Set() } - func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { [] } - func saveReaction(_ dto: ReactionDTO) async throws {} - func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { false } - func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} - func deleteReactionsForMessage(messageID: UUID) async throws {} - func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - - // MARK: - Notification Level - - func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} - func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} - func fetchDevice(id: UUID) async throws -> DeviceDTO? { nil } - func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { nil } - func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} - func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { nil } - func markSessionDisconnected(_ sessionID: UUID) async throws {} - func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { false } - func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { false } - func clearRetryingToSent(id: UUID) async throws -> Bool { false } - func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { false } - func markMessageAsRead(id: UUID) async throws {} - func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { nil } - func saveDevice(_ dto: DeviceDTO) async throws {} - func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { [] } - func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { [] } - func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} - func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} - func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} - func deleteRemoteNodeSession(id: UUID) async throws {} - func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} - func resetRoomUnreadCount(_ sessionID: UUID) async throws {} - func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { nil } - func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { nil } - func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} - func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } - func clearRxLogEntries(radioID: UUID) async throws {} - func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { [] } - func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} - func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} - func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - - // MARK: - Channel Message Deletion - - func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} - - // MARK: - Pending Sends - - func upsertPendingSend(_ dto: PendingSendDTO) async throws {} - func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { 0 } - func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { [] } - func deletePendingSend(id: UUID) async throws {} - func deletePendingSendsForMessage(messageID: UUID) async throws {} - func hasPendingSend(messageID: UUID) async throws -> Bool { false } - - // MARK: - Room Messages - - func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} - func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { nil } - func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { [] } - func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { false } - func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { false } - func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} - func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} - - // MARK: - Node Status Snapshots - - // swiftlint:disable:next line_length function_parameter_count - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } - func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } - func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } - func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} - func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} - func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { UUID() } - func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { UUID() } - func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} -} - -// MARK: - Mock Link Preview Cache - -actor MockLinkPreviewCacheForPagination: LinkPreviewCaching { - func preview( - for url: URL, - using dataStore: any PersistenceStoreProtocol, - isChannelMessage: Bool - ) async -> LinkPreviewResult { - .noPreviewAvailable - } - - func manualFetch( - for url: URL, - using dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - .noPreviewAvailable - } - - func isFetching(_ url: URL) async -> Bool { false } - func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { nil } + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) } // MARK: - Pagination Tests @@ -464,217 +124,73 @@ actor MockLinkPreviewCacheForPagination: LinkPreviewCaching { @Suite("ChatViewModel Pagination Tests") @MainActor struct ChatViewModelPaginationTests { - - // MARK: - Test: loadOlderMessages sets hasMoreMessages = false when fewer than pageSize returned - - @Test("Loading fewer messages than pageSize marks no more messages available") - func loadFewerThanPageSizeStopsLoading() async throws { - let dataStore = PaginationTestDataStore() - let linkPreviewCache = MockLinkPreviewCacheForPagination() - let viewModel = ChatViewModel() - - let radioID = UUID() - let contactID = UUID() - let contact = createTestContact(id: contactID, radioID: radioID) - - try await dataStore.saveContact(contact) - - // Add only 10 messages (less than pageSize of 50) - for index in 0..<10 { - let message = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(1000 + index) - ) - try await dataStore.saveMessage(message) - } - - // Configure view model - need to use the internal configure method - // Since we can't directly inject a PersistenceStoreProtocol, we'll test through observable behavior - viewModel.currentContact = contact - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - let fetched = try await dataStore.fetchMessages(contactID: contactID, limit: 50, offset: 0) - coordinator.replaceAll(fetched) - - let initialCount = viewModel.messages.count - #expect(initialCount == 10) - - // After loading 10 messages (< 50 pageSize), hasMoreMessages should be false internally - // We verify this by checking that calling loadOlderMessages has no effect - // when there are no more messages (since we loaded all 10 and offset would be 10) - - // Direct unit test of the pagination logic: if initial load < pageSize, no more loading - #expect(viewModel.messages.count < 50, "Should have fewer than pageSize messages") - } - - @Test("loadOlderMessages prepends messages to array") - func loadOlderMessagesPrepends() async throws { - let dataStore = PaginationTestDataStore() - let radioID = UUID() - let contactID = UUID() - let contact = createTestContact(id: contactID, radioID: radioID) - - try await dataStore.saveContact(contact) - - // Add 60 messages with sequential timestamps (0-59) - for index in 0..<60 { - let message = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(1000 + index), - text: "Message \(index)" - ) - try await dataStore.saveMessage(message) - } - - // Production pagination: sort descending, apply offset/limit, reverse to ascending - // With 60 messages (timestamps 1000-1059): - // - offset 0, limit 50 returns the 50 most recent (1010-1059), sorted ascending - // - offset 50, limit 50 returns the next 10 older (1000-1009), sorted ascending - - let firstPage = try await dataStore.fetchMessages(contactID: contactID, limit: 50, offset: 0) - #expect(firstPage.count == 50) - #expect(firstPage.first?.timestamp == 1010, "First page starts with oldest of the 50 most recent") - #expect(firstPage.last?.timestamp == 1059, "First page ends with the most recent message") - - // Fetch older messages (what loadOlderMessages does) - let secondPage = try await dataStore.fetchMessages(contactID: contactID, limit: 50, offset: 50) - #expect(secondPage.count == 10, "Second page has remaining 10 older messages") - #expect(secondPage.first?.timestamp == 1000, "Second page starts with the oldest message") - #expect(secondPage.last?.timestamp == 1009, "Second page ends before first page starts") - - // Simulate loadOlderMessages: prepend older messages - var messages = firstPage - messages.insert(contentsOf: secondPage, at: 0) - - #expect(messages.count == 60) - #expect(messages.first?.timestamp == 1000, "After prepend, oldest is first") - #expect(messages.last?.timestamp == 1059, "After prepend, newest is last") - } - - @Test("loadOlderMessages guards against concurrent fetches") - func loadOlderMessagesGuardsConcurrent() async { - let viewModel = ChatViewModel() - - // isLoadingOlder starts false - #expect(viewModel.isLoadingOlder == false) - - // After initial setup without dataStore, calling loadOlderMessages returns early - // This tests the guard condition - await viewModel.loadOlderMessages() - #expect(viewModel.isLoadingOlder == false) - } - - @Test("loadOlderMessages returns early without dataStore") - func loadOlderMessagesWithoutDataStoreDoesNothing() async { - let viewModel = ChatViewModel() - let radioID = UUID() - let contactID = UUID() - let contact = createTestContact(id: contactID, radioID: radioID) - - viewModel.currentContact = contact - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - - // Without configuring dataStore, loadOlderMessages should return early - await viewModel.loadOlderMessages() - - // No error should be set - #expect(viewModel.errorMessage == nil) - #expect(viewModel.messages.isEmpty) - } - - @Test("Pagination state resets when loading messages for new contact") - func paginationStateResetsOnConversationSwitch() async { - let viewModel = ChatViewModel() - let radioID = UUID() - - // Create two contacts - let contactA = createTestContact(id: UUID(), radioID: radioID, name: "Alice") - let contactB = createTestContact(id: UUID(), radioID: radioID, name: "Bob") - - // Start with contact A - viewModel.currentContact = contactA - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - coordinator.replaceAll([ - createTestMessage(contactID: contactA.id, radioID: radioID, timestamp: 1000) - ]) - - // isLoadingOlder should be false - #expect(viewModel.isLoadingOlder == false) - - // Switch to contact B - viewModel.currentContact = contactB - coordinator.replaceAll([]) - - // State should be clean for new contact - #expect(viewModel.messages.isEmpty) - #expect(viewModel.isLoadingOlder == false) + @Test + func `loadOlderMessages returns early without dataStore`() async { + let viewModel = ChatViewModel() + let radioID = UUID() + let contactID = UUID() + let contact = createTestContact(id: contactID, radioID: radioID) + + viewModel.currentContact = contact + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + + // Without configuring dataStore, loadOlderMessages should return early + await viewModel.loadOlderMessages() + + // No error should be set + #expect(viewModel.errorMessage == nil) + #expect(viewModel.messages.isEmpty) + } + + /// Verifies that `loadOlderMessages` clears `isLoadingOlder` before + /// entering the reaction-indexing loop, so the pagination spinner is not + /// held open by a slow `ReactionService.indexMessage` actor hop on a + /// busy channel. The structural ordering is enforced in + /// `ChatViewModel.loadOlderMessages`: the spinner is cleared immediately + /// after `buildItems()` and before the channel/DM reaction-indexing + /// blocks. This test exercises the success path with the reaction-service + /// provider at its nil default (so the indexing loops are skipped) and asserts the post-return + /// state matches the documented contract — `isLoadingOlder == false`, + /// older messages prepended, no error surfaced. The deeper "spinner + /// already false while indexing still running" property is verified by + /// source-level review at PR time; `ReactionService` is a concrete + /// actor without a protocol surface, so an injectable continuation- + /// blocking stub is not available. + @Test + func `loadOlderMessages clears isLoadingOlder on the success path`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contactID = UUID() + + let viewModel = ChatViewModel() + viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) + viewModel.currentContact = createTestContact(id: contactID, radioID: radioID) + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + + // Seed the database with a page worth of messages so the + // pagination fetch returns rows and proceeds past the + // prepend/buildItems block where the early-clear lives. + for index in 0..<10 { + let message = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: UInt32(1000 + index) + ) + try await dataStore.saveMessage(message) } - @Test("Initial message load sets hasMoreMessages based on count") - func initialLoadSetsHasMoreMessages() async { - let viewModel = ChatViewModel() - - // When messages.count equals pageSize (50), hasMoreMessages should remain true - // When messages.count < pageSize, hasMoreMessages becomes false + #expect(viewModel.isLoadingOlder == false) - // This is tested indirectly through the loadMessages behavior - // The key is that with < 50 messages, subsequent loadOlderMessages calls should not fetch + await viewModel.loadOlderMessages() - #expect(viewModel.messages.isEmpty) - } - - /// Verifies that `loadOlderMessages` clears `isLoadingOlder` before - /// entering the reaction-indexing loop, so the pagination spinner is not - /// held open by a slow `ReactionService.indexMessage` actor hop on a - /// busy channel. The structural ordering is enforced in - /// `ChatViewModel.loadOlderMessages`: the spinner is cleared immediately - /// after `buildItems()` and before the channel/DM reaction-indexing - /// blocks. This test exercises the success path with the reaction-service - /// provider at its nil default (so the indexing loops are skipped) and asserts the post-return - /// state matches the documented contract — `isLoadingOlder == false`, - /// older messages prepended, no error surfaced. The deeper "spinner - /// already false while indexing still running" property is verified by - /// source-level review at PR time; `ReactionService` is a concrete - /// actor without a protocol surface, so an injectable continuation- - /// blocking stub is not available. - @Test("loadOlderMessages clears isLoadingOlder on the success path") - func loadOlderMessagesClearsSpinnerBeforeIndexing() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let radioID = UUID() - let contactID = UUID() - - let viewModel = ChatViewModel() - viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) - viewModel.currentContact = createTestContact(id: contactID, radioID: radioID) - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - - // Seed the database with a page worth of messages so the - // pagination fetch returns rows and proceeds past the - // prepend/buildItems block where the early-clear lives. - for index in 0..<10 { - let message = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(1000 + index) - ) - try await dataStore.saveMessage(message) - } - - #expect(viewModel.isLoadingOlder == false) - - await viewModel.loadOlderMessages() - - #expect(viewModel.isLoadingOlder == false, "Pagination must release the spinner before returning") - #expect(viewModel.errorMessage == nil) - #expect(viewModel.errorBannerMessage == nil) - #expect(viewModel.messages.count == 10, "Pagination should have prepended fetched messages") - } + #expect(viewModel.isLoadingOlder == false, "Pagination must release the spinner before returning") + #expect(viewModel.errorMessage == nil) + #expect(viewModel.errorBannerMessage == nil) + #expect(viewModel.messages.count == 10, "Pagination should have prepended fetched messages") + } } // MARK: - Channel Pagination Tests @@ -682,130 +198,42 @@ struct ChatViewModelPaginationTests { @Suite("ChatViewModel Channel Pagination Tests") @MainActor struct ChatViewModelChannelPaginationTests { + @Test + func `Switching from a channel to a DM clears the channel axis so an incoming channel message is not admitted into the open DM`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channelIndex: UInt8 = 1 + let contactID = UUID() + + let viewModel = ChatViewModel() + viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) + viewModel.coordinator = ChatCoordinator.makeForTesting() + + let channel = createTestChannel(radioID: radioID, index: channelIndex, name: "General") + let contact = createTestContact(id: contactID, radioID: radioID) + + // Open the channel, then switch to the DM. loadMessages(for:) must clear currentChannel. + await viewModel.loadChannelMessages(for: channel) + #expect(viewModel.currentChannel?.index == channelIndex) + + await viewModel.loadMessages(for: contact) + #expect(viewModel.currentChannel == nil, "Loading a DM must clear the channel axis") + #expect(viewModel.currentContact?.id == contactID) + + let baselineCount = viewModel.messages.count + + // A channel message for the channel that was just open must not enter the open DM. + let channelMessage = createChannelMessage( + radioID: radioID, + channelIndex: channelIndex, + timestamp: 2000 + ) + await viewModel.handle(.channelMessageReceived(message: channelMessage, channelIndex: channelIndex)) - @Test("Channel message pagination works similar to direct messages") - func channelPaginationWorks() async throws { - let dataStore = PaginationTestDataStore() - let radioID = UUID() - let channelIndex: UInt8 = 0 - let channel = createTestChannel(radioID: radioID, index: channelIndex) - - try await dataStore.saveChannel(channel) - - // Add 30 channel messages - for index in 0..<30 { - let message = createChannelMessage( - radioID: radioID, - channelIndex: channelIndex, - timestamp: UInt32(1000 + index), - senderName: "User\(index % 3)" - ) - try await dataStore.saveMessage(message) - } - - // Fetch first page - let messages = try await dataStore.fetchMessages( - radioID: radioID, - channelIndex: channelIndex, - limit: 50, - offset: 0 - ) - - #expect(messages.count == 30) - #expect(messages.count < 50, "Fewer than pageSize means no more messages available") - } - - @Test("loadOlderMessages handles channel messages") - func loadOlderMessagesHandlesChannels() async { - let viewModel = ChatViewModel() - let radioID = UUID() - let channelIndex: UInt8 = 1 - let channel = createTestChannel(radioID: radioID, index: channelIndex, name: "General") - - viewModel.currentChannel = channel - viewModel.currentContact = nil - - // Without dataStore configured, loadOlderMessages returns early - await viewModel.loadOlderMessages() - - #expect(viewModel.isLoadingOlder == false) - #expect(viewModel.errorMessage == nil) - } - - @Test("Initial channel load uses unfiltered count for hasMoreMessages") - func initialLoadUsesUnfilteredCountForPagination() async throws { - // If we fetch 50 messages and 10 are blocked, hasMoreMessages should still be true - // because the unfiltered count (50) equals pageSize - let dataStore = PaginationTestDataStore() - let radioID = UUID() - let channelIndex: UInt8 = 0 - - // Add exactly 50 messages (pageSize), some from blocked sender - for index in 0..<50 { - let senderName = index < 10 ? "BlockedUser" : "User\(index)" - let message = createChannelMessage( - radioID: radioID, - channelIndex: channelIndex, - timestamp: UInt32(1000 + index), - senderName: senderName - ) - try await dataStore.saveMessage(message) - } - - // Fetch all messages - let messages = try await dataStore.fetchMessages( - radioID: radioID, - channelIndex: channelIndex, - limit: 50, - offset: 0 - ) - - #expect(messages.count == 50, "Should fetch 50 messages before filtering") - - // After filtering, would have 40 messages, but hasMoreMessages should be based on 50 - let filtered = messages.filter { $0.senderNodeName != "BlockedUser" } - #expect(filtered.count == 40, "After filtering should have 40 messages") - - // The key insight: unfiltered count (50) == pageSize means hasMoreMessages = true - #expect(messages.count == 50, "Unfiltered count should drive pagination decision") - } - - @Test("Switching from a channel to a DM clears the channel axis so an incoming channel message is not admitted into the open DM") - func channelToDMSwitchRejectsLateChannelMessage() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let radioID = UUID() - let channelIndex: UInt8 = 1 - let contactID = UUID() - - let viewModel = ChatViewModel() - viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) - viewModel.coordinator = ChatCoordinator.makeForTesting() - - let channel = createTestChannel(radioID: radioID, index: channelIndex, name: "General") - let contact = createTestContact(id: contactID, radioID: radioID) - - // Open the channel, then switch to the DM. loadMessages(for:) must clear currentChannel. - await viewModel.loadChannelMessages(for: channel) - #expect(viewModel.currentChannel?.index == channelIndex) - - await viewModel.loadMessages(for: contact) - #expect(viewModel.currentChannel == nil, "Loading a DM must clear the channel axis") - #expect(viewModel.currentContact?.id == contactID) - - let baselineCount = viewModel.messages.count - - // A channel message for the channel that was just open must not enter the open DM. - let channelMessage = createChannelMessage( - radioID: radioID, - channelIndex: channelIndex, - timestamp: 2_000 - ) - await viewModel.handle(.channelMessageReceived(message: channelMessage, channelIndex: channelIndex)) - - #expect(viewModel.messages.count == baselineCount, - "An incoming channel message must not be admitted into the open DM after the axis switch") - } + #expect(viewModel.messages.count == baselineCount, + "An incoming channel message must not be admitted into the open DM after the axis switch") + } } // MARK: - Display Items Tests @@ -813,257 +241,254 @@ struct ChatViewModelChannelPaginationTests { @Suite("ChatViewModel Display Items Pagination Tests") @MainActor struct ChatViewModelDisplayItemsPaginationTests { - - @Test("Display items are rebuilt after loading older messages") - func displayItemsRebuildAfterLoadingOlder() async { - let viewModel = ChatViewModel() - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - - // Start with some messages - let radioID = UUID() - let contactID = UUID() - - let messages = (0..<5).map { index in - createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(1000 + index) - ) - } - - coordinator.replaceAll(messages) - viewModel.buildItems() - await coordinator.buildItemsTask?.value - - #expect(viewModel.items.count == 5) - - // Add more messages (simulating loadOlderMessages prepend) - let olderMessages = (0..<3).map { index in - createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(900 + index) - ) - } - - coordinator.prepend(olderMessages) - viewModel.buildItems() - await coordinator.buildItemsTask?.value - - #expect(viewModel.items.count == 8) - } - - @Test("Message lookup by ID works after pagination") - func messageLookupWorksAfterPagination() async { - let viewModel = ChatViewModel() - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - let radioID = UUID() - let contactID = UUID() - - let message1 = createTestMessage(contactID: contactID, radioID: radioID, timestamp: 1000) - let message2 = createTestMessage(contactID: contactID, radioID: radioID, timestamp: 1001) - - coordinator.replaceAll([message1, message2]) - viewModel.buildItems() - await coordinator.buildItemsTask?.value - - // Lookup should work - #expect(viewModel.items.count == 2) - let foundMessage = viewModel.message(for: viewModel.items[0]) - #expect(foundMessage?.id == message1.id) + @Test + func `Display items are rebuilt after loading older messages`() async { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + + // Start with some messages + let radioID = UUID() + let contactID = UUID() + + let messages = (0..<5).map { index in + createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: UInt32(1000 + index) + ) } -} -// MARK: - Cross-Boundary Reordering Tests + coordinator.replaceAll(messages) + viewModel.buildItems() + await coordinator.buildItemsTask?.value -@Suite("Same-Sender Cluster Reordering Across Page Boundaries") -@MainActor -struct CrossBoundaryReorderingTests { + #expect(viewModel.items.count == 5) - @Test("Reordering fixes same-sender cluster split across pagination boundary") - func reorderingFixesSplitCluster() { - // Scenario: Sender sends msg1 (t=100), msg2 (t=101), msg3 (t=102) rapidly. - // Mesh delivers them out of order: msg3, msg1, msg2. - // msg3 ends up on page 2 (older), msg1 and msg2 on page 1 (newer). - // - // Each page is reordered independently, but the cross-boundary cluster - // (msg3 on page 2, msg1+msg2 on page 1) is NOT reordered until merge. - - let radioID = UUID() - let contactID = UUID() - let base = Date(timeIntervalSince1970: 1_000_000) - - // Page 2 (older, loaded second via loadOlderMessages): msg3 arrived first - let msg3 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 102, - createdAt: base.addingTimeInterval(0), // received first - text: "msg3" - ) - - // Page 1 (newer, loaded first): msg1 and msg2 arrived later - let msg1 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 100, - createdAt: base.addingTimeInterval(2), // received second - text: "msg1" - ) - let msg2 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 101, - createdAt: base.addingTimeInterval(3), // received third - text: "msg2" - ) - - // Simulate independent per-page reordering (as production does) - let page2Reordered = MessageDTO.reorderSameSenderClusters([msg3]) // single msg, no-op - let page1Reordered = MessageDTO.reorderSameSenderClusters([msg1, msg2]) // already ordered - - // Merge: prepend older page - var merged = page2Reordered - merged.append(contentsOf: page1Reordered) - - // Without cross-boundary reordering: msg3, msg1, msg2 (receive order at boundary) - #expect(merged.map(\.text) == ["msg3", "msg1", "msg2"]) - - // After re-running reorderSameSenderClusters on the full merged array - let fixed = MessageDTO.reorderSameSenderClusters(merged) - - // All three are from the same sender (DM, same direction), within 5s window, - // so they're reordered by sender timestamp: msg1, msg2, msg3 - #expect(fixed.map(\.text) == ["msg1", "msg2", "msg3"]) + // Add more messages (simulating loadOlderMessages prepend) + let olderMessages = (0..<3).map { index in + createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: UInt32(900 + index) + ) } - @Test("Reordering does not merge clusters beyond the 5-second window") - func reorderingRespectsWindowAtBoundary() { - let radioID = UUID() - let contactID = UUID() - let base = Date(timeIntervalSince1970: 1_000_000) - - // Page 2 message: received well before the page 1 messages (>5s gap) - let oldMsg = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 100, - createdAt: base.addingTimeInterval(0), - text: "old" - ) - - // Page 1 messages: received 10 seconds later - let newMsg1 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 99, // earlier sender timestamp but later receive - createdAt: base.addingTimeInterval(10), - text: "new1" - ) - let newMsg2 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 102, - createdAt: base.addingTimeInterval(11), - text: "new2" - ) - - // Merge: prepend older page - var merged = [oldMsg] - merged.append(contentsOf: [newMsg1, newMsg2]) - - let result = MessageDTO.reorderSameSenderClusters(merged) - - // The 10-second gap between oldMsg and newMsg1 exceeds the 5s window, - // so they should NOT be clustered — order stays as-is - #expect(result.map(\.text) == ["old", "new1", "new2"]) - } -} + coordinator.prepend(olderMessages) + viewModel.buildItems() + await coordinator.buildItemsTask?.value -// MARK: - Channel Sender Registration (Pagination Regression) + #expect(viewModel.items.count == 8) + } -@Suite("ChatViewModel Channel Sender Registration") -@MainActor -struct ChatViewModelChannelSenderRegistrationTests { + @Test + func `Message lookup by ID works after pagination`() async { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + let radioID = UUID() + let contactID = UUID() - @Test("addChannelSenderIfNew inserts synthetic sender and records timestamp") - func registersNewSender() { - let viewModel = ChatViewModel() - let radioID = UUID() + let message1 = createTestMessage(contactID: contactID, radioID: radioID, timestamp: 1000) + let message2 = createTestMessage(contactID: contactID, radioID: radioID, timestamp: 1001) - viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 1000) + coordinator.replaceAll([message1, message2]) + viewModel.buildItems() + await coordinator.buildItemsTask?.value - #expect(viewModel.channelSenderNames.contains("Alice")) - #expect(viewModel.channelSenders.count == 1) - #expect(viewModel.channelSenderOrder["Alice"] == 1000) - } + // Lookup should work + #expect(viewModel.items.count == 2) + let foundMessage = viewModel.message(for: viewModel.items[0]) + #expect(foundMessage?.id == message1.id) + } +} - @Test("addChannelSenderIfNew max-merges timestamps for the same sender") - func maxMergesTimestamp() { - let viewModel = ChatViewModel() - let radioID = UUID() +// MARK: - Cross-Boundary Reordering Tests - viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 1000) - viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 500) +@Suite("Same-Sender Cluster Reordering Across Page Boundaries") +@MainActor +struct CrossBoundaryReorderingTests { + @Test + func `Reordering fixes same-sender cluster split across pagination boundary`() { + // Scenario: Sender sends msg1 (t=100), msg2 (t=101), msg3 (t=102) rapidly. + // Mesh delivers them out of order: msg3, msg1, msg2. + // msg3 ends up on page 2 (older), msg1 and msg2 on page 1 (newer). + // + // Each page is reordered independently, but the cross-boundary cluster + // (msg3 on page 2, msg1+msg2 on page 1) is NOT reordered until merge. + + let radioID = UUID() + let contactID = UUID() + let base = Date(timeIntervalSince1970: 1_000_000) + + // Page 2 (older, loaded second via loadOlderMessages): msg3 arrived first + let msg3 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 102, + createdAt: base.addingTimeInterval(0), // received first + text: "msg3" + ) - // Older message arriving after a newer one must not lower the recency stamp. - #expect(viewModel.channelSenderOrder["Alice"] == 1000) - #expect(viewModel.channelSenders.count == 1) - } + // Page 1 (newer, loaded first): msg1 and msg2 arrived later + let msg1 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 100, + createdAt: base.addingTimeInterval(2), // received second + text: "msg1" + ) + let msg2 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 101, + createdAt: base.addingTimeInterval(3), // received third + text: "msg2" + ) - @Test("addChannelSenderIfNew skips synthetic for known contacts but still records order") - func recordsOrderForKnownContact() { - let viewModel = ChatViewModel() - let radioID = UUID() - viewModel.contactNameSet = ["Bob"] + // Simulate independent per-page reordering (as production does) + let page2Reordered = MessageDTO.reorderSameSenderClusters([msg3]) // single msg, no-op + let page1Reordered = MessageDTO.reorderSameSenderClusters([msg1, msg2]) // already ordered + + // Merge: prepend older page + var merged = page2Reordered + merged.append(contentsOf: page1Reordered) + + // Without cross-boundary reordering: msg3, msg1, msg2 (receive order at boundary) + #expect(merged.map(\.text) == ["msg3", "msg1", "msg2"]) + + // After re-running reorderSameSenderClusters on the full merged array + let fixed = MessageDTO.reorderSameSenderClusters(merged) + + // All three are from the same sender (DM, same direction), within 5s window, + // so they're reordered by sender timestamp: msg1, msg2, msg3 + #expect(fixed.map(\.text) == ["msg1", "msg2", "msg3"]) + } + + @Test + func `Reordering does not merge clusters beyond the 5-second window`() { + let radioID = UUID() + let contactID = UUID() + let base = Date(timeIntervalSince1970: 1_000_000) + + // Page 2 message: received well before the page 1 messages (>5s gap) + let oldMsg = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 100, + createdAt: base.addingTimeInterval(0), + text: "old" + ) - viewModel.addChannelSenderIfNew("Bob", radioID: radioID, timestamp: 1500) + // Page 1 messages: received 10 seconds later + let newMsg1 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 99, // earlier sender timestamp but later receive + createdAt: base.addingTimeInterval(10), + text: "new1" + ) + let newMsg2 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 102, + createdAt: base.addingTimeInterval(11), + text: "new2" + ) - #expect(viewModel.channelSenders.isEmpty, "No synthetic for a known contact") - #expect(viewModel.channelSenderNames.contains("Bob") == false) - #expect(viewModel.channelSenderOrder["Bob"] == 1500, "Order tracks all senders for mention recency") - } + // Merge: prepend older page + var merged = [oldMsg] + merged.append(contentsOf: [newMsg1, newMsg2]) - @Test("addChannelSenderIfNew rejects empty and oversized names") - func rejectsInvalidNames() { - let viewModel = ChatViewModel() - let radioID = UUID() - let tooLong = String(repeating: "x", count: 129) + let result = MessageDTO.reorderSameSenderClusters(merged) - viewModel.addChannelSenderIfNew(" ", radioID: radioID, timestamp: 1) - viewModel.addChannelSenderIfNew("", radioID: radioID, timestamp: 1) - viewModel.addChannelSenderIfNew(tooLong, radioID: radioID, timestamp: 1) + // The 10-second gap between oldMsg and newMsg1 exceeds the 5s window, + // so they should NOT be clustered — order stays as-is + #expect(result.map(\.text) == ["old", "new1", "new2"]) + } +} - #expect(viewModel.channelSenders.isEmpty) - #expect(viewModel.channelSenderOrder.isEmpty) - } +// MARK: - Channel Sender Registration (Pagination Regression) - @Test("Pagination registration: older-page senders join channelSenderNames and channelSenderOrder") - func paginationRegistersOlderSenders() { - // Simulates the loadOlderMessages call site: live receive registers - // recent senders first, then a paginated older page registers earlier - // senders. After both steps, the mention picker must see all senders - // and the order map must reflect each sender's own timestamp. - let viewModel = ChatViewModel() - let radioID = UUID() - - // Live-receive: senders A and B on the current page - viewModel.addChannelSenderIfNew("A", radioID: radioID, timestamp: 2000) - viewModel.addChannelSenderIfNew("B", radioID: radioID, timestamp: 2100) - - // Pagination: older page reveals senders C and D, plus another - // A message from earlier - viewModel.addChannelSenderIfNew("C", radioID: radioID, timestamp: 500) - viewModel.addChannelSenderIfNew("D", radioID: radioID, timestamp: 600) - viewModel.addChannelSenderIfNew("A", radioID: radioID, timestamp: 100) - - #expect(viewModel.channelSenderNames == ["A", "B", "C", "D"]) - #expect(viewModel.channelSenders.count == 4) - #expect(viewModel.channelSenderOrder["A"] == 2000, "A keeps its live recency, not the older one") - #expect(viewModel.channelSenderOrder["B"] == 2100) - #expect(viewModel.channelSenderOrder["C"] == 500) - #expect(viewModel.channelSenderOrder["D"] == 600) - } +@Suite("ChatViewModel Channel Sender Registration") +@MainActor +struct ChatViewModelChannelSenderRegistrationTests { + @Test + func `addChannelSenderIfNew inserts synthetic sender and records timestamp`() { + let viewModel = ChatViewModel() + let radioID = UUID() + + viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 1000) + + #expect(viewModel.channelSenderNames.contains("Alice")) + #expect(viewModel.channelSenders.count == 1) + #expect(viewModel.channelSenderOrder["Alice"] == 1000) + } + + @Test + func `addChannelSenderIfNew max-merges timestamps for the same sender`() { + let viewModel = ChatViewModel() + let radioID = UUID() + + viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 1000) + viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 500) + + // Older message arriving after a newer one must not lower the recency stamp. + #expect(viewModel.channelSenderOrder["Alice"] == 1000) + #expect(viewModel.channelSenders.count == 1) + } + + @Test + func `addChannelSenderIfNew skips synthetic for known contacts but still records order`() { + let viewModel = ChatViewModel() + let radioID = UUID() + viewModel.contactNameSet = ["Bob"] + + viewModel.addChannelSenderIfNew("Bob", radioID: radioID, timestamp: 1500) + + #expect(viewModel.channelSenders.isEmpty, "No synthetic for a known contact") + #expect(viewModel.channelSenderNames.contains("Bob") == false) + #expect(viewModel.channelSenderOrder["Bob"] == 1500, "Order tracks all senders for mention recency") + } + + @Test + func `addChannelSenderIfNew rejects empty and oversized names`() { + let viewModel = ChatViewModel() + let radioID = UUID() + let tooLong = String(repeating: "x", count: 129) + + viewModel.addChannelSenderIfNew(" ", radioID: radioID, timestamp: 1) + viewModel.addChannelSenderIfNew("", radioID: radioID, timestamp: 1) + viewModel.addChannelSenderIfNew(tooLong, radioID: radioID, timestamp: 1) + + #expect(viewModel.channelSenders.isEmpty) + #expect(viewModel.channelSenderOrder.isEmpty) + } + + @Test + func `Pagination registration: older-page senders join channelSenderNames and channelSenderOrder`() { + // Simulates the loadOlderMessages call site: live receive registers + // recent senders first, then a paginated older page registers earlier + // senders. After both steps, the mention picker must see all senders + // and the order map must reflect each sender's own timestamp. + let viewModel = ChatViewModel() + let radioID = UUID() + + // Live-receive: senders A and B on the current page + viewModel.addChannelSenderIfNew("A", radioID: radioID, timestamp: 2000) + viewModel.addChannelSenderIfNew("B", radioID: radioID, timestamp: 2100) + + // Pagination: older page reveals senders C and D, plus another + // A message from earlier + viewModel.addChannelSenderIfNew("C", radioID: radioID, timestamp: 500) + viewModel.addChannelSenderIfNew("D", radioID: radioID, timestamp: 600) + viewModel.addChannelSenderIfNew("A", radioID: radioID, timestamp: 100) + + #expect(viewModel.channelSenderNames == ["A", "B", "C", "D"]) + #expect(viewModel.channelSenders.count == 4) + #expect(viewModel.channelSenderOrder["A"] == 2000, "A keeps its live recency, not the older one") + #expect(viewModel.channelSenderOrder["B"] == 2100) + #expect(viewModel.channelSenderOrder["C"] == 500) + #expect(viewModel.channelSenderOrder["D"] == 600) + } } diff --git a/MC1Tests/ViewModels/ChatViewModelTests.swift b/MC1Tests/ViewModels/ChatViewModelTests.swift index ca144d7e..f7da5353 100644 --- a/MC1Tests/ViewModels/ChatViewModelTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelTests.swift @@ -1,96 +1,96 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing // MARK: - Test Helpers private func createTestContact( - radioID: UUID = UUID(), - name: String = "TestContact", - type: ContactType = .chat, - isBlocked: Bool = false + radioID: UUID = UUID(), + name: String = "TestContact", + type: ContactType = .chat, + isBlocked: Bool = false ) -> ContactDTO { - let contact = Contact( - id: UUID(), - radioID: radioID, - publicKey: Data((0.. MessageDTO { - let resolvedCreatedAt = createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)) - let message = Message( - id: UUID(), - radioID: UUID(), - contactID: UUID(), - text: text, - timestamp: timestamp, - createdAt: resolvedCreatedAt, - sortDate: sortDate, - directionRawValue: MessageDirection.outgoing.rawValue, - statusRawValue: MessageStatus.sent.rawValue - ) - return MessageDTO(from: message) + let resolvedCreatedAt = createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)) + let message = Message( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + text: text, + timestamp: timestamp, + createdAt: resolvedCreatedAt, + sortDate: sortDate, + directionRawValue: MessageDirection.outgoing.rawValue, + statusRawValue: MessageStatus.sent.rawValue + ) + return MessageDTO(from: message) } private func createChannelMessage( - timestamp: UInt32, - createdAt: Date? = nil, - senderName: String? = nil, - isOutgoing: Bool = false, - text: String = "Test message" + timestamp: UInt32, + createdAt: Date? = nil, + senderName: String? = nil, + isOutgoing: Bool = false, + text: String = "Test message" ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: UUID(), - contactID: nil, // nil = channel message - channelIndex: 0, - text: text, - timestamp: timestamp, - createdAt: createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)), - direction: isOutgoing ? .outgoing : .incoming, - status: isOutgoing ? .sent : .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, // Always nil for channel messages per MeshCore protocol - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: nil, // nil = channel message + channelIndex: 0, + text: text, + timestamp: timestamp, + createdAt: createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: isOutgoing ? .outgoing : .incoming, + status: isOutgoing ? .sent : .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, // Always nil for channel messages per MeshCore protocol + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) } /// Builds a calendar date at a specific day and time in the current calendar. private func makeDate(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int = 0) -> Date { - Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minute))! + Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minute))! } /// Sender-clock timestamp for a day/time. Day-divider detection keys on /// `MessageDTO.senderDate`, which derives from `timestamp`, so this drives the real path. private func makeTimestamp(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int = 0) -> UInt32 { - UInt32(makeDate(year, month, day, hour, minute).timeIntervalSince1970) + UInt32(makeDate(year, month, day, hour, minute).timeIntervalSince1970) } // MARK: - ChatViewModel Tests @@ -98,497 +98,523 @@ private func makeTimestamp(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ @Suite("ChatViewModel Tests") @MainActor struct ChatViewModelTests { - - /// `ChatViewModel.makeBuildInputs` calls `MapSnapshotStore.shared.isResolved`, - /// which lazily initializes the process-lifetime singleton. Swift Testing - /// constructs a fresh suite instance per `@Test`, so resetting the singleton - /// here keeps `resolvedKeys`, `imageEntries`, and `failed` from leaking - /// between tests in this suite (and from earlier suites that touched it). - init() { - MapSnapshotStore.shared.clear() - } - - // MARK: - Timestamp Logic Tests - - @Test("First message always shows timestamp") - func firstMessageAlwaysShowsTimestamp() { - let messages = [ - createTestMessage(timestamp: 1000) - ] - - let flags = ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil) - #expect(flags.showTimestamp == true) - } - - @Test("Consecutive messages within 5 minutes don't show timestamp") - func consecutiveMessagesWithin5MinutesDontShowTimestamp() { - let baseTime: UInt32 = 1000 - let messages = [ - createTestMessage(timestamp: baseTime), - createTestMessage(timestamp: baseTime + 60), // 1 minute later - createTestMessage(timestamp: baseTime + 120), // 2 minutes later - createTestMessage(timestamp: baseTime + 180), // 3 minutes later - createTestMessage(timestamp: baseTime + 240) // 4 minutes later - ] - - // First message always shows timestamp - #expect(ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) - - // Messages 1-4 shouldn't show timestamp (within 5 min of previous) - #expect(ChatViewModel.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == false) - #expect(ChatViewModel.computeDisplayFlags(for: messages[2], previous: messages[1]).showTimestamp == false) - #expect(ChatViewModel.computeDisplayFlags(for: messages[3], previous: messages[2]).showTimestamp == false) - #expect(ChatViewModel.computeDisplayFlags(for: messages[4], previous: messages[3]).showTimestamp == false) - } - - @Test("Message after 5+ minute gap shows timestamp") - func messageAfter5MinuteGapShowsTimestamp() { - let baseTime: UInt32 = 1000 - let messages = [ - createTestMessage(timestamp: baseTime), - createTestMessage(timestamp: baseTime + 301) // 5 min 1 sec later - ] - - #expect(ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) - #expect(ChatViewModel.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == true) + /// `ChatViewModel.makeBuildInputs` calls `MapSnapshotStore.shared.isResolved`, + /// which lazily initializes the process-lifetime singleton. Swift Testing + /// constructs a fresh suite instance per `@Test`, so resetting the singleton + /// here keeps `resolvedKeys`, `imageEntries`, and `failed` from leaking + /// between tests in this suite (and from earlier suites that touched it). + init() { + MapSnapshotStore.shared.clear() + } + + // MARK: - Timestamp Logic Tests + + @Test + func `First message always shows timestamp`() { + let messages = [ + createTestMessage(timestamp: 1000) + ] + + let flags = ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil) + #expect(flags.showTimestamp == true) + } + + @Test + func `Consecutive messages within 5 minutes don't show timestamp`() { + let baseTime: UInt32 = 1000 + let messages = [ + createTestMessage(timestamp: baseTime), + createTestMessage(timestamp: baseTime + 60), // 1 minute later + createTestMessage(timestamp: baseTime + 120), // 2 minutes later + createTestMessage(timestamp: baseTime + 180), // 3 minutes later + createTestMessage(timestamp: baseTime + 240) // 4 minutes later + ] + + // First message always shows timestamp + #expect(ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) + + // Messages 1-4 shouldn't show timestamp (within 5 min of previous) + #expect(ChatViewModel.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == false) + #expect(ChatViewModel.computeDisplayFlags(for: messages[2], previous: messages[1]).showTimestamp == false) + #expect(ChatViewModel.computeDisplayFlags(for: messages[3], previous: messages[2]).showTimestamp == false) + #expect(ChatViewModel.computeDisplayFlags(for: messages[4], previous: messages[3]).showTimestamp == false) + } + + @Test + func `Message after 5+ minute gap shows timestamp`() { + let baseTime: UInt32 = 1000 + let messages = [ + createTestMessage(timestamp: baseTime), + createTestMessage(timestamp: baseTime + 301) // 5 min 1 sec later + ] + + #expect(ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) + #expect(ChatViewModel.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == true) + } + + @Test + func `Exactly 5 minute gap does not show timestamp`() { + let baseTime: UInt32 = 1000 + let messages = [ + createTestMessage(timestamp: baseTime), + createTestMessage(timestamp: baseTime + 300) // Exactly 5 minutes + ] + + #expect(ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) + #expect(ChatViewModel.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == false) // 300 is not > 300 + } + + @Test + func `Backlog block keys divider grouping on send time, not the shared drain anchor`() { + // Two backlog rows drained together share the anchor as their sortDate, but were + // sent ten minutes apart. Grouping must follow send time so the divider still + // appears inside the block; keying on the shared sortDate would collapse it. + let anchor = Date(timeIntervalSince1970: 5_000_000) + let earlier = createTestMessage(timestamp: 1000, sortDate: anchor) + let later = createTestMessage(timestamp: 1600, sortDate: anchor) // +10 min send time + #expect(ChatViewModel.computeDisplayFlags(for: later, previous: earlier).showTimestamp == true) + } + + @Test + @MainActor + func `Unread divider lands on the first unread row of the recent block`() { + // Block-at-reconnect layout: older already-read rows, then a recent unread block + // at the tail. The positional divider must land on the block's first row. This also + // guards against a regression to a first(where: { !$0.isRead }) scan — every row here + // has the default isRead == false, so such a scan would wrongly pick index 0. + let vm = ChatViewModel() + let readCount = 8 + let unreadCount = 12 + var messages: [MessageDTO] = [] + let readBase = Date(timeIntervalSince1970: 1_000_000) + for i in 0.. 300 - } - - @Test("Backlog block keys divider grouping on send time, not the shared drain anchor") - func backlogBlockGroupsBySendTime() { - // Two backlog rows drained together share the anchor as their sortDate, but were - // sent ten minutes apart. Grouping must follow send time so the divider still - // appears inside the block; keying on the shared sortDate would collapse it. - let anchor = Date(timeIntervalSince1970: 5_000_000) - let earlier = createTestMessage(timestamp: 1000, sortDate: anchor) - let later = createTestMessage(timestamp: 1600, sortDate: anchor) // +10 min send time - #expect(ChatViewModel.computeDisplayFlags(for: later, previous: earlier).showTimestamp == true) - } - - @Test("Unread divider lands on the first unread row of the recent block") - @MainActor - func dividerLandsOnFirstUnreadBlockRow() { - // Block-at-reconnect layout: older already-read rows, then a recent unread block - // at the tail. The positional divider must land on the block's first row. This also - // guards against a regression to a first(where: { !$0.isRead }) scan — every row here - // has the default isRead == false, so such a scan would wrongly pick index 0. - let vm = ChatViewModel() - let readCount = 8 - let unreadCount = 12 - var messages: [MessageDTO] = [] - let readBase = Date(timeIntervalSince1970: 1_000_000) - for i in 0..